Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
40df65e
fix(graph): skip import edges for lexically shadowed require (#2890)
hussnainahmedd Oct 13, 2026
4c4c273
fix(graph): address review findings on shadowed-require detection
hussnainahmedd Oct 13, 2026
8ef0dd1
Merge branch 'main' into fix/2890-shadowed-require
hussnainahmedd Oct 13, 2026
4c7faad
Merge branch 'main' into fix/2890-shadowed-require
hussnainahmedd Oct 13, 2026
3e26716
fix(graph): address greptile review findings on loader exemptions
hussnainahmedd Oct 13, 2026
92e2783
Merge branch 'main' into fix/2890-shadowed-require
hussnainahmedd Oct 13, 2026
e8b8bed
Merge branch 'main' into fix/2890-shadowed-require
hussnainahmedd Oct 13, 2026
3d0f980
fix(graph): verify loader-parameter bindings positionally and accept …
hussnainahmedd Oct 13, 2026
4173418
Merge branch 'main' into fix/2890-shadowed-require
hussnainahmedd Oct 13, 2026
84ea39f
fix(graph): make require-loader checks positional for writes, keys, a…
hussnainahmedd Oct 14, 2026
87049c7
Merge branch 'main' into fix/2890-shadowed-require
hussnainahmedd Oct 14, 2026
0c1aca4
fix(graph): require writes to provably run before shadowing a call
hussnainahmedd Oct 14, 2026
b3e1522
Merge branch 'main' into fix/2890-shadowed-require
hussnainahmedd Oct 14, 2026
dd6c1a2
fix(graph): instance field writes shadow calls in later fields
hussnainahmedd Oct 14, 2026
453a31a
Merge branch 'main' into fix/2890-shadowed-require
hussnainahmedd Oct 14, 2026
9a09003
fix(graph): restore loader on later createRequire writes
hussnainahmedd Oct 14, 2026
8659198
Merge branch 'main' into fix/2890-shadowed-require
hussnainahmedd Oct 14, 2026
a6e6555
fix(graph): guard createRequire establishing writes by must-run and p…
hussnainahmedd Oct 14, 2026
e8111ff
Merge branch 'main' into fix/2890-shadowed-require
hussnainahmedd Oct 14, 2026
5b49524
fix(graph): honor for-initializer and class-evaluation order in shado…
hussnainahmedd Oct 14, 2026
82859a2
Merge branch 'main' into fix/2890-shadowed-require
hussnainahmedd Oct 14, 2026
68cb6c6
fix(graph): honor do-while body execution in shadowed-require analysis
hussnainahmedd Oct 14, 2026
b633841
Merge branch 'main' into fix/2890-shadowed-require
hussnainahmedd Oct 14, 2026
f3a4463
fix(graph): honor createRequire alias established inside a branch
hussnainahmedd Oct 14, 2026
c8030ec
Merge branch 'main' into fix/2890-shadowed-require
hussnainahmedd Oct 14, 2026
6b9f061
fix(graph): honor loop-condition writes in shadowed-require analysis
hussnainahmedd Oct 14, 2026
c7017dc
Merge branch 'main' into fix/2890-shadowed-require
hussnainahmedd Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 104 additions & 2 deletions src/domain/javascript/javascriptStaticAnalysisCalls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
argumentValue,
calleeName,
endpointArgument as endpointArgumentHelper,
isImportMetaUrl,
prefixedArgument,
range,
rangeForOffsets,
Expand All @@ -33,7 +34,10 @@ import type {
} from "./javascriptStaticAnalysisState.js";
import {
resolveSemanticBindingState,
semanticResolutionBlocked,
semanticStableInitializer,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import {
isUnmodifiedGlobalReference,
Expand All @@ -57,7 +61,7 @@ export const inspectCall = (
const first = stringValue(node.arguments[0]);
const module = context.accumulator.moduleRangeIndex.find(node.start);
const moduleRequireName = module?.requireName ?? null;
const requireCall = requireCallSpecifier(node, name, moduleRequireName);
const requireCall = requireCallSpecifier(node, name, moduleRequireName, bindings);
if (requireCall !== null)
addReference(context, {
node,
Expand Down Expand Up @@ -214,9 +218,18 @@ const requireCallSpecifier = (
node: JavaScriptCallLike,
name: string,
moduleRequireName: string | null,
bindings: JavaScriptSemanticAnalysisState,
): { readonly specifier: string | undefined } | null => {
if (name === "require" || name.endsWith(".require"))
if (name === "require" || name.endsWith(".require")) {
// A bare `require` is the CommonJS loader only while its callee
// identifier is lexically unbound. A local parameter, hoisted function,
// catch binding or block binding shadows the wrapper loader, so the call
// cannot be a module load. The binding index is consulted at the call
// site; no file-wide walk is needed.
if (name === "require" && isShadowedRequireCallee(node, bindings))
return null;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
return { specifier: argumentValue(node.arguments[0]) };
}
if (name === "__webpack_require__" || name === moduleRequireName)
return { specifier: argumentValue(node.arguments[0]) };
if (
Expand All @@ -227,6 +240,95 @@ const requireCallSpecifier = (
return null;
};

/**
* Decide whether a bare `require(...)` / `require?.(...)` callee resolves to
* a local binding that shadows the CommonJS loader. Unbound identifiers keep
* the loader; only a binding that provably retains or aliases the loader
* (uninitialized top-level `var require`, direct `createRequire` alias)
* preserves the candidate.
*/
const isShadowedRequireCallee = (
node: JavaScriptCallLike,
bindings: JavaScriptSemanticAnalysisState,
): boolean => {
const callee = unwrapJavaScriptExpression(node.callee).node;
if (!t.isIdentifier(callee, { name: "require" })) return false;
// An incomplete scope cannot prove shadowing; keep the candidate.
if (semanticResolutionBlocked(bindings, callee, "require")) return false;
const binding = resolveSemanticBindingState(bindings, callee, "require");
if (binding === undefined) return false;
return !isRetainedRequireLoader(binding, node, bindings);
};

const isRetainedRequireLoader = (
binding: JavaScriptSemanticBindingState,
node: JavaScriptCallLike,
state: JavaScriptSemanticAnalysisState,
): boolean =>
isDirectCreateRequireBinding(binding, state) ||
isUninitializedTopLevelVarRequire(binding, state, node);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

/**
* `const require = createRequire(import.meta.url)` aliases the ESM loader.
* Erased TypeScript syntax around the initializer (e.g. `as NodeRequire`)
* is unwrapped before matching, mirroring the reference-source classifier.
*/
const isDirectCreateRequireBinding = (
binding: JavaScriptSemanticBindingState,
state: JavaScriptSemanticAnalysisState,
): boolean => {
const initializer = semanticStableInitializer(binding, state);
if (initializer === undefined) return false;
const init = unwrapJavaScriptExpression(initializer.node).node;
if (!t.isCallExpression(init)) return false;
const callee = unwrapJavaScriptExpression(init.callee).node;
if (!t.isIdentifier(callee)) return false;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
const calleeBinding = resolveSemanticBindingState(state, callee, callee.name);
const [origin] = calleeBinding?.directOrigins ?? [];
return (
calleeBinding?.runtimeImport === true &&
origin !== undefined &&
(origin.specifier === "node:module" || origin.specifier === "module") &&
origin.importedPath.length === 1 &&
origin.importedPath[0] === "createRequire" &&
isImportMetaUrl(init.arguments[0])
);
};

/**
* An uninitialized top-level `var require` retains Node's wrapper loader, but
* a later write replaces it. Write handling is positional: a call that runs
* before every write still sees the loader (this covers loader calls before
* reassignment and eagerly evaluated computed method keys without classifying
* enclosing functions as deferred). A call at or after a write is shadowed.
*/
const isUninitializedTopLevelVarRequire = (
binding: JavaScriptSemanticBindingState,
state: JavaScriptSemanticAnalysisState,
node: JavaScriptCallLike,
): boolean => {
const assignmentCount = binding.definitions.filter(
({ kind }) => kind === "assignment",
).length;
if (
binding.kind !== "variable" ||
!binding.definitions.every(
({ kind }) => kind === "variable" || kind === "assignment",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Repeated writes slow analysis

Every bare require call now scans the binding’s full definitions array with filter and every, then potentially scans it again with some. Each assignment adds another definition, so files with many calls and writes pay calls × writes work, plus a temporary array per call.

Cache the binding’s declaration facts and index its write positions once instead of rescanning them.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/domain/javascript/javascriptStaticAnalysisCalls.ts
Line: 310-316

Comment:
**Repeated writes slow analysis**

Every bare `require` call now scans the binding’s full `definitions` array with `filter` and `every`, then potentially scans it again with `some`. Each assignment adds another definition, so files with many calls and writes pay calls × writes work, plus a temporary array per call.

Cache the binding’s declaration facts and index its write positions once instead of rescanning them.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

) ||
// A declaration initializer means the loader was replaced at declaration.
binding.initializers.length !== assignmentCount ||
state.scopesById.get(binding.scopeId)?.kind !== "program"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
)
return false;
const { line, column } = range(node).start;
return !binding.definitions.some(
({ kind, location }) =>
kind === "assignment" &&
(location.start.line < line ||
(location.start.line === line && location.start.column <= column)),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
);
};

const chunkLoadSpecifier = (
node: JavaScriptCallLike,
name: string,
Expand Down
106 changes: 106 additions & 0 deletions src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";

import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";

/**
* Regression tests for https://github.com/morluto/rea/issues/2890: the
* application graph must not invent a CommonJS `imports` edge for a `require`
* call whose callee resolves to a lexically shadowing local binding. The
* static findings are the producing boundary, so the tests assert on the
* recovered `require` references directly.
*/
const requireSpecifiers = (source: string): readonly (string | null)[] =>
analyzeJavaScriptStaticSource(source)
.references.filter((reference) => reference.kind === "require")
.map((reference) => reference.specifier);

describe("lexically shadowed require calls", () => {
it.each([
[
"function parameter",
`function run(require) {\n return require("./ghost.cjs");\n}\nconsole.log(run(() => "local"));`,
],
[
"optional call with function parameter",
`function run(require) {\n return require?.("./ghost.cjs");\n}`,
],
[
"destructured parameter",
`function load({ require }) { return require("./throwing.cjs"); }\nconsole.log(load({ require: () => "local" }));`,
],
[
"hoisted function declaration",
`function load() {\n console.log(require("./throwing.cjs"));\n function require() { return "local"; }\n}\nload();`,
],
[
"catch binding",
`try { throw () => "local"; }\ncatch (require) { console.log(require("./throwing.cjs")); }`,
],
[
"block-scoped binding",
`{ const require = () => "local";\n console.log(require("./throwing.cjs")); }`,
],
[
"initialized top-level var",
`var require = () => "local";\nconsole.log(require("./throwing.cjs"));`,
],
])("emits no require reference for a %s shadow", (_label, source) => {
expect(requireSpecifiers(source)).toEqual([]);
});

it.each([
[
"ordinary unbound call",
`const value = require("./real.cjs");`,
["./real.cjs"],
],
[
"optional unbound call",
`const value = require?.("./real.cjs");`,
["./real.cjs"],
],
[
"uninitialized top-level var retains the wrapper loader",
`var require;\nconsole.log(require("./loaded.cjs"));`,
["./loaded.cjs"],
],
[
"direct createRequire binding",
`import { createRequire } from "node:module";\nconst require = createRequire(import.meta.url);\nconst value = require("./dep.cjs");`,
["./dep.cjs"],
],
[
"createRequire binding behind erased TypeScript syntax",
`import { createRequire } from "node:module";\nconst require = createRequire(import.meta.url) as NodeRequire;\nconst value = require("./dep.cjs");`,
["./dep.cjs"],
],
[
"loader call before reassignment",
`require?.("./dep");\nrequire = localLoader;`,
["./dep"],
],
[
"eager computed method key before reassignment",
`const routes = { [require?.("./dep")]() {} };\nrequire = localLoader;`,
["./dep"],
],
])("keeps the require reference for %s", (_label, source, expected) => {
expect(requireSpecifiers(source)).toEqual(expected);
});

it("keeps only the genuine call in a mixed file", () => {
expect(
requireSpecifiers(
`console.log(require("./loaded.cjs"));\nfunction load(require) { return require("./throwing.cjs"); }\nconsole.log(load(() => "local"));`,
),
).toEqual(["./loaded.cjs"]);
});

it("keeps the loader call before a write but not the call after it", () => {
expect(
requireSpecifiers(
`var require;\nrequire("./before.cjs");\nrequire = localLoader;\nrequire("./after.cjs");`,
),
).toEqual(["./before.cjs"]);
});
});