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:
co-authored by
Claude Opus 4.8
parent
b4865ffcbf
commit
3c332ba72a
+25
-1
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user