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 2025/05/29 22:15:29 UTC

(maven) branch mvnup updated (680fa69e43 -> 1258b0d03f)

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

gnodet pushed a change to branch mvnup
in repository https://gitbox.apache.org/repos/asf/maven.git


 discard 680fa69e43 feat: Implement comprehensive Maven upgrade tool with plugin compatibility
     new 1258b0d03f feat: Implement comprehensive Maven upgrade tool with plugin compatibility

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (680fa69e43)
            \
             N -- N -- N   refs/heads/mvnup (1258b0d03f)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 src/graph/ReactorGraph.java | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)


(maven) 01/01: feat: Implement comprehensive Maven upgrade tool with plugin compatibility

Posted by gn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 1258b0d03ffd355d44bfa073eb1b3c4d4d3090f3
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Wed May 28 22:47:26 2025 +0200

    feat: Implement comprehensive Maven upgrade tool with plugin compatibility
    
    Implement a complete Maven upgrade tool (mvnup) that helps migrate projects to Maven 4
    with comprehensive plugin compatibility and smart upgrade options.
    
    ## Core Maven Upgrade Tool Features
    
    ### Model Version Upgrades
    - Upgrade POMs from Maven 4.0.0 to 4.1.0 model
    - Namespace and schema location updates
    - Convert modules to subprojects for 4.1.0 models
    - Preserve formatting and structure during upgrades
    
    ### Inference Support
    - Full inference for 4.1.0+ models (remove redundant information)
    - Limited inference for 4.0.0 models (parent groupId/version inheritance)
    - Smart parent element trimming with multi-module project awareness
    - Remove managed dependencies pointing to project artifacts
    - Remove redundant modules/subprojects lists
    
    ### Maven 4 Compatibility Fixes
    - Fix unsupported combine.children/combine.self attributes
    - Remove duplicate dependencies
    - Fix unsupported expressions in repository URLs
    - Comprehensive validation and error handling
    
    ## New Plugin Upgrade System
    
    ### --plugins Option
    - Upgrade Maven 4 incompatible plugins to compatible versions:
      * maven-exec-plugin: upgrade to 3.2.0+
      * maven-enforcer-plugin: upgrade to 3.0.0+
      * flatten-maven-plugin: upgrade to 1.2.7+
      * maven-shade-plugin: upgrade to 3.5.0+
    - Support plugin upgrades in build/plugins and pluginManagement
    - Handle property-based plugin versions with semantic version comparison
    - Automatic groupId defaulting for Maven plugins
    
    ### Enhanced Options System
    - --fix-model: Apply Maven 4 compatibility fixes
    - --plugins: Upgrade Maven 4 incompatible plugins
    - --all: Comprehensive upgrades (model + inference + fixes + plugins)
    - --infer: Apply inference optimizations
    - --model <version>: Target specific model version
    - --directory <path>: Specify starting directory
    - --force, --yes: Non-interactive operation
    - Support short form options (-m, -d, -i, -a, -f, -y)
    
    ### Smart Default Behavior
    - Default: --fix-model and --plugins (Maven 4 compatibility focus)
    - No unwanted model upgrades unless explicitly requested
    - Clean, focused output based on user intent
    - Intelligent apply command suggestions
    
    ## User Experience Improvements
    
    ### Intelligent Target Model Logic
    - Default target model is 4.0.0 (compatibility fixes only)
    - --all or explicit --model triggers model upgrades to 4.1.0
    - No model version noise for compatibility-only operations
    - Clear distinction between model upgrades and compatibility fixes
    
    ### Comprehensive Help System
    - Updated help text with all available options
    - Clear usage examples and option descriptions
    - Smart command suggestions based on check results
    
    ### Goals and Workflow
    - check: Analyze and report potential upgrades
    - apply: Execute upgrades and save changes
    - help: Display comprehensive help information
    
    ## Implementation Details
    
    ### Multi-Module Project Support
    - Recursive POM discovery from starting directory
    - Cross-project GAV resolution for inference
    - Parent-child relationship awareness
    - Consistent upgrades across entire project hierarchy
    
    ### Robust Error Handling
    - Graceful handling of malformed POMs
    - Detailed error reporting and logging
    - Validation of upgrade compatibility
    - Safe fallbacks for edge cases
    
    ### Comprehensive Testing
    - 78+ unit tests covering all scenarios
    - Plugin upgrade logic tests (11 tests)
    - Options integration tests (8 tests)
    - CLI parsing tests (13 tests)
    - Help functionality tests (7 tests)
    - Maven 4 compatibility fixes tests
    - Edge case and error condition coverage
    
    ## Usage Examples
    
    ```bash
    # Default behavior: compatibility fixes + plugin upgrades
    mvnup check
    mvnup apply
    
    # Specific operations
    mvnup check --fix-model --plugins
    mvnup apply --fix-model --plugins
    
    # Full upgrade to Maven 4.1.0
    mvnup check --all
    mvnup apply --all
    
    # Model upgrade with inference
    mvnup check --model 4.1.0 --infer
    mvnup apply --model 4.1.0 --infer
    
    # Help and information
    mvnup help
    ```
    
    ## Backward Compatibility
    - All existing Maven projects supported
    - Non-destructive analysis with check goal
    - Preserves POM formatting and structure
    - Safe upgrade paths with comprehensive validation
    
    This implementation provides a production-ready tool for Maven 4 migration
    with comprehensive plugin compatibility and user-friendly defaults.
---
 apache-maven/src/assembly/component.xml            |    1 +
 apache-maven/src/assembly/maven/bin/mvn            |    3 +
 apache-maven/src/assembly/maven/bin/mvn.cmd        |   10 +-
 apache-maven/src/assembly/maven/bin/mvnup          |   30 +
 apache-maven/src/assembly/maven/bin/mvnup.cmd      |   37 +
 .../main/appended-resources/META-INF/LICENSE.vm    |    9 +-
 .../org/apache/maven/api/cli/ParserRequest.java    |   24 +
 .../main/java/org/apache/maven/api/cli/Tools.java  |    3 +
 .../apache/maven/api/cli/mvnup/UpgradeOptions.java |  117 ++
 .../cli/{Tools.java => mvnup/package-info.java}    |   34 +-
 impl/maven-cli/pom.xml                             |    5 +
 .../java/org/apache/maven/cling/MavenUpCling.java  |   90 +
 .../maven/cling/invoker/CommonsCliOptions.java     |    5 +
 .../BuiltinShellCommandRegistryFactory.java        |   28 +
 .../invoker/mvnup/CommonsCliUpgradeOptions.java    |  231 +++
 .../org/apache/maven/cling/invoker/mvnup/Goal.java |   24 +-
 .../maven/cling/invoker/mvnup/PomDiscovery.java    |  303 ++++
 .../maven/cling/invoker/mvnup/UpgradeContext.java  |   56 +
 .../maven/cling/invoker/mvnup/UpgradeInvoker.java  |  118 ++
 .../cling/invoker/mvnup/UpgradeInvokerRequest.java |   73 +
 .../maven/cling/invoker/mvnup/UpgradeParser.java   |   75 +
 .../maven/cling/invoker/mvnup/goals/Apply.java     |   35 +-
 .../cling/invoker/mvnup/goals/BaseUpgradeGoal.java | 1904 ++++++++++++++++++++
 .../maven/cling/invoker/mvnup/goals/Check.java     |   36 +-
 .../maven/cling/invoker/mvnup/goals/Help.java      |   63 +
 .../maven/cling/invoker/mvnup/jdom/Artifacts.java  |   24 +-
 .../maven/cling/invoker/mvnup/jdom/IOConsumer.java |   24 +-
 .../maven/cling/invoker/mvnup/jdom/IOSupplier.java |   25 +-
 .../maven/cling/invoker/mvnup/jdom/JDomCfg.java    |   35 +-
 .../invoker/mvnup/jdom/JDomContentHelper.java      |  184 ++
 .../cling/invoker/mvnup/jdom/JDomDocumentIO.java   |  139 ++
 .../invoker/mvnup/jdom/JDomExtensionsCfg.java      |   54 +
 .../invoker/mvnup/jdom/JDomExtensionsEditor.java   |  101 ++
 .../invoker/mvnup/jdom/JDomExtensionsSource.java   |   48 +
 .../mvnup/jdom/JDomExtensionsTransformer.java      |   93 +
 .../maven/cling/invoker/mvnup/jdom/JDomPomCfg.java |  201 +++
 .../invoker/mvnup/jdom/JDomPomCleanupHelper.java   |  279 +++
 .../cling/invoker/mvnup/jdom/JDomPomEditor.java    |  576 ++++++
 .../invoker/mvnup/jdom/JDomPomTransformer.java     |  167 ++
 .../mvnup/jdom/JDomTransformationContext.java      |   52 +
 .../maven/cling/invoker/mvnup/jdom/JDomUtils.java  |  552 ++++++
 .../maven/cling/invoker/mvnup/jdom/Sink.java       |   67 +
 .../maven/cling/invoker/mvnup/jdom/Source.java     |   27 +-
 .../cling/invoker/mvnup/jdom/package-info.java     |   16 +
 .../maven/cling/invoker/mvnup/package-info.java    |   24 +-
 .../cling/invoker/mvnup/PluginUpgradeCliTest.java  |  193 ++
 .../maven/cling/invoker/mvnup/goals/ApplyTest.java |  379 ++++
 .../mvnup/goals/DependencyInferenceTest.java       |  373 ++++
 .../maven/cling/invoker/mvnup/goals/HelpTest.java  |  140 ++
 .../invoker/mvnup/goals/Maven400InferenceTest.java |  387 ++++
 .../mvnup/goals/Maven4CompatibilityFixesTest.java  |  268 +++
 .../invoker/mvnup/goals/ParentInferenceTest.java   |  371 ++++
 .../invoker/mvnup/goals/PluginOptionsTest.java     |  294 +++
 .../invoker/mvnup/goals/PluginUpgradeTest.java     |  514 ++++++
 pom.xml                                            |    5 +
 src/graph/ReactorGraph.java                        |    2 +-
 56 files changed, 8736 insertions(+), 192 deletions(-)

diff --git a/apache-maven/src/assembly/component.xml b/apache-maven/src/assembly/component.xml
index fefc8b49bf..4d75c9a38c 100644
--- a/apache-maven/src/assembly/component.xml
+++ b/apache-maven/src/assembly/component.xml
@@ -87,6 +87,7 @@ under the License.
         <include>mvn</include>
         <include>mvnenc</include>
         <include>mvnsh</include>
+        <include>mvnup</include>
         <include>mvnDebug</include>
         <include>mvnencDebug</include>
         <!-- This is so that CI systems can periodically run the profiler -->
diff --git a/apache-maven/src/assembly/maven/bin/mvn b/apache-maven/src/assembly/maven/bin/mvn
index 511e5e241f..59cb66a9cc 100755
--- a/apache-maven/src/assembly/maven/bin/mvn
+++ b/apache-maven/src/assembly/maven/bin/mvn
@@ -228,6 +228,9 @@ handle_args() {
       --shell)
         MAVEN_MAIN_CLASS="org.apache.maven.cling.MavenShellCling"
         ;;
+      --up)
+        MAVEN_MAIN_CLASS="org.apache.maven.cling.MavenUpCling"
+        ;;
       *)
         ;;
     esac
diff --git a/apache-maven/src/assembly/maven/bin/mvn.cmd b/apache-maven/src/assembly/maven/bin/mvn.cmd
index 4d292203c1..1d50c0ec32 100644
--- a/apache-maven/src/assembly/maven/bin/mvn.cmd
+++ b/apache-maven/src/assembly/maven/bin/mvn.cmd
@@ -179,22 +179,22 @@ if not exist "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadJvmConfig
 set JVM_CONFIG_MAVEN_OPTS=
 for /F "usebackq tokens=* delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do (
     set "line=%%a"
-    
+
     rem Skip empty lines and full-line comments
     echo !line! | findstr /b /r /c:"[ ]*#" >nul
     if errorlevel 1 (
         rem Handle end-of-line comments by taking everything before #
         for /f "tokens=1* delims=#" %%i in ("!line!") do set "line=%%i"
-        
+
         rem Trim leading/trailing spaces while preserving spaces in quotes
         set "trimmed=!line!"
         for /f "tokens=* delims= " %%i in ("!trimmed!") do set "trimmed=%%i"
         for /l %%i in (1,1,100) do if "!trimmed:~-1!"==" " set "trimmed=!trimmed:~0,-1!"
-        
+
         rem Replace MAVEN_PROJECTBASEDIR placeholders
         set "trimmed=!trimmed:${MAVEN_PROJECTBASEDIR}=%MAVEN_PROJECTBASEDIR%!"
         set "trimmed=!trimmed:$MAVEN_PROJECTBASEDIR=%MAVEN_PROJECTBASEDIR%!"
-        
+
         if not "!trimmed!"=="" (
             if "!JVM_CONFIG_MAVEN_OPTS!"=="" (
                 set "JVM_CONFIG_MAVEN_OPTS=!trimmed!"
@@ -229,6 +229,8 @@ if "%~1"=="--debug" (
     set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenEncCling"
 ) else if "%~1"=="--shell" (
       set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenShellCling"
+) else if "%~1"=="--up" (
+      set "MAVEN_MAIN_CLASS=org.apache.maven.cling.MavenUpCling"
 )
 exit /b 0
 
diff --git a/apache-maven/src/assembly/maven/bin/mvnup b/apache-maven/src/assembly/maven/bin/mvnup
new file mode 100755
index 0000000000..83cce8714e
--- /dev/null
+++ b/apache-maven/src/assembly/maven/bin/mvnup
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+# 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.
+
+# -----------------------------------------------------------------------------
+# Apache Maven Upgrade Script
+#
+# Environment Variable Prerequisites
+#
+#   JAVA_HOME           (Optional) Points to a Java installation.
+#   MAVEN_OPTS          (Optional) Java runtime options used when Maven is executed.
+#   MAVEN_SKIP_RC       (Optional) Flag to disable loading of mavenrc files.
+# -----------------------------------------------------------------------------
+
+"`dirname "$0"`/mvn" --up "$@"
diff --git a/apache-maven/src/assembly/maven/bin/mvnup.cmd b/apache-maven/src/assembly/maven/bin/mvnup.cmd
new file mode 100644
index 0000000000..21fa1fadc9
--- /dev/null
+++ b/apache-maven/src/assembly/maven/bin/mvnup.cmd
@@ -0,0 +1,37 @@
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements.  See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership.  The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License.  You may obtain a copy of the License at
+@REM
+@REM    http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied.  See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+
+@REM -----------------------------------------------------------------------------
+@REM Apache Maven Upgrade Script
+@REM
+@REM Environment Variable Prerequisites
+@REM
+@REM   JAVA_HOME           (Optional) Points to a Java installation.
+@REM   MAVEN_OPTS          (Optional) Java runtime options used when Maven is executed.
+@REM   MAVEN_SKIP_RC       (Optional) Flag to disable loading of mavenrc files.
+@REM -----------------------------------------------------------------------------
+
+@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
+@echo off
+@REM set title of command window
+title %0
+@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
+@if "%MAVEN_BATCH_ECHO%"=="on" echo %MAVEN_BATCH_ECHO%
+
+@setlocal
+
+@call "%~dp0"mvn.cmd --up %*
diff --git a/apache-maven/src/main/appended-resources/META-INF/LICENSE.vm b/apache-maven/src/main/appended-resources/META-INF/LICENSE.vm
index 0666af6677..02ddf974cf 100644
--- a/apache-maven/src/main/appended-resources/META-INF/LICENSE.vm
+++ b/apache-maven/src/main/appended-resources/META-INF/LICENSE.vm
@@ -39,13 +39,14 @@ subject to the terms and conditions of the following licenses:
 #*      *##set ( $spdx = 'EPL-1.0' )
 #*    *##elseif ( $license.name == "Eclipse Public License, Version 2.0" )
 #*      *##set ( $spdx = 'EPL-2.0' )
-#*    *##elseif ( $license.url.contains( "www.apache.org/licenses/LICENSE-2.0" ) )
+#*    *##elseif ( $license.url.contains( "www.apache.org/licenses/LICENSE-2.0" )
+                    || $license.url.contains( "https://raw.github.com/hunterhacker/jdom/master/LICENSE.txt" ) )
 #*      *##set ( $spdx = 'Apache-2.0' )
 #*    *##elseif ( $license.name == "BSD-2-Clause" || $license.name == "The BSD 2-Clause License"
-                    || $license.url.contains("www.opensource.org/licenses/bsd-license") )
+                    || $license.url.contains( "www.opensource.org/licenses/bsd-license" ) )
 #*      *##set ( $spdx = 'BSD-2-Clause' )
 #*    *##elseif ( $license.name == "BSD-3-Clause"
-                    || $license.url.contains("opensource.org/licenses/BSD-3-Clause") )
+                    || $license.url.contains( "opensource.org/licenses/BSD-3-Clause" ) )
 #*      *##set ( $spdx = 'BSD-3-Clause' )
 #*    *##elseif ( $license.name == "Public Domain" )
 #*      *##set ( $spdx = 'Public-Domain' )
@@ -53,7 +54,7 @@ subject to the terms and conditions of the following licenses:
 #*      *##set ( $spdx = 'CDDL+GPLv2-with-classpath-exception' )
 #*    *##else
 #*      *### unrecognized license will require analysis to know obligations
-#*      *##set ( $spdx = 'unrecognized' )
+#*      *##set ( $spdx = $license )
 #*    *##end
 #*    *###
 #*    *### fix project urls that are wrong in pom
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java
index ea30233fc1..ee25ec63da 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/ParserRequest.java
@@ -229,6 +229,30 @@ static Builder mvnsh(@Nonnull List<String> args, @Nonnull MessageBuilderFactory
         return builder(Tools.MVNSHELL_CMD, Tools.MVNSHELL_NAME, args, messageBuilderFactory);
     }
 
+    /**
+     * Creates a new Builder instance for constructing a Maven Upgrade Tool ParserRequest.
+     *
+     * @param args the command-line arguments
+     * @param messageBuilderFactory the factory for creating message builders
+     * @return a new Builder instance
+     */
+    @Nonnull
+    static Builder mvnup(@Nonnull String[] args, @Nonnull MessageBuilderFactory messageBuilderFactory) {
+        return mvnup(Arrays.asList(args), messageBuilderFactory);
+    }
+
+    /**
+     * Creates a new Builder instance for constructing a Maven Upgrade Tool ParserRequest.
+     *
+     * @param args the command-line arguments
+     * @param messageBuilderFactory the factory for creating message builders
+     * @return a new Builder instance
+     */
+    @Nonnull
+    static Builder mvnup(@Nonnull List<String> args, @Nonnull MessageBuilderFactory messageBuilderFactory) {
+        return builder(Tools.MVNUP_CMD, Tools.MVNUP_NAME, args, messageBuilderFactory);
+    }
+
     /**
      * Creates a new Builder instance for constructing a ParserRequest.
      *
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
index ce566d2710..7559d7ffee 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
@@ -39,4 +39,7 @@ private Tools() {}
 
     public static final String MVNSHELL_CMD = "mvnsh";
     public static final String MVNSHELL_NAME = "Maven Shell Tool";
+
+    public static final String MVNUP_CMD = "mvnup";
+    public static final String MVNUP_NAME = "Maven Upgrade Tool";
 }
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnup/UpgradeOptions.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnup/UpgradeOptions.java
new file mode 100644
index 0000000000..2835fc283b
--- /dev/null
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnup/UpgradeOptions.java
@@ -0,0 +1,117 @@
+/*
+ * 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.cli.mvnup;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.function.UnaryOperator;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.cli.Options;
+
+/**
+ * Defines the options specific to the Maven upgrade tool.
+ * This interface extends the general {@link Options} interface, adding upgrade-specific configuration options.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+public interface UpgradeOptions extends Options {
+    /**
+     * Should the operation be forced (ie overwrite existing files, if any).
+     *
+     * @return an {@link Optional} containing the boolean value {@code true} if specified, or empty
+     */
+    Optional<Boolean> force();
+
+    /**
+     * Should imply "yes" to all questions.
+     *
+     * @return an {@link Optional} containing the boolean value {@code true} if specified, or empty
+     */
+    Optional<Boolean> yes();
+
+    /**
+     * Returns the list of upgrade goals to be executed.
+     * These goals can include operations like "check", "dependencies", "plugins", etc.
+     *
+     * @return an {@link Optional} containing the list of goals, or empty if not specified
+     */
+    @Nonnull
+    Optional<List<String>> goals();
+
+    /**
+     * Returns the target POM model version for upgrades.
+     * Supported values include "4.0.0" and "4.1.0".
+     *
+     * @return an {@link Optional} containing the model version, or empty if not specified
+     */
+    Optional<String> model();
+
+    /**
+     * Returns the directory to use as starting point for POM discovery.
+     * If not specified, the current directory will be used.
+     *
+     * @return an {@link Optional} containing the directory path, or empty if not specified
+     */
+    Optional<String> directory();
+
+    /**
+     * Should use inference when upgrading (remove redundant information).
+     *
+     * @return an {@link Optional} containing the boolean value {@code true} if specified, or empty
+     */
+    Optional<Boolean> infer();
+
+    /**
+     * Should fix Maven 4 compatibility issues in POMs.
+     * This includes fixing unsupported combine attributes, duplicate dependencies,
+     * unsupported expressions, and other Maven 4 validation issues.
+     *
+     * @return an {@link Optional} containing the boolean value {@code true} if specified, or empty
+     */
+    Optional<Boolean> fixModel();
+
+    /**
+     * Should upgrade plugins known to fail with Maven 4 to their minimum compatible versions.
+     * This includes upgrading plugins like maven-exec-plugin, maven-enforcer-plugin,
+     * flatten-maven-plugin, and maven-shade-plugin to versions that work with Maven 4.
+     *
+     * @return an {@link Optional} containing the boolean value {@code true} if specified, or empty
+     */
+    Optional<Boolean> plugins();
+
+    /**
+     * Should apply all upgrade options (equivalent to --model 4.1.0 --infer --fix-model --plugins).
+     * This is a convenience option that combines model upgrade, inference, compatibility fixes, and plugin upgrades.
+     *
+     * @return an {@link Optional} containing the boolean value {@code true} if specified, or empty
+     */
+    Optional<Boolean> all();
+
+    /**
+     * Returns a new instance of UpgradeOptions with values interpolated using the given properties.
+     *
+     * @param callback a callback to use for interpolation
+     * @return a new UpgradeOptions instance with interpolated values
+     */
+    @Nonnull
+    UpgradeOptions interpolate(UnaryOperator<String> callback);
+}
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnup/package-info.java
similarity index 57%
copy from api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
copy to api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnup/package-info.java
index ce566d2710..d260dcffb3 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/mvnup/package-info.java
@@ -16,27 +16,23 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.maven.api.cli;
-
-import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Immutable;
 
 /**
- * Represents most common tools supported by CLIng.
+ * Provides the API for the Maven Upgrade tool ({@code mvnup}).
+ *
+ * <p>This package contains interfaces and classes for the Maven upgrade tool,
+ * which provides functionality for upgrading Maven projects and dependencies.</p>
+ *
+ * <p>Key features include:</p>
+ * <ul>
+ *   <li>Project upgrade capabilities</li>
+ *   <li>Dependency version management</li>
+ *   <li>Configuration migration</li>
+ *   <li>Interactive upgrade workflows</li>
+ * </ul>
  *
+ * @see org.apache.maven.api.cli.Tools#MVNUP_CMD
+ * @see org.apache.maven.api.cli.Tools#MVNUP_NAME
  * @since 4.0.0
  */
-@Immutable
-@Experimental
-public final class Tools {
-    private Tools() {}
-
-    public static final String MVN_CMD = "mvn";
-    public static final String MVN_NAME = "Maven";
-
-    public static final String MVNENC_CMD = "mvnenc";
-    public static final String MVNENC_NAME = "Maven Password Encrypting Tool";
-
-    public static final String MVNSHELL_CMD = "mvnsh";
-    public static final String MVNSHELL_NAME = "Maven Shell Tool";
-}
+package org.apache.maven.api.cli.mvnup;
diff --git a/impl/maven-cli/pom.xml b/impl/maven-cli/pom.xml
index 4ab36ecf7e..cabe1f0eee 100644
--- a/impl/maven-cli/pom.xml
+++ b/impl/maven-cli/pom.xml
@@ -151,6 +151,11 @@ under the License.
       <artifactId>plexus-xml</artifactId>
     </dependency>
 
+    <dependency>
+      <groupId>org.jdom</groupId>
+      <artifactId>jdom2</artifactId>
+    </dependency>
+
     <dependency>
       <groupId>javax.inject</groupId>
       <artifactId>javax.inject</artifactId>
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenUpCling.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenUpCling.java
new file mode 100644
index 0000000000..aeb44cf7da
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/MavenUpCling.java
@@ -0,0 +1,90 @@
+/*
+ * 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.cling;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.cli.Invoker;
+import org.apache.maven.api.cli.Parser;
+import org.apache.maven.api.cli.ParserRequest;
+import org.apache.maven.cling.invoker.ProtoLookup;
+import org.apache.maven.cling.invoker.mvnup.UpgradeInvoker;
+import org.apache.maven.cling.invoker.mvnup.UpgradeParser;
+import org.codehaus.plexus.classworlds.ClassWorld;
+
+/**
+ * Maven upgrade CLI "new-gen".
+ */
+public class MavenUpCling extends ClingSupport {
+    /**
+     * "Normal" Java entry point. Note: Maven uses ClassWorld Launcher and this entry point is NOT used under normal
+     * circumstances.
+     */
+    public static void main(String[] args) throws IOException {
+        int exitCode = new MavenUpCling().run(args, null, null, null, false);
+        System.exit(exitCode);
+    }
+
+    /**
+     * ClassWorld Launcher "enhanced" entry point: returning exitCode and accepts Class World.
+     */
+    public static int main(String[] args, ClassWorld world) throws IOException {
+        return new MavenUpCling().run(args, null, null, null, false);
+    }
+
+    /**
+     * ClassWorld Launcher "embedded" entry point: returning exitCode and accepts Class World and streams.
+     */
+    public static int main(
+            String[] args,
+            ClassWorld world,
+            @Nullable InputStream stdIn,
+            @Nullable OutputStream stdOut,
+            @Nullable OutputStream stdErr)
+            throws IOException {
+        return new MavenUpCling(world).run(args, stdIn, stdOut, stdErr, true);
+    }
+
+    public MavenUpCling() {
+        super();
+    }
+
+    public MavenUpCling(ClassWorld classWorld) {
+        super(classWorld);
+    }
+
+    @Override
+    protected Invoker createInvoker() {
+        return new UpgradeInvoker(
+                ProtoLookup.builder().addMapping(ClassWorld.class, classWorld).build());
+    }
+
+    @Override
+    protected Parser createParser() {
+        return new UpgradeParser();
+    }
+
+    @Override
+    protected ParserRequest.Builder createParserRequestBuilder(String[] args) {
+        return ParserRequest.mvnup(args, createMessageBuilderFactory());
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java
index cc914e7d93..10f8a06ec4 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/CommonsCliOptions.java
@@ -285,6 +285,7 @@ protected static class CLIManager {
         // parameters handled by script
         public static final String DEBUG = "debug";
         public static final String ENC = "enc";
+        public static final String UPGRADE = "up";
         public static final String SHELL = "shell";
         public static final String YJP = "yjp";
 
@@ -406,6 +407,10 @@ protected void prepareOptions(org.apache.commons.cli.Options options) {
                     .longOpt(ENC)
                     .desc("Launch the Maven Encryption tool (script option).")
                     .build());
+            options.addOption(Option.builder()
+                    .longOpt(UPGRADE)
+                    .desc("Launch the Maven Upgrade tool (script option).")
+                    .build());
             options.addOption(Option.builder()
                     .longOpt(SHELL)
                     .desc("Launch the Maven Shell tool (script option).")
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java
index 54fa219bd2..6e06cd7425 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnsh/builtin/BuiltinShellCommandRegistryFactory.java
@@ -44,6 +44,8 @@
 import org.apache.maven.cling.invoker.mvnenc.EncryptParser;
 import org.apache.maven.cling.invoker.mvnenc.Goal;
 import org.apache.maven.cling.invoker.mvnsh.ShellCommandRegistryFactory;
+import org.apache.maven.cling.invoker.mvnup.UpgradeInvoker;
+import org.apache.maven.cling.invoker.mvnup.UpgradeParser;
 import org.apache.maven.impl.util.Os;
 import org.jline.builtins.Completers;
 import org.jline.console.CmdDesc;
@@ -70,6 +72,8 @@ private static class BuiltinShellCommandRegistry extends JlineCommandRegistry im
         private final MavenParser mavenParser;
         private final EncryptInvoker shellEncryptInvoker;
         private final EncryptParser encryptParser;
+        private final UpgradeInvoker shellUpgradeInvoker;
+        private final UpgradeParser upgradeParser;
 
         private BuiltinShellCommandRegistry(LookupContext shellContext) {
             this.shellContext = requireNonNull(shellContext, "shellContext");
@@ -77,12 +81,15 @@ private BuiltinShellCommandRegistry(LookupContext shellContext) {
             this.mavenParser = new MavenParser();
             this.shellEncryptInvoker = new EncryptInvoker(shellContext.invokerRequest.lookup(), contextCopier());
             this.encryptParser = new EncryptParser();
+            this.shellUpgradeInvoker = new UpgradeInvoker(shellContext.invokerRequest.lookup(), contextCopier());
+            this.upgradeParser = new UpgradeParser();
             Map<String, CommandMethods> commandExecute = new HashMap<>();
             commandExecute.put("!", new CommandMethods(this::shell, this::defaultCompleter));
             commandExecute.put("cd", new CommandMethods(this::cd, this::cdCompleter));
             commandExecute.put("pwd", new CommandMethods(this::pwd, this::defaultCompleter));
             commandExecute.put("mvn", new CommandMethods(this::mvn, this::mvnCompleter));
             commandExecute.put("mvnenc", new CommandMethods(this::mvnenc, this::mvnencCompleter));
+            commandExecute.put("mvnup", new CommandMethods(this::mvnup, this::mvnupCompleter));
             registerCommands(commandExecute);
         }
 
@@ -113,6 +120,7 @@ private Consumer<LookupContext> contextCopier() {
         public void close() throws Exception {
             shellMavenInvoker.close();
             shellEncryptInvoker.close();
+            shellUpgradeInvoker.close();
         }
 
         @Override
@@ -241,6 +249,26 @@ private List<Completer> mvnencCompleter(String name) {
             return List.of(new ArgumentCompleter(new StringsCompleter(
                     shellContext.lookup.lookupMap(Goal.class).keySet())));
         }
+
+        private void mvnup(CommandInput input) {
+            try {
+                shellUpgradeInvoker.invoke(upgradeParser.parseInvocation(
+                        ParserRequest.mvnup(input.args(), shellContext.invokerRequest.messageBuilderFactory())
+                                .cwd(shellContext.cwd.get())
+                                .build()));
+            } catch (InvokerException.ExitException e) {
+                shellContext.logger.error("mvnup command exited with exit code " + e.getExitCode());
+            } catch (Exception e) {
+                saveException(e);
+            }
+        }
+
+        private List<Completer> mvnupCompleter(String name) {
+            return List.of(new ArgumentCompleter(new StringsCompleter(shellContext
+                    .lookup
+                    .lookupMap(org.apache.maven.cling.invoker.mvnup.Goal.class)
+                    .keySet())));
+        }
     }
 
     private static class StreamGobbler implements Runnable {
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/CommonsCliUpgradeOptions.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/CommonsCliUpgradeOptions.java
new file mode 100644
index 0000000000..08c9a0b484
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/CommonsCliUpgradeOptions.java
@@ -0,0 +1,231 @@
+/*
+ * 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.cling.invoker.mvnup;
+
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Optional;
+import java.util.function.Consumer;
+import java.util.function.UnaryOperator;
+
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.ParseException;
+import org.apache.maven.api.cli.Options;
+import org.apache.maven.api.cli.ParserRequest;
+import org.apache.maven.api.cli.mvnup.UpgradeOptions;
+import org.apache.maven.api.services.Interpolator;
+import org.apache.maven.api.services.InterpolatorException;
+import org.apache.maven.cling.invoker.CommonsCliOptions;
+
+import static org.apache.maven.cling.invoker.CliUtils.createInterpolator;
+
+/**
+ * Implementation of {@link UpgradeOptions} (base + mvnup).
+ */
+public class CommonsCliUpgradeOptions extends CommonsCliOptions implements UpgradeOptions {
+    public static CommonsCliUpgradeOptions parse(String[] args) throws ParseException {
+        CLIManager cliManager = new CLIManager();
+        return new CommonsCliUpgradeOptions(Options.SOURCE_CLI, cliManager, cliManager.parse(args));
+    }
+
+    protected CommonsCliUpgradeOptions(String source, CLIManager cliManager, CommandLine commandLine) {
+        super(source, cliManager, commandLine);
+    }
+
+    private static CommonsCliUpgradeOptions interpolate(
+            CommonsCliUpgradeOptions options, UnaryOperator<String> callback) {
+        try {
+            // now that we have properties, interpolate all arguments
+            Interpolator interpolator = createInterpolator();
+            CommandLine.Builder commandLineBuilder = new CommandLine.Builder();
+            commandLineBuilder.setDeprecatedHandler(o -> {});
+            for (Option option : options.commandLine.getOptions()) {
+                if (!CLIManager.USER_PROPERTY.equals(option.getOpt())) {
+                    List<String> values = option.getValuesList();
+                    for (ListIterator<String> it = values.listIterator(); it.hasNext(); ) {
+                        it.set(interpolator.interpolate(it.next(), callback));
+                    }
+                }
+                commandLineBuilder.addOption(option);
+            }
+            for (String arg : options.commandLine.getArgList()) {
+                commandLineBuilder.addArg(interpolator.interpolate(arg, callback));
+            }
+            return new CommonsCliUpgradeOptions(
+                    options.source, (CLIManager) options.cliManager, commandLineBuilder.build());
+        } catch (InterpolatorException e) {
+            throw new IllegalArgumentException("Could not interpolate CommonsCliOptions", e);
+        }
+    }
+
+    @Override
+    public Optional<Boolean> force() {
+        if (commandLine.hasOption(CLIManager.FORCE)) {
+            return Optional.of(Boolean.TRUE);
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<Boolean> yes() {
+        if (commandLine.hasOption(CLIManager.YES)) {
+            return Optional.of(Boolean.TRUE);
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<List<String>> goals() {
+        if (!commandLine.getArgList().isEmpty()) {
+            return Optional.of(commandLine.getArgList());
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<String> model() {
+        if (commandLine.hasOption(CLIManager.MODEL)) {
+            return Optional.of(commandLine.getOptionValue(CLIManager.MODEL));
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<String> directory() {
+        if (commandLine.hasOption(CLIManager.DIRECTORY)) {
+            return Optional.of(commandLine.getOptionValue(CLIManager.DIRECTORY));
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<Boolean> infer() {
+        if (commandLine.hasOption(CLIManager.INFER)) {
+            return Optional.of(Boolean.TRUE);
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<Boolean> fixModel() {
+        if (commandLine.hasOption(CLIManager.FIX_MODEL)) {
+            return Optional.of(Boolean.TRUE);
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<Boolean> plugins() {
+        if (commandLine.hasOption(CLIManager.PLUGINS)) {
+            return Optional.of(Boolean.TRUE);
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public Optional<Boolean> all() {
+        if (commandLine.hasOption(CLIManager.ALL)) {
+            return Optional.of(Boolean.TRUE);
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public UpgradeOptions interpolate(UnaryOperator<String> callback) {
+        return interpolate(this, callback);
+    }
+
+    @Override
+    public void displayHelp(ParserRequest request, Consumer<String> printStream) {
+        super.displayHelp(request, printStream);
+        printStream.accept("");
+        // we have no DI here (to discover)
+        printStream.accept("Goals:");
+        printStream.accept("  help  - display this help message");
+        printStream.accept("  check - check for available upgrades");
+        printStream.accept("  apply - apply available upgrades");
+        printStream.accept("");
+        printStream.accept("Options:");
+        printStream.accept("  -m, --model <version> Target POM model version (4.0.0 or 4.1.0)");
+        printStream.accept("  -d, --directory <path> Directory to use as starting point for POM discovery");
+        printStream.accept("  -i, --infer           Remove redundant information that can be inferred by Maven");
+        printStream.accept("      --fix-model       Fix Maven 4 compatibility issues in POM files");
+        printStream.accept("      --plugins         Upgrade plugins known to fail with Maven 4");
+        printStream.accept(
+                "  -a, --all             Apply all upgrades (equivalent to --model 4.1.0 --infer --fix-model --plugins)");
+        printStream.accept("  -f, --force           Overwrite files without asking for confirmation");
+        printStream.accept("  -y, --yes             Answer \"yes\" to all prompts automatically");
+        printStream.accept("");
+        printStream.accept("Default behavior: --fix-model and --plugins are applied if no other options are specified");
+        printStream.accept("");
+    }
+
+    protected static class CLIManager extends CommonsCliOptions.CLIManager {
+        public static final String FORCE = "f";
+        public static final String YES = "y";
+        public static final String MODEL = "m";
+        public static final String DIRECTORY = "d";
+        public static final String INFER = "i";
+        public static final String FIX_MODEL = "fix-model";
+        public static final String PLUGINS = "plugins";
+        public static final String ALL = "a";
+
+        @Override
+        protected void prepareOptions(org.apache.commons.cli.Options options) {
+            super.prepareOptions(options);
+            options.addOption(Option.builder(FORCE)
+                    .longOpt("force")
+                    .desc("Should overwrite without asking any configuration?")
+                    .build());
+            options.addOption(Option.builder(YES)
+                    .longOpt("yes")
+                    .desc("Should imply user answered \"yes\" to all incoming questions?")
+                    .build());
+            options.addOption(Option.builder(MODEL)
+                    .longOpt("model")
+                    .hasArg()
+                    .argName("version")
+                    .desc("Target POM model version (4.0.0 or 4.1.0)")
+                    .build());
+            options.addOption(Option.builder(DIRECTORY)
+                    .longOpt("directory")
+                    .hasArg()
+                    .argName("path")
+                    .desc("Directory to use as starting point for POM discovery")
+                    .build());
+            options.addOption(Option.builder(INFER)
+                    .longOpt("infer")
+                    .desc("Use inference when upgrading (remove redundant information)")
+                    .build());
+            options.addOption(Option.builder(FIX_MODEL)
+                    .longOpt("fix-model")
+                    .desc("Fix Maven 4 compatibility issues in POM files")
+                    .build());
+            options.addOption(Option.builder(PLUGINS)
+                    .longOpt("plugins")
+                    .desc("Upgrade plugins known to fail with Maven 4 to their minimum compatible versions")
+                    .build());
+            options.addOption(Option.builder(ALL)
+                    .longOpt("all")
+                    .desc("Apply all upgrades (equivalent to --model 4.1.0 --infer --fix-model --plugins)")
+                    .build());
+        }
+    }
+}
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/Goal.java
similarity index 56%
copy from api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/Goal.java
index ce566d2710..659e59ebe1 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/Goal.java
@@ -16,27 +16,11 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.maven.api.cli;
-
-import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Immutable;
+package org.apache.maven.cling.invoker.mvnup;
 
 /**
- * Represents most common tools supported by CLIng.
- *
- * @since 4.0.0
+ * The mvnup tool goal.
  */
-@Immutable
-@Experimental
-public final class Tools {
-    private Tools() {}
-
-    public static final String MVN_CMD = "mvn";
-    public static final String MVN_NAME = "Maven";
-
-    public static final String MVNENC_CMD = "mvnenc";
-    public static final String MVNENC_NAME = "Maven Password Encrypting Tool";
-
-    public static final String MVNSHELL_CMD = "mvnsh";
-    public static final String MVNSHELL_NAME = "Maven Shell Tool";
+public interface Goal {
+    int execute(UpgradeContext context) throws Exception;
 }
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/PomDiscovery.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/PomDiscovery.java
new file mode 100644
index 0000000000..3e0fbc900b
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/PomDiscovery.java
@@ -0,0 +1,303 @@
+/*
+ * 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.cling.invoker.mvnup;
+
+import java.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.jdom2.Document;
+import org.jdom2.Element;
+import org.jdom2.JDOMException;
+import org.jdom2.Namespace;
+import org.jdom2.input.SAXBuilder;
+
+/**
+ * Utility class for discovering and loading POM files in a Maven project hierarchy.
+ */
+public class PomDiscovery {
+
+    private static final String POM_XML = "pom.xml";
+
+    /**
+     * Discovers and loads all POM files starting from the given directory.
+     *
+     * @param startDirectory the directory to start discovery from
+     * @return a map of Path to Document for all discovered POM files
+     * @throws IOException if there's an error reading files
+     * @throws JDOMException if there's an error parsing XML
+     */
+    public static Map<Path, Document> discoverPoms(Path startDirectory) throws IOException, JDOMException {
+        Map<Path, Document> pomMap = new HashMap<>();
+
+        // Find and load the root POM
+        Path rootPomPath = startDirectory.resolve(POM_XML);
+        if (!Files.exists(rootPomPath)) {
+            throw new IOException("No pom.xml found in directory: " + startDirectory);
+        }
+
+        Document rootPom = loadPom(rootPomPath);
+        pomMap.put(rootPomPath, rootPom);
+
+        // Recursively discover modules
+        discoverModules(startDirectory, rootPom, pomMap);
+
+        return pomMap;
+    }
+
+    /**
+     * Discovers and loads all POM files starting from the given directory path string.
+     *
+     * @param startDirectoryPath the directory path string to start discovery from
+     * @return a map of Path to Document for all discovered POM files
+     * @throws IOException if there's an error reading files
+     * @throws JDOMException if there's an error parsing XML
+     */
+    public static Map<Path, Document> discoverPoms(String startDirectoryPath) throws IOException, JDOMException {
+        return discoverPoms(Paths.get(startDirectoryPath));
+    }
+
+    /**
+     * Recursively discovers modules from a POM document.
+     * Enhanced for 4.1.0 models to support subprojects, profiles, and directory scanning.
+     *
+     * @param currentDirectory the current directory being processed
+     * @param pomDocument the POM document to extract modules from
+     * @param pomMap the map to add discovered POMs to
+     * @throws IOException if there's an error reading files
+     * @throws JDOMException if there's an error parsing XML
+     */
+    private static void discoverModules(Path currentDirectory, Document pomDocument, Map<Path, Document> pomMap)
+            throws IOException, JDOMException {
+
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+
+        // Detect model version to determine discovery strategy
+        String modelVersion = detectModelVersion(pomDocument);
+        boolean is410OrLater = "4.1.0".equals(modelVersion) || isNewerThan410(modelVersion);
+
+        boolean foundModulesOrSubprojects = false;
+
+        // Look for modules element (both 4.0.0 and 4.1.0)
+        foundModulesOrSubprojects |= discoverFromModules(currentDirectory, root, namespace, pomMap);
+
+        // For 4.1.0+ models, also check subprojects/subproject elements
+        if (is410OrLater) {
+            foundModulesOrSubprojects |= discoverFromSubprojects(currentDirectory, root, namespace, pomMap);
+        }
+
+        // Check inside profiles for both 4.0.0 and 4.1.0
+        foundModulesOrSubprojects |= discoverFromProfiles(currentDirectory, root, namespace, pomMap, is410OrLater);
+
+        // For 4.1.0 models, if no modules or subprojects defined, scan direct child directories
+        if (is410OrLater && !foundModulesOrSubprojects) {
+            discoverFromDirectories(currentDirectory, pomMap);
+        }
+    }
+
+    /**
+     * Detects the model version from a POM document.
+     * The explicit modelVersion element takes precedence over namespace URI.
+     */
+    private static String detectModelVersion(Document pomDocument) {
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+
+        String explicitVersion = null;
+        String namespaceVersion = null;
+
+        // Check explicit modelVersion element first (takes precedence)
+        Element modelVersionElement = root.getChild("modelVersion", namespace);
+        if (modelVersionElement != null) {
+            explicitVersion = modelVersionElement.getTextTrim();
+        }
+
+        // Check namespace URI for 4.1.0+ models
+        if (namespace != null && namespace.getURI() != null) {
+            String namespaceUri = namespace.getURI();
+            if (namespaceUri.contains("4.1.0")) {
+                namespaceVersion = "4.1.0";
+            }
+            // Future versions can be detected here
+            // if (namespaceUri.contains("4.2.0")) {
+            //     namespaceVersion = "4.2.0";
+            // }
+        }
+
+        // Explicit version takes precedence
+        if (explicitVersion != null && !explicitVersion.isEmpty()) {
+            // Check for mismatch between explicit version and namespace
+            if (namespaceVersion != null && !explicitVersion.equals(namespaceVersion)) {
+                System.err.println("WARNING: Model version mismatch in POM - explicit: " + explicitVersion
+                        + ", namespace suggests: " + namespaceVersion + ". Using explicit version.");
+            }
+            return explicitVersion;
+        }
+
+        // Fall back to namespace-inferred version
+        if (namespaceVersion != null) {
+            return namespaceVersion;
+        }
+
+        // Default to 4.0.0 with warning
+        System.err.println("WARNING: No model version found in POM, falling back to 4.0.0");
+        return "4.0.0";
+    }
+
+    /**
+     * Checks if a model version is newer than 4.1.0.
+     */
+    private static boolean isNewerThan410(String modelVersion) {
+        // Future versions like 4.2.0, 4.3.0, etc.
+        return modelVersion.compareTo("4.1.0") > 0;
+    }
+
+    /**
+     * Discovers modules from the modules element.
+     */
+    private static boolean discoverFromModules(
+            Path currentDirectory, Element root, Namespace namespace, Map<Path, Document> pomMap)
+            throws IOException, JDOMException {
+        Element modulesElement = root.getChild("modules", namespace);
+        if (modulesElement != null) {
+            List<Element> moduleElements = modulesElement.getChildren("module", namespace);
+
+            for (Element moduleElement : moduleElements) {
+                String modulePath = moduleElement.getTextTrim();
+                if (!modulePath.isEmpty()) {
+                    discoverModule(currentDirectory, modulePath, pomMap);
+                }
+            }
+            return !moduleElements.isEmpty();
+        }
+        return false;
+    }
+
+    /**
+     * Discovers subprojects from the subprojects element (4.1.0+ models).
+     */
+    private static boolean discoverFromSubprojects(
+            Path currentDirectory, Element root, Namespace namespace, Map<Path, Document> pomMap)
+            throws IOException, JDOMException {
+        Element subprojectsElement = root.getChild("subprojects", namespace);
+        if (subprojectsElement != null) {
+            List<Element> subprojectElements = subprojectsElement.getChildren("subproject", namespace);
+
+            for (Element subprojectElement : subprojectElements) {
+                String subprojectPath = subprojectElement.getTextTrim();
+                if (!subprojectPath.isEmpty()) {
+                    discoverModule(currentDirectory, subprojectPath, pomMap);
+                }
+            }
+            return !subprojectElements.isEmpty();
+        }
+        return false;
+    }
+
+    /**
+     * Discovers modules/subprojects from profiles.
+     */
+    private static boolean discoverFromProfiles(
+            Path currentDirectory, Element root, Namespace namespace, Map<Path, Document> pomMap, boolean is410OrLater)
+            throws IOException, JDOMException {
+        boolean foundAny = false;
+        Element profilesElement = root.getChild("profiles", namespace);
+        if (profilesElement != null) {
+            List<Element> profileElements = profilesElement.getChildren("profile", namespace);
+
+            for (Element profileElement : profileElements) {
+                // Check modules in profiles
+                foundAny |= discoverFromModules(currentDirectory, profileElement, namespace, pomMap);
+
+                // For 4.1.0+ models, also check subprojects in profiles
+                if (is410OrLater) {
+                    foundAny |= discoverFromSubprojects(currentDirectory, profileElement, namespace, pomMap);
+                }
+            }
+        }
+        return foundAny;
+    }
+
+    /**
+     * Discovers POM files by scanning direct child directories (4.1.0+ fallback).
+     */
+    private static void discoverFromDirectories(Path currentDirectory, Map<Path, Document> pomMap)
+            throws IOException, JDOMException {
+        try (DirectoryStream<Path> stream = Files.newDirectoryStream(currentDirectory, Files::isDirectory)) {
+            for (Path childDir : stream) {
+                Path childPomPath = childDir.resolve(POM_XML);
+                if (Files.exists(childPomPath) && !pomMap.containsKey(childPomPath)) {
+                    Document childPom = loadPom(childPomPath);
+                    pomMap.put(childPomPath, childPom);
+
+                    // Recursively discover from this child
+                    discoverModules(childDir, childPom, pomMap);
+                }
+            }
+        }
+    }
+
+    /**
+     * Discovers a single module/subproject.
+     * The modulePath may point directly at a pom.xml file or a directory containing one.
+     */
+    private static void discoverModule(Path currentDirectory, String modulePath, Map<Path, Document> pomMap)
+            throws IOException, JDOMException {
+        Path resolvedPath = currentDirectory.resolve(modulePath);
+        Path modulePomPath;
+        Path moduleDirectory;
+
+        // Check if modulePath points directly to a pom.xml file
+        if (modulePath.endsWith(POM_XML) || (Files.exists(resolvedPath) && Files.isRegularFile(resolvedPath))) {
+            modulePomPath = resolvedPath;
+            moduleDirectory = resolvedPath.getParent();
+        } else {
+            // modulePath points to a directory
+            moduleDirectory = resolvedPath;
+            modulePomPath = moduleDirectory.resolve(POM_XML);
+        }
+
+        if (Files.exists(modulePomPath) && !pomMap.containsKey(modulePomPath)) {
+            Document modulePom = loadPom(modulePomPath);
+            pomMap.put(modulePomPath, modulePom);
+
+            // Recursively discover sub-modules
+            discoverModules(moduleDirectory, modulePom, pomMap);
+        }
+    }
+
+    /**
+     * Loads a POM file using JDOM.
+     *
+     * @param pomPath the path to the POM file
+     * @return the parsed Document
+     * @throws IOException if there's an error reading the file
+     * @throws JDOMException if there's an error parsing the XML
+     */
+    private static Document loadPom(Path pomPath) throws IOException, JDOMException {
+        SAXBuilder builder = new SAXBuilder();
+        return builder.build(pomPath.toFile());
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/UpgradeContext.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/UpgradeContext.java
new file mode 100644
index 0000000000..3762578781
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/UpgradeContext.java
@@ -0,0 +1,56 @@
+/*
+ * 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.cling.invoker.mvnup;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.maven.api.cli.InvokerRequest;
+import org.apache.maven.cling.invoker.LookupContext;
+import org.jline.reader.LineReader;
+import org.jline.utils.AttributedString;
+import org.jline.utils.AttributedStringBuilder;
+import org.jline.utils.AttributedStyle;
+
+@SuppressWarnings("VisibilityModifier")
+public class UpgradeContext extends LookupContext {
+    public UpgradeContext(InvokerRequest invokerRequest) {
+        this(invokerRequest, true);
+    }
+
+    public UpgradeContext(InvokerRequest invokerRequest, boolean containerCapsuleManaged) {
+        super(invokerRequest, containerCapsuleManaged);
+    }
+
+    public Map<String, Goal> goals;
+
+    public List<AttributedString> header;
+    public AttributedStyle style;
+    public LineReader reader;
+
+    public void addInHeader(String text) {
+        addInHeader(AttributedStyle.DEFAULT, text);
+    }
+
+    public void addInHeader(AttributedStyle style, String text) {
+        AttributedStringBuilder asb = new AttributedStringBuilder();
+        asb.style(style).append(text);
+        header.add(asb.toAttributedString());
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/UpgradeInvoker.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/UpgradeInvoker.java
new file mode 100644
index 0000000000..5c58459426
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/UpgradeInvoker.java
@@ -0,0 +1,118 @@
+/*
+ * 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.cling.invoker.mvnup;
+
+import java.io.InterruptedIOException;
+import java.util.ArrayList;
+import java.util.function.Consumer;
+
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.cli.InvokerRequest;
+import org.apache.maven.api.cli.mvnup.UpgradeOptions;
+import org.apache.maven.api.services.Lookup;
+import org.apache.maven.cling.invoker.LookupContext;
+import org.apache.maven.cling.invoker.LookupInvoker;
+import org.apache.maven.cling.utils.CLIReportingUtils;
+import org.jline.reader.LineReaderBuilder;
+import org.jline.reader.UserInterruptException;
+import org.jline.terminal.Terminal;
+import org.jline.utils.AttributedStyle;
+import org.jline.utils.Colors;
+
+/**
+ * mvnup invoker implementation.
+ */
+public class UpgradeInvoker extends LookupInvoker<UpgradeContext> {
+
+    public static final int OK = 0; // OK
+    public static final int ERROR = 1; // "generic" error
+    public static final int BAD_OPERATION = 2; // bad user input or alike
+    public static final int CANCELED = 3; // user canceled
+
+    public UpgradeInvoker(Lookup protoLookup) {
+        this(protoLookup, null);
+    }
+
+    public UpgradeInvoker(Lookup protoLookup, @Nullable Consumer<LookupContext> contextConsumer) {
+        super(protoLookup, contextConsumer);
+    }
+
+    @Override
+    protected UpgradeContext createContext(InvokerRequest invokerRequest) {
+        return new UpgradeContext(invokerRequest);
+    }
+
+    @Override
+    protected void lookup(UpgradeContext context) throws Exception {
+        if (context.goals == null) {
+            super.lookup(context);
+            context.goals = context.lookup.lookupMap(Goal.class);
+        }
+    }
+
+    @Override
+    protected int execute(UpgradeContext context) throws Exception {
+        try {
+            context.header = new ArrayList<>();
+            context.style = new AttributedStyle();
+            context.addInHeader(
+                    context.style.italic().bold().foreground(Colors.rgbColor("green")),
+                    "Maven Upgrade " + CLIReportingUtils.showVersionMinimal());
+            context.addInHeader("Tool for upgrading Maven projects and dependencies.");
+            context.addInHeader("This tool is part of Apache Maven 4 distribution.");
+            context.addInHeader("");
+
+            context.terminal.handle(
+                    Terminal.Signal.INT, signal -> Thread.currentThread().interrupt());
+
+            context.reader =
+                    LineReaderBuilder.builder().terminal(context.terminal).build();
+
+            UpgradeOptions upgradeOptions = ((UpgradeInvokerRequest) context.invokerRequest).options();
+            if (upgradeOptions.goals().isEmpty()) {
+                return badGoalsErrorMessage("No goals specified.", context);
+            }
+
+            String goalName = upgradeOptions.goals().get().get(0);
+            Goal goal = context.goals.get(goalName);
+            if (goal == null) {
+                return badGoalsErrorMessage("Unknown goal: " + goalName, context);
+            }
+
+            return goal.execute(context);
+        } catch (InterruptedException | InterruptedIOException | UserInterruptException e) {
+            context.logger.error("Goal canceled by user.");
+            return CANCELED;
+        } catch (Exception e) {
+            if (context.invokerRequest.options().showErrors().orElse(false)) {
+                context.logger.error(e.getMessage(), e);
+            } else {
+                context.logger.error(e.getMessage());
+            }
+            return ERROR;
+        }
+    }
+
+    protected int badGoalsErrorMessage(String message, UpgradeContext context) {
+        context.logger.error(message);
+        context.logger.error("Supported goals are: " + String.join(", ", context.goals.keySet()));
+        context.logger.error("Use -h to display help.");
+        return BAD_OPERATION;
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/UpgradeInvokerRequest.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/UpgradeInvokerRequest.java
new file mode 100644
index 0000000000..add706e2e8
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/UpgradeInvokerRequest.java
@@ -0,0 +1,73 @@
+/*
+ * 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.cling.invoker.mvnup;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.cli.CoreExtensions;
+import org.apache.maven.api.cli.ParserRequest;
+import org.apache.maven.api.cli.cisupport.CIInfo;
+import org.apache.maven.api.cli.mvnup.UpgradeOptions;
+import org.apache.maven.cling.invoker.BaseInvokerRequest;
+
+import static java.util.Objects.requireNonNull;
+
+public class UpgradeInvokerRequest extends BaseInvokerRequest {
+    private final UpgradeOptions options;
+
+    @SuppressWarnings("ParameterNumber")
+    public UpgradeInvokerRequest(
+            ParserRequest parserRequest,
+            boolean parsingFailed,
+            Path cwd,
+            Path installationDirectory,
+            Path userHomeDirectory,
+            Map<String, String> userProperties,
+            Map<String, String> systemProperties,
+            Path topDirectory,
+            Path rootDirectory,
+            List<CoreExtensions> coreExtensions,
+            CIInfo ciInfo,
+            UpgradeOptions options) {
+        super(
+                parserRequest,
+                parsingFailed,
+                cwd,
+                installationDirectory,
+                userHomeDirectory,
+                userProperties,
+                systemProperties,
+                topDirectory,
+                rootDirectory,
+                coreExtensions,
+                ciInfo);
+        this.options = requireNonNull(options);
+    }
+
+    /**
+     * The mandatory Upgrade options.
+     */
+    @Nonnull
+    public UpgradeOptions options() {
+        return options;
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/UpgradeParser.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/UpgradeParser.java
new file mode 100644
index 0000000000..6c90206266
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/UpgradeParser.java
@@ -0,0 +1,75 @@
+/*
+ * 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.cling.invoker.mvnup;
+
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.cli.ParseException;
+import org.apache.maven.api.cli.Options;
+import org.apache.maven.api.cli.mvnup.UpgradeOptions;
+import org.apache.maven.cling.invoker.BaseParser;
+
+public class UpgradeParser extends BaseParser {
+
+    @Override
+    protected UpgradeOptions emptyOptions() {
+        try {
+            return CommonsCliUpgradeOptions.parse(new String[0]);
+        } catch (ParseException e) {
+            throw new IllegalArgumentException(e);
+        }
+    }
+
+    @Override
+    protected UpgradeInvokerRequest getInvokerRequest(LocalContext context) {
+        return new UpgradeInvokerRequest(
+                context.parserRequest,
+                context.parsingFailed,
+                context.cwd,
+                context.installationDirectory,
+                context.userHomeDirectory,
+                context.userProperties,
+                context.systemProperties,
+                context.topDirectory,
+                context.rootDirectory,
+                context.extensions,
+                context.ciInfo,
+                (UpgradeOptions) context.options);
+    }
+
+    @Override
+    protected List<Options> parseCliOptions(LocalContext context) {
+        return Collections.singletonList(parseUpgradeCliOptions(context.parserRequest.args()));
+    }
+
+    protected CommonsCliUpgradeOptions parseUpgradeCliOptions(List<String> args) {
+        try {
+            return CommonsCliUpgradeOptions.parse(args.toArray(new String[0]));
+        } catch (ParseException e) {
+            throw new IllegalArgumentException("Failed to parse command line options: " + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    protected Options assembleOptions(List<Options> parsedOptions) {
+        // nothing to assemble, we deal with CLI only
+        return parsedOptions.get(0);
+    }
+}
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/Apply.java
similarity index 56%
copy from api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/Apply.java
index ce566d2710..6916713f06 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/Apply.java
@@ -16,27 +16,30 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.maven.api.cli;
+package org.apache.maven.cling.invoker.mvnup.goals;
 
-import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Immutable;
+import org.apache.maven.api.di.Named;
+import org.apache.maven.api.di.Singleton;
+import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
 
 /**
- * Represents most common tools supported by CLIng.
- *
- * @since 4.0.0
+ * The "apply" goal implementation.
  */
-@Immutable
-@Experimental
-public final class Tools {
-    private Tools() {}
+@Named("apply")
+@Singleton
+public class Apply extends BaseUpgradeGoal {
 
-    public static final String MVN_CMD = "mvn";
-    public static final String MVN_NAME = "Maven";
+    @Override
+    protected boolean shouldSaveModifications() {
+        return true;
+    }
 
-    public static final String MVNENC_CMD = "mvnenc";
-    public static final String MVNENC_NAME = "Maven Password Encrypting Tool";
+    @Override
+    public int execute(UpgradeContext context) throws Exception {
+        context.logger.info("Maven Upgrade Tool - Apply");
+        context.logger.info("");
 
-    public static final String MVNSHELL_CMD = "mvnsh";
-    public static final String MVNSHELL_NAME = "Maven Shell Tool";
+        super.execute(context);
+        return 0;
+    }
 }
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/BaseUpgradeGoal.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/BaseUpgradeGoal.java
new file mode 100644
index 0000000000..c3574c1531
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/BaseUpgradeGoal.java
@@ -0,0 +1,1904 @@
+/*
+ * 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.cling.invoker.mvnup.goals;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.maven.api.cli.mvnup.UpgradeOptions;
+import org.apache.maven.cling.invoker.mvnup.Goal;
+import org.apache.maven.cling.invoker.mvnup.PomDiscovery;
+import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
+import org.apache.maven.cling.invoker.mvnup.UpgradeInvokerRequest;
+import org.jdom2.Document;
+import org.jdom2.Element;
+import org.jdom2.JDOMException;
+import org.jdom2.Namespace;
+import org.jdom2.output.Format;
+import org.jdom2.output.XMLOutputter;
+
+/**
+ * Base class for upgrade goals containing shared functionality.
+ * Subclasses only differ in whether they save modifications to disk.
+ *
+ * <h2>Supported Upgrades</h2>
+ *
+ * <h3>Model Version Upgrades</h3>
+ * <ul>
+ *   <li><strong>4.0.0 → 4.1.0</strong>: Upgrades Maven 3.x POMs to Maven 4.1.0 format</li>
+ * </ul>
+ *
+ * <h3>4.0.0 → 4.1.0 Upgrade Process</h3>
+ * <ol>
+ *   <li><strong>Namespace Update</strong>: Changes namespace from Maven 4.0.0 to 4.1.0 for all elements</li>
+ *   <li><strong>Schema Location Update</strong>: Updates xsi:schemaLocation to Maven 4.1.0 XSD</li>
+ *   <li><strong>Module Conversion</strong>: Converts {@code <modules>} to {@code <subprojects>} and {@code <module>} to {@code <subproject>}</li>
+ *   <li><strong>Model Version Update</strong>: Updates {@code <modelVersion>} to 4.1.0</li>
+ * </ol>
+ *
+ * <h3>Default Behavior</h3>
+ * If no specific options are provided, the tool applies {@code --fix-model} and {@code --plugins} by default to ensure Maven 4 compatibility.
+ *
+ * <h3>All-in-One Option</h3>
+ * The {@code --all} option is a convenience flag equivalent to {@code --model 4.1.0 --infer --fix-model --plugins}.
+ * It performs a complete upgrade to Maven 4.1.0 with all optimizations, compatibility fixes, and plugin upgrades.
+ *
+ * <h3>Maven 4 Compatibility Fixes</h3>
+ * When {@code --fix-model} option is enabled (or by default), applies fixes for Maven 4 compatibility issues:
+ * <ul>
+ *   <li><strong>Unsupported combine.children Attributes</strong>: Changes 'override' to 'merge' (Maven 4 only supports 'append' and 'merge')</li>
+ *   <li><strong>Unsupported combine.self Attributes</strong>: Changes 'append' to 'merge' (Maven 4 only supports 'override', 'merge', and 'remove')</li>
+ *   <li><strong>Duplicate Dependencies</strong>: Removes duplicate dependency declarations that Maven 4 strictly validates</li>
+ *   <li><strong>Unsupported Repository Expressions</strong>: Comments out repositories with expressions not supported by Maven 4</li>
+ * </ul>
+ *
+ * <h3>Plugin Upgrades</h3>
+ * When {@code --plugins} option is enabled (or by default), upgrades plugins known to fail with Maven 4:
+ * <ul>
+ *   <li><strong>maven-exec-plugin</strong>: Upgrades to version 3.2.0 or higher</li>
+ *   <li><strong>maven-enforcer-plugin</strong>: Upgrades to version 3.0.0 or higher</li>
+ *   <li><strong>flatten-maven-plugin</strong>: Upgrades to version 1.2.7 or higher</li>
+ *   <li><strong>maven-shade-plugin</strong>: Upgrades to version 3.5.0 or higher</li>
+ * </ul>
+ * Plugin versions are upgraded in both {@code <build><plugins>} and {@code <build><pluginManagement><plugins>} sections.
+ * If a plugin version is defined via a property, the property value is updated instead.
+ *
+ * <h3>Inference Optimizations (Optional)</h3>
+ * When {@code --infer} option is enabled, applies inference optimizations to remove redundant information:
+ *
+ * <h4>Limited Inference for 4.0.0 Models (Maven 3.x POMs)</h4>
+ * <ul>
+ *   <li><strong>Child GroupId Removal</strong>: Removes child {@code <groupId>} when it matches parent groupId</li>
+ *   <li><strong>Child Version Removal</strong>: Removes child {@code <version>} when it matches parent version</li>
+ * </ul>
+ *
+ * <h4>Full Inference for 4.1.0+ Models</h4>
+ * <ul>
+ *   <li><strong>ModelVersion Removal</strong>: Removes {@code <modelVersion>} element (inference enabled)</li>
+ *   <li><strong>Root Attribute</strong>: Adds {@code root="true"} attribute to root project</li>
+ *   <li><strong>Parent Element Trimming</strong>:
+ *     <ul>
+ *       <li>Removes parent {@code <groupId>} when child has no explicit groupId</li>
+ *       <li>Removes parent {@code <version>} when child has no explicit version</li>
+ *       <li>Removes parent {@code <artifactId>} when it can be inferred from relativePath</li>
+ *     </ul>
+ *   </li>
+ *   <li><strong>Managed Dependencies Cleanup</strong>: Removes managed dependencies pointing to project artifacts</li>
+ *   <li><strong>Dependency Inference</strong>:
+ *     <ul>
+ *       <li>Removes dependency {@code <version>} when it points to a project artifact</li>
+ *       <li>Removes dependency {@code <groupId>} when it points to a project artifact</li>
+ *       <li>Applies to main dependencies, profile dependencies, and plugin dependencies</li>
+ *     </ul>
+ *   </li>
+ *   <li><strong>Subprojects List Removal</strong>: Removes redundant {@code <subprojects>} lists that match direct child directories</li>
+ * </ul>
+ *
+ * <h3>Multi-Module Project Support</h3>
+ * <ul>
+ *   <li><strong>POM Discovery</strong>: Recursively discovers all POM files in the project structure</li>
+ *   <li><strong>GAV Resolution</strong>: Computes GroupId, ArtifactId, Version for all project artifacts with parent inheritance</li>
+ *   <li><strong>Cross-Module Inference</strong>: Uses knowledge of all project artifacts for intelligent inference decisions</li>
+ *   <li><strong>RelativePath Resolution</strong>: Resolves parent POMs via relativePath for artifactId inference</li>
+ * </ul>
+ *
+ * <h3>Format Preservation</h3>
+ * <ul>
+ *   <li><strong>Whitespace Preservation</strong>: Maintains original formatting when removing elements</li>
+ *   <li><strong>Comment Preservation</strong>: Preserves XML comments and processing instructions</li>
+ *   <li><strong>Line Separator Handling</strong>: Uses system-appropriate line separators</li>
+ * </ul>
+ */
+public abstract class BaseUpgradeGoal implements Goal {
+
+    /**
+     * Executes the upgrade goal.
+     * Template method that calls doUpgrade and optionally saves modifications.
+     */
+    @Override
+    public int execute(UpgradeContext context) throws Exception {
+        UpgradeInvokerRequest request = (UpgradeInvokerRequest) context.invokerRequest;
+        UpgradeOptions options = request.options();
+
+        // Determine target model version
+        // Default to 4.0.0 unless --all is specified or explicit --model is provided
+        String targetModel;
+        if (options.model().isPresent()) {
+            targetModel = options.model().get();
+        } else if (options.all().orElse(false)) {
+            targetModel = "4.1.0";
+        } else {
+            targetModel = "4.0.0";
+        }
+
+        if (!isValidModelVersion(targetModel)) {
+            context.logger.error("Invalid target model version: " + targetModel);
+            context.logger.error("Supported versions: 4.0.0, 4.1.0");
+            return 1;
+        }
+
+        // Discover POMs
+        context.logger.info("Discovering POM files...");
+        Path startingDirectory = options.directory().map(Paths::get).orElse(context.invokerRequest.cwd());
+
+        Map<Path, Document> pomMap;
+        try {
+            pomMap = PomDiscovery.discoverPoms(startingDirectory);
+        } catch (IOException | JDOMException e) {
+            context.logger.error("Failed to discover POM files: " + e.getMessage());
+            return 1;
+        }
+
+        if (pomMap.isEmpty()) {
+            context.logger.warn("No POM files found in " + startingDirectory);
+            return 0;
+        }
+
+        context.logger.info("Found " + pomMap.size() + " POM file(s)");
+
+        // Perform the upgrade logic
+        int result = doUpgrade(context, targetModel, pomMap);
+
+        // Save modifications if this is an apply goal
+        if (shouldSaveModifications() && result == 0) {
+            saveModifications(context, pomMap);
+        }
+
+        return result;
+    }
+
+    /**
+     * Performs the upgrade logic. Subclasses can override for specific behavior.
+     */
+    protected int doUpgrade(UpgradeContext context, String targetModel, Map<Path, Document> pomMap) {
+        UpgradeOptions options = ((UpgradeInvokerRequest) context.invokerRequest).options();
+
+        // Handle --all option (overrides individual options)
+        boolean useAll = options.all().orElse(false);
+        boolean useInference = useAll || options.infer().orElse(false);
+        boolean useFixModel = useAll || options.fixModel().orElse(false);
+        boolean usePlugins = useAll || options.plugins().orElse(false);
+
+        // Apply default behavior: if no specific options are provided, enable --fix-model and --plugins
+        if (!useAll
+                && !options.infer().isPresent()
+                && !options.fixModel().isPresent()
+                && !options.plugins().isPresent()
+                && !options.model().isPresent()) {
+            useFixModel = true;
+            usePlugins = true;
+        }
+
+        // Determine if we should perform model upgrades
+        boolean performModelUpgrade = !"4.0.0".equals(targetModel);
+
+        String action = shouldSaveModifications() ? "Upgrading" : "Checking";
+        if (performModelUpgrade) {
+            context.logger.info(action + " POM model to: " + targetModel);
+        } else {
+            context.logger.info(action + " POMs for Maven 4 compatibility (keeping current model versions)");
+        }
+
+        if (useAll) {
+            context.logger.info(
+                    "Using --all option (model upgrade + inference + compatibility fixes + plugin upgrades)");
+        } else if ((useFixModel && !options.fixModel().isPresent())
+                || (usePlugins && !options.plugins().isPresent())) {
+            context.logger.info("Applying default behavior: Maven 4 compatibility fixes and plugin upgrades");
+        }
+        context.logger.info("");
+
+        // Step 1: Analyze each POM and perform upgrade (only if model upgrade is requested)
+        int upgradeableCount = 0;
+        int alreadyTargetCount = 0;
+        int incompatibleCount = 0;
+
+        if (performModelUpgrade) {
+            for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
+                Path pomPath = entry.getKey();
+                Document pomDocument = entry.getValue();
+
+                String currentVersion = detectModelVersion(context, pomDocument);
+                context.logger.info("  " + pomPath + " (current: " + currentVersion + ")");
+
+                if (currentVersion.equals(targetModel)) {
+                    context.logger.info("    ✓ Already at target version " + targetModel);
+                    alreadyTargetCount++;
+                } else if (canUpgrade(currentVersion, targetModel)) {
+                    context.logger.info("    → " + action + " from " + currentVersion + " to " + targetModel);
+                    upgradeableCount++;
+
+                    // Perform the actual upgrade
+                    if ("4.0.0".equals(currentVersion) && "4.1.0".equals(targetModel)) {
+                        upgradeFrom400To410(context, pomPath, pomDocument);
+                    }
+                } else {
+                    context.logger.warn("    ✗ Cannot upgrade from " + currentVersion + " to " + targetModel);
+                    incompatibleCount++;
+                }
+            }
+        } else {
+            // When no model upgrade is requested, skip model version analysis entirely
+            // Just count the POMs for summary purposes
+            alreadyTargetCount = pomMap.size();
+        }
+
+        // Step 2: Apply inference if enabled (only for models >= 4.1.0)
+        if (useInference) {
+            applyInferenceToEligiblePoms(context, pomMap);
+        }
+
+        // Step 3: Apply Maven 4 compatibility fixes if enabled
+        if (useFixModel) {
+            applyMaven4CompatibilityFixes(context, pomMap);
+        }
+
+        // Step 4: Apply plugin upgrades if enabled
+        if (usePlugins) {
+            applyPluginUpgrades(context, pomMap);
+        }
+
+        // Summary
+        context.logger.info("");
+        context.logger.info("Summary:");
+        if (performModelUpgrade) {
+            context.logger.info("  " + alreadyTargetCount + " POM(s) already at target version " + targetModel);
+            context.logger.info("  " + upgradeableCount + " POM(s) "
+                    + (shouldSaveModifications() ? "upgraded" : "can be upgraded") + " to " + targetModel);
+            context.logger.info("  " + incompatibleCount + " POM(s) cannot be upgraded to " + targetModel);
+
+            if (!shouldSaveModifications() && upgradeableCount > 0) {
+                context.logger.info("");
+                context.logger.info("To apply the upgrade, run:");
+                context.logger.info("  mvnup apply --model=" + targetModel);
+            }
+        } else {
+            // Suggest apply command with the same options that were used for check
+            if (!shouldSaveModifications() && (useFixModel || usePlugins)) {
+                context.logger.info("");
+                context.logger.info("To apply the changes, run:");
+                StringBuilder applyCommand = new StringBuilder("  mvnup apply");
+
+                // Include explicit options that were specified
+                if (options.fixModel().isPresent()) {
+                    applyCommand.append(" --fix-model");
+                }
+                if (options.plugins().isPresent()) {
+                    applyCommand.append(" --plugins");
+                }
+                if (options.infer().isPresent()) {
+                    applyCommand.append(" --infer");
+                }
+
+                // If no explicit options were specified, this was default behavior
+                if (!options.fixModel().isPresent()
+                        && !options.plugins().isPresent()
+                        && !options.infer().isPresent()
+                        && !options.model().isPresent()) {
+                    // Default behavior - no options needed, just "mvnup apply"
+                }
+
+                context.logger.info(applyCommand.toString());
+            }
+        }
+
+        return 0;
+    }
+
+    /**
+     * Applies inference to all POMs that are eligible (model version >= 4.1.0).
+     * This is done as a second step after any upgrades have been performed.
+     */
+    protected void applyInferenceToEligiblePoms(UpgradeContext context, Map<Path, Document> pomMap) {
+        context.logger.info("");
+        context.logger.info("Applying inference to eligible POMs (model >= 4.1.0)...");
+
+        int inferenceAppliedCount = 0;
+        int ineligibleCount = 0;
+
+        for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
+            Path pomPath = entry.getKey();
+            Document pomDocument = entry.getValue();
+
+            String currentVersion = detectModelVersion(context, pomDocument);
+
+            if (isModelVersionEligibleForInference(currentVersion)) {
+                context.logger.info("  " + pomPath + " (applying inference to " + currentVersion + ")");
+                try {
+                    applyInference(context, pomDocument, pomPath, pomMap);
+                    inferenceAppliedCount++;
+                    context.logger.info("    ✓ Inference applied successfully");
+                } catch (Exception e) {
+                    context.logger.error("    ✗ Inference failed: " + e.getMessage());
+                }
+            } else {
+                context.logger.info(
+                        "  " + pomPath + " (skipping - model " + currentVersion + " not eligible for inference)");
+                ineligibleCount++;
+            }
+        }
+
+        context.logger.info("");
+        context.logger.info("Inference Summary:");
+        context.logger.info("  " + inferenceAppliedCount + " POM(s) had inference applied");
+        context.logger.info("  " + ineligibleCount + " POM(s) were not eligible for inference");
+    }
+
+    /**
+     * Applies Maven 4 compatibility fixes to all POMs.
+     * This fixes issues that prevent POMs from being processed by Maven 4.
+     */
+    protected void applyMaven4CompatibilityFixes(UpgradeContext context, Map<Path, Document> pomMap) {
+        context.logger.info("");
+        context.logger.info("Applying Maven 4 compatibility fixes...");
+
+        int fixedCount = 0;
+        int noFixesNeededCount = 0;
+
+        for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
+            Path pomPath = entry.getKey();
+            Document pomDocument = entry.getValue();
+
+            context.logger.info("  " + pomPath + " (checking for Maven 4 compatibility issues)");
+
+            boolean hasIssues = false;
+            try {
+                // Fix unsupported combine.children attributes
+                if (fixUnsupportedCombineChildrenAttributes(context, pomDocument)) {
+                    hasIssues = true;
+                }
+
+                // Fix unsupported combine.self attributes
+                if (fixUnsupportedCombineSelfAttributes(context, pomDocument)) {
+                    hasIssues = true;
+                }
+
+                // Fix duplicate dependencies
+                if (fixDuplicateDependencies(context, pomDocument)) {
+                    hasIssues = true;
+                }
+
+                // Fix unsupported expressions in repository URLs
+                if (fixUnsupportedRepositoryExpressions(context, pomDocument)) {
+                    hasIssues = true;
+                }
+
+                if (hasIssues) {
+                    fixedCount++;
+                    context.logger.info("    ✓ Maven 4 compatibility issues fixed");
+                } else {
+                    noFixesNeededCount++;
+                    context.logger.info("    ✓ No Maven 4 compatibility issues found");
+                }
+            } catch (Exception e) {
+                context.logger.error("    ✗ Failed to fix Maven 4 compatibility issues: " + e.getMessage());
+            }
+        }
+
+        context.logger.info("");
+        context.logger.info("Maven 4 Compatibility Fixes Summary:");
+        context.logger.info("  " + fixedCount + " POM(s) had compatibility issues fixed");
+        context.logger.info("  " + noFixesNeededCount + " POM(s) had no compatibility issues");
+    }
+
+    /**
+     * Applies plugin upgrades to all POMs.
+     * This upgrades plugins known to fail with Maven 4 to their minimum compatible versions.
+     */
+    protected void applyPluginUpgrades(UpgradeContext context, Map<Path, Document> pomMap) {
+        context.logger.info("");
+        context.logger.info("Applying plugin upgrades for Maven 4 compatibility...");
+
+        int upgradedCount = 0;
+        int noUpgradesNeededCount = 0;
+
+        for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
+            Path pomPath = entry.getKey();
+            Document pomDocument = entry.getValue();
+
+            context.logger.info("  " + pomPath + " (checking for plugin upgrades)");
+
+            boolean hasUpgrades = false;
+            try {
+                // Upgrade plugins in build/plugins and build/pluginManagement/plugins
+                if (upgradePluginsInPom(context, pomDocument)) {
+                    hasUpgrades = true;
+                }
+
+                if (hasUpgrades) {
+                    upgradedCount++;
+                    context.logger.info("    ✓ Plugin upgrades applied");
+                } else {
+                    noUpgradesNeededCount++;
+                    context.logger.info("    ✓ No plugin upgrades needed");
+                }
+            } catch (Exception e) {
+                context.logger.error("    ✗ Failed to upgrade plugins: " + e.getMessage());
+            }
+        }
+
+        context.logger.info("");
+        context.logger.info("Plugin Upgrades Summary:");
+        context.logger.info("  " + upgradedCount + " POM(s) had plugins upgraded");
+        context.logger.info("  " + noUpgradesNeededCount + " POM(s) had no plugin upgrades needed");
+    }
+
+    /**
+     * Determines if a model version is eligible for inference.
+     * - 4.1.0+ models: Full inference support
+     * - 4.0.0 models: Limited inference support (parent groupId/version inheritance only)
+     */
+    protected boolean isModelVersionEligibleForInference(String modelVersion) {
+        // 4.1.0+ models have full inference support
+        if ("4.1.0".equals(modelVersion)) {
+            return true;
+        }
+        // Future versions: || "4.2.0".equals(modelVersion)
+
+        // 4.0.0 models (Maven 3.x POMs) have limited inference support when explicitly requested
+        if ("4.0.0".equals(modelVersion)) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Determines whether modifications should be saved to disk.
+     * Apply goals return true, Check goals return false.
+     */
+    protected abstract boolean shouldSaveModifications();
+
+    /**
+     * Saves the modified documents to disk.
+     */
+    protected void saveModifications(UpgradeContext context, Map<Path, Document> pomMap) {
+        context.logger.info("");
+        context.logger.info("Saving modified POMs...");
+
+        for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
+            Path pomPath = entry.getKey();
+            Document document = entry.getValue();
+            try {
+                String content = Files.readString(entry.getKey(), StandardCharsets.UTF_8);
+                int startIndex = content.indexOf("<" + document.getRootElement().getName());
+                String head = startIndex >= 0 ? content.substring(0, startIndex) : "";
+                String lastTag = document.getRootElement().getName() + ">";
+                int endIndex = content.lastIndexOf(lastTag);
+                String tail = endIndex >= 0 ? content.substring(endIndex + lastTag.length()) : "";
+                Format format = Format.getRawFormat();
+                format.setLineSeparator(System.lineSeparator());
+                XMLOutputter out = new XMLOutputter(format);
+                ByteArrayOutputStream output = new ByteArrayOutputStream();
+                try (OutputStream outputStream = output) {
+                    outputStream.write(head.getBytes(StandardCharsets.UTF_8));
+                    out.output(document.getRootElement(), outputStream);
+                    outputStream.write(tail.getBytes(StandardCharsets.UTF_8));
+                }
+                String newBody = output.toString(StandardCharsets.UTF_8);
+                Files.writeString(pomPath, newBody, StandardCharsets.UTF_8);
+            } catch (Exception e) {
+                context.logger.error("Failed to save " + pomPath + ": " + e.getMessage());
+            }
+        }
+    }
+
+    /**
+     * Detects the current model version from a POM document.
+     * The explicit modelVersion element takes precedence over namespace URI.
+     */
+    protected String detectModelVersion(UpgradeContext context, Document pomDocument) {
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+
+        String explicitVersion = null;
+        String namespaceVersion = null;
+
+        // Check explicit modelVersion element first (takes precedence)
+        Element modelVersionElement = root.getChild("modelVersion", namespace);
+        if (modelVersionElement != null) {
+            explicitVersion = modelVersionElement.getTextTrim();
+        }
+
+        // Check namespace URI for 4.1.0+ models
+        if (namespace != null && namespace.getURI() != null) {
+            String namespaceUri = namespace.getURI();
+            if (namespaceUri.contains("4.1.0")) {
+                namespaceVersion = "4.1.0";
+            }
+            // Future versions can be detected here
+            // if (namespaceUri.contains("4.2.0")) {
+            //     namespaceVersion = "4.2.0";
+            // }
+        }
+
+        // Explicit version takes precedence
+        if (explicitVersion != null && !explicitVersion.isEmpty()) {
+            // Check for mismatch between explicit version and namespace
+            if (namespaceVersion != null && !explicitVersion.equals(namespaceVersion)) {
+                context.logger.warn("Model version mismatch in POM - explicit: " + explicitVersion
+                        + ", namespace suggests: " + namespaceVersion + ". Using explicit version.");
+            }
+            return explicitVersion;
+        }
+
+        // Fall back to namespace-inferred version
+        if (namespaceVersion != null) {
+            return namespaceVersion;
+        }
+
+        // Default to 4.0.0 with warning
+        context.logger.warn("No model version found in POM, falling back to 4.0.0");
+        return "4.0.0";
+    }
+
+    /**
+     * Determines if an upgrade from one model version to another is supported.
+     */
+    protected boolean canUpgrade(String currentVersion, String targetVersion) {
+        // Define supported upgrade paths
+        if ("4.0.0".equals(currentVersion) && "4.1.0".equals(targetVersion)) {
+            return true;
+        }
+        // Future upgrade paths can be added here:
+        // if ("4.1.0".equals(currentVersion) && "4.2.0".equals(targetVersion)) {
+        //     return true;
+        // }
+
+        return false;
+    }
+
+    /**
+     * Validates if a model version is supported.
+     */
+    protected boolean isValidModelVersion(String version) {
+        return "4.0.0".equals(version) || "4.1.0".equals(version);
+        // Future versions: || "4.2.0".equals(version)
+    }
+
+    /**
+     * Upgrades a POM from model version 4.0.0 to 4.1.0.
+     * Note: Inference is now handled separately in a second step.
+     */
+    protected void upgradeFrom400To410(UpgradeContext context, Path pomPath, Document pomDocument) {
+        String action = shouldSaveModifications() ? "Upgrading" : "Analyzing";
+        context.logger.info("      " + action + " " + pomPath.getFileName() + " from 4.0.0 to 4.1.0:");
+
+        try {
+            // Perform the 4.0.0 → 4.1.0 upgrade
+            performModelUpgrade(context, pomDocument);
+
+            String result = shouldSaveModifications() ? "completed successfully" : "completed";
+            context.logger.info("        ✓ " + action + " " + result);
+
+        } catch (Exception e) {
+            String result = shouldSaveModifications() ? "failed" : "failed";
+            context.logger.error("        ✗ " + action + " " + result + ": " + e.getMessage());
+        }
+    }
+
+    /**
+     * Performs the core 4.0.0 → 4.1.0 model upgrade.
+     * This includes namespace updates and module conversion.
+     */
+    protected void performModelUpgrade(UpgradeContext context, Document pomDocument) {
+        context.logger.info("        Performing 4.0.0 → 4.1.0 model upgrade...");
+
+        // Update namespace and schema location to 4.1.0
+        upgradeNamespaceAndSchemaLocation(context, pomDocument);
+
+        // Convert modules to subprojects
+        convertModulesToSubprojects(context, pomDocument);
+
+        // Update modelVersion to 4.1.0 (may be removed later during inference step)
+        updateModelVersion(context, pomDocument);
+    }
+
+    /**
+     * Applies inference optimizations based on the model version.
+     * - 4.1.0+ models: Full inference support
+     * - 4.0.0 models: Limited inference support (parent groupId/version inheritance only)
+     */
+    protected void applyInference(
+            UpgradeContext context, Document pomDocument, Path pomPath, Map<Path, Document> pomMap) {
+        String modelVersion = detectModelVersion(context, pomDocument);
+
+        if ("4.0.0".equals(modelVersion)) {
+            applyLimitedInferenceFor400(context, pomDocument, pomPath, pomMap);
+        } else {
+            applyFullInferenceFor410Plus(context, pomDocument, pomPath, pomMap);
+        }
+    }
+
+    /**
+     * Applies limited inference optimizations to a 4.0.0 model (Maven 3.x POM).
+     * Maven 4 supports limited inference for parent groupId and version inheritance when loading 4.0.0 models.
+     */
+    protected void applyLimitedInferenceFor400(
+            UpgradeContext context, Document pomDocument, Path pomPath, Map<Path, Document> pomMap) {
+        context.logger.info("        Applying limited inference optimizations for 4.0.0 models...");
+
+        // Get all GAVs for inference from the entire multi-module project
+        Set<GAV> allGAVs = computeAllGAVs(context, pomMap);
+
+        // Apply limited parent element trimming (only groupId/version inheritance, no artifactId inference)
+        trimParentElementLimited400(context, pomDocument, pomPath, allGAVs, pomMap);
+    }
+
+    /**
+     * Applies full inference optimizations to a 4.1.0+ model.
+     * This includes all Maven 4.1.0+ inference capabilities.
+     */
+    protected void applyFullInferenceFor410Plus(
+            UpgradeContext context, Document pomDocument, Path pomPath, Map<Path, Document> pomMap) {
+        context.logger.info("        Applying full inference optimizations for 4.1.0+ models...");
+
+        // Get all GAVs for inference from the entire multi-module project
+        Set<GAV> allGAVs = computeAllGAVs(context, pomMap);
+
+        // Determine if this is the root project
+        boolean isRootProject = isRootProject(context, pomPath);
+
+        // Remove modelVersion (inference removes redundant information)
+        removeModelVersion(context, pomDocument);
+
+        // Add root attribute to root project only
+        if (isRootProject) {
+            addRootAttribute(context, pomDocument);
+        }
+
+        // Trim parent element (now with full pomMap knowledge for artifactId inference)
+        trimParentElement(context, pomDocument, pomPath, allGAVs, pomMap);
+
+        // Remove managed dependencies pointing to project artifacts
+        removeManagedDependencies(context, pomDocument, allGAVs);
+
+        // Remove redundant dependency information that can be inferred
+        removeDependencyInferenceRedundancy(context, pomDocument, pomPath, pomMap);
+
+        // Remove redundant modules/subprojects list
+        removeRedundantModulesList(context, pomDocument, pomPath);
+    }
+
+    /**
+     * Updates namespace and schema location to 4.1.0 for all elements.
+     */
+    protected void upgradeNamespaceAndSchemaLocation(UpgradeContext context, Document pomDocument) {
+        Element root = pomDocument.getRootElement();
+
+        // Update namespace to 4.1.0 for all elements recursively
+        Namespace newNamespace = Namespace.getNamespace("http://maven.apache.org/POM/4.1.0");
+        updateNamespaceRecursively(root, newNamespace);
+        context.logger.info("        • Updated namespace to Maven 4.1.0 for all elements");
+
+        // Update xsi:schemaLocation if present
+        Namespace xsiNamespace = Namespace.getNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance");
+        org.jdom2.Attribute schemaLocationAttr = root.getAttribute("schemaLocation", xsiNamespace);
+        if (schemaLocationAttr != null) {
+            String newSchemaLocation =
+                    "http://maven.apache.org/POM/4.1.0 " + "http://maven.apache.org/xsd/maven-4.1.0.xsd";
+            schemaLocationAttr.setValue(newSchemaLocation);
+            context.logger.info("        • Updated xsi:schemaLocation to Maven 4.1.0");
+        }
+    }
+
+    /**
+     * Recursively updates the namespace for an element and all its children.
+     */
+    protected void updateNamespaceRecursively(Element element, Namespace newNamespace) {
+        // Update the namespace for this element
+        element.setNamespace(newNamespace);
+
+        // Recursively update all child elements
+        List<Element> children = element.getChildren();
+        for (Element child : children) {
+            updateNamespaceRecursively(child, newNamespace);
+        }
+    }
+
+    /**
+     * Updates modelVersion to 4.1.0.
+     */
+    protected void updateModelVersion(UpgradeContext context, Document pomDocument) {
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+
+        Element modelVersionElement = root.getChild("modelVersion", namespace);
+        if (modelVersionElement != null) {
+            modelVersionElement.setText("4.1.0");
+            context.logger.info("        • Updated modelVersion to 4.1.0");
+        }
+    }
+
+    /**
+     * Removes modelVersion element (for inference).
+     */
+    protected void removeModelVersion(UpgradeContext context, Document pomDocument) {
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+
+        Element modelVersionElement = root.getChild("modelVersion", namespace);
+        if (modelVersionElement != null) {
+            removeElementWithFormatting(modelVersionElement);
+            context.logger.info("        • Removed modelVersion (inference enabled)");
+        }
+    }
+
+    /**
+     * Removes an element while preserving formatting by also removing
+     * leading whitespace and line separators.
+     */
+    protected void removeElementWithFormatting(Element element) {
+        Element parent = element.getParentElement();
+        if (parent != null) {
+            int index = parent.indexOf(element);
+
+            // Remove the element
+            parent.removeContent(element);
+
+            // Try to remove preceding whitespace/newline
+            if (index > 0) {
+                org.jdom2.Content prevContent = parent.getContent(index - 1);
+                if (prevContent instanceof org.jdom2.Text) {
+                    org.jdom2.Text textContent = (org.jdom2.Text) prevContent;
+                    String text = textContent.getText();
+                    // If it's just whitespace and newlines, remove it
+                    if (text.trim().isEmpty() && text.contains("\n")) {
+                        parent.removeContent(prevContent);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Converts modules to subprojects and module to subproject.
+     */
+    protected void convertModulesToSubprojects(UpgradeContext context, Document pomDocument) {
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+
+        // Convert modules to subprojects in main model
+        convertModulesElement(root, namespace);
+
+        // Convert modules to subprojects in profiles
+        Element profilesElement = root.getChild("profiles", namespace);
+        if (profilesElement != null) {
+            List<Element> profileElements = profilesElement.getChildren("profile", namespace);
+            for (Element profileElement : profileElements) {
+                convertModulesElement(profileElement, namespace);
+            }
+        }
+
+        context.logger.info("        • Converted modules to subprojects");
+    }
+
+    /**
+     * Converts a modules element to subprojects while preserving formatting.
+     */
+    protected void convertModulesElement(Element parent, Namespace namespace) {
+        Element modulesElement = parent.getChild("modules", namespace);
+        if (modulesElement != null) {
+            // Simply rename the element and its children to preserve formatting
+            modulesElement.setName("subprojects");
+
+            // Convert each module to subproject
+            List<Element> moduleElements = modulesElement.getChildren("module", namespace);
+            for (Element moduleElement : moduleElements) {
+                moduleElement.setName("subproject");
+            }
+        }
+    }
+
+    /**
+     * Computes all GAVs from all POMs in the multi-module project for inference.
+     * This includes resolving parent inheritance and relative path parents.
+     */
+    protected Set<GAV> computeAllGAVs(UpgradeContext context, Map<Path, Document> pomMap) {
+        Set<GAV> gavs = new HashSet<>();
+
+        context.logger.info("        Computing GAVs for inference from " + pomMap.size() + " POM(s)...");
+
+        // Extract GAV from all POMs in the project
+        for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
+            Path pomPath = entry.getKey();
+            Document pomDocument = entry.getValue();
+
+            GAV gav = extractGAVWithParentResolution(pomDocument, context);
+            if (gav != null) {
+                gavs.add(gav);
+                context.logger.debug("        Found GAV: " + gav + " from " + pomPath);
+            }
+        }
+
+        context.logger.info("        Computed " + gavs.size() + " GAV(s) for inference");
+        return gavs;
+    }
+
+    /**
+     * Determines if the given POM is the root project.
+     */
+    protected boolean isRootProject(UpgradeContext context, Path pomPath) {
+        // Root project is typically the one without a parent or at the top level
+        // For now, simple heuristic: check if it's in the starting directory
+        Path startDir = context.invokerRequest.cwd();
+        return pomPath.getParent().equals(startDir);
+    }
+
+    /**
+     * Adds root=true attribute to the root project.
+     */
+    protected void addRootAttribute(UpgradeContext context, Document pomDocument) {
+        Element root = pomDocument.getRootElement();
+        root.setAttribute("root", "true");
+        context.logger.info("        • Added root=true attribute");
+    }
+
+    /**
+     * Applies limited inference for 4.0.0 models (Maven 3.x POMs).
+     * Maven 4 supports limited inference when loading 4.0.0 models where child groupId/version can be inferred from parent:
+     * - If child.groupId matches parent.groupId, we can remove child.groupId (it will be inferred from parent)
+     * - If child.version matches parent.version, we can remove child.version (it will be inferred from parent)
+     * - Parent elements are always kept in 4.0.0 models (no relativePath inference)
+     */
+    protected void trimParentElementLimited400(
+            UpgradeContext context, Document pomDocument, Path pomPath, Set<GAV> allGAVs, Map<Path, Document> pomMap) {
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+        Element parentElement = root.getChild("parent", namespace);
+
+        if (parentElement == null) {
+            return;
+        }
+
+        // Get parent GAV for comparison
+        String parentGroupId = getChildText(parentElement, "groupId", namespace);
+        String parentVersion = getChildText(parentElement, "version", namespace);
+
+        // Check if we can remove child groupId
+        // Maven 4 logic for 4.0.0 models: if child.groupId == null, use parent.groupId
+        // Reverse: if child.groupId == parent.groupId, we can remove child.groupId (it will be inferred)
+        Element childGroupIdElement = root.getChild("groupId", namespace);
+        if (childGroupIdElement != null && parentGroupId != null) {
+            String childGroupId = childGroupIdElement.getTextTrim();
+            if (childGroupId.equals(parentGroupId)) {
+                removeElementWithFormatting(childGroupIdElement);
+                context.logger.info("        • Removed child groupId (will be inferred from parent)");
+            }
+        }
+
+        // Check if we can remove child version
+        // Maven 4 logic for 4.0.0 models: if child.version == null, use parent.version
+        // Reverse: if child.version == parent.version, we can remove child.version (it will be inferred)
+        Element childVersionElement = root.getChild("version", namespace);
+        if (childVersionElement != null && parentVersion != null) {
+            String childVersion = childVersionElement.getTextTrim();
+            if (childVersion.equals(parentVersion)) {
+                removeElementWithFormatting(childVersionElement);
+                context.logger.info("        • Removed child version (will be inferred from parent)");
+            }
+        }
+
+        // Note: In 4.0.0 models, parent elements are always kept (no relativePath inference)
+        // and child artifactId is always required (cannot be inferred from parent)
+    }
+
+    /**
+     * Trims parent element by removing redundant information that can be inferred (Maven 4.1.0+).
+     * This reverses Maven's parent inference logic:
+     * - If child has no explicit groupId, it inherits from parent -> we can remove parent groupId
+     * - If child has no explicit version, it inherits from parent -> we can remove parent version
+     * - Parent GAV can be inferred from relativePath if missing
+     */
+    protected void trimParentElement(
+            UpgradeContext context, Document pomDocument, Path pomPath, Set<GAV> allGAVs, Map<Path, Document> pomMap) {
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+        Element parentElement = root.getChild("parent", namespace);
+
+        if (parentElement == null) {
+            return;
+        }
+
+        // Check if we can remove parent groupId
+        // Maven logic: if child.groupId == null, use parent.groupId
+        // Reverse: if child.groupId == null, we can remove parent.groupId (it will be inferred)
+        Element childGroupIdElement = root.getChild("groupId", namespace);
+        if (childGroupIdElement == null) {
+            Element parentGroupIdElement = parentElement.getChild("groupId", namespace);
+            if (parentGroupIdElement != null) {
+                removeElementWithFormatting(parentGroupIdElement);
+                context.logger.info("        • Removed parent groupId (will be inferred from relativePath)");
+            }
+        }
+
+        // Check if we can remove parent version
+        // Maven logic: if child.version == null, use parent.version
+        // Reverse: if child.version == null, we can remove parent.version (it will be inferred)
+        Element childVersionElement = root.getChild("version", namespace);
+        if (childVersionElement == null) {
+            Element parentVersionElement = parentElement.getChild("version", namespace);
+            if (parentVersionElement != null) {
+                removeElementWithFormatting(parentVersionElement);
+                context.logger.info("        • Removed parent version (will be inferred from relativePath)");
+            }
+        }
+
+        // Check if we can remove parent artifactId
+        // Maven logic: parent.artifactId is always required for resolution, but if relativePath
+        // points to a POM in the multi-module project and we can resolve the GAV, the artifactId can be inferred
+        Element parentArtifactIdElement = parentElement.getChild("artifactId", namespace);
+        if (parentArtifactIdElement != null && canInferParentArtifactId(context, parentElement, pomPath, pomMap)) {
+            removeElementWithFormatting(parentArtifactIdElement);
+            context.logger.info("        • Removed parent artifactId (will be inferred from relativePath)");
+        }
+
+        // Note: The artifactId inference logic (canInferParentArtifactId) specifically checks
+        // if the parent POM is available in our pomMap. This distinguishes between:
+        // - Internal parents (in pomMap): artifactId can be inferred from relativePath
+        // - External parents (not in pomMap): artifactId must be kept explicitly
+    }
+
+    /**
+     * Determines if parent artifactId can be inferred from relativePath.
+     * This is possible when:
+     * 1. Parent has a relativePath (explicit or default "../pom.xml")
+     * 2. The relativePath points to a POM that exists in our pomMap
+     * 3. We can resolve the parent POM and extract its artifactId
+     */
+    protected boolean canInferParentArtifactId(
+            UpgradeContext context, Element parentElement, Path currentPomPath, Map<Path, Document> pomMap) {
+        Namespace namespace = parentElement.getNamespace();
+
+        // Get relativePath (default is "../pom.xml" if not specified)
+        String relativePath = getChildText(parentElement, "relativePath", namespace);
+        if (relativePath == null || relativePath.trim().isEmpty()) {
+            relativePath = "../pom.xml"; // Maven default
+        }
+
+        // Resolve the parent POM path
+        Path parentPomPath = resolveParentPomPath(currentPomPath, relativePath);
+        if (parentPomPath == null) {
+            return false;
+        }
+
+        // Check if the parent POM exists in our pomMap
+        Document parentPomDocument = pomMap.get(parentPomPath);
+        if (parentPomDocument == null) {
+            // Try to find it by normalizing the path
+            for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
+                try {
+                    if (java.nio.file.Files.isSameFile(entry.getKey(), parentPomPath)) {
+                        parentPomDocument = entry.getValue();
+                        break;
+                    }
+                } catch (Exception e) {
+                    // Ignore and continue
+                }
+            }
+        }
+
+        if (parentPomDocument == null) {
+            return false;
+        }
+
+        // Extract artifactId from the parent POM
+        Element parentRoot = parentPomDocument.getRootElement();
+        String parentArtifactId = getChildText(parentRoot, "artifactId", parentRoot.getNamespace());
+
+        // We can infer if the parent POM has an artifactId
+        boolean canInfer = parentArtifactId != null && !parentArtifactId.trim().isEmpty();
+
+        if (canInfer && context != null) {
+            context.logger.debug(
+                    "        Can infer parent artifactId '" + parentArtifactId + "' from " + parentPomPath);
+        }
+
+        return canInfer;
+    }
+
+    /**
+     * Resolves the parent POM path from a child POM path and relativePath.
+     */
+    protected Path resolveParentPomPath(Path childPomPath, String relativePath) {
+        try {
+            Path childDir = childPomPath.getParent();
+            if (childDir == null) {
+                return null;
+            }
+
+            // Handle null or empty relativePath (default to ../pom.xml)
+            if (relativePath == null || relativePath.trim().isEmpty()) {
+                relativePath = "../pom.xml";
+            }
+
+            Path resolvedPath = childDir.resolve(relativePath).normalize();
+
+            // If relativePath doesn't end with .xml, assume it's a directory and append pom.xml
+            if (!relativePath.endsWith(".xml")) {
+                resolvedPath = resolvedPath.resolve("pom.xml");
+            }
+
+            return resolvedPath;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /**
+     * Removes redundant dependency information that can be inferred by Maven.
+     * This reverses Maven's dependency inference logic from DefaultModelBuilder.inferDependencyVersion().
+     *
+     * Maven can infer:
+     * - Dependency version from project artifacts in the same reactor
+     * - Dependency groupId from project artifacts in the same reactor
+     *
+     * This method removes such redundant information when the dependency points to a project
+     * artifact that exists in our pomMap.
+     */
+    protected void removeDependencyInferenceRedundancy(
+            UpgradeContext context, Document pomDocument, Path pomPath, Map<Path, Document> pomMap) {
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+
+        // Process main dependencies
+        Element dependenciesElement = root.getChild("dependencies", namespace);
+        if (dependenciesElement != null) {
+            removeDependencyInferenceFromSection(
+                    context, dependenciesElement, namespace, pomPath, pomMap, "dependencies");
+        }
+
+        // Process profile dependencies
+        Element profilesElement = root.getChild("profiles", namespace);
+        if (profilesElement != null) {
+            List<Element> profileElements = profilesElement.getChildren("profile", namespace);
+            for (Element profileElement : profileElements) {
+                Element profileDependencies = profileElement.getChild("dependencies", namespace);
+                if (profileDependencies != null) {
+                    removeDependencyInferenceFromSection(
+                            context, profileDependencies, namespace, pomPath, pomMap, "profile dependencies");
+                }
+            }
+        }
+
+        // Process build plugin dependencies
+        Element buildElement = root.getChild("build", namespace);
+        if (buildElement != null) {
+            Element pluginsElement = buildElement.getChild("plugins", namespace);
+            if (pluginsElement != null) {
+                List<Element> pluginElements = pluginsElement.getChildren("plugin", namespace);
+                for (Element pluginElement : pluginElements) {
+                    Element pluginDependencies = pluginElement.getChild("dependencies", namespace);
+                    if (pluginDependencies != null) {
+                        removeDependencyInferenceFromSection(
+                                context, pluginDependencies, namespace, pomPath, pomMap, "plugin dependencies");
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Helper method to remove dependency inference redundancy from a specific dependencies section.
+     */
+    protected void removeDependencyInferenceFromSection(
+            UpgradeContext context,
+            Element dependencies,
+            Namespace namespace,
+            Path pomPath,
+            Map<Path, Document> pomMap,
+            String sectionName) {
+        List<Element> dependencyElements = dependencies.getChildren("dependency", namespace);
+
+        for (Element dependency : dependencyElements) {
+            String groupId = getChildText(dependency, "groupId", namespace);
+            String artifactId = getChildText(dependency, "artifactId", namespace);
+            String version = getChildText(dependency, "version", namespace);
+
+            if (artifactId != null) {
+                // Try to find the dependency's POM in our pomMap
+                Document dependencyPom = findDependencyPomInMap(groupId, artifactId, pomMap);
+                if (dependencyPom != null) {
+                    // We found the dependency in our project - we can infer information
+
+                    // Check if we can remove the version (Maven can infer it from the project artifact)
+                    if (version != null && canInferDependencyVersion(dependencyPom, version)) {
+                        Element versionElement = dependency.getChild("version", namespace);
+                        if (versionElement != null) {
+                            removeElementWithFormatting(versionElement);
+                            context.logger.info("        • Removed dependency version for " + groupId + ":" + artifactId
+                                    + " from " + sectionName + " (can be inferred from project)");
+                        }
+                    }
+
+                    // Check if we can remove the groupId (Maven can infer it from the project artifact)
+                    if (groupId != null && canInferDependencyGroupId(dependencyPom, groupId)) {
+                        Element groupIdElement = dependency.getChild("groupId", namespace);
+                        if (groupIdElement != null) {
+                            removeElementWithFormatting(groupIdElement);
+                            context.logger.info("        • Removed dependency groupId for " + artifactId + " from "
+                                    + sectionName + " (can be inferred from project)");
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Finds a dependency's POM document in the pomMap by groupId and artifactId.
+     */
+    protected Document findDependencyPomInMap(String groupId, String artifactId, Map<Path, Document> pomMap) {
+        for (Document pomDocument : pomMap.values()) {
+            GAV gav = extractGAVWithParentResolution(pomDocument, null);
+            if (gav != null
+                    && java.util.Objects.equals(gav.artifactId, artifactId)
+                    && (groupId == null || java.util.Objects.equals(gav.groupId, groupId))) {
+                return pomDocument;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Determines if a dependency version can be inferred from the project artifact.
+     * Maven logic: if the dependency points to a project artifact, the version can be inferred.
+     */
+    protected boolean canInferDependencyVersion(Document dependencyPom, String declaredVersion) {
+        GAV projectGav = extractGAVWithParentResolution(dependencyPom, null);
+        if (projectGav == null || projectGav.version == null) {
+            return false;
+        }
+
+        // We can infer the version if the declared version matches the project version
+        // This ensures we don't remove version information that's intentionally different
+        return java.util.Objects.equals(declaredVersion, projectGav.version);
+    }
+
+    /**
+     * Determines if a dependency groupId can be inferred from the project artifact.
+     * Maven logic: if the dependency points to a project artifact, the groupId can be inferred.
+     */
+    protected boolean canInferDependencyGroupId(Document dependencyPom, String declaredGroupId) {
+        GAV projectGav = extractGAVWithParentResolution(dependencyPom, null);
+        if (projectGav == null || projectGav.groupId == null) {
+            return false;
+        }
+
+        // We can infer the groupId if the declared groupId matches the project groupId
+        // This ensures we don't remove groupId information that's intentionally different
+        return java.util.Objects.equals(declaredGroupId, projectGav.groupId);
+    }
+
+    /**
+     * Removes managed dependencies that point to project artifacts.
+     * In Maven 4.1.0+, managed dependencies for project artifacts can be inferred automatically
+     * because the version can be determined from the project structure.
+     */
+    protected void removeManagedDependencies(UpgradeContext context, Document pomDocument, Set<GAV> allGAVs) {
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+
+        // Check dependencyManagement section
+        Element dependencyManagement = root.getChild("dependencyManagement", namespace);
+        if (dependencyManagement != null) {
+            Element dependencies = dependencyManagement.getChild("dependencies", namespace);
+            if (dependencies != null) {
+                removeManagedDependenciesFromSection(context, dependencies, namespace, allGAVs, "dependencyManagement");
+            }
+        }
+
+        // Check profiles for dependencyManagement
+        Element profilesElement = root.getChild("profiles", namespace);
+        if (profilesElement != null) {
+            List<Element> profileElements = profilesElement.getChildren("profile", namespace);
+            for (Element profileElement : profileElements) {
+                Element profileDependencyManagement = profileElement.getChild("dependencyManagement", namespace);
+                if (profileDependencyManagement != null) {
+                    Element profileDependencies = profileDependencyManagement.getChild("dependencies", namespace);
+                    if (profileDependencies != null) {
+                        removeManagedDependenciesFromSection(
+                                context, profileDependencies, namespace, allGAVs, "profile dependencyManagement");
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Helper method to remove managed dependencies from a specific dependencies section.
+     */
+    protected void removeManagedDependenciesFromSection(
+            UpgradeContext context, Element dependencies, Namespace namespace, Set<GAV> allGAVs, String sectionName) {
+        List<Element> dependencyElements = dependencies.getChildren("dependency", namespace);
+        List<Element> toRemove = new ArrayList<>();
+
+        for (Element dependency : dependencyElements) {
+            String groupId = getChildText(dependency, "groupId", namespace);
+            String artifactId = getChildText(dependency, "artifactId", namespace);
+
+            if (groupId != null && artifactId != null) {
+                // Check if this dependency matches any project artifact (ignoring version)
+                boolean isProjectArtifact = allGAVs.stream()
+                        .anyMatch(gav -> java.util.Objects.equals(gav.groupId, groupId)
+                                && java.util.Objects.equals(gav.artifactId, artifactId));
+
+                if (isProjectArtifact) {
+                    toRemove.add(dependency);
+                }
+            }
+        }
+
+        // Remove the identified dependencies
+        for (Element dependency : toRemove) {
+            removeElementWithFormatting(dependency);
+            String groupId = getChildText(dependency, "groupId", namespace);
+            String artifactId = getChildText(dependency, "artifactId", namespace);
+            context.logger.info("        • Removed managed dependency " + groupId + ":" + artifactId + " from "
+                    + sectionName + " (project artifact)");
+        }
+    }
+
+    /**
+     * Removes redundant subprojects list when it matches direct child directories.
+     * In Maven 4.1.0+, subprojects can be auto-discovered from direct child directories.
+     */
+    protected void removeRedundantModulesList(UpgradeContext context, Document pomDocument, Path pomPath) {
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+
+        // Check main subprojects
+        Element subprojectsElement = root.getChild("subprojects", namespace);
+        if (subprojectsElement != null) {
+            if (isSubprojectsListRedundant(context, subprojectsElement, namespace, pomPath)) {
+                removeElementWithFormatting(subprojectsElement);
+                context.logger.info("        • Removed redundant subprojects list (matches direct children)");
+            }
+        }
+
+        // Check profiles for subprojects
+        Element profilesElement = root.getChild("profiles", namespace);
+        if (profilesElement != null) {
+            List<Element> profileElements = profilesElement.getChildren("profile", namespace);
+            for (Element profileElement : profileElements) {
+                Element profileSubprojects = profileElement.getChild("subprojects", namespace);
+                if (profileSubprojects != null) {
+                    if (isSubprojectsListRedundant(context, profileSubprojects, namespace, pomPath)) {
+                        removeElementWithFormatting(profileSubprojects);
+                        context.logger.info(
+                                "        • Removed redundant subprojects list from profile (matches direct children)");
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Checks if a subprojects list is redundant (matches direct child directories with pom.xml).
+     */
+    protected boolean isSubprojectsListRedundant(
+            UpgradeContext context, Element subprojectsElement, Namespace namespace, Path pomPath) {
+        List<Element> subprojectElements = subprojectsElement.getChildren("subproject", namespace);
+        if (subprojectElements.isEmpty()) {
+            return true; // Empty list is redundant
+        }
+
+        // Get the directory containing this POM
+        Path parentDir = pomPath.getParent();
+        if (parentDir == null) {
+            return false;
+        }
+
+        // Get list of declared subprojects
+        Set<String> declaredSubprojects = new HashSet<>();
+        for (Element subprojectElement : subprojectElements) {
+            String subprojectPath = subprojectElement.getTextTrim();
+            if (subprojectPath != null && !subprojectPath.isEmpty()) {
+                // Normalize path (remove trailing pom.xml if present)
+                if (subprojectPath.endsWith("/pom.xml")) {
+                    subprojectPath = subprojectPath.substring(0, subprojectPath.length() - "/pom.xml".length());
+                } else if (subprojectPath.endsWith("\\pom.xml")) {
+                    subprojectPath = subprojectPath.substring(0, subprojectPath.length() - "\\pom.xml".length());
+                }
+                declaredSubprojects.add(subprojectPath);
+            }
+        }
+
+        // Get list of actual direct child directories with pom.xml
+        Set<String> actualSubprojects = new HashSet<>();
+        try {
+            if (java.nio.file.Files.exists(parentDir) && java.nio.file.Files.isDirectory(parentDir)) {
+                try (java.util.stream.Stream<Path> children = java.nio.file.Files.list(parentDir)) {
+                    children.filter(java.nio.file.Files::isDirectory)
+                            .filter(dir -> java.nio.file.Files.exists(dir.resolve("pom.xml")))
+                            .forEach(dir ->
+                                    actualSubprojects.add(dir.getFileName().toString()));
+                }
+            }
+        } catch (Exception e) {
+            // If we can't read the directory, assume not redundant
+            if (context != null) {
+                context.logger.debug(
+                        "        Could not read directory " + parentDir + " for subprojects check: " + e.getMessage());
+            }
+            return false;
+        }
+
+        // Compare declared vs actual - they should match exactly for the list to be redundant
+        return declaredSubprojects.equals(actualSubprojects);
+    }
+
+    /**
+     * Extracts GAV from a POM document with parent inheritance resolution.
+     * If groupId or version are missing, they are inherited from the parent element.
+     */
+    protected GAV extractGAVWithParentResolution(Document pomDocument, UpgradeContext context) {
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+
+        String groupId = getChildText(root, "groupId", namespace);
+        String artifactId = getChildText(root, "artifactId", namespace);
+        String version = getChildText(root, "version", namespace);
+
+        // Simple parent inheritance
+        if (groupId == null || version == null) {
+            Element parentElement = root.getChild("parent", namespace);
+            if (parentElement != null) {
+                if (groupId == null) {
+                    groupId = getChildText(parentElement, "groupId", namespace);
+                }
+                if (version == null) {
+                    version = getChildText(parentElement, "version", namespace);
+                }
+            }
+        }
+
+        if (groupId != null && artifactId != null && version != null) {
+            return new GAV(groupId, artifactId, version);
+        }
+
+        return null;
+    }
+
+    protected GAV extractParentGAVFromRelativePath(Document pomDocument, UpgradeContext context) {
+        // Simplified implementation
+        return null;
+    }
+
+    /**
+     * Helper method to get child element text.
+     */
+    protected String getChildText(Element parent, String childName, Namespace namespace) {
+        Element child = parent.getChild(childName, namespace);
+        return child != null ? child.getTextTrim() : null;
+    }
+
+    /**
+     * Upgrades plugins in a POM document.
+     * Checks both build/plugins and build/pluginManagement/plugins sections.
+     */
+    protected boolean upgradePluginsInPom(UpgradeContext context, Document pomDocument) {
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+        boolean hasUpgrades = false;
+
+        // Define the plugins that need to be upgraded for Maven 4 compatibility
+        Map<String, PluginUpgrade> pluginUpgrades = getPluginUpgrades();
+
+        // Check build/plugins
+        Element buildElement = root.getChild("build", namespace);
+        if (buildElement != null) {
+            Element pluginsElement = buildElement.getChild("plugins", namespace);
+            if (pluginsElement != null) {
+                hasUpgrades |= upgradePluginsInSection(
+                        context, pluginsElement, namespace, pluginUpgrades, pomDocument, "build/plugins");
+            }
+
+            // Check build/pluginManagement/plugins
+            Element pluginManagementElement = buildElement.getChild("pluginManagement", namespace);
+            if (pluginManagementElement != null) {
+                Element managedPluginsElement = pluginManagementElement.getChild("plugins", namespace);
+                if (managedPluginsElement != null) {
+                    hasUpgrades |= upgradePluginsInSection(
+                            context,
+                            managedPluginsElement,
+                            namespace,
+                            pluginUpgrades,
+                            pomDocument,
+                            "build/pluginManagement/plugins");
+                }
+            }
+        }
+
+        return hasUpgrades;
+    }
+
+    /**
+     * Returns the map of plugins that need to be upgraded for Maven 4 compatibility.
+     */
+    protected Map<String, PluginUpgrade> getPluginUpgrades() {
+        Map<String, PluginUpgrade> upgrades = new HashMap<>();
+        upgrades.put(
+                "org.apache.maven.plugins:maven-exec-plugin",
+                new PluginUpgrade("org.apache.maven.plugins", "maven-exec-plugin", "3.2.0"));
+        upgrades.put(
+                "org.apache.maven.plugins:maven-enforcer-plugin",
+                new PluginUpgrade("org.apache.maven.plugins", "maven-enforcer-plugin", "3.0.0"));
+        upgrades.put(
+                "org.codehaus.mojo:flatten-maven-plugin",
+                new PluginUpgrade("org.codehaus.mojo", "flatten-maven-plugin", "1.2.7"));
+        upgrades.put(
+                "org.apache.maven.plugins:maven-shade-plugin",
+                new PluginUpgrade("org.apache.maven.plugins", "maven-shade-plugin", "3.5.0"));
+        return upgrades;
+    }
+
+    /**
+     * Upgrades plugins in a specific plugins section (either build/plugins or build/pluginManagement/plugins).
+     */
+    protected boolean upgradePluginsInSection(
+            UpgradeContext context,
+            Element pluginsElement,
+            Namespace namespace,
+            Map<String, PluginUpgrade> pluginUpgrades,
+            Document pomDocument,
+            String sectionName) {
+        boolean hasUpgrades = false;
+        List<Element> pluginElements = pluginsElement.getChildren("plugin", namespace);
+
+        for (Element pluginElement : pluginElements) {
+            String groupId = getChildText(pluginElement, "groupId", namespace);
+            String artifactId = getChildText(pluginElement, "artifactId", namespace);
+
+            // Default groupId for Maven plugins
+            if (groupId == null && artifactId != null && artifactId.startsWith("maven-")) {
+                groupId = "org.apache.maven.plugins";
+            }
+
+            if (groupId != null && artifactId != null) {
+                String pluginKey = groupId + ":" + artifactId;
+                PluginUpgrade upgrade = pluginUpgrades.get(pluginKey);
+
+                if (upgrade != null) {
+                    if (upgradePluginVersion(context, pluginElement, namespace, upgrade, pomDocument, sectionName)) {
+                        hasUpgrades = true;
+                    }
+                }
+            }
+        }
+
+        return hasUpgrades;
+    }
+
+    /**
+     * Upgrades a specific plugin's version if needed.
+     */
+    protected boolean upgradePluginVersion(
+            UpgradeContext context,
+            Element pluginElement,
+            Namespace namespace,
+            PluginUpgrade upgrade,
+            Document pomDocument,
+            String sectionName) {
+        Element versionElement = pluginElement.getChild("version", namespace);
+        String currentVersion = null;
+        boolean isProperty = false;
+        String propertyName = null;
+
+        if (versionElement != null) {
+            currentVersion = versionElement.getTextTrim();
+            // Check if version is a property reference
+            if (currentVersion.startsWith("${") && currentVersion.endsWith("}")) {
+                isProperty = true;
+                propertyName = currentVersion.substring(2, currentVersion.length() - 1);
+            }
+        } else {
+            // Plugin version might be inherited from parent or pluginManagement
+            context.logger.debug("      Plugin " + upgrade.groupId + ":" + upgrade.artifactId
+                    + " has no explicit version, may inherit from parent");
+            return false;
+        }
+
+        if (currentVersion != null) {
+            if (isProperty) {
+                // Update property value if it's below minimum version
+                return upgradePropertyVersion(context, pomDocument, propertyName, upgrade, sectionName);
+            } else {
+                // Direct version comparison and upgrade
+                if (isVersionBelow(currentVersion, upgrade.minVersion)) {
+                    versionElement.setText(upgrade.minVersion);
+                    context.logger.info("      • Upgraded " + upgrade.groupId + ":" + upgrade.artifactId + " from "
+                            + currentVersion + " to " + upgrade.minVersion + " in " + sectionName);
+                    return true;
+                } else {
+                    context.logger.debug("      Plugin " + upgrade.groupId + ":" + upgrade.artifactId + " version "
+                            + currentVersion + " is already >= " + upgrade.minVersion);
+                }
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Upgrades a property value if it represents a plugin version below the minimum.
+     */
+    protected boolean upgradePropertyVersion(
+            UpgradeContext context,
+            Document pomDocument,
+            String propertyName,
+            PluginUpgrade upgrade,
+            String sectionName) {
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+        Element propertiesElement = root.getChild("properties", namespace);
+
+        if (propertiesElement != null) {
+            Element propertyElement = propertiesElement.getChild(propertyName, namespace);
+            if (propertyElement != null) {
+                String currentVersion = propertyElement.getTextTrim();
+                if (isVersionBelow(currentVersion, upgrade.minVersion)) {
+                    propertyElement.setText(upgrade.minVersion);
+                    context.logger.info("      • Upgraded property " + propertyName + " (for " + upgrade.groupId + ":"
+                            + upgrade.artifactId + ") from " + currentVersion + " to " + upgrade.minVersion + " in "
+                            + sectionName);
+                    return true;
+                } else {
+                    context.logger.debug("      Property " + propertyName + " version " + currentVersion
+                            + " is already >= " + upgrade.minVersion);
+                }
+            } else {
+                context.logger.warn("      Property " + propertyName + " not found in POM properties");
+            }
+        } else {
+            context.logger.warn("      No properties section found in POM for property " + propertyName);
+        }
+
+        return false;
+    }
+
+    /**
+     * Simple version comparison to check if current version is below minimum version.
+     * This is a basic implementation that works for most Maven plugin versions.
+     */
+    protected boolean isVersionBelow(String currentVersion, String minVersion) {
+        if (currentVersion == null || minVersion == null) {
+            return false;
+        }
+
+        // Remove any qualifiers like -SNAPSHOT, -alpha, etc. for comparison
+        String cleanCurrent = currentVersion.split("-")[0];
+        String cleanMin = minVersion.split("-")[0];
+
+        try {
+            String[] currentParts = cleanCurrent.split("\\.");
+            String[] minParts = cleanMin.split("\\.");
+
+            int maxLength = Math.max(currentParts.length, minParts.length);
+
+            for (int i = 0; i < maxLength; i++) {
+                int currentPart = i < currentParts.length ? Integer.parseInt(currentParts[i]) : 0;
+                int minPart = i < minParts.length ? Integer.parseInt(minParts[i]) : 0;
+
+                if (currentPart < minPart) {
+                    return true;
+                } else if (currentPart > minPart) {
+                    return false;
+                }
+            }
+
+            return false; // Versions are equal
+        } catch (NumberFormatException e) {
+            // Fallback to string comparison if parsing fails
+            return currentVersion.compareTo(minVersion) < 0;
+        }
+    }
+
+    /**
+     * Simple holder for plugin upgrade information.
+     */
+    protected static class PluginUpgrade {
+        final String groupId;
+        final String artifactId;
+        final String minVersion;
+
+        PluginUpgrade(String groupId, String artifactId, String minVersion) {
+            this.groupId = groupId;
+            this.artifactId = artifactId;
+            this.minVersion = minVersion;
+        }
+    }
+
+    /**
+     * Simple GAV (GroupId, ArtifactId, Version) holder for tracking project artifacts.
+     */
+    protected static class GAV {
+        final String groupId;
+        final String artifactId;
+        final String version;
+
+        GAV(String groupId, String artifactId, String version) {
+            this.groupId = groupId;
+            this.artifactId = artifactId;
+            this.version = version;
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (this == obj) {
+                return true;
+            }
+            if (obj == null || getClass() != obj.getClass()) {
+                return false;
+            }
+            GAV gav = (GAV) obj;
+            return java.util.Objects.equals(groupId, gav.groupId)
+                    && java.util.Objects.equals(artifactId, gav.artifactId)
+                    && java.util.Objects.equals(version, gav.version);
+        }
+
+        @Override
+        public int hashCode() {
+            return java.util.Objects.hash(groupId, artifactId, version);
+        }
+
+        @Override
+        public String toString() {
+            return groupId + ":" + artifactId + ":" + (version != null ? version : "null");
+        }
+
+        /**
+         * Checks if this GAV matches another GAV ignoring version.
+         */
+        public boolean matchesIgnoringVersion(GAV other) {
+            return java.util.Objects.equals(groupId, other.groupId)
+                    && java.util.Objects.equals(artifactId, other.artifactId);
+        }
+    }
+
+    /**
+     * Fixes unsupported combine.children attribute values.
+     * Maven 4 only supports 'append' and 'merge' (default), not 'override'.
+     */
+    protected boolean fixUnsupportedCombineChildrenAttributes(UpgradeContext context, Document pomDocument) {
+        boolean fixed = false;
+        Element root = pomDocument.getRootElement();
+
+        // Find all elements with combine.children="override" and change to "merge"
+        List<Element> elementsWithCombineChildren = findElementsWithAttribute(root, "combine.children", "override");
+        for (Element element : elementsWithCombineChildren) {
+            element.getAttribute("combine.children").setValue("merge");
+            context.logger.info("      • Fixed combine.children='override' → 'merge' in " + element.getName());
+            fixed = true;
+        }
+
+        return fixed;
+    }
+
+    /**
+     * Fixes unsupported combine.self attribute values.
+     * Maven 4 only supports 'override', 'merge', and 'remove' (default is merge), not 'append'.
+     */
+    protected boolean fixUnsupportedCombineSelfAttributes(UpgradeContext context, Document pomDocument) {
+        boolean fixed = false;
+        Element root = pomDocument.getRootElement();
+
+        // Find all elements with combine.self="append" and change to "merge"
+        List<Element> elementsWithCombineSelf = findElementsWithAttribute(root, "combine.self", "append");
+        for (Element element : elementsWithCombineSelf) {
+            element.getAttribute("combine.self").setValue("merge");
+            context.logger.info("      • Fixed combine.self='append' → 'merge' in " + element.getName());
+            fixed = true;
+        }
+
+        return fixed;
+    }
+
+    /**
+     * Fixes duplicate dependency declarations.
+     * Maven 4 enforces stricter validation for duplicate dependencies.
+     */
+    protected boolean fixDuplicateDependencies(UpgradeContext context, Document pomDocument) {
+        boolean fixed = false;
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+
+        // Check dependencies in main project
+        fixed |= fixDuplicateDependenciesInElement(context, root, namespace);
+
+        // Check dependencies in profiles
+        Element profilesElement = root.getChild("profiles", namespace);
+        if (profilesElement != null) {
+            List<Element> profileElements = profilesElement.getChildren("profile", namespace);
+            for (Element profileElement : profileElements) {
+                fixed |= fixDuplicateDependenciesInElement(context, profileElement, namespace);
+            }
+        }
+
+        return fixed;
+    }
+
+    /**
+     * Fixes duplicate dependencies within a specific element (project or profile).
+     */
+    protected boolean fixDuplicateDependenciesInElement(UpgradeContext context, Element parent, Namespace namespace) {
+        boolean fixed = false;
+        Element dependenciesElement = parent.getChild("dependencies", namespace);
+
+        if (dependenciesElement != null) {
+            List<Element> dependencies = dependenciesElement.getChildren("dependency", namespace);
+            Map<String, Element> seenDependencies = new HashMap<>();
+            List<Element> toRemove = new ArrayList<>();
+
+            for (Element dependency : dependencies) {
+                String groupId = getChildText(dependency, "groupId", namespace);
+                String artifactId = getChildText(dependency, "artifactId", namespace);
+                String type = getChildText(dependency, "type", namespace);
+                String classifier = getChildText(dependency, "classifier", namespace);
+
+                // Create a key for uniqueness check
+                String key = groupId + ":" + artifactId + ":" + (type != null ? type : "jar") + ":"
+                        + (classifier != null ? classifier : "");
+
+                if (seenDependencies.containsKey(key)) {
+                    // Found duplicate - remove it
+                    toRemove.add(dependency);
+                    context.logger.info("      • Removed duplicate dependency: " + key);
+                    fixed = true;
+                } else {
+                    seenDependencies.put(key, dependency);
+                }
+            }
+
+            // Remove duplicates while preserving formatting
+            for (Element duplicate : toRemove) {
+                removeElementWithFormatting(duplicate);
+            }
+        }
+
+        return fixed;
+    }
+
+    /**
+     * Fixes unsupported expressions in repository URLs.
+     * Maven 4 only supports expressions starting with 'project.basedir' or 'project.rootDirectory'.
+     */
+    protected boolean fixUnsupportedRepositoryExpressions(UpgradeContext context, Document pomDocument) {
+        boolean fixed = false;
+        Element root = pomDocument.getRootElement();
+        Namespace namespace = root.getNamespace();
+
+        // Check repositories
+        fixed |= fixRepositoryExpressions(context, root.getChild("repositories", namespace), namespace);
+
+        // Check pluginRepositories
+        fixed |= fixRepositoryExpressions(context, root.getChild("pluginRepositories", namespace), namespace);
+
+        // Check repositories in profiles
+        Element profilesElement = root.getChild("profiles", namespace);
+        if (profilesElement != null) {
+            List<Element> profileElements = profilesElement.getChildren("profile", namespace);
+            for (Element profileElement : profileElements) {
+                fixed |= fixRepositoryExpressions(
+                        context, profileElement.getChild("repositories", namespace), namespace);
+                fixed |= fixRepositoryExpressions(
+                        context, profileElement.getChild("pluginRepositories", namespace), namespace);
+            }
+        }
+
+        return fixed;
+    }
+
+    /**
+     * Fixes expressions in repository URLs within a repositories or pluginRepositories element.
+     */
+    protected boolean fixRepositoryExpressions(
+            UpgradeContext context, Element repositoriesElement, Namespace namespace) {
+        boolean fixed = false;
+
+        if (repositoriesElement != null) {
+            String elementType =
+                    repositoriesElement.getName().equals("repositories") ? "repository" : "pluginRepository";
+            List<Element> repositories = repositoriesElement.getChildren(elementType, namespace);
+
+            for (Element repository : repositories) {
+                Element urlElement = repository.getChild("url", namespace);
+                if (urlElement != null) {
+                    String url = urlElement.getTextTrim();
+                    if (url.contains("${")
+                            && !url.contains("${project.basedir}")
+                            && !url.contains("${project.rootDirectory}")) {
+                        // Replace unsupported expressions with a comment
+                        String repositoryId = getChildText(repository, "id", namespace);
+                        context.logger.warn("      • Found unsupported expression in " + elementType + " URL (id: "
+                                + repositoryId + "): " + url);
+                        context.logger.warn(
+                                "        Maven 4 only supports ${project.basedir} and ${project.rootDirectory} expressions in repository URLs");
+
+                        // For now, we'll comment out the problematic repository
+                        // In a real implementation, you might want to ask the user how to handle this
+                        org.jdom2.Comment comment = new org.jdom2.Comment(
+                                " Repository disabled due to unsupported expression in URL: " + url + " ");
+                        Element parent = repository.getParentElement();
+                        int index = parent.indexOf(repository);
+                        parent.addContent(index, comment);
+                        removeElementWithFormatting(repository);
+
+                        context.logger.info("      • Commented out " + elementType
+                                + " with unsupported URL expression (id: " + repositoryId + ")");
+                        fixed = true;
+                    }
+                }
+            }
+        }
+
+        return fixed;
+    }
+
+    /**
+     * Recursively finds all elements with a specific attribute value.
+     */
+    protected List<Element> findElementsWithAttribute(Element element, String attributeName, String attributeValue) {
+        List<Element> result = new ArrayList<>();
+
+        // Check current element
+        org.jdom2.Attribute attr = element.getAttribute(attributeName);
+        if (attr != null && attributeValue.equals(attr.getValue())) {
+            result.add(element);
+        }
+
+        // Recursively check children
+        for (Element child : element.getChildren()) {
+            result.addAll(findElementsWithAttribute(child, attributeName, attributeValue));
+        }
+
+        return result;
+    }
+}
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/Check.java
similarity index 56%
copy from api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/Check.java
index ce566d2710..a2324281a1 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/Check.java
@@ -16,27 +16,29 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.maven.api.cli;
+package org.apache.maven.cling.invoker.mvnup.goals;
 
-import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Immutable;
+import org.apache.maven.api.di.Named;
+import org.apache.maven.api.di.Singleton;
+import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
 
 /**
- * Represents most common tools supported by CLIng.
- *
- * @since 4.0.0
+ * The "check" goal implementation.
  */
-@Immutable
-@Experimental
-public final class Tools {
-    private Tools() {}
-
-    public static final String MVN_CMD = "mvn";
-    public static final String MVN_NAME = "Maven";
+@Named("check")
+@Singleton
+public class Check extends BaseUpgradeGoal {
+    @Override
+    protected boolean shouldSaveModifications() {
+        return false;
+    }
 
-    public static final String MVNENC_CMD = "mvnenc";
-    public static final String MVNENC_NAME = "Maven Password Encrypting Tool";
+    @Override
+    public int execute(UpgradeContext context) throws Exception {
+        context.logger.info("Maven Upgrade Tool - Check");
+        context.logger.info("");
 
-    public static final String MVNSHELL_CMD = "mvnsh";
-    public static final String MVNSHELL_NAME = "Maven Shell Tool";
+        super.execute(context);
+        return 0;
+    }
 }
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/Help.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/Help.java
new file mode 100644
index 0000000000..0b3bfe832f
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/Help.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.cling.invoker.mvnup.goals;
+
+import org.apache.maven.api.di.Named;
+import org.apache.maven.api.di.Singleton;
+import org.apache.maven.cling.invoker.mvnup.Goal;
+import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
+
+/**
+ * The "help" goal implementation.
+ */
+@Named("help")
+@Singleton
+public class Help implements Goal {
+
+    @Override
+    public int execute(UpgradeContext context) throws Exception {
+        context.logger.info("Maven Upgrade Tool - Help");
+        context.logger.info("");
+        context.logger.info("Upgrades Maven projects to be compatible with Maven 4.");
+        context.logger.info("");
+        context.logger.info("Available goals:");
+        context.logger.info("  help  - display this help message");
+        context.logger.info("  check - check for available upgrades");
+        context.logger.info("  apply - apply available upgrades");
+        context.logger.info("");
+        context.logger.info("Usage: mvnup [options] <goal>");
+        context.logger.info("");
+        context.logger.info("Options:");
+        context.logger.info("  -m, --model <version> Target POM model version (4.0.0 or 4.1.0)");
+        context.logger.info("  -d, --directory <path> Directory to use as starting point for POM discovery");
+        context.logger.info("  -i, --infer           Remove redundant information that can be inferred by Maven");
+        context.logger.info("      --fix-model       Fix Maven 4 compatibility issues in POM files");
+        context.logger.info("      --plugins         Upgrade plugins known to fail with Maven 4");
+        context.logger.info(
+                "  -a, --all             Apply all upgrades (equivalent to --model 4.1.0 --infer --fix-model --plugins)");
+        context.logger.info("  -f, --force           Overwrite files without asking for confirmation");
+        context.logger.info("  -y, --yes             Answer \"yes\" to all prompts automatically");
+        context.logger.info("");
+        context.logger.info(
+                "Default behavior: --fix-model and --plugins are applied if no other options are specified");
+        context.logger.info("");
+
+        return 0;
+    }
+}
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/Artifacts.java
similarity index 56%
copy from api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/Artifacts.java
index ce566d2710..09f9106dcd 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/Artifacts.java
@@ -16,27 +16,17 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.maven.api.cli;
+package org.apache.maven.cling.invoker.mvnup.jdom;
 
-import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Immutable;
+import org.eclipse.aether.artifact.Artifact;
 
 /**
- * Represents most common tools supported by CLIng.
- *
- * @since 4.0.0
+ * Artifacts.
  */
-@Immutable
-@Experimental
-public final class Tools {
-    private Tools() {}
-
-    public static final String MVN_CMD = "mvn";
-    public static final String MVN_NAME = "Maven";
+public final class Artifacts {
+    private Artifacts() {}
 
-    public static final String MVNENC_CMD = "mvnenc";
-    public static final String MVNENC_NAME = "Maven Password Encrypting Tool";
+    public interface Sink extends org.apache.maven.cling.invoker.mvnup.jdom.Sink<Artifact> {}
 
-    public static final String MVNSHELL_CMD = "mvnsh";
-    public static final String MVNSHELL_NAME = "Maven Shell Tool";
+    public interface Source extends org.apache.maven.cling.invoker.mvnup.jdom.Source<Artifact> {}
 }
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/IOConsumer.java
similarity index 55%
copy from api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/IOConsumer.java
index ce566d2710..c262629e42 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/IOConsumer.java
@@ -16,27 +16,11 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.maven.api.cli;
+package org.apache.maven.cling.invoker.mvnup.jdom;
 
-import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Immutable;
+import java.io.IOException;
 
-/**
- * Represents most common tools supported by CLIng.
- *
- * @since 4.0.0
- */
-@Immutable
-@Experimental
-public final class Tools {
-    private Tools() {}
-
-    public static final String MVN_CMD = "mvn";
-    public static final String MVN_NAME = "Maven";
-
-    public static final String MVNENC_CMD = "mvnenc";
-    public static final String MVNENC_NAME = "Maven Password Encrypting Tool";
+public interface IOConsumer<T> {
 
-    public static final String MVNSHELL_CMD = "mvnsh";
-    public static final String MVNSHELL_NAME = "Maven Shell Tool";
+    void accept(T t) throws IOException;
 }
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/IOSupplier.java
similarity index 55%
copy from api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/IOSupplier.java
index ce566d2710..eea1f6e5f3 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/IOSupplier.java
@@ -16,27 +16,10 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.maven.api.cli;
+package org.apache.maven.cling.invoker.mvnup.jdom;
 
-import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Immutable;
+import java.io.IOException;
 
-/**
- * Represents most common tools supported by CLIng.
- *
- * @since 4.0.0
- */
-@Immutable
-@Experimental
-public final class Tools {
-    private Tools() {}
-
-    public static final String MVN_CMD = "mvn";
-    public static final String MVN_NAME = "Maven";
-
-    public static final String MVNENC_CMD = "mvnenc";
-    public static final String MVNENC_NAME = "Maven Password Encrypting Tool";
-
-    public static final String MVNSHELL_CMD = "mvnsh";
-    public static final String MVNSHELL_NAME = "Maven Shell Tool";
+public interface IOSupplier<T> {
+    T get() throws IOException;
 }
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomCfg.java
similarity index 50%
copy from api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomCfg.java
index ce566d2710..bb756d4c3e 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomCfg.java
@@ -16,27 +16,28 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.maven.api.cli;
+package org.apache.maven.cling.invoker.mvnup.jdom;
 
-import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Immutable;
-
-/**
- * Represents most common tools supported by CLIng.
+/*
+ * Copyright 2018 CoreMedia AG, Hamburg
+ *
+ * Licensed 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
  *
- * @since 4.0.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.
  */
-@Immutable
-@Experimental
-public final class Tools {
-    private Tools() {}
 
-    public static final String MVN_CMD = "mvn";
-    public static final String MVN_NAME = "Maven";
+import java.util.List;
 
-    public static final String MVNENC_CMD = "mvnenc";
-    public static final String MVNENC_NAME = "Maven Password Encrypting Tool";
+public interface JDomCfg {
+    List<String> getElementOrder(String type);
 
-    public static final String MVNSHELL_CMD = "mvnsh";
-    public static final String MVNSHELL_NAME = "Maven Shell Tool";
+    void setElementOrder(String type, List<String> elementOrder);
 }
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomContentHelper.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomContentHelper.java
new file mode 100644
index 0000000000..9d20d14326
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomContentHelper.java
@@ -0,0 +1,184 @@
+/*
+ * 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.cling.invoker.mvnup.jdom;
+
+/*
+ * 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.
+ */
+
+import org.codehaus.plexus.util.StringUtils;
+import org.jdom2.Comment;
+import org.jdom2.Content;
+import org.jdom2.Element;
+import org.jdom2.Text;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Utility methods for {@link Content}.
+ */
+class JDomContentHelper {
+
+    private static final Logger LOG = LoggerFactory.getLogger(JDomContentHelper.class);
+
+    static String contentAsString(Content content) {
+        if (content instanceof Element) {
+            return elementToString((Element) content);
+        }
+        if (content instanceof Text) {
+            return textToString((Text) content);
+        }
+        if (null != content) {
+            return content.getCType() + " => " + content.getValue().trim();
+        }
+        return "content is null";
+    }
+
+    private static String elementToString(Element element) {
+        return element.getCType() + " => <" + element.getName() + "> : "
+                + element.getValue().trim().replaceAll("\n", "\\\\n");
+    }
+
+    private static String textToString(Text text) {
+        String value = text.getValue().replaceAll("\n", "\\\\n");
+        if (isNewline(text)) {
+            return "New Line: '" + value + "'";
+        }
+        if (isMultiNewLine(text)) {
+            return "Multi New Line: '" + value + "'";
+        }
+        return text.getCType() + " => " + value;
+    }
+
+    /**
+     * Check if content represent newlines.
+     *
+     * @param content the content to check
+     * @return boolean Returns true if content has at least one newline
+     */
+    static boolean hasNewlines(Content content) {
+        if (content instanceof Text) {
+            Text text = (Text) content;
+            String value = text.getValue();
+            return StringUtils.countMatches(value, "\n") >= 1 && value.trim().isEmpty();
+        }
+        return false;
+    }
+
+    /**
+     * Check if content is one newline, i.e. \n.<br>
+     * Maybe followed by an empty string.
+     *
+     * @param content the content to check
+     * @return boolean
+     */
+    static boolean isNewline(Content content) {
+        if (content instanceof Text) {
+            Text text = (Text) content;
+            String value = text.getValue();
+            return StringUtils.countMatches(value, "\n") == 1 && value.trim().isEmpty();
+        }
+        return false;
+    }
+
+    /**
+     * Check if content is a multiline text, i.e. \n\n (or more).<br>
+     * * Maybe followed by an empty string.
+     *
+     * @param content the content to check
+     * @return boolean
+     */
+    static boolean isMultiNewLine(Content content) {
+        if (content instanceof Text) {
+            Text text = (Text) content;
+            String value = text.getValue();
+            return StringUtils.countMatches(value, "\n") > 1 && value.trim().isEmpty();
+        }
+        return false;
+    }
+
+    static boolean isComment(Content content) {
+        return content instanceof Comment;
+    }
+
+    static Content getSuccessorOfContentWithIndex(int index, Element parent) {
+        return isIndexValid(index + 1, parent) ? parent.getContent(index + 1) : null;
+    }
+
+    static Content getPredecessorOfContentWithIndex(int index, Element parent) {
+        return isIndexValid(index - 1, parent) ? parent.getContent(index - 1) : null;
+    }
+
+    static Content getContentWithIndex(int index, Element parent) {
+        return isIndexValid(index, parent) ? parent.getContent(index) : null;
+    }
+
+    /**
+     * Check if index is a valid regarding the contents of the given element
+     *
+     * @param index   the index to check
+     * @param element the element to get the contents from
+     * @return true if index is valid index: <code>element.getContent(index)</code>
+     */
+    static boolean isIndexValid(int index, Element element) {
+        int numberOfContents = element.getContent().size();
+        if (index < 0 || numberOfContents == 0 || index >= numberOfContents) {
+            LOG.trace("Parent: {} has no content with index {}", JDomContentHelper.contentAsString(element), index);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Count new lines of new/multiline-predecessors
+     *
+     * @param content the content to start from
+     * @return count of newlines
+     */
+    static int countNewlinesPredecessors(Content content) {
+        int newLineCount = 0;
+
+        Element parent = content.getParentElement();
+        int descendantIndex = parent.indexOf(content);
+        Content predecessor = JDomContentHelper.getPredecessorOfContentWithIndex(descendantIndex, parent);
+
+        while (JDomContentHelper.hasNewlines(predecessor)) {
+            newLineCount += StringUtils.countMatches(predecessor.getValue(), "\n");
+            descendantIndex--;
+            predecessor = JDomContentHelper.getPredecessorOfContentWithIndex(descendantIndex, parent);
+        }
+
+        return newLineCount;
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomDocumentIO.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomDocumentIO.java
new file mode 100644
index 0000000000..72969bb897
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomDocumentIO.java
@@ -0,0 +1,139 @@
+/*
+ * 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.cling.invoker.mvnup.jdom;
+
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.StringReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Iterator;
+import java.util.Objects;
+
+import org.jdom2.CDATA;
+import org.jdom2.Comment;
+import org.jdom2.Document;
+import org.jdom2.JDOMException;
+import org.jdom2.filter.ContentFilter;
+import org.jdom2.input.SAXBuilder;
+import org.jdom2.located.LocatedJDOMFactory;
+import org.jdom2.output.Format;
+import org.jdom2.output.XMLOutputter;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Class that parses and writes possibly manipulated JDOM to file.
+ */
+public final class JDomDocumentIO implements Closeable {
+    private final IOConsumer<String> consumer;
+    private final String lineSeparator;
+    private final String body;
+    private final String head;
+    private final String tail;
+    private final Document document;
+
+    public JDomDocumentIO(Path file) throws IOException {
+        this(
+                () -> Files.readString(file, StandardCharsets.UTF_8),
+                s -> Files.writeString(file, s, StandardCharsets.UTF_8));
+    }
+
+    public JDomDocumentIO(IOSupplier<String> supplier, IOConsumer<String> consumer) throws IOException {
+        this(supplier, consumer, System.lineSeparator());
+    }
+
+    public JDomDocumentIO(IOSupplier<String> supplier, IOConsumer<String> consumer, String lineSeparator)
+            throws IOException {
+        requireNonNull(supplier, "supplier");
+        this.consumer = requireNonNull(consumer, "consumer");
+        this.lineSeparator = requireNonNull(lineSeparator, "lineSeparator");
+
+        this.body = normalizeLineEndings(supplier.get(), lineSeparator);
+        SAXBuilder builder = new SAXBuilder();
+        builder.setJDOMFactory(new LocatedJDOMFactory());
+        try {
+            this.document = builder.build(new StringReader(body));
+        } catch (JDOMException e) {
+            throw new IOException(e);
+        }
+        normaliseLineEndings(document, lineSeparator);
+
+        int headIndex = body.indexOf("<" + document.getRootElement().getName());
+        if (headIndex >= 0) {
+            this.head = body.substring(0, headIndex);
+        } else {
+            this.head = null;
+        }
+        String lastTag = "</" + document.getRootElement().getName() + ">";
+        int tailIndex = body.lastIndexOf(lastTag);
+        if (tailIndex >= 0) {
+            this.tail = body.substring(tailIndex + lastTag.length());
+        } else {
+            this.tail = null;
+        }
+    }
+
+    public Document getDocument() {
+        return document;
+    }
+
+    @Override
+    public void close() throws IOException {
+        Format format = Format.getRawFormat();
+        format.setLineSeparator(lineSeparator);
+        XMLOutputter out = new XMLOutputter(format);
+        ByteArrayOutputStream output = new ByteArrayOutputStream();
+        try (OutputStream outputStream = output) {
+            if (head != null) {
+                outputStream.write(head.getBytes(StandardCharsets.UTF_8));
+            }
+            out.output(document.getRootElement(), outputStream);
+            if (tail != null) {
+                outputStream.write(tail.getBytes(StandardCharsets.UTF_8));
+            }
+        }
+        String newBody = output.toString(StandardCharsets.UTF_8);
+        if (!Objects.equals(body, newBody)) {
+            consumer.accept(newBody);
+        }
+    }
+
+    private static void normaliseLineEndings(Document document, String lineSeparator) {
+        for (Iterator<?> i = document.getDescendants(new ContentFilter(ContentFilter.COMMENT)); i.hasNext(); ) {
+            Comment c = (Comment) i.next();
+            c.setText(normalizeLineEndings(c.getText(), lineSeparator));
+        }
+        for (Iterator<?> i = document.getDescendants(new ContentFilter(ContentFilter.CDATA)); i.hasNext(); ) {
+            CDATA c = (CDATA) i.next();
+            c.setText(normalizeLineEndings(c.getText(), lineSeparator));
+        }
+    }
+
+    private static String normalizeLineEndings(String text, String separator) {
+        String norm = text;
+        if (text != null) {
+            norm = text.replaceAll("(\r\n)|(\n)|(\r)", separator);
+        }
+        return norm;
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomExtensionsCfg.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomExtensionsCfg.java
new file mode 100644
index 0000000000..fc10fcddde
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomExtensionsCfg.java
@@ -0,0 +1,54 @@
+/*
+ * 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.cling.invoker.mvnup.jdom;
+
+/*
+ * Copyright 2018 CoreMedia AG, Hamburg
+ *
+ * Licensed 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.
+ */
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class JDomExtensionsCfg implements JDomCfg {
+
+    private final Map<String, List<String>> elementOrder = new HashMap<>();
+
+    @Override
+    public List<String> getElementOrder(String type) {
+        return elementOrder.get(type);
+    }
+
+    @Override
+    public void setElementOrder(String type, List<String> elementOrder) {
+        this.elementOrder.put(type, elementOrder);
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomExtensionsEditor.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomExtensionsEditor.java
new file mode 100644
index 0000000000..643ff0e6f0
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomExtensionsEditor.java
@@ -0,0 +1,101 @@
+/*
+ * 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.cling.invoker.mvnup.jdom;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.aether.artifact.DefaultArtifact;
+import org.jdom2.Element;
+import org.jdom2.Text;
+
+/**
+ * Methods editing JDom Extensions representation.
+ */
+public final class JDomExtensionsEditor {
+    private static final JDomCfg JDOM_CONFIG = new JDomExtensionsCfg();
+
+    /**
+     * Helper for GA equality. To be used with extensions.
+     */
+    public static boolean equalsGA(Artifact artifact, Element element) {
+        String groupId = element.getChildText("groupId", element.getNamespace());
+        String artifactId = element.getChildText("artifactId", element.getNamespace());
+        return Objects.equals(artifact.getGroupId(), groupId) && Objects.equals(artifact.getArtifactId(), artifactId);
+    }
+
+    public static List<Artifact> listExtensions(Element extensions) {
+        ArrayList<Artifact> result = new ArrayList<>();
+        if (extensions != null) {
+            for (Element extension : extensions.getChildren("extension", extensions.getNamespace())) {
+                result.add(new DefaultArtifact(
+                        extension.getChildText("groupId", extension.getNamespace()),
+                        extension.getChildText("artifactId", extension.getNamespace()),
+                        "jar",
+                        extension.getChildText("version", extension.getNamespace())));
+            }
+        }
+        return result;
+    }
+
+    public static void updateExtension(Element extensions, Artifact a, boolean upsert) {
+        if (extensions != null) {
+            Element toUpdate = null;
+            for (Element extension : extensions.getChildren("extension", extensions.getNamespace())) {
+                if (equalsGA(a, extension)) {
+                    toUpdate = extension;
+                    break;
+                }
+            }
+            if (upsert && toUpdate == null) {
+                toUpdate = new Element("extension", extensions.getNamespace());
+                toUpdate.addContent(new Text("\n  " + JDomUtils.detectIndentation(extensions)));
+                JDomUtils.addElement(JDOM_CONFIG, toUpdate, extensions);
+                JDomUtils.addElement(
+                        JDOM_CONFIG,
+                        new Element("groupId", extensions.getNamespace()).setText(a.getGroupId()),
+                        toUpdate);
+                JDomUtils.addElement(
+                        JDOM_CONFIG,
+                        new Element("artifactId", extensions.getNamespace()).setText(a.getArtifactId()),
+                        toUpdate);
+                JDomUtils.addElement(
+                        JDOM_CONFIG,
+                        new Element("version", extensions.getNamespace()).setText(a.getVersion()),
+                        toUpdate);
+                return;
+            }
+            if (toUpdate != null) {
+                toUpdate.getChild("version", toUpdate.getNamespace()).setText(a.getVersion());
+            }
+        }
+    }
+
+    public static void deleteExtension(Element extensions, Artifact a) {
+        if (extensions != null) {
+            for (Element extension : extensions.getChildren("extension", extensions.getNamespace())) {
+                if (equalsGA(a, extension)) {
+                    JDomUtils.removeChildAndItsCommentFromContent(extensions, extension);
+                }
+            }
+        }
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomExtensionsSource.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomExtensionsSource.java
new file mode 100644
index 0000000000..643a5692d5
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomExtensionsSource.java
@@ -0,0 +1,48 @@
+/*
+ * 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.cling.invoker.mvnup.jdom;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.stream.Stream;
+
+import org.eclipse.aether.artifact.Artifact;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Construction to accept collection of artifacts, and applies it to some extensions.xml based on provided transformations.
+ */
+public final class JDomExtensionsSource implements Artifacts.Source {
+
+    private final Path extensions;
+
+    public JDomExtensionsSource(Path extensions) {
+        this.extensions = requireNonNull(extensions);
+    }
+
+    @Override
+    public Stream<Artifact> get() throws IOException {
+        try (JDomDocumentIO domDocumentIO = new JDomDocumentIO(extensions)) {
+            return JDomExtensionsEditor.listExtensions(
+                    domDocumentIO.getDocument().getRootElement())
+                    .stream();
+        }
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomExtensionsTransformer.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomExtensionsTransformer.java
new file mode 100644
index 0000000000..ff67748660
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomExtensionsTransformer.java
@@ -0,0 +1,93 @@
+/*
+ * 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.cling.invoker.mvnup.jdom;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import org.eclipse.aether.artifact.Artifact;
+import org.jdom2.Document;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Construction to accept collection of artifacts, and applies it to some extensions.xml based on provided transformations.
+ */
+public final class JDomExtensionsTransformer {
+
+    public static Function<Artifact, Consumer<JDomTransformationContext.JdomExtensionsTransformationContext>>
+            updateExtension(boolean upsert) {
+        return a -> context ->
+                JDomExtensionsEditor.updateExtension(context.getDocument().getRootElement(), a, upsert);
+    }
+
+    public static Function<Artifact, Consumer<JDomTransformationContext.JdomExtensionsTransformationContext>>
+            deleteExtension() {
+        return a -> context -> {
+            JDomExtensionsEditor.deleteExtension(context.getDocument().getRootElement(), a);
+        };
+    }
+
+    private final Path extensions;
+
+    public JDomExtensionsTransformer(Path extensions) {
+        this.extensions = requireNonNull(extensions);
+    }
+
+    public void apply(List<Consumer<JDomTransformationContext.JdomExtensionsTransformationContext>> transformations)
+            throws IOException {
+        requireNonNull(transformations, "transformations");
+        if (!transformations.isEmpty()) {
+            try (JDomDocumentIO domDocumentIO = new JDomDocumentIO(extensions)) {
+                ArrayList<Consumer<JDomTransformationContext.JdomExtensionsTransformationContext>> postProcessors =
+                        new ArrayList<>();
+                JDomTransformationContext.JdomExtensionsTransformationContext context =
+                        new JDomTransformationContext.JdomExtensionsTransformationContext() {
+                            @Override
+                            public Document getDocument() {
+                                return domDocumentIO.getDocument();
+                            }
+
+                            @Override
+                            public void registerPostTransformation(
+                                    Consumer<JdomExtensionsTransformationContext> transformation) {
+                                postProcessors.add(transformation);
+                            }
+
+                            @Override
+                            public Path extensions() {
+                                return extensions;
+                            }
+                        };
+                for (Consumer<JDomTransformationContext.JdomExtensionsTransformationContext> transformation :
+                        transformations) {
+                    transformation.accept(context);
+                }
+                for (Consumer<JDomTransformationContext.JdomExtensionsTransformationContext> transformation :
+                        postProcessors) {
+                    transformation.accept(context);
+                }
+            }
+        }
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomPomCfg.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomPomCfg.java
new file mode 100644
index 0000000000..8cb8d5d6cf
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomPomCfg.java
@@ -0,0 +1,201 @@
+/*
+ * 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.cling.invoker.mvnup.jdom;
+
+/*
+ * Copyright 2018 CoreMedia AG, Hamburg
+ *
+ * Licensed 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.
+ */
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static java.util.Arrays.asList;
+
+public class JDomPomCfg implements JDomCfg {
+    public static final String POM_ELEMENT_ACTIVATION = "activation";
+    public static final String POM_ELEMENT_ACTIVE_BY_DEFAULT = "activeByDefault";
+    public static final String POM_ELEMENT_ARTIFACT_ID = "artifactId";
+    public static final String POM_ELEMENT_BUILD = "build";
+    public static final String POM_ELEMENT_CI_MANAGEMENT = "ciManagement";
+    public static final String POM_ELEMENT_CLASSIFIER = "classifier";
+    public static final String POM_ELEMENT_CONFIGURATION = "configuration";
+    public static final String POM_ELEMENT_CONNECTION = "connection";
+    public static final String POM_ELEMENT_CONTRIBUTORS = "contributors";
+    public static final String POM_ELEMENT_DEFAULT_GOAL = "defaultGoal";
+    public static final String POM_ELEMENT_DEPENDENCIES = "dependencies";
+    public static final String POM_ELEMENT_DEPENDENCY_MANAGEMENT = "dependencyManagement";
+    public static final String POM_ELEMENT_DEPENDENCY = "dependency";
+    public static final String POM_ELEMENT_DESCRIPTION = "description";
+    public static final String POM_ELEMENT_DEVELOPER_CONNECTION = "developerConnection";
+    public static final String POM_ELEMENT_DEVELOPERS = "developers";
+    public static final String POM_ELEMENT_DIRECTORY = "directory";
+    public static final String POM_ELEMENT_DISTRIBUTION_MANAGEMENT = "distributionManagement";
+    public static final String POM_ELEMENT_EXCLUSIONS = "exclusions";
+    public static final String POM_ELEMENT_EXCLUSION = "exclusion";
+    public static final String POM_ELEMENT_EXECUTIONS = "executions";
+    public static final String POM_ELEMENT_EXECUTION = "execution";
+    public static final String POM_ELEMENT_EXTENSIONS = "extensions";
+    public static final String POM_ELEMENT_EXTENSION = "extension";
+    public static final String POM_ELEMENT_FINAL_NAME = "finalName";
+    public static final String POM_ELEMENT_GOALS = "goals";
+    public static final String POM_ELEMENT_GOAL = "goal";
+    public static final String POM_ELEMENT_GROUP_ID = "groupId";
+    public static final String POM_ELEMENT_ID = "id";
+    public static final String POM_ELEMENT_INCEPTION_YEAR = "inceptionYear";
+    public static final String POM_ELEMENT_INHERITED = "inherited";
+    public static final String POM_ELEMENT_ISSUE_MANAGEMENT = "issueManagement";
+    public static final String POM_ELEMENT_JDK = "jdk";
+    public static final String POM_ELEMENT_LICENSES = "licenses";
+    public static final String POM_ELEMENT_MAILING_LISTS = "mailingLists";
+    public static final String POM_ELEMENT_MODEL_VERSION = "modelVersion";
+    public static final String POM_ELEMENT_MODULE = "module";
+    public static final String POM_ELEMENT_MODULES = "modules";
+    public static final String POM_ELEMENT_NAME = "name";
+    public static final String POM_ELEMENT_OPTIONAL = "optional";
+    public static final String POM_ELEMENT_ORGANIZATION = "organization";
+    public static final String POM_ELEMENT_OUTPUT_DIRECTORY = "outputDirectory";
+    public static final String POM_ELEMENT_PACKAGING = "packaging";
+    public static final String POM_ELEMENT_PARENT = "parent";
+    public static final String POM_ELEMENT_PHASE = "phase";
+    public static final String POM_ELEMENT_PLUGIN = "plugin";
+    public static final String POM_ELEMENT_PLUGIN_MANAGEMENT = "pluginManagement";
+    public static final String POM_ELEMENT_PLUGIN_REPOSITORIES = "pluginRepositories";
+    public static final String POM_ELEMENT_PLUGINS = "plugins";
+    public static final String POM_ELEMENT_PREREQUISITES = "prerequisites";
+    public static final String POM_ELEMENT_PROFILE = "profile";
+    public static final String POM_ELEMENT_PROFILES = "profiles";
+    public static final String POM_ELEMENT_PROJECT = "project";
+    public static final String POM_ELEMENT_PROPERTIES = "properties";
+    public static final String POM_ELEMENT_PROPERTY = "property";
+    public static final String POM_ELEMENT_RELATIVE_PATH = "relativePath";
+    public static final String POM_ELEMENT_REPORTING = "reporting";
+    public static final String POM_ELEMENT_REPOSITORIES = "repositories";
+    public static final String POM_ELEMENT_SCM = "scm";
+    public static final String POM_ELEMENT_SCOPE = "scope";
+    public static final String POM_ELEMENT_SCRIPT_SOURCE_DIRECTORY = "scriptSourceDirectory";
+    public static final String POM_ELEMENT_SOURCE_DIRECTORY = "sourceDirectory";
+    public static final String POM_ELEMENT_SYSTEM_PATH = "systemPath";
+    public static final String POM_ELEMENT_TAG = "tag";
+    public static final String POM_ELEMENT_TEST_OUTPUT_DIRECTORY = "testOutputDirectory";
+    public static final String POM_ELEMENT_TEST_SOURCE_DIRECTORY = "testSourceDirectory";
+    public static final String POM_ELEMENT_TYPE = "type";
+    public static final String POM_ELEMENT_URL = "url";
+    public static final String POM_ELEMENT_VALUE = "value";
+    public static final String POM_ELEMENT_VERSION = "version";
+
+    private final Map<String, List<String>> elementOrder = new HashMap<>();
+
+    public JDomPomCfg() {
+        elementOrder.put(
+                POM_ELEMENT_PROJECT,
+                asList(
+                        POM_ELEMENT_MODEL_VERSION,
+                        "",
+                        POM_ELEMENT_PARENT,
+                        "",
+                        POM_ELEMENT_GROUP_ID,
+                        POM_ELEMENT_ARTIFACT_ID,
+                        POM_ELEMENT_VERSION,
+                        POM_ELEMENT_PACKAGING,
+                        "",
+                        POM_ELEMENT_NAME,
+                        POM_ELEMENT_DESCRIPTION,
+                        POM_ELEMENT_URL,
+                        POM_ELEMENT_INCEPTION_YEAR,
+                        POM_ELEMENT_ORGANIZATION,
+                        POM_ELEMENT_LICENSES,
+                        "",
+                        POM_ELEMENT_DEVELOPERS,
+                        POM_ELEMENT_CONTRIBUTORS,
+                        "",
+                        POM_ELEMENT_MAILING_LISTS,
+                        "",
+                        POM_ELEMENT_PREREQUISITES,
+                        "",
+                        POM_ELEMENT_MODULES,
+                        "",
+                        POM_ELEMENT_SCM,
+                        POM_ELEMENT_ISSUE_MANAGEMENT,
+                        POM_ELEMENT_CI_MANAGEMENT,
+                        POM_ELEMENT_DISTRIBUTION_MANAGEMENT,
+                        "",
+                        POM_ELEMENT_PROPERTIES,
+                        "",
+                        POM_ELEMENT_DEPENDENCY_MANAGEMENT,
+                        POM_ELEMENT_DEPENDENCIES,
+                        "",
+                        POM_ELEMENT_REPOSITORIES,
+                        POM_ELEMENT_PLUGIN_REPOSITORIES,
+                        "",
+                        POM_ELEMENT_BUILD,
+                        "",
+                        POM_ELEMENT_REPORTING,
+                        "",
+                        POM_ELEMENT_PROFILES));
+        elementOrder.put(
+                POM_ELEMENT_PROFILE,
+                asList(
+                        POM_ELEMENT_ID,
+                        POM_ELEMENT_ACTIVATION,
+                        POM_ELEMENT_MODULES,
+                        POM_ELEMENT_DISTRIBUTION_MANAGEMENT,
+                        POM_ELEMENT_PROPERTIES,
+                        POM_ELEMENT_DEPENDENCY_MANAGEMENT,
+                        POM_ELEMENT_DEPENDENCIES,
+                        POM_ELEMENT_REPOSITORIES,
+                        POM_ELEMENT_PLUGIN_REPOSITORIES,
+                        POM_ELEMENT_BUILD,
+                        POM_ELEMENT_REPORTING));
+        elementOrder.put(
+                POM_ELEMENT_DEPENDENCY,
+                asList(
+                        POM_ELEMENT_GROUP_ID,
+                        POM_ELEMENT_ARTIFACT_ID,
+                        POM_ELEMENT_VERSION,
+                        POM_ELEMENT_CLASSIFIER,
+                        POM_ELEMENT_TYPE,
+                        POM_ELEMENT_SCOPE,
+                        POM_ELEMENT_SYSTEM_PATH,
+                        POM_ELEMENT_OPTIONAL,
+                        POM_ELEMENT_EXCLUSIONS));
+    }
+
+    @Override
+    public List<String> getElementOrder(String type) {
+        return elementOrder.get(type);
+    }
+
+    @Override
+    public void setElementOrder(String type, List<String> elementOrder) {
+        this.elementOrder.put(type, elementOrder);
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomPomCleanupHelper.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomPomCleanupHelper.java
new file mode 100644
index 0000000000..9415d0403a
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomPomCleanupHelper.java
@@ -0,0 +1,279 @@
+/*
+ * 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.cling.invoker.mvnup.jdom;
+
+/*
+ * 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.
+ */
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.codehaus.plexus.util.StringUtils;
+import org.jdom2.Comment;
+import org.jdom2.Content;
+import org.jdom2.Element;
+import org.jdom2.Text;
+import org.jdom2.filter.ElementFilter;
+import org.jdom2.util.IteratorIterable;
+
+/**
+ * JDom cleanup methods for:
+ * <ol>
+ * <li>profiles</li>
+ * </ol>
+ */
+public class JDomPomCleanupHelper {
+
+    /**
+     * Generic cleanup useful after removal of things.
+     */
+    public static void cleanup(Element rootElement) {
+        // Remove empty elements
+        for (String cleanUpEmptyElement : List.of(
+                JDomPomCfg.POM_ELEMENT_MODULES,
+                JDomPomCfg.POM_ELEMENT_PROPERTIES,
+                JDomPomCfg.POM_ELEMENT_PLUGINS,
+                JDomPomCfg.POM_ELEMENT_PLUGIN_MANAGEMENT,
+                JDomPomCfg.POM_ELEMENT_DEPENDENCIES,
+                JDomPomCfg.POM_ELEMENT_DEPENDENCY_MANAGEMENT)) {
+            JDomPomCleanupHelper.cleanupEmptyElements(rootElement, cleanUpEmptyElement);
+        }
+        // Remove empty (i.e. with no elements) profile and profiles tag
+        JDomPomCleanupHelper.cleanupEmptyProfiles(rootElement, List.of(JDomPomCfg.POM_ELEMENT_PROJECT));
+    }
+
+    /**
+     * Remove all empty profiles and profile tags.<br>
+     * Empty is defined as
+     * <ul>
+     * <li>for tag <b>profiles</b>: there are no profile tags</li>
+     * <li>for tag <b>profile</b>: Either there are only the tags activation and id present or all other tags are empty</li>
+     * </ul>
+     * Thus, empty tags may contain comments which will be removed as well.<br>
+     * Only those {@code profiles} tags are removed whose parent stated via {@code profilesParents}
+     *
+     * @param rootElement     the root element.
+     * @param profilesParents list of allowed parents for {@code profiles} tags
+     */
+    public static void cleanupEmptyProfiles(Element rootElement, List<String> profilesParents) {
+        IteratorIterable<Element> filteredElements =
+                rootElement.getDescendants(new ElementFilter(JDomPomCfg.POM_ELEMENT_PROFILES));
+        List<Element> profiles = new ArrayList<>();
+        for (Element profilesElement : filteredElements) {
+            profiles.add(profilesElement);
+        }
+        for (Element profilesElement : profiles) {
+            if (!profilesParents.contains(profilesElement.getParentElement().getName())) {
+                continue;
+            }
+            removeElementWithEmptyChildren(
+                    profilesElement,
+                    JDomPomCfg.POM_ELEMENT_PROFILE,
+                    Arrays.asList(JDomPomCfg.POM_ELEMENT_ID, JDomPomCfg.POM_ELEMENT_ACTIVATION));
+            if (!profilesElement
+                    .getDescendants(new ElementFilter(JDomPomCfg.POM_ELEMENT_PROFILE))
+                    .hasNext()) {
+                JDomUtils.removeChildAndItsCommentFromContent(profilesElement.getParentElement(), profilesElement);
+            }
+        }
+    }
+
+    /**
+     * Remove empty element tags and their empty child tags.<br>
+     * Empty tags may contain comments which will be removed as well.
+     *
+     * @param rootElement the root element.
+     * @param tag         Tag to check.
+     */
+    public static void cleanupEmptyElements(Element rootElement, String tag) {
+        IteratorIterable<Element> filteredElements = rootElement.getDescendants(new ElementFilter(tag));
+        List<Element> elementsToRemoveIfEmpty = new ArrayList<>();
+        for (Element element : filteredElements) {
+            elementsToRemoveIfEmpty.add(element);
+        }
+        for (Element elementToRemove : elementsToRemoveIfEmpty) {
+            List<Element> children = elementToRemove.getChildren();
+            if (children.isEmpty()) {
+                JDomUtils.removeChildAndItsCommentFromContent(elementToRemove.getParentElement(), elementToRemove);
+            }
+        }
+    }
+
+    /**
+     * Squash multiple consecutive newlines into a single newline.<br>
+     * Indentations are preserved.
+     *
+     * @param rootElement the root element.
+     */
+    public static void squashMultilines(Element rootElement) {
+        // Compute groups of consecutive sibling content with only newlines (and whitespace)
+        List<List<Text>> newLineGroups = new ArrayList<>();
+        List<Text> currentGroup = new ArrayList<>();
+        for (Content descendant : rootElement.getDescendants()) {
+            if (JDomContentHelper.hasNewlines(descendant)) {
+                if (!currentGroup.isEmpty()) {
+                    Element parent = currentGroup.get(0).getParent();
+                    if (!parent.equals(descendant.getParentElement())) {
+                        newLineGroups.add(currentGroup);
+                        currentGroup = new ArrayList<>();
+                    }
+                }
+                currentGroup.add((Text) descendant);
+            } else {
+                if (!currentGroup.isEmpty()) {
+                    newLineGroups.add(currentGroup);
+                    currentGroup = new ArrayList<>();
+                }
+            }
+        }
+        if (!currentGroup.isEmpty()) {
+            newLineGroups.add(currentGroup);
+        }
+
+        // For every group keep the last element (because it might be followed by whitespace which we want to keep for
+        // indentation)
+        // and set its text to two newlines (+ whitespace).
+        // Delete all other predecessor elements in the group.
+        for (List<Text> group : newLineGroups) {
+            int newlineCount = 0;
+            for (Text text : group) {
+                newlineCount += StringUtils.countMatches(text.getText(), "\n");
+            }
+
+            if (newlineCount > 2) {
+                Text last = group.get(group.size() - 1);
+                last.setText("\n\n" + last.getText().replaceAll("\n", ""));
+                group.remove(last);
+                for (Text text : group) {
+                    text.getParentElement().removeContent(text);
+                    text.detach();
+                }
+            }
+        }
+    }
+
+    /**
+     * Remove all empty children with tag name from parent element.<br>
+     * The child is considered as empty if the child has either:
+     * <ul>
+     * <li>no children itself or</li>
+     * <li>only children which are ignored or</li>
+     * <li>empty children itself which are not ignored</li>
+     * </ul>
+     *
+     * @param parent         the parent element.
+     * @param tagName        the tag name to search for.
+     * @param ignoreChildren List of children tag names which are ignored when searching for child elements.
+     */
+    private static void removeElementWithEmptyChildren(Element parent, String tagName, List<String> ignoreChildren) {
+        // Example:
+        // parent === 'profiles' Element
+        // tagName === 'profile'
+        // ignoreChildren === ['id', 'activation']
+
+        // filteredElements === direct children of parent which are of type Element and matching 'tagName'
+        IteratorIterable<Element> filteredElements = parent.getDescendants(new ElementFilter(tagName));
+
+        List<Content> contentToBeRemoved = new ArrayList<>();
+        for (Element filteredElement : filteredElements) {
+            boolean empty = true;
+            for (Element child : filteredElement.getChildren()) {
+                if (!ignoreChildren.contains(child.getName())
+                        && !child.getChildren().isEmpty()) {
+                    empty = false;
+                    break;
+                }
+            }
+            if (empty) {
+                contentToBeRemoved.add(filteredElement);
+                contentToBeRemoved.addAll(getAttachedComments(filteredElement));
+            }
+        }
+        for (Content elementToBeRemoved : contentToBeRemoved) {
+            JDomUtils.removeChildAndItsCommentFromContent(parent, elementToBeRemoved);
+        }
+    }
+
+    /**
+     * Get all comments attached of the element.
+     *
+     * @param element the element to consider.
+     * @return List of {@link Comment}s
+     */
+    private static List<Content> getAttachedComments(Element element) {
+        List<Content> contents = new ArrayList<>();
+        List<Content> siblings = getDirectContents(element.getParentElement());
+        int indexOfElement = siblings.indexOf(element);
+        for (int i = indexOfElement - 1; i >= 0; i--) {
+            if (JDomContentHelper.isNewline(siblings.get(i))) {
+                contents.add(siblings.get(i));
+                i--;
+            }
+            if (i >= 0 && siblings.get(i) instanceof Comment) {
+                contents.add(siblings.get(i));
+                continue;
+            }
+            if (i >= 0 && JDomContentHelper.isMultiNewLine(siblings.get(i))) {
+                contents.add(siblings.get(i));
+            }
+            break;
+        }
+        return contents;
+    }
+
+    /**
+     * Get all direct children of the element.
+     *
+     * @param element the element to consider.
+     * @return List of {@link Content}s
+     */
+    private static List<Content> getDirectContents(Element element) {
+        // get all direct children
+        List<Content> children = new ArrayList<>();
+        Element parentElement = element.getParentElement();
+        if (null == parentElement) {
+            return children;
+        }
+        for (Content descendant : parentElement.getDescendants()) {
+            if (!descendant.getParent().equals(element)) {
+                // Only consider direct children
+                continue;
+            }
+            children.add(descendant);
+        }
+        return children;
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomPomEditor.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomPomEditor.java
new file mode 100644
index 0000000000..201396c973
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomPomEditor.java
@@ -0,0 +1,576 @@
+/*
+ * 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.cling.invoker.mvnup.jdom;
+
+import java.util.Objects;
+
+import org.eclipse.aether.artifact.Artifact;
+import org.jdom2.Element;
+import org.jdom2.Text;
+
+/**
+ * Methods editing JDom POM representation.
+ */
+public final class JDomPomEditor {
+    private static final JDomCfg POM_CONFIG = new JDomPomCfg();
+
+    /**
+     * Helper for GA equality. To be used with plugins.
+     */
+    public static boolean equalsGA(Artifact artifact, Element element) {
+        String groupId = element.getChildText("groupId", element.getNamespace());
+        String artifactId = element.getChildText("artifactId", element.getNamespace());
+        return Objects.equals(artifact.getGroupId(), groupId) && Objects.equals(artifact.getArtifactId(), artifactId);
+    }
+
+    /**
+     * Helper for GATC equality. To be used with dependencies.
+     */
+    public static boolean equalsGATC(Artifact artifact, Element element) {
+        String groupId = element.getChildText("groupId", element.getNamespace());
+        String artifactId = element.getChildText("artifactId", element.getNamespace());
+        String type = element.getChildText("type", element.getNamespace());
+        if (type == null) {
+            type = "jar";
+        }
+        String classifier = element.getChildText("classifier", element.getNamespace());
+        if (classifier == null) {
+            classifier = "";
+        }
+        return Objects.equals(artifact.getGroupId(), groupId)
+                && Objects.equals(artifact.getArtifactId(), artifactId)
+                && Objects.equals(artifact.getClassifier(), classifier)
+                && Objects.equals(artifact.getExtension(), type);
+    }
+
+    public static void setProperty(Element project, String key, String value, boolean upsert) {
+        if (project != null) {
+            Element properties = project.getChild("properties", project.getNamespace());
+            if (upsert && properties == null) {
+                properties = new Element("properties", project.getNamespace());
+                JDomUtils.addElement(POM_CONFIG, properties, project);
+            }
+            if (properties != null) {
+                Element property = properties.getChild(key, properties.getNamespace());
+                if (upsert && property == null) {
+                    property = new Element(key, properties.getNamespace());
+                    JDomUtils.addElement(POM_CONFIG, property, properties);
+                }
+                if (property != null) {
+                    property.setText(value);
+                }
+            }
+        }
+    }
+
+    public static void setParent(Element project, Artifact a) {
+        if (project != null) {
+            Element parent = project.getChild("parent", project.getNamespace());
+            if (parent == null) {
+                parent = new Element("parent", project.getNamespace());
+                parent.addContent(new Text("\n  " + JDomUtils.detectIndentation(project)));
+                JDomUtils.addElement(POM_CONFIG, parent, project);
+            }
+            JDomUtils.rewriteElement(POM_CONFIG, "groupId", a.getGroupId(), parent);
+            JDomUtils.rewriteElement(POM_CONFIG, "artifactId", a.getArtifactId(), parent);
+            JDomUtils.rewriteElement(POM_CONFIG, "version", a.getVersion(), parent);
+
+            Element groupId = project.getChild("groupId", project.getNamespace());
+            if (groupId != null && groupId.getText().equals(a.getGroupId())) {
+                JDomUtils.removeChildElement(project, groupId);
+            }
+
+            Element version = project.getChild("version", project.getNamespace());
+            if (version != null && version.getText().equals(a.getVersion())) {
+                JDomUtils.removeChildElement(project, version);
+            }
+        }
+    }
+
+    public static void setVersion(Element project, String version) {
+        if (project != null) {
+            Element element = project.getChild("version", project.getNamespace());
+            if (element != null) {
+                element.setText(version);
+                return;
+            }
+            element = project.getChild("parent", project.getNamespace());
+            if (element != null) {
+                element = element.getChild("version", project.getNamespace());
+                if (element != null) {
+                    element.setText(version);
+                    return;
+                }
+            }
+            throw new IllegalStateException("Could not set version");
+        }
+    }
+
+    public static void addSubProject(Element project, String subproject) {
+        if (project != null) {
+            Element modules = project.getChild("modules", project.getNamespace());
+            if (modules == null) {
+                modules = new Element("modules", project.getNamespace());
+                modules.addContent(new Text("\n  " + JDomUtils.detectIndentation(project)));
+                JDomUtils.addElement(POM_CONFIG, modules, project);
+            }
+            Element module = new Element("module", modules.getNamespace());
+            module.setText(subproject);
+            JDomUtils.addElement(POM_CONFIG, module, modules);
+        }
+    }
+
+    public static void setPackaging(Element project, String value) {
+        if (project != null) {
+            Element packaging = project.getChild("packaging", project.getNamespace());
+            if (packaging == null) {
+                packaging = new Element("packaging", project.getNamespace());
+                JDomUtils.addElement(POM_CONFIG, packaging, project);
+            }
+            if ("jar".equals(value)) {
+                JDomUtils.removeChildAndItsCommentFromContent(project, packaging);
+            } else {
+                packaging.setText(value);
+            }
+        }
+    }
+
+    public static void updateManagedPlugin(Element project, Artifact a, boolean upsert) {
+        if (project != null) {
+            Element build = project.getChild("build", project.getNamespace());
+            if (upsert && build == null) {
+                build = new Element("build", project.getNamespace());
+                build.addContent(new Text("\n  " + JDomUtils.detectIndentation(project)));
+                JDomUtils.addElement(POM_CONFIG, build, project);
+            }
+            if (build != null) {
+                Element pluginManagement = build.getChild("pluginManagement", build.getNamespace());
+                if (upsert && pluginManagement == null) {
+                    pluginManagement = new Element("pluginManagement", build.getNamespace());
+                    pluginManagement.addContent(new Text("\n  " + JDomUtils.detectIndentation(build)));
+                    JDomUtils.addElement(POM_CONFIG, pluginManagement, build);
+                }
+                if (pluginManagement != null) {
+                    Element plugins = pluginManagement.getChild("plugins", pluginManagement.getNamespace());
+                    if (upsert && plugins == null) {
+                        plugins = new Element("plugins", pluginManagement.getNamespace());
+                        plugins.addContent(new Text("\n  " + JDomUtils.detectIndentation(pluginManagement)));
+                        JDomUtils.addElement(POM_CONFIG, plugins, pluginManagement);
+                    }
+                    if (plugins != null) {
+                        Element toUpdate = null;
+                        for (Element plugin : plugins.getChildren("plugin", plugins.getNamespace())) {
+                            if (equalsGA(a, plugin)) {
+                                toUpdate = plugin;
+                                break;
+                            }
+                        }
+                        if (upsert && toUpdate == null) {
+                            toUpdate = new Element("plugin", plugins.getNamespace());
+                            toUpdate.addContent(new Text("\n  " + JDomUtils.detectIndentation(plugins)));
+                            JDomUtils.addElement(POM_CONFIG, toUpdate, plugins);
+                            JDomUtils.addElement(
+                                    POM_CONFIG,
+                                    new Element("groupId", plugins.getNamespace()).setText(a.getGroupId()),
+                                    toUpdate);
+                            JDomUtils.addElement(
+                                    POM_CONFIG,
+                                    new Element("artifactId", plugins.getNamespace()).setText(a.getArtifactId()),
+                                    toUpdate);
+                            JDomUtils.addElement(
+                                    POM_CONFIG,
+                                    new Element("version", plugins.getNamespace()).setText(a.getVersion()),
+                                    toUpdate);
+                            return;
+                        }
+                        if (toUpdate != null) {
+                            Element version = toUpdate.getChild("version", toUpdate.getNamespace());
+                            if (version != null) {
+                                String versionValue = version.getText();
+                                if (versionValue.startsWith("${") && versionValue.endsWith("}")) {
+                                    String propertyKey = versionValue.substring(2, versionValue.length() - 1);
+                                    setProperty(project, propertyKey, a.getVersion(), true);
+                                } else {
+                                    version.setText(a.getVersion());
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    public static void deleteManagedPlugin(Element project, Artifact a) {
+        if (project != null) {
+            Element build = project.getChild("build", project.getNamespace());
+            if (build != null) {
+                Element pluginManagement = project.getChild("pluginManagement", project.getNamespace());
+                if (pluginManagement != null) {
+                    Element plugins = pluginManagement.getChild("plugins", pluginManagement.getNamespace());
+                    if (plugins != null) {
+                        for (Element plugin : plugins.getChildren("plugin", plugins.getNamespace())) {
+                            if (equalsGA(a, plugin)) {
+                                JDomUtils.removeChildAndItsCommentFromContent(plugins, plugin);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    public static void updatePlugin(Element project, Artifact a, boolean upsert) {
+        if (project != null) {
+            Element build = project.getChild("build", project.getNamespace());
+            if (upsert && build == null) {
+                build = new Element("build", project.getNamespace());
+                build.addContent(new Text("\n  " + JDomUtils.detectIndentation(project)));
+                JDomUtils.addElement(POM_CONFIG, build, project);
+            }
+            if (build != null) {
+                Element plugins = build.getChild("plugins", build.getNamespace());
+                if (upsert && plugins == null) {
+                    plugins = new Element("plugins", build.getNamespace());
+                    plugins.addContent(new Text("\n  " + JDomUtils.detectIndentation(build)));
+                    JDomUtils.addElement(POM_CONFIG, plugins, build);
+                }
+                if (plugins != null) {
+                    Element toUpdate = null;
+                    for (Element plugin : plugins.getChildren("plugin", plugins.getNamespace())) {
+                        if (equalsGA(a, plugin)) {
+                            toUpdate = plugin;
+                            break;
+                        }
+                    }
+                    if (upsert && toUpdate == null) {
+                        toUpdate = new Element("plugin", plugins.getNamespace());
+                        toUpdate.addContent(new Text("\n  " + JDomUtils.detectIndentation(plugins)));
+                        JDomUtils.addElement(POM_CONFIG, toUpdate, plugins);
+                        JDomUtils.addElement(
+                                POM_CONFIG,
+                                new Element("groupId", plugins.getNamespace()).setText(a.getGroupId()),
+                                toUpdate);
+                        JDomUtils.addElement(
+                                POM_CONFIG,
+                                new Element("artifactId", plugins.getNamespace()).setText(a.getArtifactId()),
+                                toUpdate);
+                        JDomUtils.addElement(
+                                POM_CONFIG,
+                                new Element("version", plugins.getNamespace()).setText(a.getVersion()),
+                                toUpdate);
+                        return;
+                    }
+                    if (toUpdate != null) {
+                        Element version = toUpdate.getChild("version", plugins.getNamespace());
+                        if (version != null) {
+                            String versionValue = version.getText();
+                            if (versionValue.startsWith("${") && versionValue.endsWith("}")) {
+                                String propertyKey = versionValue.substring(2, versionValue.length() - 1);
+                                setProperty(project, propertyKey, a.getVersion(), true);
+                            } else {
+                                version.setText(a.getVersion());
+                            }
+                        } else {
+                            updateManagedPlugin(project, a, upsert);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    public static void deletePlugin(Element project, Artifact a) {
+        if (project != null) {
+            Element build = project.getChild("build", project.getNamespace());
+            if (build != null) {
+                Element plugins = build.getChild("plugins", build.getNamespace());
+                if (plugins != null) {
+                    for (Element plugin : plugins.getChildren("plugin", plugins.getNamespace())) {
+                        if (equalsGA(a, plugin)) {
+                            JDomUtils.removeChildAndItsCommentFromContent(plugins, plugin);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    public static void deletePluginVersion(Element project, Artifact a) {
+        if (project != null) {
+            Element build = project.getChild("build", project.getNamespace());
+            if (build != null) {
+                Element plugins = build.getChild("plugins", build.getNamespace());
+                if (plugins != null) {
+                    for (Element plugin : plugins.getChildren("plugin", plugins.getNamespace())) {
+                        if (equalsGA(a, plugin)) {
+                            Element version = plugin.getChild("version", plugin.getNamespace());
+                            if (version != null) {
+                                JDomUtils.removeChildAndItsCommentFromContent(plugin, version);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    public static void updateExtension(Element project, Artifact a, boolean upsert) {
+        if (project != null) {
+            Element build = project.getChild("build", project.getNamespace());
+            if (upsert && build == null) {
+                build = new Element("build", project.getNamespace());
+                build.addContent(new Text("\n  " + JDomUtils.detectIndentation(project)));
+                JDomUtils.addElement(POM_CONFIG, build, project);
+            }
+            if (build != null) {
+                Element extensions = build.getChild("extensions", build.getNamespace());
+                if (upsert && extensions == null) {
+                    extensions = new Element("extensions", build.getNamespace());
+                    extensions.addContent(new Text("\n  " + JDomUtils.detectIndentation(build)));
+                    JDomUtils.addElement(POM_CONFIG, extensions, build);
+                }
+                if (extensions != null) {
+                    Element toUpdate = null;
+                    for (Element plugin : extensions.getChildren("extension", extensions.getNamespace())) {
+                        if (equalsGA(a, plugin)) {
+                            toUpdate = plugin;
+                            break;
+                        }
+                    }
+                    if (upsert && toUpdate == null) {
+                        toUpdate = new Element("plugin", extensions.getNamespace());
+                        toUpdate.addContent(new Text("\n  " + JDomUtils.detectIndentation(extensions)));
+                        JDomUtils.addElement(POM_CONFIG, toUpdate, extensions);
+                        JDomUtils.addElement(
+                                POM_CONFIG,
+                                new Element("groupId", extensions.getNamespace()).setText(a.getGroupId()),
+                                toUpdate);
+                        JDomUtils.addElement(
+                                POM_CONFIG,
+                                new Element("artifactId", extensions.getNamespace()).setText(a.getArtifactId()),
+                                toUpdate);
+                        JDomUtils.addElement(
+                                POM_CONFIG,
+                                new Element("version", extensions.getNamespace()).setText(a.getVersion()),
+                                toUpdate);
+                        return;
+                    }
+                    if (toUpdate != null) {
+                        Element version = toUpdate.getChild("version", extensions.getNamespace());
+                        if (version != null) {
+                            String versionValue = version.getText();
+                            if (versionValue.startsWith("${") && versionValue.endsWith("}")) {
+                                String propertyKey = versionValue.substring(2, versionValue.length() - 1);
+                                setProperty(project, propertyKey, a.getVersion(), true);
+                            } else {
+                                version.setText(a.getVersion());
+                            }
+                        } else {
+                            updateManagedPlugin(project, a, upsert);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    public static void deleteExtension(Element project, Artifact a) {
+        if (project != null) {
+            Element build = project.getChild("build", project.getNamespace());
+            if (build != null) {
+                Element extensions = build.getChild("extensions", build.getNamespace());
+                if (extensions != null) {
+                    for (Element extension : extensions.getChildren("extension", extensions.getNamespace())) {
+                        if (equalsGA(a, extension)) {
+                            JDomUtils.removeChildAndItsCommentFromContent(extensions, extension);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    public static void updateManagedDependency(Element project, Artifact a, boolean upsert) {
+        if (project != null) {
+            Element dependencyManagement = project.getChild("dependencyManagement", project.getNamespace());
+            if (upsert && dependencyManagement == null) {
+                dependencyManagement = new Element("dependencyManagement", project.getNamespace());
+                dependencyManagement.addContent(new Text("\n  " + JDomUtils.detectIndentation(project)));
+                JDomUtils.addElement(POM_CONFIG, dependencyManagement, project);
+            }
+            if (dependencyManagement != null) {
+                Element dependencies =
+                        dependencyManagement.getChild("dependencies", dependencyManagement.getNamespace());
+                if (upsert && dependencies == null) {
+                    dependencies = new Element("dependencies", dependencyManagement.getNamespace());
+                    dependencies.addContent(new Text("\n  " + JDomUtils.detectIndentation(dependencyManagement)));
+                    JDomUtils.addElement(POM_CONFIG, dependencies, dependencyManagement);
+                }
+                if (dependencies != null) {
+                    Element toUpdate = null;
+                    for (Element dependency : dependencies.getChildren("dependency", dependencies.getNamespace())) {
+                        if (equalsGATC(a, dependency)) {
+                            toUpdate = dependency;
+                            break;
+                        }
+                    }
+                    if (upsert && toUpdate == null) {
+                        toUpdate = new Element("dependency", dependencies.getNamespace());
+                        toUpdate.addContent(new Text("\n  " + JDomUtils.detectIndentation(dependencies)));
+                        JDomUtils.addElement(POM_CONFIG, toUpdate, dependencies);
+                        JDomUtils.addElement(
+                                POM_CONFIG,
+                                new Element("groupId", dependencies.getNamespace()).setText(a.getGroupId()),
+                                toUpdate);
+                        JDomUtils.addElement(
+                                POM_CONFIG,
+                                new Element("artifactId", dependencies.getNamespace()).setText(a.getArtifactId()),
+                                toUpdate);
+                        JDomUtils.addElement(
+                                POM_CONFIG,
+                                new Element("version", dependencies.getNamespace()).setText(a.getVersion()),
+                                toUpdate);
+                        if (!"jar".equals(a.getExtension())) {
+                            JDomUtils.addElement(
+                                    POM_CONFIG,
+                                    new Element("type", dependencies.getNamespace()).setText(a.getExtension()),
+                                    toUpdate);
+                        }
+                        if (!a.getClassifier().isEmpty()) {
+                            JDomUtils.addElement(
+                                    POM_CONFIG,
+                                    new Element("classifier", dependencies.getNamespace()).setText(a.getClassifier()),
+                                    toUpdate);
+                        }
+                        return;
+                    }
+                    if (toUpdate != null) {
+                        Element version = toUpdate.getChild("version", dependencies.getNamespace());
+                        if (version != null) {
+                            String versionValue = version.getText();
+                            if (versionValue.startsWith("${") && versionValue.endsWith("}")) {
+                                String propertyKey = versionValue.substring(2, versionValue.length() - 1);
+                                setProperty(project, propertyKey, a.getVersion(), true);
+                            } else {
+                                version.setText(a.getVersion());
+                            }
+                        } else {
+                            updateManagedDependency(project, a, upsert);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    public static void deleteManagedDependency(Element project, Artifact a) {
+        if (project != null) {
+            Element dependencyManagement = project.getChild("dependencyManagement", project.getNamespace());
+            if (dependencyManagement != null) {
+                Element dependencies =
+                        dependencyManagement.getChild("dependencies", dependencyManagement.getNamespace());
+                if (dependencies != null) {
+                    for (Element dependency : dependencies.getChildren("dependency", dependencies.getNamespace())) {
+                        if (equalsGATC(a, dependency)) {
+                            JDomUtils.removeChildAndItsCommentFromContent(dependencies, dependency);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    public static void updateDependency(Element project, Artifact a, boolean upsert) {
+        if (project != null) {
+            Element dependencies = project.getChild("dependencies", project.getNamespace());
+            if (upsert && dependencies == null) {
+                dependencies = new Element("dependencies", project.getNamespace());
+                dependencies.addContent(new Text("\n  " + JDomUtils.detectIndentation(project)));
+                JDomUtils.addElement(POM_CONFIG, dependencies, project);
+            }
+            if (dependencies != null) {
+                Element toUpdate = null;
+                for (Element dependency : dependencies.getChildren("dependency", dependencies.getNamespace())) {
+                    if (equalsGATC(a, dependency)) {
+                        toUpdate = dependency;
+                        break;
+                    }
+                }
+                if (upsert && toUpdate == null) {
+                    toUpdate = new Element("dependency", dependencies.getNamespace());
+                    toUpdate.addContent(new Text("\n  " + JDomUtils.detectIndentation(dependencies)));
+                    JDomUtils.addElement(POM_CONFIG, toUpdate, dependencies);
+                    JDomUtils.addElement(
+                            POM_CONFIG,
+                            new Element("groupId", dependencies.getNamespace()).setText(a.getGroupId()),
+                            toUpdate);
+                    JDomUtils.addElement(
+                            POM_CONFIG,
+                            new Element("artifactId", dependencies.getNamespace()).setText(a.getArtifactId()),
+                            toUpdate);
+                    JDomUtils.addElement(
+                            POM_CONFIG,
+                            new Element("version", dependencies.getNamespace()).setText(a.getVersion()),
+                            toUpdate);
+                    if (!"jar".equals(a.getExtension())) {
+                        JDomUtils.addElement(
+                                POM_CONFIG,
+                                new Element("type", dependencies.getNamespace()).setText(a.getExtension()),
+                                toUpdate);
+                    }
+                    if (!a.getClassifier().isEmpty()) {
+                        JDomUtils.addElement(
+                                POM_CONFIG,
+                                new Element("classifier", dependencies.getNamespace()).setText(a.getClassifier()),
+                                toUpdate);
+                    }
+                    return;
+                }
+                if (toUpdate != null) {
+                    Element version = toUpdate.getChild("version", dependencies.getNamespace());
+                    if (version != null) {
+                        String versionValue = version.getText();
+                        if (versionValue.startsWith("${") && versionValue.endsWith("}")) {
+                            String propertyKey = versionValue.substring(2, versionValue.length() - 1);
+                            setProperty(project, propertyKey, a.getVersion(), true);
+                        } else {
+                            version.setText(a.getVersion());
+                        }
+                    } else {
+                        updateManagedDependency(project, a, upsert);
+                    }
+                }
+            }
+        }
+    }
+
+    public static void deleteDependency(Element project, Artifact a) {
+        if (project != null) {
+            Element dependencies = project.getChild("dependencies", project.getNamespace());
+            if (dependencies != null) {
+                for (Element dependency : dependencies.getChildren("dependency", dependencies.getNamespace())) {
+                    if (equalsGATC(a, dependency)) {
+                        JDomUtils.removeChildAndItsCommentFromContent(dependencies, dependency);
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomPomTransformer.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomPomTransformer.java
new file mode 100644
index 0000000000..c0f0971277
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomPomTransformer.java
@@ -0,0 +1,167 @@
+/*
+ * 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.cling.invoker.mvnup.jdom;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import org.eclipse.aether.artifact.Artifact;
+import org.jdom2.Document;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Construction to accept collection of artifacts, and applies it to some POM based on provided transformations.
+ */
+public final class JDomPomTransformer {
+
+    /**
+     * Removes empty remnant tags, like {@code <plugins />}.
+     */
+    private static final Consumer<JDomTransformationContext.JDomPomTransformationContext> REMOVE_EMPTY_ELEMENTS =
+            ctx -> {
+                JDomPomCleanupHelper.cleanup(ctx.getDocument().getRootElement());
+            };
+
+    private static Consumer<JDomTransformationContext.JDomPomTransformationContext> setProperty(
+            String key, String value, boolean upsert) {
+        return context -> JDomPomEditor.setProperty(context.getDocument().getRootElement(), key, value, upsert);
+    }
+
+    public static Function<Artifact, Consumer<JDomTransformationContext.JDomPomTransformationContext>>
+            updateManagedPlugin(boolean upsert) {
+        return a -> context ->
+                JDomPomEditor.updateManagedPlugin(context.getDocument().getRootElement(), a, upsert);
+    }
+
+    public static Function<Artifact, Consumer<JDomTransformationContext.JDomPomTransformationContext>>
+            deleteManagedPlugin() {
+        return a -> context -> {
+            JDomPomEditor.deleteManagedPlugin(context.getDocument().getRootElement(), a);
+            context.registerPostTransformation(REMOVE_EMPTY_ELEMENTS);
+        };
+    }
+
+    public static Function<Artifact, Consumer<JDomTransformationContext.JDomPomTransformationContext>> updatePlugin(
+            boolean upsert) {
+        return a -> context -> JDomPomEditor.updatePlugin(context.getDocument().getRootElement(), a, upsert);
+    }
+
+    public static Function<Artifact, Consumer<JDomTransformationContext.JDomPomTransformationContext>> deletePlugin() {
+        return a -> context -> {
+            JDomPomEditor.deletePlugin(context.getDocument().getRootElement(), a);
+            context.registerPostTransformation(REMOVE_EMPTY_ELEMENTS);
+        };
+    }
+
+    public static Function<Artifact, Consumer<JDomTransformationContext.JDomPomTransformationContext>>
+            deletePluginVersion() {
+        return a -> context -> {
+            JDomPomEditor.deletePluginVersion(context.getDocument().getRootElement(), a);
+        };
+    }
+
+    public static Function<Artifact, Consumer<JDomTransformationContext.JDomPomTransformationContext>>
+            updateManagedDependency(boolean upsert) {
+        return a -> context ->
+                JDomPomEditor.updateManagedDependency(context.getDocument().getRootElement(), a, upsert);
+    }
+
+    public static Function<Artifact, Consumer<JDomTransformationContext.JDomPomTransformationContext>>
+            deleteManagedDependency() {
+        return a -> context -> {
+            JDomPomEditor.deleteManagedDependency(context.getDocument().getRootElement(), a);
+            context.registerPostTransformation(REMOVE_EMPTY_ELEMENTS);
+        };
+    }
+
+    public static Function<Artifact, Consumer<JDomTransformationContext.JDomPomTransformationContext>> updateDependency(
+            boolean upsert) {
+        return a ->
+                context -> JDomPomEditor.updateDependency(context.getDocument().getRootElement(), a, upsert);
+    }
+
+    public static Function<Artifact, Consumer<JDomTransformationContext.JDomPomTransformationContext>>
+            deleteDependency() {
+        return a -> context -> {
+            JDomPomEditor.deleteDependency(context.getDocument().getRootElement(), a);
+            context.registerPostTransformation(REMOVE_EMPTY_ELEMENTS);
+        };
+    }
+
+    public static Function<Artifact, Consumer<JDomTransformationContext.JDomPomTransformationContext>> updateExtension(
+            boolean upsert) {
+        return a ->
+                context -> JDomPomEditor.updateExtension(context.getDocument().getRootElement(), a, upsert);
+    }
+
+    public static Function<Artifact, Consumer<JDomTransformationContext.JDomPomTransformationContext>>
+            deleteExtension() {
+        return a -> context -> {
+            JDomPomEditor.deleteExtension(context.getDocument().getRootElement(), a);
+            context.registerPostTransformation(REMOVE_EMPTY_ELEMENTS);
+        };
+    }
+
+    private final Path pom;
+
+    public JDomPomTransformer(Path pom) {
+        this.pom = requireNonNull(pom);
+    }
+
+    public void apply(List<Consumer<JDomTransformationContext.JDomPomTransformationContext>> transformations)
+            throws IOException {
+        requireNonNull(transformations, "transformations");
+        if (!transformations.isEmpty()) {
+            try (JDomDocumentIO domDocumentIO = new JDomDocumentIO(pom)) {
+                ArrayList<Consumer<JDomTransformationContext.JDomPomTransformationContext>> postProcessors =
+                        new ArrayList<>();
+                JDomTransformationContext.JDomPomTransformationContext context =
+                        new JDomTransformationContext.JDomPomTransformationContext() {
+                            @Override
+                            public Document getDocument() {
+                                return domDocumentIO.getDocument();
+                            }
+
+                            @Override
+                            public void registerPostTransformation(
+                                    Consumer<JDomTransformationContext.JDomPomTransformationContext> transformation) {
+                                postProcessors.add(transformation);
+                            }
+
+                            @Override
+                            public Path pom() {
+                                return pom;
+                            }
+                        };
+                for (Consumer<JDomTransformationContext.JDomPomTransformationContext> transformation :
+                        transformations) {
+                    transformation.accept(context);
+                }
+                for (Consumer<JDomTransformationContext.JDomPomTransformationContext> transformation : postProcessors) {
+                    transformation.accept(context);
+                }
+            }
+        }
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomTransformationContext.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomTransformationContext.java
new file mode 100644
index 0000000000..e4a7600838
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomTransformationContext.java
@@ -0,0 +1,52 @@
+/*
+ * 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.cling.invoker.mvnup.jdom;
+
+import java.nio.file.Path;
+import java.util.function.Consumer;
+
+import org.jdom2.Document;
+
+/**
+ * The transformation context.
+ */
+public interface JDomTransformationContext {
+    /**
+     * The document,
+     */
+    Document getDocument();
+
+    interface JDomPomTransformationContext extends JDomTransformationContext {
+        /**
+         * Hook for post processor registration.
+         */
+        void registerPostTransformation(Consumer<JDomPomTransformationContext> transformation);
+
+        Path pom();
+    }
+
+    interface JdomExtensionsTransformationContext extends JDomTransformationContext {
+        /**
+         * Hook for post processor registration.
+         */
+        void registerPostTransformation(Consumer<JdomExtensionsTransformationContext> transformation);
+
+        Path extensions();
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomUtils.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomUtils.java
new file mode 100644
index 0000000000..b21fcd813b
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/JDomUtils.java
@@ -0,0 +1,552 @@
+/*
+ * 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.cling.invoker.mvnup.jdom;
+
+/*
+ * 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.
+ */
+
+import java.util.Iterator;
+import java.util.List;
+
+import org.codehaus.plexus.util.StringUtils;
+import org.jdom2.Content;
+import org.jdom2.Element;
+import org.jdom2.Parent;
+import org.jdom2.Text;
+import org.jdom2.filter.ElementFilter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static java.lang.Math.max;
+import static org.jdom2.filter.Filters.textOnly;
+
+/**
+ * Common JDom functions
+ *
+ * @author Robert Scholte (for <a href="https://github.com/apache/maven-release/">Maven Release projct</a>, version 3.0)
+ * @author Marc Rohlfs, CoreMedia AG
+ */
+public final class JDomUtils {
+
+    private static final Logger LOG = LoggerFactory.getLogger(JDomUtils.class);
+
+    private JDomUtils() {
+        // noop
+    }
+
+    /**
+     * Adds an element as new child to the given root element. The position where the element is inserted is calculated
+     * using the element order that is defined in the {@link JDomCfg} (see {@link JDomCfg#getElementOrder(String)}). When
+     * no order is defined for the element, the new element is append as last element (before the closing tag of the
+     * root element). In the root element, the new element is always prepended by a text element containing a linebreak
+     * followed by the indentation characters. The indentation of the new element nodes will be reset to the indentation
+     * that is (tried to be) detected from the root element (see {@link #detectIndentation(Element)}).
+     *
+     * @param element the name of the new element.
+     * @param root    the root element.
+     */
+    public static void addElement(JDomCfg jDomCfg, Element element, Element root) {
+        addElement(jDomCfg, element, root, calcNewElementIndex(jDomCfg, element.getName(), root));
+    }
+
+    /**
+     * Inserts a new child element to the given root element at the given index.
+     * For details see {@link #addElement(JDomCfg, Element, Element)}
+     *
+     * @param element the element to add.
+     * @param root    the root element.
+     * @param index   the index where the element should be inserted.
+     */
+    public static void addElement(JDomCfg jDomCfg, Element element, Element root, int index) {
+        root.addContent(index, element);
+
+        String prependingElementName = ((Element) root.getContent(max(0, index - 1))).getName();
+        if (isBlankLineBetweenElements(jDomCfg, prependingElementName, element.getName(), root)) {
+            root.addContent(index, new Text("\n\n" + detectIndentation(root)));
+        } else {
+            root.addContent(index, new Text("\n" + detectIndentation(root)));
+        }
+
+        resetIndentations(root, detectIndentation(root));
+        resetIndentations(element, detectIndentation(root) + "  ");
+    }
+
+    public static int getElementIndex(Element element, Element root) {
+        return root.indexOf(element);
+    }
+
+    private static int getLastElementIndex(Element root) {
+        List<Element> elements = root.getContent(new ElementFilter());
+
+        int size = elements.size();
+        return size > 0 ? root.indexOf(elements.get(size - 1)) : -1;
+    }
+
+    /**
+     * Inserts a new child element to the given root element. The position where the element is inserted is calculated
+     * using the element order that is defined in the {@link JDomCfg} (see {@link JDomCfg#getElementOrder(String)}).
+     * When no order is defined for the element, the new element is append as last element (before the closing tag of the
+     * root element). In the root element, the new element is always prepended by a text element containing a linebreak
+     * followed by the indentation characters. The indentation characters are (tried to be) detected from the root element
+     * (see {@link #detectIndentation(Element)} ).
+     *
+     * @param name the name of the new element.
+     * @param root the root element.
+     * @return the new element.
+     */
+    public static Element insertNewElement(JDomCfg jDomCfg, String name, Element root) {
+        return insertNewElement(jDomCfg, name, root, calcNewElementIndex(jDomCfg, name, root));
+    }
+
+    /**
+     * Inserts a new child element to the given root element at the given index.
+     * For details see {@link #insertNewElement(JDomCfg, String, Element)}
+     *
+     * @param name  the name of the new element.
+     * @param root  the root element.
+     * @param index the index where the element should be inserted.
+     * @return the new element.
+     */
+    public static Element insertNewElement(JDomCfg jDomCfg, String name, Element root, int index) {
+        Element newElement;
+
+        String indent = detectIndentation(root);
+
+        newElement = new Element(name, root.getNamespace());
+        newElement.addContent("\n" + indent);
+        root.addContent(index, newElement);
+
+        String prependingElementName = ((Element) root.getContent(max(0, index - 1))).getName();
+        if (isBlankLineBetweenElements(jDomCfg, prependingElementName, name, root)) {
+            root.addContent(index, new Text("\n\n" + indent));
+        } else {
+            root.addContent(index, new Text("\n" + indent));
+        }
+
+        return newElement;
+    }
+
+    /**
+     * Inserts nested elements of the given tags into jdomParent.
+     *
+     * @param jdomParent the parent element
+     * @param tags       the names of the new elements
+     * @return the innermost element
+     */
+    public static Element insertNewNestedElements(JDomCfg jDomCfg, Element jdomParent, String... tags) {
+        for (String tag : tags) {
+            jdomParent = insertNewElement(jDomCfg, tag, jdomParent);
+        }
+        return jdomParent;
+    }
+
+    /**
+     * Inserts an element with text, like &lt;version&gt;1.2.3&lt;/version&gt;
+     *
+     * @param jdomParent the parent element
+     * @param tag        the name of the new element
+     * @param text       the content of the new element
+     */
+    public static void insertContentElement(JDomCfg jDomCfg, Element jdomParent, String tag, String text) {
+        if (text != null) {
+            Element jdomVersion = insertNewElement(jDomCfg, tag, jdomParent);
+            jdomVersion.setContent(new Text(text));
+        }
+    }
+
+    private static int calcNewElementIndex(JDomCfg jDomCfg, String name, Element root) {
+        int addIndex = 0;
+
+        List<String> elementOrder = jDomCfg.getElementOrder(root.getName());
+        if (elementOrder == null) {
+            addIndex = max(0, getLastElementIndex(root) + 1);
+        } else {
+            for (int i = elementOrder.indexOf(name) - 1; i >= 0; i--) {
+                String addAfterElementName = elementOrder.get(i);
+                if (!addAfterElementName.isEmpty()) {
+                    Element addAfterElement = root.getChild(addAfterElementName, root.getNamespace());
+                    if (addAfterElement != null) {
+                        addIndex = root.indexOf(addAfterElement) + 1;
+                        break;
+                    }
+                }
+            }
+        }
+
+        return addIndex;
+    }
+
+    private static boolean isBlankLineBetweenElements(JDomCfg jDomCfg, String element1, String element2, Element root) {
+        List<String> elementOrder = jDomCfg.getElementOrder(root.getName());
+        if (elementOrder != null) {
+            return elementOrder
+                    .subList(elementOrder.indexOf(element1), elementOrder.indexOf(element2))
+                    .contains("");
+        }
+        return false;
+    }
+
+    /**
+     * Tries to detect the indentation that is used within the given element and returns it.
+     * <p>
+     * The method actually returns all characters (supposed to be whitespaces) that occur after the last linebreak in a
+     * text element.
+     *
+     * @param element the element whose contents should be used to detect the indentation.
+     * @return the detected indentation or {@code null} if not indentation can be detected.
+     */
+    public static String detectIndentation(Element element) {
+
+        for (Iterator<Text> iterator = element.getContent(textOnly()).iterator(); iterator.hasNext(); ) {
+            String text = iterator.next().getText();
+            int lastLsIndex = StringUtils.lastIndexOfAny(text, new String[] {"\n", "\r"});
+            if (lastLsIndex > -1) {
+                String indent = text.substring(lastLsIndex + 1);
+                if (iterator.hasNext()) {
+                    // This should be the indentation of a child element.
+                    return indent;
+                } else {
+                    // This should be the indentation of the elements end tag.
+                    return indent + "  ";
+                }
+            }
+        }
+
+        Parent parent = element.getParent();
+        if (parent instanceof Element) {
+            return detectIndentation((Element) parent) + "  ";
+        }
+
+        return "";
+    }
+
+    /**
+     * Creates a new element with the given name. The new element has the same namespace and the same indentation (before)
+     * its closing tag) like the given parent element, but is not yet attached to it.
+     *
+     * @param name   the name of the new e.lement
+     * @param parent the parent element.
+     * @return the new element.
+     */
+    public static Element newDetachedElement(String name, Element parent) {
+        Element newElement = new Element(name, parent.getNamespace());
+        newElement.addContent("\n" + detectIndentation(parent));
+        return newElement;
+    }
+
+    /**
+     * Returns the given elements child element with the specified name.
+     *
+     * @param name   the name of the child element.
+     * @param parent the parent of the requested element - must not be {@code null}.
+     * @return the requested element or {@code null}.
+     */
+    public static Element getChildElement(String name, Element parent) {
+        return parent.getChild(name, parent.getNamespace());
+    }
+
+    /**
+     * Returns the trimmed text value of the given elements child element with the specified name.
+     *
+     * @param name   the name of the child element.
+     * @param parent the parent of the element whose text value is requested - must not be {@code null}.
+     * @return the trimmed text value of the element or {@code null}.
+     */
+    public static String getChildElementTextTrim(String name, Element parent) {
+        Element child = getChildElement(name, parent);
+        if (child == null) {
+            return null;
+        } else {
+            String text = child.getTextTrim();
+            return "null".equals(text) ? null : text;
+        }
+    }
+
+    /**
+     * Remove a child element from the parent.
+     *
+     * @param parent      the parent element.
+     * @param removeChild the child element to be removed.
+     */
+    public static void removeChildElement(Element parent, Element removeChild) {
+        int index = parent.indexOf(removeChild);
+        parent.removeContent(index--);
+        if (index >= 0 && parent.getContent(index) instanceof Text) {
+            // Remove prepending whitespaces (linebreaks and indentation)
+            parent.removeContent(index);
+        }
+        removeChild.detach();
+    }
+
+    /**
+     * Remove a child of type {@link Content} and its attached comments from the parent.
+     *
+     * @param parent      the parent element.
+     * @param removeChild the child content to be removed.
+     */
+    public static void removeChildAndItsCommentFromContent(Element parent, Content removeChild) {
+        int index = parent.indexOf(removeChild);
+        if (index >= 0) {
+            LOG.debug("");
+            LOG.debug("index [{}] => REMOVE: {}", index, JDomContentHelper.contentAsString(parent.getContent(index)));
+            parent.removeContent(index);
+            index--;
+            Content elementToCheck = JDomContentHelper.getContentWithIndex(index, parent);
+            // remove new line
+            if (JDomContentHelper.isNewline(elementToCheck) && simpleRemoveAtIndex(index, parent)) {
+                int newIndex = index - 1;
+                int prevIndex;
+                do {
+                    prevIndex = newIndex;
+                    newIndex = removeContentAtIndexIfContentIsComment(newIndex, parent);
+                } while (newIndex >= 0 && newIndex != prevIndex);
+            } else if (JDomContentHelper.isMultiNewLine(elementToCheck)) {
+                removeFirstNewLineFromMultiline(parent.indexOf(elementToCheck), parent);
+            }
+            // Now detach removed child
+            removeChild.detach();
+        }
+    }
+
+    /**
+     * Remove and detach content at index from parent.
+     *
+     * @param index  the index of the content
+     * @param parent the parent of the content
+     */
+    private static boolean simpleRemoveAtIndex(int index, Element parent) {
+        if (!JDomContentHelper.isIndexValid(index, parent)) {
+            return false;
+        }
+        Content contentToRemove = parent.getContent(index);
+        LOG.debug(
+                "remove content => {} from parent tag: <{}>",
+                JDomContentHelper.contentAsString(contentToRemove),
+                parent.getName());
+        parent.removeContent(index);
+        contentToRemove.detach();
+        return true;
+    }
+
+    /**
+     * Remove and detach content.
+     *
+     * @param content the content to remove
+     */
+    static void simpleRemoveAtIndex(Content content) {
+        Element parent = content.getParentElement();
+        int index = parent.indexOf(content);
+        Content contentToRemove = parent.getContent(index);
+        LOG.debug(
+                "remove content => {} from parent tag: <{}>",
+                JDomContentHelper.contentAsString(contentToRemove),
+                parent.getName());
+        parent.removeContent(index);
+        contentToRemove.detach();
+    }
+
+    /**
+     * Remove first newline from multiline
+     *
+     * @param index  the index of the multiline content
+     * @param parent the parent of the multiline content
+     */
+    private static void removeFirstNewLineFromMultiline(int index, Element parent) {
+        if (!JDomContentHelper.isIndexValid(index, parent)) {
+            return;
+        }
+        Content contentToRemove = parent.getContent(index);
+        LOG.debug("       Content to remove  : {}", JDomContentHelper.contentAsString(contentToRemove));
+
+        // Remove first newline
+        String text = contentToRemove.getValue().replaceFirst("\n", "");
+
+        // Remove indentation if
+        // * predecessor has no newlines or
+        // * successor has newlines
+        Content predecessor = JDomContentHelper.getPredecessorOfContentWithIndex(index, parent);
+        Content successor = JDomContentHelper.getSuccessorOfContentWithIndex(index, parent);
+        if (JDomContentHelper.hasNewlines(successor)
+                || (predecessor != null && !JDomContentHelper.hasNewlines(predecessor))) {
+            // remove indentation
+            text = text.replaceAll(" ", "");
+            LOG.debug("       Replaced intention : {}", text);
+        }
+
+        // Remove multiline content
+        simpleRemoveAtIndex(index, parent);
+
+        // Add new Text content
+        Text replacement = new Text(text);
+        parent.addContent(index, replacement);
+        LOG.debug("       Content replacement: {}", JDomContentHelper.contentAsString(replacement));
+    }
+
+    /**
+     * Remove comment at index position from parent.<br>
+     * The return value is:
+     * <ul>
+     * <li><b>-1</b>: Index is not valid</li>
+     * <li><b>-2</b>: Comment has been removed but there is no predecessor</li>
+     * <li><b>-3</b>: Comment has been removed but the predecessor is a multi newline (whose first line is removed as well and its indentation - if necessary)</li>
+     * <li><b>index</b>: The same index if content at this index is no comment</li>
+     * <li><b>index - 1</b>: Content at the given index is a comment and there is no new newline predecessor</li>
+     * <li><b>index - 2</b>: Content at the given index is a comment and there is one new newline predecessor (which is removed as well)</li>
+     * </ul>
+     *
+     * @param index  the index of the content to check
+     * @param parent the parent of the content
+     * @return int the new index
+     */
+    private static int removeContentAtIndexIfContentIsComment(int index, Element parent) {
+        if (!JDomContentHelper.isIndexValid(index, parent)) {
+            return -1;
+        }
+        Content content = parent.getContent(index);
+        if (!JDomContentHelper.isComment(content)) {
+            LOG.debug("Content at index {} is no comment", index);
+            return index;
+        }
+
+        // remove comment
+        simpleRemoveAtIndex(index, parent);
+
+        // get predecessor
+        int prevIndex = index - 1;
+        if (prevIndex < 0) {
+            return -2;
+        }
+        Content predecessor = JDomContentHelper.getPredecessorOfContentWithIndex(index, parent);
+        if (JDomContentHelper.isNewline(predecessor)) {
+            simpleRemoveAtIndex(prevIndex, parent);
+            return prevIndex - 1;
+        } else if (JDomContentHelper.isMultiNewLine(predecessor)) {
+            removeFirstNewLineFromMultiline(prevIndex, parent);
+            return -3;
+        }
+        return prevIndex;
+    }
+
+    /**
+     * Resets the XML indentations of an element.
+     *
+     * @param element the element whose indentations should be reset.
+     * @param indent  the indentation to be used.
+     */
+    public static void resetIndentations(Element element, String indent) {
+        List<Content> childContents = element.getContent();
+        for (int i = 1; i < childContents.size(); i++) {
+            Content childContent = childContents.get(i);
+            if (childContent instanceof Element) {
+                Element childElement = (Element) childContent;
+
+                // Reset indentations of child elements.
+                resetIndentation(childContents.get(i - 1), indent);
+
+                // Reset indentations of before closing tags of child elements.
+                List<Content> grandChildElements = childElement.getContent();
+                if (grandChildElements.size() > 1) {
+                    resetIndentation(grandChildElements.get(grandChildElements.size() - 1), indent);
+                }
+            }
+        }
+    }
+
+    private static void resetIndentation(Content whitespaceContentBeforeElement, String indent) {
+        if (whitespaceContentBeforeElement instanceof Text) {
+            Text whitespaceTextContent = (Text) whitespaceContentBeforeElement;
+            String whitespaces = whitespaceTextContent.getText();
+            int lastLsIndex = StringUtils.lastIndexOfAny(whitespaces, new String[] {"\n", "\r"});
+            whitespaceTextContent.setText("\n" + whitespaces.substring(0, lastLsIndex) + indent);
+        }
+    }
+
+    /**
+     * Updates the text value of the given element. The primary purpose of this method is to preserve any whitespace and
+     * comments around the original text value.
+     *
+     * @param element The element to update, must not be <code>null</code>.
+     * @param value   The text string to set, must not be <code>null</code>.
+     */
+    public static void rewriteValue(Element element, String value) {
+        Text text = null;
+        if (element.getContent() != null) {
+            for (Iterator<?> it = element.getContent().iterator(); it.hasNext(); ) {
+                Object content = it.next();
+                if ((content instanceof Text) && !((Text) content).getTextTrim().isEmpty()) {
+                    text = (Text) content;
+                    while (it.hasNext()) {
+                        content = it.next();
+                        if (content instanceof Text) {
+                            text.append((Text) content);
+                            it.remove();
+                        } else {
+                            break;
+                        }
+                    }
+                    break;
+                }
+            }
+        }
+        if (text == null) {
+            element.addContent(value);
+        } else {
+            String chars = text.getText();
+            String trimmed = text.getTextTrim();
+            int idx = chars.indexOf(trimmed);
+            String leadingWhitespace = chars.substring(0, idx);
+            String trailingWhitespace = chars.substring(idx + trimmed.length());
+            text.setText(leadingWhitespace + value + trailingWhitespace);
+        }
+    }
+
+    public static Element rewriteElement(JDomCfg jDomCfg, String name, String value, Element root) {
+        Element tagElement = root.getChild(name, root.getNamespace());
+        if (tagElement != null) {
+            if (value != null) {
+                rewriteValue(tagElement, value);
+            } else {
+                JDomUtils.removeChildAndItsCommentFromContent(root, tagElement);
+            }
+        } else {
+            if (value != null) {
+                Element element = insertNewElement(jDomCfg, name, root);
+                element.setText(value);
+                tagElement = element;
+            }
+        }
+        return tagElement;
+    }
+}
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/Sink.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/Sink.java
new file mode 100644
index 0000000000..8bcc36e9b4
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/Sink.java
@@ -0,0 +1,67 @@
+/*
+ * 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.cling.invoker.mvnup.jdom;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Collection;
+import java.util.stream.Stream;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Construct to accept collection of things.
+ *
+ * @param <T> the type of objects accepted in this Sink
+ */
+@FunctionalInterface
+public interface Sink<T> extends AutoCloseable {
+    void accept(T thing) throws IOException;
+
+    default void accept(Stream<T> stream) throws IOException {
+        try {
+            stream.forEach(t -> {
+                try {
+                    accept(t);
+                } catch (IOException e) {
+                    throw new UncheckedIOException(e);
+                }
+            });
+        } catch (UncheckedIOException e) {
+            throw e.getCause();
+        }
+    }
+
+    default void accept(Collection<T> things) throws IOException {
+        requireNonNull(things, "things");
+        try {
+            for (T thing : things) {
+                accept(thing);
+            }
+        } catch (IOException e) {
+            cleanup(e);
+            throw e;
+        }
+    }
+
+    default void cleanup(Exception e) {}
+
+    @Override
+    default void close() throws Exception {}
+}
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/Source.java
similarity index 56%
copy from api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/Source.java
index ce566d2710..07f7723e6d 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/Source.java
@@ -16,27 +16,20 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.maven.api.cli;
+package org.apache.maven.cling.invoker.mvnup.jdom;
 
-import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Immutable;
+import java.io.IOException;
+import java.util.stream.Stream;
 
 /**
- * Represents most common tools supported by CLIng.
+ * Construction to supply collection of things.
  *
- * @since 4.0.0
+ * @param <T> the type of objects produced by this Source
  */
-@Immutable
-@Experimental
-public final class Tools {
-    private Tools() {}
+@FunctionalInterface
+public interface Source<T> extends AutoCloseable {
+    Stream<T> get() throws IOException;
 
-    public static final String MVN_CMD = "mvn";
-    public static final String MVN_NAME = "Maven";
-
-    public static final String MVNENC_CMD = "mvnenc";
-    public static final String MVNENC_NAME = "Maven Password Encrypting Tool";
-
-    public static final String MVNSHELL_CMD = "mvnsh";
-    public static final String MVNSHELL_NAME = "Maven Shell Tool";
+    @Override
+    default void close() throws Exception {}
 }
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/package-info.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/package-info.java
new file mode 100644
index 0000000000..e27c1114c4
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/jdom/package-info.java
@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) 2023-2024 Maveniverse Org.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+/**
+ * This package contains sources from <a href="https://github.com/apache/maven-release/">Maven Release project</a>
+ * and improved codebase from <a href="https://github.com/CoreMedia/maven-jdom-parser">Maven JDom Parser</a>.
+ *
+ * @author Robert Scholte (for <a href="https://github.com/apache/maven-release/">Maven Release project</a>)
+ * @author Marc Rohlfs, CoreMedia AG
+ */
+package org.apache.maven.cling.invoker.mvnup.jdom;
diff --git a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/package-info.java
similarity index 57%
copy from api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
copy to impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/package-info.java
index ce566d2710..b2d580c977 100644
--- a/api/maven-api-cli/src/main/java/org/apache/maven/api/cli/Tools.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/package-info.java
@@ -16,27 +16,13 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.maven.api.cli;
-
-import org.apache.maven.api.annotations.Experimental;
-import org.apache.maven.api.annotations.Immutable;
 
 /**
- * Represents most common tools supported by CLIng.
+ * Implementation of the Maven Upgrade tool ({@code mvnup}).
+ *
+ * <p>This package contains the implementation classes for the Maven upgrade tool,
+ * providing functionality for upgrading Maven projects and dependencies.</p>
  *
  * @since 4.0.0
  */
-@Immutable
-@Experimental
-public final class Tools {
-    private Tools() {}
-
-    public static final String MVN_CMD = "mvn";
-    public static final String MVN_NAME = "Maven";
-
-    public static final String MVNENC_CMD = "mvnenc";
-    public static final String MVNENC_NAME = "Maven Password Encrypting Tool";
-
-    public static final String MVNSHELL_CMD = "mvnsh";
-    public static final String MVNSHELL_NAME = "Maven Shell Tool";
-}
+package org.apache.maven.cling.invoker.mvnup;
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/PluginUpgradeCliTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/PluginUpgradeCliTest.java
new file mode 100644
index 0000000000..c04a4e6324
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/PluginUpgradeCliTest.java
@@ -0,0 +1,193 @@
+/*
+ * 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.cling.invoker.mvnup;
+
+import org.apache.commons.cli.ParseException;
+import org.apache.maven.api.cli.mvnup.UpgradeOptions;
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for CLI parsing of plugin upgrade options.
+ * These tests verify that the --plugins option is properly parsed and handled.
+ */
+class PluginUpgradeCliTest {
+
+    @Test
+    void testPluginsOptionParsing() throws ParseException {
+        String[] args = {"apply", "--plugins"};
+        CommonsCliUpgradeOptions options = CommonsCliUpgradeOptions.parse(args);
+
+        assertTrue(options.plugins().isPresent(), "--plugins option should be present");
+        assertTrue(options.plugins().get(), "--plugins option should be true");
+    }
+
+    @Test
+    void testAllOptionParsing() throws ParseException {
+        String[] args = {"apply", "--all"};
+        CommonsCliUpgradeOptions options = CommonsCliUpgradeOptions.parse(args);
+
+        assertTrue(options.all().isPresent(), "--all option should be present");
+        assertTrue(options.all().get(), "--all option should be true");
+    }
+
+    @Test
+    void testCombinedOptionsWithPlugins() throws ParseException {
+        String[] args = {"apply", "--model", "4.1.0", "--infer", "--fix-model", "--plugins"};
+        CommonsCliUpgradeOptions options = CommonsCliUpgradeOptions.parse(args);
+
+        assertTrue(options.model().isPresent(), "--model option should be present");
+        assertEquals("4.1.0", options.model().get(), "--model should be 4.1.0");
+
+        assertTrue(options.infer().isPresent(), "--infer option should be present");
+        assertTrue(options.infer().get(), "--infer option should be true");
+
+        assertTrue(options.fixModel().isPresent(), "--fix-model option should be present");
+        assertTrue(options.fixModel().get(), "--fix-model option should be true");
+
+        assertTrue(options.plugins().isPresent(), "--plugins option should be present");
+        assertTrue(options.plugins().get(), "--plugins option should be true");
+    }
+
+    @Test
+    void testNoPluginsOptionByDefault() throws ParseException {
+        String[] args = {"apply"};
+        CommonsCliUpgradeOptions options = CommonsCliUpgradeOptions.parse(args);
+
+        assertFalse(options.plugins().isPresent(), "--plugins option should not be present by default");
+    }
+
+    @Test
+    void testPluginsOptionWithOtherFlags() throws ParseException {
+        String[] args = {"check", "--plugins", "--directory", "/some/path"};
+        CommonsCliUpgradeOptions options = CommonsCliUpgradeOptions.parse(args);
+
+        assertTrue(options.plugins().isPresent(), "--plugins option should be present");
+        assertTrue(options.plugins().get(), "--plugins option should be true");
+
+        assertTrue(options.directory().isPresent(), "--directory option should be present");
+        assertEquals("/some/path", options.directory().get(), "--directory should be /some/path");
+    }
+
+    @Test
+    void testGoalsParsing() throws ParseException {
+        String[] args = {"apply", "--plugins"};
+        CommonsCliUpgradeOptions options = CommonsCliUpgradeOptions.parse(args);
+
+        assertTrue(options.goals().isPresent(), "Goals should be present");
+        assertEquals(1, options.goals().get().size(), "Should have one goal");
+        assertEquals("apply", options.goals().get().get(0), "Goal should be 'apply'");
+    }
+
+    @Test
+    void testCheckGoalWithPlugins() throws ParseException {
+        String[] args = {"check", "--plugins"};
+        CommonsCliUpgradeOptions options = CommonsCliUpgradeOptions.parse(args);
+
+        assertTrue(options.goals().isPresent(), "Goals should be present");
+        assertEquals("check", options.goals().get().get(0), "Goal should be 'check'");
+
+        assertTrue(options.plugins().isPresent(), "--plugins option should be present");
+        assertTrue(options.plugins().get(), "--plugins option should be true");
+    }
+
+    @Test
+    void testAllOptionImpliesPlugins() throws ParseException {
+        // This test verifies that when --all is used, the logic should enable plugins
+        // The actual logic is in BaseUpgradeGoal, but we can test the option parsing here
+        String[] args = {"apply", "--all"};
+        CommonsCliUpgradeOptions options = CommonsCliUpgradeOptions.parse(args);
+
+        assertTrue(options.all().isPresent(), "--all option should be present");
+        assertTrue(options.all().get(), "--all option should be true");
+
+        // The plugins option itself won't be set, but the logic in BaseUpgradeGoal
+        // should treat --all as enabling plugins
+        assertFalse(options.plugins().isPresent(), "--plugins option should not be explicitly set when using --all");
+    }
+
+    @Test
+    void testLongFormPluginsOption() throws ParseException {
+        String[] args = {"apply", "--plugins"};
+        CommonsCliUpgradeOptions options = CommonsCliUpgradeOptions.parse(args);
+
+        assertTrue(options.plugins().isPresent(), "Long form --plugins option should be present");
+        assertTrue(options.plugins().get(), "Long form --plugins option should be true");
+    }
+
+    @Test
+    void testInvalidCombinationStillParses() throws ParseException {
+        // Even if the combination doesn't make logical sense, the CLI should parse it
+        String[] args = {"apply", "--all", "--plugins", "--infer"};
+        CommonsCliUpgradeOptions options = CommonsCliUpgradeOptions.parse(args);
+
+        assertTrue(options.all().isPresent(), "--all option should be present");
+        assertTrue(options.plugins().isPresent(), "--plugins option should be present");
+        assertTrue(options.infer().isPresent(), "--infer option should be present");
+    }
+
+    @Test
+    void testHelpDisplayIncludesPluginsOption() throws ParseException {
+        // Test that help text includes the plugins option
+        String[] args = {"help"};
+        CommonsCliUpgradeOptions options = CommonsCliUpgradeOptions.parse(args);
+        assertNotNull(options);
+
+        // We can't easily test the help output directly, but we can verify
+        // that the option is properly configured by checking if it parses
+        String[] pluginsArgs = {"apply", "--plugins"};
+        CommonsCliUpgradeOptions pluginsOptions = CommonsCliUpgradeOptions.parse(pluginsArgs);
+
+        assertTrue(pluginsOptions.plugins().isPresent(), "Plugins option should be properly configured");
+    }
+
+    @Test
+    void testEmptyArgsDefaultBehavior() throws ParseException {
+        // Test that empty args (except for goal) work correctly
+        String[] args = {"apply"};
+        CommonsCliUpgradeOptions options = CommonsCliUpgradeOptions.parse(args);
+
+        // None of the optional flags should be present
+        assertFalse(options.plugins().isPresent(), "--plugins should not be present by default");
+        assertFalse(options.all().isPresent(), "--all should not be present by default");
+        assertFalse(options.infer().isPresent(), "--infer should not be present by default");
+        assertFalse(options.fixModel().isPresent(), "--fix-model should not be present by default");
+        assertFalse(options.model().isPresent(), "--model should not be present by default");
+
+        // But the goal should be present
+        assertTrue(options.goals().isPresent(), "Goals should be present");
+        assertEquals("apply", options.goals().get().get(0), "Goal should be 'apply'");
+    }
+
+    @Test
+    void testInterpolationWithPluginsOption() throws ParseException {
+        String[] args = {"apply", "--plugins"};
+        CommonsCliUpgradeOptions options = CommonsCliUpgradeOptions.parse(args);
+
+        // Test that interpolation works (even though there's nothing to interpolate here)
+        UpgradeOptions interpolated = options.interpolate(s -> s);
+
+        assertTrue(interpolated.plugins().isPresent(), "Interpolated options should preserve --plugins");
+        assertTrue(interpolated.plugins().get(), "Interpolated --plugins should be true");
+    }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/ApplyTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/ApplyTest.java
new file mode 100644
index 0000000000..09d749f955
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/ApplyTest.java
@@ -0,0 +1,379 @@
+/*
+ * 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.cling.invoker.mvnup.goals;
+
+import java.io.StringReader;
+import java.util.Map;
+
+import org.jdom2.Document;
+import org.jdom2.Element;
+import org.jdom2.Namespace;
+import org.jdom2.input.SAXBuilder;
+import org.junit.jupiter.api.BeforeEach;
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for Apply goal methods.
+ * These tests focus on XML transformation logic by modifying Document objects in memory.
+ * The key insight is that we can test the same code paths used by both apply and check modes
+ * by modifying Documents in memory without writing to disk.
+ */
+class ApplyTest {
+
+    private BaseUpgradeGoal upgrade;
+    private SAXBuilder saxBuilder;
+
+    @BeforeEach
+    void setUp() {
+        upgrade = new Apply();
+        saxBuilder = new SAXBuilder();
+    }
+
+    @Test
+    void testUpdateNamespaceRecursively() throws Exception {
+        // Test recursive namespace update - core functionality used by both apply and check
+        String pomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.0.0">
+                <modelVersion>4.0.0</modelVersion>
+                <dependencies>
+                    <dependency>
+                        <groupId>test</groupId>
+                        <artifactId>test</artifactId>
+                        <version>1.0.0</version>
+                    </dependency>
+                </dependencies>
+            </project>
+            """;
+
+        Document document = saxBuilder.build(new StringReader(pomXml));
+        Element root = document.getRootElement();
+
+        Namespace newNamespace = Namespace.getNamespace("http://maven.apache.org/POM/4.1.0");
+
+        upgrade.updateNamespaceRecursively(root, newNamespace);
+
+        // Verify root namespace updated
+        assertEquals(newNamespace, root.getNamespace());
+
+        // Verify child elements namespace updated recursively
+        Element dependencies = root.getChild("dependencies", newNamespace);
+        assertNotNull(dependencies);
+        assertEquals(newNamespace, dependencies.getNamespace());
+
+        Element dependency = dependencies.getChild("dependency", newNamespace);
+        assertNotNull(dependency);
+        assertEquals(newNamespace, dependency.getNamespace());
+
+        Element groupId = dependency.getChild("groupId", newNamespace);
+        assertNotNull(groupId);
+        assertEquals(newNamespace, groupId.getNamespace());
+    }
+
+    @Test
+    void testConvertModulesElement() throws Exception {
+        // Test converting modules to subprojects while preserving formatting
+        String pomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <modules>
+                    <module>module1</module>
+                    <module>module2</module>
+                </modules>
+            </project>
+            """;
+
+        Document document = saxBuilder.build(new StringReader(pomXml));
+        Element root = document.getRootElement();
+        Namespace namespace = root.getNamespace();
+
+        upgrade.convertModulesElement(root, namespace);
+
+        // Verify modules element was renamed to subprojects
+        assertNull(root.getChild("modules", namespace));
+        Element subprojects = root.getChild("subprojects", namespace);
+        assertNotNull(subprojects);
+
+        // Verify module elements were renamed to subproject
+        assertEquals(0, subprojects.getChildren("module", namespace).size());
+        assertEquals(2, subprojects.getChildren("subproject", namespace).size());
+
+        assertEquals(
+                "module1",
+                subprojects.getChildren("subproject", namespace).get(0).getText());
+        assertEquals(
+                "module2",
+                subprojects.getChildren("subproject", namespace).get(1).getText());
+    }
+
+    @Test
+    void testExtractGAVWithParentInheritance() throws Exception {
+        // Test extracting GAV with parent inheritance using the available method
+        String pomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <parent>
+                    <groupId>com.example</groupId>
+                    <artifactId>parent</artifactId>
+                    <version>1.0.0</version>
+                </parent>
+                <artifactId>child</artifactId>
+            </project>
+            """;
+
+        Document document = saxBuilder.build(new StringReader(pomXml));
+
+        // Use reflection to access the protected method
+        java.lang.reflect.Method method = BaseUpgradeGoal.class.getDeclaredMethod(
+                "extractGAVWithParentResolution",
+                Document.class,
+                org.apache.maven.cling.invoker.mvnup.UpgradeContext.class);
+        method.setAccessible(true);
+
+        // We need a mock context, but for this test we can pass null since the method doesn't use it
+        BaseUpgradeGoal.GAV result = (BaseUpgradeGoal.GAV) method.invoke(upgrade, document, null);
+
+        assertNotNull(result);
+        assertEquals("com.example", result.groupId);
+        assertEquals("child", result.artifactId);
+        assertEquals("1.0.0", result.version);
+    }
+
+    @Test
+    void testCanUpgrade400To410() throws Exception {
+        boolean result = upgrade.canUpgrade("4.0.0", "4.1.0");
+        assertTrue(result);
+    }
+
+    @Test
+    void testCanUpgradeUnsupportedPath() throws Exception {
+        boolean result = upgrade.canUpgrade("4.1.0", "4.0.0");
+        assertFalse(result);
+    }
+
+    @Test
+    void testIsValidModelVersion() throws Exception {
+        assertTrue(upgrade.isValidModelVersion("4.0.0"));
+        assertTrue(upgrade.isValidModelVersion("4.1.0"));
+        assertFalse(upgrade.isValidModelVersion("3.0.0"));
+        assertFalse(upgrade.isValidModelVersion("5.0.0"));
+    }
+
+    @Test
+    void testDetectModelVersion() throws Exception {
+        // Test model version detection using the available method
+        String pomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.0.0">
+                <modelVersion>4.0.0</modelVersion>
+                <groupId>test</groupId>
+                <artifactId>test</artifactId>
+                <version>1.0.0</version>
+            </project>
+            """;
+
+        Document document = saxBuilder.build(new StringReader(pomXml));
+
+        // Use reflection to access the protected method
+        java.lang.reflect.Method method = BaseUpgradeGoal.class.getDeclaredMethod(
+                "detectModelVersion", org.apache.maven.cling.invoker.mvnup.UpgradeContext.class, Document.class);
+        method.setAccessible(true);
+
+        // We need a mock context, but for this test we can pass null since the method doesn't use it for logging
+        String result = (String) method.invoke(upgrade, null, document);
+        assertEquals("4.0.0", result);
+    }
+
+    @Test
+    void testGAVEquals() throws Exception {
+        // Create GAV instances
+        Object gav1 = new BaseUpgradeGoal.GAV("com.example", "artifact", "1.0.0");
+        Object gav2 = new BaseUpgradeGoal.GAV("com.example", "artifact", "1.0.0");
+        Object gav3 = new BaseUpgradeGoal.GAV("com.example", "artifact", "2.0.0");
+
+        assertEquals(gav1, gav2);
+        assertNotNull(gav3); // Different version should not equal
+    }
+
+    @Test
+    void testGAVMatchesIgnoringVersion() throws Exception {
+        // Create GAV instances with different versions
+        BaseUpgradeGoal.GAV gav1 = new BaseUpgradeGoal.GAV("com.example", "artifact", "1.0.0");
+        BaseUpgradeGoal.GAV gav2 = new BaseUpgradeGoal.GAV("com.example", "artifact", "2.0.0");
+
+        // Test matchesIgnoringVersion method
+        boolean result = gav1.matchesIgnoringVersion(gav2);
+        assertTrue(result);
+    }
+
+    @Test
+    void testIsModelVersionEligibleForInference() throws Exception {
+        // Test that 4.0.0+ models are eligible for inference (4.0.0 has limited support)
+        assertTrue(upgrade.isModelVersionEligibleForInference("4.0.0"));
+        assertTrue(upgrade.isModelVersionEligibleForInference("4.1.0"));
+        assertFalse(upgrade.isModelVersionEligibleForInference("3.0.0"));
+        // Future versions should also be eligible
+        // assertTrue(upgrade.isModelVersionEligibleForInference("4.2.0"));
+    }
+
+    @Test
+    void testRemoveElementWithFormatting() throws Exception {
+        // Test that removeElementWithFormatting correctly removes elements
+        String pomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0" root="true">
+                <modelVersion>4.1.0</modelVersion>
+                <groupId>com.example</groupId>
+                <artifactId>test-project</artifactId>
+                <version>1.0.0</version>
+            </project>
+            """;
+
+        Document doc = saxBuilder.build(new StringReader(pomXml));
+        Element root = doc.getRootElement();
+        Element modelVersionElement = root.getChild("modelVersion", root.getNamespace());
+
+        // Verify modelVersion exists before removal
+        assertNotNull(modelVersionElement);
+
+        // Remove the element using the formatting-preserving method
+        upgrade.removeElementWithFormatting(modelVersionElement);
+
+        // Verify modelVersion was removed
+        assertNull(root.getChild("modelVersion", root.getNamespace()));
+    }
+
+    @Test
+    void testIsSubprojectsListRedundant() throws Exception {
+        // Test that subprojects list redundancy detection works
+        String pomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <subprojects>
+                    <subproject>child1</subproject>
+                    <subproject>child2</subproject>
+                </subprojects>
+            </project>
+            """;
+
+        Document doc = saxBuilder.build(new StringReader(pomXml));
+        Element root = doc.getRootElement();
+        Element subprojectsElement = root.getChild("subprojects", root.getNamespace());
+
+        // Test with a temporary directory structure
+        java.nio.file.Path tempDir = java.nio.file.Files.createTempDirectory("mvnup-test");
+        java.nio.file.Path pomPath = tempDir.resolve("pom.xml");
+
+        try {
+            // Create child directories with pom.xml files
+            java.nio.file.Files.createDirectory(tempDir.resolve("child1"));
+            java.nio.file.Files.createFile(tempDir.resolve("child1").resolve("pom.xml"));
+            java.nio.file.Files.createDirectory(tempDir.resolve("child2"));
+            java.nio.file.Files.createFile(tempDir.resolve("child2").resolve("pom.xml"));
+
+            // Test redundancy check
+            boolean isRedundant =
+                    upgrade.isSubprojectsListRedundant(null, subprojectsElement, root.getNamespace(), pomPath);
+            assertTrue(isRedundant, "Subprojects list should be redundant when it matches directory structure");
+
+        } finally {
+            // Clean up
+            java.nio.file.Files.deleteIfExists(tempDir.resolve("child1").resolve("pom.xml"));
+            java.nio.file.Files.deleteIfExists(tempDir.resolve("child1"));
+            java.nio.file.Files.deleteIfExists(tempDir.resolve("child2").resolve("pom.xml"));
+            java.nio.file.Files.deleteIfExists(tempDir.resolve("child2"));
+            java.nio.file.Files.deleteIfExists(tempDir);
+        }
+    }
+
+    @Test
+    void testCanInferParentArtifactId() throws Exception {
+        // Test that parent artifactId inference works when parent POM is in pomMap
+        String parentPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <groupId>com.example</groupId>
+                <artifactId>parent-project</artifactId>
+                <version>1.0.0</version>
+            </project>
+            """;
+
+        String childPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <parent>
+                    <groupId>com.example</groupId>
+                    <artifactId>parent-project</artifactId>
+                    <version>1.0.0</version>
+                    <relativePath>../pom.xml</relativePath>
+                </parent>
+                <artifactId>child-project</artifactId>
+            </project>
+            """;
+
+        Document parentDoc = saxBuilder.build(new StringReader(parentPomXml));
+        Document childDoc = saxBuilder.build(new StringReader(childPomXml));
+
+        // Create pomMap with both POMs
+        Map<java.nio.file.Path, Document> pomMap = new java.util.HashMap<>();
+        java.nio.file.Path parentPath = java.nio.file.Paths.get("/project/pom.xml");
+        java.nio.file.Path childPath = java.nio.file.Paths.get("/project/child/pom.xml");
+        pomMap.put(parentPath, parentDoc);
+        pomMap.put(childPath, childDoc);
+
+        Element childRoot = childDoc.getRootElement();
+        Element parentElement = childRoot.getChild("parent", childRoot.getNamespace());
+
+        // Test that we can infer parent artifactId
+        boolean canInfer = upgrade.canInferParentArtifactId(null, parentElement, childPath, pomMap);
+        assertTrue(canInfer, "Should be able to infer parent artifactId when parent POM is in pomMap");
+    }
+
+    @Test
+    void testResolveParentPomPath() throws Exception {
+        // Test parent POM path resolution
+        java.nio.file.Path childPath = java.nio.file.Paths.get("/project/child/pom.xml");
+
+        // Test default relativePath
+        java.nio.file.Path parentPath1 = upgrade.resolveParentPomPath(childPath, "../pom.xml");
+        assertEquals(java.nio.file.Paths.get("/project/pom.xml"), parentPath1);
+
+        // Test directory relativePath
+        java.nio.file.Path parentPath2 = upgrade.resolveParentPomPath(childPath, "..");
+        assertEquals(java.nio.file.Paths.get("/project/pom.xml"), parentPath2);
+
+        // Test explicit file relativePath
+        java.nio.file.Path parentPath3 = upgrade.resolveParentPomPath(childPath, "../parent/pom.xml");
+        assertEquals(java.nio.file.Paths.get("/project/parent/pom.xml"), parentPath3);
+    }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/DependencyInferenceTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/DependencyInferenceTest.java
new file mode 100644
index 0000000000..a4094045b6
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/DependencyInferenceTest.java
@@ -0,0 +1,373 @@
+/*
+ * 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.cling.invoker.mvnup.goals;
+
+import java.io.StringReader;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.maven.api.cli.InvokerRequest;
+import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
+import org.jdom2.Document;
+import org.jdom2.Element;
+import org.jdom2.input.SAXBuilder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for dependency inference functionality in Maven upgrade tools.
+ * Tests the logic that determines when dependency information can be removed because
+ * it can be inferred from project artifacts in the same reactor.
+ */
+class DependencyInferenceTest {
+
+    private BaseUpgradeGoal upgrade;
+    private SAXBuilder saxBuilder;
+
+    @BeforeEach
+    void setUp() {
+        upgrade = new Apply();
+        saxBuilder = new SAXBuilder();
+    }
+
+    private UpgradeContext createMockContext() {
+        InvokerRequest request = Mockito.mock(InvokerRequest.class);
+
+        // Mock all required properties for LookupContext constructor
+        Mockito.when(request.cwd()).thenReturn(Paths.get("/project"));
+        Mockito.when(request.installationDirectory()).thenReturn(Paths.get("/maven"));
+        Mockito.when(request.userHomeDirectory()).thenReturn(Paths.get("/home/user"));
+        Mockito.when(request.topDirectory()).thenReturn(Paths.get("/project"));
+        Mockito.when(request.rootDirectory()).thenReturn(java.util.Optional.empty());
+        Mockito.when(request.userProperties()).thenReturn(java.util.Map.of());
+        Mockito.when(request.systemProperties()).thenReturn(java.util.Map.of());
+
+        // Mock parserRequest and logger
+        org.apache.maven.api.cli.ParserRequest parserRequest =
+                Mockito.mock(org.apache.maven.api.cli.ParserRequest.class);
+        org.apache.maven.api.cli.Logger logger = Mockito.mock(org.apache.maven.api.cli.Logger.class);
+        Mockito.when(request.parserRequest()).thenReturn(parserRequest);
+        Mockito.when(parserRequest.logger()).thenReturn(logger);
+
+        UpgradeContext context = new UpgradeContext(request);
+        return context;
+    }
+
+    @Test
+    void testRemoveDependencyVersionForProjectArtifact() throws Exception {
+        // Test that dependency version is removed when it points to a project artifact
+        String parentPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <groupId>com.example</groupId>
+                <artifactId>parent-project</artifactId>
+                <version>1.0.0</version>
+                <packaging>pom</packaging>
+            </project>
+            """;
+
+        String moduleAPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <parent>
+                    <groupId>com.example</groupId>
+                    <artifactId>parent-project</artifactId>
+                    <version>1.0.0</version>
+                </parent>
+                <artifactId>module-a</artifactId>
+            </project>
+            """;
+
+        String moduleBPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <parent>
+                    <groupId>com.example</groupId>
+                    <artifactId>parent-project</artifactId>
+                    <version>1.0.0</version>
+                </parent>
+                <artifactId>module-b</artifactId>
+                <dependencies>
+                    <dependency>
+                        <groupId>com.example</groupId>
+                        <artifactId>module-a</artifactId>
+                        <version>1.0.0</version>
+                    </dependency>
+                </dependencies>
+            </project>
+            """;
+
+        Document parentDoc = saxBuilder.build(new StringReader(parentPomXml));
+        Document moduleADoc = saxBuilder.build(new StringReader(moduleAPomXml));
+        Document moduleBDoc = saxBuilder.build(new StringReader(moduleBPomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path parentPath = Paths.get("/project/pom.xml");
+        Path moduleAPath = Paths.get("/project/module-a/pom.xml");
+        Path moduleBPath = Paths.get("/project/module-b/pom.xml");
+        pomMap.put(parentPath, parentDoc);
+        pomMap.put(moduleAPath, moduleADoc);
+        pomMap.put(moduleBPath, moduleBDoc);
+
+        Element moduleBRoot = moduleBDoc.getRootElement();
+        Element dependencies = moduleBRoot.getChild("dependencies", moduleBRoot.getNamespace());
+        Element dependency = dependencies.getChild("dependency", moduleBRoot.getNamespace());
+
+        // Verify dependency elements exist before inference
+        assertNotNull(dependency.getChild("groupId", moduleBRoot.getNamespace()));
+        assertNotNull(dependency.getChild("artifactId", moduleBRoot.getNamespace()));
+        assertNotNull(dependency.getChild("version", moduleBRoot.getNamespace()));
+
+        // Apply dependency inference
+        UpgradeContext context = createMockContext();
+        upgrade.removeDependencyInferenceRedundancy(context, moduleBDoc, moduleBPath, pomMap);
+
+        // Verify version was removed (can be inferred from project)
+        assertNull(dependency.getChild("version", moduleBRoot.getNamespace()));
+        // groupId should also be removed (can be inferred from project)
+        assertNull(dependency.getChild("groupId", moduleBRoot.getNamespace()));
+        // artifactId should remain (always required)
+        assertNotNull(dependency.getChild("artifactId", moduleBRoot.getNamespace()));
+    }
+
+    @Test
+    void testKeepDependencyVersionForExternalArtifact() throws Exception {
+        // Test that dependency version is kept when it points to an external artifact
+        String modulePomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <groupId>com.example</groupId>
+                <artifactId>my-module</artifactId>
+                <version>1.0.0</version>
+                <dependencies>
+                    <dependency>
+                        <groupId>org.apache.commons</groupId>
+                        <artifactId>commons-lang3</artifactId>
+                        <version>3.12.0</version>
+                    </dependency>
+                </dependencies>
+            </project>
+            """;
+
+        Document moduleDoc = saxBuilder.build(new StringReader(modulePomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path modulePath = Paths.get("/project/pom.xml");
+        pomMap.put(modulePath, moduleDoc);
+
+        Element moduleRoot = moduleDoc.getRootElement();
+        Element dependencies = moduleRoot.getChild("dependencies", moduleRoot.getNamespace());
+        Element dependency = dependencies.getChild("dependency", moduleRoot.getNamespace());
+
+        // Apply dependency inference
+        UpgradeContext context = createMockContext();
+        upgrade.removeDependencyInferenceRedundancy(context, moduleDoc, modulePath, pomMap);
+
+        // Verify all dependency elements remain (external dependency)
+        assertNotNull(dependency.getChild("groupId", moduleRoot.getNamespace()));
+        assertNotNull(dependency.getChild("artifactId", moduleRoot.getNamespace()));
+        assertNotNull(dependency.getChild("version", moduleRoot.getNamespace()));
+    }
+
+    @Test
+    void testKeepDependencyVersionWhenVersionMismatch() throws Exception {
+        // Test that dependency version is kept when it doesn't match the project version
+        String moduleAPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <groupId>com.example</groupId>
+                <artifactId>module-a</artifactId>
+                <version>1.0.0</version>
+            </project>
+            """;
+
+        String moduleBPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <groupId>com.example</groupId>
+                <artifactId>module-b</artifactId>
+                <version>1.0.0</version>
+                <dependencies>
+                    <dependency>
+                        <groupId>com.example</groupId>
+                        <artifactId>module-a</artifactId>
+                        <version>0.9.0</version>
+                    </dependency>
+                </dependencies>
+            </project>
+            """;
+
+        Document moduleADoc = saxBuilder.build(new StringReader(moduleAPomXml));
+        Document moduleBDoc = saxBuilder.build(new StringReader(moduleBPomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path moduleAPath = Paths.get("/project/module-a/pom.xml");
+        Path moduleBPath = Paths.get("/project/module-b/pom.xml");
+        pomMap.put(moduleAPath, moduleADoc);
+        pomMap.put(moduleBPath, moduleBDoc);
+
+        Element moduleBRoot = moduleBDoc.getRootElement();
+        Element dependencies = moduleBRoot.getChild("dependencies", moduleBRoot.getNamespace());
+        Element dependency = dependencies.getChild("dependency", moduleBRoot.getNamespace());
+
+        // Apply dependency inference
+        UpgradeContext context = createMockContext();
+        upgrade.removeDependencyInferenceRedundancy(context, moduleBDoc, moduleBPath, pomMap);
+
+        // Verify correct behavior when version doesn't match:
+        // - groupId should be removed (can be inferred from project regardless of version)
+        // - version should remain (doesn't match project version, so can't be inferred)
+        // - artifactId should remain (always required)
+        assertNull(dependency.getChild("groupId", moduleBRoot.getNamespace()));
+        assertNotNull(dependency.getChild("artifactId", moduleBRoot.getNamespace()));
+        assertNotNull(dependency.getChild("version", moduleBRoot.getNamespace()));
+    }
+
+    @Test
+    void testRemoveDependencyFromPluginDependencies() throws Exception {
+        // Test that dependency inference works for plugin dependencies
+        String moduleAPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <groupId>com.example</groupId>
+                <artifactId>module-a</artifactId>
+                <version>1.0.0</version>
+            </project>
+            """;
+
+        String moduleBPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <groupId>com.example</groupId>
+                <artifactId>module-b</artifactId>
+                <version>1.0.0</version>
+                <build>
+                    <plugins>
+                        <plugin>
+                            <groupId>org.apache.maven.plugins</groupId>
+                            <artifactId>maven-compiler-plugin</artifactId>
+                            <dependencies>
+                                <dependency>
+                                    <groupId>com.example</groupId>
+                                    <artifactId>module-a</artifactId>
+                                    <version>1.0.0</version>
+                                </dependency>
+                            </dependencies>
+                        </plugin>
+                    </plugins>
+                </build>
+            </project>
+            """;
+
+        Document moduleADoc = saxBuilder.build(new StringReader(moduleAPomXml));
+        Document moduleBDoc = saxBuilder.build(new StringReader(moduleBPomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path moduleAPath = Paths.get("/project/module-a/pom.xml");
+        Path moduleBPath = Paths.get("/project/module-b/pom.xml");
+        pomMap.put(moduleAPath, moduleADoc);
+        pomMap.put(moduleBPath, moduleBDoc);
+
+        Element moduleBRoot = moduleBDoc.getRootElement();
+        Element build = moduleBRoot.getChild("build", moduleBRoot.getNamespace());
+        Element plugins = build.getChild("plugins", moduleBRoot.getNamespace());
+        Element plugin = plugins.getChild("plugin", moduleBRoot.getNamespace());
+        Element dependencies = plugin.getChild("dependencies", moduleBRoot.getNamespace());
+        Element dependency = dependencies.getChild("dependency", moduleBRoot.getNamespace());
+
+        // Apply dependency inference
+        UpgradeContext context = createMockContext();
+        upgrade.removeDependencyInferenceRedundancy(context, moduleBDoc, moduleBPath, pomMap);
+
+        // Verify version and groupId were removed from plugin dependency
+        assertNull(dependency.getChild("version", moduleBRoot.getNamespace()));
+        assertNull(dependency.getChild("groupId", moduleBRoot.getNamespace()));
+        assertNotNull(dependency.getChild("artifactId", moduleBRoot.getNamespace()));
+    }
+
+    @Test
+    void testFindDependencyPomInMap() throws Exception {
+        // Test the helper method that finds dependency POMs in the map
+        String moduleAPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <groupId>com.example</groupId>
+                <artifactId>module-a</artifactId>
+                <version>1.0.0</version>
+            </project>
+            """;
+
+        Document moduleADoc = saxBuilder.build(new StringReader(moduleAPomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path moduleAPath = Paths.get("/project/module-a/pom.xml");
+        pomMap.put(moduleAPath, moduleADoc);
+
+        // Test finding by groupId and artifactId
+        Document found = upgrade.findDependencyPomInMap("com.example", "module-a", pomMap);
+        assertNotNull(found);
+
+        // Test finding by artifactId only (groupId null)
+        Document foundByArtifactId = upgrade.findDependencyPomInMap(null, "module-a", pomMap);
+        assertNotNull(foundByArtifactId);
+
+        // Test not finding non-existent artifact
+        Document notFound = upgrade.findDependencyPomInMap("com.example", "non-existent", pomMap);
+        assertNull(notFound);
+    }
+
+    @Test
+    void testCanInferDependencyVersion() throws Exception {
+        // Test the logic for determining if dependency version can be inferred
+        String modulePomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <groupId>com.example</groupId>
+                <artifactId>module-a</artifactId>
+                <version>1.0.0</version>
+            </project>
+            """;
+
+        Document moduleDoc = saxBuilder.build(new StringReader(modulePomXml));
+
+        // Test matching version
+        assertTrue(upgrade.canInferDependencyVersion(moduleDoc, "1.0.0"));
+
+        // Test non-matching version
+        assertFalse(upgrade.canInferDependencyVersion(moduleDoc, "2.0.0"));
+    }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/HelpTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/HelpTest.java
new file mode 100644
index 0000000000..594749e968
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/HelpTest.java
@@ -0,0 +1,140 @@
+/*
+ * 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.cling.invoker.mvnup.goals;
+
+import java.nio.file.Paths;
+
+import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
+import org.apache.maven.cling.invoker.mvnup.UpgradeInvokerRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Unit tests for the Help goal.
+ */
+class HelpTest {
+
+    private Help help;
+
+    @BeforeEach
+    void setUp() {
+        help = new Help();
+    }
+
+    private UpgradeContext createMockContext() {
+        // Mock parserRequest and logger
+        org.apache.maven.api.cli.ParserRequest parserRequest =
+                Mockito.mock(org.apache.maven.api.cli.ParserRequest.class);
+        org.apache.maven.api.cli.Logger logger = Mockito.mock(org.apache.maven.api.cli.Logger.class);
+        Mockito.when(parserRequest.logger()).thenReturn(logger);
+
+        // Create UpgradeInvokerRequest mock
+        UpgradeInvokerRequest upgradeRequest = Mockito.mock(UpgradeInvokerRequest.class);
+
+        // Mock all required properties for LookupContext constructor
+        Mockito.when(upgradeRequest.cwd()).thenReturn(Paths.get("/project"));
+        Mockito.when(upgradeRequest.installationDirectory()).thenReturn(Paths.get("/maven"));
+        Mockito.when(upgradeRequest.userHomeDirectory()).thenReturn(Paths.get("/home/user"));
+        Mockito.when(upgradeRequest.topDirectory()).thenReturn(Paths.get("/project"));
+        Mockito.when(upgradeRequest.rootDirectory()).thenReturn(java.util.Optional.empty());
+        Mockito.when(upgradeRequest.userProperties()).thenReturn(java.util.Map.of());
+        Mockito.when(upgradeRequest.systemProperties()).thenReturn(java.util.Map.of());
+        Mockito.when(upgradeRequest.parserRequest()).thenReturn(parserRequest);
+
+        UpgradeContext context = new UpgradeContext(upgradeRequest);
+        return context;
+    }
+
+    @Test
+    void testHelpExecuteReturnsZero() throws Exception {
+        UpgradeContext context = createMockContext();
+
+        int result = help.execute(context);
+
+        assertEquals(0, result, "Help goal should return 0 (success)");
+    }
+
+    @Test
+    void testHelpExecuteDoesNotThrow() throws Exception {
+        UpgradeContext context = createMockContext();
+
+        // Should not throw any exceptions
+        assertDoesNotThrow(() -> help.execute(context));
+    }
+
+    @Test
+    void testHelpLogsMessages() throws Exception {
+        UpgradeContext context = createMockContext();
+
+        help.execute(context);
+
+        // Verify that logger.info was called multiple times
+        // We can't easily verify the exact content without capturing the logger output,
+        // but we can verify that the method executes without errors
+        Mockito.verify(context.logger, Mockito.atLeastOnce()).info(Mockito.anyString());
+    }
+
+    @Test
+    void testHelpIncludesPluginsOption() throws Exception {
+        UpgradeContext context = createMockContext();
+
+        help.execute(context);
+
+        // Verify that the plugins option is mentioned in the help output
+        Mockito.verify(context.logger).info("      --plugins         Upgrade plugins known to fail with Maven 4");
+    }
+
+    @Test
+    void testHelpIncludesAllOption() throws Exception {
+        UpgradeContext context = createMockContext();
+
+        help.execute(context);
+
+        // Verify that the --all option is mentioned with correct description
+        Mockito.verify(context.logger)
+                .info(
+                        "  -a, --all             Apply all upgrades (equivalent to --model 4.1.0 --infer --fix-model --plugins)");
+    }
+
+    @Test
+    void testHelpIncludesDefaultBehavior() throws Exception {
+        UpgradeContext context = createMockContext();
+
+        help.execute(context);
+
+        // Verify that the default behavior is explained
+        Mockito.verify(context.logger)
+                .info("Default behavior: --fix-model and --plugins are applied if no other options are specified");
+    }
+
+    @Test
+    void testHelpIncludesForceAndYesOptions() throws Exception {
+        UpgradeContext context = createMockContext();
+
+        help.execute(context);
+
+        // Verify that --force and --yes options are included
+        Mockito.verify(context.logger).info("  -f, --force           Overwrite files without asking for confirmation");
+        Mockito.verify(context.logger).info("  -y, --yes             Answer \"yes\" to all prompts automatically");
+    }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/Maven400InferenceTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/Maven400InferenceTest.java
new file mode 100644
index 0000000000..e59dd260bb
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/Maven400InferenceTest.java
@@ -0,0 +1,387 @@
+/*
+ * 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.cling.invoker.mvnup.goals;
+
+import java.io.StringReader;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+
+import org.apache.maven.api.cli.InvokerRequest;
+import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
+import org.jdom2.Document;
+import org.jdom2.Element;
+import org.jdom2.input.SAXBuilder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for Maven 4.0.0 limited inference functionality.
+ * Tests the logic that applies Maven 4.0.0's limited parent inheritance capabilities.
+ */
+class Maven400InferenceTest {
+
+    private BaseUpgradeGoal upgrade;
+    private SAXBuilder saxBuilder;
+
+    @BeforeEach
+    void setUp() {
+        upgrade = new Apply();
+        saxBuilder = new SAXBuilder();
+    }
+
+    private UpgradeContext createMockContext() {
+        InvokerRequest request = Mockito.mock(InvokerRequest.class);
+
+        // Mock all required properties for LookupContext constructor
+        Mockito.when(request.cwd()).thenReturn(Paths.get("/project"));
+        Mockito.when(request.installationDirectory()).thenReturn(Paths.get("/maven"));
+        Mockito.when(request.userHomeDirectory()).thenReturn(Paths.get("/home/user"));
+        Mockito.when(request.topDirectory()).thenReturn(Paths.get("/project"));
+        Mockito.when(request.rootDirectory()).thenReturn(java.util.Optional.empty());
+        Mockito.when(request.userProperties()).thenReturn(java.util.Map.of());
+        Mockito.when(request.systemProperties()).thenReturn(java.util.Map.of());
+
+        // Mock parserRequest and logger
+        org.apache.maven.api.cli.ParserRequest parserRequest =
+                Mockito.mock(org.apache.maven.api.cli.ParserRequest.class);
+        org.apache.maven.api.cli.Logger logger = Mockito.mock(org.apache.maven.api.cli.Logger.class);
+        Mockito.when(request.parserRequest()).thenReturn(parserRequest);
+        Mockito.when(parserRequest.logger()).thenReturn(logger);
+
+        UpgradeContext context = new UpgradeContext(request);
+        return context;
+    }
+
+    @Test
+    void testIsModelVersionEligibleForInferenceIncludesMaven400() throws Exception {
+        // Test that Maven 4.0.0 is now eligible for limited inference
+        assertTrue(upgrade.isModelVersionEligibleForInference("4.0.0"));
+        assertTrue(upgrade.isModelVersionEligibleForInference("4.1.0"));
+        assertFalse(upgrade.isModelVersionEligibleForInference("3.0.0"));
+    }
+
+    @Test
+    void testTrimParentElementLimited400RemoveChildGroupIdWhenMatchesParent() throws Exception {
+        // Test that child groupId is removed in 4.0.0 when it matches parent groupId
+        String parentPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.0.0">
+                <modelVersion>4.0.0</modelVersion>
+                <groupId>com.example</groupId>
+                <artifactId>parent-project</artifactId>
+                <version>1.0.0</version>
+                <packaging>pom</packaging>
+            </project>
+            """;
+
+        String childPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.0.0">
+                <modelVersion>4.0.0</modelVersion>
+                <parent>
+                    <groupId>com.example</groupId>
+                    <artifactId>parent-project</artifactId>
+                    <version>1.0.0</version>
+                    <relativePath>../pom.xml</relativePath>
+                </parent>
+                <groupId>com.example</groupId>
+                <artifactId>child-project</artifactId>
+                <version>1.0.0</version>
+                <!-- Child groupId and version match parent - can be inferred -->
+            </project>
+            """;
+
+        Document parentDoc = saxBuilder.build(new StringReader(parentPomXml));
+        Document childDoc = saxBuilder.build(new StringReader(childPomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path parentPath = Paths.get("/project/pom.xml");
+        Path childPath = Paths.get("/project/child/pom.xml");
+        pomMap.put(parentPath, parentDoc);
+        pomMap.put(childPath, childDoc);
+
+        Element childRoot = childDoc.getRootElement();
+        Element parentElement = childRoot.getChild("parent", childRoot.getNamespace());
+
+        // Verify child and parent elements exist before trimming
+        assertNotNull(childRoot.getChild("groupId", childRoot.getNamespace()));
+        assertNotNull(childRoot.getChild("version", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("groupId", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("artifactId", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("version", childRoot.getNamespace()));
+
+        // Apply limited 4.0.0 inference
+        UpgradeContext context = createMockContext();
+        upgrade.trimParentElementLimited400(context, childDoc, childPath, new HashSet<>(), pomMap);
+
+        // Verify child groupId and version were removed (Maven 4.0.0 can infer these from parent)
+        assertNull(childRoot.getChild("groupId", childRoot.getNamespace()));
+        assertNull(childRoot.getChild("version", childRoot.getNamespace()));
+        // Child artifactId should remain (always required)
+        assertNotNull(childRoot.getChild("artifactId", childRoot.getNamespace()));
+        // Parent elements should all remain (no relativePath inference in 4.0.0)
+        assertNotNull(parentElement.getChild("groupId", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("artifactId", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("version", childRoot.getNamespace()));
+    }
+
+    @Test
+    void testTrimParentElementLimited400KeepChildGroupIdWhenDifferentFromParent() throws Exception {
+        // Test that child groupId is kept in 4.0.0 when it differs from parent groupId
+        String parentPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.0.0">
+                <modelVersion>4.0.0</modelVersion>
+                <groupId>com.example</groupId>
+                <artifactId>parent-project</artifactId>
+                <version>1.0.0</version>
+                <packaging>pom</packaging>
+            </project>
+            """;
+
+        String childPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.0.0">
+                <modelVersion>4.0.0</modelVersion>
+                <parent>
+                    <groupId>com.example</groupId>
+                    <artifactId>parent-project</artifactId>
+                    <version>1.0.0</version>
+                    <relativePath>../pom.xml</relativePath>
+                </parent>
+                <groupId>com.example.child</groupId>
+                <artifactId>child-project</artifactId>
+                <version>2.0.0</version>
+            </project>
+            """;
+
+        Document parentDoc = saxBuilder.build(new StringReader(parentPomXml));
+        Document childDoc = saxBuilder.build(new StringReader(childPomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path parentPath = Paths.get("/project/pom.xml");
+        Path childPath = Paths.get("/project/child/pom.xml");
+        pomMap.put(parentPath, parentDoc);
+        pomMap.put(childPath, childDoc);
+
+        Element childRoot = childDoc.getRootElement();
+        Element parentElement = childRoot.getChild("parent", childRoot.getNamespace());
+
+        // Apply limited 4.0.0 inference
+        UpgradeContext context = createMockContext();
+        upgrade.trimParentElementLimited400(context, childDoc, childPath, new HashSet<>(), pomMap);
+
+        // Verify child elements are kept (since they differ from parent)
+        assertNotNull(childRoot.getChild("groupId", childRoot.getNamespace()));
+        assertNotNull(childRoot.getChild("version", childRoot.getNamespace()));
+        assertNotNull(childRoot.getChild("artifactId", childRoot.getNamespace()));
+        // Parent elements should all remain (no relativePath inference in 4.0.0)
+        assertNotNull(parentElement.getChild("groupId", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("artifactId", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("version", childRoot.getNamespace()));
+    }
+
+    @Test
+    void testTrimParentElementLimited400PartialInheritance() throws Exception {
+        // Test case where child groupId matches parent but version differs in 4.0.0
+        String parentPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.0.0">
+                <modelVersion>4.0.0</modelVersion>
+                <groupId>com.example</groupId>
+                <artifactId>parent-project</artifactId>
+                <version>1.0.0</version>
+                <packaging>pom</packaging>
+            </project>
+            """;
+
+        String childPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.0.0">
+                <modelVersion>4.0.0</modelVersion>
+                <parent>
+                    <groupId>com.example</groupId>
+                    <artifactId>parent-project</artifactId>
+                    <version>1.0.0</version>
+                    <relativePath>../pom.xml</relativePath>
+                </parent>
+                <groupId>com.example</groupId>
+                <artifactId>child-project</artifactId>
+                <version>2.0.0</version>
+                <!-- Child groupId matches parent, but version differs -->
+            </project>
+            """;
+
+        Document parentDoc = saxBuilder.build(new StringReader(parentPomXml));
+        Document childDoc = saxBuilder.build(new StringReader(childPomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path parentPath = Paths.get("/project/pom.xml");
+        Path childPath = Paths.get("/project/child/pom.xml");
+        pomMap.put(parentPath, parentDoc);
+        pomMap.put(childPath, childDoc);
+
+        Element childRoot = childDoc.getRootElement();
+        Element parentElement = childRoot.getChild("parent", childRoot.getNamespace());
+
+        // Apply limited 4.0.0 inference
+        UpgradeContext context = createMockContext();
+        upgrade.trimParentElementLimited400(context, childDoc, childPath, new HashSet<>(), pomMap);
+
+        // Verify child groupId was removed (matches parent, can be inferred)
+        assertNull(childRoot.getChild("groupId", childRoot.getNamespace()));
+        // Verify child version was kept (differs from parent, cannot be inferred)
+        assertNotNull(childRoot.getChild("version", childRoot.getNamespace()));
+        // Verify child artifactId was kept (always required)
+        assertNotNull(childRoot.getChild("artifactId", childRoot.getNamespace()));
+        // Parent elements should all remain (no relativePath inference in 4.0.0)
+        assertNotNull(parentElement.getChild("groupId", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("artifactId", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("version", childRoot.getNamespace()));
+    }
+
+    @Test
+    void testApplyLimitedInferenceFor400() throws Exception {
+        // Test the complete limited inference workflow for 4.0.0
+        String parentPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.0.0">
+                <modelVersion>4.0.0</modelVersion>
+                <groupId>com.example</groupId>
+                <artifactId>parent-project</artifactId>
+                <version>1.0.0</version>
+                <packaging>pom</packaging>
+            </project>
+            """;
+
+        String childPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.0.0">
+                <modelVersion>4.0.0</modelVersion>
+                <parent>
+                    <groupId>com.example</groupId>
+                    <artifactId>parent-project</artifactId>
+                    <version>1.0.0</version>
+                    <relativePath>../pom.xml</relativePath>
+                </parent>
+                <groupId>com.example</groupId>
+                <artifactId>child-project</artifactId>
+                <version>1.0.0</version>
+            </project>
+            """;
+
+        Document parentDoc = saxBuilder.build(new StringReader(parentPomXml));
+        Document childDoc = saxBuilder.build(new StringReader(childPomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path parentPath = Paths.get("/project/pom.xml");
+        Path childPath = Paths.get("/project/child/pom.xml");
+        pomMap.put(parentPath, parentDoc);
+        pomMap.put(childPath, childDoc);
+
+        Element childRoot = childDoc.getRootElement();
+        Element parentElement = childRoot.getChild("parent", childRoot.getNamespace());
+
+        // Apply complete limited inference for 4.0.0
+        UpgradeContext context = createMockContext();
+        upgrade.applyLimitedInferenceFor400(context, childDoc, childPath, pomMap);
+
+        // Verify limited inference was applied correctly
+        assertNull(childRoot.getChild("groupId", childRoot.getNamespace()));
+        assertNull(childRoot.getChild("version", childRoot.getNamespace()));
+        assertNotNull(childRoot.getChild("artifactId", childRoot.getNamespace()));
+
+        // Verify parent elements remain (no relativePath inference in 4.0.0)
+        assertNotNull(parentElement.getChild("groupId", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("artifactId", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("version", childRoot.getNamespace()));
+
+        // Verify modelVersion is still present (not removed in 4.0.0)
+        assertNotNull(childRoot.getChild("modelVersion", childRoot.getNamespace()));
+    }
+
+    @Test
+    void testApplyInferenceRoutesToCorrectMethod() throws Exception {
+        // Test that applyInference routes to the correct method based on model version
+        String maven400PomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.0.0">
+                <modelVersion>4.0.0</modelVersion>
+                <groupId>com.example</groupId>
+                <artifactId>test-project</artifactId>
+                <version>1.0.0</version>
+            </project>
+            """;
+
+        String maven410PomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <modelVersion>4.1.0</modelVersion>
+                <groupId>com.example</groupId>
+                <artifactId>test-project</artifactId>
+                <version>1.0.0</version>
+            </project>
+            """;
+
+        Document maven400Doc = saxBuilder.build(new StringReader(maven400PomXml));
+        Document maven410Doc = saxBuilder.build(new StringReader(maven410PomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path maven400Path = Paths.get("/project/pom.xml");
+        Path maven410Path = Paths.get("/project/pom.xml");
+
+        UpgradeContext context = createMockContext();
+
+        // Test 4.0.0 routing
+        pomMap.put(maven400Path, maven400Doc);
+        upgrade.applyInference(context, maven400Doc, maven400Path, pomMap);
+
+        // Verify modelVersion is still present for 4.0.0 (limited inference)
+        Element maven400Root = maven400Doc.getRootElement();
+        assertNotNull(maven400Root.getChild("modelVersion", maven400Root.getNamespace()));
+
+        // Test 4.1.0 routing
+        pomMap.clear();
+        pomMap.put(maven410Path, maven410Doc);
+        upgrade.applyInference(context, maven410Doc, maven410Path, pomMap);
+
+        // Verify modelVersion is removed for 4.1.0 (full inference)
+        Element maven410Root = maven410Doc.getRootElement();
+        assertNull(maven410Root.getChild("modelVersion", maven410Root.getNamespace()));
+    }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/Maven4CompatibilityFixesTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/Maven4CompatibilityFixesTest.java
new file mode 100644
index 0000000000..73e2426c74
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/Maven4CompatibilityFixesTest.java
@@ -0,0 +1,268 @@
+/*
+ * 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.cling.invoker.mvnup.goals;
+
+import java.io.StringReader;
+
+import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
+import org.jdom2.Document;
+import org.jdom2.Element;
+import org.jdom2.input.SAXBuilder;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test for Maven 4 compatibility fixes functionality.
+ */
+class Maven4CompatibilityFixesTest {
+
+    @Test
+    void testFixUnsupportedCombineChildrenAttributes() 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>
+                <groupId>test</groupId>
+                <artifactId>test</artifactId>
+                <version>1.0</version>
+                <build>
+                    <plugins>
+                        <plugin>
+                            <groupId>org.apache.maven.plugins</groupId>
+                            <artifactId>maven-surefire-plugin</artifactId>
+                            <configuration combine.children="override">
+                                <includes>
+                                    <include>**/*Test.java</include>
+                                </includes>
+                            </configuration>
+                        </plugin>
+                    </plugins>
+                </build>
+            </project>
+            """;
+
+        SAXBuilder builder = new SAXBuilder();
+        Document document = builder.build(new StringReader(pomXml));
+
+        TestableBaseUpgradeGoal goal = new TestableBaseUpgradeGoal();
+        UpgradeContext context = Mockito.mock(UpgradeContext.class);
+        context.logger = Mockito.mock(org.apache.maven.api.cli.Logger.class);
+
+        boolean fixed = goal.fixUnsupportedCombineChildrenAttributes(context, document);
+
+        assertTrue(fixed, "Should have fixed combine.children attribute");
+
+        Element configElement = document.getRootElement()
+                .getChild("build", document.getRootElement().getNamespace())
+                .getChild("plugins", document.getRootElement().getNamespace())
+                .getChild("plugin", document.getRootElement().getNamespace())
+                .getChild("configuration", document.getRootElement().getNamespace());
+
+        assertEquals("merge", configElement.getAttributeValue("combine.children"));
+    }
+
+    @Test
+    void testFixUnsupportedCombineSelfAttributes() 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>
+                <groupId>test</groupId>
+                <artifactId>test</artifactId>
+                <version>1.0</version>
+                <build>
+                    <plugins>
+                        <plugin>
+                            <groupId>org.apache.maven.plugins</groupId>
+                            <artifactId>maven-assembly-plugin</artifactId>
+                            <configuration combine.self="append">
+                                <descriptors>
+                                    <descriptor>src/assembly/dist.xml</descriptor>
+                                </descriptors>
+                            </configuration>
+                        </plugin>
+                    </plugins>
+                </build>
+            </project>
+            """;
+
+        SAXBuilder builder = new SAXBuilder();
+        Document document = builder.build(new StringReader(pomXml));
+
+        TestableBaseUpgradeGoal goal = new TestableBaseUpgradeGoal();
+        UpgradeContext context = Mockito.mock(UpgradeContext.class);
+        context.logger = Mockito.mock(org.apache.maven.api.cli.Logger.class);
+
+        boolean fixed = goal.fixUnsupportedCombineSelfAttributes(context, document);
+
+        assertTrue(fixed, "Should have fixed combine.self attribute");
+
+        Element configElement = document.getRootElement()
+                .getChild("build", document.getRootElement().getNamespace())
+                .getChild("plugins", document.getRootElement().getNamespace())
+                .getChild("plugin", document.getRootElement().getNamespace())
+                .getChild("configuration", document.getRootElement().getNamespace());
+
+        assertEquals("merge", configElement.getAttributeValue("combine.self"));
+    }
+
+    @Test
+    void testAllOptionBehavior() throws Exception {
+        // Test that --all option enables all features
+        org.apache.maven.cling.invoker.mvnup.UpgradeInvokerRequest request =
+                Mockito.mock(org.apache.maven.cling.invoker.mvnup.UpgradeInvokerRequest.class);
+        org.apache.maven.api.cli.mvnup.UpgradeOptions options =
+                Mockito.mock(org.apache.maven.api.cli.mvnup.UpgradeOptions.class);
+
+        // Mock all required properties for LookupContext constructor
+        Mockito.when(request.cwd()).thenReturn(java.nio.file.Paths.get("/project"));
+        Mockito.when(request.installationDirectory()).thenReturn(java.nio.file.Paths.get("/maven"));
+        Mockito.when(request.userHomeDirectory()).thenReturn(java.nio.file.Paths.get("/home/user"));
+        Mockito.when(request.topDirectory()).thenReturn(java.nio.file.Paths.get("/project"));
+        Mockito.when(request.rootDirectory()).thenReturn(java.util.Optional.empty());
+        Mockito.when(request.userProperties()).thenReturn(java.util.Map.of());
+        Mockito.when(request.systemProperties()).thenReturn(java.util.Map.of());
+
+        // Mock --all option as true
+        Mockito.when(request.options()).thenReturn(options);
+        Mockito.when(options.all()).thenReturn(java.util.Optional.of(true));
+        Mockito.when(options.infer()).thenReturn(java.util.Optional.empty());
+        Mockito.when(options.fixModel()).thenReturn(java.util.Optional.empty());
+        Mockito.when(options.model()).thenReturn(java.util.Optional.empty());
+
+        // Mock parserRequest and logger
+        org.apache.maven.api.cli.ParserRequest parserRequest =
+                Mockito.mock(org.apache.maven.api.cli.ParserRequest.class);
+        org.apache.maven.api.cli.Logger logger = Mockito.mock(org.apache.maven.api.cli.Logger.class);
+        Mockito.when(request.parserRequest()).thenReturn(parserRequest);
+        Mockito.when(parserRequest.logger()).thenReturn(logger);
+
+        UpgradeContext context = new UpgradeContext(request);
+        TestableBaseUpgradeGoal goal = new TestableBaseUpgradeGoal();
+
+        // Test that --all option sets target model to 4.1.0 and enables all features
+        String result = goal.testDoUpgradeLogic(context, "4.0.0", new java.util.HashMap<>());
+        assertEquals("4.1.0", result); // Should upgrade to 4.1.0 when --all is used
+    }
+
+    @Test
+    void testDefaultBehavior() throws Exception {
+        // Test that default behavior enables --fix-model
+        org.apache.maven.cling.invoker.mvnup.UpgradeInvokerRequest request =
+                Mockito.mock(org.apache.maven.cling.invoker.mvnup.UpgradeInvokerRequest.class);
+        org.apache.maven.api.cli.mvnup.UpgradeOptions options =
+                Mockito.mock(org.apache.maven.api.cli.mvnup.UpgradeOptions.class);
+
+        // Mock all required properties for LookupContext constructor
+        Mockito.when(request.cwd()).thenReturn(java.nio.file.Paths.get("/project"));
+        Mockito.when(request.installationDirectory()).thenReturn(java.nio.file.Paths.get("/maven"));
+        Mockito.when(request.userHomeDirectory()).thenReturn(java.nio.file.Paths.get("/home/user"));
+        Mockito.when(request.topDirectory()).thenReturn(java.nio.file.Paths.get("/project"));
+        Mockito.when(request.rootDirectory()).thenReturn(java.util.Optional.empty());
+        Mockito.when(request.userProperties()).thenReturn(java.util.Map.of());
+        Mockito.when(request.systemProperties()).thenReturn(java.util.Map.of());
+
+        // Mock no options specified
+        Mockito.when(request.options()).thenReturn(options);
+        Mockito.when(options.all()).thenReturn(java.util.Optional.empty());
+        Mockito.when(options.infer()).thenReturn(java.util.Optional.empty());
+        Mockito.when(options.fixModel()).thenReturn(java.util.Optional.empty());
+        Mockito.when(options.model()).thenReturn(java.util.Optional.empty());
+
+        // Mock parserRequest and logger
+        org.apache.maven.api.cli.ParserRequest parserRequest =
+                Mockito.mock(org.apache.maven.api.cli.ParserRequest.class);
+        org.apache.maven.api.cli.Logger logger = Mockito.mock(org.apache.maven.api.cli.Logger.class);
+        Mockito.when(request.parserRequest()).thenReturn(parserRequest);
+        Mockito.when(parserRequest.logger()).thenReturn(logger);
+
+        UpgradeContext context = new UpgradeContext(request);
+        TestableBaseUpgradeGoal goal = new TestableBaseUpgradeGoal();
+
+        // Test that default behavior enables fix-model
+        boolean defaultFixModel = goal.testDefaultFixModelBehavior(context, "4.0.0", new java.util.HashMap<>());
+        assertTrue(defaultFixModel, "Default behavior should enable --fix-model");
+    }
+
+    /**
+     * Testable subclass that exposes protected methods for testing.
+     */
+    private static class TestableBaseUpgradeGoal extends BaseUpgradeGoal {
+        @Override
+        protected boolean shouldSaveModifications() {
+            return false;
+        }
+
+        @Override
+        public boolean fixUnsupportedCombineChildrenAttributes(UpgradeContext context, Document pomDocument) {
+            return super.fixUnsupportedCombineChildrenAttributes(context, pomDocument);
+        }
+
+        @Override
+        public boolean fixUnsupportedCombineSelfAttributes(UpgradeContext context, Document pomDocument) {
+            return super.fixUnsupportedCombineSelfAttributes(context, pomDocument);
+        }
+
+        // Test helper methods to expose internal logic
+        public String testDoUpgradeLogic(
+                UpgradeContext context,
+                String targetModel,
+                java.util.Map<java.nio.file.Path, org.jdom2.Document> pomMap) {
+            org.apache.maven.api.cli.mvnup.UpgradeOptions options =
+                    ((org.apache.maven.cling.invoker.mvnup.UpgradeInvokerRequest) context.invokerRequest).options();
+
+            // Handle --all option (overrides individual options)
+            boolean useAll = options.all().orElse(false);
+
+            // If --all is specified, ensure we're targeting 4.1.0
+            if (useAll && !options.model().isPresent()) {
+                targetModel = "4.1.0";
+            }
+
+            return targetModel;
+        }
+
+        public boolean testDefaultFixModelBehavior(
+                UpgradeContext context,
+                String targetModel,
+                java.util.Map<java.nio.file.Path, org.jdom2.Document> pomMap) {
+            org.apache.maven.api.cli.mvnup.UpgradeOptions options =
+                    ((org.apache.maven.cling.invoker.mvnup.UpgradeInvokerRequest) context.invokerRequest).options();
+
+            // Handle --all option (overrides individual options)
+            boolean useAll = options.all().orElse(false);
+            boolean useFixModel = useAll || options.fixModel().orElse(false);
+
+            // Apply default behavior: if no specific options are provided, enable --fix-model
+            if (!useAll
+                    && !options.infer().isPresent()
+                    && !options.fixModel().isPresent()
+                    && !options.model().isPresent()) {
+                useFixModel = true;
+            }
+
+            return useFixModel;
+        }
+    }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/ParentInferenceTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/ParentInferenceTest.java
new file mode 100644
index 0000000000..51df6fa578
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/ParentInferenceTest.java
@@ -0,0 +1,371 @@
+/*
+ * 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.cling.invoker.mvnup.goals;
+
+import java.io.StringReader;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+
+import org.apache.maven.api.cli.InvokerRequest;
+import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
+import org.jdom2.Document;
+import org.jdom2.Element;
+import org.jdom2.input.SAXBuilder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for parent inference functionality in Maven upgrade tools.
+ * Tests the logic that determines when parent GAV elements can be removed because
+ * they can be inferred from relativePath during Maven model building.
+ */
+class ParentInferenceTest {
+
+    private BaseUpgradeGoal upgrade;
+    private SAXBuilder saxBuilder;
+
+    @BeforeEach
+    void setUp() {
+        upgrade = new Apply();
+        saxBuilder = new SAXBuilder();
+    }
+
+    private UpgradeContext createMockContext() {
+        InvokerRequest request = Mockito.mock(InvokerRequest.class);
+
+        // Mock all required properties for LookupContext constructor
+        Mockito.when(request.cwd()).thenReturn(Paths.get("/project"));
+        Mockito.when(request.installationDirectory()).thenReturn(Paths.get("/maven"));
+        Mockito.when(request.userHomeDirectory()).thenReturn(Paths.get("/home/user"));
+        Mockito.when(request.topDirectory()).thenReturn(Paths.get("/project"));
+        Mockito.when(request.rootDirectory()).thenReturn(java.util.Optional.empty());
+        Mockito.when(request.userProperties()).thenReturn(java.util.Map.of());
+        Mockito.when(request.systemProperties()).thenReturn(java.util.Map.of());
+
+        // Mock parserRequest and logger
+        org.apache.maven.api.cli.ParserRequest parserRequest =
+                Mockito.mock(org.apache.maven.api.cli.ParserRequest.class);
+        org.apache.maven.api.cli.Logger logger = Mockito.mock(org.apache.maven.api.cli.Logger.class);
+        Mockito.when(request.parserRequest()).thenReturn(parserRequest);
+        Mockito.when(parserRequest.logger()).thenReturn(logger);
+
+        UpgradeContext context = new UpgradeContext(request);
+        return context;
+    }
+
+    @Test
+    void testCanInferParentArtifactIdWithExplicitRelativePath() throws Exception {
+        // Test parent artifactId inference with explicit relativePath
+        String parentPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <groupId>com.example</groupId>
+                <artifactId>parent-project</artifactId>
+                <version>1.0.0</version>
+            </project>
+            """;
+
+        String childPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <parent>
+                    <groupId>com.example</groupId>
+                    <artifactId>parent-project</artifactId>
+                    <version>1.0.0</version>
+                    <relativePath>../pom.xml</relativePath>
+                </parent>
+                <artifactId>child-project</artifactId>
+            </project>
+            """;
+
+        Document parentDoc = saxBuilder.build(new StringReader(parentPomXml));
+        Document childDoc = saxBuilder.build(new StringReader(childPomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path parentPath = Paths.get("/project/pom.xml");
+        Path childPath = Paths.get("/project/child/pom.xml");
+        pomMap.put(parentPath, parentDoc);
+        pomMap.put(childPath, childDoc);
+
+        Element childRoot = childDoc.getRootElement();
+        Element parentElement = childRoot.getChild("parent", childRoot.getNamespace());
+
+        boolean canInfer = upgrade.canInferParentArtifactId(null, parentElement, childPath, pomMap);
+        assertTrue(canInfer, "Should be able to infer parent artifactId when parent POM is in pomMap");
+    }
+
+    @Test
+    void testCanInferParentArtifactIdWithDefaultRelativePath() throws Exception {
+        // Test parent artifactId inference with default relativePath (no explicit relativePath element)
+        String parentPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <groupId>com.example</groupId>
+                <artifactId>parent-project</artifactId>
+                <version>1.0.0</version>
+            </project>
+            """;
+
+        String childPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <parent>
+                    <groupId>com.example</groupId>
+                    <artifactId>parent-project</artifactId>
+                    <version>1.0.0</version>
+                    <!-- No relativePath - should default to ../pom.xml -->
+                </parent>
+                <artifactId>child-project</artifactId>
+            </project>
+            """;
+
+        Document parentDoc = saxBuilder.build(new StringReader(parentPomXml));
+        Document childDoc = saxBuilder.build(new StringReader(childPomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path parentPath = Paths.get("/project/pom.xml");
+        Path childPath = Paths.get("/project/child/pom.xml");
+        pomMap.put(parentPath, parentDoc);
+        pomMap.put(childPath, childDoc);
+
+        Element childRoot = childDoc.getRootElement();
+        Element parentElement = childRoot.getChild("parent", childRoot.getNamespace());
+
+        boolean canInfer = upgrade.canInferParentArtifactId(null, parentElement, childPath, pomMap);
+        assertTrue(canInfer, "Should be able to infer parent artifactId with default relativePath");
+    }
+
+    @Test
+    void testCanInferParentArtifactIdParentNotInPomMap() throws Exception {
+        // Test that inference fails when parent POM is not in pomMap
+        String childPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <parent>
+                    <groupId>com.example</groupId>
+                    <artifactId>external-parent</artifactId>
+                    <version>1.0.0</version>
+                    <relativePath>../external-parent/pom.xml</relativePath>
+                </parent>
+                <artifactId>child-project</artifactId>
+            </project>
+            """;
+
+        Document childDoc = saxBuilder.build(new StringReader(childPomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path childPath = Paths.get("/project/child/pom.xml");
+        // Note: parent POM is NOT in pomMap
+
+        Element childRoot = childDoc.getRootElement();
+        Element parentElement = childRoot.getChild("parent", childRoot.getNamespace());
+
+        boolean canInfer = upgrade.canInferParentArtifactId(null, parentElement, childPath, pomMap);
+        assertFalse(canInfer, "Should not be able to infer parent artifactId when parent POM is not in pomMap");
+    }
+
+    @Test
+    void testResolveParentPomPathExplicitFile() throws Exception {
+        Path childPath = Paths.get("/project/child/pom.xml");
+
+        Path parentPath = upgrade.resolveParentPomPath(childPath, "../pom.xml");
+        assertEquals(Paths.get("/project/pom.xml"), parentPath);
+    }
+
+    @Test
+    void testResolveParentPomPathDirectory() throws Exception {
+        Path childPath = Paths.get("/project/child/pom.xml");
+
+        Path parentPath = upgrade.resolveParentPomPath(childPath, "..");
+        assertEquals(Paths.get("/project/pom.xml"), parentPath);
+    }
+
+    @Test
+    void testResolveParentPomPathNullRelativePath() throws Exception {
+        Path childPath = Paths.get("/project/child/pom.xml");
+
+        // Null relativePath should default to ../pom.xml
+        Path parentPath = upgrade.resolveParentPomPath(childPath, null);
+        assertEquals(Paths.get("/project/pom.xml"), parentPath);
+    }
+
+    @Test
+    void testTrimParentElementRemoveGroupIdWhenChildHasNone() throws Exception {
+        // Test that parent groupId is removed when child doesn't have explicit groupId
+        String parentPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <groupId>com.example</groupId>
+                <artifactId>parent-project</artifactId>
+                <version>1.0.0</version>
+            </project>
+            """;
+
+        String childPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <parent>
+                    <groupId>com.example</groupId>
+                    <artifactId>parent-project</artifactId>
+                    <version>1.0.0</version>
+                    <relativePath>../pom.xml</relativePath>
+                </parent>
+                <artifactId>child-project</artifactId>
+                <!-- No explicit groupId - will inherit from parent -->
+                <!-- No explicit version - will inherit from parent -->
+            </project>
+            """;
+
+        Document parentDoc = saxBuilder.build(new StringReader(parentPomXml));
+        Document childDoc = saxBuilder.build(new StringReader(childPomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path parentPath = Paths.get("/project/pom.xml");
+        Path childPath = Paths.get("/project/child/pom.xml");
+        pomMap.put(parentPath, parentDoc);
+        pomMap.put(childPath, childDoc);
+
+        Element childRoot = childDoc.getRootElement();
+        Element parentElement = childRoot.getChild("parent", childRoot.getNamespace());
+
+        // Verify parent elements exist before trimming
+        assertNotNull(parentElement.getChild("groupId", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("artifactId", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("version", childRoot.getNamespace()));
+
+        // Apply trimming logic
+        UpgradeContext context = createMockContext();
+        upgrade.trimParentElement(context, childDoc, childPath, new HashSet<>(), pomMap);
+
+        // Verify parent groupId and version were removed (since child doesn't have explicit ones)
+        assertNull(parentElement.getChild("groupId", childRoot.getNamespace()));
+        assertNull(parentElement.getChild("version", childRoot.getNamespace()));
+        // artifactId should also be removed since parent POM is in pomMap
+        assertNull(parentElement.getChild("artifactId", childRoot.getNamespace()));
+    }
+
+    @Test
+    void testTrimParentElementKeepGroupIdWhenChildHasExplicit() throws Exception {
+        // Test that parent groupId is kept when child has explicit groupId
+        String parentPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <groupId>com.example</groupId>
+                <artifactId>parent-project</artifactId>
+                <version>1.0.0</version>
+            </project>
+            """;
+
+        String childPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <parent>
+                    <groupId>com.example</groupId>
+                    <artifactId>parent-project</artifactId>
+                    <version>1.0.0</version>
+                    <relativePath>../pom.xml</relativePath>
+                </parent>
+                <groupId>com.example.child</groupId>
+                <artifactId>child-project</artifactId>
+                <version>2.0.0</version>
+            </project>
+            """;
+
+        Document parentDoc = saxBuilder.build(new StringReader(parentPomXml));
+        Document childDoc = saxBuilder.build(new StringReader(childPomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path parentPath = Paths.get("/project/pom.xml");
+        Path childPath = Paths.get("/project/child/pom.xml");
+        pomMap.put(parentPath, parentDoc);
+        pomMap.put(childPath, childDoc);
+
+        Element childRoot = childDoc.getRootElement();
+        Element parentElement = childRoot.getChild("parent", childRoot.getNamespace());
+
+        // Apply trimming logic
+        UpgradeContext context = createMockContext();
+        upgrade.trimParentElement(context, childDoc, childPath, new HashSet<>(), pomMap);
+
+        // Verify parent elements are kept (since child has explicit values)
+        assertNotNull(parentElement.getChild("groupId", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("version", childRoot.getNamespace()));
+        // artifactId should still be removed since parent POM is in pomMap
+        assertNull(parentElement.getChild("artifactId", childRoot.getNamespace()));
+    }
+
+    @Test
+    void testTrimParentElementExternalParent() throws Exception {
+        // Test that parent elements are not trimmed when parent is external (not in pomMap)
+        String childPomXml =
+                """
+            <?xml version="1.0" encoding="UTF-8"?>
+            <project xmlns="http://maven.apache.org/POM/4.1.0">
+                <parent>
+                    <groupId>org.springframework.boot</groupId>
+                    <artifactId>spring-boot-starter-parent</artifactId>
+                    <version>3.0.0</version>
+                    <relativePath/>
+                </parent>
+                <artifactId>my-spring-app</artifactId>
+                <!-- No explicit groupId or version - would inherit from parent -->
+            </project>
+            """;
+
+        Document childDoc = saxBuilder.build(new StringReader(childPomXml));
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        Path childPath = Paths.get("/project/pom.xml");
+        // Note: external parent is not in pomMap
+
+        Element childRoot = childDoc.getRootElement();
+        Element parentElement = childRoot.getChild("parent", childRoot.getNamespace());
+
+        // Apply trimming logic
+        UpgradeContext context = createMockContext();
+        upgrade.trimParentElement(context, childDoc, childPath, new HashSet<>(), pomMap);
+
+        // Verify correct behavior for external parent:
+        // - groupId should be removed (child doesn't have explicit groupId, can inherit from parent)
+        // - version should be removed (child doesn't have explicit version, can inherit from parent)
+        // - artifactId should be kept (external parent, cannot infer from relativePath)
+        assertNull(parentElement.getChild("groupId", childRoot.getNamespace()));
+        assertNotNull(parentElement.getChild("artifactId", childRoot.getNamespace()));
+        assertNull(parentElement.getChild("version", childRoot.getNamespace()));
+    }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginOptionsTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginOptionsTest.java
new file mode 100644
index 0000000000..27f95fcc46
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginOptionsTest.java
@@ -0,0 +1,294 @@
+/*
+ * 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.cling.invoker.mvnup.goals;
+
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Optional;
+
+import org.apache.maven.api.cli.mvnup.UpgradeOptions;
+import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
+import org.apache.maven.cling.invoker.mvnup.UpgradeInvokerRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for plugin options handling in BaseUpgradeGoal.
+ * These tests verify that the --plugins option is properly integrated with the upgrade workflow.
+ */
+class PluginOptionsTest {
+
+    private TestableBaseUpgradeGoal upgrade;
+
+    @BeforeEach
+    void setUp() {
+        upgrade = new TestableBaseUpgradeGoal();
+    }
+
+    private UpgradeContext createMockContext(UpgradeOptions options) {
+        // Mock parserRequest and logger
+        org.apache.maven.api.cli.ParserRequest parserRequest =
+                Mockito.mock(org.apache.maven.api.cli.ParserRequest.class);
+        org.apache.maven.api.cli.Logger logger = Mockito.mock(org.apache.maven.api.cli.Logger.class);
+        Mockito.when(parserRequest.logger()).thenReturn(logger);
+
+        // Create UpgradeInvokerRequest mock
+        UpgradeInvokerRequest upgradeRequest = Mockito.mock(UpgradeInvokerRequest.class);
+        Mockito.when(upgradeRequest.options()).thenReturn(options);
+
+        // Mock all required properties for LookupContext constructor
+        Mockito.when(upgradeRequest.cwd()).thenReturn(Paths.get("/project"));
+        Mockito.when(upgradeRequest.installationDirectory()).thenReturn(Paths.get("/maven"));
+        Mockito.when(upgradeRequest.userHomeDirectory()).thenReturn(Paths.get("/home/user"));
+        Mockito.when(upgradeRequest.topDirectory()).thenReturn(Paths.get("/project"));
+        Mockito.when(upgradeRequest.rootDirectory()).thenReturn(java.util.Optional.empty());
+        Mockito.when(upgradeRequest.userProperties()).thenReturn(java.util.Map.of());
+        Mockito.when(upgradeRequest.systemProperties()).thenReturn(java.util.Map.of());
+        Mockito.when(upgradeRequest.parserRequest()).thenReturn(parserRequest);
+
+        UpgradeContext context = new UpgradeContext(upgradeRequest);
+        return context;
+    }
+
+    @Test
+    void testAllOptionIncludesPlugins() throws Exception {
+        // Test that --all option enables plugin upgrades
+        UpgradeOptions options = Mockito.mock(UpgradeOptions.class);
+        Mockito.when(options.all()).thenReturn(Optional.of(true));
+        Mockito.when(options.infer()).thenReturn(Optional.empty());
+        Mockito.when(options.fixModel()).thenReturn(Optional.empty());
+        Mockito.when(options.plugins()).thenReturn(Optional.empty());
+        Mockito.when(options.model()).thenReturn(Optional.empty());
+
+        UpgradeContext context = createMockContext(options);
+        TestableBaseUpgradeGoal goal = new TestableBaseUpgradeGoal();
+
+        // Test that --all option enables plugins
+        boolean pluginsEnabled = goal.testPluginsOptionLogic(context, "4.0.0", new HashMap<>());
+        assertTrue(pluginsEnabled, "--all option should enable plugin upgrades");
+    }
+
+    @Test
+    void testExplicitPluginsOption() throws Exception {
+        // Test that explicit --plugins option enables plugin upgrades
+        UpgradeOptions options = Mockito.mock(UpgradeOptions.class);
+        Mockito.when(options.all()).thenReturn(Optional.empty());
+        Mockito.when(options.infer()).thenReturn(Optional.empty());
+        Mockito.when(options.fixModel()).thenReturn(Optional.empty());
+        Mockito.when(options.plugins()).thenReturn(Optional.of(true));
+        Mockito.when(options.model()).thenReturn(Optional.empty());
+
+        UpgradeContext context = createMockContext(options);
+        TestableBaseUpgradeGoal goal = new TestableBaseUpgradeGoal();
+
+        // Test that explicit --plugins option enables plugins
+        boolean pluginsEnabled = goal.testPluginsOptionLogic(context, "4.0.0", new HashMap<>());
+        assertTrue(pluginsEnabled, "Explicit --plugins option should enable plugin upgrades");
+    }
+
+    @Test
+    void testDefaultBehaviorIncludesPlugins() throws Exception {
+        // Test that default behavior enables plugin upgrades
+        UpgradeOptions options = Mockito.mock(UpgradeOptions.class);
+        Mockito.when(options.all()).thenReturn(Optional.empty());
+        Mockito.when(options.infer()).thenReturn(Optional.empty());
+        Mockito.when(options.fixModel()).thenReturn(Optional.empty());
+        Mockito.when(options.plugins()).thenReturn(Optional.empty());
+        Mockito.when(options.model()).thenReturn(Optional.empty());
+
+        UpgradeContext context = createMockContext(options);
+        TestableBaseUpgradeGoal goal = new TestableBaseUpgradeGoal();
+
+        // Test that default behavior enables plugins
+        boolean pluginsEnabled = goal.testPluginsOptionLogic(context, "4.0.0", new HashMap<>());
+        assertTrue(pluginsEnabled, "Default behavior should enable plugin upgrades");
+    }
+
+    @Test
+    void testPluginsDisabledWhenOtherOptionsPresent() throws Exception {
+        // Test that plugins are not enabled by default when other options are present
+        UpgradeOptions options = Mockito.mock(UpgradeOptions.class);
+        Mockito.when(options.all()).thenReturn(Optional.empty());
+        Mockito.when(options.infer()).thenReturn(Optional.of(true)); // Other option present
+        Mockito.when(options.fixModel()).thenReturn(Optional.empty());
+        Mockito.when(options.plugins()).thenReturn(Optional.empty());
+        Mockito.when(options.model()).thenReturn(Optional.empty());
+
+        UpgradeContext context = createMockContext(options);
+        TestableBaseUpgradeGoal goal = new TestableBaseUpgradeGoal();
+
+        // Test that plugins are not enabled when other options are present
+        boolean pluginsEnabled = goal.testPluginsOptionLogic(context, "4.0.0", new HashMap<>());
+        assertFalse(pluginsEnabled, "Plugin upgrades should not be enabled by default when other options are present");
+    }
+
+    @Test
+    void testDefaultBehaviorIncludesFixModelAndPlugins() throws Exception {
+        // Test that default behavior enables both fix-model and plugins
+        UpgradeOptions options = Mockito.mock(UpgradeOptions.class);
+        Mockito.when(options.all()).thenReturn(Optional.empty());
+        Mockito.when(options.infer()).thenReturn(Optional.empty());
+        Mockito.when(options.fixModel()).thenReturn(Optional.empty());
+        Mockito.when(options.plugins()).thenReturn(Optional.empty());
+        Mockito.when(options.model()).thenReturn(Optional.empty());
+
+        UpgradeContext context = createMockContext(options);
+        TestableBaseUpgradeGoal goal = new TestableBaseUpgradeGoal();
+
+        // Test that default behavior enables both fix-model and plugins
+        boolean fixModelEnabled = goal.testFixModelOptionLogic(context, "4.0.0", new HashMap<>());
+        boolean pluginsEnabled = goal.testPluginsOptionLogic(context, "4.0.0", new HashMap<>());
+
+        assertTrue(fixModelEnabled, "Default behavior should enable fix-model");
+        assertTrue(pluginsEnabled, "Default behavior should enable plugin upgrades");
+    }
+
+    @Test
+    void testAllOptionSetsTargetModelTo410() throws Exception {
+        // Test that --all option sets target model to 4.1.0
+        UpgradeOptions options = Mockito.mock(UpgradeOptions.class);
+        Mockito.when(options.all()).thenReturn(Optional.of(true));
+        Mockito.when(options.infer()).thenReturn(Optional.empty());
+        Mockito.when(options.fixModel()).thenReturn(Optional.empty());
+        Mockito.when(options.plugins()).thenReturn(Optional.empty());
+        Mockito.when(options.model()).thenReturn(Optional.empty());
+
+        UpgradeContext context = createMockContext(options);
+        TestableBaseUpgradeGoal goal = new TestableBaseUpgradeGoal();
+
+        // Test that --all option sets target model to 4.1.0
+        String targetModel = goal.testTargetModelLogic(context, "4.0.0", new HashMap<>());
+        assertEquals("4.1.0", targetModel, "--all option should set target model to 4.1.0");
+    }
+
+    @Test
+    void testDefaultBehaviorKeepsTargetModelAt400() throws Exception {
+        // Test that default behavior (--fix-model and --plugins) keeps target model at 4.0.0
+        UpgradeOptions options = Mockito.mock(UpgradeOptions.class);
+        Mockito.when(options.all()).thenReturn(Optional.empty());
+        Mockito.when(options.infer()).thenReturn(Optional.empty());
+        Mockito.when(options.fixModel()).thenReturn(Optional.empty());
+        Mockito.when(options.plugins()).thenReturn(Optional.empty());
+        Mockito.when(options.model()).thenReturn(Optional.empty());
+
+        UpgradeContext context = createMockContext(options);
+        TestableBaseUpgradeGoal goal = new TestableBaseUpgradeGoal();
+
+        // Test that default behavior keeps target model at 4.0.0
+        String targetModel = goal.testTargetModelLogic(context, "4.0.0", new HashMap<>());
+        assertEquals("4.0.0", targetModel, "Default behavior should keep target model at 4.0.0");
+    }
+
+    @Test
+    void testExplicitFixModelAndPluginsKeepsTargetModelAt400() throws Exception {
+        // Test that explicit --fix-model and --plugins keeps target model at 4.0.0
+        UpgradeOptions options = Mockito.mock(UpgradeOptions.class);
+        Mockito.when(options.all()).thenReturn(Optional.empty());
+        Mockito.when(options.infer()).thenReturn(Optional.empty());
+        Mockito.when(options.fixModel()).thenReturn(Optional.of(true));
+        Mockito.when(options.plugins()).thenReturn(Optional.of(true));
+        Mockito.when(options.model()).thenReturn(Optional.empty());
+
+        UpgradeContext context = createMockContext(options);
+        TestableBaseUpgradeGoal goal = new TestableBaseUpgradeGoal();
+
+        // Test that explicit --fix-model and --plugins keeps target model at 4.0.0
+        String targetModel = goal.testTargetModelLogic(context, "4.0.0", new HashMap<>());
+        assertEquals("4.0.0", targetModel, "Explicit --fix-model and --plugins should keep target model at 4.0.0");
+    }
+
+    /**
+     * Testable subclass that exposes protected methods for testing.
+     */
+    private static class TestableBaseUpgradeGoal extends BaseUpgradeGoal {
+        @Override
+        protected boolean shouldSaveModifications() {
+            return false;
+        }
+
+        // Test helper methods to expose internal logic
+        public boolean testPluginsOptionLogic(
+                UpgradeContext context,
+                String targetModel,
+                java.util.Map<java.nio.file.Path, org.jdom2.Document> pomMap) {
+            UpgradeOptions options = ((UpgradeInvokerRequest) context.invokerRequest).options();
+
+            // Handle --all option (overrides individual options)
+            boolean useAll = options.all().orElse(false);
+            boolean usePlugins = useAll || options.plugins().orElse(false);
+
+            // Apply default behavior: if no specific options are provided, enable --fix-model and --plugins
+            if (!useAll
+                    && !options.infer().isPresent()
+                    && !options.fixModel().isPresent()
+                    && !options.plugins().isPresent()
+                    && !options.model().isPresent()) {
+                usePlugins = true;
+            }
+
+            return usePlugins;
+        }
+
+        public boolean testFixModelOptionLogic(
+                UpgradeContext context,
+                String targetModel,
+                java.util.Map<java.nio.file.Path, org.jdom2.Document> pomMap) {
+            UpgradeOptions options = ((UpgradeInvokerRequest) context.invokerRequest).options();
+
+            // Handle --all option (overrides individual options)
+            boolean useAll = options.all().orElse(false);
+            boolean useFixModel = useAll || options.fixModel().orElse(false);
+
+            // Apply default behavior: if no specific options are provided, enable --fix-model and --plugins
+            if (!useAll
+                    && !options.infer().isPresent()
+                    && !options.fixModel().isPresent()
+                    && !options.plugins().isPresent()
+                    && !options.model().isPresent()) {
+                useFixModel = true;
+            }
+
+            return useFixModel;
+        }
+
+        public String testTargetModelLogic(
+                UpgradeContext context,
+                String initialTargetModel,
+                java.util.Map<java.nio.file.Path, org.jdom2.Document> pomMap) {
+            UpgradeOptions options = ((UpgradeInvokerRequest) context.invokerRequest).options();
+
+            // Determine target model version (same logic as in execute method)
+            String targetModel;
+            if (options.model().isPresent()) {
+                targetModel = options.model().get();
+            } else if (options.all().orElse(false)) {
+                targetModel = "4.1.0";
+            } else {
+                targetModel = "4.0.0";
+            }
+
+            return targetModel;
+        }
+    }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeTest.java
new file mode 100644
index 0000000000..db833de645
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeTest.java
@@ -0,0 +1,514 @@
+/*
+ * 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.cling.invoker.mvnup.goals;
+
+import java.io.StringReader;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.apache.maven.api.cli.InvokerRequest;
+import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
+import org.jdom2.Document;
+import org.jdom2.Element;
+import org.jdom2.Namespace;
+import org.jdom2.input.SAXBuilder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for plugin upgrade functionality in BaseUpgradeGoal.
+ * These tests focus on XML transformation logic by modifying Document objects in memory.
+ */
+class PluginUpgradeTest {
+
+    private TestableBaseUpgradeGoal upgrade;
+    private SAXBuilder saxBuilder;
+
+    @BeforeEach
+    void setUp() {
+        upgrade = new TestableBaseUpgradeGoal();
+        saxBuilder = new SAXBuilder();
+    }
+
+    private UpgradeContext createMockContext() {
+        InvokerRequest request = Mockito.mock(InvokerRequest.class);
+
+        // Mock all required properties for LookupContext constructor
+        Mockito.when(request.cwd()).thenReturn(Paths.get("/project"));
+        Mockito.when(request.installationDirectory()).thenReturn(Paths.get("/maven"));
+        Mockito.when(request.userHomeDirectory()).thenReturn(Paths.get("/home/user"));
+        Mockito.when(request.topDirectory()).thenReturn(Paths.get("/project"));
+        Mockito.when(request.rootDirectory()).thenReturn(java.util.Optional.empty());
+        Mockito.when(request.userProperties()).thenReturn(java.util.Map.of());
+        Mockito.when(request.systemProperties()).thenReturn(java.util.Map.of());
+
+        // Mock parserRequest and logger
+        org.apache.maven.api.cli.ParserRequest parserRequest =
+                Mockito.mock(org.apache.maven.api.cli.ParserRequest.class);
+        org.apache.maven.api.cli.Logger logger = Mockito.mock(org.apache.maven.api.cli.Logger.class);
+        Mockito.when(request.parserRequest()).thenReturn(parserRequest);
+        Mockito.when(parserRequest.logger()).thenReturn(logger);
+
+        UpgradeContext context = new UpgradeContext(request);
+        return context;
+    }
+
+    @Test
+    void testUpgradePluginInBuildPlugins() 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>
+                <groupId>com.example</groupId>
+                <artifactId>test-project</artifactId>
+                <version>1.0.0</version>
+
+                <build>
+                    <plugins>
+                        <plugin>
+                            <groupId>org.apache.maven.plugins</groupId>
+                            <artifactId>maven-exec-plugin</artifactId>
+                            <version>3.1.0</version>
+                        </plugin>
+                    </plugins>
+                </build>
+            </project>
+            """;
+
+        Document document = saxBuilder.build(new StringReader(pomXml));
+        UpgradeContext context = createMockContext();
+
+        boolean upgraded = upgrade.upgradePluginsInPom(context, document);
+
+        assertTrue(upgraded, "Should have upgraded maven-exec-plugin");
+
+        // Verify the version was upgraded
+        Element root = document.getRootElement();
+        Namespace namespace = root.getNamespace();
+        Element pluginElement =
+                root.getChild("build", namespace).getChild("plugins", namespace).getChild("plugin", namespace);
+        Element versionElement = pluginElement.getChild("version", namespace);
+        assertEquals("3.2.0", versionElement.getTextTrim());
+    }
+
+    @Test
+    void testUpgradePluginInPluginManagement() 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>
+                <groupId>com.example</groupId>
+                <artifactId>test-project</artifactId>
+                <version>1.0.0</version>
+
+                <build>
+                    <pluginManagement>
+                        <plugins>
+                            <plugin>
+                                <groupId>org.apache.maven.plugins</groupId>
+                                <artifactId>maven-enforcer-plugin</artifactId>
+                                <version>2.0.0</version>
+                            </plugin>
+                        </plugins>
+                    </pluginManagement>
+                </build>
+            </project>
+            """;
+
+        Document document = saxBuilder.build(new StringReader(pomXml));
+        UpgradeContext context = createMockContext();
+
+        boolean upgraded = upgrade.upgradePluginsInPom(context, document);
+
+        assertTrue(upgraded, "Should have upgraded maven-enforcer-plugin");
+
+        // Verify the version was upgraded
+        Element root = document.getRootElement();
+        Namespace namespace = root.getNamespace();
+        Element pluginElement = root.getChild("build", namespace)
+                .getChild("pluginManagement", namespace)
+                .getChild("plugins", namespace)
+                .getChild("plugin", namespace);
+        Element versionElement = pluginElement.getChild("version", namespace);
+        assertEquals("3.0.0", versionElement.getTextTrim());
+    }
+
+    @Test
+    void testUpgradePluginWithPropertyVersion() 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>
+                <groupId>com.example</groupId>
+                <artifactId>test-project</artifactId>
+                <version>1.0.0</version>
+
+                <properties>
+                    <shade.plugin.version>3.0.0</shade.plugin.version>
+                </properties>
+
+                <build>
+                    <plugins>
+                        <plugin>
+                            <groupId>org.apache.maven.plugins</groupId>
+                            <artifactId>maven-shade-plugin</artifactId>
+                            <version>${shade.plugin.version}</version>
+                        </plugin>
+                    </plugins>
+                </build>
+            </project>
+            """;
+
+        Document document = saxBuilder.build(new StringReader(pomXml));
+        UpgradeContext context = createMockContext();
+
+        boolean upgraded = upgrade.upgradePluginsInPom(context, document);
+
+        assertTrue(upgraded, "Should have upgraded shade plugin property");
+
+        // Verify the property was upgraded
+        Element root = document.getRootElement();
+        Namespace namespace = root.getNamespace();
+        Element propertyElement = root.getChild("properties", namespace).getChild("shade.plugin.version", namespace);
+        assertEquals("3.5.0", propertyElement.getTextTrim());
+    }
+
+    @Test
+    void testNoUpgradeNeededWhenVersionIsAlreadyHigher() 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>
+                <groupId>com.example</groupId>
+                <artifactId>test-project</artifactId>
+                <version>1.0.0</version>
+
+                <build>
+                    <plugins>
+                        <plugin>
+                            <groupId>org.codehaus.mojo</groupId>
+                            <artifactId>flatten-maven-plugin</artifactId>
+                            <version>1.3.0</version>
+                        </plugin>
+                    </plugins>
+                </build>
+            </project>
+            """;
+
+        Document document = saxBuilder.build(new StringReader(pomXml));
+        UpgradeContext context = createMockContext();
+
+        boolean upgraded = upgrade.upgradePluginsInPom(context, document);
+
+        assertFalse(upgraded, "Should not upgrade when version is already higher");
+
+        // Verify the version was not changed
+        Element root = document.getRootElement();
+        Namespace namespace = root.getNamespace();
+        Element pluginElement =
+                root.getChild("build", namespace).getChild("plugins", namespace).getChild("plugin", namespace);
+        Element versionElement = pluginElement.getChild("version", namespace);
+        assertEquals("1.3.0", versionElement.getTextTrim());
+    }
+
+    @Test
+    void testVersionComparison() throws Exception {
+        // Test the version comparison logic
+        assertTrue(upgrade.isVersionBelow("3.1.0", "3.2.0"));
+        assertTrue(upgrade.isVersionBelow("2.9.0", "3.0.0"));
+        assertTrue(upgrade.isVersionBelow("1.2.6", "1.2.7"));
+
+        assertFalse(upgrade.isVersionBelow("3.2.0", "3.2.0")); // Equal versions
+        assertFalse(upgrade.isVersionBelow("3.3.0", "3.2.0")); // Higher version
+        assertFalse(upgrade.isVersionBelow("4.0.0", "3.5.0")); // Major version higher
+
+        // Test with qualifiers
+        assertTrue(upgrade.isVersionBelow("3.1.0-SNAPSHOT", "3.2.0"));
+        assertFalse(upgrade.isVersionBelow("3.2.0-alpha", "3.2.0"));
+    }
+
+    @Test
+    void testMultiplePluginUpgrades() 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>
+                <groupId>com.example</groupId>
+                <artifactId>test-project</artifactId>
+                <version>1.0.0</version>
+
+                <build>
+                    <plugins>
+                        <plugin>
+                            <groupId>org.apache.maven.plugins</groupId>
+                            <artifactId>maven-exec-plugin</artifactId>
+                            <version>3.1.0</version>
+                        </plugin>
+                        <plugin>
+                            <groupId>org.apache.maven.plugins</groupId>
+                            <artifactId>maven-enforcer-plugin</artifactId>
+                            <version>2.0.0</version>
+                        </plugin>
+                        <plugin>
+                            <groupId>org.apache.maven.plugins</groupId>
+                            <artifactId>maven-compiler-plugin</artifactId>
+                            <version>3.8.0</version>
+                        </plugin>
+                    </plugins>
+                </build>
+            </project>
+            """;
+
+        Document document = saxBuilder.build(new StringReader(pomXml));
+        UpgradeContext context = createMockContext();
+
+        boolean upgraded = upgrade.upgradePluginsInPom(context, document);
+
+        assertTrue(upgraded, "Should have upgraded some plugins");
+
+        // Verify specific upgrades
+        Element root = document.getRootElement();
+        Namespace namespace = root.getNamespace();
+        Element pluginsElement = root.getChild("build", namespace).getChild("plugins", namespace);
+
+        // Check maven-exec-plugin was upgraded
+        Element execPlugin = pluginsElement.getChildren("plugin", namespace).stream()
+                .filter(p -> "maven-exec-plugin".equals(p.getChildText("artifactId", namespace)))
+                .findFirst()
+                .orElse(null);
+        assertNotNull(execPlugin);
+        assertEquals("3.2.0", execPlugin.getChildText("version", namespace));
+
+        // Check maven-enforcer-plugin was upgraded
+        Element enforcerPlugin = pluginsElement.getChildren("plugin", namespace).stream()
+                .filter(p -> "maven-enforcer-plugin".equals(p.getChildText("artifactId", namespace)))
+                .findFirst()
+                .orElse(null);
+        assertNotNull(enforcerPlugin);
+        assertEquals("3.0.0", enforcerPlugin.getChildText("version", namespace));
+
+        // Check maven-compiler-plugin was not changed (not in upgrade list)
+        Element compilerPlugin = pluginsElement.getChildren("plugin", namespace).stream()
+                .filter(p -> "maven-compiler-plugin".equals(p.getChildText("artifactId", namespace)))
+                .findFirst()
+                .orElse(null);
+        assertNotNull(compilerPlugin);
+        assertEquals("3.8.0", compilerPlugin.getChildText("version", namespace));
+    }
+
+    @Test
+    void testPluginWithoutExplicitGroupId() 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>
+                <groupId>com.example</groupId>
+                <artifactId>test-project</artifactId>
+                <version>1.0.0</version>
+
+                <build>
+                    <plugins>
+                        <plugin>
+                            <artifactId>maven-exec-plugin</artifactId>
+                            <version>3.1.0</version>
+                        </plugin>
+                    </plugins>
+                </build>
+            </project>
+            """;
+
+        Document document = saxBuilder.build(new StringReader(pomXml));
+        UpgradeContext context = createMockContext();
+
+        boolean upgraded = upgrade.upgradePluginsInPom(context, document);
+
+        assertTrue(upgraded, "Should have upgraded maven-exec-plugin even without explicit groupId");
+
+        // Verify the version was upgraded
+        Element root = document.getRootElement();
+        Namespace namespace = root.getNamespace();
+        Element pluginElement =
+                root.getChild("build", namespace).getChild("plugins", namespace).getChild("plugin", namespace);
+        Element versionElement = pluginElement.getChild("version", namespace);
+        assertEquals("3.2.0", versionElement.getTextTrim());
+    }
+
+    @Test
+    void testPluginWithoutVersion() 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>
+                <groupId>com.example</groupId>
+                <artifactId>test-project</artifactId>
+                <version>1.0.0</version>
+
+                <build>
+                    <plugins>
+                        <plugin>
+                            <groupId>org.apache.maven.plugins</groupId>
+                            <artifactId>maven-exec-plugin</artifactId>
+                            <!-- No version - inherited from parent or pluginManagement -->
+                        </plugin>
+                    </plugins>
+                </build>
+            </project>
+            """;
+
+        Document document = saxBuilder.build(new StringReader(pomXml));
+        UpgradeContext context = createMockContext();
+
+        boolean upgraded = upgrade.upgradePluginsInPom(context, document);
+
+        assertFalse(upgraded, "Should not upgrade plugin without explicit version");
+    }
+
+    @Test
+    void testPropertyNotFoundInPom() 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>
+                <groupId>com.example</groupId>
+                <artifactId>test-project</artifactId>
+                <version>1.0.0</version>
+
+                <build>
+                    <plugins>
+                        <plugin>
+                            <groupId>org.apache.maven.plugins</groupId>
+                            <artifactId>maven-exec-plugin</artifactId>
+                            <version>${exec.plugin.version}</version>
+                        </plugin>
+                    </plugins>
+                </build>
+            </project>
+            """;
+
+        Document document = saxBuilder.build(new StringReader(pomXml));
+        UpgradeContext context = createMockContext();
+
+        boolean upgraded = upgrade.upgradePluginsInPom(context, document);
+
+        assertFalse(upgraded, "Should not upgrade when property is not found");
+    }
+
+    @Test
+    void testGetPluginUpgrades() throws Exception {
+        Map<String, BaseUpgradeGoal.PluginUpgrade> upgrades = upgrade.getPluginUpgrades();
+
+        assertEquals(4, upgrades.size(), "Should have 4 plugin upgrades defined");
+
+        assertTrue(upgrades.containsKey("org.apache.maven.plugins:maven-exec-plugin"));
+        assertTrue(upgrades.containsKey("org.apache.maven.plugins:maven-enforcer-plugin"));
+        assertTrue(upgrades.containsKey("org.codehaus.mojo:flatten-maven-plugin"));
+        assertTrue(upgrades.containsKey("org.apache.maven.plugins:maven-shade-plugin"));
+
+        assertEquals("3.2.0", upgrades.get("org.apache.maven.plugins:maven-exec-plugin").minVersion);
+        assertEquals("3.0.0", upgrades.get("org.apache.maven.plugins:maven-enforcer-plugin").minVersion);
+        assertEquals("1.2.7", upgrades.get("org.codehaus.mojo:flatten-maven-plugin").minVersion);
+        assertEquals("3.5.0", upgrades.get("org.apache.maven.plugins:maven-shade-plugin").minVersion);
+    }
+
+    @Test
+    void testApplyPluginUpgrades() 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>
+                <groupId>com.example</groupId>
+                <artifactId>test-project</artifactId>
+                <version>1.0.0</version>
+
+                <build>
+                    <plugins>
+                        <plugin>
+                            <groupId>org.apache.maven.plugins</groupId>
+                            <artifactId>maven-exec-plugin</artifactId>
+                            <version>3.1.0</version>
+                        </plugin>
+                    </plugins>
+                </build>
+            </project>
+            """;
+
+        Document document = saxBuilder.build(new StringReader(pomXml));
+        UpgradeContext context = createMockContext();
+
+        Map<Path, Document> pomMap = new HashMap<>();
+        pomMap.put(Paths.get("/project/pom.xml"), document);
+
+        upgrade.applyPluginUpgrades(context, pomMap);
+
+        // Verify the version was upgraded
+        Element root = document.getRootElement();
+        Namespace namespace = root.getNamespace();
+        Element pluginElement =
+                root.getChild("build", namespace).getChild("plugins", namespace).getChild("plugin", namespace);
+        Element versionElement = pluginElement.getChild("version", namespace);
+        assertEquals("3.2.0", versionElement.getTextTrim());
+    }
+
+    /**
+     * Testable subclass that exposes protected methods for testing.
+     */
+    private static class TestableBaseUpgradeGoal extends BaseUpgradeGoal {
+        @Override
+        protected boolean shouldSaveModifications() {
+            return false;
+        }
+
+        @Override
+        public boolean upgradePluginsInPom(UpgradeContext context, Document pomDocument) {
+            return super.upgradePluginsInPom(context, pomDocument);
+        }
+
+        @Override
+        public boolean isVersionBelow(String currentVersion, String minVersion) {
+            return super.isVersionBelow(currentVersion, minVersion);
+        }
+
+        @Override
+        public Map<String, PluginUpgrade> getPluginUpgrades() {
+            return super.getPluginUpgrades();
+        }
+
+        @Override
+        public void applyPluginUpgrades(UpgradeContext context, Map<Path, Document> pomMap) {
+            super.applyPluginUpgrades(context, pomMap);
+        }
+    }
+}
diff --git a/pom.xml b/pom.xml
index c7d7511e85..fdcca64baf 100644
--- a/pom.xml
+++ b/pom.xml
@@ -672,6 +672,11 @@ under the License.
         <artifactId>jimfs</artifactId>
         <version>1.3.0</version>
       </dependency>
+      <dependency>
+        <groupId>org.jdom</groupId>
+        <artifactId>jdom2</artifactId>
+        <version>2.0.6.1</version>
+      </dependency>
     </dependencies>
     <!--bootstrap-start-comment-->
   </dependencyManagement>
diff --git a/src/graph/ReactorGraph.java b/src/graph/ReactorGraph.java
index 44d8e94c71..1c605f8f81 100755
--- a/src/graph/ReactorGraph.java
+++ b/src/graph/ReactorGraph.java
@@ -52,7 +52,7 @@ public class ReactorGraph {
         CLUSTER_PATTERNS.put("Commons", Pattern.compile("^commons-cli:.*"));
         CLUSTER_PATTERNS.put("Testing", Pattern.compile("^.*:(mockito-core|junit-jupiter-api):.*"));
     }
-    private static final Pattern HIDDEN_NODES = Pattern.compile(".*:(maven-docgen|roaster-api|roaster-jdt|velocity-engine-core|commons-lang3|asm|logback-classic|slf4j-simple):.*");
+    private static final Pattern HIDDEN_NODES = Pattern.compile(".*:(maven-docgen|roaster-api|roaster-jdt|velocity-engine-core|commons-lang3|asm|logback-classic|slf4j-simple|jdom2):.*");
 
     public static void main(String[] args) {
         try {