diff --git a/src/main/java/rife/bld/dependencies/DependencyScopes.java b/src/main/java/rife/bld/dependencies/DependencyScopes.java index 1d973dc..cadbf76 100644 --- a/src/main/java/rife/bld/dependencies/DependencyScopes.java +++ b/src/main/java/rife/bld/dependencies/DependencyScopes.java @@ -7,6 +7,7 @@ package rife.bld.dependencies; import rife.ioc.HierarchicalProperties; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; @@ -75,10 +76,13 @@ public class DependencyScopes extends LinkedHashMap { * {@code compile} scope BOMs also apply to the {@code provided}, * {@code runtime} and {@code test} scopes, and the {@code runtime} and * {@code provided} scope BOMs also apply to the {@code test} scope. - * The BOMs are returned in classpath order, for the {@code test} scope - * that is {@code compile}, {@code provided}, {@code runtime}, - * {@code test}, and this order determines their precedence when - * several manage the same dependency. + * The BOMs are returned with the scope's own BOMs first, followed by + * the inherited ones from the more specific to the more general scope, + * for the {@code test} scope that is {@code test}, {@code runtime}, + * {@code provided}, {@code compile}. This order determines their + * precedence when several manage the same dependency: a BOM declared + * in the scope where a dependency is used takes precedence over a BOM + * that is inherited from a broader scope. *

* The {@code standalone} scope only uses its own BOMs, and its BOMs * deliberately never apply to any other scope. @@ -100,9 +104,9 @@ public class DependencyScopes extends LinkedHashMap { private static Scope[] bomScopes(Scope scope) { return switch (scope) { - case provided -> new Scope[]{Scope.compile, Scope.provided}; - case runtime -> new Scope[]{Scope.compile, Scope.runtime}; - case test -> new Scope[]{Scope.compile, Scope.provided, Scope.runtime, Scope.test}; + case provided -> new Scope[]{Scope.provided, Scope.compile}; + case runtime -> new Scope[]{Scope.runtime, Scope.compile}; + case test -> new Scope[]{Scope.test, Scope.runtime, Scope.provided, Scope.compile}; default -> new Scope[]{scope}; }; } @@ -142,6 +146,37 @@ public class DependencyScopes extends LinkedHashMap { return result; } + /** + * Returns the version conflicts between the BOMs that apply to the + * scopes, where more than one applicable BOM manages the same + * dependency at a different version. + *

+ * Each conflict is reported once, the versions are keyed by their BOM + * in precedence order so that the first is the version that is used. + * + * @param properties the properties to use to get artifacts + * @param retriever the retriever to use to get artifacts + * @param repositories the repositories to use for the BOM resolution + * @return the version conflicts between the applicable BOMs + * @since 2.4.0 + */ + public List bomVersionConflicts(HierarchicalProperties properties, ArtifactRetriever retriever, List repositories) { + var result = new ArrayList(); + var seen = new HashSet(); + for (var scope : keySet()) { + var effective_boms = effectiveBoms(scope); + if (effective_boms.size() < 2) { + continue; + } + for (var conflict : VersionResolution.resolveBomVersionConflicts(properties, retriever, repositories, effective_boms)) { + if (seen.add(conflict.dependency() + conflict.bomVersions())) { + result.add(conflict); + } + } + } + return result; + } + /** * Returns the transitive set of dependencies that would be used for the compile scope in a project. * diff --git a/src/main/java/rife/bld/dependencies/VersionResolution.java b/src/main/java/rife/bld/dependencies/VersionResolution.java index f346770..8b4c65b 100644 --- a/src/main/java/rife/bld/dependencies/VersionResolution.java +++ b/src/main/java/rife/bld/dependencies/VersionResolution.java @@ -6,8 +6,10 @@ package rife.bld.dependencies; import rife.ioc.HierarchicalProperties; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.logging.Logger; @@ -175,6 +177,62 @@ public class VersionResolution { return bom_versions; } + /** + * Describes a version conflict between bills of materials, where more + * than one applicable BOM manages the same dependency at a different + * version. + * + * @param dependency the group and artifact identifiers of the + * dependency that is managed at conflicting versions + * @param bomVersions the versions that the BOMs manage the dependency + * at, keyed by the BOM, in precedence order so that + * the first entry is the version that is used + * @since 2.4.0 + */ + public record BomVersionConflict(String dependency, Map bomVersions) { + } + + /** + * Resolves the version conflicts between the provided bills of + * materials, where more than one of them manages the same dependency + * at a different version. + * + * @param properties the properties to use to get artifacts + * @param retriever the retriever to use to get the BOMs + * @param repositories the repositories to resolve the BOMs in + * @param boms the BOMs to check, in precedence order + * @return the version conflicts between the BOMs + * @since 2.4.0 + */ + public static List resolveBomVersionConflicts(HierarchicalProperties properties, ArtifactRetriever retriever, List repositories, Collection boms) { + var base = new VersionResolution(properties); + var versions_by_key = new LinkedHashMap>(); + var dependency_by_key = new LinkedHashMap(); + if (boms != null) { + for (var bom : boms) { + var pom = new DependencyResolver(base, retriever, repositories, bom).getMavenPom(bom); + for (var managed : pom.getManagedDependencies()) { + if (managed.version() != null && !managed.version().isBlank()) { + var dependency = managed.convertToDependency(); + var key = managedKey(dependency); + versions_by_key.computeIfAbsent(key, k -> new LinkedHashMap<>()) + .putIfAbsent(bom.toArtifactString(), dependency.version()); + dependency_by_key.putIfAbsent(key, dependency.toArtifactString()); + } + } + } + } + + var conflicts = new ArrayList(); + for (var entry : versions_by_key.entrySet()) { + var bom_versions = entry.getValue(); + if (bom_versions.values().stream().distinct().count() > 1) { + conflicts.add(new BomVersionConflict(dependency_by_key.get(entry.getKey()), new LinkedHashMap<>(bom_versions))); + } + } + return conflicts; + } + // builds the identity that dependency management entries are matched // on, mirroring Maven this includes the type and the classifier, the // modular and forced-classpath JAR types match the plain jar entries diff --git a/src/main/java/rife/bld/operations/DownloadOperation.java b/src/main/java/rife/bld/operations/DownloadOperation.java index 123164a..51d16dd 100644 --- a/src/main/java/rife/bld/operations/DownloadOperation.java +++ b/src/main/java/rife/bld/operations/DownloadOperation.java @@ -86,6 +86,28 @@ public class DownloadOperation extends AbstractOperation { for (var dependency : dependencies().versionlessDependenciesWithoutBom(properties(), artifactRetriever(), repositories())) { System.out.println("Warning: '" + dependency.toArtifactString() + "' isn't covered by a BOM, its latest version will be used"); } + for (var conflict : dependencies().bomVersionConflicts(properties(), artifactRetriever(), repositories())) { + System.out.println(formatBomVersionConflict(conflict)); + } + } + + /** + * Formats a warning message for a BOM version conflict. + * + * @param conflict the BOM version conflict to format + * @return the formatted warning message + * @since 2.4.0 + */ + protected static String formatBomVersionConflict(rife.bld.dependencies.VersionResolution.BomVersionConflict conflict) { + var entries = conflict.bomVersions().entrySet().iterator(); + var used = entries.next(); + var message = new StringBuilder("Warning: '" + conflict.dependency() + "' is managed by multiple BOMs, using " + + used.getValue() + " from '" + used.getKey() + "'"); + while (entries.hasNext()) { + var other = entries.next(); + message.append(", not ").append(other.getValue()).append(" from '").append(other.getKey()).append('\''); + } + return message.toString(); } /** diff --git a/src/main/java/rife/bld/operations/PurgeOperation.java b/src/main/java/rife/bld/operations/PurgeOperation.java index ca5a97a..ef63f01 100644 --- a/src/main/java/rife/bld/operations/PurgeOperation.java +++ b/src/main/java/rife/bld/operations/PurgeOperation.java @@ -78,6 +78,9 @@ public class PurgeOperation extends AbstractOperation { for (var dependency : dependencies().versionlessDependenciesWithoutBom(properties(), artifactRetriever(), repositories())) { System.out.println("Warning: '" + dependency.toArtifactString() + "' isn't covered by a BOM, its latest version will be used"); } + for (var conflict : dependencies().bomVersionConflicts(properties(), artifactRetriever(), repositories())) { + System.out.println(DownloadOperation.formatBomVersionConflict(conflict)); + } } /** diff --git a/src/test/java/rife/bld/dependencies/TestBom.java b/src/test/java/rife/bld/dependencies/TestBom.java index 9f77f67..4082cc4 100644 --- a/src/test/java/rife/bld/dependencies/TestBom.java +++ b/src/test/java/rife/bld/dependencies/TestBom.java @@ -410,10 +410,11 @@ public class TestBom { .include(new Bom("com.example", "bom2", new VersionNumber(1, 0, 0))) .include(new Dependency("com.example", "a")); - // when several applicable BOMs manage the same dependency, the - // first one in classpath composition order determines the version + // a BOM declared in the dependency's own scope takes precedence + // over a BOM inherited from a broader scope, so the test scope + // bom2 wins over the compile scope bom1 var test_resolved = scopes.resolveTestDependencies(new HierarchicalProperties(), ArtifactRetriever.cachingInstance(), serverRepositories(server)); - assertEquals(Version.parse("1.4.0"), test_resolved.get(new Dependency("com.example", "a")).version()); + assertEquals(Version.parse("2.2.0"), test_resolved.get(new Dependency("com.example", "a")).version()); } finally { server.stop(0); } @@ -470,6 +471,41 @@ public class TestBom { } } + @Test + void testBomVersionConflictsAreDetected() throws Exception { + var server = createArtifactServer(Map.of( + "bom1:1.0.0", bomPom("bom1", "1.0.0", managed("a", "1.4.0") + managed("b", "3.0.0")), + "bom2:1.0.0", bomPom("bom2", "1.0.0", managed("a", "2.2.0"))), + Map.of()); + server.start(); + try { + var scopes = new DependencyScopes(); + scopes.scope(compile) + .include(new Bom("com.example", "bom1", new VersionNumber(1, 0, 0))); + scopes.scope(Scope.test) + .include(new Bom("com.example", "bom2", new VersionNumber(1, 0, 0))); + + var conflicts = scopes.bomVersionConflicts(new HierarchicalProperties(), ArtifactRetriever.cachingInstance(), serverRepositories(server)); + // only 'a' is managed by both BOMs at different versions, 'b' + // is managed by a single BOM so it isn't a conflict + assertEquals(1, conflicts.size()); + var conflict = conflicts.get(0); + assertEquals("com.example:a", conflict.dependency()); + + var versions = conflict.bomVersions().entrySet().iterator(); + // the test scope bom2 wins and is reported first, the compile + // scope bom1 is the alternative + var used = versions.next(); + assertEquals("com.example:bom2", used.getKey()); + assertEquals(Version.parse("2.2.0"), used.getValue()); + var other = versions.next(); + assertEquals("com.example:bom1", other.getKey()); + assertEquals(Version.parse("1.4.0"), other.getValue()); + } finally { + server.stop(0); + } + } + @Test void testEffectiveBomsComposition() { var compile_bom = new Bom("com.example", "compile-bom", new VersionNumber(1, 0, 0)); @@ -485,11 +521,12 @@ public class TestBom { scopes.scope(Scope.test).include(test_bom); scopes.scope(Scope.standalone).include(standalone_bom); + // the scope's own BOMs come first, then the inherited ones from + // the more specific to the more general scope assertEquals(List.of(compile_bom), scopes.effectiveBoms(compile)); - assertEquals(List.of(compile_bom, provided_bom), scopes.effectiveBoms(Scope.provided)); - assertEquals(List.of(compile_bom, runtime_bom), scopes.effectiveBoms(Scope.runtime)); - // the test scope order matches the test classpath order - assertEquals(List.of(compile_bom, provided_bom, runtime_bom, test_bom), scopes.effectiveBoms(Scope.test)); + assertEquals(List.of(provided_bom, compile_bom), scopes.effectiveBoms(Scope.provided)); + assertEquals(List.of(runtime_bom, compile_bom), scopes.effectiveBoms(Scope.runtime)); + assertEquals(List.of(test_bom, runtime_bom, provided_bom, compile_bom), scopes.effectiveBoms(Scope.test)); assertEquals(List.of(standalone_bom), scopes.effectiveBoms(Scope.standalone)); }