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 cd7e88a1 [MRESOLVER-518] Version selector improvements (#450)
cd7e88a1 is described below

commit cd7e88a13ec8f7c87ac87ae85243d972b322c9cd
Author: Tamas Cservenak <ta...@cservenak.net>
AuthorDate: Fri Apr 12 10:07:34 2024 +0200

    [MRESOLVER-518] Version selector improvements (#450)
    
    Version selector improvements:
    * introduce strategy how to select "winner" (so far it was cemented 
"nearest") and optionally perform some enforcements
    * supplier retains same behaviour as before
    
    Big fat note: this is NOT to alter Maven4 behaviour, but to make Resolver 2 
more versatile for example for "diagnostic purposes" (while Maven may still 
benefit from these changes as well, by having some "strict version strategy" 
mode for example).
    
    ---
    
    https://issues.apache.org/jira/browse/MRESOLVER-518
---
 .../UnsolvableVersionConflictException.java        |  20 +-
 .../GetDependencyHierarchyWithConflicts.java       | 143 ++++++++++
 .../internal/impl/scope/ScopeManagerImpl.java      |   4 +-
 .../aether/supplier/SessionBuilderSupplier.java    |   5 +-
 .../transformer/ConfigurableVersionSelector.java   | 316 +++++++++++++++++++++
 .../graph/transformer/NearestVersionSelector.java  |   3 +
 .../ConfigurableVersionSelectorTest.java           | 233 +++++++++++++++
 .../version-resolver/range-major-backtracking.txt  |  10 +
 .../version-resolver/sibling-major-versions.txt    |   7 +
 9 files changed, 736 insertions(+), 5 deletions(-)

diff --git 
a/maven-resolver-api/src/main/java/org/eclipse/aether/collection/UnsolvableVersionConflictException.java
 
b/maven-resolver-api/src/main/java/org/eclipse/aether/collection/UnsolvableVersionConflictException.java
index 445f9339..10d26a25 100644
--- 
a/maven-resolver-api/src/main/java/org/eclipse/aether/collection/UnsolvableVersionConflictException.java
+++ 
b/maven-resolver-api/src/main/java/org/eclipse/aether/collection/UnsolvableVersionConflictException.java
@@ -29,6 +29,8 @@ import org.eclipse.aether.artifact.Artifact;
 import org.eclipse.aether.graph.DependencyNode;
 import org.eclipse.aether.version.VersionConstraint;
 
+import static java.util.Objects.requireNonNull;
+
 /**
  * Thrown in case of an unsolvable conflict between different version 
constraints for a dependency.
  */
@@ -42,9 +44,25 @@ public class UnsolvableVersionConflictException extends 
RepositoryException {
      * Creates a new exception with the specified paths to conflicting nodes 
in the dependency graph.
      *
      * @param paths The paths to the dependency nodes that participate in the 
version conflict, may be {@code null}.
+     * @deprecated Use {@link #UnsolvableVersionConflictException(String, 
Collection)} instead.
      */
+    @Deprecated
     public UnsolvableVersionConflictException(Collection<? extends List<? 
extends DependencyNode>> paths) {
-        super("Could not resolve version conflict among " + toPaths(paths));
+        this("Unsolvable hard constraint combination", paths);
+    }
+
+    /**
+     * Creates a new exception with the specified paths to conflicting nodes 
in the dependency graph.
+     *
+     * @param message The strategy that throw the bucket in, must not be 
{@code null}. Should provide concise message
+     *                why this exception was thrown.
+     * @param paths The paths to the dependency nodes that participate in the 
version conflict, may be {@code null}.
+     *
+     * @since 2.0.0
+     */
+    public UnsolvableVersionConflictException(
+            String message, Collection<? extends List<? extends 
DependencyNode>> paths) {
+        super(requireNonNull(message, "message") + "; Could not resolve 
version conflict among " + toPaths(paths));
         if (paths == null) {
             this.paths = Collections.emptyList();
             this.versions = Collections.emptyList();
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
new file mode 100644
index 00000000..aacf00bb
--- /dev/null
+++ 
b/maven-resolver-demos/maven-resolver-demo-snippets/src/main/java/org/apache/maven/resolver/examples/GetDependencyHierarchyWithConflicts.java
@@ -0,0 +1,143 @@
+/*
+ * 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.apache.maven.resolver.examples;
+
+import org.apache.maven.resolver.examples.util.Booter;
+import org.eclipse.aether.RepositorySystem;
+import org.eclipse.aether.RepositorySystemSession.CloseableSession;
+import org.eclipse.aether.RepositorySystemSession.SessionBuilder;
+import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.aether.artifact.DefaultArtifact;
+import org.eclipse.aether.collection.CollectRequest;
+import org.eclipse.aether.collection.UnsolvableVersionConflictException;
+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.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.SimpleOptionalitySelector;
+
+/**
+ * Visualizes the transitive dependencies of an artifact similar to m2e's 
dependency hierarchy view.
+ */
+public class GetDependencyHierarchyWithConflicts {
+
+    /**
+     * Main.
+     * @param args
+     * @throws Exception
+     */
+    public static void main(String[] args) throws Exception {
+        
System.out.println("------------------------------------------------------------");
+        
System.out.println(GetDependencyHierarchyWithConflicts.class.getSimpleName());
+
+        // incompatible versions: two incompatible versions present in graph
+        try (RepositorySystem system = 
Booter.newRepositorySystem(Booter.selectFactory(args))) {
+            SessionBuilder sessionBuilder = 
Booter.newRepositorySystemSession(system);
+            
sessionBuilder.setConfigProperty(ConflictResolver.CONFIG_PROP_VERBOSE, true);
+            
sessionBuilder.setConfigProperty(DependencyManagerUtils.CONFIG_PROP_VERBOSE, 
true);
+            try (CloseableSession session = sessionBuilder
+                    .setDependencyGraphTransformer(new 
ChainedDependencyGraphTransformer(
+                            new ConflictResolver(
+                                    new ConfigurableVersionSelector(
+                                            new 
ConfigurableVersionSelector.MajorVersionConvergence(
+                                                    new 
ConfigurableVersionSelector.Nearest())),
+                                    new JavaScopeSelector(),
+                                    new SimpleOptionalitySelector(),
+                                    new JavaScopeDeriver()),
+                            new JavaDependencyContextRefiner()))
+                    .build()) {
+                Artifact artifact = new 
DefaultArtifact("org.apache.maven.shared:maven-dependency-tree:3.0.1");
+
+                ArtifactDescriptorRequest descriptorRequest = new 
ArtifactDescriptorRequest();
+                descriptorRequest.setArtifact(artifact);
+                
descriptorRequest.setRepositories(Booter.newRepositories(system, session));
+                ArtifactDescriptorResult descriptorResult = 
system.readArtifactDescriptor(session, descriptorRequest);
+
+                CollectRequest collectRequest = new CollectRequest();
+                collectRequest.setRootArtifact(descriptorResult.getArtifact());
+                
collectRequest.setDependencies(descriptorResult.getDependencies());
+                
collectRequest.setManagedDependencies(descriptorResult.getManagedDependencies());
+                
collectRequest.setRepositories(descriptorRequest.getRepositories());
+
+                system.collectDependencies(session, collectRequest);
+                throw new IllegalStateException("should fail");
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+            if (e.getCause() instanceof UnsolvableVersionConflictException) {
+                String cause = e.getCause().getMessage();
+                if (!cause.contains(
+                        "Incompatible versions for 
org.apache.maven:maven-core, incompatible versions: [2.0], all versions [2.0, 
3.0.4]")) {
+                    throw new IllegalStateException("should fail due 
incompatible versions");
+                }
+            } else {
+                throw new IllegalStateException("should fail due incompatible 
versions");
+            }
+        }
+
+        // dependency divergence: multiple versions of same GA present in graph
+        try (RepositorySystem system = 
Booter.newRepositorySystem(Booter.selectFactory(args))) {
+            SessionBuilder sessionBuilder = 
Booter.newRepositorySystemSession(system);
+            
sessionBuilder.setConfigProperty(ConflictResolver.CONFIG_PROP_VERBOSE, true);
+            
sessionBuilder.setConfigProperty(DependencyManagerUtils.CONFIG_PROP_VERBOSE, 
true);
+            try (CloseableSession session = sessionBuilder
+                    .setDependencyGraphTransformer(new 
ChainedDependencyGraphTransformer(
+                            new ConflictResolver(
+                                    new ConfigurableVersionSelector(new 
ConfigurableVersionSelector.VersionConvergence(
+                                            new 
ConfigurableVersionSelector.Nearest())),
+                                    new JavaScopeSelector(),
+                                    new SimpleOptionalitySelector(),
+                                    new JavaScopeDeriver()),
+                            new JavaDependencyContextRefiner()))
+                    .build()) {
+                Artifact artifact = new 
DefaultArtifact("org.apache.maven.shared:maven-dependency-tree:3.1.0");
+
+                ArtifactDescriptorRequest descriptorRequest = new 
ArtifactDescriptorRequest();
+                descriptorRequest.setArtifact(artifact);
+                
descriptorRequest.setRepositories(Booter.newRepositories(system, session));
+                ArtifactDescriptorResult descriptorResult = 
system.readArtifactDescriptor(session, descriptorRequest);
+
+                CollectRequest collectRequest = new CollectRequest();
+                collectRequest.setRootArtifact(descriptorResult.getArtifact());
+                
collectRequest.setDependencies(descriptorResult.getDependencies());
+                
collectRequest.setManagedDependencies(descriptorResult.getManagedDependencies());
+                
collectRequest.setRepositories(descriptorRequest.getRepositories());
+
+                system.collectDependencies(session, collectRequest);
+                throw new IllegalStateException("should fail");
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+            if (e.getCause() instanceof UnsolvableVersionConflictException) {
+                String cause = e.getCause().getMessage();
+                if (!cause.contains(
+                        "Convergence violated for 
org.codehaus.plexus:plexus-utils, versions present: [2.1, 1.5.5, 2.0.6]")) {
+                    throw new IllegalStateException("should fail due 
convergence violation");
+                }
+            } else {
+                throw new IllegalStateException("should fail due convergence 
violation");
+            }
+        }
+    }
+}
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 485b7aac..eec86246 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
@@ -49,8 +49,8 @@ import org.eclipse.aether.util.filter.ScopeDependencyFilter;
 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.NearestVersionSelector;
 import org.eclipse.aether.util.graph.transformer.SimpleOptionalitySelector;
 import org.eclipse.aether.util.graph.visitor.CloningDependencyVisitor;
 import org.eclipse.aether.util.graph.visitor.FilteringDependencyVisitor;
@@ -157,7 +157,7 @@ public final class ScopeManagerImpl implements 
InternalScopeManager {
     public DependencyGraphTransformer 
getDependencyGraphTransformer(ResolutionScope resolutionScope) {
         return new ChainedDependencyGraphTransformer(
                 new ConflictResolver(
-                        new NearestVersionSelector(), new 
ManagedScopeSelector(this),
+                        new ConfigurableVersionSelector(), new 
ManagedScopeSelector(this),
                         new SimpleOptionalitySelector(), new 
ManagedScopeDeriver(this)),
                 new ManagedDependencyContextRefiner(this));
     }
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 89fb5f3c..882edef6 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
@@ -39,11 +39,11 @@ 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.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.NearestVersionSelector;
 import org.eclipse.aether.util.graph.transformer.SimpleOptionalitySelector;
 import org.eclipse.aether.util.graph.traverser.FatArtifactTraverser;
 import org.eclipse.aether.util.repository.SimpleArtifactDescriptorPolicy;
@@ -69,6 +69,7 @@ public class SessionBuilderSupplier implements 
Supplier<SessionBuilder> {
     }
 
     protected void configureSessionBuilder(SessionBuilder session) {
+        session.setSystemProperties(System.getProperties());
         session.setDependencyTraverser(getDependencyTraverser());
         session.setDependencyManager(getDependencyManager());
         session.setDependencySelector(getDependencySelector());
@@ -95,7 +96,7 @@ public class SessionBuilderSupplier implements 
Supplier<SessionBuilder> {
     protected DependencyGraphTransformer getDependencyGraphTransformer() {
         return new ChainedDependencyGraphTransformer(
                 new ConflictResolver(
-                        new NearestVersionSelector(), new JavaScopeSelector(),
+                        new ConfigurableVersionSelector(), new 
JavaScopeSelector(),
                         new SimpleOptionalitySelector(), new 
JavaScopeDeriver()),
                 new JavaDependencyContextRefiner());
     }
diff --git 
a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConfigurableVersionSelector.java
 
b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConfigurableVersionSelector.java
new file mode 100644
index 00000000..9ad0e055
--- /dev/null
+++ 
b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConfigurableVersionSelector.java
@@ -0,0 +1,316 @@
+/*
+ * 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.HashSet;
+import java.util.Iterator;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.eclipse.aether.RepositoryException;
+import org.eclipse.aether.collection.UnsolvableVersionConflictException;
+import org.eclipse.aether.graph.DependencyFilter;
+import org.eclipse.aether.graph.DependencyNode;
+import 
org.eclipse.aether.util.graph.transformer.ConflictResolver.ConflictContext;
+import org.eclipse.aether.util.graph.transformer.ConflictResolver.ConflictItem;
+import 
org.eclipse.aether.util.graph.transformer.ConflictResolver.VersionSelector;
+import org.eclipse.aether.util.graph.visitor.PathRecordingDependencyVisitor;
+import org.eclipse.aether.util.graph.visitor.TreeDependencyVisitor;
+import org.eclipse.aether.version.Version;
+import org.eclipse.aether.version.VersionConstraint;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * A configurable version selector for use with {@link ConflictResolver} that 
resolves version conflicts using a
+ * specified strategy. If there is no single node that satisfies all 
encountered version ranges, the selector will fail.
+ * Based on configuration, this selector may fail for other reasons as well.
+ *
+ * @since 2.0.0
+ */
+public class ConfigurableVersionSelector extends VersionSelector {
+    /**
+     * The strategy how "winner" is being selected.
+     */
+    public interface SelectionStrategy {
+        /**
+         * Invoked for every "candidate" when winner is already set (very 
first candidate is set as winner).
+         * <p>
+         * This method should determine is candidate "better" or not and 
should replace current winner. This method
+         * is invoked whenever {@code candidate} is "considered" (fits any 
constraint in effect, if any).
+         */
+        boolean isBetter(ConflictItem candidate, ConflictItem winner);
+        /**
+         * Method invoked at version selection end, just before version 
selector returns. Note: {@code winner} may
+         * be {@code null}, while the rest of parameters cannot. The parameter 
{@code candidates} contains all the
+         * "considered candidates", dependencies that fulfil all constraints, 
if present. The {@code context} on the
+         * other hand contains all items participating in conflict.
+         * <p>
+         * This method by default just returns the passed in {@code winner}, 
but can do much more.
+         */
+        default ConflictItem winnerSelected(
+                ConflictItem winner, Collection<ConflictItem> candidates, 
ConflictContext context)
+                throws UnsolvableVersionConflictException {
+            return winner;
+        }
+    }
+    /**
+     * The strategy of winner selection, never {@code null}.
+     */
+    protected final SelectionStrategy selectionStrategy;
+
+    /**
+     * Creates a new instance of this version selector that works "as Maven 
did so far".
+     */
+    public ConfigurableVersionSelector() {
+        this(new Nearest());
+    }
+
+    /**
+     * Creates a new instance of this version selector.
+     *
+     * @param selectionStrategy The winner selection strategy, must not be 
{@code null}. Maven3
+     *                          used {@link Nearest} strategy.
+     */
+    public ConfigurableVersionSelector(SelectionStrategy selectionStrategy) {
+        this.selectionStrategy = requireNonNull(selectionStrategy, 
"selectionStrategy");
+    }
+
+    @Override
+    public void selectVersion(ConflictContext context) throws 
RepositoryException {
+        ConflictGroup group = new ConflictGroup();
+        for (ConflictItem candidate : context.getItems()) {
+            DependencyNode node = candidate.getNode();
+            VersionConstraint constraint = node.getVersionConstraint();
+
+            boolean backtrack = false;
+            boolean hardConstraint = constraint.getRange() != null;
+
+            if (hardConstraint) {
+                if (group.constraints.add(constraint)) {
+                    if (group.winner != null
+                            && !constraint.containsVersion(
+                                    group.winner.getNode().getVersion())) {
+                        backtrack = true;
+                    }
+                }
+            }
+
+            if (isAcceptableByConstraints(group, node.getVersion())) {
+                group.candidates.add(candidate);
+
+                if (backtrack) {
+                    backtrack(group, context);
+                } else if (group.winner == null || 
selectionStrategy.isBetter(candidate, group.winner)) {
+                    group.winner = candidate;
+                }
+            } else if (backtrack) {
+                backtrack(group, context);
+            }
+        }
+        context.setWinner(selectionStrategy.winnerSelected(group.winner, 
group.candidates, context));
+    }
+
+    protected void backtrack(ConflictGroup group, ConflictContext context) 
throws UnsolvableVersionConflictException {
+        group.winner = null;
+
+        for (Iterator<ConflictItem> it = group.candidates.iterator(); 
it.hasNext(); ) {
+            ConflictItem candidate = it.next();
+
+            if (!isAcceptableByConstraints(group, 
candidate.getNode().getVersion())) {
+                it.remove();
+            } else if (group.winner == null || 
selectionStrategy.isBetter(candidate, group.winner)) {
+                group.winner = candidate;
+            }
+        }
+
+        if (group.winner == null) {
+            throw newFailure("Unsolvable hard constraint combination", 
context);
+        }
+    }
+
+    protected boolean isAcceptableByConstraints(ConflictGroup group, Version 
version) {
+        for (VersionConstraint constraint : group.constraints) {
+            if (!constraint.containsVersion(version)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Helper method to create failure, creates instance of {@link 
UnsolvableVersionConflictException}.
+     */
+    public static UnsolvableVersionConflictException newFailure(String 
message, ConflictContext context) {
+        DependencyFilter filter = (node, parents) -> {
+            requireNonNull(node, "node cannot be null");
+            requireNonNull(parents, "parents cannot be null");
+            return context.isIncluded(node);
+        };
+        PathRecordingDependencyVisitor visitor = new 
PathRecordingDependencyVisitor(filter);
+        context.getRoot().accept(new TreeDependencyVisitor(visitor));
+        return new UnsolvableVersionConflictException(message, 
visitor.getPaths());
+    }
+
+    protected static class ConflictGroup {
+
+        final Collection<VersionConstraint> constraints;
+
+        final Collection<ConflictItem> candidates;
+
+        ConflictItem winner;
+
+        ConflictGroup() {
+            constraints = new HashSet<>();
+            candidates = new ArrayList<>(64);
+        }
+
+        @Override
+        public String toString() {
+            return String.valueOf(winner);
+        }
+    }
+
+    /**
+     * Selection strategy that selects "nearest" (to the root) version.
+     * <p>
+     * This is the "classic" Maven strategy.
+     */
+    public static class Nearest implements SelectionStrategy {
+        @Override
+        public boolean isBetter(ConflictItem candidate, ConflictItem winner) {
+            if (candidate.isSibling(winner)) {
+                return candidate
+                                .getNode()
+                                .getVersion()
+                                .compareTo(winner.getNode().getVersion())
+                        > 0;
+            } else {
+                return candidate.getDepth() < winner.getDepth();
+            }
+        }
+    }
+
+    /**
+     * Selection strategy that selects "highest" version.
+     */
+    public static class Highest implements SelectionStrategy {
+        @Override
+        public boolean isBetter(ConflictItem candidate, ConflictItem winner) {
+            return 
candidate.getNode().getVersion().compareTo(winner.getNode().getVersion()) > 0;
+        }
+    }
+
+    /**
+     * Example selection strategy (used in tests and demos), is not 
recommended to be used in production.
+     * <p>
+     * Selection strategy that delegates to another selection strategy, and at 
the end enforces dependency convergence
+     * among candidates.
+     */
+    public static class VersionConvergence implements SelectionStrategy {
+        private final SelectionStrategy delegate;
+
+        public VersionConvergence(SelectionStrategy delegate) {
+            this.delegate = requireNonNull(delegate, "delegate");
+        }
+
+        @Override
+        public boolean isBetter(ConflictItem candidate, ConflictItem winner) {
+            return delegate.isBetter(candidate, winner);
+        }
+
+        @Override
+        public ConflictItem winnerSelected(
+                ConflictItem winner, Collection<ConflictItem> candidates, 
ConflictContext context)
+                throws UnsolvableVersionConflictException {
+            if (winner != null && 
winner.getNode().getVersionConstraint().getRange() == null) {
+                Set<String> versions = candidates.stream()
+                        .map(c -> c.getDependency().getArtifact().getVersion())
+                        .collect(Collectors.toSet());
+                if (versions.size() > 1) {
+                    throw newFailure(
+                            "Convergence violated for "
+                                    + 
winner.getDependency().getArtifact().getGroupId() + ":"
+                                    + 
winner.getDependency().getArtifact().getArtifactId() + ", versions present: "
+                                    + versions,
+                            context);
+                }
+            }
+            return winner;
+        }
+    }
+
+    /**
+     * Example selection strategy (used in tests and demos), is not 
recommended to be used in production.
+     * <p>
+     * Selection strategy that delegates to another selection strategy, and at 
end enforces aligned "major versions"
+     * among candidates.
+     */
+    public static class MajorVersionConvergence implements SelectionStrategy {
+        private final SelectionStrategy delegate;
+
+        public MajorVersionConvergence(SelectionStrategy delegate) {
+            this.delegate = requireNonNull(delegate, "delegate");
+        }
+
+        @Override
+        public boolean isBetter(ConflictItem candidate, ConflictItem winner) {
+            return delegate.isBetter(candidate, winner);
+        }
+
+        @Override
+        public ConflictItem winnerSelected(
+                ConflictItem winner, Collection<ConflictItem> candidates, 
ConflictContext context)
+                throws UnsolvableVersionConflictException {
+            if (winner != null && !candidates.isEmpty()) {
+                Set<String> incompatibleVersions = candidates.stream()
+                        .filter(c -> !sameMajor(c, winner))
+                        .map(c -> c.getDependency().getArtifact().getVersion())
+                        .collect(Collectors.toSet());
+                if (!incompatibleVersions.isEmpty()) {
+                    Set<String> allVersions = candidates.stream()
+                            .map(c -> 
c.getDependency().getArtifact().getVersion())
+                            .collect(Collectors.toSet());
+                    throw newFailure(
+                            "Incompatible versions for "
+                                    + 
winner.getDependency().getArtifact().getGroupId() + ":"
+                                    + 
winner.getDependency().getArtifact().getArtifactId() + ", incompatible 
versions: "
+                                    + incompatibleVersions + ", all versions " 
+ allVersions,
+                            context);
+                }
+            }
+            return winner;
+        }
+
+        private boolean sameMajor(ConflictItem candidate, ConflictItem winner) 
{
+            String candidateVersion = 
candidate.getDependency().getArtifact().getVersion();
+            String winnerVersion = 
winner.getDependency().getArtifact().getVersion();
+            // for now a naive check: major versions should be same
+            if (candidateVersion.contains(".") && winnerVersion.contains(".")) 
{
+                String candidateMajor = candidateVersion.substring(0, 
candidateVersion.indexOf('.'));
+                String winnerMajor = winnerVersion.substring(0, 
winnerVersion.indexOf('.'));
+                return Objects.equals(candidateMajor, winnerMajor);
+            }
+            return true; // cannot determine, so just leave it
+        }
+    }
+}
diff --git 
a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/NearestVersionSelector.java
 
b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/NearestVersionSelector.java
index 1bca8240..32b862dc 100644
--- 
a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/NearestVersionSelector.java
+++ 
b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/NearestVersionSelector.java
@@ -40,7 +40,10 @@ import static java.util.Objects.requireNonNull;
 /**
  * A version selector for use with {@link ConflictResolver} that resolves 
version conflicts using a nearest-wins
  * strategy. If there is no single node that satisfies all encountered version 
ranges, the selector will fail.
+ *
+ * @deprecated Use {@link ConfigurableVersionSelector} instead.
  */
+@Deprecated
 public final class NearestVersionSelector extends VersionSelector {
 
     /**
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
new file mode 100644
index 00000000..0e33d9fd
--- /dev/null
+++ 
b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConfigurableVersionSelectorTest.java
@@ -0,0 +1,233 @@
+/*
+ * 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.List;
+
+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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+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());
+    }
+
+    @Override
+    protected DependencyGraphParser newParser() {
+        return new DependencyGraphParser("transformer/version-resolver/");
+    }
+
+    @Test
+    void testSelectHighestVersionFromMultipleVersionsAtSameLevel() throws 
Exception {
+        DependencyNode root = parseResource("sibling-versions.txt");
+        assertSame(root, transform(root));
+
+        assertEquals(1, root.getChildren().size());
+        assertEquals("3", 
root.getChildren().get(0).getArtifact().getVersion());
+    }
+
+    @Test
+    void 
testSelectHighestVersionFromMultipleVersionsAtSameLevelIncompatibleMajors() {
+        assertThrows(UnsolvableVersionConflictException.class, () -> {
+            DependencyNode root = parseResource("sibling-major-versions.txt");
+            transform(root);
+        });
+    }
+
+    @Test
+    void testSelectedVersionAtDeeperLevelThanOriginallySeen() throws Exception 
{
+        DependencyNode root = parseResource("nearest-underneath-loser-a.txt");
+
+        assertSame(root, transform(root));
+
+        List<DependencyNode> trail = find(root, "j");
+        assertEquals(5, trail.size());
+    }
+
+    @Test
+    void testNearestDirtyVersionUnderneathRemovedNode() throws Exception {
+        DependencyNode root = parseResource("nearest-underneath-loser-b.txt");
+
+        assertSame(root, transform(root));
+
+        List<DependencyNode> trail = find(root, "j");
+        assertEquals(5, trail.size());
+    }
+
+    @Test
+    void 
testViolationOfHardConstraintFallsBackToNearestSeenNotFirstSeenIncompatibleMajors()
 throws Exception {
+        assertThrows(UnsolvableVersionConflictException.class, () -> {
+            DependencyNode root = 
parseResource("range-major-backtracking.txt");
+            transform(root);
+        });
+    }
+
+    @Test
+    void testViolationOfHardConstraintFallsBackToNearestSeenNotFirstSeen() 
throws Exception {
+        DependencyNode root = parseResource("range-backtracking.txt");
+
+        assertSame(root, transform(root));
+
+        List<DependencyNode> trail = find(root, "x");
+        assertEquals(3, trail.size());
+        assertEquals("2", trail.get(0).getArtifact().getVersion());
+    }
+
+    @Test
+    void testCyclicConflictIdGraph() throws Exception {
+        DependencyNode root = parseResource("conflict-id-cycle.txt");
+
+        assertSame(root, transform(root));
+
+        assertEquals(2, root.getChildren().size());
+        assertEquals("a", 
root.getChildren().get(0).getArtifact().getArtifactId());
+        assertEquals("b", 
root.getChildren().get(1).getArtifact().getArtifactId());
+        assertTrue(root.getChildren().get(0).getChildren().isEmpty());
+        assertTrue(root.getChildren().get(1).getChildren().isEmpty());
+    }
+
+    @Test
+    void testUnsolvableRangeConflictBetweenHardConstraints() {
+        assertThrows(UnsolvableVersionConflictException.class, () -> {
+            DependencyNode root = parseResource("unsolvable.txt");
+            transform(root);
+        });
+    }
+
+    @Test
+    void testUnsolvableRangeConflictWithUnrelatedCycle() throws Exception {
+        assertThrows(UnsolvableVersionConflictException.class, () -> {
+            DependencyNode root = parseResource("unsolvable-with-cycle.txt");
+            assertSame(root, transform(root));
+        });
+    }
+
+    @Test
+    void testSolvableConflictBetweenHardConstraints() throws Exception {
+        DependencyNode root = parseResource("ranges.txt");
+
+        assertSame(root, transform(root));
+    }
+
+    @Test
+    void testConflictGroupCompletelyDroppedFromResolvedTree() throws Exception 
{
+        DependencyNode root = parseResource("dead-conflict-group.txt");
+
+        assertSame(root, transform(root));
+
+        assertEquals(2, root.getChildren().size());
+        assertEquals("a", 
root.getChildren().get(0).getArtifact().getArtifactId());
+        assertEquals("b", 
root.getChildren().get(1).getArtifact().getArtifactId());
+        assertTrue(root.getChildren().get(0).getChildren().isEmpty());
+        assertTrue(root.getChildren().get(1).getChildren().isEmpty());
+    }
+
+    @Test
+    void testNearestSoftVersionPrunedByFartherRange() throws Exception {
+        DependencyNode root = parseResource("soft-vs-range.txt");
+
+        assertSame(root, transform(root));
+
+        assertEquals(2, root.getChildren().size());
+        assertEquals("a", 
root.getChildren().get(0).getArtifact().getArtifactId());
+        assertEquals(0, root.getChildren().get(0).getChildren().size());
+        assertEquals("b", 
root.getChildren().get(1).getArtifact().getArtifactId());
+        assertEquals(1, root.getChildren().get(1).getChildren().size());
+    }
+
+    @Test
+    void testCyclicGraph() throws Exception {
+        DependencyNode root = parseResource("cycle.txt");
+
+        assertSame(root, transform(root));
+
+        assertEquals(2, root.getChildren().size());
+        assertEquals(1, root.getChildren().get(0).getChildren().size());
+        assertEquals(
+                0, 
root.getChildren().get(0).getChildren().get(0).getChildren().size());
+        assertEquals(0, root.getChildren().get(1).getChildren().size());
+    }
+
+    @Test
+    void testLoop() throws Exception {
+        DependencyNode root = parseResource("loop.txt");
+
+        assertSame(root, transform(root));
+
+        assertEquals(0, root.getChildren().size());
+    }
+
+    @Test
+    void testOverlappingCycles() throws Exception {
+        DependencyNode root = parseResource("overlapping-cycles.txt");
+
+        assertSame(root, transform(root));
+
+        assertEquals(2, root.getChildren().size());
+    }
+
+    @Test
+    void 
testScopeDerivationAndConflictResolutionCantHappenForAllNodesBeforeVersionSelection()
 throws Exception {
+        DependencyNode root = parseResource("scope-vs-version.txt");
+
+        assertSame(root, transform(root));
+
+        DependencyNode[] nodes = find(root, "y").toArray(new 
DependencyNode[0]);
+        assertEquals(3, nodes.length);
+        assertEquals("test", nodes[1].getDependency().getScope());
+        assertEquals("test", nodes[0].getDependency().getScope());
+    }
+
+    @Test
+    void testVerboseMode() throws Exception {
+        DependencyNode root = parseResource("verbose.txt");
+
+        session.setConfigProperty(ConflictResolver.CONFIG_PROP_VERBOSE, 
Boolean.TRUE);
+        assertSame(root, transform(root));
+
+        assertEquals(2, root.getChildren().size());
+        assertEquals(1, root.getChildren().get(0).getChildren().size());
+        DependencyNode winner = root.getChildren().get(0).getChildren().get(0);
+        assertEquals("test", winner.getDependency().getScope());
+        assertEquals("compile", 
winner.getData().get(ConflictResolver.NODE_DATA_ORIGINAL_SCOPE));
+        assertEquals(false, 
winner.getData().get(ConflictResolver.NODE_DATA_ORIGINAL_OPTIONALITY));
+        assertEquals(1, root.getChildren().get(1).getChildren().size());
+        DependencyNode loser = root.getChildren().get(1).getChildren().get(0);
+        assertEquals("test", loser.getDependency().getScope());
+        assertEquals(0, loser.getChildren().size());
+        assertSame(winner, 
loser.getData().get(ConflictResolver.NODE_DATA_WINNER));
+        assertEquals("compile", 
loser.getData().get(ConflictResolver.NODE_DATA_ORIGINAL_SCOPE));
+        assertEquals(false, 
loser.getData().get(ConflictResolver.NODE_DATA_ORIGINAL_OPTIONALITY));
+    }
+}
diff --git 
a/maven-resolver-util/src/test/resources/transformer/version-resolver/range-major-backtracking.txt
 
b/maven-resolver-util/src/test/resources/transformer/version-resolver/range-major-backtracking.txt
new file mode 100644
index 00000000..cf201232
--- /dev/null
+++ 
b/maven-resolver-util/src/test/resources/transformer/version-resolver/range-major-backtracking.txt
@@ -0,0 +1,10 @@
+(null)
++- test:x:1.0
++- test:a:1
+|  \- test:b:1
+|     \- test:x:3.0
++- test:c:1
+|  \- test:x:2.0
+\- test:d:1
+   \- test:e:1
+      \- test:x:2[2,)   # forces rejection of x:1, should fallback to nearest 
and not first-seen, i.e. x:2 and not x:3
diff --git 
a/maven-resolver-util/src/test/resources/transformer/version-resolver/sibling-major-versions.txt
 
b/maven-resolver-util/src/test/resources/transformer/version-resolver/sibling-major-versions.txt
new file mode 100644
index 00000000..a32ea844
--- /dev/null
+++ 
b/maven-resolver-util/src/test/resources/transformer/version-resolver/sibling-major-versions.txt
@@ -0,0 +1,7 @@
+# multiple versions of the same GA beneath the same parent as seen after 
expansion of version ranges
+# versions neither in ascending nor descending order
+
+test:root:1
++- test:a:1.0
++- test:a:3.0
+\- test:a:2.0


Reply via email to