Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -380,14 +380,27 @@ jobs:
- name: Check linting
run: ruff check .

# Advertised counts (tools, references, mechanisms, version) must match
# the repository. Runs here — on every push and PR — because doc drift is
# Advertised counts (tools, references, mechanisms) must match the
# repository. Runs here — on every push and PR — because doc drift is
# introduced at commit time, not at release time: on 2026-07-27 README,
# CONTRIBUTING, CLAUDE.md and the MCPB manifest each advertised a
# different tool count, and nothing failed. Static only (no imports).
- name: Check documentation claims
run: python scripts/check_doc_claims.py

# [project].version in pyproject.toml against every one of the 14
# version sites across 11 files (manifests, plugin.jsons, the
# marketplace's metadata AND primary-entry version, the MCP registry
# package entry, the four textual occurrences in the version badge,
# and uv.lock's own root-package version). Two of these sites
# (server.json packages[0].version, marketplace.json metadata.version)
# were covered by nothing before this gate existed, and the one gate
# that came closest — marketplace-pins.yml — is path-filtered, so it
# sits outside ci-green and only fires on its weekly cron (issue #392).
# Static only (no imports), same reason as the doc-claim gate above.
- name: Check version surfaces
run: python scripts/check_version_surfaces.py

# The `ci-green` job below is the single status check branch protection
# names; the list of jobs it covers lives in its `needs:`. A job added
# to ci.yml but not to that list would run outside the gate and could
Expand Down
34 changes: 7 additions & 27 deletions scripts/check_doc_claims.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Doc-claim gate: the numbers the docs advertise must match the repository.

