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

Merged opened this

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

Merged

scout/forge:scout/6500-a-test-fails-when-a-new-module-under-src → main 11 commits fork

  • Merged. scout/6500-a-test-fails-when-a-new-module-under-src was merged into main as 41c7daa.

Review

Sign in to review this change.
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
369369
370370 Tests, checks, the gate, lint, types and how a change reaches main: **[docs/DEVELOP.md](docs/DEVELOP.md)**.
371371
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
372381 ## Shape
373382
374383 ```
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs