Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("mod")>()` or
# `f<import("mod").Foo>()`) 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:
Expand All @@ -1271,13 +1313,21 @@ def extract_python(path: Path) -> dict:
def extract_js(path: Path) -> dict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_js()

85 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""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)
Expand Down Expand Up @@ -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.
Expand Down
141 changes: 141 additions & 0 deletions tests/test_ts_import_type_arguments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""#3154: TypeScript `import(...)` types used in call-expression type arguments.

tree-sitter-typescript misparses `f<typeof import("mod")>()` and
`f<import("mod").Foo>()` 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<typeof import('mod')>();\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<import('mod').Foo>();\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<typeof import('mod')>();\n"
"function after() {}\n"
"class AfterWidget {}\n"
"export const Comp = () => <div>hello</div>;\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<string>();\n"
"f<typeof window>();\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<typeof import('mod')>();\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)
Loading