Skip to content

Maven dependency resolution on real Maven APIs#8240

Draft
jkschneider wants to merge 34 commits into
mainfrom
maven-resolution-engine
Draft

Maven dependency resolution on real Maven APIs#8240
jkschneider wants to merge 34 commits into
mainfrom
maven-resolution-engine

Conversation

@jkschneider

Copy link
Copy Markdown
Member

What

Replaces rewrite-maven's custom dependency-resolution algorithm (~4.5k lines: ResolvedPom.Resolver, doResolveDependencies, VersionRequirement) with real Maven APIs: Maven Resolver 2.0.20 and Maven 3.9.16's model builder, shaded into a new rewrite-maven-engine module. Any divergence from Maven's resolution behavior is a bug, not a feature — recipes now see the same effective POMs, dependency graphs, and failures mvn itself produces.

The frozen org.openrewrite.maven.tree.* API, wire format, and LST identity are unchanged. No user-facing configuration flags: this is a single flip-over. The legacy resolver stays in-tree, unreachable through any supported surface, for one release (revert-by-release), then gets deleted.

Design record: doc/adr/0010-maven-resolution-engine.md. Full plan, benchmarks, and corpus tooling: maven-resolution-plan/.

Behavior changes

Every deliberate change is catalogued in doc/maven-resolution-ledger.md (one row per divergence, each with a pinning test asserting the Maven-identical behavior) and summarized user-facing in maven-resolution-plan/RELEASE-NOTES.md. Highlights:

  • Profile activation reads properties (not environment variables) and evaluates <os>/<jdk>/<file> conditions; MavenExecutionContextView.setActivationSystemProperties pins activation inputs for reproducible fleets.
  • Conflict resolution keys on groupId:artifactId:classifier:type; duplicate declarations collapse per Maven's model normalizer.
  • Relocations (<distributionManagement><relocation>) are followed.
  • Metaversions (LATEST/RELEASE) in <dependencyManagement> stay literal, as Maven defers them.
  • Interpolation uses Maven's exact value-source order (user properties > pom properties > system properties).
  • Model validation is Maven's: self-parents, jar-packaged parents/aggregators, missing dependency versions, recursive property cycles, and invalid systemPath fail per file via ParseExceptionResult without aborting the parse run.
  • Failure handling keeps the complete-model contract: a failing scope surfaces its error while resolvable scopes stay populated.

Architecture

  • rewrite-maven-engine: shaded stack behind org.openrewrite.maven.engine.shaded.*; a jar guard test forbids un-relocated classes. HttpSenderTransporter is the sole network transport (HttpSender injection remains a hard host requirement); MavenPomCache/MavenArtifactCache remain the pluggable persistence seams.
  • org.openrewrite.maven.internal.engine: the adapters — MavenEngineResolution (routing facade), EngineEffectivePom/EffectivePomMapper (effective model → frozen ResolvedPom), EngineDependencyCollector/DependencyGraphMapper (one verbose collect → per-scope ResolvedDependency trees), CacheBridge/RegionMetadataResolver (cache regions, HTML-index metadata derivation).
  • A permanent dual-engine shadow oracle (internal.parity) diffs both engines under a mask registry in which every mask must cite a ledger row; it proved parity during construction and guards optimization after it.

