fix(extract): parse .tsx with the TSX grammar so symbol-resolution stops leaking absolute-slug ids (#2262)

_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) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-29 17:25:08 +01:00
co-authored by Claude Opus 4.8
parent b4865ffcbf
commit 3c332ba72a
3 changed files with 110 additions and 4 deletions
+25 -1
View File
@@ -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 <stem-key>_<symbol> 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(<absolute stem>, <symbol>) 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:
+28 -3
View File
@@ -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:
+57
View File
@@ -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 <section className="invoice">\n'
" <header>Balance</header>\n"
' <span data-testid="row">{constructRowWithId("invoice").id}</span>\n'
" </section>;\n"
" };\n"
' const handleApply = () => constructRowWithId("apply");\n'
" const handleTabClick = (tab: string) => {\n"
" return constructRowWithId(tab);\n"
" };\n"
" return <InvoiceBalanceSubsection />;\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