mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 17:26:48 +00:00
Model Java records as type nodes and extract constructor calls (#1373)
Add record_declaration to the Java class types so a record becomes a first-class type node instead of an isolated file node, and add object_creation_expression to call types with a dedicated branch that reads the callee from the `type` field (new Foo() has no `name` field), so `new Foo(...)` emits a calls edge to the constructed type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fix: Java `record` declarations are now modeled as first-class type nodes (they share `class_declaration`'s name/body/interfaces fields), and `new Foo(...)` constructor calls now produce a `calls` edge to the constructed type. Previously a record appeared only as its file node (degree 0) with no incoming edges, and body-level `new` usages were dropped because `object_creation_expression` wasn't a recognized call type and its callee lives in the `type` field rather than `name`. (#1373)
|
||||
- Security fix: `.graphifyignore` and `.gitignore` are now **merged** per directory instead of `.graphifyignore` silently replacing that directory's `.gitignore`. Previously, adding a `.graphifyignore` (e.g. to exclude media) disabled the dir's `.gitignore` entirely, so a file excluded only by `.gitignore` — including neutrally-named secrets like `prod-dump.sql` or `customer-data.json` that the sensitive-file heuristic doesn't catch — got indexed into the graph, whose artifacts embed file contents and are routinely committed. `.gitignore` is read first and `.graphifyignore` last, so `.graphifyignore` patterns (including `!` negations) still win on conflict; adding one can only ever exclude more, never re-include a `.gitignore`-excluded file. (#1363)
|
||||
|
||||
## 0.8.41 (2026-06-17)
|
||||
|
||||
+16
-2
@@ -2091,10 +2091,14 @@ _TSX_CONFIG = LanguageConfig(
|
||||
|
||||
_JAVA_CONFIG = LanguageConfig(
|
||||
ts_module="tree_sitter_java",
|
||||
class_types=frozenset({"class_declaration", "interface_declaration"}),
|
||||
# record_declaration shares class_declaration's name/body/interfaces fields,
|
||||
# so it becomes a first-class type node instead of an isolated file (#1373).
|
||||
class_types=frozenset({"class_declaration", "interface_declaration", "record_declaration"}),
|
||||
function_types=frozenset({"method_declaration", "constructor_declaration"}),
|
||||
import_types=frozenset({"import_declaration"}),
|
||||
call_types=frozenset({"method_invocation"}),
|
||||
# object_creation_expression (`new Foo(...)`) is handled by a dedicated Java
|
||||
# branch in walk_calls below — its callee is in the `type` field, not `name`.
|
||||
call_types=frozenset({"method_invocation", "object_creation_expression"}),
|
||||
call_function_field="name",
|
||||
call_accessor_node_types=frozenset(),
|
||||
function_boundary_types=frozenset({"method_declaration", "constructor_declaration"}),
|
||||
@@ -3579,6 +3583,16 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
|
||||
name = func_node.child_by_field_name("field") or func_node.child_by_field_name("name")
|
||||
if name:
|
||||
callee_name = _read_text(name, 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<Bar> -> 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]
|
||||
else:
|
||||
# Generic: get callee from call_function_field
|
||||
func_node = node.child_by_field_name(config.call_function_field) if config.call_function_field else None
|
||||
|
||||
@@ -98,3 +98,74 @@ def test_java_implements_edge_survives_build(tmp_path: Path):
|
||||
assert impl_edges
|
||||
# The interface node has an incoming implements edge (not isolated).
|
||||
assert any(G.in_degree(v) >= 1 for _, v in impl_edges)
|
||||
|
||||
|
||||
def _label_edges(result: dict, relations):
|
||||
by_id = {n["id"]: n.get("label", "") for n in result["nodes"]}
|
||||
return {
|
||||
(by_id.get(e["source"], ""), e["relation"], by_id.get(e["target"], ""))
|
||||
for e in result["edges"]
|
||||
if e.get("relation") in relations
|
||||
}
|
||||
|
||||
|
||||
def test_java_record_becomes_type_node(tmp_path: Path):
|
||||
# #1373: a Java `record` must produce a first-class type node (with a
|
||||
# `contains` edge from its file), not be left as an isolated file node.
|
||||
rec = _write(
|
||||
tmp_path / "Foo.java",
|
||||
"package com.app;\npublic record Foo(int x, String y) {}\n",
|
||||
)
|
||||
result = extract([rec], cache_root=tmp_path)
|
||||
|
||||
foo = [n for n in result["nodes"]
|
||||
if n.get("label") == "Foo" and n.get("source_file")]
|
||||
assert foo, "record Foo should be a type node, not just the file node"
|
||||
contains = _label_edges(result, {"contains"})
|
||||
assert ("Foo.java", "contains", "Foo") in contains
|
||||
|
||||
|
||||
def test_java_record_implements_interface(tmp_path: Path):
|
||||
# Records reuse class interface handling: `record Foo implements I` emits it.
|
||||
iface = _write(tmp_path / "I.java", "package com.app;\npublic interface I {}\n")
|
||||
rec = _write(
|
||||
tmp_path / "Foo.java",
|
||||
"package com.app;\npublic record Foo(int x) implements I {}\n",
|
||||
)
|
||||
result = extract([iface, rec], cache_root=tmp_path)
|
||||
implements = [e for e in result["edges"] if e["relation"] == "implements"]
|
||||
assert implements, "record implementing an interface should emit an implements edge"
|
||||
|
||||
|
||||
def test_java_cross_file_constructor_call_resolves(tmp_path: Path):
|
||||
# #1373: `new Foo(...)` in a method body must produce a cross-file edge to the
|
||||
# Foo definition. Foo is NOT used as a return type here, so the edge can only
|
||||
# come from the constructor call (object_creation_expression), not return-type
|
||||
# handling.
|
||||
foo = _write(
|
||||
tmp_path / "Foo.java",
|
||||
"package com.app;\npublic record Foo(int x, String y) {}\n",
|
||||
)
|
||||
caller = _write(
|
||||
tmp_path / "Helper.java",
|
||||
"package com.app;\n"
|
||||
"public class Helper {\n"
|
||||
" public void build() {\n"
|
||||
" Object o = new Foo(1, \"a\");\n"
|
||||
" System.out.println(o);\n"
|
||||
" }\n"
|
||||
"}\n",
|
||||
)
|
||||
result = extract([foo, caller], cache_root=tmp_path)
|
||||
|
||||
foo_id = next(n["id"] for n in result["nodes"]
|
||||
if n.get("label") == "Foo" and n.get("source_file"))
|
||||
call_targets = {
|
||||
e["target"] for e in result["edges"]
|
||||
if e.get("relation") in ("calls", "references")
|
||||
}
|
||||
assert foo_id in call_targets, "new Foo(...) should produce a calls/references edge to Foo"
|
||||
|
||||
# Survives graph construction (target is a real node).
|
||||
g = build_from_json(result)
|
||||
assert foo_id in set(g.nodes())
|
||||
|
||||
Reference in New Issue
Block a user