From 822ac35ad932538bb10183e47000a15027264208 Mon Sep 17 00:00:00 2001 From: cdeust Date: Thu, 30 Jul 2026 20:24:21 +0200 Subject: [PATCH 1/2] fix(mutmut): dotted-load doc_claim_scan + doc_claim_structural's remaining functions + repo_badge_catalog for real mutation coverage (#292) check_doc_claims.py bare-imports its siblings doc_claim_scan and doc_claim_structural; generate_repo_badges.py bare-imports repo_badge_catalog. A function's __module__ is fixed at definition time to whatever name it was imported under, and mutmut's trampoline only activates a mutant when that matches the dotted, path-derived name it generated the mutant under -- so every mutant in these bare-imported siblings reported "no tests" under a scoped run, even though the functions are exercised by real passing tests through the bare-imported path (same defect class as badge_render.py/check_badge_floor, issues #262/#280/#293, and doc_claim_sources.py's own instance of it, already fixed on main by #304/issue #235). Fix: dotted-load each sibling under its own "scripts." spec (mirroring the existing check_badge_floor/doc_claim_sources precedent) and add direct test classes that call through those dotted references instead of through gate's/gen's bare-imported copies. Exact assertEqual on every message string and dict field (not assertIn) to kill the underlying string-literal mutants directly, plus targeted continue-vs- break and split/rsplit/maxsplit tests for the loop- and parsing-shaped mutants exact-match assertions alone don't reach. Verified with a real tally, not the absence of an error: a scoped mutmut run (scripts/mutation_check.sh) against doc_claim_structural.py, doc_claim_scan.py, doc_claim_sources.py, repo_badge_catalog.py and both test files reports "0 surviving mutants" (301 mutants, 301 killed, 0 "no tests", 0 survived). Full suite green (6919 passed, 5 skipped, 121 subtests), doc-claims and badge-floor gates green, ruff clean. Closes #292 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u --- tests_py/scripts/test_check_doc_claims.py | 387 ++++++++++++++++++ tests_py/scripts/test_generate_repo_badges.py | 107 +++++ 2 files changed, 494 insertions(+) diff --git a/tests_py/scripts/test_check_doc_claims.py b/tests_py/scripts/test_check_doc_claims.py index f654440a..96a995c2 100644 --- a/tests_py/scripts/test_check_doc_claims.py +++ b/tests_py/scripts/test_check_doc_claims.py @@ -64,6 +64,19 @@ doc_claim_sources = importlib.util.module_from_spec(_dcso_spec) _dcso_spec.loader.exec_module(doc_claim_sources) +# Same defect, same fix, for the one remaining sibling check_doc_claims.py +# bare-imports (`import doc_claim_scan`) — issue #292's remaining scope +# (doc_claim_sources.py above is already dotted-loaded by #304/issue #235; +# doc_claim_scan.py was not in that PR's acceptance criteria). Its functions +# take `scanned_files`/`read_fn` as explicit parameters (not module globals), +# so the direct-test class below needs no monkeypatching, only this dotted +# reference to call through. +_scan_spec = importlib.util.spec_from_file_location( + "scripts.doc_claim_scan", _SCRIPTS / "doc_claim_scan.py" +) +doc_claim_scan = importlib.util.module_from_spec(_scan_spec) +_scan_spec.loader.exec_module(doc_claim_scan) + CATALOGUE = ( "52 standalone tools register unconditionally; 3 more register only when an\n" @@ -758,6 +771,380 @@ def test_an_exact_match_is_not_reported(self): ) +class DocClaimScanDirectTests(unittest.TestCase): + """Direct tests of doc_claim_scan's claim-scanning machinery. + + ScanTests above exercises these only through gate's bare-imported copy — + see the dotted-load comment atop this file for why that carries zero + mutation coverage. `scanned_files`/`read_fn` are explicit parameters here + too, so each test builds its own tiny fixture and calls through the + dotted `doc_claim_scan` reference. + """ + + def _read(self, text: str) -> object: + return _FakeRepo(**{"DOC.md": text}).read + + def test_wrong_count_is_reported_with_file_and_line(self): + read_fn = self._read("intro\nExposes 50 memory tools today.\n") + failures = doc_claim_scan.check_counts( + gate.TOOL_CLAIM, 52, "tools", ("DOC.md",), read_fn + ) + self.assertEqual(len(failures), 1) + self.assertIn("DOC.md:2", failures[0]) + self.assertIn("advertises 50 tools", failures[0]) + + def test_matching_count_produces_no_failure(self): + read_fn = self._read("Exposes 52 memory tools today.\n") + self.assertEqual( + doc_claim_scan.check_counts( + gate.TOOL_CLAIM, 52, "tools", ("DOC.md",), read_fn + ), + [], + ) + + def test_release_history_lines_are_exempt(self): + read_fn = self._read( + "Exposes 52 memory tools today.\n" + "**v4.13.0 — grooming.** **49 memory tools** (52 with upstream).\n" + ) + self.assertEqual( + doc_claim_scan.check_counts( + gate.TOOL_CLAIM, 52, "tools", ("DOC.md",), read_fn + ), + [], + ) + + def test_a_pattern_that_matches_nothing_fails_instead_of_passing_vacuously(self): + read_fn = self._read("No numbers here.\n") + failures = doc_claim_scan.check_counts( + gate.TOOL_CLAIM, 52, "tools", ("DOC.md",), read_fn + ) + self.assertEqual(len(failures), 1) + self.assertIn("vacuously", failures[0]) + + def test_both_test_count_phrasings_state_the_same_claim(self): + read_fn = self._read("The suite has 5594 tests.\nCI runs a 5594-test suite.\n") + self.assertEqual( + doc_claim_scan.check_counts( + gate.TEST_CLAIM, 5594, "tests", ("DOC.md",), read_fn + ), + [], + ) + + def test_stale_hyphenated_test_count_is_reported(self): + read_fn = self._read("The suite has 5594 tests.\nCI runs a 5571-test suite.\n") + failures = doc_claim_scan.check_counts( + gate.TEST_CLAIM, 5594, "tests", ("DOC.md",), read_fn + ) + self.assertEqual(len(failures), 1) + self.assertIn("DOC.md:2", failures[0]) + self.assertIn("advertises 5571 tests", failures[0]) + + def test_a_count_of_test_files_is_not_a_suite_size_claim(self): + read_fn = self._read("The suite has 5594 tests.\nThere are 3 test files.\n") + self.assertEqual( + doc_claim_scan.check_counts( + gate.TEST_CLAIM, 5594, "tests", ("DOC.md",), read_fn + ), + [], + ) + + def test_a_declared_exemption_is_not_a_claim_for_that_family(self): + read_fn = self._read( + "The suite has 5594 tests.\n" + "12 tests skipped [not-a-count-claim: tests] locally\n" + ) + self.assertEqual( + doc_claim_scan.check_counts( + gate.TEST_CLAIM, 5594, "tests", ("DOC.md",), read_fn + ), + [], + ) + + def test_an_exemption_binds_only_the_family_it_names(self): + read_fn = self._read( + "The suite has 5594 tests.\n" + "Exposes 52 memory tools today.\n" + "Exposes 50 memory tools and 12 tests skipped [not-a-count-claim: tests]\n" + ) + self.assertEqual( + doc_claim_scan.check_counts( + gate.TEST_CLAIM, 5594, "tests", ("DOC.md",), read_fn + ), + [], + ) + failures = doc_claim_scan.check_counts( + gate.TOOL_CLAIM, 52, "tools", ("DOC.md",), read_fn + ) + self.assertEqual(len(failures), 1) + self.assertIn("advertises 50 tools", failures[0]) + + def test_a_misspelled_exemption_does_not_exempt(self): + for marker in ( + "[not-a-count-claim]", + "[not-a-count-claim: Tests]", + "[not a count claim: tests]", + "[not-a-count-claim: tools]", + "[not-a-count-claim:tests]", + ): + with self.subTest(marker=marker): + read_fn = self._read( + f"The suite has 5594 tests.\n12 tests {marker} here\n" + ) + failures = doc_claim_scan.check_counts( + gate.TEST_CLAIM, 5594, "tests", ("DOC.md",), read_fn + ) + self.assertEqual(len(failures), 1) + self.assertIn("advertises 12 tests", failures[0]) + + def test_the_test_count_guard_fires_when_only_an_exemption_remains(self): + read_fn = self._read("12 tests skipped [not-a-count-claim: tests] locally\n") + failures = doc_claim_scan.check_counts( + gate.TEST_CLAIM, 5594, "tests", ("DOC.md",), read_fn + ) + self.assertEqual(len(failures), 1) + self.assertIn("vacuously", failures[0]) + + def test_the_with_integrations_claim_has_a_vacuity_guard(self): + read_fn = self._read("no parenthetical here\n") + failures = doc_claim_scan.check_counts( + gate.TOOL_TOTAL_CLAIM, 55, "tools with integrations", ("DOC.md",), read_fn + ) + self.assertEqual(len(failures), 1) + self.assertIn("vacuously", failures[0]) + + def test_the_registry_records_file_line_and_family(self): + read_fn = self._read( + "The suite has 5594 tests.\n" + "12 tests skipped [not-a-count-claim: tests] locally\n" + "and 3 more [not-a-count-claim: tools with integrations]\n" + ) + self.assertEqual( + doc_claim_scan.exemption_registry(("DOC.md",), read_fn), + [("DOC.md", 2, "tests"), ("DOC.md", 3, "tools with integrations")], + ) + + def test_release_history_lines_declare_no_exemptions(self): + read_fn = self._read("**v4.13.0 — old.** 12 tests [not-a-count-claim: tests]\n") + self.assertEqual(doc_claim_scan.exemption_registry(("DOC.md",), read_fn), []) + + +class StructuralDirectTests(unittest.TestCase): + """Direct tests of doc_claim_structural's remaining functions. + + check_badge_floor already has CheckBadgeFloorDirectTests above; these + cover the other four (check_badge, check_no_hotlinked_badges, + check_no_conflict_markers, check_scanned_json_parses), which + CollectFailuresTests/StructuralIntegrityTests exercise only through + gate's bare-imported copy — real behavioural coverage, not mutation + coverage (issue #292). + """ + + def test_check_badge_missing_file_fails_closed(self): + read_fn = _FakeRepo().read + self.assertEqual( + doc_claim_structural.check_badge( + "assets/badge-version.svg", + doc_claim_structural.VERSION_BADGE, + "4.16.0", + "version", + read_fn, + ), + ["assets/badge-version.svg: missing — run scripts/generate_repo_badges.py"], + ) + + def test_check_badge_no_title_match_fails_closed(self): + read_fn = _FakeRepo(**{"assets/badge-version.svg": ""}).read + self.assertEqual( + doc_claim_structural.check_badge( + "assets/badge-version.svg", + doc_claim_structural.VERSION_BADGE, + "4.16.0", + "version", + read_fn, + ), + [ + "assets/badge-version.svg: no version figure in its ;" + " the badge and this gate have diverged" + ], + ) + + def test_check_badge_mismatch_is_reported(self): + read_fn = _FakeRepo( + **{"assets/badge-version.svg": "<title>Version 4.15.0"} + ).read + self.assertEqual( + doc_claim_structural.check_badge( + "assets/badge-version.svg", + doc_claim_structural.VERSION_BADGE, + "4.16.0", + "version", + read_fn, + ), + [ + "assets/badge-version.svg: version badge says 4.15.0," + " canonical is 4.16.0" + ], + ) + + def test_check_badge_match_is_not_reported(self): + read_fn = _FakeRepo( + **{"assets/badge-version.svg": "Version 4.16.0"} + ).read + self.assertEqual( + doc_claim_structural.check_badge( + "assets/badge-version.svg", + doc_claim_structural.VERSION_BADGE, + "4.16.0", + "version", + read_fn, + ), + [], + ) + + def test_hotlinked_badge_is_reported(self): + read_fn = _FakeRepo( + **{"README.md": '\n'} + ).read + self.assertEqual( + doc_claim_structural.check_no_hotlinked_badges(read_fn), + [ + "README.md:1: hotlinked shields.io badge — these are" + " committed under assets/ (scripts/generate_repo_badges.py)" + ], + ) + + def test_no_hotlink_reports_nothing(self): + read_fn = _FakeRepo( + **{"README.md": '\n'} + ).read + self.assertEqual(doc_claim_structural.check_no_hotlinked_badges(read_fn), []) + + def test_conflict_markers_are_reported_with_path_and_line(self): + read_fn = _FakeRepo( + **{ + ".bestpractices.json": ( + '{\n<<<<<<< HEAD\n"a": 1\n=======\n"a": 2\n>>>>>>> theirs\n}\n' + ) + } + ).read + failures = doc_claim_structural.check_no_conflict_markers( + (".bestpractices.json",), read_fn + ) + self.assertEqual( + failures, + [ + ".bestpractices.json:2: unresolved merge conflict marker" + " ('<<<<<<< HEAD') — the file states both sides of its claims", + ".bestpractices.json:6: unresolved merge conflict marker" + " ('>>>>>>> theirs') — the file states both sides of its claims", + ], + ) + + def test_a_clean_file_reports_no_conflict_markers(self): + read_fn = _FakeRepo(**{"README.md": "# Cortex\n\nAll good.\n"}).read + self.assertEqual( + doc_claim_structural.check_no_conflict_markers(("README.md",), read_fn), [] + ) + + def test_a_markdown_setext_underline_is_not_a_conflict_marker(self): + read_fn = _FakeRepo( + **{"docs/ROADMAP.md": "Roadmap\n=======\n\nNext up.\n"} + ).read + self.assertEqual( + doc_claim_structural.check_no_conflict_markers( + ("docs/ROADMAP.md",), read_fn + ), + [], + ) + + def test_a_missing_scanned_file_fails_closed_for_conflict_markers(self): + read_fn = _FakeRepo().read + self.assertEqual( + doc_claim_structural.check_no_conflict_markers(("GONE.md",), read_fn), + ["GONE.md: missing — the doc-claim gate reads it"], + ) + + def test_a_missing_file_does_not_stop_the_scan_of_the_rest(self): + """Pins `continue` over `break` on the FileNotFoundError arm: a + missing file must not cut the loop short and hide a conflict + marker in a LATER scanned file.""" + read_fn = _FakeRepo(**{"SECOND.md": "ok\n<<<<<<< HEAD\nmore\n"}).read + failures = doc_claim_structural.check_no_conflict_markers( + ("GONE.md", "SECOND.md"), read_fn + ) + self.assertEqual( + failures, + [ + "GONE.md: missing — the doc-claim gate reads it", + "SECOND.md:2: unresolved merge conflict marker" + " ('<<<<<<< HEAD') — the file states both sides of its claims", + ], + ) + + def test_unparseable_json_is_reported(self): + read_fn = _FakeRepo( + **{".bestpractices.json": '{"a": 1,\n<<<<<<< HEAD\n}\n'} + ).read + failures = doc_claim_structural.check_scanned_json_parses( + (".bestpractices.json",), read_fn + ) + self.assertEqual(len(failures), 1, failures) + self.assertTrue( + failures[0].startswith(".bestpractices.json: not valid JSON — ") + ) + + def test_valid_json_reports_nothing(self): + read_fn = _FakeRepo(**{".bestpractices.json": '{"test_status": "Met"}\n'}).read + self.assertEqual( + doc_claim_structural.check_scanned_json_parses( + (".bestpractices.json",), read_fn + ), + [], + ) + + def test_markdown_is_not_json_checked(self): + read_fn = _FakeRepo(**{"README.md": "{ this is prose, not JSON\n"}).read + self.assertEqual( + doc_claim_structural.check_scanned_json_parses(("README.md",), read_fn), [] + ) + + def test_a_non_json_file_does_not_stop_the_scan_of_the_rest(self): + """Pins `continue` over `break` on the non-`.json` skip: a + Markdown file earlier in `scanned_files` must not cut the loop + short and hide a LATER json file's parse error.""" + read_fn = _FakeRepo(**{"README.md": "prose\n", "bad.json": '{"a": 1,\n'}).read + failures = doc_claim_structural.check_scanned_json_parses( + ("README.md", "bad.json"), read_fn + ) + self.assertEqual(len(failures), 1, failures) + self.assertTrue(failures[0].startswith("bad.json: not valid JSON — ")) + + def test_a_missing_scanned_json_file_fails_closed(self): + """The FileNotFoundError branch: not exercised anywhere before this + direct test (a genuine gap, not a mutation-attribution artifact — + found while adding this class, fixed here rather than deferred).""" + read_fn = _FakeRepo().read + self.assertEqual( + doc_claim_structural.check_scanned_json_parses(("GONE.json",), read_fn), + ["GONE.json: missing — the doc-claim gate reads it"], + ) + + def test_a_missing_json_file_does_not_stop_the_scan_of_the_rest(self): + """Pins `continue` over `break` on the JSON FileNotFoundError arm: + a missing json file must not cut the loop short and hide a LATER + file's parse error.""" + read_fn = _FakeRepo(**{"bad.json": '{"a": 1,\n'}).read + failures = doc_claim_structural.check_scanned_json_parses( + ("gone.json", "bad.json"), read_fn + ) + self.assertEqual(len(failures), 2, failures) + self.assertEqual( + failures[0], "gone.json: missing — the doc-claim gate reads it" + ) + self.assertTrue(failures[1].startswith("bad.json: not valid JSON — ")) + + class RepositoryTests(unittest.TestCase): """The gate must pass on the tree it ships with — this is the gate itself.""" diff --git a/tests_py/scripts/test_generate_repo_badges.py b/tests_py/scripts/test_generate_repo_badges.py index f329713b..273f6980 100644 --- a/tests_py/scripts/test_generate_repo_badges.py +++ b/tests_py/scripts/test_generate_repo_badges.py @@ -57,6 +57,18 @@ sys.modules[_badge_render_spec.name] = badge_render _badge_render_spec.loader.exec_module(badge_render) +# Same defect, same fix, for generate_repo_badges.py's other bare-imported +# sibling (`import repo_badge_catalog`): StaleTestsBadgeTests below already +# calls `gen.repo_badge_catalog.tests_badge_spec` through the bare-cached +# copy (real behavioural coverage only). RepoBadgeCatalogDirectTests calls +# through this dotted reference instead, so its mutants attribute to a test +# (issue #292). +_catalog_spec = importlib.util.spec_from_file_location( + "scripts.repo_badge_catalog", _SCRIPTS / "repo_badge_catalog.py" +) +repo_badge_catalog = importlib.util.module_from_spec(_catalog_spec) +_catalog_spec.loader.exec_module(repo_badge_catalog) + PYPROJECT = ( '[project]\nversion = "4.16.0"\nlicense = "MIT"\nrequires-python = ">=3.10"\n' ) @@ -585,6 +597,101 @@ def test_a_non_integer_test_count_is_rejected_not_stored_as_text(self): self.assertIn("--test-count", err.getvalue()) +class RepoBadgeCatalogDirectTests(unittest.TestCase): + """Direct tests of repo_badge_catalog's field specs (issue #292). + + BuildTests/StaleTestsBadgeTests above exercise `fixed_badge_specs`/ + `tests_badge_spec` only through gen's bare-imported copy — real + behavioural coverage, not mutation coverage, for the same reason + badge_render's direct tests exist above. + """ + + def _fixed_specs_by_filename(self) -> dict[str, dict[str, str]]: + """Exact equality is asserted per-badge below (not one 40+-line + four-badge literal — coding-standards.md §4.2's method cap) — a + partial assertion (message only, or one field at a time) leaves + every other string literal and dict key unpinned, and each is its + own mutmut mutant (issue #292's own repro: 37 survivors on this + function alone before these tests existed).""" + return { + s["filename"]: s + for s in repo_badge_catalog.fixed_badge_specs( + licence="MIT", floor="3.10", references=2, version="4.16.0" + ) + } + + def test_exactly_four_fixed_specs_are_returned(self): + self.assertEqual(len(self._fixed_specs_by_filename()), 4) + + def test_license_spec_is_exactly_this_dict(self): + self.assertEqual( + self._fixed_specs_by_filename()["badge-license.svg"], + { + "filename": "badge-license.svg", + "label": "license", + "message": "MIT", + "fill": repo_badge_catalog._NEUTRAL, + "alt": "License: MIT", + "derivation": "Source: [project].license in pyproject.toml.", + }, + ) + + def test_python_spec_is_exactly_this_dict(self): + self.assertEqual( + self._fixed_specs_by_filename()["badge-python.svg"], + { + "filename": "badge-python.svg", + "label": "python", + "message": "3.10+", + "fill": repo_badge_catalog._NEUTRAL, + "alt": "Python 3.10+", + "derivation": "Source: [project].requires-python in pyproject.toml.", + }, + ) + + def test_references_spec_is_exactly_this_dict(self): + self.assertEqual( + self._fixed_specs_by_filename()["badge-references.svg"], + { + "filename": "badge-references.svg", + "label": "references", + "message": "2 papers", + "fill": repo_badge_catalog._CORPUS, + "alt": "2 referenced papers", + "derivation": ( + "Source: entries under '## References' in" + " docs/papers/bibliography.md." + ), + }, + ) + + def test_version_spec_is_exactly_this_dict(self): + self.assertEqual( + self._fixed_specs_by_filename()["badge-version.svg"], + { + "filename": "badge-version.svg", + "label": "version", + "message": "4.16.0", + "fill": repo_badge_catalog._HEALTHY, + "alt": "Version 4.16.0", + "derivation": "Source: [project].version in pyproject.toml.", + }, + ) + + def test_tests_badge_spec_is_exactly_this_dict(self): + self.assertEqual( + repo_badge_catalog.tests_badge_spec(42), + { + "filename": "badge-tests.svg", + "label": "tests", + "message": "42 passing", + "fill": repo_badge_catalog._HEALTHY, + "alt": "42 tests passing", + "derivation": "Source: the count pytest collects on this tree.", + }, + ) + + class RepositoryTests(unittest.TestCase): """The committed badges must match the tree they ship with.""" From 4f12c05d2646416ec02de294f8d1384b6b877095 Mon Sep 17 00:00:00 2001 From: cdeust Date: Thu, 30 Jul 2026 20:30:41 +0200 Subject: [PATCH 2/2] docs(changelog): add Unreleased entry for issue #292's mutmut fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every prior fix in this defect family (badge_render/check_badge_floor #262/#280, doc_claim_sources #235, and #293 itself) has a matching CHANGELOG entry under [Unreleased] ### Fixed; this fix's own commit lacked one. Boy-scout (coding-standards.md §14): the gap was visible the moment the touched diff was compared against the repo's own established convention for this exact class of change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca498c76..a7d644cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ adheres to [Semantic Versioning](https://semver.org/). - **`.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. +- **`doc_claim_scan.py`, `doc_claim_structural.py`'s remaining functions and `repo_badge_catalog.py` — every mutant reported "no tests"** (#292), the last siblings in the `badge_render`/`check_badge_floor`/`doc_claim_sources` defect family (#262/#280/#293, and #235's own instance of it): `check_doc_claims.py` bare-imports the first two (`import doc_claim_scan`, `import doc_claim_structural`) and `generate_repo_badges.py` bare-imports the third, and a function's `__module__` is fixed at definition time to whatever name it was imported under (`mutmut/mutation/trampoline.py`, `module != decorated_func.__module__`) — never mutmut's dotted, path-derived `"scripts."`, so its trampoline never activated and every mutant in these three files showed "no tests" despite being exercised by real passing tests through the bare-imported path. Fixed the same way as the existing `check_badge_floor`/`doc_claim_sources` precedent: each sibling is loaded a second time via `importlib.util.spec_from_file_location("scripts.", ...)`, and new direct-test classes (`DocClaimScanDirectTests`, `StructuralDirectTests` in `test_check_doc_claims.py`; `RepoBadgeCatalogDirectTests` in `test_generate_repo_badges.py`) call through those dotted references so mutmut's trampoline attributes the mutant to a real test. Verified with a real tally, not the absence of an error: a scoped `mutmut` run (`scripts/mutation_check.sh`) against `doc_claim_structural.py`, `doc_claim_scan.py`, `doc_claim_sources.py` and `repo_badge_catalog.py` plus both test files reports **301 mutants, 301 killed, 0 "no tests", 0 survived** — every assertion tightened to exact `assertEqual` (not `assertIn`) on the full message/dict, plus targeted `continue`-vs-`break` tests for multi-file scans for the loop-shaped survivors exact-match assertions alone don't reach. Boy-scout: `check_scanned_json_parses`'s `FileNotFoundError` branch (a missing scanned `.json` file) had no test at all, direct or indirect, before this change. Test-only; no production code changed. ### 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.