Skip to content

feat: L1 emission in canonical schema v2 (opt-in via --schema v2) - #188

Open
sinha108 wants to merge 22 commits into
mainfrom
enhancement/issue-180-l1-v2-tree
Open

feat: L1 emission in canonical schema v2 (opt-in via --schema v2)#188
sinha108 wants to merge 22 commits into
mainfrom
enhancement/issue-180-l1-v2-tree

Conversation

@sinha108

Copy link
Copy Markdown
Collaborator

Implements L1 emission in the canonical schema v2: the additive CPG tree (application → module → type → callable → body) with can:// ids, byte-offset spans, and a per-module source blob.

v2 is opt-in via --schema v2; v1 remains the default, so no existing consumer is affected. Unsupported combinations (-a > 1, --emit neo4j, --source-analysis, --target-files, an unknown --schema value) exit non-zero with a clear message rather than silently emitting a different shape.

Delivered

  • Modular AST-driven builders (syntactic_analysis/) — one per node kind, sharing an L1BuildContext. Built from the JavaParser AST rather than mapped from the v1 entities, which cannot supply byte offsets or annotation arguments.
  • Type resolution via the symbol solver, including the project's library dependency jars, so third-party types resolve to qualified names (org.springframework.ui.Model, Page<…Owner>); reflection is restricted to the JRE so the analyzer's own dependencies are never mistaken for the project's. Signatures use erased resolved parameter types.
  • Full v1 information parity — comments, local variables, enum constants, record components, initializer blocks (as kind:"initializer" callables), field initializers, modifiers, declaration, code_start_line, is_implicit, entrypoint flags, and resolved call-site facts (receiver, argument types, callee signature, static/constructor flags).
  • Anonymous inner classes as type nodes ($anon$N), in callable bodies and field initializers.
  • callable.body_span, so source[body_span.bytes] reproduces v1's per-callable code byte for byte without duplicating text.
  • Incremental cache (-c/--cache-dir, --eager) keyed on content_hash, skipping the parse as well as the build: commons-lang 130s cold → 4s warm.
  • Conformance gate against a strict in-repo JSON Schema: in-repo fixtures on every run, plus four real-world applications via ./gradlew realWorldConformanceTest.

Verification

docs/design/notes/l1-v1-v2-comparison.md — twenty runs (ten real-world applications × both schemas), generated from the payloads so it cannot drift from the data.

v2 matches or exceeds v1 on every structural metric: modules 1081 = 1081, enum constants / record components / entrypoints exact, and more types (+233), callables (+256) and call sites (+416) — v1 dropped local classes and missed this(...)/super(...) chaining. Resolution is at parity (95.8% qualified) with callee signatures on 99% of call sites. All 1081 v1 symbol_table keys are absolute paths; v2 has none.

Of four negative deltas, none is a v2 defect: local variables (−11) and initializer blocks (−1) are v1 counting bugs v2 declines to reproduce; comments is mostly v1 duplication (2.09×) plus body-internal comments, which need L3's statement nodes; CRUD is deferred to #187.

Design decisions: .claude/SCHEMA_DECISIONS.md. Spec: docs/design/specs/schema-v2-l3-l4-design.md.

Not in this PR

Closes#180

…l 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.
…s_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.
)
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.
…tion (#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.
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).
…ag (#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.
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.
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.
…arison 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.
…, --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).
- 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.
@sinha108
sinha108 requested a review from rahlkAugust 19, 2026 21:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

codeanalyzer-java: L1 v2 tree emission

1 participant

@sinha108