Skip to content

Commit 6c7081d

Browse files
committed
fix/neo4j: lossless field, import, and call-graph projection (#156, #157, #158)
Three bugs in the `--emit neo4j` projection (GraphProjector) that silently dropped IR data the `--emit json` backend preserves: - #156: every :JField shared the id `<fqn>#field#null` because the node was keyed by Field.getName(), which is always null for a field declaration (the IR keys fields by their `variables` list). All fields of a class collapsed to one node under the j_field_id unique constraint. Key the id by the joined variable names, with a positional-index fallback. - #157: single-type imports were stripped to a :JPackage and multiple imports from one package collapsed to a single edge, losing the type name. Link non-wildcard, non-static imports to a :JType (materializing an external ghost when it is not a project type), keep :JPackage for wildcard/static-member imports, and carry the full import `path` on every J_IMPORTS edge. Adds the `path` property to the J_IMPORTS schema and regenerates schema.neo4j.json. - #158: constructor call edges were gated out because the call-graph vertex `signature` rewrites <init>/<clinit> to the simple class name for readability, while the :JCallable node id keeps the raw <init> signature. Key J_CALLS endpoints off `callable_declaration`, which preserves the symbol-table signature verbatim. On daytrader8 this recovers all resolvable constructor edges (J_CALLS 1562 -> 1715; ~97% of call-graph edges with both endpoints present). The remainder are synthetic/implicit callables with no source node (implicit default ctors, access$ accessors, bridge methods, anonymous classes), which are correctly dropped. Adds GraphProjectorCallGraphTest covering constructor resolution, gating of unresolved targets, and the signature fallback.
1 parent f32312a commit 6c7081d

4 files changed

Lines changed: 176 additions & 13 deletions

File tree

schema.neo4j.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@
421421
"JPackage"
422422
],
423423
"properties": {
424+
"path": "string",
424425
"is_static": "boolean",
425426
"is_wildcard": "boolean"
426427
}

src/main/java/com/ibm/cldk/neo4j/GraphProjector.java

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -129,27 +129,36 @@ private static void projectCompilationUnit(RowBuilder b, String fileKey, NodeRef
129129
projectTypeBody(b, fileKey, fqn, typeRef, type);
130130
}
131131

132-
// Imports: resolve to a known Type (gated) or to a Package node.
132+
// Imports: resolve single-type imports to a JType (project or external), wildcards to a Package.
133133
if (cu.getImports() != null) {
134134
for (Import im : cu.getImports()) {
135-
projectImport(b, cuRef, im, typeKeys);
135+
projectImport(b, cuRef, im);
136136
}
137137
}
138138

139139
projectComments(b, cuRef, cu.getComments(), fileKey);
140140
}
141141

