diff --git a/graphify/cache.py b/graphify/cache.py index 227b9b367..d49b65ac3 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -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 diff --git a/graphify/extract.py b/graphify/extract.py index 1d2fb8d4e..1c4be2d65 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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( diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index b09bdd096..8c72881cd 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -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: diff --git a/tests/test_java_member_calls.py b/tests/test_java_member_calls.py index 7298298fa..72344dc4f 100644 --- a/tests/test_java_member_calls.py +++ b/tests/test_java_member_calls.py @@ -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 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