Repository navigation
fix(repo): resolve the e2e fixture bake against a pinned registry snapshot - #285
systemfsoftware-maker wants to merge 26 commits into
Conversation
…pshot Every e2e leg stalled for 20 minutes once @effect/platform-node 4.0.3 was published with a peer of effect ^4.0.3 that no published effect satisfies: the bake's second npm install never settles. The bake now runs every npm install with --before at the root pnpm-lock.yaml commit time, and each staged fixture manifest pins the effect family to the versions that lockfile resolves (direct specs, plus overrides in the fixture root). The cutoff is part of the packs key, the pins are part of each fixture key. An install that runs past 300 s fails with E2E_BAKE_STALLED naming the fixture, the step and the host repro.
…core Wire schemas (npm-wire.schema.ts) leave tests/__fixtures__ and close over npm's lockfile v3 fields; catalog resolution moves from the harness into e2e-core. fixture-lock.ts stages a fixture manifest with its closure specs; find-lock-drift.workflow.ts reports LockMissing, RootOutOfSync, ClosureMemberMissing, ClosureMemberExtra and PinMoved per fixture. U1 of the committed-fixture-lock plan (#285).
committed-fixture-locks stages every fixture against the packed closure and pnpm-lock.yaml, overlays its committed package-lock.json, and runs npm ls --package-lock-only offline. LockMissing, RootOutOfSync, ClosureMemberMissing, ClosureMemberExtra, PinMoved and npm ls problems each print one E2E_PINS_DRIFT line with the fixtures:lock next action. e2e-core#test now hashes the fixtures, pnpm-workspace.yaml and the workspace manifests, so a fixture or catalog change re-runs it. U2 of the committed-fixture-lock plan (#285); red until U3 commits the seven locks.
pnpm --filter @systemfsoftware/stryker-e2e-core fixtures:lock packs the workspace closure, stages each fixture with its catalog and effect-family pins, keeps every locked version that still satisfies its range, and writes a committable lock (closure members as versionless file: tarballs, no integrity). Two runs are byte-identical; the seven locks pass the E2E_PINS_DRIFT check. U3 of the committed-fixture-lock plan (#285).
# Conflicts: # test/e2e/tests/__fixtures__/bake-fixtures.sh
U4 and U5 of the hermetic e2e bake plan. - The bake script (moved to test/e2e-core/bake/) runs one `npm ci` per fixture over the committed package-lock.json, under a 300 s deadline with a KILL 10 s later, four lanes in parallel. - Every registry cutoff path is gone: REGISTRY_CUTOFF, --before, --min-release-age, E2E_BAKE_CUTOFF_STALE, and the hermetic-resolution test. Packed tarballs are versionless, so a release leaves locks as is. - Staging is one pure function, stagedFixtureOf, shared by the harness and the lock generator; regenerating the locks is byte-identical. - Bake failures carry a reason code (E2E_BAKE_STALLED, E2E_BAKE_TARBALL_MISSING, E2E_BAKE_FAILED, E2E_BAKE_OVER_BUDGET, E2E_BAKE_ARGV, E2E_SETUP_FAILED) with a next action. Global setup writes a bake record; each e2e leg's "Bake report" step puts it in the step summary and emits it as ::error annotations, or reports E2E_BAKE_NO_RECORD when there is none.
…stored The bake copied the committed lock into the fixture verbatim. Its closure entries carry no version, so npm ci saw stryker-framework-interface's edge to stryker-ignorer-interface@^0.1.2 as unmet and resolved it from the registry (ETARGET on CI run 38071044590). The check never installs the committed lock either: it overlays each closure entry with the packed member's version and dependency fields (overlayOf) first. The harness now stages package-lock.json through the same overlayOf, so the bake installs exactly the lock check validated, and the fixture key covers it. The release-bump scenario now also runs npm ci on the bumped closure and asserts both members install at the new version offline.
…s the lock The harness packed every package in turbo's build graph for its entry packages, toolchain configs and build-only packages included, and listed each as a file: dependency of the staged fixture. The committed locks hold closureMembersOf, the installed-dependency closure the generator packs, so npm ci refused every lock as out of sync (EUSAGE, CI run 38071662754). The harness now reads the workspace manifests once and packs exactly closureMembersOf, the function the generator and check use. The turbo dry-run closure, its parser and its schemas are gone. A failed npm ci's reason also carries npm's Missing/Invalid lock lines.
# Conflicts: # test/e2e-core/src/catalog-resolution.schema.ts # test/e2e-core/src/catalog-resolution.ts # test/e2e-core/src/mod.ts # test/e2e/src/Harness/fixture-cache.service.ts
Proves on CI that a fixture install outrunning its deadline fails the bake with E2E_BAKE_STALLED in the step summary and an ::error annotation.
…ws)" This reverts commit ddda281.
npm ls exits non-zero with an {"error": ...} report and no problems key when it cannot load a lock (ENOLOCK on npm 11.19.0). The check read that as no problems and passed. admitFixtureLock now decides LockAdmitted, LockProblemsListed or LockUnreadable from the listing, and an unreadable lock reports E2E_PINS_LOCK_UNREADABLE with npm's error code and the relock next action.
…ails install() exited the backgrounded lane subshell on the first failure, so later fixtures in that lane were never installed and never named in the bake report. It now returns its code; each lane records a failure and keeps going, and the script exits non-zero once after every lane finishes. Covered with six fixtures on four lanes.
Pins covered only the effect family and catalog: specs in a fixture root, so catalog packages that packed members pull in (@opentelemetry/sdk-trace-base 2.12.0 in the locks vs 2.11.0 in pnpm-lock.yaml) drifted unseen. Every pnpm-lock.yaml catalog package that the workspace resolves to one version is now a registry pin: it overrides the fixture install and the drift check names its move. Catalog packages the workspace itself holds at several versions (@oxc-project/types, typescript, @effect/tsgo) stay unpinned, since an npm override cannot reproduce that split. Fixture locks regenerated.
The bake ran as one guest job under one budget, so E2E_BAKE_OVER_BUDGET could not say whether the image pull and microVM boot or npm ci used the time. The bake now boots an idle microVM (GuestJobs.boot), then execs the script in it (GuestJobs.requireCleanExec, which keeps the e2e.guest.job span and maps a signal exit to GuestSignaledFailure). The budget is unchanged and still covers both phases; an over-budget reason names the phase it ran out in and, when installing, how long pull and boot took. The bake record's summary reports both times.
Merging main dropped its parseWorkspaceCatalogs laws. They return drawn from the production schemas (DependencySpecs, WorkspaceCatalogsYaml) written as JSON, which is YAML: the catalog section and catalogs.default both become the default catalog, and every other catalogs entry becomes a named catalog.
The drift check derives fixture pins from the root pnpm-lock.yaml, but turbo hashed only e2e-core's own lockfile closure, so a resolution move outside that closure replayed a cached green check (review #285, finding A). A dry-run moving a version in test/checker-parity's importer kept the task hash at 841ff03a before this change and moves it after.
…drift npm ls's report is now the Encoded side of LockListingJson, decoded into LockProblemReport | LockUnreadableReport, so admitFixtureLock dispatches on a union instead of probing optionals (K). checkFixtureLock fails when the drift workflow does not decide instead of dropping it as no drift (E). PackumentJson moves into the test registry that is its only user (P).
Boot gets its own 7.2 s budget (E2E_BAKE_BOOT_OVER_BUDGET), each npm ci a 320 s deadline (E2E_BAKE_INSTALL_STALLED), and the whole bake a 337.2 s ceiling (E2E_BAKE_OVER_BUDGET) with all 7 fixtures in one round; numbers are 3x the CI-measured pull+boot (2.4 s) and install-phase wall (103.9 s). Phases are a NothingBaked | Baked union, BakeDone/BakeFailed are tagged structs with baked <= fixtures checked. requireCleanExec reports every non-zero exit as an ExitFailure instead of guessing a signal; the summary digests the committed lock and says so; the script range-checks argv, points stalls at a staged reproducer and names a missing tarball only for packed closure members. Review #285 findings B, C, D, F, G, H, I, J, L, M, N, O, Q.
NpmLockfile, NpmLockEntry, NpmManifestJson and PinnedFixtureManifestJson held String-keyed records and S.Json metadata, so the generated laws drew keys V8 13.6 JSON.parse corrupts (nodejs/node#63785): the CI replay for ∀x_NpmLockfileJson_=x shrank to a "\u0000" packages key. Every dependency-map, bin and peerDependenciesMeta key is an npm package name; packages keys are "", node_modules/<name> nested any depth, or a workspace path, the three shapes the seven committed fixture locks hold. bin, license, engines and funding take npm's lock shapes instead of S.Json; they stay declared because overlayOf and committableLockOf write locks back through this codec. The committed-locks feature now reads every committed lock directly and refuses a lone-surrogate, NUL or trailing-backslash key by path.
# Conflicts: # test/e2e-core/src/catalog-resolution.schema.ts # test/e2e-core/src/catalog-resolution.ts
Unfixed String-keyed records /
|
| site | schema | construct | mode | src/test |
|---|---|---|---|---|
| packages/stryker-js/src/admit-reuse-source.workflow.ts:57 | ReuseReportSchema | IncrementalDiff.schema.ts:123 files: S.Record(S.String, ReuseFileSchema) (also :117 ReuseTestFilesSchema) |
decode | src |
| packages/stryker-js/src/audit.schema.ts:25 | KillMatrixReport | audit.schema.ts:18 & :22 S.Record(S.String, …) |
both | src |
| packages/stryker-js/src/audit.schema.ts:42 | CountedReport | audit.schema.ts:30 & :39 S.Record(S.String, …) |
both | src |
| packages/stryker-js/src/audit.schema.ts:212 | AuditReport | (AuditReport = Union[DropAuditPassed, DropAuditFailed, CountsReport]; CountsReport→audit.schema.ts:44+ embeds KillMatrixReport/CountedReport Record(S.String, …)) |
both | src |
| packages/stryker-js/src/framework-claimant.service.ts:44 | ProjectDependencies | Plugins.schema.ts:130–131 dependencies/devDependencies: S.Record(S.String, S.Unknown) |
decode | src |
| packages/stryker-js/src/import-closure.cell.ts:317 | PackageManifestSchema | import-closure.schema.ts:35 exports: … S.Record(S.String, ExportEntrySchema) |
decode | src |
| packages/stryker-js/src/json-report.cell.ts:50 | S.Unknown | inline S.fromJsonString(S.Unknown, {space:0}) |
encode-only | src |
| packages/stryker-js/src/mutation-reporting.service.ts:715 | S.Unknown | inline S.Unknown |
encode-only | src |
| packages/stryker-js/src/mutation-reporting.service.ts:716 | S.Record(S.String, S.Json) | inline :716 | decode | src |
| packages/stryker-js/src/mutation-reporting.service.ts:892 | S.Unknown | inline S.Unknown, {space:2} |
encode-only | src |
| packages/stryker-js/src/mutation-reporting.service.ts:921 | S.Unknown | inline S.Unknown, {space:2} |
encode-only | src |
| packages/stryker-js/src/mutation-reporting.service.ts:1054 | S.Unknown | inline S.Unknown |
encode-only | src |
| packages/stryker-js/src/mutation-reporting.service.ts:1069 | IncrementalReportObjectSchema | mutation-reporting.schema.ts:14 S.Record(S.String, S.Json) |
decode | src |
| packages/stryker-js/src/mutation-reporting.service.ts:1089 | S.Unknown | inline S.Unknown |
encode-only | src |
| packages/stryker-js/src/plan-request.cell.ts:86 | CostsFieldSchema | plan-request.schema.ts:7 [S.Record(S.String, S.Json)] rest (also :4 costs Record) |
decode | src |
| packages/stryker-js/src/plan-request.cell.ts:99 | ReportedDryRunCoverageSchema | dry-run-coverage.schema.ts:6 -> Mutant.schema.ts:199–201 perTest/static: S.Record(S.String, …) |
decode | src |
| packages/stryker-js/src/plan-request.cell.ts:190 | CompileErrorProbeSchema | plan-request.schema.ts:9 files: S.Record(S.String, …) |
decode | src |
| packages/stryker-js/src/read-project.cell.ts:193 | IncrementalReportSchema | IncrementalReport.schema.ts:57/:59/:63 (Record(String,…) + [Record(String,Unknown)] rest) | decode | src |
| packages/stryker-js/src/reporter-factories.ts:134 | S.Unknown | inline S.Unknown, {space:2} |
encode-only | src |
| packages/stryker-js/src/run-request.cell.ts:243 | VerdictSchema | compare-verdicts.workflow.ts:17 files: S.Record(S.String, …) |
decode | src |
| packages/stryker-js/src/run-request.cell.ts:302 | PriorReportDocument | Survivors.schema.ts:6 config: S.Record(S.String, S.Unknown) (+ :9 files Record) |
decode | src |
| packages/stryker-js/src/run-request.cell.ts:511 | Report.MutationTestResult | report.schema.ts:60 StructWithRest(…, [Record(String, Json)]) (+ many member rests) |
decode | src |
| packages/stryker-js/src/Feedback/read-report.ts:45 | Report.MutationTestResult | report.schema.ts:60 | decode | src |
| packages/stryker-js/src/Rerun/Rerun.cell.ts:55 | PriorReportDocument | Survivors.schema.ts:9 files: S.Record(S.String, …) |
decode | src |
| packages/stryker-js/src/Serve/msp.schema.ts:166 | JsonRpcResponse | msp.schema.ts:100 result: S.Unknown |
encode | src |
| packages/stryker-js/src/Serve/msp.schema.ts:168 | ReportMutationTestProgress | msp.schema.ts:152 files: S.Record(S.String, …) |
encode | src |
| packages/stryker-js/src/Serve/msp.schema.ts:171 | JsonRpcRequest | msp.schema.ts:72 params: S.optionalKey(S.Json) |
decode | src |
| packages/stryker-js/src/Survivors/Survivors.cell.ts:134 | S.Unknown | inline S.Unknown |
decode | src |
| packages/stryker-js/tests/compile-error-reuse.integration.test.ts:240 | reportSchema (inline) | compile-error-reuse.integration.test.ts:236 files: S.Record(S.String, …) |
decode | test |
| packages/stryker-js/tests/cost-model.integration.test.ts:84 | Engine.IncrementalReportSchema | IncrementalReport.schema.ts:57/:63 | decode | test |
| packages/stryker-js/tests/diff-scope.integration.test.ts:132 | Report.MutationTestResult | report.schema.ts:60 | decode | test |
| packages/stryker-js/tests/empty-file-selection.integration.test.ts:77 | Report.MutationTestResult | report.schema.ts:60 | decode | test |
| packages/stryker-js/tests/framework-run.integration.test.ts:356 | Engine.IncrementalReportSchema | IncrementalReport.schema.ts:57/:63 | decode | test |
| packages/stryker-js/tests/incremental-report-roundtrip.integration.test.ts:170 / :225 | Engine.IncrementalReportSchema | IncrementalReport.schema.ts:57/:63 | decode | test |
| packages/stryker-js/tests/incremental-report-roundtrip.integration.test.ts:227 | inline Struct | :229/:230 framework/config: S.optional(S.Unknown) |
decode | test |
| packages/stryker-js/tests/incremental-reuse.integration.test.ts:56 / :489 | Engine.IncrementalReportSchema | IncrementalReport.schema.ts:57/:63 | decode | test |
| packages/stryker-js/tests/incremental-reuse.integration.test.ts:450 / :452 / :465 / :467 | S.Record(S.String, S.Unknown) | inline | both | test |
| packages/stryker-js/tests/mutant-location-parity.integration.test.ts:109 | Report.MutationTestResult | report.schema.ts:60 | decode | test |
| packages/stryker-js/tests/mutation-server.integration.test.ts:149 / :225 | S.Json | inline | both | test |
| packages/stryker-js/tests/plan-costs.integration.test.ts:140 | Engine.IncrementalReportSchema | IncrementalReport.schema.ts:57/:63 | decode | test |
| packages/stryker-js/tests/plugin-mutator-provider.integration.test.ts:81 | Report.MutationTestResult | report.schema.ts:60 | decode | test |
| packages/stryker-js/tests/rerun-by-id.integration.test.ts:116 | ReportSurvivors | rerun-by-id.schema.ts:11 files: S.Record(S.String, …) |
decode | test |
| packages/stryker-js/tests/sarif-reporter.integration.test.ts:133 | Report.MutationTestResult | report.schema.ts:60 | decode | test |
| packages/stryker-js/tests/shard-merge.integration.test.ts:87 | Report.MutationTestResult | report.schema.ts:60 | decode | test |
| packages/stryker-js/tests/shard-merge.integration.test.ts:109 | inline Struct | :109 costs: optional(S.Record(S.String, S.Unknown)) |
decode | test |
| packages/stryker-js/tests/shard-merge.integration.test.ts:323 | S.Record(S.String, S.Unknown) | inline | decode | test |
| packages/stryker-js/tests/slow-reporter-terminal-report.integration.test.ts:69 | Report.MutationTestResult | report.schema.ts:60 | decode | test |
| packages/stryker-js/tests/uncovered-mutant-checking.integration.test.ts:125 | Report.MutationTestResult | report.schema.ts:60 | decode | test |
| packages/stryker-js/tests/wall-clock-timeout.integration.test.ts:126 | Report.MutationTestResult | report.schema.ts:60 | decode | test |
| packages/stryker-js/tests/worker-launcher.integration.test.ts:79 | Options.StrykerOptionsSchema | stryker-options.schema.ts:289 [S.Record(S.String, S.Unknown)] |
decode | test |
| packages/stryker-js/tests/fixtures/check-cost-workspace.fixture.ts:238 | Engine.IncrementalReportSchema | IncrementalReport.schema.ts:57/:63 | decode | test |
| packages/stryker-js/tests/fixtures/reusable-report.schema.ts:24 | ReusableReportSchema | reusable-report.schema.ts:5 files: Record(String,…) + :11 testFiles: optional(Record(String, Unknown)) (+ :17–18 perTest/static Records) |
decode | test |
| packages/stryker-js-cli-contract/tests/fixtures/contract-compat.fixture.ts:33 | JsonDocument = S.Json | inline | decode | test |
| packages/stryker-js-plugin-runtime/src/worker-options.schema.ts:46 | Options.StrykerOptionsSchema (via OptionsLawDomain) | stryker-options.schema.ts:289 | both | src |
| packages/stryker-js-typescript-checker/src/tce-emit.ts:84 | S.Unknown | inline S.Unknown |
encode-only | src |
| packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts:489 | inline Struct | :489 exports: optional(S.Json), tsconfig: optional(S.Json) |
decode | src |
| packages/stryker-js-vitest-runner/src/VitestRuntime.blueprint.ts:53 | S.Record(S.String, S.Unknown) | inline | decode | src |
| test/bench/src/prepare-side.service.ts:356 | PackedManifest | e2e-core install-closure.schema.ts:3 EdgeSpecs = optional(Record(String,String)) (+ :5 meta) |
decode | test |
| test/bench/src/prepare-side.service.ts:393 | FixtureManifest | e2e-core install-closure.schema.ts:3/:5 | decode | test |
| test/checker-parity/src/run-side.ts:86 / :87 | ParityLine | Parity.schema.ts:93 Counts.fallbacks: S.Record(S.String, NonNegativeFinite) |
both | test |
| test/e2e-core/src/covering-test-files.workflow.ts:17 | IncrementalReportSubset | covering-test-files.workflow.ts:10 & :14 S.Record(S.String, …) |
decode | test |
| test/e2e-core/src/decode-report.workflow.ts:6 | Report.MutationTestResult | report.schema.ts:60 | decode | test |
| test/e2e/src/Harness/fixture-cache.service.ts:576 | PackedManifest | install-closure.schema.ts:3/:5 | decode | test |
| test/e2e/src/Harness/fixture-cache.service.ts:586 | FixtureManifest | install-closure.schema.ts:3/:5 | decode | test |
| test/e2e/src/Harness/fixture-cache.service.ts:835 | FixtureKeys | bake-key.schema.ts:41 Schema.Record(Schema.String, Schema.String) |
decode | test |
| test/e2e-core/tests/fixtures/fixture-locks.ts:97 | PackedManifest | install-closure.schema.ts:3/:5 | decode | test |
| test/e2e-core/tests/fixtures/fixture-locks.ts:108 | WorkspaceManifest | install-closure.schema.ts:8–12 EdgeSpecs/meta Records (branch install-closure.schema.ts:3/:5) | decode | test |
| test/e2e-core/tests/fixtures/fixture-locks.ts:218 | S.Record(S.String, S.Json) | inline | encode-only | test |
| test/e2e-core/tests/fixtures/local-registry.ts:13 | PackumentJson | local-registry.ts:15–16/:25 Record(String, …) (dist-tags, versions, time) |
both | test |
| test/e2e/src/Harness/fixture-cache.service.ts:488 | WorkspaceManifest | branch install-closure.schema.ts:3/:5 | decode | test |
| test/e2e/src/Harness/fixture-cache.service.ts:512 | PackedManifest | branch install-closure.schema.ts:3/:5 | decode | test |
| test/e2e/src/Harness/fixture-cache.service.ts:885 | FixtureKeys | bake-key.schema.ts:41 Record(String, String) |
decode | test |
|
Closing without merging: this doesn't land small (about 3,430 hand-written lines in 41 files, beyond the generated fixture lockfiles), and it adds per-install and whole-bake kill caps, which the ci-wall-clock rule forbids. Main keeps the current bake without its 300 s cap (removed in the e2e-postsubmit PR). If the age gate (--min-release-age=1) ever blocks a needed release, the replacement is a fresh PR off main with only: a committed package-lock.json per fixture, one lock-regeneration script, and one uncapped |
pnpm --filter @systemfsoftware/stryker-e2e fixtures:lock runs BakedFixtureCache.lockProgram: it stages every fixture the way the bake does, runs npm install --package-lock-only over the filled committed lock and writes it back with each closure tarball's integrity and version stripped. The script is the harness entry point under tsx, which test/e2e now declares. The seven locks are regenerated from #285's, which pin effect, @effect/platform-node, @effect/platform-node-shared and @effect/opentelemetry at 4.0.0, as pnpm-lock.yaml does.
* docs(plans): e2e fixtures install from committed locks Requirements-only plan for fix/e2e-locked-fixtures: one npm ci per fixture from a committed package-lock.json, closure entries overlaid from packed manifests, no age gate, timeout or second install. Answers the root P0's four questions with registry, stalled-log and npm ci probe evidence. * docs(plans): dprint the e2e locked fixtures plan * docs(plans): plan the e2e locked fixtures in four units Adds the planning contract (KTD1-KTD7), units U1-U4, verification contract and definition of done to the brainstorm plan; R4 copies npm's manifest-sourced lock fields and R9 counts tests and docs, per the root's rulings. * feat(e2e-core): fill and strip a fixture lock's closure tarball entries Adds the pure closure-lock functions the bake and the lock script share: filledLockOf copies npm's manifest-sourced lock fields onto each file: tarball entry from its packed manifest, committableLockOf drops the integrity and version a source change or release moves, and withClosureDevDependencies lists the closure on the fixture root. ClosureInstall gains the closure as a name-to-spec record. * fix(e2e): bake each fixture with one npm ci from its committed lock The bake no longer resolves: it stages each fixture with the packed closure as root devDependencies and the committed package-lock.json filled from the freshly packed manifests, then runs one npm ci per fixture with no timeout, budget or age gate. A drifted or missing lock fails with npm ci's own output and exit code plus a line naming fixtures:lock. Closure tarballs pack under version-free names (pnpm pack --out), so a release does not move the specs the locks record. The staging path is exported as BakedFixtureCache.stageProgram for the lock script. * refactor(e2e-core): fill only the lock fields npm ci validates A closure entry is filled with version, which npm ci compares against the ideal tree (npm/lib/utils/validate-lockfile.js), and the four fields arborist turns into edges (dependencies, optionalDependencies, peerDependencies, peerDependenciesMeta: @npmcli/arborist/lib/node.js), so a new dependency fails npm ci with Missing ... from lock file. Every other committed field is kept as written. * test(e2e-core): property-test the closure lock fill-in Covers AE3 (a stale closure entry takes the packed manifest's version and dependencies, other fields kept), AE4's precondition (a dependency the lock lacks lands in the filled entry), and a registry entry passing through unchanged with its integrity. * refactor(e2e-core): filter lock fields with Record.filter directly * feat(e2e): regenerate the fixture locks with one script pnpm --filter @systemfsoftware/stryker-e2e fixtures:lock runs BakedFixtureCache.lockProgram: it stages every fixture the way the bake does, runs npm install --package-lock-only over the filled committed lock and writes it back with each closure tarball's integrity and version stripped. The script is the harness entry point under tsx, which test/e2e now declares. The seven locks are regenerated from #285's, which pin effect, @effect/platform-node, @effect/platform-node-shared and @effect/opentelemetry at 4.0.0, as pnpm-lock.yaml does. * docs(e2e): describe the npm ci bake from committed fixture locks * docs(plans): supersede the locked fixtures plan with the five-field fill-in The sub-conductor ruling narrows R4 to the fields npm ci checks, the test-discipline lint puts the property suite in-source, the lock loop moves into the harness as lockProgram, and U4 drops the hang lesson. * fix(e2e-core): fill a closure entry's bin from its packed manifest npm links bins from the lock's package metadata, not from the extracted tarball (arborist rebuild.js binLinks({ pkg: node.package })), so a closure member that changed its bin installed with the stale link. The fill-in now copies bin with the version and edge fields. * refactor(e2e-core): name the lock's edge fields once * docs(e2e): describe what npm ci catches and what the bake refills README names the drift npm ci reports (a fixture range or a closure edge) and the bin the bake copies from the packed manifest. The lane's AGENTS.md and the alias lesson's guard bullet now describe the npm ci bake instead of the positional-spec install.
Design update: the bake resolves nothing
Outcome. Every fixture dependency, direct and transitive, installs at a version committed in the repo. The bake makes no registry resolution choice: no
--before, noREGISTRY_CUTOFF, no--min-release-age.Mechanism. Each fixture under
test/e2e/testResources/<fixture>/commits apackage-lock.json. Its root manifest declares the packed workspace closure asfile:tarball specs under the guest packs root, next to the fixture's own registry dependencies. The bake runs onenpm ciper fixture.npm ciinstalls the lock exactly and never resolves, so one install replaces today's two (registry dependencies, then closure tarballs).Workspace tarball entries carry no
integrity. npm records a sha512 for afile:tarball and failsnpm ciwithEINTEGRITYonce the tarball changes, which happens on any source edit to a packed package. Without the hash,npm ciinstalls the current tarball. That entry is the code under test, not a registry choice. Registry entries keep theirresolvedURL andintegrity.The lock is generated, never hand-edited. A workspace script stages each fixture manifest (catalog specs resolved, closure tarball specs added). It seeds the versions the root
pnpm-lock.yamlresolves asoverrides, today'sregistryPinsOf, so fixtures run the sameeffectfamily as the workspace. Then it runsnpm install --package-lock-onlyon the host, stripsintegrityfromfile:entries and writes the lock. Registry resolution happens only here, at a commit a reviewer sees.Drift check in the required
checklane. It is a pure, offline check in@systemfsoftware/stryker-e2e-core, run by the Turbo-cachedtesttask thatpnpm check:cialready runs, so it needs no new job or gate. For each fixture it asserts that:workspace:andcatalog:rewritepnpm packapplies, declares exactly the dependencies its lock entry lists;pnpm-lock.yamlpins is locked at that version.A failure reports
E2E_PINS_DRIFT, naming the fixture, the package and the field, with the next action "run the lock generator and commit the result", in the step summary and as an::errorannotation.When a dependency bumps. A Dependabot or human bump to
pnpm-lock.yaml, a catalog entry, or a packed package's dependencies failscheckwithE2E_PINS_DRIFTuntil the generator's output is committed on the same PR. A release that renames the packed tarballs (the version is in the file name) fails the same way.Kept from U0/U5, retargeted.
E2E_BAKE_STALLEDand a next action. Each fixture now has one install, so the derived budget halvesINSTALLS_PER_FIXTURE.E2E_BAKE_OVER_BUDGET), andE2E_BAKE_NO_RECORD.::errorannotations.bake-fixtures.shintegration test against a local registry. Its scenarios become: a stall past the deadline, and a lock entry whose tarball the registry does not serve, which fails loudly and installs nothing else.E2E_BAKE_CUTOFF_STALE,ENOVERSIONSmapping,REGISTRY_CUTOFFand--beforeare removed. The packs key drops the cutoff.Renames.
test/e2e-core/tests/__fixtures__/npm-closure.schema.tsholds the npm manifest, lockfile and packument schemas. The drift check consumes it, so it moves into the core astest/e2e-core/src/npm-wire.schema.ts. The local test registry (tests/__fixtures__/local-registry.tson U5) moves out of__fixtures__astests/local-registry.ts.Evidence (host probes, npm 11, fresh cache per install)
npm ciwith the lock'sfile:tarball unchangedintegrityEINTEGRITYintegrityremoved from thefile:entrypnpm-lock.yamlcoverage of fixture dependenciestestResourcesimporter.@sveltejs/vite-plugin-svelteand@testing-library/svelte(svelte fixture) are catalog entries with no lock entryThe fourth row is why the drift check is required: once
npm cihas a committed lock, nothing else notices a closure dependency change.Rejected
pnpm-lock.yamlat bake time (today'soverridespins, widened).pnpm-lock.yamlhas no entry for two svelte fixture dependencies. Any transitive dependency not overridden is still resolved by npm at bake time. Overriding all of them means writing npm's tree, which is a lockfile.pnpm-lock.yamlinto npm lockfiles. This has the same coverage gap. pnpm's peer-suffixed snapshot graph and npm's hoistednode_moduleslayout differ, so the translator would be a second resolver to maintain.node:24-alpinewith npm, and the lane exists to prove the published closure installs with npm (npm-alias edges,install-closuretests).--min-release-age,--before). This is ruled out. Either gate still resolves at bake time, so a release inside the window can change a fixture.npm-shrinkwrap.jsonin the published tarballs. It would pin real consumers' installs, which is outside the e2e lane's job.Deferred to planning
packWorkspaceClosureversuspnpm packin the script). Whethernpm cilock entries for the npm-alias closure edges (<alias>@file:<target>) round-trip through the generator unchanged.npm-wire keys (stacked on #306)
test/e2e-core/src/npm-wire.schema.tskeys npm manifest dependency maps by npm package name and lockfilepackagesby lock path ("",node_modules/<name>at any depth, or a workspace path), the shapes the seven committed fixture locks hold. A key that would need a JSON escape (lone surrogate, NUL, trailing backslash) is refused at its path, so the V8JSON.parsekey bug (nodejs/node#63785, Node 24 / V8 13.6) can no longer flake the generated laws. The feature "Committed fixture locks agree with the repository" decodes every committed lock and refuses an escaped-key lock by path. Unfixed sites elsewhere: inventory comment.npm-wire.schema.tslaw file (NpmManifestJson,NpmLockfileJson,PinnedFixtureManifestJsonand theirEnclaws), local,env -u AGENT CI=true, Node 24.21 / V8 13.6, 200 independent unpinned runs, seed store cleared each run80e0e2e9b630413948The 17 before-failures span all three schemas and both law directions (
=xandEnc_=x).Plan: committed fixture lockfiles installed with npm ci
artifact_contract: ce-unified-plan/v1·product_contract_source: ce-brainstorm·execution: code· 2026-10-10. Report-onlyce-doc-reviewfindings await the operator's rulings; none applied.Goal Capsule
checklane fails, names the fixture and package, and says how to fix it.package-lock.json, the bake runs onenpm ciper fixture, and an offline drift check runs in thetesttask ofcheck:ci(KTD1-KTD3).npm ci;E2E_PINS_DRIFTcheck in the required lane), then this plan, thentest/e2e/AGENTS.mdandCONSTITUTION.md.stryker/bake-release-age) is not merged onmain. Stop if a regenerated lock for any fixture differs from the committed one when regeneration runs twice in a row (KTD4 no longer holds).fix/hermetic-e2e-bake(fix(repo): resolve the e2e fixture bake against a pinned registry snapshot #285) after a plaingit merge origin/mainthat brings in fix(e2e): keep day-old releases out of the fixture bake and bound each install #284, with no rebase and no force-push. Light local verification only: typecheck, lint, e2e-core tests, actionlint, dprint. The e2e lanes run in CI.Product Contract
Summary
Replace the bake's registry resolution (two
npm installs per fixture,--before=<cutoff>, effect-family pins) with committed lockfiles thatnpm ciinstalls verbatim. An offline check keeps those lockfiles in step with the workspace.Problem Frame
On 2026-10-10,
@effect/platform-node4.0.3 was published with a peereffect ^4.0.3that had no matching release. Every e2e leg then hung in the bake until the 1200 s step timeout. #284 bounds the hang with--min-release-age=1and a 300 s per-install timeout. #285's first design added a--beforecutoff and effect pins. Both still choose versions at bake time from a live registry, so the next unlucky release can break the lanes again. The ruling removes every resolution choice from the bake.Requirements
npm cifrom a committedpackage-lock.json. No--before,--min-release-ageor date cutoff remains anywhere in the bake path.file:tarballs, with nointegrityand noversionon those entries. Registry entries keep their URL andintegrity.pnpm-lock.yamlresolves. Fixtures therefore run theeffectversions the workspace builds against.testtask ofcheck:cifails withE2E_PINS_DRIFT, naming the fixture, the package and the next action, whenever a committed lock no longer matches the fixture manifest, the closure's packed manifests or thepnpm-lock.yamlpins. A release bump that only changes closure versions is not drift.::errorannotations:E2E_BAKE_STALLED(per-install deadline, with a next action),E2E_BAKE_OVER_BUDGET(whole-bake budget derived from one install per fixture),E2E_BAKE_TARBALL_MISSING,E2E_BAKE_LOCK_OUT_OF_SYNC,E2E_BAKE_ARGV,E2E_BAKE_FAILEDandE2E_BAKE_NO_RECORD.REGISTRY_CUTOFF,registryCutoffin the packs key,--beforeargv, theETARGET/ENOVERSIONSmapping,E2E_BAKE_CUTOFF_STALEand their README rows (DEL1).__fixtures__.test/e2e-core/tests/__fixtures__/npm-closure.schema.tsbecomestest/e2e-core/src/npm-wire.schema.ts, andlocal-registry.tsbecomestest/e2e-core/tests/local-registry.ts. The__fixtures__directory undertest/e2e-core/tests/is removed.Key Decisions
npm ci. Settled by the operator ruling and the probe evidence. Rejected: pinning frompnpm-lock.yamlat bake time (misses the svelte fixture's packagespnpm-lock.yamldoesn't carry), a pnpm-to-npm lock translator (a second version picker), a vendored registry mirror (314 MB of tarballs), pnpm inside the guest, age or date cutoffs, andnpm-shrinkwrap.jsonin published packages. Governs R1-R3.E2E_PINS_DRIFTruns in the requiredchecklane. Settled by the probe:npm ciexits 0 and silently skips a dependency that a closure member gained after the lock was written. Governs R4.Scope Boundaries
actions/cachesave and restore) stays U5 scope onci/wall-clock-u5. This plan ports only U5's bake report, budget and reason-code pieces it keeps (KTD7).checkuntil someone runs the generator and commits the result. The failure message names the command.Planning Contract
Key Technical Decisions
<scope>-<name>.tgz, without the version. With noversionand nointegrityonfile:entries, a release bump leaves every lock byte-identical. Probe:npm ciinstalls a 1.0.1 tarball under a lock entry recording 1.0.0 (exit 0), and also when the entry has noversionat all.file:specs relative to the guest layout (/baked/<id>→../../packs/<file>).npm cirequires the manifest and the lock to agree: an earlier probe failed with "not in sync" on a mismatch. So the closure that fix(repo): resolve the e2e fixture bake against a pinned registry snapshot #285 passes as install argv moves into the manifest, through one staging function used by the bake, the generator and the drift check.npm lsoracle. The pure core (e2e-core) compares bytes it can decide on: the fixture has a lock; the lock's root entry matches the staged manifest; the lock'sfile:entries match the closure members exactly; the effect-family locked versions equal thepnpm-lock.yamlpins. The shell overlays the current packed manifests (version and dependency fields) onto a temporary copy of the lock, then runsnpm ls --package-lock-only --all --offline. Probe:lsexits 1 withELSPROBLEMS, naming the package, for a member dependency the lock lacks, for a range the locked version misses, and for a root dependency the lock lacks. It exits 0 on a clean lock. Offline regeneration is not a usable oracle:npm install --package-lock-only --offlineover an existing lock left it byte-identical when a member gained a dependency.pnpm packand no build: the probe packedstryker-js-vitest-runnerunbuilt in 0.4 s, with catalog specs rewritten to concrete ranges, and a lock depends only on manifests. It stages each fixture at<tmp>/baked/<id>beside<tmp>/packs, runs hostnpm install --package-lock-only --ignore-scriptsonline, then stripsintegrityandversionfromfile:entries. Probe: alias entries survive (name: "@sfs/runner"undernode_modules/@sfs/vm-runner), regeneration from scratch and over an existing lock is byte-identical, andnpm ciinstalls edited tarball contents under an unchanged lock.registryCutoffleavesPackInputandpacksKeyBytes. Each fixture's committed lock is in its source tree, so the fixture key already moves with it.e2e-coretesttask adds$TURBO_ROOT$/pnpm-lock.yaml,$TURBO_ROOT$/pnpm-workspace.yaml,$TURBO_ROOT$/packages/**/package.jsonand$TURBO_ROOT$/test/e2e/testResources/**. Otherwise a pin-only PR could get a cache hit that silently passes the drift check.bake-report.tsand its schema), the budget (bake-budget.ts),E2E_BAKE_NO_RECORD, the CI bake-report step andbake-fixtures.integration.test.tscome fromci/wall-clock-u5(1510c2382) as a path checkout onto fix(repo): resolve the e2e fixture bake against a pinned registry snapshot #285, then get retargeted.ci/wall-clock-u5later merges fix(repo): resolve the e2e fixture bake against a pinned registry snapshot #285 up, so only the cache diff stays U5's.High-Level Technical Design
Assumptions
node:24-alpine, npm 11) installs lockfile v3 written by host npm 11.19.0. Both are npm 11; the e2e lanes confirm this on the first CI run.enterprise-monorepo-fixture(workspaces: ["packages/*"]) generates and installs undernpm cilike the single-package fixtures. U3 proves this on all seven fixtures.Sequencing
#284 merges to
main, then a plain merge oforigin/mainintofix/hermetic-e2e-bake, then U1 → U2 → U3 → U4 → U5 in order, then push and watch CI. Afterwards, plain merges offix/hermetic-e2e-bakeintoci/wall-clock-u5and oforigin/mainintoci/wall-clock-u1(#279, whose e2e legs are red only from the shared bake hang).Implementation Units
U1. Wire schemas out of
__fixtures__, plus the pure lock coretest/e2e-core/src/npm-wire.schema.ts(moved fromtests/__fixtures__/npm-closure.schema.ts),test/e2e-core/tests/local-registry.ts(moved),test/e2e-core/src/fixture-lock.workflow.ts(new),test/e2e-core/src/mod.ts, and the importers of the moved modules (install-closure,hermetic-resolutionandbake-fixturesintegration tests).stagedManifestOffolds catalog resolution, effect-family pins (from today'spinnedFieldsOf) and the closure'sfile:specs (frominstallClosure's specs, with versionless names) into the manifest fields.committableLockOfstripsintegrityandversionfromfile:entries.overlayOfwrites the current packed manifests' version and dependency fields intofile:entries.pinsDriftOfreturns a tagged union of findings:LockMissing,RootOutOfSync,ClosureMemberMissing,ClosureMemberExtra,PinMoved. Each finding carries the fixture id and the package name. All of this follows the house standard: effect 4, Schema,@effect/vitestproperties (pack: schema-laws).committableLockOfis idempotent and leaves registry entries byte-identical.stagedManifestOflists every closure member and alias exactly once, relative to/baked/<id>.pnpm --filter @systemfsoftware/stryker-e2e-core typecheck test.U2.
E2E_PINS_DRIFTcheck in thechecklanecheck, offline, before any e2e leg runs.test/e2e-core/tests/fixture-locks.drift.test.ts(new),test/e2e-core/tests/npm-ci-skips-missing-dependency.integration.test.ts(new),turbo.json(thee2e-coretestinputs, KTD6).test/e2e/testResources: pack the closure unbuilt into a temporary directory, stage the manifest, runpinsDriftOf, overlay, then runnpm ls --package-lock-only --all --offline --registry=http://127.0.0.1:9/. Each failure printsE2E_PINS_DRIFT: <fixture>: <package>: <finding>. Next: pnpm --filter @systemfsoftware/stryker-e2e fixtures:lock, then commit test/e2e/testResources/<fixture>/package-lock.json. How the check computes closure membership inside a running turbo task (the harness'sturbo --dry=jsonpath, or pnpm's filter graph if a nested turbo is refused) is deferred to implementation.E2E_PINS_DRIFTnaming the member and the dependency (npm lsreports missing).E2E_PINS_DRIFT, invalid.pnpm-lock.yamlpin moves (effect 4.0.0 → 4.0.1):PinMoved.RootOutOfSync, andnpm lsreports missing.ClosureMemberMissingorClosureMemberExtra.LockMissing.npm ciexits 0 and doesn't install the dependency, while the drift check fails on the same input.turbo run test --dry=jsonshows a differente2e-core#testhash when onlypnpm-lock.yamlchanges. Before U3 commits the locks, the check fails on the real repo withLockMissingfor each of the seven fixtures.U3. Lock generator and the seven committed locks
test/e2e/src(reusing the harness's closure packing and the U1 staging), afixtures:lockscript intest/e2e/package.json, andtest/e2e/testResources/*/package-lock.json(seven new files).nameand afile:resolved.enterprise-monorepo-fixture's lock includes its workspace links.pnpm --filter @systemfsoftware/stryker-e2e fixtures:locktwice, thengit diff --exit-code. Then the drift check is green.U4. Bake switches to one
npm ciper fixtureGoal: the bake installs the committed locks and makes no resolution choice.
Requirements: R1, R5.
Files:
test/e2e-core/bake/bake-fixtures.sh(ported from U5, KTD7),test/e2e/src/Harness/fixture-cache.service.ts,test/e2e-core/src/bake-budget.ts,test/e2e-core/src/bake-report.ts,test/e2e-core/src/bake-report.schema.ts,test/e2e/src/Harness/harness-failure.schema.ts,.github/workflows/ci.yml(bake-report step),test/e2e-core/tests/bake-fixtures.integration.test.ts.Approach: the script takes
--root,--deadlineand--lanes(no--before). It runstimeout -s TERM -k 10 <deadline> npm ci --no-audit --no-fundper fixture in four lanes and maps failures to reason codes:ENOENTon afile:tarball →E2E_BAKE_TARBALL_MISSING, naming the tarball.npm ci's "not in sync" usage error →E2E_BAKE_LOCK_OUT_OF_SYNC, with the next action "runfixtures:lock".E2E_BAKE_STALLED.INSTALLS_PER_FIXTUREdrops to 1, so seven fixtures get a 170 s budget. Packing uses the versionless names. The closure install argv goes away (KTD2).Test scenarios (the script against the local registry with real npm):
E2E_BAKE_STALLED, naming the fixture and the deadline. The rendered report puts it in the step summary and as::error title=E2E_BAKE_STALLED::….E2E_BAKE_TARBALL_MISSING, naming it.E2E_BAKE_LOCK_OUT_OF_SYNC.--deadlineends inE2E_BAKE_ARGV.Verification: the e2e-core tests and both e2e packages' typecheck and lint pass locally. On CI, all five e2e legs are green and each leg's summary shows its bake state. One planted-stall CI run (a registry URL that never answers, reverted afterwards) shows
E2E_BAKE_STALLEDin the summary and the annotation, with the guest's realtimeoutexit code.U5. Remove every cutoff path; update the README
test/e2e-core/src/registry-pins.ts's cutoff parts and theRegistryPinsuses that U1 doesn't keep;test/e2e-core/src/bake-key.schema.ts,test/e2e-core/src/key-material.ts,test/e2e-core/tests/hermetic-resolution.integration.test.ts(deleted; its scenario moves to U4),test/e2e/README.md(the "Registry snapshot" section becomes "Committed fixture locks": the generator command, the drift codes, and the reason-code table withoutE2E_BAKE_CUTOFF_STALE).git grep -nI -e REGISTRY_CUTOFF -e registryCutoff -e CUTOFF_STALE -e ENOVERSIONS -e ETARGET -e min-release-age -e '--before=' -- . ':!*.lock' ':!docs/solutions' ':!docs/plans'returns no match.Verification Contract
./bin/dprint check;pnpm --filter @systemfsoftware/stryker-e2e-core typecheck lint test;cd test/e2e && pnpm exec tsc -b && pnpm run -s lint; actionlint on.github/workflows/ci.yml; the U3 generator twice plusgit diff --exit-code. No local e2e, microVM or mutation runs.run_watchon the fix(repo): resolve the e2e fixture bake against a pinned registry snapshot #285 head):check(includingE2E_PINS_DRIFT), the five e2e legs, Changeset Check, Commitlint and Nix all green. Plus one planted-stall run (U4), reverted afterwards.Definition of Done
test/e2e/README.mddescribes the locks, the generator,E2E_PINS_DRIFTand the bake reason codes.Appendix: probe evidence (host, npm 11.19.0, a fresh cache per call)
npm ci, file tarball changed, lock still has its integrityEINTEGRITYnpm ci, file tarball changed, integrity strippednpm ci, a member gains a dependency the lock lacksnpm install --package-lock-only --offlineover a lock, member gains a dependencyENOTCACHED(an error, not a diagnosis)npm ls --package-lock-only --all: missing / invalid / root missing / cleanELSPROBLEMSnaming the package ×3; exit 0npm ci, versionless tarball name, 1.0.0 → 1.0.1version@sfs/vm-runner→file:runner tarballname; regeneration byte-identical;npm ciinstalls editspnpm packunbuiltstryker-js-vitest-runner^ranges; nodistOriginal change (superseded cutoff design)
Unit U0 of the CI wall-clock plan. Since about 10:04Z on 2026-10-10, every e2e leg on main and on every open PR times out at 20 min. This change makes the fixture bake hermetic.
Cause
@effect/platform-node,@effect/platform-node-sharedand@effect/opentelemetry4.0.3 were published at 10:03:40–10:04:38Z withpeerDependencies.effect: ^4.0.3. Noeffect4.0.3 exists (latest is 4.0.2). The bake's secondnpm install(the packed closure tarballs, whose manifests declare@effect/platform-node ^4.0.0) resolves 4.0.3, then never settles the unsatisfiable peer and prints nothing. The guest job has no deadline, so the lane sits silent until the steptimeout 1200kills it.Reproduced on the host with the CI tarballs and
calc-fixture: step 2 got exit 124 after 300 s with no output. With--before=2026-10-10T10:00:00Zit got exit 0 in 6 s.Mechanism
Two parts. Each covers what the other misses:
npm installruns with--before=REGISTRY_CUTOFF, which is the commit time of the rootpnpm-lock.yaml(2026-10-10T05:51:38Z). No release published later can enter a fixture.registryPinsOfreads the rootpnpm-lock.yamland pins everyeffect/@effect/*package with a single resolved version: currentlyeffect,@effect/platform-node,@effect/platform-node-sharedand@effect/opentelemetry, all 4.0.0. They are pinned as direct specs in every fixture manifest, and asoverridesin the fixture root. The fixtures therefore resolve the same effect versions the repo builds and tests against, not merely whatever was latest at the cutoff.Why not a committed lockfile per fixture: the closure tarballs change on every workspace edit, so a committed lockfile would need regenerating with every PR that touches a packed package. Derived pins and a cutoff stay correct without that churn.
The cutoff is in the packs key. The pins are in each fixture key, through the resolved manifests. Changing either re-bakes.
Deadline
Each bake install has a 300 s deadline. A stall fails with
E2E_BAKE_STALLED: <fixture>: npm made no progress installing <step> for 300s.plus a next action (the hostnpm install --package-lock-only --before=…that reproduces it). Host smoke with a hanging fakenpmand a 2 s deadline: exit 124 at 2.0 s, message namingb-fixtureand the tarball step.Proof
test/e2e-core/tests/hermetic-resolution.integration.test.tsruns realnpm install --package-lock-onlyagainst a local registry that publisheseffect4.0.2,@effect/platform-node4.0.3 (peereffect ^4.0.3) andleft-pad1.1.0 after the cutoff. Expected resolution:effect4.0.0,@effect/platform-node4.0.0,left-pad1.0.0.--beforefails it (left-pad1.1.0); dropping the pins fails it (effect4.0.2).tsc -bfortest/e2eandtest/e2e-core, lint pluslint:tsgofor e2e-core (0 errors), the e2e-core suite (31 files / 217 tests), dprint. No local e2e or microVM.e2e legs on this head
CI run 38050305466 on
d0a40054b: every job green, CI wall 14.2 min (11:58:56–12:13:06Z). Changeset Check, Commitlint and Nix are green too.Bake completion, from the lifecycle leg's exported spans (
e2e-telemetry-38050305466-lifecycle):e2e.setup.baketook 138.8 s with status OK,await_job_completionclosed at +154.6 s, and the leg's vitest run passed. In the hung run 38044445700 that span never closed. The guest's[bake]stderr lines are not in the job log: the guest job captures stderr and only prints a tail on failure, which is true on main as well.