diff --git a/CHANGELOG.md b/CHANGELOG.md index 50511f9..9e9fbc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Fix: the Go AST extractor no longer creates phantom duplicate nodes for cross-file type references — the Go copy of `ensure_named_node` still used the older sourced-stub fallback; it now emits a sourceless stub like the other extractors, extending the #1402 fix to Go (#1500). + ## 0.8.50 (2026-06-27) - Feat: `graphify label --missing-only` relabels only communities that are unnamed or still hold a `Community N` placeholder, preserving existing non-placeholder labels from `.graphify_labels.json` (#1481, thanks @jiangyq9; supersedes #1421 by @matiasduartee, who proposed the same flag). Lets a large graph be relabeled incrementally without re-naming (and paying for) communities that already have good names. diff --git a/graphify/extract.py b/graphify/extract.py index 75efb65..0ccb6b8 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6472,7 +6472,21 @@ def extract_go(path: Path) -> dict: return nid nid = _make_id(name) if nid not in seen_ids: - add_node(nid, name, line) + # The name isn't declared in this file, so this is a cross-file reference + # (e.g. a type defined in another file of the package). Emit a SOURCELESS + # stub — like the inheritance-base path in the other extractors — 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_go_method_refs(func_node, func_nid: str, line: int) -> None: diff --git a/tests/test_extract.py b/tests/test_extract.py index 90e597b..6f9a0ba 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -123,6 +123,35 @@ def test_cross_file_type_annotation_refs_resolve_to_single_node(tmp_path): assert "_py" not in thing_nodes[0]["id"], thing_nodes[0]["id"] +def test_go_cross_file_type_refs_resolve_to_single_node(tmp_path): + """#1402 (Go): the sourceless-stub fix landed in six extractors but the Go copy + of ``ensure_named_node`` was missed, so a Go type defined once but referenced via + parameter/return types in N sibling files produced 1+N phantom duplicate nodes + with the referencing file's path (extension and all) baked into the id + (e.g. ``pkg_a_go_thing``). Same-package references must resolve to the single + canonical type node instead.""" + pkg = tmp_path / "pkg" + pkg.mkdir() + (pkg / "thing.go").write_text( + "package pkg\n\ntype Thing struct{}\n\nfunc (t Thing) Run() int { return 1 }\n", + encoding="utf-8", + ) + (pkg / "a.go").write_text( + "package pkg\n\nfunc UseA(obj Thing) Thing { return obj }\n", encoding="utf-8" + ) + (pkg / "b.go").write_text( + "package pkg\n\nfunc UseB(obj Thing) Thing { return obj }\n", encoding="utf-8" + ) + + result = extract([pkg / "thing.go", pkg / "a.go", pkg / "b.go"], 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 phantom signature is the referencing file's path (with .go extension) + # baked into the id — must not appear. + assert "_go" 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"