Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0c68323
test(l4): add l4-sdg-test fixture (chain, mutual recursion, heap roun…
rahlk Aug 28, 2026
d2c4c31
feat(l4): schema model — synthetic-vertex fields, param_in/param_out,…
rahlk Aug 28, 2026
067a73f
feat(l4): accept -a 4 and --precision {rta,0-cfa,0-1-cfa}
rahlk Aug 28, 2026
1efc891
feat(l4): SdgVertices — formal/actual synthetic vertices + param_in/p…
rahlk Aug 28, 2026
67c5fa3
fix(l4): SdgVertices param_in loops over all actuals, not min(nArgs, …
rahlk Aug 28, 2026
22d79c9
feat(l4): app-scoped mod/ref priming + L4WalaOverlays semantic ddg (p…
rahlk Aug 28, 2026
309616f
test(l4): pin the points-to tuple and body-node endpoints, harden the…
rahlk Aug 28, 2026
13caa89
test(l4): give Heap.roundTrip a real local def-use pair for a non-vac…
rahlk Aug 28, 2026
e5362da
feat(l4): wire SDG pass at -a 4 — vertices, param edges, semantic ddg…
rahlk Aug 28, 2026
407aabf
feat(l4): iterative Tarjan SCC condensation over the call graph
rahlk Aug 28, 2026
a6a4efb
feat(l4): summary pass — SCC bottom-up k-limited fixpoint, summary ed…
rahlk Aug 28, 2026
8da8f70
fix(l4): close the summary composition path — actual_out reaches the …
rahlk Aug 28, 2026
09b75b2
fix(l4): route the composition hop through unresolved calls too
rahlk Aug 28, 2026
5b8e3c1
feat(l4): project J_PARAM_IN/J_PARAM_OUT/J_SUMMARY; graph contract 2.1.0
rahlk Aug 28, 2026
9914843
fix(l4): seed summary flows from any spanned body node, not just returns
rahlk Aug 28, 2026
8f4d7a6
fix(cli): validate flag values after --emit neo4j raises the level
rahlk Aug 28, 2026
86102a1
test(l4): pin the fixture's overlay counts and widen the gate assertions
rahlk Aug 28, 2026
64fed71
fix(l4): seed summary flows from container body nodes too
rahlk Aug 28, 2026
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
14 changes: 14 additions & 0 deletions docs/design/specs/schema-v2-l3-l4-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,20 @@ Global/static state modeled as **extra** formal/actual vertices (rides the same

**Cost controls:** flag-gated (nothing at L4 runs unless `-a 4`); k-limiting mandatory for termination; summaries content-hashed/cached with recorded dependency metadata (incremental re-analysis aspirational); parallel-by-construction wavefront over the SCC DAG, `-j N` byte-identical to `-j 1`.

### 9a. Implementation delta

What the L4 branch actually shipped, against the sketch above. Output conforms; the construction does not, and two vertex families were deferred.

**Deferred (not built):**
- **Global/static state as extra formal/actual vertices.** No such vertices are emitted. Static-field *flow* is not lost: `L4WalaOverlays` maps a `StaticFieldKey` heap location to the field name, so static-mediated dependence still rides the `prov:["points-to"]` ddg — it is just not addressable as a vertex a consumer can enumerate.
- **`formal_out` for by-ref parameters.** `SdgVertices` emits exactly one `@formal_out` per callable, for `$ret`, and only when the callable returns a value. Java has no by-ref parameters; mutation-through-a-reference-argument therefore has no dedicated vertex and shows up (if at all) as points-to ddg.

**Built differently (engine deviations):**
- **`param_in`/`param_out` are derived, not sliced.** They come straight from the v2 tree — body `call` nodes with L2-backfilled `callee`, wired to the callee's parameter list — rather than from a WALA `SDG<InstanceKey>` pruned with `GraphSlicer.prune`. The edges are structurally determined by the call graph alone, so the derived result is identical and byte-deterministic, and it avoids re-opening the whole-program ModRef closure that OOMs at 4 GB over a JDK-inclusive call graph (`WalaAnalysis.emptyDefaultingMap`). Recorded in `docs/design/plans/2026-08-27-l4-sdg.md`.
- **Semantic ddg comes from per-method PDGs, not a whole-program SDG.** `L4WalaOverlays` primes mod/ref restricted to application-scope CG nodes (the bounded answer to that same OOM), re-runs `WalaPdgBuilder` per application method, and projects caller-side `HeapStatement`s onto their call's own `NormalStatement` so the interprocedural round trip lands on real body nodes. Consequence: library-mediated heap flow is conservatively absent.

**No `SDG` object is ever constructed** — a reader looking for one will not find it.

---

## 10. CLI contract
Expand Down
4 changes: 3 additions & 1 deletion schema.neo4j.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"schema_version": "2.0.0",
"schema_version": "2.1.0",
"generator": "codeanalyzer-java",
"marker_labels": [
"JEntrypoint"
Expand Down Expand Up @@ -156,6 +156,8 @@
"argument_types": "string[]",
"argument_expr": "string[]",
"_module": "string",
"var": "string",
"call_node": "string",
"start_line": "integer",
"end_line": "integer"
}
Expand Down
81 changes: 65 additions & 16 deletions src/main/java/com/ibm/cldk/CodeAnalyzer.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
import com.ibm.cldk.syntactic_analysis.L1Cache;
import com.ibm.cldk.syntactic_analysis.L1Extractor;
import com.ibm.cldk.syntactic_analysis.L2CallGraph;
import com.ibm.cldk.syntactic_analysis.dataflow.SdgVertices;
import com.ibm.cldk.syntactic_analysis.dataflow.SummaryPass;
import com.ibm.cldk.utils.BuildProject;
import com.ibm.cldk.utils.Log;
import com.ibm.cldk.wala.WalaAnalysis;
Expand Down Expand Up @@ -97,7 +99,7 @@ public class CodeAnalyzer implements Runnable {
public static String projectRootPom;

@Option(names = { "-a",
"--analysis-level" }, description = "Level of analysis to perform. Options: 1 (for just symbol table); 2 (for call graph); 3 (for intraprocedural dataflow: cfg/cdg/ddg). Default: 1")
"--analysis-level" }, description = "Level of analysis to perform. Options: 1 (for just symbol table); 2 (for call graph); 3 (for intraprocedural dataflow: cfg/cdg/ddg); 4 (adds the interprocedural SDG). Default: 1")
public static int analysisLevel = 1;

@Option(names = { "--include-test-classes" }, hidden = true, description = "Print logs to console.")
Expand Down Expand Up @@ -144,7 +146,8 @@ public class CodeAnalyzer implements Runnable {

@Option(names = {
"--no-rta" }, description = "Skip the WALA RTA overlay at --schema v2 --analysis-level 2, "
+ "emitting declared-only call edges without building the application.")
+ "emitting declared-only call edges without building the application. Does not "
+ "suppress the L4 WALA build at --analysis-level 4: the semantic ddg still needs it.")
private boolean noRta = false;

@Option(names = {
Expand All @@ -159,6 +162,11 @@ public class CodeAnalyzer implements Runnable {
+ "class files — uses WALA RTA + PDG for cfg/cdg/ddg).")
private String l3Engine = "ast";

@Option(names = {
"--precision" }, description = "L4 points-to precision: rta (default; reuses the L2 "
+ "call-graph build) | 0-cfa | 0-1-cfa (rebuild the call graph for L4).")
public static String precision = "rta";

@Option(names = {
"--graph-field-depth" }, description = "DDG access-path bound k at --analysis-level 3 (default 3).")
private int graphFieldDepth = 3;
Expand Down Expand Up @@ -353,21 +361,19 @@ private boolean isV2Schema() {
}

/**
* Emit the canonical schema v2 payload. Levels 1 (containment tree) and 2 (the {@code call_graph}
* overlay) are supported, whole-project, JSON. Anything else is an explicit error rather than a
* silently different result.
* Emit the canonical schema v2 payload. Levels 1 (containment tree), 2 (the {@code call_graph}
* overlay), 3 (the intraprocedural {@code cfg}/{@code cdg}/{@code ddg} overlays) and 4 (the
* interprocedural SDG overlays) are supported, whole-project, JSON. Anything else is an explicit
* error rather than a silently different result.
*/
private void analyzeV2() throws Exception {
if (analysisLevel > 3) {
if (analysisLevel > 4) {
throw new ParameterException(spec.commandLine(),
"error: --schema v2 currently supports --analysis-level 1, 2, and 3 only");
}
if (analysisLevel >= 3
&& !"ast".equalsIgnoreCase(l3Engine)
&& !"wala".equalsIgnoreCase(l3Engine)) {
throw new ParameterException(spec.commandLine(),
"error: unknown --l3-engine '" + l3Engine + "'; use ast or wala");
"error: --schema v2 currently supports --analysis-level 1, 2, 3, and 4 only");
}
// Ahead of the flag-value checks below, because it raises the effective level to 4 and those
// checks are level-gated: validating first would let `--emit neo4j --precision garbage` run at
// level 4 with an unrecognised value and silently fall back, against the CLI contract.
if ("neo4j".equalsIgnoreCase(emit)) {
// The graph is always full-depth (keystone depth rule): depth/section selectors cannot
// be combined with it — error loudly rather than silently project a partial graph.
Expand All @@ -381,9 +387,22 @@ private void analyzeV2() throws Exception {
"error: --graph-field-depth does not apply to --emit neo4j; "
+ "the graph is always projected at full depth");
}
analysisLevel = 3;
analysisLevel = 4;
externalCalls = true;
}
if (analysisLevel >= 4
&& !"rta".equalsIgnoreCase(precision)
&& !"0-cfa".equalsIgnoreCase(precision)
&& !"0-1-cfa".equalsIgnoreCase(precision)) {
throw new ParameterException(spec.commandLine(),
"error: unknown --precision '" + precision + "'; use rta, 0-cfa or 0-1-cfa");
}
if (analysisLevel >= 3
&& !"ast".equalsIgnoreCase(l3Engine)
&& !"wala".equalsIgnoreCase(l3Engine)) {
throw new ParameterException(spec.commandLine(),
"error: unknown --l3-engine '" + l3Engine + "'; use ast or wala");
}
if (sourceAnalysis != null || targetFiles != null) {
throw new ParameterException(spec.commandLine(),
"error: --schema v2 supports whole-project analysis only "
Expand Down Expand Up @@ -458,6 +477,24 @@ private void analyzeV2() throws Exception {
if (wala != null) {
L3WalaOverlays.apply(wala, input, modules, graphFieldDepth);
}
// L4: the semantic ddg needs a WALA build regardless of --l3-engine, so build it here (or
// reuse the instance --l3-engine wala already built above) while the jars are still live.
// Must run strictly after the L3 overlay above: primeL4ModRef() permanently mutates the
// WalaAnalysis instance's shared mod/ref maps, and every PDG built afterwards — including
// L3's — would see the primed closure instead of the empty-defaulting one L3 relies on.
if (analysisLevel >= 4) {
if (wala == null) {
String buildCommand = noBuild ? null : (build == null ? "auto" : build);
String deps = dependencyDir == null ? null : dependencyDir.toString();
wala = WalaAnalysis.of(input, deps, buildCommand, precision).orElse(null);
}
if (wala != null) {
L4WalaOverlays.apply(wala, modules, graphFieldDepth);
} else {
Log.warn("L4 semantic ddg unavailable (WALA build failed); emitting the derived "
+ "SDG vertices and param edges only");
}
}
} finally {
BuildProject.cleanLibraryDependencies();
}
Expand All @@ -468,11 +505,23 @@ private void analyzeV2() throws Exception {
L1Cache.save(cache, application, version, modules);
}

// maxLevel reports the requested level: the L1-L3 passes above always run to that level (or
// degrade a specific overlay with a warning), and the L4 vertices/param edges below are
// engine-free, so they run whenever analysisLevel >= 4 regardless of the WALA build's fate.
Analysis analysis;
if (analysisLevel >= 2) {
L2CallGraph.Result l2 = L2CallGraph.build(application, modules, rtaEndpoints, externalCalls);
analysis = V2Emitter.emit(
application, analysisLevel, modules, version, l2.callGraph(), l2.externalSymbols());
// SdgVertices needs the callee ids L2CallGraph.build just backfilled onto call body nodes,
// and no dependency jars, so it runs here rather than inside the try block above.
SdgVertices.Result sdg = null;
if (analysisLevel >= 4) {
sdg = SdgVertices.apply(modules);
// Summaries read the vertices SdgVertices just added, so this must follow it.
SummaryPass.apply(modules, l2.callGraph(), graphFieldDepth);
}
analysis = V2Emitter.emit(application, analysisLevel, modules, version,
l2.callGraph(), l2.externalSymbols(),
sdg == null ? null : sdg.paramIn, sdg == null ? null : sdg.paramOut);
} else {
analysis = V2Emitter.emit(application, analysisLevel, modules, version);
}
Expand Down
102 changes: 67 additions & 35 deletions src/main/java/com/ibm/cldk/L3WalaOverlays.java
Original file line number Diff line number Diff line change
Expand Up @@ -89,47 +89,22 @@ public static void apply(
int totalOverApprox = 0;

for (MethodIr m : wala.applicationMethods()) {
// Derive the join keys using the same converters as RtaCallGraph (same package).
String binaryType =
RtaCallGraph.binaryTypeName(m.method.getDeclaringClass().getName().toString());
String sig = RtaCallGraph.signature(
m.method.getName().toString(), m.method.getDescriptor().toString());

// Look up the JType then the JCallable.
TypeEntry entry = typeIndex.get(binaryType);
if (entry == null) {
skippedNoMatch++;
continue;
}
JCallable callable = entry.type.getCallables().get(sig);
if (callable == null) {
Optional<Joined> joinedOpt = join(m, typeIndex, parseCache, modules);
if (!joinedOpt.isPresent()) {
skippedNoMatch++;
continue;
}

// Re-parse the source (memoized; source text comes from the L1 JModule).
CompilationUnit cu = parseOrCached(parseCache, entry.moduleKey, modules);
if (cu == null) {
skippedNoMatch++;
continue;
}

// Find the BlockStmt for this method in the re-parsed CU.
Optional<BlockStmt> blockOpt =
findBody(cu, binaryType, entry.packageName, callable);
if (!blockOpt.isPresent()) {
skippedNoMatch++;
continue;
}
BlockStmt blockStmt = blockOpt.get();
Joined joined = joinedOpt.get();
JCallable callable = joined.callable;
BlockStmt blockStmt = joined.blockStmt;

// Source text for L1BuildContext (spans need the original text for byte offsets).
String source = modules.get(entry.moduleKey).getSource();
String source = modules.get(joined.moduleKey).getSource();

// Build a minimal L1BuildContext: only spanOf() is called in BodyNodeBuilder.populate;
// the solver-dependent helpers are not used on this path.
L1BuildContext ctx = new L1BuildContext(
applicationId, entry.moduleKey, source, 3, fieldDepth, "wala");
applicationId, joined.moduleKey, source, 3, fieldDepth, "wala");

// Populate the body-node graph seeded with the callable's existing L1 call nodes.
ControlFlowGraph cfg = new ControlFlowGraph();
Expand Down Expand Up @@ -161,13 +136,57 @@ public static void apply(
+ totalOverApprox + " sentinel over-approximation(s)");
}

// ----- the WALA-method → JCallable join -----------------------------------------------------

/**
* Resolves the WALA method {@code m} to the {@link JCallable} it was compiled from and the
* {@link BlockStmt} of its re-parsed source, or empty when any leg of the join fails (type not
* in the L1 map, signature not among its callables, source absent or unparseable, body not
* locatable — including a callable with no body at all, whose {@code body_span} is absent).
*
* <p>Shared with {@link L4WalaOverlays} so both overlay passes reach the same callable from the
* same WALA node; {@code parseCache} carries the memoized compilation units across the loop.
*/
static Optional<Joined> join(
MethodIr m,
Map<String, TypeEntry> typeIndex,
Map<String, CompilationUnit> parseCache,
Map<String, JModule> modules) {

// Derive the join keys using the same converters as RtaCallGraph (same package).
String binaryType =
RtaCallGraph.binaryTypeName(m.method.getDeclaringClass().getName().toString());
String sig = RtaCallGraph.signature(
m.method.getName().toString(), m.method.getDescriptor().toString());

// Look up the JType then the JCallable.
TypeEntry entry = typeIndex.get(binaryType);
if (entry == null) {
return Optional.empty();
}
JCallable callable = entry.type.getCallables().get(sig);
if (callable == null) {
return Optional.empty();
}

// Re-parse the source (memoized; source text comes from the L1 JModule).
CompilationUnit cu = parseOrCached(parseCache, entry.moduleKey, modules);
if (cu == null) {
return Optional.empty();
}

// Find the BlockStmt for this method in the re-parsed CU.
return findBody(cu, binaryType, entry.packageName, callable)
.map(block -> new Joined(entry.moduleKey, callable, block));
}

// ----- index building -----------------------------------------------------------------------

/**
* Builds a map from WALA binary type name (e.g. {@code "com.example.Widget$Inner"}) to the
* module key and JType that holds that type's callables.
*/
private static Map<String, TypeEntry> buildTypeIndex(Map<String, JModule> modules) {
static Map<String, TypeEntry> buildTypeIndex(Map<String, JModule> modules) {
Map<String, TypeEntry> index = new LinkedHashMap<>();
for (Map.Entry<String, JModule> entry : modules.entrySet()) {
String moduleKey = entry.getKey();
Expand Down Expand Up @@ -357,9 +376,22 @@ private static String deriveApplicationId(Map<String, JModule> modules) {
return last > 0 ? moduleId.substring(0, last) : moduleId;
}

// ----- inner type ---------------------------------------------------------------------------
// ----- inner types --------------------------------------------------------------------------

/** One WALA method successfully joined to its L1 callable and re-parsed body block. */
static final class Joined {
final String moduleKey;
final JCallable callable;
final BlockStmt blockStmt;

Joined(String moduleKey, JCallable callable, BlockStmt blockStmt) {
this.moduleKey = moduleKey;
this.callable = callable;
this.blockStmt = blockStmt;
}
}

private static final class TypeEntry {
static final class TypeEntry {
final String moduleKey;
final JType type;
final String packageName;
Expand Down
Loading