Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,26 @@ public enum DependencyScope {
*/
COMPILE("compile", true),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Nit: consider positioning after RUNTIME

The new API and IMPLEMENTATION enum constants are placed between COMPILE and RUNTIME. This is defensible (grouping compile-related scopes together), but it changes the ordinal values of RUNTIME, PROVIDED, TEST, TEST_ONLY, TEST_RUNTIME, and SYSTEM. Since DependencyScope is an @Experimental API this is technically fine — but worth noting that any code using ordinal() or values() ordering will see a change.

Not blocking — just flagging for awareness.


/**
* Compile, runtime and test, transitively exposed to consumers.
* Semantically equivalent to {@code compile}, but explicitly declares
* that this dependency forms part of the project's public API.
* Only valid for {@code modelVersion 4.2.0+}.
*
* @since 4.2.0
*/
API("api", true),

/**
* Compile, runtime and test, but <em>not</em> transitively exposed to consumers.
* Use for dependencies that are internal implementation details.
* Mapped to {@code runtime} scope in consumer POMs for Maven 3 compatibility.
* Only valid for {@code modelVersion 4.2.0+}.
*
* @since 4.2.0
*/
IMPLEMENTATION("implementation", false),

/**
* Runtime and test.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,24 @@ public interface PathScope extends ExtensibleEnum {
ProjectScope.MAIN,
DependencyScope.COMPILE_ONLY,
DependencyScope.COMPILE,
DependencyScope.API,
DependencyScope.IMPLEMENTATION,
DependencyScope.PROVIDED);

PathScope MAIN_RUNTIME =
pathScope("main-runtime", ProjectScope.MAIN, DependencyScope.COMPILE, DependencyScope.RUNTIME);
PathScope MAIN_RUNTIME = pathScope(
"main-runtime",
ProjectScope.MAIN,
DependencyScope.COMPILE,
DependencyScope.API,
DependencyScope.IMPLEMENTATION,
DependencyScope.RUNTIME);

PathScope TEST_COMPILE = pathScope(
"test-compile",
ProjectScope.TEST,
DependencyScope.COMPILE,
DependencyScope.API,
DependencyScope.IMPLEMENTATION,
DependencyScope.PROVIDED,
DependencyScope.TEST_ONLY,
DependencyScope.TEST);
Expand All @@ -75,6 +84,8 @@ public interface PathScope extends ExtensibleEnum {
"test-runtime",
ProjectScope.TEST,
DependencyScope.COMPILE,
DependencyScope.API,
DependencyScope.IMPLEMENTATION,
DependencyScope.RUNTIME,
DependencyScope.PROVIDED,
DependencyScope.TEST,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@

import java.io.InputStream;
import java.util.Collections;
import java.util.List;

import org.apache.maven.api.model.Build;
import org.apache.maven.api.model.Dependency;
import org.apache.maven.api.model.DependencyManagement;
import org.apache.maven.api.model.Model;
import org.apache.maven.api.model.Plugin;
import org.apache.maven.api.model.PluginExecution;
Expand Down Expand Up @@ -72,4 +75,69 @@ void testV4ModelPriority() {
PluginExecution.newInstance().withPriority(5))))));
assertEquals("4.0.0", new MavenModelVersion().getModelVersion(m));
}

@Test
void testApiScopeDependencyRequires420() {
// A model with an api-scoped dependency should require modelVersion 4.2.0
Model m = model.withDependencies(List.of(Dependency.newBuilder()
.groupId("org.example")
.artifactId("api-lib")
.version("1.0")
.scope("api")
.build()));
assertEquals("4.2.0", new MavenModelVersion().getModelVersion(m));
}

@Test
void testImplementationScopeDependencyRequires420() {
// A model with an implementation-scoped dependency should require modelVersion 4.2.0
Model m = model.withDependencies(List.of(Dependency.newBuilder()
.groupId("org.example")
.artifactId("impl-lib")
.version("1.0")
.scope("implementation")
.build()));
assertEquals("4.2.0", new MavenModelVersion().getModelVersion(m));
}

@Test
void testApiScopeInDependencyManagementRequires420() {
// A model with an api-scoped dependency in dependencyManagement should require 4.2.0
Model m = model.withDependencyManagement(DependencyManagement.newBuilder()
.dependencies(List.of(Dependency.newBuilder()
.groupId("org.example")
.artifactId("api-lib")
.version("1.0")
.scope("api")
.build()))
.build());
assertEquals("4.2.0", new MavenModelVersion().getModelVersion(m));
}

@Test
void testCompileScopeDependencyRemains400() {
// A model with only compile-scoped dependencies should stay at 4.0.0
Model m = model.withDependencies(List.of(Dependency.newBuilder()
.groupId("org.example")
.artifactId("compile-lib")
.version("1.0")
.scope("compile")
.build()));
assertEquals("4.0.0", new MavenModelVersion().getModelVersion(m));
}

@Test
void testApiScopeInProfileRequires420() {
// A model with an api-scoped dependency in a profile should require 4.2.0
Model m = model.withProfiles(List.of(org.apache.maven.api.model.Profile.newBuilder()
.id("my-profile")
.dependencies(List.of(Dependency.newBuilder()
.groupId("org.example")
.artifactId("api-lib")
.version("1.0")
.scope("api")
.build()))
.build()));
assertEquals("4.2.0", new MavenModelVersion().getModelVersion(m));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ public Collection<org.eclipse.aether.scope.DependencyScope> buildDependencyScope
ArrayList<org.eclipse.aether.scope.DependencyScope> result = new ArrayList<>();
result.add(internalScopeManager.createDependencyScope(
DependencyScope.COMPILE.id(), DependencyScope.COMPILE.isTransitive(), all()));
result.add(internalScopeManager.createDependencyScope(
DependencyScope.API.id(), DependencyScope.API.isTransitive(), all()));
result.add(internalScopeManager.createDependencyScope(
DependencyScope.IMPLEMENTATION.id(), DependencyScope.IMPLEMENTATION.isTransitive(), all()));
result.add(internalScopeManager.createDependencyScope(
DependencyScope.RUNTIME.id(),
DependencyScope.RUNTIME.isTransitive(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -582,8 +582,10 @@ private Model buildEffectiveModel(RepositorySystemSession session, MavenProject
}
return dependency;
});
// Only keep transitive scopes (null/empty => COMPILE)
// Only keep consumer-visible scopes (compile, api, runtime, implementation)
directDependencies.values().removeIf(DefaultConsumerPomBuilder::hasDependencyScope);
// Map 4.2.0 scopes to their 4.0.0 consumer POM equivalents (api→compile, implementation→runtime)
directDependencies.replaceAll((k, v) -> mapScopeForConsumerPom(v));
managedDependencies.keySet().removeAll(directDependencies.keySet());

model = model.withDependencyManagement(
Expand All @@ -600,23 +602,45 @@ private Model buildEffectiveModel(RepositorySystemSession session, MavenProject
Function.identity(),
this::merge,
LinkedHashMap::new));
// Only keep transitive scopes
// Only keep consumer-visible scopes (compile, api, runtime, implementation)
directDependencies.values().removeIf(DefaultConsumerPomBuilder::hasDependencyScope);
// Map 4.2.0 scopes to their 4.0.0 consumer POM equivalents (api→compile, implementation→runtime)
directDependencies.replaceAll((k, v) -> mapScopeForConsumerPom(v));
model = model.withDependencies(directDependencies.isEmpty() ? null : directDependencies.values());
}

return model;
}

private static boolean hasDependencyScope(Dependency dependency) {
static boolean hasDependencyScope(Dependency dependency) {
String scopeId = dependency.getScope();
DependencyScope scope;
if (scopeId == null || scopeId.isEmpty()) {
scope = DependencyScope.COMPILE;
} else {
scope = DependencyScope.forId(scopeId);
}
return scope == null || !scope.isTransitive();
return scope != DependencyScope.COMPILE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The hasDependencyScope filter logic is correct but the name is confusing

The method returns true for scopes that should be removed (it's used with removeIf). The new explicit allowlist approach (scope != COMPILE && scope != RUNTIME && scope != API && scope != IMPLEMENTATION) is clearer than the old !scope.isTransitive() — good change.

However, note that scope == null (unknown scope string) now returns true (= remove), where previously an unknown scope with isTransitive() == false would also have been removed. So behavior is consistent. 👍

&& scope != DependencyScope.RUNTIME
&& scope != DependencyScope.API
&& scope != DependencyScope.IMPLEMENTATION;
}

/**
* Maps a 4.2.0 dependency scope to its 4.0.0 consumer POM equivalent.
* <ul>
* <li>{@code api} → {@code compile} (semantically identical, expresses intent)</li>
* <li>{@code implementation} → {@code runtime} (consumers cannot compile against it, present at runtime)</li>
* </ul>
*/
static Dependency mapScopeForConsumerPom(Dependency dependency) {
String scope = dependency.getScope();
if (DependencyScope.API.id().equals(scope)) {
return dependency.withScope("compile");
} else if (DependencyScope.IMPLEMENTATION.id().equals(scope)) {
return dependency.withScope("runtime");
}
return dependency;
}

private Dependency merge(Dependency dep1, Dependency dep2) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1499,4 +1499,110 @@ void testHasNonModelPropertiesReturnsFalseForEnvAndSettingsProperties() {
DefaultConsumerPomBuilder.hasNonModelProperties("${env.HOME}-${ext.qualifier}", Map.of()),
"Should return true when at least one property is not built-in or in the model");
}

