Skip to content

feat(stryker-js-cli-contract): stream mutant lines carry their facts and next action - #298

Open
systemfsoftware-maker wants to merge 24 commits into
mainfrom
stryker/agent-surfaces-u3
Open

systemfsoftware-maker wants to merge 24 commits into
mainfrom
stryker/agent-surfaces-u3

Conversation

@systemfsoftware-maker

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

Copy link
Copy Markdown
Collaborator

What

Every mutant line on the run event stream carries what an agent needs to act on that mutant, typed by status (RunEvent.RunMutantTestedEvent, a union over status):

  • every status: statusReason as <code>: <detail>, the code from Mutant.StatusReason (plugin-interface's shared vocabulary, no parallel set); the JSON report's statusReason carries the code too
  • Killed: killedBy
  • Survived, Timeout, RuntimeError: original (the source text the mutant replaces; null when the file could not be read), coveredBy and next, a closed RunEvent.NextAction; NoCoverage: original and next
  • a Survived mutant whose run reported coverage gets strengthen-tests (its first 3 covering tests, a total, a reproducer); when that coverage names no test (a static mutant, or a mutant under coverageAnalysis: 'all') the reason says no test covers it alone; N tests ran with it active and tests.total is 0; with no coverage reported it gets fix-config

A mutant reaches the stream only when admitMutantReason (a pure workflow, admit-mutant-reason.workflow.ts) admits its reason: a Pending mutant, a missing reason, or a reason outside its status's vocabulary fails the run with mutant-facts-invalid naming the mutant, instead of writing an incomplete line. Stream schema 9.0; the changeset carries the migration note (major for plugin-interface and stryker-js, minor for cli-contract while it is 0.x).

Also on this branch, from merging main: #329's stream-file overwrite guard read run-event record openings from top-level union members only and so missed the nested mutant union; the openings now come from every object member (stream-file-head.schema.ts, eca06076). Per the root's M4 rule, agent-facts and wall-clock-timeout drive the built stryker binary on a real project (d560f159); framework-stream and run-event-stream stay wire-codec suites over the exported RunEvent.makeRunEventStream/RunEventDrain (root's decision this session).

Stream version 9.0

Release v19.0.0 shipped stream 8.0 with cli-contract 0.5.0. Since #302 the contract-version law reads the released documents from git at the version commit, the newest first-parent commit of the merge base that sets main's declared version, whose parent declares a lower one (tests/contract-version-law.integration.test.ts:166-176, tests/__fixtures__/contract-law.fixture.ts:228-245). On current main that commit is 9e81669b8 for both cli-contract (0.4.0 → 0.5.0) and plugin-interface (15.0.0 → 16.0.0), and its contract/stream.schema.json declares 8.0. This PR's change is breaking: a line missing its status's fields or reason code is refused, and the mutant-detail fields move. The law's stream rule (contract-law.fixture.ts:178-207, unchanged by #302) passes a breaking change only when the committed stream major is higher than the released one. At 8.0 it refuses with code: stream-version, "the released document declares stream version 8.0 and the committed document declares 8.0; a breaking stream change requires a higher stream version major"; at 9.0 the law raises no stream-version failure. That was checked by running evaluateContractLaw on 9e81669b8's stream document against this head's document as committed (9.0) and with the version set back to 8.0. StreamSchemaVersion is 9.0, contract/stream.schema.json is regenerated with generate:contract, and the README sample plus the tests that decode or compare real CLI output read 9.0. The changeset's migration note says: "The stream version moves from 8.0 to 9.0." #273's subsumption key is unchanged; no other contract change.

Verification (local, head 23133502, main merged at b08379dd)

  • build, tsc -b workspace typecheck, lint + lint:tsgo (stryker-js, cli-contract, plugin-interface), lint:conventions, dprint check, api:check: clean
  • integration: agent-facts, wall-clock-timeout, incremental-reuse, framework-stream, run-event-stream, rerun-by-id, shard-merge, progress-stream-file, ignore-patterns, workspace-package-exports-reuse, sarif-reporter, vm-parity, mcp-server: 91/91 (on 90cc1999; the two later commits change only descriptions, after which cli-contract 212/212, plugin-interface 347/347 and engine in-source 1627/1627 pass on 23133502)
  • e2e-core 357/357
  • Failing first. On a main worktree's built binary the three converted built-CLI scenarios fail: agent-facts' facts and remembered-code scenarios, and wall-clock-timeout (3 failed of 3). The overwrite-guard law ∀ec_WholeFileHoldingATornRunEventRecord_≡Allowed fails on replay property=141736395;seed=344594339 without eca06076 and passes with it.
  • Sabotage, one per guard. admitMutantReason: admitting Pending fails 3 laws; admitting a missing reason fails ∀c_CommandWithoutReason_≡Refused; skipping the vocabulary check fails ∀ct_CommandWhoseReasonNamesNoCode_≡Refused. Restoring the old survived wording fails the new static-survivor scenario ("1 covering tests ran, none failed" received). Each sabotage was reverted, and the suites pass after.
  • No mutation run of any kind; mutation evidence comes from the nightly on main.

CI on 23133502

Workflow Result Run
CI: check green (21:07→21:29Z) https://github.com/systemfsoftware/stryker-js-effect/actions/runs/38467175398
CI: checker-parity (1)-(6), checker-parity-changes, lane green same run
CI: checker-parity-report red, inherited (below) https://github.com/systemfsoftware/stryker-js-effect/actions/runs/38467175398/job/115545360171
Nix green https://github.com/systemfsoftware/stryker-js-effect/actions/runs/38467175453
Commitlint green https://github.com/systemfsoftware/stryker-js-effect/actions/runs/38467175598
Changeset Check green https://github.com/systemfsoftware/stryker-js-effect/actions/runs/38467176170

checker-parity-report: the gate is red, and the measurements do not show a #298 slowdown. Its one violation: slower-than-main, branch 183,478.6 ms against main 179,768.3 ms (+2.1%). All six parity legs passed, and both sides checked the same 165 mutants with the same work: 30 check calls, 195 snapshot updates, 30 emit builds. #298 does change the checker worker's dependency closure: the worker depends on plugin-interface and cli-contract, and #298 edits both. That is why the lane ran. The gate sums three line kinds per side, CheckCall, GroupCall and DigestCall (Parity.schema.ts:180), with no tolerance (speed-gate.workflow.ts:97). From the run's own shard NDJSON (artifacts checker-parity-38467175398-1..6):

Run Gate sum, main → branch CheckCall, main → branch DigestCall, main → branch Small-project digests slower on branch packages/stryker-js digest, branch − main per leg (ms) Result
#298 23133502 179,768 → 183,479 (+2.1%) 50,616 → 48,968 (−3.3%) 129,014 → 134,407 11/12, +1,510 ms −445, +1,824, +290, −1,079, +2,090, +1,202 red
#298 90cc1999 192,483 → 191,862 (−0.3%) 52,648 → 51,778 139,724 → 139,983 11/12, +565 ms −889, −853, +1,877, −827, +612, −226 green
#342 (no checker-closure file) 28,954 → 30,341 (+4.8%) 1,644 → 1,627 27,267 → 28,683 8/8, +1,416 ms not sampled red
#341 (ci.yml only) 25,143 → 26,315 (+4.7%) 1,816 → 1,589 23,289 → 24,689 8/8, +1,400 ms not sampled red
  • The check calls themselves are faster on the branch (−3.3%). The whole excess is in DigestCall, which times the first digest RPC on a freshly booted worker: building the TypeScript program for the project, then hashing its files (run-side.ts:746, ts-compiler.handle.ts:339-344). Both sides do that over the same corpus, the branch checkout (main.ts:568 repoRoot: path.resolve('.')).
  • packages/stryker-js carries most of the excess (+3,883 ms), and its per-leg sign flips. For the same program, with the same digest on every leg, main's own time ranges from 12,028 to 19,803 ms across legs.
  • The remaining branch-side digest cost (+1,510 ms) also appears on PRs that change no checker-closure file. build(deps): bump comment-checker from 3bfedd8 to 44e1c87 #342 and build(deps): bump actions/download-artifact from 7 to 8 in the github-actions group across 1 directory #341 show it at +1,416 and +1,400 ms, and both fail the gate at +4.7–4.8%. Main's side always runs first on every leg, and the two worker bundles are built in different trees (ci.yml:491-501 builds main's worker in a merge-base worktree; :537-538 pass the two workers). Which of the two makes the branch side slower is not established here.
  • The same worker code passed the gate. 90cc1999 ran the gate (it contains feat(stryker-js-typescript-checker): skip importer re-checks for edits inside function bodies #286) and passed at −0.3%. Between 90cc1999 and 23133502, the worker's plugin-interface and cli-contract source differs only by two description strings, plus their two copies in contract/stream.schema.json.

Disposition: this is not a #298 checker regression. With identical worker code the gate passed at −0.3% and then failed at +2.1%, and it fails PRs that leave the checker untouched. The defect is in the #286 speed gate on main: it counts program load as check time, it applies no noise margin, and it carries a branch-side offset. This PR does not chase it. Merge needs that gate fixed on main, or an explicit waiver from the gate's owner for this required check. A rerun would only draw another sample of the same noise.

Review receipt

Full-path ce-code-review, report-only, on 122d0430: lenses correctness, adversarial, testing, project-standards, learnings, plus three pack lenses (cell-architecture; schema-laws with mutation-discipline; boundary-testing, package-topology, ci-wall-clock and turbo-task-graph). The first dispatch of five persona lenses pointed at a wrong persona path; they were re-run with their personas, and the first returns were kept as extra input, so no finding was dropped. Every finding went to a fresh Stage 5b validator; three were re-validated with evidence the first validator had not read (#3 the two gate outputs, #5 the package turbo.json, #10 the pack file). Then one correctness lens over each later delta, every finding fixed or validator-rejected.

Models that served the review, read from omp's session records: every lens and every validator ran on DeepSeek V4.1 Flash (deepseek/deepseek-v4.1-flash); the merge leaf on Claude Haiku 4.5. No external cross-model peer CLI (codex, claude, grok, cursor-agent, opencode, gemini) is installed on this host.

Full-path round on 122d0430 (11 findings)

# Sev Lens Finding Disposition
1 P2 pack-schema-mutation MutantFactsInvalid declares its mutant id as a bare S.String, not the refined branded Mutant.MutantId Fixed in 84946922: MutantFactsInvalid.mutantId is Mutant.MutantId.
2 P2 project-standards New run-failure error carries a bare S.String where every sibling error uses the Mutant.MutantId brand Fixed in 84946922: same fix as #1.
3 P2 project-standards The remembered: <detail> strip is written three times in one PR, and the new module built for that vocabulary is not used Rejected (second validator, given both gate outputs): all three copies decode the one published Mutant.RememberedReason, and both shared homes are refused by the repo's gates (schema-file-exports-schemas-only on a *.schema.ts, make-body-purity on an imported helper in a workflow). The first validator had confirmed it; the attempted fix hit both gates and was reverted.
4 P2 pack-boundary-topology-ci-turbo Regenerated contract/stream.schema.json is compared only against the generator that wrote it, so the drift test passes whatever the schema says Rejected: the drift test compares the committed document with one regenerated from the source schema, so a stale or hand-edited document fails it (contract-documents.integration.test.ts:34-40, package README:50-53).
5 P2 pack-boundary-topology-ci-turbo New README-sample guard reads the repo-root README, which turbo's test inputs never declare, so a README-only change reuses the cached pass and the guard never runs Rejected (second validator, given the package config): packages/stryker-js-cli-contract/turbo.json sets test.cache: false, so the guard is never replayed from cache, and a README-only PR skips check by the lane policy (scripts/change-lane.ts:14). The first validator, which had not read that file, had confirmed it.
6 P2 correctness Survived reason claims N covering tests ran when none cover it Fixed in ef662a17: A survivor whose measured coverage names no test reads no test covers it alone; N tests ran with it active, none failed. The new built-CLI scenario (static module-level constant) fails with the old wording.
7 P2 correctness JSON mutation report statusReason now carries a code prefix Fixed in b7b99a4d: The changeset states the JSON report's statusReason carries its code, and how to get the bare message.
8 P2 testing The engine guard that fails a run on incomplete mutant facts has no test at any layer Fixed in 84946922: The admission moved into the pure workflow admit-mutant-reason.workflow.ts (inside the *.workflow.ts mutate glob) with four property laws; each clause, broken alone, fails its law (below).
9 P3 correctness (first dispatch) --mutant help still promises mutant-detail fields the event dropped Fixed in b7b99a4d: --mutant help, the option description and both READMEs name the status reason and the facts each status admits.
10 P3 pack-cell-architecture Schema refusal that fails the run is flattened to a string, dropping the structured issue Fixed in 84946922: MutantFactsInvalid carries the refused SchemaIssue as cause (first validator: unresolved, the pack file was outside its checkout; second, given the file: confirmed).
11 P2 adversarial Seeded-fault harness: two of eight witness lanes abort before running (NoCoverage and Ignored anchors no longer exist) Rejected: both stale anchors (seed-status-fault.ts:54, :85) and their target files are unchanged by this PR; the abort predates it.

Delta rounds

Round (head) Sev Finding Disposition
f31bb7f5 P3 The changeset's mutant-detail bullet lost the statuses that carry coveredBy Fixed in a87306b7
f31bb7f5 P3 covered-not-killed documentation did not cover a survivor naming no covering test Fixed in a87306b7
90cc1999 P3 That documentation called the empty case "a static mutant"; under coverageAnalysis: 'all' a non-static mutant gets coveredBy: [] too Validator confirmed; fixed in 0fe8fb20
0fe8fb20 P3 strengthen-tests description tied selection to the coverage mode, not to reported coverage Fixed in 23133502
0fe8fb20 P3 The covered-not-killed list of causes for an empty coveredBy still missed a case Fixed in 23133502: both descriptions now state only the condition the code tests
23133502 P3 The regenerated contract document re-blesses the drift test's own expected value (re-raise of full-round #4) Rejected by a fresh validator: the oracle is the generator over the source schema, and the README requires the joint update

Pack rulings

Full-path round, 57 rules across seven packs:

Pack Comply Not matched Violate
boundary-testing 2 4 1
package-topology 6 1 0
ci-wall-clock 1 12 1
turbo-task-graph 0 0 1
cell-architecture 6 8 1
schema-laws 7 2 1
mutation-discipline 1 2 0

Every matched rule (comply or violate) with the line its ruling rests on:

Pack Rule Ruling File:line
boundary-testing expected-values-never-come-from-the-subjects-output violate → #4 (rejected) packages/stryker-js-cli-contract/tests/contract-documents.integration.test.ts:40
boundary-testing no-mocks-on-internal-glue comply packages/stryker-js/tests/wall-clock-timeout.integration.test.ts:11
boundary-testing real-system-oracles comply packages/stryker-js/tests/wall-clock-timeout.integration.test.ts:109
package-topology condition-branch-agreement comply packages/stryker-js-cli-contract/etc/stryker-js-cli-contract.api.md:16
package-topology enumerated-entry-modules comply packages/stryker-js-cli-contract/src/RunEvent/mod.ts:1
package-topology import-time-inertness comply packages/stryker-js-cli-contract/src/next-action.ts:1
package-topology one-access-path comply packages/stryker-js-cli-contract/src/mod.ts:2
package-topology public-signature-types comply packages/stryker-js-cli-contract/src/next-action.ts:7
package-topology surface-changes-are-versioned comply .changeset/mutant-lines-carry-their-facts.md:2
ci-wall-clock no-lane-grades-the-repos-own-docs comply packages/stryker-js-cli-contract/tests/readme-stream-sample.integration.test.ts:63
ci-wall-clock pr-gate-runs-only-what-the-diff-affects violate → #5 (rejected) packages/stryker-js-cli-contract/tests/readme-stream-sample.integration.test.ts:25
turbo-task-graph selection-and-caching-go-through-turbo violate → #5 (rejected) turbo.json:61
cell-architecture callable-vs-resource-syntax comply packages/stryker-js-cli-contract/src/next-action.ts:44
cell-architecture decode-never-cast comply packages/stryker-js-cli-contract/src/run-event.schema.ts:238
cell-architecture four-channel-contracts violate → #10 (fixed 84946922) packages/stryker-js/src/run/mutant-facts.ts:51
cell-architecture pipeable-dual-parity comply packages/stryker-js-cli-contract/src/next-action.ts:44
cell-architecture pure-decision-workflows comply packages/stryker-js/src/Reporter/render-clear-text-report.workflow.ts:376
cell-architecture service-and-layer-boundaries comply packages/stryker-js/src/status-reason.ts:10
cell-architecture single-namespace-barrel comply packages/stryker-js-cli-contract/src/RunEvent/mod.ts:1
schema-laws arbitrary-filter-floors comply packages/stryker-js-cli-contract/src/next-action.schema.ts:18
schema-laws cross-field-invariants-as-struct-checks comply packages/stryker-js-cli-contract/src/run-event.schema.ts:101
schema-laws data-only-schema-classes comply packages/stryker-js-cli-contract/src/run-event.schema.ts:207
schema-laws invariants-as-refinements violate → #1 (fixed 84946922) packages/stryker-js/src/Run.schema.ts:52
schema-laws law-failure-is-a-codec-defect comply packages/stryker-js-cli-contract/src/run-event.schema.ts:235
schema-laws refusals-beside-generated-laws comply packages/stryker-js-cli-contract/src/next-action.schema.ts:115
schema-laws tagged-unions-over-state-by-presence comply packages/stryker-js-cli-contract/src/run-event.schema.ts:207
schema-laws tests-own-no-schemas comply packages/stryker-js-cli-contract/tests/run-event-wire-encode.integration.test.ts:47
mutation-discipline kill-survivors-with-contract-properties comply packages/stryker-js/src/__tests__/render-clear-text-report.workflow.property.test.ts:194

Delta round on f31bb7f5 (the fix commits and the README merge resolution), matched rules: 12 comply, 45 not matched, 0 violate.

Pack Rule Ruling File:line
cell-architecture callable-vs-resource-syntax comply packages/stryker-js/src/admit-mutant-reason.workflow.ts:61
cell-architecture decode-never-cast comply packages/stryker-js/src/admit-mutant-reason.workflow.ts:46
cell-architecture four-channel-contracts comply packages/stryker-js/src/Run.schema.ts:52
cell-architecture pure-decision-workflows comply packages/stryker-js/src/admit-mutant-reason.workflow.ts:61
boundary-testing no-mocks-on-internal-glue comply packages/stryker-js/tests/agent-facts.integration.test.ts:171
boundary-testing real-system-oracles comply packages/stryker-js/tests/agent-facts.integration.test.ts:171
schema-laws data-only-schema-classes comply packages/stryker-js/src/Run.schema.ts:50
schema-laws invariants-as-refinements comply packages/stryker-js/src/Run.schema.ts:52
schema-laws tests-own-no-schemas comply packages/stryker-js/src/__tests__/admit-mutant-reason.workflow.property.test.ts:14
package-topology import-time-inertness comply packages/stryker-js/src/admit-mutant-reason.workflow.ts:6
package-topology surface-changes-are-versioned comply .changeset/mutant-lines-carry-their-facts.md:4
mutation-discipline kill-survivors-with-contract-properties comply packages/stryker-js/src/__tests__/admit-mutant-reason.workflow.property.test.ts:39

Delta rounds on 90cc1999 and 0fe8fb20: 57 not matched each (description-only changes). On 23133502: 56 not matched, 1 violate (boundary-testing/expected-values-never-come-from-the-subjects-output at packages/stryker-js-cli-contract/contract/stream.schema.json:647), the re-raised drift-test finding, rejected by the validator above.

Overlaps with open PRs (coordinated by the root)

The OP13b overlap gate is waived by the root for this PR: U3 opens off main and merges main up as other streams land; no force-push. Files shared with each open PR (git diff --name-only origin/main...<head>, main b08379dd):

  • feat(stryker-js-instrumenter)!: mark which mutants a dry run can probe (T10 U2) #350 stryker/t10-u2-probe-eligibility: packages/stryker-js-plugin-interface/etc/stryker-js-plugin-interface.api.md, packages/stryker-js-plugin-interface/src/Mutant/mod.ts, packages/stryker-js/src/mutation-reporting.service.ts, packages/stryker-js/tests/run-event-stream.integration.test.ts
  • fix(stryker-js)!: confirm a wall-clock expiry before scoring it a Timeout #347 stryker/runaway-mutant-safety: packages/stryker-js/src/run/mutant-run.ts, packages/stryker-js/src/run/mutant-settlement.ts, packages/stryker-js/tests/wall-clock-timeout.integration.test.ts
  • feat(mutant-quality)!: L6 Effect concurrency mutators run by default #305 stryker/mutant-quality-l6-concurrency-default: packages/stryker-js-cli-contract/README.md, packages/stryker-js-cli-contract/etc/stryker-js-cli-contract.api.md
  • fix(mutant-quality)!: L5 equivalence refusals and constant-collection-size #304 stryker/mutant-quality-l5-equivalence-refusals: README.md, packages/stryker-js-cli-contract/README.md, packages/stryker-js-cli-contract/contract/stream.schema.json, packages/stryker-js-cli-contract/src/stream-version.schema.ts, packages/stryker-js-cli-contract/tests/run-event-wire-line.integration.test.ts, packages/stryker-js-plugin-interface/src/ignore-rule.schema.ts, packages/stryker-js/tests/framework-stream.integration.test.ts, packages/stryker-js/tests/run-event-stream.integration.test.ts, packages/stryker-js/tests/shard-merge.integration.test.ts, test/e2e/tests/failing-run.e2e.test.ts
  • feat(stryker-js): skip if-condition mutants whose block no test runs #296 stryker/mutant-quality-l4-uncovered-block: README.md, packages/stryker-js-cli-contract/README.md, packages/stryker-js-cli-contract/contract/stream.schema.json, packages/stryker-js-cli-contract/src/stream-version.schema.ts, packages/stryker-js-cli-contract/tests/run-event-wire-line.integration.test.ts, packages/stryker-js-plugin-interface/etc/stryker-js-plugin-interface.api.md, packages/stryker-js-plugin-interface/src/Mutant/mod.ts, packages/stryker-js-plugin-interface/src/ignore-rule.schema.ts, packages/stryker-js/src/__tests__/incremental-diff.workflow.property.test.ts, packages/stryker-js/src/incremental-diff.workflow.ts, packages/stryker-js/src/run/mutant-settlement.ts, packages/stryker-js/tests/framework-stream.integration.test.ts, packages/stryker-js/tests/run-event-stream.integration.test.ts, packages/stryker-js/tests/shard-merge.integration.test.ts, test/e2e/tests/failing-run.e2e.test.ts
  • ci(mutation): share each project's verdict store across shards and runs #289 stryker/verdict-store-ci: README.md, packages/stryker-js-cli-contract/contract/stream.schema.json, packages/stryker-js-cli-contract/src/run-event.schema.ts, packages/stryker-js-cli-contract/src/stream-version.schema.ts, packages/stryker-js-cli-contract/tests/run-event-wire-line.integration.test.ts, packages/stryker-js-plugin-interface/etc/stryker-js-plugin-interface.api.md, packages/stryker-js/README.md, packages/stryker-js/src/__tests__/incremental-diff.workflow.property.test.ts, packages/stryker-js/src/incremental-diff.workflow.ts, packages/stryker-js/src/mutation-reporting.service.ts, packages/stryker-js/src/run/instrument.ts, packages/stryker-js/src/run/mutant-run.ts, packages/stryker-js/src/run/mutant-settlement.ts, packages/stryker-js/tests/framework-stream.integration.test.ts, packages/stryker-js/tests/run-event-stream.integration.test.ts, packages/stryker-js/tests/shard-merge.integration.test.ts, test/e2e/tests/failing-run.e2e.test.ts
  • feat(stryker-js)!: plan only the lines changed since a git ref #287 stryker/mutant-quality-l2-diff-plan: README.md, packages/stryker-js-cli-contract/README.md, packages/stryker-js-cli-contract/contract/stream.schema.json, packages/stryker-js-cli-contract/etc/stryker-js-cli-contract.api.md, packages/stryker-js-cli-contract/src/stream-version.schema.ts, packages/stryker-js-cli-contract/tests/run-event-wire-line.integration.test.ts, packages/stryker-js/src/bin/cli-command.ts, packages/stryker-js/tests/framework-stream.integration.test.ts, packages/stryker-js/tests/run-event-stream.integration.test.ts, packages/stryker-js/tests/shard-merge.integration.test.ts, test/e2e/tests/failing-run.e2e.test.ts
  • feat(stryker-js): share verdicts across runs, shards and PRs via a store #271 stryker/verdict-store: README.md, packages/stryker-js-cli-contract/contract/stream.schema.json, packages/stryker-js-cli-contract/src/run-event.schema.ts, packages/stryker-js-cli-contract/src/stream-version.schema.ts, packages/stryker-js-cli-contract/tests/run-event-wire-line.integration.test.ts, packages/stryker-js-plugin-interface/etc/stryker-js-plugin-interface.api.md, packages/stryker-js/README.md, packages/stryker-js/src/__tests__/incremental-diff.workflow.property.test.ts, packages/stryker-js/src/incremental-diff.workflow.ts, packages/stryker-js/src/mutation-reporting.service.ts, packages/stryker-js/src/run/instrument.ts, packages/stryker-js/src/run/mutant-run.ts, packages/stryker-js/src/run/mutant-settlement.ts, packages/stryker-js/tests/framework-stream.integration.test.ts, packages/stryker-js/tests/run-event-stream.integration.test.ts, packages/stryker-js/tests/shard-merge.integration.test.ts, test/e2e/tests/failing-run.e2e.test.ts

#271, #289, #287, #296 and #304 also declare stream 9.0 against the same released 8.0; their breaking changes ride the one unreleased major, so whichever lands second keeps 9.0 and merges the stream document.

#6 — ∀e_NonTerminal_≡FramedWhateverTheMode restored; counterexample and disposition

  • Counterexample. CONFORMANCE_REPLAY="property=2854989700;seed=1890475051;runs=100"; after 11 shrinks it is
    [{"mode":"human","signal":"tool","headerWritten":false,"terminalSeen":false,"completed":0,"total":0}, mutantTested].
    The seed file src/__tests__/__property_seeds__/frame-run-event.workflow.property.test.ts.jsonl was untracked: the
    red run itself wrote it. The only tracked seed file in the package is incremental-diff.workflow.property.test.ts.jsonl.
  • Cause. The decision passes the event through unchanged (frame-run-event.workflow.ts:198-201, event: command.event).
    The old law asserted reference identity (result.success.event === event). On base, RunMutantTestedEvent was an
    S.TaggedClass (base run-event.schema.ts:130); its instances pass through EventFramed.make as the same object.
    U3 turned it into a union of S.Struct variants over status, and schema construction rebuilds plain structs.
    Probe on this branch: EventFramed.make(...).event === event holds for 50/50 Heartbeat (class) samples and fails
    for RunMutantTestedEvent (struct union). The framing decision has no defect; the identity check depended on the
    class representation.
  • Fix. The law now asserts the framed event equals the input under the event schema's own equivalence:
    S.toEquivalence(S.toType(RunEvent.RunEvent)). Nothing about the claim is weakened: any field that differs still fails.
  • Replay of the exact counterexample against current code.
    • Identity assertion put back temporarily: the seed-only replay and the pinned replay
      (…;attempt=4;size=0;path=0×11;failure=ReturnedFalse) both reproduce the input above.
    • Equivalence assertion: the seed-only replay passes (100 runs, attempt 4 included).
    • The pinned replay reports PropertyRefuted … 0 shrinks … Shrunk input: undefined. That is
      ReplayMismatch { reason: "PropertyPassed" } (effect/src/internal/arbitrary/runner.ts:597): the recorded attempt
      now passes. The harness prints a mismatch, which carries no input, as a refutation with an undefined input.
  • Three fresh-seed runs pass.

README sample

The README stream sample on main fails the new decode test: its Killed line is refused at statusReason (Expected string, got null). The updated sample passes.

Mutants the new code exposes

admit-mutant-reason.workflow.ts is inside the dogfood mutate glob (src/**/*.workflow.ts), so the nightly on main after merge grades it against the four laws. run/mutant-facts.ts, status-reason.ts, run/mutant-run.ts and the stryker-js-cli-contract package stay outside main's mutate scope.

Mutant-line size on main's corpus

Source: run 38083502908 (mutation-report-438, main 56426ca94, the commit this branch merged). The merged
mutation.json carries no coveredBy and no killedBy on any mutant, though per-test coverage was measured (491
NoCoverage). So each line was rebuilt from the four per-project stryker-incremental.json files in the same artifact,
using real covering and killing test names, and encoded through RunEvent.RunEventWireLine. All 11,041 lines were
accepted by the status constructors.

status n p50 B p95 B max B p50 without the new keys B
Survived 1,315 884 2,087 4,713 467
NoCoverage 491 608 753 1,036 444
Timeout 14 676 4,409 4,409 463
Killed 2,508 529 594 789 442
CompileError 4,863 489 511 631 488
Ignored 1,850 430 460 785 429

The largest line (4,713 B) fits the 10,000-byte R11 budget by itself. The per-mutant stream as a whole is not bounded
here: it is still on default machine stdout until the summary-first unit (R11/R12) moves it, and survivor lines roughly
double at p50. Separate finding: the merged CI report drops coveredBy and killedBy (not fixed here).

coveredBy distribution on the same corpus

A median alone misleads here: NoCoverage is 0 by definition, and many killed, survived and compile-error mutants list no test. Count of covering test names per mutant, from the four stryker-incremental.json files (the merged mutation.json has coveredBy on 0 of 11,041 mutants):

status n coveredBy absent length 0 p50 p95 max
Survived 1,315 0 372 2 17 28
Killed 2,508 0 414 4 25 31
NoCoverage 491 0 491 0 0 0
Timeout 14 0 8 0 31 31
CompileError 4,863 0 2,185 1 8 31
Ignored 1,850 1,850 — — — —
RuntimeError 0 — — — — —

Survived lines show at most 3 covering tests in next.tests.shown plus next.tests.total, so the survivor next stays small even at the max (28).

…and next action

Each `mutant` line and `mutant-detail` event is a union over `status`:
Survived/NoCoverage/Timeout/RuntimeError carry `original`, `coveredBy`
and a `next` action (`RunEvent.NextAction`); Killed carries the full
`killedBy`; every status carries its `<code>: <detail>` reason from the
plugin-interface vocabulary, with reuse emitting `remembered`.

- next-action.schema.ts / next-action.ts: closed NextAction set, pure
  `nextActionOf` (dual over its facts).
- NextActionTests generates constructively (S.declare + toCodecArbitrary)
  and states its refusal boundary in-source (schema-laws pack).
- Engine: mutant-facts.ts builds lines from the pre-instrumentation source;
  reporting, early results and incremental reuse emit coded reasons.
- MCP rerun and `stryker run --mutant` detail read the same facts.
- agent-facts.integration.test.ts: AE1/AE2 against the real engine and vm
  runner; incremental-reuse asserts reused lines decode to `remembered`.
… statuses and reproducer

nextActionTestsOf owns the three-name cut beside the schema whose refinement depends on it; the next-action table keys on Mutant.ActionableStatus; reproducerOf is exported and used by the settlement detail and the MCP rerun. The engine builds actionable facts only in their branches, indexes rerun results once, and names the per-run source map sourceTextsOf.
…s-u3

# Conflicts:
#	packages/stryker-js-cli-contract/etc/stryker-js-cli-contract.api.md
#	packages/stryker-js-cli-contract/tests/run-event-wire-line.integration.test.ts
…ant lines

- Each status variant is built from one explicit tuple, and a type-level gate
  refuses a union that misses a status.
- statusReason is required on every variant; a settled mutant without one fails
  the run with the mutant-facts-invalid run failure code.
- The remembered reason prefix decodes through a schema in plugin-interface.
- original is null when the source file cannot be read; in-place runs cite the
  pre-instrumentation text.
- A survivor without measured coverage asks for perTest coverage (fix-config).
- Clear-text prints only the reason detail; README stream sample is decoded
  against the published wire codec.
tsgo's newSchemaClass rule refuses `new` on a Schema class; CI check failed
@systemfsoftware/stryker-js#lint:tsgo on mutant-facts.ts:35.
…s-u3

# Conflicts:
#	.changeset/ignored-reasons-on-the-stream.md
… 9.0

Release v19.0.0 shipped stream 8.0, so the mutant-line change (required
per-status facts, mutant-detail fields moved) needs the next major: the
contract-version law refused it as stream-version under 8.0.

- StreamSchemaVersion is '9.0'; contract/stream.schema.json regenerated
  with generate:contract.
- README stream sample and tests that decode or compare real CLI output
  read 9.0.
- Changeset migration note names the 8.0 -> 9.0 move.
systemfsoftware-maker added a commit that referenced this pull request Oct 12, 2026
…anged

A summarized report with no regression said "no project/phase changed past
the signal threshold" even when its table showed a phase that regressed under
an unchanged total. The notice is now titled "Bench phase-only slowdown", says
no leg total crossed the regression threshold, and lists each such cell as a
phase-only slowdown. A new law drives a check-only slowdown through
summarizeBench and renderBenchReport.

The plan moves to docs/plans/2026-10-12-2032-ci-bench-off-pr-plan.md
(supersedes 1926) and adds the CONST-W3 declaration for the bench.yml edits,
the corrected cause of the stream-undecodable PR runs (#298 changes the
stream contract), and that case as a known limit.
@ryanleecode

Copy link
Copy Markdown
Contributor

Closed by the root conductor: one open PR per stream from now on, and this one is waiting on other work (#329 and a red check). Branch kept; the stream reopens it (or a fresh PR off main) when it becomes that stream's active PR.

@ryanleecode

Copy link
Copy Markdown
Contributor

Reopened by the root conductor: closing this was wrong. It stays open and is driven to merge.

@ryanleecode ryanleecode reopened this Oct 13, 2026
ryanleecode added a commit that referenced this pull request Oct 13, 2026
…d cell (#317)

* refactor(stryker-js): acquire the sandbox through cells whose gates are workflows (U3)

Sandbox.blueprint.ts (511 lines) now holds only the kind, its withPreprocessor
step and its scoped/layer targets (67 lines, one Blueprint.make, no
Effect.fn). Its targets run acquireSandboxCell.

planSandboxAcquisition decides from inPlace, backupDirectory,
symlinkNodeModules and buildCommand: in place with or without a restore,
copied with or without node_modules links, and the build command when it
is non-empty. acquire-sandbox.cell.ts writes one straight line per
variant. The restore stays an Effect.addFinalizer on the caller's scope.
Build and link run after it through Cell.gate, never through cell.run.

judgeSandboxBuild (sandbox-build.cell.ts) passes a zero exit and fails
with the same message main built. classifySandboxDirectory
(link-node-modules.cell.ts) skips the temp directory before it matches
node_modules, as main's walk did. planNodeModulesLinks links every
node_modules the walk found, in order.

MakeSandboxInput, SandboxSpec and FilePreprocessor move to
Sandbox.schema.ts. Laws for the four workflows draw their production
command schemas.

* fix(stryker-js): keep the build and node_modules link inside the sandbox acquisition

The Sandbox blueprint now runs the acquisition: a stryker.sandbox.acquire
span around the prepare cell, the build cell under its own scope, and the
link cell. prepare-sandbox.cell.ts (was acquire-sandbox.cell.ts) decides
in place or copied under the new stryker.sandbox.prepare span. The link
cell runs on every path and logs "Start symlink node_modules" each time,
as main does; planNodeModulesLinks decides NodeModulesNotLinked when the
sandbox does not link.

The build cell's read phase no longer closes a scope; the blueprint scopes
each build run. The unused withPreprocessor step and the preprocessors
field are gone. The sandbox-acquisition-telemetry suite pins the span tree,
step order, symlink log and in-place restore for three paths; it passes on
main's code and fails when the build and link steps are swapped or dropped.

* docs(repo): refresh plan entry 6 (mutation verdict) against main ddeb67a

Re-grounds U6 on main ddeb67a: refreshed line references (P20), the
log texts and exit precedence no suite pins (P21), the four classify-exit
survivors re-read from mutation-report-444 (P22), overlap with #298, #271,
#297 and #277 with a merge-tree probe (P23), and the cell's span
choice (P24, KTD12). U6 no longer stacks on U2, which merged as #268.
Raises Q1-Q3 for the conductor before ce-work.

* docs(repo): supersede the grab-bag plan with U6 as a Sandwich layer on #297

The plan has executed units (#260, #268, #272), so the change lands as a
new dated plan declaring `supersedes:` (harness rule plan-discipline);
REPO-D2 caps a PR at one added plan file, which a replacement satisfies.

- KTD12: the verdict cell is a Sandwich under a new span,
  stryker.mutationReporting.judgeScore. ADR-0001's cell row refuses
  Cell.fromEffect stage wrappers; judging inline in reportAll keeps the
  verdict's I/O out of a cell (R10). The taxonomy entry edits two files
  #297 edits, so U6 stacks on U3 (OP13b, P25).
- R14: main's spans keep name, attributes and ancestors; a new span is a
  SpanTaxonomy entry with a cli-contract patch changeset, as #297 did.
- U6 adds tests/drain-failure-outranks-verdict.integration.test.ts with
  a revert proof; P14 rewritten, P23 refreshed, P24 and P25 added.

* refactor(stryker-js): judge the mutation score in its own workflow and cell

U6 of the grab-bag decomposition plan. The score-vs-threshold decision
leaves mutation-reporting.service.ts and classify-exit.workflow.ts:

- judgeMutationScore (judge-mutation-score.workflow.ts) decides one of
  five variants, one per verdict log branch on main; each carries a
  single-valued exitClass, so no arm can return null or undefined.
- mutationVerdictCell is a Sandwich under the new span
  stryker.mutationReporting.judgeScore (KTD12); its write handlers emit
  main's log lines verbatim and answer the variant's exit class.
- classifyExit keeps only precedence over pending classes; every decision
  variant carries its exitClass, so reportAll's reverse maps go.
- reportAll drains, runs the cell, then ranks [verdict, terminalDrain]
  once; a drain RuntimeError outranks a VerdictFail by precedence.
- SpanTaxonomy gains mutationReportingJudgeScore; span-taxonomy.json is
  regenerated with generate:contract.

tests/drain-failure-outranks-verdict.integration.test.ts pins the drain
precedence no suite graded (P21): it passes on the pre-change code and
fails with the drain class dropped from reportAll's pending list.

* test(stryker-js): trace the judgeScore span and decode the judge oracle

Review ruling r29 on U6 (grab-bag decomposition plan, entry 6):

- G2: tests/drain-failure-outranks-verdict.integration.test.ts gains a
  scenario that runs the same in-process mutation run under an
  InMemorySpanExporter and asserts exactly one
  stryker.mutationReporting.judgeScore span, parented by
  stryker.mutationReporting.reportAll, with verdict VerdictFail. With the
  cell replaced by the bare workflow the scenario fails (parents []).
- G4: the judgeMutationScore laws decode the oracle through the
  ScoreJudgement schema and compare with its schema equivalence instead
  of JSON.stringify.
- F1: the plan says the classifyExit pair and single laws were
  consolidated into the one pending-array law, and records the span
  scenario and its revert proof.

* docs(repo): exclude docs/plans from the U6 removal check

Review ruling r30: the plan's U6 Verification grep matched the plan's own mentions of the removed symbols, so it could never return nothing. It now excludes lock files and docs/plans, as earlier plans do, and returns no matches at this head.

* test(stryker-js): grade drain-over-verdict through the built CLI

Intent r38 (operator rules 2026-10-12): an integration test drives the
built CLI or the published API and asserts only what a user observes.

- tests/drain-failure-outranks-verdict.integration.test.ts now runs
  dist/main.mjs on a consumer project with an on-disk reporter plugin
  (tests/__fixtures__/refusing-reporter/) loaded through `plugins`. One
  outline: with the refusing plugin the run exits 3, without it 1; each
  row decodes the stdout verdict record and checks the score is under the
  break threshold.
- The in-process span-parentage scenario is removed: parentage is not in
  the published span contract.
- Plan U6 test lines drop the hand-applied mutant proofs and name the
  mutant classes as targets for the next main Mutation report.

* docs(repo): cite src/bin/main.ts for the CLI OTEL_* reads in plan U6

Debate r39: the span-parentage line named bin/main.ts, but the file is packages/stryker-js/src/bin/main.ts.

* fix(stryker-js): decode the break threshold into a tagged union before the verdict decides

Review of #317 (run 20261013-032741-afbae46e, validator-confirmed): JudgeMutationScoreCommand carried breakingThreshold as S.NullOr(Percentage) and decide probed its presence, which schema-laws tagged-unions-over-state-by-presence and CONST-D4 forbid. The field now decodes the options' null|Percentage into NoBreakThreshold | BreakAt { threshold } (encode forbidden: the threshold is only ever read from options), and decide dispatches with Match.valueTags. The cell's read is unchanged; laws draw the field's type side.

* fix(stryker-js): encode the tagged break threshold back to its options value

The command field decoded null|percentage into NoBreakThreshold|BreakAt but refused to encode, so the generated in-source codec laws (∀x_JudgeMutationScoreCommandEnc_=x, ∀x_JudgeMutationScoreCommand_=x) failed on CI run 38344123637. Encode now maps NoBreakThreshold to null and BreakAt to its threshold, the inverse every other decodeTo in this package defines (Plugins.schema.ts).

* docs(plans): supersede U6 with the tagged break-threshold codec

Review of 9cf18e7 confirmed two P3s: the 1732 plan's U6 approach and test-scenario bullets still described breakingThreshold as S.NullOr(Report.Percentage) with a constructor-defaulted null exitClass. The 0456 plan supersedes section U6 only (as the 2348 plan does for U9), stating BreakThresholdFromOption, the tag dispatch, the decoded-side law draw and the plain S.Null exitClass.

---------

Co-authored-by: Ryan Lee <drdgvhbh@gmail.com>
systemfsoftware-maker added a commit that referenced this pull request Oct 13, 2026
…lution

The root accepted D1, D2, D3 (guards checked first), D5 and D6, the Done
grep, no-rename, no-shim, R6, R7, the inventory and the landing table.

Its five review corrections are now in the plan:
- no scratch-edit proofs: refusals are committed negative fixtures or a
  closed deliberate-red PR (bench lint ban, E2E-1's missing-journey case);
- a contract codec is never mutation-graded, because Mutation selects
  *.workflow.ts only; PR C's codec is graded by its schema laws;
- #306 and #327 fixed the ManifestDocument and CountedReportJson flakes,
  so the law moves unchanged with no follow-up unit;
- D2's corpus change is timed before the post-#316 speed baseline, or
  recorded as a reviewed corpus change;
- landing order A, then C at any time, B after #316 and #298, D last.

PR A's evidence and its one-time cost (the first main Mutation run after
merge re-verifies every mutant, because the cache path list changes)
are named in the plan.
ryanleecode pushed a commit that referenced this pull request Oct 13, 2026
* docs(plans): plan the dissolution of test/e2e-core into the e2e harness, the bench and the CLI contract

Per-file destinations for all 80 files, a four-layer stack (Mutation de-enrolment, bench, stream codec, harness and deletion), Stream K sequencing and the landing order of the open PRs that touch the package. ce-doc-review skipped: the root reviews this plan (r42).

* docs(plans): refresh the test/e2e-core dissolution plan against main and the root's corrections

Applies the root's six corrections of 22:25: MUTATED_PROJECTS is data at lockfile-closure-digest.integration.test.ts:29-34 and changes with mutation PROJECTS; stryker-js-cli-contract is not in PROJECTS, so the moved stream codec is ungraded until Stream K enrols it; the lint coupling at test/bench/oxlint.config.ts:1, test/bench/tsconfig.node.json:11, test/e2e/oxlint.config.ts:17 and test/e2e/AGENTS.md:21; kill-matrix.yml 9/15/82 and mutation.yml 36/43/64/118; the exact inventory (29 src modules plus mod.ts, 13 laws, 7 suites, 18 fixtures, 12 package files); and verified or [unverified]-marked pack and config claims.

Adds Done and Does-not-count, the 28 outside files that name the package by layer, #287's overlap with PR D, #321's merge, and D6 for the three fixture schemas under tests-own-no-schemas.

* docs(plans): record the root's plan review of the test/e2e-core dissolution

The root accepted D1, D2, D3 (guards checked first), D5 and D6, the Done
grep, no-rename, no-shim, R6, R7, the inventory and the landing table.

Its five review corrections are now in the plan:
- no scratch-edit proofs: refusals are committed negative fixtures or a
  closed deliberate-red PR (bench lint ban, E2E-1's missing-journey case);
- a contract codec is never mutation-graded, because Mutation selects
  *.workflow.ts only; PR C's codec is graded by its schema laws;
- #306 and #327 fixed the ManifestDocument and CountedReportJson flakes,
  so the law moves unchanged with no follow-up unit;
- D2's corpus change is timed before the post-#316 speed baseline, or
  recorded as a reviewed corpus change;
- landing order A, then C at any time, B after #316 and #298, D last.

PR A's evidence and its one-time cost (the first main Mutation run after
merge re-verifies every mutant, because the cache path list changes)
are named in the plan.

* ci(mutation): grade only the shipped products, not test/e2e-core

Mutation and the kill matrix mutated four projects: the three product
packages and test/e2e-core, a private test-tooling package. Test tooling
is not shipped, so grading it spends shard time on code no user runs
(plan R7).

- mutation.yml drops test/e2e-core from PROJECTS (:36), from the
  INCREMENTAL_REPORTS cache paths (:43), and from the incremental (:64)
  and build (:118) cache keys.
- kill-matrix.yml drops it from both default project lists (:9, :15)
  and from its build cache key (:82), which stays byte-identical to
  mutation.yml's so the kill matrix still restores the mutation build.
- lockfile-closure-digest.integration.test.ts drops it from
  MUTATED_PROJECTS, the test's copy of Mutation's project list, so its
  live-lockfile scenario checks the projects Mutation actually grades.

The package itself, its mutation script and stryker.config.ts stay until
the last layer deletes the package.

One-time cost: a cache entry's version is a hash of its path list, so
the first main Mutation run after this lands cannot restore the previous
incremental reports and re-verifies every mutant once.

* test(stryker-js): read the mutated projects from mutation.yml in the lockfile test

The lockfile-closure scenarios checked "every mutated project" against a
hard-coded copy of Mutation's corpus, which nothing compared with the
workflow. The test now reads env.PROJECTS from mutation.yml, so the two
lists cannot drift apart (review run 20261013-084434-c89dc2ab, testing P2).

CONST-W3: this PR edits .github/workflows/mutation.yml and kill-matrix.yml,
which AGENTS.md:7 marks read-only and CONST-E9 treats as a judgment surface.
Root assigned that edit when it accepted the plan (PR A). The edit only
removes test/e2e-core from the corpus; no threshold, baseline or check
changes. .stryker/budget-baseline.json stays as measured over four projects,
and its owner decides whether to re-measure it.

Plan 0900 supersedes 0218 with these review outcomes.

* chore(changeset): record a none intent for the lockfile test's corpus change

release.jsonc now gates intents on turbo's typecheck graph (#335), whose inputs include tests, so the test edit in this PR needs an intent. Nothing published changes.

* docs(plans): name the workflow-derived corpus wherever plan 0900 cited MUTATED_PROJECTS

Delta review run 20261013-090355-9173e728 (P3): the Problem Frame, the PR D ordering risk, the landing note and P9 still described the retired constant. They now give it as main's state at 381b3f5 and name the read of mutation.yml env.PROJECTS that replaces it.
ryanleecode pushed a commit that referenced this pull request Oct 13, 2026
* ci(bench)!: bench each main commit against its first parent

The bench leaves the pull-request path. bench.yml runs on push to main
(docs-only pushes excluded) and on workflow_dispatch, with concurrency
bench-${{ github.sha }} and no cancel, so no PR pays for or waits on it.

- Side A is the first parent, side B the pushed commit; both are worktrees
  under RUNNER_TEMP with their own released tarballs and frozen install,
  and the checkout holds only the harness.
- Every in-process deadline is gone: BENCH_DEADLINE_MS, the setup budget,
  the run deadline, BENCH_RUN_TIMEOUT_MS, and the setup-timed-out,
  budget-exceeded, timed-out and timeout shapes. Every timeout-minutes is
  60; that job timeout is the only kill.
- The report carries the leg's wall time (from JOB_STARTED_AT) against a
  10-minute target, never enforced, and schemaVersion moves to 2.0.
- Only a leg's total decides a regression (non-overlapping samples and
  |delta| >= max(3% of A, 10 ms)). The regression block names the commit,
  its parent, total then every regressed phase, and the next action: a
  revert PR or root-fix P0 on main, speed evidence only elsewhere.
- Every run executes from RUNNER_TEMP/bench-run, so both sides record the
  same project root, inputs digest and mutant ids. Bench configs reference
  plugins relative to the config file so they survive the move.

Plan: docs/plans/2026-10-12-1800-ci-bench-off-pr-plan.md (layer 1; the
enterprise install is layer 2).

BREAKING CHANGE: bench-report.json schemaVersion 2.0 drops the
setup-timed-out and budget-exceeded abort codes and the timed-out run exit,
and adds ref, leg and the regression block.

* docs(bench): say an environment-incomplete abort writes no report

Review finding (correctness, P3): the README said every aborted leg uploads
its report, but main.ts publishes environment-incomplete without writing
bench-report.json or the step summary.

* ci(bench): drop the hand-written path filter from the main trigger

Root amendment 2026-10-12: no change classifier where Turbo can do the job.
paths-ignore (**/*.md, docs/**) skipped none of 11 sampled main commits;
turbo query affected skips three of them. Every push now benches every
leg until layer 1b scopes legs with turbo query affected.

Plan: docs/plans/2026-10-12-1854-ci-bench-off-pr-plan.md supersedes the
1800 plan and records the turbo-query evidence and rule-4 reasons.

* docs(plan): bench every main SHA; defer Turbo affected-only leg selection

Sub-conductor ruling 2026-10-12: the acceptance is a comparison per main
SHA, so Turbo's affected-packages answer stays evidence, not a leg filter.
The 1926 plan supersedes 1854: layer 1b is dropped, KD10 keeps Turbo as
the owner of builds and cache and adds no classifier, KD11 defers
affected-only selection to root.

* fix(bench): name phase-only slowdowns instead of claiming no phase changed

A summarized report with no regression said "no project/phase changed past
the signal threshold" even when its table showed a phase that regressed under
an unchanged total. The notice is now titled "Bench phase-only slowdown", says
no leg total crossed the regression threshold, and lists each such cell as a
phase-only slowdown. A new law drives a check-only slowdown through
summarizeBench and renderBenchReport.

The plan moves to docs/plans/2026-10-12-2032-ci-bench-off-pr-plan.md
(supersedes 1926) and adds the CONST-W3 declaration for the bench.yml edits,
the corrected cause of the stream-undecodable PR runs (#298 changes the
stream contract), and that case as a known limit.

* docs(plan): list every bench.yml edit in the CONST-W3 waiver and scope the PR-run evidence

Review of eec5cfa found that the waiver called side B the checkout (it is the bench-b worktree) and left out the checkout ref, Turbo cache and harness install edits. Run 38284821646 also had a budget-exceeded abort on one leg, so the Problem Frame now scopes stream-undecodable to the other three legs.

* test(e2e-core): move the bench decision checks beside their workflows as laws

bench-decisions.integration.test.ts fed hand-written NDJSON lines and
report text to the pure readBenchRun and coveringTestFiles workflows and
asserted their decision fields. Under root's 2026-10-12 testing law an
integration test drives the real product and asserts only observable
outcomes, and a pure decision gets property laws beside it (CONST-T12,
CONST-T14). This layer had to change the file (ReadBenchRunCommand's
exit field), so it is deleted rather than carried.

The behaviours no other suite pinned are now laws over the production
schemas: testsExecuted sums every drawn MutantCost, the first undecodable
line is named by its 1-based number among blank and decodable lines, a
verdict count other than one is stream-invalid, verdict phase durations
are carried as reported or refused when absent, covering test files are
the sorted distinct holders, and a non-JSON report is refused. The
legacy-line decode stays pinned by the cli-contract wire-line suite.

The plan moves to docs/plans/2026-10-12-2154-ci-bench-off-pr-plan.md
(supersedes 2032) with the widened edit scope and test admission rows.

* refactor(bench): keep the bench's new decisions in test/bench

Root's 2026-10-13 scope law puts no new code in test/e2e-core. The
layer's four new modules and their laws move to test/bench/src, which is
their only consumer: run-path.schema, plan-run-path and settle-run-path
(the fixed run path) and measure-leg (the leg wall time). e2e-core's
mod.ts is back to main.

measureLeg now decides a bench-local LegClock (LegElapsed or
LegClockBehind) that main.ts maps onto the report's LegTime:
make-body-purity forbids a decision from constructing a class imported
from another package, and e2e-core cannot import test/bench.

test/bench gains a test lane shaped like test/checker-parity: a test
script, a vitest config with inSourceSchemaLaws, tsconfig.test.json and
lint:tsgo over it. The plan moves to
docs/plans/2026-10-13-0223-ci-bench-off-pr-plan.md (supersedes 2154).

* fix(bench): abort an outside-tree run path as a defect

Review of f1da499 (MiniMax lenses, report-only) found two defects in
the bench's run path:

- planRunPath's identity law drew cwd and CLI tails of length one or
  more, so the enterprise shape (cwd equal to the tree) was never
  generated. Dropping the `path === tree` operand left the law green;
  with empty tails allowed it now fails.
- A cwd or CLI outside its side's tree aborted as run-path-move-failed,
  whose next action says to fix the runner. It is a bench bug, so it
  now aborts as defect. The README lists that case.

The plan moves to docs/plans/2026-10-13-0238-ci-bench-off-pr-plan.md
(supersedes 0223) with an accurate split of the e2e-core law files.

* fix(bench): name both causes in the run-path-move-failed action

63ba7bb sent an outside-tree run path to `defect`. Review of that head
found two problems: it contradicted KTD3, and `defect`'s next action asks
for a stack trace, which a typed failure does not have. So the outside-tree
case aborts as run-path-move-failed again. That code's next action, which
this layer added, now names both causes. An OS error needs a runner fix; a
path outside the tree is a bug in side setup. The README matches.

The plan moves to docs/plans/2026-10-13-0258-ci-bench-off-pr-plan.md
(supersedes 0238), with U4 brought back in line with KTD3.

* docs(plan): plan the pack-violation fixes for the bench lane

Supersedes the 0258 plan. Root (2026-10-13) requires every confirmed pack
violation in the a359a82 full-path receipt to be fixed, not waived.
Adds KD12-KD15 and units U7-U12, the e2e-core boundary (one in-source
refusal block per refined e2e-core file, LegTime as a data union), and
test-admission rows for the refusal properties and the settle law.

* fix(bench): carry run-path settle outcomes as tagged causes

Fixes full-path receipt findings #8 (four-channel-contracts) and #11
(tagged-unions-over-state-by-presence) on a359a82.

The settle command took restoreError and runFailure as nullable
Cause.pretty strings and decided on their presence. It now takes two
tagged unions, RunPathMoveBack (MovedBack | MoveBackFailed { cause }) and
RunPathRunEnd (RunSucceeded | RunFailed { cause }), and matches on the
move-back. RunPathUnrestored carries cause: S.optional(S.Unknown) and the
run's end instead of strings; the service passes each Exit's Cause as is.
Text is rendered once, in the error's message, when the report's reason
is built.

The settle law draws the production SettleRunPathCommand and asserts
unrestored iff the move-back failed, with both causes carried by
identity. Plan: docs/plans/2026-10-13-0416-ci-bench-off-pr-plan.md U7.

* fix(bench): make the report's leg time plain data

Fixes full-path receipt finding #9 (data-only-schema-classes) on a359a82.

LegMeasured and LegNotMeasured were TaggedClass variants carrying a
LegTime type id that no Workflow.make checks; LegTime is only a field of
BenchReport. LegTime is now an S.TaggedUnion of the same two cases, so
bench-report.schema.ts declares two fewer classes and no type id, and the
encoded shape is unchanged. main.ts and the render law construct through
LegTime.cases.

Evidence: tsc, oxlint and lint:tsgo clean in test/e2e-core and
test/bench; vitest 364/364 in test/e2e-core (the 3 fewer tests are the
generated codec laws of the two deleted classes; LegTime's own laws
remain) and 39/39 in test/bench. Plan:
docs/plans/2026-10-13-0416-ci-bench-off-pr-plan.md U8.

* fix(bench): refine the leg's elapsed time and the report's ref

Fixes full-path receipt finding #10 (invariants-as-refinements) on
a359a82, both fields named by root.

- LegElapsed.wallMs was S.Finite, so a negative elapsed time decoded as
  the "clock advanced" variant. It is now finite and >= 0, with the
  invariant stated in the check's message.
- BenchReport.ref was S.String while SummarizeBenchCommand.ref is
  S.NonEmptyString. It is now S.NonEmptyString, and main.ts reads
  BENCH_REF with Config.NonEmptyString, so an empty value takes the
  existing environment-incomplete path (main.ts program, UNKNOWN_REF)
  instead of failing at BenchReport.make.

Evidence: tsc, oxlint and lint:tsgo clean in test/e2e-core and
test/bench; vitest 364/364 and 39/39. The refusal properties for these
refinements land with #12. Plan:
docs/plans/2026-10-13-0416-ci-bench-off-pr-plan.md U9.

* fix(bench): state the refusal boundary of every refinement this PR adds

Fixes full-path receipt finding #12 (refusals-beside-generated-laws) on
a359a82.

The generated schema laws draw only accepted values. Each refined schema
the PR adds or changes now states its refusals in its own file, in one
import.meta.vitest block whose subject is a private predicate. Each
block draws the unrefined base, appends boundary values to every draw,
and compares acceptance with a predicate written from the domain:

- test/bench: run-path.schema.ts (all ten path fields), leg-clock.schema.ts
  (elapsed ms finite and >= 0), and the commands of plan-run-path,
  settle-run-path and measure-leg.
- test/e2e-core, the narrowest arrangement under root's scope law (plan
  KD13: no new file, export, dependency, config or helper):
  bench-report.schema.ts (LegTargetMs, ref, abort codes = the codes with a
  next action, rendered exit 0|1), bench-summary.schema.ts (RegressedMetric
  and PhaseRegression fields), bench-run.schema.ts (failure codes without
  timeout, exitCode a safe integer, never null) and summarize-bench (ref).

Evidence: tsc, oxlint and lint:tsgo clean in both packages; vitest
44/44 in test/bench (+5) and 375/375 in test/e2e-core (+11). The pack's
widen-once check is not run: root forbids hand-applied probes. Plan:
docs/plans/2026-10-13-0416-ci-bench-off-pr-plan.md U10.

* ci(bench): give each dispatch its own concurrency group

Fixes full-path receipt finding #15 (heavy-suites-judge-every-main-commit)
on a359a82.

The group was bench-${{ github.sha }} for both triggers, so a
workflow_dispatch on main's head shared, and could replace, the pending
push run for that commit. A push now groups by github.sha and a
dispatch by github.run_id, with cancel-in-progress false, as the rule's
Concurrency bullet requires. Why side A is HEAD^1 rather than the last
green commit is stated in the lane's cost header (#14). Plan:
docs/plans/2026-10-13-0416-ci-bench-off-pr-plan.md U11.

* ci(bench): state the lane's measured runner-minutes in its header

Fixes full-path receipt finding #14 (heavy-lanes-state-their-daily-minutes)
on a359a82.

bench.yml now opens with the cost header the rule asks for:
- the daily figure, about 267 runner-min/day (241-319), priced on the
  run every push to main starts;
- that run's shape, the full corpus against the first parent, and why
  side A is HEAD^1 rather than the last green commit (the corpus is a
  fixed workload; an older base selects no smaller workload and would
  credit several pushes' change to one commit), which also answers #15;
- the per-run cost, 15.3-20.3 job-min, with the seven run ids;
- the 7-day first-parent merge count, 110 in 2026-10-06T01:54Z to
  2026-10-13T01:54Z (15.71/day);
- whether the lane can starve PR CI, from the same week's job
  timestamps: only once about 40 jobs are already running; the replay
  without the pull_request Bench lowers hours at 40+ jobs from 11.98 to
  11.45.

Every figure is from GitHub Actions job timestamps already reported in
the PR's Bench cost section; nothing was rerun. Plan:
docs/plans/2026-10-13-0416-ci-bench-off-pr-plan.md U12.

* docs(plan): record the landed concurrency, settle law and header shape

Supersedes the 0416 plan. R3, U1 and the CONST-W3 list now carry the
push-sha/dispatch-run-id concurrency group from 7d962fc. U7's test
sentence matches the landed settle law (it draws the production command
and asserts the carried causes, not message text, whose oracle would
copy Cause.pretty). U12 records that the HEAD^1 rationale lives in the
cost header.

* docs(plan): say where the HEAD^1 rationale lives in U11

Supersedes the 0434 plan. U11 no longer claims an inline comment; the rationale is in the cost header (U12).

* docs(plan): plan the fixes for every confirmed row of the b30908c receipt

Supersedes the 0435 plan. Root's merge bar (2026-10-13) requires a fixing
commit on the reported head for every confirmed pack violation and accepted
finding. Adds KD16 (push runs scoped from the last green main commit, legs
selected by Turbo's affected set), KD17-KD19 and units U13-U18.

* fix(bench): build the report-unwritable fallback with the report builder

Fixes b30908c full-path receipt finding #5 (maintainability: the fallback
hand-rebuilt every BenchReport field). reportOf now takes report runs, and
writeReport's fallback calls it with the report's own fields.

Plan: docs/plans/2026-10-13-0554-ci-bench-off-pr-plan.md U13.

* fix(bench): walk the phase verdicts once in the report renderer

Fixes b30908c full-path receipt finding #4 (maintainability: two
near-identical verdict walks). One traversal yields each improved or slower
phase change; the improved and phase-only lists filter it. Only lines this
PR added change.

Plan: docs/plans/2026-10-13-0554-ci-bench-off-pr-plan.md U14.

* fix(bench): move the corpus schema into test/bench and state its refusals

Fixes b30908c full-path receipt finding #2 (testing: the corpus empty-repo
refusal lost its only test when bench-decisions.integration.test.ts was
deleted).

bench-corpus.schema.ts is consumed only by the bench, so it moves from
test/e2e-core/src to test/bench/src, unchanged, and e2e-core's mod.ts drops
its export. Beside it, one in-source refusal block states every refinement
the corpus carries: each text field refuses only the empty string, and each
list (repo, mutate, testFiles) refuses only the empty list. The repo list's
refusal is the empty-repo case the deleted test covered; the bench aborts
such a corpus as corpus-unreadable.

test/bench: 54 tests (44 + 2 refusal properties + 8 generated codec laws);
test/e2e-core: 367 (375 less the moved schema's 8 codec laws).

Plan: docs/plans/2026-10-13-0554-ci-bench-off-pr-plan.md U15 (amended in
the next plan revision: a move, not a workflow).

* fix(bench): decide every run-path abort in a lawed workflow

Fixes b30908c full-path receipt finding #1 (testing: the three run-path
abort codes were produced only in run-bench.service.ts, which no test
reaches).

Each way the run path can fail is now a tagged failure carrying its cause:
RunPathUnlisted, RunPathOutsideTree, RunPathOccupied, RunPathMoveInFailed
and RunPathUnrestored (run-path.schema.ts). abortRunPath
(abort-run-path.workflow.ts) decides the abort for each: occupied ->
run-path-occupied, unrestored -> run-path-unrestored, the rest ->
run-path-move-failed, with a reason that names the side and the paths.
run-bench.service.ts keeps only the listing, the two renames and the run,
and routes every failure through the decision.

The law draws RunPathFailure from its production schema and checks the code
and that the reason names the side and paths. In-source refusal properties
cover the new path fields and each abort variant's code. An in-process test
of the renames is refused by the gates (tests/ may import only a package's
public surface, and the bench has none), so the A/A dispatch remains the
evidence for the rename order. test/bench: 68 tests.

Plan: docs/plans/2026-10-13-0554-ci-bench-off-pr-plan.md U16 (amended in
the next plan revision: a lawed decision, not an integration test).

* refactor(bench): call side A the base, not the parent

Side A is no longer always the first parent: on a push it is the lane's
last green main commit. SummarizeBenchCommand.parentSha and
PhaseRegression.parentSha become baseSha, and the regression annotation,
the step-summary table header and the regression section say "base".
Every renamed line is one this PR added (origin/main has no parentSha or
"parent" wording in these files), so the e2e-core boundary holds.

Plan: docs/plans/2026-10-13-0554-ci-bench-off-pr-plan.md KD18.

* fix(bench): bench a push from the lane's last green main commit, affected legs only

Fixes b30908c full-path receipt finding #7 (ci-wall-clock
heavy-suites-judge-every-main-commit: bench.yml compared each push with
HEAD^1 instead of the lane's last green commit).

A plan job now picks side A and the legs before any bench job starts:

- selectLegs (select-legs.workflow.ts) is the decision. On a push, side A is
  the newest main commit whose push-triggered Bench run concluded success and
  which is an ancestor of the pushed commit. A leg runs when Turbo's affected
  set holds the package containing its entry or an engine directory every leg
  builds. The full corpus runs on a dispatch, when no green ancestor exists
  (side A is then the first parent), and when the lane's own inputs changed
  (test/bench, bench.yml, the released-tarballs action, the flake pin). A
  commit with no green ancestor and no first parent is refused as NoBenchBase.
- plan-legs.service.ts gathers the inputs: the lane's green push runs (gh api),
  ancestry and the diff (git), and `turbo query affected --packages`. It fails
  when the checkout has uncommitted changes, because Turbo adds them to the
  affected set (seen locally: a dirty test/bench marked every leg affected).
- plan.ts writes `base` and `legs` to GITHUB_OUTPUT and the choice to the step
  summary; bench.yml's plan job runs it with `actions: read`, the bench job
  uses its base for side A and BENCH_BASE_SHA, and it is skipped when no leg
  is selected.
- engine-dirs.ts holds the engine directories, now shared by prepare-side and
  the plan; command.service.ts holds the command runner, moved out of
  prepare-side so the plan reuses it.

Laws: select-legs covers dispatch, no green ancestor, lane inputs changed, and
affected-only selection (drawing a prefix-without-slash path; the law refutes a
`startsWith` without the `/` boundary). In-source refusal laws cover the new
schemas. Smoke: the real plan program on this checkout (push, dispatch) and,
with a gh shim naming the parent as green, on 3cb1342, ddeb67a and 61d40db.

Plan: docs/plans/2026-10-13-0554-ci-bench-off-pr-plan.md U17, KD16.

* docs(plan): record how U15-U17 landed

Supersedes the 0554 plan. U15 moves the corpus schema into test/bench with
its refusals instead of adding a resolveTarget workflow; U16 decides every
run-path abort in a lawed workflow, because the gates refuse an integration
test of a package with no public surface; U17 adds plan-legs.service.ts
(refusing a dirty checkout, since Turbo counts uncommitted changes) and moves
the command runner into command.service.ts. The edit scope records the one
file that leaves e2e-core, and the test admission table records the refused
and withdrawn tests.

* ci(bench): price the cost header for affected-only push runs

Keeps b30908c receipt row #14 (heavy-lanes-state-their-daily-minutes)
true after #7's selection changed the run shape.

The header now states the plan-job-then-selected-legs shape, each leg's
median job-minutes (range) over the seven dispatch runs, the plan job's
0.40-0.71 min (the same setup steps as timed in those runs, plus the plan
step timed locally), and the week's price: the real plan program run over
all 110 first-parent main commits of 2026-10-06T01:54Z..2026-10-13T01:54Z,
each with its parent as side A, picks no leg for 16, one for 2 and four for
92 (64 for changed lane inputs), so 1,655 job-min (1,333-1,948), about 236
runner-min/day (190-278). The PR CI replay stays as measured for the
full-corpus shape; the selection only removes jobs from it.

Plan: docs/plans/2026-10-13-0629-ci-bench-off-pr-plan.md U18.

* docs(plan): plan the fixes for the c0cf821b review's confirmed findings

Supersedes the 0629 plan. Fixes c0cf821b #4 (KTD2 named a
run-path.service.ts that never landed) and the plan half of #6 (R13 and the
summary still said first-parent side A).

KD20 records root's merge bar for this round: every confirmed finding of the
c0cf821b review of 5a088c6 gets a fixing commit, and the final receipt is a
new full-path review of the head that holds them. U19-U26 plan one fix per
confirmed finding group: the package description, an escaped plan-job
annotation, the plan job's wall time in its step summary, Turbo's wire decoded
into paths, driver modules off the reserved *.service.ts suffix, a lawed
green-ancestor choice, and the run's regression as a tagged union.

* docs(bench): describe the green-ancestor side A in the package description

Fixes c0cf821b #1, #3 and #6 (correctness, project-standards and
maintainability, all confirmed): test/bench/package.json still said each
push is benched against its first parent. It now states the rule the plan
job applies, the same one README.md and the bench.yml header state.

Plan: docs/plans/2026-10-13-0658-ci-bench-off-pr-plan.md U20.

* fix(bench): escape the plan job's ::error annotation

Fixes c0cf821b #5 (maintainability, confirmed): failWith printed
`::error title=<title>::<message>` raw, and a refusal's message can hold
multi-line command output (git status, gh's error body), which breaks the
single-line workflow command. The title is now escaped as a property (%,
CR, LF, colon, comma) and the message as data (%, CR, LF), the same
replacement set e2e-core's renderer uses; that helper is private and KD13
forbids a new e2e-core export, so plan.ts keeps its own copy.

Smoke: a plan run against a repository gh cannot find prints one
`::error title=Bench plan failed::...%0A...` line.

Plan: docs/plans/2026-10-13-0658-ci-bench-off-pr-plan.md U21.

* fix(bench): write the plan job's wall time to its step summary

Fixes c0cf821b #13 (ci-wall-clock one-stuck-job-backstop, confirmed): the
plan job had the 60-minute backstop but wrote no wall time to its step
summary, so a stuck plan job could not be told from a slow one.

The plan job's first step now records JOB_STARTED_AT, as the bench job's
does, and plan.ts appends the job's wall time in a finalizer, so the line is
written after a selection, a refusal and an incomplete environment alike.

Smoke: the plan program on a dispatch (exit 0), on an unknown repository
(exit 1) and with no GITHUB_SHA (exit 1) each appended
"**Plan job wall time:** N s through the plan step".

Plan: docs/plans/2026-10-13-0658-ci-bench-off-pr-plan.md U22.

* fix(bench): decode Turbo's affected-packages JSON into the paths the plan uses

Fixes c0cf821b #10 (schema-laws data-only-schema-classes) and #11
(schema-laws rich-type-over-foreign-encoded), both confirmed:
TurboAffectedPackage and TurboAffected were schema classes no contract
required, and plan-legs walked Turbo's data.affectedPackages.items envelope
itself.

The wire is now a private S.Struct, and TurboAffectedPaths decodes it with
S.decodeTo into ReadonlyArray<string>, so plan-legs receives the paths and
nothing of Turbo's envelope. The generated codec laws now cover
TurboAffectedPaths in place of the two classes.

Plan: docs/plans/2026-10-13-0658-ci-bench-off-pr-plan.md U23.

* refactor(bench): move the driver modules off the reserved *.service.ts suffix

Fixes c0cf821b #8 (cell-architecture service-and-layer-boundaries,
confirmed): command.service.ts and plan-legs.service.ts were driver-backed
modules (ChildProcessSpawner, gh, git, turbo) under the suffix the rule
reserves for a Context.Service contract, and test/bench declares no
Context.Service. They become command.ts and plan-legs.ts, names the rule
leaves free for a driver-backed module, and their three importers follow.
prepare-side.service.ts and run-bench.service.ts predate this PR and are
unchanged (the review ruled them pre-existing).

Plan: docs/plans/2026-10-13-0658-ci-bench-off-pr-plan.md U24.

* fix(bench): choose side A in a lawed decision, the newest green ancestor

Fixes c0cf821b #2 (testing, confirmed): the push-path side A choice lived
in plan-legs, which took the first SHA in the API's listing that git
called a strict ancestor. No test covered it, and the choice depended on
the API's list order.

chooseBase (new workflow) takes every green Bench push run on main
(GreenRun: SHA, creation time in epoch seconds, and whether it is an
ancestor of the pushed commit), the event and the first parent. On a
push it picks the newest run that is an ancestor and not the pushed
commit, else the first parent ('no-green-ancestor'); on a dispatch, the
first parent ('dispatch'); with no first parent, NoBenchBase. selectLegs
now takes that BaseChoice plus the diff against it and can no longer
fail. The base must be chosen before the diff is computed, so the choice
cannot live inside selectLegs as U25 first said; the plan amendment
follows.

plan-legs lists the runs through gh's --jq (created_at via
fromdateiso8601), decodes them with GreenRunsJson, asks git once per run
whether it is an ancestor, and computes the diff only for a GreenBase.

Laws: chooseBase's dispatch and push rules over drawn GreenRuns, with the
head drawn as a green run's SHA. Two mutants were refuted: taking the
first listed run, and not excluding the pushed commit. selectLegs' laws
are now keyed on the base they receive. Refusal laws cover GreenRun's
safe-integer seconds, FirstParentReason and both commands.

Plan: docs/plans/2026-10-13-0658-ci-bench-off-pr-plan.md U25.

* docs(plan): choose side A in its own decision before selecting legs

Supersedes the 0658 plan. U25 as written put the green-ancestor choice
inside selectLegs, but selectLegs reads the diff against side A, so the
base has to be chosen before that diff exists. U25 now names chooseBase
(new) as the decision; selectLegs takes its BaseChoice. The Test Admission
table splits the select-legs row, adds the choose-base law file with the
bugs it catches, and records the U25 smoke for plan-legs.

* fix(e2e-core): decide a run's regression as a tagged union, not NullOr

Fixes c0cf821b #12 (schema-laws tagged-unions-over-state-by-presence,
confirmed): BenchSummary.regression and the summarized report outcome were
S.NullOr(PhaseRegression), so "no leg regressed" was the absence of a
value, and the renderer probed it with Option.fromNullishOr.

Regression = S.TaggedUnion({ PhaseRegressed, NoLegRegressed }) replaces
PhaseRegression and its 'phase-regressed' code literal. summarizeBench
decides NoLegRegressed explicitly, and renderBenchReport matches the two
tags with Match.valueTags for both the annotation and the markdown block.
The annotation and block title stay 'phase-regressed', now a renderer
constant, so the rendered verdict line is unchanged. The refusal law on
the code literal becomes one on the discriminant: only the two tags
decode.

The law files lose their `=== null` / `!== null` checks, which still
compiled against the union but would have passed vacuously; each is now a
tag check. test/bench/README.md says the JSON report carries the tag.

origin/main has none of these lines, so every edited line is one this PR
added (KD13). report schema 2.0, bumped by this PR, covers the shape change.

Plan: docs/plans/2026-10-13-0711-ci-bench-off-pr-plan.md U26.

* docs(plan): plan the b2fe30db review's fixes and route two findings to root

Supersedes the 0711 plan. Fixes b2fe30db #2 (testing-confirmed: KD13 said
no e2e-core export was added, but the pack fixes added LegTargetMs,
LegTime, RegressedMetric, Regression and PhaseRegressed) and answers #1
(feb5085's subject is scoped e2e-core; rewriting it needs a force push,
and the squash merge carries only the PR title).

KD21 records the round. U27-U29 fix #12 (GreenRunsJson loses its
generated laws), #11 (corpus schemas as data classes) and #6 (two
stderr-tail helpers). #9 (pin Turbo's --packages output) and #3 (test
atRunPath's renames) ask for tests the admission gate refuses: a Turbo
pin spawns git and turbo, and atRunPath is a private executor helper. The
repository's real-Turbo pins live in scripts/ under PR #319, so both go
to root. The Turbo semantics the plan job relies on were measured on
2.10.12 in a scratch repository and are recorded in KD21.

* fix(bench): root the green-run listing codec at the schema vocabulary

Fixes b2fe30db #12 (schema-laws rich-type-over-foreign-encoded,
confirmed): GreenRunsJson was GreenRunListing.pipe(...), a chain rooted
at a module-local name, which effect-schema-discovery skips. The codec
the plan job decodes gh's output with therefore had no generated
round-trip or encode-stability law. It is now
S.Array(GreenRunListing).pipe(S.fromJsonString), and
∀x_GreenRunsJson_=x and ∀x_GreenRunsJsonEnc_=x run.

Plan: docs/plans/2026-10-13-0749-ci-bench-off-pr-plan.md U27.

* fix(bench): declare the bench corpus as plain data

Fixes b2fe30db #11 (schema-laws data-only-schema-classes, confirmed):
BenchRepoEntry, BenchEnterpriseCorpus and BenchCorpus were S.Class
declarations, though none is a Workflow.make command, a decision variant
or a tagged error; every consumer only reads their fields. They are now
S.Struct schemas with exported types. BenchCorpusJson keeps its S.*-rooted
chain, so its generated laws still run, and the refusal block is
unchanged.

Plan: docs/plans/2026-10-13-0749-ci-bench-off-pr-plan.md U28.

* refactor(bench): keep one stderr-tail helper for the bench's commands

Fixes b2fe30db #6 (maintainability, confirmed): command.ts kept a
4000-character tail over a CommandOutcome, and run-bench.service.ts a
second 4096-character tail over a string, both added by this PR.
command.ts now owns the one budget (4096, private) and exports
tailOf(text); runChecked, the interrupt message and runCli's stderr
collector all use it. A setup failure keeps 96 more characters of
output.

Smoke: a plan run whose gh exits 7 after 6000 bytes of stderr fails with
an annotation that carries the last 4096 characters.

Plan: docs/plans/2026-10-13-0749-ci-bench-off-pr-plan.md U29 (the plan
says both names are exported; only tailOf has an importer, so the budget
stays private).

* fix(bench): pin Turbo's affected-packages output in the repo scripts lane

Fixes b2fe30db #9 (P1, boundary-testing pin-dependency-semantics, E7):
the plan job's Turbo --packages output was pinned only by a measurement.

scripts/turbo-affected-packages.test.ts runs the plan job's own Turbo
arguments (turboAffectedArgs, now shared with plan-legs.ts) through
node_modules/.bin/turbo, the binary `pnpm exec turbo` resolves to, in
scratch pnpm workspaces, and decodes the output through TurboAffectedPaths,
the schema plan-legs.ts decodes with. It pins three facts: a changed package
reports its repo-relative directory (packages/a), a root file reports "",
and no change reports [].

TurboAffectedPaths moves to turbo-affected.schema.ts: the scripts lane
type-checks with Deno, which cannot resolve bench-plan.schema.ts's in-source
law block (@systemfsoftware/vitest, import.meta.vitest). Its generated codec
laws still run there.

scripts/deno.json takes #319's permission hunk byte for byte, plus an
effect/ import-map entry, and scripts/assert.ts is #319's file byte for
byte, so merging #319 later is a no-op for both. Deno applies the grant
only with -P, which ci.yml:137 does not pass yet; root's workflow owner
adds it in a separate PR. Until then these three tests fail in the Repo
scripts tests step with NotCapable, as #319's do.

Plan: docs/plans/2026-10-13-0954-ci-bench-off-pr-plan.md U31, KD22.

* ci(bench): give each Bench leg job a 20-minute timeout

Root's E10 ruling: per-SHA concurrency groups stay, because every main
SHA must be judged, and each leg job is capped at 20 minutes instead of
60. A leg's longest measured job is 7.68 job-minutes. The plan job keeps 60.

Plan: docs/plans/2026-10-13-0954-ci-bench-off-pr-plan.md U32, KD2.

* docs(plan): record root's resolution of E3-E11 and the 20-minute leg cap

Supersedes the 0749 plan. KD22 records root's rulings of 2026-10-13: #9
fixed by the scripts-lane Turbo pin (U31), inert until ci.yml passes -P in
a separate root-owned PR; E10's 20-minute leg cap (U32, KD2); E8 and E11
closed by A/A run 38375500452; #4 and E4 accepted; E9 satisfied by #335.

* docs(bench): state the 20-minute leg-job timeout in the package description and plan

Fixes the f13b992..aabe4c2 correctness lens finding #2 (P3, confirmed):
cf39161 lowered each Bench leg job's timeout to 20 minutes, but the
@systemfsoftware/stryker-bench description still said GitHub's 60-minute
job timeout is the only kill, and the 0954 plan's CONST-W3 caps bullet, R13
and the read-bench-run admission row still said 60 minutes. The plan job
keeps 60, so plan.ts's step-summary line is unchanged.

Plan: docs/plans/2026-10-13-0954-ci-bench-off-pr-plan.md U32, KD2.
…s-u3

# Conflicts:
#	test/e2e-core/tests/bench-decisions.integration.test.ts
The overwrite guard from #329 reads the run event record openings from
the top-level members of the RunEvent union. This branch made the
mutant line a nested union over status, so it contributed no opening,
and a whole file holding a torn mutant line was refused instead of
truncated. The openings now come from every object member, through
nested unions, without duplicates.
…he built CLI

agent-facts and wall-clock-timeout ran Engine.mutationTestCell in
process. Both now run the built stryker binary on a real project and
read only its stream records, exit code and JSON report. The remembered
reason code on reused verdicts moves from the in-process
incremental-reuse suite, which is back to main's text, into the
agent-facts feature.
…s-u3

# Conflicts:
#	packages/stryker-js-cli-contract/README.md
#	packages/stryker-js-cli-contract/etc/stryker-js-cli-contract.api.md
A settled mutant reaches the stream only when admitMutantReason admits
its status reason: a Pending mutant, a missing reason and a reason
outside its status's vocabulary are refused, and the run fails with
mutant-facts-invalid. The decision is a pure workflow with property
laws, so mutation grades it; the stream builder now matches only
streamable statuses. MutantFactsInvalid types its mutant id as the
branded Mutant.MutantId and keeps a refused facts schema issue as its
cause.
A survivor whose measured coverage names no test (a static mutant the
suite loads) read "N covering tests ran". Its reason now says no test
covers it alone and how many tests ran with it active. The agent-facts
feature runs the built CLI over a module-level constant the suite
loads and never asserts on.
…nt emits

The changeset tells report readers that statusReason in the JSON report
carries its code too. The --mutant help, the option description and
both READMEs name the status reason and the facts each status admits
instead of a covering and a killing test on every mutant-detail.
…s-u3

# Conflicts:
#	README.md
#	packages/stryker-js/README.md
The covered-not-killed description now says what to strengthen when the
mutant names no covering test, and the migration note names the
statuses whose mutant-detail carries coveredBy again.
… test

A survivor names no covering test when it is static, and also whenever
coverage was not measured per test. The covered-not-killed and
strengthen-tests descriptions now say both, and strengthen-tests is
selected whenever coverage analysis was on.
…age a run reports

The covered-not-killed and strengthen-tests descriptions now state the
condition the code tests (the run reported coverage for the mutant, and
that coverage may name no single test) instead of listing causes of an
empty covering-test list, each of which missed a case.
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