From 187e008e5c51e691c87ee43a1baad5844dd13f7a Mon Sep 17 00:00:00 2001 From: cdeust Date: Thu, 30 Jul 2026 11:29:47 +0200 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20Extract=20Module=20doc=5Fclaim?= =?UTF-8?q?=5Fsources/doc=5Fclaim=5Fscan/doc=5Fclaim=5Fstructural=20from?= =?UTF-8?q?=20check=5Fdoc=5Fclaims=20(=C2=A74.1=20300-line=20cap,=20issue?= =?UTF-8?q?=20#287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_doc_claims.py was 420 lines, over the repo's 300-line file cap (CLAUDE.md, Code Style) before this PR needed to add floor-check machinery to it (issue #287 — eliminating the six-file test-count conflict class). Boy-scout fix (§14): the file is touched by this series regardless, so its pre-existing size violation is fixed now rather than compounded. Split by cohesion, matching the module's own "Claim | Owner" docstring table: canonical truth-readers (doc_claim_sources.py), claim-scanning/ comparison machinery (doc_claim_scan.py), and badge/structural-integrity checks (doc_claim_structural.py). check_doc_claims.py keeps thin wrapper functions of the SAME names the test suite reaches via `gate.` — tests_py/scripts/test_check_doc_claims.py monkeypatches `gate.read` and `gate.SCANNED_FILES` and relies on every check_doc_claims function resolving those as bare (module-global) names to see the patch; moving a function's BODY to another module while it still called `read` as a bare name would silently stop seeing the test's fake and read the real filesystem instead. The extracted functions take `read_fn`/`scanned_files` as explicit parameters instead (constructor injection, coding-standards.md §5) and check_doc_claims.py's wrappers pass its own (patchable) `read`/ `SCANNED_FILES` through — a mechanical verification, not a guess: read the generated Python data model's attribute-resolution rules for module globals vs. imported names before choosing this shape. Before: check_doc_claims.py 420 lines (over cap). After: check_doc_claims.py 232 lines; doc_claim_sources.py 107; doc_claim_scan.py 131; doc_claim_structural.py 174 (all under 300). Tests: tests_py/scripts/test_check_doc_claims.py + test_generate_repo_badges.py — 86 passed, 30 subtests passed before AND after, 0 test files modified. Both scripts also re-verified as direct CLI invocations (`check_doc_claims.py --test-count 6609`, `generate_repo_badges.py --check --test-count 6609`), not just through the test harness's importlib loading. ruff check + ruff format --check: clean on all four files. Co-Authored-By: Claude --- scripts/check_doc_claims.py | 280 ++++++-------------------------- scripts/doc_claim_scan.py | 131 +++++++++++++++ scripts/doc_claim_sources.py | 107 ++++++++++++ scripts/doc_claim_structural.py | 180 ++++++++++++++++++++ 4 files changed, 464 insertions(+), 234 deletions(-) create mode 100644 scripts/doc_claim_scan.py create mode 100644 scripts/doc_claim_sources.py create mode 100644 scripts/doc_claim_structural.py diff --git a/scripts/check_doc_claims.py b/scripts/check_doc_claims.py index 877c84bd..3d4185c6 100644 --- a/scripts/check_doc_claims.py +++ b/scripts/check_doc_claims.py @@ -39,6 +39,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 #287, 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 +56,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 +93,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") @@ -96,167 +104,40 @@ # only the first let a stale number sit unread in .bestpractices.json. 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 . -# 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: 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: