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:
Safi
2026-06-18 01:18:48 +01:00
co-authored by Claude Opus 4.8
parent b2a1722903
commit 895a60f4e3
3 changed files with 88 additions and 2 deletions
+1
View File
@@ -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
View File
@@ -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
+71
View File
@@ -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())