From 1695efae1ad356500166c9456ee5231adfc6389e Mon Sep 17 00:00:00 2001 From: xiongjianxu <6457197+xiongjianxu@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:06:35 +0800 Subject: [PATCH] feat(go): resolve member calls through the receiver's declared type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `g.Greet()` where `Greeter` is declared in another file produced no edge. The shared cross-file pass skips member calls, and the Go extractor read the receiver's identifier only to discard it for anything that was not an imported package — so the one shape Go dependency injection actually takes (a struct field used from a method) contributed nothing to the call graph. `extract_go` now exports a per-file `go_type_table` built from the four places a Go receiver's type is written down: a struct field, a parameter (which covers the method receiver `func (s *Server)`), `var x T`, and `x := T{}` / `x := &T{}`. `_resolve_go_member_calls` looks the receiver up there, takes the single declaration of that type and emits the `calls` edge to its method. Also stamps `_callable` / `_callable_class` on Go declarations (#2438). Go is the one extractor outside the tree-sitter engine, so its nodes carried neither, which left them invisible to every consumer that reads the markers off the node. Design notes: - The receiver travels as `member_receiver`, not `receiver`: in this extractor `receiver` means "imported package", and the Swift/Python/Ruby member-call resolvers select on `is_member_call` plus `receiver` without checking the language, so a Go name there binds to their types in a mixed corpus. - `s.logger.Log()` is read as a call on the `logger` field only when `s` is the enclosing method's own receiver. The table is flat and file-scoped, so any other head could be a package or a variable whose type it cannot confirm. - Names are matched case-sensitively, unlike the sibling resolvers. Go exports by capitalisation, so `Run` and `run` on one type are two different methods. - Go does not defer: a member call reaches `raw_calls` only once the bare callee name misses in the caller's file, so in-file behaviour is byte-identical. - Two packages declaring the same name stay two type nodes and the single-definition guard bails — without import evidence neither is the answer. (cherry picked from commit deca9fa3668cbbe6a092fef1c84b81f2be822459) --- graphify/extract.py | 92 ++++++++++++++ graphify/extractors/go.py | 108 +++++++++++++++-- tests/test_go_receiver_member_calls.py | 161 +++++++++++++++++++++++++ 3 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 tests/test_go_receiver_member_calls.py diff --git a/graphify/extract.py b/graphify/extract.py index a34616ddc..454e8d302 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6796,6 +6796,92 @@ def _resolve_rust_self_member_calls( }) +def _resolve_go_member_calls( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Resolve Go member calls (``g.Greet()``) through the receiver's declared type. + + The shared cross-file pass skips member calls, so a method call on a receiver whose + type is declared in another file resolved to nothing. The per-file ``go_type_table`` + names the declared type of every struct field, parameter (the method receiver + included), ``var x T`` and ``x := T{}`` binding; this pass looks the receiver up + there, takes the single declaration of that type, and emits the ``calls`` edge to its + method. Always INFERRED: the type comes from the table, never from the call site. + + Names are matched case-sensitively, unlike the sibling resolvers. Go exports by + capitalisation, so `Run` and `run` on one type are two different methods with two + different visibilities, and folding them would pick whichever came last. + """ + raw = [ + rc + for result in per_file + for rc in result.get("raw_calls", []) + if rc.get("language") == "go" and rc.get("is_member_call") + and rc.get("member_receiver") and rc.get("callee") and rc.get("caller_nid") + ] + if not raw: + return + type_table_by_file: dict[str, dict[str, str]] = {} + for result in per_file: + tt = result.get("go_type_table") + if tt and tt.get("path"): + type_table_by_file[tt["path"]] = tt.get("table", {}) + + def _key(label: object) -> str: + return str(label or "").strip().removeprefix(".").removesuffix("()") + + # A Go type node id folds in the package directory, so two packages declaring the + # same name stay two entries and the single-definition guard below bails: without + # import evidence neither one is the answer. + type_def_nids: dict[str, list[str]] = {} + node_by_id: dict[str, dict] = {} + for n in all_nodes: + node_by_id[n.get("id")] = n + if n.get("_callable_class") and n.get("source_file"): + type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) + + method_index: dict[tuple[str, str], list[str]] = {} + for e in all_edges: + if e.get("relation") != "method": + continue + tnode = node_by_id.get(e.get("target")) + if tnode is not None: + method_index.setdefault( + (e.get("source"), _key(tnode.get("label", ""))), []).append(e["target"]) + + existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} + for rc in raw: + receiver, callee, caller = rc["member_receiver"], rc["callee"], rc["caller_nid"] + type_name = type_table_by_file.get(rc.get("source_file", ""), {}).get(receiver) + if not type_name or type_name in _LANGUAGE_BUILTIN_GLOBALS: + continue + type_defs = type_def_nids.get(_key(type_name), []) + if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard) + continue + targets = method_index.get((type_defs[0], _key(callee)), []) + if len(targets) != 1: + continue + target = targets[0] + if target == caller or (caller, target) in existing_pairs: + continue + existing_pairs.add((caller, target)) + all_edges.append({ + "source": caller, + "target": target, + "relation": "calls", + "context": "call", + "confidence": "INFERRED", + # The rubric's discrete INFERRED scale (references/extraction-spec.md): + # a single-definition type-table hit is the high-confidence rung. + "confidence_score": 0.85, + "source_file": rc.get("source_file", ""), + "source_location": rc.get("source_location"), + "weight": 1.0, + }) + + def _resolve_elixir_import_targets( per_file: list[dict], all_nodes: list[dict], @@ -7145,6 +7231,12 @@ register_language_resolver( "kotlin_member_calls", frozenset({".kt", ".kts"}), _resolve_kotlin_member_calls ) ) +# Go receiver-typed member-call resolution: `g.Greet()` where the method is declared in +# another file. The shared pass skips member calls, so these had no edge unless the bare +# method name happened to match a symbol in the caller's own file. +register_language_resolver( + LanguageResolver("go_member_calls", frozenset({".go"}), _resolve_go_member_calls) +) # C# qualified construction (#2997): `new A.B.Cache()` arrives as the bare name, # so a colliding `Cache` elsewhere makes it ambiguous. Runs in the tail registry # beside csharp_member_calls and matches the prefix against declared namespaces. diff --git a/graphify/extractors/go.py b/graphify/extractors/go.py index 71464f72b..3f0ede9a7 100644 --- a/graphify/extractors/go.py +++ b/graphify/extractors/go.py @@ -133,6 +133,58 @@ def _go_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[st if c.is_named: _go_collect_type_refs(c, source, generic, out, skip) +def _go_single_type_name(node, source: bytes) -> str | None: + """The one declared type a Go type expression names, or None. + + Only `Greeter` and `*Greeter` qualify. A slice, map or channel element is not the + receiver of `x.M()`, and a `qualified_type` (`pkg.Greeter`) names a type no bare + local name can stand for. + """ + if node is not None and node.type == "pointer_type": + node = next((c for c in node.children if c.is_named), None) + if node is None or node.type != "type_identifier": + return None + name = _read_text(node, source) + return name if name and name not in _GO_PREDECLARED_TYPES else None + + +def _go_receiver_type_table(root, source: bytes) -> dict[str, str]: + """Collect ``name -> TypeName`` for every Go receiver whose type is written down. + + Four sources: a struct field, a parameter (which covers the method receiver + `func (s *Server)`), `var x T`, and `x := T{}` / `x := &T{}`. File-scoped and flat, + first binding wins — a parameter shadowing a field in another function must not + retype the field's own calls. Children are pushed reversed so the walk yields + document order and "first" means first in the file. + """ + table: dict[str, str] = {} + stack = [root] + while stack: + n = stack.pop() + t = n.type + if t in ("field_declaration", "parameter_declaration", "var_spec"): + type_name = _go_single_type_name(n.child_by_field_name("type"), source) + for c in n.children if type_name else (): + if c.type in ("field_identifier", "identifier"): + table.setdefault(_read_text(c, source), type_name) + elif t == "short_var_declaration": + left, right = n.child_by_field_name("left"), n.child_by_field_name("right") + if left is not None and right is not None: + names = [c for c in left.children if c.type == "identifier"] + values = [c for c in right.children if c.is_named] + for name_node, value in zip(names, values): + if value.type == "unary_expression": + value = value.child_by_field_name("operand") or value + if value.type != "composite_literal": + continue + type_name = _go_single_type_name( + value.child_by_field_name("type"), source) + if type_name: + table.setdefault(_read_text(name_node, source), type_name) + stack.extend(reversed(n.children)) + return table + + def extract_go(path: Path) -> dict: """Extract functions, methods, type declarations, and imports from a .go file.""" try: @@ -158,7 +210,13 @@ def extract_go(path: Path) -> dict: nodes: list[dict] = [] edges: list[dict] = [] seen_ids: set[str] = set() - function_bodies: list[tuple[str, object]] = [] + # `(caller nid, body, receiver variable name)` — the receiver name is what makes + # `s.logger.Log()` readable as a call on the `logger` field rather than on `s`. + function_bodies: list[tuple[str, object, str | None]] = [] + # Fed to the `_callable` / `_callable_class` stamp below (#2438): the indirect-call + # guard and the cross-repo member-call pass both read them off the node. + callable_nids: set[str] = set() + callable_class_nids: set[str] = set() # local package name (including aliases) -> written Go import path go_imported_pkgs: dict[str, str] = {} @@ -351,22 +409,27 @@ def extract_go(path: Path) -> dict: line = node.start_point[0] + 1 func_nid = symbol_nid(_make_id(stem, func_name), func_name) add_node(func_nid, f"{func_name}()", line) + callable_nids.add(func_nid) add_edge(file_nid, func_nid, "contains", line) emit_go_method_refs(node, func_nid, line) body = node.child_by_field_name("body") if body: - function_bodies.append((func_nid, body)) + function_bodies.append((func_nid, body, None)) return if t == "method_declaration": receiver = node.child_by_field_name("receiver") receiver_type: str | None = None + receiver_name: str | None = None if receiver: for param in receiver.children: if param.type == "parameter_declaration": type_node = param.child_by_field_name("type") if type_node: receiver_type = _read_text(type_node, source).lstrip("*").strip() + name_node = param.child_by_field_name("name") + if name_node: + receiver_name = _read_text(name_node, source) break name_node = node.child_by_field_name("name") if not name_node: @@ -377,6 +440,7 @@ def extract_go(path: Path) -> dict: if receiver_type: parent_nid = _make_id(pkg_scope, receiver_type) add_node(parent_nid, receiver_type, line) + callable_class_nids.add(parent_nid) method_nid = symbol_nid(_make_id(parent_nid, method_name), method_name) add_node(method_nid, f".{method_name}()", line) add_edge(parent_nid, method_nid, "method", line) @@ -384,11 +448,12 @@ def extract_go(path: Path) -> dict: method_nid = symbol_nid(_make_id(stem, method_name), method_name) add_node(method_nid, f"{method_name}()", line) add_edge(file_nid, method_nid, "contains", line) + callable_nids.add(method_nid) emit_go_method_refs(node, method_nid, line) body = node.child_by_field_name("body") if body: - function_bodies.append((method_nid, body)) + function_bodies.append((method_nid, body, receiver_name)) return if t == "type_declaration": @@ -402,6 +467,7 @@ def extract_go(path: Path) -> dict: line = child.start_point[0] + 1 type_nid = _make_id(pkg_scope, type_name) add_node(type_nid, type_name, line) + callable_class_nids.add(type_nid) add_edge(file_nid, type_nid, "contains", line) # A generic type's own parameters (`type Box[T any]`) are local # bindings; a field or method using `T` must not mint a stub. @@ -533,6 +599,16 @@ def extract_go(path: Path) -> dict: _scan_declarations(root) walk(root) + # A type carries both markers, as in the tree-sitter engine: `_callable_class` is + # the narrowing, not a separate kind. + for n in nodes: + if n["id"] in callable_nids or n["id"] in callable_class_nids: + n["_callable"] = True + if n["id"] in callable_class_nids: + n["_callable_class"] = True + + type_table = _go_receiver_type_table(root, source) + label_to_nid: dict[str, str] = {} for n in nodes: raw = n["label"] @@ -542,7 +618,7 @@ def extract_go(path: Path) -> dict: seen_call_pairs: set[tuple[str, str]] = set() raw_calls: list[dict] = [] - def walk_calls(node, caller_nid: str) -> None: + def walk_calls(node, caller_nid: str, self_name: str | None) -> None: if node.type in ("function_declaration", "method_declaration"): return if node.type == "call_expression": @@ -551,6 +627,7 @@ def extract_go(path: Path) -> dict: is_member_call: bool = False is_bare_identifier: bool = False package_receiver: str | None = None + member_receiver: str | None = None import_path: str | None = None if func_node: if func_node.type == "identifier": @@ -566,6 +643,17 @@ def extract_go(path: Path) -> dict: if not is_member_call: package_receiver = receiver_name import_path = go_imported_pkgs[receiver_name] + elif operand is not None and operand.type == "identifier": + member_receiver = receiver_name + elif operand is not None and operand.type == "selector_expression": + # `s.logger.Log()` names the receiver by the field only when `s` is + # this method's own receiver; any other head could be a package or + # a variable whose type the flat table does not know. + inner = operand.child_by_field_name("operand") + if (self_name and inner is not None and inner.type == "identifier" + and _read_text(inner, source) == self_name): + member_receiver = _read_text( + operand.child_by_field_name("field"), source) if field: callee_name = _read_text(field, source) if is_bare_identifier and callee_name in _GO_PREDECLARED_FUNCS: @@ -599,15 +687,20 @@ def extract_go(path: Path) -> dict: "is_member_call": is_member_call, "language": "go", "receiver": package_receiver, + # A key of its own: `receiver` means "imported package" here, and + # the Swift/Python/Ruby member-call resolvers select on + # `is_member_call` plus `receiver` without checking the language, + # so a Go name placed there binds to their types in a mixed corpus. + "member_receiver": member_receiver, "import_path": import_path, "source_file": str_path, "source_location": f"L{node.start_point[0] + 1}", }) for child in node.children: - walk_calls(child, caller_nid) + walk_calls(child, caller_nid, self_name) - for caller_nid, body_node in function_bodies: - walk_calls(body_node, caller_nid) + for caller_nid, body_node, self_name in function_bodies: + walk_calls(body_node, caller_nid, self_name) valid_ids = seen_ids clean_edges = [] @@ -621,4 +714,5 @@ def extract_go(path: Path) -> dict: "edges": clean_edges, "raw_calls": raw_calls, "go_imports": dict(go_imported_pkgs), + "go_type_table": {"path": str_path, "table": type_table}, } diff --git a/tests/test_go_receiver_member_calls.py b/tests/test_go_receiver_member_calls.py new file mode 100644 index 000000000..beeace3bd --- /dev/null +++ b/tests/test_go_receiver_member_calls.py @@ -0,0 +1,161 @@ +"""Go member calls resolve through the receiver's declared type. + +The shared cross-file pass skips member calls and the Go extractor read the receiver's +name only to throw it away, so `g.Greet()` on a receiver whose type lives in another +file produced no edge — the Go twin of the Swift gap in #1356. Each case below pins one +source of the receiver's type, and the negative cases pin what must stay unresolved: a +constructor return, a chain that does not start at the method's own receiver, a +package-qualified type, and an ambiguous type name. +""" +from __future__ import annotations + +import importlib + +import pytest + +from graphify.extract import extract + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("tree_sitter_go") is None, + reason="tree_sitter_go not installed", +) + +GREETER = "package svc\n\ntype Greeter struct{}\n\nfunc (g *Greeter) Greet() {}\n" + + +def _calls(tmp_path, files: dict[str, str]): + for name, body in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + result = extract([tmp_path / n for n in files], + cache_root=tmp_path / "graphify-out", parallel=False) + label = {n["id"]: n["label"] for n in result["nodes"]} + calls = {(label.get(e["source"]), label.get(e["target"])): e + for e in result["edges"] if e["relation"] == "calls"} + return calls, result + + +def _greet_edge(calls: dict) -> dict | None: + return next((e for (src, tgt), e in calls.items() + if src and "un()" in src and tgt == ".Greet()"), None) + + +def test_a_typed_parameter_types_the_receiver(tmp_path): + calls, _ = _calls(tmp_path, { + "svc/greeter.go": GREETER, + "svc/app.go": "package svc\n\nfunc Run(g *Greeter) { g.Greet() }\n", + }) + edge = _greet_edge(calls) + assert edge is not None, calls + # The type came from the table, never from the call site: `g` names a variable, so + # there is no spelling of this call that would be exact. + assert edge["confidence"] == "INFERRED" + + +def test_a_struct_field_types_the_receiver_through_the_methods_own_receiver(tmp_path): + # The dominant Go shape: the dependency is held as a field and used from a method. + calls, _ = _calls(tmp_path, { + "svc/greeter.go": GREETER, + "svc/app.go": "package svc\n\ntype App struct {\n\tgreeter *Greeter\n}\n\n" + "func (a *App) Run() { a.greeter.Greet() }\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_a_var_declaration_types_the_receiver(tmp_path): + calls, _ = _calls(tmp_path, { + "svc/greeter.go": GREETER, + "svc/app.go": "package svc\n\nfunc Run() {\n\tvar g Greeter\n\tg.Greet()\n}\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_a_composite_literal_binding_types_the_receiver(tmp_path): + calls, _ = _calls(tmp_path, { + "svc/greeter.go": GREETER, + "svc/app.go": "package svc\n\nfunc Run() {\n\tg := Greeter{}\n\tg.Greet()\n}\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_an_address_of_composite_literal_types_the_receiver(tmp_path): + calls, _ = _calls(tmp_path, { + "svc/greeter.go": GREETER, + "svc/app.go": "package svc\n\nfunc Run() {\n\tg := &Greeter{}\n\tg.Greet()\n}\n", + }) + assert _greet_edge(calls) is not None, calls + + +def test_a_constructor_return_resolves_to_nothing(tmp_path): + # `g := NewGreeter()` types `g` only by reading the constructor's return type, which + # this pass does not do; nothing in the file writes `Greeter` next to `g`. + calls, _ = _calls(tmp_path, { + "svc/greeter.go": GREETER + "\nfunc NewGreeter() *Greeter { return &Greeter{} }\n", + "svc/app.go": "package svc\n\nfunc Run() {\n\tg := NewGreeter()\n\tg.Greet()\n}\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_a_chain_not_rooted_at_the_methods_receiver_resolves_to_nothing(tmp_path): + # `p.greeter.Greet()` on a parameter: the flat table cannot say that `greeter` is a + # field of `p`'s type rather than of some other type declared in the file. + calls, _ = _calls(tmp_path, { + "svc/greeter.go": GREETER, + "svc/app.go": "package svc\n\ntype App struct {\n\tgreeter *Greeter\n}\n\n" + "func Run(p *App) { p.greeter.Greet() }\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_a_package_qualified_type_resolves_to_nothing(tmp_path): + # `other.Greeter` names a type in another package; binding it to the local `Greeter` + # by bare name would ignore the qualifier. + calls, _ = _calls(tmp_path, { + "svc/greeter.go": GREETER, + "svc/app.go": "package svc\n\nimport \"example.com/other\"\n\n" + "func Run(g *other.Greeter) { g.Greet() }\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_two_packages_declaring_the_same_type_resolve_to_neither(tmp_path): + # The single-definition guard: without import evidence, guessing one of two + # `Greeter`s is worse than leaving the call unresolved. + calls, _ = _calls(tmp_path, { + "a/greeter.go": GREETER.replace("package svc", "package a"), + "b/greeter.go": GREETER.replace("package svc", "package b"), + "svc/app.go": "package svc\n\nfunc Run(g *Greeter) { g.Greet() }\n", + }) + assert _greet_edge(calls) is None, calls + + +def test_the_first_binding_of_a_name_wins(tmp_path): + # The table is flat per file, so a parameter named like an earlier one has to lose: + # otherwise `other`'s signature would redirect the first function's calls. + calls, result = _calls(tmp_path, { + "svc/greeter.go": GREETER, + "svc/other.go": "package svc\n\ntype Other struct{}\n\nfunc (o *Other) Greet() {}\n", + "svc/app.go": "package svc\n\nfunc Run(g *Greeter) { g.Greet() }\n\n" + "func Also(g *Other) { g.Greet() }\n", + }) + source_of = {n["id"]: str(n.get("source_file") or "") for n in result["nodes"]} + label_of = {n["id"]: n["label"] for n in result["nodes"]} + run_targets = {source_of[e["target"]] for e in result["edges"] + if e["relation"] == "calls" + and label_of.get(e["source"]) == "Run()" + and label_of.get(e["target"]) == ".Greet()"} + assert len(run_targets) == 1, run_targets + assert run_targets.pop().endswith("greeter.go") + + +def test_a_same_file_method_call_keeps_its_extracted_edge(tmp_path): + # In-file resolution still happens by bare label before this pass, so the edge must + # stay EXTRACTED and must not be doubled. + calls, _ = _calls(tmp_path, { + "svc/app.go": "package svc\n\ntype App struct{}\n\nfunc (a *App) Greet() {}\n\n" + "func Run(a *App) { a.Greet() }\n", + }) + edges = [e for (src, tgt), e in calls.items() if src == "Run()" and tgt == ".Greet()"] + assert len(edges) == 1, calls + assert edges[0]["confidence"] == "EXTRACTED"