diff --git a/graphify/extract.py b/graphify/extract.py
index 237bb453..03db9945 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -5471,12 +5471,24 @@ def extract(
# naming the first error line so the user can find the construct.
_syntax_error_files: list[tuple[str, int | None]] = []
for i, _p in enumerate(paths):
- _pe = (per_file[i] or {}).get("parse_errors")
- if _pe:
- _syntax_error_files.append((str(_p), _pe.get("first_error_line")))
+ _res = per_file[i] or {}
+ _pe = _res.get("parse_errors")
+ if not _pe:
+ continue
+ # #2610/#2599: gate the #2551 warning on plausible symbol loss.
+ # tree-sitter-typescript sets has_error on tiny fully-recovered errors
+ # (a `&` in a JSX string attr; a semicolon-less `in_*` interface
+ # member) that extract completely — stay silent. Warn only when
+ # nothing beyond the file node extracted, or an ERROR region
+ # dissolved multiple lines (the genuine #2551 Kotlin one-line-body /
+ # #2520 Luau case). `multiline_error` is absent from pre-fix cached
+ # results, so those fall back to the file-node-only arm.
+ if len(_res.get("nodes", [])) <= 1 or _pe.get("multiline_error"):
+ _rel = os.path.relpath(str(_p), str(root)).replace("\\", "/")
+ _syntax_error_files.append((_rel, _pe.get("first_error_line")))
if _syntax_error_files:
_shown = ", ".join(
- f"{Path(x).name} (first error at line {ln})" if ln else Path(x).name
+ f"{x} (first error at line {ln})" if ln else x
for x, ln in _syntax_error_files[:5]
)
_more = (
diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py
index c831fc27..6a261769 100644
--- a/graphify/extractors/engine.py
+++ b/graphify/extractors/engine.py
@@ -2344,6 +2344,19 @@ def _first_parse_error_line(root) -> int:
node = child
+def _has_multiline_error(root) -> bool:
+ """True if any materialized ERROR node spans more than one line (a
+ recovery region large enough to plausibly drop symbols, vs a tiny
+ single-line recovery that extracts completely)."""
+ stack = [root]
+ while stack:
+ n = stack.pop()
+ if n.type == "ERROR" and n.end_point[0] > n.start_point[0]:
+ return True
+ stack.extend(c for c in n.children if c.has_error)
+ return False
+
+
def _read_csharp_type_name(node, source: bytes) -> tuple[str, bool, str] | None:
"""Resolve a C# type name, whether it was qualified, and its qualifier prefix."""
if node is None:
@@ -5255,7 +5268,10 @@ def _extract_generic(
# error's line so extract() can warn instead of reporting silent success.
# Rides on the result dict, so it survives the per-file AST cache.
if root.has_error:
- result["parse_errors"] = {"first_error_line": _first_parse_error_line(root)}
+ result["parse_errors"] = {
+ "first_error_line": _first_parse_error_line(root),
+ "multiline_error": _has_multiline_error(root),
+ }
# Kotlin (#2526/#2550): the declared package qualifies every node in the
# file; the import-target and qualified-call resolvers key their per-package
# symbol indexes off it.
diff --git a/tests/test_kotlin_grammar.py b/tests/test_kotlin_grammar.py
index 2c414e63..dabc4dc0 100644
--- a/tests/test_kotlin_grammar.py
+++ b/tests/test_kotlin_grammar.py
@@ -15,9 +15,12 @@ older forks the extractor was written against:
all-identifier chains into a `qualified_prefix` resolved against the
declared packages (exactly-one-candidate guarded).
* #2551 — the grammar rejects one-line `class C { val x }` bodies; consecutive
- one-liners can dissolve the whole file's parse. Graphify now warns on any
- file extracted through ERROR recovery (language-agnostic, also #2520) and
- keeps class linkage for declarations recovered inside an ERROR span.
+ one-liners can dissolve the whole file's parse. Graphify warns on a file
+ extracted through ERROR recovery (language-agnostic, also #2520) and keeps
+ class linkage for declarations recovered inside an ERROR span. Since
+ #2610/#2599 the warning fires only on PLAUSIBLE symbol loss (file-node-only
+ result or a multiline ERROR region) — tiny fully-recovered errors that
+ extract completely stay silent.
"""
from __future__ import annotations
@@ -272,7 +275,10 @@ def test_kotlin_partial_parse_warns_with_file_and_line(tmp_path, capsys):
def test_kotlin_one_line_class_with_fun_still_extracts(tmp_path, capsys):
# `class VM { fun f() = 1 }` trips has_error but recovers structurally:
- # everything must extract, and the warning names the file.
+ # everything must extract. #2610: since the recovery is zero-width and
+ # every symbol is present, the corrected gate (warn only on plausible
+ # symbol loss — file-node-only or a multiline ERROR region) stays SILENT;
+ # the old has_error-gated warning here was a false positive.
r = _extract(tmp_path, {
"VM.kt": (
"class VM { fun f() = 1 }\n"
@@ -283,7 +289,7 @@ def test_kotlin_one_line_class_with_fun_still_extracts(tmp_path, capsys):
f = _find(r, ".f()")
_find(r, "After()") # present
assert (vm, f) in _edges(r, "method")
- assert "VM.kt" in capsys.readouterr().err
+ assert "VM.kt" not in capsys.readouterr().err
def test_kotlin_one_line_class_keeps_field_reference(tmp_path):
diff --git a/tests/test_ts_parse_warning.py b/tests/test_ts_parse_warning.py
new file mode 100644
index 00000000..431e375d
--- /dev/null
+++ b/tests/test_ts_parse_warning.py
@@ -0,0 +1,120 @@
+"""#2610/#2599: the #2551 partial-parse warning must not fire on VALID TS/TSX.
+
+tree-sitter-typescript (0.23.x) sets ``has_error`` on tiny, fully-recovered
+errors in code ``tsc`` accepts — a ``&`` inside a JSX string attribute, or a
+semicolon-less interface member named ``in_*``/``instanceof_*`` — while still
+extracting every symbol. Gating the warning on bare ``has_error`` (0.9.37,
+#2551) therefore flagged valid files as "syntax errors". The corrected gate
+warns only on PLAUSIBLE symbol loss: nothing beyond the file node extracted,
+or an ERROR region spanning multiple lines.
+"""
+from __future__ import annotations
+
+import os
+import re
+from pathlib import Path
+
+from graphify.extract import extract
+
+
+def _extract(tmp_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)
+ 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):
+ return {n["label"] for n in r["nodes"]}
+
+
+def _assert_silent(err):
+ assert "syntax errors" not in err
+ assert "partially extracted" not in err
+
+
+def test_tsx_amp_in_jsx_string_attr_is_silent(tmp_path, capsys):
+ # `&` inside a JSX string attribute trips a grammar lexer bug: a 4-byte
+ # ERROR node covering `&b=2`, has_error=True — but `el` extracts fine and
+ # tsc accepts the file. No warning.
+ r = _extract(tmp_path, {
+ "app.tsx": (
+ "declare const Comp: (props: { to: string }) => null;\n"
+ 'export const el = ;\n'
+ ),
+ })
+ assert "el" in _labels(r)
+ _assert_silent(capsys.readouterr().err)
+
+
+def test_ts_interface_member_named_in_prefix_is_silent(tmp_path, capsys):
+ # A semicolon-less interface member starting with `in` (`in_workshop`)
+ # yields a zero-width MISSING `}` + a 1-byte ERROR, has_error=True — but
+ # `I` extracts fine and tsc accepts the file. No warning.
+ r = _extract(tmp_path, {
+ "types.ts": (
+ "interface I { a: number\n"
+ " in_workshop: number }\n"
+ ),
+ })
+ assert "I" in _labels(r)
+ _assert_silent(capsys.readouterr().err)
+
+
+def test_ts_generics_as_and_jsx_logical_and_are_silent(tmp_path, capsys):
+ # Regression canary: constructs superficially near the lexer bugs —
+ # `foo(x)`, an `as` cast, a `` arrow generic, `&&` in JSX — parse
+ # CLEAN (no has_error) and must never warn.
+ r = _extract(tmp_path, {
+ "generics.ts": (
+ "function foo(x: T): T { return x }\n"
+ "const y = foo(1);\n"
+ "const z = y as number;\n"
+ ),
+ "arrow.tsx": (
+ "const pick = (x: T) => x;\n"
+ "export const view = {true && hi}
;\n"
+ ),
+ })
+ assert {"foo()", "pick()", "view"} <= _labels(r)
+ _assert_silent(capsys.readouterr().err)
+
+
+def test_ts_genuinely_broken_file_still_warns(tmp_path, capsys):
+ # `function f( {` dissolves the whole parse — nothing beyond the file
+ # node extracts. The warning must fire and name the file + first line.
+ _extract(tmp_path, {"broken.ts": "function f( {\n"})
+ err = capsys.readouterr().err
+ assert "syntax errors" in err
+ assert "broken.ts" in err
+ assert re.search(r"first error at line 1", err)
+
+
+def test_ts_midfile_breakage_warns_and_keeps_intact_functions(tmp_path, capsys):
+ # An unclosed brace between two valid functions produces a multiline
+ # ERROR region: the warning fires AND both intact functions extract.
+ r = _extract(tmp_path, {
+ "midfile.ts": (
+ "function alpha() { return 1 }\n"
+ "\n"
+ "function bad( {\n"
+ " oops;\n"
+ " more;\n"
+ "\n"
+ "function beta() { return 2 }\n"
+ ),
+ })
+ labels = _labels(r)
+ assert "alpha()" in labels
+ assert "beta()" in labels
+ err = capsys.readouterr().err
+ assert "syntax errors" in err
+ assert "midfile.ts" in err