diff --git a/graphify/extract.py b/graphify/extract.py index dfebb195..5aeef585 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1787,12 +1787,23 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p return resolved_path: "Path | None" = None + module_string = None for child in node.children: if child.type == "string": - raw = _read_text(child, source).strip("'\"` ") - resolved = _resolve_js_import_target(raw, str_path) - if resolved is None: - break + module_string = child + break + if child.type == "import_require_clause": + # TS import-equals form: `import x = require("./m")`. The module + # string sits inside the clause, not on the import_statement + # itself, so the direct-child scan above never sees it. + module_string = next( + (sub for sub in child.children if sub.type == "string"), None + ) + break + if module_string is not None: + raw = _read_text(module_string, source).strip("'\"` ") + resolved = _resolve_js_import_target(raw, str_path) + if resolved is not None: tgt_nid, resolved_path = resolved edges.append({ "source": file_nid, @@ -1804,7 +1815,6 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p "source_location": f"L{node.start_point[0] + 1}", "weight": 1.0, }) - break # Emit symbol-level edges for named imports/re-exports from local/aliased files. # e.g. `import { Foo, type Bar } from './bar'` → file → Foo, file → Bar (EXTRACTED) diff --git a/tests/test_ts_import_require.py b/tests/test_ts_import_require.py new file mode 100644 index 00000000..4612e6c2 --- /dev/null +++ b/tests/test_ts_import_require.py @@ -0,0 +1,111 @@ +"""Regression tests for the TypeScript import-equals form: `import x = require("./m")`. + +Before the fix, the module string of an import-equals declaration was invisible: +tree-sitter parses it as an `import_statement` whose string sits inside an +`import_require_clause`, not as a direct child of the statement — so the +direct-child string scan in `_import_js` never found it and the file produced +no `imports_from` edge at all (while the equivalent ESM `import * as x from +"./m"` did). The fix gives the import-equals form exact parity with the ESM +namespace import: one file-level `imports_from` edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import _file_node_id, _make_id, extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _has_edge(result: dict, source: str, target: str, relation: str = "imports_from") -> bool: + expected = (_file_node_id(Path(source)), _file_node_id(Path(target)), relation) + actual = { + (edge["source"], edge["target"], edge["relation"]) + for edge in result["edges"] + } + return expected in actual + + +def test_import_require_relative_emits_file_edge(tmp_path: Path): + target = _write(tmp_path / "src/lib/legacy.ts", "export function foo(): number { return 1 }\n") + importer = _write( + tmp_path / "src/lib/consumer.ts", + 'import legacy = require("./legacy");\nconst n = legacy.foo();\n', + ) + + result = extract([target, importer], cache_root=tmp_path) + + assert _has_edge(result, "src/lib/consumer.ts", "src/lib/legacy.ts") + + +def test_import_require_single_quotes(tmp_path: Path): + target = _write(tmp_path / "src/util.ts", "export const V = 1\n") + importer = _write( + tmp_path / "src/main.ts", + "import util = require('./util');\nexport const x = util.V;\n", + ) + + result = extract([target, importer], cache_root=tmp_path) + + assert _has_edge(result, "src/main.ts", "src/util.ts") + + +def test_import_require_bare_module_targets_stub(tmp_path: Path): + importer = _write( + tmp_path / "src/io.ts", + 'import fs = require("fs");\nexport const data = fs.readFileSync("x");\n', + ) + + result = extract([importer], cache_root=tmp_path) + + src = _file_node_id(Path("src/io.ts")) + tgt = _make_id("fs") + assert any( + e["source"] == src and e["target"] == tgt and e["relation"] == "imports_from" + for e in result["edges"] + ), "bare-module import-equals should target the module-name stub, like ESM" + + +def test_import_require_parity_with_namespace_import(tmp_path: Path): + """`import x = require("./m")` must produce the same file-level edge as + `import * as x from "./m"` — no more, no less.""" + _write(tmp_path / "a/dep.ts", "export function f() {}\n") + req = _write(tmp_path / "a/via_require.ts", 'import dep = require("./dep");\ndep.f();\n') + esm = _write(tmp_path / "a/via_esm.ts", 'import * as dep from "./dep";\ndep.f();\n') + + result = extract([tmp_path / "a/dep.ts", req, esm], cache_root=tmp_path) + + def edges_from(source_file: str): + src = _file_node_id(Path(source_file)) + return sorted( + (e["target"], e["relation"]) + for e in result["edges"] + if e["source"] == src and e["relation"] != "contains" + ) + + assert _has_edge(result, "a/via_require.ts", "a/dep.ts") + assert edges_from("a/via_require.ts") == edges_from("a/via_esm.ts") + + +def test_esm_imports_unaffected(tmp_path: Path): + """Regression guard: the restructured string scan must not change ESM handling + (file-level edge + named-import symbol edge both still emitted).""" + target = _write(tmp_path / "src/bar.ts", "export class Bar {}\n") + importer = _write( + tmp_path / "src/app.ts", + 'import { Bar } from "./bar";\nexport const b = new Bar();\n', + ) + + result = extract([target, importer], cache_root=tmp_path) + + assert _has_edge(result, "src/app.ts", "src/bar.ts") + src = _file_node_id(Path("src/app.ts")) + sym = [ + e for e in result["edges"] + if e["source"] == src and e["relation"] == "imports" + ] + assert sym, "named-import symbol edge should still be emitted"