mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-15 08:29:26 +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.
This commit is contained in:
1 parent
a9860f70dc
commit
deca9fa366
3 files changed
+354
-7
No files matched your search
@@ -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(
|
def _resolve_elixir_import_targets(
|
||||||
per_file: list[dict],
|
per_file: list[dict],
|
||||||
all_nodes: list[dict],
|
all_nodes: list[dict],
|
||||||
@@ -7144,6 +7230,12 @@ register_language_resolver(
|
|||||||
"kotlin_member_calls", frozenset({".kt", ".kts"}), _resolve_kotlin_member_calls
|
"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,
|
# 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
|
# so a colliding `Cache` elsewhere makes it ambiguous. Runs in the tail registry
|
||||||
# beside csharp_member_calls and matches the prefix against declared namespaces.
|
# 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:
|
if c.is_named:
|
||||||
_go_collect_type_refs(c, source, generic, out, skip)
|
_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:
|
def extract_go(path: Path) -> dict:
|
||||||
"""Extract functions, methods, type declarations, and imports from a .go file."""
|
"""Extract functions, methods, type declarations, and imports from a .go file."""
|
||||||
try:
|
try:
|
||||||
@@ -158,7 +210,13 @@ def extract_go(path: Path) -> dict:
|
|||||||
nodes: list[dict] = []
|
nodes: list[dict] = []
|
||||||
edges: list[dict] = []
|
edges: list[dict] = []
|
||||||
seen_ids: set[str] = set()
|
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
|
# local package name (including aliases) -> written Go import path
|
||||||
go_imported_pkgs: dict[str, str] = {}
|
go_imported_pkgs: dict[str, str] = {}
|
||||||
|
|
||||||
@@ -351,22 +409,27 @@ def extract_go(path: Path) -> dict:
|
|||||||
line = node.start_point[0] + 1
|
line = node.start_point[0] + 1
|
||||||
func_nid = symbol_nid(_make_id(stem, func_name), func_name)
|
func_nid = symbol_nid(_make_id(stem, func_name), func_name)
|
||||||
add_node(func_nid, f"{func_name}()", line)
|
add_node(func_nid, f"{func_name}()", line)
|
||||||
|
callable_nids.add(func_nid)
|
||||||
add_edge(file_nid, func_nid, "contains", line)
|
add_edge(file_nid, func_nid, "contains", line)
|
||||||
emit_go_method_refs(node, func_nid, line)
|
emit_go_method_refs(node, func_nid, line)
|
||||||
body = node.child_by_field_name("body")
|
body = node.child_by_field_name("body")
|
||||||
if body:
|
if body:
|
||||||
function_bodies.append((func_nid, body))
|
function_bodies.append((func_nid, body, None))
|
||||||
return
|
return
|
||||||
|
|
||||||
if t == "method_declaration":
|
if t == "method_declaration":
|
||||||
receiver = node.child_by_field_name("receiver")
|
receiver = node.child_by_field_name("receiver")
|
||||||
receiver_type: str | None = None
|
receiver_type: str | None = None
|
||||||
|
receiver_name: str | None = None
|
||||||
if receiver:
|
if receiver:
|
||||||
for param in receiver.children:
|
for param in receiver.children:
|
||||||
if param.type == "parameter_declaration":
|
if param.type == "parameter_declaration":
|
||||||
type_node = param.child_by_field_name("type")
|
type_node = param.child_by_field_name("type")
|
||||||
if type_node:
|
if type_node:
|
||||||
receiver_type = _read_text(type_node, source).lstrip("*").strip()
|
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
|
break
|
||||||
name_node = node.child_by_field_name("name")
|
name_node = node.child_by_field_name("name")
|
||||||
if not name_node:
|
if not name_node:
|
||||||
@@ -377,6 +440,7 @@ def extract_go(path: Path) -> dict:
|
|||||||
if receiver_type:
|
if receiver_type:
|
||||||
parent_nid = _make_id(pkg_scope, receiver_type)
|
parent_nid = _make_id(pkg_scope, receiver_type)
|
||||||
add_node(parent_nid, receiver_type, line)
|
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)
|
method_nid = symbol_nid(_make_id(parent_nid, method_name), method_name)
|
||||||
add_node(method_nid, f".{method_name}()", line)
|
add_node(method_nid, f".{method_name}()", line)
|
||||||
add_edge(parent_nid, method_nid, "method", 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)
|
method_nid = symbol_nid(_make_id(stem, method_name), method_name)
|
||||||
add_node(method_nid, f"{method_name}()", line)
|
add_node(method_nid, f"{method_name}()", line)
|
||||||
add_edge(file_nid, method_nid, "contains", line)
|
add_edge(file_nid, method_nid, "contains", line)
|
||||||
|
callable_nids.add(method_nid)
|
||||||
|
|
||||||
emit_go_method_refs(node, method_nid, line)
|
emit_go_method_refs(node, method_nid, line)
|
||||||
body = node.child_by_field_name("body")
|
body = node.child_by_field_name("body")
|
||||||
if body:
|
if body:
|
||||||
function_bodies.append((method_nid, body))
|
function_bodies.append((method_nid, body, receiver_name))
|
||||||
return
|
return
|
||||||
|
|
||||||
if t == "type_declaration":
|
if t == "type_declaration":
|
||||||
@@ -402,6 +467,7 @@ def extract_go(path: Path) -> dict:
|
|||||||
line = child.start_point[0] + 1
|
line = child.start_point[0] + 1
|
||||||
type_nid = _make_id(pkg_scope, type_name)
|
type_nid = _make_id(pkg_scope, type_name)
|
||||||
add_node(type_nid, type_name, line)
|
add_node(type_nid, type_name, line)
|
||||||
|
callable_class_nids.add(type_nid)
|
||||||
add_edge(file_nid, type_nid, "contains", line)
|
add_edge(file_nid, type_nid, "contains", line)
|
||||||
# A generic type's own parameters (`type Box[T any]`) are local
|
# A generic type's own parameters (`type Box[T any]`) are local
|
||||||
# bindings; a field or method using `T` must not mint a stub.
|
# 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)
|
_scan_declarations(root)
|
||||||
walk(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] = {}
|
label_to_nid: dict[str, str] = {}
|
||||||
for n in nodes:
|
for n in nodes:
|
||||||
raw = n["label"]
|
raw = n["label"]
|
||||||
@@ -542,7 +618,7 @@ def extract_go(path: Path) -> dict:
|
|||||||
seen_call_pairs: set[tuple[str, str]] = set()
|
seen_call_pairs: set[tuple[str, str]] = set()
|
||||||
raw_calls: list[dict] = []
|
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"):
|
if node.type in ("function_declaration", "method_declaration"):
|
||||||
return
|
return
|
||||||
if node.type == "call_expression":
|
if node.type == "call_expression":
|
||||||
@@ -551,6 +627,7 @@ def extract_go(path: Path) -> dict:
|
|||||||
is_member_call: bool = False
|
is_member_call: bool = False
|
||||||
is_bare_identifier: bool = False
|
is_bare_identifier: bool = False
|
||||||
package_receiver: str | None = None
|
package_receiver: str | None = None
|
||||||
|
member_receiver: str | None = None
|
||||||
import_path: str | None = None
|
import_path: str | None = None
|
||||||
if func_node:
|
if func_node:
|
||||||
if func_node.type == "identifier":
|
if func_node.type == "identifier":
|
||||||
@@ -566,6 +643,17 @@ def extract_go(path: Path) -> dict:
|
|||||||
if not is_member_call:
|
if not is_member_call:
|
||||||
package_receiver = receiver_name
|
package_receiver = receiver_name
|
||||||
import_path = go_imported_pkgs[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:
|
if field:
|
||||||
callee_name = _read_text(field, source)
|
callee_name = _read_text(field, source)
|
||||||
if is_bare_identifier and callee_name in _GO_PREDECLARED_FUNCS:
|
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,
|
"is_member_call": is_member_call,
|
||||||
"language": "go",
|
"language": "go",
|
||||||
"receiver": package_receiver,
|
"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,
|
"import_path": import_path,
|
||||||
"source_file": str_path,
|
"source_file": str_path,
|
||||||
"source_location": f"L{node.start_point[0] + 1}",
|
"source_location": f"L{node.start_point[0] + 1}",
|
||||||
})
|
})
|
||||||
for child in node.children:
|
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:
|
for caller_nid, body_node, self_name in function_bodies:
|
||||||
walk_calls(body_node, caller_nid)
|
walk_calls(body_node, caller_nid, self_name)
|
||||||
|
|
||||||
valid_ids = seen_ids
|
valid_ids = seen_ids
|
||||||
clean_edges = []
|
clean_edges = []
|
||||||
@@ -621,4 +714,5 @@ def extract_go(path: Path) -> dict:
|
|||||||
"edges": clean_edges,
|
"edges": clean_edges,
|
||||||
"raw_calls": raw_calls,
|
"raw_calls": raw_calls,
|
||||||
"go_imports": dict(go_imported_pkgs),
|
"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