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/10/13 12:03:27 UTC

(maven) branch master updated: feat: make MojoExecution @Immutable with noun-based accessors

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 cc76fd30a4 feat: make MojoExecution @Immutable with noun-based accessors
cc76fd30a4 is described below

commit cc76fd30a4f8bbede171e3e377e2372aea1fcf8a
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Tue Oct 13 14:03:20 2026 +0200

    feat: make MojoExecution @Immutable with noun-based accessors
    
    - Replace get-prefixed abstract methods on MojoExecution with noun-based equivalents (plugin(), model(), descriptor(), executionId(), goal(), lifecyclePhase(), configuration()) matching the pattern established by ExecutionEvent, Node, etc.
    - Remove deprecated get-prefixed bridge methods (since GA 4.0.0 has not shipped yet, no shim retention is needed); update two internal callers (PluginParameterExpressionEvaluatorV4Test, MojoExtension)
    - DefaultMojoExecution now captures all fields at construction time (after plan calculation is complete) and drops the live reference to mutable legacy MojoExecution; @Immutable is now honest
    - lifecyclePhase() and executionId() return Optional<String> to model nullable CLI-invoked goals correctly; deprecated bridges return @Nullable
    - Eagerly capture lifecycle mappings in buildPlugin snapshot
    - Add noun-based fallback in ReflectionValueExtractor: when no prefixed method is found for a ${mojo.*} expression, try the property name as-is, fixing PluginParameterExpressionEvaluatorV4Test failures for prefixless noun accessors; add test coverage and javadoc for the fallback
    - Add null-guard on DependencyNode and MojoDescriptor in buildPlugin(), merge function on Collectors.toMap() to avoid ISE on duplicate keys, requireNonNull guard for goal field, log at debug when lifecycle mappings cannot be loaded
    - Update MojoExecutionStub to implement new noun-based abstract methods
---
 .../java/org/apache/maven/api/MojoExecution.java   |  39 ++++-
 .../src/main/java/org/apache/maven/api/Plugin.java |   5 +-
 .../maven/internal/impl/DefaultMojoExecution.java  | 157 ++++++++++++++-------
 .../internal/impl/DefaultMojoExecutionTest.java    | 120 ++++++++++++++++
 .../PluginParameterExpressionEvaluatorV4Test.java  |  10 +-
 .../model/reflection/ReflectionValueExtractor.java |  22 ++-
 .../reflection/ReflectionValueExtractorTest.java   |  51 +++++++
 .../apache/maven/testing/plugin/MojoExtension.java |   2 +-
 .../testing/plugin/stubs/MojoExecutionStub.java    |  20 +--
 9 files changed, 350 insertions(+), 76 deletions(-)

diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/MojoExecution.java b/api/maven-api-core/src/main/java/org/apache/maven/api/MojoExecution.java
index 15ffac662f..c4c6b52095 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/MojoExecution.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/MojoExecution.java
@@ -21,6 +21,7 @@
 import java.util.Optional;
 
 import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
 import org.apache.maven.api.annotations.Nonnull;
 import org.apache.maven.api.model.PluginExecution;
 import org.apache.maven.api.plugin.descriptor.MojoDescriptor;
@@ -31,30 +32,54 @@
  * An instance of this object is bound to the {@link org.apache.maven.api.di.MojoExecutionScoped}
  * and available as {@code mojoExecution} within {@link org.apache.maven.api.plugin.annotations.Parameter}
  * expressions.
+ * <p>
+ * Instances are immutable snapshots taken at the point when execution begins (after configuration
+ * merging, descriptor resolution, and lifecycle phase assignment are all complete).
  *
  * @since 4.0.0
  */
 @Experimental
