fix(mutmut): dot the last scripts/ test-loader gap, make pip_constraint_sets.py mutation-testable (#262) - #280
Conversation
…raint_sets.py mutation-testable (#262) test_typecheck_env_parity.py loaded scripts/pip_constraint_sets.py under the bare name "pip_constraint_sets" instead of the path-derived "scripts.pip_constraint_sets" mutmut keys mutant trampolines on — #264 fixed this for 14 sibling scripts/ test files but missed this one, so a scoped mutation run on it aborted with "Stopping early ... none match any mutant key" (found by code-reviewer post-merge on #264). Dotting the loader alone was not sufficient: ConstraintSet's three methods (path/command/header_lines) carried its entire logic, and mutmut's mutation generator categorically excludes the body of any @dataclass-decorated class (confirmed empirically: 0 mutants for the whole file with the methods in place, vs 58 after extracting them to free functions constraint_path/constraint_command/constraint_header_lines with ConstraintSet left as pure data). Every call site across generate_pip_constraints.py and its two test files is updated to match; the existing 301-test tests_py/scripts/ suite passes unchanged, proving the refactor is behavior-preserving. A dedicated tests_py/scripts/test_pip_constraint_sets.py directly exercises the three extracted functions (neither existing test file called them for real: everything went through compose()/render(), which every test either mocks or short-circuits before reaching). The scoped mutation run (tests_py/scripts/test_typecheck_env_parity.py + test_pip_constraint_sets.py against scripts/pip_constraint_sets.py) now produces real attribution: 58 mutants, 58 killed, 0 survived, 0 no-tests. Boy-scout finds in the same sweep (grepped every spec_from_file_location and sys.path-plus-bare-import in tests_py/, 21 call sites verified): - tests_py/scripts/test_generate_repo_badges.py bare-imported scripts/badge_render.py (`import badge_render` after a sys.path insert) instead of loading it under "scripts.badge_render" — same defect class, now fixed with its own dotted load, independent of generate_repo_badges.py's own bare import of the same file. - Two ruff E501 violations introduced by this diff's own line-length growth, fixed in the same commit (§14). - generate_repo_badges.py's RepoBadge class has the identical @dataclass-exclusion gap (confirmed: 234 mutants total, 0 attributed to RepoBadge.path()/.render()) — out of this PR's blast radius (a separate root-cause fix of comparable size; mixing it in here would violate §15.1's "don't mix the enabling refactor with the fix"); reported with evidence rather than filed, per this task's no-new- issues instruction. Resynced the 6 suite-count sites + badge SVG to the measured absolute (6560, +10 for test_pip_constraint_sets.py) via generate_repo_badges.py and manual doc edits; both check_doc_claims.py --test-count 6560 and generate_repo_badges.py --check --test-count 6560 pass. Full suite: 6560 passed, 117 subtests passed (167.83s). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
…onstraint_sets.py scoped runs tests_py/scripts/test_typecheck_env_parity.py reads .github/workflows/ci.yml directly (cross-checking CONTRIBUTING.md's documented type-check command against it). Running scripts/mutation_check.sh with this test in the selection failed collection under every mutant with FileNotFoundError (mutants/.github/workflows/ci.yml missing) instead of scoring the suite -- found while producing the real mutant tally for the previous commit's pip_constraint_sets.py fix. .github wasn't in also_copy for any prior scoped run (json_native.py, launcher_deps.py, generate_pip_constraints.py all read files already listed), so this is additive and does not change behavior for any other scoped run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
|
Discharging the boy-scout deferral flagged in review. The Branch: That is a stronger discharge of §14 than an issue number would be: the defect gets fixed, not tracked. Also noted from review, non-blocking: the sweep tally in this PR's body should read 22 sites (15 + 7), not 21 (15 + 6) — |
…conflict-resolution corruption Rebasing fix-stdio-drain-on-eof onto origin/main (which had landed PR #280 in the interim, also touching the doc-claims test-count figure) produced conflicts in the same five files this branch already updates. Resolving them mechanically dropped a trailing newline at each conflict site, merging adjacent lines in four files (a plain markdown corruption, not a JSON validity break -- .bestpractices.json stayed parseable but silently lost 4 blank-line separators). Both found by diffing each file's line count against origin/main's own version of the same file (should be identical apart from the in-place number) and fixed by restoring each missing newline precisely. Reconciled to the true canonical count after rebase: 6572 tests (6560 from #280's own additions + 12 new in this branch). CHANGELOG's Boy-scout note regained the test-count-claims mention that a prior edit in this same working session had dropped. Co-Authored-By: Claude <noreply@anthropic.com>
…unt (issue #262) `RepoBadge` (scripts/generate_repo_badges.py) is a @DataClass; mutmut categorically skips decorated-class bodies (mutmut/mutation/file_mutation.py:236), so its path()/render() methods carried zero mutation coverage — 234 mutants generated for the file, 0 attributed to either method (confirmed empirically via mutate_file_contents() before any change). Extracted to repo_badge_path()/ repo_badge_svg() free functions, same shape as PR #280's ConstraintSet fix for scripts/pip_constraint_sets.py; existing tests pass unchanged, proving the refactor is behavior-preserving. The mandated dataclass sweep found the identical defect in scripts/refresh_mcp_toplist_badge.py's Ranking class (percentile()/tier_text(), 298 mutants generated, 0 attributed) — fixed the same way. A full-file mutation run there surfaced 116 additional, unrelated pre-existing survivors (fetch/parse_leaderboard/parse_server_page/validate/render_badge/ resolve_ranking/main/_provenance_comment) that predate and are independent of the dataclass defect; filed as issue #281 per §15.1 rather than mixed into this PR. A repo-wide sweep also found 29 more @DataClass classes with hidden methods, all in mcp_server/ and outside the currently-committed mutmut scope; filed as issue #282. Real mutation tallies (scripts/mutation_check.sh scope, direct mutmut against the shared venv — `uv run` fails with missing plugins in a fresh clone): generate_repo_badges.py went from 234 mutants/0 attributed to path+render, to 285 mutants total, 280 killed, 5 documented-equivalent survivors (maxsplit argument invariance on str.split(sep, n)[0], and default=None being argparse's own implicit default) — 0 unexplained survivors. refresh_mcp_toplist_badge.py's Ranking-derived functions: 0 survivors (the pre-existing 116 elsewhere are issue #281). Also fixed while touched (§14 boy-scout): build_badges() was 63 lines, over the §4 50-line function cap — split into _fixed_badges()/_tests_badge(). Test count moved 6550 -> 6568 (+18 for the new direct-unit-test coverage); resynced the six hard-coded sites from the measured absolute and regenerated assets/badge-tests.svg. The other four committed badge SVGs are byte-identical (SHA-256 verified against main). Full suite: 6568 passed, 117 subtests passed. ruff check/format clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
…d pass) (#283) * fix(mutmut): make RepoBadge/Ranking mutation-testable, resync test count (issue #262) `RepoBadge` (scripts/generate_repo_badges.py) is a @DataClass; mutmut categorically skips decorated-class bodies (mutmut/mutation/file_mutation.py:236), so its path()/render() methods carried zero mutation coverage — 234 mutants generated for the file, 0 attributed to either method (confirmed empirically via mutate_file_contents() before any change). Extracted to repo_badge_path()/ repo_badge_svg() free functions, same shape as PR #280's ConstraintSet fix for scripts/pip_constraint_sets.py; existing tests pass unchanged, proving the refactor is behavior-preserving. The mandated dataclass sweep found the identical defect in scripts/refresh_mcp_toplist_badge.py's Ranking class (percentile()/tier_text(), 298 mutants generated, 0 attributed) — fixed the same way. A full-file mutation run there surfaced 116 additional, unrelated pre-existing survivors (fetch/parse_leaderboard/parse_server_page/validate/render_badge/ resolve_ranking/main/_provenance_comment) that predate and are independent of the dataclass defect; filed as issue #281 per §15.1 rather than mixed into this PR. A repo-wide sweep also found 29 more @DataClass classes with hidden methods, all in mcp_server/ and outside the currently-committed mutmut scope; filed as issue #282. Real mutation tallies (scripts/mutation_check.sh scope, direct mutmut against the shared venv — `uv run` fails with missing plugins in a fresh clone): generate_repo_badges.py went from 234 mutants/0 attributed to path+render, to 285 mutants total, 280 killed, 5 documented-equivalent survivors (maxsplit argument invariance on str.split(sep, n)[0], and default=None being argparse's own implicit default) — 0 unexplained survivors. refresh_mcp_toplist_badge.py's Ranking-derived functions: 0 survivors (the pre-existing 116 elsewhere are issue #281). Also fixed while touched (§14 boy-scout): build_badges() was 63 lines, over the §4 50-line function cap — split into _fixed_badges()/_tests_badge(). Test count moved 6550 -> 6568 (+18 for the new direct-unit-test coverage); resynced the six hard-coded sites from the measured absolute and regenerated assets/badge-tests.svg. The other four committed badge SVGs are byte-identical (SHA-256 verified against main). Full suite: 6568 passed, 117 subtests passed. ruff check/format clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u * chore: rebase onto main and resync the advertised suite size to 6588 Rebase bookkeeping only. Conflicts were confined to the six files that hard-code the suite size, and only hunks differing solely by that number were resolved (main's figure wins); any hunk with real content divergence aborts the rebase for human/agent resolution instead of being guessed at. Count is the measured absolute from a real collection. The branch's own touched tests were re-run after resolution to catch content loss. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ct fact (issue #287, coding-standards.md §6 root-cause fix) Root cause (§6): check_doc_claims.py/generate_repo_badges.py required the test-count claim in six files to EXACTLY equal the live `pytest --collect-only` count of whichever branch's CI ran the check. 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, to stay green, each edits the SAME six lines to its own value. Whichever merges second stomps the first's correct figure with its own now-stale one; this is why main's gate went red twice (PR #280 synced to 6570, #278 added more tests but had recorded 6560 against a stale base, and post-merge the `--test-count`-computed canonical exceeded what the six files said). Fix: assets/badge-tests.svg becomes the ONLY artifact stating an absolute test count (the prose removal ships in the next commit), and its check changes from equality to a monotone floor — `committed <= live` — in both gates: - doc_claim_structural.check_badge_floor (check_doc_claims.py's collect_failures now calls this instead of check_badge for "tests"; check_counts(TEST_CLAIM, ...) is no longer called for that family at all, since no prose file will state it after the next commit) - generate_repo_badges.stale_tests_badge (generate_repo_badges.py's main() --check branch excludes the tests badge from the plain, still-exact stale() loop and checks it here instead) Both still fail on the one direction that IS a lie — an OVER-claim (a hand-typed or stale-high number, or tests removed below what is claimed) — and stale_tests_badge still runs the ordinary exact-match `stale()` against the label/colour/provenance rendered with the COMMITTED count, so structural drift in anything else about the badge fails exactly as before. Test changes (deliberate — this is a contract change to the gate, not a pure refactor, and these pin the OLD, now-superseded exact-match contract): - test_each_family_reports_its_own_stale_claim: dropped the "tests" subtest (no prose claim left to go stale) - test_the_test_badge_is_checked_against_the_live_count: replaced by test_an_over_claiming_test_badge_is_reported / test_an_under_claiming_test_badge_is_not_reported / test_an_exactly_matching_test_badge_is_not_reported / test_a_missing_test_badge_fails_closed / test_a_test_badge_without_a_matching_title_fails_closed (full branch coverage of check_badge_floor, for mutation testing) - test_every_advertised_test_count_states_the_same_number: repurposed to test_no_prose_file_states_the_suite_size_any_more, asserting scan_claims returns [] — a standing regression guard against a hardcoded count reappearing in ANY scanned file, .bestpractices.json included. This currently FAILS on the real tree (10 remaining occurrences across 5 files) until the next commit removes them — expected, not a bug. - generate_repo_badges: new StaleTestsBadgeTests (6 methods, direct branch coverage of stale_tests_badge) + 2 new CheckModeTests methods exercising the floor through main() end-to-end. Verified: tests_py/scripts/test_check_doc_claims.py + tests_py/scripts/test_generate_repo_badges.py — 97 passed, 29 subtests passed, 1 expected failure (the new regression guard, pending the next commit). ruff check + ruff format --check clean. Both files re-verified as direct CLI invocations against the real tree (still green: the currently committed badge exactly equals the currently measured 6609, a trivial floor pass). Co-Authored-By: Claude <noreply@anthropic.com>
… floor (issue #293) (#294) * refactor: Extract Module doc_claim_sources/doc_claim_scan/doc_claim_structural from check_doc_claims (§4.1 300-line cap, issue #287) 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.<name>` — 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 <noreply@anthropic.com> * refactor: Extract Function repo_badge_catalog from generate_repo_badges (§4.1 300-line cap, issue #287) generate_repo_badges.py was 305 lines, over the repo's 300-line file cap (CLAUDE.md, Code Style) before this PR needed to add tests-badge floor logic to it (issue #287). Boy-scout fix (§14): fixed now, since the file is touched by this series regardless. _fixed_badges/_tests_badge and the six palette constants had no dependency on `read`/check_doc_claims and were not referenced directly by any test (confirmed via `grep -oE "gen\.[A-Za-z_]+" tests_py/scripts/test_generate_repo_badges.py` — only gen._HEALTHY reads a moved constant, addressed by a re-export; _fixed_badges/_tests_badge themselves are exercised only indirectly through build_badges(), which stays put and is directly tested). Moved to repo_badge_catalog.py as plain field dicts rather than RepoBadge instances: RepoBadge is a `@dataclass` whose owning module matters to mutmut, and this avoids ever constructing it from two different module-identities of the same class. Before: generate_repo_badges.py 305 lines (over cap). After: generate_repo_badges.py 258 lines; repo_badge_catalog.py 87 (both 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 re-verified as direct CLI invocations. ruff check + ruff format --check: clean. Co-Authored-By: Claude <noreply@anthropic.com> * fix: make the test-count claim a monotone floor, not a per-branch exact fact (issue #287, coding-standards.md §6 root-cause fix) Root cause (§6): check_doc_claims.py/generate_repo_badges.py required the test-count claim in six files to EXACTLY equal the live `pytest --collect-only` count of whichever branch's CI ran the check. 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, to stay green, each edits the SAME six lines to its own value. Whichever merges second stomps the first's correct figure with its own now-stale one; this is why main's gate went red twice (PR #280 synced to 6570, #278 added more tests but had recorded 6560 against a stale base, and post-merge the `--test-count`-computed canonical exceeded what the six files said). Fix: assets/badge-tests.svg becomes the ONLY artifact stating an absolute test count (the prose removal ships in the next commit), and its check changes from equality to a monotone floor — `committed <= live` — in both gates: - doc_claim_structural.check_badge_floor (check_doc_claims.py's collect_failures now calls this instead of check_badge for "tests"; check_counts(TEST_CLAIM, ...) is no longer called for that family at all, since no prose file will state it after the next commit) - generate_repo_badges.stale_tests_badge (generate_repo_badges.py's main() --check branch excludes the tests badge from the plain, still-exact stale() loop and checks it here instead) Both still fail on the one direction that IS a lie — an OVER-claim (a hand-typed or stale-high number, or tests removed below what is claimed) — and stale_tests_badge still runs the ordinary exact-match `stale()` against the label/colour/provenance rendered with the COMMITTED count, so structural drift in anything else about the badge fails exactly as before. Test changes (deliberate — this is a contract change to the gate, not a pure refactor, and these pin the OLD, now-superseded exact-match contract): - test_each_family_reports_its_own_stale_claim: dropped the "tests" subtest (no prose claim left to go stale) - test_the_test_badge_is_checked_against_the_live_count: replaced by test_an_over_claiming_test_badge_is_reported / test_an_under_claiming_test_badge_is_not_reported / test_an_exactly_matching_test_badge_is_not_reported / test_a_missing_test_badge_fails_closed / test_a_test_badge_without_a_matching_title_fails_closed (full branch coverage of check_badge_floor, for mutation testing) - test_every_advertised_test_count_states_the_same_number: repurposed to test_no_prose_file_states_the_suite_size_any_more, asserting scan_claims returns [] — a standing regression guard against a hardcoded count reappearing in ANY scanned file, .bestpractices.json included. This currently FAILS on the real tree (10 remaining occurrences across 5 files) until the next commit removes them — expected, not a bug. - generate_repo_badges: new StaleTestsBadgeTests (6 methods, direct branch coverage of stale_tests_badge) + 2 new CheckModeTests methods exercising the floor through main() end-to-end. Verified: tests_py/scripts/test_check_doc_claims.py + tests_py/scripts/test_generate_repo_badges.py — 97 passed, 29 subtests passed, 1 expected failure (the new regression guard, pending the next commit). ruff check + ruff format --check clean. Both files re-verified as direct CLI invocations against the real tree (still green: the currently committed badge exactly equals the currently measured 6609, a trivial floor pass). Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove the hard-coded test count from the five remaining files (issue #287) Completes the root-cause fix begun in the prior commit: assets/badge-tests.svg is now the ONLY artifact stating an absolute test count, checked as a monotone floor (check_badge_floor / stale_tests_badge). These five point at it instead of restating the figure by hand: - CLAUDE.md, CONTRIBUTING.md (x2), README.md (x2): dropped the number, pointing at assets/badge-tests.svg (README's badge alt text: "tests passing", matching the plain "CI" alt on the adjacent live-status badge rather than restating a number the image itself already carries). - docs/ASSURANCE-CASE.md: same, in the standing-analysis paragraph. - .bestpractices.json (4 fields: test_justification, tests_are_added_justification, dynamic_analysis_justification, automated_integration_testing_justification): reworded to point at the badge instead of restating an exact figure. The OpenSSF criteria these answer (test_status, tests_are_added, dynamic_analysis, automated_ integration_testing) ask whether an automated suite exists, grows with new functionality, and runs in CI — not for a specific count, so the claim stays fully answerable without one. This was the file explicitly flagged as needing special care (transcribed into the OpenSSF Best Practices questionnaire); dropping the number here, like the other four, is simpler and more robust than keeping one on a generated ratchet, and every field it touches remains "Met" with unchanged justification quality. Verified: the six-file conflict class's root artifact (a hard-coded absolute count duplicated across independently-editable files) no longer exists anywhere in prose or JSON — tests_py/scripts/test_check_doc_claims.py::RepositoryTests:: test_no_prose_file_states_the_suite_size_any_more (added in the prior commit specifically to enumerate every remaining occurrence) now passes; it listed all 10 by file:line before this commit and lists none after. .bestpractices.json re-validated as parseable JSON. Both gates re-run against the real tree, both --test-count and static (no-count) modes: `check_doc_claims.py` -> "doc claims OK"; `generate_repo_badges.py --check` -> "badges OK" (5 checked with --test-count 6609, 4 checked without). Full tests_py/scripts/ suite: 98 passed, 29 subtests passed, 0 failed. Co-Authored-By: Claude <noreply@anthropic.com> * docs: CHANGELOG entry for the test-count conflict-class fix (issue #287) §13.1 H4 — this is an observable contract change (five files no longer state a number; the badge check's semantics moved from exact to floor) and gets its own entry, alongside a note on why check_doc_claims.py/ generate_repo_badges.py needed the Extract Module split first. Verified the CI workflow's own comments (ci.yml lines ~179-204, ~429-444) describe WHERE/WHEN the checks run, not the equality-vs-floor comparison semantics that changed — re-read against the current diff, neither needed an edit. Co-Authored-By: Claude <noreply@anthropic.com> * fix(mutmut): add assets/ to also_copy, closing a scoped-run blocker for check_doc_claims.py (issue #287) Boy-scout fix (§14) — seen while running the mutation testing this same series requires on scripts/check_doc_claims.py's new floor-check logic. tests_py/scripts/test_check_doc_claims.py::RepositoryTests runs UNPATCHED against the real tree, and check_versions -> check_badge reads assets/badge-version.svg directly off disk; assets/ was absent from also_copy, so a scoped mutmut run naming check_doc_claims.py or generate_repo_badges.py failed EVERY mutant on "assets/badge-version.svg: missing" (reproduced empirically, both via `uv run mutmut` and via mutmut invoked directly against the shared .venv) before scoring a single one — the exact "no usable result" failure mode the existing also_copy comments already describe for .github/uv.lock/requirements, just for a file nobody had scoped in yet. Co-Authored-By: Claude <noreply@anthropic.com> * fix(mutmut): kill 5 real survivors from mutation-testing the floor-check logic; correct a citation error (issue #293, was mis-cited #287) Citation correction (§8 / no-invented-references): every prior commit in this series, and the code/test comments they added, cited "issue #287". That number was never verified before use — it turned out to already be a real, unrelated, open issue ("Local dev venv (macOS, reused .venv) collects 10 fewer tests than CI's hash-pinned Linux install"). Filed the actual tracking issue for this fix (#293) and replaced every "#287" reference across CHANGELOG.md, pyproject.toml, and every scripts/*.py + tests_py/ scripts/*.py file this series touches. The earlier commits' own messages are left as committed history (this repo's convention is new commits, not rewritten ones) — this commit's message is the correction record. Mutation testing (§12), scoped to the three files carrying new/changed logic (scripts/check_doc_claims.py, scripts/doc_claim_structural.py, scripts/generate_repo_badges.py) against tests_py/scripts/ test_check_doc_claims.py + test_generate_repo_badges.py, direct mutmut invocation against the shared .venv (`uv run` creates a fresh venv missing required_plugins — reproduces the documented issue #262/#283 finding). Two real gaps found and fixed: - encoding=None survived both scripts/check_doc_claims.py's read() and generate_repo_badges.stale_tests_badge's read_text call: neither test pinned the encoding kwarg, and every committed badge's provenance comment (and most scanned Markdown) contains an em dash a locale default can mis-decode (Windows is in this project's CI matrix). Fixed with the SAME mock-spy technique this file's own test_write_pins_utf8_on_both_the_read_and_the_write already established (assert read_spy.call_args(_list).kwargs["encoding"] == "utf-8"); the stale_tests_badge version checks EVERY call in the list, not just the last, because it falls through to stale()'s own separate read_text call which would otherwise mask the first. - check_badge_floor's "no title match" message: the test asserted only the prefix ("no tests figure in its <title>"), so a mutant altering the trailing "this gate have diverged" clause survived. Now asserts the full message. One inert mutant eliminated by fixing the code rather than documenting it as equivalent: stale_tests_badge constructed a whole RepoBadge from test_count just to read its constant .filename; resolves the path from repo_badge_catalog.TESTS_BADGE_FILENAME directly instead. One infrastructure defect found and fixed: EVERY mutant in doc_claim_structural.py showed "no tests" under the scoped run. Root cause, confirmed by reading mutmut/mutation/trampoline.py: its dispatcher activates a mutant only when the calling module's __name__ matches the dotted, path-derived name mutmut generated the mutant under ("scripts.doc_claim_structural"); check_doc_claims.py's bare `import doc_claim_structural` gives every function in that file `__module__ == "doc_claim_structural"` (no "scripts." prefix), so the trampoline never activates and stats attribution never fires — the exact defect class this file's own test_generate_repo_badges.py already documents and works around for badge_render.py. Fixed the same way: test_check_doc_claims.py now also loads doc_claim_structural.py under its own dotted name, and the new CheckBadgeFloorDirectTests calls check_badge_floor through that reference — 12/12 of its mutants went from "no tests" to attributed (10 killed outright, 2 real survivors above, both now fixed). The other three functions in doc_claim_structural.py, plus doc_claim_scan.py/ doc_claim_sources.py/repo_badge_catalog.py (not in this run's scope), likely have the same gap — filed as its own issue (#292) per §15.1 rather than folded in here, since fixing it needs no change to this PR's own logic. Final scoped tally: 453 mutants, 361 killed, 87 not-covered (documented above, tracked as #292), 5 survivors — all 5 pre-existing, in canonical_licence/canonical_python_floor/_build_parser (unmodified by this series), and already self-documented as equivalent by comments predating this change (maxsplit=1 vs 0/2/-1 equivalence; default=None as argparse's own implicit default) — re-verified, not re-asserted from scratch. Tests: tests_py/scripts/test_check_doc_claims.py + test_generate_repo_badges.py — 105 passed, 29 subtests passed (was 100/29 before this commit's 5 new direct tests + 1 dotted-load addition). ruff check + ruff format --check clean. Both scripts re-verified as direct CLI invocations against the real tree. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
…ions (issue #282) (#303) * fix(mutmut): extract 30 @dataclass-with-methods classes to free functions (issue #282) mutmut categorically skips the body of any @dataclass-decorated ClassDef (mutmut/mutation/file_mutation.py:236), including @staticmethod members and __post_init__ dunders, so logic placed on methods carries zero mutation coverage regardless of test-loader naming. Issues #262/#280/#283 established the fix (extract methods to free functions, keep the dataclass pure data) for ConstraintSet, RepoBadge, and Ranking; this closes the systemic sweep across the remaining 30 classes the issue's own AST scan found (26 files, spanning mcp_server/core, mcp_server/handlers/consolidation, mcp_server/shared, fuzz/, and benchmarks/lib/). Every extracted function now attributes a real, scoped mutation tally (0 attributed -> a number, matching the RepoBadge precedent) — verified via per-file `mutmut run "<module>.x_<func>__mutmut_*"` isolation runs (a 26-file combined run gave unreliable results and was discarded; see the per-file tallies below). Surviving mutants were killed by strengthening assertions (precision-insensitive test fixtures, message-substring assertions matching the padded/typo'd input, missing negative-case coverage) or documented as equivalent at the use site. Also found and fixed one real regression risk while sweeping: a `getattr(goal, "is_active", False)` call site in recall_pipeline.py silently defaulted to False once the `is_active` property was removed, which would have disabled A3 goal-maintenance recall re-ranking with no signal — grep for `.attr` patterns alone does not catch getattr() defaults. Rebased onto a fast-moving origin/main mid-session (issue #276's headless_authoring.py split moved CycleBudget to a new cycle_types.py); the CycleBudget extraction was re-applied against the new file location. Closes #282 * fix(mutmut): close 3 real survivors + 1 psycopg import bug in issue #282's sweep Verified per-file mutant tallies for all 26 files/61 extracted functions from the #282 sweep via isolated `mutmut run "<pattern>"` invocations (a combined 26-file run gave unreliable "survived"/"not checked" counts — discarded per this branch's own prior checkpoint). Two methodology bugs found while producing trustworthy numbers, both fixed at the source rather than worked around: - tests_py/fuzz/test_corpus_replay.py imported replay_corpus.py via importlib.util.spec_from_file_location under the bare name "replay_corpus", not the package-qualified "fuzz.replay_corpus" mutmut's trampoline dispatch expects -- every mutant in that file was invisible to mutation testing regardless of what the test asserted. Switched to a normal `from fuzz import replay_corpus` import (behavior unchanged, confirmed by the existing tests still passing); mutants went from 29/35 "not checked" to real killed/survived numbers. - tests_py/test_doctor_mcp.py's launcher smoke test needs scripts/ on disk; mutation_scope_run.sh (scratchpad tooling, not shipped) now adds it to also_copy so the scoped run's baseline-stats phase doesn't fail before scoring a single mutant (same class of gap as issue #293). Real surviving mutants found and killed with negative-case tests: - harness_consume (fuzz/replay_corpus.py): missing-consume() and module-preregistration paths were untested; added two tests pinning the exact RuntimeError message and the sys.modules[spec.name] ordering the function's own docstring documents as load-bearing. - replay() (same file, touched by this diff's call-site update): its AssertionError-wrapping path had no test that made consume() itself raise; added one. Remaining survivors in backpressure_pipeline_run (6) and exp_result_verdict (2) are the SAME equivalent mutants their source comments already document (thread `name=` kwargs; `cmp.get(op, False)`'s falsy default) -- verified by reading the actual mutant diffs, not re-litigated. Also fixes a real bug the sweep surfaced: benchmarks/lib/__init__.py eagerly imported BenchmarkDB (-> psycopg/psycopg_pool/pgvector) at package-init time, so importing the Postgres-independent benchmarks.lib.verification_report (one of the 26 extracted files) on a SQLite-only install raised ModuleNotFoundError before a single mutant could run. Every other consumer in benchmarks/lib/ already defers this import inside a function for exactly this reason; __init__.py did not follow its own package's convention. Fixed via a PEP 562 module __getattr__, with a subprocess-isolated regression test poisoning sys.modules for psycopg/psycopg_pool/pgvector. Full suite: 6864 passed, 5 skipped (was 6706 passed before this commit). Closes #282 * fix(tests): skip the psycopg-present sanity check when Postgres extras are absent test_benchmark_db_resolves_with_psycopg_present asserted the real BenchmarkDB resolves via benchmarks.lib's lazy __getattr__, but assumed psycopg is always importable in the test environment. CI's "Test (SQLite backend)" job installs without the Postgres extras, so this sanity check would fail there for the same reason BenchmarkDB is lazy in the first place. Guarded with pytest.importorskip("psycopg") -- the negative case (no psycopg -> loud ImportError, not silent) is already pinned by test_benchmark_db_still_reachable_lazily_and_fails_ loudly_without_psycopg above, so this only skips the redundant positive-case check on an install that cannot exercise it. Full suite: 6868 passed, 5 skipped, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ge_catalog for real mutation coverage (#292) check_doc_claims.py and generate_repo_badges.py bare-import their sibling modules (doc_claim_scan, doc_claim_sources, doc_claim_structural, 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/doc_claim_structural.py's check_badge_floor, issues #262/ #280/#293). Fix: dotted-load each sibling under its own "scripts.<name>" spec (mirroring the existing check_badge_floor 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 all four production files -- doc_claim_structural.py, doc_claim_scan.py, doc_claim_sources.py, repo_badge_catalog.py -- and both test files reports "0 surviving mutants" (previously 62 genuine survivors once the "no tests" misattribution was fixed, all now killed). Full suite green (6872 passed, 5 skipped, 121 subtests), doc-claims and badge-floor gates green, ruff clean. Closes #292 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
Every prior fix in this defect family (badge_render/check_badge_floor #262/#280, and #293 itself) has a matching CHANGELOG entry under [Unreleased] ### Fixed; this PR'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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
…ining 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.<name>" 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u
…ge_catalog for real mutation coverage (#292) (#305) * 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.<name>" 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u * docs(changelog): add Unreleased entry for issue #292's mutmut fix 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Summary
tests_py/scripts/test_typecheck_env_parity.pystill loadedscripts/pip_constraint_sets.pyunder the bare synthetic name"pip_constraint_sets"instead of the path-derived"scripts.pip_constraint_sets"mutmut keys mutant trampolines on. #264 fixed this exact defect for 14
sibling
scripts/test files but missed this one (found by code-reviewerpost-merge on #264, filed conceptually under issue #262).
Dotting the loader name alone was not sufficient to produce a real
mutant tally:
ConstraintSet's three methods (path/command/header_lines)carried 100% of the file's logic, and mutmut's mutation generator
categorically excludes the body of any
@dataclass-decorated class (itmust — copying a decorated class for the trampoline setup can re-run the
decorator's side effects). Confirmed empirically: 0 mutants for the
whole file with the logic as methods, vs 58 mutants after extracting
constraint_path/constraint_command/constraint_header_linesintofree functions and leaving
ConstraintSetas pure data. Every call siteacross
generate_pip_constraints.pyand its two test files is updated tomatch; the pre-existing 301-test
tests_py/scripts/suite passesunchanged, proving the refactor is behavior-preserving.
What changed
tests_py/scripts/test_typecheck_env_parity.py— dotted thepip_constraint_setsloader (the reported gap).scripts/pip_constraint_sets.py— extractedConstraintSet's threemethods to module-level functions (data/behavior split), so the logic is
no longer hidden inside a
@dataclass-excluded class body.scripts/generate_pip_constraints.py+tests_py/scripts/test_generate_pip_constraints.py— updated every call site (
.path()→constraint_path(...),.command()→
constraint_command(...),.header_lines()→constraint_header_lines(...)).tests_py/scripts/test_pip_constraint_sets.py(new) — a dedicated,direct unit-test file for the three extracted functions. Neither existing
test file called them for real: every prior real invocation went through
compose()/render(), which every existing test either mocks orshort-circuits before reaching (this is what "0 killed, 31 no-tests, 22
survived" surfaced on the first honest run, before this file existed).
tests_py/scripts/test_generate_repo_badges.py— boy-scout find fromthe directory-wide sweep: it bare-imported
scripts/badge_render.py(
import badge_renderafter insertingscripts/ontosys.path)instead of loading it under
"scripts.badge_render"— same defect class.Fixed with its own dotted load, independent of
generate_repo_badges.py'sown (unaffected, still-bare) internal import of the same file.
pyproject.toml—.githubwas missing from[tool.mutmut] also_copy;test_typecheck_env_parity.pyreads.github/workflows/ci.ymldirectly,so any scoped mutation run selecting it failed collection under every
mutant with
FileNotFoundErrorinstead of scoring the suite. Found whileproducing the tally below; purely additive, no effect on any other
scoped run (json_native.py/launcher_deps.py/generate_pip_constraints.py
all read files already listed).
ruffE501 violations this diff's own line growth introduced,fixed in the same commit (§14).
six hard-coded sites + the committed badge SVG, regenerated via
generate_repo_badges.py --test-count 6560and verified withcheck_doc_claims.py --test-count 6560.Directory-wide sweep (deliverable #4)
grep -rn "spec_from_file_location\|sys.path.insert\|sys.path.append" tests_py/— 21 call sites verified by hand:
spec_from_file_locationsites: all 13 already-fixedscripts/testfiles confirmed still dotted correctly; the 1 reported gap (fixed above);
1 more found and fixed (
badge_render, above).tests_py/fuzz/test_corpus_replay.pyand
tests_py/infrastructure/test_config.pyalso use this pattern butload files outside the current committed mutmut
source_paths = ["mcp_server"]scope and outside issue Mutation testing cannot attribute mutants for any scripts/ module (synthetic module names in tests_py/scripts) #262'sscripts/blast radius —test_config.py's reload is a deliberate one-off isolation instancealongside ~40 normal dotted importers of
mcp_server.infrastructure.configthat already provide correct attribution, so it is not this defect.
sys.path.insert/appendsites: 3 wiki-migration test files already usethe correct
from scripts.X import ...dotted form (citing issue Mutation testing cannot attribute mutants for any scripts/ module (synthetic module names in tests_py/scripts) #262 intheir own comments); the other 3 (
test_goal_maintenance.py,test_goal_maintenance_wiring.py,benchmark_harness.py) insert"."only to make
mcp_serverimportable and already import it dotted(
from mcp_server.core import ...) — not an instance of this defect.Mutation tally (real, quoted — deliverable #2/#3)
Reproducing the reported abort, literal invocation exactly as this
task named the files:
(against the pre-fix tree, bare loader name) —
After dotting the loader name alone (methods still on
ConstraintSet,before the data/behavior split):
mutate_file_contents()on the filereturns 0 mutants — confirmed via direct call, independent of any
test — because mutmut's
MutationVisitor._skip_node_and_childrencategorically excludes
@dataclass-decoratedClassDefbodies.After the full fix, same literal 2-arg invocation (
scripts/mutation_check.sh tests_py/scripts/test_typecheck_env_parity.py scripts/pip_constraint_sets.py,scoping the test selection to exactly the ONE file this task named — the
script's CLI takes a single test path):
Real attribution now happens (no more abort) — but that ONE test file
alone never called
constraint_command/constraint_header_lineswithenough assertions to kill everything, because
pip_constraint_sets.pyisalso exercised by
test_generate_pip_constraints.pyand the new dedicatedtest_pip_constraint_sets.py, neither of which the script's single-test-pathCLI can express alongside the other in one invocation.
Full, honest triage — scoping
pytest_add_cli_args_test_selectiontoBOTH
test_typecheck_env_parity.pyand the newtest_pip_constraint_sets.py(mirroring
scripts/mutation_check.sh's ownpyproject.tomlpatchinglogic by hand, then invoking
mutmut rundirectly against the shared venvto avoid
uv run's fresh-venv side effect):58 mutants, 58 killed, 0 survived, 0 no-tests. Every one of the 53
non-trivially-killed mutants was a missing test, now added in
test_pip_constraint_sets.py— none were equivalent; every mutationobserved was a real, distinguishable behavior change (a wrong CLI flag, a
dropped accumulator, a corrupted banner line).
Boy-scout defect found, evidenced, NOT fixed (too large for this PR)
generate_repo_badges.py'sRepoBadgeclass has the identical@dataclass-exclusion gap: confirmed empirically, 234 total mutants forthe file, 0 attributed to
RepoBadge.path()/.render(). Fixing itrequires the same shape of work as this PR (extract to free functions,
update every call site in
generate_repo_badges.py+ its test file, adddirect tests, re-run mutation testing) — a second, independent root-cause
fix of comparable size. Mixing it into this PR would violate §15.1 ("don't
mix the enabling refactor with the fix") and roughly double this diff's
blast radius beyond its named subject (
pip_constraint_sets.py/ issue#262). Per this task's explicit no-new-issues instruction, reported here
with evidence rather than filed.
Test plan
pytest tests_py/scripts/— 311 passed, 117 subtests passed(was 301 before this PR's new test file)
pytest -q— 6560 passed, 117 subtests passed (167.83s)python scripts/generate_pip_constraints.py --check—requirements OK (13 checked)python scripts/check_doc_claims.py --test-count 6560— OKpython scripts/generate_repo_badges.py --check --test-count 6560— OKruff check/ruff format --checkon every touched file — cleanscripts/mutation_check.shrun twice (before/after) + a manually-scopedfull-triage run — real tallies quoted above, 0 survivors in the final state
(
generate_pip_constraints.py'sfrom pip_constraint_sets import ...,generate_repo_badges.py/refresh_mcp_toplist_badge.py'simport badge_render) verified to still resolve via real subprocessinvocation, unaffected by the test-loader renames.
🤖 Generated with Claude Code
https://claude.ai/code/session_012Yu6EnWspTfqHoGkExyS6u