mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-14 16:09:26 +00:00
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:
1 parent
7536dc0665
commit
724626659a
4 files changed
+246
-16
No files matched your search
+1
-1
@@ -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
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user