Archilyzer · Source

archilyzer

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

commit ab1eb83ed36b007e2fcc8ddc037c9a1c04703177
parent 42ff75097d719cf78130ccadb496280ab387eb5e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 29 Sep 2026 22:21:11 -0400

scripts: the trace guard reads umtool's last build back, follows relative imports, and checks opens and writes

- A post-build test: every .nft.json under umtool/.next (cache/ and dev/
  aside) fails on an entry outside the repo, under transcripts/, or through
  any dot-named directory or file but the build's own and node_modules/.pnpm.
  It skips, saying so, when there is no build. On the old clip-audio route it
  fails with 1,704 entries.
- The scan set follows every relative import out of the listed folders:
  common/bin/_publicFile.ts, homepage/content/docs.ts and seven umtool/song
  modules join it (the release 14 review's L1); a test pins them.
- open, writeFile, appendFile, createWriteStream (and Sync forms) and the
  fs.promises. / fsPromises. prefixes are checked calls. No new finding.
- The header no longer says the env and home directory are unfollowed, or that
  the opt-out is documented; the nested-call exemption says what it is.

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

Diffstat:
Mscripts/next-build-trace.test.mjs | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 180 insertions(+), 18 deletions(-)

diff --git a/scripts/next-build-trace.test.mjs b/scripts/next-build-trace.test.mjs @@ -15,17 +15,27 @@ // 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 */`. +// argument list with Turbopack's opt-out, `/* turbopackIgnore: true */` (the +// form its own "whole project was traced" warning advises; the Next docs list +// the comment only for import(), require(), require.resolve() and new Worker()). // The comment changes nothing at run time. A function declared in the file whose // body carries a source is a source too (`const ROOT = findMonorepoRoot()`). -// `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.*`. +// +// A value Turbopack cannot know -- `process.env.*`, `os.homedir()`, a +// parameter, an imported binding -- is not one of these sources, and it is not +// ignored either: it is a dynamic part, and a path or fs call on it becomes a +// PATTERN over the app's own directory. Measured in umtool (release 15, slice +// UT): the path ops on env and home-directory values in its path modules took +// in the app's whole tree outside dot-directories (opting them out cleaned 31 +// of 68 routes), and the clip-audio route's join with a dynamic extension took +// in the dot-directories too, the e2e fixture and `.env.local` among them. +// Neither walk entered a symlinked directory. No static check here can tell +// such a pattern from a harmless one, so the last test reads a build's traces +// back instead. // // Run with: pnpm test:scripts import assert from "node:assert/strict"; -import { readdirSync, readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; @@ -38,10 +48,13 @@ 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. +// (`fs.readdir(`, `fsp.stat(`, `fs.promises.readFile(`). A method on anything +// else (`obj.stat(`) is not one. The opens and writes are checked too +// (`open`, `writeFile`, `appendFile`, `createWriteStream`): which fs calls +// Turbopack traces is not documented, and an opt-out on one it does not trace +// costs nothing. 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; + /(?:\bpath\.(?:join|resolve|dirname|relative)|(?<![\w$.])(?:fs\.promises\.|fsPromises\.|fs\.|fsp\.|promises\.)?(?:existsSync|readFileSync|readdirSync|statSync|lstatSync|realpathSync|opendirSync|openSync|writeFileSync|appendFileSync|readFile|readdir|stat|lstat|opendir|open|writeFile|appendFile|createReadStream|createWriteStream))\s*\(/g; /** Comments out, except the opt-out, which becomes a marker. Strings stay. */ function prepare(text) { @@ -154,9 +167,15 @@ export function untracedCalls(text) { 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: + // A call nested in these arguments that opts out is let through: // `readFileSync(path.join(/* turbopackIgnore: true */ HERE, "a.json"))`. Its // own arguments are cut out before asking whether this call carries a source. + // That is a simplification, not how Turbopack reads it: the outer call traces + // the join's value all the same (release 15, slice UT, measured). On a + // cwd-derived value that value is a known path, so the outer call traces the + // one file it names (a changelog), or the files of one known directory (the + // brand kits). Where the join names a directory, or a dynamic part could + // reach past the files the call reads, give the outer call its own opt-out. const withoutOptedOut = (args) => { let out = args; for (let i = out.search(new RegExp(`\\(\\s*${MARK}`)); i !== -1; i = out.search(new RegExp(`\\(\\s*${MARK}`))) { @@ -187,24 +206,75 @@ function modulesUnder(dir, out = []) { return out; } -/** umtool's modules that a Next build can reach: the app, its libs, the pipeline. */ +const MODULE_EXT = [".ts", ".tsx", ".mjs", ".js", ".cjs"]; +const isModule = (p) => MODULE_EXT.some((x) => p.endsWith(x)) && !/\.test\./.test(p) && !p.endsWith(".d.ts"); +const isFile = (p) => { + try { + return statSync(p).isFile(); + } catch { + return false; + } +}; + +/** The module a relative specifier names, the way the bundler resolves it, or null. */ +function resolveRelative(from, spec) { + const base = path.resolve(path.dirname(from), spec); + const candidates = [base, ...MODULE_EXT.map((x) => base + x), ...MODULE_EXT.map((x) => path.join(base, "index" + x))]; + return candidates.find((c) => isModule(c) && isFile(c)) ?? null; +} + +// `import … from "./x"`, `export … from "../x"`, `import "./x"`, `import("./x")`, +// `require("./x")`: the relative specifiers only. A package import (`next`, +// `yt-dlp-transcript-common/…`) is covered by the package's own directory in +// the set, or is not this repo's code. +const RELATIVE_IMPORT = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)(["'])(\.{1,2}\/[^"'\n]+)\1/g; + +/** + * `files` plus every module of this repo they reach by relative imports, to + * any depth. A directory list alone misses a module one app imports from a + * folder the list treats as CLI-only (`common/bin/_publicFile.ts`, imported by + * `common/publish/source.ts`; umtool's `song/pitch.mjs`, imported by + * `lib/verdict.ts`) or keeps outside `app/` (`homepage/content/docs.ts`). + */ +export function withRelativeImports(files) { + const seen = new Set(files); + const queue = [...files]; + while (queue.length) { + const file = queue.pop(); + for (const m of readFileSync(file, "utf8").matchAll(RELATIVE_IMPORT)) { + const target = resolveRelative(file, m[2]); + if (!target || seen.has(target) || target.includes(`${path.sep}node_modules${path.sep}`)) continue; + if (path.relative(REPO, target).startsWith("..")) continue; + seen.add(target); + queue.push(target); + } + } + return [...seen]; +} + +/** + * umtool's modules that a Next build can reach: the app, its components and + * libs, the report pipeline, and whatever of `song/` they import (`paths.mjs` + * through `lib/paths.mjs`, `pitch.mjs`, `reasons.mjs` and the rest through the + * libs; the other song scripts are CLIs nothing in the app imports). + */ function umtoolModules() { const out = []; for (const d of ["app", "components", "lib"]) modulesUnder(path.join(UMTOOL, d), out); 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; + return withRelativeImports(out); } /** * The editor's, the export's and the homepage's modules a Next build can * reach: each app's `app/` (the editor's `lib/` and `instrumentation.ts` too), - * and every module of common/ but its CLIs (`bin/`, which no app imports). The - * common set is wider than what the apps import today, on purpose: a module - * that starts being imported is already covered. + * every module of common/ but its CLIs in `bin/`, and every module those reach + * by a relative import — which brings in the few `bin/` modules an app does + * import (`_publicFile.ts`) and the homepage's `content/docs.ts`. The common set + * is wider than what the apps import today, on purpose: a module that starts + * being imported is already covered. */ function nextAppModules() { const out = []; @@ -214,7 +284,7 @@ function nextAppModules() { if (!e.isDirectory() || e.name === "bin" || e.name === "node_modules" || e.name.startsWith(".")) continue; modulesUnder(path.join(REPO, "common", e.name), out); } - return out; + return withRelativeImports(out); } function untracedIn(files) { @@ -298,6 +368,98 @@ test("no umtool module the app can import joins a cwd-derived path without optin ); }); +test("the scan set follows relative imports out of the listed folders", () => { + const rel = (files) => new Set(files.map((f) => path.relative(REPO, f))); + const um = rel(umtoolModules()); + for (const m of ["paths", "reasons", "archive-url", "pitch", "flatness", "clipwindow", "deplosive", "orderfeat"]) { + assert.ok(um.has(`umtool/song/${m}.mjs`), `umtool/song/${m}.mjs is not scanned`); + } + // A song CLI nothing in the app imports stays out. + assert.ok(!um.has("umtool/song/build-um.mjs")); + const apps = rel(nextAppModules()); + assert.ok(apps.has("common/bin/_publicFile.ts"), "common/bin/_publicFile.ts is not scanned"); + assert.ok(apps.has("homepage/content/docs.ts"), "homepage/content/docs.ts is not scanned"); + assert.ok(!apps.has("common/bin/compose-site.ts"), "a common CLI no app imports is scanned"); +}); + +/** Every `.nft.json` a build wrote under `dir`, its cache and dev-server output aside. */ +function traceFilesUnder(dir, out = []) { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) { + if (e.name !== "cache" && e.name !== "dev") traceFilesUnder(p, out); + } else if (e.name.endsWith(".nft.json")) out.push(p); + } + return out; +} + +/** + * Why `abs`, a file a build traced, must not be in the trace, or null. + * + * The build's own directory (`dist`) holds the chunks every trace lists, and + * `node_modules/.pnpm` is where pnpm keeps the packages; any other path through + * a directory or file whose name starts with a dot is something no server + * needs at run time — a fixture (`.e2e-song`, where the e2e fixture links the + * song data), another build (`.next-e2e`), a secret (`.env.local`), `.git` — + * and is the mark of a pattern Turbopack could not bound. So are the corpus + * and anything outside the repo. + */ +export function forbiddenTrace(abs, dist) { + const rel = path.relative(REPO, abs); + if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return "outside the repo"; + if (abs.startsWith(dist + path.sep)) return null; + const parts = rel.split(path.sep); + if (parts[0] === "transcripts") return "the corpus"; + for (let i = 0; i < parts.length; i += 1) { + if (!parts[i].startsWith(".")) continue; + if (parts[i] === ".pnpm" && parts[i - 1] === "node_modules") continue; + return `under ${parts.slice(0, i + 1).join("/")}`; + } + return null; +} + +test("forbiddenTrace: a fixture, another build, a secret, the corpus, outside the repo", () => { + const dist = path.join(UMTOOL, ".next"); + const at = (p) => forbiddenTrace(path.join(REPO, p), dist); + assert.equal(at("umtool/.next/server/chunks/ssr/a.js"), null); + assert.equal(at("node_modules/.pnpm/next@16.2.3/node_modules/next/dist/server/next.js"), null); + assert.equal(at("umtool/lib/paths.mjs"), null); + assert.equal(at("umtool/.e2e-song/data/planted/x/config.json"), "under umtool/.e2e-song"); + assert.equal(at("umtool/.next-e2e/dev/server/a.js"), "under umtool/.next-e2e"); + assert.equal(at("umtool/.env.local"), "under umtool/.env.local"); + assert.equal(at(".git/config"), "under .git"); + assert.equal(at("transcripts/channels/x/config.json"), "the corpus"); + assert.equal(forbiddenTrace(path.resolve(REPO, "..", "elsewhere", "a.json"), dist), "outside the repo"); +}); + +// The static checks above cannot see a value Turbopack reads through an +// import: `path.join(CACHE_DIR, `${stamp}.${asMp3 ? "mp3" : "wav"}`)` in +// umtool's clip-audio route made every file under umtool/ with a dot in its +// name part of that route's trace, the fixture and the e2e build's directory +// included (plans/release-15.md, slice UT). So the last build's traces are read +// back, when there is one. +test("umtool's last build traced no dot-directory, no corpus file and nothing outside the repo", (t) => { + const dist = path.join(UMTOOL, ".next"); + if (!existsSync(path.join(dist, "server"))) { + t.skip("no umtool build to read (umtool/.next/server); `pnpm --filter umtool exec next build` makes one"); + return; + } + const bad = []; + for (const nft of traceFilesUnder(dist)) { + const { files } = JSON.parse(readFileSync(nft, "utf8")); + for (const f of files) { + const why = forbiddenTrace(path.resolve(path.dirname(nft), f), dist); + if (why) bad.push(`${path.relative(dist, nft)}: ${f} (${why})`); + } + } + assert.deepEqual( + bad.slice(0, 20), + [], + `${bad.length} traced file(s) no server needs; find the fs or path call whose value Turbopack could not bound (this file's header):\n` + + bad.slice(0, 20).join("\n"), + ); +}); + test("no module the editor, the export or the homepage can bundle joins a cwd-derived path without opting out", () => { const files = nextAppModules(); assert.ok(files.length > 500, `only ${files.length} modules found`);