diff --git a/CHANGELOG.md b/CHANGELOG.md index b61e98b..11b4cbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `<<>>` 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) diff --git a/graphify/extract.py b/graphify/extract.py index b211d22..6f5c200 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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: diff --git a/tests/test_extract.py b/tests/test_extract.py index e1c3d45..062e05e 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -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"