Summary
mutmut run reports mutants as survived that the test suite actually kills. The cause is not a coverage gap: mutmut's cached test-selection map (mutants/mutmut-stats.json) can be partial, and once it exists mutmut never repairs it — so every mutant of an affected function is checked against a too-small subset of tests.
Separately, the nightly mutation workflow on main has failed on at least four consecutive nights (runner shutdown at ~1.5–2 h, ~65 % through the mutant list), so AGENTS.md principle 5's "zero surviving mutants" is currently unverified on main.
Evidence — false survivors
During PR #34 review follow-up I ran targeted mutation testing and re-verified every reported survivor by running the suite against the live mutant:
cd mutants && MUTANT_UNDER_TEST=<name> PYTHONPATH=src \
uv run --project .. python -m pytest <relevant test files> -x -q
Two batches, every reported survivor re-checked:
| batch |
mutmut said "survived" |
actually killed by the suite |
| argstate / store / flows, round 1 |
37 |
21 (57 %) |
| same modules, round 2 |
8 |
8 (100 %) |
Concrete case — skit.argstate.x__purge_secret_locked__mutmut_16 (doc["values"] = _strip_secrets(...) → doc["values"] = None):
mutmut results → survived
MUTANT_UNDER_TEST=…__mutmut_16 pytest tests/test_argstate_mut.py → FAILED test_purge_secret_reports_names_removed_across_values_and_presets
That killing test is absent from the cached map:
$ python -c "import json; d=json.load(open('mutants/mutmut-stats.json')); \
print(len(d['tests_by_mangled_function_name']['skit.argstate.x__purge_secret_locked']))"
11 # and test_purge_secret_reports_names_removed_across_values_and_presets is not among them
Re-running mutmut's own StatsCollector over that one file records 6/6 purge tests, including the killer — so the test does execute the function; the cached map is simply missing it.
Root cause
mutmut/__main__.py:
def collect_or_load_stats(runner):
did_load = load_stats()
if not did_load:
run_stats_collection(runner) # full collection
else:
... # incremental
new_tests = <test ids not already in duration_by_test>
run_stats_collection(runner, tests=new_tests)
and the collection itself runs pytest with -x:
pytest_args = ["-x", "-q", "-p", "no:randomly", "-p", "no:random-order"]
So:
- One failing test aborts stats collection mid-suite. Every test after the abort point contributes nothing to
tests_by_mangled_function_name. (Reproduced: an abort at tests/test_childenv.py leaves everything alphabetically after it unmapped.)
- The partial map is then permanent.
load_stats() succeeds on the next run, so mutmut takes the incremental path and only re-collects for test IDs it has never seen. An unchanged test that now exercises a newly added or newly extracted function is never re-mapped — load_stats merges with |=, so the map only ever grows by whole new test IDs, never gets corrected.
- Mutants of those functions are then checked against whatever subset happened to be recorded, and anything the missing tests would have killed is reported as survived.
Ruled out while diagnosing: max_stack_depth truncation in record_trampoline_hit (defaults to -1, i.e. disabled — we do not set it), and a wholly aborted stats phase (duration_by_test held all 5 940 test IDs).
Why CI is a separate question
The nightly workflow does not cache mutants/ (enable-cache: true is uv's package cache), so each run starts with no stats file and does a full collection. That path is only vulnerable via -x if a test fails during the stats phase. Main's nightly failures are a timeout/shutdown problem, not obviously this one — but the 1 472 survivors it reported before dying have not been verified either way, and the same "no repair" design applies to any run that aborts.
Suggested actions
Impact
Not a product bug — no shipped behaviour is wrong. But the mutation gate is currently reporting noise, and a survivor list that is >50 % false is a gate people learn to ignore. The verification loop above is cheap and makes the gate trustworthy again.
Summary
mutmut runreports mutants as survived that the test suite actually kills. The cause is not a coverage gap: mutmut's cached test-selection map (mutants/mutmut-stats.json) can be partial, and once it exists mutmut never repairs it — so every mutant of an affected function is checked against a too-small subset of tests.Separately, the nightly mutation workflow on
mainhas failed on at least four consecutive nights (runner shutdown at ~1.5–2 h, ~65 % through the mutant list), so AGENTS.md principle 5's "zero surviving mutants" is currently unverified on main.Evidence — false survivors
During PR #34 review follow-up I ran targeted mutation testing and re-verified every reported survivor by running the suite against the live mutant:
Two batches, every reported survivor re-checked:
Concrete case —
skit.argstate.x__purge_secret_locked__mutmut_16(doc["values"] = _strip_secrets(...)→doc["values"] = None):mutmut results→survivedMUTANT_UNDER_TEST=…__mutmut_16 pytest tests/test_argstate_mut.py→FAILED test_purge_secret_reports_names_removed_across_values_and_presetsThat killing test is absent from the cached map:
Re-running mutmut's own
StatsCollectorover that one file records 6/6 purge tests, including the killer — so the test does execute the function; the cached map is simply missing it.Root cause
mutmut/__main__.py:and the collection itself runs pytest with
-x:So:
tests_by_mangled_function_name. (Reproduced: an abort attests/test_childenv.pyleaves everything alphabetically after it unmapped.)load_stats()succeeds on the next run, so mutmut takes the incremental path and only re-collects for test IDs it has never seen. An unchanged test that now exercises a newly added or newly extracted function is never re-mapped —load_statsmerges with|=, so the map only ever grows by whole new test IDs, never gets corrected.Ruled out while diagnosing:
max_stack_depthtruncation inrecord_trampoline_hit(defaults to-1, i.e. disabled — we do not set it), and a wholly aborted stats phase (duration_by_testheld all 5 940 test IDs).Why CI is a separate question
The nightly workflow does not cache
mutants/(enable-cache: trueis uv's package cache), so each run starts with no stats file and does a full collection. That path is only vulnerable via-xif a test fails during the stats phase. Main's nightly failures are a timeout/shutdown problem, not obviously this one — but the 1 472 survivors it reported before dying have not been verified either way, and the same "no repair" design applies to any run that aborts.Suggested actions
mutants/mutmut-stats.jsonis the current workaround and should be documented in AGENTS.md next touv run mutmut run.MUTANT_UNDER_TESTset and reports only the ones that really survive.scripts/check_mutation_stats.pyis the natural home.timeout-minutes, or run incrementally against changed files.collect_or_load_stats's incremental path cannot repair a partial map, and that-xduring stats collection silently produces one.Impact
Not a product bug — no shipped behaviour is wrong. But the mutation gate is currently reporting noise, and a survivor list that is >50 % false is a gate people learn to ignore. The verification loop above is cheap and makes the gate trustworthy again.