diff --git a/graphify/extract.py b/graphify/extract.py index f68757a85..a62138295 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1258,6 +1258,48 @@ def walk_docstrings(node, parent_nid: str) -> None: _add_rationale(stripped, lineno, file_nid) +# ── TypeScript import type normalization (#3154) ────────────────────────────── +# tree-sitter-typescript misparses `import(...)` types used inside explicit call- +# expression type arguments (e.g. `f()` or +# `f()`) as binary comparison expressions (`<` and `>`). +# The trailing `();` generates an ERROR node, leaving an open `binary_expression` +# that absorbs subsequent declarations as anonymous `function_expression` or `class` +# expressions, silently dropping them from extraction. Normalizing `import(...)` +# within generic call type arguments `<...>(...)` to a valid type identifier of +# identical byte length keeps AST parsing clean while preserving source offsets. + +_TS_IMPORT_CALL_RE = re.compile( + rb"\bimport\s*\(\s*['\"][^'\"\r\n]+['\"]\s*\)" +) +_TS_IMPORT_TYPE_CALL_RE = re.compile( + rb"<((?:[^;{}]*?\bimport\s*\([^()]+\)[^;{}]*?)+)>(?=\s*\()" +) + + +def _normalize_ts_import_types(source: bytes) -> bytes | None: + """Rewrite TypeScript `import(...)` type arguments in call expressions + to standard type identifiers of identical byte length (#3154). + + Preserves byte length, newlines, and source offsets so all downstream node + source_location metadata remains 100% accurate. + """ + if rb"import(" not in source and rb"import (" not in source: + return None + + def repl_type_args(m: "re.Match[bytes]") -> bytes: + type_arg_content = m.group(1) + + def repl_import(im: "re.Match[bytes]") -> bytes: + matched = im.group(0) + return b"T" + re.sub(rb"[^\r\n]", b" ", matched[1:]) + + new_content = _TS_IMPORT_CALL_RE.sub(repl_import, type_arg_content) + return b"<" + new_content + b">" + + norm = _TS_IMPORT_TYPE_CALL_RE.sub(repl_type_args, source) + return norm if norm != source else None + + # ── Public API ──────────────────────────────────────────────────────────────── def extract_python(path: Path) -> dict: @@ -1271,13 +1313,21 @@ def extract_python(path: Path) -> dict: def extract_js(path: Path) -> dict: """Extract classes, functions, arrow functions, and imports from a .js/.ts/.tsx/.mts/.cts file.""" suffix = path.suffix.lower() + is_ts = suffix in (".ts", ".tsx", ".mts", ".cts") if suffix == ".tsx": config = _TSX_CONFIG elif suffix in (".ts", ".mts", ".cts"): config = _TS_CONFIG else: config = _JS_CONFIG - result = _extract_generic(path, config) + source_override = None + if is_ts: + try: + source = path.read_bytes() + source_override = _normalize_ts_import_types(source) + except OSError: + pass + result = _extract_generic(path, config, source_override=source_override) if "error" not in result: _extract_js_rationale(path, result) _rescue_js_dynamic_imports(path, result) @@ -1742,8 +1792,11 @@ def extract_vue(path: Path) -> dict: config = _JS_CONFIG else: # "ts" or unspecified — default to the TS grammar (superset of JS) config = _TS_CONFIG + masked_bytes = masked.encode("utf-8") + if config in (_TS_CONFIG, _TSX_CONFIG): + masked_bytes = _normalize_ts_import_types(masked_bytes) or masked_bytes - result = _extract_generic(path, config, source_override=masked.encode("utf-8")) + result = _extract_generic(path, config, source_override=masked_bytes) # Dynamic `import('…')` calls aren't edged by the AST pass; recover by regex, # mirroring extract_svelte/extract_astro. diff --git a/tests/test_ts_import_type_arguments.py b/tests/test_ts_import_type_arguments.py new file mode 100644 index 000000000..f0bc1e2b9 --- /dev/null +++ b/tests/test_ts_import_type_arguments.py @@ -0,0 +1,141 @@ +"""#3154: TypeScript `import(...)` types used in call-expression type arguments. + +tree-sitter-typescript misparses `f()` and +`f()` as binary comparison expressions (`<` and `>`), +dropping valid symbols declared after the expression when error recovery absorbs +them into the malformed expression statement. Normalizing `import(...)` within +call-expression type arguments to standard type identifiers before AST parsing +keeps extraction complete while preserving source locations and offsets. +""" +from __future__ import annotations + +import os +from pathlib import Path + +from graphify.extract import extract + + +def _extract(tmp_path: Path, files: dict[str, str]): + for name, body in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body, encoding="utf-8") + old = os.getcwd() + try: + os.chdir(tmp_path) + r = extract([Path(n) for n in files], + cache_root=tmp_path / ".cache", parallel=False) + finally: + os.chdir(old) + return r + + +def _labels(r: dict) -> set[str]: + return {n["label"] for n in r["nodes"]} + + +def _assert_silent(err: str): + assert "syntax errors" not in err + assert "partially extracted" not in err + + +def test_ts_call_typeof_import_keeps_subsequent_declarations(tmp_path: Path, capsys): + r = _extract(tmp_path, { + "main.ts": ( + "function before() {}\n" + "f();\n" + "function after() {}\n" + "class AfterClass {}\n" + ) + }) + labels = _labels(r) + assert "before()" in labels + assert "after()" in labels + assert "AfterClass" in labels + assert "mod" in labels + _assert_silent(capsys.readouterr().err) + + +def test_ts_call_import_member_type_keeps_subsequent_declarations(tmp_path: Path, capsys): + r = _extract(tmp_path, { + "main.ts": ( + "function before() {}\n" + "f();\n" + "function after() {}\n" + "class AfterClass {}\n" + ) + }) + labels = _labels(r) + assert "before()" in labels + assert "after()" in labels + assert "AfterClass" in labels + assert "mod" in labels + _assert_silent(capsys.readouterr().err) + + +def test_tsx_call_typeof_import_keeps_subsequent_declarations(tmp_path: Path, capsys): + r = _extract(tmp_path, { + "comp.tsx": ( + "function before() {}\n" + "f();\n" + "function after() {}\n" + "class AfterWidget {}\n" + "export const Comp = () =>
hello
;\n" + ) + }) + labels = _labels(r) + assert "before()" in labels + assert "after()" in labels + assert "AfterWidget" in labels + assert "Comp()" in labels + assert "mod" in labels + _assert_silent(capsys.readouterr().err) + + +def test_ts_call_generic_controls_remain_clean(tmp_path: Path, capsys): + r = _extract(tmp_path, { + "controls.ts": ( + "function before() {}\n" + "f();\n" + "f();\n" + "function after() {}\n" + ) + }) + labels = _labels(r) + assert "before()" in labels + assert "after()" in labels + _assert_silent(capsys.readouterr().err) + + +def test_ts_call_import_types_source_locations_are_exact(tmp_path: Path): + r = _extract(tmp_path, { + "sample.ts": ( + "function before() {}\n" + "\n" + "f();\n" + "\n" + "function after() {}\n" + "class TargetClass {}\n" + ) + }) + after_node = next(n for n in r["nodes"] if n["label"] == "after()") + target_class_node = next(n for n in r["nodes"] if n["label"] == "TargetClass") + assert after_node["source_location"] == "L5" + assert target_class_node["source_location"] == "L6" + + +def test_ts_multiline_import_type_arguments(tmp_path: Path, capsys): + r = _extract(tmp_path, { + "multiline.ts": ( + "f<\n" + " typeof import(\n" + " 'mod'\n" + " )\n" + ">();\n" + "function after() {}\n" + ) + }) + labels = _labels(r) + assert "after()" in labels + assert "mod" in labels + _assert_silent(capsys.readouterr().err)