From 3c332ba72ab6d8c2dc2e39aabd2651a91479b4f3 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Wed, 29 Jul 2026 17:25:08 +0100 Subject: [PATCH] fix(extract): parse .tsx with the TSX grammar so symbol-resolution stops leaking absolute-slug ids (#2262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _parse_js_tree parsed .tsx with language_typescript(), so JSX misparsed and error-recovery floated nested handlers to top level; the symbol-resolution pass then emitted calls edges whose SOURCE was an absolute-stem id for a caller that owns no node — a leak the 0.9.29 backstop (learns only from nodes) can't see. Fix: use language_tsx() for .tsx; never emit a calls use-edge from an unowned source (reattribute to the file node); and teach the backstop stem-form prefixes so any node-less absolute-derived endpoint canonicalizes. No node id or edge endpoint now carries the scan-root slug. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/extract.py | 26 +++++++++++++- graphify/extractors/resolution.py | 31 +++++++++++++++-- tests/test_node_id_canonical.py | 57 +++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 4 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index b834fb4..516cd37 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -5523,7 +5523,16 @@ def extract( sf_resolved = sf_path.resolve() except (OSError, RuntimeError): sf_resolved = sf_path - keys = tuple({_make_id(str(sf_path)), _make_id(str(sf_resolved))}) + # Learn the STEM (extension-dropped) forms too: symbol producers mint + # compound ids as _make_id(_file_stem(path), name), so a node-less + # absolute-derived endpoint arrives as _ and only + # the stem prefix can identify the file it came from (#2262). + keys = tuple({ + _make_id(str(sf_path)), + _make_id(str(sf_resolved)), + _make_id(_file_stem(sf_path)), + _make_id(_file_stem(sf_resolved)), + }) entry = (new_sf, canonical_id, keys) _sf_forms[sf] = entry return entry @@ -5560,6 +5569,21 @@ def extract( return ext_id_remap[nid] if nid.endswith(_ENTRY) and nid[: -len(_ENTRY)] in ext_id_remap: return ext_id_remap[nid[: -len(_ENTRY)]] + _ENTRY + if nid not in owned_ids: + # Node-less suffixed-compound endpoint (#2262): an id minted + # as _make_id(, ) by a producer that + # never materialized the node. No node ever registers it, so + # rewrite by longest learned prefix: the endpoint stays + # dangling (no node is fabricated) but becomes + # machine-portable. Ids owned by real nodes are never + # touched (guard above), and only absolute-path-derived + # prefixes are in ext_id_remap, so ordinary ids can't match. + idx = nid.rfind("_") + while idx > 0: + canonical = ext_id_remap.get(nid[:idx]) + if canonical is not None: + return canonical + nid[idx:] + idx = nid.rfind("_", 0, idx) return nid for n in all_nodes: diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 457c266..097c32b 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -1057,6 +1057,16 @@ def _apply_symbol_resolution_facts( local_alias=local_name if local_name != to_path.stem else None, ) + # #2262 producer guard: never emit a `calls` use-edge from a source id + # that owns no node. All node appends (ensure_symbol_node, declarations, + # namespace exports) happened above, so the owned set is complete here. + # A node-less caller id can never be canonicalized by the extract() + # remaps (they learn only from nodes), so an absolute-derived one would + # leak the machine/scan-path slug into the edge source. Reattribute the + # edge to the caller's FILE node — the true file-level dependency + # survives, and the file id is exactly what the #2231 remap + # canonicalizes — or drop it when no file node id is available. + owned = {str(n.get("id")) for n in nodes} for use_fact in facts.uses: file_path = use_fact.file_path.resolve() target_id = None @@ -1074,8 +1084,13 @@ def _apply_symbol_resolution_facts( target_id = symbol_nodes.get((file_path, use_fact.local_name)) if target_id is None: continue + source_id = use_fact.source_id + if use_fact.relation == "calls" and source_id not in owned: + source_id = source_file_id.get(file_path) + if source_id is None: + continue add_edge( - use_fact.source_id, + source_id, target_id, use_fact.relation, use_fact.context, @@ -1096,10 +1111,20 @@ def _parse_js_tree(path: Path): source = masked.encode("utf-8") else: source = path.read_bytes() - use_ts = path.suffix in (".ts", ".tsx", ".mts", ".cts") or ( + use_ts = path.suffix in (".ts", ".mts", ".cts") or ( path.suffix == ".vue" and vue_lang not in ("js", "jsx") ) - if use_ts: + if path.suffix == ".tsx": + # .tsx must use the JSX-aware TSX grammar, mirroring the engine's + # _TSX_CONFIG (ts_language_fn="language_tsx"). Parsing .tsx with + # language_typescript misparses JSX, and tree-sitter's error + # recovery floats nested arrow components up to top level — + # _js_top_level_function_bodies then mints caller ids for callers + # that own no node, leaking absolute-path slugs into calls-edge + # sources (#2262). + import tree_sitter_typescript as tstypescript + language = Language(tstypescript.language_tsx()) + elif use_ts: import tree_sitter_typescript as tstypescript language = Language(tstypescript.language_typescript()) else: diff --git a/tests/test_node_id_canonical.py b/tests/test_node_id_canonical.py index 6b0c9ea..74e2b14 100644 --- a/tests/test_node_id_canonical.py +++ b/tests/test_node_id_canonical.py @@ -115,6 +115,63 @@ def test_bash_source_incremental_target_canonicalizes(tmp_path): assert not any("target_file" in e for e in full["edges"]) +def test_tsx_nested_handler_calls_source_is_canonical(tmp_path): + """#2262: a .tsx component with a JSX-returning nested arrow component + defined BEFORE its handlers used to be parsed with the plain TypeScript + grammar; tree-sitter's error recovery floated the nested handlers to top + level, minting `calls` edge SOURCES from the absolute path — ids that own + no node and that no remap ever learns. Every calls edge source must be a + real node id and no endpoint may carry the scan-root slug.""" + root = _real(tmp_path) + (root / "row.tsx").write_text( + "export const constructRowWithId = (id: string) => {\n" + " return { id };\n" + "};\n" + ) + (root / "panel.tsx").write_text( + 'import { constructRowWithId } from "./row";\n' + "\n" + "export const PrepayBalanceContainer = () => {\n" + " const InvoiceBalanceSubsection = () => {\n" + ' return
\n' + "
Balance
\n" + ' {constructRowWithId("invoice").id}\n' + "
;\n" + " };\n" + ' const handleApply = () => constructRowWithId("apply");\n' + " const handleTabClick = (tab: string) => {\n" + " return constructRowWithId(tab);\n" + " };\n" + " return ;\n" + "};\n" + ) + + result = extract( + [root / "panel.tsx", root / "row.tsx"], cache_root=tmp_path, root=root + ) + + # (a) no scan-root slug in any node id or edge endpoint (source AND target). + _assert_no_slug(result, _slug(root)) + + node_ids = {n["id"] for n in result["nodes"]} + calls = [ + e for e in result["edges"] if e["relation"] in ("calls", "indirect_call") + ] + # (b) the call edges into the imported symbol target its canonical id. + imported_targets = { + e["target"] for e in calls if e["target"].endswith("constructrowwithid") + } + assert imported_targets == {"row_constructrowwithid"}, ( + f"imported-symbol call target must be canonical; got {imported_targets}" + ) + # (c) every calls edge SOURCE is a real node — a node-less source id can + # never be canonicalized and leaks the machine slug. + bad_sources = [ + (e["source"], e["target"]) for e in calls if e["source"] not in node_ids + ] + assert not bad_sources, f"calls edges with node-less sources: {bad_sources}" + + def test_extract_invariant_no_absolute_root_slug_anywhere(tmp_path): """General invariant: extracting a mixed corpus (python module-level dispatch + bash source + a normal import) from ABSOLUTE input paths leaves