diff --git a/src/main/java/com/solubris/enforcer/BomImportScopeRule.java b/src/main/java/com/solubris/enforcer/BomImportScopeRule.java new file mode 100644 index 0000000..e222d18 --- /dev/null +++ b/src/main/java/com/solubris/enforcer/BomImportScopeRule.java @@ -0,0 +1,106 @@ +package com.solubris.enforcer; + +import org.apache.maven.enforcer.rule.api.AbstractEnforcerRule; +import org.apache.maven.enforcer.rule.api.EnforcerRuleException; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.model.Dependency; +import org.apache.maven.model.DependencyManagement; +import org.apache.maven.model.Model; +import org.apache.maven.project.MavenProject; + +import javax.inject.Inject; +import javax.inject.Named; +import java.util.Collection; +import java.util.Optional; +import java.util.concurrent.atomic.LongAdder; +import java.util.stream.Stream; + +import static java.text.MessageFormat.format; +import static java.util.stream.Collectors.joining; +import static org.apache.maven.artifact.ArtifactUtils.key; + +/** + * A custom Maven Enforcer rule that ensures all dependencies declared with + * {@code type=pom} (i.e. BOMs) also specify {@code scope=import}. + * + *

This rule inspects both the top-level {@code } section and + * the {@code } section of the project. Any dependency + * with {@code type=pom} that does not have {@code scope=import} will cause + * a build failure. + * + *

BOMs (Bill of Materials) are intended to be imported via + * {@code import} inside {@code }. + * Using them without import scope is almost always a mistake and can lead + * to unexpected transitive dependency resolution. + */ +@Named("bomImportScopeRule") +public class BomImportScopeRule extends AbstractEnforcerRule { + private static final String POM_TYPE = "pom"; + private static final String IMPORT_SCOPE = "import"; + + private final Model model; + + @SuppressWarnings("unused") + @Inject + public BomImportScopeRule(MavenSession session) { + this(modelFrom(session)); + } + + protected BomImportScopeRule(Model model) { + this.model = model; + } + + private static Model modelFrom(MavenSession session) { + MavenProject project = session.getCurrentProject(); + Model originalModel = project.getOriginalModel(); + return originalModel != null ? originalModel : project.getModel(); + } + + @Override + public void execute() throws EnforcerRuleException { + LongAdder count = new LongAdder(); + String body = scanAll() + .peek(v -> count.increment()) + .map(v -> " - " + v) + .collect(joining("\n", "", "\n")); + if (count.longValue() > 0) { + String title = "BOM dependencies (type=pom) must use scope=import. Found " + count.sum() + " violation(s):"; + throw new EnforcerRuleException(title + "\n" + body); + } + + getLog().info("All BOM dependencies (type=pom) correctly use scope=import."); + } + + protected Stream scanAll() { + Stream topLevelViolations = directDependencies() + .filter(dep -> POM_TYPE.equals(dep.getType())) + .map(dep -> formatViolation(dep, "dependencies")); + + Stream depMgmtViolations = managedDependencies() + .filter(dep -> POM_TYPE.equals(dep.getType())) + .filter(dep -> !IMPORT_SCOPE.equals(dep.getScope())) + .map(dep -> formatViolation(dep, "dependencyManagement")); + + return Stream.concat(topLevelViolations, depMgmtViolations); + } + + private Stream directDependencies() { + return Optional.ofNullable(model.getDependencies()) + .stream() + .flatMap(Collection::stream); + } + + private Stream managedDependencies() { + return Optional.ofNullable(model.getDependencyManagement()) + .map(DependencyManagement::getDependencies) + .stream() + .flatMap(Collection::stream); + } + + private static String formatViolation(Dependency dep, String section) { + String scope = dep.getScope(); + String scopeInfo = (scope == null || scope.isEmpty()) ? "no scope" : "scope=" + scope; + String key = key(dep.getGroupId(), dep.getArtifactId(), dep.getVersion()); + return format("{0} in <{1}> has type=pom but {2} (expected scope=import)", key, section, scopeInfo); + } +} diff --git a/src/test/java/com/solubris/enforcer/BomImportScopeRuleTest.java b/src/test/java/com/solubris/enforcer/BomImportScopeRuleTest.java new file mode 100644 index 0000000..10d5f7c --- /dev/null +++ b/src/test/java/com/solubris/enforcer/BomImportScopeRuleTest.java @@ -0,0 +1,151 @@ +package com.solubris.enforcer; + +import org.apache.maven.enforcer.rule.api.EnforcerLogger; +import org.apache.maven.enforcer.rule.api.EnforcerRuleException; +import org.apache.maven.model.Dependency; +import org.apache.maven.model.DependencyManagement; +import org.apache.maven.model.Model; +import org.junit.jupiter.api.Test; + +import java.util.stream.Stream; + +import static com.solubris.enforcer.ModelStubber.dependencyOf; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +class BomImportScopeRuleTest { + private final Model model = new Model(); + private final BomImportScopeRule rule = new BomImportScopeRule(model); + + BomImportScopeRuleTest() { + rule.setLog(mock(EnforcerLogger.class)); + } + + @Test + void noDependenciesPasses() { + assertThatNoException().isThrownBy(rule::execute); + } + + @Test + void jarDependencyPasses() { + model.addDependency(dependencyOf("com.example", "some-lib", "1.0")); + + assertThatNoException().isThrownBy(rule::execute); + } + + @Test + void bomWithImportScopeInDepMgmtPasses() { + Dependency bom = bomDependency("com.example", "some-bom", "1.0", "import"); + DependencyManagement depMgmt = new DependencyManagement(); + depMgmt.addDependency(bom); + model.setDependencyManagement(depMgmt); + + assertThatNoException().isThrownBy(rule::execute); + } + + @Test + void bomWithoutImportScopeInDepMgmtFails() { + Dependency bom = bomDependency("com.example", "some-bom", "1.0", "compile"); + DependencyManagement depMgmt = new DependencyManagement(); + depMgmt.addDependency(bom); + model.setDependencyManagement(depMgmt); + + Stream violations = rule.scanAll(); + + assertThat(violations) + .hasSize(1) + .first() + .asString() + .contains("com.example:some-bom:1.0") + .contains("scope=compile") + .contains("dependencyManagement"); + } + + @Test + void bomWithNoScopeInDepMgmtFails() { + Dependency bom = bomDependency("com.example", "some-bom", "1.0", null); + DependencyManagement depMgmt = new DependencyManagement(); + depMgmt.addDependency(bom); + model.setDependencyManagement(depMgmt); + + Stream violations = rule.scanAll(); + + assertThat(violations) + .hasSize(1) + .first() + .asString() + .contains("com.example:some-bom:1.0") + .contains("no scope"); + } + + @Test + void bomInTopLevelDependenciesFails() { + Dependency bom = bomDependency("com.example", "some-bom", "1.0", "import"); + model.addDependency(bom); + + Stream violations = rule.scanAll(); + + assertThat(violations) + .hasSize(1) + .first() + .asString() + .contains("com.example:some-bom:1.0") + .contains(""); + } + + @Test + void multipleViolationsReported() { + // BOM in top-level dependencies + Dependency topBom = bomDependency("com.example", "top-bom", "1.0", "compile"); + model.addDependency(topBom); + + // BOM in depMgmt without import scope + Dependency mgmtBom = bomDependency("com.example", "mgmt-bom", "2.0", "runtime"); + DependencyManagement depMgmt = new DependencyManagement(); + depMgmt.addDependency(mgmtBom); + model.setDependencyManagement(depMgmt); + + Stream violations = rule.scanAll(); + + assertThat(violations) + .hasSize(2) + .anyMatch(v -> v.contains("com.example:top-bom:1.0")) + .anyMatch(v -> v.contains("com.example:mgmt-bom:2.0")); + } + + @Test + void mixedDependenciesWithValidBomPasses() { + // Non-pom dependency in top-level deps + model.addDependency(dependencyOf("com.example", "some-lib", "1.0")); + + // Valid BOM in depMgmt + Dependency bom = bomDependency("com.example", "some-bom", "1.0", "import"); + DependencyManagement depMgmt = new DependencyManagement(); + depMgmt.addDependency(bom); + model.setDependencyManagement(depMgmt); + + assertThatNoException().isThrownBy(rule::execute); + } + + @Test + void executeThrowsWithViolationMessage() { + Dependency bom = bomDependency("com.example", "bad-bom", "3.0", "compile"); + DependencyManagement depMgmt = new DependencyManagement(); + depMgmt.addDependency(bom); + model.setDependencyManagement(depMgmt); + + assertThatThrownBy(rule::execute) + .isInstanceOf(EnforcerRuleException.class) + .hasMessageContaining("com.example:bad-bom:3.0") + .hasMessageContaining("1 violation(s)"); + } + + private static Dependency bomDependency(String groupId, String artifactId, String version, String scope) { + Dependency dep = dependencyOf(groupId, artifactId, version); + dep.setType("pom"); + dep.setScope(scope); + return dep; + } +}