Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions schema.neo4j.json
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@
"JPackage"
],
"properties": {
"path": "string",
"is_static": "boolean",
"is_wildcard": "boolean"
}
Expand Down
50 changes: 38 additions & 12 deletions src/main/java/com/ibm/cldk/neo4j/GraphProjector.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> typeKeys) {
private static void projectImport(RowBuilder b, NodeRef cuRef, Import im) {
String path = im.getPath();
if (path == null || path.isEmpty()) {
return;
}
Map<String, Object> 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<String, Object> 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));
Expand Down Expand Up @@ -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<Field> 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);
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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
// <init>/<clinit> to the simple class name for readability (e.g. `Foo()` instead of
// `<init>()`), which never matches the JCallable node id — keyed by the symbol-table signature
// (`<init>(...)`). `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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/ibm/cldk/neo4j/SchemaCatalog.java
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ private static List<RelType> 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")
Expand Down
6 changes: 5 additions & 1 deletion src/main/java/com/ibm/cldk/utils/BuildProject.java
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,11 @@ private static boolean buildProject(String projectPath, String build) {
* @return true if the streaming was successful, false otherwise.
*/
public static List<Path> 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.
// `<root>/<root>`), 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) {
Expand Down
8 changes: 6 additions & 2 deletions src/main/java/com/ibm/cldk/utils/ScopeUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,12 @@ public static AnalysisScope createScope(String projectPath, String applicationDe

List<Path> 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.");
Expand Down
136 changes: 136 additions & 0 deletions src/test/java/com/ibm/cldk/neo4j/GraphProjectorCallGraphTest.java
Original file line number Diff line number Diff line change
@@ -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 <init>(...)} to the simple
* class name for readability, while the {@code :JCallable} node id keeps the raw {@code <init>(...)}
* signature. {@link GraphProjector} must key {@code J_CALLS} endpoints off {@code callable_declaration}
* (which preserves {@code <init>}) 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<String, JavaCompilationUnit> symbolTable() {
Type type = new Type();
Map<String, Callable> callables = new HashMap<>();
callables.put("<init>()", callable("<init>()"));
callables.put("bar()", callable("bar()"));
type.setCallableDeclarations(callables);

Map<String, Type> types = new HashMap<>();
types.put(FQN, type);

JavaCompilationUnit cu = new JavaCompilationUnit();
cu.setFilePath("Foo.java");
cu.setTypeDeclarations(types);

Map<String, JavaCompilationUnit> 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 `<init>()`.
cg.add(edge(vertex(FQN, "bar()", "bar()"),
vertex(FQN, "Foo()", "<init>()")));

GraphRows rows = GraphProjector.project(symbolTable(), cg, "app");

assertTrue(hasCall(rows, FQN + "#bar()", FQN + "#<init>()"),
"constructor call edge should resolve to the <init> 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, "<init>()", null)));

GraphRows rows = GraphProjector.project(symbolTable(), cg, "app");

assertTrue(hasCall(rows, FQN + "#bar()", FQN + "#<init>()"),
"method edge should resolve using signature when callable_declaration is absent");
}
}