@avi2dg/checks 0.63.1 → 0.65.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.
@@ -0,0 +1,261 @@
1
+ import { Config, Effect, FileSystem, Option, Path, Schema } from "effect";
2
+ import { pathsSinceMergeBase, workingTreeFiles } from "../core/git.ts";
3
+ import { decodePullRequestEvent, pullRequestEndsOf } from "../core/lint.ts";
4
+ import { classifyPath } from "../core/scope.ts";
5
+ import { decodeBunfig, importedFile, importsOf, preloadPaths, resolved, TEST_TREE } from "./mutation-scope-graph.ts";
6
+
7
+ export type Narrowing =
8
+ | { readonly kind: "full"; readonly because: string }
9
+ | { readonly kind: "files"; readonly files: readonly string[] };
10
+
11
+ type SelfReference = {
12
+ readonly name: string;
13
+ readonly exports: ReadonlyMap<string, string>;
14
+ };
15
+
16
+ export type Repository = {
17
+ readonly files: readonly string[];
18
+ readonly sources: ReadonlyMap<string, string>;
19
+ readonly bins: ReadonlyMap<string, string>;
20
+ readonly self: SelfReference | undefined;
21
+ readonly preloads: readonly string[];
22
+ };
23
+
24
+ const FIXTURES = "tests/fixtures/";
25
+ // A workflow runs the suite rather than being read by it, so a test that reads one names its path.
26
+ const WORKFLOWS = ".github/";
27
+ const TEST_FILE = /\.test\.tsx?$/;
28
+ const SOURCE_FILE = /\.(?:[cm]?[jt]sx?|astro|svelte|vue|sh)$/;
29
+ const LOCKFILES = new Set(["bun.lock", "bun.lockb", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml"]);
30
+ const TOOL_PINS = new Set([".bun-version", ".npmrc", ".nvmrc", ".node-version", ".tool-versions"]);
31
+ const CONFIG = /(?:^|\.)config\.(?:[cm]?[jt]s|json)$/;
32
+ const STYLESHEET = /\.(?:css|scss|sass|less|pcss|styl)$/;
33
+ const FONT = /\.(?:woff2?|ttf|otf|eot)$/;
34
+ const LAYOUT = /^layout\./i;
35
+ const LISTED_SEGMENTS = /["'`][ \t]*,[ \t]*["'`]/g;
36
+ const TOKEN = /[\w.@/-]+/g;
37
+ const LEADING_HOPS = /^(?:\.{1,2}\/|\/)+/;
38
+ const BARE_IMPORT = /(?:from|import)\s*\(?\s*["']([^"'.][^"']*)["']|require\s*\(\s*["']([^"'.][^"']*)["']\s*\)/g;
39
+ // Bun picks the first condition it knows, and types names no file it runs.
40
+ const CONDITIONS = ["bun", "import", "default", "node", "require"];
41
+ const BUILT = "the build";
42
+
43
+ const basenameOf = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
44
+ const directoryNode = (directory: string): string => directory + "/";
45
+
46
+ export function sharedReason(path: string, preloads: ReadonlySet<string>): string | undefined {
47
+ if (path.startsWith(FIXTURES)) return undefined;
48
+ const name = basenameOf(path);
49
+ const directories = path.split("/").slice(0, -1);
50
+ if (LOCKFILES.has(name)) return "a lockfile";
51
+ if ((classifyPath(path) === "workflow" && !path.startsWith(WORKFLOWS)) || CONFIG.test(name) || TOOL_PINS.has(name)) {
52
+ return "a config every test runs under";
53
+ }
54
+ if (preloads.has(path)) return "a file Bun preloads into every test";
55
+ if (path.startsWith(TEST_TREE) && !TEST_FILE.test(path)) return "a test helper";
56
+ if (STYLESHEET.test(name)) return "a stylesheet";
57
+ if (FONT.test(name) || directories.includes("fonts")) return "a font";
58
+ if (directories.includes("layouts") || LAYOUT.test(name)) return "a layout";
59
+ return undefined;
60
+ }
61
+
62
+ // A path mentioned by any trailing run of two or more of its segments, or in full, names it.
63
+ function mentionKeys(path: string): readonly string[] {
64
+ const segments = path.split("/");
65
+ const keys = [path];
66
+ for (let start = 1; start <= segments.length - 2; start += 1) keys.push(segments.slice(start).join("/"));
67
+ return keys;
68
+ }
69
+
70
+ // Only a fixture is read by its directory; a source directory a file names is one it lists, as a runner does.
71
+ function directoriesOf(path: string): readonly string[] {
72
+ if (!path.startsWith(FIXTURES)) return [];
73
+ const segments = path.split("/");
74
+ return segments.slice(1).map((_, end) => segments.slice(0, end + 1).join("/"));
75
+ }
76
+
77
+ function exportTarget(target: unknown): string | undefined {
78
+ if (typeof target === "string") return target;
79
+ if (typeof target !== "object" || target === null) return undefined;
80
+ const conditions: Readonly<Record<string, unknown>> = { ...target };
81
+ for (const condition of CONDITIONS) {
82
+ const found = exportTarget(conditions[condition]);
83
+ if (found !== undefined) return found;
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ function exportsMap(exports: unknown): ReadonlyMap<string, string> {
89
+ if (typeof exports === "string") return new Map([[".", resolved("", exports)]]);
90
+ if (typeof exports !== "object" || exports === null) return new Map();
91
+ const entries: Readonly<Record<string, unknown>> = { ...exports };
92
+ return new Map(Object.entries(entries).flatMap(([key, target]) => {
93
+ const file = exportTarget(target);
94
+ return file === undefined ? [] : [[key, resolved("", file)] as const];
95
+ }));
96
+ }
97
+
98
+ function selfTarget(spec: string, self: SelfReference): string | undefined {
99
+ if (spec !== self.name && !spec.startsWith(self.name + "/")) return undefined;
100
+ const subpath = spec === self.name ? "." : "./" + spec.slice(self.name.length + 1);
101
+ if (self.exports.size === 0) return resolved("", subpath === "." ? "index" : subpath);
102
+ const exact = self.exports.get(subpath);
103
+ if (exact !== undefined) return exact;
104
+ for (const [key, target] of self.exports) {
105
+ const star = key.indexOf("*");
106
+ if (star === -1) continue;
107
+ const [before, after] = [key.slice(0, star), key.slice(star + 1)];
108
+ if (subpath.startsWith(before) && subpath.endsWith(after) && subpath.length >= key.length - 1) {
109
+ return target.replace("*", subpath.slice(before.length, subpath.length - after.length));
110
+ }
111
+ }
112
+ return BUILT;
113
+ }
114
+
115
+ type Graph = { readonly readers: ReadonlyMap<string, ReadonlySet<string>>; readonly builtReaders: ReadonlySet<string> };
116
+
117
+ function linkTo(readers: Map<string, Set<string>>, read: string, reader: string): void {
118
+ if (read === reader) return;
119
+ readers.set(read, (readers.get(read) ?? new Set()).add(reader));
120
+ }
121
+
122
+ function selfImports(text: string, self: SelfReference, known: ReadonlySet<string>): readonly string[] {
123
+ return [...text.matchAll(BARE_IMPORT)].flatMap(([, viaImport = "", viaRequire = ""]) => {
124
+ const target = selfTarget(viaImport || viaRequire, self);
125
+ if (target === undefined || target === BUILT) return target === undefined ? [] : [BUILT];
126
+ const found = importedFile(target, known);
127
+ return found.length === 0 ? [BUILT] : found;
128
+ });
129
+ }
130
+
131
+ function mentioned(text: string, index: ReadonlyMap<string, readonly string[]>): readonly string[] {
132
+ const joined = text.replaceAll(LISTED_SEGMENTS, "/");
133
+ return [...joined.matchAll(TOKEN)].flatMap(([token]) => index.get(token.replace(LEADING_HOPS, "").replace(/\/$/, "")) ?? []);
134
+ }
135
+
136
+ function mentionIndex(repository: Repository, known: ReadonlySet<string>): ReadonlyMap<string, readonly string[]> {
137
+ const index = new Map<string, string[]>();
138
+ const add = (key: string, node: string) => index.set(key, [...(index.get(key) ?? []), node]);
139
+ const directories = new Set([...known].flatMap(directoriesOf));
140
+ for (const file of known) for (const key of mentionKeys(file)) add(key, file);
141
+ for (const directory of directories) for (const key of mentionKeys(directory)) if (key.includes("/")) add(key, directoryNode(directory));
142
+ for (const [bin, file] of repository.bins) add(bin, file);
143
+ return index;
144
+ }
145
+
146
+ function graphOf(repository: Repository, changed: readonly string[]): Graph {
147
+ const known = new Set([...repository.files, ...changed]);
148
+ const index = mentionIndex(repository, known);
149
+ const readers = new Map<string, Set<string>>();
150
+ const builtReaders = new Set<string>();
151
+ for (const [file, text] of repository.sources) {
152
+ const self = repository.self === undefined ? [] : selfImports(text, repository.self, known);
153
+ if (self.includes(BUILT)) builtReaders.add(file);
154
+ const named = file.startsWith(TEST_TREE) ? mentioned(text, index) : [];
155
+ for (const read of [...importsOf(file, text, known), ...self.filter((target) => target !== BUILT), ...named]) {
156
+ linkTo(readers, read, file);
157
+ }
158
+ }
159
+ return { readers, builtReaders };
160
+ }
161
+
162
+ function reach(graph: Graph, changed: readonly string[], sources: ReadonlyMap<string, string>): ReadonlySet<string> {
163
+ const reached = new Set<string>();
164
+ const pending = [...changed];
165
+ const builds = changed.some((path) => sources.has(path) && !path.startsWith(TEST_TREE));
166
+ if (builds) pending.push(...graph.builtReaders);
167
+ for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
168
+ if (reached.has(node)) continue;
169
+ reached.add(node);
170
+ pending.push(...(graph.readers.get(node) ?? []));
171
+ if (!node.endsWith("/")) pending.push(...directoriesOf(node).map(directoryNode));
172
+ }
173
+ return reached;
174
+ }
175
+
176
+ function preloadClosure(repository: Repository): ReadonlySet<string> {
177
+ const known = new Set(repository.files);
178
+ const loaded = new Set<string>();
179
+ for (let pending = [...repository.preloads]; pending.length > 0; ) {
180
+ const file = pending.pop() ?? "";
181
+ if (loaded.has(file)) continue;
182
+ loaded.add(file);
183
+ pending.push(...importsOf(file, repository.sources.get(file) ?? "", known));
184
+ }
185
+ return loaded;
186
+ }
187
+
188
+ // A test or its helper reads a file it imports or names by path or bin, and a source reads only what it imports, at any depth.
189
+ // A source that names a path, as a registry does, would make every importer read every file it lists.
190
+ export function narrowTests(changed: readonly string[], repository: Repository, tests: readonly string[]): Narrowing {
191
+ if (changed.length === 0) return { kind: "full", because: "the pull request changes no file" };
192
+ const preloads = preloadClosure(repository);
193
+ for (const path of changed) {
194
+ const reason = sharedReason(path, preloads);
195
+ if (reason !== undefined) return { kind: "full", because: `the pull request changes ${path}, ${reason}` };
196
+ }
197
+ const reached = reach(graphOf(repository, changed), changed, repository.sources);
198
+ return { kind: "files", files: tests.filter((test) => reached.has(test)) };
199
+ }
200
+
201
+ const Manifest = Schema.Struct({
202
+ name: Schema.optionalKey(Schema.String),
203
+ exports: Schema.optionalKey(Schema.Unknown),
204
+ bin: Schema.optionalKey(Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.String)])),
205
+ });
206
+ const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest));
207
+
208
+ class NarrowingError extends Schema.TaggedError<NarrowingError>()("NarrowingError", {
209
+ message: Schema.String,
210
+ }) {}
211
+
212
+ function binsOf(manifest: typeof Manifest.Type): ReadonlyMap<string, string> {
213
+ const { bin, name } = manifest;
214
+ if (bin === undefined) return new Map();
215
+ if (typeof bin === "string") return name === undefined ? new Map() : new Map([[basenameOf(name), resolved("", bin)]]);
216
+ return new Map(Object.entries(bin).map(([command, file]) => [command, resolved("", file)]));
217
+ }
218
+
219
+ const readRepository = Effect.fn("readRepository")(function* (root: string) {
220
+ const fs = yield* FileSystem.FileSystem;
221
+ const path = yield* Path.Path;
222
+ const files = yield* workingTreeFiles(root);
223
+ const read = (file: string) => fs.readFileString(path.join(root, file)).pipe(Effect.orElseSucceed(() => ""));
224
+ const sources = new Map(yield* Effect.forEach(files.filter((file) => SOURCE_FILE.test(file)), (file) =>
225
+ read(file).pipe(Effect.map((text) => [file, text] as const)), { concurrency: 16 }));
226
+ const manifest = files.includes("package.json") ? yield* read("package.json").pipe(Effect.flatMap(decodeManifest)) : {};
227
+ const bunfig = files.includes("bunfig.toml")
228
+ ? yield* read("bunfig.toml").pipe(
229
+ Effect.flatMap((text) => Effect.try({ try: (): unknown => Bun.TOML.parse(text), catch: (cause) => new NarrowingError({ message: "cannot parse bunfig.toml: " + String(cause) }) })),
230
+ Effect.flatMap(decodeBunfig),
231
+ )
232
+ : {};
233
+ return {
234
+ files,
235
+ sources,
236
+ bins: binsOf(manifest),
237
+ self: manifest.name === undefined ? undefined : { name: manifest.name, exports: exportsMap(manifest.exports) },
238
+ preloads: preloadPaths(bunfig),
239
+ } satisfies Repository;
240
+ });
241
+
242
+ const pullRequestEvent = Config.all({
243
+ name: Config.String("GITHUB_EVENT_NAME").pipe(Config.withDefault("")),
244
+ path: Config.String("GITHUB_EVENT_PATH").pipe(Config.withDefault("")),
245
+ });
246
+
247
+ // Only a pull_request run has a base to diff against; every other run keeps the suite whole.
248
+ export const readPullRequestNarrowing = Effect.fn("readPullRequestNarrowing")(function* (root: string, tests: readonly string[]) {
249
+ const event = yield* pullRequestEvent.pipe(Effect.orElseSucceed(() => ({ name: "", path: "" })));
250
+ if (event.name !== "pull_request" || event.path === "") return Option.none<Narrowing>();
251
+ const narrowing = Effect.gen(function* () {
252
+ const text = yield* (yield* FileSystem.FileSystem).readFileString(event.path);
253
+ const { pull_request: pullRequest } = yield* decodePullRequestEvent(text);
254
+ const { base, head } = pullRequestEndsOf(pullRequest);
255
+ const changed = yield* pathsSinceMergeBase(base, head, root);
256
+ return narrowTests(changed, yield* readRepository(root), tests);
257
+ });
258
+ return Option.some(yield* narrowing.pipe(
259
+ Effect.catch((cause) => Effect.succeed<Narrowing>({ kind: "full", because: "the pull request's changes cannot be read: " + cause.message })),
260
+ ));
261
+ });
@@ -1,3 +1,5 @@
1
+ import { Schema } from "effect";
2
+
1
3
  export const TEST_TREE = "tests/";
