diff --git a/.bestpractices.json b/.bestpractices.json index 1da781de..7e741691 100644 --- a/.bestpractices.json +++ b/.bestpractices.json @@ -99,7 +99,7 @@ "build_floss_tools_justification": "pip, uv, hatchling, pytest, ruff and pyright are all FLOSS, and the build runs on ubuntu-latest and windows-latest GitHub runners.", "test_status": "Met", - "test_justification": "An automated test suite of 6673 tests lives under tests_py/ (measured 2026-07-30 with 'pytest --collect-only -q'), covering core, handlers, infrastructure, integration, invariants and architecture: https://github.com/cdeust/Cortex/tree/main/tests_py", + "test_justification": "An automated test suite lives under tests_py/ (current size tracked by assets/badge-tests.svg, regenerated from a live 'pytest --collect-only -q'), covering core, handlers, infrastructure, integration, invariants and architecture: https://github.com/cdeust/Cortex/tree/main/tests_py", "test_invocation_status": "Met", "test_invocation_justification": "The whole suite runs with a single 'pytest' command, documented in CONTRIBUTING.md under Testing along with per-layer subsets: https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md", @@ -114,7 +114,7 @@ "test_policy_justification": "CONTRIBUTING.md carries an explicit, mandatory testing policy: new functionality ships with tests in the automated suite, a bug fix carries a regression test that fails on the pre-fix code, and each failure path asserts its observable effect including the signal it emits \u2014 https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#the-testing-policy-mandatory. The per-tool checklist ('Add a unit test', 'Add an integration test if the tool touches the database') and the five-element mechanism checklist restate it per change type.", "tests_are_added_status": "Met", - "tests_are_added_justification": "New functionality ships with its tests; the suite has grown to 6673 tests alongside the v4.x feature series, and CI runs it on every pull request.", + "tests_are_added_justification": "New functionality ships with its tests; the suite has grown continuously alongside the v4.x feature series (current size: assets/badge-tests.svg), and CI runs it on every pull request.", "tests_documented_added_status": "Met", "tests_documented_added_justification": "The requirement is written into the documented instructions for change proposals: https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#the-testing-policy-mandatory states that every change adding or altering observable behaviour must arrive with tests in the same PR, the per-tool and per-mechanism checklists repeat it as a concrete step, and .github/PULL_REQUEST_TEMPLATE.md requires a Test plan section in every pull request.", @@ -189,7 +189,7 @@ "static_analysis_often_justification": "CodeQL default setup analyses each push and pull request and additionally runs on a weekly schedule, so analysis happens per change rather than per release.", "dynamic_analysis_status": "Unmet", - "dynamic_analysis_justification": "No dynamic analysis tool in the badge's sense (fuzzer, sanitizer, or scanner) is applied. The project runs a 6673-test suite with coverage on every change, but a test suite is not a dynamic analysis tool and is not claimed as one here.", + "dynamic_analysis_justification": "No dynamic analysis tool in the badge's sense (fuzzer, sanitizer, or scanner) is applied. The project runs its automated test suite with coverage on every change, but a test suite is not a dynamic analysis tool and is not claimed as one here.", "dynamic_analysis_unsafe_status": "N/A", "dynamic_analysis_unsafe_justification": "Cortex is written in Python, a memory-safe language, so the memory-safety tooling this criterion asks about (ASan, Valgrind) does not apply.", @@ -297,7 +297,7 @@ "interfaces_current_justification": "The stack targets currently supported runtimes and APIs: Python 3.10 through 3.13, all four in the CI matrix, with pgvector 0.3+/PostgreSQL 17 and current major versions of FastMCP, Pydantic v2, numpy and sentence-transformers. Deprecated interfaces are removed rather than wrapped \u2014 the standing rule is one-shot migrations with no back-compat shims (CLAUDE.md), and CI runs on the newest released Python so a deprecation surfaces as a warning in the build rather than as a surprise at end of life.", "automated_integration_testing_status": "Met", - "automated_integration_testing_justification": "The full suite runs on every push and pull request to main via .github/workflows/ci.yml and reports success or failure per job: 6673 tests on Python 3.10-3.13 against PostgreSQL + pgvector, the same suite against the SQLite backend, the suite on Windows, and a Docker smoke job that boots the bare container and exercises the DB-less contract. tests_py/integration/ holds the database-backed integration tests specifically.", + "automated_integration_testing_justification": "The full suite runs on every push and pull request to main via .github/workflows/ci.yml and reports success or failure per job: the current-sized suite (assets/badge-tests.svg) on Python 3.10-3.13 against PostgreSQL + pgvector, the same suite against the SQLite backend, the suite on Windows, and a Docker smoke job that boots the bare container and exercises the DB-less contract. tests_py/integration/ holds the database-backed integration tests specifically.", "regression_tests_added50_status": "Met", "regression_tests_added50_justification": "Measured on 2026-07-27 over the merged pull requests titled as fixes in the preceding six months (2026-01-27 onward): 23 of 30 \u2014 76.7% \u2014 changed the test tree in the same PR, against the 50% this criterion asks for. (The measure counts a PR as carrying tests when it touches tests_py/ or tests_js/, which is a proxy for 'added a regression test'; the policy behind it is written down at https://github.com/cdeust/Cortex/blob/main/CONTRIBUTING.md#the-testing-policy-mandatory \u2014 a bug fix carries a regression test that fails on the pre-fix code.)", diff --git a/CHANGELOG.md b/CHANGELOG.md index d8f1aee5..a183e164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ adheres to [Semantic Versioning](https://semver.org/). - **`scripts/setup.sh` reported success over any install failure**: the dependency install ended in `2>/dev/null`, which is where a resolution failure, a hash mismatch and a network error all appear, and the script printed "Python packages installed" regardless. Its exit status is now checked. Its hand-written package list — a duplicate of `pyproject.toml` — had already drifted from it, asking for `sentence-transformers>=2.2.0` against a real floor of `>=3.0.0`, and is replaced by the generated hashed file. - **`.gitattributes` marks `fuzz/corpus/**` as binary**: end-of-line normalisation would have rewritten the CRLF corpus seed to LF on checkout, silently deleting the case that seed exists to cover. - **`.bestpractices.json` was committed carrying four unresolved merge-conflict blocks**, which left it invalid JSON — and the file is transcribed into the OpenSSF Best Practices questionnaire, so an unparseable copy is a broken consumer rather than a stale number. It passed the doc-claim gate, CodeQL and 18 green checks, because `.bestpractices.json` is one of that gate's own `SCANNED_FILES` and every check it runs is a claim regex: a regex matches the first side of a conflict and never looks at the file's structure. Both sides of all four blocks were byte-identical, so the repair is lossless (verified by comparing the sides, not by choosing one). The gate now also runs `check_no_conflict_markers` and `check_scanned_json_parses`, both derived from `SCANNED_FILES` so a newly scanned file is enrolled with no further edit, and both failing closed on a file they cannot read. Only the **labelled** markers are matched (`<<<<<<< HEAD`, `>>>>>>> origin/main`) — a bare `=======` is a legal setext H1 underline in Markdown, and most scanned files are Markdown, so matching it would fail honest documents; a test pins that. +- **Any two PRs that added tests conflicted on six files, by construction — eliminated at the root** (#293). The collected test count was hand-carried as an exact figure in `.bestpractices.json`, `CLAUDE.md`, `CONTRIBUTING.md` (×2), `README.md` (×2) and `docs/ASSURANCE-CASE.md`, each checked for EQUALITY against whichever branch's own live `pytest --collect-only` count ran in CI. That count is a property of the post-merge tree, not of any one branch: two branches that each add tests compute two different, both-true numbers and must each edit the same six lines to match, so the second to merge silently overwrites the first's correct figure with its own now-stale one — measured on this repo as two red `main` runs (PR #280 synced to its own total, #278 added more tests against a stale base) and a PR rebased three times solely to resolve the resulting conflicts. `assets/badge-tests.svg` is now the ONLY artifact stating an absolute count; the five others point at it instead of restating the figure. The badge's own check moves from an exact match to a monotone **floor** (`doc_claim_structural.check_badge_floor`, `generate_repo_badges.stale_tests_badge`): a committed count that lags the live one is stale-but-true and passes, so a PR that only adds tests never touches it, and only an actual OVER-claim — a hand-typed number, or tests removed below what was claimed — fails. A standing regression guard (`test_no_prose_file_states_the_suite_size_any_more`) asserts no scanned file, including `.bestpractices.json`, states this claim in prose again. `check_doc_claims.py` (420 lines) and `generate_repo_badges.py` (305 lines) were both over the repo's 300-line file cap before this change needed to touch them further; split into `doc_claim_sources.py`/`doc_claim_scan.py`/`doc_claim_structural.py`/`repo_badge_catalog.py` (Extract Module) with zero behavior change, verified by an unchanged existing test suite before the floor logic was added. ### Added - **The README's repo-derived badges are now self-hosted SVGs, gated in CI** — `assets/badge-{license,python,tests,references,version}.svg`, `scripts/generate_repo_badges.py`, `scripts/badge_render.py`. Five hotlinked `img.shields.io` images became committed files, for the reason the MCP Toplist badge already was one: a remote badge URL is a third-party request fired on every README view, and it lets its host restate our claim with no commit in this repository. **Only repo-derived figures were converted**, and the line is deliberate — every one of these (licence, Python floor, collected test count, bibliography size, package version) is determined BY THIS REPOSITORY, so a committed copy can always be made true again from the working tree with no network access. That is why they are kept honest by a **blocking `--check` gate on every push and PR** rather than by a cron: drift is caught where it is introduced. **Two badges were deliberately NOT converted and must not be:** the CI status badge reports the LIVE result of the last run on main, so a committed copy would assert "passing" while main was broken — a static build-status badge is a false claim by construction, not merely a stale one — and it is GitHub-hosted, so it is not a third-party beacon in the first place; the OpenSSF Best Practices badge reflects an external body's live assessment that can be downgraded without any commit here, and `.bestpractices.json` separately justifies displaying THEIR badge image. The MCP Toplist badge sits between the two and stays committed because it carries an explicit "as of " stamp, which keeps a stale copy a true statement about a point in time. **The conversion had to rewire the gate it would otherwise have silenced:** `check_doc_claims.py` enforced the version and test-count claims by regex over the shields.io URLs (`badge/version-(\d+\.\d+\.\d+)`, `badge/tests-(\d+)_passing`), so self-hosting alone would have left both patterns matching nothing while the gate still reported success. They now read the figure out of the committed SVG's own `` and **fail closed** on a missing file or an unmatched title — the predecessor's `if badge and ...` passed silently the moment its subject disappeared. A new check also fails any reintroduced `img.shields.io` hotlink in the README, so reverting this is loud rather than quiet. Found by the new tests before shipping: `--` is illegal inside an XML comment, and the first cut described its own gate as `--check` and its source as `pytest --collect-only` in the provenance comment, leaving **all five badges unparseable**; the renderer now parses every badge it produces and refuses to return one that is not well-formed, so no future wording can reintroduce the class. The shared renderer was extracted from the MCP Toplist refresher first, as a separate behavior-preserving commit whose proof is that `assets/badge-mcp-toplist.svg` is byte-identical and its 51 tests pass unchanged. Suite grows 6348 → 6373. diff --git a/CLAUDE.md b/CLAUDE.md index d427d008..7977af11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ coding write gates, causal graphs, and intent-aware retrieval. resolving from the `pyproject.toml` ranges instead gives you versions CI never had (issue #253). - Environment preflight: `python -m mcp_server.doctor` (backend-aware check list, fix message per check) -- Tests: `pytest` (full suite, 6673 tests) · `pytest tests_py/core/` (one layer) · `pytest --cov=mcp_server --cov-report=term-missing` +- Tests: `pytest` (full suite — current count: `assets/badge-tests.svg`, or run `pytest --collect-only -q`) · `pytest tests_py/core/` (one layer) · `pytest --cov=mcp_server --cov-report=term-missing` - Lint BEFORE every commit: `ruff check && ruff format --check` — the CI enforces **both**; passing only `ruff check` is not enough. - Type gate (pyright, zero-diagnostic): resolve its environment from `uv.lock` (`uv sync --no-default-groups --extra … --group typecheck`), never from the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08729804..83ed692e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ bash scripts/setup.sh # macOS / Linux # Verify everything is wired uvx --python 3.13 --from "hypermnesia-mcp[postgresql]" cortex-doctor -# Run tests (6673 tests under tests_py/) +# Run tests (under tests_py/; current count: assets/badge-tests.svg) pytest # Run a benchmark @@ -163,7 +163,7 @@ The full standard lives in ## Testing ```bash -pytest # full suite (6673 tests) +pytest # full suite (see assets/badge-tests.svg for the current count) pytest tests_py/core # core (pure business logic) only pytest tests_py/integration # PostgreSQL-backed integration pytest tests_py/benchmarks -k locomo # subset diff --git a/README.md b/README.md index 02f692d9..08cf7991 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ <a href="https://github.com/cdeust/Cortex/actions/workflows/ci.yml"><img src="https://github.com/cdeust/Cortex/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <a href="LICENSE"><img src="assets/badge-license.svg" alt="License: MIT"></a> <img src="assets/badge-python.svg" alt="Python 3.10+"> - <img src="assets/badge-tests.svg" alt="6673 tests passing"> + <img src="assets/badge-tests.svg" alt="tests passing"> <img src="assets/badge-references.svg" alt="97 referenced papers"> <img src="assets/badge-version.svg" alt="Version 4.16.0"> <a href="https://www.bestpractices.dev/projects/13836"><img src="https://www.bestpractices.dev/projects/13836/badge" alt="OpenSSF Best Practices"></a> @@ -526,7 +526,7 @@ Cortex is **local-first**: your memories, conversations, and profiles stay on yo ## Development ```bash -pytest # 6673 tests +pytest # full suite (see assets/badge-tests.svg for the current count) ruff check . # Lint ruff format --check . # Format python scripts/check_doc_claims.py # advertised counts must match the repo diff --git a/docs/ASSURANCE-CASE.md b/docs/ASSURANCE-CASE.md index 21f77b30..ffee1238 100644 --- a/docs/ASSURANCE-CASE.md +++ b/docs/ASSURANCE-CASE.md @@ -128,8 +128,9 @@ decoratively: Standing analysis: CodeQL default setup (Python, JavaScript/TypeScript, Actions) on every push and pull request plus weekly, currently 0 open alerts; -OpenSSF Scorecard via `.github/workflows/scorecard.yml`; and 6673 tests run on -four Python versions, two backends and Windows. +OpenSSF Scorecard via `.github/workflows/scorecard.yml`; and the full automated +test suite (`assets/badge-tests.svg` carries the current count) runs on four +Python versions, two backends and Windows. ## 6. What this assurance case does NOT claim diff --git a/pyproject.toml b/pyproject.toml index fd91a578..0da36583 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -518,11 +518,18 @@ only_mutate = ["mcp_server/shared/json_native.py"] # .github/workflows/ci.yml directly; without it, a scoped run that selects # that test fails collection under every mutant with FileNotFoundError # instead of scoring the suite — issue #262. +# assets: check_doc_claims.py's own unpatched RepositoryTests read the +# committed badge SVGs (check_versions -> check_badge) directly off disk; +# without this a scoped run that includes check_doc_claims.py/ +# generate_repo_badges.py fails EVERY mutant on "assets/badge-version.svg: +# missing" before it can score a single one — reproduced empirically +# (issue #293) attempting exactly that run. also_copy = [ ".github", "tests_py", "deps", "docs", + "assets", "README.md", "CONTRIBUTING.md", "SECURITY.md", diff --git a/scripts/check_doc_claims.py b/scripts/check_doc_claims.py index 877c84bd..9aa1900a 100644 --- a/scripts/check_doc_claims.py +++ b/scripts/check_doc_claims.py @@ -20,7 +20,18 @@ 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 ``--test-count``, from a live ``pytest --collect-only`` +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 + count any more: a PR that adds tests would otherwise + have to hand-edit six files to the same new number, + and any two such PRs conflict on every one of them BY + CONSTRUCTION. The badge is also not an exact fact — + it is checked as a monotone FLOOR (``committed <= + live``), because the true count is a property of the + post-merge tree that no single branch can compute in + advance; only an OVER-claim is reported. See + ``doc_claim_structural.check_badge_floor``. =================== ===================================================== Release history is exempt: a line describing v4.13.0 may legitimately say @@ -39,6 +50,15 @@ python scripts/check_doc_claims.py # static claims python scripts/check_doc_claims.py --test-count 5571 + +Split across scripts/doc_claim_sources.py (canonical readers), +scripts/doc_claim_scan.py (claim scanning/comparison) and +scripts/doc_claim_structural.py (badge + structural-integrity checks) — +issue #293, Extract Function/Move Function — to stay under the repo's +300-line file cap (CLAUDE.md, Code Style); this module is the thin +orchestrator each of those forwards through, and the only place ``read``/ +``SCANNED_FILES`` are defined (tests patch them here; see each sibling +module's docstring for why they take these as parameters instead). """ from __future__ import annotations @@ -47,11 +67,21 @@ import json import re import sys -from collections.abc import Iterator from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent +# Sibling modules, path-imported for the same reason generate_repo_badges.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_scan # noqa: E402 +import doc_claim_sources # noqa: E402 +import doc_claim_structural # noqa: E402 +from doc_claim_sources import ClaimError # noqa: E402 (re-export) + # Files whose numbers describe the present. Release history lives elsewhere # (CHANGELOG.md, docs/release-notes/) and is deliberately not scanned. SCANNED_FILES = ( @@ -74,17 +104,6 @@ ".bestpractices.json", ) -# A line introducing a past release states that release's numbers. -HISTORY_MARKER = re.compile(r"\*\*v\d+\.\d+\.\d+") - -# A line whose number counts something else declares which family it is not a -# claim for. Rewording the prose to dodge a pattern would hide a true, measured -# number to keep the gate quiet; declaring it keeps the number and puts the -# exemption on the record, at the one site that knows why it is not a claim. -# The label must match a claim family exactly — an unrecognised or misspelled -# label exempts nothing, so the marker fails closed. -NOT_A_CLAIM = re.compile(r"\[not-a-count-claim: ([a-z][a-z ]*)\]") - TOOL_CLAIM = re.compile(r"(\d+)\s+(?:memory|standalone|MCP)\s+tools\b") TOOL_TOTAL_CLAIM = re.compile(r"\((\d+)\s+(?:total\s+)?with\b[^)]*\)") REFERENCE_CLAIM = re.compile(r"(\d+)[-\s]reference\b") @@ -92,171 +111,58 @@ r"(\d+)\s+(?:neuroscience[- ]grounded|neuroscience|biological|brain)?" r"\s*mechanisms\b" ) -# Both the "N tests" and the "N-test suite" phrasings state the count; matching -# only the first let a stale number sit unread in .bestpractices.json. +# Both the "N tests" and the "N-test suite" phrasings state the count. No +# scanned file states this claim in prose any more (issue #293 — see the +# module docstring's "test count" row); the pattern stays defined because +# it is still the generic worked example scan_claims/check_counts's own +# tests exercise, and tests_py/scripts/test_check_doc_claims.py asserts its +# absence from the real tree as a standing regression guard (a re-added +# hardcoded prose count would fail +# RepositoryTests.test_no_prose_file_states_the_suite_size). TEST_CLAIM = re.compile(r"(\d+)(?:\s+tests|-test suite)\b") -# The version and test badges are COMMITTED SVGs under assets/, not hotlinked -# shields.io URLs, so their figures are read out of the files' own <title>. -# These patterns replaced URL-shaped ones when the badges were self-hosted: -# had they been left matching "badge/version-X.Y.Z", they would have found -# nothing in the new README and both gates would have gone quiet while still -# reporting success. A gate that cannot find its subject must fail, not pass. -VERSION_BADGE = re.compile(r"<title>Version (\d+\.\d+\.\d+)") -TESTS_BADGE = re.compile(r"(\d+) tests passing") - -# Self-hosting the badges is only durable if reverting it is loud. Any -# reintroduced shields.io hotlink in the README is a third-party beacon AND -# silently detaches whichever claim it carries from the checks below. -SHIELDS_HOTLINK = re.compile(r"img\.shields\.io") - -# An unresolved merge conflict inside a scanned file states BOTH sides of a -# claim at once, so every check above reads a file that no longer says one -# thing. This is not hypothetical: `.bestpractices.json` was committed with -# four such blocks (branch sec/pin-dependencies-and-fuzzing, commit c090278, -# found 2026-07-29) and shipped through the whole gate, because the claim -# regexes matched the first side and never looked at the file's structure. -# -# Matched on the labelled markers only (`<<<<<<< HEAD`, `>>>>>>> origin/main` -# — git always writes a ref after the seven characters). A bare `=======` is -# deliberately NOT matched: it is a legal setext H1 underline in Markdown, and -# half the scanned files are Markdown, so matching it would fail honest docs. -CONFLICT_MARKER = re.compile(r"^(?:<{7}|>{7}) \S") - -# source: structural — str.split(marker, 1) yields exactly (before, after) -# when the marker is present -_MARKER_SPLIT_PARTS = 2 - - -class ClaimError(Exception): - """A canonical source could not be read — the gate cannot run blind.""" - def read(relative_path: str) -> str: + # encoding="utf-8" is pinned explicitly, never the platform default: + # every scanned Markdown file uses non-ASCII prose (em dashes, arrows), + # and a locale-dependent default can mis-decode them on a non-UTF-8- + # default platform (Windows is in this project's own CI matrix) — see + # test_read_pins_utf8. "UTF-8" (verbatim uppercase) is a documented- + # equivalent spelling: codecs.lookup is case-insensitive (CPython + # Lib/encodings/aliases.py normalizes via .lower()), so it is the SAME + # codec, not a different one a wrong-encoding bug could reach. return (REPO_ROOT / relative_path).read_text(encoding="utf-8") def canonical_tool_counts() -> tuple[int, int]: - """(standalone, total) from the mcp-tools.md header, cross-checked. - - The header sentence is the single place the catalogue states the counts; - the pinned test name in tests_py/test_main.py carries the registry-derived - standalone number, so the two disagreeing means the catalogue drifted from - the server itself. - """ - header = read("docs/mcp-tools.md") - match = re.search( - r"(\d+)\s+standalone tools register unconditionally;" - r"\s*(\d+)\s+more[^(]*\((\d+)\s+total", - header, - ) - if not match: - raise ClaimError("docs/mcp-tools.md: standalone/total tool sentence not found") - standalone, extra, total = (int(g) for g in match.groups()) - if standalone + extra != total: - raise ClaimError(f"docs/mcp-tools.md: {standalone} + {extra} != {total}") - - pinned = re.search( - r"test_standalone_baseline_is_(\d+)_tools", read("tests_py/test_main.py") - ) - if not pinned: - raise ClaimError("tests_py/test_main.py: pinned tool-count test not found") - if int(pinned.group(1)) != standalone: - raise ClaimError( - f"docs/mcp-tools.md says {standalone} standalone tools, but the pinned " - f"registry test says {pinned.group(1)}" - ) - return standalone, total + return doc_claim_sources.canonical_tool_counts(read) def canonical_reference_count() -> int: - """Entries counted in the bibliography, which declares itself canonical.""" - body = read("docs/papers/bibliography.md").split("## References", 1) - if len(body) != _MARKER_SPLIT_PARTS: - raise ClaimError( - "docs/papers/bibliography.md: '## References' section not found" - ) - entries = [ - line - for line in body[1].splitlines() - if line.strip() and not line.startswith(("#", "---")) - ] - if not entries: - raise ClaimError("docs/papers/bibliography.md: no reference entries found") - return len(entries) + return doc_claim_sources.canonical_reference_count(read) def canonical_mechanism_count() -> int: - """The mechanism count declared in the bibliography header. - - Mechanisms are not machine-countable (they are implementations spread over - core modules), so one file declares the number and every other file must - agree with it. Changing the count is a one-line edit here plus whatever the - gate then reports as stale. - """ - match = MECHANISM_CLAIM.search(read("docs/papers/bibliography.md")) - if not match: - raise ClaimError("docs/papers/bibliography.md: no mechanism count declared") - return int(match.group(1)) + return doc_claim_sources.canonical_mechanism_count(read) def canonical_version() -> str: - match = re.search(r'^version\s*=\s*"([^"]+)"', read("pyproject.toml"), re.MULTILINE) - if not match: - raise ClaimError("pyproject.toml: [project].version not found") - return match.group(1) - - -def scannable_lines() -> Iterator[tuple[str, int, str]]: - """Every (file, line number, text) that describes the present.""" - for relative_path in SCANNED_FILES: - for number, line in enumerate(read(relative_path).splitlines(), start=1): - if not HISTORY_MARKER.search(line): - yield relative_path, number, line + return doc_claim_sources.canonical_version(read) def exemption_registry() -> list[tuple[str, int, str]]: """Every declared not-a-claim marker: (file, line, the family it exempts).""" - return [ - (path, number, match.group(1)) - for path, number, line in scannable_lines() - for match in NOT_A_CLAIM.finditer(line) - ] + return doc_claim_scan.exemption_registry(SCANNED_FILES, read) def scan_claims(pattern: re.Pattern[str], label: str) -> list[tuple[str, int, int]]: - """Every (file, line number, claimed value) that claims `label`. - - A line declaring ``[not-a-count-claim: