diff --git a/.changeset/itchy-forks-hope.md b/.changeset/itchy-forks-hope.md new file mode 100644 index 000000000..0af3cb9a0 --- /dev/null +++ b/.changeset/itchy-forks-hope.md @@ -0,0 +1,7 @@ +--- +"@systemfsoftware/stryker-js": patch +--- + +Incremental runs keep reusing verdicts for a test that imports a linked workspace package whose manifest declares `exports` with nested conditions, array fallbacks, root conditions without a `.` key or `null` targets. Such a manifest no longer counts as unreadable as a whole: each `exports`, `imports`, `main` and `module` entry is read on its own, and an entry that maps to no file still keeps the closures that import it open. + +A condition object or array fallback now resolves as Node resolves it, skipping what Node skips and refusing what Node refuses, and closes its importers' closures only when ESM and CommonJS importers load the same file. A `types`-first or dual `import`/`require` package no longer keeps a stale verdict on a test runner that does not report loaded modules. diff --git a/CONCEPTS.md b/CONCEPTS.md new file mode 100644 index 000000000..d366e35c7 --- /dev/null +++ b/CONCEPTS.md @@ -0,0 +1,15 @@ +# Concepts + +> Shared domain vocabulary for this project — entities, named processes, and status concepts with project-specific meaning. Seeded with core domain vocabulary, then accretes as ce-compound and ce-compound-refresh process learnings; direct edits are fine. Glossary only, not a spec or catch-all. + +## Incremental reuse + +### Import closure + +The set of project files a test file can reach through its imports, used to decide whether that test file's earlier results still hold. + +A closure is closed when every import in it led to a project file or to something outside the project, such as a Node builtin or an installed dependency. It is open when some import could not be followed or some file could not be parsed. An open closure cannot bound what its test file depends on, so it counts every project file as a dependency. + +### Closure digest + +A hash over the contents of the files in a test file's import closure. An incremental run reuses a test file's earlier results while its closure digest is unchanged. The digest of an open closure also covers every project file, so an edit anywhere in the project changes it. diff --git a/docs/plans/2026-10-13-0656-fix-manifest-exports-decode-plan.md b/docs/plans/2026-10-13-0656-fix-manifest-exports-decode-plan.md new file mode 100644 index 000000000..c1f46cec8 --- /dev/null +++ b/docs/plans/2026-10-13-0656-fix-manifest-exports-decode-plan.md @@ -0,0 +1,174 @@ +--- +title: Manifest exports the resolver cannot read stop making the whole package.json unreadable - Plan +type: fix +date: 2026-10-13 +supersedes: docs/plans/2026-10-13-0620-fix-manifest-exports-decode-plan.md +topic: manifest-exports-decode +artifact_contract: ce-unified-plan/v1 +product_contract_source: ce-brainstorm +execution: code +--- + +# Manifest Exports Decode - Plan + +## Goal Capsule + +- **Objective.** A project that links a workspace package whose `package.json` declares `exports` in any shape Node accepts keeps its verdicts across an edit no test reaches, and re-tests them after an edit to the file the runtime loads. +- **Means.** Decode every manifest key the resolver reads into a tagged entry Type that has a variant for every JSON shape, and choose the target inside the pure decision by the entry's tag, resolving each condition object to the file the runtime loads (KTD1, KTD2). +- **Authority.** Conductor intent r41 (`.rulings/stream-d-r41-manifest-exports-intent.md`) as clarified by r46 (`.rulings/stream-d-r46-pr330-debate.md`) and the root's r49 ruling on the schema (`.rulings/stream-d-r51b-prompt.md:3-6`) > this plan > the repo's packs and `CONSTITUTION.md`. +- **Stop.** One PR off `origin/main` (#330), green on its exact head, with a patch changeset for `@systemfsoftware/stryker-js`, the review receipt and per-finding dispositions in its body. +- **Why superseded.** The 0620 plan shipped string-target validity in `e8393042d`. The full-path review of that head (run `20261013-063937-1f11f4b8`) confirmed two P2s and one pack violation. (1) A condition object with an array-index key was read as conditions, where Node refuses it with `ERR_INVALID_PACKAGE_CONFIG` (`resolve.js:522-530`); that error is not `ERR_INVALID_PACKAGE_TARGET`, so inside an array it ends the resolution instead of being skipped (`:501-506`). (2) A non-string `main` fell through to `module`, which Node never reads; on `main` such a manifest failed to decode, so the PR had turned an open closure into one closed over a file Node does not load. (3) schema-laws `tests-own-no-schemas`: the differential spec's probe schemas sat in `tests/__fixtures__/node-resolution.schema.ts`. KTD2, R4, U2 and U5 change with them. The lineage (0606, 0546, 0519, 0441, 0239) never reached `main`; this plan replaces 0620 in this PR. + +## Product Contract + +### Summary + +The import closure decides which files a test file depends on. Incremental runs reuse a mutant's verdict while the closures of its covering tests stay the same. For a workspace-linked package, the closure reads that package's `package.json` to map an import to a file. On `main`, one `exports` shape outside a two-level, string-only grammar fails the decode of the whole file, so every import of that package opens its closure and reuse stops for every test that imports it. + +### Requirements + +- R1. A linked package whose `exports` holds nested condition objects, array fallbacks, root conditions without a `.` key, or `null` targets resolves its imports, so an edit to a file no test reaches leaves every verdict reused. Observable as the second run's `reuse` event: `ran` is 0 and no refusal counts a mutant. +- R2. A manifest key whose value the resolver cannot interpret affects only the requests that read that key's entry. It never refuses the other keys or the rest of the file. This holds for `exports`, `imports`, `main` and `module`. +- R3. Every specifier resolves to the file the runtime loads, or leaves its importers' closures open; never to a file no runtime loads. Every `#` import and node builtin that #308 closed stays closed. +- R4. An entry the resolver cannot map (a `null` target, an empty array, an array none of whose elements yields a file once one of them is `null` or an invalid target, a condition branch ending in `null` or a number or boolean, a condition object no condition of Node's matches, a condition object with an array-index key at any depth, a non-string `main` (then `module` is not read), or an entry whose ESM and CommonJS importers load different files) yields no target, and that is final: no pattern and no `main`/`module` rescues it. So the closures that import it stay open. +- R5. Exit codes, log lines, report files and stream lines are unchanged for projects without the bug. + +### Acceptance Examples + +- AE1 (R1). A project links `@fixture/lib` whose `exports` is `{".": {"import": {"types": "./dist/index.d.ts", "default": "./dist/index.js"}}}`, the shape r41 names. Run 1 tests every mutant. A file no test imports is edited. Run 2 exits 0, and its `reuse` event reports `ran: 0` and `reused` equal to run 1's tested count, with every refusal 0. +- AE2 (R1). The same with `exports` `{".": ["./dist/index.js"]}`, `["./dist/index.js"]`, `{".": "./dist/index.js", "./internal/*": null}`, and, on a runner that reports no loaded modules, `{".": {"types": ..., "default": ...}}` and root conditions `{"types": ..., "import": ...}`. +- AE3 (R1, control). The same with flat `exports` `{".": "./dist/index.js"}`. This passes on `main` today, which shows the harness measures the bug rather than its own setup. +- AE4 (R4). Proven by U2's laws only. `main` already keeps such closures open (the whole manifest fails), so a CLI scenario could not fail there. +- AE5 (R3). A `types`-first package (nested and flat) on a runner that reports no loaded modules: only the package's `.js` changes between the runs, and run 2 tests again every mutant run 1 tested, refusing reuse for `closureChanged`. The same on the Vitest-backed `vm` runner, which reports loaded modules. A package with `import` then `require` branches, required by its test file, on the runner that reports no loaded modules: only its `.cjs` changes, and run 2 tests again every mutant. + +### Scope Boundaries + +- The condition rule: `@systemfsoftware/source` first (the workspace's own condition); otherwise the entry is read twice, as Node reads it, under the ESM set (`import`, `node`, `module-sync`, `node-addons`, `default`) and the CommonJS set (`require`, `node`, `module-sync`, `node-addons`, `default`): in each, the first key in object order that the set matches decides, and every other key (`types`, `browser`, `module`, `development`, user conditions) is skipped. The entry yields a target when both reads yield the same file, or when one yields a file and the other none; when they yield different files it yields none, because the scanner sends `require()` and `import` specifiers through one request and a runner that compiles `import` syntax to CommonJS takes the `require` branch, so the static reader cannot know which file loads. +- Array fallbacks follow Node's implementation (`resolve.js:488-520`) for the shapes the reader decodes: the first element yielding a file wins; `null`, a number or boolean, and an object no condition matches are skipped; with no file, the array yields none if it is empty or any element was `null` or an invalid target, and does not decide (the parent object reads its next condition) when every element matched no condition. A string Node rejects as a target (KTD2) is skipped the way Node skips it. Percent-encoded segments (`%2e%2e`) are not recognised as invalid; such a target names a directory that does not exist, so its closure stays open where Node would read the next element: slow, never stale. +- Not here: Stream B's reuse suites (`plan-stream-location`, `incremental-reuse`, `lockfile-closure-digest` and the others). A new suite is added instead. +- In scope since r49: schema-laws `rich-type-over-foreign-encoded`. The root ruled that it and the workflow-only mutation ruling do not conflict (`.rulings/stream-d-r51b-prompt.md:3-6`): the codec is graded by its generated laws and the target choice by mutation, so no exception is recorded. + +### Sources + +- `packages/stryker-js/src/import-closure.schema.ts` on `main`: `ExportEntrySchema` and the `exports` union; `imports` already `S.Json` (#308). +- `packages/stryker-js/src/import-closure.cell.ts`: `manifestOf` maps a failed decode to `undefined`, and `manifestTargetOf` maps `undefined` to `ManifestTargetMissing`; a chosen target becomes a member only if a candidate file exists; otherwise the resolution is `Unresolved`. +- `packages/stryker-js-cli-contract/src/run-event.schema.ts`: `ReuseRefusals` and the `reuse` event a run prints on stdout in machine mode. +- Node `doc/api/packages.md`, "Conditional exports" and "Community Conditions Definitions": Node matches conditions in key order against `node`, `import`, `require`, `module-sync`, `node-addons`, `default` and user conditions, and never `types`; an `exports` object with no `.`-prefixed key is sugar for `{".": {...}}`, and mixing both kinds of key is an invalid package configuration. +- Node `doc/api/esm.md`, resolution algorithm: `PACKAGE_RESOLVE` uses `exports` only when it "is not null or undefined". +- Node `lib/internal/modules/esm/resolve.js` (v24.x) `resolvePackageTarget`, `:482-548`: `[]` returns `null` (`:489-491`); an array element that throws `ERR_INVALID_PACKAGE_TARGET`, returns `undefined` or returns `null` is skipped (`:501-514`); with no resolved element the array returns `undefined` only when every element returned `undefined`, else `null` or the last error (`:517-520`); an object returns its first matched condition whose result is not `undefined` (`:531-542`). Where `esm.md`'s prose reads as an array returning on `null`, the implementation is what loads the file, and U5 pins it. +- `CompleteDryRunResult.testFileModules` (plugin interface): only the Vitest runner reports it; `analyzeImportClosure` adds each observed module to its test file's closure. +- `docs/solutions/performance-issues/strict-optional-key-in-a-third-party-manifest-schema-refuses-the-whole-manifest.md`: the rule this unit completes. + +## Planning Contract + +### Key Technical Decisions + +- KTD1. **`PackageManifestSchema` keeps the foreign `package.json` as its Encoded side and decodes `exports`, `imports`, `main` and `module` into `PackageEntryField`** (r49). `PackageEntry` is a tagged tree with one variant per JSON shape: `EntryPath` (string), `EntryFallbacks` (array), `EntryMap` (object, key order kept), `EntryNull` and `EntryScalar` (number or boolean); an absent key is `EntryUndeclared`. The decode is total over JSON, so the manifest fails only when `package.json` is not a JSON object, and the generated round-trip, encode-stability and recursion laws grade the codec from the Type side. The schema holds no target choice: which condition, which fallback and which key form counts all stay in `selectManifestTarget`. `ExportEntrySchema` and its type are deleted along with their production uses. Rejected: + - `S.optionalKey(S.Json)` with the grammar read inside the decision (this plan's first KTD1). It keeps the producer's raw shape as the domain type, against `rich-type-over-foreign-encoded` rule 2. + - Widening the `exports` schema to Node's grammar (only the shapes Node accepts). It refuses the whole file on any shape outside the grammar, which is the defect the lesson records, and it moves the decision of what Node accepts into the codec, which mutation does not grade. + - Widening `exports` only. It leaves `main`/`module` able to refuse the file, against the rule the lesson records. +- KTD2. **One entry reader serves `exports` and `imports` at any depth, a present entry is final, and a condition object follows the runtime.** + - Reading an entry: + - a string is the target when Node accepts it as one (`resolve.js:389-423`): in `exports`, it starts with `./` and has no `.`, `..` or `node_modules` segment after it; in `imports`, a `./` string the same way, or a bare package specifier (not empty, not starting with `.`, `/` or `#`); any other string decides that no target loads, and inside an array it is skipped; + - an array yields its first element that yields a file, read again by the same reader; with none, it yields none when it is empty or an element was `null` or a number or boolean, and does not decide when every element matched no condition; + - an object yields its `@systemfsoftware/source` value if present; otherwise its first key in the read's condition set, read again by the same reader; with no such key it does not decide; + - `null` and a number or boolean decide that no target loads (inside an array they are skipped, as above); + - an object with an array-index key (`0`, `1`, …) refuses the whole read, as Node's `ERR_INVALID_PACKAGE_CONFIG` does: the reader carries the refusal in a failure channel, so an object returns it, an array stops at it instead of skipping it, and the entry yields no target. + - Every entry is read under the ESM set and the CommonJS set. Both yield the same file, or one a file and the other none: that file. Different files, or none in both: no target. + - A top-level `exports` string or array is the root entry. A top-level `exports` object with no `.`-prefixed key is the root's conditions; one mixing `.`-prefixed and other keys yields no target. A top-level `null` counts as absent, as in Node, so `main`/`module` apply. For the root fallback, a string `main` is the target; a present non-string `main` yields no target; `module` is read only when `main` is absent. + - Lookup separates "no entry for this request" from "an entry that yields no target": + - **No entry:** no exact key and no matching pattern. Patterns, then `main`/`module` for the root. + - **An entry that yields no target:** `ManifestTargetMissing`, with no pattern and no `main` consulted (R4). + + Rejected: + - The first-value rule (the 0239 plan's choice). It closes a `types`-first object over a `.d.ts` no runtime loads; the review's confirmed P1. + - Skipping `require` as never active. A CommonJS importer loads that branch, and the request does not carry the importer's syntax, so the closure stays open instead. + - Letting the first key past `types` decide, with `import` followed only when no later key serves CommonJS (this plan's first draft). Node skips a condition it does not match, so `{ "module": …, "default": … }` loads `default`; the draft left it open, and its key test duplicated what reading under the CommonJS set states directly. + - Following `import` whatever its siblings (0519's rule). The scanner sends `require()` and `import` specifiers through one request (`extract-module-specifiers.workflow.ts` `isRequireCall`), so a CommonJS importer of `{ import, require }` closed over the ESM file; the review's confirmed P2. + - Carrying the importer's syntax on the request. The source syntax does not fix the runtime's condition set: a runner that compiles `import` syntax to CommonJS loads the `require` branch, so the reader still cannot know which branch runs. + - Keeping a declaration-only choice open while keeping first-value elsewhere (r46's minimal alternative). It still closes flat `{types, default}` on the `.d.ts`, the same stale path. + - Ending an array at its first `null` (0546's rule, and this bullet's former claim that Node returns `null` as blocked). Node's implementation skips it (`resolve.js:511-514`). + - Pinning Node with a known-answer table in the laws. A table restates what the author believes Node does, and that belief was wrong twice here; U5 asks Node. + - Falling through to patterns or `main` when an entry yields no target. That closes the closure on a file Node never loads. + - Treating an array-index object as an unmatched or blocked element (`e8393042d`). Node rethrows its `ERR_INVALID_PACKAGE_CONFIG` inside an array, so a later element must not be read. + - Letting a non-string `main` fall through to `module` (`e8393042d` and before). Node ignores such a `main` and never reads `module`; the closure must stay open, as it did on `main`. + - Judging a string only after the target is chosen (`mapPathTargetOf`, 0606 and before). An array then stops at a string Node skips, and an `exports` string without `./` became a path target; U5 shrank that to `"/a.js"`. +- KTD3. **The user-observable test drives the built CLI.** `node dist/main.mjs run` runs twice on a temporary repo, with an edit between the runs, and the test reads only the exit codes and the `reuse` line of stdout. The `vm` runner reports the modules each test loaded, which can hide a stale static closure, so AE5 and the closure-only rows of AE2 use an on-disk runner fixture, `closure-only`, that reports no `testFileModules` (the on-disk runner pattern `dry-run-failure.integration.test.ts` uses). + +### Plan Gates (plan-discipline rule) + +- **Test-layer admission (`test-layer-selection`, `architect-property-tests` rule).** U2's laws draw the production `ManifestTargetCommand` schema and state relations over values derived from the draw. U3/U4 spawn the built CLI, which the skill's in-process gate refuses, but conductor intent r41 ("Ryan's rule") requires exactly this test and outranks the skill; the conflict is recorded here. +- **Wiki probe.** `software-wiki` returned no document on reading `package.json` `exports`, nor on decoding a foreign document into a total tagged union. +- **Web probe.** Node's packages and ESM resolution documents (Sources). `PACKAGE_TARGET_RESOLVE` (Node `doc/api/esm.md`) branches on exactly these target kinds: a string, an array (it loops past invalid targets, `null` and unmatched elements; Sources), a conditions object and `null`; anything else is an Invalid Package Target. KTD1's variants are those kinds, plus `EntryScalar` for the invalid numbers and booleans. +- **Destructive review: assumptions it breaks.** + - "First value is a safe approximation at any depth." It is not when the first branch is one no runtime takes (`types`); KTD2 now follows the runtime. + - "A runner's observed modules make the static closure's file choice harmless." Only the Vitest runner reports them; KTD3 adds a runner that does not. + - "An entry that yields nothing can fall back." It cannot: Node never falls back from a present entry. + - "`import` is a runtime condition like `node`." It is not: only ESM importers match it, and a CommonJS importer of the same object takes a later branch. + - "The first key past `types` decides." It does not: Node skips every key outside its condition set, so a `browser` or `module` branch first falls through to `default`. + - "A schema over a foreign document must either refuse or stay raw JSON." It need not: a decode with one variant per JSON shape is total, so the Type can be rich without refusing a file the reader could still use (KTD1). + +### Sequencing + +U1 → U2 → U3 → U4 → U5. + +## Implementation Units + +### U1. Manifest keys decode into tagged entries, entries read at any depth + +- **Goal.** R1, R2, R4 at the decision. +- **Files.** `packages/stryker-js/src/import-closure.schema.ts`; `packages/stryker-js/src/select-manifest-target.workflow.ts`; `packages/stryker-js/src/__tests__/select-manifest-target.workflow.property.test.ts` (fixtures built through the production codec). +- **Approach.** KTD1 and KTD2. The decision dispatches exhaustively on the entry's tag. The lookup returns `Option>`: the outer layer says whether an entry exists, the inner one whether it yields a target. The laws keep writing manifests as `package.json` JSON and reach the decision through `PackageManifestSchema`, so they speak the grammar Node documents. + +### U2. Laws for the decision + +- **File.** `packages/stryker-js/src/__tests__/select-manifest-target.workflow.property.test.ts`. +- **Laws.** Each draws `ManifestTargetCommand`: + - `∀c_ConditionChain_≡FirstRuntimeConditionPastTypesAtEveryDepthForExportsAndImports`; + - `∀c_ConditionChain_≡UnmatchedConditionsFallThroughToTheFileEveryImporterLoads`; + - `∀c_ConditionChain_≡MissingWhenEsmAndCommonJsImportersLoadDifferentFiles`; + - `∀c_RootConditions_≡ReadAsTheRootEntryAndMissingBesideASubpathKey`; + - `∀c_ConditionChain_≡SourceConditionBeforeEarlierKeysAtItsOwnDepth`; + - `∀c_Fallbacks_≡FirstElementYieldingAFileWinsAndOnlyAllUnmatchedElementsFallThrough`; + - `∀c_StringTargets_≡AnInvalidTargetYieldsNoneAloneAndIsSkippedInsideAnArray`; + - `∀c_ConditionObject_≡AnArrayIndexKeyRefusesTheEntryEvenInsideAnArray`; + - `∀c_RootEntry_≡MainDecidesTheRootOnlyWhenExportsDeclaresNone`; + - `∀c_NullExports_≡ReadAsIfExportsWereAbsent`; + - `∀c_ExactEntry_≡DecidesAloneWhateverPatternSitsBesideIt`; + - `∀c_MainOrModule_≡ANonStringMainYieldsNoneAndModuleCountsOnlyWithoutMain`. +- **Evidence.** The first-value decision of `14a06fdab` fails the condition-chain and root-conditions laws; the rest failed against `main`'s decision when added. The two-condition-set decision fails three laws on `07f55c9f9`, its parent: the fall-through, the ESM/CommonJS and the fallbacks laws. The fallbacks law fails on `72410ddba` (0546's array rule), and the string-targets law on `3eee2a48c` (0606's string rule); the array-index and main/module laws fail on `e8393042d`. All pass on the head. + +### U3. The built CLI keeps verdicts across an unrelated edit + +- **Files.** New `packages/stryker-js/tests/workspace-package-exports-reuse.integration.test.ts`. +- **Scenarios.** AE1-AE3 as one outline over runner and `exports` shape. +- **Two-sided proof.** Built from `origin/main` the nested, array and `null`-beside-valid rows fail and the flat control passes; built from the head every row passes. The root-conditions row fails on the first-value build of `14a06fdab`. + +### U4. A runtime-only edit re-tests on every runner + +- **Files.** `packages/stryker-js/tests/__fixtures__/closure-only-runner/` (new on-disk runner plugin that reports one passing test and no `testFileModules`); the runtime-edit outline in U3's suite. +- **Scenarios.** AE5: nested `types`-first on the `vm` runner, and nested `types`-first, flat `types`-first and root conditions on the `closure-only` runner; and the dual `import`/`require` package required through `createRequire` on the `closure-only` runner, editing only its `.cjs`. +- **Two-sided proof.** Built from `14a06fdab` (first-value rule), the closure-only nested and flat `types`-first rows reuse stale verdicts and fail; built from `07f55c9f9` (`import` followed whatever its siblings), the dual-package scenario reuses 2 stale verdicts and fails, as do the laws `∀c_ConditionChain_≡MissingWhenEsmAndCommonJsImportersLoadDifferentFiles` and `∀c_ConditionChain_≡UnmatchedConditionsFallThroughToTheFileEveryImporterLoads`; built from the head every row and law passes. + +### U5. The import closure agrees with Node's resolver + +- **Goal.** R3 and R4 pinned against the runtime (boundary-testing `pin-dependency-semantics`). +- **Files.** New `packages/stryker-js/tests/manifest-target-node-resolution.differential.test.ts` and the reference harness `packages/stryker-js/tests/__fixtures__/node-resolution.fixture.ts` (probe source, its output decode and the `node` run; harness schemas stay unexported there, per schema-laws `tests-own-no-schemas` rule 3). +- **Approach.** `Differential.compare` (`@systemfsoftware/differential-spec`, already a devDependency). Input: two generated entry trees, one for a linked package's `exports` subpath and one for the project's `imports` key, over the strings `./a.js`, `./b.js`, `./c.cjs`, the rejected targets `/a.js`, `../a.js`, `./x/../a.js` and `./node_modules/a.js`, plus `a.js` for `exports` only (in `imports` it names a package, which Node tries to load and whose absence ends an array with an error), `null`, a number, a boolean, arrays and condition objects keyed by `types`, `import`, `require`, `node`, `module-sync`, `node-addons`, `default`, `browser`, `module`, `development` and the array index `0`. Reference: a real `node` process resolves both specifiers with `import.meta.resolve` and `require.resolve` against the package written to disk. Candidate: `ImportClosure.analyzeImportClosure` over the same project, through the public barrel. Oracle: from Node's ESM and CommonJS answers, the file both agree on (or the one answer when the other is none), else open; it must equal the closure's outcome exactly. Host-bound lane (temp directories, a child process); `runBudget` 100. +- **Out of its program space.** Percent-encoded segments, bare package targets in `imports`, pattern keys, `@systemfsoftware/source` and the `main`/`module` fallback; the laws own those. +- **Two-sided proof.** On `07f55c9f9` it fails, shrunk to `imports` `{ "require": "./a.js", "types": "./a.js" }` (Node's CommonJS side loads `a.js`; the closure stayed open). On `72410ddba` it fails, shrunk to `{ "module-sync": [], "node": "./a.js" }` (Node blocks; the closure closed on `a.js`). On `3eee2a48c`, once the generator draws rejected strings, it fails, shrunk to `exports` `"/a.js"` (Node rejects it; the closure closed on `pkgs/lib/a.js`). On `e8393042d`, once the generator draws the key `0`, it fails, shrunk to an `imports` object holding `"0"` (Node refuses it in both module systems; the closure closed on `a.js`). It passes on the head. + +## Verification Contract + +- `pnpm --filter @systemfsoftware/stryker-js typecheck`, `lint`, `lint:tsgo`, and the package's full `test`. +- Build `@systemfsoftware/stryker-js` (`--concurrency=1`) before each built-CLI run; the baseline build is never taken from the head. +- `./bin/dprint check`. +- No local mutation. Survivors on `main` (run 38349712161, `add57742a`) in `select-manifest-target.workflow.ts` are `bf8fc33bfe874ae6`, `c1c49ef7599681d9`, `f00516bf7f032d1a` and `ecd4fec1e600291a`, all in `patternMatchOf`, which this PR leaves alone. The new reader's mutants get their first grade on the next `main` Mutation run. +- Turbo rule: non-applicable. The diff adds tests, a test fixture and changes sources in an existing package; no CI task scope or cache input changes. + +## Definition of Done + +- U1-U5 in one PR off `origin/main`, with a patch changeset for `@systemfsoftware/stryker-js`. +- The lesson records the runtime-condition rule and its closure-only gate. +- CI is green on the PR's exact head, except Bench's inherited budget aborts. +- The PR body carries the two-sided results, the review receipt and every finding's disposition, each row naming its fixing commit. diff --git a/docs/solutions/performance-issues/strict-optional-key-in-a-third-party-manifest-schema-refuses-the-whole-manifest.md b/docs/solutions/performance-issues/strict-optional-key-in-a-third-party-manifest-schema-refuses-the-whole-manifest.md new file mode 100644 index 000000000..369ee76c8 --- /dev/null +++ b/docs/solutions/performance-issues/strict-optional-key-in-a-third-party-manifest-schema-refuses-the-whole-manifest.md @@ -0,0 +1,86 @@ +--- +title: A strictly typed optional key in a third-party manifest schema made shapes Node accepts fail the whole manifest decode +date: 2026-10-11 +category: performance-issues +module: stryker-js +problem_type: performance_issue +component: tooling +symptoms: + - "A test file that imports a workspace package through `exports` gets an open import closure once that package's `package.json` also holds an `imports` entry that is `null`, an array, or a nested condition object" + - "A test file that imports a workspace package whose `exports` uses nested condition objects, an array or a `null` subpath gets an open import closure, so it re-runs after an edit to a file it never reaches" + - "That test file's closure digest moves on an edit to any project file, so incremental runs re-test it whatever changed" + - "Node's `imports` grammar admits these shapes; only Stryker's own manifest decode refuses them" +root_cause: logic_error +resolution_type: code_fix +severity: medium +tags: [import-closure, package-json, effect-schema, decode-granularity, package-imports, package-exports, incremental-reuse] +--- + +# A strictly typed optional key in a third-party manifest schema made shapes Node accepts fail the whole manifest decode + +## Problem + +The import closure finds the files a test file depends on and digests them. Incremental runs reuse a test file's results while that digest stays the same. To resolve a package import, the closure cell decodes the package's `package.json` with one struct, `PackageManifestSchema`, through `S.decodeOption(S.fromJsonString(PackageManifestSchema))`. PR #308 teaches the closure to resolve `#` subpath imports. Its first version added `imports` to that struct with the type `exports` already used: each entry a string or a flat condition record. Node accepts three more shapes for an entry: `null` (a blocked subpath), an array of fallbacks, and a nested condition object such as `{ "node": { "import": "./x.js" } }`. With any of them in the file, the whole manifest failed to decode. The import then resolved nowhere and the closure opened, even for imports that only needed the package's `exports`, `main` or `module`. Review caught it before merge. Main never had the regression. + +## Symptoms + +- A package imported through `exports` loses its closure when one unrelated `imports` entry has a shape Node accepts and the schema does not. +- The digest moves on unrelated edits, so incremental reuse silently stops for every test file that imports that package. +- The cause is discarded. The cell turns the decode `None` into a missing manifest, then into an `Unresolved` resolution that carries no reason, and the closure reports only `open: true`. + +## What Didn't Work + +- Typing the new key the way the existing `exports` key was typed. It looked consistent, and it was the defect. A narrow type on one key does not refuse that key. It refuses the whole document. + +## Solution + +- Decode the key into a tagged entry type that covers every JSON shape, so that key can never fail the decode. `PackageEntry` is `EntryPath | EntryFallbacks | EntryMap | EntryNull | EntryScalar` (a string, an array, an object, `null`, a number or boolean), and an absent key is `EntryUndeclared`. The foreign `package.json` stays the Encoded side; the decode is total, and the generated round-trip laws hold from the Type side (pack: schema-laws, `rich-type-over-foreign-encoded.md`). +- Read entries one at a time in the pure decision `selectManifestTarget`, which dispatches on the entry's tag. A path, or a condition map whose chosen value is a path, resolves. Anything else gives `ManifestTargetMissing` for that one specifier, which opens only the closures that import it. +- Regression gate: the import-closure integration scenario "Imports entries the resolver cannot read leave the rest of that package.json in force". A workspace package carries a nested-condition entry, a `null` entry and an array `imports` entry beside a valid `exports` map. A test file imports it through `exports` and must get a closed closure whose digest stays the same after an unrelated edit. With the strict key the scenario fails (`open: true`, the digest moves). With the fix it passes. + +## Why This Works + +Mechanism, in order: + +1. `S.Struct` ignores keys it does not declare. Before the key was declared, no `imports` value could affect the decode. +2. Declaring `imports` with a type narrower than Node's grammar turned every unlisted shape into a decode failure. The failure belongs to the whole struct, not to the key. +3. The closure cell maps a failed decode to "no manifest", so every specifier into that package resolves to `Unresolved`. +4. An `Unresolved` resolution opens the closure (`opensClosure`). An open closure folds `projectDigest`, a hash over every scanned module, into its own digest in `closureDigestOf`. So the digest moves on any project edit. + +Before the change, the blast radius of an unsupported `imports` entry was nothing. After it, the blast radius was every test file that imports the package. The useful radius is only the closures that import that one `#` specifier. + +**Invariant: refuse at the granularity of use.** A schema that decodes a document another tool owns may refuse only what the reader would fail to use. A key whose grammar the reader handles only in part decodes into a tagged type whose variants cover every shape the document can hold, and the decision judges each entry by its tag. The struct types only the keys that are needed whole. + +```ts +// WRONG: one unsupported entry refuses exports, main and module too +const PackageManifestSchema = S.Struct({ + exports: S.optionalKey(S.Union([S.String, S.Record(S.String, ExportEntrySchema)])), + imports: S.optionalKey(S.Record(S.String, ExportEntrySchema)), +}) +// RIGHT: the key always decodes into a tagged entry; the decision refuses one specifier +const PackageEntryFieldFromKey = S.optionalKey(S.Json).pipe( + S.decodeTo( + PackageEntryFieldSchema, + SchemaTransformation.transformOptional({ decode: declaredFieldOf, encode: declaredJsonOf }), + ), +) +const PackageManifestSchema = S.Struct({ imports: PackageEntryFieldFromKey }) +// selectManifestTarget: EntryPath -> target; EntryMap -> chosen condition; any other tag -> ManifestTargetMissing +``` + +The value is still decoded and never cast (pack: cell-architecture, `decode-never-cast.md`). Only the place where the typed refusal lands changes. + +**Code smell to grep for:** `S.optionalKey(` or `S.optional(` holding a union or record narrower than the owning tool's grammar, inside a struct decoded by `S.decodeOption(S.fromJsonString(...))` over a file Stryker does not write (`package.json`, `tsconfig.json`, a report from another release). + +## Prevention + +- Before you add a key to a schema over a document Stryker does not own, list every shape the owning tool accepts for that key. If the domain distinguishes fewer shapes, decode the key into a tagged type with a variant for each shape the document can hold, and refuse per entry in the decision. +- Gate each such key with a scenario that puts an unsupported shape beside a supported field and asserts that the supported field still takes effect. A test that only uses valid shapes cannot see this failure. Here, the scenario named under Solution is the gate. +- `exports`, `main` and `module` had the same flaw and are fixed the same way: every key of `PackageManifestSchema` decodes into `PackageEntryField`, and `selectManifestTarget` reads each entry at any depth by its tag. A path is the target, fallbacks yield their first element that yields a file (as in Node's `resolvePackageTarget`, `null`, a number, a boolean and an object no condition matches are skipped, and `[]` yields none), `EntryNull` yields no target, and an `exports` map with no `.`-prefixed key is the root's conditions (Node's sugar for `{ ".": {...} }`); mixing both kinds of key yields no target, as Node refuses it. Nested conditions, array fallbacks, a top-level array, root conditions and a `null` subpath no longer fail the whole decode; the workspace's own `vitest-config`, `tsdown-config`, `stryker-config` and `oxlint-ignorer-config` manifests all use the nested shape `{ ".": { "import": { "types": "...", "default": "..." } } }`. Gate: the built-CLI feature "Reusing verdicts for tests that import a linked workspace package", whose outline runs each shape twice around an edit no test reaches. +- A condition object resolves to the file the runtime loads, never the first value. `@systemfsoftware/source` wins (the workspace's own condition); otherwise the entry is read as Node reads it, once under the ESM conditions (`import`, `node`, `module-sync`, `node-addons`, `default`) and once under the CommonJS ones (`require` in place of `import`), skipping every other key (`types`, `browser`, `module`, a user condition). Both reads giving one file, or one giving a file and the other none, is the target; two different files yield no target, because the importer's module system decides between them and the static reader cannot know it: the scanner sends `require()` and `import` specifiers through one request, and a runner that compiles `import` syntax to CommonJS takes the `require` branch anyway. Open is slow; a closure closed over a file no runtime loads is stale. The first-value rule closed `{ "types", "default" }` on the `.d.ts`, and only dry-run evidence (`CompleteDryRunResult.testFileModules`, which the Vitest runner reports and `analyzeImportClosure` adds to the closure) kept those verdicts honest. Gate: the same feature's runtime-edit outline with the `closure-only` test-runner fixture, which reports no `testFileModules`; it changes only the package's `.js` and expects every mutant tested again, and on the first-value rule its nested and flat `types`-first rows reused stale verdicts. The scenario "A linked package with import and require branches still re-tests its CommonJS importer…" is the gate for the dual case: it edits only the `.cjs` file a `require` importer loads. +- When the reader encodes another program's algorithm (here Node's package resolver), pin it against that program, not against the author's reading of its docs. Gate: `tests/manifest-target-node-resolution.differential.test.ts`, which resolves generated `exports` and `imports` entries with a real `node` and compares the closure. Its runs found three rules the laws had stated wrongly: `[]` blocks instead of falling through; a `null` element is skipped, not final; a string Node rejects as a target (`/x.js`, `../x.js`, `./a/../x.js`, or one without `./` in `exports`) yields no file, and is skipped inside an array; and a condition object with an array-index key refuses the whole read, even inside an array. A non-string `main` yields no target and `module` is never read past it, since Node reads neither. + +## Related Issues + +- `docs/solutions/performance-issues/checker-digest-lost-to-tsconfig-extends-through-package-exports.md`: the same failure family. A private resolver that understands less of `package.json` than Node does silently turns a reuse key into "always changed". +- PR #308: the `#` subpath imports and Node builtins fix this learning came from. diff --git a/packages/stryker-js/src/__tests__/select-manifest-target.workflow.property.test.ts b/packages/stryker-js/src/__tests__/select-manifest-target.workflow.property.test.ts index 141ef4867..3b1266319 100644 --- a/packages/stryker-js/src/__tests__/select-manifest-target.workflow.property.test.ts +++ b/packages/stryker-js/src/__tests__/select-manifest-target.workflow.property.test.ts @@ -1,22 +1,19 @@ import { describe, it } from '@systemfsoftware/vitest' import * as Match from 'effect/Match' import * as Result from 'effect/Result' -import type * as S from 'effect/Schema' +import * as S from 'effect/Schema' import { - type ExportEntry, type ManifestTarget, ManifestTargetCommand, PackageExportRequest, PackageImportRequest, - type PackageManifest, + PackageManifestSchema, } from '../import-closure.schema.js' import { selectManifestTarget } from '../select-manifest-target.workflow.js' type SelectSubject = (command: ManifestTargetCommand) => Result.Result -type EntryMap = Readonly> - const INVALID_SEGMENTS: readonly string[] = ['.', '..', 'node_modules', 'Node_Modules'] const SOURCE_CONDITION = '@systemfsoftware/source' @@ -33,32 +30,92 @@ const oneSegmentOf = (command: ManifestTargetCommand): string => textOf(command) const exportKeyOf = (subpath: string): string => (subpath.length === 0 ? '.' : `./${subpath}`) +type ManifestWire = typeof PackageManifestSchema.Encoded + +const wireOf = (command: ManifestTargetCommand): ManifestWire => + Result.getOrThrow(S.encodeResult(PackageManifestSchema)(command.manifest)) + const selected = ( subject: SelectSubject, - manifest: PackageManifest, + wire: ManifestWire, request: PackageExportRequest | PackageImportRequest, ): string => - Result.match(subject(ManifestTargetCommand.make({ manifest, request })), { - onFailure: () => 'refused', - onSuccess: (target) => - Match.value(target).pipe( - Match.tagsExhaustive({ - ManifestPathTarget: ({ target: path }) => `path:${path}`, - ManifestPackageTarget: ({ specifier }) => `package:${specifier}`, - ManifestTargetMissing: () => 'missing', - }), - ), - }) + Result.match( + Result.flatMap( + S.decodeResult(PackageManifestSchema)(wire), + (manifest) => subject(ManifestTargetCommand.make({ manifest, request })), + ), + { + onFailure: () => 'refused', + onSuccess: (target) => + Match.value(target).pipe( + Match.tagsExhaustive({ + ManifestPathTarget: ({ target: path }) => `path:${path}`, + ManifestPackageTarget: ({ specifier }) => `package:${specifier}`, + ManifestTargetMissing: () => 'missing', + }), + ), + }, + ) const exported = ( subject: SelectSubject, command: ManifestTargetCommand, - exports: string | EntryMap, + exports: S.Json, subpath: string, -) => selected(subject, { ...command.manifest, exports }, PackageExportRequest.make({ subpath })) +) => selected(subject, { ...wireOf(command), exports }, PackageExportRequest.make({ subpath })) + +const ROOT_REQUEST = PackageExportRequest.make({ subpath: '' }) + +const RUNTIME_CONDITIONS: ReadonlyArray = ['import', 'node', 'default'] + +const runtimeConditionAt = (depth: number): string => RUNTIME_CONDITIONS[depth % RUNTIME_CONDITIONS.length] ?? 'default' + +const nestedUnder = (keys: ReadonlyArray, innermost: S.Json): S.Json => + keys.reduceRight( + (inner, _key, depth) => ({ + types: './index.d.ts', + [`types@>=${depth}`]: './versioned.d.ts', + [runtimeConditionAt(depth)]: inner, + browser: './later.js', + }), + innermost, + ) + +const COMMONJS_SIBLINGS: ReadonlyArray = ['require', 'module-sync', 'node', 'default'] + +const commonJsSiblingOf = (command: ManifestTargetCommand): string => + COMMONJS_SIBLINGS[textOf(command).length % COMMONJS_SIBLINGS.length] ?? 'require' + +const SHARED_CONDITIONS: ReadonlyArray = ['node', 'default'] + +const sharedUnder = (keys: ReadonlyArray, innermost: S.Json): S.Json => + keys.reduceRight( + (inner, _key, depth) => ({ + types: './index.d.ts', + [SHARED_CONDITIONS[depth % SHARED_CONDITIONS.length] ?? 'default']: inner, + browser: './later.js', + }), + innermost, + ) + +const UNMATCHED_CONDITIONS: ReadonlyArray = ['browser', 'module', 'development', 'production'] + +const unmatchedConditionOf = (command: ManifestTargetCommand): string => + [ + ...UNMATCHED_CONDITIONS, + `c${oneSegmentOf(command)}`, + ][textOf(command).length % (UNMATCHED_CONDITIONS.length + 1)] ?? + 'browser' + +const targetOf = (command: ManifestTargetCommand): string => `./${oneSegmentOf(command)}x.ts` + +const keysOf = (command: ManifestTargetCommand): ReadonlyArray => ['outer', ...textOf(command).split('/')] + +const manifestExportsOf = (command: ManifestTargetCommand): S.Json => wireOf(command).exports ?? null const imported = (subject: SelectSubject, command: ManifestTargetCommand, imports: S.Json, specifier: string) => - selected(subject, { ...command.manifest, imports }, PackageImportRequest.make({ specifier })) + selected(subject, { ...wireOf(command), imports }, PackageImportRequest.make({ specifier })) describe('selectManifestTarget', () => { it.prop( @@ -129,13 +186,18 @@ describe('selectManifestTarget', () => { ) it.prop( - '∀c_StringExports_≡RootOnlyWhateverMainAndModuleSay', + '∀c_RootExports_≡StringOrArrayRootServesTheRootOnlyWhateverMainAndModuleSay', { of: [ManifestTargetCommand], subject: selectManifestTarget }, - (subject, [command]) => - [ - exported(subject, command, './index.js', ''), - exported(subject, command, './index.js', `x${textOf(command)}`), - ].join('|') === 'path:./index.js|missing', + (subject, [command]) => { + const target = targetOf(command) + const subpath = `x${textOf(command)}` + return [ + exported(subject, command, target, ''), + exported(subject, command, target, subpath), + exported(subject, command, [target], ''), + exported(subject, command, [target], subpath), + ].join('|') === `path:${target}|missing|path:${target}|missing` + }, ) it.prop( @@ -188,9 +250,254 @@ describe('selectManifestTarget', () => { '∀c_ManifestWithoutImports_≡EveryImportMissingWhateverItsExports', { of: [ManifestTargetCommand], subject: selectManifestTarget }, (subject, [command]) => { - const { imports: _dropped, ...withoutImports } = command.manifest + const { imports: _dropped, ...withoutImports } = wireOf(command) return selected(subject, withoutImports, PackageImportRequest.make({ specifier: `#${textOf(command)}` })) === 'missing' }, ) + + it.prop( + '∀c_ConditionChain_≡FirstRuntimeConditionPastTypesAtEveryDepthForExportsAndImports', + { of: [ManifestTargetCommand], subject: selectManifestTarget }, + (subject, [command]) => { + const target = targetOf(command) + const subpath = `x${oneSegmentOf(command)}` + const specifier = `#${subpath}` + const nested = nestedUnder(keysOf(command), target) + return [ + exported(subject, command, { [exportKeyOf(subpath)]: nested }, subpath), + imported(subject, command, { [specifier]: nested }, specifier), + ].every((outcome) => outcome === `path:${target}`) + }, + ) + + it.prop( + '∀c_ConditionChain_≡UnmatchedConditionsFallThroughToTheFileEveryImporterLoads', + { of: [ManifestTargetCommand], subject: selectManifestTarget }, + (subject, [command]) => { + const target = targetOf(command) + const subpath = `x${oneSegmentOf(command)}` + const specifier = `#${subpath}` + const keys = keysOf(command) + const fallsThrough = sharedUnder(keys, { + types: './index.d.ts', + [unmatchedConditionOf(command)]: './other.js', + default: target, + }) + const typesOnly = sharedUnder(keys, { types: target, [`types@>=${textOf(command).length}`]: target }) + const scalarFirst = sharedUnder(keys, { types: './index.d.ts', node: textOf(command).length, default: target }) + return [ + exported(subject, command, { [exportKeyOf(subpath)]: fallsThrough }, subpath) === `path:${target}`, + imported(subject, command, { [specifier]: fallsThrough }, specifier) === `path:${target}`, + exported(subject, command, { [exportKeyOf(subpath)]: typesOnly }, subpath) === 'missing', + imported(subject, command, { [specifier]: typesOnly }, specifier) === 'missing', + exported(subject, command, { [exportKeyOf(subpath)]: scalarFirst }, subpath) === 'missing', + imported(subject, command, { [specifier]: scalarFirst }, specifier) === 'missing', + ].every((holds) => holds) + }, + ) + + it.prop( + '∀c_ConditionChain_≡MissingWhenEsmAndCommonJsImportersLoadDifferentFiles', + { of: [ManifestTargetCommand], subject: selectManifestTarget }, + (subject, [command]) => { + const target = targetOf(command) + const subpath = `x${oneSegmentOf(command)}` + const specifier = `#${subpath}` + const keys = keysOf(command) + const dual = sharedUnder(keys, { + types: './index.d.ts', + import: target, + [commonJsSiblingOf(command)]: './other.cjs', + }) + const requireFirst = sharedUnder(keys, { types: './index.d.ts', require: './other.cjs', import: target }) + const esmOnly = sharedUnder(keys, { + types: './index.d.ts', + import: target, + [unmatchedConditionOf(command)]: './other.js', + }) + return [ + exported(subject, command, { [exportKeyOf(subpath)]: dual }, subpath) === 'missing', + imported(subject, command, { [specifier]: dual }, specifier) === 'missing', + exported(subject, command, { [exportKeyOf(subpath)]: requireFirst }, subpath) === 'missing', + imported(subject, command, { [specifier]: requireFirst }, specifier) === 'missing', + exported(subject, command, { [exportKeyOf(subpath)]: esmOnly }, subpath) === `path:${target}`, + imported(subject, command, { [specifier]: esmOnly }, specifier) === `path:${target}`, + ].every((holds) => holds) + }, + ) + + it.prop( + '∀c_RootConditions_≡ReadAsTheRootEntryAndMissingBesideASubpathKey', + { of: [ManifestTargetCommand], subject: selectManifestTarget }, + (subject, [command]) => { + const target = targetOf(command) + const conditions = nestedUnder(keysOf(command), target) + const subpath = `x${oneSegmentOf(command)}` + return [ + exported(subject, command, conditions, '') === `path:${target}`, + exported(subject, command, conditions, subpath) === 'missing', + exported(subject, command, { import: conditions, [exportKeyOf(subpath)]: target }, '') === 'missing', + ].every((holds) => holds) + }, + ) + + it.prop( + '∀c_ConditionChain_≡SourceConditionBeforeEarlierKeysAtItsOwnDepth', + { of: [ManifestTargetCommand], subject: selectManifestTarget }, + (subject, [command]) => { + const subpath = `x${oneSegmentOf(command)}` + const conditions = { default: './default.js', [SOURCE_CONDITION]: './source.ts' } + return exported( + subject, + command, + { [exportKeyOf(subpath)]: nestedUnder(keysOf(command), conditions) }, + subpath, + ) === + 'path:./source.ts' + }, + ) + + it.prop( + '∀c_Fallbacks_≡FirstElementYieldingAFileWinsAndOnlyAllUnmatchedElementsFallThrough', + { of: [ManifestTargetCommand], subject: selectManifestTarget }, + (subject, [command]) => { + const later = targetOf(command) + const head = `./h${oneSegmentOf(command)}.js` + const subpath = `x${oneSegmentOf(command)}` + const key = exportKeyOf(subpath) + const specifier = `#${subpath}` + const unmatchedHead = { [unmatchedConditionOf(command)]: head } + const scalarHead = textOf(command).length + const allUnmatched = { node: [unmatchedHead], default: later } + const blockedBeforeUnmatched = { node: [null, unmatchedHead], default: later } + return [ + exported(subject, command, { [key]: [head, later] }, subpath) === `path:${head}`, + imported(subject, command, { [specifier]: [head, later] }, specifier) === `path:${head}`, + exported(subject, command, { [key]: [null, later] }, subpath) === `path:${later}`, + imported(subject, command, { [specifier]: [null, later] }, specifier) === `path:${later}`, + exported(subject, command, { [key]: [unmatchedHead, later] }, subpath) === `path:${later}`, + imported(subject, command, { [specifier]: [unmatchedHead, later] }, specifier) === `path:${later}`, + exported(subject, command, { [key]: [scalarHead, later] }, subpath) === `path:${later}`, + imported(subject, command, { [specifier]: [scalarHead, later] }, specifier) === `path:${later}`, + exported(subject, command, { [key]: [] }, subpath) === 'missing', + imported(subject, command, { [specifier]: [] }, specifier) === 'missing', + exported(subject, command, { [key]: [null] }, subpath) === 'missing', + imported(subject, command, { [specifier]: [null] }, specifier) === 'missing', + exported(subject, command, { [key]: allUnmatched }, subpath) === `path:${later}`, + imported(subject, command, { [specifier]: allUnmatched }, specifier) === `path:${later}`, + exported(subject, command, { [key]: blockedBeforeUnmatched }, subpath) === 'missing', + imported(subject, command, { [specifier]: blockedBeforeUnmatched }, specifier) === 'missing', + ].every((holds) => holds) + }, + ) + + it.prop( + '∀c_StringTargets_≡AnInvalidTargetYieldsNoneAloneAndIsSkippedInsideAnArray', + { of: [ManifestTargetCommand], subject: selectManifestTarget }, + (subject, [command]) => { + const later = targetOf(command) + const subpath = `x${oneSegmentOf(command)}` + const key = exportKeyOf(subpath) + const specifier = `#${subpath}` + const bare = `${oneSegmentOf(command)}x.js` + const escaping = `./${oneSegmentOf(command)}/../x.js` + const absolute = `/${oneSegmentOf(command)}x.js` + return [ + exported(subject, command, { [key]: bare }, subpath) === 'missing', + exported(subject, command, { [key]: [bare, later] }, subpath) === `path:${later}`, + exported(subject, command, { [key]: escaping }, subpath) === 'missing', + exported(subject, command, { [key]: [escaping, later] }, subpath) === `path:${later}`, + exported(subject, command, { [key]: absolute }, subpath) === 'missing', + exported(subject, command, { [key]: [absolute, later] }, subpath) === `path:${later}`, + imported(subject, command, { [specifier]: escaping }, specifier) === 'missing', + imported(subject, command, { [specifier]: [escaping, later] }, specifier) === `path:${later}`, + imported(subject, command, { [specifier]: absolute }, specifier) === 'missing', + imported(subject, command, { [specifier]: [absolute, later] }, specifier) === `path:${later}`, + ].every((holds) => holds) + }, + ) + + it.prop( + '∀c_RootEntry_≡MainDecidesTheRootOnlyWhenExportsDeclaresNone', + { of: [ManifestTargetCommand], subject: selectManifestTarget }, + (subject, [command]) => { + const entry = manifestExportsOf(command) + const main = targetOf(command) + const rootOf = (manifest: ManifestWire) => selected(subject, manifest, ROOT_REQUEST) + const declared = { imports: wireOf(command).imports ?? null, exports: { '.': entry } } + return [ + rootOf({ ...declared, main }) === rootOf(declared), + rootOf({ ...declared, exports: { './feature': entry }, main }) === `path:${main}`, + ].every((holds) => holds) + }, + ) + + it.prop( + '∀c_NullExports_≡ReadAsIfExportsWereAbsent', + { of: [ManifestTargetCommand], subject: selectManifestTarget }, + (subject, [command]) => { + const { exports: _dropped, ...withoutExports } = wireOf(command) + return selected(subject, { ...withoutExports, exports: null }, command.request) === + selected(subject, withoutExports, command.request) + }, + ) + + it.prop( + '∀c_ExactEntry_≡DecidesAloneWhateverPatternSitsBesideIt', + { of: [ManifestTargetCommand], subject: selectManifestTarget }, + (subject, [command]) => { + const entry = manifestExportsOf(command) + const subpath = `x${oneSegmentOf(command)}` + const specifier = `#${subpath}` + return [ + exported(subject, command, { './*': './wild/*', [`./${subpath}`]: entry }, subpath) === + exported(subject, command, { [`./${subpath}`]: entry }, subpath), + imported(subject, command, { '#*': './any/*', [specifier]: entry }, specifier) === + imported(subject, command, { [specifier]: entry }, specifier), + ].every((holds) => holds) + }, + ) + + it.prop( + '∀c_ConditionObject_≡AnArrayIndexKeyRefusesTheEntryEvenInsideAnArray', + { of: [ManifestTargetCommand], subject: selectManifestTarget }, + (subject, [command]) => { + const target = targetOf(command) + const other = `./o${oneSegmentOf(command)}.js` + const subpath = `x${oneSegmentOf(command)}` + const key = exportKeyOf(subpath) + const specifier = `#${subpath}` + const indexed = { [`${textOf(command).length}`]: other, default: target } + const nested = { node: indexed, default: target } + const leading = [indexed, target] + return [ + exported(subject, command, { [key]: indexed }, subpath) === 'missing', + imported(subject, command, { [specifier]: indexed }, specifier) === 'missing', + exported(subject, command, { [key]: nested }, subpath) === 'missing', + imported(subject, command, { [specifier]: nested }, specifier) === 'missing', + exported(subject, command, { [key]: leading }, subpath) === 'missing', + imported(subject, command, { [specifier]: leading }, specifier) === 'missing', + ].every((holds) => holds) + }, + ) + + it.prop( + '∀c_MainOrModule_≡ANonStringMainYieldsNoneAndModuleCountsOnlyWithoutMain', + { of: [ManifestTargetCommand], subject: selectManifestTarget }, + (subject, [command]) => { + const { exports: _exports, main: _main, module: _module, ...rest } = wireOf(command) + const target = targetOf(command) + const other = `./o${oneSegmentOf(command)}.js` + const unreadable = textOf(command).length + const nonStringMains: ReadonlyArray = [unreadable, true, null, [target], { default: target }] + const root = (fields: Readonly>) => selected(subject, { ...rest, ...fields }, ROOT_REQUEST) + return [ + ...nonStringMains.map((main) => root({ main, module: other }) === 'missing'), + root({ module: target }) === `path:${target}`, + root({ main: target, module: other }) === `path:${target}`, + root({ module: unreadable }) === 'missing', + ].every((holds) => holds) + }, + ) }) diff --git a/packages/stryker-js/src/import-closure.schema.ts b/packages/stryker-js/src/import-closure.schema.ts index d12a61d3c..d5a92bafd 100644 --- a/packages/stryker-js/src/import-closure.schema.ts +++ b/packages/stryker-js/src/import-closure.schema.ts @@ -1,6 +1,10 @@ import { Workflow } from '@systemfsoftware/effect-cell-types' import * as Boolean from 'effect/Boolean' +import * as Match from 'effect/Match' +import * as Option from 'effect/Option' +import * as Predicate from 'effect/Predicate' import * as S from 'effect/Schema' +import * as SchemaTransformation from 'effect/SchemaTransformation' const ImportClosureTypeId: unique symbol = Symbol.for('@systemfsoftware/stryker-js/TestFileClosure') type ImportClosureTypeId = typeof ImportClosureTypeId @@ -29,14 +33,91 @@ export class TestFileClosure extends S.TaggedClass()('TestFileC readonly [ImportClosureTypeId] = ImportClosureTypeId } -export const ExportEntrySchema = S.Union([S.String, S.Record(S.String, S.String)]) -export type ExportEntry = typeof ExportEntrySchema.Type +const EntryPathSchema = S.TaggedStruct('EntryPath', { path: S.String }) +export type EntryPath = typeof EntryPathSchema.Type + +const EntryFallbacksTag = S.TaggedStruct('EntryFallbacks', {}) +export type EntryFallbacks = typeof EntryFallbacksTag.Type & { readonly fallbacks: ReadonlyArray } + +const EntryMapTag = S.TaggedStruct('EntryMap', {}) +export type EntryMap = typeof EntryMapTag.Type & { readonly entries: { readonly [key: string]: PackageEntry } } + +const EntryNullSchema = S.TaggedStruct('EntryNull', {}) +export type EntryNull = typeof EntryNullSchema.Type + +const EntryScalarSchema = S.TaggedStruct('EntryScalar', { value: S.Union([S.Finite, S.Boolean]) }) +export type EntryScalar = typeof EntryScalarSchema.Type + +export type PackageEntry = EntryPath | EntryFallbacks | EntryMap | EntryNull | EntryScalar + +const EntryFallbacksSchema = EntryFallbacksTag.pipe( + S.fieldsAssign({ fallbacks: S.Array(S.suspend((): S.Codec => PackageEntrySchema)) }), +) + +const EntryMapSchema = EntryMapTag.pipe( + S.fieldsAssign({ entries: S.Record(S.String, S.suspend((): S.Codec => PackageEntrySchema)) }), +) + +export const PackageEntrySchema: S.Codec = S.suspend((): S.Codec => + S.Union([EntryPathSchema, EntryFallbacksSchema, EntryMapSchema, EntryNullSchema, EntryScalarSchema]) +).annotate({ identifier: 'PackageEntry', recursionBudget: { maxDepth: 4, depthSize: 'small' } }) + +export const PackageEntryFieldSchema = S.Union([PackageEntrySchema, S.TaggedStruct('EntryUndeclared', {})]) +export type PackageEntryField = typeof PackageEntryFieldSchema.Type + +const isJsonList = (json: S.Json): json is S.JsonArray => Array.isArray(json) + +const isJsonObject = (json: S.Json): json is S.JsonObject => Predicate.isObject(json) + +const entryOf = (json: S.Json): PackageEntry => + Match.value(json).pipe( + Match.when(Predicate.isString, (path): PackageEntry => ({ _tag: 'EntryPath', path })), + Match.when(Predicate.isNull, (): PackageEntry => ({ _tag: 'EntryNull' })), + Match.when( + isJsonList, + (fallbacks): PackageEntry => ({ _tag: 'EntryFallbacks', fallbacks: fallbacks.map(entryOf) }), + ), + Match.when(isJsonObject, (record): PackageEntry => ({ + _tag: 'EntryMap', + entries: Object.fromEntries(Object.entries(record).map(([key, target]) => [key, entryOf(target)])), + })), + Match.orElse((value): PackageEntry => ({ _tag: 'EntryScalar', value })), + ) + +const jsonOf = (entry: PackageEntry): S.Json => + Match.value(entry).pipe( + Match.tagsExhaustive({ + EntryPath: ({ path }): S.Json => path, + EntryFallbacks: ({ fallbacks }): S.Json => fallbacks.map(jsonOf), + EntryMap: ({ entries }): S.Json => + Object.fromEntries(Object.entries(entries).map(([key, target]) => [key, jsonOf(target)])), + EntryNull: (): S.Json => null, + EntryScalar: ({ value }): S.Json => value, + }), + ) + +const declaredFieldOf = (json: Option.Option): Option.Option => + Option.some(Option.match(json, { onNone: (): PackageEntryField => ({ _tag: 'EntryUndeclared' }), onSome: entryOf })) + +const declaredJsonOf = (field: Option.Option): Option.Option => + Option.flatMap(field, (present) => + Match.value(present).pipe( + Match.tag('EntryUndeclared', () => Option.none()), + Match.orElse((entry) => Option.some(jsonOf(entry))), + )) + +const PackageEntryFieldFromKey = S.optionalKey(S.Json).pipe( + S.decodeTo( + PackageEntryFieldSchema, + SchemaTransformation.transformOptional({ decode: declaredFieldOf, encode: declaredJsonOf }), + ), +) export const PackageManifestSchema = S.Struct({ - exports: S.optionalKey(S.Union([S.String, S.Record(S.String, ExportEntrySchema)])), - imports: S.optionalKey(S.Json), - main: S.optionalKey(S.String), - module: S.optionalKey(S.String), + exports: PackageEntryFieldFromKey, + imports: PackageEntryFieldFromKey, + main: PackageEntryFieldFromKey, + module: PackageEntryFieldFromKey, }) export type PackageManifest = typeof PackageManifestSchema.Type diff --git a/packages/stryker-js/src/select-manifest-target.workflow.ts b/packages/stryker-js/src/select-manifest-target.workflow.ts index e5692c56d..fb7000c57 100644 --- a/packages/stryker-js/src/select-manifest-target.workflow.ts +++ b/packages/stryker-js/src/select-manifest-target.workflow.ts @@ -4,22 +4,28 @@ import * as Boolean from 'effect/Boolean' import * as Match from 'effect/Match' import * as Option from 'effect/Option' import * as Order from 'effect/Order' -import * as Predicate from 'effect/Predicate' import * as Result from 'effect/Result' import * as S from 'effect/Schema' import { + type EntryMap, ManifestPackageTarget, ManifestPathTarget, type ManifestTarget, ManifestTargetCommand, ManifestTargetMissing, ManifestTargetSchema, + type PackageEntry, + type PackageEntryField, type PackageManifest, } from './import-closure.schema.js' const PACKAGE_SOURCE_CONDITION = '@systemfsoftware/source' +const ESM_CONDITIONS: ReadonlyArray = ['import', 'node', 'module-sync', 'node-addons', 'default'] + +const COMMONJS_CONDITIONS: ReadonlyArray = ['require', 'node', 'module-sync', 'node-addons', 'default'] + const DEFAULT_ENTRY_KEY = '.' const PATTERN_WILDCARD = '*' @@ -38,31 +44,126 @@ interface PatternMatch { readonly captured: string } -const isEntryMap = (value: S.Json): value is S.JsonObject => Predicate.isObject(value) +type EntryTable = EntryMap['entries'] + +type TargetValidity = (target: string) => boolean + +const isPackageRelative = (target: string): boolean => target.startsWith(PACKAGE_RELATIVE_PREFIX) + +const hasInvalidSegment = (target: string): boolean => + target + .slice(PACKAGE_RELATIVE_PREFIX.length) + .split(TARGET_SEGMENT_SEPARATOR) + .some((segment) => INVALID_TARGET_SEGMENTS.includes(segment.toLowerCase())) -const ownValueOf = (record: S.JsonObject, key: string): Option.Option => +const isValidPathTarget: TargetValidity = (target) => + [isPackageRelative(target), !hasInvalidSegment(target)].every((flag) => flag) + +const isInvalidImportTarget = (target: string): boolean => + [target.length === 0, ...INVALID_IMPORT_TARGET_PREFIXES.map((prefix) => target.startsWith(prefix))].some((flag) => + flag + ) + +const isValidImportTarget: TargetValidity = (target) => + Boolean.match(isPackageRelative(target), { + onTrue: () => !hasInvalidSegment(target), + onFalse: () => !isInvalidImportTarget(target), + }) + +const ownValueOf = (record: EntryTable, key: string): Option.Option => Boolean.match(Object.hasOwn(record, key), { onTrue: () => Option.fromUndefinedOr(record[key]), onFalse: () => Option.none(), }) -const stringOf = (value: S.Json): Option.Option => Option.liftPredicate(value, Predicate.isString) +type Resolution = Option.Option> -const conditionTargetOption = (conditions: S.JsonObject): Option.Option => - Option.flatMap( - Option.orElse(ownValueOf(conditions, PACKAGE_SOURCE_CONDITION), () => Arr.head(Object.values(conditions))), - stringOf, - ) +type InvalidPackageConfig = 'invalid-package-config' + +type Read = Result.Result + +const unmatched: Read = Result.succeed(Option.none()) + +const blocked: Read = Option.none().pipe(Option.some, Result.succeed) + +const refused: Read = Result.fail('invalid-package-config') + +const ARRAY_INDEX_LIMIT = 0xFFFF_FFFF + +const isArrayIndex = (key: string): boolean => { + const index = Number(key) + return [`${index}` === key, index >= 0, index < ARRAY_INDEX_LIMIT].every((flag) => flag) +} + +const isDecided = (read: Read): boolean => Result.match(read, { onFailure: () => true, onSuccess: Option.isSome }) -const entryTargetOfEntry = (entry: S.Json): Option.Option => +const endsFallbacks = (read: Read): boolean => + Result.match(read, { onFailure: () => true, onSuccess: (outcome) => outcome.pipe(Option.flatten, Option.isSome) }) + +const firstDecided = (reads: ReadonlyArray): Read => + Option.getOrElse(Arr.findFirst(reads, isDecided), () => unmatched) + +const fallbacksRead = (reads: ReadonlyArray): Read => + Arr.match(reads, { + onEmpty: () => blocked, + onNonEmpty: (present) => + Option.getOrElse( + Arr.findFirst(present, endsFallbacks), + () => Option.match(Arr.findFirst(present, isDecided), { onNone: () => unmatched, onSome: () => blocked }), + ), + }) + +const matchedEntries = (entries: EntryTable, conditions: ReadonlyArray): ReadonlyArray => + Arr.getSomes(Object.keys(entries).filter((key) => conditions.includes(key)).map((key) => ownValueOf(entries, key))) + +const conditionsRead = (entries: EntryTable, conditions: ReadonlyArray, isValid: TargetValidity): Read => + Option.match(ownValueOf(entries, PACKAGE_SOURCE_CONDITION), { + onSome: readIn(conditions, isValid), + onNone: () => firstDecided(matchedEntries(entries, conditions).map(readIn(conditions, isValid))), + }) + +const readIn = (conditions: ReadonlyArray, isValid: TargetValidity) => (entry: PackageEntry): Read => Match.value(entry).pipe( - Match.when(Predicate.isString, (target) => Option.some(target)), - Match.when(isEntryMap, conditionTargetOption), - Match.orElse(() => Option.none()), + Match.tagsExhaustive({ + EntryPath: ({ path }): Read => Option.liftPredicate(path, isValid).pipe(Option.some, Result.succeed), + EntryNull: (): Read => blocked, + EntryScalar: (): Read => blocked, + EntryFallbacks: ({ fallbacks }): Read => fallbacksRead(fallbacks.map(readIn(conditions, isValid))), + EntryMap: ({ entries }): Read => + Boolean.match(Object.keys(entries).some(isArrayIndex), { + onTrue: () => refused, + onFalse: () => conditionsRead(entries, conditions, isValid), + }), + }), ) -const entryTargetOption = (entries: S.JsonObject, key: string): Option.Option => - Option.flatMap(ownValueOf(entries, key), entryTargetOfEntry) +const fileReadIn = (conditions: ReadonlyArray, isValid: TargetValidity) => (entry: PackageEntry) => + Result.match(readIn(conditions, isValid)(entry), { + onFailure: (): Option.Option => Option.none(), + onSuccess: Option.flatten, + }) + +const loadedTargetOf = (esm: Option.Option, commonJs: Option.Option): Option.Option => + Option.match(esm, { + onNone: () => commonJs, + onSome: (esmTarget) => + Option.match(commonJs, { + onNone: () => Option.some(esmTarget), + onSome: (commonJsTarget) => Option.liftPredicate(esmTarget, (target) => target === commonJsTarget), + }), + }) + +const entryTargetOfEntry = (isValid: TargetValidity) => (entry: PackageEntry): Option.Option => + loadedTargetOf(fileReadIn(ESM_CONDITIONS, isValid)(entry), fileReadIn(COMMONJS_CONDITIONS, isValid)(entry)) + +const exactEntryOption = ( + entries: EntryTable, + key: string, + isValid: TargetValidity, +): Option.Option> => Option.map(ownValueOf(entries, key), entryTargetOfEntry(isValid)) + +const entryTargetOption = (entries: EntryTable, key: string, isValid: TargetValidity): Option.Option => + Option.flatten(exactEntryOption(entries, key, isValid)) const patternMatchOf = (matchKey: string) => (key: string): Option.Option => { const star = key.indexOf(PATTERN_WILDCARD) @@ -87,16 +188,26 @@ const PATTERN_PRECEDENCE: Order.Order = Order.combine( Order.mapInput(Order.flip(Order.Number), (match: PatternMatch) => match.key.length), ) -const patternTargetOption = (entries: S.JsonObject, matchKey: string): Option.Option => - Option.flatMap( +const patternEntryOption = ( + entries: EntryTable, + matchKey: string, + isValid: TargetValidity, +): Option.Option> => + Option.map( Arr.head(Arr.sort(Arr.getSomes(Object.keys(entries).map(patternMatchOf(matchKey))), PATTERN_PRECEDENCE)), (match) => - Option.map(entryTargetOption(entries, match.key), (target) => - target.replaceAll(PATTERN_WILDCARD, () => match.captured)), + Option.map( + entryTargetOption(entries, match.key, isValid), + (target) => target.replaceAll(PATTERN_WILDCARD, () => match.captured), + ), ) -const mapTargetOption = (entries: S.JsonObject, matchKey: string): Option.Option => - Option.orElse(entryTargetOption(entries, matchKey), () => patternTargetOption(entries, matchKey)) +const mapEntryOption = ( + entries: EntryTable, + matchKey: string, + isValid: TargetValidity, +): Option.Option> => + Option.orElse(exactEntryOption(entries, matchKey, isValid), () => patternEntryOption(entries, matchKey, isValid)) const isRootSubpath = (subpath: string): boolean => subpath.length === 0 @@ -106,21 +217,35 @@ const entryKeyOf = (subpath: string): string => onFalse: () => `${PACKAGE_RELATIVE_PREFIX}${subpath}`, }) -const rootTargetOption = (target: string, subpath: string): Option.Option => - Option.liftPredicate(target, () => isRootSubpath(subpath)) +const rootEntryOption = (root: PackageEntry, subpath: string): Option.Option> => + Boolean.match(isRootSubpath(subpath), { + onTrue: () => Option.some(entryTargetOfEntry(isValidPathTarget)(root)), + onFalse: () => Option.none(), + }) + +const isSubpathKey = (key: string): boolean => key.startsWith(DEFAULT_ENTRY_KEY) -const exportsTargetOption = (exports: S.Json, subpath: string): Option.Option => - Match.value(exports).pipe( - Match.when(Predicate.isString, (target) => rootTargetOption(target, subpath)), - Match.when(isEntryMap, (entries) => mapTargetOption(entries, entryKeyOf(subpath))), - Match.orElse(() => Option.none()), +const objectExportsOption = (map: EntryMap, subpath: string): Option.Option> => + Match.value(Object.keys(map.entries)).pipe( + Match.when( + (keys) => keys.every(isSubpathKey), + () => mapEntryOption(map.entries, entryKeyOf(subpath), isValidPathTarget), + ), + Match.when((keys) => !keys.some(isSubpathKey), () => rootEntryOption(map, subpath)), + Match.orElse(() => Option.some(Option.none())), ) -const hasInvalidSegment = (target: string): boolean => - target - .slice(PACKAGE_RELATIVE_PREFIX.length) - .split(TARGET_SEGMENT_SEPARATOR) - .some((segment) => INVALID_TARGET_SEGMENTS.includes(segment.toLowerCase())) +const exportsEntryOption = (exports: PackageEntryField, subpath: string): Option.Option> => + Match.value(exports).pipe( + Match.tagsExhaustive({ + EntryMap: (map) => objectExportsOption(map, subpath), + EntryPath: (root) => rootEntryOption(root, subpath), + EntryFallbacks: (root) => rootEntryOption(root, subpath), + EntryNull: (): Option.Option> => Option.none(), + EntryScalar: (): Option.Option> => Option.none(), + EntryUndeclared: (): Option.Option> => Option.none(), + }), + ) const pathTargetOf = (target: string): ManifestTarget => ManifestPathTarget.make({ target }) @@ -132,33 +257,61 @@ const mapPathTargetOf = (target: string): ManifestTarget => onFalse: () => pathTargetOf(target), }) +const pathOf = (field: PackageEntryField): Option.Option => + Match.value(field).pipe( + Match.tagsExhaustive({ + EntryPath: ({ path }): Option.Option => Option.some(path), + EntryFallbacks: (): Option.Option => Option.none(), + EntryMap: (): Option.Option => Option.none(), + EntryNull: (): Option.Option => Option.none(), + EntryScalar: (): Option.Option => Option.none(), + EntryUndeclared: (): Option.Option => Option.none(), + }), + ) + const mainTargetOption = (manifest: PackageManifest): Option.Option => - Option.orElse(Option.fromUndefinedOr(manifest.main), () => Option.fromUndefinedOr(manifest.module)) + Match.value(manifest.main).pipe( + Match.tagsExhaustive({ + EntryPath: ({ path }): Option.Option => Option.some(path), + EntryUndeclared: (): Option.Option => pathOf(manifest.module), + EntryFallbacks: (): Option.Option => Option.none(), + EntryMap: (): Option.Option => Option.none(), + EntryNull: (): Option.Option => Option.none(), + EntryScalar: (): Option.Option => Option.none(), + }), + ) const rootFallbackOption = (manifest: PackageManifest, subpath: string): Option.Option => Option.flatMap(Option.liftPredicate(subpath, isRootSubpath), () => mainTargetOption(manifest)) +const declaredTargetOf = (target: Option.Option): ManifestTarget => + Option.match(target, { onNone: missingTarget, onSome: mapPathTargetOf }) + +const fallbackTargetOf = (manifest: PackageManifest, subpath: string): ManifestTarget => + Option.match(rootFallbackOption(manifest, subpath), { onNone: missingTarget, onSome: pathTargetOf }) + const exportTargetOf = (manifest: PackageManifest, subpath: string): ManifestTarget => - Option.getOrElse( - Option.orElse( - Option.map( - Option.flatMap(Option.fromUndefinedOr(manifest.exports), (exports) => exportsTargetOption(exports, subpath)), - mapPathTargetOf, - ), - () => Option.map(rootFallbackOption(manifest, subpath), pathTargetOf), - ), - missingTarget, + Option.match(exportsEntryOption(manifest.exports, subpath), { + onNone: () => fallbackTargetOf(manifest, subpath), + onSome: declaredTargetOf, + }) + +const importEntriesOption = (imports: PackageEntryField): Option.Option => + Match.value(imports).pipe( + Match.tagsExhaustive({ + EntryMap: ({ entries }): Option.Option => Option.some(entries), + EntryPath: (): Option.Option => Option.none(), + EntryFallbacks: (): Option.Option => Option.none(), + EntryNull: (): Option.Option => Option.none(), + EntryScalar: (): Option.Option => Option.none(), + EntryUndeclared: (): Option.Option => Option.none(), + }), ) const importTargetOption = (manifest: PackageManifest, specifier: string): Option.Option => Option.flatMap( - Option.filter(Option.fromUndefinedOr(manifest.imports), isEntryMap), - (imports) => mapTargetOption(imports, specifier), - ) - -const isInvalidImportTarget = (target: string): boolean => - [target.length === 0, ...INVALID_IMPORT_TARGET_PREFIXES.map((prefix) => target.startsWith(prefix))].some((flag) => - flag + importEntriesOption(manifest.imports), + (imports) => Option.flatten(mapEntryOption(imports, specifier, isValidImportTarget)), ) const importTargetOf = (target: string): ManifestTarget => diff --git a/packages/stryker-js/tests/__fixtures__/closure-only-runner/index.mjs b/packages/stryker-js/tests/__fixtures__/closure-only-runner/index.mjs new file mode 100644 index 000000000..276d65a57 --- /dev/null +++ b/packages/stryker-js/tests/__fixtures__/closure-only-runner/index.mjs @@ -0,0 +1,3 @@ +export const strykerPlugins = [ + { kind: 'TestRunner', name: 'closure-only', workerEntry: new URL('./worker.mjs', import.meta.url).href }, +] diff --git a/packages/stryker-js/tests/__fixtures__/closure-only-runner/worker.mjs b/packages/stryker-js/tests/__fixtures__/closure-only-runner/worker.mjs new file mode 100644 index 000000000..78ed19b62 --- /dev/null +++ b/packages/stryker-js/tests/__fixtures__/closure-only-runner/worker.mjs @@ -0,0 +1,48 @@ +import { NodeFileSystem, NodeSocketServer } from '@effect/platform-node' +import * as NodeRuntime from '@effect/platform-node/NodeRuntime' +import { Plugin, TestRunner } from '@systemfsoftware/stryker-js-plugin-interface' +import { Worker } from '@systemfsoftware/stryker-js-plugin-runtime' +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import * as Stream from 'effect/Stream' +import { join } from 'node:path' + +const TEST_FILE = 'test/math.test.js' +const TEST_ID = `${TEST_FILE}#adds a doubled number` + +const handlers = Plugin.TestRunnerRpcs.toLayer({ + capabilities: () => Effect.succeed({ reloadEnvironment: true }), + dryRun: () => + Effect.succeed({ + status: 'complete', + tests: [{ + id: TEST_ID, + name: 'adds a doubled number', + timeSpentMs: 1, + status: 'success', + fileName: join(process.cwd(), TEST_FILE), + }], + }), + mutantRun: () => + Stream.make( + TestRunner.MutantRunStarted.make({}), + TestRunner.MutantRunSettled.make({ + result: { + status: 'killed', + killedBy: [TEST_ID], + failureMessage: 'expected 5', + nrOfTests: 1, + executedTests: [{ id: TEST_ID, timeSpentMs: 1 }], + }, + }), + ), +}) + +NodeRuntime.runMain( + Layer.launch( + Worker.workerServerLayer({ rpcs: Plugin.TestRunnerRpcs, handlers, schemaServices: Layer.empty }).pipe( + Layer.provide(NodeSocketServer.layer({ path: process.env['STRYKER_SOCKET'] })), + Layer.provide(NodeFileSystem.layer), + ), + ), +) diff --git a/packages/stryker-js/tests/__fixtures__/node-resolution.fixture.ts b/packages/stryker-js/tests/__fixtures__/node-resolution.fixture.ts new file mode 100644 index 000000000..0428cac10 --- /dev/null +++ b/packages/stryker-js/tests/__fixtures__/node-resolution.fixture.ts @@ -0,0 +1,53 @@ +import * as Effect from 'effect/Effect' +import * as Fiber from 'effect/Fiber' +import * as ChildProcess from 'effect/process/ChildProcess' +import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner' +import * as S from 'effect/Schema' +import * as Stream from 'effect/Stream' + +const NodeResolutionSchema = S.Struct({ esm: S.NullOr(S.String), cjs: S.NullOr(S.String) }) +export type NodeResolution = typeof NodeResolutionSchema.Type + +const NodeResolutionsSchema = S.Struct({ imports: NodeResolutionSchema, exports: NodeResolutionSchema }) +export type NodeResolutions = typeof NodeResolutionsSchema.Type + +const PROBE = 'probe.mjs' + +const PROBE_SOURCE = [ + "import { createRequire } from 'node:module'", + "import { basename } from 'node:path'", + "import { fileURLToPath } from 'node:url'", + 'const require = createRequire(import.meta.url)', + 'const attempt = (resolve) => { try { return basename(resolve()) } catch { return null } }', + 'const of = (specifier) => ({', + ' esm: attempt(() => fileURLToPath(import.meta.resolve(specifier))),', + ' cjs: attempt(() => require.resolve(specifier)),', + '})', + "process.stdout.write(JSON.stringify({ imports: of('#x'), exports: of('@fx/lib/x') }))", + '', +].join('\n') + +export const probeFile: readonly [string, string] = [PROBE, PROBE_SOURCE] + +const decodeResolutions = S.decodeUnknownEffect(S.fromJsonString(NodeResolutionsSchema)) + +export const resolveWithNode = ( + root: string, +): Effect.Effect => + Effect.scoped( + Effect.gen(function*() { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const handle = yield* spawner.spawn( + ChildProcess.make(globalThis.process.execPath, [PROBE], { + cwd: root, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }), + ) + const stdout = yield* Effect.forkScoped(handle.stdout.pipe(Stream.decodeText, Stream.mkString)) + yield* Effect.forkScoped(handle.stderr.pipe(Stream.runDrain)) + yield* handle.exitCode + return yield* decodeResolutions(yield* Fiber.join(stdout)) + }), + ).pipe(Effect.orDie) diff --git a/packages/stryker-js/tests/manifest-target-node-resolution.differential.test.ts b/packages/stryker-js/tests/manifest-target-node-resolution.differential.test.ts new file mode 100644 index 000000000..b5eb56087 --- /dev/null +++ b/packages/stryker-js/tests/manifest-target-node-resolution.differential.test.ts @@ -0,0 +1,187 @@ +import { NodeServices } from '@effect/platform-node' +import { Differential } from '@systemfsoftware/differential-spec' +import { Engine, ImportClosure } from '@systemfsoftware/stryker-js' +import * as Arr from 'effect/Array' +import * as Effect from 'effect/Effect' +import * as FileSystem from 'effect/FileSystem' +import * as Option from 'effect/Option' +import * as Path from 'effect/Path' +import * as S from 'effect/Schema' +import * as fc from 'fast-check' + +import { + type NodeResolution, + type NodeResolutions, + probeFile, + resolveWithNode, +} from './__fixtures__/node-resolution.fixture.js' + +const TARGET_FILES: ReadonlyArray = ['a.js', 'b.js', 'c.cjs'] + +const TARGETS: ReadonlyArray = TARGET_FILES.map((file) => `./${file}`) + +const REJECTED_TARGETS: ReadonlyArray = ['/a.js', '../a.js', './x/../a.js', './node_modules/a.js'] + +const REJECTED_EXPORTS_TARGET: S.Json = 'a.js' + +const CONDITION_KEYS: ReadonlyArray = [ + 'types', + 'import', + 'require', + 'node', + 'module-sync', + 'node-addons', + 'default', + 'browser', + 'module', + '0', + 'development', +] + +const entryOver = (leaves: ReadonlyArray): fc.Arbitrary => + fc.letrec<{ entry: S.Json }>((tie) => ({ + entry: fc.oneof( + { depthSize: 'small', withCrossShrink: true }, + fc.constantFrom(...leaves), + fc.array(tie('entry'), { maxLength: 3 }), + fc.uniqueArray(fc.tuple(fc.constantFrom(...CONDITION_KEYS), tie('entry')), { + selector: ([key]) => key, + minLength: 1, + maxLength: 4, + }).map((pairs): S.Json => Object.fromEntries(pairs)), + ), + })).entry + +const IMPORTS_LEAVES: ReadonlyArray = [...TARGETS, ...REJECTED_TARGETS, null, 0, true] + +interface ManifestEntries { + readonly exportsEntry: S.Json + readonly importsEntry: S.Json +} + +const manifestEntries: fc.Arbitrary = fc.record({ + exportsEntry: entryOver([...IMPORTS_LEAVES, REJECTED_EXPORTS_TARGET]), + importsEntry: entryOver(IMPORTS_LEAVES), +}) + +const IMPORTS_TEST = 'test/imports.test.js' + +const EXPORTS_TEST = 'test/exports.test.js' + +const APP_DIRECTORY = '' + +const LIB_DIRECTORY = 'pkgs/lib/' + +const targetFilesUnder = (directory: string): ReadonlyArray => + TARGET_FILES.map((file) => [ + `${directory}${file}`, + file.endsWith('.cjs') ? 'module.exports = { value: 1 }\n' : 'export const value = 1\n', + ]) + +const projectFilesOf = (input: ManifestEntries): ReadonlyArray => [ + [ + `${APP_DIRECTORY}package.json`, + JSON.stringify({ name: '@fx/app', type: 'module', imports: { '#x': input.importsEntry } }), + ], + [ + `${LIB_DIRECTORY}package.json`, + JSON.stringify({ name: '@fx/lib', type: 'module', exports: { './x': input.exportsEntry } }), + ], + ...targetFilesUnder(APP_DIRECTORY), + ...targetFilesUnder(LIB_DIRECTORY), + [IMPORTS_TEST, "import '#x'\n"], + [EXPORTS_TEST, "import '@fx/lib/x'\n"], +] + +const writeProject = ( + files: ReadonlyArray, +): Effect.Effect => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fs.makeTempDirectory({ prefix: 'stryker-manifest-target-' }) + yield* Effect.forEach( + files, + ([relative, content]) => + fs.makeDirectory(path.dirname(path.join(root, relative)), { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(path.join(root, relative), content)), + ), + { discard: true }, + ) + yield* fs.makeDirectory(path.join(root, 'node_modules', '@fx'), { recursive: true }) + yield* fs.symlink(path.join(root, 'pkgs', 'lib'), path.join(root, 'node_modules', '@fx', 'lib')) + return root + }).pipe(Effect.orDie) + +const removeProject = (root: string): Effect.Effect => + Effect.ignore(FileSystem.FileSystem.pipe(Effect.flatMap((fs) => fs.remove(root, { recursive: true })))) + +const inProject = ( + files: ReadonlyArray, + use: (root: string) => Effect.Effect, +): Effect.Effect => + Effect.acquireUseRelease(writeProject(files), use, removeProject) + +const nodeResolutions = (input: ManifestEntries): Effect.Effect => + inProject([...projectFilesOf(input), probeFile], resolveWithNode).pipe(Effect.provide(NodeServices.layer)) + +const OPEN = 'open' + +const CLOSED_WITHOUT_TARGET = 'closed without a target file' + +interface ClosureOutcomes { + readonly imports: string + readonly exports: string +} + +const NO_CLOSURE = 'no closure for the test file' + +const outcomeOf = (analysis: ImportClosure.ImportClosureAnalysis, testFile: string, targetDirectory: string): string => + Option.match(Arr.findFirst(analysis.closures, (closure) => closure.testFile === testFile), { + onNone: () => NO_CLOSURE, + onSome: (closure) => + closure.open + ? OPEN + : Option.getOrElse( + Arr.findFirst(TARGET_FILES, (file) => closure.files.includes(`${targetDirectory}${file}`)), + () => CLOSED_WITHOUT_TARGET, + ), + }) + +const closureOutcomes = (input: ManifestEntries): Effect.Effect => { + const files = projectFilesOf(input) + return inProject(files, (root) => + Effect.gen(function*() { + const path = yield* Path.Path + const analysis = yield* ImportClosure.analyzeImportClosure({ + rootDir: root, + projectFiles: files.map(([relative]) => relative), + testFiles: [path.join(root, IMPORTS_TEST), path.join(root, EXPORTS_TEST)], + }) + return { + imports: outcomeOf(analysis, IMPORTS_TEST, APP_DIRECTORY), + exports: outcomeOf(analysis, EXPORTS_TEST, LIB_DIRECTORY), + } + }).pipe(Effect.orDie)).pipe(Effect.provide(Engine.nodePlatformLayer)) +} + +const fileEsmAndCommonJsImportersAgreeOn = ({ esm, cjs }: NodeResolution): string => + esm === null ? (cjs ?? OPEN) : cjs === null || cjs === esm ? esm : OPEN + +const closuresFollowNode = (node: NodeResolutions, closure: ClosureOutcomes): boolean => + fileEsmAndCommonJsImportersAgreeOn(node.imports) === closure.imports && + fileEsmAndCommonJsImportersAgreeOn(node.exports) === closure.exports + +Differential.compare({ + name: 'import closures contain the file Node loads for an exports or imports entry, or stay open', + reference: nodeResolutions, + candidate: closureOutcomes, +}) + .on(manifestEntries, { + runBudget: 100, + hostBound: { + timeout: 240_000, + reason: 'each side writes a package to a temp directory; the reference spawns node to resolve it', + }, + }) + .assert(closuresFollowNode) diff --git a/packages/stryker-js/tests/workspace-package-exports-reuse.integration.test.ts b/packages/stryker-js/tests/workspace-package-exports-reuse.integration.test.ts new file mode 100644 index 000000000..bb51eb719 --- /dev/null +++ b/packages/stryker-js/tests/workspace-package-exports-reuse.integration.test.ts @@ -0,0 +1,385 @@ +import { NodeServices } from '@effect/platform-node' +import { Gherkin, Given, it, makeFeature, Then, When } from '@systemfsoftware/effect-gherkin-spec' +import { RunEvent } from '@systemfsoftware/stryker-js-cli-contract' +import * as Arr from 'effect/Array' +import * as Effect from 'effect/Effect' +import * as Fiber from 'effect/Fiber' +import * as FileSystem from 'effect/FileSystem' +import * as Option from 'effect/Option' +import * as Path from 'effect/Path' +import * as ChildProcess from 'effect/process/ChildProcess' +import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner' +import * as S from 'effect/Schema' +import * as Scope from 'effect/Scope' +import * as Stream from 'effect/Stream' + +const Feature = makeFeature({ it }) + +const STRYKER_BIN = decodeURIComponent(new URL('../dist/main.mjs', import.meta.url).pathname) + +const PACKAGE_ROOT = decodeURIComponent(new URL('..', import.meta.url).pathname).replace(/\/$/, '') + +const CLOSURE_ONLY_RUNNER_PLUGIN = new URL('./__fixtures__/closure-only-runner/index.mjs', import.meta.url).href + +const MODULE_REPORTING_RUNNER = "testRunner: 'vm',\n coverageAnalysis: 'perTest'," + +const CLOSURE_ONLY_RUNNER = `testRunner: { plugin: ${ + JSON.stringify(CLOSURE_ONLY_RUNNER_PLUGIN) +} },\n coverageAnalysis: 'off',` + +const configOf = (runner: string): string => + `export default { + ${runner} + testFiles: ['test/**/*.test.js'], + mutate: ['src/**/*.js'], + incremental: true, + incrementalFile: 'reports/stryker-incremental.json', + checkers: [], + concurrency: 1, + reporters: [], + cleanTempDir: 'always', +} +` + +const SOURCE = 'export const add = (left, right) => left + right\n' + +const TEST = [ + "import { expect, test } from 'vitest'", + "import { double } from '@fixture/lib'", + "import { add } from '../src/math.js'", + '', + "test('adds a doubled number', () => {", + ' expect(add(1, double(2))).toBe(5)', + '})', + '', +].join('\n') + +const UNRELATED_FILE = 'tools/unrelated.js' + +interface RunOutcome { + readonly exitCode: number + readonly reuse: Option.Option +} + +interface RunPair { + readonly first: RunOutcome + readonly second: RunOutcome +} + +interface ReuseCounts { + readonly reused: number + readonly ran: number + readonly refusedFor: ReadonlyArray +} + +type Services = FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + +const decodeLine = S.decodeOption(RunEvent.RunEventWireLine) + +const reuseOf = (stdout: string): Option.Option => + Arr.findFirst( + stdout.split('\n').flatMap((line) => Option.toArray(decodeLine(line))), + (event): event is RunEvent.ReuseReported => S.is(RunEvent.ReuseReported)(event), + ) + +const spawnRun = (root: string): Effect.Effect => + Effect.scoped( + Effect.gen(function*() { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const handle = yield* spawner.spawn( + ChildProcess.make(globalThis.process.execPath, [STRYKER_BIN, 'run'], { + cwd: root, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + env: { STRYKER_MODE: 'machine', NO_COLOR: '1', GITHUB_ACTIONS: '', ALLOW_LOCAL_MUTATION: '1' }, + extendEnv: true, + }), + ) + const stdout = yield* Effect.forkScoped(handle.stdout.pipe(Stream.decodeText, Stream.mkString)) + yield* Effect.forkScoped(handle.stderr.pipe(Stream.runDrain)) + const exitCode = yield* handle.exitCode + return { exitCode: Number(exitCode), reuse: reuseOf(yield* Fiber.join(stdout)) } + }), + ).pipe(Effect.orDie) + +const countsOf = (reuse: Option.Option): ReuseCounts => + Option.match(reuse, { + onNone: () => ({ reused: -1, ran: -1, refusedFor: ['no reuse event'] }), + onSome: (event) => ({ + reused: event.reused, + ran: event.ran, + refusedFor: Object.entries(event.refused).filter(([, count]) => count !== 0).map(([reason]) => reason), + }), + }) + +const encodeManifest = S.encodeEffect(S.fromJsonString(S.Json)) + +const writeFile = (root: string, relative: string, content: string): Effect.Effect => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const target = path.join(root, relative) + yield* fs.makeDirectory(path.dirname(target), { recursive: true }) + yield* fs.writeFileString(target, content) + }).pipe(Effect.orDie) + +const linkInstalledPackages = (root: string): Effect.Effect => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + yield* fs.makeDirectory(path.join(root, 'node_modules', '@fixture'), { recursive: true }) + const installed = yield* fs.readDirectory(path.join(PACKAGE_ROOT, 'node_modules')) + yield* Effect.forEach( + installed, + (entry) => fs.symlink(path.join(PACKAGE_ROOT, 'node_modules', entry), path.join(root, 'node_modules', entry)), + { discard: true, concurrency: 'unbounded' }, + ) + yield* fs.symlink(path.join(root, 'lib'), path.join(root, 'node_modules', '@fixture', 'lib')) + }).pipe(Effect.orDie) + +interface ProjectLayout { + readonly test: string + readonly libFiles: ReadonlyArray +} + +const ESM_IMPORTER: ProjectLayout = { test: TEST, libFiles: [] } + +const COMMONJS_TEST = [ + "import { createRequire } from 'node:module'", + "import { expect, test } from 'vitest'", + "import { add } from '../src/math.js'", + '', + 'const require = createRequire(import.meta.url)', + "const { double } = require('@fixture/lib')", + '', + "test('adds a doubled number', () => {", + ' expect(add(1, double(2))).toBe(5)', + '})', + '', +].join('\n') + +const COMMONJS_IMPORTER: ProjectLayout = { + test: COMMONJS_TEST, + libFiles: [['lib/dist/index.cjs', 'exports.double = (value) => value * 2\n']], +} + +const makeProject = ( + exports: S.Json, + runner = MODULE_REPORTING_RUNNER, + layout = ESM_IMPORTER, +): Effect.Effect => + Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: 'stryker-exports-reuse-' })) + const libManifest = yield* encodeManifest({ name: '@fixture/lib', type: 'module', exports }) + yield* Effect.forEach( + [ + ['package.json', '{ "name": "exports-reuse-app", "type": "module", "private": true }\n'], + ['stryker.config.mjs', configOf(runner)], + ['src/math.js', SOURCE], + ['test/math.test.js', layout.test], + [UNRELATED_FILE, 'export const unrelated = 1\n'], + ['lib/package.json', libManifest], + ['lib/dist/index.js', 'export const double = (value) => value * 2\n'], + ['lib/dist/index.d.ts', 'export declare const double: (value: number) => number\n'], + ...layout.libFiles, + ] as const, + ([relative, content]) => writeFile(root, relative, content), + { discard: true }, + ) + yield* linkInstalledPackages(root) + return root + }).pipe(Effect.orDie) + +interface Edit { + readonly file: string + readonly content: string +} + +const UNRELATED_EDIT: Edit = { file: UNRELATED_FILE, content: 'export const unrelated = 2\n' } + +const RUNTIME_EDIT: Edit = { file: 'lib/dist/index.js', content: 'export const double = (value) => value + value\n' } + +const TYPES_FIRST_EXPORTS: S.Json = { '.': { import: { types: './dist/index.d.ts', default: './dist/index.js' } } } + +const FLAT_TYPES_FIRST_EXPORTS: S.Json = { '.': { types: './dist/index.d.ts', default: './dist/index.js' } } + +const BARE_CONDITIONS_EXPORTS: S.Json = { types: './dist/index.d.ts', import: './dist/index.js' } + +const DUAL_EXPORTS: S.Json = { + '.': { types: './dist/index.d.ts', import: './dist/index.js', require: './dist/index.cjs' }, +} + +const REQUIRE_EDIT: Edit = { file: 'lib/dist/index.cjs', content: 'exports.double = (value) => value + value\n' } + +const runTwiceAround = (root: string, edit: Edit): Effect.Effect => + Effect.gen(function*() { + const first = yield* spawnRun(root) + yield* writeFile(root, edit.file, edit.content) + const second = yield* spawnRun(root) + return { first, second } + }) + +Feature('Reusing verdicts for tests that import a linked workspace package', { timeout: 600_000 }) + .withLayer(NodeServices.layer) + .live('the built stryker binary runs a project twice around an edit') + .body(({ scenario, scenarioOutline }) => { + scenarioOutline( + 'A linked package whose exports use keeps every verdict on across an edit no test reaches', + [ + { + runner: 'a runner that reports the modules each test loaded', + config: MODULE_REPORTING_RUNNER, + shape: 'a flat string root', + exports: { '.': './dist/index.js' }, + }, + { + runner: 'a runner that reports the modules each test loaded', + config: MODULE_REPORTING_RUNNER, + shape: 'nested conditions with types first', + exports: TYPES_FIRST_EXPORTS, + }, + { + runner: 'a runner that reports the modules each test loaded', + config: MODULE_REPORTING_RUNNER, + shape: 'an array fallback', + exports: { '.': ['./dist/index.js'] }, + }, + { + runner: 'a runner that reports the modules each test loaded', + config: MODULE_REPORTING_RUNNER, + shape: 'a top-level array', + exports: ['./dist/index.js'], + }, + { + runner: 'a runner that reports the modules each test loaded', + config: MODULE_REPORTING_RUNNER, + shape: 'a null subpath beside the root', + exports: { '.': './dist/index.js', './internal/*': null }, + }, + { + runner: 'a runner that reports no loaded modules', + config: CLOSURE_ONLY_RUNNER, + shape: 'flat conditions with types first', + exports: FLAT_TYPES_FIRST_EXPORTS, + }, + { + runner: 'a runner that reports no loaded modules', + config: CLOSURE_ONLY_RUNNER, + shape: 'root conditions without a "." key', + exports: BARE_CONDITIONS_EXPORTS, + }, + ] as const, + (row) => + Gherkin.Do.pipe( + Given(`a project whose test imports a linked package with ${row.shape} in its exports`)( + 'root', + () => makeProject(row.exports, row.config), + ), + When('the binary runs the project, a file no test imports changes, and it runs again')( + 'runs', + (s) => runTwiceAround(s.root, UNRELATED_EDIT), + ), + Then('both runs exit 0 and the second reuses every verdict the first one tested')((s, expect) => { + const firstRan = countsOf(s.runs.first.reuse).ran + return expect({ + firstExitCode: s.runs.first.exitCode, + firstTestedMutants: firstRan > 0, + secondExitCode: s.runs.second.exitCode, + second: countsOf(s.runs.second.reuse), + }).toEqual({ + firstExitCode: 0, + firstTestedMutants: true, + secondExitCode: 0, + second: { reused: firstRan, ran: 0, refusedFor: [] }, + }) + }), + ), + ) + + scenarioOutline( + 'A linked package whose types come first still re-tests its importers on when only its runtime file changes', + [ + { + runner: 'a runner that reports the modules each test loaded', + config: MODULE_REPORTING_RUNNER, + shape: 'nested conditions', + exports: TYPES_FIRST_EXPORTS, + }, + { + runner: 'a runner that reports no loaded modules', + config: CLOSURE_ONLY_RUNNER, + shape: 'nested conditions', + exports: TYPES_FIRST_EXPORTS, + }, + { + runner: 'a runner that reports no loaded modules', + config: CLOSURE_ONLY_RUNNER, + shape: 'flat conditions', + exports: FLAT_TYPES_FIRST_EXPORTS, + }, + { + runner: 'a runner that reports no loaded modules', + config: CLOSURE_ONLY_RUNNER, + shape: 'root conditions without a "." key', + exports: BARE_CONDITIONS_EXPORTS, + }, + ] as const, + (row) => + Gherkin.Do.pipe( + Given( + `a project whose test imports a linked package that lists its types before its runtime file in ${row.shape}`, + )( + 'root', + () => makeProject(row.exports, row.config), + ), + When('the binary runs the project, only the package runtime file changes, and it runs again')( + 'runs', + (s) => runTwiceAround(s.root, RUNTIME_EDIT), + ), + Then('both runs exit 0 and the second tests again every mutant the first one tested')((s, expect) => { + const firstRan = countsOf(s.runs.first.reuse).ran + return expect({ + firstExitCode: s.runs.first.exitCode, + firstTestedMutants: firstRan > 0, + secondExitCode: s.runs.second.exitCode, + second: countsOf(s.runs.second.reuse), + }).toEqual({ + firstExitCode: 0, + firstTestedMutants: true, + secondExitCode: 0, + second: { reused: 0, ran: firstRan, refusedFor: ['closureChanged'] }, + }) + }), + ), + ) + + scenario( + 'A linked package with import and require branches still re-tests its CommonJS importer on a runner that reports no loaded modules when only its require file changes', + Gherkin.Do.pipe( + Given('a project whose test requires a linked package that lists an import branch before a require branch')( + 'root', + () => makeProject(DUAL_EXPORTS, CLOSURE_ONLY_RUNNER, COMMONJS_IMPORTER), + ), + When('the binary runs the project, only the package require file changes, and it runs again')( + 'runs', + (s) => runTwiceAround(s.root, REQUIRE_EDIT), + ), + Then('both runs exit 0 and the second tests again every mutant the first one tested')((s, expect) => { + const firstRan = countsOf(s.runs.first.reuse).ran + return expect({ + firstExitCode: s.runs.first.exitCode, + firstTestedMutants: firstRan > 0, + secondExitCode: s.runs.second.exitCode, + second: countsOf(s.runs.second.reuse), + }).toEqual({ + firstExitCode: 0, + firstTestedMutants: true, + secondExitCode: 0, + second: { reused: 0, ran: firstRan, refusedFor: ['closureChanged'] }, + }) + }), + ), + ) + })