From 0a6990c0eeb382f1aa5cea29bfcea421c9e681d6 Mon Sep 17 00:00:00 2001 From: rajatnagda45 Date: Tue, 6 Oct 2026 08:56:05 +0530 Subject: [PATCH] fix(java): capture methods and calls inside anonymous class bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Java/Groovy anonymous class (`new Runnable() { … }`) keeps its body as a `class_body` hanging off an `object_creation_expression`. That node is not a class_type and the function branch never recurses into bodies, so the anonymous class's methods — and every call made inside them — got no nodes at all. Anonymous listeners/callbacks are routine in Java, so this silently dropped a large slice of the call graph (e.g. a listener's `onClick` calling a sibling method vanished). Mirror the Kotlin anonymous-object handling (#2347): scan a function body for anonymous classes (without crossing a nested method/lambda boundary or descending into a found one), emit an owner node per anonymous class, and walk its class_body like the class branch so members and their calls flow through the normal machinery. The base type is recorded only as the node's label, not an implements/inherits edge — an anonymous class usually realises a library interface/class whose kind is unknowable from this file, so a guessed subtype relation is withheld (fail-closed). --- graphify/extractors/engine.py | 50 ++++++++++++++ tests/test_java_anonymous_class.py | 102 +++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 tests/test_java_anonymous_class.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index d865b4f98..bb980c58e 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -6397,6 +6397,56 @@ def _extract_generic( if lit_body is not None: for child in lit_body.children: walk(child, parent_class_nid=obj_nid) + if config.ts_module in ("tree_sitter_java", "tree_sitter_groovy"): + # Java/Groovy anonymous classes (`new Runnable() { … }`): the + # body hangs off an `object_creation_expression` as a + # `class_body`. The function branch never recurses into + # bodies and object_creation_expression is not a class_type, + # so the anonymous class's methods — and every call inside + # them — got no nodes at all (the Java/Groovy twin of Kotlin + # #2347). Scan this body for anonymous classes without + # crossing a nested method/lambda boundary and without + # descending into a found one, then emit an owner node per + # anonymous class and walk its class_body like the class + # branch so members and their calls flow through the normal + # machinery. The base type is recorded only as the node's + # label, not an implements/inherits edge: an anonymous class + # usually realises a library interface/class whose kind is + # unknowable from this file, so a guessed subtype relation is + # withheld (fail-closed). + _jv_anon = [] + _jv_stack = list(body.children) + while _jv_stack: + _jv_node = _jv_stack.pop() + if _jv_node.type in ("method_declaration", + "lambda_expression"): + continue + if _jv_node.type == "object_creation_expression" and any( + c.type == "class_body" for c in _jv_node.children + ): + _jv_anon.append(_jv_node) + continue + _jv_stack.extend(_jv_node.children) + _jv_anon.sort(key=lambda n: n.start_byte) + for anon in _jv_anon: + anon_line = anon.start_point[0] + 1 + type_node = anon.child_by_field_name("type") + base = _read_text(type_node, source).strip() if type_node else "" + obj_label = base or f"object@L{anon_line}" + obj_nid = _make_id( + func_nid, f"object:{obj_label}", f"L{anon_line}" + ) + add_node(obj_nid, obj_label, anon_line) + add_edge(func_nid, obj_nid, "contains", anon_line) + callable_def_nids.add(obj_nid) + callable_class_nids.add(obj_nid) + anon_body = next( + (c for c in anon.children if c.type == "class_body"), + None, + ) + if anon_body is not None: + for child in anon_body.children: + walk(child, parent_class_nid=obj_nid) return # JS/TS arrow functions and C# namespaces — language-specific extra handling diff --git a/tests/test_java_anonymous_class.py b/tests/test_java_anonymous_class.py new file mode 100644 index 000000000..8db6c0b9f --- /dev/null +++ b/tests/test_java_anonymous_class.py @@ -0,0 +1,102 @@ +"""Java/Groovy anonymous-class members (the Java twin of Kotlin #2347). + +`new Runnable() { ... }` keeps its body as a `class_body` hanging off an +`object_creation_expression`. That node is not a class_type and the function +branch never recurses into bodies, so the anonymous class's methods AND every +call inside them were silently dropped — a large loss given how routine +anonymous listeners/callbacks are in Java. The extractor now emits an owner +node per anonymous class (labeled after the base type), a `contains` edge from +the enclosing method, and walks the body like a class so members and their +calls flow normally. The subtype relation is deliberately NOT asserted: an +anonymous class usually realises a library interface/class whose kind is +unknowable from this file (fail-closed). +""" +from __future__ import annotations + +import os +from pathlib import Path + +from graphify.extract import extract + + +def _extract(tmp_path, files: dict[str, str]): + for name, body in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + old = os.getcwd() + try: + os.chdir(tmp_path) + r = extract([Path(n) for n in files], cache_root=tmp_path / ".cache") + finally: + os.chdir(old) + return r + + +def _edges(r, relation): + return {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == relation} + + +def _find(r, label, id_contains=""): + return next(n["id"] for n in r["nodes"] + if n["label"] == label and id_contains in n["id"]) + + +_APP = { + "App.java": ( + "class App {\n" + " void setup() {\n" + " Runnable r = new Runnable() {\n" + " public void run() { helper(); }\n" + " };\n" + " }\n" + " void helper() {}\n" + "}\n" + ), +} + + +def test_anonymous_class_members_get_nodes_and_method_edges(tmp_path): + r = _extract(tmp_path, _APP) + obj_nid = _find(r, "Runnable", "object") + run = _find(r, ".run()", "object") + methods = _edges(r, "method") + assert (obj_nid, run) in methods, "anonymous-class member must hang off the owner" + # The owner itself is contained by the enclosing method. + setup = _find(r, ".setup()", "app_setup") + assert (setup, obj_nid) in _edges(r, "contains"), \ + "the enclosing method contains the anonymous class" + + +def test_anonymous_class_member_call_resolves_to_enclosing_method(tmp_path): + r = _extract(tmp_path, _APP) + run = _find(r, ".run()", "object") + helper = _find(r, ".helper()", "app_helper") + assert (run, helper) in _edges(r, "calls"), \ + "a call made inside an anonymous-class method was dropped" + + +def test_two_anonymous_classes_in_one_method_do_not_collide(tmp_path): + r = _extract(tmp_path, { + "App.java": ( + "import java.util.Comparator;\n" + "class App {\n" + " void setup() {\n" + " Runnable a = new Runnable() {\n" + " public void run() { doWork(); }\n" + " };\n" + " Comparator c = new Comparator() {\n" + " public int compare(Integer x, Integer y) { return rank(x); }\n" + " };\n" + " }\n" + " void doWork() {}\n" + " int rank(Integer x) { return 0; }\n" + "}\n" + ), + }) + run = _find(r, ".run()", "object") + compare = _find(r, ".compare()", "object") + assert run != compare, "two anonymous classes collapsed onto one node" + calls = _edges(r, "calls") + assert (run, _find(r, ".doWork()", "app_dowork")) in calls + assert (compare, _find(r, ".rank()", "app_rank")) in calls