fix(extract): resolve Java member calls by receiver type (#1696)

Java member calls (`gw.charge()`) resolved by bare method name, so a call bound
to any same-named method in the corpus — e.g. `PaymentGateway.charge` and
`AuditLog.charge` were indistinguishable, producing phantom cross-class edges
and a false god node.

The extractor now preserves the receiver and its static type, and
_resolve_java_member_calls binds the call against the receiver's declared type:
explicit-type receivers and `this` are exact; current-class fields, method
parameters, and explicitly-typed locals resolve via a method-scoped type table;
a missing/ambiguous/inherited/chained receiver is skipped rather than falling
back to a bare name match (same single-owner god-node guard as the C#/Swift/Ruby
resolvers). Fully-qualified and nested-type receivers are deferred since they
need package- and nesting-aware type identity.

Verified: `gw.ping()/gw.charge()` (gw: PaymentGateway) bind to PaymentGateway,
the three charge() calls dedup to one edge, and no edge targets the same-named
AuditLog methods. Applies cleanly to the post-#1737 layout (extract.py +
extractors/engine.py). 13 new tests; full suite 3135 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
oleksii-tumanov
2026-07-10 02:11:36 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent c5db9ffb06
commit dae602ccd1
4 changed files with 598 additions and 17 deletions
+2
View File
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## 0.9.12 (unreleased)
- Fix: Java member calls resolve against the receiver's declared type instead of a bare method-name match (#1696/#1697, thanks @oleksii-tumanov). `gw.charge()` where `gw: PaymentGateway` now binds to `PaymentGateway.charge`, not a same-named `AuditLog.charge` in another file. Explicit-type receivers and `this` are exact; current-class fields, method parameters, and explicitly-typed locals resolve via a method-scoped type table; a missing, ambiguous, inherited, or chained receiver is skipped rather than guessed (same god-node guard as the C#/Swift/Ruby resolvers). Fully-qualified and nested-type receivers are deferred (they need package/nesting-aware type identity).
- Fix: output/cache artifacts no longer land in the scanned corpus or CWD when `--out`/`--graph` point elsewhere (#1747, thanks @bbqboogiedwonsen). `extract <corpus> --out <dir>` correctly wrote the graph to `<dir>` but `detect()`'s word-count/stat-index cache still created a stray `graphify-out/cache/` inside the corpus (it uses the scan root); it now honors the `--out` dir via a threaded `cache_root`. And `cluster-only --graph <elsewhere>/graphify-out/graph.json` wrote `GRAPH_REPORT.md`/labels/analysis/re-clustered graph to the CWD instead of beside the input; it now writes beside `--graph` when that graph lives in a `graphify-out/` dir, while still restoring into the CWD for an archived `backup/graph.json` (#934).
- Fix: `imports`/`references` edges no longer bind across a language boundary (#1749, thanks @philberndt). The spec already forbids cross-language `calls`, but an unresolved Python `import time` could still resolve by bare stem onto a `src/time.ts` file node — welding a polyglot repo's halves together at a phantom edge (in the reporter's repo, 3 such edges were the *only* thing bridging 2409 Python nodes to 1403 TS nodes, inflating `time.ts` betweenness ~90x and making it the #1 "god node"). The build-time cross-language guard now covers `imports`/`imports_from`/`references` in addition to `calls`, dropping an edge only when both endpoints are known code languages of different interop families (so a config/manifest → code reference is untouched).
+92
View File
@@ -2493,6 +2493,95 @@ def _resolve_csharp_member_calls(
})
def _resolve_java_member_calls(
per_file: list[dict],
all_nodes: list[dict],
all_edges: list[dict],
) -> None:
"""Resolve Java member calls against the receiver's declared type.
Explicit type receivers and ``this`` are exact. Fields declared on the
caller's class plus method parameters and explicit locals are inferred from
the extractor's method-scoped type table. A missing or ambiguous receiver
type is skipped rather than falling back to a bare method-name match.
"""
def key(label: str) -> str:
return str(label).strip().removeprefix(".").removesuffix("()")
contained = {edge.get("target") for edge in all_edges
if edge.get("relation") == "contains"}
node_by_id = {node.get("id"): node for node in all_nodes}
type_def_nids: dict[str, list[str]] = {}
for node in all_nodes:
if (
node.get("source_file")
and node.get("id") in contained
and _is_type_like_definition(node)
):
type_def_nids.setdefault(key(node.get("label", "")), []).append(node["id"])
method_index: dict[tuple[str, str], set[str]] = {}
enclosing_type: dict[str, str] = {}
for edge in all_edges:
if edge.get("relation") != "method":
continue
owner, method = edge.get("source"), edge.get("target")
method_node = node_by_id.get(method)
if method_node is None:
continue
enclosing_type.setdefault(method, owner)
method_index.setdefault((owner, key(method_node.get("label", ""))), set()).add(method)
existing_pairs = {(edge.get("source"), edge.get("target")) for edge in all_edges}
for result in per_file:
for raw_call in result.get("raw_calls", []):
if raw_call.get("lang") != "java" or not raw_call.get("is_member_call"):
continue
receiver = raw_call.get("receiver")
callee = raw_call.get("callee")
caller = raw_call.get("caller_nid")
if not receiver or not callee or not caller:
continue
exact = False
if receiver == "this":
type_nid = enclosing_type.get(caller)
exact = True
if not type_nid:
continue
else:
type_name = raw_call.get("receiver_type")
if not type_name and receiver[:1].isupper():
type_name = receiver
exact = True
if not type_name:
continue
type_defs = type_def_nids.get(key(type_name), [])
if len(type_defs) != 1:
continue
type_nid = type_defs[0]
method_nids = method_index.get((type_nid, key(callee)), set())
if len(method_nids) != 1:
continue
method_nid = next(iter(method_nids))
if method_nid == caller or (caller, method_nid) in existing_pairs:
continue
existing_pairs.add((caller, method_nid))
all_edges.append({
"source": caller,
"target": method_nid,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED" if exact else "INFERRED",
"confidence_score": 1.0 if exact else 0.8,
"source_file": raw_call.get("source_file", ""),
"source_location": raw_call.get("source_location"),
"weight": 1.0,
})
def _resolve_objc_member_calls(
per_file: list[dict],
all_nodes: list[dict],
@@ -2643,6 +2732,9 @@ register_language_resolver(
register_language_resolver(
LanguageResolver("csharp_member_calls", frozenset({".cs"}), _resolve_csharp_member_calls)
)
register_language_resolver(
LanguageResolver("java_member_calls", frozenset({".java"}), _resolve_java_member_calls)
)
# Pascal/Delphi cross-file inherited-method-call resolution: a call from a
# manual descendant class to a method it inherits from an ancestor declared
# in a DIFFERENT file (the common generated-base/manual-descendant split,
+226 -17
View File
@@ -381,6 +381,158 @@ def _java_collect_type_refs(
if c.is_named:
_java_collect_type_refs(c, source, generic, out, skip)
def _java_receiver_type_name(type_node, source: bytes) -> str | None:
"""Return the concrete declared type usable for Java receiver resolution."""
if type_node is None:
return None
t = type_node.type
if t == "type_identifier":
name = _read_text(type_node, source)
elif t == "scoped_type_identifier":
name = _read_text(type_node, source).rsplit(".", 1)[-1]
elif t == "generic_type":
base = next(
(
child
for child in type_node.children
if child.type in ("type_identifier", "scoped_type_identifier")
),
None,
)
return _java_receiver_type_name(base, source)
else:
return None
if (
not name
or name in _JAVA_BUILTIN_TYPES
or name in _java_type_parameters_in_scope(type_node, source)
):
return None
return name
def _java_declarator_names(declaration_node, source: bytes) -> list[str]:
names: list[str] = []
for child in declaration_node.children:
if child.type != "variable_declarator":
continue
name_node = child.child_by_field_name("name")
if name_node is not None:
name = _read_text(name_node, source)
if name:
names.append(name)
return names
def _java_lambda_parameters(
lambda_node,
source: bytes,
) -> list[tuple[str, str | None]]:
parameters = lambda_node.child_by_field_name("parameters")
if parameters is None:
return []
if parameters.type == "identifier":
return [(_read_text(parameters, source), None)]
if parameters.type == "inferred_parameters":
return [
(_read_text(child, source), None)
for child in parameters.children
if child.type == "identifier"
]
bindings: list[tuple[str, str | None]] = []
for parameter in parameters.children:
if parameter.type not in ("formal_parameter", "spread_parameter"):
continue
name_node = parameter.child_by_field_name("name")
if name_node is not None:
bindings.append((
_read_text(name_node, source),
_java_receiver_type_name(
parameter.child_by_field_name("type"), source
),
))
return bindings
def _java_method_receiver_types(
method_node,
source: bytes,
field_types: dict[str, str],
) -> dict[str, str]:
"""Build the receiver type table visible to one Java method.
Current-class fields are the base scope, and parameters shadow them for the
full method. Conflicting local declarations are omitted because raw call
facts do not retain lexical scope.
"""
method_types: dict[str, str] = {}
ambiguous: set[str] = set()
def bind(name: str, type_name: str | None) -> None:
if not name or not type_name or name in ambiguous:
return
previous = method_types.get(name)
if previous is not None and previous != type_name:
method_types.pop(name, None)
ambiguous.add(name)
else:
method_types[name] = type_name
params = method_node.child_by_field_name("parameters")
if params is not None:
for param in params.children:
if param.type not in ("formal_parameter", "spread_parameter"):
continue
type_name = _java_receiver_type_name(
param.child_by_field_name("type"), source
)
name_node = param.child_by_field_name("name")
if name_node is not None:
bind(_read_text(name_node, source), type_name)
body = method_node.child_by_field_name("body")
stack = list(body.children) if body is not None else []
while stack:
node = stack.pop()
if node.type in (
"class_declaration",
"class_body",
"interface_declaration",
"record_declaration",
"enum_declaration",
"annotation_type_declaration",
):
continue
if node.type == "lambda_expression":
# Raw calls are method-scoped, so a lambda-local binding cannot be
# distinguished from an enclosing binding with the same name.
for name, type_name in _java_lambda_parameters(node, source):
if type_name is None or field_types.get(name) not in (None, type_name):
method_types.pop(name, None)
ambiguous.add(name)
else:
bind(name, type_name)
if node.type == "local_variable_declaration":
type_name = _java_receiver_type_name(
node.child_by_field_name("type"), source
)
for name in _java_declarator_names(node, source):
if field_types.get(name) not in (None, type_name):
method_types.pop(name, None)
ambiguous.add(name)
else:
bind(name, type_name)
stack.extend(node.children)
table = dict(field_types)
table.update(method_types)
for name in ambiguous:
table.pop(name, None)
table.update({f"this.{name}": type_name for name, type_name in field_types.items()})
return table
def _java_annotation_names(declaration_node, source: bytes) -> list[str]:
"""Collect annotation names from a Java declaration's `modifiers` child."""
names: list[str] = []
@@ -2034,6 +2186,10 @@ def _extract_generic(
# threaded out as `swift_type_table` so member calls (`vm.update()`) can be
# resolved to the receiver's real definition in _resolve_swift_member_calls.
type_table: dict[str, str] = {}
# Java receiver typing is method-scoped: current-class fields are shared,
# while parameters and locals belong only to their declaring method.
java_field_types: dict[str, dict[str, str]] = {}
java_method_scopes: dict[int, tuple[object, str]] = {}
csharp_interface_names: set[str] = set()
if config.ts_module == "tree_sitter_c_sharp":
@@ -2768,6 +2924,11 @@ def _extract_generic(
and parent_class_nid):
type_node = node.child_by_field_name("type")
if type_node is not None:
receiver_type = _java_receiver_type_name(type_node, source)
if receiver_type:
fields = java_field_types.setdefault(parent_class_nid, {})
for field_name in _java_declarator_names(node, source):
fields[field_name] = receiver_type
line = node.start_point[0] + 1
refs: list[tuple[str, str]] = []
_java_collect_type_refs(type_node, source, False, refs)
@@ -3288,6 +3449,8 @@ def _extract_generic(
if m_body:
function_bodies.append((m_nid, m_body))
if body:
if config.ts_module == "tree_sitter_java" and parent_class_nid:
java_method_scopes[id(body)] = (node, parent_class_nid)
function_bodies.append((func_nid, body))
return
@@ -3387,6 +3550,14 @@ def _extract_generic(
# populated before walk_calls runs. Lets member-call raw_calls carry a
# receiver_type so the cross-file pass resolves `var.method` by type (#ruby).
ruby_var_types: dict[str, dict[str, str | None]] = {}
java_receiver_types = {
body_id: _java_method_receiver_types(
method_node,
source,
java_field_types.get(class_nid, {}),
)
for body_id, (method_node, class_nid) in java_method_scopes.items()
}
def _emit_indirect_by_name(ident_name: str, loc_node, scope_nid: str,
context: str) -> None:
@@ -3529,7 +3700,11 @@ def _extract_generic(
_tracked_body_ids: set[int] = set()
_JS_CLOSURE_TYPES = ("arrow_function", "function_expression")
def walk_calls(node, caller_nid: str) -> None:
def walk_calls(
node,
caller_nid: str,
java_types: dict[str, str] | None = None,
) -> None:
if node.type in config.function_boundary_types:
# JS/TS: an inline/returned closure not separately tracked in
# function_bodies would otherwise drop its calls at this boundary.
@@ -3542,7 +3717,7 @@ def _extract_generic(
body = node.child_by_field_name("body")
if body is not None and id(body) not in _tracked_body_ids:
for child in node.children:
walk_calls(child, caller_nid)
walk_calls(child, caller_nid, java_types)
return
if node.type in config.call_types:
@@ -3552,7 +3727,7 @@ def _extract_generic(
edges, seen_dyn_import_pairs):
# Still recurse into children (import().then(...) may have calls)
for child in node.children:
walk_calls(child, caller_nid)
walk_calls(child, caller_nid, java_types)
return
callee_name: str | None = None
@@ -3699,16 +3874,32 @@ def _extract_generic(
scope = func_node.child_by_field_name("scope")
if scope is not None:
member_receiver = _read_text(scope, source)
elif config.ts_module == "tree_sitter_java" and node.type == "object_creation_expression":
# `new Foo(...)` — the constructed type is in the `type` field, not
# `name`, so the generic path misses it (#1373). Reduce a qualified
# / generic type to its simple name (com.a.Foo<Bar> -> Foo). Java
# method_invocation still flows through the generic branch below.
type_node = node.child_by_field_name("type")
if type_node is not None:
raw = _read_text(type_node, source).split("<", 1)[0].strip()
if raw:
callee_name = raw.rsplit(".", 1)[-1]
elif config.ts_module == "tree_sitter_java":
if node.type == "object_creation_expression":
# `new Foo(...)` — the constructed type is in the `type` field,
# not `name`, so the generic path misses it (#1373).
type_node = node.child_by_field_name("type")
if type_node is not None:
raw = _read_text(type_node, source).split("<", 1)[0].strip()
if raw:
callee_name = raw.rsplit(".", 1)[-1]
elif node.type == "method_invocation":
name_node = node.child_by_field_name("name")
if name_node is not None:
callee_name = _read_text(name_node, source)
receiver = node.child_by_field_name("object")
if receiver is not None:
is_member_call = True
if receiver.type == "identifier":
member_receiver = _read_text(receiver, source)
elif receiver.type == "this":
member_receiver = "this"
elif receiver.type == "field_access":
owner = receiver.child_by_field_name("object")
field = receiver.child_by_field_name("field")
if owner is not None and owner.type == "this" and field is not None:
member_receiver = f"this.{_read_text(field, source)}"
is_this_field_call = True
elif config.ts_module == "tree_sitter_ruby":
# Ruby's `call` node carries `receiver` and `method` as direct
# fields (no intermediate accessor node), so the generic accessor
@@ -3778,8 +3969,17 @@ def _extract_generic(
config.ts_module == "tree_sitter_c_sharp"
and is_member_call and member_receiver
)
if is_member_call and member_receiver and (
member_receiver[:1].isupper() or is_this_field_call or _csharp_defer
_java_defer = (
config.ts_module == "tree_sitter_java" and is_member_call
)
if _java_defer or (
is_member_call
and member_receiver
and (
member_receiver[:1].isupper()
or is_this_field_call
or _csharp_defer
)
):
tgt_nid = None
else:
@@ -3826,6 +4026,11 @@ def _extract_generic(
# type table (#1609).
if config.ts_module == "tree_sitter_c_sharp":
rc_entry["lang"] = "csharp"
if config.ts_module == "tree_sitter_java":
rc_entry["lang"] = "java"
receiver_type = (java_types or {}).get(member_receiver or "")
if receiver_type:
rc_entry["receiver_type"] = receiver_type
raw_calls.append(rc_entry)
# Indirect dispatch: a function passed BY NAME as a call argument
@@ -4032,7 +4237,7 @@ def _extract_generic(
_emit_indirect_ref(ident, caller_nid, enclosing_locals, "return")
for child in node.children:
walk_calls(child, caller_nid)
walk_calls(child, caller_nid, java_types)
if config.ts_module == "tree_sitter_ruby":
for caller_nid, body_node in function_bodies:
@@ -4062,7 +4267,11 @@ def _extract_generic(
_tracked_body_ids.update(id(b) for _, b in function_bodies)
for caller_nid, body_node in function_bodies:
walk_calls(body_node, caller_nid)
walk_calls(
body_node,
caller_nid,
java_receiver_types.get(id(body_node)),
)
# #1356: walk property/field initializers (collected above). walk_calls
# self-guards against re-entering function bodies and dedups via
+278
View File
@@ -0,0 +1,278 @@
"""Java receiver-typed member-call resolution.
Java ``method_invocation`` nodes carry both the method name and its receiver,
but the generic extractor currently resolves only by the bare method name. A
typed receiver must select the method owned by its declared type; unresolved or
ambiguous receivers must stay unlinked rather than creating a false call edge.
"""
from __future__ import annotations
from pathlib import Path
from graphify.extract import extract
def _calls(tmp_path: Path, files: dict[str, str]):
paths = []
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")
paths.append(path)
result = extract(paths, cache_root=tmp_path / "graphify-out")
calls = {
(edge["source"], edge["target"])
for edge in result["edges"]
if edge.get("relation") == "calls"
}
return calls, result
def _find(result: dict, label: str, id_contains: str) -> str:
return next(
node["id"]
for node in result["nodes"]
if node.get("label") == label and id_contains in node["id"]
)
_AMBIGUOUS_METHODS = {
"Services.java": (
"class PaymentGateway { static void ping() {} void charge() {} }\n"
"class AuditLog { static void ping() {} void charge() {} }\n"
),
}
def test_explicit_type_receiver_resolves_to_owned_method(tmp_path: Path):
calls, result = _calls(tmp_path, {
**_AMBIGUOUS_METHODS,
"Checkout.java": (
"class Checkout { void run() { PaymentGateway.ping(); } }\n"
),
})
run = _find(result, ".run()", "checkout")
gateway_ping = _find(result, ".ping()", "paymentgateway")
audit_ping = _find(result, ".ping()", "auditlog")
assert (run, gateway_ping) in calls
assert (run, audit_ping) not in calls
def test_field_receiver_resolves_to_declared_type(tmp_path: Path):
calls, result = _calls(tmp_path, {
**_AMBIGUOUS_METHODS,
"Checkout.java": (
"class Checkout {\n"
" void run() { gateway.charge(); }\n"
" PaymentGateway gateway;\n"
"}\n"
),
})
run = _find(result, ".run()", "checkout")
gateway_charge = _find(result, ".charge()", "paymentgateway")
audit_charge = _find(result, ".charge()", "auditlog")
assert (run, gateway_charge) in calls
assert (run, audit_charge) not in calls
def test_this_field_receiver_resolves_to_declared_type(tmp_path: Path):
calls, result = _calls(tmp_path, {
**_AMBIGUOUS_METHODS,
"Checkout.java": (
"class Checkout {\n"
" PaymentGateway gateway;\n"
" void run() { this.gateway.charge(); }\n"
"}\n"
),
})
run = _find(result, ".run()", "checkout")
gateway_charge = _find(result, ".charge()", "paymentgateway")
assert (run, gateway_charge) in calls
def test_this_field_uses_field_type_when_parameter_shadows_name(tmp_path: Path):
calls, result = _calls(tmp_path, {
**_AMBIGUOUS_METHODS,
"Checkout.java": (
"class Checkout {\n"
" PaymentGateway service;\n"
" void run(AuditLog service) {\n"
" service.charge();\n"
" this.service.charge();\n"
" }\n"
"}\n"
),
})
run = _find(result, ".run()", "checkout")
gateway_charge = _find(result, ".charge()", "paymentgateway")
audit_charge = _find(result, ".charge()", "auditlog")
assert (run, gateway_charge) in calls
assert (run, audit_charge) in calls
def test_parameter_and_local_receivers_resolve_per_method(tmp_path: Path):
calls, result = _calls(tmp_path, {
**_AMBIGUOUS_METHODS,
"Checkout.java": (
"class Checkout {\n"
" void fromParameter(PaymentGateway service) { service.charge(); }\n"
" void fromLocal() { AuditLog service = new AuditLog(); service.charge(); }\n"
"}\n"
),
})
from_parameter = _find(result, ".fromParameter()", "checkout")
from_local = _find(result, ".fromLocal()", "checkout")
gateway_charge = _find(result, ".charge()", "paymentgateway")
audit_charge = _find(result, ".charge()", "auditlog")
assert (from_parameter, gateway_charge) in calls
assert (from_parameter, audit_charge) not in calls
assert (from_local, audit_charge) in calls
assert (from_local, gateway_charge) not in calls
def test_nested_receiver_bindings_do_not_escape_their_scope(tmp_path: Path):
calls, result = _calls(tmp_path, {
**_AMBIGUOUS_METHODS,
"Checkout.java": (
"class Checkout {\n"
" PaymentGateway service;\n"
" void blockLocal() {\n"
" service.charge();\n"
" { AuditLog service = null; service.charge(); }\n"
" }\n"
" void anonymousClass() {\n"
" new Object() { void nested() { AuditLog service = null; } };\n"
" service.charge();\n"
" }\n"
"}\n"
),
})
block_local = _find(result, ".blockLocal()", "checkout")
anonymous_class = _find(result, ".anonymousClass()", "checkout")
gateway_charge = _find(result, ".charge()", "paymentgateway")
audit_charge = _find(result, ".charge()", "auditlog")
assert not any(source == block_local and "charge" in target
for source, target in calls)
assert (anonymous_class, gateway_charge) in calls
assert (anonymous_class, audit_charge) not in calls
def test_lambda_shadowing_does_not_reuse_enclosing_receiver_type(tmp_path: Path):
calls, result = _calls(tmp_path, {
**_AMBIGUOUS_METHODS,
"Checkout.java": (
"class Checkout {\n"
" PaymentGateway service;\n"
" void captured() {\n"
" Runnable task = () -> service.charge();\n"
" }\n"
" void shadowed() {\n"
" java.util.function.Consumer<AuditLog> task =\n"
" service -> service.charge();\n"
" }\n"
" void parenthesized() {\n"
" java.util.function.Consumer<AuditLog> task =\n"
" (service) -> service.charge();\n"
" }\n"
" void typed() {\n"
" java.util.function.Consumer<AuditLog> task =\n"
" (AuditLog service) -> service.charge();\n"
" }\n"
" void sameType() {\n"
" java.util.function.Consumer<PaymentGateway> task =\n"
" (PaymentGateway service) -> service.charge();\n"
" }\n"
"}\n"
),
})
captured = _find(result, ".captured()", "checkout")
same_type = _find(result, ".sameType()", "checkout")
shadowed_callers = {
_find(result, f".{name}()", "checkout")
for name in ("shadowed", "parenthesized", "typed")
}
gateway_charge = _find(result, ".charge()", "paymentgateway")
assert (captured, gateway_charge) in calls
assert (same_type, gateway_charge) in calls
assert not any(source in shadowed_callers and "charge" in target
for source, target in calls)
def test_overloaded_callers_keep_body_scoped_receiver_types(tmp_path: Path):
calls, result = _calls(tmp_path, {
**_AMBIGUOUS_METHODS,
"Checkout.java": (
"class Checkout {\n"
" void run(int value) { PaymentGateway service = null; service.charge(); }\n"
" void run(String value) { AuditLog service = null; service.charge(); }\n"
"}\n"
),
})
run = _find(result, ".run()", "checkout")
gateway_charge = _find(result, ".charge()", "paymentgateway")
audit_charge = _find(result, ".charge()", "auditlog")
assert (run, gateway_charge) in calls
assert (run, audit_charge) in calls
def test_ambiguous_receiver_type_emits_no_edge(tmp_path: Path):
calls, result = _calls(tmp_path, {
"a/Gateway.java": "package a; public class Gateway { public void send() {} }\n",
"b/Gateway.java": "package b; public class Gateway { public void send() {} }\n",
"Caller.java": (
"class Caller { void run(Gateway gateway) { gateway.send(); } }\n"
),
})
run = _find(result, ".run()", "caller")
send_targets = {
target
for source, target in calls
if source == run and "send" in target
}
assert send_targets == set()
def test_inherited_field_and_chained_receiver_are_deferred(tmp_path: Path):
calls, result = _calls(tmp_path, {
"Services.java": (
"class Gateway { void charge() {} Gateway create() { return this; } }\n"
"class Base { Gateway gateway; }\n"
"class Checkout extends Base {\n"
" Gateway factory;\n"
" void inherited() { this.gateway.charge(); }\n"
" void chained() { factory.create().charge(); }\n"
"}\n"
),
})
inherited = _find(result, ".inherited()", "checkout")
chained = _find(result, ".chained()", "checkout")
assert not any(source in {inherited, chained} and "charge" in target
for source, target in calls)
def test_unqualified_call_still_resolves(tmp_path: Path):
calls, result = _calls(tmp_path, {
"Checkout.java": (
"class Checkout {\n"
" void run() { helper(); this.other(); }\n"
" void helper() {}\n"
" void other() {}\n"
"}\n"
),
})
run = _find(result, ".run()", "checkout")
helper = _find(result, ".helper()", "checkout")
other = _find(result, ".other()", "checkout")
assert (run, helper) in calls
assert (run, other) in calls