From 3a87e8486d2c5adaf376bbf3640ad07fc3d236f1 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Sat, 21 Feb 2026 14:48:06 +0100 Subject: [PATCH 1/2] Move some of the codes needed by `ProjectSourcesHelper` in an utility class that we can reuse in other packages. Co-authored-by: Gerd Aschemann --- .../maven/project/DefaultProjectBuilder.java | 38 +----- .../maven/project/SourceHandlingContext.java | 116 ++++++++++-------- .../apache/maven/project/SourceQueries.java | 84 +++++++++++++ 3 files changed, 155 insertions(+), 83 deletions(-) create mode 100644 impl/maven-core/src/main/java/org/apache/maven/project/SourceQueries.java diff --git a/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java b/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java index 7b13a9c12a4e..cdddbdaafe13 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java +++ b/impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java @@ -655,7 +655,6 @@ private void initProject(MavenProject project, ModelBuilderResult result) { // only set those on 2nd phase, ignore on 1st pass if (project.getFile() != null) { Build build = project.getBuild().getDelegate(); - List sources = build.getSources(); Path baseDir = project.getBaseDirectory(); Function outputDirectory = (scope) -> { if (scope == ProjectScope.MAIN) { @@ -666,23 +665,11 @@ private void initProject(MavenProject project, ModelBuilderResult result) { return build.getDirectory(); } }; - // Extract modules from sources to detect modular projects - Set modules = extractModules(sources); - boolean isModularProject = !modules.isEmpty(); - - logger.trace( - "Module detection for project {}: found {} module(s) {} - modular project: {}.", - project.getId(), - modules.size(), - modules, - isModularProject); - // Create source handling context for unified tracking of all lang/scope combinations - SourceHandlingContext sourceContext = - new SourceHandlingContext(project, baseDir, modules, isModularProject, result); + final SourceHandlingContext sourceContext = new SourceHandlingContext(project, result); // Process all sources, tracking enabled ones and detecting duplicates - for (var source : sources) { + for (org.apache.maven.api.model.Source source : sourceContext.sources) { var sourceRoot = DefaultSourceRoot.fromModel(session, baseDir, outputDirectory, source); // Track enabled sources for duplicate detection and hasSources() queries // Only add source if it's not a duplicate enabled source (first enabled wins) @@ -711,7 +698,7 @@ private void initProject(MavenProject project, ModelBuilderResult result) { implicit fallback (only if they match the default, e.g., inherited) - This allows incremental adoption (e.g., custom resources + default Java) */ - if (sources.isEmpty()) { + if (sourceContext.sources.isEmpty()) { // Classic fallback: no configured, use legacy directories project.addScriptSourceRoot(build.getScriptSourceDirectory()); project.addCompileSourceRoot(build.getSourceDirectory()); @@ -724,8 +711,7 @@ implicit fallback (only if they match the default, e.g., inherited) if (!sourceContext.hasSources(Language.SCRIPT, ProjectScope.MAIN)) { project.addScriptSourceRoot(build.getScriptSourceDirectory()); } - - if (isModularProject) { + if (sourceContext.usesModuleSourceHierarchy()) { // Modular: reject ALL legacy directory configurations failIfLegacyDirectoryPresent( build.getSourceDirectory(), @@ -1243,22 +1229,6 @@ public Set> entrySet() { } } - /** - * Extracts unique module names from the given list of source elements. - * A project is considered modular if it has at least one module name. - * - * @param sources list of source elements from the build - * @return set of non-blank module names - */ - private static Set extractModules(List sources) { - return sources.stream() - .map(org.apache.maven.api.model.Source::getModule) - .filter(Objects::nonNull) - .map(String::trim) - .filter(s -> !s.isBlank()) - .collect(Collectors.toSet()); - } - private Model injectLifecycleBindings( Model model, ModelBuilderRequest request, diff --git a/impl/maven-core/src/main/java/org/apache/maven/project/SourceHandlingContext.java b/impl/maven-core/src/main/java/org/apache/maven/project/SourceHandlingContext.java index 400f9f5dc07d..1d7eb393e692 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/project/SourceHandlingContext.java +++ b/impl/maven-core/src/main/java/org/apache/maven/project/SourceHandlingContext.java @@ -27,6 +27,7 @@ import org.apache.maven.api.ProjectScope; import org.apache.maven.api.SourceRoot; import org.apache.maven.api.model.Resource; +import org.apache.maven.api.model.Source; import org.apache.maven.api.services.BuilderProblem.Severity; import org.apache.maven.api.services.ModelBuilderResult; import org.apache.maven.api.services.ModelProblem.Version; @@ -37,9 +38,7 @@ /** * Handles source configuration for Maven projects with unified tracking for all language/scope combinations. - *

- * This class replaces the previous approach of hardcoded boolean flags (hasMain, hasTest, etc.) - * with a flexible set-based tracking mechanism that works for any language and scope combination. + * This class uses a flexible set-based tracking mechanism that works for any language and scope combination. *

* Key features: *

    @@ -51,7 +50,7 @@ * * @since 4.0.0 */ -class SourceHandlingContext { +final class SourceHandlingContext { private static final Logger LOGGER = LoggerFactory.getLogger(SourceHandlingContext.class); @@ -60,26 +59,38 @@ class SourceHandlingContext { */ record SourceKey(Language language, ProjectScope scope, String module, Path directory) {} + /** + * The {@code } elements declared in the {@code } elements. + */ + final List sources; + private final MavenProject project; - private final Path baseDir; private final Set modules; - private final boolean modularProject; private final ModelBuilderResult result; private final Set declaredSources; - SourceHandlingContext( - MavenProject project, - Path baseDir, - Set modules, - boolean modularProject, - ModelBuilderResult result) { + SourceHandlingContext(MavenProject project, ModelBuilderResult result) { this.project = project; - this.baseDir = baseDir; - this.modules = modules; - this.modularProject = modularProject; + this.sources = project.getBuild().getDelegate().getSources(); + this.modules = SourceQueries.getModuleNames(sources); this.result = result; // Each module typically has main, test, main resources, test resources = 4 sources this.declaredSources = new HashSet<>(4 * modules.size()); + if (usesModuleSourceHierarchy()) { + LOGGER.trace("Found {} module(s) in the \"{}\" project: {}.", project.getId(), modules.size(), modules); + } else { + LOGGER.trace("Project \"{}\" is non-modular.", project.getId()); + } + } + + /** + * Whether the project uses module source hierarchy. + * Note that this is not synonymous of whether the project is modular, + * because it is possible to create a single Java module in a classic Maven project + * (i.e., using package hierarchy). + */ + boolean usesModuleSourceHierarchy() { + return !modules.isEmpty(); } /** @@ -112,7 +123,7 @@ boolean shouldAddSource(SourceRoot sourceRoot) { SourceKey key = new SourceKey( sourceRoot.language(), sourceRoot.scope(), sourceRoot.module().orElse(null), normalizedDir); - if (declaredSources.contains(key)) { + if (!declaredSources.add(key)) { String message = String.format( "Duplicate enabled source detected: lang=%s, scope=%s, module=%s, directory=%s. " + "First enabled source wins, this duplicate is ignored.", @@ -130,7 +141,6 @@ boolean shouldAddSource(SourceRoot sourceRoot) { return false; // Don't add duplicate enabled source } - declaredSources.add(key); LOGGER.debug( "Adding and tracking enabled source: lang={}, scope={}, module={}, dir={}", key.language(), @@ -151,6 +161,13 @@ boolean hasSources(Language language, ProjectScope scope) { return declaredSources.stream().anyMatch(key -> language.equals(key.language()) && scope.equals(key.scope())); } + /** + * {@return the source directory as defined by Maven conventions} + */ + private Path getStandardSourceDirectory() { + return project.getBaseDirectory().resolve("src"); + } + /** * Fails the build if modular and classic (non-modular) sources are mixed within {@code }. *

    @@ -164,30 +181,32 @@ boolean hasSources(Language language, ProjectScope scope) { void failIfMixedModularAndClassicSources() { for (ProjectScope scope : List.of(ProjectScope.MAIN, ProjectScope.TEST)) { for (Language language : List.of(Language.JAVA_FAMILY, Language.RESOURCES)) { - boolean hasModular = declaredSources.stream() - .anyMatch(key -> - language.equals(key.language()) && scope.equals(key.scope()) && key.module() != null); - boolean hasClassic = declaredSources.stream() - .anyMatch(key -> - language.equals(key.language()) && scope.equals(key.scope()) && key.module() == null); - - if (hasModular && hasClassic) { - String message = String.format( - "Mixed modular and classic sources detected for lang=%s, scope=%s. " - + "A project must be either fully modular (all sources have a module) " - + "or fully classic (no sources have a module). " - + "The compiler plugin cannot handle mixed configurations.", - language.id(), scope.id()); - LOGGER.error(message); - result.getProblemCollector() - .reportProblem(new DefaultModelProblem( - message, - Severity.ERROR, - Version.V41, - project.getModel().getDelegate(), - -1, - -1, - null)); + boolean hasModular = false; + boolean hasClassic = false; + for (SourceKey key : declaredSources) { + if (language.equals(key.language()) && scope.equals(key.scope())) { + String module = key.module(); + hasModular |= (module != null); + hasClassic |= (module == null); + if (hasModular && hasClassic) { + String message = String.format( + "Mixed modular and classic sources detected for lang=%s, scope=%s. " + + "A project must be either fully modular (all sources have a module) " + + "or fully classic (no sources have a module).", + language.id(), scope.id()); + LOGGER.error(message); + result.getProblemCollector() + .reportProblem(new DefaultModelProblem( + message, + Severity.ERROR, + Version.V41, + project.getModel().getDelegate(), + -1, + -1, + null)); + break; + } + } } } } @@ -219,7 +238,7 @@ void handleResourceConfiguration(ProjectScope scope) { ? "resources" : "resourcestest"; - if (modularProject) { + if (usesModuleSourceHierarchy()) { if (hasResourcesInSources) { // Modular project with resources configured via - already added above if (hasExplicitLegacyResources(resources, scopeId)) { @@ -298,6 +317,7 @@ void handleResourceConfiguration(ProjectScope scope) { // Use legacy resources element LOGGER.debug( "Using explicit or default {} resources ({} resources configured).", scopeId, resources.size()); + Path baseDir = project.getBaseDirectory(); for (Resource resource : resources) { project.addSourceRoot(new DefaultSourceRoot(baseDir, scope, resource)); } @@ -315,7 +335,7 @@ void handleResourceConfiguration(ProjectScope scope) { */ private DefaultSourceRoot createModularResourceRoot(String module, ProjectScope scope) { Path resourceDir = - baseDir.resolve("src").resolve(module).resolve(scope.id()).resolve("resources"); + getStandardSourceDirectory().resolve(module).resolve(scope.id()).resolve("resources"); return new DefaultSourceRoot( scope, @@ -345,12 +365,10 @@ private boolean hasExplicitLegacyResources(List resources, String scop } // Super POM default paths - String defaultPath = - baseDir.resolve("src").resolve(scope).resolve("resources").toString(); - String defaultFilteredPath = baseDir.resolve("src") - .resolve(scope) - .resolve("resources-filtered") - .toString(); + Path srcDir = getStandardSourceDirectory(); + String defaultPath = srcDir.resolve(scope).resolve("resources").toString(); + String defaultFilteredPath = + srcDir.resolve(scope).resolve("resources-filtered").toString(); // Check if any resource differs from Super POM defaults for (Resource resource : resources) { diff --git a/impl/maven-core/src/main/java/org/apache/maven/project/SourceQueries.java b/impl/maven-core/src/main/java/org/apache/maven/project/SourceQueries.java new file mode 100644 index 000000000000..41759d104954 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/project/SourceQueries.java @@ -0,0 +1,84 @@ +/* + * 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.project; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +import org.apache.maven.api.model.Source; + +/** + * Static utility methods for analyzing {@code } elements of a project. + *

    + * Warning: This is an internal utility class, not part of the public API. + * It can be changed or removed without prior notice. + * + * @since 4.0.0 + */ +public final class SourceQueries { + private SourceQueries() {} + + /** + * Returns whether at least one source in the collection has a non-blank module name, + * indicating a modular source hierarchy. + * + * @param sources the source elements to check + * @return {@code true} if at least one source declares a module + */ + public static boolean usesModuleSourceHierarchy(Collection sources) { + return sources.stream().map(Source::getModule).filter(Objects::nonNull).anyMatch(s -> !s.isBlank()); + } + + /** + * Returns whether at least one source in the collection is enabled. + * + * @param sources the source elements to check + * @return {@code true} if at least one source is enabled + */ + public static boolean hasEnabledSources(Collection sources) { + for (Source source : sources) { + if (source.isEnabled()) { + return true; + } + } + return false; + } + + /** + * Extracts unique, non-blank module names from the source elements, preserving declaration order. + * The following relationship should always be true: + * + *

    getModuleNames(sources).isEmpty() == !usesModuleSourceHierarchy(sources)
    + * + * @param sources the source elements to extract module names from + * @return set of non-blank module names in declaration order + */ + public static Set getModuleNames(Collection sources) { + var modules = new LinkedHashSet(); + sources.stream() + .map(Source::getModule) + .filter(Objects::nonNull) + .map(String::strip) + .filter(s -> !s.isEmpty()) + .forEach(modules::add); + return modules; + } +} From 9e4989a617497406b49e3099ca456f0352e05800 Mon Sep 17 00:00:00 2001 From: Martin Desruisseaux Date: Sat, 21 Feb 2026 15:15:36 +0100 Subject: [PATCH 2/2] Consumer POM of multi-module project should exclude and elements. --- .../impl/ConsumerPomArtifactTransformer.java | 2 +- .../impl/DefaultConsumerPomBuilder.java | 26 ++++++-- .../impl/ConsumerPomBuilderTest.java | 65 ++++++++++++------- .../resources/consumer/multi-module/pom.xml | 41 ++++++++++++ 4 files changed, 105 insertions(+), 29 deletions(-) create mode 100644 impl/maven-core/src/test/resources/consumer/multi-module/pom.xml diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/ConsumerPomArtifactTransformer.java b/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/ConsumerPomArtifactTransformer.java index 9444f972a9be..f32c96288657 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/ConsumerPomArtifactTransformer.java +++ b/impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/ConsumerPomArtifactTransformer.java @@ -96,7 +96,7 @@ public void injectTransformedArtifacts(RepositorySystemSession session, MavenPro } } - TransformedArtifact createConsumerPomArtifact( + private TransformedArtifact createConsumerPomArtifact( MavenProject project, Path consumer, RepositorySystemSession session) { Path actual = project.getFile().toPath(); Path parent = project.getBaseDirectory(); 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 c46d3d5b6d31..27272ddeb874 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 @@ -37,6 +37,7 @@ import org.apache.maven.api.model.DistributionManagement; import org.apache.maven.api.model.Model; import org.apache.maven.api.model.ModelBase; +import org.apache.maven.api.model.Parent; import org.apache.maven.api.model.Profile; import org.apache.maven.api.model.Repository; import org.apache.maven.api.model.Scm; @@ -50,6 +51,7 @@ import org.apache.maven.impl.InternalSession; import org.apache.maven.model.v4.MavenModelVersion; import org.apache.maven.project.MavenProject; +import org.apache.maven.project.SourceQueries; import org.eclipse.aether.RepositorySystemSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -342,7 +344,7 @@ static Model transformNonPom(Model model, MavenProject project) { return model; } - static Model transformBom(Model model, MavenProject project) { + private static Model transformBom(Model model, MavenProject project) { boolean preserveModelVersion = model.isPreserveModelVersion(); Model.Builder builder = prune( @@ -369,11 +371,25 @@ static Model transformPom(Model model, MavenProject project) { // raw to consumer transform model = model.withRoot(false).withModules(null).withSubprojects(null); - if (model.getParent() != null) { - model = model.withParent(model.getParent().withRelativePath(null)); + Parent parent = model.getParent(); + if (parent != null) { + model = model.withParent(parent.withRelativePath(null)); + } + var projectSources = project.getBuild().getDelegate().getSources(); + if (SourceQueries.usesModuleSourceHierarchy(projectSources)) { + // Dependencies are dispatched by maven-jar-plugin in the POM generated for each module. + model = model.withDependencies(null).withPackaging(POM_PACKAGING); } - if (!preserveModelVersion) { + /* + * If tne contains elements, it is not compatible with the Maven 4.0.0 model. + * Remove the full element instead of removing only the element, because the + * build without sources does not mean much. Reminder: this removal can be disabled by setting + * the `preserveModelVersion` XML attribute or `preserve.model.version` property to true. + */ + if (SourceQueries.hasEnabledSources(projectSources)) { + model = model.withBuild(null); + } model = model.withPreserveModelVersion(false); String modelVersion = new MavenModelVersion().getModelVersion(model); model = model.withModelVersion(modelVersion); @@ -381,7 +397,7 @@ static Model transformPom(Model model, MavenProject project) { return model; } - static void warnNotDowngraded(MavenProject project) { + private static void warnNotDowngraded(MavenProject project) { LOGGER.warn("The consumer POM for " + project.getId() + " cannot be downgraded to 4.0.0. " + "If you intent your build to be consumed with Maven 3 projects, you need to remove " + "the features that request a newer model version. If you're fine with having the " 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 11dc8cd9c7ef..7862298b3310 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 @@ -51,6 +51,7 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -88,15 +89,22 @@ protected List getSessionServices() { return services; } - @Test - void testTrivialConsumer() throws Exception { - InternalMavenSession.from(InternalSession.from(session)) + /** + * Configures {@link #session} with the root directory of a test in {@code src/test/resources/consumer}. + * Returns the request in case the caller wants to apply more configuration. + */ + private MavenExecutionRequest setRootDirectory(String test) { + MavenExecutionRequest request = InternalMavenSession.from(InternalSession.from(session)) .getMavenSession() - .getRequest() - .setRootDirectory(Paths.get("src/test/resources/consumer/trivial")); - - Path file = Paths.get("src/test/resources/consumer/trivial/child/pom.xml"); + .getRequest(); + request.setRootDirectory(Paths.get("src/test/resources/consumer", test)); + return request; + } + /** + * Builds the effective model for the given {@code pom.xml} file. + */ + private MavenProject getEffectiveModel(Path file) { ModelBuilder.ModelBuilderSession mbs = modelBuilder.newSession(); InternalSession.from(session).getData().set(SessionData.key(ModelBuilder.ModelBuilderSession.class), mbs); Model orgModel = mbs.build(ModelBuilderRequest.builder() @@ -108,39 +116,50 @@ void testTrivialConsumer() throws Exception { MavenProject project = new MavenProject(orgModel); project.setOriginalModel(new org.apache.maven.model.Model(orgModel)); + return project; + } + + @Test + void testTrivialConsumer() throws Exception { + setRootDirectory("trivial"); + Path file = Paths.get("src/test/resources/consumer/trivial/child/pom.xml"); + + MavenProject project = getEffectiveModel(file); Model model = builder.build(session, project, Sources.buildSource(file)); assertNotNull(model); + assertNotNull(model.getDependencies()); } @Test void testSimpleConsumer() throws Exception { - MavenExecutionRequest request = InternalMavenSession.from(InternalSession.from(session)) - .getMavenSession() - .getRequest(); - request.setRootDirectory(Paths.get("src/test/resources/consumer/simple")); + MavenExecutionRequest request = setRootDirectory("simple"); request.getUserProperties().setProperty("changelist", "MNG6957"); - Path file = Paths.get("src/test/resources/consumer/simple/simple-parent/simple-weather/pom.xml"); - ModelBuilder.ModelBuilderSession mbs = modelBuilder.newSession(); - InternalSession.from(session).getData().set(SessionData.key(ModelBuilder.ModelBuilderSession.class), mbs); - Model orgModel = mbs.build(ModelBuilderRequest.builder() - .session(InternalSession.from(session)) - .source(Sources.buildSource(file)) - .requestType(ModelBuilderRequest.RequestType.BUILD_PROJECT) - .build()) - .getEffectiveModel(); - - MavenProject project = new MavenProject(orgModel); - project.setOriginalModel(new org.apache.maven.model.Model(orgModel)); + MavenProject project = getEffectiveModel(file); request.setRootDirectory(Paths.get("src/test/resources/consumer/simple")); Model model = builder.build(session, project, Sources.buildSource(file)); assertNotNull(model); + assertFalse(model.getDependencies().isEmpty()); assertTrue(model.getProfiles().isEmpty()); } + @Test + void testMultiModuleConsumer() throws Exception { + setRootDirectory("multi-module"); + Path file = Paths.get("src/test/resources/consumer/multi-module/pom.xml"); + + MavenProject project = getEffectiveModel(file); + Model model = builder.build(session, project, Sources.buildSource(file)); + + assertNotNull(model); + assertNull(model.getBuild()); + assertTrue(model.getDependencies().isEmpty()); + assertFalse(model.getDependencyManagement().getDependencies().isEmpty()); + } + @Test void testScmInheritance() throws Exception { Model model = Model.newBuilder() diff --git a/impl/maven-core/src/test/resources/consumer/multi-module/pom.xml b/impl/maven-core/src/test/resources/consumer/multi-module/pom.xml new file mode 100644 index 000000000000..972e7c9be2b4 --- /dev/null +++ b/impl/maven-core/src/test/resources/consumer/multi-module/pom.xml @@ -0,0 +1,41 @@ + + org.my.group + parent + 1.0-SNAPSHOT + pom + + + + + org.slf4j + slf4j-api + 2.0.9 + + + + + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter-api + 5.10.1 + test + + + + + + + org.foo + + + org.foo.bar + + + + +