Skip to content

Commit 78ed4a2

Browse files
Consumer POM of multi-module project should exclude <build> and <dependencies> elements (#11639)
* Move some of the codes needed by `ProjectSourcesHelper` in an utility class that we can reuse in other packages. * Consumer POM of multi-module project should exclude <build> and <dependencies> elements. * Add a test that verifies that `<build>` is preserved when `preserveModelVersion=true`. Co-authored-by: Gerd Aschemann <gerd@aschemann.net>
1 parent c94b33c commit 78ed4a2

File tree

7 files changed

+281
-112
lines changed

7 files changed

+281
-112
lines changed

impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/ConsumerPomArtifactTransformer.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ public void injectTransformedArtifacts(RepositorySystemSession session, MavenPro
9696
}
9797
}
9898

99-
TransformedArtifact createConsumerPomArtifact(
99+
private TransformedArtifact createConsumerPomArtifact(
100100
MavenProject project, Path consumer, RepositorySystemSession session) {
101101
Path actual = project.getFile().toPath();
102102
Path parent = project.getBaseDirectory();

impl/maven-core/src/main/java/org/apache/maven/internal/transformation/impl/DefaultConsumerPomBuilder.java

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.apache.maven.api.model.DistributionManagement;
3838
import org.apache.maven.api.model.Model;
3939
import org.apache.maven.api.model.ModelBase;
40+
import org.apache.maven.api.model.Parent;
4041
import org.apache.maven.api.model.Profile;
4142
import org.apache.maven.api.model.Repository;
4243
import org.apache.maven.api.model.Scm;
@@ -50,6 +51,7 @@
5051
import org.apache.maven.impl.InternalSession;
5152
import org.apache.maven.model.v4.MavenModelVersion;
5253
import org.apache.maven.project.MavenProject;
54+
import org.apache.maven.project.SourceQueries;
5355
import org.eclipse.aether.RepositorySystemSession;
5456
import org.slf4j.Logger;
5557
import org.slf4j.LoggerFactory;
@@ -342,7 +344,7 @@ static Model transformNonPom(Model model, MavenProject project) {
342344
return model;
343345
}
344346

345-
static Model transformBom(Model model, MavenProject project) {
347+
private static Model transformBom(Model model, MavenProject project) {
346348
boolean preserveModelVersion = model.isPreserveModelVersion();
347349

348350
Model.Builder builder = prune(
@@ -369,19 +371,33 @@ static Model transformPom(Model model, MavenProject project) {
369371

370372
// raw to consumer transform
371373
model = model.withRoot(false).withModules(null).withSubprojects(null);
372-
if (model.getParent() != null) {
373-
model = model.withParent(model.getParent().withRelativePath(null));
374+
Parent parent = model.getParent();
375+
if (parent != null) {
376+
model = model.withParent(parent.withRelativePath(null));
377+
}
378+
var projectSources = project.getBuild().getDelegate().getSources();
379+
if (SourceQueries.usesModuleSourceHierarchy(projectSources)) {
380+
// Dependencies are dispatched by maven-jar-plugin in the POM generated for each module.
381+
model = model.withDependencies(null).withPackaging(POM_PACKAGING);
374382
}
375-
376383
if (!preserveModelVersion) {
384+
/*
385+
* If the <build> contains <source> elements, it is not compatible with the Maven 4.0.0 model.
386+
* Remove the full <build> element instead of removing only the <sources> element, because the
387+
* build without sources does not mean much. Reminder: this removal can be disabled by setting
388+
* the `preserveModelVersion` XML attribute or `preserve.model.version` property to true.
389+
*/
390+
if (SourceQueries.hasEnabledSources(projectSources)) {
391+
model = model.withBuild(null);
392+
}
377393
model = model.withPreserveModelVersion(false);
378394
String modelVersion = new MavenModelVersion().getModelVersion(model);
379395
model = model.withModelVersion(modelVersion);
380396
}
381397
return model;
382398
}
383399

384-
static void warnNotDowngraded(MavenProject project) {
400+
private static void warnNotDowngraded(MavenProject project) {
385401
LOGGER.warn("The consumer POM for " + project.getId() + " cannot be downgraded to 4.0.0. "
386402
+ "If you intent your build to be consumed with Maven 3 projects, you need to remove "
387403
+ "the features that request a newer model version. If you're fine with having the "

impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuilder.java

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -655,7 +655,6 @@ private void initProject(MavenProject project, ModelBuilderResult result) {
655655
// only set those on 2nd phase, ignore on 1st pass
656656
if (project.getFile() != null) {
657657
Build build = project.getBuild().getDelegate();
658-
List<org.apache.maven.api.model.Source> sources = build.getSources();
659658
Path baseDir = project.getBaseDirectory();
660659
Function<ProjectScope, String> outputDirectory = (scope) -> {
661660
if (scope == ProjectScope.MAIN) {
@@ -666,23 +665,11 @@ private void initProject(MavenProject project, ModelBuilderResult result) {
666665
return build.getDirectory();
667666
}
668667
};
669-
// Extract modules from sources to detect modular projects
670-
Set<String> modules = extractModules(sources);
671-
boolean isModularProject = !modules.isEmpty();
672-
673-
logger.trace(
674-
"Module detection for project {}: found {} module(s) {} - modular project: {}.",
675-
project.getId(),
676-
modules.size(),
677-
modules,
678-
isModularProject);
679-
680668
// Create source handling context for unified tracking of all lang/scope combinations
681-
SourceHandlingContext sourceContext =
682-
new SourceHandlingContext(project, baseDir, modules, isModularProject, result);
669+
final SourceHandlingContext sourceContext = new SourceHandlingContext(project, result);
683670

684671
// Process all sources, tracking enabled ones and detecting duplicates
685-
for (var source : sources) {
672+
for (org.apache.maven.api.model.Source source : sourceContext.sources) {
686673
var sourceRoot = DefaultSourceRoot.fromModel(session, baseDir, outputDirectory, source);
687674
// Track enabled sources for duplicate detection and hasSources() queries
688675
// 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) {
711698
implicit fallback (only if they match the default, e.g., inherited)
712699
- This allows incremental adoption (e.g., custom resources + default Java)
713700
*/
714-
if (sources.isEmpty()) {
701+
if (sourceContext.sources.isEmpty()) {
715702
// Classic fallback: no <sources> configured, use legacy directories
716703
project.addScriptSourceRoot(build.getScriptSourceDirectory());
717704
project.addCompileSourceRoot(build.getSourceDirectory());
@@ -724,8 +711,7 @@ implicit fallback (only if they match the default, e.g., inherited)
724711
if (!sourceContext.hasSources(Language.SCRIPT, ProjectScope.MAIN)) {
725712
project.addScriptSourceRoot(build.getScriptSourceDirectory());
726713
}
727-
728-
if (isModularProject) {
714+
if (sourceContext.usesModuleSourceHierarchy()) {
729715
// Modular: reject ALL legacy directory configurations
730716
failIfLegacyDirectoryPresent(
731717
build.getSourceDirectory(),
@@ -1243,22 +1229,6 @@ public Set<Entry<K, V>> entrySet() {
12431229
}
12441230
}
12451231

1246-
/**
1247-
* Extracts unique module names from the given list of source elements.
1248-
* A project is considered modular if it has at least one module name.
1249-
*
1250-
* @param sources list of source elements from the build
1251-
* @return set of non-blank module names
1252-
*/
1253-
private static Set<String> extractModules(List<org.apache.maven.api.model.Source> sources) {
1254-
return sources.stream()
1255-
.map(org.apache.maven.api.model.Source::getModule)
1256-
.filter(Objects::nonNull)
1257-
.map(String::trim)
1258-
.filter(s -> !s.isBlank())
1259-
.collect(Collectors.toSet());
1260-
}
1261-
12621232
private Model injectLifecycleBindings(
12631233
Model model,
12641234
ModelBuilderRequest request,

impl/maven-core/src/main/java/org/apache/maven/project/SourceHandlingContext.java

Lines changed: 67 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import org.apache.maven.api.ProjectScope;
2828
import org.apache.maven.api.SourceRoot;
2929
import org.apache.maven.api.model.Resource;
30+
import org.apache.maven.api.model.Source;
3031
import org.apache.maven.api.services.BuilderProblem.Severity;
3132
import org.apache.maven.api.services.ModelBuilderResult;
3233
import org.apache.maven.api.services.ModelProblem.Version;
@@ -37,9 +38,7 @@
3738

3839
/**
3940
* Handles source configuration for Maven projects with unified tracking for all language/scope combinations.
40-
* <p>
41-
* This class replaces the previous approach of hardcoded boolean flags (hasMain, hasTest, etc.)
42-
* with a flexible set-based tracking mechanism that works for any language and scope combination.
41+
* This class uses a flexible set-based tracking mechanism that works for any language and scope combination.
4342
* <p>
4443
* Key features:
4544
* <ul>
@@ -51,7 +50,7 @@
5150
*
5251
* @since 4.0.0
5352
*/
54-
class SourceHandlingContext {
53+
final class SourceHandlingContext {
5554

5655
private static final Logger LOGGER = LoggerFactory.getLogger(SourceHandlingContext.class);
5756

@@ -60,26 +59,38 @@ class SourceHandlingContext {
6059
*/
6160
record SourceKey(Language language, ProjectScope scope, String module, Path directory) {}
6261

62+
/**
63+
* The {@code <source>} elements declared in the {@code <build>} elements.
64+
*/
65+
final List<Source> sources;
66+
6367
private final MavenProject project;
64-
private final Path baseDir;
6568
private final Set<String> modules;
66-
private final boolean modularProject;
6769
private final ModelBuilderResult result;
6870
private final Set<SourceKey> declaredSources;
6971

70-
SourceHandlingContext(
71-
MavenProject project,
72-
Path baseDir,
73-
Set<String> modules,
74-
boolean modularProject,
75-
ModelBuilderResult result) {
72+
SourceHandlingContext(MavenProject project, ModelBuilderResult result) {
7673
this.project = project;
77-
this.baseDir = baseDir;
78-
this.modules = modules;
79-
this.modularProject = modularProject;
74+
this.sources = project.getBuild().getDelegate().getSources();
75+
this.modules = SourceQueries.getModuleNames(sources);
8076
this.result = result;
8177
// Each module typically has main, test, main resources, test resources = 4 sources
8278
this.declaredSources = new HashSet<>(4 * modules.size());
79+
if (usesModuleSourceHierarchy()) {
80+
LOGGER.trace("Found {} module(s) in the \"{}\" project: {}.", project.getId(), modules.size(), modules);
81+
} else {
82+
LOGGER.trace("Project \"{}\" is non-modular.", project.getId());
83+
}
84+
}
85+
86+
/**
87+
* Whether the project uses module source hierarchy.
88+
* Note that this is not synonymous of whether the project is modular,
89+
* because it is possible to create a single Java module in a classic Maven project
90+
* (i.e., using package hierarchy).
91+
*/
92+
boolean usesModuleSourceHierarchy() {
93+
return !modules.isEmpty();
8394
}
8495

8596
/**
@@ -112,7 +123,7 @@ boolean shouldAddSource(SourceRoot sourceRoot) {
112123
SourceKey key = new SourceKey(
113124
sourceRoot.language(), sourceRoot.scope(), sourceRoot.module().orElse(null), normalizedDir);
114125

115-
if (declaredSources.contains(key)) {
126+
if (!declaredSources.add(key)) {
116127
String message = String.format(
117128
"Duplicate enabled source detected: lang=%s, scope=%s, module=%s, directory=%s. "
118129
+ "First enabled source wins, this duplicate is ignored.",
@@ -130,7 +141,6 @@ boolean shouldAddSource(SourceRoot sourceRoot) {
130141
return false; // Don't add duplicate enabled source
131142
}
132143

133-
declaredSources.add(key);
134144
LOGGER.debug(
135145
"Adding and tracking enabled source: lang={}, scope={}, module={}, dir={}",
136146
key.language(),
@@ -151,6 +161,13 @@ boolean hasSources(Language language, ProjectScope scope) {
151161
return declaredSources.stream().anyMatch(key -> language.equals(key.language()) && scope.equals(key.scope()));
152162
}
153163

164+
/**
165+
* {@return the source directory as defined by Maven conventions}
166+
*/
167+
private Path getStandardSourceDirectory() {
168+
return project.getBaseDirectory().resolve("src");
169+
}
170+
154171
/**
155172
* Fails the build if modular and classic (non-modular) sources are mixed within {@code <sources>}.
156173
* <p>
@@ -164,30 +181,32 @@ boolean hasSources(Language language, ProjectScope scope) {
164181
void failIfMixedModularAndClassicSources() {
165182
for (ProjectScope scope : List.of(ProjectScope.MAIN, ProjectScope.TEST)) {
166183
for (Language language : List.of(Language.JAVA_FAMILY, Language.RESOURCES)) {
167-
boolean hasModular = declaredSources.stream()
168-
.anyMatch(key ->
169-
language.equals(key.language()) && scope.equals(key.scope()) && key.module() != null);
170-
boolean hasClassic = declaredSources.stream()
171-
.anyMatch(key ->
172-
language.equals(key.language()) && scope.equals(key.scope()) && key.module() == null);
173-
174-
if (hasModular && hasClassic) {
175-
String message = String.format(
176-
"Mixed modular and classic sources detected for lang=%s, scope=%s. "
177-
+ "A project must be either fully modular (all sources have a module) "
178-
+ "or fully classic (no sources have a module). "
179-
+ "The compiler plugin cannot handle mixed configurations.",
180-
language.id(), scope.id());
181-
LOGGER.error(message);
182-
result.getProblemCollector()
183-
.reportProblem(new DefaultModelProblem(
184-
message,
185-
Severity.ERROR,
186-
Version.V41,
187-
project.getModel().getDelegate(),
188-
-1,
189-
-1,
190-
null));
184+
boolean hasModular = false;
185+
boolean hasClassic = false;
186+
for (SourceKey key : declaredSources) {
187+
if (language.equals(key.language()) && scope.equals(key.scope())) {
188+
String module = key.module();
189+
hasModular |= (module != null);
190+
hasClassic |= (module == null);
191+
if (hasModular && hasClassic) {
192+
String message = String.format(
193+
"Mixed modular and classic sources detected for lang=%s, scope=%s. "
194+
+ "A project must be either fully modular (all sources have a module) "
195+
+ "or fully classic (no sources have a module).",
196+
language.id(), scope.id());
197+
LOGGER.error(message);
198+
result.getProblemCollector()
199+
.reportProblem(new DefaultModelProblem(
200+
message,
201+
Severity.ERROR,
202+
Version.V41,
203+
project.getModel().getDelegate(),
204+
-1,
205+
-1,
206+
null));
207+
break;
208+
}
209+
}
191210
}
192211
}
193212
}
@@ -219,7 +238,7 @@ void handleResourceConfiguration(ProjectScope scope) {
219238
? "<source><lang>resources</lang></source>"
220239
: "<source><lang>resources</lang><scope>test</scope></source>";
221240

222-
if (modularProject) {
241+
if (usesModuleSourceHierarchy()) {
223242
if (hasResourcesInSources) {
224243
// Modular project with resources configured via <sources> - already added above
225244
if (hasExplicitLegacyResources(resources, scopeId)) {
@@ -298,6 +317,7 @@ void handleResourceConfiguration(ProjectScope scope) {
298317
// Use legacy resources element
299318
LOGGER.debug(
300319
"Using explicit or default {} resources ({} resources configured).", scopeId, resources.size());
320+
Path baseDir = project.getBaseDirectory();
301321
for (Resource resource : resources) {
302322
project.addSourceRoot(new DefaultSourceRoot(baseDir, scope, resource));
303323
}
@@ -315,7 +335,7 @@ void handleResourceConfiguration(ProjectScope scope) {
315335
*/
316336
private DefaultSourceRoot createModularResourceRoot(String module, ProjectScope scope) {
317337
Path resourceDir =
318-
baseDir.resolve("src").resolve(module).resolve(scope.id()).resolve("resources");
338+
getStandardSourceDirectory().resolve(module).resolve(scope.id()).resolve("resources");
319339

320340
return new DefaultSourceRoot(
321341
scope,
@@ -345,12 +365,10 @@ private boolean hasExplicitLegacyResources(List<Resource> resources, String scop
345365
}
346366

347367
// Super POM default paths
348-
String defaultPath =
349-
baseDir.resolve("src").resolve(scope).resolve("resources").toString();
350-
String defaultFilteredPath = baseDir.resolve("src")
351-
.resolve(scope)
352-
.resolve("resources-filtered")
353-
.toString();
368+
Path srcDir = getStandardSourceDirectory();
369+
String defaultPath = srcDir.resolve(scope).resolve("resources").toString();
370+
String defaultFilteredPath =
371+
srcDir.resolve(scope).resolve("resources-filtered").toString();
354372

355373
// Check if any resource differs from Super POM defaults
356374
for (Resource resource : resources) {

0 commit comments

Comments
 (0)