feat(go): resolve member calls through the receiver's declared type

`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.
This commit is contained in:
xiongjianxu committed 2026-10-13 11:32:39 +08:00
1 parent a9860f70dc
commit deca9fa366
3 files changed
+354 -7

No files matched your search

+92
View File
@@ -6795,6 +6795,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],
@@ -7144,6 +7230,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.
+101 -7
View File
@@ -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},
}
+161
View File
@@ -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"