@avi2dg/checks 0.43.0 → 0.44.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 +12 -0
- package/README.md +5 -2
- package/{commitlint.config.js → commitlint.config.ts} +1 -1
- package/dist/mutation/mutation-guard-plugin.js +40 -0
- package/dist/mutation/mutation-refusal.js +23 -0
- package/docs/configs/commit-messages.md +2 -2
- package/docs/design.md +20 -0
- package/docs/gates/checks-ci-wiring.md +3 -2
- package/docs/gates/checks-copy.md +113 -0
- package/docs/gates/checks-docs-only.md +3 -2
- package/docs/gates/checks-docs.md +3 -0
- package/docs/gates/checks-flake.md +49 -1
- package/docs/gates/checks-lint.md +2 -1
- package/docs/gates/checks-mutation-baseline.md +4 -2
- package/docs/gates/checks-mutation-compare.md +33 -8
- package/docs/gates/checks-mutation-scope.md +140 -0
- package/docs/gates/checks-mutation.md +11 -7
- package/docs/gates/checks-no-plain-js.md +60 -0
- package/docs/gates/checks-test.md +5 -2
- package/package.json +18 -6
- package/src/core/declared-inputs.ts +76 -0
- package/src/core/gates.ts +4 -0
- package/src/core/git.ts +9 -0
- package/src/delivery/ci-wiring.ts +25 -11
- package/src/delivery/commit-title.ts +2 -2
- package/src/docs/copy-html.ts +110 -0
- package/src/docs/copy-text.ts +70 -0
- package/src/docs/copy.ts +70 -0
- package/src/docs/prose-matchers.ts +14 -14
- package/src/quality/frontend-syntax.ts +9 -52
- package/src/quality/no-plain-js.ts +71 -0
- package/src/testing/flake-preload.ts +4 -0
- package/src/testing/flake.ts +142 -16
- package/src/testing/{mutation-scope.js → mutation-refusal.ts} +11 -9
- package/src/testing/mutation-scope.ts +354 -0
- package/src/testing/mutation.ts +1 -1
- package/src/testing/test-layout.ts +4 -20
- package/src/testing/test-skips.ts +1 -1
- package/src/testing/test.ts +19 -3
- package/stryker.preset.js +3 -3
- package/src/testing/mutation-guard-plugin.js +0 -13
package/src/testing/flake.ts
CHANGED
|
@@ -3,12 +3,15 @@ import { Config, Console, Effect, FileSystem, Option, Path, Random, Schema } fro
|
|
|
3
3
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
4
4
|
import { runMain, Usage } from "../core/main.ts";
|
|
5
5
|
import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
|
|
6
|
+
import { scriptOptions } from "./test.ts";
|
|
6
7
|
|
|
7
8
|
export type Run = {
|
|
8
9
|
readonly seed: number;
|
|
9
10
|
readonly passed: boolean;
|
|
10
11
|
readonly tests: number;
|
|
11
12
|
readonly failed: readonly TestResult[];
|
|
13
|
+
readonly crashedFiles: readonly string[];
|
|
14
|
+
readonly assignment: readonly (readonly string[])[];
|
|
12
15
|
};
|
|
13
16
|
|
|
14
17
|
export type Failure = {
|
|
@@ -18,20 +21,33 @@ export type Failure = {
|
|
|
18
21
|
readonly seeds: readonly number[];
|
|
19
22
|
};
|
|
20
23
|
|
|
21
|
-
export type RunRecord = Omit<Run, "failed"> & { readonly failed: readonly string[] };
|
|
24
|
+
export type RunRecord = Omit<Run, "failed" | "crashedFiles" | "assignment"> & { readonly failed: readonly string[] };
|
|
25
|
+
|
|
26
|
+
export type FailingWorker = { readonly seed: number; readonly files: readonly string[] };
|
|
27
|
+
|
|
28
|
+
export type Replay = FailingWorker & { readonly command: string };
|
|
22
29
|
|
|
23
30
|
export type FlakeRecord = {
|
|
24
31
|
readonly runs: readonly RunRecord[];
|
|
25
32
|
readonly failures: readonly Failure[];
|
|
26
33
|
readonly outsideTests: readonly number[];
|
|
34
|
+
readonly parallel?: { readonly workers: number; readonly replays: readonly Replay[] };
|
|
27
35
|
};
|
|
28
36
|
|
|
37
|
+
export class ManifestError extends Schema.TaggedError<ManifestError>()("ManifestError", {
|
|
38
|
+
message: Schema.String,
|
|
39
|
+
}) {}
|
|
40
|
+
|
|
29
41
|
type Plan = { readonly seeds: readonly number[] } | { readonly runs: number };
|
|
30
42
|
|
|
31
43
|
const NAME = "checks-flake";
|
|
32
44
|
const USAGE = `usage: ${NAME} [--runs <count> | --seed <seed>...] [--report <file>]`;
|
|
33
45
|
const DEFAULT_RUNS = 10;
|
|
34
46
|
const MAX_SEED = 2 ** 32 - 1;
|
|
47
|
+
const WORKER_LOG = "CHECKS_FLAKE_WORKER_LOG";
|
|
48
|
+
const PRELOAD = "flake-preload.ts";
|
|
49
|
+
const SHELL_SAFE = /^[\w./-]+$/;
|
|
50
|
+
const SILENT = { stdin: "ignore", stdout: "ignore", stderr: "ignore" } as const;
|
|
35
51
|
|
|
36
52
|
const wholeBetween = (minimum: number, maximum: number) =>
|
|
37
53
|
Schema.decodeUnknownEffect(Schema.NumberFromString.check(Schema.isInt(), Schema.isBetween({ minimum, maximum })));
|
|
@@ -60,23 +76,63 @@ const parseArgs = Effect.fnUntraced(function* (args: readonly string[]) {
|
|
|
60
76
|
return { plan, report };
|
|
61
77
|
});
|
|
62
78
|
|
|
79
|
+
const Manifest = Schema.fromJsonString(Schema.Struct({ scripts: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)) }));
|
|
80
|
+
const decodeManifest = Schema.decodeUnknownEffect(Manifest);
|
|
81
|
+
const decodeWorkerLine = Schema.decodeUnknownEffect(
|
|
82
|
+
Schema.fromJsonString(Schema.Struct({ worker: Schema.Int, file: Schema.String })),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const workersOf = Effect.fn("workersOf")(function* (root: string) {
|
|
86
|
+
const fs = yield* FileSystem.FileSystem;
|
|
87
|
+
const file = (yield* Path.Path).join(root, "package.json");
|
|
88
|
+
if (!(yield* fs.exists(file))) return undefined;
|
|
89
|
+
const { scripts } = yield* fs.readFileString(file).pipe(
|
|
90
|
+
Effect.flatMap(decodeManifest),
|
|
91
|
+
Effect.mapError((cause) => new ManifestError({ message: `cannot read ${file}: ${cause.message}` })),
|
|
92
|
+
);
|
|
93
|
+
return scriptOptions(scripts?.["test"])?.workers;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const assignmentOf = Effect.fn("assignmentOf")(function* (log: string) {
|
|
97
|
+
const fs = yield* FileSystem.FileSystem;
|
|
98
|
+
const path = yield* Path.Path;
|
|
99
|
+
if (!(yield* fs.exists(log))) return [];
|
|
100
|
+
const byWorker = new Map<number, string[]>();
|
|
101
|
+
for (const line of (yield* fs.readFileString(log)).split("\n").filter((entry) => entry !== "")) {
|
|
102
|
+
const { worker, file } = yield* decodeWorkerLine(line).pipe(
|
|
103
|
+
Effect.mapError((cause) => new ReportError({ message: `the worker log is malformed: ${cause.message}` })),
|
|
104
|
+
);
|
|
105
|
+
byWorker.set(worker, [...(byWorker.get(worker) ?? []), path.relative(process.cwd(), file)]);
|
|
106
|
+
}
|
|
107
|
+
return [...byWorker.values()];
|
|
108
|
+
});
|
|
109
|
+
|
|
63
110
|
const seedsOf = (plan: Plan) =>
|
|
64
111
|
"seeds" in plan ? Effect.succeed(plan.seeds) : Effect.replicateEffect(Random.nextIntBetween(0, MAX_SEED), plan.runs);
|
|
65
112
|
|
|
66
|
-
const
|
|
67
|
-
const fs = yield* FileSystem.FileSystem;
|
|
113
|
+
const spawnSuite = Effect.fn("spawnSuite")(function* (seed: number, outfile: string, workers: number | undefined, log: string) {
|
|
68
114
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
69
|
-
const args = ["test", "--randomize", `--seed=${seed}
|
|
70
|
-
|
|
71
|
-
|
|
115
|
+
const args = ["test", "--randomize", `--seed=${seed}`];
|
|
116
|
+
if (workers === undefined) return yield* spawner.exitCode(ChildProcess.make(process.execPath, [...args, ...reporterArgs(outfile)], SILENT));
|
|
117
|
+
const preload = (yield* Path.Path).join(import.meta.dir, PRELOAD);
|
|
118
|
+
const parallelArgs = [`--parallel=${workers}`, "--preload", preload, ...reporterArgs(outfile)];
|
|
119
|
+
return yield* spawner.exitCode(
|
|
120
|
+
ChildProcess.make(process.execPath, [...args, ...parallelArgs], { ...SILENT, env: { [WORKER_LOG]: log }, extendEnv: true }),
|
|
72
121
|
);
|
|
73
|
-
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const runOnce = Effect.fn("runOnce")(function* (seed: number, outfile: string, workers: number | undefined) {
|
|
125
|
+
const fs = yield* FileSystem.FileSystem;
|
|
126
|
+
const log = `${outfile}.workers`;
|
|
127
|
+
const passed = (yield* spawnSuite(seed, outfile, workers, log)) === ChildProcessSpawner.ExitCode(0);
|
|
128
|
+
const assignment = yield* assignmentOf(log);
|
|
74
129
|
if (!(yield* fs.exists(outfile))) {
|
|
75
130
|
if (passed) return yield* new ReportError({ message: `bun test passed with seed ${seed} but wrote no report` });
|
|
76
|
-
return { seed, passed, tests: 0, failed: [] } satisfies Run;
|
|
131
|
+
return { seed, passed, tests: 0, failed: [], crashedFiles: [], assignment } satisfies Run;
|
|
77
132
|
}
|
|
78
|
-
const { results } = yield* parseReport(yield* fs.readFileString(outfile));
|
|
79
|
-
|
|
133
|
+
const { results, crashedFiles } = yield* parseReport(yield* fs.readFileString(outfile));
|
|
134
|
+
const failed = results.filter((result) => result.outcome === "failed");
|
|
135
|
+
return { seed, passed, tests: results.length, failed, crashedFiles, assignment } satisfies Run;
|
|
80
136
|
});
|
|
81
137
|
|
|
82
138
|
function outcomeOf(run: Run): string {
|
|
@@ -88,7 +144,16 @@ function nameOf(result: TestResult): string {
|
|
|
88
144
|
return `${result.file}${NAME_SEPARATOR}${result.name}`;
|
|
89
145
|
}
|
|
90
146
|
|
|
91
|
-
export function
|
|
147
|
+
export function failingWorkersOf(run: Run): readonly FailingWorker[] {
|
|
148
|
+
if (run.passed) return [];
|
|
149
|
+
const failing = new Set([...run.failed.map((result) => result.file), ...run.crashedFiles]);
|
|
150
|
+
return run.assignment.flatMap((files) => {
|
|
151
|
+
const last = files.findLastIndex((file) => failing.has(file));
|
|
152
|
+
return last === -1 ? [] : [{ seed: run.seed, files: files.slice(0, last + 1) }];
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function recordOf(runs: readonly Run[], parallel?: FlakeRecord["parallel"]): FlakeRecord {
|
|
92
157
|
const failures = new Map<string, { file: string; test: string; line: number; seeds: number[] }>();
|
|
93
158
|
for (const run of runs) {
|
|
94
159
|
const failing = new Map(run.failed.map((result) => [nameOf(result), result]));
|
|
@@ -102,9 +167,63 @@ export function recordOf(runs: readonly Run[]): FlakeRecord {
|
|
|
102
167
|
runs: runs.map(({ seed, passed, tests, failed }) => ({ seed, passed, tests, failed: failed.map(nameOf) })),
|
|
103
168
|
failures: [...failures.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line),
|
|
104
169
|
outsideTests: runs.filter((run) => !run.passed && run.failed.length === 0).map((run) => run.seed),
|
|
170
|
+
...(parallel === undefined ? {} : { parallel }),
|
|
105
171
|
};
|
|
106
172
|
}
|
|
107
173
|
|
|
174
|
+
function shellWord(file: string): string {
|
|
175
|
+
const word = `./${file}`;
|
|
176
|
+
return SHELL_SAFE.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function replayFlags(seed: number): readonly string[] {
|
|
180
|
+
return ["--isolate", "--randomize", `--seed=${seed}`];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function replayCommand(seed: number, listed: readonly string[]): string {
|
|
184
|
+
return ["bun test", ...replayFlags(seed), ...listed.map(shellWord)].join(" ");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function probeFile(log: string, slot: number): string {
|
|
188
|
+
return `import { test } from "bun:test";\nimport { appendFileSync } from "node:fs";\nappendFileSync(${JSON.stringify(log)}, "${slot}\\n");\ntest("probe", () => {});\n`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Bun shuffles the listed files too, by a permutation of their positions that the seed and their count fix.
|
|
192
|
+
const replayOf = Effect.fn("replayOf")(function* (worker: FailingWorker, dir: string) {
|
|
193
|
+
const fs = yield* FileSystem.FileSystem;
|
|
194
|
+
const path = yield* Path.Path;
|
|
195
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
196
|
+
const probe = yield* fs.makeTempDirectory({ directory: dir, prefix: "replay-" });
|
|
197
|
+
const log = path.join(probe, "order");
|
|
198
|
+
const slots = worker.files.map((_, slot) => `./${slot}.test.ts`);
|
|
199
|
+
yield* Effect.forEach(slots, (name, slot) => fs.writeFileString(path.join(probe, name), probeFile(log, slot)));
|
|
200
|
+
yield* spawner.exitCode(ChildProcess.make(process.execPath, ["test", ...replayFlags(worker.seed), ...slots], { ...SILENT, cwd: probe }));
|
|
201
|
+
const ran = (yield* fs.readFileString(log)).split("\n").filter((entry) => entry !== "").map(Number);
|
|
202
|
+
const listed = slots.map((_, slot) => worker.files[ran.indexOf(slot)]);
|
|
203
|
+
if (ran.length !== slots.length || !listed.every((file): file is string => file !== undefined)) {
|
|
204
|
+
return yield* new ReportError({ message: `bun ran the replay probe for seed ${worker.seed} in the order ${ran.join(", ")}` });
|
|
205
|
+
}
|
|
206
|
+
return { ...worker, command: replayCommand(worker.seed, listed) } satisfies Replay;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
function replayLines({ workers, replays }: NonNullable<FlakeRecord["parallel"]>): readonly string[] {
|
|
210
|
+
const lines =
|
|
211
|
+
replays.length === 0
|
|
212
|
+
? []
|
|
213
|
+
: [
|
|
214
|
+
"",
|
|
215
|
+
"Reproduce a failing run with the files its failing worker ran, listed so the seed runs them in that order:",
|
|
216
|
+
"",
|
|
217
|
+
...replays.map((replay) => `- seed ${replay.seed}: \`${replay.command}\``),
|
|
218
|
+
];
|
|
219
|
+
if (workers === 1) return lines;
|
|
220
|
+
return [
|
|
221
|
+
...lines,
|
|
222
|
+
"",
|
|
223
|
+
`Rerunning a seed with bun test --randomize --seed=<seed> --parallel=${workers} reproduces a failing run only some of the time, because which worker takes each file is a race.`,
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
|
|
108
227
|
function cell(text: string): string {
|
|
109
228
|
return text.replaceAll("|", "\\|");
|
|
110
229
|
}
|
|
@@ -112,8 +231,9 @@ function cell(text: string): string {
|
|
|
112
231
|
export function summary(record: FlakeRecord): string {
|
|
113
232
|
const total = record.runs.length;
|
|
114
233
|
const failedRuns = record.runs.filter((run) => !run.passed).length;
|
|
115
|
-
|
|
116
|
-
|
|
234
|
+
const on = record.parallel === undefined ? "" : ` on ${record.parallel.workers} worker(s)`;
|
|
235
|
+
if (failedRuns === 0) return `${NAME}: ${total} run(s) passed${on}, with seeds ${record.runs.map((run) => run.seed).join(", ")}`;
|
|
236
|
+
const lines = [`${NAME}: ${failedRuns} of ${total} run(s) failed${on}, ${record.failures.length} test(s) failing in them`];
|
|
117
237
|
if (record.failures.length > 0) {
|
|
118
238
|
lines.push("", "| Test | Failed | Seeds |", "| --- | --- | --- |");
|
|
119
239
|
for (const failure of record.failures) {
|
|
@@ -124,7 +244,8 @@ export function summary(record: FlakeRecord): string {
|
|
|
124
244
|
if (record.outsideTests.length > 0) {
|
|
125
245
|
lines.push("", `${record.outsideTests.length} run(s) failed outside any test, with seeds ${record.outsideTests.join(", ")}`);
|
|
126
246
|
}
|
|
127
|
-
lines.push("", "Reproduce a failing run with bun test --randomize --seed=<seed>.");
|
|
247
|
+
if (record.parallel === undefined) lines.push("", "Reproduce a failing run with bun test --randomize --seed=<seed>.");
|
|
248
|
+
else lines.push(...replayLines(record.parallel));
|
|
128
249
|
return lines.join("\n");
|
|
129
250
|
}
|
|
130
251
|
|
|
@@ -133,14 +254,19 @@ const flake = Effect.gen(function* () {
|
|
|
133
254
|
const path = yield* Path.Path;
|
|
134
255
|
const { plan, report } = yield* parseArgs(process.argv.slice(2));
|
|
135
256
|
const seeds = yield* seedsOf(plan);
|
|
257
|
+
const workers = yield* workersOf(process.cwd());
|
|
136
258
|
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-flake-" });
|
|
137
259
|
|
|
138
260
|
const runs = yield* Effect.forEach(seeds, (seed, index) =>
|
|
139
|
-
runOnce(seed, path.join(dir, `${index}.xml`)).pipe(
|
|
261
|
+
runOnce(seed, path.join(dir, `${index}.xml`), workers).pipe(
|
|
140
262
|
Effect.tap((run) => Console.log(`${NAME}: run ${index + 1} of ${seeds.length}, seed ${seed}: ${outcomeOf(run)}`)),
|
|
141
263
|
),
|
|
142
264
|
);
|
|
143
|
-
const
|
|
265
|
+
const parallel =
|
|
266
|
+
workers === undefined
|
|
267
|
+
? undefined
|
|
268
|
+
: { workers, replays: yield* Effect.forEach(runs.flatMap(failingWorkersOf), (worker) => replayOf(worker, dir)) };
|
|
269
|
+
const record = recordOf(runs, parallel);
|
|
144
270
|
const text = summary(record);
|
|
145
271
|
yield* Console.log(text);
|
|
146
272
|
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
const NON_RUNS: readonly string[] = ["--help", "-h", "--version"];
|
|
2
|
+
const MUTATE_FLAGS: readonly string[] = ["--mutate", "-m"];
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
const MUTATE_FLAGS = ["--mutate", "-m"];
|
|
5
|
-
|
|
6
|
-
function namesGlob(args) {
|
|
4
|
+
function namesGlob(args: readonly string[]): boolean {
|
|
7
5
|
return args.some(
|
|
8
6
|
(arg, index) =>
|
|
9
7
|
(MUTATE_FLAGS.includes(arg) && (args[index + 1] ?? "") !== "") ||
|
|
@@ -11,17 +9,21 @@ function namesGlob(args) {
|
|
|
11
9
|
);
|
|
12
10
|
}
|
|
13
11
|
|
|
14
|
-
function allowedAnyway(args, ci) {
|
|
12
|
+
function allowedAnyway(args: readonly string[], ci: string): boolean {
|
|
15
13
|
return ci === "true" || args.some((arg) => NON_RUNS.includes(arg)) || namesGlob(args);
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
export function fullRunRefusal(args, ci) {
|
|
16
|
+
export function fullRunRefusal(args: readonly string[], ci: string): string | undefined {
|
|
19
17
|
if (allowedAnyway(args, ci) || (args.includes("--incremental") && !args.includes("--force"))) return undefined;
|
|
20
18
|
return "refusing a full mutation run outside CI; start the same run in CI with `gh workflow run mutation`, or scope this run with `--mutate <glob>` or `--incremental`";
|
|
21
19
|
}
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
export function missingReportRefusal(
|
|
22
|
+
args: readonly string[],
|
|
23
|
+
ci: string,
|
|
24
|
+
incrementalFile: string,
|
|
25
|
+
reportExists: (path: string) => boolean,
|
|
26
|
+
): string | undefined {
|
|
25
27
|
if (allowedAnyway(args, ci) || reportExists(incrementalFile)) return undefined;
|
|
26
28
|
return `refusing a full mutation run outside CI: \`--incremental\` finds no report at ${incrementalFile} to reuse; scope this run with \`--mutate <glob>\`, or start the full baseline in CI with \`gh workflow run mutation\``;
|
|
27
29
|
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
+
import { escape, minimatch } from "minimatch";
|
|
4
|
+
import { changedPaths, git, pathsAt, rangeEnds, type Change } from "../core/git.ts";
|
|
5
|
+
import { runMain, Usage } from "../core/main.ts";
|
|
6
|
+
|
|
7
|
+
const NAME = "mutation-scope";
|
|
8
|
+
const USAGE = "usage: mutation-scope.ts <base> [full-baseline-report]";
|
|
9
|
+
const TEST_TREE = "tests/";
|
|
10
|
+
const UNIT_TESTS = "tests/unit/";
|
|
11
|
+
const FIXTURES = "tests/fixtures/";
|
|
12
|
+
const TEST_FILE = /\.test\.tsx?$/;
|
|
13
|
+
const READ_FILE = /\.tsx?$/;
|
|
14
|
+
const BUNFIG = "bunfig.toml";
|
|
15
|
+
const MUTATION_RANGE = /:\d+(?::\d+)?-\d+(?::\d+)?$/;
|
|
16
|
+
const IMPORTED_EXTENSIONS = [".tsx", ".ts", ".mts", ".mjs", ".js"];
|
|
17
|
+
|
|
18
|
+
export const STRYKER_DEFAULT_MUTATE = [
|
|
19
|
+
"{src,lib}/**/!(*.+(s|S)pec|*.+(t|T)est).+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)",
|
|
20
|
+
"!{src,lib}/**/__tests__/**/*.+(cjs|mjs|js|ts|mts|cts|jsx|tsx|html|vue|svelte)",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Stryker loads the first of these that exists, in this order.
|
|
24
|
+
const STRYKER_CONFIGS = ["", "."].flatMap((dot) =>
|
|
25
|
+
[".conf", ".config"].flatMap((kind) => ["json", "js", "mjs", "cjs"].map((extension) => `${dot}stryker${kind}.${extension}`)),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export class ScopeError extends Schema.TaggedError<ScopeError>()("ScopeError", {
|
|
29
|
+
message: Schema.String,
|
|
30
|
+
}) {}
|
|
31
|
+
|
|
32
|
+
const StrykerConfig = Schema.Struct({ mutate: Schema.optionalKey(Schema.Array(Schema.String)) });
|
|
33
|
+
const decodeJsonConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(StrykerConfig));
|
|
34
|
+
const decodeConfigModule = Schema.decodeUnknownEffect(Schema.Struct({ default: StrykerConfig }));
|
|
35
|
+
|
|
36
|
+
const BaselineSchema = Schema.Struct({
|
|
37
|
+
files: Schema.Record(
|
|
38
|
+
Schema.String,
|
|
39
|
+
Schema.Struct({
|
|
40
|
+
mutants: Schema.Array(
|
|
41
|
+
Schema.Struct({
|
|
42
|
+
coveredBy: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
43
|
+
killedBy: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
testFiles: Schema.Record(
|
|
49
|
+
Schema.String,
|
|
50
|
+
Schema.Struct({ source: Schema.optionalKey(Schema.String), tests: Schema.Array(Schema.Struct({ id: Schema.String })) }),
|
|
51
|
+
),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export type Baseline = typeof BaselineSchema.Type;
|
|
55
|
+
|
|
56
|
+
const decodeBaseline = Schema.decodeUnknownEffect(Schema.fromJsonString(BaselineSchema));
|
|
57
|
+
|
|
58
|
+
const decodeBunfig = Schema.decodeUnknownEffect(
|
|
59
|
+
Schema.Struct({
|
|
60
|
+
test: Schema.optionalKey(Schema.Struct({ preload: Schema.optionalKey(Schema.Union([Schema.String, Schema.Array(Schema.String)])) })),
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
export type MutationScope = { readonly scope: readonly string[]; readonly staleTests: readonly string[] };
|
|
65
|
+
|
|
66
|
+
export type Picked = {
|
|
67
|
+
readonly scope: ReadonlySet<string>;
|
|
68
|
+
readonly tests: ReadonlySet<string>;
|
|
69
|
+
readonly touched: ReadonlySet<string>;
|
|
70
|
+
readonly paths: ReadonlySet<string>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const isUnitTest = (path: string): boolean => path.startsWith(UNIT_TESTS) && TEST_FILE.test(path);
|
|
74
|
+
|
|
75
|
+
const absolute = (root: string, path: string): string => `/${resolved(path.startsWith("/") ? "" : root, path)}`;
|
|
76
|
+
|
|
77
|
+
// --mutate replaces the config's globs, so the scope applies the negated ones itself, resolving each against root as Stryker does.
|
|
78
|
+
export function mutatedBy(patterns: readonly string[], root: string): (file: string) => boolean {
|
|
79
|
+
const rules = patterns.map((pattern) => {
|
|
80
|
+
const negated = pattern.startsWith("!");
|
|
81
|
+
return { glob: absolute(root, negated ? pattern.slice(1) : pattern), mutated: !negated };
|
|
82
|
+
});
|
|
83
|
+
return (file) => rules.reduce((mutated, rule) => (minimatch(absolute(root, file), rule.glob) ? rule.mutated : mutated), false);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function scopedNames(change: Change, mutated: (file: string) => boolean): readonly string[] {
|
|
87
|
+
if (change.kind === "deleted") return [];
|
|
88
|
+
if (change.kind === "renamed" && mutated(change.from) && mutated(change.path)) return [change.from, change.path];
|
|
89
|
+
return mutated(change.path) ? [change.path] : [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const pathsOf = (change: Change): readonly string[] => (change.kind === "renamed" ? [change.from, change.path] : [change.path]);
|
|
93
|
+
|
|
94
|
+
export function pickFromDiff(changes: readonly Change[], mutated: (file: string) => boolean): Picked {
|
|
95
|
+
const paths = changes.flatMap(pathsOf);
|
|
96
|
+
return {
|
|
97
|
+
scope: new Set(changes.flatMap((change) => scopedNames(change, mutated))),
|
|
98
|
+
tests: new Set(paths.filter(isUnitTest)),
|
|
99
|
+
touched: new Set(paths.filter((path) => path.startsWith(TEST_TREE))),
|
|
100
|
+
paths: new Set(paths),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
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
|
+
}
|
|
188
|
+
|
|
189
|
+
// Names what every unit test loads that the pull request changes: the preload list, a preload, or a file a preload imports.
|
|
190
|
+
export function preloadChange(
|
|
191
|
+
changed: ReadonlySet<string>,
|
|
192
|
+
preloads: { readonly atBase: readonly string[]; readonly atHead: readonly string[] },
|
|
193
|
+
loaded: ReadonlySet<string>,
|
|
194
|
+
): string | undefined {
|
|
195
|
+
if (preloads.atBase.join("\n") !== preloads.atHead.join("\n")) return `the [test] preload list in ${BUNFIG}`;
|
|
196
|
+
return [...preloads.atBase, ...loaded].find((path) => changed.has(path));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Stryker leaves coveredBy empty on a static mutant, so only killedBy names its tests, and all of them only from a run without bail.
|
|
200
|
+
export function coveringTests(baseline: Baseline, tests: ReadonlySet<string>, mutated: (file: string) => boolean): Map<string, readonly string[]> {
|
|
201
|
+
const owners = new Map(Object.entries(baseline.testFiles).flatMap(([file, { tests: listed }]) => listed.map(({ id }) => [id, file] as const)));
|
|
202
|
+
const covering = new Map<string, readonly string[]>();
|
|
203
|
+
for (const [source, { mutants }] of Object.entries(baseline.files)) {
|
|
204
|
+
if (!mutated(source)) continue;
|
|
205
|
+
const owning = mutants.flatMap(({ coveredBy = [], killedBy = [] }) => [...coveredBy, ...killedBy].map((id) => owners.get(id) ?? ""));
|
|
206
|
+
const changed = [...new Set(owning)].filter((file) => tests.has(file)).toSorted();
|
|
207
|
+
if (changed.length > 0) covering.set(source, changed);
|
|
208
|
+
}
|
|
209
|
+
return covering;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Stryker reads each --mutate entry as a glob whose backslashes are separators, and the incremental state keys each test by its literal path.
|
|
213
|
+
export function envLines({ scope, staleTests }: MutationScope): string {
|
|
214
|
+
const globs = scope.toSorted().map((file) => escape(file, { windowsPathsNoEscape: true, magicalBraces: true }));
|
|
215
|
+
return [`SCOPE=${globs.join(",")}`, `STALE_TESTS=${staleTests.toSorted().join(",")}`].join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export const parseArgs = Effect.fnUntraced(function* (argv: readonly string[]): Effect.fn.Return<{ readonly base: string; readonly baseline: string | undefined }, Usage> {
|
|
219
|
+
const [base, baseline, ...extra] = argv;
|
|
220
|
+
if (base === undefined || base.startsWith("-") || baseline?.startsWith("-") === true || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
221
|
+
return { base, baseline };
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const loadConfig = Effect.fn("loadConfig")(function* (file: string) {
|
|
225
|
+
const fs = yield* FileSystem.FileSystem;
|
|
226
|
+
const path = yield* Path.Path;
|
|
227
|
+
const refused = (reason: string) => () => new ScopeError({ message: `${file} ${reason}` });
|
|
228
|
+
if (file.endsWith(".json")) {
|
|
229
|
+
return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeJsonConfig), Effect.mapError(refused("is not a Stryker config whose mutate is a list of globs")));
|
|
230
|
+
}
|
|
231
|
+
const url = yield* path.toFileUrl(path.resolve(file)).pipe(Effect.mapError(refused("cannot be addressed")));
|
|
232
|
+
const loaded = yield* Effect.tryPromise({
|
|
233
|
+
try: (): Promise<unknown> => import(url.href),
|
|
234
|
+
catch: (cause) => new ScopeError({ message: `cannot load ${file}: ${String(cause)}` }),
|
|
235
|
+
});
|
|
236
|
+
const { default: config } = yield* decodeConfigModule(loaded).pipe(Effect.mapError(refused("exports no Stryker config whose mutate is a list of globs")));
|
|
237
|
+
return config;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const configuredMutate = Effect.gen(function* () {
|
|
241
|
+
const fs = yield* FileSystem.FileSystem;
|
|
242
|
+
for (const file of STRYKER_CONFIGS) {
|
|
243
|
+
if (yield* fs.exists(file)) return (yield* loadConfig(file)).mutate ?? STRYKER_DEFAULT_MUTATE;
|
|
244
|
+
}
|
|
245
|
+
return STRYKER_DEFAULT_MUTATE;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const mutatePatterns = Effect.gen(function* () {
|
|
249
|
+
const patterns = yield* configuredMutate;
|
|
250
|
+
const ranged = patterns.filter((pattern) => MUTATION_RANGE.test(pattern));
|
|
251
|
+
if (ranged.length > 0) {
|
|
252
|
+
return yield* new ScopeError({ message: `the Stryker mutate entries ${ranged.join(" ")} name a mutation range, which a scope of whole files cannot keep` });
|
|
253
|
+
}
|
|
254
|
+
return patterns;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const preloadsAt = Effect.fn("preloadsAt")(function* (rev: string, paths: ReadonlySet<string>) {
|
|
258
|
+
if (!paths.has(BUNFIG)) return [];
|
|
259
|
+
const text = yield* git(["show", `${rev}:${BUNFIG}`]);
|
|
260
|
+
const unparsed = () => new ScopeError({ message: `${BUNFIG} at ${rev} does not parse as TOML whose [test] preload is a path or a list of paths` });
|
|
261
|
+
const bunfig = yield* Effect.try({ try: (): unknown => Bun.TOML.parse(text), catch: unparsed }).pipe(Effect.flatMap(decodeBunfig), Effect.mapError(unparsed));
|
|
262
|
+
const preload = bunfig.test?.preload ?? [];
|
|
263
|
+
return (typeof preload === "string" ? [preload] : preload).map((entry) => resolved("", entry));
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const loadedByPreloads = Effect.fn("loadedByPreloads")(function* (rev: string, preloads: readonly string[], paths: ReadonlySet<string>) {
|
|
267
|
+
const loaded = new Set<string>();
|
|
268
|
+
for (let next = preloads.flatMap((preload) => importedFile(preload, paths) ?? []); next.length > 0; ) {
|
|
269
|
+
for (const file of next) loaded.add(file);
|
|
270
|
+
const read = (file: string) => git(["show", `${rev}:${file}`]).pipe(Effect.map((text) => importsOf(file, text, paths)));
|
|
271
|
+
const imported = (yield* Effect.forEach(next, read, { concurrency: 16 })).flat();
|
|
272
|
+
next = [...new Set(imported)].filter((file) => !loaded.has(file));
|
|
273
|
+
}
|
|
274
|
+
return loaded;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const readTestTree = Effect.fn("readTestTree")(function* (paths: Iterable<string>) {
|
|
278
|
+
const fs = yield* FileSystem.FileSystem;
|
|
279
|
+
const files = [...paths].filter((path) => path.startsWith(TEST_TREE) && READ_FILE.test(path));
|
|
280
|
+
const read = (file: string) => fs.readFileString(file).pipe(Effect.map((text) => [file, text] as const));
|
|
281
|
+
return new Map(yield* Effect.forEach(files, read, { concurrency: 16 }));
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const loadBaseline = Effect.fn("loadBaseline")(function* (report: string | undefined, tests: ReadonlySet<string>) {
|
|
285
|
+
const fs = yield* FileSystem.FileSystem;
|
|
286
|
+
if (report === undefined || !(yield* fs.exists(report))) {
|
|
287
|
+
const where = report === undefined ? "" : ` at ${report}`;
|
|
288
|
+
return yield* new ScopeError({ message: `${[...tests].toSorted().join(",")} changed, but no baseline report${where} says which sources they cover` });
|
|
289
|
+
}
|
|
290
|
+
const unparsed = (cause: { readonly message: string }) => new ScopeError({ message: `${report} is not a Stryker mutation report with its test files: ${cause.message}` });
|
|
291
|
+
return yield* fs.readFileString(report).pipe(Effect.flatMap(decodeBaseline), Effect.mapError(unparsed));
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
type Snapshots = { readonly base: string; readonly head: string; readonly atBase: ReadonlySet<string>; readonly atHead: ReadonlySet<string> };
|
|
295
|
+
|
|
296
|
+
// Every unit test loads each preload, so a change to one makes every unit test read it.
|
|
297
|
+
const unitReaders = Effect.fn("unitReaders")(function* (picked: Picked, { base, head, atBase, atHead }: Snapshots) {
|
|
298
|
+
const preloads = { atBase: yield* preloadsAt(base, atBase), atHead: yield* preloadsAt(head, atHead) };
|
|
299
|
+
const everyTestReads = preloadChange(picked.paths, preloads, yield* loadedByPreloads(head, preloads.atHead, atHead));
|
|
300
|
+
if (everyTestReads !== undefined) {
|
|
301
|
+
yield* Console.error(`${NAME}: every unit test reads ${everyTestReads}`);
|
|
302
|
+
return [...atHead].filter((test) => isUnitTest(test) && !picked.tests.has(test)).map((test) => [test, everyTestReads] as const);
|
|
303
|
+
}
|
|
304
|
+
if (picked.touched.size === 0) return [];
|
|
305
|
+
const readers = [...readersOf(yield* readTestTree(atHead), [...picked.touched])].filter(([reader]) => isUnitTest(reader));
|
|
306
|
+
for (const [reader, via] of readers) yield* Console.error(`${NAME}: ${reader} reads ${via}`);
|
|
307
|
+
return readers;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// An absent coverage edge holds only for a test whose merge-base source is the one the report ran.
|
|
311
|
+
const staleIn = Effect.fn("staleIn")(function* (baseline: Baseline, tests: readonly string[], base: string) {
|
|
312
|
+
const recorded = new Map(Object.entries(baseline.testFiles).map(([file, { source }]) => [file, source]));
|
|
313
|
+
const atBase = yield* Effect.forEach(tests, (test) => git(["show", `${base}:${test}`]), { concurrency: 16 });
|
|
314
|
+
return tests.filter((test, index) => recorded.get(test) !== atBase[index]).toSorted();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Stryker reports no source without mutants, so a source the report lacks may be newer than it and enters.
|
|
318
|
+
const enteringSources = Effect.fn("enteringSources")(function* (
|
|
319
|
+
report: string | undefined,
|
|
320
|
+
tests: ReadonlySet<string>,
|
|
321
|
+
mutated: (file: string) => boolean,
|
|
322
|
+
{ base, atBase, atHead }: Snapshots,
|
|
323
|
+
scoped: ReadonlySet<string>,
|
|
324
|
+
) {
|
|
325
|
+
if (tests.size === 0) return [];
|
|
326
|
+
const baseline = yield* loadBaseline(report, tests);
|
|
327
|
+
const eligible = (source: string): boolean => mutated(source) && !scoped.has(source) && atHead.has(source);
|
|
328
|
+
const candidates = [...atHead].filter(eligible).toSorted();
|
|
329
|
+
const stale = yield* staleIn(baseline, [...tests].filter((test) => atBase.has(test)), base);
|
|
330
|
+
if (stale.length > 0) {
|
|
331
|
+
yield* Console.error(`${NAME}: the baseline report did not run ${stale.join(" ")} as the merge base holds it, so every mutated source enters`);
|
|
332
|
+
return candidates;
|
|
333
|
+
}
|
|
334
|
+
const entering = [...coveringTests(baseline, tests, mutated)].filter(([source]) => eligible(source));
|
|
335
|
+
for (const [source, via] of entering) yield* Console.error(`${NAME}: ${source} enters through ${via.join(" ")}`);
|
|
336
|
+
const unknown = candidates.filter((source) => !Object.hasOwn(baseline.files, source));
|
|
337
|
+
if (unknown.length > 0) yield* Console.error(`${NAME}: ${unknown.join(" ")} enter, as the baseline report holds no mutant of them`);
|
|
338
|
+
return [...entering.map(([source]) => source), ...unknown];
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const selectScope = Effect.gen(function* () {
|
|
342
|
+
const { base, baseline } = yield* parseArgs(process.argv.slice(2));
|
|
343
|
+
const range = yield* rangeEnds(base, "HEAD");
|
|
344
|
+
const mutated = mutatedBy(yield* mutatePatterns, (yield* Path.Path).resolve("."));
|
|
345
|
+
const snapshots: Snapshots = { ...range, atBase: new Set(yield* pathsAt(range.base, [])), atHead: new Set(yield* pathsAt(range.head, [])) };
|
|
346
|
+
const picked = pickFromDiff(yield* changedPaths(range.base, range.head, []), mutated);
|
|
347
|
+
const readers = (yield* unitReaders(picked, snapshots)).map(([reader]) => reader);
|
|
348
|
+
const tests = new Set([...picked.tests, ...readers]);
|
|
349
|
+
const entering = yield* enteringSources(baseline, tests, mutated, snapshots, picked.scope);
|
|
350
|
+
yield* Console.log(envLines({ scope: [...picked.scope, ...entering], staleTests: readers }));
|
|
351
|
+
return true;
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
if (import.meta.main) runMain(NAME, selectScope);
|
package/src/testing/mutation.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Config, Effect, Schema } from "effect";
|
|
3
3
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
4
4
|
import { runMain } from "../core/main.ts";
|
|
5
|
-
import { fullRunRefusal } from "./mutation-
|
|
5
|
+
import { fullRunRefusal } from "./mutation-refusal.ts";
|
|
6
6
|
|
|
7
7
|
export class MutationError extends Schema.TaggedError<MutationError>()("MutationError", {
|
|
8
8
|
message: Schema.String,
|