Skip to content

feat(config): config-read literal tier — J_USES_CONFIG and J_READS_CONFIG_UNRESOLVED - #233

Merged
rahlk merged 2 commits into
mainfrom
feat/issue-232-config-read-literal-tier
Sep 7, 2026
Merged

feat(config): config-read literal tier — J_USES_CONFIG and J_READS_CONFIG_UNRESOLVED#233
rahlk merged 2 commits into
mainfrom
feat/issue-232-config-read-literal-tier

Conversation

@rahlk

@rahlk rahlk commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #232. Spec: codellm-devkit/.github docs/design/specs/2026-09-07-java-config-reads-and-entrypoint-report.md (codellm-devkit/.github#63), epic codellm-devkit/.github#62.

What

The analyzer projected 9,688 :ConfigKey nodes and no relationship saying which code reads one. ConfigKeys answers "which keys are declared"; nothing answered "who reads one" — there was no config-use analysis in the analyzer at all, so this is a new pass, not projection wiring.

  • application.config_uses[] and application.config_reads_unresolved[] in analysis.json
  • J_USES_CONFIG and J_READS_CONFIG_UNRESOLVED in V2SchemaCatalog and V2GraphProjector
  • Detectors: System.getenv, System.getProperty, Properties.getProperty, Environment/PropertyResolver.getProperty/getRequiredProperty, @Value("\${x}"), @ConfigurationProperties

Divergences from codeanalyzer-python, both deliberate (D30)

src is a union, not a call body node alone. Python anchors every read on a call site because Python's config reads are calls. Java's dominant idiom is an annotation with no call site, so a read is attributed to the annotated :JField / :JCallable / :JType, and a call-site read to its :JBodyNode. Call-sites-only would be literal parity that reports almost nothing on a Spring app. A consumer must not assume the source of a config-read edge is a body node.

The literal tier runs at L1, not L2. A call body node already carries argument_expr/receiver_type/method_name at L1 and an annotation is pure L1 data, so nothing here needs a call graph. Same flag therefore yields different availability on the two analyzers; the L3/L4 dataflow tier (#232's sibling, epic child 2) widens this additively.

Notable choices

  • Detection is by declared receiver type, never by bare method name — a project's own getProperty(String) is not a config read.
  • A @ConfigurationProperties prefix claims every declared key beneath it, because the accessor's real question is which class binds a given key. @Value("a plain default") with no placeholder is not a read at all and appears in neither list.
  • The JExternal ghost is minted at detection time, not looked up in the L2 external-symbol set — a lookup would make every unresolved read invisible at L1 and absent whenever --external-calls is off, precisely the runs where the literal tier is the only tier there is.
  • _k = (key, reason) on the unresolved edge: one callee legitimately reads many undeclared keys, and a plain endpoint-pair MERGE would keep only the last key SET. Python hit this hazard first.
  • RowBuilder.refTo lets an edge address a node by id alone, so J_USES_CONFIG does not need its caller to know which of four merge labels the source landed on.

Verification

On daytrader8, -a 1 --no-build:

resolved reads 15, every dst a declared :ConfigKey
undeclared-key reads 12
schema conformance payload validates against analysis.v2.schema.json
graph both relationship families project; ghost minted with --external-calls off; _k collapses repeats per (key, reason)

ConfigUsesTest covers all four outcomes on one fixture — resolved annotation read, resolved call-site read, undefined key, non-literal key — plus prefix binding, the _k discriminant, no-dangling-endpoints, JSON/graph edge-count agreement, and sort determinism.

Full suite: 528 tests, 1 failure — CodeAnalyzerIntegrationTest cannot reach a Docker daemon on this machine, and fails identically on a clean main (verified by stashing).

schema.neo4j.json regenerated with --emit schema.

Contract version

Stays at 2.0.0. A re-baseline is codellm-devkit/.github#50, not a local bump. Recorded consequence: a consumer cannot detect these relationships from schema_version alone — detection is by presence until #50 lands, the same gap the _module removal already lives with.

D30 fixes the config-read vocabulary: `J_USES_CONFIG` / `J_READS_CONFIG_UNRESOLVED`
with a `src` widened to the annotated element, a literal tier at L1 and a dataflow
tier at L3/L4. D31 fixes the entrypoint report's four keys and what Java puts in
each. Both note that the graph contract version stays at 2.0.0, and what that costs
a consumer.

Spec: codellm-devkit/.github docs/design/specs/2026-09-07-java-config-reads-and-entrypoint-report.md
…NFIG_UNRESOLVED

The analyzer projected 9,688 `:ConfigKey` nodes and no relationship saying which
code reads one, so `DEFINES_CONFIG` was the only config relationship in a Java
graph. `ConfigKeys` answers "which keys are declared"; nothing answered "who
reads one" — there was no config-use analysis in the analyzer at all.

Adds `application.config_uses[]` and `application.config_reads_unresolved[]` to
`analysis.json`, and `J_USES_CONFIG` / `J_READS_CONFIG_UNRESOLVED` to the Neo4j
catalog and projection.

Two deliberate divergences from codeanalyzer-python's equivalent pass, both
recorded in D30:

- `src` is a union, not a call body node alone. Python anchors every read on a
  call site because Python's config reads are calls; Java's dominant idiom is
  `@Value("${x}")` / `@ConfigurationProperties`, an annotation with no call site.
  So a read is attributed to the annotated field, callable, or type, and a
  call-site read to its body node. Reading only call sites would report almost
  nothing on a Spring application.
- The literal tier runs at L1, not python's L2. A `call` body node already
  carries `argument_expr`/`receiver_type`/`method_name` at L1 and an annotation
  is pure L1 data, so nothing in the tier needs a call graph. The L3/L4 dataflow
  tier widens it additively.

Detection is by declared receiver type, never by bare method name: a project's
own `getProperty(String)` is not a config read. A `@ConfigurationProperties`
prefix claims every declared key beneath it, since the accessor's real question
is which class binds a given key.

The unresolved edge's `JExternal` ghost is minted at detection time rather than
looked up in the L2 external-symbol set — a lookup would make every unresolved
read invisible at L1 and absent whenever `--external-calls` is off, precisely
the runs where the literal tier is the only tier there is. `_k` = (key, reason)
discriminates the edge because one callee legitimately reads many undeclared
keys, and a plain endpoint-pair MERGE would keep only the last key SET.

`RowBuilder.refTo` lets an edge address a node by id alone, so `J_USES_CONFIG`
does not need its caller to know which of four merge labels the source landed
on.

Verified on daytrader8 (`-a 1`, `--no-build`): 15 resolved reads, every `dst` a
declared `:ConfigKey`; 12 undeclared-key reads; the payload validates against
`analysis.v2.schema.json`; and the projected graph carries both relationship
families with the ghost minted and `_k` collapsing repeats per (key, reason).

The graph contract version stays at 2.0.0 — a re-baseline is
codellm-devkit/.github#50, and the consequence is recorded in D30.

Closes #232
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.

Config-read literal tier: J_USES_CONFIG and J_READS_CONFIG_UNRESOLVED

1 participant