Skip to content

feat(artifacts): repository-artifact layer — artifacts, dependencies, config keys; graph 2.2.0 - #206

Merged
rahlk merged 19 commits into
mainfrom
feat/issue-197-artifact-layer
Aug 31, 2026
Merged

feat(artifacts): repository-artifact layer — artifacts, dependencies, config keys; graph 2.2.0#206
rahlk merged 19 commits into
mainfrom
feat/issue-197-artifact-layer

Conversation

@rahlk

@rahlk rahlk commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Closes #197, a child of epic codellm-devkit/.github#45. Plan: docs/design/plans/2026-08-29-repository-artifact-layer.md.

What lands

An inventory of every non-source file in a repository, the dependencies declared in them, and the configuration keys they define — emitted into analysis.json and projected into the Neo4j graph.

  • JArtifact / JDependency / JConfigKey, anchored on the application as sibling maps so symbol_table stays strictly code-keyed. Config keys nest inside their owning artifact.
  • A repo-wide discovery walk the existing source-root walker structurally cannot do — it never sees a root pom.xml or Dockerfile. Classification is a first-match-wins glob table, and no file is ever dropped: unmatched files are inventoried as text/unknown, undecodable ones as binary. The only exception is a .java file no rule names, which the symbol table owns.
  • Dependency parsingpom.xml through a hardened DOM, a deliberately shallow Gradle reader, and gradle.lockfile reconciliation. Net-new: dependencies were previously only downloaded as jars, never modelled.
  • Config-key flattening for .properties, YAML, XML descriptors, Dockerfiles and .env files, with ${...} references recognised but not resolved.
  • Neo4j projection at graph contract 2.2.0 — Artifact, Package and ConfigKey with HAS_ARTIFACT, DEFINES_CONFIG, DECLARES_DEPENDENCY and LOCKS. This is the part Repository-artifact layer for java — artifact / dependency / config_key nodes #197 originally deferred; V2SchemaCatalog had already reserved the first two labels.
  • Two flags: --artifact-text / --no-artifact-text and --artifact-text-max-bytes.

It emulates codeanalyzer-python v1.3.0, not the epic spec

Deliberately, and on explicit instruction. The spec mandates an artifact_kind enum, a scope enum and a shared ecosystem mapping table; none of the three exist in the reference implementation — its entire package contains one Enum, unrelated. What ships there is a bare format string plus a many-valued roles list, a four-value kind, and a hardcoded ecosystem literal. That contradiction is filed as codellm-devkit/.github#48 and needs resolving there, not here.

Four places Java cannot be literal, each stated where it occurs rather than left to be discovered:

  • Maven's provided and system scopes have no slot in the four-value kind; both map to build, and <optional> wins over the scope mapping — which matches the reference, where every optional group maps to optional regardless of its name.
  • Coordinates are two-segment, so JDependency carries group additively and the purl is pkg:maven/<group>/<artifact>.
  • PEP 503 normalisation is not ported; Maven coordinates are already canonical.
  • provides_imports and unresolved_imports are not implemented at all. Roughly half the reference's dependency machinery exists to bridge the gap between a PyPI distribution name and its import name. Java packages are declared, so a heuristic there would be strictly worse than emitting nothing.

One deliberate improvement on the reference: our config-key id grammar for compose-style env vars is @key:env/<NAME> where the reference uses @key/env.<NAME>. The reference's form provably self-collides when a plain YAML path begins with the segment env.. Since ConfigKey is a cross-language merge target, the two analyzers will mint different nodes for the same compose file until the reference converges — recorded in CanId's javadoc and being raised on #48.

Verification

Full suite green apart from CodeAnalyzerIntegrationTest, which needs a Docker daemon and fails identically on main. Beyond the suite: the graph was loaded into a throwaway Neo4j 5 container twice — once confirming all four edge types non-zero with no dangling endpoints and _k present on DECLARES_DEPENDENCY but not LOCKS, and once confirming a re-push after removing a file leaves no orphans while the shared Package node survives.