@Test
void testConsumerPomRetainsCompileApiRuntimeDeps() throws Exception {
// Consumer POMs must retain compile, api, and runtime dependencies
org.apache.maven.api.model.Dependency compileDep = org.apache.maven.api.model.Dependency.newBuilder()
.groupId("g")
.artifactId("compile-dep")
.version("1")
.scope("compile")
.build();
org.apache.maven.api.model.Dependency apiDep = org.apache.maven.api.model.Dependency.newBuilder()
.groupId("g")
.artifactId("api-dep")
.version("1")
.scope("api")
.build();
org.apache.maven.api.model.Dependency runtimeDep = org.apache.maven.api.model.Dependency.newBuilder()
.groupId("g")
.artifactId("runtime-dep")
.version("1")
.scope("runtime")
.build();
org.apache.maven.api.model.Dependency unscopedDep = org.apache.maven.api.model.Dependency.newBuilder()
.groupId("g")
.artifactId("unscoped-dep")
.version("1")
.build();

Model model = Model.newBuilder()
.groupId("test")
.artifactId("test")
.version("1.0")
.dependencies(List.of(compileDep, apiDep, runtimeDep, unscopedDep))
.build();

Model transformed = DefaultConsumerPomBuilder.transformNonPom(model, null);
assertNotNull(transformed.getDependencies());
// All four should be retained
assertTrue(
transformed.getDependencies().stream().anyMatch(d -> "compile-dep".equals(d.getArtifactId())),
"compile-scoped dep should be retained");
assertTrue(
transformed.getDependencies().stream().anyMatch(d -> "api-dep".equals(d.getArtifactId())),
"api-scoped dep should be retained");
assertTrue(
transformed.getDependencies().stream().anyMatch(d -> "runtime-dep".equals(d.getArtifactId())),
"runtime-scoped dep should be retained");
assertTrue(
transformed.getDependencies().stream().anyMatch(d -> "unscoped-dep".equals(d.getArtifactId())),
"unscoped (default compile) dep should be retained");
}

