From 3c6a20f6118032833b9e33afe10bead1d1deea70 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:55:19 -0400 Subject: [PATCH 01/13] chore(build): one generator registry behind build:check Before: package.json's build:check spelled ten generated paths by hand and never checked src/upstream-gaps/index.ts. After: .github/scripts/generated.ts derives GENERATED_OUTPUTS (path, generator, kind) from the generators' own registries; build:check runs every generator in the table and diffs every registered path, the gaps index included. - test/scripts/generated.test.ts pins the table against the tree: every tracked .md/.yml file carrying a BEGIN GENERATED marker is registered and vice versa, the two whole-file outputs are tracked with one writer each. - The hand-typed page list in gen-docs.test.ts goes; the tree scan is the pin. --- .github/scripts/gen-docs.ts | 2 +- .github/scripts/gen-gaps-index.ts | 2 +- .github/scripts/generated.ts | 86 +++++++++++++++++++++++++++++++ package.json | 2 +- test/scripts/gen-docs.test.ts | 11 +--- test/scripts/generated.test.ts | 65 +++++++++++++++++++++++ 6 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 .github/scripts/generated.ts create mode 100644 test/scripts/generated.test.ts diff --git a/.github/scripts/gen-docs.ts b/.github/scripts/gen-docs.ts index 7d4bfbdf..fb383123 100644 --- a/.github/scripts/gen-docs.ts +++ b/.github/scripts/gen-docs.ts @@ -22,7 +22,7 @@ import { } from "./lib/generated-regions.js"; const ROOT = join(import.meta.dir, "..", ".."); -const COVERAGE_PATH = "COVERAGE.md"; +export const COVERAGE_PATH = "COVERAGE.md"; /** The repository these pages document; the token form's name and description derive from it. */ const REPO_SLUG = "Vivswan/github-settings-as-code"; diff --git a/.github/scripts/gen-gaps-index.ts b/.github/scripts/gen-gaps-index.ts index b0797330..5c87da70 100644 --- a/.github/scripts/gen-gaps-index.ts +++ b/.github/scripts/gen-gaps-index.ts @@ -11,7 +11,7 @@ import { join } from "node:path"; const ROOT = join(import.meta.dir, "..", ".."); const GAPS_DIR = "src/upstream-gaps"; -const INDEX_PATH = `${GAPS_DIR}/index.ts`; +export const INDEX_PATH = `${GAPS_DIR}/index.ts`; export const NON_GAP_FILE_NAMES: ReadonlySet = new Set(["index.ts", "gap.ts"]); diff --git a/.github/scripts/generated.ts b/.github/scripts/generated.ts new file mode 100644 index 00000000..e710c2c6 --- /dev/null +++ b/.github/scripts/generated.ts @@ -0,0 +1,86 @@ +/** + * The one table of committed generated output, derived from the generators' own registries, and the drift check + * behind `bun run build:check` (`bun .github/scripts/generated.ts`): every generator runs, then every registered + * path must be tracked and unchanged. + * regions -> marker-delimited generated regions inside an authored .md or .yml file (lib/generated-regions.ts) + * file -> the whole file is generated + * A page two generators write into (docs/reference/inputs.md) has one row per generator. + */ + +import { join } from "node:path"; +import { GENERATED_REGIONS } from "./gen-action-docs.js"; +import { COVERAGE_PATH, PAGE_REGIONS } from "./gen-docs.js"; +import { INDEX_PATH } from "./gen-gaps-index.js"; + +const ROOT = join(import.meta.dir, "..", ".."); + +export type GeneratedKind = "regions" | "file"; + +export interface GeneratedOutput { + /** The committed output, repo-relative. */ + readonly path: string; + /** The script that writes it, repo-relative; `bun ` regenerates it in place. */ + readonly generator: string; + readonly kind: GeneratedKind; +} + +const GEN_SETTINGS_SCHEMA = ".github/scripts/gen-settings-schema.ts"; +const GEN_DOCS = ".github/scripts/gen-docs.ts"; +const GEN_ACTION_DOCS = ".github/scripts/gen-action-docs.ts"; +const GEN_GAPS_INDEX = ".github/scripts/gen-gaps-index.ts"; + +function regions(generator: string, paths: readonly string[]): GeneratedOutput[] { + return paths.map((path) => ({ path, generator, kind: "regions" })); +} + +export const GENERATED_OUTPUTS: readonly GeneratedOutput[] = [ + { path: "lib/settings.schema.json", generator: GEN_SETTINGS_SCHEMA, kind: "file" }, + ...regions(GEN_DOCS, [COVERAGE_PATH, ...Object.keys(PAGE_REGIONS)]), + ...regions(GEN_ACTION_DOCS, Object.keys(GENERATED_REGIONS)), + { path: INDEX_PATH, generator: GEN_GAPS_INDEX, kind: "file" }, +]; + +/** The distinct paths, in table order. */ +export function generatedPaths(): string[] { + return [...new Set(GENERATED_OUTPUTS.map((output) => output.path))]; +} + +function git(args: readonly string[]): string[] { + const run = Bun.spawnSync(["git", ...args], { cwd: ROOT, stdout: "pipe", stderr: "inherit" }); + if (run.exitCode !== 0) { + throw new Error(`build:check: git ${args[0]} exited ${run.exitCode}`); + } + return run.stdout + .toString() + .split("\n") + .filter((line) => line !== ""); +} + +if (import.meta.main) { + for (const generator of new Set(GENERATED_OUTPUTS.map((output) => output.generator))) { + const run = Bun.spawnSync([process.execPath, generator], { + cwd: ROOT, + stdout: "inherit", + stderr: "inherit", + }); + if (run.exitCode !== 0) { + console.error(`build:check: ${generator} exited ${run.exitCode}`); + process.exit(1); + } + } + const paths = generatedPaths(); + // Working tree against the index, as the git diff of the former hand-typed list was: a regenerated file + // already staged passes, a stale one fails. + const drifted = git(["diff", "--name-only", "--", ...paths]); + const untracked = git(["ls-files", "--others", "--exclude-standard", "--", ...paths]); + if (drifted.length > 0 || untracked.length > 0) { + console.error( + `build:check: generated output drifted from the committed tree; commit the regenerated files:\n${[ + ...drifted.map((path) => ` modified: ${path}`), + ...untracked.map((path) => ` untracked: ${path}`), + ].join("\n")}`, + ); + process.exit(1); + } + console.log(`build:check: ${paths.length} generated files match their generators`); +} diff --git a/package.json b/package.json index 0365ed8a..cc53409e 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "build:schema": "bun .github/scripts/gen-settings-schema.ts", "build:docs": "bun .github/scripts/gen-docs.ts", "build:action-docs": "bun .github/scripts/gen-action-docs.ts", - "build:check": "bun run build:schema && git diff --exit-code lib/settings.schema.json && ! git ls-files --others --exclude-standard lib/settings.schema.json | grep . && bun run build:docs && bun run build:action-docs && git diff --exit-code README.md COVERAGE.md action.yml docs/start/getting-started.md docs/reference/sections.md docs/reference/inputs.md docs/reference/architecture.md docs/reference/undeclared-policy.md docs/reference/permissions.md docs/operate/check-mode.md && ! git ls-files --others --exclude-standard README.md COVERAGE.md action.yml docs/start/getting-started.md docs/reference/sections.md docs/reference/inputs.md docs/reference/architecture.md docs/reference/undeclared-policy.md docs/reference/permissions.md docs/operate/check-mode.md | grep .", + "build:check": "bun .github/scripts/generated.ts", "prepare": "lefthook install || true" }, "dependencies": { diff --git a/test/scripts/gen-docs.test.ts b/test/scripts/gen-docs.test.ts index c58d3a64..09761d96 100644 --- a/test/scripts/gen-docs.test.ts +++ b/test/scripts/gen-docs.test.ts @@ -345,16 +345,9 @@ describe("patFormParameters and renderPatFormUrl", () => { describe("the committed pages", () => { const pages = Object.keys(PAGE_REGIONS); - test("are exactly what the generator renders, and every page is registered", () => { + test("are exactly what the generator renders", () => { // Every generated region fresh (build:check's contract) also proves the Sections renderer parses every real grant prose and every real permission - // has a form parameter. - expect(pages.sort()).toEqual([ - "README.md", - "docs/reference/architecture.md", - "docs/reference/inputs.md", - "docs/reference/sections.md", - "docs/start/getting-started.md", - ]); + // has a form parameter. The page set is pinned against the tree by test/scripts/generated.test.ts, through the generated-output table. for (const path of pages) { const text = readFileSync(join(ROOT, path), "utf8"); expect(renderPage(path, text), path).toBe(text); diff --git a/test/scripts/generated.test.ts b/test/scripts/generated.test.ts new file mode 100644 index 00000000..0a9d7758 --- /dev/null +++ b/test/scripts/generated.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { GENERATED_OUTPUTS, generatedPaths } from "../../.github/scripts/generated.js"; + +const ROOT = join(import.meta.dir, "..", ".."); + +/** A generated-region marker in either comment syntax, opening a line. */ +const BEGIN_MARKER = /^[ \t]*(?:\n" + - "Use `COMPAT(vN): reason; for example COMPAT(v5): keep the old path; delete it`.\n", - }); - expect(checkCompatMarkers({ cwd })).toEqual({ - code: 0, - stdout: [ - "COMPAT markers by removal major (package.json 2.0.0):", - " v3", - " .github/workflows/publish.yml:2 tolerate the old asset name; delete this step", - ' src/config.ts:1 config.json\'s retired "legacy" key still loads; delete this fallback', - ' src/inputs.ts:1 accept the pre-2.1 "repos_dir" input name; delete this branch and the alias', - " v4", - " src/cache.ts:1 read the v3 cache layout; delete the reader", - " src/cache.ts:1 and the v3 key format; delete the parser", - " v5", - " docs/anchors.md:3 keep the old anchor ids; delete the redirect table", - " docs/anchors.md:4 the old heading slugs still resolve; delete the redirect list", - " docs/anchors.md:5 keep the old path; delete it`.", - "", - ].join("\n"), - stderr: "", - }); - }); + tempTest( + "lists well-formed markers by removal major across file types and passes under the due line", + (dir) => { + const cwd = repo(dir, "2.0.0", { + "src/inputs.ts": + 'const dir = input("repos_dir"); // COMPAT(v3): accept the pre-2.1 "repos_dir" input name; delete this branch and the alias\n', + "src/cache.ts": + "/* COMPAT(v4): read the v3 cache layout; delete the reader */ // COMPAT(v4): and the v3 key format; delete the parser\nexport {};\n", + "src/config.ts": + '// COMPAT(v3): config.json\'s retired "legacy" key still loads; delete this fallback\n', + "config.json": '{"legacy": true}\n', + ".github/workflows/publish.yml": + "name: publish\n# COMPAT(v3): tolerate the old asset name; delete this step\n", + "docs/anchors.md": + "The marker is `COMPAT(vN): `, a `COMPAT(vN)` comment.\n\n" + + "COMPAT(v5): keep the old anchor ids; delete the redirect table\n" + + "\n" + + "Use `COMPAT(vN): reason; for example COMPAT(v5): keep the old path; delete it`.\n", + }); + expect(checkCompatMarkers({ cwd })).toEqual({ + code: 0, + stdout: [ + "COMPAT markers by removal major (package.json 2.0.0):", + " v3", + " .github/workflows/publish.yml:2 tolerate the old asset name; delete this step", + ' src/config.ts:1 config.json\'s retired "legacy" key still loads; delete this fallback', + ' src/inputs.ts:1 accept the pre-2.1 "repos_dir" input name; delete this branch and the alias', + " v4", + " src/cache.ts:1 read the v3 cache layout; delete the reader", + " src/cache.ts:1 and the v3 key format; delete the parser", + " v5", + " docs/anchors.md:3 keep the old anchor ids; delete the redirect table", + " docs/anchors.md:4 the old heading slugs still resolve; delete the redirect list", + " docs/anchors.md:5 keep the old path; delete it`.", + "", + ].join("\n"), + stderr: "", + }); + }, + ); - test("names every malformed occurrence by file and line, and still lists the well-formed ones", () => { - const cwd = repo("2.0.0", { - "src/m.ts": [ - "// COMPAT(v3) accept the old key", - "// COMPAT(3): drop the v prefix", - "// COMPAT(v3):", - "// COMPAT(vN): the placeholder outside Markdown", - "// COMPAT(v03): a leading zero", - "/* COMPAT(v3): */", - "const legacy = /* COMPAT(v3): */ true;", - "/* See #123. COMPAT(v3): */", - "/* issue #5: COMPAT(v3): real text; delete it */", - "// COMPAT(v3): fine; delete it", - '// COMPAT(v3): the "#" alias still parses; delete support for #', - "// COMPAT(v3): keep the src/**/ glob; delete the legacy matcher */", - "/* closed */ // COMPAT(v3): a closed block before the marker; delete the matcher */", - "", - ].join("\n"), - "notes.md": - "A `COMPAT(vN)` marker, and one gone wrong: COMPAT(v3)\n\n\n`COMPAT(vN): keep the old path; delete it\n", - }); - expect(checkCompatMarkers({ cwd })).toEqual({ - code: 1, - stdout: [ - "COMPAT markers by removal major (package.json 2.0.0):", - " v3", - " src/m.ts:9 real text; delete it", - " src/m.ts:10 fine; delete it", - ' src/m.ts:11 the "#" alias still parses; delete support for #', - " src/m.ts:12 keep the src/**/ glob; delete the legacy matcher */", - " src/m.ts:13 a closed block before the marker; delete the matcher */", - "", - ].join("\n"), - stderr: [ - malformed("notes.md:1", "COMPAT(v3)"), - malformed("notes.md:2", "COMPAT(v3): -->"), - malformed("notes.md:3", "COMPAT(vN): keep the old anchor; delete the redirect -->"), - malformed("notes.md:4", "COMPAT(vN): keep the old path; delete it"), - malformed("src/m.ts:1", "COMPAT(v3) accept the old key"), - malformed("src/m.ts:2", "COMPAT(3): drop the v prefix"), - malformed("src/m.ts:3", "COMPAT(v3):"), - malformed("src/m.ts:4", "COMPAT(vN): the placeholder outside Markdown"), - malformed("src/m.ts:5", "COMPAT(v03): a leading zero"), - malformed("src/m.ts:6", "COMPAT(v3): */"), - malformed("src/m.ts:7", "COMPAT(v3): */ true;"), - malformed("src/m.ts:8", "COMPAT(v3): */"), - "", - ].join("\n"), - }); - }); + tempTest( + "names every malformed occurrence by file and line, and still lists the well-formed ones", + (dir) => { + const cwd = repo(dir, "2.0.0", { + "src/m.ts": [ + "// COMPAT(v3) accept the old key", + "// COMPAT(3): drop the v prefix", + "// COMPAT(v3):", + "// COMPAT(vN): the placeholder outside Markdown", + "// COMPAT(v03): a leading zero", + "/* COMPAT(v3): */", + "const legacy = /* COMPAT(v3): */ true;", + "/* See #123. COMPAT(v3): */", + "/* issue #5: COMPAT(v3): real text; delete it */", + "// COMPAT(v3): fine; delete it", + '// COMPAT(v3): the "#" alias still parses; delete support for #', + "// COMPAT(v3): keep the src/**/ glob; delete the legacy matcher */", + "/* closed */ // COMPAT(v3): a closed block before the marker; delete the matcher */", + "", + ].join("\n"), + "notes.md": + "A `COMPAT(vN)` marker, and one gone wrong: COMPAT(v3)\n\n\n`COMPAT(vN): keep the old path; delete it\n", + }); + expect(checkCompatMarkers({ cwd })).toEqual({ + code: 1, + stdout: [ + "COMPAT markers by removal major (package.json 2.0.0):", + " v3", + " src/m.ts:9 real text; delete it", + " src/m.ts:10 fine; delete it", + ' src/m.ts:11 the "#" alias still parses; delete support for #', + " src/m.ts:12 keep the src/**/ glob; delete the legacy matcher */", + " src/m.ts:13 a closed block before the marker; delete the matcher */", + "", + ].join("\n"), + stderr: [ + malformed("notes.md:1", "COMPAT(v3)"), + malformed("notes.md:2", "COMPAT(v3): -->"), + malformed("notes.md:3", "COMPAT(vN): keep the old anchor; delete the redirect -->"), + malformed("notes.md:4", "COMPAT(vN): keep the old path; delete it"), + malformed("src/m.ts:1", "COMPAT(v3) accept the old key"), + malformed("src/m.ts:2", "COMPAT(3): drop the v prefix"), + malformed("src/m.ts:3", "COMPAT(v3):"), + malformed("src/m.ts:4", "COMPAT(vN): the placeholder outside Markdown"), + malformed("src/m.ts:5", "COMPAT(v03): a leading zero"), + malformed("src/m.ts:6", "COMPAT(v3): */"), + malformed("src/m.ts:7", "COMPAT(v3): */ true;"), + malformed("src/m.ts:8", "COMPAT(v3): */"), + "", + ].join("\n"), + }); + }, + ); // The due line is package.json's major, or the major a release PR cuts; a marker at or below it fails. test.each<[string, string, number, number | undefined, string | undefined]>([ @@ -146,62 +145,68 @@ describe("checkCompatMarkers", () => { ["a release targeting a major above the marker's", "2.0.0", 2, 3, "the release target v3"], ["a marker beyond the release target passes", "2.0.0", 4, 3, undefined], ["a minor release targets the current major, the default gate", "2.0.0", 3, 2, undefined], - ])("%s", (_, version, major, targetMajor, dueLine) => { - const cwd = repo(version, { - "src/x.ts": `// COMPAT(v${major}): the old shape still parses; delete the branch\n`, - }); - const context = - targetMajor === undefined - ? `package.json ${version}` - : `package.json ${version}, release target v${targetMajor}`; - expect(checkCompatMarkers({ cwd, targetMajor })).toEqual({ - code: dueLine === undefined ? 0 : 1, - stdout: [ - `COMPAT markers by removal major (${context}):`, - ` v${major}`, - " src/x.ts:1 the old shape still parses; delete the branch", - "", - ].join("\n"), - stderr: dueLine === undefined ? "" : `${due("src/x.ts:1", major, dueLine)}\n`, - }); - }); + ])("%s", (_, version, major, targetMajor, dueLine) => + withTempDir("compat-markers-", (dir) => { + const cwd = repo(dir, version, { + "src/x.ts": `// COMPAT(v${major}): the old shape still parses; delete the branch\n`, + }); + const context = + targetMajor === undefined + ? `package.json ${version}` + : `package.json ${version}, release target v${targetMajor}`; + expect(checkCompatMarkers({ cwd, targetMajor })).toEqual({ + code: dueLine === undefined ? 0 : 1, + stdout: [ + `COMPAT markers by removal major (${context}):`, + ` v${major}`, + " src/x.ts:1 the old shape still parses; delete the branch", + "", + ].join("\n"), + stderr: dueLine === undefined ? "" : `${due("src/x.ts:1", major, dueLine)}\n`, + }); + }), + ); - test("skips built output, dependencies, the fetched spec, the changelog, ignored files, symlinks, deleted tracked files, and its own two files", () => { - const marker = "// COMPAT(v3): kept; delete it\n"; - const cwd = repo("2.0.0", { - ".gitignore": "scratch/\n", - "lib/index.js": marker, - "node_modules/dep/index.js": marker, - "test/e2e/openapi/github-openapi.trimmed.json": marker, - "CHANGELOG.md": "* remove the COMPAT(v3) legacy reader (#12)\n", - "scratch/out.ts": marker, - "gone.ts": marker, - ".github/scripts/check-compat-markers.ts": "// COMPAT(v3) the syntax, spelled to define it\n", - "test/scripts/check-compat-markers.test.ts": "// COMPAT(3): a fixture text\n", - ".github/scripts/check-compat-markers.tsx": marker, - "AGENTS.md": - "Rules.\n\nCOMPAT(v3): the old rule wording still applies; delete this paragraph\n", - "src/real.ts": marker, - }); - symlinkSync("AGENTS.md", join(cwd, "CLAUDE.md")); - execFileSync("git", ["add", "gone.ts"], { cwd }); - rmSync(join(cwd, "gone.ts")); - expect(checkCompatMarkers({ cwd })).toEqual({ - code: 0, - stdout: [ - "COMPAT markers by removal major (package.json 2.0.0):", - " v3", - " .github/scripts/check-compat-markers.tsx:1 kept; delete it", - " AGENTS.md:3 the old rule wording still applies; delete this paragraph", - " src/real.ts:1 kept; delete it", - "", - ].join("\n"), - stderr: "", - }); - }); + tempTest( + "skips built output, dependencies, the fetched spec, the changelog, ignored files, symlinks, deleted tracked files, and its own two files", + (dir) => { + const marker = "// COMPAT(v3): kept; delete it\n"; + const cwd = repo(dir, "2.0.0", { + ".gitignore": "scratch/\n", + "lib/index.js": marker, + "node_modules/dep/index.js": marker, + "test/e2e/openapi/github-openapi.trimmed.json": marker, + "CHANGELOG.md": "* remove the COMPAT(v3) legacy reader (#12)\n", + "scratch/out.ts": marker, + "gone.ts": marker, + ".github/scripts/check-compat-markers.ts": + "// COMPAT(v3) the syntax, spelled to define it\n", + "test/scripts/check-compat-markers.test.ts": "// COMPAT(3): a fixture text\n", + ".github/scripts/check-compat-markers.tsx": marker, + "AGENTS.md": + "Rules.\n\nCOMPAT(v3): the old rule wording still applies; delete this paragraph\n", + "src/real.ts": marker, + }); + symlinkSync("AGENTS.md", join(cwd, "CLAUDE.md")); + execFileSync("git", ["add", "gone.ts"], { cwd }); + rmSync(join(cwd, "gone.ts")); + expect(checkCompatMarkers({ cwd })).toEqual({ + code: 0, + stdout: [ + "COMPAT markers by removal major (package.json 2.0.0):", + " v3", + " .github/scripts/check-compat-markers.tsx:1 kept; delete it", + " AGENTS.md:3 the old rule wording still applies; delete this paragraph", + " src/real.ts:1 kept; delete it", + "", + ].join("\n"), + stderr: "", + }); + }, + ); - test("a tree without markers says so", () => { - const cwd = repo("2.0.0", { "src/x.ts": "export {};\n" }); + tempTest("a tree without markers says so", (dir) => { + const cwd = repo(dir, "2.0.0", { "src/x.ts": "export {};\n" }); expect(checkCompatMarkers({ cwd })).toEqual({ code: 0, stdout: "no COMPAT markers (package.json 2.0.0)\n", @@ -222,19 +227,24 @@ describe("checkCompatMarkers", () => { undefined, 'package.json\'s version "2.0" is not X.Y.Z; refusing to derive the due line from it.', ], - ])("refuses %s", (_, version, targetMajor, message) => { - const cwd = repo(version, {}); - expect(() => checkCompatMarkers({ cwd, targetMajor })).toThrow(message); - }); + ])("refuses %s", (_, version, targetMajor, message) => + withTempDir("compat-markers-", (dir) => { + const cwd = repo(dir, version, {}); + expect(() => checkCompatMarkers({ cwd, targetMajor })).toThrow(message); + }), + ); - test("a listed path that cannot be inspected for any reason but absence stops the scan", () => { - const cwd = repo("2.0.0", { "dir/file.ts": "export {};\n" }); - execFileSync("git", ["add", "dir/file.ts"], { cwd }); - // The tracked path's parent is now a regular file: lstat fails with ENOTDIR, not ENOENT. - rmSync(join(cwd, "dir"), { recursive: true }); - writeFileSync(join(cwd, "dir"), ""); - expect(() => checkCompatMarkers({ cwd })).toThrow(/ENOTDIR/); - }); + tempTest( + "a listed path that cannot be inspected for any reason but absence stops the scan", + (dir) => { + const cwd = repo(dir, "2.0.0", { "dir/file.ts": "export {};\n" }); + execFileSync("git", ["add", "dir/file.ts"], { cwd }); + // The tracked path's parent is now a regular file: lstat fails with ENOTDIR, not ENOENT. + rmSync(join(cwd, "dir"), { recursive: true }); + writeFileSync(join(cwd, "dir"), ""); + expect(() => checkCompatMarkers({ cwd })).toThrow(/ENOTDIR/); + }, + ); }); describe("the CLI", () => { @@ -286,9 +296,11 @@ describe("the CLI", () => { "check-compat-markers: --target-major 1 is below package.json's major 2 (2.0.0); a release never targets an older major.\n", }, ], - ])("%s", (_, version, argv, expected) => { - const cwd = repo(version, { "src/x.ts": DUE }); - const run = spawnSync("bun", [SCRIPT, ...argv], { cwd, encoding: "utf8" }); - expect({ status: run.status, stdout: run.stdout, stderr: run.stderr }).toEqual(expected); - }); + ])("%s", (_, version, argv, expected) => + withTempDir("compat-markers-", (dir) => { + const cwd = repo(dir, version, { "src/x.ts": DUE }); + const run = spawnSync("bun", [SCRIPT, ...argv], { cwd, encoding: "utf8" }); + expect({ status: run.status, stdout: run.stdout, stderr: run.stderr }).toEqual(expected); + }), + ); }); diff --git a/test/temp-dir.ts b/test/temp-dir.ts new file mode 100644 index 00000000..6261bcd6 --- /dev/null +++ b/test/temp-dir.ts @@ -0,0 +1,30 @@ +/** + * The one temp-dir style for tests: a directory whose lifetime is the body it is handed to, created under + * os.tmpdir() and removed on every exit path, failure included. No afterEach or afterAll hook cleans up. + */ + +import { test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export async function withTempDir( + prefix: string, + body: (dir: string) => T | Promise, +): Promise { + const dir = mkdtempSync(join(tmpdir(), prefix)); + try { + return await body(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** `test()` whose body is handed a fresh directory under `prefix`; a `test.each` case calls withTempDir itself. */ +export function tempDirTest( + prefix: string, +): (name: string, body: (dir: string) => void | Promise) => void { + return (name, body) => { + test(name, () => withTempDir(prefix, body)); + }; +} From 807a3b7ddb265ec24824fd178eb00ca2122bee75 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:25:27 -0400 Subject: [PATCH 04/13] docs: state the code conventions and the generated-output table Before: CONTRIBUTING.md called lib/settings.schema.json "the one committed generated artifact" and stated no line caps, test placement, or file-header rule. After: the committed generated output is the table in .github/scripts/generated.ts; a Code conventions section states the line caps (100 for code, 256 elsewhere, 10-line comment blocks), the one-line-per-paragraph markdown rule, the colocated-test rule, and the file-header convention; the Tests section names the shared fakes. --- AGENTS.md | 2 +- CONTRIBUTING.md | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 68703d70..37dd02dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Code is the source of truth: this section holds only the rules and the decisions ### Hard rules -- Generated artifacts (`lib/settings.schema.json`, the generated docs, the generated `action.yml` regions) are regenerated, never hand-edited; `bun run build:check` fails on drift. +- Generated artifacts (`lib/settings.schema.json`, `src/upstream-gaps/index.ts`, the generated docs and `action.yml` regions) are regenerated, never hand-edited; `.github/scripts/generated.ts` lists them and `bun run build:check` fails on drift. - `lib/index.js` (the action bundle) and `lib/pkg/` (the npm library) are built, never committed on main; the packaged `build` branch carries them. - Every GitHub list call goes through `listAll()` or `listAllEnveloped()`, and every API error through `call()`/`throwFor()`, so the permission policy holds (`src/sections/contract/requests.ts`). - The import layering of `src/` is declared in `architecture.yml`; a new cross-layer import is a deliberate edit to that file. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fba10485..142a2e5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,8 @@ The fleet-wide conventions - Conventional Commit titles, squash merges, the `all ## Toolchain - `src/` is TypeScript built with [bun](https://bun.com). The scripts in `package.json` are the commands; `bun run check` is the whole local gate. -- `lib/settings.schema.json` is the one committed generated artifact; `bun run build:check` fails when it or the generated docs drift. +- Committed generated output is the table in `.github/scripts/generated.ts`: `lib/settings.schema.json`, `src/upstream-gaps/index.ts`, and the generated regions of `action.yml`, `COVERAGE.md`, and the docs pages. +- `bun run build:check` regenerates every table entry and fails on drift. - `lib/index.js` (the action bundle) and `lib/pkg/` (the npm library) are built where they are needed and never committed on `main`. Every runtime dependency is compiled into them. - [COVERAGE.md](COVERAGE.md) is the inventory of the supported API surface. A change that adds or extends a section keeps it in step. @@ -15,9 +16,17 @@ The fleet-wide conventions - Conventional Commit titles, squash merges, the `all - `bun run check:compat` (in `bun run check` and in CI) rejects a malformed marker and any marker whose major is at or below `package.json`'s, and prints the remaining markers grouped by major. - A release PR's tree already carries the version it cuts, so the same check makes a major release PR unmergeable until every marker for that major is deleted on `main` first: with 3.0.0 in preparation, every v3-marked path goes before v3 cuts. +## Code conventions + +- Line caps: code wraps at biome's `lineWidth` of 100; comments, YAML, and prose stay under the fleet's 256-character cap, and a comment block is at most 10 lines. +- Markdown keeps one source line per paragraph or list item, so a long item is split into items, never wrapped. +- A source file under `src/` or `.github/scripts/` opens with a one-paragraph header comment saying what the file owns; test files need none. +- Tests live in two places: a section's unit tests sit beside it in `src/sections//`; everything else is under `test/`, mirroring `src/`. + ## Tests - A test's temp directory lives inside the test body: `withTempDir()` or `tempDirTest()` from `test/temp-dir.ts` (try/finally, removed on every exit path); no afterEach or afterAll hook cleans up. +- An Io a test records through is `captureIo()` from `test/io/capture.ts`: every channel in its own list and in one ordered event log. ## End-to-end tests From fb875af8a4c9454e876388daab61bf8fe1c6d27f Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:32:43 -0400 Subject: [PATCH 05/13] test: one ROOT in test/root.ts Before: 26 verbatim `const ROOT = join(import.meta.dir, "..", "..")` copies, a REPO_ROOT, a depth-one copy, and eight inline `join(import.meta.dir, "..", "..", ...)` spellings across test/. After: test/root.ts exports ROOT once; every repository-relative path in a test resolves from it. A file beside its test still resolves from import.meta.dir. On the way: toBe(null) -> toBeNull() in test/e2e/openapi/validate.test.ts. --- CONTRIBUTING.md | 1 + test/action/action-yml.test.ts | 3 +-- test/action/io.test.ts | 3 ++- test/action/run.test.ts | 3 ++- test/architecture/architecture.test.ts | 4 +--- test/cli/execution.test.ts | 2 +- test/cli/program.test.ts | 2 +- test/docs/biome-schema.test.ts | 3 +-- test/docs/checks-sections-gate.test.ts | 3 +-- test/docs/checks-workflow.test.ts | 2 +- test/docs/ci-workflow.test.ts | 3 +-- test/docs/coverage.test.ts | 2 +- test/docs/diagrams.test.ts | 2 +- test/docs/guides.test.ts | 2 +- test/docs/issue-templates.test.ts | 2 +- test/docs/library-examples.test.ts | 2 +- test/docs/permissions.test.ts | 2 +- test/docs/readme.test.ts | 2 +- test/docs/repo-owned-workflows.test.ts | 2 +- test/docs/semantics.test.ts | 2 +- test/docs/version-pins.ts | 3 +-- test/docs/workflow-loader.ts | 2 +- test/e2e/foundation.test.ts | 6 +++--- test/e2e/openapi/validate.test.ts | 2 +- test/e2e/runner.ts | 2 +- test/e2e/schema.ts | 5 ++--- test/flows/execute.test.ts | 2 +- test/library/public-surface.test.ts | 2 +- test/published-schema.test.ts | 2 +- test/root.ts | 5 +++++ test/scripts/changed-sections.test.ts | 6 +++--- test/scripts/check-compat-markers.test.ts | 3 ++- test/scripts/gen-action-docs.test.ts | 3 +-- test/scripts/gen-docs.test.ts | 3 +-- test/scripts/generated.test.ts | 3 +-- test/scripts/graduate-upstream-gaps.test.ts | 7 ++++--- test/scripts/release-pipeline.test.ts | 7 +++---- test/sections/docs-registry.test.ts | 3 +-- test/sections/graphql-queries.test.ts | 3 ++- 39 files changed, 57 insertions(+), 59 deletions(-) create mode 100644 test/root.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 142a2e5e..aaea05e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,7 @@ The fleet-wide conventions - Conventional Commit titles, squash merges, the `all - A test's temp directory lives inside the test body: `withTempDir()` or `tempDirTest()` from `test/temp-dir.ts` (try/finally, removed on every exit path); no afterEach or afterAll hook cleans up. - An Io a test records through is `captureIo()` from `test/io/capture.ts`: every channel in its own list and in one ordered event log. +- A repository-relative path in a test resolves from `ROOT` in `test/root.ts`; a file beside the test resolves from `import.meta.dir`. ## End-to-end tests diff --git a/test/action/action-yml.test.ts b/test/action/action-yml.test.ts index 8e7abcae..399b0d53 100644 --- a/test/action/action-yml.test.ts +++ b/test/action/action-yml.test.ts @@ -8,8 +8,7 @@ import { OUTPUT_DECLS } from "../../src/action/io.js"; import { DEFAULT_DISCOVERY_FILTERS } from "../../src/discovery/discover.js"; import { RUN_RESULTS } from "../../src/engine/outcome.js"; import { FILTER_INPUTS, INPUT_DECLS, type InputDecl } from "../../src/flows/inputs.js"; - -const ROOT = join(import.meta.dir, "..", ".."); +import { ROOT } from "../root.js"; interface ActionYml { name: string; diff --git a/test/action/io.test.ts b/test/action/io.test.ts index 166de01e..a15c6ac0 100644 --- a/test/action/io.test.ts +++ b/test/action/io.test.ts @@ -3,6 +3,7 @@ import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { actionsIo } from "../../src/action/io.js"; import { writeSummary } from "../../src/flows/summary.js"; +import { ROOT } from "../root.js"; import { tempDirTest } from "../temp-dir.js"; /** A static import, a re-export, a dynamic import(), or a require() all quote the specifier; a comment mentioning it bare does not. */ @@ -25,7 +26,7 @@ describe("the Io port boundary", () => { test("only src/action/ names @actions/core", () => { // Every other layer reaches the runner through the Io port, so redaction and capture have one place to stand. - const srcDir = join(import.meta.dir, "..", "..", "src"); + const srcDir = join(ROOT, "src"); const files = readdirSync(srcDir, { recursive: true }) as string[]; const offenders: string[] = []; let scanned = 0; diff --git a/test/action/run.test.ts b/test/action/run.test.ts index 505d0cc2..6b42b5c0 100644 --- a/test/action/run.test.ts +++ b/test/action/run.test.ts @@ -9,6 +9,7 @@ import type { ArtifactUploader } from "../../src/report/artifact-report.js"; import { REPORT_HEADING } from "../../src/report/composer.js"; import { SECTION_KEYS } from "../../src/schema.js"; import { MockApi } from "../mock-api.js"; +import { ROOT } from "../root.js"; import { tempDirTest, withTempDir } from "../temp-dir.js"; // A capturing Io replaces the @actions/core sink, so a green suite prints no raw workflow commands and the failure-path tests assert the exact @@ -542,7 +543,7 @@ describe("run in mode: merge", () => { "GITHUB_REPOSITORY", ]; const saved = new Map(ENV_KEYS.map((k) => [k, process.env[k]])); - const FIXTURES = join(import.meta.dir, "..", "fixtures", "layers"); + const FIXTURES = join(ROOT, "test", "fixtures", "layers"); const layer = (name: string) => join(FIXTURES, name); const tempTest = tempDirTest("merge-mode-"); diff --git a/test/architecture/architecture.test.ts b/test/architecture/architecture.test.ts index 314f4437..ff6d03c0 100644 --- a/test/architecture/architecture.test.ts +++ b/test/architecture/architecture.test.ts @@ -4,7 +4,6 @@ */ import { describe, expect, test } from "bun:test"; -import { join } from "node:path"; import { ARCHITECTURE_PATH, importSpecifiers, @@ -12,8 +11,7 @@ import { readArchitecture, renderArchitectureMermaid, } from "../../.github/scripts/arch-lint.js"; - -const ROOT = join(import.meta.dir, "..", ".."); +import { ROOT } from "../root.js"; describe("architecture.yml against src/", () => { const arch = readArchitecture(ROOT); diff --git a/test/cli/execution.test.ts b/test/cli/execution.test.ts index 1d4c2813..769a73fd 100644 --- a/test/cli/execution.test.ts +++ b/test/cli/execution.test.ts @@ -17,6 +17,7 @@ import { parseConfig, } from "../../src/index.js"; import { MockApi, type Route } from "../mock-api.js"; +import { ROOT } from "../root.js"; import { withTempDir } from "../temp-dir.js"; import { runCli } from "./streams.js"; @@ -36,7 +37,6 @@ interface Case { readonly ends: { readonly code: number; readonly result: string }; } -const ROOT = join(import.meta.dir, "..", ".."); const SINGLE = join(ROOT, "test", "fixtures", "single.yml"); const LAYERS = join(ROOT, "test", "fixtures", "layers"); const TOKEN = "ghp_equivalence_token"; diff --git a/test/cli/program.test.ts b/test/cli/program.test.ts index d874ec73..5e83e224 100644 --- a/test/cli/program.test.ts +++ b/test/cli/program.test.ts @@ -13,10 +13,10 @@ import type { CliHost } from "../../src/cli/commands.js"; import { CLI_COMMANDS, main } from "../../src/cli/program.js"; import { type ConfigEnv, type GithubClient, sectionGrant, sectionModule } from "../../src/index.js"; import { MockApi } from "../mock-api.js"; +import { ROOT } from "../root.js"; import { tempDirTest, withTempDir } from "../temp-dir.js"; import { memoryStream, runCli } from "./streams.js"; -const ROOT = join(import.meta.dir, "..", ".."); const SINGLE = join(ROOT, "test", "fixtures", "single.yml"); const LAYERS = join(ROOT, "test", "fixtures", "layers"); const TOKEN = "ghp_cli_test_token"; diff --git a/test/docs/biome-schema.test.ts b/test/docs/biome-schema.test.ts index 9598283a..25a7a911 100644 --- a/test/docs/biome-schema.test.ts +++ b/test/docs/biome-schema.test.ts @@ -6,8 +6,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; - -const ROOT = join(import.meta.dir, "..", ".."); +import { ROOT } from "../root.js"; /** The version segment of a biomejs.dev schema URL, or null when the URL has another shape. */ function biomeSchemaVersion(schemaUrl: string): string | null { diff --git a/test/docs/checks-sections-gate.test.ts b/test/docs/checks-sections-gate.test.ts index aa6d488d..acee0dbd 100644 --- a/test/docs/checks-sections-gate.test.ts +++ b/test/docs/checks-sections-gate.test.ts @@ -7,8 +7,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { ALL, NONE } from "../../.github/scripts/changed-sections.js"; - -const ROOT = join(import.meta.dir, "..", ".."); +import { ROOT } from "../root.js"; describe("checks.yml e2e-smoke section-selection sentinels", () => { const workflow = readFileSync(join(ROOT, ".github", "workflows", "checks.yml"), "utf8"); diff --git a/test/docs/checks-workflow.test.ts b/test/docs/checks-workflow.test.ts index 3010379e..fb8c333f 100644 --- a/test/docs/checks-workflow.test.ts +++ b/test/docs/checks-workflow.test.ts @@ -8,12 +8,12 @@ import { transitiveDependents, } from "../../.github/scripts/changed-sections.js"; import { RELEASE_PR_BRANCH_PREFIX } from "../../.github/scripts/release-pipeline.js"; +import { ROOT } from "../root.js"; import { headRefPrefixes, headRefPrefixesIn } from "./head-ref.js"; import { type CompositeAction, executedLines, installs, - ROOT, readAction, readWorkflow, SETUP_USES, diff --git a/test/docs/ci-workflow.test.ts b/test/docs/ci-workflow.test.ts index 6a363efa..4710ba22 100644 --- a/test/docs/ci-workflow.test.ts +++ b/test/docs/ci-workflow.test.ts @@ -10,10 +10,9 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; import { RELEASE_PR_BRANCH_PREFIX } from "../../.github/scripts/release-pipeline.js"; +import { ROOT } from "../root.js"; import { headRefPrefixes } from "./head-ref.js"; -const ROOT = join(import.meta.dir, "..", ".."); - // Outside the gate on purpose: a red run flags template-convention drift the next sync PR heals; mirrors the central CI's validator // (docs/all-green.md in the platform repository). const INFORMATIONAL = new Set(["validate-template"]); diff --git a/test/docs/coverage.test.ts b/test/docs/coverage.test.ts index acf1bb61..a079be42 100644 --- a/test/docs/coverage.test.ts +++ b/test/docs/coverage.test.ts @@ -14,8 +14,8 @@ import { } from "../../src/sections/contract/endpoints.js"; import { DOCS } from "../../src/sections/docs-registry.js"; import { allEndpoints, allGraphqlOps, SECTIONS } from "../../src/sections/registry.js"; +import { ROOT } from "../root.js"; -const ROOT = join(import.meta.dir, "..", ".."); const coverage = renderCoverage(SECTIONS, DOCS, COVERAGE_DATA); describe("COVERAGE path citations", () => { diff --git a/test/docs/diagrams.test.ts b/test/docs/diagrams.test.ts index 6bd60fa8..393c58a8 100644 --- a/test/docs/diagrams.test.ts +++ b/test/docs/diagrams.test.ts @@ -10,9 +10,9 @@ import { describe, expect, test } from "bun:test"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { ROOT } from "../root.js"; import { fencedBlocks } from "./markdown.js"; -const ROOT = join(import.meta.dir, "..", ".."); const DOCS = join(ROOT, "docs"); /** The URL prefix docs/ pages use to reach a repository file (relative links may not leave docs/). */ diff --git a/test/docs/guides.test.ts b/test/docs/guides.test.ts index 46450e09..bf44a846 100644 --- a/test/docs/guides.test.ts +++ b/test/docs/guides.test.ts @@ -14,12 +14,12 @@ import { SECTION_KEYS } from "../../src/schema.js"; import { NESTED_KEYS } from "../../src/sections/environments/nested.js"; import { SECTIONS } from "../../src/sections/registry.js"; import { STALE_VERSION_HINT } from "../../src/sections/secret_scanning_custom_patterns/index.js"; +import { ROOT } from "../root.js"; import { deleteEnumerationProblems } from "./claims.js"; import { fencedBlocks, sectionLines } from "./markdown.js"; import { assertValidSettingsExample } from "./settings-examples.js"; import { stalePins } from "./version-pins.js"; -const ROOT = join(import.meta.dir, "..", ".."); const DOCS = join(ROOT, "docs"); const REQUIRED_PAGES = [ diff --git a/test/docs/issue-templates.test.ts b/test/docs/issue-templates.test.ts index 91c000f3..60bdaaee 100644 --- a/test/docs/issue-templates.test.ts +++ b/test/docs/issue-templates.test.ts @@ -4,8 +4,8 @@ import { describe, expect, test } from "bun:test"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; +import { ROOT } from "../root.js"; -const ROOT = join(import.meta.dir, "..", ".."); const TEMPLATES = join(ROOT, ".github", "ISSUE_TEMPLATE"); interface LabelEntry { diff --git a/test/docs/library-examples.test.ts b/test/docs/library-examples.test.ts index 593f1fb5..d8ade95b 100644 --- a/test/docs/library-examples.test.ts +++ b/test/docs/library-examples.test.ts @@ -11,8 +11,8 @@ import { spawnSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { ROOT } from "../root.js"; -const ROOT = join(import.meta.dir, "..", ".."); const PAGE = "docs/reference/library.md"; const PACKAGE = "@vivswan/github-settings-as-code"; diff --git a/test/docs/permissions.test.ts b/test/docs/permissions.test.ts index 135ef684..7cf3fc71 100644 --- a/test/docs/permissions.test.ts +++ b/test/docs/permissions.test.ts @@ -7,8 +7,8 @@ import { overrideAdviceLevel } from "../../src/sections/contract/errors.js"; import { RESOURCE_LABEL, type SectionPermission } from "../../src/sections/contract/permissions.js"; import { DOCS } from "../../src/sections/docs-registry.js"; import { allEndpoints, sectionModule } from "../../src/sections/registry.js"; +import { ROOT } from "../root.js"; -const ROOT = join(import.meta.dir, "..", ".."); // Flattened for matching: the pages wrap sentences across lines. const permissions = readFileSync(join(ROOT, "docs", "reference", "permissions.md"), "utf8").replace( /\s+/g, diff --git a/test/docs/readme.test.ts b/test/docs/readme.test.ts index 9c69d9b1..e5772a46 100644 --- a/test/docs/readme.test.ts +++ b/test/docs/readme.test.ts @@ -12,12 +12,12 @@ import { PRIVATE_REPORT_CHANNELS } from "../../src/report/delivery.js"; import { PROBOT_PARITY_KEYS, SECTION_KEYS } from "../../src/schema.js"; import { DOCS } from "../../src/sections/docs-registry.js"; import { SECTIONS } from "../../src/sections/registry.js"; +import { ROOT } from "../root.js"; import { defaultClaimProblems, deleteEnumerationProblems } from "./claims.js"; import { fencedBlocks, sectionLines } from "./markdown.js"; import { assertValidSettingsExample } from "./settings-examples.js"; import { stalePins } from "./version-pins.js"; -const ROOT = join(import.meta.dir, "..", ".."); const readme = readFileSync(join(ROOT, "README.md"), "utf8"); function assertBacktickedEnumeration( diff --git a/test/docs/repo-owned-workflows.test.ts b/test/docs/repo-owned-workflows.test.ts index 267ba8bd..a41e4b4f 100644 --- a/test/docs/repo-owned-workflows.test.ts +++ b/test/docs/repo-owned-workflows.test.ts @@ -10,9 +10,9 @@ import { execFileSync } from "node:child_process"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { isAlias, isMap, isScalar, parseDocument, visit } from "yaml"; +import { ROOT } from "../root.js"; import { type Job, - ROOT, readAction, readWorkflow, repoOwnedWorkflowFiles, diff --git a/test/docs/semantics.test.ts b/test/docs/semantics.test.ts index a800de95..689b8cea 100644 --- a/test/docs/semantics.test.ts +++ b/test/docs/semantics.test.ts @@ -9,8 +9,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { countWord } from "../../.github/scripts/lib/count-word.js"; import { MAX_RETRIES, MAX_RETRY_WAIT_S } from "../../src/github/api.js"; +import { ROOT } from "../root.js"; -const ROOT = join(import.meta.dir, "..", ".."); const semantics = readFileSync(join(ROOT, "docs", "reference", "semantics.md"), "utf8").replace( /\s+/g, " ", diff --git a/test/docs/version-pins.ts b/test/docs/version-pins.ts index 3618853c..73e34422 100644 --- a/test/docs/version-pins.ts +++ b/test/docs/version-pins.ts @@ -5,8 +5,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; - -const ROOT = join(import.meta.dir, "..", ".."); +import { ROOT } from "../root.js"; /** One stale workflow-snippet pin: where it sits and what it names. */ export interface StalePin { diff --git a/test/docs/workflow-loader.ts b/test/docs/workflow-loader.ts index 1d299903..9f64d311 100644 --- a/test/docs/workflow-loader.ts +++ b/test/docs/workflow-loader.ts @@ -6,8 +6,8 @@ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; +import { ROOT } from "../root.js"; -export const ROOT = join(import.meta.dir, "..", ".."); const WORKFLOWS_DIR = join(ROOT, ".github", "workflows"); /** The setup composite (bun, the locked install, yamllint on request), as a job's `uses:` spells it. */ export const SETUP_USES = "./.github/actions/setup"; diff --git a/test/e2e/foundation.test.ts b/test/e2e/foundation.test.ts index 345d6c75..ec75b502 100644 --- a/test/e2e/foundation.test.ts +++ b/test/e2e/foundation.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { MARKER_LABEL, MARKER_LABEL_CONFIG } from "../../src/report/issue-report.js"; import { SECTION_KEYS } from "../../src/schema.js"; +import { ROOT } from "../root.js"; import { ADMIN_OWNER, ADMIN_REPO, @@ -482,12 +483,11 @@ describe("harness identity constants", () => { test("section mock fragments mint identity from state.slug, never the harness constants", async () => { // Urls minted from ADMIN_SLUG were served verbatim for multi-repo targets in five fragments, so the // class is banned at the import boundary: a fragment always has the owning state in scope. - const root = join(import.meta.dir, "..", ".."); const offenders: string[] = []; let fragments = 0; - for await (const file of new Bun.Glob("src/sections/*/mock.ts").scan(root)) { + for await (const file of new Bun.Glob("src/sections/*/mock.ts").scan(ROOT)) { fragments++; - const text = await Bun.file(join(root, file)).text(); + const text = await Bun.file(join(ROOT, file)).text(); if (/from "[^"]*\/test\/e2e\/constants\.js"/.test(text)) { offenders.push(file); } diff --git a/test/e2e/openapi/validate.test.ts b/test/e2e/openapi/validate.test.ts index 19aa6f3f..4a296300 100644 --- a/test/e2e/openapi/validate.test.ts +++ b/test/e2e/openapi/validate.test.ts @@ -116,7 +116,7 @@ describe("toJsonSchema", () => { test("leaves primitives untouched", () => { expect(toJsonSchema("s")).toBe("s"); expect(toJsonSchema(3)).toBe(3); - expect(toJsonSchema(null)).toBe(null); + expect(toJsonSchema(null)).toBeNull(); }); test("relaxed variant rewrites oneOf to anyOf (widened branches may overlap)", () => { diff --git a/test/e2e/runner.ts b/test/e2e/runner.ts index b457688b..c099bafd 100644 --- a/test/e2e/runner.ts +++ b/test/e2e/runner.ts @@ -23,6 +23,7 @@ import { dirname, join, relative } from "node:path"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { parseRepoSlug } from "../../src/discovery/targets.js"; import type { OutputName } from "../../src/io.js"; +import { ROOT } from "../root.js"; import { assertApplyIdempotent, captureRerun, @@ -37,7 +38,6 @@ import { type ServerOptions, startMockServer } from "./mock/server.js"; import { sharedValidator } from "./openapi/validate.js"; import { collectYmlFiles, type Expect, type Scenario, settingsYamlFor } from "./schema.js"; -const ROOT = join(import.meta.dir, "..", ".."); /** * The production bundle command, pinned verbatim so the Bun.build call below cannot drift from it: a * flag added to build:bundle (minify, sourcemap, define) would make e2e exercise a different artifact diff --git a/test/e2e/schema.ts b/test/e2e/schema.ts index db112e88..8612d595 100644 --- a/test/e2e/schema.ts +++ b/test/e2e/schema.ts @@ -14,6 +14,7 @@ import { SECTION_KEYS } from "../../src/schema.js"; import type { PatResource } from "../../src/sections/contract/permissions.js"; import { renamedKeyError } from "../../src/sections/shared/renamed-key.js"; import type { MustBeNever } from "../../src/types.js"; +import { ROOT } from "../root.js"; import { LAYER_FILE_PREFIX, RUNNER_ROOT_FILES } from "./constants.js"; import type { LiveState } from "./mock/state.js"; import { LIVE_STATE_KEYS } from "./mock/state.js"; @@ -601,9 +602,7 @@ export function collectYmlFiles(dir: string): string[] { * test/e2e/scenarios/ -> multi-section flows; a scenario exercising ONE section lives with that section * //scenarios/ -> every registered section, so a new section's first scenario needs no list edit */ -export function scenarioRoots( - sectionsDir: string = join(import.meta.dir, "..", "..", "src", "sections"), -): string[] { +export function scenarioRoots(sectionsDir: string = join(ROOT, "src", "sections")): string[] { return [ join(import.meta.dir, "scenarios"), ...SECTION_KEYS.map((key) => join(sectionsDir, key, "scenarios")), diff --git a/test/flows/execute.test.ts b/test/flows/execute.test.ts index aa4f97d1..4561e524 100644 --- a/test/flows/execute.test.ts +++ b/test/flows/execute.test.ts @@ -17,9 +17,9 @@ import { SectionSelection, } from "../../src/index.js"; import { MockApi } from "../mock-api.js"; +import { ROOT } from "../root.js"; import { tempDirTest } from "../temp-dir.js"; -const ROOT = join(import.meta.dir, "..", ".."); const LAYERS = join(ROOT, "test", "fixtures", "layers"); type SingleConfig = Extract; diff --git a/test/library/public-surface.test.ts b/test/library/public-surface.test.ts index 29374c05..c4d628ed 100644 --- a/test/library/public-surface.test.ts +++ b/test/library/public-surface.test.ts @@ -8,8 +8,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseSync } from "oxc-parser"; import { ARCHITECTURE_PATH, lintArchitecture } from "../../.github/scripts/arch-lint.js"; +import { ROOT } from "../root.js"; -const ROOT = join(import.meta.dir, "..", ".."); const ENTRY = "src/index.ts"; /** Every name src/index.ts exports, runtime and type-only alike, sorted. */ diff --git a/test/published-schema.test.ts b/test/published-schema.test.ts index ece70f35..08320605 100644 --- a/test/published-schema.test.ts +++ b/test/published-schema.test.ts @@ -10,8 +10,8 @@ import { ok } from "neverthrow"; import { validateSectionShapes } from "../src/engine/validate.js"; import { SettingsFile, UNDECLARED_POLICY_SECTIONS } from "../src/schema.js"; import { FLAG_PAIRING_FIXTURES } from "./fixtures/environment-flag-pairing.js"; +import { ROOT } from "./root.js"; -const ROOT = join(import.meta.dir, ".."); const schema = JSON.parse(readFileSync(join(ROOT, "lib", "settings.schema.json"), "utf8")) as { $id?: string; definitions: Record>; diff --git a/test/root.ts b/test/root.ts new file mode 100644 index 00000000..aaa130cd --- /dev/null +++ b/test/root.ts @@ -0,0 +1,5 @@ +/** The repository root, for tests that read committed files or spawn the scripts by path. */ + +import { join } from "node:path"; + +export const ROOT = join(import.meta.dir, ".."); diff --git a/test/scripts/changed-sections.test.ts b/test/scripts/changed-sections.test.ts index 66a2859c..761cf81f 100644 --- a/test/scripts/changed-sections.test.ts +++ b/test/scripts/changed-sections.test.ts @@ -12,10 +12,10 @@ import { sectionsForFiles, } from "../../.github/scripts/changed-sections.js"; import { SECTION_KEYS, type SectionKey, UNDECLARED_POLICY_SECTIONS } from "../../src/schema.js"; +import { ROOT } from "../root.js"; import { tempDirTest } from "../temp-dir.js"; -const REPO_ROOT = join(import.meta.dir, "..", ".."); -const SRC_DIR = join(REPO_ROOT, "src"); +const SRC_DIR = join(ROOT, "src"); const SECTIONS_DIR = join(SRC_DIR, "sections"); function changed(...paths: string[]): ChangedFile[] { @@ -80,7 +80,7 @@ describe("changed-sections derived fan-out", () => { test("the real tree derives exactly the layout's fan-out for every shared file", () => { // A canary over the whole map: a new or dropped importer, a new or deleted shared file, or a hub edge reopening surfaces here and forces a // decision. - expect(deriveSharedFanOut(REPO_ROOT)).toEqual({ + expect(deriveSharedFanOut(ROOT)).toEqual({ "roles.ts": inKeyOrder("collaborators", "teams"), "secrets-engine.ts": inKeyOrder( "environments", diff --git a/test/scripts/check-compat-markers.test.ts b/test/scripts/check-compat-markers.test.ts index 159ec1b0..0496c01b 100644 --- a/test/scripts/check-compat-markers.test.ts +++ b/test/scripts/check-compat-markers.test.ts @@ -3,9 +3,10 @@ import { execFileSync, spawnSync } from "node:child_process"; import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { checkCompatMarkers } from "../../.github/scripts/check-compat-markers.js"; +import { ROOT } from "../root.js"; import { tempDirTest, withTempDir } from "../temp-dir.js"; -const SCRIPT = join(import.meta.dir, "..", "..", ".github", "scripts", "check-compat-markers.ts"); +const SCRIPT = join(ROOT, ".github", "scripts", "check-compat-markers.ts"); const tempTest = tempDirTest("compat-markers-"); diff --git a/test/scripts/gen-action-docs.test.ts b/test/scripts/gen-action-docs.test.ts index 95c99671..3a1b434e 100644 --- a/test/scripts/gen-action-docs.test.ts +++ b/test/scripts/gen-action-docs.test.ts @@ -23,10 +23,9 @@ import { OUTPUT_DECLS } from "../../src/action/io.js"; import { INPUT_DECLS } from "../../src/flows/inputs.js"; import type { SectionMeta } from "../../src/sections/contract/module.js"; import { sectionModule } from "../../src/sections/registry.js"; +import { ROOT } from "../root.js"; import { relocatedRegion } from "./relocated-region.js"; -const ROOT = join(import.meta.dir, "..", ".."); - describe("action.yml renderers", () => { test("inputs fold long descriptions, quote every default, and parse back verbatim", () => { const decls = { diff --git a/test/scripts/gen-docs.test.ts b/test/scripts/gen-docs.test.ts index 09761d96..ae3651ec 100644 --- a/test/scripts/gen-docs.test.ts +++ b/test/scripts/gen-docs.test.ts @@ -14,10 +14,9 @@ import { renderSectionsTable, } from "../../.github/scripts/gen-docs.js"; import { RUN_RESULTS } from "../../src/engine/outcome.js"; +import { ROOT } from "../root.js"; import { relocatedRegion } from "./relocated-region.js"; -const ROOT = join(import.meta.dir, "..", ".."); - describe("renderSectionsTable", () => { test("renders one row per section, derived cells around the authored ones", () => { const table = renderSectionsTable( diff --git a/test/scripts/generated.test.ts b/test/scripts/generated.test.ts index 0a9d7758..1392ade5 100644 --- a/test/scripts/generated.test.ts +++ b/test/scripts/generated.test.ts @@ -3,8 +3,7 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { GENERATED_OUTPUTS, generatedPaths } from "../../.github/scripts/generated.js"; - -const ROOT = join(import.meta.dir, "..", ".."); +import { ROOT } from "../root.js"; /** A generated-region marker in either comment syntax, opening a line. */ const BEGIN_MARKER = /^[ \t]*(?:x", + "html", + ), + ).toBe(true); + expect( + hasGeneratedRegion("\n\n", "html"), + ).toBe(true); + expect(hasGeneratedRegion("the words BEGIN GENERATED: a outside a comment\n", "html")).toBe( + false, + ); + expect( + hasGeneratedRegion("inputs:\n # BEGIN GENERATED: a\n # END GENERATED: a\n", "yaml"), + ).toBe(true); + expect(hasGeneratedRegion('d: "one\n # BEGIN GENERATED: a\n two"\n', "yaml")).toBe(false); + }); + test("names the whole-file outputs the marker scan cannot see, each tracked with one writer", () => { const files = GENERATED_OUTPUTS.filter((output) => output.kind === "file"); expect(files.map((output) => output.path).sort()).toEqual(WHOLE_FILES); @@ -62,3 +87,60 @@ describe("the generated-output table", () => { ).toHaveLength(2); }); }); + +describe("the build:check runner", () => { + const RUNNER = ".github/scripts/generated.ts"; + + function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }); + } + + function runner(cwd: string): { status: number; stdout: string; stderr: string } { + const run = Bun.spawnSync([process.execPath, RUNNER], { cwd, stdout: "pipe", stderr: "pipe" }); + return { + status: run.exitCode, + stdout: run.stdout.toString(), + stderr: run.stderr.toString(), + }; + } + + test( + "a clean clone passes; a staged stale byte in a region file and in a whole file fails naming both", + () => + withTempDir("build-check-", (dir) => { + // HEAD of this tree, its own index and working tree, sharing the object store and node_modules. + git(ROOT, "clone", "--quiet", "--shared", ROOT, dir); + symlinkSync(join(ROOT, "node_modules"), join(dir, "node_modules")); + + const clean = runner(dir); + expect(clean.stderr).toBe(""); + expect(clean.status).toBe(0); + expect(clean.stdout).toContain( + `build:check: ${generatedPaths().length} generated files match their generators`, + ); + // The node_modules link is untracked by design; everything else is exactly HEAD. + expect(git(dir, "status", "--porcelain", "--", ".", ":(exclude)node_modules")).toBe(""); + + // A stale cell the generator repairs (the row shape holds), and a stale byte in a wholesale file. + const table = join(dir, "docs/reference/sections.md"); + const page = readFileSync(table, "utf8"); + expect(page).toContain("| `labels` |"); + writeFileSync(table, page.replace("| `labels` |", "| `labelz` |")); + const index = join(dir, "src/upstream-gaps/index.ts"); + writeFileSync(index, `${readFileSync(index, "utf8")}\n`); + git(dir, "add", "docs/reference/sections.md", "src/upstream-gaps/index.ts"); + + const stale = runner(dir); + expect(stale.status).toBe(1); + expect(stale.stderr).toContain("build:check: generated output drifted"); + expect(stale.stderr).toContain(" modified: docs/reference/sections.md"); + expect(stale.stderr).toContain(" modified: src/upstream-gaps/index.ts"); + // The generators repaired the working tree; only the staged stale copies differ. + expect(git(dir, "diff", "--name-only").trim().split("\n").sort()).toEqual([ + "docs/reference/sections.md", + "src/upstream-gaps/index.ts", + ]); + }), + 120_000, + ); +}); From 5a5b5fdad857090c5dde11348e241e278c64ee1f Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:12:30 -0400 Subject: [PATCH 09/13] test: the drift-runner control exercises the working tree's runner, not HEAD's Before: the temp clone ran HEAD's .github/scripts/generated.ts, so an uncommitted edit that broke the runner still passed the control. After: the working tree's .github/scripts is copied over the clone before each run; the postconditions exclude that copy and the node_modules link. --- test/scripts/generated.test.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/test/scripts/generated.test.ts b/test/scripts/generated.test.ts index 91c5fe39..9da1aacc 100644 --- a/test/scripts/generated.test.ts +++ b/test/scripts/generated.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { GENERATED_OUTPUTS, generatedPaths } from "../../.github/scripts/generated.js"; import { @@ -108,9 +108,14 @@ describe("the build:check runner", () => { "a clean clone passes; a staged stale byte in a region file and in a whole file fails naming both", () => withTempDir("build-check-", (dir) => { - // HEAD of this tree, its own index and working tree, sharing the object store and node_modules. + // HEAD's tree with its own index, sharing the object store and node_modules; the runner and the + // generators are copied from the working tree, so the code under test is the code being edited. git(ROOT, "clone", "--quiet", "--shared", ROOT, dir); symlinkSync(join(ROOT, "node_modules"), join(dir, "node_modules")); + cpSync(join(ROOT, ".github", "scripts"), join(dir, ".github", "scripts"), { + recursive: true, + }); + const untouched = [".", ":(exclude)node_modules", ":(exclude).github/scripts"]; const clean = runner(dir); expect(clean.stderr).toBe(""); @@ -118,8 +123,7 @@ describe("the build:check runner", () => { expect(clean.stdout).toContain( `build:check: ${generatedPaths().length} generated files match their generators`, ); - // The node_modules link is untracked by design; everything else is exactly HEAD. - expect(git(dir, "status", "--porcelain", "--", ".", ":(exclude)node_modules")).toBe(""); + expect(git(dir, "status", "--porcelain", "--", ...untouched)).toBe(""); // A stale cell the generator repairs (the row shape holds), and a stale byte in a wholesale file. const table = join(dir, "docs/reference/sections.md"); @@ -136,10 +140,12 @@ describe("the build:check runner", () => { expect(stale.stderr).toContain(" modified: docs/reference/sections.md"); expect(stale.stderr).toContain(" modified: src/upstream-gaps/index.ts"); // The generators repaired the working tree; only the staged stale copies differ. - expect(git(dir, "diff", "--name-only").trim().split("\n").sort()).toEqual([ - "docs/reference/sections.md", - "src/upstream-gaps/index.ts", - ]); + expect( + git(dir, "diff", "--name-only", "--", ...untouched) + .trim() + .split("\n") + .sort(), + ).toEqual(["docs/reference/sections.md", "src/upstream-gaps/index.ts"]); }), 120_000, ); From a4cfa604d43d9b138adfed760a590b87ffa4ce81 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:12:31 -0400 Subject: [PATCH 10/13] docs: name the e2e bundle's process-exit cleanup beside the release-pipeline fixture in the temp-dir rule --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 549b5522..c946acc6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ The fleet-wide conventions - Conventional Commit titles, squash merges, the `all ## Tests -- Every temp directory a test creates is removed on every exit path, failure included: `withTempDir()` or `tempDirTest()` from `test/temp-dir.ts`, or a try/finally of its own; only a suite-scoped fixture (`test/scripts/release-pipeline-fixture.ts`) cleans in afterAll. +- Every temp directory a test creates is removed on every exit path, failure included: `withTempDir()` or `tempDirTest()` from `test/temp-dir.ts`, or a try/finally of its own. A fixture that outlives one test removes its dir when it ends (the release-pipeline fixture in afterAll, the e2e bundle on process exit). - An Io a test records through is `captureIo()` from `test/io/capture.ts`: every channel in its own list and in one ordered event log. - A repository-relative path in a test resolves from `ROOT` in `test/root.ts`; a file beside the test resolves from `import.meta.dir`. From 90c4bbb8f3a147346f8e85b1f2f61079c72505f1 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:14:37 -0400 Subject: [PATCH 11/13] test: say what the inline-marker control guards: a page whose only marker is inline --- test/scripts/generated.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/scripts/generated.test.ts b/test/scripts/generated.test.ts index 9da1aacc..5b809c31 100644 --- a/test/scripts/generated.test.ts +++ b/test/scripts/generated.test.ts @@ -34,7 +34,7 @@ describe("the generated-output table", () => { }); test("the scan reads the shared marker grammar: an inline HTML marker counts, a marker-shaped scalar does not", () => { - // inputs.md's outputs-list marker sits mid-line; a line-anchored scan would drop that page from the pin. + // A page whose only marker sits mid-line (inputs.md's outputs-list is the tree's inline case) would slip a line-anchored scan. expect( hasGeneratedRegion( "- `result`: x", From 69272ccf80866b4de9579a8bfae004ed60e3f2e1 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:54:15 -0400 Subject: [PATCH 12/13] test: trim the new machinery to the line count of a consolidation Before: tests +140 net; tempTest("long title", async (dir) => {...}) wrapped to three header lines because biome applies test-call formatting only to test/it; generated.test.ts carried shape checks the pin implies. After: tests +0 net. One temp-dir shape, test("title", () => withTempDir(prefix, async (dir) => {...})); tempDirTest() deleted. generated.test.ts is the tree-scan pin and the two controls; generated.ts is the table and the runner; capture.ts builds its lists once. --- .github/scripts/generated.ts | 32 ++-- CONTRIBUTING.md | 2 +- test/action/io.test.ts | 43 ++--- test/action/run.test.ts | 119 +++++------- test/cli/init.test.ts | 216 ++++++++++------------ test/cli/io.test.ts | 84 ++++----- test/cli/program.test.ts | 166 ++++++++--------- test/flows/execute.test.ts | 65 ++++--- test/flows/layers.test.ts | 37 ++-- test/flows/merge.test.ts | 37 ++-- test/io/capture.ts | 33 ++-- test/scripts/changed-sections.test.ts | 107 +++++------ test/scripts/check-compat-markers.test.ts | 53 +++--- test/scripts/generated.test.ts | 89 +++------ test/temp-dir.ts | 14 +- 15 files changed, 486 insertions(+), 611 deletions(-) diff --git a/.github/scripts/generated.ts b/.github/scripts/generated.ts index e710c2c6..846fa3e3 100644 --- a/.github/scripts/generated.ts +++ b/.github/scripts/generated.ts @@ -1,10 +1,6 @@ /** * The one table of committed generated output, derived from the generators' own registries, and the drift check - * behind `bun run build:check` (`bun .github/scripts/generated.ts`): every generator runs, then every registered - * path must be tracked and unchanged. - * regions -> marker-delimited generated regions inside an authored .md or .yml file (lib/generated-regions.ts) - * file -> the whole file is generated - * A page two generators write into (docs/reference/inputs.md) has one row per generator. + * behind `bun run build:check`: every generator runs, then every registered path must be tracked and unchanged. */ import { join } from "node:path"; @@ -14,30 +10,29 @@ import { INDEX_PATH } from "./gen-gaps-index.js"; const ROOT = join(import.meta.dir, "..", ".."); -export type GeneratedKind = "regions" | "file"; - export interface GeneratedOutput { /** The committed output, repo-relative. */ readonly path: string; /** The script that writes it, repo-relative; `bun ` regenerates it in place. */ readonly generator: string; - readonly kind: GeneratedKind; + /** Marker-delimited regions inside an authored file (lib/generated-regions.ts), or the whole file. */ + readonly kind: "regions" | "file"; } -const GEN_SETTINGS_SCHEMA = ".github/scripts/gen-settings-schema.ts"; -const GEN_DOCS = ".github/scripts/gen-docs.ts"; -const GEN_ACTION_DOCS = ".github/scripts/gen-action-docs.ts"; -const GEN_GAPS_INDEX = ".github/scripts/gen-gaps-index.ts"; - function regions(generator: string, paths: readonly string[]): GeneratedOutput[] { return paths.map((path) => ({ path, generator, kind: "regions" })); } +/** A page two generators write into (docs/reference/inputs.md) has one row per generator. */ export const GENERATED_OUTPUTS: readonly GeneratedOutput[] = [ - { path: "lib/settings.schema.json", generator: GEN_SETTINGS_SCHEMA, kind: "file" }, - ...regions(GEN_DOCS, [COVERAGE_PATH, ...Object.keys(PAGE_REGIONS)]), - ...regions(GEN_ACTION_DOCS, Object.keys(GENERATED_REGIONS)), - { path: INDEX_PATH, generator: GEN_GAPS_INDEX, kind: "file" }, + { + path: "lib/settings.schema.json", + generator: ".github/scripts/gen-settings-schema.ts", + kind: "file", + }, + ...regions(".github/scripts/gen-docs.ts", [COVERAGE_PATH, ...Object.keys(PAGE_REGIONS)]), + ...regions(".github/scripts/gen-action-docs.ts", Object.keys(GENERATED_REGIONS)), + { path: INDEX_PATH, generator: ".github/scripts/gen-gaps-index.ts", kind: "file" }, ]; /** The distinct paths, in table order. */ @@ -69,8 +64,7 @@ if (import.meta.main) { } } const paths = generatedPaths(); - // Working tree against the index, as the git diff of the former hand-typed list was: a regenerated file - // already staged passes, a stale one fails. + // Working tree against the index: a regenerated file already staged passes, a stale one fails. const drifted = git(["diff", "--name-only", "--", ...paths]); const untracked = git(["ls-files", "--others", "--exclude-standard", "--", ...paths]); if (drifted.length > 0 || untracked.length > 0) { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c946acc6..b53b0699 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ The fleet-wide conventions - Conventional Commit titles, squash merges, the `all ## Tests -- Every temp directory a test creates is removed on every exit path, failure included: `withTempDir()` or `tempDirTest()` from `test/temp-dir.ts`, or a try/finally of its own. A fixture that outlives one test removes its dir when it ends (the release-pipeline fixture in afterAll, the e2e bundle on process exit). +- Every temp directory a test creates is removed on every exit path, failure included: `withTempDir()` from `test/temp-dir.ts`, or a try/finally of its own. A fixture that outlives one test removes its dir when it ends (the release-pipeline fixture in afterAll, the e2e bundle on process exit). - An Io a test records through is `captureIo()` from `test/io/capture.ts`: every channel in its own list and in one ordered event log. - A repository-relative path in a test resolves from `ROOT` in `test/root.ts`; a file beside the test resolves from `import.meta.dir`. diff --git a/test/action/io.test.ts b/test/action/io.test.ts index a15c6ac0..82b9c706 100644 --- a/test/action/io.test.ts +++ b/test/action/io.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { actionsIo } from "../../src/action/io.js"; import { writeSummary } from "../../src/flows/summary.js"; import { ROOT } from "../root.js"; -import { tempDirTest } from "../temp-dir.js"; +import { withTempDir } from "../temp-dir.js"; /** A static import, a re-export, a dynamic import(), or a require() all quote the specifier; a comment mentioning it bare does not. */ function namesActionsCore(source: string): boolean { @@ -63,11 +63,8 @@ describe("actionsIo", () => { } } }); - const tempTest = tempDirTest("sac-io-"); - - tempTest( - "summary appends each block with one trailing newline, and skips when the runner file is unset", - (dir) => { + test("summary appends each block with one trailing newline, and skips when the runner file is unset", () => + withTempDir("sac-io-", (dir) => { const file = join(dir, "summary.md"); delete process.env.GITHUB_STEP_SUMMARY; actionsIo.summary("dropped"); @@ -90,24 +87,24 @@ describe("actionsIo", () => { "", ].join("\n"), ); - }, - ); + })); - tempTest("output writes the runner's output file only when it is set", (dir) => { - // The runner creates the file; @actions/core refuses to append to a missing one. - const file = join(dir, "output.txt"); - writeFileSync(file, ""); - delete process.env.GITHUB_OUTPUT; - actionsIo.output("result", "dropped"); - // @ts-expect-error a misspelled output name fails to compile at the port - actionsIo.output("reslut", "dropped"); - process.env.GITHUB_OUTPUT = file; - actionsIo.output("result", "clean"); - // @actions/core writes outputs in heredoc form: name< + withTempDir("sac-io-", (dir) => { + // The runner creates the file; @actions/core refuses to append to a missing one. + const file = join(dir, "output.txt"); + writeFileSync(file, ""); + delete process.env.GITHUB_OUTPUT; + actionsIo.output("result", "dropped"); + // @ts-expect-error a misspelled output name fails to compile at the port + actionsIo.output("reslut", "dropped"); + process.env.GITHUB_OUTPUT = file; + actionsIo.output("result", "clean"); + // @actions/core writes outputs in heredoc form: name< { actionsIo.mask("o/private"); diff --git a/test/action/run.test.ts b/test/action/run.test.ts index 6b42b5c0..d688389b 100644 --- a/test/action/run.test.ts +++ b/test/action/run.test.ts @@ -10,7 +10,7 @@ import { REPORT_HEADING } from "../../src/report/composer.js"; import { SECTION_KEYS } from "../../src/schema.js"; import { MockApi } from "../mock-api.js"; import { ROOT } from "../root.js"; -import { tempDirTest, withTempDir } from "../temp-dir.js"; +import { withTempDir } from "../temp-dir.js"; // A capturing Io replaces the @actions/core sink, so a green suite prints no raw workflow commands and the failure-path tests assert the exact // captured text. @@ -545,8 +545,6 @@ describe("run in mode: merge", () => { const saved = new Map(ENV_KEYS.map((k) => [k, process.env[k]])); const FIXTURES = join(ROOT, "test", "fixtures", "layers"); const layer = (name: string) => join(FIXTURES, name); - const tempTest = tempDirTest("merge-mode-"); - afterEach(() => { for (const [key, value] of saved) { if (value === undefined) { @@ -607,9 +605,8 @@ describe("run in mode: merge", () => { }, }; - tempTest( - "three layers fold into the merged file with no token and no API call; nulls opt out with notices", - async (dir) => { + test("three layers fold into the merged file with no token and no API call; nulls opt out with notices", () => + withTempDir("merge-mode-", async (dir) => { const layers = [layer("fleet.yml"), layer("team.yml"), layer("repo.yml")]; const mergedFile = setMergeEnv(dir, layers); const api = new MockApi({}); @@ -636,12 +633,10 @@ describe("run in mode: merge", () => { `Merged document written to ${mergedFile}.`, ].join("\n"), ]); - }, - ); + })); - tempTest( - "a layer that is invalid on its own fails the run naming the layer, before any merge or write", - async (dir) => { + test("a layer that is invalid on its own fails the run naming the layer, before any merge or write", () => + withTempDir("merge-mode-", async (dir) => { const broken = tempLayer(dir, "broken.yml", { labels: [{ color: "ffffff" }] }); const mergedFile = setMergeEnv(dir, [layer("fleet.yml"), broken]); const api = new MockApi({}); @@ -654,21 +649,20 @@ describe("run in mode: merge", () => { expect(errors[0]).toStartWith( `error: ${broken} has malformed section entries: labels[0].name:`, ); - }, - ); + })); - tempTest("an unreadable layer fails the run naming the path and the input", async (dir) => { - const missing = join(dir, "nope.yml"); - setMergeEnv(dir, [layer("fleet.yml"), missing]); - expect(await run({ api: new MockApi({}), io: testIo })).toBe(1); - expect(captured.filter((line) => line.startsWith("error: "))).toEqual([ - `error: cannot read the settings layer ${missing}: Error: ENOENT: no such file or directory, open '${missing}'. Check that every path in the "settings-file" input exists and is valid YAML`, - ]); - }); + test("an unreadable layer fails the run naming the path and the input", () => + withTempDir("merge-mode-", async (dir) => { + const missing = join(dir, "nope.yml"); + setMergeEnv(dir, [layer("fleet.yml"), missing]); + expect(await run({ api: new MockApi({}), io: testIo })).toBe(1); + expect(captured.filter((line) => line.startsWith("error: "))).toEqual([ + `error: cannot read the settings layer ${missing}: Error: ENOENT: no such file or directory, open '${missing}'. Check that every path in the "settings-file" input exists and is valid YAML`, + ]); + })); - tempTest( - "layering: replace lets the higher layer's keyed lists win while mappings still merge", - async (dir) => { + test("layering: replace lets the higher layer's keyed lists win while mappings still merge", () => + withTempDir("merge-mode-", async (dir) => { const mergedFile = setMergeEnv(dir, [layer("fleet.yml"), layer("repo.yml")], { layering: "replace", }); @@ -678,12 +672,10 @@ describe("run in mode: merge", () => { labels: { _undeclared: "delete", entries: [{ name: "docs", color: "ffffff" }] }, rulesets: { _undeclared: "keep", entries: [FLEET_RULESET] }, }); - }, - ); + })); - tempTest( - "a wrapper _layering: replace under a merge run replaces that section alone, and never reaches the file", - async (dir) => { + test("a wrapper _layering: replace under a merge run replaces that section alone, and never reaches the file", () => + withTempDir("merge-mode-", async (dir) => { const top = tempLayer(dir, "top.yml", { labels: { _layering: "replace", entries: [{ name: "only", color: "000000" }] }, rulesets: [{ name: "tags", target: "tag" }], @@ -699,12 +691,10 @@ describe("run in mode: merge", () => { }, pages: { build_type: "workflow", source: { branch: "main", path: "/" } }, }); - }, - ); + })); - tempTest( - "a top-level _layering: replace governs every keyed section of its layer and is consumed", - async (dir) => { + test("a top-level _layering: replace governs every keyed section of its layer and is consumed", () => + withTempDir("merge-mode-", async (dir) => { const top = tempLayer(dir, "top.yml", { _layering: "replace", labels: [{ name: "only", color: "000000" }], @@ -721,12 +711,10 @@ describe("run in mode: merge", () => { }, pages: { build_type: "workflow", source: { branch: "main", path: "/" } }, }); - }, - ); + })); - tempTest( - "a null inside a keyed entry is a merge marker too, announced against its layer", - async (dir) => { + test("a null inside a keyed entry is a merge marker too, announced against its layer", () => + withTempDir("merge-mode-", async (dir) => { const bypass = [{ actor_id: 1, actor_type: "Team", bypass_mode: "always" }]; const fleet = tempLayer(dir, "fleet.yml", { rulesets: [{ ...FLEET_RULESET, bypass_actors: bypass }], @@ -742,8 +730,7 @@ describe("run in mode: merge", () => { `merged 2 layer(s) into ${mergedFile}`, "result: merged", ]); - }, - ); + })); test.each([ ["a mapping", { setting: true }], @@ -767,9 +754,8 @@ describe("run in mode: merge", () => { }), ); - tempTest( - "a sections allowlist is rejected before any layer is read, so the written document is never narrower than the fold", - async (dir) => { + test("a sections allowlist is rejected before any layer is read, so the written document is never narrower than the fold", () => + withTempDir("merge-mode-", async (dir) => { // An allowlist naming one of the three folded sections is refused up front rather than narrowing the file; the nonexistent layer path proves the // refusal precedes the read. const mergedFile = setMergeEnv(dir, [ @@ -787,12 +773,10 @@ describe("run in mode: merge", () => { "them to the apply or check step that runs the merged document", "result: failed", ]); - }, - ); + })); - tempTest( - "a cyclic layer (a YAML anchor that includes itself) is refused by the fold, naming the layer", - async (dir) => { + test("a cyclic layer (a YAML anchor that includes itself) is refused by the fold, naming the layer", () => + withTempDir("merge-mode-", async (dir) => { // A self-referencing anchor under a private key parses to a cyclic object; standalone validation ignores the key, so the engine's boundary is // what refuses the layer. const top = join(dir, "top.yml"); @@ -804,12 +788,10 @@ describe("run in mode: merge", () => { `error: layer "${top}": the document contains a reference cycle (a YAML anchor that includes itself); layers must be trees`, "result: failed", ]); - }, - ); + })); - tempTest( - "merge then apply: the written file applies exactly like the expected merged document", - async (dir) => { + test("merge then apply: the written file applies exactly like the expected merged document", () => + withTempDir("merge-mode-", async (dir) => { const mergedFile = setMergeEnv(dir, [ layer("fleet.yml"), layer("team.yml"), @@ -844,8 +826,7 @@ describe("run in mode: merge", () => { "POST /repos/o/r/labels", "POST /repos/o/r/labels", ]); - }, - ); + })); }); describe("run in mode: snapshot", () => { @@ -859,8 +840,6 @@ describe("run in mode: snapshot", () => { "GITHUB_REPOSITORY", ]; const saved = new Map(ENV_KEYS.map((k) => [k, process.env[k]])); - const tempTest = tempDirTest("snapshot-mode-"); - afterEach(() => { for (const [key, value] of saved) { if (value === undefined) { @@ -892,9 +871,8 @@ describe("run in mode: snapshot", () => { const labelsApi = () => new MockApi({ "GET /repos/o/r/labels?per_page=100&page=1": { data: LABELS } }); - tempTest( - "writes the live settings to snapshot-file and publishes the snapshot result", - async (dir) => { + test("writes the live settings to snapshot-file and publishes the snapshot result", () => + withTempDir("snapshot-mode-", async (dir) => { const snapshotFile = setSnapshotEnv(dir); const api = labelsApi(); expect(await run({ api, io: testIo })).toBe(0); @@ -904,12 +882,10 @@ describe("run in mode: snapshot", () => { }); expect(outputs).toEqual({ result: "snapshot", "skipped-sections": "", "repos-result": "{}" }); expect(captured).toEqual([`snapshot written to ${snapshotFile}`, "result: snapshot"]); - }, - ); + })); - tempTest( - "an apply-time input is rejected before any API call, and no file is written", - async (dir) => { + test("an apply-time input is rejected before any API call, and no file is written", () => + withTempDir("snapshot-mode-", async (dir) => { const snapshotFile = setSnapshotEnv(dir); process.env["INPUT_SETTINGS-FILE"] = "other.yml"; const api = labelsApi(); @@ -923,12 +899,10 @@ describe("run in mode: snapshot", () => { "the apply, check, or merge step they belong to", "result: failed", ]); - }, - ); + })); - tempTest( - "snapshot then check: the written file checks clean against the live state it was read from", - async (dir) => { + test("snapshot then check: the written file checks clean against the live state it was read from", () => + withTempDir("snapshot-mode-", async (dir) => { const snapshotFile = setSnapshotEnv(dir); expect(await run({ api: labelsApi(), io: testIo })).toBe(0); for (const key of ENV_KEYS) { @@ -944,6 +918,5 @@ describe("run in mode: snapshot", () => { expect(await run({ api, io: testIo })).toBe(0); expect(api.mutations()).toEqual([]); expect(outputs.result).toBe("clean"); - }, - ); + })); }); diff --git a/test/cli/init.test.ts b/test/cli/init.test.ts index 5beebe34..39955bfe 100644 --- a/test/cli/init.test.ts +++ b/test/cli/init.test.ts @@ -19,7 +19,7 @@ import { validateSettings, } from "../../src/index.js"; import { MockApi } from "../mock-api.js"; -import { tempDirTest, withTempDir } from "../temp-dir.js"; +import { withTempDir } from "../temp-dir.js"; import { memoryStream, runCli } from "./streams.js"; const TOKEN = "ghp_cli_init_token"; @@ -28,8 +28,6 @@ const LABELS_ROUTE = { "GET /repos/o/r/labels?per_page=100&page=1": { data: [BUG const LABELS_DOC = { labels: { _undeclared: "delete", entries: [BUG] } }; const LABELS_GRANT = sectionGrant(sectionModule("labels")); -const tempTest = tempDirTest("gsac-init-"); - const cli = runCli; /** Drive init on `argv` and capture what its argv port parsed, or what it printed. */ @@ -147,9 +145,8 @@ describe("init: argv -> config", () => { }); describe("init: the written file and the printed grant", () => { - tempTest( - "writes the snapshot under the schema hint, prints the grant for its sections, exits 0", - async (dir) => { + test("writes the snapshot under the schema hint, prints the grant for its sections, exits 0", () => + withTempDir("gsac-init-", async (dir) => { const file = join(dir, ".github", "settings.yml"); const api = new MockApi(LABELS_ROUTE); const result = await cli( @@ -184,12 +181,10 @@ describe("init: the written file and the printed grant", () => { const doc = parseYaml(written); expect(doc).toEqual(LABELS_DOC); expect(validateSettings(doc).isOk()).toBe(true); - }, - ); + })); - tempTest( - "the grant is the section declarations' for exactly the sections the file declares", - async (dir) => { + test("the grant is the section declarations' for exactly the sections the file declares", () => + withTempDir("gsac-init-", async (dir) => { const file = join(dir, "settings.yml"); const api = new MockApi({ ...LABELS_ROUTE, @@ -223,12 +218,10 @@ describe("init: the written file and the printed grant", () => { declared.map((key) => [key, sectionGrant(sectionModule(key as "labels"))]), ), }); - }, - ); + })); - tempTest( - "a section failing beside one that read back fails init: no file, the errors name the section", - async (dir) => { + test("a section failing beside one that read back fails init: no file, the errors name the section", () => + withTempDir("gsac-init-", async (dir) => { const file = join(dir, "settings.yml"); const api = new MockApi({ "GET /repos/o/r/labels?per_page=100&page=1": { @@ -259,53 +252,52 @@ describe("init: the written file and the printed grant", () => { const json = await cli([...args, "--json"], api); expect(json.code).toBe(1); expect(JSON.parse(json.stdout)).toEqual({ result: "failed", file, problem }); - }, - ); + })); - tempTest("a check over the written file reads clean against the same repository", async (dir) => { - const file = join(dir, "settings.yml"); - const api = new MockApi(LABELS_ROUTE); - const init = await cli( - [ - "init", - "--token", - TOKEN, - "--repository", - "o/r", - "--settings-file", - file, - "--sections", - "labels", - ], - api, - ); - expect(init.code).toBe(0); - const check = await cli( - [ - "check", - "--token", - TOKEN, - "--repository", - "o/r", - "--settings-file", - file, - "--sections", - "labels", - "--private-repos", - "show", - ], - api, - ); - expect(check).toEqual({ - code: 0, - stdout: "result: clean\nresult=clean\nskipped-sections=\nrepos-result={}\n", - stderr: "", - }); - }); + test("a check over the written file reads clean against the same repository", () => + withTempDir("gsac-init-", async (dir) => { + const file = join(dir, "settings.yml"); + const api = new MockApi(LABELS_ROUTE); + const init = await cli( + [ + "init", + "--token", + TOKEN, + "--repository", + "o/r", + "--settings-file", + file, + "--sections", + "labels", + ], + api, + ); + expect(init.code).toBe(0); + const check = await cli( + [ + "check", + "--token", + TOKEN, + "--repository", + "o/r", + "--settings-file", + file, + "--sections", + "labels", + "--private-repos", + "show", + ], + api, + ); + expect(check).toEqual({ + code: 0, + stdout: "result: clean\nresult=clean\nskipped-sections=\nrepos-result={}\n", + stderr: "", + }); + })); - tempTest( - "a denied section under warn is skipped: partial, the file omits it, the line says so, exit 0", - async (dir) => { + test("a denied section under warn is skipped: partial, the file omits it, the line says so, exit 0", () => + withTempDir("gsac-init-", async (dir) => { const file = join(dir, "settings.yml"); // No variables route: the read answers 404, the fine-grained denial. const result = await cli( @@ -339,12 +331,10 @@ describe("init: the written file and the printed grant", () => { ].join("\n"), ); expect(parseYaml(readFileSync(file, "utf8"))).toEqual(LABELS_DOC); - }, - ); + })); - tempTest( - "a denied section under fail fails the run: no file, the error names the section, exit 1", - async (dir) => { + test("a denied section under fail fails the run: no file, the error names the section, exit 1", () => + withTempDir("gsac-init-", async (dir) => { const file = join(dir, "settings.yml"); const result = await cli( [ @@ -369,8 +359,7 @@ describe("init: the written file and the printed grant", () => { `error: the snapshot of o/r failed, so ${file} was not written; the errors above name the section and the fix\n`, ); expect(existsSync(file)).toBe(false); - }, - ); + })); }); describe("init: an existing settings file", () => { @@ -386,30 +375,30 @@ describe("init: an existing settings file", () => { "labels", ]; - tempTest("is refused naming --force, before any API call, and left as it was", async (dir) => { - const file = join(dir, "settings.yml"); - writeFileSync(file, "repository:\n has_wiki: false\n"); - const api = new MockApi(LABELS_ROUTE); - const result = await cli(target(file), api); - expect(api.calls).toHaveLength(0); - expect(result).toEqual({ - code: 1, - stdout: "", - stderr: `error: ${file} already exists: init writes the starting settings file and does not replace the one you author. Pass --force to replace it, or --settings-file to write elsewhere\n`, - }); - expect(readFileSync(file, "utf8")).toBe("repository:\n has_wiki: false\n"); - const json = await cli([...target(file), "--json"], api); - expect(json.code).toBe(1); - expect(JSON.parse(json.stdout)).toEqual({ - result: "failed", - file, - problem: result.stderr.slice("error: ".length, -1), - }); - }); + test("is refused naming --force, before any API call, and left as it was", () => + withTempDir("gsac-init-", async (dir) => { + const file = join(dir, "settings.yml"); + writeFileSync(file, "repository:\n has_wiki: false\n"); + const api = new MockApi(LABELS_ROUTE); + const result = await cli(target(file), api); + expect(api.calls).toHaveLength(0); + expect(result).toEqual({ + code: 1, + stdout: "", + stderr: `error: ${file} already exists: init writes the starting settings file and does not replace the one you author. Pass --force to replace it, or --settings-file to write elsewhere\n`, + }); + expect(readFileSync(file, "utf8")).toBe("repository:\n has_wiki: false\n"); + const json = await cli([...target(file), "--json"], api); + expect(json.code).toBe(1); + expect(JSON.parse(json.stdout)).toEqual({ + result: "failed", + file, + problem: result.stderr.slice("error: ".length, -1), + }); + })); - tempTest( - "is left byte for byte as it was when --force gets past the check and a section then fails", - async (dir) => { + test("is left byte for byte as it was when --force gets past the check and a section then fails", () => + withTempDir("gsac-init-", async (dir) => { const file = join(dir, "settings.yml"); const original = "repository:\n has_wiki: false\n"; writeFileSync(file, original); @@ -430,12 +419,10 @@ describe("init: an existing settings file", () => { expect(json.stderr).toBe(result.stderr); expect(JSON.parse(json.stdout)).toEqual({ result: "failed", file, problem }); expect(readFileSync(file, "utf8")).toBe(original); - }, - ); + })); - tempTest( - "a problem in the flags names the settings file in the envelope when the command was told one", - async (dir) => { + test("a problem in the flags names the settings file in the envelope when the command was told one", () => + withTempDir("gsac-init-", async (dir) => { const file = join(dir, "settings.yml"); const result = await cli( ["init", "--repository", "o/r", "--settings-file", file, "--json"], @@ -448,8 +435,7 @@ describe("init: an existing settings file", () => { stdout: `${JSON.stringify({ result: "failed", file, problem })}\n`, stderr: `error: ${problem}\n`, }); - }, - ); + })); const NO_MILESTONES = { "GET /repos/o/r/milestones?state=all&per_page=100&page=1": { data: [] } }; test.each<[string, string, Record, string, number | undefined]>([ @@ -495,21 +481,23 @@ describe("init: an existing settings file", () => { }), ); - tempTest("is replaced under --force", async (dir) => { - const file = join(dir, "settings.yml"); - writeFileSync(file, "repository:\n has_wiki: false\n"); - const result = await cli([...target(file), "--force"], new MockApi(LABELS_ROUTE)); - expect(result.code).toBe(0); - expect(parseYaml(readFileSync(file, "utf8"))).toEqual(LABELS_DOC); - }); + test("is replaced under --force", () => + withTempDir("gsac-init-", async (dir) => { + const file = join(dir, "settings.yml"); + writeFileSync(file, "repository:\n has_wiki: false\n"); + const result = await cli([...target(file), "--force"], new MockApi(LABELS_ROUTE)); + expect(result.code).toBe(0); + expect(parseYaml(readFileSync(file, "utf8"))).toEqual(LABELS_DOC); + })); - tempTest("an unwritable destination fails naming --settings-file", async (dir) => { - // A directory at the path: --force gets past the existence check, the write itself fails. - const file = join(dir, "settings.yml"); - mkdirSync(file); - const result = await cli([...target(file), "--force"], new MockApi(LABELS_ROUTE)); - expect(result.code).toBe(1); - expect(result.stderr).toStartWith(`error: cannot write the settings file ${file}: `); - expect(result.stderr).toEndWith(". Check that --settings-file names a writable path\n"); - }); + test("an unwritable destination fails naming --settings-file", () => + withTempDir("gsac-init-", async (dir) => { + // A directory at the path: --force gets past the existence check, the write itself fails. + const file = join(dir, "settings.yml"); + mkdirSync(file); + const result = await cli([...target(file), "--force"], new MockApi(LABELS_ROUTE)); + expect(result.code).toBe(1); + expect(result.stderr).toStartWith(`error: cannot write the settings file ${file}: `); + expect(result.stderr).toEndWith(". Check that --settings-file names a writable path\n"); + })); }); diff --git a/test/cli/io.test.ts b/test/cli/io.test.ts index b5610d05..f77fdb40 100644 --- a/test/cli/io.test.ts +++ b/test/cli/io.test.ts @@ -8,11 +8,9 @@ import { describe, expect, test } from "bun:test"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { type CliIoOptions, cliIo, maskedStreams } from "../../src/cli/io.js"; -import { tempDirTest, withTempDir } from "../temp-dir.js"; +import { withTempDir } from "../temp-dir.js"; import { memoryStream } from "./streams.js"; -const tempTest = tempDirTest("gsac-io-"); - function open(options: Partial> = {}) { const stdout = memoryStream(); const stderr = memoryStream(); @@ -85,46 +83,48 @@ describe("the CLI Io", () => { expect(stderr()).toBe("error: boom\n"); }); - tempTest("a masked value is redacted on every channel, the outputs included", (dir) => { - const summary = join(dir, "summary.md"); - const { io, flush, stdout, stderr } = open({ summaryFile: summary, verbose: true }); - io.mask("ghp_secret"); - io.log("token ghp_secret in a log line"); - io.annotate("error", "401 for ghp_secret"); - io.debug("Authorization: token ghp_secret"); - io.summary("# run by ghp_secret"); - io.output("result", "failed ghp_secret"); - flush(); - const everything = stdout() + stderr() + readFileSync(summary, "utf8"); - expect(everything).not.toContain("ghp_secret"); - expect(stdout()).toBe("token *** in a log line\nresult=failed ***\n"); - expect(stderr()).toBe("error: 401 for ***\ndebug: Authorization: token ***\n"); - expect(readFileSync(summary, "utf8")).toBe("# run by ***\n"); - }); + test("a masked value is redacted on every channel, the outputs included", () => + withTempDir("gsac-io-", (dir) => { + const summary = join(dir, "summary.md"); + const { io, flush, stdout, stderr } = open({ summaryFile: summary, verbose: true }); + io.mask("ghp_secret"); + io.log("token ghp_secret in a log line"); + io.annotate("error", "401 for ghp_secret"); + io.debug("Authorization: token ghp_secret"); + io.summary("# run by ghp_secret"); + io.output("result", "failed ghp_secret"); + flush(); + const everything = stdout() + stderr() + readFileSync(summary, "utf8"); + expect(everything).not.toContain("ghp_secret"); + expect(stdout()).toBe("token *** in a log line\nresult=failed ***\n"); + expect(stderr()).toBe("error: 401 for ***\ndebug: Authorization: token ***\n"); + expect(readFileSync(summary, "utf8")).toBe("# run by ***\n"); + })); - tempTest("summary blocks append to the named file and are dropped without one", async (dir) => { - const summary = join(dir, "summary.md"); - const named = open({ summaryFile: summary }); - named.io.summary("## first"); - named.io.summary("## second"); - expect(readFileSync(summary, "utf8")).toBe("## first\n## second\n"); - // Without a file the block goes nowhere: no stream carries it, and the working - // directory (where a defaulted path would land) stays empty. - await withTempDir("gsac-io-cwd-", (cwd) => { - const previous = process.cwd(); - process.chdir(cwd); - try { - const unnamed = open(); - unnamed.io.summary("## dropped"); - expect(unnamed.stdout() + unnamed.stderr()).toBe(""); - expect(readdirSync(cwd)).toEqual([]); - } finally { - process.chdir(previous); - } - }); - expect(readdirSync(dir)).toEqual(["summary.md"]); - expect(readFileSync(summary, "utf8")).toBe("## first\n## second\n"); - }); + test("summary blocks append to the named file and are dropped without one", () => + withTempDir("gsac-io-", async (dir) => { + const summary = join(dir, "summary.md"); + const named = open({ summaryFile: summary }); + named.io.summary("## first"); + named.io.summary("## second"); + expect(readFileSync(summary, "utf8")).toBe("## first\n## second\n"); + // Without a file the block goes nowhere: no stream carries it, and the working + // directory (where a defaulted path would land) stays empty. + await withTempDir("gsac-io-cwd-", (cwd) => { + const previous = process.cwd(); + process.chdir(cwd); + try { + const unnamed = open(); + unnamed.io.summary("## dropped"); + expect(unnamed.stdout() + unnamed.stderr()).toBe(""); + expect(readdirSync(cwd)).toEqual([]); + } finally { + process.chdir(previous); + } + }); + expect(readdirSync(dir)).toEqual(["summary.md"]); + expect(readFileSync(summary, "utf8")).toBe("## first\n## second\n"); + })); test("identical consecutive lines are all printed, never folded", () => { // consola folds repeats within a second by default; a drift report with diff --git a/test/cli/program.test.ts b/test/cli/program.test.ts index 5e83e224..6c6f1e5a 100644 --- a/test/cli/program.test.ts +++ b/test/cli/program.test.ts @@ -14,15 +14,13 @@ import { CLI_COMMANDS, main } from "../../src/cli/program.js"; import { type ConfigEnv, type GithubClient, sectionGrant, sectionModule } from "../../src/index.js"; import { MockApi } from "../mock-api.js"; import { ROOT } from "../root.js"; -import { tempDirTest, withTempDir } from "../temp-dir.js"; +import { withTempDir } from "../temp-dir.js"; import { memoryStream, runCli } from "./streams.js"; const SINGLE = join(ROOT, "test", "fixtures", "single.yml"); const LAYERS = join(ROOT, "test", "fixtures", "layers"); const TOKEN = "ghp_cli_test_token"; -const tempTest = tempDirTest("gsac-cli-"); - const cli = runCli; describe("check and apply", () => { @@ -230,60 +228,62 @@ describe("check and apply", () => { }), ); - tempTest("--summary appends the run's markdown to the named file", async (dir) => { - const summary = join(dir, "summary.md"); - const result = await cli( - ["check", ...target, "--summary", summary], - new MockApi({ "GET /repos/o/r": { data: { has_wiki: false } } }), - ); - expect(result.code).toBe(0); - expect(readFileSync(summary, "utf8")).toContain("clean"); - }); + test("--summary appends the run's markdown to the named file", () => + withTempDir("gsac-cli-", async (dir) => { + const summary = join(dir, "summary.md"); + const result = await cli( + ["check", ...target, "--summary", summary], + new MockApi({ "GET /repos/o/r": { data: { has_wiki: false } } }), + ); + expect(result.code).toBe(0); + expect(readFileSync(summary, "utf8")).toContain("clean"); + })); }); describe("merge", () => { - tempTest("folds the layers into the merged file, exits 0, and reports merged", async (dir) => { - const out = join(dir, "merged.yml"); - const result = await cli([ - "merge", - "--settings-file", - join(LAYERS, "fleet.yml"), - "--settings-file", - join(LAYERS, "team.yml"), - "--merged-file", - out, - ]); - expect(result.code).toBe(0); - expect(result.stdout).toEndWith( - "result: merged\nresult=merged\nskipped-sections=\nrepos-result={}\n", - ); - // The whole document only the fold produces: team.yml's label and rule join - // fleet.yml's under explicit policy wrappers, its `has_projects: null` removes - // fleet.yml's key, and pages passes through untouched. - expect(parseYaml(readFileSync(out, "utf8"))).toEqual({ - repository: { has_wiki: false }, - labels: { - _undeclared: "delete", - entries: [ - { name: "bug", color: "d73a4a" }, - { name: "docs", color: "0075ca" }, - { name: "team", color: "00ff00" }, - ], - }, - rulesets: { - _undeclared: "keep", - entries: [ - { - name: "main", - target: "branch", - enforcement: "active", - rules: [{ type: "deletion" }, { type: "non_fast_forward" }], - }, - ], - }, - pages: { build_type: "workflow", source: { branch: "main", path: "/" } }, - }); - }); + test("folds the layers into the merged file, exits 0, and reports merged", () => + withTempDir("gsac-cli-", async (dir) => { + const out = join(dir, "merged.yml"); + const result = await cli([ + "merge", + "--settings-file", + join(LAYERS, "fleet.yml"), + "--settings-file", + join(LAYERS, "team.yml"), + "--merged-file", + out, + ]); + expect(result.code).toBe(0); + expect(result.stdout).toEndWith( + "result: merged\nresult=merged\nskipped-sections=\nrepos-result={}\n", + ); + // The whole document only the fold produces: team.yml's label and rule join + // fleet.yml's under explicit policy wrappers, its `has_projects: null` removes + // fleet.yml's key, and pages passes through untouched. + expect(parseYaml(readFileSync(out, "utf8"))).toEqual({ + repository: { has_wiki: false }, + labels: { + _undeclared: "delete", + entries: [ + { name: "bug", color: "d73a4a" }, + { name: "docs", color: "0075ca" }, + { name: "team", color: "00ff00" }, + ], + }, + rulesets: { + _undeclared: "keep", + entries: [ + { + name: "main", + target: "branch", + enforcement: "active", + rules: [{ type: "deletion" }, { type: "non_fast_forward" }], + }, + ], + }, + pages: { build_type: "workflow", source: { branch: "main", path: "/" } }, + }); + })); test("a merge without merged-file fails with the action's line", async () => { const result = await cli(["merge", "--settings-file", join(LAYERS, "fleet.yml")]); @@ -298,9 +298,8 @@ describe("merge", () => { describe("snapshot", () => { const LABEL = { name: "bug", color: "d73a4a", description: "Something is broken" }; - tempTest( - "writes the live settings to the snapshot file, exits 0, and reports snapshot", - async (dir) => { + test("writes the live settings to the snapshot file, exits 0, and reports snapshot", () => + withTempDir("gsac-cli-", async (dir) => { const out = join(dir, "out", "snapshot.yml"); const api = new MockApi({ "GET /repos/o/r": { data: { private: false } }, @@ -329,8 +328,7 @@ describe("snapshot", () => { expect(parseYaml(readFileSync(out, "utf8"))).toEqual({ labels: { _undeclared: "delete", entries: [LABEL] }, }); - }, - ); + })); test("a snapshot without a destination fails before any API call, the action's line on stderr", async () => { const api = new MockApi({}); @@ -363,30 +361,31 @@ describe("validate and permissions", () => { }); }); - tempTest("validate: an invalid file exits 1 with the validator's line", async (dir) => { - const file = join(dir, "bad.yml"); - writeFileSync(file, "labels:\n - color: d73a4a\n"); - const result = await cli(["validate", file]); - expect(result.code).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toStartWith(`error: ${file} has malformed section entries:`); - // The same message, once on stderr and once as the object's problem. - const problem = result.stderr.slice("error: ".length, -1); - const json = await cli(["validate", file, "--json"]); - expect(json.code).toBe(1); - expect(json.stderr).toBe(result.stderr); - expect(JSON.parse(json.stdout)).toEqual({ result: "failed", file, problem }); - }); - - tempTest("validate: an unreadable file exits 1 naming the path", async (dir) => { - const result = await cli(["validate", join(dir, "missing.yml")]); - expect(result.code).toBe(1); - expect(result.stderr).toStartWith("error: cannot read settings from "); - }); + test("validate: an invalid file exits 1 with the validator's line", () => + withTempDir("gsac-cli-", async (dir) => { + const file = join(dir, "bad.yml"); + writeFileSync(file, "labels:\n - color: d73a4a\n"); + const result = await cli(["validate", file]); + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toStartWith(`error: ${file} has malformed section entries:`); + // The same message, once on stderr and once as the object's problem. + const problem = result.stderr.slice("error: ".length, -1); + const json = await cli(["validate", file, "--json"]); + expect(json.code).toBe(1); + expect(json.stderr).toBe(result.stderr); + expect(JSON.parse(json.stdout)).toEqual({ result: "failed", file, problem }); + })); + + test("validate: an unreadable file exits 1 naming the path", () => + withTempDir("gsac-cli-", async (dir) => { + const result = await cli(["validate", join(dir, "missing.yml")]); + expect(result.code).toBe(1); + expect(result.stderr).toStartWith("error: cannot read settings from "); + })); - tempTest( - "permissions prints one grant per declared section, from the section declarations", - async (dir) => { + test("permissions prints one grant per declared section, from the section declarations", () => + withTempDir("gsac-cli-", async (dir) => { const file = join(dir, "settings.yml"); writeFileSync( file, @@ -409,8 +408,7 @@ describe("validate and permissions", () => { labels: sectionGrant(sectionModule("labels")), }, }); - }, - ); + })); }); describe("the --json failure envelope", () => { diff --git a/test/flows/execute.test.ts b/test/flows/execute.test.ts index 4561e524..2c0c30f4 100644 --- a/test/flows/execute.test.ts +++ b/test/flows/execute.test.ts @@ -18,7 +18,7 @@ import { } from "../../src/index.js"; import { MockApi } from "../mock-api.js"; import { ROOT } from "../root.js"; -import { tempDirTest } from "../temp-dir.js"; +import { withTempDir } from "../temp-dir.js"; const LAYERS = join(ROOT, "test", "fixtures", "layers"); @@ -41,8 +41,6 @@ const single = (overrides: Partial = {}): SingleConfig => ({ ...overrides, }); -const tempTest = tempDirTest("gsac-execute-"); - /** Deps over a collecting Io and one stub client; `createClient` records whether the run asked for it. */ function deps(api: MockApi, overrides: Partial = {}) { const collected = collectingIo(); @@ -59,36 +57,36 @@ function deps(api: MockApi, overrides: Partial = {}) { } describe("executeRun", () => { - tempTest("a merge opens no client and ends at the merge conclusion", async (dir) => { - const mergedFile = join(dir, "merged.yml"); - const api = new MockApi({}); - const d = deps(api); - const code = await executeRun( - { - kind: "merge", - settingsFiles: [join(LAYERS, "fleet.yml"), join(LAYERS, "team.yml")], - mergedFile, - layering: "merge", - }, - d.run, - ); - expect(code).toBe(0); - expect(d.opened()).toBe(0); - expect(api.calls).toEqual([]); - expect(d.collected.outputs).toEqual({ - result: "merged", - "skipped-sections": "", - "repos-result": "{}", - }); - expect(d.collected.lines.slice(-2)).toEqual([ - { line: `merged 2 layer(s) into ${mergedFile}` }, - { line: "result: merged" }, - ]); - }); + test("a merge opens no client and ends at the merge conclusion", () => + withTempDir("gsac-execute-", async (dir) => { + const mergedFile = join(dir, "merged.yml"); + const api = new MockApi({}); + const d = deps(api); + const code = await executeRun( + { + kind: "merge", + settingsFiles: [join(LAYERS, "fleet.yml"), join(LAYERS, "team.yml")], + mergedFile, + layering: "merge", + }, + d.run, + ); + expect(code).toBe(0); + expect(d.opened()).toBe(0); + expect(api.calls).toEqual([]); + expect(d.collected.outputs).toEqual({ + result: "merged", + "skipped-sections": "", + "repos-result": "{}", + }); + expect(d.collected.lines.slice(-2)).toEqual([ + { line: `merged 2 layer(s) into ${mergedFile}` }, + { line: "result: merged" }, + ]); + })); - tempTest( - "a fatal problem raised after the parse is worded by the face's describe", - async (dir) => { + test("a fatal problem raised after the parse is worded by the face's describe", () => + withTempDir("gsac-execute-", async (dir) => { const settingsFile = join(dir, "missing.yml"); const api = new MockApi({}); const d = deps(api, { describe: (problem) => `worded: ${problem.code}` }); @@ -104,8 +102,7 @@ describe("executeRun", () => { { level: "error", line: "worded: settings-file-unreadable" }, { line: "result: failed" }, ]); - }, - ); + })); test("the artifact channel is the uploader dep's: absent it fails before any API call, present the run reaches the API", async () => { const reportPublicKey = await identityToRecipient(await generateX25519Identity()); diff --git a/test/flows/layers.test.ts b/test/flows/layers.test.ts index 22590ef0..5418f873 100644 --- a/test/flows/layers.test.ts +++ b/test/flows/layers.test.ts @@ -1,14 +1,12 @@ -import { describe, expect, spyOn } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { err, ok } from "neverthrow"; import { readLayerFiles } from "../../src/flows/layers.js"; import * as settingsRead from "../../src/flows/settings-read.js"; -import { tempDirTest } from "../temp-dir.js"; +import { withTempDir } from "../temp-dir.js"; describe("readLayerFiles", () => { - const tempTest = tempDirTest("read-layers-"); - /** Two readable layers and one with broken YAML, written into `dir`. */ function writeLayers(dir: string): void { writeFileSync(join(dir, "fleet.yml"), "repository:\n has_wiki: false\n"); @@ -16,21 +14,21 @@ describe("readLayerFiles", () => { writeFileSync(join(dir, "broken.yml"), "labels: [oops, unclosed\n"); } - tempTest("reads every layer in path order, each named by its path", (dir) => { - writeLayers(dir); - const fleet = join(dir, "fleet.yml"); - const repo = join(dir, "repo.yml"); - expect(readLayerFiles([repo, fleet])).toEqual( - ok([ - { name: repo, doc: { repository: { has_issues: true } } }, - { name: fleet, doc: { repository: { has_wiki: false } } }, - ]), - ); - }); + test("reads every layer in path order, each named by its path", () => + withTempDir("read-layers-", (dir) => { + writeLayers(dir); + const fleet = join(dir, "fleet.yml"); + const repo = join(dir, "repo.yml"); + expect(readLayerFiles([repo, fleet])).toEqual( + ok([ + { name: repo, doc: { repository: { has_issues: true } } }, + { name: fleet, doc: { repository: { has_wiki: false } } }, + ]), + ); + })); - tempTest( - "the first unreadable layer is the problem, and the layers after it are never read", - (dir) => { + test("the first unreadable layer is the problem, and the layers after it are never read", () => + withTempDir("read-layers-", (dir) => { writeLayers(dir); const read = spyOn(settingsRead, "readSettingsFile"); try { @@ -48,6 +46,5 @@ describe("readLayerFiles", () => { } finally { read.mockRestore(); } - }, - ); + })); }); diff --git a/test/flows/merge.test.ts b/test/flows/merge.test.ts index c88c40d2..c6a91b5f 100644 --- a/test/flows/merge.test.ts +++ b/test/flows/merge.test.ts @@ -3,14 +3,12 @@ import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { err, ok } from "neverthrow"; import { collectingIo, concludeMerge, type MergeConfig, runMerge } from "../../src/index.js"; -import { tempDirTest, withTempDir } from "../temp-dir.js"; +import { withTempDir } from "../temp-dir.js"; const FLEET = "repository:\n has_wiki: false\n"; const REPO = "repository:\n has_issues: true\n"; describe("runMerge", () => { - const tempTest = tempDirTest("run-merge-"); - /** The two layers written into `dir`, and the config folding them into `mergedFile`. */ const cfg = (dir: string, mergedFile: string): MergeConfig => { writeFileSync(join(dir, "fleet.yml"), FLEET); @@ -22,22 +20,23 @@ describe("runMerge", () => { }; }; - tempTest("folds the layers into merged-file, and the finished merge concludes merged", (dir) => { - const collected = collectingIo(); - const mergedFile = join(dir, "out", "merged.yml"); - const config = cfg(dir, mergedFile); - const merged = runMerge(config, collected.io); - expect(merged).toEqual(ok({ layers: config.settingsFiles, mergedFile })); - expect(readFileSync(mergedFile, "utf8")).toBe( - "repository:\n has_wiki: false\n has_issues: true\n", - ); - expect(concludeMerge(collected.io, merged._unsafeUnwrap())).toBe(0); - expect(collected.outputs).toEqual({ - result: "merged", - "skipped-sections": "", - "repos-result": "{}", - }); - }); + test("folds the layers into merged-file, and the finished merge concludes merged", () => + withTempDir("run-merge-", (dir) => { + const collected = collectingIo(); + const mergedFile = join(dir, "out", "merged.yml"); + const config = cfg(dir, mergedFile); + const merged = runMerge(config, collected.io); + expect(merged).toEqual(ok({ layers: config.settingsFiles, mergedFile })); + expect(readFileSync(mergedFile, "utf8")).toBe( + "repository:\n has_wiki: false\n has_issues: true\n", + ); + expect(concludeMerge(collected.io, merged._unsafeUnwrap())).toBe(0); + expect(collected.outputs).toEqual({ + result: "merged", + "skipped-sections": "", + "repos-result": "{}", + }); + })); test.each<[string, (dir: string) => string, number, string]>([ ["a layer's own path", (d) => join(d, "repo.yml"), 1, "repo.yml"], diff --git a/test/io/capture.ts b/test/io/capture.ts index 1868df07..42ea093f 100644 --- a/test/io/capture.ts +++ b/test/io/capture.ts @@ -1,7 +1,7 @@ /** - * The one Io capture fake for tests: every channel lands in its own list and in one ordered event log, so a - * test pins a single surface or the sequence across surfaces. Nothing is redacted (collectingIo does that); - * the debug trace is dropped, as a runner without step debugging drops it. + * The one Io capture fake for tests: every channel lands in its own list and in one ordered event log + * (`annotate : `, `log: `, `summary: `, `output =`, + * `mask: `). Nothing is redacted (collectingIo does that); the debug trace is dropped. */ import { type Io, maskRegistry, type OutputName } from "../../src/io.js"; @@ -15,21 +15,20 @@ export interface CapturedIo { outputs: Partial>; /** Every value registered through mask, in call order; io.masked() is the live set. */ masks: string[]; - /** - * Every channel in call order: `annotate : `, `log: `, `summary: `, - * `output =`, `mask: `. - */ events: string[]; } /** `onMask` runs at each mask call, for a test pinning what else has happened by the time a value is masked. */ export function captureIo(onMask: (value: string) => void = () => {}): CapturedIo { - const annotations: string[] = []; - const logs: string[] = []; - const summaries: string[] = []; - const outputs: Partial> = {}; - const masks: string[] = []; - const events: string[] = []; + const captured: Omit = { + annotations: [], + logs: [], + summaries: [], + outputs: {}, + masks: [], + events: [], + }; + const { annotations, logs, summaries, outputs, masks, events } = captured; return { io: { annotate: (level, message) => { @@ -43,6 +42,7 @@ export function captureIo(onMask: (value: string) => void = () => {}): CapturedI debug: () => {}, summary: (markdown) => { summaries.push(markdown); + // Coerced first: a test forcing an opaque Private box through the port must not crash the fake. events.push(`summary: ${`${markdown}`.split("\n")[0]}`); }, output: (name, value) => { @@ -55,11 +55,6 @@ export function captureIo(onMask: (value: string) => void = () => {}): CapturedI onMask(value); }), }, - annotations, - logs, - summaries, - outputs, - masks, - events, + ...captured, }; } diff --git a/test/scripts/changed-sections.test.ts b/test/scripts/changed-sections.test.ts index 761cf81f..cfcd0444 100644 --- a/test/scripts/changed-sections.test.ts +++ b/test/scripts/changed-sections.test.ts @@ -13,7 +13,7 @@ import { } from "../../.github/scripts/changed-sections.js"; import { SECTION_KEYS, type SectionKey, UNDECLARED_POLICY_SECTIONS } from "../../src/schema.js"; import { ROOT } from "../root.js"; -import { tempDirTest } from "../temp-dir.js"; +import { withTempDir } from "../temp-dir.js"; const SRC_DIR = join(ROOT, "src"); const SECTIONS_DIR = join(SRC_DIR, "sections"); @@ -46,8 +46,6 @@ function inKeyOrder(...keys: SectionKey[]): SectionKey[] { return SECTION_KEYS.filter((key) => wanted.has(key)); } -const tempTest = tempDirTest("changed-sections-"); - /** `root` filled as a repo holding `files` (repo-relative path -> text). */ function syntheticRepo(root: string, files: Record): string { mkdirSync(join(root, "src", "sections", "shared"), { recursive: true }); @@ -201,9 +199,8 @@ describe("changed-sections derived fan-out", () => { ).toEqual(["./cli/program.js"]); }); - tempTest( - "resolveImport maps .js to .ts, a directory to its index, and .json to itself, and throws on a dangling one", - (dir) => { + test("resolveImport maps .js to .ts, a directory to its index, and .json to itself, and throws on a dangling one", () => + withTempDir("changed-sections-", (dir) => { const root = syntheticRepo(dir, { "src/sections/shared/engine.ts": "", "src/sections/shared/util/index.ts": "", @@ -229,12 +226,10 @@ describe("changed-sections derived fan-out", () => { expect(() => resolveImport(importer, "../../../lib/missing.json")).toThrow( /imports "\.\.\/\.\.\/\.\.\/lib\/missing\.json", which resolves to no file/, ); - }, - ); + })); - tempTest( - "the fan-out follows the graph through intermediates and ignores non-section importers", - (dir) => { + test("the fan-out follows the graph through intermediates and ignores non-section importers", () => + withTempDir("changed-sections-", (dir) => { const fanOut = deriveSharedFanOut(syntheticRepo(dir, GRAPH_FIXTURE)); expect(fanOut).toEqual({ // src/schema.ts imports engine directly and registry.ts reaches it through teams; neither adds a key, and pages' type-only import is no edge. @@ -243,48 +238,49 @@ describe("changed-sections derived fan-out", () => { // labels' unit test imports util too and is not an edge. "util/index.ts": inKeyOrder("pages", "milestones"), }); - }, - ); - - tempTest("a new import edge widens exactly the shared file it reaches", (dir) => { - const fanOut = deriveSharedFanOut( - syntheticRepo(dir, { - ...GRAPH_FIXTURE, - "src/sections/webhooks/index.ts": - 'import { engine } from "../shared/engine.js";\nexport default engine;\n', - }), - ); - expect(fanOut).toEqual({ - "engine.ts": inKeyOrder("labels", "teams", "webhooks"), - "factory.ts": inKeyOrder("labels"), - "util/index.ts": inKeyOrder("pages", "milestones"), - }); - }); + })); - tempTest("a shared file no section imports throws", (dir) => { - const root = syntheticRepo(dir, { - "src/sections/shared/live.ts": "export const live = 1;\n", - "src/sections/shared/dead.ts": "export const dead = 1;\n", - "src/sections/labels/index.ts": - 'import { live } from "../shared/live.js";\nexport default live;\n', - }); - expect(() => deriveSharedFanOut(root)).toThrow( - /no section imports src\/sections\/shared\/dead\.ts/, - ); - }); + test("a new import edge widens exactly the shared file it reaches", () => + withTempDir("changed-sections-", (dir) => { + const fanOut = deriveSharedFanOut( + syntheticRepo(dir, { + ...GRAPH_FIXTURE, + "src/sections/webhooks/index.ts": + 'import { engine } from "../shared/engine.js";\nexport default engine;\n', + }), + ); + expect(fanOut).toEqual({ + "engine.ts": inKeyOrder("labels", "teams", "webhooks"), + "factory.ts": inKeyOrder("labels"), + "util/index.ts": inKeyOrder("pages", "milestones"), + }); + })); - tempTest("a dangling relative import anywhere under src throws", (dir) => { - const root = syntheticRepo(dir, { - "src/sections/shared/engine.ts": "export const engine = 1;\n", - "src/sections/labels/index.ts": - 'import { gone } from "../shared/gone.js";\nexport default gone;\n', - }); - expect(() => deriveSharedFanOut(root)).toThrow(/resolves to no file/); - }); + test("a shared file no section imports throws", () => + withTempDir("changed-sections-", (dir) => { + const root = syntheticRepo(dir, { + "src/sections/shared/live.ts": "export const live = 1;\n", + "src/sections/shared/dead.ts": "export const dead = 1;\n", + "src/sections/labels/index.ts": + 'import { live } from "../shared/live.js";\nexport default live;\n', + }); + expect(() => deriveSharedFanOut(root)).toThrow( + /no section imports src\/sections\/shared\/dead\.ts/, + ); + })); + + test("a dangling relative import anywhere under src throws", () => + withTempDir("changed-sections-", (dir) => { + const root = syntheticRepo(dir, { + "src/sections/shared/engine.ts": "export const engine = 1;\n", + "src/sections/labels/index.ts": + 'import { gone } from "../shared/gone.js";\nexport default gone;\n', + }); + expect(() => deriveSharedFanOut(root)).toThrow(/resolves to no file/); + })); - tempTest( - "a computed import anywhere under src fails the whole derivation, naming the file", - (dir) => { + test("a computed import anywhere under src fails the whole derivation, naming the file", () => + withTempDir("changed-sections-", (dir) => { // The template edge in GRAPH_FIXTURE (pages/mock.ts) proves a substitution-free template passes. for (const load of ["await import(which)", "require(which)", `await import(\`\${which}\`)`]) { const root = syntheticRepo(dir, { @@ -295,8 +291,7 @@ describe("changed-sections derived fan-out", () => { /src\/sections\/webhooks\/index\.ts:2 loads a module through a computed specifier/, ); } - }, - ); + })); }); describe("changed-sections file map", () => { @@ -383,9 +378,8 @@ describe("changed-sections selection", () => { ); }); - tempTest( - "a deleted shared file whose importers now resolve to its sibling spelling selects that sibling's sections", - (dir) => { + test("a deleted shared file whose importers now resolve to its sibling spelling selects that sibling's sections", () => + withTempDir("changed-sections-", (dir) => { // foo.ts and foo/index.ts are interchangeable to an importer of "./foo.js", so deleting one leaves the importers unchanged and typecheck green. const fanOut = deriveSharedFanOut( syntheticRepo(dir, { @@ -406,8 +400,7 @@ describe("changed-sections selection", () => { expect(() => select(removed("src/sections/shared/notes.md"))).toThrow( /matches no selector rule/, ); - }, - ); + })); test("parseNameStatus reads NUL-delimited records raw and throws on any other shape", () => { // -z keeps a path with a tab, a quote, and a backslash verbatim; git would C-quote it otherwise and the src/sections/ prefix would go unmatched. diff --git a/test/scripts/check-compat-markers.test.ts b/test/scripts/check-compat-markers.test.ts index 0496c01b..5fd0031c 100644 --- a/test/scripts/check-compat-markers.test.ts +++ b/test/scripts/check-compat-markers.test.ts @@ -4,12 +4,10 @@ import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { checkCompatMarkers } from "../../.github/scripts/check-compat-markers.js"; import { ROOT } from "../root.js"; -import { tempDirTest, withTempDir } from "../temp-dir.js"; +import { withTempDir } from "../temp-dir.js"; const SCRIPT = join(ROOT, ".github", "scripts", "check-compat-markers.ts"); -const tempTest = tempDirTest("compat-markers-"); - /** `root` filled as a git repository holding `files`, its package.json at `version`. Its own .gitignore is the only * excludes source: the machine's global excludes file would otherwise hide fixture files. */ function repo(root: string, version: string, files: Record): string { @@ -34,9 +32,8 @@ function due(where: string, major: number, dueLine: string): string { } describe("checkCompatMarkers", () => { - tempTest( - "lists well-formed markers by removal major across file types and passes under the due line", - (dir) => { + test("lists well-formed markers by removal major across file types and passes under the due line", () => + withTempDir("compat-markers-", (dir) => { const cwd = repo(dir, "2.0.0", { "src/inputs.ts": 'const dir = input("repos_dir"); // COMPAT(v3): accept the pre-2.1 "repos_dir" input name; delete this branch and the alias\n', @@ -72,12 +69,10 @@ describe("checkCompatMarkers", () => { ].join("\n"), stderr: "", }); - }, - ); + })); - tempTest( - "names every malformed occurrence by file and line, and still lists the well-formed ones", - (dir) => { + test("names every malformed occurrence by file and line, and still lists the well-formed ones", () => + withTempDir("compat-markers-", (dir) => { const cwd = repo(dir, "2.0.0", { "src/m.ts": [ "// COMPAT(v3) accept the old key", @@ -126,8 +121,7 @@ describe("checkCompatMarkers", () => { "", ].join("\n"), }); - }, - ); + })); // The due line is package.json's major, or the major a release PR cuts; a marker at or below it fails. test.each<[string, string, number, number | undefined, string | undefined]>([ @@ -168,9 +162,8 @@ describe("checkCompatMarkers", () => { }), ); - tempTest( - "skips built output, dependencies, the fetched spec, the changelog, ignored files, symlinks, deleted tracked files, and its own two files", - (dir) => { + test("skips built output, dependencies, the fetched spec, the changelog, ignored files, symlinks, deleted tracked files, and its own two files", () => + withTempDir("compat-markers-", (dir) => { const marker = "// COMPAT(v3): kept; delete it\n"; const cwd = repo(dir, "2.0.0", { ".gitignore": "scratch/\n", @@ -203,17 +196,17 @@ describe("checkCompatMarkers", () => { ].join("\n"), stderr: "", }); - }, - ); + })); - tempTest("a tree without markers says so", (dir) => { - const cwd = repo(dir, "2.0.0", { "src/x.ts": "export {};\n" }); - expect(checkCompatMarkers({ cwd })).toEqual({ - code: 0, - stdout: "no COMPAT markers (package.json 2.0.0)\n", - stderr: "", - }); - }); + test("a tree without markers says so", () => + withTempDir("compat-markers-", (dir) => { + const cwd = repo(dir, "2.0.0", { "src/x.ts": "export {};\n" }); + expect(checkCompatMarkers({ cwd })).toEqual({ + code: 0, + stdout: "no COMPAT markers (package.json 2.0.0)\n", + stderr: "", + }); + })); test.each<[string, string, number | undefined, string]>([ [ @@ -235,17 +228,15 @@ describe("checkCompatMarkers", () => { }), ); - tempTest( - "a listed path that cannot be inspected for any reason but absence stops the scan", - (dir) => { + test("a listed path that cannot be inspected for any reason but absence stops the scan", () => + withTempDir("compat-markers-", (dir) => { const cwd = repo(dir, "2.0.0", { "dir/file.ts": "export {};\n" }); execFileSync("git", ["add", "dir/file.ts"], { cwd }); // The tracked path's parent is now a regular file: lstat fails with ENOTDIR, not ENOENT. rmSync(join(cwd, "dir"), { recursive: true }); writeFileSync(join(cwd, "dir"), ""); expect(() => checkCompatMarkers({ cwd })).toThrow(/ENOTDIR/); - }, - ); + })); }); describe("the CLI", () => { diff --git a/test/scripts/generated.test.ts b/test/scripts/generated.test.ts index 5b809c31..55c05464 100644 --- a/test/scripts/generated.test.ts +++ b/test/scripts/generated.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { cpSync, existsSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { cpSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { GENERATED_OUTPUTS, generatedPaths } from "../../.github/scripts/generated.js"; import { @@ -12,25 +12,31 @@ import { withTempDir } from "../temp-dir.js"; /** The file types generated-regions.ts has a marker syntax for; a marker string anywhere else is test or script text. */ const REGION_FILE = /\.(?:md|ya?ml)$/; +/** The outputs the marker scan cannot see: whole generated files. */ +const WHOLE_FILES = ["lib/settings.schema.json", "src/upstream-gaps/index.ts"]; const tracked = execFileSync("git", ["ls-files", "-z"], { cwd: ROOT, encoding: "utf8" }) .split("\0") .filter((path) => path !== ""); describe("the generated-output table", () => { - const carrying = tracked.filter( - (path) => - REGION_FILE.test(path) && - hasGeneratedRegion(readFileSync(join(ROOT, path), "utf8"), markerSyntaxFor(path)), - ); - /** The outputs the marker scan cannot see: whole generated files. */ - const WHOLE_FILES = ["lib/settings.schema.json", "src/upstream-gaps/index.ts"]; - - test("holds exactly the tracked files that carry a generated-region marker", () => { - const registered = GENERATED_OUTPUTS.filter((output) => output.kind === "regions").map( - (output) => output.path, + test("is exactly the tree's generated files: every tracked marker-carrying page plus the whole files, once each", () => { + const carrying = tracked.filter( + (path) => + REGION_FILE.test(path) && + hasGeneratedRegion(readFileSync(join(ROOT, path), "utf8"), markerSyntaxFor(path)), ); - expect([...new Set(registered)].sort()).toEqual(carrying.sort()); + const paths = generatedPaths(); + expect([...paths].sort()).toEqual([...carrying, ...WHOLE_FILES].sort()); + expect(new Set(paths).size).toBe(paths.length); + expect( + GENERATED_OUTPUTS.filter((output) => output.kind === "file") + .map((output) => output.path) + .sort(), + ).toEqual(WHOLE_FILES); + for (const generator of new Set(GENERATED_OUTPUTS.map((output) => output.generator))) { + expect(tracked, generator).toContain(generator); + } }); test("the scan reads the shared marker grammar: an inline HTML marker counts, a marker-shaped scalar does not", () => { @@ -41,9 +47,6 @@ describe("the generated-output table", () => { "html", ), ).toBe(true); - expect( - hasGeneratedRegion("\n\n", "html"), - ).toBe(true); expect(hasGeneratedRegion("the words BEGIN GENERATED: a outside a comment\n", "html")).toBe( false, ); @@ -52,56 +55,20 @@ describe("the generated-output table", () => { ).toBe(true); expect(hasGeneratedRegion('d: "one\n # BEGIN GENERATED: a\n two"\n', "yaml")).toBe(false); }); - - test("names the whole-file outputs the marker scan cannot see, each tracked with one writer", () => { - const files = GENERATED_OUTPUTS.filter((output) => output.kind === "file"); - expect(files.map((output) => output.path).sort()).toEqual(WHOLE_FILES); - for (const { path, generator } of files) { - expect(tracked, path).toContain(path); - expect( - GENERATED_OUTPUTS.filter((output) => output.path === path), - path, - ).toEqual([{ path, generator, kind: "file" }]); - } - }); - - test("every generator is a tracked script, and build:check runs this table", () => { - for (const generator of new Set(GENERATED_OUTPUTS.map((output) => output.generator))) { - expect(tracked, generator).toContain(generator); - expect(existsSync(join(ROOT, generator)), generator).toBe(true); - } - const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { - scripts: Record; - }; - expect(pkg.scripts["build:check"]).toBe("bun .github/scripts/generated.ts"); - }); - - test("generatedPaths() is exactly the tree's generated files, once each, the schema first", () => { - const paths = generatedPaths(); - expect([...paths].sort()).toEqual([...carrying, ...WHOLE_FILES].sort()); - expect(new Set(paths).size).toBe(paths.length); - expect(paths[0]).toBe("lib/settings.schema.json"); - // The one page two generators write into is a single path to diff. - expect( - GENERATED_OUTPUTS.filter((output) => output.path === "docs/reference/inputs.md"), - ).toHaveLength(2); - }); }); describe("the build:check runner", () => { - const RUNNER = ".github/scripts/generated.ts"; - function git(cwd: string, ...args: string[]): string { return execFileSync("git", args, { cwd, encoding: "utf8" }); } - function runner(cwd: string): { status: number; stdout: string; stderr: string } { - const run = Bun.spawnSync([process.execPath, RUNNER], { cwd, stdout: "pipe", stderr: "pipe" }); - return { - status: run.exitCode, - stdout: run.stdout.toString(), - stderr: run.stderr.toString(), - }; + function runner(cwd: string): { status: number; stderr: string } { + const run = Bun.spawnSync([process.execPath, ".github/scripts/generated.ts"], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + return { status: run.exitCode, stderr: run.stderr.toString() }; } test( @@ -120,9 +87,6 @@ describe("the build:check runner", () => { const clean = runner(dir); expect(clean.stderr).toBe(""); expect(clean.status).toBe(0); - expect(clean.stdout).toContain( - `build:check: ${generatedPaths().length} generated files match their generators`, - ); expect(git(dir, "status", "--porcelain", "--", ...untouched)).toBe(""); // A stale cell the generator repairs (the row shape holds), and a stale byte in a wholesale file. @@ -136,7 +100,6 @@ describe("the build:check runner", () => { const stale = runner(dir); expect(stale.status).toBe(1); - expect(stale.stderr).toContain("build:check: generated output drifted"); expect(stale.stderr).toContain(" modified: docs/reference/sections.md"); expect(stale.stderr).toContain(" modified: src/upstream-gaps/index.ts"); // The generators repaired the working tree; only the staged stale copies differ. diff --git a/test/temp-dir.ts b/test/temp-dir.ts index 6261bcd6..d5488fd1 100644 --- a/test/temp-dir.ts +++ b/test/temp-dir.ts @@ -1,9 +1,8 @@ /** - * The one temp-dir style for tests: a directory whose lifetime is the body it is handed to, created under - * os.tmpdir() and removed on every exit path, failure included. No afterEach or afterAll hook cleans up. + * A temp directory whose lifetime is the body it is handed to: created under os.tmpdir(), removed on every exit + * path, failure included. `test("...", () => withTempDir("prefix-", async (dir) => {...}))` is the one shape. */ -import { test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -19,12 +18,3 @@ export async function withTempDir( rmSync(dir, { recursive: true, force: true }); } } - -/** `test()` whose body is handed a fresh directory under `prefix`; a `test.each` case calls withTempDir itself. */ -export function tempDirTest( - prefix: string, -): (name: string, body: (dir: string) => void | Promise) => void { - return (name, body) => { - test(name, () => withTempDir(prefix, body)); - }; -} From 29c96a0fe5f43efc0379e649472ac6b1cf1ae696 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Sun, 13 Sep 2026 05:33:10 -0400 Subject: [PATCH 13/13] chore(build): let the drift check's diff speak, drop the unread summaries capture Before: the runner re-listed drifted paths in its own modified:/untracked: format; captureIo kept a summaries list no test read. After: git diff --exit-code --stat prints the drifted paths, ls-files prints an untracked one, and one error line follows; a failing ls-files fails the check. captureIo's summary channel lands in events only. --- .github/scripts/generated.ts | 43 +++++++++++++++------------------- test/io/capture.ts | 5 +--- test/scripts/generated.test.ts | 12 ++++++---- 3 files changed, 27 insertions(+), 33 deletions(-) diff --git a/.github/scripts/generated.ts b/.github/scripts/generated.ts index 846fa3e3..cfb6715e 100644 --- a/.github/scripts/generated.ts +++ b/.github/scripts/generated.ts @@ -40,39 +40,34 @@ export function generatedPaths(): string[] { return [...new Set(GENERATED_OUTPUTS.map((output) => output.path))]; } -function git(args: readonly string[]): string[] { - const run = Bun.spawnSync(["git", ...args], { cwd: ROOT, stdout: "pipe", stderr: "inherit" }); - if (run.exitCode !== 0) { - throw new Error(`build:check: git ${args[0]} exited ${run.exitCode}`); - } - return run.stdout - .toString() - .split("\n") - .filter((line) => line !== ""); +/** Runs `argv` at the repository root on the terminal's stdio; the exit code is the verdict. */ +function run(argv: string[]): number { + return Bun.spawnSync(argv, { cwd: ROOT, stdout: "inherit", stderr: "inherit" }).exitCode; } if (import.meta.main) { for (const generator of new Set(GENERATED_OUTPUTS.map((output) => output.generator))) { - const run = Bun.spawnSync([process.execPath, generator], { - cwd: ROOT, - stdout: "inherit", - stderr: "inherit", - }); - if (run.exitCode !== 0) { - console.error(`build:check: ${generator} exited ${run.exitCode}`); + if (run([process.execPath, generator]) !== 0) { + console.error(`build:check: ${generator} failed`); process.exit(1); } } const paths = generatedPaths(); - // Working tree against the index: a regenerated file already staged passes, a stale one fails. - const drifted = git(["diff", "--name-only", "--", ...paths]); - const untracked = git(["ls-files", "--others", "--exclude-standard", "--", ...paths]); - if (drifted.length > 0 || untracked.length > 0) { + // Working tree against the index: a regenerated file already staged passes, a stale one fails. git lists the + // drifted paths itself; an untracked registered path is listed the same way. + const untracked = Bun.spawnSync( + ["git", "ls-files", "--others", "--exclude-standard", "--", ...paths], + { cwd: ROOT, stderr: "inherit" }, + ); + if (untracked.exitCode !== 0) { + console.error("build:check: git ls-files failed"); + process.exit(1); + } + const drifted = run(["git", "diff", "--exit-code", "--stat", "--", ...paths]) !== 0; + if (drifted || untracked.stdout.length > 0) { + process.stdout.write(untracked.stdout); console.error( - `build:check: generated output drifted from the committed tree; commit the regenerated files:\n${[ - ...drifted.map((path) => ` modified: ${path}`), - ...untracked.map((path) => ` untracked: ${path}`), - ].join("\n")}`, + "build:check: generated output drifted from the committed tree; commit the files listed above", ); process.exit(1); } diff --git a/test/io/capture.ts b/test/io/capture.ts index 42ea093f..00b3f6c2 100644 --- a/test/io/capture.ts +++ b/test/io/capture.ts @@ -11,7 +11,6 @@ export interface CapturedIo { /** `: ` per annotate call. */ annotations: string[]; logs: string[]; - summaries: string[]; outputs: Partial>; /** Every value registered through mask, in call order; io.masked() is the live set. */ masks: string[]; @@ -23,12 +22,11 @@ export function captureIo(onMask: (value: string) => void = () => {}): CapturedI const captured: Omit = { annotations: [], logs: [], - summaries: [], outputs: {}, masks: [], events: [], }; - const { annotations, logs, summaries, outputs, masks, events } = captured; + const { annotations, logs, outputs, masks, events } = captured; return { io: { annotate: (level, message) => { @@ -41,7 +39,6 @@ export function captureIo(onMask: (value: string) => void = () => {}): CapturedI }, debug: () => {}, summary: (markdown) => { - summaries.push(markdown); // Coerced first: a test forcing an opaque Private box through the port must not crash the fake. events.push(`summary: ${`${markdown}`.split("\n")[0]}`); }, diff --git a/test/scripts/generated.test.ts b/test/scripts/generated.test.ts index 55c05464..25fc3745 100644 --- a/test/scripts/generated.test.ts +++ b/test/scripts/generated.test.ts @@ -62,13 +62,14 @@ describe("the build:check runner", () => { return execFileSync("git", args, { cwd, encoding: "utf8" }); } - function runner(cwd: string): { status: number; stderr: string } { + /** The runner's exit code and everything it printed on either stream. */ + function runner(cwd: string): { status: number; printed: string } { const run = Bun.spawnSync([process.execPath, ".github/scripts/generated.ts"], { cwd, stdout: "pipe", stderr: "pipe", }); - return { status: run.exitCode, stderr: run.stderr.toString() }; + return { status: run.exitCode, printed: run.stdout.toString() + run.stderr.toString() }; } test( @@ -85,8 +86,8 @@ describe("the build:check runner", () => { const untouched = [".", ":(exclude)node_modules", ":(exclude).github/scripts"]; const clean = runner(dir); - expect(clean.stderr).toBe(""); expect(clean.status).toBe(0); + expect(clean.printed).not.toContain("drifted"); expect(git(dir, "status", "--porcelain", "--", ...untouched)).toBe(""); // A stale cell the generator repairs (the row shape holds), and a stale byte in a wholesale file. @@ -100,8 +101,9 @@ describe("the build:check runner", () => { const stale = runner(dir); expect(stale.status).toBe(1); - expect(stale.stderr).toContain(" modified: docs/reference/sections.md"); - expect(stale.stderr).toContain(" modified: src/upstream-gaps/index.ts"); + // git's own --stat lists the two staged stale paths. + expect(stale.printed).toContain("docs/reference/sections.md"); + expect(stale.printed).toContain("src/upstream-gaps/index.ts"); // The generators repaired the working tree; only the staged stale copies differ. expect( git(dir, "diff", "--name-only", "--", ...untouched)