Skip to content

JavaScript: calls through a CommonJS destructured require are never resolved (express: 63 call edges from 11,733 call sites) #3778

Description

@BloodyBeard

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 calls edges, 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, then cluster-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 from graph.json:

Repo Language Call sites calls edges ratio
JamesNK/Newtonsoft.Json C# 48,070 13,487 28%
google/gson Java 23,468 5,337 23%
spf13/cobra Go 4,430 934 21%
BurntSushi/ripgrep Rust 17,103 3,270 19%
psf/requests Python 2,687 453 17%
gin-gonic/gin Go 9,414 1,346 14%
pallets/flask Python 3,963 386 10%
expressjs/express JavaScript 11,733 63 1%

express's whole graph contains 11 calls and 52 indirect_call edges across 404 nodes and 119 callable nodes.

Concrete case

expressjs/express at current HEAD:

// lib/utils.js:61
exports.normalizeType = function(type){ ... };

// lib/response.js:27
var normalizeType = require('./utils').normalizeType;

// lib/response.js:587
this.set('Content-Type', normalizeType(key).value);

Everything here is literal: a literal require path, a literal property, and a plain identifier call.

In the resulting graph.json, the node lib_utils_normalizetype has exactly two incoming edges:

response.js --imports--> normalizeType()
lib/utils.js --contains--> normalizeType()

There is no calls edge from lib/response.js to normalizeType(), even though the import was resolved correctly — so the resolver clearly knows where the symbol lives.

Expected

A calls edge from the calling function in lib/response.js to normalizeType() in lib/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').x destructuring form is extremely common in CommonJS, so this is likely not an edge case.

Environment

  • graphify 0.9.65 (PyPI graphifyy)
  • Python 3.14, Windows 10
  • expressjs/express default branch, shallow clone 2026-09-22

Activity

  1. BloodyBeard commented on Sep 23, 2026

    @BloodyBeard
    Author

    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.js contains 41 function declarations by tree-sitter's count, but graph.json has 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 the exports. case produces a node. In express, res.*, req.* and app.* account for most of the public API.

    That explains the call-edge ratio rather than it being a separate defect: lib/response.js:587 calls normalizeType(key) from inside res.format, and since res.format has no node there is nothing for a calls edge 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 imports edge for normalizeType resolves 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 calls edges, and produces exactly the expected res.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.

  2. harshaygadekar commented on Oct 5, 2026

    @harshaygadekar
    Contributor

    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:

    1. _js_extra_walk only captured exports.x = fn and <Class>.prototype.x = fn. It intentionally ignored arbitrary obj.x = fn to prevent phantom god-nodes (Extraction noise: scope-blind identifier collision + over-eager markdown fragment nodes #1077).
    2. Because res.format was not recognized as a method node, its AST body was never appended to function_bodies.
    3. walk_calls never walked the body of res.format, meaning calls made inside it (such as normalizeType(...)) were never emitted or resolved to their imported functions.

    Solution

    1. 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.).
    2. 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 to function_bodies so walk_calls extracts its internal call sites.
    3. 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.

  3. safishamsi commented on Oct 5, 2026

    @safishamsi
    Member

    Resolved by #4121 (thanks @harshaygadekar), shipped in v0.9.77. Closing as fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions