You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/07/28 09:33:22 UTC
(maven) branch master updated: Fix #12534: Wire up @After annotation processing in Maven core
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven.git
The following commit(s) were added to refs/heads/master by this push:
new 0003334f5c Fix #12534: Wire up @After annotation processing in Maven core
0003334f5c is described below
commit 0003334f5c3fcd6035c9e2c5c29e3dba4c4ea9de
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Tue Jul 28 11:33:16 2026 +0200
Fix #12534: Wire up @After annotation processing in Maven core
Makes @After repeatable (@Afters container), defaults type() to PROJECT,
and wires up annotation processing in BuildPlanExecutor so that mojos
annotated with @After correctly declare ordering constraints. Includes
unit test and integration test (mng-12534-after-annotation).
Closes #12535
---
.../apache/maven/api/plugin/annotations/After.java | 4 +-
.../plugin/annotations/{After.java => Afters.java} | 28 +---
.../plugin/annotations/AfterAnnotationTest.java | 97 +++++++++++
api/maven-api-plugin/src/main/mdo/plugin.mdo | 55 +++++++
.../internal/concurrent/BuildPlanExecutor.java | 84 +++++++++-
.../internal/concurrent/BuildPlanCreatorTest.java | 179 +++++++++++++++++++++
.../it/MavenITmng12534AfterAnnotationTest.java | 66 ++++++++
.../mng-12534-after-annotation/consumer/pom.xml | 29 ++++
.../mng-12534-after-annotation/plugin/pom.xml | 48 ++++++
.../org/apache/maven/its/mng12534/TouchMojo.java | 63 ++++++++
.../maven/its/mng12534/TouchMojoFactory.java | 32 ++++
.../META-INF/maven/org.apache.maven.api.di.Inject | 1 +
.../src/main/resources/META-INF/maven/plugin.xml | 23 +++
13 files changed, 682 insertions(+), 27 deletions(-)
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/annotations/After.java b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/annotations/After.java
index 697b9d4802..3e86bfa2c0 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/annotations/After.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/annotations/After.java
@@ -21,6 +21,7 @@
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
+import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@@ -37,6 +38,7 @@
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Inherited
+@Repeatable(Afters.class)
public @interface After {
/**
@@ -57,7 +59,7 @@ enum Type {
/**
* The type of this pointer.
*/
- Type type();
+ Type type() default Type.PROJECT;
/**
* The scope for dependencies, only if {@code type() == Type.Dependencies}.
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/annotations/After.java b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/annotations/Afters.java
similarity index 71%
copy from api/maven-api-core/src/main/java/org/apache/maven/api/plugin/annotations/After.java
copy to api/maven-api-core/src/main/java/org/apache/maven/api/plugin/annotations/Afters.java
index 697b9d4802..c00ee72de8 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/annotations/After.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/plugin/annotations/Afters.java
@@ -28,7 +28,7 @@
import org.apache.maven.api.annotations.Experimental;
/**
- * Specifies that the mojo should be run after the specific phase.
+ * Container annotation for repeatable {@link After} annotations.
*
* @since 4.0.0
*/
@@ -37,30 +37,10 @@
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Inherited
-public @interface After {
+public @interface Afters {
/**
- * Type of pointer.
- * @see org.apache.maven.api.Lifecycle.Pointer.Type
+ * The contained {@link After} annotations.
*/
- enum Type {
- PROJECT,
- DEPENDENCIES,
- CHILDREN
- }
-
- /**
- * The phase name.
- */
- String phase();
-
- /**
- * The type of this pointer.
- */
- Type type();
-
- /**
- * The scope for dependencies, only if {@code type() == Type.Dependencies}.
- */
- String scope() default "";
+ After[] value();
}
diff --git a/api/maven-api-core/src/test/java/org/apache/maven/api/plugin/annotations/AfterAnnotationTest.java b/api/maven-api-core/src/test/java/org/apache/maven/api/plugin/annotations/AfterAnnotationTest.java
new file mode 100644
index 0000000000..4c681b778d
--- /dev/null
+++ b/api/maven-api-core/src/test/java/org/apache/maven/api/plugin/annotations/AfterAnnotationTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.api.plugin.annotations;
+
+import java.lang.annotation.Repeatable;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests the {@link After} annotation, including its {@link Repeatable} behavior.
+ */
+class AfterAnnotationTest {
+
+ @After(phase = "compile", type = After.Type.PROJECT)
+ static class SingleAfterMojo {}
+
+ @After(phase = "sources")
+ static class DefaultTypeMojo {}
+
+ @After(phase = "compile", type = After.Type.PROJECT)
+ @After(phase = "ready", type = After.Type.DEPENDENCIES, scope = "compile")
+ @After(phase = "package", type = After.Type.CHILDREN)
+ static class MultipleAfterMojo {}
+
+ @Test
+ void afterIsRepeatable() {
+ assertTrue(After.class.isAnnotationPresent(Repeatable.class));
+ assertEquals(Afters.class, After.class.getAnnotation(Repeatable.class).value());
+ }
+
+ @Test
+ void singleAfterAnnotation() {
+ After after = SingleAfterMojo.class.getAnnotation(After.class);
+ assertNotNull(after);
+ assertEquals("compile", after.phase());
+ assertEquals(After.Type.PROJECT, after.type());
+ assertEquals("", after.scope());
+ }
+
+ @Test
+ void multipleAfterAnnotations() {
+ After[] afters = MultipleAfterMojo.class.getAnnotationsByType(After.class);
+ assertNotNull(afters);
+ assertEquals(3, afters.length);
+
+ assertEquals("compile", afters[0].phase());
+ assertEquals(After.Type.PROJECT, afters[0].type());
+
+ assertEquals("ready", afters[1].phase());
+ assertEquals(After.Type.DEPENDENCIES, afters[1].type());
+ assertEquals("compile", afters[1].scope());
+
+ assertEquals("package", afters[2].phase());
+ assertEquals(After.Type.CHILDREN, afters[2].type());
+ }
+
+ @Test
+ void aftersContainerAnnotation() {
+ Afters afters = MultipleAfterMojo.class.getAnnotation(Afters.class);
+ assertNotNull(afters);
+ assertEquals(3, afters.value().length);
+ }
+
+ @Test
+ void typeDefaultsToProject() {
+ After after = DefaultTypeMojo.class.getAnnotation(After.class);
+ assertNotNull(after);
+ assertEquals("sources", after.phase());
+ assertEquals(After.Type.PROJECT, after.type());
+ }
+
+ @Test
+ void scopeDefaultsToEmpty() {
+ After after = SingleAfterMojo.class.getAnnotation(After.class);
+ assertEquals("", after.scope());
+ }
+}
diff --git a/api/maven-api-plugin/src/main/mdo/plugin.mdo b/api/maven-api-plugin/src/main/mdo/plugin.mdo
index 5eeaaf3315..d987ed29b1 100644
--- a/api/maven-api-plugin/src/main/mdo/plugin.mdo
+++ b/api/maven-api-plugin/src/main/mdo/plugin.mdo
@@ -425,6 +425,21 @@ under the License.
<type>String</type>
<description>the full goal name</description>
</field>
+ <field xdoc.separator="blank">
+ <name>afterLinks</name>
+ <version>2.0.0+</version>
+ <description>
+ Lifecycle ordering constraints declared by {@code @After} annotations on the Mojo class.
+ Each entry specifies that this Mojo's bound phase should execute after a given target phase,
+ with a pointer type ({@code PROJECT}, {@code DEPENDENCIES}, or {@code CHILDREN}) that controls
+ how the ordering applies across project boundaries.
+ @since 4.0.0
+ </description>
+ <association>
+ <type>AfterLink</type>
+ <multiplicity>*</multiplicity>
+ </association>
+ </field>
</fields>
</class>
@@ -585,6 +600,46 @@ under the License.
</fields>
</class>
+ <class xdoc.anchorName="afterLink">
+ <name>AfterLink</name>
+ <version>2.0.0+</version>
+ <description>
+ A lifecycle ordering constraint from an {@code @After} annotation on a Mojo class.
+ Specifies that the Mojo's bound phase should execute after a given target phase,
+ with a pointer type controlling how the ordering applies across project boundaries.
+ @since 4.0.0
+ </description>
+ <fields>
+ <field>
+ <name>phase</name>
+ <required>true</required>
+ <version>2.0.0+</version>
+ <type>String</type>
+ <description>The target phase name that this Mojo should run after.</description>
+ </field>
+ <field>
+ <name>type</name>
+ <required>true</required>
+ <version>2.0.0+</version>
+ <type>String</type>
+ <description>
+ The type of pointer: {@code PROJECT} (same-project phase ordering),
+ {@code DEPENDENCIES} (cross-project dependency ordering),
+ or {@code CHILDREN} (parent-child module ordering).
+ </description>
+ </field>
+ <field>
+ <name>scope</name>
+ <version>2.0.0+</version>
+ <type>String</type>
+ <description>
+ The dependency scope, only meaningful when type is {@code DEPENDENCIES}.
+ Examples: {@code compile}, {@code runtime}, {@code test}.
+ </description>
+ </field>
+ </fields>
+ </class>
+
<class xdoc.anchorName="resolution">
<name>Resolution</name>
<version>2.0.0+</version>
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 460d15bb32..cbd5ae2ed9 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.plugin.descriptor.AfterLink;
import org.apache.maven.api.services.LifecycleRegistry;
import org.apache.maven.api.services.MavenException;
import org.apache.maven.api.xml.XmlNode;
@@ -622,6 +623,8 @@ private void plan() {
.executeAfter(a));
}
}
+ // Apply @After annotation ordering constraints from the mojo descriptor
+ applyAfterLinks(mojoDescriptor, project, resolvedPhase);
});
}
}
@@ -649,6 +652,83 @@ private void plan() {
}
}
+ /**
+ * Applies lifecycle ordering constraints from {@code @After} annotations on a mojo descriptor.
+ * Each {@link AfterLink} is translated into build step ordering edges, matching the same
+ * semantics as {@link Lifecycle.Link} processing in {@code calculateLifecycleMappings}.
+ *
+ * @param mojoDescriptor the mojo descriptor that may contain after links
+ * @param project the project the mojo is bound to
+ * @param resolvedPhase the resolved phase the mojo is bound to
+ */
+ private void applyAfterLinks(MojoDescriptor mojoDescriptor, MavenProject project, String resolvedPhase) {
+ List<AfterLink> afterLinks = mojoDescriptor.getMojoDescriptorV4().getAfterLinks();
+ if (afterLinks == null || afterLinks.isEmpty()) {
+ return;
+ }
+ for (AfterLink afterLink : afterLinks) {
+ String targetPhase = afterLink.getPhase();
+ String type = afterLink.getType();
+ if ("PROJECT".equals(type)) {
+ // Same-project ordering: this phase starts after target phase completes
+ plan.step(project, AFTER + targetPhase)
+ .ifPresent(targetAfter -> plan.requiredStep(project, BEFORE + resolvedPhase)
+ .executeAfter(targetAfter));
+ } else if ("DEPENDENCIES".equals(type)) {
+ // Cross-project ordering: this phase starts after each dependency's target phase completes
+ String scope = afterLink.getScope();
+ for (MavenProject dep :
+ filterByScope(project, plan.getAllProjects().get(project), scope)) {
+ plan.step(dep, AFTER + targetPhase)
+ .ifPresent(depAfter -> plan.requiredStep(project, BEFORE + resolvedPhase)
+ .executeAfter(depAfter));
+ }
+ } else if ("CHILDREN".equals(type)) {
+ // Parent-child ordering: bidirectional coordination with child modules
+ BuildStep before = plan.requiredStep(project, BEFORE + resolvedPhase);
+ BuildStep after = plan.requiredStep(project, AFTER + resolvedPhase);
+ if (project.getCollectedProjects() != null) {
+ project.getCollectedProjects().forEach(child -> {
+ plan.step(child, BEFORE + targetPhase).ifPresent(before::executeBefore);
+ plan.step(child, AFTER + targetPhase).ifPresent(after::executeAfter);
+ });
+ }
+ }
+ }
+ }
+
+ /**
+ * Filters upstream projects by dependency scope. If the scope is null or empty,
+ * all upstream projects are returned. Otherwise, only projects that the given
+ * project depends on with a matching scope are included.
+ * <p>
+ * Matching is exact on the dependency's declared scope string (e.g. "compile",
+ * "provided", "test"). Maven's default dependency scope is "compile" (when no
+ * scope is declared), so a null scope in the model is treated as "compile" for
+ * matching purposes. Note that this does <em>not</em> 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.
+ *
+ * @param project the project whose dependencies to check
+ * @param upstreamProjects the list of upstream reactor projects
+ * @param scope the dependency scope to filter by, or null/empty for all
+ * @return the filtered list of upstream projects
+ */
+ static List<MavenProject> filterByScope(
+ MavenProject project, List<MavenProject> upstreamProjects, String scope) {
+ if (scope == null || scope.isEmpty()) {
+ return upstreamProjects;
+ }
+ 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")))
+ .collect(Collectors.toList());
+ }
+
protected BuildPlan computeForkPlan(BuildStep step, MojoExecution execution, BuildPlan buildPlan) {
MojoDescriptor mojoDescriptor = execution.getMojoDescriptor();
PluginDescriptor pluginDescriptor = mojoDescriptor.getPluginDescriptor();
@@ -968,8 +1048,8 @@ public BuildPlan calculateLifecycleMappings(
if (pointer instanceof Lifecycle.DependenciesPointer) {
// For dependencies: ensure current project's phase starts after dependency's phase completes
// Example: project's compile starts after dependency's package completes
- // TODO: String scope = ((Lifecycle.DependenciesPointer) pointer).scope();
- projects.get(project)
+ String scope = ((Lifecycle.DependenciesPointer) pointer).scope();
+ filterByScope(project, projects.get(project), scope)
.forEach(p -> plan.step(p, AFTER + n2).ifPresent(before::executeAfter));
} else if (pointer instanceof Lifecycle.ChildrenPointer) {
// For children: ensure bidirectional phase coordination
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 e32c7b3a36..6a08b7ee35 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
@@ -26,11 +26,15 @@
import java.util.stream.Stream;
import org.apache.maven.internal.impl.DefaultLifecycleRegistry;
+import org.apache.maven.model.Dependency;
import org.apache.maven.model.Plugin;
import org.apache.maven.plugin.MojoExecution;
import org.apache.maven.project.MavenProject;
import org.junit.jupiter.api.Test;
+import static org.apache.maven.api.Lifecycle.AFTER;
+import static org.apache.maven.api.Lifecycle.BEFORE;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -130,6 +134,181 @@ private BuildPlan calculateLifecycleMappings(Map<MavenProject, List<MavenProject
return context.calculateLifecycleMappings(projects, phase);
}
+ /**
+ * Tests that PROJECT-type @After link ordering constraints work correctly.
+ * Simulates what {@code applyAfterLinks} does for {@code @After(phase="resources", type=PROJECT)}.
+ * <p>
+ * This is a real constraint: in the V4 lifecycle, "compile" and "resources" are
+ * parallel siblings (compile depends on sources, not resources), so the @After
+ * link creates a genuine ordering edge that doesn't exist naturally.
+ */
+ @Test
+ void testAfterLinkProjectOrdering() {
+ MavenProject project = new MavenProject();
+ project.setCollectedProjects(List.of());
+ Map<MavenProject, List<MavenProject>> projects = Collections.singletonMap(project, Collections.emptyList());
+
+ BuildPlan plan = calculateLifecycleMappings(projects, "package");
+
+ // Simulate @After(phase="resources", type=PROJECT) on a mojo bound to "compile"
+ // This means: compile's BEFORE step must wait for resources' AFTER step
+ // This is a real constraint since compile and resources are parallel in the lifecycle
+ BuildStep compileBefore = plan.requiredStep(project, BEFORE + "compile");
+ BuildStep resourcesAfter = plan.requiredStep(project, AFTER + "resources");
+ compileBefore.executeAfter(resourcesAfter);
+
+ // Verify: compile is now a successor of resources (via the after link)
+ assertIsSuccessor(resourcesAfter, compileBefore);
+ }
+
+ /**
+ * Tests that DEPENDENCIES-type @After link ordering constraints work correctly.
+ * Simulates what {@code applyAfterLinks} does for {@code @After(phase="ready", type=DEPENDENCIES)}.
+ */
+ @Test
+ void testAfterLinkDependenciesOrdering() {
+ MavenProject p1 = new MavenProject();
+ p1.setArtifactId("p1");
+ p1.setCollectedProjects(List.of());
+ MavenProject p2 = new MavenProject();
+ p2.setArtifactId("p2");
+ p2.setCollectedProjects(List.of());
+ Map<MavenProject, List<MavenProject>> projects = new HashMap<>();
+ projects.put(p1, Collections.emptyList());
+ projects.put(p2, Collections.singletonList(p1));
+
+ BuildPlan plan = calculateLifecycleMappings(projects, "package");
+
+ // Simulate @After(phase="ready", type=DEPENDENCIES, scope="compile") on p2's compile phase
+ // This means: p2's compile BEFORE must wait for p1's ready AFTER
+ BuildStep p2CompileBefore = plan.requiredStep(p2, BEFORE + "compile");
+ BuildStep p1ReadyAfter = plan.requiredStep(p1, AFTER + "ready");
+
+ // Apply the DEPENDENCIES link (same logic as applyAfterLinks)
+ for (MavenProject dep : projects.get(p2)) {
+ plan.step(dep, AFTER + "ready").ifPresent(p2CompileBefore::executeAfter);
+ }
+
+ // Verify: p2's compile is now a successor of p1's ready
+ assertIsSuccessor(p1ReadyAfter, p2CompileBefore);
+ }
+
+ /**
+ * Tests that CHILDREN-type @After link ordering constraints work correctly.
+ * Simulates what {@code applyAfterLinks} does for {@code @After(phase="package", type=CHILDREN)}.
+ */
+ @Test
+ void testAfterLinkChildrenOrdering() {
+ MavenProject child = new MavenProject();
+ child.setArtifactId("child");
+ child.setCollectedProjects(List.of());
+ MavenProject parent = new MavenProject();
+ parent.setArtifactId("parent");
+ parent.setCollectedProjects(List.of(child));
+ Map<MavenProject, List<MavenProject>> projects = Map.of(parent, List.of(), child, List.of());
+
+ BuildPlan plan = calculateLifecycleMappings(projects, "install");
+
+ // Simulate @After(phase="package", type=CHILDREN) on parent's install phase
+ // This means: parent waits for children's package before its install completes
+ BuildStep parentInstallBefore = plan.requiredStep(parent, BEFORE + "install");
+ BuildStep parentInstallAfter = plan.requiredStep(parent, AFTER + "install");
+
+ // Apply the CHILDREN link (same logic as applyAfterLinks)
+ parent.getCollectedProjects().forEach(c -> {
+ plan.step(c, BEFORE + "package").ifPresent(parentInstallBefore::executeBefore);
+ plan.step(c, AFTER + "package").ifPresent(parentInstallAfter::executeAfter);
+ });
+
+ // Verify: parent's install after waits for child's package after
+ BuildStep childPackageAfter = plan.requiredStep(child, AFTER + "package");
+ assertIsSuccessor(childPackageAfter, parentInstallAfter);
+ }
+
+ /**
+ * Tests that {@code filterByScope} returns all upstream projects when scope is null or empty.
+ */
+ @Test
+ void testFilterByScopeNullReturnsAll() {
+ MavenProject p1 = createProjectWithId("g", "p1");
+ MavenProject p2 = createProjectWithId("g", "p2");
+ List<MavenProject> upstream = List.of(p1, p2);
+
+ MavenProject consumer = new MavenProject();
+ assertEquals(upstream, BuildPlanExecutor.BuildContext.filterByScope(consumer, upstream, null));
+ assertEquals(upstream, BuildPlanExecutor.BuildContext.filterByScope(consumer, upstream, ""));
+ }
+
+ /**
+ * Tests that {@code filterByScope} filters upstream projects by exact scope match,
+ * treating null-scoped dependencies as "compile" (Maven default).
+ */
+ @Test
+ void testFilterByScopeMatchesExact() {
+ MavenProject compileDep = createProjectWithId("g", "compile-dep");
+ MavenProject providedDep = createProjectWithId("g", "provided-dep");
+ MavenProject testDep = createProjectWithId("g", "test-dep");
+ MavenProject nullScopeDep = createProjectWithId("g", "null-scope-dep");
+ List<MavenProject> upstream = List.of(compileDep, providedDep, 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", "test-dep", "test"));
+ consumer.getDependencies().add(createDependency("g", "null-scope-dep", null));
+
+ // "compile" matches explicit compile + null-scoped (Maven default is compile)
+ List<MavenProject> compileFiltered =
+ BuildPlanExecutor.BuildContext.filterByScope(consumer, upstream, "compile");
+ assertEquals(2, compileFiltered.size());
+ assertTrue(compileFiltered.contains(compileDep));
+ assertTrue(compileFiltered.contains(nullScopeDep));
+
+ // "provided" matches only provided-scoped
+ List<MavenProject> providedFiltered =
+ BuildPlanExecutor.BuildContext.filterByScope(consumer, upstream, "provided");
+ assertEquals(1, providedFiltered.size());
+ assertTrue(providedFiltered.contains(providedDep));
+
+ // "test" matches only test-scoped
+ List<MavenProject> testFiltered = BuildPlanExecutor.BuildContext.filterByScope(consumer, upstream, "test");
+ assertEquals(1, testFiltered.size());
+ assertTrue(testFiltered.contains(testDep));
+ }
+
+ /**
+ * Tests that {@code filterByScope} excludes upstream projects not declared as dependencies.
+ */
+ @Test
+ void testFilterByScopeExcludesNonDependencies() {
+ MavenProject dep = createProjectWithId("g", "dep");
+ MavenProject nonDep = createProjectWithId("g", "non-dep");
+ List<MavenProject> upstream = List.of(dep, nonDep);
+
+ MavenProject consumer = new MavenProject();
+ consumer.getDependencies().add(createDependency("g", "dep", "compile"));
+
+ List<MavenProject> filtered = BuildPlanExecutor.BuildContext.filterByScope(consumer, upstream, "compile");
+ assertEquals(1, filtered.size());
+ assertTrue(filtered.contains(dep));
+ }
+
+ private static MavenProject createProjectWithId(String groupId, String artifactId) {
+ MavenProject project = new MavenProject();
+ project.setGroupId(groupId);
+ project.setArtifactId(artifactId);
+ project.setCollectedProjects(List.of());
+ return project;
+ }
+
+ private static Dependency createDependency(String groupId, String artifactId, String scope) {
+ Dependency dep = new Dependency();
+ dep.setGroupId(groupId);
+ dep.setArtifactId(artifactId);
+ dep.setScope(scope);
+ return dep;
+ }
+
@Test
void testReactorPlugin() {
MavenProject p1 = new MavenProject();
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12534AfterAnnotationTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12534AfterAnnotationTest.java
new file mode 100644
index 0000000000..4cc4e68c34
--- /dev/null
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng12534AfterAnnotationTest.java
@@ -0,0 +1,66 @@
+/*
+ * 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.it;
+
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * This is a test for
+ * <a href="https://github.com/apache/maven/issues/12534">MNG-12534</a>.
+ * <p>
+ * Verifies that {@code afterLinks} in a V2 plugin descriptor
+ * ({@code http://maven.apache.org/PLUGIN/2.0.0}) are correctly loaded
+ * and processed by the build plan executor without errors.
+ * <p>
+ * The test plugin carries a handcrafted V2 plugin.xml with a
+ * {@code <afterLink>} of type PROJECT. The build plan executor
+ * must parse the descriptor, create the ordering edges, and
+ * execute the mojo successfully.
+ */
+class MavenITmng12534AfterAnnotationTest extends AbstractMavenIntegrationTestCase {
+
+ /**
+ * Verify that a plugin with {@code afterLinks} in its V2 descriptor
+ * is correctly loaded and the mojo executes under the concurrent builder.
+ */
+ @Test
+ void testAfterLinksLoadedAndMojoExecutes() throws Exception {
+ Path testDir = extractResources("/mng-12534-after-annotation");
+
+ // Step 1: install the test plugin with a handcrafted V2 plugin descriptor
+ Verifier pluginVerifier = newVerifier(testDir.resolve("plugin"));
+ pluginVerifier.addCliArgument("install");
+ pluginVerifier.execute();
+ pluginVerifier.verifyErrorFreeLog();
+
+ // Step 2: build the consumer project using the concurrent builder
+ Verifier consumerVerifier = newVerifier(testDir.resolve("consumer"));
+ consumerVerifier.addCliArgument("-b");
+ consumerVerifier.addCliArgument("concurrent");
+ consumerVerifier.addCliArgument("compile");
+ consumerVerifier.execute();
+ consumerVerifier.verifyErrorFreeLog();
+
+ // Verify the mojo actually executed
+ consumerVerifier.verifyTextInLog("[MNG-12534] touch goal executed - afterLinks wired correctly");
+ consumerVerifier.verifyFilePresent("target/touch.txt");
+ }
+}
diff --git a/its/core-it-suite/src/test/resources/mng-12534-after-annotation/consumer/pom.xml b/its/core-it-suite/src/test/resources/mng-12534-after-annotation/consumer/pom.xml
new file mode 100644
index 0000000000..23e8c56161
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/mng-12534-after-annotation/consumer/pom.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <groupId>org.apache.maven.its.mng12534</groupId>
+ <artifactId>consumer</artifactId>
+ <version>1.0-SNAPSHOT</version>
+
+ <name>MNG-12534 Consumer</name>
+ <description>Consumes the test plugin with afterLinks</description>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.its.mng12534</groupId>
+ <artifactId>mng12534-plugin</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <executions>
+ <execution>
+ <id>touch</id>
+ <goals>
+ <goal>touch</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git a/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/pom.xml b/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/pom.xml
new file mode 100644
index 0000000000..7363c59c19
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/pom.xml
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <groupId>org.apache.maven.its.mng12534</groupId>
+ <artifactId>mng12534-plugin</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>maven-plugin</packaging>
+ <name>MNG-12534 Test Plugin</name>
+ <description>V4 test plugin with afterLinks in V2 descriptor to verify @After annotation wiring</description>
+
+ <properties>
+ <javaVersion>17</javaVersion>
+ <mavenVersion>4.1.0-SNAPSHOT</mavenVersion>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.maven</groupId>
+ <artifactId>maven-api-core</artifactId>
+ <version>${mavenVersion}</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.maven</groupId>
+ <artifactId>maven-api-di</artifactId>
+ <version>${mavenVersion}</version>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <!-- Skip maven-plugin-plugin descriptor generation; we provide a handcrafted V2 plugin.xml -->
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-plugin-plugin</artifactId>
+ <version>4.0.0-beta-1</version>
+ <executions>
+ <execution>
+ <id>default-descriptor</id>
+ <phase>none</phase>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git a/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/java/org/apache/maven/its/mng12534/TouchMojo.java b/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/java/org/apache/maven/its/mng12534/TouchMojo.java
new file mode 100644
index 0000000000..01ac60c4ef
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/java/org/apache/maven/its/mng12534/TouchMojo.java
@@ -0,0 +1,63 @@
+/*
+ * 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.its.mng12534;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.maven.api.Project;
+import org.apache.maven.api.di.Inject;
+import org.apache.maven.api.plugin.Log;
+import org.apache.maven.api.plugin.annotations.After;
+import org.apache.maven.api.plugin.annotations.Mojo;
+
+/**
+ * V4 Mojo that creates a marker file to prove the goal was executed.
+ * The {@code @After} annotation declares that this mojo must run after
+ * the "resources" phase. This is a real ordering constraint because
+ * in the V4 lifecycle, "compile" and "resources" are parallel siblings
+ * (compile depends on sources, not resources) — so without this
+ * {@code @After} link, compile could start before resources completes.
+ * <p>
+ * The handcrafted V2 plugin descriptor mirrors this as an
+ * {@code <afterLink>} element — once maven-plugin-tools learns to
+ * scan {@code @After}, the descriptor will be generated automatically.
+ */
+@Mojo(name = "touch", defaultPhase = "compile")
+@After(phase = "resources")
+public class TouchMojo implements org.apache.maven.api.plugin.Mojo {
+
+ @Inject
+ private Log log;
+
+ @Inject
+ private Project project;
+
+ @Override
+ public void execute() throws Exception {
+ log.info("[MNG-12534] touch goal executed - afterLinks wired correctly");
+ Path targetDir = project.getBasedir().resolve("target");
+ Files.createDirectories(targetDir);
+ Path touchFile = targetDir.resolve("touch.txt");
+ if (!Files.exists(touchFile)) {
+ Files.createFile(touchFile);
+ }
+ }
+}
diff --git a/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/java/org/apache/maven/its/mng12534/TouchMojoFactory.java b/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/java/org/apache/maven/its/mng12534/TouchMojoFactory.java
new file mode 100644
index 0000000000..b4d1f3d583
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/java/org/apache/maven/its/mng12534/TouchMojoFactory.java
@@ -0,0 +1,32 @@
+/*
+ * 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.its.mng12534;
+
+import org.apache.maven.api.di.Named;
+
+/**
+ * DI factory for the TouchMojo. Normally maven-plugin-plugin generates this class
+ * at build time, but since we use a handcrafted V2 plugin descriptor (to include
+ * afterLinks), we must provide the factory manually.
+ *
+ * The @Named value must match MojoDescriptor.getRoleHint() at runtime:
+ * "groupId:artifactId:version:goal"
+ */
+@Named("org.apache.maven.its.mng12534:mng12534-plugin:1.0-SNAPSHOT:touch")
+public class TouchMojoFactory extends TouchMojo {}
diff --git a/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/resources/META-INF/maven/org.apache.maven.api.di.Inject b/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/resources/META-INF/maven/org.apache.maven.api.di.Inject
new file mode 100644
index 0000000000..450d5b29cc
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/resources/META-INF/maven/org.apache.maven.api.di.Inject
@@ -0,0 +1 @@
+org.apache.maven.its.mng12534.TouchMojoFactory
diff --git a/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/resources/META-INF/maven/plugin.xml b/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/resources/META-INF/maven/plugin.xml
new file mode 100644
index 0000000000..1b4009ec30
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/mng-12534-after-annotation/plugin/src/main/resources/META-INF/maven/plugin.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<plugin xmlns="http://maven.apache.org/PLUGIN/2.0.0">
+ <name>MNG-12534 Test Plugin</name>
+ <groupId>org.apache.maven.its.mng12534</groupId>
+ <artifactId>mng12534-plugin</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <goalPrefix>mng12534</goalPrefix>
+ <mojos>
+ <mojo>
+ <goal>touch</goal>
+ <phase>compile</phase>
+ <implementation>org.apache.maven.its.mng12534.TouchMojo</implementation>
+ <language>java</language>
+ <description>Creates a marker file; has afterLinks to test @After wiring</description>
+ <afterLinks>
+ <afterLink>
+ <phase>resources</phase>
+ <type>PROJECT</type>
+ </afterLink>
+ </afterLinks>
+ </mojo>
+ </mojos>
+</plugin>