From f7d2a5060341e8531c726d32d2b70f24cc8d80df Mon Sep 17 00:00:00 2001 From: xiongjianxu <6457197+xiongjianxu@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:10:11 +0800 Subject: [PATCH] feat(go): park a member call whose receiver type is in another repo (#3152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- graphify/cross_repo_calls.py | 1 + graphify/extract.py | 11 +++++++- tests/test_cross_repo_member_calls.py | 39 +++++++++++++++++++++++++-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/graphify/cross_repo_calls.py b/graphify/cross_repo_calls.py index 32117e182..2c232bde2 100644 --- a/graphify/cross_repo_calls.py +++ b/graphify/cross_repo_calls.py @@ -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"}), } diff --git a/graphify/extract.py b/graphify/extract.py index b102bd4fd..f6228bf59 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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: diff --git a/tests/test_cross_repo_member_calls.py b/tests/test_cross_repo_member_calls.py index fc6bc030e..817b29538 100644 --- a/tests/test_cross_repo_member_calls.py +++ b/tests/test_cross_repo_member_calls.py @@ -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],