diff --git a/CHANGELOG.md b/CHANGELOG.md index 1827df27..0ff92521 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/graphify/extract.py b/graphify/extract.py index 008c150b..e72afdfa 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 -> 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 diff --git a/tests/test_java_type_resolution.py b/tests/test_java_type_resolution.py index 40018c52..dc89f048 100644 --- a/tests/test_java_type_resolution.py +++ b/tests/test_java_type_resolution.py @@ -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())