Skip to content

fix(repo): resolve the e2e fixture bake against a pinned registry snapshot - #285

Closed
systemfsoftware-maker wants to merge 26 commits into
fix/manifest-document-keysfrom
fix/hermetic-e2e-bake
Closed

systemfsoftware-maker wants to merge 26 commits into
fix/manifest-document-keysfrom
fix/hermetic-e2e-bake

Conversation

@systemfsoftware-maker

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

Copy link
Copy Markdown
Collaborator

Draft. The hang hotfix is #284 (--min-release-age=1 plus a 300 s per-install timeout), which lands first. After #284 is on main, this branch takes a plain git merge origin/main and is reworked to the pinned design below. That design replaces #284's age gate and this PR's --before cutoff. The sections under "Original change" describe the superseded cutoff design.

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, no REGISTRY_CUTOFF, no --min-release-age.

Mechanism. Each fixture under test/e2e/testResources/<fixture>/ commits a package-lock.json. Its root manifest declares the packed workspace closure as file: tarball specs under the guest packs root, next to the fixture's own registry dependencies. The bake runs one npm ci per fixture. npm ci installs 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 a file: tarball and fails npm ci with EINTEGRITY once the tarball changes, which happens on any source edit to a packed package. Without the hash, npm ci installs the current tarball. That entry is the code under test, not a registry choice. Registry entries keep their resolved URL and integrity.

  • 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.yaml resolves as overrides, today's registryPinsOf, so fixtures run the same effect family as the workspace. Then it runs npm install --package-lock-only on the host, strips integrity from file: entries and writes the lock. Registry resolution happens only here, at a commit a reviewer sees.

  • Drift check in the required check lane. It is a pure, offline check in @systemfsoftware/stryker-e2e-core, run by the Turbo-cached test task that pnpm check:ci already runs, so it needs no new job or gate. For each fixture it asserts that:

    • the staged manifest equals the lock's root entry;
    • every packed workspace package's manifest, after the same workspace: and catalog: rewrite pnpm pack applies, declares exactly the dependencies its lock entry lists;
    • every package pnpm-lock.yaml pins 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 ::error annotation.

  • When a dependency bumps. A Dependabot or human bump to pnpm-lock.yaml, a catalog entry, or a packed package's dependencies fails check with E2E_PINS_DRIFT until 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.

  • Per-install deadline with E2E_BAKE_STALLED and a next action. Each fixture now has one install, so the derived budget halves INSTALLS_PER_FIXTURE.
  • The whole-bake budget derived from the same constants (E2E_BAKE_OVER_BUDGET), and E2E_BAKE_NO_RECORD.
  • Reason codes in the step summary and as ::error annotations.
  • The bake-fixtures.sh integration 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, ENOVERSIONS mapping, REGISTRY_CUTOFF and --before are removed. The packs key drops the cutoff.

Renames. test/e2e-core/tests/__fixtures__/npm-closure.schema.ts holds the npm manifest, lockfile and packument schemas. The drift check consumes it, so it moves into the core as test/e2e-core/src/npm-wire.schema.ts. The local test registry (tests/__fixtures__/local-registry.ts on U5) moves out of __fixtures__ as tests/local-registry.ts.

Evidence (host probes, npm 11, fresh cache per install)

Probe Result
npm ci with the lock's file: tarball unchanged exit 0, installs it
tarball contents changed, lock keeps its integrity exit 1, EINTEGRITY
tarball contents changed, integrity removed from the file: entry exit 0, installs the new contents
packed package gains a dependency the lock lacks exit 0, dependency silently not installed
pnpm-lock.yaml coverage of fixture dependencies no testResources importer. @sveltejs/vite-plugin-svelte and @testing-library/svelte (svelte fixture) are catalog entries with no lock entry

The fourth row is why the drift check is required: once npm ci has a committed lock, nothing else notices a closure dependency change.

Rejected

  • Derive versions from pnpm-lock.yaml at bake time (today's overrides pins, widened). pnpm-lock.yaml has 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.
  • Translate pnpm-lock.yaml into npm lockfiles. This has the same coverage gap. pnpm's peer-suffixed snapshot graph and npm's hoisted node_modules layout differ, so the translator would be a second resolver to maintain.
  • Vendor a registry mirror or offline npm cache in the repo. Every registry tarball would be committed (the installed fixture trees measured 314 MB compressed in U5), and it still needs a lock to remove choices.
  • pnpm in the guest. The fixtures are not workspace members, the guest image is node:24-alpine with npm, and the lane exists to prove the published closure installs with npm (npm-alias edges, install-closure tests).
  • Keep an age or date gate (--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.json in the published tarballs. It would pin real consumers' installs, which is outside the e2e lane's job.

Deferred to planning

  • How the generator gets the packed closure on the host (reuse packWorkspaceClosure versus pnpm pack in the script). Whether npm ci lock entries for the npm-alias closure edges (<alias>@file:<target>) round-trip through the generator unchanged.
  • Committed lock sizes. In U5, one fixture's lock-only resolution had about 205 registry entries.

npm-wire keys (stacked on #306)

test/e2e-core/src/npm-wire.schema.ts keys npm manifest dependency maps by npm package name and lockfile packages by 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 V8 JSON.parse key 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.ts law file (NpmManifestJson, NpmLockfileJson, PinnedFixtureManifestJson and their Enc laws), local, env -u AGENT CI=true, Node 24.21 / V8 13.6, 200 independent unpinned runs, seed store cleared each run failed
before, 80e0e2e9b 17/200
after, 630413948 0/200

The 17 before-failures span all three schemas and both law directions (=x and Enc_=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-only ce-doc-review findings await the operator's rulings; none applied.

Goal Capsule

  • Objective: an upstream npm release can't change, stall or break the e2e lanes. Every fixture installs exactly the versions the repository has committed. If a committed version goes stale relative to the workspace, the required check lane fails, names the fixture and package, and says how to fix it.
  • Means: each fixture commits a generated package-lock.json, the bake runs one npm ci per fixture, and an offline drift check runs in the test task of check:ci (KTD1-KTD3).
  • Authority: the root rulings of 2026-10-10 (no age or date cutoff; locks plus npm ci; E2E_PINS_DRIFT check in the required lane), then this plan, then test/e2e/AGENTS.md and CONSTITUTION.md.
  • Stop conditions: stop before writing code while fix(e2e): keep day-old releases out of the fixture bake and bound each install #284 (stryker/bake-release-age) is not merged on main. Stop if a regenerated lock for any fixture differs from the committed one when regeneration runs twice in a row (KTD4 no longer holds).
  • Execution: lands on fix/hermetic-e2e-bake (fix(repo): resolve the e2e fixture bake against a pinned registry snapshot #285) after a plain git merge origin/main that 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 that npm ci installs verbatim. An offline check keeps those lockfiles in step with the workspace.

Problem Frame

On 2026-10-10, @effect/platform-node 4.0.3 was published with a peer effect ^4.0.3 that 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=1 and a 300 s per-install timeout. #285's first design added a --before cutoff 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

  • R1. The bake makes no version-resolution choice. Each fixture installs with npm ci from a committed package-lock.json. No --before, --min-release-age or date cutoff remains anywhere in the bake path.
  • R2. Each fixture's lock records the workspace closure as local file: tarballs, with no integrity and no version on those entries. Registry entries keep their URL and integrity.
  • R3. Registry versions in a lock are chosen once, when the lock is generated, and the generator seeds them with the versions pnpm-lock.yaml resolves. Fixtures therefore run the effect versions the workspace builds against.
  • R4. An offline check in the test task of check:ci fails with E2E_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 the pnpm-lock.yaml pins. A release bump that only changes closure versions is not drift.
  • R5. Bake failures keep reason codes, written to the step summary and as ::error annotations: 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_FAILED and E2E_BAKE_NO_RECORD.
  • R6. Every cutoff path is removed: REGISTRY_CUTOFF, registryCutoff in the packs key, --before argv, the ETARGET/ENOVERSIONS mapping, E2E_BAKE_CUTOFF_STALE and their README rows (DEL1).
  • R7. The wire schemas leave __fixtures__. test/e2e-core/tests/__fixtures__/npm-closure.schema.ts becomes test/e2e-core/src/npm-wire.schema.ts, and local-registry.ts becomes test/e2e-core/tests/local-registry.ts. The __fixtures__ directory under test/e2e-core/tests/ is removed.

Key Decisions

  • Committed per-fixture lockfiles installed with npm ci. Settled by the operator ruling and the probe evidence. Rejected: pinning from pnpm-lock.yaml at bake time (misses the svelte fixture's packages pnpm-lock.yaml doesn'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, and npm-shrinkwrap.json in published packages. Governs R1-R3.
  • No age or date cutoff in the bake. A directive from the root ruling; fix(e2e): keep day-old releases out of the fixture bake and bound each install #284 is the interim hotfix. Governs R1, R6.
  • E2E_PINS_DRIFT runs in the required check lane. Settled by the probe: npm ci exits 0 and silently skips a dependency that a closure member gained after the lock was written. Governs R4.

Scope Boundaries

  • The cross-run bake cache (actions/cache save and restore) stays U5 scope on ci/wall-clock-u5. This plan ports only U5's bake report, budget and reason-code pieces it keeps (KTD7).
  • No automated lock-regeneration workflow. A Dependabot or catalog bump that moves a pin fails check until someone runs the generator and commits the result. The failure message names the command.

Planning Contract

Key Technical Decisions

  • KTD1. Versionless closure tarball names. The harness packs each closure member as <scope>-<name>.tgz, without the version. With no version and no integrity on file: entries, a release bump leaves every lock byte-identical. Probe: npm ci installs a 1.0.1 tarball under a lock entry recording 1.0.0 (exit 0), and also when the entry has no version at all.
  • KTD2. The closure is a manifest dependency, not argv. The staged fixture manifest lists each closure member, and each alias, as file: specs relative to the guest layout (/baked/<id> → ../../packs/<file>). npm ci requires 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.
  • KTD3. The drift check is a pure core plus an npm ls oracle. 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's file: entries match the closure members exactly; the effect-family locked versions equal the pnpm-lock.yaml pins. The shell overlays the current packed manifests (version and dependency fields) onto a temporary copy of the lock, then runs npm ls --package-lock-only --all --offline. Probe: ls exits 1 with ELSPROBLEMS, 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 --offline over an existing lock left it byte-identical when a member gained a dependency.
  • KTD4. Generation runs on the host, against a mirrored guest layout, without building. The generator packs the closure with pnpm pack and no build: the probe packed stryker-js-vitest-runner unbuilt 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 host npm install --package-lock-only --ignore-scripts online, then strips integrity and version from file: entries. Probe: alias entries survive (name: "@sfs/runner" under node_modules/@sfs/vm-runner), regeneration from scratch and over an existing lock is byte-identical, and npm ci installs edited tarball contents under an unchanged lock.
  • KTD5. Packs key without the cutoff. registryCutoff leaves PackInput and packsKeyBytes. Each fixture's committed lock is in its source tree, so the fixture key already moves with it.
  • KTD6. Turbo inputs cover everything the check reads. The e2e-core test task adds $TURBO_ROOT$/pnpm-lock.yaml, $TURBO_ROOT$/pnpm-workspace.yaml, $TURBO_ROOT$/packages/**/package.json and $TURBO_ROOT$/test/e2e/testResources/**. Otherwise a pin-only PR could get a cache hit that silently passes the drift check.
  • KTD7. Port U5's report and budget pieces, then merge up. The bake report (bake-report.ts and its schema), the budget (bake-budget.ts), E2E_BAKE_NO_RECORD, the CI bake-report step and bake-fixtures.integration.test.ts come from ci/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-u5 later 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

host (developer)                          check lane (offline)                  e2e lane (guest)
pnpm pack closure (no build)              pure: lock present, root sync,         stage manifest (same fn)
stage manifest (pins + closure file:)     closure members, pnpm pins            npm ci per fixture, 60 s deadline
npm install --package-lock-only           overlay packed manifests -> npm ls    reason codes -> summary + ::error
strip file: integrity/version -> commit   E2E_PINS_DRIFT <fixture>: <pkg> ...

Assumptions

  • The guest's npm (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 under npm ci like the single-package fixtures. U3 proves this on all seven fixtures.

Sequencing

#284 merges to main, then a plain merge of origin/main into fix/hermetic-e2e-bake, then U1 → U2 → U3 → U4 → U5 in order, then push and watch CI. Afterwards, plain merges of fix/hermetic-e2e-bake into ci/wall-clock-u5 and of origin/main into ci/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 core

  • Goal: one schema module for npm's wire formats, plus pure functions for staging, committable locks and drift findings.
  • Requirements: R2, R3, R4, R7.
  • Files: test/e2e-core/src/npm-wire.schema.ts (moved from tests/__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-resolution and bake-fixtures integration tests).
  • Approach: stagedManifestOf folds catalog resolution, effect-family pins (from today's pinnedFieldsOf) and the closure's file: specs (from installClosure's specs, with versionless names) into the manifest fields. committableLockOf strips integrity and version from file: entries. overlayOf writes the current packed manifests' version and dependency fields into file: entries. pinsDriftOf returns 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/vitest properties (pack: schema-laws).
  • Test scenarios:
    • committableLockOf is idempotent and leaves registry entries byte-identical.
    • Changing only closure versions in the input yields the same committable lock (release-bump invariance).
    • Each finding kind is produced for its input and names the fixture and package. A consistent input yields no finding.
    • stagedManifestOf lists every closure member and alias exactly once, relative to /baked/<id>.
  • Verification: pnpm --filter @systemfsoftware/stryker-e2e-core typecheck test.

U2. E2E_PINS_DRIFT check in the check lane

  • Goal: a stale lock fails check, offline, before any e2e leg runs.
  • Requirements: R4.
  • Files: 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 (the e2e-core test inputs, KTD6).
  • Approach: for every fixture under test/e2e/testResources: pack the closure unbuilt into a temporary directory, stage the manifest, run pinsDriftOf, overlay, then run npm ls --package-lock-only --all --offline --registry=http://127.0.0.1:9/. Each failure prints E2E_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's turbo --dry=json path, or pnpm's filter graph if a nested turbo is refused) is deferred to implementation.
  • Test scenarios (each against a scratch fixture and pack set built in the test):
    • A closure member gains a registry dependency the lock lacks: E2E_PINS_DRIFT naming the member and the dependency (npm ls reports missing).
    • A member's range moves outside the locked version: E2E_PINS_DRIFT, invalid.
    • The pnpm-lock.yaml pin moves (effect 4.0.0 → 4.0.1): PinMoved.
    • The fixture manifest gains a dependency: RootOutOfSync, and npm ls reports missing.
    • The closure gains or loses a member: ClosureMemberMissing or ClosureMemberExtra.
    • The fixture has no lock: LockMissing.
    • A release bump that changes only closure versions passes.
    • Probe kept as a test: on the "member gains a dependency" input, npm ci exits 0 and doesn't install the dependency, while the drift check fails on the same input.
  • Verification: the e2e-core tests pass. turbo run test --dry=json shows a different e2e-core#test hash when only pnpm-lock.yaml changes. Before U3 commits the locks, the check fails on the real repo with LockMissing for each of the seven fixtures.

U3. Lock generator and the seven committed locks

  • Goal: one host command (re)generates every fixture lock.
  • Requirements: R2, R3.
  • Files: a generator program in test/e2e/src (reusing the harness's closure packing and the U1 staging), a fixtures:lock script in test/e2e/package.json, and test/e2e/testResources/*/package-lock.json (seven new files).
  • Approach: KTD4. Pack with the versionless names from KTD1. The registry versions for the effect family come from the pins. Everything else resolves once, here, and is reviewed in the PR diff.
  • Test scenarios:
    • Running the generator twice yields byte-identical locks.
    • The generated locks pass U2's check.
    • Alias entries keep name and a file: resolved.
    • enterprise-monorepo-fixture's lock includes its workspace links.
  • Verification: pnpm --filter @systemfsoftware/stryker-e2e fixtures:lock twice, then git diff --exit-code. Then the drift check is green.

U4. Bake switches to one npm ci per fixture

  • Goal: 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, --deadline and --lanes (no --before). It runs timeout -s TERM -k 10 <deadline> npm ci --no-audit --no-fund per fixture in four lanes and maps failures to reason codes:

    • npm ENOENT on a file: 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 "run fixtures:lock".
    • A timeout → E2E_BAKE_STALLED.

    INSTALLS_PER_FIXTURE drops 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):

    • A locked fixture installs exactly the locked versions while the registry also serves a newer release. This replaces the hermetic-resolution scenario.
    • A registry that never answers ends in 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::….
    • A lock referencing an absent tarball ends in E2E_BAKE_TARBALL_MISSING, naming it.
    • A manifest out of sync with its lock ends in E2E_BAKE_LOCK_OUT_OF_SYNC.
    • A call missing --deadline ends in E2E_BAKE_ARGV.
    • Budget law: budget = ⌈fixtures/4⌉ × (deadline + grace) + boot slack.
  • 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_STALLED in the summary and the annotation, with the guest's real timeout exit code.

U5. Remove every cutoff path; update the README

  • Goal: no cutoff concept remains (DEL1).
  • Requirements: R6, R7.
  • Files: delete test/e2e-core/src/registry-pins.ts's cutoff parts and the RegistryPins uses 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 without E2E_BAKE_CUTOFF_STALE).
  • Test scenarios: none new. DEL1 is the gate.
  • Verification: 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

  • Locally, one command at a time: ./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 plus git diff --exit-code. No local e2e, microVM or mutation runs.
  • On CI (run_watch on the fix(repo): resolve the e2e fixture bake against a pinned registry snapshot #285 head): check (including E2E_PINS_DRIFT), the five e2e legs, Changeset Check, Commitlint and Nix all green. Plus one planted-stall run (U4), reverted afterwards.

Definition of Done

  • R1-R7 hold, and every unit's verification passed on the stated surface.
  • The seven locks are committed, and a second generator run leaves them byte-identical.
  • The DEL1 grep is empty, and no abandoned-attempt code (the two-install path, cutoff helpers, the stale-cutoff tests) remains.
  • test/e2e/README.md describes the locks, the generator, E2E_PINS_DRIFT and the bake reason codes.

Appendix: probe evidence (host, npm 11.19.0, a fresh cache per call)

Probe Result
npm ci, file tarball changed, lock still has its integrity fails EINTEGRITY
npm ci, file tarball changed, integrity stripped installs the new contents
npm ci, a member gains a dependency the lock lacks exit 0, dependency not installed
npm install --package-lock-only --offline over a lock, member gains a dependency exit 0, lock byte-identical (not an oracle)
same, pin moves or fixture gains a dependency ENOTCACHED (an error, not a diagnosis)
npm ls --package-lock-only --all: missing / invalid / root missing / clean ELSPROBLEMS naming the package ×3; exit 0
npm ci, versionless tarball name, 1.0.0 → 1.0.1 exit 0, installs 1.0.1; also with no lock version
alias @sfs/vm-runner → file: runner tarball entry keeps name; regeneration byte-identical; npm ci installs edits
pnpm pack unbuilt stryker-js-vitest-runner 0.41 s; catalog specs rewritten to ^ ranges; no dist

Original 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-shared and @effect/opentelemetry 4.0.3 were published at 10:03:40–10:04:38Z with peerDependencies.effect: ^4.0.3. No effect 4.0.3 exists (latest is 4.0.2). The bake's second npm 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 step timeout 1200 kills 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:00Z it got exit 0 in 6 s.

Mechanism

Two parts. Each covers what the other misses:

  • Registry snapshot. Every bake npm install runs with --before=REGISTRY_CUTOFF, which is the commit time of the root pnpm-lock.yaml (2026-10-10T05:51:38Z). No release published later can enter a fixture.
  • Lockfile pins. registryPinsOf reads the root pnpm-lock.yaml and pins every effect / @effect/* package with a single resolved version: currently effect, @effect/platform-node, @effect/platform-node-shared and @effect/opentelemetry, all 4.0.0. They are pinned as direct specs in every fixture manifest, and as overrides in 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 host npm install --package-lock-only --before=… that reproduces it). Host smoke with a hanging fake npm and a 2 s deadline: exit 124 at 2.0 s, message naming b-fixture and the tarball step.

Proof

  • test/e2e-core/tests/hermetic-resolution.integration.test.ts runs real npm install --package-lock-only against a local registry that publishes effect 4.0.2, @effect/platform-node 4.0.3 (peer effect ^4.0.3) and left-pad 1.1.0 after the cutoff. Expected resolution: effect 4.0.0, @effect/platform-node 4.0.0, left-pad 1.0.0.
  • Sabotage: dropping --before fails it (left-pad 1.1.0); dropping the pins fails it (effect 4.0.2).
  • Locally: tsc -b for test/e2e and test/e2e-core, lint plus lint:tsgo for 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.

Leg Wall (min) Job
e2e (rest-1) 14.0 114207953941
e2e (sabotage) 11.2 114207953879
e2e (lifecycle) 10.6 114207953884
e2e (svelte-composite) 9.5 114207953909
e2e (rest-2) 8.4 114207953930
check 7.8 114207953880

Bake completion, from the lifecycle leg's exported spans (e2e-telemetry-38050305466-lifecycle): e2e.setup.bake took 138.8 s with status OK, await_job_completion closed 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.

…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.
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.
@systemfsoftware-maker
systemfsoftware-maker changed the base branch from main to fix/manifest-document-keys October 11, 2026 07:17
@systemfsoftware-maker
systemfsoftware-maker marked this pull request as ready for review October 11, 2026 07:17
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
@systemfsoftware-maker

Copy link
Copy Markdown
Collaborator Author

Unfixed String-keyed records / S.Json / S.JsonObject (ruling step 5)

Not fixed here, on purpose; the root decides these. The construct list is mechanical: every line in packages/ and test/ (excluding testResources, dist and .d.ts) at d3080c4b2 (code-identical to the pushed head 630413948, which only merges #306's lesson doc) matching Record(S.String | Schema.String …) (multi-line too) or S.Json / S.JsonObject / S.MutableJson. That is 326 lines in 80 files. A construct counts only where a fromJsonString decode reaches it; the second table is a scout's reachability map of those decode sites, which I have not re-verified line by line (its line numbers drift).

Construct list (file:line)
packages/stryker-js-cli-contract/src/SpanTaxonomy.schema.ts: 38
packages/stryker-js-cli-contract/tests/__fixtures__/changeset-intents.fixture.ts: 45, 47
packages/stryker-js-cli-contract/tests/__fixtures__/contract-compat.fixture.ts: 33
packages/stryker-js-plugin-interface/src/Mutant.schema.ts: 181, 200
packages/stryker-js-plugin-interface/src/Plugin.schema.ts: 28
packages/stryker-js-plugin-interface/src/TestRunner.schema.ts: 45
packages/stryker-js-plugin-interface/src/generated/report.schema.ts: 6, 7, 9, 10, 12, 13, 15, 16, 18, 21, 22, 24, 25, 27, 28, 30, 31, 33, 34, 36, 37, 39, 40, 42, 43, 45, 46, 48, 49, 51, 52, 54, 57, 60, 61
packages/stryker-js-plugin-interface/src/stryker-options.schema.ts: 43, 199, 216, 289
packages/stryker-js-plugin-interface/tests/report-codec.integration.test.ts: 10, 61, 66
packages/stryker-js-plugin-runtime/src/worker-options.schema.ts: 7, 9, 11, 18, 24, 32, 39
packages/stryker-js-typescript-checker/src/CheckerCommands.schema.ts: 16
packages/stryker-js-typescript-checker/src/Tsconfig.schema.ts: 24, 26, 55, 57, 58, 97
packages/stryker-js-typescript-checker/src/__tests__/parse-tsconfig-text.workflow.property.test.ts: 10, 41
packages/stryker-js-typescript-checker/src/__tests__/resolve-package-exports.workflow.property.test.ts: 18
packages/stryker-js-typescript-checker/src/parse-tsconfig-text.workflow.ts: 33, 59
packages/stryker-js-typescript-checker/src/resolve-package-exports.workflow.ts: 10, 20
packages/stryker-js-typescript-checker/src/ts-compiler.handle.ts: 263, 295, 489
packages/stryker-js-typescript-checker/tests/program-digest.integration.test.ts: 198, 216
packages/stryker-js-vitest-runner/src/VitestRunner.schema.ts: 33, 38, 40
packages/stryker-js-vitest-runner/src/VitestRuntime.blueprint.ts: 53, 203
packages/stryker-js/src/Config.schema.ts: 9, 18, 23, 24, 28, 78
packages/stryker-js/src/IncrementalDiff.schema.ts: 117, 125
packages/stryker-js/src/IncrementalReport.schema.ts: 57, 59, 63
packages/stryker-js/src/MutantCost.schema.ts: 18, 29, 31, 32, 33
packages/stryker-js/src/MutantTestPlanCommand.schema.ts: 23
packages/stryker-js/src/Plugins.schema.ts: 44, 130, 131
packages/stryker-js/src/Sandbox.schema.ts: 7
packages/stryker-js/src/Serve/Serve.cell.ts: 127, 128, 129, 337, 441
packages/stryker-js/src/Serve/msp-protocol.workflow.ts: 83, 98, 114, 123
packages/stryker-js/src/Serve/msp.schema.ts: 72, 79, 134, 152, 164
packages/stryker-js/src/Survivors/Survivors.schema.ts: 6, 9
packages/stryker-js/src/__tests__/extract-module-specifiers.workflow.property.test.ts: 21, 23, 30, 37, 40, 45, 54, 57, 58, 59, 60, 68, 75, 86, 95, 96
packages/stryker-js/src/__tests__/warning-enabled.workflow.property.test.ts: 30
packages/stryker-js/src/admit-checker-answer.workflow.ts: 61
packages/stryker-js/src/admit-survivors-run.workflow.ts: 17, 22, 24, 25, 71, 78, 83, 84
packages/stryker-js/src/audit.schema.ts: 11, 22, 28, 39
packages/stryker-js/src/compare-verdicts.workflow.ts: 17
packages/stryker-js/src/config/merge-config.ts: 159, 160
packages/stryker-js/src/config/warning-enabled.workflow.ts: 24
packages/stryker-js/src/dry-run-coverage.schema.ts: 8
packages/stryker-js/src/extract-module-specifiers.workflow.ts: 29, 31, 33, 35, 37, 39, 41, 44, 47, 49, 52, 55, 61, 64, 67, 75, 81, 84, 86, 88, 94, 97, 103, 111, 117, 120
packages/stryker-js/src/import-closure.schema.ts: 16, 31, 35, 48
packages/stryker-js/src/mutation-reporting.schema.ts: 14
packages/stryker-js/src/mutation-reporting.service.ts: 716
packages/stryker-js/src/oxc-source-parser.ts: 22, 32, 34, 57, 59, 61, 68, 70, 72, 74, 77, 80
packages/stryker-js/src/plan-request.schema.ts: 5, 7, 13
packages/stryker-js/src/plan-shards.workflow.ts: 31
packages/stryker-js/src/reporting/report-assembly.schema.ts: 8
packages/stryker-js/src/reporting/verdict-envelope.ts: 100, 110
packages/stryker-js/src/resolve-output-mode.workflow.ts: 17
packages/stryker-js/src/run/package-manifest.schema.ts: 3, 6, 7
packages/stryker-js/src/run/validate-options-admission.workflow.ts: 17, 30
packages/stryker-js/src/select-project-files.workflow.ts: 411
packages/stryker-js/src/shard/incremental-union.ts: 8, 90, 113, 129
packages/stryker-js/src/source-parser.service.ts: 8
packages/stryker-js/src/verdict-semantics.ts: 23, 66, 100, 114
packages/stryker-js/tests/__fixtures__/config-law.schema.ts: 5, 7, 9, 18
packages/stryker-js/tests/__fixtures__/feedback.schema.ts: 5, 6, 11
packages/stryker-js/tests/__fixtures__/mcp-server.schema.ts: 5, 6, 13
packages/stryker-js/tests/__fixtures__/mutation-server.schema.ts: 44, 49
packages/stryker-js/tests/__fixtures__/rerun-by-id.schema.ts: 11
packages/stryker-js/tests/__fixtures__/reusable-report.schema.ts: 5, 11, 14, 15
packages/stryker-js/tests/compile-error-reuse.integration.test.ts: 236
packages/stryker-js/tests/feedback.integration.test.ts: 58, 164
packages/stryker-js/tests/incremental-reuse.integration.test.ts: 450, 452, 465, 467
packages/stryker-js/tests/mcp-server.integration.test.ts: 64, 87, 210, 223
packages/stryker-js/tests/mutation-server.integration.test.ts: 86, 87, 149, 151, 153, 222, 225, 261, 263
packages/stryker-js/tests/shard-merge.integration.test.ts: 109, 323
test/checker-parity/src/Parity.schema.ts: 81
test/e2e-core/src/bake-report.schema.ts: 32
test/e2e-core/src/catalog-resolution.schema.ts: 24, 39, 44
test/e2e-core/src/confirm-annotations.workflow.ts: 25, 26
test/e2e-core/src/covering-test-files.workflow.ts: 10, 14
test/e2e-core/src/find-lock-drift.workflow.ts: 22
test/e2e-core/src/install-closure.schema.ts: 8, 10
test/e2e-core/src/install-closure.workflow.ts: 30
test/e2e-core/src/registry-pins.schema.ts: 3, 6
test/e2e-core/tests/__fixtures__/fixture-locks.ts: 217, 218
test/e2e-core/tests/__fixtures__/local-registry.ts: 11, 15, 16, 26
test/e2e/src/Harness/bake-key.schema.ts: 12
Scout map: fromJsonString decode site → construct (unverified)
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

@ryanleecode

Copy link
Copy Markdown
Contributor

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 npm ci per fixture. No registry-snapshot pins, wire schemas or bake budgets. The branch fix/hermetic-e2e-bake is kept for reference.

systemfsoftware-maker added a commit that referenced this pull request Oct 11, 2026
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.
ryanleecode pushed a commit that referenced this pull request Oct 12, 2026
* 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.
An error occurred while trying to automatically change base from fix/manifest-document-keys to main October 13, 2026 02:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants