From 6c7081dae6e9daa94ebf2e338ff3f0a1a1ae53b4 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Mon, 22 Jun 2026 21:50:37 -0400 Subject: [PATCH 1/2] 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 `#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 / to the simple class name for readability, while the :JCallable node id keeps the raw 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. --- schema.neo4j.json | 1 + .../com/ibm/cldk/neo4j/GraphProjector.java | 50 +++++-- .../com/ibm/cldk/neo4j/SchemaCatalog.java | 2 +- .../neo4j/GraphProjectorCallGraphTest.java | 136 ++++++++++++++++++ 4 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 src/test/java/com/ibm/cldk/neo4j/GraphProjectorCallGraphTest.java diff --git a/schema.neo4j.json b/schema.neo4j.json index ea45ac5e..09fff6b6 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -421,6 +421,7 @@ "JPackage" ], "properties": { + "path": "string", "is_static": "boolean", "is_wildcard": "boolean" } diff --git a/src/main/java/com/ibm/cldk/neo4j/GraphProjector.java b/src/main/java/com/ibm/cldk/neo4j/GraphProjector.java index 3851d233..a5fae557 100644 --- a/src/main/java/com/ibm/cldk/neo4j/GraphProjector.java +++ b/src/main/java/com/ibm/cldk/neo4j/GraphProjector.java @@ -129,27 +129,36 @@ private static void projectCompilationUnit(RowBuilder b, String fileKey, NodeRef projectTypeBody(b, fileKey, fqn, typeRef, type); } - // Imports: resolve to a known Type (gated) or to a Package node. + // Imports: resolve single-type imports to a JType (project or external), wildcards to a Package. if (cu.getImports() != null) { for (Import im : cu.getImports()) { - projectImport(b, cuRef, im, typeKeys); + projectImport(b, cuRef, im); } } projectComments(b, cuRef, cu.getComments(), fileKey); } - private static void projectImport(RowBuilder b, NodeRef cuRef, Import im, Set typeKeys) { + private static void projectImport(RowBuilder b, NodeRef cuRef, Import im) { String path = im.getPath(); if (path == null || path.isEmpty()) { return; } - Map props = map("is_static", im.isStatic(), "is_wildcard", im.isWildcard()); - if (!im.isWildcard() && typeKeys.contains(path)) { - b.edgeToSymbol("J_IMPORTS", cuRef, path, props); + // The full import path always rides on the edge so it round-trips regardless of target kind. + Map props = map("path", path, "is_static", im.isStatic(), "is_wildcard", im.isWildcard()); + + // A single-type (non-wildcard, non-static) import names a type: link to that JType so the full + // type name round-trips and multiple imports from the same package stay distinct nodes (a + // package node would collapse them — issue #157). The type may already be a project JType, or + // it may be library/external — in which case materialize a bodyless JType keyed by its FQN + // (re-seeing the same id merges, so a real declaration later fills in the remaining props). + if (!im.isWildcard() && !im.isStatic()) { + NodeRef typeRef = b.node(symbolLabels("JType", false), "id", path, + map("id", path, "name", simpleName(path), "fqn", path)); + b.edge("J_IMPORTS", cuRef, typeRef, props); return; } - // Otherwise model the imported package: the path's package portion (strip the trailing class). + // Wildcard (java.util.*) or static-member import: model the package portion of the path. String pkg = im.isWildcard() ? path : packageOf(path); if (pkg != null && !pkg.isEmpty()) { 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 projectCallable(b, fileKey, fqn, typeRef, ce.getValue(), ce.getKey()); } } - for (Field f : safe(type.getFieldDeclarations())) { - projectField(b, fileKey, fqn, typeRef, f); + List fields = type.getFieldDeclarations(); + if (fields != null) { + for (int i = 0; i < fields.size(); i++) { + projectField(b, fileKey, fqn, typeRef, fields.get(i), i); + } } for (EnumConstant ec : safe(type.getEnumConstants())) { projectEnumConstant(b, fileKey, fqn, typeRef, ec); @@ -281,8 +293,14 @@ private static void projectVariable(RowBuilder b, String fileKey, String callabl projectComment(b, ref, v.getComment(), fileKey); } - private static void projectField(RowBuilder b, String fileKey, String ownerFqn, NodeRef owner, Field f) { - String id = ownerFqn + "#field#" + f.getName(); + private static void projectField(RowBuilder b, String fileKey, String ownerFqn, NodeRef owner, Field f, int index) { + // A Java field declaration is keyed by its `variables` list, not a single `name` (which the IR + // leaves null for multi-declarator fields). Key the node id by the joined variable names so each + // declaration is a distinct node; fall back to a positional index if no variables are present. + String key = (f.getVariables() != null && !f.getVariables().isEmpty()) + ? String.join("+", f.getVariables()) + : String.valueOf(index); + String id = ownerFqn + "#field#" + key; NodeRef ref = b.node(Collections.singletonList("JField"), "id", id, RowBuilder.prune( map("id", id, "name", f.getName(), "type", f.getType(), "modifiers", strList(f.getModifiers()), "annotations", strList(f.getAnnotations()), @@ -432,7 +450,15 @@ private static String vertexId(JsonObject vertex) { return null; } String typeDecl = str(vertex, "type_declaration"); - String signature = str(vertex, "signature"); + // Key off `callable_declaration`, not `signature`: the call-graph `signature` rewrites + // / to the simple class name for readability (e.g. `Foo()` instead of + // `()`), which never matches the JCallable node id — keyed by the symbol-table signature + // (`(...)`). `callable_declaration` carries that raw signature verbatim, so constructor + // call edges resolve to their nodes instead of being gated out (issue #158). + String signature = str(vertex, "callable_declaration"); + if (signature == null) { + signature = str(vertex, "signature"); + } if (typeDecl == null || signature == null) { return null; } diff --git a/src/main/java/com/ibm/cldk/neo4j/SchemaCatalog.java b/src/main/java/com/ibm/cldk/neo4j/SchemaCatalog.java index 668dab1e..ba20ddaa 100644 --- a/src/main/java/com/ibm/cldk/neo4j/SchemaCatalog.java +++ b/src/main/java/com/ibm/cldk/neo4j/SchemaCatalog.java @@ -216,7 +216,7 @@ private static List buildRelTypes() { r.add(new RelType("J_IMPLEMENTS", Arrays.asList("JType"), Arrays.asList("JType"), none)); r.add(new RelType("J_ANNOTATED_BY", Arrays.asList("JType", "JCallable", "JField"), Arrays.asList("JAnnotation"), none)); r.add(new RelType("J_IMPORTS", Arrays.asList("JCompilationUnit"), Arrays.asList("JType", "JPackage"), - new P().put("is_static", "boolean").put("is_wildcard", "boolean").done())); + new P().put("path", "string").put("is_static", "boolean").put("is_wildcard", "boolean").done())); r.add(new RelType("J_RESOLVES_TO", Arrays.asList("JCallSite"), Arrays.asList("JCallable"), none)); r.add(new RelType("J_CALLS", Arrays.asList("JCallable"), Arrays.asList("JCallable"), new P().put("type", "string").put("weight", "integer") diff --git a/src/test/java/com/ibm/cldk/neo4j/GraphProjectorCallGraphTest.java b/src/test/java/com/ibm/cldk/neo4j/GraphProjectorCallGraphTest.java new file mode 100644 index 00000000..3552b3fe --- /dev/null +++ b/src/test/java/com/ibm/cldk/neo4j/GraphProjectorCallGraphTest.java @@ -0,0 +1,136 @@ +/* +Copyright IBM Corporation 2023, 2024 + +Licensed under the Apache Public License 2.0, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package com.ibm.cldk.neo4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.ibm.cldk.entities.Callable; +import com.ibm.cldk.entities.JavaCompilationUnit; +import com.ibm.cldk.entities.Type; +import com.ibm.cldk.neo4j.GraphRows.EdgeRow; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Unit-level guard for the level-2 call-graph projection (issue #158). The call-graph {@code source} + * /{@code target} vertices rewrite constructor signatures from {@code (...)} to the simple + * class name for readability, while the {@code :JCallable} node id keeps the raw {@code (...)} + * signature. {@link GraphProjector} must key {@code J_CALLS} endpoints off {@code callable_declaration} + * (which preserves {@code }) so constructor call edges resolve to their nodes instead of being + * silently gated out. + */ +public class GraphProjectorCallGraphTest { + + private static final String FQN = "com.x.Foo"; + + /** A compilation unit declaring {@code com.x.Foo} with a constructor and a {@code bar()} method. */ + private static Map symbolTable() { + Type type = new Type(); + Map callables = new HashMap<>(); + callables.put("()", callable("()")); + callables.put("bar()", callable("bar()")); + type.setCallableDeclarations(callables); + + Map types = new HashMap<>(); + types.put(FQN, type); + + JavaCompilationUnit cu = new JavaCompilationUnit(); + cu.setFilePath("Foo.java"); + cu.setTypeDeclarations(types); + + Map st = new HashMap<>(); + st.put("Foo.java", cu); + return st; + } + + private static Callable callable(String signature) { + Callable c = new Callable(); + c.setSignature(signature); + return c; + } + + private static JsonObject vertex(String typeDecl, String signature, String callableDeclaration) { + JsonObject v = new JsonObject(); + v.addProperty("type_declaration", typeDecl); + v.addProperty("signature", signature); + if (callableDeclaration != null) { + v.addProperty("callable_declaration", callableDeclaration); + } + return v; + } + + private static JsonObject edge(JsonObject source, JsonObject target) { + JsonObject e = new JsonObject(); + e.add("source", source); + e.add("target", target); + e.addProperty("type", "CALL_DEP"); + e.addProperty("weight", "1"); + return e; + } + + private static boolean hasCall(GraphRows rows, String fromId, String toId) { + for (EdgeRow er : rows.edges) { + if (er.type.equals("J_CALLS") && er.from.value.equals(fromId) && er.to.value.equals(toId)) { + return true; + } + } + return false; + } + + @Test + public void constructorCallEdgeResolvesViaCallableDeclaration() { + JsonArray cg = new JsonArray(); + // bar() -> Foo() : the constructor target is rewritten to the simple class name in `signature`, + // but `callable_declaration` keeps `()`. + cg.add(edge(vertex(FQN, "bar()", "bar()"), + vertex(FQN, "Foo()", "()"))); + + GraphRows rows = GraphProjector.project(symbolTable(), cg, "app"); + + assertTrue(hasCall(rows, FQN + "#bar()", FQN + "#()"), + "constructor call edge should resolve to the node via callable_declaration"); + assertEquals(1, rows.edges.stream().filter(e -> e.type.equals("J_CALLS")).count(), + "exactly one J_CALLS edge expected"); + } + + @Test + public void unresolvableTargetIsGatedOut() { + JsonArray cg = new JsonArray(); + // Target is a synthetic accessor with no JCallable node — must NOT produce a J_CALLS edge. + cg.add(edge(vertex(FQN, "bar()", "bar()"), + vertex(FQN, "access$000()", "access$000()"))); + + GraphRows rows = GraphProjector.project(symbolTable(), cg, "app"); + + assertFalse(rows.edges.stream().anyMatch(e -> e.type.equals("J_CALLS")), + "edge to a callable with no node should be gated out"); + } + + @Test + public void fallsBackToSignatureWhenCallableDeclarationAbsent() { + JsonArray cg = new JsonArray(); + // Older payloads without callable_declaration must still resolve via signature. + cg.add(edge(vertex(FQN, "bar()", null), + vertex(FQN, "()", null))); + + GraphRows rows = GraphProjector.project(symbolTable(), cg, "app"); + + assertTrue(hasCall(rows, FQN + "#bar()", FQN + "#()"), + "method edge should resolve using signature when callable_declaration is absent"); + } +} From 48d4cb2cbb22176576da71a8674c36742411773c Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Mon, 22 Jun 2026 21:53:19 -0400 Subject: [PATCH 2/2] fix/build: resolve project path to absolute and surface empty-build failures Two coupled papercuts in the level-2 build path: - A relative `--input` doubled the project path. The build runs with its working directory set to the project root, but gradleBuild passed the raw (relative) `-p ` (maven was already absolutized, gradle was not), so it resolved against the root to `/`, the build failed, and zero application classes loaded. Normalize the path to absolute once at the buildProjectAndStreamClassFiles entry point so every downstream tool is consistent. - A failed build was swallowed: buildProjectAndStreamClassFiles returns an empty list (never null) on failure, but ScopeUtils only guarded `== null`, so analysis proceeded with an empty scope and died later with a cryptic WALA "Could not create entrypoint callsites" error. Guard on emptiness too and report the real cause (build failed / no compiled classes / bad input path). --- src/main/java/com/ibm/cldk/utils/BuildProject.java | 6 +++++- src/main/java/com/ibm/cldk/utils/ScopeUtils.java | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ibm/cldk/utils/BuildProject.java b/src/main/java/com/ibm/cldk/utils/BuildProject.java index 53dcd649..c7e76481 100644 --- a/src/main/java/com/ibm/cldk/utils/BuildProject.java +++ b/src/main/java/com/ibm/cldk/utils/BuildProject.java @@ -194,7 +194,11 @@ private static boolean buildProject(String projectPath, String build) { * @return true if the streaming was successful, false otherwise. */ public static List buildProjectAndStreamClassFiles(String projectPath, String build) throws IOException { - return buildProject(projectPath, build) ? classFilesStream(projectPath) : new ArrayList<>(); + // Normalize to an absolute path up front: the build runs with its working directory set to the + // project root, so a relative `-p`/`-f` would resolve against it and double the path (e.g. + // `/`), making the build fail and yielding zero application classes. + String absProjectPath = Paths.get(projectPath).toAbsolutePath().normalize().toString(); + return buildProject(absProjectPath, build) ? classFilesStream(absProjectPath) : new ArrayList<>(); } private static boolean mkLibDepDirs(String projectPath) { diff --git a/src/main/java/com/ibm/cldk/utils/ScopeUtils.java b/src/main/java/com/ibm/cldk/utils/ScopeUtils.java index 25db185c..d94fa03a 100644 --- a/src/main/java/com/ibm/cldk/utils/ScopeUtils.java +++ b/src/main/java/com/ibm/cldk/utils/ScopeUtils.java @@ -105,8 +105,12 @@ public static AnalysisScope createScope(String projectPath, String applicationDe List applicationClassFiles = BuildProject.buildProjectAndStreamClassFiles(projectPath, build); Log.debug("Application class files: " + String.valueOf(applicationClassFiles.size())); - if (applicationClassFiles == null) { - Log.error("No application classes found."); + // An empty list (not null) is what a failed build returns, so guard on emptiness too — otherwise + // analysis proceeds with zero application classes and fails later with a cryptic WALA entrypoint + // error instead of surfacing the real cause (the project build failed or produced no classes). + if (applicationClassFiles == null || applicationClassFiles.isEmpty()) { + Log.error("No application classes found — the project build may have failed or produced no " + + "compiled classes. Check the build output above and that the input path is correct."); throw new RuntimeException("No application classes found."); } Log.info("Adding application classes to scope.");