Files
ironclaw/scripts/mutation-verify-fix.sh
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

142 lines
4.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# Prove that a new test actually kills the mutant it claims to kill.
#
# This is the acceptance gate for mutation-driven test work, and the reason the
# triage queue can be worked at scale without trusting anyone's judgement: the
# criterion is mechanical, not a review opinion.
#
# A fix is accepted only when BOTH hold:
# 1. the suite passes on unmodified code (the test is not just broken)
# 2. the suite fails with the sabotage applied (the test actually checks it)
#
# Condition 2 is what a decorative test cannot satisfy, and condition 1 is what
# a test reshaped to match current behaviour cannot satisfy. Together they
# reject both failure modes without a human reading the diff.
#
# Usage:
# ./scripts/mutation-verify-fix.sh -p <package> \
# 'crates/foo/src/bar.rs:80:5: replace baz with ()'
#
# The mutant string is copied verbatim from mutants.out/missed.txt or the
# triage queue.
#
# Options (env vars):
# MUT_TIMEOUT=300 Per-mutant timeout in seconds (default: 300)
#
# Exit codes: 0 accepted · 1 rejected · 2 usage/tooling error
set -euo pipefail
MUT_TIMEOUT="${MUT_TIMEOUT:-300}"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
# A shared CARGO_TARGET_DIR silently corrupts mutation results and must never
# be inherited here. cargo-mutants copies the source tree per job; if every
# copy is redirected at one absolute target directory, parallel jobs clobber
# each other's compiled artifacts and a job can run a test binary built from a
# different job's mutated source. The verdicts then come out wrong in BOTH
# directions — a killed mutant reported as surviving, and worse, a surviving
# mutant reported as caught. This was observed, not theorised: the same mutant
# reported MISSED with a shared dir and caught without one, on identical source.
# Correctness beats the warm cache for a tool whose only job is to be trusted.
if [ -n "${CARGO_TARGET_DIR:-}" ]; then
echo "note: ignoring CARGO_TARGET_DIR=$CARGO_TARGET_DIR — a shared target" >&2
echo " directory produces wrong mutation verdicts (see comment above)." >&2
unset CARGO_TARGET_DIR
fi
package=""
mutant=""
while [ $# -gt 0 ]; do
case "$1" in
-p | --package)
package="$2"
shift 2
;;
-h | --help)
sed -n '2,26p' "${BASH_SOURCE[0]}" | sed 's|^# \{0,1\}||'
exit 0
;;
*)
mutant="$1"
shift
;;
esac
done
if [ -z "$package" ] || [ -z "$mutant" ]; then
echo "usage: $0 -p <package> '<mutant line from missed.txt>'" >&2
exit 2
fi
if ! command -v cargo-mutants >/dev/null 2>&1; then
echo "error: cargo-mutants not installed (cargo install cargo-mutants --locked)" >&2
exit 2
fi
# The mutant line is "<file>:<line>:<col>: <description>". cargo-mutants
# --file wants just the path, and re-running the whole file is what lets us
# assert this specific mutant flipped to caught.
file="${mutant%%:*}"
if [ ! -f "$file" ]; then
echo "error: mutant does not name an existing file: $file" >&2
exit 2
fi
work="$(mktemp -d "${TMPDIR:-/tmp}/ironclaw-mutation-verify.XXXXXX")"
cleanup() { rm -rf "$work"; }
trap cleanup EXIT
echo "▶ verifying fix for:"
echo " $mutant"
echo
set +e
cargo mutants --package "$package" -f "$file" \
--timeout "$MUT_TIMEOUT" --jobs 3 --output "$work" >"$work/run.log" 2>&1
status=$?
set -e
if [ ! -f "$work/mutants.out/missed.txt" ]; then
echo "REJECTED: cargo mutants produced no report (status $status)." >&2
tail -20 "$work/run.log" >&2
exit 2
fi
# Baseline failure means the suite does not pass on unmodified code, so nothing
# downstream is meaningful — this catches a test written to match a mutant
# rather than the intended behaviour.
if grep -qiE "^(FAILED|ERROR).*([Uu]nmutated baseline|baseline failed)" "$work/run.log"; then
echo "REJECTED: the test suite does not pass on unmodified code." >&2
echo " A regression test must pass before it can prove anything." >&2
exit 1
fi
if grep -Fq "$mutant" "$work/mutants.out/missed.txt"; then
echo "REJECTED: that sabotage still survives — the suite passes with it applied."
echo " The new test does not actually check this behaviour."
echo
echo "Still-surviving mutants in $file:"
sed 's/^/ /' "$work/mutants.out/missed.txt"
exit 1
fi
if ! grep -Fq "$mutant" "$work/mutants.out/caught.txt" 2>/dev/null; then
echo "REJECTED: that mutant is neither missed nor caught in this run." >&2
echo " It is likely unviable now, or the string does not match any" >&2
echo " generated mutant — re-copy it from missed.txt verbatim." >&2
exit 1
fi
echo "ACCEPTED: the sabotage is now caught."
echo " suite passes on real code, and fails once this mutant is applied."
echo
echo "Remaining survivors in $file (for the triage queue):"
if [ -s "$work/mutants.out/missed.txt" ]; then
sed 's/^/ /' "$work/mutants.out/missed.txt"
else
echo " none"
fi