You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by cs...@apache.org on 2025/08/28 19:36:10 UTC

(maven-resolver) branch master updated: ConflictResolver improvements (#1558)

This is an automated email from the ASF dual-hosted git repository.

cstamas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-resolver.git


The following commit(s) were added to refs/heads/master by this push:
     new ffa2177cb ConflictResolver improvements (#1558)
ffa2177cb is described below

commit ffa2177cb8719c00a79c229b7b9bebdaec58ce30
Author: Tamas Cservenak <ta...@cservenak.net>
AuthorDate: Thu Aug 28 21:36:06 2025 +0200

    ConflictResolver improvements (#1558)
    
    The ConflictResolver class was last bit that was identical between Resolver 1.x and 2.x and it had a nasty property: in "worst case" it has performance of O(N^2) where N=partition count, that in some projects (see projects generated with https://github.com/maven-turbo-reactor/maven-multiproject-generator) is same as module count and can be huge.
    
    Moreover, Maven 4 does "one extra" collection on `install` to build consumer POM. This impacted Maven performance a lot, see https://github.com/apache/maven/issues/2481
    
    This PR introduces following changes related to ConflictResolver:
    * changes to make possible for more ConflictResolver implementations to coexist
    * The old one is renamed to "classic" conflict resolver
    * implemented "path" conflict resolver
    * UTs run against both
    * **this is a breaking change** for integrators (mostly Maven only, as others use suppliers and are protected!), but luckily conflict resolver was never part of public API aside of instantiating one... now client code must choose between `ClassicConflictResolver` and `PathConflictResolver`.
    
    There is one UT added that comes from BF/DF resolver UTs too.
    
    The "path" conflict resolver idea:
    Input is "dirty rooted graph" (as it may contain cycles) coming from Collector. Moreover, due memory savings, same instance of `DependencyNode` may be present on different paths in the graph (even without cycles involved). Finally, the `DependencyNode.children` list may be shared across unrelated instances (again, memory saving). I did not want to touch collectors at all, but I "lived" with these things.
    
    Approach was to introduce `Path` type, that "builds a parallel" graph to input graph but only to up to cycle beginnings (verbosity decides what happens with node, is removed, is left with children/cycle removed, etc). Hence, `Path` graph from root is always a tree. Then, the parent-child "inheritance" is applied (scope + optionality) and also `Path` records conflictId it belongs to (partition). Basically, each instance of `Path` represents a path to given `Path`'s `DependencyNode`, as [...]
    
    Then, based on topologically sorted conflictIds, I am selecting "winner", and applying changes to related `Path`s, recursively applying to children (but only one level!) or unlinking them (eliminating them fully from graph).
    
    Finally, changes are "pushed" to parallel existing `DependencyNode` graph applying required changes (scope, optional, version, unlinking).
    
    At the end, DN graph is:
    * verbosity=none, a tree pruned from all of redundant/loser nodes
    * verbosity=standard a tree with redundant/loser nodes left in place containing recorded information and children/cycle trimmed
    * verbosirt=full a graph (if it had cycles, it still have them), but the tree subset of it is "decorated" with information as in case of standard verbosity.
---
 .../GetDependencyHierarchyWithConflicts.java       |   5 +-
 .../internal/impl/scope/ScopeManagerImpl.java      |   5 +-
 .../DependencyCollectorDelegateTestSupport.java    |   4 +-
 .../aether/supplier/SessionBuilderSupplier.java    |   4 +-
 .../aether/supplier/SessionBuilderSupplier.java    | 110 ++-
 maven-resolver-util/pom.xml                        |   5 +
 ...tResolver.java => ClassicConflictResolver.java} | 457 ++--------
 .../util/graph/transformer/ConflictResolver.java   | 971 +++------------------
 .../graph/transformer/PathConflictResolver.java    | 906 +++++++++++++++++++
 .../transformer/AbstractConflictResolverTest.java  | 107 +++
 .../ConfigurableVersionSelectorTest.java           | 151 ++--
 .../graph/transformer/ConflictResolverTest.java    | 165 ++--
 .../graph/transformer/JavaScopeSelectorTest.java   | 107 ++-
 .../transformer/NearestVersionSelectorTest.java    | 143 +--
 .../transformer/SimpleOptionalitySelectorTest.java |  48 +-
 ...tedSubtreeOnDescriptorDependenciesEmptyLeft.txt |  11 +
 pom.xml                                            |   4 +
 17 files changed, 1709 insertions(+), 1494 deletions(-)

diff --git a/maven-resolver-demos/maven-resolver-demo-snippets/src/main/java/org/apache/maven/resolver/examples/GetDependencyHierarchyWithConflicts.java b/maven-resolver-demos/maven-resolver-demo-snippets/src/main/java/org/apache/maven/resolver/examples/GetDependencyHierarchyWithConflicts.java
index aacf00bb3..7d80b1fde 100644
--- a/maven-resolver-demos/maven-resolver-demo-snippets/src/main/java/org/apache/maven/resolver/examples/GetDependencyHierarchyWithConflicts.java
+++ b/maven-resolver-demos/maven-resolver-demo-snippets/src/main/java/org/apache/maven/resolver/examples/GetDependencyHierarchyWithConflicts.java
@@ -30,6 +30,7 @@ import org.eclipse.aether.resolution.ArtifactDescriptorRequest;
 import org.eclipse.aether.resolution.ArtifactDescriptorResult;
 import org.eclipse.aether.util.graph.manager.DependencyManagerUtils;
 import org.eclipse.aether.util.graph.transformer.ChainedDependencyGraphTransformer;
+import org.eclipse.aether.util.graph.transformer.ClassicConflictResolver;
 import org.eclipse.aether.util.graph.transformer.ConfigurableVersionSelector;
 import org.eclipse.aether.util.graph.transformer.ConflictResolver;
 import org.eclipse.aether.util.graph.transformer.JavaDependencyContextRefiner;
@@ -58,7 +59,7 @@ public class GetDependencyHierarchyWithConflicts {
             sessionBuilder.setConfigProperty(DependencyManagerUtils.CONFIG_PROP_VERBOSE, true);
             try (CloseableSession session = sessionBuilder
                     .setDependencyGraphTransformer(new ChainedDependencyGraphTransformer(
-                            new ConflictResolver(
+                            new ClassicConflictResolver(
                                     new ConfigurableVersionSelector(
                                             new ConfigurableVersionSelector.MajorVersionConvergence(
                                                     new ConfigurableVersionSelector.Nearest())),
@@ -103,7 +104,7 @@ public class GetDependencyHierarchyWithConflicts {
             sessionBuilder.setConfigProperty(DependencyManagerUtils.CONFIG_PROP_VERBOSE, true);
             try (CloseableSession session = sessionBuilder
                     .setDependencyGraphTransformer(new ChainedDependencyGraphTransformer(
-                            new ConflictResolver(
+                            new ClassicConflictResolver(
                                     new ConfigurableVersionSelector(new ConfigurableVersionSelector.VersionConvergence(
                                             new ConfigurableVersionSelector.Nearest())),
                                     new JavaScopeSelector(),
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/scope/ScopeManagerImpl.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/scope/ScopeManagerImpl.java
index becfaffd4..45d3b2b20 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/scope/ScopeManagerImpl.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/scope/ScopeManagerImpl.java
@@ -50,7 +50,7 @@ import org.eclipse.aether.util.graph.selector.AndDependencySelector;
 import org.eclipse.aether.util.graph.selector.ExclusionDependencySelector;
 import org.eclipse.aether.util.graph.transformer.ChainedDependencyGraphTransformer;
 import org.eclipse.aether.util.graph.transformer.ConfigurableVersionSelector;
-import org.eclipse.aether.util.graph.transformer.ConflictResolver;
+import org.eclipse.aether.util.graph.transformer.PathConflictResolver;
 import org.eclipse.aether.util.graph.transformer.SimpleOptionalitySelector;
 import org.eclipse.aether.util.graph.visitor.CloningDependencyVisitor;
 import org.eclipse.aether.util.graph.visitor.FilteringDependencyVisitor;
@@ -159,8 +159,9 @@ public final class ScopeManagerImpl implements InternalScopeManager {
 
     @Override
     public DependencyGraphTransformer getDependencyGraphTransformer(ResolutionScope resolutionScope) {
+        // TODO: this should be parameterized!
         return new ChainedDependencyGraphTransformer(
-                new ConflictResolver(
+                new PathConflictResolver(
                         new ConfigurableVersionSelector(), new ManagedScopeSelector(this),
                         new SimpleOptionalitySelector(), new ManagedScopeDeriver(this)),
                 new ManagedDependencyContextRefiner(this));
diff --git a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/collect/DependencyCollectorDelegateTestSupport.java b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/collect/DependencyCollectorDelegateTestSupport.java
index ca2f1b3e6..ff52c3f43 100644
--- a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/collect/DependencyCollectorDelegateTestSupport.java
+++ b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/collect/DependencyCollectorDelegateTestSupport.java
@@ -51,7 +51,7 @@ import org.eclipse.aether.util.graph.manager.ClassicDependencyManager;
 import org.eclipse.aether.util.graph.manager.DefaultDependencyManager;
 import org.eclipse.aether.util.graph.manager.DependencyManagerUtils;
 import org.eclipse.aether.util.graph.manager.TransitiveDependencyManager;
-import org.eclipse.aether.util.graph.transformer.ConflictResolver;
+import org.eclipse.aether.util.graph.transformer.ClassicConflictResolver;
 import org.eclipse.aether.util.graph.transformer.JavaScopeDeriver;
 import org.eclipse.aether.util.graph.transformer.JavaScopeSelector;
 import org.eclipse.aether.util.graph.transformer.NearestVersionSelector;
@@ -567,7 +567,7 @@ public abstract class DependencyCollectorDelegateTestSupport {
     void testDescriptorDependenciesEmpty() throws Exception {
         collector = setupCollector(newReader("dependencies-empty/"));
 
-        session.setDependencyGraphTransformer(new ConflictResolver(
+        session.setDependencyGraphTransformer(new ClassicConflictResolver(
                 new NearestVersionSelector(),
                 new JavaScopeSelector(),
                 new SimpleOptionalitySelector(),
diff --git a/maven-resolver-supplier-mvn3/src/main/java/org/eclipse/aether/supplier/SessionBuilderSupplier.java b/maven-resolver-supplier-mvn3/src/main/java/org/eclipse/aether/supplier/SessionBuilderSupplier.java
index 07f0ea7ac..f6aaeb83a 100644
--- a/maven-resolver-supplier-mvn3/src/main/java/org/eclipse/aether/supplier/SessionBuilderSupplier.java
+++ b/maven-resolver-supplier-mvn3/src/main/java/org/eclipse/aether/supplier/SessionBuilderSupplier.java
@@ -42,10 +42,10 @@ import org.eclipse.aether.util.graph.selector.AndDependencySelector;
 import org.eclipse.aether.util.graph.selector.ExclusionDependencySelector;
 import org.eclipse.aether.util.graph.transformer.ChainedDependencyGraphTransformer;
 import org.eclipse.aether.util.graph.transformer.ConfigurableVersionSelector;
-import org.eclipse.aether.util.graph.transformer.ConflictResolver;
 import org.eclipse.aether.util.graph.transformer.JavaDependencyContextRefiner;
 import org.eclipse.aether.util.graph.transformer.JavaScopeDeriver;
 import org.eclipse.aether.util.graph.transformer.JavaScopeSelector;
+import org.eclipse.aether.util.graph.transformer.PathConflictResolver;
 import org.eclipse.aether.util.graph.transformer.SimpleOptionalitySelector;
 import org.eclipse.aether.util.graph.traverser.FatArtifactTraverser;
 import org.eclipse.aether.util.repository.SimpleArtifactDescriptorPolicy;
@@ -102,7 +102,7 @@ public class SessionBuilderSupplier implements Supplier<SessionBuilder> {
 
     protected DependencyGraphTransformer getDependencyGraphTransformer() {
         return new ChainedDependencyGraphTransformer(
-                new ConflictResolver(
+                new PathConflictResolver(
                         new ConfigurableVersionSelector(), new JavaScopeSelector(),
                         new SimpleOptionalitySelector(), new JavaScopeDeriver()),
                 new JavaDependencyContextRefiner());
diff --git a/maven-resolver-supplier-mvn4/src/main/java/org/eclipse/aether/supplier/SessionBuilderSupplier.java b/maven-resolver-supplier-mvn4/src/main/java/org/eclipse/aether/supplier/SessionBuilderSupplier.java
index c548b7a25..b96c7e8ff 100644
--- a/maven-resolver-supplier-mvn4/src/main/java/org/eclipse/aether/supplier/SessionBuilderSupplier.java
+++ b/maven-resolver-supplier-mvn4/src/main/java/org/eclipse/aether/supplier/SessionBuilderSupplier.java
@@ -18,14 +18,42 @@
  */
 package org.eclipse.aether.supplier;
 
+import java.util.Arrays;
 import java.util.Locale;
+import java.util.Objects;
 import java.util.function.Supplier;
 
-import org.apache.maven.repository.internal.MavenSessionBuilderSupplier;
+import org.apache.maven.api.DependencyScope;
+import org.apache.maven.repository.internal.artifact.FatArtifactTraverser;
+import org.apache.maven.repository.internal.scopes.Maven4ScopeManagerConfiguration;
+import org.apache.maven.repository.internal.type.DefaultTypeProvider;
 import org.apache.maven.utils.Os;
 import org.eclipse.aether.RepositorySystem;
+import org.eclipse.aether.RepositorySystemSession;
 import org.eclipse.aether.RepositorySystemSession.CloseableSession;
 import org.eclipse.aether.RepositorySystemSession.SessionBuilder;
+import org.eclipse.aether.artifact.ArtifactTypeRegistry;
+import org.eclipse.aether.collection.DependencyGraphTransformer;
+import org.eclipse.aether.collection.DependencyManager;
+import org.eclipse.aether.collection.DependencySelector;
+import org.eclipse.aether.collection.DependencyTraverser;
+import org.eclipse.aether.impl.scope.InternalScopeManager;
+import org.eclipse.aether.internal.impl.scope.ManagedDependencyContextRefiner;
+import org.eclipse.aether.internal.impl.scope.ManagedScopeDeriver;
+import org.eclipse.aether.internal.impl.scope.ManagedScopeSelector;
+import org.eclipse.aether.internal.impl.scope.OptionalDependencySelector;
+import org.eclipse.aether.internal.impl.scope.ScopeDependencySelector;
+import org.eclipse.aether.internal.impl.scope.ScopeManagerImpl;
+import org.eclipse.aether.resolution.ArtifactDescriptorPolicy;
+import org.eclipse.aether.util.artifact.DefaultArtifactTypeRegistry;
+import org.eclipse.aether.util.graph.manager.ClassicDependencyManager;
+import org.eclipse.aether.util.graph.selector.AndDependencySelector;
+import org.eclipse.aether.util.graph.selector.ExclusionDependencySelector;
+import org.eclipse.aether.util.graph.transformer.ChainedDependencyGraphTransformer;
+import org.eclipse.aether.util.graph.transformer.NearestVersionSelector;
+import org.eclipse.aether.util.graph.transformer.PathConflictResolver;
+import org.eclipse.aether.util.graph.transformer.SimpleOptionalitySelector;
+import org.eclipse.aether.util.repository.SimpleArtifactDescriptorPolicy;
 
 /**
  * A simple {@link Supplier} of {@link SessionBuilder} instances, that on each call supplies newly
@@ -36,14 +64,77 @@ import org.eclipse.aether.RepositorySystemSession.SessionBuilder;
  *
  * @since 2.0.0
  */
-public class SessionBuilderSupplier extends MavenSessionBuilderSupplier {
+public class SessionBuilderSupplier {
+    protected final RepositorySystem repositorySystem;
+    protected final InternalScopeManager scopeManager;
+
     public SessionBuilderSupplier(RepositorySystem repositorySystem) {
-        super(repositorySystem);
+        this.repositorySystem = (RepositorySystem) Objects.requireNonNull(repositorySystem);
+        this.scopeManager = new ScopeManagerImpl(Maven4ScopeManagerConfiguration.INSTANCE);
+    }
+
+    /** @deprecated */
+    @Deprecated
+    public SessionBuilderSupplier() {
+        this.repositorySystem = null;
+        this.scopeManager = new ScopeManagerImpl(Maven4ScopeManagerConfiguration.INSTANCE);
+    }
+
+    protected InternalScopeManager getScopeManager() {
+        return this.scopeManager;
+    }
+
+    protected DependencyTraverser getDependencyTraverser() {
+        return new FatArtifactTraverser();
+    }
+
+    protected DependencyManager getDependencyManager() {
+        return this.getDependencyManager(true);
+    }
+
+    public DependencyManager getDependencyManager(boolean transitive) {
+        return new ClassicDependencyManager(transitive, this.getScopeManager());
+    }
+
+    protected DependencySelector getDependencySelector() {
+        return new AndDependencySelector(new DependencySelector[] {
+            ScopeDependencySelector.legacy(
+                    null, Arrays.asList(DependencyScope.TEST.id(), DependencyScope.PROVIDED.id())),
+            OptionalDependencySelector.fromDirect(),
+            new ExclusionDependencySelector()
+        });
+    }
+
+    protected DependencyGraphTransformer getDependencyGraphTransformer() {
+        return new ChainedDependencyGraphTransformer(new DependencyGraphTransformer[] {
+            new PathConflictResolver(
+                    new NearestVersionSelector(),
+                    new ManagedScopeSelector(this.getScopeManager()),
+                    new SimpleOptionalitySelector(),
+                    new ManagedScopeDeriver(this.getScopeManager())),
+            new ManagedDependencyContextRefiner(this.getScopeManager())
+        });
+    }
+
+    protected ArtifactTypeRegistry getArtifactTypeRegistry() {
+        DefaultArtifactTypeRegistry stereotypes = new DefaultArtifactTypeRegistry();
+        new DefaultTypeProvider().types().forEach(stereotypes::add);
+        return stereotypes;
+    }
+
+    protected ArtifactDescriptorPolicy getArtifactDescriptorPolicy() {
+        return new SimpleArtifactDescriptorPolicy(true, true);
     }
 
-    @Override
-    protected void configureSessionBuilder(SessionBuilder session) {
-        super.configureSessionBuilder(session);
+    protected void configureSessionBuilder(RepositorySystemSession.SessionBuilder session) {
+        session.setDependencyTraverser(this.getDependencyTraverser());
+        session.setDependencyManager(this.getDependencyManager());
+        session.setDependencySelector(this.getDependencySelector());
+        session.setDependencyGraphTransformer(this.getDependencyGraphTransformer());
+        session.setArtifactTypeRegistry(this.getArtifactTypeRegistry());
+        session.setArtifactDescriptorPolicy(this.getArtifactDescriptorPolicy());
+        session.setScopeManager(this.getScopeManager());
+
         session.setSystemProperties(System.getProperties());
         boolean caseSensitive = !Os.IS_WINDOWS;
         System.getenv().forEach((key, value) -> {
@@ -51,4 +142,11 @@ public class SessionBuilderSupplier extends MavenSessionBuilderSupplier {
             session.setSystemProperty(key, value);
         });
     }
+
+    public RepositorySystemSession.SessionBuilder get() {
+        Objects.requireNonNull(this.repositorySystem, "repositorySystem");
+        RepositorySystemSession.SessionBuilder builder = this.repositorySystem.createSessionBuilder();
+        this.configureSessionBuilder(builder);
+        return builder;
+    }
 }
diff --git a/maven-resolver-util/pom.xml b/maven-resolver-util/pom.xml
index 089cb977a..ae6f4e7c8 100644
--- a/maven-resolver-util/pom.xml
+++ b/maven-resolver-util/pom.xml
@@ -53,6 +53,11 @@
       <artifactId>junit-jupiter-api</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.junit.jupiter</groupId>
+      <artifactId>junit-jupiter-params</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <build>
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConflictResolver.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ClassicConflictResolver.java
similarity index 64%
copy from maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConflictResolver.java
copy to maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ClassicConflictResolver.java
index ade67a527..2b807aebc 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConflictResolver.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ClassicConflictResolver.java
@@ -19,7 +19,6 @@
 package org.eclipse.aether.util.graph.transformer;
 
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
@@ -31,12 +30,9 @@ import java.util.ListIterator;
 import java.util.Map;
 import java.util.Objects;
 
-import org.eclipse.aether.ConfigurationProperties;
 import org.eclipse.aether.RepositoryException;
-import org.eclipse.aether.RepositorySystemSession;
 import org.eclipse.aether.artifact.Artifact;
 import org.eclipse.aether.collection.DependencyGraphTransformationContext;
-import org.eclipse.aether.collection.DependencyGraphTransformer;
 import org.eclipse.aether.graph.DefaultDependencyNode;
 import org.eclipse.aether.graph.Dependency;
 import org.eclipse.aether.graph.DependencyNode;
@@ -45,120 +41,51 @@ import org.eclipse.aether.util.artifact.ArtifactIdUtils;
 import static java.util.Objects.requireNonNull;
 
 /**
- * A dependency graph transformer that resolves version and scope conflicts among dependencies. For a given set of
- * conflicting nodes, one node will be chosen as the winner and the other nodes are removed from the dependency graph.
- * The exact rules by which a winning node and its effective scope are determined are controlled by user-supplied
- * implementations of {@link VersionSelector}, {@link ScopeSelector}, {@link OptionalitySelector} and
- * {@link ScopeDeriver}.
+ * A legacy dependency graph transformer that resolves version and scope conflicts among dependencies.
+ * This implementation maintains backward compatibility with Maven 3.x and Resolver 1.x behavior but has
+ * O(N²) worst-case performance characteristics. For new projects, consider using {@link PathConflictResolver}.
  * <p>
- * By default, this graph transformer will turn the dependency graph into a tree without duplicate artifacts. Using the
- * configuration property {@link #CONFIG_PROP_VERBOSE}, a verbose mode can be enabled where the graph is still turned
- * into a tree but all nodes participating in a conflict are retained. The nodes that were rejected during conflict
- * resolution have no children and link back to the winner node via the {@link #NODE_DATA_WINNER} key in their custom
- * data. Additionally, the keys {@link #NODE_DATA_ORIGINAL_SCOPE} and {@link #NODE_DATA_ORIGINAL_OPTIONALITY} are used
- * to store the original scope and optionality of each node. Obviously, the resulting dependency tree is not suitable
- * for artifact resolution unless a filter is employed to exclude the duplicate dependencies.
+ * For a given set of conflicting nodes, one node will be chosen as the winner. How losing nodes are handled
+ * depends on the configured verbosity level: they may be removed entirely, have their children removed, or
+ * be left in place with conflict information. The exact rules by which a winning node and its effective scope
+ * are determined are controlled by user-supplied implementations of {@link VersionSelector}, {@link ScopeSelector},
+ * {@link OptionalitySelector} and {@link ScopeDeriver}.
  * <p>
- * This transformer will query the keys {@link TransformationContextKeys#CONFLICT_IDS},
- * {@link TransformationContextKeys#SORTED_CONFLICT_IDS}, {@link TransformationContextKeys#CYCLIC_CONFLICT_IDS} for
- * existing information about conflict ids. In absence of this information, it will automatically invoke the
- * {@link ConflictIdSorter} to calculate it.
+ * <strong>Performance Characteristics:</strong>
+ * <ul>
+ * <li><strong>Time Complexity:</strong> O(N²) worst-case where N is the number of dependency nodes</li>
+ * <li><strong>Memory Usage:</strong> Modifies the dependency graph in-place</li>
+ * <li><strong>Scalability:</strong> Performance degrades significantly on large multi-module projects</li>
+ * </ul>
+ * <p>
+ * <strong>Algorithm Overview:</strong>
+ * <ol>
+ * <li><strong>Depth-First Traversal:</strong> Walks the dependency graph depth-first</li>
+ * <li><strong>In-Place Modification:</strong> Modifies nodes directly during traversal</li>
+ * <li><strong>Conflict Detection:</strong> Identifies conflicts by comparing conflict IDs</li>
+ * <li><strong>Winner Selection:</strong> Uses provided selectors to choose winners</li>
+ * <li><strong>Loser Removal:</strong> Removes or marks losing nodes during traversal</li>
+ * </ol>
+ * <p>
+ * <strong>When to Use:</strong>
+ * <ul>
+ * <li>Exact backward compatibility with Maven 3.x/Resolver 1.x is required</li>
+ * <li>Debugging performance differences between old and new algorithms</li>
+ * <li>Small projects where performance is not a concern</li>
+ * <li>Testing and validation scenarios</li>
+ * </ul>
+ * <p>
+ * <strong>Migration Recommendation:</strong> New projects should use {@link PathConflictResolver} for better
+ * performance. This implementation is retained primarily for compatibility and testing purposes.
+ * <p>
+ * <strong>Implementation Note:</strong> This conflict resolver is identical to the one used in Maven 3/Resolver 1.x.
+ * The implementation may produce O(N²) worst-case performance on projects with many small conflict groups
+ * (typically one member each), which is common in large multi-module projects.
+ *
+ * @see PathConflictResolver
+ * @since 2.0.11
  */
-public final class ConflictResolver implements DependencyGraphTransformer {
-
-    /**
-     * The key in the repository session's {@link org.eclipse.aether.RepositorySystemSession#getConfigProperties()
-     * configuration properties} used to store a {@link Boolean} flag controlling the transformer's verbose mode.
-     * Accepted values are Boolean types, String type (where "true" would be interpreted as {@code true})
-     * or Verbosity enum instances.
-     *
-     * @configurationSource {@link RepositorySystemSession#getConfigProperties()}
-     * @configurationType {@link java.lang.Object}
-     * @configurationDefaultValue "NONE"
-     */
-    public static final String CONFIG_PROP_VERBOSE = ConfigurationProperties.PREFIX_AETHER + "conflictResolver.verbose";
-
-    /**
-     * The enum representing verbosity levels of conflict resolver.
-     *
-     * @since 1.9.8
-     */
-    public enum Verbosity {
-        /**
-         * Verbosity level to be used in all "common" resolving use cases (ie. dependencies to build class path). The
-         * {@link ConflictResolver} in this mode will trim down the graph to the barest minimum: will not leave
-         * any conflicting node in place, hence no conflicts will be present in transformed graph either.
-         */
-        NONE,
-
-        /**
-         * Verbosity level to be used in "analyze" resolving use cases (ie. dependency convergence calculations). The
-         * {@link ConflictResolver} in this mode will remove any redundant collected nodes, in turn it will leave one
-         * with recorded conflicting information. This mode corresponds to "classic verbose" mode when
-         * {@link #CONFIG_PROP_VERBOSE} was set to {@code true}. Obviously, the resulting dependency tree is not
-         * suitable for artifact resolution unless a filter is employed to exclude the duplicate dependencies.
-         */
-        STANDARD,
-
-        /**
-         * Verbosity level to be used in "analyze" resolving use cases (ie. dependency convergence calculations). The
-         * {@link ConflictResolver} in this mode will not remove any collected node, in turn it will record on all
-         * eliminated nodes the conflicting information. Obviously, the resulting dependency tree is not suitable
-         * for artifact resolution unless a filter is employed to exclude the duplicate dependencies.
-         */
-        FULL
-    }
-
-    /**
-     * Helper method that uses {@link RepositorySystemSession} and {@link #CONFIG_PROP_VERBOSE} key to figure out
-     * current {@link Verbosity}: if {@link Boolean} or {@code String} found, returns {@link Verbosity#STANDARD}
-     * or {@link Verbosity#NONE}, depending on value (string is parsed with {@link Boolean#parseBoolean(String)}
-     * for {@code true} or {@code false} correspondingly. This is to retain "existing" behavior, where the config
-     * key accepted only these values.
-     * Since 1.9.8 release, this key may contain {@link Verbosity} enum instance as well, in which case that instance
-     * is returned.
-     * This method never returns {@code null}.
-     */
-    private static Verbosity getVerbosity(RepositorySystemSession session) {
-        final Object verbosityValue = session.getConfigProperties().get(CONFIG_PROP_VERBOSE);
-        if (verbosityValue instanceof Boolean) {
-            return (Boolean) verbosityValue ? Verbosity.STANDARD : Verbosity.NONE;
-        } else if (verbosityValue instanceof String) {
-            return Boolean.parseBoolean(verbosityValue.toString()) ? Verbosity.STANDARD : Verbosity.NONE;
-        } else if (verbosityValue instanceof Verbosity) {
-            return (Verbosity) verbosityValue;
-        } else if (verbosityValue != null) {
-            throw new IllegalArgumentException("Unsupported Verbosity configuration: " + verbosityValue);
-        }
-        return Verbosity.NONE;
-    }
-
-    /**
-     * The key in the dependency node's {@link DependencyNode#getData() custom data} under which a reference to the
-     * {@link DependencyNode} which has won the conflict is stored.
-     */
-    public static final String NODE_DATA_WINNER = "conflict.winner";
-
-    /**
-     * The key in the dependency node's {@link DependencyNode#getData() custom data} under which the scope of the
-     * dependency before scope derivation and conflict resolution is stored.
-     */
-    public static final String NODE_DATA_ORIGINAL_SCOPE = "conflict.originalScope";
-
-    /**
-     * The key in the dependency node's {@link DependencyNode#getData() custom data} under which the optional flag of
-     * the dependency before derivation and conflict resolution is stored.
-     */
-    public static final String NODE_DATA_ORIGINAL_OPTIONALITY = "conflict.originalOptionality";
-
-    private final VersionSelector versionSelector;
-
-    private final ScopeSelector scopeSelector;
-
-    private final ScopeDeriver scopeDeriver;
-
-    private final OptionalitySelector optionalitySelector;
-
+public final class ClassicConflictResolver extends ConflictResolver {
     /**
      * Creates a new conflict resolver instance with the specified hooks.
      *
@@ -167,15 +94,12 @@ public final class ConflictResolver implements DependencyGraphTransformer {
      * @param optionalitySelector The optionality selector ot use, must not be {@code null}.
      * @param scopeDeriver The scope deriver to use, must not be {@code null}.
      */
-    public ConflictResolver(
+    public ClassicConflictResolver(
             VersionSelector versionSelector,
             ScopeSelector scopeSelector,
             OptionalitySelector optionalitySelector,
             ScopeDeriver scopeDeriver) {
-        this.versionSelector = requireNonNull(versionSelector, "version selector cannot be null");
-        this.scopeSelector = requireNonNull(scopeSelector, "scope selector cannot be null");
-        this.optionalitySelector = requireNonNull(optionalitySelector, "optionality selector cannot be null");
-        this.scopeDeriver = requireNonNull(scopeDeriver, "scope deriver cannot be null");
+        super(versionSelector, scopeSelector, optionalitySelector, scopeDeriver);
     }
 
     @SuppressWarnings("unchecked")
@@ -237,7 +161,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
                 if (ctx.winner == null) {
                     throw new RepositoryException("conflict resolver did not select winner among " + state.items);
                 }
-                DependencyNode winner = ctx.winner.node;
+                DependencyNode winner = ((ConflictItem) (ctx.winner)).node;
 
                 state.scopeSelector.selectScope(ctx);
                 if (Verbosity.NONE != state.verbosity) {
@@ -262,7 +186,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
 
             // in case of cycles, trigger final graph walk to ensure all leftover losers are gone
             if (!it.hasNext() && !conflictIdCycles.isEmpty() && state.conflictCtx.winner != null) {
-                DependencyNode winner = state.conflictCtx.winner.node;
+                DependencyNode winner = ((ConflictItem) (state.conflictCtx).winner).node;
                 // Note: using non-existing key here (empty) as that one for sure was not met
                 state.prepare("", null);
                 gatherConflictItems(winner, state);
@@ -301,7 +225,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
     }
 
     private static void removeLosers(State state) {
-        ConflictItem winner = state.conflictCtx.winner;
+        ConflictItem winner = ((ConflictItem) state.conflictCtx.winner);
         String winnerArtifactId = ArtifactIdUtils.toId(winner.node.getArtifact());
         List<DependencyNode> previousParent = null;
         ListIterator<DependencyNode> childIt = null;
@@ -596,10 +520,10 @@ public final class ConflictResolver implements DependencyGraphTransformer {
             parentInfos = new ArrayList<>(64);
             conflictCtx = new ConflictContext(root, conflictIds, items);
             scopeCtx = new ScopeContext(null, null);
-            versionSelector = ConflictResolver.this.versionSelector.getInstance(root, context);
-            scopeSelector = ConflictResolver.this.scopeSelector.getInstance(root, context);
-            scopeDeriver = ConflictResolver.this.scopeDeriver.getInstance(root, context);
-            optionalitySelector = ConflictResolver.this.optionalitySelector.getInstance(root, context);
+            versionSelector = ClassicConflictResolver.this.versionSelector.getInstance(root, context);
+            scopeSelector = ClassicConflictResolver.this.scopeSelector.getInstance(root, context);
+            scopeDeriver = ClassicConflictResolver.this.scopeDeriver.getInstance(root, context);
+            optionalitySelector = ClassicConflictResolver.this.optionalitySelector.getInstance(root, context);
         }
 
         void prepare(String conflictId, Collection<String> cyclicPredecessors) {
@@ -634,7 +558,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
         }
 
         void winner() {
-            resolvedIds.put(currentId, (conflictCtx.winner != null) ? conflictCtx.winner.node : null);
+            resolvedIds.put(currentId, (conflictCtx.winner != null) ? ((ConflictItem) conflictCtx.winner).node : null);
         }
 
         boolean loser(DependencyNode node, String conflictId) {
@@ -784,13 +708,10 @@ public final class ConflictResolver implements DependencyGraphTransformer {
      * @noinstantiate This class is not intended to be instantiated by clients in production code, the constructor may
      *                change without notice and only exists to enable unit testing.
      */
-    public static final class ScopeContext {
-
-        String parentScope;
-
-        String childScope;
-
-        String derivedScope;
+    private static final class ScopeContext implements ConflictResolver.ScopeContext {
+        private String parentScope;
+        private String childScope;
+        private String derivedScope;
 
         /**
          * Creates a new scope context with the specified properties.
@@ -800,7 +721,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          * @noreference This class is not intended to be instantiated by clients in production code, the constructor may
          *              change without notice and only exists to enable unit testing.
          */
-        public ScopeContext(String parentScope, String childScope) {
+        private ScopeContext(String parentScope, String childScope) {
             this.parentScope = (parentScope != null) ? parentScope : "";
             derivedScope = (childScope != null) ? childScope : "";
             this.childScope = (childScope != null) ? childScope : "";
@@ -812,6 +733,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @return The scope of the parent dependency, never {@code null}.
          */
+        @Override
         public String getParentScope() {
             return parentScope;
         }
@@ -822,6 +744,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @return The original scope of the child dependency, never {@code null}.
          */
+        @Override
         public String getChildScope() {
             return childScope;
         }
@@ -832,6 +755,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @return The derived scope of the child dependency, never {@code null}.
          */
+        @Override
         public String getDerivedScope() {
             return derivedScope;
         }
@@ -841,6 +765,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @param derivedScope The derived scope of the dependency, may be {@code null}.
          */
+        @Override
         public void setDerivedScope(String derivedScope) {
             this.derivedScope = (derivedScope != null) ? derivedScope : "";
         }
@@ -852,7 +777,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
      * @noinstantiate This class is not intended to be instantiated by clients in production code, the constructor may
      *                change without notice and only exists to enable unit testing.
      */
-    public static final class ConflictItem {
+    private static final class ConflictItem implements ConflictResolver.ConflictItem {
 
         // nodes can share child lists, we care about the unique owner of a child node which is the child list
         final List<DependencyNode> parent;
@@ -881,7 +806,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          */
         public static final int OPTIONAL_TRUE = 0x02;
 
-        ConflictItem(DependencyNode parent, DependencyNode node, String scope, boolean optional) {
+        private ConflictItem(DependencyNode parent, DependencyNode node, String scope, boolean optional) {
             if (parent != null) {
                 this.parent = parent.getChildren();
                 this.artifact = parent.getArtifact();
@@ -894,37 +819,15 @@ public final class ConflictResolver implements DependencyGraphTransformer {
             this.optionalities = optional ? OPTIONAL_TRUE : OPTIONAL_FALSE;
         }
 
-        /**
-         * Creates a new conflict item with the specified properties.
-         *
-         * @param parent The parent node of the conflicting dependency, may be {@code null}.
-         * @param node The conflicting dependency, must not be {@code null}.
-         * @param depth The zero-based depth of the conflicting dependency.
-         * @param optionalities The optionalities the dependency was encountered with, encoded as a bit field consisting
-         *            of {@link ConflictResolver.ConflictItem#OPTIONAL_TRUE} and
-         *            {@link ConflictResolver.ConflictItem#OPTIONAL_FALSE}.
-         * @param scopes The derived scopes of the conflicting dependency, must not be {@code null}.
-         * @noreference This class is not intended to be instantiated by clients in production code, the constructor may
-         *              change without notice and only exists to enable unit testing.
-         */
-        public ConflictItem(
-                DependencyNode parent, DependencyNode node, int depth, int optionalities, String... scopes) {
-            this.parent = (parent != null) ? parent.getChildren() : null;
-            this.artifact = (parent != null) ? parent.getArtifact() : null;
-            this.node = node;
-            this.depth = depth;
-            this.optionalities = optionalities;
-            this.scopes = Arrays.asList(scopes);
-        }
-
         /**
          * Determines whether the specified conflict item is a sibling of this item.
          *
          * @param item The other conflict item, must not be {@code null}.
          * @return {@code true} if the given item has the same parent as this item, {@code false} otherwise.
          */
-        public boolean isSibling(ConflictItem item) {
-            return parent == item.parent;
+        @Override
+        public boolean isSibling(ConflictResolver.ConflictItem item) {
+            return parent == ((ConflictItem) item).parent;
         }
 
         /**
@@ -932,6 +835,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @return The involved dependency node, never {@code null}.
          */
+        @Override
         public DependencyNode getNode() {
             return node;
         }
@@ -941,6 +845,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @return The involved dependency, never {@code null}.
          */
+        @Override
         public Dependency getDependency() {
             return node.getDependency();
         }
@@ -952,6 +857,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @return The zero-based depth of the node in the graph.
          */
+        @Override
         public int getDepth() {
             return depth;
         }
@@ -964,6 +870,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          * @return The (read-only) set of derived scopes of the dependency, never {@code null}.
          */
         @SuppressWarnings("unchecked")
+        @Override
         public Collection<String> getScopes() {
             if (scopes instanceof String) {
                 return Collections.singleton((String) scopes);
@@ -991,6 +898,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *         {@link ConflictResolver.ConflictItem#OPTIONAL_TRUE} indicating the derived optionalities the
          *         dependency was encountered with.
          */
+        @Override
         public int getOptionalities() {
             return optionalities;
         }
@@ -1013,67 +921,29 @@ public final class ConflictResolver implements DependencyGraphTransformer {
      * @noinstantiate This class is not intended to be instantiated by clients in production code, the constructor may
      *                change without notice and only exists to enable unit testing.
      */
-    public static final class ConflictContext {
-
+    private static final class ConflictContext implements ConflictResolver.ConflictContext {
         final DependencyNode root;
-
         final Map<DependencyNode, String> conflictIds;
-
-        final Collection<ConflictItem> items;
+        final Collection<ConflictResolver.ConflictItem> items;
 
         String conflictId;
-
-        ConflictItem winner;
-
+        ConflictResolver.ConflictItem winner;
         String scope;
-
         Boolean optional;
 
-        ConflictContext(DependencyNode root, Map<DependencyNode, String> conflictIds, Collection<ConflictItem> items) {
+        private ConflictContext(
+                DependencyNode root, Map<DependencyNode, String> conflictIds, Collection<ConflictItem> items) {
             this.root = root;
             this.conflictIds = conflictIds;
             this.items = Collections.unmodifiableCollection(items);
         }
 
-        /**
-         * Creates a new conflict context.
-         *
-         * @param root The root node of the dependency graph, must not be {@code null}.
-         * @param conflictId The conflict id for the set of conflicting dependencies in this context, must not be
-         *            {@code null}.
-         * @param conflictIds The mapping from dependency node to conflict id, must not be {@code null}.
-         * @param items The conflict items in this context, must not be {@code null}.
-         * @noreference This class is not intended to be instantiated by clients in production code, the constructor may
-         *              change without notice and only exists to enable unit testing.
-         */
-        public ConflictContext(
-                DependencyNode root,
-                String conflictId,
-                Map<DependencyNode, String> conflictIds,
-                Collection<ConflictItem> items) {
-            this(root, conflictIds, items);
-            this.conflictId = conflictId;
-        }
-
-        /**
-         * Left in place only to maintain source compatibility.
-         *
-         * @deprecated Do not use, is left in place only to maintain source compatibility (Object is casted to String).
-         */
-        @Deprecated
-        public ConflictContext(
-                DependencyNode root,
-                Object conflictId,
-                Map<DependencyNode, String> conflictIds,
-                Collection<ConflictItem> items) {
-            this(root, (String) conflictId, conflictIds, items);
-        }
-
         /**
          * Gets the root node of the dependency graph being transformed.
          *
          * @return The root node of the dependency graph, never {@code null}.
          */
+        @Override
         public DependencyNode getRoot() {
             return root;
         }
@@ -1084,6 +954,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          * @param node The dependency node to check, must not be {@code null}.
          * @return {@code true} if the given node belongs to this conflict context, {@code false} otherwise.
          */
+        @Override
         public boolean isIncluded(DependencyNode node) {
             return conflictId.equals(conflictIds.get(node));
         }
@@ -1093,7 +964,8 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @return The (read-only) collection of conflict items in this context, never {@code null}.
          */
-        public Collection<ConflictItem> getItems() {
+        @Override
+        public Collection<ConflictResolver.ConflictItem> getItems() {
             return items;
         }
 
@@ -1102,7 +974,8 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @return The winning conflict item or {@code null} if not set yet.
          */
-        public ConflictItem getWinner() {
+        @Override
+        public ConflictResolver.ConflictItem getWinner() {
             return winner;
         }
 
@@ -1111,7 +984,8 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @param winner The winning conflict item, may be {@code null}.
          */
-        public void setWinner(ConflictItem winner) {
+        @Override
+        public void setWinner(ConflictResolver.ConflictItem winner) {
             this.winner = winner;
         }
 
@@ -1120,6 +994,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @return The effective scope of the winning dependency or {@code null} if none.
          */
+        @Override
         public String getScope() {
             return scope;
         }
@@ -1129,6 +1004,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @param scope The effective scope, may be {@code null}.
          */
+        @Override
         public void setScope(String scope) {
             this.scope = scope;
         }
@@ -1138,6 +1014,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @return The effective optional flag or {@code null} if none.
          */
+        @Override
         public Boolean getOptional() {
             return optional;
         }
@@ -1147,6 +1024,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @param optional The effective optional flag, may be {@code null}.
          */
+        @Override
         public void setOptional(Boolean optional) {
             this.optional = optional;
         }
@@ -1156,163 +1034,4 @@ public final class ConflictResolver implements DependencyGraphTransformer {
             return winner + " @ " + scope + " < " + items;
         }
     }
-
-    /**
-     * An extension point of {@link ConflictResolver} that determines the winner among conflicting dependencies. The
-     * winning node (and its children) will be retained in the dependency graph, the other nodes will get removed. The
-     * version selector does not need to deal with potential scope conflicts, these will be addressed afterwards by the
-     * {@link ScopeSelector}.
-     * <p>
-     * <strong>Note:</strong> Implementations must be stateless.
-     */
-    public abstract static class VersionSelector {
-
-        /**
-         * Retrieves the version selector for use during the specified graph transformation. The conflict resolver calls
-         * this method once per
-         * {@link ConflictResolver#transformGraph(DependencyNode, DependencyGraphTransformationContext)} invocation to
-         * allow implementations to prepare any auxiliary data that is needed for their operation. Given that
-         * implementations must be stateless, a new instance needs to be returned to hold such auxiliary data. The
-         * default implementation simply returns the current instance which is appropriate for implementations which do
-         * not require auxiliary data.
-         *
-         * @param root The root node of the (possibly cyclic!) graph to transform, must not be {@code null}.
-         * @param context The graph transformation context, must not be {@code null}.
-         * @return The scope deriver to use for the given graph transformation, never {@code null}.
-         * @throws RepositoryException If the instance could not be retrieved.
-         */
-        public VersionSelector getInstance(DependencyNode root, DependencyGraphTransformationContext context)
-                throws RepositoryException {
-            return this;
-        }
-
-        /**
-         * Determines the winning node among conflicting dependencies. Implementations will usually iterate
-         * {@link ConflictContext#getItems()}, inspect {@link ConflictItem#getNode()} and eventually call
-         * {@link ConflictContext#setWinner(ConflictResolver.ConflictItem)} to deliver the winner. Failure to select a
-         * winner will automatically fail the entire conflict resolution.
-         *
-         * @param context The conflict context, must not be {@code null}.
-         * @throws RepositoryException If the version selection failed.
-         */
-        public abstract void selectVersion(ConflictContext context) throws RepositoryException;
-    }
-
-    /**
-     * An extension point of {@link ConflictResolver} that determines the effective scope of a dependency from a
-     * potentially conflicting set of {@link ScopeDeriver derived scopes}. The scope selector gets invoked after the
-     * {@link VersionSelector} has picked the winning node.
-     * <p>
-     * <strong>Note:</strong> Implementations must be stateless.
-     */
-    public abstract static class ScopeSelector {
-
-        /**
-         * Retrieves the scope selector for use during the specified graph transformation. The conflict resolver calls
-         * this method once per
-         * {@link ConflictResolver#transformGraph(DependencyNode, DependencyGraphTransformationContext)} invocation to
-         * allow implementations to prepare any auxiliary data that is needed for their operation. Given that
-         * implementations must be stateless, a new instance needs to be returned to hold such auxiliary data. The
-         * default implementation simply returns the current instance which is appropriate for implementations which do
-         * not require auxiliary data.
-         *
-         * @param root The root node of the (possibly cyclic!) graph to transform, must not be {@code null}.
-         * @param context The graph transformation context, must not be {@code null}.
-         * @return The scope selector to use for the given graph transformation, never {@code null}.
-         * @throws RepositoryException If the instance could not be retrieved.
-         */
-        public ScopeSelector getInstance(DependencyNode root, DependencyGraphTransformationContext context)
-                throws RepositoryException {
-            return this;
-        }
-
-        /**
-         * Determines the effective scope of the dependency given by {@link ConflictContext#getWinner()}.
-         * Implementations will usually iterate {@link ConflictContext#getItems()}, inspect
-         * {@link ConflictItem#getScopes()} and eventually call {@link ConflictContext#setScope(String)} to deliver the
-         * effective scope.
-         *
-         * @param context The conflict context, must not be {@code null}.
-         * @throws RepositoryException If the scope selection failed.
-         */
-        public abstract void selectScope(ConflictContext context) throws RepositoryException;
-    }
-
-    /**
-     * An extension point of {@link ConflictResolver} that determines the scope of a dependency in relation to the scope
-     * of its parent.
-     * <p>
-     * <strong>Note:</strong> Implementations must be stateless.
-     */
-    public abstract static class ScopeDeriver {
-
-        /**
-         * Retrieves the scope deriver for use during the specified graph transformation. The conflict resolver calls
-         * this method once per
-         * {@link ConflictResolver#transformGraph(DependencyNode, DependencyGraphTransformationContext)} invocation to
-         * allow implementations to prepare any auxiliary data that is needed for their operation. Given that
-         * implementations must be stateless, a new instance needs to be returned to hold such auxiliary data. The
-         * default implementation simply returns the current instance which is appropriate for implementations which do
-         * not require auxiliary data.
-         *
-         * @param root The root node of the (possibly cyclic!) graph to transform, must not be {@code null}.
-         * @param context The graph transformation context, must not be {@code null}.
-         * @return The scope deriver to use for the given graph transformation, never {@code null}.
-         * @throws RepositoryException If the instance could not be retrieved.
-         */
-        public ScopeDeriver getInstance(DependencyNode root, DependencyGraphTransformationContext context)
-                throws RepositoryException {
-            return this;
-        }
-
-        /**
-         * Determines the scope of a dependency in relation to the scope of its parent. Implementors need to call
-         * {@link ScopeContext#setDerivedScope(String)} to deliver the result of their calculation. If said method is
-         * not invoked, the conflict resolver will assume the scope of the child dependency remains unchanged.
-         *
-         * @param context The scope context, must not be {@code null}.
-         * @throws RepositoryException If the scope deriviation failed.
-         */
-        public abstract void deriveScope(ScopeContext context) throws RepositoryException;
-    }
-
-    /**
-     * An extension point of {@link ConflictResolver} that determines the effective optional flag of a dependency from a
-     * potentially conflicting set of derived optionalities. The optionality selector gets invoked after the
-     * {@link VersionSelector} has picked the winning node.
-     * <p>
-     * <strong>Note:</strong> Implementations must be stateless.
-     */
-    public abstract static class OptionalitySelector {
-
-        /**
-         * Retrieves the optionality selector for use during the specified graph transformation. The conflict resolver
-         * calls this method once per
-         * {@link ConflictResolver#transformGraph(DependencyNode, DependencyGraphTransformationContext)} invocation to
-         * allow implementations to prepare any auxiliary data that is needed for their operation. Given that
-         * implementations must be stateless, a new instance needs to be returned to hold such auxiliary data. The
-         * default implementation simply returns the current instance which is appropriate for implementations which do
-         * not require auxiliary data.
-         *
-         * @param root The root node of the (possibly cyclic!) graph to transform, must not be {@code null}.
-         * @param context The graph transformation context, must not be {@code null}.
-         * @return The optionality selector to use for the given graph transformation, never {@code null}.
-         * @throws RepositoryException If the instance could not be retrieved.
-         */
-        public OptionalitySelector getInstance(DependencyNode root, DependencyGraphTransformationContext context)
-                throws RepositoryException {
-            return this;
-        }
-
-        /**
-         * Determines the effective optional flag of the dependency given by {@link ConflictContext#getWinner()}.
-         * Implementations will usually iterate {@link ConflictContext#getItems()}, inspect
-         * {@link ConflictItem#getOptionalities()} and eventually call {@link ConflictContext#setOptional(Boolean)} to
-         * deliver the effective optional flag.
-         *
-         * @param context The conflict context, must not be {@code null}.
-         * @throws RepositoryException If the optionality selection failed.
-         */
-        public abstract void selectOptionality(ConflictContext context) throws RepositoryException;
-    }
 }
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConflictResolver.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConflictResolver.java
index ade67a527..1e5273880 100644
--- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConflictResolver.java
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConflictResolver.java
@@ -18,53 +18,101 @@
  */
 package org.eclipse.aether.util.graph.transformer;
 
-import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.IdentityHashMap;
-import java.util.Iterator;
-import java.util.List;
-import java.util.ListIterator;
-import java.util.Map;
-import java.util.Objects;
 
 import org.eclipse.aether.ConfigurationProperties;
 import org.eclipse.aether.RepositoryException;
 import org.eclipse.aether.RepositorySystemSession;
-import org.eclipse.aether.artifact.Artifact;
 import org.eclipse.aether.collection.DependencyGraphTransformationContext;
 import org.eclipse.aether.collection.DependencyGraphTransformer;
-import org.eclipse.aether.graph.DefaultDependencyNode;
 import org.eclipse.aether.graph.Dependency;
 import org.eclipse.aether.graph.DependencyNode;
-import org.eclipse.aether.util.artifact.ArtifactIdUtils;
 
 import static java.util.Objects.requireNonNull;
 
 /**
- * A dependency graph transformer that resolves version and scope conflicts among dependencies. For a given set of
- * conflicting nodes, one node will be chosen as the winner and the other nodes are removed from the dependency graph.
- * The exact rules by which a winning node and its effective scope are determined are controlled by user-supplied
- * implementations of {@link VersionSelector}, {@link ScopeSelector}, {@link OptionalitySelector} and
- * {@link ScopeDeriver}.
+ * Abstract base class for dependency graph transformers that resolve version and scope conflicts among dependencies.
+ * For a given set of conflicting nodes, one node will be chosen as the winner. How losing nodes are handled depends
+ * on the configured verbosity level: they may be removed entirely, have their children removed, or be left in place
+ * with conflict information. The exact rules by which a winning node and its effective scope are determined are
+ * controlled by user-supplied implementations of {@link VersionSelector}, {@link ScopeSelector},
+ * {@link OptionalitySelector} and {@link ScopeDeriver}.
  * <p>
- * By default, this graph transformer will turn the dependency graph into a tree without duplicate artifacts. Using the
- * configuration property {@link #CONFIG_PROP_VERBOSE}, a verbose mode can be enabled where the graph is still turned
- * into a tree but all nodes participating in a conflict are retained. The nodes that were rejected during conflict
- * resolution have no children and link back to the winner node via the {@link #NODE_DATA_WINNER} key in their custom
- * data. Additionally, the keys {@link #NODE_DATA_ORIGINAL_SCOPE} and {@link #NODE_DATA_ORIGINAL_OPTIONALITY} are used
- * to store the original scope and optionality of each node. Obviously, the resulting dependency tree is not suitable
- * for artifact resolution unless a filter is employed to exclude the duplicate dependencies.
+ * <strong>Available Implementations:</strong>
+ * <ul>
+ * <li><strong>{@link PathConflictResolver}</strong> - Recommended high-performance implementation with O(N) complexity</li>
+ * <li><strong>{@link ClassicConflictResolver}</strong> - Legacy implementation for backward compatibility (O(N²) worst-case)</li>
+ * </ul>
  * <p>
+ * <strong>Implementation Selection Guide:</strong>
+ * <ul>
+ * <li><strong>New Projects:</strong> Use {@link PathConflictResolver} for optimal performance</li>
+ * <li><strong>Large Multi-Module Projects:</strong> Use {@link PathConflictResolver} to avoid performance bottlenecks</li>
+ * <li><strong>Maven 4+ Environments:</strong> Use {@link PathConflictResolver} for best build performance</li>
+ * <li><strong>Legacy Compatibility:</strong> Use {@link ClassicConflictResolver} only when exact Maven 3.x behavior is required</li>
+ * </ul>
+ * <p>
+ * <strong>Usage Example:</strong>
+ * <pre>{@code
+ * // Recommended: High-performance path-based resolver
+ * DependencyGraphTransformer transformer = new ChainedDependencyGraphTransformer(
+ *     new PathConflictResolver(
+ *         new NearestVersionSelector(),
+ *         new JavaScopeSelector(),
+ *         new SimpleOptionalitySelector(),
+ *         new JavaScopeDeriver()),
+ *     // other transformers...
+ * );
+ *
+ * // Legacy: Classic resolver for backward compatibility
+ * DependencyGraphTransformer legacyTransformer = new ChainedDependencyGraphTransformer(
+ *     new ClassicConflictResolver(
+ *         new NearestVersionSelector(),
+ *         new JavaScopeSelector(),
+ *         new SimpleOptionalitySelector(),
+ *         new JavaScopeDeriver()),
+ *     // other transformers...
+ * );
+ * }</pre>
+ * <p>
+ * <strong>Verbosity Levels and Conflict Handling:</strong>
+ * <ul>
+ * <li><strong>NONE (default):</strong> Creates a clean dependency tree without duplicate artifacts.
+ *     Losing nodes are completely removed from the graph, so are cycles as well.</li>
+ * <li><strong>STANDARD:</strong> Retains losing nodes for analysis but removes their children to prevent
+ *     duplicate dependencies. Special handling for version ranges: redundant nodes may still be removed
+ *     if multiple versions of the same artifact exist. Losing nodes link back to the winner via
+ *     {@link #NODE_DATA_WINNER} and preserve original scope/optionality information. This mode removes cycles only,
+ *     while conflict nodes/duplicates are left in place. Graphs in this verbosity level cannot be resolved,
+ *     their purpose is for analysis only.</li>
+ * <li><strong>FULL:</strong> Preserves the complete original graph structure including all conflicts and cycles.
+ *     All nodes remain with their children, but conflict information is recorded for analysis.
+ *     Graphs in this verbosity level cannot be resolved, their purpose is for analysis only.</li>
+ * </ul>
+ * The verbosity level is controlled by the {@link #CONFIG_PROP_VERBOSE} configuration property.
+ * <p>
+ * <strong>Conflict Metadata:</strong> In STANDARD and FULL modes, the keys {@link #NODE_DATA_ORIGINAL_SCOPE}
+ * and {@link #NODE_DATA_ORIGINAL_OPTIONALITY} are used to store the original scope and optionality of each node.
+ * Obviously, dependency trees with verbosity STANDARD or FULL are not suitable for artifact resolution unless
+ * a filter is employed to exclude the duplicate dependencies.
+ * <p>
+ * <strong>Conflict ID Processing Pipeline:</strong>
+ * <ol>
+ * <li><strong>{@link ConflictMarker}:</strong> Assigns conflict IDs based on GACE (groupId:artifactId:classifier:extension)
+ *     coordinates, grouping artifacts that differ only in version (partitions the graph, assigning same conflict IDs
+ *     to nodes belonging to same conflict group).</li>
+ * <li><strong>{@link ConflictIdSorter}:</strong> Creates topological ordering of conflict IDs and detects cycles</li>
+ * <li><strong>ConflictResolver implementation:</strong> Uses the sorted conflict IDs to resolve conflicts in dependency order</li>
+ * </ol>
  * This transformer will query the keys {@link TransformationContextKeys#CONFLICT_IDS},
  * {@link TransformationContextKeys#SORTED_CONFLICT_IDS}, {@link TransformationContextKeys#CYCLIC_CONFLICT_IDS} for
  * existing information about conflict ids. In absence of this information, it will automatically invoke the
  * {@link ConflictIdSorter} to calculate it.
+ *
+ * @see PathConflictResolver
+ * @see ClassicConflictResolver
  */
-public final class ConflictResolver implements DependencyGraphTransformer {
+public abstract class ConflictResolver implements DependencyGraphTransformer {
 
     /**
      * The key in the repository session's {@link org.eclipse.aether.RepositorySystemSession#getConfigProperties()
@@ -119,7 +167,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
      * is returned.
      * This method never returns {@code null}.
      */
-    private static Verbosity getVerbosity(RepositorySystemSession session) {
+    protected static Verbosity getVerbosity(RepositorySystemSession session) {
         final Object verbosityValue = session.getConfigProperties().get(CONFIG_PROP_VERBOSE);
         if (verbosityValue instanceof Boolean) {
             return (Boolean) verbosityValue ? Verbosity.STANDARD : Verbosity.NONE;
@@ -151,13 +199,13 @@ public final class ConflictResolver implements DependencyGraphTransformer {
      */
     public static final String NODE_DATA_ORIGINAL_OPTIONALITY = "conflict.originalOptionality";
 
-    private final VersionSelector versionSelector;
+    protected final VersionSelector versionSelector;
 
-    private final ScopeSelector scopeSelector;
+    protected final ScopeSelector scopeSelector;
 
-    private final ScopeDeriver scopeDeriver;
+    protected final ScopeDeriver scopeDeriver;
 
-    private final OptionalitySelector optionalitySelector;
+    protected final OptionalitySelector optionalitySelector;
 
     /**
      * Creates a new conflict resolver instance with the specified hooks.
@@ -178,605 +226,6 @@ public final class ConflictResolver implements DependencyGraphTransformer {
         this.scopeDeriver = requireNonNull(scopeDeriver, "scope deriver cannot be null");
     }
 
-    @SuppressWarnings("unchecked")
-    @Override
-    public DependencyNode transformGraph(DependencyNode node, DependencyGraphTransformationContext context)
-            throws RepositoryException {
-        requireNonNull(node, "node cannot be null");
-        requireNonNull(context, "context cannot be null");
-        List<String> sortedConflictIds = (List<String>) context.get(TransformationContextKeys.SORTED_CONFLICT_IDS);
-        if (sortedConflictIds == null) {
-            ConflictIdSorter sorter = new ConflictIdSorter();
-            sorter.transformGraph(node, context);
-
-            sortedConflictIds = (List<String>) context.get(TransformationContextKeys.SORTED_CONFLICT_IDS);
-        }
-
-        @SuppressWarnings("unchecked")
-        Map<String, Object> stats = (Map<String, Object>) context.get(TransformationContextKeys.STATS);
-        long time1 = System.nanoTime();
-
-        @SuppressWarnings("unchecked")
-        Collection<Collection<String>> conflictIdCycles =
-                (Collection<Collection<String>>) context.get(TransformationContextKeys.CYCLIC_CONFLICT_IDS);
-        if (conflictIdCycles == null) {
-            throw new RepositoryException("conflict id cycles have not been identified");
-        }
-
-        Map<DependencyNode, String> conflictIds =
-                (Map<DependencyNode, String>) context.get(TransformationContextKeys.CONFLICT_IDS);
-        if (conflictIds == null) {
-            throw new RepositoryException("conflict groups have not been identified");
-        }
-
-        Map<String, Collection<String>> cyclicPredecessors = new HashMap<>();
-        for (Collection<String> cycle : conflictIdCycles) {
-            for (String conflictId : cycle) {
-                Collection<String> predecessors = cyclicPredecessors.computeIfAbsent(conflictId, k -> new HashSet<>());
-                predecessors.addAll(cycle);
-            }
-        }
-
-        State state = new State(node, conflictIds, sortedConflictIds.size(), context);
-        for (Iterator<String> it = sortedConflictIds.iterator(); it.hasNext(); ) {
-            String conflictId = it.next();
-
-            // reset data structures for next graph walk
-            state.prepare(conflictId, cyclicPredecessors.get(conflictId));
-
-            // find nodes with the current conflict id and while walking the graph (more deeply), nuke leftover losers
-            gatherConflictItems(node, state);
-
-            // now that we know the min depth of the parents, update depth of conflict items
-            state.finish();
-
-            // earlier runs might have nuked all parents of the current conflict id, so it might not exist anymore
-            if (!state.items.isEmpty()) {
-                ConflictContext ctx = state.conflictCtx;
-                state.versionSelector.selectVersion(ctx);
-                if (ctx.winner == null) {
-                    throw new RepositoryException("conflict resolver did not select winner among " + state.items);
-                }
-                DependencyNode winner = ctx.winner.node;
-
-                state.scopeSelector.selectScope(ctx);
-                if (Verbosity.NONE != state.verbosity) {
-                    winner.setData(
-                            NODE_DATA_ORIGINAL_SCOPE, winner.getDependency().getScope());
-                }
-                winner.setScope(ctx.scope);
-
-                state.optionalitySelector.selectOptionality(ctx);
-                if (Verbosity.NONE != state.verbosity) {
-                    winner.setData(
-                            NODE_DATA_ORIGINAL_OPTIONALITY,
-                            winner.getDependency().isOptional());
-                }
-                winner.setOptional(ctx.optional);
-
-                removeLosers(state);
-            }
-
-            // record the winner so we can detect leftover losers during future graph walks
-            state.winner();
-
-            // in case of cycles, trigger final graph walk to ensure all leftover losers are gone
-            if (!it.hasNext() && !conflictIdCycles.isEmpty() && state.conflictCtx.winner != null) {
-                DependencyNode winner = state.conflictCtx.winner.node;
-                // Note: using non-existing key here (empty) as that one for sure was not met
-                state.prepare("", null);
-                gatherConflictItems(winner, state);
-            }
-        }
-
-        if (stats != null) {
-            long time2 = System.nanoTime();
-            stats.put("ConflictResolver.totalTime", time2 - time1);
-            stats.put("ConflictResolver.conflictItemCount", state.totalConflictItems);
-        }
-
-        return node;
-    }
-
-    private boolean gatherConflictItems(DependencyNode node, State state) throws RepositoryException {
-        String conflictId = state.conflictIds.get(node);
-        if (state.currentId.equals(conflictId)) {
-            // found it, add conflict item (if not already done earlier by another path)
-            state.add(node);
-            // we don't recurse here so we might miss losers beneath us, those will be nuked during future walks below
-        } else if (state.loser(node, conflictId)) {
-            // found a leftover loser (likely in a cycle) of an already processed conflict id, tell caller to nuke it
-            return false;
-        } else if (state.push(node, conflictId)) {
-            // found potential parent, no cycle and not visited before with the same derived scope, so recurse
-            for (Iterator<DependencyNode> it = node.getChildren().iterator(); it.hasNext(); ) {
-                DependencyNode child = it.next();
-                if (!gatherConflictItems(child, state)) {
-                    it.remove();
-                }
-            }
-            state.pop();
-        }
-        return true;
-    }
-
-    private static void removeLosers(State state) {
-        ConflictItem winner = state.conflictCtx.winner;
-        String winnerArtifactId = ArtifactIdUtils.toId(winner.node.getArtifact());
-        List<DependencyNode> previousParent = null;
-        ListIterator<DependencyNode> childIt = null;
-        HashSet<String> toRemoveIds = new HashSet<>();
-        for (ConflictItem item : state.items) {
-            if (item == winner) {
-                continue;
-            }
-            if (item.parent != previousParent) {
-                childIt = item.parent.listIterator();
-                previousParent = item.parent;
-            }
-            while (childIt.hasNext()) {
-                DependencyNode child = childIt.next();
-                if (child == item.node) {
-                    // NONE: just remove it and done
-                    if (Verbosity.NONE == state.verbosity) {
-                        childIt.remove();
-                        break;
-                    }
-
-                    // STANDARD: doing extra bookkeeping to select "which nodes to remove"
-                    if (Verbosity.STANDARD == state.verbosity) {
-                        String childArtifactId = ArtifactIdUtils.toId(child.getArtifact());
-                        // if two IDs are equal, it means "there is nearest", not conflict per se.
-                        // In that case we do NOT allow this child to be removed (but remove others)
-                        // and this keeps us safe from iteration (and in general, version) ordering
-                        // as we explicitly leave out ID that is "nearest found" state.
-                        //
-                        // This tackles version ranges mostly, where ranges are turned into list of
-                        // several nodes in collector (as many were discovered, ie. from metadata), and
-                        // old code would just "mark" the first hit as conflict, and remove the rest,
-                        // even if rest could contain "more suitable" version, that is not conflicting/diverging.
-                        // This resulted in verbose mode transformed tree, that was misrepresenting things
-                        // for dependency convergence calculations: it represented state like parent node
-                        // depends on "wrong" version (diverge), while "right" version was present (but removed)
-                        // as well, as it was contained in parents version range.
-                        if (!Objects.equals(winnerArtifactId, childArtifactId)) {
-                            toRemoveIds.add(childArtifactId);
-                        }
-                    }
-
-                    // FULL: just record the facts
-                    DependencyNode loser = new DefaultDependencyNode(child);
-                    loser.setData(NODE_DATA_WINNER, winner.node);
-                    loser.setData(
-                            NODE_DATA_ORIGINAL_SCOPE, loser.getDependency().getScope());
-                    loser.setData(
-                            NODE_DATA_ORIGINAL_OPTIONALITY,
-                            loser.getDependency().isOptional());
-                    loser.setScope(item.getScopes().iterator().next());
-                    loser.setChildren(Collections.emptyList());
-                    childIt.set(loser);
-                    item.node = loser;
-                    break;
-                }
-            }
-        }
-
-        // 2nd pass to apply "standard" verbosity: leaving only 1 loser, but with care
-        if (Verbosity.STANDARD == state.verbosity && !toRemoveIds.isEmpty()) {
-            previousParent = null;
-            for (ConflictItem item : state.items) {
-                if (item == winner) {
-                    continue;
-                }
-                if (item.parent != previousParent) {
-                    childIt = item.parent.listIterator();
-                    previousParent = item.parent;
-                }
-                while (childIt.hasNext()) {
-                    DependencyNode child = childIt.next();
-                    if (child == item.node) {
-                        String childArtifactId = ArtifactIdUtils.toId(child.getArtifact());
-                        if (toRemoveIds.contains(childArtifactId)
-                                && relatedSiblingsCount(child.getArtifact(), item.parent) > 1) {
-                            childIt.remove();
-                        }
-                        break;
-                    }
-                }
-            }
-        }
-
-        // there might still be losers beneath the winner (e.g. in case of cycles)
-        // those will be nuked during future graph walks when we include the winner in the recursion
-    }
-
-    private static long relatedSiblingsCount(Artifact artifact, List<DependencyNode> parent) {
-        String ga = artifact.getGroupId() + ":" + artifact.getArtifactId();
-        return parent.stream()
-                .map(DependencyNode::getArtifact)
-                .filter(a -> ga.equals(a.getGroupId() + ":" + a.getArtifactId()))
-                .count();
-    }
-
-    static final class NodeInfo {
-
-        /**
-         * The smallest depth at which the node was seen, used for "the" depth of its conflict items.
-         */
-        int minDepth;
-
-        /**
-         * The set of derived scopes the node was visited with, used to check whether an already seen node needs to be
-         * revisited again in context of another scope. To conserve memory, we start with {@code String} and update to
-         * {@code Set<String>} if needed.
-         */
-        Object derivedScopes;
-
-        /**
-         * The set of derived optionalities the node was visited with, used to check whether an already seen node needs
-         * to be revisited again in context of another optionality. To conserve memory, encoded as bit field (bit 0 ->
-         * optional=false, bit 1 -> optional=true).
-         */
-        int derivedOptionalities;
-
-        /**
-         * The conflict items which are immediate children of the node, used to easily update those conflict items after
-         * a new parent scope/optionality was encountered.
-         */
-        List<ConflictItem> children;
-
-        static final int CHANGE_SCOPE = 0x01;
-
-        static final int CHANGE_OPTIONAL = 0x02;
-
-        private static final int OPT_FALSE = 0x01;
-
-        private static final int OPT_TRUE = 0x02;
-
-        NodeInfo(int depth, String derivedScope, boolean optional) {
-            minDepth = depth;
-            derivedScopes = derivedScope;
-            derivedOptionalities = optional ? OPT_TRUE : OPT_FALSE;
-        }
-
-        @SuppressWarnings("unchecked")
-        int update(int depth, String derivedScope, boolean optional) {
-            if (depth < minDepth) {
-                minDepth = depth;
-            }
-            int changes;
-            if (derivedScopes.equals(derivedScope)) {
-                changes = 0;
-            } else if (derivedScopes instanceof Collection) {
-                changes = ((Collection<String>) derivedScopes).add(derivedScope) ? CHANGE_SCOPE : 0;
-            } else {
-                Collection<String> scopes = new HashSet<>();
-                scopes.add((String) derivedScopes);
-                scopes.add(derivedScope);
-                derivedScopes = scopes;
-                changes = CHANGE_SCOPE;
-            }
-            int bit = optional ? OPT_TRUE : OPT_FALSE;
-            if ((derivedOptionalities & bit) == 0) {
-                derivedOptionalities |= bit;
-                changes |= CHANGE_OPTIONAL;
-            }
-            return changes;
-        }
-
-        void add(ConflictItem item) {
-            if (children == null) {
-                children = new ArrayList<>(1);
-            }
-            children.add(item);
-        }
-    }
-
-    final class State {
-
-        /**
-         * The conflict id currently processed.
-         */
-        String currentId;
-
-        /**
-         * Stats counter.
-         */
-        int totalConflictItems;
-
-        /**
-         * Flag whether we should keep losers in the graph to enable visualization/troubleshooting of conflicts.
-         */
-        final Verbosity verbosity;
-
-        /**
-         * A mapping from conflict id to winner node, helps to recognize nodes that have their effective
-         * scope&optionality set or are leftovers from previous removals.
-         */
-        final Map<String, DependencyNode> resolvedIds;
-
-        /**
-         * The set of conflict ids which could apply to ancestors of nodes with the current conflict id, used to avoid
-         * recursion early on. This is basically a superset of the key set of resolvedIds, the additional ids account
-         * for cyclic dependencies.
-         */
-        final Collection<String> potentialAncestorIds;
-
-        /**
-         * The output from the conflict marker
-         */
-        final Map<DependencyNode, String> conflictIds;
-
-        /**
-         * The conflict items we have gathered so far for the current conflict id.
-         */
-        final List<ConflictItem> items;
-
-        /**
-         * The (conceptual) mapping from nodes to extra infos, technically keyed by the node's child list which better
-         * captures the identity of a node since we're basically concerned with effects towards children.
-         */
-        final Map<List<DependencyNode>, NodeInfo> infos;
-
-        /**
-         * The set of nodes on the DFS stack to detect cycles, technically keyed by the node's child list to match the
-         * dirty graph structure produced by the dependency collector for cycles.
-         */
-        final Map<List<DependencyNode>, Boolean> stack;
-
-        /**
-         * The stack of parent nodes.
-         */
-        final List<DependencyNode> parentNodes;
-
-        /**
-         * The stack of derived scopes for parent nodes.
-         */
-        final List<String> parentScopes;
-
-        /**
-         * The stack of derived optional flags for parent nodes.
-         */
-        final List<Boolean> parentOptionals;
-
-        /**
-         * The stack of node infos for parent nodes, may contain {@code null} which is used to disable creating new
-         * conflict items when visiting their parent again (conflict items are meant to be unique by parent-node combo).
-         */
-        final List<NodeInfo> parentInfos;
-
-        /**
-         * The conflict context passed to the version/scope/optionality selectors, updated as we move along rather than
-         * recreated to avoid tmp objects.
-         */
-        final ConflictContext conflictCtx;
-
-        /**
-         * The scope context passed to the scope deriver, updated as we move along rather than recreated to avoid tmp
-         * objects.
-         */
-        final ScopeContext scopeCtx;
-
-        /**
-         * The effective version selector, i.e. after initialization.
-         */
-        final VersionSelector versionSelector;
-
-        /**
-         * The effective scope selector, i.e. after initialization.
-         */
-        final ScopeSelector scopeSelector;
-
-        /**
-         * The effective scope deriver, i.e. after initialization.
-         */
-        final ScopeDeriver scopeDeriver;
-
-        /**
-         * The effective optionality selector, i.e. after initialization.
-         */
-        final OptionalitySelector optionalitySelector;
-
-        State(
-                DependencyNode root,
-                Map<DependencyNode, String> conflictIds,
-                int conflictIdCount,
-                DependencyGraphTransformationContext context)
-                throws RepositoryException {
-            this.conflictIds = conflictIds;
-            this.verbosity = getVerbosity(context.getSession());
-            potentialAncestorIds = new HashSet<>(conflictIdCount * 2);
-            resolvedIds = new HashMap<>(conflictIdCount * 2);
-            items = new ArrayList<>(256);
-            infos = new IdentityHashMap<>(64);
-            stack = new IdentityHashMap<>(64);
-            parentNodes = new ArrayList<>(64);
-            parentScopes = new ArrayList<>(64);
-            parentOptionals = new ArrayList<>(64);
-            parentInfos = new ArrayList<>(64);
-            conflictCtx = new ConflictContext(root, conflictIds, items);
-            scopeCtx = new ScopeContext(null, null);
-            versionSelector = ConflictResolver.this.versionSelector.getInstance(root, context);
-            scopeSelector = ConflictResolver.this.scopeSelector.getInstance(root, context);
-            scopeDeriver = ConflictResolver.this.scopeDeriver.getInstance(root, context);
-            optionalitySelector = ConflictResolver.this.optionalitySelector.getInstance(root, context);
-        }
-
-        void prepare(String conflictId, Collection<String> cyclicPredecessors) {
-            currentId = conflictId;
-            conflictCtx.conflictId = conflictId;
-            conflictCtx.winner = null;
-            conflictCtx.scope = null;
-            conflictCtx.optional = null;
-            items.clear();
-            infos.clear();
-            if (cyclicPredecessors != null) {
-                potentialAncestorIds.addAll(cyclicPredecessors);
-            }
-        }
-
-        void finish() {
-            List<DependencyNode> previousParent = null;
-            int previousDepth = 0;
-            totalConflictItems += items.size();
-            for (ListIterator<ConflictItem> iterator = items.listIterator(items.size()); iterator.hasPrevious(); ) {
-                ConflictItem item = iterator.previous();
-                if (item.parent == previousParent) {
-                    item.depth = previousDepth;
-                } else if (item.parent != null) {
-                    previousParent = item.parent;
-                    NodeInfo info = infos.get(previousParent);
-                    previousDepth = info.minDepth + 1;
-                    item.depth = previousDepth;
-                }
-            }
-            potentialAncestorIds.add(currentId);
-        }
-
-        void winner() {
-            resolvedIds.put(currentId, (conflictCtx.winner != null) ? conflictCtx.winner.node : null);
-        }
-
-        boolean loser(DependencyNode node, String conflictId) {
-            DependencyNode winner = resolvedIds.get(conflictId);
-            return winner != null && winner != node;
-        }
-
-        boolean push(DependencyNode node, String conflictId) throws RepositoryException {
-            if (conflictId == null) {
-                if (node.getDependency() != null) {
-                    if (node.getData().get(NODE_DATA_WINNER) != null) {
-                        return false;
-                    }
-                    throw new RepositoryException("missing conflict id for node " + node);
-                }
-            } else if (!potentialAncestorIds.contains(conflictId)) {
-                return false;
-            }
-
-            List<DependencyNode> graphNode = node.getChildren();
-            if (stack.put(graphNode, Boolean.TRUE) != null) {
-                return false;
-            }
-
-            int depth = depth();
-            String scope = deriveScope(node, conflictId);
-            boolean optional = deriveOptional(node, conflictId);
-            NodeInfo info = infos.get(graphNode);
-            if (info == null) {
-                info = new NodeInfo(depth, scope, optional);
-                infos.put(graphNode, info);
-                parentInfos.add(info);
-                parentNodes.add(node);
-                parentScopes.add(scope);
-                parentOptionals.add(optional);
-            } else {
-                int changes = info.update(depth, scope, optional);
-                if (changes == 0) {
-                    stack.remove(graphNode);
-                    return false;
-                }
-                parentInfos.add(null); // disable creating new conflict items, we update the existing ones below
-                parentNodes.add(node);
-                parentScopes.add(scope);
-                parentOptionals.add(optional);
-                if (info.children != null) {
-                    if ((changes & NodeInfo.CHANGE_SCOPE) != 0) {
-                        ListIterator<ConflictItem> itemIterator = info.children.listIterator(info.children.size());
-                        while (itemIterator.hasPrevious()) {
-                            ConflictItem item = itemIterator.previous();
-                            String childScope = deriveScope(item.node, null);
-                            item.addScope(childScope);
-                        }
-                    }
-                    if ((changes & NodeInfo.CHANGE_OPTIONAL) != 0) {
-                        ListIterator<ConflictItem> itemIterator = info.children.listIterator(info.children.size());
-                        while (itemIterator.hasPrevious()) {
-                            ConflictItem item = itemIterator.previous();
-                            boolean childOptional = deriveOptional(item.node, null);
-                            item.addOptional(childOptional);
-                        }
-                    }
-                }
-            }
-
-            return true;
-        }
-
-        void pop() {
-            int last = parentInfos.size() - 1;
-            parentInfos.remove(last);
-            parentScopes.remove(last);
-            parentOptionals.remove(last);
-            DependencyNode node = parentNodes.remove(last);
-            stack.remove(node.getChildren());
-        }
-
-        void add(DependencyNode node) throws RepositoryException {
-            DependencyNode parent = parent();
-            if (parent == null) {
-                ConflictItem item = newConflictItem(parent, node);
-                items.add(item);
-            } else {
-                NodeInfo info = parentInfos.get(parentInfos.size() - 1);
-                if (info != null) {
-                    ConflictItem item = newConflictItem(parent, node);
-                    info.add(item);
-                    items.add(item);
-                }
-            }
-        }
-
-        private ConflictItem newConflictItem(DependencyNode parent, DependencyNode node) throws RepositoryException {
-            return new ConflictItem(parent, node, deriveScope(node, null), deriveOptional(node, null));
-        }
-
-        private int depth() {
-            return parentNodes.size();
-        }
-
-        private DependencyNode parent() {
-            int size = parentNodes.size();
-            return (size <= 0) ? null : parentNodes.get(size - 1);
-        }
-
-        private String deriveScope(DependencyNode node, String conflictId) throws RepositoryException {
-            if ((node.getManagedBits() & DependencyNode.MANAGED_SCOPE) != 0
-                    || (conflictId != null && resolvedIds.containsKey(conflictId))) {
-                return scope(node.getDependency());
-            }
-
-            int depth = parentNodes.size();
-            scopes(depth, node.getDependency());
-            if (depth > 0) {
-                scopeDeriver.deriveScope(scopeCtx);
-            }
-            return scopeCtx.derivedScope;
-        }
-
-        private void scopes(int parent, Dependency child) {
-            scopeCtx.parentScope = (parent > 0) ? parentScopes.get(parent - 1) : null;
-            scopeCtx.derivedScope = scope(child);
-            scopeCtx.childScope = scope(child);
-        }
-
-        private String scope(Dependency dependency) {
-            return (dependency != null) ? dependency.getScope() : null;
-        }
-
-        private boolean deriveOptional(DependencyNode node, String conflictId) {
-            Dependency dep = node.getDependency();
-            boolean optional = (dep != null) && dep.isOptional();
-            if (optional
-                    || (node.getManagedBits() & DependencyNode.MANAGED_OPTIONAL) != 0
-                    || (conflictId != null && resolvedIds.containsKey(conflictId))) {
-                return optional;
-            }
-            int depth = parentNodes.size();
-            return (depth > 0) ? parentOptionals.get(depth - 1) : false;
-        }
-    }
-
     /**
      * A context used to hold information that is relevant for deriving the scope of a child dependency.
      *
@@ -784,37 +233,14 @@ public final class ConflictResolver implements DependencyGraphTransformer {
      * @noinstantiate This class is not intended to be instantiated by clients in production code, the constructor may
      *                change without notice and only exists to enable unit testing.
      */
-    public static final class ScopeContext {
-
-        String parentScope;
-
-        String childScope;
-
-        String derivedScope;
-
-        /**
-         * Creates a new scope context with the specified properties.
-         *
-         * @param parentScope The scope of the parent dependency, may be {@code null}.
-         * @param childScope The scope of the child dependency, may be {@code null}.
-         * @noreference This class is not intended to be instantiated by clients in production code, the constructor may
-         *              change without notice and only exists to enable unit testing.
-         */
-        public ScopeContext(String parentScope, String childScope) {
-            this.parentScope = (parentScope != null) ? parentScope : "";
-            derivedScope = (childScope != null) ? childScope : "";
-            this.childScope = (childScope != null) ? childScope : "";
-        }
-
+    public interface ScopeContext {
         /**
          * Gets the scope of the parent dependency. This is usually the scope that was derived by earlier invocations of
          * the scope deriver.
          *
          * @return The scope of the parent dependency, never {@code null}.
          */
-        public String getParentScope() {
-            return parentScope;
-        }
+        String getParentScope();
 
         /**
          * Gets the original scope of the child dependency. This is the scope that was declared in the artifact
@@ -822,9 +248,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @return The original scope of the child dependency, never {@code null}.
          */
-        public String getChildScope() {
-            return childScope;
-        }
+        String getChildScope();
 
         /**
          * Gets the derived scope of the child dependency. This is initially equal to {@link #getChildScope()} until the
@@ -832,18 +256,14 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @return The derived scope of the child dependency, never {@code null}.
          */
-        public String getDerivedScope() {
-            return derivedScope;
-        }
+        String getDerivedScope();
 
         /**
          * Sets the derived scope of the child dependency.
          *
          * @param derivedScope The derived scope of the dependency, may be {@code null}.
          */
-        public void setDerivedScope(String derivedScope) {
-            this.derivedScope = (derivedScope != null) ? derivedScope : "";
-        }
+        void setDerivedScope(String derivedScope);
     }
 
     /**
@@ -852,98 +272,28 @@ public final class ConflictResolver implements DependencyGraphTransformer {
      * @noinstantiate This class is not intended to be instantiated by clients in production code, the constructor may
      *                change without notice and only exists to enable unit testing.
      */
-    public static final class ConflictItem {
-
-        // nodes can share child lists, we care about the unique owner of a child node which is the child list
-        final List<DependencyNode> parent;
-
-        // only for debugging/toString() to help identify the parent node(s)
-        final Artifact artifact;
-
-        // is mutable as removeLosers will mutate it (if Verbosity==STANDARD)
-        DependencyNode node;
-
-        int depth;
-
-        // we start with String and update to Set<String> if needed
-        Object scopes;
-
-        // bit field of OPTIONAL_FALSE and OPTIONAL_TRUE
-        int optionalities;
-
-        /**
-         * Bit flag indicating whether one or more paths consider the dependency non-optional.
-         */
-        public static final int OPTIONAL_FALSE = 0x01;
-
-        /**
-         * Bit flag indicating whether one or more paths consider the dependency optional.
-         */
-        public static final int OPTIONAL_TRUE = 0x02;
-
-        ConflictItem(DependencyNode parent, DependencyNode node, String scope, boolean optional) {
-            if (parent != null) {
-                this.parent = parent.getChildren();
-                this.artifact = parent.getArtifact();
-            } else {
-                this.parent = null;
-                this.artifact = null;
-            }
-            this.node = node;
-            this.scopes = scope;
-            this.optionalities = optional ? OPTIONAL_TRUE : OPTIONAL_FALSE;
-        }
-
-        /**
-         * Creates a new conflict item with the specified properties.
-         *
-         * @param parent The parent node of the conflicting dependency, may be {@code null}.
-         * @param node The conflicting dependency, must not be {@code null}.
-         * @param depth The zero-based depth of the conflicting dependency.
-         * @param optionalities The optionalities the dependency was encountered with, encoded as a bit field consisting
-         *            of {@link ConflictResolver.ConflictItem#OPTIONAL_TRUE} and
-         *            {@link ConflictResolver.ConflictItem#OPTIONAL_FALSE}.
-         * @param scopes The derived scopes of the conflicting dependency, must not be {@code null}.
-         * @noreference This class is not intended to be instantiated by clients in production code, the constructor may
-         *              change without notice and only exists to enable unit testing.
-         */
-        public ConflictItem(
-                DependencyNode parent, DependencyNode node, int depth, int optionalities, String... scopes) {
-            this.parent = (parent != null) ? parent.getChildren() : null;
-            this.artifact = (parent != null) ? parent.getArtifact() : null;
-            this.node = node;
-            this.depth = depth;
-            this.optionalities = optionalities;
-            this.scopes = Arrays.asList(scopes);
-        }
-
+    public interface ConflictItem {
         /**
          * Determines whether the specified conflict item is a sibling of this item.
          *
          * @param item The other conflict item, must not be {@code null}.
          * @return {@code true} if the given item has the same parent as this item, {@code false} otherwise.
          */
-        public boolean isSibling(ConflictItem item) {
-            return parent == item.parent;
-        }
+        boolean isSibling(ConflictItem item);
 
         /**
          * Gets the dependency node involved in the conflict.
          *
          * @return The involved dependency node, never {@code null}.
          */
-        public DependencyNode getNode() {
-            return node;
-        }
+        DependencyNode getNode();
 
         /**
          * Gets the dependency involved in the conflict, short for {@code getNode.getDependency()}.
          *
          * @return The involved dependency, never {@code null}.
          */
-        public Dependency getDependency() {
-            return node.getDependency();
-        }
+        Dependency getDependency();
 
         /**
          * Gets the zero-based depth at which the conflicting node occurs in the graph. As such, the depth denotes the
@@ -952,9 +302,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *
          * @return The zero-based depth of the node in the graph.
          */
-        public int getDepth() {
-            return depth;
-        }
+        int getDepth();
 
         /**
          * Gets the derived scopes of the dependency. In general, the same dependency node could be reached via
@@ -963,25 +311,17 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          * @see ScopeDeriver
          * @return The (read-only) set of derived scopes of the dependency, never {@code null}.
          */
-        @SuppressWarnings("unchecked")
-        public Collection<String> getScopes() {
-            if (scopes instanceof String) {
-                return Collections.singleton((String) scopes);
-            }
-            return (Collection<String>) scopes;
-        }
+        Collection<String> getScopes();
 
-        @SuppressWarnings("unchecked")
-        void addScope(String scope) {
-            if (scopes instanceof Collection) {
-                ((Collection<String>) scopes).add(scope);
-            } else if (!scopes.equals(scope)) {
-                Collection<String> set = new HashSet<>();
-                set.add((String) scopes);
-                set.add(scope);
-                scopes = set;
-            }
-        }
+        /**
+         * Bit flag indicating whether one or more paths consider the dependency non-optional.
+         */
+        int OPTIONAL_FALSE = 0x01;
+
+        /**
+         * Bit flag indicating whether one or more paths consider the dependency optional.
+         */
+        int OPTIONAL_TRUE = 0x02;
 
         /**
          * Gets the derived optionalities of the dependency. In general, the same dependency node could be reached via
@@ -991,18 +331,7 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          *         {@link ConflictResolver.ConflictItem#OPTIONAL_TRUE} indicating the derived optionalities the
          *         dependency was encountered with.
          */
-        public int getOptionalities() {
-            return optionalities;
-        }
-
-        void addOptional(boolean optional) {
-            optionalities |= optional ? OPTIONAL_TRUE : OPTIONAL_FALSE;
-        }
-
-        @Override
-        public String toString() {
-            return node + " @ " + depth + " < " + artifact;
-        }
+        int getOptionalities();
     }
 
     /**
@@ -1013,70 +342,13 @@ public final class ConflictResolver implements DependencyGraphTransformer {
      * @noinstantiate This class is not intended to be instantiated by clients in production code, the constructor may
      *                change without notice and only exists to enable unit testing.
      */
-    public static final class ConflictContext {
-
-        final DependencyNode root;
-
-        final Map<DependencyNode, String> conflictIds;
-
-        final Collection<ConflictItem> items;
-
-        String conflictId;
-
-        ConflictItem winner;
-
-        String scope;
-
-        Boolean optional;
-
-        ConflictContext(DependencyNode root, Map<DependencyNode, String> conflictIds, Collection<ConflictItem> items) {
-            this.root = root;
-            this.conflictIds = conflictIds;
-            this.items = Collections.unmodifiableCollection(items);
-        }
-
-        /**
-         * Creates a new conflict context.
-         *
-         * @param root The root node of the dependency graph, must not be {@code null}.
-         * @param conflictId The conflict id for the set of conflicting dependencies in this context, must not be
-         *            {@code null}.
-         * @param conflictIds The mapping from dependency node to conflict id, must not be {@code null}.
-         * @param items The conflict items in this context, must not be {@code null}.
-         * @noreference This class is not intended to be instantiated by clients in production code, the constructor may
-         *              change without notice and only exists to enable unit testing.
-         */
-        public ConflictContext(
-                DependencyNode root,
-                String conflictId,
-                Map<DependencyNode, String> conflictIds,
-                Collection<ConflictItem> items) {
-            this(root, conflictIds, items);
-            this.conflictId = conflictId;
-        }
-
-        /**
-         * Left in place only to maintain source compatibility.
-         *
-         * @deprecated Do not use, is left in place only to maintain source compatibility (Object is casted to String).
-         */
-        @Deprecated
-        public ConflictContext(
-                DependencyNode root,
-                Object conflictId,
-                Map<DependencyNode, String> conflictIds,
-                Collection<ConflictItem> items) {
-            this(root, (String) conflictId, conflictIds, items);
-        }
-
+    public interface ConflictContext {
         /**
          * Gets the root node of the dependency graph being transformed.
          *
          * @return The root node of the dependency graph, never {@code null}.
          */
-        public DependencyNode getRoot() {
-            return root;
-        }
+        DependencyNode getRoot();
 
         /**
          * Determines whether the specified dependency node belongs to this conflict context.
@@ -1084,77 +356,56 @@ public final class ConflictResolver implements DependencyGraphTransformer {
          * @param node The dependency node to check, must not be {@code null}.
          * @return {@code true} if the given node belongs to this conflict context, {@code false} otherwise.
          */
-        public boolean isIncluded(DependencyNode node) {
-            return conflictId.equals(conflictIds.get(node));
-        }
+        boolean isIncluded(DependencyNode node);
 
         /**
          * Gets the collection of conflict items in this context.
          *
          * @return The (read-only) collection of conflict items in this context, never {@code null}.
          */
-        public Collection<ConflictItem> getItems() {
-            return items;
-        }
+        Collection<ConflictItem> getItems();
 
         /**
          * Gets the conflict item which has been selected as the winner among the conflicting dependencies.
          *
          * @return The winning conflict item or {@code null} if not set yet.
          */
-        public ConflictItem getWinner() {
-            return winner;
-        }
+        ConflictItem getWinner();
 
         /**
          * Sets the conflict item which has been selected as the winner among the conflicting dependencies.
          *
          * @param winner The winning conflict item, may be {@code null}.
          */
-        public void setWinner(ConflictItem winner) {
-            this.winner = winner;
-        }
+        void setWinner(ConflictItem winner);
 
         /**
          * Gets the effective scope of the winning dependency.
          *
          * @return The effective scope of the winning dependency or {@code null} if none.
          */
-        public String getScope() {
-            return scope;
-        }
+        String getScope();
 
         /**
          * Sets the effective scope of the winning dependency.
          *
          * @param scope The effective scope, may be {@code null}.
          */
-        public void setScope(String scope) {
-            this.scope = scope;
-        }
+        void setScope(String scope);
 
         /**
          * Gets the effective optional flag of the winning dependency.
          *
          * @return The effective optional flag or {@code null} if none.
          */
-        public Boolean getOptional() {
-            return optional;
-        }
+        Boolean getOptional();
 
         /**
          * Sets the effective optional flag of the winning dependency.
          *
          * @param optional The effective optional flag, may be {@code null}.
          */
-        public void setOptional(Boolean optional) {
-            this.optional = optional;
-        }
-
-        @Override
-        public String toString() {
-            return winner + " @ " + scope + " < " + items;
-        }
+        void setOptional(Boolean optional);
     }
 
     /**
diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/PathConflictResolver.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/PathConflictResolver.java
new file mode 100644
index 000000000..7f737ec97
--- /dev/null
+++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/PathConflictResolver.java
@@ -0,0 +1,906 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.eclipse.aether.util.graph.transformer;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import org.eclipse.aether.RepositoryException;
+import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.aether.collection.DependencyGraphTransformationContext;
+import org.eclipse.aether.graph.DefaultDependencyNode;
+import org.eclipse.aether.graph.Dependency;
+import org.eclipse.aether.graph.DependencyNode;
+import org.eclipse.aether.util.artifact.ArtifactIdUtils;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * A high-performance dependency graph transformer that resolves version and scope conflicts among dependencies.
+ * This is the recommended conflict resolver implementation that provides O(N) performance characteristics,
+ * significantly improving upon the O(N²) worst-case performance of {@link ClassicConflictResolver}.
+ * <p>
+ * For a given set of conflicting nodes, one node will be chosen as the winner. How losing nodes are handled
+ * depends on the configured verbosity level: they may be removed entirely, have their children removed, or
+ * be left in place with conflict information. The exact rules by which a winning node and its effective scope
+ * are determined are controlled by user-supplied implementations of {@link VersionSelector}, {@link ScopeSelector},
+ * {@link OptionalitySelector} and {@link ScopeDeriver}.
+ * <p>
+ * <strong>Performance Characteristics:</strong>
+ * <ul>
+ * <li><strong>Time Complexity:</strong> O(N) where N is the number of dependency nodes</li>
+ * <li><strong>Memory Usage:</strong> Creates a parallel tree structure for conflict-free processing</li>
+ * <li><strong>Scalability:</strong> Excellent performance on large multi-module projects</li>
+ * </ul>
+ * <p>
+ * <strong>Algorithm Overview:</strong>
+ * <ol>
+ * <li><strong>Path Tree Construction:</strong> Builds a cycle-free parallel tree structure from the input
+ *     dependency graph, where each {@code Path} represents a unique route to a dependency node</li>
+ * <li><strong>Conflict Partitioning:</strong> Groups paths by conflict ID (based on groupId:artifactId:classifier:extension coordinates)</li>
+ * <li><strong>Topological Processing:</strong> Processes conflict groups in topologically sorted order</li>
+ * <li><strong>Winner Selection:</strong> Uses provided selectors to choose winners within each conflict group</li>
+ * <li><strong>Graph Transformation:</strong> Applies changes back to the original dependency graph</li>
+ * </ol>
+ * <p>
+ * <strong>Key Differences from {@link ClassicConflictResolver}:</strong>
+ * <ul>
+ * <li><strong>Performance:</strong> O(N) vs O(N²) time complexity</li>
+ * <li><strong>Memory Strategy:</strong> Uses parallel tree structure vs in-place graph modification</li>
+ * <li><strong>Cycle Handling:</strong> Explicitly breaks cycles during tree construction</li>
+ * <li><strong>Processing Order:</strong> Level-by-level from root vs depth-first traversal</li>
+ * </ul>
+ * <p>
+ * <strong>When to Use:</strong>
+ * <ul>
+ * <li>Default choice for all new projects and Maven 4+ installations</li>
+ * <li>Large multi-module projects with many dependencies</li>
+ * <li>Performance-critical build environments</li>
+ * <li>Any scenario where {@link ClassicConflictResolver} shows performance bottlenecks</li>
+ * </ul>
+ * <p>
+ * <strong>Implementation Note:</strong> This conflict resolver builds a cycle-free "parallel" structure based on the
+ * passed-in dependency graph, and applies operations level by level starting from the root. The parallel {@code Path}
+ * tree ensures that cycles in the original graph don't affect the conflict resolution algorithm's performance.
+ *
+ * @see ClassicConflictResolver
+ * @since 2.0.11
+ */
+public final class PathConflictResolver extends ConflictResolver {
+
+    /**
+     * Creates a new conflict resolver instance with the specified hooks.
+     *
+     * @param versionSelector The version selector to use, must not be {@code null}.
+     * @param scopeSelector The scope selector to use, must not be {@code null}.
+     * @param optionalitySelector The optionality selector ot use, must not be {@code null}.
+     * @param scopeDeriver The scope deriver to use, must not be {@code null}.
+     */
+    public PathConflictResolver(
+            VersionSelector versionSelector,
+            ScopeSelector scopeSelector,
+            OptionalitySelector optionalitySelector,
+            ScopeDeriver scopeDeriver) {
+        super(versionSelector, scopeSelector, optionalitySelector, scopeDeriver);
+    }
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public DependencyNode transformGraph(DependencyNode node, DependencyGraphTransformationContext context)
+            throws RepositoryException {
+        requireNonNull(node, "node cannot be null");
+        requireNonNull(context, "context cannot be null");
+        List<String> sortedConflictIds = (List<String>) context.get(TransformationContextKeys.SORTED_CONFLICT_IDS);
+        if (sortedConflictIds == null) {
+            ConflictIdSorter sorter = new ConflictIdSorter();
+            sorter.transformGraph(node, context);
+
+            sortedConflictIds = (List<String>) context.get(TransformationContextKeys.SORTED_CONFLICT_IDS);
+        }
+
+        @SuppressWarnings("unchecked")
+        Map<String, Object> stats = (Map<String, Object>) context.get(TransformationContextKeys.STATS);
+        long time1 = System.nanoTime();
+
+        @SuppressWarnings("unchecked")
+        Collection<Collection<String>> conflictIdCycles =
+                (Collection<Collection<String>>) context.get(TransformationContextKeys.CYCLIC_CONFLICT_IDS);
+        if (conflictIdCycles == null) {
+            throw new RepositoryException("conflict id cycles have not been identified");
+        }
+
+        Map<DependencyNode, String> conflictIds =
+                (Map<DependencyNode, String>) context.get(TransformationContextKeys.CONFLICT_IDS);
+        if (conflictIds == null) {
+            throw new RepositoryException("conflict groups have not been identified");
+        }
+
+        Map<String, Collection<String>> cyclicPredecessors = new HashMap<>();
+        for (Collection<String> cycle : conflictIdCycles) {
+            for (String conflictId : cycle) {
+                Collection<String> predecessors = cyclicPredecessors.computeIfAbsent(conflictId, k -> new HashSet<>());
+                predecessors.addAll(cycle);
+            }
+        }
+
+        State state = new State(
+                getVerbosity(context.getSession()),
+                versionSelector.getInstance(node, context),
+                scopeSelector.getInstance(node, context),
+                scopeDeriver.getInstance(node, context),
+                optionalitySelector.getInstance(node, context),
+                sortedConflictIds,
+                conflictIds,
+                cyclicPredecessors);
+
+        state.build(node);
+
+        // loop over topographically sorted conflictIds
+        for (String conflictId : state.sortedConflictIds) {
+            // paths in given conflict group to consider
+            List<Path> paths = state.partitions.get(conflictId);
+            if (paths.isEmpty()) {
+                // this means that whole group "fall out of scope" (are all on loser branches); skip
+                continue;
+            }
+
+            // create conflict context for given conflictId
+            ConflictContext ctx = new ConflictContext(
+                    node,
+                    state.conflictIds,
+                    paths.stream().map(ConflictItem::new).collect(Collectors.toList()),
+                    conflictId);
+
+            // select winner (is done by VersionSelector)
+            state.versionSelector.selectVersion(ctx);
+            if (ctx.winner == null) {
+                throw new RepositoryException("conflict resolver did not select winner among " + ctx.items);
+            }
+            // select scope (no side effect between this and above operations)
+            state.scopeSelector.selectScope(ctx);
+            // select optionality (no side effect between this and above operations)
+            state.optionalitySelector.selectOptionality(ctx);
+
+            // we have a winner path
+            Path winnerPath = ctx.winner.path;
+
+            // mark conflictId as resolved with winner; sanity check
+            if (state.resolvedIds.containsKey(conflictId)) {
+                throw new RepositoryException("conflict resolver already have winner for conflictId=" + conflictId
+                        + ": " + state.resolvedIds);
+            }
+            state.resolvedIds.put(conflictId, winnerPath);
+
+            // loop over considered paths and apply selection results; note: node may remove itself from iterated list
+            for (Path path : new ArrayList<>(paths)) {
+                // apply selected inherited properties scope/optional to all (winner carries version; others are losers)
+                path.scope = ctx.scope;
+                path.optional = ctx.optional;
+
+                // reset children as inheritance may be affected by this node scope/optionality change
+                path.children.forEach(c -> c.pull(0));
+                // derive with new values from this to children only; observe winner flag
+                path.derive(1, path == winnerPath);
+                // push this node full level changes to DN graph
+                path.push(0);
+            }
+        }
+
+        if (stats != null) {
+            long time2 = System.nanoTime();
+            stats.put("ConflictResolver.totalTime", time2 - time1);
+            stats.put(
+                    "ConflictResolver.conflictItemCount",
+                    state.partitions.values().stream().map(List::size).reduce(0, Integer::sum));
+        }
+
+        return node;
+    }
+
+    /**
+     * State of conflict resolution processing, to make this component (held in session) re-entrant by multiple threads.
+     */
+    private static class State {
+        /**
+         * Verbosity to be applied, see {@link Verbosity}.
+         */
+        private final Verbosity verbosity;
+
+        /**
+         * The {@link VersionSelector} to use.
+         */
+        private final VersionSelector versionSelector;
+
+        /**
+         * The {@link ScopeSelector} to use.
+         */
+        private final ScopeSelector scopeSelector;
+
+        /**
+         * The {@link ScopeDeriver} to use.
+         */
+        private final ScopeDeriver scopeDeriver;
+
+        /**
+         * The {@link OptionalitySelector} to use/
+         */
+        private final OptionalitySelector optionalitySelector;
+
+        /**
+         * Topologically sorted conflictIds from {@link ConflictIdSorter}.
+         */
+        private final List<String> sortedConflictIds;
+
+        /**
+         * The node to conflictId mapping from {@link ConflictMarker}.
+         */
+        private final Map<DependencyNode, String> conflictIds;
+
+        /**
+         * The map of conflictIds which could apply to ancestors of nodes with the key conflict id, used to avoid
+         * recursion early on. This is basically a superset of the key set of resolvedIds, the additional ids account
+         * for cyclic dependencies. From {@link ConflictIdSorter}.
+         */
+        private final Map<String, Collection<String>> cyclicPredecessors;
+
+        /**
+         * A mapping from conflictId to paths represented as {@link Path}s that exist for each conflictId. In other
+         * words all paths to each {@link DependencyNode} that are member of same conflictId group.
+         */
+        private final Map<String, List<Path>> partitions;
+
+        /**
+         * A mapping from conflictIds to winner {@link Path}, hence {@link DependencyNode}  for given conflictId.
+         */
+        private final Map<String, Path> resolvedIds;
+
+        @SuppressWarnings("checkstyle:ParameterNumber")
+        private State(
+                Verbosity verbosity,
+                VersionSelector versionSelector,
+                ScopeSelector scopeSelector,
+                ScopeDeriver scopeDeriver,
+                OptionalitySelector optionalitySelector,
+                List<String> sortedConflictIds,
+                Map<DependencyNode, String> conflictIds,
+                Map<String, Collection<String>> cyclicPredecessors) {
+            this.verbosity = verbosity;
+            this.versionSelector = versionSelector;
+            this.scopeSelector = scopeSelector;
+            this.scopeDeriver = scopeDeriver;
+            this.optionalitySelector = optionalitySelector;
+            this.sortedConflictIds = sortedConflictIds;
+            this.conflictIds = conflictIds;
+            this.cyclicPredecessors = cyclicPredecessors;
+            this.partitions = new HashMap<>();
+            this.resolvedIds = new HashMap<>();
+        }
+
+        /**
+         * Consumes the dirty graph and builds internal structures out of {@link Path} instances that is always a
+         * tree.
+         */
+        private Path build(DependencyNode node) throws RepositoryException {
+            Path root = new Path(this, node, null);
+            gatherCRNodes(root);
+            return root;
+        }
+
+        /**
+         * Recursively builds {@link Path} graph by observing each node associated {@link DependencyNode}.
+         */
+        private void gatherCRNodes(Path node) throws RepositoryException {
+            List<DependencyNode> children = node.dn.getChildren();
+            if (!children.isEmpty()) {
+                // add children; we will get back those really added (not causing cycles)
+                List<Path> added = node.addChildren(children);
+                for (Path child : added) {
+                    gatherCRNodes(child);
+                }
+            }
+        }
+    }
+
+    /**
+     * Represents a unique path within the dependency graph from the root to a specific {@link DependencyNode}.
+     * This is the core data structure that enables the O(N) performance of {@link PathConflictResolver}.
+     * <p>
+     * <strong>Key Concepts:</strong>
+     * <ul>
+     * <li><strong>Path Uniqueness:</strong> Each {@code Path} instance represents a distinct route through
+     *     the dependency graph, even if multiple paths lead to the same {@code DependencyNode}</li>
+     * <li><strong>Cycle-Free Structure:</strong> The {@code Path} tree is guaranteed to be acyclic, even
+     *     when the original dependency graph contains cycles</li>
+     * <li><strong>Parallel Structure:</strong> This creates a "clean" tree alongside the original "dirty"
+     *     graph for efficient processing</li>
+     * </ul>
+     * <p>
+     * <strong>Example:</strong> If dependency A appears in the graph via two different routes:
+     * <pre>
+     * Root → B → A (path 1)
+     * Root → C → A (path 2)
+     * </pre>
+     * Two separate {@code Path} instances will be created, both pointing to the same {@code DependencyNode} A,
+     * but representing different paths through the dependency tree.
+     * <p>
+     * <strong>Memory Optimization:</strong> While this creates additional objects, it enables the algorithm
+     * to process conflicts in O(N) time rather than O(N²), making it much more efficient for large graphs.
+     * <p>
+     * <strong>Conflict Resolution:</strong> Paths are grouped by conflict ID (based on groupId:artifactId:classifier:extension coordinates),
+     * and the conflict resolution algorithm can efficiently process each group independently.
+     */
+    private static class Path {
+        private final State state;
+        private DependencyNode dn;
+        private final String conflictId;
+        private final Path parent;
+        private final int depth;
+        private final List<Path> children;
+        private String scope;
+        private boolean optional;
+
+        private Path(State state, DependencyNode dn, Path parent) {
+            this.state = state;
+            this.dn = dn;
+            this.conflictId = state.conflictIds.get(dn);
+            this.parent = parent;
+            this.depth = parent != null ? parent.depth + 1 : 0;
+            this.children = new ArrayList<>();
+            pull(0);
+
+            this.state
+                    .partitions
+                    .computeIfAbsent(this.conflictId, k -> new ArrayList<>())
+                    .add(this);
+        }
+
+        /**
+         * Pulls (possibly updated) scope and optional values from associated {@link DependencyNode} to this instance,
+         * going down toward children recursively the required count of levels.
+         */
+        private void pull(int levels) {
+            Dependency d = dn.getDependency();
+            if (d != null) {
+                this.scope = d.getScope();
+                this.optional = d.isOptional();
+            } else {
+                this.scope = "";
+                this.optional = false;
+            }
+            int newLevels = levels - 1;
+            if (newLevels >= 0) {
+                for (Path child : this.children) {
+                    child.pull(newLevels);
+                }
+            }
+        }
+
+        /**
+         * Derives (from this to children direction) values that are "inherited" in tree: scope and optionality in the tree
+         * recursively going down required count of "levels".
+         */
+        private void derive(int levels, boolean winner) throws RepositoryException {
+            if (!winner) {
+                if (this.parent != null) {
+                    if ((dn.getManagedBits() & DependencyNode.MANAGED_SCOPE) == 0) {
+                        ScopeContext context = new ScopeContext(this.parent.scope, this.scope);
+                        state.scopeDeriver.deriveScope(context);
+                        this.scope = context.derivedScope;
+                    }
+                    if ((dn.getManagedBits() & DependencyNode.MANAGED_OPTIONAL) == 0) {
+                        if (!this.optional && this.parent.optional) {
+                            this.optional = true;
+                        }
+                    }
+                } else {
+                    this.scope = "";
+                    this.optional = false;
+                }
+            }
+            int newLevels = levels - 1;
+            if (newLevels >= 0) {
+                for (Path child : children) {
+                    child.derive(newLevels, false);
+                }
+            }
+        }
+
+        /**
+         * Pushes (applies) the scope and optional and structural changes to associated {@link DependencyNode} modifying
+         * the graph of it. Verbosity is observed, and depending on it the conflicting/loser nodes are removed, or
+         * just their children is removed (with special care for version ranges, see {@link #relatedSiblingsCount(Artifact, Path)}
+         * or by just doing nothing with them only marking losers in full verbosity mode.
+         */
+        private void push(int levels) {
+            if (this.parent != null) {
+                Path winner = this.state.resolvedIds.get(this.conflictId);
+                if (winner == null) {
+                    throw new IllegalStateException(
+                            "Winner selection did not happen for conflictId=" + this.conflictId);
+                }
+                if (!Objects.equals(winner.conflictId, this.conflictId)) {
+                    throw new IllegalStateException(
+                            "ConflictId mix-up: this=" + this.conflictId + " winner=" + winner.conflictId);
+                }
+
+                if (winner == this) {
+                    // copy onto dn; if applicable
+                    if (this.dn.getDependency() != null) {
+                        this.dn.setData(
+                                NODE_DATA_ORIGINAL_SCOPE,
+                                this.dn.getDependency().getScope());
+                        this.dn.setData(
+                                NODE_DATA_ORIGINAL_OPTIONALITY,
+                                this.dn.getDependency().getOptional());
+                        this.dn.setScope(this.scope);
+                        this.dn.setOptional(this.optional);
+                    }
+                } else {
+                    // loser; move out of scope
+                    moveOutOfScope();
+                    boolean markLoser = false;
+                    switch (state.verbosity) {
+                        case NONE:
+                            // remove this dn
+                            this.parent.children.remove(this);
+                            this.parent.dn.setChildren(new ArrayList<>(this.parent.dn.getChildren()));
+                            this.parent.dn.getChildren().remove(this.dn);
+                            this.children.clear();
+                            break;
+                        case STANDARD:
+                            // leave this dn; remove children
+                            String artifactId = ArtifactIdUtils.toId(this.dn.getArtifact());
+                            String winnerArtifactId = ArtifactIdUtils.toId(winner.dn.getArtifact());
+                            if (!Objects.equals(artifactId, winnerArtifactId)
+                                    && relatedSiblingsCount(this.dn.getArtifact(), this.parent) > 1) {
+                                this.parent.children.remove(this);
+                                this.parent.dn.setChildren(new ArrayList<>(this.parent.dn.getChildren()));
+                                this.parent.dn.getChildren().remove(this.dn);
+                                this.children.clear();
+                            } else {
+                                this.children.clear();
+                                this.dn.setChildren(Collections.emptyList());
+                                markLoser = true;
+                            }
+                            break;
+                        case FULL:
+                            // leave all in place (even cycles)
+                            markLoser = true;
+                            break;
+                        default:
+                            throw new IllegalArgumentException("Unknown " + state.verbosity);
+                    }
+                    if (markLoser) {
+                        // copy dn
+                        DependencyNode dnCopy = new DefaultDependencyNode(this.dn);
+                        dnCopy.setData(NODE_DATA_WINNER, winner.dn);
+                        dnCopy.setData(
+                                NODE_DATA_ORIGINAL_SCOPE,
+                                this.dn.getDependency().getScope());
+                        dnCopy.setData(
+                                NODE_DATA_ORIGINAL_OPTIONALITY,
+                                this.dn.getDependency().getOptional());
+                        dnCopy.setScope(this.scope);
+                        dnCopy.setOptional(this.optional);
+                        if (Verbosity.FULL != state.verbosity) {
+                            dnCopy.getChildren().clear();
+                        }
+
+                        // swap it out in DN graph
+                        this.parent
+                                .dn
+                                .getChildren()
+                                .set(this.parent.dn.getChildren().indexOf(this.dn), dnCopy);
+                        this.dn = dnCopy;
+                    }
+                }
+            }
+            if (!this.children.isEmpty()) {
+                int newLevels = levels - 1;
+                if (newLevels >= 0) {
+                    // child may remove itself from iterated list
+                    for (Path child : new ArrayList<>(children)) {
+                        child.push(newLevels);
+                    }
+                }
+            } else if (!this.dn.getChildren().isEmpty()) {
+                this.dn.setChildren(Collections.emptyList());
+            }
+        }
+
+        /**
+         * Counts "relatives" (GACE equal) artifacts under same parent; this is for cleaning up redundant nodes in
+         * case of version ranges, where same GACE is resolved into multiple GACEV as range is resolved. In {@link Verbosity#STANDARD}
+         * verbosity mode we remove "redundant" nodes (of a range) leaving only "winner equal" loser, that have same GACEV as winner.
+         */
+        private int relatedSiblingsCount(Artifact artifact, Path parent) {
+            String ga = artifact.getGroupId() + ":" + artifact.getArtifactId();
+            return Math.toIntExact(parent.children.stream()
+                    .map(n -> n.dn.getArtifact())
+                    .filter(a -> ga.equals(a.getGroupId() + ":" + a.getArtifactId()))
+                    .count());
+        }
+
+        /**
+         * Removes this and all child {@link Path} nodes from winner selection scope; essentially marks whole subtree
+         * from "this and below" as loser, to not be considered in subsequent winner selections.
+         */
+        private void moveOutOfScope() {
+            this.state.partitions.get(this.conflictId).remove(this);
+            for (Path child : this.children) {
+                child.moveOutOfScope();
+            }
+        }
+
+        /**
+         * Adds node children: this method should be "batch" used, as all (potential) children should be added at once.
+         * Method will return really added ones, as this class avoids cycles.
+         */
+        private List<Path> addChildren(List<DependencyNode> children) throws RepositoryException {
+            Collection<String> thisCyclicPredecessors =
+                    this.state.cyclicPredecessors.getOrDefault(this.conflictId, Collections.emptySet());
+
+            ArrayList<Path> added = new ArrayList<>(children.size());
+            for (DependencyNode child : children) {
+                String childConflictId = this.state.conflictIds.get(child);
+                if (!this.state.partitions.containsKey(childConflictId)
+                        || !thisCyclicPredecessors.contains(childConflictId)) {
+                    Path c = new Path(this.state, child, this);
+                    this.children.add(c);
+                    c.derive(0, false);
+                    added.add(c);
+                }
+            }
+            return added;
+        }
+
+        /**
+         * Dump for debug.
+         */
+        private void dump(String padding) {
+            System.out.println(padding + this.dn + ": " + this.scope + "/" + this.optional);
+            for (Path child : children) {
+                child.dump(padding + "  ");
+            }
+        }
+
+        /**
+         * For easier debug.
+         */
+        @Override
+        public String toString() {
+            return this.dn.toString();
+        }
+    }
+
+    /**
+     * A context used to hold information that is relevant for deriving the scope of a child dependency.
+     *
+     * @see ScopeDeriver
+     * @noinstantiate This class is not intended to be instantiated by clients in production code, the constructor may
+     *                change without notice and only exists to enable unit testing.
+     */
+    private static final class ScopeContext implements ConflictResolver.ScopeContext {
+        private final String parentScope;
+        private final String childScope;
+        private String derivedScope;
+
+        /**
+         * Creates a new scope context with the specified properties.
+         *
+         * @param parentScope The scope of the parent dependency, may be {@code null}.
+         * @param childScope The scope of the child dependency, may be {@code null}.
+         * @noreference This class is not intended to be instantiated by clients in production code, the constructor may
+         *              change without notice and only exists to enable unit testing.
+         */
+        private ScopeContext(String parentScope, String childScope) {
+            this.parentScope = (parentScope != null) ? parentScope : "";
+            this.derivedScope = (childScope != null) ? childScope : "";
+            this.childScope = (childScope != null) ? childScope : "";
+        }
+
+        /**
+         * Gets the scope of the parent dependency. This is usually the scope that was derived by earlier invocations of
+         * the scope deriver.
+         *
+         * @return The scope of the parent dependency, never {@code null}.
+         */
+        public String getParentScope() {
+            return parentScope;
+        }
+
+        /**
+         * Gets the original scope of the child dependency. This is the scope that was declared in the artifact
+         * descriptor of the parent dependency.
+         *
+         * @return The original scope of the child dependency, never {@code null}.
+         */
+        public String getChildScope() {
+            return childScope;
+        }
+
+        /**
+         * Gets the derived scope of the child dependency. This is initially equal to {@link #getChildScope()} until the
+         * scope deriver makes changes.
+         *
+         * @return The derived scope of the child dependency, never {@code null}.
+         */
+        public String getDerivedScope() {
+            return derivedScope;
+        }
+
+        /**
+         * Sets the derived scope of the child dependency.
+         *
+         * @param derivedScope The derived scope of the dependency, may be {@code null}.
+         */
+        public void setDerivedScope(String derivedScope) {
+            this.derivedScope = (derivedScope != null) ? derivedScope : "";
+        }
+    }
+
+    /**
+     * A conflicting dependency.
+     *
+     * @noinstantiate This class is not intended to be instantiated by clients in production code, the constructor may
+     *                change without notice and only exists to enable unit testing.
+     */
+    private static final class ConflictItem implements ConflictResolver.ConflictItem {
+        private final Path path;
+        private final List<DependencyNode> parent;
+        private final Artifact artifact;
+        private final DependencyNode node;
+        private final int depth;
+        private final String scope;
+        private final int optionalities;
+
+        /**
+         * Bit flag indicating whether one or more paths consider the dependency non-optional.
+         */
+        public static final int OPTIONAL_FALSE = 0x01;
+
+        /**
+         * Bit flag indicating whether one or more paths consider the dependency optional.
+         */
+        public static final int OPTIONAL_TRUE = 0x02;
+
+        private ConflictItem(Path path) {
+            this.path = path;
+            if (path.parent != null) {
+                DependencyNode parent = path.parent.dn;
+                this.parent = parent.getChildren();
+                this.artifact = parent.getArtifact();
+            } else {
+                this.parent = null;
+                this.artifact = null;
+            }
+            this.node = path.dn;
+            this.depth = path.depth;
+            this.scope = path.scope;
+            this.optionalities = path.optional ? OPTIONAL_TRUE : OPTIONAL_FALSE;
+        }
+
+        /**
+         * Determines whether the specified conflict item is a sibling of this item.
+         *
+         * @param item The other conflict item, must not be {@code null}.
+         * @return {@code true} if the given item has the same parent as this item, {@code false} otherwise.
+         */
+        @Override
+        public boolean isSibling(ConflictResolver.ConflictItem item) {
+            return parent == ((ConflictItem) item).parent;
+        }
+
+        /**
+         * Gets the dependency node involved in the conflict.
+         *
+         * @return The involved dependency node, never {@code null}.
+         */
+        @Override
+        public DependencyNode getNode() {
+            return node;
+        }
+
+        /**
+         * Gets the dependency involved in the conflict, short for {@code getNode.getDependency()}.
+         *
+         * @return The involved dependency, never {@code null}.
+         */
+        @Override
+        public Dependency getDependency() {
+            return node.getDependency();
+        }
+
+        /**
+         * Gets the zero-based depth at which the conflicting node occurs in the graph. As such, the depth denotes the
+         * number of parent nodes. If actually multiple paths lead to the node, the return value denotes the smallest
+         * possible depth.
+         *
+         * @return The zero-based depth of the node in the graph.
+         */
+        @Override
+        public int getDepth() {
+            return depth;
+        }
+
+        /**
+         * Gets the derived scopes of the dependency. In general, the same dependency node could be reached via
+         * different paths and each path might result in a different derived scope.
+         *
+         * @see ScopeDeriver
+         * @return The (read-only) set of derived scopes of the dependency, never {@code null}.
+         */
+        @Override
+        public Collection<String> getScopes() {
+            return Collections.singleton(scope);
+        }
+
+        /**
+         * Gets the derived optionalities of the dependency. In general, the same dependency node could be reached via
+         * different paths and each path might result in a different derived optionality.
+         *
+         * @return A bit field consisting of {@link PathConflictResolver.ConflictItem#OPTIONAL_FALSE} and/or
+         *         {@link PathConflictResolver.ConflictItem#OPTIONAL_TRUE} indicating the derived optionalities the
+         *         dependency was encountered with.
+         */
+        @Override
+        public int getOptionalities() {
+            return optionalities;
+        }
+
+        @Override
+        public String toString() {
+            return node + " @ " + depth + " < " + artifact;
+        }
+    }
+
+    /**
+     * A context used to hold information that is relevant for resolving version and scope conflicts.
+     *
+     * @see VersionSelector
+     * @see ScopeSelector
+     * @noinstantiate This class is not intended to be instantiated by clients in production code, the constructor may
+     *                change without notice and only exists to enable unit testing.
+     */
+    private static final class ConflictContext implements ConflictResolver.ConflictContext {
+        private final DependencyNode root;
+        private final Map<DependencyNode, String> conflictIds;
+        private final Collection<ConflictResolver.ConflictItem> items;
+        private final String conflictId;
+
+        // elected properties
+        private ConflictItem winner;
+        private String scope;
+        private Boolean optional;
+
+        private ConflictContext(
+                DependencyNode root,
+                Map<DependencyNode, String> conflictIds,
+                Collection<ConflictItem> items,
+                String conflictId) {
+            this.root = root;
+            this.conflictIds = conflictIds;
+            this.items = Collections.unmodifiableCollection(items);
+            this.conflictId = conflictId;
+        }
+
+        /**
+         * Gets the root node of the dependency graph being transformed.
+         *
+         * @return The root node of the dependency graph, never {@code null}.
+         */
+        @Override
+        public DependencyNode getRoot() {
+            return root;
+        }
+
+        /**
+         * Determines whether the specified dependency node belongs to this conflict context.
+         *
+         * @param node The dependency node to check, must not be {@code null}.
+         * @return {@code true} if the given node belongs to this conflict context, {@code false} otherwise.
+         */
+        @Override
+        public boolean isIncluded(DependencyNode node) {
+            return conflictId.equals(conflictIds.get(node));
+        }
+
+        /**
+         * Gets the collection of conflict items in this context.
+         *
+         * @return The (read-only) collection of conflict items in this context, never {@code null}.
+         */
+        @Override
+        public Collection<ConflictResolver.ConflictItem> getItems() {
+            return items;
+        }
+
+        /**
+         * Gets the conflict item which has been selected as the winner among the conflicting dependencies.
+         *
+         * @return The winning conflict item or {@code null} if not set yet.
+         */
+        @Override
+        public ConflictResolver.ConflictItem getWinner() {
+            return winner;
+        }
+
+        /**
+         * Sets the conflict item which has been selected as the winner among the conflicting dependencies.
+         *
+         * @param winner The winning conflict item, may be {@code null}.
+         */
+        @Override
+        public void setWinner(ConflictResolver.ConflictItem winner) {
+            this.winner = (ConflictItem) winner;
+        }
+
+        /**
+         * Gets the effective scope of the winning dependency.
+         *
+         * @return The effective scope of the winning dependency or {@code null} if none.
+         */
+        @Override
+        public String getScope() {
+            return scope;
+        }
+
+        /**
+         * Sets the effective scope of the winning dependency.
+         *
+         * @param scope The effective scope, may be {@code null}.
+         */
+        @Override
+        public void setScope(String scope) {
+            this.scope = scope;
+        }
+
+        /**
+         * Gets the effective optional flag of the winning dependency.
+         *
+         * @return The effective optional flag or {@code null} if none.
+         */
+        @Override
+        public Boolean getOptional() {
+            return optional;
+        }
+
+        /**
+         * Sets the effective optional flag of the winning dependency.
+         *
+         * @param optional The effective optional flag, may be {@code null}.
+         */
+        @Override
+        public void setOptional(Boolean optional) {
+            this.optional = optional;
+        }
+
+        @Override
+        public String toString() {
+            return winner + " @ " + scope + " < " + items;
+        }
+    }
+}
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/AbstractConflictResolverTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/AbstractConflictResolverTest.java
new file mode 100644
index 000000000..d47dc1b0c
--- /dev/null
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/AbstractConflictResolverTest.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.eclipse.aether.util.graph.transformer;
+
+import java.util.LinkedList;
+import java.util.List;
+
+import org.eclipse.aether.DefaultRepositorySystemSession;
+import org.eclipse.aether.RepositoryException;
+import org.eclipse.aether.collection.DependencyGraphTransformationContext;
+import org.eclipse.aether.graph.DependencyNode;
+import org.eclipse.aether.internal.test.util.DependencyGraphParser;
+import org.eclipse.aether.internal.test.util.TestUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ */
+public abstract class AbstractConflictResolverTest {
+
+    protected DependencyGraphParser parser;
+
+    protected DefaultRepositorySystemSession session;
+
+    protected DependencyGraphTransformationContext context;
+
+    protected abstract DependencyGraphParser newParser();
+
+    protected DependencyNode transform(ConflictResolver conflictResolver, DependencyNode root)
+            throws RepositoryException {
+        context = TestUtils.newTransformationContext(session);
+        root = conflictResolver.transformGraph(root, context);
+        assertNotNull(root);
+        return root;
+    }
+
+    protected void setVerbosity(ConflictResolver.Verbosity verbosity) {
+        session.setConfigProperty(ConflictResolver.CONFIG_PROP_VERBOSE, verbosity);
+    }
+
+    protected DependencyNode parseResource(String resource, String... substitutions) throws Exception {
+        parser.setSubstitutions(substitutions);
+        return parser.parseResource(resource);
+    }
+
+    protected List<DependencyNode> find(DependencyNode node, String id) {
+        LinkedList<DependencyNode> trail = new LinkedList<>();
+        find(trail, node, id);
+        return trail;
+    }
+
+    private boolean find(LinkedList<DependencyNode> trail, DependencyNode node, String id) {
+        trail.addFirst(node);
+
+        if (isMatch(node, id)) {
+            return true;
+        }
+
+        for (DependencyNode child : node.getChildren()) {
+            if (find(trail, child, id)) {
+                return true;
+            }
+        }
+
+        trail.removeFirst();
+
+        return false;
+    }
+
+    private boolean isMatch(DependencyNode node, String id) {
+        if (node.getDependency() == null) {
+            return false;
+        }
+        return id.equals(node.getDependency().getArtifact().getArtifactId());
+    }
+
+    @BeforeEach
+    void setUp() {
+        parser = newParser();
+        session = TestUtils.newSession();
+    }
+
+    @AfterEach
+    void tearDown() {
+        parser = null;
+        session = null;
+        context = null;
+    }
+}
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConfigurableVersionSelectorTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConfigurableVersionSelectorTest.java
index 0e33d9fd9..8339217b8 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConfigurableVersionSelectorTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConfigurableVersionSelectorTest.java
@@ -19,11 +19,14 @@
 package org.eclipse.aether.util.graph.transformer;
 
 import java.util.List;
+import java.util.stream.Stream;
 
 import org.eclipse.aether.collection.UnsolvableVersionConflictException;
 import org.eclipse.aether.graph.DependencyNode;
 import org.eclipse.aether.internal.test.util.DependencyGraphParser;
-import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertSame;
@@ -32,15 +35,21 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
  */
-public class ConfigurableVersionSelectorTest extends AbstractDependencyGraphTransformerTest {
-    @Override
-    protected ConflictResolver newTransformer() {
-        return new ConflictResolver(
-                new ConfigurableVersionSelector(new ConfigurableVersionSelector.MajorVersionConvergence(
-                        new ConfigurableVersionSelector.Nearest())),
-                new JavaScopeSelector(),
-                new SimpleOptionalitySelector(),
-                new JavaScopeDeriver());
+public final class ConfigurableVersionSelectorTest extends AbstractConflictResolverTest {
+    private static Stream<Arguments> conflictResolverSource() {
+        return Stream.of(
+                Arguments.of(new ClassicConflictResolver(
+                        new ConfigurableVersionSelector(new ConfigurableVersionSelector.MajorVersionConvergence(
+                                new ConfigurableVersionSelector.Nearest())),
+                        new JavaScopeSelector(),
+                        new SimpleOptionalitySelector(),
+                        new JavaScopeDeriver())),
+                Arguments.of(new PathConflictResolver(
+                        new ConfigurableVersionSelector(new ConfigurableVersionSelector.MajorVersionConvergence(
+                                new ConfigurableVersionSelector.Nearest())),
+                        new JavaScopeSelector(),
+                        new SimpleOptionalitySelector(),
+                        new JavaScopeDeriver())));
     }
 
     @Override
@@ -48,67 +57,76 @@ public class ConfigurableVersionSelectorTest extends AbstractDependencyGraphTran
         return new DependencyGraphParser("transformer/version-resolver/");
     }
 
-    @Test
-    void testSelectHighestVersionFromMultipleVersionsAtSameLevel() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testSelectHighestVersionFromMultipleVersionsAtSameLevel(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("sibling-versions.txt");
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(1, root.getChildren().size());
         assertEquals("3", root.getChildren().get(0).getArtifact().getVersion());
     }
 
-    @Test
-    void testSelectHighestVersionFromMultipleVersionsAtSameLevelIncompatibleMajors() {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testSelectHighestVersionFromMultipleVersionsAtSameLevelIncompatibleMajors(ConflictResolver conflictResolver) {
         assertThrows(UnsolvableVersionConflictException.class, () -> {
             DependencyNode root = parseResource("sibling-major-versions.txt");
-            transform(root);
+            transform(conflictResolver, root);
         });
     }
 
-    @Test
-    void testSelectedVersionAtDeeperLevelThanOriginallySeen() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testSelectedVersionAtDeeperLevelThanOriginallySeen(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("nearest-underneath-loser-a.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         List<DependencyNode> trail = find(root, "j");
         assertEquals(5, trail.size());
     }
 
-    @Test
-    void testNearestDirtyVersionUnderneathRemovedNode() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testNearestDirtyVersionUnderneathRemovedNode(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("nearest-underneath-loser-b.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         List<DependencyNode> trail = find(root, "j");
         assertEquals(5, trail.size());
     }
 
-    @Test
-    void testViolationOfHardConstraintFallsBackToNearestSeenNotFirstSeenIncompatibleMajors() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testViolationOfHardConstraintFallsBackToNearestSeenNotFirstSeenIncompatibleMajors(
+            ConflictResolver conflictResolver) throws Exception {
         assertThrows(UnsolvableVersionConflictException.class, () -> {
             DependencyNode root = parseResource("range-major-backtracking.txt");
-            transform(root);
+            transform(conflictResolver, root);
         });
     }
 
-    @Test
-    void testViolationOfHardConstraintFallsBackToNearestSeenNotFirstSeen() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testViolationOfHardConstraintFallsBackToNearestSeenNotFirstSeen(ConflictResolver conflictResolver)
+            throws Exception {
         DependencyNode root = parseResource("range-backtracking.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         List<DependencyNode> trail = find(root, "x");
         assertEquals(3, trail.size());
         assertEquals("2", trail.get(0).getArtifact().getVersion());
     }
 
-    @Test
-    void testCyclicConflictIdGraph() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testCyclicConflictIdGraph(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("conflict-id-cycle.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
         assertEquals("a", root.getChildren().get(0).getArtifact().getArtifactId());
@@ -117,34 +135,38 @@ public class ConfigurableVersionSelectorTest extends AbstractDependencyGraphTran
         assertTrue(root.getChildren().get(1).getChildren().isEmpty());
     }
 
-    @Test
-    void testUnsolvableRangeConflictBetweenHardConstraints() {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testUnsolvableRangeConflictBetweenHardConstraints(ConflictResolver conflictResolver) {
         assertThrows(UnsolvableVersionConflictException.class, () -> {
             DependencyNode root = parseResource("unsolvable.txt");
-            transform(root);
+            transform(conflictResolver, root);
         });
     }
 
-    @Test
-    void testUnsolvableRangeConflictWithUnrelatedCycle() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testUnsolvableRangeConflictWithUnrelatedCycle(ConflictResolver conflictResolver) throws Exception {
         assertThrows(UnsolvableVersionConflictException.class, () -> {
             DependencyNode root = parseResource("unsolvable-with-cycle.txt");
-            assertSame(root, transform(root));
+            assertSame(root, transform(conflictResolver, root));
         });
     }
 
-    @Test
-    void testSolvableConflictBetweenHardConstraints() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testSolvableConflictBetweenHardConstraints(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("ranges.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
     }
 
-    @Test
-    void testConflictGroupCompletelyDroppedFromResolvedTree() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testConflictGroupCompletelyDroppedFromResolvedTree(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("dead-conflict-group.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
         assertEquals("a", root.getChildren().get(0).getArtifact().getArtifactId());
@@ -153,11 +175,12 @@ public class ConfigurableVersionSelectorTest extends AbstractDependencyGraphTran
         assertTrue(root.getChildren().get(1).getChildren().isEmpty());
     }
 
-    @Test
-    void testNearestSoftVersionPrunedByFartherRange() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testNearestSoftVersionPrunedByFartherRange(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("soft-vs-range.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
         assertEquals("a", root.getChildren().get(0).getArtifact().getArtifactId());
@@ -166,11 +189,12 @@ public class ConfigurableVersionSelectorTest extends AbstractDependencyGraphTran
         assertEquals(1, root.getChildren().get(1).getChildren().size());
     }
 
-    @Test
-    void testCyclicGraph() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testCyclicGraph(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("cycle.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
         assertEquals(1, root.getChildren().get(0).getChildren().size());
@@ -179,29 +203,33 @@ public class ConfigurableVersionSelectorTest extends AbstractDependencyGraphTran
         assertEquals(0, root.getChildren().get(1).getChildren().size());
     }
 
-    @Test
-    void testLoop() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testLoop(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("loop.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(0, root.getChildren().size());
     }
 
-    @Test
-    void testOverlappingCycles() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testOverlappingCycles(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("overlapping-cycles.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
     }
 
-    @Test
-    void testScopeDerivationAndConflictResolutionCantHappenForAllNodesBeforeVersionSelection() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testScopeDerivationAndConflictResolutionCantHappenForAllNodesBeforeVersionSelection(
+            ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("scope-vs-version.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         DependencyNode[] nodes = find(root, "y").toArray(new DependencyNode[0]);
         assertEquals(3, nodes.length);
@@ -209,12 +237,13 @@ public class ConfigurableVersionSelectorTest extends AbstractDependencyGraphTran
         assertEquals("test", nodes[0].getDependency().getScope());
     }
 
-    @Test
-    void testVerboseMode() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testVerboseMode(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("verbose.txt");
 
         session.setConfigProperty(ConflictResolver.CONFIG_PROP_VERBOSE, Boolean.TRUE);
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
         assertEquals(1, root.getChildren().get(0).getChildren().size());
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverTest.java
index 8630ce792..5ed1bd352 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverTest.java
@@ -21,43 +21,62 @@ package org.eclipse.aether.util.graph.transformer;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.stream.Stream;
 
-import org.eclipse.aether.DefaultRepositorySystemSession;
 import org.eclipse.aether.RepositoryException;
 import org.eclipse.aether.artifact.DefaultArtifact;
 import org.eclipse.aether.graph.DefaultDependencyNode;
 import org.eclipse.aether.graph.Dependency;
 import org.eclipse.aether.graph.DependencyNode;
-import org.eclipse.aether.internal.test.util.TestUtils;
+import org.eclipse.aether.internal.test.util.DependencyGraphParser;
 import org.eclipse.aether.internal.test.util.TestVersion;
 import org.eclipse.aether.internal.test.util.TestVersionConstraint;
 import org.eclipse.aether.util.graph.visitor.DependencyGraphDumper;
-import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import static org.junit.jupiter.api.Assertions.*;
 
-public class ConflictResolverTest {
-    @Test
-    void noTransformationRequired() throws RepositoryException {
-        ConflictResolver resolver = makeDefaultResolver();
+public final class ConflictResolverTest extends AbstractConflictResolverTest {
+    private static Stream<Arguments> conflictResolverSource() {
+        return Stream.of(
+                Arguments.of(new ClassicConflictResolver(
+                        new NearestVersionSelector(),
+                        new JavaScopeSelector(),
+                        new SimpleOptionalitySelector(),
+                        new JavaScopeDeriver())),
+                Arguments.of(new PathConflictResolver(
+                        new NearestVersionSelector(),
+                        new JavaScopeSelector(),
+                        new SimpleOptionalitySelector(),
+                        new JavaScopeDeriver())));
+    }
+
+    @Override
+    protected DependencyGraphParser newParser() {
+        // is unused!!!
+        return new DependencyGraphParser("transformer/conflict-resolver/");
+    }
 
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void noTransformationRequired(ConflictResolver conflictResolver) throws RepositoryException {
         // Foo -> Bar
         DependencyNode fooNode = makeDependencyNode("group-id", "foo", "1.0");
         DependencyNode barNode = makeDependencyNode("group-id", "bar", "1.0");
         fooNode.setChildren(mutableList(barNode));
 
-        DependencyNode transformedNode =
-                resolver.transformGraph(fooNode, TestUtils.newTransformationContext(TestUtils.newSession()));
+        DependencyNode transformedNode = transform(conflictResolver, fooNode);
 
         assertSame(fooNode, transformedNode);
         assertEquals(1, transformedNode.getChildren().size());
         assertSame(barNode, transformedNode.getChildren().get(0));
     }
 
-    @Test
-    void versionClash() throws RepositoryException {
-        ConflictResolver resolver = makeDefaultResolver();
-
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void versionClash(ConflictResolver conflictResolver) throws RepositoryException {
         // Foo -> Bar -> Baz 2.0
         //  |---> Baz 1.0
         DependencyNode fooNode = makeDependencyNode("some-group", "foo", "1.0");
@@ -67,8 +86,7 @@ public class ConflictResolverTest {
         fooNode.setChildren(mutableList(barNode, baz1Node));
         barNode.setChildren(mutableList(baz2Node));
 
-        DependencyNode transformedNode =
-                resolver.transformGraph(fooNode, TestUtils.newTransformationContext(TestUtils.newSession()));
+        DependencyNode transformedNode = transform(conflictResolver, fooNode);
 
         assertSame(fooNode, transformedNode);
         assertEquals(2, fooNode.getChildren().size());
@@ -77,9 +95,9 @@ public class ConflictResolverTest {
         assertSame(baz1Node, fooNode.getChildren().get(1));
     }
 
-    @Test
-    void versionClashForkedStandardVerbose() throws RepositoryException {
-
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void versionClashForkedStandardVerbose(ConflictResolver conflictResolver) throws RepositoryException {
         // root -> impl1 -> api:1
         //  |----> impl2 -> api:2
         DependencyNode root = makeDependencyNode("some-group", "root", "1.0");
@@ -92,7 +110,7 @@ public class ConflictResolverTest {
         impl1.setChildren(mutableList(api1));
         impl2.setChildren(mutableList(api2));
 
-        DependencyNode transformedNode = versionRangeClash(root, ConflictResolver.Verbosity.STANDARD);
+        DependencyNode transformedNode = versionRangeClash(conflictResolver, root, ConflictResolver.Verbosity.STANDARD);
 
         assertSame(root, transformedNode);
         assertEquals(2, root.getChildren().size());
@@ -104,8 +122,9 @@ public class ConflictResolverTest {
         assertConflictedButSameAsOriginal(api2, impl2.getChildren().get(0));
     }
 
-    @Test
-    void versionRangeClashAscOrder() throws RepositoryException {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void versionRangeClashAscOrder(ConflictResolver conflictResolver) throws RepositoryException {
         //  A -> B -> C[1..2]
         //  \--> C[1..2]
         DependencyNode a = makeDependencyNode("some-group", "a", "1.0");
@@ -115,7 +134,7 @@ public class ConflictResolverTest {
         a.setChildren(mutableList(b, c1, c2));
         b.setChildren(mutableList(c1, c2));
 
-        DependencyNode ta = versionRangeClash(a, ConflictResolver.Verbosity.NONE);
+        DependencyNode ta = versionRangeClash(conflictResolver, a, ConflictResolver.Verbosity.NONE);
 
         assertSame(a, ta);
         assertEquals(2, a.getChildren().size());
@@ -124,8 +143,9 @@ public class ConflictResolverTest {
         assertEquals(0, b.getChildren().size());
     }
 
-    @Test
-    void versionRangeClashAscOrderStandardVerbose() throws RepositoryException {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void versionRangeClashAscOrderStandardVerbose(ConflictResolver conflictResolver) throws RepositoryException {
         //  A -> B -> C[1..2]
         //  \--> C[1..2]
         DependencyNode a = makeDependencyNode("some-group", "a", "1.0");
@@ -135,7 +155,7 @@ public class ConflictResolverTest {
         a.setChildren(mutableList(b, c1, c2));
         b.setChildren(mutableList(c1, c2));
 
-        DependencyNode ta = versionRangeClash(a, ConflictResolver.Verbosity.STANDARD);
+        DependencyNode ta = versionRangeClash(conflictResolver, a, ConflictResolver.Verbosity.STANDARD);
 
         assertSame(a, ta);
         assertEquals(2, a.getChildren().size());
@@ -145,8 +165,9 @@ public class ConflictResolverTest {
         assertConflictedButSameAsOriginal(c2, b.getChildren().get(0));
     }
 
-    @Test
-    void versionRangeClashAscOrderFullVerbose() throws RepositoryException {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void versionRangeClashAscOrderFullVerbose(ConflictResolver conflictResolver) throws RepositoryException {
         //  A -> B -> C[1..2]
         //  \--> C[1..2]
         DependencyNode a = makeDependencyNode("some-group", "a", "1.0");
@@ -156,7 +177,7 @@ public class ConflictResolverTest {
         a.setChildren(mutableList(b, c1, c2));
         b.setChildren(mutableList(c1, c2));
 
-        DependencyNode ta = versionRangeClash(a, ConflictResolver.Verbosity.FULL);
+        DependencyNode ta = versionRangeClash(conflictResolver, a, ConflictResolver.Verbosity.FULL);
 
         assertSame(a, ta);
         assertEquals(3, a.getChildren().size());
@@ -168,8 +189,9 @@ public class ConflictResolverTest {
         assertConflictedButSameAsOriginal(c2, b.getChildren().get(1));
     }
 
-    @Test
-    void versionRangeClashDescOrder() throws RepositoryException {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void versionRangeClashDescOrder(ConflictResolver conflictResolver) throws RepositoryException {
         //  A -> B -> C[1..2]
         //  \--> C[1..2]
         DependencyNode a = makeDependencyNode("some-group", "a", "1.0");
@@ -179,7 +201,7 @@ public class ConflictResolverTest {
         a.setChildren(mutableList(b, c2, c1));
         b.setChildren(mutableList(c2, c1));
 
-        DependencyNode ta = versionRangeClash(a, ConflictResolver.Verbosity.NONE);
+        DependencyNode ta = versionRangeClash(conflictResolver, a, ConflictResolver.Verbosity.NONE);
 
         assertSame(a, ta);
         assertEquals(2, a.getChildren().size());
@@ -188,8 +210,9 @@ public class ConflictResolverTest {
         assertEquals(0, b.getChildren().size());
     }
 
-    @Test
-    void versionRangeClashDescOrderStandardVerbose() throws RepositoryException {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void versionRangeClashDescOrderStandardVerbose(ConflictResolver conflictResolver) throws RepositoryException {
         //  A -> B -> C[1..2]
         //  \--> C[1..2]
         DependencyNode a = makeDependencyNode("some-group", "a", "1.0");
@@ -199,7 +222,7 @@ public class ConflictResolverTest {
         a.setChildren(mutableList(b, c2, c1));
         b.setChildren(mutableList(c2, c1));
 
-        DependencyNode ta = versionRangeClash(a, ConflictResolver.Verbosity.STANDARD);
+        DependencyNode ta = versionRangeClash(conflictResolver, a, ConflictResolver.Verbosity.STANDARD);
 
         assertSame(a, ta);
         assertEquals(2, a.getChildren().size());
@@ -209,8 +232,9 @@ public class ConflictResolverTest {
         assertConflictedButSameAsOriginal(c2, b.getChildren().get(0));
     }
 
-    @Test
-    void versionRangeClashDescOrderFullVerbose() throws RepositoryException {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void versionRangeClashDescOrderFullVerbose(ConflictResolver conflictResolver) throws RepositoryException {
         //  A -> B -> C[1..2]
         //  \--> C[1..2]
         DependencyNode a = makeDependencyNode("some-group", "a", "1.0");
@@ -220,7 +244,7 @@ public class ConflictResolverTest {
         a.setChildren(mutableList(b, c2, c1));
         b.setChildren(mutableList(c2, c1));
 
-        DependencyNode ta = versionRangeClash(a, ConflictResolver.Verbosity.FULL);
+        DependencyNode ta = versionRangeClash(conflictResolver, a, ConflictResolver.Verbosity.FULL);
 
         assertSame(a, ta);
         assertEquals(3, a.getChildren().size());
@@ -232,8 +256,9 @@ public class ConflictResolverTest {
         assertConflictedButSameAsOriginal(c1, b.getChildren().get(1));
     }
 
-    @Test
-    void versionRangeClashMixedOrder() throws RepositoryException {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void versionRangeClashMixedOrder(ConflictResolver conflictResolver) throws RepositoryException {
         //  A -> B -> C[1..2]
         //  \--> C[1..2]
         DependencyNode a = makeDependencyNode("some-group", "a", "1.0");
@@ -243,7 +268,7 @@ public class ConflictResolverTest {
         a.setChildren(mutableList(b, c2, c1));
         b.setChildren(mutableList(c1, c2));
 
-        DependencyNode ta = versionRangeClash(a, ConflictResolver.Verbosity.NONE);
+        DependencyNode ta = versionRangeClash(conflictResolver, a, ConflictResolver.Verbosity.NONE);
 
         assertSame(a, ta);
         assertEquals(2, a.getChildren().size());
@@ -252,8 +277,9 @@ public class ConflictResolverTest {
         assertEquals(0, b.getChildren().size());
     }
 
-    @Test
-    void versionRangeClashMixedOrderStandardVerbose() throws RepositoryException {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void versionRangeClashMixedOrderStandardVerbose(ConflictResolver conflictResolver) throws RepositoryException {
         //  A -> B -> C[1..2]
         //  \--> C[1..2]
         DependencyNode a = makeDependencyNode("some-group", "a", "1.0");
@@ -263,7 +289,7 @@ public class ConflictResolverTest {
         a.setChildren(mutableList(b, c2, c1));
         b.setChildren(mutableList(c1, c2));
 
-        DependencyNode ta = versionRangeClash(a, ConflictResolver.Verbosity.STANDARD);
+        DependencyNode ta = versionRangeClash(conflictResolver, a, ConflictResolver.Verbosity.STANDARD);
 
         assertSame(a, ta);
         assertEquals(2, a.getChildren().size());
@@ -273,8 +299,10 @@ public class ConflictResolverTest {
         assertConflictedButSameAsOriginal(c2, b.getChildren().get(0));
     }
 
-    @Test
-    void versionRangeClashMixedOrderStandardVerboseLeavesOne() throws RepositoryException {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void versionRangeClashMixedOrderStandardVerboseLeavesOne(ConflictResolver conflictResolver)
+            throws RepositoryException {
         // This is a bit different then others, is related to MRESOLVER-357 and makes sure that
         // ConflictResolver fulfils the promise of "leaving 1 loser"
         //
@@ -291,7 +319,7 @@ public class ConflictResolverTest {
         a.setChildren(mutableList(b, c2, c1, d2));
         b.setChildren(mutableList(c1, c2, d1));
 
-        DependencyNode ta = versionRangeClash(a, ConflictResolver.Verbosity.STANDARD);
+        DependencyNode ta = versionRangeClash(conflictResolver, a, ConflictResolver.Verbosity.STANDARD);
 
         assertSame(a, ta);
         assertEquals(3, a.getChildren().size());
@@ -303,8 +331,9 @@ public class ConflictResolverTest {
         assertConflictedButSameAsOriginal(d1, b.getChildren().get(1));
     }
 
-    @Test
-    void versionRangeClashMixedOrderFullVerbose() throws RepositoryException {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void versionRangeClashMixedOrderFullVerbose(ConflictResolver conflictResolver) throws RepositoryException {
         //  A -> B -> C[1..2]
         //  \--> C[1..2]
         DependencyNode a = makeDependencyNode("some-group", "a", "1.0");
@@ -314,7 +343,7 @@ public class ConflictResolverTest {
         a.setChildren(mutableList(b, c2, c1));
         b.setChildren(mutableList(c1, c2));
 
-        DependencyNode ta = versionRangeClash(a, ConflictResolver.Verbosity.FULL);
+        DependencyNode ta = versionRangeClash(conflictResolver, a, ConflictResolver.Verbosity.FULL);
 
         assertSame(a, ta);
         assertEquals(3, a.getChildren().size());
@@ -348,17 +377,15 @@ public class ConflictResolverTest {
     /**
      * Performs a verbose conflict resolution on passed in root.
      */
-    private DependencyNode versionRangeClash(DependencyNode root, ConflictResolver.Verbosity verbosity)
+    private DependencyNode versionRangeClash(
+            ConflictResolver conflictResolver, DependencyNode root, ConflictResolver.Verbosity verbosity)
             throws RepositoryException {
-        ConflictResolver resolver = makeDefaultResolver();
-
         System.out.println();
         System.out.println("Input node:");
         root.accept(DUMPER_SOUT);
 
-        DefaultRepositorySystemSession session = TestUtils.newSession();
-        session.setConfigProperty(ConflictResolver.CONFIG_PROP_VERBOSE, verbosity);
-        DependencyNode transformedRoot = resolver.transformGraph(root, TestUtils.newTransformationContext(session));
+        setVerbosity(verbosity);
+        DependencyNode transformedRoot = transform(conflictResolver, root);
 
         System.out.println();
         System.out.println("Transformed node:");
@@ -367,10 +394,9 @@ public class ConflictResolverTest {
         return transformedRoot;
     }
 
-    @Test
-    void derivedScopeChange() throws RepositoryException {
-        ConflictResolver resolver = makeDefaultResolver();
-
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void derivedScopeChange(ConflictResolver conflictResolver) throws RepositoryException {
         // Foo -> Bar (test) -> Jaz
         //  |---> Baz -> Jaz
         DependencyNode fooNode = makeDependencyNode("some-group", "foo", "1.0");
@@ -383,8 +409,7 @@ public class ConflictResolverTest {
         barNode.setChildren(jazList);
         bazNode.setChildren(jazList);
 
-        DependencyNode transformedNode =
-                resolver.transformGraph(fooNode, TestUtils.newTransformationContext(TestUtils.newSession()));
+        DependencyNode transformedNode = transform(conflictResolver, fooNode);
 
         assertSame(fooNode, transformedNode);
         assertEquals(2, fooNode.getChildren().size());
@@ -396,10 +421,9 @@ public class ConflictResolverTest {
         assertSame(jazNode, barNode.getChildren().get(0));
     }
 
-    @Test
-    void derivedOptionalStatusChange() throws RepositoryException {
-        ConflictResolver resolver = makeDefaultResolver();
-
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void derivedOptionalStatusChange(ConflictResolver conflictResolver) throws RepositoryException {
         // Foo -> Bar (optional) -> Jaz
         //  |---> Baz -> Jaz
         DependencyNode fooNode = makeDependencyNode("some-group", "foo", "1.0");
@@ -413,8 +437,7 @@ public class ConflictResolverTest {
         barNode.setChildren(jazList);
         bazNode.setChildren(jazList);
 
-        DependencyNode transformedNode =
-                resolver.transformGraph(fooNode, TestUtils.newTransformationContext(TestUtils.newSession()));
+        DependencyNode transformedNode = transform(conflictResolver, fooNode);
 
         assertSame(fooNode, transformedNode);
         assertEquals(2, fooNode.getChildren().size());
@@ -426,14 +449,6 @@ public class ConflictResolverTest {
         assertSame(jazNode, barNode.getChildren().get(0));
     }
 
-    private static ConflictResolver makeDefaultResolver() {
-        return new ConflictResolver(
-                new NearestVersionSelector(),
-                new JavaScopeSelector(),
-                new SimpleOptionalitySelector(),
-                new JavaScopeDeriver());
-    }
-
     private static DependencyNode makeDependencyNode(String groupId, String artifactId, String version) {
         return makeDependencyNode(groupId, artifactId, version, "compile");
     }
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/JavaScopeSelectorTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/JavaScopeSelectorTest.java
index 2ad09b22d..5f6e8cb9d 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/JavaScopeSelectorTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/JavaScopeSelectorTest.java
@@ -19,15 +19,30 @@
 package org.eclipse.aether.util.graph.transformer;
 
 import java.util.Locale;
+import java.util.stream.Stream;
 
-import org.eclipse.aether.collection.DependencyGraphTransformer;
 import org.eclipse.aether.graph.DependencyNode;
 import org.eclipse.aether.internal.test.util.DependencyGraphParser;
-import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import static org.junit.jupiter.api.Assertions.*;
 
-public class JavaScopeSelectorTest extends AbstractDependencyGraphTransformerTest {
+public final class JavaScopeSelectorTest extends AbstractConflictResolverTest {
+    private static Stream<Arguments> conflictResolverSource() {
+        return Stream.of(
+                Arguments.of(new ClassicConflictResolver(
+                        new NearestVersionSelector(),
+                        new JavaScopeSelector(),
+                        new SimpleOptionalitySelector(),
+                        new JavaScopeDeriver())),
+                Arguments.of(new PathConflictResolver(
+                        new NearestVersionSelector(),
+                        new JavaScopeSelector(),
+                        new SimpleOptionalitySelector(),
+                        new JavaScopeDeriver())));
+    }
 
     private enum Scope {
         TEST,
@@ -41,13 +56,6 @@ public class JavaScopeSelectorTest extends AbstractDependencyGraphTransformerTes
         }
     }
 
-    @Override
-    protected DependencyGraphTransformer newTransformer() {
-        return new ConflictResolver(
-                new NearestVersionSelector(), new JavaScopeSelector(),
-                new SimpleOptionalitySelector(), new JavaScopeDeriver());
-    }
-
     @Override
     protected DependencyGraphParser newParser() {
         return new DependencyGraphParser("transformer/scope-calculator/");
@@ -76,55 +84,61 @@ public class JavaScopeSelectorTest extends AbstractDependencyGraphTransformerTes
         return node;
     }
 
-    @Test
-    void testScopeInheritanceProvided() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testScopeInheritanceProvided(ConflictResolver conflictResolver) throws Exception {
         String resource = "inheritance.txt";
 
         String expected = "test";
-        DependencyNode root = transform(parseResource(resource, "provided", "test"));
+        DependencyNode root = transform(conflictResolver, parseResource(resource, "provided", "test"));
         expectScope(parser.dump(root), expected, root, 0, 0);
     }
 
-    @Test
-    void testConflictWinningScopeGetsUsedForInheritance() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testConflictWinningScopeGetsUsedForInheritance(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("conflict-and-inheritance.txt");
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         expectScope("compile", root, 0, 0);
         expectScope("compile", root, 0, 0, 0);
     }
 
-    @Test
-    void testScopeOfDirectDependencyWinsConflictAndGetsUsedForInheritanceToChildrenEverywhereInGraph()
-            throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testScopeOfDirectDependencyWinsConflictAndGetsUsedForInheritanceToChildrenEverywhereInGraph(
+            ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("direct-with-conflict-and-inheritance.txt");
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         expectScope("test", root, 0, 0);
     }
 
-    @Test
-    void testCycleA() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testCycleA(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("cycle-a.txt");
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         expectScope("compile", root, 0);
         expectScope("runtime", root, 1);
     }
 
-    @Test
-    void testCycleB() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testCycleB(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("cycle-b.txt");
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         expectScope("runtime", root, 0);
         expectScope("compile", root, 1);
     }
 
-    @Test
-    void testCycleC() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testCycleC(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("cycle-c.txt");
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         expectScope("runtime", root, 0);
         expectScope("runtime", root, 0, 0);
@@ -132,17 +146,19 @@ public class JavaScopeSelectorTest extends AbstractDependencyGraphTransformerTes
         expectScope("runtime", root, 1, 0);
     }
 
-    @Test
-    void testCycleD() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testCycleD(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("cycle-d.txt");
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         expectScope("compile", root, 0);
         expectScope("compile", root, 0, 0);
     }
 
-    @Test
-    void testDirectNodesAlwaysWin() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testDirectNodesAlwaysWin(ConflictResolver conflictResolver) throws Exception {
 
         for (Scope directScope : Scope.values()) {
             String direct = directScope.toString();
@@ -151,15 +167,16 @@ public class JavaScopeSelectorTest extends AbstractDependencyGraphTransformerTes
 
             String msg = String.format(
                     "direct node should be setting scope ('%s') for all nodes.\n" + parser.dump(root), direct);
-            assertSame(root, transform(root));
+            assertSame(root, transform(conflictResolver, root));
             msg += "\ntransformed:\n" + parser.dump(root);
 
             expectScope(msg, direct, root, 0);
         }
     }
 
-    @Test
-    void testNonDirectMultipleInheritance() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testNonDirectMultipleInheritance(ConflictResolver conflictResolver) throws Exception {
         for (Scope scope1 : Scope.values()) {
             for (Scope scope2 : Scope.values()) {
                 DependencyNode root = parseResource("multiple-inheritance.txt", scope1.toString(), scope2.toString());
@@ -167,7 +184,7 @@ public class JavaScopeSelectorTest extends AbstractDependencyGraphTransformerTes
                 String expected = scope1.compareTo(scope2) >= 0 ? scope1.toString() : scope2.toString();
                 String msg = String.format("expected '%s' to win\n" + parser.dump(root), expected);
 
-                assertSame(root, transform(root));
+                assertSame(root, transform(conflictResolver, root));
                 msg += "\ntransformed:\n" + parser.dump(root);
 
                 expectScope(msg, expected, root, 0, 0);
@@ -175,8 +192,9 @@ public class JavaScopeSelectorTest extends AbstractDependencyGraphTransformerTes
         }
     }
 
-    @Test
-    void testConflictScopeOrdering() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testConflictScopeOrdering(ConflictResolver conflictResolver) throws Exception {
         for (Scope scope1 : Scope.values()) {
             for (Scope scope2 : Scope.values()) {
                 DependencyNode root = parseResource("dueling-scopes.txt", scope1.toString(), scope2.toString());
@@ -184,7 +202,7 @@ public class JavaScopeSelectorTest extends AbstractDependencyGraphTransformerTes
                 String expected = scope1.compareTo(scope2) >= 0 ? scope1.toString() : scope2.toString();
                 String msg = String.format("expected '%s' to win\n" + parser.dump(root), expected);
 
-                assertSame(root, transform(root));
+                assertSame(root, transform(conflictResolver, root));
                 msg += "\ntransformed:\n" + parser.dump(root);
 
                 expectScope(msg, expected, root, 0, 0);
@@ -195,8 +213,9 @@ public class JavaScopeSelectorTest extends AbstractDependencyGraphTransformerTes
     /**
      * obscure case (illegal maven POM).
      */
-    @Test
-    void testConflictingDirectNodes() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testConflictingDirectNodes(ConflictResolver conflictResolver) throws Exception {
         for (Scope scope1 : Scope.values()) {
             for (Scope scope2 : Scope.values()) {
                 DependencyNode root =
@@ -205,7 +224,7 @@ public class JavaScopeSelectorTest extends AbstractDependencyGraphTransformerTes
                 String expected = scope1.toString();
                 String msg = String.format("expected '%s' to win\n" + parser.dump(root), expected);
 
-                assertSame(root, transform(root));
+                assertSame(root, transform(conflictResolver, root));
                 msg += "\ntransformed:\n" + parser.dump(root);
 
                 expectScope(msg, expected, root, 0);
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/NearestVersionSelectorTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/NearestVersionSelectorTest.java
index 7a4b4972c..c75f5eeba 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/NearestVersionSelectorTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/NearestVersionSelectorTest.java
@@ -19,23 +19,32 @@
 package org.eclipse.aether.util.graph.transformer;
 
 import java.util.List;
+import java.util.stream.Stream;
 
 import org.eclipse.aether.collection.UnsolvableVersionConflictException;
 import org.eclipse.aether.graph.DependencyNode;
 import org.eclipse.aether.internal.test.util.DependencyGraphParser;
-import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import static org.junit.jupiter.api.Assertions.*;
 
 /**
  */
-public class NearestVersionSelectorTest extends AbstractDependencyGraphTransformerTest {
-
-    @Override
-    protected ConflictResolver newTransformer() {
-        return new ConflictResolver(
-                new NearestVersionSelector(), new JavaScopeSelector(),
-                new SimpleOptionalitySelector(), new JavaScopeDeriver());
+public final class NearestVersionSelectorTest extends AbstractConflictResolverTest {
+    private static Stream<Arguments> conflictResolverSource() {
+        return Stream.of(
+                Arguments.of(new ClassicConflictResolver(
+                        new NearestVersionSelector(),
+                        new JavaScopeSelector(),
+                        new SimpleOptionalitySelector(),
+                        new JavaScopeDeriver())),
+                Arguments.of(new PathConflictResolver(
+                        new NearestVersionSelector(),
+                        new JavaScopeSelector(),
+                        new SimpleOptionalitySelector(),
+                        new JavaScopeDeriver())));
     }
 
     @Override
@@ -43,51 +52,57 @@ public class NearestVersionSelectorTest extends AbstractDependencyGraphTransform
         return new DependencyGraphParser("transformer/version-resolver/");
     }
 
-    @Test
-    void testSelectHighestVersionFromMultipleVersionsAtSameLevel() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testSelectHighestVersionFromMultipleVersionsAtSameLevel(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("sibling-versions.txt");
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(1, root.getChildren().size());
         assertEquals("3", root.getChildren().get(0).getArtifact().getVersion());
     }
 
-    @Test
-    void testSelectedVersionAtDeeperLevelThanOriginallySeen() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testSelectedVersionAtDeeperLevelThanOriginallySeen(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("nearest-underneath-loser-a.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         List<DependencyNode> trail = find(root, "j");
         assertEquals(5, trail.size());
     }
 
-    @Test
-    void testNearestDirtyVersionUnderneathRemovedNode() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testNearestDirtyVersionUnderneathRemovedNode(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("nearest-underneath-loser-b.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         List<DependencyNode> trail = find(root, "j");
         assertEquals(5, trail.size());
     }
 
-    @Test
-    void testViolationOfHardConstraintFallsBackToNearestSeenNotFirstSeen() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testViolationOfHardConstraintFallsBackToNearestSeenNotFirstSeen(ConflictResolver conflictResolver)
+            throws Exception {
         DependencyNode root = parseResource("range-backtracking.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         List<DependencyNode> trail = find(root, "x");
         assertEquals(3, trail.size());
         assertEquals("2", trail.get(0).getArtifact().getVersion());
     }
 
-    @Test
-    void testCyclicConflictIdGraph() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testCyclicConflictIdGraph(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("conflict-id-cycle.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
         assertEquals("a", root.getChildren().get(0).getArtifact().getArtifactId());
@@ -96,34 +111,38 @@ public class NearestVersionSelectorTest extends AbstractDependencyGraphTransform
         assertTrue(root.getChildren().get(1).getChildren().isEmpty());
     }
 
-    @Test
-    void testUnsolvableRangeConflictBetweenHardConstraints() {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testUnsolvableRangeConflictBetweenHardConstraints(ConflictResolver conflictResolver) {
         assertThrows(UnsolvableVersionConflictException.class, () -> {
             DependencyNode root = parseResource("unsolvable.txt");
-            transform(root);
+            transform(conflictResolver, root);
         });
     }
 
-    @Test
-    void testUnsolvableRangeConflictWithUnrelatedCycle() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testUnsolvableRangeConflictWithUnrelatedCycle(ConflictResolver conflictResolver) throws Exception {
         assertThrows(UnsolvableVersionConflictException.class, () -> {
             DependencyNode root = parseResource("unsolvable-with-cycle.txt");
-            assertSame(root, transform(root));
+            assertSame(root, transform(conflictResolver, root));
         });
     }
 
-    @Test
-    void testSolvableConflictBetweenHardConstraints() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testSolvableConflictBetweenHardConstraints(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("ranges.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
     }
 
-    @Test
-    void testConflictGroupCompletelyDroppedFromResolvedTree() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testConflictGroupCompletelyDroppedFromResolvedTree(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("dead-conflict-group.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
         assertEquals("a", root.getChildren().get(0).getArtifact().getArtifactId());
@@ -132,11 +151,12 @@ public class NearestVersionSelectorTest extends AbstractDependencyGraphTransform
         assertTrue(root.getChildren().get(1).getChildren().isEmpty());
     }
 
-    @Test
-    void testNearestSoftVersionPrunedByFartherRange() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testNearestSoftVersionPrunedByFartherRange(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("soft-vs-range.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
         assertEquals("a", root.getChildren().get(0).getArtifact().getArtifactId());
@@ -145,11 +165,12 @@ public class NearestVersionSelectorTest extends AbstractDependencyGraphTransform
         assertEquals(1, root.getChildren().get(1).getChildren().size());
     }
 
-    @Test
-    void testCyclicGraph() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testCyclicGraph(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("cycle.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
         assertEquals(1, root.getChildren().get(0).getChildren().size());
@@ -158,29 +179,33 @@ public class NearestVersionSelectorTest extends AbstractDependencyGraphTransform
         assertEquals(0, root.getChildren().get(1).getChildren().size());
     }
 
-    @Test
-    void testLoop() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testLoop(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("loop.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(0, root.getChildren().size());
     }
 
-    @Test
-    void testOverlappingCycles() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testOverlappingCycles(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("overlapping-cycles.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
     }
 
-    @Test
-    void testScopeDerivationAndConflictResolutionCantHappenForAllNodesBeforeVersionSelection() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testScopeDerivationAndConflictResolutionCantHappenForAllNodesBeforeVersionSelection(
+            ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("scope-vs-version.txt");
 
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         DependencyNode[] nodes = find(root, "y").toArray(new DependencyNode[0]);
         assertEquals(3, nodes.length);
@@ -188,12 +213,24 @@ public class NearestVersionSelectorTest extends AbstractDependencyGraphTransform
         assertEquals("test", nodes[0].getDependency().getScope());
     }
 
-    @Test
-    void testVerboseMode() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testExpectedSubtreeOnDescriptorDependenciesEmptyLeft(ConflictResolver conflictResolver) throws Exception {
+        DependencyNode root = parseResource("expectedSubtreeOnDescriptorDependenciesEmptyLeft.txt");
+
+        assertSame(root, transform(conflictResolver, root));
+
+        // h is not lost
+        assertEquals(5, find(root, "h").size());
+    }
+
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testVerboseMode(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("verbose.txt");
 
         session.setConfigProperty(ConflictResolver.CONFIG_PROP_VERBOSE, Boolean.TRUE);
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
         assertEquals(1, root.getChildren().get(0).getChildren().size());
diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/SimpleOptionalitySelectorTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/SimpleOptionalitySelectorTest.java
index 8630e3b93..e2149702b 100644
--- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/SimpleOptionalitySelectorTest.java
+++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/SimpleOptionalitySelectorTest.java
@@ -18,20 +18,29 @@
  */
 package org.eclipse.aether.util.graph.transformer;
 
-import org.eclipse.aether.collection.DependencyGraphTransformer;
+import java.util.stream.Stream;
+
 import org.eclipse.aether.graph.DependencyNode;
 import org.eclipse.aether.internal.test.util.DependencyGraphParser;
-import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import static org.junit.jupiter.api.Assertions.*;
 
-public class SimpleOptionalitySelectorTest extends AbstractDependencyGraphTransformerTest {
-
-    @Override
-    protected DependencyGraphTransformer newTransformer() {
-        return new ConflictResolver(
-                new NearestVersionSelector(), new JavaScopeSelector(),
-                new SimpleOptionalitySelector(), new JavaScopeDeriver());
+public final class SimpleOptionalitySelectorTest extends AbstractConflictResolverTest {
+    private static Stream<Arguments> conflictResolverSource() {
+        return Stream.of(
+                Arguments.of(new ClassicConflictResolver(
+                        new NearestVersionSelector(),
+                        new JavaScopeSelector(),
+                        new SimpleOptionalitySelector(),
+                        new JavaScopeDeriver())),
+                Arguments.of(new PathConflictResolver(
+                        new NearestVersionSelector(),
+                        new JavaScopeSelector(),
+                        new SimpleOptionalitySelector(),
+                        new JavaScopeDeriver())));
     }
 
     @Override
@@ -39,10 +48,11 @@ public class SimpleOptionalitySelectorTest extends AbstractDependencyGraphTransf
         return new DependencyGraphParser("transformer/optionality-selector/");
     }
 
-    @Test
-    void testDeriveOptionality() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testDeriveOptionality(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("derive.txt");
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
         assertTrue(root.getChildren().get(0).getDependency().isOptional());
@@ -53,10 +63,11 @@ public class SimpleOptionalitySelectorTest extends AbstractDependencyGraphTransf
                 root.getChildren().get(1).getChildren().get(0).getDependency().isOptional());
     }
 
-    @Test
-    void testResolveOptionalityConflict_NonOptionalWins() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testResolveOptionalityConflict_NonOptionalWins(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("conflict.txt");
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
         assertTrue(root.getChildren().get(0).getDependency().isOptional());
@@ -64,10 +75,11 @@ public class SimpleOptionalitySelectorTest extends AbstractDependencyGraphTransf
                 root.getChildren().get(0).getChildren().get(0).getDependency().isOptional());
     }
 
-    @Test
-    void testResolveOptionalityConflict_DirectDeclarationWins() throws Exception {
+    @ParameterizedTest
+    @MethodSource("conflictResolverSource")
+    void testResolveOptionalityConflict_DirectDeclarationWins(ConflictResolver conflictResolver) throws Exception {
         DependencyNode root = parseResource("conflict-direct-dep.txt");
-        assertSame(root, transform(root));
+        assertSame(root, transform(conflictResolver, root));
 
         assertEquals(2, root.getChildren().size());
         assertTrue(root.getChildren().get(1).getDependency().isOptional());
diff --git a/maven-resolver-util/src/test/resources/transformer/version-resolver/expectedSubtreeOnDescriptorDependenciesEmptyLeft.txt b/maven-resolver-util/src/test/resources/transformer/version-resolver/expectedSubtreeOnDescriptorDependenciesEmptyLeft.txt
new file mode 100644
index 000000000..f9fecd8b8
--- /dev/null
+++ b/maven-resolver-util/src/test/resources/transformer/version-resolver/expectedSubtreeOnDescriptorDependenciesEmptyLeft.txt
@@ -0,0 +1,11 @@
+gid:a:jar:ver
++- gid:b:jar:ver compile
+|  \- gid:d:jar:1 compile
++- gid:c:jar:ver compile
+|  \- gid:d:jar:2 compile
+|     \- gid:g:jar:1 compile
+|        \- gid:h:jar:1 compile
+\- gid:e:jar:ver compile
+|  +- gid:f:jar:ver compile
+|  |  +- gid:g:jar:2 compile
+|  |  |  \- gid:h:jar:2 compile
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 7c235659a..891f9a526 100644
--- a/pom.xml
+++ b/pom.xml
@@ -411,6 +411,8 @@
                     <!-- MRESOLVER-440 Move properties to their place and cleanup -->
                     <exclude>org.eclipse.aether.ConfigurationProperties#PERSISTED_CHECKSUMS</exclude>
                     <exclude>org.eclipse.aether.ConfigurationProperties#DEFAULT_PERSISTED_CHECKSUMS</exclude>
+                    <!-- ConflictResolver (these API classes should not be created by clients; ctor changes) -->
+                    <exclude>org.eclipse.aether.util.graph.transformer.ConflictResolver</exclude>
                   </excludes>
                   <ignoreMissingClasses>true</ignoreMissingClasses>
                   <breakBuildOnBinaryIncompatibleModifications>false</breakBuildOnBinaryIncompatibleModifications>
@@ -455,6 +457,8 @@
                     <!-- MRESOLVER-440 Move properties to their place and cleanup -->
                     <exclude>org.eclipse.aether.ConfigurationProperties#PERSISTED_CHECKSUMS</exclude>
                     <exclude>org.eclipse.aether.ConfigurationProperties#DEFAULT_PERSISTED_CHECKSUMS</exclude>
+                    <!-- ConflictResolver (these API classes should not be created by clients; ctor changes) -->
+                    <exclude>org.eclipse.aether.util.graph.transformer.ConflictResolver</exclude>
                   </excludes>
                   <ignoreMissingClasses>true</ignoreMissingClasses>
                   <breakBuildOnBinaryIncompatibleModifications>true</breakBuildOnBinaryIncompatibleModifications>