From 0c683230d99f4dc2fbc5a1c900f98de067dd13cd Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 11:10:01 -0700 Subject: [PATCH 01/18] test(l4): add l4-sdg-test fixture (chain, mutual recursion, heap round-trip) --- .../l4-sdg-test/build.gradle | 20 +++++++++++++++++++ .../src/main/java/com/l4/Chain.java | 15 ++++++++++++++ .../src/main/java/com/l4/Heap.java | 18 +++++++++++++++++ .../src/main/java/com/l4/Mutual.java | 17 ++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 src/test/resources/test-applications/l4-sdg-test/build.gradle create mode 100644 src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Chain.java create mode 100644 src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Heap.java create mode 100644 src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Mutual.java diff --git a/src/test/resources/test-applications/l4-sdg-test/build.gradle b/src/test/resources/test-applications/l4-sdg-test/build.gradle new file mode 100644 index 00000000..c18e0fae --- /dev/null +++ b/src/test/resources/test-applications/l4-sdg-test/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +sourceSets { + main { + java { + srcDirs = ["src/main/java"] + } + } +} diff --git a/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Chain.java b/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Chain.java new file mode 100644 index 00000000..2d6d014b --- /dev/null +++ b/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Chain.java @@ -0,0 +1,15 @@ +package com.l4; + +public class Chain { + public int a(int x) { + return b(x + 1); + } + + public int b(int y) { + return c(y * 2); + } + + public int c(int z) { + return z - 3; + } +} diff --git a/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Heap.java b/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Heap.java new file mode 100644 index 00000000..0d70bb31 --- /dev/null +++ b/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Heap.java @@ -0,0 +1,18 @@ +package com.l4; + +public class Heap { + private int box; + + public void put(int v) { + this.box = v; + } + + public int get() { + return this.box; + } + + public int roundTrip(int v) { + put(v); + return get(); + } +} diff --git a/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Mutual.java b/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Mutual.java new file mode 100644 index 00000000..80f957d6 --- /dev/null +++ b/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Mutual.java @@ -0,0 +1,17 @@ +package com.l4; + +public class Mutual { + public int even(int n) { + if (n == 0) { + return 1; + } + return odd(n - 1); + } + + public int odd(int n) { + if (n == 0) { + return 0; + } + return even(n - 1); + } +} From d2c4c310402e4cca37c3a852a13c7cda5d661c48 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 11:11:11 -0700 Subject: [PATCH 02/18] =?UTF-8?q?feat(l4):=20schema=20model=20=E2=80=94=20?= =?UTF-8?q?synthetic-vertex=20fields,=20param=5Fin/param=5Fout,=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ibm/cldk/schema/JApplication.java | 6 +++ .../java/com/ibm/cldk/schema/JBodyNode.java | 10 ++++ .../java/com/ibm/cldk/schema/JCallable.java | 3 ++ .../java/com/ibm/cldk/schema/JIdEdge.java | 13 +++++ .../java/com/ibm/cldk/schema/V2Emitter.java | 18 +++++++ .../cldk/schema/L4ModelSerializationTest.java | 52 +++++++++++++++++++ 6 files changed, 102 insertions(+) create mode 100644 src/main/java/com/ibm/cldk/schema/JIdEdge.java create mode 100644 src/test/java/com/ibm/cldk/schema/L4ModelSerializationTest.java diff --git a/src/main/java/com/ibm/cldk/schema/JApplication.java b/src/main/java/com/ibm/cldk/schema/JApplication.java index 73962639..3f558c74 100644 --- a/src/main/java/com/ibm/cldk/schema/JApplication.java +++ b/src/main/java/com/ibm/cldk/schema/JApplication.java @@ -23,4 +23,10 @@ public class JApplication { * edge dangles. Left {@code null} at L1 so the key is omitted. */ private Map externalSymbols; + + /** L4 {@code actual_in → formal_in} edges (global ordinals); null (absent) below level 4. */ + private List paramIn; + + /** L4 {@code formal_out → actual_out} edges (global ordinals); null (absent) below level 4. */ + private List paramOut; } diff --git a/src/main/java/com/ibm/cldk/schema/JBodyNode.java b/src/main/java/com/ibm/cldk/schema/JBodyNode.java index f1839bfd..571082c9 100644 --- a/src/main/java/com/ibm/cldk/schema/JBodyNode.java +++ b/src/main/java/com/ibm/cldk/schema/JBodyNode.java @@ -68,6 +68,16 @@ public class JBodyNode { /** Syntactically evident (a {@code new} expression or {@code this(...)}/{@code super(...)}). */ private boolean isConstructorCall; + /** + * L4 synthetic-vertex payload: the value this vertex stands for — a parameter name on + * {@code formal_in}, {@code $ret} on {@code formal_out}/{@code actual_out}, {@code argN} on + * {@code actual_in}. Absent on every non-synthetic node. + */ + private String of; + + /** The call-site local id an {@code actual_in}/{@code actual_out} vertex belongs to. */ + private String parent; + /** * L2 backfill plumbing (§4): the binary name of the resolved callee's declaring type * ({@code java.util.Map$Entry}) — the fact {@code dst} needs but {@code receiver_type} cannot diff --git a/src/main/java/com/ibm/cldk/schema/JCallable.java b/src/main/java/com/ibm/cldk/schema/JCallable.java index fb8926e1..e1c02bc3 100644 --- a/src/main/java/com/ibm/cldk/schema/JCallable.java +++ b/src/main/java/com/ibm/cldk/schema/JCallable.java @@ -77,6 +77,9 @@ public class JCallable { /** L3 data-dependence edges (prov {@code ssa}) over this callable's body nodes; null below level 3. */ private List ddg; + /** L4 {@code actual_in → actual_out} summary edges (local ids); null (absent) below level 4. */ + private List summary; + /** Local (method-body) classes, keyed by simple name — nesting encoded by containment (D4). */ private Map types = new LinkedHashMap<>(); } diff --git a/src/main/java/com/ibm/cldk/schema/JIdEdge.java b/src/main/java/com/ibm/cldk/schema/JIdEdge.java new file mode 100644 index 00000000..a0fdaf0f --- /dev/null +++ b/src/main/java/com/ibm/cldk/schema/JIdEdge.java @@ -0,0 +1,13 @@ +package com.ibm.cldk.schema; + +import lombok.Data; + +/** + * An id-to-id edge with no payload — the shape of {@code param_in}/{@code param_out} (application + * scope, global-ordinal endpoints) and {@code summary} (callable scope, local endpoints). + */ +@Data +public class JIdEdge { + private String src; + private String dst; +} diff --git a/src/main/java/com/ibm/cldk/schema/V2Emitter.java b/src/main/java/com/ibm/cldk/schema/V2Emitter.java index e6b7d402..dbda9f53 100644 --- a/src/main/java/com/ibm/cldk/schema/V2Emitter.java +++ b/src/main/java/com/ibm/cldk/schema/V2Emitter.java @@ -38,6 +38,18 @@ public static Analysis emit( String analyzerVersion, List callGraph, Map externalSymbols) { + return emit(appName, maxLevel, modules, analyzerVersion, callGraph, externalSymbols, null, null); + } + + public static Analysis emit( + String appName, + int maxLevel, + Map modules, + String analyzerVersion, + List callGraph, + Map externalSymbols, + List paramIn, + List paramOut) { JApplication application = new JApplication(); application.setId(CanId.applicationId(appName)); @@ -55,6 +67,12 @@ public static Analysis emit( if (externalSymbols != null && !externalSymbols.isEmpty()) { application.setExternalSymbols(externalSymbols); } + if (paramIn != null && !paramIn.isEmpty()) { + application.setParamIn(paramIn); + } + if (paramOut != null && !paramOut.isEmpty()) { + application.setParamOut(paramOut); + } Analysis analysis = new Analysis(); analysis.setSchemaVersion("2.0.0"); diff --git a/src/test/java/com/ibm/cldk/schema/L4ModelSerializationTest.java b/src/test/java/com/ibm/cldk/schema/L4ModelSerializationTest.java new file mode 100644 index 00000000..6a3348b3 --- /dev/null +++ b/src/test/java/com/ibm/cldk/schema/L4ModelSerializationTest.java @@ -0,0 +1,52 @@ +package com.ibm.cldk.schema; + +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 java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** L4 model fields serialize under their canonical names and are absent (not empty) below L4. */ +class L4ModelSerializationTest { + + @Test + void syntheticVertexFieldsSerializeAsOfAndParent() { + JBodyNode n = new JBodyNode(); + n.setKind("actual_in"); + n.setOf("arg0"); + n.setParent("5:16"); + String json = V2Json.compact().toJson(n); + assertTrue(json.contains("\"of\":\"arg0\""), json); + assertTrue(json.contains("\"parent\":\"5:16\""), json); + } + + @Test + void applicationCarriesParamEdgesOnlyWhenSet() { + JApplication app = new JApplication(); + app.setId("can://java/x"); + assertFalse(V2Json.compact().toJson(app).contains("param_in"), + "absent means no fact — no empty lists below L4"); + + JIdEdge e = new JIdEdge(); + e.setSrc("can://java/x/f.java/A/a(int)@3:16/actual_in:0"); + e.setDst("can://java/x/f.java/A/b(int)@formal_in:0"); + app.setParamIn(List.of(e)); + String json = V2Json.compact().toJson(app); + assertTrue(json.contains("\"param_in\""), json); + assertTrue(json.contains("actual_in:0"), json); + } + + @Test + void emitterOverloadAttachesParamEdges() { + JIdEdge in = new JIdEdge(); + in.setSrc("s"); + in.setDst("d"); + Analysis a = V2Emitter.emit("app", 4, Map.of(), "test", null, null, List.of(in), List.of()); + assertEquals(4, a.getMaxLevel()); + assertEquals(1, a.getApplication().getParamIn().size()); + assertTrue(a.getApplication().getParamOut() == null, + "empty overlay is omitted, matching call_graph's absence rule"); + } +} From 067a73f794ecc5c70cf7bcbea2783057eb8ab9ef Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 11:29:33 -0700 Subject: [PATCH 03/18] feat(l4): accept -a 4 and --precision {rta,0-cfa,0-1-cfa} --- src/main/java/com/ibm/cldk/CodeAnalyzer.java | 24 +++++++++++--- .../com/ibm/cldk/CodeAnalyzerV2CliTest.java | 32 +++++++++++++++---- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/ibm/cldk/CodeAnalyzer.java b/src/main/java/com/ibm/cldk/CodeAnalyzer.java index 1ab609c2..7b1ad7a2 100644 --- a/src/main/java/com/ibm/cldk/CodeAnalyzer.java +++ b/src/main/java/com/ibm/cldk/CodeAnalyzer.java @@ -97,7 +97,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.") @@ -159,6 +159,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; @@ -358,9 +363,16 @@ private boolean isV2Schema() { * 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, 3, and 4 only"); + } + if (analysisLevel >= 4 + && !"rta".equalsIgnoreCase(precision) + && !"0-cfa".equalsIgnoreCase(precision) + && !"0-1-cfa".equalsIgnoreCase(precision)) { throw new ParameterException(spec.commandLine(), - "error: --schema v2 currently supports --analysis-level 1, 2, and 3 only"); + "error: unknown --precision '" + precision + "'; use rta, 0-cfa or 0-1-cfa"); } if (analysisLevel >= 3 && !"ast".equalsIgnoreCase(l3Engine) @@ -468,13 +480,15 @@ private void analyzeV2() throws Exception { L1Cache.save(cache, application, version, modules); } + // maxLevel reports what was computed: 4 only once the L4 pass ran (Task 6 flips this). + int emittedLevel = Math.min(analysisLevel, 3); 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()); + application, emittedLevel, modules, version, l2.callGraph(), l2.externalSymbols()); } else { - analysis = V2Emitter.emit(application, analysisLevel, modules, version); + analysis = V2Emitter.emit(application, emittedLevel, modules, version); } if ("neo4j".equalsIgnoreCase(emit)) { diff --git a/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java b/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java index f3065151..43d08a93 100644 --- a/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java +++ b/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java @@ -54,6 +54,7 @@ private static int run(String... args) { void resetStaticOptions() throws Exception { set("emit", "json"); set("analysisLevel", 1); + set("precision", "rta"); set("output", null); set("input", null); set("targetFiles", null); @@ -277,13 +278,6 @@ void v2AtAnalysisLevelThreeEmitsDataflowOverlays(@TempDir Path tmp) throws IOExc assertTrue(json.contains("\"cfg\""), "level 3 lays the cfg overlay on callables"); } - @Test - void v2WithAnalysisLevelAboveThreeFailsLoudly(@TempDir Path tmp) throws IOException { - Path in = project(tmp.resolve("app")); - assertNotEquals(0, run("-i", in.toString(), "--schema", "v2", "-a", "4"), - "L4 is not implemented; asking for a level beyond 3 must be an error, not an L3 payload"); - } - @Test void v2WalaL3EngineDegradesClearlyWhenBuildAbsent(@TempDir Path tmp) throws IOException { // No class files present — WALA cannot build the call graph; must exit 0 with declared @@ -445,4 +439,28 @@ void v2ExternalCallsFlagHomesOutOfProjectTargets(@TempDir Path tmp) throws IOExc "--external-calls homes out-of-project targets (e.g. java.lang.Math)"); } + @Test + void analysisLevelFourIsAccepted(@TempDir Path tmp) throws IOException { + Path in = project(tmp.resolve("app")); + Path out = tmp.resolve("out"); + assertEquals(0, run("-i", in.toString(), "-o", out.toString(), "-a", "4", "--no-build"), + "level 4 must run; WALA-unavailable degrades, never crashes"); + JsonObject root = JsonParser.parseString(Files.readString(out.resolve("analysis.json"))).getAsJsonObject(); + assertTrue(root.get("max_level").getAsInt() >= 3, + "degraded runs report what was actually computed"); + } + + @Test + void analysisLevelFiveStillFailsLoudly(@TempDir Path tmp) throws IOException { + Path in = project(tmp.resolve("app")); + assertNotEquals(0, run("-i", in.toString(), "-a", "5")); + } + + @Test + void unknownPrecisionFailsLoudly(@TempDir Path tmp) throws IOException { + Path in = project(tmp.resolve("app")); + assertNotEquals(0, run("-i", in.toString(), "-a", "4", "--precision", "2-cfa"), + "unrecognized flag values exit non-zero (CLI contract)"); + } + } From 1efc89184343eaacde9fbfd14c529f41efbfb4a7 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 11:51:24 -0700 Subject: [PATCH 04/18] =?UTF-8?q?feat(l4):=20SdgVertices=20=E2=80=94=20for?= =?UTF-8?q?mal/actual=20synthetic=20vertices=20+=20param=5Fin/param=5Fout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dataflow/SdgVertices.java | 170 ++++++++++++++++++ .../dataflow/SdgVerticesTest.java | 108 +++++++++++ 2 files changed, 278 insertions(+) create mode 100644 src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVertices.java create mode 100644 src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVerticesTest.java diff --git a/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVertices.java b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVertices.java new file mode 100644 index 00000000..ba446c46 --- /dev/null +++ b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVertices.java @@ -0,0 +1,170 @@ +package com.ibm.cldk.syntactic_analysis.dataflow; + +import com.ibm.cldk.schema.JBodyNode; +import com.ibm.cldk.schema.JCallable; +import com.ibm.cldk.schema.JIdEdge; +import com.ibm.cldk.schema.JModule; +import com.ibm.cldk.schema.JParameter; +import com.ibm.cldk.schema.JType; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * L4 stage 7 (HRB assembly), derived: the synthetic parameter vertices and the cross-function + * param_in/param_out edges are structurally determined by the L2-backfilled call sites and the + * callee's parameter list, so they are built directly from the v2 tree — no engine, pure and + * deterministic. Summary edges are the SummaryPass's job (PR 2), and the semantic ddg is + * L4WalaOverlays' (points-to genuinely needs WALA). + */ +public final class SdgVertices { + + private SdgVertices() {} + + public static final class Result { + public final List paramIn; + public final List paramOut; + + private Result(List paramIn, List paramOut) { + this.paramIn = paramIn; + this.paramOut = paramOut; + } + } + + /** Mutates modules in place (adds synthetic body nodes); returns the application-scope edges. */ + public static Result apply(Map modules) { + Map byId = new LinkedHashMap<>(); + forEachCallable(modules, c -> { + if (c.getId() != null) { + byId.put(c.getId(), c); + } + }); + + List paramIn = new ArrayList<>(); + List paramOut = new ArrayList<>(); + forEachCallable(modules, c -> { + addFormals(c); + addActualsAndEdges(c, byId, paramIn, paramOut); + }); + + paramIn.sort(Comparator.comparing(JIdEdge::getSrc).thenComparing(JIdEdge::getDst)); + paramOut.sort(Comparator.comparing(JIdEdge::getSrc).thenComparing(JIdEdge::getDst)); + return new Result(paramIn, paramOut); + } + + /** + * Adds {@code c}'s own formal vertices: one {@code @formal_in} per declared parameter + * (declaration order), plus a {@code @formal_out} when {@code c} actually hands a value back — + * the thing a caller's {@code actual_out} eventually connects to. + */ + private static void addFormals(JCallable c) { + List params = c.getParameters(); + for (int i = 0; i < params.size(); i++) { + c.getBody().put("@formal_in:" + i, vertex("formal_in", params.get(i).getName(), null)); + } + if (returnsValue(c)) { + c.getBody().put("@formal_out", vertex("formal_out", "$ret", null)); + } + } + + /** {@code void} and a constructor's declared "return type" are not a value a caller can receive. */ + private static boolean returnsValue(JCallable c) { + return c.getReturnType() != null + && !"void".equals(c.getReturnType()) + && !"constructor".equals(c.getKind()); + } + + /** + * For each in-project call site already in {@code c}'s own body, adds its actual vertices and + * wires them to the resolved callee's formals. Iterates a snapshot of {@code c.getBody()} since + * the loop body inserts the very actual vertices it discovers into that same map. An in-project + * callee id that does not resolve through {@code byId} (a stale id) still gets its vertices — + * only the edges are skipped. + */ + private static void addActualsAndEdges( + JCallable c, Map byId, List paramIn, List paramOut) { + List> callSites = new ArrayList<>(c.getBody().entrySet()); + for (Map.Entry entry : callSites) { + String local = entry.getKey(); + JBodyNode node = entry.getValue(); + String calleeId = node.getCallee(); + if (!"call".equals(node.getKind()) || calleeId == null || calleeId.contains("/@external/")) { + continue; + } + + int nArgs = node.getArgumentExpr().size(); + for (int i = 0; i < nArgs; i++) { + c.getBody().put(local + "/actual_in:" + i, vertex("actual_in", "arg" + i, local)); + } + boolean hasActualOut = node.getReturnType() != null && !"void".equals(node.getReturnType()); + if (hasActualOut) { + c.getBody().put(local + "/actual_out", vertex("actual_out", "$ret", local)); + } + + JCallable callee = byId.get(calleeId); + if (callee == null) { + continue; // vertices stand on their own; edge-only-when-resolved + } + + int calleeParams = callee.getParameters().size(); + if (calleeParams > 0) { + int bound = Math.min(nArgs, calleeParams); + for (int i = 0; i < bound; i++) { + int formalIndex = Math.min(i, calleeParams - 1); // varargs: tail args collapse onto it + paramIn.add(edge( + global(c, local + "/actual_in:" + i), global(callee, "@formal_in:" + formalIndex))); + } + } + // Recomputed rather than read off callee.getBody(): traversal order is not call order, so + // the callee's own addFormals may not have run yet when the caller's site is processed. + if (hasActualOut && returnsValue(callee)) { + paramOut.add(edge(global(callee, "@formal_out"), global(c, local + "/actual_out"))); + } + } + } + + /** The global body-node id for a local key under {@code c} — real keys get {@code @}, synthetics concatenate. */ + private static String global(JCallable c, String local) { + return local.startsWith("@") ? c.getId() + local : c.getId() + "@" + local; + } + + /** A synthetic body node; synthetics carry no {@code span} — they are not a source position. */ + private static JBodyNode vertex(String kind, String of, String parent) { + JBodyNode n = new JBodyNode(); + n.setKind(kind); + n.setOf(of); + n.setParent(parent); + return n; + } + + private static JIdEdge edge(String src, String dst) { + JIdEdge e = new JIdEdge(); + e.setSrc(src); + e.setDst(dst); + return e; + } + + /** + * Visits every callable reachable from {@code modules} — mirrors the traversal shape of + * {@code V2GraphProjector.indexTypes} (nested types, then each callable's own local types) without + * depending on that class. + */ + private static void forEachCallable(Map modules, Consumer visitor) { + for (JModule m : modules.values()) { + walkTypes(m.getTypes(), visitor); + } + } + + private static void walkTypes(Map types, Consumer visitor) { + for (JType t : types.values()) { + walkTypes(t.getTypes(), visitor); + for (JCallable c : t.getCallables().values()) { + visitor.accept(c); + walkTypes(c.getTypes(), visitor); + } + } + } +} diff --git a/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVerticesTest.java b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVerticesTest.java new file mode 100644 index 00000000..b7ca7acd --- /dev/null +++ b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVerticesTest.java @@ -0,0 +1,108 @@ +package com.ibm.cldk.syntactic_analysis.dataflow; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.ibm.cldk.schema.JBodyNode; +import com.ibm.cldk.schema.JCallable; +import com.ibm.cldk.schema.JIdEdge; +import com.ibm.cldk.schema.JModule; +import com.ibm.cldk.schema.JParameter; +import com.ibm.cldk.schema.JType; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SdgVerticesTest { + + /** a(int x) at 3:16 calls b(int) — the minimal HRB shape. */ + private static Map twoCallableModule() { + JModule m = new JModule(); + m.setId("can://java/app/A.java"); + + JCallable b = new JCallable(); + b.setId("can://java/app/A.java/A/b(int)"); + b.setReturnType("int"); + JParameter p = new JParameter(); + p.setName("y"); + p.setType("int"); + b.getParameters().add(p); + + JCallable a = new JCallable(); + a.setId("can://java/app/A.java/A/a(int)"); + a.setReturnType("int"); + JParameter px = new JParameter(); + px.setName("x"); + px.setType("int"); + a.getParameters().add(px); + JBodyNode call = new JBodyNode(); + call.setKind("call"); + call.setCallee("can://java/app/A.java/A/b(int)"); + call.getArgumentExpr().add("x + 1"); + call.setReturnType("int"); + a.getBody().put("3:16", call); + + JType t = new JType(); + t.setId("can://java/app/A.java/A"); + t.getCallables().put("a(int)", a); + t.getCallables().put("b(int)", b); + m.getTypes().put("A", t); + + Map modules = new LinkedHashMap<>(); + modules.put("A.java", m); + return modules; + } + + @Test + void buildsFormalActualVerticesAndParamEdges() { + Map modules = twoCallableModule(); + SdgVertices.Result r = SdgVertices.apply(modules); + + JCallable a = modules.get("A.java").getTypes().get("A").getCallables().get("a(int)"); + JCallable b = modules.get("A.java").getTypes().get("A").getCallables().get("b(int)"); + + assertEquals("formal_in", b.getBody().get("@formal_in:0").getKind()); + assertEquals("y", b.getBody().get("@formal_in:0").getOf()); + assertEquals("$ret", b.getBody().get("@formal_out").getOf()); + JBodyNode actualIn = a.getBody().get("3:16/actual_in:0"); + assertNotNull(actualIn, "actual_in child of the call node"); + assertEquals("3:16", actualIn.getParent()); + assertEquals("arg0", actualIn.getOf()); + assertNotNull(a.getBody().get("3:16/actual_out")); + + assertEquals(1, r.paramIn.size()); + JIdEdge in = r.paramIn.get(0); + assertEquals("can://java/app/A.java/A/a(int)@3:16/actual_in:0", in.getSrc()); + assertEquals("can://java/app/A.java/A/b(int)@formal_in:0", in.getDst()); + assertEquals(1, r.paramOut.size()); + assertEquals("can://java/app/A.java/A/b(int)@formal_out", r.paramOut.get(0).getSrc()); + assertEquals("can://java/app/A.java/A/a(int)@3:16/actual_out", r.paramOut.get(0).getDst()); + } + + @Test + void externalCalleeGetsNoVerticesAndNoEdges() { + Map modules = twoCallableModule(); + JCallable a = modules.get("A.java").getTypes().get("A").getCallables().get("a(int)"); + JBodyNode ext = new JBodyNode(); + ext.setKind("call"); + ext.setCallee("can://java/app/@external/java.lang.Math/max(int, int)"); + ext.getArgumentExpr().add("x"); + a.getBody().put("4:9", ext); + + SdgVertices.Result r = SdgVertices.apply(modules); + assertTrue(a.getBody().keySet().stream().noneMatch(k -> k.startsWith("4:9/")), + "external callee: no actual vertices"); + assertEquals(1, r.paramIn.size(), "only the in-project edge"); + } + + @Test + void deterministicAcrossRuns() { + SdgVertices.Result r1 = SdgVertices.apply(twoCallableModule()); + SdgVertices.Result r2 = SdgVertices.apply(twoCallableModule()); + assertEquals( + com.ibm.cldk.schema.V2Json.compact().toJson(r1.paramIn), + com.ibm.cldk.schema.V2Json.compact().toJson(r2.paramIn)); + } +} From 67c5fa339cb9449ea768b83e5fcd1c4208537302 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 11:54:56 -0700 Subject: [PATCH 05/18] fix(l4): SdgVertices param_in loops over all actuals, not min(nArgs, calleeParams) L4 is over-approximate/weak-update; capping the loop at calleeParams silently dropped varargs tail arguments instead of collapsing them onto the last formal. Add a test covering more actuals than declared formals. --- .../dataflow/SdgVertices.java | 9 ++++++--- .../dataflow/SdgVerticesTest.java | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVertices.java b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVertices.java index ba446c46..1227400a 100644 --- a/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVertices.java +++ b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVertices.java @@ -111,9 +111,12 @@ private static void addActualsAndEdges( int calleeParams = callee.getParameters().size(); if (calleeParams > 0) { - int bound = Math.min(nArgs, calleeParams); - for (int i = 0; i < bound; i++) { - int formalIndex = Math.min(i, calleeParams - 1); // varargs: tail args collapse onto it + // Loop over every argument, not just the first calleeParams of them: L4 is + // over-approximate/weak-update (may add reach, never drop it), so a call with more + // actuals than declared formals (varargs) must not silently lose the tail args — + // they all collapse onto the last formal instead of being left unconnected. + for (int i = 0; i < nArgs; i++) { + int formalIndex = Math.min(i, calleeParams - 1); paramIn.add(edge( global(c, local + "/actual_in:" + i), global(callee, "@formal_in:" + formalIndex))); } diff --git a/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVerticesTest.java b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVerticesTest.java index b7ca7acd..6c09e919 100644 --- a/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVerticesTest.java +++ b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SdgVerticesTest.java @@ -97,6 +97,26 @@ void externalCalleeGetsNoVerticesAndNoEdges() { assertEquals(1, r.paramIn.size(), "only the in-project edge"); } + @Test + void extraVarargsArgumentsAllMapToTheLastFormal() { + // b(int y) has a single parameter; make the call site to it carry 3 arguments (as if the + // call were being resolved to a varargs-shaped callee with only one declared formal). + Map modules = twoCallableModule(); + JCallable a = modules.get("A.java").getTypes().get("A").getCallables().get("a(int)"); + JBodyNode call = a.getBody().get("3:16"); + call.getArgumentExpr().add("2"); + call.getArgumentExpr().add("3"); + + SdgVertices.Result r = SdgVertices.apply(modules); + + assertNotNull(a.getBody().get("3:16/actual_in:0")); + assertNotNull(a.getBody().get("3:16/actual_in:1")); + assertNotNull(a.getBody().get("3:16/actual_in:2")); + assertEquals(3, r.paramIn.size()); + assertTrue(r.paramIn.stream().allMatch(e -> e.getDst().endsWith("@formal_in:0")), + "every extra argument beyond the last formal collapses onto it"); + } + @Test void deterministicAcrossRuns() { SdgVertices.Result r1 = SdgVertices.apply(twoCallableModule()); From 22d79c96c1177783e54e601440f26afe3a0aaaa1 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 12:28:15 -0700 Subject: [PATCH 06/18] feat(l4): app-scoped mod/ref priming + L4WalaOverlays semantic ddg (prov points-to) --- .../java/com/ibm/cldk/L3WalaOverlays.java | 102 +++--- .../java/com/ibm/cldk/L4WalaOverlays.java | 290 ++++++++++++++++++ .../java/com/ibm/cldk/wala/WalaAnalysis.java | 88 +++++- .../com/ibm/cldk/wala/WalaL4ModRefTest.java | 93 ++++++ 4 files changed, 537 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/ibm/cldk/L4WalaOverlays.java create mode 100644 src/test/java/com/ibm/cldk/wala/WalaL4ModRefTest.java diff --git a/src/main/java/com/ibm/cldk/L3WalaOverlays.java b/src/main/java/com/ibm/cldk/L3WalaOverlays.java index fa79879a..6f6167ff 100644 --- a/src/main/java/com/ibm/cldk/L3WalaOverlays.java +++ b/src/main/java/com/ibm/cldk/L3WalaOverlays.java @@ -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 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 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(); @@ -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). + * + *

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 join( + MethodIr m, + Map typeIndex, + Map parseCache, + Map 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 buildTypeIndex(Map modules) { + static Map buildTypeIndex(Map modules) { Map index = new LinkedHashMap<>(); for (Map.Entry entry : modules.entrySet()) { String moduleKey = entry.getKey(); @@ -357,9 +376,22 @@ private static String deriveApplicationId(Map 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; diff --git a/src/main/java/com/ibm/cldk/L4WalaOverlays.java b/src/main/java/com/ibm/cldk/L4WalaOverlays.java new file mode 100644 index 00000000..773f4b08 --- /dev/null +++ b/src/main/java/com/ibm/cldk/L4WalaOverlays.java @@ -0,0 +1,290 @@ +/* +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; + +import com.github.javaparser.ast.CompilationUnit; +import com.ibm.cldk.L3WalaOverlays.Joined; +import com.ibm.cldk.L3WalaOverlays.TypeEntry; +import com.ibm.cldk.schema.JCallable; +import com.ibm.cldk.schema.JDdgEdge; +import com.ibm.cldk.schema.JModule; +import com.ibm.cldk.utils.Log; +import com.ibm.cldk.wala.InstructionToNode; +import com.ibm.cldk.wala.WalaAnalysis; +import com.ibm.cldk.wala.WalaAnalysis.MethodIr; +import com.ibm.cldk.wala.WalaPdgBuilder; +import com.ibm.cldk.wala.WalaPdgBuilder.PdgOverlays; +import com.ibm.wala.ipa.callgraph.propagation.ArrayContentsKey; +import com.ibm.wala.ipa.callgraph.propagation.InstanceFieldKey; +import com.ibm.wala.ipa.callgraph.propagation.InstanceKey; +import com.ibm.wala.ipa.callgraph.propagation.PointerKey; +import com.ibm.wala.ipa.callgraph.propagation.StaticFieldKey; +import com.ibm.wala.ipa.slicer.Dependency; +import com.ibm.wala.ipa.slicer.HeapStatement; +import com.ibm.wala.ipa.slicer.NormalStatement; +import com.ibm.wala.ipa.slicer.PDG; +import com.ibm.wala.ipa.slicer.Statement; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Post-build L4 orchestrator: the semantic half of {@code ddg}. + * + *

L3 answers "which definitions reach this use through named variables" — syntax the AST or the + * SSA form can see on its own, tagged {@code prov:["ssa"]}. What neither can see is dependence + * carried by the heap across a call: {@code put(v)} then {@code get()} touch the same field through + * two different frames, and only a points-to analysis can attest that they alias. Those edges are + * tagged {@code prov:["points-to"]} and are what this pass adds. + * + *

It starts by priming WALA's mod/ref maps ({@link WalaAnalysis#primeL4ModRef}), which is what + * makes the PDG materialize heap statements at call sites at all. The dependences then arrive in + * two shapes, and both are collected per method: + *

    + *
  • Intraprocedural — a write and a read of the same location inside one body. Both + * endpoints are {@code NormalStatement}s, so {@link WalaPdgBuilder} already emits them with + * {@code prov:["points-to"]} and is simply re-run here. (Under {@code --l3-engine wala} L3 + * emitted these too and the merge dedupes them; under the AST engine they are new.) + *
  • Interprocedural — the round trip through a callee. WALA anchors these on + * {@code HeapStatement}s, which have no body node, so the builder's deliberate + * {@code NormalStatement}-only filter cannot carry them. They are projected onto their own + * call statements here instead; see {@link #interproceduralHeapEdges}. + *
+ * + *

The merge is strictly additive: L4 may only add reach, never remove it, so every edge L3 + * produced survives byte-identical and only unseen {@code (src,dst,var,prov)} tuples are appended. + */ +public final class L4WalaOverlays { + + private L4WalaOverlays() {} + + /** + * Merges WALA's points-to {@code ddg} edges into the L3 overlays already on {@code modules}. + * + * @param wala the pre-built WALA analysis (the same instance L3 used) + * @param modules the module map, already carrying L3 overlays (mutated in place) + * @param fieldDepth the DDG access-path bound k, for parity with + * {@link L3WalaOverlays#apply} — heap access paths come from the WALA + * instruction's declared field, so it is not consulted here + */ + public static void apply(WalaAnalysis wala, Map modules, int fieldDepth) { + if (modules.isEmpty()) { + return; + } + + wala.primeL4ModRef(); + + Map typeIndex = L3WalaOverlays.buildTypeIndex(modules); + Map parseCache = new LinkedHashMap<>(); + + int matched = 0; + int addedEdges = 0; + + for (MethodIr m : wala.applicationMethods()) { + Optional joined = L3WalaOverlays.join(m, typeIndex, parseCache, modules); + if (!joined.isPresent()) { + continue; + } + JCallable callable = joined.get().callable; + if (callable.getDdg() == null) { + // No L3 ddg to extend (analysis ran below level 3): nothing to merge into. + continue; + } + + InstructionToNode mapper = new InstructionToNode( + InstructionToNode.statementsByLine(joined.get().blockStmt)); + + // Intraprocedural half: the builder's own points-to edges, both endpoints already + // NormalStatements. Under `--l3-engine wala` L3 emitted these too and the merge dedupes + // them away; under the AST engine they are new. + PdgOverlays pdg = WalaPdgBuilder.build(wala, m, mapper); + List produced = new ArrayList<>(pdg.ddg); + + // Interprocedural half: the edges only the primed mod/ref can see. + produced.addAll(interproceduralHeapEdges(wala, m, mapper)); + + addedEdges += merge(callable, produced); + matched++; + } + + Log.info("L4 WALA semantic ddg applied: " + matched + " callable(s) visited, " + + addedEdges + " points-to edge(s) added"); + } + + /** + * The heap dependences that cross a call boundary, projected onto the call statements that + * carry them. + * + *

WALA anchors every interprocedural heap dependence on a {@link HeapStatement}, never on a + * {@link NormalStatement}: {@code put(v); … get()} surfaces as {@code HeapReturnCaller(put) → + * HeapParamCaller(get)}. Those endpoints have no body node of their own, which is why + * {@link WalaPdgBuilder} — whose {@code NormalStatement}-only filter is deliberate — cannot see + * them. Each caller-side heap statement does name the SSA index of its call, so it projects + * onto that call's own {@code NormalStatement}, and the dependence lands on the two body nodes + * a reader would point at. + * + *

Callee-side heap statements ({@code HEAP_PARAM_CALLEE}, {@code HEAP_RET_CALLEE}) are + * dropped: they are the method's own SDG interface, which {@code SdgVertices} derives as + * {@code param_in}/{@code param_out} vertices rather than {@code ddg} edges. + * + *

This builds a second PDG for {@code m}. That is deliberate — it keeps {@link WalaPdgBuilder} + * and the L3 paths it serves untouched — and the pass runs only at {@code -a 4}. + */ + private static List interproceduralHeapEdges( + WalaAnalysis wala, MethodIr m, InstructionToNode mapper) { + + PDG pdg = wala.pdgFor(m.node); + + // Same lazy-edge trap as WalaPdgBuilder: WALA materializes heap du-pairs only when the + // *unlabeled* successor/predecessor accessor is called. Without this loop the labeled + // accessors below return nothing. + for (Statement s : pdg) { + pdg.getSuccNodes(s).forEachRemaining(x -> {}); + pdg.getPredNodes(s).forEachRemaining(x -> {}); + } + + Map callStatements = new HashMap<>(); + for (Statement s : pdg) { + if (s instanceof NormalStatement) { + NormalStatement ns = (NormalStatement) s; + callStatements.put(ns.getInstructionIndex(), ns); + } + } + + List edges = new ArrayList<>(); + for (Dependency label : new Dependency[]{Dependency.DATA_DEP, Dependency.HEAP_DATA_DEP}) { + for (Statement s : pdg) { + for (Iterator it = pdg.getSuccNodes(s, label); it.hasNext();) { + Statement d = it.next(); + + // An endpoint that is a heap statement is what makes the edge interprocedural; + // a pair of NormalStatements is the intraprocedural case WalaPdgBuilder emits. + if (!(s instanceof HeapStatement) && !(d instanceof HeapStatement)) { + continue; + } + // PDG labels an edge into a heap statement DATA_DEP even though it is heap flow + // (PDG.createHeapDataDependenceEdges), so a DATA_DEP edge carries the heap + // exactly when its target is one; every HEAP_DATA_DEP edge does. + if (label == Dependency.DATA_DEP && !(d instanceof HeapStatement)) { + continue; + } + NormalStatement src = projectToCall(s, callStatements); + NormalStatement dst = projectToCall(d, callStatements); + if (src == null || dst == null || src.equals(dst)) { + continue; + } + JDdgEdge edge = new JDdgEdge(); + edge.setSrc(mapper.map( + src.getInstruction(), wala.sourceLine(m, src.getInstructionIndex()))); + edge.setDst(mapper.map( + dst.getInstruction(), wala.sourceLine(m, dst.getInstructionIndex()))); + edge.setVar(heapVar(s, d)); + edge.getProv().add("points-to"); + edges.add(edge); + } + } + } + return edges; + } + + /** + * The {@link NormalStatement} that owns {@code s}: itself when it already is one, the statement + * of the call it decorates when it is a caller-side heap statement, and {@code null} otherwise + * (callee-side heap statements and the method entry/exit interface have no owning instruction). + */ + private static NormalStatement projectToCall( + Statement s, Map callStatements) { + if (s instanceof NormalStatement) { + return (NormalStatement) s; + } + if (s instanceof HeapStatement.HeapParamCaller) { + return callStatements.get(((HeapStatement.HeapParamCaller) s).getCallIndex()); + } + if (s instanceof HeapStatement.HeapReturnCaller) { + return callStatements.get(((HeapStatement.HeapReturnCaller) s).getCallIndex()); + } + return null; + } + + /** + * The access path of the heap location the edge carries, taken from whichever endpoint is a + * {@link HeapStatement}. Mirrors the vocabulary {@link WalaPdgBuilder} uses for its own heap + * edges: the field's simple name, {@code "[*]"} for array contents, {@code "heap"} otherwise — + * non-empty by schema contract. + */ + private static String heapVar(Statement src, Statement dst) { + PointerKey location = src instanceof HeapStatement + ? ((HeapStatement) src).getLocation() + : ((HeapStatement) dst).getLocation(); + if (location instanceof InstanceFieldKey) { + return ((InstanceFieldKey) location).getField().getName().toString(); + } + if (location instanceof StaticFieldKey) { + return ((StaticFieldKey) location).getField().getName().toString(); + } + if (location instanceof ArrayContentsKey) { + return "[*]"; + } + return "heap"; + } + + /** + * Appends the {@code points-to} edges of {@code produced} that {@code callable} does not + * already carry, then re-sorts the whole list on {@code (src,dst,var,prov)} — the same key + * both producers sort on, so the merged list stays in canonical order. + * + * @return the number of edges actually added + */ + private static int merge(JCallable callable, List produced) { + List merged = new ArrayList<>(callable.getDdg()); + Set seen = new LinkedHashSet<>(); + for (JDdgEdge edge : merged) { + seen.add(key(edge)); + } + + int added = 0; + for (JDdgEdge edge : produced) { + if (!edge.getProv().contains("points-to")) { + continue; + } + if (seen.add(key(edge))) { + merged.add(edge); + added++; + } + } + if (added == 0) { + return 0; + } + + merged.sort(Comparator.comparing(JDdgEdge::getSrc) + .thenComparing(JDdgEdge::getDst) + .thenComparing(JDdgEdge::getVar) + .thenComparing(e -> e.getProv().toString())); + callable.setDdg(merged); + return added; + } + + /** The dedup identity of a ddg edge: {@code (src, dst, var, prov)}. */ + private static String key(JDdgEdge edge) { + return edge.getSrc() + "\0" + edge.getDst() + "\0" + edge.getVar() + + "\0" + edge.getProv(); + } +} diff --git a/src/main/java/com/ibm/cldk/wala/WalaAnalysis.java b/src/main/java/com/ibm/cldk/wala/WalaAnalysis.java index 90da386a..358b8ca5 100644 --- a/src/main/java/com/ibm/cldk/wala/WalaAnalysis.java +++ b/src/main/java/com/ibm/cldk/wala/WalaAnalysis.java @@ -22,6 +22,7 @@ import com.ibm.wala.cast.java.translator.jdt.ecj.ECJClassLoaderFactory; import com.ibm.wala.classLoader.IBytecodeMethod; import com.ibm.wala.classLoader.IMethod; +import com.ibm.wala.classLoader.Language; import com.ibm.wala.ipa.callgraph.AnalysisCacheImpl; import com.ibm.wala.ipa.callgraph.AnalysisOptions; import com.ibm.wala.ipa.callgraph.AnalysisOptions.ReflectionOptions; @@ -30,6 +31,7 @@ import com.ibm.wala.ipa.callgraph.CallGraph; import com.ibm.wala.ipa.callgraph.CallGraphBuilder; import com.ibm.wala.ipa.callgraph.IAnalysisCacheView; +import com.ibm.wala.ipa.callgraph.impl.PartialCallGraph; import com.ibm.wala.ipa.callgraph.impl.Util; import com.ibm.wala.ipa.callgraph.propagation.InstanceKey; import com.ibm.wala.ipa.callgraph.propagation.PointerAnalysis; @@ -43,12 +45,15 @@ import com.ibm.wala.util.intset.MutableMapping; import com.ibm.wala.util.intset.MutableSparseIntSet; import com.ibm.wala.util.intset.OrdinalSet; +import com.ibm.wala.util.intset.OrdinalSetMapping; import java.io.PrintStream; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import org.apache.commons.io.output.NullOutputStream; /** @@ -129,6 +134,20 @@ private WalaAnalysis( * skip building and stream pre-compiled {@code .class} files directly) */ public static Optional of(String input, String dependencies, String build) { + return of(input, dependencies, build, "rta"); + } + + /** + * As {@link #of(String, String, String)}, but with an explicit pointer-analysis precision: + * {@code "0-cfa"} and {@code "0-1-cfa"} select WALA's context-insensitive propagation builders, + * anything else (including {@code null}) keeps the default RTA builder. + * + *

Precision is chosen here rather than later because it is a property of the call-graph + * builder: the pointer analysis retained for L4's mod/ref closure is the one this builder + * produced. + */ + public static Optional of( + String input, String dependencies, String build, String precision) { // Mirror RtaCallGraph.endpoints: default the global so BuildProject's static initializer // does not NPE when it resolves the build-tool wrappers. if (CodeAnalyzer.projectRootPom == null) { @@ -152,7 +171,17 @@ public static Optional of(String input, String dependencies, Strin IAnalysisCacheView cache = new AnalysisCacheImpl(AstIRFactory.makeDefaultFactory(), options.getSSAOptions()); - CallGraphBuilder builder = Util.makeRTABuilder(options, cache, cha); + CallGraphBuilder builder; + switch (precision == null ? "rta" : precision.toLowerCase()) { + case "0-cfa": + builder = Util.makeZeroCFABuilder(Language.JAVA, options, cache, cha); + break; + case "0-1-cfa": + builder = Util.makeZeroOneCFABuilder(Language.JAVA, options, cache, cha); + break; + default: + builder = Util.makeRTABuilder(options, cache, cha); + } CallGraph cg = builder.makeCallGraph(options, null); PointerAnalysis pa = builder.getPointerAnalysis(); @@ -206,6 +235,36 @@ public PDG pdgFor(CGNode node) { modRef); } + /** + * Replaces the empty-defaulting mod/ref entries with a real interprocedural closure, computed + * over the application-scope subgraph only. This is what turns L3's intraprocedural + * heap dependences into L4's semantic ones: with real mod/ref, {@link PDG} materializes the + * heap parameter statements at call sites that {@link #emptyDefaultingMap} suppresses. + * + *

Scope, not laziness, is the point. The global closure over a JDK-inclusive call graph is + * the recorded 4 GB OOM (see {@link #emptyDefaultingMap}); {@link PartialCallGraph} restricts + * both the per-node scan and the bottom-up fixpoint — and with them the {@code PointerKey} + * domain the bit vectors are sized by — to application nodes. Library nodes keep the empty + * defaulting entry, so heap flow mediated by a library frame is conservatively absent. + * + *

Nodes whose closure came back empty are stored as an empty set in the computed + * domain rather than left to the default: {@code PDG.unionHeapLocations} both reads the + * backing set (which is null on an empty {@code BitVectorVariable}) and takes its result + * mapping from the PDG's own node, so every primed node must carry a non-null backing set and + * the same {@link OrdinalSetMapping} as its callees. + */ + public void primeL4ModRef() { + Set appNodes = new LinkedHashSet<>(); + for (CGNode node : callGraph) { + if (AnalysisUtils.isApplicationClass(node.getMethod().getDeclaringClass())) { + appNodes.add(node); + } + } + CallGraph appOnly = PartialCallGraph.make(callGraph, appNodes, appNodes); + prime(emptyMod, modRef.computeMod(appOnly, pa)); + prime(emptyRef, modRef.computeRef(appOnly, pa)); + } + /** * The RTA call graph's edges as endpoint pairs. Delegates to * {@link RtaCallGraph#toEndpoints(CallGraph)} so the L2 {@code rta} overlay is byte-identical @@ -217,6 +276,33 @@ public List rtaEndpoints() { // ----- helpers ------------------------------------------------------------------------------ + /** + * Copies {@code computed} into {@code target}, substituting a non-null-backed empty set for + * every entry whose backing set is null (see {@link #primeL4ModRef}). When no entry carries a + * domain at all — no application node touches the heap — nothing is written, leaving the + * empty-defaulting behaviour exactly as it was. + */ + private static void prime( + Map> target, + Map> computed) { + OrdinalSetMapping domain = null; + for (OrdinalSet set : computed.values()) { + if (set.getBackingSet() != null) { + domain = set.getMapping(); + break; + } + } + if (domain == null) { + return; + } + OrdinalSet emptyInDomain = + new OrdinalSet<>(MutableSparseIntSet.makeEmpty(), domain); + for (Map.Entry> entry : computed.entrySet()) { + OrdinalSet set = entry.getValue(); + target.put(entry.getKey(), set.getBackingSet() != null ? set : emptyInDomain); + } + } + private static List buildApplicationMethods(CallGraph callGraph) { List result = new ArrayList<>(); for (CGNode node : callGraph) { diff --git a/src/test/java/com/ibm/cldk/wala/WalaL4ModRefTest.java b/src/test/java/com/ibm/cldk/wala/WalaL4ModRefTest.java new file mode 100644 index 00000000..27860a91 --- /dev/null +++ b/src/test/java/com/ibm/cldk/wala/WalaL4ModRefTest.java @@ -0,0 +1,93 @@ +package com.ibm.cldk.wala; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.ibm.cldk.L4WalaOverlays; +import com.ibm.cldk.schema.JCallable; +import com.ibm.cldk.schema.JDdgEdge; +import com.ibm.cldk.schema.JModule; +import com.ibm.cldk.syntactic_analysis.L1Extractor; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Real WALA over the l4-sdg-test fixture: heap round-trip yields points-to ddg additions. */ +class WalaL4ModRefTest { + + private static final String FIXTURE = "src/test/resources/test-applications/l4-sdg-test"; + + @Test + void heapRoundTripGainsPointsToEdgesWithoutLosingSsaOnes(@TempDir Path tmp) throws Exception { + Map modules = L1Extractor.extractAll( + Paths.get(FIXTURE), "l4-sdg-test", null, new LinkedHashMap<>(), 3, 3, "ast"); + Optional wala = WalaAnalysis.of(compileFixture(tmp), null, null, "rta"); + assumeTrue(wala.isPresent(), "WALA build unavailable in this environment"); + + JCallable roundTrip = modules.values().stream() + .flatMap(m -> m.getTypes().values().stream()) + .filter(t -> t.getId().endsWith("/Heap")) + .flatMap(t -> t.getCallables().values().stream()) + .filter(c -> c.getId().endsWith("roundTrip(int)")) + .findFirst().orElseThrow(); + + // Snapshot every callable's L3 ddg, not just the one under test: additivity is the gate. + // JCallable's equals is structural and its ddg is about to be mutated, so index by + // position rather than hashing the callables themselves. + List callables = modules.values().stream() + .flatMap(m -> m.getTypes().values().stream()) + .flatMap(t -> t.getCallables().values().stream()) + .filter(c -> c.getDdg() != null) + .collect(Collectors.toList()); + // The fixture's methods declare no locals, so the AST L3 engine emits no ssa edges of its + // own — seed one, or the additivity gate below has nothing to be additive over. It shares + // (src,dst,var) with the points-to edge WALA is about to derive and differs only in prov, + // so it also pins prov as part of the dedup identity: collapse the two and one is lost. + JDdgEdge seeded = new JDdgEdge(); + seeded.setSrc("15:9"); + seeded.setDst("16:9"); + seeded.setVar("box"); + seeded.getProv().add("ssa"); + roundTrip.getDdg().add(seeded); + + List> before = new ArrayList<>(); + for (JCallable callable : callables) { + before.add(List.copyOf(callable.getDdg())); + } + + L4WalaOverlays.apply(wala.get(), modules, 3); + + for (int i = 0; i < callables.size(); i++) { + assertTrue(callables.get(i).getDdg().containsAll(before.get(i)), + "L3 ssa edges survive untouched (L3 ⊆ L4): " + callables.get(i).getId()); + } + assertTrue(roundTrip.getDdg().stream().anyMatch(e -> e.getProv().contains("points-to")), + "the this.box write→read round-trip appears as a points-to dependence"); + } + + /** Compiles the fixture's sources into {@code tmp} and returns it as the WALA input root. */ + private static String compileFixture(Path tmp) throws Exception { + List sources; + try (Stream walk = Files.walk(Paths.get(FIXTURE, "src", "main", "java"))) { + sources = walk.filter(p -> p.toString().endsWith(".java")) + .map(Path::toString) + .collect(Collectors.toList()); + } + String[] args = Stream.concat( + Stream.of("-g", "-d", tmp.toString()), sources.stream()).toArray(String[]::new); + assertEquals(0, ToolProvider.getSystemJavaCompiler().run(null, null, null, args), + "fixture compilation must succeed"); + return tmp.toString(); + } +} From 309616fd48a3f3a71c3ec3fba405ace6d4bf7487 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 12:45:25 -0700 Subject: [PATCH 07/18] test(l4): pin the points-to tuple and body-node endpoints, harden the additivity gate --- .../com/ibm/cldk/wala/WalaL4ModRefTest.java | 58 +++++++++++++++---- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/src/test/java/com/ibm/cldk/wala/WalaL4ModRefTest.java b/src/test/java/com/ibm/cldk/wala/WalaL4ModRefTest.java index 27860a91..feb54c5a 100644 --- a/src/test/java/com/ibm/cldk/wala/WalaL4ModRefTest.java +++ b/src/test/java/com/ibm/cldk/wala/WalaL4ModRefTest.java @@ -2,7 +2,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assumptions.assumeTrue; import com.ibm.cldk.L4WalaOverlays; import com.ibm.cldk.schema.JCallable; @@ -20,20 +19,36 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.tools.ToolProvider; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; /** Real WALA over the l4-sdg-test fixture: heap round-trip yields points-to ddg additions. */ +@Tag("realworld") class WalaL4ModRefTest { private static final String FIXTURE = "src/test/resources/test-applications/l4-sdg-test"; + /** + * The heap round trip, from {@code Heap.java}: + *

+     * 14:     public int roundTrip(int v) {
+     * 15:         put(v);        // writes this.box  -> body node 15:9
+     * 16:         return get();  // reads  this.box  -> body node 16:9
+     * 17:     }
+     * 
+ */ + private static final String PUT_CALL = "15:9"; + private static final String GET_CALL = "16:9"; + @Test void heapRoundTripGainsPointsToEdgesWithoutLosingSsaOnes(@TempDir Path tmp) throws Exception { Map modules = L1Extractor.extractAll( Paths.get(FIXTURE), "l4-sdg-test", null, new LinkedHashMap<>(), 3, 3, "ast"); Optional wala = WalaAnalysis.of(compileFixture(tmp), null, null, "rta"); - assumeTrue(wala.isPresent(), "WALA build unavailable in this environment"); + // Not an assumption: WalaAnalysis.of swallows every Throwable, so a regression inside it + // would silently downgrade this gate to a skip. + assertTrue(wala.isPresent(), "WalaAnalysis.of must succeed over the compiled fixture"); JCallable roundTrip = modules.values().stream() .flatMap(m -> m.getTypes().values().stream()) @@ -54,26 +69,45 @@ void heapRoundTripGainsPointsToEdgesWithoutLosingSsaOnes(@TempDir Path tmp) thro // own — seed one, or the additivity gate below has nothing to be additive over. It shares // (src,dst,var) with the points-to edge WALA is about to derive and differs only in prov, // so it also pins prov as part of the dedup identity: collapse the two and one is lost. - JDdgEdge seeded = new JDdgEdge(); - seeded.setSrc("15:9"); - seeded.setDst("16:9"); - seeded.setVar("box"); - seeded.getProv().add("ssa"); - roundTrip.getDdg().add(seeded); + roundTrip.getDdg().add(edge(PUT_CALL, GET_CALL, "box", List.of("ssa"))); + // Deep-copy the snapshot: sharing the JDdgEdge references would compare a rewritten edge + // with itself, so an in-place mutation of an L3 edge would slip through the gate below. List> before = new ArrayList<>(); for (JCallable callable : callables) { - before.add(List.copyOf(callable.getDdg())); + before.add(callable.getDdg().stream() + .map(e -> edge(e.getSrc(), e.getDst(), e.getVar(), e.getProv())) + .collect(Collectors.toList())); } L4WalaOverlays.apply(wala.get(), modules, 3); for (int i = 0; i < callables.size(); i++) { assertTrue(callables.get(i).getDdg().containsAll(before.get(i)), - "L3 ssa edges survive untouched (L3 ⊆ L4): " + callables.get(i).getId()); + "L3 ssa edges survive untouched and unrewritten (L3 ⊆ L4): " + + callables.get(i).getId()); } - assertTrue(roundTrip.getDdg().stream().anyMatch(e -> e.getProv().contains("points-to")), - "the this.box write→read round-trip appears as a points-to dependence"); + + // Pin the whole tuple, not just the provenance: the endpoints are the projection of WALA's + // caller-side heap statements onto the calls they decorate, so a regression that lost the + // attribution (both ids collapsing to the ":0" sentinel, or landing on the wrong call + // site) would still satisfy a bare "some edge is points-to" assertion. + assertTrue(roundTrip.getDdg().contains(edge(PUT_CALL, GET_CALL, "box", List.of("points-to"))), + "the this.box write→read round-trip is a points-to dependence from the put call to " + + "the get call, keyed on the field: " + roundTrip.getDdg()); + + // ...and the endpoints must be real body nodes of this callable, not merely well-formed ids. + assertTrue(roundTrip.getBody().keySet().containsAll(List.of(PUT_CALL, GET_CALL)), + "both endpoints are body nodes of roundTrip: " + roundTrip.getBody().keySet()); + } + + private static JDdgEdge edge(String src, String dst, String var, List prov) { + JDdgEdge edge = new JDdgEdge(); + edge.setSrc(src); + edge.setDst(dst); + edge.setVar(var); + edge.getProv().addAll(prov); + return edge; } /** Compiles the fixture's sources into {@code tmp} and returns it as the WALA input root. */ From 13caa893304144b2eb35f97d4d108c7d0865d65e Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 13:23:34 -0700 Subject: [PATCH 08/18] test(l4): give Heap.roundTrip a real local def-use pair for a non-vacuous ssa gate l4-sdg-test's fixture had zero L3 ssa ddg edges anywhere: none of Chain, Mutual or Heap declares a local variable, so the AST DdgBuilder (and, for the same structural reason, WALA's own WalaPdgBuilder, whose NormalStatement -only filter excludes parameter-to-first-use flow) has no def-use pair to emit for any of them. Task 5's WalaL4ModRefTest already had to work around this by manually seeding a synthetic prov:["ssa"] edge before calling L4WalaOverlays.apply (see its comment); the project's own pre-flight ledger records the same finding twice under Task 5's progress notes. Task 6's end-to-end gate (L4GateTest.semanticDdgAddsToNotReplacesSsa) drives the real CLI and asserts unconditionally that Heap.roundTrip's ddg still carries an ssa edge after the L4 merge (spec section 14: "added to, not replacing, L3's ssa edges"). An end-to-end run has no seeding hook, so against the original fixture this assertion could never be satisfied, in any environment, independent of the WALA-availability guard already ruled for the points-to half. Minimal fix: roundTrip now assigns get()'s result to a local `r` before returning it, giving the AST engine a genuine def->use pair. put(v)'s call site keeps id 15:9, and the statement enclosing the get() call keeps id 16:9 (now a local-variable-declaration statement rather than a return), so WalaL4ModRefTest's PUT_CALL/GET_CALL literals and its points-to assertion are unaffected -- verified by re-running it unchanged (heapRoundTripGainsPointsToEdgesWithoutLosingSsaOnes still passes, byte-identical "8 callable(s) visited, 1 points-to edge(s) added" output). Also updates that test's now-stale line-number javadoc comment. --- src/test/java/com/ibm/cldk/wala/WalaL4ModRefTest.java | 5 +++-- .../l4-sdg-test/src/main/java/com/l4/Heap.java | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/ibm/cldk/wala/WalaL4ModRefTest.java b/src/test/java/com/ibm/cldk/wala/WalaL4ModRefTest.java index feb54c5a..ceec39bb 100644 --- a/src/test/java/com/ibm/cldk/wala/WalaL4ModRefTest.java +++ b/src/test/java/com/ibm/cldk/wala/WalaL4ModRefTest.java @@ -34,8 +34,9 @@ class WalaL4ModRefTest { *
      * 14:     public int roundTrip(int v) {
      * 15:         put(v);        // writes this.box  -> body node 15:9
-     * 16:         return get();  // reads  this.box  -> body node 16:9
-     * 17:     }
+     * 16:         int r = get(); // reads  this.box  -> body node 16:9
+     * 17:         return r;
+     * 18:     }
      * 
*/ private static final String PUT_CALL = "15:9"; diff --git a/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Heap.java b/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Heap.java index 0d70bb31..665e7e7e 100644 --- a/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Heap.java +++ b/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Heap.java @@ -13,6 +13,7 @@ public int get() { public int roundTrip(int v) { put(v); - return get(); + int r = get(); + return r; } } From e5362da3cbd329ea10cd33c94039c762e71395ef Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 13:23:52 -0700 Subject: [PATCH 09/18] =?UTF-8?q?feat(l4):=20wire=20SDG=20pass=20at=20-a?= =?UTF-8?q?=204=20=E2=80=94=20vertices,=20param=20edges,=20semantic=20ddg,?= =?UTF-8?q?=20degrade=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connects the two L4 halves Tasks 4 and 5 built, which nothing called yet. Inside the existing try (before the finally that deletes the dependency jars), after the L3 overlay: at -a 4, build a WalaAnalysis if --l3-engine wala hasn't already built one (reusing that instance otherwise), then run L4WalaOverlays.apply. This must come strictly after L3WalaOverlays.apply -- primeL4ModRef() permanently mutates the WalaAnalysis instance's shared mod/ref maps, so every PDG built afterwards would see the primed closure instead of the empty-defaulting one L3 relies on. When the build fails, warn and continue: the engine-free half of L4 still has something to emit. After the try, once L2CallGraph.build has backfilled callee onto call body nodes: SdgVertices.apply(modules) at -a 4 (it reads callee, so it must run after that backfill, not before). Its param_in/param_out edges pass through the 8-arg V2Emitter.emit overload. max_level drops the Task 3 clamp (Math.min(analysisLevel, 3)) and reports analysisLevel directly: the L4 vertices/param edges are engine-free and run whenever -a 4 is requested, independent of whether the semantic ddg's WALA build succeeds -- mirroring how a degraded --l3-engine wala run already reports max_level 3. Also documents in --no-rta's help text that it does not suppress the L4 build. Also tightens CodeAnalyzerV2CliTest.analysisLevelFourIsAccepted, which asserted max_level >= 3 -- correct only while the clamp existed -- to the exact expected value now that -a 4 always means max_level 4. L4GateTest (new) drives the real CLI end-to-end over the l4-sdg-test fixture: max_level, param_in/param_out arity and no-dangling, a named a->b edge, ssa-and-points-to additivity on Heap.roundTrip, and L3-vs-L4 monotonicity. This environment has no gradle on PATH, so the fixture's WALA build fails and degrades; the points-to half of the additivity check is guarded with Assumptions.assumeTrue on whether the run produced one, while everything else -- including the ssa-survival half, engine-free by the preceding fixture fix -- is asserted unconditionally. --- src/main/java/com/ibm/cldk/CodeAnalyzer.java | 37 +++- .../com/ibm/cldk/CodeAnalyzerV2CliTest.java | 5 +- .../java/com/ibm/cldk/schema/L4GateTest.java | 176 ++++++++++++++++++ 3 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/ibm/cldk/schema/L4GateTest.java diff --git a/src/main/java/com/ibm/cldk/CodeAnalyzer.java b/src/main/java/com/ibm/cldk/CodeAnalyzer.java index 7b1ad7a2..0471b554 100644 --- a/src/main/java/com/ibm/cldk/CodeAnalyzer.java +++ b/src/main/java/com/ibm/cldk/CodeAnalyzer.java @@ -31,6 +31,7 @@ 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.utils.BuildProject; import com.ibm.cldk.utils.Log; import com.ibm.cldk.wala.WalaAnalysis; @@ -144,7 +145,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 = { @@ -470,6 +472,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(); } @@ -480,15 +500,20 @@ private void analyzeV2() throws Exception { L1Cache.save(cache, application, version, modules); } - // maxLevel reports what was computed: 4 only once the L4 pass ran (Task 6 flips this). - int emittedLevel = Math.min(analysisLevel, 3); + // 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, emittedLevel, 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 = analysisLevel >= 4 ? SdgVertices.apply(modules) : null; + 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, emittedLevel, modules, version); + analysis = V2Emitter.emit(application, analysisLevel, modules, version); } if ("neo4j".equalsIgnoreCase(emit)) { diff --git a/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java b/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java index 43d08a93..bb7d5c07 100644 --- a/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java +++ b/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java @@ -446,8 +446,9 @@ void analysisLevelFourIsAccepted(@TempDir Path tmp) throws IOException { assertEquals(0, run("-i", in.toString(), "-o", out.toString(), "-a", "4", "--no-build"), "level 4 must run; WALA-unavailable degrades, never crashes"); JsonObject root = JsonParser.parseString(Files.readString(out.resolve("analysis.json"))).getAsJsonObject(); - assertTrue(root.get("max_level").getAsInt() >= 3, - "degraded runs report what was actually computed"); + assertEquals(4, root.get("max_level").getAsInt(), + "max_level is the requested level: the L4 vertices/param edges are engine-free and " + + "still ran, even though the semantic ddg (WALA-dependent) degraded"); } @Test diff --git a/src/test/java/com/ibm/cldk/schema/L4GateTest.java b/src/test/java/com/ibm/cldk/schema/L4GateTest.java new file mode 100644 index 00000000..5b84c422 --- /dev/null +++ b/src/test/java/com/ibm/cldk/schema/L4GateTest.java @@ -0,0 +1,176 @@ +package com.ibm.cldk.schema; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +/** Spec §14 L4 gate over the l4-sdg-test fixture (minus summary edges — PR 2). */ +class L4GateTest { + + private static JsonObject root; + + @BeforeAll + static void analyze(@TempDir Path tmp) throws Exception { + int exit = new CommandLine(new com.ibm.cldk.CodeAnalyzer()).execute( + "-i", "src/test/resources/test-applications/l4-sdg-test", + "-a", "4", "-o", tmp.toString()); + assertEquals(0, exit); + root = JsonParser.parseString(Files.readString(tmp.resolve("analysis.json"))).getAsJsonObject(); + } + + @Test + void maxLevelIsFour() { + assertEquals(4, root.get("max_level").getAsInt()); + } + + @Test + void paramEdgeAritiesMatchAndNothingDangles() { + JsonObject app = root.getAsJsonObject("application"); + assertTrue(app.has("param_in") && app.has("param_out")); + + // Collect every global body-node ordinal actually emitted. + Set ordinals = new HashSet<>(); + collectOrdinals(app.getAsJsonObject("symbol_table"), ordinals); + + for (String key : new String[] {"param_in", "param_out"}) { + for (var e : app.getAsJsonArray(key)) { + JsonObject o = e.getAsJsonObject(); + assertTrue(ordinals.contains(o.get("src").getAsString()), key + " src dangles: " + o); + assertTrue(ordinals.contains(o.get("dst").getAsString()), key + " dst dangles: " + o); + } + } + + // Chain.a calls b(1 arg): exactly one actual_in:0 → b@formal_in:0 edge exists. + boolean found = false; + for (var e : app.getAsJsonArray("param_in")) { + String dst = e.getAsJsonObject().get("dst").getAsString(); + if (dst.endsWith("/Chain/b(int)@formal_in:0")) { + found = true; + } + } + assertTrue(found, "a→b param_in edge present"); + } + + @Test + void semanticDdgAddsToNotReplacesSsa() { + JsonObject heap = callable(root, "Heap", "roundTrip(int)"); + JsonArray ddg = heap.getAsJsonArray("ddg"); + assertNotNull(ddg); + boolean ssa = false; + boolean pts = false; + for (var e : ddg) { + String prov = e.getAsJsonObject().getAsJsonArray("prov").toString(); + ssa |= prov.contains("ssa"); + pts |= prov.contains("points-to"); + } + assertTrue(ssa, "L3 ssa edges survive"); + // points-to presence requires a successful WALA build of the fixture; this environment has + // no gradle on PATH, so the CLI's `auto` build fails and the run degrades (Ruling R4). Guard + // rather than assert, so the gate still runs for real wherever a build is available. + Assumptions.assumeTrue(pts, "points-to edges require a working WALA build; none produced here"); + } + + @Test + void monotonicOverL3(@TempDir Path tmp) throws Exception { + int exit = new CommandLine(new com.ibm.cldk.CodeAnalyzer()).execute( + "-i", "src/test/resources/test-applications/l4-sdg-test", + "-a", "3", "-o", tmp.toString()); + assertEquals(0, exit); + JsonObject l3 = JsonParser.parseString(Files.readString(tmp.resolve("analysis.json"))).getAsJsonObject(); + JsonObject l3Chain = callable(l3, "Chain", "a(int)"); + JsonObject l4Chain = callable(root, "Chain", "a(int)"); + for (var e : l3Chain.getAsJsonArray("cfg")) { + assertTrue(l4Chain.getAsJsonArray("cfg").contains(e), "L3 cfg ⊆ L4 cfg"); + } + for (var e : l3Chain.getAsJsonArray("ddg")) { + assertTrue(l4Chain.getAsJsonArray("ddg").contains(e), "L3 ddg ⊆ L4 ddg"); + } + for (String key : l3Chain.getAsJsonObject("body").keySet()) { + assertTrue(l4Chain.getAsJsonObject("body").has(key), "L3 body nodes survive: " + key); + } + assertFalse(l3Chain.getAsJsonObject("body").keySet().stream().anyMatch(k -> k.contains("formal")), + "no L4 vertices leak into -a 3"); + } + + // ----- helpers ------------------------------------------------------------------------------ + + /** Walks {@code symbol_table → types → callables}, matching a type whose {@code id} ends with + * {@code "/" + typeSuffix}, then returns the callable keyed by {@code sigKey} on that type. */ + private static JsonObject callable(JsonObject root, String typeSuffix, String sigKey) { + JsonObject symbolTable = root.getAsJsonObject("application").getAsJsonObject("symbol_table"); + for (Map.Entry fileEntry : symbolTable.entrySet()) { + JsonObject type = findType(fileEntry.getValue().getAsJsonObject().getAsJsonObject("types"), typeSuffix); + if (type != null) { + return type.getAsJsonObject("callables").getAsJsonObject(sigKey); + } + } + throw new AssertionError("no type found with suffix /" + typeSuffix); + } + + /** Depth-first search of a {@code types} map (and its nested {@code types}) for an id match. */ + private static JsonObject findType(JsonObject types, String typeSuffix) { + if (types == null) { + return null; + } + for (Map.Entry typeEntry : types.entrySet()) { + JsonObject type = typeEntry.getValue().getAsJsonObject(); + if (type.get("id").getAsString().endsWith("/" + typeSuffix)) { + return type; + } + JsonObject nested = findType(type.getAsJsonObject("types"), typeSuffix); + if (nested != null) { + return nested; + } + } + return null; + } + + /** Every callable's global-ordinal body-node ids, per the rule {@code local.startsWith("@") ? + * id + local : id + "@" + local} (matches {@code SdgVertices.global} / {@code V2GraphProjector}). */ + private static void collectOrdinals(JsonObject symbolTable, Set ordinals) { + for (Map.Entry fileEntry : symbolTable.entrySet()) { + collectOrdinalsFromTypes(fileEntry.getValue().getAsJsonObject().getAsJsonObject("types"), ordinals); + } + } + + private static void collectOrdinalsFromTypes(JsonObject types, Set ordinals) { + if (types == null) { + return; + } + for (Map.Entry typeEntry : types.entrySet()) { + JsonObject type = typeEntry.getValue().getAsJsonObject(); + collectOrdinalsFromTypes(type.getAsJsonObject("types"), ordinals); + JsonObject callables = type.getAsJsonObject("callables"); + if (callables == null) { + continue; + } + for (Map.Entry callableEntry : callables.entrySet()) { + JsonObject callable = callableEntry.getValue().getAsJsonObject(); + String id = callable.get("id").getAsString(); + JsonObject body = callable.getAsJsonObject("body"); + if (body == null) { + continue; + } + for (String local : body.keySet()) { + ordinals.add(local.startsWith("@") ? id + local : id + "@" + local); + } + } + } + } +} From 407aabfa5a20389474e0058e2b56ca74c4df8848 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 13:45:22 -0700 Subject: [PATCH 10/18] feat(l4): iterative Tarjan SCC condensation over the call graph --- .../cldk/syntactic_analysis/dataflow/Scc.java | 139 ++++++++++++++++++ .../syntactic_analysis/dataflow/SccTest.java | 46 ++++++ 2 files changed, 185 insertions(+) create mode 100644 src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/Scc.java create mode 100644 src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SccTest.java diff --git a/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/Scc.java b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/Scc.java new file mode 100644 index 00000000..d23e0a56 --- /dev/null +++ b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/Scc.java @@ -0,0 +1,139 @@ +package com.ibm.cldk.syntactic_analysis.dataflow; + +import com.ibm.cldk.schema.JCallEdge; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * Tarjan SCC condensation of the call graph: the bottom-up (callees-first) processing order the L4 + * summary pass (Task 9) needs, since a function's summary can only be computed once its callees' + * are known, and mutual recursion must be solved together as one group. Tarjan is iterative — an + * explicit-stack DFS, not the textbook recursive one — because a real call graph is deep enough to + * overflow the JVM stack. Node visitation and component membership are both sorted so the schedule + * is independent of the caller's iteration order over {@code nodes}. + */ +public final class Scc { + + private Scc() {} + + /** Components in reverse-topological (bottom-up, callees-first) order; ids sorted within each. */ + public static List> condense(Collection nodes, List edges) { + Map> adjacency = buildAdjacency(nodes, edges); + + Map index = new HashMap<>(); + Map lowlink = new HashMap<>(); + Set onStack = new HashSet<>(); + Deque stack = new ArrayDeque<>(); + List> components = new ArrayList<>(); + int[] counter = {0}; + + for (String root : adjacency.keySet()) { + if (!index.containsKey(root)) { + strongConnect(root, adjacency, index, lowlink, onStack, stack, components, counter); + } + } + return components; + } + + /** {@code nodes} sorted, each mapped to its edges into other {@code nodes} (external targets dropped). */ + private static Map> buildAdjacency(Collection nodes, List edges) { + // TreeSet per node: dedups (src, dst) pairs and sorts successors in one step, so a cycle + // reached via more than one edge can't leak the caller's edge-list order into which member + // Tarjan happens to root the component on. + Map> bySource = new TreeMap<>(); + for (String n : nodes) { + bySource.put(n, new TreeSet<>()); + } + for (JCallEdge e : edges) { + TreeSet successors = bySource.get(e.getSrc()); + if (successors != null && bySource.containsKey(e.getDst())) { + successors.add(e.getDst()); + } + } + Map> adjacency = new TreeMap<>(); + for (Map.Entry> entry : bySource.entrySet()) { + adjacency.put(entry.getKey(), new ArrayList<>(entry.getValue())); + } + return adjacency; + } + + /** + * One frame of the explicit-stack DFS: a node plus how far its successor list has been + * consumed, standing in for the recursive call's local variables and program counter. + */ + private static final class Frame { + final String node; + int nextSuccessor; + + Frame(String node) { + this.node = node; + } + } + + private static void strongConnect( + String root, + Map> adjacency, + Map index, + Map lowlink, + Set onStack, + Deque stack, + List> components, + int[] counter) { + Deque work = new ArrayDeque<>(); + work.push(new Frame(root)); + index.put(root, counter[0]); + lowlink.put(root, counter[0]); + counter[0]++; + stack.push(root); + onStack.add(root); + + while (!work.isEmpty()) { + Frame frame = work.peek(); + List successors = adjacency.get(frame.node); + if (frame.nextSuccessor < successors.size()) { + String succ = successors.get(frame.nextSuccessor++); + if (!index.containsKey(succ)) { + index.put(succ, counter[0]); + lowlink.put(succ, counter[0]); + counter[0]++; + stack.push(succ); + onStack.add(succ); + work.push(new Frame(succ)); + } else if (onStack.contains(succ)) { + lowlink.put(frame.node, Math.min(lowlink.get(frame.node), index.get(succ))); + } + continue; + } + + // All of frame.node's successors are explored; if it's a component root, pop the + // component off the Tarjan stack, then fold its lowlink into its caller's (the next + // frame down) exactly as the recursive version does on return. + work.pop(); + if (lowlink.get(frame.node).equals(index.get(frame.node))) { + List component = new ArrayList<>(); + String member; + do { + member = stack.pop(); + onStack.remove(member); + component.add(member); + } while (!member.equals(frame.node)); + Collections.sort(component); + components.add(component); + } + if (!work.isEmpty()) { + Frame caller = work.peek(); + lowlink.put(caller.node, Math.min(lowlink.get(caller.node), lowlink.get(frame.node))); + } + } + } +} diff --git a/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SccTest.java b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SccTest.java new file mode 100644 index 00000000..dcfdd09a --- /dev/null +++ b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SccTest.java @@ -0,0 +1,46 @@ +package com.ibm.cldk.syntactic_analysis.dataflow; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.ibm.cldk.schema.JCallEdge; +import java.util.List; +import org.junit.jupiter.api.Test; + +class SccTest { + + private static JCallEdge e(String s, String d) { + JCallEdge x = new JCallEdge(); + x.setSrc(s); + x.setDst(d); + return x; + } + + @Test + void chainCondensesToSingletonsCalleesFirst() { + List> order = Scc.condense(List.of("a", "b", "c"), List.of(e("a", "b"), e("b", "c"))); + assertEquals(List.of(List.of("c"), List.of("b"), List.of("a")), order); + } + + @Test + void mutualRecursionFormsOneComponent() { + List> order = Scc.condense( + List.of("even", "odd", "main"), + List.of(e("even", "odd"), e("odd", "even"), e("main", "even"))); + assertEquals(2, order.size()); + assertEquals(List.of("even", "odd"), order.get(0), "SCC first (bottom-up), sorted within"); + assertEquals(List.of("main"), order.get(1)); + } + + @Test + void deterministicRegardlessOfInputOrder() { + List> a = Scc.condense(List.of("x", "y"), List.of(e("x", "y"))); + List> b = Scc.condense(List.of("y", "x"), List.of(e("x", "y"))); + assertEquals(a, b); + } + + @Test + void edgesToUnknownNodesAreIgnored() { + List> order = Scc.condense(List.of("a"), List.of(e("a", "can://…/@external/x"))); + assertEquals(List.of(List.of("a")), order); + } +} From a6a4efb5e12f6a6bc542290b0b295d0cd975ae0c Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 14:10:05 -0700 Subject: [PATCH 11/18] =?UTF-8?q?feat(l4):=20summary=20pass=20=E2=80=94=20?= =?UTF-8?q?SCC=20bottom-up=20k-limited=20fixpoint,=20summary=20edges=20on?= =?UTF-8?q?=20callers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/ibm/cldk/CodeAnalyzer.java | 8 +- .../dataflow/SummaryPass.java | 360 ++++++++++++++++++ .../java/com/ibm/cldk/schema/L4GateTest.java | 9 +- .../dataflow/SummaryPassTest.java | 102 +++++ 4 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java create mode 100644 src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java diff --git a/src/main/java/com/ibm/cldk/CodeAnalyzer.java b/src/main/java/com/ibm/cldk/CodeAnalyzer.java index 0471b554..a166f9f7 100644 --- a/src/main/java/com/ibm/cldk/CodeAnalyzer.java +++ b/src/main/java/com/ibm/cldk/CodeAnalyzer.java @@ -32,6 +32,7 @@ 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; @@ -508,7 +509,12 @@ private void analyzeV2() throws Exception { L2CallGraph.Result l2 = L2CallGraph.build(application, modules, rtaEndpoints, externalCalls); // 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 = analysisLevel >= 4 ? SdgVertices.apply(modules) : null; + 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); diff --git a/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java new file mode 100644 index 00000000..8de584b6 --- /dev/null +++ b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java @@ -0,0 +1,360 @@ +package com.ibm.cldk.syntactic_analysis.dataflow; + +import com.ibm.cldk.schema.JBodyNode; +import com.ibm.cldk.schema.JCallEdge; +import com.ibm.cldk.schema.JCallable; +import com.ibm.cldk.schema.JDdgEdge; +import com.ibm.cldk.schema.JIdEdge; +import com.ibm.cldk.schema.JModule; +import com.ibm.cldk.schema.JParameter; +import com.ibm.cldk.schema.JType; +import com.ibm.cldk.schema.Span; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; + +/** + * L4 stage 8: the interprocedural {@code summary} edges. A summary edge is a shortcut inside a + * caller — {@code /actual_in:j → /actual_out} at one call site — recording that data + * entering the callee through argument {@code j} may come back out through its return value. It + * exists so a consumer (slicing, taint) can cross a call in one hop instead of descending into the + * callee. + * + *

Emitting it needs the callee's transfer relation {@code flows(callee) ⊆ params × {$ret}}, which + * needs its callees' first — hence the bottom-up walk over {@link Scc#condense} (callees + * first), with mutual recursion resolved by iterating one component to a fixpoint. + * + *

The relation is derived syntactically, in the weak-update (may-flow, never-drop) + * posture the L4 design takes: a parameter reaches the return if the callable's {@code ddg} carries + * it there, if a call site passes it on and that callee's own summary returns it, or if the + * parameter is simply named in a {@code return} expression. That last rule is what makes the pass + * useful at all on ordinary code — {@code int c(int z) { return z - 3; }} has no local variable, so + * no def-use, so no {@code ddg} edge to follow — at the cost of counting a parameter merely + * mentioned in a return as flowing. Over-approximating there is the accepted trade. + */ +public final class SummaryPass { + + private SummaryPass() {} + + /** + * Sets {@code summary} on every callable holding at least one pass-through call site; leaves it + * null (absent — "no fact") elsewhere. Runs after {@link SdgVertices#apply}, whose synthetic + * vertices are both this pass's seeds and its edge endpoints. + * + * @param fieldDepth the k of the access-path k-limit the {@code ddg} was built with. Flow labels + * here are parameter indices, and {@link AccessPath#of} truncates only the tail of a + * path, never its base segment — so base matching already agrees with any k, and the label + * set is bounded by arity rather than by k. Carried because it is where the bound enters once + * the relation grows past {@code param → $ret} to field-sensitive labels. + */ + public static void apply(Map modules, List callGraph, int fieldDepth) { + // ponytail: single-threaded. Wavefront parallelism over the SCC DAG (a component may run as + // soon as its successors have) drops straight in if this ever dominates a real run — the + // condensation already supplies the dependency order. + // ponytail: recomputed every run. Caching summaries in `cache_dir` waits on a cache that + // survives analysisLevel >= 3, which CodeAnalyzer currently bypasses outright. + Map fns = index(modules); + Map> flows = solve(fns, callGraph); + emit(fns, flows); + } + + // ----- stage 1: the per-callable transfer relation ------------------------------------------- + + /** One resolved, in-project call site — where bridge edges and summary edges both come from. */ + private static final class Site { + private final String local; + private final String callee; + private final List args; + private final boolean hasActualOut; + + Site(String local, String callee, List args, boolean hasActualOut) { + this.local = local; + this.callee = callee; + this.args = args; + this.hasActualOut = hasActualOut; + } + + String actualIn(int j) { + return local + "/actual_in:" + j; + } + + String actualOut() { + return local + "/actual_out"; + } + } + + /** + * The round-invariant facts one callable contributes. Everything here is pure syntax, so it is + * computed once; only the callee bridges change between fixpoint rounds. + */ + private static final class Fn { + private final JCallable callable; + /** {@code ddg} as adjacency (src → dsts) — the fixed half of the reachability graph. */ + private final Map> ddg = new LinkedHashMap<>(); + /** Parameter index → the body nodes at which its value is first visible. */ + private final Map> seeds = new LinkedHashMap<>(); + /** Reaching one of these means "the value left through the return". */ + private final Set sinks = new LinkedHashSet<>(); + + private final List sites = new ArrayList<>(); + + Fn(JCallable callable) { + this.callable = callable; + } + } + + /** Indexes every callable's static facts, keyed by callable id. */ + private static Map index(Map modules) { + Map fns = new LinkedHashMap<>(); + for (JModule module : modules.values()) { + // Decoded once per module, not per node: `span.bytes` are offsets into this array. + byte[] source = + module.getSource() == null ? null : module.getSource().getBytes(StandardCharsets.UTF_8); + walkTypes(module.getTypes(), source, fns); + } + return fns; + } + + /** Mirrors {@link SdgVertices}'s traversal (nested types, then each callable's local types). */ + private static void walkTypes(Map types, byte[] source, Map fns) { + for (JType t : types.values()) { + walkTypes(t.getTypes(), source, fns); + for (JCallable c : t.getCallables().values()) { + if (c.getId() != null) { + fns.put(c.getId(), facts(c, source)); + } + walkTypes(c.getTypes(), source, fns); + } + } + } + + /** Stage 1's syntactic half: the reachability graph, the sinks, the call sites and the seeds. */ + private static Fn facts(JCallable c, byte[] source) { + Fn fn = new Fn(c); + if (c.getDdg() != null) { + for (JDdgEdge e : c.getDdg()) { + fn.ddg.computeIfAbsent(e.getSrc(), k -> new ArrayList<>()).add(e.getDst()); + } + } + + Map body = c.getBody(); + Map returnText = new LinkedHashMap<>(); + for (Map.Entry entry : body.entrySet()) { + JBodyNode node = entry.getValue(); + if ("return".equals(node.getKind())) { + fn.sinks.add(entry.getKey()); + String text = slice(source, node.getSpan()); + if (text != null) { + returnText.put(entry.getKey(), text); + } + } else if ("call".equals(node.getKind()) + && node.getCallee() != null + && !node.getCallee().contains("/@external/")) { + fn.sites.add(new Site( + entry.getKey(), + node.getCallee(), + node.getArgumentExpr(), + body.containsKey(entry.getKey() + "/actual_out"))); + } + } + if (body.containsKey("@formal_out")) { + fn.sinks.add("@formal_out"); + } + + List params = c.getParameters(); + for (int i = 0; i < params.size(); i++) { + Set seeds = seedsFor(fn, params.get(i).getName(), returnText); + if (!seeds.isEmpty()) { + fn.seeds.put(i, seeds); + } + } + return fn; + } + + /** + * Where a parameter's value is visible, syntactically: the def end of any {@code ddg} edge whose + * access path is rooted at it, any call-site {@code actual_in} vertex whose argument text names + * it, and any {@code return} statement whose text names it (see the class javadoc — a parameter + * flowing straight to the return is the commonest summary shape and leaves no def-use trail). + */ + private static Set seedsFor(Fn fn, String name, Map returnText) { + Set seeds = new LinkedHashSet<>(); + if (name == null || name.isEmpty()) { + return seeds; + } + Pattern word = Pattern.compile("\\b" + Pattern.quote(name) + "\\b"); + + List ddg = fn.callable.getDdg(); + if (ddg != null) { + for (JDdgEdge e : ddg) { + if (name.equals(base(e.getVar()))) { + seeds.add(e.getSrc()); + } + } + } + for (Site site : fn.sites) { + for (int j = 0; j < site.args.size(); j++) { + String vertex = site.actualIn(j); + if (site.args.get(j) != null + && word.matcher(site.args.get(j)).find() + && fn.callable.getBody().containsKey(vertex)) { + seeds.add(vertex); + } + } + } + for (Map.Entry ret : returnText.entrySet()) { + if (word.matcher(ret.getValue()).find()) { + seeds.add(ret.getKey()); + } + } + return seeds; + } + + /** An access path's base segment — the text before the first {@code .} or {@code [}. */ + private static String base(String var) { + if (var == null) { + return null; + } + int dot = var.indexOf('.'); + int bracket = var.indexOf('['); + int cut = dot < 0 ? bracket : (bracket < 0 ? dot : Math.min(dot, bracket)); + return cut < 0 ? var : var.substring(0, cut); + } + + /** The UTF-8 byte slice a node's {@code span} names, or null when the span is unusable. */ + private static String slice(byte[] source, Span span) { + if (source == null || span == null || span.getBytes() == null || span.getBytes().length < 2) { + return null; + } + int from = span.getBytes()[0]; + int to = span.getBytes()[1]; + if (from < 0 || to > source.length || from >= to) { + return null; + } + return new String(source, from, to - from, StandardCharsets.UTF_8); + } + + // ----- stage 2: the bottom-up SCC fixpoint --------------------------------------------------- + + /** + * Solves every callable's {@code flows} relation, callees first. Within one component each member + * is recomputed until none grows. Termination is structural: a member's seeds, sinks and + * {@code ddg} are fixed, the only other edges — the callee bridges — appear as callee relations + * grow, and the result is unioned in rather than replaced, so every relation grows monotonically; + * {@code flows(c) ⊆ {0 … arity(c)-1}} bounds it, so a component settles in at most (its members' + * total arity + 1) rounds. + */ + private static Map> solve(Map fns, List callGraph) { + Map> flows = new LinkedHashMap<>(); + for (List component : Scc.condense(fns.keySet(), callGraph)) { + boolean changed = true; + while (changed) { + changed = false; + for (String id : component) { + Fn fn = fns.get(id); + if (fn == null || fn.seeds.isEmpty()) { + continue; // no seed, no flow — and seeds never appear later + } + Set reaching = computeFlows(fn, fns, flows); + changed |= flows.computeIfAbsent(id, k -> new TreeSet<>()).addAll(reaching); + } + } + } + return flows; + } + + /** One iteration of a callable's transfer relation: which parameters reach a return sink. */ + private static Set computeFlows(Fn fn, Map fns, Map> flows) { + // Built once per callable per round, then walked once per parameter. + Map> graph = new LinkedHashMap<>(fn.ddg); + for (Site site : fn.sites) { + for (int j : passThrough(site, fns, flows)) { + graph.computeIfAbsent(site.actualIn(j), k -> new ArrayList<>()).add(site.actualOut()); + } + } + + Set reaching = new TreeSet<>(); + for (Map.Entry> seed : fn.seeds.entrySet()) { + if (reaches(graph, seed.getValue(), fn.sinks)) { + reaching.add(seed.getKey()); + } + } + return reaching; + } + + /** BFS from {@code seeds}; true as soon as a sink is touched. */ + private static boolean reaches(Map> graph, Set seeds, Set sinks) { + Set seen = new LinkedHashSet<>(seeds); + Deque queue = new ArrayDeque<>(seeds); + while (!queue.isEmpty()) { + String node = queue.poll(); + if (sinks.contains(node)) { + return true; + } + for (String next : graph.getOrDefault(node, List.of())) { + if (seen.add(next)) { + queue.add(next); + } + } + } + return false; + } + + /** + * The argument positions of {@code site} the callee hands back through its return, per its + * relation as currently known — empty for a callee still being solved in this same SCC, + * which the next round then picks up. An argument past the callee's last formal collapses onto + * it, matching how {@link SdgVertices} wires varargs {@code param_in} edges. + */ + private static List passThrough(Site site, Map fns, Map> flows) { + Set calleeFlows = flows.get(site.callee); + Fn callee = fns.get(site.callee); + if (!site.hasActualOut || calleeFlows == null || callee == null) { + return List.of(); + } + int arity = callee.callable.getParameters().size(); + List out = new ArrayList<>(); + for (int j = 0; arity > 0 && j < site.args.size(); j++) { + if (calleeFlows.contains(Math.min(j, arity - 1))) { + out.add(j); + } + } + return out; + } + + // ----- stage 3: emission --------------------------------------------------------------------- + + /** Writes each caller's shortcut edges; a caller with none keeps {@code summary} absent. */ + private static void emit(Map fns, Map> flows) { + for (Fn fn : fns.values()) { + List summary = new ArrayList<>(); + for (Site site : fn.sites) { + for (int j : passThrough(site, fns, flows)) { + if (fn.callable.getBody().containsKey(site.actualIn(j))) { + summary.add(edge(site.actualIn(j), site.actualOut())); + } + } + } + if (!summary.isEmpty()) { + summary.sort(Comparator.comparing(JIdEdge::getSrc).thenComparing(JIdEdge::getDst)); + fn.callable.setSummary(summary); + } + } + } + + private static JIdEdge edge(String src, String dst) { + JIdEdge e = new JIdEdge(); + e.setSrc(src); + e.setDst(dst); + return e; + } +} diff --git a/src/test/java/com/ibm/cldk/schema/L4GateTest.java b/src/test/java/com/ibm/cldk/schema/L4GateTest.java index 5b84c422..32a32b9a 100644 --- a/src/test/java/com/ibm/cldk/schema/L4GateTest.java +++ b/src/test/java/com/ibm/cldk/schema/L4GateTest.java @@ -20,7 +20,7 @@ import org.junit.jupiter.api.io.TempDir; import picocli.CommandLine; -/** Spec §14 L4 gate over the l4-sdg-test fixture (minus summary edges — PR 2). */ +/** Spec §14 L4 gate over the l4-sdg-test fixture. */ class L4GateTest { private static JsonObject root; @@ -86,6 +86,13 @@ void semanticDdgAddsToNotReplacesSsa() { Assumptions.assumeTrue(pts, "points-to edges require a working WALA build; none produced here"); } + @Test + void summaryEdgeExistsForTheKnownTransitiveFlow() { + JsonObject a = callable(root, "Chain", "a(int)"); + assertTrue(a.has("summary"), "caller carries summary edges"); + assertEquals(1, a.getAsJsonArray("summary").size()); + } + @Test void monotonicOverL3(@TempDir Path tmp) throws Exception { int exit = new CommandLine(new com.ibm.cldk.CodeAnalyzer()).execute( diff --git a/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java new file mode 100644 index 00000000..176d3b94 --- /dev/null +++ b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java @@ -0,0 +1,102 @@ +package com.ibm.cldk.syntactic_analysis.dataflow; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.ibm.cldk.schema.JCallable; +import com.ibm.cldk.schema.JModule; +import com.ibm.cldk.schema.V2Json; +import com.ibm.cldk.syntactic_analysis.L1Extractor; +import com.ibm.cldk.syntactic_analysis.L2CallGraph; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SummaryPassTest { + + private static final String FIXTURE = "src/test/resources/test-applications/l4-sdg-test"; + + private static Map analyzed() throws Exception { + Map modules = L1Extractor.extractAll( + Paths.get(FIXTURE), "l4-sdg-test", null, new LinkedHashMap<>(), 3, 3, "ast"); + L2CallGraph.Result l2 = L2CallGraph.build("l4-sdg-test", modules, null, true); + SdgVertices.apply(modules); + SummaryPass.apply(modules, l2.callGraph(), 3); + return modules; + } + + private static JCallable callable(Map modules, String idSuffix) { + return modules.values().stream() + .flatMap(m -> m.getTypes().values().stream()) + .flatMap(t -> t.getCallables().values().stream()) + .filter(c -> c.getId().endsWith(idSuffix)) + .findFirst() + .orElseThrow(); + } + + @Test + void transitiveFlowYieldsSummaryEdgeInTheCaller() throws Exception { + Map modules = analyzed(); + // Chain.a calls b; b's param flows to its return via c — so a's call site gets a summary edge. + JCallable a = callable(modules, "/Chain/a(int)"); + assertNotNull(a.getSummary(), "a carries a summary edge for the a→b→c flow"); + assertEquals(1, a.getSummary().size()); + String src = a.getSummary().get(0).getSrc(); + String dst = a.getSummary().get(0).getDst(); + assertTrue(src.endsWith("/actual_in:0") && dst.endsWith("/actual_out"), src + " → " + dst); + assertEquals( + src.substring(0, src.indexOf("/actual_in")), + dst.substring(0, dst.indexOf("/actual_out")), + "same call site"); + } + + @Test + void mutualRecursionReachesFixpointAndIsDeterministic() throws Exception { + Map m1 = analyzed(); // terminating at all is half the gate + Map m2 = analyzed(); + assertEquals( + V2Json.compact().toJson(m1), + V2Json.compact().toJson(m2), + "two runs byte-identical (fixpoint order must not leak)"); + } + + @Test + void mutualRecursionSummarisesBothDirections() throws Exception { + Map modules = analyzed(); + // even → odd → even is one SCC, so both are solved by the same fixpoint loop: reaching this + // assertion at all means it terminated, and each member ends up shortcutting its own site. + for (String id : new String[] {"/Mutual/even(int)", "/Mutual/odd(int)"}) { + JCallable c = callable(modules, id); + assertNotNull(c.getSummary(), id + " carries its call site's summary edge"); + assertEquals(1, c.getSummary().size(), id); + assertTrue(c.getSummary().get(0).getSrc().endsWith("/actual_in:0"), id); + assertTrue(c.getSummary().get(0).getDst().endsWith("/actual_out"), id); + } + } + + @Test + void aCallableWithNoPassThroughCallSiteKeepsSummaryAbsent() throws Exception { + Map modules = analyzed(); + // Chain.c calls nothing, so it has no call site to shortcut... + assertNull(callable(modules, "/Chain/c(int)").getSummary(), "no call sites, no summary"); + // ...and Heap.roundTrip's two sites are a void call (no actual_out) and a no-arg call (no + // actual_in), so neither can carry a shortcut. Absent, not an empty list. + assertNull(callable(modules, "/Heap/roundTrip(int)").getSummary(), "no viable site, no summary"); + } + + @Test + void summaryEndpointsAreExistingLocalBodyNodes() throws Exception { + Map modules = analyzed(); + modules.values().stream() + .flatMap(m -> m.getTypes().values().stream()) + .flatMap(t -> t.getCallables().values().stream()) + .filter(c -> c.getSummary() != null) + .forEach(c -> c.getSummary().forEach(e -> { + assertTrue(c.getBody().containsKey(e.getSrc()), c.getId() + " src " + e.getSrc()); + assertTrue(c.getBody().containsKey(e.getDst()), c.getId() + " dst " + e.getDst()); + })); + } +} From 8da8f706805c44a5827ed47d09d8d56b85e1c911 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 14:20:06 -0700 Subject: [PATCH 12/18] =?UTF-8?q?fix(l4):=20close=20the=20summary=20compos?= =?UTF-8?q?ition=20path=20=E2=80=94=20actual=5Fout=20reaches=20the=20enclo?= =?UTF-8?q?sing=20CFG=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A callee bridge (actual_in:j -> actual_out) was a dead end: nothing led out of actual_out, so a value passing through a local before being returned (int t = f(x); return t;) never reached a sink and flows() came back empty for the caller. The fixture masked this because its calls sit inside the return expression, where the syntactic return rule seeds the sink directly. The ddg does not hang off a nested call node -- DdgBuilder analyses CFG nodes, and a call inside a larger expression is not one, so the def-use edge leaves the enclosing statement instead. Route the value over both hops, actual_out -> call node -> smallest span-enclosing node, entirely inside the pass's own reachability graph: no new vertex, no schema change, no emission change. Also copy successor lists when seeding a round's graph, so bridges can no longer accumulate into the round-invariant one. --- .../dataflow/SummaryPass.java | 76 ++++++++-- .../dataflow/SummaryPassTest.java | 139 ++++++++++++++++++ 2 files changed, 206 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java index 8de584b6..9198ab5d 100644 --- a/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java +++ b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java @@ -75,12 +75,15 @@ private static final class Site { private final String callee; private final List args; private final boolean hasActualOut; + /** The body node whose span encloses this call, or null when the call is itself one. */ + private final String enclosing; - Site(String local, String callee, List args, boolean hasActualOut) { + Site(String local, String callee, List args, boolean hasActualOut, String enclosing) { this.local = local; this.callee = callee; this.args = args; this.hasActualOut = hasActualOut; + this.enclosing = enclosing; } String actualIn(int j) { @@ -98,8 +101,8 @@ String actualOut() { */ private static final class Fn { private final JCallable callable; - /** {@code ddg} as adjacency (src → dsts) — the fixed half of the reachability graph. */ - private final Map> ddg = new LinkedHashMap<>(); + /** The intraprocedural reachability graph (src → dsts) — the fixed half; bridges are added per round. */ + private final Map> graph = new LinkedHashMap<>(); /** Parameter index → the body nodes at which its value is first visible. */ private final Map> seeds = new LinkedHashMap<>(); /** Reaching one of these means "the value left through the return". */ @@ -142,7 +145,7 @@ private static Fn facts(JCallable c, byte[] source) { Fn fn = new Fn(c); if (c.getDdg() != null) { for (JDdgEdge e : c.getDdg()) { - fn.ddg.computeIfAbsent(e.getSrc(), k -> new ArrayList<>()).add(e.getDst()); + fn.graph.computeIfAbsent(e.getSrc(), k -> new ArrayList<>()).add(e.getDst()); } } @@ -163,13 +166,27 @@ private static Fn facts(JCallable c, byte[] source) { entry.getKey(), node.getCallee(), node.getArgumentExpr(), - body.containsKey(entry.getKey() + "/actual_out"))); + body.containsKey(entry.getKey() + "/actual_out"), + enclosing(entry.getKey(), body))); } } if (body.containsKey("@formal_out")) { fn.sinks.add("@formal_out"); } + // What a call's returned value does next, without which the callee bridges below would be + // dead ends and summaries could never compose. Two hops, because the `ddg` does not hang off + // a call node that sits inside a larger expression: `DdgBuilder` analyses CFG nodes, and a + // nested call is not one (`int t = f(x);` puts the def-use edge on the *statement*, and the + // call node is an orphan body entry). So the value flows actual_out → the call node → the + // node enclosing it, which is the CFG node the ddg edges actually leave from. + for (Site site : fn.sites) { + fn.graph.computeIfAbsent(site.actualOut(), k -> new ArrayList<>()).add(site.local); + if (site.enclosing != null) { + fn.graph.computeIfAbsent(site.local, k -> new ArrayList<>()).add(site.enclosing); + } + } + List params = c.getParameters(); for (int i = 0; i < params.size(); i++) { Set seeds = seedsFor(fn, params.get(i).getName(), returnText); @@ -219,6 +236,45 @@ private static Set seedsFor(Fn fn, String name, Map retu return seeds; } + /** + * The smallest body node whose span strictly encloses {@code local}'s — the statement (or branch, + * or outer call) a nested call sits inside. Null when nothing encloses it, which is the case for + * a call in statement position: there the call node is the CFG node, so no hop is needed. + * Ties break on the node id so the choice cannot depend on map order. + */ + private static String enclosing(String local, Map body) { + int[] inner = bytes(body.get(local)); + if (inner == null) { + return null; + } + String best = null; + long bestWidth = Long.MAX_VALUE; + for (Map.Entry entry : body.entrySet()) { + int[] outer = bytes(entry.getValue()); + if (entry.getKey().equals(local) + || outer == null + || outer[0] > inner[0] + || outer[1] < inner[1] + || (outer[0] == inner[0] && outer[1] == inner[1])) { + continue; + } + long width = (long) outer[1] - outer[0]; + if (best == null || width < bestWidth || (width == bestWidth && entry.getKey().compareTo(best) < 0)) { + best = entry.getKey(); + bestWidth = width; + } + } + return best; + } + + /** A node's {@code span.bytes}, or null when it has none (every synthetic vertex). */ + private static int[] bytes(JBodyNode node) { + if (node == null || node.getSpan() == null || node.getSpan().getBytes() == null) { + return null; + } + return node.getSpan().getBytes().length < 2 ? null : node.getSpan().getBytes(); + } + /** An access path's base segment — the text before the first {@code .} or {@code [}. */ private static String base(String var) { if (var == null) { @@ -248,8 +304,8 @@ private static String slice(byte[] source, Span span) { /** * Solves every callable's {@code flows} relation, callees first. Within one component each member * is recomputed until none grows. Termination is structural: a member's seeds, sinks and - * {@code ddg} are fixed, the only other edges — the callee bridges — appear as callee relations - * grow, and the result is unioned in rather than replaced, so every relation grows monotonically; + * reachability graph are fixed, the only other edges — the callee bridges — appear as callee + * relations grow, and the result is unioned in rather than replaced, so relations only grow; * {@code flows(c) ⊆ {0 … arity(c)-1}} bounds it, so a component settles in at most (its members' * total arity + 1) rounds. */ @@ -274,8 +330,10 @@ private static Map> solve(Map fns, List computeFlows(Fn fn, Map fns, Map> flows) { - // Built once per callable per round, then walked once per parameter. - Map> graph = new LinkedHashMap<>(fn.ddg); + // Built once per callable per round, then walked once per parameter. Successor lists are + // copied, not shared: the bridges below would otherwise accumulate into fn.graph each round. + Map> graph = new LinkedHashMap<>(); + fn.graph.forEach((node, next) -> graph.put(node, new ArrayList<>(next))); for (Site site : fn.sites) { for (int j : passThrough(site, fns, flows)) { graph.computeIfAbsent(site.actualIn(j), k -> new ArrayList<>()).add(site.actualOut()); diff --git a/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java index 176d3b94..98a3cde4 100644 --- a/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java +++ b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java @@ -5,13 +5,21 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.ibm.cldk.schema.JBodyNode; +import com.ibm.cldk.schema.JCallEdge; import com.ibm.cldk.schema.JCallable; +import com.ibm.cldk.schema.JDdgEdge; import com.ibm.cldk.schema.JModule; +import com.ibm.cldk.schema.JParameter; +import com.ibm.cldk.schema.JType; +import com.ibm.cldk.schema.Span; import com.ibm.cldk.schema.V2Json; import com.ibm.cldk.syntactic_analysis.L1Extractor; import com.ibm.cldk.syntactic_analysis.L2CallGraph; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -87,6 +95,137 @@ void aCallableWithNoPassThroughCallSiteKeepsSummaryAbsent() throws Exception { assertNull(callable(modules, "/Heap/roundTrip(int)").getSummary(), "no viable site, no summary"); } + /** + * A call whose value passes through a local before being returned — {@code int t = f(x); return + * t;} — which is where summary composition has to work: nothing in the return statement + * names the parameter, so the syntactic return rule cannot rescue it and the only route is the + * callee's own summary. The tree is built by hand, node for node, from the shape the AST engine + * really emits for this source (verified by running {@code -a 4} over it): the call sits at its + * own body node, the def-use edge hangs off the enclosing statement, and the call node + * itself carries no ddg edge at all. + * + *

The assertion is on {@code top}, not {@code mid}: {@code mid}'s own summary edge follows + * from {@code flows(callee)} alone and holds either way, so only the grandparent's edge actually + * depends on {@code flows(mid)} having composed through the call. + */ + @Test + void aValuePassingThroughALocalStillComposes() { + Map modules = passThroughChain(); + SdgVertices.apply(modules); + SummaryPass.apply(modules, passThroughCallGraph(), 3); + + JCallable top = callable(modules, "/Pass/top(int)"); + assertNotNull(top.getSummary(), "top composes through mid, which composes through callee"); + assertEquals(1, top.getSummary().size()); + assertEquals("topCall/actual_in:0", top.getSummary().get(0).getSrc()); + assertEquals("topCall/actual_out", top.getSummary().get(0).getDst()); + + JCallable mid = callable(modules, "/Pass/mid(int)"); + assertNotNull(mid.getSummary()); + assertEquals(1, mid.getSummary().size()); + } + + // Line 2 holds callee, 3 mid, 4 top; every snippet below is unique, and the text is ASCII so a + // char offset is a byte offset. + private static final String PASS_SOURCE = String.join( + "\n", + "class Pass {", + " int callee(int q) { return q; }", + " int mid(int x) { int t = callee(x); return t; }", + " int top(int w) { int s = mid(w); return s; }", + "}"); + + /** {@code callee → mid → top}, each level passing its argument out through the return. */ + private static Map passThroughChain() { + JType type = new JType(); + type.setId("can://java/pass/Pass.java/Pass"); + + JCallable callee = method("callee(int)", "q"); + callee.getBody().put("@entry", node("entry", null)); + callee.getBody().put("calleeRet", node("return", "return q;")); + callee.setDdg(new ArrayList<>()); + type.getCallables().put("callee(int)", callee); + + JCallable mid = method("mid(int)", "x"); + mid.getBody().put("@entry", node("entry", null)); + mid.getBody().put("midCall", callNode("callee(x)", "callee(int)")); + mid.getBody().put("midDecl", node("statement", "int t = callee(x);")); + mid.getBody().put("midRet", node("return", "return t;")); + // The def-use edge leaves the *statement*, never the nested call node — that asymmetry is + // the whole point of the case. + mid.setDdg(new ArrayList<>(List.of(ddg("midDecl", "midRet", "t")))); + type.getCallables().put("mid(int)", mid); + + JCallable top = method("top(int)", "w"); + top.getBody().put("@entry", node("entry", null)); + top.getBody().put("topCall", callNode("mid(w)", "mid(int)")); + top.getBody().put("topDecl", node("statement", "int s = mid(w);")); + top.getBody().put("topRet", node("return", "return s;")); + top.setDdg(new ArrayList<>(List.of(ddg("topDecl", "topRet", "s")))); + type.getCallables().put("top(int)", top); + + JModule module = new JModule(); + module.setId("can://java/pass/Pass.java"); + module.setSource(PASS_SOURCE); + module.getTypes().put("Pass", type); + return new LinkedHashMap<>(Map.of("Pass.java", module)); + } + + private static JDdgEdge ddg(String src, String dst, String var) { + JDdgEdge e = new JDdgEdge(); + e.setSrc(src); + e.setDst(dst); + e.setVar(var); + e.setProv(List.of("ssa")); + return e; + } + + private static JCallable method(String signature, String param) { + JCallable c = new JCallable(); + c.setId("can://java/pass/Pass.java/Pass/" + signature); + c.setKind("method"); + c.setSignature(signature); + c.setReturnType("int"); + JParameter p = new JParameter(); + p.setName(param); + p.setType("int"); + c.setParameters(new ArrayList<>(List.of(p))); + return c; + } + + private static JBodyNode node(String kind, String snippet) { + JBodyNode n = new JBodyNode(); + n.setKind(kind); + if (snippet != null) { + Span span = new Span(); + int at = PASS_SOURCE.indexOf(snippet); + span.setBytes(new int[] {at, at + snippet.length()}); + n.setSpan(span); + } + return n; + } + + private static JBodyNode callNode(String snippet, String calleeSignature) { + JBodyNode n = node("call", snippet); + n.setCallee("can://java/pass/Pass.java/Pass/" + calleeSignature); + n.setReturnType("int"); + n.setArgumentExpr(new ArrayList<>(List.of(snippet.substring(snippet.indexOf('(') + 1, snippet.length() - 1)))); + return n; + } + + private static List passThroughCallGraph() { + return List.of( + callEdge("top(int)", "mid(int)"), + callEdge("mid(int)", "callee(int)")); + } + + private static JCallEdge callEdge(String from, String to) { + JCallEdge e = new JCallEdge(); + e.setSrc("can://java/pass/Pass.java/Pass/" + from); + e.setDst("can://java/pass/Pass.java/Pass/" + to); + return e; + } + @Test void summaryEndpointsAreExistingLocalBodyNodes() throws Exception { Map modules = analyzed(); From 09b75b24c367b40734eb57362342ccba7f0a284a Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 14:37:18 -0700 Subject: [PATCH 13/18] fix(l4): route the composition hop through unresolved calls too enclosing() can land on an external or unresolved call node -- every call node gets a span covering the whole invocation, so Math.abs(id(a)) is what encloses id(a), being narrower than the statement. That node is an orphan (no ddg edge leaves it) and was excluded from fn.sites, so it got no hop and the chain died on it: flows(caller) came back empty and every caller of caller silently lost its summary edge. Under-approximation, which the L4 weak-update posture does not permit. Build the call -> enclosing hop for every call body node, before the resolved/in-project filter; leave fn.sites filtered as it was, since only a resolved site has an actual_out vertex for the other half of the route. Site.enclosing is redundant now and is dropped. Also correct two comments that said the opposite of what the code does: a bare call inside an if/for/while body does get a hop (to the branch or loop node, whose span covers the whole statement), and the syntactic seeding also over-credits a field access (return a.z;) and a string literal (return "x marks";), not just the cases already listed. --- .../dataflow/SummaryPass.java | 67 ++++++++------ .../dataflow/SummaryPassTest.java | 92 ++++++++++++++++--- 2 files changed, 119 insertions(+), 40 deletions(-) diff --git a/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java index 9198ab5d..4a9b100c 100644 --- a/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java +++ b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java @@ -75,15 +75,12 @@ private static final class Site { private final String callee; private final List args; private final boolean hasActualOut; - /** The body node whose span encloses this call, or null when the call is itself one. */ - private final String enclosing; - Site(String local, String callee, List args, boolean hasActualOut, String enclosing) { + Site(String local, String callee, List args, boolean hasActualOut) { this.local = local; this.callee = callee; this.args = args; this.hasActualOut = hasActualOut; - this.enclosing = enclosing; } String actualIn(int j) { @@ -159,32 +156,38 @@ private static Fn facts(JCallable c, byte[] source) { if (text != null) { returnText.put(entry.getKey(), text); } - } else if ("call".equals(node.getKind()) - && node.getCallee() != null - && !node.getCallee().contains("/@external/")) { - fn.sites.add(new Site( - entry.getKey(), - node.getCallee(), - node.getArgumentExpr(), - body.containsKey(entry.getKey() + "/actual_out"), - enclosing(entry.getKey(), body))); + } else if ("call".equals(node.getKind())) { + // Where a call's value goes next. Deliberately outside the resolved/in-project filter + // below: an unresolved or external call is still a node the chain has to pass + // *through*. `int t = Math.abs(id(a));` encloses the `id(a)` site in the `Math.abs` + // node, which is an orphan (not a CFG node, so no ddg edge leaves it) and has no + // summary of its own — filtering it out here would strand the hop and silently drop + // the caller's summary edge, an under-approximation the L4 posture does not allow. + String outer = enclosing(entry.getKey(), body); + if (outer != null) { + fn.graph.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).add(outer); + } + if (node.getCallee() != null && !node.getCallee().contains("/@external/")) { + fn.sites.add(new Site( + entry.getKey(), + node.getCallee(), + node.getArgumentExpr(), + body.containsKey(entry.getKey() + "/actual_out"))); + } } } if (body.containsKey("@formal_out")) { fn.sinks.add("@formal_out"); } - // What a call's returned value does next, without which the callee bridges below would be - // dead ends and summaries could never compose. Two hops, because the `ddg` does not hang off - // a call node that sits inside a larger expression: `DdgBuilder` analyses CFG nodes, and a - // nested call is not one (`int t = f(x);` puts the def-use edge on the *statement*, and the - // call node is an orphan body entry). So the value flows actual_out → the call node → the - // node enclosing it, which is the CFG node the ddg edges actually leave from. + // The other half of that route: a resolved call's returned value reaches the call node. Only + // resolved sites have an actual_out vertex to leave from, so unlike the hop above this one + // does belong inside the filter. Together they give actual_out → call node → enclosing node, + // which is the CFG node the ddg edges actually leave from — the `ddg` does not hang off a + // call that sits inside a larger expression, since `DdgBuilder` analyses CFG nodes and such a + // call is not one (`int t = f(x);` puts the def-use edge on the *statement*). for (Site site : fn.sites) { fn.graph.computeIfAbsent(site.actualOut(), k -> new ArrayList<>()).add(site.local); - if (site.enclosing != null) { - fn.graph.computeIfAbsent(site.local, k -> new ArrayList<>()).add(site.enclosing); - } } List params = c.getParameters(); @@ -208,6 +211,13 @@ private static Set seedsFor(Fn fn, String name, Map retu if (name == null || name.isEmpty()) { return seeds; } + // Spelling, not binding — this is the syntactic seeding the L4 design settles for, and every + // way it can over-credit is a may-flow direction the weak-update posture accepts. It credits + // `name` when it is merely mentioned rather than returned (`return other(x) + 1;`); when it + // is a *field* of something else, since `.` is not a word character (`return a.z;` credits + // parameter `z`); when it appears inside a string literal (`return "x marks";` credits `x`); + // and when it sits next to `$`, which Java allows in identifiers but regex `\b` does not + // treat as a word character (`a$b` credits `a`). None of these can lose a real flow. Pattern word = Pattern.compile("\\b" + Pattern.quote(name) + "\\b"); List ddg = fn.callable.getDdg(); @@ -237,10 +247,15 @@ private static Set seedsFor(Fn fn, String name, Map retu } /** - * The smallest body node whose span strictly encloses {@code local}'s — the statement (or branch, - * or outer call) a nested call sits inside. Null when nothing encloses it, which is the case for - * a call in statement position: there the call node is the CFG node, so no hop is needed. - * Ties break on the node id so the choice cannot depend on map order. + * The smallest body node whose span strictly encloses {@code local}'s — the statement, outer + * call, or enclosing {@code branch}/{@code loop}/{@code switch} a nested call sits inside. Null + * only when nothing encloses it: a call in statement position at the top level of the + * body, where the call node is itself the CFG node and needs no hop. A call in statement position + * inside an {@code if}/{@code for}/{@code while} body does get a hop, to that branch or + * loop node, whose span covers the whole statement — reach then continues along its ddg + * out-edges, which over-approximates (the condition's dependences are not the call's) in the + * may-flow direction the L4 posture accepts. Ties break on the node id so the choice cannot + * depend on map order. */ private static String enclosing(String local, Map body) { int[] inner = bytes(body.get(local)); diff --git a/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java index 98a3cde4..f229fc60 100644 --- a/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java +++ b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java @@ -125,6 +125,70 @@ void aValuePassingThroughALocalStillComposes() { assertEquals(1, mid.getSummary().size()); } + /** + * The same composition, but with the in-project call wrapped in an unresolved one — + * {@code int t = Math.abs(id(a)); return t;}. The wrapper is a body node too, and its span is + * narrower than the statement's, so it is what encloses the inner call; it is external, has no + * summary, and is an orphan carrying no ddg edge. If the route out of a call node were built only + * for resolved in-project sites, the chain would stop dead on the wrapper and {@code top} would + * lose a summary edge it should have — an under-approximation. Node ids, spans and the + * {@code callee}-less wrapper mirror what {@code -a 4} really emits for this source. + */ + @Test + void anUnresolvedWrapperDoesNotBreakComposition() { + Map modules = wrappedChain(); + SdgVertices.apply(modules); + SummaryPass.apply(modules, List.of(callEdge("top(int)", "caller(int)"), callEdge("caller(int)", "id(int)")), 3); + + JCallable top = callable(modules, "/Pass/top(int)"); + assertNotNull(top.getSummary(), "top still composes through caller, wrapper notwithstanding"); + assertEquals(1, top.getSummary().size()); + assertEquals("topCall/actual_in:0", top.getSummary().get(0).getSrc()); + assertEquals("topCall/actual_out", top.getSummary().get(0).getDst()); + } + + private static final String WRAP_SOURCE = String.join( + "\n", + "class Pass {", + " int id(int p) { return p; }", + " int caller(int a) { int t = Math.abs(id(a)); return t; }", + " int top(int b) { int u = caller(b); return u; }", + "}"); + + private static Map wrappedChain() { + JType type = new JType(); + type.setId("can://java/pass/Pass.java/Pass"); + + JCallable id = method("id(int)", "p"); + id.getBody().put("@entry", node("entry", null, WRAP_SOURCE)); + id.getBody().put("idRet", node("return", "return p;", WRAP_SOURCE)); + id.setDdg(new ArrayList<>()); + type.getCallables().put("id(int)", id); + + JCallable caller = method("caller(int)", "a"); + caller.getBody().put("@entry", node("entry", null, WRAP_SOURCE)); + caller.getBody().put("wrapCall", node("call", "Math.abs(id(a))", WRAP_SOURCE)); // no callee: unresolved + caller.getBody().put("idCall", callNode("id(a)", "id(int)", WRAP_SOURCE)); + caller.getBody().put("callerDecl", node("statement", "int t = Math.abs(id(a));", WRAP_SOURCE)); + caller.getBody().put("callerRet", node("return", "return t;", WRAP_SOURCE)); + caller.setDdg(new ArrayList<>(List.of(ddg("callerDecl", "callerRet", "t")))); + type.getCallables().put("caller(int)", caller); + + JCallable top = method("top(int)", "b"); + top.getBody().put("@entry", node("entry", null, WRAP_SOURCE)); + top.getBody().put("topCall", callNode("caller(b)", "caller(int)", WRAP_SOURCE)); + top.getBody().put("topDecl", node("statement", "int u = caller(b);", WRAP_SOURCE)); + top.getBody().put("topRet", node("return", "return u;", WRAP_SOURCE)); + top.setDdg(new ArrayList<>(List.of(ddg("topDecl", "topRet", "u")))); + type.getCallables().put("top(int)", top); + + JModule module = new JModule(); + module.setId("can://java/pass/Pass.java"); + module.setSource(WRAP_SOURCE); + module.getTypes().put("Pass", type); + return new LinkedHashMap<>(Map.of("Pass.java", module)); + } + // Line 2 holds callee, 3 mid, 4 top; every snippet below is unique, and the text is ASCII so a // char offset is a byte offset. private static final String PASS_SOURCE = String.join( @@ -141,26 +205,26 @@ private static Map passThroughChain() { type.setId("can://java/pass/Pass.java/Pass"); JCallable callee = method("callee(int)", "q"); - callee.getBody().put("@entry", node("entry", null)); - callee.getBody().put("calleeRet", node("return", "return q;")); + callee.getBody().put("@entry", node("entry", null, PASS_SOURCE)); + callee.getBody().put("calleeRet", node("return", "return q;", PASS_SOURCE)); callee.setDdg(new ArrayList<>()); type.getCallables().put("callee(int)", callee); JCallable mid = method("mid(int)", "x"); - mid.getBody().put("@entry", node("entry", null)); - mid.getBody().put("midCall", callNode("callee(x)", "callee(int)")); - mid.getBody().put("midDecl", node("statement", "int t = callee(x);")); - mid.getBody().put("midRet", node("return", "return t;")); + mid.getBody().put("@entry", node("entry", null, PASS_SOURCE)); + mid.getBody().put("midCall", callNode("callee(x)", "callee(int)", PASS_SOURCE)); + mid.getBody().put("midDecl", node("statement", "int t = callee(x);", PASS_SOURCE)); + mid.getBody().put("midRet", node("return", "return t;", PASS_SOURCE)); // The def-use edge leaves the *statement*, never the nested call node — that asymmetry is // the whole point of the case. mid.setDdg(new ArrayList<>(List.of(ddg("midDecl", "midRet", "t")))); type.getCallables().put("mid(int)", mid); JCallable top = method("top(int)", "w"); - top.getBody().put("@entry", node("entry", null)); - top.getBody().put("topCall", callNode("mid(w)", "mid(int)")); - top.getBody().put("topDecl", node("statement", "int s = mid(w);")); - top.getBody().put("topRet", node("return", "return s;")); + top.getBody().put("@entry", node("entry", null, PASS_SOURCE)); + top.getBody().put("topCall", callNode("mid(w)", "mid(int)", PASS_SOURCE)); + top.getBody().put("topDecl", node("statement", "int s = mid(w);", PASS_SOURCE)); + top.getBody().put("topRet", node("return", "return s;", PASS_SOURCE)); top.setDdg(new ArrayList<>(List.of(ddg("topDecl", "topRet", "s")))); type.getCallables().put("top(int)", top); @@ -193,20 +257,20 @@ private static JCallable method(String signature, String param) { return c; } - private static JBodyNode node(String kind, String snippet) { + private static JBodyNode node(String kind, String snippet, String source) { JBodyNode n = new JBodyNode(); n.setKind(kind); if (snippet != null) { Span span = new Span(); - int at = PASS_SOURCE.indexOf(snippet); + int at = source.indexOf(snippet); span.setBytes(new int[] {at, at + snippet.length()}); n.setSpan(span); } return n; } - private static JBodyNode callNode(String snippet, String calleeSignature) { - JBodyNode n = node("call", snippet); + private static JBodyNode callNode(String snippet, String calleeSignature, String source) { + JBodyNode n = node("call", snippet, source); n.setCallee("can://java/pass/Pass.java/Pass/" + calleeSignature); n.setReturnType("int"); n.setArgumentExpr(new ArrayList<>(List.of(snippet.substring(snippet.indexOf('(') + 1, snippet.length() - 1)))); From 5b8e3c1ff5d544df2141f9b7971fdade79d5a4f7 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 15:02:45 -0700 Subject: [PATCH 14/18] feat(l4): project J_PARAM_IN/J_PARAM_OUT/J_SUMMARY; graph contract 2.1.0 --- schema.neo4j.json | 4 +- src/main/java/com/ibm/cldk/CodeAnalyzer.java | 2 +- .../com/ibm/cldk/neo4j/V2GraphProjector.java | 32 ++++++++++++++- .../com/ibm/cldk/neo4j/V2SchemaCatalog.java | 13 +++++-- .../com/ibm/cldk/CodeAnalyzerV2CliTest.java | 4 +- .../neo4j/V2Neo4jSchemaConformanceTest.java | 39 ++++++++++++++----- 6 files changed, 75 insertions(+), 19 deletions(-) diff --git a/schema.neo4j.json b/schema.neo4j.json index 338c2b23..236d7f25 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -1,5 +1,5 @@ { - "schema_version": "2.0.0", + "schema_version": "2.1.0", "generator": "codeanalyzer-java", "marker_labels": [ "JEntrypoint" @@ -156,6 +156,8 @@ "argument_types": "string[]", "argument_expr": "string[]", "_module": "string", + "var": "string", + "call_node": "string", "start_line": "integer", "end_line": "integer" } diff --git a/src/main/java/com/ibm/cldk/CodeAnalyzer.java b/src/main/java/com/ibm/cldk/CodeAnalyzer.java index a166f9f7..cf4d4d61 100644 --- a/src/main/java/com/ibm/cldk/CodeAnalyzer.java +++ b/src/main/java/com/ibm/cldk/CodeAnalyzer.java @@ -396,7 +396,7 @@ 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 (sourceAnalysis != null || targetFiles != null) { diff --git a/src/main/java/com/ibm/cldk/neo4j/V2GraphProjector.java b/src/main/java/com/ibm/cldk/neo4j/V2GraphProjector.java index d24009b5..f0696928 100644 --- a/src/main/java/com/ibm/cldk/neo4j/V2GraphProjector.java +++ b/src/main/java/com/ibm/cldk/neo4j/V2GraphProjector.java @@ -25,6 +25,7 @@ import com.ibm.cldk.schema.JEnumConstant; import com.ibm.cldk.schema.JExternalSymbol; import com.ibm.cldk.schema.JField; +import com.ibm.cldk.schema.JIdEdge; import com.ibm.cldk.schema.JImport; import com.ibm.cldk.schema.JModule; import com.ibm.cldk.schema.JRecordComponent; @@ -41,10 +42,11 @@ /** * The schema v2 → Neo4j projection: a pure {@code (Analysis, appName) → GraphRows} function, no - * I/O, no driver. The vocabulary is {@link V2SchemaCatalog} (graph contract 2.0.0), mirroring + * I/O, no driver. The vocabulary is {@link V2SchemaCatalog} (graph contract 2.1.0), mirroring * codeanalyzer-python's projection: call sites are {@code :JBodyNode} rows (no call-site nodes), * parameters flatten to {@code parameters_json}, javadoc collapses to {@code docstring}, and the - * L3 {@code cfg}/{@code cdg}/{@code ddg} overlays become typed relationships between body nodes. + * L3 {@code cfg}/{@code cdg}/{@code ddg} and L4 {@code param_in}/{@code param_out}/{@code summary} + * overlays become typed relationships between body nodes. * *

Body-node identity is the global ordinal: {@code @} for real * statements ({@code 12:5}), {@code } for synthetic bookends whose local @@ -114,6 +116,21 @@ public static GraphRows project(Analysis analysis, String appName) { } } + if (analysis.getApplication().getParamIn() != null) { + for (JIdEdge e : analysis.getApplication().getParamIn()) { + b.edgeIfBothResolved("J_PARAM_IN", + new NodeRef("JBodyNode", "id", e.getSrc()), + new NodeRef("JBodyNode", "id", e.getDst()), RowBuilder.props()); + } + } + if (analysis.getApplication().getParamOut() != null) { + for (JIdEdge e : analysis.getApplication().getParamOut()) { + b.edgeIfBothResolved("J_PARAM_OUT", + new NodeRef("JBodyNode", "id", e.getSrc()), + new NodeRef("JBodyNode", "id", e.getDst()), RowBuilder.props()); + } + } + if (analysis.getApplication().getCallGraph() != null) { for (JCallEdge e : analysis.getApplication().getCallGraph()) { Map p = RowBuilder.props(); @@ -314,6 +331,9 @@ private static void projectCallable(RowBuilder b, NodeRef owner, String signatur np.put("argument_expr", n.getArgumentExpr()); putLines(np, n.getSpan()); np.put("_module", fileKey); + // L4 SDG synthetic-vertex payload: absent on every non-synthetic node (prune drops nulls). + np.put("var", n.getOf()); + np.put("call_node", n.getParent() == null ? null : globalOrdinal(c.getId(), n.getParent())); NodeRef nr = b.node(Arrays.asList("JBodyNode"), "id", id, RowBuilder.prune(np)); b.edge("J_HAS_BODY_NODE", ref, nr); if (n.getCallee() != null) { @@ -345,6 +365,14 @@ private static void projectCallable(RowBuilder b, NodeRef owner, String signatur RowBuilder.prune(ep), k); } } + if (c.getSummary() != null) { + // Endpoints are call-site-local ids on c's own body (SummaryPass.emit), same globalOrdinal + // rule as J_CFG_NEXT/J_CDG/J_DDG above — no resolution gating needed, unlike the + // application-scope param_in/param_out edges above, which cross into another callable's body. + for (JIdEdge e : c.getSummary()) { + b.edge("J_SUMMARY", bodyRef(c, e.getSrc()), bodyRef(c, e.getDst())); + } + } for (Map.Entry local : c.getTypes().entrySet()) { projectType(b, ref, "J_DECLARES", local.getKey(), local.getValue(), module, fileKey, diff --git a/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java b/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java index 2f420504..89920c2d 100644 --- a/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java +++ b/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java @@ -21,7 +21,7 @@ import java.util.Map; /** - * The schema v2 Neo4j graph catalog (graph contract {@code 2.0.0}) — the in-repo source of truth + * The schema v2 Neo4j graph catalog (graph contract {@code 2.1.0}) — the in-repo source of truth * for what {@link V2GraphProjector} may emit, serialized by {@code --emit schema} and enforced by * the v2 conformance test. Mirrors codeanalyzer-python's {@code neo4j/schema.py} vocabulary with * {@code J}/{@code J_} namespacing; java-only constructs (enum constants, record components, @@ -40,7 +40,9 @@ public final class V2SchemaCatalog { private V2SchemaCatalog() {} - public static final String SCHEMA_VERSION = "2.0.0"; + // 2.1.0: additive MINOR — L4 SDG overlay (JBodyNode.var/call_node; J_PARAM_IN/J_PARAM_OUT/ + // J_SUMMARY, reserved at 2.0.0, now actually emitted). + public static final String SCHEMA_VERSION = "2.1.0"; /** Labels layered onto a node in addition to its merge + specific labels. */ public static final List MARKER_LABELS = Arrays.asList("JEntrypoint"); @@ -125,7 +127,9 @@ private static List buildNodeLabels() { .put("return_type", "string").put("accessibility", "string") .put("is_constructor_call", "boolean").put("is_static_call", "boolean") .put("argument_types", "string[]").put("argument_expr", "string[]") - .put("_module", "string")))); + .put("_module", "string") + // L4 SDG synthetic-vertex payload (python-parity names). + .put("var", "string").put("call_node", "string")))); n.add(node("JPackage", "JPackage", "name", new P().put("name", "string").done())); @@ -168,7 +172,8 @@ private static List buildRelTypes() { r.add(rel("J_CDG", body, body, none)); r.add(rel("J_DDG", body, body, new P().put("var", "string").put("prov", "string[]").put("_k", "string").done())); - // L4 SDG — declared so the contract is stable; not emitted until L4 lands. + // L4 SDG — reserved at 2.0.0, emitted from 2.1.0: J_PARAM_IN/J_PARAM_OUT from the + // application-scope param_in/param_out edges, J_SUMMARY per callable. r.add(rel("J_PARAM_IN", body, body, new P().put("var", "string").done())); r.add(rel("J_PARAM_OUT", body, body, new P().put("var", "string").done())); r.add(rel("J_SUMMARY", body, body, none)); diff --git a/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java b/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java index bb7d5c07..0aa03da4 100644 --- a/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java +++ b/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java @@ -168,10 +168,10 @@ void emitSchemaAlwaysEmitsTheV2Catalog(@TempDir Path tmp) throws IOException { assertEquals(0, run("--emit", "schema", "-o", out.toString())); JsonObject doc = JsonParser.parseString(Files.readString(out.resolve("schema.neo4j.json"))) .getAsJsonObject(); - assertEquals("2.0.0", doc.get("schema_version").getAsString()); + assertEquals("2.1.0", doc.get("schema_version").getAsString()); assertEquals(0, run("--emit", "schema", "-o", out.toString(), "--schema", "v1"), "--emit schema ignores --schema"); - assertEquals("2.0.0", JsonParser.parseString(Files.readString(out.resolve("schema.neo4j.json"))) + assertEquals("2.1.0", JsonParser.parseString(Files.readString(out.resolve("schema.neo4j.json"))) .getAsJsonObject().get("schema_version").getAsString()); } diff --git a/src/test/java/com/ibm/cldk/neo4j/V2Neo4jSchemaConformanceTest.java b/src/test/java/com/ibm/cldk/neo4j/V2Neo4jSchemaConformanceTest.java index f214706a..51957c98 100644 --- a/src/test/java/com/ibm/cldk/neo4j/V2Neo4jSchemaConformanceTest.java +++ b/src/test/java/com/ibm/cldk/neo4j/V2Neo4jSchemaConformanceTest.java @@ -26,6 +26,8 @@ import com.ibm.cldk.schema.V2Emitter; 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 java.nio.file.Path; import java.nio.file.Paths; import java.util.HashMap; @@ -38,15 +40,19 @@ import org.junit.jupiter.api.Test; /** - * Schema v2 graph conformance (no container needed): run the real L1–L3 pipeline over a fixture, - * project with {@link V2GraphProjector}, and assert the projector only ever produces what - * {@link V2SchemaCatalog} declares — the anti-drift guard for the 2.0.0 graph contract. Also pins + * Schema v2 graph conformance (no container needed): run the real L1–L3 pipeline plus the L4 SDG + * passes ({@link SdgVertices}, {@link SummaryPass}) over a fixture, project with + * {@link V2GraphProjector}, and assert the projector only ever produces what + * {@link V2SchemaCatalog} declares — the anti-drift guard for the 2.1.0 graph contract. Also pins * the convergence decisions: body nodes instead of call-site nodes, and the {@code _k}-keyed * CFG/DDG relationships. */ public class V2Neo4jSchemaConformanceTest { - private static final Path FIXTURE = Paths.get("src/test/resources/test-applications/call-graph-test"); + // l4-sdg-test (not call-graph-test): its calls are all 1-arg with a transitive a→b→c chain, so + // J_PARAM_IN/J_SUMMARY are guaranteed non-empty here. The v1/v2 conformance tests still exercise + // call-graph-test. + private static final Path FIXTURE = Paths.get("src/test/resources/test-applications/l4-sdg-test"); private static GraphRows rows; @@ -65,11 +71,14 @@ static void project() throws Exception { REL_BY_TYPE.put(rt.type, rt); } Map modules = L1Extractor.extractAll( - FIXTURE, "call-graph-test", null, new LinkedHashMap<>(), 3, 3, "ast"); - L2CallGraph.Result l2 = L2CallGraph.build("call-graph-test", modules, null, true); + FIXTURE, "l4-sdg-test", null, new LinkedHashMap<>(), 3, 3, "ast"); + L2CallGraph.Result l2 = L2CallGraph.build("l4-sdg-test", modules, null, true); + SdgVertices.Result sdg = SdgVertices.apply(modules); + SummaryPass.apply(modules, l2.callGraph(), 3); Analysis analysis = V2Emitter.emit( - "call-graph-test", 3, modules, "test", l2.callGraph(), l2.externalSymbols()); - rows = V2GraphProjector.project(analysis, "call-graph-test"); + "l4-sdg-test", 3, modules, "test", l2.callGraph(), l2.externalSymbols(), + sdg.paramIn, sdg.paramOut); + rows = V2GraphProjector.project(analysis, "l4-sdg-test"); } private static String specificLabel(List labels) { @@ -163,7 +172,7 @@ public void l3OverlayEdgesAreKeyedAndPresent() { @Test public void wipeCoversBothGenerationsSoV2ReplacesAPriorV1Graph() { - String cypher = CypherWriter.renderCypher(rows, "call-graph-test"); + String cypher = CypherWriter.renderCypher(rows, "l4-sdg-test"); assertTrue(cypher.contains("J_HAS_UNIT|J_HAS_MODULE"), "the wipe must traverse both generations' unit relationship"); assertTrue(cypher.contains("MATCH (s:JSymbol) WHERE NOT (s)--() DELETE s"), @@ -174,4 +183,16 @@ public void wipeCoversBothGenerationsSoV2ReplacesAPriorV1Graph() { "wipe/prune descendant traversal must include " + rel); } } + + @Test + void l4OverlayProjectsParamAndSummaryEdges() { + boolean paramIn = false; + boolean summary = false; + for (EdgeRow edge : rows.edges) { + paramIn |= edge.type.equals("J_PARAM_IN"); + summary |= edge.type.equals("J_SUMMARY"); + } + assertTrue(paramIn, "J_PARAM_IN projected from application param_in"); + assertTrue(summary, "J_SUMMARY projected from callable summaries"); + } } From 9914843179778ab8c8978345458c14cb823ac019 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 15:47:34 -0700 Subject: [PATCH 15/18] fix(l4): seed summary flows from any spanned body node, not just returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `seedsFor` seeded a parameter from ddg edges rooted at it, call-argument text and return-statement text. But `DdgBuilder` gives a parameter no defining node — nothing in the ddg is ever rooted at one — so int m(int q) { int t = q; return t; } had ddg `(decl -> ret, var "t")`, no call site, and a return text naming `t` rather than `q`: `flows(m)` came out empty and every caller of `m` silently lost its summary edge. Because composition only propagates when `flows(callee)` is non-empty, the miss cascaded up whole call chains. This is the commonest Java shape after `return q;` itself, and dropping a real flow is the under-approximation L4 does not allow. Widen the word-boundary text rule from return nodes to every spanned body node of kind statement/return/call; the existing `decl -> ret` ddg edge then carries the seed to the sink. branch/loop/switch are excluded: their spans swallow every nested statement, so slicing them would seed a container node by pure containment and hand it all of its def-use reach, for no soundness gain — the nested statement carrying the real flow is seeded on its own. Strictly more over-approximate, which the spec accepts. Also give `summaryEndpointsAreExistingLocalBodyNodes` a count assertion: it passed vacuously if nothing had a summary at all. --- .../dataflow/SummaryPass.java | 57 +++++++++++----- .../dataflow/SummaryPassTest.java | 66 +++++++++++++++++++ 2 files changed, 106 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java index 4a9b100c..50443ea7 100644 --- a/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java +++ b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java @@ -36,10 +36,13 @@ *

The relation is derived syntactically, in the weak-update (may-flow, never-drop) * posture the L4 design takes: a parameter reaches the return if the callable's {@code ddg} carries * it there, if a call site passes it on and that callee's own summary returns it, or if the - * parameter is simply named in a {@code return} expression. That last rule is what makes the pass - * useful at all on ordinary code — {@code int c(int z) { return z - 3; }} has no local variable, so - * no def-use, so no {@code ddg} edge to follow — at the cost of counting a parameter merely - * mentioned in a return as flowing. Over-approximating there is the accepted trade. + * parameter is simply named in a body node's source text. That last rule is what makes the + * pass useful at all on ordinary code, because {@link DdgBuilder} gives a parameter no defining node + * — nothing in the {@code ddg} is ever rooted at one. Without it {@code int c(int z) { return z - 3; + * }} has no def-use to follow at all, and {@code int m(int q) { int t = q; return t; }} has only + * {@code (decl → ret, var "t")}, which no seed reaches. The cost is counting a parameter merely + * mentioned in a statement as flowing out of it. Over-approximating there is the accepted + * trade. */ public final class SummaryPass { @@ -147,15 +150,17 @@ private static Fn facts(JCallable c, byte[] source) { } Map body = c.getBody(); - Map returnText = new LinkedHashMap<>(); + Map spanText = new LinkedHashMap<>(); for (Map.Entry entry : body.entrySet()) { JBodyNode node = entry.getValue(); - if ("return".equals(node.getKind())) { - fn.sinks.add(entry.getKey()); + if (SEEDABLE_KINDS.contains(node.getKind())) { String text = slice(source, node.getSpan()); if (text != null) { - returnText.put(entry.getKey(), text); + spanText.put(entry.getKey(), text); } + } + if ("return".equals(node.getKind())) { + fn.sinks.add(entry.getKey()); } else if ("call".equals(node.getKind())) { // Where a call's value goes next. Deliberately outside the resolved/in-project filter // below: an unresolved or external call is still a node the chain has to pass @@ -192,7 +197,7 @@ private static Fn facts(JCallable c, byte[] source) { List params = c.getParameters(); for (int i = 0; i < params.size(); i++) { - Set seeds = seedsFor(fn, params.get(i).getName(), returnText); + Set seeds = seedsFor(fn, params.get(i).getName(), spanText); if (!seeds.isEmpty()) { fn.seeds.put(i, seeds); } @@ -200,21 +205,39 @@ private static Fn facts(JCallable c, byte[] source) { return fn; } + /** + * The body-node kinds whose {@code span} is a single statement or expression, so that matching a + * parameter name against the slice says something about that node. Deliberately omits + * {@code branch}, {@code loop} and {@code switch}, whose spans swallow every statement nested + * inside them: a name mentioned anywhere in a loop body would otherwise seed the loop node and + * inherit all of its def-use reach, which buys no soundness (the nested statement carrying the + * real flow is seeded on its own) and costs precision in bulk. {@code entry}/{@code exit} and the + * synthetic L4 vertices carry no span at all, so {@link #slice} skips them regardless. + * + *

A {@code statement} spanning a local class declaration is a container in source terms but not + * in body-node terms — the nested methods are separate callables with their own {@code body} maps + * (see {@link #walkTypes}) — so it is a leaf here and is safe to slice. + */ + private static final Set SEEDABLE_KINDS = Set.of("statement", "return", "call"); + /** * Where a parameter's value is visible, syntactically: the def end of any {@code ddg} edge whose * access path is rooted at it, any call-site {@code actual_in} vertex whose argument text names - * it, and any {@code return} statement whose text names it (see the class javadoc — a parameter - * flowing straight to the return is the commonest summary shape and leaves no def-use trail). + * it, and any {@link #SEEDABLE_KINDS} body node whose source text names it (see the class javadoc + * — a parameter has no defining node in the {@code ddg}, so text is the only thing that can put it + * on the map at all). */ - private static Set seedsFor(Fn fn, String name, Map returnText) { + private static Set seedsFor(Fn fn, String name, Map spanText) { Set seeds = new LinkedHashSet<>(); if (name == null || name.isEmpty()) { return seeds; } // Spelling, not binding — this is the syntactic seeding the L4 design settles for, and every // way it can over-credit is a may-flow direction the weak-update posture accepts. It credits - // `name` when it is merely mentioned rather than returned (`return other(x) + 1;`); when it - // is a *field* of something else, since `.` is not a word character (`return a.z;` credits + // `name` when it is merely mentioned rather than used (`return other(x) + 1;`, `log(x);`); + // when the mention is a *shadowing* local or catch parameter of the same spelling, since this + // reads text and not scopes (`{ int x = 0; sink(x); }` in a method taking `x`); when it is a + // *field* of something else, since `.` is not a word character (`return a.z;` credits // parameter `z`); when it appears inside a string literal (`return "x marks";` credits `x`); // and when it sits next to `$`, which Java allows in identifiers but regex `\b` does not // treat as a word character (`a$b` credits `a`). None of these can lose a real flow. @@ -238,9 +261,9 @@ private static Set seedsFor(Fn fn, String name, Map retu } } } - for (Map.Entry ret : returnText.entrySet()) { - if (word.matcher(ret.getValue()).find()) { - seeds.add(ret.getKey()); + for (Map.Entry node : spanText.entrySet()) { + if (word.matcher(node.getValue()).find()) { + seeds.add(node.getKey()); } } return seeds; diff --git a/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java index f229fc60..b8eb18db 100644 --- a/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java +++ b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java @@ -147,6 +147,66 @@ void anUnresolvedWrapperDoesNotBreakComposition() { assertEquals("topCall/actual_out", top.getSummary().get(0).getDst()); } + /** + * A parameter that reaches the return through a local — {@code int m(int q) { int t = q; + * return t; }} — the commonest Java shape after {@code return q;} itself. Nothing in the ddg is + * rooted at {@code q}: {@code DdgBuilder} gives a parameter no defining node, so the only edge is + * {@code (decl → ret, var "t")}; there is no call site; and the return text names {@code t}, not + * {@code q}. The seed therefore has to come from the declaration statement's own text, + * which is what the widened word-boundary rule supplies — the existing {@code decl → ret} edge + * then carries it to the sink. + * + *

Asserted through a caller, because that is where the miss actually shows up: with + * {@code flows(m)} empty, every caller of {@code m} silently loses its summary edge. + */ + @Test + void aParameterCopiedIntoALocalStillReachesTheReturn() { + Map modules = localCopyChain(); + SdgVertices.apply(modules); + SummaryPass.apply(modules, List.of(callEdge("top(int)", "m(int)")), 3); + + JCallable top = callable(modules, "/Pass/top(int)"); + assertNotNull(top.getSummary(), "q reaches m's return through the local t, so top shortcuts the call"); + assertEquals(1, top.getSummary().size()); + assertEquals("topCall/actual_in:0", top.getSummary().get(0).getSrc()); + assertEquals("topCall/actual_out", top.getSummary().get(0).getDst()); + } + + private static final String LOCAL_SOURCE = String.join( + "\n", + "class Pass {", + " int m(int q) { int t = q; return t; }", + " int top(int b) { int u = m(b); return u; }", + "}"); + + /** {@code m} copies its parameter into a local and returns the local; {@code top} calls it. */ + private static Map localCopyChain() { + JType type = new JType(); + type.setId("can://java/pass/Pass.java/Pass"); + + JCallable m = method("m(int)", "q"); + m.getBody().put("@entry", node("entry", null, LOCAL_SOURCE)); + m.getBody().put("mDecl", node("statement", "int t = q;", LOCAL_SOURCE)); + m.getBody().put("mRet", node("return", "return t;", LOCAL_SOURCE)); + // The only edge the ddg has: `q` itself is never a def site, so no edge is rooted at it. + m.setDdg(new ArrayList<>(List.of(ddg("mDecl", "mRet", "t")))); + type.getCallables().put("m(int)", m); + + JCallable top = method("top(int)", "b"); + top.getBody().put("@entry", node("entry", null, LOCAL_SOURCE)); + top.getBody().put("topCall", callNode("m(b)", "m(int)", LOCAL_SOURCE)); + top.getBody().put("topDecl", node("statement", "int u = m(b);", LOCAL_SOURCE)); + top.getBody().put("topRet", node("return", "return u;", LOCAL_SOURCE)); + top.setDdg(new ArrayList<>(List.of(ddg("topDecl", "topRet", "u")))); + type.getCallables().put("top(int)", top); + + JModule module = new JModule(); + module.setId("can://java/pass/Pass.java"); + module.setSource(LOCAL_SOURCE); + module.getTypes().put("Pass", type); + return new LinkedHashMap<>(Map.of("Pass.java", module)); + } + private static final String WRAP_SOURCE = String.join( "\n", "class Pass {", @@ -293,6 +353,7 @@ private static JCallEdge callEdge(String from, String to) { @Test void summaryEndpointsAreExistingLocalBodyNodes() throws Exception { Map modules = analyzed(); + int[] edges = {0}; modules.values().stream() .flatMap(m -> m.getTypes().values().stream()) .flatMap(t -> t.getCallables().values().stream()) @@ -300,6 +361,11 @@ void summaryEndpointsAreExistingLocalBodyNodes() throws Exception { .forEach(c -> c.getSummary().forEach(e -> { assertTrue(c.getBody().containsKey(e.getSrc()), c.getId() + " src " + e.getSrc()); assertTrue(c.getBody().containsKey(e.getDst()), c.getId() + " dst " + e.getDst()); + edges[0]++; })); + // Without this the endpoint check passes vacuously on a regression that empties every + // summary. Four is the fixture's whole set: Chain.a→b, Chain.b→c, Mutual.even→odd, + // Mutual.odd→even. Heap's two sites cannot carry one (void callee, no-arg callee). + assertEquals(4, edges[0], "every fixture summary edge is checked, and there are four of them"); } } From 8f4d7a6bb251e3828070228e7315c26480c6de5c Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 15:47:42 -0700 Subject: [PATCH 16/18] fix(cli): validate flag values after --emit neo4j raises the level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `--precision` and `--l3-engine` checks are level-gated (>= 4 and >= 3), and the `--emit neo4j` block that sets `analysisLevel = 4` ran after them. So `--emit neo4j --precision garbage` exited 0 and silently fell back to RTA, while `-a 4 --precision garbage` exited 1 — against the CLI contract that unrecognized flag values exit non-zero. Hoist the neo4j block above both checks: it only reads the parse result and sets analysisLevel/externalCalls, so it is safe to run first, and it cures the same latent bypass for `--l3-engine`. Reset the static CLI options after each test as well as before: they are static fields, so whichever test happened to run last leaked `emit` and `precision` into other classes driving the same CLI (with the hoist in place, a leaked `emit=neo4j` makes an unrelated `-a 4` run fail). Also correct the `analyzeV2` javadoc, which still claimed levels 1 and 2. --- src/main/java/com/ibm/cldk/CodeAnalyzer.java | 36 ++++++++++--------- .../com/ibm/cldk/CodeAnalyzerV2CliTest.java | 14 +++++++- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/ibm/cldk/CodeAnalyzer.java b/src/main/java/com/ibm/cldk/CodeAnalyzer.java index cf4d4d61..080201cb 100644 --- a/src/main/java/com/ibm/cldk/CodeAnalyzer.java +++ b/src/main/java/com/ibm/cldk/CodeAnalyzer.java @@ -361,28 +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 > 4) { throw new ParameterException(spec.commandLine(), "error: --schema v2 currently supports --analysis-level 1, 2, 3, and 4 only"); } - 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"); - } + // 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. @@ -399,6 +390,19 @@ private void analyzeV2() throws Exception { 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 " diff --git a/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java b/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java index 0aa03da4..7c960caf 100644 --- a/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java +++ b/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java @@ -48,9 +48,12 @@ private static int run(String... args) { /** * The pre-existing CLI options on {@link CodeAnalyzer} are static, so a value set by one test - * would leak into the next. Reset the ones these tests touch so each case starts from defaults. + * would leak into the next. Reset the ones these tests touch so each case starts from defaults — + * and again afterwards, so whichever test happens to run last cannot leak {@code emit=neo4j} or a + * deliberately bad {@code --precision} into another test class that drives the same CLI. */ @BeforeEach + @AfterEach void resetStaticOptions() throws Exception { set("emit", "json"); set("analysisLevel", 1); @@ -464,4 +467,13 @@ void unknownPrecisionFailsLoudly(@TempDir Path tmp) throws IOException { "unrecognized flag values exit non-zero (CLI contract)"); } + @Test + void unknownPrecisionFailsLoudlyUnderEmitNeo4jToo(@TempDir Path tmp) throws IOException { + Path in = project(tmp.resolve("app")); + // --emit neo4j raises the level to 4 itself, and the precision check is level-gated: if it ran + // before the raise, this would exit 0 and silently fall back to RTA. + assertNotEquals(0, run("-i", in.toString(), "--emit", "neo4j", "--precision", "2-cfa"), + "unrecognized flag values exit non-zero (CLI contract), --emit neo4j included"); + } + } From 86102a154a762e5b4e48bccbd79a6d156f4f9b77 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 15:47:51 -0700 Subject: [PATCH 17/18] test(l4): pin the fixture's overlay counts and widen the gate assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `paramEdgeAritiesMatchAndNothingDangles` claimed "exactly one" a->b param_in edge but only checked at-least-one; count and assert 1. - Nothing pinned the fixture's overlay sizes, so §14's "arity matches" was a name rather than a check. Assert param_in = 5, param_out = 5, summary = 4, each hand-derived from the three fixture files and confirmed against a real -a 4 run. - `monotonicOverL3` sampled only Chain.a(int). Widen it to every callable in the emitted document: body nodes, cfg, cdg and ddg must all survive L3 -> L4, and no L4 vertex may appear at -a 3. - The neo4j conformance test asserted J_PARAM_IN and J_SUMMARY but not J_PARAM_OUT. Also record an "Implementation delta" subsection in spec §9: the global/static-state vertices and by-ref formal_out that were deferred (static-field flow still rides the points-to ddg, since StaticFieldKey is handled), and the two engine deviations — param edges derived from the L2-backfilled call sites, and semantic ddg from per-method PDGs with caller-side heap statements projected onto their call's NormalStatement rather than a whole-program SDG object — both to stay inside the recorded ModRef OOM bound. --- docs/design/specs/schema-v2-l3-l4-design.md | 14 +++ .../neo4j/V2Neo4jSchemaConformanceTest.java | 3 + .../java/com/ibm/cldk/schema/L4GateTest.java | 109 +++++++++++++++--- 3 files changed, 111 insertions(+), 15 deletions(-) diff --git a/docs/design/specs/schema-v2-l3-l4-design.md b/docs/design/specs/schema-v2-l3-l4-design.md index 5cde382f..33f0b0b5 100644 --- a/docs/design/specs/schema-v2-l3-l4-design.md +++ b/docs/design/specs/schema-v2-l3-l4-design.md @@ -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` 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 diff --git a/src/test/java/com/ibm/cldk/neo4j/V2Neo4jSchemaConformanceTest.java b/src/test/java/com/ibm/cldk/neo4j/V2Neo4jSchemaConformanceTest.java index 51957c98..92120978 100644 --- a/src/test/java/com/ibm/cldk/neo4j/V2Neo4jSchemaConformanceTest.java +++ b/src/test/java/com/ibm/cldk/neo4j/V2Neo4jSchemaConformanceTest.java @@ -187,12 +187,15 @@ public void wipeCoversBothGenerationsSoV2ReplacesAPriorV1Graph() { @Test void l4OverlayProjectsParamAndSummaryEdges() { boolean paramIn = false; + boolean paramOut = false; boolean summary = false; for (EdgeRow edge : rows.edges) { paramIn |= edge.type.equals("J_PARAM_IN"); + paramOut |= edge.type.equals("J_PARAM_OUT"); summary |= edge.type.equals("J_SUMMARY"); } assertTrue(paramIn, "J_PARAM_IN projected from application param_in"); + assertTrue(paramOut, "J_PARAM_OUT projected from application param_out"); assertTrue(summary, "J_SUMMARY projected from callable summaries"); } } diff --git a/src/test/java/com/ibm/cldk/schema/L4GateTest.java b/src/test/java/com/ibm/cldk/schema/L4GateTest.java index 32a32b9a..8d7ec97a 100644 --- a/src/test/java/com/ibm/cldk/schema/L4GateTest.java +++ b/src/test/java/com/ibm/cldk/schema/L4GateTest.java @@ -12,6 +12,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Assumptions; @@ -57,14 +58,44 @@ void paramEdgeAritiesMatchAndNothingDangles() { } // Chain.a calls b(1 arg): exactly one actual_in:0 → b@formal_in:0 edge exists. - boolean found = false; + int found = 0; for (var e : app.getAsJsonArray("param_in")) { String dst = e.getAsJsonObject().get("dst").getAsString(); if (dst.endsWith("/Chain/b(int)@formal_in:0")) { - found = true; + found++; } } - assertTrue(found, "a→b param_in edge present"); + assertEquals(1, found, "exactly one a→b param_in edge"); + } + + /** + * §14's "arity matches" as an actual count. Hand-derived from the three fixture files and + * confirmed against this run: + * + *

    + *
  • {@code param_in} = 5 — one per argument at each of the five in-project call sites with + * arguments: {@code a→b}, {@code b→c}, {@code even→odd}, {@code odd→even}, + * {@code roundTrip→put}. {@code roundTrip→get()} passes none, so it contributes none. + *
  • {@code param_out} = 5 — one per site whose callee returns a value: the same four + * {@code Chain}/{@code Mutual} sites plus {@code roundTrip→get()}; {@code put} is + * {@code void}, so that site has no {@code actual_out} to reach. + *
  • {@code summary} = 4 — {@code Chain.a}, {@code Chain.b}, {@code Mutual.even}, + * {@code Mutual.odd}, one shortcut each. {@code Heap.roundTrip}'s two sites are a void + * callee and a no-arg callee, so neither can carry one. + *
+ */ + @Test + void overlayCountsAreExactlyWhatTheFixtureImplies() { + JsonObject app = root.getAsJsonObject("application"); + assertEquals(5, app.getAsJsonArray("param_in").size(), "param_in: one per argument at a resolved site"); + assertEquals(5, app.getAsJsonArray("param_out").size(), "param_out: one per value-returning site"); + + int summaries = 0; + for (JsonObject c : callablesById(root).values()) { + JsonArray summary = c.getAsJsonArray("summary"); + summaries += summary == null ? 0 : summary.size(); + } + assertEquals(4, summaries, "summary: one shortcut per pass-through call site"); } @Test @@ -100,19 +131,38 @@ void monotonicOverL3(@TempDir Path tmp) throws Exception { "-a", "3", "-o", tmp.toString()); assertEquals(0, exit); JsonObject l3 = JsonParser.parseString(Files.readString(tmp.resolve("analysis.json"))).getAsJsonObject(); - JsonObject l3Chain = callable(l3, "Chain", "a(int)"); - JsonObject l4Chain = callable(root, "Chain", "a(int)"); - for (var e : l3Chain.getAsJsonArray("cfg")) { - assertTrue(l4Chain.getAsJsonArray("cfg").contains(e), "L3 cfg ⊆ L4 cfg"); - } - for (var e : l3Chain.getAsJsonArray("ddg")) { - assertTrue(l4Chain.getAsJsonArray("ddg").contains(e), "L3 ddg ⊆ L4 ddg"); - } - for (String key : l3Chain.getAsJsonObject("body").keySet()) { - assertTrue(l4Chain.getAsJsonObject("body").has(key), "L3 body nodes survive: " + key); + + // Every callable, not a sample of one: L4 only ever adds, so the whole L3 payload has to + // survive it — body nodes, cfg and ddg alike. + Map l3Callables = callablesById(l3); + Map l4Callables = callablesById(root); + assertFalse(l3Callables.isEmpty(), "the -a 3 run produced callables to compare against"); + for (Map.Entry entry : l3Callables.entrySet()) { + String id = entry.getKey(); + JsonObject before = entry.getValue(); + JsonObject after = l4Callables.get(id); + assertNotNull(after, "L3 callable survives into L4: " + id); + for (String overlay : new String[] {"cfg", "cdg", "ddg"}) { + JsonArray l3Edges = before.getAsJsonArray(overlay); + if (l3Edges == null) { + continue; // no fact at L3 is nothing to preserve + } + JsonArray l4Edges = after.getAsJsonArray(overlay); + assertNotNull(l4Edges, id + " loses its " + overlay + " at L4"); + for (var e : l3Edges) { + assertTrue(l4Edges.contains(e), "L3 " + overlay + " ⊆ L4 " + overlay + " in " + id + ": " + e); + } + } + JsonObject l3Body = before.getAsJsonObject("body"); + if (l3Body == null) { + continue; + } + for (String key : l3Body.keySet()) { + assertTrue(after.getAsJsonObject("body").has(key), "L3 body node survives: " + id + " " + key); + } + assertFalse(l3Body.keySet().stream().anyMatch(k -> k.contains("formal") || k.contains("actual")), + "no L4 vertices leak into -a 3: " + id); } - assertFalse(l3Chain.getAsJsonObject("body").keySet().stream().anyMatch(k -> k.contains("formal")), - "no L4 vertices leak into -a 3"); } // ----- helpers ------------------------------------------------------------------------------ @@ -130,6 +180,35 @@ private static JsonObject callable(JsonObject root, String typeSuffix, String si throw new AssertionError("no type found with suffix /" + typeSuffix); } + /** Every callable in a document, keyed by its {@code id} (nested and callable-local types included). */ + private static Map callablesById(JsonObject doc) { + Map byId = new LinkedHashMap<>(); + JsonObject symbolTable = doc.getAsJsonObject("application").getAsJsonObject("symbol_table"); + for (Map.Entry fileEntry : symbolTable.entrySet()) { + collectCallables(fileEntry.getValue().getAsJsonObject().getAsJsonObject("types"), byId); + } + return byId; + } + + private static void collectCallables(JsonObject types, Map byId) { + if (types == null) { + return; + } + for (Map.Entry typeEntry : types.entrySet()) { + JsonObject type = typeEntry.getValue().getAsJsonObject(); + collectCallables(type.getAsJsonObject("types"), byId); + JsonObject callables = type.getAsJsonObject("callables"); + if (callables == null) { + continue; + } + for (Map.Entry callableEntry : callables.entrySet()) { + JsonObject callable = callableEntry.getValue().getAsJsonObject(); + byId.put(callable.get("id").getAsString(), callable); + collectCallables(callable.getAsJsonObject("types"), byId); + } + } + } + /** Depth-first search of a {@code types} map (and its nested {@code types}) for an id match. */ private static JsonObject findType(JsonObject types, String typeSuffix) { if (types == null) { From 64fed7172ca33d7937ae14e0af07542d0c2abb19 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 28 Aug 2026 16:28:49 -0700 Subject: [PATCH 18/18] fix(l4): seed summary flows from container body nodes too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous pass excluded branch/loop/switch from text seeding, on the reasoning that a container's span swallows its nested statements. That traded the reported miss for another one in the same forbidden class. `BodyNodeBuilder` gives a for/for-each exactly one `loop` node spanning the whole statement, and recurses only into the body — the header never gets a node of its own. `DdgBuilder.collect` then attributes the loop variable and the iterated expression to that same node. So a container node carries real outgoing ddg edges, but with `loop` excluded nothing could ever seed it for a parameter, since a parameter has no ddg def site either. For int first(int[] q) { for (int x : q) { return x; } return 0; } `q` is named nowhere but the loop header, `return x;` never mentions it, and `flows(first)` came out empty — a real flow with zero edges, and every caller silently short of a summary edge. Correcting the mechanism corrects the conclusion: including a container costs bulk over-approximation (the parameter inherits that node's whole def-use reach), which the spec accepts; excluding one costs under-approximation, which it forbids. Every kind with a span is now seedable, so the allowlist has no job left and is deleted; entry/exit and the synthetic vertices carry no span and are skipped by `slice` anyway. Covered by a fixture method rather than a hand-built tree, because the shape only arises through the real AST pipeline: `Loops.first` reached through `Loops.callFirst`. Fixture counts move with it — param_in 5 -> 6, param_out 5 -> 6, summary 4 -> 5, all from the one new call site. --- .../dataflow/SummaryPass.java | 40 ++++++++----------- .../java/com/ibm/cldk/schema/L4GateTest.java | 28 +++++++------ .../dataflow/SummaryPassTest.java | 27 +++++++++++-- .../src/main/java/com/l4/Loops.java | 17 ++++++++ 4 files changed, 74 insertions(+), 38 deletions(-) create mode 100644 src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Loops.java diff --git a/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java index 50443ea7..310427ba 100644 --- a/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java +++ b/src/main/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPass.java @@ -153,11 +153,9 @@ private static Fn facts(JCallable c, byte[] source) { Map spanText = new LinkedHashMap<>(); for (Map.Entry entry : body.entrySet()) { JBodyNode node = entry.getValue(); - if (SEEDABLE_KINDS.contains(node.getKind())) { - String text = slice(source, node.getSpan()); - if (text != null) { - spanText.put(entry.getKey(), text); - } + String text = slice(source, node.getSpan()); + if (text != null) { + spanText.put(entry.getKey(), text); } if ("return".equals(node.getKind())) { fn.sinks.add(entry.getKey()); @@ -205,27 +203,23 @@ private static Fn facts(JCallable c, byte[] source) { return fn; } - /** - * The body-node kinds whose {@code span} is a single statement or expression, so that matching a - * parameter name against the slice says something about that node. Deliberately omits - * {@code branch}, {@code loop} and {@code switch}, whose spans swallow every statement nested - * inside them: a name mentioned anywhere in a loop body would otherwise seed the loop node and - * inherit all of its def-use reach, which buys no soundness (the nested statement carrying the - * real flow is seeded on its own) and costs precision in bulk. {@code entry}/{@code exit} and the - * synthetic L4 vertices carry no span at all, so {@link #slice} skips them regardless. - * - *

A {@code statement} spanning a local class declaration is a container in source terms but not - * in body-node terms — the nested methods are separate callables with their own {@code body} maps - * (see {@link #walkTypes}) — so it is a leaf here and is safe to slice. - */ - private static final Set SEEDABLE_KINDS = Set.of("statement", "return", "call"); - /** * Where a parameter's value is visible, syntactically: the def end of any {@code ddg} edge whose * access path is rooted at it, any call-site {@code actual_in} vertex whose argument text names - * it, and any {@link #SEEDABLE_KINDS} body node whose source text names it (see the class javadoc - * — a parameter has no defining node in the {@code ddg}, so text is the only thing that can put it - * on the map at all). + * it, and every body node whose source text names it (see the class javadoc — a parameter + * has no defining node in the {@code ddg}, so text is the only thing that can put it on the map at + * all). + * + *

Every kind with a span is seedable, containers included. A {@code branch}/{@code loop}/ + * {@code switch} span swallows the statements nested inside it, so seeding one on a name mentioned + * anywhere within hands the parameter that whole node's def-use reach — bulk over-approximation, + * which this posture explicitly accepts. Excluding them is the direction that is not + * allowed: those nodes carry real defs and uses of their own ({@code DdgBuilder.collect} attributes + * a {@code for}-each's loop variable and iterated expression, and an {@code if}'s condition, to the + * container node), while the header they come from gets no node of its own — so + * {@code int first(int[] q) { for (int x : q) { return x; } return 0; }} names {@code q} nowhere a + * non-container node can see, and excluding the loop drops a real flow. {@code entry}/{@code exit} + * and the synthetic L4 vertices need no rule: they carry no span, so {@link #slice} skips them. */ private static Set seedsFor(Fn fn, String name, Map spanText) { Set seeds = new LinkedHashSet<>(); diff --git a/src/test/java/com/ibm/cldk/schema/L4GateTest.java b/src/test/java/com/ibm/cldk/schema/L4GateTest.java index 8d7ec97a..9d28876a 100644 --- a/src/test/java/com/ibm/cldk/schema/L4GateTest.java +++ b/src/test/java/com/ibm/cldk/schema/L4GateTest.java @@ -69,33 +69,37 @@ void paramEdgeAritiesMatchAndNothingDangles() { } /** - * §14's "arity matches" as an actual count. Hand-derived from the three fixture files and + * §14's "arity matches" as an actual count. Hand-derived from the four fixture files and * confirmed against this run: * *

    - *
  • {@code param_in} = 5 — one per argument at each of the five in-project call sites with + *
  • {@code param_in} = 6 — one per argument at each of the six in-project call sites with * arguments: {@code a→b}, {@code b→c}, {@code even→odd}, {@code odd→even}, - * {@code roundTrip→put}. {@code roundTrip→get()} passes none, so it contributes none. - *
  • {@code param_out} = 5 — one per site whose callee returns a value: the same four - * {@code Chain}/{@code Mutual} sites plus {@code roundTrip→get()}; {@code put} is - * {@code void}, so that site has no {@code actual_out} to reach. - *
  • {@code summary} = 4 — {@code Chain.a}, {@code Chain.b}, {@code Mutual.even}, - * {@code Mutual.odd}, one shortcut each. {@code Heap.roundTrip}'s two sites are a void - * callee and a no-arg callee, so neither can carry one. + * {@code roundTrip→put}, {@code callFirst→first}. {@code roundTrip→get()} passes none, so + * it contributes none. + *
  • {@code param_out} = 6 — one per site whose callee returns a value: the same four + * {@code Chain}/{@code Mutual} sites, plus {@code callFirst→first} and + * {@code roundTrip→get()}; {@code put} is {@code void}, so that site has no + * {@code actual_out} to reach. + *
  • {@code summary} = 5 — {@code Chain.a}, {@code Chain.b}, {@code Mutual.even}, + * {@code Mutual.odd} and {@code Loops.callFirst}, one shortcut each. + * {@code Heap.roundTrip}'s two sites are a void callee and a no-arg callee, so neither can + * carry one. {@code Loops.callFirst}'s is the one that needs container nodes to be + * seedable: {@code first}'s parameter is named only in its {@code for}-each header. *
*/ @Test void overlayCountsAreExactlyWhatTheFixtureImplies() { JsonObject app = root.getAsJsonObject("application"); - assertEquals(5, app.getAsJsonArray("param_in").size(), "param_in: one per argument at a resolved site"); - assertEquals(5, app.getAsJsonArray("param_out").size(), "param_out: one per value-returning site"); + assertEquals(6, app.getAsJsonArray("param_in").size(), "param_in: one per argument at a resolved site"); + assertEquals(6, app.getAsJsonArray("param_out").size(), "param_out: one per value-returning site"); int summaries = 0; for (JsonObject c : callablesById(root).values()) { JsonArray summary = c.getAsJsonArray("summary"); summaries += summary == null ? 0 : summary.size(); } - assertEquals(4, summaries, "summary: one shortcut per pass-through call site"); + assertEquals(5, summaries, "summary: one shortcut per pass-through call site"); } @Test diff --git a/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java index b8eb18db..cb63dd51 100644 --- a/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java +++ b/src/test/java/com/ibm/cldk/syntactic_analysis/dataflow/SummaryPassTest.java @@ -147,6 +147,26 @@ void anUnresolvedWrapperDoesNotBreakComposition() { assertEquals("topCall/actual_out", top.getSummary().get(0).getDst()); } + /** + * A parameter named only in a {@code for}-each header — {@code Loops.first}. Real source through + * the real pipeline, because that is the point: {@link com.ibm.cldk.syntactic_analysis.controlflow.BodyNodeBuilder} + * gives a for-each exactly one {@code loop} node spanning the whole statement (the header never + * gets a node of its own), and {@link DdgBuilder} attributes the iterated expression and the loop + * variable to that node. So the loop node has a real outgoing ddg edge to {@code return x;}, but + * the only thing that can ever seed it for {@code q} is its own span text — a parameter has no ddg + * def site. With container kinds excluded from seeding, {@code flows(first)} was empty and the + * caller lost a summary edge for a flow that genuinely exists. + */ + @Test + void aParameterUsedOnlyInALoopHeaderStillReachesTheReturn() throws Exception { + Map modules = analyzed(); + JCallable callFirst = callable(modules, "/Loops/callFirst(int[])"); + assertNotNull(callFirst.getSummary(), "q flows out of first through the for-each, so callFirst shortcuts it"); + assertEquals(1, callFirst.getSummary().size()); + assertTrue(callFirst.getSummary().get(0).getSrc().endsWith("/actual_in:0")); + assertTrue(callFirst.getSummary().get(0).getDst().endsWith("/actual_out")); + } + /** * A parameter that reaches the return through a local — {@code int m(int q) { int t = q; * return t; }} — the commonest Java shape after {@code return q;} itself. Nothing in the ddg is @@ -364,8 +384,9 @@ void summaryEndpointsAreExistingLocalBodyNodes() throws Exception { edges[0]++; })); // Without this the endpoint check passes vacuously on a regression that empties every - // summary. Four is the fixture's whole set: Chain.a→b, Chain.b→c, Mutual.even→odd, - // Mutual.odd→even. Heap's two sites cannot carry one (void callee, no-arg callee). - assertEquals(4, edges[0], "every fixture summary edge is checked, and there are four of them"); + // summary. Five is the fixture's whole set: Chain.a→b, Chain.b→c, Mutual.even→odd, + // Mutual.odd→even, Loops.callFirst→first. Heap's two sites cannot carry one (void callee, + // no-arg callee). + assertEquals(5, edges[0], "every fixture summary edge is checked, and there are five of them"); } } diff --git a/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Loops.java b/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Loops.java new file mode 100644 index 00000000..e46d43e4 --- /dev/null +++ b/src/test/resources/test-applications/l4-sdg-test/src/main/java/com/l4/Loops.java @@ -0,0 +1,17 @@ +package com.l4; + +public class Loops { + // The parameter is named nowhere but the for-each header, which BodyNodeBuilder folds into the + // single `loop` node covering the whole statement. A parameter has no ddg def site, so nothing + // can seed `q` except that node's own source text. + public int first(int[] q) { + for (int x : q) { + return x; + } + return 0; + } + + public int callFirst(int[] a) { + return first(a); + } +}