fix(extract): parse TS 5 export type star re-exports (#3942)

Landed from PR #4303 (squashed, authorship preserved).
This commit is contained in:
ayushcodes10 authored and safishamsi committed 2026-10-10 21:38:54 +01:00
1 parent fb6e7c7888
commit 9dafae2acc
2 files changed
+109 -1

No files matched your search

+32 -1
View File
@@ -2019,6 +2019,33 @@ def _ts_mask_candidate_is_malformed(
return False
_TS_EXPORT_TYPE_STAR_RE = re.compile(rb"\bexport\s+(type)\s*\*")
def _normalize_ts_export_type_star(source: bytes) -> bytes | None:
"""Rewrite ``export type * from "..."`` to a plain ``export * from "..."``.
TS 5.0's type-only star re-export has no equivalent in tree-sitter-typescript
0.23 (the latest release as of #3942): the grammar raises an ERROR on the
``type`` keyword in this position and the whole file is dropped, losing
every symbol, not just the one statement. The construct is erased at
compile time, the same as ``import type`` / ``export type { X }``, so the
graph only needs the re-export edge, not the type-only distinction —
blank out ``type`` (keeping every other byte and offset identical) so the
grammar parses the ordinary, already-supported ``export * from "..."``
form instead. Unlike ``import(...)``, this prefix is never ambiguous with
a runtime expression, so no AST-based disambiguation pass is needed here.
"""
matches = list(_TS_EXPORT_TYPE_STAR_RE.finditer(source))
if not matches:
return None
masked = bytearray(source)
for match in matches:
start, end = match.span(1)
masked[start:end] = b" " * (end - start)
return bytes(masked)
def _normalize_ts_import_types(source: bytes, *, tsx: bool = False) -> bytes | None:
"""Rewrite only syntactic TypeScript ``import(...)`` type arguments.
@@ -2132,7 +2159,11 @@ def extract_js(path: Path) -> dict:
if is_ts:
try:
source = _read_source_bytes(path)
source_override = _normalize_ts_import_types(source, tsx=suffix == ".tsx")
working = _normalize_ts_export_type_star(source) or source
normalized = _normalize_ts_import_types(working, tsx=suffix == ".tsx")
source_override = normalized if normalized is not None else (
working if working is not source else None
)
except OSError:
pass
result = _extract_generic(path, config, source_override=source_override)
+77
View File
@@ -0,0 +1,77 @@
"""#3942: TypeScript 5.0's `export type * from "..."` has no equivalent in
tree-sitter-typescript 0.23 (the latest release) — the grammar raises an
ERROR on the `type` keyword in this position, and the whole file is dropped,
losing every symbol, not just the one statement.
The construct is erased at compile time, the same as `import type` /
`export type { X }`, so the graph only needs the re-export edge. Blanking
out `type` (same byte length, same offsets) before parsing lets the grammar
read the ordinary, already-supported `export * from "..."` form instead.
"""
from __future__ import annotations
import os
from pathlib import Path
from graphify.extract import _normalize_ts_export_type_star, 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 test_export_type_star_no_longer_a_syntax_error(tmp_path, capsys):
r = _extract(tmp_path, {
"index.ts": 'export type * from "./types.js";\n',
"types.ts": "export type FetchFn = typeof globalThis.fetch;\n",
})
err = capsys.readouterr().err
assert "syntax error" not in err, err
def test_export_type_star_still_produces_the_re_export_edge(tmp_path):
r = _extract(tmp_path, {
"index.ts": 'export type * from "./types.js";\n',
"types.ts": "export type FetchFn = typeof globalThis.fetch;\n",
})
rels = {e["relation"] for e in r["edges"]
if e["source"].endswith("index_ts") or e["source"] == "index"}
assert "imports_from" in rels or "re_exports" in rels, r["edges"]
# types.ts's own symbol must survive too — the whole point is that
# neither file is dropped.
assert "FetchFn" in {n["label"] for n in r["nodes"]}
def test_export_type_named_form_is_unaffected(tmp_path):
"""Control: the already-working named type re-export must not be touched
by a regex that is specifically anchored on the `*` star form."""
r = _extract(tmp_path, {
"named.ts": 'export type { FetchFn } from "./types.js";\n',
"types.ts": "export type FetchFn = typeof globalThis.fetch;\n",
})
type_only_edges = [e for e in r["edges"] if e.get("type_only")]
assert type_only_edges, "the named form must keep its type_only stamp (#3123)"
def test_normalize_function_only_blanks_the_type_keyword():
source = b'export type * from "./types.js";\n'
normalized = _normalize_ts_export_type_star(source)
assert normalized is not None
assert len(normalized) == len(source)
assert normalized == b'export * from "./types.js";\n'
def test_normalize_function_returns_none_when_no_match():
assert _normalize_ts_export_type_star(b'export * from "./x";\n') is None
assert _normalize_ts_export_type_star(b'export type { X } from "./x";\n') is None