mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-21 13:05:41 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Vendored
+12
@@ -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 };
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user