From 9dafae2acc8514d44d2f737b82bfe6310582360e Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Sat, 10 Oct 2026 21:38:54 +0100 Subject: [PATCH] fix(extract): parse TS 5 export type star re-exports (#3942) Landed from PR #4303 (squashed, authorship preserved). --- graphify/extract.py | 33 ++++++++++++- tests/test_ts_export_type_star.py | 77 +++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 tests/test_ts_export_type_star.py diff --git a/graphify/extract.py b/graphify/extract.py index dca1a66ec..d55f567cd 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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) diff --git a/tests/test_ts_export_type_star.py b/tests/test_ts_export_type_star.py new file mode 100644 index 000000000..8216efbab --- /dev/null +++ b/tests/test_ts_export_type_star.py @@ -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