Files
rea/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts
Kaoru0822andClaude Opus 5.5 4692bb3330 fix(reference): stop attributing overwritten require members to Node (#3801)
* fix(reference): stop attributing overwritten require members to Node

import-reference-source treated require.resolve(...) and require.main(...)
as Node's resolver even after the file replaced that member, so a literal
argument became a parsed internal dependency that the source never loads.

Attribute member calls only while the loader's resolve and main members
keep Node's identity: no write to either member or a computed member
(assignment, update, delete, destructuring) and no use of require as a
value where a mutator or alias could replace them. Bare require(...) calls
are unaffected.

Fixes #3393

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(reference): keep optional and wrapped loader uses intact

An optional call require?.(...) or a TypeScript or parenthesis wrapper
around require was read as require escaping as a value, which disabled
every member-call link in the file. Look through inert wrappers and
accept optional calls before deciding the loader escaped, and add
controls for bare calls after a member overwrite, a local loader's
member write, and optional calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:23:06 -07:00

186 lines
6.5 KiB
TypeScript

import { execFile } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { importReferenceSource } from "../../support/referenceSourceResourceScope.js";
it("resolves require calls by lexical binding and source mode", async () => {
const root = await createTestTempDirectory("rea-reference-require-scope-");
const sources: Record<string, string> = {
"parameter.cjs": [
"function load(require) { return require('./throwing.cjs'); }",
"console.log(load(() => 'local'));",
].join("\n"),
"destructured.cjs": [
"function load({ require }) { return require('./throwing.cjs'); }",
"console.log(load({ require: () => 'local' }));",
].join("\n"),
"hoisted.cjs": [
"function load() {",
" console.log(require('./throwing.cjs'));",
" function require() { return 'local'; }",
"}",
"load();",
].join("\n"),
"catch.cjs": [
"try { throw () => 'local'; }",
"catch (require) { console.log(require('./throwing.cjs')); }",
].join("\n"),
"nested.cjs": [
"console.log(require('./loaded.cjs'));",
"function load(require) { return require('./throwing.cjs'); }",
"console.log(load(() => 'local'));",
].join("\n"),
"dynamic-with.cjs": [
"with ({ require: () => 'local' }) {",
" console.log(require('./throwing.cjs'));",
"}",
].join("\n"),
"top-level-var.cjs": [
"var require;",
"console.log(require('./loaded.cjs'));",
].join("\n"),
"initialized-var.cjs": [
"var require = () => 'local';",
"console.log(require('./throwing.cjs'));",
].join("\n"),
"assigned-var.cjs": [
"require = () => 'local';",
"console.log(require('./throwing.cjs'));",
].join("\n"),
"unbound-esm.mjs": [
"try { require('./throwing.cjs'); }",
"catch (error) {",
" if (!(error instanceof ReferenceError)) throw error;",
" console.log('no-global-require');",
"}",
].join("\n"),
"shadowed-esm.mjs": [
"const require = () => 'local';",
"console.log(require('./throwing.cjs'));",
].join("\n"),
"create-require-esm.mjs": [
"import { createRequire as makeRequire } from 'node:module';",
"const require = makeRequire(import.meta.url);",
"console.log(require('./loaded.cjs'));",
].join("\n"),
"loaded.cjs": "console.log('loaded');\nmodule.exports = 'real';\n",
"throwing.cjs": "throw new Error('A shadowed require must not load me');\n",
};
await Promise.all(
Object.entries(sources).map(([path, source]) =>
writeFile(join(root, path), source),
),
);
const runNode = promisify(execFile);
for (const [path, stdout] of [
["parameter.cjs", "local\n"],
["destructured.cjs", "local\n"],
["hoisted.cjs", "local\n"],
["catch.cjs", "local\n"],
["nested.cjs", "loaded\nreal\nlocal\n"],
["dynamic-with.cjs", "local\n"],
["top-level-var.cjs", "loaded\nreal\n"],
["initialized-var.cjs", "local\n"],
["assigned-var.cjs", "local\n"],
["unbound-esm.mjs", "no-global-require\n"],
["shadowed-esm.mjs", "local\n"],
["create-require-esm.mjs", "loaded\nreal\n"],
] as const) {
const executed = await runNode(process.execPath, [join(root, path)], {
cwd: root,
timeout: 5_000,
});
expect(executed.stdout).toBe(stdout);
expect(executed.stderr).toBe("");
}
const result = await importReferenceSource({
root,
caller: "reference-shadowed-require-test",
policy: { secretPatterns: [] },
});
if (!result.ok) throw result.error;
expect(
result.value.relationships
.filter(({ kind }) => kind === "requires")
.map(({ from_path, to }) => [from_path, to])
.sort((left, right) => (left[0] ?? "").localeCompare(right[0] ?? "")),
).toEqual([
["create-require-esm.mjs", "loaded.cjs"],
["nested.cjs", "loaded.cjs"],
["top-level-var.cjs", "loaded.cjs"],
]);
});
it("does not attribute overwritten require members to Node's resolver", async () => {
const root = await createTestTempDirectory("rea-reference-require-member-");
const sources: Record<string, string> = {
"assigned.cjs":
"require.resolve = () => 'custom';\nconsole.log(require.resolve('./loaded.cjs'));",
"defined.cjs":
"Object.defineProperty(require, 'resolve', { value: () => 'custom' });\nconsole.log(require.resolve('./loaded.cjs'));",
"computed.cjs":
"const key = 'resolve';\nrequire[key] = () => 'custom';\nconsole.log(require.resolve('./loaded.cjs'));",
"control.cjs": "console.log(typeof require.resolve('./loaded.cjs'));",
// A member overwrite does not change bare loader calls.
"bare-after-overwrite.cjs":
"require.resolve = () => 'custom';\nconsole.log(require('./loaded.cjs'));",
// A local loader's member write does not touch Node's loader.
"local-loader.cjs":
"function patch(require) { require.resolve = () => 'custom'; }\npatch({});\nconsole.log(typeof require.resolve('./loaded.cjs'));",
// Optional calls and inert wrappers do not let the loader escape.
"optional-call.cjs":
"require?.('./loaded.cjs');\nconsole.log(typeof (require).resolve('./loaded.cjs'));",
"loaded.cjs": "module.exports = 'real';\n",
};
await Promise.all(
Object.entries(sources).map(([path, source]) =>
writeFile(join(root, path), source),
),
);
const runNode = promisify(execFile);
for (const [path, stdout] of [
["assigned.cjs", "custom\n"],
["defined.cjs", "custom\n"],
["computed.cjs", "custom\n"],
["control.cjs", "string\n"],
["bare-after-overwrite.cjs", "real\n"],
["local-loader.cjs", "string\n"],
["optional-call.cjs", "string\n"],
] as const)
expect(
(
await runNode(process.execPath, [join(root, path)], {
cwd: root,
timeout: 5_000,
})
).stdout,
).toBe(stdout);
const result = await importReferenceSource({
root,
caller: "reference-require-member-test",
policy: { secretPatterns: [] },
});
if (!result.ok) throw result.error;
expect(
result.value.relationships
.filter(({ kind }) => kind === "requires")
.map(({ from_path, to }) => [from_path, to])
.sort((left, right) => (left[0] ?? "").localeCompare(right[0] ?? "")),
).toEqual([
["bare-after-overwrite.cjs", "loaded.cjs"],
["control.cjs", "loaded.cjs"],
["local-loader.cjs", "loaded.cjs"],
["optional-call.cjs", "loaded.cjs"],
]);
});