fix(extract): capture the TS import-equals form (import x = require(...))

`import x = require("./m")` produced no edge at all: tree-sitter parses it
as an `import_statement` whose module 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. The file-level
dependency was silently dropped while the equivalent ESM form
(`import * as x from "./m"`) was captured — an invisible hole in the
import graph of TS codebases that interop with CommonJS modules.

Restructure the scan to first locate the module string — a direct `string`
child for ESM imports/re-exports, or the `string` nested inside an
`import_require_clause` for the import-equals form — then emit the
`imports_from` edge from the single shared path. Relative paths, tsconfig
aliases, and bare modules all resolve through the same
`_resolve_js_import_target` as ESM, giving the import-equals form exact
parity with a namespace import: one file-level `imports_from` edge.

Plain JS is unaffected (the grammar has no `import_require_clause`), and
the pure namespace alias form (`import A = B.C`) is out of scope — it has
no module string and models an intra-code alias, not an import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paulo Pinto
2026-07-02 22:29:32 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 1226c34731
commit 9811def1b3
2 changed files with 126 additions and 5 deletions
+15 -5
View File
@@ -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)
+111
View File
@@ -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"