Skip to content

Commit b3b46bd

Browse files
authored
feat: L1 emission in canonical schema v2 (opt-in via --schema v2) (#188)
* feat(schema): can:// id + byte-offset span utils for v2 (#180) * test: add real-world Java app fixtures as git submodules (#180) * feat(schema): v2 L1 module/type tree — AST-driven modular builders (#180) * build: exclude test-application fixtures from spotless formatting (#180) * feat(schema): nested member types via containment; drop nesting field (D4) (#180) * feat(schema): callable/field/param builders; call sites keyed by local id (#180) Key body nodes by bare local id (line:col) per the keystone, and emit call nodes for constructor invocations and this()/super() chaining so L2 can resolve those edges. * feat(schema): v2 JSON config, module imports/hash/span, field kind, is_variadic (#180) Add V2Json (snake_case keys, no nulls emitted), module span/imports/ content_hash, field kind discriminator and parameter is_variadic, with a serialization-contract test covering the emitted key names. * feat(schema): type/param modifiers, field initializer, declaration, code_start_line, comments (#180) * feat(schema): local variables on callable (#180) * feat(schema): resolve types via the JavaParser symbol solver at L1 (#180) Types, supertypes, error_channel and refs.types are now resolved to qualified names, and callable signatures use erased resolved parameter types, matching the v1 symbol table. Tests parse with a symbol solver so the resolution path is actually exercised; unresolvable types degrade to their AST spelling. * feat(schema): enum constants, record components, initializer-block callables (#180) * feat(schema): rich call-site facts on call nodes (receiver/arg types, callee signature, flags) (#180) * feat(schema): entrypoint flags, qualified field refs, broader type refs, AstScopes (#180) * feat(schema): L1 extractor — v2 modules from the project parse loop (#180) * feat(cli): --schema v2 emits the canonical envelope, with flag validation (#180) v2 is opt-in (v1 stays the default until the rest of the migration lands). Unsupported combinations (-a > 1, --emit neo4j, --source-analysis, --target-files, unknown --schema) fail with a clear non-zero error rather than silently emitting a different shape. stdout carries compact JSON only. * test: L1 conformance gate against the canonical v2 JSON Schema (#180) Adds the strict in-repo schema used as the L1 oracle until the SDK's v2 models exist, a gate over the in-repo fixtures in the default suite, and a realWorldConformanceTest task for the submodule applications (too slow for the inner loop, but required). * fix(schema): resolve library types at L1; omit unknown static-call flag (#180) L1 now downloads the project's dependencies and puts a JarTypeSolver on the solver path, so third-party types resolve to qualified names as they do in v1 (verified on spring-petclinic: Model, Pageable, Page<Owner>, and callee_signature on 99% of call sites). Reflection is restricted to the JRE so the analyzer's own dependencies can no longer be resolved as if the analysed project depended on them. is_static_call becomes a Boolean that is omitted when the callee is unresolved rather than reported as false. * test: drop the daytrader-microservices fixture (contains no Java source) The repo is deployment tooling (Makefile, docker-compose, helm charts) with zero .java files, so it cannot exercise the analyzer. Also ignores output/, used for ad-hoc v1/v2 comparison runs. * feat(schema): model anonymous classes; add callable body_span (#180) Anonymous class bodies now get their own type node ($anon$N) under the callable that declares them, like named local classes — v1 mis-attributed their initializers and locals to the enclosing type and the first v2 attempt dropped them. Re-measured, initializer blocks and local variables are back at parity with v1. body_span delimits the { ... } block so source[body_span.bytes] reproduces v1's per-callable code byte for byte without duplicating the text; the callable's own span covers the whole declaration. Pinned by a test that compares against the v1 emitter directly. * feat(schema): model anonymous classes in field initializers; add comparison report Anonymous classes occur in two places: inside a callable body and inside a field initializer, which belongs to no callable. The latter was missed, so commons-lang's AnnotationUtils lost the double-brace initializer configuring its ToStringStyle. Adds docs/design/notes/l1-v1-v2-comparison.md, generated from twenty runs (ten applications x both schemas). v2 matches or exceeds v1 on every structural metric; the two remaining negative deltas are v1 counting bugs (anonymous-class fields reported as method locals, nested initializer blocks counted twice) that v2 does not reproduce. * feat(cli): incremental L1 cache keyed on content_hash (-c/--cache-dir, --eager) Reuses modules whose files are byte-for-byte unchanged, skipping the parse as well as the build: commons-lang goes from 130s cold to 4s warm. Caching is opt-in, and the cache is discarded wholesale when the app name or analyzer version changes since both are baked into every can:// id. The extractor now enumerates and hashes files itself instead of parsing whole source roots up front; module discovery is unchanged (commons-lang still yields 625 modules, matching v1). * fix(schema): six correctness fixes from code review - Constructor callee_signature normalises to <init> so it joins against the target callable's signature; otherwise L2 drops every constructor edge (88 of petclinic's call sites). - Expression-type resolution no longer memoises failures by expression text: the same text can denote different types in different scopes of one file, so a failure blanked later resolvable occurrences. - metrics.cyclomatic is scope-filtered like every other callable fact, so branches inside a nested or anonymous class are no longer double-counted. - Call sites with no source range are skipped rather than colliding on 0:0 and silently overwriting one another. - Module span end position is computed for universal newlines and for files with no trailing newline. - Corrected the byteOffsets javadoc: the range is end-exclusive. * fix(schema): emit dropped constructs; restore v1 call-site facts Constructs L1 was silently dropping: - compact constructors (CompactConstructorDeclaration is not a CallableDeclaration; signature comes from the record components) - enum-constant class bodies, as $enum$<NAME> types whose overrides are their own callables - nested anonymous classes in field initializers were double-emitted - partial parses are now announced instead of accepted silently Lapsed v1 parity on call sites: method_name, return_type, comment, and accessibility as a single enum replacing v1's four booleans (which conflated unknown with package-private). Initializer error_channel now reaches throws nested inside catch blocks. Also: per-parameter signature degradation instead of all-or-nothing, drop the redundant code_start_line, fix stale javadocs. * feat(schema): model type parameters on generic types and callables A generic signature was not reconstructable from the emitted facts: a parameter declared `T` resolves to the bare spelling `T` (a type variable has no qualified name), and `declaration` omits the clause because JavaParser's getDeclarationAsString does. So `type: "T"` appeared with no record of what T ranges over. type_parameters on `type` and `callable` carries the name, resolved bounds, span and annotations, in declaration order (a type argument binds by position). An unbounded parameter gets no bounds rather than a fabricated java.lang.Object. Genericity is keyed off NodeWithTypeParameters, so enums, annotation types, anonymous classes and enum-constant bodies are excluded by the language rule rather than an instanceof chain. Purely additive: signatures, ids and call-site joins are untouched. * fix(schema): fully erase signatures; synthesize implicit constructors Erasure was one level deep, so a type variable's bound kept its own type arguments: `<T extends Consumer<?>> copy(T...)` was keyed `copy(java.util.function.Consumer<?>[])`, and `<T extends Comparable<T>>` leaked `T` into a durable name. Iterate to a fixpoint. Also repairs v1, whose WALA join compares these keys against fully-erased ASM descriptors. 55 of 4688 callables change across the fixtures, all in commons-lang. L1 now emits the constructors the language guarantees but the source never writes, so a `new Foo()` site names a callable instead of a dangling id: classes and enums declaring none, a record's canonical constructor, and an anonymous class's generated one. Identity only. Enum constant bodies are excluded: which constructor a constant invokes is not recoverable, and a constant is not a call site.
1 parent e2cc4e3 commit b3b46bd

70 files changed

Lines changed: 6106 additions & 40 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/SCHEMA_DECISIONS.md

Lines changed: 227 additions & 4 deletions
Large diffs are not rendered by default.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,6 @@ gradle-app.setting
196196
bin/
197197
etc/
198198
/src/test/resources/sample_apps/daytrader8/output/
199+
200+
# Ad-hoc analysis output from manual v1/v2 comparison runs
201+
output/

.gitmodules

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[submodule "src/test/resources/test-applications/spring-petclinic"]
2+
path = src/test/resources/test-applications/spring-petclinic
3+
url = https://github.com/spring-projects/spring-petclinic.git
4+
[submodule "src/test/resources/test-applications/cargotracker"]
5+
path = src/test/resources/test-applications/cargotracker
6+
url = https://github.com/eclipse-ee4j/cargotracker.git
7+
[submodule "src/test/resources/test-applications/commons-lang"]
8+
path = src/test/resources/test-applications/commons-lang
9+
url = https://github.com/apache/commons-lang.git
10+
[submodule "src/test/resources/test-applications/quarkuscoffeeshop-counter"]
11+
path = src/test/resources/test-applications/quarkuscoffeeshop-counter
12+
url = https://github.com/quarkuscoffeeshop/quarkuscoffeeshop-counter.git
13+
[submodule "src/test/resources/test-applications/quarkuscoffeeshop-barista"]
14+
path = src/test/resources/test-applications/quarkuscoffeeshop-barista
15+
url = https://github.com/quarkuscoffeeshop/quarkuscoffeeshop-barista.git
16+
[submodule "src/test/resources/test-applications/quarkuscoffeeshop-kitchen"]
17+
path = src/test/resources/test-applications/quarkuscoffeeshop-kitchen
18+
url = https://github.com/quarkuscoffeeshop/quarkuscoffeeshop-kitchen.git
19+
[submodule "src/test/resources/test-applications/quarkuscoffeeshop-inventory"]
20+
path = src/test/resources/test-applications/quarkuscoffeeshop-inventory
21+
url = https://github.com/quarkuscoffeeshop/quarkuscoffeeshop-inventory.git
22+
[submodule "src/test/resources/test-applications/quarkuscoffeeshop-domain"]
23+
path = src/test/resources/test-applications/quarkuscoffeeshop-domain
24+
url = https://github.com/quarkuscoffeeshop/quarkuscoffeeshop-domain.git

build.gradle

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,21 +142,48 @@ dependencies {
142142
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.1'
143143

144144
// SLF4J - for TestContainers logging
145+
// Validates emitted analysis.json against the canonical v2 JSON Schema (the L1 conformance oracle
146+
// until the SDK's v2 models exist).
147+
testImplementation 'com.networknt:json-schema-validator:1.5.1'
145148
testImplementation 'org.slf4j:slf4j-api:2.0.9'
146149
testImplementation 'org.slf4j:slf4j-simple:2.0.9'
147150
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
148151

149152
}
150153

151154
test {
152-
useJUnitPlatform()
155+
useJUnitPlatform {
156+
// Whole-application conformance runs take minutes (full symbol resolution over real projects),
157+
// so they are opt-in via `realWorldConformanceTest` rather than part of the inner loop.
158+
excludeTags 'realworld'
159+
}
153160
// Optional: Enable TestContainers reuse to speed up tests
154161
systemProperty 'testcontainers.reuse.enable', 'true'
155162
}
156163

164+
// The L1 conformance gate over the real-world fixture applications (git submodules).
165+
tasks.register('realWorldConformanceTest', Test) {
166+
description = 'Runs the L1 conformance gate over the real-world fixture applications.'
167+
group = 'verification'
168+
testClassesDirs = sourceSets.test.output.classesDirs
169+
classpath = sourceSets.test.runtimeClasspath
170+
useJUnitPlatform {
171+
includeTags 'realworld'
172+
}
173+
// These projects are large; give the JVM room and do not let a slow app fail the run spuriously.
174+
maxHeapSize = '4g'
175+
testLogging {
176+
events 'passed', 'failed', 'skipped'
177+
showStandardStreams = false
178+
}
179+
}
180+
157181
spotless {
158182
java {
159-
target 'src/**/*.java'
183+
// Format only the analyzer's own sources. Test-application fixtures under
184+
// src/test/resources (vendored apps and git submodules) are third-party inputs and must
185+
// not be reformatted — doing so mutates test inputs and dirties submodule working trees.
186+
target 'src/main/java/**/*.java', 'src/test/java/**/*.java'
160187
trimTrailingWhitespace()
161188
endWithNewline()
162189
importOrder()
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# L1 output comparison: legacy v1 schema vs canonical schema v2
2+
3+
Generated 2026-08-19 from `codeanalyzer-2.4.1`. Each of ten real-world fixture applications was analysed
4+
twice — once with the default (v1) emitter, once with `--schema v2` — and the payloads diffed field by
5+
field. The purpose is to catch silent information loss in the migration: every metric where v2 records
6+
less than v1 is either explained or fixed.
7+
8+
## How to reproduce
9+
10+
```bash
11+
./gradlew fatJar
12+
JAR=build/libs/codeanalyzer-2.4.1.jar
13+
APP=src/test/resources/test-applications/spring-petclinic
14+
java -jar $JAR -i $APP -o output/spring-petclinic/v1 -a 1 # legacy
15+
java -jar $JAR -i $APP -o output/spring-petclinic/v2 --schema v2 # canonical
16+
```
17+
18+
Payloads land in `output/<app>/<schema>/analysis.json` (`output/` is git-ignored). The figures in this
19+
document are generated from those files, so it cannot drift from the data.
20+
21+
## Runs
22+
23+
All twenty runs exited 0 and left the fixture submodules clean.
24+
25+
| Application | v1 time | v2 time | v1 size | v2 size |
26+
| --- | --- | --- | --- | --- |
27+
| `spring-petclinic` | 5s | 4s | 2.4M | 2.9M |
28+
| `cargotracker` | 17s | 4s | 4.7M | 5.8M |
29+
| `commons-lang` | 623s | 148s | 128M | 142M |
30+
| `quarkuscoffeeshop-counter` | 3s | 3s | 1.5M | 1.9M |
31+
| `quarkuscoffeeshop-barista` | 3s | 2s | 425K | 575K |
32+
| `quarkuscoffeeshop-kitchen` | 2s | 2s | 342K | 460K |
33+
| `quarkuscoffeeshop-inventory` | 3s | 3s | 415K | 581K |
34+
| `quarkuscoffeeshop-domain` | 2s | 2s | 343K | 491K |
35+
| `daytrader8` | 5s | 4s | 8.5M | 10M |
36+
| `plantsbywebsphere` | 3s | 2s | 3.0M | 3.7M |
37+
38+
**v2 is consistently faster.** It never builds per-callable `code` strings, so it never invokes
39+
JavaParser's `LexicalPreservingPrinter` — the dominant cost on large projects. (A second-order effect:
40+
each v2 run reused dependency jars the preceding v1 run had already downloaded.)
41+
42+
**Incremental caching** (`-c/--cache-dir`) reuses modules whose files are byte-for-byte unchanged,
43+
skipping both the parse and the build: a second `commons-lang` run drops from 130s to 4s. The timings
44+
above are all cold runs, so they measure the emitters rather than the cache.
45+
46+
**v2 payloads are somewhat larger** even though per-callable `code` is gone: source text is stored once
47+
per module rather than duplicated per callable, but that saving is outweighed by spans on every node
48+
(`start`/`end`/`bytes`), per-node comments, local variables, and the resolved call-site facts.
49+
50+
## Totals across all ten applications
51+
52+
| Metric | v1 | v2 | Delta |
53+
| --- | --- | --- | --- |
54+
| modules | 1081 | 1081 | +0 |
55+
| types | 1581 | 1814 | +233 |
56+
| callables | 13594 | 13850 | +256 |
57+
| fields | 3727 | 3761 | +34 |
58+
| parameters | 9877 | 10077 | +200 |
59+
| call sites | 94501 | 94917 | +416 |
60+
| local variables | 11650 | 11639 | -11 * |
61+
| comment entries | 39170 | 9710 | -29460 * |
62+
| enum constants | 338 | 338 | +0 |
63+
| record components | 2 | 2 | +0 |
64+
| initializer blocks | 30 | 29 | -1 * |
65+
| entrypoint types | 95 | 95 | +0 |
66+
| entrypoint callables | 258 | 258 | +0 |
67+
| CRUD facts | 107 | 0 | -107 * |
68+
69+
\* explained below. Of the four, two (`local variables`, `initializer blocks`) turn out to be v1
70+
over-counting rather than v2 losses; one (`comment entries`) is mostly v1 duplication with a small real
71+
gap; and one (`CRUD facts`) is deliberately deferred.
72+
73+
**Type resolution is at parity:** 95.7% of v1 parameter types and 95.8% of v2 parameter types are
74+
fully qualified. v2 additionally resolves a callee signature on 94152 of 94917 call sites (99%), which v1
75+
recorded only on its separate `call_sites` entries.
76+
77+
**Identity:** all 1081 v1 `symbol_table` keys are absolute filesystem paths; v2 has 0 absolute keys —
78+
every key is project-relative, which the canonical schema requires for stable caching and SDK lookups.
79+
80+
**Anonymous classes:** 215 are modelled as their own `type` nodes across the ten applications.
81+
**Body text:** 13589 callables carry a `body_span`.
82+
83+
## Per-application detail
84+
85+
Metrics where the two schemas differ, per application. Blank means exact parity.
86+
87+
| Application | types | callables | call sites | locals | initializers | comments | CRUD |
88+
| --- | --- | --- | --- | --- | --- | --- | --- |
89+
| `spring-petclinic` | +3 | +2 | | | | -225 | |
90+
| `cargotracker` | +2 | +4 | +78 | +3 | | -501 | -77 |
91+
| `commons-lang` | +218 | +237 | +296 | -14 | -1 | -25342 | |
92+
| `quarkuscoffeeshop-counter` | +3 | +3 | +3 | | | -71 | |
93+
| `quarkuscoffeeshop-barista` | +1 | +1 | +2 | | | -16 | |
94+
| `quarkuscoffeeshop-kitchen` | +1 | +1 | +3 | | | -65 | |
95+
| `quarkuscoffeeshop-inventory` | +1 | +1 | +3 | | | -23 | |
96+
| `quarkuscoffeeshop-domain` | | | +12 | | | -6 | |
97+
| `daytrader8` | +4 | +7 | +17 | | | -2244 | -30 |
98+
| `plantsbywebsphere` | | | +2 | | | -967 | |
99+
100+
## Where v2 recovers more than v1
101+
102+
- **Types, callables and call sites.** v1 keyed its flat type map by fully-qualified name and skipped
103+
declarations without one, so **local classes declared inside method bodies were dropped entirely**;
104+
v2 nests them under the enclosing callable. v1's call-site scan also missed **explicit constructor
105+
chaining** (`this(...)` / `super(...)`), which v2 emits as `call` nodes so L2 can resolve those edges.
106+
- **Anonymous inner classes** are modelled as `type` nodes (`$anon$0`, `$anon$1`, … in declaration
107+
order) under the callable that declares them, so their methods, initializers, locals and call sites
108+
are attributed to them. v1 recursed into anonymous bodies and mis-attributed those facts to the
109+
*enclosing type*.
110+
- **Resolved call-site facts** — callee signature, receiver expression and type, argument types — sit on
111+
the body `call` nodes.
112+
- **Structured annotation arguments.** v1 stored annotations as flat strings (`@RequestMapping("/x")`);
113+
v2 records `{name, args[], span}`, so routes and column names are machine-readable without re-parsing.
114+
115+
## Where v2 records less, and why
116+
117+
### Comment entries (-29460): v1 double-counting, plus one real gap
118+
119+
v1 filled every node's `comments` with `getAllContainedComments()`, so a comment inside a method was
120+
also listed on that method's type and on the compilation unit. On `spring-petclinic` v1 emits 341
121+
comment entries of which only **163 are distinct** (a 2.09x duplication factor); v2 emits 116, each
122+
attached to exactly one node.
123+
124+
The remaining ~47 distinct comments v2 does not carry are **comments inside method bodies**, which
125+
have no declaration to attach to. They stay recoverable from `module.source`, and they belong on the
126+
statement nodes that arrive at L3 — but today they are absent from the tree. This is the one
127+
outstanding information gap.
128+
129+
### Local variables (-11) and initializer blocks (-1): v1 over-counting
130+
131+
Both remaining deltas are **v1 defects**, not v2 losses — v2 is the more accurate of the two.
132+
133+
*Locals.* v1 collected a callable's locals with a recursive `findAll(VariableDeclarator)`, which also
134+
matches **field declarations inside anonymous classes**. In `AtomicInitializerObjectTest`:
135+
136+
```java
137+
final AtomicInitializer<Object> initializer = new AtomicInitializer<Object>() {
138+
final AtomicBoolean firstRun = new AtomicBoolean(true); // a field of the anonymous class
139+
...
140+
};
141+
```
142+
143+
v1 reports the enclosing method's locals as `[initializer, firstRun]`, promoting the anonymous class's
144+
field to a method local. v2 reports `[initializer]` and records `firstRun` under
145+
`$anon$0.fields`, where it belongs. Every one of the remaining local-variable differences is this
146+
pattern.
147+
148+
*Initializer blocks.* v1 populated a type's `initialization_blocks` with a recursive `findAll`, so a
149+
`static { ... }` block in a nested class was counted **twice**: once on the nested class and again on
150+
its enclosing type. `LocaleUtils` shows this — v1 reports one block on `LocaleUtils` and one on
151+
`LocaleUtils.SyncAvoid`, though only `SyncAvoid` has a block. v2 counts it once, on `SyncAvoid`.
152+
153+
### CRUD facts (-107): tracked separately
154+
155+
v2 carries no CRUD data yet. This is deliberate and tracked in codeanalyzer-java issue 187, which also
156+
covers the Neo4j `JCrudOperation`/`JCrudQuery` families that the graph projection needs.
157+
158+
## Body text: v1 `code` versus a v2 slice
159+
160+
v2 has no per-callable `code` string — body text is a slice of `module.source`. That equivalence needs
161+
care, because a callable's own `span` covers the **whole declaration** (modifiers, signature and body),
162+
whereas v1's `code` was the `{ … }` **block alone**. `callable.body_span` delimits the block, so:
163+
164+
```
165+
source[body_span.bytes] == v1 callable.code (byte for byte)
166+
source[span.bytes] == declaration + body
167+
```
168+
169+
A test compares the two emitters directly on the same source for methods, constructors and initializer
170+
blocks, so this cannot regress silently. Note that the canonical schema defines `get_method_body(sig)`
171+
as `module.source[callable.span.bytes]`, which is *not* v1's `code` semantics — a discrepancy worth
172+
resolving upstream.
173+
174+
## Outstanding follow-ups
175+
176+
1. **Attach body-internal comments** to the statement nodes introduced at L3.
177+
2. **CRUD enrichment** — codeanalyzer-java issue 187.
178+
3. Consider a more compact span encoding if payload size becomes a concern.
179+

0 commit comments

Comments
 (0)