Validation

  • Full :rewrite-maven:test (1615 tests) and :rewrite-maven-engine:test green on the new default.
  • Recorded real-world corpus (20 entries incl. a 130-module ${revision} reactor): hermetic double-replay, byte-deterministic, zero store misses.
  • Full :rewrite-gradle:test green (the Gradle marker's synthetic-pom path degrades gracefully on Maven-strict validation).
  • Differential census against the legacy engine: every divergence ledger-classified; ground truth arbitrated against mvn 3.9.x source and live runs where the two disagreed.

Performance

Steady-state recipe re-resolution is at parity (1.04–1.10×). Warm full parse costs 1.26–1.49× — the structural price of real DefaultModelBuilder builds for transitive descriptors. Cold-network parses benefit from parallel prefetch. Benchmarks: maven-resolution-plan/benchmarks/.

Known residue (deliberate, ledgered, pre-deletion work)

  • Unresolvable ${...} settings credentials still leak on the pom-bytes path (legacy downloader seam).
  • One rare duplicate-declaration mediation case (moxy) and a small gav-mediation residue remain open and unmasked.
  • Pom.modelVersion 3→4 bump is deferred until the descriptor-region format lands (LST-consumer coordination).

…chmarks

Studies of rewrite-maven's custom resolution and the embeddable Maven
stack (resolver 2.x + Maven 3.9 model-builder), the canonical design
(DESIGN.md), Phase 0 spec, runnable feasibility/provenance/cycle spikes,
corpus tooling, and comparative benchmark baselines.
…resolve

A download failure in any single scope discarded the entire
MavenResolutionResult marker, losing the resolvable scopes. The
aggregated MavenDownloadingExceptions now carries the partial result
and MavenParser attaches it alongside the ParseExceptionResult, so the
complete model survives with the failure surfaced (parity ledger L-P0-004).
Shape-aware ResolutionSnapshot/ResolutionDiff, determinism and identity
gates, serialized-LST compatibility fixtures, a MockWebServer-backed
synthetic behavior corpus (mirrors, auth, snapshots, negative caching,
file repos, HTML-index derivation), the inert resolution-engine
selector, and the parity ledger seeded with the first seven divergences.
…spine (Phase 1)

Resolver 2.0.20 + Maven 3.9.16 model-builder relocated under
org.openrewrite.maven.engine.shaded with an empty-allowlist jar guard,
MavenEngine bootstrap whose session template mirrors Maven 3.9's own
session factory, and HttpSenderTransporter as the sole network
transport (per-session sender, structural no-bypass).
The Maven model builder reads real XML; parsed Pom objects are lossy.
Raw pom bytes become a cacheable region keyed like the parsed-Pom
region, added as default methods so host implementations keep
compiling. RocksDB stores positives only under a prefixed keyspace so
raw XML can never be misread as Smile-serialized Pom entries; the
Pom.modelVersion bump stays deferred to the descriptor-region change.
Classified KEEP_REWRITE as a transport-layer augmentation: it only
produces an answer where Maven produces an error, never a different
answer where Maven has one.
Pure-translation settings seam: request-repository assembly with
today's ordering, mirror and credential semantics, session selectors
for descriptor-discovered repositories agreeing with the request path,
per-server transport config, and EffectiveSettings for model building.
ReactorWorkspace serves printed-XML raw models with the three-tier
reactor matching and an epoch counter for re-resolution invalidation.
Consumes the engine's shadowRuntimeElements variant explicitly and
corrects its advertised TargetJvmVersion to the true Java 8.

(cherry picked from commit 3c56b49568017ee3cf33f506a5bfa18f2215348d)
…ase 2)

