fix(java): resolve calls to inherited methods and super

Member calls were only matched against the receiver's own type, so
this.start(), super.close() or worker.start() got no edge when the method
lives in a superclass. Walk up the extends chain and take the first
declaration. No edge if the chain leaves the project.

Fixes #3931
This commit is contained in:
Jan Mohr committed 2026-09-29 22:23:21 +02:00
1 parent 1cd9a36c0c
commit a2da367fc7
3 files changed
+141 -8

No files matched your search

+36 -6
View File
@@ -4578,7 +4578,7 @@ def _resolve_java_member_calls(
) -> None:
"""Resolve Java member calls against the receiver's declared type.
Explicit type receivers and ``this`` are exact. Fields declared on the
Explicit type receivers, ``this`` and ``super`` 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.
@@ -4637,6 +4637,33 @@ def _resolve_java_member_calls(
queue.extend(inherits_bases.get(cls, []))
return None
def _jvm_bases(type_nid: str) -> list[str] | None:
bases = inherits_bases.get(type_nid, [])
for base in bases:
family = _lang_family(node_by_id.get(base, {}).get("source_file"))
if base == type_nid or family != "jvm":
return None
return bases
def _method_on_type_or_bases(type_nid: str, callee_key: str) -> str | None:
hits: set[str] = set()
seen: set[str] = set()
frontier = [type_nid]
while frontier:
nid = frontier.pop()
if nid in seen:
continue
seen.add(nid)
declared = method_index.get((nid, callee_key))
if declared:
hits |= declared
continue
bases = _jvm_bases(nid)
if bases is None:
return None
frontier.extend(bases)
return next(iter(hits)) if len(hits) == 1 else None
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"):
@@ -4653,6 +4680,12 @@ def _resolve_java_member_calls(
exact = True
if not type_nid:
continue
elif receiver == "super":
superclasses = _jvm_bases(enclosing_type.get(caller, ""))
if not superclasses or len(superclasses) != 1:
continue
type_nid = superclasses[0]
exact = True
else:
type_name = raw_call.get("receiver_type")
if not type_name and receiver[:1].isupper():
@@ -4680,11 +4713,8 @@ def _resolve_java_member_calls(
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:
method_nid = _method_on_type_or_bases(type_nid, key(callee))
if not method_nid or method_nid == caller or (caller, method_nid) in existing_pairs:
continue
existing_pairs.add((caller, method_nid))
all_edges.append({
+2 -2
View File
@@ -6427,8 +6427,8 @@ def _extract_generic(
is_member_call = True
if receiver.type == "identifier":
member_receiver = _read_text(receiver, source)
elif receiver.type == "this":
member_receiver = "this"
elif receiver.type in ("this", "super"):
member_receiver = receiver.type
elif receiver.type == "field_access":
owner = receiver.child_by_field_name("object")
field = receiver.child_by_field_name("field")
+103
View File
@@ -282,3 +282,106 @@ def test_unqualified_call_still_resolves(tmp_path: Path):
other = _find(result, ".other()", "checkout")
assert (run, helper) in calls
assert (run, other) in calls
def test_inherited_method_resolves_for_this_field_and_parameter_receivers(tmp_path: Path):
calls, result = _calls(tmp_path, {
"Base.java": "class Base { void start() {} }\n",
"Worker.java": (
"class Worker extends Base {\n"
" void viaThis() { this.start(); }\n"
"}\n"
),
"Service.java": (
"class Service {\n"
" Worker worker;\n"
" void viaField() { worker.start(); }\n"
" void viaParam(Worker w) { w.start(); }\n"
"}\n"
),
})
start = _find(result, ".start()", "base_start")
assert (_find(result, ".viaThis()", "worker_viathis"), start) in calls
assert (_find(result, ".viaField()", "service_viafield"), start) in calls
assert (_find(result, ".viaParam()", "service_viaparam"), start) in calls
def test_nearest_declaration_on_the_superclass_chain_wins(tmp_path: Path):
calls, result = _calls(tmp_path, {
"Chain.java": (
"class Root { void start() {} void stop() {} }\n"
"class Middle extends Root { void stop() {} }\n"
"class Leaf extends Middle {}\n"
"class Client { void run(Leaf leaf) { leaf.start(); leaf.stop(); } }\n"
),
})
run = _find(result, ".run()", "client_run")
assert (run, _find(result, ".start()", "root_start")) in calls
assert (run, _find(result, ".stop()", "middle_stop")) in calls
assert (run, _find(result, ".stop()", "root_stop")) not in calls
def test_super_call_resolves_to_the_superclass_declaration(tmp_path: Path):
calls, result = _calls(tmp_path, {
"Base.java": "class Base { void close() {} }\n",
"Worker.java": (
"class Worker extends Base {\n"
" void close() { super.close(); }\n"
"}\n"
),
})
worker_close = _find(result, ".close()", "worker_close")
base_close = _find(result, ".close()", "base_close")
assert (worker_close, base_close) in calls
assert any(
edge.get("relation") == "calls"
and (edge["source"], edge["target"]) == (worker_close, base_close)
and edge.get("confidence") == "EXTRACTED"
for edge in result["edges"]
)
def test_ancestor_outside_the_corpus_leaves_inherited_call_unresolved(tmp_path: Path):
calls, result = _calls(tmp_path, {
"Named.java": "interface Named { String name(); }\n",
"Entity.java": (
"import org.example.Identified;\n"
"interface Entity extends Identified, Named {}\n"
),
"Client.java": "class Client { void run(Entity e) { e.name(); } }\n",
})
run = _find(result, ".run()", "client_run")
assert not any(source == run for source, _ in calls)
def test_super_call_is_not_bound_back_to_the_calling_class(tmp_path: Path):
calls, result = _calls(tmp_path, {
"lib/Handler.java": "package lib;\npublic class Handler { public void handle() {} }\n",
"app/Handler.java": (
"package app;\n"
"public class Handler extends lib.Handler {\n"
" public void handle() {}\n"
" public void retry() { super.handle(); }\n"
"}\n"
),
})
retry = _find(result, ".retry()", "handler_retry")
assert not any(source == retry for source, _ in calls)
def test_inherited_call_is_not_bound_to_another_language(tmp_path: Path):
calls, result = _calls(tmp_path, {
"Worker.java": (
"import org.lib.*;\n"
"class Worker extends Base { void run() { this.start(); super.start(); } }\n"
),
"tools/base.py": "class Base:\n def start(self):\n pass\n",
})
run = _find(result, ".run()", "worker_run")
assert not any(source == run for source, _ in calls)