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>