@Test
void testConsumerPomScopeFilter() {
// hasDependencyScope returns true for deps to REMOVE
// compile/runtime/api/implementation should be KEPT (returns false)
assertFalse(DefaultConsumerPomBuilder.hasDependencyScope(dep("compile")), "compile should be kept");
assertFalse(DefaultConsumerPomBuilder.hasDependencyScope(dep("runtime")), "runtime should be kept");
assertFalse(DefaultConsumerPomBuilder.hasDependencyScope(dep("api")), "api should be kept");
assertFalse(
DefaultConsumerPomBuilder.hasDependencyScope(dep("implementation")), "implementation should be kept");
assertFalse(
DefaultConsumerPomBuilder.hasDependencyScope(dep(null)),
"unscoped (defaults to compile) should be kept");
assertFalse(
DefaultConsumerPomBuilder.hasDependencyScope(dep("")),
"empty scope (defaults to compile) should be kept");
// provided/test/system should be STRIPPED (returns true)
assertTrue(DefaultConsumerPomBuilder.hasDependencyScope(dep("provided")), "provided should be stripped");
assertTrue(DefaultConsumerPomBuilder.hasDependencyScope(dep("test")), "test should be stripped");
assertTrue(DefaultConsumerPomBuilder.hasDependencyScope(dep("system")), "system should be stripped");
assertTrue(DefaultConsumerPomBuilder.hasDependencyScope(dep("test-only")), "test-only should be stripped");
}

