Skip to content

Commit 0a4f01c

Browse files
authored
feat(l3): intraprocedural dataflow (CFG/CDG/DDG) — AST engine (#195)
* docs(design): detailed L3 two-engine dataflow spec; link from parent §8 Add docs/design/specs/l3-intraprocedural-dataflow-design.md — the user-facing L3 design elaborating parent §8 into two interchangeable engines (--l3-engine ast|wala, AST default) over one shared contract, with a differential gate. Revises D5 (WALA-engine/AST-fallback) as D28 (AST-default/WALA-opt-in) and forward-links the parent section. The WALA engine section spells out how line:col is recovered: WALA supplies only a line, so the column is adopted from the matched AST statement node (line-cover match -> content disambiguation -> no-source-position handling -> innermost fallback), never synthesized. §4.5.1 illustrates the limitation of intraprocedural DDG without points-to (missed and spurious heap def-use through aliases), noting it is a property of the level and affects both engines identically; L4 points-to adds the corrected edges as prov:[points-to]. §5.3 lays out the engine trade-offs and why AST is the default while WALA is still built. §7.1 explains why the engines are used alternatively rather than as an overlay: CFG/CDG shapes can't be unioned without malforming the graph, a DDG union is redundant (same ssa tier, not complementary like L2 declared/rta), and engine is an implementation choice kept out of prov. * feat(l3): type cfg/cdg/ddg schema edges + L3 schema oracle Tighten the callable's cfg/cdg/ddg arrays from bare {type:array} to typed cfgEdge/cdgEdge/ddgEdge $defs (endpoints are body-node local ids, not can:// ids; ddg prov is the closed enum [ssa]). Add L3SchemaOracleTest mirroring the L2 oracle: one accept per surface plus a rejection per way a plausible L3 payload can be malformed. * feat(l3): cfg/cdg/ddg edge models and callable fields Add JCfgEdge{src,dst,kind}, JCdgEdge{src,dst}, JDdgEdge{src,dst,var,prov} (endpoints are body-node local ids) and the null-until-L3 cfg/cdg/ddg fields on JCallable. Gson (no serializeNulls) keeps them absent from the payload below level 3; V2Emitter needs no change. * feat(l3): control-flow core in controlflow package + L3 orchestrator Organize L3 by concern: controlflow package (ControlFlowGraph, CfgBuilder, and the coming CdgBuilder — control flow and control dependence are cohesive, CDG is derived from the CFG), with data dependence to live in a dataflow package. L3Overlays is the orchestrator, sibling to L2CallGraph, returning the completed body + cfg/cdg/ddg. CfgBuilder is the structured-CFG core: a recursive link wiring straight-line sequences today (conditionals/loops/switch/exceptions follow), seeding L1 call nodes so the additive invariant holds. Deterministic edge ordering. No records (Java 11). * feat(l3): wire the parse-time L3 pass, CLI flags, and level gate Thread analysisLevel + graphFieldDepth through L1BuildContext (new overloaded ctor) and a new L1Extractor.extractAll overload; CallableBuilder runs L3Overlays.build at parse time when level >= 3, merging the completed body + cfg/cdg/ddg onto the callable. CodeAnalyzer: relax the v2 gate to allow -a 3, add --l3-engine (ast default; wala rejected as not-yet- implemented) and --graph-field-depth, and bypass the incremental cache at level >= 3 (the AST a warm hit would skip is needed, and L3 overlays are not an L1 cache artifact). Invert the CLI gate test: -a 3 now emits dataflow overlays, -a 4 fails, --l3-engine wala is rejected. * feat(l3): CFG return/throw to exit + bare-call node reuse return edges to @EXIT with kind 'return' (node kind 'return'); an uncaught throw edges to @EXIT with kind 'exception' (handler routing follows with the try-catch work). A bare-call statement is keyed at the call anchor (invoked name / instantiated type), so it reuses the L1 call node rather than duplicating it — kind stays 'call', honoring the additive invariant. * feat(l3): CFG conditionals (if/else) Refactor link into a type dispatch: blocks recurse via linkSequence, if becomes a branch node with true/false edges into the arms (both rejoining at the following statement; a missing else routes false straight to it). Extract an ensure() helper; kinds are now passed per case. * feat(l3): CFG loops (while/for/do/for-each) Refactor CfgBuilder to an instance so the recursion carries graph+context, and thread the terminal edge kind through link/linkSequence (loop bodies pass loop_back). Top-tested loops (while/for/for-each) emit a loop node with true into the body, a loop_back from the body tail, and false to the exit; do/while enters the body first with the test looping back from the bottom. for init/update are folded onto the loop node (no statement-level nodes). * feat(l3): CFG break/continue and labeled targets Add a frame stack of enclosing loop targets; break edges to the loop exit, continue to the loop test. Labeled loops carry their label on the frame so break/continue <label> resolve to the right enclosing loop; a labeled non-loop gets a break-only frame. Loops push/pop their frame around the body. * feat(l3): CFG switch (classic fall-through + arrow) A switch node with a switch_case edge per case entry; classic (colon) cases fall through to the next case's entry, arrow (->) cases do not. break inside a case exits to the join via the switch frame; a switch with no default gets an implicit no-match switch_case edge to the join. Labeled switch carries its label for labeled break. * feat(l3): CFG exceptions, try/catch/finally, try-with-resources, synchronized An exception-handler stack routes throwing statements (calls/allocations and explicit throw) to the enclosing catch/finally, else the method exit; outside a try no explicit edge is emitted (normal flow already reaches exit). try/catch/finally: normal and exceptional paths both flow through a single finally node (line:col identity precludes literal duplication); the try body routes to every catch entry (type dispatch over-approximated). try-with-resources analyses as a plain try (implicit close has no source node); synchronized carries flow through its body. Infinite loops stay well-formed via the loop's false edge, keeping @EXIT reachable. * feat(l3): CDG via post-dominance Compute post-dominators with Cooper-Harper-Kennedy on the reverse CFG rooted at @EXIT (iterative DFS postorder to bound recursion), then derive control dependence by the Ferrante-Ottenstein-Warren rule: for a CFG edge A->B where B does not post-dominate A, every node from B up to ipdom(A) is control- dependent on A. Wire cdg into L3Overlays; deterministic edge order. Tests cover if/if-else, nested-branch chaining, loop body (and that code after the branch/loop is not dependent), early return making following code control-dependent, switch cases, and determinism. * feat(l3): DDG reaching-defs over k-limited access paths AccessPath derives base(.field|[*])* spellings (arrays index-insensitive, truncated to k). DdgBuilder runs a monotone reaching-definitions fixpoint over the CFG: a def prefix-kills its path and extensions, a use joins to every reaching def whose path overlaps it, edges are prov:[ssa] (syntactic, object-insensitive; aliasing is L4). ControlFlowGraph now records the AST statement per node so defs/uses read off the source. Wire ddg into L3Overlays. Tests: local def-use, reassignment kill, field access paths, array [*] collapse, loop-carried def-use, determinism. * feat(l3): route abrupt exits through finally blocks in the CFG Every exit from a try (normal, catch, return/break/continue, uncaught throw) now runs the finally first. A finally is a single node (line:col identity precludes per-path copies) whose completion fans out to the union of all exits' continuations — a sound over-approximation that makes the finally post-dominate the try body, so CDG is correct. Abrupt exits reroute through the enclosing finally chain (innermost first, nested-aware) via a unified scope stack; ControlFlowGraph.redirect fans the finally's completion out from a temporary sentinel with no synthetic node. Design doc: finally semantics, the WALA parity result (javac duplicates the finally in bytecode but the copies collapse to the same single source node under the line:col projection, so the engines converge), the pinned exception-edge-density divergence, and the precise-duplication route as an alternative if the one-node-per-line:col invariant is relaxed. * test(l3): conformance gate — CFG well-formedness, PDG slice, L2 subset, schema A dataflow-test fixture exercising loop-carried def-use, if/else, early return, try/catch/finally (+ return through finally), and a shadowed variable. L3DataflowGateTest asserts: every callable's cfg is well-formed (single @entry/@EXIT, real spans, reachable from entry); try/catch yields an exception edge; a return inside try/finally runs the finally; the PDG backward slice of a variable includes its defs and controlling loop but not unrelated variables; L2 output is a subset of L3; level-3 output is deterministic and conforms to the canonical schema. * test(l3): real-world conformance gate over the fixture apps Add a realworld-tagged, parameterized L3 gate that runs the AST engine (source-only, no build) over spring-petclinic, two quarkuscoffeeshop modules, and commons-lang, asserting every callable's cfg is well-formed (nested/anonymous types included) and the whole level-3 payload conforms to the canonical schema. Extract validateSchema + a recursive well-formedness walk shared with the fixture gate. All four apps pass. * docs(l3): record D25-D28 in the decisions ledger; fix localId wording Add the L3 decisions to .claude/SCHEMA_DECISIONS.md (D25 local-id endpoints, D26 two engines, D27 syntactic DDG, D28 AST-default/WALA-opt-in + finally single-node) and mark D5 as revised by D28. Correct the design doc's schema section: localId already exists in the schema (broader pattern), so the edge defs reuse it rather than adding one. * docs(l3): report — AST-engine node/edge metrics over the 10 real-world fixtures Baseline of the L3 AST engine's cfg/cdg/ddg output across cargotracker, commons-lang, daytrader8, plantsbywebsphere, spring-petclinic, and the five quarkuscoffeeshop modules: per-app structure/node/edge counts plus edge- and body-node-kind breakdowns. Structured so a second set of columns can be added when the WALA engine (#194) lands, with the should-match vs pinned-divergence expectations noted. Batch runner and metrics script live under the git-ignored output/l3/. * docs(design): consolidate Neo4j projection into one post-L4 pass (#182) Neo4j v2 projection — base relabel + L3/L4 overlays — is now one consolidated pass after L4 (issue #182 widened), not a per-level in-PR overlay. So the JSON levels (L3 here, L4 next) carry no Neo4j overlay, and the v2 default-output flip (gated on the Neo4j projection) moves to post-L4; the analyzer major can still be cut on the JSON levels first. * docs(l3): document dataflow builders; drop stray NUL separators Comment pass on the thin algorithm internals (DdgBuilder reaching-defs, CdgBuilder post-dominance/intersect). Replace two raw NUL bytes in string literals — the CDG dedup separator (-> space) and the finally sentinel (-> '#finally-sentinel-'). Behavior-neutral: suite green, L3 metrics unchanged.
1 parent 2f88349 commit 0a4f01c

31 files changed

Lines changed: 3164 additions & 15 deletions

.claude/SCHEMA_DECISIONS.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,14 @@ containment subsumes it, matching how `codeanalyzer-python` models it
4545
(`PyClass.types` for inner classes, `PyCallable.types` for local classes).
4646

4747
### D5 — L3 CFG engine & granularity: WALA engine → source-statement nodes
48+
**Revised by D28** (AST engine is now the default; WALA is opt-in). The original decision:
4849
Use WALA as the analysis engine (`SSACFG` + dominance + SSA def-use — heap-ready for
4950
L4), but emit **source-statement-level** body nodes keyed by `line:col`: project each
5051
SSA instruction to its enclosing source statement via `IMethod.getSourcePosition` +
5152
JavaParser statement spans; fold/drop synthetic phi/pi nodes.
5253
**Fallback (recorded, not silent):** if SSA→source-statement fidelity proves
5354
unresolvable, revisit hand-building the CFG from the JavaParser AST (as Python/TS/Go do).
55+
The fallback became the default: what began as Option B is now the reference AST engine (D28).
5456

5557
### D6 — L4 points-to precision: RTA default + `--precision`
5658
Default RTA (reuse the L2 call-graph pointer analysis; proven to scale — 0-1-CFA was
@@ -370,6 +372,39 @@ an empty `external_symbols`/`call_graph` is omitted rather than emitted as `{}`/
370372
fact, D10), so parity is a *missing* key, not an empty one. The `L2CallGraph.build` library default
371373
keeps external on (it is intrinsic to L2); only the CLI defaults it off.
372374

375+
### D25 — L3 overlay endpoints are body-node local ids, not `can://` ids
376+
`cfg`/`cdg`/`ddg` are intra-callable overlays, so their edge `src`/`dst` are the body-node **local ids**
377+
(`line:col` or an `@tag` such as `@entry`/`@exit`) that key the `body{}` map — not application-scope
378+
`can://` ids (contrast the `call_graph`, D17, which crosses callables). Grounded in the keystone's own
379+
`ddg` example. The schema tightens `cfg`/`cdg`/`ddg` from bare arrays to `cfgEdge`/`cdgEdge`/`ddgEdge`
380+
`$defs` whose endpoints `$ref` the pre-existing `localId` def; `ddgEdge.prov` is a closed enum `[ssa]`
381+
at L3 (L4 adds `points-to`).
382+
383+
### D26 — Two interchangeable L3 engines over one contract; `ast` default, differential gate
384+
L3 has two engines behind `--l3-engine {ast,wala}` producing the *same* schema: the AST engine
385+
(JavaParser, default, source-only) and the WALA engine (opt-in, needs a build). They are **alternatives,
386+
not an overlay** — unlike L2's complementary `declared`+`rta` (D18), both L3 engines implement the same
387+
`ssa` tier, so a union would be redundant and the engine is kept out of `prov`. A differential gate
388+
cross-checks them; the AST engine is the reference. (WALA engine + gate tracked as a follow-up.)
389+
390+
### D27 — L3 DDG is syntactic: object-insensitive, field-sensitive, k-limited access paths
391+
Data dependence at L3 is def-use over k-limited access paths (`base(.field|[*])*`, default `k=3` via
392+
`--graph-field-depth`; arrays index-insensitive `[*]`), matched by spelling. Object-insensitive: `o1.f`
393+
and `o2.f` are distinct and aliasing is not resolved, so it can both miss aliased def-use and keep a
394+
stale def across an aliased write — deferred to L4 (`prov:["points-to"]`). Allocation-site precision is
395+
L4's 0-CFA, not an L3 option. Every L3 `ddg` edge is `prov:["ssa"]`.
396+
397+
### D28 — L3 CFG/DDG derivation (revises D5): AST engine default, WALA opt-in
398+
D5 made WALA the L3 engine with an AST-CFG fallback. Revised: because body nodes are keyed `line:col`
399+
and the identity gate requires a real column — which WALA-over-bytecode lacks — nodes must come from the
400+
JavaParser AST regardless; only the *edges* differ by engine. So the AST engine (exact `line:col`,
401+
build-free) is the **default** and WALA is **opt-in**. `L3 ⊆ L4` still holds: L4 *adds* `points-to`
402+
edges over the same nodes, never removing the `ssa` ones. `finally` is a single node whose completion
403+
fans out to the union of its continuations (line:col identity precludes per-path copies); both engines
404+
converge on this — `javac` duplicates `finally` in bytecode, but the copies collapse to the one source
405+
node under WALA's projection. Precise per-path `finally` needs the one-node-per-`line:col` invariant
406+
relaxed (tracked separately).
407+
373408
### Scope guard
374409
The analyzer is a **pure graph provider**: it emits the CFG/PDG/SDG substrate and
375410
stops. Slicing, taint, and reachability are **SDK queries** over the emitted graph
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# L3 dataflow metrics: AST engine over the real-world fixtures
2+
3+
Generated 2026-08-24 from the `codeanalyzer-2.4.1` build on the `enhancement/issue-183-l3-dataflow`
4+
branch. Each of the ten real-world fixture applications was analysed at analysis level 3 with the
5+
default AST engine (`--schema v2 --l3-engine ast`), and the emitted `cfg`/`cdg`/`ddg` overlays counted.
6+
This is the L3 companion to [`l1-v1-v2-comparison.md`](l1-v1-v2-comparison.md) and
7+
[`l2-v1-v2-comparison.md`](l2-v1-v2-comparison.md).
8+
9+
Unlike the L1/L2 notes, this is **not** a v1-vs-v2 comparison — L3 is new; there is no v1 equivalent. It
10+
is a **baseline** of the AST engine's output, laid out so the same table gains a second set of columns
11+
when the WALA L3 engine lands (#194) and the two engines are compared. The AST engine is source-only, so
12+
these numbers need no build: they are the exact structural output of the parser-driven passes.
13+
14+
## How to reproduce
15+
16+
```bash
17+
./gradlew fatJar
18+
JAR=build/libs/codeanalyzer-2.4.1.jar
19+
APP=src/test/resources/test-applications/commons-lang
20+
java -jar $JAR -i $APP -o output/commons-lang/l3 -a 3 --schema v2 --no-rta --no-build
21+
```
22+
23+
`--no-rta --no-build` because L3's AST engine needs neither a build nor the WALA RTA overlay; dependency
24+
resolution only affects type *names*, not `cfg`/`cdg`/`ddg` counts. `output/l3/run.sh` batches all ten
25+
apps and `output/l3/analyze.py` computes the figures below (`output/` is git-ignored).
26+
27+
## Per-app counts
28+
29+
The columns fall into three groups. **Structure:** `modules` / `types` / `callables`, and
30+
`callables w/ body` (those carrying a `cfg`; the difference from `callables` is the
31+
abstract/interface/native methods, which have no body). **Nodes:** `body nodes` — the total completed
32+
body-node count across all callables (`call` + `statement` + `return` + `branch` + `loop` + `switch` +
33+
`@entry`/`@exit`; kind breakdown below). **Edges** — each overlay is an edge list:
34+
35+
- `cfg edges` — control-flow edges between body nodes.
36+
- `cdg edges` — control-dependence edges.
37+
- `ddg edges` — data-dependence edges; each is **one intraprocedural def→use pair** for a k-limited
38+
access path (deduped by `(def-site, use-site, var)`), so this column is the du-pair count.
39+
40+
| app | modules | types | callables | callables w/ body | body nodes | cfg edges | cdg edges | ddg edges (du-pairs) |
41+
|---|---|---|---|---|---|---|---|---|
42+
| cargotracker | 112 | 115 | 661 | 553 | 4,743 | 2,297 | 470 | 814 |
43+
| commons-lang | 625 | 1,130 | 11,447 | 10,595 | 128,961 | 72,991 | 13,141 | 36,869 |
44+
| daytrader8 | 141 | 148 | 1,221 | 1,090 | 8,884 | 7,028 | 4,448 | 2,593 |
45+
| plantsbywebsphere | 36 | 37 | 481 | 463 | 3,261 | 2,672 | 1,561 | 1,153 |
46+
| spring-petclinic | 49 | 49 | 227 | 182 | 2,287 | 962 | 177 | 414 |
47+
| quarkuscoffeeshop-barista | 21 | 21 | 73 | 56 | 468 | 279 | 64 | 118 |
48+
| quarkuscoffeeshop-counter | 42 | 42 | 242 | 206 | 1,812 | 968 | 182 | 448 |
49+
| quarkuscoffeeshop-domain | 19 | 19 | 109 | 93 | 446 | 260 | 30 | 107 |
50+
| quarkuscoffeeshop-inventory | 19 | 19 | 105 | 93 | 542 | 364 | 67 | 134 |
51+
| quarkuscoffeeshop-kitchen | 17 | 17 | 59 | 45 | 365 | 223 | 56 | 98 |
52+
| **total** | **1,081** | **1,597** | **14,625** | **13,376** | **151,769** | **88,044** | **20,196** | **42,748** |
53+
54+
Node totals (`body nodes`) and edge totals (`cfg`/`cdg`/`ddg edges`) are the two things a WALA-engine
55+
run must be compared against kind-by-kind.
56+
57+
## Edge and node kinds (all apps)
58+
59+
**CFG edges by kind:** `fallthrough` 66,435 · `return` 7,323 · `false` 4,962 · `true` 4,957 ·
60+
`exception` 2,754 · `loop_back` 1,027 · `switch_case` 339 · `break` 197 · `continue` 50 (= 88,044).
61+
62+
**Body nodes by kind:** `call` 94,627 · `statement` 18,053 · `entry` 13,376 · `exit` 13,376 ·
63+
`return` 7,323 · `branch` 3,765 · `loop` 1,203 · `switch` 46 (= 151,769).
64+
65+
## Observations
66+
67+
- **Internal consistency holds at scale.** `@entry` = `@exit` = `with_cfg` = 13,376 (exactly one of each
68+
per callable with a body); the `return` body-node count equals the `return` CFG-edge count (7,323) —
69+
each `return` edges once to `@exit`; `true` and `false` counts track each other (4,957 / 4,962). The
70+
real-world conformance gate independently asserts well-formedness (single entry/exit, every node
71+
reachable) across four of these apps.
72+
- **`call` nodes dominate the body (62%).** They come from L1 (one per call site) and real code is
73+
call-dense; the L3 pass adds the 18,053 non-call `statement` nodes plus the branch/loop/switch tests
74+
and synthetic entry/exit. `cfg` edges are ~75% `fallthrough` — most control flow is straight-line.
75+
- **Data dependence is ~2× control dependence** (42,748 `ddg` vs 20,196 `cdg`), the usual shape; `ddg`
76+
is the syntactic, object-insensitive tier (`prov:["ssa"]`) — L4 will add the `points-to` overlay.
77+
- **`exception` edges (2,754)** come only from statements that syntactically throw (calls/allocations or
78+
`throw`) inside a `try`, routed to the enclosing catch/finally. `daytrader8` and `plantsbywebsphere`
79+
carry disproportionately high `cdg` (4,448 and 1,561) — deeper nesting and more guarded control flow.
80+
- **Output size is the one caution.** `commons-lang`'s level-3 `analysis.json` is ~222 MB (625 files,
81+
full statement bodies + three overlays); L3 amplifies the L1 payload substantially. This is the
82+
concern [#191](https://github.com/codellm-devkit/codeanalyzer-java/issues/191) tracks at L1, now more
83+
pronounced at L3.
84+
85+
## Extending to the WALA engine (#194)
86+
87+
When the WALA L3 engine lands, re-run the same batch with `--l3-engine wala` and add a second set of
88+
columns (and per-kind breakdowns) beside these. The expectations, per the design (§7.2):
89+
90+
- **Should match:** the CFG node set and reachability (both engines key nodes by AST `line:col`,
91+
including the single `finally` node — see §4.4.1), and `cdg`/`ddg` where the syntactic semantics
92+
coincide. Divergence in these is a bug in one engine.
93+
- **Expected to differ (pinned):** `exception`-edge *density* — WALA's bytecode catch-all lets any
94+
instruction throw into a handler/`finally`, whereas the AST engine edges only from statements that
95+
syntactically throw — and within-line attribution on multi-statement lines. These become the
96+
documented divergences the differential gate asserts as such.

0 commit comments

Comments
 (0)