You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/09/02 18:13:03 UTC

(maven) branch maven-4.0.x updated: [Backport maven-4.0.x] Fix #12989: mvnup plugin upgrades should respect project JDK version

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

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


The following commit(s) were added to refs/heads/maven-4.0.x by this push:
     new fde2f59442 [Backport maven-4.0.x] Fix #12989: mvnup plugin upgrades should respect project JDK version
fde2f59442 is described below

commit fde2f5944225c436f8df86c98d9a36543b3b6b03
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Wed Sep 2 20:12:56 2026 +0200

    [Backport maven-4.0.x] Fix #12989: mvnup plugin upgrades should respect project JDK version
    
    * Fix #12989: mvnup plugin upgrades should respect project JDK version
    
    Add JDK compatibility checking to PluginUpgradeStrategy so that plugins
    requiring a higher JDK than the project targets are skipped rather than
    blindly upgraded. This prevents UnsupportedClassVersionError at build
    time (e.g., checkstyle-plugin 3.6.0 pulls Checkstyle 10.x which
    requires JDK 21, breaking projects that target JDK 17).
    
    Changes:
    - Add minJdk field to PluginUpgrade record and PluginUpgradeInfo class
    - Add detectProjectJdkVersion() method that reads maven.compiler.release,
      maven.compiler.source, maven.compiler.target, and compiler plugin config
    - Add JDK compatibility guard in upgradePluginVersion() (similar to the
      existing Quarkus 2.x skip pattern)
    - Add JDK compatibility guard in effective model analysis path
    - Register maven-checkstyle-plugin 3.6.0 with minJdk=21
    - Add comprehensive tests in PluginUpgradeJdkCompatibilityTest
    
    
    
    * Address review feedback: extract JDK skip helper, sort POMs by path depth
    
    - Extract duplicated JDK compatibility check into shouldSkipForJdkIncompatibility()
      helper method, eliminating the inline logic in both build/plugins and
      pluginManagement/plugins analysis paths
    - Sort pomMap entries by path depth (shortest first) when detecting the project
      JDK version, so the root POM is checked before child modules in multi-module
      projects
    
    
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
---
 .../cling/invoker/mvnup/goals/PluginUpgrade.java   |  16 +-
 .../invoker/mvnup/goals/PluginUpgradeStrategy.java | 212 +++++++-
 .../goals/PluginUpgradeJdkCompatibilityTest.java   | 596 +++++++++++++++++++++
 3 files changed, 813 insertions(+), 11 deletions(-)

diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgrade.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgrade.java
index e6895cfd57..820a7dd84d 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgrade.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgrade.java
@@ -30,14 +30,24 @@
  *     the plugin has no 4.x pre-release line. Used to upgrade old 4.x alpha/beta/RC versions
  *     to the latest pre-release rather than downgrading to a 3.x version.
  * @param reason the reason why this plugin needs to be upgraded
+ * @param minJdk the minimum JDK version required by this plugin version, or {@code 0} if
+ *     the plugin works with any JDK. When the project's detected JDK is below this value,
+ *     the upgrade is skipped to avoid {@code UnsupportedClassVersionError}.
  */
 public record PluginUpgrade(
-        String groupId, String artifactId, String minVersion, String latestPreRelease, String reason) {
+        String groupId, String artifactId, String minVersion, String latestPreRelease, String reason, int minJdk) {
 
     /**
-     * Convenience constructor for plugins without a 4.x pre-release line.
+     * Convenience constructor for plugins without a 4.x pre-release line and no JDK requirement.
      */
     public PluginUpgrade(String groupId, String artifactId, String minVersion, String reason) {
-        this(groupId, artifactId, minVersion, null, reason);
+        this(groupId, artifactId, minVersion, null, reason, 0);
+    }
+
+    /**
+     * Convenience constructor for plugins with a 4.x pre-release line but no JDK requirement.
+     */
+    public PluginUpgrade(String groupId, String artifactId, String minVersion, String latestPreRelease, String reason) {
+        this(groupId, artifactId, minVersion, latestPreRelease, reason, 0);
     }
 }
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategy.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategy.java
index 3f853e7aa9..6e3145569d 100644
--- a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategy.java
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeStrategy.java
@@ -20,6 +20,7 @@
 
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -42,6 +43,7 @@
 import org.apache.maven.api.model.Plugin;
 import org.apache.maven.api.model.PluginManagement;
 import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
