From 820018ee3c44395b969158d82aa19e095edecb26 Mon Sep 17 00:00:00 2001 From: rajatnagda45 Date: Sat, 19 Sep 2026 19:50:26 +0530 Subject: [PATCH] fix(java): attach enum body members to the enum, not the file A Java enum wraps its fields, constructors and methods in an enum_body_declarations node, nested under enum_body after the constant list. The generic walker recurses into a class body keeping the enclosing type as the parent scope, but an unknown wrapper node resets that scope to None (an unknown wrapper usually IS a scope boundary). That reset orphaned every enum method, field and constructor onto the file: public enum Planet { EARTH(5.976e+24), MARS(6.421e+23); private final double mass; Planet(double mass) { this.mass = mass; } public double surfaceGravity() { return 6.673e-11 * mass; } } Planet became a bare list of constants; surfaceGravity was emitted as a file-level function and the constructor was dropped entirely. enum_body_declarations is not a scope of its own - its members belong to the enum - so recurse through it transparently, preserving the enum as the parent scope (mirrors the companion_object and ERROR handling already in the walker). Constructors and methods now hang off the enum via method edges and their bodies are walked as call-graph scopes. Adds regression coverage: members attach to the enum and don't leak onto the file, an inter-method call is captured, and the constant case_of edges are untouched. --- graphify/extractors/engine.py | 13 ++++ tests/test_java_enum_members.py | 104 ++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 tests/test_java_enum_members.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 04cb471f73..a63090cb8f 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -5530,6 +5530,19 @@ def scala_base_name(type_node) -> str | None: walk(child, parent_class_nid=parent_class_nid) return + # A Java enum wraps its fields, constructors and methods in an + # `enum_body_declarations` node, nested under `enum_body` after the + # constant list. The default recurse below drops parent_class_nid (an + # unknown wrapper usually IS a scope boundary), which orphaned every + # enum method, field and constructor onto the file instead of the enum. + # It is not a scope of its own — its members belong to the enum — so + # recurse transparently, keeping the enum linkage (mirrors the Kotlin + # companion_object handling above). + if t == "enum_body_declarations": + for child in node.children: + walk(child, parent_class_nid=parent_class_nid) + return + # #2551: tree-sitter ERROR recovery can wrap declarations that plainly # sit inside a class body (e.g. the Kotlin grammar choking on a one-line # sibling member). The default recurse below deliberately drops diff --git a/tests/test_java_enum_members.py b/tests/test_java_enum_members.py new file mode 100644 index 0000000000..813eff5c36 --- /dev/null +++ b/tests/test_java_enum_members.py @@ -0,0 +1,104 @@ +"""Regression coverage for members declared in a Java enum body. + +A Java enum wraps its fields, constructors and methods in an +``enum_body_declarations`` node, nested under ``enum_body`` after the constant +list. The generic walker recurses into a class body preserving the enclosing +type as the parent scope, but an unknown wrapper node normally resets that +scope to ``None`` (an unknown wrapper usually IS a scope boundary). That reset +orphaned every enum method, field and constructor onto the file instead of the +enum: the type looked like a bare list of constants and the calls made inside +those bodies were dropped from the call graph. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _extract(tmp_path: Path, src: str) -> dict: + path = tmp_path / "Planet.java" + path.write_text(src, encoding="utf-8") + return extract([path], cache_root=tmp_path / "graphify-out", root=tmp_path) + + +def _methods_of(result: dict, type_label: str) -> set[str]: + by_id = {n["id"]: n for n in result["nodes"]} + type_ids = {nid for nid, n in by_id.items() if n.get("label") == type_label} + return { + by_id[e["target"]]["label"] + for e in result["edges"] + if e.get("relation") == "method" and e.get("source") in type_ids + } + + +_ENUM_SRC = ( + "public enum Planet {\n" + " EARTH(5.976e+24), MARS(6.421e+23);\n" + "\n" + " private final double mass;\n" + "\n" + " Planet(double mass) { this.mass = mass; }\n" + "\n" + " public double surfaceGravity() {\n" + " return 6.67300E-11 * mass;\n" + " }\n" + "\n" + " public double weight(double other) {\n" + " return other * surfaceGravity();\n" + " }\n" + "}\n" +) + + +def test_enum_method_and_constructor_attach_to_the_enum(tmp_path: Path) -> None: + result = _extract(tmp_path, _ENUM_SRC) + methods = _methods_of(result, "Planet") + # Constructor and both instance methods hang off the enum, not the file. + assert ".Planet()" in methods + assert ".surfaceGravity()" in methods + assert ".weight()" in methods + + # None of them leaked onto the file as a top-level function. + file_ids = { + n["id"] for n in result["nodes"] if str(n.get("label", "")).endswith(".java") + } + file_contained = { + e["target"] + for e in result["edges"] + if e.get("relation") == "contains" and e.get("source") in file_ids + } + by_id = {n["id"]: n for n in result["nodes"]} + leaked = { + by_id[t]["label"] + for t in file_contained + if by_id[t]["label"] in {"surfaceGravity()", "weight()", "Planet()"} + } + assert leaked == set(), f"enum members leaked onto the file: {leaked}" + + +def test_call_between_enum_methods_is_captured(tmp_path: Path) -> None: + """``weight`` calls ``surfaceGravity`` — the body is now a walked scope.""" + result = _extract(tmp_path, _ENUM_SRC) + by_id = {n["id"]: n for n in result["nodes"]} + weight_ids = {nid for nid, n in by_id.items() if n.get("label") == "weight()" or n.get("label") == ".weight()"} + sg_ids = {nid for nid, n in by_id.items() if n.get("label") in ("surfaceGravity()", ".surfaceGravity()")} + assert any( + e.get("relation") == "calls" + and e.get("source") in weight_ids + and e.get("target") in sg_ids + for e in result["edges"] + ), "call from one enum method to another was dropped" + + +def test_enum_constants_still_emit_case_of_edges(tmp_path: Path) -> None: + """The constant list is untouched by the body-scope fix.""" + result = _extract(tmp_path, _ENUM_SRC) + by_id = {n["id"]: n for n in result["nodes"]} + planet_ids = {nid for nid, n in by_id.items() if n.get("label") == "Planet"} + cases = { + by_id[e["target"]]["label"] + for e in result["edges"] + if e.get("relation") == "case_of" and e.get("source") in planet_ids + } + assert cases == {"EARTH", "MARS"}