Skip to content

fix(mutmut): extract 30 @dataclass-with-methods classes to free functions (issue #282) - #303

Merged
cdeust merged 3 commits into
mainfrom
fix-dataclass-mutation-blindspot-282
Jul 30, 2026
Merged

fix(mutmut): extract 30 @dataclass-with-methods classes to free functions (issue #282)#303
cdeust merged 3 commits into
mainfrom
fix-dataclass-mutation-blindspot-282

Conversation

@cdeust

@cdeust cdeust commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Closes #282

What

mutmut categorically skips the body of any @dataclass-decorated
ClassDef (mutmut/mutation/file_mutation.py:236). Issues #262/#280/#283
established the fix — extract methods to free functions, keep the
dataclass pure data — for ConstraintSet, RepoBadge, and Ranking.
This PR finishes the systemic sweep issue #282 found: all 30 remaining
@dataclass-with-methods classes across mcp_server/, fuzz/, and
benchmarks/lib/ (26 source files), each method extracted to a free
function taking the dataclass as its first argument, call sites updated,
existing behavior preserved.

A whole-repo AST sweep for @dataclass-decorated classes carrying
non-dunder methods now returns zero hits (verified below).

Also fixed one real regression risk found while sweeping: a
getattr(goal, "is_active", False) call site in recall_pipeline.py
silently defaulted to False once GoalVector.is_active became the free
function goal_vector_is_active, which would have disabled A3
goal-maintenance recall re-ranking with zero signal. grep for .attr
patterns alone does not catch getattr() defaults — caught by re-reading
every call site of every extracted method, not just the extraction itself.

Two more issues surfaced while re-verifying the mutation numbers below and
are fixed in this PR rather than left for a follow-up:

  • 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 keys mutants by (mutmut run reported:
    "tests recorded trampoline hits but none match any mutant key"). Every
    mutant in that file was therefore invisible to mutation testing
    regardless of what the test asserted — the first table below originally
    (and wrongly) reported this file at 35/35/0; the real, re-verified
    number is 35/33/2 (below). Fixed via a normal from fuzz import replay_corpus import (behavior unchanged) plus two new negative-case
    tests for harness_consume's error paths and one for replay()'s
    exception-wrapping (also touched by this diff's call-site update).
  • 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 mutmut could run a single mutant against
    it. Every other consumer of BenchmarkDB in this package already
    defers that 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 (tests_py/benchmarks/test_lib_init_no_psycopg.py) that poisons
    sys.modules for psycopg/psycopg_pool/pgvector.

Mutation evidence (§12) — per-file, per-function, scoped runs

Per the issue's methodology note (a single combined multi-file mutmut run
gives unreliable "survived" verdicts under fork()+Pool() resource
contention — reproduced and discarded), every file below was run in
isolation
via a scoped driver equivalent to scripts/mutation_check.sh
(temporarily repoints pyproject.toml's [tool.mutmut] only_mutate /
source_paths / pytest_add_cli_args_test_selection at the one file +
its test(s), runs mutmut run, restores pyproject.toml after). Two
files' isolated runs were themselves unreliable for reasons unrelated to
the combined-run bug — doctor_mcp.py's baseline-stats phase failed
outright (scripts/launcher.py missing from the mutants/ sandbox; fixed
by adding scripts to the scoped run's also_copy) and
replay_corpus.py's mutants were entirely uncounted (the module-
qualification mismatch described above) — both re-run clean after their
respective fixes; the table below reflects the corrected, re-verified
counts. 677 mutants generated across the 30 classes' extracted functions:

File (extracted class) Functions Mutants Killed Survived (equivalent, documented in-source)
mcp_server/core/ablation.py (AblationConfig) 4 12 12 0
benchmarks/lib/verification_report.py (ExpResult) 3 33 31 2 — exp_result_verdict: cmp.get(op, False) vs cmp.get(op, None)/no-default; both are falsy, truthiness-identical (comment at use site)
fuzz/replay_corpus.py (Harness) 4 35 33 2 — harness_consume: spec is None or spec.loader is None guard is structurally unreachable for a .py-suffixed module_path (spec_from_file_location only returns spec=None/loader-less for an unrecognized extension, verified empirically); comment at use site
mcp_server/core/attentional_control.py (AttentionAllocation) 1 20 20 0
mcp_server/core/conflict_monitor.py (ConflictAssessment) 1 29 29 0
mcp_server/core/context_assembly/budget.py (AssemblyMetrics) 2 22 22 0
mcp_server/core/document_model.py (DocumentSection, ParsedDocument, DocumentProvenance) 4 5 5 0 (2 functions generate 0 mutants — trivial f-string pass-through, no comparison/arithmetic/literal for mutmut's operators; documented as tool-limitation, not a gap)
mcp_server/core/dual_process_retrieval.py (FamiliaritySignal) 1 25 25 0
mcp_server/core/forward_model.py (ForwardModelState) 1 23 23 0
mcp_server/core/goal_maintenance.py (GoalVector) 2 17 17 0
mcp_server/core/habituation.py (HabituationOutcome) 1 39 39 0
mcp_server/core/procedural_memory.py (ActionStep, ProceduralSkill) 4 27 27 0 (procedural_skill_length = len(skill.sequence), 0 mutants — same trivial-body reason)
mcp_server/core/sensory_buffer.py (BufferItem) 1 16 16 0
mcp_server/core/source_monitoring.py (SourceJudgement) 1 20 20 0
mcp_server/core/streaming/adaptive_controller.py (AdaptiveBatchController) 2 16 16 0 (adaptive_batch_controller_batch_size = return controller._b, 0 mutants)
mcp_server/core/streaming/backpressure_pipeline.py (BackpressurePipeline) 6 65 59 6 — all in backpressure_pipeline_run: threading.Thread(name=...) swaps; thread name is a debug label only, no test can observe it (comment at use site)
mcp_server/core/value_learning.py (ValueUpdate) 1 37 37 0
mcp_server/core/wiki_axis_registry.py (AxisRegistry) 5 27 27 0
mcp_server/core/wiki_coverage.py (DomainCoverage, FileCoverage) 5 13 13 0
mcp_server/core/wiki_groomer.py (PageAudit) 1 1 1 0
mcp_server/core/wiki_redirect.py (Redirect) 1 1 1 0
mcp_server/core/wiki_schema_loader.py (WikiRegistry) 1 1 1 0
mcp_server/core/wiki_view_executor.py (CompiledView) 1 1 1 0
mcp_server/doctor_mcp.py (McpReport) 3 24 24 0
mcp_server/handlers/consolidation/cycle_types.py (CycleBudget — moved here from headless_authoring.py by issue #276's mid-sweep split) 3 11 11 0
mcp_server/shared/wiki_classification.py (Classification) 6 157 157 0
Total 677 667 10, all documented equivalent at the use site

0 unexplained survivors. 4 functions generate 0 mutants (no mutable
comparison/arithmetic/literal AST node in a pure pass-through body) —
this is a mutmut operator-coverage limit, not a test gap; extraction
still moves them out of the @dataclass-skip blind spot for when a
future change adds real logic there.

replay() and main() (both pre-existing free functions in
fuzz/replay_corpus.py, not part of the Harness dataclass extraction,
but touched by this diff's call-site update from harness.consume() /
harness.inputs() to the new free functions) were swept for survivors as
a matter of course: replay()'s exception-wrapping path had one real gap
(no test made consume() itself raise), fixed with a new negative-case
test; main()'s 39 mutants are outside this diff's blast radius (its
body is unchanged, and it is already excluded from the pytest test
selection — a pre-existing CLI-only gap, not introduced or worsened
here).

Repo-wide dataclass-with-methods sweep (issue's own repro command)

Ran the issue's own reproduction script verbatim against the final tree:

$ python3 sweep_dataclass_methods.py   # issue #282's repro script, verbatim
(no output — zero @dataclass classes with non-dunder methods remain)

Test plan

  • Full suite: pytest -q6868 passed, 5 skipped, 116 subtests
    passed
    , 0 failed (confirmed after the mutation re-verification and
    the two additional fixes above — mutation runs restore
    pyproject.toml and never touch source).
  • ruff check + ruff format --check on all 77 touched files: clean.
  • scripts/check_doc_claims.py: doc claims OK.
  • scripts/generate_repo_badges.py --check: badges OK (4 checked) — no
    suite-size count reintroduced into prose/JSON docs per issue fix: eliminate the six-file test-count conflict class with a monotone floor (issue #293) #294.
  • No conflict markers; .bestpractices.json parses.

§4 size caps — measured, not asserted

Every newly extracted function is within the 40-line cap (largest:
backpressure_pipeline_run at 33 lines). This PR does not claim a
repo-wide §4.1/§4.2 pass: several touched files (auto_curator.py,
recall_pipeline.py, doctor_mcp.py, procedural_memory.py, and others)
were already over the 300-line file cap on origin/main before this diff
(confirmed via git show origin/main:<file> | wc -l against each
flagged file, re-verified with a script diffing origin/main line counts
against the current tree) and contain pre-existing over-40-line functions
unrelated to the extracted methods (e.g. mine_skills, assess_conflict,
build_clusters) — untouched debt outside this diff's blast radius
(§14.3), not introduced or worsened here.

Two categories of genuine (small) growth, both within §10's explicit
Medium-stakes "≤20% flexibility on limits" band for rule 4:

  • mcp_server/core/wiki_redirect.py crossed the file cap it was
    previously exactly at: 300 → 307 lines (+2.3%), from the extraction's
    rationale docstring (the same pattern used for every other class in
    this sweep) landing on a file that had zero headroom. Not split
    further — a dedicated file-size refactor of this module is outside
    this issue's scope (§15.1) and would mix an enabling refactor with this
    PR's mechanical extraction.
  • A handful of already-over-cap files/functions grew by 2-10 lines each
    from mechanical call-site adaptation (x.method()helper(x)
    sometimes needing an extra wrapped line): match_skills (+10),
    conflict_monitor_rerank (+2), apply_habituation (+2),
    run_distill_drain_cycle (+4), curate_wiki.py::handler (+2), and two
    test functions in test_headless_authoring_throttle.py /
    test_staging_resolve_sink.py (+2/+4). None introduce new logic; all
    verified against origin/main line counts per function.

Completion Ledger (§13.2)

Path in diff Asserting test Evidence
30 dataclasses × extracted methods (see mutation table) existing + 3 new test files (below) mutation table above: 667/677 killed, 10 documented equivalent
tests_py/benchmarks/test_verification_report.py (new) itself exercises ExpResult free functions
tests_py/core/test_sensory_buffer_dataclass.py (new) itself exercises BufferItem free functions
tests_py/core/test_wiki_schema_loader_registry.py (new) itself exercises WikiRegistry free functions
recall_pipeline.py::goal_maintenance_rerank getattr→goal_vector_is_active fix tests_py/core/test_goal_maintenance_wiring.py full suite green; A3 rerank path re-verified
tests_py/fuzz/test_corpus_replay.py module-qualified import fix test_the_replayer_is_present, test_at_least_one_harness_exists, test_harness_holds_on_its_whole_corpus (unchanged behavior) mutants for harness_module_path/harness_corpus_path/harness_inputs/harness_consume now scored (35 total) instead of 100% "not checked"
harness_consume's missing-consume() and module-preregistration paths test_harness_consume_missing_consume_raises_exact_message (new), test_harness_consume_preregisters_module_before_exec (new) exact RuntimeError message assertion; sys.modules[__name__] is not None assertion inside a synthetic harness
replay()'s exception-wrapping (touched by the harness_consume/harness_inputs call-site update) test_replay_wraps_consume_failure_with_input_context (new) exact AssertionError message + __cause__ assertion
benchmarks/lib/__init__.py lazy BenchmarkDB re-export (PEP 562 __getattr__) tests_py/benchmarks/test_lib_init_no_psycopg.py (new) subprocess with psycopg/psycopg_pool/pgvector poisoned in sys.modules; imports benchmarks.lib.verification_report successfully
All call-site updates (auto_curator.py, document_normalizer.py, wiki_classifier.py, wiki_kind_detection.py, wiki_sync.py, write_gate.py, streaming/__init__.py, streaming/adaptive_writer.py, handler modules) full suite 6868 passed / 0 failed
Whole-repo dataclass-with-methods sweep issue's own AST repro script TOTAL: 0

Boy-scout (§14)

Defects seen and fixed in this PR (all in material this diff touches or
that this diff's own mutation-testing verification step executed
against):

  1. getattr(goal, "is_active", False) stale-attribute regression in
    recall_pipeline.py (found while re-reading call sites of every
    extracted method).
  2. tests_py/fuzz/test_corpus_replay.py's file-path import defeating
    mutmut's coverage tracking for the entire Harness extraction (found
    while producing the mutation-evidence table).
  3. benchmarks/lib/__init__.py eagerly importing psycopg at
    package-init, breaking SQLite-only installs of one of the 26 extracted
    files (found the same way).
  4. test_benchmark_db_resolves_with_psycopg_present (added for defect 3)
    itself assumed psycopg is always installed; CI's "Test (SQLite
    backend)" job installs without the Postgres extras and failed with
    exactly the ModuleNotFoundError the code under test is supposed to
    raise loudly — the test's premise, not the code, was wrong. Guarded
    with pytest.importorskip("psycopg") (caught by this PR's own CI run,
    fixed same-day before merge).

No other seen-and-unfixed defects; no bypass used (no skip flags, no
narrowed globs, no temp-dir dodges — each defect above was fixed at its
source and re-verified).

Scope note (§15)

The issue's own acceptance criteria suggested sequencing this as several
PRs (one per module family) given the size. Per the release-blocker
directive for this issue queue (no new issues, adjacent PRs only where
genuinely necessary), this PR completes the full 30-class sweep in one
diff rather than splitting further — the whole-repo AST sweep confirms
there is no remainder.

cdeust and others added 3 commits July 30, 2026 16:47
…ions (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
'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
…s 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
@cdeust
cdeust merged commit 886b2dd into main Jul 30, 2026
19 checks passed
@cdeust
cdeust deleted the fix-dataclass-mutation-blindspot-282 branch July 30, 2026 17:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mutation: 29 @dataclass classes in mcp_server/ carry methods invisible to mutmut (same defect class as issue #262)

1 participant