+import org.apache.maven.impl.JdkSourceLevelSupport;
 
 import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.ARTIFACT_ID;
 import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.BUILD;
@@ -169,7 +171,14 @@ public class PluginUpgradeStrategy extends AbstractUpgradeStrategy {
                     "bnd-maven-plugin",
                     "5.1.0",
                     "Versions before 5.1.0 have internal collection mutation bugs (FELIX-6259)"
-                            + " that throw ConcurrentModificationException on JDK 17+"));
+                            + " that throw ConcurrentModificationException on JDK 17+"),
+            new PluginUpgrade(
+                    DEFAULT_MAVEN_PLUGIN_GROUP_ID,
+                    "maven-checkstyle-plugin",
+                    "3.6.0",
+                    null,
+                    MAVEN_4_COMPATIBILITY_REASON,
+                    21));
 
     private static final List<PluginUpgrade> PLUGIN_DEPENDENCY_UPGRADES = List.of(new PluginUpgrade(
             "org.codehaus.mojo",
@@ -341,7 +350,8 @@ private Map<String, PluginUpgradeInfo> getPluginUpgradesMap() {
                                 upgrade.groupId(),
                                 upgrade.artifactId(),
                                 upgrade.minVersion(),
-                                upgrade.latestPreRelease())));
+                                upgrade.latestPreRelease(),
+                                upgrade.minJdk())));
     }
 
     /**
@@ -453,6 +463,22 @@ private boolean upgradePluginVersion(
             }
         }
 
+        // Check JDK compatibility: if the plugin requires a higher JDK than the project
+        // uses, skip the upgrade to avoid UnsupportedClassVersionError at build time.
+        if (upgrade.minJdk > 0) {
+            int projectJdk = detectProjectJdkVersion(pomDocument);
+            if (shouldSkipForJdkIncompatibility(
+                    context,
+                    upgrade.groupId,
+                    upgrade.artifactId,
+                    upgrade.minVersion,
+                    upgrade.minJdk,
+                    projectJdk,
+                    sectionName)) {
+                return false;
+            }
+        }
+
         if (isProperty) {
             // For Quarkus plugins, check if the property is shared with a Quarkus BOM
             if (isQuarkusPlugin(upgrade.groupId, upgrade.artifactId)
@@ -699,6 +725,20 @@ private PluginAnalysisResults analyzePluginsUsingEffectiveModels(
         Map<String, PluginUpgrade> basePluginUpgrades = getPluginUpgradesAsMap();
         String shadePluginKey = DEFAULT_MAVEN_PLUGIN_GROUP_ID + ":maven-shade-plugin";
 
+        // Detect the project JDK version, preferring the root POM (shortest path depth).
+        // In multi-module projects, child modules may declare a different JDK level,
+        // so we sort by path depth to check the root POM first.
+        int projectJdk = -1;
+        List<Map.Entry<Path, Document>> sortedEntries = pomMap.entrySet().stream()
+                .sorted(Comparator.comparingInt(e -> e.getKey().getNameCount()))
+                .toList();
+        for (Map.Entry<Path, Document> jdkEntry : sortedEntries) {
+            projectJdk = detectProjectJdkVersion(jdkEntry.getValue());
+            if (projectJdk > 0) {
+                break;
+            }
+        }
+
         for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
             Path originalPomPath = entry.getKey();
 
@@ -720,7 +760,8 @@ private PluginAnalysisResults analyzePluginsUsingEffectiveModels(
                 }
 
                 // Build effective model using Maven 4 API
-                PluginAnalysis analysis = analyzeEffectiveModelForPlugins(context, tempPomPath, pluginUpgrades);
+                PluginAnalysis analysis =
+                        analyzeEffectiveModelForPlugins(context, tempPomPath, pluginUpgrades, projectJdk);
 
                 // Determine where to add plugin management (last local parent)
                 Path targetPom =
@@ -843,9 +884,9 @@ private Map<String, PluginUpgrade> getPluginUpgradesAsMap() {
     }
 
     private PluginAnalysis analyzeEffectiveModelForPlugins(
-            UpgradeContext context, Path tempPomPath, Map<String, PluginUpgrade> pluginUpgrades) {
+            UpgradeContext context, Path tempPomPath, Map<String, PluginUpgrade> pluginUpgrades, int projectJdk) {
         Model effectiveModel = buildEffectiveModel(context, tempPomPath);
-        return analyzePluginsFromEffectiveModel(context, effectiveModel, pluginUpgrades);
+        return analyzePluginsFromEffectiveModel(context, effectiveModel, pluginUpgrades, projectJdk);
     }
 
     /**
@@ -854,7 +895,7 @@ private PluginAnalysis analyzeEffectiveModelForPlugins(
      * explicitly in an inherited parent's build/plugins, not via pluginManagement).
      */
     private PluginAnalysis analyzePluginsFromEffectiveModel(
-            UpgradeContext context, Model effectiveModel, Map<String, PluginUpgrade> pluginUpgrades) {
+            UpgradeContext context, Model effectiveModel, Map<String, PluginUpgrade> pluginUpgrades, int projectJdk) {
         Set<String> needsManagement = new HashSet<>();
         Set<String> needsDirectOverride = new HashSet<>();
 
@@ -875,6 +916,16 @@ private PluginAnalysis analyzePluginsFromEffectiveModel(
                 String pluginKey = getPluginKey(plugin);
                 PluginUpgrade upgrade = pluginUpgrades.get(pluginKey);
                 if (upgrade != null) {
+                    if (shouldSkipForJdkIncompatibility(
+                            context,
+                            upgrade.groupId(),
+                            upgrade.artifactId(),
+                            upgrade.minVersion(),
+                            upgrade.minJdk(),
+                            projectJdk,
+                            "effective model")) {
+                        continue;
+                    }
                     String effectiveVersion = plugin.getVersion();
                     if (isVersionBelow(context, effectiveVersion, upgrade.minVersion())) {
                         needsManagement.add(pluginKey);
@@ -901,6 +952,16 @@ private PluginAnalysis analyzePluginsFromEffectiveModel(
                     String pluginKey = getPluginKey(plugin);
                     PluginUpgrade upgrade = pluginUpgrades.get(pluginKey);
                     if (upgrade != null && !needsManagement.contains(pluginKey)) {
+                        if (shouldSkipForJdkIncompatibility(
+                                context,
+                                upgrade.groupId(),
+                                upgrade.artifactId(),
+                                upgrade.minVersion(),
+                                upgrade.minJdk(),
+                                projectJdk,
+                                "effective model")) {
+                            continue;
+                        }
                         String effectiveVersion = plugin.getVersion();
                         if (isVersionBelow(context, effectiveVersion, upgrade.minVersion())) {
                             needsManagement.add(pluginKey);
@@ -1232,6 +1293,101 @@ private void collectCustomTransformers(Element configElement, List<String> custo
                 }));
     }
 
+    /**
+     * Detects the project's JDK version from the POM document.
+     * Checks the following sources in order:
+     * <ol>
+     *   <li>{@code maven.compiler.release} property</li>
+     *   <li>{@code maven.compiler.source} property</li>
+     *   <li>{@code maven.compiler.target} property</li>
+     *   <li>Compiler plugin configuration ({@code <release>} or {@code <source>})</li>
+     * </ol>
+     *
+     * @param pomDocument the POM document to inspect
+     * @return the detected JDK major version, or {@code -1} if not found
+     */
+    int detectProjectJdkVersion(Document pomDocument) {
+        Element root = pomDocument.root();
+
+        // Check properties: maven.compiler.release takes precedence, then source, then target
+        Element properties = root.childElement(PROPERTIES).orElse(null);
+        if (properties != null) {
+            for (String propName :
+                    List.of("maven.compiler.release", "maven.compiler.source", "maven.compiler.target")) {
+                Element propElement = properties.childElement(propName).orElse(null);
+                if (propElement != null) {
+                    String value = propElement.textContentTrimmed();
+                    if (value != null && !value.isEmpty() && !value.startsWith("${")) {
+                        int level = JdkSourceLevelSupport.normalizeSourceLevel(value);
+                        if (level > 0) {
+                            return level;
+                        }
+                    }
+                }
+            }
+        }
+
+        // Check compiler plugin configuration
+        Element build = root.childElement(BUILD).orElse(null);
+        if (build != null) {
+            int level = detectJdkFromPluginSection(build.childElement(PLUGINS).orElse(null));
+            if (level > 0) {
+                return level;
+            }
+
+            Element pluginManagement = build.childElement(PLUGIN_MANAGEMENT).orElse(null);
+            if (pluginManagement != null) {
+                level = detectJdkFromPluginSection(
+                        pluginManagement.childElement(PLUGINS).orElse(null));
+                if (level > 0) {
+                    return level;
+                }
+            }
+        }
+
+        return -1;
+    }
+
+    private int detectJdkFromPluginSection(Element pluginsElement) {
+        if (pluginsElement == null) {
+            return -1;
+        }
+
+        for (Element plugin : pluginsElement.childElements(PLUGIN).toList()) {
+            String artifactId = getChildText(plugin, ARTIFACT_ID);
+            String groupId = getChildText(plugin, GROUP_ID);
+            if ("maven-compiler-plugin".equals(artifactId)
+                    && (groupId == null || groupId.isEmpty() || DEFAULT_MAVEN_PLUGIN_GROUP_ID.equals(groupId))) {
+                Element config = plugin.childElement("configuration").orElse(null);
+                if (config != null) {
+                    // <release> takes precedence
+                    Element releaseNode = config.childElement("release").orElse(null);
+                    if (releaseNode != null) {
+                        String text = releaseNode.textContentTrimmed();
+                        if (text != null && !text.isEmpty() && !text.startsWith("${")) {
+                            int level = JdkSourceLevelSupport.normalizeSourceLevel(text);
+                            if (level > 0) {
+                                return level;
+                            }
+                        }
+                    }
+                    Element sourceNode = config.childElement("source").orElse(null);
+                    if (sourceNode != null) {
+                        String text = sourceNode.textContentTrimmed();
+                        if (text != null && !text.isEmpty() && !text.startsWith("${")) {
+                            int level = JdkSourceLevelSupport.normalizeSourceLevel(text);
+                            if (level > 0) {
+                                return level;
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        return -1;
+    }
+
     /**
      * Checks if the given plugin is a Quarkus Maven plugin.
      */
@@ -1240,6 +1396,36 @@ private boolean isQuarkusPlugin(String groupId, String artifactId) {
                 && ("io.quarkus".equals(groupId) || "io.quarkus.platform".equals(groupId));
     }
 
+    /**
+     * Checks whether a plugin upgrade should be skipped because the plugin requires a higher JDK
+     * than the project targets. If the plugin has a {@code minJdk} constraint and the project's
+     * detected JDK is below it, emits a warning and returns {@code true}.
+     *
+     * @param context the upgrade context for logging
+     * @param groupId the plugin's groupId
+     * @param artifactId the plugin's artifactId
+     * @param minVersion the target upgrade version
+     * @param minJdk the minimum JDK version required by the plugin, or {@code 0} if unrestricted
+     * @param projectJdk the project's detected JDK major version, or {@code -1} if unknown
+     * @param location description of where the plugin was found (for logging)
+     * @return {@code true} if the upgrade should be skipped, {@code false} otherwise
+     */
+    private boolean shouldSkipForJdkIncompatibility(
+            UpgradeContext context,
+            String groupId,
+            String artifactId,
+            String minVersion,
+            int minJdk,
+            int projectJdk,
+            String location) {
+        if (minJdk > 0 && projectJdk > 0 && projectJdk < minJdk) {
+            context.warning("Skipping " + groupId + ":" + artifactId + " upgrade to " + minVersion + " in " + location
+                    + ": plugin requires JDK " + minJdk + " but project targets JDK " + projectJdk);
+            return true;
+        }
+        return false;
+    }
+
     /**
      * Detects the Quarkus platform version used by the project.
      *
@@ -1495,6 +1681,9 @@ public static class PluginUpgradeInfo {
         /** The latest available 4.x pre-release version, or null if none exists */
         final String latestPreRelease;
 
+        /** The minimum JDK version required by this plugin, or 0 if any JDK works */
+        final int minJdk;
+
         /**
          * Creates a new plugin upgrade information holder.
          *
@@ -1506,16 +1695,23 @@ public static class PluginUpgradeInfo {
          *            the minimum version required for Maven 4 compatibility
          * @param latestPreRelease
          *            the latest 4.x pre-release version, or null
+         * @param minJdk
+         *            the minimum JDK version required by this plugin, or 0 if any JDK works
          */
-        PluginUpgradeInfo(String groupId, String artifactId, String minVersion, String latestPreRelease) {
+        PluginUpgradeInfo(String groupId, String artifactId, String minVersion, String latestPreRelease, int minJdk) {
             this.groupId = groupId;
             this.artifactId = artifactId;
             this.minVersion = minVersion;
             this.latestPreRelease = latestPreRelease;
+            this.minJdk = minJdk;
+        }
+
+        PluginUpgradeInfo(String groupId, String artifactId, String minVersion, String latestPreRelease) {
+            this(groupId, artifactId, minVersion, latestPreRelease, 0);
         }
 
         PluginUpgradeInfo(String groupId, String artifactId, String minVersion) {
-            this(groupId, artifactId, minVersion, null);
+            this(groupId, artifactId, minVersion, null, 0);
         }
     }
 }
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeJdkCompatibilityTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeJdkCompatibilityTest.java
new file mode 100644
index 0000000000..6cb8936f54
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/PluginUpgradeJdkCompatibilityTest.java
@@ -0,0 +1,596 @@
+/*
+ * 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.Path;
+import java.nio.file.Paths;
+import java.util.Map;
+
+import eu.maveniverse.domtrip.Document;
+import eu.maveniverse.domtrip.Editor;
+import eu.maveniverse.domtrip.Element;
+import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for JDK compatibility checking in {@link PluginUpgradeStrategy}.
+ * Verifies that plugin upgrades requiring a higher JDK than the project targets
+ * are skipped to avoid {@code UnsupportedClassVersionError}.
+ *
+ * @see <a href="https://github.com/apache/maven/issues/12989">apache/maven#12989</a>
+ */
+@DisplayName("PluginUpgradeStrategy — JDK Compatibility")
+class PluginUpgradeJdkCompatibilityTest {
+
+    private PluginUpgradeStrategy strategy;
+
+    @BeforeEach
+    void setUp() {
+        strategy = new PluginUpgradeStrategy();
+    }
+
+    private UpgradeContext createMockContext() {
+        return TestUtils.createMockContext();
+    }
+
+    @Nested
+    @DisplayName("Project JDK Detection")
+    class ProjectJdkDetectionTests {
+
+        @Test
+        @DisplayName("should detect JDK version from maven.compiler.release property")
+        void shouldDetectFromCompilerRelease() {
+            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>
+                    <properties>
+                        <maven.compiler.release>17</maven.compiler.release>
+                    </properties>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            assertEquals(17, strategy.detectProjectJdkVersion(document));
+        }
+
+        @Test
+        @DisplayName("should detect JDK version from maven.compiler.source property")
+        void shouldDetectFromCompilerSource() {
+            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>
+                    <properties>
+                        <maven.compiler.source>11</maven.compiler.source>
+                    </properties>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            assertEquals(11, strategy.detectProjectJdkVersion(document));
+        }
+
+        @Test
+        @DisplayName("should detect JDK version from maven.compiler.target property")
+        void shouldDetectFromCompilerTarget() {
+            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>
+                    <properties>
+                        <maven.compiler.target>17</maven.compiler.target>
+                    </properties>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            assertEquals(17, strategy.detectProjectJdkVersion(document));
+        }
+
+        @Test
+        @DisplayName("should prefer maven.compiler.release over source/target")
+        void shouldPreferReleaseOverSource() {
+            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>
+                    <properties>
+                        <maven.compiler.release>21</maven.compiler.release>
+                        <maven.compiler.source>17</maven.compiler.source>
+                        <maven.compiler.target>17</maven.compiler.target>
+                    </properties>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            assertEquals(21, strategy.detectProjectJdkVersion(document));
+        }
+
+        @Test
+        @DisplayName("should normalize old-style version like 1.8 to 8")
+        void shouldNormalizeOldStyleVersion() {
+            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>
+                    <properties>
+                        <maven.compiler.source>1.8</maven.compiler.source>
+                    </properties>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            assertEquals(8, strategy.detectProjectJdkVersion(document));
+        }
+
+        @Test
+        @DisplayName("should detect JDK version from compiler plugin configuration")
+        void shouldDetectFromCompilerPluginConfig() {
+            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>
+                    <build>
+                        <plugins>
+                            <plugin>
+                                <groupId>org.apache.maven.plugins</groupId>
+                                <artifactId>maven-compiler-plugin</artifactId>
+                                <configuration>
+                                    <release>17</release>
+                                </configuration>
+                            </plugin>
+                        </plugins>
+                    </build>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            assertEquals(17, strategy.detectProjectJdkVersion(document));
+        }
+
+        @Test
+        @DisplayName("should return -1 when no JDK version is configured")
+        void shouldReturnNegativeOneWhenNoJdkConfigured() {
+            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 = Document.of(pomXml);
+            assertEquals(-1, strategy.detectProjectJdkVersion(document));
+        }
+
+        @Test
+        @DisplayName("should skip property references (${...}) and return -1")
+        void shouldSkipPropertyReferences() {
+            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>
+                    <properties>
+                        <maven.compiler.release>${java.version}</maven.compiler.release>
+                    </properties>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            assertEquals(-1, strategy.detectProjectJdkVersion(document));
+        }
+    }
+
+    @Nested
+    @DisplayName("JDK Compatibility Skip")
+    class JdkCompatibilitySkipTests {
+
+        @Test
+        @DisplayName("should skip checkstyle-plugin upgrade when project targets JDK 17")
+        void shouldSkipCheckstyleUpgradeForJdk17() 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.0</version>
+                    <properties>
+                        <maven.compiler.release>17</maven.compiler.release>
+                    </properties>
+                    <build>
+                        <plugins>
+                            <plugin>
+                                <groupId>org.apache.maven.plugins</groupId>
+                                <artifactId>maven-checkstyle-plugin</artifactId>
+                                <version>3.4.0</version>
+                            </plugin>
+                        </plugins>
+                    </build>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            Map<Path, Document> pomMap = Map.of(Paths.get("pom.xml"), document);
+
+            UpgradeContext context = createMockContext();
+            UpgradeResult result = strategy.doApply(context, pomMap);
+
+            assertTrue(result.success(), "Strategy should succeed");
+
+            String xml = DomUtils.toXml(document);
+            assertTrue(
+                    xml.contains("<version>3.4.0</version>"),
+                    "checkstyle-plugin should NOT be upgraded when project targets JDK 17");
+            assertFalse(xml.contains("<version>3.6.0</version>"), "checkstyle-plugin should NOT be set to 3.6.0");
+        }
+
+        @Test
+        @DisplayName("should skip checkstyle-plugin upgrade when project targets JDK 11")
+        void shouldSkipCheckstyleUpgradeForJdk11() 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.0</version>
+                    <properties>
+                        <maven.compiler.source>11</maven.compiler.source>
+                        <maven.compiler.target>11</maven.compiler.target>
+                    </properties>
+                    <build>
+                        <plugins>
+                            <plugin>
+                                <groupId>org.apache.maven.plugins</groupId>
+                                <artifactId>maven-checkstyle-plugin</artifactId>
+                                <version>3.3.0</version>
+                            </plugin>
+                        </plugins>
+                    </build>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            Map<Path, Document> pomMap = Map.of(Paths.get("pom.xml"), document);
+
+            UpgradeContext context = createMockContext();
+            UpgradeResult result = strategy.doApply(context, pomMap);
+
+            assertTrue(result.success(), "Strategy should succeed");
+
+            String xml = DomUtils.toXml(document);
+            assertTrue(
+                    xml.contains("<version>3.3.0</version>"),
+                    "checkstyle-plugin should NOT be upgraded when project targets JDK 11");
+        }
+
+        @Test
+        @DisplayName("should upgrade checkstyle-plugin when project targets JDK 21")
+        void shouldUpgradeCheckstyleForJdk21() 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.0</version>
+                    <properties>
+                        <maven.compiler.release>21</maven.compiler.release>
+                    </properties>
+                    <build>
+                        <plugins>
+                            <plugin>
+                                <groupId>org.apache.maven.plugins</groupId>
+                                <artifactId>maven-checkstyle-plugin</artifactId>
+                                <version>3.4.0</version>
+                            </plugin>
+                        </plugins>
+                    </build>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            Map<Path, Document> pomMap = Map.of(Paths.get("pom.xml"), document);
+
+            UpgradeContext context = createMockContext();
+            UpgradeResult result = strategy.doApply(context, pomMap);
+
+            assertTrue(result.success(), "Strategy should succeed");
+
+            Editor editor = new Editor(document);
+            String version = editor.root()
+                    .path("build", "plugins", "plugin", "version")
+                    .map(Element::textContentTrimmed)
+                    .orElse(null);
+            assertEquals("3.6.0", version, "checkstyle-plugin should be upgraded to 3.6.0 for JDK 21");
+        }
+
+        @Test
+        @DisplayName("should upgrade checkstyle-plugin when project targets JDK 23")
+        void shouldUpgradeCheckstyleForJdk23() 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.0</version>
+                    <properties>
+                        <maven.compiler.release>23</maven.compiler.release>
+                    </properties>
+                    <build>
+                        <plugins>
+                            <plugin>
+                                <groupId>org.apache.maven.plugins</groupId>
+                                <artifactId>maven-checkstyle-plugin</artifactId>
+                                <version>3.4.0</version>
+                            </plugin>
+                        </plugins>
+                    </build>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            Map<Path, Document> pomMap = Map.of(Paths.get("pom.xml"), document);
+
+            UpgradeContext context = createMockContext();
+            UpgradeResult result = strategy.doApply(context, pomMap);
+
+            assertTrue(result.success(), "Strategy should succeed");
+
+            Editor editor = new Editor(document);
+            String version = editor.root()
+                    .path("build", "plugins", "plugin", "version")
+                    .map(Element::textContentTrimmed)
+                    .orElse(null);
+            assertEquals("3.6.0", version, "checkstyle-plugin should be upgraded to 3.6.0 for JDK 23");
+        }
+
+        @Test
+        @DisplayName("should upgrade checkstyle-plugin when no JDK version is configured")
+        void shouldUpgradeCheckstyleWhenNoJdkConfigured() 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.0</version>
+                    <build>
+                        <plugins>
+                            <plugin>
+                                <groupId>org.apache.maven.plugins</groupId>
+                                <artifactId>maven-checkstyle-plugin</artifactId>
+                                <version>3.4.0</version>
+                            </plugin>
+                        </plugins>
+                    </build>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            Map<Path, Document> pomMap = Map.of(Paths.get("pom.xml"), document);
+
+            UpgradeContext context = createMockContext();
+            UpgradeResult result = strategy.doApply(context, pomMap);
+
+            assertTrue(result.success(), "Strategy should succeed");
+
+            Editor editor = new Editor(document);
+            String version = editor.root()
+                    .path("build", "plugins", "plugin", "version")
+                    .map(Element::textContentTrimmed)
+                    .orElse(null);
+            assertEquals("3.6.0", version, "checkstyle-plugin should be upgraded when no JDK version is configured");
+        }
+
+        @Test
+        @DisplayName("should skip checkstyle-plugin upgrade via property version for JDK 17")
+        void shouldSkipCheckstylePropertyUpgradeForJdk17() 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.0</version>
+                    <properties>
+                        <maven.compiler.release>17</maven.compiler.release>
+                        <checkstyle-plugin.version>3.4.0</checkstyle-plugin.version>
+                    </properties>
+                    <build>
+                        <plugins>
+                            <plugin>
+                                <groupId>org.apache.maven.plugins</groupId>
+                                <artifactId>maven-checkstyle-plugin</artifactId>
+                                <version>${checkstyle-plugin.version}</version>
+                            </plugin>
+                        </plugins>
+                    </build>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            Map<Path, Document> pomMap = Map.of(Paths.get("pom.xml"), document);
+
+            UpgradeContext context = createMockContext();
+            UpgradeResult result = strategy.doApply(context, pomMap);
+
+            assertTrue(result.success(), "Strategy should succeed");
+
+            String xml = DomUtils.toXml(document);
+            assertTrue(
+                    xml.contains("<checkstyle-plugin.version>3.4.0</checkstyle-plugin.version>"),
+                    "checkstyle-plugin property should NOT be upgraded for JDK 17");
+        }
+
+        @Test
+        @DisplayName("should not skip plugins without minJdk requirement")
+        void shouldNotSkipPluginsWithoutMinJdk() 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.0</version>
+                    <properties>
+                        <maven.compiler.release>11</maven.compiler.release>
+                    </properties>
+                    <build>
+                        <plugins>
+                            <plugin>
+                                <groupId>org.apache.maven.plugins</groupId>
+                                <artifactId>maven-surefire-plugin</artifactId>
+                                <version>3.0.0</version>
+                            </plugin>
+                        </plugins>
+                    </build>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            Map<Path, Document> pomMap = Map.of(Paths.get("pom.xml"), document);
+
+            UpgradeContext context = createMockContext();
+            UpgradeResult result = strategy.doApply(context, pomMap);
+
+            assertTrue(result.success(), "Strategy should succeed");
+
+            Editor editor = new Editor(document);
+            String version = editor.root()
+                    .path("build", "plugins", "plugin", "version")
+                    .map(Element::textContentTrimmed)
+                    .orElse(null);
+            assertEquals("3.5.2", version, "surefire-plugin should still be upgraded (no minJdk requirement)");
+        }
+
+        @Test
+        @DisplayName("should skip checkstyle-plugin in pluginManagement for JDK 17")
+        void shouldSkipCheckstyleInPluginManagementForJdk17() 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.0</version>
+                    <properties>
+                        <maven.compiler.release>17</maven.compiler.release>
+                    </properties>
+                    <build>
+                        <pluginManagement>
+                            <plugins>
+                                <plugin>
+                                    <groupId>org.apache.maven.plugins</groupId>
+                                    <artifactId>maven-checkstyle-plugin</artifactId>
+                                    <version>3.4.0</version>
+                                </plugin>
+                            </plugins>
+                        </pluginManagement>
+                    </build>
+                </project>
+                """;
+
+            Document document = Document.of(pomXml);
+            Map<Path, Document> pomMap = Map.of(Paths.get("pom.xml"), document);
+
+            UpgradeContext context = createMockContext();
+            UpgradeResult result = strategy.doApply(context, pomMap);
+
+            assertTrue(result.success(), "Strategy should succeed");
+
+            String xml = DomUtils.toXml(document);
+            assertTrue(
+                    xml.contains("<version>3.4.0</version>"),
+                    "checkstyle-plugin in pluginManagement should NOT be upgraded for JDK 17");
+        }
+    }
+
+    @Nested
+    @DisplayName("PluginUpgrade Record")
+    class PluginUpgradeRecordTests {
+
+        @Test
+        @DisplayName("should have minJdk=0 for convenience constructor without JDK")
+        void shouldHaveDefaultMinJdk() {
+            PluginUpgrade upgrade = new PluginUpgrade("g", "a", "1.0", "reason");
+            assertEquals(0, upgrade.minJdk(), "Default minJdk should be 0");
+        }
+
+        @Test
+        @DisplayName("should have minJdk=0 for five-arg convenience constructor")
+        void shouldHaveDefaultMinJdkForFiveArgConstructor() {
+            PluginUpgrade upgrade = new PluginUpgrade("g", "a", "1.0", "2.0-beta-1", "reason");
+            assertEquals(0, upgrade.minJdk(), "Default minJdk should be 0 for five-arg constructor");
+        }
+
+        @Test
+        @DisplayName("should preserve minJdk value in full constructor")
+        void shouldPreserveMinJdk() {
+            PluginUpgrade upgrade = new PluginUpgrade("g", "a", "1.0", null, "reason", 21);
+            assertEquals(21, upgrade.minJdk(), "minJdk should be preserved");
+        }
+
+        @Test
+        @DisplayName("checkstyle plugin upgrade should have minJdk=21")
+        void checkstylePluginShouldHaveMinJdk21() {
+            PluginUpgrade checkstyleUpgrade = PluginUpgradeStrategy.getPluginUpgrades().stream()
+                    .filter(u -> "maven-checkstyle-plugin".equals(u.artifactId()))
+                    .findFirst()
+                    .orElse(null);
+
+            assertTrue(checkstyleUpgrade != null, "checkstyle-plugin should be in PLUGIN_UPGRADES");
+            assertEquals(21, checkstyleUpgrade.minJdk(), "checkstyle-plugin should require JDK 21");
+            assertEquals("3.6.0", checkstyleUpgrade.minVersion(), "checkstyle-plugin minVersion should be 3.6.0");
+        }
+    }
+}