This is an automated email from the ASF dual-hosted git repository.
desruisseaux pushed a commit to branch maven-4.0.x
in repository https://gitbox.apache.org/repos/asf/maven.git
The following commit(s) were added to refs/heads/maven-4.0.x by this push:
new 3003a40663 Add module-aware resource handling for modular sources
(#11700)
3003a40663 is described below
commit 3003a40663e22335f86d74de6dd1f670cf60bbd8
Author: Martin Desruisseaux <[email protected]>
AuthorDate: Sun Mar 8 22:49:21 2026 +0100
Add module-aware resource handling for modular sources (#11700)
This is a combination of pull requests #11505, #11632 and #11702 on master
branch.
Summary:
Maven 4.x introduces a unified <sources> element that supports modular
project layouts (src/<module>/<scope>/<lang>). However, resource handling
did not follow the modular layout - resources were always loaded from
the legacy <resources> element which defaults to src/main/resources.
This change implements automatic module-aware resource injection.
- For modular projects without resource configuration in <sources>,
automatically inject resource roots following the modular layout:
src/<module>/main/resources and src/<module>/test/resources.
- Resources configured via <sources> take priority over legacy <resources>.
- Fail the build (as ModelProblem) when explicit legacy resources are
ignored.
Co-authored-by: Gerd Aschemann <[email protected]>
---
.../maven/project/DefaultProjectBuilder.java | 234 ++++++--
.../maven/project/SourceHandlingContext.java | 366 ++++++++++++
.../apache/maven/project/ProjectBuilderTest.java | 619 +++++++++++++++++++++
.../classic-sources-with-explicit-legacy/pom.xml | 30 +
.../duplicate-enabled-sources/pom.xml | 64 +++
.../modular-java-with-explicit-source-dir/pom.xml | 33 ++
.../pom.xml | 34 ++
.../pom.xml | 42 ++
.../src/main/java/.gitkeep | 0
.../src/test/java/.gitkeep | 0
.../pom.xml | 39 ++
.../project-builder/modular-sources/pom.xml | 40 ++
.../modular-with-physical-legacy/pom.xml | 41 ++
.../src/main/java/.gitkeep | 0
.../src/test/java/.gitkeep | 0
.../multiple-directories-same-module/pom.xml | 51 ++
.../pom.xml | 42 ++
.../non-modular-resources-only/pom.xml | 38 ++
.../project-builder/sources-mixed-modules/pom.xml | 49 ++
19 files changed, 1685 insertions(+), 37 deletions(-)
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 5bfa2d3f32..b2116b285b 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
@@ -27,6 +27,7 @@
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
import java.nio.file.Path;
import java.util.AbstractMap;
import java.util.ArrayList;
@@ -63,7 +64,6 @@
import org.apache.maven.api.model.Plugin;
import org.apache.maven.api.model.Profile;
import org.apache.maven.api.model.ReportPlugin;
-import org.apache.maven.api.model.Resource;
import org.apache.maven.api.services.ArtifactResolver;
import org.apache.maven.api.services.ArtifactResolverException;
import org.apache.maven.api.services.ArtifactResolverRequest;
@@ -520,7 +520,7 @@ List<ProjectBuildingResult> doBuild(List<File> pomFiles,
boolean recursive) {
return pomFiles.stream()
.map(pomFile -> build(pomFile, recursive))
.flatMap(List::stream)
- .collect(Collectors.toList());
+ .toList();
} finally {
Thread.currentThread().setContextClassLoader(oldContextClassLoader);
}
@@ -566,7 +566,7 @@ private List<ProjectBuildingResult> build(File pomFile,
boolean recursive) {
project.setCollectedProjects(results(r)
.filter(cr -> cr != r && cr.getEffectiveModel() !=
null)
.map(cr ->
projectIndex.get(cr.getEffectiveModel().getId()))
- .collect(Collectors.toList()));
+ .toList());
DependencyResolutionResult resolutionResult = null;
if (request.isResolveDependencies()) {
@@ -660,45 +660,130 @@ private void initProject(MavenProject project,
ModelBuilderResult result) {
return build.getDirectory();
}
};
- boolean hasScript = false;
- boolean hasMain = false;
- boolean hasTest = false;
+ // Extract modules from sources to detect modular projects
+ Set<String> 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);
+
+ // Process all sources, tracking enabled ones and detecting
duplicates
for (var source : sources) {
- var src = DefaultSourceRoot.fromModel(session, baseDir,
outputDirectory, source);
- project.addSourceRoot(src);
- Language language = src.language();
- if (Language.JAVA_FAMILY.equals(language)) {
- ProjectScope scope = src.scope();
- if (ProjectScope.MAIN.equals(scope)) {
- hasMain = true;
- } else {
- hasTest |= ProjectScope.TEST.equals(scope);
- }
- } else {
- hasScript |= Language.SCRIPT.equals(language);
+ 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)
+ if (sourceContext.shouldAddSource(sourceRoot)) {
+ project.addSourceRoot(sourceRoot);
}
}
+
/*
- * `sourceDirectory`, `testSourceDirectory` and
`scriptSourceDirectory`
- * are ignored if the POM file contains at least one <source>
element
- * for the corresponding scope and language. This rule exists
because
- * Maven provides default values for those elements which may
conflict
- * with user's configuration.
- */
- if (!hasScript) {
+ Source directory handling depends on project type and
<sources> configuration:
+
+ 1. CLASSIC projects (no <sources>):
+ - All legacy directories are used
+
+ 2. MODULAR projects (have <module> in <sources>):
+ - ALL legacy directories cause the build to fail (cannot
dispatch
+ between modules)
+ - The build also fails if default directories
(src/main/java)
+ physically exist on the filesystem
+
+ 3. NON-MODULAR projects with <sources>:
+ - Explicit legacy directories (differ from default)
always cause
+ the build to fail
+ - Legacy directories for scopes where <sources> defines
Java are ignored
+ - Legacy directories for scopes where <sources> has no
Java serve as
+ implicit fallback (only if they match the default,
e.g., inherited)
+ - This allows incremental adoption (e.g., custom
resources + default Java)
+ */
+ if (sources.isEmpty()) {
+ // Classic fallback: no <sources> configured, use legacy
directories
project.addScriptSourceRoot(build.getScriptSourceDirectory());
- }
- if (!hasMain) {
project.addCompileSourceRoot(build.getSourceDirectory());
- }
- if (!hasTest) {
project.addTestCompileSourceRoot(build.getTestSourceDirectory());
- }
- for (Resource resource :
project.getBuild().getDelegate().getResources()) {
- project.addSourceRoot(new DefaultSourceRoot(baseDir,
ProjectScope.MAIN, resource));
- }
- for (Resource resource :
project.getBuild().getDelegate().getTestResources()) {
- project.addSourceRoot(new DefaultSourceRoot(baseDir,
ProjectScope.TEST, resource));
+ // Handle resources using legacy configuration
+
sourceContext.handleResourceConfiguration(ProjectScope.MAIN);
+
sourceContext.handleResourceConfiguration(ProjectScope.TEST);
+ } else {
+ // Add script source root if no <sources lang="script">
configured
+ if (!sourceContext.hasSources(Language.SCRIPT,
ProjectScope.MAIN)) {
+
project.addScriptSourceRoot(build.getScriptSourceDirectory());
+ }
+
+ if (isModularProject) {
+ // Modular: reject ALL legacy directory configurations
+ failIfLegacyDirectoryPresent(
+ build.getSourceDirectory(),
+ baseDir.resolve("src/main/java"),
+ "<sourceDirectory>",
+ project.getId(),
+ result,
+ true); // check physical presence
+ failIfLegacyDirectoryPresent(
+ build.getTestSourceDirectory(),
+ baseDir.resolve("src/test/java"),
+ "<testSourceDirectory>",
+ project.getId(),
+ result,
+ true); // check physical presence
+ } else {
+ // Non-modular: always validate legacy directories
(error if differs from default)
+ Path mainDefault = baseDir.resolve("src/main/java");
+ Path testDefault = baseDir.resolve("src/test/java");
+
+ failIfLegacyDirectoryPresent(
+ build.getSourceDirectory(),
+ mainDefault,
+ "<sourceDirectory>",
+ project.getId(),
+ result,
+ false); // no physical presence check
+ failIfLegacyDirectoryPresent(
+ build.getTestSourceDirectory(),
+ testDefault,
+ "<testSourceDirectory>",
+ project.getId(),
+ result,
+ false); // no physical presence check
+
+ // Use legacy as fallback only if:
+ // 1. <sources> doesn't have Java for this scope
+ // 2. Legacy matches default (otherwise error was
reported above)
+ if (!sourceContext.hasSources(Language.JAVA_FAMILY,
ProjectScope.MAIN)) {
+ Path configuredMain =
Path.of(build.getSourceDirectory())
+ .toAbsolutePath()
+ .normalize();
+ if (configuredMain.equals(
+ mainDefault.toAbsolutePath().normalize()))
{
+
project.addCompileSourceRoot(build.getSourceDirectory());
+ }
+ }
+ if (!sourceContext.hasSources(Language.JAVA_FAMILY,
ProjectScope.TEST)) {
+ Path configuredTest =
Path.of(build.getTestSourceDirectory())
+ .toAbsolutePath()
+ .normalize();
+ if (configuredTest.equals(
+ testDefault.toAbsolutePath().normalize()))
{
+
project.addTestCompileSourceRoot(build.getTestSourceDirectory());
+ }
+ }
+ }
+
+ // Fail if modular and classic sources are mixed within
<sources>
+ sourceContext.failIfMixedModularAndClassicSources();
+
+ // Handle main and test resources using unified source
handling
+
sourceContext.handleResourceConfiguration(ProjectScope.MAIN);
+
sourceContext.handleResourceConfiguration(ProjectScope.TEST);
}
}
@@ -870,6 +955,65 @@ private void initProject(MavenProject project,
ModelBuilderResult result) {
project.setRemoteArtifactRepositories(remoteRepositories);
}
+ /**
+ * Validates that legacy directory configuration does not conflict
with {@code <sources>}.
+ * <p>
+ * When {@code <sources>} is configured, the build fails if:
+ * <ul>
+ * <li><strong>Configuration presence</strong>: an explicit legacy
configuration differs from the default</li>
+ * <li><strong>Physical presence</strong>: the default directory
exists on the filesystem (only checked
+ * when {@code checkPhysicalPresence} is true, typically for
modular projects where
+ * {@code <source>} elements use different paths like {@code
src/<module>/main/java})</li>
+ * </ul>
+ * <p>
+ * The presence of {@code <sources>} is the trigger for this
validation, not whether the
+ * project is modular or non-modular.
+ * <p>
+ * This ensures consistency with resource handling.
+ *
+ * @param configuredDir the configured legacy directory value
+ * @param defaultDir the default legacy directory path
+ * @param elementName the XML element name for error messages
+ * @param projectId the project ID for error messages
+ * @param result the model builder result for reporting problems
+ * @param checkPhysicalPresence whether to check for physical presence
of the default directory
+ * @see SourceHandlingContext#handleResourceConfiguration(ProjectScope)
+ */
+ private void failIfLegacyDirectoryPresent(
+ String configuredDir,
+ Path defaultDir,
+ String elementName,
+ String projectId,
+ ModelBuilderResult result,
+ boolean checkPhysicalPresence) {
+ if (configuredDir != null) {
+ Path configuredPath =
Path.of(configuredDir).toAbsolutePath().normalize();
+ Path defaultPath = defaultDir.toAbsolutePath().normalize();
+ if (!configuredPath.equals(defaultPath)) {
+ // Configuration presence: explicit config differs from
default
+ String message = String.format(
+ "Legacy %s cannot be used in project %s because
sources are configured via <sources>. "
+ + "Remove the %s configuration.",
+ elementName, projectId, elementName);
+ logger.error(message);
+ result.getProblemCollector()
+ .reportProblem(new
org.apache.maven.impl.model.DefaultModelProblem(
+ message, Severity.ERROR, Version.V41,
null, -1, -1, null));
+ } else if (checkPhysicalPresence &&
Files.isDirectory(defaultPath)) {
+ // Physical presence: default directory exists but would
be ignored
+ String message = String.format(
+ "Legacy directory '%s' exists but cannot be used
in project %s "
+ + "because sources are configured via
<sources>. "
+ + "Remove or rename the directory.",
+ defaultPath, projectId);
+ logger.error(message);
+ result.getProblemCollector()
+ .reportProblem(new
org.apache.maven.impl.model.DefaultModelProblem(
+ message, Severity.ERROR, Version.V41,
null, -1, -1, null));
+ }
+ }
+ }
+
private void initParent(MavenProject project, ModelBuilderResult
result) {
Model parentModel = result.getParentModel();
@@ -1011,8 +1155,8 @@ private DependencyResolutionResult
resolveDependencies(MavenProject project) {
}
}
- private List<String> getProfileIds(List<Profile> profiles) {
- return
profiles.stream().map(Profile::getId).collect(Collectors.toList());
+ private static List<String> getProfileIds(List<Profile> profiles) {
+ return profiles.stream().map(Profile::getId).toList();
}
private static ModelSource createStubModelSource(Artifact artifact) {
@@ -1093,6 +1237,22 @@ public Set<Entry<K, V>> 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<String>
extractModules(List<org.apache.maven.api.model.Source> 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
new file mode 100644
index 0000000000..400f9f5dc0
--- /dev/null
+++
b/impl/maven-core/src/main/java/org/apache/maven/project/SourceHandlingContext.java
@@ -0,0 +1,366 @@
+/*
+ * 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.nio.file.Path;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.maven.api.Language;
+import org.apache.maven.api.ProjectScope;
+import org.apache.maven.api.SourceRoot;
+import org.apache.maven.api.model.Resource;
+import org.apache.maven.api.services.BuilderProblem.Severity;
+import org.apache.maven.api.services.ModelBuilderResult;
+import org.apache.maven.api.services.ModelProblem.Version;
+import org.apache.maven.impl.DefaultSourceRoot;
+import org.apache.maven.impl.model.DefaultModelProblem;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Handles source configuration for Maven projects with unified tracking for
all language/scope combinations.
+ * <p>
+ * 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.
+ * <p>
+ * Key features:
+ * <ul>
+ * <li>Tracks declared sources using {@code (language, scope, module,
directory)} identity</li>
+ * <li>Only tracks enabled sources - disabled sources are effectively
no-ops</li>
+ * <li>Detects duplicate enabled sources and emits warnings</li>
+ * <li>Provides {@link #hasSources(Language, ProjectScope)} to check if
sources exist for a combination</li>
+ * </ul>
+ *
+ * @since 4.0.0
+ */
+class SourceHandlingContext {
+
+ private static final Logger LOGGER =
LoggerFactory.getLogger(SourceHandlingContext.class);
+
+ /**
+ * Identity key for source tracking. Two sources with the same key are
considered duplicates.
+ */
+ record SourceKey(Language language, ProjectScope scope, String module,
Path directory) {}
+
+ private final MavenProject project;
+ private final Path baseDir;
+ private final Set<String> modules;
+ private final boolean modularProject;
+ private final ModelBuilderResult result;
+ private final Set<SourceKey> declaredSources;
+
+ SourceHandlingContext(
+ MavenProject project,
+ Path baseDir,
+ Set<String> modules,
+ boolean modularProject,
+ ModelBuilderResult result) {
+ this.project = project;
+ this.baseDir = baseDir;
+ this.modules = modules;
+ this.modularProject = modularProject;
+ this.result = result;
+ // Each module typically has main, test, main resources, test
resources = 4 sources
+ this.declaredSources = new HashSet<>(4 * modules.size());
+ }
+
+ /**
+ * Determines if a source root should be added to the project and tracks
it for duplicate detection.
+ * <p>
+ * Rules:
+ * <ul>
+ * <li>Disabled sources are always added (they're filtered by {@code
getEnabledSourceRoots()})</li>
+ * <li>First enabled source for an identity is added and tracked</li>
+ * <li>Subsequent enabled sources with same identity trigger a WARNING
and are NOT added</li>
+ * </ul>
+ *
+ * @param sourceRoot the source root to evaluate
+ * @return true if the source should be added to the project, false if
it's a duplicate enabled source
+ */
+ boolean shouldAddSource(SourceRoot sourceRoot) {
+ if (!sourceRoot.enabled()) {
+ // Disabled sources are always added - they're filtered out by
getEnabledSourceRoots()
+ LOGGER.trace(
+ "Adding disabled source (will be filtered by
getEnabledSourceRoots): lang={}, scope={}, module={}, dir={}",
+ sourceRoot.language(),
+ sourceRoot.scope(),
+ sourceRoot.module().orElse(null),
+ sourceRoot.directory());
+ return true;
+ }
+
+ // Normalize path for consistent duplicate detection (handles
symlinks, relative paths)
+ Path normalizedDir =
sourceRoot.directory().toAbsolutePath().normalize();
+ SourceKey key = new SourceKey(
+ sourceRoot.language(), sourceRoot.scope(),
sourceRoot.module().orElse(null), normalizedDir);
+
+ if (declaredSources.contains(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.",
+ key.language(), key.scope(), key.module() != null ?
key.module() : "(none)", key.directory());
+ LOGGER.warn(message);
+ result.getProblemCollector()
+ .reportProblem(new DefaultModelProblem(
+ message,
+ Severity.WARNING,
+ Version.V41,
+ project.getModel().getDelegate(),
+ -1,
+ -1,
+ null));
+ return false; // Don't add duplicate enabled source
+ }
+
+ declaredSources.add(key);
+ LOGGER.debug(
+ "Adding and tracking enabled source: lang={}, scope={},
module={}, dir={}",
+ key.language(),
+ key.scope(),
+ key.module(),
+ key.directory());
+ return true; // Add first enabled source with this identity
+ }
+
+ /**
+ * Checks if any enabled sources have been declared for the given language
and scope combination.
+ *
+ * @param language the language to check (e.g., {@link
Language#JAVA_FAMILY}, {@link Language#RESOURCES})
+ * @param scope the scope to check (e.g., {@link ProjectScope#MAIN},
{@link ProjectScope#TEST})
+ * @return true if at least one enabled source exists for this combination
+ */
+ boolean hasSources(Language language, ProjectScope scope) {
+ return declaredSources.stream().anyMatch(key ->
language.equals(key.language()) && scope.equals(key.scope()));
+ }
+
+ /**
+ * Fails the build if modular and classic (non-modular) sources are mixed
within {@code <sources>}.
+ * <p>
+ * A project must be either fully modular (all sources have a module) or
fully classic
+ * (no sources have a module). Mixing modular and non-modular sources
within the same
+ * project is not supported because the compiler plugin cannot handle such
configurations.
+ * <p>
+ * This validation checks each (language, scope) combination and reports
an ERROR if
+ * both modular and non-modular sources are found.
+ */
+ 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));
+ }
+ }
+ }
+ }
+
+ /**
+ * Handles resource configuration for a given scope (main or test).
+ * This method applies the resource priority rules:
+ * <ol>
+ * <li>Modular project: use resources from {@code <sources>} if present,
otherwise inject defaults</li>
+ * <li>Classic project: use resources from {@code <sources>} if present,
otherwise use legacy resources</li>
+ * </ol>
+ * <p>
+ * The error behavior for conflicting legacy configuration is consistent
with source directory handling.
+ *
+ * @param scope the project scope (MAIN or TEST)
+ */
+ void handleResourceConfiguration(ProjectScope scope) {
+ boolean hasResourcesInSources = hasSources(Language.RESOURCES, scope);
+
+ List<Resource> resources = scope == ProjectScope.MAIN
+ ? project.getBuild().getDelegate().getResources()
+ : project.getBuild().getDelegate().getTestResources();
+
+ String scopeId = scope.id();
+ String scopeName = scope == ProjectScope.MAIN ? "Main" : "Test";
+ String legacyElement = scope == ProjectScope.MAIN ? "<resources>" :
"<testResources>";
+ String sourcesConfig = scope == ProjectScope.MAIN
+ ? "<source><lang>resources</lang></source>"
+ : "<source><lang>resources</lang><scope>test</scope></source>";
+
+ if (modularProject) {
+ if (hasResourcesInSources) {
+ // Modular project with resources configured via <sources> -
already added above
+ if (hasExplicitLegacyResources(resources, scopeId)) {
+ String message = String.format(
+ "Legacy %s element cannot be used because %s
resources are configured via %s in <sources>.",
+ legacyElement, scopeId, sourcesConfig);
+ LOGGER.error(message);
+ result.getProblemCollector()
+ .reportProblem(new DefaultModelProblem(
+ message,
+ Severity.ERROR,
+ Version.V41,
+ project.getModel().getDelegate(),
+ -1,
+ -1,
+ null));
+ } else {
+ LOGGER.debug(
+ "{} resources configured via <sources> element,
ignoring legacy {} element.",
+ scopeName,
+ legacyElement);
+ }
+ } else {
+ // Modular project without resources in <sources> - inject
module-aware defaults
+ if (hasExplicitLegacyResources(resources, scopeId)) {
+ String message = "Legacy " + legacyElement
+ + " element cannot be used because modular sources
are configured. "
+ + "Use " + sourcesConfig + " in <sources> for
custom resource paths.";
+ LOGGER.error(message);
+ result.getProblemCollector()
+ .reportProblem(new DefaultModelProblem(
+ message,
+ Severity.ERROR,
+ Version.V41,
+ project.getModel().getDelegate(),
+ -1,
+ -1,
+ null));
+ }
+ for (String module : modules) {
+ project.addSourceRoot(createModularResourceRoot(module,
scope));
+ }
+ if (!modules.isEmpty()) {
+ LOGGER.debug(
+ "Injected {} module-aware {} resource root(s) for
modules: {}.",
+ modules.size(),
+ scopeId,
+ modules);
+ }
+ }
+ } else {
+ // Classic (non-modular) project
+ if (hasResourcesInSources) {
+ // Resources configured via <sources> - already added above
+ if (hasExplicitLegacyResources(resources, scopeId)) {
+ String message = String.format(
+ "Legacy %s element cannot be used because %s
resources are configured via %s in <sources>.",
+ legacyElement, scopeId, sourcesConfig);
+ LOGGER.error(message);
+ result.getProblemCollector()
+ .reportProblem(new DefaultModelProblem(
+ message,
+ Severity.ERROR,
+ Version.V41,
+ project.getModel().getDelegate(),
+ -1,
+ -1,
+ null));
+ } else {
+ LOGGER.debug(
+ "{} resources configured via <sources> element,
ignoring legacy {} element.",
+ scopeName,
+ legacyElement);
+ }
+ } else {
+ // Use legacy resources element
+ LOGGER.debug(
+ "Using explicit or default {} resources ({} resources
configured).", scopeId, resources.size());
+ for (Resource resource : resources) {
+ project.addSourceRoot(new DefaultSourceRoot(baseDir,
scope, resource));
+ }
+ }
+ }
+ }
+
+ /**
+ * Creates a DefaultSourceRoot for module-aware resource directories.
+ * Generates paths following the pattern: {@code
src/<module>/<scope>/resources}
+ *
+ * @param module module name
+ * @param scope project scope (main or test)
+ * @return configured DefaultSourceRoot for the module's resources
+ */
+ private DefaultSourceRoot createModularResourceRoot(String module,
ProjectScope scope) {
+ Path resourceDir =
+
baseDir.resolve("src").resolve(module).resolve(scope.id()).resolve("resources");
+
+ return new DefaultSourceRoot(
+ scope,
+ Language.RESOURCES,
+ module,
+ null, // targetVersion
+ resourceDir,
+ null, // includes
+ null, // excludes
+ false, // stringFiltering
+ Path.of(module), // targetPath - resources go to
target/classes/<module>
+ true // enabled
+ );
+ }
+
+ /**
+ * Checks if the given resource list contains explicit legacy resources
that differ
+ * from Super POM defaults. Super POM defaults are: src/{scope}/resources
and src/{scope}/resources-filtered
+ *
+ * @param resources list of resources to check
+ * @param scope scope (main or test)
+ * @return true if explicit legacy resources are present that conflict
with modular sources
+ */
+ private boolean hasExplicitLegacyResources(List<Resource> resources,
String scope) {
+ if (resources.isEmpty()) {
+ return false; // No resources means no explicit legacy resources
to warn about
+ }
+
+ // 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();
+
+ // Check if any resource differs from Super POM defaults
+ for (Resource resource : resources) {
+ String resourceDir = resource.getDirectory();
+ if (resourceDir != null && !resourceDir.equals(defaultPath) &&
!resourceDir.equals(defaultFilteredPath)) {
+ // Found an explicit legacy resource
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git
a/impl/maven-core/src/test/java/org/apache/maven/project/ProjectBuilderTest.java
b/impl/maven-core/src/test/java/org/apache/maven/project/ProjectBuilderTest.java
index 541ac8063c..fac2b79dee 100644
---
a/impl/maven-core/src/test/java/org/apache/maven/project/ProjectBuilderTest.java
+++
b/impl/maven-core/src/test/java/org/apache/maven/project/ProjectBuilderTest.java
@@ -25,10 +25,16 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Optional;
import java.util.Properties;
+import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
import org.apache.maven.AbstractCoreMavenComponentTestCase;
+import org.apache.maven.api.Language;
+import org.apache.maven.api.ProjectScope;
+import org.apache.maven.api.SourceRoot;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.model.Dependency;
import org.apache.maven.model.InputLocation;
@@ -49,6 +55,7 @@
import static org.hamcrest.Matchers.hasKey;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -381,4 +388,616 @@ void testLocationTrackingResolution() throws Exception {
assertEquals(
"org.apache.maven.its:parent:0.1",
pluginLocation.getSource().getModelId());
}
+ /**
+ * Tests that a project with multiple modules defined in sources is
detected as modular,
+ * and module-aware resource roots are injected for each module.
+ * <p>
+ * Acceptance Criterion: AC2 (unified source tracking for all lang/scope
combinations)
+ *
+ * @see <a href="https://github.com/apache/maven/issues/11612">Issue
#11612</a>
+ */
+ @Test
+ void testModularSourcesInjectResourceRoots() throws Exception {
+ File pom = getProject("modular-sources");
+
+ MavenSession session = createMavenSession(pom);
+ MavenProject project = session.getCurrentProject();
+
+ // Get all resource source roots for main scope
+ List<SourceRoot> mainResourceRoots =
project.getEnabledSourceRoots(ProjectScope.MAIN, Language.RESOURCES)
+ .toList();
+
+ // Should have resource roots for both modules
+ Set<String> modules = mainResourceRoots.stream()
+ .map(SourceRoot::module)
+ .flatMap(Optional::stream)
+ .collect(Collectors.toSet());
+
+ assertEquals(2, modules.size(), "Should have resource roots for 2
modules");
+ assertTrue(modules.contains("org.foo.moduleA"), "Should have resource
root for moduleA");
+ assertTrue(modules.contains("org.foo.moduleB"), "Should have resource
root for moduleB");
+
+ // Get all resource source roots for test scope
+ List<SourceRoot> testResourceRoots =
project.getEnabledSourceRoots(ProjectScope.TEST, Language.RESOURCES)
+ .toList();
+
+ // Should have test resource roots for both modules
+ Set<String> testModules = testResourceRoots.stream()
+ .map(SourceRoot::module)
+ .flatMap(Optional::stream)
+ .collect(Collectors.toSet());
+
+ assertEquals(2, testModules.size(), "Should have test resource roots
for 2 modules");
+ assertTrue(testModules.contains("org.foo.moduleA"), "Should have test
resource root for moduleA");
+ assertTrue(testModules.contains("org.foo.moduleB"), "Should have test
resource root for moduleB");
+ }
+
+ /**
+ * Tests that when modular sources are configured alongside explicit
legacy resources, an error is raised.
+ * <p>
+ * This verifies the behavior described in the design:
+ * - Modular projects with explicit legacy {@code <resources>}
configuration should raise an error
+ * - The modular resource roots are injected instead of using the legacy
configuration
+ * <p>
+ * Acceptance Criteria:
+ * - AC2 (unified source tracking for all lang/scope combinations)
+ * - AC8 (legacy directories error - supersedes AC7 which originally used
WARNING)
+ *
+ * @see <a href="https://github.com/apache/maven/issues/11612">Issue
#11612</a>
+ * @see <a
href="https://github.com/apache/maven/issues/11701#issuecomment-3858462609">AC8
definition</a>
+ */
+ @Test
+ void testModularSourcesWithExplicitResourcesIssuesError() throws Exception
{
+ File pom = getProject("modular-sources-with-explicit-resources");
+
+ MavenSession mavenSession = createMavenSession(null);
+ ProjectBuildingRequest configuration = new
DefaultProjectBuildingRequest();
+
configuration.setRepositorySession(mavenSession.getRepositorySession());
+
+ ProjectBuildingResult result = getContainer()
+ .lookup(org.apache.maven.project.ProjectBuilder.class)
+ .build(pom, configuration);
+
+ MavenProject project = result.getProject();
+
+ // Verify errors are raised for conflicting legacy resources (AC8)
+ List<ModelProblem> errors = result.getProblems().stream()
+ .filter(p -> p.getSeverity() == ModelProblem.Severity.ERROR)
+ .filter(p -> p.getMessage().contains("Legacy") &&
p.getMessage().contains("cannot be used"))
+ .toList();
+
+ assertEquals(2, errors.size(), "Should have 2 errors (one for
resources, one for testResources)");
+ assertTrue(
+ errors.stream().anyMatch(e ->
e.getMessage().contains("<resources>")),
+ "Should error about conflicting <resources>");
+ assertTrue(
+ errors.stream().anyMatch(e ->
e.getMessage().contains("<testResources>")),
+ "Should error about conflicting <testResources>");
+
+ // Verify modular resources are still injected correctly
+ List<SourceRoot> mainResourceRoots =
project.getEnabledSourceRoots(ProjectScope.MAIN, Language.RESOURCES)
+ .toList();
+
+ assertEquals(2, mainResourceRoots.size(), "Should have 2 modular
resource roots (one per module)");
+
+ Set<String> mainModules = mainResourceRoots.stream()
+ .map(SourceRoot::module)
+ .flatMap(Optional::stream)
+ .collect(Collectors.toSet());
+
+ assertEquals(2, mainModules.size(), "Should have resource roots for 2
modules");
+ assertTrue(mainModules.contains("org.foo.moduleA"), "Should have
resource root for moduleA");
+ assertTrue(mainModules.contains("org.foo.moduleB"), "Should have
resource root for moduleB");
+ }
+
+ /**
+ * Tests AC8: ALL legacy directories are rejected when {@code <sources>}
is configured.
+ * <p>
+ * Modular project with Java in {@code <sources>} for MAIN scope and
explicit legacy
+ * {@code <sourceDirectory>} that differs from default. The legacy
directory is rejected
+ * because modular projects cannot use legacy directories (content cannot
be dispatched
+ * between modules).
+ *
+ * @see <a
href="https://github.com/apache/maven/issues/11701#issuecomment-3897961755">Issue
#11701 (AC8/AC9)</a>
+ */
+ @Test
+ void testModularWithJavaSourcesRejectsLegacySourceDirectory() throws
Exception {
+ File pom = getProject("modular-java-with-explicit-source-dir");
+
+ MavenSession mavenSession = createMavenSession(null);
+ ProjectBuildingRequest configuration = new
DefaultProjectBuildingRequest();
+
configuration.setRepositorySession(mavenSession.getRepositorySession());
+
+ ProjectBuildingResult result = getContainer()
+ .lookup(org.apache.maven.project.ProjectBuilder.class)
+ .build(pom, configuration);
+
+ MavenProject project = result.getProject();
+
+ // Verify ERROR for <sourceDirectory> (MAIN scope has Java in
<sources>)
+ List<ModelProblem> errors = result.getProblems().stream()
+ .filter(p -> p.getSeverity() == ModelProblem.Severity.ERROR)
+ .filter(p -> p.getMessage().contains("Legacy") &&
p.getMessage().contains("cannot be used"))
+ .filter(p -> p.getMessage().contains("<sourceDirectory>"))
+ .toList();
+
+ assertEquals(1, errors.size(), "Should have 1 error for
<sourceDirectory>");
+
+ // Verify modular source is used, not legacy
+ List<SourceRoot> mainJavaRoots =
project.getEnabledSourceRoots(ProjectScope.MAIN, Language.JAVA_FAMILY)
+ .toList();
+ assertEquals(1, mainJavaRoots.size(), "Should have 1 modular main Java
source root");
+ assertEquals("org.foo.app",
mainJavaRoots.get(0).module().orElse(null), "Should have module org.foo.app");
+
+ // Legacy sourceDirectory is NOT used
+ assertFalse(
+
mainJavaRoots.get(0).directory().toString().contains("src/custom/main/java"),
+ "Legacy sourceDirectory should not be used");
+ }
+
+ /**
+ * Tests AC8: Modular project rejects legacy {@code <testSourceDirectory>}
even when
+ * {@code <sources>} has NO Java for TEST scope.
+ * <p>
+ * Modular project with NO Java in {@code <sources>} for TEST scope and
explicit legacy
+ * {@code <testSourceDirectory>} that differs from default. The legacy
directory is rejected
+ * because modular projects cannot use legacy directories (content cannot
be dispatched
+ * between modules).
+ *
+ * @see <a
href="https://github.com/apache/maven/issues/11701#issuecomment-3897961755">Issue
#11701 (AC8/AC9)</a>
+ */
+ @Test
+ void testModularWithoutTestSourcesRejectsLegacyTestSourceDirectory()
throws Exception {
+ File pom =
getProject("modular-no-test-java-with-explicit-test-source-dir");
+
+ MavenSession mavenSession = createMavenSession(null);
+ ProjectBuildingRequest configuration = new
DefaultProjectBuildingRequest();
+
configuration.setRepositorySession(mavenSession.getRepositorySession());
+
+ ProjectBuildingResult result = getContainer()
+ .lookup(org.apache.maven.project.ProjectBuilder.class)
+ .build(pom, configuration);
+
+ MavenProject project = result.getProject();
+
+ // Verify ERROR for <testSourceDirectory> (modular projects reject all
legacy directories)
+ List<ModelProblem> errors = result.getProblems().stream()
+ .filter(p -> p.getSeverity() == ModelProblem.Severity.ERROR)
+ .filter(p -> p.getMessage().contains("Legacy") &&
p.getMessage().contains("cannot be used"))
+ .filter(p -> p.getMessage().contains("<testSourceDirectory>"))
+ .toList();
+
+ assertEquals(1, errors.size(), "Should have 1 error for
<testSourceDirectory>");
+
+ // No test Java sources (legacy rejected, none in <sources>)
+ List<SourceRoot> testJavaRoots =
project.getEnabledSourceRoots(ProjectScope.TEST, Language.JAVA_FAMILY)
+ .toList();
+ assertEquals(0, testJavaRoots.size(), "Should have no test Java
sources");
+ }
+
+ /**
+ * Tests AC9: explicit legacy directories raise an error in non-modular
projects when
+ * {@code <sources>} has Java for that scope.
+ * <p>
+ * This test uses a non-modular project (no {@code <module>} attribute)
with both:
+ * <ul>
+ * <li>{@code <sources>} with main and test Java sources</li>
+ * <li>Explicit {@code <sourceDirectory>} and {@code
<testSourceDirectory>} (conflicting)</li>
+ * </ul>
+ * Both legacy directories should trigger ERROR because {@code <sources>}
has Java.
+ *
+ * @see <a
href="https://github.com/apache/maven/issues/11701#issuecomment-3897961755">Issue
#11701 (AC8/AC9)</a>
+ */
+ @Test
+ void testClassicSourcesWithExplicitLegacyDirectories() throws Exception {
+ File pom = getProject("classic-sources-with-explicit-legacy");
+
+ MavenSession mavenSession = createMavenSession(null);
+ ProjectBuildingRequest configuration = new
DefaultProjectBuildingRequest();
+
configuration.setRepositorySession(mavenSession.getRepositorySession());
+
+ ProjectBuildingResult result = getContainer()
+ .lookup(org.apache.maven.project.ProjectBuilder.class)
+ .build(pom, configuration);
+
+ // Verify errors are raised for conflicting legacy directories (AC9)
+ List<ModelProblem> errors = result.getProblems().stream()
+ .filter(p -> p.getSeverity() == ModelProblem.Severity.ERROR)
+ .filter(p -> p.getMessage().contains("Legacy") &&
p.getMessage().contains("cannot be used"))
+ .toList();
+
+ assertEquals(2, errors.size(), "Should have 2 errors (one for
sourceDirectory, one for testSourceDirectory)");
+
+ // Verify error messages mention the conflicting elements
+ assertTrue(
+ errors.stream().anyMatch(e ->
e.getMessage().contains("<sourceDirectory>")),
+ "Should have error for <sourceDirectory>");
+ assertTrue(
+ errors.stream().anyMatch(e ->
e.getMessage().contains("<testSourceDirectory>")),
+ "Should have error for <testSourceDirectory>");
+ }
+
+ /**
+ * Tests AC9: Non-modular project with only resources in {@code <sources>}
uses implicit Java fallback.
+ * <p>
+ * When {@code <sources>} contains only resources (no Java sources), the
legacy
+ * {@code <sourceDirectory>} and {@code <testSourceDirectory>} are used as
implicit fallback.
+ * This enables incremental adoption of {@code <sources>} - customize
resources while
+ * keeping the default Java directory structure.
+ *
+ * @see <a
href="https://github.com/apache/maven/issues/11701#issuecomment-3897961755">Issue
#11701 (AC8/AC9)</a>
+ */
+ @Test
+ void testNonModularResourcesOnlyWithImplicitJavaFallback() throws
Exception {
+ File pom = getProject("non-modular-resources-only");
+
+ MavenSession mavenSession = createMavenSession(null);
+ ProjectBuildingRequest configuration = new
DefaultProjectBuildingRequest();
+
configuration.setRepositorySession(mavenSession.getRepositorySession());
+
+ ProjectBuildingResult result = getContainer()
+ .lookup(org.apache.maven.project.ProjectBuilder.class)
+ .build(pom, configuration);
+
+ MavenProject project = result.getProject();
+
+ // Verify NO errors - legacy directories are used as fallback (AC9)
+ List<ModelProblem> errors = result.getProblems().stream()
+ .filter(p -> p.getSeverity() == ModelProblem.Severity.ERROR)
+ .filter(p -> p.getMessage().contains("Legacy") &&
p.getMessage().contains("cannot be used"))
+ .toList();
+
+ assertEquals(0, errors.size(), "Should have no errors - legacy
directories used as fallback (AC9)");
+
+ // Verify resources from <sources> are used
+ List<SourceRoot> mainResources =
project.getEnabledSourceRoots(ProjectScope.MAIN, Language.RESOURCES)
+ .toList();
+ assertTrue(
+ mainResources.stream().anyMatch(sr -> sr.directory()
+ .toString()
+ .replace(File.separatorChar, '/')
+ .contains("src/main/custom-resources")),
+ "Should have custom main resources from <sources>");
+
+ // Verify legacy Java directories are used as fallback
+ List<SourceRoot> mainJavaRoots =
project.getEnabledSourceRoots(ProjectScope.MAIN, Language.JAVA_FAMILY)
+ .toList();
+ assertEquals(1, mainJavaRoots.size(), "Should have 1 main Java source
(implicit fallback)");
+ assertTrue(
+ mainJavaRoots
+ .get(0)
+ .directory()
+ .toString()
+ .replace(File.separatorChar, '/')
+ .endsWith("src/main/java"),
+ "Should use default src/main/java as fallback");
+
+ List<SourceRoot> testJavaRoots =
project.getEnabledSourceRoots(ProjectScope.TEST, Language.JAVA_FAMILY)
+ .toList();
+ assertEquals(1, testJavaRoots.size(), "Should have 1 test Java source
(implicit fallback)");
+ assertTrue(
+ testJavaRoots
+ .get(0)
+ .directory()
+ .toString()
+ .replace(File.separatorChar, '/')
+ .endsWith("src/test/java"),
+ "Should use default src/test/java as fallback");
+ }
+
+ /**
+ * Tests AC9 violation: Non-modular project with only resources in {@code
<sources>} and explicit legacy directories.
+ * <p>
+ * AC9 allows implicit fallback to legacy directories (when they match
defaults).
+ * When legacy directories differ from the default, this is explicit
configuration,
+ * which violates AC9's "implicit" requirement, so an ERROR is raised.
+ *
+ * @see <a
href="https://github.com/apache/maven/issues/11701#issuecomment-3897961755">Issue
#11701 (AC8/AC9)</a>
+ */
+ @Test
+ void testNonModularResourcesOnlyWithExplicitLegacyDirectoriesRejected()
throws Exception {
+ File pom = getProject("non-modular-resources-only-explicit-legacy");
+
+ MavenSession mavenSession = createMavenSession(null);
+ ProjectBuildingRequest configuration = new
DefaultProjectBuildingRequest();
+
configuration.setRepositorySession(mavenSession.getRepositorySession());
+
+ ProjectBuildingResult result = getContainer()
+ .lookup(org.apache.maven.project.ProjectBuilder.class)
+ .build(pom, configuration);
+
+ MavenProject project = result.getProject();
+
+ // Verify ERRORs for explicit legacy directories (differ from default)
+ List<ModelProblem> errors = result.getProblems().stream()
+ .filter(p -> p.getSeverity() == ModelProblem.Severity.ERROR)
+ .filter(p -> p.getMessage().contains("Legacy") &&
p.getMessage().contains("cannot be used"))
+ .toList();
+
+ assertEquals(2, errors.size(), "Should have 2 errors for explicit
legacy directories");
+ assertTrue(
+ errors.stream().anyMatch(e ->
e.getMessage().contains("<sourceDirectory>")),
+ "Should error about <sourceDirectory>");
+ assertTrue(
+ errors.stream().anyMatch(e ->
e.getMessage().contains("<testSourceDirectory>")),
+ "Should error about <testSourceDirectory>");
+
+ // Verify resources from <sources> are still used
+ List<SourceRoot> mainResources =
project.getEnabledSourceRoots(ProjectScope.MAIN, Language.RESOURCES)
+ .toList();
+ assertTrue(
+ mainResources.stream().anyMatch(sr -> sr.directory()
+ .toString()
+ .replace(File.separatorChar, '/')
+ .contains("src/main/custom-resources")),
+ "Should have custom main resources from <sources>");
+
+ // Verify NO Java source roots (legacy was rejected, none in <sources>)
+ List<SourceRoot> mainJavaRoots =
project.getEnabledSourceRoots(ProjectScope.MAIN, Language.JAVA_FAMILY)
+ .toList();
+ assertEquals(0, mainJavaRoots.size(), "Should have no main Java
sources (legacy rejected)");
+
+ List<SourceRoot> testJavaRoots =
project.getEnabledSourceRoots(ProjectScope.TEST, Language.JAVA_FAMILY)
+ .toList();
+ assertEquals(0, testJavaRoots.size(), "Should have no test Java
sources (legacy rejected)");
+ }
+
+ /**
+ * Tests AC8: Modular project with Java in {@code <sources>} and physical
default legacy directories.
+ * <p>
+ * Even when legacy directories use Super POM defaults (no explicit
override),
+ * if the physical directories exist on the filesystem, an ERROR is raised.
+ * This is because modular projects use paths like {@code
src/<module>/main/java},
+ * so content in {@code src/main/java} would be silently ignored.
+ *
+ * @see <a
href="https://github.com/apache/maven/issues/11701#issuecomment-3897961755">Issue
#11701 (AC8/AC9)</a>
+ */
+ @Test
+ void testModularWithPhysicalDefaultLegacyDirectory() throws Exception {
+ File pom = getProject("modular-with-physical-legacy");
+
+ MavenSession mavenSession = createMavenSession(null);
+ ProjectBuildingRequest configuration = new
DefaultProjectBuildingRequest();
+
configuration.setRepositorySession(mavenSession.getRepositorySession());
+
+ ProjectBuildingResult result = getContainer()
+ .lookup(org.apache.maven.project.ProjectBuilder.class)
+ .build(pom, configuration);
+
+ // Verify ERRORs are raised for physical presence of default
directories (AC8)
+ List<ModelProblem> errors = result.getProblems().stream()
+ .filter(p -> p.getSeverity() == ModelProblem.Severity.ERROR)
+ .filter(p -> p.getMessage().contains("Legacy directory")
+ && p.getMessage().contains("exists"))
+ .toList();
+
+ // Should have 2 errors: one for src/main/java, one for src/test/java
+ assertEquals(2, errors.size(), "Should have 2 errors for physical
legacy directories");
+ // Use File.separator for platform-independent path matching
(backslash on Windows)
+ String mainJava = "src" + File.separator + "main" + File.separator +
"java";
+ String testJava = "src" + File.separator + "test" + File.separator +
"java";
+ assertTrue(
+ errors.stream().anyMatch(e ->
e.getMessage().contains(mainJava)),
+ "Should error about physical src/main/java");
+ assertTrue(
+ errors.stream().anyMatch(e ->
e.getMessage().contains(testJava)),
+ "Should error about physical src/test/java");
+ }
+
+ /**
+ * Tests AC8: Modular project with only resources in {@code <sources>} and
physical default legacy directories.
+ * <p>
+ * Even when {@code <sources>} only contains resources (no Java), if the
physical
+ * default directories exist, an ERROR is raised for modular projects.
+ * Unlike non-modular projects (AC9), modular projects cannot use legacy
directories as fallback.
+ *
+ * @see <a
href="https://github.com/apache/maven/issues/11701#issuecomment-3897961755">Issue
#11701 (AC8/AC9)</a>
+ */
+ @Test
+ void testModularResourcesOnlyWithPhysicalDefaultLegacyDirectory() throws
Exception {
+ File pom = getProject("modular-resources-only-with-physical-legacy");
+
+ MavenSession mavenSession = createMavenSession(null);
+ ProjectBuildingRequest configuration = new
DefaultProjectBuildingRequest();
+
configuration.setRepositorySession(mavenSession.getRepositorySession());
+
+ ProjectBuildingResult result = getContainer()
+ .lookup(org.apache.maven.project.ProjectBuilder.class)
+ .build(pom, configuration);
+
+ // Verify ERRORs are raised for physical presence of default
directories (AC8)
+ // Unlike non-modular (AC9), modular projects cannot use legacy as
fallback
+ List<ModelProblem> errors = result.getProblems().stream()
+ .filter(p -> p.getSeverity() == ModelProblem.Severity.ERROR)
+ .filter(p -> p.getMessage().contains("Legacy directory")
+ && p.getMessage().contains("exists"))
+ .toList();
+
+ // Should have 2 errors: one for src/main/java, one for src/test/java
+ assertEquals(
+ 2, errors.size(), "Should have 2 errors for physical legacy
directories (no AC9 fallback for modular)");
+ // Use File.separator for platform-independent path matching
(backslash on Windows)
+ String mainJava = "src" + File.separator + "main" + File.separator +
"java";
+ String testJava = "src" + File.separator + "test" + File.separator +
"java";
+ assertTrue(
+ errors.stream().anyMatch(e ->
e.getMessage().contains(mainJava)),
+ "Should error about physical src/main/java");
+ assertTrue(
+ errors.stream().anyMatch(e ->
e.getMessage().contains(testJava)),
+ "Should error about physical src/test/java");
+ }
+
+ /**
+ * Tests that mixing modular and non-modular sources within {@code
<sources>} is not allowed.
+ * <p>
+ * A project must be either fully modular (all sources have a module) or
fully classic
+ * (no sources have a module). Mixing them within the same project is not
supported
+ * because the compiler plugin cannot handle such configurations.
+ * <p>
+ * This verifies:
+ * - An ERROR is reported when both modular and non-modular sources exist
in {@code <sources>}
+ * - sourceDirectory is not used because {@code <sources>} exists
+ * <p>
+ * Acceptance Criteria:
+ * - AC1 (boolean flags eliminated - uses hasSources() for source
detection)
+ * - AC6 (mixed sources error - mixing modular and classic sources within
{@code <sources>}
+ * triggers an ERROR)
+ *
+ * @see <a href="https://github.com/apache/maven/issues/11612">Issue
#11612</a>
+ */
+ @Test
+ void testSourcesMixedModulesWithinSources() throws Exception {
+ File pom = getProject("sources-mixed-modules");
+
+ MavenSession mavenSession = createMavenSession(null);
+ ProjectBuildingRequest configuration = new
DefaultProjectBuildingRequest();
+
configuration.setRepositorySession(mavenSession.getRepositorySession());
+
+ ProjectBuildingResult result = getContainer()
+ .lookup(org.apache.maven.project.ProjectBuilder.class)
+ .build(pom, configuration);
+
+ // Verify an ERROR is reported for mixing modular and non-modular
sources
+ List<ModelProblem> errors = result.getProblems().stream()
+ .filter(p -> p.getSeverity() == ModelProblem.Severity.ERROR)
+ .filter(p -> p.getMessage().contains("Mixed modular and
classic sources"))
+ .toList();
+
+ assertEquals(1, errors.size(), "Should have 1 error for mixed
modular/classic configuration");
+ assertTrue(errors.get(0).getMessage().contains("lang=java"), "Error
should mention java language");
+ assertTrue(errors.get(0).getMessage().contains("scope=main"), "Error
should mention main scope");
+ }
+
+ /**
+ * Tests that multiple source directories for the same (lang, scope,
module) combination
+ * are allowed and all are added as source roots.
+ * <p>
+ * This is a valid use case for Phase 2: users may have generated sources
alongside regular sources,
+ * both belonging to the same module. Different directories = different
identities = not duplicates.
+ * <p>
+ * Acceptance Criterion: AC2 (unified source tracking - multiple
directories per module supported)
+ *
+ * @see <a href="https://github.com/apache/maven/issues/11612">Issue
#11612</a>
+ */
+ @Test
+ void testMultipleDirectoriesSameModule() throws Exception {
+ File pom = getProject("multiple-directories-same-module");
+
+ MavenSession session = createMavenSession(pom);
+ MavenProject project = session.getCurrentProject();
+
+ // Get main Java source roots
+ List<SourceRoot> mainJavaRoots =
project.getEnabledSourceRoots(ProjectScope.MAIN, Language.JAVA_FAMILY)
+ .toList();
+
+ // Should have 2 main sources: both for com.example.app but different
directories
+ assertEquals(2, mainJavaRoots.size(), "Should have 2 main Java source
roots for same module");
+
+ // Both should be for the same module
+ long moduleCount = mainJavaRoots.stream()
+ .filter(sr ->
"com.example.app".equals(sr.module().orElse(null)))
+ .count();
+ assertEquals(2, moduleCount, "Both main sources should be for
com.example.app module");
+
+ // One should be implicit directory, one should be generated-sources
+ boolean hasImplicitDir = mainJavaRoots.stream().anyMatch(sr ->
sr.directory()
+ .toString()
+ .replace(File.separatorChar, '/')
+ .contains("src/com.example.app/main/java"));
+ boolean hasGeneratedDir = mainJavaRoots.stream().anyMatch(sr ->
sr.directory()
+ .toString()
+ .replace(File.separatorChar, '/')
+ .contains("target/generated-sources/com.example.app/java"));
+
+ assertTrue(hasImplicitDir, "Should have implicit source directory for
module");
+ assertTrue(hasGeneratedDir, "Should have generated-sources directory
for module");
+
+ // Get test Java source roots
+ List<SourceRoot> testJavaRoots =
project.getEnabledSourceRoots(ProjectScope.TEST, Language.JAVA_FAMILY)
+ .toList();
+
+ // Should have 2 test sources: both for com.example.app
+ assertEquals(2, testJavaRoots.size(), "Should have 2 test Java source
roots for same module");
+
+ // Both test sources should be for the same module
+ long testModuleCount = testJavaRoots.stream()
+ .filter(sr ->
"com.example.app".equals(sr.module().orElse(null)))
+ .count();
+ assertEquals(2, testModuleCount, "Both test sources should be for
com.example.app module");
+ }
+
+ /**
+ * Tests duplicate handling with enabled discriminator.
+ * <p>
+ * Test scenario:
+ * - Same (lang, scope, module, directory) with enabled=true appearing
twice → triggers WARNING
+ * - Same identity with enabled=false → should be filtered out (disabled
sources are no-ops)
+ * - Different modules should be added normally
+ * <p>
+ * Verifies:
+ * - First enabled source wins, subsequent duplicates trigger WARNING
+ * - Disabled sources don't count as duplicates
+ * - Different modules are unaffected
+ * <p>
+ * Acceptance Criteria:
+ * - AC3 (duplicate detection - duplicates trigger WARNING)
+ * - AC4 (first enabled wins - duplicates are skipped)
+ * - AC5 (disabled sources unchanged - still added but filtered by
getEnabledSourceRoots)
+ *
+ * @see <a href="https://github.com/apache/maven/issues/11612">Issue
#11612</a>
+ */
+ @Test
+ void testDuplicateEnabledSources() throws Exception {
+ File pom = getProject("duplicate-enabled-sources");
+
+ MavenSession mavenSession = createMavenSession(null);
+ ProjectBuildingRequest configuration = new
DefaultProjectBuildingRequest();
+
configuration.setRepositorySession(mavenSession.getRepositorySession());
+
+ ProjectBuildingResult result = getContainer()
+ .lookup(org.apache.maven.project.ProjectBuilder.class)
+ .build(pom, configuration);
+
+ MavenProject project = result.getProject();
+
+ // Verify warnings are issued for duplicate enabled sources
+ List<ModelProblem> duplicateWarnings = result.getProblems().stream()
+ .filter(p -> p.getSeverity() == ModelProblem.Severity.WARNING)
+ .filter(p -> p.getMessage().contains("Duplicate enabled
source"))
+ .toList();
+
+ // We have 2 duplicate pairs: main scope and test scope for
com.example.dup
+ assertEquals(2, duplicateWarnings.size(), "Should have 2 duplicate
warnings (main and test scope)");
+
+ // Get main Java source roots
+ List<SourceRoot> mainJavaRoots =
project.getEnabledSourceRoots(ProjectScope.MAIN, Language.JAVA_FAMILY)
+ .toList();
+
+ // Should have 2 main sources: 1 for com.example.dup (first wins) + 1
for com.example.other
+ // Note: MavenProject.addSourceRoot still adds all sources, but
tracking only counts first enabled
+ assertEquals(2, mainJavaRoots.size(), "Should have 2 main Java source
roots");
+
+ // Verify com.example.other module is present
+ boolean hasOtherModule = mainJavaRoots.stream()
+ .anyMatch(sr ->
"com.example.other".equals(sr.module().orElse(null)));
+ assertTrue(hasOtherModule, "Should have source root for
com.example.other module");
+
+ // Verify com.example.dup module is present (first enabled wins)
+ boolean hasDupModule = mainJavaRoots.stream()
+ .anyMatch(sr ->
"com.example.dup".equals(sr.module().orElse(null)));
+ assertTrue(hasDupModule, "Should have source root for com.example.dup
module");
+
+ // Get test Java source roots
+ List<SourceRoot> testJavaRoots =
project.getEnabledSourceRoots(ProjectScope.TEST, Language.JAVA_FAMILY)
+ .toList();
+
+ // Test scope has 1 source for com.example.dup (first wins)
+ assertEquals(1, testJavaRoots.size(), "Should have 1 test Java source
root");
+
+ // Verify it's for the dup module
+ assertEquals(
+ "com.example.dup",
+ testJavaRoots.get(0).module().orElse(null),
+ "Test source root should be for com.example.dup module");
+ }
}
diff --git
a/impl/maven-core/src/test/projects/project-builder/classic-sources-with-explicit-legacy/pom.xml
b/impl/maven-core/src/test/projects/project-builder/classic-sources-with-explicit-legacy/pom.xml
new file mode 100644
index 0000000000..0c5726393a
--- /dev/null
+++
b/impl/maven-core/src/test/projects/project-builder/classic-sources-with-explicit-legacy/pom.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.1.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.1.0
https://maven.apache.org/xsd/maven-4.1.0.xsd">
+ <modelVersion>4.1.0</modelVersion>
+
+ <groupId>org.apache.maven.tests</groupId>
+ <artifactId>classic-sources-explicit-legacy-test</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <build>
+ <sources>
+ <!-- Classic (non-modular) source - no module attribute -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <directory>src/main/java</directory>
+ </source>
+ <source>
+ <scope>test</scope>
+ <lang>java</lang>
+ <directory>src/test/java</directory>
+ </source>
+ </sources>
+ <!-- Explicit legacy directories that conflict with <sources> - should
trigger ERROR (AC9) -->
+ <sourceDirectory>src/legacy/main/java</sourceDirectory>
+ <testSourceDirectory>src/legacy/test/java</testSourceDirectory>
+ </build>
+</project>
diff --git
a/impl/maven-core/src/test/projects/project-builder/duplicate-enabled-sources/pom.xml
b/impl/maven-core/src/test/projects/project-builder/duplicate-enabled-sources/pom.xml
new file mode 100644
index 0000000000..42d48ddcdc
--- /dev/null
+++
b/impl/maven-core/src/test/projects/project-builder/duplicate-enabled-sources/pom.xml
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Tests duplicate handling with the enabled discriminator.
+
+ Expected behavior:
+ - Same (lang, scope, module, directory) with enabled=true appearing twice
should trigger a WARNING
+ - Same identity with enabled=false is harmless (explicitly disabled)
+ - First enabled=true entry wins and defines the effective configuration
+-->
+<project xmlns="http://maven.apache.org/POM/4.1.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.1.0
https://maven.apache.org/xsd/maven-4.1.0.xsd">
+ <modelVersion>4.1.0</modelVersion>
+
+ <groupId>org.apache.maven.tests</groupId>
+ <artifactId>duplicate-enabled-sources-test</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <build>
+ <sources>
+ <!-- First enabled source for module (this should win) -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <module>com.example.dup</module>
+ <enabled>true</enabled>
+ </source>
+ <!-- Duplicate enabled source for same module (implicit directory)
- should trigger WARNING -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <module>com.example.dup</module>
+ <enabled>true</enabled>
+ </source>
+ <!-- Disabled source for same module - this is harmless -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <module>com.example.dup</module>
+ <enabled>false</enabled>
+ </source>
+ <!-- Different module - should be added normally -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <module>com.example.other</module>
+ </source>
+ <!-- Test scope duplicate scenario -->
+ <source>
+ <scope>test</scope>
+ <lang>java</lang>
+ <module>com.example.dup</module>
+ <enabled>true</enabled>
+ </source>
+ <source>
+ <scope>test</scope>
+ <lang>java</lang>
+ <module>com.example.dup</module>
+ <enabled>true</enabled>
+ </source>
+ </sources>
+ </build>
+</project>
diff --git
a/impl/maven-core/src/test/projects/project-builder/modular-java-with-explicit-source-dir/pom.xml
b/impl/maven-core/src/test/projects/project-builder/modular-java-with-explicit-source-dir/pom.xml
new file mode 100644
index 0000000000..dfab4a6b3f
--- /dev/null
+++
b/impl/maven-core/src/test/projects/project-builder/modular-java-with-explicit-source-dir/pom.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Tests AC8 scenario 2: Modular project with Java in <sources> for MAIN scope
+ and explicit legacy <sourceDirectory> that differs from default.
+
+ Expected behavior:
+ - ERROR for <sourceDirectory> (modular project, <sources> has Java for MAIN)
+ - Modular Java sources are used, not legacy
+-->
+<project xmlns="http://maven.apache.org/POM/4.1.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.1.0
https://maven.apache.org/xsd/maven-4.1.0.xsd">
+ <modelVersion>4.1.0</modelVersion>
+
+ <groupId>org.apache.maven.tests</groupId>
+ <artifactId>modular-java-with-explicit-source-dir-test</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <build>
+ <!-- Explicit legacy sourceDirectory - triggers ERROR (AC8) -->
+ <sourceDirectory>src/custom/main/java</sourceDirectory>
+
+ <sources>
+ <!-- Modular main Java source -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <module>org.foo.app</module>
+ </source>
+ </sources>
+ </build>
+</project>
diff --git
a/impl/maven-core/src/test/projects/project-builder/modular-no-test-java-with-explicit-test-source-dir/pom.xml
b/impl/maven-core/src/test/projects/project-builder/modular-no-test-java-with-explicit-test-source-dir/pom.xml
new file mode 100644
index 0000000000..dd27dac586
--- /dev/null
+++
b/impl/maven-core/src/test/projects/project-builder/modular-no-test-java-with-explicit-test-source-dir/pom.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Tests AC8 scenario 5: Modular project with NO Java in <sources> for TEST
scope
+ and explicit legacy <testSourceDirectory> that differs from default.
+
+ Expected behavior:
+ - ERROR for <testSourceDirectory> (modular project, no AC9 fallback)
+ - No test Java sources (legacy rejected, none in <sources>)
+-->
+<project xmlns="http://maven.apache.org/POM/4.1.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.1.0
https://maven.apache.org/xsd/maven-4.1.0.xsd">
+ <modelVersion>4.1.0</modelVersion>
+
+ <groupId>org.apache.maven.tests</groupId>
+
<artifactId>modular-no-test-java-with-explicit-test-source-dir-test</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <build>
+ <!-- Explicit legacy testSourceDirectory - triggers ERROR (AC8, no AC9
fallback for modular) -->
+ <testSourceDirectory>src/custom/test/java</testSourceDirectory>
+
+ <sources>
+ <!-- Modular main Java sources only - no test Java -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <module>org.foo.app</module>
+ </source>
+ <!-- No test Java sources in <sources> -->
+ </sources>
+ </build>
+</project>
diff --git
a/impl/maven-core/src/test/projects/project-builder/modular-resources-only-with-physical-legacy/pom.xml
b/impl/maven-core/src/test/projects/project-builder/modular-resources-only-with-physical-legacy/pom.xml
new file mode 100644
index 0000000000..92f8cb52a4
--- /dev/null
+++
b/impl/maven-core/src/test/projects/project-builder/modular-resources-only-with-physical-legacy/pom.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Tests AC8: Modular project with only resources in <sources> and physical
default legacy directory.
+
+ Expected behavior:
+ - Modular resources from <sources> are processed
+ - Physical src/main/java and src/test/java directories exist but would be
ignored
+ - ERROR is raised because physical default directories exist (AC8 physical
presence check)
+ - Unlike non-modular projects (AC9), modular projects cannot use legacy
directories as fallback
+
+ Note: The src/main/java and src/test/java directories are created empty to
trigger
+ the physical presence check.
+-->
+<project xmlns="http://maven.apache.org/POM/4.1.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.1.0
https://maven.apache.org/xsd/maven-4.1.0.xsd">
+ <modelVersion>4.1.0</modelVersion>
+
+ <groupId>org.apache.maven.tests</groupId>
+ <artifactId>modular-resources-only-with-physical-legacy-test</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <build>
+ <!-- No explicit sourceDirectory/testSourceDirectory - uses Super POM
defaults -->
+ <!-- But physical src/main/java and src/test/java directories exist -->
+
+ <sources>
+ <!-- Only modular resources - no Java sources -->
+ <source>
+ <scope>main</scope>
+ <lang>resources</lang>
+ <module>org.example.app</module>
+ </source>
+ <source>
+ <scope>test</scope>
+ <lang>resources</lang>
+ <module>org.example.app</module>
+ </source>
+ </sources>
+ </build>
+</project>
diff --git
a/impl/maven-core/src/test/projects/project-builder/modular-resources-only-with-physical-legacy/src/main/java/.gitkeep
b/impl/maven-core/src/test/projects/project-builder/modular-resources-only-with-physical-legacy/src/main/java/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git
a/impl/maven-core/src/test/projects/project-builder/modular-resources-only-with-physical-legacy/src/test/java/.gitkeep
b/impl/maven-core/src/test/projects/project-builder/modular-resources-only-with-physical-legacy/src/test/java/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git
a/impl/maven-core/src/test/projects/project-builder/modular-sources-with-explicit-resources/pom.xml
b/impl/maven-core/src/test/projects/project-builder/modular-sources-with-explicit-resources/pom.xml
new file mode 100644
index 0000000000..d2bd1a614b
--- /dev/null
+++
b/impl/maven-core/src/test/projects/project-builder/modular-sources-with-explicit-resources/pom.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.1.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.1.0
https://maven.apache.org/xsd/maven-4.1.0.xsd">
+ <modelVersion>4.1.0</modelVersion>
+
+ <groupId>org.apache.maven.tests</groupId>
+ <artifactId>modular-sources-explicit-resources-test</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <build>
+ <sources>
+ <!-- Module A - main java -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <module>org.foo.moduleA</module>
+ </source>
+ <!-- Module B - main java -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <module>org.foo.moduleB</module>
+ </source>
+ </sources>
+ <!-- Legacy resources are IGNORED when modular sources are configured
- a warning should be issued -->
+ <resources>
+ <resource>
+ <directory>src/custom/resources</directory>
+ </resource>
+ </resources>
+ <testResources>
+ <testResource>
+ <directory>src/custom/test-resources</directory>
+ </testResource>
+ </testResources>
+ </build>
+</project>
\ No newline at end of file
diff --git
a/impl/maven-core/src/test/projects/project-builder/modular-sources/pom.xml
b/impl/maven-core/src/test/projects/project-builder/modular-sources/pom.xml
new file mode 100644
index 0000000000..2f9b1e7b03
--- /dev/null
+++ b/impl/maven-core/src/test/projects/project-builder/modular-sources/pom.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.1.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.1.0
https://maven.apache.org/xsd/maven-4.1.0.xsd">
+ <modelVersion>4.1.0</modelVersion>
+
+ <groupId>org.apache.maven.tests</groupId>
+ <artifactId>modular-sources-test</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <build>
+ <sources>
+ <!-- Module A - main java -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <module>org.foo.moduleA</module>
+ </source>
+ <!-- Module A - test java -->
+ <source>
+ <scope>test</scope>
+ <lang>java</lang>
+ <module>org.foo.moduleA</module>
+ </source>
+ <!-- Module B - main java -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <module>org.foo.moduleB</module>
+ </source>
+ <!-- Module B - test java -->
+ <source>
+ <scope>test</scope>
+ <lang>java</lang>
+ <module>org.foo.moduleB</module>
+ </source>
+ </sources>
+ </build>
+</project>
\ No newline at end of file
diff --git
a/impl/maven-core/src/test/projects/project-builder/modular-with-physical-legacy/pom.xml
b/impl/maven-core/src/test/projects/project-builder/modular-with-physical-legacy/pom.xml
new file mode 100644
index 0000000000..27267c0bc2
--- /dev/null
+++
b/impl/maven-core/src/test/projects/project-builder/modular-with-physical-legacy/pom.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Tests AC8: Modular project with physical default legacy directory.
+
+ Expected behavior:
+ - Modular Java sources from <sources> are processed
+ - Physical src/main/java and src/test/java directories exist but would be
ignored
+ - ERROR is raised because physical default directories exist (AC8 physical
presence check)
+
+ Note: The src/main/java and src/test/java directories are created empty to
trigger
+ the physical presence check.
+-->
+<project xmlns="http://maven.apache.org/POM/4.1.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.1.0
https://maven.apache.org/xsd/maven-4.1.0.xsd">
+ <modelVersion>4.1.0</modelVersion>
+
+ <groupId>org.apache.maven.tests</groupId>
+ <artifactId>modular-with-physical-legacy-test</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <build>
+ <!-- No explicit sourceDirectory/testSourceDirectory - uses Super POM
defaults -->
+ <!-- But physical src/main/java and src/test/java directories exist -->
+
+ <sources>
+ <!-- Modular Java sources -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <module>org.example.app</module>
+ </source>
+ <source>
+ <scope>test</scope>
+ <lang>java</lang>
+ <module>org.example.app</module>
+ </source>
+ </sources>
+ </build>
+</project>
diff --git
a/impl/maven-core/src/test/projects/project-builder/modular-with-physical-legacy/src/main/java/.gitkeep
b/impl/maven-core/src/test/projects/project-builder/modular-with-physical-legacy/src/main/java/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git
a/impl/maven-core/src/test/projects/project-builder/modular-with-physical-legacy/src/test/java/.gitkeep
b/impl/maven-core/src/test/projects/project-builder/modular-with-physical-legacy/src/test/java/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git
a/impl/maven-core/src/test/projects/project-builder/multiple-directories-same-module/pom.xml
b/impl/maven-core/src/test/projects/project-builder/multiple-directories-same-module/pom.xml
new file mode 100644
index 0000000000..a1128eaa56
--- /dev/null
+++
b/impl/maven-core/src/test/projects/project-builder/multiple-directories-same-module/pom.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Tests multiple source directories for the same (lang, scope, module)
combination.
+ This is a valid use case: users may have generated sources alongside regular
sources.
+
+ Expected behavior:
+ - Both directories should be added as source roots
+ - Both should have the same module attribute
+ - The compiler will merge sources from both directories into the same output
+-->
+<project xmlns="http://maven.apache.org/POM/4.1.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.1.0
https://maven.apache.org/xsd/maven-4.1.0.xsd">
+ <modelVersion>4.1.0</modelVersion>
+
+ <groupId>org.apache.maven.tests</groupId>
+ <artifactId>multiple-directories-same-module-test</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <build>
+ <sources>
+ <!-- Primary Java source for module (uses implicit directory) -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <module>com.example.app</module>
+ </source>
+ <!-- Generated sources for the same module (explicit directory) -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <module>com.example.app</module>
+
<directory>target/generated-sources/com.example.app/java</directory>
+ </source>
+ <!-- Test sources for module -->
+ <source>
+ <scope>test</scope>
+ <lang>java</lang>
+ <module>com.example.app</module>
+ </source>
+ <!-- Generated test sources -->
+ <source>
+ <scope>test</scope>
+ <lang>java</lang>
+ <module>com.example.app</module>
+
<directory>target/generated-test-sources/com.example.app/java</directory>
+ </source>
+ </sources>
+ </build>
+</project>
diff --git
a/impl/maven-core/src/test/projects/project-builder/non-modular-resources-only-explicit-legacy/pom.xml
b/impl/maven-core/src/test/projects/project-builder/non-modular-resources-only-explicit-legacy/pom.xml
new file mode 100644
index 0000000000..2bb12cd7a6
--- /dev/null
+++
b/impl/maven-core/src/test/projects/project-builder/non-modular-resources-only-explicit-legacy/pom.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Tests AC9 violation: Non-modular project with only resources in <sources>
and explicit legacy directories.
+
+ AC9 allows implicit fallback to legacy directories (when they match
defaults).
+ This test verifies that explicit configuration (differs from default) is
rejected.
+
+ Expected behavior:
+ - Resources from <sources> are used
+ - ERROR for sourceDirectory and testSourceDirectory because they differ from
defaults
+ - No Java source roots are added (legacy rejected, none in <sources>)
+-->
+<project xmlns="http://maven.apache.org/POM/4.1.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.1.0
https://maven.apache.org/xsd/maven-4.1.0.xsd">
+ <modelVersion>4.1.0</modelVersion>
+
+ <groupId>org.apache.maven.tests</groupId>
+ <artifactId>non-modular-resources-only-explicit-legacy-test</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <build>
+ <sources>
+ <!-- Only resources configured - no Java sources -->
+ <source>
+ <scope>main</scope>
+ <lang>resources</lang>
+ <directory>src/main/custom-resources</directory>
+ </source>
+ <source>
+ <scope>test</scope>
+ <lang>resources</lang>
+ <directory>src/test/custom-resources</directory>
+ </source>
+ <!-- No <source lang="java"> elements -->
+ </sources>
+ <!-- Explicit legacy directories - triggers ERROR (differ from
default) -->
+ <sourceDirectory>src/custom/main/java</sourceDirectory>
+ <testSourceDirectory>src/custom/test/java</testSourceDirectory>
+ </build>
+</project>
diff --git
a/impl/maven-core/src/test/projects/project-builder/non-modular-resources-only/pom.xml
b/impl/maven-core/src/test/projects/project-builder/non-modular-resources-only/pom.xml
new file mode 100644
index 0000000000..12eee4001d
--- /dev/null
+++
b/impl/maven-core/src/test/projects/project-builder/non-modular-resources-only/pom.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Tests AC9: Non-modular project with only resources in <sources>.
+
+ Expected behavior:
+ - Resources from <sources> are used
+ - Legacy sourceDirectory/testSourceDirectory are used as implicit fallback
(AC9)
+ - No errors since <sources> doesn't have Java for these scopes
+-->
+<project xmlns="http://maven.apache.org/POM/4.1.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.1.0
https://maven.apache.org/xsd/maven-4.1.0.xsd">
+ <modelVersion>4.1.0</modelVersion>
+
+ <groupId>org.apache.maven.tests</groupId>
+ <artifactId>non-modular-resources-only-test</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <build>
+ <sources>
+ <!-- Only resources configured - no Java sources -->
+ <source>
+ <scope>main</scope>
+ <lang>resources</lang>
+ <directory>src/main/custom-resources</directory>
+ </source>
+ <source>
+ <scope>test</scope>
+ <lang>resources</lang>
+ <directory>src/test/custom-resources</directory>
+ </source>
+ <!-- No <source lang="java"> elements -->
+ </sources>
+ <!-- Legacy directories should be used as fallback (AC9) -->
+ <!-- Using Super POM defaults: src/main/java and src/test/java -->
+ </build>
+</project>
diff --git
a/impl/maven-core/src/test/projects/project-builder/sources-mixed-modules/pom.xml
b/impl/maven-core/src/test/projects/project-builder/sources-mixed-modules/pom.xml
new file mode 100644
index 0000000000..0c658483c0
--- /dev/null
+++
b/impl/maven-core/src/test/projects/project-builder/sources-mixed-modules/pom.xml
@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Tests that mixing modular and non-modular sources within <sources> is NOT
allowed.
+
+ Configuration:
+ - Some <source> elements WITH module (modular)
+ - Some <source> elements WITHOUT module (classic)
+
+ Expected behavior:
+ - An ERROR is reported because mixing modular and classic sources is not
supported
+ - The compiler plugin cannot handle mixed configurations
+ - A project must be either fully modular or fully classic
+-->
+<project xmlns="http://maven.apache.org/POM/4.1.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.1.0
https://maven.apache.org/xsd/maven-4.1.0.xsd">
+ <modelVersion>4.1.0</modelVersion>
+
+ <groupId>org.apache.maven.tests</groupId>
+ <artifactId>sources-mixed-modules-test</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <build>
+ <!-- This should be IGNORED because <sources> has main java -->
+ <sourceDirectory>src/should-be-ignored/java</sourceDirectory>
+
+ <sources>
+ <!-- Modular source -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <module>org.foo.moduleA</module>
+ </source>
+ <!-- Non-modular source (no module attribute) - mixing with
modular triggers ERROR -->
+ <source>
+ <scope>main</scope>
+ <lang>java</lang>
+ <!-- No module - this creates a mixed configuration which is
not allowed -->
+ </source>
+ <!-- Another modular source for test -->
+ <source>
+ <scope>test</scope>
+ <lang>java</lang>
+ <module>org.foo.moduleA</module>
+ </source>
+ </sources>
+ </build>
+</project>