diff --git a/graphify/extract.py b/graphify/extract.py index be7e99448..2fd797a85 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -984,6 +984,119 @@ def _java_collect_type_refs( _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_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", + "lambda_expression", + ): + continue + 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] = [] @@ -3525,6 +3638,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": @@ -4303,6 +4420,11 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: 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) @@ -4823,6 +4945,8 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: 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 @@ -4910,6 +5034,14 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: # 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: @@ -5052,7 +5184,11 @@ def _php_class_const_scope(n) -> str | None: _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. @@ -5065,7 +5201,7 @@ def walk_calls(node, caller_nid: str) -> None: 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: @@ -5075,7 +5211,7 @@ def walk_calls(node, caller_nid: str) -> None: 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 @@ -5222,16 +5358,32 @@ def walk_calls(node, caller_nid: str) -> None: 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 -> 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 @@ -5301,8 +5453,17 @@ def walk_calls(node, caller_nid: str) -> None: 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: @@ -5349,6 +5510,11 @@ def walk_calls(node, caller_nid: str) -> None: # 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 @@ -5555,7 +5721,7 @@ def walk_calls(node, caller_nid: str) -> None: _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: @@ -5585,7 +5751,11 @@ def walk_calls(node, caller_nid: str) -> None: _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 @@ -12251,6 +12421,95 @@ def _key(label: str) -> str: }) +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], @@ -12401,6 +12660,9 @@ def _key(label: str) -> str: 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) +) def extract_objc(path: Path) -> dict: diff --git a/tests/test_java_member_calls.py b/tests/test_java_member_calls.py new file mode 100644 index 000000000..8580744c6 --- /dev/null +++ b/tests/test_java_member_calls.py @@ -0,0 +1,236 @@ +"""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_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