Repository navigation
Description
> Rewritten. The first version of this issue cited the wrong CI run and presented this as a new discovery. Both corrected below; the conclusion (the gate is vacuous) survives, the diagnosis does not.
Problem
rust / Unit mutation — changed lines (rust) reports success without testing anything, on a PR that changed 146 lines of packages/rust/src/db.rs (#966).
This is the third occurrence of the same class: #659 (local false green) and #672 (CI false green) were fixed upstream in testing-conventions #467/#468 (0.0.88) and #469/#471 (0.0.89), closed 2026-07-27 with a green proof of 3 mutant(s) tested. Bespoke PR #685 was closed as obsolete at that point. It is vacuous again.
Evidence
CI. Job rust / Unit mutation — changed lines (rust) on head commit 63b83d3 — whose diff against base ddb7208 contains the whole db.rs change — logs:
unit mutation: no surviving mutants — every mutation was caught (0 mutant(s) tested)
Duration 60s, versus ~7 minutes for a real 22-mutant run. (My first filing cited run 32397912886, which is the tests-only RED commit — 0 is correct there. The head-commit job above is the real evidence.)
Reproduced locally, deterministically:
$ npx -y testing-conventions unit mutation --language rust \
--base ddb7208765d75c67008696b98242d713f86aefd9 \
--config testing-conventions.toml packages/rust
unit mutation: no surviving mutants — every mutation was caught (0 mutant(s) tested)
The diff is fine, and cargo-mutants is fine. mutation.rs::write_base_diff builds git diff --relative {base}...HEAD -- packages/rust from the cargo workspace root (confirmed /home/user/dirsql/Cargo.toml, so prefix = packages/rust). Running that exact command yields a correct 287-line diff whose paths are workspace-relative and which contains packages/rust/src/db.rs. Handing that same diff straight to the engine:
$ cargo mutants --features cli --in-diff /tmp/bigbase.diff --list
... 22 mutants, e.g.
packages/rust/src/db.rs:94:5: replace is_path_char -> bool with true
packages/rust/src/db.rs:842:9: replace Db::unquoted_path_hint_for -> Option with None
and a real run kills them: 22 mutants tested in 7m: 19 caught, 3 unviable (scoped -- --lib; see #977 for why the full-suite baseline can't run here).
So the mutants exist, the diff is correct, the engine finds them from that diff, and the unit tests kill them — but the gate reports 0.
Not a version regression. I initially guessed 0.0.91 had regressed 0.0.89's fix. That is wrong — pinned runs of 0.0.89, 0.0.90 and 0.0.91 behave identically on the same inputs (132s / 125s / 132s, all reaching the engine's baseline stage, which cargo-mutants only does after --in-diff filtering).
It is base-sensitive. Same HEAD (2e0a272), same tree, same tool, same config — only --base differs:
--base |
result |
|---|---|
3ec31c6 (immediate first parent) |
engine finds the 22 mutants, reaches baseline |
ddb7208 (further back) |
0 mutant(s) tested in ~80s, no baseline attempted |
Both diffs contain the identical db.rs change, and both yield 22 mutants when passed to cargo mutants --in-diff by hand. The only content difference is one extra unrelated file (packages/rust/MIGRATIONS.md). So the divergence is inside the tool's own path between generating the diff and reporting the count — not in the diff and not in the engine. Narrowing further wants tracing inside measure_rust / run_cargo_mutants / rebase_report_paths, which is upstream's to do.
The separable defect that makes this silent
mutation.rs already distinguishes two outcomes — Measurement::EngineNotRun ("unit mutation: no mutatable changed lines — engine not run") from Measurement::Tested { count, survivors }. Yet Tested { count: 0 } prints the all-caught success line. That state is a contradiction: nothing was tested, so nothing was caught.
Upstream already states the right rule for the sibling case, in its own changelog for #469:
> A baseline that still cannot build remains a hard failure naming the engine output, never a 0 mutant(s) tested pass.
The same should hold here. If Tested { count: 0 } were fatal (or routed to EngineNotRun), all three occurrences of this bug would have been loud on first contact instead of silently green for weeks.
Enforcement — the actual ask
This has now silently broken three times, so a fix without a test that fails when it breaks again is not a fix.
Upstream (testing-conventions): packages/rust/tests/mutation_base_rust.rs builds its primary fixture with [workspace] in the crate's own Cargo.toml — a standalone workspace root, the one layout where the prefix logic is trivially None. The vacuous-pass assertion needs to be on the workspace-member layout, which is the shape #467 existed to fix and the shape dirsql actually has. Assert a nonzero tested count there, not merely the absence of survivors.
Here (dirsql): #672 rejected a repo-side guard as fragile because "a genuinely 0-mutant diff would false-positive". That objection only applies to a guard keyed on the PR's own diff. A canary keyed on a fixed fixture has no such failure mode:
- a small fixture crate laid out as a workspace member, plus a committed synthetic diff with known mutants;
- a
dirsql-checks mutation-canarysubcommand (per AGENTS.md's "CI logic lives in scripts") that runs the gate's exact invocation against it and fails unless the reported count is > 0; - wired into
dirsql-rust-ci.ymlon every PR — which matters because@v0+npxresolveslatest, so this dependency can change under us on any run with no commit to blame.
That guard is independent of whatever the current root cause turns out to be, and would have caught all three occurrences.
Tasks
- Report upstream with the base-sensitivity repro above; get the root cause fixed — testing-conventions#481
- Upstream: make
Tested { count: 0 }impossible or fatal — written at upstream HEAD, not yet published (see the comment below) - Upstream: assert a nonzero tested count on a workspace-member fixture
- Here: add the fixture-based
mutation-canaryand wire it intodirsql-rust-ci.yml— split out to Add a mutation canary so a vacuousunit mutationgate fails loudly instead of passing green #980 - Confirm whether
python-sdk/typescript-sdkmutation are affected — no, structurally impossible (see the comment below) - Re-run mutation over Rust source that merged while the gate was blind (at least Hint at quoting when an unquoted path is used as a table name #966), and fix any survivors — Audit the window the vacuous
unit mutationgate left unmutated, and kill what escaped #1013; the four that remain are Four mutants in effectful core survive every test tier (found by the #975 blind-window audit) #1012 - Nit, same neighborhood:
.gitignorehasmutants.out/but notmutants.out.old/— already fixed ineb9443f
Note for whoever picks this up
cargo mutants cannot establish a baseline in the hosted sandbox — the uid-0 vtab permission test (#977) fails, and cargo-mutants aborts with cargo test failed in an unmutated tree. Scope with -- --lib, which is also the semantically right scope: the gate is about unit tests killing mutants. (#977 has since been fixed by #992, so the unscoped baseline is green under uid 0 again — but see #1013 for the -C --lib argument clash that produces an identical-looking error for an entirely different reason.)
Activity
[-]Rust `unit mutation` gate passes vacuously — 0 mutants tested on a real source diff[/-][+]Rust `unit mutation` is vacuous again — 0 mutants tested on a 146-line `db.rs` diff (same class as #659/#672)[/+]thekevinbot commentedon Aug 20, 2026
Split the two follow-ups out of this issue's task list so they can land independently:
Tested { count: 0 }hole in ci: probe the packaging gate for rust in conventions.yml (#413) #459's vacuous-pass fix, and the standalone-vs-workspace-member fixture gap inmutation_base_rust.rs.unit mutationgate fails loudly instead of passing green #980. Independent of upstream's root cause and unblocked, so it can land first.This issue stays as the defect report and the record of what was verified here: #966's 22 mutants are confirmed killed (
19 caught, 3 unviable, scoped-- --lib), so no survivors were merged behind the blind gate. Still open here: confirming whether the python/typescript arms share the affected path, re-checking Rust source that merged while the gate was blind, and themutants.out.old/gitignore nit.Generated by Claude Code
docs: record the #975 blind-window mutation audit
thekevinbot commentedon Aug 21, 2026
Closing out the two investigation items. PR: #1013.
The python and typescript arms are not affected, and cannot be
Read at upstream
9077875.measure_rustis the only arm that materializes a diff file and delegates the filtering to its engine:write_base_diffrunsgit diff --relative <base>...HEAD -- <prefix>at the cargo workspace root so the paths reachcargo mutants --in-diffworkspace-relative, andrebase_report_pathsstrips the prefix back off the report. That workspace-prefix round-trip is exactly where #659, #672 and this issue have lived, and it exists nowhere else:measure_typescriptcallsmutate_ranges→patch_coverage::changed_linesand turns the result into explicit--mutate <file>:<start>-<end>specs for Stryker, prefixed against the package root (package.json), with no workspace concept.measure_pythoncallspatch_coverage::changed_linesdirectly, passes the changed.pyfiles to cosmic-ray as--module, and post-filters the mutants to the changed lines.changed_lines(root, base)runsgit diff --unified=0 --relativewithcurrent_dirset to the scan dir, so its paths come out scan-relative already — no rebase step to get wrong. It is also the same helper the changed-linesunit coveragecheck uses on every PR, which is independent evidence that path is sound.What all three arms do share is the reporting hole: each can return
Measurement::Tested { count: 0 }, and the published CLI prints that as the all-caught success line. That is the part worth fixing generically.Upstream's fix exists but is unpublished — CI is still blind today
At upstream
9077875,measure_rusthas gainedzero_mutant_verdict: when cargo-mutants writes nooutcomes.json, the crate's mutants are re-listed and the run bails if any of them sits on a line the diff inserted. Thecount == 0branch also now printsthe engine found no mutants to testinstead of the all-caught line. Both are exactly what this issue asked for.Neither has shipped.
stringson the published binary finds only the two old messages, at 0.0.86 and 0.0.91 alike:0.0.91 is the npm
latestthat@v0+npxresolves, so the gate stays vacuous until upstream cuts a release. That makes #980's fixture canary the only in-repo signal that does not depend on someone remembering to check. (Pin the version when probing — a barenpm pack testing-conventionsserved a stale0.0.86packument through this sandbox's registry proxy even though thelatestdist-tag says 0.0.91.)Nothing escaped behind the blind gate except test debt
Every
packages/rust/srcline that merged while the gate could have been blind was re-mutated: base6bc91e2(the lastmaincommit on or before 2026-07-27, the day #672 closed with a green proof) through6f9e34a— 22 files, +7413/-2411, 470 mutants.Two scopings, because the repo's docs and its CI disagree about which one the rust gate uses.
-C --libis the documented rule ("fails on any mutant no unit test kills"); CI's actual invocation ismutants_argv'scargo mutants --output <out> --in-diff <diff> --features cliwith no target scoping, so the wholecargo testsuite judges each mutant.The 18 split three ways:
unit mutationgate left unmutated, and kill what escaped #1013. Six operator mutations inmatcher.rs'splaceholder_spans— its own documented grammar counter-examples ({},{1a},{a-b}, an unclosed{) had no assertions at all — andlib.rs'sprepare_persist, wheremeta_current: compatible && lengths matchsurvives&&→||, which would call a stale glob hash current and skip the meta rewrite. Confirmed fixed by re-running cargo-mutants: the five new tests kill 14 mutants at those sites.|→^over disjoint bit fields is a no-op —function_flags'SQLITE_UTF8|SQLITE_DETERMINISTIC,base64_encode's(b0<<16)|(b1<<8)|b2(exhaustive, 16,777,216 inputs),base64_decode's(triple<<6)|value_of(b)(67,108,864). And inplaceholder_spans,j += 1→j *= 1is re-consumed by the followingwhile, whilei = j + 1→i = j * 1/i = j - 1land on the closing brace or a name byte, never a{— zero differing results over all 335,923 strings up to length 7 on{ } a 1 _ -. No exemptions added; the gate is diff-scoped, so these surface only if someone edits those lines.RootedFs::hash(two),cli/run.rs'sload_stateguard andwait_for_shutdown. Filed as Four mutants in effectful core survive every test tier (found by the #975 blind-window audit) #1012 rather than re-litigating testing-conventions: integrateunit lint(isolation) #233 here.So the answer to "did anything bad merge while the gate was blind" is no — no silent behavior regression. What the blind gate hid was test debt in two pure functions.
Method, the two scopings, and two sandbox traps that cost real time —
-j4unscoped exhausts the disk allowance and cargo-mutants records the resultingNo space left on deviceasUnviable, a wrong verdict that looks real; and-- --libon top of-C --libmakes cargo reject the duplicated flag so the run dies withcargo test failed in an unmutated tree, indistinguishable at a glance from #977 — are written up inagents/reference/testing-gates.mdin #1013.Generated by Claude Code