Skip to content

feat(java): leg 3b — the query surface for Java, addressing through interprocedural flow - #358

Merged
rahlk merged 49 commits into
release/2.0from
feat/issue-311-java-query-surface
Sep 7, 2026
Merged

feat(java): leg 3b — the query surface for Java, addressing through interprocedural flow#358
rahlk merged 49 commits into
release/2.0from
feat/issue-311-java-query-surface

Conversation

@rahlk

@rahlk rahlk commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #311.

Leg 3b of the CLDK 2.0 agent-facing query facade: the leg-1.5/1.6 query surface for Java, over codeanalyzer-java 3.0.2. Spec docs/design/specs/2026-09-06-leg-3-java.md (decisions J-1..J-17 and its errata), plan docs/design/plans/2026-09-06-leg-3b-java-query-surface.md.

What it adds

Thirty-eight accessors in Python's own signatures: the addressing vocabulary (locate, locate_many, resolve_callable, resolve_value, get_source, describe), the per-callable graphs with keyset pagination, slices and cones, the call-graph and value-flow predicates, the entrypoint and policy accessors, and the leaf accessors. Both backends answer identically, including on the miss paths — asserted programmatically rather than by inspection.

Addressing

A callable is addressed by <type fqn>.<signature> (J-1). Names resolve by simple name, by @-prefixed annotation, or by full signature; overloads raise with the candidates listed rather than guessing. There is no fuzzy matching anywhere, including in error paths — an ambiguity's advice names only spellings that genuinely matched, and drops a keyword that cannot split the matches it lists.

Where it refuses instead of answering

Four interprocedural value accessors — slice_forward, paths_between, flows_to_call, flows_to_argument — raise on a Java graph while the analyzer's port lattice carries no dependence edge. The refusal is measured from the data, never from an analyzer version: codeanalyzer-java emitted the port vertices and the lattice between them but no ddg edge touching a port, so every one of those four would return the same answer whatever the program does, which is indistinguishable from a proved absence of flow.