142-
private static void projectImport(RowBuilder b, NodeRef cuRef, Import im, Set<String> typeKeys) {
142+
private static void projectImport(RowBuilder b, NodeRef cuRef, Import im) {
143143
String path = im.getPath();
144144
if (path == null || path.isEmpty()) {
145145
return;
146146
}
147-
Map<String, Object> props = map("is_static", im.isStatic(), "is_wildcard", im.isWildcard());
148-
if (!im.isWildcard() && typeKeys.contains(path)) {
149-
b.edgeToSymbol("J_IMPORTS", cuRef, path, props);
147+
// The full import path always rides on the edge so it round-trips regardless of target kind.
148+
Map<String, Object> props = map("path", path, "is_static", im.isStatic(), "is_wildcard", im.isWildcard());
149+
150+
// A single-type (non-wildcard, non-static) import names a type: link to that JType so the full
151+
// type name round-trips and multiple imports from the same package stay distinct nodes (a
152+
// package node would collapse them — issue #157). The type may already be a project JType, or
153+
// it may be library/external — in which case materialize a bodyless JType keyed by its FQN
154+
// (re-seeing the same id merges, so a real declaration later fills in the remaining props).
155+
if (!im.isWildcard() && !im.isStatic()) {
156+
NodeRef typeRef = b.node(symbolLabels("JType", false), "id", path,
157+
map("id", path, "name", simpleName(path), "fqn", path));
158+
b.edge("J_IMPORTS", cuRef, typeRef, props);
150159
return;
151160
}
152-
// Otherwise model the imported package: the path's package portion (strip the trailing class).
161+
// Wildcard (java.util.*) or static-member import: model the package portion of the path.
153162
String pkg = im.isWildcard() ? path : packageOf(path);
154163
if (pkg != null && !pkg.isEmpty()) {
155164
NodeRef pkgRef = b.node(Collections.singletonList("JPackage"), "name", pkg, map("name", pkg));
@@ -177,8 +186,11 @@ private static void projectTypeBody(RowBuilder b, String fileKey, String fqn, No
177186
projectCallable(b, fileKey, fqn, typeRef, ce.getValue(), ce.getKey());
178187
}
179188
}
180-
for (Field f : safe(type.getFieldDeclarations())) {
181-
projectField(b, fileKey, fqn, typeRef, f);
189+
List<Field> fields = type.getFieldDeclarations();
190+
if (fields != null) {
191+
for (int i = 0; i < fields.size(); i++) {
192+
projectField(b, fileKey, fqn, typeRef, fields.get(i), i);
193+
}
182194
}
183195
for (EnumConstant ec : safe(type.getEnumConstants())) {
184196
projectEnumConstant(b, fileKey, fqn, typeRef, ec);
@@ -281,8 +293,14 @@ private static void projectVariable(RowBuilder b, String fileKey, String callabl
281293
projectComment(b, ref, v.getComment(), fileKey);
282294
}
283295

284-
private static void projectField(RowBuilder b, String fileKey, String ownerFqn, NodeRef owner, Field f) {
285-
String id = ownerFqn + "#field#" + f.getName();
296+
private static void projectField(RowBuilder b, String fileKey, String ownerFqn, NodeRef owner, Field f, int index) {
297+
// A Java field declaration is keyed by its `variables` list, not a single `name` (which the IR
298+
// leaves null for multi-declarator fields). Key the node id by the joined variable names so each
299+
// declaration is a distinct node; fall back to a positional index if no variables are present.
300+
String key = (f.getVariables() != null && !f.getVariables().isEmpty())
301+
? String.join("+", f.getVariables())
302+
: String.valueOf(index);
303+
String id = ownerFqn + "#field#" + key;
286304
NodeRef ref = b.node(Collections.singletonList("JField"), "id", id, RowBuilder.prune(
287305
map("id", id, "name", f.getName(), "type", f.getType(),
288306
"modifiers", strList(f.getModifiers()), "annotations", strList(f.getAnnotations()),
@@ -432,7 +450,15 @@ private static String vertexId(JsonObject vertex) {
432450
return null;
433451
}
434452
String typeDecl = str(vertex, "type_declaration");
435-
String signature = str(vertex, "signature");
453+
// Key off `callable_declaration`, not `signature`: the call-graph `signature` rewrites
454+
// <init>/<clinit> to the simple class name for readability (e.g. `Foo()` instead of
455+
// `<init>()`), which never matches the JCallable node id — keyed by the symbol-table signature
456+
// (`<init>(...)`). `callable_declaration` carries that raw signature verbatim, so constructor
457+
// call edges resolve to their nodes instead of being gated out (issue #158).
458+
String signature = str(vertex, "callable_declaration");
459+
if (signature == null) {
460+
signature = str(vertex, "signature");
461+
}
436462
if (typeDecl == null || signature == null) {
437463
return null;
438464
}

src/main/java/com/ibm/cldk/neo4j/SchemaCatalog.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ private static List<RelType> buildRelTypes() {
216216
r.add(new RelType("J_IMPLEMENTS", Arrays.asList("JType"), Arrays.asList("JType"), none));
217217
r.add(new RelType("J_ANNOTATED_BY", Arrays.asList("JType", "JCallable", "JField"), Arrays.asList("JAnnotation"), none));
218218
r.add(new RelType("J_IMPORTS", Arrays.asList("JCompilationUnit"), Arrays.asList("JType", "JPackage"),
219-
new P().put("is_static", "boolean").put("is_wildcard", "boolean").done()));
219+
new P().put("path", "string").put("is_static", "boolean").put("is_wildcard", "boolean").done()));
220220
r.add(new RelType("J_RESOLVES_TO", Arrays.asList("JCallSite"), Arrays.asList("JCallable"), none));
221221
r.add(new RelType("J_CALLS", Arrays.asList("JCallable"), Arrays.asList("JCallable"),
222222
new P().put("type", "string").put("weight", "integer")
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
Copyright IBM Corporation 2023, 2024
3+
4+
Licensed under the Apache Public License 2.0, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
7+
Unless required by applicable law or agreed to in writing, software
8+
distributed under the License is distributed on an "AS IS" BASIS,
9+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
package com.ibm.cldk.neo4j;
14+
15+
import static org.junit.jupiter.api.Assertions.assertEquals;
16+
import static org.junit.jupiter.api.Assertions.assertFalse;
17+
import static org.junit.jupiter.api.Assertions.assertTrue;
18+
19+
import com.google.gson.JsonArray;
20+
import com.google.gson.JsonObject;
21+
import com.ibm.cldk.entities.Callable;
22+
import com.ibm.cldk.entities.JavaCompilationUnit;
23+
import com.ibm.cldk.entities.Type;
24+
import com.ibm.cldk.neo4j.GraphRows.EdgeRow;
25+
import java.util.HashMap;
26+
import java.util.Map;
27+
import org.junit.jupiter.api.Test;
28+
29+
/**
30+
* Unit-level guard for the level-2 call-graph projection (issue #158). The call-graph {@code source}
31+
* /{@code target} vertices rewrite constructor signatures from {@code <init>(...)} to the simple
32+
* class name for readability, while the {@code :JCallable} node id keeps the raw {@code <init>(...)}
33+
* signature. {@link GraphProjector} must key {@code J_CALLS} endpoints off {@code callable_declaration}
34+
* (which preserves {@code <init>}) so constructor call edges resolve to their nodes instead of being
35+
* silently gated out.
36+
*/
37+
public class GraphProjectorCallGraphTest {
38+
39+
private static final String FQN = "com.x.Foo";
40+
41+
/** A compilation unit declaring {@code com.x.Foo} with a constructor and a {@code bar()} method. */
42+
private static Map<String, JavaCompilationUnit> symbolTable() {
43+
Type type = new Type();
44+
Map<String, Callable> callables = new HashMap<>();
45+
callables.put("<init>()", callable("<init>()"));
46+
callables.put("bar()", callable("bar()"));
47+
type.setCallableDeclarations(callables);
48+
49+
Map<String, Type> types = new HashMap<>();
50+
types.put(FQN, type);
51+
52+
JavaCompilationUnit cu = new JavaCompilationUnit();
53+
cu.setFilePath("Foo.java");
54+
cu.setTypeDeclarations(types);
55+
56+
Map<String, JavaCompilationUnit> st = new HashMap<>();
57+
st.put("Foo.java", cu);
58+
return st;
59+
}
60+
61+
private static Callable callable(String signature) {
62+
Callable c = new Callable();
63+
c.setSignature(signature);
64+
return c;
65+
}
66+
67+
private static JsonObject vertex(String typeDecl, String signature, String callableDeclaration) {
68+
JsonObject v = new JsonObject();
69+
v.addProperty("type_declaration", typeDecl);
70+
v.addProperty("signature", signature);
71+
if (callableDeclaration != null) {
72+
v.addProperty("callable_declaration", callableDeclaration);
73+
}
74+
return v;
75+
}
76+
77+
private static JsonObject edge(JsonObject source, JsonObject target) {
78+
JsonObject e = new JsonObject();
79+
e.add("source", source);
80+
e.add("target", target);
81+
e.addProperty("type", "CALL_DEP");
82+
e.addProperty("weight", "1");
83+
return e;
84+
}
85+
86+
private static boolean hasCall(GraphRows rows, String fromId, String toId) {
87+
for (EdgeRow er : rows.edges) {
88+
if (er.type.equals("J_CALLS") && er.from.value.equals(fromId) && er.to.value.equals(toId)) {
89+
return true;
90+
}
91+
}
92+
return false;
93+
}
94+
95+
@Test
96+
public void constructorCallEdgeResolvesViaCallableDeclaration() {
97+
JsonArray cg = new JsonArray();
98+
// bar() -> Foo() : the constructor target is rewritten to the simple class name in `signature`,
99+
// but `callable_declaration` keeps `<init>()`.
100+
cg.add(edge(vertex(FQN, "bar()", "bar()"),
101+
vertex(FQN, "Foo()", "<init>()")));
102+
103+
GraphRows rows = GraphProjector.project(symbolTable(), cg, "app");
104+
105+
assertTrue(hasCall(rows, FQN + "#bar()", FQN + "#<init>()"),
106+
"constructor call edge should resolve to the <init> node via callable_declaration");
107+
assertEquals(1, rows.edges.stream().filter(e -> e.type.equals("J_CALLS")).count(),
108+
"exactly one J_CALLS edge expected");
109+
}
110+
111+
@Test
112+
public void unresolvableTargetIsGatedOut() {
113+
JsonArray cg = new JsonArray();
114+
// Target is a synthetic accessor with no JCallable node — must NOT produce a J_CALLS edge.
115+
cg.add(edge(vertex(FQN, "bar()", "bar()"),
116+
vertex(FQN, "access$000()", "access$000()")));
117+
118+
GraphRows rows = GraphProjector.project(symbolTable(), cg, "app");
119+
120+
assertFalse(rows.edges.stream().anyMatch(e -> e.type.equals("J_CALLS")),
121+
"edge to a callable with no node should be gated out");
122+
}
123+
124+
@Test
125+
public void fallsBackToSignatureWhenCallableDeclarationAbsent() {
126+
JsonArray cg = new JsonArray();
127+
// Older payloads without callable_declaration must still resolve via signature.
128+
cg.add(edge(vertex(FQN, "bar()", null),
129+
vertex(FQN, "<init>()", null)));
130+
131+
GraphRows rows = GraphProjector.project(symbolTable(), cg, "app");
132+
133+
assertTrue(hasCall(rows, FQN + "#bar()", FQN + "#<init>()"),
134+
"method edge should resolve using signature when callable_declaration is absent");
135+
}
136+
}

0 commit comments

Comments
 (0)