|
| 1 | +#!/usr/bin/env node |
| 2 | +// `npm run typecheck` must actually typecheck everything that can be typechecked (#9860). |
| 3 | +// |
| 4 | +// THE INCIDENT. #9815 turned main red because `ChatActionDispatchResult` was closed into a union and a |
| 5 | +// miner-UI mock stopped satisfying it. The change was validated locally with `npm run typecheck`, which |
| 6 | +// passed -- because `ui:typecheck` was in `test:ci` but NOT in the root `typecheck` chain. A contributor |
| 7 | +// running the obvious command got a green result on a tree that does not compile. |
| 8 | +// |
| 9 | +// That is the worst shape a check can have: not missing, but SILENTLY PARTIAL. A missing check is noticed |
| 10 | +// the first time something breaks; a partial one is trusted precisely because it passes. |
| 11 | +// |
| 12 | +// WHAT THIS COMPUTES. Every workspace that declares its own `typecheck` script, and whether the root |
| 13 | +// `typecheck` script reaches it -- following `npm run <script>` references transitively, so a workspace |
| 14 | +// covered through an intermediate script (`ui:typecheck` -> `npm --workspace @loopover/ui run typecheck`) |
| 15 | +// counts as covered. Anything declaring a typecheck nobody runs is reported. |
| 16 | +// |
| 17 | +// This is #9853's bar applied to one more hand-maintained list: compute the fact rather than remember it. |
| 18 | +// The failure that motivated the issue was not "someone forgot to add it" -- it was that nothing could tell |
| 19 | +// them they had forgotten. |
| 20 | +import { readFileSync, readdirSync } from "node:fs"; |
| 21 | +import { join } from "node:path"; |
| 22 | +import { fileURLToPath, URL } from "node:url"; |
| 23 | + |
| 24 | +export type TypecheckGap = { workspace: string; script: string }; |
| 25 | + |
| 26 | +/** Every `npm run <name>` this script body invokes (the root package's own scripts). */ |
| 27 | +function referencedRootScripts(body: string): string[] { |
| 28 | + // `npm run x`, `npm run x --silent`, `npm --silent run x` -- all forms used in this package.json. |
| 29 | + return [...body.matchAll(/npm\s+(?:--\S+\s+)*run\s+([\w:.-]+)/g)].map((match) => match[1]).filter((name): name is string => Boolean(name)); |
| 30 | +} |
| 31 | + |
| 32 | +/** Every workspace whose OWN `typecheck` this script body invokes directly. */ |
| 33 | +function referencedWorkspaces(body: string): string[] { |
| 34 | + // `npm --workspace @scope/name run typecheck` and `npm run typecheck --workspace @scope/name`. |
| 35 | + const names = [ |
| 36 | + ...body.matchAll(/npm\s+--workspace[= ]\s*(\S+)\s+run\s+([\w:.-]+)/g), |
| 37 | + ...body.matchAll(/npm\s+run\s+([\w:.-]+)\s+--workspace[= ]\s*(\S+)/g), |
| 38 | + ]; |
| 39 | + const out: string[] = []; |
| 40 | + for (const match of names) { |
| 41 | + // The two patterns capture (workspace, script) and (script, workspace) respectively; the workspace is |
| 42 | + // whichever capture looks like a package name. |
| 43 | + const [a, b] = [match[1], match[2]]; |
| 44 | + const workspace = a?.startsWith("@") || a?.includes("/") ? a : b; |
| 45 | + const script = workspace === a ? b : a; |
| 46 | + if (workspace && script === "typecheck") out.push(workspace); |
| 47 | + } |
| 48 | + return out; |
| 49 | +} |
| 50 | + |
| 51 | +/** |
| 52 | + * PURE: workspaces that declare a `typecheck` script the root `typecheck` never reaches. |
| 53 | + * |
| 54 | + * `scripts` is the root package's script map; `workspacesWithTypecheck` is every workspace package name that |
| 55 | + * declares one. Reachability follows `npm run` references transitively from `entry`, because a workspace is |
| 56 | + * covered whether it is invoked directly or through an intermediate script. |
| 57 | + */ |
| 58 | +export function findTypecheckGaps( |
| 59 | + scripts: Readonly<Record<string, string>>, |
| 60 | + workspacesWithTypecheck: readonly string[], |
| 61 | + entry = "typecheck", |
| 62 | +): TypecheckGap[] { |
| 63 | + const covered = new Set<string>(); |
| 64 | + const seen = new Set<string>(); |
| 65 | + const queue: string[] = [entry]; |
| 66 | + while (queue.length > 0) { |
| 67 | + const name = queue.shift(); |
| 68 | + if (!name || seen.has(name)) continue; |
| 69 | + seen.add(name); |
| 70 | + const body = scripts[name]; |
| 71 | + if (body === undefined) continue; |
| 72 | + for (const workspace of referencedWorkspaces(body)) covered.add(workspace); |
| 73 | + // A `tsc -p packages/<x>/tsconfig.json` counts too: the root chain typechecks that project directly |
| 74 | + // without going through the workspace's own script. |
| 75 | + for (const match of body.matchAll(/-p\s+((?:packages|apps)\/[\w.-]+)\//g)) { |
| 76 | + const dir = match[1]; |
| 77 | + if (dir) covered.add(dir); |
| 78 | + } |
| 79 | + queue.push(...referencedRootScripts(body)); |
| 80 | + } |
| 81 | + return workspacesWithTypecheck |
| 82 | + .filter((workspace) => !covered.has(workspace) && !covered.has(workspace.replace(/^@[\w-]+\//, ""))) |
| 83 | + .map((workspace) => ({ workspace, script: "typecheck" })); |
| 84 | +} |
| 85 | + |
| 86 | +/** Workspace package names (and their directories) that declare their own `typecheck` script. */ |
| 87 | +export function workspacesDeclaringTypecheck(root: string): { name: string; dir: string }[] { |
| 88 | + const out: { name: string; dir: string }[] = []; |
| 89 | + for (const group of ["apps", "packages"]) { |
| 90 | + let dirs: string[]; |
| 91 | + try { |
| 92 | + dirs = readdirSync(join(root, group), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name); |
| 93 | + } catch { |
| 94 | + continue; |
| 95 | + } |
| 96 | + for (const dir of dirs) { |
| 97 | + try { |
| 98 | + const manifest = JSON.parse(readFileSync(join(root, group, dir, "package.json"), "utf8")) as { name?: string; scripts?: Record<string, string> }; |
| 99 | + if (manifest.name && manifest.scripts?.typecheck) out.push({ name: manifest.name, dir: `${group}/${dir}` }); |
| 100 | + } catch { |
| 101 | + // not a workspace package |
| 102 | + } |
| 103 | + } |
| 104 | + } |
| 105 | + return out; |
| 106 | +} |
| 107 | + |
| 108 | +function main(): void { |
| 109 | + const root = join(fileURLToPath(new URL(".", import.meta.url)), ".."); |
| 110 | + const rootManifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { scripts?: Record<string, string> }; |
| 111 | + const declared = workspacesDeclaringTypecheck(root); |
| 112 | + const gaps = findTypecheckGaps(rootManifest.scripts ?? {}, declared.map((entry) => entry.name)); |
| 113 | + |
| 114 | + if (gaps.length > 0) { |
| 115 | + console.error("`npm run typecheck` does not reach every workspace that declares one:\n"); |
| 116 | + for (const gap of gaps) console.error(` ${gap.workspace} (declares "${gap.script}", never invoked)`); |
| 117 | + console.error( |
| 118 | + "\n A typecheck that passes while part of the tree does not compile is worse than no typecheck: it is\n" + |
| 119 | + " trusted BECAUSE it passes. #9815 turned main red exactly this way -- the change was validated with\n" + |
| 120 | + " `npm run typecheck`, which did not cover apps/**.\n\n" + |
| 121 | + " Fix: chain the workspace into the root `typecheck` script (directly, or through one it already\n" + |
| 122 | + " calls), so the obvious command means what a contributor assumes it means.", |
| 123 | + ); |
| 124 | + process.exit(1); |
| 125 | + } |
| 126 | + console.log(`typecheck-coverage: OK — all ${declared.length} workspace typecheck script(s) are reachable from \`npm run typecheck\`.`); |
| 127 | +} |
| 128 | + |
| 129 | +if (process.argv[1]?.endsWith("check-typecheck-coverage.ts")) main(); |
0 commit comments