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:
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`);