Repository navigation
fix(graph): skip import edges for lexically shadowed require (#2890) - #2989
hussnainahmedd wants to merge 27 commits into
Conversation
Do not emit a CommonJS import edge when the callee of a bare require call resolves to a local binding (parameter, hoisted function, catch binding or block binding). The lexical binding index is consulted at the call site, so no file-wide AST walk is needed. Unbound require, an uninitialized top-level var require, and direct createRequire(import.meta.url) aliases keep their edges; write handling is positional so loader calls before a reassignment (including eagerly evaluated computed method keys) are preserved.
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
|
| const assignmentCount = binding.definitions.filter( | ||
| ({ kind }) => kind === "assignment", | ||
| ).length; | ||
| if ( | ||
| binding.kind !== "variable" || | ||
| !binding.definitions.every( | ||
| ({ kind }) => kind === "variable" || kind === "assignment", |
There was a problem hiding this comment.
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!
- Preserve calls resolving to a bundle factory's loader binding when the
factory names its parameter require (e.g. webpack (module, exports,
require)); nested bindings with the same name stay shadowed.
- Restrict the uninitialized top-level wrapper-loader exception to var:
record the declaration kind in the binding index so let require no
longer invents a dependency.
- Treat a write as taking effect at the end of its assignment expression,
so a call on the right-hand side (require = require("./dep.cjs"))
still sees the loader; writes inside a function body only shadow calls
in that same body.
- Accept createRequire through a module namespace import
(ns.createRequire) by resolving its imported origin.
- Cache per-binding declaration facts instead of rescanning definitions
for every bare require call.
|
Thanks for the thorough review — all five findings are addressed in 4c4c273: Bundle dependencies disappear — the factory's loader scope id is now recorded in the module range index, so a bare Local calls still invent dependencies — the binding index now records the Assignment hides its own dependency — a write now takes effect at the end of its assignment expression rather than at the target's position, so Namespace loaders lose dependencies — Repeated writes slow analysis — declaration facts and write positions are computed once per binding (WeakMap cache) instead of rescanning definitions for every bare Tests: extended Also updated the branch — it's now current with |
Verify the factory binding still holds the loader parameter before exempting it, require an empty projection for direct createRequire aliases, reject rest-element copies as retained loaders, and model for...of/for...in writes at body entry.
|
Addressed all four greptile P1 findings in
Added 11 regression tests to |
…more createRequire forms The bundle-factory loader-parameter gate now treats an uninitialized var redeclaration as loader-retaining (var hoists without touching the parameter value) instead of dropping the dependency, and applies the existing write-order analysis to assignments: a call at or after an effective write in the same function body is shadowed, earlier calls keep the loader. Also fixed nearestFunctionNode to walk from the node's parent so a write whose RHS is itself a function is attributed to the enclosing body. The createRequire alias gate now accepts the default-import form (import mod from 'node:module'; mod.createRequire(...)) — ESM import bindings are read-only, so the default export's identity is as stable as the namespace form — and file-identity arguments (fileURLToPath(import.meta.url), __filename) via the shared isFileIdentity predicate. Regression tests added for each case; full javascript domain suite green.
|
Thanks for the review — verified all three P1s against the code with probe tests, and all three were valid:
Added a regression test per case. Full |
…nd rest copies nearestFunctionNode now skips a computed method key, which executes in the enclosing scope rather than inside the method it names, so a key call after a factory write is correctly treated as shadowed. isLoaderRetainingParameterBinding now rejects rest-element copies in referenceInitializers, mirroring the top-level loader path: a rest copy replaces the loader parameter. isDirectCreateRequireBinding and the uninitialized-var loader check now model declaration initializers as positional writes instead of rejecting the binding file-wide: calls before a later write keep their dependency, calls at or after it are shadowed.
|
Thanks for the three P1s — I verified each one with a failing probe first, and all three were valid. Fixed in 84ea39f with regression tests:
Validation: full |
| return writes.some( | ||
| (write) => | ||
| (write.endLine < line || | ||
| (write.endLine === line && write.endColumn <= column)) && | ||
| (write.functionNode === null || write.functionNode === callFunction), |
There was a problem hiding this comment.
Skipped writes hide real dependencies
requireWriteShadowsCall treats a skipped write as proof that the loader was replaced. In var require; if (false) require = localLoader; require("./dep.cjs");, the call still uses Node's loader, but the earlier assignment causes its dependency to disappear. The same problem affects writes in empty loops.
Check whether the write must run before the call, not just whether it appears earlier.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/domain/javascript/javascriptStaticAnalysisCalls.ts
Line: 564-568
Comment:
**Skipped writes hide real dependencies**
`requireWriteShadowsCall` treats a skipped write as proof that the loader was replaced. In `var require; if (false) require = localLoader; require("./dep.cjs");`, the call still uses Node's loader, but the earlier assignment causes its dependency to disappear. The same problem affects writes in empty loops.
Check whether the write must run before the call, not just whether it appears earlier.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.- A write inside an if branch, loop body, switch case, or try block only shadows a call in the same branch after it; a skipped write (e.g. if (false) require = localLoader) no longer hides a real dependency. - A createRequire alias only preserves calls at or after its establishing write in the same straight-line scope; calls before installation can no longer invent dependencies. - Instance class-field initializers are deferred work scoped to their field, so a never-instantiated class no longer hides a later import edge. Static fields, computed keys, and decorators keep declaration-time behavior.
|
Good catch — fixed. A write in an instance field initializer was scoped to its own field node, so a call in a later field of the same class never saw it, even though field initializers run in textual order during construction.
Added regression tests for the cross-field case plus pins for field order, per-class scoping, and method calls. The full |
The direct-alias gate only considered the first initializer, so `let require = () => "local"; require = createRequire(import.meta.url);` dropped the real dependency. The establishing write is now the last createRequire-alias write that takes effect before the call; writes after it still shadow, writes before it were already overwritten. Regression tests: restored-loader keeps the import, re-shadowed stays dropped, calls on either side of a restore both keep theirs.
|
Good catch — fixed. The gate now treats the last |
| const establishing = binding.initializers | ||
| .filter( | ||
| (initializer) => | ||
| isDirectAliasWrite(initializer) && !callBeforeWrite(initializer.node), | ||
| ) | ||
| .at(-1); |
There was a problem hiding this comment.
Skipped assignments invent imports
A skipped createRequire assignment can turn a local call into a dependency. For example, import { createRequire } from "node:module"; let require = () => "local"; if (false) require = createRequire(import.meta.url); require("./ghost.cjs"); selects the assignment as the establishing write even though it never runs.
conditionalInitializers only marks conditional var declarations, so it does not reject this assignment. Check that the establishing write runs before the call, not just that it appears earlier.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/domain/javascript/javascriptStaticAnalysisCalls.ts
Line: 399-404
Comment:
**Skipped assignments invent imports**
A skipped `createRequire` assignment can turn a local call into a dependency. For example, `import { createRequire } from "node:module"; let require = () => "local"; if (false) require = createRequire(import.meta.url); require("./ghost.cjs");` selects the assignment as the establishing write even though it never runs.
`conditionalInitializers` only marks conditional `var` declarations, so it does not reject this assignment. Check that the establishing write runs before the call, not just that it appears earlier.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.…osition A conditional createRequire assignment (e.g. inside an if) could be selected as the establishing write even though it may never execute, inventing an import edge for a local call. The establishing write must now provably run before the call (branch alignment), covering conditional assignments that conditionalInitializers does not mark. binding.initializers is also not in execution order (declaration initializers precede assignment writes), so later replacement writes are now found by execution position instead of array index. Regression tests: conditional createRequire write invents no dependency; var declaration initializer after the createRequire write shadows it.
|
Both P1s verified true — reproduced each with a failing probe before fixing:
Fixed in |
| if (t.isFunction(current) && !isComputedKeyOf(child, current)) | ||
| return current; | ||
| if (isDeferredInstanceFieldValue(child, current)) | ||
| return enclosingClassNode(current, state) ?? current; |
There was a problem hiding this comment.
Static class fields and computed keys do not run in source order. In var require; class C { static value = (require = () => "local"); [require("./real.cjs")]() {} }, the computed key runs before the static field replaces the loader. Both are assigned the same enclosing scope here, so the earlier-looking write drops the real dependency. Account for class evaluation order before comparing write positions.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/domain/javascript/javascriptStaticAnalysisCalls.ts
Line: 731-734
Comment:
**Class key imports disappear**
Static class fields and computed keys do not run in source order. In `var require; class C { static value = (require = () => "local"); [require("./real.cjs")]() {} }`, the computed key runs before the static field replaces the loader. Both are assigned the same enclosing scope here, so the earlier-looking write drops the real dependency. Account for class evaluation order before comparing write positions.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.|
Both P1s verified as real bugs against the runtime and fixed — thanks for the sharp catches. Loop initializers invent imports: a classic Class key imports disappear: verified against Node that computed method keys evaluate before static field initializers and static blocks run, so source order can't decide shadowing across those slots. Verification: 7 new regression tests (both reported snippets plus the mirrored orderings and a for-update guard), targeted suite 72/72, full |
A do...while body always runs once, so a write inside it provably runs before any call after the loop. writeMustRunBeforeCall no longer treats the loop as a conditional construct for a write in its own body, matching the existing for-initializer exception. Other loops (while, classic for bodies, for...of/for...in) may still skip their body and stay conditional.
|
Good catch — verified true with a failing probe first: Fixed by no longer treating a Added 5 regression cases: a do-while body write shadowing a later call, a looping do-while, a call in the loop test — plus loader-side counterparts (do-while inside a skipped Fix commit: 68cb6c6. Re: inline comment 4267079662. |
|
Thanks — verified this one. A Now the branch-alignment check ( Added 4 regression tests to the shadowed-require suite (81/81 green); full |
Use shared lexical bindings and recovered bundler identities for require and chunk references. Preserve uncertain writes as unresolved evidence; remove name-only loader aliases and repeated definition scans. Reviewed-PR: #2989 Co-authored-by: Hussnain Ahmad <ha7886899@gmail.com>
|
Good catch — verified true with a failing probe first: The root cause: Fixed Greptile Review will re-run on the updated branch. |
|
Superseded by |
Problem
analyze-javascript-application(CLI and stdio MCP) emits a resolved, high-confidenceimportsedge for arequire(...)call even whenrequireresolves to a local binding — e.g. a function parameter, a hoisted function declaration, a catch binding, or a block-scoped binding. That is a confident-looking false fact in the evidence graph: lexical binding already establishes the call is not the CommonJS loader.Minimal repro from the issue (still failing on current
main):Node prints
local, but REA returns animportsedge (kind: "require",resolution_status: "resolved").Root cause:
inspectCallalready receives the lexical binding index (JavaScriptSemanticAnalysisState), butrequireCallSpecifierinsrc/domain/javascript/javascriptStaticAnalysisCalls.tsrecognizes the barerequirename syntactically without consulting it.JavaScriptArtifactGraphFindingsthen resolves the candidate specifier to an inventoried file and emits the edge.Fix
requireCallSpecifiernow consults the binding index at the call site before emitting arequirereference for a barerequire/require?.()callee:var/let/const, import binding) → no reference, no edge.var require→ reference kept (retains the wrapper loader, per the issue spec).createRequire(import.meta.url)alias (const/let, incl. behind erased TS syntax likeas NodeRequire, incl. aliased imports) → reference kept.requirestill sees the loader, sorequire?.("./dep"); require = localLoaderand eagerly evaluated computed method keys ({ [require?.("./dep")]() {} }) keep their edges. A call at/after a write is treated as shadowed.The check is local to the call site (
resolveSemanticBindingState, O(scope depth)); no file-wide AST walk is introduced. The gate applies only to the barerequirename —__webpack_require__, module-scoped require names, and bundle-factory loaders are untouched.Verification
src/domain/javascript/javascriptStaticAnalysisShadowedRequire.test.ts(16 tests): the issue repro (plain + optional call), destructured param, hoisted function, catch, block and initialized-var shadows emit no reference; unbound, optional-unbound, uninitialized top-levelvar,createRequire(plain +as-cast + aliased import) keep theirs; loader-before-reassignment, computed-method-key, mixed-file, and call-before-write/call-after-write cases behave as specified. All 16 pass.src/domain/javascripttests — 1289 passed, 0 failed (one fast-check property suite excluded, see below);tests/boundary/filesystem/referenceSourceShadowedRequire.test.js— pass (reference-source path unaffected);tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.js— 6 passed (full application-graph pipeline).npm run verify:module-boundaries— pass ("Checked resolved module boundaries in 1758 source files").tsc --noEmit: 823 errors before and after the change — identical count, all pre-existing (corrupted.d.tsfiles innode_modulesfrom a disk-fullnpm ciin this sandbox); zero errors in the touched files.Environment note: this sandbox cannot spawn vitest workers (SIGBUS, also breaks the repo's own
verify:test-discoveryscript and theoxfmtpre-commit hook), and ESLint is not installed here. Tests were executed by compiling with the repo's TypeScript and running the compiled suites through a small node-based vitest-API shim. CI should run the standard gates.Relation to #2892
This addresses every review point left on the closed PR #2892:
collectSemanticReferences+ two full traversals synchronously inside the analysis steps. This fix resolves scope per call site via the already-computed binding index — no new walks, and files withoutrequirecalls pay nothing beyond a string comparison.requireLoaderWasWrittenflag dropped every call once any write existed. Writes are now compared positionally against each call, sorequire?.("./dep"); require = localLoaderkeeps its edge.{ [require?.("./dep")]() {} }correctly without walking ancestors.createRequireinitializers (Codex): the initializer is passed throughunwrapJavaScriptExpression(unwrapsTSAsExpressionand friends) before matching, soconst require = createRequire(import.meta.url) as NodeRequireis recognized.