Summary
The BfDependencyCollector.doRecurse() method has a cache-transparency issue: whether a dependency node retains its children depends on whether the pool cache hits or misses — a performance optimization is changing the semantic result.
Root cause
doRecurse() uses two independent caching/dedup layers with different keys:
- Pool cache — keyed on
(artifact, repos, selector, manager, traverser, filter), i.e. it includes the DependencyManager (compared via equals).
- GACE skipper — keyed on artifact identity only (
G:A:C:E, no version, no manager).
The interaction at BfDependencyCollector.java lines ~400-436:
List<DependencyNode> children = pool.getChildren(key); // key includes DependencyManager
if (children == null) { // POOL MISS → skipper is consulted
if (!skipper.skipResolution(child, parents)) {
// resolve children, cache in pool
}
// else: node gets ZERO children (skipper marked it as duplicate)
} else { // POOL HIT → skipper is NEVER consulted
child.setChildren(children);
}
When the pool hits, the skipper is bypassed and the node gets cached children. When the pool misses, the skipper IS consulted and may mark the node as skippedAsDuplicate, giving it zero children. The ConflictResolver (in default Verbosity.NONE) then prunes childless conflict-loser nodes entirely.
This means the graph structure depends on whether the pool cache hits or misses — which is a function of DependencyManager.equals(), not of the dependency being resolved.
How this manifests
Discovered via apache/maven-shade-plugin#819. Consider a project where:
- Direct dependency
a depends on b (no classifier) and b:alt (classifier "alt")
- Both
b and b:alt share a transitive dependency c
Under Maven 3.10 (ClassicDependencyManager): at depth ≥ 2, deriveChildManager() returns the same instance → pool key for c under b equals pool key for c under b:alt → pool hit → skipper is never consulted → c appears under both variants.
Under Maven 4 (TransitiveDependencyManager): deriveChildManager() always creates a new instance with a unique path field (added in #1539) → pool key differs → pool miss → skipper marks c under b:alt as skippedAsDuplicate → c gets zero children → ConflictResolver prunes it → c disappears from b:alt's subtree.
The same Resolver 2.0.20 produces different graphs under Maven 3.10 vs Maven 4, purely due to the DependencyManager choice.
Reproducer
Standalone reproducer (no plugin build needed): https://github.com/aschemaven/mshade-819-reproducer
Results:
| Maven |
Resolver |
c under b |
c under b:alt |
| 3.9.16 |
1.x |
✅ present |
✅ present |
| 3.10.0-rc-1 |
2.0.20 |
✅ present |
✅ present |
| 4.0.0-rc-5 |
2.0.13 |
✅ present |
❌ pruned |
4.0.0-rc-5 + -Daether.conflictResolver.verbose=true |
2.0.13 |
✅ present |
✅ present |
Suggested fix
Make the pool cache transparent — either:
- Always consult the skipper regardless of pool hit/miss, but let the pool provide cached children if the skipper says "don't skip".
- On pool miss + skipper skip, still try to provide cached children from the winning node's pool entry (since the GACE keys match), so the node retains its children even when the pool key differs.
Either approach would make the graph structure independent of the DependencyManager.equals() behavior.
Workaround
The maven-shade-plugin has a workaround in PR #820: collecting with ConflictResolver.CONFIG_PROP_VERBOSE = STANDARD so pruned nodes are retained as markers. This is a valid fix on the plugin side, but other plugins that walk the dependency graph may be silently affected by the same issue.
Summary
The
BfDependencyCollector.doRecurse()method has a cache-transparency issue: whether a dependency node retains its children depends on whether the pool cache hits or misses — a performance optimization is changing the semantic result.Root cause
doRecurse()uses two independent caching/dedup layers with different keys:(artifact, repos, selector, manager, traverser, filter), i.e. it includes theDependencyManager(compared viaequals).G:A:C:E, no version, no manager).The interaction at BfDependencyCollector.java lines ~400-436:
When the pool hits, the skipper is bypassed and the node gets cached children. When the pool misses, the skipper IS consulted and may mark the node as
skippedAsDuplicate, giving it zero children. TheConflictResolver(in defaultVerbosity.NONE) then prunes childless conflict-loser nodes entirely.This means the graph structure depends on whether the pool cache hits or misses — which is a function of
DependencyManager.equals(), not of the dependency being resolved.How this manifests
Discovered via apache/maven-shade-plugin#819. Consider a project where:
adepends onb(no classifier) andb:alt(classifier "alt")bandb:altshare a transitive dependencycUnder Maven 3.10 (
ClassicDependencyManager): at depth ≥ 2,deriveChildManager()returns the same instance → pool key forcunderbequals pool key forcunderb:alt→ pool hit → skipper is never consulted →cappears under both variants.Under Maven 4 (
TransitiveDependencyManager):deriveChildManager()always creates a new instance with a uniquepathfield (added in #1539) → pool key differs → pool miss → skipper markscunderb:altasskippedAsDuplicate→cgets zero children →ConflictResolverprunes it →cdisappears fromb:alt's subtree.The same Resolver 2.0.20 produces different graphs under Maven 3.10 vs Maven 4, purely due to the
DependencyManagerchoice.Reproducer
Standalone reproducer (no plugin build needed): https://github.com/aschemaven/mshade-819-reproducer
Results:
cunderbcunderb:alt-Daether.conflictResolver.verbose=trueSuggested fix
Make the pool cache transparent — either:
Either approach would make the graph structure independent of the
DependencyManager.equals()behavior.Workaround
The maven-shade-plugin has a workaround in PR #820: collecting with
ConflictResolver.CONFIG_PROP_VERBOSE = STANDARDso pruned nodes are retained as markers. This is a valid fix on the plugin side, but other plugins that walk the dependency graph may be silently affected by the same issue.