Resolve cross-file type-annotation refs to a single node, not phantom duplicates (#1402)

A class defined once but referenced via type annotations in N other files appeared
as 1+N nodes — the extras carrying the referencing file's path (with extension)
baked into the id (e.g. pkg_a_py_thing). ensure_named_node's cross-file fallback
called add_node, which stamps the referencing file as source_file; that sourced
stub then collided in _disambiguate_colliding_node_ids (baking the .py path into
the id) and _rewire_unique_stub_nodes skipped it (a node with a source_file is
treated as a real definition, not a stub).

The fallback now emits a SOURCELESS stub (mirroring the inheritance-base path), so
disambiguation ignores it and the rewire collapses it onto the canonical
definition. The helper is duplicated across all six language extractors, so the
fix is applied to all six. Genuinely-defined duplicates (same name, different
files) still stay separate — only cross-file references collapse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-06-23 00:17:59 +01:00
co-authored by Claude Opus 4.8
parent 739230e17a
commit 0aeda15c10
3 changed files with 114 additions and 6 deletions
+1
View File
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
- Fix: cross-file type-annotation references no longer create phantom duplicate nodes. A class defined once but referenced via type annotations in N other files (`def f(x: Thing) -> Thing`) produced 1+N nodes — the extra ones with the referencing file's path baked into the id (`pkg_a_py_thing`). `ensure_named_node` minted a *sourced* stub for these cross-file refs, which `_disambiguate_colliding_node_ids` then collided into per-file ids and `_rewire_unique_stub_nodes` refused to collapse. The fallback now emits a *sourceless* stub (like the inheritance-base path), so the references resolve to the single canonical definition. Fixed uniformly across all six language extractors that share the helper (#1402).
- Feat: CUDA (`.cu`/`.cuh`) source files are now extracted. CUDA is a C++ superset, so these files route through the existing C++ (`tree-sitter-cpp`) extractor — no new grammar dependency. `__global__`/`__device__` kernels, host functions, structs and `#include`s are captured, host call edges are inferred, and `<<<grid, block>>>` kernel-launch syntax parses without error. Detection and file-watching follow automatically since both derive their extension sets from the dispatch table / `CODE_EXTENSIONS` (#1411).
## 0.8.45 (2026-06-22)
+90 -6
View File
@@ -2467,7 +2467,21 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
return nid
nid = _make_id(name)
if nid not in seen_ids:
add_node(nid, name, line)
# The name isn't defined in this file, so this is a cross-file reference
# (e.g. a `Thing` type annotation imported from another module). Emit a
# SOURCELESS stub — like the inheritance-base path below — so the
# corpus-level rewire can collapse it onto the real definition. A sourced
# stub here makes _disambiguate_colliding_node_ids bake the referencing
# file's path (with extension) into the id and blocks the rewire, which is
# the phantom-duplicate-node bug (#1402).
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": name,
"file_type": "code",
"source_file": "",
"source_location": "",
})
return nid
file_nid = _make_id(str(path))
@@ -5821,7 +5835,21 @@ def extract_julia(path: Path) -> dict:
return nid
nid = _make_id(name)
if nid not in seen_ids:
add_node(nid, name, line)
# The name isn't defined in this file, so this is a cross-file reference
# (e.g. a `Thing` type annotation imported from another module). Emit a
# SOURCELESS stub — like the inheritance-base path below — so the
# corpus-level rewire can collapse it onto the real definition. A sourced
# stub here makes _disambiguate_colliding_node_ids bake the referencing
# file's path (with extension) into the id and blocks the rewire, which is
# the phantom-duplicate-node bug (#1402).
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": name,
"file_type": "code",
"source_file": "",
"source_location": "",
})
return nid
def _func_name_from_signature(sig_node) -> str | None:
@@ -6103,7 +6131,21 @@ def extract_fortran(path: Path) -> dict:
return nid
nid = _make_id(name)
if nid not in seen_ids:
add_node(nid, name, line)
# The name isn't defined in this file, so this is a cross-file reference
# (e.g. a `Thing` type annotation imported from another module). Emit a
# SOURCELESS stub — like the inheritance-base path below — so the
# corpus-level rewire can collapse it onto the real definition. A sourced
# stub here makes _disambiguate_colliding_node_ids bake the referencing
# file's path (with extension) into the id and blocks the rewire, which is
# the phantom-duplicate-node bug (#1402).
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": name,
"file_type": "code",
"source_file": "",
"source_location": "",
})
return nid
def emit_signature_refs(scope_node, fn_nid: str, is_function: bool) -> None:
@@ -6678,7 +6720,21 @@ def extract_rust(path: Path) -> dict:
return nid
nid = _make_id(name)
if nid not in seen_ids:
add_node(nid, name, line)
# The name isn't defined in this file, so this is a cross-file reference
# (e.g. a `Thing` type annotation imported from another module). Emit a
# SOURCELESS stub — like the inheritance-base path below — so the
# corpus-level rewire can collapse it onto the real definition. A sourced
# stub here makes _disambiguate_colliding_node_ids bake the referencing
# file's path (with extension) into the id and blocks the rewire, which is
# the phantom-duplicate-node bug (#1402).
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": name,
"file_type": "code",
"source_file": "",
"source_location": "",
})
return nid
def emit_param_return_refs(func_node, func_nid: str, line: int) -> None:
@@ -7133,7 +7189,21 @@ def extract_powershell(path: Path) -> dict:
return nid
nid = _make_id(name)
if nid not in seen_ids:
add_node(nid, name, line)
# The name isn't defined in this file, so this is a cross-file reference
# (e.g. a `Thing` type annotation imported from another module). Emit a
# SOURCELESS stub — like the inheritance-base path below — so the
# corpus-level rewire can collapse it onto the real definition. A sourced
# stub here makes _disambiguate_colliding_node_ids bake the referencing
# file's path (with extension) into the id and blocks the rewire, which is
# the phantom-duplicate-node bug (#1402).
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": name,
"file_type": "code",
"source_file": "",
"source_location": "",
})
return nid
def _ps_type_name(type_literal_node) -> str | None:
@@ -9311,7 +9381,21 @@ def extract_objc(path: Path) -> dict:
return nid
nid = _make_id(name)
if nid not in seen_ids:
add_node(nid, name, line)
# The name isn't defined in this file, so this is a cross-file reference
# (e.g. a `Thing` type annotation imported from another module). Emit a
# SOURCELESS stub — like the inheritance-base path below — so the
# corpus-level rewire can collapse it onto the real definition. A sourced
# stub here makes _disambiguate_colliding_node_ids bake the referencing
# file's path (with extension) into the id and blocks the rewire, which is
# the phantom-duplicate-node bug (#1402).
seen_ids.add(nid)
nodes.append({
"id": nid,
"label": name,
"file_type": "code",
"source_file": "",
"source_location": "",
})
return nid
def walk(node, parent_nid: str | None = None) -> None:
+23
View File
@@ -100,6 +100,29 @@ def test_extract_disambiguates_duplicate_symbol_ids_by_source_path(tmp_path):
assert edge["target"] in node_ids, f"Dangling structural target: {edge}"
def test_cross_file_type_annotation_refs_resolve_to_single_node(tmp_path):
"""#1402: a class defined once but referenced via type annotations in N other
files must NOT create 1+N phantom duplicate nodes (with the referencing file's
path — extension and all — baked into the id, e.g. ``pkg_a_py_thing``). The
annotation references resolve to the single canonical definition.
Contrast with test_extract_disambiguates_...: genuinely *defined* duplicates
stay separate; only cross-file *references* collapse onto the real node."""
pkg = tmp_path / "pkg"
pkg.mkdir()
(pkg / "thing.py").write_text("class Thing:\n def run(self):\n return 1\n", encoding="utf-8")
(pkg / "a.py").write_text("from pkg.thing import Thing\ndef use_a(obj: Thing) -> Thing:\n return obj\n", encoding="utf-8")
(pkg / "b.py").write_text("from pkg.thing import Thing\ndef use_b(obj: Thing) -> Thing:\n return obj\n", encoding="utf-8")
result = extract([pkg / "thing.py", pkg / "a.py", pkg / "b.py"], cache_root=tmp_path)
thing_nodes = [n for n in result["nodes"] if n["label"] == "Thing"]
assert len(thing_nodes) == 1, [n["id"] for n in thing_nodes]
# The tell-tale phantom signature is the referencing file's path (with .py
# extension) baked into the id — must not appear.
assert "_py" not in thing_nodes[0]["id"], thing_nodes[0]["id"]
def test_extract_updates_raw_call_callers_after_duplicate_id_disambiguation(tmp_path):
first = tmp_path / "apps/api/Program.cs"
second = tmp_path / "tools/api/Program.cs"