@avi2dg/checks 0.64.0 → 0.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/docs/bins.md +1 -1
- package/docs/design.md +3 -2
- package/docs/gates/checks-advisories.md +8 -6
- package/docs/gates/checks-ci-wiring.md +45 -7
- package/docs/gates/checks-flake.md +6 -7
- package/docs/gates/checks-lint.md +1 -1
- package/docs/gates/checks-mutation-scope.md +1 -1
- package/docs/gates/checks-mutation.md +1 -0
- package/docs/gates/checks-night-run.md +7 -7
- package/docs/gates/checks-release-notes.md +5 -0
- package/docs/gates/checks-release-pr.md +2 -1
- package/docs/gates/checks-release-push.md +4 -3
- package/docs/gates/checks-setup.md +2 -0
- package/docs/gates/checks-test.md +28 -1
- package/docs/how-to/set-up-a-repository.md +4 -0
- package/package.json +5 -1
- package/src/delivery/ci-wiring.ts +20 -7
- package/src/delivery/job-timeout.ts +30 -0
- package/src/delivery/release-push.ts +1 -1
- package/src/delivery/release-schedule.ts +53 -18
- package/src/delivery/schedule-policy.ts +64 -0
- package/src/delivery/setup-workflow.ts +43 -0
- package/src/delivery/setup.ts +2 -39
- package/src/testing/affected-tests.ts +261 -0
- package/src/testing/mutation-scope-graph.ts +13 -0
- package/src/testing/mutation-scope.ts +2 -9
- package/src/testing/test-skips.ts +3 -3
- package/src/testing/test.ts +57 -10
|
@@ -5,6 +5,7 @@ import { isRecord, locate, names, workflowJobs, type Workflow } from "./workflow
|
|
|
5
5
|
|
|
6
6
|
export type ScheduledRelease = {
|
|
7
7
|
readonly location: string;
|
|
8
|
+
readonly fault: string;
|
|
8
9
|
};
|
|
9
10
|
|
|
10
11
|
export type ScheduleContext = {
|
|
@@ -12,18 +13,26 @@ export type ScheduleContext = {
|
|
|
12
13
|
readonly files: ReadonlyMap<string, string>;
|
|
13
14
|
};
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
export type ScheduleOwner = {
|
|
17
|
+
readonly visibility: "private" | "public" | "unknown";
|
|
18
|
+
readonly publishes: boolean;
|
|
19
|
+
};
|
|
16
20
|
|
|
17
21
|
const RELEASE_BINS = ["pr", "push", "tag", "publish"].flatMap((bin) => [`checks-release-${bin}`, `release-${bin}.ts`]);
|
|
22
|
+
const PUBLISH_BINS = ["checks-release-publish", "release-publish.ts"];
|
|
23
|
+
const NUMBER = /^\d+$/;
|
|
24
|
+
const BY_HAND = "drop the schedule, so a release runs only when someone starts it";
|
|
25
|
+
|
|
26
|
+
function isBinIn(bins: readonly string[], word: string): boolean {
|
|
27
|
+
return bins.some((name) => word === name || word.endsWith(`/${name}`));
|
|
28
|
+
}
|
|
18
29
|
|
|
19
|
-
function
|
|
20
|
-
return
|
|
30
|
+
function isPublishCommand([head = "", ...rest]: Command): boolean {
|
|
31
|
+
return (head === "npm" && rest[0] === "publish") || isBinIn(PUBLISH_BINS, head);
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
function isReleaseCommand(program: Command): boolean {
|
|
24
|
-
|
|
25
|
-
if (head === "npm" && rest[0] === "publish") return true;
|
|
26
|
-
return isReleaseBin(head);
|
|
35
|
+
return isPublishCommand(program) || isBinIn(RELEASE_BINS, program[0] ?? "");
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
function localUses(job: Readonly<Record<string, unknown>>): string | undefined {
|
|
@@ -31,9 +40,10 @@ function localUses(job: Readonly<Record<string, unknown>>): string | undefined {
|
|
|
31
40
|
return typeof uses === "string" && uses.startsWith("./") ? uses.slice(2) : undefined;
|
|
32
41
|
}
|
|
33
42
|
|
|
34
|
-
function
|
|
43
|
+
function workflowRuns(
|
|
35
44
|
workflow: Workflow,
|
|
36
45
|
context: ScheduleContext,
|
|
46
|
+
isBin: (program: Command) => boolean,
|
|
37
47
|
byPath: ReadonlyMap<string, Workflow>,
|
|
38
48
|
walkedWorkflows: readonly string[],
|
|
39
49
|
): boolean {
|
|
@@ -41,32 +51,57 @@ function workflowReleases(
|
|
|
41
51
|
if (
|
|
42
52
|
workflowJobs(workflow)
|
|
43
53
|
.flatMap(([, job]) => jobRuns(workflow, job))
|
|
44
|
-
.some((run) => runsBin(run, context.scripts, files,
|
|
54
|
+
.some((run) => runsBin(run, context.scripts, files, isBin, true))
|
|
45
55
|
)
|
|
46
56
|
return true;
|
|
47
57
|
return workflowJobs(workflow).some(([, job]) => {
|
|
48
58
|
const called = localUses(job);
|
|
49
59
|
if (called === undefined || walkedWorkflows.includes(called)) return false;
|
|
50
60
|
const next = byPath.get(called);
|
|
51
|
-
return next !== undefined &&
|
|
61
|
+
return next !== undefined && workflowRuns(next, context, isBin, byPath, [...walkedWorkflows, called]);
|
|
52
62
|
});
|
|
53
63
|
}
|
|
54
64
|
|
|
55
|
-
function
|
|
65
|
+
function crons(document: unknown): readonly unknown[] {
|
|
66
|
+
const on = isRecord(document) ? document["on"] : undefined;
|
|
67
|
+
const schedule = isRecord(on) ? on["schedule"] : undefined;
|
|
68
|
+
return Array.isArray(schedule) ? schedule.map((entry) => (isRecord(entry) ? entry["cron"] : undefined)) : [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// At most once a day is one minute and one hour, whatever the day, month and weekday fields allow.
|
|
72
|
+
export function atMostDaily(cron: unknown): boolean {
|
|
73
|
+
if (typeof cron !== "string") return false;
|
|
74
|
+
const fields = cron.trim().split(/\s+/);
|
|
75
|
+
return fields.length === 5 && NUMBER.test(fields[0] ?? "") && NUMBER.test(fields[1] ?? "");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A run outside CI cannot read whether the repository is private, so only a package.json that sets private refuses there.
|
|
79
|
+
function releaseFault(workflow: Workflow, owner: ScheduleOwner, publishesToNpm: boolean): string | undefined {
|
|
80
|
+
if (owner.visibility === "private" || !owner.publishes || !publishesToNpm) return "only a public package that publishes to npm releases on a schedule; " + BY_HAND;
|
|
81
|
+
const entries = crons(workflow.document);
|
|
82
|
+
if (entries.length === 1 && entries.every(atMostDaily)) return undefined;
|
|
83
|
+
return 'the release runs more than once a day; keep one cron at a minute and an hour, such as "7 3 * * *"';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function schedules(document: unknown): boolean {
|
|
56
87
|
const on = isRecord(document) ? document["on"] : undefined;
|
|
57
88
|
return isRecord(on) ? "schedule" in on : (names(on) ?? []).includes("schedule");
|
|
58
89
|
}
|
|
59
90
|
|
|
60
|
-
export function
|
|
91
|
+
export function releasesOnSchedule(workflows: readonly Workflow[], context: ScheduleContext): readonly Workflow[] {
|
|
61
92
|
const byPath = new Map(workflows.map((workflow) => [workflow.path, workflow] as const));
|
|
62
|
-
return workflows
|
|
63
|
-
|
|
64
|
-
|
|
93
|
+
return workflows.filter((workflow) => schedules(workflow.document) && workflowRuns(workflow, context, isReleaseCommand, byPath, [workflow.path]));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function findScheduledReleases(workflows: readonly Workflow[], context: ScheduleContext, owner: ScheduleOwner): readonly ScheduledRelease[] {
|
|
97
|
+
const byPath = new Map(workflows.map((workflow) => [workflow.path, workflow] as const));
|
|
98
|
+
const publishesToNpm = workflows.some((workflow) => workflowRuns(workflow, context, isPublishCommand, byPath, [workflow.path]));
|
|
99
|
+
return releasesOnSchedule(workflows, context).flatMap((workflow) => {
|
|
100
|
+
const fault = releaseFault(workflow, owner, publishesToNpm);
|
|
101
|
+
return fault === undefined ? [] : [{ location: locate(workflow.path, workflow.text.split("\n"))(["on"], "schedule", "on schedule"), fault }];
|
|
102
|
+
});
|
|
65
103
|
}
|
|
66
104
|
|
|
67
105
|
export function formatScheduleReport(scheduled: readonly ScheduledRelease[]): string {
|
|
68
|
-
return [
|
|
69
|
-
`ci-wiring: ${scheduled.length} workflow(s) release on a schedule:`,
|
|
70
|
-
...scheduled.map(({ location }) => ` ${location}: drop the schedule, so a release runs only when someone starts it`),
|
|
71
|
-
].join("\n");
|
|
106
|
+
return [`ci-wiring: ${scheduled.length} workflow(s) release on a schedule:`, ...scheduled.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
|
|
72
107
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { runsBin } from "./mutation-job.ts";
|
|
2
|
+
import { releasesOnSchedule, schedules } from "./release-schedule.ts";
|
|
3
|
+
import { jobRuns } from "./runner-policy.ts";
|
|
4
|
+
import { invokes, plainCommand, type Command } from "./shell-command.ts";
|
|
5
|
+
import { isRecord, locate, workflowJobs, type Workflow } from "./workflow-references.ts";
|
|
6
|
+
import type { ScheduleContext, ScheduleOwner } from "./release-schedule.ts";
|
|
7
|
+
|
|
8
|
+
export type ScheduledRun = {
|
|
9
|
+
readonly location: string;
|
|
10
|
+
readonly fault: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const ADVISORY_BINS = ["checks-advisories", "advisories.ts"];
|
|
14
|
+
const INSTALL = ["bun", "install"];
|
|
15
|
+
const NUMBER = /^\d+$/;
|
|
16
|
+
const WEEKDAY = /^(?:[0-7]|sun|mon|tue|wed|thu|fri|sat)$/i;
|
|
17
|
+
const BY_HAND = "drop the schedule, so it runs only when someone starts it with gh workflow run";
|
|
18
|
+
|
|
19
|
+
function isAdvisoryBin([head = ""]: Command): boolean {
|
|
20
|
+
return ADVISORY_BINS.some((name) => head === name || head.endsWith(`/${name}`));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function crons(document: unknown): readonly unknown[] {
|
|
24
|
+
const on = isRecord(document) ? document["on"] : undefined;
|
|
25
|
+
const schedule = isRecord(on) ? on["schedule"] : undefined;
|
|
26
|
+
return Array.isArray(schedule) ? schedule.map((entry) => (isRecord(entry) ? entry["cron"] : undefined)) : [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Once a week is one minute, one hour and one weekday, on any day of any month.
|
|
30
|
+
export function weekly(cron: unknown): boolean {
|
|
31
|
+
if (typeof cron !== "string") return false;
|
|
32
|
+
const [minute = "", hour = "", day = "", month = "", weekday = "", ...rest] = cron.trim().split(/\s+/);
|
|
33
|
+
return rest.length === 0 && NUMBER.test(minute) && NUMBER.test(hour) && day === "*" && month === "*" && WEEKDAY.test(weekday);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function scansAdvisoriesOnly(workflow: Workflow, context: ScheduleContext): boolean {
|
|
37
|
+
const files = (file: string) => context.files.get(file);
|
|
38
|
+
const runs = workflowJobs(workflow).flatMap(([, job]) => jobRuns(workflow, job));
|
|
39
|
+
const scans = runs.filter((run) => runsBin(run, context.scripts, files, isAdvisoryBin, true));
|
|
40
|
+
return scans.length > 0 && runs.every((run) => scans.includes(run) || invokes(plainCommand(run.script), INSTALL));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function advisoryFault(workflow: Workflow, owner: ScheduleOwner): string | undefined {
|
|
44
|
+
if (owner.visibility === "private") return "a private repository scans advisories by hand; " + BY_HAND;
|
|
45
|
+
if (!owner.publishes) return "a package.json that sets private publishes nothing to npm, so it scans advisories by hand; " + BY_HAND;
|
|
46
|
+
const entries = crons(workflow.document);
|
|
47
|
+
if (entries.length === 1 && entries.every(weekly)) return undefined;
|
|
48
|
+
return 'the advisory scan runs more than once a week; keep one cron at a minute, an hour and a weekday, such as "15 5 * * 1"';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A workflow that releases on a schedule is the release rule's to judge, so this rule passes over it.
|
|
52
|
+
export function findScheduledRuns(workflows: readonly Workflow[], context: ScheduleContext, owner: ScheduleOwner): readonly ScheduledRun[] {
|
|
53
|
+
const releasing = new Set(releasesOnSchedule(workflows, context));
|
|
54
|
+
return workflows.flatMap((workflow) => {
|
|
55
|
+
if (!schedules(workflow.document) || releasing.has(workflow)) return [];
|
|
56
|
+
const fault = scansAdvisoriesOnly(workflow, context) ? advisoryFault(workflow, owner) : BY_HAND;
|
|
57
|
+
if (fault === undefined) return [];
|
|
58
|
+
return [{ location: locate(workflow.path, workflow.text.split("\n"))(["on"], "schedule", "on schedule"), fault }];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatScheduledRunReport(scheduled: readonly ScheduledRun[]): string {
|
|
63
|
+
return [`ci-wiring: ${scheduled.length} workflow(s) run on a schedule:`, ...scheduled.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
|
|
64
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const HOSTED_FALLBACK = "ubuntu-latest";
|
|
2
|
+
const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
|
|
3
|
+
|
|
4
|
+
export function ciWorkflow(isPrivate: boolean, hasBuild: boolean): string {
|
|
5
|
+
const lines = [
|
|
6
|
+
"name: ci",
|
|
7
|
+
"",
|
|
8
|
+
"on:",
|
|
9
|
+
" pull_request:",
|
|
10
|
+
" types: [opened, synchronize]",
|
|
11
|
+
"",
|
|
12
|
+
"concurrency:",
|
|
13
|
+
" group: ci-${{ github.event.pull_request.number }}",
|
|
14
|
+
" cancel-in-progress: true",
|
|
15
|
+
"",
|
|
16
|
+
"jobs:",
|
|
17
|
+
" checks:",
|
|
18
|
+
" runs-on: " + (isPrivate ? HOSTED_DEFAULT : HOSTED_FALLBACK),
|
|
19
|
+
" timeout-minutes: 30",
|
|
20
|
+
" steps:",
|
|
21
|
+
" - uses: actions/checkout@v5",
|
|
22
|
+
" with:",
|
|
23
|
+
" fetch-depth: 0",
|
|
24
|
+
" - uses: oven-sh/setup-bun@v2",
|
|
25
|
+
" with:",
|
|
26
|
+
" bun-version-file: .bun-version",
|
|
27
|
+
" - run: bun install --frozen-lockfile",
|
|
28
|
+
];
|
|
29
|
+
if (hasBuild) {
|
|
30
|
+
lines.push(" - run: bun run build", " - run: git diff --exit-code");
|
|
31
|
+
}
|
|
32
|
+
lines.push(" - run: bun run lint", " - run: bun run typecheck", " - run: bun run test");
|
|
33
|
+
lines.push(
|
|
34
|
+
" - id: title",
|
|
35
|
+
" name: lint the title",
|
|
36
|
+
" env:",
|
|
37
|
+
" PR_TITLE: ${{ github.event.pull_request.title }}",
|
|
38
|
+
" GH_TOKEN: ${{ github.token }}",
|
|
39
|
+
" run: bunx checks-commit-title",
|
|
40
|
+
"",
|
|
41
|
+
);
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
package/src/delivery/setup.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
3
|
import { runMain, Usage } from "../core/main.ts";
|
|
4
|
+
import { ciWorkflow } from "./setup-workflow.ts";
|
|
4
5
|
import { TEMPLATES } from "../docs/doc-templates.ts";
|
|
5
6
|
import { scriptOptions } from "../testing/test.ts";
|
|
6
7
|
import { withServiceOverrides, type ServiceOverride } from "../quality/effect-scope.ts";
|
|
@@ -10,8 +11,6 @@ import severities from "../quality/presets/effect.language-service.json" with {
|
|
|
10
11
|
const NAME = "checks-setup";
|
|
11
12
|
const USAGE = "usage: " + NAME + " [--private]";
|
|
12
13
|
const PRIVATE = "--private";
|
|
13
|
-
const HOSTED_FALLBACK = "ubuntu-latest";
|
|
14
|
-
const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
|
|
15
14
|
const IGNORE_ENTRY = "node_modules/";
|
|
16
15
|
const KIT_ID = "@avi2dg/checks";
|
|
17
16
|
|
|
@@ -116,42 +115,6 @@ const KNIP_CONFIG = [
|
|
|
116
115
|
"",
|
|
117
116
|
].join("\n");
|
|
118
117
|
|
|
119
|
-
function workflow(isPrivate: boolean, hasBuild: boolean): string {
|
|
120
|
-
const lines = [
|
|
121
|
-
"name: ci",
|
|
122
|
-
"",
|
|
123
|
-
"on:",
|
|
124
|
-
" pull_request:",
|
|
125
|
-
" types: [opened, synchronize]",
|
|
126
|
-
"",
|
|
127
|
-
"jobs:",
|
|
128
|
-
" checks:",
|
|
129
|
-
" runs-on: " + (isPrivate ? HOSTED_DEFAULT : HOSTED_FALLBACK),
|
|
130
|
-
" steps:",
|
|
131
|
-
" - uses: actions/checkout@v5",
|
|
132
|
-
" with:",
|
|
133
|
-
" fetch-depth: 0",
|
|
134
|
-
" - uses: oven-sh/setup-bun@v2",
|
|
135
|
-
" with:",
|
|
136
|
-
" bun-version-file: .bun-version",
|
|
137
|
-
" - run: bun install --frozen-lockfile",
|
|
138
|
-
];
|
|
139
|
-
if (hasBuild) {
|
|
140
|
-
lines.push(" - run: bun run build", " - run: git diff --exit-code");
|
|
141
|
-
}
|
|
142
|
-
lines.push(" - run: bun run lint", " - run: bun run typecheck", " - run: bun run test");
|
|
143
|
-
lines.push(
|
|
144
|
-
" - id: title",
|
|
145
|
-
" name: lint the title",
|
|
146
|
-
" env:",
|
|
147
|
-
" PR_TITLE: ${{ github.event.pull_request.title }}",
|
|
148
|
-
" GH_TOKEN: ${{ github.token }}",
|
|
149
|
-
" run: bunx checks-commit-title",
|
|
150
|
-
"",
|
|
151
|
-
);
|
|
152
|
-
return lines.join("\n");
|
|
153
|
-
}
|
|
154
|
-
|
|
155
118
|
const kitText = Effect.fn("kitText")(function* (file: string) {
|
|
156
119
|
const system = yield* FileSystem.FileSystem;
|
|
157
120
|
const path = yield* Path.Path;
|
|
@@ -385,7 +348,7 @@ const check = Effect.gen(function* () {
|
|
|
385
348
|
: OXLINT_CONFIG;
|
|
386
349
|
yield* setupTsconfig(root, oxlintText);
|
|
387
350
|
yield* setupFile(root, "knip.config.ts", KNIP_CONFIG);
|
|
388
|
-
yield* setupFile(root, ".github/workflows/ci.yml",
|
|
351
|
+
yield* setupFile(root, ".github/workflows/ci.yml", ciWorkflow(isPrivate, outcome.hasBuild));
|
|
389
352
|
for (const kind of Object.keys(TEMPLATES)) {
|
|
390
353
|
yield* setupFile(root, "templates/" + kind + ".md", yield* kitText("dist/templates/" + kind + ".md"));
|
|
391
354
|
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { Config, Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
2
|
+
import { pathsSinceMergeBase, workingTreeFiles } from "../core/git.ts";
|
|
3
|
+
import { decodePullRequestEvent, pullRequestEndsOf } from "../core/lint.ts";
|
|
4
|
+
import { classifyPath } from "../core/scope.ts";
|
|
5
|
+
import { decodeBunfig, importedFile, importsOf, preloadPaths, resolved, TEST_TREE } from "./mutation-scope-graph.ts";
|
|
6
|
+
|
|
7
|
+
export type Narrowing =
|
|
8
|
+
| { readonly kind: "full"; readonly because: string }
|
|
9
|
+
| { readonly kind: "files"; readonly files: readonly string[] };
|
|
10
|
+
|
|
11
|
+
type SelfReference = {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly exports: ReadonlyMap<string, string>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type Repository = {
|
|
17
|
+
readonly files: readonly string[];
|
|
18
|
+
readonly sources: ReadonlyMap<string, string>;
|
|
19
|
+
readonly bins: ReadonlyMap<string, string>;
|
|
20
|
+
readonly self: SelfReference | undefined;
|
|
21
|
+
readonly preloads: readonly string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const FIXTURES = "tests/fixtures/";
|
|
25
|
+
// A workflow runs the suite rather than being read by it, so a test that reads one names its path.
|
|
26
|
+
const WORKFLOWS = ".github/";
|
|
27
|
+
const TEST_FILE = /\.test\.tsx?$/;
|
|
28
|
+
const SOURCE_FILE = /\.(?:[cm]?[jt]sx?|astro|svelte|vue|sh)$/;
|
|
29
|
+
const LOCKFILES = new Set(["bun.lock", "bun.lockb", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml"]);
|
|
30
|
+
const TOOL_PINS = new Set([".bun-version", ".npmrc", ".nvmrc", ".node-version", ".tool-versions"]);
|
|
31
|
+
const CONFIG = /(?:^|\.)config\.(?:[cm]?[jt]s|json)$/;
|
|
32
|
+
const STYLESHEET = /\.(?:css|scss|sass|less|pcss|styl)$/;
|
|
33
|
+
const FONT = /\.(?:woff2?|ttf|otf|eot)$/;
|
|
34
|
+
const LAYOUT = /^layout\./i;
|
|
35
|
+
const LISTED_SEGMENTS = /["'`][ \t]*,[ \t]*["'`]/g;
|
|
36
|
+
const TOKEN = /[\w.@/-]+/g;
|
|
37
|
+
const LEADING_HOPS = /^(?:\.{1,2}\/|\/)+/;
|
|
38
|
+
const BARE_IMPORT = /(?:from|import)\s*\(?\s*["']([^"'.][^"']*)["']|require\s*\(\s*["']([^"'.][^"']*)["']\s*\)/g;
|
|
39
|
+
// Bun picks the first condition it knows, and types names no file it runs.
|
|
40
|
+
const CONDITIONS = ["bun", "import", "default", "node", "require"];
|
|
41
|
+
const BUILT = "the build";
|
|
42
|
+
|
|
43
|
+
const basenameOf = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
|
|
44
|
+
const directoryNode = (directory: string): string => directory + "/";
|
|
45
|
+
|
|
46
|
+
export function sharedReason(path: string, preloads: ReadonlySet<string>): string | undefined {
|
|
47
|
+
if (path.startsWith(FIXTURES)) return undefined;
|
|
48
|
+
const name = basenameOf(path);
|
|
49
|
+
const directories = path.split("/").slice(0, -1);
|
|
50
|
+
if (LOCKFILES.has(name)) return "a lockfile";
|
|
51
|
+
if ((classifyPath(path) === "workflow" && !path.startsWith(WORKFLOWS)) || CONFIG.test(name) || TOOL_PINS.has(name)) {
|
|
52
|
+
return "a config every test runs under";
|
|
53
|
+
}
|
|
54
|
+
if (preloads.has(path)) return "a file Bun preloads into every test";
|
|
55
|
+
if (path.startsWith(TEST_TREE) && !TEST_FILE.test(path)) return "a test helper";
|
|
56
|
+
if (STYLESHEET.test(name)) return "a stylesheet";
|
|
57
|
+
if (FONT.test(name) || directories.includes("fonts")) return "a font";
|
|
58
|
+
if (directories.includes("layouts") || LAYOUT.test(name)) return "a layout";
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A path mentioned by any trailing run of two or more of its segments, or in full, names it.
|
|
63
|
+
function mentionKeys(path: string): readonly string[] {
|
|
64
|
+
const segments = path.split("/");
|
|
65
|
+
const keys = [path];
|
|
66
|
+
for (let start = 1; start <= segments.length - 2; start += 1) keys.push(segments.slice(start).join("/"));
|
|
67
|
+
return keys;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Only a fixture is read by its directory; a source directory a file names is one it lists, as a runner does.
|
|
71
|
+
function directoriesOf(path: string): readonly string[] {
|
|
72
|
+
if (!path.startsWith(FIXTURES)) return [];
|
|
73
|
+
const segments = path.split("/");
|
|
74
|
+
return segments.slice(1).map((_, end) => segments.slice(0, end + 1).join("/"));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function exportTarget(target: unknown): string | undefined {
|
|
78
|
+
if (typeof target === "string") return target;
|
|
79
|
+
if (typeof target !== "object" || target === null) return undefined;
|
|
80
|
+
const conditions: Readonly<Record<string, unknown>> = { ...target };
|
|
81
|
+
for (const condition of CONDITIONS) {
|
|
82
|
+
const found = exportTarget(conditions[condition]);
|
|
83
|
+
if (found !== undefined) return found;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function exportsMap(exports: unknown): ReadonlyMap<string, string> {
|
|
89
|
+
if (typeof exports === "string") return new Map([[".", resolved("", exports)]]);
|
|
90
|
+
if (typeof exports !== "object" || exports === null) return new Map();
|
|
91
|
+
const entries: Readonly<Record<string, unknown>> = { ...exports };
|
|
92
|
+
return new Map(Object.entries(entries).flatMap(([key, target]) => {
|
|
93
|
+
const file = exportTarget(target);
|
|
94
|
+
return file === undefined ? [] : [[key, resolved("", file)] as const];
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function selfTarget(spec: string, self: SelfReference): string | undefined {
|
|
99
|
+
if (spec !== self.name && !spec.startsWith(self.name + "/")) return undefined;
|
|
100
|
+
const subpath = spec === self.name ? "." : "./" + spec.slice(self.name.length + 1);
|
|
101
|
+
if (self.exports.size === 0) return resolved("", subpath === "." ? "index" : subpath);
|
|
102
|
+
const exact = self.exports.get(subpath);
|
|
103
|
+
if (exact !== undefined) return exact;
|
|
104
|
+
for (const [key, target] of self.exports) {
|
|
105
|
+
const star = key.indexOf("*");
|
|
106
|
+
if (star === -1) continue;
|
|
107
|
+
const [before, after] = [key.slice(0, star), key.slice(star + 1)];
|
|
108
|
+
if (subpath.startsWith(before) && subpath.endsWith(after) && subpath.length >= key.length - 1) {
|
|
109
|
+
return target.replace("*", subpath.slice(before.length, subpath.length - after.length));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return BUILT;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type Graph = { readonly readers: ReadonlyMap<string, ReadonlySet<string>>; readonly builtReaders: ReadonlySet<string> };
|
|
116
|
+
|
|
117
|
+
function linkTo(readers: Map<string, Set<string>>, read: string, reader: string): void {
|
|
118
|
+
if (read === reader) return;
|
|
119
|
+
readers.set(read, (readers.get(read) ?? new Set()).add(reader));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function selfImports(text: string, self: SelfReference, known: ReadonlySet<string>): readonly string[] {
|
|
123
|
+
return [...text.matchAll(BARE_IMPORT)].flatMap(([, viaImport = "", viaRequire = ""]) => {
|
|
124
|
+
const target = selfTarget(viaImport || viaRequire, self);
|
|
125
|
+
if (target === undefined || target === BUILT) return target === undefined ? [] : [BUILT];
|
|
126
|
+
const found = importedFile(target, known);
|
|
127
|
+
return found.length === 0 ? [BUILT] : found;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function mentioned(text: string, index: ReadonlyMap<string, readonly string[]>): readonly string[] {
|
|
132
|
+
const joined = text.replaceAll(LISTED_SEGMENTS, "/");
|
|
133
|
+
return [...joined.matchAll(TOKEN)].flatMap(([token]) => index.get(token.replace(LEADING_HOPS, "").replace(/\/$/, "")) ?? []);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function mentionIndex(repository: Repository, known: ReadonlySet<string>): ReadonlyMap<string, readonly string[]> {
|
|
137
|
+
const index = new Map<string, string[]>();
|
|
138
|
+
const add = (key: string, node: string) => index.set(key, [...(index.get(key) ?? []), node]);
|
|
139
|
+
const directories = new Set([...known].flatMap(directoriesOf));
|
|
140
|
+
for (const file of known) for (const key of mentionKeys(file)) add(key, file);
|
|
141
|
+
for (const directory of directories) for (const key of mentionKeys(directory)) if (key.includes("/")) add(key, directoryNode(directory));
|
|
142
|
+
for (const [bin, file] of repository.bins) add(bin, file);
|
|
143
|
+
return index;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function graphOf(repository: Repository, changed: readonly string[]): Graph {
|
|
147
|
+
const known = new Set([...repository.files, ...changed]);
|
|
148
|
+
const index = mentionIndex(repository, known);
|
|
149
|
+
const readers = new Map<string, Set<string>>();
|
|
150
|
+
const builtReaders = new Set<string>();
|
|
151
|
+
for (const [file, text] of repository.sources) {
|
|
152
|
+
const self = repository.self === undefined ? [] : selfImports(text, repository.self, known);
|
|
153
|
+
if (self.includes(BUILT)) builtReaders.add(file);
|
|
154
|
+
const named = file.startsWith(TEST_TREE) ? mentioned(text, index) : [];
|
|
155
|
+
for (const read of [...importsOf(file, text, known), ...self.filter((target) => target !== BUILT), ...named]) {
|
|
156
|
+
linkTo(readers, read, file);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { readers, builtReaders };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function reach(graph: Graph, changed: readonly string[], sources: ReadonlyMap<string, string>): ReadonlySet<string> {
|
|
163
|
+
const reached = new Set<string>();
|
|
164
|
+
const pending = [...changed];
|
|
165
|
+
const builds = changed.some((path) => sources.has(path) && !path.startsWith(TEST_TREE));
|
|
166
|
+
if (builds) pending.push(...graph.builtReaders);
|
|
167
|
+
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
|
168
|
+
if (reached.has(node)) continue;
|
|
169
|
+
reached.add(node);
|
|
170
|
+
pending.push(...(graph.readers.get(node) ?? []));
|
|
171
|
+
if (!node.endsWith("/")) pending.push(...directoriesOf(node).map(directoryNode));
|
|
172
|
+
}
|
|
173
|
+
return reached;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function preloadClosure(repository: Repository): ReadonlySet<string> {
|
|
177
|
+
const known = new Set(repository.files);
|
|
178
|
+
const loaded = new Set<string>();
|
|
179
|
+
for (let pending = [...repository.preloads]; pending.length > 0; ) {
|
|
180
|
+
const file = pending.pop() ?? "";
|
|
181
|
+
if (loaded.has(file)) continue;
|
|
182
|
+
loaded.add(file);
|
|
183
|
+
pending.push(...importsOf(file, repository.sources.get(file) ?? "", known));
|
|
184
|
+
}
|
|
185
|
+
return loaded;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A test or its helper reads a file it imports or names by path or bin, and a source reads only what it imports, at any depth.
|
|
189
|
+
// A source that names a path, as a registry does, would make every importer read every file it lists.
|
|
190
|
+
export function narrowTests(changed: readonly string[], repository: Repository, tests: readonly string[]): Narrowing {
|
|
191
|
+
if (changed.length === 0) return { kind: "full", because: "the pull request changes no file" };
|
|
192
|
+
const preloads = preloadClosure(repository);
|
|
193
|
+
for (const path of changed) {
|
|
194
|
+
const reason = sharedReason(path, preloads);
|
|
195
|
+
if (reason !== undefined) return { kind: "full", because: `the pull request changes ${path}, ${reason}` };
|
|
196
|
+
}
|
|
197
|
+
const reached = reach(graphOf(repository, changed), changed, repository.sources);
|
|
198
|
+
return { kind: "files", files: tests.filter((test) => reached.has(test)) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const Manifest = Schema.Struct({
|
|
202
|
+
name: Schema.optionalKey(Schema.String),
|
|
203
|
+
exports: Schema.optionalKey(Schema.Unknown),
|
|
204
|
+
bin: Schema.optionalKey(Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.String)])),
|
|
205
|
+
});
|
|
206
|
+
const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest));
|
|
207
|
+
|
|
208
|
+
class NarrowingError extends Schema.TaggedError<NarrowingError>()("NarrowingError", {
|
|
209
|
+
message: Schema.String,
|
|
210
|
+
}) {}
|
|
211
|
+
|
|
212
|
+
function binsOf(manifest: typeof Manifest.Type): ReadonlyMap<string, string> {
|
|
213
|
+
const { bin, name } = manifest;
|
|
214
|
+
if (bin === undefined) return new Map();
|
|
215
|
+
if (typeof bin === "string") return name === undefined ? new Map() : new Map([[basenameOf(name), resolved("", bin)]]);
|
|
216
|
+
return new Map(Object.entries(bin).map(([command, file]) => [command, resolved("", file)]));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const readRepository = Effect.fn("readRepository")(function* (root: string) {
|
|
220
|
+
const fs = yield* FileSystem.FileSystem;
|
|
221
|
+
const path = yield* Path.Path;
|
|
222
|
+
const files = yield* workingTreeFiles(root);
|
|
223
|
+
const read = (file: string) => fs.readFileString(path.join(root, file)).pipe(Effect.orElseSucceed(() => ""));
|
|
224
|
+
const sources = new Map(yield* Effect.forEach(files.filter((file) => SOURCE_FILE.test(file)), (file) =>
|
|
225
|
+
read(file).pipe(Effect.map((text) => [file, text] as const)), { concurrency: 16 }));
|
|
226
|
+
const manifest = files.includes("package.json") ? yield* read("package.json").pipe(Effect.flatMap(decodeManifest)) : {};
|
|
227
|
+
const bunfig = files.includes("bunfig.toml")
|
|
228
|
+
? yield* read("bunfig.toml").pipe(
|
|
229
|
+
Effect.flatMap((text) => Effect.try({ try: (): unknown => Bun.TOML.parse(text), catch: (cause) => new NarrowingError({ message: "cannot parse bunfig.toml: " + String(cause) }) })),
|
|
230
|
+
Effect.flatMap(decodeBunfig),
|
|
231
|
+
)
|
|
232
|
+
: {};
|
|
233
|
+
return {
|
|
234
|
+
files,
|
|
235
|
+
sources,
|
|
236
|
+
bins: binsOf(manifest),
|
|
237
|
+
self: manifest.name === undefined ? undefined : { name: manifest.name, exports: exportsMap(manifest.exports) },
|
|
238
|
+
preloads: preloadPaths(bunfig),
|
|
239
|
+
} satisfies Repository;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const pullRequestEvent = Config.all({
|
|
243
|
+
name: Config.String("GITHUB_EVENT_NAME").pipe(Config.withDefault("")),
|
|
244
|
+
path: Config.String("GITHUB_EVENT_PATH").pipe(Config.withDefault("")),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Only a pull_request run has a base to diff against; every other run keeps the suite whole.
|
|
248
|
+
export const readPullRequestNarrowing = Effect.fn("readPullRequestNarrowing")(function* (root: string, tests: readonly string[]) {
|
|
249
|
+
const event = yield* pullRequestEvent.pipe(Effect.orElseSucceed(() => ({ name: "", path: "" })));
|
|
250
|
+
if (event.name !== "pull_request" || event.path === "") return Option.none<Narrowing>();
|
|
251
|
+
const narrowing = Effect.gen(function* () {
|
|
252
|
+
const text = yield* (yield* FileSystem.FileSystem).readFileString(event.path);
|
|
253
|
+
const { pull_request: pullRequest } = yield* decodePullRequestEvent(text);
|
|
254
|
+
const { base, head } = pullRequestEndsOf(pullRequest);
|
|
255
|
+
const changed = yield* pathsSinceMergeBase(base, head, root);
|
|
256
|
+
return narrowTests(changed, yield* readRepository(root), tests);
|
|
257
|
+
});
|
|
258
|
+
return Option.some(yield* narrowing.pipe(
|
|
259
|
+
Effect.catch((cause) => Effect.succeed<Narrowing>({ kind: "full", because: "the pull request's changes cannot be read: " + cause.message })),
|
|
260
|
+
));
|
|
261
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
1
3
|
export const TEST_TREE = "tests/";
|
|
2
4
|
const FIXTURES = "tests/fixtures/";
|
|
3
5
|
const CANDIDATE_EXTENSIONS = [".tsx", ".ts", ".mts", ".cts", ".jsx", ".js", ".mjs", ".cjs", ".json", ".node"];
|
|
@@ -6,6 +8,17 @@ const IMPORT = /(?:from|import)\s*\(?\s*["'](\.[^"']*)["']|require\s*\(\s*["'](\
|
|
|
6
8
|
const LISTED_SEGMENTS = /["'`][ \t]*,[ \t]*["'`]/g;
|
|
7
9
|
const DIRECTORY_ENDS = ['"', "'", "`", '/"', "/'", "/`", "/${"];
|
|
8
10
|
|
|
11
|
+
const Bunfig = Schema.Struct({
|
|
12
|
+
test: Schema.optionalKey(Schema.Struct({ preload: Schema.optionalKey(Schema.Union([Schema.String, Schema.Array(Schema.String)])) })),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const decodeBunfig = Schema.decodeUnknownEffect(Bunfig);
|
|
16
|
+
|
|
17
|
+
export function preloadPaths(bunfig: typeof Bunfig.Type): readonly string[] {
|
|
18
|
+
const preload = bunfig.test?.preload ?? [];
|
|
19
|
+
return (typeof preload === "string" ? [preload] : preload).map((entry) => resolved("", entry));
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
type Needle = { readonly text: string; readonly kind: "file" | "directory"; readonly fixture: string };
|
|
10
23
|
|
|
11
24
|
export function resolved(directory: string, spec: string): string {
|
|
@@ -3,7 +3,7 @@ import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
|
3
3
|
import { escape, minimatch } from "minimatch";
|
|
4
4
|
import { changedPaths, git, pathsAt, pathsOf, rangeEnds, type Change } from "../core/git.ts";
|
|
5
5
|
import { runMain, Usage } from "../core/main.ts";
|
|
6
|
-
import { importedFile, importsOf, readersOf, resolved, TEST_TREE } from "./mutation-scope-graph.ts";
|
|
6
|
+
import { decodeBunfig, importedFile, importsOf, preloadPaths, readersOf, resolved, TEST_TREE } from "./mutation-scope-graph.ts";
|
|
7
7
|
|
|
8
8
|
const NAME = "mutation-scope";
|
|
9
9
|
const USAGE = "usage: mutation-scope.ts <base> [full-baseline-report]";
|
|
@@ -53,12 +53,6 @@ export type Baseline = typeof BaselineSchema.Type;
|
|
|
53
53
|
|
|
54
54
|
const decodeBaseline = Schema.decodeUnknownEffect(Schema.fromJsonString(BaselineSchema));
|
|
55
55
|
|
|
56
|
-
const decodeBunfig = Schema.decodeUnknownEffect(
|
|
57
|
-
Schema.Struct({
|
|
58
|
-
test: Schema.optionalKey(Schema.Struct({ preload: Schema.optionalKey(Schema.Union([Schema.String, Schema.Array(Schema.String)])) })),
|
|
59
|
-
}),
|
|
60
|
-
);
|
|
61
|
-
|
|
62
56
|
export type MutationScope = { readonly scope: readonly string[]; readonly staleTests: readonly string[] };
|
|
63
57
|
|
|
64
58
|
export type Picked = {
|
|
@@ -173,8 +167,7 @@ const preloadsAt = Effect.fn("preloadsAt")(function* (rev: string, paths: Readon
|
|
|
173
167
|
const text = yield* git(["show", `${rev}:${BUNFIG}`]);
|
|
174
168
|
const unparsed = () => new ScopeError({ message: `${BUNFIG} at ${rev} does not parse as TOML whose [test] preload is a path or a list of paths` });
|
|
175
169
|
const bunfig = yield* Effect.try({ try: (): unknown => Bun.TOML.parse(text), catch: unparsed }).pipe(Effect.flatMap(decodeBunfig), Effect.mapError(unparsed));
|
|
176
|
-
|
|
177
|
-
return (typeof preload === "string" ? [preload] : preload).map((entry) => resolved("", entry));
|
|
170
|
+
return preloadPaths(bunfig);
|
|
178
171
|
});
|
|
179
172
|
|
|
180
173
|
const transitivelyImported = Effect.fn("transitivelyImported")(function* (rev: string, roots: readonly string[], paths: ReadonlySet<string>) {
|
|
@@ -138,13 +138,13 @@ function declarationsIn(source: string, file: string, module: unknown): Declarat
|
|
|
138
138
|
return { kind: "valid", declarations };
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
function selectedTierFile(file: string, tier: TestTier | undefined): boolean {
|
|
141
|
+
export function selectedTierFile(file: string, tier: TestTier | undefined): boolean {
|
|
142
142
|
if (file.startsWith("tests/quarantine/")) return false;
|
|
143
143
|
if (tier === undefined) return !file.startsWith("tests/live/") && !file.startsWith("tests/pixel/");
|
|
144
144
|
return file.startsWith(`tests/${tier}/`);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
const scanTestFiles = Effect.fn("scanTestFiles")(function* (root: string) {
|
|
147
|
+
export const scanTestFiles = Effect.fn("scanTestFiles")(function* (root: string) {
|
|
148
148
|
return yield* Effect.tryPromise({
|
|
149
149
|
try: () =>
|
|
150
150
|
Promise.all(
|
|
@@ -156,7 +156,7 @@ const scanTestFiles = Effect.fn("scanTestFiles")(function* (root: string) {
|
|
|
156
156
|
});
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
-
const readDeclarationsIn = Effect.fn("readDeclarationsIn")(function* (root: string, files: readonly string[]) {
|
|
159
|
+
export const readDeclarationsIn = Effect.fn("readDeclarationsIn")(function* (root: string, files: readonly string[]) {
|
|
160
160
|
const fs = yield* FileSystem.FileSystem;
|
|
161
161
|
const path = yield* Path.Path;
|
|
162
162
|
const declarations: SkipDeclaration[] = [];
|