Three things worth a reviewer's attention, all of which the review chain caught rather than shipped:

  • The XML hardening is single-sourced and each of its four switches is independently pinned by a test. That took a round: the original probe only exercised one switch, because disallow-doctype-decl rejects a DOCTYPE-bearing payload before any entity is consulted, and getFeature(FEATURE_SECURE_PROCESSING) reports true even when never set.
  • The graph wipe deliberately does not reach Artifact, Package or ConfigKey. An earlier iteration widened it to stop stale nodes after a file is removed; that would have DETACH DELETEd nodes a sibling analyzer shares, destroying its edges. The staleness is the accepted tradeoff and matches the reference, which accepts the same. A negative-invariant test now pins it.
  • Extraction reads from disk, never from the cached source, which may be empty or truncated depending on the capture flag. The helper exists exactly once, in contrast to the reference's own duplicated pair, and two adversarial tests fence it.

Follow-ups, not in this PR

config_use / J_USES_CONFIG (the reference's detector mechanism is call-shaped and has no purchase on Spring's @Value annotation injection); POM parent and BOM-import inheritance; a libs.versions.toml parser, without which a version catalog's dependencies are invisible while the artifact reports clean extraction; ${VAR:default} yielding no reference, which the reference also gets wrong; and per-entry degradation so one unreadable file cannot abort a run.

Separately, this branch surfaced a pre-existing gap it did not fix: the conformance schema's localId pattern does not match the compound body-node keys SdgVertices emits, so an L4 payload does not validate against this repo's own schema. Filed separately.

rahlk added 19 commits August 30, 2026 21:50
…it parseManifest's catch

Each of the four DocumentBuilderFactory switches is now backed by a test that
fails when that specific switch alone is removed: disallow-doctype-decl via
the existing behavioural probe, the two external-entity features via a
getFeature() config assertion, and FEATURE_SECURE_PROCESSING via a dedicated
test (getFeature() cannot attribute it -- it reads true on an untouched
factory regardless of whether setFeature was ever called).

parseManifest now gives pom.xml its own try/catch narrowed to the three
checked types it declares, and no catch at all on the Gradle branch, so a
future bug in either no longer risks being misreported under the other
branch's failure contract.
Keeps the from-disk helper written exactly once, per the design plan's
constraint, instead of task 6 recreating the same three lines in
CodeAnalyzer.java the way the python reference duplicates it across
dependencies.py and core.py.
- guard the yaml branch against a top-level scalar document (not null,
  not a Map, not a List) minting a spurious key="" entry
- give the compose env-dual-mint a delimiter distinct from
  CanId.configKeyId's "@key/", so it can no longer collide with a
  plain yaml dotted path that legitimately starts with "env."
- dedupe the ENV/ARG dockerfile scanners into one scanner plus two
  small per-directive tail parsers
…structor

CanId.configKeyEnvDualMintId(artifactId, bareKey) now owns the
"@key:env/" delimiter and the collision-freedom rationale, matching
the eight other can:// id constructors already funneled through CanId.
ConfigKeys.java:157 calls it instead of concatenating the string
itself, alongside the existing configKeyId call for the dockerfile
arg. case one call earlier. ArtifactModelTest gains a case next to
configKeyIdNestsUnderItsArtifact asserting the new shape and that it
cannot collide with configKeyId for the same key name.
….2.0

Fills in the neutral Artifact/Package labels V2SchemaCatalog already
reserved, adds ConfigKey, and emits HAS_ARTIFACT/DEFINES_CONFIG/
DECLARES_DEPENDENCY/LOCKS. This is the Neo4j half of #197 that was
deferred on the premise that the Java projector ran off a legacy model
and could not carry a v2 addition -- that stopped being true once the
v2 graph projection shipped, so the deferral is withdrawn.

Package nodes are graph-only (minted from CanId.purlMaven); analysis.json
continues to carry only the bare group/name, matching python. Nodes and
containment edges are un-prefixed -- cross-language merge targets, same
reasoning python's schema.py already states at its own declaration.
DECLARES_DEPENDENCY carries a `_k`=kind MERGE discriminant via the L4
overlay's keyedEdge machinery, since one manifest may declare a package
under two kinds. LOCKS fans out to every lock artifact present, since
lock pins are merged upstream with no per-lock attribution -- a known
limitation carried over from codeanalyzer-python as-is.

