Repository navigation
fix(stryker-js): reuse dry-run coverage only under the root that recorded it - #313
Merged
Merged
Conversation
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.
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
approved these changes
Oct 13, 2026
ryanleecode
left a comment
Contributor
There was a problem hiding this comment.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 notestFilesconfigured 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:dryRunReusedecides on a tagged prior: under the current root, under other roots, unrecorded, or absent.rootChangedandrootUnrecordedoutrankforced, which outranksnoPriorCoverage. 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 andplanboth use it.projectRootis 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.rootUnrecorded. That run records the root, and the next run reuses. Only unreleased builds of cache version6(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 carriesincrementalVersion5and is discarded by the cache-layout gate (admitReuseSource,incremental-reuse.ts:98-99) before the root check, so an upgrading user seesnoPriorCoverage, notrootUnrecorded.INCREMENTAL_CACHE_VERSIONis 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:
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 andpath.resolvecontracts this head replaces).Red on main
Two runs, both on unchanged main production code. They used the in-process suite that
0ac39e89later replaced with the built-CLI suites; the CLI suites' own red-on-main run is under Verification.(a) The red-scenario commit
87909510changes onlytests/dry-run-coverage-across-roots.integration.test.ts, so its production code is main84052beb4.pnpm exec vitest run --reporter=verbose tests/dry-run-coverage-across-roots.integration.test.tsgivesTests 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: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:
The R11 scenario (same root, task log rewritten) passes on main.
(b) This PR's final scenario file over main
84052beb4givesTests 4 failed | 3 passed (7). Red:reused: false), and the plan prices from another report.refusedUnrecorded: false,reused: true).Green on main, as expected:
(c) The spelled-root ruling.
83ba3d59addstests/symlinked-base-path.integration.test.ts(nowbase-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 CIcheckonc4aae46e(run 38376657276, job 115230114085) failed it:e85eeb5a(root compared as spelled) turned it green.(d) Relative and absolute spellings. After
e85eeb5athe root waspath.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…vsa4cfeddd…) but recorded one coverage root (/tmp/sn9R6V), so the root check let the pair through. OnlyrunInputsDigest(e8f2314bvs0d511736) 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. Aftera6187f8f(rootpath.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/hnOZncand 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 ofb99e2125it adds83ba3d59(red scenario, above), the plain merges of main at8f68cb53(#334) and3797debd(#322, #336, #337), both with an empty--remerge-diff, thene85eeb5a,02734a7d,8e9299dc,4b66f7c3,635c8fc3anda6187f8f(the ruling and the review fixes below). Fresh container,packages/stryker-js:pnpm install --frozen-lockfile(repo root)pnpm exec turbo run build --filter=@systemfsoftware/stryker-js(repo root)pnpm exec tsc -b --forcepnpm exec oxlint --format=defaulton the changed source and test filespnpm run api:checkpnpm exec vitest runon the base-path-spelling, relocated-project, incremental-runs and plan suites plus thedryRunCandidatesanddryRunReuselawspnpm exec vitest run(whole package) ona6187f8f./bin/dprint checkon every changed fileOn
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.tslaws,tests/vm-runner,tests/wall-clock-timeout). The whole package then passed ona6187f8f, 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
LeakedStateon 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'sincremental-reusescenario "A refused re-run starts a mutant with its previous killing test", a file this PR does not touch, with the sameLeakedStateshape. 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 scoresNoCoveragewhere A scoredKilled) 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
checkfailure ond14ebbedfwas this PR's own in-process scenario timing out at 60 s in CI.0ac39e89deleted that suite: every scenario now runs the builtstrykerbinary on a temp project and asserts only what a user sees (exit codes,reports/mutation/mutation.jsonverdicts, 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).rootChangedAPriorUnderOtherRootsRunsTheDryRunNamingRootChangedWhateverForceAndDigests; R "A project copied to another root…" (red on main) and "A TypeScript project copied to another root scores its compile errors and kills…"rootUnrecorded, whatever the header saysAPriorWithNoRecordedRootRunsTheDryRunNamingRootUnrecordedWhateverForceAndDigests. Law only (see finding #2 below)AForcedRunUnderTheCurrentRootNeverReusesCoverage,AnAbsentPriorRunsTheDryRunNamingForcedOverNoPriorCoverage,AnAbsentClosureDigestRunsTheDryRunNamingClosureUnavailable,AClosureChangeRunsTheDryRunNamingClosureChanged,ARunInputsChangeRunsTheDryRunNamingRunInputsChangeddry-run coverage reuse refused (rootChanged)and both roots. Sorted, de-duplicated root lists: CWithNoCandidateUnderTheCurrentRootTheRecordedRootsAreNamedSortedAndOnceEach. The dropped-count, older-version and cross-machine sentences are not asserted end to endplanreads through the same filter.changeset/all-socks-wonder.md; CI Changeset CheckAnOtherRootCandidateBeforeACurrentRootCandidateKeepsTheSecondAndCountsTheFirst,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"SIGKILLat the firstmutantstream event); I "A dry-run-only run spares the next run its initial test run"Review findings and dispositions
Full-path
ce-code-reviewof3797debd..02734a7d, and delta lenses toa6187f8fReport-only, depth full: six lenses plus an independent validator, run on
02734a7dafter 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 fromb99e2125. Each finding below names its fixing commit or the evidence for leaving it.planreads and decodes the incremental reports twice (plan-request.cell.ts:252-253)635c8fc3:dryRunChoiceOftakes the report texts its caller read.dry-run-candidates.workflow.ts:50-82)635c8fc3: one match per root collects index and path.dry-run-coverage-across-rootsfileb99e2125..02734a7d4b66f7c3: R11 names the CLI scenario inrelocated-project.path.resolve) but hash different mutant ids; only the run-inputs digest refuses the pairb99e2125..635c8fc3a6187f8f: the root ispath.join(basePath, '.'). Probe evidence under Red on main (d).planrealpaths the base path (plan-request.cell.ts:246) while the run compares the spelled rootb99e2125..635c8fc3; again as P3 at confidence 50 by the lens toa6187f8f.(RunEnvironment.forStream), so only embedders with a non-real base path pay the extra estimate.The lens to
a6187f8fraised no finding beyond D7-2. It recorded three residual risks and two testing gaps, listed under Known limits.Full-path
ce-code-reviewof251201755..0ac39e89Report-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.src/run/dry-run.cell.ts:606)bcc3b7b9: the copied-root scenario asserts that the refusing run's output namesrootChangedand both roots. The dropped-count, older-version and cross-machine sentences remain unasserted end to end (trace R5)"5", so the version gate discards its report before the root filter runsbcc3b7b9: 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 --containsthe #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 forbidsflake.nix:38,AGENTS.md:8)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 testStaleLogInputrestates six fields ofDryRunChoice(src/run/dry-run.cell.ts:590)Suppressed below the confidence gate (anchor 50), dispositioned anyway:
bcc3b7b9: the release is also a scope finalizerrunCliKilledAtFirstMutantaccepted aclioverride it ignoredbcc3b7b9: it takesOmit<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):
src/dry-run-candidates.workflow.ts:94-100,src/dry-run-reuse.workflow.ts:70-79src/run/dry-run-choice.ts:41-43tests/__fixtures__/cli-project.fixture.ts:15spawns the built binary on real temp directories, scoped teardowntests/__fixtures__/cli-project.fixture.ts:17-19(released CLI from the pinned tarball)src/dry-run-coverage.schema.ts:19-23src/dry-run-candidates.workflow.ts:41-48Report.MutationTestResultandRunEvent.RunEventsrc/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-reviewof84052beb4..52e1b215f(six reviewers plus an independent validator) confirmed 4 findings, none P0 or P1:730061fa1. The mixed-roots scenario fails if the run goes back to the first candidate overall (checked by temporarily reverting toArr.head(candidates): red; restored: green)kept[0]while the run reusedprior730061fa1. The planner prices fromdryRunChoice.prior; the mixed-roots scenario asserts the planStaleLogInputrestates fields ofDryRunChoicerunOncerunOnceis the R11 harness, which must stay unchangedRulings applied after the review:
730061fa1). A merged shard report's header and its coverage can come from different shards. Coverage with no root of its own isrootUnrecorded, 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.rootChanged, naming only the recorded roots.Full-path
ce-code-reviewof2083ba43..9c05cd51Report-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.
rootUnrecordedrefusal has no end-to-end test (run/dry-run.cell.ts:604)dryRunReuselaw (dry-run-reuse.workflow.property.test.ts) and thedryRunCandidateslaw. Listed under Known limits..changeset/all-socks-wonder.md:5)11167fdb). A released report carriesincrementalVersion5;admitReuseSourcediscards it before the root check (incremental-reuse.ts:98-99,verdict-semantics.ts:42is'6'). The sentence is gone from the changeset and this body.rootUnrecordedis unreachable for any released version's report (same line)11167fdb).planprices mutant costs from every report with no root filter (plan-request.cell.ts:256)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.run/dry-run.cell.ts:590)49251210).staleLinesOf(choice, reason).incremental-runsscored comparison passes on two empty reports (incremental-runs.integration.test.ts:66)49251210). Both scenarios assert the reference scoredKilledandNoCoverage.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);currentRootCoveragesOfruns twice per run (pre-existing shape);CurrentRootCoveragesis 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-testingreal-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-lawstagged-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
0babd3d3fixed a race inSandbox.service.tswhere a run sharing the temp directory deleted it while another run was creating its sandbox inside (the cause of thecheckfailure on6c960828, and of main's run 38290483072). PR #331 (stryker/sandbox-cleanup-race) already owns that defect with its own design, so4597b38freverts0babd3d3and its failing-first testa977c763; the net diff against6c960828for those files is empty.Reviewer receipt
Every review leaf below was dispatched by its persona. The omp config routes each
*-reviewerpersona tominimax-code/MiniMax-M3.1-Flash-Previewand thedocs-engineerpersona toanthropic/claude-haiku-4-5. The models in the "Answered on" column come from each leaf's own session transcript (model_changeentries and the model on every assistant message). For every*-reviewerleaf, the configured MiniMax model's first call returned429 rate_limit_error("Token Plan usage limit reached"). omp's fallback chain then served the leaf onopenrouter/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.git diff 84052beb4 HEAD -- packages52e1b215fce-code-review(13:59)84052beb4..52e1b215fIntcounts (P3), path-keyed reuse against the content-keyed doctrine (P3, answered by the solution-doc update in3097f1da)ce-code-review(2026-10-13 05:04 UTC)251201755..0ac39e89codex,claude,grok,cursor-agentandopencodeare absent); the in-process adversarial lens covered itce-code-review(2026-10-13 06:51 UTC)2083ba43..9c05cd519c05cd51730061fa1and3097f1da, which landed after the code reviewce-code-review(2026-10-13 10:21 UTC)*-reviewerpersona to it)3797debd..02734a7d02734a7db99e2125..02734a7d, PR-own first-parent commits635c8fc3)b99e2125..635c8fc3a6187f8f)b99e2125..a6187f8f, the final headDelta pass findings (2026-10-12):
83ba3d59(CI run 38376657276), green ine85eeb5a, relative spelling ina6187f8f. Was: Open, for ruling. The CLI cannot reach it:RunEnvironment.forStreamrealpaths.. Embedders that stage their ownRunEnvironmentwith a non-realbasePath(the MCP server passesMcpServerOptions.basePaththrough) 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.stryker planstill buildstestCoverage, cost fallbacks and reuse inputs from the head kept candidate, which the run refuses. The release-note sentence "the run andplanuse the same coverage" holds only when the run reuses.e85eeb5a: the release note now saysplanprices 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.0ac39e89deleted that scenario; the sort rule is pinned by the lawWithNoCandidateUnderTheCurrentRootTheRecordedRootsAreNamedSortedAndOnceEach.Known limits
rootUnrecordedhas 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 thedryRunReuseanddryRunCandidateslaws.incrementalSourcesbut not inignorePatternsis crawled as project content and changes the dry-run closure digest. Coverage reuse is then refused (closureChanged). This predates this PR (strykerOutputFilesOfexcludes only configured output files); the shared Stryker config already ignoresreports.planand 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.path.resolve. The ruling is shown by the recorded root (probe, Red on main (d)), not pinned by a test.incrementalSourcesand 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