From 739940a42bc81cda6efb7f1b6b224cefc26bf45b Mon Sep 17 00:00:00 2001 From: rajatnagda45 Date: Sat, 19 Sep 2026 19:37:50 +0530 Subject: [PATCH] fix(go): extract method requirements declared in an interface body The interface_type branch only handled interface embedding and generic type-set constraints, so the method requirements that make up an interface's contract were dropped. An interface such as type Shape interface { Area() float64 Perimeter() (float64, error) } became an empty node - Area and Perimeter never entered the graph, and nothing could resolve against the interface's method set. Walk the method_elem children too, mirroring the receiver-method path: each requirement becomes a .Method() node hung off the interface via a method edge. Embedding (type_elem) keeps emitting embeds/references, so the two never blur. Adds regression coverage: requirements surface as method nodes, an embedded interface stays a heritage edge, and an interface method and a concrete struct method of the same name remain distinct nodes. --- graphify/extractors/go.py | 21 ++++++ tests/test_go_interface_methods.py | 108 +++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 tests/test_go_interface_methods.py diff --git a/graphify/extractors/go.py b/graphify/extractors/go.py index e6478d526f..a6bdd4ae6e 100644 --- a/graphify/extractors/go.py +++ b/graphify/extractors/go.py @@ -370,6 +370,27 @@ def walk(node) -> None: field.start_point[0] + 1, context=ctx) elif type_body.type == "interface_type": for elem in type_body.children: + if elem.type == "method_elem": + # A method requirement declared in the interface body + # is part of the interface's contract. Emit it as a + # method node so the interface isn't left an empty + # shell and calls against the interface can resolve + # (mirrors receiver methods and the way the other + # extractors capture interface members). + m_name_node = elem.child_by_field_name("name") + if m_name_node is None: + for mc in elem.children: + if mc.type == "field_identifier": + m_name_node = mc + break + if m_name_node is None: + continue + m_name = _read_text(m_name_node, source) + m_line = elem.start_point[0] + 1 + m_nid = symbol_nid(_make_id(type_nid, m_name), m_name) + add_node(m_nid, f".{m_name}()", m_line) + add_edge(type_nid, m_nid, "method", m_line) + continue if elem.type != "type_elem": continue # A type_elem that is a generics type-set constraint - diff --git a/tests/test_go_interface_methods.py b/tests/test_go_interface_methods.py new file mode 100644 index 0000000000..d90c846dde --- /dev/null +++ b/tests/test_go_interface_methods.py @@ -0,0 +1,108 @@ +"""Regression coverage for method requirements declared in a Go interface. + +The interface body carries the type's contract. Before the fix the extractor +only handled interface embedding and generic type-set constraints, so an +interface like ``type Reader interface { Read(p []byte) (int, error) }`` became +an empty node with no members - the method requirements were dropped entirely. +""" + +from pathlib import Path + +from graphify.extract import extract + + +def _extract(root: Path) -> dict: + return extract( + sorted(root.rglob("*.go")), + cache_root=root, + root=root, + parallel=False, + ) + + +def _methods_of(result: dict, type_label: str) -> set[str]: + """Labels of nodes reached by a ``method`` edge from the named type.""" + by_id = {node["id"]: node for node in result["nodes"]} + type_ids = {nid for nid, n in by_id.items() if n.get("label") == type_label} + return { + by_id[edge["target"]]["label"].strip(".()") + for edge in result["edges"] + if edge.get("relation") == "method" and edge.get("source") in type_ids + } + + +def test_interface_method_requirements_are_extracted(tmp_path: Path) -> None: + """Each method requirement becomes a method node under the interface.""" + (tmp_path / "go.mod").write_text("module example.com/repro\n\ngo 1.22\n") + (tmp_path / "shape.go").write_text( + "package repro\n\n" + "type Shape interface {\n" + "\tArea() float64\n" + "\tPerimeter() (float64, error)\n" + "}\n" + ) + + result = _extract(tmp_path) + assert _methods_of(result, "Shape") == {"Area", "Perimeter"} + + +def test_interface_embedding_still_produces_a_heritage_edge(tmp_path: Path) -> None: + """A bare embedded interface stays an ``embeds`` edge, not a method.""" + (tmp_path / "go.mod").write_text("module example.com/repro\n\ngo 1.22\n") + (tmp_path / "rw.go").write_text( + "package repro\n\n" + "type Reader interface {\n" + "\tRead(p []byte) (int, error)\n" + "}\n\n" + "type ReadCloser interface {\n" + "\tReader\n" + "\tClose() error\n" + "}\n" + ) + + result = _extract(tmp_path) + by_id = {node["id"]: node for node in result["nodes"]} + rc_ids = {nid for nid, n in by_id.items() if n.get("label") == "ReadCloser"} + reader_ids = {nid for nid, n in by_id.items() if n.get("label") == "Reader"} + + # The embedded interface is heritage, not a method of ReadCloser. + assert any( + edge.get("relation") == "embeds" + and edge.get("source") in rc_ids + and edge.get("target") in reader_ids + for edge in result["edges"] + ) + # Only the directly declared method requirement is a method of ReadCloser. + assert _methods_of(result, "ReadCloser") == {"Close"} + assert _methods_of(result, "Reader") == {"Read"} + + +def test_interface_method_is_distinct_from_a_concrete_method(tmp_path: Path) -> None: + """An interface's ``Area`` and a struct's ``Area`` are two separate nodes.""" + (tmp_path / "go.mod").write_text("module example.com/repro\n\ngo 1.22\n") + (tmp_path / "shape.go").write_text( + "package repro\n\n" + "type Shape interface {\n" + "\tArea() float64\n" + "}\n\n" + "type Rect struct{ W, H float64 }\n\n" + "func (r Rect) Area() float64 { return r.W * r.H }\n" + ) + + result = _extract(tmp_path) + by_id = {node["id"]: node for node in result["nodes"]} + shape_ids = {nid for nid, n in by_id.items() if n.get("label") == "Shape"} + rect_ids = {nid for nid, n in by_id.items() if n.get("label") == "Rect"} + + shape_area = { + edge["target"] + for edge in result["edges"] + if edge.get("relation") == "method" and edge.get("source") in shape_ids + } + rect_area = { + edge["target"] + for edge in result["edges"] + if edge.get("relation") == "method" and edge.get("source") in rect_ids + } + assert shape_area and rect_area + assert shape_area.isdisjoint(rect_area), "interface and struct methods collapsed"