View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.cling.invoker.mvnup.goals;
20  
21  import java.io.File;
22  import java.nio.file.Files;
23  import java.nio.file.Path;
24  import java.util.Comparator;
25  import java.util.HashMap;
26  import java.util.HashSet;
27  import java.util.List;
28  import java.util.Map;
29  import java.util.Set;
30  import java.util.stream.Collectors;
31  
32  import eu.maveniverse.domtrip.Document;
33  import eu.maveniverse.domtrip.Editor;
34  import eu.maveniverse.domtrip.Element;
35  import org.apache.maven.api.RemoteRepository;
36  import org.apache.maven.api.Session;
37  import org.apache.maven.api.cli.mvnup.UpgradeOptions;
38  import org.apache.maven.api.di.Inject;
39  import org.apache.maven.api.di.Named;
40  import org.apache.maven.api.di.Priority;
41  import org.apache.maven.api.di.Singleton;
42  import org.apache.maven.api.model.Build;
43  import org.apache.maven.api.model.Model;
44  import org.apache.maven.api.model.Parent;
45  import org.apache.maven.api.model.Plugin;
46  import org.apache.maven.api.model.PluginManagement;
47  import org.apache.maven.api.model.Repository;
48  import org.apache.maven.api.model.RepositoryPolicy;
49  import org.apache.maven.api.services.ModelBuilder;
50  import org.apache.maven.api.services.ModelBuilderRequest;
51  import org.apache.maven.api.services.ModelBuilderResult;
52  import org.apache.maven.api.services.RepositoryFactory;
53  import org.apache.maven.api.services.Sources;
54  import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
55  import org.apache.maven.impl.standalone.ApiRunner;
56  import org.codehaus.plexus.components.secdispatcher.Dispatcher;
57  import org.codehaus.plexus.components.secdispatcher.internal.dispatchers.LegacyDispatcher;
58  import org.eclipse.aether.internal.impl.DefaultPathProcessor;
59  import org.eclipse.aether.internal.impl.DefaultTransporterProvider;
60  import org.eclipse.aether.internal.impl.transport.http.DefaultChecksumExtractor;
61  import org.eclipse.aether.spi.connector.transport.TransporterProvider;
62  import org.eclipse.aether.transport.file.FileTransporterFactory;
63  import org.eclipse.aether.transport.jdk.JdkTransporterFactory;
64  
65  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.ARTIFACT_ID;
66  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.BUILD;
67  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.GROUP_ID;
68  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PARENT;
69  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGIN;
70  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGINS;
71  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGIN_MANAGEMENT;
72  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PROPERTIES;
73  import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.VERSION;
74  import static eu.maveniverse.domtrip.maven.MavenPomElements.Plugins.DEFAULT_MAVEN_PLUGIN_GROUP_ID;
75  import static eu.maveniverse.domtrip.maven.MavenPomElements.Plugins.MAVEN_4_COMPATIBILITY_REASON;
76  import static eu.maveniverse.domtrip.maven.MavenPomElements.Plugins.MAVEN_PLUGIN_PREFIX;
77  
78  /**
79   * Strategy for upgrading Maven plugins to recommended versions.
80   * Handles plugin version upgrades in build/plugins and build/pluginManagement sections.
81   */
82  @Named
83  @Singleton
84  @Priority(10)
85  public class PluginUpgradeStrategy extends AbstractUpgradeStrategy {
86  
87      private static final List<PluginUpgrade> PLUGIN_UPGRADES = List.of(
88              new PluginUpgrade(
89                      DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-compiler-plugin", "3.2.0", MAVEN_4_COMPATIBILITY_REASON),
90              new PluginUpgrade(
91                      DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-exec-plugin", "3.2.0", MAVEN_4_COMPATIBILITY_REASON),
92              new PluginUpgrade(
93                      DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-enforcer-plugin", "3.0.0", MAVEN_4_COMPATIBILITY_REASON),
94              new PluginUpgrade("org.codehaus.mojo", "flatten-maven-plugin", "1.2.7", MAVEN_4_COMPATIBILITY_REASON),
95              new PluginUpgrade(
96                      DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-shade-plugin", "3.5.0", MAVEN_4_COMPATIBILITY_REASON),
97              new PluginUpgrade(
98                      DEFAULT_MAVEN_PLUGIN_GROUP_ID,
99                      "maven-remote-resources-plugin",
100                     "3.0.0",
101                     MAVEN_4_COMPATIBILITY_REASON));
102 
103     private Session session;
104 
105     @Inject
106     public PluginUpgradeStrategy() {}
107 
108     @Override
109     public boolean isApplicable(UpgradeContext context) {
110         UpgradeOptions options = getOptions(context);
111         return isOptionEnabled(options, options.plugins(), true);
112     }
113 
114     @Override
115     public String getDescription() {
116         return "Upgrading Maven plugins to recommended versions";
117     }
118 
119     @Override
120     public UpgradeResult doApply(UpgradeContext context, Map<Path, Document> pomMap) {
121         Set<Path> processedPoms = new HashSet<>();
122         Set<Path> modifiedPoms = new HashSet<>();
123         Set<Path> errorPoms = new HashSet<>();
124 
125         try {
126             // Phase 1: Write all modifications to temp directory (keeping project structure)
127             Path tempDir = createTempProjectStructure(context, pomMap);
128 
129             // Phase 2: For each POM, build effective model using the session and analyze plugins
130             Map<Path, Set<String>> pluginsNeedingManagement =
131                     analyzePluginsUsingEffectiveModels(context, pomMap, tempDir);
132 
133             // Phase 3: Add plugin management to the last local parent in hierarchy
134             for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
135                 Path pomPath = entry.getKey();
136                 Document pomDocument = entry.getValue();
137                 processedPoms.add(pomPath);
138 
139                 context.info(pomPath + " (checking for plugin upgrades)");
140                 context.indent();
141 
142                 try {
143                     boolean hasUpgrades = false;
144 
145                     // Apply direct plugin upgrades in the document
146                     hasUpgrades |= upgradePluginsInDocument(pomDocument, context);
147 
148                     // Add plugin management based on effective model analysis
149                     // Note: pluginsNeedingManagement only contains entries for POMs that should receive plugin
150                     // management
151                     // (i.e., the "last local parent" for each plugin that needs management)
152                     Set<String> pluginsForThisPom = pluginsNeedingManagement.get(pomPath);
153                     if (pluginsForThisPom != null && !pluginsForThisPom.isEmpty()) {
154                         hasUpgrades |= addPluginManagementForEffectivePlugins(context, pomDocument, pluginsForThisPom);
155                         context.detail("Added plugin management to " + pomPath + " (target parent for "
156                                 + pluginsForThisPom.size() + " plugins)");
157                     }
158 
159                     if (hasUpgrades) {
160                         modifiedPoms.add(pomPath);
161                         context.success("Plugin upgrades applied");
162                     } else {
163                         context.success("No plugin upgrades needed");
164                     }
165                 } catch (Exception e) {
166                     context.failure("Failed to upgrade plugins: " + e.getMessage());
167                     errorPoms.add(pomPath);
168                 } finally {
169                     context.unindent();
170                 }
171             }
172 
173             // Clean up temp directory
174             cleanupTempDirectory(tempDir);
175 
176         } catch (Exception e) {
177             context.failure("Failed to create temp project structure: " + e.getMessage());
178             // Mark all POMs as errors
179             errorPoms.addAll(pomMap.keySet());
180         }
181 
182         return new UpgradeResult(processedPoms, modifiedPoms, errorPoms);
183     }
184 
185     /**
186      * Upgrades plugins in the document.
187      * Checks both build/plugins and build/pluginManagement/plugins sections.
188      * Only processes plugins explicitly defined in the current POM document.
189      */
190     private boolean upgradePluginsInDocument(Document pomDocument, UpgradeContext context) {
191         Element root = pomDocument.root();
192         boolean hasUpgrades = false;
193 
194         // Define the plugins that need to be upgraded for Maven 4 compatibility
195         Map<String, PluginUpgradeInfo> pluginUpgrades = getPluginUpgradesMap();
196 
197         // Check build/plugins
198         Element buildElement = root.child(BUILD).orElse(null);
199         if (buildElement != null) {
200             Element pluginsElement = buildElement.child(PLUGINS).orElse(null);
201             if (pluginsElement != null) {
202                 hasUpgrades |= upgradePluginsInSection(
203                         pluginsElement, pluginUpgrades, pomDocument, BUILD + "/" + PLUGINS, context);
204             }
205 
206             // Check build/pluginManagement/plugins
207             Element pluginManagementElement =
208                     buildElement.child(PLUGIN_MANAGEMENT).orElse(null);
209             if (pluginManagementElement != null) {
210                 Element managedPluginsElement =
211                         pluginManagementElement.child(PLUGINS).orElse(null);
212                 if (managedPluginsElement != null) {
213                     hasUpgrades |= upgradePluginsInSection(
214                             managedPluginsElement,
215                             pluginUpgrades,
216                             pomDocument,
217                             BUILD + "/" + PLUGIN_MANAGEMENT + "/" + PLUGINS,
218                             context);
219                 }
220             }
221         }
222 
223         return hasUpgrades;
224     }
225 
226     /**
227      * Returns the map of plugins that need to be upgraded for Maven 4 compatibility.
228      */
229     private Map<String, PluginUpgradeInfo> getPluginUpgradesMap() {
230         Map<String, PluginUpgradeInfo> upgrades = new HashMap<>();
231         upgrades.put(
232                 DEFAULT_MAVEN_PLUGIN_GROUP_ID + ":maven-compiler-plugin",
233                 new PluginUpgradeInfo(DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-compiler-plugin", "3.2"));
234         upgrades.put(
235                 "org.codehaus.mojo:exec-maven-plugin",
236                 new PluginUpgradeInfo("org.codehaus.mojo", "exec-maven-plugin", "3.2.0"));
237         upgrades.put(
238                 DEFAULT_MAVEN_PLUGIN_GROUP_ID + ":maven-enforcer-plugin",
239                 new PluginUpgradeInfo(DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-enforcer-plugin", "3.0.0"));
240         upgrades.put(
241                 "org.codehaus.mojo:flatten-maven-plugin",
242                 new PluginUpgradeInfo("org.codehaus.mojo", "flatten-maven-plugin", "1.2.7"));
243         upgrades.put(
244                 DEFAULT_MAVEN_PLUGIN_GROUP_ID + ":maven-shade-plugin",
245                 new PluginUpgradeInfo(DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-shade-plugin", "3.5.0"));
246         upgrades.put(
247                 DEFAULT_MAVEN_PLUGIN_GROUP_ID + ":maven-remote-resources-plugin",
248                 new PluginUpgradeInfo(DEFAULT_MAVEN_PLUGIN_GROUP_ID, "maven-remote-resources-plugin", "3.0.0"));
249         return upgrades;
250     }
251 
252     /**
253      * Upgrades plugins in a specific plugins section (either build/plugins or build/pluginManagement/plugins).
254      */
255     private boolean upgradePluginsInSection(
256             Element pluginsElement,
257             Map<String, PluginUpgradeInfo> pluginUpgrades,
258             Document pomDocument,
259             String sectionName,
260             UpgradeContext context) {
261 
262         return pluginsElement
263                 .children(PLUGIN)
264                 .map(pluginElement -> {
265                     String groupId = getChildText(pluginElement, GROUP_ID);
266                     String artifactId = getChildText(pluginElement, ARTIFACT_ID);
267 
268                     // Default groupId for Maven plugins
269                     if (groupId == null && artifactId != null && artifactId.startsWith(MAVEN_PLUGIN_PREFIX)) {
270                         groupId = DEFAULT_MAVEN_PLUGIN_GROUP_ID;
271                     }
272 
273                     if (groupId != null && artifactId != null) {
274                         String pluginKey = groupId + ":" + artifactId;
275                         PluginUpgradeInfo upgrade = pluginUpgrades.get(pluginKey);
276 
277                         if (upgrade != null) {
278                             return upgradePluginVersion(pluginElement, upgrade, pomDocument, sectionName, context);
279                         }
280                     }
281                     return false;
282                 })
283                 .reduce(false, Boolean::logicalOr);
284     }
285 
286     /**
287      * Upgrades a specific plugin's version if needed.
288      */
289     private boolean upgradePluginVersion(
290             Element pluginElement,
291             PluginUpgradeInfo upgrade,
292             Document pomDocument,
293             String sectionName,
294             UpgradeContext context) {
295         Element versionElement = pluginElement.child(VERSION).orElse(null);
296         String currentVersion;
297         boolean isProperty = false;
298         String propertyName = null;
299 
300         if (versionElement != null) {
301             currentVersion = versionElement.textContentTrimmed();
302             // Check if version is a property reference
303             if (currentVersion.startsWith("${") && currentVersion.endsWith("}")) {
304                 isProperty = true;
305                 propertyName = currentVersion.substring(2, currentVersion.length() - 1);
306             }
307         } else {
308             // Plugin version might be inherited from parent or pluginManagement
309             context.debug("Plugin " + upgrade.groupId + ":" + upgrade.artifactId
310                     + " has no explicit version, may inherit from parent");
311             return false;
312         }
313 
314         if (isProperty) {
315             // Update property value if it's below minimum version
316             return upgradePropertyVersion(pomDocument, propertyName, upgrade, sectionName, context);
317         } else {
318             // Direct version comparison and upgrade
319             if (isVersionBelow(currentVersion, upgrade.minVersion)) {
320                 Editor editor = new Editor(pomDocument);
321                 editor.setTextContent(versionElement, upgrade.minVersion);
322                 context.detail("Upgraded " + upgrade.groupId + ":" + upgrade.artifactId + " from " + currentVersion
323                         + " to " + upgrade.minVersion + " in " + sectionName);
324                 return true;
325             } else {
326                 context.debug("Plugin " + upgrade.groupId + ":" + upgrade.artifactId + " version " + currentVersion
327                         + " is already >= " + upgrade.minVersion);
328             }
329         }
330 
331         return false;
332     }
333 
334     /**
335      * Upgrades a property value if it represents a plugin version below the minimum.
336      */
337     private boolean upgradePropertyVersion(
338             Document pomDocument,
339             String propertyName,
340             PluginUpgradeInfo upgrade,
341             String sectionName,
342             UpgradeContext context) {
343         Editor editor = new Editor(pomDocument);
344         Element root = editor.root();
345         Element propertiesElement = root.child(PROPERTIES).orElse(null);
346 
347         if (propertiesElement != null) {
348             Element propertyElement = propertiesElement.child(propertyName).orElse(null);
349             if (propertyElement != null) {
350                 String currentVersion = propertyElement.textContentTrimmed();
351                 if (isVersionBelow(currentVersion, upgrade.minVersion)) {
352                     editor.setTextContent(propertyElement, upgrade.minVersion);
353                     context.detail("Upgraded property " + propertyName + " (for " + upgrade.groupId
354                             + ":"
355                             + upgrade.artifactId + ") from " + currentVersion + " to " + upgrade.minVersion
356                             + " in "
357                             + sectionName);
358                     return true;
359                 } else {
360                     context.debug("Property " + propertyName + " version " + currentVersion + " is already >= "
361                             + upgrade.minVersion);
362                 }
363             } else {
364                 context.warning("Property " + propertyName + " not found in POM properties");
365             }
366         } else {
367             context.warning("No properties section found in POM for property " + propertyName);
368         }
369 
370         return false;
371     }
372 
373     /**
374      * Simple version comparison to check if current version is below minimum version.
375      * This is a basic implementation that works for most Maven plugin versions.
376      */
377     private boolean isVersionBelow(String currentVersion, String minVersion) {
378         if (currentVersion == null || minVersion == null) {
379             return false;
380         }
381 
382         // Remove any qualifiers like -SNAPSHOT, -alpha, etc. for comparison
383         String cleanCurrent = currentVersion.split("-")[0];
384         String cleanMin = minVersion.split("-")[0];
385 
386         try {
387             String[] currentParts = cleanCurrent.split("\\.");
388             String[] minParts = cleanMin.split("\\.");
389 
390             int maxLength = Math.max(currentParts.length, minParts.length);
391 
392             for (int i = 0; i < maxLength; i++) {
393                 int currentPart = i < currentParts.length ? Integer.parseInt(currentParts[i]) : 0;
394                 int minPart = i < minParts.length ? Integer.parseInt(minParts[i]) : 0;
395 
396                 if (currentPart < minPart) {
397                     return true;
398                 } else if (currentPart > minPart) {
399                     return false;
400                 }
401             }
402 
403             return false; // Versions are equal
404         } catch (NumberFormatException e) {
405             // Fallback to string comparison if parsing fails
406             return currentVersion.compareTo(minVersion) < 0;
407         }
408     }
409 
410     /**
411      * Helper method to get child element text.
412      */
413     private String getChildText(Element parent, String childName) {
414         Element child = parent.child(childName).orElse(null);
415         return child != null ? child.textContentTrimmed() : null;
416     }
417 
418     /**
419      * Gets the list of plugin upgrades to apply.
420      */
421     public static List<PluginUpgrade> getPluginUpgrades() {
422         return PLUGIN_UPGRADES;
423     }
424 
425     /**
426      * Gets or creates the cached Maven 4 session.
427      */
428     private Session getSession() {
429         if (session == null) {
430             session = createMaven4Session();
431         }
432         return session;
433     }
434 
435     /**
436      * Creates a new Maven 4 session for effective POM computation.
437      */
438     private Session createMaven4Session() {
439         Session session = ApiRunner.createSession(injector -> {
440             injector.bindInstance(Dispatcher.class, new LegacyDispatcher());
441 
442             injector.bindInstance(
443                     TransporterProvider.class,
444                     new DefaultTransporterProvider(Map.of(
445                             "https",
446                             new JdkTransporterFactory(
447                                     new DefaultChecksumExtractor(Map.of()), new DefaultPathProcessor()),
448                             "file",
449                             new FileTransporterFactory())));
450         });
451 
452         // Configure repositories
453         // TODO: we should read settings
454         RemoteRepository central =
455                 session.createRemoteRepository(RemoteRepository.CENTRAL_ID, "https://repo.maven.apache.org/maven2");
456         RemoteRepository snapshots = session.getService(RepositoryFactory.class)
457                 .createRemote(Repository.newBuilder()
458                         .id("apache-snapshots")
459                         .url("https://repository.apache.org/content/repositories/snapshots/")
460                         .releases(RepositoryPolicy.newBuilder().enabled("false").build())
461                         .snapshots(RepositoryPolicy.newBuilder().enabled("true").build())
462                         .build());
463 
464         return session.withRemoteRepositories(List.of(central, snapshots));
465     }
466 
467     /**
468      * Creates a temporary project structure with all POMs written to preserve relative paths.
469      * This allows Maven 4 API to properly resolve the project hierarchy.
470      */
471     private Path createTempProjectStructure(UpgradeContext context, Map<Path, Document> pomMap) throws Exception {
472         Path tempDir = Files.createTempDirectory("mvnup-project-");
473         context.debug("Created temp project directory: " + tempDir);
474 
475         // Find the common root of all POM paths to preserve relative structure
476         Path commonRoot = findCommonRoot(pomMap.keySet());
477         context.debug("Common root: " + commonRoot);
478 
479         // Write each POM to the temp directory, preserving relative structure
480         for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
481             Path originalPath = entry.getKey();
482             Document document = entry.getValue();
483 
484             // Calculate the relative path from common root
485             Path relativePath = commonRoot.relativize(originalPath);
486             Path tempPomPath = tempDir.resolve(relativePath);
487 
488             // Ensure parent directories exist
489             Files.createDirectories(tempPomPath.getParent());
490 
491             // Write POM to temp location
492             writePomToFile(document, tempPomPath);
493             context.debug("Wrote POM to temp location: " + tempPomPath);
494         }
495 
496         return tempDir;
497     }
498 
499     /**
500      * Finds the common root directory of all POM paths.
501      */
502     private Path findCommonRoot(Set<Path> pomPaths) {
503         Path commonRoot = null;
504         for (Path pomPath : pomPaths) {
505             Path parent = pomPath.getParent();
506             if (parent == null) {
507                 parent = Path.of(".");
508             }
509             if (commonRoot == null) {
510                 commonRoot = parent;
511             } else {
512                 // Find common ancestor
513                 while (!parent.startsWith(commonRoot)) {
514                     commonRoot = commonRoot.getParent();
515                     if (commonRoot == null) {
516                         break;
517                     }
518                 }
519             }
520         }
521         return commonRoot;
522     }
523 
524     /**
525      * Writes a Document to a file using the same format as the existing codebase.
526      */
527     private void writePomToFile(Document document, Path filePath) throws Exception {
528         Files.writeString(filePath, document.toXml());
529     }
530 
531     /**
532      * Analyzes plugins using effective models built from the temp directory.
533      * Returns a map of POM path to the set of plugin keys that need management.
534      */
535     private Map<Path, Set<String>> analyzePluginsUsingEffectiveModels(
536             UpgradeContext context, Map<Path, Document> pomMap, Path tempDir) {
537         Map<Path, Set<String>> result = new HashMap<>();
538         Map<String, PluginUpgrade> pluginUpgrades = getPluginUpgradesAsMap();
539 
540         for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
541             Path originalPomPath = entry.getKey();
542 
543             try {
544                 // Find the corresponding temp POM path
545                 Path commonRoot = findCommonRoot(pomMap.keySet());
546                 Path relativePath = commonRoot.relativize(originalPomPath);
547                 Path tempPomPath = tempDir.resolve(relativePath);
548 
549                 // Build effective model using Maven 4 API
550                 Set<String> pluginsNeedingUpgrade =
551                         analyzeEffectiveModelForPlugins(context, tempPomPath, pluginUpgrades);
552 
553                 // Determine where to add plugin management (last local parent)
554                 Path targetPomForManagement =
555                         findLastLocalParentForPluginManagement(context, tempPomPath, pomMap, tempDir, commonRoot);
556 
557                 if (targetPomForManagement != null) {
558                     result.computeIfAbsent(targetPomForManagement, k -> new HashSet<>())
559                             .addAll(pluginsNeedingUpgrade);
560 
561                     if (!pluginsNeedingUpgrade.isEmpty()) {
562                         context.debug("Will add plugin management to " + targetPomForManagement + " for plugins: "
563                                 + pluginsNeedingUpgrade);
564                     }
565                 }
566 
567             } catch (Exception e) {
568                 context.debug("Failed to analyze effective model for " + originalPomPath + ": " + e.getMessage());
569             }
570         }
571 
572         return result;
573     }
574 
575     /**
576      * Converts PluginUpgradeInfo map to PluginUpgrade map for compatibility.
577      */
578     private Map<String, PluginUpgrade> getPluginUpgradesAsMap() {
579         return PLUGIN_UPGRADES.stream()
580                 .collect(Collectors.toMap(
581                         upgrade -> upgrade.groupId() + ":" + upgrade.artifactId(), upgrade -> upgrade));
582     }
583 
584     /**
585      * Analyzes the effective model for a single POM to find plugins that need upgrades.
586      */
587     private Set<String> analyzeEffectiveModelForPlugins(
588             UpgradeContext context, Path tempPomPath, Map<String, PluginUpgrade> pluginUpgrades) {
589 
590         // Use the cached Maven 4 session
591         Session session = getSession();
592         ModelBuilder modelBuilder = session.getService(ModelBuilder.class);
593 
594         // Build effective model
595         ModelBuilderRequest request = ModelBuilderRequest.builder()
596                 .session(session)
597                 .source(Sources.buildSource(tempPomPath))
598                 .requestType(ModelBuilderRequest.RequestType.BUILD_EFFECTIVE)
599                 .recursive(false) // We only want this POM, not its modules
600                 .build();
601 
602         ModelBuilderResult result = modelBuilder.newSession().build(request);
603         Model effectiveModel = result.getEffectiveModel();
604 
605         // Analyze plugins from effective model
606         return analyzePluginsFromEffectiveModel(context, effectiveModel, pluginUpgrades);
607     }
608 
609     /**
610      * Analyzes plugins from the effective model and determines which ones need upgrades.
611      */
612     private Set<String> analyzePluginsFromEffectiveModel(
613             UpgradeContext context, Model effectiveModel, Map<String, PluginUpgrade> pluginUpgrades) {
614         Set<String> pluginsNeedingUpgrade = new HashSet<>();
615 
616         Build build = effectiveModel.getBuild();
617         if (build != null) {
618             // Check build/plugins - these are the actual plugins used in the build
619             for (Plugin plugin : build.getPlugins()) {
620                 String pluginKey = getPluginKey(plugin);
621                 PluginUpgrade upgrade = pluginUpgrades.get(pluginKey);
622                 if (upgrade != null) {
623                     String effectiveVersion = plugin.getVersion();
624                     if (isVersionBelow(effectiveVersion, upgrade.minVersion())) {
625                         pluginsNeedingUpgrade.add(pluginKey);
626                         context.debug("Plugin " + pluginKey + " version " + effectiveVersion + " needs upgrade to "
627                                 + upgrade.minVersion());
628                     }
629                 }
630             }
631 
632             // Check build/pluginManagement/plugins - these provide version management
633             PluginManagement pluginManagement = build.getPluginManagement();
634             if (pluginManagement != null) {
635                 for (Plugin plugin : pluginManagement.getPlugins()) {
636                     String pluginKey = getPluginKey(plugin);
637                     PluginUpgrade upgrade = pluginUpgrades.get(pluginKey);
638                     if (upgrade != null) {
639                         String effectiveVersion = plugin.getVersion();
640                         if (isVersionBelow(effectiveVersion, upgrade.minVersion())) {
641                             pluginsNeedingUpgrade.add(pluginKey);
642                             context.debug("Managed plugin " + pluginKey + " version " + effectiveVersion
643                                     + " needs upgrade to " + upgrade.minVersion());
644                         }
645                     }
646                 }
647             }
648         }
649 
650         return pluginsNeedingUpgrade;
651     }
652 
653     /**
654      * Gets the plugin key (groupId:artifactId) for a plugin, handling default groupId.
655      */
656     private String getPluginKey(Plugin plugin) {
657         String groupId = plugin.getGroupId();
658         String artifactId = plugin.getArtifactId();
659 
660         // Default groupId for Maven plugins
661         if (groupId == null && artifactId != null && artifactId.startsWith(MAVEN_PLUGIN_PREFIX)) {
662             groupId = DEFAULT_MAVEN_PLUGIN_GROUP_ID;
663         }
664 
665         return groupId + ":" + artifactId;
666     }
667 
668     /**
669      * Finds the last local parent in the hierarchy where plugin management should be added.
670      * This implements the algorithm: start with the effective model, check if parent is in pomMap,
671      * if so continue to its parent, else that's the target.
672      */
673     private Path findLastLocalParentForPluginManagement(
674             UpgradeContext context, Path tempPomPath, Map<Path, Document> pomMap, Path tempDir, Path commonRoot) {
675 
676         // Build effective model to get parent information
677         Session session = getSession();
678         ModelBuilder modelBuilder = session.getService(ModelBuilder.class);
679 
680         ModelBuilderRequest request = ModelBuilderRequest.builder()
681                 .session(session)
682                 .source(Sources.buildSource(tempPomPath))
683                 .requestType(ModelBuilderRequest.RequestType.BUILD_EFFECTIVE)
684                 .recursive(false)
685                 .build();
686 
687         ModelBuilderResult result = modelBuilder.newSession().build(request);
688         Model effectiveModel = result.getEffectiveModel();
689 
690         // Convert the temp path back to the original path
691         Path relativePath = tempDir.relativize(tempPomPath);
692         Path currentOriginalPath = commonRoot.resolve(relativePath);
693 
694         // Start with current POM as the candidate
695         Path lastLocalParent = currentOriginalPath;
696 
697         // Walk up the parent hierarchy
698         Model currentModel = effectiveModel;
699         while (currentModel.getParent() != null) {
700             Parent parent = currentModel.getParent();
701 
702             // Check if this parent is in our local pomMap
703             Path parentPath = findParentInPomMap(parent, pomMap);
704             if (parentPath != null) {
705                 // Parent is local, so it becomes our new candidate
706                 lastLocalParent = parentPath;
707 
708                 // Load the parent model to continue walking up
709                 Path parentTempPath = tempDir.resolve(commonRoot.relativize(parentPath));
710                 ModelBuilderRequest parentRequest = ModelBuilderRequest.builder()
711                         .session(session)
712                         .source(Sources.buildSource(parentTempPath))
713                         .requestType(ModelBuilderRequest.RequestType.BUILD_EFFECTIVE)
714                         .recursive(false)
715                         .build();
716 
717                 ModelBuilderResult parentResult = modelBuilder.newSession().build(parentRequest);
718                 currentModel = parentResult.getEffectiveModel();
719             } else {
720                 // Parent is external, stop here
721                 break;
722             }
723         }
724 
725         context.debug("Last local parent for " + currentOriginalPath + " is " + lastLocalParent);
726         return lastLocalParent;
727     }
728 
729     /**
730      * Finds a parent POM in the pomMap based on its coordinates.
731      */
732     private Path findParentInPomMap(Parent parent, Map<Path, Document> pomMap) {
733         String parentGroupId = parent.getGroupId();
734         String parentArtifactId = parent.getArtifactId();
735         String parentVersion = parent.getVersion();
736 
737         for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
738             Document doc = entry.getValue();
739             Element root = doc.root();
740 
741             // Extract GAV from this POM
742             String groupId = getChildText(root, GROUP_ID);
743             String artifactId = getChildText(root, ARTIFACT_ID);
744             String version = getChildText(root, VERSION);
745 
746             // Handle inheritance from parent
747             Element parentElement = root.child(PARENT).orElse(null);
748             if (parentElement != null) {
749                 if (groupId == null) {
750                     groupId = getChildText(parentElement, GROUP_ID);
751                 }
752                 if (version == null) {
753                     version = getChildText(parentElement, VERSION);
754                 }
755             }
756 
757             // Check if this POM matches the parent coordinates
758             if (parentGroupId.equals(groupId) && parentArtifactId.equals(artifactId) && parentVersion.equals(version)) {
759                 return entry.getKey();
760             }
761         }
762 
763         return null; // Parent not found in local project
764     }
765 
766     /**
767      * Adds plugin management entries for plugins found through effective model analysis.
768      */
769     private boolean addPluginManagementForEffectivePlugins(
770             UpgradeContext context, Document pomDocument, Set<String> pluginKeys) {
771 
772         Map<String, PluginUpgrade> pluginUpgrades = getPluginUpgradesAsMap();
773         boolean hasUpgrades = false;
774 
775         Element root = pomDocument.root();
776 
777         // Ensure build/pluginManagement/plugins structure exists
778         Element buildElement = root.child(BUILD).orElse(null);
779         if (buildElement == null) {
780             buildElement = DomUtils.insertNewElement(BUILD, root);
781         }
782 
783         Element pluginManagementElement = buildElement.child(PLUGIN_MANAGEMENT).orElse(null);
784         if (pluginManagementElement == null) {
785             pluginManagementElement = DomUtils.insertNewElement(PLUGIN_MANAGEMENT, buildElement);
786         }
787 
788         Element managedPluginsElement = pluginManagementElement.child(PLUGINS).orElse(null);
789         if (managedPluginsElement == null) {
790             managedPluginsElement = DomUtils.insertNewElement(PLUGINS, pluginManagementElement);
791         }
792 
793         // Add plugin management entries for each plugin
794         for (String pluginKey : pluginKeys) {
795             PluginUpgrade upgrade = pluginUpgrades.get(pluginKey);
796             if (upgrade != null) {
797                 // Check if plugin is already managed
798                 if (!isPluginAlreadyManagedInElement(managedPluginsElement, upgrade)) {
799                     addPluginManagementEntryFromUpgrade(managedPluginsElement, upgrade, context);
800                     hasUpgrades = true;
801                 }
802             }
803         }
804 
805         return hasUpgrades;
806     }
807 
808     /**
809      * Checks if a plugin is already managed in the given plugins element.
810      */
811     private boolean isPluginAlreadyManagedInElement(Element pluginsElement, PluginUpgrade upgrade) {
812         List<Element> pluginElements = pluginsElement.children(PLUGIN).toList();
813         for (Element pluginElement : pluginElements) {
814             String groupId = getChildText(pluginElement, GROUP_ID);
815             String artifactId = getChildText(pluginElement, ARTIFACT_ID);
816 
817             // Default groupId for Maven plugins
818             if (groupId == null && artifactId != null && artifactId.startsWith(MAVEN_PLUGIN_PREFIX)) {
819                 groupId = DEFAULT_MAVEN_PLUGIN_GROUP_ID;
820             }
821 
822             if (upgrade.groupId().equals(groupId) && upgrade.artifactId().equals(artifactId)) {
823                 return true;
824             }
825         }
826         return false;
827     }
828 
829     /**
830      * Adds a plugin management entry from a PluginUpgrade.
831      */
832     private void addPluginManagementEntryFromUpgrade(
833             Element managedPluginsElement, PluginUpgrade upgrade, UpgradeContext context) {
834         // Create plugin element using DomUtils convenience method for proper formatting
835         DomUtils.createPlugin(managedPluginsElement, upgrade.groupId(), upgrade.artifactId(), upgrade.minVersion());
836 
837         context.detail("Added plugin management for " + upgrade.groupId() + ":" + upgrade.artifactId() + " version "
838                 + upgrade.minVersion() + " (found through effective model analysis)");
839     }
840 
841     /**
842      * Cleans up the temporary directory.
843      */
844     private void cleanupTempDirectory(Path tempDir) {
845         try {
846             Files.walk(tempDir)
847                     .sorted(Comparator.reverseOrder())
848                     .map(Path::toFile)
849                     .forEach(File::delete);
850         } catch (Exception e) {
851             // Best effort cleanup - don't fail the whole operation
852         }
853     }
854 
855     /**
856      * Holds plugin upgrade information for Maven 4 compatibility.
857      * This class contains the minimum version requirements for plugins
858      * that need to be upgraded to work properly with Maven 4.
859      */
860     public static class PluginUpgradeInfo {
861         /** The Maven groupId of the plugin */
862         final String groupId;
863 
864         /** The Maven artifactId of the plugin */
865         final String artifactId;
866 
867         /** The minimum version required for Maven 4 compatibility */
868         final String minVersion;
869 
870         /**
871          * Creates a new plugin upgrade information holder.
872          *
873          * @param groupId the Maven groupId of the plugin
874          * @param artifactId the Maven artifactId of the plugin
875          * @param minVersion the minimum version required for Maven 4 compatibility
876          */
877         PluginUpgradeInfo(String groupId, String artifactId, String minVersion) {
878             this.groupId = groupId;
879             this.artifactId = artifactId;
880             this.minVersion = minVersion;
881         }
882     }
883 }