@avi2dg/checks 0.50.1 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.51.0
6
+
7
+ Released 2026-10-23.
8
+
9
+ ### Features
10
+
11
+ - **testing:** reuse full baseline results for unchanged files [#197](https://github.com/avi2d/checks/pull/197)
12
+
13
+ ### Fixes
14
+
15
+ - **delivery:** accept GitHub display name on commits GitHub made [#198](https://github.com/avi2d/checks/pull/198)
16
+ - **testing:** correct unit test fallback order wording to Bun default directory order [#195](https://github.com/avi2d/checks/pull/195)
17
+
5
18
  ## 0.50.1
6
19
 
7
20
  Released 2026-10-20.
@@ -150,7 +150,7 @@ var timedFiles = Effect2.fn("timedFiles")(function* (listed) {
150
150
  return fastestFirst(yield* fileSeconds(yield* fs.readFileString(outfile)));
151
151
  }, Effect2.scoped);
152
152
  var NodeServices = NodeChildProcessSpawner.layer.pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)));
153
- var runOrder = (listed) => Effect2.runPromise(timedFiles(listed).pipe(Effect2.map((timed) => inRunOrder(listed, timed)), Effect2.catch((failure) => Console.error(`checks: ${failure.message}, so each static mutant runs the unit tests in path order`).pipe(Effect2.as(listed))), Effect2.provide(NodeServices)));
153
+ var runOrder = (listed) => Effect2.runPromise(timedFiles(listed).pipe(Effect2.map((timed) => inRunOrder(listed, timed)), Effect2.catch((failure) => Console.error(`checks: ${failure.message}, so each static mutant runs the unit tests in Bun's default directory order`).pipe(Effect2.as(listed))), Effect2.provide(NodeServices)));
154
154
  export {
155
155
  fastestFirst,
156
156
  inRunOrder,
@@ -11,6 +11,9 @@ audience: consumers
11
11
  The author and committer of each commit must be allowed.
12
12
  A commit whose trailer block carries a `Co-authored-by` trailer, as git parses it, is refused.
13
13
  `GitHub <noreply@github.com>` is allowed as committer only.
14
+ A commit that GitHub committed allows any author name with an allowed email.
15
+ GitHub authors a web merge under the account's display name.
16
+ The same author on a commit with any other committer must match the allowed name too.
14
17
  `github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>` is allowed as author only of a commit whose subject is `chore: release <version>`, with or without the pull request number a squash merge adds.
15
18
  GitHub attributes the release commit [checks-release-pr](checks-release-pr.md) makes to that bot, since it commits through the workflow token.
16
19
  Such a commit may carry a `Co-authored-by` trailer naming that bot, since GitHub adds one to some squash merges of a pull request the bot opened, and every other trailer is refused.
@@ -22,6 +22,13 @@ With `--full` it lists up to 50 `schedule` runs and up to 50 `workflow_dispatch`
22
22
  It tries those runs newest first, and restores the first `mutation-baseline-full` artifact that has not expired, downloads and holds the report.
23
23
  It tries no run after that one.
24
24
  It takes the report from `mutation.json` at the artifact's root, or else from `mutation/mutation.json`, and copies it to the destination.
25
+ With an incremental destination it also copies `stryker-incremental.json` when the artifact holds one, so the next full run retests the mutants its changes reach, while inputs a test reads at runtime stay untracked.
26
+ The restore then drops each test that reads a changed file and each mutant result for a changed source from the copied state.
27
+ It drops non-killed results when any test goes stale.
28
+ It drops the whole state when a Stryker config, stryker.preset.js, the lockfile, the Bun version, package.json apart from its version, a patch its patchedDependencies names, a file the active config loads at either revision, or the bunfig preload set or a file it loads changed.
29
+ It walks the config and preload dependencies at both revisions, so a file only the older revision resolved still drops the state when it changes.
30
+ A state that does not parse fails the restore instead of running on unknown results.
31
+ Without the state file the report still restores.
25
32
 
26
33
  It creates the directories each destination needs.
27
34
 
@@ -41,9 +48,14 @@ It deletes through `gh`, which needs a token that deletes the repository's Actio
41
48
 
42
49
  It reads the runs and their artifacts through `gh`, which needs a token that reads the repository's Actions, such as `GH_TOKEN: ${{ github.token }}` with `actions: read`.
43
50
  The `mutation-baseline` artifact holds `stryker-incremental.json` and `mutation/mutation.json`, which an upload of `reports/stryker-incremental.json` and `reports/mutation/mutation.json` writes.
44
- The `mutation-baseline-full` artifact holds `mutation.json` at its root, which an upload of `reports/mutation/mutation.json` alone writes.
51
+ The `mutation-baseline-full` artifact holds `mutation.json` and `stryker-incremental.json` at its root, which an upload of `${{ runner.temp }}/full/` writes.
45
52
  It reads `RUNNER_ENVIRONMENT` and `HOME` to place the runner cache, and `GITHUB_REPOSITORY_ID` to find the saved baselines.
46
53
  `--save` and `--prune` read `GITHUB_RUN_ID`.
54
+ With `--full` and an incremental destination it reads the run's head through `gh run view <run> --json headSha`.
55
+ It diffs that head against `HEAD`, which needs the checkout's full history.
56
+ It shows `bunfig.toml`, `package.json`, every tracked `package.json`, and the files the active Stryker config imports at both revisions.
57
+ It reads every tracked `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.jsx`, `.mjs`, and `.cjs` file from the working tree for their relative imports.
58
+ Inputs a test reads at runtime without a static import, such as markdown files, escape invalidation as a known limitation.
47
59
 
48
60
  ## The runner cache
49
61
 
@@ -91,13 +103,13 @@ It also refuses when `HOME`, `GITHUB_REPOSITORY_ID` or `GITHUB_RUN_ID` is unset.
91
103
 
92
104
  ```sh
93
105
  checks-mutation-baseline <incremental-dest> [mutation-json-dest]
94
- checks-mutation-baseline --full <mutation-json-dest>
106
+ checks-mutation-baseline --full <mutation-json-dest> [incremental-dest]
95
107
  checks-mutation-baseline --save <incremental-src> [mutation-json-src]
96
108
  checks-mutation-baseline --slim <mutation-json-src> <dest>
97
109
  checks-mutation-baseline --prune [--dry-run]
98
110
  ```
99
111
 
100
- `--full` restores only the report of a run that started with no state.
112
+ `--full` restores the report of a full run, and its incremental state when given an incremental destination.
101
113
  `--save` keeps this run's baseline on the runner's disk in place of an upload.
102
114
  `--slim` writes a report without `statusReason` to `<dest>`, for a report that still goes up as an artifact.
103
115
  `--prune` deletes superseded baselines after a save or an upload.
@@ -108,7 +120,7 @@ checks-mutation-baseline --prune [--dry-run]
108
120
  | Code | When |
109
121
  | --- | --- |
110
122
  | 0 | it restored a baseline, found none to restore, saved the baseline or wrote the slim report |
111
- | 2 | the arguments do not parse, `gh` cannot list the runs, the cache cannot be written, another job removed the entry before it was copied, a file to save or slim does not parse, or `--save` runs where it refuses |
123
+ | 2 | the arguments do not parse, `gh` cannot list the runs, the cache cannot be written, another job removed the entry before it was copied, a file to save or slim does not parse, `--save` runs where it refuses, or `--full` with an incremental destination cannot read the run's head, diff it, or parse the restored state |
112
124
 
113
125
  A failed artifact lookup or download prints the `gh` error and moves on, as a missing artifact does.
114
126
  With `--prune`, `0` means it deleted the superseded artifacts, found none, or could not delete some.
@@ -166,15 +178,28 @@ The saved baseline uses none of the account's Actions storage, which every priva
166
178
  That storage accrues by the hour over the month, so an artifact deleted early still counts until the month ends.
167
179
  A baseline job on a GitHub-hosted runner uploads the same two files as a `mutation-baseline` artifact with `retention-days: 7` instead, since the restore reads only the newest `main` run.
168
180
 
169
- A full run uploads its report as the `mutation-baseline-full` artifact, which a branch comparison's scope step reads on any runner.
170
- It slims the report first, so the artifact holds about 0.5 MB in place of about 100 MB:
181
+ A full baseline job restores the previous full state before its incremental Stryker run, so it retests the mutants its changes reach, while inputs a test reads at runtime stay untracked.
182
+ The checkout behind that step fetches the full history, so the restore can diff the restored run against `HEAD`:
183
+
184
+ ```yaml
185
+ - name: Restore previous full baseline state
186
+ env:
187
+ GH_TOKEN: ${{ github.token }}
188
+ run: bunx checks-mutation-baseline --full reports/mutation/mutation.json reports/stryker-incremental.json
189
+ ```
190
+
191
+ The restore reads the repository's Actions through `gh`, so the job that holds it grants `contents: read` and `actions: read`.
192
+
193
+ A full run uploads its slimmed report and its slimmed state as the `mutation-baseline-full` artifact, which a branch comparison's scope step reads on any runner.
194
+ It slims the report and the state first, so the one directory holds about 10 MB in place of about 500 MB:
171
195
 
172
196
  ```yaml
173
197
  - run: bunx checks-mutation-baseline --slim reports/mutation/mutation.json "$RUNNER_TEMP/full/mutation.json"
198
+ - run: bunx checks-mutation-baseline --slim reports/stryker-incremental.json "$RUNNER_TEMP/full/stryker-incremental.json"
174
199
  - uses: actions/upload-artifact@v4
175
200
  with:
176
201
  name: mutation-baseline-full
177
- path: ${{ runner.temp }}/full/mutation.json
202
+ path: ${{ runner.temp }}/full/
178
203
  retention-days: 30
179
204
  ```
180
205
 
@@ -53,7 +53,7 @@ So a helper, fixture or preload changed on `main` since that baseline can hide a
53
53
  - The full baseline report that `checks-mutation-baseline --full` restores, for each mutant's `coveredBy` and `killedBy` and the `testFiles` that own those tests.
54
54
 
55
55
  Stryker leaves `coveredBy` empty on a static mutant, so only `killedBy` names its tests.
56
- The report must come from a run that restored no state, so each mutant's tests are the ones it ran against.
56
+ A reused result needs its affected tests, sources, and configuration invalidated before reuse, so each mutant's tests are the ones the run recorded.
57
57
  With bail, Stryker's default, `killedBy` names the one test that killed the mutant first.
58
58
  One killer is enough, since a branch loses a kill through its tests only when it changes every test that made the kill.
59
59
  A test left alone, whose sources the branch also leaves alone, still kills the mutant at `HEAD`.
@@ -30,7 +30,7 @@ So when a `stryker run` without `--ignoreStatic` loads the preset, the preset fi
30
30
  `unitTestFiles` then lists each file with a leading `./`, fastest first, and Bun runs such paths in the order given.
31
31
  It then lists each path again without the `./`, the spelling the runner matches a covering test against.
32
32
  Bun loads a file once however often the list names it.
33
- When that timing run fails, the preset prints why on stderr and `unitTestFiles` keeps the path order.
33
+ When that timing run fails, the preset prints why on stderr and `unitTestFiles` keeps Bun's default directory order.
34
34
  The config then reads:
35
35
 
36
36
  ```js
@@ -82,7 +82,7 @@ An `--incremental` run with no report stops before instrumenting with the missin
82
82
  A repository runs full baselines from the mutation workflow on `workflow_dispatch`.
83
83
  A repository that picks a branch scope from a baseline starts that workflow weekly with `gh workflow run mutation`, because a GitHub schedule can start hours late.
84
84
  Run scoped checks locally during development.
85
- Each baseline costs a full Stryker run.
85
+ A first baseline with no state costs a full Stryker run, and each later one retests the mutants its changes reach, while inputs a test reads at runtime stay untracked.
86
86
 
87
87
  ## Running it in CI
88
88
 
@@ -94,29 +94,42 @@ on:
94
94
  workflow_dispatch:
95
95
  jobs:
96
96
  mutation:
97
+ permissions:
98
+ contents: read
99
+ actions: read
97
100
  runs-on: [self-hosted, Linux, X64, winbox]
98
101
  steps:
99
102
  - uses: actions/checkout@v5
103
+ with:
104
+ fetch-depth: 0
100
105
  - uses: oven-sh/setup-bun@v2
101
106
  with:
102
107
  bun-version-file: .bun-version
103
108
  - run: bun install --frozen-lockfile
104
- - run: bunx stryker run
109
+ - env:
110
+ GH_TOKEN: ${{ github.token }}
111
+ run: bunx checks-mutation-baseline --full reports/mutation/mutation.json reports/stryker-incremental.json
112
+ - run: bunx stryker run --incremental
105
113
  - run: bunx checks-mutation-baseline --slim reports/mutation/mutation.json "$RUNNER_TEMP/full/mutation.json"
114
+ - run: bunx checks-mutation-baseline --slim reports/stryker-incremental.json "$RUNNER_TEMP/full/stryker-incremental.json"
106
115
  - uses: actions/upload-artifact@v4
107
116
  with:
108
117
  name: mutation-baseline-full
109
- path: ${{ runner.temp }}/full/mutation.json
118
+ path: ${{ runner.temp }}/full/
110
119
  retention-days: 30
111
120
  ```
112
121
 
113
- The run restores no state, so its report names every test that covers each covered mutant and the first test that killed each killed mutant.
122
+ A first baseline with no state tests every mutant.
123
+ Each later restore drops the tests that read a changed file and the mutant results for a changed source from the restored state, and drops non-killed results when any test goes stale.
124
+ It drops the whole state when a Stryker config, stryker.preset.js, the lockfile, the Bun version, package.json apart from its version, a patch its patchedDependencies names, a file the active config loads at either revision, or the bunfig preload set or a file it loads changed, and fails the restore when the state does not parse, so the run retests every mutant those changes reach and reuses the rest, while inputs a test reads at runtime stay untracked.
125
+ The checkout fetches the full history, so the restore can tell which files changed since the previous full run.
126
+ Its report names every test that covers each covered mutant and the first test that killed each killed mutant.
114
127
  [checks-mutation-scope](checks-mutation-scope.md#what-it-reads) says why that one killer is enough.
115
128
  The upload is the `mutation-baseline-full` artifact that a branch comparison's scope step restores with `checks-mutation-baseline --full`.
116
129
  [checks-mutation-scope](checks-mutation-scope.md) reads it to bring in the sources a changed unit test covers or kills.
117
130
  The restore reads only a successful run of `mutation.yml` on `main`, so a sweep that fails publishes nothing a scope step reads.
118
131
  The artifact keeps 30 days, so the newest full baseline outlives the week between full runs.
119
- The upload holds the report without each mutant's `statusReason`, as [checks-mutation-baseline](checks-mutation-baseline.md#the-saved-baselines) says, since the full report of a large repository passes 100 MB as an artifact.
132
+ The upload holds the slimmed report and the slimmed state without each mutant's `statusReason`, as [checks-mutation-baseline](checks-mutation-baseline.md#the-saved-baselines) says, so the one directory holds about 10 MB in place of about 500 MB.
120
133
 
121
134
  The job runs on the fleet's self-hosted Linux runner labelled `winbox`, which is where a repository sends its full sweeps.
122
135
  It never reads `CI_RUNS_ON`, so an override that moves a repository's other jobs during a runner outage leaves the sweep waiting for `winbox`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.50.1",
3
+ "version": "0.51.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,7 +48,9 @@
48
48
  "src/testing/mutation-refusal.ts",
49
49
  "src/testing/mutation-compare.ts",
50
50
  "src/testing/mutation-scope.ts",
51
+ "src/testing/mutation-scope-graph.ts",
51
52
  "src/testing/mutation-baseline.ts",
53
+ "src/testing/mutation-baseline-full.ts",
52
54
  "src/testing/mutation-baseline-saved.ts",
53
55
  "src/testing/subsumed-tests.ts",
54
56
  "src/testing/night-run.ts",
@@ -123,11 +123,17 @@ function allows(allowed: readonly Identity[], identity: Identity): boolean {
123
123
  );
124
124
  }
125
125
 
126
+ // GitHub authors a web merge under the account's display name, which the owner's git name need not match.
127
+ function allowsEmail(allowed: readonly Identity[], identity: Identity): boolean {
128
+ return allowed.some((entry) => entry.email.toLowerCase() === identity.email.toLowerCase());
129
+ }
130
+
126
131
  function inspect(commit: Commit, allowed: readonly Identity[]): Offence | undefined {
127
132
  const reasons: string[] = [];
128
133
  const release = releasedVersionOf(commit.subject) !== undefined;
129
134
  const authors = release ? [...allowed, RELEASE_AUTHOR] : allowed;
130
- if (!allows(authors, commit.author)) {
135
+ const madeByGitHub = allows([SQUASH_COMMITTER], commit.committer);
136
+ if (!(madeByGitHub ? allowsEmail(authors, commit.author) : allows(authors, commit.author))) {
131
137
  reasons.push(`author ${render(commit.author)}`);
132
138
  }
133
139
  if (!allows([...allowed, SQUASH_COMMITTER], commit.committer)) {
@@ -158,7 +164,7 @@ const check = Effect.gen(function* () {
158
164
  for (const reason of reasons) lines.push(` ${reason}`);
159
165
  }
160
166
  lines.push(` allowed: ${allowed.map(render).join(", ")}`);
161
- lines.push(` allowed as committer only: ${render(SQUASH_COMMITTER)}`);
167
+ lines.push(` allowed as committer only: ${render(SQUASH_COMMITTER)}, which also lets an allowed email author under any name`);
162
168
  lines.push(` allowed as release author only: ${render(RELEASE_AUTHOR)}`);
163
169
  yield* Console.error(lines.join("\n"));
164
170
  return false;
@@ -0,0 +1,92 @@
1
+ import { Console, Effect, FileSystem, Schema } from "effect";
2
+ import { ChildProcessSpawner } from "effect/process";
3
+ import { collect } from "../core/git.ts";
4
+ import { isRecord, withoutStale } from "./mutation-baseline-saved.ts";
5
+ import { STRYKER_CONFIGS, stalenessSince } from "./mutation-scope.ts";
6
+
7
+ export class BaselineFailure extends Schema.TaggedError<BaselineFailure>()("BaselineFailure", {
8
+ message: Schema.String,
9
+ }) {}
10
+
11
+ export const gh = Effect.fn("gh")(function* (args: readonly string[]) {
12
+ const failed = (reason: string): BaselineFailure => new BaselineFailure({ message: `gh ${args.join(" ")}: ${reason.trim()}` });
13
+ const { stdout, stderr, exitCode } = yield* collect("gh", args).pipe(Effect.mapError((cause) => failed(cause.message)));
14
+ if (exitCode !== ChildProcessSpawner.ExitCode(0)) return yield* failed(stderr);
15
+ return stdout;
16
+ });
17
+
18
+ const FULL_CONFIGS = [...STRYKER_CONFIGS, "stryker.preset.js", "bun.lock", ".bun-version"];
19
+
20
+ const parsedJson = (text: string) =>
21
+ Effect.try({
22
+ try: (): unknown => JSON.parse(text),
23
+ catch: () => new BaselineFailure({ message: "package.json is not JSON" }),
24
+ });
25
+
26
+ const withoutVersion = (text: string) =>
27
+ parsedJson(text).pipe(
28
+ Effect.map((parsed) => {
29
+ if (!isRecord(parsed)) return undefined;
30
+ return JSON.stringify(Object.fromEntries(Object.entries(parsed).filter(([key]) => key !== "version")));
31
+ }),
32
+ Effect.catchTag("BaselineFailure", () => Effect.succeed(undefined)),
33
+ );
34
+
35
+ const patchedPaths = (text: string | undefined): Effect.Effect<readonly string[], BaselineFailure> => {
36
+ if (text === undefined) return Effect.succeed([]);
37
+ return parsedJson(text).pipe(
38
+ Effect.map((parsed) => {
39
+ if (!isRecord(parsed)) return [];
40
+ const patched = parsed["patchedDependencies"];
41
+ if (!isRecord(patched)) return [];
42
+ return Object.values(patched).flatMap((value) => (typeof value === "string" ? [value.replace(/^\.\//, "")] : []));
43
+ }),
44
+ Effect.catchTag("BaselineFailure", (): Effect.Effect<readonly string[]> => Effect.succeed([])),
45
+ );
46
+ };
47
+
48
+ const shownAt = (rev: string, path: string) =>
49
+ collect("git", ["show", `${rev}:${path}`]).pipe(
50
+ Effect.map(({ stdout, exitCode }) => (exitCode === ChildProcessSpawner.ExitCode(0) ? stdout : undefined)),
51
+ Effect.mapError((cause) => new BaselineFailure({ message: cause.message })),
52
+ Effect.catchTag("BaselineFailure", () => Effect.succeed(undefined)),
53
+ );
54
+
55
+ const packageInputs = Effect.fn("packageInputs")(function* (base: string, head: string, paths: ReadonlySet<string>) {
56
+ const inputs = new Set<string>();
57
+ const [before, after] = yield* Effect.all([shownAt(base, "package.json"), shownAt(head, "package.json")]);
58
+ if (paths.has("package.json") && (before === undefined || after === undefined || (yield* withoutVersion(before)) !== (yield* withoutVersion(after))))
59
+ inputs.add("package.json");
60
+ for (const text of [before, after]) for (const path of yield* patchedPaths(text)) if (paths.has(path)) inputs.add(path);
61
+ return inputs;
62
+ });
63
+
64
+ const decodeHeadSha = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ headSha: Schema.String })));
65
+
66
+ const viewedHead = Effect.fn("viewedHead")(function* (run: number) {
67
+ return (yield* gh(["run", "view", String(run), "--json", "headSha"]).pipe(Effect.flatMap(decodeHeadSha))).headSha;
68
+ });
69
+
70
+ export const pruneFullState = Effect.fn("pruneFullState")(function* (run: number, state: string) {
71
+ const fs = yield* FileSystem.FileSystem;
72
+ if (!(yield* fs.exists(state))) return;
73
+ const base = yield* viewedHead(run);
74
+ const { paths, preload, stale, config } = yield* stalenessSince(base, "HEAD");
75
+ const inputs = yield* packageInputs(base, "HEAD", paths);
76
+ const changed = [...paths].filter((path) => FULL_CONFIGS.includes(path) || inputs.has(path) || config.has(path)).toSorted();
77
+ if (changed.length > 0 || preload !== undefined) {
78
+ yield* fs.remove(state);
79
+ yield* Console.log(`mutation-baseline: dropped the restored full state, ${preload ?? changed.join(" ")} changed since run ${run}`);
80
+ return;
81
+ }
82
+ const text = yield* fs.readFileString(state);
83
+ const stateJson: unknown = yield* Effect.try({
84
+ try: (): unknown => JSON.parse(text),
85
+ catch: () => new BaselineFailure({ message: `${state} is not a Stryker incremental state with its test files` }),
86
+ });
87
+ const stripped = withoutStale(stateJson, { tests: new Set(stale), sources: paths });
88
+ if (stripped.tests.length === 0 && stripped.sources.length === 0) return;
89
+ yield* fs.writeFileString(state, JSON.stringify(stripped.state));
90
+ const dropped = [...stripped.tests, ...stripped.sources.map((source) => `${source} results`)].join(" ");
91
+ yield* Console.log(`mutation-baseline: dropped ${dropped} from the restored full state`);
92
+ });
@@ -16,7 +16,7 @@ const SavedFiles = Schema.Struct({ [INCREMENTAL]: Schema.Unknown, [REPORT]: Sche
16
16
  export type SavedFiles = typeof SavedFiles.Type;
17
17
  const decodeSavedFiles = Schema.decodeUnknownEffect(Schema.fromJsonString(SavedFiles));
18
18
 
19
- const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> => typeof value === "object" && value !== null && !Array.isArray(value);
19
+ export const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> => typeof value === "object" && value !== null && !Array.isArray(value);
20
20
 
21
21
  const withoutReason = (mutant: unknown): unknown => (isRecord(mutant) ? Object.fromEntries(Object.entries(mutant).filter(([key]) => key !== "statusReason")) : mutant);
22
22
 
@@ -40,6 +40,58 @@ const slimOf = Effect.fn("slimOf")(function* (from: string) {
40
40
  return withoutReasons(report);
41
41
  });
42
42
 
43
+ export type Invalidation = { readonly tests: ReadonlySet<string>; readonly sources: ReadonlySet<string> };
44
+
45
+ export type Invalidated = { readonly state: unknown; readonly tests: readonly string[]; readonly sources: readonly string[] };
46
+
47
+ const isKilled = (mutant: unknown): boolean => isRecord(mutant) && mutant["status"] === "Killed";
48
+
49
+ const staleTests = (state: Readonly<Record<string, unknown>>, invalidation: Invalidation): readonly string[] => {
50
+ const testFiles = state["testFiles"];
51
+ return isRecord(testFiles) ? Object.keys(testFiles).filter((file) => invalidation.tests.has(file)) : [];
52
+ };
53
+
54
+ const keptEntry = (file: unknown, stale: boolean, dropStatic: boolean): { readonly entry: unknown; readonly dropped: boolean } => {
55
+ if (!isRecord(file) || !Array.isArray(file["mutants"])) return { entry: file, dropped: false };
56
+ const before: readonly unknown[] = file["mutants"];
57
+ const mutants = stale ? [] : dropStatic ? before : before.filter(isKilled);
58
+ return { entry: mutants.length > 0 ? { ...file, mutants } : undefined, dropped: mutants.length < before.length };
59
+ };
60
+
61
+ const prunedFiles = (
62
+ state: Readonly<Record<string, unknown>>,
63
+ invalidation: Invalidation,
64
+ ): { readonly files: Record<string, unknown> | undefined; readonly sources: readonly string[] } => {
65
+ const files = state["files"];
66
+ if (!isRecord(files)) return { files: undefined, sources: [] };
67
+ const next: Record<string, unknown> = {};
68
+ const sources: string[] = [];
69
+ for (const [name, file] of Object.entries(files)) {
70
+ const kept = keptEntry(file, invalidation.sources.has(name), invalidation.tests.size === 0);
71
+ if (kept.entry !== undefined) next[name] = kept.entry;
72
+ if (kept.dropped) sources.push(name);
73
+ }
74
+ return { files: next, sources };
75
+ };
76
+
77
+ export function withoutStale(state: unknown, invalidation: Invalidation): Invalidated {
78
+ if (!isRecord(state)) return { state, tests: [], sources: [] };
79
+ const tests = staleTests(state, invalidation);
80
+ const pruned = prunedFiles(state, invalidation);
81
+ if (tests.length === 0 && pruned.sources.length === 0) return { state, tests: [], sources: [] };
82
+ return {
83
+ state: {
84
+ ...state,
85
+ ...(isRecord(state["testFiles"])
86
+ ? { testFiles: Object.fromEntries(Object.entries(state["testFiles"]).filter(([file]) => !invalidation.tests.has(file))) }
87
+ : {}),
88
+ ...(pruned.files === undefined ? {} : { files: pruned.files }),
89
+ },
90
+ tests: tests.toSorted(),
91
+ sources: pruned.sources.toSorted(),
92
+ };
93
+ }
94
+
43
95
  export const slimmed = Effect.fn("slimmed")(function* (from: string, to: string) {
44
96
  const fs = yield* FileSystem.FileSystem;
45
97
  const report = yield* slimOf(from);
@@ -1,18 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
3
- import { ChildProcessSpawner } from "effect/process";
4
- import { collect } from "../core/git.ts";
5
3
  import { runMain, Usage } from "../core/main.ts";
6
4
  import { cacheRoot } from "../dependencies/cache-root.ts";
5
+ import { BaselineFailure, gh, pruneFullState } from "./mutation-baseline-full.ts";
7
6
  import { INCREMENTAL, LATEST_ARTIFACT, outlived, REPORT, saveBaseline, savedFiles, savedShelf, slimmed } from "./mutation-baseline-saved.ts";
8
7
 
9
- class BaselineFailure extends Schema.TaggedError<BaselineFailure>()("BaselineFailure", {
10
- message: Schema.String,
11
- }) {}
12
-
13
8
  const NAME = "checks-mutation-baseline";
14
9
  const USAGE =
15
- "usage: checks-mutation-baseline <incremental-dest> [mutation-json-dest] | --full <mutation-json-dest> | --save <incremental-src> [mutation-json-src] | --slim <mutation-json-src> <dest> | --prune [--dry-run]";
10
+ "usage: checks-mutation-baseline <incremental-dest> [mutation-json-dest] | --full <mutation-json-dest> [incremental-dest] | --save <incremental-src> [mutation-json-src] | --slim <mutation-json-src> <dest> | --prune [--dry-run]";
16
11
  const WORKFLOW = "mutation.yml";
17
12
  const BRANCH = "main";
18
13
  const REPORT_ALONE = "mutation.json";
@@ -40,13 +35,6 @@ type Artifact = typeof Artifact.Type;
40
35
  const decodeRuns = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Run)));
41
36
  const decodeArtifacts = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ artifacts: Schema.Array(Artifact) })));
42
37
 
43
- const gh = Effect.fn("gh")(function* (args: readonly string[]) {
44
- const failed = (reason: string): BaselineFailure => new BaselineFailure({ message: `gh ${args.join(" ")}: ${reason.trim()}` });
45
- const { stdout, stderr, exitCode } = yield* collect("gh", args).pipe(Effect.mapError((cause) => failed(cause.message)));
46
- if (exitCode !== ChildProcessSpawner.ExitCode(0)) return yield* failed(stderr);
47
- return stdout;
48
- });
49
-
50
38
  const warned = <A>(fallback: A) => (failure: BaselineFailure) => Console.error(`mutation-baseline: ${failure.message}`).pipe(Effect.as(fallback));
51
39
 
52
40
  const listed = Effect.fn("listed")(function* (event: string | undefined, limit: number) {
@@ -334,8 +322,15 @@ const FLAGS = new Map<string, (rest: readonly string[]) => Command | undefined>(
334
322
  ["--slim", ([from, to, ...extra]) => (isDestination(from) && isDestination(to) && extra.length === 0 ? { kind: "slim", from, to } : undefined)],
335
323
  [
336
324
  "--full",
337
- ([report, ...extra]) =>
338
- isDestination(report) && extra.length === 0 ? { kind: "restore", full: true, needed: { from: [REPORT_ALONE, REPORT], to: report }, optional: [] } : undefined,
325
+ ([report, incremental, ...extra]) =>
326
+ isDestination(report) && (incremental === undefined || isDestination(incremental)) && extra.length === 0
327
+ ? {
328
+ kind: "restore",
329
+ full: true,
330
+ needed: { from: [REPORT_ALONE, REPORT], to: report },
331
+ optional: incremental === undefined ? [] : [{ from: [INCREMENTAL], to: incremental }],
332
+ }
333
+ : undefined,
339
334
  ],
340
335
  ]);
341
336
 
@@ -346,14 +341,21 @@ function parsed([first, ...rest]: readonly string[]): Command | undefined {
346
341
  return restoreRequest(first, report, extra);
347
342
  }
348
343
 
344
+ const restoredRun = Effect.fn("restoredRun")(function* (run: number, name: string, root: Option.Option<string>, shelf: Option.Option<string>, request: Request) {
345
+ if (Option.isSome(shelf) && (yield* restoredFromSaved(shelf.value, run, request))) return true;
346
+ const artifact = yield* artifactOf(run, name).pipe(Effect.catch(warned(Option.none<Artifact>())));
347
+ if (Option.isNone(artifact) || !(yield* restoredFrom(run, artifact.value, root, request))) return false;
348
+ const incremental = request.full ? request.optional[0] : undefined;
349
+ if (incremental !== undefined) yield* pruneFullState(run, incremental.to);
350
+ return true;
351
+ });
352
+
349
353
  const restoreBaseline = Effect.fn("restoreBaseline")(function* (request: Request) {
350
354
  const name = request.full ? FULL_ARTIFACT : LATEST_ARTIFACT;
351
355
  const root = yield* runnerCache;
352
356
  const shelf = request.full ? Option.none<string>() : yield* savedShelf(root);
353
357
  for (const run of yield* request.full ? fullCandidates : latestCandidates) {
354
- if (Option.isSome(shelf) && (yield* restoredFromSaved(shelf.value, run, request))) return true;
355
- const artifact = yield* artifactOf(run, name).pipe(Effect.catch(warned(Option.none<Artifact>())));
356
- if (Option.isSome(artifact) && (yield* restoredFrom(run, artifact.value, root, request))) return true;
358
+ if (yield* restoredRun(run, name, root, shelf, request)) return true;
357
359
  }
358
360
  yield* Console.log(`mutation-baseline: no successful ${BRANCH} run holds a ${name} artifact, so nothing was restored`);
359
361
  return true;
@@ -0,0 +1,98 @@
1
+ export const TEST_TREE = "tests/";
2
+ const FIXTURES = "tests/fixtures/";
3
+ const CANDIDATE_EXTENSIONS = [".tsx", ".ts", ".mts", ".cts", ".jsx", ".js", ".mjs", ".cjs", ".json", ".node"];
4
+
5
+ const IMPORT = /(?:from|import)\s*\(?\s*["'](\.[^"']*)["']|require\s*\(\s*["'](\.[^"']*)["']\s*\)/g;
6
+ const LISTED_SEGMENTS = /["'`][ \t]*,[ \t]*["'`]/g;
7
+ const DIRECTORY_ENDS = ['"', "'", "`", '/"', "/'", "/`", "/${"];
8
+
9
+ type Needle = { readonly text: string; readonly kind: "file" | "directory"; readonly fixture: string };
10
+
11
+ export function resolved(directory: string, spec: string): string {
12
+ const parts: string[] = [];
13
+ for (const part of `${directory}/${spec}`.split("/")) {
14
+ if (part === "..") parts.pop();
15
+ else if (part !== "." && part !== "") parts.push(part);
16
+ }
17
+ return parts.join("/");
18
+ }
19
+
20
+ // Bun takes the file a specifier names, then one with an extension added, and only then the directory's index.
21
+ export function importedFile(path: string, files: ReadonlySet<string>, mains: ReadonlyMap<string, string> = new Map()): readonly string[] {
22
+ const probe = (target: string): readonly string[] => {
23
+ const bare = target.replace(/\.m?js$/, "");
24
+ return [target, ...CANDIDATE_EXTENSIONS.map((extension) => bare + extension)].filter((file) => files.has(file));
25
+ };
26
+ const index = (target: string): readonly string[] =>
27
+ CANDIDATE_EXTENSIONS.map((extension) => `${target}/index${extension}`).filter((file) => files.has(file));
28
+ const direct = probe(path);
29
+ if (direct.length > 0) return direct;
30
+ const main = mains.get(path);
31
+ if (main === undefined) return index(path);
32
+ const manifest = path === "" ? "package.json" : `${path}/package.json`;
33
+ if (main === manifest) return [manifest, ...index(path)];
34
+ return [manifest, ...probe(main), ...index(main), ...index(path)];
35
+ }
36
+
37
+ function fixtureNeedles(changed: readonly string[]): readonly Needle[] {
38
+ const needles = new Map<string, Needle>();
39
+ for (const fixture of changed.filter((path) => path.startsWith(FIXTURES))) {
40
+ const text = fixture.slice(TEST_TREE.length);
41
+ needles.set(text, { text, kind: "file", fixture });
42
+ for (let end = text.lastIndexOf("/"); end > 0; end = text.lastIndexOf("/", end - 1)) {
43
+ const directory = text.slice(0, end);
44
+ if (!needles.has(directory)) needles.set(directory, { text: directory, kind: "directory", fixture });
45
+ }
46
+ }
47
+ return [...needles.values()];
48
+ }
49
+
50
+ function mentions(line: string, { text, kind }: Needle): boolean {
51
+ if (kind === "file") return line.includes(text);
52
+ return DIRECTORY_ENDS.some((end) => line.includes(text + end));
53
+ }
54
+
55
+ type Edge = { readonly importer: string; readonly imported: string };
56
+
57
+ export function importsOf(file: string, text: string, files: ReadonlySet<string>, mains: ReadonlyMap<string, string> = new Map()): readonly string[] {
58
+ const directory = file.slice(0, Math.max(file.lastIndexOf("/"), 0));
59
+ return [...text.matchAll(IMPORT)].flatMap(([, throughImport = "", throughRequire = ""]) => importedFile(resolved(directory, throughImport || throughRequire), files, mains));
60
+ }
61
+
62
+ const edgesOf = (file: string, text: string, files: ReadonlySet<string>, mains: ReadonlyMap<string, string> = new Map()): readonly Edge[] =>
63
+ importsOf(file, text, files, mains).map((imported) => ({ importer: file, imported }));
64
+
65
+ function fixtureNamedIn(text: string, needles: readonly Needle[]): string | undefined {
66
+ if (needles.length === 0) return undefined;
67
+ for (const line of text.split("\n")) {
68
+ const joined = line.replaceAll(LISTED_SEGMENTS, "/");
69
+ const needle = needles.find((each) => mentions(joined, each));
70
+ if (needle !== undefined) return needle.fixture;
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ function spread(reached: Map<string, string>, edges: readonly Edge[]): void {
76
+ for (let grew = true; grew; ) {
77
+ grew = false;
78
+ for (const { importer, imported } of edges) {
79
+ const via = reached.get(imported);
80
+ if (via === undefined || reached.has(importer)) continue;
81
+ reached.set(importer, via);
82
+ grew = true;
83
+ }
84
+ }
85
+ }
86
+
87
+ // Maps each file that reads a changed path, through an import or the fixture's path, to that changed path.
88
+ export function readersOf(sources: ReadonlyMap<string, string>, changed: readonly string[], mains: ReadonlyMap<string, string> = new Map()): Map<string, string> {
89
+ const reached = new Map(changed.map((path) => [path, path]));
90
+ const needles = fixtureNeedles(changed);
91
+ for (const [file, text] of sources) {
92
+ const fixture = reached.has(file) ? undefined : fixtureNamedIn(text, needles);
93
+ if (fixture !== undefined) reached.set(file, fixture);
94
+ }
95
+ const files = new Set([...sources.keys(), ...changed]);
96
+ spread(reached, [...sources].flatMap(([file, text]) => edgesOf(file, text, files, mains)));
97
+ return new Map([...reached].filter(([file, via]) => file !== via));
98
+ }
@@ -3,17 +3,16 @@ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { escape, minimatch } from "minimatch";
4
4
  import { changedPaths, git, pathsAt, rangeEnds, type Change } from "../core/git.ts";
5
5
  import { runMain, Usage } from "../core/main.ts";
6
+ import { isRecord } from "./mutation-baseline-saved.ts";
7
+ import { importedFile, importsOf, readersOf, resolved, TEST_TREE } from "./mutation-scope-graph.ts";
6
8
 
7
9
  const NAME = "mutation-scope";
8
10
  const USAGE = "usage: mutation-scope.ts <base> [full-baseline-report]";
9
- const TEST_TREE = "tests/";
10
11
  const UNIT_TESTS = "tests/unit/";
11
- const FIXTURES = "tests/fixtures/";
12
12
  const TEST_FILE = /\.test\.tsx?$/;
13
13
  const READ_FILE = /\.tsx?$/;
14
14
  const BUNFIG = "bunfig.toml";
15
15
  const MUTATION_RANGE = /:\d+(?::\d+)?-\d+(?::\d+)?$/;
16
- const IMPORTED_EXTENSIONS = [".tsx", ".ts", ".mts", ".mjs", ".js"];
17
16
 
18
17
  export const STRYKER_DEFAULT_MUTATE = [
19
18
  "{src,lib}/**/!(*.+(s|S)pec|*.+(t|T)est).+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)",
@@ -21,7 +20,7 @@ export const STRYKER_DEFAULT_MUTATE = [
21
20
  ];
22
21
 
23
22
  // Stryker loads the first of these that exists, in this order.
24
- const STRYKER_CONFIGS = ["", "."].flatMap((dot) =>
23
+ export const STRYKER_CONFIGS = ["", "."].flatMap((dot) =>
25
24
  [".conf", ".config"].flatMap((kind) => ["json", "js", "mjs", "cjs"].map((extension) => `${dot}stryker${kind}.${extension}`)),
26
25
  );
27
26
 
@@ -101,90 +100,7 @@ export function pickFromDiff(changes: readonly Change[], mutated: (file: string)
101
100
  };
102
101
  }
103
102
 
104
- const IMPORT = /(?:from|import)[ \t]*\(?[ \t]*["'](\.[^"']*)["']/g;
105
- const LISTED_SEGMENTS = /["'`][ \t]*,[ \t]*["'`]/g;
106
- const DIRECTORY_ENDS = ['"', "'", "`", '/"', "/'", "/`", "/${"];
107
-
108
- type Needle = { readonly text: string; readonly kind: "file" | "directory"; readonly fixture: string };
109
-
110
- function resolved(directory: string, spec: string): string {
111
- const parts: string[] = [];
112
- for (const part of `${directory}/${spec}`.split("/")) {
113
- if (part === "..") parts.pop();
114
- else if (part !== "." && part !== "") parts.push(part);
115
- }
116
- return parts.join("/");
117
- }
118
-
119
- // Bun takes the file a specifier names, then one with an extension added, and only then the directory's index.
120
- function importedFile(path: string, files: ReadonlySet<string>): string | undefined {
121
- const bare = path.replace(/\.m?js$/, "");
122
- const tried = [path, ...IMPORTED_EXTENSIONS.map((extension) => bare + extension), ...IMPORTED_EXTENSIONS.map((extension) => `${path}/index${extension}`)];
123
- return tried.find((file) => files.has(file));
124
- }
125
-
126
- function fixtureNeedles(changed: readonly string[]): readonly Needle[] {
127
- const needles = new Map<string, Needle>();
128
- for (const fixture of changed.filter((path) => path.startsWith(FIXTURES))) {
129
- const text = fixture.slice(TEST_TREE.length);
130
- needles.set(text, { text, kind: "file", fixture });
131
- for (let end = text.lastIndexOf("/"); end > 0; end = text.lastIndexOf("/", end - 1)) {
132
- const directory = text.slice(0, end);
133
- if (!needles.has(directory)) needles.set(directory, { text: directory, kind: "directory", fixture });
134
- }
135
- }
136
- return [...needles.values()];
137
- }
138
-
139
- function mentions(line: string, { text, kind }: Needle): boolean {
140
- if (kind === "file") return line.includes(text);
141
- return DIRECTORY_ENDS.some((end) => line.includes(text + end));
142
- }
143
-
144
- type Edge = { readonly importer: string; readonly imported: string };
145
-
146
- function importsOf(file: string, text: string, files: ReadonlySet<string>): readonly string[] {
147
- const directory = file.slice(0, Math.max(file.lastIndexOf("/"), 0));
148
- return [...text.matchAll(IMPORT)].flatMap(([, spec = ""]) => importedFile(resolved(directory, spec), files) ?? []);
149
- }
150
-
151
- const edgesOf = (file: string, text: string, files: ReadonlySet<string>): readonly Edge[] =>
152
- importsOf(file, text, files).map((imported) => ({ importer: file, imported }));
153
-
154
- function fixtureNamedIn(text: string, needles: readonly Needle[]): string | undefined {
155
- if (needles.length === 0) return undefined;
156
- for (const line of text.split("\n")) {
157
- const joined = line.replaceAll(LISTED_SEGMENTS, "/");
158
- const needle = needles.find((each) => mentions(joined, each));
159
- if (needle !== undefined) return needle.fixture;
160
- }
161
- return undefined;
162
- }
163
-
164
- function spread(reached: Map<string, string>, edges: readonly Edge[]): void {
165
- for (let grew = true; grew; ) {
166
- grew = false;
167
- for (const { importer, imported } of edges) {
168
- const via = reached.get(imported);
169
- if (via === undefined || reached.has(importer)) continue;
170
- reached.set(importer, via);
171
- grew = true;
172
- }
173
- }
174
- }
175
-
176
- // Maps each file that reads a changed path, through an import or the fixture's path, to that changed path.
177
- export function readersOf(sources: ReadonlyMap<string, string>, changed: readonly string[]): Map<string, string> {
178
- const reached = new Map(changed.map((path) => [path, path]));
179
- const needles = fixtureNeedles(changed);
180
- for (const [file, text] of sources) {
181
- const fixture = reached.has(file) ? undefined : fixtureNamedIn(text, needles);
182
- if (fixture !== undefined) reached.set(file, fixture);
183
- }
184
- const files = new Set([...sources.keys(), ...changed]);
185
- spread(reached, [...sources].flatMap(([file, text]) => edgesOf(file, text, files)));
186
- return new Map([...reached].filter(([file, via]) => file !== via));
187
- }
103
+ export { readersOf };
188
104
 
189
105
  // Names what every unit test loads that the branch changes: the preload list, a preload, or a file a preload imports.
190
106
  export function preloadChange(
@@ -264,24 +180,72 @@ const preloadsAt = Effect.fn("preloadsAt")(function* (rev: string, paths: Readon
264
180
  return (typeof preload === "string" ? [preload] : preload).map((entry) => resolved("", entry));
265
181
  });
266
182
 
267
- const loadedByPreloads = Effect.fn("loadedByPreloads")(function* (rev: string, preloads: readonly string[], paths: ReadonlySet<string>) {
183
+ const transitivelyImported = Effect.fn("transitivelyImported")(function* (rev: string, roots: readonly string[], paths: ReadonlySet<string>, mains: ReadonlyMap<string, string> = new Map()) {
268
184
  const loaded = new Set<string>();
269
- for (let next = preloads.flatMap((preload) => importedFile(preload, paths) ?? []); next.length > 0; ) {
185
+ for (let next = [...roots]; next.length > 0; ) {
270
186
  for (const file of next) loaded.add(file);
271
- const read = (file: string) => git(["show", `${rev}:${file}`]).pipe(Effect.map((text) => importsOf(file, text, paths)));
187
+ const read = (file: string) =>
188
+ git(["show", `${rev}:${file}`]).pipe(
189
+ Effect.map((text) => importsOf(file, text, paths, mains)),
190
+ Effect.catchTag("GitFailure", (): Effect.Effect<readonly string[]> => Effect.succeed([])),
191
+ );
272
192
  const imported = (yield* Effect.forEach(next, read, { concurrency: 16 })).flat();
273
193
  next = [...new Set(imported)].filter((file) => !loaded.has(file));
274
194
  }
275
195
  return loaded;
276
196
  });
277
197
 
278
- const readTestTree = Effect.fn("readTestTree")(function* (paths: Iterable<string>) {
198
+ const loadedByPreloads = Effect.fn("loadedByPreloads")(function* (rev: string, preloads: readonly string[], paths: ReadonlySet<string>, mains: ReadonlyMap<string, string> = new Map()) {
199
+ return yield* transitivelyImported(rev, preloads.flatMap((preload) => importedFile(preload, paths, mains)), paths, mains);
200
+ });
201
+
202
+ const mainOf = (text: string) =>
203
+ Effect.try({
204
+ try: (): unknown => JSON.parse(text),
205
+ catch: () => new ScopeError({ message: "package.json does not parse as JSON holding a main entry" }),
206
+ }).pipe(
207
+ Effect.map((parsed) => {
208
+ if (!isRecord(parsed)) return undefined;
209
+ const main = parsed["main"];
210
+ return typeof main === "string" && main.trim() !== "" ? main : undefined;
211
+ }),
212
+ Effect.catchTag("ScopeError", () => Effect.succeed(undefined)),
213
+ );
214
+
215
+ const readMains = Effect.fn("readMains")(function* (rev: string, paths: ReadonlySet<string>) {
216
+ const packages = [...paths].filter((path) => path === "package.json" || path.endsWith("/package.json"));
217
+ const entries = yield* Effect.forEach(
218
+ packages,
219
+ (file) => git(["show", `${rev}:${file}`]).pipe(Effect.map((text): readonly [string, string] => [file, text]), Effect.catchTag("GitFailure", () => Effect.succeed(undefined))),
220
+ { concurrency: 16 },
221
+ );
222
+ const mains = new Map<string, string>();
223
+ for (const entry of entries) {
224
+ if (entry === undefined) continue;
225
+ const [file, text] = entry;
226
+ const main = yield* mainOf(text);
227
+ const directory = file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "";
228
+ mains.set(directory, main === undefined ? file : resolved(directory, main));
229
+ }
230
+ return mains;
231
+ });
232
+
233
+ const readFiles = Effect.fn("readFiles")(function* (files: readonly string[]) {
279
234
  const fs = yield* FileSystem.FileSystem;
280
- const files = [...paths].filter((path) => path.startsWith(TEST_TREE) && READ_FILE.test(path));
281
235
  const read = (file: string) => fs.readFileString(file).pipe(Effect.map((text) => [file, text] as const));
282
236
  return new Map(yield* Effect.forEach(files, read, { concurrency: 16 }));
283
237
  });
284
238
 
239
+ const readTestTree = Effect.fn("readTestTree")(function* (paths: Iterable<string>) {
240
+ return yield* readFiles([...paths].filter((path) => path.startsWith(TEST_TREE) && READ_FILE.test(path)));
241
+ });
242
+
243
+ const GRAPH_FILE = /\.(?:tsx?|mts|cts|jsx?|mjs|cjs)$/;
244
+
245
+ const readImportGraph = Effect.fn("readImportGraph")(function* (paths: Iterable<string>) {
246
+ return yield* readFiles([...paths].filter((path) => GRAPH_FILE.test(path)));
247
+ });
248
+
285
249
  const loadBaseline = Effect.fn("loadBaseline")(function* (report: string | undefined, tests: ReadonlySet<string>) {
286
250
  const fs = yield* FileSystem.FileSystem;
287
251
  if (report === undefined || !(yield* fs.exists(report))) {
@@ -308,6 +272,30 @@ const unitReaders = Effect.fn("unitReaders")(function* (picked: Picked, { base,
308
272
  return readers;
309
273
  });
310
274
 
275
+ export type Staleness = { readonly paths: ReadonlySet<string>; readonly preload: string | undefined; readonly stale: readonly string[]; readonly config: ReadonlySet<string> };
276
+
277
+ export const stalenessSince = Effect.fn("stalenessSince")(function* (base: string, head: string) {
278
+ const picked = pickFromDiff(yield* changedPaths(base, head, []), () => false);
279
+ const atBase = new Set(yield* pathsAt(base, []));
280
+ const atHead = new Set(yield* pathsAt(head, []));
281
+ const preloads = { atBase: yield* preloadsAt(base, atBase), atHead: yield* preloadsAt(head, atHead) };
282
+ const mains = new Map([...(yield* readMains(base, atBase)), ...(yield* readMains(head, atHead))]);
283
+ const loaded = new Set([...(yield* loadedByPreloads(head, preloads.atHead, atHead, mains)), ...(yield* loadedByPreloads(base, preloads.atBase, atBase, mains))]);
284
+ const preload = preloadChange(picked.paths, preloads, loaded);
285
+ const active = { atBase: STRYKER_CONFIGS.find((name) => atBase.has(name)), atHead: STRYKER_CONFIGS.find((name) => atHead.has(name)) };
286
+ const config = new Set([
287
+ ...(yield* transitivelyImported(head, active.atHead === undefined ? [] : [active.atHead], atHead, mains)),
288
+ ...(yield* transitivelyImported(base, active.atBase === undefined ? [] : [active.atBase], atBase, mains)),
289
+ ]);
290
+ if (preload !== undefined) return { paths: picked.paths, preload, stale: [], config };
291
+ if (picked.paths.size === 0) return { paths: picked.paths, preload: undefined, stale: [], config };
292
+ const readers = [...readersOf(yield* readImportGraph(atHead), [...picked.paths], mains)]
293
+ .filter(([reader]) => isUnitTest(reader))
294
+ .map(([reader]) => reader);
295
+ const stale = [...new Set([...[...picked.paths].filter(isUnitTest), ...readers])].toSorted();
296
+ return { paths: picked.paths, preload: undefined, stale, config };
297
+ });
298
+
311
299
  // An absent coverage edge holds only for a test whose merge-base source is the one the report ran.
312
300
  const staleIn = Effect.fn("staleIn")(function* (baseline: Baseline, tests: readonly string[], base: string) {
313
301
  const recorded = new Map(Object.entries(baseline.testFiles).map(([file, { source }]) => [file, source]));