@avi2dg/checks 0.50.1 → 0.52.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.
Files changed (45) hide show
  1. package/.bun-version +1 -0
  2. package/CHANGELOG.md +27 -0
  3. package/README.md +24 -249
  4. package/commitlint.config.ts +0 -1
  5. package/dist/mutation/unit-test-order.js +1 -1
  6. package/dist/presets/knip.d.ts +1 -1
  7. package/dist/presets/knip.js +1 -1
  8. package/dist/templates/how-to.md +1 -1
  9. package/dist/templates/readme.md +1 -1
  10. package/dist/templates/tutorial.md +1 -1
  11. package/docs/bins.md +67 -0
  12. package/docs/gates/checks-advisories.md +2 -2
  13. package/docs/gates/checks-browser.md +5 -1
  14. package/docs/gates/checks-ci-wiring.md +18 -9
  15. package/docs/gates/checks-commit-identity.md +6 -3
  16. package/docs/gates/checks-copy.md +4 -0
  17. package/docs/gates/checks-docs.md +24 -3
  18. package/docs/gates/checks-exports.md +1 -1
  19. package/docs/gates/checks-frontend-syntax.md +5 -1
  20. package/docs/gates/checks-lint.md +2 -2
  21. package/docs/gates/checks-mutation-baseline.md +36 -7
  22. package/docs/gates/checks-mutation-compare.md +3 -3
  23. package/docs/gates/checks-mutation-scope.md +1 -1
  24. package/docs/gates/checks-mutation.md +26 -9
  25. package/docs/gates/checks-night-run.md +1 -1
  26. package/docs/gates/checks-secrets.md +2 -2
  27. package/docs/gates/checks-unused.md +1 -1
  28. package/docs/how-to/set-up-a-repository.md +142 -0
  29. package/docs/how-to/turn-on-the-css-families.md +35 -0
  30. package/docs/how-to/upgrade-the-kit.md +42 -0
  31. package/docs/shipped-files.md +36 -0
  32. package/package.json +13 -1
  33. package/src/delivery/bun-pin.ts +19 -5
  34. package/src/delivery/ci-wiring.ts +11 -77
  35. package/src/delivery/commit-identity.ts +14 -7
  36. package/src/delivery/runner-policy.ts +385 -0
  37. package/src/delivery/workflow-references.ts +1 -1
  38. package/src/docs/doc-templates.ts +1 -1
  39. package/src/docs/doc-versions.ts +137 -0
  40. package/src/docs/docs.ts +7 -0
  41. package/src/testing/mutation-baseline-full.ts +68 -0
  42. package/src/testing/mutation-baseline-saved.ts +52 -0
  43. package/src/testing/mutation-baseline.ts +27 -23
  44. package/src/testing/mutation-scope-graph.ts +89 -0
  45. package/src/testing/mutation-scope.ts +48 -93
@@ -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) {
@@ -58,14 +46,16 @@ const listed = Effect.fn("listed")(function* (event: string | undefined, limit:
58
46
  );
59
47
  });
60
48
 
49
+ const byNewest = (runs: readonly Run[]): readonly Run[] => [...runs].sort((one, other) => other.createdAt.localeCompare(one.createdAt));
50
+
61
51
  export function newestFirst(runs: readonly Run[]): readonly number[] {
62
- return [...runs].sort((one, other) => other.createdAt.localeCompare(one.createdAt)).map((run) => run.databaseId);
52
+ return byNewest(runs).map((run) => run.databaseId);
63
53
  }
64
54
 
65
55
  // All non-pull-request runs newest first: a newer full run holds no baseline, and the newest baseline must survive it.
66
56
  const latestCandidates = Effect.gen(function* () {
67
57
  const newest = (yield* listed(undefined, LATEST_LIMIT)).find((run) => run.event !== "pull_request");
68
- return newest === undefined ? [] : [newest.databaseId];
58
+ return newest === undefined ? [] : [newest];
69
59
  });
70
60
 
