mutant-survival-triage
Normalizes a surviving-mutant record across StrykerJS, PIT, mutmut, and Mull into one shape, classifies why it survived (missing case, weak assertion, equivalent mutant, unreachable code, flaky killer), applies per-mutator heuristics for conditional-boundary, arithmetic-operator, statement-removal, and constant mutations, and drafts the specific test that would kill it. Includes the full read-only investigation workflow: take a mutation report (Stryker JSON / PIT XML / mutmut output / Mull JSON) plus the repo at the same commit, read each survivor's mutated line and covering tests, classify, and propose - never auto-rewriting tests. Treats equivalence as a judgment call, because deciding whether a mutant is equivalent to the original is undecidable in general, so a residual survivor rate is expected rather than a defect. Use when a mutation run has finished and the report lists surviving mutants (typically 5+) that nobody has yet explained or turned into concrete test cases.
Install with skills.sh (any agent)
npx skills add testland/qa --skill mutant-survival-triagemutant-survival-triage
Turns "this mutant survived" into "here is the specific test that would kill it, and here is the input that separates the mutant from the original."
What this owns, and what it does not
Owns: one surviving mutant at a time. Reading the mutated line and the tests that already covered it, deciding which of five reasons explains the survival, and writing a concrete test proposal (inputs plus the assertion) that would flip the mutant to killed.
Does not own: installing or configuring a mutation testing tool, choosing mutate globs, picking a mutation-score threshold, or wiring a build gate. Those are per-tool configuration decisions and are settled before a report exists. This work starts at the report and ends at a test proposal a human writes.
Running the triage as an investigation
Inputs: a mutation report (Stryker JSON, PIT XML, mutmut output, Mull JSON) and the source repo at the same commit. For each surviving mutant: read the report entry, the mutated line with its surrounding context, and the tests that executed the line without killing it (git log / git blame help date the line and its tests); then classify (Step 2) and propose (Step 4).
Ground rules for the investigation - it is read-only:
Downstream hand-offs: weak-assertion survivors → test-code-critic (qa-test-review, assertion dimension); flaky-killer survivors → e2e-flake-bisector (qa-flake-triage, shared-state isolation stage); "where to add tests" rather than "what to test" → test-coverage-targeter (qa-test-reporting).
Step 1 - Normalize the survivor into one record
Every tool reports the same underlying facts in a different container. Flatten each survivor into one record before classifying, so the heuristics below stay tool-independent:
interface SurvivedMutant {
tool: 'stryker' | 'pit' | 'mutmut' | 'mull';
file: string;
line: number;
column?: number;
mutator: string; // tool-native operator name, kept verbatim
original: string; // the source text before mutation
mutated: string; // the source text after mutation
coveredBy: string[]; // tests that executed the line and did not kill it
}Where each field comes from per tool, and why the non-killed statuses must not be merged (Survived vs NoCoverage / no-coverage, Timeout, and the rest), is in references/tool-normalization.md. One rule from there is load-bearing for Step 2: a no-coverage mutant is never a weak-assertion finding, because no assertion ran.
Step 2 - Classify why it survived
Work the classes in this order. The cheap, tool-asserted ones come first, and equivalent-mutant comes last because it is the only class that cannot be proven (Step 5).
| Class | Signal to look for | Recommended action |
|---|---|---|
unreachable | The tool reported no coverage, or the line sits in a path no caller can reach. | Delete the dead code, or record why the path is intentionally unexecuted. |
flaky-killer | The same mutant id is killed in one run and survives in another with no source change; the covering test also appears in flake history. | Stabilize the covering test first, then re-run the mutant. A flaky test is not a mutation finding. |
missing-case | The covering tests never supply an input on which the original and the mutant produce different observable behavior. | Add one test at the separating input (Step 3 gives the input per mutator). |
weak-assertion | A covering test does exercise a separating input, but its assertion is too loose to observe the difference. | Tighten that assertion to the exact expected value, then re-run. Do not add a second test. |
equivalent-mutant | No input exists on which the mutant and the original differ observably. | Record the reasoning, exclude the mutant, and accept the score cost (Step 5). |
The missing-case versus weak-assertion split is the whole decision: both look identical in the report, and only reading the covering test tells them apart. Ask one question: did any covering test already run the separating input? If yes the test is the defect; if no the suite is.
Step 3 - Per-mutator heuristics
The four mutation families below cover most survivors. Tool operator names differ, so match on the family, not the string. The full operator-name-to-family mapping across StrykerJS, PIT, mutmut, and Mull, with sources, is in references/tool-normalization.md.
Conditional boundary
if (qty > maxQty) throw new Error('Cap exceeded'); // original
if (qty >= maxQty) throw new Error('Cap exceeded'); // mutant, survivedThe separating input is always the boundary value itself. Here only qty === maxQty distinguishes the two: the original does not throw, the mutant does. Any test at qty < maxQty or qty > maxQty behaves identically under both and can never kill this mutant, no matter how strict its assertion.
Propose: one test at exactly the boundary, asserting the original behavior.
Arithmetic operator
const total = subtotal + tax; // original
const total = subtotal - tax; // mutant, survivedTwo survival causes, and they need different fixes:
Propose: the non-identity operand plus an exact-value assertion.
Statement or call removal
notifyUser(orderId); // original
// mutant: the call is gone, and it survived
return success;A removed call survives whenever its only effect is outside the value under assertion. The return value is unchanged, so an assertion on the return value can never kill it. This is almost always missing-case for an unobserved side effect, not a loose assertion.
Propose: verify the effect, not the return. A test double on the collaborator asserting it was called with orderId, or an assertion against the state the call was supposed to change.
Constant or literal
const PAGE_SIZE = 42; // original
const PAGE_SIZE = 0; // mutant, survivedA surviving constant mutation means no assertion is coupled to the constant's value. The common shapes: the behavior the constant drives (here, pagination) has no test at all, or the test hard-codes its own copy of the value and never reads the production one. The second shape is the trap, because the test looks like coverage.
Propose: an assertion on the behavior the constant drives, reading the production constant rather than restating the literal. Note that PIT's default return mutators (EMPTY_RETURNS, NULL_RETURNS, PRIMITIVE_RETURNS) produce the same shape at the return boundary (PIT mutation operators (opens in new window)).
Step 4 - Propose the specific test
A proposal is complete only when all four are present:
Emit one block per survivor:
**Surviving mutant:** `src/cart.ts:42` - Equality Operator / GreaterThanBoundary
**Original:** `if (qty > maxQty) throw new Error('Cap exceeded');`
**Mutated:** `if (qty >= maxQty) throw new Error('Cap exceeded');`
**Class:** missing-case (boundary)
**Covering tests that did not kill it:**
- `cart.spec.ts > addItem qty=1` - `1 > 100` and `1 >= 100` are both false; identical behavior.
- `cart.spec.ts > addItem qty=101` - `101 > 100` and `101 >= 100` are both true; identical behavior.
**Separating input:** `qty === maxQty === 100`. Original: no throw. Mutant: throws.
**Proposed test (new):**
```ts
it('accepts a quantity exactly at the cap', () => {
const cart = new Cart({ maxQty: 100 });
expect(() => cart.addItem({ qty: 100 })).not.toThrow();
expect(cart.items).toHaveLength(1);
});
```
**If a boundary test already exists and the mutant still survived,** the suite is
not the only suspect. Either the assertion does not observe the throw (for example
it wraps the call in a try/catch and asserts nothing), or the production boundary
is off by one and the existing test encodes the wrong expectation. Confirm the
intended cap semantics against the specification before changing either side.
That last paragraph is the point of the whole step: a survivor is evidence that the test and the production code disagree with the specification somewhere, and "add another test" is only one of the two possible resolutions.
Step 5 - Equivalent mutants and the residual survivor rate
equivalent-mutant is a judgment, not a proof: finding equivalent mutants is undecidable in general (Straubinger, Degenhart and Fraser, 2024 (opens in new window), citing Budd and Angluin, Acta Informatica 18, 31 to 45, 1982). StrykerJS agrees "there is no definitive way for Stryker to find and ignore them" and to "accept that you won't make 100%" mutation score (Stryker equivalent mutants (opens in new window)); PIT calls them "equivalent mutations" and filters some by not mutating lines that call common logging frameworks (PIT basic concepts (opens in new window)).
Practical consequences for triage:
If you quote a mutation score, state the convention. StrykerJS documents two: detected / valid * 100, which counts uncovered mutants against you, and detected / covered * 100, which does not (mutant states and metrics (opens in new window)). Neither excludes equivalent mutants, because no tool can identify them reliably, so a score is always an underestimate of test strength by an unknown margin. Comparing two scores computed under different conventions is meaningless.
Output format
## Mutation survivor analysis - <run id>
**Tool:** stryker | pit | mutmut | mull
**Survivors analyzed:** N
**Mutation score:** X% (convention: detected / valid)
| Class | Count | Recommended action |
|---|---:|---|
| missing-case | 14 | Add one test per separating input. |
| weak-assertion | 7 | Tighten the named existing assertion. |
| equivalent-mutant | 3 | Exclude, reasoning recorded below. |
| unreachable / no coverage | 2 | Remove the code or document the path. |
| flaky-killer | 1 | Stabilize the covering test, then re-run. |
### Per-survivor detail
(one Step 4 block per survivor)
### Top priority (5 to 10)
1. `file:line` - class - one-line description of the test to write.Rank by blast radius of the mutated code, not by class. A surviving boundary mutant in a pricing rule outranks ten survivors in a formatter.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Auto-generating a test for every survivor | Machine-written tests assert whatever the current code does, which kills the mutant while locking in the behavior nobody checked against the specification. | Propose the test, let a human write and review it. |
| Skipping the equivalent-mutant class entirely | The team burns weeks on mutants that no test can kill, then abandons mutation testing as noise. | Classify explicitly, and expect a residual rate (Step 5). |
| Adding a new test next to a weak assertion | The loose assertion stays and keeps hiding the next regression on that line. | Split missing-case from weak-assertion (Step 2) and tighten in place when it is the latter. |
| One recommendation per file instead of per mutant | Specific, actionable suggestions get averaged into "improve tests for cart.ts". | One block per survivor (Step 4). |
| Treating the covering test as correct by default | The mutant may be revealing an off-by-one in production code that the test faithfully encodes. | Check the boundary against the specification before choosing a side (Step 4). |
| Quoting a mutation score without its convention | Two teams report incomparable numbers and draw conclusions from the difference. | State detected / valid or detected / covered every time (Step 5). |
Limitations
Tool normalization reference
View source (opens in new window)Tool normalization reference
Per-tool field mapping, status vocabularies, and operator-name-to-family tables for mutant-survival-triage. Linked inline from that skill's SKILL.md. Flatten each survivor into the SurvivedMutant record (see SKILL.md Step 1), then use these tables to fill the fields (Step 1) and match the mutator family (Step 3).
Where each field comes from
| Field | StrykerJS | PIT | mutmut | Mull |
|---|---|---|---|---|
mutator | mutatorName in the JSON report | mutator name from the mutator set, for example CONDITIONALS_BOUNDARY | operator implied by the diff shown in mutmut browse | operator id in brackets, for example [cxx_ge_to_gt] |
file / line | location (start and end line and column) | source file and line number in the HTML or XML report | the mutated function in the mutants/ directory | path:line:column prefix on the survivor line |
mutated | replacement | rendered on the report line | diff shown in mutmut browse | text after Survived: on the survivor line |
coveredBy | coveredBy (and killedBy when killed) | the covering tests PIT ran for that line | the tests mutmut selected for that function | not reported per mutant |
StrykerJS field names above are the properties the mutation testing report schema defines for a mutant result: id, mutatorName, replacement, description, location, status, statusReason, testsCompleted, static, coveredBy, killedBy, duration (report schema JSON (opens in new window)). Mull's console format is exactly /tmp/sc-tTV8a84lL/main.cpp:2:11: warning: Survived: Replaced >= with > [cxx_ge_to_gt] under a [info] Survived mutants (1/4): heading (Mull hello-world tutorial (opens in new window)). mutmut's documented workflow is mutmut run then mutmut browse, with mutants kept "in the mutants/ directory" and written back with mutmut apply <mutant> (mutmut docs (opens in new window)); it publishes no machine-readable report format, so the mutmut record is filled in from the browse UI rather than parsed.
Do not merge the status vocabularies
"Survived" is not the only non-killed outcome, and the neighbouring statuses change the classification in Step 2. Keep the tool's own status word:
| Tool | Statuses that are not "killed" |
|---|---|
| StrykerJS | Survived, NoCoverage, Timeout, RuntimeError, CompileError, Ignored, Pending (mutant states (opens in new window)) |
| PIT | survived, no coverage, timed out, non viable, memory error, run error (PIT basic concepts (opens in new window)) |
| mutmut | survived, plus timeout and suspicious outcomes surfaced in mutmut browse (mutmut docs (opens in new window)) |
| Mull | survived, listed under [info] Survived mutants (n/total): (Mull tutorial (opens in new window)) |
Two of these decide a class on their own. StrykerJS defines NoCoverage as "the mutant isn't covered by one of your tests and survived as a result" (mutant states (opens in new window)), and PIT states "No coverage is the same as Survived except there were no tests that exercised the line of code where the mutation was created" (PIT basic concepts (opens in new window)). A no-coverage mutant is never a weak-assertion finding: no assertion ran.
Per-mutator operator names by tool
Match on the family, not the string:
| Family | StrykerJS | PIT | mutmut | Mull |
|---|---|---|---|---|
| Conditional boundary | GreaterThanBoundary (a > b to a >= b), LessThanBoundary (a < b to a <= b) under the Equality Operator mutator | CONDITIONALS_BOUNDARY, a default mutator that "replaces the relational operators <, <=, >, >= with their boundary counterpart" | documented example: "< is changed to <=" | cxx_lt_to_le, cxx_ge_to_gt, group cxx_boundary |
| Arithmetic operator | Arithmetic Operator: AdditionNegation (a + b to a - b), MultiplicationNegation (a * b to a / b) | MATH, a default mutator that replaces binary arithmetic operations | not documented as a named operator | cxx_add_to_sub, cxx_sub_to_add, cxx_mul_to_div, group cxx_arithmetic |
| Statement or call removal | Block Statement: BlockRemoval "removes the content of every block statement" | VOID_METHOD_CALLS, a default mutator that removes calls to void methods | not documented as a named operator | cxx_remove_void_call, group cxx_calls |
| Constant or literal | String Literal ("foo" to ""), Boolean Literal (true to false) | EMPTY_RETURNS, FALSE_RETURNS, TRUE_RETURNS, NULL_RETURNS, PRIMITIVE_RETURNS (defaults); INLINE_CONSTS (optional) | documented example: "Integer literals are changed by adding 1. So 0 becomes 1, 5 becomes 6" | cxx_assign_const, cxx_init_const, cxx_replace_scalar_call |
Sources for that table: StrykerJS operator names and replacements from supported mutators (opens in new window); PIT operator names, quoted definitions, and default-set membership from PIT mutation operators (opens in new window); mutmut's documented example mutations from mutmut docs (opens in new window), which point at node_mutation.py for the complete list rather than enumerating it; Mull operator identifiers and groups from Mull supported mutations (opens in new window). mutmut is left blank in two rows on purpose: its public docs do not name those operators, so do not assert them.
Related skills
mull-mutation
Runs Mull, the LLVM-IR mutation testing tool, against C/C++ test binaries built with Clang: covers install (the version-matched mull-NN package), the -fpass-plugin build flags for the Mull IR frontend, mull-runner invocation, the mutator catalog, path filtering, and GitHub Actions CI. Use when a C or C++ project needs mutation-score verification with the tool already chosen. Does not select among mutation tools and does not cover other languages (stryker-mutation for JS/TS, stryker-net-mutation for .NET, pitest-mutation for the JVM, mutmut-mutation for Python).
mutmut-mutation
Configures mutmut for Python mutation testing - `pip install mutmut`, runs via `mutmut run`, browses results via `mutmut browse` or `mutmut results`, applies surviving mutants to disk via `mutmut apply {id}`, suppresses with `# pragma: no mutate` annotations. Configures via `setup.cfg` / `pyproject.toml` with `source_paths` + per-test selection. Use for Python codebases needing mutation-quality verification of pytest / unittest suites.
pitest-mutation
Configures PIT (PITest) for mutation testing of JVM projects (Java, Kotlin via the Kotlin plugin) - wires the `pitest-maven` or `pitest-gradle-plugin` with `mutationThreshold`, `coverageThreshold`, target classes/tests filtering, runs `mvn pitest:mutationCoverage`, parses the HTML + XML reports. Use when the JVM suite needs mutation-quality verification - the canonical Java mutation testing tool, fast (PIT analyzes "in minutes rather than days").
stryker-mutation
Configures StrykerJS for mutation testing of JavaScript / TypeScript / React / Vue / Svelte / Node - picks the test-runner plugin (`@stryker-mutator/jest-runner`, `mocha-runner`, `vitest-runner`, `karma-runner`), authors `stryker.conf.json` with mutate globs + thresholds, runs incremental mode for PRs (only mutate changed files), and reports the mutation score. Use when a JS/TS test suite has ≥80% line coverage and the team wants to verify the tests actually catch bugs (not just touch lines).
stryker-net-mutation
Configures Stryker.NET for mutation testing of .NET Core / .NET Framework projects - installs `dotnet-stryker` global tool, scopes mutation to specific csproj, supports xUnit / NUnit / MSTest, authors `stryker-config.json` with thresholds, runs in CI. Use when a .NET test suite needs mutation-quality verification - closes the .NET ecosystem gap left by Stryker.NET being newer than the JS variant.