Cortex advertises counts in prose — tools, references, mechanisms, version,
Cortex advertises counts in prose — tools, references, mechanisms,
tests. Each one is a claim a reader can check, and each one drifts silently:
between 2026-07-12 and 2026-07-27 the tool count moved from 49 to 52 while
README, CONTRIBUTING and the MCPB manifest still said 50, 43 and 49, and
Expand All @@ -19,7 +19,6 @@
test_standalone_baseline_is_52_tools``
reference count entries counted in ``docs/papers/bibliography.md``
mechanism count the count declared in that bibliography's header
version ``[project].version`` in ``pyproject.toml``
test count ``assets/badge-tests.svg`` alone (issue #293) — the
one artifact that still states an absolute figure. No
prose file (nor ``.bestpractices.json``) states this
Expand All @@ -34,6 +33,12 @@
``doc_claim_structural.check_badge_floor``.
=================== =====================================================

Version-site coherence (``[project].version`` in ``pyproject.toml`` against
every manifest, plugin, and badge occurrence that restates it) is NOT this
gate's job any more — it moved to ``scripts/check_version_surfaces.py``,
which is a strict superset of what this gate's own ``check_versions`` used
to compare (issue #392).

Release history is exempt: a line describing v4.13.0 may legitimately say
"49 memory tools". Lines carrying a ``**vX.Y.Z`` marker, and files that are
history by nature (CHANGELOG, docs/release-notes/), are skipped.
Expand Down Expand Up @@ -64,7 +69,6 @@
from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path
Expand Down Expand Up @@ -177,29 +181,6 @@ def check_counts(pattern: re.Pattern[str], expected: int, label: str) -> list[st
return doc_claim_scan.check_counts(pattern, expected, label, SCANNED_FILES, read)


def check_versions(expected: str) -> list[str]:
"""The version in the packaging metadata, the badge and every manifest."""
failures: list[str] = []
for relative_path, key in (
("manifest.json", "version"),
("server.json", "version"),
("package.json", "version"),
):
actual = json.loads(read(relative_path)).get(key)
if actual != expected:
failures.append(
f"{relative_path}: version {actual!r}, pyproject says {expected!r}"
)
failures += doc_claim_structural.check_badge(
"assets/badge-version.svg",
doc_claim_structural.VERSION_BADGE,
expected,
"version",
read,
)
return failures


def check_no_hotlinked_badges() -> list[str]:
"""The README's repo-derived badges stay self-hosted."""
return doc_claim_structural.check_no_hotlinked_badges(read)
Expand All @@ -221,7 +202,6 @@ def collect_failures(test_count: int | None) -> list[str]:
failures += check_counts(TOOL_TOTAL_CLAIM, total, "tools with integrations")
failures += check_counts(REFERENCE_CLAIM, canonical_reference_count(), "references")
failures += check_counts(MECHANISM_CLAIM, canonical_mechanism_count(), "mechanisms")
failures += check_versions(canonical_version())
failures += check_no_hotlinked_badges()
failures += check_no_conflict_markers()
failures += check_scanned_json_parses()
Expand Down
257 changes: 257 additions & 0 deletions scripts/check_version_surfaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
"""Version-coherence gate: every version site in the repo must agree.

Three gates already touch a version number, and each trusts a DIFFERENT
canonical source:

- ``check_doc_claims.py``'s (now removed) ``check_versions`` compared
``manifest.json``/``server.json``/``package.json``/the version badge
against ``pyproject.toml``.
- ``check_marketplace_pins.py`` compares ``server.json``/``manifest.json``
against the marketplace's *own* primary pin (not pyproject.toml), and only
for the two files it happens to enumerate.
- ``tests_py/scripts/test_cross_host_manifests.py`` compares four more
manifests against ``package.json`` (not pyproject.toml either).

Two sites were covered by NOTHING: ``server.json``'s
``packages[0].version`` (the MCP-registry package entry, served to every
registry client) and ``.claude-plugin/marketplace.json``'s
``metadata.version``. A partial version bump can reach ``main`` and only be
caught by the weekly ``marketplace-pins`` cron, which is not part of the
``ci-green`` aggregate (issue #392).

This module is the single authoritative source: every version site is
compared against ONE canonical value, ``[project].version`` in
``pyproject.toml`` (via ``doc_claim_sources.canonical_version``, the same
reader ``check_doc_claims.py`` uses). ``SURFACES`` is DATA — a tuple of
no-argument-bound check callables built by ``functools.partial`` over three
small, pure functions (``_json_check``, ``_badge_check``,
``_uv_lock_check``/``_marketplace_primary_plugin_check``) — so adding a 15th
surface of an existing kind (another JSON file/key, another badge
occurrence) is a one-line data edit, never a new branch in
``check_version_surfaces`` itself (OCP).

Badge parsing reuses ``doc_claim_structural.check_badge`` verbatim (fails
closed on a missing file or an unmatched pattern) rather than reimplementing
it; the pyproject version regex is reused from ``doc_claim_sources`` rather
than re-derived here.

Usage::

python scripts/check_version_surfaces.py
"""

from __future__ import annotations

import json
import re
import sys
from collections.abc import Callable
from functools import partial
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent

# Sibling modules, path-imported for the same reason check_doc_claims.py does
# it: resolves identically whether this runs as a script or is loaded via
# importlib.util.spec_from_file_location from a test.
_SCRIPTS_DIR = str(Path(__file__).resolve().parent)
if _SCRIPTS_DIR not in sys.path:
sys.path.insert(0, _SCRIPTS_DIR)
import doc_claim_sources # noqa: E402
import doc_claim_structural # noqa: E402
from doc_claim_sources import ClaimError # noqa: E402 (re-export)

ReadFn = Callable[[str], str]
SurfaceCheck = Callable[[ReadFn, str], list[str]]


def read(relative_path: str) -> str:
# encoding="utf-8" pinned explicitly — see check_doc_claims.read's own
# docstring for why (non-ASCII prose, locale-dependent defaults).
return (REPO_ROOT / relative_path).read_text(encoding="utf-8")


def canonical_version() -> str:
return doc_claim_sources.canonical_version(read)


def _json_check(
path: str, keys: tuple[str | int, ...], label: str, read_fn: ReadFn, expected: str
) -> list[str]:
"""A version nested at `keys` inside a JSON file must match `expected`."""
try:
body = read_fn(path)
except FileNotFoundError:
return [f"{path}: missing — the version-surfaces gate reads it"]
try:
value: object = json.loads(body)
for key in keys:
value = value[key]
except (json.JSONDecodeError, KeyError, IndexError, TypeError) as error:
return [f"{path}: {label}: could not read a version there ({error})"]
if value != expected:
return [f"{path}: {label}: version {value!r}, pyproject says {expected!r}"]
return []


def _badge_check(
pattern: re.Pattern[str], label: str, read_fn: ReadFn, expected: str
) -> list[str]:
"""One of the four textual version occurrences in the version-badge SVG.

Delegates entirely to doc_claim_structural.check_badge — no badge parsing
lives in this module.
"""
return doc_claim_structural.check_badge(
"assets/badge-version.svg", pattern, expected, label, read_fn
)


# The badge is a generated SVG (scripts/generate_repo_badges.py) that states
# its version four times: the accessible aria-label, the <title> (reused
# from doc_claim_structural.VERSION_BADGE below), and a drop-shadow + solid
# <text> pair. Each occurrence can desync independently of the others (a
# hand-edit, or a future template change to only one of them), so each is
# its own surface rather than one "the badge" surface.
_BADGE_ARIA_LABEL = re.compile(r'aria-label="Version (\d+\.\d+\.\d+)"')
_BADGE_SHADOW_TEXT = re.compile(r'fill-opacity="0.25"[^>]*>(\d+\.\d+\.\d+)</text>')
_BADGE_SOLID_TEXT = re.compile(r'fill="#fff"[^>]*>(\d+\.\d+\.\d+)</text>')


def _marketplace_primary_plugin_check(read_fn: ReadFn, expected: str) -> list[str]:
"""The self-hosted (primary) marketplace plugin entry's own version.

Selection rule mirrors check_marketplace_pins.main's own primary-entry
predicate (`source.strip("/") in ("", ".")`) verbatim, rather than
re-deriving a second notion of "the primary plugin" that could disagree
with it.
"""
path = ".claude-plugin/marketplace.json"
label = "primary plugin entry version"
try:
data = json.loads(read_fn(path))
except (FileNotFoundError, json.JSONDecodeError) as error:
return [f"{path}: {label}: could not read the marketplace manifest ({error})"]
entries = [
plugin
for plugin in data.get("plugins", [])
if isinstance(plugin.get("source"), str)
and plugin["source"].strip("/") in ("", ".")
]
if len(entries) != 1:
return [
f"{path}: {label}: expected exactly one self-hosted plugin entry, "
f"found {len(entries)}"
]
value = entries[0].get("version")
if value != expected:
return [f"{path}: {label}: version {value!r}, pyproject says {expected!r}"]
return []


# The root package uv.lock resolved from this repo's own pyproject.toml
# (`source = { editable = "." }`).
_UV_LOCK_PACKAGE = "hypermnesia-mcp"


def _uv_lock_check(read_fn: ReadFn, expected: str) -> list[str]:
"""The root `hypermnesia-mcp` package block's own version.

uv.lock is TOML, but read line-by-line rather than parsed: this repo's
floor is Python 3.10, where `tomllib` does not exist — the same
rationale scripts/generate_pip_constraints.py's lock_registries uses for
the identical [[package]]/name/version scan.
"""
path = "uv.lock"
label = f"{_UV_LOCK_PACKAGE} package block"
name: str | None = None
version: str | None = None
for line in read_fn(path).splitlines():
if line.startswith("[[package]]"):
name, version = None, None
elif line.startswith('name = "'):
name = line.split('"')[1]
elif line.startswith('version = "'):
version = line.split('"')[1]
if name == _UV_LOCK_PACKAGE and version is not None:
break
else:
# The loop ran to completion without finding our package: whatever
# `version` last held belongs to a DIFFERENT package block.
version = None
if version is None:
return [f"{path}: {label}: no version found for {_UV_LOCK_PACKAGE!r}"]
if version != expected:
return [f"{path}: {label}: version {version!r}, pyproject says {expected!r}"]
return []


# One row per version site (14 sites across 11 files). Adding a 15th JSON
# site or badge occurrence is a one-line addition here; only a genuinely new
# FILE FORMAT (neither JSON, regex-matchable text, nor uv.lock's own shape)
# would need a new `_..._check` function alongside it.
SURFACES: tuple[SurfaceCheck, ...] = (
partial(_json_check, "package.json", ("version",), "version"),
partial(_json_check, "server.json", ("version",), "version"),
partial(
_json_check, "server.json", ("packages", 0, "version"), "packages[0].version"
),
partial(_json_check, "manifest.json", ("version",), "version"),
partial(_json_check, "gemini-extension.json", ("version",), "version"),
partial(_json_check, ".claude-plugin/plugin.json", ("version",), "version"),
partial(
_json_check,
".claude-plugin/marketplace.json",
("metadata", "version"),
"metadata.version",
),
_marketplace_primary_plugin_check,
partial(
_json_check,
"plugins/hypermnesia-mcp-codex/.codex-plugin/plugin.json",
("version",),
"version",
),
partial(_badge_check, _BADGE_ARIA_LABEL, "aria-label"),
partial(_badge_check, doc_claim_structural.VERSION_BADGE, "<title>"),
partial(_badge_check, _BADGE_SHADOW_TEXT, "shadow <text>"),
partial(_badge_check, _BADGE_SOLID_TEXT, "solid <text>"),
_uv_lock_check,
)


def check_version_surfaces(read_fn: ReadFn) -> list[str]:
"""Every version site's failures, against the canonical pyproject.toml one.

Raises ClaimError (uncaught) if pyproject.toml itself cannot be read —
the gate cannot run blind, the same contract check_doc_claims.py's
canonical readers use.
"""
expected = doc_claim_sources.canonical_version(read_fn)
failures: list[str] = []
for surface_check in SURFACES:
failures += surface_check(read_fn, expected)
return failures


def main() -> int:
try:
failures = check_version_surfaces(read)
except ClaimError as error:
print(f"version-surfaces gate could not run: {error}", file=sys.stderr)
return 2

if failures:
print("Version surfaces disagree with pyproject.toml:", file=sys.stderr)
for failure in failures:
print(f" {failure}", file=sys.stderr)
return 1
print(
f"version surfaces OK ({len(SURFACES)} site(s) checked, "
"all agree with pyproject.toml)"
)
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading