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..dc1e0c3650c8 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 @@ -62,9 +62,22 @@ public enum DependencyScope { COMPILE_ONLY("compile-only", false), /** - * Compile, runtime and test. + * Compile, runtime and test (non-transitive). + *

+ * Note: If a project uses this scope and doesn't explicitly opt-in to + * Maven 4 modelVersion (e.g., 4.1.0) through other features, the generated + * consumer POM may be downgraded to 4.0.0. In this case, downstream resolvers + * will remap this scope to {@code api} for backward compatibility, making it + * behave transitively again. To enforce non-transitive behavior, the project + * must ensure its modelVersion is preserved (e.g., via {@code preserveModelVersion=true}). + *

*/ - COMPILE("compile", true), + COMPILE("compile", false), + + /** + * Compile, runtime and test, transitively. + */ + API("api", true), /** * 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..16188f23ca15 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,17 @@ public interface PathScope extends ExtensibleEnum { ProjectScope.MAIN, DependencyScope.COMPILE_ONLY, DependencyScope.COMPILE, + DependencyScope.API, 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.RUNTIME); PathScope TEST_COMPILE = pathScope( "test-compile", ProjectScope.TEST, DependencyScope.COMPILE, + DependencyScope.API, DependencyScope.PROVIDED, DependencyScope.TEST_ONLY, DependencyScope.TEST); @@ -75,6 +77,7 @@ public interface PathScope extends ExtensibleEnum { "test-runtime", ProjectScope.TEST, DependencyScope.COMPILE, + DependencyScope.API, 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..7a9618e77267 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,57 @@ void testV4ModelPriority() { PluginExecution.newInstance().withPriority(5)))))); assertEquals("4.0.0", new MavenModelVersion().getModelVersion(m)); } + + @Test + void testApiScopeDependencyRequires410() { + // A model with an api-scoped dependency should require modelVersion 4.1.0 + Model m = model.withDependencies(List.of(Dependency.newBuilder() + .groupId("org.example") + .artifactId("api-lib") + .version("1.0") + .scope("api") + .build())); + assertEquals("4.1.0", new MavenModelVersion().getModelVersion(m)); + } + + @Test + void testApiScopeInDependencyManagementRequires410() { + // A model with an api-scoped dependency in dependencyManagement should require 4.1.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.1.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 testApiScopeInProfileRequires410() { + // A model with an api-scoped dependency in a profile should require 4.1.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.1.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..8cec4de3667d 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,8 @@ 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.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 5aba71386686..2bc05fe854fd 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 @@ -257,7 +257,7 @@ private Model buildEffectiveModel(RepositorySystemSession session, MavenProject } return dependency; }); - // Only keep transitive scopes (null/empty => COMPILE) + // Only keep consumer-visible scopes (compile, api, runtime) directDependencies.values().removeIf(DefaultConsumerPomBuilder::hasDependencyScope); managedDependencies.keySet().removeAll(directDependencies.keySet()); @@ -275,7 +275,7 @@ private Model buildEffectiveModel(RepositorySystemSession session, MavenProject Function.identity(), this::merge, LinkedHashMap::new)); - // Only keep transitive scopes + // Only keep consumer-visible scopes (compile, api, runtime) directDependencies.values().removeIf(DefaultConsumerPomBuilder::hasDependencyScope); model = model.withDependencies(directDependencies.isEmpty() ? null : directDependencies.values()); } @@ -291,7 +291,7 @@ 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; } 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 76a9752f5506..e87a97bc8020 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 @@ -276,4 +276,108 @@ void testConsumerPomPassesProjectRepositoriesToModelBuilder() throws Exception { consumerRequest.getRepositories().stream().anyMatch(r -> "custom-repo".equals(r.getId())); assertTrue(hasCustomRepo, "Consumer POM model builder request should include the project's custom repository"); } + + @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 testConsumerPomStripsProvidedTestSystemDeps() throws Exception { + // Consumer POMs must strip provided, test, and system 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 providedDep = org.apache.maven.api.model.Dependency.newBuilder() + .groupId("g") + .artifactId("provided-dep") + .version("1") + .scope("provided") + .build(); + org.apache.maven.api.model.Dependency testDep = org.apache.maven.api.model.Dependency.newBuilder() + .groupId("g") + .artifactId("test-dep") + .version("1") + .scope("test") + .build(); + org.apache.maven.api.model.Dependency systemDep = org.apache.maven.api.model.Dependency.newBuilder() + .groupId("g") + .artifactId("system-dep") + .version("1") + .scope("system") + .build(); + + Model model = Model.newBuilder() + .groupId("test") + .artifactId("test") + .version("1.0") + .dependencies(List.of(compileDep, providedDep, testDep, systemDep)) + .build(); + + Model transformed = DefaultConsumerPomBuilder.transformNonPom(model, null); + assertNotNull(transformed.getDependencies()); + // compile should be retained + assertTrue( + transformed.getDependencies().stream().anyMatch(d -> "compile-dep".equals(d.getArtifactId())), + "compile-scoped dep should be retained"); + // provided, test, system should be stripped + assertFalse( + transformed.getDependencies().stream().anyMatch(d -> "provided-dep".equals(d.getArtifactId())), + "provided-scoped dep should be stripped"); + assertFalse( + transformed.getDependencies().stream().anyMatch(d -> "test-dep".equals(d.getArtifactId())), + "test-scoped dep should be stripped"); + assertFalse( + transformed.getDependencies().stream().anyMatch(d -> "system-dep".equals(d.getArtifactId())), + "system-scoped dep should be stripped"); + } } 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 0c8739a22ade..6211f14d947b 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 @@ -1239,7 +1239,8 @@ private void validate20RawDependencies( String scope = dependency.getScope(); if (DependencyScope.COMPILE_ONLY.id().equals(scope) || DependencyScope.TEST_ONLY.id().equals(scope) - || DependencyScope.TEST_RUNTIME.id().equals(scope)) { + || DependencyScope.TEST_RUNTIME.id().equals(scope) + || DependencyScope.API.id().equals(scope)) { addViolation( problems, Severity.ERROR, diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultArtifactDescriptorReader.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultArtifactDescriptorReader.java index 4283cfceaec2..433f3cfb5b34 100644 --- a/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultArtifactDescriptorReader.java +++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/resolver/DefaultArtifactDescriptorReader.java @@ -345,6 +345,12 @@ private int getPolicy(RepositorySystemSession session, Artifact a, ArtifactDescr private void populateResult(InternalSession session, ArtifactDescriptorResult result, Model model) { ArtifactTypeRegistry stereotypes = session.getSession().getArtifactTypeRegistry(); + // Compute once whether compile-scoped dependencies should be remapped to api (transitive) + // for backward compatibility. Use the declared modelVersion rather than feature detection + // to respect the developer's explicit intent. + String declaredModelVersion = model.getModelVersion(); + boolean remapCompileToApi = declaredModelVersion == null || declaredModelVersion.startsWith("4.0."); + for (Repository repository : model.getRepositories()) { result.addRepository(session.toRepository( session.getService(RepositoryFactory.class).createRemote(repository))); @@ -355,7 +361,7 @@ private void populateResult(InternalSession session, ArtifactDescriptorResult re logger.debug("Filtered dependency with uninterpolated expression: {}", dependency); continue; } - result.addDependency(convert(dependency, stereotypes)); + result.addDependency(convert(dependency, stereotypes, remapCompileToApi)); } DependencyManagement dependencyManagement = model.getDependencyManagement(); @@ -365,7 +371,7 @@ private void populateResult(InternalSession session, ArtifactDescriptorResult re logger.debug("Filtered managed dependency with uninterpolated expression: {}", dependency); continue; } - result.addManagedDependency(convert(dependency, stereotypes)); + result.addManagedDependency(convert(dependency, stereotypes, remapCompileToApi)); } } @@ -391,7 +397,10 @@ private void populateResult(InternalSession session, ArtifactDescriptorResult re setArtifactProperties(result, model); } - private Dependency convert(org.apache.maven.api.model.Dependency dependency, ArtifactTypeRegistry stereotypes) { + private Dependency convert( + org.apache.maven.api.model.Dependency dependency, + ArtifactTypeRegistry stereotypes, + boolean remapCompileToApi) { ArtifactType stereotype = stereotypes.get(dependency.getType()); if (stereotype == null) { stereotype = new DefaultType(dependency.getType(), Language.NONE, dependency.getType(), null, false) @@ -420,11 +429,13 @@ private Dependency convert(org.apache.maven.api.model.Dependency dependency, Art exclusions.add(convert(exclusion)); } + String scope = dependency.getScope() != null ? dependency.getScope() : ""; + if (remapCompileToApi && ("compile".equals(scope) || scope.isEmpty())) { + scope = "api"; + } + return new Dependency( - artifact, - dependency.getScope(), - dependency.getOptional() != null ? dependency.isOptional() : null, - exclusions); + artifact, scope, dependency.getOptional() != null ? dependency.isOptional() : null, exclusions); } private Exclusion convert(org.apache.maven.api.model.Exclusion exclusion) { 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..8c64bc31f00c 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,8 @@ 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.RUNTIME.id(), DependencyScope.RUNTIME.isTransitive(), diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/DefaultArtifactDescriptorReaderTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/DefaultArtifactDescriptorReaderTest.java new file mode 100644 index 000000000000..d5051078787b --- /dev/null +++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/resolver/DefaultArtifactDescriptorReaderTest.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.impl.resolver; + +import java.lang.reflect.Method; +import java.util.Collections; + +import org.apache.maven.api.model.Dependency; +import org.apache.maven.api.services.ModelBuilder; +import org.eclipse.aether.artifact.ArtifactType; +import org.eclipse.aether.artifact.ArtifactTypeRegistry; +import org.eclipse.aether.artifact.DefaultArtifactType; +import org.eclipse.aether.impl.ArtifactResolver; +import org.eclipse.aether.impl.RepositoryEventDispatcher; +import org.eclipse.aether.impl.VersionResolver; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DefaultArtifactDescriptorReaderTest { + + @Test + void testRemapCompileToApi() throws Exception { + // Create an instance of DefaultArtifactDescriptorReader + DefaultArtifactDescriptorReader reader = new DefaultArtifactDescriptorReader( + Mockito.mock(VersionResolver.class), + Mockito.mock(ArtifactResolver.class), + Mockito.mock(ModelBuilder.class), + Mockito.mock(RepositoryEventDispatcher.class), + Collections.emptyMap()); + + // Get the private convert() method via reflection + Method convertMethod = DefaultArtifactDescriptorReader.class.getDeclaredMethod( + "convert", Dependency.class, ArtifactTypeRegistry.class, boolean.class); + convertMethod.setAccessible(true); + + // Mock ArtifactTypeRegistry + ArtifactTypeRegistry stereotypes = new ArtifactTypeRegistry() { + @Override + public ArtifactType get(String typeId) { + return new DefaultArtifactType(typeId, "jar", "", "java"); + } + }; + + // 1. Test remapCompileToApi = true, scope = compile -> api + Dependency depCompile = Dependency.newBuilder() + .groupId("g") + .artifactId("a") + .version("1") + .type("jar") + .scope("compile") + .build(); + org.eclipse.aether.graph.Dependency aetherDep1 = + (org.eclipse.aether.graph.Dependency) convertMethod.invoke(reader, depCompile, stereotypes, true); + assertEquals("api", aetherDep1.getScope(), "Compile scope should be remapped to api when remap=true"); + + // 2. Test remapCompileToApi = true, scope = "" -> api + Dependency depEmpty = Dependency.newBuilder() + .groupId("g") + .artifactId("a") + .version("1") + .type("jar") + .scope("") + .build(); + org.eclipse.aether.graph.Dependency aetherDep2 = + (org.eclipse.aether.graph.Dependency) convertMethod.invoke(reader, depEmpty, stereotypes, true); + assertEquals("api", aetherDep2.getScope(), "Empty scope should be remapped to api when remap=true"); + + // 3. Test remapCompileToApi = false, scope = compile -> compile + org.eclipse.aether.graph.Dependency aetherDep3 = + (org.eclipse.aether.graph.Dependency) convertMethod.invoke(reader, depCompile, stereotypes, false); + assertEquals("compile", aetherDep3.getScope(), "Compile scope should remain compile when remap=false"); + + // 4. Test remapCompileToApi = false, scope = "" -> "" + org.eclipse.aether.graph.Dependency aetherDep4 = + (org.eclipse.aether.graph.Dependency) convertMethod.invoke(reader, depEmpty, stereotypes, false); + assertEquals("", aetherDep4.getScope(), "Empty scope should remain empty when remap=false"); + } +} diff --git a/src/mdo/model-version.vm b/src/mdo/model-version.vm index 0fcca232e44a..b7be7f1c975a 100644 --- a/src/mdo/model-version.vm +++ b/src/mdo/model-version.vm @@ -161,11 +161,31 @@ public class ${className} { #end #set ( $pfx = "||" ) #end + #if ( $v == "4_1_0" && $class.name == "Model" ) + $pfx hasApiDependency(${var}) // Dependency scope api + #end ); } #end #end + private boolean hasApiDependency(Model model) { + return hasApiDependency((ModelBase) model) + || model.getProfiles().stream().anyMatch(this::hasApiDependency); + } + + private boolean hasApiDependency(Profile profile) { + return hasApiDependency((ModelBase) profile); + } + + private boolean hasApiDependency(ModelBase model) { + return model != null + && (model.getDependencies().stream().anyMatch(dependency -> "api".equals(dependency.getScope())) + || (model.getDependencyManagement() != null + && model.getDependencyManagement().getDependencies().stream() + .anyMatch(dependency -> "api".equals(dependency.getScope())))); + } + private boolean has(String str) { return str != null; }