mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-18 01:49:22 +00:00
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.
This commit is contained in:
1 parent
b9cd957072
commit
820018ee3c
2 files changed
+117
No files matched your search
@@ -5530,6 +5530,19 @@ def _extract_generic(
|
||||
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
|
||||
|
||||
@@ -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"}
|
||||
Reference in new issue
Block a user