Public: anyone can read this; the crew can change it.

`bun scripts/fast.ts` prints the test files the fast tier would run for the working tree's diff against main #6580

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6570-bun-scripts-fast-ts-prints-the-test-file → main 3 commits fork

  • Merged. scout/6570-bun-scripts-fast-ts-prints-the-test-file was merged into main as 21e75fc.

Review

Sign in to review this change.
6 files changed +415 −24
README.md +13
@@ -416,6 +416,19 @@ map has never seen fall back to the import-hop guess, and a change to
416416 `forge.json`, `package.json`, `scripts/**` or `src/kernel/**` runs
417417 everything. With no map yet the line says `by imports (no impact map)`.
418418
419 Before you push, ask what the fast tier would run for your change:
420 `files=$(bun scripts/fast.ts) && bun test $files`. `bun scripts/fast.ts
421 [--base origin/main] [--why | --null | --json]` lists the picked test files
422 for the diff from the base's merge-base to the working tree (committed,
423 uncommitted, untracked), one per line on stdout and nothing else there;
424 `--why` adds a tab and the reason, `--null` ends each path with NUL (for
425 `xargs -0 -r bun test`), `--json` is `{full, files:[{file, why}]}`. Exit 0:
426 files listed. Exit 3: the change reaches no test (stdout empty). Exit 2: the
427 diff cannot be bounded (`forge.json`, `package.json`, `scripts/**`…, said on
428 stderr as `full tier: … changed`), or the arguments or git failed. The
429 `&&` is what keeps an empty or unbounded answer from becoming a bare
430 `bun test`, which would run the whole suite.
431
419432 Files go to shards by how long they take, not how big they are: longest
420433 first onto the least-loaded shard, weighed from `scripts/test-times.json`
421434 (file → seconds, from one recorded run). A file the table does not name
docs/OPERATING.md +2

Diff not shown — a line 2,328 characters wide (limit 2,000). +2

