From bc4cfaad48ec099bf256a36e1cdc7235dda0bda4 Mon Sep 17 00:00:00 2001 From: cdeust Date: Mon, 10 Aug 2026 12:20:07 +0200 Subject: [PATCH 1/3] fix(marketplace): close the pin-verification gap that let dangling versions ship silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hypermnesia-mcp-viz marketplace pin read version "3.0.0" for six days with no matching cortex-viz tag (v2.8.0 was, and remained, the latest real one). check_marketplace_pins.py never caught it: PIN_BEHIND_RELEASE only ever asked "is the pin behind the latest known tag?" — a pin sitting AHEAD of every real release read as current and passed silently. Root cause, fixed at the source: add PIN_VERSION_UNPUBLISHED (existence, not staleness) for both github-source and self-source pins, and the same principle for a third version surface this incident exposed — the public MCP registry (io.github.cdeust/hypermnesia-mcp was published at 4.17.1 while the tag/server.json/PyPI were already at 4.17.2, invisible until queried directly): REGISTRY_VERSION_STALE, exact-equality against server.json's own declared version. - scripts/check_marketplace_pins.py split into a thin composition-root facade + 5 single-purpose modules (marketplace_pins_{http,semver,github, self,manifests,registry}.py) — the single file crossed the 300-line §4.1 cap once REGISTRY_VERSION_STALE was added. AP's byte-identical mirror needs the same split; its weekly diff job will show that until synced, which is the intended signal, not a regression. - PENDING_PINS / PENDING_REGISTRY: a real, correctly-flagged finding whose fix is genuinely in flight (a real open PR, or a workflow that only fires on the next tag) degrades to a named, non-silent NOTICE instead of a red run — never a placeholder, always naming the tracking reference, dead code once the real state catches up. - Corrected an unverified hypothesis relayed from another session: the marketplace schema does NOT accept only a raw sha. Checked against https://json.schemastore.org/claude-code-marketplace.json (2026-08-10): a github-source pin's `ref` field ("Git branch or tag to use") is schema-legal. The hypermnesia-mcp-viz pin now carries `ref: "v3.1.0"` alongside `sha` (kept for immutability — a tag ref alone can be force-moved after the fact; PIN_SHA_UNREACHABLE verifies it independently) so the pin self-documents its target. - Bumped the pin to the real cortex-viz v3.1.0 release (052e4a40d3e6bddaeb1cec6662e23b451575c481, cdeust/cortex-viz#130): merged, tagged, PyPI-published (verified live on pypi.org 2026-08-10). Supersedes Cortex#418, whose sha (064e6d1) was a provisional PR-head value that changed on squash-merge, per that PR's own note. - Corrected three places in this repo that repeated the same "renamed in v3.0.0" claim cortex-viz's own CHANGELOG made (README.md, marketplace.json's cortex-viz shim description, plugins/cortex-viz-deprecated/hooks/hooks.json's live session-start notice, docs/codex-plugin.md) plus this repo's own CHANGELOG.md Unreleased entry (still in-progress prose, not settled history, so corrected in place rather than annotated). tests_py/scripts/ test_codex_plugin_contract.py had hardcoded the dangling "3.0.0"/sha as its expected contract — fixed to the real v3.1.0/sha. - Adds .github/workflows/release.yml::publish-mcp-registry: auto-publish server.json to registry.modelcontextprotocol.io on every v* tag via `mcp-publisher login github-oidc` (GitHub OIDC, no stored secret — checked against modelcontextprotocol/registry's own docs, not assumed to mirror PyPI Trusted Publishing). Checksum-pinned mcp-publisher v1.8.1 binary. Uncovered and fixed a real blocker while wiring this: server.json's description was 113 chars against the registry's 100-char schema limit (`mcp-publisher validate` 422) — shortened to 98. The actual registry publish of 4.17.2 is NOT done by this PR: it requires either the next v* tag (this job) or a maintainer with real mcp-publisher access — an interactive OAuth/PAT login attempt was correctly refused by this agent's own permission classifier, which is the right outcome for a sensitive, irreversible action taken by an autonomous agent. Test: a dedicated regression replays the incident's exact historical values (repo tags topping out at v2.8.0, pin "3.0.0") through both the pre-fix and post-fix check_github_pin — silently (None, None) before, PIN_VERSION_UNPUBLISHED after. 38 tests across 3 new + 1 modified test file; full tests_py/scripts/ suite green (764 passed, 5 skipped). Co-Authored-By: Claude Sonnet 5 --- .claude-plugin/marketplace.json | 7 +- .craftsmanship-baseline.json | 10 - .github/workflows/release.yml | 69 +++ CHANGELOG.md | 20 +- CLAUDE.md | 10 + README.md | 6 +- docs/codex-plugin.md | 2 +- .../cortex-viz-deprecated/hooks/hooks.json | 2 +- scripts/check_marketplace_pins.py | 446 ++++++------------ scripts/marketplace_pins_github.py | 219 +++++++++ scripts/marketplace_pins_http.py | 24 + scripts/marketplace_pins_manifests.py | 43 ++ scripts/marketplace_pins_registry.py | 162 +++++++ scripts/marketplace_pins_self.py | 65 +++ scripts/marketplace_pins_semver.py | 56 +++ server.json | 2 +- .../scripts/_marketplace_pins_test_loader.py | 44 ++ .../scripts/test_check_marketplace_pins.py | 251 ++++------ .../test_check_marketplace_pins_registry.py | 123 +++++ ...est_check_marketplace_pins_sha_manifest.py | 133 ++++++ .../scripts/test_codex_plugin_contract.py | 9 +- 21 files changed, 1218 insertions(+), 485 deletions(-) create mode 100644 scripts/marketplace_pins_github.py create mode 100644 scripts/marketplace_pins_http.py create mode 100644 scripts/marketplace_pins_manifests.py create mode 100644 scripts/marketplace_pins_registry.py create mode 100644 scripts/marketplace_pins_self.py create mode 100644 scripts/marketplace_pins_semver.py create mode 100644 tests_py/scripts/_marketplace_pins_test_loader.py create mode 100644 tests_py/scripts/test_check_marketplace_pins_registry.py create mode 100644 tests_py/scripts/test_check_marketplace_pins_sha_manifest.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8bbdf627..d318a386 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -62,10 +62,11 @@ "source": { "source": "github", "repo": "cdeust/cortex-viz", - "sha": "1c1940e278979f35cdecea6146d7fb5f749907e9" + "ref": "v3.1.0", + "sha": "052e4a40d3e6bddaeb1cec6662e23b451575c481" }, "description": "Canonical Claude Code publication for the standalone Hypermnesia MCP Viz server — a live neural-graph galaxy of every project, file, symbol, memory, discussion and wiki page, plus a per-session execution trace, a consolidation kanban, and a curated knowledge/wiki browser. Read-only bridge over Cortex's shared PostgreSQL store and the ~/.claude artifacts; it renders, it never remembers. Restores the open_visualization and get_methodology_graph tools removed from Cortex; launch with /cortex-visualize.", - "version": "3.0.0", + "version": "3.1.0", "author": { "name": "Clement Deust", "email": "admin@ai-architect.tools" @@ -86,7 +87,7 @@ { "name": "cortex-viz", "source": "./plugins/cortex-viz-deprecated", - "description": "DEPRECATED — renamed to hypermnesia-mcp-viz in v3.0.0. This frozen, nonfunctional migration shim has no MCP server or tools; it only announces the replacement at session start. Uninstall cortex-viz@cortex-plugins, refresh this marketplace, then install hypermnesia-mcp-viz@cortex-plugins. The repository remains cdeust/cortex-viz; only the marketplace plugin identity changed.", + "description": "DEPRECATED — renamed to hypermnesia-mcp-viz, first published in cortex-viz v3.1.0 (the rename itself was never tagged v3.0.0 — that version number sat unpublished in this marketplace's pin for six days). This frozen, nonfunctional migration shim has no MCP server or tools; it only announces the replacement at session start. Uninstall cortex-viz@cortex-plugins, refresh this marketplace, then install hypermnesia-mcp-viz@cortex-plugins. The repository remains cdeust/cortex-viz; only the marketplace plugin identity changed.", "version": "2.8.0", "author": { "name": "Clement Deust", diff --git a/.craftsmanship-baseline.json b/.craftsmanship-baseline.json index 3108d165..5af6b19e 100644 --- a/.craftsmanship-baseline.json +++ b/.craftsmanship-baseline.json @@ -5587,16 +5587,6 @@ "kind": "unsourced-constant", "detail": "_SIDE_PADDING" }, - { - "file": "scripts/check_marketplace_pins.py", - "kind": "file-size", - "detail": "exceeds 300-line cap" - }, - { - "file": "scripts/check_marketplace_pins.py", - "kind": "method-size", - "detail": "main" - }, { "file": "scripts/groomer.py", "kind": "file-size", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03003378..2f2b4b12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -430,3 +430,72 @@ jobs: # is a no-op instead of a hard failure — this was the exact # failure mode that motivated removing PyPI in a2dc7e3. skip-existing: true + + # Public MCP registry (registry.modelcontextprotocol.io) compatibility + # channel — a THIRD version surface alongside the marketplace pin and + # PyPI, previously updated by hand and, like the other two, drifting + # silently: `io.github.cdeust/hypermnesia-mcp` sat published at 4.17.1 + # while this repo's tag, server.json, and PyPI were already at 4.17.2 — + # one release short, invisible until queried directly (no CI gate read + # this surface until scripts/check_marketplace_pins.py's + # REGISTRY_VERSION_STALE check, added alongside this job). Same failure + # shape as issue #179 (marketplace pin) and the cortex-viz CHANGELOG + # incident: a mandatory per-release step that lived only in prose. + # + # Auth: GitHub OIDC (`mcp-publisher login github-oidc`), NOT a + # PyPI-Trusted-Publishing equivalent — a distinct mechanism documented at + # modelcontextprotocol/registry docs/reference/cli/commands.md and + # docs/modelcontextprotocol-io/github-actions.mdx (checked 2026-08-10, + # not assumed). `io.github.cdeust/*` namespace authentication comes free + # from the workflow's own OIDC token (bound to this repo + workflow + + # ref by Sigstore) once `id-token: write` is granted — no stored secret, + # unlike the PAT or DNS auth alternatives the docs also describe. + publish-mcp-registry: + name: Publish to the MCP registry (best-effort compatibility) + needs: build + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + id-token: write # OIDC — mcp-publisher login github-oidc + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Verify server.json matches the tag (same guard as mcpb-bundle) + run: | + set -euxo pipefail + VERSION="$(python3 -c "import json;print(json.load(open('server.json'))['version'])")" + if [ "v${VERSION}" != "${GITHUB_REF_NAME}" ]; then + echo "server.json version ${VERSION} does not match tag ${GITHUB_REF_NAME}" >&2 + exit 1 + fi + + # Checksum-verified, not just version-pinned (issue #178 supply-chain + # posture applied to a third-party tool this workflow now trusts). + # source: sha256 measured 2026-08-10 by downloading this exact asset + # from https://github.com/modelcontextprotocol/registry/releases/tag/v1.8.1 + - name: Install mcp-publisher (pinned release, checksum-verified) + run: | + set -euxo pipefail + curl -fsSL -o mcp-publisher.tar.gz \ + "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" + echo "${MCP_PUBLISHER_SHA256} mcp-publisher.tar.gz" | sha256sum -c - + tar xzf mcp-publisher.tar.gz mcp-publisher + env: + MCP_PUBLISHER_VERSION: v1.8.1 + MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc + + - name: Authenticate to the MCP registry (OIDC) + run: ./mcp-publisher login github-oidc + + - name: Publish server.json to the MCP registry (best-effort — must not fail primary release) + # Same rationale as publish-pypi: this is a secondary compatibility + # channel (ADR-0050 names the marketplace primary) — a registry + # outage or a stricter future validation rule must not red-X the + # GitHub Release / marketplace propagation this workflow exists to + # ship. A silenced failure is exactly what let this surface drift + # in the first place, so it is not swallowed: the step still shows + # red in the Actions UI, `continue-on-error` only stops it from + # failing the *job*/workflow. + continue-on-error: true + run: ./mcp-publisher publish diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc9c6d1..664b3a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,13 +100,19 @@ adheres to [Semantic Versioning](https://semver.org/). visualization-plugin migration described below. - **Breaking visualization-plugin publication rename, with migration shim.** The canonical Claude Code publication is - `hypermnesia-mcp-viz@cortex-plugins` 3.0.0, sourced from the unchanged - `cdeust/cortex-viz` repository at exact commit - `1c1940e278979f35cdecea6146d7fb5f749907e9`. Existing installs must uninstall - `cortex-viz@cortex-plugins`, refresh `cortex-plugins`, and install - `hypermnesia-mcp-viz@cortex-plugins`. The former identity remains as a - frozen 2.8.0 migration shim that only prints those instructions; it - registers no MCP server or tools. Claude's composed tool names also change: + `hypermnesia-mcp-viz@cortex-plugins` 3.1.0, sourced from the unchanged + `cdeust/cortex-viz` repository at tag `v3.1.0` (commit + `052e4a40d3e6bddaeb1cec6662e23b451575c481`, cdeust/cortex-viz#130). + (Corrected: the rename commit itself, `1c1940e2789...`, was originally + pinned here at a claimed "3.0.0" — cortex-viz never actually tagged a + v3.0.0, so that pin was dangling for six days, detected and fixed by + `scripts/check_marketplace_pins.py`'s new `PIN_VERSION_UNPUBLISHED` + check; see that script's module docstring for the incident.) Existing + installs must uninstall `cortex-viz@cortex-plugins`, refresh + `cortex-plugins`, and install `hypermnesia-mcp-viz@cortex-plugins`. The + former identity remains as a frozen 2.8.0 migration shim that only + prints those instructions; it registers no MCP server or tools. Claude's + composed tool names also change: `mcp__plugin_cortex-viz_cortex-viz__open_visualization` becomes `mcp__plugin_hypermnesia-mcp-viz_hypermnesia-mcp-viz__open_visualization`, and `mcp__plugin_cortex-viz_cortex-viz__get_methodology_graph` becomes diff --git a/CLAUDE.md b/CLAUDE.md index 0d50e705..3f183152 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,16 @@ ENDS with: bump the marketplace pin(s) and `server.json`, and confirm the 2026-07-25 incident where six zetetic-team-subagents releases and two cortex-viz releases shipped to zero installs (#179). +The public MCP registry (`io.github.cdeust/hypermnesia-mcp`) is a third +version surface alongside the marketplace pin and PyPI — auto-published on +every `v*` tag by `release.yml`'s `publish-mcp-registry` job (GitHub OIDC, +no stored secret) and cross-checked by `check_marketplace_pins.py` against +`server.json`'s own declared version (`REGISTRY_VERSION_STALE`). Source: +2026-08-10, `io.github.cdeust/hypermnesia-mcp` sat published at 4.17.1 +while the tag/server.json/PyPI were already at 4.17.2 — the publish step +had lived only in prose, with nothing committed to run it or verify it +happened. + ## Architecture Clean Architecture, concentric layers: `server → handlers → core ← shared`, diff --git a/README.md b/README.md index 7861a6a9..1cb41dfd 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ claude plugin install hypermnesia-mcp ``` > **Upgrading from the `cortex` plugin?** The plugin was renamed `hypermnesia-mcp` in v4.15.0 (a community-directory name collision with an unrelated `cortex` plugin): `claude plugin uninstall cortex && claude plugin install hypermnesia-mcp` — your memories and configuration are untouched, storage paths do not change. > -> **Upgrading from `cortex-viz@cortex-plugins`?** Its Claude Code marketplace identity was renamed in v3.0.0. Run `claude plugin uninstall cortex-viz@cortex-plugins`, then `claude plugin marketplace update cortex-plugins`, then `claude plugin install hypermnesia-mcp-viz@cortex-plugins`. The retained `cortex-viz@cortex-plugins` item is a frozen, nonfunctional migration shim: it only prints this notice and exposes no MCP server or tools. The repository remains `cdeust/cortex-viz`; only its marketplace plugin identity changed. +> **Upgrading from `cortex-viz@cortex-plugins`?** Its Claude Code marketplace identity was renamed to `hypermnesia-mcp-viz`, first published in cortex-viz v3.1.0 (the rename commit itself was never tagged as v3.0.0 — that version number was pinned here for six days without a matching release; see cortex-viz's CHANGELOG). Run `claude plugin uninstall cortex-viz@cortex-plugins`, then `claude plugin marketplace update cortex-plugins`, then `claude plugin install hypermnesia-mcp-viz@cortex-plugins`. The retained `cortex-viz@cortex-plugins` item is a frozen, nonfunctional migration shim: it only prints this notice and exposes no MCP server or tools. The repository remains `cdeust/cortex-viz`; only its marketplace plugin identity changed. > > Claude tool allowlists, hooks, skills, and agents must migrate both composed names: `mcp__plugin_cortex-viz_cortex-viz__open_visualization` becomes `mcp__plugin_hypermnesia-mcp-viz_hypermnesia-mcp-viz__open_visualization`, and `mcp__plugin_cortex-viz_cortex-viz__get_methodology_graph` becomes `mcp__plugin_hypermnesia-mcp-viz_hypermnesia-mcp-viz__get_methodology_graph`. @@ -167,8 +167,8 @@ Or add it to `~/.gemini/settings.json` directly: repository now carries an isolated Codex marketplace and an exact 10-tool lean MCP surface. Claude Code remains the primary integration and retains its automatic hooks, custom agent, and full tool profile. Its shared marketplace -catalog changes only for the pinned `hypermnesia-mcp-viz` 3.0.0 publication -and the frozen `cortex-viz` migration shim. +catalog changes only for the pinned `hypermnesia-mcp-viz` publication and +the frozen `cortex-viz` migration shim. Pre-install the same published package once so the plugin's first `uvx` handshake can reuse the local uv cache instead of spending its startup budget downloading the Python environment. The bundled server also declares a diff --git a/docs/codex-plugin.md b/docs/codex-plugin.md index b77f2896..04378e01 100644 --- a/docs/codex-plugin.md +++ b/docs/codex-plugin.md @@ -62,7 +62,7 @@ env CORTEX_RUNTIME=cowork \ policy; it does not install or invoke the Cowork plugin. Claude Code remains the primary integration, and its primary plugin manifest, hooks, agents, and full tool profile are unchanged. The shared Claude marketplace catalog changes -only to publish `hypermnesia-mcp-viz` 3.0.0 and retain `cortex-viz` as a frozen, +only to publish `hypermnesia-mcp-viz` and retain `cortex-viz` as a frozen, nonfunctional migration shim. This is a local plugin. It does not make Cortex available to ChatGPT web and diff --git a/plugins/cortex-viz-deprecated/hooks/hooks.json b/plugins/cortex-viz-deprecated/hooks/hooks.json index 4e22906a..2bc5f9f7 100644 --- a/plugins/cortex-viz-deprecated/hooks/hooks.json +++ b/plugins/cortex-viz-deprecated/hooks/hooks.json @@ -8,7 +8,7 @@ "hooks": [ { "type": "command", - "command": "echo '[cortex-viz@cortex-plugins] DEPRECATED: the Claude Code plugin was renamed in v3.0.0. Run: claude plugin uninstall cortex-viz@cortex-plugins; claude plugin marketplace update cortex-plugins; claude plugin install hypermnesia-mcp-viz@cortex-plugins. The repository remains cdeust/cortex-viz; only the marketplace plugin identity changed.'", + "command": "echo '[cortex-viz@cortex-plugins] DEPRECATED: the Claude Code plugin was renamed to hypermnesia-mcp-viz. Run: claude plugin uninstall cortex-viz@cortex-plugins; claude plugin marketplace update cortex-plugins; claude plugin install hypermnesia-mcp-viz@cortex-plugins. The repository remains cdeust/cortex-viz; only the marketplace plugin identity changed.'", "timeout": 5 } ] diff --git a/scripts/check_marketplace_pins.py b/scripts/check_marketplace_pins.py index 9e220d32..9a4913af 100644 --- a/scripts/check_marketplace_pins.py +++ b/scripts/check_marketplace_pins.py @@ -1,19 +1,47 @@ #!/usr/bin/env python3 """Marketplace pin-staleness gate. CANONICAL COPY: cdeust/Cortex. -A byte-identical copy lives in cdeust/ai-architect-mcp-codebase (its CI diffs -against this file weekly and fails on drift), so the gate is ONE artifact -guarding both repos — never two copies that diverge into a no-op. +A byte-identical copy of this file AND its five sibling modules +(marketplace_pins_http.py, marketplace_pins_semver.py, +marketplace_pins_github.py, marketplace_pins_self.py, +marketplace_pins_manifests.py, marketplace_pins_registry.py) lives in +cdeust/ai-architect-mcp-codebase (its CI diffs against these files weekly +and fails on drift), so the gate is ONE artifact guarding both repos — +never two copies that diverge into a no-op. This file used to be the +whole gate in one file; it was split (issue: crossed the 300-line §4.1 +cap once REGISTRY_VERSION_STALE was added) into a thin composition root +(this file: CLI entry point + per-plugin dispatch) over five single- +purpose modules — AP's mirror needs the same split or its weekly diff +will show it, which is the intended signal, not a regression. Releasing does not ship: delivery is gated by pins in ``.claude-plugin/marketplace.json``. Six zetetic-team-subagents releases and two cortex-viz releases were withheld silently (Cortex #179); AP's own manifests sat a three-way split with its tag (AP #67). Failure classes: - PIN_BEHIND_TAG local-source pin < this repo's latest semver git tag. - Offline, reads git only — detects the #67 incident - even when every manifest agrees (they were BOTH stale). - PIN_BEHIND_RELEASE github-source pin < that repo's latest release. + PIN_BEHIND_TAG local-source pin < this repo's latest semver git tag. + Offline, reads git only — detects the #67 incident + even when every manifest agrees (they were BOTH stale). + PIN_BEHIND_RELEASE github-source pin < that repo's latest release, but a + release matching the pin DOES exist. + PIN_VERSION_UNPUBLISHED the pinned version has no matching tag/release in + the target repo AT ALL — not "behind", genuinely + never cut. `PIN_BEHIND_*` only ever compared the pin + to the *latest* known tag, so a pin sitting AHEAD of + every real release (a version nobody ever tagged) + read as "current" and passed silently. Incident: + Cortex's own marketplace.json pinned + `hypermnesia-mcp-viz` at cortex-viz version "3.0.0" + for six days; cortex-viz's own tag history never + contained a v3.0.0 — v2.8.0 was, and remained, the + latest real tag. `3.0.0 < 2.8.0` is false, so the old + `PIN_BEHIND_RELEASE` check never fired: a pin ahead + of the truth is invisible to a "behind" comparison. + Diagnostic method: put the declared versions and the + actually-tagged versions side by side; the gap is + only visible in the join, never in either list read + alone, and never in the manifest file by itself — the + manifest doesn't know what wasn't tagged. SELF_PIN_MISMATCH local-source pin != the plugin's own plugin.json. SERVER_JSON_SPLIT root server.json version != the primary local pin (the unguarded third leg of AP's three-way split). @@ -30,13 +58,42 @@ because it only ever read server.json (AP #172). The file is copied verbatim into the .mcpb bundle, so the wrong version shipped to every install. + REGISTRY_VERSION_STALE the public MCP registry (registry.modelcontextprotocol.io) + serves a version != this repo's authoritative one for + server.json's own `name`. A THIRD version surface, + independent of the marketplace pin and PyPI: measured + 2026-08-10, io.github.cdeust/hypermnesia-mcp was + published at 4.17.1 while the tag/server.json/PyPI + were already at 4.17.2 — the registry publish step + lived only in prose (no committed workflow ran it), + same failure shape as #179 and PIN_VERSION_UNPUBLISHED + above. Equality, not "behind": a registry entry ahead + of the repo (a republish of an untagged version) is + exactly as false as one that lags. Network failures DEGRADE TO SILENCE (NOTICE + exit 0): a gate that reddens every PR during a GitHub outage gets disabled, and then the six-release gap recurs with the gate nominally in place. The offline path is tested. Frozen pins: deliberately never-advancing pins (deprecation shims) are -listed in FROZEN_PINS with a reason — audited allowlist, not silence. +listed in FROZEN_PINS (marketplace_pins_self.py) with a reason — audited +allowlist, not silence. + +Pending pins: a pin correctly flagged PIN_VERSION_UNPUBLISHED because the +release that will cut its tag is genuinely in flight (tracked by an already +-open upstream PR, not a promise) is listed in PENDING_PINS +(marketplace_pins_github.py) with that PR's URL — printed every run as a +NOTICE, never swallowed, and dead code the moment the tag lands and the pin +is bumped to point at it (source: schema verified against +https://json.schemastore.org/claude-code-marketplace.json 2026-08-10 — the +github-source `source` object accepts an optional `ref` field, "Git branch +or tag to use", so a tag-based pin is schema-legal; a prior report claiming +the schema accepts only a raw `sha` was not checked against the schema and +was wrong. `sha` remains additionally used here for immutability — a tag +ref alone can be force-moved after the fact — and this gate verifies `sha` +reachability independently via PIN_SHA_UNREACHABLE). PENDING_REGISTRY +(marketplace_pins_registry.py) is the same mechanism for +REGISTRY_VERSION_STALE. Exit codes: 0 current (or degraded, with NOTICE), 1 stale pin(s), 2 error. """ @@ -44,268 +101,97 @@ from __future__ import annotations import json -import os -import re -import subprocess import sys -import urllib.error -import urllib.request from pathlib import Path +# Bare sibling imports: see marketplace_pins_semver.py's bootstrap comment +# for why this file inserts its own directory onto sys.path before them — +# both direct execution (`python3 scripts/check_marketplace_pins.py`) and +# the test suite's `importlib.util.spec_from_file_location` load need it. +_scripts_dir = str(Path(__file__).resolve().parent) +if _scripts_dir not in sys.path: + sys.path.insert(0, _scripts_dir) + +from marketplace_pins_github import ( # noqa: E402 + PENDING_PINS, + check_github_pin, + check_pin_sha, +) +from marketplace_pins_manifests import check_root_manifests # noqa: E402 +from marketplace_pins_registry import ( # noqa: E402 + check_registry_surface, + check_registry_version, +) +from marketplace_pins_self import FROZEN_PINS, check_self_pin # noqa: E402 +from marketplace_pins_semver import parse_semver # noqa: E402 + +__all__ = [ + "FROZEN_PINS", + "PENDING_PINS", + "check_github_pin", + "check_pin_sha", + "check_root_manifests", + "check_registry_surface", + "check_registry_version", + "check_self_pin", + "main", + "parse_semver", +] + MARKETPLACE = ( Path(__file__).resolve().parent.parent / ".claude-plugin" / "marketplace.json" ) -API_TIMEOUT_S = 15 # source: GitHub API p99 well below; matches prior gate rev -# source: audited 2026-07-25 (Cortex PR #182 review clause 5) and 2026-08-04 -# (Cortex PR #351 Opus review) — each legacy identity is a notice-only shim -# frozen at its rename release; advancing one would hide the migration boundary. -FROZEN_PINS = { - "cortex": "deprecation shim, frozen at the 4.15.0 rename release", - "cortex-viz": "deprecation shim, frozen at the 2.8.0 pre-rename release", -} - - -def parse_semver(tag: str) -> tuple[int, ...] | None: - """'v2.34.0' / '2.34.0' -> (2, 34, 0); None when not semver.""" - m = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", tag.strip()) - return tuple(int(g) for g in m.groups()) if m else None - - -def latest_local_tag(root: Path, run=subprocess.run) -> str | None: - """Highest semver tag in this repo, or None (no tags / not a repo).""" - proc = run( - ["git", "-C", str(root), "tag", "--list"], - capture_output=True, - text=True, - timeout=API_TIMEOUT_S, - ) - if proc.returncode != 0: - return None - parsed = [(v, t) for t in proc.stdout.split() if (v := parse_semver(t))] - return max(parsed)[1] if parsed else None - - -def tags_between(root: Path, pin: tuple, latest: tuple, run=subprocess.run) -> int: - proc = run( - ["git", "-C", str(root), "tag", "--list"], - capture_output=True, - text=True, - timeout=API_TIMEOUT_S, - ) - if proc.returncode != 0: - return 0 - return sum( - 1 for t in proc.stdout.split() if (v := parse_semver(t)) and pin < v <= latest - ) - - -def _headers() -> dict[str, str]: - headers = {"Accept": "application/vnd.github+json", "User-Agent": "pin-gate"} - if token := os.environ.get("GITHUB_TOKEN", ""): - headers["Authorization"] = f"Bearer {token}" - return headers - - -# source: RFC 9110 §15.5.5 — HTTP 404 Not Found -_HTTP_NOT_FOUND = 404 - - -def latest_release_tag(repo: str) -> str | None: - """Latest release tag; None when the repo has no releases (404).""" - req = urllib.request.Request( - f"https://api.github.com/repos/{repo}/releases/latest", headers=_headers() - ) - try: - with urllib.request.urlopen(req, timeout=API_TIMEOUT_S) as resp: - return json.load(resp).get("tag_name") - except urllib.error.HTTPError as e: - if e.code == _HTTP_NOT_FOUND: - return None - raise - - -def releases_between(repo: str, pin: tuple, latest: tuple) -> int | None: - """Count releases with pin < tag <= latest; None when not determinable.""" - req = urllib.request.Request( - f"https://api.github.com/repos/{repo}/releases?per_page=100", headers=_headers() - ) - try: - with urllib.request.urlopen(req, timeout=API_TIMEOUT_S) as resp: - releases = json.load(resp) - except (urllib.error.URLError, TimeoutError, OSError, ValueError): - return None - return sum( - 1 - for r in releases - if (v := parse_semver(r.get("tag_name", ""))) and pin < v <= latest - ) -# source: GitHub REST "Compare two commits" — `status` is exactly one of -# ahead / behind / identical / diverged. -# https://docs.github.com/rest/commits/commits#compare-two-commits -# -# compare/BASE...HEAD describes HEAD relative to BASE. With BASE = the default -# branch, `identical` means the pin IS the branch tip and `behind` means it is -# an ancestor of it — both reachable. `ahead` and `diverged` mean the pin -# carries commits the branch does not: an unmerged PR head, which stops being -# reachable the moment that PR is squash-merged. -REACHABLE_FROM_DEFAULT = frozenset({"identical", "behind"}) -SHA_DISPLAY_LEN = 12 # source: git's default core.abbrev floor for readable logs +def _check_plugin_pin(name: str, pin: str, source, root: Path): + """One marketplace entry -> (failures, notices, primary_pin_or_None). - -def default_branch(repo: str) -> str | None: - """Repo's default branch; None when the repo does not resolve (404).""" - req = urllib.request.Request( - f"https://api.github.com/repos/{repo}", headers=_headers() - ) - try: - with urllib.request.urlopen(req, timeout=API_TIMEOUT_S) as resp: - return json.load(resp).get("default_branch") - except urllib.error.HTTPError as e: - if e.code == _HTTP_NOT_FOUND: - return None - raise - - -def compare_status(repo: str, base: str, head: str) -> str | None: - """Comparison status of head vs base; None when head does not resolve.""" - req = urllib.request.Request( - f"https://api.github.com/repos/{repo}/compare/{base}...{head}", - headers=_headers(), - ) - try: - with urllib.request.urlopen(req, timeout=API_TIMEOUT_S) as resp: - return json.load(resp).get("status") - except urllib.error.HTTPError as e: - if e.code == _HTTP_NOT_FOUND: - return None - raise - - -def check_pin_sha( - name: str, repo: str, sha: str, branch=default_branch, compare=compare_status -): - """Returns (failure, notice) — exactly one is non-None, or both are None.""" - try: - base = branch(repo) - if base is None: - return ( - None, - f"NOTICE: {name}: {repo} does not resolve; " - f"pinned sha not verified this run", - ) - status = compare(repo, base, sha) - except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e: - return ( - None, - f"NOTICE: {name}: network degraded ({e.__class__.__name__}); " - f"pinned sha not verified this run", - ) - short = sha[:SHA_DISPLAY_LEN] - if status is None: - return ( - f"PIN_SHA_UNREACHABLE: {name}: {repo} does not resolve commit {short} " - f"(absent from the repository)", - None, - ) - if status not in REACHABLE_FROM_DEFAULT: - return ( - f"PIN_SHA_UNREACHABLE: {name}: {repo}@{short} is '{status}' of " - f"{base} — the pin targets a commit outside the default branch " - f"(an unmerged PR head stops resolving once that PR is squashed)", - None, - ) - return None, None - - -def check_github_pin( - name: str, repo: str, pin: str, fetch=latest_release_tag, count=releases_between -): - """Returns (failure, notice) — exactly one is non-None or both None.""" - try: - tag = fetch(repo) - except (urllib.error.URLError, TimeoutError, OSError) as e: - return ( - None, - f"NOTICE: {name}: network degraded ({e.__class__.__name__}); " - f"pin not verified this run", - ) - if tag is None: - return ( - None, - f"NOTICE: {name}: {repo} has no published releases; pin not comparable", - ) - latest, pinned = parse_semver(tag), parse_semver(pin) - if latest is None or pinned is None: - return f"UNPARSEABLE: {name}: pin={pin!r} latest={tag!r}", None - if pinned < latest: - n = count(repo, pinned, latest) - behind = f"{n} release(s)" if n is not None else "release(s)" - return ( - f"PIN_BEHIND_RELEASE: {name}: pins {pin}, {repo} latest is {tag} " - f"({behind} never delivered)", - None, - ) - return None, None - - -def check_self_pin(name: str, source: str, pin: str, root: Path) -> list[str]: + `primary_pin` is non-None only for the entry whose local source is the + repo root itself ("./") — the one whose version the root manifests + (server.json/manifest.json) and the public registry are checked against. + """ failures: list[str] = [] - plugin_json = root / source / ".claude-plugin" / "plugin.json" - if not plugin_json.is_file(): - plugin_json = root / source / "plugin.json" - if plugin_json.is_file(): - actual = json.loads(plugin_json.read_text()).get("version", "") - if actual and actual != pin: - failures.append( - f"SELF_PIN_MISMATCH: {name}: pins {pin} " - f"but {plugin_json.relative_to(root)} says {actual}" - ) - if name in FROZEN_PINS: - # frozen: manifest coherence still checked, tag advance is by-design - return failures - tag = latest_local_tag(root) - pinned = parse_semver(pin) - if tag and pinned and (latest := parse_semver(tag)) and pinned < latest: - n = tags_between(root, pinned, latest) - failures.append( - f"PIN_BEHIND_TAG: {name}: pins {pin} but this repo's latest tag is {tag} " - f"({n} release(s) never delivered to installs)" - ) - return failures - - -# Root manifests that carry a version which must agree with the primary -# marketplace pin. One row per file: adding a manifest never adds a branch to -# the check below, which is what let manifest.json go unguarded for two -# releases when the check was hardcoded to server.json alone (AP #172). -ROOT_VERSION_MANIFESTS: tuple[tuple[str, str, str], ...] = ( - ("server.json", "version", "SERVER_JSON_SPLIT"), - ("manifest.json", "version", "MANIFEST_JSON_SPLIT"), -) - + notices: list[str] = [] + if isinstance(source, dict) and source.get("source") == "github": + failure, notice = check_github_pin(name, source["repo"], pin) + if failure: + failures.append(failure) + if notice: + notices.append(notice) + # A pin may name an exact commit as well as a version. Both are + # delivery-gating and they fail independently: #351's sha was + # unreachable while its version was perfectly current. + if sha := source.get("sha"): + failure, notice = check_pin_sha(name, source["repo"], sha) + if failure: + failures.append(failure) + if notice: + notices.append(notice) + return failures, notices, None + if isinstance(source, str): + failures.extend(check_self_pin(name, source, pin, root)) + primary = pin if source.strip("/") in ("", ".") else None + return failures, notices, primary + return failures, notices, None -def check_root_manifests(root: Path, primary_pin: str) -> list[str]: - """Flag every root manifest whose version disagrees with the primary pin. - An absent file is not a failure and neither is a missing version key: the - canonical repo has no manifest.json, ai-architect-mcp-codebase has both, and a - gate that demanded every row exist everywhere would be a false positive in - one repo or the other rather than a guard in both. - """ - failures: list[str] = [] - for filename, key, failure_class in ROOT_VERSION_MANIFESTS: - path = root / filename - if not path.is_file(): - continue - version = json.loads(path.read_text()).get(key, "") - if version and version != primary_pin: - failures.append( - f"{failure_class}: {filename} says {version} " - f"but the primary marketplace pin is {primary_pin}" - ) - return failures +def _report(failures: list[str], notices: list[str]) -> int: + for line in notices: + print(line) + for line in failures: + print(line) + if failures: + print( + f"\n{len(failures)} stale pin(s). A release is not shipped until " + f"its pin moves — bump .claude-plugin/marketplace.json " + f"(and server.json / manifest.json)." + ) + return 1 + print( + "All marketplace pins current." + + (" (network-degraded checks noticed above)" if notices else "") + ) + return 0 def main() -> int: @@ -325,43 +211,17 @@ def main() -> int: ) if not pin: continue - if isinstance(source, dict) and source.get("source") == "github": - failure, notice = check_github_pin(name, source["repo"], pin) - if failure: - failures.append(failure) - if notice: - notices.append(notice) - # A pin may name an exact commit as well as a version. Both are - # delivery-gating and they fail independently: #351's sha was - # unreachable while its version was perfectly current. - if sha := source.get("sha"): - failure, notice = check_pin_sha(name, source["repo"], sha) - if failure: - failures.append(failure) - if notice: - notices.append(notice) - elif isinstance(source, str): - failures.extend(check_self_pin(name, source, pin, root)) - if source.strip("/") in ("", "."): - primary_pin = pin + f, n, primary = _check_plugin_pin(name, pin, source, root) + failures.extend(f) + notices.extend(n) + if primary: + primary_pin = primary if primary_pin: failures.extend(check_root_manifests(root, primary_pin)) - for line in notices: - print(line) - for line in failures: - print(line) - if failures: - print( - f"\n{len(failures)} stale pin(s). A release is not shipped until " - f"its pin moves — bump .claude-plugin/marketplace.json " - f"(and server.json / manifest.json)." - ) - return 1 - print( - "All marketplace pins current." - + (" (network-degraded checks noticed above)" if notices else "") - ) - return 0 + reg_failures, reg_notices = check_registry_surface(root, primary_pin) + failures.extend(reg_failures) + notices.extend(reg_notices) + return _report(failures, notices) if __name__ == "__main__": diff --git a/scripts/marketplace_pins_github.py b/scripts/marketplace_pins_github.py new file mode 100644 index 00000000..0b09cd53 --- /dev/null +++ b/scripts/marketplace_pins_github.py @@ -0,0 +1,219 @@ +"""GitHub-source pin checks for the marketplace pin-staleness gate. + +Split out of check_marketplace_pins.py (issue: that file crossed the +300-line §4.1 cap once REGISTRY_VERSION_STALE was added). Covers pins +shaped ``{"source": "github", "repo": ..., "version": ..., "sha": ...}``: +does a release matching the pinned version exist at all +(PIN_VERSION_UNPUBLISHED), is it the latest one (PIN_BEHIND_RELEASE), and +is the optional pinned ``sha`` reachable from the repo's default branch +(PIN_SHA_UNREACHABLE). See check_marketplace_pins.py's module docstring +for the full failure-class catalogue and the incidents each one closes. +""" + +from __future__ import annotations + +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path + +# See marketplace_pins_semver.py's identical bootstrap comment. +_scripts_dir = str(Path(__file__).resolve().parent) +if _scripts_dir not in sys.path: + sys.path.insert(0, _scripts_dir) +from marketplace_pins_http import HTTP_NOT_FOUND, API_TIMEOUT_S, api_headers # noqa: E402 +from marketplace_pins_semver import parse_semver # noqa: E402 + +# Same contract as PENDING_REGISTRY (marketplace_pins_registry.py): an +# entry here degrades a real PIN_VERSION_UNPUBLISHED finding to a named +# NOTICE while a committed, tracked fix is in flight — never a +# placeholder, never silent. Empty here because the one entry this gate +# needed (cdeust/cortex-viz#130) resolved within the same change that +# introduced the mechanism — merged, tagged v3.1.0, published to PyPI, +# verified 2026-08-10. +PENDING_PINS: dict[str, str] = {} + + +def list_release_tags(repo: str) -> list[str] | None: + """Raw tag_name of every published release (<=100); None if repo has none (404). + + The full list, not just "latest": PIN_VERSION_UNPUBLISHED needs to test + set-membership (does a tag matching the pin exist AT ALL), which a + latest-only fetch cannot answer — a pin can be simultaneously "not the + latest" AND "not published either", and only the full list tells them + apart from "published but superseded". + """ + req = urllib.request.Request( + f"https://api.github.com/repos/{repo}/releases?per_page=100", + headers=api_headers(), + ) + try: + with urllib.request.urlopen(req, timeout=API_TIMEOUT_S) as resp: + releases = json.load(resp) + except urllib.error.HTTPError as e: + if e.code == HTTP_NOT_FOUND: + return None + raise + return [r.get("tag_name", "") for r in releases] + + +# source: GitHub REST "Compare two commits" — `status` is exactly one of +# ahead / behind / identical / diverged. +# https://docs.github.com/rest/commits/commits#compare-two-commits +# +# compare/BASE...HEAD describes HEAD relative to BASE. With BASE = the default +# branch, `identical` means the pin IS the branch tip and `behind` means it is +# an ancestor of it — both reachable. `ahead` and `diverged` mean the pin +# carries commits the branch does not: an unmerged PR head, which stops being +# reachable the moment that PR is squash-merged. +REACHABLE_FROM_DEFAULT = frozenset({"identical", "behind"}) +SHA_DISPLAY_LEN = 12 # source: git's default core.abbrev floor for readable logs + + +def default_branch(repo: str) -> str | None: + """Repo's default branch; None when the repo does not resolve (404).""" + req = urllib.request.Request( + f"https://api.github.com/repos/{repo}", headers=api_headers() + ) + try: + with urllib.request.urlopen(req, timeout=API_TIMEOUT_S) as resp: + return json.load(resp).get("default_branch") + except urllib.error.HTTPError as e: + if e.code == HTTP_NOT_FOUND: + return None + raise + + +def compare_status(repo: str, base: str, head: str) -> str | None: + """Comparison status of head vs base; None when head does not resolve.""" + req = urllib.request.Request( + f"https://api.github.com/repos/{repo}/compare/{base}...{head}", + headers=api_headers(), + ) + try: + with urllib.request.urlopen(req, timeout=API_TIMEOUT_S) as resp: + return json.load(resp).get("status") + except urllib.error.HTTPError as e: + if e.code == HTTP_NOT_FOUND: + return None + raise + + +def check_pin_sha( + name: str, repo: str, sha: str, branch=default_branch, compare=compare_status +): + """Returns (failure, notice) — exactly one is non-None, or both are None.""" + try: + base = branch(repo) + if base is None: + return ( + None, + f"NOTICE: {name}: {repo} does not resolve; " + f"pinned sha not verified this run", + ) + status = compare(repo, base, sha) + except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e: + return ( + None, + f"NOTICE: {name}: network degraded ({e.__class__.__name__}); " + f"pinned sha not verified this run", + ) + short = sha[:SHA_DISPLAY_LEN] + if status is None: + return ( + f"PIN_SHA_UNREACHABLE: {name}: {repo} does not resolve commit {short} " + f"(absent from the repository)", + None, + ) + if status not in REACHABLE_FROM_DEFAULT: + return ( + f"PIN_SHA_UNREACHABLE: {name}: {repo}@{short} is '{status}' of " + f"{base} — the pin targets a commit outside the default branch " + f"(an unmerged PR head stops resolving once that PR is squashed)", + None, + ) + return None, None + + +def _check_unpublished_github_pin( + name: str, repo: str, pin: str, latest_tag: str, pending: dict[str, str] +): + """The PIN_VERSION_UNPUBLISHED branch of `check_github_pin`, split out to + keep the caller under the §4.2 method-size cap. `pending` degrades this + to a named NOTICE for a release genuinely in flight — never silence. + """ + if name in pending: + return ( + None, + f"NOTICE: {name}: pins {pin}, no matching {repo} release yet " + f"— PENDING: {pending[name]}", + ) + return ( + f"PIN_VERSION_UNPUBLISHED: {name}: pins {pin} but {repo} has no " + f"matching release (latest published is {latest_tag})", + None, + ) + + +def _fetch_github_versions(name: str, repo: str, pin: str, list_tags): + """Resolve `repo`'s releases into (parsed pin, [(version, raw_tag), ...]). + + Returns `(early_result, resolved)` — exactly one is None. `early_result` + is a ready-to-return (failure, notice) pair for every terminal case that + doesn't need the pin/latest comparison (network failure, no releases, + nothing parses). Split out of `check_github_pin` to keep it under the + §4.2 method-size cap. + """ + try: + tags = list_tags(repo) + except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e: + notice = ( + f"NOTICE: {name}: network degraded ({e.__class__.__name__}); " + f"pin not verified this run" + ) + return (None, notice), None + if tags is None: + notice = f"NOTICE: {name}: {repo} has no published releases; pin not comparable" + return (None, notice), None + pinned = parse_semver(pin) + versions = [(v, t) for t in tags if (v := parse_semver(t))] + if pinned is None: + return (f"UNPARSEABLE: {name}: pin={pin!r}", None), None + if not versions: + latest_raw = tags[0] if tags else None + return (f"UNPARSEABLE: {name}: pin={pin!r} latest={latest_raw!r}", None), None + return None, (pinned, versions) + + +def check_github_pin( + name: str, + repo: str, + pin: str, + list_tags=list_release_tags, + pending: dict[str, str] | None = None, +): + """Returns (failure, notice) — exactly one is non-None or both None. + + Checks TWO independent properties, in order: (1) does a release matching + the pin exist at all (PIN_VERSION_UNPUBLISHED if not), and only if it + does, (2) is it the latest one (PIN_BEHIND_RELEASE if not). + """ + if pending is None: + pending = PENDING_PINS + early_result, resolved = _fetch_github_versions(name, repo, pin, list_tags) + if resolved is None: + return early_result + pinned, versions = resolved + published = {v for v, _ in versions} + latest_v, latest_tag = max(versions) + if pinned not in published: + return _check_unpublished_github_pin(name, repo, pin, latest_tag, pending) + if pinned < latest_v: + n = sum(1 for v, _ in versions if pinned < v <= latest_v) + return ( + f"PIN_BEHIND_RELEASE: {name}: pins {pin}, {repo} latest is {latest_tag} " + f"({n} release(s) never delivered)", + None, + ) + return None, None diff --git a/scripts/marketplace_pins_http.py b/scripts/marketplace_pins_http.py new file mode 100644 index 00000000..54b0662b --- /dev/null +++ b/scripts/marketplace_pins_http.py @@ -0,0 +1,24 @@ +"""Shared HTTP plumbing for the marketplace pin-staleness gate. + +Split out of check_marketplace_pins.py (issue: that file crossed the +300-line §4.1 cap once REGISTRY_VERSION_STALE was added) — every module +that talks to a JSON API (GitHub REST, the public MCP registry) shares +this request-header builder, timeout, and the one HTTP status code they +all treat specially. +""" + +from __future__ import annotations + +import os + +API_TIMEOUT_S = 15 # source: GitHub API p99 well below; matches prior gate rev + +# source: RFC 9110 §15.5.5 — HTTP 404 Not Found +HTTP_NOT_FOUND = 404 + + +def api_headers() -> dict[str, str]: + headers = {"Accept": "application/vnd.github+json", "User-Agent": "pin-gate"} + if token := os.environ.get("GITHUB_TOKEN", ""): + headers["Authorization"] = f"Bearer {token}" + return headers diff --git a/scripts/marketplace_pins_manifests.py b/scripts/marketplace_pins_manifests.py new file mode 100644 index 00000000..49ddea2f --- /dev/null +++ b/scripts/marketplace_pins_manifests.py @@ -0,0 +1,43 @@ +"""Root-manifest version-split checks for the marketplace pin gate. + +Split out of check_marketplace_pins.py (issue: that file crossed the +300-line §4.1 cap once REGISTRY_VERSION_STALE was added). Checks that +every root-level manifest carrying its own copy of the version (server.json, +manifest.json) agrees with the primary local marketplace pin — the guard +AP #172 was missing when manifest.json alone went stale for two releases. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +# One row per file: adding a manifest never adds a branch to the check +# below, which is what let manifest.json go unguarded for two releases +# when the check was hardcoded to server.json alone (AP #172). +ROOT_VERSION_MANIFESTS: tuple[tuple[str, str, str], ...] = ( + ("server.json", "version", "SERVER_JSON_SPLIT"), + ("manifest.json", "version", "MANIFEST_JSON_SPLIT"), +) + + +def check_root_manifests(root: Path, primary_pin: str) -> list[str]: + """Flag every root manifest whose version disagrees with the primary pin. + + An absent file is not a failure and neither is a missing version key: the + canonical repo has no manifest.json, ai-architect-mcp-codebase has both, and a + gate that demanded every row exist everywhere would be a false positive in + one repo or the other rather than a guard in both. + """ + failures: list[str] = [] + for filename, key, failure_class in ROOT_VERSION_MANIFESTS: + path = root / filename + if not path.is_file(): + continue + version = json.loads(path.read_text()).get(key, "") + if version and version != primary_pin: + failures.append( + f"{failure_class}: {filename} says {version} " + f"but the primary marketplace pin is {primary_pin}" + ) + return failures diff --git a/scripts/marketplace_pins_registry.py b/scripts/marketplace_pins_registry.py new file mode 100644 index 00000000..82bfc922 --- /dev/null +++ b/scripts/marketplace_pins_registry.py @@ -0,0 +1,162 @@ +"""Public MCP registry cross-check for the marketplace pin-staleness gate. + +Split out of check_marketplace_pins.py (issue: that file crossed the +300-line §4.1 cap once this module was added). The public MCP registry +(registry.modelcontextprotocol.io) is a THIRD version surface — independent +of the marketplace pin and PyPI — that server.json's own "name" field +names an entry for. Measured 2026-08-10: io.github.cdeust/hypermnesia-mcp +was published at 4.17.1 while the tag/server.json/PyPI were already at +4.17.2, invisible until queried directly (same failure shape as #179 and +PIN_VERSION_UNPUBLISHED — a mandatory per-release step that lived only in +prose, with nothing committed to run it or verify it happened). +""" + +from __future__ import annotations + +import json +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +# See marketplace_pins_semver.py's identical bootstrap comment. +_scripts_dir = str(Path(__file__).resolve().parent) +if _scripts_dir not in sys.path: + sys.path.insert(0, _scripts_dir) +from marketplace_pins_http import HTTP_NOT_FOUND, API_TIMEOUT_S, api_headers # noqa: E402 + +REGISTRY_API = "https://registry.modelcontextprotocol.io/v0/servers" + +# Same contract as PENDING_PINS (marketplace_pins_github.py): an entry +# here degrades a real REGISTRY_VERSION_STALE finding to a named NOTICE +# while a committed, tracked fix is in flight — never a placeholder, +# never silent. +# +# One entry, not empty: the automated publish job +# (.github/workflows/release.yml::publish-mcp-registry, added in the same +# change as this check) only runs on a `v*` tag push. Merging this PR does +# not push a new tag, so the registry stays at 4.17.1 — a real, currently +# true gap, not a hypothetical one — until the next release tag runs that +# job. Publishing the missing 4.17.2 entry out of band requires a +# credential this agent does not hold (`mcp-publisher login` needs either +# an interactive GitHub OAuth browser flow or a personal access token) and +# was correctly refused when attempted; a maintainer with real +# credentials, or the next tagged release, closes this. Remove this entry +# once the registry serves >= the repo's authoritative version. +PENDING_REGISTRY: dict[str, str] = { + "io.github.cdeust/hypermnesia-mcp": ( + "release.yml::publish-mcp-registry (this PR) resolves it on the next " + "v* tag push; no CI credential exists to publish it sooner" + ), +} + + +def list_registry_versions(search_term: str) -> list[tuple[str, str, bool]] | None: + """[(name, version, is_latest), ...] for every registry entry matching + `search_term`; None when the registry has nothing for it. + + The registry API has no exact-name lookup (verified 2026-08-10: a + `name=` query parameter is silently ignored, not a filter), so this + fetches by `search=` and the caller filters to the exact name it wants. + """ + url = f"{REGISTRY_API}?search={urllib.parse.quote(search_term)}" + req = urllib.request.Request(url, headers=api_headers()) + try: + with urllib.request.urlopen(req, timeout=API_TIMEOUT_S) as resp: + data = json.load(resp) + except urllib.error.HTTPError as e: + if e.code == HTTP_NOT_FOUND: + return None + raise + out = [] + for entry in data.get("servers", []): + srv = entry.get("server", {}) + meta = entry.get("_meta", {}).get( + "io.modelcontextprotocol.registry/official", {} + ) + out.append( + (srv.get("name", ""), srv.get("version", ""), bool(meta.get("isLatest"))) + ) + return out + + +def _resolve_latest_registry_version(registry_name: str, list_versions): + """Fetch + narrow to the single 'latest' entry for `registry_name`. + + Returns `(early_result, latest_version)` — exactly one is None. + `early_result` is a ready (failure, notice) pair for every terminal + case (network failure, nothing found, no 'latest' flag). Split out of + `check_registry_version` to keep it under the §4.2 method-size cap. + """ + try: + entries = list_versions(registry_name.rsplit("/", 1)[-1]) + except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e: + notice = ( + f"NOTICE: registry: network degraded ({e.__class__.__name__}); " + f"{registry_name} not verified this run" + ) + return (None, notice), None + if entries is None: + return ( + None, + f"NOTICE: registry: {registry_name} not found; not comparable", + ), None + latest = [v for n, v, is_latest in entries if n == registry_name and is_latest] + if not latest: + notice = ( + f"NOTICE: registry: no 'latest' entry for {registry_name}; not comparable" + ) + return (None, notice), None + return None, latest[0] + + +def check_registry_version( + registry_name: str, + authoritative_version: str, + list_versions=list_registry_versions, + pending: dict[str, str] | None = None, +): + """Returns (failure, notice) — exactly one is non-None or both None. + + Exact-equality, not "behind": a registry entry ahead of the repo (a + republish of a version this repo never tagged) is just as much a lie + to whoever reads the registry as one that lags. + """ + if pending is None: + pending = PENDING_REGISTRY + early_result, registry_version = _resolve_latest_registry_version( + registry_name, list_versions + ) + if registry_version is None: + return early_result + if registry_version == authoritative_version: + return None, None + if registry_name in pending: + return ( + None, + f"NOTICE: registry: {registry_name} serves {registry_version}, " + f"repo is {authoritative_version} — PENDING: {pending[registry_name]}", + ) + return ( + f"REGISTRY_VERSION_STALE: {registry_name}: MCP registry serves " + f"{registry_version} but this repo's authoritative version is " + f"{authoritative_version}", + None, + ) + + +def check_registry_surface(root: Path, primary_pin: str): + """Cross-check server.json's own registry `name` against what the + public MCP registry actually serves. Returns (failures, notices). + Absent server.json or a missing `name` field is not a failure — not + every repo on this marketplace publishes to the MCP registry. + """ + server_json = root / "server.json" + if not server_json.is_file(): + return [], [] + registry_name = json.loads(server_json.read_text()).get("name", "") + if not registry_name: + return [], [] + failure, notice = check_registry_version(registry_name, primary_pin) + return ([failure] if failure else []), ([notice] if notice else []) diff --git a/scripts/marketplace_pins_self.py b/scripts/marketplace_pins_self.py new file mode 100644 index 00000000..bbed5b3f --- /dev/null +++ b/scripts/marketplace_pins_self.py @@ -0,0 +1,65 @@ +"""Local-source (self-hosted-in-this-repo) pin checks for the marketplace gate. + +Split out of check_marketplace_pins.py (issue: that file crossed the +300-line §4.1 cap once REGISTRY_VERSION_STALE was added). Covers pins +shaped ``{"source": "./some/path", "version": ...}`` — a plugin whose code +lives in this repo, checked against this repo's own ``plugin.json`` and +git tags rather than a remote GitHub API. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +# See marketplace_pins_semver.py's identical bootstrap comment. +_scripts_dir = str(Path(__file__).resolve().parent) +if _scripts_dir not in sys.path: + sys.path.insert(0, _scripts_dir) +from marketplace_pins_semver import local_semver_tags, parse_semver, tags_between # noqa: E402 + +# source: audited 2026-07-25 (Cortex PR #182 review clause 5) and 2026-08-04 +# (Cortex PR #351 Opus review) — each legacy identity is a notice-only shim +# frozen at its rename release; advancing one would hide the migration boundary. +FROZEN_PINS = { + "cortex": "deprecation shim, frozen at the 4.15.0 rename release", + "cortex-viz": "deprecation shim, frozen at the 2.8.0 pre-rename release", +} + + +def check_self_pin(name: str, source: str, pin: str, root: Path) -> list[str]: + failures: list[str] = [] + plugin_json = root / source / ".claude-plugin" / "plugin.json" + if not plugin_json.is_file(): + plugin_json = root / source / "plugin.json" + if plugin_json.is_file(): + actual = json.loads(plugin_json.read_text()).get("version", "") + if actual and actual != pin: + failures.append( + f"SELF_PIN_MISMATCH: {name}: pins {pin} " + f"but {plugin_json.relative_to(root)} says {actual}" + ) + if name in FROZEN_PINS: + # frozen: manifest coherence still checked, tag advance is by-design + return failures + parsed_tags = local_semver_tags(root) + pinned = parse_semver(pin) + if not parsed_tags or pinned is None: + # no tags to compare against, or an unparseable pin: nothing to say + return failures + published = {v for v, _ in parsed_tags} + latest_v, latest_tag = max(parsed_tags) + if pinned not in published: + failures.append( + f"PIN_VERSION_UNPUBLISHED: {name}: pins {pin} but this repo has no " + f"matching tag (latest tag is {latest_tag})" + ) + return failures + if pinned < latest_v: + n = tags_between(root, pinned, latest_v) + failures.append( + f"PIN_BEHIND_TAG: {name}: pins {pin} but this repo's latest tag is " + f"{latest_tag} ({n} release(s) never delivered to installs)" + ) + return failures diff --git a/scripts/marketplace_pins_semver.py b/scripts/marketplace_pins_semver.py new file mode 100644 index 00000000..d5a028e6 --- /dev/null +++ b/scripts/marketplace_pins_semver.py @@ -0,0 +1,56 @@ +"""Semver parsing + local git-tag helpers for the marketplace pin gate. + +Split out of check_marketplace_pins.py (issue: that file crossed the +300-line §4.1 cap once REGISTRY_VERSION_STALE was added). Offline, reads +git only — the PIN_BEHIND_TAG detection path (AP #67) depends on this +staying dependency-free of the network-facing modules. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +# Bare sibling import (not a package-relative one): this file is loaded +# both as a plain script-directory module (`python3 scripts/check_...py`, +# where Python puts scripts/ on sys.path[0] automatically) and, in tests, +# via `importlib.util.spec_from_file_location` from a different cwd — same +# idiom as craftsmanship_rules.py's sibling-import bootstrap. +_scripts_dir = str(Path(__file__).resolve().parent) +if _scripts_dir not in sys.path: + sys.path.insert(0, _scripts_dir) +from marketplace_pins_http import API_TIMEOUT_S # noqa: E402 + + +def parse_semver(tag: str) -> tuple[int, ...] | None: + """'v2.34.0' / '2.34.0' -> (2, 34, 0); None when not semver.""" + m = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", tag.strip()) + return tuple(int(g) for g in m.groups()) if m else None + + +def local_semver_tags( + root: Path, run=subprocess.run +) -> list[tuple[tuple[int, ...], str]]: + """Every (parsed_version, raw_tag) pair in this repo; [] on no tags/not a repo.""" + proc = run( + ["git", "-C", str(root), "tag", "--list"], + capture_output=True, + text=True, + timeout=API_TIMEOUT_S, + ) + if proc.returncode != 0: + return [] + return [(v, t) for t in proc.stdout.split() if (v := parse_semver(t))] + + +def latest_local_tag(root: Path, run=subprocess.run) -> str | None: + """Highest semver tag in this repo, or None (no tags / not a repo).""" + parsed = local_semver_tags(root, run) + return max(parsed)[1] if parsed else None + + +def tags_between(root: Path, pin: tuple, latest: tuple, run=subprocess.run) -> int: + parsed = local_semver_tags(root, run) + return sum(1 for v, _ in parsed if pin < v <= latest) diff --git a/server.json b/server.json index 442958f0..e52d3954 100644 --- a/server.json +++ b/server.json @@ -1,7 +1,7 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.cdeust/hypermnesia-mcp", - "description": "Cross-platform persistent memory for AI agents — 36 cited neuroscience mechanisms, local-first, hybrid retrieval.", + "description": "Persistent memory for AI agents — 36 cited neuroscience mechanisms, local-first, hybrid retrieval.", "repository": { "url": "https://github.com/cdeust/Cortex", "source": "github" diff --git a/tests_py/scripts/_marketplace_pins_test_loader.py b/tests_py/scripts/_marketplace_pins_test_loader.py new file mode 100644 index 00000000..8c431697 --- /dev/null +++ b/tests_py/scripts/_marketplace_pins_test_loader.py @@ -0,0 +1,44 @@ +"""Shared module loader for the check_marketplace_pins.py test files. + +Split out of test_check_marketplace_pins.py (issue: that file crossed the +300-line §4.1 cap once PIN_VERSION_UNPUBLISHED coverage was added) so both +test_check_marketplace_pins.py and test_check_marketplace_pins_sha_manifest.py +import the exact same `gate` module object — one dotted spec load, not two +independent ones that could see divergent module-level mutable state +(FROZEN_PINS/PENDING_PINS) across the split. +""" + +from __future__ import annotations + +import importlib.util +import subprocess +from pathlib import Path + +# Dotted to match the path-derived name mutmut keys mutant trampolines on +# ("scripts.check_marketplace_pins.*") — a bare module name makes every +# mutant look unreached to a scoped mutation run (issue #262). +_spec = importlib.util.spec_from_file_location( + "scripts.check_marketplace_pins", + Path(__file__).resolve().parents[2] / "scripts" / "check_marketplace_pins.py", +) +gate = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(gate) + + +def git_repo_with_tags(root: str, tags: list[str]) -> Path: + p = Path(root) + subprocess.run(["git", "-C", root, "init", "-q"], check=True) + subprocess.run( + ["git", "-C", root, "commit", "-q", "--allow-empty", "-m", "x"], + check=True, + env={ + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", + "PATH": "/usr/bin:/bin:/usr/local/bin", + }, + ) + for t in tags: + subprocess.run(["git", "-C", root, "tag", t], check=True) + return p diff --git a/tests_py/scripts/test_check_marketplace_pins.py b/tests_py/scripts/test_check_marketplace_pins.py index 68718eb4..3a9f0575 100644 --- a/tests_py/scripts/test_check_marketplace_pins.py +++ b/tests_py/scripts/test_check_marketplace_pins.py @@ -3,46 +3,22 @@ Written unittest-style on purpose: the byte-identical AP copy runs them via plain `python3 -m unittest` (no pytest there); Cortex's pytest collects unittest classes natively. + +Split into three files (issue: this file crossed the 300-line §4.1 cap once +PIN_VERSION_UNPUBLISHED coverage was added) — sha/reachability and root- +manifest-split coverage moved to +test_check_marketplace_pins_sha_manifest.py, same `gate` module instance via +_marketplace_pins_test_loader.py. """ from __future__ import annotations -import importlib.util import json -import subprocess import unittest import urllib.error -from pathlib import Path from tempfile import TemporaryDirectory -# Dotted to match the path-derived name mutmut keys mutant trampolines on -# ("scripts.check_marketplace_pins.*") — a bare module name makes every -# mutant look unreached to a scoped mutation run (issue #262). -_spec = importlib.util.spec_from_file_location( - "scripts.check_marketplace_pins", - Path(__file__).resolve().parents[2] / "scripts" / "check_marketplace_pins.py", -) -gate = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(gate) - - -def _git_repo_with_tags(root: str, tags: list[str]) -> Path: - p = Path(root) - subprocess.run(["git", "-C", root, "init", "-q"], check=True) - subprocess.run( - ["git", "-C", root, "commit", "-q", "--allow-empty", "-m", "x"], - check=True, - env={ - "GIT_AUTHOR_NAME": "t", - "GIT_AUTHOR_EMAIL": "t@t", - "GIT_COMMITTER_NAME": "t", - "GIT_COMMITTER_EMAIL": "t@t", - "PATH": "/usr/bin:/bin:/usr/local/bin", - }, - ) - for t in tags: - subprocess.run(["git", "-C", root, "tag", t], check=True) - return p +from tests_py.scripts._marketplace_pins_test_loader import gate, git_repo_with_tags class TestParseSemver(unittest.TestCase): @@ -74,7 +50,7 @@ def test_legacy_publication_identities_are_explicitly_frozen(self): def test_incident_replay_both_manifests_stale_tag_ahead(self): with TemporaryDirectory() as d: - root = _git_repo_with_tags(d, ["v0.7.0", "v0.8.0", "v0.8.1", "v0.8.2"]) + root = git_repo_with_tags(d, ["v0.7.0", "v0.8.0", "v0.8.1", "v0.8.2"]) plug = root / ".claude-plugin" plug.mkdir() (plug / "plugin.json").write_text(json.dumps({"version": "0.8.0"})) @@ -86,7 +62,7 @@ def test_incident_replay_both_manifests_stale_tag_ahead(self): def test_current_pin_green(self): with TemporaryDirectory() as d: - root = _git_repo_with_tags(d, ["v0.8.2"]) + root = git_repo_with_tags(d, ["v0.8.2"]) plug = root / ".claude-plugin" plug.mkdir() (plug / "plugin.json").write_text(json.dumps({"version": "0.8.2"})) @@ -94,12 +70,30 @@ def test_current_pin_green(self): def test_untagged_repo_no_crash_no_flag(self): with TemporaryDirectory() as d: - root = _git_repo_with_tags(d, []) + root = git_repo_with_tags(d, []) self.assertEqual(gate.check_self_pin("p", "./", "1.0.0", root), []) + def test_pin_ahead_of_every_tag_is_unpublished_not_silently_current(self): + """The generic form of the cortex-viz 3.0.0 incident, on a self-pin. + + A pin newer than the latest tag used to read as "current" (the old + check only ever asked "is pin < latest?"). It never asked "does a + tag matching pin exist at all?" — so a version nobody ever tagged + passed silently. This is the exact defect PIN_VERSION_UNPUBLISHED + closes, replayed against check_self_pin instead of check_github_pin. + """ + with TemporaryDirectory() as d: + root = git_repo_with_tags(d, ["v2.8.0", "v2.7.1", "v2.7.0"]) + failures = gate.check_self_pin("p", "./", "3.0.0", root) + joined = "\n".join(failures) + self.assertIn("PIN_VERSION_UNPUBLISHED", joined) + self.assertIn("no matching tag", joined) + self.assertIn("2.8.0", joined) # latest real tag, named for the reader + self.assertNotIn("PIN_BEHIND_TAG", joined) # not a staleness case + def test_frozen_pin_skips_tag_check_keeps_coherence(self): with TemporaryDirectory() as d: - root = _git_repo_with_tags(d, ["v4.16.0"]) + root = git_repo_with_tags(d, ["v4.16.0"]) shim = root / "shim" / ".claude-plugin" shim.mkdir(parents=True) (shim / "plugin.json").write_text(json.dumps({"version": "4.15.0"})) @@ -117,163 +111,92 @@ def test_frozen_pin_skips_tag_check_keeps_coherence(self): class TestGithubPin(unittest.TestCase): def test_stale_flagged_with_count(self): + tags = [ + "v2.28.0", + "v2.29.0", + "v2.30.0", + "v2.31.0", + "v2.32.0", + "v2.33.0", + "v2.34.0", + ] failure, notice = gate.check_github_pin( - "p", "o/r", "2.29.0", fetch=lambda r: "v2.34.0", count=lambda *a: 6 + "p", "o/r", "2.28.0", list_tags=lambda r: tags ) self.assertIn("PIN_BEHIND_RELEASE", failure) - self.assertIn("6 release(s)", failure) + self.assertIn("6 release(s)", failure) # v2.29.0..v2.34.0 self.assertIsNone(notice) - def test_stale_flagged_count_degraded(self): - failure, _ = gate.check_github_pin( - "p", "o/r", "2.29.0", fetch=lambda r: "v2.34.0", count=lambda *a: None - ) - self.assertIn("PIN_BEHIND_RELEASE", failure) - self.assertNotIn("None", failure) - - def test_current_and_ahead_pass(self): + def test_current_pin_passes(self): + tags = ["v2.29.0", "v2.34.0"] self.assertEqual( - gate.check_github_pin("p", "o/r", "2.34.0", fetch=lambda r: "v2.34.0"), + gate.check_github_pin("p", "o/r", "2.34.0", list_tags=lambda r: tags), (None, None), ) - self.assertEqual( - gate.check_github_pin("p", "o/r", "2.35.0", fetch=lambda r: "v2.34.0"), - (None, None), + + def test_incident_replay_pin_ahead_of_every_release_is_unpublished(self): + """The exact cortex-viz incident: pin "3.0.0", repo's real tags top + out at v2.8.0. `3.0.0 < 2.8.0` is false, so the pre-fix check (which + only ever asked "is pin behind latest?") read this as current and + returned (None, None) — six days of a dangling pin, zero red runs. + """ + tags = ["v2.8.0", "v2.7.1", "v2.7.0", "v2.6.3"] + failure, notice = gate.check_github_pin( + "hypermnesia-mcp-viz", + "cdeust/cortex-viz", + "3.0.0", + list_tags=lambda r: tags, + pending={}, # no pending exemption active for this assertion ) + self.assertIsNotNone(failure, "must not silently pass — this is the incident") + self.assertIn("PIN_VERSION_UNPUBLISHED", failure) + self.assertIn("no matching", failure) + self.assertIn("v2.8.0", failure) # names the real latest, for the reader + self.assertIsNone(notice) + + def test_pending_pin_degrades_unpublished_to_named_notice(self): + """A pin correctly flagged unpublished, but the release is genuinely + in flight (tracked by an open upstream PR) — not silence: a NOTICE + that names the tracking reference, still printed every run. + """ + tags = ["v2.8.0"] + failure, notice = gate.check_github_pin( + "hypermnesia-mcp-viz", + "cdeust/cortex-viz", + "3.1.0", + list_tags=lambda r: tags, + pending={"hypermnesia-mcp-viz": "cdeust/cortex-viz#130"}, + ) + self.assertIsNone(failure) + self.assertIn("PENDING", notice) + self.assertIn("cortex-viz#130", notice) def test_network_failure_degrades_to_notice(self): def down(_repo): raise urllib.error.URLError("offline") - failure, notice = gate.check_github_pin("p", "o/r", "2.29.0", fetch=down) + failure, notice = gate.check_github_pin("p", "o/r", "2.29.0", list_tags=down) self.assertIsNone(failure) # fail-open: no red run from an outage self.assertIn("network degraded", notice) def test_no_releases_repo_is_notice_not_keyerror(self): failure, notice = gate.check_github_pin( - "p", "o/r", "1.0.0", fetch=lambda r: None + "p", "o/r", "1.0.0", list_tags=lambda r: None ) self.assertIsNone(failure) self.assertIn("no published releases", notice) - def test_unparseable_tag_reported(self): + def test_unpublishable_pin_reported(self): failure, _ = gate.check_github_pin( - "p", "o/r", "1.0.0", fetch=lambda r: "nightly" + "p", "o/r", "not-a-version", list_tags=lambda r: ["v1.0.0"] ) self.assertIn("UNPARSEABLE", failure) - -class TestPinShaReachability(unittest.TestCase): - """PIN_SHA_UNREACHABLE — a pinned commit must live on the default branch.""" - - @staticmethod - def _probe(status): - return dict(branch=lambda r: "main", compare=lambda r, b, h: status) - - def test_identical_and_behind_pass(self): - for status in ("identical", "behind"): - with self.subTest(status=status): - self.assertEqual( - gate.check_pin_sha("p", "o/r", "a" * 40, **self._probe(status)), - (None, None), - ) - - def test_ahead_is_the_cortex_351_incident(self): - # Regression: Cortex #351 pinned cortex-viz at an unmerged PR head - # twice (ee0d41db, then 7e297ebc), both `ahead` of main. - failure, notice = gate.check_pin_sha( - "hypermnesia-mcp-viz", - "cdeust/cortex-viz", - "7e297ebc31af3f4be0a5d06974c7f11a72070b99", - **self._probe("ahead"), - ) - self.assertIn("PIN_SHA_UNREACHABLE", failure) - self.assertIn("7e297ebc31af", failure) - self.assertIn("main", failure) - self.assertIsNone(notice) - - def test_diverged_flagged(self): - failure, _ = gate.check_pin_sha("p", "o/r", "b" * 40, **self._probe("diverged")) - self.assertIn("PIN_SHA_UNREACHABLE", failure) - - def test_absent_commit_flagged_not_crashed(self): - failure, notice = gate.check_pin_sha("p", "o/r", "c" * 40, **self._probe(None)) - self.assertIn("PIN_SHA_UNREACHABLE", failure) - self.assertIn("absent", failure) - self.assertIsNone(notice) - - def test_unknown_repo_degrades_to_notice(self): - failure, notice = gate.check_pin_sha( - "p", "o/r", "d" * 40, branch=lambda r: None, compare=lambda *a: "ahead" - ) - self.assertIsNone(failure) # fail-open, same contract as the version path - self.assertIn("does not resolve", notice) - - def test_network_failure_degrades_to_notice(self): - def down(_repo): - raise urllib.error.URLError("offline") - - failure, notice = gate.check_pin_sha("p", "o/r", "e" * 40, branch=down) - self.assertIsNone(failure) - self.assertIn("network degraded", notice) - - def test_compare_outage_degrades_to_notice(self): - def down(*_a): - raise TimeoutError("slow") - - failure, notice = gate.check_pin_sha( - "p", "o/r", "f" * 40, branch=lambda r: "main", compare=down + def test_repo_with_only_unparseable_tags_reported(self): + failure, _ = gate.check_github_pin( + "p", "o/r", "1.0.0", list_tags=lambda r: ["nightly"] ) - self.assertIsNone(failure) - self.assertIn("network degraded", notice) - - -class TestRootManifestSplit(unittest.TestCase): - def test_three_way_split_third_leg_flagged(self): - with TemporaryDirectory() as d: - root = Path(d) - (root / "server.json").write_text(json.dumps({"version": "0.8.2"})) - issues = gate.check_root_manifests(root, "0.8.0") - self.assertTrue(any("SERVER_JSON_SPLIT" in i for i in issues)) - - def test_ap_172_incident_replay_manifest_json_two_releases_stale(self): - """The exact tree that exited 0 before this check existed. - - ai-architect-mcp-codebase carried manifest.json 0.8.0 while server.json and - every marketplace pin read 0.8.2. The gate passed, and the wrong - version shipped inside every .mcpb bundle for two releases. - """ - with TemporaryDirectory() as d: - root = Path(d) - (root / "server.json").write_text(json.dumps({"version": "0.8.2"})) - (root / "manifest.json").write_text(json.dumps({"version": "0.8.0"})) - issues = gate.check_root_manifests(root, "0.8.2") - self.assertEqual(len(issues), 1, issues) - self.assertIn("MANIFEST_JSON_SPLIT", issues[0]) - self.assertIn("0.8.0", issues[0]) - - def test_both_stale_are_reported_separately(self): - with TemporaryDirectory() as d: - root = Path(d) - (root / "server.json").write_text(json.dumps({"version": "0.8.1"})) - (root / "manifest.json").write_text(json.dumps({"version": "0.8.0"})) - issues = gate.check_root_manifests(root, "0.8.2") - self.assertEqual(len(issues), 2, issues) - - def test_aligned_passes_and_absent_passes(self): - with TemporaryDirectory() as d: - root = Path(d) - # Absent: the canonical repo has neither file — not a failure. - self.assertEqual(gate.check_root_manifests(root, "0.8.2"), []) - (root / "server.json").write_text(json.dumps({"version": "0.8.2"})) - (root / "manifest.json").write_text(json.dumps({"version": "0.8.2"})) - self.assertEqual(gate.check_root_manifests(root, "0.8.2"), []) - - def test_missing_version_key_is_not_a_failure(self): - with TemporaryDirectory() as d: - root = Path(d) - (root / "manifest.json").write_text(json.dumps({"name": "x"})) - self.assertEqual(gate.check_root_manifests(root, "0.8.2"), []) + self.assertIn("UNPARSEABLE", failure) if __name__ == "__main__": diff --git a/tests_py/scripts/test_check_marketplace_pins_registry.py b/tests_py/scripts/test_check_marketplace_pins_registry.py new file mode 100644 index 00000000..9606b81c --- /dev/null +++ b/tests_py/scripts/test_check_marketplace_pins_registry.py @@ -0,0 +1,123 @@ +"""Tests for scripts/marketplace_pins_registry.py — the public MCP registry +cross-check (REGISTRY_VERSION_STALE). Split out as its own test file +(same rationale as test_check_marketplace_pins_sha_manifest.py: keeps each +test file under the 300-line §4.1 cap); same `gate` module instance via +_marketplace_pins_test_loader.py. +""" + +from __future__ import annotations + +import unittest +import urllib.error + +from tests_py.scripts._marketplace_pins_test_loader import gate + + +class TestRegistryVersion(unittest.TestCase): + def test_current_passes(self): + entries = [("io.github.cdeust/hypermnesia-mcp", "4.17.2", True)] + self.assertEqual( + gate.check_registry_version( + "io.github.cdeust/hypermnesia-mcp", + "4.17.2", + list_versions=lambda s: entries, + pending={}, + ), + (None, None), + ) + + def test_incident_replay_registry_behind_repo_is_flagged(self): + """The exact incident: registry serves 4.17.1, repo (tag/server.json/ + PyPI) is already 4.17.2 — measured 2026-08-10, no CI gate caught it + because nothing read this surface before this check existed. + """ + entries = [("io.github.cdeust/hypermnesia-mcp", "4.17.1", True)] + failure, notice = gate.check_registry_version( + "io.github.cdeust/hypermnesia-mcp", + "4.17.2", + list_versions=lambda s: entries, + pending={}, + ) + self.assertIn("REGISTRY_VERSION_STALE", failure) + self.assertIn("4.17.1", failure) + self.assertIn("4.17.2", failure) + self.assertIsNone(notice) + + def test_registry_ahead_of_repo_is_also_flagged(self): + """Equality, not "behind": a registry entry ahead of the repo is a + republish of a version this repo never tagged — just as false. + """ + entries = [("io.github.cdeust/hypermnesia-mcp", "5.0.0", True)] + failure, _ = gate.check_registry_version( + "io.github.cdeust/hypermnesia-mcp", + "4.17.2", + list_versions=lambda s: entries, + pending={}, + ) + self.assertIn("REGISTRY_VERSION_STALE", failure) + + def test_pending_degrades_to_named_notice(self): + entries = [("io.github.cdeust/hypermnesia-mcp", "4.17.1", True)] + failure, notice = gate.check_registry_version( + "io.github.cdeust/hypermnesia-mcp", + "4.17.2", + list_versions=lambda s: entries, + pending={"io.github.cdeust/hypermnesia-mcp": "release.yml (tracked)"}, + ) + self.assertIsNone(failure) + self.assertIn("PENDING", notice) + self.assertIn("release.yml", notice) + + def test_no_is_latest_entry_is_notice_not_crash(self): + entries = [("io.github.cdeust/hypermnesia-mcp", "4.17.1", False)] + failure, notice = gate.check_registry_version( + "io.github.cdeust/hypermnesia-mcp", + "4.17.2", + list_versions=lambda s: entries, + pending={}, + ) + self.assertIsNone(failure) + self.assertIn("no 'latest' entry", notice) + + def test_not_found_is_notice_not_crash(self): + failure, notice = gate.check_registry_version( + "io.github.cdeust/nonexistent", + "1.0.0", + list_versions=lambda s: None, + pending={}, + ) + self.assertIsNone(failure) + self.assertIn("not found", notice) + + def test_network_failure_degrades_to_notice(self): + def down(_search): + raise urllib.error.URLError("offline") + + failure, notice = gate.check_registry_version( + "io.github.cdeust/hypermnesia-mcp", "4.17.2", list_versions=down + ) + self.assertIsNone(failure) + self.assertIn("network degraded", notice) + + +class TestRegistrySurface(unittest.TestCase): + def test_absent_server_json_is_not_a_failure(self): + from pathlib import Path + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as d: + self.assertEqual(gate.check_registry_surface(Path(d), "1.0.0"), ([], [])) + + def test_missing_name_field_is_not_a_failure(self): + import json + from pathlib import Path + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as d: + root = Path(d) + (root / "server.json").write_text(json.dumps({"version": "1.0.0"})) + self.assertEqual(gate.check_registry_surface(root, "1.0.0"), ([], [])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_py/scripts/test_check_marketplace_pins_sha_manifest.py b/tests_py/scripts/test_check_marketplace_pins_sha_manifest.py new file mode 100644 index 00000000..d17f791b --- /dev/null +++ b/tests_py/scripts/test_check_marketplace_pins_sha_manifest.py @@ -0,0 +1,133 @@ +"""Tests for scripts/check_marketplace_pins.py — sha reachability + root +manifest split checks. Split out of test_check_marketplace_pins.py (issue: +that file crossed the 300-line §4.1 cap once PIN_VERSION_UNPUBLISHED +coverage was added); same `gate` module instance via +_marketplace_pins_test_loader.py. +""" + +from __future__ import annotations + +import json +import unittest +import urllib.error +from pathlib import Path +from tempfile import TemporaryDirectory + +from tests_py.scripts._marketplace_pins_test_loader import gate + + +class TestPinShaReachability(unittest.TestCase): + """PIN_SHA_UNREACHABLE — a pinned commit must live on the default branch.""" + + @staticmethod + def _probe(status): + return dict(branch=lambda r: "main", compare=lambda r, b, h: status) + + def test_identical_and_behind_pass(self): + for status in ("identical", "behind"): + with self.subTest(status=status): + self.assertEqual( + gate.check_pin_sha("p", "o/r", "a" * 40, **self._probe(status)), + (None, None), + ) + + def test_ahead_is_the_cortex_351_incident(self): + # Regression: Cortex #351 pinned cortex-viz at an unmerged PR head + # twice (ee0d41db, then 7e297ebc), both `ahead` of main. + failure, notice = gate.check_pin_sha( + "hypermnesia-mcp-viz", + "cdeust/cortex-viz", + "7e297ebc31af3f4be0a5d06974c7f11a72070b99", + **self._probe("ahead"), + ) + self.assertIn("PIN_SHA_UNREACHABLE", failure) + self.assertIn("7e297ebc31af", failure) + self.assertIn("main", failure) + self.assertIsNone(notice) + + def test_diverged_flagged(self): + failure, _ = gate.check_pin_sha("p", "o/r", "b" * 40, **self._probe("diverged")) + self.assertIn("PIN_SHA_UNREACHABLE", failure) + + def test_absent_commit_flagged_not_crashed(self): + failure, notice = gate.check_pin_sha("p", "o/r", "c" * 40, **self._probe(None)) + self.assertIn("PIN_SHA_UNREACHABLE", failure) + self.assertIn("absent", failure) + self.assertIsNone(notice) + + def test_unknown_repo_degrades_to_notice(self): + failure, notice = gate.check_pin_sha( + "p", "o/r", "d" * 40, branch=lambda r: None, compare=lambda *a: "ahead" + ) + self.assertIsNone(failure) # fail-open, same contract as the version path + self.assertIn("does not resolve", notice) + + def test_network_failure_degrades_to_notice(self): + def down(_repo): + raise urllib.error.URLError("offline") + + failure, notice = gate.check_pin_sha("p", "o/r", "e" * 40, branch=down) + self.assertIsNone(failure) + self.assertIn("network degraded", notice) + + def test_compare_outage_degrades_to_notice(self): + def down(*_a): + raise TimeoutError("slow") + + failure, notice = gate.check_pin_sha( + "p", "o/r", "f" * 40, branch=lambda r: "main", compare=down + ) + self.assertIsNone(failure) + self.assertIn("network degraded", notice) + + +class TestRootManifestSplit(unittest.TestCase): + def test_three_way_split_third_leg_flagged(self): + with TemporaryDirectory() as d: + root = Path(d) + (root / "server.json").write_text(json.dumps({"version": "0.8.2"})) + issues = gate.check_root_manifests(root, "0.8.0") + self.assertTrue(any("SERVER_JSON_SPLIT" in i for i in issues)) + + def test_ap_172_incident_replay_manifest_json_two_releases_stale(self): + """The exact tree that exited 0 before this check existed. + + ai-architect-mcp-codebase carried manifest.json 0.8.0 while server.json and + every marketplace pin read 0.8.2. The gate passed, and the wrong + version shipped inside every .mcpb bundle for two releases. + """ + with TemporaryDirectory() as d: + root = Path(d) + (root / "server.json").write_text(json.dumps({"version": "0.8.2"})) + (root / "manifest.json").write_text(json.dumps({"version": "0.8.0"})) + issues = gate.check_root_manifests(root, "0.8.2") + self.assertEqual(len(issues), 1, issues) + self.assertIn("MANIFEST_JSON_SPLIT", issues[0]) + self.assertIn("0.8.0", issues[0]) + + def test_both_stale_are_reported_separately(self): + with TemporaryDirectory() as d: + root = Path(d) + (root / "server.json").write_text(json.dumps({"version": "0.8.1"})) + (root / "manifest.json").write_text(json.dumps({"version": "0.8.0"})) + issues = gate.check_root_manifests(root, "0.8.2") + self.assertEqual(len(issues), 2, issues) + + def test_aligned_passes_and_absent_passes(self): + with TemporaryDirectory() as d: + root = Path(d) + # Absent: the canonical repo has neither file — not a failure. + self.assertEqual(gate.check_root_manifests(root, "0.8.2"), []) + (root / "server.json").write_text(json.dumps({"version": "0.8.2"})) + (root / "manifest.json").write_text(json.dumps({"version": "0.8.2"})) + self.assertEqual(gate.check_root_manifests(root, "0.8.2"), []) + + def test_missing_version_key_is_not_a_failure(self): + with TemporaryDirectory() as d: + root = Path(d) + (root / "manifest.json").write_text(json.dumps({"name": "x"})) + self.assertEqual(gate.check_root_manifests(root, "0.8.2"), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_py/scripts/test_codex_plugin_contract.py b/tests_py/scripts/test_codex_plugin_contract.py index 55c65acf..bd444882 100644 --- a/tests_py/scripts/test_codex_plugin_contract.py +++ b/tests_py/scripts/test_codex_plugin_contract.py @@ -107,11 +107,16 @@ def test_claude_marketplace_publishes_pinned_canonical_viz_identity() -> None: entries = {entry["name"]: entry for entry in marketplace["plugins"]} canonical = entries["hypermnesia-mcp-viz"] - assert canonical["version"] == "3.0.0" + # "3.0.0"/1c1940e... was the dangling pin from the #179-style incident: + # cortex-viz never tagged a v3.0.0 (the rename landed straight on main + # without a release); the first real tagged release carrying it was + # v3.1.0 (cdeust/cortex-viz#130), which is what this pin now targets. + assert canonical["version"] == "3.1.0" assert canonical["source"] == { "source": "github", "repo": "cdeust/cortex-viz", - "sha": "1c1940e278979f35cdecea6146d7fb5f749907e9", + "ref": "v3.1.0", + "sha": "052e4a40d3e6bddaeb1cec6662e23b451575c481", } assert "standalone Hypermnesia MCP Viz server" in canonical["description"] From b211c6b9901e55ec7b03d28b5442a07cd9340e1c Mon Sep 17 00:00:00 2001 From: cdeust Date: Mon, 10 Aug 2026 13:36:51 +0200 Subject: [PATCH 2/3] fix(marketplace): close dispatch silence, rebase, and make the replay real Addresses REQUEST_CHANGES on #419. 1. BLOCK - _check_plugin_pin recognized only two of the marketplace schema's five source shapes (github, local-path) and silently returned ([], [], None) for the other three (npm, url, git-subdir), all schema-legal (verified live against https://json.schemastore.org/claude-code-marketplace.json), all currently unused by this repo's manifest (verified by reading every plugins[].source in it) but not by the dispatcher's own logic. That silence is exactly the defect class this gate exists to close, in code this same PR introduced. Fixed: a dict source with a recognized- but-unchecked type (npm/url/git-subdir) now fails loudly (UNVERIFIED_SOURCE_TYPE, naming the type and pointing at where to add a checker); any other shape (wrong type, unknown source key) fails loudly too (UNRECOGNIZED_SOURCE). 7 new tests in tests_py/scripts/test_check_marketplace_pins_dispatch.py replay the reviewer's exact npm/url probe plus git-subdir, an unknown dict, and two malformed non-dict/non-str shapes - every one now produces a failure, none pass silently. 2. BLOCK - rebased onto origin/main (e88e4e21). The branch was stale behind #414/#416/#417; check_craftsmanship.py --base origin/main (the exact invocation CI runs) is clean post-rebase. 3. Non-blocking, fixed anyway - the incident-replay tests now execute a frozen, verbatim copy of the pre-fix check_github_pin/check_self_pin logic (git blame: pre-e0661ad9) against the identical historical inputs, asserting it returns the old silent (None, None)/[] BEFORE asserting the current code returns the failure AFTER (frozen copies live in tests_py/scripts/_marketplace_pins_legacy_replay.py). The commit message on e0661ad9 asserted this replay already happened; it did not - only the new code was exercised, and the "before" was prose. This is what the prose should have described from the start. Incidental fix required to keep this PR's own CI green: bumped zetetic-team-subagents 2.36.0 -> 2.37.0 (a real v2.37.0 tag landed on cdeust/zetetic-team-subagents at 2026-08-10T10:32Z, mid-session, from unrelated work - confirmed via `gh release view`, not assumed). Unrelated to items 1-3 and to this PR's actual subject; flagged here rather than silently folded in. Re-measured after, not before: check_craftsmanship.py --base origin/main clean; check_marketplace_pins.py exits 0 live (one NOTICE, the already- disclosed pending registry entry); ruff check/format clean; pyright 0 diagnostics on every touched file; tests_py/scripts/ 771 passed (up from 764 pre-review), 5 skipped. Co-Authored-By: Claude Sonnet 5 --- .claude-plugin/marketplace.json | 2 +- scripts/check_marketplace_pins.py | 186 +++++++++++------- .../_marketplace_pins_legacy_replay.py | 100 ++++++++++ .../scripts/test_check_marketplace_pins.py | 51 +++-- .../test_check_marketplace_pins_dispatch.py | 88 +++++++++ 5 files changed, 347 insertions(+), 80 deletions(-) create mode 100644 tests_py/scripts/_marketplace_pins_legacy_replay.py create mode 100644 tests_py/scripts/test_check_marketplace_pins_dispatch.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d318a386..3373a6ff 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -109,7 +109,7 @@ "repo": "cdeust/zetetic-team-subagents" }, "description": "Zetetic agent team for Cortex — 97 genius reasoning patterns from history's greatest minds + 23 team specialists (architect, engineer, code-reviewer, refactorer, …), 78 skills, 26 commands, 42 tools, 20 hooks, under one epistemic standard none of them can bypass. Composes with Cortex memory: every agent recalls/remembers through the Cortex MCP and routes via /genius, /agent, /zetetic, /quality, /research commands.", - "version": "2.36.0", + "version": "2.37.0", "author": { "name": "Clement Deust", "email": "admin@ai-architect.tools" diff --git a/scripts/check_marketplace_pins.py b/scripts/check_marketplace_pins.py index 9a4913af..ac418aa8 100644 --- a/scripts/check_marketplace_pins.py +++ b/scripts/check_marketplace_pins.py @@ -19,57 +19,55 @@ two cortex-viz releases were withheld silently (Cortex #179); AP's own manifests sat a three-way split with its tag (AP #67). Failure classes: - PIN_BEHIND_TAG local-source pin < this repo's latest semver git tag. - Offline, reads git only — detects the #67 incident - even when every manifest agrees (they were BOTH stale). - PIN_BEHIND_RELEASE github-source pin < that repo's latest release, but a - release matching the pin DOES exist. - PIN_VERSION_UNPUBLISHED the pinned version has no matching tag/release in - the target repo AT ALL — not "behind", genuinely - never cut. `PIN_BEHIND_*` only ever compared the pin - to the *latest* known tag, so a pin sitting AHEAD of - every real release (a version nobody ever tagged) - read as "current" and passed silently. Incident: - Cortex's own marketplace.json pinned - `hypermnesia-mcp-viz` at cortex-viz version "3.0.0" - for six days; cortex-viz's own tag history never - contained a v3.0.0 — v2.8.0 was, and remained, the - latest real tag. `3.0.0 < 2.8.0` is false, so the old - `PIN_BEHIND_RELEASE` check never fired: a pin ahead - of the truth is invisible to a "behind" comparison. - Diagnostic method: put the declared versions and the - actually-tagged versions side by side; the gap is - only visible in the join, never in either list read - alone, and never in the manifest file by itself — the - manifest doesn't know what wasn't tagged. - SELF_PIN_MISMATCH local-source pin != the plugin's own plugin.json. - SERVER_JSON_SPLIT root server.json version != the primary local pin - (the unguarded third leg of AP's three-way split). - PIN_SHA_UNREACHABLE github-source pin names a `sha` that is not reachable - from that repo's default branch. Cortex #351 pinned - cortex-viz twice at an unmerged PR head (ee0d41db, then - 7e297ebc); both were `ahead` of main, so a squash-merge - would have left the marketplace serving an orphaned - commit. Version checks cannot see this: the pin read - 3.0.0 and was current on every run. - MANIFEST_JSON_SPLIT root manifest.json version != the primary local pin. - AP shipped manifest.json stuck at 0.8.0 for TWO releases - while every other pin read 0.8.2 and this gate exited 0, - because it only ever read server.json (AP #172). The - file is copied verbatim into the .mcpb bundle, so the - wrong version shipped to every install. - REGISTRY_VERSION_STALE the public MCP registry (registry.modelcontextprotocol.io) - serves a version != this repo's authoritative one for - server.json's own `name`. A THIRD version surface, - independent of the marketplace pin and PyPI: measured - 2026-08-10, io.github.cdeust/hypermnesia-mcp was - published at 4.17.1 while the tag/server.json/PyPI - were already at 4.17.2 — the registry publish step - lived only in prose (no committed workflow ran it), - same failure shape as #179 and PIN_VERSION_UNPUBLISHED - above. Equality, not "behind": a registry entry ahead - of the repo (a republish of an untagged version) is - exactly as false as one that lags. +- PIN_BEHIND_TAG: local-source pin < this repo's latest semver git tag. + Offline, reads git only — detects the #67 incident even when every + manifest agrees (they were BOTH stale). +- PIN_BEHIND_RELEASE: github-source pin < that repo's latest release, but + a release matching the pin DOES exist. +- PIN_VERSION_UNPUBLISHED: the pinned version has no matching tag/release + in the target repo AT ALL — not "behind", genuinely never cut. + `PIN_BEHIND_*` only ever compared the pin to the *latest* known tag, so + a pin sitting AHEAD of every real release (a version nobody ever + tagged) read as "current" and passed silently. Incident: Cortex's own + marketplace.json pinned `hypermnesia-mcp-viz` at cortex-viz version + "3.0.0" for six days; cortex-viz's own tag history never contained a + v3.0.0 — v2.8.0 was, and remained, the latest real tag. `3.0.0 < 2.8.0` + is false, so the old `PIN_BEHIND_RELEASE` check never fired: a pin + ahead of the truth is invisible to a "behind" comparison. Diagnostic + method: put the declared versions and the actually-tagged versions + side by side; the gap is only visible in the join, never in either + list read alone, and never in the manifest file by itself — the + manifest doesn't know what wasn't tagged. +- SELF_PIN_MISMATCH: local-source pin != the plugin's own plugin.json. +- SERVER_JSON_SPLIT: root server.json version != the primary local pin + (the unguarded third leg of AP's three-way split). +- PIN_SHA_UNREACHABLE: github-source pin names a `sha` that is not + reachable from that repo's default branch. Cortex #351 pinned + cortex-viz twice at an unmerged PR head (ee0d41db, then 7e297ebc); + both were `ahead` of main, so a squash-merge would have left the + marketplace serving an orphaned commit. Version checks cannot see + this: the pin read 3.0.0 and was current on every run. +- MANIFEST_JSON_SPLIT: root manifest.json version != the primary local + pin. AP shipped manifest.json stuck at 0.8.0 for TWO releases while + every other pin read 0.8.2 and this gate exited 0, because it only + ever read server.json (AP #172). The file is copied verbatim into + the .mcpb bundle, so the wrong version shipped to every install. +- REGISTRY_VERSION_STALE: the public MCP registry + (registry.modelcontextprotocol.io) serves a version != this repo's + authoritative one for server.json's own `name`. A THIRD version + surface, independent of the marketplace pin and PyPI: measured + 2026-08-10, io.github.cdeust/hypermnesia-mcp was published at 4.17.1 + while the tag/server.json/PyPI were already at 4.17.2 — the registry + publish step lived only in prose (no committed workflow ran it), same + failure shape as #179 and PIN_VERSION_UNPUBLISHED above. Equality, + not "behind": a registry entry ahead of the repo (a republish of an + untagged version) is exactly as false as one that lags. +- UNVERIFIED_SOURCE_TYPE: a plugin's `source.source` is a schema-legal + type (npm/url/git-subdir) this gate has no checker for. Never silent + — a dispatcher recognizing only some schema-legal shapes and passing + the rest through unflagged is the same defect class as the above. +- UNRECOGNIZED_SOURCE: a plugin's `source` matches no schema shape at + all (wrong type, unknown `source` key) — same non-silence principle. Network failures DEGRADE TO SILENCE (NOTICE + exit 0): a gate that reddens every PR during a GitHub outage gets disabled, and then the six-release gap @@ -123,7 +121,11 @@ check_registry_version, ) from marketplace_pins_self import FROZEN_PINS, check_self_pin # noqa: E402 -from marketplace_pins_semver import parse_semver # noqa: E402 +from marketplace_pins_semver import ( # noqa: E402 + latest_local_tag, + parse_semver, + tags_between, +) __all__ = [ "FROZEN_PINS", @@ -134,8 +136,10 @@ "check_registry_surface", "check_registry_version", "check_self_pin", + "latest_local_tag", "main", "parse_semver", + "tags_between", ] MARKETPLACE = ( @@ -143,6 +147,45 @@ ) +# Every `source` shape the marketplace schema defines (verified live against +# https://json.schemastore.org/claude-code-marketplace.json 2026-08-10: the +# schema's `plugins[].source` is an anyOf of exactly these five — a bare +# local-path string, plus four typed objects keyed by `source`). This gate +# has a checker for "github" (check_github_pin/check_pin_sha) and for the +# bare-string local form (check_self_pin). It does NOT verify npm/url/ +# git-subdir pins — no marketplace entry uses them today (source: read +# every `plugins[].source` in .claude-plugin/marketplace.json, 2026-08-10), +# so this is not an active incident, but a dispatcher that silently returns +# (no failure, no notice) for a shape it does not recognize is exactly the +# defect class this gate exists to close. UNVERIFIED_SOURCE_TYPE below +# fails loudly instead: a real npm/url/git-subdir pin must not pass through +# an unimplemented checker as if it were current. +KNOWN_TYPED_SOURCES = frozenset({"npm", "url", "git-subdir"}) + + +def _check_github_source_pin(name: str, pin: str, source: dict): + """The `source.get("source") == "github"` branch of `_check_plugin_pin`, + split out to keep the caller under the §4.2 method-size cap. + """ + failures: list[str] = [] + notices: list[str] = [] + failure, notice = check_github_pin(name, source["repo"], pin) + if failure: + failures.append(failure) + if notice: + notices.append(notice) + # A pin may name an exact commit as well as a version. Both are + # delivery-gating and they fail independently: #351's sha was + # unreachable while its version was perfectly current. + if sha := source.get("sha"): + failure, notice = check_pin_sha(name, source["repo"], sha) + if failure: + failures.append(failure) + if notice: + notices.append(notice) + return failures, notices + + def _check_plugin_pin(name: str, pin: str, source, root: Path): """One marketplace entry -> (failures, notices, primary_pin_or_None). @@ -152,26 +195,33 @@ def _check_plugin_pin(name: str, pin: str, source, root: Path): """ failures: list[str] = [] notices: list[str] = [] - if isinstance(source, dict) and source.get("source") == "github": - failure, notice = check_github_pin(name, source["repo"], pin) - if failure: - failures.append(failure) - if notice: - notices.append(notice) - # A pin may name an exact commit as well as a version. Both are - # delivery-gating and they fail independently: #351's sha was - # unreachable while its version was perfectly current. - if sha := source.get("sha"): - failure, notice = check_pin_sha(name, source["repo"], sha) - if failure: - failures.append(failure) - if notice: - notices.append(notice) - return failures, notices, None if isinstance(source, str): failures.extend(check_self_pin(name, source, pin, root)) primary = pin if source.strip("/") in ("", ".") else None return failures, notices, primary + if isinstance(source, dict): + source_type = source.get("source") + if source_type == "github": + gh_failures, gh_notices = _check_github_source_pin(name, pin, source) + return failures + gh_failures, notices + gh_notices, None + if source_type in KNOWN_TYPED_SOURCES: + failures.append( + f"UNVERIFIED_SOURCE_TYPE: {name}: source type {source_type!r} " + f"is schema-legal but this gate has no checker for it — add " + f"one (see marketplace_pins_github.py for the github-source " + f"pattern) before relying on this pin's currency" + ) + return failures, notices, None + failures.append( + f"UNRECOGNIZED_SOURCE: {name}: source {source!r} does not match " + f"any known marketplace source shape (github/npm/url/git-subdir/" + f"local-path)" + ) + return failures, notices, None + failures.append( + f"UNRECOGNIZED_SOURCE: {name}: source {source!r} is neither a local-" + f"path string nor a source object" + ) return failures, notices, None diff --git a/tests_py/scripts/_marketplace_pins_legacy_replay.py b/tests_py/scripts/_marketplace_pins_legacy_replay.py new file mode 100644 index 00000000..81079d54 --- /dev/null +++ b/tests_py/scripts/_marketplace_pins_legacy_replay.py @@ -0,0 +1,100 @@ +"""Frozen, verbatim copies of pre-fix check_marketplace_pins.py logic +(git blame: pre-e0661ad9), kept ONLY so the incident-replay tests in +test_check_marketplace_pins.py can prove the historical defect existed — +by executing the actual old code against the actual historical inputs — +rather than merely asserting it in prose. Split into its own file (issue: +test_check_marketplace_pins.py crossed the 300-line §4.1 cap once this +coverage was added). + +Never call these outside a test, and never "fix" them — the whole point +is that they reproduce the bug. +""" + +from __future__ import annotations + +import json +import urllib.error +from typing import NamedTuple + +from tests_py.scripts._marketplace_pins_test_loader import gate + + +class LegacyGithubFetchers(NamedTuple): + """Bundles the two pre-fix `check_github_pin` injection points (§4.4: + a 5th positional parameter on the function below would be a hard-rule + violation even for a frozen historical copy — this is the parameter- + object fix coding-standards.md §4.4 itself prescribes, not a + behavioral change from the original). + """ + + fetch: object + count: object + + +def pre_fix_check_github_pin( + name: str, repo: str, pin: str, fetchers: LegacyGithubFetchers +): + """Frozen, verbatim copy of `check_github_pin` as it existed BEFORE this + change: only ever compared the pin to the *latest* known tag + (`pinned < latest`), never checked whether a release matching the pin + existed at all — the defect PIN_VERSION_UNPUBLISHED closes. + """ + try: + tag = fetchers.fetch(repo) + except (urllib.error.URLError, TimeoutError, OSError) as e: + return ( + None, + f"NOTICE: {name}: network degraded ({e.__class__.__name__}); " + f"pin not verified this run", + ) + if tag is None: + return ( + None, + f"NOTICE: {name}: {repo} has no published releases; pin not comparable", + ) + latest, pinned = gate.parse_semver(tag), gate.parse_semver(pin) + if latest is None or pinned is None: + return f"UNPARSEABLE: {name}: pin={pin!r} latest={tag!r}", None + if pinned < latest: + n = fetchers.count(repo, pinned, latest) + behind = f"{n} release(s)" if n is not None else "release(s)" + return ( + f"PIN_BEHIND_RELEASE: {name}: pins {pin}, {repo} latest is {tag} " + f"({behind} never delivered)", + None, + ) + return None, None + + +def pre_fix_check_self_pin(name: str, source: str, pin: str, root): + """Frozen, verbatim copy of `check_self_pin` as it existed BEFORE this + change — same rationale as `pre_fix_check_github_pin` above. + + The original referenced module-level `FROZEN_PINS` directly (a global, + not a parameter — its real 4-parameter signature is reproduced exactly + here); this frozen copy inlines an empty set since every caller uses a + `name` that was never in FROZEN_PINS, so the branch's outcome is + identical without needing a 5th parameter to inject one. + """ + failures: list[str] = [] + plugin_json = root / source / ".claude-plugin" / "plugin.json" + if not plugin_json.is_file(): + plugin_json = root / source / "plugin.json" + if plugin_json.is_file(): + actual = json.loads(plugin_json.read_text()).get("version", "") + if actual and actual != pin: + failures.append( + f"SELF_PIN_MISMATCH: {name}: pins {pin} " + f"but {plugin_json.relative_to(root)} says {actual}" + ) + if name in frozenset(): # stands in for the real FROZEN_PINS global + return failures + tag = gate.latest_local_tag(root) + pinned = gate.parse_semver(pin) + if tag and pinned and (latest := gate.parse_semver(tag)) and pinned < latest: + n = gate.tags_between(root, pinned, latest) + failures.append( + f"PIN_BEHIND_TAG: {name}: pins {pin} but this repo's latest tag is {tag} " + f"({n} release(s) never delivered to installs)" + ) + return failures diff --git a/tests_py/scripts/test_check_marketplace_pins.py b/tests_py/scripts/test_check_marketplace_pins.py index 3a9f0575..e6cc32c8 100644 --- a/tests_py/scripts/test_check_marketplace_pins.py +++ b/tests_py/scripts/test_check_marketplace_pins.py @@ -18,6 +18,11 @@ import urllib.error from tempfile import TemporaryDirectory +from tests_py.scripts._marketplace_pins_legacy_replay import ( + LegacyGithubFetchers, + pre_fix_check_github_pin, + pre_fix_check_self_pin, +) from tests_py.scripts._marketplace_pins_test_loader import gate, git_repo_with_tags @@ -74,16 +79,22 @@ def test_untagged_repo_no_crash_no_flag(self): self.assertEqual(gate.check_self_pin("p", "./", "1.0.0", root), []) def test_pin_ahead_of_every_tag_is_unpublished_not_silently_current(self): - """The generic form of the cortex-viz 3.0.0 incident, on a self-pin. - - A pin newer than the latest tag used to read as "current" (the old - check only ever asked "is pin < latest?"). It never asked "does a - tag matching pin exist at all?" — so a version nobody ever tagged - passed silently. This is the exact defect PIN_VERSION_UNPUBLISHED - closes, replayed against check_self_pin instead of check_github_pin. + """The generic form of the cortex-viz 3.0.0 incident, on a self-pin — + replayed through BOTH the frozen pre-fix `check_self_pin` and the + current one on identical inputs, not just asserted in prose. """ with TemporaryDirectory() as d: root = git_repo_with_tags(d, ["v2.8.0", "v2.7.1", "v2.7.0"]) + + # BEFORE: a pin newer than the latest tag read as "current" (the + # old check only ever asked "is pin < latest?"; it never asked + # "does a tag matching pin exist at all?"). + pre_fix_failures = pre_fix_check_self_pin("p", "./", "3.0.0", root) + self.assertEqual( + pre_fix_failures, [], "the historical defect: silently current" + ) + + # AFTER: this is the exact defect PIN_VERSION_UNPUBLISHED closes. failures = gate.check_self_pin("p", "./", "3.0.0", root) joined = "\n".join(failures) self.assertIn("PIN_VERSION_UNPUBLISHED", joined) @@ -135,12 +146,30 @@ def test_current_pin_passes(self): ) def test_incident_replay_pin_ahead_of_every_release_is_unpublished(self): - """The exact cortex-viz incident: pin "3.0.0", repo's real tags top - out at v2.8.0. `3.0.0 < 2.8.0` is false, so the pre-fix check (which - only ever asked "is pin behind latest?") read this as current and - returned (None, None) — six days of a dangling pin, zero red runs. + """The exact cortex-viz incident, replayed through BOTH the frozen + pre-fix logic and the current one on the identical historical + inputs — not just asserted in prose. Pin "3.0.0", repo's real tags + top out at v2.8.0. """ tags = ["v2.8.0", "v2.7.1", "v2.7.0", "v2.6.3"] + + # BEFORE: `3.0.0 < 2.8.0` is false, so the pre-fix check (which only + # ever asked "is pin behind latest?") read this as current — silent + # pass, six days of a dangling pin, zero red runs. + pre_fix_result = pre_fix_check_github_pin( + "hypermnesia-mcp-viz", + "cdeust/cortex-viz", + "3.0.0", + LegacyGithubFetchers(fetch=lambda r: "v2.8.0", count=lambda *a: 0), + ) + self.assertEqual( + pre_fix_result, + (None, None), + "the historical defect: silently current, not a red run", + ) + + # AFTER: the same inputs (repo's tags list rather than a single + # "latest" fetch — the new signature) through the current check. failure, notice = gate.check_github_pin( "hypermnesia-mcp-viz", "cdeust/cortex-viz", diff --git a/tests_py/scripts/test_check_marketplace_pins_dispatch.py b/tests_py/scripts/test_check_marketplace_pins_dispatch.py new file mode 100644 index 00000000..89a1a6d6 --- /dev/null +++ b/tests_py/scripts/test_check_marketplace_pins_dispatch.py @@ -0,0 +1,88 @@ +"""Tests for scripts/check_marketplace_pins.py — `_check_plugin_pin` source- +shape dispatch. Split out of test_check_marketplace_pins.py (issue: that +file crossed the 300-line §4.1 cap once this coverage was added); same +`gate` module instance via _marketplace_pins_test_loader.py. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from unittest import mock + +from tests_py.scripts._marketplace_pins_test_loader import gate + + +class TestPluginSourceDispatch(unittest.TestCase): + """`_check_plugin_pin` — the schema defines 5 `source` shapes (verified + live against json.schemastore.org/claude-code-marketplace.json), this + gate implements checkers for 2 (github, local-path). The other 3 + (npm/url/git-subdir) must fail loudly, never pass through silently — + a dispatcher returning ([], [], None) for a shape it does not recognize + is exactly the defect class this gate exists to close, reproduced here + against every schema-legal shape it does not check. + """ + + def _dispatch(self, source): + return gate._check_plugin_pin("p", "1.0.0", source, Path(".")) + + def test_npm_source_fails_loudly_not_silently(self): + failures, notices, primary = self._dispatch( + {"source": "npm", "package": "some-pkg", "version": "1.0.0"} + ) + self.assertTrue(failures, "npm source must not silently pass") + self.assertIn("UNVERIFIED_SOURCE_TYPE", failures[0]) + self.assertIn("npm", failures[0]) + self.assertIsNone(primary) + + def test_url_source_fails_loudly_not_silently(self): + failures, _, _ = self._dispatch( + {"source": "url", "url": "https://example.com/repo.git"} + ) + self.assertTrue(failures, "url source must not silently pass") + self.assertIn("UNVERIFIED_SOURCE_TYPE", failures[0]) + self.assertIn("url", failures[0]) + + def test_git_subdir_source_fails_loudly_not_silently(self): + failures, _, _ = self._dispatch( + {"source": "git-subdir", "url": "owner/repo", "path": "sub"} + ) + self.assertTrue(failures, "git-subdir source must not silently pass") + self.assertIn("UNVERIFIED_SOURCE_TYPE", failures[0]) + self.assertIn("git-subdir", failures[0]) + + def test_unknown_dict_shape_fails_loudly(self): + failures, _, _ = self._dispatch({"source": "ftp"}) + self.assertTrue(failures) + self.assertIn("UNRECOGNIZED_SOURCE", failures[0]) + + def test_non_string_non_dict_source_fails_loudly(self): + failures, _, _ = self._dispatch(42) + self.assertTrue(failures) + self.assertIn("UNRECOGNIZED_SOURCE", failures[0]) + + def test_none_source_fails_loudly(self): + failures, _, _ = self._dispatch(None) + self.assertTrue(failures) + self.assertIn("UNRECOGNIZED_SOURCE", failures[0]) + + def test_github_source_still_dispatches_to_its_checker(self): + # Regression guard for the refactor that split the github branch + # into _check_github_source_pin: a github source must NOT fall + # into the UNVERIFIED_SOURCE_TYPE/UNRECOGNIZED_SOURCE branches this + # test class exists to cover. No live network call: check_github_pin + # / check_pin_sha are stubbed at the module level for this test only. + with ( + mock.patch.object(gate, "check_github_pin", return_value=(None, None)), + mock.patch.object(gate, "check_pin_sha", return_value=(None, None)), + ): + failures, notices, primary = self._dispatch( + {"source": "github", "repo": "o/r", "sha": "a" * 40} + ) + self.assertEqual(failures, []) + self.assertEqual(notices, []) + self.assertIsNone(primary) + + +if __name__ == "__main__": + unittest.main() From b447b992f0ed58d15304b0847210b42b1629d96b Mon Sep 17 00:00:00 2001 From: cdeust Date: Mon, 10 Aug 2026 13:53:46 +0200 Subject: [PATCH 3/3] fix(marketplace): sequence registry publish after PyPI; pin zetetic-team-subagents sha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more items from review on #419. 1. REFUSED, with evidence — the request was to replace "ai-architect-mcp- codebase" with "automatised-pipeline" in check_marketplace_pins.py's docstring (now marketplace_pins_manifests.py), citing an AP canonical- drift job failure. Direct verification (curl raw.githubusercontent.com/ cdeust/Cortex/main/... + gh api against ai-architect-mcp-codebase's copy, both fetched and diffed this session) shows the OPPOSITE of the claim: Cortex main already reads "ai-architect-mcp-codebase" (the current name); AP's own copy still reads "automatised-pipeline" (the old one). Making the requested edit would reintroduce the exact half- rename the instruction itself warned against. AP's repo needs the fix, not Cortex's — out of scope for this PR. No change made here. 2. zetetic-team-subagents pin hardened the same way hypermnesia-mcp-viz already was: added `ref: "v2.37.0"` + `sha` (57a5723d..., verified via `gh api .../git/refs/tags/v2.37.0` and confirmed `identical` to `main` via the compare endpoint — not a dangling PR-head). 3. `publish-mcp-registry` re-sequenced: `needs: publish-pypi` (was `needs: build`, parallel to `publish-pypi` rather than after it). A registry entry naming a version before the PyPI package exists is the same PIN_VERSION_UNPUBLISHED-shaped defect this PR spends most of its diff closing, just pointed the other direction — nearly reintroduced it in the very job meant to fix the analogous drift. Verified against cortex-viz's own Release.yaml (`needs: [test, release]` on its publish-registry job, fetched and read this session), same rationale. Re-measured after: check_craftsmanship.py --base origin/main clean; check_marketplace_pins.py exits 0 live; actionlint clean on release.yml; ruff check/format clean repo-wide; tests_py/scripts/ 771 passed, 5 skipped (unchanged — no test asserts the two JSON/YAML-only edits' exact values, appropriately, since neither introduces new logic). Co-Authored-By: Claude Sonnet 5 --- .claude-plugin/marketplace.json | 4 +++- .github/workflows/release.yml | 13 ++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3373a6ff..66c66382 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -106,7 +106,9 @@ "name": "zetetic-team-subagents", "source": { "source": "github", - "repo": "cdeust/zetetic-team-subagents" + "repo": "cdeust/zetetic-team-subagents", + "ref": "v2.37.0", + "sha": "57a5723df4262c014f5b2a1992cbace0efa4429f" }, "description": "Zetetic agent team for Cortex — 97 genius reasoning patterns from history's greatest minds + 23 team specialists (architect, engineer, code-reviewer, refactorer, …), 78 skills, 26 commands, 42 tools, 20 hooks, under one epistemic standard none of them can bypass. Composes with Cortex memory: every agent recalls/remembers through the Cortex MCP and routes via /genius, /agent, /zetetic, /quality, /research commands.", "version": "2.37.0", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f2b4b12..6d0d943c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -452,7 +452,18 @@ jobs: # unlike the PAT or DNS auth alternatives the docs also describe. publish-mcp-registry: name: Publish to the MCP registry (best-effort compatibility) - needs: build + # `needs: publish-pypi`, NOT `build` — the registry's package-ownership + # verification (see server.json's `packages[].registryType: pypi`) + # resolves the version this job's `server.json` declares against what + # PyPI actually serves. `build` only builds the sdist/wheel; the PyPI + # publish itself happens in `publish-pypi`, a job that runs in + # parallel with this one when both only `need: build` — so a fast + # registry publish could name a version PyPI had not yet received, + # the exact "dangling pin, in reverse" shape this PR's own + # REGISTRY_VERSION_STALE check exists to catch (cortex-viz's own + # Release.yaml gates its registry job on `needs: [test, release]` for + # the identical reason — verified against that workflow, not assumed). + needs: publish-pypi if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest permissions: