@avi2dg/checks 0.51.0 → 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 +14 -0
- package/README.md +24 -249
- package/commitlint.config.ts +0 -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 +3 -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 +9 -5
- package/docs/gates/checks-mutation-compare.md +3 -3
- package/docs/gates/checks-mutation.md +9 -5
- 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 +11 -1
- package/src/delivery/bun-pin.ts +19 -5
- package/src/delivery/ci-wiring.ts +11 -77
- package/src/delivery/commit-identity.ts +6 -5
- 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 +23 -47
- package/src/testing/mutation-baseline-saved.ts +1 -1
- package/src/testing/mutation-baseline.ts +10 -8
- package/src/testing/mutation-scope-graph.ts +10 -19
- package/src/testing/mutation-scope.ts +8 -41
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Effect, Option, Schema } from "effect";
|
|
2
|
+
import { git } from "../core/git.ts";
|
|
3
|
+
import { PACKAGE_MANIFEST } from "./doc-references.ts";
|
|
4
|
+
import { scanMarkdown, type MarkdownLine } from "./prose-matchers.ts";
|
|
5
|
+
|
|
6
|
+
type Pin = { readonly name: string | undefined; readonly version: string; readonly holder: string };
|
|
7
|
+
|
|
8
|
+
const BUN_VERSION = ".bun-version";
|
|
9
|
+
|
|
10
|
+
const Group = Schema.optionalKey(Schema.Record(Schema.String, Schema.String));
|
|
11
|
+
const Manifest = Schema.Struct({
|
|
12
|
+
version: Schema.optionalKey(Schema.String),
|
|
13
|
+
packageManager: Schema.optionalKey(Schema.String),
|
|
14
|
+
engines: Group,
|
|
15
|
+
dependencies: Group,
|
|
16
|
+
devDependencies: Group,
|
|
17
|
+
peerDependencies: Group,
|
|
18
|
+
optionalDependencies: Group,
|
|
19
|
+
});
|
|
20
|
+
const decodeManifest = Schema.decodeUnknownOption(Schema.fromJsonString(Manifest));
|
|
21
|
+
const NO_MANIFEST: typeof Manifest.Type = {};
|
|
22
|
+
|
|
23
|
+
const VERSION = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/;
|
|
24
|
+
const ONE_VERSION = new RegExp(String.raw`^[\^~=v]?(${VERSION.source})$`);
|
|
25
|
+
const VERSIONS = new RegExp(VERSION.source, "g");
|
|
26
|
+
|
|
27
|
+
function namedPins(group: Readonly<Record<string, string>> | undefined): readonly Pin[] {
|
|
28
|
+
return Object.entries(group ?? {}).flatMap(([name, range]) => {
|
|
29
|
+
const version = ONE_VERSION.exec(range.trim())?.[1];
|
|
30
|
+
return version === undefined ? [] : [{ name, version, holder: PACKAGE_MANIFEST }];
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function barePins(value: string | undefined, holder: string): readonly Pin[] {
|
|
35
|
+
return [...(value ?? "").matchAll(VERSIONS)].map(([version]) => ({ name: undefined, version, holder }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function pinsOf(manifest: string, bunVersion: string): readonly Pin[] {
|
|
39
|
+
const { version, packageManager, engines = {}, dependencies, devDependencies, peerDependencies, optionalDependencies } = Option.getOrElse(
|
|
40
|
+
decodeManifest(manifest),
|
|
41
|
+
() => NO_MANIFEST,
|
|
42
|
+
);
|
|
43
|
+
const pins = [
|
|
44
|
+
...[dependencies, devDependencies, peerDependencies, optionalDependencies].flatMap(namedPins),
|
|
45
|
+
...[version, packageManager, ...Object.values(engines)].flatMap((value) => barePins(value, PACKAGE_MANIFEST)),
|
|
46
|
+
...barePins(bunVersion, BUN_VERSION),
|
|
47
|
+
];
|
|
48
|
+
return pins.filter((pin, index) => pins.findIndex((other) => other.name === pin.name && other.version === pin.version) === index);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function escaped(text: string): string {
|
|
52
|
+
return text.replaceAll(/[.*+?^${}()|[\]\\/]/g, String.raw`\$&`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A longer version that only opens with the pin, as 1.2.30 or 1.2.3-beta, is another version.
|
|
56
|
+
const VERSION_END = String.raw`(?![\w-]|\.\d)`;
|
|
57
|
+
|
|
58
|
+
function patternOf({ name, version }: Pin): RegExp {
|
|
59
|
+
const written =
|
|
60
|
+
name === undefined ? String.raw`(?<![\w.+-])v?${escaped(version)}` : String.raw`(?<![\w@/.-])${escaped(name)}@[\^~]?${escaped(version)}`;
|
|
61
|
+
return new RegExp(written + VERSION_END, "g");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const GENERATED_OPEN = /^\s*<!-- generated ([\w-]+):/;
|
|
65
|
+
const GENERATED_CLOSE = /^\s*<!-- end generated ([\w-]+) -->/;
|
|
66
|
+
|
|
67
|
+
function handWritten(lines: readonly MarkdownLine[]): readonly MarkdownLine[] {
|
|
68
|
+
const exempt = new Set<number>();
|
|
69
|
+
let at = 0;
|
|
70
|
+
while (at < lines.length) {
|
|
71
|
+
const opening = lines[at]?.kind === "code" ? null : GENERATED_OPEN.exec(lines[at]?.raw ?? "");
|
|
72
|
+
if (opening === null) {
|
|
73
|
+
at += 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const closeAt = lines.findIndex(
|
|
77
|
+
(line, index) => index > at && line.kind !== "code" && GENERATED_CLOSE.exec(line.raw)?.[1] === opening[1],
|
|
78
|
+
);
|
|
79
|
+
if (closeAt < 0) {
|
|
80
|
+
at += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
for (let index = at; index <= closeAt; index += 1) exempt.add(index);
|
|
84
|
+
at = closeAt + 1;
|
|
85
|
+
}
|
|
86
|
+
return lines.filter((_, index) => !exempt.has(index));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type Copy = { readonly at: number; readonly end: number; readonly pin: Pin; readonly written: string };
|
|
90
|
+
|
|
91
|
+
function namePresent(raw: string, name: string): boolean {
|
|
92
|
+
return new RegExp(String.raw`(?<![\w@/.-])${escaped(name)}(?![\w/.-])`, "i").test(raw);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function barePatternFor(version: string): RegExp {
|
|
96
|
+
return new RegExp(String.raw`(?<![\w.+-])v?${escaped(version)}` + VERSION_END, "g");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A bare pin inside a named copy, as 1.2.3 in zod@1.2.3, is the same copy.
|
|
100
|
+
function copiesIn(raw: string, pins: readonly Pin[]): readonly Copy[] {
|
|
101
|
+
const found: Copy[] = [];
|
|
102
|
+
for (const pin of pins) {
|
|
103
|
+
for (const match of raw.matchAll(patternOf(pin))) {
|
|
104
|
+
const at = match.index;
|
|
105
|
+
found.push({ at, end: at + match[0].length, pin, written: match[0] });
|
|
106
|
+
}
|
|
107
|
+
if (pin.name !== undefined && namePresent(raw, pin.name)) {
|
|
108
|
+
for (const match of raw.matchAll(barePatternFor(pin.version))) {
|
|
109
|
+
const at = match.index;
|
|
110
|
+
found.push({ at, end: at + match[0].length, pin, written: match[0] });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const installed = found.filter((copy) => copy.written.includes("@"));
|
|
115
|
+
return found
|
|
116
|
+
.filter((copy) => copy.written.includes("@") || !installed.some(({ at, end }) => copy.at >= at && copy.end <= end))
|
|
117
|
+
.toSorted((a, b) => a.at - b.at);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function versionFindings(text: string, pins: readonly Pin[], within?: ReadonlySet<number>): readonly { readonly line: number; readonly message: string }[] {
|
|
121
|
+
if (pins.length === 0) return [];
|
|
122
|
+
return handWritten(scanMarkdown(text))
|
|
123
|
+
.filter(({ line }) => within === undefined || within.has(line))
|
|
124
|
+
.flatMap(({ line, raw }) =>
|
|
125
|
+
copiesIn(raw, pins).map(({ written, pin }) => ({
|
|
126
|
+
line,
|
|
127
|
+
message: `carries \`${written}\`, a version ${pin.holder} pins. Name ${pin.holder} instead, or let a build write the line between generated markers`,
|
|
128
|
+
})),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const readAt = (root: string, head: string, path: string) =>
|
|
133
|
+
git(["show", `${head}:${path}`], root).pipe(Effect.catchTag("GitFailure", () => Effect.succeed("")));
|
|
134
|
+
|
|
135
|
+
export const pinsAt = Effect.fn("pinsAt")(function* (root: string, head: string) {
|
|
136
|
+
return pinsOf(yield* readAt(root, head, PACKAGE_MANIFEST), yield* readAt(root, head, BUN_VERSION));
|
|
137
|
+
});
|
package/src/docs/docs.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { ceilingFinding, entryFindings, isAgentFile, maintainingFinding } from "
|
|
|
4
4
|
import { rootsOf, snapshotOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
|
|
5
5
|
import { ADR_DIRECTORY, judge, placementOf, placementProblem, recordsOf, speaksToConsumers, type Placement, type Records } from "./doc-rules.ts";
|
|
6
6
|
import { vanishedNames } from "./doc-names.ts";
|
|
7
|
+
import { pinsAt, versionFindings } from "./doc-versions.ts";
|
|
7
8
|
import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
|
|
8
9
|
import { changedLines, changedPaths, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
|
|
9
10
|
import { runMain } from "../core/main.ts";
|
|
@@ -107,6 +108,10 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
|
|
|
107
108
|
const prose = edited.flatMap(({ path, reader }) =>
|
|
108
109
|
proseFindings(text(path), reader, changed.get(path)).map(({ line, message }) => ({ path, line, message })),
|
|
109
110
|
);
|
|
111
|
+
const pins = yield* pinsAt(root, head);
|
|
112
|
+
const versions = edited.flatMap(({ path }) =>
|
|
113
|
+
versionFindings(text(path), pins, changed.get(path)).map(({ line, message }) => ({ path, line, message })),
|
|
114
|
+
);
|
|
110
115
|
const sentenceAdvisory = edited
|
|
111
116
|
.flatMap(({ path }) => longSentences(text(path), changed.get(path)).map(({ line, message }) => ({ path, line, message })))
|
|
112
117
|
.toSorted(inPathOrder);
|
|
@@ -132,6 +137,7 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
|
|
|
132
137
|
findings: [
|
|
133
138
|
...templated.filter((finding) => touched.has(finding.path)),
|
|
134
139
|
...prose,
|
|
140
|
+
...versions,
|
|
135
141
|
...references.failing,
|
|
136
142
|
...vanished,
|
|
137
143
|
...shapes,
|
|
@@ -153,6 +159,7 @@ export function report({ held, edited, named, agents, findings, advisory, senten
|
|
|
153
159
|
? [
|
|
154
160
|
`${NAME}: ${held.length} doc file(s) the range touches hold to their templates`,
|
|
155
161
|
`${NAME}: ${edited.lines} line(s) the range adds or edits in ${edited.docs} living doc(s) or agent file(s) hold to the prose rules`,
|
|
162
|
+
`${NAME}: no line the range adds or edits copies a version package.json or .bun-version pins`,
|
|
156
163
|
`${NAME}: the range breaks no path, link or command the ${named} living doc(s) or agent file(s) name`,
|
|
157
164
|
`${NAME}: the ${agents} agent file(s) hold to the ceiling, and every entry names a tracked path, a link or a command`,
|
|
158
165
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Console, Effect, FileSystem, Schema } from "effect";
|
|
1
|
+
import { Clock, Config, Console, Effect, FileSystem, Option, Schema } from "effect";
|
|
2
2
|
import { ChildProcessSpawner } from "effect/process";
|
|
3
3
|
import { collect } from "../core/git.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { withoutStale } from "./mutation-baseline-saved.ts";
|
|
5
5
|
import { STRYKER_CONFIGS, stalenessSince } from "./mutation-scope.ts";
|
|
6
6
|
|
|
7
7
|
export class BaselineFailure extends Schema.TaggedError<BaselineFailure>()("BaselineFailure", {
|
|
@@ -17,49 +17,18 @@ export const gh = Effect.fn("gh")(function* (args: readonly string[]) {
|
|
|
17
17
|
|
|
18
18
|
const FULL_CONFIGS = [...STRYKER_CONFIGS, "stryker.preset.js", "bun.lock", ".bun-version"];
|
|
19
19
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
try: (): unknown => JSON.parse(text),
|
|
23
|
-
catch: () => new BaselineFailure({ message: "package.json is not JSON" }),
|
|
24
|
-
});
|
|
20
|
+
const REFRESH_DAYS = 28;
|
|
21
|
+
const DAY_MS = 86_400_000;
|
|
25
22
|
|
|
26
|
-
const
|
|
27
|
-
parsedJson(text).pipe(
|
|
28
|
-
Effect.map((parsed) => {
|
|
29
|
-
if (!isRecord(parsed)) return undefined;
|
|
30
|
-
return JSON.stringify(Object.fromEntries(Object.entries(parsed).filter(([key]) => key !== "version")));
|
|
31
|
-
}),
|
|
32
|
-
Effect.catchTag("BaselineFailure", () => Effect.succeed(undefined)),
|
|
33
|
-
);
|
|
23
|
+
const refreshWindow = (time: number): number => Math.floor(time / (REFRESH_DAYS * DAY_MS));
|
|
34
24
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const patched = parsed["patchedDependencies"];
|
|
41
|
-
if (!isRecord(patched)) return [];
|
|
42
|
-
return Object.values(patched).flatMap((value) => (typeof value === "string" ? [value.replace(/^\.\//, "")] : []));
|
|
43
|
-
}),
|
|
44
|
-
Effect.catchTag("BaselineFailure", (): Effect.Effect<readonly string[]> => Effect.succeed([])),
|
|
45
|
-
);
|
|
46
|
-
};
|
|
25
|
+
// Each full run reuses what the run before it restored, so a cap on the restored run's age alone caps nothing.
|
|
26
|
+
// Windows fixed from the epoch cap the whole chain: every state in a window was built from runs in that window.
|
|
27
|
+
export function inRefreshWindow(createdAt: string, now: number): boolean {
|
|
28
|
+
return refreshWindow(Date.parse(createdAt)) === refreshWindow(now);
|
|
29
|
+
}
|
|
47
30
|
|
|
48
|
-
|
|
49
|
-
collect("git", ["show", `${rev}:${path}`]).pipe(
|
|
50
|
-
Effect.map(({ stdout, exitCode }) => (exitCode === ChildProcessSpawner.ExitCode(0) ? stdout : undefined)),
|
|
51
|
-
Effect.mapError((cause) => new BaselineFailure({ message: cause.message })),
|
|
52
|
-
Effect.catchTag("BaselineFailure", () => Effect.succeed(undefined)),
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
const packageInputs = Effect.fn("packageInputs")(function* (base: string, head: string, paths: ReadonlySet<string>) {
|
|
56
|
-
const inputs = new Set<string>();
|
|
57
|
-
const [before, after] = yield* Effect.all([shownAt(base, "package.json"), shownAt(head, "package.json")]);
|
|
58
|
-
if (paths.has("package.json") && (before === undefined || after === undefined || (yield* withoutVersion(before)) !== (yield* withoutVersion(after))))
|
|
59
|
-
inputs.add("package.json");
|
|
60
|
-
for (const text of [before, after]) for (const path of yield* patchedPaths(text)) if (paths.has(path)) inputs.add(path);
|
|
61
|
-
return inputs;
|
|
62
|
-
});
|
|
31
|
+
export type FullRun = { readonly databaseId: number; readonly createdAt: string };
|
|
63
32
|
|
|
64
33
|
const decodeHeadSha = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ headSha: Schema.String })));
|
|
65
34
|
|
|
@@ -67,16 +36,23 @@ const viewedHead = Effect.fn("viewedHead")(function* (run: number) {
|
|
|
67
36
|
return (yield* gh(["run", "view", String(run), "--json", "headSha"]).pipe(Effect.flatMap(decodeHeadSha))).headSha;
|
|
68
37
|
});
|
|
69
38
|
|
|
70
|
-
export const pruneFullState = Effect.fn("pruneFullState")(function* (run:
|
|
39
|
+
export const pruneFullState = Effect.fn("pruneFullState")(function* (run: FullRun, state: string) {
|
|
71
40
|
const fs = yield* FileSystem.FileSystem;
|
|
72
41
|
if (!(yield* fs.exists(state))) return;
|
|
73
|
-
const
|
|
42
|
+
const override = yield* Config.Int("CHECKS_MUTATION_BASELINE_NOW_MS").pipe(Config.option);
|
|
43
|
+
let now = yield* Clock.currentTimeMillis;
|
|
44
|
+
if (Option.isSome(override)) now = override.value;
|
|
45
|
+
if (!inRefreshWindow(run.createdAt, now)) {
|
|
46
|
+
yield* fs.remove(state);
|
|
47
|
+
yield* Console.log(`mutation-baseline: dropped the restored full state, run ${run.databaseId} started before the current ${REFRESH_DAYS}-day refresh window`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const base = yield* viewedHead(run.databaseId);
|
|
74
51
|
const { paths, preload, stale, config } = yield* stalenessSince(base, "HEAD");
|
|
75
|
-
const
|
|
76
|
-
const changed = [...paths].filter((path) => FULL_CONFIGS.includes(path) || inputs.has(path) || config.has(path)).toSorted();
|
|
52
|
+
const changed = [...paths].filter((path) => FULL_CONFIGS.includes(path) || config.has(path)).toSorted();
|
|
77
53
|
if (changed.length > 0 || preload !== undefined) {
|
|
78
54
|
yield* fs.remove(state);
|
|
79
|
-
yield* Console.log(`mutation-baseline: dropped the restored full state, ${preload ?? changed.join(" ")} changed since run ${run}`);
|
|
55
|
+
yield* Console.log(`mutation-baseline: dropped the restored full state, ${preload ?? changed.join(" ")} changed since run ${run.databaseId}`);
|
|
80
56
|
return;
|
|
81
57
|
}
|
|
82
58
|
const text = yield* fs.readFileString(state);
|
|
@@ -16,7 +16,7 @@ const SavedFiles = Schema.Struct({ [INCREMENTAL]: Schema.Unknown, [REPORT]: Sche
|
|
|
16
16
|
export type SavedFiles = typeof SavedFiles.Type;
|
|
17
17
|
const decodeSavedFiles = Schema.decodeUnknownEffect(Schema.fromJsonString(SavedFiles));
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
20
|
|
|
21
21
|
const withoutReason = (mutant: unknown): unknown => (isRecord(mutant) ? Object.fromEntries(Object.entries(mutant).filter(([key]) => key !== "statusReason")) : mutant);
|
|
22
22
|
|
|
@@ -46,14 +46,16 @@ const listed = Effect.fn("listed")(function* (event: string | undefined, limit:
|
|
|
46
46
|
);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
const byNewest = (runs: readonly Run[]): readonly Run[] => [...runs].sort((one, other) => other.createdAt.localeCompare(one.createdAt));
|
|
50
|
+
|
|
49
51
|
export function newestFirst(runs: readonly Run[]): readonly number[] {
|
|
50
|
-
return
|
|
52
|
+
return byNewest(runs).map((run) => run.databaseId);
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
// All non-pull-request runs newest first: a newer full run holds no baseline, and the newest baseline must survive it.
|
|
54
56
|
const latestCandidates = Effect.gen(function* () {
|
|
55
57
|
const newest = (yield* listed(undefined, LATEST_LIMIT)).find((run) => run.event !== "pull_request");
|
|
56
|
-
return newest === undefined ? [] : [newest
|
|
58
|
+
return newest === undefined ? [] : [newest];
|
|
57
59
|
});
|
|
58
60
|
|
|
59
61
|
const latestRuns = listed(undefined, LATEST_LIMIT).pipe(Effect.map((runs) => newestFirst(runs.filter((run) => run.event !== "pull_request"))));
|
|
@@ -61,7 +63,7 @@ const latestRuns = listed(undefined, LATEST_LIMIT).pipe(Effect.map((runs) => new
|
|
|
61
63
|
// Each full event is listed on its own, because one shared window lets push runs crowd out every full run.
|
|
62
64
|
const fullCandidates = Effect.gen(function* () {
|
|
63
65
|
const perEvent = yield* Effect.forEach(FULL_EVENTS, (event) => listed(event, FULL_LIMIT));
|
|
64
|
-
return
|
|
66
|
+
return byNewest(perEvent.flat());
|
|
65
67
|
});
|
|
66
68
|
|
|
67
69
|
// A successful run uploaded the files, so the newest unexpired artifact is the one the restore takes.
|
|
@@ -108,7 +110,7 @@ const keptLatest = Effect.fn("keptLatest")(function* (runs: readonly number[], s
|
|
|
108
110
|
const pruneBaselines = Effect.fn("pruneBaselines")(function* (dryRun: boolean) {
|
|
109
111
|
const running = yield* Config.Int("GITHUB_RUN_ID").pipe(Config.option);
|
|
110
112
|
const latest = yield* keptLatest(yield* latestRuns, yield* savedShelf(yield* runnerCache));
|
|
111
|
-
const full = yield* keptArtifact(yield* fullCandidates, FULL_ARTIFACT);
|
|
113
|
+
const full = yield* keptArtifact((yield* fullCandidates).map((run) => run.databaseId), FULL_ARTIFACT);
|
|
112
114
|
const artifacts = yield* everyArtifact();
|
|
113
115
|
const keeping = [...Option.toArray(latest.kept), ...Option.toArray(full), ...uploadedBy(artifacts, running)];
|
|
114
116
|
const victims = superseded(artifacts, new Set(keeping.map((keep) => keep.id)));
|
|
@@ -341,10 +343,10 @@ function parsed([first, ...rest]: readonly string[]): Command | undefined {
|
|
|
341
343
|
return restoreRequest(first, report, extra);
|
|
342
344
|
}
|
|
343
345
|
|
|
344
|
-
const restoredRun = Effect.fn("restoredRun")(function* (run:
|
|
345
|
-
if (Option.isSome(shelf) && (yield* restoredFromSaved(shelf.value, run, request))) return true;
|
|
346
|
-
const artifact = yield* artifactOf(run, name).pipe(Effect.catch(warned(Option.none<Artifact>())));
|
|
347
|
-
if (Option.isNone(artifact) || !(yield* restoredFrom(run, artifact.value, root, request))) return false;
|
|
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;
|
|
348
350
|
const incremental = request.full ? request.optional[0] : undefined;
|
|
349
351
|
if (incremental !== undefined) yield* pruneFullState(run, incremental.to);
|
|
350
352
|
return true;
|
|
@@ -18,20 +18,11 @@ export function resolved(directory: string, spec: string): string {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
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
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
return [target, ...CANDIDATE_EXTENSIONS.map((extension) => bare + extension)].filter((file) => files.has(file));
|
|
25
|
-
};
|
|
26
|
-
const index = (target: string): readonly string[] =>
|
|
27
|
-
CANDIDATE_EXTENSIONS.map((extension) => `${target}/index${extension}`).filter((file) => files.has(file));
|
|
28
|
-
const direct = probe(path);
|
|
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));
|
|
29
24
|
if (direct.length > 0) return direct;
|
|
30
|
-
|
|
31
|
-
if (main === undefined) return index(path);
|
|
32
|
-
const manifest = path === "" ? "package.json" : `${path}/package.json`;
|
|
33
|
-
if (main === manifest) return [manifest, ...index(path)];
|
|
34
|
-
return [manifest, ...probe(main), ...index(main), ...index(path)];
|
|
25
|
+
return CANDIDATE_EXTENSIONS.map((extension) => `${path}/index${extension}`).filter((file) => files.has(file));
|
|
35
26
|
}
|
|
36
27
|
|
|
37
28
|
function fixtureNeedles(changed: readonly string[]): readonly Needle[] {
|
|
@@ -54,13 +45,13 @@ function mentions(line: string, { text, kind }: Needle): boolean {
|
|
|
54
45
|
|
|
55
46
|
type Edge = { readonly importer: string; readonly imported: string };
|
|
56
47
|
|
|
57
|
-
export function importsOf(file: string, text: string, files: ReadonlySet<string
|
|
48
|
+
export function importsOf(file: string, text: string, files: ReadonlySet<string>): readonly string[] {
|
|
58
49
|
const directory = file.slice(0, Math.max(file.lastIndexOf("/"), 0));
|
|
59
|
-
return [...text.matchAll(IMPORT)].flatMap(([, throughImport = "", throughRequire = ""]) => importedFile(resolved(directory, throughImport || throughRequire), files
|
|
50
|
+
return [...text.matchAll(IMPORT)].flatMap(([, throughImport = "", throughRequire = ""]) => importedFile(resolved(directory, throughImport || throughRequire), files));
|
|
60
51
|
}
|
|
61
52
|
|
|
62
|
-
const edgesOf = (file: string, text: string, files: ReadonlySet<string
|
|
63
|
-
importsOf(file, text, files
|
|
53
|
+
const edgesOf = (file: string, text: string, files: ReadonlySet<string>): readonly Edge[] =>
|
|
54
|
+
importsOf(file, text, files).map((imported) => ({ importer: file, imported }));
|
|
64
55
|
|
|
65
56
|
function fixtureNamedIn(text: string, needles: readonly Needle[]): string | undefined {
|
|
66
57
|
if (needles.length === 0) return undefined;
|
|
@@ -85,7 +76,7 @@ function spread(reached: Map<string, string>, edges: readonly Edge[]): void {
|
|
|
85
76
|
}
|
|
86
77
|
|
|
87
78
|
// Maps each file that reads a changed path, through an import or the fixture's path, to that changed path.
|
|
88
|
-
export function readersOf(sources: ReadonlyMap<string, string>, changed: readonly string[]
|
|
79
|
+
export function readersOf(sources: ReadonlyMap<string, string>, changed: readonly string[]): Map<string, string> {
|
|
89
80
|
const reached = new Map(changed.map((path) => [path, path]));
|
|
90
81
|
const needles = fixtureNeedles(changed);
|
|
91
82
|
for (const [file, text] of sources) {
|
|
@@ -93,6 +84,6 @@ export function readersOf(sources: ReadonlyMap<string, string>, changed: readonl
|
|
|
93
84
|
if (fixture !== undefined) reached.set(file, fixture);
|
|
94
85
|
}
|
|
95
86
|
const files = new Set([...sources.keys(), ...changed]);
|
|
96
|
-
spread(reached, [...sources].flatMap(([file, text]) => edgesOf(file, text, files
|
|
87
|
+
spread(reached, [...sources].flatMap(([file, text]) => edgesOf(file, text, files)));
|
|
97
88
|
return new Map([...reached].filter(([file, via]) => file !== via));
|
|
98
89
|
}
|
|
@@ -3,7 +3,6 @@ import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
|
3
3
|
import { escape, minimatch } from "minimatch";
|
|
4
4
|
import { changedPaths, git, pathsAt, rangeEnds, type Change } from "../core/git.ts";
|
|
5
5
|
import { runMain, Usage } from "../core/main.ts";
|
|
6
|
-
import { isRecord } from "./mutation-baseline-saved.ts";
|
|
7
6
|
import { importedFile, importsOf, readersOf, resolved, TEST_TREE } from "./mutation-scope-graph.ts";
|
|
8
7
|
|
|
9
8
|
const NAME = "mutation-scope";
|
|
@@ -180,13 +179,13 @@ const preloadsAt = Effect.fn("preloadsAt")(function* (rev: string, paths: Readon
|
|
|
180
179
|
return (typeof preload === "string" ? [preload] : preload).map((entry) => resolved("", entry));
|
|
181
180
|
});
|
|
182
181
|
|
|
183
|
-
const transitivelyImported = Effect.fn("transitivelyImported")(function* (rev: string, roots: readonly string[], paths: ReadonlySet<string
|
|
182
|
+
const transitivelyImported = Effect.fn("transitivelyImported")(function* (rev: string, roots: readonly string[], paths: ReadonlySet<string>) {
|
|
184
183
|
const loaded = new Set<string>();
|
|
185
184
|
for (let next = [...roots]; next.length > 0; ) {
|
|
186
185
|
for (const file of next) loaded.add(file);
|
|
187
186
|
const read = (file: string) =>
|
|
188
187
|
git(["show", `${rev}:${file}`]).pipe(
|
|
189
|
-
Effect.map((text) => importsOf(file, text, paths
|
|
188
|
+
Effect.map((text) => importsOf(file, text, paths)),
|
|
190
189
|
Effect.catchTag("GitFailure", (): Effect.Effect<readonly string[]> => Effect.succeed([])),
|
|
191
190
|
);
|
|
192
191
|
const imported = (yield* Effect.forEach(next, read, { concurrency: 16 })).flat();
|
|
@@ -195,39 +194,8 @@ const transitivelyImported = Effect.fn("transitivelyImported")(function* (rev: s
|
|
|
195
194
|
return loaded;
|
|
196
195
|
});
|
|
197
196
|
|
|
198
|
-
const loadedByPreloads = Effect.fn("loadedByPreloads")(function* (rev: string, preloads: readonly string[], paths: ReadonlySet<string
|
|
199
|
-
return yield* transitivelyImported(rev, preloads.flatMap((preload) => importedFile(preload, paths
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
const mainOf = (text: string) =>
|
|
203
|
-
Effect.try({
|
|
204
|
-
try: (): unknown => JSON.parse(text),
|
|
205
|
-
catch: () => new ScopeError({ message: "package.json does not parse as JSON holding a main entry" }),
|
|
206
|
-
}).pipe(
|
|
207
|
-
Effect.map((parsed) => {
|
|
208
|
-
if (!isRecord(parsed)) return undefined;
|
|
209
|
-
const main = parsed["main"];
|
|
210
|
-
return typeof main === "string" && main.trim() !== "" ? main : undefined;
|
|
211
|
-
}),
|
|
212
|
-
Effect.catchTag("ScopeError", () => Effect.succeed(undefined)),
|
|
213
|
-
);
|
|
214
|
-
|
|
215
|
-
const readMains = Effect.fn("readMains")(function* (rev: string, paths: ReadonlySet<string>) {
|
|
216
|
-
const packages = [...paths].filter((path) => path === "package.json" || path.endsWith("/package.json"));
|
|
217
|
-
const entries = yield* Effect.forEach(
|
|
218
|
-
packages,
|
|
219
|
-
(file) => git(["show", `${rev}:${file}`]).pipe(Effect.map((text): readonly [string, string] => [file, text]), Effect.catchTag("GitFailure", () => Effect.succeed(undefined))),
|
|
220
|
-
{ concurrency: 16 },
|
|
221
|
-
);
|
|
222
|
-
const mains = new Map<string, string>();
|
|
223
|
-
for (const entry of entries) {
|
|
224
|
-
if (entry === undefined) continue;
|
|
225
|
-
const [file, text] = entry;
|
|
226
|
-
const main = yield* mainOf(text);
|
|
227
|
-
const directory = file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "";
|
|
228
|
-
mains.set(directory, main === undefined ? file : resolved(directory, main));
|
|
229
|
-
}
|
|
230
|
-
return mains;
|
|
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);
|
|
231
199
|
});
|
|
232
200
|
|
|
233
201
|
const readFiles = Effect.fn("readFiles")(function* (files: readonly string[]) {
|
|
@@ -279,17 +247,16 @@ export const stalenessSince = Effect.fn("stalenessSince")(function* (base: strin
|
|
|
279
247
|
const atBase = new Set(yield* pathsAt(base, []));
|
|
280
248
|
const atHead = new Set(yield* pathsAt(head, []));
|
|
281
249
|
const preloads = { atBase: yield* preloadsAt(base, atBase), atHead: yield* preloadsAt(head, atHead) };
|
|
282
|
-
const
|
|
283
|
-
const loaded = new Set([...(yield* loadedByPreloads(head, preloads.atHead, atHead, mains)), ...(yield* loadedByPreloads(base, preloads.atBase, atBase, mains))]);
|
|
250
|
+
const loaded = new Set([...(yield* loadedByPreloads(head, preloads.atHead, atHead)), ...(yield* loadedByPreloads(base, preloads.atBase, atBase))]);
|
|
284
251
|
const preload = preloadChange(picked.paths, preloads, loaded);
|
|
285
252
|
const active = { atBase: STRYKER_CONFIGS.find((name) => atBase.has(name)), atHead: STRYKER_CONFIGS.find((name) => atHead.has(name)) };
|
|
286
253
|
const config = new Set([
|
|
287
|
-
...(yield* transitivelyImported(head, active.atHead === undefined ? [] : [active.atHead], atHead
|
|
288
|
-
...(yield* transitivelyImported(base, active.atBase === undefined ? [] : [active.atBase], atBase
|
|
254
|
+
...(yield* transitivelyImported(head, active.atHead === undefined ? [] : [active.atHead], atHead)),
|
|
255
|
+
...(yield* transitivelyImported(base, active.atBase === undefined ? [] : [active.atBase], atBase)),
|
|
289
256
|
]);
|
|
290
257
|
if (preload !== undefined) return { paths: picked.paths, preload, stale: [], config };
|
|
291
258
|
if (picked.paths.size === 0) return { paths: picked.paths, preload: undefined, stale: [], config };
|
|
292
|
-
const readers = [...readersOf(yield* readImportGraph(atHead), [...picked.paths]
|
|
259
|
+
const readers = [...readersOf(yield* readImportGraph(atHead), [...picked.paths])]
|
|
293
260
|
.filter(([reader]) => isUnitTest(reader))
|
|
294
261
|
.map(([reader]) => reader);
|
|
295
262
|
const stale = [...new Set([...[...picked.paths].filter(isUnitTest), ...readers])].toSorted();
|