This is an automated email from the ASF dual-hosted git repository.

gnodet pushed a commit to branch cake-dew
in repository https://gitbox.apache.org/repos/asf/maven.git

commit 7bcb282c33bba1f233581c49f4d85fd7ee7753ee
Author: Guillaume Nodet <[email protected]>
AuthorDate: Wed May 27 09:09:08 2026 +0200

    Fix mvnup plugin upgrade for versions locked by parent's build/plugins
    
    When a remote parent POM declares a plugin with an explicit version in
    build/plugins (not via pluginManagement), adding a pluginManagement
    entry in the child is insufficient — the parent's explicit version
    takes precedence.
    
    The analysis now compares each plugin's effective version in
    build/plugins against build/pluginManagement/plugins:
    - Same version: version comes from PM, pluginManagement override works
    - Different version (or absent from PM): explicit version in parent's
      plugins, needs a direct build/plugins entry to override
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
 .../invoker/mvnup/goals/PluginUpgradeStrategy.java | 152 ++++++++++++++++-----
 .../mvnup/goals/PluginUpgradeStrategyTest.java     | 128 +++++++++++++++++
 2 files changed, 245 insertions(+), 35 deletions(-)

diff --git 
a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategy.java
 
b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategy.java
index 6a6d16cb15..28353a8b1e 100644
--- 
a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategy.java
+++ 
b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategy.java
@@ -131,10 +131,9 @@ public UpgradeResult doApply(UpgradeContext context, 
Map<Path, Document> pomMap)
             Path tempDir = createTempProjectStructure(context, pomMap);
 
             // Phase 2: For each POM, build effective model using the session 
and analyze plugins
-            Map<Path, Set<String>> pluginsNeedingManagement =
-                    analyzePluginsUsingEffectiveModels(context, pomMap, 
tempDir);
+            PluginAnalysisResults analysisResults = 
analyzePluginsUsingEffectiveModels(context, pomMap, tempDir);
 
-            // Phase 3: Add plugin management to the last local parent in 
hierarchy
+            // Phase 3: Add plugin management and direct overrides to the last 
local parent in hierarchy
             for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
                 Path pomPath = entry.getKey();
                 Document pomDocument = entry.getValue();
@@ -150,14 +149,21 @@ public UpgradeResult doApply(UpgradeContext context, 
Map<Path, Document> pomMap)
                     hasUpgrades |= upgradePluginsInDocument(pomDocument, 
context);
 
                     // Add plugin management based on effective model analysis