2
4
  const FIXTURES = "tests/fixtures/";
3
5
  const CANDIDATE_EXTENSIONS = [".tsx", ".ts", ".mts", ".cts", ".jsx", ".js", ".mjs", ".cjs", ".json", ".node"];
@@ -6,6 +8,17 @@ const IMPORT = /(?:from|import)\s*\(?\s*["'](\.[^"']*)["']|require\s*\(\s*["'](\
6
8
  const LISTED_SEGMENTS = /["'`][ \t]*,[ \t]*["'`]/g;
7
9
  const DIRECTORY_ENDS = ['"', "'", "`", '/"', "/'", "/`", "/${"];
8
10
 
11
+ const Bunfig = Schema.Struct({
12
+ test: Schema.optionalKey(Schema.Struct({ preload: Schema.optionalKey(Schema.Union([Schema.String, Schema.Array(Schema.String)])) })),
13
+ });
14
+
15
+ export const decodeBunfig = Schema.decodeUnknownEffect(Bunfig);
16
+
17
+ export function preloadPaths(bunfig: typeof Bunfig.Type): readonly string[] {
18
+ const preload = bunfig.test?.preload ?? [];
19
+ return (typeof preload === "string" ? [preload] : preload).map((entry) => resolved("", entry));
20
+ }
21
+
9
22
  type Needle = { readonly text: string; readonly kind: "file" | "directory"; readonly fixture: string };
10
23
 
11
24
  export function resolved(directory: string, spec: string): string {
@@ -3,7 +3,7 @@ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { escape, minimatch } from "minimatch";
4
4
  import { changedPaths, git, pathsAt, pathsOf, 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
+ import { decodeBunfig, importedFile, importsOf, preloadPaths, readersOf, resolved, TEST_TREE } from "./mutation-scope-graph.ts";
7
7
 
8
8
  const NAME = "mutation-scope";
9
9
  const USAGE = "usage: mutation-scope.ts <base> [full-baseline-report]";
@@ -53,12 +53,6 @@ export type Baseline = typeof BaselineSchema.Type;
53
53
 
54
54
  const decodeBaseline = Schema.decodeUnknownEffect(Schema.fromJsonString(BaselineSchema));
55
55
 
56
- const decodeBunfig = Schema.decodeUnknownEffect(
57
- Schema.Struct({
58
- test: Schema.optionalKey(Schema.Struct({ preload: Schema.optionalKey(Schema.Union([Schema.String, Schema.Array(Schema.String)])) })),
59
- }),
60
- );
61
-
62
56
  export type MutationScope = { readonly scope: readonly string[]; readonly staleTests: readonly string[] };
63
57
 
64
58
  export type Picked = {
@@ -173,8 +167,7 @@ const preloadsAt = Effect.fn("preloadsAt")(function* (rev: string, paths: Readon
173
167
  const text = yield* git(["show", `${rev}:${BUNFIG}`]);
174
168
  const unparsed = () => new ScopeError({ message: `${BUNFIG} at ${rev} does not parse as TOML whose [test] preload is a path or a list of paths` });
175
169
  const bunfig = yield* Effect.try({ try: (): unknown => Bun.TOML.parse(text), catch: unparsed }).pipe(Effect.flatMap(decodeBunfig), Effect.mapError(unparsed));
176
- const preload = bunfig.test?.preload ?? [];
177
- return (typeof preload === "string" ? [preload] : preload).map((entry) => resolved("", entry));
170
+ return preloadPaths(bunfig);
178
171
  });
179
172
 
180
173
  const transitivelyImported = Effect.fn("transitivelyImported")(function* (rev: string, roots: readonly string[], paths: ReadonlySet<string>) {
@@ -20,7 +20,8 @@ export type Finding =
20
20
  | { readonly kind: "mutation"; readonly file: string; readonly before: Score; readonly after: Score; readonly since: string }
21
21
  | { readonly kind: "test"; readonly file: string; readonly name: string }
22
22
  | { readonly kind: "crash"; readonly file: string }
23
- | { readonly kind: "gates"; readonly file: string };
23
+ | { readonly kind: "gates"; readonly file: string }
24
+ | { readonly kind: "mutation-report"; readonly file: string };
24
25
 
25
26
  function scoreOf(file: ReportFile): Score {
26
27
  let detected = 0;
@@ -44,12 +45,13 @@ function fell(before: Score, after: Score): boolean {
44
45
  }
45
46
 
46
47
  // A night judges a key only from a report it read, since a report it lacks leaves every finding in it absent.
47
- export type Observed = { readonly tests: boolean; readonly gates: boolean; readonly mutation: boolean };
48
+ export type Observed = { readonly tests: boolean; readonly gates: boolean; readonly mutation: boolean; readonly mutationReports: ReadonlySet<string> };
48
49
 
49
50
  export function keyOf(finding: Finding): string {
50
51
  if (finding.kind === "mutation") return `mutation:${finding.file}`;
51
52
  if (finding.kind === "test") return `test:${finding.file}${NAME_SEPARATOR}${finding.name}`;
52
53
  if (finding.kind === "gates") return `gates:${finding.file}`;
54
+ if (finding.kind === "mutation-report") return `mutation-report:${finding.file}`;
53
55
  return `test:${finding.file}`;
54
56
  }
55
57
 
@@ -70,8 +72,8 @@ function testFindings(reports: readonly TestReport[]): readonly Finding[] {
70
72
  return [...new Map(failing.map((finding) => [keyOf(finding), finding])).values()];
71
73
  }
72
74
 
73
- export function findingsOf(previous: Option.Option<PreviousNight>, tonight: Tonight, gates: readonly Finding[] = []): readonly Finding[] {
74
- return [...scoreFindings(previous, tonight.scores), ...testFindings(tonight.tests), ...gates].sort((one, other) =>
75
+ export function findingsOf(previous: Option.Option<PreviousNight>, tonight: Tonight, others: readonly Finding[] = []): readonly Finding[] {
76
+ return [...scoreFindings(previous, tonight.scores), ...testFindings(tonight.tests), ...others].sort((one, other) =>
75
77
  keyOf(one).localeCompare(keyOf(other)),
76
78
  );
77
79
  }
@@ -97,6 +99,7 @@ export function gatesDecided(log: string): boolean {
97
99
  export function cleared(key: string, present: ReadonlySet<string>, observed: Observed): boolean {
98
100
  if (present.has(key)) return false;
99
101
  if (key.startsWith("gates:")) return observed.gates;
102
+ if (key.startsWith("mutation-report:")) return observed.mutationReports.has(key.slice("mutation-report:".length));
100
103
  if (!key.startsWith("test:")) return false;
101
104
  const [file] = key.slice("test:".length).split(NAME_SEPARATOR);
102
105
  return observed.tests && !present.has(`test:${file}`);
@@ -4,12 +4,12 @@ import { ChildProcessSpawner } from "effect/process";
4
4
  import { DEFAULT_BRANCH } from "../core/gates.ts";
5
5
  import { runMain, Usage } from "../core/main.ts";
6
6
  import { gh, type GitHubFailure, gitHubJson, REPOSITORY } from "../delivery/github.ts";
7
- import { parseReport as parseMutationReport } from "./mutation-compare.ts";
7
+ import { parseReport as parseMutationReport, type ReportFile } from "./mutation-compare.ts";
8
8
  import { cleared, type Finding, findingsOf, gatesDecided, gatesOf, keyOf, NightRecordSchema, noted, type Observed, type NightRecord, percent, type PreviousNight, scoresOf, type TestReport } from "./night-run-findings.ts";
9
9
  import { parseReport as parseTestReport } from "./test-report.ts";
10
10
 
11
11
  const NAME = "checks-night-run";
12
- const USAGE = `usage: ${NAME} [--mutation <mutation-report>] [--tests <junit-report>]... [--gates <lint-log>]`;
12
+ const USAGE = `usage: ${NAME} [--mutation <mutation-report>]... [--tests <junit-report>]... [--gates <lint-log>]`;
13
13
  export const LABEL = "night-run";
14
14
  const LABEL_COLOR = "5319e7";
15
15
  const LABEL_DESCRIPTION = "A regression a night run on main found";
@@ -23,7 +23,7 @@ class NightRunFailure extends Schema.TaggedError<NightRunFailure>()("NightRunFai
23
23
  message: Schema.String,
24
24
  }) {}
25
25
 
26
- export type Options = { readonly mutation: Option.Option<string>; readonly tests: readonly string[]; readonly gates: Option.Option<string> };
26
+ export type Options = { readonly mutation: readonly string[]; readonly tests: readonly string[]; readonly gates: Option.Option<string> };
27
27
 
28
28
  export const parseArgs = Effect.fnUntraced(function* (argv: readonly string[]): Effect.fn.Return<Options, Usage> {
29
29
  const mutation: string[] = [];
@@ -35,10 +35,10 @@ export const parseArgs = Effect.fnUntraced(function* (argv: readonly string[]):
35
35
  if (into === undefined || value === undefined || value.startsWith("--")) return yield* new Usage({ message: USAGE });
36
36
  into.push(value);
37
37
  }
38
- if (mutation.length > 1 || gates.length > 1 || mutation.length + tests.length + gates.length === 0) {
38
+ if (gates.length > 1 || mutation.length + tests.length + gates.length === 0) {
39
39
  return yield* new Usage({ message: USAGE });
40
40
  }
41
- return { mutation: Option.fromNullishOr(mutation[0]), tests, gates: Option.fromNullishOr(gates[0]) };
41
+ return { mutation, tests, gates: Option.fromNullishOr(gates[0]) };
42
42
  });
43
43
 
44
44
  export type OpenIssue = { readonly number: number; readonly key: string };
@@ -120,6 +120,7 @@ function fullTitleOf(finding: Finding): string {
120
120
  if (finding.kind === "mutation") return `Night run: mutation score fell in ${finding.file}`;
121
121
  if (finding.kind === "test") return `Night run: ${finding.file} > ${finding.name} fails`;
122
122
  if (finding.kind === "gates") return `Night run: nightly gate ${finding.file} failed`;
123
+ if (finding.kind === "mutation-report") return `Night run: no mutation report at ${finding.file}`;
123
124
  return `Night run: ${finding.file} crashed its test worker`;
124
125
  }
125
126
 
@@ -144,6 +145,7 @@ export function findingText(finding: Finding, run: string): string {
144
145
  }
145
146
  if (finding.kind === "test") return `The night run ${run} failed \`${finding.name}\` in \`${finding.file}\`.`;
146
147
  if (finding.kind === "gates") return `The night run ${run} failed nightly gate \`${finding.file}\`.`;
148
+ if (finding.kind === "mutation-report") return `The night run ${run} found no mutation report at \`${finding.file}\`: the mutation job that writes it failed, timed out or was cancelled, so no file it mutates was scored.`;
147
149
  return `The night run ${run} lost every result of \`${finding.file}\`, because the worker running it crashed.`;
148
150
  }
149
151
 
@@ -264,13 +266,33 @@ const writeRecord = Effect.fn("writeRecord")(function* (record: NightRecord) {
264
266
  yield* fs.writeFileString(RECORD, `${JSON.stringify(record, null, 2)}\n`);
265
267
  });
266
268
 
267
- const tonightScores = Effect.fn("tonightScores")(function* (file: string) {
268
- return scoresOf(yield* parseMutationReport(file, yield* readText(file)));
269
+ type MutationReport = { readonly file: string; readonly files: Option.Option<ReadonlyMap<string, ReportFile>> };
270
+
271
+ // A mutation job that timed out or failed uploads no report, so a missing file is a finding rather than a failed read.
272
+ const mutationReport = Effect.fn("mutationReport")(function* (file: string) {
273
+ if (!(yield* (yield* FileSystem.FileSystem).exists(file).pipe(Effect.mapError(unreadable(file))))) return { file, files: Option.none() } satisfies MutationReport;
274
+ return { file, files: Option.some(yield* parseMutationReport(file, yield* readText(file))) } satisfies MutationReport;
269
275
  });
270
276
 
277
+ function missingReports(reports: readonly MutationReport[]): readonly Finding[] {
278
+ return reports.flatMap(({ file, files }): readonly Finding[] => (Option.isNone(files) ? [{ kind: "mutation-report", file }] : []));
279
+ }
280
+
281
+ function readReports(reports: readonly MutationReport[]): ReadonlySet<string> {
282
+ return new Set(reports.flatMap(({ file, files }) => (Option.isSome(files) ? [file] : [])));
283
+ }
284
+
285
+ function tonightScores(reports: readonly MutationReport[]): Option.Option<NightRecord["scores"]> {
286
+ const read = reports.flatMap(({ files }) => (Option.isSome(files) ? [...files.value] : []));
287
+ return reports.some(({ files }) => Option.isSome(files)) ? Option.some(scoresOf(new Map(read))) : Option.none();
288
+ }
289
+
271
290
  type Filing = { readonly onMain: true; readonly filed: readonly Filed[] } | { readonly onMain: false; readonly ref: string };
272
291
 
273
- export function report(run: string, scored: Option.Option<number>, previous: Option.Option<PreviousNight>, findings: readonly Finding[], filing: Filing): string {
292
+ // A night missing one report records nothing, so the next night still compares every file with the last night that scored it.
293
+ type Scored = { readonly files: number; readonly missing: number };
294
+
295
+ export function report(run: string, scored: Option.Option<Scored>, previous: Option.Option<PreviousNight>, findings: readonly Finding[], filing: Filing): string {
274
296
  const against = Option.isNone(scored)
275
297
  ? "had no mutation report, so it compared no score"
276
298
  : Option.isSome(previous)
@@ -280,25 +302,30 @@ export function report(run: string, scored: Option.Option<number>, previous: Opt
280
302
  const listed = filing.onMain ? filing.filed.map((one) => ` ${one.action} #${one.issue} for ${one.key}`) : findings.map((finding) => ` found ${keyOf(finding)}`);
281
303
  const recorded = !filing.onMain
282
304
  ? `${NAME}: the run is on ${filing.ref}, not ${DEFAULT_BRANCH}, so it filed no issue and recorded no score`
283
- : Option.isSome(scored)
284
- ? `${NAME}: recorded ${scored.value} file score(s) in ${RECORD}`
285
- : `${NAME}: recorded no score, since it had no mutation report`;
305
+ : Option.isNone(scored)
306
+ ? `${NAME}: recorded no score, since it had no mutation report`
307
+ : scored.value.missing === 0
308
+ ? `${NAME}: recorded ${scored.value.files} file score(s) in ${RECORD}`
309
+ : `${NAME}: recorded no score, since ${scored.value.missing} mutation report(s) were missing`;
286
310
  return [`${NAME}: the night run ${run} ${against}; ${verdict}`, ...listed, recorded].join("\n");
287
311
  }
288
312
 
289
313
  const nightRun = Effect.gen(function* () {
290
314
  const options = yield* parseArgs(process.argv.slice(2));
291
315
  const actions = yield* actionsRun;
292
- const scores = Option.isSome(options.mutation) ? Option.some(yield* tonightScores(options.mutation.value)) : Option.none<NightRecord["scores"]>();
316
+ const reports = yield* Effect.forEach(options.mutation, mutationReport);
317
+ const missing = missingReports(reports);
318
+ const scores = tonightScores(reports);
293
319
  const tests: TestReport[] = yield* Effect.forEach(options.tests, testReport);
294
320
  const log = Option.isSome(options.gates) ? Option.some(yield* readText(options.gates.value)) : Option.none<string>();
295
321
  const gates: readonly Finding[] = Option.isSome(log) ? gatesOf(log.value) : [];
296
322
  const previous = Option.isSome(scores) ? yield* previousNight(actions) : Option.none<PreviousNight>();
297
- const observed: Observed = { tests: options.tests.length > 0, gates: Option.isSome(log) && gatesDecided(log.value), mutation: Option.isSome(previous) };
298
- const findings = findingsOf(previous, { scores: Option.getOrElse(scores, () => ({})), tests }, gates);
323
+ const observed: Observed = { tests: options.tests.length > 0, gates: Option.isSome(log) && gatesDecided(log.value), mutation: Option.isSome(previous) && missing.length === 0, mutationReports: readReports(reports) };
324
+ const findings = findingsOf(previous, { scores: Option.getOrElse(scores, () => ({})), tests }, [...missing, ...gates]);
299
325
  const filing: Filing = actions.ref === `refs/heads/${DEFAULT_BRANCH}` ? { onMain: true, filed: yield* fileFindings(findings, observed, actions.run) } : { onMain: false, ref: actions.ref };
300
- if (filing.onMain && Option.isSome(scores)) yield* writeRecord({ scores: scores.value });
301
- yield* Console.log(report(actions.run, Option.map(scores, (tonight) => Object.keys(tonight).length), previous, findings, filing));
326
+ if (filing.onMain && Option.isSome(scores) && missing.length === 0) yield* writeRecord({ scores: scores.value });
327
+ const scored = Option.map(scores, (tonight): Scored => ({ files: Object.keys(tonight).length, missing: missing.length }));
328
+ yield* Console.log(report(actions.run, scored, previous, findings, filing));
302
329
  return true;
303
330
  });
304
331
 
@@ -138,13 +138,13 @@ function declarationsIn(source: string, file: string, module: unknown): Declarat
138
138
  return { kind: "valid", declarations };
139
139
  }
140
140
 
141
- function selectedTierFile(file: string, tier: TestTier | undefined): boolean {
141
+ export function selectedTierFile(file: string, tier: TestTier | undefined): boolean {
142
142
  if (file.startsWith("tests/quarantine/")) return false;
143
143
  if (tier === undefined) return !file.startsWith("tests/live/") && !file.startsWith("tests/pixel/");
144
144
  return file.startsWith(`tests/${tier}/`);
145
145
  }
146
146
 
147
- const scanTestFiles = Effect.fn("scanTestFiles")(function* (root: string) {
147
+ export const scanTestFiles = Effect.fn("scanTestFiles")(function* (root: string) {
148
148
  return yield* Effect.tryPromise({
149
149
  try: () =>
150
150
  Promise.all(
@@ -156,7 +156,7 @@ const scanTestFiles = Effect.fn("scanTestFiles")(function* (root: string) {
156
156
  });
157
157
  });
158
158
 
159
- const readDeclarationsIn = Effect.fn("readDeclarationsIn")(function* (root: string, files: readonly string[]) {
159
+ export const readDeclarationsIn = Effect.fn("readDeclarationsIn")(function* (root: string, files: readonly string[]) {
160
160
  const fs = yield* FileSystem.FileSystem;
161
161
  const path = yield* Path.Path;
162
162
  const declarations: SkipDeclaration[] = [];
@@ -4,9 +4,13 @@ import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effec
4
4
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
5
5
  import { TEST_ENTRY_POINT } from "../core/gates.ts";
6
6
  import { runMain, Usage } from "../core/main.ts";
7
+ import { readPullRequestNarrowing } from "./affected-tests.ts";
7
8
  import {
9
+ readDeclarationsIn,
8
10
  readSkipDeclarations,
9
11
  readSmokeDeclarations,
12
+ scanTestFiles,
13
+ selectedTierFile,
10
14
  type Environment,
11
15
  type SkipDeclaration,
12
16
  type TestTier,
@@ -171,7 +175,8 @@ const environmentOf = Config.Boolean("CI").pipe(
171
175
  export type SuiteSelection =
172
176
  | { readonly kind: "full" }
173
177
  | { readonly kind: "tier"; readonly tier: TestTier }
174
- | { readonly kind: "smoke" };
178
+ | { readonly kind: "smoke" }
179
+ | { readonly kind: "files"; readonly files: readonly string[] };
175
180
 
176
181
  export function selectSuite(options: TestOptions, environment: Environment): SuiteSelection {
177
182
  if (options.tier !== undefined) return { kind: "tier", tier: options.tier };
@@ -191,6 +196,7 @@ export function smokeList(manifest: unknown): readonly string[] | undefined {
191
196
  export function suiteArgs(selection: SuiteSelection, smoke: readonly string[]): readonly string[] {
192
197
  if (selection.kind === "tier") return tierArgs(selection.tier);
193
198
  if (selection.kind === "smoke") return ["./tests/unit", ...smoke.map((file) => `./${file}`)];
199
+ if (selection.kind === "files") return selection.files.map((file) => `./${file}`);
194
200
  return [];
195
201
  }
196
202
 
@@ -218,6 +224,37 @@ const readSmokeList = Effect.fn("readSmokeList")(function* (root: string) {
218
224
  return smoke;
219
225
  });
220
226
 
227
+ // A live or pixel tier and --full run whole, since no pull request picks them.
228
+ export function narrowedTier(options: TestOptions, selection: SuiteSelection): { readonly tier: TestTier | undefined } | undefined {
229
+ if (options.full === true) return undefined;
230
+ if (selection.kind === "full") return { tier: undefined };
231
+ if (selection.kind === "tier" && (selection.tier === "unit" || selection.tier === "e2e")) return { tier: selection.tier };
232
+ return undefined;
233
+ }
234
+
235
+ const narrowSelection = Effect.fn("narrowSelection")(function* (options: TestOptions, selection: SuiteSelection, environment: Environment) {
236
+ const narrowed = narrowedTier(options, selection);
237
+ if (environment !== "ci" || narrowed === undefined) return selection;
238
+ const tests = (yield* scanTestFiles(process.cwd())).filter((file) => selectedTierFile(file, narrowed.tier));
239
+ const narrowing = yield* readPullRequestNarrowing(process.cwd(), tests);
240
+ if (Option.isNone(narrowing)) return selection;
241
+ if (narrowing.value.kind === "full") {
242
+ yield* Console.log(`${NAME}: ${narrowing.value.because}, so the whole suite runs`);
243
+ return selection;
244
+ }
245
+ yield* Console.log(`${NAME}: the pull request reaches ${narrowing.value.files.length} of ${tests.length} test file(s)`);
246
+ return { kind: "files", files: narrowing.value.files } satisfies SuiteSelection;
247
+ });
248
+
249
+ const readScope = Effect.fn("readScope")(function* (selection: SuiteSelection, smoke: readonly string[]) {
250
+ if (selection.kind === "smoke") return yield* readSmokeDeclarations(process.cwd(), smoke);
251
+ const declarations =
252
+ selection.kind === "files"
253
+ ? yield* readDeclarationsIn(process.cwd(), selection.files)
254
+ : yield* readSkipDeclarations(process.cwd(), selection.kind === "tier" ? selection.tier : undefined);
255
+ return { files: undefined, declarations };
256
+ });
257
+
221
258
  const runSuite = Effect.fn("runSuite")(function* (outfile: string, args: readonly string[], workers: number | undefined) {
222
259
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
223
260
  const command = ["test", "--randomize", `--parallel=${workerCount(workers)}`, ...args, ...reporterArgs(outfile)];
@@ -239,15 +276,13 @@ const testEntry = Effect.gen(function* () {
239
276
  const fs = yield* FileSystem.FileSystem;
240
277
  const path = yield* Path.Path;
241
278
  const environment = yield* environmentOf;
242
- const selection = selectSuite(options, environment);
279
+ const selection = yield* narrowSelection(options, selectSuite(options, environment), environment);
280
+ if (selection.kind === "files" && selection.files.length === 0) {
281
+ yield* Console.log(`${NAME}: the pull request changes no file a test reads, so no test ran`);
282
+ return true;
283
+ }
243
284
  const smoke = selection.kind === "smoke" ? yield* readSmokeList(process.cwd()) : [];
244
- const scope =
245
- selection.kind === "smoke"
246
- ? yield* readSmokeDeclarations(process.cwd(), smoke)
247
- : {
248
- files: undefined,
249
- declarations: yield* readSkipDeclarations(process.cwd(), selection.kind === "tier" ? selection.tier : undefined),
250
- };
285
+ const scope = yield* readScope(selection, smoke);
251
286
  if (scope.files?.length === 0) {
252
287
  yield* Console.log(`${NAME}: no unit test and no smoke list, so this local run ran nothing; declare checks.smoke or pass ${FULL}`);
253
288
  return true;
@@ -257,6 +292,18 @@ const testEntry = Effect.gen(function* () {
257
292
  const outfile = path.join(yield* fs.makeTempDirectoryScoped({ prefix: "checks-test-" }), "junit.xml");
258
293
  const suitePassed =
259
294
  (yield* runSuite(outfile, suiteArgs(selection, smoke), options.workers)) === ChildProcessSpawner.ExitCode(0);
295
+ return yield* judgeRun(outfile, junit, suitePassed, declarations, environment);
296
+ }).pipe(Effect.scoped);
297
+
298
+ const judgeRun = Effect.fn("judgeRun")(function* (
299
+ outfile: string,
300
+ junit: Option.Option<string>,
301
+ suitePassed: boolean,
302
+ declarations: Parameters<typeof judgeSkips>[1],
303
+ environment: Environment,
304
+ ) {
305
+ const fs = yield* FileSystem.FileSystem;
306
+ const path = yield* Path.Path;
260
307
  const written = yield* fs.exists(outfile);
261
308
  if (written && Option.isSome(junit)) {
262
309
  yield* fs.makeDirectory(path.dirname(junit.value), { recursive: true });
@@ -276,6 +323,6 @@ const testEntry = Effect.gen(function* () {
276
323
  const verdict = judgeSkips(results, judged, environment);
277
324
  yield* passes(verdict) ? Console.log(report(verdict)) : Console.error(report(verdict));
278
325
  return suitePassed && crashedFiles.length === 0 && passes(verdict);
279
- }).pipe(Effect.scoped);
326
+ });
280
327
 
281
328
  if (import.meta.main) runMain(NAME, testEntry);