Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit f560271492ed555cc6a83b10423d83972e06babe
parent f991e35b2a78ad01740bfb7b4cc8e5225ef9d070
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 19:59:31 -0400

scripts: a guard — no umtool app module joins a cwd-derived path without turbopackIgnore

scripts/umtool-build-trace.test.mjs (run by test:scripts, 3 tests)
scans umtool's app, components, lib, report-to-video and song/paths.mjs
per module: a path or fs call whose arguments carry a value derived in
that file from process.cwd(), import.meta.url/dirname/filename or
__dirname must open with the opt-out (a nested opted-out call covers
its caller). With main's lib/paths.mjs it fails and names the defect:
`paths.mjs:118 : path.join(REPO_ROOT, "transcripts", "channels")`.
A worktree build cannot catch this -- it has no corpus to walk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Ascripts/umtool-build-trace.test.mjs | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 195 insertions(+), 0 deletions(-)

diff --git a/scripts/umtool-build-trace.test.mjs b/scripts/umtool-build-trace.test.mjs @@ -0,0 +1,195 @@ +// umtool's modules must not hand Turbopack a directory to bundle. +// +// Turbopack evaluates `process.cwd()` (and a module's own `import.meta.url` / +// `__dirname`) statically, as a path in the project, and a `path.join` / +// `path.resolve` / fs call on such a value becomes an ASSET REFERENCE: to a +// file, or, when the joined path is a directory, to EVERY file under it. Release +// 12 slice Q wrote `path.join(REPO_ROOT, "transcripts", "channels")` with +// `REPO_ROOT = findRepoRoot(process.cwd())`, and `next build` in the primary +// checkout walked the whole corpus (hundreds of GB, `data/` symlinked to another +// drive) until the kernel killed it -- while a worktree, which has no +// `transcripts/`, built in 30 s. So no build-in-a-worktree gate can see this. +// +// The rule, checked statically and per module (Turbopack's value analysis is +// per module; an imported binding is opaque to it): every path or fs call whose +// arguments carry a value derived IN THAT FILE from `process.cwd()`, +// `import.meta.url`, `import.meta.dirname|filename` or `__dirname` must open its +// argument list with the documented opt-out, `/* turbopackIgnore: true */`. +// The comment changes nothing at run time. `os.homedir()` is NOT a source: the +// tracer does not follow it (a build with HOME pointed at a synthetic home full +// of out-of-root symlinks inside the project succeeds), and neither is +// `process.env.*`. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const UMTOOL = path.join(REPO, "umtool"); + +const SOURCE = /process\.cwd\(\)|import\.meta\.(?:url|dirname|filename)|\b__dirname\b/; +const MARK = "__TURBOPACK_IGNORE__"; +const IGNORE_COMMENT = /\/\*\s*turbopackIgnore\s*:\s*true\s*\*\//g; + +// path ops, and fs calls either bare (`existsSync(`) or on a namespace +// (`fs.readdir(`, `fsp.stat(`). A method on anything else (`obj.stat(`) is not +// one. +const SINK = + /(?:\bpath\.(?:join|resolve|dirname|relative)|(?<![\w$.])(?:fs\.|fsp\.|promises\.)?(?:existsSync|readFileSync|readdirSync|statSync|lstatSync|realpathSync|opendirSync|readFile|readdir|stat|lstat|opendir|createReadStream))\s*\(/g; + +/** Comments out, except the opt-out, which becomes a marker. Strings stay. */ +function prepare(text) { + let s = text.replace(IGNORE_COMMENT, ` ${MARK} `); + s = s.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")); + // A line comment: `//` not preceded by `:` (URLs, `file://` templates). + s = s.replace(/(^|[^:\\])\/\/[^\n]*/g, (m, pre) => pre + " ".repeat(m.length - pre.length)); + return s; +} + +/** Index just past the bracket that closes the one at `open`. */ +function closeOf(s, open) { + let depth = 0; + let quote = null; + for (let i = open; i < s.length; i += 1) { + const c = s[i]; + if (quote) { + if (c === "\\") i += 1; + else if (c === quote) quote = null; + continue; + } + if (c === '"' || c === "'" || c === "`") quote = c; + else if (c === "(" || c === "[" || c === "{") depth += 1; + else if (c === ")" || c === "]" || c === "}") { + depth -= 1; + if (depth === 0) return i + 1; + } + } + return s.length; +} + +/** Names assigned, in this file, from a source or from another such name. */ +function taintedNames(s) { + const decls = []; + const re = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g; + for (let m; (m = re.exec(s)); ) { + // The right-hand side runs to the first `;` or newline at depth 0 -- good + // enough for this repo's formatter, which ends statements with `;`. + let depth = 0; + let end = s.length; + for (let i = re.lastIndex; i < s.length; i += 1) { + const c = s[i]; + if (c === "(" || c === "[" || c === "{") depth += 1; + else if (c === ")" || c === "]" || c === "}") depth -= 1; + else if (c === ";" && depth <= 0) { + end = i; + break; + } + } + decls.push({ name: m[1], rhs: s.slice(re.lastIndex, end) }); + } + const names = new Set(); + for (let grew = true; grew; ) { + grew = false; + for (const { name, rhs } of decls) { + if (names.has(name)) continue; + if (SOURCE.test(rhs) || [...names].some((n) => new RegExp(`(?<![\\w$.])${n.replace(/\$/g, "\\$")}\\b`).test(rhs))) { + names.add(name); + grew = true; + } + } + } + return names; +} + +/** Every path/fs call on a cwd-derived value that does not opt out. */ +export function untracedCalls(text) { + const s = prepare(text); + const names = taintedNames(s); + const carries = (args) => + SOURCE.test(args) || + [...names].some((n) => new RegExp(`(?<![\\w$.])${n.replace(/\$/g, "\\$")}\\b(?!\\s*:)`).test(args)); + // A call nested in these arguments that opts out is opaque to this one too: + // `readFileSync(path.join(/* turbopackIgnore: true */ HERE, "a.json"))`. Its + // own arguments are cut out before asking whether this call carries a source. + const withoutOptedOut = (args) => { + let out = args; + for (let i = out.search(new RegExp(`\\(\\s*${MARK}`)); i !== -1; i = out.search(new RegExp(`\\(\\s*${MARK}`))) { + out = out.slice(0, i) + out.slice(closeOf(out, i)); + } + return out; + }; + const out = []; + for (let m; (m = SINK.exec(s)); ) { + const open = SINK.lastIndex - 1; + const args = s.slice(open + 1, closeOf(s, open) - 1); + if (args.trimStart().startsWith(MARK)) continue; + if (!carries(withoutOptedOut(args))) continue; + const line = s.slice(0, m.index).split("\n").length; + out.push({ line, call: text.split("\n")[line - 1].trim() }); + } + return out; +} + +/** umtool's modules that a Next build can reach: the app, its libs, the pipeline. */ +function appModules() { + const out = []; + const walk = (dir) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.name === "node_modules" || e.name.startsWith(".")) continue; + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (/\.(?:mjs|js|ts|tsx)$/.test(e.name) && !/\.test\./.test(e.name) && !e.name.endsWith(".d.ts")) out.push(p); + } + }; + for (const d of ["app", "components", "lib"]) walk(path.join(UMTOOL, d)); + for (const e of readdirSync(path.join(UMTOOL, "report-to-video"))) { + if (e.endsWith(".mjs") && !e.includes(".test.")) out.push(path.join(UMTOOL, "report-to-video", e)); + } + // song/paths.mjs is imported by lib/paths.mjs; the other song scripts are CLIs. + out.push(path.join(UMTOOL, "song", "paths.mjs")); + return out; +} + +test("the check flags slice Q's join and passes the opted-out form", () => { + const bad = [ + 'const REPO_ROOT = findRepoRoot(process.cwd());', + 'export const CHANNELS_DIR = path.resolve(', + ' process.env.CHANNELS_DIR ?? path.join(REPO_ROOT, "transcripts", "channels"),', + ');', + ].join("\n"); + const found = untracedCalls(bad); + assert.ok(found.some((f) => f.call.includes('path.join(REPO_ROOT, "transcripts"')), JSON.stringify(found)); + + const good = bad + .replace("path.resolve(", "path.resolve(/* turbopackIgnore: true */") + .replace("path.join(REPO_ROOT", "path.join(/* turbopackIgnore: true */ REPO_ROOT"); + assert.deepEqual(untracedCalls(good), []); +}); + +test("sources: cwd, import.meta, __dirname; not homedir, env, or a comment", () => { + assert.equal(untracedCalls('const X = path.join(process.cwd(), "song");').length, 1); + assert.equal(untracedCalls("const H = path.dirname(fileURLToPath(import.meta.url));").length, 1); + assert.equal(untracedCalls('readFileSync(path.join(__dirname, "a.json"));').length, 2); + assert.equal(untracedCalls('const R = path.join(os.homedir(), "reports");').length, 0); + assert.equal(untracedCalls('const R = path.join(process.env.X, "channels");').length, 0); + assert.equal(untracedCalls('// path.join(process.cwd(), "x")\nconst y = 1;').length, 0); + // An object key named like a tainted value is not a use of it. + assert.equal(untracedCalls('const cwd = path.join(/* turbopackIgnore: true */ process.cwd(), "s");\nf(path.join(a, { cwd: 1 }));').length, 0); +}); + +test("no umtool module the app can import joins a cwd-derived path without opting out", () => { + const bad = []; + for (const file of appModules()) { + for (const f of untracedCalls(readFileSync(file, "utf8"))) { + bad.push(`${path.relative(REPO, file)}:${f.line} ${f.call}`); + } + } + assert.deepEqual( + bad, + [], + "add /* turbopackIgnore: true */ as the first argument (see this file's header):\n" + bad.join("\n"), + ); +});