Public: anyone can read this; the crew can change it.
Automate a review finding as a lint or test: First fix: item 1 (make the record reachable). #11851
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11843-automate-a-review-finding-as-a-lint-or-t → main 1 commit fork 1 thread · 1 unresolved
3 files
changed +105
−0
scripts/unread-tables-allow.json added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | [ | |
| 2 | "keeper_beat", | |
| 3 | "proof_run", | |
| 4 | "sentinel_kept" | |
| 5 | ] |
scripts/unread-tables.ts added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | /** | |
| 2 | * Unread tables (#11843): each table a migration creates that no code under | |
| 3 | * `src/` reads. A record the forge writes and nobody can read back is not done | |
| 4 | * (docs/VISION.md: done means reachable); reviewers asked for "make the record | |
| 5 | * reachable" four times (#9975, #10663, #10664, #11735). Read means the table | |
| 6 | * follows `FROM` or `JOIN` in a `src/` file other than a migration, in any | |
| 7 | * quoting. A table a later migration drops is not counted. | |
| 8 | * `scripts/unread-tables-allow.json` is what main carries today; | |
| 9 | * `test/unread-tables.test.ts` keeps it shrinking. | |
| 10 | */ | |
| 11 | ||
| 12 | import { readFileSync } from "node:fs"; | |
| 13 | import { join } from "node:path"; | |
| 14 | ||
| 15 | const CREATE = /\bCREATE\s+(?:VIRTUAL\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?([a-z_][a-z0-9_]*)["`]?/gi; | |
| 16 | const DROP = /\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["`]?([a-z_][a-z0-9_]*)["`]?/gi; | |
| 17 | const RENAME = /\bALTER\s+TABLE\s+["`]?([a-z_][a-z0-9_]*)["`]?\s+RENAME\s+TO\s+["`]?([a-z_][a-z0-9_]*)["`]?/gi; | |
| 18 | ||
| 19 | const scan = (root: string, glob: string) => [...new Bun.Glob(glob).scanSync({ cwd: root })].sort(); | |
| 20 | ||
| 21 | /** The tables the migrations under `root` create and keep, sorted. */ | |
| 22 | export function createdTables(root: string): string[] { | |
| 23 | const made = new Set<string>(); | |
| 24 | for (const f of scan(root, "src/core/migrations/*.ts")) { | |
| 25 | const text = readFileSync(join(root, f), "utf8"); | |
| 26 | for (const m of text.matchAll(CREATE)) made.add(m[1]!.toLowerCase()); | |
| 27 | for (const m of text.matchAll(RENAME)) { | |
| 28 | made.delete(m[1]!.toLowerCase()); | |
| 29 | made.add(m[2]!.toLowerCase()); | |
| 30 | } | |
| 31 | for (const m of text.matchAll(DROP)) made.delete(m[1]!.toLowerCase()); | |
| 32 | } | |
| 33 | return [...made].sort(); | |
| 34 | } | |
| 35 | ||
| 36 | /** The created tables no `src/` file outside migrations reads, sorted. */ | |
| 37 | export function unreadTables(root: string): string[] { | |
| 38 | const text = scan(root, "src/**/*.ts") | |
| 39 | .filter((f) => !f.startsWith("src/core/migrations/") && !f.endsWith(".test.ts")) | |
| 40 | .map((f) => readFileSync(join(root, f), "utf8")) | |
| 41 | .join("\n"); | |
| 42 | return createdTables(root).filter( | |
| 43 | (t) => !new RegExp(`\\b(?:FROM|JOIN)\\s+["\`]?${t}\\b`, "i").test(text), | |
| 44 | ); | |
| 45 | } |
test/unread-tables.test.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | /** | |
| 2 | * A table a migration creates that no code under src/ reads is a record nobody | |
| 3 | * can reach (#11843; reviewers asked "make the record reachable" in #9975, | |
| 4 | * #10663, #10664, #11735). It fails here unless `scripts/unread-tables-allow.json` | |
| 5 | * lists it, and a listed table that is now read fails too, so the list only shrinks. | |
| 6 | */ | |
| 7 | ||
| 8 | import { expect, test } from "bun:test"; | |
| 9 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { dirname, join } from "node:path"; | |
| 12 | import { createdTables, unreadTables } from "../scripts/unread-tables.ts"; | |
| 13 | import allowed from "../scripts/unread-tables-allow.json"; | |
| 14 | ||
| 15 | const root = new URL("..", import.meta.url).pathname; | |
| 16 | const found = unreadTables(root); | |
| 17 | ||
| 18 | test("every table a migration creates is read by code under src/, or allowed", () => { | |
| 19 | const fresh = found.filter((t) => !allowed.includes(t)); | |
| 20 | expect( | |
| 21 | fresh, | |
| 22 | `rule "make the record reachable": no src/ code reads table ${fresh.join(", ")} (no FROM/JOIN); show it on a page, a /system line or an MCP tool, or it is not done (docs/VISION.md)`, | |
| 23 | ).toEqual([]); | |
| 24 | }); | |
| 25 | ||
| 26 | test("an allowed table that is now read leaves the list", () => { | |
| 27 | const stale = allowed.filter((t) => !found.includes(t)); | |
| 28 | expect(stale, `now read or gone, remove from scripts/unread-tables-allow.json: ${stale.join(", ")}`).toEqual([]); | |
| 29 | }); | |
| 30 | ||
| 31 | test("unreadTables() counts FROM and JOIN in src, not migrations or tests, and follows drops and renames", () => { | |
| 32 | const dir = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), "unread-")); | |
| 33 | const put = (path: string, text: string) => { | |
| 34 | mkdirSync(dirname(join(dir, path)), { recursive: true }); | |
| 35 | writeFileSync(join(dir, path), text); | |
| 36 | }; | |
| 37 | try { | |
| 38 | put( | |
| 39 | "src/core/migrations/1.ts", | |
| 40 | "export default `CREATE TABLE read_me (a INT); CREATE TABLE joined (a INT); CREATE TABLE write_only (a INT);\n" + | |
| 41 | "CREATE TABLE gone (a INT); CREATE TABLE tmp_new (a INT); CREATE TABLE test_only (a INT);`;\n", | |
| 42 | ); | |
| 43 | put("src/core/migrations/2.ts", "export default `DROP TABLE gone; ALTER TABLE tmp_new RENAME TO final_name;`;\n"); | |
| 44 | put( | |
| 45 | "src/a.ts", | |
| 46 | 'db.query("SELECT a FROM read_me JOIN joined ON 1");\ndb.query("INSERT INTO write_only (a) VALUES (1)");\n' + | |
| 47 | 'db.query("SELECT 1 FROM final_name");\n', | |
| 48 | ); | |
| 49 | put("src/a.test.ts", 'db.query("SELECT 1 FROM test_only");\n'); | |
| 50 | expect(createdTables(dir)).toEqual(["final_name", "joined", "read_me", "test_only", "write_only"]); | |
| 51 | expect(unreadTables(dir)).toEqual(["test_only", "write_only"]); | |
| 52 | } finally { | |
| 53 | rmSync(dir, { recursive: true, force: true }); | |
| 54 | } | |
| 55 | }); |
new above
offline
\b(?:FROM|JOIN)also matchesDELETE FROM t, so a write-and-prune-only table counts as read. Exclude a preceding DELETE, and add a fixture.