Skip to content

refactor(stryker-js): keep a run's Project read-only through the sandbox - #268

Merged
ryanleecode merged 7 commits into
mainfrom
stream-d/pr2-project-immutability
Oct 10, 2026
Merged

ryanleecode merged 7 commits into
mainfrom
stream-d/pr2-project-immutability

Conversation

@systemfsoftware-maker

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

Copy link
Copy Markdown
Collaborator

Summary

A sandbox preprocessor can no longer write into the Project it receives: files and filesToMutate are now ReadonlyMaps, each preprocessor returns a new Project, and the decision about how a tsconfig is rewritten for the sandbox moves into the rewriteSandboxTsconfig workflow, where property tests can now reach it. Before this change, mergeUpdatedFile called MutableHashMap.set on the caller's maps, and the tsconfig rewriter used Object.assign on the caller's ProjectFile.

This is PR 2 (U2) of the grab-bag decomposition series. PR 1 (#260) retired the Checker plans and protocol modules. U3 (sandbox acquisition cells) builds on this branch next.

What a reviewer should check

  • Breaking change to Engine.Project. files/filesToMutate change from MutableHashMap to ReadonlyMap<string, ProjectFile>. The changeset bumps @systemfsoftware/stryker-js major (BREAK-1). etc/stryker-js.api.md lists Engine.Project by name only, so the report itself does not change. Instrument.MutateDescriptionSchema is newly exported (instrumenter minor) so ProjectFile can be a schema rather than a hand-written copy.
  • The merge stays where the type lives. withPreprocessedFiles sits in Project.schema.ts (ADR-0002), not in a merge-preprocessed-files.workflow.ts. Its law snapshots the caller's entries before the call and requires the returned maps to be new objects, so an in-place merge fails. ReadonlyMap is only a type-level guarantee, which is why the law checks this. An update whose name is not in files is added to files only, as MutableHashMap.set did.
  • One tsconfig per cell run. sandboxTsconfigCell reads, decides, and returns the rewritten file plus its follow list. Sandbox.blueprint.ts walks the follow list depth-first and rewrites followed files before the parent, which is the pre-refactor order. A cell never runs another cell (ADR-0001 *.cell.ts row).
  • Behaviour is unchanged, including two pre-existing quirks. A diamond of tsconfigs is still rewritten once per parent with last-write-wins, and an extends cycle still recurses. Adding a visited set would change behaviour, so both stay as follow-ups.
  • Plan. U1 executed against 2026-10-09-1840, so the corrected contract is a new dated plan (2026-10-10-0431, supersedes:) and the old file is removed. KTD4 now records ReadonlyMap + withPreprocessedFiles instead of HashMap + a merge workflow.

Sandbox.blueprint.ts goes from 734 to 511 lines. The 400-line bar for it belongs to U3, which splits acquisition into cells.

Tests

  • src/__tests__/rewrite-sandbox-tsconfig.workflow.property.test.ts: escape, shape and follow laws, with the expectation built from the drawn entries rather than from production helpers. The schema-derived command generator emits all three arms (TsconfigMissing, TsconfigUnparsable, TsconfigParsed). Checked by sampling 300 commands.
  • In-source laws: withPreprocessedFiles (non-aliasing, last update wins, unknown names join files only); referencedEntriesOf and entriesOf (order, absent arrays contribute nothing).
  • tests/sandbox-tsconfig.integration.test.ts (new): real mutation runs through Engine.strykerCell on a temp project whose tsconfig extends and references files both inside and outside it. It asserts the exact text written into the sandbox, the missing-file skip, the unparsable-file warning, and that an in-place run rewrites nothing. It also passes unchanged against origin/main d2f018d, which is the parity evidence for "behaviour unchanged".
  • Mutation targets (main run 38023408744, mutation-report-420): the two Sandbox.schema.ts survivors 5d7353b99a24bd51 and 0e47ce21d936380f are the [JsonRecord] rest arrays of TsConfigSchema (main lines 26 and 34, now 14 and 19). The integration fixture carries compilerOptions and a reference prepend key and asserts the exact written text, so dropping either rest array should fail it. This is inferred: no mutation run happens locally.

Run locally on the merged head: pnpm typecheck (exit 0); vitest run src (196 files, 1179 tests); instrument-stage, framework-run, clean-temp-dir, plan-without-sandbox, sandbox-tsconfig and compile-error-reuse integration suites each run on their own, all passing; pnpm build and api:check for stryker-js and the instrumenter. The e2e lanes and mutation run only in CI.

Review record

Report-only ce-code-review (correctness, adversarial, project-standards, testing, maintainability, learnings). The validator confirmed 7 findings and rejected 2. All 7 confirmed findings are fixed here, along with the below-bar cell-runs-cell finding and the test-helper placement residual. The two rejected findings were a refactor preference and a placement preference. The cross-model pass did not run because no second-family CLI is installed in the container.

Known pre-existing bug, not fixed here

On main, a successful inPlace run leaves the mutated source instrumented on disk. Reproduced on d2f018d with the new suite's fixture. Likely cause [inference]: the in-place backup writes file.content (the instrumented text) rather than the original (project-files.service.ts writeInto → read). The in-place scenario therefore asserts only on tsconfig files. A fix is out of scope for a behaviour-unchanged PR.

Stacking note

This branch is a one-layer stack on main. It shares one line with #261 (stream-a/l1-ports-topology) in mutation-reporting.service.ts (MutableHashMap.get → files.get), a file #261 almost entirely removes. Per the operator's direction for this series (independent PRs off main that Stream A merges up), it is not stacked on #261.

…box preprocessors

Project.files and filesToMutate are ReadonlyMaps (insertion order kept, as MutableHashMap iterated). Sandbox preprocessors take a Project and return a new one; acquireSandbox writes the folded result and the caller's Project is untouched. The tsconfig rewrite moves out of Sandbox.blueprint.ts into rewriteSandboxTsconfig (escape, rewrite and follow decision) and sandboxTsconfigCell (read, parse, follow, write).
Laws over drawn TsConfigSchema values: escaping entries in all five positions move two levels up and others stay; the follow list is the non-escaping extends then the non-escaping references' tsconfigs; keys outside the five survive encoding; each read outcome maps to its decision.
…rojectFile as a schema

sandboxTsconfigCell now rewrites a single tsconfig and returns the rewritten file plus its follow list; the blueprint walks the follow list depth-first, so a cell never runs another cell (ADR-0001 *.cell.ts row). ProjectFile is a production schema in Project.schema.ts built on the newly exported Instrument.MutateDescriptionSchema, and the withPreprocessedFiles law snapshots the caller's maps, so an in-place merge now fails it. referencedEntriesOf and entriesOf gain in-source laws, and run/instrument.ts keeps its law helpers inside the import.meta.vitest guard.
…dary

The workflow property test now derives its expected escapes from the drawn entries instead of the production referencedEntriesOf. A new integration suite runs real mutation runs on a temp project and asserts the exact tsconfig text written into the sandbox, the missing-file skip, the unparsable-file warning and the in-place no-rewrite; it passes unchanged against origin/main d2f018d.
U1 executed against 2026-10-09-1840, so the corrected contract is a new dated plan declaring supersedes: KTD4 records ReadonlyMap with fresh maps and withPreprocessedFiles in Project.schema.ts (ADR-0002) instead of HashMap and a merge workflow; U2 lists the boundary suite, the one-tsconfig cell and the blueprint-owned follow walk.
@systemfsoftware-maker systemfsoftware-maker changed the title stream d/pr2 project immutability refactor(stryker-js): keep a run's Project read-only through the sandbox Oct 10, 2026

@ryanleecode ryanleecode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root verified: 10/10 green on 0776b03, no open threads, no skips/disables.

@ryanleecode
ryanleecode merged commit cfdf3cd into main Oct 10, 2026
10 checks passed
@ryanleecode
ryanleecode deleted the stream-d/pr2-project-immutability branch October 10, 2026 05:52
systemfsoftware-maker added a commit that referenced this pull request Oct 12, 2026
…#297

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Ryan Lee <drdgvhbh@gmail.com>
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