Skip to content

fix(stryker-js): reuse dry-run coverage only under the root that recorded it - #313

Merged
ryanleecode merged 38 commits into
mainfrom
fix/dry-run-coverage-root
Oct 13, 2026
Merged

ryanleecode merged 38 commits into
mainfrom
fix/dry-run-coverage-root

Conversation

@systemfsoftware-maker

@systemfsoftware-maker systemfsoftware-maker commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A run whose saved dry-run coverage was recorded under another workspace root reused it, then planned every mutant NoCoverage. Mutant ids hash the absolute file path (mutantIdOf), so coverage keyed by root A's ids matches nothing under root B. The reuse decision compared digests only, and with no testFiles configured the closure digest holds no path at all, so the copied coverage passed.

This PR makes the coverage record carry the root it was recorded under, and reuses it only under that root. The root is spelled as the crawl spells it, path.join(basePath, '.'): every crawled file name is that root joined with the entry, and mutant ids hash those names. One directory reached through a symlink and through its real path, or a relative base path and its absolute spelling, are therefore two roots, because they hash two sets of mutant ids. The real path is never compared:

  • dryRunReuse decides on a tagged prior: under the current root, under other roots, unrecorded, or absent. rootChanged and rootUnrecorded outrank forced, which outranks noPriorCoverage. The digest reasons keep their order.
  • dryRunCandidates (new pure workflow) keeps only the restored reports whose coverage records the current root. Kept reports keep their input order, and the workflow counts what it dropped. When none are kept, it names the distinct recorded roots, sorted. The run and plan both use it.
  • Inputs from kept reports only: the closure inputs, the reused prior, the planner's dry-run cost and the flaky mutant ids all come from the kept reports. Mutant costs are read from every admitted report, but a cost is keyed by the mutant id, which hashes the absolute path, so another root's entries never match a current mutant.
  • No header fallback: the report header's projectRoot is never consulted. A merged shard report pairs the first shard's header with coverage from whichever shard has some, so a header fallback would admit another root's coverage.
  • Coverage with no recorded root is refused once with rootUnrecorded. That run records the root, and the next run reuses. Only unreleased builds of cache version 6 (main after fix(stryker-js): keep verdicts when lockfile edits miss the project #303, before this PR) write such coverage. A report from a released version carries incrementalVersion 5 and is discarded by the cache-layout gate (admitReuseSource, incremental-reuse.ts:98-99) before the root check, so an upgrading user sees noPriorCoverage, not rootUnrecorded.
  • INCREMENTAL_CACHE_VERSION is unchanged ('6' since fix(stryker-js): keep verdicts when lockfile edits miss the project #303, verdict-semantics.ts:42). This PR only adds an optional field to the coverage record.

Log lines:

Running the initial test run: dry-run coverage reuse refused (rootChanged)
  current project root:   <B>
  recorded project roots: <A>, <C>
  This run performs its own dry run and records its coverage under <B>.
  Coverage is shared across machines only when they run from the same absolute workspace path.

Running the initial test run: dry-run coverage reuse refused (rootUnrecorded)
  The saved coverage came from an older version that recorded no project root.
  This full dry run records its coverage under <root>.

Skipped saved dry-run coverage: 1 recorded under another project root, 0 with no recorded root

Plan: docs/plans/2026-10-13-1059-fix-dry-run-coverage-root-plan.md (supersedes the 1507 and 1018 copies, which record the real-path and path.resolve contracts this head replaces).

Red on main

Two runs, both on unchanged main production code. They used the in-process suite that 0ac39e89 later replaced with the built-CLI suites; the CLI suites' own red-on-main run is under Verification.

(a) The red-scenario commit 87909510 changes only tests/dry-run-coverage-across-roots.integration.test.ts, so its production code is main 84052beb4.

pnpm exec vitest run --reporter=verbose tests/dry-run-coverage-across-roots.integration.test.ts gives Tests 2 failed | 1 passed (3).

R7. Root B, holding root A's report after A is deleted, reuses A's coverage and scores all 7 mutants NoCoverage; A scored 3 Killed and 4 NoCoverage:

-   "secondReusedCoverage": false,
-   "secondRootRefusal": true,
+   "secondReusedCoverage": true,
+   "secondRootRefusal": false,
    "secondStatuses": {
-     "Killed": 3,
-     "NoCoverage": 4,
+     "NoCoverage": 7,

R13. Planning B with A's copied report prices B's dry run at 0, so every shard predicts one second less than planning B with no report:

-           "predictedSeconds": 3,
+           "predictedSeconds": 2,
            "shard": "1/4",
 ...
-           "predictedSeconds": 2,
+           "predictedSeconds": 1,
            "shard": "4/4",

The R11 scenario (same root, task log rewritten) passes on main.

(b) This PR's final scenario file over main 84052beb4 gives Tests 4 failed | 3 passed (7). Red:

  • the moved-root scenario. Its refusal line, naming the current root, both recorded roots and the cross-machine rule, is absent.
  • the planner scenario (the same diff as R13 above).
  • the mixed-roots scenario. B's own matching report is not reused (reused: false), and the plan prices from another report.
  • the legacy scenario. Main reuses coverage that records no root (refusedUnrecorded: false, reused: true).

Green on main, as expected:

  • R11.
  • the checkpoint scenario (AE11) and the dry-run-only scenario (AE12). These guard R14: same-root reuse already worked on main.

(c) The spelled-root ruling. 83ba3d59 adds tests/symlinked-base-path.integration.test.ts (now base-path-spelling) with no product change: a project run through a symlink, then through its real path, must score as a fresh run. It was pushed red, and CI check on c4aae46e (run 38376657276, job 115230114085) failed it:

-   "viaRealPathExecutions": 5,
+   "viaRealPathExecutions": 0,
    "viaRealPathScored": [
-     { "status": "Killed", ... },
+     { "status": "NoCoverage", ... },

e85eeb5a (root compared as spelled) turned it green.

(d) Relative and absolute spellings. After e85eeb5a the root was path.resolve(basePath), which merges a relative spelling with its absolute one. A probe (temporary edit, reverted) ran the pair from the project's parent directory and read the incremental file after each run. The two runs hashed different mutant ids (9a2d307c… vs a4cfeddd…) but recorded one coverage root (/tmp/sn9R6V), so the root check let the pair through. Only runInputsDigest (e8f2314b vs 0d511736) refused it. That digest depends on the spelling through the options fingerprint and the manifest and lockfile search, so no integration setup makes the pair's digests equal, and the scenario stays green either way. After a6187f8f (root path.join(basePath, '.')), a probe on the committed scenario, whose relative run uses a path relative to the working directory, shows that run's coverage recorded under ../../…/tmp/hnOZnc and the absolute run's under /tmp/hnOZnc, so the root check refuses the pair on its own.

Verification on this head

This head is a6187f8f. On top of b99e2125 it adds 83ba3d59 (red scenario, above), the plain merges of main at 8f68cb53 (#334) and 3797debd (#322, #336, #337), both with an empty --remerge-diff, then e85eeb5a, 02734a7d, 8e9299dc, 4b66f7c3, 635c8fc3 and a6187f8f (the ruling and the review fixes below). Fresh container, packages/stryker-js:

Command Result
pnpm install --frozen-lockfile (repo root) rc 0
pnpm exec turbo run build --filter=@systemfsoftware/stryker-js (repo root) rc 0
pnpm exec tsc -b --force rc 0
pnpm exec oxlint --format=default on the changed source and test files 0 warnings, 0 errors
pnpm run api:check rc 0
pnpm exec vitest run on the base-path-spelling, relocated-project, incremental-runs and plan suites plus the dryRunCandidates and dryRunReuse laws 6 files, 29 passed
pnpm exec vitest run (whole package) on a6187f8f 286 files, 1798 passed
./bin/dprint check on every changed file rc 0
author-solution-docs and author-changesets validators OK

On 635c8fc3, under load average 63-80, a whole-package run had 4 timeouts at 60 s in files this PR does not change (src/verdict-semantics.ts laws, tests/vm-runner, tests/wall-clock-timeout). The whole package then passed on a6187f8f, and those three files alone passed 16/16. No timeout was raised.

One unexplained local failure. In 9 local whole-package runs under load, "A project holding its own report and one restored from another root..." failed once, as a LeakedState on the harness's second live run. The failure message elides which field differed. A temporary probe printed every compared field in the next 7 whole-package runs and 6 isolated runs (2 live runs each): every value matched, every time. One of those runs instead failed main's incremental-reuse scenario "A refused re-run starts a mutant with its previous killing test", a file this PR does not touch, with the same LeakedState shape. No timeout was raised and no retry was added.

Red on main for the CLI suites. The same two suite files laid over main 251201755 (no product change): 2 failed, 6 passed. Red: "A project copied to another root with its reports scores its mutants as the run at the first root did" (root B scores NoCoverage where A scored Killed) and "A shard plan at another root does not depend on the reports copied from the first root" (the plan priced from A's coverage differs).

The earlier check failure on d14ebbedf was this PR's own in-process scenario timing out at 60 s in CI. 0ac39e89 deleted that suite: every scenario now runs the built stryker binary on a temp project and asserts only what a user sees (exit codes, reports/mutation/mutation.json verdicts, the run's output, and how many times the project's own tests executed, counted by the fixture project itself). No timeout was raised.

Requirements trace

Laws: C = src/__tests__/dry-run-candidates.workflow.property.test.ts, D = src/__tests__/dry-run-reuse.workflow.property.test.ts. CLI suites (built binary, real temp projects): R = tests/relocated-project.integration.test.ts, I = tests/incremental-runs.integration.test.ts. E = tests/compile-error-reuse.integration.test.ts (main's suite, unchanged in kind).

Req Covered by
R1 another root refuses with rootChanged D APriorUnderOtherRootsRunsTheDryRunNamingRootChangedWhateverForceAndDigests; R "A project copied to another root…" (red on main) and "A TypeScript project copied to another root scores its compile errors and kills…"
R2 no recorded root refuses with rootUnrecorded, whatever the header says D APriorWithNoRecordedRootRunsTheDryRunNamingRootUnrecordedWhateverForceAndDigests. Law only (see finding #2 below)
R3 precedence D: both root laws, AForcedRunUnderTheCurrentRootNeverReusesCoverage, AnAbsentPriorRunsTheDryRunNamingForcedOverNoPriorCoverage, AnAbsentClosureDigestRunsTheDryRunNamingClosureUnavailable, AClosureChangeRunsTheDryRunNamingClosureChanged, ARunInputsChangeRunsTheDryRunNamingRunInputsChanged
R4 legacy report refused once, even under its header's root D (as R2). Law only
R5 log lines R "A project copied to another root…": the refusing run's output names dry-run coverage reuse refused (rootChanged) and both roots. Sorted, de-duplicated root lists: C WithNoCandidateUnderTheCurrentRootTheRecordedRootsAreNamedSortedAndOnceEach. The dropped-count, older-version and cross-machine sentences are not asserted end to end
R6 plan reads through the same filter R "A shard plan at another root…" (red on main); R "A project holding its own report and one restored from another root…" (plans as before the restore)
R7 red-first cross-root scenario R copied-root and shard-plan scenarios, red on main (above)
R8 reuse law covers the root D, 8 laws
R9 patch changeset .changeset/all-socks-wonder.md; CI Changeset Check
R10 candidates: kept only, first kept feeds the digest, prior is the first kept that reuses, planner prices from it, flaky ids from kept only C AnOtherRootCandidateBeforeACurrentRootCandidateKeepsTheSecondAndCountsTheFirst, KeptCandidatesComeInInputOrderAndKeptPlusDroppedCountsEveryCandidate; R own-plus-restored scenario (re-executes nothing); E "a flaky mutant saved under another root does not force the initial test run of a checker-only leaf"
R11 task-log rewrite keeps reuse R "A rebuild that only rewrites a build log naming the project root re-executes nothing"
R12 candidate law C, 8 laws
R13 whole shard plans equal R shard-plan scenario (red on main)
R14 checkpoint and dry-run-only coverage record the root I "A run killed after it scored its first mutant spares the next run its initial test run" (real SIGKILL at the first mutant stream event); I "A dry-run-only run spares the next run its initial test run"
R15 legacy costs exactly one dry run D (as R2). Law only

Review findings and dispositions

Full-path ce-code-review of 3797debd..02734a7d, and delta lenses to a6187f8f

Report-only, depth full: six lenses plus an independent validator, run on 02734a7d after the spelled-root ruling. The lenses returned 2 findings, both from maintainability; the validator confirmed both. None is P0 or P1. Learnings graded 14 pack rules, all comply. Two delta lenses (correctness persona) then reviewed the PR's own first-parent commits from b99e2125. Each finding below names its fixing commit or the evidence for leaving it.

# Sev Finding Raised by Disposition
1 P2 plan reads and decodes the incremental reports twice (plan-request.cell.ts:252-253) maintainability; validator confirmed Fixed in 635c8fc3: dryRunChoiceOf takes the report texts its caller read.
2 P3 The candidate filter walks the root list twice with parallel tag matches (dry-run-candidates.workflow.ts:50-82) maintainability; validator confirmed Fixed in 635c8fc3: one match per root collects index and path.
D6-1 P3 The reissued plan's R11 names the deleted dry-run-coverage-across-roots file delta lens b99e2125..02734a7d Fixed in 4b66f7c3: R11 names the CLI scenario in relocated-project.
D7-1 P1 A relative base path and its absolute spelling record one root (path.resolve) but hash different mutant ids; only the run-inputs digest refuses the pair delta lens b99e2125..635c8fc3 Fixed in a6187f8f: the root is path.join(basePath, '.'). Probe evidence under Red on main (d).
D7-2 P2 plan realpaths the base path (plan-request.cell.ts:246) while the run compares the spelled root delta lens b99e2125..635c8fc3; again as P3 at confidence 50 by the lens to a6187f8f Known limit, not changed. The realpath predates this PR (#187). On a non-real base path, plan computes another root than the run, refuses the run's coverage and estimates from scratch. Plan's root and mutant ids both use the real spelling, so it never scores NoCoverage. The CLI realpaths . (RunEnvironment.forStream), so only embedders with a non-real base path pay the extra estimate.

The lens to a6187f8f raised no finding beyond D7-2. It recorded three residual risks and two testing gaps, listed under Known limits.

Full-path ce-code-review of 251201755..0ac39e89

Report-only, depth full: six lenses (correctness, project standards, testing, maintainability, learnings, adversarial) plus an independent validator. 5 findings went to validation and all 5 were confirmed; none is P0 or P1. Fixes landed in bcc3b7b9.

# Sev Finding Raised by Disposition
1 P2 The new refusal log lines are asserted nowhere (src/run/dry-run.cell.ts:606) testing, learnings Fixed in bcc3b7b9: the copied-root scenario asserts that the refusing run's output names rootChanged and both roots. The dropped-count, older-version and cross-machine sentences remain unasserted end to end (trace R5)
2 P2 The legacy-report scenario cannot fail on the root check: the released CLI (19.0.0) writes cache version "5", so the version gate discards its report before the root filter runs testing, validator Claim narrowed in bcc3b7b9: the scenario is retitled and no longer claims the root refusal. R2/R4/R15 end to end: open, for ruling. No release writes version-6 coverage without a root (git tag --contains the #303 commit is empty), so only unreleased main builds produce that input. An end-to-end case would have to edit the incremental file, which the built-CLI rewrite forbids
3 P2 The same scenario asserted the first upgraded run executes as much as the released one, which flips once the dogfood pin moves past this change (flake.nix:38, AGENTS.md:8) adversarial, validator Fixed in bcc3b7b9: it now asserts only what holds whatever the pin records: all runs exit 0 and score alike, and the second upgraded run executes no test
4 P3 StaleLogInput restates six fields of DryRunChoice (src/run/dry-run.cell.ts:590) maintainability Deferred, as in the earlier pass: no behaviour change
5 P3 The R5 refusal lines lost their only test when the in-process suite was deleted learnings Fixed with #1

Suppressed below the confidence gate (anchor 50), dispositioned anyway:

Sev Finding Raised by Disposition
P2 Root identity uses the real path while mutant ids hash the crawl's spelling correctness Open, for ruling: same as the delta-pass P2 below
P2 The killed-run scenario wrote the hold's release file only on success, so a failing step could leave a worker waiting orchestrator fast pass Fixed in bcc3b7b9: the release is also a scope finalizer
P3 runCliKilledAtFirstMutant accepted a cli override it ignored adversarial Fixed in bcc3b7b9: it takes Omit<CliRun, 'cli'>

Testing gap applied: scored-list comparisons could pass vacuously on an empty report, so each now also asserts the statuses ['Killed', 'NoCoverage'] (bcc3b7b9).

Compound-pack rules matched (learnings lens, file:line evidence in its artifact):

Pack Rule Verdict Evidence
cell-architecture pure-decision-workflows comply src/dry-run-candidates.workflow.ts:94-100, src/dry-run-reuse.workflow.ts:70-79
cell-architecture decode-never-cast comply src/run/dry-run-choice.ts:41-43
boundary-testing real-system-oracles comply tests/__fixtures__/cli-project.fixture.ts:15 spawns the built binary on real temp directories, scoped teardown
boundary-testing no-mocks-on-internal-glue comply the in-process engine suite is deleted; the CLI suites mock nothing
boundary-testing pin-dependency-semantics comply tests/__fixtures__/cli-project.fixture.ts:17-19 (released CLI from the pinned tarball)
schema-laws tagged-unions-over-state-by-presence comply src/dry-run-coverage.schema.ts:19-23
schema-laws data-only-schema-classes comply src/dry-run-candidates.workflow.ts:41-48
schema-laws tests-own-no-schemas comply the fixture decodes with the production Report.MutationTestResult and RunEvent.RunEvent
schema-laws invariants-as-refinements comply src/dry-run-candidates.workflow.ts:17-19 (bare counts, no decoder re-check)

The other 21 pack rules did not apply to this diff.

Earlier passes

A report-only ce-code-review of 84052beb4..52e1b215f (six reviewers plus an independent validator) confirmed 4 findings, none P0 or P1:

# Sev Finding Raised by Disposition
1 P2 The digest source "first kept candidate", AE7 and the dropped-count line were untested end to end testing Fixed in 730061fa1. The mixed-roots scenario fails if the run goes back to the first candidate overall (checked by temporarily reverting to Arr.head(candidates): red; restored: green)
2 P3 The planner priced from kept[0] while the run reused prior maintainability Fixed in 730061fa1. The planner prices from dryRunChoice.prior; the mixed-roots scenario asserts the plan
3 P3 StaleLogInput restates fields of DryRunChoice maintainability Deferred. No behaviour change; the simplify pass judged it acceptable
4 P3 The test harness rebuilds the same engine layer as runOnce maintainability Deferred. runOnce is the R11 harness, which must stay unchanged

Rulings applied after the review:

  • Header-root fallback removed, as a defect (730061fa1). A merged shard report's header and its coverage can come from different shards. Coverage with no root of its own is rootUnrecorded, whatever the header says. The learnings reviewer raised this as a P2 and the adversarial reviewer as a residual risk; the confidence gate suppressed the finding, and the ruling acted on it anyway.
  • Flaky ids come from other roots no longer: only kept reports contribute flaky mutant ids (scenario in E).
  • R5: the moved-root scenario restores reports from two other roots and asserts that both are named, sorted, with the cross-machine sentence.
  • R12: a law covers a list that mixes other-root and unrecorded reports with none kept. It refuses with rootChanged, naming only the recorded roots.

Full-path ce-code-review of 2083ba43..9c05cd51

Report-only, depth full, run after the review fixes and the revert below: the same six lenses plus an independent validator. 6 primary findings, none P0 or P1. The merge leaf sent 4 to the validator; all 4 were confirmed. The two release-note findings (#2, #3) were routed to the author, not validated, and are confirmed by the source lines cited in their dispositions.

# Sev Finding Raised by Disposition
1 P2 rootUnrecorded refusal has no end-to-end test (run/dry-run.cell.ts:604) testing Not fixed, with reason. No released build and no build of this head writes cache-version-6 coverage without a root (#2, #3), so the only CLI path to it is hand-editing the incremental file, which the built-CLI suites do not do. The decision is pinned by the dryRunReuse law (dry-run-reuse.workflow.property.test.ts) and the dryRunCandidates law. Listed under Known limits.
2 P3 The changeset says older reports cost one dry run after upgrade (.changeset/all-socks-wonder.md:5) testing Fixed (11167fdb). A released report carries incrementalVersion 5; admitReuseSource discards it before the root check (incremental-reuse.ts:98-99, verdict-semantics.ts:42 is '6'). The sentence is gone from the changeset and this body.
3 P3 rootUnrecorded is unreachable for any released version's report (same line) adversarial Fixed with #2 (11167fdb).
4 P3 plan prices mutant costs from every report with no root filter (plan-request.cell.ts:256) maintainability Documented, no code change (11167fdb). Costs are keyed by the root-bound mutant id, so another root's entries never match. The solution doc no longer claims the planner's prices come only from kept reports, and states this invariant. A code comment was tried and refused by the repo's comment checker as restating the code.
5 P3 The refusal formatter takes the whole choice through an intersection alias (run/dry-run.cell.ts:590) maintainability Fixed (49251210). staleLinesOf(choice, reason).
6 P3 The incremental-runs scored comparison passes on two empty reports (incremental-runs.integration.test.ts:66) testing Fixed (49251210). Both scenarios assert the reference scored Killed and NoCoverage.

Residual risks raised and not promoted: the real-path vs crawl-spelling P2 and the plan-prices-from-kept[0] P3 (both already open below); currentRootCoveragesOf runs twice per run (pre-existing shape); CurrentRootCoverages is exported but unused outside its module.

Matched compound-pack rules (learnings lens, all comply): cell-architecture pure-decision-workflows (dry-run-candidates.workflow.ts:69-97, dry-run-reuse.workflow.ts:73-85), decode-never-cast (run/dry-run-choice.ts:41-44); boundary-testing real-system-oracles (relocated-project.integration.test.ts:99), no-mocks-on-internal-glue (cli-project.fixture.ts:129-131), pin-dependency-semantics (cli-project.fixture.ts:17-19); schema-laws tagged-unions-over-state-by-presence (dry-run-coverage.schema.ts:23,34-39), data-only-schema-classes (dry-run-candidates.workflow.ts:16-37), tests-own-no-schemas (cli-project.fixture.ts:166,192), invariants-as-refinements (dry-run-candidates.workflow.ts:17-19).

Reverted: the sandbox cleanup fix

0babd3d3 fixed a race in Sandbox.service.ts where a run sharing the temp directory deleted it while another run was creating its sandbox inside (the cause of the check failure on 6c960828, and of main's run 38290483072). PR #331 (stryker/sandbox-cleanup-race) already owns that defect with its own design, so 4597b38f reverts 0babd3d3 and its failing-first test a977c763; the net diff against 6c960828 for those files is empty.

Reviewer receipt

Every review leaf below was dispatched by its persona. The omp config routes each *-reviewer persona to minimax-code/MiniMax-M3.1-Flash-Preview and the docs-engineer persona to anthropic/claude-haiku-4-5. The models in the "Answered on" column come from each leaf's own session transcript (model_change entries and the model on every assistant message). For every *-reviewer leaf, the configured MiniMax model's first call returned 429 rate_limit_error ("Token Plan usage limit reached"). omp's fallback chain then served the leaf on openrouter/deepseek/deepseek-v4.1-flash (resolvedModelIsFallback: true). So every review judgment came from a non-Claude model, just not the configured one. The orchestrating session is Claude (anthropic/claude-opus-5-5); it dispatched, applied fixes and wrote this body, and judged no finding.

Pass Persona (leaf) Configured model Answered on Scope Result
Plan review (2026-10-11 12:51 UTC) generalist-reviewer (coherence), correctness-reviewer (feasibility), generalist-reviewer (scope) MiniMax M3.1 Flash Preview DeepSeek v4.1 flash (MiniMax 429 on call 1) The unified plan document, before any code Report-only review of the plan
Simplify pass (13:46) generalist-reviewer ×3 (reuse, quality, efficiency) MiniMax M3.1 Flash Preview DeepSeek v4.1 flash (MiniMax 429 on call 1) git diff 84052beb4 HEAD -- packages Approve with comments; three cleanups pushed as 52e1b215f
ce-code-review (13:59) correctness-reviewer MiniMax M3.1 Flash Preview DeepSeek v4.1 flash (MiniMax 429 on call 1) 84052beb4..52e1b215f 0 findings, 3 residual risks
adversarial-reviewer MiniMax M3.1 Flash Preview DeepSeek v4.1 flash (MiniMax 429 on call 1) same 0 findings, 2 residual risks (one became the header-root ruling)
generalist-reviewer (project standards) MiniMax M3.1 Flash Preview DeepSeek v4.1 flash (MiniMax 429 on call 1) same 0 findings, 3 residual risks
generalist-reviewer (testing) MiniMax M3.1 Flash Preview DeepSeek v4.1 flash (MiniMax 429 on call 1) same 1 finding → #1
generalist-reviewer (maintainability) MiniMax M3.1 Flash Preview DeepSeek v4.1 flash (MiniMax 429 on call 1) same 3 findings → #2, #3, #4
generalist-reviewer (learnings) MiniMax M3.1 Flash Preview DeepSeek v4.1 flash (MiniMax 429 on call 1) same 3 findings, all suppressed at the confidence gate (anchor 50): header-root fallback (P2, acted on by ruling), bare Int counts (P3), path-keyed reuse against the content-keyed doctrine (P3, answered by the solution-doc update in 3097f1da)
docs-engineer (merge leaf) Claude Haiku 4.5 Claude Haiku 4.5 Merges the six artifacts 4 primary, 3 suppressed; no judgment of its own
correctness-reviewer (validator) MiniMax M3.1 Flash Preview DeepSeek v4.1 flash (MiniMax 429 on call 1) The 4 merged findings against the diff 4/4 confirmed
docs-engineer (report leaf) Claude Haiku 4.5 Claude Haiku 4.5 Renders the report no judgment of its own
Full-path ce-code-review (2026-10-13 05:04 UTC) correctness-reviewer (correctness), generalist-reviewer ×4 (project standards, testing, maintainability, learnings), adversarial-reviewer MiniMax M3.1 Flash Preview DeepSeek v4.1 flash for every turn (67, 40, 38, 25, 35 and 30 answers; no error turns) 251201755..0ac39e89 7 findings, 5 primary (above)
docs-engineer (merge leaf) Claude Haiku 4.5 Claude Haiku 4.5 (22 answers) Merges the six artifacts 5 primary, 3 suppressed; no judgment of its own
correctness-reviewer (validator) MiniMax M3.1 Flash Preview DeepSeek v4.1 flash (29 answers) The 5 merged findings against the diff 5/5 confirmed
docs-engineer (report leaf) Claude Haiku 4.5 Claude Haiku 4.5 Renders the report no judgment of its own
cross-model peer — not run — no different-provider CLI route is installed (codex, claude, grok, cursor-agent and opencode are absent); the in-process adversarial lens covered it
Full-path ce-code-review (2026-10-13 06:51 UTC) correctness-reviewer (correctness), generalist-reviewer ×4 (project standards, testing, maintainability, learnings), adversarial-reviewer MiniMax M3.1 Flash Preview DeepSeek v4.1 flash for every turn (39, 27, 32, 40, 40 and 65 answers; no error turns) 2083ba43..9c05cd51 6 findings, 6 primary (above)
docs-engineer (merge leaf) Claude Haiku 4.5 Claude Haiku 4.5 (84 answers) Merges the six artifacts 6 primary, 4 selected; no judgment of its own
correctness-reviewer (validator) MiniMax M3.1 Flash Preview DeepSeek v4.1 flash (29 answers) The 4 selected findings against 9c05cd51 4/4 confirmed
docs-engineer (report leaf) Claude Haiku 4.5 Claude Haiku 4.5 (18 answers) Renders the report no judgment of its own
cross-model peer — not run — no different-provider CLI route is installed; the in-process adversarial lens covered it
Delta pass (2026-10-12 18:03) correctness-reviewer MiniMax M3.1 Flash Preview MiniMax for 8 tool turns, then 429; DeepSeek v4.1 flash for the remaining 62 turns and the verdict 730061fa1 and 3097f1da, which landed after the code review 3 findings, no P0/P1 (below)
Full-path ce-code-review (2026-10-13 10:21 UTC) correctness-reviewer, generalist-reviewer ×4 (project standards, testing, maintainability, learnings), adversarial-reviewer DeepSeek v4.1 flash (omp config now routes every *-reviewer persona to it) DeepSeek v4.1 flash for every turn (39, 29, 31, 21, 38 and 31 answers; no fallback, no error turns) 3797debd..02734a7d 2 findings, 2 primary (above)
docs-engineer (merge leaf) Claude Haiku 4.5 Claude Haiku 4.5 (20 answers) Merges the six artifacts 2 primary, 2 selected; no judgment of its own
correctness-reviewer (validator) DeepSeek v4.1 flash DeepSeek v4.1 flash (8 answers) The 2 selected findings against 02734a7d 2/2 confirmed
docs-engineer (report leaf) Claude Haiku 4.5 Claude Haiku 4.5 (16 answers) Renders the report no judgment of its own
cross-model peer — not run — no different-provider CLI route is installed; the in-process adversarial lens covered it
Delta lens (10:21, with the full-path run) correctness-reviewer DeepSeek v4.1 flash DeepSeek v4.1 flash (37 answers) b99e2125..02734a7d, PR-own first-parent commits 1 finding (D6-1)
Delta lens (after 635c8fc3) correctness-reviewer DeepSeek v4.1 flash DeepSeek v4.1 flash (30 answers) b99e2125..635c8fc3 2 findings (D7-1 P1, D7-2 P2)
Delta lens (after a6187f8f) correctness-reviewer DeepSeek v4.1 flash DeepSeek v4.1 flash (43 answers) b99e2125..a6187f8f, the final head 1 finding (D7-2 again, P3 at confidence 50); 3 residual risks, 2 testing gaps

Delta pass findings (2026-10-12):

Sev Finding Disposition
P2 The root check compares the real path, but mutant ids hash the file name as the crawl spelled it. Two runs over one directory reached through a symlink and then through its real path pass both the root and digest checks, then score every mutant NoCoverage. Fixed per the root's ruling: red in 83ba3d59 (CI run 38376657276), green in e85eeb5a, relative spelling in a6187f8f. Was: Open, for ruling. The CLI cannot reach it: RunEnvironment.forStream realpaths .. Embedders that stage their own RunEnvironment with a non-real basePath (the MCP server passes McpServerOptions.basePath through) can. The reviewer marked the run-level effect [INFERENCE], not executed. Proposed fix: record the crawl's spelling next to the real root, or realpath file names before identity.
P3 When kept candidates exist but none reuses, stryker plan still builds testCoverage, cost fallbacks and reuse inputs from the head kept candidate, which the run refuses. The release-note sentence "the run and plan use the same coverage" holds only when the run reuses. Wording narrowed per the root's ruling, in e85eeb5a: the release note now says plan prices from that coverage only when the run reuses it, and otherwise estimates from the first report recorded under the current root. The behaviour is unchanged (kept[0]) and stays fail-safe. Gating plan's inputs on the reuse decision would change the planner's cost model, so it is left for a separate unit. Was: Open, for ruling.
P3 The moved-root scenario's "sorted" check builds its expected string from two random temp-dir names, so an unsorted listing passes whenever the names happen to sort in read order. Moot. 0ac39e89 deleted that scenario; the sort rule is pinned by the law WithNoCandidateUnderTheCurrentRootTheRecordedRootsAreNamedSortedAndOnceEach.

Known limits

  • Coverage never crosses workspace roots. A fleet whose machines check out at different absolute paths pays one full dry run per shard. Hosted runners share one path, so main's CI keeps its reuse. Cross-root reuse needs root-independent mutant ids, which is out of scope here.
  • rootUnrecorded has no end-to-end test. No released build and no build of this head writes cache-version-6 coverage without a root, so the only way to reach it through the CLI is to hand-edit the incremental file, which these suites do not do. The decision is covered by the dryRunReuse and dryRunCandidates laws.
  • Restored report copies must be ignored. A report copy listed in incrementalSources but not in ignorePatterns is crawled as project content and changes the dry-run closure digest. Coverage reuse is then refused (closureChanged). This predates this PR (strykerOutputFilesOf excludes only configured output files); the shared Stryker config already ignores reports.
  • plan and the run disagree on the root for a non-real base path (D7-2). Plan realpaths the base path, so it refuses coverage a run recorded through a symlink or a relative path, then estimates from scratch. This is fail-safe; the CLI is unaffected.
  • No test fails if the root merged relative and absolute spellings again. The run-inputs digest also differs between those spellings, so the relative scenario stays green under path.resolve. The ruling is shown by the recorded root (probe, Red on main (d)), not pinned by a test.
  • Coverage from a relative base path is shared at any absolute path. A relative root carries no absolute prefix, so two checkouts at different absolute paths that pass the same relative base path share mutant ids and roots. Reuse there still needs equal closure and run-inputs digests. The "same absolute workspace path" wording in the log line and the solution doc describes the CLI, which always passes an absolute, realpathed root.
  • Overlap with the verdict-store PR. That PR removes incrementalSources and multi-report reading and conflicts with this branch in five source files and the content-keyed reuse solution doc. Whichever lands second ports the root check onto the other's single-report path.

Release note

Refuse dry-run coverage recorded under another project root. Coverage now records the project root as the run spells it, the spelling mutant ids are computed from, and a run reuses it only under that same root: coverage from another root, including the same directory reached through a symlink, is refused with the new reason rootChanged, and coverage that records no root is refused once with rootUnrecorded. Either refusal runs a full dry run that records the current root, and its log line names the roots and the next action. Among several restored reports, only coverage recorded under the current root is reused, and only its flaky mutants force a dry run. plan prices from that coverage only when the run reuses it; otherwise it estimates from the first report recorded under the current root. Coverage is shared across machines only when they run from the same absolute workspace path.

systemfsoftware-maker and others added 28 commits October 11, 2026 13:13
Requirements and implementation units for refusing dry-run coverage that
was recorded under another project root, or under no root at all, so a
run that moves workspace paths no longer scores every mutant NoCoverage.
…Coverage

Replaces the cross-root scenario that expected coverage reuse with two
scenarios over a per-test-coverage fixture:

- A run under root B, holding the report root A wrote, must score its
  mutants as root A did. On unchanged code it reuses A's coverage and
  scores all 7 mutants NoCoverage where A scored 3 Killed, 4 NoCoverage.
- Planning root B with A's copied report must give the same shard plan
  as planning B with no report. On unchanged code the copied coverage
  prices B's dry run at 0, so every shard predicts a second less.

Both fail until the dry-run reuse decision and the planner check the
root the coverage was recorded under.
The dry-run reuse decision now takes its prior as a tagged union: no
prior, a prior recorded under the current root (with its digests), a
prior recorded only under other roots, or a prior that records no root.
Other roots refuse with rootChanged and an unrecorded root with
rootUnrecorded, ahead of force; force still outranks noPriorCoverage.

The coverage record and the report header gain an optional projectRoot
key, so reports written before this change still decode. The run maps
its single candidate onto the current-root variant until the root
filter lands, so its behaviour is unchanged by this commit.
A pure workflow decides which saved coverage candidates belong to the
current project root. A candidate's root is the one its coverage record
names, falling back to its report header only when the record names
none. It keeps the current-root candidates in input order with counts of
what it dropped, or else names every other recorded root once, sorted,
or reports that no candidate recorded a root, or that there were none.

The workflow is not wired into the run or the planner yet.
…rded it

The run now resolves its project root to a real path, reads each saved
coverage candidate's own root (falling back to its report header's root,
resolved to a real path), and passes the candidates through the root
filter. Reuse is judged only on the candidates recorded under the current
root, and the first of them feeds the closure digest. With none left it
refuses with rootChanged when some candidate recorded a root and with
rootUnrecorded when none did.

Fresh and reused coverage both record the current root, so the end-of-run,
checkpoint and dry-run-only writers all persist it. The refusal log names
the current and recorded roots and the next action, says when coverage
came from a version that recorded no root, and counts skipped candidates.

New scenarios show that a checkpoint left by an interrupted run and a
dry-run-only report are reused under the same root, and that coverage
with no root anywhere costs exactly one dry run.
…s root

The planner took the first saved dry-run coverage it could decode, so a
report copied from another root priced per-mutant costs and fed the
verdict-reuse closure inputs from coverage that root B's mutant ids never
match. It now takes the first candidate the dry-run choice kept under the
current root, through the same root filter the run uses, and drops its
own coverage decode.
…ot a poll

The checkpoint scenario raced a run of about a second with a 10 ms
sleep-poll on the report file, so its outcome depended on timing. The
FileSystem port the run gets now resolves a Deferred with the report text
right after the rename that lands the first checkpoint carrying coverage
and Pending rows, and then never returns, so the run is parked there
until the test interrupts it. The fixture config and the gate share one
incremental-file path.
… scenarios

The candidate filter passes the recorded paths it already computed to
the no-candidate-kept branch instead of deriving them again. The
cross-root scenarios use their reuse-log helper and the shared
incremental-file path instead of restating them.
A legacy merged shard report pairs the first shard's header root with
coverage from another shard, so falling back to the header root let that
coverage through under the wrong root. The candidate filter now reads
only the root recorded in the coverage record: coverage with no root is
refused once with rootUnrecorded under any root, and the header root is
no longer decoded.

The planner prices shards from the prior the run reuses, not from the
first kept candidate, and flaky mutant ids come only from kept
candidates, so a flaky id in another root's report no longer forces a
dry run.

New scenarios: reports from several roots where B's own matching report
follows A's and a stale one (fails if the closure digest goes back to
the first candidate), two other roots named sorted, a legacy end-of-run
report under the same root refused with rootUnrecorded, and a flaky id
under another root that leaves the checker-only run deferred. The
candidate law now covers a none-kept list mixing other-root and
unrecorded candidates.

The plan is superseded by a new dated plan carrying the corrected
contract (R2, R4, R10-R12, Key Decision 2, KTD1/2/4/6/7, legacy table).
…space root

The verdict-cache solution doc said the key names no path. Mutant ids
hash the absolute file name, so every per-mutant map a run saves is
bound to the root that wrote it. The doc now says so, records why
coverage copied across roots scored every mutant NoCoverage, and states
the invariant the fix enforces: coverage carries its own root, the root
is checked before any digest, even on a forced run, and the report
header is never consulted. It adds a cross-root verification step and
code smell.
…root

The moved-root scenario ran the engine under three roots so that root B
held reports recorded under two other roots. Every live case runs twice
(the second run checks for leaked state), so the scenario ran six engine
runs inside one 60 s test budget, and on the loaded CI check job it hit
the limit: 60253 ms, while every scenario in the file ran 4-6x slower
than locally. That was slowness, not a hang.

Root C's run existed only to produce a report recorded under C. Copying
root A's report and setting its coverage projectRoot to C's real path
gives the reuse decision the same input: two other recorded roots, both
named in the refusal. The scenario now runs the engine twice per pass.
Unloaded it drops from about 10 s to 7.5 s; with two copies of the whole
file on one core it drops from 32.7 s to 16.4-22.8 s.
… CLI

The in-process suite built the engine, read the incremental file back and
asserted reuse decisions. It is replaced by two suites that run the built
stryker binary on real temp projects and judge only what a user sees: exit
codes, the JSON report's verdicts, and how often the project's own test ran.

relocated-project covers a copied project, a plan at the new root, reports
restored from another root, a released-version report, a log-only rebuild
and a TypeScript project under the real checker. Its copied-project and
plan scenarios fail on main and pass here. incremental-runs covers a run
killed at its first scored mutant and a dry-run-only run, each followed by
a run that must execute the reference run's tests minus the dry run's.

cli-project.fixture is the shared project, CLI and report helper.
Review fixes on the built-CLI suites:

- The copied-root scenario now asserts what a user reads on the refusing
  run: the rootChanged refusal and both project roots.
- The released-report scenario no longer claims the first upgraded run
  executes as much as the released one. That held only while the pinned
  release predates the root record, so it now asserts scores and the
  second run's zero executions.
- Scored-list comparisons guard against an empty report.
- The killed-run scenario releases its hold in a finalizer as well, so
  a failing step cannot leave a worker waiting.
…root

# Conflicts:
#	packages/stryker-js/tests/dry-run-coverage-across-roots.integration.test.ts
…just created

A run that cleans its sandbox reads the shared temp directory, finds it
empty and removes it. Another run that has just created that directory
for its own sandbox then fails to create the sandbox inside it. This is
the race that failed CI check on 6c96082 (compile-error-reuse) and on
main 38924e5. The scenario freezes the losing instant: the directory
exists and is empty when this run finishes. It is red until the fix.
The temp directory (`tempDirName`, resolved against the working
directory) is shared by every run that resolves to it. A run's cleanup
read it, found it empty and removed it recursively: a check, then a
delete, with nothing in between to stop another run. A second run that
had just created the directory then failed `makeTempDirectory` inside
it with ENOENT, or a concurrent cleanup died on ENOENT (readDirectory)
or ENOTEMPTY (remove).

This failed CI check on 6c96082: compile-error-reuse's runTwice runs
do not chdir, so they share packages/stryker-js/.stryker-tmp with
incremental-reuse in another fork. The compile-error run stopped right
after file discovery, 2 ms after the incremental-reuse run's "Done in".
Main's run 38290483072 failed the same way.

The FileSystem port has no atomic remove-if-empty, so the cleanup now
removes only the run's own sandbox and leaves the shared directory.
The span stryker.sandbox.remove_empty_parent is retired with it
(cli-contract minor while 0.x, per its compatibility rule).

Evidence: the scenario in a977c76 is red before this commit and green
after. A throwaway probe of 16 concurrent fibers x 100 create/cleanup
cycles of the sandbox layer on one shared directory failed 92 to 207 of
1,600 cycles per run before, and 0 of 16,000 after.
Reverts 0babd3d and a977c76. The CI check failure on 6c96082 is the
shared-temp-directory cleanup race in Sandbox.service.ts, which is on
main and which #331 (stryker/sandbox-cleanup-race) already fixes with
its own design and race feature. Keeping a second, conflicting fix here
would ship two answers to one defect.
StaleLogInput restated six fields of DryRunChoice, which readDryRun
already spreads into it. It is now DryRunChoice plus the refusal reason.
… a build

- The own-plus-restored scenario now asserts the re-run's output line
  that names the report it skipped from another root (R5).
- The CLI fixture dies with a build-first message when dist/main.mjs is
  missing, as the audit suite does, instead of spawning a missing file.
…reach

A report written by a released version carries incrementalVersion 5, and
the cache-layout gate discards it before the root check, so it never
reaches rootUnrecorded. The release note no longer says older reports
cost one dry run. The solution doc now says the planner's dry-run cost
comes from the kept reports, while mutant costs are read from every
admitted report and match only by root-bound mutant id.
- staleLinesOf takes the DryRunChoice and the refusal reason as two
  parameters instead of an intersection alias.
- Both incremental-runs scenarios assert the reference run scored
  Killed and NoCoverage, so a missing report cannot pass the scored
  comparison as two empty lists.
…overage

A run through the published Engine API whose RunEnvironment basePath is a
symlink to the project, followed by a run from the project's real path,
reuses the first run's dry-run coverage: both runs resolve the same real
root, but mutant ids hash the file names as each run's crawl spelled them.
The second run scores every mutant NoCoverage and executes no test, where
a fresh run of a copy kills them. This scenario is red until the fix.
Mutant ids hash each file name as the crawl spelled it, which is
path.join(basePath, <relative file>). The dry-run root check compared
fs.realPath(basePath), so one directory reached through a symlink and
then through its real path passed the root check while every mutant id
differed, and the second run scored every mutant NoCoverage without
executing a test. The recorded and compared root is now
path.resolve(basePath): a different spelling refuses the saved coverage
with rootChanged and runs a fresh dry run. The CLI, plan, audit and MCP
paths already pass a realpathed basePath, so their roots are unchanged.

The release note now says plan prices from the run's coverage only when
the run reuses it; when the run refuses it, plan still estimates from
the first report under the current root. It also restores the note's
last sentence, which 11167fd cut short.
…ot ruling

KTD4 now compares roots as the crawl spells them, path.resolve(basePath),
the prefix every mutant id hashes, instead of real paths. Key Decision 2,
R5 and the R7 scenario say each run's spelling, and the plan names the
symlinked-base-path regression scenario as KTD4's gate.
The scenario file is renamed to base-path-spelling and gains a run from a relative base path followed by the absolute one, which must score as a fresh run.
The plan and solution doc now describe the root compared by spelling, name the base-path-spelling feature as its gate, and drop claims the shipped code contradicts.
…once

dryRunChoiceOf takes the report texts its caller read, so plan decodes them once, and the candidate filter matches each root once (review findings #1 and #2).
The current root is path.join(basePath, '.'), the prefix every crawled file name and so every mutant id carries. path.resolve merged a relative spelling with its absolute one, so only the run-inputs digest refused that pair. The plan is superseded by a dated copy with the corrected KTD4.

@ryanleecode ryanleecode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root verdict on a6187f8: full-path review plus delta lenses through the head (DeepSeek v4.1 flash), each accepted finding fixed in a named commit, pack rules all comply, CI green except the inherited bench leg that #316 removes, 0 threads. Rulings: (1) R2/R4/R15 end-to-end is accepted as covered by the dryRunCandidates law. No released CLI writes rootless version-6 coverage ('git tag --contains' on #303 is empty), so editing the incremental file to build that input would only test a hand-made artifact. (2) D7-2, where plan realpaths but the run uses the spelled root, is fail-safe and stays a known limit; bringing the embedder/MCP base path into line is B's follow-up unit.

@ryanleecode
ryanleecode merged commit b4db6d8 into main Oct 13, 2026
21 of 22 checks passed
@ryanleecode
ryanleecode deleted the fix/dry-run-coverage-root branch October 13, 2026 14:42
systemfsoftware-maker added a commit that referenced this pull request Oct 13, 2026
…t of the next run's inputs

Takes main through b4db6d8 (#313 root-bound dry-run coverage, #314, #317,
#321, #322, #326, #330, #331, #332, #334, #335, #337).

Merge resolutions:
- dry-run-choice: main's current-root candidate filter over the one
  incremental file this branch reads; prior statuses come from the
  verdict store, flaky ids from the kept coverage.
- dry-run.cell, deferrable-dry-run.cell, plan-request.cell: pass that one
  report text into main's choice; the plan prices from the kept coverage.
- typescript-checker: main moved the runtime into
  typescript-checker-runtime.ts; it carries this branch's config/program
  digest scope.
- e2e StrykerCliRunner: main split service from layer; runScript and
  runInFork move into microsandbox-stryker-cli-runner.ts.
- Tests: main's flaky-root CompileError scenario writes the incremental
  file instead of the removed incrementalSources; the relocated-project
  multi-report scenario and dry-run-coverage-across-roots go with
  incrementalSources; the version-gate test uses layerStage.
- The solution doc keeps both the store key and the root binding.

Fix found while validating the merge: "A run killed after it scored its
first mutant spares the next run its initial test run" failed 2 of 4
runs. The fixture kills the CLI at the first mutant event
(run/mutant-run.ts:264-265), which can land inside the checkpoint's
atomic write of the incremental file (mutation-reporting.service.ts:754).
SIGKILL skips the temp file's cleanup (replace-file-atomically.ts:44), so
reports/.main.json.<hex>.tmp stays. The crawl excluded only main.json
(stryker-outputs.ts, read-project.cell.ts:116-124), so the temp file
became a project file and the next run refused its dry run with
closureChanged.

replace-file-atomically.ts now owns the temp name and the rule matching
it, and strykerOutputFilesOf excludes the incremental file's temp
siblings. A new scenario plants that temp file between two runs of the
built CLI; on the unfixed build the second run executed 2 tests, and it
now executes none. The changeset says what holds for a killed run.
systemfsoftware-maker added a commit that referenced this pull request Oct 13, 2026
…shortcut

Brings main up to efd1996 (#328 type queries, #322 CheckerRuntime split, #331, #313, #316, #329, #340). Two import conflicts, both additive: type-query.handle.ts keeps main's context-link imports and this branch's shared decided from ts-compiler.handle.ts; compare-sides' law imports both Deferred (main) and DigestCall (this branch). #322 removed CheckerRuntime.layer from ./runtime, so the importer-shortcut and isolated-declarations integration tests now build the runtime with TypeScriptCheckerRuntime.layer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants