diff --git a/CHANGELOG.md b/CHANGELOG.md index 51196b11..9d122c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/graphify/extract.py b/graphify/extract.py index 43c2bf61..49505daa 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 diff --git a/tests/fixtures/cjs_require.js b/tests/fixtures/cjs_require.js new file mode 100644 index 00000000..92650b1d --- /dev/null +++ b/tests/fixtures/cjs_require.js @@ -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 }; diff --git a/tests/test_extract.py b/tests/test_extract.py index be074527..880b4a10 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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()