71
61
  const latestRuns = listed(undefined, LATEST_LIMIT).pipe(Effect.map((runs) => newestFirst(runs.filter((run) => run.event !== "pull_request"))));
@@ -73,7 +63,7 @@ const latestRuns = listed(undefined, LATEST_LIMIT).pipe(Effect.map((runs) => new
73
63
  // Each full event is listed on its own, because one shared window lets push runs crowd out every full run.
74
64
  const fullCandidates = Effect.gen(function* () {
75
65
  const perEvent = yield* Effect.forEach(FULL_EVENTS, (event) => listed(event, FULL_LIMIT));
76
- return newestFirst(perEvent.flat());
66
+ return byNewest(perEvent.flat());
77
67
  });
78
68
 
79
69
  // A successful run uploaded the files, so the newest unexpired artifact is the one the restore takes.
@@ -120,7 +110,7 @@ const keptLatest = Effect.fn("keptLatest")(function* (runs: readonly number[], s
120
110
  const pruneBaselines = Effect.fn("pruneBaselines")(function* (dryRun: boolean) {
121
111
  const running = yield* Config.Int("GITHUB_RUN_ID").pipe(Config.option);
122
112
  const latest = yield* keptLatest(yield* latestRuns, yield* savedShelf(yield* runnerCache));
123
- const full = yield* keptArtifact(yield* fullCandidates, FULL_ARTIFACT);
113
+ const full = yield* keptArtifact((yield* fullCandidates).map((run) => run.databaseId), FULL_ARTIFACT);
124
114
  const artifacts = yield* everyArtifact();
125
115
  const keeping = [...Option.toArray(latest.kept), ...Option.toArray(full), ...uploadedBy(artifacts, running)];
126
116
  const victims = superseded(artifacts, new Set(keeping.map((keep) => keep.id)));
@@ -334,8 +324,15 @@ const FLAGS = new Map<string, (rest: readonly string[]) => Command | undefined>(
334
324
  ["--slim", ([from, to, ...extra]) => (isDestination(from) && isDestination(to) && extra.length === 0 ? { kind: "slim", from, to } : undefined)],
335
325
  [
336
326
  "--full",
337
- ([report, ...extra]) =>
338
- isDestination(report) && extra.length === 0 ? { kind: "restore", full: true, needed: { from: [REPORT_ALONE, REPORT], to: report }, optional: [] } : undefined,
327
+ ([report, incremental, ...extra]) =>
328
+ isDestination(report) && (incremental === undefined || isDestination(incremental)) && extra.length === 0
329
+ ? {
330
+ kind: "restore",
331
+ full: true,
332
+ needed: { from: [REPORT_ALONE, REPORT], to: report },
333
+ optional: incremental === undefined ? [] : [{ from: [INCREMENTAL], to: incremental }],
334
+ }
335
+ : undefined,
339
336
  ],
340
337
  ]);
341
338
 
@@ -346,14 +343,21 @@ function parsed([first, ...rest]: readonly string[]): Command | undefined {
346
343
  return restoreRequest(first, report, extra);
347
344
  }
348
345
 
346
+ const restoredRun = Effect.fn("restoredRun")(function* (run: Run, name: string, root: Option.Option<string>, shelf: Option.Option<string>, request: Request) {
347
+ if (Option.isSome(shelf) && (yield* restoredFromSaved(shelf.value, run.databaseId, request))) return true;
348
+ const artifact = yield* artifactOf(run.databaseId, name).pipe(Effect.catch(warned(Option.none<Artifact>())));
349
+ if (Option.isNone(artifact) || !(yield* restoredFrom(run.databaseId, artifact.value, root, request))) return false;
350
+ const incremental = request.full ? request.optional[0] : undefined;
351
+ if (incremental !== undefined) yield* pruneFullState(run, incremental.to);
352
+ return true;
353
+ });
354
+
349
355
  const restoreBaseline = Effect.fn("restoreBaseline")(function* (request: Request) {
350
356
  const name = request.full ? FULL_ARTIFACT : LATEST_ARTIFACT;
351
357
  const root = yield* runnerCache;
352
358
  const shelf = request.full ? Option.none<string>() : yield* savedShelf(root);
353
359
  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;
360
+ if (yield* restoredRun(run, name, root, shelf, request)) return true;
357
361
  }
358
362
  yield* Console.log(`mutation-baseline: no successful ${BRANCH} run holds a ${name} artifact, so nothing was restored`);
359
363
  return true;
@@ -0,0 +1,89 @@
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>): readonly string[] {
22
+ const bare = path.replace(/\.m?js$/, "");
23
+ const direct = [path, ...CANDIDATE_EXTENSIONS.map((extension) => bare + extension)].filter((file) => files.has(file));
24
+ if (direct.length > 0) return direct;
25
+ return CANDIDATE_EXTENSIONS.map((extension) => `${path}/index${extension}`).filter((file) => files.has(file));
26
+ }
27
+
28
+ function fixtureNeedles(changed: readonly string[]): readonly Needle[] {
29
+ const needles = new Map<string, Needle>();
30
+ for (const fixture of changed.filter((path) => path.startsWith(FIXTURES))) {
31
+ const text = fixture.slice(TEST_TREE.length);
32
+ needles.set(text, { text, kind: "file", fixture });
33
+ for (let end = text.lastIndexOf("/"); end > 0; end = text.lastIndexOf("/", end - 1)) {
34
+ const directory = text.slice(0, end);
35
+ if (!needles.has(directory)) needles.set(directory, { text: directory, kind: "directory", fixture });
36
+ }
37
+ }
38
+ return [...needles.values()];
39
+ }
40
+
41
+ function mentions(line: string, { text, kind }: Needle): boolean {
42
+ if (kind === "file") return line.includes(text);
43
+ return DIRECTORY_ENDS.some((end) => line.includes(text + end));
44
+ }
45
+
46
+ type Edge = { readonly importer: string; readonly imported: string };
47
+
48
+ export function importsOf(file: string, text: string, files: ReadonlySet<string>): readonly string[] {
49
+ const directory = file.slice(0, Math.max(file.lastIndexOf("/"), 0));
50
+ return [...text.matchAll(IMPORT)].flatMap(([, throughImport = "", throughRequire = ""]) => importedFile(resolved(directory, throughImport || throughRequire), files));
51
+ }
52
+
53
+ const edgesOf = (file: string, text: string, files: ReadonlySet<string>): readonly Edge[] =>
54
+ importsOf(file, text, files).map((imported) => ({ importer: file, imported }));
55
+
56
+ function fixtureNamedIn(text: string, needles: readonly Needle[]): string | undefined {
57
+ if (needles.length === 0) return undefined;
58
+ for (const line of text.split("\n")) {
59
+ const joined = line.replaceAll(LISTED_SEGMENTS, "/");
60
+ const needle = needles.find((each) => mentions(joined, each));
61
+ if (needle !== undefined) return needle.fixture;
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ function spread(reached: Map<string, string>, edges: readonly Edge[]): void {
67
+ for (let grew = true; grew; ) {
68
+ grew = false;
69
+ for (const { importer, imported } of edges) {
70
+ const via = reached.get(imported);
71
+ if (via === undefined || reached.has(importer)) continue;
72
+ reached.set(importer, via);
73
+ grew = true;
74
+ }
75
+ }
76
+ }
77
+
78
+ // Maps each file that reads a changed path, through an import or the fixture's path, to that changed path.
79
+ export function readersOf(sources: ReadonlyMap<string, string>, changed: readonly string[]): Map<string, string> {
80
+ const reached = new Map(changed.map((path) => [path, path]));
81
+ const needles = fixtureNeedles(changed);
82
+ for (const [file, text] of sources) {
83
+ const fixture = reached.has(file) ? undefined : fixtureNamedIn(text, needles);
84
+ if (fixture !== undefined) reached.set(file, fixture);
85
+ }
86
+ const files = new Set([...sources.keys(), ...changed]);
87
+ spread(reached, [...sources].flatMap(([file, text]) => edgesOf(file, text, files)));
88
+ return new Map([...reached].filter(([file, via]) => file !== via));
89
+ }
@@ -3,17 +3,15 @@ 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 { importedFile, importsOf, readersOf, resolved, TEST_TREE } from "./mutation-scope-graph.ts";
6
7
 
7
8
  const NAME = "mutation-scope";
8
9
  const USAGE = "usage: mutation-scope.ts <base> [full-baseline-report]";
9
- const TEST_TREE = "tests/";
10
10
  const UNIT_TESTS = "tests/unit/";
11
- const FIXTURES = "tests/fixtures/";
12
11
  const TEST_FILE = /\.test\.tsx?$/;
13
12
  const READ_FILE = /\.tsx?$/;
14
13
  const BUNFIG = "bunfig.toml";
15
14
  const MUTATION_RANGE = /:\d+(?::\d+)?-\d+(?::\d+)?$/;
16
- const IMPORTED_EXTENSIONS = [".tsx", ".ts", ".mts", ".mjs", ".js"];
17
15
 
18
16
  export const STRYKER_DEFAULT_MUTATE = [
19
17
  "{src,lib}/**/!(*.+(s|S)pec|*.+(t|T)est).+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)",
@@ -21,7 +19,7 @@ export const STRYKER_DEFAULT_MUTATE = [
21
19
  ];
22
20
 
23
21
  // Stryker loads the first of these that exists, in this order.
24
- const STRYKER_CONFIGS = ["", "."].flatMap((dot) =>
22
+ export const STRYKER_CONFIGS = ["", "."].flatMap((dot) =>
25
23
  [".conf", ".config"].flatMap((kind) => ["json", "js", "mjs", "cjs"].map((extension) => `${dot}stryker${kind}.${extension}`)),
26
24
  );
27
25
 
@@ -101,90 +99,7 @@ export function pickFromDiff(changes: readonly Change[], mutated: (file: string)
101
99
  };
102
100
  }
103
101
 
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
- }
102
+ export { readersOf };
188
103
 
189
104
  // Names what every unit test loads that the branch changes: the preload list, a preload, or a file a preload imports.
190
105
  export function preloadChange(
@@ -264,24 +179,41 @@ const preloadsAt = Effect.fn("preloadsAt")(function* (rev: string, paths: Readon
264
179
  return (typeof preload === "string" ? [preload] : preload).map((entry) => resolved("", entry));
265
180
  });
266
181
 
267
- const loadedByPreloads = Effect.fn("loadedByPreloads")(function* (rev: string, preloads: readonly string[], paths: ReadonlySet<string>) {
182
+ const transitivelyImported = Effect.fn("transitivelyImported")(function* (rev: string, roots: readonly string[], paths: ReadonlySet<string>) {
268
183
  const loaded = new Set<string>();
269
- for (let next = preloads.flatMap((preload) => importedFile(preload, paths) ?? []); next.length > 0; ) {
184
+ for (let next = [...roots]; next.length > 0; ) {
270
185
  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)));
186
+ const read = (file: string) =>
187
+ git(["show", `${rev}:${file}`]).pipe(
188
+ Effect.map((text) => importsOf(file, text, paths)),
189
+ Effect.catchTag("GitFailure", (): Effect.Effect<readonly string[]> => Effect.succeed([])),
190
+ );
272
191
  const imported = (yield* Effect.forEach(next, read, { concurrency: 16 })).flat();
273
192
  next = [...new Set(imported)].filter((file) => !loaded.has(file));
274
193
  }
275
194
  return loaded;
276
195
  });
277
196
 
278
- const readTestTree = Effect.fn("readTestTree")(function* (paths: Iterable<string>) {
197
+ const loadedByPreloads = Effect.fn("loadedByPreloads")(function* (rev: string, preloads: readonly string[], paths: ReadonlySet<string>) {
198
+ return yield* transitivelyImported(rev, preloads.flatMap((preload) => importedFile(preload, paths)), paths);
199
+ });
200
+
201
+ const readFiles = Effect.fn("readFiles")(function* (files: readonly string[]) {
279
202
  const fs = yield* FileSystem.FileSystem;
280
- const files = [...paths].filter((path) => path.startsWith(TEST_TREE) && READ_FILE.test(path));
281
203
  const read = (file: string) => fs.readFileString(file).pipe(Effect.map((text) => [file, text] as const));
282
204
  return new Map(yield* Effect.forEach(files, read, { concurrency: 16 }));
283
205
  });
284
206
 
207
+ const readTestTree = Effect.fn("readTestTree")(function* (paths: Iterable<string>) {
208
+ return yield* readFiles([...paths].filter((path) => path.startsWith(TEST_TREE) && READ_FILE.test(path)));
209
+ });
210
+
211
+ const GRAPH_FILE = /\.(?:tsx?|mts|cts|jsx?|mjs|cjs)$/;
212
+
213
+ const readImportGraph = Effect.fn("readImportGraph")(function* (paths: Iterable<string>) {
214
+ return yield* readFiles([...paths].filter((path) => GRAPH_FILE.test(path)));
215
+ });
216
+
285
217
  const loadBaseline = Effect.fn("loadBaseline")(function* (report: string | undefined, tests: ReadonlySet<string>) {
286
218
  const fs = yield* FileSystem.FileSystem;
287
219
  if (report === undefined || !(yield* fs.exists(report))) {
@@ -308,6 +240,29 @@ const unitReaders = Effect.fn("unitReaders")(function* (picked: Picked, { base,
308
240
  return readers;
309
241
  });
310
242
 
243
+ export type Staleness = { readonly paths: ReadonlySet<string>; readonly preload: string | undefined; readonly stale: readonly string[]; readonly config: ReadonlySet<string> };
244
+
245
+ export const stalenessSince = Effect.fn("stalenessSince")(function* (base: string, head: string) {
246
+ const picked = pickFromDiff(yield* changedPaths(base, head, []), () => false);
247
+ const atBase = new Set(yield* pathsAt(base, []));
248
+ const atHead = new Set(yield* pathsAt(head, []));
249
+ const preloads = { atBase: yield* preloadsAt(base, atBase), atHead: yield* preloadsAt(head, atHead) };
250
+ const loaded = new Set([...(yield* loadedByPreloads(head, preloads.atHead, atHead)), ...(yield* loadedByPreloads(base, preloads.atBase, atBase))]);
251
+ const preload = preloadChange(picked.paths, preloads, loaded);
252
+ const active = { atBase: STRYKER_CONFIGS.find((name) => atBase.has(name)), atHead: STRYKER_CONFIGS.find((name) => atHead.has(name)) };
253
+ const config = new Set([
254
+ ...(yield* transitivelyImported(head, active.atHead === undefined ? [] : [active.atHead], atHead)),
255
+ ...(yield* transitivelyImported(base, active.atBase === undefined ? [] : [active.atBase], atBase)),
256
+ ]);
257
+ if (preload !== undefined) return { paths: picked.paths, preload, stale: [], config };
258
+ if (picked.paths.size === 0) return { paths: picked.paths, preload: undefined, stale: [], config };
259
+ const readers = [...readersOf(yield* readImportGraph(atHead), [...picked.paths])]
260
+ .filter(([reader]) => isUnitTest(reader))
261
+ .map(([reader]) => reader);
262
+ const stale = [...new Set([...[...picked.paths].filter(isUnitTest), ...readers])].toSorted();
263
+ return { paths: picked.paths, preload: undefined, stale, config };
264
+ });
265
+
311
266
  // An absent coverage edge holds only for a test whose merge-base source is the one the report ran.
312
267
  const staleIn = Effect.fn("staleIn")(function* (baseline: Baseline, tests: readonly string[], base: string) {
313
268
  const recorded = new Map(Object.entries(baseline.testFiles).map(([file, { source }]) => [file, source]));