codeanalyzer-python deliberately did not bump its own schema version for
this layer ("no consumers yet"), so a consumer cannot detect the layer's
presence from python's version alone. Java bumps to 2.2.0 anyway, the
better behaviour and a deliberate divergence from python here.
Two gaps found in review of the graph contract 2.2.0 work:

- schema.neo4j.json (via V2SchemaCatalog.uniquenessConstraints())
  promised artifact_id/package_id/configkey_id uniqueness constraints,
  but Schema.CONSTRAINTS -- the hand-maintained list CypherWriter and
  BoltWriter actually execute -- never gained them, so no load ever
  created what the document promised. Added the same three, verbatim
  matching the auto-derived names/alias so the document and the
  executed DDL agree exactly, not merely both exist.

- CypherWriter's wipe traversal couldn't reach :Artifact/:ConfigKey at
  all: HAS_ARTIFACT hangs off :JApplication directly (not off the
  :JModule/:JCompilationUnit the wipe's first hop was scoped to), so a
  repeat push of the same application after a config-bearing artifact
  was removed left the old :Artifact and its :ConfigKey rows as
  permanent orphans -- the same bug class the v1/v2 unification already
  guards against, recreated for this new label family. Fixed by folding
  HAS_ARTIFACT into the app-anchor's first hop and DEFINES_CONFIG into
  DESCENDANTS, reusing the existing wipe mechanism rather than adding a
  second one. :Package stays unreachable from the wipe on purpose, same
  as :JPackage/:JAnnotation -- a purl-keyed Package is a cross-app
  merge target, not owned by one application's push.

Verified live: pushed a fixture into a throwaway Neo4j 5 container,
removed a config-bearing artifact from the analyzed repo, pushed again
-- the removed Artifact and its ConfigKeys are gone (0 orphans), the
still-present pom.xml/Package survive untouched.
… guards

The prior fix (a025f6c) widened CypherWriter's wipe to reach :Artifact/
:ConfigKey so a re-push wouldn't leave them stale. That was wrong: both
are un-prefixed cross-language merge targets by design (CanId.artifactId's
own javadoc -- the `artifact` id segment exists precisely so a
sibling-language analyzer over the same repository lands on the same
node). When it does, its own edges attach to that shared node. A Java
wipe that reaches it DETACH DELETEs everything on it, including that
other analyzer's edges, and recreates only what Java itself declares --
silently destroying data in a tool Java cannot see or repair.

Reverted DESCENDANTS and the wipe's first hop to their pre-widening
form (functionally identical to 26150a5, comments extended to name all
three shared labels -- Package, Artifact, ConfigKey -- and the reason).
This restores the tradeoff already accepted for Package/JPackage/
JAnnotation: a stale Artifact/ConfigKey survives a re-push after its
file is removed from the repo, recoverable with a full re-push or a
separate sweep. That is the safe direction versus the alternative of
occasionally corrupting a sibling tool's graph with no way to detect it.

Two tests added, both proven to catch what they guard against (verified
by temporarily reintroducing the removed code/line and confirming each
fails, then restoring):

- wipeStaysOffTheCrossLanguageArtifactPackageSubgraph: the negative
  counterpart to the existing "wipe covers both generations" test --
  asserts the app-anchor hop stays exactly J_HAS_UNIT|J_HAS_MODULE and
  DESCENDANTS never contains HAS_ARTIFACT/DEFINES_CONFIG/
  DECLARES_DEPENDENCY/LOCKS, so widening this again requires deliberately
  overriding an explicit assertion, not just missing a gap.
- constraintsStayInSyncWithTheCatalog: every (merge_label, key) pair
  V2SchemaCatalog.uniquenessConstraints() derives (what schema.neo4j.json
  promises) has a semantically matching entry in Schema.CONSTRAINTS
  (what CypherWriter/BoltWriter actually execute) -- the exact
  document-vs-DDL drift the previous review round found and fixed by
  hand, now guarded so a future schema addition can't reintroduce it
  silently.
…es not have

Four documentation defects in the repository-artifact layer, all of the same
class: prose that survived from an early spec instead of describing what the
code emits.

- ArtifactDiscovery's class javadoc told a maintainer that later tasks read
  `source` back out of the returned artifacts "so that later reading never has
  to touch the filesystem again". That is the exact inverse of the binding
  constraint: `source` is empty under --no-artifact-text and a truncated prefix
  past --artifact-text-max-bytes, so extraction reads from disk via
  DependencyView.readFromDisk. Two adversarial tests already fence the
  behaviour; the front-door javadoc was inviting the regression they guard.
- JArtifact listed roles "build, ci, deploy, config, dependency-lock" and called
  them user-assigned. They are rule-assigned by ArtifactDiscovery's RULES table,
  and the emitted vocabulary is dependency-manifest, tool-config,
  container-image, service-topology, iac, ci, env, legal, docs, script, unknown.
- JDependency promised ecosystems "maven, npm, gradle, pypi, golang"; there is
  no setEcosystem caller in the tree, so the field is always "maven". Matches
  the reference's candour about "pypi" being the only one it emits.
- JApplication called the `dependencies` List "indexed by" a purl; it is one
  entry per declaration, sorted by (name, declaredIn).
- ArtifactDiscovery's classify() comment claimed "k8s/*.yml" matches only
  directly under k8s/, contradicting globMatches' own correct comment thirteen
  lines down. The code is right (fnmatch semantics: '*' crosses '/'), so
  "k8s/base/svc.yml" matches too and the comment was the wrong half.
Every deliberate divergence on this branch is stated in a comment where it
occurs; this one was not, and it is the one that matters most.

configKeyEnvDualMintId builds `<artifact-id>@key:env/<NAME>`. The reference
builds `<artifact-id>@key/env.<NAME>` (config_keys.py:577 into ids.py:39). Ours
is the correct grammar and stays: the reference's provably self-collides,
because a plain yaml dotted path can itself begin with the segment `env.`, so
one document with both a top-level `env:` block and a compose environment block
yields two records under one id. The reference guarded the bare-name collision
and missed the dotted-path one.

The javadoc already argued why a prefixed key routed through configKeyId would
be unsound, but never said the reference does exactly that, nor named the price:
ConfigKey is an un-prefixed cross-language merge target, so in a polyglot repo
the two analyzers currently mint different nodes for the same environment
variable, splitting the very nodes that label exists to share. That stands until
the reference converges. Grammar unchanged.
…ct-text form

Closes three test blind spots in the repository-artifact layer.

LOCKS is a per-package fact, so a coordinate declared in two manifests and
pinned by one lockfile walks the mint twice for one (lock, package) pair. The
reference guards that with a `seen` set (neo4j/project.py:350-359) because its
RowBuilder.finish() only sorts. Ours dedupes edges by (type, from, to, _k)
there, so the repeat mint already collapses and a guard would be dead code -- an
assertion, not a belief: with that dedupe disabled the new test reports 2 rows,
with it 1. The test pins the deduped-bag contract at the observable boundary
whichever layer keeps it, and asserts the DECLARES_DEPENDENCY half too: it stays
one row per declaring manifest and must never be collapsed per package. The
divergence from the reference's implementation is now stated where it occurs.

The artifact-layer edge tests asserted only `to`, props and key, never `from` --
one-sided coverage of the kind that already let a wrong decision ship on this
layer. All four types now assert both endpoints. Verified by mutation: reversing
each of the four fails, and so do two wrong-but-plausible sources (LOCKS from
the declaring manifest, DECLARES_DEPENDENCY from a wrong artifact id) that only
the new `from` assertions can catch.

Nothing exercised a bare --artifact-text. Its fallbackValue = "true" exists only
because picocli 4.1.0 resolves the bare negatable form to the opposite of what
negatable=true implies, so a cleanup of that "redundant" attribute would
silently invert a user-visible flag. The new CLI test asserts the bare form
matches the no-flag default, and separately that capture is genuinely on --
equality alone would also hold if both runs captured nothing, which is exactly
what the inverted flag produces. Removing fallbackValue fails it.
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.

Repository-artifact layer for java — artifact / dependency / config_key nodes

1 participant