Keep a probe load eager when it catches the refusal of a program file

A package that catches the probe's refusal to load a program file
evaluated a fallback, and the probe recorded that load as clean and
snapshotted the fallback's exports, which the executable never has. A
load in which the resolve hook refused a program file is now recorded as
throwing the refusal whether or not the load caught it, and so is a later
load that reaches one of its files through the module cache.
This commit is contained in:
Chris Tate
2026-10-30 01:30:27 -05:00
parent e4bc919aec
commit 1ec321ed77
2 changed files with 42 additions and 2 deletions
@@ -72,3 +72,25 @@ test("the probe never evaluates the program's own source", () => {
expect(existsSync(join(root, "src/evaluated-program"))).toBe(false);
expect(existsSync(join(root, "src/evaluated-shared"))).toBe(false);
});
test("a package that catches the refusal does not defer, nor does one that loads it later", () => {
const root = project({
"src/config.js": marker("config"),
"node_modules/catches/package.json": '{ "name": "catches" }',
"node_modules/catches/index.js":
'let config;\ntry { config = require("../../src/config.js"); } catch { config = { fallback: true }; }\nmodule.exports = config;\n',
"node_modules/later/package.json": '{ "name": "later" }',
"node_modules/later/index.js": 'module.exports = { config: require("catches") };\n',
"node_modules/clean/package.json": '{ "name": "clean" }',
"node_modules/clean/index.js": "module.exports = { ok: true };\n",
});
const results = probe(root, [
join(root, "node_modules/catches/index.js"),
join(root, "node_modules/later/index.js"),
join(root, "node_modules/clean/index.js"),
]);
const refusal = `the build does not evaluate ${join(root, "src/config.js")}, a file of the program`;
expect(results.map((r) => r.throws)).toEqual([refusal, refusal, undefined]);
expect(results.map((r) => r.root === undefined)).toEqual([true, true, false]);
expect(existsSync(join(root, "src/evaluated-config"))).toBe(false);
});
@@ -21,7 +21,8 @@
* 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;
* program's own source) throws before that file evaluates, and its
* record is that throw even when the load catches it;
*
* and takes the interface snapshot of the module's value (snapshot.ts).
* Calls made by Node's own internals (the module loader reading files,
@@ -190,6 +191,11 @@ exports.probe = function probe(input) {
const inputs = [], volatile = [], effects = [], tty = new Map(), envKeys = new Map();
const files = new Set();
let recording = false;
// The first program file the current load asked for (the resolve hook),
// and, for every file a load with a refusal evaluated, that program file:
// the module cache keeps what such a file exported without it.
let refused = null;
const refusedBy = new Map();
const note = (list, what) => {
if (recording && !internalCaller()) list.push(list === volatile ? what : what + " (" + lastCaller + ")");
};
@@ -440,8 +446,10 @@ exports.probe = function probe(input) {
const result = nextResolve(specifier, context);
if (recording && typeof result.url === "string" && result.url.startsWith("file:")) {
const file = require("node:url").fileURLToPath(result.url);
if (!/[\\/]node_modules[\\/]/.test(file))
if (!/[\\/]node_modules[\\/]/.test(file)) {
if (refused === null) refused = file;
throw new Error("the build does not evaluate " + file + ", a file of the program");
}
files.add(file);
}
return result;
@@ -511,6 +519,7 @@ exports.probe = function probe(input) {
pendingTimers.clear();
loadPromises.clear();
tty.clear(); envKeys.clear(); files.clear();
refused = null;
files.add(path);
currentFiles = files;
currentPackageDir = null;
@@ -522,6 +531,15 @@ exports.probe = function probe(input) {
recording = true;
try { exportsValue = createRequire(path)(path); } catch (e) { threw = String((e && e.message) || e); }
recording = false;
// A load that catches the refusal evaluates a fallback the executable
// never runs: its exports are not the module's, and neither are those of
// a later load that reaches one of its files through the module cache.
if (refused === null)
for (const f of files) if (refusedBy.has(f)) { refused = refusedBy.get(f); break; }
if (refused !== null) {
threw = "the build does not evaluate " + refused + ", a file of the program";
for (const f of files) if (!refusedBy.has(f)) refusedBy.set(f, refused);
}
const after = shot();
const root = threw === undefined ? value(exportsValue, 0) : undefined;
const mutations = [];