diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/DependencyScope.java b/api/maven-api-core/src/main/java/org/apache/maven/api/DependencyScope.java
index f9f788e00316..89e4a205eb55 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/DependencyScope.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/DependencyScope.java
@@ -66,6 +66,26 @@ public enum DependencyScope {
*/
COMPILE("compile", true),
+ /**
+ * 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 not 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.
*/
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/PathScope.java b/api/maven-api-core/src/main/java/org/apache/maven/api/PathScope.java
index bd8a6f1bf51c..42ec56cc63ed 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/PathScope.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/PathScope.java
@@ -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);
@@ -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,
diff --git a/compat/maven-model/src/test/java/org/apache/maven/model/v4/MavenModelVersionTest.java b/compat/maven-model/src/test/java/org/apache/maven/model/v4/MavenModelVersionTest.java
index 9c3555aefff7..d89b14146772 100644
--- a/compat/maven-model/src/test/java/org/apache/maven/model/v4/MavenModelVersionTest.java
+++ b/compat/maven-model/src/test/java/org/apache/maven/model/v4/MavenModelVersionTest.java
@@ -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;
@@ -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));
+ }
}
diff --git a/compat/maven-resolver-provider/src/main/java/org/apache/maven/repository/internal/scopes/Maven4ScopeManagerConfiguration.java b/compat/maven-resolver-provider/src/main/java/org/apache/maven/repository/internal/scopes/Maven4ScopeManagerConfiguration.java
index a38833076355..52e61fa9bbf6 100644
--- a/compat/maven-resolver-provider/src/main/java/org/apache/maven/repository/internal/scopes/Maven4ScopeManagerConfiguration.java
+++ b/compat/maven-resolver-provider/src/main/java/org/apache/maven/repository/internal/scopes/Maven4ScopeManagerConfiguration.java
@@ -90,6 +90,10 @@ public Collection buildDependencyScope
ArrayList 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(),
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/DefaultConsumerPomBuilder.java b/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/DefaultConsumerPomBuilder.java
index 67a869302a29..6119f9ab73d1 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/DefaultConsumerPomBuilder.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/DefaultConsumerPomBuilder.java
@@ -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(
@@ -600,15 +602,17 @@ 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()) {
@@ -616,7 +620,27 @@ private static boolean hasDependencyScope(Dependency dependency) {
} else {
scope = DependencyScope.forId(scopeId);
}
- return scope == null || !scope.isTransitive();
+ return scope != DependencyScope.COMPILE
+ && scope != DependencyScope.RUNTIME
+ && scope != DependencyScope.API
+ && scope != DependencyScope.IMPLEMENTATION;
+ }
+
+ /**
+ * Maps a 4.2.0 dependency scope to its 4.0.0 consumer POM equivalent.
+ *
+ * - {@code api} → {@code compile} (semantically identical, expresses intent)
+ * - {@code implementation} → {@code runtime} (consumers cannot compile against it, present at runtime)
+ *
+ */
+ 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) {
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomBuilderTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomBuilderTest.java
index 5f6661a66563..2d3a6ff8656e 100644
--- a/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomBuilderTest.java
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomBuilderTest.java
@@ -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();
+ }
}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelValidator.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelValidator.java
index f17ee0c8cdeb..2bffb9a7be2d 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelValidator.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelValidator.java
@@ -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);
@@ -547,6 +549,7 @@ && equals(parent.getArtifactId(), model.getArtifactId())) {
"dependencies.dependency.",
EMPTY,
isModelVersion41OrMore,
+ isModelVersion42OrMore,
validationLevel);
validate20RawDependenciesSelfReferencing(
@@ -559,6 +562,7 @@ && equals(parent.getArtifactId(), model.getArtifactId())) {
"dependencyManagement.dependencies.dependency.",
EMPTY,
isModelVersion41OrMore,
+ isModelVersion42OrMore,
validationLevel);
}
@@ -603,6 +607,7 @@ && equals(parent.getArtifactId(), model.getArtifactId())) {
prefix,
"dependencies.dependency.",
isModelVersion41OrMore,
+ isModelVersion42OrMore,
validationLevel);
if (profile.getDependencyManagement() != null) {
@@ -612,6 +617,7 @@ && equals(parent.getArtifactId(), model.getArtifactId())) {
prefix,
"dependencyManagement.dependencies.dependency.",
isModelVersion41OrMore,
+ isModelVersion42OrMore,
validationLevel);
}
@@ -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);
@@ -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(
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/scopes/Maven4ScopeManagerConfiguration.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/scopes/Maven4ScopeManagerConfiguration.java
index 896b240053f6..f88edd1ab6f0 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/scopes/Maven4ScopeManagerConfiguration.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/scopes/Maven4ScopeManagerConfiguration.java
@@ -88,6 +88,10 @@ public Collection buildDependencyScope
ArrayList 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(),
diff --git a/src/mdo/model-version.vm b/src/mdo/model-version.vm
index 0fcca232e44a..c3f67ac32ac8 100644
--- a/src/mdo/model-version.vm
+++ b/src/mdo/model-version.vm
@@ -161,11 +161,35 @@ public class ${className} {
#end
#set ( $pfx = "||" )
#end
+ #if ( $v == "4_2_0" && $class.name == "Model" )
+ $pfx hasNewScopes(${var}) // Dependency scopes api / implementation
+ #end
);
}
#end
#end
+ private boolean hasNewScopes(Model model) {
+ return hasNewScopes((ModelBase) model)
+ || model.getProfiles().stream().anyMatch(this::hasNewScopes);
+ }
+
+ private boolean hasNewScopes(Profile profile) {
+ return hasNewScopes((ModelBase) profile);
+ }
+
+ private boolean hasNewScopes(ModelBase model) {
+ return model != null
+ && (model.getDependencies().stream().anyMatch(dependency ->
+ "api".equals(dependency.getScope()) || "implementation".equals(dependency.getScope()))
+ || (model.getDependencyManagement() != null
+ && model.getDependencyManagement().getDependencies().stream()
+ .anyMatch(dependency ->
+ "api".equals(dependency.getScope())
+ || "implementation".equals(dependency.getScope()))));
+ }
+ }
+
private boolean has(String str) {
return str != null;
}