|
| 1 | +#!/usr/bin/env node |
| 2 | +// turbo's `//#typecheck` inputs must cover everything tsc actually reads (#9848). |
| 3 | +// |
| 4 | +// THE HAZARD, in turbo.json's own words: that inputs list is "a snapshot of test/'s real cross-package reach |
| 5 | +// as of the audit that added it, not a structural guarantee -- a future test file importing from a NOT-yet- |
| 6 | +// listed package/app would reopen the same silent-stale-cache gap." It then asks a human to "re-run the same |
| 7 | +// grep ... before trusting this list again." |
| 8 | +// |
| 9 | +// Nobody re-runs a grep on request, and the list HAD already drifted when this check was written: `src/` and |
| 10 | +// `test/` import from `packages/loopover-mcp/lib/**` and `packages/loopover-miner/scripts/**`, neither of |
| 11 | +// which was hashed. Editing either could therefore leave a stale cache HIT on a typecheck that a real |
| 12 | +// `tsc --noEmit` would fail -- the exact class of bug PR #5082 already burned this repo on once, and the |
| 13 | +// reason the list exists at all. |
| 14 | +// |
| 15 | +// So: compute the reach instead of remembering it. This is the grep that comment asks for, run every CI. |
| 16 | +// |
| 17 | +// WHAT COUNTS AS COVERED. A path is fine if it is matched by an inputs glob, OR if it belongs to a workspace |
| 18 | +// this task already `dependsOn` -- turbo hashes a dependency task's own inputs, so `@loopover/engine#build` |
| 19 | +// covers the engine's sources without them being listed here. Anything else is unhashed and reported. |
| 20 | +import { existsSync, readFileSync, readdirSync } from "node:fs"; |
| 21 | +import { join } from "node:path"; |
| 22 | +import { fileURLToPath, URL } from "node:url"; |
| 23 | + |
| 24 | +/** A cross-boundary import found in src/ or test/, as `<group>/<workspace>/<first-segment>`. */ |
| 25 | +export type CrossBoundaryReach = { path: string; importedBy: string }; |
| 26 | + |
| 27 | +/** Strip `//` line comments and trailing commas so turbo.json (JSONC) parses. Deliberately not a full JSONC |
| 28 | + * parser: this file is ours, its comment style is known, and a dependency for one read would be worse. */ |
| 29 | +export function parseJsonc(text: string): unknown { |
| 30 | + return JSON.parse(text.replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1")); |
| 31 | +} |
| 32 | + |
| 33 | +/** |
| 34 | + * PURE core: every cross-workspace path `src/`+`test/` reach that no glob and no dependency covers. |
| 35 | + * |
| 36 | + * `globs` are turbo `inputs` entries; `coveredWorkspaces` are the workspace directory names whose builds this |
| 37 | + * task depends on. Matching is prefix-based on the glob's literal head, which is all turbo's own globs use |
| 38 | + * here (`packages/x/lib/**`) -- a stricter matcher would reject valid entries and a looser one would let a |
| 39 | + * real gap through. |
| 40 | + */ |
| 41 | +export function findUnhashedReach( |
| 42 | + reach: readonly CrossBoundaryReach[], |
| 43 | + globs: readonly string[], |
| 44 | + coveredWorkspaces: ReadonlySet<string>, |
| 45 | +): CrossBoundaryReach[] { |
| 46 | + const prefixes = globs.map((glob) => glob.replace(/\*\*.*$/, "").replace(/\/$/, "")); |
| 47 | + return reach.filter((entry) => { |
| 48 | + const workspace = entry.path.split("/").slice(0, 2).join("/"); |
| 49 | + if (coveredWorkspaces.has(workspace)) return false; |
| 50 | + return !prefixes.some((prefix) => prefix.length > 0 && (entry.path === prefix || entry.path.startsWith(`${prefix}/`) || prefix.startsWith(entry.path))); |
| 51 | + }); |
| 52 | +} |
| 53 | + |
| 54 | +function walk(dir: string, out: string[]): void { |
| 55 | + // Typed via the call's own return rather than `ReturnType<typeof readdirSync>`: that resolves to the |
| 56 | + // Buffer-named overload under this tsconfig, which the string form is not assignable to. |
| 57 | + let entries: ReadonlyArray<{ name: string; isDirectory(): boolean }>; |
| 58 | + try { |
| 59 | + entries = readdirSync(dir, { withFileTypes: true }); |
| 60 | + } catch { |
| 61 | + return; // a directory that does not exist here is not an error |
| 62 | + } |
| 63 | + for (const entry of entries) { |
| 64 | + const path = join(dir, entry.name); |
| 65 | + if (entry.isDirectory()) { |
| 66 | + if (entry.name !== "node_modules") walk(path, out); |
| 67 | + } else if (/\.(ts|tsx)$/.test(entry.name)) out.push(path); |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +/** Every `packages/<x>/<seg>` or `apps/<x>/<seg>` a relative import from src/ or test/ reaches. */ |
| 72 | +export function collectCrossBoundaryReach(root: string): CrossBoundaryReach[] { |
| 73 | + const files: string[] = []; |
| 74 | + walk(join(root, "src"), files); |
| 75 | + walk(join(root, "test"), files); |
| 76 | + |
| 77 | + const reach = new Map<string, string>(); |
| 78 | + for (const file of files) { |
| 79 | + const source = readFileSync(file, "utf8"); |
| 80 | + // Both `from "..."` and bare `import "..."`, since a side-effect import is type-checked too. |
| 81 | + for (const match of source.matchAll(/(?:from|import)\s+"((?:\.\.\/)+[^"]+)"/g)) { |
| 82 | + const specifier = match[1]; |
| 83 | + if (!specifier) continue; |
| 84 | + const segments = /(?:^|\/)(packages|apps)\/([^/]+)\/([^/"]+)/.exec(specifier); |
| 85 | + if (!segments) continue; |
| 86 | + const path = `${segments[1]}/${segments[2]}/${segments[3]}`; |
| 87 | + // Must exist on disk. The checker-testing files (check-import-specifiers-script.test.ts et al.) embed |
| 88 | + // import statements INSIDE FIXTURE STRINGS -- `"src/foo.ts": 'import ... from "../packages/engine/..."'` |
| 89 | + // -- and those name packages that were renamed away or never existed. A path tsc cannot resolve is not |
| 90 | + // part of its real surface, so requiring the directory to exist filters exactly those without needing |
| 91 | + // to parse TypeScript to tell code from a string literal. |
| 92 | + if (!existsSync(join(root, path))) continue; |
| 93 | + if (!reach.has(path)) reach.set(path, file); |
| 94 | + } |
| 95 | + } |
| 96 | + return [...reach].map(([path, importedBy]) => ({ path, importedBy })).sort((a, b) => a.path.localeCompare(b.path)); |
| 97 | +} |
| 98 | + |
| 99 | +/** Workspace dirs whose build this task depends on — turbo hashes their inputs transitively. */ |
| 100 | +export function coveredWorkspacesFromDependsOn(dependsOn: readonly string[], root: string): Set<string> { |
| 101 | + const covered = new Set<string>(); |
| 102 | + for (const dependency of dependsOn) { |
| 103 | + const name = dependency.split("#")[0]; |
| 104 | + if (!name) continue; |
| 105 | + for (const group of ["packages", "apps"]) { |
| 106 | + let dirs: string[]; |
| 107 | + try { |
| 108 | + dirs = readdirSync(join(root, group), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name); |
| 109 | + } catch { |
| 110 | + continue; |
| 111 | + } |
| 112 | + for (const dir of dirs) { |
| 113 | + try { |
| 114 | + const manifest = JSON.parse(readFileSync(join(root, group, dir, "package.json"), "utf8")) as { name?: string }; |
| 115 | + if (manifest.name === name) covered.add(`${group}/${dir}`); |
| 116 | + } catch { |
| 117 | + // not a workspace package |
| 118 | + } |
| 119 | + } |
| 120 | + } |
| 121 | + } |
| 122 | + return covered; |
| 123 | +} |
| 124 | + |
| 125 | +function main(): void { |
| 126 | + const root = join(fileURLToPath(new URL(".", import.meta.url)), ".."); |
| 127 | + const turbo = parseJsonc(readFileSync(join(root, "turbo.json"), "utf8")) as { |
| 128 | + tasks?: Record<string, { inputs?: string[]; dependsOn?: string[] }>; |
| 129 | + }; |
| 130 | + const task = turbo.tasks?.["//#typecheck"]; |
| 131 | + if (!task) { |
| 132 | + console.error('turbo-typecheck-inputs: turbo.json has no "//#typecheck" task — this check can no longer verify anything, so it fails rather than passing silently.'); |
| 133 | + process.exit(1); |
| 134 | + } |
| 135 | + |
| 136 | + const unhashed = findUnhashedReach( |
| 137 | + collectCrossBoundaryReach(root), |
| 138 | + task.inputs ?? [], |
| 139 | + coveredWorkspacesFromDependsOn(task.dependsOn ?? [], root), |
| 140 | + ); |
| 141 | + |
| 142 | + if (unhashed.length > 0) { |
| 143 | + console.error("turbo //#typecheck does not hash everything tsc reads:\n"); |
| 144 | + for (const entry of unhashed) console.error(` ${entry.path} (e.g. imported by ${entry.importedBy})`); |
| 145 | + console.error( |
| 146 | + "\n tsc's real surface is everything transitively imported from src/ + test/, wherever it lives. A path\n" + |
| 147 | + " reached from there but absent from `inputs` (and not covered by a dependsOn build) is NOT hashed, so\n" + |
| 148 | + " editing it can leave a stale cache HIT on a typecheck a real `tsc --noEmit` would fail — the #5082\n" + |
| 149 | + " class of bug this inputs list exists to prevent.\n\n" + |
| 150 | + ' Fix: add the path (e.g. "packages/x/lib/**") to //#typecheck\'s `inputs` in turbo.json.', |
| 151 | + ); |
| 152 | + process.exit(1); |
| 153 | + } |
| 154 | + console.log("turbo-typecheck-inputs: OK — every cross-workspace path src/+test/ reach is hashed."); |
| 155 | +} |
| 156 | + |
| 157 | +if (process.argv[1]?.endsWith("check-turbo-typecheck-inputs.ts")) main(); |
0 commit comments