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:35:11 UTC
(maven) branch backport/12535-after-annotation created (now 3ade2d870a)
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a change to branch backport/12535-after-annotation
in repository https://gitbox.apache.org/repos/asf/maven.git
at 3ade2d870a Fix #12534: Wire up @After annotation processing in Maven core
This branch includes the following new commits:
new 3ade2d870a Fix #12534: Wire up @After annotation processing in Maven core
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(maven) 01/01: Fix #12534: Wire up @After annotation processing in Maven core
Posted by gn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch backport/12535-after-annotation
in repository https://gitbox.apache.org/repos/asf/maven.git
commit 3ade2d870a2cc894e542d955663f85a880a1115f
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 | 180 +++++++++++++++++++++
.../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, 683 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 3ed6c002d1..f38a62c365 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;
@@ -607,6 +608,8 @@ private void plan() {
.executeAfter(a));
}
}
+ // Apply @After annotation ordering constraints from the mojo descriptor
+ applyAfterLinks(mojoDescriptor, project, resolvedPhase);
});
}
}
@@ -634,6 +637,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();
@@ -951,8 +1031,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 1881f2dcbd..dc785ed28b 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,10 +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;
@@ -129,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 testPlugins() {
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>