+@Immutable
 public interface MojoExecution {
 
+    /** {@return the plugin that owns this execution} */
     @Nonnull
-    Plugin getPlugin();
+    Plugin plugin();
 
+    /**
+     * {@return the {@code <execution>} element from the POM (or from the default lifecycle bindings)
+     * that corresponds to this execution, or empty for a direct CLI invocation
+     * (e.g. {@code mvn groupId:artifactId:goal})}
+     * <p>
+     * Default lifecycle bindings (such as {@code maven-compiler-plugin:compile} bound to the
+     * {@code compile} phase for {@code jar} packaging) are synthesised from lifecycle mapping
+     * metadata and injected as {@code <execution>} elements before model resolution, so they
+     * are also present here.  Only a CLI-invoked goal — which has no backing {@code <plugin>}
+     * entry in the model — results in an empty {@code Optional}.
+     */
     @Nonnull
-    PluginExecution getModel();
+    Optional<PluginExecution> model();
 
+    /** {@return the descriptor of the mojo being executed} */
     @Nonnull
-    MojoDescriptor getDescriptor();
+    MojoDescriptor descriptor();
 
+    /**
+     * {@return the execution identifier as declared in the POM, or empty for a direct CLI invocation
+     * (e.g. {@code mvn groupId:artifactId:goal}) where no {@code <execution>} element is present}
+     */
     @Nonnull
-    String getExecutionId();
+    Optional<String> executionId();
 
+    /** {@return the goal being executed} */
     @Nonnull
-    String getGoal();
+    String goal();
 
+    /** {@return the lifecycle phase this execution is bound to, or empty for a direct CLI invocation} */
     @Nonnull
-    String getLifecyclePhase();
+    Optional<String> lifecyclePhase();
 
+    /** {@return the merged configuration for this execution, if any} */
     @Nonnull
-    Optional<XmlNode> getConfiguration();
+    Optional<XmlNode> configuration();
 }
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/Plugin.java b/api/maven-api-core/src/main/java/org/apache/maven/api/Plugin.java
index df26c2b8e9..049f7dd48f 100644
--- a/api/maven-api-core/src/main/java/org/apache/maven/api/Plugin.java
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/Plugin.java
@@ -24,6 +24,7 @@
 
 import org.apache.maven.api.annotations.Experimental;
 import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
 import org.apache.maven.api.plugin.descriptor.PluginDescriptor;
 import org.apache.maven.api.plugin.descriptor.lifecycle.Lifecycle;
 