-                    // Note: pluginsNeedingManagement only contains entries 
for POMs that should receive plugin
-                    // management
-                    // (i.e., the "last local parent" for each plugin that 
needs management)
-                    Set<String> pluginsForThisPom = 
pluginsNeedingManagement.get(pomPath);
-                    if (pluginsForThisPom != null && 
!pluginsForThisPom.isEmpty()) {
-                        hasUpgrades |= 
addPluginManagementForEffectivePlugins(context, pomDocument, pluginsForThisPom);
+                    Set<String> pluginsForManagement =
+                            
analysisResults.pluginsNeedingManagement().get(pomPath);
+                    if (pluginsForManagement != null && 
!pluginsForManagement.isEmpty()) {
+                        hasUpgrades |=
+                                
addPluginManagementForEffectivePlugins(context, pomDocument, 
pluginsForManagement);
                         context.detail("Added plugin management to " + pomPath 
+ " (target parent for "
-                                + pluginsForThisPom.size() + " plugins)");
+                                + pluginsForManagement.size() + " plugins)");
+                    }
+
+                    // Add direct plugin overrides in build/plugins for 
inherited plugins
+                    // whose versions cannot be overridden via 
pluginManagement alone
+                    Set<String> pluginsForDirectOverride =
+                            
analysisResults.pluginsNeedingDirectOverride().get(pomPath);
+                    if (pluginsForDirectOverride != null && 
!pluginsForDirectOverride.isEmpty()) {
+                        hasUpgrades |= addDirectPluginOverrides(context, 
pomDocument, pluginsForDirectOverride);
                     }
 
                     if (hasUpgrades) {
@@ -462,11 +468,13 @@ public static List<PluginUpgrade> getPluginUpgrades() {
 
     /**
      * Analyzes plugins using effective models built from the temp directory.
-     * Returns a map of POM path to the set of plugin keys that need 
management.
+     * Returns analysis results with two maps: plugins needing 
pluginManagement entries
+     * and plugins needing direct build/plugins overrides.
      */
-    private Map<Path, Set<String>> analyzePluginsUsingEffectiveModels(
+    private PluginAnalysisResults analyzePluginsUsingEffectiveModels(
             UpgradeContext context, Map<Path, Document> pomMap, Path tempDir) {
-        Map<Path, Set<String>> result = new HashMap<>();
+        Map<Path, Set<String>> managementResult = new HashMap<>();
+        Map<Path, Set<String>> directOverrideResult = new HashMap<>();
         Map<String, PluginUpgrade> pluginUpgrades = getPluginUpgradesAsMap();
 
         for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
@@ -479,20 +487,27 @@ private Map<Path, Set<String>> 
analyzePluginsUsingEffectiveModels(
                 Path tempPomPath = tempDir.resolve(relativePath);
 
                 // Build effective model using Maven 4 API
-                Set<String> pluginsNeedingUpgrade =
-                        analyzeEffectiveModelForPlugins(context, tempPomPath, 
pluginUpgrades);
+                PluginAnalysis analysis = 
analyzeEffectiveModelForPlugins(context, tempPomPath, pluginUpgrades);
 
                 // Determine where to add plugin management (last local parent)
-                Path targetPomForManagement =
+                Path targetPom =
                         findLastLocalParentForPluginManagement(context, 
tempPomPath, pomMap, tempDir, commonRoot);
 
-                if (targetPomForManagement != null) {
-                    result.computeIfAbsent(targetPomForManagement, k -> new 
HashSet<>())
-                            .addAll(pluginsNeedingUpgrade);
-
-                    if (!pluginsNeedingUpgrade.isEmpty()) {
-                        context.debug("Will add plugin management to " + 
targetPomForManagement + " for plugins: "
-                                + pluginsNeedingUpgrade);
+                if (targetPom != null) {
+                    managementResult
+                            .computeIfAbsent(targetPom, k -> new HashSet<>())
+                            .addAll(analysis.needsManagement());
+                    directOverrideResult
+                            .computeIfAbsent(targetPom, k -> new HashSet<>())
+                            .addAll(analysis.needsDirectOverride());
+
+                    if (!analysis.needsManagement().isEmpty()) {
+                        context.debug("Will add plugin management to " + 
targetPom + " for plugins: "
+                                + analysis.needsManagement());
+                    }
+                    if (!analysis.needsDirectOverride().isEmpty()) {
+                        context.debug("Will add direct plugin overrides to " + 
targetPom + " for plugins: "
+                                + analysis.needsDirectOverride());
                     }
                 }
 
@@ -501,7 +516,7 @@ private Map<Path, Set<String>> 
analyzePluginsUsingEffectiveModels(
             }
         }
 
-        return result;
+        return new PluginAnalysisResults(managementResult, 
directOverrideResult);
     }
 
     /**
@@ -513,7 +528,7 @@ private Map<String, PluginUpgrade> getPluginUpgradesAsMap() 
{
                         upgrade -> upgrade.groupId() + ":" + 
upgrade.artifactId(), upgrade -> upgrade));
     }
 
-    private Set<String> analyzeEffectiveModelForPlugins(
+    private PluginAnalysis analyzeEffectiveModelForPlugins(
             UpgradeContext context, Path tempPomPath, Map<String, 
PluginUpgrade> pluginUpgrades) {
         Model effectiveModel = buildEffectiveModel(tempPomPath);
         return analyzePluginsFromEffectiveModel(context, effectiveModel, 
pluginUpgrades);
@@ -521,13 +536,27 @@ private Set<String> analyzeEffectiveModelForPlugins(
 
     /**
      * Analyzes plugins from the effective model and determines which ones 
need upgrades.
+     * Separates plugins into those overridable via pluginManagement and those 
requiring
+     * a direct build/plugins entry (because the version is set explicitly in 
an inherited
+     * parent's build/plugins, not via pluginManagement).
      */
-    private Set<String> analyzePluginsFromEffectiveModel(
+    private PluginAnalysis analyzePluginsFromEffectiveModel(
             UpgradeContext context, Model effectiveModel, Map<String, 
PluginUpgrade> pluginUpgrades) {
-        Set<String> pluginsNeedingUpgrade = new HashSet<>();
+        Set<String> needsManagement = new HashSet<>();
+        Set<String> needsDirectOverride = new HashSet<>();
 
         Build build = effectiveModel.getBuild();
         if (build != null) {
+            // Collect managed plugin versions for comparison
+            Map<String, String> managedVersions = new HashMap<>();
+            PluginManagement pluginManagement = build.getPluginManagement();
+            if (pluginManagement != null) {
+                for (Plugin plugin : pluginManagement.getPlugins()) {
+                    String pluginKey = getPluginKey(plugin);
+                    managedVersions.put(pluginKey, plugin.getVersion());
+                }
+            }
+
             // Check build/plugins - these are the actual plugins used in the 
build
             for (Plugin plugin : build.getPlugins()) {
                 String pluginKey = getPluginKey(plugin);
@@ -535,23 +564,33 @@ private Set<String> analyzePluginsFromEffectiveModel(
                 if (upgrade != null) {
                     String effectiveVersion = plugin.getVersion();
                     if (isVersionBelow(effectiveVersion, 
upgrade.minVersion())) {
-                        pluginsNeedingUpgrade.add(pluginKey);
-                        context.debug("Plugin " + pluginKey + " version " + 
effectiveVersion + " needs upgrade to "
-                                + upgrade.minVersion());
+                        needsManagement.add(pluginKey);
+                        String managedVersion = managedVersions.get(pluginKey);
+                        if (managedVersion == null || 
!managedVersion.equals(effectiveVersion)) {
+                            // Version differs from pluginManagement (or not 
in PM at all):
+                            // the parent sets an explicit version in 
build/plugins that
+                            // pluginManagement alone cannot override
+                            needsDirectOverride.add(pluginKey);
+                            context.debug("Plugin " + pluginKey + " version " 
+ effectiveVersion
+                                    + " has explicit version in inherited 
build/plugins"
+                                    + " — needs direct override to " + 
upgrade.minVersion());
+                        } else {
+                            context.debug("Plugin " + pluginKey + " version " 
+ effectiveVersion
+                                    + " is managed via pluginManagement — 
needs upgrade to " + upgrade.minVersion());
+                        }
                     }
                 }
             }
 
-            // Check build/pluginManagement/plugins - these provide version 
management
-            PluginManagement pluginManagement = build.getPluginManagement();
+            // Check build/pluginManagement/plugins for managed-only plugins
             if (pluginManagement != null) {
                 for (Plugin plugin : pluginManagement.getPlugins()) {
                     String pluginKey = getPluginKey(plugin);
                     PluginUpgrade upgrade = pluginUpgrades.get(pluginKey);
-                    if (upgrade != null) {
+                    if (upgrade != null && 
!needsManagement.contains(pluginKey)) {
                         String effectiveVersion = plugin.getVersion();
                         if (isVersionBelow(effectiveVersion, 
upgrade.minVersion())) {
-                            pluginsNeedingUpgrade.add(pluginKey);
+                            needsManagement.add(pluginKey);
                             context.debug("Managed plugin " + pluginKey + " 
version " + effectiveVersion
                                     + " needs upgrade to " + 
upgrade.minVersion());
                         }
@@ -560,7 +599,7 @@ private Set<String> analyzePluginsFromEffectiveModel(
             }
         }
 
-        return pluginsNeedingUpgrade;
+        return new PluginAnalysis(needsManagement, needsDirectOverride);
     }
 
     /**
@@ -732,6 +771,49 @@ private void addPluginManagementEntryFromUpgrade(
                 + upgrade.minVersion() + " (found through effective model 
analysis)");
     }
 
+    /**
+     * Adds direct plugin entries in build/plugins for plugins inherited from 
remote parents.
+     * This is necessary when a parent POM sets an explicit version in its 
build/plugins
+     * that pluginManagement alone cannot override.
+     */
+    private boolean addDirectPluginOverrides(UpgradeContext context, Document 
pomDocument, Set<String> pluginKeys) {
+        Map<String, PluginUpgrade> pluginUpgrades = getPluginUpgradesAsMap();
+        boolean hasUpgrades = false;
+
+        Element root = pomDocument.root();
+
+        Element buildElement = root.childElement(BUILD).orElse(null);
+        if (buildElement == null) {
+            buildElement = DomUtils.insertNewElement(BUILD, root);
+        }
+
+        Element pluginsElement = 
buildElement.childElement(PLUGINS).orElse(null);
+        if (pluginsElement == null) {
+            pluginsElement = DomUtils.insertNewElement(PLUGINS, buildElement);
+        }
+
+        for (String pluginKey : pluginKeys) {
+            PluginUpgrade upgrade = pluginUpgrades.get(pluginKey);
+            if (upgrade != null) {
+                if (!isPluginAlreadyManagedInElement(pluginsElement, upgrade)) 
{
+                    DomUtils.createPlugin(
+                            pluginsElement, upgrade.groupId(), 
upgrade.artifactId(), upgrade.minVersion());
+                    hasUpgrades = true;
+                    context.detail("Added " + upgrade.groupId() + ":" + 
upgrade.artifactId() + " version "
+                            + upgrade.minVersion()
+                            + " in build/plugins (overrides version locked by 
parent)");
+                }
+            }
+        }
+
+        return hasUpgrades;
+    }
+
+    private record PluginAnalysis(Set<String> needsManagement, Set<String> 
needsDirectOverride) {}
+
+    private record PluginAnalysisResults(
+            Map<Path, Set<String>> pluginsNeedingManagement, Map<Path, 
Set<String>> pluginsNeedingDirectOverride) {}
+
     /**
      * Holds plugin upgrade information for Maven 4 compatibility.
      * This class contains the minimum version requirements for plugins
diff --git 
a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategyTest.java
 
b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategyTest.java
index d1961700e7..c10f00864b 100644
--- 
a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategyTest.java
+++ 
b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategyTest.java
@@ -808,6 +808,134 @@ void shouldDetectInheritedPluginsFromRemoteParent() 
throws Exception {
                     
xml.contains("<artifactId>maven-enforcer-plugin</artifactId>"),
                     "Should add pluginManagement for maven-enforcer-plugin 
inherited from parent");
         }
+
+        @Test
+        @DisplayName("should not add direct build/plugins override when plugin 
version comes from pluginManagement")
+        void shouldNotAddDirectOverrideWhenVersionFromPluginManagement() 
throws Exception {
+            // org.apache:apache:23 has maven-enforcer-plugin in build/plugins 
WITHOUT
+            // an explicit version — the version (1.4.1) comes from 
pluginManagement.
+            // In this case, adding a pluginManagement override in the child 
is sufficient;
+            // no direct build/plugins entry should be added for enforcer.
+            String pomXml = """
+                <?xml version="1.0" encoding="UTF-8"?>
+                <project xmlns="http://maven.apache.org/POM/4.0.0";>
+                    <modelVersion>4.0.0</modelVersion>
+                    <parent>
+                        <groupId>org.apache</groupId>
+                        <artifactId>apache</artifactId>
+                        <version>23</version>
+                    </parent>
+                    <groupId>org.example</groupId>
+                    <artifactId>test-child</artifactId>
+                    <version>1.0.0-SNAPSHOT</version>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            Path pomPath = Paths.get("/project/pom.xml").toAbsolutePath();
+            Map<Path, Document> pomMap = Map.of(pomPath, document);
+
+            UpgradeContext context = createMockContext();
+            UpgradeResult result = strategy.doApply(context, pomMap);
+
+            assertTrue(result.success(), "Strategy should succeed");
+
+            Editor editor = new Editor(document);
+            Element root = editor.root();
+
+            // Verify pluginManagement entry exists for enforcer
+            Element pmPlugins =
+                    root.path("build", "pluginManagement", 
"plugins").orElse(null);
+            assertNotNull(pmPlugins, "Should have pluginManagement/plugins");
+            boolean hasEnforcerInPM = pmPlugins
+                    .childElements("plugin")
+                    .anyMatch(p -> "maven-enforcer-plugin"
+                            .equals(p.childElement("artifactId")
+                                    .map(Element::textContentTrimmed)
+                                    .orElse("")));
+            assertTrue(hasEnforcerInPM, "Should have enforcer in 
pluginManagement");
+
+            // Verify NO direct build/plugins entry for enforcer (PM override 
is sufficient)
+            Element buildPlugins = root.childElement("build")
+                    .flatMap(b -> b.childElement("plugins"))
+                    .orElse(null);
+            if (buildPlugins != null) {
+                boolean hasEnforcerInPlugins = buildPlugins
+                        .childElements("plugin")
+                        .anyMatch(p -> "maven-enforcer-plugin"
+                                .equals(p.childElement("artifactId")
+                                        .map(Element::textContentTrimmed)
+                                        .orElse("")));
+                assertFalse(
+                        hasEnforcerInPlugins,
+                        "Should NOT add enforcer in build/plugins when 
pluginManagement override suffices");
+            }
+        }
+
+        @Test
+        @DisplayName("should not duplicate plugin in build/plugins when 
already locally declared")
+        void shouldNotDuplicatePluginInBuildPluginsWhenAlreadyDeclared() 
throws Exception {
+            String pomXml = """
+                <?xml version="1.0" encoding="UTF-8"?>
+                <project xmlns="http://maven.apache.org/POM/4.0.0";>
+                    <modelVersion>4.0.0</modelVersion>
+                    <parent>
+                        <groupId>org.apache</groupId>
+                        <artifactId>apache</artifactId>
+                        <version>23</version>
+                    </parent>
+                    <groupId>org.example</groupId>
+                    <artifactId>test-child</artifactId>
+                    <version>1.0.0-SNAPSHOT</version>
+                    <build>
+                        <plugins>
+                            <plugin>
+                                <groupId>org.apache.maven.plugins</groupId>
+                                <artifactId>maven-enforcer-plugin</artifactId>
+                                <version>3.0.0</version>
+                            </plugin>
+                        </plugins>
+                    </build>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            Path pomPath = Paths.get("/project/pom.xml").toAbsolutePath();
+            Map<Path, Document> pomMap = Map.of(pomPath, document);
+
+            UpgradeContext context = createMockContext();
+            UpgradeResult result = strategy.doApply(context, pomMap);
+
+            assertTrue(result.success(), "Strategy should succeed");
+
+            Editor editor = new Editor(document);
+            Element root = editor.root();
+            Element buildPlugins = root.childElement("build")
+                    .flatMap(b -> b.childElement("plugins"))
+                    .orElse(null);
+            assertNotNull(buildPlugins, "Should have build/plugins section");
+
+            long enforcerCount = buildPlugins
+                    .childElements("plugin")
+                    .filter(p -> "maven-enforcer-plugin"
+                            .equals(p.childElement("artifactId")
+                                    .map(Element::textContentTrimmed)
+                                    .orElse("")))
+                    .count();
+            assertEquals(1, enforcerCount, "Should have exactly one 
maven-enforcer-plugin in build/plugins");
+
+            String version = buildPlugins
+                    .childElements("plugin")
+                    .filter(p -> "maven-enforcer-plugin"
+                            .equals(p.childElement("artifactId")
+                                    .map(Element::textContentTrimmed)
+                                    .orElse("")))
+                    .findFirst()
+                    .flatMap(p -> p.childElement("version"))
+                    .map(Element::textContentTrimmed)
+                    .orElse(null);
+            assertEquals("3.5.0", version, "Existing enforcer-plugin version 
should be upgraded to 3.5.0");
+        }
     }
 
     @Nested

Reply via email to