@Test
void testConsumerPomScopeMappings() {
// api → compile, implementation → runtime, others unchanged
assertEquals(
"compile",
DefaultConsumerPomBuilder.mapScopeForConsumerPom(dep("api")).getScope(),
"api should map to compile in consumer POM");
assertEquals(
"runtime",
DefaultConsumerPomBuilder.mapScopeForConsumerPom(dep("implementation"))
.getScope(),
"implementation should map to runtime in consumer POM");
assertEquals(
"compile",
DefaultConsumerPomBuilder.mapScopeForConsumerPom(dep("compile")).getScope(),
"compile should remain compile");
assertEquals(
"runtime",
DefaultConsumerPomBuilder.mapScopeForConsumerPom(dep("runtime")).getScope(),
"runtime should remain runtime");
}

private static org.apache.maven.api.model.Dependency dep(String scope) {
org.apache.maven.api.model.Dependency.Builder b = org.apache.maven.api.model.Dependency.newBuilder()
.groupId("g")
.artifactId("a")
.version("1");
if (scope != null) {
b.scope(scope);
}
return b.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,8 @@ && equals(parent.getArtifactId(), model.getArtifactId())) {
}

boolean isModelVersion41OrMore = !Objects.equals(ModelBuilder.MODEL_VERSION_4_0_0, model.getModelVersion());
boolean isModelVersion42OrMore = isModelVersion41OrMore
&& !Objects.equals(ModelBuilder.MODEL_VERSION_4_1_0, model.getModelVersion());
if (isModelVersion41OrMore) {
validateStringNoExpression("groupId", problems, Severity.FATAL, Version.V41, model.getGroupId(), model);

Expand Down Expand Up @@ -547,6 +549,7 @@ && equals(parent.getArtifactId(), model.getArtifactId())) {
"dependencies.dependency.",
EMPTY,
isModelVersion41OrMore,
isModelVersion42OrMore,
validationLevel);

validate20RawDependenciesSelfReferencing(
Expand All @@ -559,6 +562,7 @@ && equals(parent.getArtifactId(), model.getArtifactId())) {
"dependencyManagement.dependencies.dependency.",
EMPTY,
isModelVersion41OrMore,
isModelVersion42OrMore,
validationLevel);
}

Expand Down Expand Up @@ -603,6 +607,7 @@ && equals(parent.getArtifactId(), model.getArtifactId())) {
prefix,
"dependencies.dependency.",
isModelVersion41OrMore,
isModelVersion42OrMore,
validationLevel);

if (profile.getDependencyManagement() != null) {
Expand All @@ -612,6 +617,7 @@ && equals(parent.getArtifactId(), model.getArtifactId())) {
prefix,
"dependencyManagement.dependencies.dependency.",
isModelVersion41OrMore,
isModelVersion42OrMore,
validationLevel);
}

Expand Down Expand Up @@ -1183,6 +1189,7 @@ private void validate20RawDependencies(
String prefix,
String prefix2,
boolean is41OrBeyond,
boolean is42OrBeyond,
int validationLevel) {
Severity errOn30 = getSeverity(validationLevel, ModelValidator.VALIDATION_LEVEL_MAVEN_3_0);
Severity errOn31 = getSeverity(validationLevel, ModelValidator.VALIDATION_LEVEL_MAVEN_3_1);
Expand Down Expand Up @@ -1270,6 +1277,22 @@ private void validate20RawDependencies(
dependency);
}
}
// MNG-8099: api and implementation scopes require modelVersion 4.2.0+
if (!is42OrBeyond) {
String scope = dependency.getScope();
if (DependencyScope.API.id().equals(scope)
|| DependencyScope.IMPLEMENTATION.id().equals(scope)) {
addViolation(
problems,
Severity.ERROR,
Version.V20,
prefix + prefix2 + "scope",
SourceHint.dependencyManagementKey(dependency),
"scope '" + scope + "' is not supported with modelVersion 4.0.0 or 4.1.0; "
+ "use modelVersion 4.2.0 or remove this scope.",
dependency);
}
}

if (equals("LATEST", dependency.getVersion()) || equals("RELEASE", dependency.getVersion())) {
addViolation(
Expand Down
Loading
Loading