EngineEffectivePom builds effective models from real XML through the
seams: CacheBridge supplies pom bytes from the pluggable cache with
fall-through to the resolver path (HttpSenderTransporter I/O, scratch
LRM, per-repo attribution), EngineModelResolver handles parents/BOMs
with range support and recessive repository aggregation, and reactor
poms flow through ReactorWorkspace (bypassing the session model cache,
whose relativePath cross-check distrusts in-memory sources; staleness
is guarded by the workspace's own epoch-cleared cache). Cycles and
self-parents fail Maven-identically via ModelParityErrorMapper.
…(Phase 2)

EffectivePomMapper projects ModelBuildingResult into ResolvedPom per
the canonical design: raw-lineage properties with injected-property
overlay, InputLocation-joined dependency management threading the
original ManagedDependency instances, GA-keyed requested-dependency
merge preserving instance identity, and super-POM-contributed entries
filtered to keep the projected model declared/inherited-only (ledger
L-P2-B2-001). BomGavAttributor stamps imported-BOM provenance by
effective-DM membership, correct for BOMs that inherit their
management. Differential gate: 10/11 parity fixtures project
identically to the legacy engine; the eleventh differs only by the
Maven-correct plugin ordering fixes (L-P0-002/003).
…r (Phase 2)

MavenEngineResolution routes effective-pom construction: legacy
(default, untouched), maven (EngineEffectivePom + mappers), or shadow —
both engines run, the projected poms diff under the ledgered mask
registry, and any unexplained difference or asymmetric failure throws
with the rendered diff while legacy's result is returned. PomXmlRegistry
carries printed document bytes from MavenParser and UpdateMavenModel
(with reactor-epoch invalidation) so the engine always builds from real
XML. Parity snapshot classes move to main sources to serve the
comparator. Shadow census on MavenParserTest: 41 clean, 37+1
masked-expected flips, 31 open findings recorded for the Phase-2
burn-down.
Twenty-two engine/mapper fixes: projected gav interpolation with
raw-vs-interpolated modelId reconciliation for property-versioned poms,
reactor and inherited BOM imports served through the workspace with
provenance attribution, RELEASE/LATEST metaversion resolution, injected
parser properties overriding declared ones, and the DM projection
deduplicated by g:a:classifier:type (proven against maven-model-builder
source to be output shape, not Maven semantics). Nine parity-correct
strictness cases where the engine fails Maven-identically are ledgered
as outcome masks (L-P2-D-001..008) that flip at cutover. MavenParserTest
in shadow: zero unexplained differences.
…fixes (Phase 2)

Root cause of the 90-test shadow failure wave: recipes that mutate the
requested Pom and call resolve() directly (ChangeParentPom's scanner)
bypassed both PomXmlRegistry refresh points, so the engine built from
stale initial-parse XML. The registry now binds each payload to its
source Pom and serves bytes only while the stored source equals the
requested instance, falling back to PomToModelConverter otherwise —
covering every direct-resolve path uniformly. Residuals: declaring-pom
empty-vs-null property representation, injected properties no longer
leak onto downloaded poms, the dependency-resolution pass is guarded
legacy-only for Phase 2, bomGav matching interpolates import
coordinates, and UpdateMavenModel re-reads profile properties. Ledger
L-P2-E-001/002 (outcome + value-conditioned metaversion masks);
L-P2-E-003 (nested import-BOM version selection) is a real engine
defect deliberately left red for the Phase-3 collection work.
…machinery (Phase 3)

EngineDependencyCollector runs the single widest-scope verbose collect
(per the large-reactor benchmark decision) with winner/loser and
premanaged state intact. EngineDescriptorReader implements the
descriptor SPI over EngineEffectivePom with Maven 3.9's exact tolerance
profile (missing dep pom tolerated, unresolvable parent/BOM fails,
relocation loop guarded); the direct-fails/transitive-warns split is
applied post-collect as rewrite's reporting layer. PinnedVersionResolver
honors pinned snapshots transitively without metadata reads.

(cherry picked from commit 7be030f30b6337e4e1b5e61fe95eafb874f50e83)
…ndencies (Phase 3)

DependencyGraphMapper projects the single widest-scope verbose graph
into the four pure per-scope lists with the identity contracts intact:
requested instances threaded at depth 0, winners shared within a scope
and never across scopes, legacy ordering and membership semantics, and
direct failures aggregated with the partial result per the
complete-model contract. ExclusionAttributor recomputes
effectiveExclusions as reporting. Scopes differential: 12/12 fixtures
zero unexplained. L-P2-E-003 inverted on investigation: the engine's DM
superset comes from Maven correctly activating a property-negation
profile (infinispan-bom community) that legacy's env-var-based
activation drops — reclassified ALIGN_TO_MAVEN with a hermetic pinning
test and a directional dm-superset mask that can never hide a value
divergence or a drop.
…t shadow (Phase 3)

MavenResolutionResult.resolveDependencies dispatches legacy (default),
maven (single collect projected per scope, preserving the partial-result
and per-GA dedup contracts), or shadow — now diffing the complete
snapshot including dependency graphs, with re-entrancy guarded. The
first full census with scopes compared found five gap mechanisms
(ledgered L-P3-C-001..005, left red and unmasked): exclusion
attribution, reactor-transitive attribution, closure divergence, and
requested-shape threading. Also fixes a real pre-existing concurrency
race: parallel tests publishing different bodies for the same GAV into
the process-global local repository (reproduced in legacy mode) now
serialize through the publish-run-cleanup window.
Exclusion attribution rebuilt over descriptor-time declared-dependency
capture with path-based shallowest-ancestor attribution; reactor and
warm-pool attribution made cumulative across collects; requested
threading interpolates property coordinates before matching; and the
scope of test-reachable compile transitives now replicates legacy's
classpath derivation after ground truth showed aether's scope promotion
disagreeing with both Maven and legacy. Census 381 to 172, dominated by
one pre-existing effective-pom attribution class (L-P3-D-004) plus the
machine-state-dependent local-repository attribution class (L-P3-D-002)
and depth-tolerance residue (L-P3-D-003), all ledgered red.
The dmRequestedRef volume leader was a re-resolution identity subtlety,
not attribution: legacy's no-change gate returns the caller's instance
still threading pre-edit BOM declarations, so the engine now threads
against the caller's pom when threading inputs are value-equal. Local
repository attribution replicates legacy's file-repo gate exactly
(machine-independence proven against a populated ~/.m2). Jar-type
pom-404 failures align to the arbitrated behavior: any depth, but only
resolved-graph members, root-attributed for per-scope reporting. Three
more pre-existing classes found and fixed along the way (duplicate
declaration retention, parent-inherited requested threading, shaded
super-POM plugin-groupId leak). Remaining shadow failures are all
ledger-classified: Phase-5 ALIGN flips, strictness outcomes, mediation
residue, and two newly ledgered OPEN gaps (L-P3-E-004/005). Legacy full
suite: 1583/0. Maven-mode worst-three classes: 77 percent.
…orpus

The corpus runner consumes the local build (Maven Local snapshot wiring;
composite build blocked by a Gradle classloader-scope bug), passes the
engine selector through, records per-entry shadow verdicts instead of
aborting, and tops up the HTTP store with the engine's checksum and
HEAD request profile so hermetic replay covers both engines with zero
misses. First census: 10 of 20 entries clean or masked-expected; the
other 10 classified to ledgered residues plus four new evidenced gap
classes (project.parent.version interpolation in threaded requested,
os-profile activation machine-dependence, mirror attribution
convention, system-scoped profile dependency retention). Determinism
verified in-JVM and across JVMs.

(cherry picked from commit c69218c271b19ceb8dbd622727098e476025acef)
…on dedup (Phase 3)

RELEASE and LATEST seeds resolve through merged metadata before
collection so descriptor reads carry concrete versions, pinned
hermetically. Ground truth from real Maven arbitrated the duplicate
scope/classifier/type dedup: the engine is Maven-identical in all three
shapes and legacy is the outlier — those pins flip at cutover. One new
bug ledgered open (L-P3-E-006, intra-pom duplicate declaration raw
threading) after a blunt fix regressed 81 tests and was reverted;
the remaining mediation value divergences stay red under drop-safety.
…se 3)

The project.parent namespace now interpolates in threaded transitive
requested coordinates (dubbo's class eliminated). Mirror attribution
resolves through the parent-merged effective-model repositories so the
declared mirror is attributed as legacy does. The file-exists-activated
system-scoped dependency arbitrated Maven-correct. OS/JDK profile
activation becomes an embedder input:
MavenExecutionContextView.setActivationSystemProperties, defaulting to
host values (Maven parity), pinnable by hosts wanting fleet-determinism
— corpus runner pins a canonical snapshot; the directional ALIGN mask
awaits sign-off. Synthetic request-log assertions are legacy-scoped
where shadow's dual-engine traffic invalidates exact counts. Census 94,
every entry ledger-classified.
… (Phase 4)

Ground truth dissolved the engine-drops-members premise: dual-engine
flat-set probes show identical per-scope sets on every recurring victim;
the divergences are nested tree shape and attribution where the engine
matches real Maven and legacy is the outlier — L-P3-C-004 reclassified
from bulk residue to individually arbitrated ALIGN. The one genuine
engine bug: a dependency reached at depth 1 as runtime and depth 2 as
compile belongs on the compile classpath (Maven never narrows scope
across paths); the mapper now trusts the wider aether edge scope while
preserving the no-promote guard. Pinned by CompileScopeWideningTest.
Clean full-census confirmation deferred: Central rate-limiting degraded
mid-slice; hermetic gates green and unchanged.
…ss hygiene (Phase 4)

Transitive requested classifiers thread the declaring pom's raw value —
hermetic repro inverted the assumed direction (legacy preserves empty
classifier elements). The recipe re-resolution residue reclassified:
commons-io 1.3.2 is a relocation the engine follows Maven-correctly and
legacy ignores — registered as an ALIGN flip with fixture. The intra-pom
duplicate hypothesis was disproven by hermetic repro and ledgered as
such. The synthetic transport suite pins legacy in shadow mode since its
dual-run reds are already-ledgered value divergences, not new findings.
…Phase 4)

The end-to-end gate FAILS: maven mode is 2.3-4.6x slower than legacy
across all tiers with no crossover, ~6x allocation at maven-15, and an
FD-exhaustion crash on BOM-heavy reactors from unreleased materialized
pom streams — recorded as flip blockers with measured cost centers
(benchmarks/integrated-2026-07-09). The raw pipeline resolves the same
warm reactor ~25x faster than the integrated path, so the gap is
per-iteration reconstruction, not adapter arithmetic. Debt closed:
EngineCollectSystemSupplier now extends the public engine supplier so
one class owns the transport monopoly and RRF-off (pinned), and
metadata reads route through the pluggable region with tri-state
semantics (pinned, zero-network warm metaversion).
… (Phase 4)

The file-descriptor exhaustion was aether's file-based named-lock
factory holding a FileChannel per artifact across context lifetimes —
in-JVM locks are the correct choice for a private per-run scratch
repository and were also the top measured cost. Super-POM re-reads
collapse to a shared singleton builder, descriptor builds share the
session cache (72 percent hit rate), imported-BOM attribution caches
per epoch instead of rebuilding per module, and model sources are
byte-backed instead of materialized. Warm ratios improve from 2.3-4.6x
to 1.5-1.9x with allocation within 1.4x and stable descriptors at 2g.
Gate still short of targets: the residual is structural (root-build
fresh cache for servedBy attribution; ModelBuilder per-invocation
weight) — named with evidence for the next slice.
A ctx-lifetime memo above DefaultModelBuilder, keyed by reactor epoch,
active profiles, and content hash, serves every repeated build — the
re-resolution loop that dominates recipe time drops from up to 2.64x to
1.04-1.10x of legacy with zero model rebuilds, invalidating wholesale on
any mutation (pinned by a memo-hit-plus-invalidation test). Root-cache
sharing was measured unnecessary and documented rather than built. The
remaining warm-parse gap (1.26-1.49x) is proven structural: ~3,215 full
model builds per warm camel-400 parse that legacy's RawPom path never
pays; closing it requires a descriptor-only projection, a design trade
against the parity guarantee, recorded as the Phase-5 decision input.
Transitive descriptor model builds now run without location tracking,
matching DefaultArtifactDescriptorReader.loadPom exactly; the root and
BOM-attribution builds keep full provenance, and the grades are
cache-partitioned by construction. Correctness proven identical across
the differential suites and corpus replay. The A/B answered the open
question negatively: tracking was not the warm cost — per-build
attribution puts it in ModelBuilder's inheritance assembly,
interpolation, and import processing, confirming the structural floor.
The internal selector now defaults to MAVEN; the legacy resolver stays
in-tree for one release as revert-by-release insurance, reachable only
through the internal dev/CI key. SHADOW returns the engine result in
every routing (it must never change behavior relative to the default),
and the single-scope projection routes through the engine in shadow
mode as well.
ChangeParentPom's speculative trial resolve and UpdateMavenModel's
re-resolution both meet Maven's model validation now: a transiently
invalid document (managed version renamed before its dependency, a new
parent that no longer manages a version the visitor later repairs)
keeps the previous resolution instead of failing the run.
UpdateMavenProjectPropertyJavaVersion reads the parent's requested pom
plugins, since the effective model interpolates ${java.version}-style
placeholders from session system properties exactly as Maven does
(ledger row L-P5-A-001).
…e 5)