This lifts by itself on codeanalyzer-java 3.0.3 (upstream #227 / #230), verified with no SDK change: the probe flips, slice_forward returns 45 nodes where it refused, and paths_between returns a 21-hop cross-callable path alternating data, argument, return and control. #354 carries the pin bump and the fixture regeneration. slice_backward, get_ddg and the whole call-graph half are unaffected and are not gated.

CRUD accessors raise pending codeanalyzer-java#187; get_external_symbols raises on the in-process backend, which never passes --external-calls, and answers over Neo4j, which always does.

Review findings fixed in this branch

Three independent adversarial reviews ran against the completed leg; all ten code findings and all ten documentation findings are fixed here, each with a test that fails without it. The ones worth calling out:

  • A located call site said what it resolves to over Neo4j too. body.callee was unconditionally None on the graph backend and populated in process, so every located call site read as unresolved — while has_resolution_edges returned True, which is the flag that tells a caller those nulls are per-site. The offline harness emitted the same three columns as the real query, so it could not have caught it.
  • Implicit callables refuse a graph, rather than answering an empty one. get_cfg/get_cdg/get_ddg returned total=0, complete=True for the 99 implicit constructors on daytrader8 — indistinguishable from "this callable has no control flow". One guard, reached by both backends and by resolve_value, so the slice and flow half is covered at the root.
  • Two variable-length statements scoped their endpoints and not their walk interior, and the audit written for exactly that class filtered on allShortestPaths and so skipped both.
  • reaches(x, x) was a backend divergence, not just wrong advice — both graph backends already answered the cycle question, all three in-memory ones excluded the source. One shared implementation now.

Published numbers were re-measured rather than trusted: get_method_bodies returns 1,117 (not 1,115, and the two <clinit>$N() initializers are present, not omitted), and two fixture-README figures had been measured with a walk that skips nested, local and anonymous types — the same walk that produced the callable count the spec already retracted.

Verification

Full release gate green: 1521 passed, 354 skipped, 84.91% coverage. Java live suite green against a graph holding two applications side by side, which is the live half of the multi-application scope audit. No test is flaky across repeated runs, including in randomized order.

Stack

#342 (leg 2.5a) → #357 (leg 2.5b) → #347 (leg 3a) → this. Each targets release/2.0 and contains its predecessors, so merge in that order. This branch has been rebased onto 2.5b, so its tree is the reconciled one if any merge conflicts appear.

rahlk and others added 30 commits September 7, 2026 06:23
…test (#337)

The inject step downloaded codeanalyzer-java's latest release regardless of the
pin in pyproject.toml, so 2.0.0rc2 shipped both codeanalyzer-2.4.1.jar (checked
in) and codeanalyzer-3.0.1.jar (injected); JCodeanalyzer._locate_jar picked
2.4.1 only by lexical order.

Read the pin, fetch codeanalyzer-<pin>.jar from releases/tags/v<pin>, replace
whatever is under jar/, and make the bundle check require exactly that one jar
in the wheel and the sdist. A pin without a matching asset now fails the
release instead of silently bundling something else.

Closes #336
Decisions J-1..J-12: string call-graph node keys, path key + package
dotted vocabulary, name-or-signature callable resolution with overloads
raising, CRUD raising on v2 until codeanalyzer-java #187, annotation
markers, addressability of initializers/implicit/anonymous callables,
leaf accessors, rich v1 fields as computed views, floor 3.0.1 with a
generation-aware probe, source_code removal, two work items (#310 = 3a,
#311 = 3b) in 2.0.0-rc.4.
Generated by the released codeanalyzer-3.0.1.jar on Temurin 21.0.5+11, never
hand-edited. a1 is the whole application at -a 1 (138 units). a4 is -a 4 for
the two beans, TradeDirect and TradeServletAction; --schema v2 rejects -t, so
the restriction is a source-pruned copy of the tree analyzed with --no-build.
The README carries the exact command lines and the measured contents.
….3.7 ones

analysis_json now yields the v2/a1 (L1, whole application) text and a new
analysis_json_a4 fixture yields v2/a4 (L4, four units). The two 2.3.7
fixtures go with them. Expected red until T1 lands the v2 models:
83 failed, 52 passed, 14 skipped -- every one the v1 JApplication refusing
the v2 envelope (symbol_table Field required). Plan Task 0 ticked with the
measured floors and the deviations (no -t at --schema v2; fixture sizes).
The analyzer pretty-prints; the raw a1 + a4 pair is 18.7 MB. gzip -9 brings
them to 1.3 MB with no loss, and conftest reads them with gzip.open.
…es kept as views

cldk/models/java is now an extra="forbid" pydantic mirror of the 3.0.1
envelope (JAnalysis -> JApplication -> JCompilationUnit -> JType ->
JCallable, with body nodes, cfg/cdg/ddg/summary edges, param edges,
artifacts and dependencies). The v1 field names callers read stay as
computed views: code is the span slice of the unit source, call_sites are
built from the call body nodes, thrown_exceptions/cyclomatic_complexity/
variable_declarations/referenced_types/accessed_fields read error_channel/
metrics/local_variables/refs, the JType predicates read kind and the
owner chain threaded in model_post_init. JGraphEdges is now the wire
JCallGraphEdge; the module-global callable lookup table is gone.

Both 3.0.1 fixtures round-trip byte-equal through model_dump(exclude_unset,
by_alias). tests/analysis/java is red at the backend sites until Task 2.
- code slices by UTF-8 byte offsets (span.bytes are getBytes(UTF_8) prefix
  sums), decoded; plain-index fast path for ASCII units
- JCallable.code is the v1 value: the body block (body_span), falling back
  to span only without a body
- node identity is the id on JType/JCallable/JField/JCompilationUnit so
  parses compare without walking the owner back-references
- nested_type_declarations returns qualified names; annotations return the
  v1 source spelling (@name(args))
- a spanned node never threaded into a unit raises instead of returning
  ""; file_path raises when no JApplication stamped it
- JBodyNode is spanned; one helper each for decorator names / first comment
…3.0.1, string call-graph keys, source_code removed

JavaAnalysisBackend now parameterises AnalysisBackend[JApplication, JCompilationUnit,
JType, JCallable, JField, JCallableParameter] with P = N = "J", so a query added to the
shared shape has to land in Java too or the backends stop instantiating. JCodeanalyzer
drives 3.0.1 at L1-L4 (the level reaches the analyzer as -a via analyzer_level, so 3 and 4
are newly requestable for Java), validates the schema-v2 envelope with
JAnalysis.model_validate_json, and keeps it on .analysis alongside .application.

Two sanctioned public shifts (spec leg 3):

- J-1: get_call_graph() nodes are "<type fqn>.<signature>" strings, not (signature, klass)
  tuples. Node attrs method_detail/kind; edge attrs type/weight/calling_lines unchanged.
  Nested and local classes are spelled in source form off JType.qualified_name
  (Outer.Inner, Outer.$anon$0). @external/ endpoints are dropped, keeping 1.x's
  callable-only graph; get_external_symbols arrives in 3b. An endpoint homed on no
  callable raises naming it rather than becoming a node keyed by a raw id.
- J-10: source_code single-file mode is removed from JavaAnalysis, CLDK.java, the compat
  shim's Java branch and JCodeanalyzer. get_test_methods reads every unit's source;
  remove_all_comments raises, which is the honest v2 behaviour for the one accessor that
  only ever worked in source mode. The ten skip-gated witnesses retire.

Also: CRUD accessors raise from one module-level constant on both backends, naming
codeanalyzer-java#187 (J-4); a cached analysis.json without schema_version is refused with
a re-run message and one below the requested max_level triggers a re-run (J-9, local half);
the artifact/config five return the shared Py* models, with get_config_uses and
get_unresolved_config_reads empty because the Java wire carries neither; JGraphEdgesST is
deleted with its importers and get_system_dependency_graph returns the wire call graph;
get_method_parameters is annotated List[JCallableParameter], which is what it always
returned. The public surface is frozen by test, derived from 1375b55 minus source_code.

The pin moves to 3.0.1 but no jar is committed: python-sdk#339 removes committed jars in
this leg, so _locate_jar gains a documented test/dev seam, $CLDK_CODEANALYZER_JAVA_JAR, and
the e2e runs the release jar through it. The jar the e2e exercised is
codeanalyzer-3.0.1.jar, sha256 c4e7d4b0b3aa4750d6783b370b6b2aea011df6607d9ee4dd6319cd776ab7a4dd.

JNeo4jBackend stays abstract until Task 3 implements the artifact five; its 17 red tests
are that one cause, as TSNeo4jBackend's were at the equivalent TypeScript step.
…scoped by can:// prefix, with a schema probe

Re-points every statement `JNeo4jBackend` issues at what 3.0.1 projects, and
refuses at attach anything else. The anchor is `:JApplication {name}`; scope is
the single id prefix `can://java/<app>/` from one helper, `_scoped`, so the
spelling cannot drift; `_module`, `J_HAS_UNIT`, `:JCompilationUnit`,
`J_HAS_CALLABLE`, `:JParameter`, `:JCallSite`, `:JComment` and the CRUD labels
are gone (the last six still have index definitions on a 3.0.1 database and
hold no nodes, which is why reading them was a silent empty, not an error).

Eight statements rebuild the canonical JApplication: one anchored module fetch,
one `*0..` containment traversal that replaces every per-parent fetch, one
prefix-scoped call-site fetch, one anchored import fetch, one doubly-scoped
J_CALLS fetch and the anchored artifact/dependency pair. Every accessor then
answers from the same models the in-memory backend walks, so the two agree by
construction rather than by two implementations of the same query.

Seek labels are measured, not assumed (table in the plan): every keyed label
already owns a uniqueness constraint on `id`, so each statement anchors on the
bare specific label and `:JCanNode` is used nowhere -- it loses the
whole-application prefix (118 ms against 24) and the point lookup (1.40 against
1.13 on `:JSymbol`), and wins only a per-module prefix, which no statement here
issues.

Two accessors raise instead of answering, because the projection cannot serve
them and an empty list would read as an answer: get_all_comments and
get_comment_in_file (there are no comment nodes at all -- a declaration keeps
only its javadoc, in a `docstring` property, and a file-level comment is not
projected). get_method_parameters does *not* raise: `parameters_json` is the
analyzer's own serialisation of the list, so parameters round-trip exactly.

Live parity on daytrader8 with ThingsBoard in the same database: identical
symbol-table keys (138), classes (149), JType/JField/JCallable fields,
parameters, call sites (4,006), wire call graph (1,862 edges, prov and weight
included), nx node/edge sets, callers/callees and class call graphs in both
modes, entrypoints (66/133), artifacts (235), dependencies (4), config keys
(336). The projection forces external calls -- 10,705 J_CALLS out of daytrader8
callables, 8,843 to `:JExternal` -- and 3a keeps the 1.x callable-only graph, so
1,862 remain, exactly the JSON's count. Documented tolerances, each measured:
a module's `source` is not projected; `code` is the declaration slice, not the
body block, so it ends with the reference's on all 1,117 callables with a body
and `calling_lines` shift on 563 of 1,862 edges; order within one source line is
not recoverable for fields (1 class), local variables (12 callables) or
annotations (30 declarations); imports are aggregated per target; docstrings are
declaration-level, not file-level; `param_in`/`param_out` and the L3/L4 graphs
are not rebuilt in 3a.
…est span sentinels, and review corrections

A local or anonymous class's `JType.qualified_name` now carries the signature of
the callable that declares it -- `p.Outer.m(int).$anon$0`, mirroring the id
grammar the analyzer already writes -- and with it every `get_class(...)`,
`get_all_classes()` and `get_call_graph()` node key on both backends. `$anon$N`
is numbered per declaring callable, so without that segment two sibling
callables of one type both spell `$anon$0`: on ThingsBoard 97 colliding names
shadowing 381 of 5,102 type declarations, and on `JNeo4jBackend` the duplicate
made the first index-routed accessor raise -- the whole backend was dead on the
scale corpus, while daytrader8's four non-colliding local classes hid it. Fixed
once on the model, so both indexes inherit it and neither is patched. Member
(nested) types are unchanged: no collision in the 97 involves only
type-declared anons, because those share a per-type counter. Recorded as the
J-1 erratum in the spec and in CHANGELOG.md with a migration; witnessed offline
by a hand-built payload driven through both backends, and live by a new
ThingsBoard suite.

Spans stop reporting a plausible wrong number. The projection carries
`start_line`/`end_line` and nothing else, so every reconstructed column and
both byte offsets are the model's own -1 on types, callables, fields, local
variables, call sites and units -- the `0` columns and the
`bytes=(0, len(code))` offset into a `source` that is `""` are gone. Frozen by
assertion in the type, callable and field parity tests.

J-16, a new spec decision that overrides Task 3's ruling: the two *file-keyed*
comment accessors keep the raise (the graph holds nothing file-level, so any
answer is fabricated), the three *declaration-keyed* ones keep the javadoc-only
subset -- and the split is now stated where a caller reads it, with a `Raises:`
clause on the ABC and the narrowing documented on the ABC and the facade both.
`get_all_docstrings` also harvests enum-constant and record-component javadoc,
which `reconstruct` had always rebuilt and the loop had always skipped
(ThingsBoard: 1,248 constants, 29 documented; daytrader8 has none, so the
parity test could not see it).

The Neo4j module docstring's seek rationale was factually wrong -- `:JCallable`
owns no id index at all, only a range index on `name` and a fulltext, so bare
`:JCallable` plans a label scan. The conclusion survives re-measurement and
stands; the argument is now the one the measurements support: the two
prefix-scoped statements are traversal-dominated (under 1% wall clock either
way, `:JCanNode` a quarter again the db hits) and everything else walks out
from the `:JApplication` anchor.

The multi-application audit was accepting a statement that scopes one endpoint
of two, and was blind to any statement not passed to `self._run(`. It now parses
the node variables out of each pattern and requires every one to be prefixed or
reachable from the application anchor over a containment or shared-vocabulary
relationship (`J_CALLS` is deliberately neither), with three negative and three
positive cases as its own net; and it allow-lists every attribute touched on
`self._driver` / `self._session_obj`, so `execute_query` cannot slip past.

Also: `application` is a read-only property over a private `_application` cache,
because rebinding it left `_idx` and `_call_graph` stale; `reconstruct` reads
`props["source"]`, `edge["spec"]` and `edge["kind"]` rather than defaulting
(`"runtime"` would have silently reclassified every dependency); `_facet` is
deleted (its result was discarded, its `"callable"` branch unreachable, and
`JType.kind` is already a `Literal`); the two backends' twin defect messages are
one shared id-free text each and `_node_of` names the endpoint by signature and
module key; the `code`/`body` divergence is documented on the model, the ABC and
the facade; the probe's substring assertions are replaced by behaviour, the seek
test renamed to say it greps a ruling rather than measuring one, and the dead
`_MATCHES_BY_ID` branch removed.

Five measured numbers corrected in the plan and the docstrings that quoted them:
body-less callables are 99, not 129 (99 + 1,117 = 1,216); local-variable order
differs on 6 callables, not 12; the body-key column delta reaches 110 with a
mode of 4, not "up to 12"; attach plus first use is nine round trips (3 + 6),
not eight statements. `calling_lines` re-measures at 1,299 of 1,862 -- the plan
was right -- and the shared-contract line's `26 passed` was the correct total of
the three files it named; both are now labelled rather than dropped.

Java offline: 304 passed, 29 skipped (from 294/25 -- +10 tests, +4 live-gated
skips). Live parity on 7691 with ThingsBoard in the same database: 19 passed.
New ThingsBoard suite: 4 passed.
…anctioned shifts

CHANGELOG [Unreleased] gains the Java block: the 2.4.1 -> 3.0.1 pin with the
extra="forbid" v2 model mirror and the refusal of a v1 cache; analysis levels 3
and 4 becoming reachable; the two sanctioned breaks (string call-graph node keys
with a migration that survives dotted parameter types and local classes, and the
removal of source_code single-file mode); JGraphEdges becoming JCallGraphEdge;
the CRUD raise (codeanalyzer-java#187); the J-16 comment split; the declaration
vs body-block divergence of JCallable.code over Neo4j (codeanalyzer-java#176);
the 3.0.1 graph vocabulary and probe floor; the generic-ABC artifact accessors;
and the get_method_parameters annotation fix.

docs/agent-api-reference.md gains a Java section: what attaches today, the 3.0.1
floor as an attach table, the accessors that still raise, and the note that the
leg-1.5/1.6 query surface reaches Java in 3b. CLAUDE.md's Java row records the
level range, the probed graph and the jar that #339 still has to move.

The leg-3 spec gains a J-8 erratum: 3.0.1 emits declaration for every callable
that has one (1,115 of daytrader8's 1,216); it is None only on the 99 implicit
callables and the two <clinit>$N() initializers, and codeanalyzer-java#215 is
about storing the text once per module, not about a missing field. The plan's
Task 4 is ticked with the three runs, the grep and the refusal matrix.

Also here, because the verification found them: a live refusal test attaching
the Java Neo4j backend to a real codeanalyzer-python graph, and the two prose
mentions of the retired 2.4.1 pin reworded to name schema v1 instead.
…d drop the bundled jar and JDK download

The Java backend now gets both the analyzer and the JVM it runs on from the
codeanalyzer-java PyPI wheel, pinned at 3.0.2 behind a new optional `java`
extra. 3.0.2 reads its primordial scope from `jrt:/` in the running JVM, so
nothing needs `JAVA_HOME` or a `jmods/` directory any more:

* `JCodeanalyzer._get_codeanalyzer_exec` returns `codeanalyzer_java.command()`.
  The import is lazy, so `import cldk` and `import cldk.analysis.java` work
  without the extra; running the backend without it raises
  CodeanalyzerExecutionException naming the distribution and
  `pip install "cldk[java]"`.
* Deleted `cldk/analysis/java/codeanalyzer/_jdk.py` (`ensure_jdk`, the pinned
  Temurin download, the ~200 MB per-project JDK cache), the checked-in
  `codeanalyzer-2.4.1.jar` and its directory, the `[tool.hatch.build]`
  force-include, the root `.gitignore` `*.jar` rule and the
  `CLDK_CODEANALYZER_JAVA_JAR` seam the e2e grew while the tree's jar was stale.
* release.yml no longer downloads the pinned jar from the codeanalyzer-java
  GitHub release and no longer verifies a jar is bundled in the wheel and
  sdist. This retires the mechanism #284, #336 and #337 patched: the wheel pin
  is now the single source of the analyzer version, and nothing is fetched at
  build time.
* Extras take the thin-base shape 2.0 is heading for: `neo4j`, `java`, and
  `all = ["cldk[java]", "cldk[neo4j]"]`. codeanalyzer-python and
  codeanalyzer-typescript stay hard dependencies until #340.
* Fixtures regenerated at 3.0.2 with the command lines the v2 README records;
  the only content delta against the 3.0.1 pair is `analyzer.version` (verified
  by recursive parsed-JSON compare: one differing leaf per file).

Verified: the wheel path with `JAVA_HOME` unset and no JDK on PATH returns the
identical daytrader8 `-a 4` call-graph edge set the 3.0.1 jar returned on the
provisioned Temurin -- 1862 edges, 1378 `declared+rta` / 375 `rta` / 109
`declared`, symmetric difference 0 after normalising the app name. A wheel built
from this tree contains no `.jar`, and neither does the sdist.

Closes #339
…urning a declared-only graph silently

codeanalyzer-java degrades rather than failing: when the build it needs for L3/L4
cannot run, it emits a declared-only call graph and an SDG with no points-to
provenance, exits 0, and still stamps max_level with the level it was asked for.
Measured on daytrader8 at -a 4, the only difference between Maven on PATH and not
is 1862 edges (declared+rta) versus 1391 (all declared) and 1220 points-to ddg
edges versus none -- the envelope, the exit code and the application keys are
identical. The analyzer declares this itself, but only on its log, at WARN.

So the log is the signal. The SDK now passes -v (only alongside -o, since without
it the payload occupies the same stdout the log would), matches WARN lines on the
shape the analyzer uses -- a capability "unavailable", "emitting ... only" --
rather than on the exception text inside the parentheses, strips the ANSI colour,
and keeps the analyzer's own sentence as the message. The verdict is persisted
beside analysis.json as <cache>/java/analyzer_diagnostics.json, never as a field
inside the payload, so a cache hit -- which by design never re-invokes the
analyzer, and therefore has no log -- still knows.

Three states, distinguishable, on JCodeanalyzer.analyzer_diagnostics: a non-empty
list of level_too_low Diagnostics (the analyzer declared a degradation), [] (it
declared none), and None (no verdict is recorded, so it is unknown -- a pre-#341
cache, an analysis.json from elsewhere, or stdout-pipe mode). None is reported as
unknown, never as clean. Each state is logged once per analysis at WARNING, and
nothing raises: a declared-only call graph is still a real answer, and callers
content with it keep working. The payload's proxies (no rta, no points-to) are
not consulted at all -- a small project can legitimately have neither.

No new accessor on JavaAnalysis; a public reader is a 3b candidate.

Closes #341
…g, and close the leg review

`get_test_methods()` re-parsed each compilation unit's `source` with Tree-sitter, and the
Neo4j projection carries no module source, so it returned `{}` on every graph-backed
analysis — `{}` on ThingsBoard, where 3,354 callables carry `@Test`/`@ParameterizedTest`.
It now reads the analyzer's own annotations off the model, so both backends answer the
same, and is keyed by `"<type fqn>.<signature>"` rather than a bare method name that
collapses same-named tests across classes.

`CHANGELOG.md`'s `[Unreleased]` block was structurally corrupt and the release workflow
lifts it verbatim: a `### Removed` heading split the `### Changed` list, two bullets
appeared twice (the second copy superseded), the Java pin read 3.0.1 where the pin is
3.0.2, and the preamble named only leg 2.5a. One heading of each kind now, no bullet
repeated, and both legs and both release targets named.

Also from the review: `get_config_keys()` is keyed by the artifact-relative key rather
than a `can://` id stamped with `--app-name` (the two backends shared zero keys);
`calling_lines` are sorted absolute file lines rather than offsets into a `JCallable.code`
that differs by backend, which removes the divergence on 560 of daytrader8's 1,862 edges;
`get_call_graph()` parses each body once instead of once per edge (ThingsBoard 145.7s →
41.0s); the degradation sidecar is bound to its payload's sha256 and never raises; the
`Makefile` no longer wgets a jar into a deleted directory; and the docstrings that
described behaviour neither backend has now name the backend arm.
The rebase left two Changed and two Fixed headings and six duplicated
bullets, at 417 lines. Same content, one section of each, ordered by what
a reader needs first: what breaks and how to migrate, what is new, what
changed, what is fixed, what is still missing.
…and signature-aware resolution

Java's two addressing rules the shared helpers cannot express, and nothing else
(leg 3b, task 0).

J-2: a Java module's dotted name is its declared package, not a derivation of
its path — `src/main/java/com/ibm/…/TradeDirect.java` dots to
`src.main.java.com.ibm…`, which names nothing, silently. `java_module_dotted`
reads the package and adds the `package.TypeName` spelling, which is what
splits two files of one package.

J-3: a callable is keyed by a signature carrying the erased parameter tail, so
`java_callable_names` gives the resolver both spellings — the bare name finds
the callable with the tail cut, two overloads raise listing the tail-carrying
signatures, and writing the tail resolves exactly. The cut is at the last `(`:
an anonymous class's qualified name carries its declaring callable's tail
(the J-1 erratum), and `in_class=` accepts that spelling unchanged.

Both rules reach the shared policy as parameters rather than a fork:
`CallableCandidate` gains `match_names` / `module_names` (empty = the previous
behaviour, so every Python and TypeScript call site is unchanged) and
`resolve_callable_signature` gains `by_full_name`, because "narrow it by naming
more of the dotted path" cannot split two overloads and an instruction that
cannot work is the failure E8 keeps out of the error path.
Both legs parameterised resolve_callable_signature for the same reason and
differently. The candidate fields subsume the injected function and express
what it cannot: a Java module answers to several dotted spellings, not one.
Writing the resolution down so the rebase does not pick one silently.
…e must be followable

Three findings from the Task 0 review, plus the reconciliation with leg 2.5b.

module_names becomes Optional: an empty tuple means the language supplied
none, None means derive it from the path. Conflating them on truthiness
handed a .java path to a derivation whose default suffix list is (".py",),
so nothing was stripped and the extension rode into the dotted name — a
path derivation J-2 forbids, presented as a successful match.

The ambiguity advice said to name the signature "with erased parameter
types". The analyzer does not erase them, so a caller following that
literally gets a not-found error. It now points at the candidates the
message already carries. The in_class= and in_module= clauses are pruned
per ambiguity, since neither can split two overloads of one class.

2.5b parameterised the same resolver with an injected dotted function.
The two compose rather than compete: explicit module_names win, and the
injected function is what derives when a language has none to give — so a
derivation is never made with another language•s suffix list.
A task report read the a1/a4 difference as the tail spelling being unstable
across analysis levels. It is not: it is type resolution. a4 is a pruned
copy, so most types cannot be resolved and the analyzer falls back to the
spelling written in the source — unqualified, generics intact. a1 has 143
signatures containing '<' against a4's 8, and 34 of 34 qualified interface
names against 4 of 5.

The consequence for the remaining tasks is the point: a test that pins an
a4 signature pins the pruning, not the analyzer, and will not match the
same method in a complete project.
The seven accessors of leg 3b Task 1, in Python's signatures keyword-for-keyword:
locate, locate_many, resolve_callable, resolve_value, get_source, describe and the
has_resolution_edges property.

Implemented once, on JavaAnalysisBackend, rather than per backend: leg 3a made
JNeo4jBackend rebuild the canonical JApplication and answer from it, so a second
implementation would have nothing extra to read and would only be a second place for
"what does cancelOrder mean" to drift. Each backend supplies three facts — the body
nodes of a callable, the text of one, and whether call sites resolve at all.

What still differs is the data, not the code, and it is documented where a caller
reads it: a callable's text is the body block off analysis.json and the whole
declaration over Neo4j (codeanalyzer-java#176), a body node has text only locally,
and a module-scope locate over the graph answers "" plus module_source_unavailable.

J-6 in full: initializers resolve and behave like methods, implicit callables resolve
with line=-1 and get_source refuses them by name, anonymous classes carry their
declaring callable.

The one statement this adds is the per-callable body-node fetch, anchored on the bare
:JBodyNode label and measured on ThingsBoard rather than ported: 86.6 ms / 16,024 db
hits against :JCanNode's 86.8 / 20,028 and a J_HAS_BODY_NODE hop's 204.2 / 503,741.
Carrying the whole-application prefix as well, so the scope audit's existing regex
would match, was measured at 10-15x slower — the audit learned the UNWIND spelling
instead, and now judges the values bound to $prefixes.
…ates

The thirteen dataflow accessors of leg 3b Task 2, on both backends and the
facade, in PythonAnalysis's signatures with Java's edge models.

The call-graph half (reaches, callers_of, callees_of, backward_cone,
call_paths_between) is implemented once on JavaAnalysisBackend over the
get_call_graph() both backends already build, so it issues no new Cypher; the
body-node half reaches the v2 models locally and five prefix-scoped statements
over Neo4j, anchored on the callable's id prefix rather than a doubled
containment hop, which drops every self-loop (python-sdk#349 — Log.printCollection
has 20 J_DDG edges, 11 of them self-loops, and the doubled spelling returns 9).

codeanalyzer-java 3.0.1 emits the L4 port lattice disconnected from the
statement dependence graph: no J_DDG or J_CDG edge touches a formal_in /
actual_in / formal_out / actual_out vertex, so a parameter has out-degree zero.
slice_forward, paths_between, flows_to_call and flows_to_argument would
therefore return the same answer for every input, which is indistinguishable
from a proved absence of flow, so they raise naming the gap through one
data-driven guard. slice_backward and the call-graph half are unaffected.
…ry surface

Completes the leg-1.5/1.6 query surface for Java (leg 3b, Task 3): the entrypoint
trio, the four bulk projections, get_external_symbols, the artifact layer on the
facade, and the J-7 leaf accessors get_interfaces/get_enums/get_enum_members/
get_records — 18 accessors in PythonAnalysis's signatures, with Java's models.

Thirteen of them live once on JavaAnalysisBackend and neither backend overrides
one: 3a made JNeo4jBackend rebuild the canonical JApplication (decorators
included, since J_ANNOTATED_BY is one of the relationships its containment walk
collects), so a second implementation would only be a second place to drift.
The leg adds exactly one Cypher statement, _external_rows.

Three things Java says rather than answers, each measured:

  * get_entrypoint_coverage reports entrypoint_report_unavailable. The
    :JApplication anchor carries name/schema_version/analyzer_name/
    analyzer_version and nothing else, and analysis.json has no report key —
    unlike codeanalyzer-python 1.4.1 and codeanalyzer-typescript 1.3.0. A count
    of syntactically-marked callables is not a coverage report, so none is
    synthesised from the booleans (J-4). The marks themselves agree id-for-id
    between the two sources: 133 callables and 66 types on daytrader8.
  * get_external_symbols raises off a local run and answers over Neo4j.
    codeanalyzer-java homes out-of-project call targets only under
    --external-calls, which --emit neo4j forces on and the SDK's own run does
    not pass, so the graph carries 1,195 (2,570 on ThingsBoard) and a plain -a
    payload carries None. {} would read as "this project calls nothing outside
    itself"; the raise names the flag instead.
  * get_enum_members raises SelectorNotInGraph on a name that is not an enum,
    where TypeScript returns []. "This enum declares no constant" and "no such
    enum" are different answers.

get_decorated_callables matches a marker by simple name, with a leading @
ignored, or by fully-qualified name — one rule, since the Java wire carries the
simple name only (J-5). get_method_bodies and get_callsites_for are keyed by the
J-1 "<type fqn>.<signature>" name, matched exactly, and omit a miss rather than
mapping it to None. get_config_keys's artifact-relative key is untouched
(python-sdk#346).

The multi-application audit grows from 24 statements to 25 and judges the new
one unchanged; two behavioural leak tests cover the Task 3 surface and the
external ghosts.

Docs: the agent reference replaces 3a's "arrives in 3b" line with the two landed
sections and gains five lossiness bullets — the disconnected L4 port lattice
(codeanalyzer-java#227), the 87 dangling ddg endpoints (#228), the twelfth
body-node kind `switch`, get_external_symbols' asymmetry and get_callsites_for's
intra-line order. One number Task 2 published was wrong and is corrected: a
`switch` vertex's J_CDG fan-out is at most 154, not 1,498.
``_BODY_NODES`` returned id, kind and the two line numbers, so ``R.body_node``
minted ``callee=None`` on every row and every call site located over the graph
read as unresolved -- while ``has_resolution_edges`` was telling the caller
those nulls were per-site.  The graph has the fact as a ``J_RESOLVES_TO`` edge;
it is now read in the same round trip, scoped to the application prefix like
every other endpoint, and an ``@external`` target is carried as the ``callee``
it is (``get_external_symbols`` is keyed by those very ids).

The offline responder emitted the same three columns and so froze the
divergence; it projects ``callee`` now.  The live parity assertion is
containment rather than equality, which is the relation that is actually true:
``--emit neo4j`` forces ``--external-calls`` and the reference run does not, so
the graph resolves all 4,006 of daytrader8's call sites where the payload
resolves 1,723 -- and must agree on every one of those.
…H it follows

A free-standing WHERE deleted the null row an OPTIONAL MATCH exists to
produce, and read a property off an unbound variable while doing it.  Both
are now what Cypher does: the clause filters the optional half, the null row
survives, and a predicate on an unbound variable is null rather than a
KeyError.
…nswer empty

The spec says an implicit callable resolves and that get_source, the three
per-callable graphs and the slices raise naming the reason.  Only get_source
did: get_cfg/get_cdg/get_ddg returned total=0, edges=[], complete=True on both
backends -- a well-formed page reading "this callable has no control flow" for
99 of daytrader8's 1,216 callables -- and resolve_value blamed the value name
("value not in graph") for a callable that declares nothing at all.

One guard, _require_explicit, on the shared ABC beside the level and port
guards, reached by both backends' graph accessors and by resolve_value, which
is the single door every slice, path and flow predicate addresses a value
through.  The premise is one message constant; only the tail differs.
_SLICE and _VALUE_REACHES bound only their seed and their far endpoint, so a
walk could leave the application between them and come back -- and
_VALUE_REACHES carried a comment claiming 'every hop is inside the
application, as in _PATHS', which was the one thing it was not.  Both now
carry the same whole-path predicate _PATHS does, keeping the endpoint one
alongside so the per-variable audit still reads them as scoped.

The audit that exists for exactly this class selected on 'allShortestPaths'
while its docstring said 'a shortest-path or variable-length pattern', so it
skipped both of them; it now matches any variable-length quantifier and names
_SUBTREE as the one exclusion, with the reason (it walks containment out of
the application anchor, so its interior cannot leave).  Two docstrings that
described tests and scopes that did not exist are corrected.

Cost, re-measured on the shipped statements against ThingsBoard's largest
formal_in backward cone (604 nodes, depth 5, interleaved, median of 5, three
sessions): 9.6/9.9/9.9 ms against 9.2/9.5/9.1, identical rows.
…he body map

resolve_value composes <callable id>@formal_in:<n> from the declared
parameters, which exist at every analysis level; the vertex that carries one
exists only from level 3.  _sources_for looked it up among the body nodes, so
describe() raised KeyError -- documented as 'a stale or foreign address' -- on
a ref resolve_value had just returned, at the default analysis_level of
CLDK.java(...).  It now answers present-with-no-text, which is true at every
level on both backends.  An index past the end of the parameter list is a
stale address and still raises.
--emit neo4j forces --external-calls, so no :JExternal row means the run homed
them and found none -- a real answer about a project that calls nothing outside
itself.  'or None' turned it into the value get_external_symbols raises on,
whose message says the Neo4j backend answers this, and made the documented {}
unreachable on either backend.
rahlk added 18 commits September 7, 2026 06:23
The Neo4j spelling matches (b:JBodyNode)-[...]->(m:JBodyNode), so it sees an
edge only when its target was emitted as a node; the in-memory one counted any
outgoing edge of a formal_in.  codeanalyzer-java 3.0.2 emitted 87 of
daytrader8's 5,434 ddg edges naming an endpoint it never emitted (#228, fixed
in 3.0.3), which is the shape that makes the two disagree -- and this one
boolean decides whether four accessors raise or answer.  The offline
responder's stand-in for the graph's answer moves to the same definition.
describe() listed LocateResult among the shapes it accepts and then raised
'needs something carrying a ref ...; got LocateResult' whenever the position
landed on no body node -- 187 of 300 random in-callable positions on
daytrader8, so the common reading rather than an edge case.  It now says which
of the three things happened (inside a callable but on no emitted vertex, at
module scope, or in an uncovered file) and ends with a call that runs on the
value the caller already has.
AmbiguousName renders 'Narrow it with {narrow_with}.', so every clause has to
be a noun phrase.  The shared by_full_name default and Java's override were
verb phrases, and Java's keyword pruning makes that clause the only one for
every overload ambiguity -- the commonest ambiguity in the language.  Both are
now noun phrases; the Python and TypeScript renderings keep reading as before,
and a test over every clause this module can produce holds the sentence.
…ed for

Three facades document 'self-reachability is True only through a real cycle',
bounds.py's self-path refusal tells the caller to ask reaches(X, X), and both
Neo4j backends answer it that way -- their pattern is {1,depth} and lands back
on the source like any other node.  The three in-memory backends asked
'b in descendants(a)', and descendants excludes the source, self-loop or not,
so the answer was False for every input: a wrong answer reached from the error
path, and a backend divergence besides.

One shared call_reaches in commons/graphs.py replaces the identical line in
all three: for a != b it is what it was, and for a == b it asks whether any
predecessor of the source is reachable from it -- one hop shorter when the
budget is finite, so the two halves add up to depth.
… either

test_java_addressing_rules pinned the word out of the error messages and not
out of the models, so JCallableOverview.signature still called it 'the erased
parameter tail' -- the normalisation the fixtures disprove (a4 spells
setTopLosers(Collection<QuoteDataBean>), a1 setTopLosers(java.util.Collection)
for the same method).  The docstring now says what the tail is, and the ruling
covers the model modules.
check_distinct_endpoints, not refuse_self_path; daytrader8 has 13,436 body
nodes, not 12,236 (counted on the reference graph).
…callables

Four places said the omitted set is "the 99 implicit constructors and the two
<clinit>$N() initializers", and the plan put the count at 1,115. Both halves
are wrong. The filter is `code`, not `declaration`: 99 callables have no code,
101 have no declaration, and the two initializers are in the second set only --
they carry a body block and come back.

Measured through the shipped accessor on the committed a1 fixture, on both
backends: get_method_bodies over the whole overview is 1,117 of 1,216, and the
omitted key set is exactly {o.key for o in overview if o.is_implicit}. The
offline test now pins the count, the omitted set and the two initializers'
presence instead of only asserting the values are strings; the plan already
said 1,117 at Task 1 Step 2 and the live suite already asserted
`compared == 1117`.
…etracted

Two published figures for a1 -- "143" signatures containing '<' and "34 of 34"
qualified interface names -- came from a walk over each unit's top-level
`type_declarations` only. That walk skips nested, local and anonymous types,
and it yields 1,177 callables, the number the leg-3a J-8 erratum already
retracted in favour of 1,216. Reproduced exactly (143 / 34 of 34 / 1,177), so
the walk is the cause.

Re-measured over `JCodeanalyzer._types`, the flattened index the SDK addresses
on: whole a1 is 149 types, 1,216 callables, 154 signatures containing '<' and
45 of 45 qualified interface names.

The row was also mislabelled "generic arguments". All 154 of a1's '<'
characters are in the callable's *name* (<init>/<clinit>); counting only the
parameter tail (`"<" in sig.partition("(")[2]`), a1 has 0 and a4 has 4. The
table now reports the four shared types on both sides -- 128 callables each,
0 vs 4 generics in the tail, 5 of 5 vs 4 of 5 qualified interfaces -- with the
old figures kept as a dated erratum. The conclusion is unchanged.
Two of them sit in files that implement the thing they promise:
`get_external_symbols` is right there on both backends. The clause is deleted.

The third promised a retirement that did not happen. The reference said the
nine NotImplementedError raisers hold "until 3b"; the spec's section 4 erratum
records that no task of the plan carries that work, and
test_java_public_surface.py's RAISING deliberately keeps all nine. It now
points at the erratum and at that record instead of at a release.
`resolve_callable_signature` gained keyword pruning in this leg: the
"Narrow it with ..." sentence now offers in_class=/in_module= only when the
caller has not already passed that keyword and the listed matches disagree on
it, and the trailing clause is a noun phrase that differs per language. That
changes the message Python's resolve_callable / callers_of / callees_of /
resolve_within produce for the same input -- a pre-existing Python test had to
be rewritten for it -- with no CHANGELOG line. Adds one, describing the
message as the code renders it today (verified against both branches of the
pruning).

Also fixes a docstring in test_resolve.py that cited
test_the_advice_drops_a_keyword_that_cannot_split_these_matches; the test is
test_the_hint_drops_... A sweep of the branch for other unresolvable test and
symbol cross-references found none.
It returns [] unconditionally, for the reason get_config_uses does -- the Java
wire carries no code-to-config edges and no config-read detector -- but it was
listed among the landed accessors with nothing caller-facing saying so, and the
CHANGELOG's Known limitations had no bullet for any of the three. A caller
reading [] as "no code reads this key" would be reading a fact about the
analyzer as a fact about the program.
The plan claimed has_resolution_edges costs "no extra round trip" and then
gave the parenthetical that contradicts it. What it actually costs is one
statement rather than two, by reusing the relationship-type fingerprint the
schema probe already reads. test_java_schema_probe.py's
test_attach_does_not_reconstruct_the_application states it correctly and pins
the four; the plan line now says the same thing.
J-3 says spelling `name(erased types)` "resolves exactly". Measured on the
committed a1 fixture, both backends:

  resolve_callable("cancelOrder(java.lang.Integer, boolean)")
    -> AmbiguousName, 4 matches (TradeDirect, TradeSLSBBean, DirectSLSBBean,
       TradeServices)

An interface and its implementors declare the same signature by construction.
Over the whole fixture, 1,216 callables carry only 581 distinct tails, 200 of
those declared by more than one type, covering 835 callables -- so a bare tail
addresses 381 of 1,216 and no framework method at all.

The behaviour is right: J-1 already makes <type fqn>.<signature> the address,
the ambiguity lists the full keys, and in_class=/in_module= do split this one.
Only the sentence was wrong, in three places -- the J-3 row, java_callable_names'
docstring and the resolve_callable bullet of the agent reference. Same style as
the J-1, J-8, J-9 and section 4 errata.
SHOW CONSTRAINTS / SHOW INDEXES on 7691: it is not true that every keyed Java
label owns an id constraint. :JCallable has only a range index on `name` plus
the code/docstring fulltext, :JType only one on `name`, :JExternal nothing at
all, and :JCanNode a non-constraint RANGE index over 615,329 nodes. Their nodes
carry the merge label :JSymbol, whose id uniqueness constraint is the only way
to seek one by id. :JBodyNode is the only anchor on this surface that owns an
id uniqueness constraint directly -- and it is the label the hot statements
use, so the seek rule holds for a different reason than the leg-3a plan
assumed. The code is conformant; only the stated reason was wrong.

The cfg/cdg/ddg keyset orders have no unique tie-break, unlike _PATHS, which
appends elementId for exactly that reason -- they cannot, since the same key
has to be computable from analysis.json. Two edges with an identical full sort
key straddling a page boundary would lose the second while `total` still
counted both. Measured: 0 fully-identical parallel J_CFG_NEXT/J_CDG/J_DDG
edges across both applications of the reference graph, and 0 in analysis.json
(a1, a4, and the whole 22.5 MB daytrader8 -a 4 payload: 6,984 cfg, 4,416 cdg,
5,434 ddg, every key distinct within its callable). It cannot fire today; the
asymmetry is now recorded beside the elementId comment.
The port-lattice bullet said codeanalyzer-python and codeanalyzer-typescript
"have both already fixed the same gap" and then cited a Python-only number.
Measured on the TypeScript reference graph (superset-frontend, read-only):
16,774 TS_DDG edges leave a formal_in, from 7,704 of its 8,025 formal_in
vertices -- against Java's zero of all of them.
…graph loses

The write-gated Neo4j parity module has skipped through every leg since it was
written, so all five of its comparisons had gone stale. No backend changes.

Two of the differences were not lossiness at all, and the fixture now removes
them instead of tolerating them:

- The project is created in a directory named APP_NAME. The in-memory backend
  takes the can:// application segment from the project directory's name and the
  emitter takes it from --app-name; with different names every id disagreed for
  a reason unrelated to the projection.
- The graph is emitted at analysis_level=2, matching the reference's
  "call_graph". AnalysisOptions defaults to 1, which emits no PY_RESOLVES_TO
  edges (every call site's callee_signature None) and no defuse-provenance call
  edges (greet -> greet._decorate missing). That was a level difference being
  read as a projection loss; at level 2 the call graph matches exactly.

Also stale: the module-level scope key. A module's own module_name for
pkg/models.py is the short "models", not "pkg.models" -- both backends agree,
including on answering an unresolvable scope key with an empty result, so the
#246 regression is asserted on both spellings.

What remains is tolerated in _norm with its cause named in the module docstring:
absolute vs repo-relative file_path (asserted as a relation, not skipped),
unprojected PyModule.source and imports (asserted empty), id and span left at
their model defaults, an attribute's initializer and a call site's arguments,
argument_types and columns. callee_signature stays exact on get_callsites_for --
the one accessor that follows PY_RESOLVES_TO -- so its resolution cannot regress
behind a blanket tolerance. Three of these (id, initializer, arguments) are
recoverable from properties the graph already carries and are marked as gaps.
…-java-query-surface

# Conflicts:
#	.github/workflows/release.yml
#	docs/design/specs/2026-09-06-leg-3-java.md
@rahlk
rahlk merged commit 9af6168 into release/2.0 Sep 7, 2026
Sign up for free to 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.

1 participant