diff --git a/graphify/cross_repo_calls.py b/graphify/cross_repo_calls.py index 553af7eaf..b8aab7d39 100644 --- a/graphify/cross_repo_calls.py +++ b/graphify/cross_repo_calls.py @@ -41,6 +41,7 @@ _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 d54b841d9..f9d4c2a2e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4838,6 +4838,106 @@ 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 a declaration, 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. + + A receiver typed to a type this corpus declares nowhere is parked on the caller for + a merged graph to finish (#3152). + """ + 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. + # Only Go declarations count: `_callable_class` is corpus-wide, so a same-named Java + # class would both answer a Go receiver and hide that nothing local declares it. + 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 _lang_family(n.get("source_file")) == "go": + 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"] + # The enclosing method's own receiver is typed by its declaration, which beats the + # flat table: two types in one file may both name their receiver `s`. + type_name = rc.get("receiver_type") or 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 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: + 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], @@ -4998,6 +5098,12 @@ def _resolve_elixir_import_targets( "kotlin_qualified_calls", frozenset({".kt", ".kts"}), _resolve_kotlin_qualified_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 e6478d526..c03991b09 100644 --- a/graphify/extractors/go.py +++ b/graphify/extractors/go.py @@ -82,6 +82,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) +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{}`. Flat over the subtree it is + given, first binding wins — run over one function it is that function's own scope, and + over the file it is the fallback for a struct field. Children are pushed reversed so the + walk yields document order and "first" means first in the source. + """ + 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: @@ -107,7 +159,15 @@ 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, receiver type, own-scope types)` — the + # receiver name is what makes `s.logger.Log()` readable as a call on the `logger` field + # rather than on `s`, and the scope types are the bindings the file-flat table can get + # wrong: every function in a file is free to name a parameter `s` after its own type. + function_bodies: list[tuple[str, object, str | None, str | None, dict[str, str]]] = [] + # 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] = {} @@ -279,22 +339,29 @@ def walk(node) -> None: 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, None, + _go_receiver_type_table(node, source))) 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: @@ -305,6 +372,7 @@ def walk(node) -> None: 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) @@ -312,11 +380,14 @@ def walk(node) -> None: 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, receiver_type, + _go_receiver_type_table(node, source))) return if t == "type_declaration": @@ -330,6 +401,7 @@ def walk(node) -> None: 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) # Type body: struct fields (with embeds) or interface embedding. type_body = None @@ -436,6 +508,16 @@ def walk(node) -> None: _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"] @@ -445,7 +527,8 @@ def walk(node) -> None: 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, + self_type: str | None, scope_types: dict[str, str]) -> None: if node.type in ("function_declaration", "method_declaration"): return if node.type == "call_expression": @@ -454,6 +537,8 @@ def walk_calls(node, caller_nid: str) -> None: is_member_call: bool = False is_bare_identifier: bool = False package_receiver: str | None = None + member_receiver: str | None = None + member_receiver_type: str | None = None import_path: str | None = None if func_node: if func_node.type == "identifier": @@ -469,6 +554,21 @@ def walk_calls(node, caller_nid: str) -> None: 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 + if self_name and receiver_name == self_name: + member_receiver_type = self_type + else: + member_receiver_type = scope_types.get(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: @@ -502,15 +602,24 @@ def walk_calls(node, caller_nid: str) -> None: "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, + # The enclosing function's own receiver and parameters/locals, whose + # declarations are in scope here; the flat table is the fallback for + # a struct field, and must not answer a name this scope rebinds. + "receiver_type": member_receiver_type, "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, self_type, scope_types) - for caller_nid, body_node in function_bodies: - walk_calls(body_node, caller_nid) + for caller_nid, body_node, self_name, self_type, scope_types in function_bodies: + walk_calls(body_node, caller_nid, self_name, self_type, scope_types) valid_ids = seen_ids clean_edges = [] @@ -524,4 +633,5 @@ def walk_calls(node, caller_nid: str) -> None: "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_cross_repo_member_calls.py b/tests/test_cross_repo_member_calls.py index 3c1672464..1f8dab48f 100644 --- a/tests/test_cross_repo_member_calls.py +++ b/tests/test_cross_repo_member_calls.py @@ -6,8 +6,8 @@ `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 @@ def _needs(module: str): 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", @@ -249,6 +250,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": {}, @@ -373,6 +398,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], diff --git a/tests/test_go_receiver_member_calls.py b/tests/test_go_receiver_member_calls.py new file mode 100644 index 000000000..a3a74caa6 --- /dev/null +++ b/tests/test_go_receiver_member_calls.py @@ -0,0 +1,186 @@ +"""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_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" + + +def test_a_type_from_another_language_never_answers_a_go_receiver(tmp_path): + # The declaration index is corpus-wide, so a same-named Java class would both answer + # the receiver and hide that no Go file declares it — the call belongs to the merge. + calls, result = _calls(tmp_path, { + "svc/app.go": "package svc\n\nfunc Run(g *Greeter) { g.Greet() }\n", + "java/Greeter.java": "public class Greeter { public void Greet() {} }\n", + }) + assert _greet_edge(calls) is None, calls + parked = [(n.get("metadata") or {}).get("unresolved_calls") + for n in result["nodes"] if n["label"] == "Run()"] + assert parked == [[{"callee": "Greet", "receiver_type": "Greeter", + "lang": "go", "line": "L3"}]], parked + + +def test_the_methods_own_receiver_outranks_an_earlier_binding_of_the_name(tmp_path): + # `s` is bound to `Server` earlier in the file, but inside `Get` it is the method's own + # receiver: a `Store` with no `Save` must stay unresolved rather than reach Server's. + calls, _ = _calls(tmp_path, { + "svc/a.go": "package svc\n\nfunc Run(s *Server) {}\n\n" + "type Store struct{}\n\nfunc (s *Store) Get() { s.Save() }\n", + "svc/b.go": "package svc\n\ntype Server struct{}\n\nfunc (s *Server) Save() {}\n", + }) + assert (".Get()", ".Save()") not in calls, calls + + +def test_a_parameter_binds_only_inside_its_own_function(tmp_path): + # `s`, `c`, `w`, `r` get reused as parameter names by every function in a Go file, so a + # table flat over the file answers one function's receiver with another's type. + 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"]} + targets = {} + for e in result["edges"]: + if e["relation"] == "calls" and label_of.get(e["target"]) == ".Greet()": + targets.setdefault(label_of.get(e["source"]), set()).add(source_of[e["target"]]) + assert len(targets["Run()"]) == 1 and targets["Run()"].pop().endswith("greeter.go"), targets + assert len(targets["Also()"]) == 1 and targets["Also()"].pop().endswith("other.go"), targets