|
| 1 | +// Every `scripts/check-*.ts` must actually RUN somewhere (#9860, item 3). |
| 2 | +// |
| 3 | +// The failure this exists to prevent: a checker lands, is correct, is reviewed, and is never wired into |
| 4 | +// anything. It then guards nothing while looking exactly like a guard -- and the class it was written to |
| 5 | +// catch keeps shipping. That is strictly worse than not having written it, because its presence in the tree |
| 6 | +// is read as coverage. |
| 7 | +// |
| 8 | +// It is the same shape as the three incidents behind #9860: a hand-maintained list (here, `test:ci`'s ~50 |
| 9 | +// command chain) that nobody re-derives. So this does not hold a list of which checkers are wired. It |
| 10 | +// COMPUTES where each one runs, from the three places a checker can legitimately live: |
| 11 | +// |
| 12 | +// 1. Reachable from `test:ci` -- transitively, since `test:ci` calls npm scripts that call npm scripts. |
| 13 | +// 2. Referenced by a GitHub workflow -- release cadence and ops probes belong there, not in the local gate. |
| 14 | +// 3. Imported by another script -- a shared module that happens to match `check-*.ts` is not an entry point. |
| 15 | +// |
| 16 | +// A checker in none of the three is dead, and this fails with the specific fix. ALLOWED_UNWIRED exists for |
| 17 | +// the genuine exception, and each entry must carry a reason -- but reaching for it should feel like a |
| 18 | +// concession, because "add it to the list too" is exactly the fix #9860 rejects. |
| 19 | +import { readdirSync, readFileSync } from "node:fs"; |
| 20 | +import { join } from "node:path"; |
| 21 | + |
| 22 | +/** `npm run <name>`, capturing a trailing `--workspace` so a workspace-scoped call is not read as a root one. */ |
| 23 | +const NPM_RUN_REFERENCE = /npm run ([\w:.-]+)((?:\s+--workspace[= ]\S+)?)/g; |
| 24 | + |
| 25 | +const SCRIPTS_DIR = "scripts"; |
| 26 | +const WORKFLOWS_DIR = ".github/workflows"; |
| 27 | + |
| 28 | +/** |
| 29 | + * Checkers that legitimately run nowhere in this repo, with the reason. Deliberately tiny: a checker whose |
| 30 | + * home is a workflow or `test:ci` is DETECTED, never listed, so this holds only the true oddities. |
| 31 | + */ |
| 32 | +const ALLOWED_UNWIRED: Record<string, string> = { |
| 33 | + "check-changelog.ts": "release-time only: run against a release-please branch, where the changelog it validates exists. Nothing in the local gate produces one.", |
| 34 | + "check-roadmap-issue-drift.ts": "operator-invoked: reads live GitHub issues, so it needs a token and a network the local gate deliberately does not assume.", |
| 35 | +}; |
| 36 | + |
| 37 | +/** |
| 38 | + * npm scripts referenced by `root` that DO NOT EXIST (#9860). |
| 39 | + * |
| 40 | + * Found the hard way: `test:ci` called `npm run publishable-deps:check`, no such script was ever defined, so |
| 41 | + * the documented one-command local gate died with "Missing script" -- and the checker behind it had never |
| 42 | + * run. A chain of ~50 hand-kept commands has no other way of noticing that one of them is a typo or a |
| 43 | + * rename, which is precisely the hand-maintained-list class this file exists to close. |
| 44 | + * |
| 45 | + * `--workspace` invocations are excluded: those resolve against the workspace's own package.json. |
| 46 | + */ |
| 47 | +export function danglingScriptReferences(scripts: Record<string, string>, root: string): string[] { |
| 48 | + const body = scripts[root]; |
| 49 | + if (body === undefined) return []; |
| 50 | + const missing = new Set<string>(); |
| 51 | + for (const match of body.matchAll(NPM_RUN_REFERENCE)) { |
| 52 | + if (match[2]) continue; |
| 53 | + const name = match[1]!; |
| 54 | + if (!(name in scripts)) missing.add(name); |
| 55 | + } |
| 56 | + return [...missing].sort(); |
| 57 | +} |
| 58 | + |
| 59 | +/** Every `check-*.ts` entry point under scripts/. */ |
| 60 | +export function listCheckerScripts(entries: readonly string[]): string[] { |
| 61 | + return entries.filter((entry) => entry.startsWith("check-") && entry.endsWith(".ts")).sort(); |
| 62 | +} |
| 63 | + |
| 64 | +/** |
| 65 | + * The npm scripts transitively reachable from `root`, following `npm run <name>` references. |
| 66 | + * |
| 67 | + * Transitive because `test:ci` does not invoke every checker directly -- several sit behind an aggregate |
| 68 | + * script. Treating only direct mentions as wired would report a correctly-wired checker as dead, and a |
| 69 | + * checker that cries wolf gets muted, which would leave the repo worse off than before. |
| 70 | + */ |
| 71 | +export function reachableNpmScripts(scripts: Record<string, string>, root: string): Set<string> { |
| 72 | + const seen = new Set<string>(); |
| 73 | + const queue = [root]; |
| 74 | + while (queue.length > 0) { |
| 75 | + const name = queue.shift()!; |
| 76 | + if (seen.has(name)) continue; |
| 77 | + seen.add(name); |
| 78 | + const body = scripts[name]; |
| 79 | + if (body === undefined) continue; |
| 80 | + for (const match of body.matchAll(NPM_RUN_REFERENCE)) { |
| 81 | + // `npm run build --workspace X` targets the WORKSPACE's script, not a root one -- following it as a |
| 82 | + // root reference would both miss the real target and report a nonexistent root "build". |
| 83 | + if (match[2]) continue; |
| 84 | + const next = match[1]!; |
| 85 | + if (!seen.has(next)) queue.push(next); |
| 86 | + } |
| 87 | + // npm lifecycle hooks run automatically around their base script, so a checker wired as `pretest` is |
| 88 | + // wired -- no `npm run` mentions it anywhere. Missing this reported correctly-wired checkers as dead, |
| 89 | + // and a checker that cries wolf gets muted. |
| 90 | + for (const hook of [`pre${name}`, `post${name}`]) { |
| 91 | + if (scripts[hook] !== undefined && !seen.has(hook)) queue.push(hook); |
| 92 | + } |
| 93 | + } |
| 94 | + return seen; |
| 95 | +} |
| 96 | + |
| 97 | +/** Which of `scripts` (by npm-script name) invoke this checker file. */ |
| 98 | +export function npmScriptsInvoking(scripts: Record<string, string>, file: string): string[] { |
| 99 | + return Object.entries(scripts) |
| 100 | + .filter(([, body]) => body.includes(`${SCRIPTS_DIR}/${file}`)) |
| 101 | + .map(([name]) => name) |
| 102 | + .sort(); |
| 103 | +} |
| 104 | + |
| 105 | +export type CheckerHome = |
| 106 | + | { kind: "test-ci"; via: string } |
| 107 | + | { kind: "workflow"; via: string } |
| 108 | + | { kind: "imported"; via: string } |
| 109 | + | { kind: "allowed"; via: string } |
| 110 | + | { kind: "none" }; |
| 111 | + |
| 112 | +/** Where a checker actually runs, or `none`. Pure, so every branch is testable without a filesystem. */ |
| 113 | +export function resolveCheckerHome(input: { |
| 114 | + file: string; |
| 115 | + scripts: Record<string, string>; |
| 116 | + reachableFromTestCi: ReadonlySet<string>; |
| 117 | + workflowText: string; |
| 118 | + otherScriptSources: readonly string[]; |
| 119 | + allowed?: Record<string, string>; |
| 120 | +}): CheckerHome { |
| 121 | + const invokers = npmScriptsInvoking(input.scripts, input.file); |
| 122 | + const wired = invokers.find((name) => input.reachableFromTestCi.has(name)); |
| 123 | + if (wired !== undefined) return { kind: "test-ci", via: `npm run ${wired}` }; |
| 124 | + |
| 125 | + // A workflow may call the npm script OR the file directly (`tsx scripts/check-foo.ts`); both count. |
| 126 | + const workflowRef = invokers.find((name) => input.workflowText.includes(name)) ?? (input.workflowText.includes(input.file) ? input.file : undefined); |
| 127 | + if (workflowRef !== undefined) return { kind: "workflow", via: workflowRef }; |
| 128 | + |
| 129 | + // Imported by a sibling script => a shared module, not an entry point. Matched on the extensionless |
| 130 | + // specifier because a TS import may or may not carry `.ts`/`.js`. |
| 131 | + const base = input.file.replace(/\.ts$/, ""); |
| 132 | + if (input.otherScriptSources.some((source) => source.includes(`${base}.ts`) || source.includes(`${base}.js`) || source.includes(`./${base}"`) || source.includes(`./${base}'`))) { |
| 133 | + return { kind: "imported", via: base }; |
| 134 | + } |
| 135 | + |
| 136 | + const reason = (input.allowed ?? ALLOWED_UNWIRED)[input.file]; |
| 137 | + if (reason !== undefined) return { kind: "allowed", via: reason }; |
| 138 | + return { kind: "none" }; |
| 139 | +} |
| 140 | + |
| 141 | +function main(): void { |
| 142 | + const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { scripts: Record<string, string> }; |
| 143 | + const entries = readdirSync(SCRIPTS_DIR); |
| 144 | + const checkers = listCheckerScripts(entries); |
| 145 | + const reachable = reachableNpmScripts(pkg.scripts, "test:ci"); |
| 146 | + |
| 147 | + const workflowText = readdirSync(WORKFLOWS_DIR) |
| 148 | + .filter((entry) => entry.endsWith(".yml") || entry.endsWith(".yaml")) |
| 149 | + .map((entry) => readFileSync(join(WORKFLOWS_DIR, entry), "utf8")) |
| 150 | + .join("\n"); |
| 151 | + |
| 152 | + const dangling = danglingScriptReferences(pkg.scripts, "test:ci"); |
| 153 | + if (dangling.length > 0) { |
| 154 | + console.error(`check-checkers-wired: "test:ci" references ${dangling.length} npm script(s) that do not exist, so the local gate cannot run to completion:\n`); |
| 155 | + for (const name of dangling) console.error(` npm run ${name}`); |
| 156 | + console.error("\nDefine the script, or remove the step from test:ci."); |
| 157 | + process.exit(1); |
| 158 | + } |
| 159 | + |
| 160 | + const dead: string[] = []; |
| 161 | + for (const file of checkers) { |
| 162 | + // Every OTHER script, so a file cannot count as its own importer. |
| 163 | + const otherScriptSources = entries |
| 164 | + .filter((entry) => entry.endsWith(".ts") && entry !== file) |
| 165 | + .map((entry) => readFileSync(join(SCRIPTS_DIR, entry), "utf8")); |
| 166 | + const home = resolveCheckerHome({ file, scripts: pkg.scripts, reachableFromTestCi: reachable, workflowText, otherScriptSources }); |
| 167 | + if (home.kind === "none") dead.push(file); |
| 168 | + } |
| 169 | + |
| 170 | + if (dead.length > 0) { |
| 171 | + console.error(`check-checkers-wired: ${dead.length} checker(s) run NOWHERE — they guard nothing while looking like a guard (#9860):\n`); |
| 172 | + for (const file of dead) console.error(` ${SCRIPTS_DIR}/${file}`); |
| 173 | + console.error( |
| 174 | + [ |
| 175 | + "", |
| 176 | + "Fix by giving each one a real home:", |
| 177 | + ` • local gate — add an npm script and chain it into "test:ci"`, |
| 178 | + ` • scheduled — reference it from a workflow under ${WORKFLOWS_DIR}/`, |
| 179 | + " • shared code — if it is not an entry point, import it from the script that uses it", |
| 180 | + "", |
| 181 | + "Only as a last resort, add it to ALLOWED_UNWIRED in this file WITH a reason.", |
| 182 | + ].join("\n"), |
| 183 | + ); |
| 184 | + process.exit(1); |
| 185 | + } |
| 186 | + |
| 187 | + console.log(`check-checkers-wired: all ${checkers.length} scripts/check-*.ts entry points run somewhere.`); |
| 188 | +} |
| 189 | + |
| 190 | +if (process.argv[1]?.endsWith("check-checkers-wired.ts")) main(); |
0 commit comments