mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-14 07:59:40 +00:00
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.
(cherry picked from commit deca9fa366)
This commit is contained in:
1 parent
15d2312b03
commit
1695efae1a
3 files changed
+354
-7
No files matched your search
@@ -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.
|
||||
|
||||
+101
-7
@@ -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},
|
||||
}
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user