Skip to content

qa-mutants

qa-mutants

qawriteshands-off

Use this when: surviving mutants need closing with real tests

QA mutants (kill the survivors)

Close surviving mutants by adding tests, lifting the codebase to the 95% covered-MSI nightly gate. The executor for Epic #131. Read docs/agents/mutation-testing.md + the CLAUDE.md Mutation Testing Policy first — this skill is that policy, automated.

Two modes

  • Interactive (/qa-mutants, a dev driving) — kill survivors on the current branch, then report. You commit/PR; the skill never merges. Normal surface-then-confirm.
  • Autonomous (headless, qa-mutants-autofix.yml after a red nightly) — run the full loop + merge policy below unattended.

The loop (autonomous)

  1. Read the survivors. From the failing mutation (nightly full) run, or locally via the memory-safe escape hatch php artisan mutate:diff --no-parallel (parallel mutation is CI-only — the block hook denies it). Tally by file:line + mutator.
  2. Kill every one. This is the skill — see the discipline below. Account for each survivor; none left un-examined.
  3. Re-run mutation to verify, then fix the remainder.
  4. Repeat, capped at 5 iterations OR 120 minutes. Still < 95% at the cap → raise an issue documenting the remaining survivors, linked to #131.

The discipline (every survivor, one of two ways)

  • Default — kill it. Add the assertion that pins the branch / boundary / value. A survivor you "can't be bothered" to test is a real gap, not an equivalent.
  • DTO & casts() mutants are killable, not ignorable. RemoveStringCast/RemoveIntegerCast/RemoveBooleanCast in from*() hydrators and the casts() method have observable behaviour (HTTP/JSON arrives as strings) — hydrate from a string and assert the strict resulting type (->toBe(15), not '15'). Kill before reaching for an ignore.
  • Last resort — reason-ignore a genuine equivalent, with the targeted // @pest-mutate-ignore: Mutator form + a one-line why (own line above the statement; doesn't reach if/foreach openers, break/continue, mid-chain). An un-reasoned ignore is rubber-stamping — the bar is high.
  • Never lower a threshold; never contort production code to suit the tool. If a mutant reveals a real bug, fix the code and add the covering test.

Auto-merge bar (autonomous mode only — maintainer-approved)

The safety crux: a fixer optimising for "go green" can re-create a false-green with tautological tests or over-ignoring. So auto-merge only when there's nothing to second-guess:

Auto-merge iff ALL hold:

  • every targeted survivor killed by an added assertion,
  • zero new @pest-mutate-ignore,
  • the diff is tests-only (no app/ changes),
  • the post-fix mutation run actually reports ≥ 95%,
  • composer ci:check is green.

Otherwise open a PR for review — any of: an ignore was needed (equivalence is a judgement call), production code had to change, or only partial progress. Budget exhausted without 95% → raise an issue (don't merge a half-fix).

Done when

Autonomous: the nightly is back to ≥ 95% covered-MSI and either auto-merged (bar met) or a PR/issue is open with the residual. Interactive: survivors on the branch are resolved and reported, composer ci:check green, left for the dev to ship.

Not in check-everything: this is an autonomous, codebase-wide, auto-merging daemon — too heavy for a per-change health sweep, and qa-tests already owns the interactive pre-PR mutation review there.