feat(go): park a member call whose receiver type is in another repo (#3152)

The Go resolver had the receiver's type in hand and dropped the call when nothing
in the corpus declared it, so `graph.json` — the only artifact `merge-graphs` and
`global add` read — recorded nothing and no merge-time pass could recover it. A
merged graph of a Go service and a Go library was missing exactly the edges that
make it a call graph.

Parks the pair on the caller node like the Java/C++/C#/Swift resolvers already do,
and adds `"go": {".go"}` to the merge pass's language guard. `.go` alone: Go and
Java both turn up in one backend merge, and a shared type name across them is a
coincidence, not a namespace.

The `_key` case preservation the merge pass already documents is what makes this
correct for Go — `greet` is a different method from `Greet` and is not reachable
from another package at all.
This commit is contained in:
xiongjianxu committed 2026-10-13 11:32:39 +08:00
1 parent deca9fa366
commit f7d2a50603
3 files changed
+48 -3

No files matched your search

+1
View File
@@ -41,6 +41,7 @@ UNRESOLVED_CALLS_KEY = "unresolved_calls"
_LANG_SUFFIXES: dict[str, frozenset[str]] = {
"cpp": frozenset({".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".h", ".cu", ".cuh"}),
"csharp": frozenset({".cs"}),
"go": frozenset({".go"}),
"java": frozenset({".java"}),
"swift": frozenset({".swift"}),
}
+10 -1
View File
@@ -6812,6 +6812,9 @@ def _resolve_go_member_calls(
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.
A receiver typed to a type this corpus declares nowhere is parked on the caller for
a merged graph to finish (#3152).
"""
raw = [
rc
@@ -6857,7 +6860,13 @@ def _resolve_go_member_calls(
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)
if not type_defs:
# Declared nowhere here — usually "in a repo this build does not contain",
# so park it for the merge (#3152). The extractor's language tag already
# says who is asking, so no suffix sniff is needed.
_park_unresolved_member_call(node_by_id.get(caller), callee, type_name, "go", rc)
continue
if len(type_defs) != 1: # ambiguous -> bail (god-node guard)
continue
targets = method_index.get((type_defs[0], _key(callee)), [])
if len(targets) != 1:
+37 -2
View File
@@ -6,8 +6,8 @@ type already in hand and nothing about it reached `graph.json` — the only arti
`merge-graphs` and `global add` read. The two-repo graph was missing precisely the
edges that make it a call graph.
The Java, C++, C# and Swift resolvers now park those calls on the caller node and
this pass finishes them after the merge. The cases below pin what it must NOT do
The Java, C++, C#, Swift and Go resolvers now park those calls on the caller node
and this pass finishes them after the merge. The cases below pin what it must NOT do
as much as what it must: the single-definition guard, the cross-repo-only scope,
and the language guard are what keep it from fabricating an edge from a name
collision.
@@ -45,6 +45,7 @@ needs_java = _needs("tree_sitter_java")
needs_cpp = _needs("tree_sitter_cpp")
needs_csharp = _needs("tree_sitter_c_sharp")
needs_swift = _needs("tree_sitter_swift")
needs_go = _needs("tree_sitter_go")
def _caller(repo: str, parked: list[dict], node_id: str = "app_run",
@@ -250,6 +251,30 @@ def test_a_repo_that_stops_declaring_the_type_loses_the_edge():
assert _added_calls(G) == set()
PARKED_GO = [{"callee": "Greet", "receiver_type": "Greeter", "lang": "go", "line": "L7"}]
def test_a_go_call_does_not_bind_to_a_java_declaration():
# Go's suffix set is `.go` alone. Two backend services in one merge often hold one
# of each, and a shared type name there is a coincidence, not a namespace.
G = _graph(
caller=_caller("a", PARKED_GO, source_file="src/app.go"),
declarations=[(_declaration("b", "Greeter"), _method("b", ".Greet()"))],
)
assert link_cross_repo_member_calls(G) == 0
def test_a_go_call_does_not_bind_to_a_differently_cased_method():
# Go exports by capitalisation, so `greet` is a different method from `Greet` and
# is unreachable from another package at all.
G = _graph(
caller=_caller("a", PARKED_GO, source_file="src/app.go"),
declarations=[(_declaration("b", "Greeter", "greeter.go"),
_method("b", ".greet()", source_file="greeter.go"))],
)
assert link_cross_repo_member_calls(G) == 0
def _write_graph(path: Path, nodes: list[dict], links: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"directed": False, "multigraph": False, "graph": {},
@@ -374,6 +399,16 @@ def test_a_java_build_parks_the_call_and_the_merge_finishes_it(tmp_path: Path):
("src/Greeter.swift", "class Greeter { func greet() {} }\n"),
marks=needs_swift, id="swift-property-receiver",
),
pytest.param(
# A struct field used from a method is the shape Go dependency injection takes,
# and the field's declared type is the only place the receiver is named.
"go", "Greet",
("src/app.go", "package svc\n\ntype App struct {\n\tgreeter *Greeter\n}\n\n"
"func (a *App) Run() { a.greeter.Greet() }\n"),
("src/greeter.go", "package svc\n\ntype Greeter struct{}\n\n"
"func (g *Greeter) Greet() {}\n"),
marks=needs_go, id="go-struct-field",
),
])
def test_each_language_parks_the_call_and_the_merge_finishes_it(
tmp_path: Path, lang: str, callee: str, app_file: tuple[str, str],