Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions graphify/cross_repo_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}),
}
Expand Down
106 changes: 106 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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.
Expand Down
124 changes: 117 additions & 7 deletions graphify/extractors/go.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_go()

fans out to 8 callees (efferent coupling); 19 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_go()

fans out to 8 callees (efferent coupling); 19 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_go()

fans out to 8 callees (efferent coupling); 19 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_go()

fans out to 8 callees (efferent coupling); 19 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_go()

fans out to 8 callees (efferent coupling); 19 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_go()

fans out to 8 callees (efferent coupling); 19 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_go()

fans out to 8 callees (efferent coupling); 19 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_go()

fans out to 8 callees (efferent coupling); 19 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""Extract functions, methods, type declarations, and imports from a .go file."""
try:
Expand All @@ -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] = {}

Expand Down Expand Up @@ -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:
Expand All @@ -305,18 +372,22 @@ 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)
else:
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":
Expand All @@ -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
Expand Down Expand Up @@ -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"]
Expand All @@ -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":
Expand All @@ -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":
Expand All @@ -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:
Expand Down Expand Up @@ -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 = []
Expand All @@ -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},
}
Loading
Loading