From 259bb6acfbf3ea38a991fb6bae1dcb0a7b96698f Mon Sep 17 00:00:00 2001 From: rajanpanth <189964965+rajanpanth@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:44:34 +0100 Subject: [PATCH] fix(js): retain lazy CommonJS require dependencies (#2700) A require(...) written inside a function body (the canonical lazy-require idiom for breaking circular deps) produced no edge at all, while the identical require at module scope resolved as EXTRACTED imports_from/imports edges. walk_calls now feeds nested require declarations to the same _require_imports_js routine, attributing the edge to the enclosing callable; dynamic require(var) is still skipped and no bare local node is minted (#1077 scope guard). Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/extractors/engine.py | 14 +++++++++++--- tests/test_extract.py | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index f7247854..4dad535d 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -1861,7 +1861,7 @@ def _find_require_call(value_node): 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: +def _require_imports_js(node, source: bytes, importer_nid: str, stem: str, edges: list, str_path: str) -> bool: """Detect CommonJS require imports inside lexical_declaration / variable_declaration. Handles three patterns: @@ -1900,7 +1900,7 @@ def _require_imports_js(node, source: bytes, file_nid: str, stem: str, edges: li tgt_nid, resolved_path = resolved line = node.start_point[0] + 1 edge = { - "source": file_nid, + "source": importer_nid, "target": tgt_nid, "relation": "imports_from", "context": "import", @@ -1937,7 +1937,7 @@ def _require_imports_js(node, source: bytes, file_nid: str, stem: str, edges: li if target_stem is not None: for sym in sym_names: edges.append({ - "source": file_nid, + "source": importer_nid, "target": _make_id(target_stem, sym), "relation": "imports", "context": "import", @@ -4746,6 +4746,14 @@ def _extract_generic( walk_calls(child, caller_nid, receiver_types, closure_locals) return + # CommonJS imports are valid at any lexical depth. The module-level + # pass records top-level require() declarations; this pass owns function + # bodies, so detect lazy/cycle-breaking requires here and attribute the + # dependency to the enclosing callable rather than silently dropping it. + if (config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript") + and node.type in ("lexical_declaration", "variable_declaration")): + _require_imports_js(node, source, caller_nid, stem, edges, str_path) + if node.type in config.call_types: # JS/TS dynamic imports: await import('./foo.js') if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"): diff --git a/tests/test_extract.py b/tests/test_extract.py index 2dbda9f8..84a17a5b 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -776,6 +776,31 @@ def test_extract_js_member_require_emits_property_symbol(): assert _make_id(helpers_stem, "helperFn") in sym_targets +def test_extract_js_function_scoped_require_emits_import_edge(tmp_path): + """Lazy CommonJS requires belong to their enclosing function, not nowhere.""" + target = tmp_path / "target.js" + target.write_text("exports.helper = () => 42;\n", encoding="utf-8") + caller = tmp_path / "lazy.js" + caller.write_text( + "function useItLazily() {\n" + " const { helper } = require('./target');\n" + " return helper();\n" + "}\n", + encoding="utf-8", + ) + + result = extract([caller, target], cache_root=tmp_path, root=tmp_path, parallel=False) + labels = {node["id"]: node["label"] for node in result["nodes"]} + lazy_edges = [ + edge for edge in result["edges"] + if edge["relation"] == "imports_from" and "target" in edge["target"] + ] + + assert len(lazy_edges) == 1 + assert labels[lazy_edges[0]["source"]] == "useItLazily()" + assert lazy_edges[0]["confidence"] == "EXTRACTED" + + def test_extract_js_arrow_function_still_extracted(): """Regression: arrow functions in lexical_declaration must still produce nodes.""" from graphify.extract import extract_js