Surviving-mutant backlog
Generated from npm run mutate:expressions, first full run. Updated 2026-08-03 — see "2026-08-03 session" below; the numbers in the historical sections describe the earlier runs they belonged to.
Result
| Area | Score | Gate | Status |
|---|---|---|---|
core/src/expressions | 74.01% | ≥ 90% | Failing (exit 1) |
2026-08-03 session: 74.01 → ~90, and the equivalence decision
Two things moved the number, in order of honesty:
- The 74.01% was stale.
sql.tswas moved to@routier/sql-plugin-coreafter the last run, taking its mutants (63 survivors) out of this area's denominator. The fresh baseline with no new tests was 78.39% (1,055 mutants). - Direct tests took it to 82.65%:
forEachtraversal semantics (utils.ts to 94.7%), theEXPRESSION_TYPESwhitelist behindisExpression(constants.ts to 100%), theExpressionsentinel statics (all 15 no-coverage types.ts mutants; types.ts to 100%),parserCoverageGaps.test.ts(the parser's 29 no-coverage paths), andparserSurvivors.test.ts(failure-message content for every newly covered rejection site, string escapes, keyword literals, converters on bound params, method-call boolean folding, the truthy shorthand).
The equivalence decision (per "Open work" item 3 and the analysis below): the gate stays at 90 and the experimentally-established equivalent clusters are excluded with inline // Stryker disable comments carrying written justifications — visible in the source, auditable in the diff, never a config-side list. Excluded:
SINGLE_CHARACTER_PUNCTUATION(the line-45 cluster, 11 mutants) — the experiment below established equivalence: rejection messages name the character from the source, not the set.- The four-conjunct bracket-access guard and the
TRANSFORM_METHODSguard (the 621/666 clusters) — every mutation reroutes between two paths that both collapse to NOT_PARSABLE; 30 targeted tests killed one. - The template-cache cap — a pure resource bound; every mutation parses identically.
- Three
convertersentries (Computed/Definition/Function) — those types cannot appear in a parsable filter (computed/function filters route to in-memory execution).
Measured after annotations + tests: 89.83% on 993 mutants; the last two kills came from the forEach right-link failure propagation and four remaining uncovered parser paths (consumed-past-the-end, lone-value condition, out-of-scope variable, membership .includes() binding).
1,416 mutants over 6 files, 166 tests, ~8 minutes. The gate fired correctly, which is the point: line coverage on expressions was already high, and mutation testing says roughly four in ten deliberate defects there would ship unnoticed.
Choosing each area's test set
Three configurations were measured, and the result is not intuitive:
| Test set | Tests | Survived | No coverage | Score | Runtime |
|---|---|---|---|---|---|
| All core + datastore | 469 | 323 | 206 | 62.64% | ~19 min |
core/src/expressions only | 111 | 309 | 228 | 62.08% | ~2.5 min |
| expressions + datastore | 166 | 309 | 199 | 62.99% | ~8 min |
Narrowing is not free. Scoping to core/src/expressions alone moved 22 mutants to "no coverage", which counts against the score exactly like a survivor — the parser is reached end-to-end through datastore queryables, and those suites kill mutants no expressions-local test covers. An area's globs must include every suite that exercises the mutated code, not just the suite sitting next to it. Getting this wrong makes a run both faster and less truthful.
Survivors by file
| File | Survivors |
|---|---|
parser.ts | 241 |
sql.ts | 63 |
utils.ts | 11 |
constants.ts | 7 |
types.ts | 1 |
Survivors by mutator
| Mutator | Count | Reading |
|---|---|---|
| ConditionalExpression | 138 | Branch conditions no test discriminates. Highest value. |
| StringLiteral | 87 | Mostly ERROR_MESSAGES text. See "Error message text" below. |
| LogicalOperator | 36 | &&/` |
| BlockStatement | 15 | Whole blocks removable with no test noticing. |
| EqualityOperator | 14 | ===/!== swaps. |
| BooleanLiteral | 10 | |
| ArrowFunction, ObjectLiteral, MethodExpression, other | 23 |
Progress
| Round | Score | What changed |
|---|---|---|
| Baseline | 62.99% | — |
+ parser.mutants.test.ts (12 tests) | 63.28% | Null coercion, tokenizer whitespace |
+ filterForms.test.ts (50 tests) | (included above) | Behavioral filter battery — low yield, see below |
+ parserRejection.test.ts (31 tests) | 67.02% | The 33 untested throw sites |
+ sqlDialects.test.ts (65 tests) | 68.50% | 4 dialects x quoting / placeholders / LIKE vs GLOB |
+ parserMessages.test.ts (18 tests) | 70.41% | Failure-message content, via a logger spy |
+ tokenizer.test.ts (50 tests) | 73.66% | Block comments, operator table, string/template literals |
+ parserParams.test.ts (18 tests) | 73.94% | Param-driven property access, transform-method table |
Yield per test, measured
| File | Tests | Points gained | Points per 10 tests |
|---|---|---|---|
parserMessages.test.ts | 18 | +1.91 | 1.06 |
parserRejection.test.ts | 31 | +3.74 | 1.21 |
tokenizer.test.ts | 50 | +3.25 | 0.65 |
parserParams.test.ts | 18 | +0.28 | 0.16 |
sqlDialects.test.ts | 65 | +1.48 | 0.23 |
filterForms.test.ts | 50 | +0.29 | 0.06 |
Direct assertions against the unit under test outperform end-to-end behavioral tests by roughly ten to twenty times here. The two winners both assert something no behavioral test can observe: that a refusal happens at all, and what it says.
Why the behavioral battery under-delivered
filterForms.test.ts runs 50 filter forms through a real query and compares to Array.prototype.filter. It is a good test of the query path, but a weak oracle for parser mutants: when the parser returns NOT_PARSABLE the datastore silently falls back to in-memory filtering, so the rows come back correct either way. A mutant that breaks parsing is invisible to it.
Direct toExpression assertions are the right tool for this area. The rejection battery, one third the size, moved the score more than three times as far.
Closed so far
core/src/expressions/parser.mutants.test.ts targets two concrete groups:
- Null coercion —
Number: v => v == null ? v : Number(v)and its String/Boolean siblings. The null guard was untested, soprice === nullwould have parsed asprice === 0,name === nullasname === "null", andactive === nullasactive === false. Each matches the wrong rows rather than erroring. - Tokenizer whitespace — every existing filter is written with plain spaces, so the
\t,\r, and\nbranches of the whitespace test were never exercised. A formatter emitting tabs would have broken parsing with nothing to catch it.
Analysis: the resistant clusters are equivalent mutants
An experiment settled this rather than leaving it to judgement.
Hypothesis. The three resistant clusters looked killable. parser.ts:45 is SINGLE_CHARACTER_PUNCTUATION; its twelve survivors each delete one entry ({ } ; % * / + = ? : & | ,). Every one of those characters appears only in syntax the parser rejects, and toExpression collapses all rejections to NOT_PARSABLE — so behavior cannot separate them. But the rejection message names the token ("unexpected token '%'"), so a character dropped from the set should not be able to name itself. Asserting the message ought to kill the cluster. The same reasoning applied to parser.ts:621, whose four-conjunct guard routes bracket access to either the param branch or an unsupported-access throw — again two different messages behind one NOT_PARSABLE.
Result: falsified. 30 tests written precisely against that theory killed one mutant of twelve at line 45, and none at 621/666. Score moved 73.94% -> 74.01%.
Conclusion. Eleven of the twelve at line 45 are equivalent: removing a character from the punctuation set does not change the message, so no assertion at any observable boundary can distinguish the mutant from the original. Per the strategy, these should be recorded as equivalent and excluded from the score rather than chased.
The tests were kept regardless. "A rejection names the character it choked on" is worth asserting on its own merits — it is the difference between a usable diagnostic and "unsupported expression format".
What this means for the 90% gate. With ~11 known-equivalent mutants at one line alone, and 93 singleton survivors spread one-per-line, part of the remaining 16 points is unreachable by testing. Before more test-writing, someone should audit a sample of survivors for equivalence and decide whether 90% is the right number for this file or whether the gate should be set against a survivor budget that excludes documented equivalents.
Earlier note: diminishing returns
parserParams.test.ts aimed 18 direct tests at two clusters totalling 17 mutants (parser.ts:621 param-driven property access, parser.ts:666 the transform-method table) and killed one. Both clusters are unchanged in the next run, as is parser.ts:45.
That is the signal to stop adding tests and start reading mutants. Three explanations, and the next session should establish which applies before writing anything:
- Equivalent mutants. A mutant whose behavior is indistinguishable from the original cannot be killed by any test.
parser.ts:45isMULTI_CHARACTER_PUNCTUATION, a module-level constant; blanking one entry may leave the tokenizer producing the same tokens by a different route. The strategy calls for documenting these rather than chasing them. - Static mutants. 537 of 1,416 (38%) are static — evaluated once at module load. Attribution under
perTestcoverage is weaker for these. - Genuinely uncovered branches the tests missed despite aiming at the right lines.
93 of the remaining 245 survivors are singletons — one per source line. Even if every one were killable, that is one test per mutant with no leverage left.
Open work, in priority order
parser.tsConditionalExpression survivors. The bulk of the gap and the highest value: each is a branch the parser takes on some input no test supplies. Best attacked with generated input rather than hand-written cases — the fast-check corpus inplugins/memory/src/tests/queryProperties.test.tsis the natural place to widen, since it already builds real arrow-function source.sql.ts(63). No differential test exists for SQL generation. The query oracle covers plugin results; nothing compares generated SQL against an expectation.- Error message text (87 StringLiteral). A decision, not a defect: either assert message content where it is part of the contract (
NOT_PARSABLEreasons that callers branch on), or accept that message wording is not behavior and excludeERROR_MESSAGESfrom the mutation set. Do not silently exclude it to move the number — the gate is only worth having if it measures something real.
Notes for the next run
- 123 static mutants (9% of total) consume ~62% of runtime.
ignoreStatic: truewould cut the run substantially, at the cost of not testing module-load-time code. - The run executes all 469 core + datastore tests. Narrowing
testMatchper area would speed it up, butperTestcoverage already attributes correctly, so this is a runtime concern rather than a correctness one.
Area: plugins/replication — 2026-08-04
New area. Config: stryker/replication.mjs (+ stryker/jest.replication.js, stryker/replication.setup.js), script npm run mutate:replication, gate 80.
Runtime is the binding constraint here, so the setup file caps the chaos suite at 3 seeds (CHAOS_SEEDS=3) and silences the plugin logger for mutant runs. Budget ~3 minutes per 100 mutants at ~15 tests/mutant. Run per file, not per package.
Result
| Scope | Mutants | Before | After | Notes |
|---|---|---|---|---|
httpUtils.ts | 104 | 85.05% | 99.01% | 1 survivor, equivalent-only |
UnsyncedQueue.ts | 269 | 63.68% | 83.96% | 30 survivors, 4 no-coverage |
auth.ts | 35 | 27.78% | 88.89% | 2 survivors, both the documented redundancy |
HttpSwrDbPlugin.ts, HttpDbPlugin.ts, PluginSyncEngine.ts, OptimisticUpdatesDbPlugin.ts | — | — | not yet run | HttpSwrDbPlugin.ts alone is ~1 200 lines |
The gains came from tests, not exclusions: KeyedMutex behavior (mutual exclusion, arrival order, key cleanup, lock release on failure), Retry-After parsing edges, the status predicates, jitter spread rather than just its bounds, UnsyncedQueue's failure paths (store query fails, store persist fails, unparseable entityJson, legacy rows, single-row and null store responses, empty-work short-circuits), coalescing order under both row orders, and buildAuthErrorEvent in full.
Mutation testing found a real bug while doing it: readRetryAfterMs sent a negative Retry-After through Date.parse, which accepts "-5" as a year, so a malformed header meant "retry immediately" instead of "use the computed backoff".
Remaining survivors, classified
Of the 32 in UnsyncedQueue.ts + auth.ts:
- 18 log-message and log-payload mutants (
logger.warn(""),logger.debug(..., {})at lines 244, 258, 343, 389, 449, 506, plus thesource/actionstrings on the store events the queue builds). Unobservable through any boundary the queue exposes. Decision: recorded here, not annotated inline. Eighteen// Stryker disablecomments through the durability core would cost more readability than the annotations buy, and the class is uniform enough to describe once. This is the same call the expressions area faced with its 87ERROR_MESSAGESStringLiteral survivors. - ~8 equivalent guards.
if (changes.length === 0) return(lines 183, 403) —persistToStorehas its own empty guard, so the outer one only saves work;confirmedSeq == null/<=vs<(254, 257) — seq is unique per change, so the boundary case cannot occur;if (c.seq != null)(230) — the mutant producesNaN, which fails every comparison, reaching the same outcome;Math.max→Math.min(231) — only differs when one call confirms several changes for one entity, and the mutant retires less, which is safe by construction;newest == null(146) andgroup[0]?.recordIds(343) — groups are built by pushing and are never empty. - 4 store-tolerance mutants.
removeRowIds: ["Stryker was here"](199, 236, 382, 408) andcollectionName: "Stryker was here!"(395, 406). Removing a row id that does not exist is a no-op inMemoryPlugin, andcollectionNameon a remove is cosmetic — the row id is the key. A store that errored on unknown ids would kill these. - 2 in
auth.ts, annotated inline: theinstanceof HttpStatusErrorbranch is deliberately redundant with the message fallback below it, which parses the same status out of the same message. No test can separate them.
Open work
- Run the four remaining files. This is where the interesting logic lives — the SWR read/write paths, retry loops and the composition engine. Expect it to take hours; run one file at a time and add a row to the table above for each.
- Then tighten the gate. 80 was picked before any score existed. Once the package has a number, set
breakjust under it, the way expressions sits at 90. - The 74 "errors" in
UnsyncedQueue.tsare mutants that break the schema definition at module load, so they never reach a test. They are excluded from the score denominator, which is correct, but worth confirming none of them hides a real gap.
Area: plugins/replication — first full run (2026-08-06)
npm run mutate:replication, 41 minutes, all nine files. This is the run "Open work" item 1 above was asking for, and it answers item 2's question.
Result
| File | Score | Killed | Survived | No coverage | Errors |
|---|---|---|---|---|---|
| All files | 51.52 | 942 | 577 | 364 | 195 |
auth.ts | 100.00 | 15 | 0 | 0 | 0 |
httpUtils.ts | 84.69 | 159 | 21 | 11 | 24 |
UnsyncedQueue.ts | 77.57 | 207 | 54 | 7 | 87 |
HttpDbPlugin.ts | 54.59 | 96 | 62 | 22 | 5 |
PluginSyncEngine.ts | 51.79 | 94 | 71 | 23 | 3 |
HttpSwrDbPlugin.ts | 44.99 | 308 | 279 | 127 | 76 |
swrUtils.ts | 44.44 | 24 | 25 | 5 | 0 |
OptimisticUpdatesDbPlugin.ts | 38.71 | 36 | 46 | 11 | 0 |
queryParamHelpers.ts | 1.67 | 3 | 19 | 158 | 0 |
51.52 against a gate of 80. The gate was set before any score existed, so this is the first time the number has been knowable rather than aspirational.
The finding
queryParamHelpers.ts scores 1.67% with 158 mutants no test executes at all.
That file serializes a Routier query into HTTP query parameters — the filter expression tree, the sort spec, skip and take. It is the layer that produced defects #48 and #49, and it has no direct tests: what little coverage it has arrives incidentally through plugins that happen to call it. 158 uncovered mutants in a ~170-line file means the expression-to-JSON walk is, for practical purposes, unverified.
This is the highest-value target in the package and probably the cheapest — it is a pure function from a query to a record of strings, so it needs no plugin, no fetch mock and no store. plugins/sql-core/src/sql.test.ts is the model: build an expression, assert the output.
Second target is HttpSwrDbPlugin.ts — 279 survivors and 127 uncovered out of 683 mutants. It is by far the largest file and carries the read path, the revalidate classification and the flush, so a low score there is the least surprising and the most worth moving.
auth.ts at 100% and httpUtils.ts at 84.69% need nothing.
The gate
Lowered from 80 to 45, against a measured 51.52.
A gate above the real score fails every run, and a check that always fails is one nobody runs — which is how this package went from "gate: 80" to never having been measured. Set under the current number it does the one thing a gate can do before the score is good: catch a regression. Raise it as the score moves, the way expressions sits at 90 against its 90.
The margin is ~6 points rather than 1. This run recorded 58 timeouts, and a timeout scores as killed — on a quieter machine some of those become survivors and the total drops by roughly that much. A gate one point under a score that moves three flaps, which is no more useful than one that always fails.
The target stays 80. It is written in this file rather than in the config, where it would only produce noise.
Caveats on the number
- 195 mutants errored rather than being killed or surviving, 87 of them in
UnsyncedQueue.tsand 76 inHttpSwrDbPlugin.ts. As the note above records for the earlier run, these break the module at load and never reach a test. They are outside the denominator, which is right, but nobody has confirmed none of them hides a real gap. - These numbers are lower than the per-file runs recorded in
HARDENING-HANDOFF.md§7a —httpUtils.ts99.01% there against 84.69% here,UnsyncedQueue.ts83.96% against 77.57%. The runs are not comparable: §7a mutated one file at a time, soenableFindRelatedTestsselected a different test set per mutant, and the mutant counts differ too (httpUtils.ts104 there, 215 here). This full run is the number to quote, because it is the one the gate measures. Nobody has checked whether the per-file figures were optimistic or simply different. - Log-message mutants inflate the survivor count. Every
logger.warn('[X] message', {...})yields a StringLiteral and an ObjectLiteral mutant, and no test should be asserting on log text. The clear-text tail of this run is three of those and one more. Worth measuring before reading 577 survivors as 577 gaps.