Repository navigation
JavaScript: calls through a CommonJS destructured require are never resolved (express: 63 call edges from 11,733 call sites) #3778
Description
Activity
Narrowed this down further, and the root cause is upstream of the call binding: the calling function often has no node at all.
lib/response.jscontains 41 function declarations by tree-sitter's count, butgraph.jsonhas 11 nodes for that file. The pattern separating them is which object is assigned to:// lib/utils.js:61 -> node IS created exports.normalizeType = function(type){ ... }; // lib/response.js:573 -> node is NOT created res.format = function(obj){ ... };
Same syntactic form (
<object>.<property> = function(){}), but only theexports.case produces a node. In express,res.*,req.*andapp.*account for most of the public API.That explains the call-edge ratio rather than it being a separate defect:
lib/response.js:587callsnormalizeType(key)from insideres.format, and sinceres.formathas no node there is nothing for acallsedge to originate from. Fixing the declaration forms would likely recover most of the missing edges on its own.Worth noting the information is all present and literal — the
importsedge fornormalizeTyperesolves correctly, so the resolver already knows where the symbol lives.As a check on that theory: synthesizing the missing declarations from tree-sitter spans and then re-resolving call sites by name takes express from 11 to 165
callsedges, and produces exactly the expectedres.format -> normalizeType()edge. Same treatment on other languages adds far less (Go's cobra: 0 synthesized nodes, since its declaration forms are fully captured), which is consistent with this being JS-specific rather than a general gap.I investigated the missing call edges in Express and other CommonJS codebases.
Rca
In Express, methods are attached to module objects before being exported (
res.format = function(obj) { ... }; module.exports = res).In
graphify/extractors/engine.py:_js_extra_walkonly capturedexports.x = fnand<Class>.prototype.x = fn. It intentionally ignored arbitraryobj.x = fnto prevent phantom god-nodes (Extraction noise: scope-blind identifier collision + over-eager markdown fragment nodes #1077).- Because
res.formatwas not recognized as a method node, its AST body was never appended tofunction_bodies. walk_callsnever walked the body ofres.format, meaning calls made inside it (such asnormalizeType(...)) were never emitted or resolved to their imported functions.
Solution
- Added
_js_find_exported_objects()to identify top-level identifiers exported via CommonJS/ESM (module.exports = res,exports = module.exports = res,module.exports = { res }, etc.). - In
_js_extra_walk, when a member function is assigned to an exported object (owner_name in exported_names), we emit the method node and append its body tofunction_bodiessowalk_callsextracts its internal call sites. - Arbitrary unexported objects (
const obj = {}; obj.whatever = () => 1;) remain ignored, preserving the Extraction noise: scope-blind identifier collision + over-eager markdown fragment nodes #1077 guard.
All JS unit tests and the reproduction case pass. opened the PR.
- added a commit that references this issue
on Oct 5, 2026 - added a commit that references this issue
on Oct 5, 2026 Resolved by #4121 (thanks @harshaygadekar), shipped in v0.9.77. Closing as fixed.
Summary
For CommonJS code, graphify resolves the import of a function but not calls through the imported binding. The result is that JavaScript projects get almost no
callsedges, while the same analysis on other languages produces one to two orders of magnitude more.Measurement
Same command on eight well-known repos (
graphify <path> --code-only, thencluster-only --no-label). Call sites counted by walking each file's tree-sitter AST for the language's own call node type; call edges counted fromgraph.json:callsedgesexpress's whole graph contains 11
callsand 52indirect_calledges across 404 nodes and 119 callable nodes.Concrete case
expressjs/expressat current HEAD:Everything here is literal: a literal require path, a literal property, and a plain identifier call.
In the resulting
graph.json, the nodelib_utils_normalizetypehas exactly two incoming edges:There is no
callsedge fromlib/response.jstonormalizeType(), even though the import was resolved correctly — so the resolver clearly knows where the symbol lives.Expected
A
callsedge from the calling function inlib/response.jstonormalizeType()inlib/utils.js.Why it matters
The import edge being present while the call edge is missing means the information needed to resolve the call is already available; only the call-site binding is not being made. Any consumer doing reachability or call-graph work on JavaScript gets a graph that is essentially call-free — a "no path found" result on a JS project currently means very little.
The
var x = require('./m').xdestructuring form is extremely common in CommonJS, so this is likely not an edge case.Environment
0.9.65(PyPIgraphifyy)expressjs/expressdefault branch, shallow clone 2026-09-22