diff --git a/packages/compiler/src/node-islands/probe-recorder.test.ts b/packages/compiler/src/node-islands/probe-recorder.test.ts new file mode 100644 index 000000000..2e82be3e9 --- /dev/null +++ b/packages/compiler/src/node-islands/probe-recorder.test.ts @@ -0,0 +1,74 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { PROBE_RECORDER } from "./probe-recorder.js"; + +const made: string[] = []; +afterEach(() => { + for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function project(files: Record): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), "scriptc-probe-recorder-"))); + made.push(root); + for (const [path, text] of Object.entries(files)) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), text); + } + return root; +} + +interface ProbeResult { + path: string; + throws?: string; + root?: number; +} + +/** Runs the recorder in a Node process of its own, as the probe host runs + * it in libnode, over `modules`. */ +function probe(root: string, modules: string[]): ProbeResult[] { + writeFileSync(join(root, "recorder.cjs"), PROBE_RECORDER); + writeFileSync( + join(root, "run.cjs"), + `const out = require("./recorder.cjs").probe({ modules: ${JSON.stringify(modules)} });\n` + + `process.stdout.write(JSON.stringify(out.results));\n`, + ); + const run = spawnSync(process.execPath, [join(root, "run.cjs")], { cwd: root, encoding: "utf8" }); + expect(run.status, run.stderr).toBe(0); + return JSON.parse(run.stdout) as ProbeResult[]; +} + +// A source file that, when it evaluates, writes `evaluated-` beside itself. +const marker = (name: string): string => + `require("fs").writeFileSync(require("path").join(__dirname, "evaluated-${name}"), "");\nmodule.exports = { n: 1 };\n`; + +test("the probe evaluates an installed package", () => { + const root = project({ + "node_modules/pkg/package.json": '{ "name": "pkg" }', + "node_modules/pkg/index.js": "module.exports = { answer: 42 };\n", + }); + const [result] = probe(root, [join(root, "node_modules/pkg/index.js")]); + expect(result!.throws).toBeUndefined(); + expect(result!.root).toBeDefined(); +}); + +test("the probe never evaluates the program's own source", () => { + const root = project({ + "src/program.js": marker("program"), + "src/shared.js": marker("shared"), + "node_modules/reaches/package.json": '{ "name": "reaches" }', + "node_modules/reaches/index.js": 'module.exports = require("../../src/shared.js");\n', + }); + const results = probe(root, [ + join(root, "src/program.js"), + join(root, "node_modules/reaches/index.js"), + ]); + expect(results.map((r) => r.throws)).toEqual([ + `the build does not evaluate ${join(root, "src/program.js")}, a file of the program`, + `the build does not evaluate ${join(root, "src/shared.js")}, a file of the program`, + ]); + expect(existsSync(join(root, "src/evaluated-program"))).toBe(false); + expect(existsSync(join(root, "src/evaluated-shared"))).toBe(false); +}); diff --git a/packages/compiler/src/node-islands/probe-recorder.ts b/packages/compiler/src/node-islands/probe-recorder.ts index 499de3593..af29be084 100644 --- a/packages/compiler/src/node-islands/probe-recorder.ts +++ b/packages/compiler/src/node-islands/probe-recorder.ts @@ -20,6 +20,8 @@ * (island-local, since native code has its own builtins, but * observable by other island modules evaluated out of order); * - every file the load required (the module's closure), and a throw; + * a load that resolves a file outside every node_modules directory (the + * program's own source) throws before that file evaluates; * * and takes the interface snapshot of the module's value (snapshot.ts). * Calls made by Node's own internals (the module loader reading files, @@ -429,11 +431,19 @@ exports.probe = function probe(input) { undo.push(() => hook.disable()); // The closure: a resolve hook (no frame of ours stays on the stack while // modules evaluate, so Node's node_modules checks see the real callers). + // The probe evaluates installed packages only: a file outside every + // node_modules directory is the program's own source (a program module, + // or a project file a package requires), and its load throws before the + // file evaluates, so the module that asked for it stays eager. const hooks = Module.registerHooks({ resolve(specifier, context, nextResolve) { const result = nextResolve(specifier, context); - if (recording && typeof result.url === "string" && result.url.startsWith("file:")) - files.add(require("node:url").fileURLToPath(result.url)); + if (recording && typeof result.url === "string" && result.url.startsWith("file:")) { + const file = require("node:url").fileURLToPath(result.url); + if (!/[\\/]node_modules[\\/]/.test(file)) + throw new Error("the build does not evaluate " + file + ", a file of the program"); + files.add(file); + } return result; }, });