diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/LifecycleDependencyResolver.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/LifecycleDependencyResolver.java index f41b00f9056a..107124ea0543 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/LifecycleDependencyResolver.java +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/LifecycleDependencyResolver.java @@ -130,10 +130,24 @@ public void resolveProjectDependencies( Map reactorProjects = new HashMap<>(session.getProjects().size()); + // Build a GAV-keyed map for fallback lookups when exact Artifact.equals() misses + // (e.g. type/classifier mismatch between resolved dependency and reactor artifact). + Map reactorGavMap = + new HashMap<>(session.getProjects().size()); for (MavenProject reactorProject : session.getProjects()) { - reactorProjects.put( - reactorProject.getArtifact(), - reactorProject.getArtifact().getFile()); + File file = reactorProject.getArtifact().getFile(); + // In the concurrent builder, a reactor project may have compiled (output directory + // exists) but not yet been packaged (artifact file is null). Use the output + // directory as a fallback so that downstream projects see the compiled classes. + if (file == null) { + File outputDir = new File(reactorProject.getBuild().getOutputDirectory()); + if (outputDir.isDirectory()) { + file = outputDir; + } + } + reactorProjects.put(reactorProject.getArtifact(), file); + String gavKey = ArtifactUtils.key(reactorProject.getArtifact()); + reactorGavMap.put(gavKey, reactorProject); } Map map = new HashMap<>(); @@ -146,6 +160,38 @@ public void resolveProjectDependencies( File reactorProjectFile = reactorProjects.get(artifact); if (reactorProjectFile != null) { artifact.setFile(reactorProjectFile); + } else if (artifact.getFile() == null) { + // Fallback: try matching reactor projects by GAV (handles type/classifier + // mismatches) or look up the artifact in the local repository. + String gavKey = ArtifactUtils.key(artifact); + MavenProject reactorProject = reactorGavMap.get(gavKey); + if (reactorProject != null) { + File fallback = reactorProject.getArtifact().getFile(); + if (fallback == null) { + File outputDir = new File(reactorProject.getBuild().getOutputDirectory()); + if (outputDir.isDirectory()) { + fallback = outputDir; + } + } + if (fallback != null) { + artifact.setFile(fallback); + } + } else { + // Non-reactor artifact with null file — the Aether resolver may not have + // resolved it (e.g. it was only collected, or a race condition in the + // concurrent builder prevented the file from being set). Try looking + // up the file in the local repository. + org.eclipse.aether.artifact.Artifact aetherArtifact = RepositoryUtils.toArtifact(artifact); + org.eclipse.aether.repository.LocalRepositoryManager lrm = + session.getRepositorySession().getLocalRepositoryManager(); + if (lrm != null) { + String path = lrm.getPathForLocalArtifact(aetherArtifact); + File localFile = new File(lrm.getRepository().getBasedir(), path); + if (localFile.isFile()) { + artifact.setFile(localFile); + } + } + } } map.put(artifact.getDependencyConflictId(), artifact); diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java index 92b3f75bee09..d7b7cfc3d56d 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java @@ -391,8 +391,11 @@ public void ensureDependenciesAreResolved( } ArtifactFilter artifactFilter = getArtifactFilter(mojoDescriptor); + // Use the project captured in the dependency context — session.getCurrentProject() + // is shared mutable state that can be overwritten by concurrent threads in the + // concurrent builder, causing the filter to be applied to the wrong project. List projectsToResolve = LifecycleDependencyResolver.getProjects( - session.getCurrentProject(), session, mojoDescriptor.isAggregator()); + dependencyContext.getProject(), session, mojoDescriptor.isAggregator()); for (MavenProject projectToResolve : projectsToResolve) { projectToResolve.setArtifactFilter(artifactFilter); } diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/concurrent/BuildPlanExecutor.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/concurrent/BuildPlanExecutor.java index cbd5ae2ed9db..f6a444b8980a 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/concurrent/BuildPlanExecutor.java +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/concurrent/BuildPlanExecutor.java @@ -43,6 +43,7 @@ import org.apache.maven.api.Lifecycle; import org.apache.maven.api.MonotonicClock; +import org.apache.maven.api.feature.Features; import org.apache.maven.api.plugin.descriptor.AfterLink; import org.apache.maven.api.services.LifecycleRegistry; import org.apache.maven.api.services.MavenException; @@ -95,6 +96,8 @@ import static org.apache.maven.api.Lifecycle.BEFORE; import static org.apache.maven.api.Lifecycle.Phase.PACKAGE; import static org.apache.maven.api.Lifecycle.Phase.READY; +import static org.apache.maven.api.Lifecycle.Phase.RESOURCES; +import static org.apache.maven.api.Lifecycle.Phase.SOURCES; import static org.apache.maven.lifecycle.internal.concurrent.BuildStep.CREATED; import static org.apache.maven.lifecycle.internal.concurrent.BuildStep.EXECUTED; import static org.apache.maven.lifecycle.internal.concurrent.BuildStep.FAILED; @@ -375,14 +378,19 @@ private void processStep(BuildStep step) { // Check if all predecessors are executed successfully boolean allPredecessorsExecuted = step.predecessors.stream().allMatch(s -> s.status.get() == EXECUTED); - // Special case for after:* steps - they should run if their corresponding before:* step ran + // Special case for after:* steps - they should run if their corresponding phase step ran if (isAfterStep) { String phaseName = step.name.substring(AFTER.length()); - // Always process after:* steps for cleanup if their before:* step ran - shouldExecute = plan.step(step.project, BEFORE + phaseName) + // Run after:* steps for cleanup if their phase step itself ran or failed. + // Check the phase step (not before:*) because before:* is an empty lifecycle + // setup step that completes early — it doesn't indicate the phase's mojos ran. + // If the phase was SKIPPED (e.g. due to a dependency cascade), the after:* + // step should also be skipped to prevent downstream steps from executing + // out of order. + shouldExecute = plan.step(step.project, phaseName) .map(s -> { int stepStatus = s.status.get(); - return stepStatus == EXECUTED; + return stepStatus == EXECUTED || stepStatus == FAILED; }) .orElse(false); @@ -562,6 +570,12 @@ private void executeStep(BuildStep step) throws IOException, LifecycleExecutionE List executions = step.executions().toList(); if (!executions.isEmpty()) { attachToThread(step); + // Set a thread-local project reference so the concurrent + // MojoExecutor can build its DependencyContext with the + // correct project. session.getCurrentProject() is racy — + // another thread can overwrite it between attachToThread + // and mojoExecutor.execute. + org.apache.maven.lifecycle.internal.concurrent.MojoExecutor.setThreadProject(step.project); clock.start(); try { executions.forEach(mojoExecution -> { @@ -569,7 +583,17 @@ private void executeStep(BuildStep step) throws IOException, LifecycleExecutionE finalizeMojoConfiguration(mojoExecution); }); mojoExecutor.execute(session, executions); + // Record lifecycle phase on the correct project directly. + // The base MojoExecutor's PhaseRecorder uses session.getCurrentProject() + // which is overwritten by concurrent threads in parallel builds, + // causing phases to be recorded on the wrong project. This ensures + // ReactorReader.determineBuildOutputDirectoryForArtifact() can find + // compiled output via project.hasLifecyclePhase(). + if (!step.name.startsWith(BEFORE) && !step.name.startsWith(AFTER)) { + step.project.addLifecyclePhase(step.name); + } } finally { + org.apache.maven.lifecycle.internal.concurrent.MojoExecutor.clearThreadProject(); clock.stop(); } } @@ -708,8 +732,16 @@ private void applyAfterLinks(MojoDescriptor mojoDescriptor, MavenProject project * matching purposes. Note that this does not perform path-scope * resolution — for example, filtering by "compile" will not include * "provided"-scoped dependencies even though they contribute to - * {@code PathScope.MAIN_COMPILE}. This keeps the filter simple and predictable; - * broader scope-aware filtering can be added in a follow-up if needed. + * {@code PathScope.MAIN_COMPILE}. + *

+ * The scope parameter is expanded to match the Maven dependency resolution semantics: + *

    + *
  • "compile" matches compile, provided, and system scoped dependencies
  • + *
  • "runtime" matches compile and runtime scoped dependencies
  • + *
  • "test" matches all scopes
  • + *
+ * This ensures that provided-scope reactor dependencies are properly ordered in the + * build plan when needed for compilation. * * @param project the project whose dependencies to check * @param upstreamProjects the list of upstream reactor projects @@ -721,14 +753,32 @@ static List filterByScope( if (scope == null || scope.isEmpty()) { return upstreamProjects; } + Set matchingScopes = expandScope(scope); return upstreamProjects.stream() .filter(dep -> project.getDependencies().stream() .anyMatch(d -> dep.getGroupId().equals(d.getGroupId()) && dep.getArtifactId().equals(d.getArtifactId()) - && scope.equals(d.getScope() != null ? d.getScope() : "compile"))) + && matchingScopes.contains(d.getScope() != null ? d.getScope() : "compile"))) .collect(Collectors.toList()); } + /** + * Expands a dependency scope identifier to the set of artifact scopes that are included + * in dependency resolution for that scope. This mirrors the semantics of + * {@link org.apache.maven.lifecycle.internal.MojoExecutor#toScopes(String)}. + */ + private static Set expandScope(String scope) { + return switch (scope) { + case "compile" -> Set.of("compile", "provided", "system"); + case "runtime" -> Set.of("compile", "runtime"); + case "compile+runtime" -> Set.of("compile", "provided", "system", "runtime"); + case "runtime+system" -> Set.of("compile", "system", "runtime"); + case "test" -> Set.of("compile", "provided", "system", "runtime", "test"); + case "test-only" -> Set.of("test"); + default -> Set.of(scope); + }; + } + protected BuildPlan computeForkPlan(BuildStep step, MojoExecution execution, BuildPlan buildPlan) { MojoDescriptor mojoDescriptor = execution.getMojoDescriptor(); PluginDescriptor pluginDescriptor = mojoDescriptor.getPluginDescriptor(); @@ -1020,6 +1070,22 @@ public BuildPlan calculateLifecycleMappings( }); }); + // Maven 3 personality: enforce sequential SOURCES → RESOURCES ordering. + // In Maven 3, generate-resources always ran after process-sources, so plugins + // bound to resource phases could rely on source generation being complete. + // The V4 lifecycle allows them to run in parallel; V4-native plugins should + // use @After(phase="sources") to declare the dependency explicitly. + @SuppressWarnings("unchecked") + Map userProps = + session != null ? (Map) (Map) session.getUserProperties() : null; + if (Features.mavenMaven3Personality(userProps)) { + BuildStep afterSources = steps.get(AFTER + SOURCES); + BuildStep beforeResources = steps.get(BEFORE + RESOURCES); + if (afterSources != null && beforeResources != null) { + beforeResources.executeAfter(afterSources); + } + } + // Only keep mojo executions before the end phase String endPhase = lifecyclePhase.startsWith(BEFORE) || lifecyclePhase.startsWith(AFTER) ? lifecyclePhase diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/concurrent/MojoExecutor.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/concurrent/MojoExecutor.java index 273035b82c13..53963b3e74d0 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/concurrent/MojoExecutor.java +++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/concurrent/MojoExecutor.java @@ -23,18 +23,35 @@ import javax.inject.Provider; import javax.inject.Singleton; +import java.util.List; + import org.apache.maven.api.services.MessageBuilderFactory; import org.apache.maven.execution.MavenSession; +import org.apache.maven.lifecycle.internal.DependencyContext; import org.apache.maven.lifecycle.internal.ExecutionEventCatapult; import org.apache.maven.lifecycle.internal.LifecycleDependencyResolver; import org.apache.maven.plugin.BuildPluginManager; import org.apache.maven.plugin.MavenPluginManager; +import org.apache.maven.plugin.MojoExecution; import org.apache.maven.plugin.MojosExecutionStrategy; +import org.apache.maven.project.MavenProject; +/** + * Concurrent-builder variant of the mojo executor. + *