The 216 tests that pinned legacy resolution behavior now assert what
real Maven does: fixtures gain pom packaging where Maven requires it
(aggregators, parents), tolerance tests assert Maven's model-validation
failures (self-parent, expression cycles, missing versions, systemPath),
duplicate declarations collapse on Maven's g:a:classifier:type conflict
key, relocations are followed, dependencyManagement metaversions stay
literal, and projected repositories carry only declared/inherited
entries. The parity comparison harness and the synthetic transport
suite pin the legacy engine explicitly where they document its
semantics until its deletion. Sixteen tests stay deliberately red on
engine-side gaps tracked for arbitration before release.
…s (Phase 5)

Code comments no longer reference parity-ledger row ids or its
classification vocabulary; the masks registry keeps ids as its
build-enforced data format. Adds ADR 0010 documenting the resolution
engine decision and backfills the ADR index.
…them (Phase 5)

Maven's interpolation value sources are user properties, then model
properties, then system properties; only injected (user) properties
override a pom-declared value. Inverts the legacy pin that had a bare
system property clobbering the pom.
Everything the pin inversion surfaced as a genuine engine-side gap:

- datedSnapshotVersion carries the timestamped form for genuine
  snapshots, derived from the already-cached metadata region with the
  legacy downloader's exact precedence; releases still duplicate the
  plain version deliberately.
