From fc8a3b3fa7542689323e34e92f1a0fae3242540f Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Fri, 9 Oct 2026 20:34:58 +0000 Subject: [PATCH 1/9] docs(brainstorms): requirements for the TypeScript 7 native checker unit Gap table against origin/main, measured updateSnapshot cost on the TS 7.0.2 unstable/async API, the edit-location soundness rule for the importer re-check shortcut, the CI verdict-parity design, the api-extractor TS5 ruling, and six open questions for the supervisor. --- ...erf-typescript-checker-tsgo-native-plan.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/brainstorms/2026-10-09-2030-perf-typescript-checker-tsgo-native-plan.md diff --git a/docs/brainstorms/2026-10-09-2030-perf-typescript-checker-tsgo-native-plan.md b/docs/brainstorms/2026-10-09-2030-perf-typescript-checker-tsgo-native-plan.md new file mode 100644 index 000000000..63b532c69 --- /dev/null +++ b/docs/brainstorms/2026-10-09-2030-perf-typescript-checker-tsgo-native-plan.md @@ -0,0 +1,194 @@ +--- +title: TypeScript checker on TypeScript 7 native - Plan +type: perf +date: 2026-10-09 +topic: typescript-checker-tsgo-native +artifact_contract: ce-unified-plan/v1 +product_contract_source: ce-brainstorm +execution: code +--- + +# TypeScript checker on TypeScript 7 native - Plan + +## Goal Capsule + +- **Objective:** A Stryker run with the TypeScript checker rejects non-compiling mutants with the same verdicts as main's checker, spends less time doing it, and nothing in a published package reaches a pre-7 TypeScript compiler. +- **Product authority:** the binding unit contract `.omp-brief/unit-h-contract.md` (Done 1-5, Does-not-count, Constraints) and the supervisor rulings S1 (targeted local verification only) and S2 (a TS7-capable `@microsoft/api-extractor` is pre-approved; no override that forces TS7 onto a tool that does not support it). `CONSTITUTION.md` governs where they conflict (`CONST-1`). +- **Open blockers:** OQ1-OQ6 under Outstanding Questions are Resolve Before Planning. Two contract items are unmeasurable or vacuous as written (Done 2 on this corpus, Done 4 from today's NDJSON), and the CI parity lane needs an edit to a read-only path. + +--- + +## Product Contract + +### Summary + +Keep the checker on the TypeScript 7.0.2 API it already uses, let one snapshot carry several mutants when they cannot see each other, skip importer re-checks for mutants whose edit cannot change any type outside a function body, and prove both changes with a CI gate that compares every corpus verdict against main's checker. +Remove the remaining TypeScript 5 claims, and remove the TypeScript 5 that `@microsoft/api-extractor` pulls into the lockfile through a TS7-capable release or an approved replacement for its gate. + +### Problem Frame + +The supervisor's premise holds for the runtime: the checker already drives TypeScript 7 through `typescript/unstable/async`, `unstable/ast` and `unstable/fs` (`packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts:22-34`). +What it does per mutant is the gap. +Every mutant gets its own snapshot update, and a mutant that compiles in its own file then re-checks every transitive importer, or the whole program when the file affects global scope (`ts-compiler.handle.ts:1435-1470`). +Measured on this repo, the snapshot update is cheap and the importer re-check is not (see Measured Cost below), so the expensive step is the one with no shortcut. +Grouping exists but only buckets mutants by file (`src/group-mutants.workflow.ts:44-53`); `check()` still applies them one at a time (`ts-compiler.handle.ts:1657-1665`). +Nothing in CI compares checker verdicts between two checker builds: the mutation lane runs only on main and nightly (`.github/workflows/mutation.yml:3-17`) and runs the released CLI (`mutation.yml:30`), so a pull request's checker change is never dogfooded before merge. + +### Gap Table + +| Done item | What origin/main already has | What is missing | Evidence source | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| 1. TS7 API, N mutants per program build, measured counts | TS7 API imports (`ts-compiler.handle.ts:22-34`); per-mutant `applyMutant` + `refreshSnapshot` (`:1484-1485`), one extra update at batch start (`:1656`) and one more on a parenthesized re-splice (`:855-861`, `:1486-1490`); file-bucket grouping (`group-mutants.workflow.ts:44-53`); TCE spawns the `tsc` binary once per file per `check()` call (`src/tce-emit.ts:46-50,103-109`) | No snapshot carries two mutants; no count of snapshot updates or `tsc` builds in any run output; no cited upstream API source in the repo | Code reads this session; `node_modules/typescript/dist/api/proto.d.ts:84-97`; probe below | +| 2. isolatedDeclarations shortcut, sound on the corpus | Importer re-check and the global-scope rule (`ts-compiler.handle.ts:1397-1401,1446-1470`); import graph (`traceAffectedFiles`) | No shortcut exists. No project in the corpus sets `isolatedDeclarations`: repo grep finds it only in the contract; `tsc --showConfig` gives `isolatedDeclarations: null` for all eight package `tsconfig.app.json`; the shared base says "`isolatedDeclarations` is intentionally **off** … Don't enable it" (`node_modules/@systemfsoftware/tsconfig/README.md:7`). A flag-gated shortcut never fires here, so a with/without comparison on this corpus proves nothing | Grep and `tsc --showConfig` this session | +| 3. Verdict parity with main on the corpus, enforced in CI | In-process runtime entry (`packages/stryker-js-typescript-checker/README.md:39-47`, `src/runtime/mod.ts`); annotation oracle on two checker fixtures in the PR e2e lane (`test/e2e/tests/typescript-checker.e2e.test.ts:25-34`, `test/e2e/tests/enterprise-composite-checker.e2e.test.ts:13`); manual compare tooling (`scripts/mutation-backstop.ts`, `packages/stryker-js/src/compare-verdicts.workflow.ts`) | No CI gate compares two checkers' verdicts; mutation lane is main-only and uses the released checker | `mutation.yml:3-17,30`; `flake.nix:37` | +| 4. Checker phase faster than main, from the CI run's NDJSON | `phaseDurations` on the verdict event with `prepare`, `instrument`, `dry-run`, `mutation-test` only (`packages/stryker-js/src/phase-durations.ts:42-51`, `packages/stryker-js-cli-contract/src/run-event.schema.ts:11,203-208`) | No checker phase; checker time is inside `mutation-test`. No PR lane runs the workspace checker over the corpus. "Stream G" exists nowhere in the repo | Same files; scout grep for Stream G / bench lanes | +| 5. No TS5 compiler API in the product; CI green with new tests | No `from 'typescript'` import in `packages/`, `test/` or `scripts/` (grep); catalog `typescript: ^7` (`pnpm-workspace.yaml:68`) resolves to 7.0.2 | README still claims "TypeScript 5.x through 7.x" and "TypeScript `>=5.0.0`" (`README.md:6,17`); `typescript@5.9.3` stays in the lockfile through `@microsoft/api-extractor@7.59.1`; the private `test/e2e-core` oracle runs ts-morph 28, whose `@ts-morph/common@0.29.0` bundles TypeScript 6.0.2 (`dist/typescript.js:2291-2292`) | Grep; lockfile; `node_modules` this session | + +### Supervisor Claim Check + +- "`typescript` catalog is `^7`, lockfile 7.0.2": confirmed (`pnpm-workspace.yaml:68`; installed `node_modules/typescript/package.json` reports 7.0.2). +- "The checker imports `typescript/unstable/async` (API, Snapshot, Program), `unstable/ast`, `unstable/fs` at `ts-compiler.handle.ts:22-34`": confirmed. It also imports `unstable/ast/is` (`:24`), and `src/ts-files.handle.ts` imports `unstable/fs`. +- "`Compiler.schema.ts:20` refuses <7.0.0": partly wrong. Line 20 is only the error message. The refusal is `minimumTypeScriptVersion` (`ts-compiler.handle.ts:210`) checked by `guardTypescriptVersion` (`:359-367`) from `init` (`:1261`). +- "The only `typescript@5.9.3` is pulled by `@microsoft/api-extractor@7.59.1`": confirmed for `typescript@` lock entries. Incomplete as a TS-version inventory: ts-morph bundles TypeScript 6.0.2 inside `@ts-morph/common`, used only by the private `test/e2e-core` oracle (`test/e2e-core/tests/__fixtures__/diagnostics.ts:3`, `test/e2e-core/tests/concurrency-checker.integration.test.ts:22`). +- "Each mutant (or group) does `mutateFile` + `updateSnapshot` (~860-920) and `getSemanticDiagnostics` per file": per mutant, not per group. `checkOne` runs for every mutant of a group (`:1657-1665`) and does its own update (`:1485`), plus a second on re-splice (`:861`). Per-file diagnostics: confirmed (`:1329`, `:1419`). +- "Program digests, verdict reuse and per-program grouping already exist": digests and reuse confirmed (`ts-compiler.handle.ts:333-357`; engine `packages/stryker-js/src/plan-request.cell.ts:214`, `run/mutant-settlement.ts:118-120`, `run/incremental-reuse.cell.ts:341`). Grouping is per file, not per program (`group-mutants.workflow.ts:32-53`). +- "The real gap is likely snapshot updates per mutant, the shortcut, a CI parity gate, and measured speed": the last three hold. Snapshot updates per mutant are not where the time goes (Measured Cost). Two gaps are missing from the list: the TCE `tsc` spawn is a full extra program build per file per `check()` call, and the corpus has no `isolatedDeclarations` project. + +### TypeScript 7 API Findings + +- **What ships.** `typescript@7.0.2` exports only `./lib/version.cjs` at `.`; the API lives under `./unstable/sync`, `./unstable/async`, `./unstable/fs`, `./unstable/proto` and `./unstable/ast*` (`node_modules/typescript/package.json` exports). The 7.0 announcement: "While TypeScript 7.0 is here, it does not ship with an API. We expect TypeScript 7.1 to ship with a new (and different) API" (https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/, 2026-07-08). The typescript-go README marks "API | not ready", and the repo is closed with development moved to microsoft/TypeScript (https://github.com/microsoft/typescript-go, HEAD 89d5d5b, 2026-08-20). The 7.1 API roadmap is https://github.com/microsoft/typescript-go/issues/4830. +- **IPC shape.** `new API()` spawns `tsc --api --async` and talks JSON-RPC over stdio, with `readFile` callbacks into the client's virtual file system (`node_modules/typescript/dist/api/options.d.ts:9-31`, `dist/api/fs.d.ts:5-19`; process args observed this session). Upstream `internal/ipc/conn_async.go` handles each request in its own goroutine, while snapshot updates are serialized (`internal/api/session.go` `updateMu`). +- **Several changed files per update: yes.** `updateSnapshot({ fileChanges: { changed: DocumentIdentifier[] } })` (`dist/api/proto.d.ts:84-97`). Upstream builds each snapshot by cloning the previous one and swapping changed files into the program (`internal/project/snapshot.go`, `internal/compiler/program.go` `ReuseProgram`). +- **No checker cache across snapshots.** Each new program gets a fresh checker pool (`internal/compiler/checkerpool.go`: "A partition is a checker with its own symbol, type, and instantiation caches"). The probe agrees: re-checking all 141 files after a one-file change costs about as much as the cold whole-program check. +- **No emit in 7.0.2.** `Emitter` has only `printNode` (`dist/api/async/api.d.ts:334-338`), so TCE has to spawn `tsc`. The `next` nightly `typescript@7.1.0-dev.20261009.1` adds `getJavaScriptEmit(files)`, `getDeclarationEmit(files)`, `runWithTemporaryFileUpdate` and multi-file `getSemanticDiagnostics`, and renames every export from `./unstable/*` to `./async`, `./sync`, `./ast` and so on. +- **`isolatedDeclarations` in tsgo:** the option and its TS9005-9013 diagnostics exist (`internal/core/compileroptions.go:47`, `internal/transformers/declarations/transform.go:106`). The December 2025 progress post lists "isolatedDeclarations errors" among known-incomplete work (https://devblogs.microsoft.com/typescript/progress-on-typescript-7-december-2025/). + +### N Mutants Per Program Build + +- **Schemata switch in one snapshot: no.** The API would accept it, since it is only file text, but the verdicts would differ from per-mutant checking. Every mutant branch type-checks next to the original, so union types and control-flow narrowing change: a removed `return` is masked by the original branch, and a union of both branches can fail where neither mutant alone does. The repo already treats schemata as non-type-checkable and stamps instrumented code `// @ts-nocheck` (`packages/stryker-js-instrumenter/src/InstrumentHeader.ts:33`, `src/TypeCheckDisablers.ts:22-29`). Done 3 parity rules this out. +- **Overlay batching: yes, under a disjointness rule.** One update can carry mutants from different files when no mutant can change a file whose diagnostics decide another mutant in the batch. Each mutant's verdict then equals its solo verdict. Without the shortcut, a mutant can change its importers, so batches stay small; the last measurement found 1.5% fewer runs and the idea was dropped (`docs/plans/2026-09-29-0427-feat-state-of-the-art-mutation-testing-plan.md:932`). With the shortcut, a shortcut mutant changes only its own file, so many shortcut mutants from different files can share one update. +- **TCE builds.** Today every `check()` call spawns one `tsc` per file that has a passing mutant (`ts-compiler.handle.ts:1602-1622`). That is a whole extra program build in a child process, and it moves with batching. +- **Count today, per run.** Snapshot updates are M + C + P: one per checked mutant (M), one per `check()` call (C) and one per parenthesized re-splice (P). Separate `tsc` program builds equal the number of (`check()` call, file with at least one passing mutant) pairs. Since groups are per file, that is about one `tsc` build per group, and every update builds a new program with a fresh checker. + +### Measured Cost + +Probe: a throwaway script drove the installed `typescript/unstable/async` API (`collectTiming: true`) on `packages/stryker-js-typescript-checker/tsconfig.app.json` (141 project source files, 0 dry-run diagnostics). It flipped a body edit in `src/group-mutants.workflow.ts` through the `fs.readFile` callback, the same path the checker uses. Host: this workstation under memory pressure, with about 4-6 GB available. + +| Step | Measured | Samples | +| ---------------------------------------------- | ---------------------------------------------------------- | ------- | +| First `updateSnapshot` (open project) | 449-1296 ms | 4 runs | +| Whole-program semantic diagnostics, cold | 13.2-13.9 s; one 106.8 s outlier under swap | 4 runs | +| `updateSnapshot` with one changed file | 2.4-6.4 ms | 6 | +| Own-file semantic diagnostics after the update | 13-21.7 ms (0.3-0.6 ms when repeated in the same snapshot) | 6 | +| Every project file re-checked after one change | 12.3-12.5 s | 2 | +| Server processing / round trip, 149 requests | 26.86 s / 27.19 s (transport about 2.2 ms per request) | 1 | + +A snapshot update costs a few milliseconds. The type-check that follows it is the expense, and nothing carries over between snapshots. For a body mutant that compiles in its own file, today's cost is roughly a fresh check of every transitive importer, which goes up to whole-program cost. The shortcut removes that step. Batching saves the update itself plus the per-snapshot warm-up of library types, which shows as the 13-22 ms first own-file check against 0.3-0.6 ms warm. During repeated full re-checks the API server's connection dropped twice. [INFERENCE] The cause was host memory pressure; it was not diagnosed further under S1. + +### Soundness Rule for the Shortcut + +A mutant cannot change any type that another file sees when all of these hold: + +1. Its replaced span lies inside the body of one function-like node F: the block of a function declaration, method, constructor, accessor or function expression, or an arrow function's block or expression body. Parameters, type parameters, initializers outside F, decorators and the return type annotation are outside the body. +2. F's type as seen from outside does not depend on the body: F has an explicit return type annotation, or F is a constructor or set accessor. Without an annotation the body sets the inferred return type, including inferred type predicates, and any exported declaration typed from F changes. +3. The file is a TypeScript module (`.ts`, `.tsx`, `.mts`, `.cts`) and does not affect global scope. In JS files, constructor `this.x =` assignments and function bodies declare members. Script and `declare global` files already go to the whole-program path (`ts-compiler.handle.ts:1397-1401`). +4. The span touches no `import()` or `require()` module specifier. Changing one changes which files the program loads, which can add or remove global declarations seen elsewhere. + +Under `isolatedDeclarations`, a program that passes the dry run already annotates every function whose type reaches a declaration, so rule 2 holds for every visible function. Rules 1, 3 and 4 still apply. Checking rule 2 per site is the safer gate: tsgo's own `isolatedDeclarations` errors are known-incomplete, so a project can pass the dry run while still inferring an exported type from a body. +The rule never compares signatures. KTD20 rejected hand-built signature comparison after four false-pass holes (`docs/plans/2026-09-29-0427-feat-state-of-the-art-mutation-testing-plan.md:268,928`). This rule only reads where the edit sits. +Soundness on the corpus is proven by running every corpus mutant with the shortcut forced on and forced off and requiring identical verdicts (R8). The run must also report how many mutants the shortcut decided, and a zero count fails, because an empty set proves nothing (`CONSTITUTION.md` CONST-T3). + +### api-extractor and TypeScript 5 Ruling (S2) + +- **Not in the product.** `typescript@5.9.3` is reachable only through `@microsoft/api-extractor@7.59.1`, a devDependency of the 17 packages that carry an `api-extractor.json`. No published package lists it in `dependencies`, and nothing in a published package imports `typescript` outside `unstable/*`. +- **It is in the merge gate.** The root `build` runs `turbo build api:check` (`package.json:24`), and `typecheck`, `test`, `lint` and `attw` depend on `api:check` (`turbo.json:37-39,69-71,107-109,127-129`). +- **No TS7-capable api-extractor exists today.** The latest release, 7.59.4 (2026-10-06), pins `typescript: 5.9.3` (`npm view`), and its `CHANGELOG.md:116` says "Upgrade the bundled compiler engine to TypeScript 5.9.3". The `8.0.0` on npm is a 2019 artifact that depends on `typescript ~3.1.6`. api-extractor needs the classic in-process compiler API, and `typescript@7` does not export one (`.` is `lib/version.cjs`). The S2 pre-approval has no release to apply to yet. +- **What removal takes.** Either a future rushstack release that runs on the TypeScript 7.1 API (pre-approved), or retiring `api-extractor` and replacing the `api:check` report gate in all 17 packages. The d.ts rollup is already native: tsdown 0.23 emits `dist/index.d.mts` through `rolldown-plugin-dts@0.28.5` with peer `typescript ^5 || ^6 || ~7.0.0` on 7.0.2, and api-extractor's own rollup only writes to `temp/` (`api-extractor.json:18-21`). Replacing the gate is a supervisor decision (OQ5). A pnpm override that forces TS7 onto api-extractor is ruled out. + +### Key Decisions + +- **Stay on `typescript@7.0.2` `unstable/async`; do not adopt the 7.1 nightly.** The nightly has emit APIs that could replace the TCE spawn, but it renames every import path and is a dev build. Governs R1. +- **No schemata switch for type-checking.** Combined branches change verdicts, and the repo already stamps instrumented code `// @ts-nocheck`. Governs R2. +- **The shortcut is decided by an edit-location rule, not by comparing signatures.** KTD20's objection was false passes from incomplete signatures. A location rule has nothing to miss, and its residual risk is the parity proof's job. Governs R4, R5. +- **Parity compares two TypeScript 7 checkers on the same compiler build.** Both sides run TS 7.0.2, so no TS5 or TS6 path is needed, and verdict text can be compared exactly. Governs R7, R8. +- **TS5 removal follows S2 strictly** (session-settled: user-directed — chosen over a pnpm override that forces TS7 onto api-extractor: the supervisor ruled overrides out). Governs R12. +- **Local verification stays targeted** (session-settled: user-directed — chosen over full local e2e and corpus runs: host memory is critically tight). Governs R9. + +### Requirements + +**Compiler boundary** + +- R1. The checker reaches TypeScript only through the `typescript@7` `unstable/*` exports and the TS7 `tsc` binary. No published package can load a pre-7 compiler. +- R2. One snapshot update may carry several mutants only when they sit in different files and none can change a file whose diagnostics decide another. Each batched mutant gets exactly the verdict it would get alone. +- R3. Every run reports the number of snapshot updates and of separate `tsc` program builds, with their per-mutant ratio, in output CI keeps. Main's checker is measured the same way, so before and after compare on one corpus. + +**Shortcut** + +- R4. A mutant that meets the Soundness Rule is decided by its own file's diagnostics, and its file's importers are not re-checked. +- R5. A mutant that fails any clause of the Soundness Rule keeps today's path: own file, then transitive importers, or the whole program for global-scope files. + +**Parity and proof** + +- R6. The corpus is the mutants the instrumenter produces for this repo's packages and the e2e fixtures, selected by a rule, not by hand. OQ6 settles the exact file set. +- R7. For every corpus mutant, the new checker's verdict (status and reason text) equals main's checker's verdict. +- R8. For every corpus mutant, the verdict with the shortcut on equals the verdict with it off. The gate reports how many mutants the shortcut decided and fails when that count is zero. +- R9. R7 and R8 run in CI on every pull request and fail on any difference. No local corpus or mutation run is needed to land the change. + +**Speed** + +- R10. On the same corpus and the same CI host, the new checker's checking time is lower than main's, taken from the CI run's own machine-readable timings. + +**TypeScript 5 removal** + +- R11. Product docs state TypeScript 7 only: README prerequisites and description (`README.md:6,17`) and anything else that claims TS5 support. +- R12. `typescript@5.9.3` leaves `pnpm-lock.yaml` only through a TS7-capable `@microsoft/api-extractor` release or a supervisor-approved replacement of the `api:check` gate. + +### Acceptance Examples + +- AE1. **Covers R4.** Given `function f(x: number): number { return x + 1 }` in a `.ts` module, when the mutant replaces `x + 1` with `x - 1`, then only the file's own diagnostics are requested. +- AE2. **Covers R5.** Given `export const f = (x: number) => { return x + 1 }` with no return annotation, when the body mutant turns `return x + 1` into `return String(x)`, then importers are re-checked, because the inferred return type changed. +- AE3. **Covers R5.** Given a parameter default `function f(x = 1): void {}`, when the mutant changes `1` to `0`, the edit is outside the body and importers are re-checked. +- AE4. **Covers R5.** Given a body that contains `await import('./a.js')`, when the mutant replaces the specifier with `""`, the program's file set can change and importers are re-checked. +- AE5. **Covers R2.** Given shortcut mutants A in `a.ts` and B in `b.ts`, where `b.ts` imports `a.ts`, they can share one update. Given a non-shortcut mutant C in `a.ts` and mutant D in `b.ts`, they cannot, because C can change `b.ts`'s diagnostics. +- AE6. **Covers R8.** A corpus where no mutant meets the Soundness Rule fails the shortcut gate with a zero-count refusal. It does not pass on vacuous equality. + +### Scope Boundaries + +- No TS5 or TS6 fallback path, no `typescript-5` alias, and no parity proven on hand-picked files. These are the contract's Does-not-count items. +- No adoption of the `typescript@7.1.0-dev` nightly API in this unit. +- No signature or declaration-emit comparison to decide importer re-checks. +- No local mutation, e2e, microVM or full-workspace runs (S1). Local proof is typecheck plus the checker package's own tests. +- The `test/e2e-core` ts-morph oracle (bundled TypeScript 6.0.2) is a private test oracle, not product. Whether it stays is OQ5. + +### Dependencies / Assumptions + +- Stream A is moving this package into its split, and Stream D lists checker oversize files. Files this unit expects to rewrite are `src/ts-compiler.handle.ts` (1817 lines), `src/group-mutants.workflow.ts`, `src/tce-emit.ts`, `src/CheckerCommands.schema.ts`, `README.md`, plus a new pure workflow for the Soundness Rule and its property test under `src/__tests__/`. Planning names the final list for the supervisor to pass to D. +- New decision code follows `cell-architecture` pure workflows: the Soundness Rule is a `Workflow.make` decision with cyclomatic complexity 1 (pack: cell-architecture, pure-decision-workflows.md). The tests pin TS7 `unstable` API behavior the checker depends on, including multi-file `fileChanges` and that no checker cache survives a snapshot, with differential tests that re-fire on upgrade (pack: boundary-testing, pin-dependency-semantics.md). +- The released checker in `.sfs-deps` (8.3.1, flake pin `27b1075`) lags `origin/main` by three checker commits, all on digest and `extends` resolution (`git log 27b1075..origin/main -- packages/stryker-js-typescript-checker/src`). +- `CHANGELOG.md:65` (8.1.0) says importers are re-checked "only when the mutant changes what that file exports". The code re-checks them whenever own-file errors are zero (`ts-compiler.handle.ts:1459-1470`). Once R4 ships the claim becomes partly true, and the changeset should state the actual rule. + +### Outstanding Questions + +#### Open questions for the supervisor (Resolve Before Planning) + +- OQ1. **Done 2 is vacuous on this corpus.** No corpus project sets `isolatedDeclarations`, and the shared tsconfig forbids it. Pick one: (a) gate the shortcut on the per-site Soundness Rule alone, which fires on this corpus and keeps `isolatedDeclarations` as one case where rule 2 always holds (recommended); (b) keep the flag gate and add an `isolatedDeclarations` e2e fixture so the proof has a non-empty set; (c) both. +- OQ2. **"Main's checker" for parity.** Pick one: (a) build the checker from the PR's merge-base with `origin/main` inside the parity lane (recommended: faithful to "main", with no release lag); (b) the released 8.3.1 tarball already installed from `.sfs-deps`, which costs no build but lags main by three commits. +- OQ3. **Where the parity and speed gate runs.** `.github/workflows/` is read-only (root `AGENTS.md` Boundaries). Pick one: (a) approve a new `checker-parity` job in `ci.yml`, sharded like the e2e lane (recommended); (b) run it inside the existing `check` job as a test of the checker package, which needs no workflow edit but loads `pnpm check:ci` and runs locally whenever that package's tests run (conflicts with S1). Also confirm that instrument-and-check without a test runner is not a "mutation run" under the no-local-mutation constraint. +- OQ4. **Done 4 timings.** NDJSON has no checker phase. Pick one: (a) the parity lane times both checkers on the identical mutant set and writes its own machine-readable timings (recommended: same host, same mutants, no stream-schema change); (b) add a checker bucket to `phaseDurations`, a `StreamSchemaVersion` bump under KTD18, which only helps after a release because the mutation lane runs the released CLI. Also decide whether OQ4(a) satisfies "from the CI run's own NDJSON phase timings". +- OQ5. **TS5 and TS6 outside the product.** Pick one: (a) rule `typescript@5.9.3` under api-extractor and ts-morph's bundled 6.0.2 as dev tooling, outside Done 5, and track the api-extractor bump for when rushstack ships TS7 support (recommended); (b) retire api-extractor now, which means approving a replacement `api:check` gate for 17 packages (GATE1), and replace the ts-morph oracle in `test/e2e-core` with a TS7 one. +- OQ6. **Corpus file set.** The contract says "this repo's packages + the e2e fixtures". Pick one: (a) every workspace package's non-test TypeScript sources under its app tsconfig, plus every e2e fixture configuration that enables the checker (recommended: broadest set that is still rule-selected, sharded in CI); (b) only the four dogfood projects' `mutate` globs (`mutation.yml:29`) plus checker-enabled e2e fixtures (cheaper, but the checker package itself mutates only `*.workflow.ts` and `*.schema.ts`). + +#### Deferred to Planning + +- How a batch's mutants are attributed and re-spliced when one fails to parse (`parseHeldAfterSpliceOf`, `ts-compiler.handle.ts:841-853`) without breaking R2. +- Whether TCE emits for every file of a batch in one `tsc` spawn instead of one per file. +- The dogfood mutant ids the new tests target, cited from main's latest mutation report as the contract requires. +- Corpus sharding and the wall-time budget for the parity lane in CI. + +### Sources + +- `.omp-brief/unit-h-contract.md`; `CONSTITUTION.md` (CONST-T3, CONST-P1/P2); root `AGENTS.md` Boundaries; `packages/AGENTS.md` SCHEMA-1. +- `node_modules/typescript` 7.0.2: `package.json` exports; `dist/api/async/api.d.ts:23-206,334-338`; `dist/api/proto.d.ts:51-97`; `dist/api/options.d.ts:9-31`; `dist/api/fs.d.ts:5-19`. +- Upstream: https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/ ; https://devblogs.microsoft.com/typescript/progress-on-typescript-7-december-2025/ ; https://github.com/microsoft/typescript-go (HEAD 89d5d5b) `internal/api/proto.go`, `internal/api/session.go`, `internal/compiler/program.go`, `internal/compiler/checkerpool.go`, `internal/ipc/conn_async.go` ; https://github.com/microsoft/typescript-go/issues/4830 ; https://github.com/nrwl/nx/issues/36104. +- api-extractor: `npm view @microsoft/api-extractor` (7.59.4, 2026-10-06, `typescript: 5.9.3`); its `CHANGELOG.md:116`. +- Prior decisions: `docs/plans/2026-09-29-0427-feat-state-of-the-art-mutation-testing-plan.md:268,928-932`. From 745275b4b7cab5dd33ae0545973bf2133c6f5efd Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Fri, 9 Oct 2026 21:42:15 +0000 Subject: [PATCH 2/9] docs(plans): implementation plan for the TypeScript 7 native checker unit Twelve stacked units: per-produce diagnostic rendering, snapshot/emit counters, the edit-location Soundness Rule shortcut, file-bounded rounds, one emit build per call, a sharded checker-parity CI lane against the merge-base checker, and the B10 type-query port for Stream I. Report-only ce-doc-review findings with proposed dispositions appended. --- ...erf-typescript-checker-tsgo-native-plan.md | 632 ++++++++++++++++++ 1 file changed, 632 insertions(+) create mode 100644 docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md diff --git a/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md b/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md new file mode 100644 index 000000000..b70726171 --- /dev/null +++ b/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md @@ -0,0 +1,632 @@ +--- +title: TypeScript checker on TypeScript 7 native - Plan +type: perf +date: 2026-10-09 +topic: typescript-checker-tsgo-native +artifact_contract: ce-unified-plan/v1 +product_contract_source: ce-brainstorm +execution: code +origin: docs/brainstorms/2026-10-09-2030-perf-typescript-checker-tsgo-native-plan.md +--- + +# TypeScript checker on TypeScript 7 native - Plan + +## Goal Capsule + +- **Objective:** A Stryker run with the TypeScript checker rejects non-compiling mutants with the same verdicts as main's checker, spends less checker time doing it, and nothing in a published package reaches a pre-7 TypeScript compiler. The tsgo-backed checker also answers advisory type queries the instrumenter can ask before it generates a mutant (B10). +- **Means:** check several mutants in one TypeScript 7 snapshot when no member can change another member's verdict, skip importer re-checks for edits that an edit-location rule proves cannot change any type outside a function body, and prove both in a CI lane that runs main's checker and the branch's checker over the same rule-selected corpus (KTD1, KTD2, KTD5). Answer type queries from one probe snapshot per request on a tsgo server separate from the verdict path, and catch every wrong "not assignable" answer in the same lane (KTD10-KTD12). +- **Authority:** the unit contract `.omp-brief/unit-h-contract.md` (Done 1-5, Does-not-count, Constraints), then the supervisor rulings `.omp-brief/rulings-brainstorm.md` (B1-B9), B10 (type-query surface for Stream I, delivered by the root during planning) and S1/S2 (targeted local verification; a TS7-capable api-extractor is pre-approved and no override forces TS7 onto it), then this plan. `CONSTITUTION.md` governs any conflict (`CONST-1`). Origin requirements: `docs/brainstorms/2026-10-09-2030-perf-typescript-checker-tsgo-native-plan.md`. +- **Stop conditions:** stop and report if the TypeScript 7.0.2 API cannot do something a unit needs (name the missing API with evidence; never fall back to TS5); if the parity lane shows any verdict difference that the position rule in R7 does not explain; if a corpus leg cannot finish inside its time budget at eight shards. +- **Execution profile:** stacked PRs on trunk `main` via `gh stack` (root `AGENTS.md` OP13b), one concern per layer, no force-push. Locally only typecheck and the touched package's tests (S1); the corpus, e2e and mutation runs happen in CI only. +- **Who ships:** the implementer opens and updates the stack and reports each layer's green head SHA; the operator merges bottom-up. +- **Open supervisor items:** OQ-P1 to OQ-P10 under Outstanding Questions. None blocks starting U1-U4; OQ-P1 must be settled before U10 turns the parity gate on; OQ-P8 (where the port's entry lives) must be settled with Stream A before U11 lands, and the "Type-query surface for Stream I" shape stays provisional until Stream I confirms it. + +--- + +## Product Contract + +### Summary + +Keep the checker on the `typescript@7.0.2` `unstable/*` API it already uses. Let one snapshot carry several mutants from different files when the edit-location rule proves none of them can change another's verdict, skip importer re-checks for those mutants, and run one TypeScript emit build per check call instead of one per file. A CI lane compares every corpus verdict and the checker time against a checker built from the pull request's merge-base with main. + +### Problem Frame + +The origin brainstorm established the gap (its Gap Table and Measured Cost): every mutant gets its own snapshot update, a mutant that compiles in its own file re-checks every transitive importer, and nothing in CI compares two checkers. Planning research added one defect that batching depends on: the checker renders a diagnostic's `line,column` after the whole check call finishes, against whichever mutant was applied last, not against the mutant that produced it (`packages/stryker-js-typescript-checker/src/Checker.cell.ts:53-55` calls `describeDiagnostics` after `check` returns; `src/ts-compiler.handle.ts:1721-1724` resolves positions in the current snapshot). On main a mutant's reason can name the wrong line whenever a later mutant in the same file changed the line count above it. + +### Requirements + +**Compiler boundary** + +- R1. The checker reaches TypeScript only through the `typescript@7` `unstable/*` exports and the TS7 `tsc` binary. No published package can load a pre-7 compiler. +- R2. One snapshot update carries several mutants only when each sits in a different file and each meets the Soundness Rule on both trees (R4). Every batched mutant gets exactly the verdict it gets when checked alone. +- R3. Each check call reports its snapshot updates, parenthesized re-splices, TypeScript emit builds, and shortcut decisions in telemetry CI keeps. The parity lane writes them per side, with per-mutant ratios. + +**Shortcut** + +- R4. A mutant that meets the Soundness Rule on both the original tree and the mutated, re-parsed tree is decided by its own file's diagnostics, and its file's importers are not re-checked. +- R5. A mutant that fails any clause on either tree keeps today's path: own file, then transitive importers, or the whole program for global-scope files. + +**Parity and proof** + +- R6. The corpus is every source file of each corpus project's program, minus declaration files and `node_modules`. Corpus projects are every workspace `tsconfig.app.json`, every tsconfig a checker-enabled e2e fixture configuration names (an unresolvable one is skipped and listed), and one `isolatedDeclarations` fixture. Mutants are whatever the branch's instrumenter produces for those files with the default mutator set. +- R7. For every corpus mutant, the branch checker's status equals main's, and its reason text equals main's except the `(line,col)` of diagnostics located in the mutant's own file (see OQ-P1). +- R8. With the shortcut on, every corpus verdict equals the verdict of a checker that always re-checks importers. The lane reports how many mutants the shortcut decided, overall and for the `isolatedDeclarations` fixture on its own, and fails when either count is zero. +- R9. R7 and R8 run in CI on every push to main and on every pull request that touches the checker, the instrumenter, the corpus fixtures, the parity driver, or the lane definition. Any difference fails the lane. No local corpus run is needed to land a change. + +**Speed** + +- R10. Summed over all corpus shards, the branch checker's checker phase is shorter than main's, both timed by the lane on the same runner and mutants and written as NDJSON. +- R13. A check call spawns at most one `tsc` emit build, covering every file with a passing mutant in that call, when the lane shows emit builds are material (KTD7). + +**Diagnostics** + +- R14. A diagnostic's `line,column` is computed from the text of the snapshot that produced it. + +**Type queries (B10)** + +- R15. The checker package ships, through a declared entry, a tsgo-backed implementation of a type-query port. The instrumenter reaches it only through the port, never through checker internals. The request and response carry an explicit schema version. +- R16. For each requested site, batched per file, the query returns the site's type and its contextual type as text, and for each candidate replacement exactly one answer: `Assignable`, `NotAssignable` or `Unknown` with a reason. +- R17. The query is advisory. No checker verdict reads a query answer, and a query failure (including a tsgo server crash) never fails or delays a check call; it turns that file's answers into a refusal. +- R18. Every `NotAssignable` answer the query gives for a corpus mutant is compared with the branch checker's verdict for the same mutant in the parity lane. A `NotAssignable` mutant whose verdict is not CompileError fails the lane and is written as a `wrong-not-assignable` line naming the site, candidate, both types and the answer's path. The lane also writes, per reason, how many answers it gave and how many CompileError mutants were answered `NotAssignable`. + +**TypeScript 5 removal** + +- R11. Product docs state TypeScript 7 only (`packages/stryker-js-typescript-checker/README.md:6,17`), and the 8.1.0 changelog claim about importer re-checks is corrected in the next release's notes. +- R12. `typescript@5.9.3` stays in the lockfile only as the dev dependency of `@microsoft/api-extractor`, which no published package reaches at runtime. It leaves when rushstack ships a TS7-capable release (pre-approved bump, tracked in Planning Contract). No pnpm override. + +### Soundness Rule + +A mutant may skip importer re-checks when all of these hold on the original tree and again on the mutated tree after its snapshot update: + +1. The replaced span lies inside the body of one function-like node F: the block of a function declaration, method, constructor, accessor or function expression, or an arrow function's block or expression body. +2. F's type seen from outside does not depend on its body: F has an explicit return type annotation, or F is a constructor or a set accessor. A contextually typed function without its own annotation and an unannotated get accessor fail this clause. +3. The file is a TypeScript module (`.ts`, `.tsx`, `.mts`, `.cts`) with no `declare global`. +4. The span contains no `import()` call, `require()` call or import type node. + +On the mutated tree, F is the function-like node of the same kind starting at the same offset; its header text (decorators, modifiers, name, type parameters, parameters, return annotation: everything from F's start to its body's start) must equal the original's byte for byte, and the shifted span (start, start + replacement length) must lie inside its body. Any mismatch fails the rule. + +Under `isolatedDeclarations`, clause 2 holds for every function whose type reaches a declaration, so the rule fires more often there. The rule never compares signatures or declaration emit (origin Key Decisions; `docs/plans/2026-09-29-0427-feat-state-of-the-art-mutation-testing-plan.md:268,928`). + +### Acceptance Examples + +- AE1. **Covers R4.** `function f(x: number): number { return x + 1 }` in a `.ts` module; the mutant replaces `x + 1` with `x - 1`; the check call reports one shortcut decision and no importer diagnostics are requested. +- AE2. **Covers R5.** `export const f = (x: number) => { return x + 1 }` with no annotation and an importer doing `const n: number = f(1)`; the mutant turns `return x + 1` into `return String(x)`; the verdict is CompileError naming the importer. +- AE3. **Covers R5.** `export function f(x = 1): void {}` and an importer calling `f(2)`; the mutant changes `1` to `""`; the edit is outside the body, and the verdict is CompileError naming the importer. +- AE4. **Covers R5.** A body containing `await import('./a.js')`; the mutant empties the specifier; the mutant falls back and the verdict is CompileError naming the module. +- AE5. **Covers R2.** Shortcut mutants A in `a.ts` and B in `b.ts`, where `b.ts` imports `a.ts`, share one snapshot update. A non-shortcut mutant C in `a.ts` never shares an update with any other mutant. +- AE6. **Covers R8.** A corpus shard where no mutant meets the rule fails the lane with a zero-count refusal; it never passes on vacuous equality. +- AE7. **Covers R4, R5.** A replacement containing `}` that closes F's body early and declares something after it fails the mutated-tree check and falls back. +- AE8. **Covers R5.** `export const f: Fn = (x) => { ... }` (contextually typed, no own annotation): a body mutant falls back. +- AE9. **Covers R5.** `get size() { return this.items.length }` without an annotation, read by an importer as a number: a body mutant returning a string falls back, and the verdict is CompileError naming the importer. +- AE10. **Covers R14.** Two mutants in one file are checked in one call; the first produces an error on line 20; the second deletes three lines above line 20. The first mutant's reason names line 20. +- AE11. **Covers R13.** One check call with passing mutants in `a.ts` and `b.mts` spawns one `tsc` build; each mutant's TCE classification equals the one-file-per-build result. +- AE12. **Covers R16.** Site `const s: "a" | "b" = "a"` at the initializer; candidates `""` and `"b"`; answers: `""` is `NotAssignable` (contextual type `"a" | "b"`), `"b"` is `Assignable`. Applying `""` through the checker gives CompileError. +- AE13. **Covers R16.** Site `f([])` where `declare function f(t: readonly [string]): void`; candidate `[]` is not in the context-free grammar, so the answer is `Unknown` (`candidate-not-context-free`), never `NotAssignable` (out of context `[]` has type `never[]`, which is not assignable to a tuple although `[]` in place can be). +- AE14. **Covers R16.** Site is the argument of a call to a generic function `declare function id(x: T): T`; candidate `""`; the answer is `Unknown` (`overloaded-or-generic-call`). +- AE15. **Covers R17.** A request whose first file holds `declare function f(t: readonly []): void; f([])` (the site `[]` whose type query panics tsgo 7.0.2, microsoft/typescript-go#4804) and a second, ordinary file: the first file is refused `server-crashed`, the second file is answered, and a check call running in the same process at the same time returns its usual verdicts. + +### Key Decisions + +- **Stay on `typescript@7.0.2` `unstable/*`; do not adopt the 7.1 nightly** (origin decision). Governs R1. +- **No schemata switch for type-checking** (origin decision; instrumented code is stamped `// @ts-nocheck`). Governs R2. +- **The shortcut is an edit-location rule on both trees, not a signature comparison** (session-settled: supervisor B2 hardened the origin rule with the both-trees check and AE7-AE9). Governs R4, R5. +- **Shortcut gated per site, not on the `isolatedDeclarations` flag, plus one `isolatedDeclarations` fixture** (session-settled: supervisor B1 chose OQ1(c)). Governs R6, R8. +- **Main's checker is built from the PR's merge-base inside the lane** (session-settled: supervisor B3). Governs R7. +- **A new sharded `checker-parity` job in `ci.yml`, path-filtered, plus every push to main** (session-settled: supervisor B4; the root may override the read-only `.github/workflows/` boundary). Governs R9. +- **Speed comes from the lane's own NDJSON, with no stream-schema change** (session-settled: supervisor B5). Governs R10. +- **TS5 under api-extractor and ts-morph's bundled 6.0.2 are dev tooling outside Done 5** (session-settled: supervisor B6). Governs R12. +- **Corpus is rule-selected over every package and checker fixture, sharded in CI** (session-settled: supervisor B7). Governs R6. +- **The TCE emit build is measured, not deferred** (session-settled: supervisor B8). Governs R13. +- **README TS7-only and the changelog correction ship in this unit** (session-settled: supervisor B9). Governs R11. +- **The type-query surface is a port in a declared entry, advisory only, versioned, provisional until Stream I confirms** (session-settled: supervisor B10). Governs R15-R18. + +### Scope Boundaries + +- No TS5 or TS6 fallback, no `typescript-5` alias, no parity on hand-picked files (contract Does-not-count). +- No 7.1 nightly API, no signature or declaration-emit comparison, no stream-schema change, no new third-party executable. +- No pnpm override and no replacement of the `api:check` gate (B6). +- No local mutation, e2e, microVM, full-workspace or corpus runs (S1). +- The type query never answers for candidates outside the context-free grammar in this unit (KTD11); an in-place probe for scope-dependent candidates is follow-up work. + +#### Deferred to Follow-Up Work + +- Bump `@microsoft/api-extractor` to the first rushstack release whose `package.json` depends on `typescript` 7 (pre-approved by S2); that removes `typescript@5.9.3` from the lockfile. Today's latest, 7.59.4 (2026-10-06), depends on `typescript: 5.9.3` and its `CHANGELOG.md:116` reads "Upgrade the bundled compiler engine to TypeScript 5.9.3". +- Replacing the `test/e2e-core` ts-morph oracle (bundled TypeScript 6.0.2) with a TypeScript 7 one. +- Adopting the 7.1 API's `getJavaScriptEmit` to replace the `tsc` spawn once 7.1 ships stable. +- An in-place probe that answers scope-dependent and context-sensitive candidates (`[]`, `a - b`, `!x`): one probe snapshot per round of sites that cannot see each other, reusing the Soundness Rule's isolation (KTD11 lists why it is not in this unit). + +--- + +## Planning Contract + +### Key Technical Decisions + +- **KTD1. A check call runs rounds.** The call first resets to the original text (one update, as today at `ts-compiler.handle.ts:1654-1656`) and reads each touched file's original tree. A pure workflow then splits the call's mutants into rounds: a round is either one mutant, or several shortcut-eligible mutants from distinct files. Each round applies its mutants and issues one `updateSnapshot` with every changed file (`node_modules/typescript/dist/api/proto.d.ts:84-97` takes a list). The round re-checks parse-held per member and re-splices all unheld members in one more update (today's `:1486-1490`, batched). It then evaluates the mutated-tree clauses for every member. If any member fails, the round's results are dropped and its members run as solo rounds. Members that pass get their own-file diagnostics; with no errors they pass without importer checks. Solo rounds keep today's path (`:1459-1470`). + - Why rounds stay inside one call: TCE marks a mutant `sibling` only against an earlier mutant at the same site in the same call (`classify-tce.workflow.ts:57-65`). Splitting a file's mutants across calls would change `Ignored` verdicts. Groups therefore keep every file whole. +- **KTD2. Groups are bounded sets of whole files.** `group()` packs whole files, in input order, into groups of at most `GROUP_MUTANT_BOUND` mutants (a file larger than the bound is a group by itself). The engine still checks groups concurrently across checker workers (`packages/stryker-js/src/Checker/checker-pool.handle.ts:270-289`). Callers that skip `group()` (`packages/stryker-js/src/run/deferrable-dry-run.cell.ts:51`) still get rounds inside `check()`. The bound's value is tuned from lane timings; it starts at 256. +- **KTD3. Diagnostics are rendered when they are produced.** Each round renders its members' diagnostics (`describeDiagnostics`) before the next update, so `MutantCheck` carries rendered lines and `Checker.cell.ts` stops rendering after the call. This fixes R14 on its own and is required by KTD1, since rounds change which text is current at the end of a call. +- **KTD4. One option, `typescriptChecker.importerCheck: 'location-rule' | 'always'`, default `'location-rule'`.** `'always'` disables both the shortcut and multi-member rounds, which reproduces main's algorithm. It is the escape hatch if a soundness hole turns up after release, and local tests use it as the reference. It lives in the `typescriptChecker` block the checker already reads (`ts-compiler.handle.ts:277-283`, `src/Checker.schema.ts:10-12`, `schema/typescript-checker-options.json`), which already feeds the program digest (`src/program-digest.schema.ts:22`), so incremental reuse never mixes verdicts from the two modes. +- **KTD5. The parity lane drives each side's shipped worker bundle over the worker protocol.** A private driver package spawns `dist/main.mjs` from each side through the engine's public `Worker` exports (`packages/stryker-js/src/Worker/mod.ts:6-13`). It passes options through `STRYKER_WORKER_DIR/options.json` (`packages/stryker-js-plugin-runtime/src/worker-options.service.ts:23-27`) and calls `init`, `group` and `check` over `Plugin.CheckerRpcs`. The bundle inlines its whole runtime closure and imports only `typescript` (`PLUG-1`), so main's bundle and the branch's bundle never share an Effect runtime in one process. Mutants come from the branch's instrumenter (`Instrument.instrument`, as in `test/e2e-core/tests/concurrency-checker.integration.test.ts:88-93`), so both sides see identical mutant ids. +- **KTD6. Counts travel as span attributes; the driver collects them with a local OTLP/HTTP receiver.** The worker already exports spans over OTLP/HTTP JSON when `OTEL_ENABLED=true`, to `OTEL_EXPORTER_OTLP_ENDPOINT` (`src/main.ts:32`; `packages/stryker-js-plugin-runtime/src/worker-telemetry.service.ts:10-14`). The branch checker annotates `typescript-checker.compiler.check` with `typescript.snapshot_updates.count`, `typescript.resplices.count`, `typescript.tce_builds.count`, `typescript.tce.ms`, `typescript.importer_shortcut.count` and per-clause fallback counts. The driver decodes the OTLP JSON with a Schema and stores the raw spans in the lane artifact for diagnosis (`OBS-1`). Main's checker has no such attributes; its counts are derived (OQ-P2). +- **KTD7. One emit build per check call, when material.** `TceEmitRequest` (`src/tce-emit.ts:13-18`) carries one `sourceExtension` for the whole request, and inputs are named by index (`:25-29`). It becomes a list of per-file entries, each input keeping its own extension and read back with its own emitted extension (`.mts`→`.mjs`, `.cts`→`.cjs`, otherwise `.js`). `tsc` emits per-file extensions within one project, and each synthetic input already lives in the same temp directory with the same package scope as today, so module format detection per file does not change. The change ships if the lane at U6 shows `typescript.tce.ms` at 5% or more of the branch checker phase (OQ-P6). Otherwise the measurement is recorded in the PR and U7 is dropped. +- **KTD8. Verdict reuse across lane runs.** A side's verdicts for one corpus project are cached under the key (sha256 of that side's checker `src/` tree and `typescript` version, the project's `ProgramDigest` from the checker's `digest` RPC, the mutant-set digest). Main's side changes only when the merge-base moves; the branch side's previous-main results are the next PR's main-side entries. Unchanged projects then cost nothing on re-push. This uses the same digest the engine already uses for verdict reuse (`ts-compiler.handle.ts:333-357`). +- **KTD9. R8 is proven against main, not by a third run.** Main's checker always re-checks importers and never batches, so it is the "without shortcut" reference. A branch-versus-main identity under R7 is the shortcut-on versus shortcut-off identity on the same mutants, and it saves a third corpus pass. Locally, the integration tests run the same fixtures with `importerCheck: 'always'` and default and compare (OQ-P3). +- **KTD10. The type query runs on its own tsgo server, in the process that calls it.** The port's implementation opens a separate `API` instance (its own `tsc --api` child process) per tsconfig and closes it when the caller's scope closes. A panicking request kills only that server: tsgo 7.0.2 panics on `getTypeAtLocation` for an array literal contextually typed by an empty tuple, and "the panic kills the tsgo server process, so every later request on the same `API` instance fails too" (microsoft/typescript-go#4804, closed upstream 2026-08-28; 7.0.2 is the latest 7.0 release on npm as of 2026-10-09). Sharing the checker worker's server would let a query take down verdicts, so the query never goes through the checker worker's RPC. On a crash the file in flight is refused `server-crashed`, the server is reopened once, and the remaining files are answered. +- **KTD11. A replacement gets a type from one probe snapshot per request, built by appending inert statements.** For each requested file, the probe text is the request's file text followed by one `;();` statement per distinct context-free candidate. An expression statement of a literal declares nothing and changes no other type, so every original site keeps its type and contextual type (pinned by a test, U11). The implementation then queries, in that one snapshot: site nodes found by exact span (the walk `nodeSpanOf`/`hasSpanOf` already do, `ts-compiler.handle.ts:794-823`), `getTypeAtLocation(siteNodes)` batched (`dist/api/async/api.d.ts:231-232`), `getContextualType(site)` per site, pipelined (`:239`), `getTypeAtLocation(candidateNodes)` batched for the appended candidate expressions, `isTypeAssignableTo(candidateType, contextualType)` per pair, pipelined (`:246`), and `typeToString` for the reported types (`:268`). Types are snapshot-scoped handles, so source and target must come from the same snapshot; this is why candidates are appended to the file rather than probed in a separate snapshot. `getTypeAtPosition(file, positions)` (`:235-236`) is not used for sites, since it returns the innermost token's type (for `a + b`, the type of `a`). + - The context-free grammar is the set of candidates whose type does not depend on where they sit: string, no-substitution template, numeric and bigint literals (with an optional leading `-`), `true`, `false`, `null`, `undefined`, `{}`, `() => undefined` and `() => {}`. Array literals are excluded (`[]` takes a tuple type in tuple context but is `never[]` out of context, AE13), and so are object literals with members and functions with parameters or literal bodies (their types widen out of context). A pure workflow decides membership from the candidate text, so a candidate that is not in the grammar is never appended and cannot corrupt the probe parse. + - An answer is `NotAssignable` only when every one of these holds; otherwise it is `Unknown` with the first failing reason: the site node exists and is an expression; it has a contextual type; the contextual type is not the error type; neither it nor any union or intersection constituent carries `TypeFlags.Instantiable`; the site is not an argument of a call or `new` whose callee has more than one signature or whose resolved signature has type parameters (`getResolvedSignature`, `:234`; `getSignaturesOfType`, `:233`). `Assignable` means only "assignable to the contextual type", never "compiles": a site with a contextual type can still break flows downstream, so callers drop on `NotAssignable` only. + - Measured on this worktree, `typescript@7.0.2`, the checker package's `tsconfig.app.json` (141 program files), eight context-free candidates (throwaway `.scratch/type-query-cost.mjs`, deleted after measuring): opening the project 193-261 ms once per tsconfig; the probe update 5.7-9.5 ms per file; for `src/group-mutants.workflow.ts` (60 lines, 41 expression sites) site types 3.9 ms batched, contextual types 11 ms pipelined (33 of 41 defined), candidate types under 1 ms, 264 assignability pairs 7.3 ms; for `src/ts-compiler.handle.ts` (1817 lines, 1832 sites) site types 131 ms batched cold and 16 ms warm, contextual types 85 ms (1438 of 1832 defined), 11504 pairs 216 ms (about 19 µs a pair). A whole large file costs about 0.45 s and a small one about 30 ms, against 13-22 ms for one own-file check and up to 12.5 s for one importer re-check per mutant in the checker (origin Measured Cost). Pairs in a real request are one per mutant, not sites × candidates, so request cost is lower than these figures. + - Reach on main's corpus: across the four dogfood projects' latest reports, 3783 of 4136 CompileError mutants (91.5%) have a replacement in the context-free grammar, and 62.8% of context-free mutants end as CompileError. That bounds what this unit's query can answer; how many it answers `NotAssignable` is measured by the lane (R18). +- **KTD12. A wrong `NotAssignable` is caught where every mutant is already checked.** The parity lane checks every corpus mutant with the branch checker anyway, so the driver queries every context-free mutant before checking and compares the answer with the verdict at no extra checker cost. Locally, a differential spec feeds every `NotAssignable` answer on fixture files through the real checker. Stream I's drop-soundness proof consumes the lane's `wrong-not-assignable` lines and per-reason counts; the port itself carries no confidence score. + +### High-Level Technical Design + +_Directional only; names and signatures are settled in the units._ + +```mermaid +flowchart TD + A[check call: mutants of whole files] --> B[reset previous members + update 1: original text] + B --> C[read original trees, extract edit-site facts] + C --> D[pure: decide shortcut per mutant, plan rounds] + D --> E{next round} + E -->|several eligible, distinct files| F[apply all, one update, re-splice unheld in one update] + E -->|one mutant| G[today's path: apply, update, own file, then importers or whole program] + F --> H{mutated-tree clauses hold for every member?} + H -->|yes| I[own-file diagnostics per member, render now] + H -->|no| J[drop round results, enqueue members as solo rounds] + J --> E + G --> K[render now] + I --> E + K --> E + E -->|done| L[one tsc emit for all passing mutants of the call, classify TCE per file] + L --> M[annotate counts on the check span] +``` + +The lane, per shard: build main's checker at the merge-base in a worktree; build the branch checker; for each corpus project in the shard, run main's worker then the branch worker over the same mutants; write verdict, phase and count lines as NDJSON plus raw spans. A report job merges all shards and fails on any R7/R8/R10 violation. + +### Assumptions + +Agent bets, not confirmed by the supervisor: + +- The engine's `Worker` exports plus `Plugin.CheckerRpcs` are enough to drive a checker worker outside a Stryker run. If a piece is internal to `packages/stryker-js`, U9 exports it from `Worker/mod.ts` (a minor engine bump) instead of re-implementing the protocol. +- A full corpus pass of main's checker fits in eight 40-minute shards on `ubuntu-latest`. Today's evidence: main's dogfood report holds 6336 mutants for 117 files of `packages/stryker-js` alone, 58% of them CompileError. The first main-push run of U10 measures it; if a shard exceeds the budget at eight shards, that is a stop condition. +- `@opentelemetry/exporter-trace-otlp-http` posts JSON by default, so a schema-decoded JSON receiver is enough. +- Reading a file's `SourceFile` through the snapshot (`project.program.getSourceFile`) costs milliseconds per file, so reading original trees once per call is cheap next to the importer checks it saves (origin Measured Cost: own-file check 13-22 ms, importer re-check up to 12.5 s). +- Stream I calls the type query in the engine process during the instrument phase, before the dry run starts checker workers, so the extra tsgo server never overlaps the checker workers' servers (S1 memory). If I calls it elsewhere, the port is unchanged but the memory assumption must be re-checked. + +### Sequencing and Stacking + +One stack on trunk `main` (`gh stack`), each layer green on its own and leaving main releasable. New code lands inert until a higher layer wires it in. + +| Layer | Branch | Units | Turns on | +| ----- | ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------- | +| 1 | `stryker/tsgo-checker` | docs+U1 | Brainstorm, this plan, README TS7-only, changelog correction | +| 2 | `stryker/tsgo-checker-positions` | U2 | Diagnostics rendered when produced | +| 3 | `stryker/tsgo-checker-counters` | U3 | Count attributes on the check span | +| 4 | `stryker/tsgo-checker-parity` | U8, U9 | Driver package and fixture, not yet run in CI | +| 5 | `stryker/tsgo-checker-parity-lane` | U10 | Lane on: verdict parity gate (R7), timings and counts reported, no speed or zero-count gate yet | +| 6 | `stryker/tsgo-checker-shortcut` | U4, U5 | Shortcut on for solo rounds; lane zero-count refusal (R8) on | +| 7 | `stryker/tsgo-checker-rounds` | U6 | Multi-member rounds; speed gate (R10) on | +| 8 | `stryker/tsgo-checker-tce-build` | U7 | One emit build per call (only if KTD7's threshold is met; otherwise the layer is dropped and the measurement is reported) | +| 9 | `stryker/tsgo-checker-type-query` | U11, U12 | Port entry and tsgo implementation (inert until Stream I calls it); lane `wrong-not-assignable` gate on | + +Layer 5 proves the lane on a branch checker whose only change from main is U2 and U3, so a non-empty diff there is a lane bug, not a checker bug. + +### Risks + +| Risk | Mitigation | +| ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Corpus runtime of main's checker exceeds CI budget | Shard by file hash; KTD8 cache; first main-push run measures; stop condition at eight shards | +| A soundness hole the rule misses | Lane compares every corpus verdict against main; `importerCheck: 'always'` escape hatch; AE2/AE3/AE9 regression fixtures | +| tsgo's `isolatedDeclarations` diagnostics are known-incomplete | The rule never trusts the flag (clause 2 is checked per site) | +| Reading original trees disposes or invalidates `RemoteSourceFile` handles across updates | Facts are extracted into plain data (spans, kinds, header text) before the next update; no `Node` survives an update | +| The lane edits a read-only path (`.github/workflows/`) | B4 put it in scope and the root may override; the job lands in its own layer (5) so it can be pulled without touching checker code | +| Required status check naming: matrix legs append `(k)` | Branch protection requires the single `checker-parity-report` job, never a leg (`docs/solutions/workflow-issues/matrix-legs-rename-the-required-status-check.md`); owner adds it | +| `.tsx` with `jsx: preserve` emits `.jsx`, which the TCE reader does not map today | Unchanged by this plan; U7's mixed-extension test includes `.tsx` and maps by the project's `jsx` setting only if a fixture shows the gap | +| A type query panics the tsgo server (microsoft/typescript-go#4804, live in 7.0.2) | Separate server per caller (KTD10); file refused `server-crashed`; AE15 regression test | +| A wrong `NotAssignable` drops a mutant that would have compiled | Grammar and `Unknown` rules (KTD11); differential spec (U11); lane gate on every corpus mutant (R18, U12) | +| `typescript/unstable/*` changes shape in a patch release | U11 pin test re-fires on every `typescript` bump; the port's schema version isolates Stream I from the change | + +### Considered and Not Built + +- **Schemata switch in one snapshot.** Rejected upstream (origin Key Decisions): combined branches change verdicts. +- **A third corpus pass with the shortcut forced off.** KTD9: main is that reference. +- **Exposing the count through the check RPC answer.** Changes `plugin-interface`, which Stream A owns; spans already reach CI. +- **Running the lane on Grafana LGTM like the e2e lane.** A trace-settle wait per shard (`TRACE_SETTLE_SECONDS`, default 70) and a container stack for six attributes; the in-driver receiver is deterministic. +- **A pnpm override forcing TS7 onto api-extractor.** Ruled out (S2, B6). +- **Answering type queries through the checker worker's RPC.** It reuses an open snapshot but puts verdicts behind the same server a query can crash (KTD10), and it changes `Plugin.CheckerRpcs`, which Stream A owns. +- **Intrinsic types without a snapshot** (`getStringType()`, `getBooleanType()`). They give `string` for `""`; `string` is not assignable to `"a" | "b"` although `""`'s literal type decides the real answer, so they would give wrong `NotAssignable` answers. +- **One in-place probe per candidate.** Exact for every candidate kind, but costs a snapshot update and a file check per candidate, the same as the checker's own-file check it is meant to save. + +--- + +## Implementation Units + +### Unit Index + +| U-ID | Title | Files touched (key) | Depends on | +| ---- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------- | +| U1 | TS7-only docs, changelog correction | `packages/stryker-js-typescript-checker/README.md`, `.changeset/*` | - | +| U2 | Render diagnostics when produced | `src/Checker.cell.ts`, `src/ts-compiler.handle.ts`, `tests/check-mutants.integration.test.ts` | U1 | +| U3 | Count updates, re-splices, emit builds | `src/ts-compiler.handle.ts`, `src/tce-emit.ts`, `test/checker-parity` zero-count refusal | U2 | +| U4 | Edit-site facts and the Soundness Rule | `src/edit-site.ts`, `src/decide-importer-shortcut.workflow.ts`, `src/CheckerCommands.schema.ts` | U3 | +| U5 | Shortcut in the solo path, option | `src/ts-compiler.handle.ts`, `src/Checker.schema.ts`, `schema/typescript-checker-options.json`, tests | U4, U10 | +| U6 | Rounds and file-bounded groups | `src/group-mutants.workflow.ts`, `src/plan-check-rounds.workflow.ts`, `src/check-rounds.ts`, `src/ts-compiler.handle.ts` | U5 | +| U7 | One emit build per check call | `src/tce-emit.ts`, `src/check-rounds.ts` | U6 | +| U8 | `isolatedDeclarations` corpus fixture | `test/checker-parity/fixtures/isolated-declarations/**` | U3 | +| U9 | Parity driver package | `test/checker-parity/**` | U3 | +| U10 | `checker-parity` CI lane | `.github/workflows/ci.yml` | U8, U9 | +| U11 | Type-query port and tsgo implementation | `stryker-js-plugin-interface` `./type-query` entry; checker `./type-query` entry, `src/type-query.handle.ts`, two workflows | U3 | +| U12 | Lane: `wrong-not-assignable` gate | `test/checker-parity/**` | U10, U11 | + +Paths under `src/`, `tests/` and `schema/` are relative to `packages/stryker-js-typescript-checker/`. + +### U1. TS7-only docs and the changelog correction + +- **Goal:** product docs claim TypeScript 7 only; the next release notes state the actual importer re-check rule. +- **Requirements:** R11, R12. +- **Dependencies:** none. +- **Files:** `packages/stryker-js-typescript-checker/README.md` (lines 6 and 17: "TypeScript 5.x through 7.x", "TypeScript `>=5.0.0`"); a new `.changeset/.md` (patch). +- **Approach:** README prerequisites say TypeScript `>=7.0.0` (the floor `minimumTypeScriptVersion` enforces at `ts-compiler.handle.ts:210`, checked by `guardTypescriptVersion` at `:359-367`). The changeset says the 8.1.0 note (`CHANGELOG.md:65`, importers checked "only when the mutant changes what that file exports") was wrong: on main, importers are re-checked whenever the mutated file has no errors of its own. Released `CHANGELOG.md` sections are not rewritten; changesets own that ledger (`docs/solutions/tooling-decisions/pnpm-owns-the-changeset-ledger.md`). +- **Patterns:** existing changesets in `.changeset/`. +- **Test scenarios:** none; documentation only. +- **Verification:** local `pnpm format:check` on the two files; CI `Changeset Check`. +- **Dogfood mutant ids:** none; no mutated file changes. + +### U2. Render diagnostics when they are produced + +- **Goal:** a verdict's `line,col` comes from the text that produced the diagnostic. +- **Requirements:** R14; prerequisite for R2 and R7. +- **Dependencies:** U1 (stack order only). +- **Files:** `src/ts-compiler.handle.ts` (`checkOne`, `MutantCheck` at `:1366-1370`, `describeDiagnostics` at `:1763-1775`); `src/Checker.cell.ts` (`verdictsOf` at `:28-45`); `tests/check-mutants.integration.test.ts`; new `tests/__fixtures__/per-mutant-position/`. +- **Approach:** `checkOne` renders its mutant's diagnostics right after collecting them, while its snapshot is still current, and `MutantCheck` carries `DiagnosticLine` values instead of raw `Diagnostic`s. `verdictsOf` maps them straight into `MutantVerdict`. The dry-run path (`CheckerRuntime.service.ts:63`) has no mutant applied and keeps its call. +- **Patterns:** `cell-architecture` `sandwich-phase-order.md` (rendering is part of the imperative read phase, before `decide(checkMutants)`). +- **Test scenarios:** + - AE10 (integration, regression): fixture where mutant 1 errors at line 20 and mutant 2 (later in the same call) replaces a multi-line block above it with `{}`; mutant 1's reason names line 20. Fails on main, which renders against mutant 2's text. + - Property (`src/__tests__/check-mutants.workflow.property.test.ts`): for any non-empty list of rendered diagnostics, a failed mutant's reason is those lines joined by `'\n'` in order. +- **Verification:** local `pnpm --filter @systemfsoftware/stryker-js-typescript-checker typecheck` and `... test`; CI full. +- **Dogfood mutant ids:** `47892392410b616f` (Survived on main, `src/check-mutants.workflow.ts:56`, the `'\n'` join replaced by `""`) is killed by the join property. `ts-compiler.handle.ts` and `Checker.cell.ts` are outside the dogfood `mutate` globs (`stryker.config.ts:12-19`), so they have no ids. + +### U3. Count updates, re-splices and emit builds + +- **Goal:** every check call reports what Done 1 measures. +- **Requirements:** R3. +- **Dependencies:** U2. +- **Files:** `src/ts-compiler.handle.ts` (`updateSnapshot` `:896-909`, `parenthesizedSpliceOf` `:855-861`, `check` `:1634-1684`); `src/tce-emit.ts` (`runEmit` `:88-121`). +- **Approach:** a per-call tally (a `Ref` local to `check`) counts updates, re-splices and emit builds, and sums emit time. `check` annotates the KTD6 attributes on its span. No new span names, so `stryker-js-cli-contract` `SpanTaxonomy` is unchanged. +- **Patterns:** `observability-code` skill for attributes. +- **Test scenarios:** none in the package (Test Admission: refused). The lane's `compare` refuses a run whose branch side reports zero snapshot updates while checking a non-zero number of mutants, which catches an attribute that is never set. +- **Verification:** local package typecheck and tests. +- **Dogfood mutant ids:** none; the touched files are outside the `mutate` globs. + +### U4. Edit-site facts and the Soundness Rule decision + +- **Goal:** a pure, tested decision for each mutant: shortcut or the first failing clause. Inert until U5. +- **Requirements:** R4, R5. +- **Dependencies:** U3. +- **Files:** new `src/edit-site.ts` (reads a `SourceFile` from `typescript/unstable/ast` and returns plain facts: the function-like ancestors of a span with kind, start, body span, header text and annotation presence; module and `declare global` status, reusing `declaresGlobalScope` at `ts-compiler.handle.ts:1397-1401`; spans of `import()`, `require()` and import type nodes); new `src/decide-importer-shortcut.workflow.ts`; `src/CheckerCommands.schema.ts` (command with original-tree and mutated-tree facts); new `src/__tests__/decide-importer-shortcut.workflow.property.test.ts`. +- **Approach:** the decision is a tagged union, `ShortcutTaken | ImportersRechecked { clause, tree }`, decided by `Workflow.make` with `error: S.Never`. `edit-site.ts` walks `forEachChild` the way `hasSpanOf` does (`:817-823`). The type guards come from `typescript/unstable/ast/is` (a public export). Facts are plain data, so no `Node` outlives its snapshot. +- **Patterns:** `cell-architecture` `pure-decision-workflows.md` (decision is a `Workflow.make` with cyclomatic complexity 1); `schema-laws` `tagged-unions-over-state-by-presence.md` (the decision union); `schema-laws` `cross-field-invariants-as-struct-checks.md` (a span lies inside its body span is a struct check on the facts schema, not a guard in the workflow); `schema-laws` `refusals-beside-generated-laws.md` (refused facts in the schema's own file). +- **Test scenarios (property):** any failing clause on either tree yields `ImportersRechecked` naming that clause and tree; all clauses true on both trees yields `ShortcutTaken`; header text that differs between trees yields `ImportersRechecked`; a span inside a nested unannotated function that sits inside an annotated F yields `ShortcutTaken` (the outermost qualifying F decides). +- **Verification:** local package typecheck and tests. +- **Dogfood mutant ids:** the new workflow and schema match the `mutate` globs; they get ids at the first main mutation run after merge. `src/CheckerCommands.schema.ts` keeps its existing ids for untouched lines (for example `141d93c9edf79341`, Timeout, `:20`), but new command classes shift line-based ids below them. + +### U5. Shortcut in the solo path, and the option + +- **Goal:** a solo mutant that meets the rule passes on own-file diagnostics; users can turn it off. +- **Requirements:** R4, R5, R8 (local proof). +- **Dependencies:** U4; U10 (the lane must be running before the shortcut changes any verdict path). +- **Files:** `src/ts-compiler.handle.ts` (`checkOne` `:1472-1497`, `checkedIn` `:1459-1470`); `src/Checker.schema.ts` (`:10-12`); `schema/typescript-checker-options.json`; `README.md` (option section); `tests/check-mutants.integration.test.ts`; new `tests/__fixtures__/importer-shortcut/`; `test/checker-parity` compare workflow (zero-count refusal on); a changeset (minor); `etc/stryker-js-typescript-checker.api.md` via `api:update` if `TypescriptCheckerOptionsSchema` is on the public surface. +- **Approach:** `checkOne` reads the mutant file's original tree before applying (the call already starts from original text after U6; until then the solo path reads it before `applyMutant`), extracts facts again after the update, and runs the U4 decision. `ShortcutTaken` with no own errors skips `beyondOwnErrorsOf`. `importerCheck: 'always'` skips the decision entirely. +- **Patterns:** `boundary-testing` `real-system-oracles.md` (tests run real tsgo against fixture files); `boundary-testing` `no-mocks-on-internal-glue.md`. +- **Test scenarios:** AE1 (one shortcut decision on the span, no importer diagnostics requested); AE2, AE3, AE9 (CompileError naming the importer: a wrongly taken shortcut makes them pass, so they fail on the plausible bug); AE4; AE7 (hand-built wire whose replacement closes the body); AE8; every fixture run once per `importerCheck` value with identical verdicts. +- **Verification:** local package typecheck and tests; CI lane (R8 zero-count refusal now on). +- **Dogfood mutant ids:** the AE2/AE3 fallback scenarios route through `trace-affected-files.workflow.ts` and `request-affected-files.workflow.ts` and keep their Killed mutants killed: `f68b6e883cbcc324`, `6f609696e10d7efb`, `752592eb349dc8da` (`trace-affected-files.workflow.ts:62`), `620a93c10b22628a`, `ba0a7d59ebb4482d`, `1e395b6653e16a69` (`request-affected-files.workflow.ts:34`). + +### U6. Rounds and file-bounded groups + +- **Goal:** several eligible mutants from distinct files share one snapshot update. +- **Requirements:** R2, R3, R10. +- **Dependencies:** U5. +- **Files:** `src/group-mutants.workflow.ts` (rewritten: groups of whole files up to `GROUP_MUTANT_BOUND`); new `src/plan-check-rounds.workflow.ts`; `src/CheckerCommands.schema.ts` (round-planning command); new `src/check-rounds.ts` (the round runner: `check`, `checkOne`, `classifyBatchTce` and their helpers move out of `ts-compiler.handle.ts`, `:1472-1684`); `src/ts-compiler.handle.ts` (keeps state, snapshot and graph code; `check` delegates); property tests for both workflows; new `tests/check-rounds.integration.test.ts`; new `tests/__fixtures__/rounds/`; `test/checker-parity` compare workflow (speed gate on); a changeset (minor). +- **Approach:** KTD1 and KTD2. `lastMutants`/`lastMutatedFileNames` (`:1667-1671`) become the last round's members and files. The re-splice, fallback-to-solo and render steps follow the flowchart. `group()` stays a pure, synchronous decision over wires. +- **Patterns:** `cell-architecture` `pure-decision-workflows.md`; `boundary-testing` `pin-dependency-semantics.md`: one pin test drives `typescript/unstable/async` directly with a virtual file system and asserts that one `updateSnapshot` with two changed files serves both new texts, and that a file's diagnostics are recomputed after an update. It re-fires on every `typescript` bump. +- **Test scenarios:** property laws: every mutant lands in exactly one round; a multi-member round holds only eligible mutants, at most one per file; an ineligible mutant is alone; groups hold whole files and respect the bound unless one file exceeds it. Integration: AE5; the `rounds` fixture (several files, eligible mutants including one with an own-file error and one AE7-style breaker) gives identical verdicts with the default option and with `'always'`. +- **Verification:** local package typecheck and tests; CI lane (R7, R8, R10 all gating). +- **Dogfood mutant ids:** the rewrite retires every current id in `group-mutants.workflow.ts`, including Killed `62022de61dbfc17f`, `25c843d6bcc5ba1d`, `ab0fe07b72c193ad`, `e9f541378e920557` and NoCoverage `17563f7f061cc8d9` (`:48`, the empty-list fallback). New ids appear at the first main mutation run after merge. + +### U7. One emit build per check call + +- **Goal:** TCE costs one `tsc` program build per check call. +- **Requirements:** R13. +- **Dependencies:** U6, and KTD7's threshold met in the lane. +- **Files:** `src/tce-emit.ts` (`TceEmitRequest` `:13-18`, naming `:25-29`, `writeInputs` `:52-69`, `runEmit` `:88-121`); `src/check-rounds.ts` (one emit for all files); `tests/check-mutants.integration.test.ts`; new `tests/__fixtures__/per-mutant-tce-mixed/` (`.ts`, `.mts`, `.tsx`); a changeset (patch). +- **Approach:** KTD7. Inputs are named `tce--`; the result maps each file to its original emit and its mutants' emits. +- **Patterns:** `boundary-testing` `real-system-oracles.md` (real `tsc` binary). +- **Test scenarios:** AE11 (integration). Property (`src/__tests__/check-mutants.workflow.property.test.ts`): a mutant whose TCE outcome is `original` is ignored with reason `equivalent-to-original: tce`, and one whose outcome is `sibling` with `duplicate-at-site: tce`. +- **Verification:** local package typecheck and tests; CI lane. +- **Dogfood mutant ids:** the exact-reason property kills `7f50c38d0d04fa7f` and `5cd5dc3a795c02d0` (both Survived on main, `src/check-mutants.workflow.ts:42-43`, the two ignore-reason strings). + +### U8. `isolatedDeclarations` corpus fixture + +- **Goal:** the contract's named case has a non-empty proof set (B1). +- **Requirements:** R6, R8. +- **Dependencies:** U3 (stack order). +- **Files:** new `test/checker-parity/fixtures/isolated-declarations/` with a standalone `tsconfig.json` (`isolatedDeclarations: true`, `declaration: true`, strict, not extending `@systemfsoftware/tsconfig`, whose README forbids the flag) and four to six modules: annotated exported functions, a class with annotated methods and accessors, an annotated arrow constant, an importer chain two deep, and a module with a dynamic import. +- **Approach:** the fixture must compile cleanly under tsgo 7.0.2, since the checker refuses a project whose dry run fails. Bodies hold arithmetic, conditionals and string literals so the default mutators produce body mutants. +- **Test scenarios:** none of its own; the lane's per-fixture shortcut count is its test. +- **Verification:** local `tsc -p test/checker-parity/fixtures/isolated-declarations --noEmit` exits 0. +- **Dogfood mutant ids:** none. + +### U9. Parity driver package + +- **Goal:** a CI tool that runs one checker side over a corpus shard and compares sides. +- **Requirements:** R3, R6, R7, R8, R10. +- **Dependencies:** U3. +- **Files:** new private package `test/checker-parity/` (`@systemfsoftware/stryker-checker-parity`): `package.json`, tsconfigs, `src/corpus.ts` (R6 discovery: workspace `tsconfig.app.json` files, the tsconfig each checker-enabled `test/e2e/testResources/*/stryker*.config.ts` names, the U8 fixture; program files listed with the TS7 `tsc --listFilesOnly`), `src/shard.ts` (file-hash sharding), `src/run-side.ts` (KTD5), `src/otlp-receiver.ts` (KTD6), `src/Parity.schema.ts` (NDJSON lines: verdict, phase, counts, refusal), `src/compare-sides.workflow.ts` (pure: R7 diff with the own-file position rule, R8 zero-count refusal overall and for the U8 fixture, R10 speed verdict, Done 1 ratios), `src/main.ts` (`run` and `compare` commands); tests under `src/__tests__/`. `pnpm-workspace.yaml` already covers `test/*`. +- **Approach:** `run --side --worker --shard k/N --out ` instruments the shard's files with the branch instrumenter, then per project spawns the worker with `typescriptChecker` options and OTLP env, calls `init`, `digest`, `group`, then `check` per group. It times each RPC, writes NDJSON, and records the KTD8 cache key per project. `compare ` exits non-zero on any violation, prints every differing mutant (id, file, line, both reasons), and writes a summary. Main-side counts are derived per OQ-P2. The driver never runs locally (S1); it refuses to start outside CI unless `--allow-local` is passed, mirroring `refuse-local-mutation.workflow.ts`. +- **Patterns:** `cell-architecture` `pure-decision-workflows.md` (compare); `cell-architecture` `decode-never-cast.md` and `schema-laws` `rich-type-over-foreign-encoded.md` (OTLP JSON and NDJSON decode through Schema; OTLP's wire shape is the Encoded side); `schema-laws` `tests-own-no-schemas.md`; `schema-laws` `invariants-as-refinements.md` (`k/N` refined at decode: 1 ≤ k ≤ N). +- **Test scenarios (property, local):** two identical sides compare clean; a single status difference fails naming the mutant; an own-file `(line,col)` difference passes and an other-file one fails; a zero shortcut count fails overall and for the fixture alone (AE6); a branch side with checked mutants and zero snapshot updates fails (U3's attribute never set); branch time ≥ main time fails once the speed gate is on; sharding assigns every file to exactly one shard for any N. +- **Verification:** local `pnpm --filter @systemfsoftware/stryker-checker-parity typecheck` and `... test` (pure tests only). +- **Dogfood mutant ids:** none; the package is not a dogfood project (`mutation.yml:29`). + +### U10. `checker-parity` CI lane + +- **Goal:** R7-R10 enforced on every relevant PR and every push to main. +- **Requirements:** R9 (and R3, R7, R8, R10 through U9). +- **Dependencies:** U8, U9. +- **Files:** `.github/workflows/ci.yml` (new jobs `checker-parity-changes`, `checker-parity` with an `include` matrix of shards, `checker-parity-report`). +- **Approach:** `checker-parity-changes` outputs `run=true` on push to main, or when `git diff --name-only ...HEAD` touches `packages/stryker-js-typescript-checker/`, `packages/stryker-js-instrumenter/`, `test/checker-parity/`, `test/e2e/testResources/`, `pnpm-lock.yaml` or `.github/workflows/ci.yml`. Each `checker-parity (k)` leg checks out with history, installs like `check` (nix dprint, released tarballs, pnpm, node 24, `pnpm install --frozen-lockfile`), builds the branch checker worker, instrumenter and driver with `turbo build --filter`, adds a worktree at the merge-base, installs and builds main's checker there, restores the KTD8 cache, runs both sides, saves the cache, and uploads `checker-parity--` (NDJSON plus raw spans, 14-day retention). `checker-parity-report` downloads all legs and runs `compare`. It is the one context branch protection should require; it succeeds when changes say `run=false`. Shard count starts at 6; the first main-push run's timings set it. +- **Patterns:** existing `e2e` job matrix and artifact steps in `ci.yml`; `docs/solutions/workflow-issues/matrix-legs-rename-the-required-status-check.md`; `docs/solutions/workflow-issues/mutation-lane-green-while-every-job-failed.md` (the report job fails when any leg wrote no NDJSON; no `continue-on-error` on verdict steps). +- **Test scenarios:** none in-repo; the layer's own CI run on a branch where the checker differs from main only by U2 and U3 must report zero verdict differences. +- **Verification:** local `pnpm format:check` (dprint covers YAML); CI: the lane's first run. +- **Dogfood mutant ids:** none. + +### U11. Type-query port and tsgo implementation + +- **Goal:** the instrumenter can ask, before generating a mutant, the type at a span and whether a candidate replacement is assignable where it would sit, through a port it already depends on. +- **Requirements:** R15, R16, R17. +- **Dependencies:** U3 (stack order). Independent of U4-U7. +- **Files:** + - Port (provisional location, OQ-P8): `packages/stryker-js-plugin-interface/src/TypeQuery.schema.ts` (the v1 schemas in "Type-query surface for Stream I"), `src/TypeQuery.service.ts` (the `TypeQuery` service tag), a declared `./type-query` entry in its `package.json` `exports` and `tsdown.config.ts`, and its api report. The instrumenter already depends on `stryker-js-plugin-interface` (`packages/stryker-js-instrumenter/package.json`), so it needs no new dependency. + - Implementation: `packages/stryker-js-typescript-checker/src/type-query.handle.ts` (server lifecycle, probe text, the API calls in KTD11), new `src/classify-candidate.workflow.ts` (pure: grammar membership from text), new `src/answer-type-query.workflow.ts` (pure: facts per site and candidate to the answer union, reasons in the order KTD11 lists), `src/CheckerCommands.schema.ts` (their commands), a declared `./type-query` entry exporting `TypeQueryLive: (options) => Layer` (`package.json` `exports`, `tsdown.config.ts` entry; `PLUG-1`: the entry imports nothing but `typescript` and node builtins). + - Tests: `src/__tests__/classify-candidate.workflow.property.test.ts`, `src/__tests__/answer-type-query.workflow.property.test.ts`, `packages/stryker-js-plugin-interface/src/__tests__/TypeQuery.schema.test.ts`, `tests/type-query.differential.test.ts`, `tests/type-query-pins.integration.test.ts`, fixtures under `tests/__fixtures__/type-query/`. + - Changesets: plugin-interface minor, checker minor. +- **Approach:** KTD10 and KTD11. Locations arrive as the plugin-interface `Location` (line and column, as mutants carry); the implementation converts them with the instrumenter's `offsetAt`, which the checker already uses. Site facts (kind, exact span found, parent call, flags) are read into plain data before the answer workflow decides, as in U4. +- **Patterns:** `cell-architecture` `pure-decision-workflows.md` and `sandwich-phase-order.md` (read facts, decide, write the response); `cell-architecture` `ports-separate-from-layers.md` (the port and its Layer live in separate modules and packages); `schema-laws` `tagged-unions-over-state-by-presence.md` (answer and file outcome unions); `schema-laws` `rich-type-over-foreign-encoded.md`; `boundary-testing` `pin-dependency-semantics.md`; `boundary-testing` `real-system-oracles.md`. +- **Test scenarios:** + - Property, `classify-candidate`: every text built from the grammar's productions is a member; any text that is not one whole production (a member with a trailing `)` or `;`, a `[]`, an object literal with a member, a function with a parameter) is not, so no non-member can be appended. + - Property, `answer-type-query`: `NotAssignable` exactly when every KTD11 condition holds and assignability is false; each failing condition alone yields `Unknown` with that reason; reasons are reported in KTD11's order when several fail. + - Schema codec laws for every v1 schema (round trip, encode stability), and a request with `version: 2` is refused `unsupported-version`. + - Differential (`tests/type-query.differential.test.ts`, reference: the real checker's verdict): over the fixture files, every candidate answered `NotAssignable` is applied as a mutant through `CheckerRuntime` and must be CompileError; AE12, AE13, AE14 are rows of this spec. + - Pin (`tests/type-query-pins.integration.test.ts`): appending `;("");` statements leaves every site's `getContextualType` text unchanged; the appended `""` has type text `""` (literal, not `string`); AE15 (the #4804 panic refuses one file and the next file is answered). These re-fire on every `typescript` bump. +- **Verification:** local `pnpm --filter @systemfsoftware/stryker-js-plugin-interface typecheck` and `... test`, then the same for the checker package; the checker's `build` once, for the `PLUG-1` `deps.onlyImport` check. +- **Dogfood mutant ids:** none yet. The two workflows and the schema match the checker's `mutate` globs and get ids at the first main mutation run after merge; `type-query.handle.ts` is shell, outside the globs. plugin-interface is not a dogfood project (`mutation.yml:29`). + +### U12. Lane: `wrong-not-assignable` gate + +- **Goal:** every wrong `NotAssignable` on the corpus fails CI, with enough detail for Stream I's drop-soundness proof. +- **Requirements:** R18. +- **Dependencies:** U10, U11. +- **Files:** `test/checker-parity/src/run-side.ts` (branch side: before checking a project, call `TypeQueryLive` once per file with every mutant of a context-free replacement as a candidate at its site); `src/Parity.schema.ts` (`TypeAnswer` and `WrongNotAssignable` lines); `src/compare-sides.workflow.ts` (R18 gate and per-reason counts); its property test. +- **Approach:** KTD12. The query runs on the driver's own tsgo server, so it adds one server per leg and no checker work. Answers are joined to verdicts by mutant id. +- **Test scenarios (property):** a `NotAssignable` answer joined to a CompileError verdict passes; joined to Survived, Killed, NoCoverage, Timeout or Ignored it fails and names the mutant; `Assignable` and `Unknown` never fail the gate; counts per reason add up to the number of queried mutants. +- **Verification:** local driver typecheck and tests; CI: the lane's run on layer 9. +- **Dogfood mutant ids:** none. + +### Type-query surface for Stream I + +**Status: provisional until Stream I confirms.** Schema version 1. Port entry `@systemfsoftware/stryker-js-plugin-interface/type-query` (location provisional, OQ-P8); tsgo implementation `@systemfsoftware/stryker-js-typescript-checker/type-query` exporting `TypeQueryLive`. Written as Effect Schema; field names are the wire names. + +```ts +// Port: Context service, no implementation in the port's entry. +interface TypeQueryShape { + readonly query: (request: TypeQueryRequest) => Effect +} + +TypeQueryRequest = Struct({ + version: Literal(1), + tsconfigFile: String, // absolute path; one tsgo project per request + files: NonEmptyArray(Struct({ + fileName: String, // absolute path, a file of that project + content: String, // the text the instrumenter parsed + sites: Array(Struct({ + siteId: String, // caller-chosen, unique within the file + location: Location, // plugin-interface Location: the node a mutator replaces + candidates: Array(Struct({ candidateId: String, text: String })), + })), + })), +}) + +TypeQueryResponse = Struct({ + version: Literal(1), + files: Array(Union([ + TaggedStruct('FileAnswered', { + fileName: String, + sites: Array(Struct({ + siteId: String, + siteType: Option(String), // typeToString(getTypeAtLocation(site)) + contextualType: Option(String), // typeToString(getContextualType(site)) + candidates: Array(Struct({ candidateId: String, answer: TypeAnswer })), + })), + }), + TaggedStruct('FileRefused', { + fileName: String, + reason: Literals(['not-in-project', 'server-crashed']), + }), + ])), +}) + +TypeAnswer = Union([ + TaggedStruct('Assignable', { candidateType: String }), // assignable to the contextual type; NOT "compiles" + TaggedStruct('NotAssignable', { candidateType: String, contextualType: String }), + TaggedStruct('Unknown', { + reason: Literals([ + 'candidate-not-context-free', + 'site-not-found', + 'site-not-expression', + 'no-contextual-type', + 'error-type', + 'instantiable-target', + 'overloaded-or-generic-call', + ]), + }), +]) + +TypeQueryRefused = TaggedError('TypeQueryRefused', { + reason: Literals(['unsupported-version', 'project-open-failed']), +}) +``` + +Contract for callers: + +1. Advisory only. The checker never reads an answer; its verdict stays authoritative (R17). Only `NotAssignable` may justify dropping a candidate; `Assignable` and `Unknown` say nothing about compiling. +2. One request opens or reuses one tsgo server for its `tsconfigFile` and makes one probe snapshot for all its files. Batch all of a file's sites in one request; batching several files in one request saves the per-request update. +3. Cost per request, measured (KTD11): one probe update of 6-10 ms, plus about 30 ms for a 60-line file and about 0.45 s for an 1800-line file with every expression site queried. Opening a project costs about 0.2-0.26 s once. +4. Answers cover the context-free grammar only (KTD11); every other candidate is `Unknown` (`candidate-not-context-free`). On main's corpus that grammar covers 91.5% of CompileError mutants. +5. A server crash refuses the file in flight (`server-crashed`) and does not fail the request; the next file is answered on a fresh server. +6. Wrong `NotAssignable` answers are detected in CI by the checker-parity lane (R18): each is a `wrong-not-assignable` NDJSON line in the lane artifact naming the site, candidate, both types and the answer path, and any such line fails `checker-parity-report`. The lane also writes per-reason counts and how many CompileError mutants were answered `NotAssignable` (the query's recall). Stream I's drop-soundness proof can consume both from the lane artifact. +7. A new field or answer reason is a schema version bump; a caller that receives a version it does not know refuses it. + +### Test Admission + +Every test the plan proposes, checked against `skill://test-layer-selection` (default REFUSE). The checker's real tsgo server is a dependency's own transport, not a CLI of this repo, so integration tests that open it pass the in-process gate the same way the checker's existing `tests/*.integration.test.ts` do (`boundary-testing` `real-system-oracles.md`). + +| Test | Cell / layer | Verdict | Reason | +| ------------------------------------------------------- | ------------------------ | -------- | ---------------------------------------------------------------------------------------------- | +| U2 AE10 | shell, integration | Admitted | Regression of a known bug (Problem Frame) | +| U2 importer-line scenario (removed) | shell, integration | Refused | Same rendering path as AE10; the lane compares other-file positions exactly | +| U2 and U7 reason properties on `check-mutants.workflow` | workflow, property | Admitted | Workflow cells take property tests only; kill three Survived ids | +| U3 count integration test (removed) | shell, integration | Refused | Pins the algorithm's internal update count; Done 1 is measured by the lane, which refuses zero | +| U4 decision properties | workflow, property | Admitted | Pure decision | +| U5 AE1-AE4, AE7-AE9, two option values | shell, integration | Admitted | Each fails if the shortcut is taken wrongly; boundary semantics of tsgo | +| U6 round and group properties | workflow, property | Admitted | Pure decision | +| U6 two-file `updateSnapshot` pin | dependency pin | Admitted | Rounds rest on it; re-fires on `typescript` bumps | +| U6 AE5 and `rounds` fixture verdict identity | shell, integration | Admitted | Two-mode differential on real tsgo | +| U6 "fewer updates than mutants" (removed) | shell, integration | Refused | Performance claim; the lane measures it | +| U7 AE11 | shell, integration | Admitted | Mixed extensions are the plausible bug in one-build emit | +| U7 "one emit build per call" (removed) | shell, integration | Refused | Counter echo; the lane measures it | +| U9 compare and shard properties | workflow, property | Admitted | Pure decision | +| U11 grammar and answer properties | workflow, property | Admitted | Pure decision | +| U11 v1 schema codec laws | schema | Admitted | Required for non-error schemas | +| U11 differential spec (query vs checker verdict) | `*.differential.test.ts` | Admitted | Two implementations on the same inputs; the checker is the reference | +| U11 pins (inert append, literal type, #4804 crash) | dependency pin | Admitted | KTD10 and KTD11 rest on them | +| U12 gate properties | workflow, property | Admitted | Pure decision | + +--- + +## Verification Contract + +| Gate | Local (S1) | CI | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| Format (`START-1`) | `pnpm format:check` (dprint; touched files) | `check` job | +| Typecheck (`START-2`) | `pnpm --filter @systemfsoftware/stryker-js-typescript-checker typecheck`; the driver package when touched | `check` job | +| Tests (`START-3`) | `pnpm --filter @systemfsoftware/stryker-js-typescript-checker test`; the driver package's tests when touched | `check` job, `e2e` job | +| Build and gates (`START-4`) | at most one local build at a time, only when a test needs `dist/` | `pnpm check:ci` in `check` | +| Changesets (`START-5`) | - | `Changeset Check` | +| Dogfood (`START-6`) | unchanged; this plan does not touch the flake input or overrides | mutation lane on main | +| Verdict parity, shortcut proof, speed (R7-R10) | never | `checker-parity-report` | +| Type-query port and implementation (R15-R17) | `pnpm --filter @systemfsoftware/stryker-js-plugin-interface typecheck` and `... test`; checker package tests (differential spec and pins open one short-lived tsgo server each and close it) | `check` job | +| No wrong `NotAssignable` on the corpus (R18) | never | `checker-parity-report` | +| No pre-7 compiler in the product (R1, R12) | `git grep -nE "from 'typescript'" -- 'packages/*/src'` returns nothing; `pnpm why typescript@5.9.3 --prod` lists no workspace package | same commands in the PR description | + +Done 4's threshold: `sum(branch checker phase) < sum(main checker phase)` over all shards of one lane run, from the NDJSON phase lines. Done 1's measurement: snapshot updates and emit builds per mutant, per side, printed by `compare` from the same run. + +## Definition of Done + +- Contract Done 1-5 hold, each with its evidence: Done 1 from `compare`'s per-side ratios on a main-push lane run after layer 7 (and 8 if shipped); Done 2 and 3 from a green `checker-parity-report` with a non-zero shortcut count overall and for the `isolatedDeclarations` fixture; Done 4 from the same run's speed line; Done 5 from the R1/R12 commands and green CI on the stack's top head. +- Every layer is green on its own head, and its head SHA is reported to the operator. +- R11 and the changelog correction are released through changesets. +- No local mutation, corpus or e2e run happened; no long-lived `tsc --api`, `--lsp` or watch process is left running. +- Cleanup: no abandoned-approach code, throwaway probes (for example `.scratch/`) or unused exports remain in the diff; every removed symbol passes `DEL1`'s `git grep` check. + +--- + +## Stream D: checker files this plan rewrites + +Paths relative to `packages/stryker-js-typescript-checker/`. + +- Rewritten or heavily changed: `src/ts-compiler.handle.ts` (1817 lines; `check`, `checkOne` and TCE classification move out to `src/check-rounds.ts`), `src/group-mutants.workflow.ts`, `src/tce-emit.ts`, `src/Checker.cell.ts`. +- Edited: `src/CheckerCommands.schema.ts`, `src/Checker.schema.ts`, `schema/typescript-checker-options.json`, `README.md`, `tests/check-mutants.integration.test.ts`. +- New: `src/check-rounds.ts`, `src/edit-site.ts`, `src/decide-importer-shortcut.workflow.ts`, `src/plan-check-rounds.workflow.ts`, `src/type-query.handle.ts`, `src/classify-candidate.workflow.ts`, `src/answer-type-query.workflow.ts`, their `src/__tests__/*.property.test.ts`, `tests/check-rounds.integration.test.ts`, `tests/type-query.differential.test.ts`, `tests/type-query-pins.integration.test.ts`, fixtures under `tests/__fixtures__/`. Properties are added to the existing `src/__tests__/check-mutants.workflow.property.test.ts`. +- Entry points: `package.json` `exports` and `tsdown.config.ts` gain `./type-query`. +- Outside the checker: `.github/workflows/ci.yml`, new `test/checker-parity/`, `packages/stryker-js-plugin-interface` (`./type-query` entry, `TypeQuery.schema.ts`, `TypeQuery.service.ts`, api report), `.changeset/*`. + +## Dogfood mutant ids + +Source: main's latest successful Mutation run, run 37960922409 (#416) on `1e1de6d05`, artifact `mutation-report-416`, file `packages/stryker-js-typescript-checker/stryker-incremental.json` (downloaded with `gh api /repos/systemfsoftware/stryker-js-effect/actions/artifacts/11631605715/zip`). The checker's dogfood mutates only `src/**/*.workflow.ts` and `src/**/*.schema.ts` (`stryker.config.ts:12-19`), so the imperative shell (`ts-compiler.handle.ts`, `tce-emit.ts`, `Checker.cell.ts`, the new `check-rounds.ts`) has no mutant ids. Its behaviour is proven by integration tests and the parity lane. + +| Id | Main status | Location | Targeted by | +| -------------------------------------------------------------------------------------------------- | --------------------- | ------------------------------------------------ | -------------------------------------- | +| `47892392410b616f` | Survived | `src/check-mutants.workflow.ts:56` (`'\n'` join) | U2 join property | +| `7f50c38d0d04fa7f` | Survived | `src/check-mutants.workflow.ts:42` | U7 exact-reason property (`original`) | +| `5cd5dc3a795c02d0` | Survived | `src/check-mutants.workflow.ts:43` | U7 exact-reason property (`sibling`) | +| `f68b6e883cbcc324`, `6f609696e10d7efb`, `752592eb349dc8da` | Killed | `src/trace-affected-files.workflow.ts:62` | U5 fallback scenarios keep them killed | +| `620a93c10b22628a`, `ba0a7d59ebb4482d`, `1e395b6653e16a69` | Killed | `src/request-affected-files.workflow.ts:34` | U5 fallback scenarios keep them killed | +| `62022de61dbfc17f`, `25c843d6bcc5ba1d`, `ab0fe07b72c193ad`, `e9f541378e920557`, `17563f7f061cc8d9` | Killed ×4, NoCoverage | `src/group-mutants.workflow.ts` | Retired by the U6 rewrite | + +New workflow and schema files (U4, U6, the U9 package is not dogfooded) get ids at the first main mutation run after they merge; the plan cannot cite them earlier because ids digest the file's content (KTD1 of the 2026-09-29 plan). + +## Outstanding Questions + +### Open questions for the supervisor + +None blocks U1-U4. Each has a default the plan follows unless overruled. + +- OQ-P1. **R7 position rule (blocks U10's gate).** Main renders every reason against the last-applied mutant's text (Problem Frame), and rounds change which text is last, so byte-equal reasons with main are impossible once U6 lands. Default: compare status exactly and reason text exactly except the `(line,col)` of diagnostics in the mutant's own file; U2's AE10 test proves the branch's positions are correct. Alternative: keep main's rendering bug and forbid multi-member rounds, which gives up Done 1's batching. +- OQ-P2. **Main-side Done 1 counts are derived, not observed.** Main's checker has no counters. Default: main's updates = check calls + mutants + re-splices (`ts-compiler.handle.ts:1656,1485,1489`) and main's emit builds = (check call, file with a passing mutant) pairs (`:1612-1622`), computed from the lane's observed calls, mutants and verdicts, with re-splices taken from the branch side's counter (a re-splice depends only on the mutant's text). Alternative: patch main's checker inside the lane with counters, which measures a checker that is not main's. +- OQ-P3. **R8 proven against main (KTD9).** Default: no third corpus pass with the shortcut forced off. Alternative: a third pass with `importerCheck: 'always'`, about 50% more lane time. +- OQ-P4. **Lane cost.** Default: six shards, the KTD8 cache, path filter per B4 plus `test/checker-parity/`, `pnpm-lock.yaml` and `ci.yml`, and a stop at eight shards over 40 minutes each. The extra paths are the lane's own code, the TypeScript version, and the lane's definition. +- OQ-P5. **Branch protection.** The owner adds `checker-parity-report` as a required check; this plan cannot read or change protection (`docs/solutions/workflow-issues/matrix-legs-rename-the-required-status-check.md:63`). +- OQ-P6. **TCE materiality threshold.** Default: U7 ships when emit time is at least 5% of the branch checker phase in the lane at layer 7. +- OQ-P7. **New user option `typescriptChecker.importerCheck`.** A public option (minor bump) that exists as the escape hatch and the local test reference. Alternative: no option, with an internal runtime switch only, which leaves users no way to turn the shortcut off if a hole appears. +- OQ-P8. **Where the type-query port lives (blocks U11 landing; Stream A owns the split).** Default: a declared `./type-query` entry of `@systemfsoftware/stryker-js-plugin-interface`, which the instrumenter already depends on. Alternative: a new port-only package; Stream A decides if its split already plans one. +- OQ-P9. **Shape confirmation from Stream I (blocks marking the surface non-provisional).** Default: the v1 shape in "Type-query surface for Stream I". Points for I to confirm: line-and-column `Location` rather than offsets; one tsconfig per request; whether I needs per-candidate types in `Unknown` answers. +- OQ-P10. **Scope of answers in this unit.** Default: context-free candidates only (91.5% of main's CompileError mutants). Alternative: also build the in-place probe for scope-dependent candidates now, which adds a round planner like U6's to U11 and several snapshot updates per file. + +### Deferred to Implementation + +- `GROUP_MUTANT_BOUND`'s final value and the shard count, from lane timings. +- Whether `Plugin.CheckerRpcs` and the engine's `Worker` exports suffice for the driver, or an engine export is added (Assumptions). +- The exact attribute names, kept under the `typescript.` prefix the check span already uses. + +## Sources + +- Origin requirements and measurements: `docs/brainstorms/2026-10-09-2030-perf-typescript-checker-tsgo-native-plan.md`. +- Contract and rulings: `.omp-brief/unit-h-contract.md`; `.omp-brief/rulings-brainstorm.md`; root and repo `AGENTS.md`; `CONSTITUTION.md`. +- Pack rules (`.compound-engineering/config.yaml`): `cell-architecture` (`pure-decision-workflows.md`, `sandwich-phase-order.md`, `decode-never-cast.md`); `boundary-testing` (`pin-dependency-semantics.md`, `real-system-oracles.md`, `no-mocks-on-internal-glue.md`); `schema-laws` (`tagged-unions-over-state-by-presence.md`, `cross-field-invariants-as-struct-checks.md`, `refusals-beside-generated-laws.md`, `rich-type-over-foreign-encoded.md`, `invariants-as-refinements.md`, `tests-own-no-schemas.md`). +- TypeScript 7.0.2 API: `node_modules/typescript/dist/api/proto.d.ts:84-97`; `dist/api/async/api.d.ts:334-338`; `dist/api/async/types.d.ts:288-309`; `dist/api/timing.d.ts:18-90`. +- Mutation report: run 37960922409, artifact 11631605715 (`mutation-report-416`, expires 2026-10-23). +- api-extractor: `npm view @microsoft/api-extractor` (7.59.4, `typescript: 5.9.3`); its `CHANGELOG.md:116`. +- Type-query primitives, TypeScript 7.0.2: `node_modules/typescript/dist/api/async/api.d.ts:231-246` (`getTypeAtLocation`, `getSignaturesOfType`, `getResolvedSignature`, `getTypeAtPosition`, `getContextualType`, `isTypeAssignableTo`), `:268` (`typeToString`), `:269` (`isContextSensitive`), `:414` (`Type.flags`); microsoft/typescript-go#4804 (server-killing panic in `getTypeAtLocation`, reproduced on 7.0.2); `npm view typescript time` (7.0.2 of 2026-07-08 is the latest 7.0 release). +- Type-query cost and reach: throwaway probe on this worktree (KTD11); main's four dogfood `stryker-incremental.json` reports from artifact 11631605715. + +## Document Review (report-only) + +`ce-doc-review` ran with `mode:non-interactive` and changed nothing in the plan. Reviewers: coherence, feasibility, scope-guardian (the plan has 18 requirements across 9 stacked layers), and adversarial (the plan adds a provisional cross-stream port and a CI soundness gate). The packs `cell-architecture`, `boundary-testing` and `schema-laws` were passed to every reviewer. The cross-model pass was skipped because no peer CLI (`codex`, `grok`, `cursor-agent`, `opencode`) is installed. 18 findings came back. Two were duplicates (F1/F10 and F9/F11), so 16 are distinct. The dispositions below are proposals, and none has been applied. Where a finding rests on a code fact, that fact was checked again in this session. + +| # | Sev | Finding | Evidence checked | Proposed disposition | +| --- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| F1 | P2 | U3's Unit Index row lists `test/checker-parity`, a package U9 creates two layers later (same as F10). | Unit Index U3 row and U3 Files differ. | **Apply.** Drop it from U3's row. The zero-update refusal stays in U9's compare property list, which already contains it. | +| F2 | P2 | The Summary promises one emit build per call, but KTD7 may drop U7. | KTD7, layer 8. | **Apply.** Make the Summary sentence conditional on KTD7. | +| F3 | P2 | U10's goal says R7-R10 are gated from layer 5, while the sequencing turns R8 on at layer 6 and R10 at layer 7. | Sequencing table. | **Apply.** Change the goal to "R7 from layer 5, R8 with U5, R10 with U6". | +| F4 | P3 | R12 says the api-extractor bump is "tracked in Planning Contract", but it is tracked under Deferred to Follow-Up Work. | Line 131-133. | **Apply.** Repoint R12's reference. | +| F5 | P2 | The request schema has `version: Literal(1)`, so a v2 request fails decode and never reaches the `unsupported-version` refusal that U11 tests. | Surface block. | **Apply.** Change the request to `version: Int`, so the implementation checks the version first and refuses anything that is not 1. The response keeps `Literal(1)`. | +| F6 | P3 | The dogfood-id note leaves out U11's two new workflows. | U11 Dogfood line. | **Apply.** Name U11 in the note. | +| F7 | P2 | `PLUG-1` (`deps.onlyImport`) is applied to the new `./type-query` library entry. That bundling would inline `plugin-interface`, which gives the `TypeQuery` tag a second identity, so `Layer.provide` cannot find the service. | `tsdown.config.ts:16-31`: only the `main` worker entry uses `onlyImport`/`alwaysBundle: [/./]`, while `index` and `runtime` keep workspace deps external. AGENTS.md scopes `PLUG-1` to the spawned worker bundle. | **Apply.** Build `./type-query` like `./runtime`, in the first tsdown block. `PLUG-1` keeps governing `main.mjs` only. U11's local build stays, and it checks the existing worker bundle. | +| F8 | P2 | The driver calls an `init` RPC that does not exist. A project whose dry run fails kills worker boot, and R6 has no skip rule for that case. | `PluginRpcs.service.ts:99-103` (`check`, `group`, `digest`). `CheckerRuntime.service.ts:100` runs `service.init` while the layer builds. | **Apply.** In KTD5 and U9, the step list becomes `digest`, `group`, `check`. A boot failure is recorded per project and per side, then skipped and listed under R6, so it never fails the whole leg. If the two sides boot differently on the same project, that is a parity violation. | +| F9 | P1 | The KTD8 cache can put this run's branch timings against a main side cached from an earlier run on another runner, which breaks R10's "same runner" condition (same as F11). | KTD8 has no rule for timings. | **Apply.** Cached entries carry verdicts and counts only. R10 and Done 1 sum only the projects whose two sides were both measured in this run, and the speed line prints how many projects were excluded. Push-to-main runs skip the cache, so Done 1 and Done 4 come from a same-run pass. The uncached run's cost is added to OQ-P4. | +| F12 | P2 | A speed gate with a strict `<` and no margin will flap. | U9 and HLD: each leg runs main and then branch on the same runner. | **Reject the premise and keep `<`.** Both sides run in the same leg, so runner variance between legs does not apply once F9 is applied. A margin would change Done 4's threshold ("faster than main"), and that is the contract's decision, not this plan's. Revisit if the lane flaps after layer 7. | +| F13 | P1 | R18 passes vacuously when the query answers `NotAssignable` for nothing. | R18 and U12 have no zero-count refusal, unlike R8 and AE6. | **Apply, narrowed.** `checker-parity-report` fails when a lane run, summed over all shards, has zero `NotAssignable` answers. A per-project zero is reported but does not gate, because a project with no context-free mutants legitimately answers nothing. Add a U12 property case for this. | +| F14 | P1 | KTD9 proves R8 through R7's comparison, which is weaker than R8's wording because R7 exempts own-file `(line,col)`. | R7, R8, KTD9. | **Apply.** Restate R8 as identity under R7's rule. The positions that rule exempts are covered separately by U2's AE10. | +| F15 | P1 | The 91.5% reach was measured on the dogfood `mutate` scope (workflow and schema files only), not on R6's whole corpus. | `stryker.config.ts:12-19`. Reach was computed from the four dogfood reports. | **Apply.** State the figure's scope wherever it is cited (KTD11, OQ-P10, surface item 4). U12 writes per-project answer shares on the R6 corpus at its first run. This is reported, not gated. | +| F16 | P2 | Probe seam: if a file ends in a line comment with no trailing newline, every appended candidate is swallowed into the comment, and there is no answer for a candidate that is missing from the probe. | KTD11 text: "file text followed by one `;();`". | **Apply.** The probe becomes file text, then `\n`, then the candidates. Add `candidate-not-found` to `Unknown`. Add a pin row in which the file ends in a `//` comment. | +| F17 | P2 | A provisional shape ships as an ordinary published entry. | U11 changesets. | **Apply, narrowed.** The plugin-interface changeset, the entry's JSDoc and the README say it is provisional until OQ-P9 closes. Do not add a deprecation path or a compat shim: `BREAK-1` makes Stream I's v2 an ordinary break. | +| F18 | P3 | The server count is stated two ways: one per leg (U12) and one per tsconfig (KTD10). | U12 Approach, KTD10. | **Apply.** Servers are opened per corpus project and closed at that project's end, and each leg records its peak count of live tsgo servers. | + +Residual risks the reviewers raised, noted here with no change proposed: + +- R7's own-file position exemption has no bound on how far a position can move, so AE10 is its only guard. U2 renders positions when they are produced, which removes the cause. A lane check on position magnitude would add a gate, which needs GATE1 approval. +- A candidate whose own type degrades to the error type or `any` answers `Assignable`, and the lane does not gate on `Assignable`. Callers drop only on `NotAssignable`, so the cost is lost recall, not soundness. +- The `isolatedDeclarations` fixture proves the rule fires on constructs this plan chose. Corpus diversity comes from R6, and in this repo the flag is set in no other project. +- Whether a cold, uncached main-push run fits eight shards is still unmeasured (Assumptions). F9 makes that run the source of the Done evidence, so its first timing decides OQ-P4. From 63f9ad0c5f46552d34122c8178dbc79013d1c797 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Fri, 9 Oct 2026 22:09:10 +0000 Subject: [PATCH 3/9] docs(plans): apply review dispositions and the four-layer stack Applies every document-review disposition the supervisor ruled in .omp-brief/rulings-plan.md (F1-F11, F13-F18; F13 and F17 narrowed; F12 rejected) and records OQ-P1..OQ-P10 as ruled. Replaces the nine-layer sequencing with four linear layers: docs+U1-U3, lane U8-U10, type query U11-U12, shortcut U4-U7. --- ...erf-typescript-checker-tsgo-native-plan.md | 102 +++++++++--------- 1 file changed, 49 insertions(+), 53 deletions(-) diff --git a/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md b/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md index b70726171..844187e58 100644 --- a/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md +++ b/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md @@ -15,11 +15,11 @@ origin: docs/brainstorms/2026-10-09-2030-perf-typescript-checker-tsgo-native-pla - **Objective:** A Stryker run with the TypeScript checker rejects non-compiling mutants with the same verdicts as main's checker, spends less checker time doing it, and nothing in a published package reaches a pre-7 TypeScript compiler. The tsgo-backed checker also answers advisory type queries the instrumenter can ask before it generates a mutant (B10). - **Means:** check several mutants in one TypeScript 7 snapshot when no member can change another member's verdict, skip importer re-checks for edits that an edit-location rule proves cannot change any type outside a function body, and prove both in a CI lane that runs main's checker and the branch's checker over the same rule-selected corpus (KTD1, KTD2, KTD5). Answer type queries from one probe snapshot per request on a tsgo server separate from the verdict path, and catch every wrong "not assignable" answer in the same lane (KTD10-KTD12). -- **Authority:** the unit contract `.omp-brief/unit-h-contract.md` (Done 1-5, Does-not-count, Constraints), then the supervisor rulings `.omp-brief/rulings-brainstorm.md` (B1-B9), B10 (type-query surface for Stream I, delivered by the root during planning) and S1/S2 (targeted local verification; a TS7-capable api-extractor is pre-approved and no override forces TS7 onto it), then this plan. `CONSTITUTION.md` governs any conflict (`CONST-1`). Origin requirements: `docs/brainstorms/2026-10-09-2030-perf-typescript-checker-tsgo-native-plan.md`. +- **Authority:** the unit contract `.omp-brief/unit-h-contract.md` (Done 1-5, Does-not-count, Constraints), then the supervisor rulings `.omp-brief/rulings-brainstorm.md` (B1-B9), B10 (type-query surface for Stream I, delivered by the root during planning), S1/S2 (targeted local verification; a TS7-capable api-extractor is pre-approved and no override forces TS7 onto it) and `.omp-brief/rulings-plan.md` (review dispositions, OQ-P1 to OQ-P10, the four-layer stack), then this plan. `CONSTITUTION.md` governs any conflict (`CONST-1`). Origin requirements: `docs/brainstorms/2026-10-09-2030-perf-typescript-checker-tsgo-native-plan.md`. - **Stop conditions:** stop and report if the TypeScript 7.0.2 API cannot do something a unit needs (name the missing API with evidence; never fall back to TS5); if the parity lane shows any verdict difference that the position rule in R7 does not explain; if a corpus leg cannot finish inside its time budget at eight shards. - **Execution profile:** stacked PRs on trunk `main` via `gh stack` (root `AGENTS.md` OP13b), one concern per layer, no force-push. Locally only typecheck and the touched package's tests (S1); the corpus, e2e and mutation runs happen in CI only. - **Who ships:** the implementer opens and updates the stack and reports each layer's green head SHA; the operator merges bottom-up. -- **Open supervisor items:** OQ-P1 to OQ-P10 under Outstanding Questions. None blocks starting U1-U4; OQ-P1 must be settled before U10 turns the parity gate on; OQ-P8 (where the port's entry lives) must be settled with Stream A before U11 lands, and the "Type-query surface for Stream I" shape stays provisional until Stream I confirms it. +- **Supervisor items:** OQ-P1 to OQ-P10 are ruled (`.omp-brief/rulings-plan.md`): every default is accepted. OQ-P5 (required check), OQ-P8 (where the port's entry lives, Stream A) and OQ-P9 (the surface shape, Stream I) are forwarded to the root; the defaults are built and the "Type-query surface for Stream I" stays provisional until Stream I confirms it. --- @@ -27,7 +27,7 @@ origin: docs/brainstorms/2026-10-09-2030-perf-typescript-checker-tsgo-native-pla ### Summary -Keep the checker on the `typescript@7.0.2` `unstable/*` API it already uses. Let one snapshot carry several mutants from different files when the edit-location rule proves none of them can change another's verdict, skip importer re-checks for those mutants, and run one TypeScript emit build per check call instead of one per file. A CI lane compares every corpus verdict and the checker time against a checker built from the pull request's merge-base with main. +Keep the checker on the `typescript@7.0.2` `unstable/*` API it already uses. Let one snapshot carry several mutants from different files when the edit-location rule proves none of them can change another's verdict, skip importer re-checks for those mutants, and run one TypeScript emit build per check call instead of one per file when the lane shows emit time is material (KTD7; otherwise U7 is dropped and the measurement reported). A CI lane compares every corpus verdict and the checker time against a checker built from the pull request's merge-base with main. ### Problem Frame @@ -50,7 +50,7 @@ The origin brainstorm established the gap (its Gap Table and Measured Cost): eve - R6. The corpus is every source file of each corpus project's program, minus declaration files and `node_modules`. Corpus projects are every workspace `tsconfig.app.json`, every tsconfig a checker-enabled e2e fixture configuration names (an unresolvable one is skipped and listed), and one `isolatedDeclarations` fixture. Mutants are whatever the branch's instrumenter produces for those files with the default mutator set. - R7. For every corpus mutant, the branch checker's status equals main's, and its reason text equals main's except the `(line,col)` of diagnostics located in the mutant's own file (see OQ-P1). -- R8. With the shortcut on, every corpus verdict equals the verdict of a checker that always re-checks importers. The lane reports how many mutants the shortcut decided, overall and for the `isolatedDeclarations` fixture on its own, and fails when either count is zero. +- R8. With the shortcut on, every corpus mutant's status equals main's, and its reason text equals main's except the `(line,col)` of diagnostics in the mutant's own file (R7's rule; main always re-checks importers, KTD9). Own-file positions are covered separately by U2's AE10. The lane reports how many mutants the shortcut decided, overall and for the `isolatedDeclarations` fixture on its own, and fails when either count is zero. - R9. R7 and R8 run in CI on every push to main and on every pull request that touches the checker, the instrumenter, the corpus fixtures, the parity driver, or the lane definition. Any difference fails the lane. No local corpus run is needed to land a change. **Speed** @@ -67,12 +67,12 @@ The origin brainstorm established the gap (its Gap Table and Measured Cost): eve - R15. The checker package ships, through a declared entry, a tsgo-backed implementation of a type-query port. The instrumenter reaches it only through the port, never through checker internals. The request and response carry an explicit schema version. - R16. For each requested site, batched per file, the query returns the site's type and its contextual type as text, and for each candidate replacement exactly one answer: `Assignable`, `NotAssignable` or `Unknown` with a reason. - R17. The query is advisory. No checker verdict reads a query answer, and a query failure (including a tsgo server crash) never fails or delays a check call; it turns that file's answers into a refusal. -- R18. Every `NotAssignable` answer the query gives for a corpus mutant is compared with the branch checker's verdict for the same mutant in the parity lane. A `NotAssignable` mutant whose verdict is not CompileError fails the lane and is written as a `wrong-not-assignable` line naming the site, candidate, both types and the answer's path. The lane also writes, per reason, how many answers it gave and how many CompileError mutants were answered `NotAssignable`. +- R18. Every `NotAssignable` answer the query gives for a corpus mutant is compared with the branch checker's verdict for the same mutant in the parity lane. A `NotAssignable` mutant whose verdict is not CompileError fails the lane and is written as a `wrong-not-assignable` line naming the site, candidate, both types and the answer's path. The lane fails when a run, summed over all shards, has zero `NotAssignable` answers; a project with zero answers is reported, not gated. The lane also writes, per reason, how many answers it gave, how many CompileError mutants were answered `NotAssignable`, and per corpus project the share of queried mutants answered `Assignable`, `NotAssignable` and `Unknown`. **TypeScript 5 removal** - R11. Product docs state TypeScript 7 only (`packages/stryker-js-typescript-checker/README.md:6,17`), and the 8.1.0 changelog claim about importer re-checks is corrected in the next release's notes. -- R12. `typescript@5.9.3` stays in the lockfile only as the dev dependency of `@microsoft/api-extractor`, which no published package reaches at runtime. It leaves when rushstack ships a TS7-capable release (pre-approved bump, tracked in Planning Contract). No pnpm override. +- R12. `typescript@5.9.3` stays in the lockfile only as the dev dependency of `@microsoft/api-extractor`, which no published package reaches at runtime. It leaves when rushstack ships a TS7-capable release (pre-approved bump, tracked under Deferred to Follow-Up Work). No pnpm override. ### Soundness Rule @@ -146,17 +146,17 @@ Under `isolatedDeclarations`, clause 2 holds for every function whose type reach - **KTD2. Groups are bounded sets of whole files.** `group()` packs whole files, in input order, into groups of at most `GROUP_MUTANT_BOUND` mutants (a file larger than the bound is a group by itself). The engine still checks groups concurrently across checker workers (`packages/stryker-js/src/Checker/checker-pool.handle.ts:270-289`). Callers that skip `group()` (`packages/stryker-js/src/run/deferrable-dry-run.cell.ts:51`) still get rounds inside `check()`. The bound's value is tuned from lane timings; it starts at 256. - **KTD3. Diagnostics are rendered when they are produced.** Each round renders its members' diagnostics (`describeDiagnostics`) before the next update, so `MutantCheck` carries rendered lines and `Checker.cell.ts` stops rendering after the call. This fixes R14 on its own and is required by KTD1, since rounds change which text is current at the end of a call. - **KTD4. One option, `typescriptChecker.importerCheck: 'location-rule' | 'always'`, default `'location-rule'`.** `'always'` disables both the shortcut and multi-member rounds, which reproduces main's algorithm. It is the escape hatch if a soundness hole turns up after release, and local tests use it as the reference. It lives in the `typescriptChecker` block the checker already reads (`ts-compiler.handle.ts:277-283`, `src/Checker.schema.ts:10-12`, `schema/typescript-checker-options.json`), which already feeds the program digest (`src/program-digest.schema.ts:22`), so incremental reuse never mixes verdicts from the two modes. -- **KTD5. The parity lane drives each side's shipped worker bundle over the worker protocol.** A private driver package spawns `dist/main.mjs` from each side through the engine's public `Worker` exports (`packages/stryker-js/src/Worker/mod.ts:6-13`). It passes options through `STRYKER_WORKER_DIR/options.json` (`packages/stryker-js-plugin-runtime/src/worker-options.service.ts:23-27`) and calls `init`, `group` and `check` over `Plugin.CheckerRpcs`. The bundle inlines its whole runtime closure and imports only `typescript` (`PLUG-1`), so main's bundle and the branch's bundle never share an Effect runtime in one process. Mutants come from the branch's instrumenter (`Instrument.instrument`, as in `test/e2e-core/tests/concurrency-checker.integration.test.ts:88-93`), so both sides see identical mutant ids. +- **KTD5. The parity lane drives each side's shipped worker bundle over the worker protocol.** A private driver package spawns `dist/main.mjs` from each side through the engine's public `Worker` exports (`packages/stryker-js/src/Worker/mod.ts:6-13`). It passes options through `STRYKER_WORKER_DIR/options.json` (`packages/stryker-js-plugin-runtime/src/worker-options.service.ts:23-27`) and calls `digest`, `group` and `check` over `Plugin.CheckerRpcs` (`packages/stryker-js-plugin-interface/src/PluginRpcs.service.ts:99-103`). The checker's `init` runs while the worker boots (`CheckerRuntime.service.ts:100`), so a corpus project whose dry run fails surfaces as a worker boot failure: the driver records it per project and per side and skips and lists the project under R6 instead of failing the leg, and a project that boots on one side but not the other is a parity violation. The bundle inlines its whole runtime closure and imports only `typescript` (`PLUG-1`), so main's bundle and the branch's bundle never share an Effect runtime in one process. Mutants come from the branch's instrumenter (`Instrument.instrument`, as in `test/e2e-core/tests/concurrency-checker.integration.test.ts:88-93`), so both sides see identical mutant ids. - **KTD6. Counts travel as span attributes; the driver collects them with a local OTLP/HTTP receiver.** The worker already exports spans over OTLP/HTTP JSON when `OTEL_ENABLED=true`, to `OTEL_EXPORTER_OTLP_ENDPOINT` (`src/main.ts:32`; `packages/stryker-js-plugin-runtime/src/worker-telemetry.service.ts:10-14`). The branch checker annotates `typescript-checker.compiler.check` with `typescript.snapshot_updates.count`, `typescript.resplices.count`, `typescript.tce_builds.count`, `typescript.tce.ms`, `typescript.importer_shortcut.count` and per-clause fallback counts. The driver decodes the OTLP JSON with a Schema and stores the raw spans in the lane artifact for diagnosis (`OBS-1`). Main's checker has no such attributes; its counts are derived (OQ-P2). - **KTD7. One emit build per check call, when material.** `TceEmitRequest` (`src/tce-emit.ts:13-18`) carries one `sourceExtension` for the whole request, and inputs are named by index (`:25-29`). It becomes a list of per-file entries, each input keeping its own extension and read back with its own emitted extension (`.mts`→`.mjs`, `.cts`→`.cjs`, otherwise `.js`). `tsc` emits per-file extensions within one project, and each synthetic input already lives in the same temp directory with the same package scope as today, so module format detection per file does not change. The change ships if the lane at U6 shows `typescript.tce.ms` at 5% or more of the branch checker phase (OQ-P6). Otherwise the measurement is recorded in the PR and U7 is dropped. -- **KTD8. Verdict reuse across lane runs.** A side's verdicts for one corpus project are cached under the key (sha256 of that side's checker `src/` tree and `typescript` version, the project's `ProgramDigest` from the checker's `digest` RPC, the mutant-set digest). Main's side changes only when the merge-base moves; the branch side's previous-main results are the next PR's main-side entries. Unchanged projects then cost nothing on re-push. This uses the same digest the engine already uses for verdict reuse (`ts-compiler.handle.ts:333-357`). -- **KTD9. R8 is proven against main, not by a third run.** Main's checker always re-checks importers and never batches, so it is the "without shortcut" reference. A branch-versus-main identity under R7 is the shortcut-on versus shortcut-off identity on the same mutants, and it saves a third corpus pass. Locally, the integration tests run the same fixtures with `importerCheck: 'always'` and default and compare (OQ-P3). -- **KTD10. The type query runs on its own tsgo server, in the process that calls it.** The port's implementation opens a separate `API` instance (its own `tsc --api` child process) per tsconfig and closes it when the caller's scope closes. A panicking request kills only that server: tsgo 7.0.2 panics on `getTypeAtLocation` for an array literal contextually typed by an empty tuple, and "the panic kills the tsgo server process, so every later request on the same `API` instance fails too" (microsoft/typescript-go#4804, closed upstream 2026-08-28; 7.0.2 is the latest 7.0 release on npm as of 2026-10-09). Sharing the checker worker's server would let a query take down verdicts, so the query never goes through the checker worker's RPC. On a crash the file in flight is refused `server-crashed`, the server is reopened once, and the remaining files are answered. -- **KTD11. A replacement gets a type from one probe snapshot per request, built by appending inert statements.** For each requested file, the probe text is the request's file text followed by one `;();` statement per distinct context-free candidate. An expression statement of a literal declares nothing and changes no other type, so every original site keeps its type and contextual type (pinned by a test, U11). The implementation then queries, in that one snapshot: site nodes found by exact span (the walk `nodeSpanOf`/`hasSpanOf` already do, `ts-compiler.handle.ts:794-823`), `getTypeAtLocation(siteNodes)` batched (`dist/api/async/api.d.ts:231-232`), `getContextualType(site)` per site, pipelined (`:239`), `getTypeAtLocation(candidateNodes)` batched for the appended candidate expressions, `isTypeAssignableTo(candidateType, contextualType)` per pair, pipelined (`:246`), and `typeToString` for the reported types (`:268`). Types are snapshot-scoped handles, so source and target must come from the same snapshot; this is why candidates are appended to the file rather than probed in a separate snapshot. `getTypeAtPosition(file, positions)` (`:235-236`) is not used for sites, since it returns the innermost token's type (for `a + b`, the type of `a`). +- **KTD8. Verdict reuse across lane runs.** A side's verdicts and counts for one corpus project are cached under the key (sha256 of that side's checker `src/` tree and `typescript` version, the project's `ProgramDigest` from the checker's `digest` RPC, the mutant-set digest). Main's side changes only when the merge-base moves; the branch side's previous-main results are the next PR's main-side entries. Unchanged projects then cost nothing on re-push. Restored rows are tagged `cached` and carry no phase time: R10's speed sum and Done 1's per-mutant ratios count only projects whose two sides were both measured in the current run, and the speed line prints how many projects it excluded. Push-to-main runs skip the cache, so the run that supplies Done 1 and Done 4 is a same-run pass. This uses the same digest the engine already uses for verdict reuse (`ts-compiler.handle.ts:333-357`). +- **KTD9. R8 is proven against main, not by a third run.** Main's checker always re-checks importers and never batches, so it is the "without shortcut" reference. A branch-versus-main identity under R7's rule is the shortcut-on versus shortcut-off identity on the same mutants under that same rule, which is what R8 states, and it saves a third corpus pass. Locally, the integration tests run the same fixtures with `importerCheck: 'always'` and default and compare (OQ-P3). +- **KTD10. The type query runs on its own tsgo server, in the process that calls it.** The port's implementation opens a separate `API` instance (its own `tsc --api` child process) per tsconfig and closes it when the caller's scope closes; the lane driver opens one per corpus project and closes it at that project's end. A panicking request kills only that server: tsgo 7.0.2 panics on `getTypeAtLocation` for an array literal contextually typed by an empty tuple, and "the panic kills the tsgo server process, so every later request on the same `API` instance fails too" (microsoft/typescript-go#4804, closed upstream 2026-08-28; 7.0.2 is the latest 7.0 release on npm as of 2026-10-09). Sharing the checker worker's server would let a query take down verdicts, so the query never goes through the checker worker's RPC. On a crash the file in flight is refused `server-crashed`, the server is reopened once, and the remaining files are answered. +- **KTD11. A replacement gets a type from one probe snapshot per request, built by appending inert statements.** For each requested file, the probe text is the request's file text, then a newline, then one `;();` statement per distinct context-free candidate; the newline keeps a trailing line comment from swallowing the appended statements, and a candidate whose node is still not found in the probe answers `Unknown` (`candidate-not-found`). An expression statement of a literal declares nothing and changes no other type, so every original site keeps its type and contextual type (pinned by a test, U11). The implementation then queries, in that one snapshot: site nodes found by exact span (the walk `nodeSpanOf`/`hasSpanOf` already do, `ts-compiler.handle.ts:794-823`), `getTypeAtLocation(siteNodes)` batched (`dist/api/async/api.d.ts:231-232`), `getContextualType(site)` per site, pipelined (`:239`), `getTypeAtLocation(candidateNodes)` batched for the appended candidate expressions, `isTypeAssignableTo(candidateType, contextualType)` per pair, pipelined (`:246`), and `typeToString` for the reported types (`:268`). Types are snapshot-scoped handles, so source and target must come from the same snapshot; this is why candidates are appended to the file rather than probed in a separate snapshot. `getTypeAtPosition(file, positions)` (`:235-236`) is not used for sites, since it returns the innermost token's type (for `a + b`, the type of `a`). - The context-free grammar is the set of candidates whose type does not depend on where they sit: string, no-substitution template, numeric and bigint literals (with an optional leading `-`), `true`, `false`, `null`, `undefined`, `{}`, `() => undefined` and `() => {}`. Array literals are excluded (`[]` takes a tuple type in tuple context but is `never[]` out of context, AE13), and so are object literals with members and functions with parameters or literal bodies (their types widen out of context). A pure workflow decides membership from the candidate text, so a candidate that is not in the grammar is never appended and cannot corrupt the probe parse. - An answer is `NotAssignable` only when every one of these holds; otherwise it is `Unknown` with the first failing reason: the site node exists and is an expression; it has a contextual type; the contextual type is not the error type; neither it nor any union or intersection constituent carries `TypeFlags.Instantiable`; the site is not an argument of a call or `new` whose callee has more than one signature or whose resolved signature has type parameters (`getResolvedSignature`, `:234`; `getSignaturesOfType`, `:233`). `Assignable` means only "assignable to the contextual type", never "compiles": a site with a contextual type can still break flows downstream, so callers drop on `NotAssignable` only. - Measured on this worktree, `typescript@7.0.2`, the checker package's `tsconfig.app.json` (141 program files), eight context-free candidates (throwaway `.scratch/type-query-cost.mjs`, deleted after measuring): opening the project 193-261 ms once per tsconfig; the probe update 5.7-9.5 ms per file; for `src/group-mutants.workflow.ts` (60 lines, 41 expression sites) site types 3.9 ms batched, contextual types 11 ms pipelined (33 of 41 defined), candidate types under 1 ms, 264 assignability pairs 7.3 ms; for `src/ts-compiler.handle.ts` (1817 lines, 1832 sites) site types 131 ms batched cold and 16 ms warm, contextual types 85 ms (1438 of 1832 defined), 11504 pairs 216 ms (about 19 µs a pair). A whole large file costs about 0.45 s and a small one about 30 ms, against 13-22 ms for one own-file check and up to 12.5 s for one importer re-check per mutant in the checker (origin Measured Cost). Pairs in a real request are one per mutant, not sites × candidates, so request cost is lower than these figures. - - Reach on main's corpus: across the four dogfood projects' latest reports, 3783 of 4136 CompileError mutants (91.5%) have a replacement in the context-free grammar, and 62.8% of context-free mutants end as CompileError. That bounds what this unit's query can answer; how many it answers `NotAssignable` is measured by the lane (R18). + - Reach on the dogfood `mutate` scope (`src/**/*.workflow.ts` and `src/**/*.schema.ts` of the four dogfood projects, from their latest reports): 3783 of 4136 CompileError mutants (91.5%) have a replacement in the context-free grammar, and 62.8% of context-free mutants end as CompileError. R6's corpus is about twice that file set and includes the imperative shell, so the figure is unverified for the corpus until U12's first lane run writes per-project answer shares (R18). - **KTD12. A wrong `NotAssignable` is caught where every mutant is already checked.** The parity lane checks every corpus mutant with the branch checker anyway, so the driver queries every context-free mutant before checking and compares the answer with the verdict at no extra checker cost. Locally, a differential spec feeds every `NotAssignable` answer on fixture files through the real checker. Stream I's drop-soundness proof consumes the lane's `wrong-not-assignable` lines and per-reason counts; the port itself carries no confidence score. ### High-Level Technical Design @@ -182,41 +182,36 @@ flowchart TD L --> M[annotate counts on the check span] ``` -The lane, per shard: build main's checker at the merge-base in a worktree; build the branch checker; for each corpus project in the shard, run main's worker then the branch worker over the same mutants; write verdict, phase and count lines as NDJSON plus raw spans. A report job merges all shards and fails on any R7/R8/R10 violation. +The lane, per shard: build main's checker at the merge-base in a worktree; build the branch checker; for each corpus project in the shard, run main's worker then the branch worker over the same mutants; write verdict, phase and count lines as NDJSON plus raw spans. A report job merges all shards and fails on any R7, R8, R10 or R18 violation that the current layer has turned on. ### Assumptions Agent bets, not confirmed by the supervisor: - The engine's `Worker` exports plus `Plugin.CheckerRpcs` are enough to drive a checker worker outside a Stryker run. If a piece is internal to `packages/stryker-js`, U9 exports it from `Worker/mod.ts` (a minor engine bump) instead of re-implementing the protocol. -- A full corpus pass of main's checker fits in eight 40-minute shards on `ubuntu-latest`. Today's evidence: main's dogfood report holds 6336 mutants for 117 files of `packages/stryker-js` alone, 58% of them CompileError. The first main-push run of U10 measures it; if a shard exceeds the budget at eight shards, that is a stop condition. +- A full corpus pass of main's checker fits in eight 40-minute shards on `ubuntu-latest`. Today's evidence: main's dogfood report holds 6336 mutants for 117 files of `packages/stryker-js` alone, 58% of them CompileError. The first main-push run of U10, which skips the KTD8 cache, measures it; if a shard exceeds the budget at eight shards, that is a stop condition. - `@opentelemetry/exporter-trace-otlp-http` posts JSON by default, so a schema-decoded JSON receiver is enough. - Reading a file's `SourceFile` through the snapshot (`project.program.getSourceFile`) costs milliseconds per file, so reading original trees once per call is cheap next to the importer checks it saves (origin Measured Cost: own-file check 13-22 ms, importer re-check up to 12.5 s). - Stream I calls the type query in the engine process during the instrument phase, before the dry run starts checker workers, so the extra tsgo server never overlaps the checker workers' servers (S1 memory). If I calls it elsewhere, the port is unchanged but the memory assumption must be re-checked. ### Sequencing and Stacking -One stack on trunk `main` (`gh stack`), each layer green on its own and leaving main releasable. New code lands inert until a higher layer wires it in. +One stack on trunk `main` (`gh stack`), four linear layers, each green on its own and leaving main releasable. New code lands inert until a higher layer wires it in. When main moves, main is merged into layer 1 and each layer merges its parent upward; plain `git push` only. -| Layer | Branch | Units | Turns on | -| ----- | ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------- | -| 1 | `stryker/tsgo-checker` | docs+U1 | Brainstorm, this plan, README TS7-only, changelog correction | -| 2 | `stryker/tsgo-checker-positions` | U2 | Diagnostics rendered when produced | -| 3 | `stryker/tsgo-checker-counters` | U3 | Count attributes on the check span | -| 4 | `stryker/tsgo-checker-parity` | U8, U9 | Driver package and fixture, not yet run in CI | -| 5 | `stryker/tsgo-checker-parity-lane` | U10 | Lane on: verdict parity gate (R7), timings and counts reported, no speed or zero-count gate yet | -| 6 | `stryker/tsgo-checker-shortcut` | U4, U5 | Shortcut on for solo rounds; lane zero-count refusal (R8) on | -| 7 | `stryker/tsgo-checker-rounds` | U6 | Multi-member rounds; speed gate (R10) on | -| 8 | `stryker/tsgo-checker-tce-build` | U7 | One emit build per call (only if KTD7's threshold is met; otherwise the layer is dropped and the measurement is reported) | -| 9 | `stryker/tsgo-checker-type-query` | U11, U12 | Port entry and tsgo implementation (inert until Stream I calls it); lane `wrong-not-assignable` gate on | +| Layer | Branch | Base | Units | Turns on | +| ----- | ---------------------------------- | ------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `stryker/tsgo-checker` | `main` | docs, U1, U2, U3 | Brainstorm, this plan, README TS7-only, changelog correction; diagnostics rendered when produced; count attributes on the check span | +| 2 | `stryker/tsgo-checker-parity-lane` | layer 1 | U8, U9, U10 | Driver, fixture and lane; verdict parity gate (R7) on, timings and counts reported | +| 3 | `stryker/tsgo-checker-type-query` | layer 2 | U11, U12 | Port entry and tsgo implementation (inert until Stream I calls it); lane `wrong-not-assignable` gate and its zero-answer refusal (R18) on | +| 4 | `stryker/tsgo-checker-shortcut` | layer 3 | U4, U5, U6, U7 | Shortcut, multi-member rounds, one emit build per call (U7 only if KTD7's threshold is met); lane zero-count refusal (R8) and speed gate (R10) on | -Layer 5 proves the lane on a branch checker whose only change from main is U2 and U3, so a non-empty diff there is a lane bug, not a checker bug. +Layer 2 proves the lane on a branch checker whose only change from main is U2 and U3, so a non-empty diff there is a lane bug, not a checker bug. Layer 3 sits below the shortcut so Stream I can build against the port early. ### Risks | Risk | Mitigation | | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Corpus runtime of main's checker exceeds CI budget | Shard by file hash; KTD8 cache; first main-push run measures; stop condition at eight shards | +| Corpus runtime of main's checker exceeds CI budget | Shard by file hash; KTD8 cache on pull requests; the uncached main-push run measures; stop condition at eight shards | | A soundness hole the rule misses | Lane compares every corpus verdict against main; `importerCheck: 'always'` escape hatch; AE2/AE3/AE9 regression fixtures | | tsgo's `isolatedDeclarations` diagnostics are known-incomplete | The rule never trusts the flag (clause 2 is checked per site) | | Reading original trees disposes or invalidates `RemoteSourceFile` handles across updates | Facts are extracted into plain data (spans, kinds, header text) before the next update; no `Node` survives an update | @@ -248,7 +243,7 @@ Layer 5 proves the lane on a branch checker whose only change from main is U2 an | ---- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------- | | U1 | TS7-only docs, changelog correction | `packages/stryker-js-typescript-checker/README.md`, `.changeset/*` | - | | U2 | Render diagnostics when produced | `src/Checker.cell.ts`, `src/ts-compiler.handle.ts`, `tests/check-mutants.integration.test.ts` | U1 | -| U3 | Count updates, re-splices, emit builds | `src/ts-compiler.handle.ts`, `src/tce-emit.ts`, `test/checker-parity` zero-count refusal | U2 | +| U3 | Count updates, re-splices, emit builds | `src/ts-compiler.handle.ts`, `src/tce-emit.ts` | U2 | | U4 | Edit-site facts and the Soundness Rule | `src/edit-site.ts`, `src/decide-importer-shortcut.workflow.ts`, `src/CheckerCommands.schema.ts` | U3 | | U5 | Shortcut in the solo path, option | `src/ts-compiler.handle.ts`, `src/Checker.schema.ts`, `schema/typescript-checker-options.json`, tests | U4, U10 | | U6 | Rounds and file-bounded groups | `src/group-mutants.workflow.ts`, `src/plan-check-rounds.workflow.ts`, `src/check-rounds.ts`, `src/ts-compiler.handle.ts` | U5 | @@ -316,7 +311,7 @@ Paths under `src/`, `tests/` and `schema/` are relative to `packages/stryker-js- - **Goal:** a solo mutant that meets the rule passes on own-file diagnostics; users can turn it off. - **Requirements:** R4, R5, R8 (local proof). - **Dependencies:** U4; U10 (the lane must be running before the shortcut changes any verdict path). -- **Files:** `src/ts-compiler.handle.ts` (`checkOne` `:1472-1497`, `checkedIn` `:1459-1470`); `src/Checker.schema.ts` (`:10-12`); `schema/typescript-checker-options.json`; `README.md` (option section); `tests/check-mutants.integration.test.ts`; new `tests/__fixtures__/importer-shortcut/`; `test/checker-parity` compare workflow (zero-count refusal on); a changeset (minor); `etc/stryker-js-typescript-checker.api.md` via `api:update` if `TypescriptCheckerOptionsSchema` is on the public surface. +- **Files:** `src/ts-compiler.handle.ts` (`checkOne` `:1472-1497`, `checkedIn` `:1459-1470`); `src/Checker.schema.ts` (`:10-12`); `schema/typescript-checker-options.json`; `README.md` (option section); `tests/check-mutants.integration.test.ts`; new `tests/__fixtures__/importer-shortcut/`; `test/checker-parity` compare workflow (zero-count refusal on, layer 4); a changeset (minor); `etc/stryker-js-typescript-checker.api.md` via `api:update` if `TypescriptCheckerOptionsSchema` is on the public surface. - **Approach:** `checkOne` reads the mutant file's original tree before applying (the call already starts from original text after U6; until then the solo path reads it before `applyMutant`), extracts facts again after the update, and runs the U4 decision. `ShortcutTaken` with no own errors skips `beyondOwnErrorsOf`. `importerCheck: 'always'` skips the decision entirely. - **Patterns:** `boundary-testing` `real-system-oracles.md` (tests run real tsgo against fixture files); `boundary-testing` `no-mocks-on-internal-glue.md`. - **Test scenarios:** AE1 (one shortcut decision on the span, no importer diagnostics requested); AE2, AE3, AE9 (CompileError naming the importer: a wrongly taken shortcut makes them pass, so they fail on the plausible bug); AE4; AE7 (hand-built wire whose replacement closes the body); AE8; every fixture run once per `importerCheck` value with identical verdicts. @@ -332,7 +327,7 @@ Paths under `src/`, `tests/` and `schema/` are relative to `packages/stryker-js- - **Approach:** KTD1 and KTD2. `lastMutants`/`lastMutatedFileNames` (`:1667-1671`) become the last round's members and files. The re-splice, fallback-to-solo and render steps follow the flowchart. `group()` stays a pure, synchronous decision over wires. - **Patterns:** `cell-architecture` `pure-decision-workflows.md`; `boundary-testing` `pin-dependency-semantics.md`: one pin test drives `typescript/unstable/async` directly with a virtual file system and asserts that one `updateSnapshot` with two changed files serves both new texts, and that a file's diagnostics are recomputed after an update. It re-fires on every `typescript` bump. - **Test scenarios:** property laws: every mutant lands in exactly one round; a multi-member round holds only eligible mutants, at most one per file; an ineligible mutant is alone; groups hold whole files and respect the bound unless one file exceeds it. Integration: AE5; the `rounds` fixture (several files, eligible mutants including one with an own-file error and one AE7-style breaker) gives identical verdicts with the default option and with `'always'`. -- **Verification:** local package typecheck and tests; CI lane (R7, R8, R10 all gating). +- **Verification:** local package typecheck and tests; CI lane (R7, R8, R10 all gating from layer 4). - **Dogfood mutant ids:** the rewrite retires every current id in `group-mutants.workflow.ts`, including Killed `62022de61dbfc17f`, `25c843d6bcc5ba1d`, `ab0fe07b72c193ad`, `e9f541378e920557` and NoCoverage `17563f7f061cc8d9` (`:48`, the empty-list fallback). New ids appear at the first main mutation run after merge. ### U7. One emit build per check call @@ -364,19 +359,19 @@ Paths under `src/`, `tests/` and `schema/` are relative to `packages/stryker-js- - **Requirements:** R3, R6, R7, R8, R10. - **Dependencies:** U3. - **Files:** new private package `test/checker-parity/` (`@systemfsoftware/stryker-checker-parity`): `package.json`, tsconfigs, `src/corpus.ts` (R6 discovery: workspace `tsconfig.app.json` files, the tsconfig each checker-enabled `test/e2e/testResources/*/stryker*.config.ts` names, the U8 fixture; program files listed with the TS7 `tsc --listFilesOnly`), `src/shard.ts` (file-hash sharding), `src/run-side.ts` (KTD5), `src/otlp-receiver.ts` (KTD6), `src/Parity.schema.ts` (NDJSON lines: verdict, phase, counts, refusal), `src/compare-sides.workflow.ts` (pure: R7 diff with the own-file position rule, R8 zero-count refusal overall and for the U8 fixture, R10 speed verdict, Done 1 ratios), `src/main.ts` (`run` and `compare` commands); tests under `src/__tests__/`. `pnpm-workspace.yaml` already covers `test/*`. -- **Approach:** `run --side --worker --shard k/N --out ` instruments the shard's files with the branch instrumenter, then per project spawns the worker with `typescriptChecker` options and OTLP env, calls `init`, `digest`, `group`, then `check` per group. It times each RPC, writes NDJSON, and records the KTD8 cache key per project. `compare ` exits non-zero on any violation, prints every differing mutant (id, file, line, both reasons), and writes a summary. Main-side counts are derived per OQ-P2. The driver never runs locally (S1); it refuses to start outside CI unless `--allow-local` is passed, mirroring `refuse-local-mutation.workflow.ts`. +- **Approach:** `run --side --worker --shard k/N --out ` instruments the shard's files with the branch instrumenter, then per project spawns the worker with `typescriptChecker` options and OTLP env and calls `digest`, `group`, then `check` per group (the checker's `init` runs at worker boot, KTD5). A boot failure is recorded per project and side, and the project is skipped and listed. It times each RPC, writes NDJSON, and records the KTD8 cache key per project; restored rows are tagged `cached`. `compare ` exits non-zero on any violation, prints every differing mutant (id, file, line, both reasons), and writes a summary. Main-side counts are derived per OQ-P2 and labelled "derived". The driver never runs locally (S1); it refuses to start outside CI unless `--allow-local` is passed, mirroring `refuse-local-mutation.workflow.ts`. - **Patterns:** `cell-architecture` `pure-decision-workflows.md` (compare); `cell-architecture` `decode-never-cast.md` and `schema-laws` `rich-type-over-foreign-encoded.md` (OTLP JSON and NDJSON decode through Schema; OTLP's wire shape is the Encoded side); `schema-laws` `tests-own-no-schemas.md`; `schema-laws` `invariants-as-refinements.md` (`k/N` refined at decode: 1 ≤ k ≤ N). -- **Test scenarios (property, local):** two identical sides compare clean; a single status difference fails naming the mutant; an own-file `(line,col)` difference passes and an other-file one fails; a zero shortcut count fails overall and for the fixture alone (AE6); a branch side with checked mutants and zero snapshot updates fails (U3's attribute never set); branch time ≥ main time fails once the speed gate is on; sharding assigns every file to exactly one shard for any N. +- **Test scenarios (property, local):** two identical sides compare clean; a single status difference fails naming the mutant; an own-file `(line,col)` difference passes and an other-file one fails; a zero shortcut count fails overall and for the fixture alone (AE6); a branch side with checked mutants and zero snapshot updates fails (U3's attribute never set); a project that boots on one side and fails boot on the other fails, and one that fails boot on both is listed as skipped; branch time ≥ main time fails once the speed gate is on, and cached projects never enter the speed sum; sharding assigns every file to exactly one shard for any N. - **Verification:** local `pnpm --filter @systemfsoftware/stryker-checker-parity typecheck` and `... test` (pure tests only). - **Dogfood mutant ids:** none; the package is not a dogfood project (`mutation.yml:29`). ### U10. `checker-parity` CI lane -- **Goal:** R7-R10 enforced on every relevant PR and every push to main. +- **Goal:** the parity lane runs on every relevant pull request and every push to main, with the R7 verdict-parity gate on from layer 2; R18's gate comes on with U12 (layer 3), and R8's zero-count refusal and R10's speed gate with U5 and U6 (layer 4). - **Requirements:** R9 (and R3, R7, R8, R10 through U9). - **Dependencies:** U8, U9. - **Files:** `.github/workflows/ci.yml` (new jobs `checker-parity-changes`, `checker-parity` with an `include` matrix of shards, `checker-parity-report`). -- **Approach:** `checker-parity-changes` outputs `run=true` on push to main, or when `git diff --name-only ...HEAD` touches `packages/stryker-js-typescript-checker/`, `packages/stryker-js-instrumenter/`, `test/checker-parity/`, `test/e2e/testResources/`, `pnpm-lock.yaml` or `.github/workflows/ci.yml`. Each `checker-parity (k)` leg checks out with history, installs like `check` (nix dprint, released tarballs, pnpm, node 24, `pnpm install --frozen-lockfile`), builds the branch checker worker, instrumenter and driver with `turbo build --filter`, adds a worktree at the merge-base, installs and builds main's checker there, restores the KTD8 cache, runs both sides, saves the cache, and uploads `checker-parity--` (NDJSON plus raw spans, 14-day retention). `checker-parity-report` downloads all legs and runs `compare`. It is the one context branch protection should require; it succeeds when changes say `run=false`. Shard count starts at 6; the first main-push run's timings set it. +- **Approach:** `checker-parity-changes` outputs `run=true` on push to main, or when `git diff --name-only ...HEAD` touches `packages/stryker-js-typescript-checker/`, `packages/stryker-js-instrumenter/`, `test/checker-parity/`, `test/e2e/testResources/`, `pnpm-lock.yaml` or `.github/workflows/ci.yml`. Each `checker-parity (k)` leg checks out with history, installs like `check` (nix dprint, released tarballs, pnpm, node 24, `pnpm install --frozen-lockfile`), builds the branch checker worker, instrumenter and driver with `turbo build --filter`, adds a worktree at the merge-base, installs and builds main's checker there, restores the KTD8 cache (pull requests only; push to main runs uncached), runs both sides, saves the cache, and uploads `checker-parity--` (NDJSON plus raw spans, 14-day retention). `checker-parity-report` downloads all legs and runs `compare`. It is the one context branch protection should require; it succeeds when changes say `run=false`. Shard count starts at 6; the first main-push run's timings set it. Each lane PR body states per-shard wall time and whether the cache was hit (OQ-P4). - **Patterns:** existing `e2e` job matrix and artifact steps in `ci.yml`; `docs/solutions/workflow-issues/matrix-legs-rename-the-required-status-check.md`; `docs/solutions/workflow-issues/mutation-lane-green-while-every-job-failed.md` (the report job fails when any leg wrote no NDJSON; no `continue-on-error` on verdict steps). - **Test scenarios:** none in-repo; the layer's own CI run on a branch where the checker differs from main only by U2 and U3 must report zero verdict differences. - **Verification:** local `pnpm format:check` (dprint covers YAML); CI: the lane's first run. @@ -389,9 +384,9 @@ Paths under `src/`, `tests/` and `schema/` are relative to `packages/stryker-js- - **Dependencies:** U3 (stack order). Independent of U4-U7. - **Files:** - Port (provisional location, OQ-P8): `packages/stryker-js-plugin-interface/src/TypeQuery.schema.ts` (the v1 schemas in "Type-query surface for Stream I"), `src/TypeQuery.service.ts` (the `TypeQuery` service tag), a declared `./type-query` entry in its `package.json` `exports` and `tsdown.config.ts`, and its api report. The instrumenter already depends on `stryker-js-plugin-interface` (`packages/stryker-js-instrumenter/package.json`), so it needs no new dependency. - - Implementation: `packages/stryker-js-typescript-checker/src/type-query.handle.ts` (server lifecycle, probe text, the API calls in KTD11), new `src/classify-candidate.workflow.ts` (pure: grammar membership from text), new `src/answer-type-query.workflow.ts` (pure: facts per site and candidate to the answer union, reasons in the order KTD11 lists), `src/CheckerCommands.schema.ts` (their commands), a declared `./type-query` entry exporting `TypeQueryLive: (options) => Layer` (`package.json` `exports`, `tsdown.config.ts` entry; `PLUG-1`: the entry imports nothing but `typescript` and node builtins). + - Implementation: `packages/stryker-js-typescript-checker/src/type-query.handle.ts` (server lifecycle, probe text, the API calls in KTD11), new `src/classify-candidate.workflow.ts` (pure: grammar membership from text), new `src/answer-type-query.workflow.ts` (pure: facts per site and candidate to the answer union, reasons in the order KTD11 lists), `src/CheckerCommands.schema.ts` (their commands), a declared `./type-query` entry exporting `TypeQueryLive: (options) => Layer` (`package.json` `exports`, and an entry in the first `tsdown.config.ts` block beside `index` and `runtime`, with workspace dependencies external so the `TypeQuery` tag stays one instance; `PLUG-1` keeps governing only the spawned `main.mjs` worker entry, `tsdown.config.ts:22-31`). - Tests: `src/__tests__/classify-candidate.workflow.property.test.ts`, `src/__tests__/answer-type-query.workflow.property.test.ts`, `packages/stryker-js-plugin-interface/src/__tests__/TypeQuery.schema.test.ts`, `tests/type-query.differential.test.ts`, `tests/type-query-pins.integration.test.ts`, fixtures under `tests/__fixtures__/type-query/`. - - Changesets: plugin-interface minor, checker minor. + - Changesets: plugin-interface minor, checker minor. Both changesets, the `./type-query` entries' JSDoc and both READMEs state that the entry is provisional and will change shape when Stream I confirms it (OQ-P9); the plugin-interface README leaves it out of its stable-surface list until then. `BREAK-1` makes that later change an ordinary break, so no compatibility path is built. - **Approach:** KTD10 and KTD11. Locations arrive as the plugin-interface `Location` (line and column, as mutants carry); the implementation converts them with the instrumenter's `offsetAt`, which the checker already uses. Site facts (kind, exact span found, parent call, flags) are read into plain data before the answer workflow decides, as in U4. - **Patterns:** `cell-architecture` `pure-decision-workflows.md` and `sandwich-phase-order.md` (read facts, decide, write the response); `cell-architecture` `ports-separate-from-layers.md` (the port and its Layer live in separate modules and packages); `schema-laws` `tagged-unions-over-state-by-presence.md` (answer and file outcome unions); `schema-laws` `rich-type-over-foreign-encoded.md`; `boundary-testing` `pin-dependency-semantics.md`; `boundary-testing` `real-system-oracles.md`. - **Test scenarios:** @@ -399,8 +394,8 @@ Paths under `src/`, `tests/` and `schema/` are relative to `packages/stryker-js- - Property, `answer-type-query`: `NotAssignable` exactly when every KTD11 condition holds and assignability is false; each failing condition alone yields `Unknown` with that reason; reasons are reported in KTD11's order when several fail. - Schema codec laws for every v1 schema (round trip, encode stability), and a request with `version: 2` is refused `unsupported-version`. - Differential (`tests/type-query.differential.test.ts`, reference: the real checker's verdict): over the fixture files, every candidate answered `NotAssignable` is applied as a mutant through `CheckerRuntime` and must be CompileError; AE12, AE13, AE14 are rows of this spec. - - Pin (`tests/type-query-pins.integration.test.ts`): appending `;("");` statements leaves every site's `getContextualType` text unchanged; the appended `""` has type text `""` (literal, not `string`); AE15 (the #4804 panic refuses one file and the next file is answered). These re-fire on every `typescript` bump. -- **Verification:** local `pnpm --filter @systemfsoftware/stryker-js-plugin-interface typecheck` and `... test`, then the same for the checker package; the checker's `build` once, for the `PLUG-1` `deps.onlyImport` check. + - Pin (`tests/type-query-pins.integration.test.ts`): appending `;("");` statements leaves every site's `getContextualType` text unchanged; the appended `""` has type text `""` (literal, not `string`); a file whose last line is a `//` comment with no trailing newline still has its candidates answered; AE15 (the #4804 panic refuses one file and the next file is answered). These re-fire on every `typescript` bump. +- **Verification:** local `pnpm --filter @systemfsoftware/stryker-js-plugin-interface typecheck` and `... test`, then the same for the checker package; the checker's `build` once, which also runs the existing `PLUG-1` `deps.onlyImport` check on the worker bundle. - **Dogfood mutant ids:** none yet. The two workflows and the schema match the checker's `mutate` globs and get ids at the first main mutation run after merge; `type-query.handle.ts` is shell, outside the globs. plugin-interface is not a dogfood project (`mutation.yml:29`). ### U12. Lane: `wrong-not-assignable` gate @@ -409,9 +404,9 @@ Paths under `src/`, `tests/` and `schema/` are relative to `packages/stryker-js- - **Requirements:** R18. - **Dependencies:** U10, U11. - **Files:** `test/checker-parity/src/run-side.ts` (branch side: before checking a project, call `TypeQueryLive` once per file with every mutant of a context-free replacement as a candidate at its site); `src/Parity.schema.ts` (`TypeAnswer` and `WrongNotAssignable` lines); `src/compare-sides.workflow.ts` (R18 gate and per-reason counts); its property test. -- **Approach:** KTD12. The query runs on the driver's own tsgo server, so it adds one server per leg and no checker work. Answers are joined to verdicts by mutant id. -- **Test scenarios (property):** a `NotAssignable` answer joined to a CompileError verdict passes; joined to Survived, Killed, NoCoverage, Timeout or Ignored it fails and names the mutant; `Assignable` and `Unknown` never fail the gate; counts per reason add up to the number of queried mutants. -- **Verification:** local driver typecheck and tests; CI: the lane's run on layer 9. +- **Approach:** KTD12. The driver opens a query server per corpus project and closes it at that project's end, never holding one across projects, and records its peak count of live tsgo servers per leg in the lane artifact; the query adds no checker work. Answers are joined to verdicts by mutant id. +- **Test scenarios (property):** a `NotAssignable` answer joined to a CompileError verdict passes; joined to Survived, Killed, NoCoverage, Timeout or Ignored it fails and names the mutant; `Assignable` and `Unknown` never fail the gate; a run with zero `NotAssignable` answers over all shards fails, while a single project with zero answers passes and is reported; counts per reason add up to the number of queried mutants; per-project answer shares add up to that project's queried mutants. +- **Verification:** local driver typecheck and tests; CI: the lane's run on layer 3. - **Dogfood mutant ids:** none. ### Type-query surface for Stream I @@ -425,7 +420,7 @@ interface TypeQueryShape { } TypeQueryRequest = Struct({ - version: Literal(1), + version: Int, // the implementation checks it first and refuses anything but 1 with 'unsupported-version' tsconfigFile: String, // absolute path; one tsgo project per request files: NonEmptyArray(Struct({ fileName: String, // absolute path, a file of that project @@ -463,6 +458,7 @@ TypeAnswer = Union([ TaggedStruct('Unknown', { reason: Literals([ 'candidate-not-context-free', + 'candidate-not-found', 'site-not-found', 'site-not-expression', 'no-contextual-type', @@ -481,9 +477,9 @@ TypeQueryRefused = TaggedError('TypeQueryRefused', { Contract for callers: 1. Advisory only. The checker never reads an answer; its verdict stays authoritative (R17). Only `NotAssignable` may justify dropping a candidate; `Assignable` and `Unknown` say nothing about compiling. -2. One request opens or reuses one tsgo server for its `tsconfigFile` and makes one probe snapshot for all its files. Batch all of a file's sites in one request; batching several files in one request saves the per-request update. +2. One request opens or reuses one tsgo server for its `tsconfigFile` and makes one probe snapshot for all its files (file text, a newline, then the appended candidates). Batch all of a file's sites in one request; batching several files in one request saves the per-request update. 3. Cost per request, measured (KTD11): one probe update of 6-10 ms, plus about 30 ms for a 60-line file and about 0.45 s for an 1800-line file with every expression site queried. Opening a project costs about 0.2-0.26 s once. -4. Answers cover the context-free grammar only (KTD11); every other candidate is `Unknown` (`candidate-not-context-free`). On main's corpus that grammar covers 91.5% of CompileError mutants. +4. Answers cover the context-free grammar only (KTD11); every other candidate is `Unknown` (`candidate-not-context-free`). On the dogfood `mutate` scope that grammar covers 91.5% of CompileError mutants; the corpus-wide share is written by the lane (R18). 5. A server crash refuses the file in flight (`server-crashed`) and does not fail the request; the next file is answered on a fresh server. 6. Wrong `NotAssignable` answers are detected in CI by the checker-parity lane (R18): each is a `wrong-not-assignable` NDJSON line in the lane artifact naming the site, candidate, both types and the answer path, and any such line fails `checker-parity-report`. The lane also writes per-reason counts and how many CompileError mutants were answered `NotAssignable` (the query's recall). Stream I's drop-soundness proof can consume both from the lane artifact. 7. A new field or answer reason is a schema version bump; a caller that receives a version it does not know refuses it. @@ -530,11 +526,11 @@ Every test the plan proposes, checked against `skill://test-layer-selection` (de | No wrong `NotAssignable` on the corpus (R18) | never | `checker-parity-report` | | No pre-7 compiler in the product (R1, R12) | `git grep -nE "from 'typescript'" -- 'packages/*/src'` returns nothing; `pnpm why typescript@5.9.3 --prod` lists no workspace package | same commands in the PR description | -Done 4's threshold: `sum(branch checker phase) < sum(main checker phase)` over all shards of one lane run, from the NDJSON phase lines. Done 1's measurement: snapshot updates and emit builds per mutant, per side, printed by `compare` from the same run. +Done 4's threshold: `sum(branch checker phase) < sum(main checker phase)` over all shards of one uncached main-push lane run, from the NDJSON phase lines, counting only projects measured on both sides in that run (KTD8). Done 1's measurement: snapshot updates and emit builds per mutant, per side, printed by `compare` from the same run (main's derived, OQ-P2). ## Definition of Done -- Contract Done 1-5 hold, each with its evidence: Done 1 from `compare`'s per-side ratios on a main-push lane run after layer 7 (and 8 if shipped); Done 2 and 3 from a green `checker-parity-report` with a non-zero shortcut count overall and for the `isolatedDeclarations` fixture; Done 4 from the same run's speed line; Done 5 from the R1/R12 commands and green CI on the stack's top head. +- Contract Done 1-5 hold, each with its evidence: Done 1 from `compare`'s per-side ratios on a main-push lane run after layer 4; Done 2 and 3 from a green `checker-parity-report` with a non-zero shortcut count overall and for the `isolatedDeclarations` fixture; Done 4 from the same run's speed line; Done 5 from the R1/R12 commands and green CI on the stack's top head. - Every layer is green on its own head, and its head SHA is reported to the operator. - R11 and the changelog correction are released through changesets. - No local mutation, corpus or e2e run happened; no long-lived `tsc --api`, `--lsp` or watch process is left running. @@ -565,24 +561,24 @@ Source: main's latest successful Mutation run, run 37960922409 (#416) on `1e1de6 | `620a93c10b22628a`, `ba0a7d59ebb4482d`, `1e395b6653e16a69` | Killed | `src/request-affected-files.workflow.ts:34` | U5 fallback scenarios keep them killed | | `62022de61dbfc17f`, `25c843d6bcc5ba1d`, `ab0fe07b72c193ad`, `e9f541378e920557`, `17563f7f061cc8d9` | Killed ×4, NoCoverage | `src/group-mutants.workflow.ts` | Retired by the U6 rewrite | -New workflow and schema files (U4, U6, the U9 package is not dogfooded) get ids at the first main mutation run after they merge; the plan cannot cite them earlier because ids digest the file's content (KTD1 of the 2026-09-29 plan). +New workflow and schema files (U4, U6, U11; the U9 package is not dogfooded) get ids at the first main mutation run after they merge; the plan cannot cite them earlier because ids digest the file's content (KTD1 of the 2026-09-29 plan). ## Outstanding Questions ### Open questions for the supervisor -None blocks U1-U4. Each has a default the plan follows unless overruled. +Ruled in `.omp-brief/rulings-plan.md`: every default below is accepted. OQ-P5, OQ-P8 and OQ-P9 are forwarded to the root, and the defaults are built as provisional. - OQ-P1. **R7 position rule (blocks U10's gate).** Main renders every reason against the last-applied mutant's text (Problem Frame), and rounds change which text is last, so byte-equal reasons with main are impossible once U6 lands. Default: compare status exactly and reason text exactly except the `(line,col)` of diagnostics in the mutant's own file; U2's AE10 test proves the branch's positions are correct. Alternative: keep main's rendering bug and forbid multi-member rounds, which gives up Done 1's batching. - OQ-P2. **Main-side Done 1 counts are derived, not observed.** Main's checker has no counters. Default: main's updates = check calls + mutants + re-splices (`ts-compiler.handle.ts:1656,1485,1489`) and main's emit builds = (check call, file with a passing mutant) pairs (`:1612-1622`), computed from the lane's observed calls, mutants and verdicts, with re-splices taken from the branch side's counter (a re-splice depends only on the mutant's text). Alternative: patch main's checker inside the lane with counters, which measures a checker that is not main's. - OQ-P3. **R8 proven against main (KTD9).** Default: no third corpus pass with the shortcut forced off. Alternative: a third pass with `importerCheck: 'always'`, about 50% more lane time. -- OQ-P4. **Lane cost.** Default: six shards, the KTD8 cache, path filter per B4 plus `test/checker-parity/`, `pnpm-lock.yaml` and `ci.yml`, and a stop at eight shards over 40 minutes each. The extra paths are the lane's own code, the TypeScript version, and the lane's definition. +- OQ-P4. **Lane cost.** Default: six shards, the KTD8 cache on pull requests (push to main runs uncached), path filter per B4 plus `test/checker-parity/`, `pnpm-lock.yaml` and `ci.yml`, and a stop at eight shards over 40 minutes each. The extra paths are the lane's own code, the TypeScript version, and the lane's definition. Each lane PR body states per-shard wall time and whether the cache was hit. - OQ-P5. **Branch protection.** The owner adds `checker-parity-report` as a required check; this plan cannot read or change protection (`docs/solutions/workflow-issues/matrix-legs-rename-the-required-status-check.md:63`). -- OQ-P6. **TCE materiality threshold.** Default: U7 ships when emit time is at least 5% of the branch checker phase in the lane at layer 7. +- OQ-P6. **TCE materiality threshold.** Default: U7 ships when emit time is at least 5% of the branch checker phase in the lane at layer 4; otherwise U7 is dropped and the measurement reported. - OQ-P7. **New user option `typescriptChecker.importerCheck`.** A public option (minor bump) that exists as the escape hatch and the local test reference. Alternative: no option, with an internal runtime switch only, which leaves users no way to turn the shortcut off if a hole appears. - OQ-P8. **Where the type-query port lives (blocks U11 landing; Stream A owns the split).** Default: a declared `./type-query` entry of `@systemfsoftware/stryker-js-plugin-interface`, which the instrumenter already depends on. Alternative: a new port-only package; Stream A decides if its split already plans one. - OQ-P9. **Shape confirmation from Stream I (blocks marking the surface non-provisional).** Default: the v1 shape in "Type-query surface for Stream I". Points for I to confirm: line-and-column `Location` rather than offsets; one tsconfig per request; whether I needs per-candidate types in `Unknown` answers. -- OQ-P10. **Scope of answers in this unit.** Default: context-free candidates only (91.5% of main's CompileError mutants). Alternative: also build the in-place probe for scope-dependent candidates now, which adds a round planner like U6's to U11 and several snapshot updates per file. +- OQ-P10. **Scope of answers in this unit.** Default: context-free candidates only (91.5% of CompileError mutants on the dogfood `mutate` scope; corpus-wide share from the lane). Alternative: also build the in-place probe for scope-dependent candidates now, which adds a round planner like U6's to U11 and several snapshot updates per file. ### Deferred to Implementation @@ -603,7 +599,7 @@ None blocks U1-U4. Each has a default the plan follows unless overruled. ## Document Review (report-only) -`ce-doc-review` ran with `mode:non-interactive` and changed nothing in the plan. Reviewers: coherence, feasibility, scope-guardian (the plan has 18 requirements across 9 stacked layers), and adversarial (the plan adds a provisional cross-stream port and a CI soundness gate). The packs `cell-architecture`, `boundary-testing` and `schema-laws` were passed to every reviewer. The cross-model pass was skipped because no peer CLI (`codex`, `grok`, `cursor-agent`, `opencode`) is installed. 18 findings came back. Two were duplicates (F1/F10 and F9/F11), so 16 are distinct. The dispositions below are proposals, and none has been applied. Where a finding rests on a code fact, that fact was checked again in this session. +`ce-doc-review` ran with `mode:non-interactive` and changed nothing in the plan. Reviewers: coherence, feasibility, scope-guardian, and adversarial. The packs `cell-architecture`, `boundary-testing` and `schema-laws` were passed to every reviewer. The cross-model pass was skipped because no peer CLI (`codex`, `grok`, `cursor-agent`, `opencode`) is installed. 18 findings came back. Two were duplicates (F1/F10 and F9/F11), so 16 are distinct. The supervisor ruled on the dispositions (`.omp-brief/rulings-plan.md` P-F): every one marked **Apply** is applied in the plan body above (F13 and F17 narrowed as written), and F12 is rejected (strict `<` stays; a flapping speed gate is reported with run URLs, never given a margin). | # | Sev | Finding | Evidence checked | Proposed disposition | | --- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | From 444c4ec3ec3ca887e19b82e2f65c6f885505c835 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Fri, 9 Oct 2026 22:14:49 +0000 Subject: [PATCH 4/9] docs(stryker-js-typescript-checker): state the TypeScript 7 floor and correct the 8.1.0 importer note U1 of docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md (R11). README prerequisites match minimumTypeScriptVersion; the changeset corrects the released importer re-check claim without rewriting CHANGELOG.md. --- .changeset/typescript-checker-ts7-only-docs.md | 7 +++++++ packages/stryker-js-typescript-checker/README.md | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changeset/typescript-checker-ts7-only-docs.md diff --git a/.changeset/typescript-checker-ts7-only-docs.md b/.changeset/typescript-checker-ts7-only-docs.md new file mode 100644 index 000000000..96eea8025 --- /dev/null +++ b/.changeset/typescript-checker-ts7-only-docs.md @@ -0,0 +1,7 @@ +--- +"@systemfsoftware/stryker-js-typescript-checker": patch +--- + +The README now states the TypeScript floor the checker enforces: TypeScript `>=7.0.0`, not 5.x. Older compilers were already refused at startup. + +Correction to the 8.1.0 notes: files that import a mutated file are not checked "only when the mutant changes what that file exports". They are re-checked whenever the mutated file has no compile errors of its own, whatever the mutant changed. diff --git a/packages/stryker-js-typescript-checker/README.md b/packages/stryker-js-typescript-checker/README.md index 272930a18..ccbda9c9a 100644 --- a/packages/stryker-js-typescript-checker/README.md +++ b/packages/stryker-js-typescript-checker/README.md @@ -3,7 +3,7 @@ [![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](../../LICENSE) [![npm version](https://img.shields.io/npm/v/@systemfsoftware/stryker-js-typescript-checker.svg)](https://www.npmjs.com/package/@systemfsoftware/stryker-js-typescript-checker) -TypeScript checker plugin for Stryker mutation testing — provides native support for TypeScript 5.x through 7.x. +TypeScript checker plugin for Stryker mutation testing, built on the native TypeScript 7 compiler. ## Why Use a Type Checker Plugin? @@ -14,7 +14,7 @@ The TypeScript checker intercepts mutants _before_ they reach the test runner. I ## Prerequisites - Node.js `>=22.18.0` -- TypeScript `>=5.0.0` +- TypeScript `>=7.0.0` - `@systemfsoftware/stryker-js` `>=5.0.0` ## Install From cc1291c9be5cb9484717f2ef7a9c3647cd3c0795 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Fri, 9 Oct 2026 22:45:54 +0000 Subject: [PATCH 5/9] docs(plans): record the root rulings on OQ-P5, OQ-P8 and OQ-P9 --- .../2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md b/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md index 844187e58..3500cbf64 100644 --- a/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md +++ b/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md @@ -19,7 +19,7 @@ origin: docs/brainstorms/2026-10-09-2030-perf-typescript-checker-tsgo-native-pla - **Stop conditions:** stop and report if the TypeScript 7.0.2 API cannot do something a unit needs (name the missing API with evidence; never fall back to TS5); if the parity lane shows any verdict difference that the position rule in R7 does not explain; if a corpus leg cannot finish inside its time budget at eight shards. - **Execution profile:** stacked PRs on trunk `main` via `gh stack` (root `AGENTS.md` OP13b), one concern per layer, no force-push. Locally only typecheck and the touched package's tests (S1); the corpus, e2e and mutation runs happen in CI only. - **Who ships:** the implementer opens and updates the stack and reports each layer's green head SHA; the operator merges bottom-up. -- **Supervisor items:** OQ-P1 to OQ-P10 are ruled (`.omp-brief/rulings-plan.md`): every default is accepted. OQ-P5 (required check), OQ-P8 (where the port's entry lives, Stream A) and OQ-P9 (the surface shape, Stream I) are forwarded to the root; the defaults are built and the "Type-query surface for Stream I" stays provisional until Stream I confirms it. +- **Supervisor items:** OQ-P1 to OQ-P10 are ruled (`.omp-brief/rulings-plan.md`): every default is accepted, and the root confirmed OQ-P5, OQ-P8 and OQ-P9 as defaulted. The root adds `checker-parity-report` to the ruleset after its first green main run (rulesets are never edited here). The plugin-interface `./type-query` entry holds the port only (schemas and service tag, no Layer); `TypeQueryLive` stays in the checker. The v1 shape is accepted with no extra fields, and the provisional marking stays until the supervisor lifts it. --- From e59fbd5065b18799139e6af37693357ca1057a2c Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Fri, 9 Oct 2026 23:18:36 +0000 Subject: [PATCH 6/9] docs(plans): record the agentic-quality standing rule in R9, U9, U10 and U12 --- ...10-09-2106-perf-typescript-checker-tsgo-native-plan.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md b/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md index 3500cbf64..636d88ad9 100644 --- a/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md +++ b/docs/plans/2026-10-09-2106-perf-typescript-checker-tsgo-native-plan.md @@ -51,7 +51,7 @@ The origin brainstorm established the gap (its Gap Table and Measured Cost): eve - R6. The corpus is every source file of each corpus project's program, minus declaration files and `node_modules`. Corpus projects are every workspace `tsconfig.app.json`, every tsconfig a checker-enabled e2e fixture configuration names (an unresolvable one is skipped and listed), and one `isolatedDeclarations` fixture. Mutants are whatever the branch's instrumenter produces for those files with the default mutator set. - R7. For every corpus mutant, the branch checker's status equals main's, and its reason text equals main's except the `(line,col)` of diagnostics located in the mutant's own file (see OQ-P1). - R8. With the shortcut on, every corpus mutant's status equals main's, and its reason text equals main's except the `(line,col)` of diagnostics in the mutant's own file (R7's rule; main always re-checks importers, KTD9). Own-file positions are covered separately by U2's AE10. The lane reports how many mutants the shortcut decided, overall and for the `isolatedDeclarations` fixture on its own, and fails when either count is zero. -- R9. R7 and R8 run in CI on every push to main and on every pull request that touches the checker, the instrumenter, the corpus fixtures, the parity driver, or the lane definition. Any difference fails the lane. No local corpus run is needed to land a change. +- R9. R7 and R8 run in CI on every push to main and on every pull request that touches the checker, the instrumenter, the corpus fixtures, the parity driver, or the lane definition. Any difference fails the lane. No local corpus run is needed to land a change. Every record this unit adds (lane NDJSON lines, compare output, `wrong-not-assignable` lines, span attributes, type-query refusals) carries a version literal, every failure carries a reason code from a closed set plus a concrete next action, listed items are capped with a count and the artifact path, and each lane job writes `$GITHUB_STEP_SUMMARY` and `::error` annotations naming the artifact and the path inside it (root standing rule, `.omp-brief/rulings-plan.md`). **Speed** @@ -355,7 +355,7 @@ Paths under `src/`, `tests/` and `schema/` are relative to `packages/stryker-js- ### U9. Parity driver package -- **Goal:** a CI tool that runs one checker side over a corpus shard and compares sides. +- **Goal:** a CI tool that runs both checker sides over a corpus shard and compares sides; every line kind and the compare output carry `schemaVersion: 1`, every violation a reason `code` and `nextAction`, and printed violations are capped at 50 with the omitted count and the summary path (R9). - **Requirements:** R3, R6, R7, R8, R10. - **Dependencies:** U3. - **Files:** new private package `test/checker-parity/` (`@systemfsoftware/stryker-checker-parity`): `package.json`, tsconfigs, `src/corpus.ts` (R6 discovery: workspace `tsconfig.app.json` files, the tsconfig each checker-enabled `test/e2e/testResources/*/stryker*.config.ts` names, the U8 fixture; program files listed with the TS7 `tsc --listFilesOnly`), `src/shard.ts` (file-hash sharding), `src/run-side.ts` (KTD5), `src/otlp-receiver.ts` (KTD6), `src/Parity.schema.ts` (NDJSON lines: verdict, phase, counts, refusal), `src/compare-sides.workflow.ts` (pure: R7 diff with the own-file position rule, R8 zero-count refusal overall and for the U8 fixture, R10 speed verdict, Done 1 ratios), `src/main.ts` (`run` and `compare` commands); tests under `src/__tests__/`. `pnpm-workspace.yaml` already covers `test/*`. @@ -367,7 +367,7 @@ Paths under `src/`, `tests/` and `schema/` are relative to `packages/stryker-js- ### U10. `checker-parity` CI lane -- **Goal:** the parity lane runs on every relevant pull request and every push to main, with the R7 verdict-parity gate on from layer 2; R18's gate comes on with U12 (layer 3), and R8's zero-count refusal and R10's speed gate with U5 and U6 (layer 4). +- **Goal:** the parity lane runs on every relevant pull request and every push to main, with the R7 verdict-parity gate on from layer 2; R18's gate comes on with U12 (layer 3), and R8's zero-count refusal and R10's speed gate with U5 and U6 (layer 4). Each leg and `checker-parity-report` write a short `$GITHUB_STEP_SUMMARY` and `::error` annotations naming the artifact and the file inside it (R9). - **Requirements:** R9 (and R3, R7, R8, R10 through U9). - **Dependencies:** U8, U9. - **Files:** `.github/workflows/ci.yml` (new jobs `checker-parity-changes`, `checker-parity` with an `include` matrix of shards, `checker-parity-report`). @@ -400,7 +400,7 @@ Paths under `src/`, `tests/` and `schema/` are relative to `packages/stryker-js- ### U12. Lane: `wrong-not-assignable` gate -- **Goal:** every wrong `NotAssignable` on the corpus fails CI, with enough detail for Stream I's drop-soundness proof. +- **Goal:** every wrong `NotAssignable` on the corpus fails CI, with enough detail for Stream I's drop-soundness proof; `wrong-not-assignable` lines carry `schemaVersion: 1`, a reason code and a next action like every other lane record (R9). - **Requirements:** R18. - **Dependencies:** U10, U11. - **Files:** `test/checker-parity/src/run-side.ts` (branch side: before checking a project, call `TypeQueryLive` once per file with every mutant of a context-free replacement as a candidate at its site); `src/Parity.schema.ts` (`TypeAnswer` and `WrongNotAssignable` lines); `src/compare-sides.workflow.ts` (R18 gate and per-reason counts); its property test. From 43ffa7fd03eb8ac74a3a1b426d47e8dea1ecaec6 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Sat, 10 Oct 2026 00:14:08 +0000 Subject: [PATCH 7/9] fix(stryker-js-typescript-checker): render each mutant's diagnostics against its own text U2 of the TS7 checker plan (R14, KTD3). checkOne renders its mutant's diagnostics while that mutant's snapshot is current; MutantCheck carries rendered lines and Checker.cell no longer renders after the call. AE10 (per-mutant-position fixture) fails on the old code (line 16 instead of 20). The join property targets dogfood mutant 47892392410b616f. --- .../typescript-checker-own-positions.md | 5 ++ .../src/Checker.cell.ts | 39 +++----- .../check-mutants.workflow.property.test.ts | 33 ++++++- .../src/ts-compiler.handle.ts | 43 ++++----- .../__fixtures__/per-mutant-position/dep.ts | 20 +++++ .../per-mutant-position/tsconfig.json | 12 +++ .../tests/check-mutants.integration.test.ts | 88 +++++++++++++++---- 7 files changed, 172 insertions(+), 68 deletions(-) create mode 100644 .changeset/typescript-checker-own-positions.md create mode 100644 packages/stryker-js-typescript-checker/tests/__fixtures__/per-mutant-position/dep.ts create mode 100644 packages/stryker-js-typescript-checker/tests/__fixtures__/per-mutant-position/tsconfig.json diff --git a/.changeset/typescript-checker-own-positions.md b/.changeset/typescript-checker-own-positions.md new file mode 100644 index 000000000..8405b3b11 --- /dev/null +++ b/.changeset/typescript-checker-own-positions.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/stryker-js-typescript-checker": patch +--- + +A `CompileError` reason now names the line and column where the error sits in that mutant's own code. When several mutants were checked together, an earlier mutant's reason could point at a position computed from a later mutant's code, so the reported line was wrong. diff --git a/packages/stryker-js-typescript-checker/src/Checker.cell.ts b/packages/stryker-js-typescript-checker/src/Checker.cell.ts index 94e918afb..a38667ecf 100644 --- a/packages/stryker-js-typescript-checker/src/Checker.cell.ts +++ b/packages/stryker-js-typescript-checker/src/Checker.cell.ts @@ -2,6 +2,7 @@ import { Sandwich } from '@systemfsoftware/effect-cell-types' import { SpanTaxonomy } from '@systemfsoftware/stryker-js-cli-contract' import { ErrorText } from '@systemfsoftware/stryker-js-instrumenter' import { Checker, Mutant } from '@systemfsoftware/stryker-js-plugin-interface' +import * as Arr from 'effect/Array' import * as Effect from 'effect/Effect' import * as Option from 'effect/Option' @@ -9,7 +10,7 @@ import { checkMutants } from './check-mutants.workflow.js' import { CheckMutantsCommand } from './Checker.schema.js' import { CheckMutantsInput, MutantVerdict, tceFieldOf } from './CheckMutants.schema.js' import type { CompilerError } from './Compiler.schema.js' -import { check, describeDiagnostics, type MutantCheck, type TSCompiler } from './ts-compiler.handle.js' +import { check, type MutantCheck } from './ts-compiler.handle.js' import { TypeScriptCompiler } from './ts-compiler.service.js' type CheckRefusalCause = CompilerError | string @@ -25,24 +26,13 @@ const refuse = ( export type CheckMutantsRead = (typeof CheckMutantsInput)['Encoded'] -const verdictsOf = ( - compiler: TSCompiler, - checked: ReadonlyArray, -): Effect.Effect> => - Effect.forEach( - checked, - (entry) => - Effect.map( - describeDiagnostics(compiler, entry.diagnostics), - (diagnostics) => - MutantVerdict.make({ - id: entry.mutantId, - diagnostics: [...diagnostics], - ...tceFieldOf(entry.tce), - }), - ), - { concurrency: 1 }, - ) +const verdictsOf = (checked: ReadonlyArray): ReadonlyArray => + Arr.map(checked, (entry) => + MutantVerdict.make({ + id: entry.mutantId, + diagnostics: [...entry.diagnostics], + ...tceFieldOf(entry.tce), + })) export const checkCell = Sandwich.named(SpanTaxonomy.Spans.typescriptCheckerCheckMutants.name)(( command: CheckMutantsCommand, @@ -50,12 +40,11 @@ export const checkCell = Sandwich.named(SpanTaxonomy.Spans.typescriptCheckerChec Effect.flatMap( TypeScriptCompiler, (compiler) => - Effect.flatMap(check(compiler, [...command.mutants]), (checked) => - Effect.map( - verdictsOf(compiler, checked), - (verdicts): CheckMutantsRead => - CheckMutantsInput.make({ mutants: [...command.mutants], verdicts: [...verdicts] }), - )), + Effect.map( + check(compiler, [...command.mutants]), + (checked): CheckMutantsRead => + CheckMutantsInput.make({ mutants: [...command.mutants], verdicts: [...verdictsOf(checked)] }), + ), ).pipe( Effect.mapError((cause) => refuse({ mutantIds: command.mutants.map((mutant) => mutant.id), cause })), ) diff --git a/packages/stryker-js-typescript-checker/src/__tests__/check-mutants.workflow.property.test.ts b/packages/stryker-js-typescript-checker/src/__tests__/check-mutants.workflow.property.test.ts index bf224071f..d177c1864 100644 --- a/packages/stryker-js-typescript-checker/src/__tests__/check-mutants.workflow.property.test.ts +++ b/packages/stryker-js-typescript-checker/src/__tests__/check-mutants.workflow.property.test.ts @@ -1,3 +1,4 @@ +import { Checker } from '@systemfsoftware/stryker-js-plugin-interface' import { describe } from '@systemfsoftware/vitest' import * as Arr from 'effect/Array' import * as Equal from 'effect/Equal' @@ -5,9 +6,10 @@ import * as HashMap from 'effect/HashMap' import * as Match from 'effect/Match' import * as Option from 'effect/Option' import * as Result from 'effect/Result' +import * as S from 'effect/Schema' import { checkMutants, type CheckOutcome } from '../check-mutants.workflow.js' -import { CheckMutantsInput, type DiagnosticDecoded, MutantVerdict } from '../CheckMutants.schema.js' +import { CheckMutantsInput, type DiagnosticDecoded, DiagnosticLine, MutantVerdict } from '../CheckMutants.schema.js' type Status = 'passed' | 'compileError' | 'ignored' @@ -59,6 +61,28 @@ const observedOutcomes = (outcomes: ReadonlyArray): ReadonlyArray< const decidedIds = (outcomes: ReadonlyArray): ReadonlyArray => Arr.map(outcomes, (outcome) => outcome.id) +const DiagnosticLines = S.NonEmptyArray(DiagnosticLine) + +const wireOf = (wire: Checker.CheckerMutantWire, diagnostics: ReadonlyArray): CheckMutantsInput => + CheckMutantsInput.make({ + mutants: [wire], + verdicts: [MutantVerdict.make({ id: wire.id, diagnostics: [...diagnostics] })], + }) + +const failedReasonOf = (wire: Checker.CheckerMutantWire, diagnostics: ReadonlyArray): string => + Result.match(checkMutants(wireOf(wire, diagnostics)), { + onFailure: () => '', + onSuccess: (outcomes) => + Option.getOrElse( + Option.map(Arr.head(outcomes), (outcome) => + Match.value(outcome).pipe( + Match.tag('MutantFailed', (failed): string => failed.reason), + Match.orElse((): string => ''), + )), + () => '', + ), + }) + describe('checkMutants', (it) => { it.prop( '∀i_Outcomes_≡OnePerMutantInOrder', @@ -79,4 +103,11 @@ describe('checkMutants', (it) => { onSuccess: (outcomes) => Equal.equals(observedOutcomes(outcomes), expectedOutcomes(input)), }), ) + + it.prop( + '∀diagnostics_FailedReason_≡RenderedLinesJoinedByNewline', + { of: [Checker.CheckerMutantWire, DiagnosticLines], subject: failedReasonOf }, + (subject, [wire, diagnostics]) => + subject(wire, diagnostics) === Arr.map(diagnostics, (line) => line.rendered).join('\n'), + ) }) diff --git a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts index 514393621..db98c3788 100644 --- a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts +++ b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts @@ -918,7 +918,7 @@ const refreshSnapshot = (rt: TSCompilerRuntime, changedFiles: ReadonlyArray => +const annotateDiagnosticSample = (diagnostics: readonly DiagnosticDecoded[]): Effect.Effect => Boolean.match(diagnostics.length === 0, { onTrue: () => Effect.void, onFalse: () => @@ -926,7 +926,7 @@ const annotateDiagnosticSample = (diagnostics: readonly Diagnostic[]): Effect.Ef 'typescript.diagnostics.sample': Arr .map( Arr.take(diagnostics, 10), - (diagnostic) => diagnostic.fileName + ':' + diagnostic.code + ': ' + diagnostic.text, + (diagnostic) => String(diagnostic.fileName) + ':' + diagnostic.code + ': ' + diagnostic.text, ) .join('; '), }), @@ -1365,7 +1365,7 @@ interface OwnedSourceFile { export interface MutantCheck { readonly mutantId: Checker.CheckerMutantWire['id'] - readonly diagnostics: ReadonlyArray + readonly diagnostics: ReadonlyArray readonly tce?: TceClassification } @@ -1432,44 +1432,31 @@ const affectedFileNamesOf = ( return HashSet.fromIterable(Arr.map(affected, (affectedFile) => affectedFile.fileName)) } -const importerErrorsOfMutant = ( - state: CompilerState, - projects: ReadonlyArray, - mutant: Checker.CheckerMutantWire, - fileName: string, -): Effect.Effect => - Effect.map( - importerErrorsOf(projects, affectedFileNamesOf(state, [fileName])), - (diagnostics): MutantCheck => ({ mutantId: mutant.id, diagnostics }), - ) - const beyondOwnErrorsOf = ( state: CompilerState, projects: ReadonlyArray, - mutant: Checker.CheckerMutantWire, owned: OwnedSourceFile, fileName: string, -): Effect.Effect => +): Effect.Effect> => Boolean.match(declaresGlobalScope(owned.sourceFile), { - onTrue: () => - Effect.map(wholeProgramErrorsOf(projects), (diagnostics): MutantCheck => ({ mutantId: mutant.id, diagnostics })), - onFalse: () => importerErrorsOfMutant(state, projects, mutant, fileName), + onTrue: () => wholeProgramErrorsOf(projects), + onFalse: () => importerErrorsOf(projects, affectedFileNamesOf(state, [fileName])), }) const checkedIn = ( state: CompilerState, projects: ReadonlyArray, - mutant: Checker.CheckerMutantWire, owned: OwnedSourceFile, fileName: string, -): Effect.Effect => +): Effect.Effect> => Effect.flatMap(ownErrorsOf(owned, fileName), (own) => Boolean.match(own.length > 0, { - onTrue: () => Effect.succeed({ mutantId: mutant.id, diagnostics: own }), - onFalse: () => beyondOwnErrorsOf(state, projects, mutant, owned, fileName), + onTrue: () => Effect.succeed>(own), + onFalse: () => beyondOwnErrorsOf(state, projects, owned, fileName), })) const checkOne = ( + self: TSCompiler, rt: TSCompilerRuntime, state: CompilerState, mutant: Checker.CheckerMutantWire, @@ -1490,10 +1477,12 @@ const checkOne = ( }) const projects = yield* projectsOf(rt) const owned = yield* projectOfFile(projects, fileName) - return yield* Option.match(owned, { - onNone: () => Effect.succeed({ mutantId: mutant.id, diagnostics: [] }), - onSome: (found) => checkedIn(state, projects, mutant, found, fileName), + const diagnostics = yield* Option.match(owned, { + onNone: (): Effect.Effect> => Effect.succeed([]), + onSome: (found) => checkedIn(state, projects, found, fileName), }) + const rendered = yield* describeDiagnostics(self, diagnostics) + return { mutantId: mutant.id, diagnostics: rendered } }) interface CheckAccumulator { @@ -1659,7 +1648,7 @@ export const check: { (): CheckAccumulator => ({ previous: Option.none(), results: [] }), (previous, mutant) => Effect.map( - checkOne(rt, state, mutant, Option.toArray(previous.previous)), + checkOne(self, rt, state, mutant, Option.toArray(previous.previous)), (result): CheckAccumulator => ({ previous: Option.some(mutant), results: [...previous.results, result] }), ), ) diff --git a/packages/stryker-js-typescript-checker/tests/__fixtures__/per-mutant-position/dep.ts b/packages/stryker-js-typescript-checker/tests/__fixtures__/per-mutant-position/dep.ts new file mode 100644 index 000000000..766866e08 --- /dev/null +++ b/packages/stryker-js-typescript-checker/tests/__fixtures__/per-mutant-position/dep.ts @@ -0,0 +1,20 @@ +export function pad(a: number): void { + let b = a + 1 + b = b + 1 + b = b + 1 + b = b + 1 +} + +export function keep(): string { + return 'kept' +} + +export function also(): void { + pad(2) +} + +export const label = keep() + +export const first = 1 +export const second = 2 +export const broken: number = 0 diff --git a/packages/stryker-js-typescript-checker/tests/__fixtures__/per-mutant-position/tsconfig.json b/packages/stryker-js-typescript-checker/tests/__fixtures__/per-mutant-position/tsconfig.json new file mode 100644 index 000000000..188348632 --- /dev/null +++ b/packages/stryker-js-typescript-checker/tests/__fixtures__/per-mutant-position/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "module": "esnext", + "moduleResolution": "bundler", + "target": "es2022", + "noEmit": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["*.ts"] +} diff --git a/packages/stryker-js-typescript-checker/tests/check-mutants.integration.test.ts b/packages/stryker-js-typescript-checker/tests/check-mutants.integration.test.ts index 3224b3aa5..0f4ee7acf 100644 --- a/packages/stryker-js-typescript-checker/tests/check-mutants.integration.test.ts +++ b/packages/stryker-js-typescript-checker/tests/check-mutants.integration.test.ts @@ -30,6 +30,7 @@ interface Observation { readonly statuses: Readonly> readonly brokenBlamesTheImporter: boolean readonly brokenReason: string + readonly brokenLine: string } type WireInput = S.Codec.Encoded @@ -46,25 +47,35 @@ interface Case { const statusOf = (results: HashMap.HashMap, id: string): string => Option.match(HashMap.get(results, id), { onNone: () => 'missing', onSome: (result) => result.status }) -const observedOf = ( +const brokenReasonOf = ( testCase: Case, - batches: ReadonlyArray>, results: HashMap.HashMap, -): Observation => ({ - batches, - statuses: Object.fromEntries(testCase.observedIds.map((id) => [id, statusOf(results, id)])), - brokenBlamesTheImporter: Option.match(HashMap.get(results, testCase.brokenId), { - onNone: () => false, - onSome: (result) => - result.status === 'compileError' && - result.reason.includes(testCase.importerFile) && - !result.reason.includes(testCase.mutatedFile), - }), - brokenReason: Option.match(HashMap.get(results, testCase.brokenId), { +): string => + Option.match(HashMap.get(results, testCase.brokenId), { onNone: () => '', onSome: (result) => (result.status === 'compileError' ? result.reason : ''), - }), -}) + }) + +const reportingLineOf = (reason: string): string => { + const positioned = /\((\d+),\d+\): /u.exec(reason) + return positioned === null ? '' : positioned[1] ?? '' +} + +const observedOf = ( + testCase: Case, + batches: ReadonlyArray>, + results: HashMap.HashMap, +): Observation => { + const brokenReason = brokenReasonOf(testCase, results) + return { + batches, + statuses: Object.fromEntries(testCase.observedIds.map((id) => [id, statusOf(results, id)])), + brokenBlamesTheImporter: brokenReason.includes(testCase.importerFile) && + !brokenReason.includes(testCase.mutatedFile), + brokenReason, + brokenLine: reportingLineOf(brokenReason), + } +} const checkFixture = ( testCase: Case, @@ -188,6 +199,33 @@ const statementCase: Case = { ], } +const POSITION_BROKEN_ID = '0000000000000011' +const POSITION_SHIFT_ID = '0000000000000012' + +const positionCase: Case = { + fixture: 'per-mutant-position', + brokenId: POSITION_BROKEN_ID, + observedIds: [POSITION_BROKEN_ID, POSITION_SHIFT_ID], + importerFile: 'dep.ts', + mutatedFile: 'dep.ts', + wires: (join) => [ + { + id: POSITION_BROKEN_ID, + fileName: join('dep.ts'), + mutatorName: 'StringLiteral', + replacement: '"nope"', + location: { start: { line: 20, column: 31 }, end: { line: 20, column: 32 } }, + }, + { + id: POSITION_SHIFT_ID, + fileName: join('dep.ts'), + mutatorName: 'BlockStatement', + replacement: '{}', + location: { start: { line: 1, column: 38 }, end: { line: 6, column: 2 } }, + }, + ], +} + const IMPORT_ID = '0000000000000007' const importCase: Case = { @@ -334,6 +372,26 @@ Feature('Deciding every TypeScript mutant on its own', { timeout: 120_000 }) ), ) + scenario( + 'An earlier mutant of one call reports the line its own text put it on', + Gherkin.Do.pipe( + When( + 'a mutant erroring on line 20 and a later mutant that empties the multi-line block above it are handed to the checker runtime in that order', + )('seen', () => checkFixture(positionCase)), + Then('the earlier mutant names line 20 and the later one passes')((s, expect) => + expect({ + batches: s.seen.batches, + statuses: s.seen.statuses, + brokenLine: s.seen.brokenLine, + }).toEqual({ + batches: [[POSITION_BROKEN_ID, POSITION_SHIFT_ID]], + statuses: { [POSITION_BROKEN_ID]: 'compileError', [POSITION_SHIFT_ID]: 'passed' }, + brokenLine: '20', + }) + ), + ), + ) + scenario( 'A mutant that empties a dynamic import specifier keeps the missing-module diagnostic', Gherkin.Do.pipe( From 1e36936f55cc01f747480e2aa6bdf0a7deb4ffef Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Sat, 10 Oct 2026 00:37:37 +0000 Subject: [PATCH 8/9] feat(stryker-js-typescript-checker): report snapshot updates, re-splices and emit builds per check U3 of the TS7 checker plan (R3, KTD6). A per-call tally on the compiler runtime counts snapshot updates, parenthesized re-splices and TCE emit builds and sums emit time; check annotates them on typescript-checker.compiler.check with typescript.counts.schema_version 1. No new span names. No package test (Test Admission refused); the parity lane's zero-updates refusal catches a tally that is never set. --- .changeset/typescript-checker-check-counts.md | 5 +++ .../src/ts-compiler.handle.ts | 40 ++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 .changeset/typescript-checker-check-counts.md diff --git a/.changeset/typescript-checker-check-counts.md b/.changeset/typescript-checker-check-counts.md new file mode 100644 index 000000000..d35e5b5d7 --- /dev/null +++ b/.changeset/typescript-checker-check-counts.md @@ -0,0 +1,5 @@ +--- +"@systemfsoftware/stryker-js-typescript-checker": patch +--- + +With OpenTelemetry enabled, each `typescript-checker.compiler.check` span now reports how much compiler work the check took: `typescript.snapshot_updates.count`, `typescript.resplices.count`, `typescript.tce_builds.count`, `typescript.tce.ms` and `typescript.importer_shortcut.count`, versioned by `typescript.counts.schema_version` (currently `1`). diff --git a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts index db98c3788..9bfa822b3 100644 --- a/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts +++ b/packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts @@ -6,6 +6,7 @@ import { offsetAt } from '@systemfsoftware/stryker-js-instrumenter' import { Checker, type Options } from '@systemfsoftware/stryker-js-plugin-interface' import * as Arr from 'effect/Array' import * as Boolean from 'effect/Boolean' +import * as Duration from 'effect/Duration' import * as Effect from 'effect/Effect' import * as FileSystem from 'effect/FileSystem' import { dual } from 'effect/Function' @@ -16,6 +17,7 @@ import * as Option from 'effect/Option' import * as Order from 'effect/Order' import * as Path from 'effect/Path' import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner' +import * as Ref from 'effect/Ref' import * as Result from 'effect/Result' import * as S from 'effect/Schema' import * as SynchronizedRef from 'effect/SynchronizedRef' @@ -129,6 +131,15 @@ interface CompilerState { readonly tsconfigFile: string } +interface CheckTally { + readonly snapshotUpdates: number + readonly resplices: number + readonly tceBuilds: number + readonly tceMs: number +} + +const emptyTally: CheckTally = { snapshotUpdates: 0, resplices: 0, tceBuilds: 0, tceMs: 0 } + interface TSCompilerRuntime { readonly options: Options.StrykerOptions readonly host: FileSystem.FileSystem @@ -137,6 +148,7 @@ interface TSCompilerRuntime { readonly files: TSFiles readonly sourceFileSystem: TSFileSystem readonly state: SynchronizedRef.SynchronizedRef + readonly tally: Ref.Ref } const TSCompiler = Handle.make()(TypeId) @@ -189,6 +201,7 @@ export const make: { allTSConfigFiles: HashSet.fromIterable([tsconfigFile]), tsconfigFile, }) + const tally = yield* Ref.make(emptyTally) return TSCompiler.make({}, { options, host: services.host, @@ -197,6 +210,7 @@ export const make: { files, sourceFileSystem: tsFileSystem(files), state, + tally, }) }), ) @@ -858,7 +872,11 @@ const parenthesizedSpliceOf = ( fileName: string, changedFiles: ReadonlyArray, ): Effect.Effect => - Effect.flatMap(applyMutant(rt, mutant, '(' + mutant.replacement + ')'), () => refreshSnapshot(rt, changedFiles)) + Effect.flatMap(applyMutant(rt, mutant, '(' + mutant.replacement + ')'), () => + Effect.andThen( + Ref.update(rt.tally, (tally) => ({ ...tally, resplices: tally.resplices + 1 })), + refreshSnapshot(rt, changedFiles), + )) const applyMutant = ( rt: TSCompilerRuntime, @@ -906,6 +924,7 @@ const updateSnapshot = Effect.fnUntraced(function*( ) yield* Effect.promise(() => initialized.snapshot.dispose()) yield* SynchronizedRef.update(rt.state, (prev) => ({ ...prev, snapshot: next })) + yield* Ref.update(rt.tally, (tally) => ({ ...tally, snapshotUpdates: tally.snapshotUpdates + 1 })) }) const refreshSnapshot = (rt: TSCompilerRuntime, changedFiles: ReadonlyArray): Effect.Effect => @@ -1553,6 +1572,17 @@ const emitForFile = ( Effect.provideService(FileSystem.FileSystem, rt.host), Effect.provideService(Path.Path, rt.pathService), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, rt.spawner), + Effect.timed, + Effect.flatMap(([elapsed, emitted]) => + Effect.as( + Ref.update(rt.tally, (tally) => ({ + ...tally, + tceBuilds: tally.tceBuilds + 1, + tceMs: tally.tceMs + Duration.toMillis(elapsed), + })), + emitted, + ) + ), ) const tceScriptOf = (file: Option.Option, fileName: string): Result.Result => @@ -1639,6 +1669,7 @@ export const check: { 'stryker.mutants.count': mutants.length, 'stryker.mutants.ids': Arr.map(mutants, (mutant) => mutant.id).join(','), }) + yield* Ref.set(rt.tally, emptyTally) const state = yield* SynchronizedRef.get(rt.state) yield* resetMutatedFiles(rt, state.lastMutants) const batchFileNames = Arr.dedupe(Arr.map(mutants, (mutant) => resolveFileName(rt, mutant.fileName))) @@ -1661,11 +1692,18 @@ export const check: { const failed = Arr.filter(checked, (entry) => entry.diagnostics.length > 0) const equivalentToOriginal = Arr.filter(checked, (entry) => entry.tce === 'original').length const duplicateAtSite = Arr.filter(checked, (entry) => entry.tce === 'sibling').length + const tally = yield* Ref.get(rt.tally) yield* Effect.annotateCurrentSpan({ 'typescript.diagnostics.count': Arr.reduce(checked, 0, (total, entry) => total + entry.diagnostics.length), 'typescript.compile_errors.count': failed.length, 'typescript.tce.equivalent_to_original.count': equivalentToOriginal, 'typescript.tce.duplicate_at_site.count': duplicateAtSite, + 'typescript.counts.schema_version': 1, + 'typescript.snapshot_updates.count': tally.snapshotUpdates, + 'typescript.resplices.count': tally.resplices, + 'typescript.tce_builds.count': tally.tceBuilds, + 'typescript.tce.ms': tally.tceMs, + 'typescript.importer_shortcut.count': 0, }) yield* annotateDiagnosticSample(Arr.flatten(Arr.map(failed, (entry) => entry.diagnostics))) return checked From 37918adda954a34b8f97ac2292ce962bbdc56b20 Mon Sep 17 00:00:00 2001 From: systemfsoftware-maker Date: Sat, 10 Oct 2026 03:03:18 +0000 Subject: [PATCH 9/9] test(stryker-js-typescript-checker): clear the two lint errors layer 1 added The per-mutant-position fixture's padding variable is now _b (oxlint no-unused-vars); lines and the shift mutant's body span are unchanged. The reason-format law is deleted: the OwnRenderedDiagnostics law already pins every mutant's reason over the full CheckMutantsInput schema, and the duplicate declared a module-scope schema in a test file (schema-declaration-location). --- .../check-mutants.workflow.property.test.ts | 33 +------------------ .../__fixtures__/per-mutant-position/dep.ts | 8 ++--- 2 files changed, 5 insertions(+), 36 deletions(-) diff --git a/packages/stryker-js-typescript-checker/src/__tests__/check-mutants.workflow.property.test.ts b/packages/stryker-js-typescript-checker/src/__tests__/check-mutants.workflow.property.test.ts index d177c1864..bf224071f 100644 --- a/packages/stryker-js-typescript-checker/src/__tests__/check-mutants.workflow.property.test.ts +++ b/packages/stryker-js-typescript-checker/src/__tests__/check-mutants.workflow.property.test.ts @@ -1,4 +1,3 @@ -import { Checker } from '@systemfsoftware/stryker-js-plugin-interface' import { describe } from '@systemfsoftware/vitest' import * as Arr from 'effect/Array' import * as Equal from 'effect/Equal' @@ -6,10 +5,9 @@ import * as HashMap from 'effect/HashMap' import * as Match from 'effect/Match' import * as Option from 'effect/Option' import * as Result from 'effect/Result' -import * as S from 'effect/Schema' import { checkMutants, type CheckOutcome } from '../check-mutants.workflow.js' -import { CheckMutantsInput, type DiagnosticDecoded, DiagnosticLine, MutantVerdict } from '../CheckMutants.schema.js' +import { CheckMutantsInput, type DiagnosticDecoded, MutantVerdict } from '../CheckMutants.schema.js' type Status = 'passed' | 'compileError' | 'ignored' @@ -61,28 +59,6 @@ const observedOutcomes = (outcomes: ReadonlyArray): ReadonlyArray< const decidedIds = (outcomes: ReadonlyArray): ReadonlyArray => Arr.map(outcomes, (outcome) => outcome.id) -const DiagnosticLines = S.NonEmptyArray(DiagnosticLine) - -const wireOf = (wire: Checker.CheckerMutantWire, diagnostics: ReadonlyArray): CheckMutantsInput => - CheckMutantsInput.make({ - mutants: [wire], - verdicts: [MutantVerdict.make({ id: wire.id, diagnostics: [...diagnostics] })], - }) - -const failedReasonOf = (wire: Checker.CheckerMutantWire, diagnostics: ReadonlyArray): string => - Result.match(checkMutants(wireOf(wire, diagnostics)), { - onFailure: () => '', - onSuccess: (outcomes) => - Option.getOrElse( - Option.map(Arr.head(outcomes), (outcome) => - Match.value(outcome).pipe( - Match.tag('MutantFailed', (failed): string => failed.reason), - Match.orElse((): string => ''), - )), - () => '', - ), - }) - describe('checkMutants', (it) => { it.prop( '∀i_Outcomes_≡OnePerMutantInOrder', @@ -103,11 +79,4 @@ describe('checkMutants', (it) => { onSuccess: (outcomes) => Equal.equals(observedOutcomes(outcomes), expectedOutcomes(input)), }), ) - - it.prop( - '∀diagnostics_FailedReason_≡RenderedLinesJoinedByNewline', - { of: [Checker.CheckerMutantWire, DiagnosticLines], subject: failedReasonOf }, - (subject, [wire, diagnostics]) => - subject(wire, diagnostics) === Arr.map(diagnostics, (line) => line.rendered).join('\n'), - ) }) diff --git a/packages/stryker-js-typescript-checker/tests/__fixtures__/per-mutant-position/dep.ts b/packages/stryker-js-typescript-checker/tests/__fixtures__/per-mutant-position/dep.ts index 766866e08..20ebd7924 100644 --- a/packages/stryker-js-typescript-checker/tests/__fixtures__/per-mutant-position/dep.ts +++ b/packages/stryker-js-typescript-checker/tests/__fixtures__/per-mutant-position/dep.ts @@ -1,8 +1,8 @@ export function pad(a: number): void { - let b = a + 1 - b = b + 1 - b = b + 1 - b = b + 1 + let _b = a + 1 + _b = _b + 1 + _b = _b + 1 + _b = _b + 1 } export function keep(): string {