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