+ * In the concurrent builder, multiple build steps for different projects + * execute in parallel. The shared {@code MavenSession.currentProject} field + * is therefore unreliable — another thread can overwrite it between the + * moment a step attaches to its thread and the moment the mojo executor + * reads it. This subclass uses a {@link ThreadLocal} to provide each + * executor thread with its own, stable reference to the project being built. + */ @Named("concurrent") @Singleton public class MojoExecutor extends org.apache.maven.lifecycle.internal.MojoExecutor { + private static final ThreadLocal THREAD_PROJECT = new ThreadLocal<>(); + @Inject public MojoExecutor( BuildPluginManager pluginManager, @@ -56,4 +73,40 @@ public MojoExecutor( protected boolean useProjectLock(MavenSession session) { return false; } + + /** + * Set the project for the current executor thread. Called by + * {@link BuildPlanExecutor} before invoking + * {@link #execute(MavenSession, List)} so that + * {@link #newDependencyContext} picks up the correct project + * regardless of what {@code session.getCurrentProject()} returns. + */ + static void setThreadProject(MavenProject project) { + THREAD_PROJECT.set(project); + } + + /** Clear the thread-local project reference after execution. */ + static void clearThreadProject() { + THREAD_PROJECT.remove(); + } + + /** + * Override to use the thread-local project instead of + * {@code session.getCurrentProject()}, which is racy in the + * concurrent builder. + */ + @Override + public DependencyContext newDependencyContext(MavenSession session, List mojoExecutions) { + DependencyContext ctx = super.newDependencyContext(session, mojoExecutions); + MavenProject threadProject = THREAD_PROJECT.get(); + if (threadProject != null && threadProject != ctx.getProject()) { + // The super method captured the wrong project from the racy + // session.currentProject — rebuild with the correct one. + return new DependencyContext( + threadProject, + ctx.getScopesToCollectForCurrentProject(), + ctx.getScopesToResolveForCurrentProject()); + } + return ctx; + } } diff --git a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/concurrent/BuildPlanCreatorTest.java b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/concurrent/BuildPlanCreatorTest.java index 6a08b7ee35ed..a6939ed2e5f6 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/concurrent/BuildPlanCreatorTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/concurrent/BuildPlanCreatorTest.java @@ -240,40 +240,49 @@ void testFilterByScopeNullReturnsAll() { } /** - * Tests that {@code filterByScope} filters upstream projects by exact scope match, - * treating null-scoped dependencies as "compile" (Maven default). + * Tests that {@code filterByScope} expands scopes to match Maven dependency resolution semantics. + * "compile" includes compile, provided, and system scoped dependencies (plus null-scoped which + * defaults to compile). This ensures provided-scope reactor dependencies are properly ordered in + * the build plan when needed at compile time. */ @Test void testFilterByScopeMatchesExact() { MavenProject compileDep = createProjectWithId("g", "compile-dep"); MavenProject providedDep = createProjectWithId("g", "provided-dep"); + MavenProject systemDep = createProjectWithId("g", "system-dep"); + MavenProject runtimeDep = createProjectWithId("g", "runtime-dep"); MavenProject testDep = createProjectWithId("g", "test-dep"); MavenProject nullScopeDep = createProjectWithId("g", "null-scope-dep"); - List upstream = List.of(compileDep, providedDep, testDep, nullScopeDep); + List upstream = List.of(compileDep, providedDep, systemDep, runtimeDep, testDep, nullScopeDep); MavenProject consumer = new MavenProject(); consumer.getDependencies().add(createDependency("g", "compile-dep", "compile")); consumer.getDependencies().add(createDependency("g", "provided-dep", "provided")); + consumer.getDependencies().add(createDependency("g", "system-dep", "system")); + consumer.getDependencies().add(createDependency("g", "runtime-dep", "runtime")); consumer.getDependencies().add(createDependency("g", "test-dep", "test")); consumer.getDependencies().add(createDependency("g", "null-scope-dep", null)); - // "compile" matches explicit compile + null-scoped (Maven default is compile) + // "compile" scope expands to compile + provided + system + null-scoped (Maven default) List compileFiltered = BuildPlanExecutor.BuildContext.filterByScope(consumer, upstream, "compile"); - assertEquals(2, compileFiltered.size()); + assertEquals(4, compileFiltered.size()); assertTrue(compileFiltered.contains(compileDep)); + assertTrue(compileFiltered.contains(providedDep)); + assertTrue(compileFiltered.contains(systemDep)); assertTrue(compileFiltered.contains(nullScopeDep)); - // "provided" matches only provided-scoped - List providedFiltered = - BuildPlanExecutor.BuildContext.filterByScope(consumer, upstream, "provided"); - assertEquals(1, providedFiltered.size()); - assertTrue(providedFiltered.contains(providedDep)); + // "runtime" scope expands to compile + runtime + List runtimeFiltered = + BuildPlanExecutor.BuildContext.filterByScope(consumer, upstream, "runtime"); + assertEquals(3, runtimeFiltered.size()); + assertTrue(runtimeFiltered.contains(compileDep)); + assertTrue(runtimeFiltered.contains(runtimeDep)); + assertTrue(runtimeFiltered.contains(nullScopeDep)); - // "test" matches only test-scoped + // "test" scope expands to all scopes List testFiltered = BuildPlanExecutor.BuildContext.filterByScope(consumer, upstream, "test"); - assertEquals(1, testFiltered.size()); - assertTrue(testFiltered.contains(testDep)); + assertEquals(6, testFiltered.size()); } /**