@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.
- package/.bun-version +1 -0
- package/CHANGELOG.md +27 -0
- package/README.md +24 -249
- package/commitlint.config.ts +0 -1
- package/dist/mutation/unit-test-order.js +1 -1
- package/dist/presets/knip.d.ts +1 -1
- package/dist/presets/knip.js +1 -1
- package/dist/templates/how-to.md +1 -1
- package/dist/templates/readme.md +1 -1
- package/dist/templates/tutorial.md +1 -1
- package/docs/bins.md +67 -0
- package/docs/gates/checks-advisories.md +2 -2
- package/docs/gates/checks-browser.md +5 -1
- package/docs/gates/checks-ci-wiring.md +18 -9
- package/docs/gates/checks-commit-identity.md +6 -3
- package/docs/gates/checks-copy.md +4 -0
- package/docs/gates/checks-docs.md +24 -3
- package/docs/gates/checks-exports.md +1 -1
- package/docs/gates/checks-frontend-syntax.md +5 -1
- package/docs/gates/checks-lint.md +2 -2
- package/docs/gates/checks-mutation-baseline.md +36 -7
- package/docs/gates/checks-mutation-compare.md +3 -3
- package/docs/gates/checks-mutation-scope.md +1 -1
- package/docs/gates/checks-mutation.md +26 -9
- package/docs/gates/checks-night-run.md +1 -1
- package/docs/gates/checks-secrets.md +2 -2
- package/docs/gates/checks-unused.md +1 -1
- package/docs/how-to/set-up-a-repository.md +142 -0
- package/docs/how-to/turn-on-the-css-families.md +35 -0
- package/docs/how-to/upgrade-the-kit.md +42 -0
- package/docs/shipped-files.md +36 -0
- package/package.json +13 -1
- package/src/delivery/bun-pin.ts +19 -5
- package/src/delivery/ci-wiring.ts +11 -77
- package/src/delivery/commit-identity.ts +14 -7
- package/src/delivery/runner-policy.ts +385 -0
- package/src/delivery/workflow-references.ts +1 -1
- package/src/docs/doc-templates.ts +1 -1
- package/src/docs/doc-versions.ts +137 -0
- package/src/docs/docs.ts +7 -0
- package/src/testing/mutation-baseline-full.ts +68 -0
- package/src/testing/mutation-baseline-saved.ts +52 -0
- package/src/testing/mutation-baseline.ts +27 -23
- package/src/testing/mutation-scope-graph.ts +89 -0
- 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
|
|
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
|
|
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
|
|
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) &&
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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 =
|
|
184
|
+
for (let next = [...roots]; next.length > 0; ) {
|
|
270
185
|
for (const file of next) loaded.add(file);
|
|
271
|
-
const read = (file: string) =>
|
|
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
|
|
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]));
|