Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions src/main/java/com/solubris/enforcer/BomImportScopeRule.java
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>This rule inspects both the top-level {@code <dependencies>} section and
* the {@code <dependencyManagement>} section of the project. Any dependency
* with {@code type=pom} that does not have {@code scope=import} will cause
* a build failure.
*
* <p>BOMs (Bill of Materials) are intended to be imported via
* {@code <scope>import</scope>} inside {@code <dependencyManagement>}.
* 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<String> scanAll() {
Stream<String> topLevelViolations = directDependencies()
.filter(dep -> POM_TYPE.equals(dep.getType()))
.map(dep -> formatViolation(dep, "dependencies"));

Stream<String> 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<Dependency> directDependencies() {
return Optional.ofNullable(model.getDependencies())
.stream()
.flatMap(Collection::stream);
}

private Stream<Dependency> 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);
}
}
151 changes: 151 additions & 0 deletions src/test/java/com/solubris/enforcer/BomImportScopeRuleTest.java
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> 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<String> violations = rule.scanAll();

assertThat(violations)
.hasSize(1)
.first()
.asString()
.contains("com.example:some-bom:1.0")
.contains("<dependencies>");
}

@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<String> 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;
}
}