Files
ironclaw/scripts/ci/mutation_triage_queue.py
firat.sertgoz 02edc4e688 test(mutation): add a mutation-audit harness, and fix the run-ordering contract it found (#6674)
* test(projections): make the run-ordering contract actually detect its own bug

Mutation testing (`cargo mutants` over runtime_projection.rs, 39 mutants)
found `sort_runs_for_projection` could be replaced with a no-op while the whole
suite stayed green — including
`replay_projection_orders_runs_by_recent_activity_descending`, which exists
specifically to pin that ordering.

The test was not wrong, it was too weak to fail. `RuntimeProjectionState::runs`
is a `HashMap`, so `into_values()` yields iteration order, and with only two
invocations that order matches sorted order roughly half the time. The test
passed with the sort deleted by luck, and would have kept passing at whatever
rate `RandomState` happened to produce.

Six invocations now, with UUIDs deliberately not in append order so hash order
cannot trivially coincide with either append or sorted order. Accidental
agreement drops from ~1-in-2 to ~1-in-720. Strengthened in place rather than
added alongside, per the consolidate-don't-proliferate rule.

Also gitignores `mutants.out/`, the cargo-mutants report tree. It is a
regenerable local-audit artifact that currently shows up as untracked.

Verification: re-ran the same 39 mutants against the strengthened suite —
`sort_runs_for_projection` moves from MISSED to caught (20 caught / 2 missed,
was 19 / 3). Crate suite green, fmt and
`clippy -p ironclaw_event_projections --tests -- -D warnings` clean.

Two survivors remain and are deliberately not addressed here:

- `retain_invocations` -> no-op survives. It is the only enforcement of
  `fold_runtime_prefix`'s documented "invocations identified by `touched`" and
  `O(touched)` memory contracts, on a checkpoint that accumulates across pages.
  A regression test for it failed against unmutated code, meaning the intended
  contract is not what I assumed; whether the runs list is page-scoped or
  thread-scoped is a product question. Filed rather than guessed — weakening the
  assertion to match current behavior would defeat the purpose.
- `> -> >=` in `enforce_capability_activity_output_limit` is an equivalent
  mutant, not a coverage gap: at `len == limit` the mutated branch runs
  `select_nth_unstable_by` then a `truncate` that is a no-op, then the same full
  sort, so both paths produce identical output. No test can catch it.

Refs #6524

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(mutation): add a mutation-audit harness with a mechanical acceptance gate

Coverage proves a line ran; it says nothing about whether anything checked the
result. Mutation testing closes that gap by sabotaging one piece of production
code at a time and re-running the suite — a sabotage the suite still passes is
code with no assertion behind it.

This lands the tooling so the audit can be worked at volume. Epic #6524
workstream 11.

- `scripts/mutation-audit.sh` runs a scoped audit and emits a triage queue.
- `scripts/ci/mutation_triage_queue.py` turns the report into a self-contained
  work queue: each survivor with its sabotage diff and enclosing source inlined,
  so nobody has to go hunting. Scores over *viable* mutants, since unviable ones
  failed to compile and carry no signal.
- `scripts/mutation-verify-fix.sh` is the acceptance gate and the reason the
  queue scales: a fix is accepted only when the suite passes on real code AND
  fails with the sabotage applied. The first condition rejects a test reshaped
  to match current behaviour; the second rejects a decorative test. Both failure
  modes were produced while developing this harness, and both are now
  auto-rejected without anyone reviewing a diff.
- `docs/internal/mutation-audit.md` documents the verdict taxonomy
  (`real-gap` / `equivalent-mutant` / `needs-product-decision`) and the staged
  rollout.

Two traps are encoded in the tooling because both produced confidently wrong
answers during development:

1. **An unscoped run silently finds zero mutants.** Without `--package`,
   cargo-mutants scopes to the workspace-root package, which has no lib/bin. The
   result reads as a clean bill of health. The script now refuses to run
   unscoped.
2. **A shared `CARGO_TARGET_DIR` corrupts verdicts in both directions.**
   cargo-mutants copies the tree per job; pointing every copy at one absolute
   target directory lets parallel jobs clobber each other's artifacts, so a job
   can test a binary built from a different mutant's source. Observed, not
   theorised: the same mutant reported MISSED with a shared dir and caught
   without one, on byte-identical source. The dangerous direction is a surviving
   mutant reported as caught, which launders a real hole as covered. Both
   scripts now unset it and say so.

`equivalent-mutant` and `needs-product-decision` are first-class verdicts, not
escape hatches. Roughly a third of the first run's survivors could not be caught
by any test, and one needed a product decision about intended behaviour. Without
those verdicts, queue work degenerates into writing tests that assert current
behaviour — manufacturing the decorative coverage the audit exists to remove.
This is also why a mutation score must never gate CI.

Regression coverage: `scripts/test-mutation-audit.sh`, 15 hermetic cases (no
cargo) pinning each trap above plus empty-report and missing-report handling.
Guardrails are code; a checker that silently does nothing is worse than none.

Validation:
- `./scripts/test-mutation-audit.sh` — 15/15 pass
- `bash -n` clean on all three scripts; `py_compile` clean
- End-to-end against a real crate: the gate ACCEPTS
  `sort_runs_for_projection` (fixed in #6674) and correctly reports the two
  remaining survivors; it REJECTED the same mutant while a shared
  CARGO_TARGET_DIR was set, which is how trap 2 was found.
- Independently confirmed the #6674 fix outside cargo-mutants entirely: applied
  the sabotage by hand and ran the test 20 times — 20/20 caught.

Refs #6524

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(mutation): fix the harness's own guards after review

Addresses five review findings on #6674. Two were functional and one exposed
that a self-test was passing for the wrong reason.

1. **The cargo-mutants presence check ran before argument validation**, so on a
   machine without the tool the unscoped-run guard was unreachable and reported
   "not installed" instead of the usage error. The self-tests asserting that
   guard passed anyway — because the author had cargo-mutants installed. A
   self-test that depends on the developer's environment is the opposite of the
   hermetic guarantee its own header claims. Moved the check after validation
   and added case A2, which runs both scripts under a stub PATH containing no
   cargo at all.

2. **Grep alternation precedence in the baseline-failure check.**
   `^(FAILED|ERROR).*[Uu]nmutated baseline|baseline failed` parses as
   `(^(FAILED|ERROR).*Unmutated baseline)|(baseline failed)`, so any log line
   containing "baseline failed" anywhere tripped it. In a mechanical gate a
   false rejection is a real cost. Grouped the alternation under the prefix;
   verified a stray occurrence no longer matches while a real baseline failure
   still does.

3. `--help` printed through `set -euo pipefail` because the sed range outran the
   comment block. Stops at the last comment line now, like its sibling script.

4. Extracted `_count_nonempty_lines`, removing three copies of the same
   "filter non-blank lines from an optional file" logic and bringing
   `build_queue` back under Ruff's PLR0915 statement limit.

5. `window[:60] + [...]` -> `[*window[:60], ...]` (Ruff RUF005).

Validation: 17/17 self-tests pass (15 before, plus the two new absent-PATH
cases); `bash -n` and `py_compile` clean; `ruff check --select
RUF005,PLR0915,ARG,F401` clean on the queue generator; `--help` output and both
grep-precedence directions checked by hand.

Refs #6524

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(mutation): finish the queue-generator lint cleanup

Follow-up to the review fixes: the `_count_nonempty_lines` extraction alone did
not clear Ruff PLR0915 (it traded two comprehensions for two calls, roughly net
zero — still 60 statements). I pushed 83282bb38 before reading the ruff output
properly, so this completes it.

`build_queue` is now decomposed into `_preamble`, `_entry`, and
`_load_outcomes`, which drops it well under the statement limit and makes each
piece independently readable.

Also parenthesised six implicit string concatenations inside list literals
(ISC004). Not chased for the linter's sake — the repo has no ruff config and its
existing Python scripts report 16 default-ruleset findings — but because inside
a list `"a" "b"` looks like two entries and is one, so a dropped comma silently
merges rows of the verdict table.

`ruff check` is now clean on the file with the default ruleset. 16 self-tests
pass and the generated queue is byte-identical on real report data (20/22 over
viable mutants, both sabotage diffs inlined).

Refs #6524

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:56:00 +02:00

207 lines
6.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Turn a cargo-mutants report into a self-contained triage queue.
Each surviving mutant becomes one entry carrying the sabotage diff and the
enclosing source, so whoever works the queue never has to go find the code.
That matters at scale: the queue is the unit of work, and an entry that
requires hunting is an entry that gets skipped or guessed at.
Verdict taxonomy is documented in docs/internal/mutation-audit.md. The
`needs-product-decision` verdict is load-bearing, not a cop-out: without it,
whoever works the queue will invent an intended contract and write a test
asserting it, which manufactures exactly the decorative coverage this audit
exists to find.
"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
MUTANT_LINE = re.compile(r"^(?P<file>[^:]+):(?P<line>\d+):(?P<col>\d+): (?P<what>.*)$")
def _enclosing_rust_item(source_path: Path, line_number: int) -> tuple[str, int]:
"""Source of the fn/impl containing `line_number`, plus its start line.
Deliberately simple: scan back to the nearest column-0 or 4-space `fn`, then
forward to the next one. A mutation report is read by a human or an agent,
not parsed, so an approximate window beats a dependency on a Rust parser.
"""
try:
lines = source_path.read_text().splitlines()
except OSError:
return ("<source unavailable>", 0)
start = 0
opener = re.compile(r"^(\s{0,4})(pub(\([^)]*\))?\s+)?(async\s+)?(unsafe\s+)?fn\s")
for index in range(min(line_number, len(lines)) - 1, -1, -1):
if opener.match(lines[index]):
start = index
break
end = len(lines)
for index in range(start + 1, len(lines)):
if opener.match(lines[index]):
end = index
break
window = lines[start:end]
# Keep entries readable; a very long function is a smell worth seeing, but
# the queue should not become a source dump.
if len(window) > 60:
window = [*window[:60], " // … truncated, see the file for the rest"]
return ("\n".join(window), start + 1)
def _count_nonempty_lines(path: Path) -> int:
"""Non-blank lines in one of cargo-mutants' plain-text result files."""
if not path.is_file():
return 0
return len([line for line in path.read_text().splitlines() if line.strip()])
def _diff_for(report_dir: Path, outcomes: dict, mutant: str) -> str:
"""The sabotage cargo-mutants applied, as a diff, when it recorded one."""
for outcome in outcomes.get("outcomes", []):
scenario = outcome.get("scenario")
if not isinstance(scenario, dict):
continue
described = scenario.get("Mutant", {})
if not isinstance(described, dict):
continue
# `name` is already the exact string cargo-mutants writes to
# missed.txt, so match on it rather than rebuilding it from parts.
if str(described.get("name", "")).strip() != mutant.strip():
continue
diff_path = outcome.get("diff_path")
if not diff_path:
continue
candidate = report_dir / diff_path
if candidate.is_file():
return candidate.read_text()
return ""
def _preamble(survivors: int, caught: int, viable: int, unviable: int) -> list[str]:
"""Header block: the counts, and how to read them."""
lines = [
"# Mutation audit triage queue",
"",
(
f"- **{survivors} survivors** to triage "
f"({caught} caught, {viable} viable, {unviable} unviable/ignored)"
),
]
if viable:
lines.append(f"- Caught rate over viable mutants: **{caught}/{viable}**")
lines += [
(
"- Score the caught rate over *viable* mutants only. Unviable "
"mutants failed to compile and carry no signal."
),
"",
(
"Assign every entry exactly one verdict. See "
"`docs/internal/mutation-audit.md`."
),
"",
"| verdict | meaning | next step |",
"|---|---|---|",
(
"| `real-gap` | behaviour genuinely unasserted | write a test, "
"then `scripts/mutation-verify-fix.sh` must accept it |"
),
(
"| `equivalent-mutant` | no test *can* catch it — the change "
"cannot alter observable behaviour | record the reasoning; no test |"
),
(
"| `needs-product-decision` | the intended contract is unclear | "
"route to an owner; **do not invent one** |"
),
"",
"---",
"",
]
return lines
def _entry(index: int, mutant: str, report_dir: Path, outcomes: dict) -> list[str]:
"""One survivor, with enough context to triage without opening the file."""
lines = [
f"## {index}. `{mutant}`",
"",
"- verdict: `TODO`",
"- reasoning: _TODO_",
"",
]
match = MUTANT_LINE.match(mutant)
if match:
source_path = Path(match.group("file"))
body, start_line = _enclosing_rust_item(source_path, int(match.group("line")))
lines += [
f"Enclosing item (`{source_path}:{start_line}`):",
"",
"```rust",
body,
"```",
"",
]
diff = _diff_for(report_dir, outcomes, mutant)
if diff:
lines += ["Sabotage applied:", "", "```diff", diff.strip(), "```", ""]
lines += ["---", ""]
return lines
def _load_outcomes(report_dir: Path) -> dict:
"""cargo-mutants' machine-readable report, when it wrote a usable one."""
outcomes_path = report_dir / "outcomes.json"
if not outcomes_path.is_file():
return {}
try:
return json.loads(outcomes_path.read_text())
except json.JSONDecodeError:
return {}
def build_queue(report_dir: Path) -> str:
missed_path = report_dir / "missed.txt"
if not missed_path.is_file():
raise SystemExit(f"no missed.txt in {report_dir} — was the audit run?")
survivors = [line for line in missed_path.read_text().splitlines() if line.strip()]
outcomes = _load_outcomes(report_dir)
caught = _count_nonempty_lines(report_dir / "caught.txt")
unviable = _count_nonempty_lines(report_dir / "unviable.txt")
out = _preamble(len(survivors), caught, caught + len(survivors), unviable)
for index, mutant in enumerate(survivors, start=1):
out += _entry(index, mutant, report_dir, outcomes)
if not survivors:
out += ["No surviving mutants. Nothing to triage.", ""]
return "\n".join(out)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--report-dir", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
queue = build_queue(args.report_dir)
args.output.write_text(queue)
print(f"wrote {args.output}")
if __name__ == "__main__":
main()