fix(java): resolve bare calls and field receivers inherited from a supertype in another file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
This commit is contained in:
rohit-jsfreakyandClaude Opus 5.5 committed 2026-10-12 22:24:42 +05:30
1 parent 7536dc0665
commit 724626659a
4 files changed
+246 -16

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ except Exception:
_EXTRACTOR_VERSION = "unknown"
# Bump when AST cache-key semantics change independently of the package version.
_AST_CACHE_SCHEMA = 10 # C# partial types carry `type_path`, the nested/generic partial merge key; prior (9): Python property-read raw calls and `_py_property` nodes; (8): Rust `use`-group per-binding edges; (7): Python opaque-base/super(args) markers + nested-class enclosing ids.
_AST_CACHE_SCHEMA = 11 # Java `receiver_unbound` raw-call marker (inherited field receivers); prior (10): C# partial types carry `type_path`, the nested/generic partial merge key; (9): Python property-read raw calls and `_py_property` nodes; (8): Rust `use`-group per-binding edges; (7): Python opaque-base/super(args) markers + nested-class enclosing ids.
# Per-process memo: distributions() scans site-packages metadata, which is
+56 -15
View File
@@ -5449,6 +5449,10 @@ def _resolve_java_member_calls(
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.
A bare `m()` or `field.m()` the caller's file could not bind names a member
the class inherits from a supertype in another file: the nearest supertype
declaring it is the target, and a tie on one level binds nothing.
"""
def key(label: str) -> str:
return str(label).strip().removeprefix(".").removesuffix("()")
@@ -5485,12 +5489,15 @@ def _resolve_java_member_calls(
# The per-file tables ride the results keyed by class label + source_file
# (never node id, see #3150); bind each to its class node, ambiguity skips.
inherits_bases: dict[str, list[str]] = {}
supertypes: dict[str, list[str]] = {}
for edge in all_edges:
if edge.get("relation") == "inherits":
inherits_bases.setdefault(edge["source"], []).append(edge["target"])
if edge.get("relation") in ("inherits", "implements"):
supertypes.setdefault(edge["source"], []).append(edge["target"])
class_fields = _bind_member_field_tables(per_file, all_nodes, lang="java")
def _inherited_field_type(class_nid, field: str):
def _inherited_field_type(class_nid, field: str, bases: dict[str, list[str]] = inherits_bases):
seen: set = set()
queue = [class_nid]
while queue:
@@ -5501,9 +5508,43 @@ def _resolve_java_member_calls(
hit = class_fields.get(cls, {}).get(field)
if hit:
return hit
queue.extend(inherits_bases.get(cls, []))
queue.extend(bases.get(cls, []))
return None
def _inherited_method(class_nid: str, callee_key: str) -> str | None:
# Level by level up the supertypes, as Java looks a simple method name
# up in the class's members; the class itself was searched in-file.
seen = {class_nid}
level = list(dict.fromkeys(supertypes.get(class_nid, [])))
while level:
hits: set[str] = set()
for nid in level:
seen.add(nid)
source_file = node_by_id.get(nid, {}).get("source_file")
if source_file and _lang_family(source_file) != "jvm":
return None
hits |= method_index.get((nid, callee_key), set())
if hits:
return next(iter(hits)) if len(hits) == 1 else None
level = list(dict.fromkeys(
base for nid in level for base in supertypes.get(nid, []) if base not in seen
))
return None
def _emit(caller: str, method_nid: str, raw_call: dict, exact: bool) -> None:
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.85,
"source_file": raw_call.get("source_file", ""),
"source_location": raw_call.get("source_location"),
"weight": 1.0,
})
def _jvm_bases(type_nid: str) -> list[str] | None:
bases = inherits_bases.get(type_nid, [])
for base in bases:
@@ -5533,11 +5574,20 @@ def _resolve_java_member_calls(
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"):
if raw_call.get("lang") != "java":
continue
receiver = raw_call.get("receiver")
callee = raw_call.get("callee")
caller = raw_call.get("caller_nid")
if not raw_call.get("is_member_call"):
class_nid = enclosing_type.get(caller)
method_nid = _inherited_method(class_nid, key(callee)) if class_nid and callee else None
# `new Base()` names a constructor, which is not inherited.
owner = node_by_id.get(enclosing_type.get(method_nid or ""), {})
if (method_nid and method_nid != caller and key(owner.get("label", "")) != key(callee)
and (caller, method_nid) not in existing_pairs):
_emit(caller, method_nid, raw_call, True)
continue
if not receiver or not callee or not caller:
continue
@@ -5555,6 +5605,8 @@ def _resolve_java_member_calls(
exact = True
else:
type_name = raw_call.get("receiver_type")
if not type_name and raw_call.get("receiver_unbound"):
type_name = _inherited_field_type(enclosing_type.get(caller), receiver, supertypes)
if not type_name and receiver[:1].isupper():
type_name = receiver
exact = True
@@ -5583,18 +5635,7 @@ def _resolve_java_member_calls(
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({
"source": caller,
"target": method_nid,
"relation": "calls",
"context": "call",
"confidence": "EXTRACTED" if exact else "INFERRED",
"confidence_score": 1.0 if exact else 0.85,
"source_file": raw_call.get("source_file", ""),
"source_location": raw_call.get("source_location"),
"weight": 1.0,
})
_emit(caller, method_nid, raw_call, exact)
def _resolve_objc_member_calls(
+58
View File
@@ -476,6 +476,41 @@ def _java_declarator_names(declaration_node, source: bytes) -> list[str]:
return names
_JAVA_NAMED_BINDINGS = frozenset({
"formal_parameter", "variable_declarator", "enhanced_for_statement",
"resource", "catch_formal_parameter", "instanceof_expression",
})
_JAVA_LISTED_BINDINGS = frozenset({
"inferred_parameters", "record_pattern_component", "type_pattern",
})
def _java_bound_names(method_node, source: bytes) -> set[str]:
"""Every name a Java method binds itself, typed or not: parameters, locals
(`var` too), lambda, catch, for-each, resource and pattern variables.
A receiver outside this set and the class's own fields can only be a field
the class inherits (or one of an enclosing class), never a local."""
names: set[str] = set()
stack = [method_node.child_by_field_name("parameters"), method_node.child_by_field_name("body")]
while stack:
node = stack.pop()
if node is None:
continue
if node.type in _JAVA_NAMED_BINDINGS:
name_node = node.child_by_field_name("name")
if name_node is not None and name_node.type == "identifier":
names.add(_read_text(name_node, source))
elif node.type in _JAVA_LISTED_BINDINGS:
names.update(_read_text(c, source) for c in node.children if c.type == "identifier")
elif node.type == "lambda_expression":
params = node.child_by_field_name("parameters")
if params is not None and params.type == "identifier":
names.add(_read_text(params, source))
stack.extend(node.children)
return names
def _java_lambda_parameters(
lambda_node,
source: bytes,
@@ -4943,6 +4978,7 @@ def _extract_generic(
# 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_field_names: dict[str, set[str]] = {}
java_method_scopes: dict[int, tuple[object, str]] = {}
# C# receiver typing is method-scoped too (#2299): class fields/properties
# are shared, parameters and locals belong only to their declaring method —
@@ -6175,6 +6211,9 @@ def _extract_generic(
if (config.ts_module == "tree_sitter_java"
and t == "field_declaration"
and parent_class_nid):
java_field_names.setdefault(parent_class_nid, set()).update(
_java_declarator_names(node, source)
)
type_node = node.child_by_field_name("type")
if type_node is not None:
receiver_type = _java_receiver_type_name(type_node, source)
@@ -7461,6 +7500,18 @@ def _extract_generic(
)
for body_id, (method_node, class_nid) in java_method_scopes.items()
}
# Names a Java receiver can be without being an inherited field. Keyed by
# caller node: overloads share one node, so their names are pooled.
java_bound_names: dict[str, set[str]] = {}
_java_body_caller = {id(body): nid for nid, body in function_bodies}
_java_field_name_tables = {c: dict.fromkeys(names, "") for c, names in java_field_names.items()}
for body_id, (method_node, class_nid) in java_method_scopes.items():
caller = _java_body_caller.get(body_id)
if caller:
java_bound_names.setdefault(caller, set()).update(
_java_bound_names(method_node, source),
_fields_up_chain(_java_field_name_tables, class_nid),
)
csharp_receiver_types = {
body_id: _csharp_method_receiver_types(
method_node,
@@ -8482,6 +8533,13 @@ def _extract_generic(
receiver_type = (receiver_types or {}).get(member_receiver or "")
if receiver_type:
rc_entry["receiver_type"] = receiver_type
elif (is_member_call and member_receiver
and member_receiver.isidentifier()
and member_receiver not in java_bound_names.get(caller_nid, ())):
# Not a local, a parameter or a field of the class
# here: a field it inherits from another file, whose
# type only the resolver can see.
rc_entry["receiver_unbound"] = True
# Kotlin fully-qualified call (#2550): the dotted prefix +
# lang tag let _resolve_kotlin_qualified_calls claim it.
if kotlin_qualified_prefix:
+131
View File
@@ -385,3 +385,134 @@ def test_inherited_call_is_not_bound_to_another_language(tmp_path: Path):
run = _find(result, ".run()", "worker_run")
assert not any(source == run for source, _ in calls)
# A name declared by two unrelated classes, so no corpus-wide unique-name match
# can stand in for the supertype lookup.
_TWO_GATEWAYS = {
"Gateway.java": "class Gateway { void charge() {} }\n",
"Audit.java": "class Audit { void charge() {} boolean isRegistered() { return false; } }\n",
}
def test_bare_call_reaches_a_method_inherited_from_another_file(tmp_path: Path):
calls, result = _calls(tmp_path, {
**_TWO_GATEWAYS,
"Base.java": "abstract class Base { boolean isRegistered() { return true; } }\n",
"Child.java": "class Child extends Base { boolean check() { return isRegistered(); } }\n",
})
check = _find(result, ".check()", "child_check")
assert (check, _find(result, ".isRegistered()", "base_isregistered")) in calls
assert (check, _find(result, ".isRegistered()", "audit_isregistered")) not in calls
def test_bare_call_reaches_an_interface_default_method(tmp_path: Path):
calls, result = _calls(tmp_path, {
**_TWO_GATEWAYS,
"Registry.java": "interface Registry { default boolean isRegistered() { return true; } }\n",
"Child.java": "class Child implements Registry { boolean check() { return isRegistered(); } }\n",
})
check = _find(result, ".check()", "child_check")
assert (check, _find(result, ".isRegistered()", "registry_isregistered")) in calls
def test_bare_call_takes_the_nearest_supertype_declaring_it(tmp_path: Path):
calls, result = _calls(tmp_path, {
"Root.java": "class Root { void stop() {} }\n",
"Middle.java": "class Middle extends Root { void stop() {} }\n",
"Leaf.java": "class Leaf extends Middle { void run() { stop(); } }\n",
})
run = _find(result, ".run()", "leaf_run")
assert (run, _find(result, ".stop()", "middle_stop")) in calls
assert (run, _find(result, ".stop()", "root_stop")) not in calls
def test_bare_call_declared_twice_on_one_level_binds_nothing(tmp_path: Path):
calls, result = _calls(tmp_path, {
"Base.java": "class Base { void log() {} }\n",
"Loud.java": "interface Loud { default void log() {} }\n",
"Child.java": "class Child extends Base implements Loud { void run() { log(); } }\n",
})
run = _find(result, ".run()", "child_run")
assert not any(source == run and "log" in target for source, target in calls)
def test_base_constructor_call_is_not_an_inherited_method(tmp_path: Path):
calls, result = _calls(tmp_path, {
"Base.java": "class Base { Base() {} Base copy() { return new Base(); } }\n",
"Child.java": "class Child extends Base { Base make() { return new Base(); } }\n",
})
make = _find(result, ".make()", "child_make")
assert (make, _find(result, ".Base()", "base_base_base")) not in calls
def test_bare_field_receiver_inherited_from_another_file(tmp_path: Path):
calls, result = _calls(tmp_path, {
**_TWO_GATEWAYS,
"Base.java": "abstract class Base { protected Gateway gateway; }\n",
"Child.java": "class Child extends Base { void pay() { gateway.charge(); } }\n",
})
pay = _find(result, ".pay()", "child_pay")
assert (pay, _find(result, ".charge()", "gateway_charge")) in calls
assert (pay, _find(result, ".charge()", "audit_charge")) not in calls
def test_local_names_shadow_an_inherited_field(tmp_path: Path):
# A parameter, a `var` local and a lambda parameter named like the inherited
# field are not the field: none of these calls may reach Gateway.charge().
calls, result = _calls(tmp_path, {
**_TWO_GATEWAYS,
"Base.java": "abstract class Base { protected Gateway gateway; }\n",
"Child.java": (
"import java.util.List;\n"
"class Child extends Base {\n"
" void viaParam(Audit gateway) { gateway.charge(); }\n"
" void viaVar() { var gateway = new Audit(); gateway.charge(); }\n"
" void viaLambda(List<Audit> xs) { xs.forEach(gateway -> gateway.charge()); }\n"
"}\n"
),
})
gateway_charge = _find(result, ".charge()", "gateway_charge")
for caller in ("viaParam", "viaVar", "viaLambda"):
assert (_find(result, f".{caller}()", caller.lower()), gateway_charge) not in calls, caller
assert (_find(result, ".viaParam()", "viaparam"), _find(result, ".charge()", "audit_charge")) in calls
def test_schema_bump_retires_java_entries_cached_without_the_unbound_marker(tmp_path: Path, monkeypatch):
"""An entry from schema 7 has no `receiver_unbound` marker; replaying it would
leave `gateway.charge()` on an inherited field unlinked."""
import graphify.cache as cache_mod
from graphify.cache import save_cached
from graphify.extract import extract_java
files = {
**_TWO_GATEWAYS,
"Base.java": "abstract class Base { protected Gateway gateway; }\n",
"Child.java": "class Child extends Base { void pay() { gateway.charge(); } }\n",
}
paths = []
for name, body in files.items():
(tmp_path / name).write_text(body, encoding="utf-8")
paths.append(tmp_path / name)
current_schema = cache_mod._AST_CACHE_SCHEMA
monkeypatch.setattr(cache_mod, "_EXTRACTOR_VERSION", "same-version")
monkeypatch.setattr(cache_mod, "_AST_CACHE_SCHEMA", 7) # last schema without the marker
monkeypatch.setattr(cache_mod, "_cleaned_ast_dirs", set())
for p in paths:
stale = extract_java(p)
for raw_call in stale.get("raw_calls", []):
raw_call.pop("receiver_unbound", None)
save_cached(p, stale, root=tmp_path, cache_root=tmp_path, kind="ast")
monkeypatch.setattr(cache_mod, "_AST_CACHE_SCHEMA", current_schema)
monkeypatch.setattr(cache_mod, "_cleaned_ast_dirs", set())
r = extract(paths, root=tmp_path, cache_root=tmp_path, parallel=False)
calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"}
assert (_find(r, ".pay()", "child_pay"), _find(r, ".charge()", "gateway_charge")) in calls