View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.eclipse.aether.util.graph.transformer;
20  
21  import java.util.ArrayList;
22  import java.util.Collection;
23  import java.util.Collections;
24  import java.util.HashMap;
25  import java.util.HashSet;
26  import java.util.List;
27  import java.util.Map;
28  import java.util.Objects;
29  import java.util.Set;
30  
31  import org.eclipse.aether.ConfigurationProperties;
32  import org.eclipse.aether.RepositoryException;
33  import org.eclipse.aether.RepositorySystemSession;
34  import org.eclipse.aether.artifact.Artifact;
35  import org.eclipse.aether.collection.DependencyGraphTransformationContext;
36  import org.eclipse.aether.graph.DefaultDependencyNode;
37  import org.eclipse.aether.graph.Dependency;
38  import org.eclipse.aether.graph.DependencyNode;
39  import org.eclipse.aether.util.ConfigUtils;
40  import org.eclipse.aether.util.artifact.ArtifactIdUtils;
41  
42  import static java.util.Objects.requireNonNull;
43  
44  /**
45   * A high-performance dependency graph transformer that resolves version and scope conflicts among dependencies.
46   * This is the recommended conflict resolver implementation that provides O(N) performance characteristics,
47   * significantly improving upon the O(N²) worst-case performance of {@link ClassicConflictResolver}.
48   * <p>
49   * For a given set of conflicting nodes, one node will be chosen as the winner. How losing nodes are handled
50   * depends on the configured verbosity level: they may be removed entirely, have their children removed, or
51   * be left in place with conflict information. The exact rules by which a winning node and its effective scope
52   * are determined are controlled by user-supplied implementations of {@link ConflictResolver.VersionSelector}, {@link ConflictResolver.ScopeSelector},
53   * {@link ConflictResolver.OptionalitySelector} and {@link ConflictResolver.ScopeDeriver}.
54   * <p>
55   * <strong>Performance Characteristics:</strong>
56   * <ul>
57   * <li><strong>Time Complexity:</strong> O(N) where N is the number of dependency nodes</li>
58   * <li><strong>Memory Usage:</strong> Creates a parallel tree structure for conflict-free processing</li>
59   * <li><strong>Scalability:</strong> Excellent performance on large multi-module projects</li>
60   * </ul>
61   * <p>
62   * <strong>Algorithm Overview:</strong>
63   * <ol>
64   * <li><strong>Path Tree Construction:</strong> Builds a cycle-free parallel tree structure from the input
65   *     dependency graph, where each {@code Path} represents a unique route to a dependency node</li>
66   * <li><strong>Conflict Partitioning:</strong> Groups paths by conflict ID (based on groupId:artifactId:classifier:extension coordinates)</li>
67   * <li><strong>Topological Processing:</strong> Processes conflict groups in topologically sorted order</li>
68   * <li><strong>Winner Selection:</strong> Uses provided selectors to choose winners within each conflict group</li>
69   * <li><strong>Graph Transformation:</strong> Applies changes back to the original dependency graph</li>
70   * </ol>
71   * <p>
72   * <strong>Key Differences from {@link ClassicConflictResolver}:</strong>
73   * <ul>
74   * <li><strong>Performance:</strong> O(N) vs O(N²) time complexity</li>
75   * <li><strong>Memory Strategy:</strong> Uses parallel tree structure vs in-place graph modification</li>
76   * <li><strong>Cycle Handling:</strong> Explicitly breaks cycles during tree construction</li>
77   * <li><strong>Processing Order:</strong> Level-by-level from root vs depth-first traversal</li>
78   * </ul>
79   * <p>
80   * <strong>When to Use:</strong>
81   * <ul>
82   * <li>Default choice for all new projects and Maven 4+ installations</li>
83   * <li>Large multi-module projects with many dependencies</li>
84   * <li>Performance-critical build environments</li>
85   * <li>Any scenario where {@link ClassicConflictResolver} shows performance bottlenecks</li>
86   * </ul>
87   * <p>
88   * <strong>Implementation Note:</strong> This conflict resolver builds a cycle-free "parallel" structure based on the
89   * passed-in dependency graph, and applies operations level by level starting from the root. The parallel {@code Path}
90   * tree ensures that cycles in the original graph don't affect the conflict resolution algorithm's performance.
91   *
92   * @see ClassicConflictResolver
93   * @since 2.0.11
94   */
95  public final class PathConflictResolver extends ConflictResolver {
96      /**
97       * This implementation of conflict resolver is able to show more precise information regarding cycles in standard
98       * verbose mode. But, to make it really drop-in-replacement, we "tame down" this information. Still, users needing it
99       * may want to enable this for easier cycle detection, but in that case this conflict resolver will provide "extra nodes"
100      * not present on "standard verbosity level" with "classic" conflict resolver, that may lead to IT issues down the
101      * stream. Hence, the default is to provide as much information as much verbose "classic" does.
102      *
103      * @since 2.0.12
104      * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
105      * @configurationType {@link java.lang.Boolean}
106      * @configurationDefaultValue {@link #DEFAULT_SHOW_CYCLES_IN_STANDARD_VERBOSITY}
107      */
108     public static final String CONFIG_PROP_SHOW_CYCLES_IN_STANDARD_VERBOSITY = ConfigurationProperties.PREFIX_AETHER
109             + "conflictResolver." + ConflictResolver.PATH_CONFLICT_RESOLVER + ".showCyclesInStandardVerbosity";
110 
111     public static final boolean DEFAULT_SHOW_CYCLES_IN_STANDARD_VERBOSITY = false;
112 
113     private final ConflictResolver.VersionSelector versionSelector;
114     private final ConflictResolver.ScopeSelector scopeSelector;
115     private final ConflictResolver.ScopeDeriver scopeDeriver;
116     private final ConflictResolver.OptionalitySelector optionalitySelector;
117 
118     /**
119      * Creates a new conflict resolver instance with the specified hooks.
120      *
121      * @param versionSelector the version selector to use, must not be {@code null}
122      * @param scopeSelector the scope selector to use, must not be {@code null}
123      * @param optionalitySelector the optionality selector ot use, must not be {@code null}
124      * @param scopeDeriver the scope deriver to use, must not be {@code null}
125      */
126     public PathConflictResolver(
127             ConflictResolver.VersionSelector versionSelector,
128             ConflictResolver.ScopeSelector scopeSelector,
129             ConflictResolver.OptionalitySelector optionalitySelector,
130             ConflictResolver.ScopeDeriver scopeDeriver) {
131         this.versionSelector = requireNonNull(versionSelector, "version selector cannot be null");
132         this.scopeSelector = requireNonNull(scopeSelector, "scope selector cannot be null");
133         this.optionalitySelector = requireNonNull(optionalitySelector, "optionality selector cannot be null");
134         this.scopeDeriver = requireNonNull(scopeDeriver, "scope deriver cannot be null");
135     }
136 
137     @SuppressWarnings("unchecked")
138     @Override
139     public DependencyNode transformGraph(DependencyNode node, DependencyGraphTransformationContext context)
140             throws RepositoryException {
141         requireNonNull(node, "node cannot be null");
142         requireNonNull(context, "context cannot be null");
143         List<String> sortedConflictIds = (List<String>) context.get(TransformationContextKeys.SORTED_CONFLICT_IDS);
144         if (sortedConflictIds == null) {
145             ConflictIdSorter sorter = new ConflictIdSorter();
146             sorter.transformGraph(node, context);
147 
148             sortedConflictIds = (List<String>) context.get(TransformationContextKeys.SORTED_CONFLICT_IDS);
149         }
150 
151         @SuppressWarnings("unchecked")
152         Map<String, Object> stats = (Map<String, Object>) context.get(TransformationContextKeys.STATS);
153         long time1 = System.nanoTime();
154 
155         Map<DependencyNode, String> conflictIds =
156                 (Map<DependencyNode, String>) context.get(TransformationContextKeys.CONFLICT_IDS);
157         if (conflictIds == null) {
158             throw new RepositoryException("conflict groups have not been identified");
159         }
160 
161         State state = new State(
162                 ConflictResolver.getVerbosity(context.getSession()),
163                 ConfigUtils.getBoolean(
164                         context.getSession(),
165                         DEFAULT_SHOW_CYCLES_IN_STANDARD_VERBOSITY,
166                         CONFIG_PROP_SHOW_CYCLES_IN_STANDARD_VERBOSITY),
167                 versionSelector.getInstance(node, context),
168                 scopeSelector.getInstance(node, context),
169                 scopeDeriver.getInstance(node, context),
170                 optionalitySelector.getInstance(node, context),
171                 conflictIds,
172                 sortedConflictIds.size(),
173                 node);
174 
175         // loop over topographically sorted conflictIds
176         int conflictItemCount = 0;
177         for (String conflictId : sortedConflictIds) {
178             // paths in given conflict group to consider; filter out those moved out of scope
179             List<Path> allPaths = state.partitions.get(conflictId);
180             List<Path> activePaths = new ArrayList<>(allPaths.size());
181             List<ConflictItem> items = new ArrayList<>(allPaths.size());
182             for (Path p : allPaths) {
183                 if (!p.outOfScope) {
184                     activePaths.add(p);
185                     items.add(new ConflictItem(p));
186                 }
187             }
188             // Replace partition entry with filtered list to release references to out-of-scope
189             // paths (and their detached subtrees), allowing GC during resolution
190             state.partitions.put(conflictId, activePaths);
191             if (activePaths.isEmpty()) {
192                 // this means that whole group "fall out of scope" (are all on loser branches); skip
193                 continue;
194             }
195             conflictItemCount += activePaths.size();
196 
197             // create conflict context for given conflictId
198             ConflictContext ctx = new ConflictContext(node, state.conflictIds, items, conflictId);
199 
200             // select winner (is done by VersionSelector)
201             state.versionSelector.selectVersion(ctx);
202             if (ctx.winner == null) {
203                 throw new RepositoryException("conflict resolver did not select winner among " + items);
204             }
205             // select scope (no side effect between this and above operations)
206             state.scopeSelector.selectScope(ctx);
207             // select optionality (no side effect between this and above operations)
208             state.optionalitySelector.selectOptionality(ctx);
209 
210             // we have a winner path
211             Path winnerPath = ctx.winner.path;
212 
213             // mark conflictId as resolved with winner; sanity check
214             if (state.resolvedIds.containsKey(conflictId)) {
215                 throw new RepositoryException("conflict resolver already have winner for conflictId=" + conflictId
216                         + ": " + state.resolvedIds);
217             }
218             state.resolvedIds.put(conflictId, winnerPath);
219 
220             // loop over considered paths and apply selection results
221             for (Path path : activePaths) {
222                 // apply selected properties scope/optional to winner (winner carries version; others are losers)
223                 if (path == winnerPath) {
224                     path.scope = ctx.scope;
225                     path.optional = ctx.optional;
226                 }
227 
228                 // reset children as inheritance may be affected by this node scope/optionality change
229                 if (path.children != null) {
230                     for (Path c : path.children) {
231                         c.pull(0);
232                     }
233                 }
234                 // derive with new values from this to children only; observe winner flag
235                 path.derive(1, path == winnerPath);
236                 // push this node full level changes to DN graph
237                 path.push(0);
238             }
239         }
240 
241         if (stats != null) {
242             long time2 = System.nanoTime();
243             stats.put("ConflictResolver.totalTime", time2 - time1);
244             stats.put("ConflictResolver.conflictItemCount", conflictItemCount);
245         }
246 
247         return node;
248     }
249 
250     /**
251      * State of conflict resolution processing, to make this component (held in session) re-entrant by multiple threads.
252      */
253     private static class State {
254         /**
255          * Verbosity to be applied, see {@link ConflictResolver.Verbosity}.
256          */
257         private final ConflictResolver.Verbosity verbosity;
258 
259         /**
260          * Whether to show nodes entering cycles, for easier identification. If this is enabled, this implementation
261          * of conflict resolver will show more data than classic.
262          */
263         private final boolean showCyclesInStandardVerbosity;
264 
265         /**
266          * The {@link ConflictResolver.VersionSelector} to use.
267          */
268         private final ConflictResolver.VersionSelector versionSelector;
269 
270         /**
271          * The {@link ConflictResolver.ScopeSelector} to use.
272          */
273         private final ConflictResolver.ScopeSelector scopeSelector;
274 
275         /**
276          * The {@link ConflictResolver.ScopeDeriver} to use.
277          */
278         private final ConflictResolver.ScopeDeriver scopeDeriver;
279 
280         /**
281          * The {@link ConflictResolver.OptionalitySelector} to use/
282          */
283         private final ConflictResolver.OptionalitySelector optionalitySelector;
284 
285         /**
286          * The node to conflictId mapping from {@link ConflictMarker}.
287          */
288         private final Map<DependencyNode, String> conflictIds;
289 
290         /**
291          * A mapping from conflictId to paths represented as {@link Path}s that exist for each conflictId. In other
292          * words all paths to each {@link DependencyNode} that are member of same conflictId group.
293          * Uses {@link ArrayList} per partition; out-of-scope paths are marked via {@link Path#outOfScope} flag
294          * and filtered at query time, avoiding the per-entry overhead of LinkedHashSet/HashMap.Node.
295          */
296         private final Map<String, List<Path>> partitions;
297 
298         /**
299          * A mapping from conflictIds to winner {@link Path}, hence {@link DependencyNode}  for given conflictId.
300          */
301         private final Map<String, Path> resolvedIds;
302 
303         /**
304          * The root {@link Path}.
305          */
306         private final Path root;
307 
308         /**
309          * Pooled {@link ScopeContext} instance reused across derive() calls to avoid allocating a new
310          * object per node. Reset via {@link ScopeContext#reset(String, String)} before each use.
311          */
312         private final ScopeContext scopeContext;
313 
314         @SuppressWarnings("checkstyle:ParameterNumber")
315         private State(
316                 ConflictResolver.Verbosity verbosity,
317                 boolean showCyclesInStandardVerbosity,
318                 ConflictResolver.VersionSelector versionSelector,
319                 ConflictResolver.ScopeSelector scopeSelector,
320                 ConflictResolver.ScopeDeriver scopeDeriver,
321                 ConflictResolver.OptionalitySelector optionalitySelector,
322                 Map<DependencyNode, String> conflictIds,
323                 int conflictIdCount,
324                 DependencyNode node)
325                 throws RepositoryException {
326             this.verbosity = verbosity;
327             this.showCyclesInStandardVerbosity = showCyclesInStandardVerbosity;
328             this.versionSelector = versionSelector;
329             this.scopeSelector = scopeSelector;
330             this.scopeDeriver = scopeDeriver;
331             this.optionalitySelector = optionalitySelector;
332             this.conflictIds = conflictIds;
333             // Right-size maps: conflictIdCount gives exact number of partitions and resolved entries
334             this.partitions = new HashMap<>(conflictIdCount * 4 / 3 + 1);
335             this.resolvedIds = new HashMap<>(conflictIdCount * 4 / 3 + 1);
336             this.scopeContext = new ScopeContext(null, null);
337             this.root = build(node);
338         }
339 
340         /**
341          * Consumes the dirty graph and builds internal structures out of {@link Path} instances that is always a
342          * tree. As a side effect, {@link #partitions} are being filled up as well, that combined with topo
343          * sorted conflictIds can serve as a starting to point to walk the graph.
344          */
345         private Path build(DependencyNode node) throws RepositoryException {
346             String nodeConflictId = this.conflictIds.get(node);
347             Path root = new Path(this, node, nodeConflictId, null);
348             gatherCRNodes(root);
349             return root;
350         }
351 
352         /**
353          * Iteratively builds {@link Path} graph by observing each node associated {@link DependencyNode}.
354          * Uses an explicit stack instead of recursion to avoid {@link StackOverflowError} on very deep
355          * dependency graphs (reported in large multi-module projects with 13+ levels of recursion).
356          */
357         private void gatherCRNodes(Path root) throws RepositoryException {
358             ArrayList<Path> stack = new ArrayList<>();
359             stack.add(root);
360             while (!stack.isEmpty()) {
361                 Path node = stack.remove(stack.size() - 1);
362                 List<DependencyNode> children = node.dn.getChildren();
363                 if (!children.isEmpty()) {
364                     // add children; we will get back those really added (not causing cycles)
365                     List<Path> added = node.addChildren(children);
366                     // push in reverse order so first child is processed first (DFS order)
367                     for (int i = added.size() - 1; i >= 0; i--) {
368                         stack.add(added.get(i));
369                     }
370                 }
371             }
372         }
373     }
374 
375     /**
376      * Represents a unique path within the dependency graph from the root to a specific {@link DependencyNode}.
377      * This is the core data structure that enables the O(N) performance of {@link PathConflictResolver}.
378      * <p>
379      * <strong>Key Concepts:</strong>
380      * <ul>
381      * <li><strong>Path Uniqueness:</strong> Each {@code Path} instance represents a distinct route through
382      *     the dependency graph, even if multiple paths lead to the same {@code DependencyNode}</li>
383      * <li><strong>Cycle-Free Structure:</strong> The {@code Path} tree is guaranteed to be acyclic, even
384      *     when the original dependency graph contains cycles</li>
385      * <li><strong>Parallel Structure:</strong> This creates a "clean" tree alongside the original "dirty"
386      *     graph for efficient processing</li>
387      * </ul>
388      * <p>
389      * <strong>Example:</strong> If dependency A appears in the graph via two different routes:
390      * <pre>
391      * Root → B → A (path 1)
392      * Root → C → A (path 2)
393      * </pre>
394      * Two separate {@code Path} instances will be created, both pointing to the same {@code DependencyNode} A,
395      * but representing different paths through the dependency tree.
396      * <p>
397      * <strong>Memory Optimization:</strong> While this creates additional objects, it enables the algorithm
398      * to process conflicts in O(N) time rather than O(N²), making it much more efficient for large graphs.
399      * <p>
400      * <strong>Conflict Resolution:</strong> Paths are grouped by conflict ID (based on groupId:artifactId:classifier:extension coordinates),
401      * and the conflict resolution algorithm can efficiently process each group independently.
402      */
403     private static class Path {
404         // given
405         private final State state;
406         private DependencyNode dn;
407         private final String conflictId;
408         private final Path parent;
409         // derived
410         private final int depth;
411         // Set of conflict IDs on the path from root to this node (inclusive), enabling O(1) cycle detection.
412         // Each node copies its parent's set and adds its own conflictId. Since dependency tree depth is
413         // bounded in practice (< 30), the per-node copy cost is negligible compared to the O(depth)
414         // parent-chain walk it replaces.
415         private final Set<String> conflictIdsOnPath;
416         // Lazy: null for leaf nodes (never populated by addChildren), right-sized for non-leaves.
417         // This avoids allocating an ArrayList + backing array for every leaf node in the tree
418         // (typically 60-70% of all nodes), saving ~40 bytes per leaf.
419         private List<Path> children;
420         // mutated
421         private String scope;
422         private boolean optional;
423         // Flag used instead of removing from partition sets; avoids LinkedHashSet overhead (~48 bytes/entry)
424         private boolean outOfScope;
425 
426         private Path(State state, DependencyNode dn, String conflictId, Path parent) {
427             this.state = state;
428             this.dn = dn;
429             this.conflictId = conflictId;
430             this.parent = parent;
431             this.depth = parent != null ? parent.depth + 1 : 0;
432             if (parent != null) {
433                 this.conflictIdsOnPath = new HashSet<>(parent.conflictIdsOnPath);
434             } else {
435                 this.conflictIdsOnPath = new HashSet<>();
436             }
437             this.conflictIdsOnPath.add(this.conflictId);
438             pull(0);
439 
440             this.state
441                     .partitions
442                     .computeIfAbsent(this.conflictId, k -> new ArrayList<>())
443                     .add(this);
444         }
445 
446         /**
447          * Checks whether the given conflictId appears on the path from this node to the root.
448          * Uses a pre-built {@link HashSet} of conflict IDs accumulated along the path from root,
449          * making this an O(1) operation instead of the previous O(depth) parent-chain walk that
450          * showed up as a JFR hotspot (3.9% CPU) in large multi-module builds.
451          */
452         private boolean hasConflictIdOnPathToRoot(String targetConflictId) {
453             return conflictIdsOnPath.contains(targetConflictId);
454         }
455 
456         /**
457          * Pulls (possibly updated) scope and optional values from associated {@link DependencyNode} to this instance,
458          * going down toward children recursively the required count of levels.
459          */
460         private void pull(int levels) {
461             Dependency d = dn.getDependency();
462             if (d != null) {
463                 this.scope = d.getScope();
464                 this.optional = d.isOptional();
465             } else {
466                 this.scope = "";
467                 this.optional = false;
468             }
469             int newLevels = levels - 1;
470             if (newLevels >= 0 && this.children != null) {
471                 for (Path child : this.children) {
472                     child.pull(newLevels);
473                 }
474             }
475         }
476 
477         /**
478          * Derives (from this to children direction) values that are "inherited" in tree: scope and optionality in the tree
479          * recursively going down required count of "levels".
480          */
481         private void derive(int levels, boolean winner) throws RepositoryException {
482             if (!winner) {
483                 if (this.parent != null) {
484                     if ((dn.getManagedBits() & DependencyNode.MANAGED_SCOPE) == 0) {
485                         state.scopeContext.reset(this.parent.scope, this.scope);
486                         state.scopeDeriver.deriveScope(state.scopeContext);
487                         this.scope = state.scopeContext.derivedScope;
488                     }
489                     if ((dn.getManagedBits() & DependencyNode.MANAGED_OPTIONAL) == 0) {
490                         if (!this.optional && this.parent.optional) {
491                             this.optional = true;
492                         }
493                     }
494                 } else {
495                     this.scope = "";
496                     this.optional = false;
497                 }
498             }
499             int newLevels = levels - 1;
500             if (newLevels >= 0 && this.children != null) {
501                 for (Path child : this.children) {
502                     child.derive(newLevels, false);
503                 }
504             }
505         }
506 
507         /**
508          * Pushes (applies) the scope and optional and structural changes to associated {@link DependencyNode} modifying
509          * the graph of it. Verbosity is observed, and depending on it the conflicting/loser nodes are removed, or
510          * just their children is removed (with special care for version ranges, see {@link #relatedSiblingsCount(Artifact, Path)}
511          * or by just doing nothing with them only marking losers in full verbosity mode.
512          */
513         private void push(int levels) {
514             if (this.parent != null) {
515                 Path winner = this.state.resolvedIds.get(this.conflictId);
516                 if (winner == null) {
517                     throw new IllegalStateException(
518                             "Winner selection did not happen for conflictId=" + this.conflictId);
519                 }
520                 if (!Objects.equals(winner.conflictId, this.conflictId)) {
521                     throw new IllegalStateException(
522                             "ConflictId mix-up: this=" + this.conflictId + " winner=" + winner.conflictId);
523                 }
524 
525                 if (winner == this) {
526                     // copy onto dn; if applicable
527                     if (this.dn.getDependency() != null) {
528                         this.dn.setData(
529                                 ConflictResolver.NODE_DATA_ORIGINAL_SCOPE,
530                                 this.dn.getDependency().getScope());
531                         this.dn.setData(
532                                 ConflictResolver.NODE_DATA_ORIGINAL_OPTIONALITY,
533                                 this.dn.getDependency().getOptional());
534                         this.dn.setScope(this.scope);
535                         this.dn.setOptional(this.optional);
536                     }
537                 } else {
538                     // loser; move out of scope
539                     moveOutOfScope();
540                     boolean markLoser = false;
541                     switch (state.verbosity) {
542                         case NONE:
543                             // remove loser dn
544                             if (this.parent.children != null) {
545                                 this.parent.children.remove(this);
546                             }
547                             this.parent.dn.setChildren(new ArrayList<>(this.parent.dn.getChildren()));
548                             this.parent.dn.getChildren().remove(this.dn);
549                             this.children = null;
550                             break;
551                         case STANDARD:
552                             // is redundant if:
553                             // - is not same as winner, and has related siblings (version range)
554                             // - same instance of DN is direct dependency on path leading here
555                             boolean isRedundant =
556                                     (!ArtifactIdUtils.equalsId(this.dn.getArtifact(), winner.dn.getArtifact())
557                                             && relatedSiblingsCount(this.dn.getArtifact(), this.parent) > 1);
558                             if (!this.state.showCyclesInStandardVerbosity) {
559                                 isRedundant = isRedundant
560                                         || this.parent.isDirectDependencyOnPathToRoot(this.dn.getArtifact());
561                             }
562                             if (isRedundant) {
563                                 // is redundant dn; remove dn
564                                 if (this.parent.children != null) {
565                                     this.parent.children.remove(this);
566                                 }
567                                 this.parent.dn.setChildren(new ArrayList<>(this.parent.dn.getChildren()));
568                                 this.parent.dn.getChildren().remove(this.dn);
569                                 this.children = null;
570                             } else {
571                                 // copy loser dn; without children
572                                 DependencyNode dnCopy = new DefaultDependencyNode(this.dn);
573                                 dnCopy.setChildren(Collections.emptyList());
574 
575                                 // swap it out in DN graph; in case of cycles this may happen more than once
576                                 int idx = this.parent.dn.getChildren().indexOf(this.dn);
577                                 if (idx >= 0) {
578                                     this.parent.dn.getChildren().set(idx, dnCopy);
579                                 }
580                                 this.dn = dnCopy;
581 
582                                 this.children = null;
583                                 markLoser = true;
584                             }
585                             break;
586                         case FULL:
587                             // copy loser dn; with children
588                             DependencyNode dnCopy = new DefaultDependencyNode(this.dn);
589                             dnCopy.setChildren(new ArrayList<>(this.dn.getChildren()));
590 
591                             // swap it out in DN graph; in case of cycles this may happen more than once
592                             int idx = this.parent.dn.getChildren().indexOf(this.dn);
593                             if (idx >= 0) {
594                                 this.parent.dn.getChildren().set(idx, dnCopy);
595                             }
596                             this.dn = dnCopy;
597 
598                             markLoser = true;
599                             break;
600                         default:
601                             throw new IllegalArgumentException("Unknown " + state.verbosity);
602                     }
603                     if (markLoser) {
604                         this.dn.setData(ConflictResolver.NODE_DATA_WINNER, winner.dn);
605                         this.dn.setData(
606                                 ConflictResolver.NODE_DATA_ORIGINAL_SCOPE,
607                                 this.dn.getDependency().getScope());
608                         this.dn.setData(
609                                 ConflictResolver.NODE_DATA_ORIGINAL_OPTIONALITY,
610                                 this.dn.getDependency().getOptional());
611                         this.dn.setScope(this.scope);
612                         this.dn.setOptional(this.optional);
613                     }
614                 }
615             }
616 
617             // Note: push() is always called with levels=0, so newLevels would be -1
618             // and the recursive block would never execute. The recursive structure is
619             // intentionally not present; all push() calls happen from the main loop.
620         }
621 
622         /**
623          * Returns {@code true} if given artifact is a direct dependency on the path leading from this toward root.
624          * A "direct dependency" is one at depth 1 (immediate child of root). Rather than recursing through every
625          * ancestor, this walks directly to the depth-1 node and performs one allocation-free comparison.
626          * <p>
627          * Note: this check and use of this method is ONLY present to make this conflict resolver produce SAME output
628          * as {@link ClassicConflictResolver} does, but IMHO this rule here is very arbitrary, moreover, in "standard"
629          * (where it is only used) verbosity it in facts HIDES the trace of possible cycles.
630          *
631          * @see #CONFIG_PROP_SHOW_CYCLES_IN_STANDARD_VERBOSITY
632          */
633         private boolean isDirectDependencyOnPathToRoot(Artifact artifact) {
634             // Walk up to depth-1 ancestor (direct dependency of root) instead of recursing every level
635             Path current = this;
636             while (current != null && current.depth > 1) {
637                 current = current.parent;
638             }
639             return current != null
640                     && current.depth == 1
641                     && ArtifactIdUtils.equalsVersionlessId(current.dn.getArtifact(), artifact);
642         }
643 
644         /**
645          * Counts "relatives" (GACE equal) artifacts under same parent; this is for cleaning up redundant nodes in
646          * case of version ranges, where same GACE is resolved into multiple GACEV as range is resolved. In {@link ConflictResolver.Verbosity#STANDARD}
647          * verbosity mode we remove "redundant" nodes (of a range) leaving only "winner equal" loser, that have same GACEV as winner.
648          */
649         private int relatedSiblingsCount(Artifact artifact, Path parent) {
650             if (parent.children == null) {
651                 return 0;
652             }
653             String groupId = artifact.getGroupId();
654             String artifactId = artifact.getArtifactId();
655             int count = 0;
656             for (Path n : parent.children) {
657                 Artifact a = n.dn.getArtifact();
658                 if (Objects.equals(groupId, a.getGroupId()) && Objects.equals(artifactId, a.getArtifactId())) {
659                     count++;
660                 }
661             }
662             return count;
663         }
664 
665         /**
666          * Marks this and all child {@link Path} nodes as out of scope; essentially marks whole subtree
667          * from "this and below" as loser, to not be considered in subsequent winner selections.
668          * Uses a boolean flag instead of removing from partition collections, avoiding the per-entry
669          * overhead of LinkedHashSet (~48 bytes/entry). Out-of-scope paths are filtered at query time.
670          * <p>
671          * Uses an explicit stack instead of recursion to avoid {@link StackOverflowError} on deep
672          * dependency graphs, consistent with the iterative approach in
673          * {@link State#gatherCRNodes(Path)}. Also nulls children references on out-of-scope nodes
674          * to allow GC of detached subtrees during resolution.
675          */
676         private void moveOutOfScope() {
677             ArrayList<Path> stack = new ArrayList<>();
678             stack.add(this);
679             while (!stack.isEmpty()) {
680                 Path node = stack.remove(stack.size() - 1);
681                 node.outOfScope = true;
682                 if (node.children != null) {
683                     stack.addAll(node.children);
684                     node.children = null;
685                 }
686             }
687         }
688 
689         /**
690          * Adds node children: this method should be "batch" used, as all (potential) children should be added at once.
691          * Method will return really added {@link Path} instances, as this class avoids cycles. Those forming a cycle
692          * are not recursed (not returned in list), keeping {@link Path} cycle free.
693          * <p>
694          * Cycle detection is performed via {@link #hasConflictIdOnPathToRoot(String)} which uses
695          * a pre-built {@link HashSet} of conflict IDs accumulated along the path from root, making
696          * each check O(1). Since dependency tree depth is bounded in practice (< 30), the per-node
697          * set copy cost is negligible.
698          * This implies that this conflict resolver, by its nature "redoes" the
699          * {@link TransformationContextKeys#CYCLIC_CONFLICT_IDS} calculated by {@link ConflictIdSorter}.
700          */
701         private List<Path> addChildren(List<DependencyNode> children) throws RepositoryException {
702             // Right-size the children list to avoid ArrayList default capacity waste
703             this.children = new ArrayList<>(children.size());
704             ArrayList<Path> added = new ArrayList<>(children.size());
705             for (DependencyNode child : children) {
706                 String childConflictId = this.state.conflictIds.get(child);
707                 boolean cycle = hasConflictIdOnPathToRoot(childConflictId);
708                 Path c = new Path(this.state, child, childConflictId, this);
709                 this.children.add(c);
710                 c.derive(0, false);
711                 if (!cycle) {
712                     added.add(c);
713                 }
714             }
715             return added;
716         }
717 
718         /**
719          * Dump for debug.
720          */
721         private void dump(String padding) {
722             System.out.println(padding + this.dn + ": " + this.scope + "/" + this.optional);
723             if (this.children != null) {
724                 for (Path child : this.children) {
725                     child.dump(padding + "  ");
726                 }
727             }
728         }
729 
730         /**
731          * For easier debug.
732          */
733         @Override
734         public String toString() {
735             return this.dn.toString();
736         }
737     }
738 
739     /**
740      * A context used to hold information that is relevant for deriving the scope of a child dependency.
741      *
742      * @see ConflictResolver.ScopeDeriver
743      * @noinstantiate This class is not intended to be instantiated by clients in production code, the constructor may
744      *                change without notice and only exists to enable unit testing
745      */
746     private static final class ScopeContext extends ConflictResolver.ScopeContext {
747         private String parentScope;
748         private String childScope;
749         private String derivedScope;
750 
751         /**
752          * Creates a new scope context with the specified properties.
753          *
754          * @param parentScope the scope of the parent dependency, may be {@code null}
755          * @param childScope the scope of the child dependency, may be {@code null}
756          * @noreference This class is not intended to be instantiated by clients in production code, the constructor may
757          *              change without notice and only exists to enable unit testing
758          */
759         private ScopeContext(String parentScope, String childScope) {
760             this.parentScope = (parentScope != null) ? parentScope : "";
761             this.derivedScope = (childScope != null) ? childScope : "";
762             this.childScope = (childScope != null) ? childScope : "";
763         }
764 
765         /**
766          * Resets this context for reuse, avoiding allocation of a new instance per derive() call.
767          */
768         private void reset(String parentScope, String childScope) {
769             this.parentScope = (parentScope != null) ? parentScope : "";
770             this.derivedScope = (childScope != null) ? childScope : "";
771             this.childScope = (childScope != null) ? childScope : "";
772         }
773 
774         /**
775          * Gets the scope of the parent dependency. This is usually the scope that was derived by earlier invocations of
776          * the scope deriver.
777          *
778          * @return the scope of the parent dependency, never {@code null}
779          */
780         public String getParentScope() {
781             return parentScope;
782         }
783 
784         /**
785          * Gets the original scope of the child dependency. This is the scope that was declared in the artifact
786          * descriptor of the parent dependency.
787          *
788          * @return the original scope of the child dependency, never {@code null}
789          */
790         public String getChildScope() {
791             return childScope;
792         }
793 
794         /**
795          * Gets the derived scope of the child dependency. This is initially equal to {@link #getChildScope()} until the
796          * scope deriver makes changes.
797          *
798          * @return the derived scope of the child dependency, never {@code null}
799          */
800         public String getDerivedScope() {
801             return derivedScope;
802         }
803 
804         /**
805          * Sets the derived scope of the child dependency.
806          *
807          * @param derivedScope the derived scope of the dependency, may be {@code null}
808          */
809         public void setDerivedScope(String derivedScope) {
810             this.derivedScope = (derivedScope != null) ? derivedScope : "";
811         }
812     }
813 
814     /**
815      * A conflicting dependency.
816      *
817      * @noinstantiate This class is not intended to be instantiated by clients in production code, the constructor may
818      *                change without notice and only exists to enable unit testing
819      */
820     private static final class ConflictItem extends ConflictResolver.ConflictItem {
821         private final Path path;
822         private final List<DependencyNode> parent;
823         private final Artifact artifact;
824         private final DependencyNode node;
825         private final int depth;
826         private final String scope;
827         private final int optionalities;
828 
829         private ConflictItem(Path path) {
830             this.path = path;
831             if (path.parent != null) {
832                 DependencyNode parent = path.parent.dn;
833                 this.parent = parent.getChildren();
834                 this.artifact = parent.getArtifact();
835             } else {
836                 this.parent = null;
837                 this.artifact = null;
838             }
839             this.node = path.dn;
840             this.depth = path.depth;
841             this.scope = path.scope;
842             this.optionalities = path.optional ? OPTIONAL_TRUE : OPTIONAL_FALSE;
843         }
844 
845         /**
846          * Determines whether the specified conflict item is a sibling of this item.
847          *
848          * @param item the other conflict item, must not be {@code null}
849          * @return {@code true} if the given item has the same parent as this item, {@code false} otherwise
850          */
851         @Override
852         public boolean isSibling(ConflictResolver.ConflictItem item) {
853             return parent == ((ConflictItem) item).parent;
854         }
855 
856         /**
857          * Gets the dependency node involved in the conflict.
858          *
859          * @return the involved dependency node, never {@code null}
860          */
861         @Override
862         public DependencyNode getNode() {
863             return node;
864         }
865 
866         /**
867          * Gets the dependency involved in the conflict, short for {@code getNode.getDependency()}.
868          *
869          * @return the involved dependency, never {@code null}
870          */
871         @Override
872         public Dependency getDependency() {
873             return node.getDependency();
874         }
875 
876         /**
877          * Gets the zero-based depth at which the conflicting node occurs in the graph. As such, the depth denotes the
878          * number of parent nodes. If actually multiple paths lead to the node, the return value denotes the smallest
879          * possible depth.
880          *
881          * @return the zero-based depth of the node in the graph
882          */
883         @Override
884         public int getDepth() {
885             return depth;
886         }
887 
888         /**
889          * Gets the derived scopes of the dependency. In general, the same dependency node could be reached via
890          * different paths and each path might result in a different derived scope.
891          *
892          * @return the (read-only) set of derived scopes of the dependency, never {@code null}
893          * @see ConflictResolver.ScopeDeriver
894          */
895         @Override
896         public Collection<String> getScopes() {
897             return Collections.singleton(scope);
898         }
899 
900         /**
901          * Gets the derived optionalities of the dependency. In general, the same dependency node could be reached via
902          * different paths and each path might result in a different derived optionality.
903          *
904          * @return a bit field consisting of {@link PathConflictResolver.ConflictItem#OPTIONAL_FALSE} and/or
905          *         {@link PathConflictResolver.ConflictItem#OPTIONAL_TRUE} indicating the derived optionalities the
906          *         dependency was encountered with
907          */
908         @Override
909         public int getOptionalities() {
910             return optionalities;
911         }
912 
913         @Override
914         public String toString() {
915             return node + " @ " + depth + " < " + artifact;
916         }
917     }
918 
919     /**
920      * A context used to hold information that is relevant for resolving version and scope conflicts.
921      *
922      * @see ConflictResolver.VersionSelector
923      * @see ConflictResolver.ScopeSelector
924      * @noinstantiate This class is not intended to be instantiated by clients in production code, the constructor may
925      *                change without notice and only exists to enable unit testing
926      */
927     private static final class ConflictContext extends ConflictResolver.ConflictContext {
928         private final DependencyNode root;
929         private final Map<DependencyNode, String> conflictIds;
930         private final Collection<ConflictResolver.ConflictItem> items;
931         private final String conflictId;
932 
933         // elected properties
934         private ConflictItem winner;
935         private String scope;
936         private Boolean optional;
937 
938         private ConflictContext(
939                 DependencyNode root,
940                 Map<DependencyNode, String> conflictIds,
941                 Collection<ConflictItem> items,
942                 String conflictId) {
943             this.root = root;
944             this.conflictIds = conflictIds;
945             this.items = Collections.unmodifiableCollection(items);
946             this.conflictId = conflictId;
947         }
948 
949         /**
950          * Gets the root node of the dependency graph being transformed.
951          *
952          * @return the root node of the dependency graph, never {@code null}
953          */
954         @Override
955         public DependencyNode getRoot() {
956             return root;
957         }
958 
959         /**
960          * Determines whether the specified dependency node belongs to this conflict context.
961          *
962          * @param node the dependency node to check, must not be {@code null}
963          * @return {@code true} if the given node belongs to this conflict context, {@code false} otherwise
964          */
965         @Override
966         public boolean isIncluded(DependencyNode node) {
967             return conflictId.equals(conflictIds.get(node));
968         }
969 
970         /**
971          * Gets the collection of conflict items in this context.
972          *
973          * @return the (read-only) collection of conflict items in this context, never {@code null}
974          */
975         @Override
976         public Collection<ConflictResolver.ConflictItem> getItems() {
977             return items;
978         }
979 
980         /**
981          * Gets the conflict item which has been selected as the winner among the conflicting dependencies.
982          *
983          * @return the winning conflict item or {@code null} if not set yet
984          */
985         @Override
986         public ConflictResolver.ConflictItem getWinner() {
987             return winner;
988         }
989 
990         /**
991          * Sets the conflict item which has been selected as the winner among the conflicting dependencies.
992          *
993          * @param winner the winning conflict item, may be {@code null}
994          */
995         @Override
996         public void setWinner(ConflictResolver.ConflictItem winner) {
997             this.winner = (ConflictItem) winner;
998         }
999 
1000         /**
1001          * Gets the effective scope of the winning dependency.
1002          *
1003          * @return the effective scope of the winning dependency or {@code null} if none
1004          */
1005         @Override
1006         public String getScope() {
1007             return scope;
1008         }
1009 
1010         /**
1011          * Sets the effective scope of the winning dependency.
1012          *
1013          * @param scope the effective scope, may be {@code null}
1014          */
1015         @Override
1016         public void setScope(String scope) {
1017             this.scope = scope;
1018         }
1019 
1020         /**
1021          * Gets the effective optional flag of the winning dependency.
1022          *
1023          * @return the effective optional flag or {@code null} if none
1024          */
1025         @Override
1026         public Boolean getOptional() {
1027             return optional;
1028         }
1029 
1030         /**
1031          * Sets the effective optional flag of the winning dependency.
1032          *
1033          * @param optional the effective optional flag, may be {@code null}
1034          */
1035         @Override
1036         public void setOptional(Boolean optional) {
1037             this.optional = optional;
1038         }
1039 
1040         @Override
1041         public String toString() {
1042             return winner + " @ " + scope + " < " + items;
1043         }
1044     }
1045 }