@@ -35,7 +36,7 @@
 @Experimental
 public interface Plugin {
 
-    @Nonnull
+    @Nullable
     org.apache.maven.api.model.Plugin getModel();
 
     @Nonnull
@@ -47,7 +48,7 @@ public interface Plugin {
     @Nonnull
     ClassLoader getClassLoader();
 
-    @Nonnull
+    @Nullable
     Artifact getArtifact();
 
     @Nonnull
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultMojoExecution.java b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultMojoExecution.java
index b331601092..1483884942 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultMojoExecution.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/impl/DefaultMojoExecution.java
@@ -38,110 +38,169 @@
 import org.apache.maven.api.plugin.descriptor.lifecycle.Lifecycle;
 import org.apache.maven.api.xml.XmlNode;
 import org.apache.maven.impl.DefaultNode;
-import org.codehaus.plexus.util.xml.Xpp3Dom;
 import org.eclipse.aether.graph.DependencyNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Immutable snapshot of a mojo execution, captured at the point when execution begins
+ * (after configuration merging, descriptor resolution, and lifecycle phase assignment are complete).
+ * All state is copied at construction time; no reference to the mutable legacy
+ * {@link org.apache.maven.plugin.MojoExecution} is retained after construction.
+ */
 public class DefaultMojoExecution implements MojoExecution {
-    private final InternalMavenSession session;
-    private final org.apache.maven.plugin.MojoExecution delegate;
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(DefaultMojoExecution.class);
+
+    private final Plugin plugin;
+    private final Optional<PluginExecution> model;
+    private final MojoDescriptor descriptor;
+    private final Optional<String> executionId;
+    private final String goal;
+    private final Optional<String> lifecyclePhase;
+    private final XmlNode configuration;
 
     public DefaultMojoExecution(InternalMavenSession session, org.apache.maven.plugin.MojoExecution delegate) {
-        this.session = session;
-        this.delegate = delegate;
+        requireNonNull(session, "session");
+        requireNonNull(delegate, "delegate");
+        this.descriptor = requireNonNull(delegate.getMojoDescriptor(), "delegate.mojoDescriptor")
+                .getMojoDescriptorV4();
+        this.executionId = Optional.ofNullable(delegate.getExecutionId());
+        this.goal = requireNonNull(delegate.getGoal(), "delegate.goal");
+        this.lifecyclePhase = Optional.ofNullable(delegate.getLifecyclePhase());
+        this.configuration = delegate.getConfiguration() != null
+                ? delegate.getConfiguration().getDom()
+                : null;
+        this.plugin = buildPlugin(session, delegate);
+        this.model = buildModel(delegate);
     }
 
-    public org.apache.maven.plugin.MojoExecution getDelegate() {
-        return delegate;
-    }
+    private static Plugin buildPlugin(InternalMavenSession session, org.apache.maven.plugin.MojoExecution delegate) {
+        org.apache.maven.plugin.descriptor.MojoDescriptor legacyDescriptor = delegate.getMojoDescriptor();
+        org.apache.maven.plugin.descriptor.PluginDescriptor legacyPluginDescriptor =
+                legacyDescriptor.getPluginDescriptor();
+        PluginDescriptor pluginDescriptorV4 = legacyPluginDescriptor.getPluginDescriptorV4();
+
+        ClassLoader classLoader = legacyDescriptor.getRealm();
+
+        org.apache.maven.artifact.Artifact legacyArtifact = legacyPluginDescriptor.getPluginArtifact();
+        org.eclipse.aether.artifact.Artifact resolverArtifact = RepositoryUtils.toArtifact(legacyArtifact);
+        Artifact artifact = resolverArtifact != null ? session.getArtifact(resolverArtifact) : null;
+
+        DependencyNode resolverNode = legacyPluginDescriptor.getDependencyNode();
+        Map<String, Dependency> dependenciesMap = resolverNode != null
+                ? Collections.unmodifiableMap(new DefaultNode(session, resolverNode, false)
+                        .stream()
+                                .filter(Objects::nonNull)
+                                .map(Node::getDependency)
+                                .filter(Objects::nonNull)
+                                .collect(Collectors.toMap(
+                                        d -> d.getGroupId() + ":" + d.getArtifactId(),
+                                        d -> d,
+                                        (a, b) -> a))) // first-wins on duplicate groupId:artifactId
+                // (transitive duplicates are normal)
+                : Collections.emptyMap();
+
+        org.apache.maven.api.model.Plugin modelPlugin =
+                delegate.getPlugin() != null ? delegate.getPlugin().getDelegate() : null;
+
+        // Eagerly capture lifecycle mappings so the Plugin snapshot is truly immutable.
+        // getLifecycleMappings() reads from the plugin JAR; plugins that do not bundle
+        // custom lifecycle metadata will throw here — fall back to an empty list.
+        List<Lifecycle> lifecycles;
+        try {
+            lifecycles = Collections.unmodifiableList(new ArrayList<>(
+                    legacyPluginDescriptor.getLifecycleMappings().values()));
+        } catch (Exception e) {
+            LOGGER.debug(
+                    "Unable to load lifecycle mappings for {}: {}", legacyPluginDescriptor.getId(), e.getMessage());
+            lifecycles = Collections.emptyList();
+        }
+        final List<Lifecycle> snapshotLifecycles = lifecycles;
 
-    @Override
-    public Plugin getPlugin() {
         return new Plugin() {
             @Override
             public org.apache.maven.api.model.Plugin getModel() {
-                return delegate.getPlugin().getDelegate();
+                return modelPlugin;
             }
 
             @Override
             public PluginDescriptor getDescriptor() {
-                return delegate.getMojoDescriptor().getPluginDescriptor().getPluginDescriptorV4();
+                return pluginDescriptorV4;
             }
 
             @Override
             public List<Lifecycle> getLifecycles() {
-                try {
-                    return Collections.unmodifiableList(new ArrayList<>(delegate.getMojoDescriptor()
-                            .getPluginDescriptor()
-                            .getLifecycleMappings()
-                            .values()));
-                } catch (Exception e) {
-                    throw new RuntimeException("Unable to load plugin lifecycles", e);
-                }
+                return snapshotLifecycles;
             }
 
             @Override
             public ClassLoader getClassLoader() {
-                return delegate.getMojoDescriptor().getRealm();
+                return classLoader;
             }
 
             @Override
             public Artifact getArtifact() {
-                org.apache.maven.artifact.Artifact artifact =
-                        delegate.getMojoDescriptor().getPluginDescriptor().getPluginArtifact();
-                org.eclipse.aether.artifact.Artifact resolverArtifact = RepositoryUtils.toArtifact(artifact);
-                return resolverArtifact != null ? session.getArtifact(resolverArtifact) : null;
+                return artifact;
             }
 
             @Override
             public Map<String, Dependency> getDependenciesMap() {
-                DependencyNode resolverNode =
-                        delegate.getMojoDescriptor().getPluginDescriptor().getDependencyNode();
-                DefaultNode node = new DefaultNode(session, resolverNode, false);
-                return Collections.unmodifiableMap(node.stream()
-                        .filter(Objects::nonNull)
-                        .map(Node::getDependency)
-                        .filter(Objects::nonNull)
-                        .collect(Collectors.toMap(d -> d.getGroupId() + ":" + d.getArtifactId(), d -> d)));
+                return dependenciesMap;
             }
         };
     }
 
-    @Override
-    public PluginExecution getModel() {
+    private static Optional<PluginExecution> buildModel(org.apache.maven.plugin.MojoExecution delegate) {
+        if (delegate.getPlugin() == null) {
+            return Optional.empty();
+        }
+        String id = delegate.getExecutionId();
         return delegate.getPlugin().getExecutions().stream()
-                .filter(pe -> Objects.equals(pe.getId(), getExecutionId()))
+                .filter(pe -> Objects.equals(pe.getId(), id))
                 .findFirst()
-                .map(org.apache.maven.model.PluginExecution::getDelegate)
-                .orElse(null);
+                .map(org.apache.maven.model.PluginExecution::getDelegate);
+    }
+
+    @Override
+    public Plugin plugin() {
+        return plugin;
+    }
+
+    @Override
+    public Optional<PluginExecution> model() {
+        return model;
     }
 
     @Override
-    public MojoDescriptor getDescriptor() {
-        return delegate.getMojoDescriptor().getMojoDescriptorV4();
+    public MojoDescriptor descriptor() {
+        return descriptor;
     }
 
     @Override
-    public String getLifecyclePhase() {
-        return delegate.getLifecyclePhase();
+    public Optional<String> executionId() {
+        return executionId;
     }
 
     @Override
-    public String getExecutionId() {
-        return delegate.getExecutionId();
+    public String goal() {
+        return goal;
     }
 
     @Override
-    public String getGoal() {
-        return delegate.getGoal();
+    public Optional<String> lifecyclePhase() {
+        return lifecyclePhase;
     }
 
     @Override
-    public Optional<XmlNode> getConfiguration() {
-        return Optional.of(delegate.getConfiguration()).map(Xpp3Dom::getDom);
+    public Optional<XmlNode> configuration() {
+        return Optional.ofNullable(configuration);
     }
 
     @Override
     public String toString() {
-        return delegate.toString();
+        return descriptor.getId() + " {execution: " + executionId.orElse("<cli>") + '}';
     }
 }
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultMojoExecutionTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultMojoExecutionTest.java
new file mode 100644
index 0000000000..2364d53348
--- /dev/null
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/impl/DefaultMojoExecutionTest.java
@@ -0,0 +1,120 @@
+/*
+ * 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.internal.impl;
+
+import org.apache.maven.plugin.MojoExecution;
+import org.apache.maven.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link DefaultMojoExecution} immutable snapshot semantics.
+ */
+class DefaultMojoExecutionTest {
+
+    /**
+     * Builds a minimal {@link MojoExecution} backed by a real {@link MojoDescriptor}
+     * with the given goal; executionId and lifecyclePhase are left at their
+     * legacy-default values (null) to simulate the CLI-invocation path.
+     */
+    private static MojoExecution cliExecution(String goal) {
+        PluginDescriptor pluginDescriptor = new PluginDescriptor();
+        pluginDescriptor.setGroupId("org.apache.maven.plugins");
+        pluginDescriptor.setArtifactId("maven-test-plugin");
+        pluginDescriptor.setVersion("1.0");
+        pluginDescriptor.setGoalPrefix("test");
+        // No plugin artifact, no dependency node → simulates a minimal CLI-invoked execution
+
+        MojoDescriptor mojoDescriptor = new MojoDescriptor();
+        mojoDescriptor.setGoal(goal);
+        mojoDescriptor.setPluginDescriptor(pluginDescriptor);
+
+        return new MojoExecution(mojoDescriptor);
+    }
+
+    /**
+     * Builds a lifecycle-bound {@link MojoExecution} with all fields populated
+     * (executionId and lifecyclePhase present).
+     */
+    private static MojoExecution lifecycleExecution(String goal, String executionId, String phase) {
+        PluginDescriptor pluginDescriptor = new PluginDescriptor();
+        pluginDescriptor.setGroupId("org.apache.maven.plugins");
+        pluginDescriptor.setArtifactId("maven-test-plugin");
+        pluginDescriptor.setVersion("1.0");
+        pluginDescriptor.setGoalPrefix("test");
+
+        MojoDescriptor mojoDescriptor = new MojoDescriptor();
+        mojoDescriptor.setGoal(goal);
+        mojoDescriptor.setPluginDescriptor(pluginDescriptor);
+
+        MojoExecution execution = new MojoExecution(mojoDescriptor, executionId);
+        execution.setLifecyclePhase(phase);
+        return execution;
+    }
+
+    @Test
+    void cliInvocationPathExecutionIdAndLifecyclePhaseAreEmpty() {
+        InternalMavenSession session = mock(InternalMavenSession.class);
+        MojoExecution delegate = cliExecution("help");
+
+        org.apache.maven.api.MojoExecution snapshot = new DefaultMojoExecution(session, delegate);
+
+        assertEquals("help", snapshot.goal());
+        assertFalse(snapshot.executionId().isPresent(), "executionId should be empty for CLI invocation");
+        assertFalse(snapshot.lifecyclePhase().isPresent(), "lifecyclePhase should be empty for CLI invocation");
+        assertFalse(snapshot.model().isPresent(), "model should be empty when no plugin in model");
+        assertFalse(snapshot.configuration().isPresent(), "configuration should be empty when not set");
+    }
+
+    @Test
+    void lifecycleBoundExecutionExecutionIdAndLifecyclePhasePresentAndImmutable() {
+        InternalMavenSession session = mock(InternalMavenSession.class);
+        MojoExecution delegate = lifecycleExecution("compile", "default-compile", "compile");
+
+        org.apache.maven.api.MojoExecution snapshot = new DefaultMojoExecution(session, delegate);
+
+        assertEquals("compile", snapshot.goal());
+        assertTrue(snapshot.executionId().isPresent());
+        assertEquals("default-compile", snapshot.executionId().get());
+        assertTrue(snapshot.lifecyclePhase().isPresent());
+        assertEquals("compile", snapshot.lifecyclePhase().get());
+
+        // Mutating the delegate after snapshot construction must not affect the snapshot
+        delegate.setLifecyclePhase("package");
+        assertEquals("compile", snapshot.lifecyclePhase().get(), "snapshot must be immutable after construction");
+    }
+
+    @Test
+    void snapshotDescriptorMatchesLegacyDescriptor() {
+        InternalMavenSession session = mock(InternalMavenSession.class);
+        MojoExecution delegate = cliExecution("clean");
+
+        org.apache.maven.api.MojoExecution snapshot = new DefaultMojoExecution(session, delegate);
+
+        assertEquals(
+                delegate.getMojoDescriptor().getMojoDescriptorV4(),
+                snapshot.descriptor(),
+                "descriptor must be a copy of the legacy V4 descriptor");
+    }
+}
diff --git a/impl/maven-core/src/test/java/org/apache/maven/plugin/PluginParameterExpressionEvaluatorV4Test.java b/impl/maven-core/src/test/java/org/apache/maven/plugin/PluginParameterExpressionEvaluatorV4Test.java
index 4b25cf559b..2b821a01b3 100644
--- a/impl/maven-core/src/test/java/org/apache/maven/plugin/PluginParameterExpressionEvaluatorV4Test.java
+++ b/impl/maven-core/src/test/java/org/apache/maven/plugin/PluginParameterExpressionEvaluatorV4Test.java
@@ -101,7 +101,7 @@ public void testPluginDescriptorExpressionReference() throws Exception {
         System.out.println("Result: " + result);
 
         assertSame(
-                exec.getPlugin().getDescriptor(),
+                exec.plugin().getDescriptor(),
                 result,
                 "${mojo.plugin.descriptor} expression does not return plugin descriptor.");
     }
@@ -120,9 +120,7 @@ public void testPluginArtifactsExpressionReference() throws Exception {
         assertNotNull(depResults);
         assertEquals(1, depResults.size());
         assertEquals(
-                exec.getPlugin().getArtifact().key(),
-                depResults.iterator().next().key(),
-                "dependency artifact is wrong.");
+                exec.plugin().getArtifact().key(), depResults.iterator().next().key(), "dependency artifact is wrong.");
     }
 
     @Test
@@ -142,7 +140,7 @@ public void testPluginArtifactMapExpressionReference() throws Exception {
         assertEquals(1, depResults.size());
         assertTrue(depResults.containsKey("org.myco.plugins:my-plugin"));
         assertEquals(
-                exec.getPlugin().getArtifact().key(),
+                exec.plugin().getArtifact().key(),
                 depResults.get("org.myco.plugins:my-plugin").key(),
                 "dependency artifact is wrong.");
     }
@@ -159,7 +157,7 @@ public void testPluginArtifactIdExpressionReference() throws Exception {
         System.out.println("Result: " + result);
 
         assertSame(
-                exec.getPlugin().getArtifact().getArtifactId(),
+                exec.plugin().getArtifact().getArtifactId(),
                 result,
                 "${plugin.artifactId} expression does not return plugin descriptor's artifactId.");
     }
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/reflection/ReflectionValueExtractor.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/reflection/ReflectionValueExtractor.java
index 105225b79a..f7def3ae7d 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/reflection/ReflectionValueExtractor.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/reflection/ReflectionValueExtractor.java
@@ -37,7 +37,8 @@
  * such as {@code project.build.sourceDirectory}.
  * <p>
  * In addition to usual getters using {@code getXxx} or {@code isXxx} suffixes, accessors
- * using {@code asXxx} or {@code toXxx} prefixes are also supported.
+ * using {@code asXxx} or {@code toXxx} prefixes are also supported, as well as noun-based
+ * accessors matching the property name exactly (e.g. {@code plugin()}, {@code descriptor()}).
  */
 public class ReflectionValueExtractor {
     private static final Object[] OBJECT_ARGS = new Object[0];
@@ -266,6 +267,19 @@ private static Object getIndexedValue(
         throw new IntrospectionException(message);
     }
 
+    /**
+     * Resolves a single property segment against {@code value} by trying, in order:
+     * <ol>
+     *   <li>Prefixed accessors: {@code getX()}, {@code isX()}, {@code toX()}, {@code asX()}</li>
+     *   <li>Noun-based accessors: a zero-arg method whose name matches the property name exactly
+     *       (e.g. {@code plugin()}, {@code descriptor()}, {@code goal()}).</li>
+     * </ol>
+     * The noun-based fallback is intentional and applies globally to <em>any</em> object in the
+     * expression tree, not just {@code MojoExecution}. Classes that expose zero-arg methods whose
+     * names collide with common property names (e.g. {@code type()}, {@code name()}) will be
+     * resolved via this path. This is by design — it allows modern noun-style APIs to be used
+     * transparently in expressions like {@code ${mojo.plugin}} or {@code ${mojo.goal}}.
+     */
     private static Object getPropertyValue(Object value, String property) throws IntrospectionException {
         if (value == null || property == null || property.isEmpty()) {
             return null;
@@ -280,6 +294,12 @@ private static Object getPropertyValue(Object value, String property) throws Int
                     return method.invoke(value, OBJECT_ARGS);
                 }
             }
+            // Also support noun-based accessor style (e.g. plugin(), descriptor(), goal())
+            // where the method name matches the property name exactly.
+            Method method = classMap.findMethod(property);
+            if (method != null) {
+                return method.invoke(value, OBJECT_ARGS);
+            }
             return null;
         } catch (InvocationTargetException e) {
             throw new IntrospectionException(e.getTargetException());
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/reflection/ReflectionValueExtractorTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/reflection/ReflectionValueExtractorTest.java
index a47d213e35..30559954e2 100644
--- a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/reflection/ReflectionValueExtractorTest.java
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/reflection/ReflectionValueExtractorTest.java
@@ -559,6 +559,57 @@ public Object getValue() {
         }
     }
 
+    /**
+     * Verifies that noun-based accessor methods (zero-arg methods whose name matches the property
+     * name exactly, without a {@code get}/is/to/as prefix) are resolved by {@link ReflectionValueExtractor}.
+     * This is the mechanism that allows {@code ${mojo.plugin}}, {@code ${mojo.goal}},
+     * {@code ${mojo.descriptor}}, etc. to work with the modern Maven 4 noun-style API.
+     */
+    @Test
+    void testNounStyleAccessorResolution() throws Exception {
+        NounStyleBean bean = new NounStyleBean("my-goal", "org.apache.maven", "maven-core", "1.0");
+
+        // trimRootToken=true (default): "bean" is stripped as the root token, then "goal" etc. are resolved
+        assertEquals("my-goal", ReflectionValueExtractor.evaluate("bean.goal", bean));
+        assertEquals("org.apache.maven", ReflectionValueExtractor.evaluate("bean.groupId", bean));
+        assertEquals("maven-core", ReflectionValueExtractor.evaluate("bean.artifactId", bean));
+        assertEquals("1.0", ReflectionValueExtractor.evaluate("bean.version", bean));
+    }
+
+    public static class NounStyleBean {
+        private final String goal;
+        private final String groupId;
+        private final String artifactId;
+        private final String version;
+
+        public NounStyleBean(String goal, String groupId, String artifactId, String version) {
+            this.goal = goal;
+            this.groupId = groupId;
+            this.artifactId = artifactId;
+            this.version = version;
+        }
+
+        /** Noun-style accessor — no {@code get} prefix. */
+        public String goal() {
+            return goal;
+        }
+
+        /** Noun-style accessor — no {@code get} prefix. */
+        public String groupId() {
+            return groupId;
+        }
+
+        /** Noun-style accessor — no {@code get} prefix. */
+        public String artifactId() {
+            return artifactId;
+        }
+
+        /** Noun-style accessor — no {@code get} prefix. */
+        public String version() {
+            return version;
+        }
+    }
+
     /**
      * <p>testRootPropertyRegression.</p>
      *
diff --git a/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/MojoExtension.java b/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/MojoExtension.java
index d8604a3782..ecfe3094c3 100644
--- a/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/MojoExtension.java
+++ b/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/MojoExtension.java
@@ -517,7 +517,7 @@ private MojoExecution createMojoExecution() {
                             mes.setDescriptor(md);
                         }
                     }
-                    requireNonNull(mes.getDescriptor());
+                    requireNonNull(mes.descriptor());
                 }
                 PluginStub plugin = new PluginStub();
                 plugin.setDescriptor(pluginDescriptor);
diff --git a/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/MojoExecutionStub.java b/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/MojoExecutionStub.java
index f82fab0d6d..7bf551cc02 100644
--- a/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/MojoExecutionStub.java
+++ b/impl/maven-testing/src/main/java/org/apache/maven/testing/plugin/stubs/MojoExecutionStub.java
@@ -62,37 +62,37 @@ public MojoExecutionStub(String executionId, String goal, XmlNode dom) {
     }
 
     @Override
-    public Plugin getPlugin() {
+    public Plugin plugin() {
         return plugin;
     }
 
     @Override
-    public PluginExecution getModel() {
-        return model;
+    public Optional<PluginExecution> model() {
+        return Optional.ofNullable(model);
     }
 
     @Override
-    public MojoDescriptor getDescriptor() {
+    public MojoDescriptor descriptor() {
         return descriptor;
     }
 
     @Override
-    public String getLifecyclePhase() {
-        return lifecyclePhase;
+    public Optional<String> lifecyclePhase() {
+        return Optional.ofNullable(lifecyclePhase);
     }
 
     @Override
-    public String getExecutionId() {
-        return executionId;
+    public Optional<String> executionId() {
+        return Optional.ofNullable(executionId);
     }
 
     @Override
-    public String getGoal() {
+    public String goal() {
         return goal;
     }
 
     @Override
-    public Optional<XmlNode> getConfiguration() {
+    public Optional<XmlNode> configuration() {
         return Optional.ofNullable(dom);
     }