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/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."); 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"); + } +}