fix: extract CommonJS require() imports as EXTRACTED edges

The JS/TS extractor only handled ES `import` statements; CommonJS
`require()` calls produced no import edges. Downstream, the call-graph
pass could not resolve which symbols belonged to which file, so every
cross-file call in CJS Node.js codebases was downgraded to INFERRED
even when the binding was a top-of-file destructured require.

Adds three patterns to `_js_extra_walk` via a new `_require_imports_js`
helper:

  const { foo, bar: alias } = require('./mod')   -> imports_from + per-symbol imports
  const mod = require('./mod')                   -> imports_from
  const x = require('./mod').y                   -> imports_from + symbol edge for y

Refactors path-resolution out of `_import_js` into
`_resolve_js_import_target` so ES imports and CJS requires share the
relative / tsconfig-alias / bare-module logic.

Tested in a 92-file CJS Node.js orchestrator codebase: confirmed all
five previously INFERRED `runExecute -> {loadFoundation,
validateDispatchConfig, fetchSymphonyIssues, listSymphonyWorktrees,
workspacePathForIssue}` edges resolve to real top-of-file destructured
requires, so downstream calls would now be EXTRACTED instead of
INFERRED.
This commit is contained in:
Mani Saint-Victor, MD
2026-05-06 09:41:56 -04:00
parent 441ac9fc38
commit c902ae952b
4 changed files with 215 additions and 48 deletions
+1
View File
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## 0.7.8 (2026-05-06)
- Fix: CommonJS `require()` imports now extracted from JS/TS -- `const { foo } = require('./mod')`, `const m = require('./mod')`, and `const x = require('./mod').y` all emit EXTRACTED `imports_from` (and per-symbol `imports`) edges. Previously CJS-only Node.js codebases produced AST graphs missing every import edge, which downgraded all cross-file calls to INFERRED.
- Feat: Gemini and OpenAI backends -- `graphify extract ./docs --backend gemini` (GEMINI_API_KEY / GOOGLE_API_KEY) or `--backend openai` (OPENAI_API_KEY); `[gemini]` and `[openai]` extras added (#735)
- Feat: Groovy and Spock support -- `.groovy` and `.gradle` extracted via tree-sitter-groovy; Spock spec files (`def "feature"()` syntax) handled via regex fallback (#732)
- Feat: Luau support -- `.luau` (Roblox Luau) added to code extraction using the Lua tree-sitter parser (#745)
+156 -48
View File
@@ -333,43 +333,40 @@ def _import_python(node, source: bytes, file_nid: str, stem: str, edges: list, s
})
def _resolve_js_import_target(raw: str, str_path: str) -> "tuple[str, Path | None] | None":
"""Resolve a JS/TS import path string to (target_nid, resolved_path).
Handles relative paths, tsconfig path aliases, and bare/scoped imports.
Returns None if `raw` is empty.
"""
if not raw:
return None
if raw.startswith("."):
resolved = Path(os.path.normpath(Path(str_path).parent / raw))
resolved = _resolve_js_module_path(resolved)
return _make_id(str(resolved)), resolved
aliases = _load_tsconfig_aliases(Path(str_path).parent)
for alias_prefix, alias_base in aliases.items():
if raw == alias_prefix or raw.startswith(alias_prefix + "/"):
rest = raw[len(alias_prefix):].lstrip("/")
resolved_alias = Path(os.path.normpath(Path(alias_base) / rest))
resolved_alias = _resolve_js_module_path(resolved_alias)
return _make_id(str(resolved_alias)), resolved_alias
module_name = raw.split("/")[-1]
if not module_name:
return None
return _make_id(module_name), None
def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None:
resolved_path: "Path | None" = None
for child in node.children:
if child.type == "string":
raw = _read_text(child, source).strip("'\"` ")
if not raw:
resolved = _resolve_js_import_target(raw, str_path)
if resolved is None:
break
if raw.startswith("."):
# Relative import - resolve to full path so IDs match file node IDs
# normpath removes ".." segments so the ID matches the target file's own node ID
resolved = Path(os.path.normpath(Path(str_path).parent / raw))
# TS / SvelteKit resolver: try .ts/.tsx/.svelte/.svelte.ts/index.{ts,…}
# so bare-path and Svelte-5-rune imports land on the right node id (#716)
resolved = _resolve_js_module_path(resolved)
tgt_nid = _make_id(str(resolved))
resolved_path = resolved
else:
# Check tsconfig.json path aliases (e.g. "@/" → "src/") before treating as external (#575)
aliases = _load_tsconfig_aliases(Path(str_path).parent)
resolved_alias = None
for alias_prefix, alias_base in aliases.items():
if raw == alias_prefix or raw.startswith(alias_prefix + "/"):
rest = raw[len(alias_prefix):].lstrip("/")
resolved_alias = Path(os.path.normpath(Path(alias_base) / rest))
break
if resolved_alias is not None:
# Same resolver fixups as the relative branch — alias targets
# are equally likely to be bare paths / .svelte.ts / index.ts (#716)
resolved_alias = _resolve_js_module_path(resolved_alias)
tgt_nid = _make_id(str(resolved_alias))
resolved_path = resolved_alias
else:
# Bare/scoped import (node_modules) - use last segment; dropped as external
module_name = raw.split("/")[-1]
if not module_name:
break
tgt_nid = _make_id(module_name)
tgt_nid, resolved_path = resolved
edges.append({
"source": file_nid,
"target": tgt_nid,
@@ -680,26 +677,137 @@ def _get_cpp_func_name(node, source: bytes) -> str | None:
# ── JS/TS extra walk for arrow functions ──────────────────────────────────────
def _find_require_call(value_node):
"""Return the call_expression node if `value_node` is a `require(...)` call
or `require(...).x` member access. Otherwise None."""
if value_node is None:
return None
if value_node.type == "call_expression":
fn = value_node.child_by_field_name("function")
if fn is not None and fn.type == "identifier":
return value_node
if value_node.type == "member_expression":
obj = value_node.child_by_field_name("object")
return _find_require_call(obj)
return None
def _require_imports_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> bool:
"""Detect CommonJS require imports inside lexical_declaration / variable_declaration.
Handles three patterns:
const { foo, bar } = require('./mod') file mod (imports_from), file foo, file bar
const mod = require('./mod') file mod (imports_from)
const x = require('./mod').y file mod (imports_from), file y
Returns True if any require import was found.
"""
if node.type not in ("lexical_declaration", "variable_declaration"):
return False
found = False
for child in node.children:
if child.type != "variable_declarator":
continue
value = child.child_by_field_name("value")
call = _find_require_call(value)
if call is None:
continue
fn = call.child_by_field_name("function")
if fn is None or _read_text(fn, source) != "require":
continue
args = call.child_by_field_name("arguments")
if args is None:
continue
raw = None
for arg in args.children:
if arg.type == "string":
raw = _read_text(arg, source).strip("'\"` ")
break
if not raw:
continue
resolved = _resolve_js_import_target(raw, str_path)
if resolved is None:
continue
tgt_nid, resolved_path = resolved
line = node.start_point[0] + 1
edges.append({
"source": file_nid,
"target": tgt_nid,
"relation": "imports_from",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
found = True
# Symbol-level edges for destructured / accessor binders.
target_stem = _file_stem(resolved_path) if resolved_path is not None else None
name_node = child.child_by_field_name("name")
sym_names: list[str] = []
if name_node is not None and name_node.type == "object_pattern":
# `const { a, b: alias } = require('./m')` — emit edges for each property key
for prop in name_node.children:
if prop.type == "shorthand_property_identifier_pattern":
sym_names.append(_read_text(prop, source))
elif prop.type == "pair_pattern":
key = prop.child_by_field_name("key")
if key is not None:
sym_names.append(_read_text(key, source))
elif value is not None and value.type == "member_expression":
# `const x = require('./m').y` — symbol is the property accessed
prop = value.child_by_field_name("property")
if prop is not None:
sym_names.append(_read_text(prop, source))
if target_stem is not None:
for sym in sym_names:
edges.append({
"source": file_nid,
"target": _make_id(target_stem, sym),
"relation": "imports",
"context": "import",
"confidence": "EXTRACTED",
"source_file": str_path,
"source_location": f"L{line}",
"weight": 1.0,
})
return found
def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
nodes: list, edges: list, seen_ids: set, function_bodies: list,
parent_class_nid: str | None, add_node_fn, add_edge_fn) -> bool:
"""Handle lexical_declaration (arrow functions) for JS/TS. Returns True if handled."""
if node.type == "lexical_declaration":
for child in node.children:
if child.type == "variable_declarator":
value = child.child_by_field_name("value")
if value and value.type == "arrow_function":
name_node = child.child_by_field_name("name")
if name_node:
func_name = _read_text(name_node, source)
line = child.start_point[0] + 1
func_nid = _make_id(stem, func_name)
add_node_fn(func_nid, f"{func_name}()", line)
add_edge_fn(file_nid, func_nid, "contains", line)
body = value.child_by_field_name("body")
if body:
function_bodies.append((func_nid, body))
return True
"""Handle lexical_declaration (arrow functions, CJS requires) for JS/TS.
Returns True if handled (caller should not descend further).
"""
if node.type in ("lexical_declaration", "variable_declaration"):
# CJS require imports — emit edges, do not block other lexical_declaration handling
require_found = _require_imports_js(node, source, file_nid, stem, edges, str_path)
# Arrow function declarations (existing behavior, lexical_declaration only)
arrow_found = False
if node.type == "lexical_declaration":
for child in node.children:
if child.type == "variable_declarator":
value = child.child_by_field_name("value")
if value and value.type == "arrow_function":
name_node = child.child_by_field_name("name")
if name_node:
func_name = _read_text(name_node, source)
line = child.start_point[0] + 1
func_nid = _make_id(stem, func_name)
add_node_fn(func_nid, f"{func_name}()", line)
add_edge_fn(file_nid, func_nid, "contains", line)
body = value.child_by_field_name("body")
if body:
function_bodies.append((func_nid, body))
arrow_found = True
if arrow_found:
return True
if require_found:
return True
return False
+12
View File
@@ -0,0 +1,12 @@
const { loadFoundation, validateConfig } = require('./foundation');
const utils = require('./utils');
const helper = require('./helpers').helperFn;
function runDispatch() {
const cfg = loadFoundation({});
validateConfig(cfg);
utils.log('go');
helper();
}
module.exports = { runDispatch };
+46
View File
@@ -194,3 +194,49 @@ def test_cross_file_calls_skip_ambiguous_duplicate_labels(tmp_path):
nodes[e["source"]]["label"] == "run()" and nodes[e["target"]]["label"] == "log()"
for e in calls
)
def test_extract_js_destructured_require_imports_from():
"""`const { foo } = require('./mod')` must emit imports_from to the resolved module path."""
from graphify.extract import extract_js
result = extract_js(FIXTURES / "cjs_require.js")
imports_from = [e for e in result["edges"] if e["relation"] == "imports_from"]
targets = [e["target"] for e in imports_from]
# Must resolve relative require() targets to file ids so they connect across the corpus
assert any("foundation" in t for t in targets), f"No foundation import_from: {targets}"
assert any("utils" in t for t in targets), f"No utils import_from: {targets}"
assert any("helpers" in t for t in targets), f"No helpers import_from: {targets}"
for e in imports_from:
assert e["confidence"] == "EXTRACTED"
def test_extract_js_destructured_require_named_symbols():
"""Destructured CJS requires must emit symbol-level `imports` edges per binder."""
from graphify.extract import extract_js, _make_id, _file_stem
result = extract_js(FIXTURES / "cjs_require.js")
sym_targets = [e["target"] for e in result["edges"] if e["relation"] == "imports"]
foundation_stem = _file_stem(FIXTURES / "foundation.js")
assert _make_id(foundation_stem, "loadFoundation") in sym_targets
assert _make_id(foundation_stem, "validateConfig") in sym_targets
def test_extract_js_member_require_emits_property_symbol():
"""`const x = require('./m').y` must emit symbol edge for `y`."""
from graphify.extract import extract_js, _make_id, _file_stem
result = extract_js(FIXTURES / "cjs_require.js")
sym_targets = [e["target"] for e in result["edges"] if e["relation"] == "imports"]
helpers_stem = _file_stem(FIXTURES / "helpers.js")
assert _make_id(helpers_stem, "helperFn") in sym_targets
def test_extract_js_arrow_function_still_extracted():
"""Regression: arrow functions in lexical_declaration must still produce nodes."""
from graphify.extract import extract_js
arrow_fixture = FIXTURES / "_arrow_only.js"
arrow_fixture.write_text("const greet = () => console.log('hi');\n")
try:
result = extract_js(arrow_fixture)
labels = [n["label"] for n in result["nodes"]]
assert "greet()" in labels
finally:
arrow_fixture.unlink()