Public: anyone can read this; the crew can change it.
A check never redoes work whose inputs did not change: results are kept per t… #12224
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12178-a-check-never-redoes-work-whose-inputs-d → main 4 commits fork 2 threads · 2 unresolved
9 files
changed +612
−2
.gitignore +4
| @@ -24,6 +24,10 @@ typesafe-api-key | ||
| 24 | 24 | # what a check keeps for the next one (src/checks/cache.ts) |
| 25 | 25 | /.forge-cache/ |
| 26 | 26 | |
| 27 | # what a check hands between runs: the impact map and the kept results, never committed | |
| 28 | /.forge-impact.json | |
| 29 | /.forge-kept.json | |
| 30 | ||
| 27 | 31 | # empty stubs an agent sandbox mounts over home-directory config; never the |
| 28 | 32 | # forge's own files, and never committed (#1148) |
| 29 | 33 | /.bash_profile |
docs/DEVELOP.md +10
| @@ -104,6 +104,16 @@ map has never seen fall back to the import-hop guess, and a change to | ||
| 104 | 104 | `forge.json`, `package.json`, `scripts/**` or `src/kernel/**` runs |
| 105 | 105 | everything. With no map yet the line says `by imports (no impact map)`. |
| 106 | 106 | |
| 107 | Kept results (#12178). A selected file whose inputs a default-branch | |
| 108 | measuring run already proved green is not run again: the log says | |
| 109 | `kept from <commit>`. The key is the Bun, the tooling files | |
| 110 | (`KEPT_TOOLING`: the runner scripts, `forge.json`, the preload among | |
| 111 | them), the test, the files its impact entry names and its imports. Only a | |
| 112 | file green on its first try is kept (never one a retry made green), only | |
| 113 | the default branch's run hands results back (`$FORGE_CHECK_KEPT`, | |
| 114 | `src/checks/kept-store.ts`), a test that opens the tree is never kept, and | |
| 115 | one kept file in ten is run anyway (`sampled`) until an audit exists. | |
| 116 | ||
| 107 | 117 | A test that reads the whole tree — every test file, every migration, the |
| 108 | 118 | README — is reached by no diff, yet any file can break it, so every fast |
| 109 | 119 | tier runs it whatever else the diff holds (#7138) — a diff of only the |
scripts/kept-input.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * What a test file reads, as hashes, for `src/checks/kept.ts`: the file | |
| 3 | * itself, the `src/**` files its impact entry names, the files it imports | |
| 4 | * (through any depth, inside the repository), and the tooling files. The | |
| 5 | * one reading for the run that keeps a result and the run that looks one up. | |
| 6 | */ | |
| 7 | ||
| 8 | import { readFileSync } from "node:fs"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { sha256 } from "../src/kernel/hash.ts"; | |
| 11 | import { | |
| 12 | KEPT_TOOLING, | |
| 13 | findsItsPlace, | |
| 14 | keptKey, | |
| 15 | readsTheTree, | |
| 16 | sampled, | |
| 17 | splitKept, | |
| 18 | type Kept, | |
| 19 | type KeptEntry, | |
| 20 | type KeptSplit, | |
| 21 | } from "../src/checks/kept.ts"; | |
| 22 | ||
| 23 | const MOST_FILES = 400; | |
| 24 | ||
| 25 | function text(root: string, path: string): string | undefined { | |
| 26 | try { | |
| 27 | return readFileSync(join(root, path), "utf8"); | |
| 28 | } catch { | |
| 29 | return undefined; | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | /** The key of a test file, or `undefined` when it is never kept (no impact entry, reads the tree, an import unreadable); `root` is the checkout. */ | |
| 34 | export function keyOfTest( | |
| 35 | test: string, | |
| 36 | impact: ReadonlyMap<string, readonly string[]>, | |
| 37 | imports: (file: string) => readonly string[], | |
| 38 | root: string = ".", | |
| 39 | ): string | undefined { | |
| 40 | const entry = impact.get(test); | |
| 41 | if (entry === undefined) return undefined; | |
| 42 | const seen = new Map<string, string>(); | |
| 43 | const queue = [test, ...entry]; | |
| 44 | while (queue.length > 0) { | |
| 45 | const file = queue.pop()!; | |
| 46 | if (seen.has(file)) continue; | |
| 47 | const source = text(root, file); | |
| 48 | if (source === undefined) { | |
| 49 | // A named source file gone is a changed input; an import guess that is no file is no input. | |
| 50 | if (entry.includes(file) || file === test) seen.set(file, "absent"); | |
| 51 | continue; | |
| 52 | } | |
| 53 | // A test, or a helper it imports, that opens the tree reads what no entry names; a source | |
| 54 | // file that finds its own place may do the same. | |
| 55 | if (file.startsWith("src/") ? findsItsPlace(source) : readsTheTree(source)) return undefined; | |
| 56 | seen.set(file, sha256(source)); | |
| 57 | if (seen.size > MOST_FILES) return undefined; | |
| 58 | for (const next of imports(file)) if (!seen.has(next)) queue.push(next); | |
| 59 | } | |
| 60 | for (const file of KEPT_TOOLING) seen.set(file, sha256(text(root, file) ?? "absent")); | |
| 61 | return keptKey({ bun: Bun.version, platform: process.platform, test, files: [...seen] }); | |
| 62 | } | |
| 63 | ||
| 64 | /** What the fast tier runs and what it leaves, given the kept results. */ | |
| 65 | export function answerKept(x: { | |
| 66 | readonly files: readonly string[]; | |
| 67 | readonly kept: Kept; | |
| 68 | readonly impact: ReadonlyMap<string, readonly string[]>; | |
| 69 | readonly imports: (file: string) => readonly string[]; | |
| 70 | readonly commit: string; | |
| 71 | readonly root?: string; | |
| 72 | }): KeptSplit { | |
| 73 | return splitKept( | |
| 74 | x.files, | |
| 75 | x.kept, | |
| 76 | (file) => keyOfTest(file, x.impact, x.imports, x.root), | |
| 77 | (file) => sampled(file, x.commit), | |
| 78 | ); | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * The results a measuring run keeps: a file that ran alone, with coverage, green on its | |
| 83 | * first try. A file a retry made green is never kept. | |
| 84 | */ | |
| 85 | export function freshKept(x: { | |
| 86 | readonly results: readonly { readonly files: readonly string[]; readonly code: number; readonly retried: boolean }[]; | |
| 87 | readonly measured: ReadonlySet<string>; | |
| 88 | readonly impact: ReadonlyMap<string, readonly string[]>; | |
| 89 | readonly imports: (file: string) => readonly string[]; | |
| 90 | readonly commit: string; | |
| 91 | readonly root?: string; | |
| 92 | }): Map<string, KeptEntry> { | |
| 93 | const fresh = new Map<string, KeptEntry>(); | |
| 94 | for (const result of x.results) { | |
| 95 | if (result.code !== 0 || result.retried || result.files.length !== 1) continue; | |
| 96 | const test = result.files[0]!.replace(/^\.\//, ""); | |
| 97 | if (!x.measured.has(test)) continue; | |
| 98 | const key = keyOfTest(test, x.impact, x.imports, x.root); | |
| 99 | if (key !== undefined) fresh.set(key, { test, commit: x.commit }); | |
| 100 | } | |
| 101 | return fresh; | |
| 102 | } |
scripts/test-shards.ts +51 −2
| @@ -99,6 +99,8 @@ import { CHECK_CACHE } from "../src/checks/cache.ts"; | ||
| 99 | 99 | import { parseCheck } from "../src/apps/manifest.ts"; |
| 100 | 100 | import { FLAKY_ALONE, onlyFiles } from "../src/checks/verify.ts"; |
| 101 | 101 | import { crashFlakeLine } from "../src/checks/flake.ts"; |
| 102 | import { formatKept, keptLine, mergeKept, parseKept, type KeptEntry } from "../src/checks/kept.ts"; | |
| 103 | import { answerKept, freshKept } from "./kept-input.ts"; | |
| 102 | 104 | import { askBase, baseVerdict, type OnBase } from "../src/checks/base_red.ts"; |
| 103 | 105 | |
| 104 | 106 | const TIMES = new URL("./test-times.json", import.meta.url).pathname; |
| @@ -108,6 +110,8 @@ const TIMES = new URL("./test-times.json", import.meta.url).pathname; | ||
| 108 | 110 | const handed = handedImpact(process.env["FORGE_CHECK_IMPACT"]); |
| 109 | 111 | const IMPACT = handed ?? new URL("./test-impact.json", import.meta.url).pathname; |
| 110 | 112 | const IMPACT_NAME = handed ?? "scripts/test-impact.json"; |
| 113 | // Test results kept by what each reads (`src/checks/kept.ts`): handed over like the map, by name. | |
| 114 | const KEPT = handedImpact(process.env["FORGE_CHECK_KEPT"]); | |
| 111 | 115 | // What the forge keeps between checks (`src/checks/cache.ts`): the full |
| 112 | 116 | // gate's own per-file times, fresher than the committed table. |
| 113 | 117 | const CACHED_TIMES = `${CHECK_CACHE}/test-times.json`; |
| @@ -326,6 +330,31 @@ if (tier === "fast") { | ||
| 326 | 330 | process.exit(0); |
| 327 | 331 | } |
| 328 | 332 | files = plan.files; |
| 333 | // A file whose inputs a default-branch run already proved green is not run again. | |
| 334 | if (KEPT !== undefined && impact !== undefined) { | |
| 335 | let kept: ReturnType<typeof parseKept> | undefined; | |
| 336 | try { | |
| 337 | kept = parseKept(readFileSync(KEPT, "utf8")); | |
| 338 | } catch { | |
| 339 | // none yet | |
| 340 | } | |
| 341 | if (kept?.ok) { | |
| 342 | const split = answerKept({ | |
| 343 | files, | |
| 344 | kept: kept.value, | |
| 345 | impact, | |
| 346 | imports, | |
| 347 | commit: process.env["FORGE_COMMIT"] ?? "", | |
| 348 | }); | |
| 349 | for (const [file, commit] of split.kept) console.log(keptLine(file, commit)); | |
| 350 | console.log(`test-shards: ${split.kept.size} of ${files.length} files kept, ${split.run.length} to run`); | |
| 351 | files = split.run; | |
| 352 | if (files.length === 0) { | |
| 353 | console.log("test-shards: every selected file is kept; nothing to run"); | |
| 354 | process.exit(0); | |
| 355 | } | |
| 356 | } | |
| 357 | } | |
| 329 | 358 | } |
| 330 | 359 | |
| 331 | 360 | const only = onlyFiles(process.env["FORGE_CHECK_ONLY"]); |
| @@ -486,7 +515,7 @@ let crashReruns = 0; | ||
| 486 | 515 | /** What a live first run shows once it has gone red: held until its end. */ |
| 487 | 516 | const RED_SIGN = /^(\(fail\)|# Unhandled error|error:)/m; |
| 488 | 517 | |
| 489 | type Timed = ShardResult & { seconds: number; tests: TestTime[]; overBudget: Breach[] }; | |
| 518 | type Timed = ShardResult & { seconds: number; tests: TestTime[]; overBudget: Breach[]; retried: boolean }; | |
| 490 | 519 | |
| 491 | 520 | // The budget (#1242) is red on the whole gate, for every file SLOW does not |
| 492 | 521 | // name, and on a fast tier for the test files the pull itself changed: a |
| @@ -626,6 +655,7 @@ async function runShardIn( | ||
| 626 | 655 | // Green the second time: the crash is a flake, named for the check to record. |
| 627 | 656 | if (first.code === 0) process.stderr.write(`${crashFlakeLine(where, quoted)}\n`); |
| 628 | 657 | } |
| 658 | let retried = crashed; | |
| 629 | 659 | let code = first.code; |
| 630 | 660 | let output = quoted === "" ? first.output : [quoted, ...first.output]; |
| 631 | 661 | let lastSeconds = first.seconds; |
| @@ -638,6 +668,7 @@ async function runShardIn( | ||
| 638 | 668 | rmSync(covDir, { recursive: true, force: true }); |
| 639 | 669 | mkdirSync(covDir); |
| 640 | 670 | } |
| 671 | retried = true; | |
| 641 | 672 | const again = await run(paths, cover, through); |
| 642 | 673 | lastSeconds = again.seconds; |
| 643 | 674 | tests = testsOfLastRun(); |
| @@ -661,6 +692,7 @@ async function runShardIn( | ||
| 661 | 692 | } |
| 662 | 693 | const verdict = besideOthers(red, alone, RETRY_CAP); |
| 663 | 694 | if (verdict.line !== null) { |
| 695 | retried = true; | |
| 664 | 696 | const note = `${output.join("").endsWith("\n") ? "" : "\n"}test-shards: ${verdict.line}\n`; |
| 665 | 697 | output.push(note); |
| 666 | 698 | through?.(note); |
| @@ -734,6 +766,7 @@ async function runShardIn( | ||
| 734 | 766 | seconds: (performance.now() - started) / 1000, |
| 735 | 767 | tests, |
| 736 | 768 | overBudget, |
| 769 | retried, | |
| 737 | 770 | }; |
| 738 | 771 | } |
| 739 | 772 | |
| @@ -814,8 +847,24 @@ if (measure) { | ||
| 814 | 847 | if ((error as NodeJS.ErrnoException).code !== "ENOENT") |
| 815 | 848 | console.log(`test-shards: ${IMPACT_NAME} unreadable (${String(error)}); writing a fresh map`); |
| 816 | 849 | } |
| 817 | writeFileSync(IMPACT, formatImpact(mergeImpact(old, measured, GATE))); | |
| 850 | const merged = mergeImpact(old, measured, GATE); | |
| 851 | writeFileSync(IMPACT, formatImpact(merged)); | |
| 818 | 852 | console.log(`test-shards: wrote ${measured.size} test impacts to ${IMPACT_NAME}`); |
| 853 | // Each file that ran green with coverage keeps its result under what it reads. | |
| 854 | const commit = process.env["FORGE_COMMIT"] ?? ""; | |
| 855 | if (KEPT !== undefined && /^[0-9a-f]{7,64}$/.test(commit)) { | |
| 856 | const imports = importsReader(); | |
| 857 | const fresh = freshKept({ results, measured: new Set(measured.keys()), impact: merged, imports, commit }); | |
| 858 | let old: Map<string, KeptEntry> = new Map(); | |
| 859 | try { | |
| 860 | const parsed = parseKept(readFileSync(KEPT, "utf8")); | |
| 861 | if (parsed.ok) old = parsed.value; | |
| 862 | } catch { | |
| 863 | // none yet | |
| 864 | } | |
| 865 | writeFileSync(KEPT, formatKept(mergeKept(old, fresh))); | |
| 866 | console.log(`test-shards: kept ${fresh.size} green results in ${KEPT}`); | |
| 867 | } | |
| 819 | 868 | } |
| 820 | 869 | } |
| 821 | 870 | // The timed full gate is the check on the cell itself: its log ends with |
src/checks/kept-store.ts added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | /** | |
| 2 | * The kept test results beside the forge (#12178), as the impact map is | |
| 3 | * (`impact-store.ts`): every check's checkout is seeded with them, and | |
| 4 | * only a run of the default branch's own measuring gate hands them back. | |
| 5 | * A pull's code never fills what another check reads. What comes back is | |
| 6 | * repository code's output: size-capped and parsed by `parseKept`. | |
| 7 | */ | |
| 8 | ||
| 9 | import { lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { formatKept, parseKept } from "./kept.ts"; | |
| 12 | ||
| 13 | /** Where in the checkout the results are handed over, both ways. */ | |
| 14 | export const KEPT_FILE = ".forge-kept.json"; | |
| 15 | ||
| 16 | const MAX_BYTES = 2 * 1024 * 1024; | |
| 17 | ||
| 18 | const storeOf = (dataDir: string, repoId: string): string => | |
| 19 | join(dataDir, "checks", "kept", `${repoId.replace(/[^A-Za-z0-9_-]/g, "_")}.json`); | |
| 20 | ||
| 21 | function readValid(path: string): string | undefined { | |
| 22 | try { | |
| 23 | if (statSync(path).size > MAX_BYTES) return undefined; | |
| 24 | const text = readFileSync(path, "utf8"); | |
| 25 | const parsed = parseKept(text); | |
| 26 | return parsed.ok && parsed.value.size > 0 ? formatKept(parsed.value) : undefined; | |
| 27 | } catch { | |
| 28 | return undefined; | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | /** Copies the kept results into a checkout; how many, or 0 for none. */ | |
| 33 | export function seedKept(dataDir: string, repoId: string, checkout: string): number { | |
| 34 | const text = readValid(storeOf(dataDir, repoId)); | |
| 35 | if (text === undefined) return 0; | |
| 36 | const path = join(checkout, KEPT_FILE); | |
| 37 | rmSync(path, { force: true, recursive: true }); | |
| 38 | writeFileSync(path, text, { flag: "wx" }); | |
| 39 | return Object.keys(JSON.parse(text)).length; | |
| 40 | } | |
| 41 | ||
| 42 | /** | |
| 43 | * Keeps what a run of the default branch left in its checkout; the log line, or `undefined` | |
| 44 | * when it left none or the run is not the default branch's (`byDefaultBranch`). | |
| 45 | */ | |
| 46 | export function keepKept( | |
| 47 | dataDir: string, | |
| 48 | repoId: string, | |
| 49 | checkout: string, | |
| 50 | byDefaultBranch: boolean, | |
| 51 | ): string | undefined { | |
| 52 | if (!byDefaultBranch) return undefined; | |
| 53 | const path = join(checkout, KEPT_FILE); | |
| 54 | let size: number; | |
| 55 | try { | |
| 56 | const stat = lstatSync(path); | |
| 57 | if (!stat.isFile()) return "kept results not kept: not a plain file"; | |
| 58 | size = stat.size; | |
| 59 | } catch { | |
| 60 | return undefined; | |
| 61 | } | |
| 62 | if (size > MAX_BYTES) return `kept results not kept: ${size} bytes is over ${MAX_BYTES}`; | |
| 63 | const parsed = parseKept(readFileSync(path, "utf8")); | |
| 64 | if (!parsed.ok) return `kept results not kept: ${parsed.error}`; | |
| 65 | if (parsed.value.size === 0) return undefined; | |
| 66 | mkdirSync(join(dataDir, "checks", "kept"), { recursive: true }); | |
| 67 | const partial = `${storeOf(dataDir, repoId)}.${process.pid}.tmp`; | |
| 68 | writeFileSync(partial, formatKept(parsed.value)); | |
| 69 | renameSync(partial, storeOf(dataDir, repoId)); | |
| 70 | return `kept results kept: ${parsed.value.size} tests`; | |
| 71 | } |
src/checks/kept.ts added +165
| @@ -0,0 +1,165 @@ | ||
| 1 | /** | |
| 2 | * A test file's result, kept by what it reads (#12178). The fast tier ran | |
| 3 | * every selected test again each time main moved, though most had read | |
| 4 | * nothing the move touched. A green test file of the default branch's own | |
| 5 | * measuring run is remembered under a key: the Bun that ran it, the files | |
| 6 | * the gate's tooling reads, the test file's own text, the source files its | |
| 7 | * impact entry says it executed, and the files it imports. A pull's fast | |
| 8 | * tier that finds the same key does not run the file again and says where | |
| 9 | * the result was kept from. | |
| 10 | * | |
| 11 | * Only a run that recorded coverage keeps a result (the key needs the | |
| 12 | * impact entry), only a file that ran green on its first try is kept (never | |
| 13 | * one a retry made green), only the default branch's run writes, a sample | |
| 14 | * of kept files runs anyway, and a test that | |
| 15 | * reads the tree or starts processes is never kept: its inputs are not | |
| 16 | * the files named here. The tier that lands a change never skips a test. | |
| 17 | * | |
| 18 | * Plain data in, plain data out; `scripts/kept-input.ts` reads the files. | |
| 19 | */ | |
| 20 | ||
| 21 | import { sha256 } from "../kernel/hash.ts"; | |
| 22 | ||
| 23 | /** Most results one repository keeps. */ | |
| 24 | export const KEPT_MAX = 4000; | |
| 25 | ||
| 26 | /** What the tooling reads besides the files named: a change to any is a new key. */ | |
| 27 | export const KEPT_TOOLING: readonly string[] = [ | |
| 28 | "package.json", | |
| 29 | "bun.lock", | |
| 30 | "bunfig.toml", | |
| 31 | "tsconfig.json", | |
| 32 | "forge.json", | |
| 33 | "test/helpers/preload.ts", | |
| 34 | "scripts/test-shards.ts", | |
| 35 | "scripts/kept-input.ts", | |
| 36 | "scripts/tier-input.ts", | |
| 37 | "src/checks/kept.ts", | |
| 38 | ]; | |
| 39 | ||
| 40 | /** One in this many kept files is run anyway, until an audit of kept results exists. */ | |
| 41 | export const SAMPLE_EVERY = 10; | |
| 42 | ||
| 43 | /** | |
| 44 | * Whether `file` is in the sample run again at `commit`: the same answer for the same pair, a | |
| 45 | * new one next commit. | |
| 46 | */ | |
| 47 | export function sampled(file: string, commit: string, every: number = SAMPLE_EVERY): boolean { | |
| 48 | return parseInt(sha256(`${commit}\0${file}`).slice(0, 8), 16) % every === 0; | |
| 49 | } | |
| 50 | ||
| 51 | export interface KeptEntry { | |
| 52 | readonly test: string; | |
| 53 | /** The commit whose run proved it. */ | |
| 54 | readonly commit: string; | |
| 55 | } | |
| 56 | ||
| 57 | export type Kept = ReadonlyMap<string, KeptEntry>; | |
| 58 | ||
| 59 | /** One result's key; `files` is each input's path and the hash of its text. */ | |
| 60 | export function keptKey(x: { | |
| 61 | readonly bun: string; | |
| 62 | readonly platform: string; | |
| 63 | readonly test: string; | |
| 64 | readonly files: readonly (readonly [string, string])[]; | |
| 65 | }): string { | |
| 66 | return sha256( | |
| 67 | JSON.stringify([x.bun, x.platform, x.test, [...x.files].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))]), | |
| 68 | ); | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * A test whose source reads files from the tree, starts a process or finds | |
| 73 | * its own place: what it reads is not what its impact entry names. | |
| 74 | */ | |
| 75 | const OPENS_THE_TREE = | |
| 76 | /\b(readFileSync|readdirSync|readFile|readdir|Bun\.file|Bun\.Glob|new Glob|statSync|existsSync|Bun\.spawn|Bun\.spawnSync|Bun\.\$|child_process|execSync|spawnSync)\b|import\.meta\.(dir|dirname|url|path|file)|process\.cwd\(|from\s*["']bun["']/; | |
| 77 | ||
| 78 | export const readsTheTree = (source: string): boolean => OPENS_THE_TREE.test(source); | |
| 79 | ||
| 80 | /** A source file that finds its own place in the tree can read what no impact entry names. */ | |
| 81 | const FINDS_ITS_PLACE = /import\.meta\.(dir|dirname|url|path|file)|process\.cwd\(/; | |
| 82 | ||
| 83 | export const findsItsPlace = (source: string): boolean => FINDS_ITS_PLACE.test(source); | |
| 84 | ||
| 85 | const KEY = /^[0-9a-f]{64}$/; | |
| 86 | const COMMIT = /^[0-9a-f]{7,64}$/; | |
| 87 | const TEST = /^test\/[A-Za-z0-9_.-]+\.test\.[cm]?[jt]sx?$/; | |
| 88 | ||
| 89 | /** The kept results in `text`, or why it is refused. */ | |
| 90 | export function parseKept(text: string): { ok: true; value: Map<string, KeptEntry> } | { ok: false; error: string } { | |
| 91 | let data: unknown; | |
| 92 | try { | |
| 93 | data = JSON.parse(text); | |
| 94 | } catch { | |
| 95 | return { ok: false, error: "not JSON" }; | |
| 96 | } | |
| 97 | if (typeof data !== "object" || data === null || Array.isArray(data)) | |
| 98 | return { ok: false, error: "not an object of key to result" }; | |
| 99 | const map = new Map<string, KeptEntry>(); | |
| 100 | for (const [key, entry] of Object.entries(data)) { | |
| 101 | const e = entry as { test?: unknown; commit?: unknown } | null; | |
| 102 | if ( | |
| 103 | !KEY.test(key) || | |
| 104 | typeof e !== "object" || | |
| 105 | e === null || | |
| 106 | typeof e.test !== "string" || | |
| 107 | !TEST.test(e.test) || | |
| 108 | typeof e.commit !== "string" || | |
| 109 | !COMMIT.test(e.commit) | |
| 110 | ) | |
| 111 | return { ok: false, error: `${key.slice(0, 20)}: not a kept result` }; | |
| 112 | map.set(key, { test: e.test, commit: e.commit }); | |
| 113 | } | |
| 114 | return { ok: true, value: map }; | |
| 115 | } | |
| 116 | ||
| 117 | export function formatKept(kept: Kept): string { | |
| 118 | return JSON.stringify(Object.fromEntries(kept)) + "\n"; | |
| 119 | } | |
| 120 | ||
| 121 | /** | |
| 122 | * The results to write: the old ones, then this run's, the oldest dropped | |
| 123 | * past the bound. A test that ran green now replaces nothing: keys are | |
| 124 | * content, so an old key is never wrong, only unused. | |
| 125 | */ | |
| 126 | export function mergeKept(old: Kept, fresh: Kept): Map<string, KeptEntry> { | |
| 127 | const merged = new Map(old); | |
| 128 | for (const [key, entry] of fresh) { | |
| 129 | merged.delete(key); | |
| 130 | merged.set(key, entry); | |
| 131 | } | |
| 132 | while (merged.size > KEPT_MAX) merged.delete(merged.keys().next().value!); | |
| 133 | return merged; | |
| 134 | } | |
| 135 | ||
| 136 | export interface KeptSplit { | |
| 137 | readonly run: string[]; | |
| 138 | /** The tests not run, each with the commit that proved it. */ | |
| 139 | readonly kept: Map<string, string>; | |
| 140 | } | |
| 141 | ||
| 142 | /** | |
| 143 | * Which of `files` the kept results already answer; `keyOf` is `undefined` for a test never | |
| 144 | * kept, and `rerun` names a kept file to run anyway (the sample). | |
| 145 | */ | |
| 146 | export function splitKept( | |
| 147 | files: readonly string[], | |
| 148 | kept: Kept, | |
| 149 | keyOf: (file: string) => string | undefined, | |
| 150 | rerun: (file: string) => boolean = () => false, | |
| 151 | ): KeptSplit { | |
| 152 | const run: string[] = []; | |
| 153 | const answered = new Map<string, string>(); | |
| 154 | for (const file of files) { | |
| 155 | const key = keyOf(file); | |
| 156 | const entry = key === undefined ? undefined : kept.get(key); | |
| 157 | if (entry === undefined || rerun(file)) run.push(file); | |
| 158 | else answered.set(file, entry.commit); | |
| 159 | } | |
| 160 | return { run, kept: answered }; | |
| 161 | } | |
| 162 | ||
| 163 | /** The log line for a test not run. */ | |
| 164 | export const keptLine = (file: string, commit: string): string => | |
| 165 | ` ${file}: kept from ${commit.slice(0, 7)}: nothing it reads changed`; |
src/checks/service.ts +14
| @@ -74,6 +74,7 @@ import { ASKED, redMain, unheld } from "./red_main.ts"; | ||
| 74 | 74 | import { proposeRevert } from "./revert_red.ts"; |
| 75 | 75 | import { rebasesFirst } from "./finishing_first.ts"; |
| 76 | 76 | import { IMPACT_FILE, keepImpact, seedImpact } from "./impact-store.ts"; |
| 77 | import { KEPT_FILE, keepKept, seedKept } from "./kept-store.ts"; | |
| 77 | 78 | import { afterVerify, closeOnGreen } from "./culprit.ts"; |
| 78 | 79 | import { redDetail, reportGreen, reportRed } from "./report.ts"; |
| 79 | 80 | import { |
| @@ -1274,6 +1275,13 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1274 | 1275 | } catch (error) { |
| 1275 | 1276 | write(`impact map not seeded: ${String(error)}`); |
| 1276 | 1277 | } |
| 1278 | // The fast tier skips a test file whose inputs a default-branch run already proved green. | |
| 1279 | try { | |
| 1280 | const seeded = seedKept(app.config.dataDir, repo.id, dir); | |
| 1281 | if (seeded > 0) write(`kept results: ${seeded} tests, from ${repo.defaultBranch}'s measuring runs`); | |
| 1282 | } catch (error) { | |
| 1283 | write(`kept results not seeded: ${String(error)}`); | |
| 1284 | } | |
| 1277 | 1285 | // The check is repository code: it runs in a cell of its own |
| 1278 | 1286 | // (`apps/contain.ts`), its checkout and a home its only places to write, |
| 1279 | 1287 | // the forge, the registry and what it declared its only way out. |
| @@ -1334,6 +1342,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1334 | 1342 | FORGE_CHECK: run.id, |
| 1335 | 1343 | FORGE_COMMIT: x.commitOid, |
| 1336 | 1344 | FORGE_CHECK_IMPACT: IMPACT_FILE, |
| 1345 | FORGE_CHECK_KEPT: KEPT_FILE, | |
| 1337 | 1346 | ...(x.only === undefined ? {} : { FORGE_CHECK_ONLY: onlyEnv(x.only) }), |
| 1338 | 1347 | // The cell's CPU limit, which `availableParallelism()` may not |
| 1339 | 1348 | // report inside it; the shard count follows this (#2257). |
| @@ -1575,6 +1584,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1575 | 1584 | try { |
| 1576 | 1585 | const kept = keepImpact(app.config.dataDir, repo.id, dir); |
| 1577 | 1586 | if (kept !== undefined) write(kept); |
| 1587 | const results = keepKept(app.config.dataDir, repo.id, dir, true); | |
| 1588 | if (results !== undefined) write(results); | |
| 1578 | 1589 | } catch (error) { |
| 1579 | 1590 | write(`impact map not kept: ${String(error)}`); |
| 1580 | 1591 | } |
| @@ -1599,6 +1610,9 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1599 | 1610 | try { |
| 1600 | 1611 | const kept = keepImpact(app.config.dataDir, repo.id, dir); |
| 1601 | 1612 | if (kept !== undefined) write(kept); |
| 1613 | // Only the default branch's own run hands results back: a pull's tree could write any. | |
| 1614 | const results = keepKept(app.config.dataDir, repo.id, dir, x.keepImpact === true); | |
| 1615 | if (results !== undefined) write(results); | |
| 1602 | 1616 | } catch (error) { |
| 1603 | 1617 | write(`impact map not kept: ${String(error)}`); |
| 1604 | 1618 | } |
src/kernel/destroy_reach.ts +1
| @@ -69,6 +69,7 @@ export const ALLOWED_REMOVALS: readonly { unit: string; why: string }[] = [ | ||
| 69 | 69 | "src/checks/cache.ts:restoreCache", |
| 70 | 70 | "src/checks/cache.ts:keepCache", |
| 71 | 71 | "src/checks/impact-store.ts:seedImpact", |
| 72 | "src/checks/kept-store.ts:seedKept", | |
| 72 | 73 | "src/checks/install.ts:prefillInstallCache", |
| 73 | 74 | "src/checks/install.ts:holdFile", |
| 74 | 75 | "src/checks/install_evict.ts:evictInstallCache", |
test/check-kept.test.ts added +194
| @@ -0,0 +1,194 @@ | ||
| 1 | import { afterEach, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { formatKept, keptKey, mergeKept, parseKept, readsTheTree, splitKept, sampled, KEPT_TOOLING, KEPT_MAX } from "../src/checks/kept.ts"; | |
| 6 | import { KEPT_FILE, keepKept, seedKept } from "../src/checks/kept-store.ts"; | |
| 7 | import { answerKept, freshKept, keyOfTest } from "../scripts/kept-input.ts"; | |
| 8 | ||
| 9 | const base = { bun: "1.2.3", platform: "linux", test: "test/a.test.ts", files: [["src/a.ts", "h1"], ["test/a.test.ts", "h2"]] as [string, string][] }; | |
| 10 | const key = keptKey(base); | |
| 11 | const commit = "a".repeat(40); | |
| 12 | ||
| 13 | describe("a test file's kept result (#12178)", () => { | |
| 14 | test("the key is the same for the same inputs in any order and differs when any input does", () => { | |
| 15 | expect(keptKey({ ...base, files: [...base.files].reverse() })).toBe(key); | |
| 16 | expect(keptKey({ ...base, files: [["src/a.ts", "h9"], ["test/a.test.ts", "h2"]] })).not.toBe(key); | |
| 17 | expect(keptKey({ ...base, bun: "1.2.4" })).not.toBe(key); | |
| 18 | expect(keptKey({ ...base, files: [...base.files, ["src/b.ts", "h3"]] })).not.toBe(key); | |
| 19 | }); | |
| 20 | ||
| 21 | test("a kept key answers; a changed input or an unkeyed test runs", () => { | |
| 22 | const kept = new Map([[key, { test: base.test, commit }]]); | |
| 23 | const keys: Record<string, string | undefined> = { "test/a.test.ts": key, "test/b.test.ts": "0".repeat(64), "test/c.test.ts": undefined }; | |
| 24 | const split = splitKept(Object.keys(keys), kept, (f) => keys[f]); | |
| 25 | expect(split.run).toEqual(["test/b.test.ts", "test/c.test.ts"]); | |
| 26 | expect([...split.kept]).toEqual([["test/a.test.ts", commit]]); | |
| 27 | }); | |
| 28 | ||
| 29 | test("the file round-trips and refuses what is not a result", () => { | |
| 30 | const kept = new Map([[key, { test: base.test, commit }]]); | |
| 31 | const parsed = parseKept(formatKept(kept)); | |
| 32 | expect(parsed.ok && [...parsed.value]).toEqual([...kept]); | |
| 33 | expect(parseKept("[]").ok).toBe(false); | |
| 34 | expect(parseKept(JSON.stringify({ [key]: { test: "../x", commit } })).ok).toBe(false); | |
| 35 | expect(parseKept(JSON.stringify({ nope: { test: base.test, commit } })).ok).toBe(false); | |
| 36 | }); | |
| 37 | ||
| 38 | test("the oldest results go past the bound", () => { | |
| 39 | const old = new Map<string, { test: string; commit: string }>(); | |
| 40 | for (let i = 0; i < KEPT_MAX; i++) old.set(String(i).padStart(64, "0"), { test: base.test, commit }); | |
| 41 | const merged = mergeKept(old, new Map([[key, { test: base.test, commit }]])); | |
| 42 | expect(merged.size).toBe(KEPT_MAX); | |
| 43 | expect(merged.has(key)).toBe(true); | |
| 44 | expect(merged.has("0".repeat(64))).toBe(false); | |
| 45 | }); | |
| 46 | ||
| 47 | test("a test that opens the tree is recognised", () => { | |
| 48 | expect(readsTheTree('readFileSync("README.md")')).toBe(true); | |
| 49 | expect(readsTheTree("expect(1).toBe(1)")).toBe(false); | |
| 50 | }); | |
| 51 | }); | |
| 52 | ||
| 53 | describe("what a test file reads, hashed", () => { | |
| 54 | const dirs: string[] = []; | |
| 55 | let root = "."; | |
| 56 | afterEach(() => { | |
| 57 | for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); | |
| 58 | }); | |
| 59 | function tree(files: Record<string, string>): void { | |
| 60 | const dir = mkdtempSync(join(tmpdir(), "kept-")); | |
| 61 | dirs.push(dir); | |
| 62 | for (const [path, text] of Object.entries(files)) { | |
| 63 | mkdirSync(join(dir, path, ".."), { recursive: true }); | |
| 64 | writeFileSync(join(dir, path), text); | |
| 65 | } | |
| 66 | root = dir; | |
| 67 | } | |
| 68 | const impact = new Map([["test/a.test.ts", ["src/a.ts"]]]); | |
| 69 | const files = { "test/a.test.ts": "test('x',()=>{})", "src/a.ts": "export const a = 1;", "package.json": "{}" }; | |
| 70 | const imports = (): string[] => []; | |
| 71 | ||
| 72 | test("a change to a file the test executes, to the test, or to the tooling is a new key; any other file is not", () => { | |
| 73 | tree(files); | |
| 74 | const before = keyOfTest("test/a.test.ts", impact, imports, root); | |
| 75 | expect(before).toBeDefined(); | |
| 76 | writeFileSync(join(root, "src/other.ts"), "export const o = 1;"); | |
| 77 | expect(keyOfTest("test/a.test.ts", impact, imports, root)).toBe(before); | |
| 78 | for (const [path, text] of [["src/a.ts", "export const a = 2;"], ["test/a.test.ts", "test('y',()=>{})"], ["package.json", '{"a":1}']] as const) { | |
| 79 | tree(files); | |
| 80 | writeFileSync(join(root, path), text); | |
| 81 | expect(keyOfTest("test/a.test.ts", impact, imports, root)).not.toBe(before); | |
| 82 | } | |
| 83 | }); | |
| 84 | ||
| 85 | test("a test with no impact entry, one that reads the tree, or whose source finds its place is never kept", () => { | |
| 86 | tree(files); | |
| 87 | expect(keyOfTest("test/b.test.ts", impact, imports, root)).toBeUndefined(); | |
| 88 | writeFileSync(join(root, "test/a.test.ts"), 'import { readFileSync } from "node:fs";'); | |
| 89 | expect(keyOfTest("test/a.test.ts", impact, imports, root)).toBeUndefined(); | |
| 90 | tree({ ...files, "src/a.ts": "export const d = import.meta.dir;" }); | |
| 91 | expect(keyOfTest("test/a.test.ts", impact, imports, root)).toBeUndefined(); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a change to the runner, forge.json or the preload is a new key", () => { | |
| 95 | for (const path of ["scripts/test-shards.ts", "scripts/kept-input.ts", "forge.json", "test/helpers/preload.ts"]) | |
| 96 | expect(KEPT_TOOLING).toContain(path); | |
| 97 | tree(files); | |
| 98 | const before = keyOfTest("test/a.test.ts", impact, imports, root); | |
| 99 | mkdirSync(join(root, "scripts")); | |
| 100 | writeFileSync(join(root, "scripts/test-shards.ts"), "// changed"); | |
| 101 | expect(keyOfTest("test/a.test.ts", impact, imports, root)).not.toBe(before); | |
| 102 | }); | |
| 103 | ||
| 104 | test("a helper the test imports is an input", () => { | |
| 105 | tree({ ...files, "test/help.ts": "export const h = 1;" }); | |
| 106 | const via = (file: string): string[] => (file === "test/a.test.ts" ? ["test/help.ts"] : []); | |
| 107 | const before = keyOfTest("test/a.test.ts", impact, via, root); | |
| 108 | writeFileSync(join(root, "test/help.ts"), "export const h = 2;"); | |
| 109 | expect(keyOfTest("test/a.test.ts", impact, via, root)).not.toBe(before); | |
| 110 | }); | |
| 111 | }); | |
| 112 | ||
| 113 | describe("the fast tier's use of kept results", () => { | |
| 114 | const dirs: string[] = []; | |
| 115 | afterEach(() => { | |
| 116 | for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); | |
| 117 | }); | |
| 118 | const dir = mkdtempSync(join(tmpdir(), "kept-wire-")); | |
| 119 | dirs.push(dir); | |
| 120 | mkdirSync(join(dir, "test"), { recursive: true }); | |
| 121 | mkdirSync(join(dir, "src"), { recursive: true }); | |
| 122 | const names = ["a", "b", "c"].map((n) => `test/${n}.test.ts`); | |
| 123 | for (const n of ["a", "b", "c"]) { | |
| 124 | writeFileSync(join(dir, `test/${n}.test.ts`), `test('${n}',()=>{})`); | |
| 125 | writeFileSync(join(dir, `src/${n}.ts`), `export const ${n} = 1;`); | |
| 126 | } | |
| 127 | const impact = new Map(names.map((t, i) => [t, [`src/${"abc"[i]}.ts`]] as const)); | |
| 128 | const imports = (): string[] => []; | |
| 129 | const measured = new Set(names); | |
| 130 | const green = (file: string, retried = false) => ({ files: [`./${file}`], code: 0, retried }); | |
| 131 | ||
| 132 | test("a green first-try file is kept, answers the next run, and a changed input runs", () => { | |
| 133 | const fresh = freshKept({ results: names.map((n) => green(n)), measured, impact, imports, commit, root: dir }); | |
| 134 | expect(fresh.size).toBe(3); | |
| 135 | // every file sampled out of the way: pick a commit where none of them is sampled | |
| 136 | let salt = "b".repeat(40); | |
| 137 | for (let i = 0; names.some((n) => sampled(n, salt)); i++) salt = i.toString(16).padStart(40, "0"); | |
| 138 | const answer = () => answerKept({ files: names, kept: fresh, impact, imports, commit: salt, root: dir }); | |
| 139 | expect(answer().run).toEqual([]); | |
| 140 | expect([...answer().kept.keys()]).toEqual(names); | |
| 141 | writeFileSync(join(dir, "src/b.ts"), "export const b = 2;"); | |
| 142 | expect(answer().run).toEqual(["test/b.test.ts"]); | |
| 143 | writeFileSync(join(dir, "src/b.ts"), "export const b = 1;"); | |
| 144 | }); | |
| 145 | ||
| 146 | test("a file a retry made green, a red one, one run beside others or never measured is not kept", () => { | |
| 147 | const fresh = freshKept({ | |
| 148 | results: [green(names[0]!, true), { files: [`./${names[1]!}`], code: 1, retried: false }, { files: names, code: 0, retried: false }], | |
| 149 | measured, | |
| 150 | impact, | |
| 151 | imports, | |
| 152 | commit, | |
| 153 | root: dir, | |
| 154 | }); | |
| 155 | expect(fresh.size).toBe(0); | |
| 156 | expect(freshKept({ results: [green(names[0]!)], measured: new Set(), impact, imports, commit, root: dir }).size).toBe(0); | |
| 157 | }); | |
| 158 | ||
| 159 | test("a sample of kept files runs anyway, the same one for the same commit", () => { | |
| 160 | const hits = Array.from({ length: 400 }, (_, i) => sampled(`test/f${i}.test.ts`, commit)).filter(Boolean).length; | |
| 161 | expect(hits).toBeGreaterThan(10); | |
| 162 | expect(hits).toBeLessThan(90); | |
| 163 | expect(sampled("test/a.test.ts", commit)).toBe(sampled("test/a.test.ts", commit)); | |
| 164 | const kept = new Map([[key, { test: base.test, commit }]]); | |
| 165 | expect(splitKept(["test/a.test.ts"], kept, () => key, () => true).run).toEqual(["test/a.test.ts"]); | |
| 166 | }); | |
| 167 | }); | |
| 168 | ||
| 169 | describe("the kept results beside the forge", () => { | |
| 170 | test("the file handed between checks is ignored, as the impact map is", () => { | |
| 171 | const ignored = readFileSync(join(import.meta.dir, "..", ".gitignore"), "utf8").split("\n"); | |
| 172 | expect(ignored).toContain(`/${KEPT_FILE}`); | |
| 173 | expect(ignored).toContain("/.forge-impact.json"); | |
| 174 | }); | |
| 175 | ||
| 176 | test("seed what was kept; refuse what is not a result; hand back only a valid file", () => { | |
| 177 | const data = mkdtempSync(join(tmpdir(), "kept-data-")); | |
| 178 | const first = mkdtempSync(join(tmpdir(), "kept-co-")); | |
| 179 | const second = mkdtempSync(join(tmpdir(), "kept-co-")); | |
| 180 | try { | |
| 181 | expect(seedKept(data, "r1", first)).toBe(0); | |
| 182 | writeFileSync(join(first, KEPT_FILE), "nonsense"); | |
| 183 | expect(keepKept(data, "r1", first, true)).toContain("not kept"); | |
| 184 | writeFileSync(join(first, KEPT_FILE), formatKept(new Map([[key, { test: base.test, commit }]]))); | |
| 185 | expect(keepKept(data, "r1", first, false)).toBeUndefined(); | |
| 186 | expect(seedKept(data, "r1", second)).toBe(0); | |
| 187 | expect(keepKept(data, "r1", first, true)).toContain("1 tests"); | |
| 188 | expect(seedKept(data, "r1", second)).toBe(1); | |
| 189 | expect(seedKept(data, "r2", second)).toBe(0); | |
| 190 | } finally { | |
| 191 | for (const dir of [data, first, second]) rmSync(dir, { recursive: true, force: true }); | |
| 192 | } | |
| 193 | }); | |
| 194 | }); |
new above
offline
Retried-green results must not be kept, and the key should include the runner scripts (see KEPT_TOOLING).