- Transitive classifiers interpolate through the declaring pom's
  property lineage, like versions already did; an explicit empty
  classifier still threads as "".
- HTML-index metadata derivation now exists on the engine path
  (RegionMetadataResolver), with the same self-disable semantics and
  region caching; the synthetic suite asserts it engine-side.
- downloadError fires once per definitively-failed pom download at the
  CacheBridge seam.
- The transporter retries a connection-level http failure once over
  https and remembers the answer, replacing legacy's eager per-repo
  probe.
- Invalid descriptors of resolvable artifacts are tolerated at every
  depth, matching Maven's uniform ArtifactDescriptorPolicy; only
  missing POMs keep failing.
- Maven-1 poms skip dependency resolution again (deliberate leniency,
  like the modelVersion injection).
- Non-pom-type scope=import entries stay unexpanded exactly as Maven
  warns-and-retains them; legacy had over-expanded on scope alone.
- RemoveRedundantDependencyVersions compares interpolated plugin
  versions and respects inherited=false parent management;
  PrintMavenAsDot dedups edges by value rather than instance identity.
…es (Phase 5)

A synthetic single-dependency pom can be model-invalid under Maven's
validation exactly when a recipe edits away the constraint or platform
that supplied a version; record the failure like a download error and
fall back to the originally resolved dependency instead of failing the
run. The recorded exception type keeps the real class name — consumers
treat it as an opaque display string. Also forwards the dev/CI engine
selector to rewrite-gradle's forked test JVMs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

1 participant