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>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-14 19:23:06 -07:00
1 parent af6a998066
commit 4692bb3330
2 files changed
+151

No files matched your search

@@ -1,5 +1,11 @@
import * as t from "@babel/types";
import {
semanticStaticPropertyKey,
semanticWriteSiteTarget,
semanticWriteTargets,
unwrapJavaScriptExpression,
} from "./javascript/javascriptAstValues.js";
import { analyzeParsedJavaScriptReferences } from "./javascript/javascriptSemanticAnalysis.js";
import {
semanticBindingId,
@@ -49,6 +55,22 @@ export const unshadowedReferenceSourceRequireCalls = (
(reference.resolution === "unbound" ||
reference.bindingId === programRequireBinding),
);
const isLoaderReference = (identifier: t.Identifier): boolean => {
const start = identifier.loc?.start;
const reference =
start === undefined
? undefined
: references.get(`${start.line}:${start.column}`);
return (
(allowsUnboundRequire && reference?.resolution === "unbound") ||
(reference?.resolution === "resolved" &&
reference.bindingId === programRequireBinding)
);
};
const loaderMembersIntact = requireLoaderMembersIntact(
file.program,
isLoaderReference,
);
const calls = new Set<t.CallExpression>();
traverseJavaScriptAst(file.program, {
@@ -64,6 +86,9 @@ export const unshadowedReferenceSourceRequireCalls = (
)
)
return;
// `require.resolve` and `require.main` denote Node's members only
// while no visible write or escape can have replaced them.
if (!t.isIdentifier(node.callee) && !loaderMembersIntact) return;
const reference = references.get(`${start.line}:${start.column}`);
if (
(allowsUnboundRequire &&
@@ -80,6 +105,68 @@ export const unshadowedReferenceSourceRequireCalls = (
return calls;
};
/**
* Whether the loader's `resolve` and `main` members keep Node's identity. A
* write to either member or to a computed member, or `require` used as a
* value other than a call or member read (where a mutator or alias can
* replace members), leaves that identity unknown.
*/
const requireLoaderMembersIntact = (
program: t.Program,
isLoaderReference: (identifier: t.Identifier) => boolean,
): boolean => {
let intact = true;
const isLoaderMemberTarget = (target: t.Node): boolean => {
if (!t.isMemberExpression(target) && !t.isOptionalMemberExpression(target))
return false;
const object = unwrapJavaScriptExpression(target.object).node;
if (!t.isIdentifier(object, { name: "require" })) return false;
if (!isLoaderReference(object)) return false;
const key = semanticStaticPropertyKey(target.property, target.computed);
return key === null || key === "resolve" || key === "main";
};
traverseJavaScriptAst(program, {
enter: (node, parent, readAncestors) => {
if (!intact) return;
const site = semanticWriteSiteTarget(node);
if (site !== null) {
for (const target of semanticWriteTargets(site))
if (isLoaderMemberTarget(target)) intact = false;
return;
}
if (
!t.isIdentifier(node, { name: "require" }) ||
parent === null ||
!isLoaderReference(node)
)
return;
// Inert parentheses and TypeScript wrappers do not change how the
// loader value is used.
let used: t.Node = node;
let user: t.Node | undefined = parent;
const read = readAncestors();
const ancestors = read.at(-1) === parent ? read : [...read, parent];
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
user = ancestors[index];
if (
user === undefined ||
unwrapJavaScriptExpression(user).node !== node
)
break;
used = user;
}
const usedAsLoader =
((t.isCallExpression(user) || t.isOptionalCallExpression(user)) &&
user.callee === used) ||
((t.isMemberExpression(user) || t.isOptionalMemberExpression(user)) &&
user.object === used) ||
t.isUnaryExpression(user, { operator: "typeof" });
if (!usedAsLoader) intact = false;
},
});
return intact;
};
const contains = (container: t.Node, node: t.Node): boolean =>
typeof container.start === "number" &&
typeof container.end === "number" &&
@@ -119,3 +119,67 @@ it("resolves require calls by lexical binding and source mode", async () => {
["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"],
]);
});