View file
scripts/fast.ts added +189
@@ -0,0 +1,189 @@
1 /**
2 * The test files the fast tier would run for your diff.
3 *
4 * bun scripts/fast.ts [--base origin/main] [--why | --null | --json]
5 * files=$(bun scripts/fast.ts) && bun test $files
6 * bun scripts/fast.ts --null | xargs -0 -r bun test
7 *
8 * The files changed between the base and the working tree (committed and
9 * uncommitted, untracked included) go through `fastSet`, with the times
10 * table and `scripts/test-impact.json` as the check reads them. Stdout
11 * holds only the answer: one path per line (`--why` adds a tab and the
12 * reason, `--null` ends each path with NUL, `--json` is one object).
13 * Words for people go to stderr. Exit 0: files listed. Exit 2: the diff
14 * is unbounded (full tier) or the arguments or git failed. Exit 3: the
15 * change reaches no test, stdout empty. A bare `bun test $(…)` with
16 * nothing listed would run everything, so guard on the exit code (`&&`).
17 * Git is spawned with an argument list, never a shell.
18 */
19
20 import { readFileSync } from "node:fs";
21 import { GATE_FILE, gateListAdds, handedImpact, needsFull } from "../src/checks/impact.ts";
22 import { fastSet, duration } from "../src/checks/tiers.ts";
23 import { importsReader, readImpact } from "./tier-input.ts";
24 import { CHECK_CACHE } from "../src/checks/cache.ts";
25 import { gateFiles } from "./gate.ts";
26 import { overlayTimes, parseTimes } from "./shards.ts";
27
28 /** A revision git cannot read as a flag. */
29 const REV = /^[A-Za-z0-9_][A-Za-z0-9_./@^~-]*$/;
30
31 export type Args =
32 | { readonly ok: true; readonly base: string; readonly format: Format }
33 | { readonly ok: false; readonly error: string };
34
35 export type Format = "lines" | "why" | "null" | "json";
36
37 /** `[--base REV] [--why | --null | --json]`; anything else is refused. */
38 export function parseArgs(argv: readonly string[]): Args {
39 let base = "origin/main";
40 let format: Format | undefined;
41 let seenBase = false;
42 for (let i = 0; i < argv.length; i++) {
43 const arg = argv[i]!;
44 if (arg === "--why" || arg === "--null" || arg === "--json") {
45 if (format !== undefined) return { ok: false, error: "one of --why, --null, --json" };
46 format = arg.slice(2) as Format;
47 } else if (arg === "--base" || arg.startsWith("--base=")) {
48 const value = arg === "--base" ? argv[++i] : arg.slice("--base=".length);
49 if (seenBase) return { ok: false, error: "--base named twice" };
50 if (value === undefined || !REV.test(value))
51 return { ok: false, error: "--base needs a revision such as origin/main" };
52 base = value;
53 seenBase = true;
54 } else return { ok: false, error: `unknown argument ${arg.slice(0, 40)}` };
55 }
56 return { ok: true, base, format: format ?? "lines" };
57 }
58
59 /** Paths from `git diff --name-only` / `ls-files` output: one per line, no repeats, none outside the repo. */
60 export function parseNames(...outputs: readonly string[]): string[] {
61 const seen = new Set<string>();
62 for (const output of outputs)
63 for (const line of output.split("\n")) {
64 const path = line.trim();
65 if (path === "" || path.startsWith("/") || path.split("/").includes("..")) continue;
66 seen.add(path);
67 }
68 return [...seen];
69 }
70
71 export interface Report {
72 /** Exactly what goes to stdout. */
73 readonly stdout: string;
74 /** Words for a person, to stderr. */
75 readonly notes: string[];
76 readonly code: 0 | 2 | 3;
77 }
78
79 /**
80 * What to print: the files, nothing (3), or the full tier (2). Stdout never
81 * carries a sentence, so word-splitting it cannot hand `bun test` a filter;
82 * `--json` always carries the whole answer, `full` included.
83 */
84 export function report(
85 plan: { files: readonly string[]; why: ReadonlyMap<string, string> },
86 unbounded: string | undefined,
87 format: Format,
88 ): Report {
89 const code = unbounded !== undefined ? 2 : plan.files.length === 0 ? 3 : 0;
90 const files = unbounded !== undefined ? [] : plan.files;
91 const notes =
92 unbounded !== undefined
93 ? [`fast: full tier: ${unbounded} changed; the diff is not bounded`]
94 : files.length === 0
95 ? ["fast: the change reaches no test; nothing to run"]
96 : [];
97 if (format === "json")
98 return {
99 stdout:
100 JSON.stringify({
101 full: unbounded ?? null,
102 files: files.map((file) => ({ file, why: plan.why.get(file) ?? "" })),
103 }) + "\n",
104 notes,
105 code,
106 };
107 if (format === "null") return { stdout: files.map((file) => `${file}\0`).join(""), notes, code };
108 return {
109 stdout: files
110 .map((file) => (format === "why" ? `${file}\t${plan.why.get(file) ?? ""}\n` : `${file}\n`))
111 .join(""),
112 notes,
113 code,
114 };
115 }
116
117 function git(args: string[]): string {
118 const run = Bun.spawnSync(["git", ...args], { stderr: "pipe" });
119 if (run.exitCode !== 0) throw new Error(`git ${args[0]} failed: ${run.stderr.toString().trim()}`);
120 return run.stdout.toString();
121 }
122
123 function read(path: string): string | undefined {
124 try {
125 return readFileSync(path, "utf8");
126 } catch {
127 return undefined;
128 }
129 }
130
131 function loadTimes(): Map<string, number> {
132 const one = (path: string): Map<string, number> => {
133 const text = read(path);
134 const parsed = text === undefined ? undefined : parseTimes(text);
135 return parsed?.ok ? parsed.value : new Map();
136 };
137 return overlayTimes(
138 one(new URL("./test-times.json", import.meta.url).pathname),
139 one(`${CHECK_CACHE}/test-times.json`),
140 );
141 }
142
143 function main(): number {
144 const args = parseArgs(process.argv.slice(2));
145 if (!args.ok) {
146 console.error(`fast: ${args.error}`);
147 return 2;
148 }
149 let changed: string[];
150 let gateAdds: string[] | undefined;
151 try {
152 const from = git(["merge-base", args.base, "HEAD"]).trim();
153 changed = parseNames(
154 git(["diff", "--name-only", "--no-renames", from]),
155 git(["ls-files", "--others", "--exclude-standard"]),
156 );
157 if (changed.includes(GATE_FILE))
158 gateAdds = gateListAdds(git(["diff", "-U0", "--no-renames", from, "--", GATE_FILE]));
159 } catch (error) {
160 console.error(`fast: ${(error as Error).message}`);
161 return 2;
162 }
163 const tests = gateFiles();
164 const handed = handedImpact(process.env["FORGE_CHECK_IMPACT"]);
165 const impact = readImpact(
166 handed ?? new URL("./test-impact.json", import.meta.url).pathname,
167 handed ?? "scripts/test-impact.json",
168 (line) => console.error(`fast: ${line}`),
169 );
170 const imports = importsReader();
171 const plan = fastSet({
172 ...(impact === undefined ? {} : { impact }),
173 changed,
174 ...(gateAdds === undefined ? {} : { gateAdds }),
175 tests,
176 imports,
177 times: loadTimes(),
178 });
179 const out = report(plan, needsFull(changed, gateAdds), args.format);
180 process.stdout.write(out.stdout);
181 for (const note of out.notes) console.error(note);
182 if (out.code === 0)
183 console.error(
184 `fast: ${plan.files.length} of ${tests.length} files for ${changed.length} changed, expected ${duration(plan.expected)}${plan.overBudget ? " (over the 5m budget)" : ""}`,
185 );
186 return out.code;
187 }
188
189 if (import.meta.main) process.exit(main());
scripts/test-shards.ts +4 −24
@@ -68,7 +68,8 @@ import {
6868 import { gateFiles, gateRefusal, presentTests } from "./gate.ts";
6969 import { cgroupMemory, summaryLines } from "./gate-summary.ts";
7070 import { GATE_FILE, executedFiles, formatImpact, gateListAdds, handedImpact, mergeImpact, parseImpact } from "../src/checks/impact.ts";
71 import { fastSet, importsOf, duration } from "../src/checks/tiers.ts";
71 import { importsReader, readImpact } from "./tier-input.ts";
72 import { fastSet, duration } from "../src/checks/tiers.ts";
7273 import { CHECK_CACHE } from "../src/checks/cache.ts";
7374 import { parseCheck } from "../src/apps/manifest.ts";
7475
@@ -259,29 +260,8 @@ if (tier === "fast") {
259260 );
260261 if (gateDiff.exitCode === 0) gateAdds = gateListAdds(gateDiff.stdout.toString());
261262 }
262 const cache = new Map<string, string[]>();
263 const imports = (file: string): string[] => {
264 let found = cache.get(file);
265 if (found === undefined) {
266 try {
267 found = importsOf(readFileSync(file, "utf8"), file).flatMap((path) =>
268 /\.[cm]?[jt]sx?$/.test(path) ? [path] : [path, `${path}.ts`],
269 );
270 } catch {
271 found = [];
272 }
273 cache.set(file, found);
274 }
275 return found;
276 };
277 let impact: Map<string, string[]> | undefined;
278 try {
279 const parsed = parseImpact(readFileSync(IMPACT, "utf8"));
280 if (parsed.ok) impact = parsed.value;
281 else console.log(`test-shards: ${IMPACT_NAME} refused (${parsed.error}); guessing by imports`);
282 } catch {
283 // no map yet: the import guess stands
284 }
263 const imports = importsReader();
264 const impact = readImpact(IMPACT, IMPACT_NAME, (line) => console.log(`test-shards: ${line}`));
285265 const plan = fastSet({
286266 ...(impact === undefined ? {} : { impact }),
287267 changed,
scripts/tier-input.ts added +47
@@ -0,0 +1,47 @@
1 /**
2 * What `fastSet` reads from the working tree, for the two scripts that ask
3 * it: `test-shards.ts --tier fast` and `fast.ts`. One reading, so the
4 * check and the builder's preview cannot drift apart.
5 */
6
7 import { readFileSync } from "node:fs";
8 import { importsOf } from "../src/checks/tiers.ts";
9 import { parseImpact } from "../src/checks/impact.ts";
10
11 /** A file's relative imports as candidate paths (an extensionless one also as `.ts`); `[]` when unreadable. Memoised. */
12 export function importsReader(): (file: string) => string[] {
13 const cache = new Map<string, string[]>();
14 return (file) => {
15 let found = cache.get(file);
16 if (found === undefined) {
17 try {
18 found = importsOf(readFileSync(file, "utf8"), file).flatMap((path) =>
19 /\.[cm]?[jt]sx?$/.test(path) ? [path] : [path, `${path}.ts`],
20 );
21 } catch {
22 found = [];
23 }
24 cache.set(file, found);
25 }
26 return found;
27 };
28 }
29
30 /**
31 * The impact map at `path`, or `undefined` when there is none yet (the
32 * import guess stands) or it is refused (said through `say`).
33 */
34 export function readImpact(
35 path: string,
36 name: string,
37 say: (line: string) => void,
38 ): Map<string, string[]> | undefined {
39 try {
40 const parsed = parseImpact(readFileSync(path, "utf8"));
41 if (parsed.ok) return parsed.value;
42 say(`${name} refused (${parsed.error}); guessing by imports`);
43 } catch {
44 // no map yet
45 }
46 return undefined;
47 }
test/scripts-fast.test.ts added +160
@@ -0,0 +1,160 @@
1 import { expect, test } from "bun:test";
2 import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { parseArgs, parseNames, report } from "../scripts/fast.ts";
6
7 test("parseArgs defaults to origin/main and takes --base and --why", () => {
8 expect(parseArgs([])).toEqual({ ok: true, base: "origin/main", format: "lines" });
9 expect(parseArgs(["--base", "main", "--why"])).toEqual({ ok: true, base: "main", format: "why" });
10 expect(parseArgs(["--base=HEAD~3"])).toEqual({ ok: true, base: "HEAD~3", format: "lines" });
11 });
12
13 test("parseArgs refuses flags-as-revisions, repeats and strangers", () => {
14 for (const bad of [["--base"], ["--base", "--output=x"], ["--base", "a b"], ["--base", "a", "--base", "b"], ["--nope"]])
15 expect(parseArgs(bad).ok).toBe(false);
16 });
17
18 test("parseNames joins outputs, drops blanks, repeats and paths outside the repo", () => {
19 expect(parseNames("a.ts\n\nsrc/b.ts\n", "src/b.ts\n../x\n/etc/passwd\ntest/c.test.ts")).toEqual([
20 "a.ts",
21 "src/b.ts",
22 "test/c.test.ts",
23 ]);
24 });
25
26 const plan = { files: ["test/a.test.ts"], why: new Map([["test/a.test.ts", "changed"]]) };
27
28 test("parseArgs takes one output format", () => {
29 expect(parseArgs(["--null"])).toEqual({ ok: true, base: "origin/main", format: "null" });
30 expect(parseArgs(["--json", "--why"]).ok).toBe(false);
31 });
32
33 test("report lists files in each format, sentences only in notes", () => {
34 expect(report(plan, undefined, "lines")).toEqual({ stdout: "test/a.test.ts\n", notes: [], code: 0 });
35 expect(report(plan, undefined, "why").stdout).toBe("test/a.test.ts\tchanged\n");
36 expect(report(plan, undefined, "null").stdout).toBe("test/a.test.ts\0");
37 expect(JSON.parse(report(plan, undefined, "json").stdout)).toEqual({
38 full: null,
39 files: [{ file: "test/a.test.ts", why: "changed" }],
40 });
41 });
42
43 test("an unbounded diff is exit 2 with nothing on stdout but json", () => {
44 for (const format of ["lines", "why", "null"] as const) {
45 const out = report(plan, "package.json", format);
46 expect(out.code).toBe(2);
47 expect(out.stdout).toBe("");
48 expect(out.notes[0]).toContain("full tier");
49 }
50 expect(JSON.parse(report(plan, "package.json", "json").stdout)).toEqual({ full: "package.json", files: [] });
51 });
52
53 test("a diff that reaches no test is exit 3 with nothing on stdout", () => {
54 const out = report({ files: [], why: new Map() }, undefined, "lines");
55 expect(out).toEqual({ stdout: "", notes: ["fast: the change reaches no test; nothing to run"], code: 3 });
56 });
57
58 // The script itself, against a throwaway repository.
59 const SCRIPT = new URL("../scripts/fast.ts", import.meta.url).pathname;
60
61 function repo(): string {
62 const dir = mkdtempSync(join(process.env["TMPDIR"] ?? tmpdir(), "fast-"));
63 const git = (...args: string[]): void => {
64 const run = Bun.spawnSync(
65 ["git", "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args],
66 { cwd: dir },
67 );
68 if (run.exitCode !== 0) throw new Error(run.stderr.toString());
69 };
70 mkdirSync(join(dir, "src"));
71 mkdirSync(join(dir, "test"));
72 writeFileSync(join(dir, "src/widget.ts"), "export const a = 1;\n");
73 writeFileSync(join(dir, "src/other.ts"), "export const b = 1;\n");
74 writeFileSync(join(dir, "test/widget.test.ts"), "export {};\n");
75 writeFileSync(join(dir, "test/other.test.ts"), "export {};\n");
76 writeFileSync(join(dir, "package.json"), "{}\n");
77 git("init", "-q", "-b", "main");
78 git("add", "-A");
79 git("commit", "-qm", "base");
80 git("checkout", "-qb", "work");
81 return dir;
82 }
83
84 function run(dir: string, ...args: string[]): { out: string; err: string; code: number } {
85 const env = { ...process.env };
86 delete env["FORGE_CHECK_IMPACT"];
87 const done = Bun.spawnSync(["bun", SCRIPT, ...args], { cwd: dir, env });
88 return { out: done.stdout.toString(), err: done.stderr.toString(), code: done.exitCode ?? -1 };
89 }
90
91 test("a changed source file lists the test it names, committed or not, with --why", () => {
92 const dir = repo();
93 try {
94 writeFileSync(join(dir, "src/widget.ts"), "export const a = 2;\n");
95 const plain = run(dir, "--base", "main");
96 expect(plain.code).toBe(0);
97 expect(plain.out.trim().split("\n")).toEqual(["test/widget.test.ts"]);
98 const why = run(dir, "--base", "main", "--why");
99 expect(why.out).toContain("test/widget.test.ts\tnamed for src/widget.ts");
100 // Untracked counts too.
101 writeFileSync(join(dir, "src/other.ts"), "export const b = 2;\n");
102 expect(run(dir, "--base", "main").out.trim().split("\n")).toEqual([
103 "test/other.test.ts",
104 "test/widget.test.ts",
105 ]);
106 } finally {
107 rmSync(dir, { recursive: true, force: true });
108 }
109 });
110
111 test("an unbounded diff exits 2 with the full-tier line and no file list", () => {
112 const dir = repo();
113 try {
114 writeFileSync(join(dir, "package.json"), '{"a":1}\n');
115 const out = run(dir, "--base", "main");
116 expect(out.code).toBe(2);
117 expect(out.out).toBe("");
118 expect(out.err).toContain("full tier: package.json changed");
119 const json = run(dir, "--base", "main", "--json");
120 expect(json.code).toBe(2);
121 expect(JSON.parse(json.out)).toEqual({ full: "package.json", files: [] });
122 } finally {
123 rmSync(dir, { recursive: true, force: true });
124 }
125 });
126
127 test("a base git cannot find exits 2 with a message", () => {
128 const dir = repo();
129 try {
130 const out = run(dir, "--base", "nope");
131 expect(out.code).toBe(2);
132 expect(out.err).toContain("fast:");
133 } finally {
134 rmSync(dir, { recursive: true, force: true });
135 }
136 });
137
138 test("a clean tree and a docs-only diff exit 3 with empty stdout, so `&&` stops bun test", () => {
139 const dir = repo();
140 try {
141 const clean = run(dir, "--base", "main");
142 expect([clean.code, clean.out]).toEqual([3, ""]);
143 expect(clean.err).toContain("nothing to run");
144 writeFileSync(join(dir, "NOTES.md"), "hi\n");
145 expect(run(dir, "--base", "main", "--null").code).toBe(3);
146 } finally {
147 rmSync(dir, { recursive: true, force: true });
148 }
149 });
150
151 test("--null and --json carry the files without word-splitting", () => {
152 const dir = repo();
153 try {
154 writeFileSync(join(dir, "src/widget.ts"), "export const a = 2;\n");
155 expect(run(dir, "--base", "main", "--null").out).toBe("test/widget.test.ts\0");
156 expect(JSON.parse(run(dir, "--base", "main", "--json").out).files[0].file).toBe("test/widget.test.ts");
157 } finally {
158 rmSync(dir, { recursive: true, force: true });
159 }
160 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs