From f7bc904bda0945a740d3973c9d562b3ea5e3f396 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Tue, 22 Sep 2026 02:47:17 -0500 Subject: [PATCH] Preserve case-only interface signature owners (cherry picked from commit 72d5914a10891e25f0bdd77428ccdbc9d4c26680) --- graphify/extractors/go.py | 15 ++++++++ tests/test_go_interface_methods.py | 60 ++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/graphify/extractors/go.py b/graphify/extractors/go.py index 2dde79bd2..5c22afccd 100644 --- a/graphify/extractors/go.py +++ b/graphify/extractors/go.py @@ -251,6 +251,21 @@ def extract_go(path: Path) -> dict: return _make_id(base, name), name def _scan_declarations(node) -> None: + if node.type == "type_spec": + name_node = node.child_by_field_name("name") + if name_node is not None: + owner_nid = _make_id(pkg_scope, _read_text(name_node, source)) + for body in node.children: + if body.type != "interface_type": + continue + for elem in body.children: + if elem.type != "method_elem": + continue + method_name = elem.child_by_field_name("name") + if method_name is not None: + name = _read_text(method_name, source) + plain_nid = _make_id(owner_nid, name) + case_groups.setdefault(plain_nid, set()).add(name) if node.type in ("function_declaration", "method_declaration"): found = _plain_symbol_nid(node) if found: diff --git a/tests/test_go_interface_methods.py b/tests/test_go_interface_methods.py index f615a1876..98e031f78 100644 --- a/tests/test_go_interface_methods.py +++ b/tests/test_go_interface_methods.py @@ -165,3 +165,63 @@ def test_interface_method_signature_keeps_type_references(tmp_path: Path, signat warm = _extract(tmp_path) assert _signature_refs(warm, "Service") == (interface_id, interface_refs) assert _signature_refs(warm, "Impl") == (concrete_id, concrete_refs) + + +@pytest.mark.parametrize("names", [("Run", "run"), ("run", "Run"), ("Run", "RUN"), ("RUN", "Run")]) +def test_case_only_interface_methods_keep_separate_signature_owners(tmp_path: Path, names): + path = tmp_path / "contract.go" + path.write_text( + "package contracts\n\n" + "type InputA struct{}\n" + "type InputB struct{}\n" + "type Service interface {\n" + f" {names[0]}(InputA)\n" + f" {names[1]}(InputB)\n" + "}\n" + "type Other interface { Run(InputA) }\n", + encoding="utf-8", + ) + + def owned_refs(result): + by_id = {node["id"]: node for node in result["nodes"]} + methods = { + by_id[edge["target"]]["label"]: edge["target"] + for edge in result["edges"] + if edge.get("relation") == "method" + and by_id[edge["source"]].get("label") == "Service" + } + assert set(methods) == {f".{name}()" for name in names} + assert len(set(methods.values())) == 2 + for name, expected in zip(names, ("InputA", "InputB")): + refs = [ + edge for edge in result["edges"] + if edge.get("relation") == "references" + and edge["source"] == methods[f".{name}()"] + ] + assert len(refs) == 1 + assert by_id[refs[0]["target"]]["label"] == expected + assert refs[0]["context"] == "parameter_type" + other_id, other_refs = _signature_refs(result, "Other") + assert other_id not in methods.values() + assert [edge["target_label"] for edge in other_refs] == ["InputA"] + return methods, other_id + + result = _extract(tmp_path) + methods, other_id = owned_refs(result) + assert owned_refs(_extract(tmp_path)) == (methods, other_id) + + # Declaration order cannot choose the canonical owner of a collision. + path.write_text(path.read_text().replace( + f" {names[0]}(InputA)\n {names[1]}(InputB)\n", + f" {names[1]}(InputB)\n {names[0]}(InputA)\n", + )) + assert owned_refs(_extract(tmp_path)) == (methods, other_id) + + # Removing the private sibling preserves the exported ID and other owners. + if "run" in names: + source = path.read_text() + path.write_text("\n".join(line for line in source.splitlines() if " run(" not in line)) + without_private = _extract(tmp_path) + exported_id, _ = _signature_refs(without_private, "Service") + assert exported_id == methods[".Run()"] + assert _signature_refs(without_private, "Other")[0] == other_id