Public: anyone can read this; the crew can change it.
A test fails when a new module under src/ is imported by nothing but tests #7690
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6500-a-test-fails-when-a-new-module-under-src → main 11 commits fork
4 files
changed +152
−0
README.md +9
| @@ -369,6 +369,15 @@ A fork chooses how it follows its parent: **tell me** (the default: a fork with | ||
| 369 | 369 | |
| 370 | 370 | Tests, checks, the gate, lint, types and how a change reaches main: **[docs/DEVELOP.md](docs/DEVELOP.md)**. |
| 371 | 371 | |
| 372 | The gate also refuses unreached modules. `test/unreached.test.ts` runs | |
| 373 | `scripts/unreached.ts`, which lists each `src/**/*.ts` no entry point reaches | |
| 374 | by following imports (the entries, including those started by path, are | |
| 375 | declared at the top of that script; anything under `scripts/` counts as a | |
| 376 | caller too). A new one fails the test unless | |
| 377 | `scripts/unreached-allow.json` lists it; wiring a listed module in means | |
| 378 | deleting its line, which the test also demands. `bun scripts/unreached.ts` | |
| 379 | prints the list. | |
| 380 | ||
| 372 | 381 | ## Shape |
| 373 | 382 | |
| 374 | 383 |
scripts/unreached-allow.json added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | [ | |
| 2 | "src/apps/cloud.ts", | |
| 3 | "src/boards/machine.ts", | |
| 4 | "src/crew/governor_log.ts", | |
| 5 | "src/crew/tries.ts", | |
| 6 | "src/flow/alarm_drift.ts", | |
| 7 | "src/flow/alarm_unproposed.ts", | |
| 8 | "src/flow/approved_wait.ts", | |
| 9 | "src/flow/clog_words.ts", | |
| 10 | "src/flow/conflict_pairs.ts", | |
| 11 | "src/flow/declines.ts", | |
| 12 | "src/flow/handovers.ts", | |
| 13 | "src/flow/landing_eta.ts", | |
| 14 | "src/flow/rebase_first.ts", | |
| 15 | "src/flow/review_rounds.ts", | |
| 16 | "src/flow/shared_red.ts", | |
| 17 | "src/flow/stale_fold.ts", | |
| 18 | "src/tui/gallery.ts", | |
| 19 | "src/tui/ui/qr.ts", | |
| 20 | "src/web/highlight-client.ts", | |
| 21 | "src/web/studio/held_posts.ts" | |
| 22 | ] |
scripts/unreached.ts added +65
| @@ -0,0 +1,65 @@ | ||
| 1 | /** | |
| 2 | * Unreached modules (#6500): each `src/**\/*.ts` that no entry point reaches by | |
| 3 | * following imports. A module only its tests import is done by no one's reckoning | |
| 4 | * (docs/VISION.md: done means reachable). `scripts/unreached-allow.json` is | |
| 5 | * what main carries today; `test/unreached.test.ts` keeps it shrinking. | |
| 6 | * | |
| 7 | * An import is a static `from "./x"`, a side-effect `import "./x"`, or an | |
| 8 | * `import("./x")` (quotes or backticks, any whitespace), resolved against the importing file's directory. | |
| 9 | * Imports from `scripts/` count as reaching what they name. | |
| 10 | */ | |
| 11 | ||
| 12 | import { readFileSync } from "node:fs"; | |
| 13 | import { dirname, join, normalize } from "node:path"; | |
| 14 | ||
| 15 | /** Files something starts, by name or by path, so nothing imports them. */ | |
| 16 | export const ENTRIES = [ | |
| 17 | "src/main.ts", | |
| 18 | "src/cli/main.ts", | |
| 19 | "src/tui/main.ts", | |
| 20 | "src/chaos/worker.ts", | |
| 21 | "src/chaos/run.ts", | |
| 22 | "src/release/boot.ts", | |
| 23 | "src/git/hook-entry.ts", | |
| 24 | ]; | |
| 25 | ||
| 26 | /** Scripts the check and operators run are callers too: their imports count. */ | |
| 27 | const SCRIPTS = "scripts/**/*.ts"; | |
| 28 | ||
| 29 | /** Not modules: tests, and migrations, which the runner finds by listing the directory. */ | |
| 30 | const NOT_MODULES = /(?:\.test\.ts$|^src\/core\/migrations\/)/; | |
| 31 | ||
| 32 | const SPEC = /(?:\bfrom\s*|\bimport\s*\(?\s*)["'`](\.{1,2}\/[^"'`]*)["'`]/g; | |
| 33 | ||
| 34 | /** The src/ file a relative specifier names, among `files`, or undefined. */ | |
| 35 | function resolve(from: string, spec: string, files: Set<string>): string | undefined { | |
| 36 | const base = normalize(join(dirname(from), spec)).replace(/\.(js|ts)$/, ""); | |
| 37 | return [`${base}.ts`, `${base}/index.ts`].find((c) => files.has(c)); | |
| 38 | } | |
| 39 | ||
| 40 | /** The `src/**\/*.ts` files no entry point reaches through imports, sorted. */ | |
| 41 | export function unreached(root: string): string[] { | |
| 42 | const files = new Set( | |
| 43 | [...new Bun.Glob("src/**/*.ts").scanSync({ cwd: root })].filter((f) => !f.endsWith(".d.ts") && !NOT_MODULES.test(f)), | |
| 44 | ); | |
| 45 | const reached = new Set<string>(ENTRIES.filter((e) => files.has(e))); | |
| 46 | const queue = [...reached, ...new Bun.Glob(SCRIPTS).scanSync({ cwd: root })]; | |
| 47 | for (let file = queue.pop(); file; file = queue.pop()) { | |
| 48 | const text = readFileSync(join(root, file), "utf8"); | |
| 49 | for (const m of text.matchAll(SPEC)) { | |
| 50 | const target = resolve(file, m[1]!, files); | |
| 51 | if (target && !reached.has(target)) { | |
| 52 | reached.add(target); | |
| 53 | queue.push(target); | |
| 54 | } | |
| 55 | } | |
| 56 | } | |
| 57 | return [...files].filter((f) => !reached.has(f)).sort(); | |
| 58 | } | |
| 59 | ||
| 60 | if (import.meta.main) { | |
| 61 | const root = new URL("..", import.meta.url).pathname; | |
| 62 | const list = unreached(root); | |
| 63 | if (process.argv.includes("--json")) console.log(JSON.stringify(list, null, 2)); | |
| 64 | else for (const f of list) console.log(f); | |
| 65 | } |
test/unreached.test.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | /** | |
| 2 | * A module under src/ that only tests import is not reachable (#6500): it | |
| 3 | * fails here unless `scripts/unreached-allow.json` lists it, and a listed | |
| 4 | * file that has become reached fails too, so the list only shrinks. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { dirname, join } from "node:path"; | |
| 11 | import { ENTRIES, unreached } from "../scripts/unreached.ts"; | |
| 12 | import allowed from "../scripts/unreached-allow.json"; | |
| 13 | ||
| 14 | const root = new URL("..", import.meta.url).pathname; | |
| 15 | const found = unreached(root); | |
| 16 | ||
| 17 | test("every module under src/ is imported by another, or allowed", () => { | |
| 18 | const fresh = found.filter((f) => !allowed.includes(f)); | |
| 19 | expect(fresh, `imported by nothing under src/: ${fresh.join(", ")}; call it from the app or it is not done`).toEqual([]); | |
| 20 | }); | |
| 21 | ||
| 22 | test("an allowed module that has become reached leaves the list", () => { | |
| 23 | const stale = allowed.filter((f) => !found.includes(f)); | |
| 24 | expect(stale, `now reached, remove from scripts/unreached-allow.json: ${stale.join(", ")}`).toEqual([]); | |
| 25 | }); | |
| 26 | ||
| 27 | test("every declared entry point exists", () => { | |
| 28 | const missing = ENTRIES.filter((e) => !existsSync(join(root, e))); | |
| 29 | expect(missing, `entry points that no longer exist, rename them in scripts/unreached.ts: ${missing.join(", ")}`).toEqual([]); | |
| 30 | }); | |
| 31 | ||
| 32 | test("unreached() follows imports from the entries and scripts, and nothing else", () => { | |
| 33 | const dir = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "unreached-")); | |
| 34 | const put = (path: string, text = "export {};\n") => { | |
| 35 | mkdirSync(dirname(join(dir, path)), { recursive: true }); | |
| 36 | writeFileSync(join(dir, path), text); | |
| 37 | }; | |
| 38 | try { | |
| 39 | put("src/main.ts", 'import { a } from "./a.js";\nimport "./side.ts";\n'); | |
| 40 | put("src/a.ts", 'export const load = () => import(\n "./b"\n);\n'); | |
| 41 | put("src/b/index.ts", "export const t = () => import(`./tpl`);\n"); | |
| 42 | put("src/b/tpl.ts"); | |
| 43 | put("src/side.ts"); | |
| 44 | put("src/c.ts"); | |
| 45 | put("src/d.ts", 'import "./c.ts";\n'); | |
| 46 | put("src/e.ts"); | |
| 47 | put("src/a.test.ts", 'import "./e.ts";\n'); | |
| 48 | put("src/types.d.ts"); | |
| 49 | put("src/core/migrations/001.ts"); | |
| 50 | put("src/f.ts"); | |
| 51 | put("scripts/tool.ts", 'const { f } = await import("../src/f.ts");\n'); | |
| 52 | expect(unreached(dir)).toEqual(["src/c.ts", "src/d.ts", "src/e.ts"]); | |
| 53 | } finally { | |
| 54 | rmSync(dir, { recursive: true, force: true }); | |
| 55 | } | |
| 56 | }); |
new above
offline