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.nio.file.Files;
22  import java.nio.file.Path;
23  import java.util.ArrayList;
24  import java.util.HashSet;
25  import java.util.List;
26  import java.util.Map;
27  import java.util.Objects;
28  import java.util.Set;
29  import java.util.stream.Stream;
30  
31  import org.apache.maven.api.cli.mvnup.UpgradeOptions;
32  import org.apache.maven.api.di.Named;
33  import org.apache.maven.api.di.Priority;
34  import org.apache.maven.api.di.Singleton;
35  import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
36  import org.jdom2.Content;
37  import org.jdom2.Document;
38  import org.jdom2.Element;
39  import org.jdom2.Namespace;
40  import org.jdom2.Text;
41  
42  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.Files.DEFAULT_PARENT_RELATIVE_PATH;
43  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.Files.POM_XML;
44  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.ModelVersions.MODEL_VERSION_4_1_0;
45  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.ARTIFACT_ID;
46  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.BUILD;
47  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.DEPENDENCY;
48  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.GROUP_ID;
49  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PARENT;
50  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PLUGIN;
51  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.PLUGINS;
52  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.RELATIVE_PATH;
53  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.SUBPROJECT;
54  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.SUBPROJECTS;
55  import static org.apache.maven.cling.invoker.mvnup.goals.UpgradeConstants.XmlElements.VERSION;
56  
57  /**
58   * Strategy for applying Maven inference optimizations.
59   * For 4.0.0 models: applies limited inference (parent-related only).
60   * For 4.1.0+ models: applies full inference optimizations.
61   * Removes redundant information that can be inferred by Maven during model building.
62   */
63  @Named
64  @Singleton
65  @Priority(30)
66  public class InferenceStrategy extends AbstractUpgradeStrategy {
67  
68      @Override
69      public boolean isApplicable(UpgradeContext context) {
70          UpgradeOptions options = getOptions(context);
71  
72          // Handle --all option (overrides individual options)
73          boolean useAll = options.all().orElse(false);
74          if (useAll) {
75              return true;
76          }
77  
78          // Check if --infer is explicitly set
79          if (options.infer().isPresent()) {
80              return options.infer().get();
81          }
82  
83          // Apply default behavior: if no specific options are provided, enable --infer
84          if (options.infer().isEmpty()
85                  && options.model().isEmpty()
86                  && options.plugins().isEmpty()
87                  && options.modelVersion().isEmpty()) {
88              return true;
89          }
90  
91          return false;
92      }
93  
94      @Override
95      public String getDescription() {
96          return "Applying Maven inference optimizations";
97      }
98  
99      @Override
100     public UpgradeResult doApply(UpgradeContext context, Map<Path, Document> pomMap) {
101         Set<Path> processedPoms = new HashSet<>();
102         Set<Path> modifiedPoms = new HashSet<>();
103         Set<Path> errorPoms = new HashSet<>();
104 
105         // Compute all GAVs for inference
106         Set<GAV> allGAVs = GAVUtils.computeAllGAVs(context, pomMap);
107 
108         for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
109             Path pomPath = entry.getKey();
110             Document pomDocument = entry.getValue();
111             processedPoms.add(pomPath);
112 
113             String currentVersion = ModelVersionUtils.detectModelVersion(pomDocument);
114             context.info(pomPath + " (current: " + currentVersion + ")");
115             context.indent();
116 
117             try {
118                 if (!ModelVersionUtils.isEligibleForInference(currentVersion)) {
119                     context.warning(
120                             "Model version " + currentVersion + " not eligible for inference (requires >= 4.0.0)");
121                     continue;
122                 }
123 
124                 boolean hasInferences = false;
125 
126                 // Apply limited parent inference for all eligible models (4.0.0+)
127                 hasInferences |= applyLimitedParentInference(context, pomDocument);
128 
129                 // Apply full inference optimizations only for 4.1.0+ models
130                 if (MODEL_VERSION_4_1_0.equals(currentVersion) || ModelVersionUtils.isNewerThan410(currentVersion)) {
131                     hasInferences |= applyFullParentInference(context, pomMap, pomDocument);
132                     hasInferences |= applyDependencyInference(context, allGAVs, pomDocument);
133                     hasInferences |= applyDependencyInferenceRedundancy(context, pomMap, pomDocument);
134                     hasInferences |= applySubprojectsInference(context, pomDocument, pomPath);
135                     hasInferences |= applyModelVersionInference(context, pomDocument);
136                 }
137 
138                 if (hasInferences) {
139                     modifiedPoms.add(pomPath);
140                     if (MODEL_VERSION_4_1_0.equals(currentVersion)
141                             || ModelVersionUtils.isNewerThan410(currentVersion)) {
142                         context.success("Full inference optimizations applied");
143                     } else {
144                         context.success("Limited inference optimizations applied (parent-related only)");
145                     }
146                 } else {
147                     context.success("No inference optimizations needed");
148                 }
149             } catch (Exception e) {
150                 context.failure("Failed to apply inference optimizations" + ": " + e.getMessage());
151                 errorPoms.add(pomPath);
152             } finally {
153                 context.unindent();
154             }
155         }
156 
157         return new UpgradeResult(processedPoms, modifiedPoms, errorPoms);
158     }
159 
160     /**
161      * Applies limited parent-related inference optimizations for Maven 4.0.0+ models.
162      * Removes redundant child groupId/version that can be inferred from parent.
163      */
164     private boolean applyLimitedParentInference(UpgradeContext context, Document pomDocument) {
165         Element root = pomDocument.getRootElement();
166         Namespace namespace = root.getNamespace();
167 
168         // Check if this POM has a parent
169         Element parentElement = root.getChild(PARENT, namespace);
170         if (parentElement == null) {
171             return false;
172         }
173 
174         // Apply limited inference (child groupId/version removal only)
175         return trimParentElementLimited(context, root, parentElement, namespace);
176     }
177 
178     /**
179      * Applies full parent-related inference optimizations for Maven 4.1.0+ models.
180      * Removes redundant parent elements that can be inferred from relativePath.
181      */
182     private boolean applyFullParentInference(UpgradeContext context, Map<Path, Document> pomMap, Document pomDocument) {
183         Element root = pomDocument.getRootElement();
184         Namespace namespace = root.getNamespace();
185 
186         // Check if this POM has a parent
187         Element parentElement = root.getChild(PARENT, namespace);
188         if (parentElement == null) {
189             return false;
190         }
191 
192         // Apply full inference (parent element trimming based on relativePath)
193         return trimParentElementFull(context, root, parentElement, namespace, pomMap);
194     }
195 
196     /**
197      * Applies dependency-related inference optimizations.
198      * Removes managed dependencies that point to project artifacts.
199      */
200     private boolean applyDependencyInference(UpgradeContext context, Set<GAV> allGAVs, Document pomDocument) {
201         boolean hasChanges = false;
202         Element root = pomDocument.getRootElement();
203         Namespace namespace = root.getNamespace();
204 
205         // Check dependencyManagement section
206         Element dependencyManagement = root.getChild("dependencyManagement", namespace);
207         if (dependencyManagement != null) {
208             Element dependencies = dependencyManagement.getChild("dependencies", namespace);
209             if (dependencies != null) {
210                 hasChanges |= removeManagedDependenciesFromSection(
211                         context, dependencies, namespace, allGAVs, "dependencyManagement");
212             }
213         }
214 
215         // Check profiles for dependencyManagement
216         Element profilesElement = root.getChild("profiles", namespace);
217         if (profilesElement != null) {
218             List<Element> profileElements = profilesElement.getChildren("profile", namespace);
219             for (Element profileElement : profileElements) {
220                 Element profileDependencyManagement = profileElement.getChild("dependencyManagement", namespace);
221                 if (profileDependencyManagement != null) {
222                     Element profileDependencies = profileDependencyManagement.getChild("dependencies", namespace);
223                     if (profileDependencies != null) {
224                         hasChanges |= removeManagedDependenciesFromSection(
225                                 context, profileDependencies, namespace, allGAVs, "profile dependencyManagement");
226                     }
227                 }
228             }
229         }
230 
231         return hasChanges;
232     }
233 
234     /**
235      * Applies dependency inference redundancy optimizations.
236      * Removes redundant groupId/version from regular dependencies that can be inferred from project artifacts.
237      */
238     private boolean applyDependencyInferenceRedundancy(
239             UpgradeContext context, Map<Path, Document> pomMap, Document pomDocument) {
240         Element root = pomDocument.getRootElement();
241         Namespace namespace = root.getNamespace();
242         boolean hasChanges = false;
243 
244         // Process main dependencies
245         Element dependenciesElement = root.getChild("dependencies", namespace);
246         if (dependenciesElement != null) {
247             hasChanges |= removeDependencyInferenceFromSection(
248                     context, dependenciesElement, namespace, pomMap, "dependencies");
249         }
250 
251         // Process profile dependencies
252         Element profilesElement = root.getChild("profiles", namespace);
253         if (profilesElement != null) {
254             List<Element> profileElements = profilesElement.getChildren("profile", namespace);
255             for (Element profileElement : profileElements) {
256                 Element profileDependencies = profileElement.getChild("dependencies", namespace);
257                 if (profileDependencies != null) {
258                     hasChanges |= removeDependencyInferenceFromSection(
259                             context, profileDependencies, namespace, pomMap, "profile dependencies");
260                 }
261             }
262         }
263 
264         // Process build plugin dependencies
265         Element buildElement = root.getChild(BUILD, namespace);
266         if (buildElement != null) {
267             Element pluginsElement = buildElement.getChild(PLUGINS, namespace);
268             if (pluginsElement != null) {
269                 List<Element> pluginElements = pluginsElement.getChildren(PLUGIN, namespace);
270                 for (Element pluginElement : pluginElements) {
271                     Element pluginDependencies = pluginElement.getChild("dependencies", namespace);
272                     if (pluginDependencies != null) {
273                         hasChanges |= removeDependencyInferenceFromSection(
274                                 context, pluginDependencies, namespace, pomMap, "plugin dependencies");
275                     }
276                 }
277             }
278         }
279 
280         return hasChanges;
281     }
282 
283     /**
284      * Applies subprojects-related inference optimizations.
285      * Removes redundant subprojects lists that match direct children.
286      */
287     private boolean applySubprojectsInference(UpgradeContext context, Document pomDocument, Path pomPath) {
288         boolean hasChanges = false;
289         Element root = pomDocument.getRootElement();
290         Namespace namespace = root.getNamespace();
291 
292         // Check main subprojects
293         Element subprojectsElement = root.getChild(SUBPROJECTS, namespace);
294         if (subprojectsElement != null) {
295             if (isSubprojectsListRedundant(subprojectsElement, namespace, pomPath)) {
296                 removeElementWithFormatting(subprojectsElement);
297                 context.detail("Removed: redundant subprojects list (matches direct children)");
298                 hasChanges = true;
299             }
300         }
301 
302         // Check profiles for subprojects
303         Element profilesElement = root.getChild("profiles", namespace);
304         if (profilesElement != null) {
305             List<Element> profileElements = profilesElement.getChildren("profile", namespace);
306             for (Element profileElement : profileElements) {
307                 Element profileSubprojects = profileElement.getChild(SUBPROJECTS, namespace);
308                 if (profileSubprojects != null) {
309                     if (isSubprojectsListRedundant(profileSubprojects, namespace, pomPath)) {
310                         removeElementWithFormatting(profileSubprojects);
311                         context.detail("Removed: redundant subprojects list from profile (matches direct children)");
312                         hasChanges = true;
313                     }
314                 }
315             }
316         }
317 
318         return hasChanges;
319     }
320 
321     /**
322      * Applies model version inference optimization.
323      * Removes modelVersion element when it can be inferred from namespace.
324      */
325     private boolean applyModelVersionInference(UpgradeContext context, Document pomDocument) {
326         String currentVersion = ModelVersionUtils.detectModelVersion(pomDocument);
327 
328         // Only remove modelVersion for 4.1.0+ models where it can be inferred from namespace
329         if (MODEL_VERSION_4_1_0.equals(currentVersion) || ModelVersionUtils.isNewerThan410(currentVersion)) {
330 
331             if (ModelVersionUtils.removeModelVersion(pomDocument)) {
332                 context.detail("Removed: modelVersion element (can be inferred from namespace)");
333                 return true;
334             }
335         }
336 
337         return false;
338     }
339 
340     /**
341      * Applies limited parent inference for 4.0.0 models.
342      * Only removes child groupId/version when they match parent.
343      */
344     private boolean trimParentElementLimited(
345             UpgradeContext context, Element root, Element parentElement, Namespace namespace) {
346         boolean hasChanges = false;
347 
348         // Get parent GAV
349         String parentGroupId = getChildText(parentElement, "groupId", namespace);
350         String parentVersion = getChildText(parentElement, "version", namespace);
351 
352         // Get child GAV
353         String childGroupId = getChildText(root, "groupId", namespace);
354         String childVersion = getChildText(root, "version", namespace);
355 
356         // Remove child groupId if it matches parent groupId
357         if (childGroupId != null && Objects.equals(childGroupId, parentGroupId)) {
358             Element childGroupIdElement = root.getChild("groupId", namespace);
359             if (childGroupIdElement != null) {
360                 removeElementWithFormatting(childGroupIdElement);
361                 context.detail("Removed: child groupId (matches parent)");
362                 hasChanges = true;
363             }
364         }
365 
366         // Remove child version if it matches parent version
367         if (childVersion != null && Objects.equals(childVersion, parentVersion)) {
368             Element childVersionElement = root.getChild("version", namespace);
369             if (childVersionElement != null) {
370                 removeElementWithFormatting(childVersionElement);
371                 context.detail("Removed: child version (matches parent)");
372                 hasChanges = true;
373             }
374         }
375 
376         return hasChanges;
377     }
378 
379     /**
380      * Applies full parent inference for 4.1.0+ models.
381      * Removes parent groupId/version/artifactId when they can be inferred.
382      */
383     private boolean trimParentElementFull(
384             UpgradeContext context,
385             Element root,
386             Element parentElement,
387             Namespace namespace,
388             Map<Path, Document> pomMap) {
389         boolean hasChanges = false;
390 
391         // Get child GAV before applying any changes
392         String childGroupId = getChildText(root, GROUP_ID, namespace);
393         String childVersion = getChildText(root, VERSION, namespace);
394 
395         // First apply limited inference (child elements) - this removes matching child groupId/version
396         hasChanges |= trimParentElementLimited(context, root, parentElement, namespace);
397 
398         // Only remove parent elements if the parent is in the same reactor (not external)
399         if (isParentInReactor(parentElement, namespace, pomMap, context)) {
400             // Remove parent groupId if child has no explicit groupId
401             if (childGroupId == null) {
402                 Element parentGroupIdElement = parentElement.getChild(GROUP_ID, namespace);
403                 if (parentGroupIdElement != null) {
404                     removeElementWithFormatting(parentGroupIdElement);
405                     context.detail("Removed: parent groupId (child has no explicit groupId)");
406                     hasChanges = true;
407                 }
408             }
409 
410             // Remove parent version if child has no explicit version
411             if (childVersion == null) {
412                 Element parentVersionElement = parentElement.getChild(VERSION, namespace);
413                 if (parentVersionElement != null) {
414                     removeElementWithFormatting(parentVersionElement);
415                     context.detail("Removed: parent version (child has no explicit version)");
416                     hasChanges = true;
417                 }
418             }
419 
420             // Remove parent artifactId if it can be inferred from relativePath
421             if (canInferParentArtifactId(parentElement, namespace, pomMap)) {
422                 Element parentArtifactIdElement = parentElement.getChild(ARTIFACT_ID, namespace);
423                 if (parentArtifactIdElement != null) {
424                     removeElementWithFormatting(parentArtifactIdElement);
425                     context.detail("Removed: parent artifactId (can be inferred from relativePath)");
426                     hasChanges = true;
427                 }
428             }
429         }
430 
431         return hasChanges;
432     }
433 
434     /**
435      * Determines if the parent is part of the same reactor (multi-module project)
436      * vs. an external parent POM by checking if the parent exists in the pomMap.
437      */
438     private boolean isParentInReactor(
439             Element parentElement, Namespace namespace, Map<Path, Document> pomMap, UpgradeContext context) {
440         // If relativePath is explicitly set to empty, parent is definitely external
441         String relativePath = getChildText(parentElement, RELATIVE_PATH, namespace);
442         if (relativePath != null && relativePath.trim().isEmpty()) {
443             return false;
444         }
445 
446         // Extract parent GAV
447         String parentGroupId = getChildText(parentElement, GROUP_ID, namespace);
448         String parentArtifactId = getChildText(parentElement, ARTIFACT_ID, namespace);
449         String parentVersion = getChildText(parentElement, VERSION, namespace);
450 
451         if (parentGroupId == null || parentArtifactId == null || parentVersion == null) {
452             // Cannot determine parent GAV, assume external
453             return false;
454         }
455 
456         GAV parentGAV = new GAV(parentGroupId, parentArtifactId, parentVersion);
457 
458         // Check if any POM in our reactor matches the parent GAV using GAVUtils
459         for (Document pomDocument : pomMap.values()) {
460             GAV pomGAV = GAVUtils.extractGAVWithParentResolution(context, pomDocument);
461             if (pomGAV != null && pomGAV.equals(parentGAV)) {
462                 return true;
463             }
464         }
465 
466         // Parent not found in reactor, must be external
467         return false;
468     }
469 
470     /**
471      * Determines if parent artifactId can be inferred from relativePath.
472      */
473     private boolean canInferParentArtifactId(Element parentElement, Namespace namespace, Map<Path, Document> pomMap) {
474         // Get relativePath (default is "../pom.xml" if not specified)
475         String relativePath = getChildText(parentElement, RELATIVE_PATH, namespace);
476         if (relativePath == null || relativePath.trim().isEmpty()) {
477             relativePath = DEFAULT_PARENT_RELATIVE_PATH; // Maven default
478         }
479 
480         // Only infer artifactId if relativePath is the default and we have multiple POMs
481         // indicating this is likely a multi-module project
482         return DEFAULT_PARENT_RELATIVE_PATH.equals(relativePath) && pomMap.size() > 1;
483     }
484 
485     /**
486      * Checks if a subprojects list is redundant (matches direct child directories with pom.xml).
487      */
488     private boolean isSubprojectsListRedundant(Element subprojectsElement, Namespace namespace, Path pomPath) {
489         List<Element> subprojectElements = subprojectsElement.getChildren(SUBPROJECT, namespace);
490         if (subprojectElements.isEmpty()) {
491             return true; // Empty list is redundant
492         }
493 
494         // Get the directory containing this POM
495         Path parentDir = pomPath.getParent();
496         if (parentDir == null) {
497             return false;
498         }
499 
500         // Get declared subprojects
501         Set<String> declaredSubprojects = new HashSet<>();
502         for (Element subprojectElement : subprojectElements) {
503             String subprojectName = subprojectElement.getTextTrim();
504             if (subprojectName != null && !subprojectName.isEmpty()) {
505                 declaredSubprojects.add(subprojectName);
506             }
507         }
508 
509         // Get list of actual direct child directories with pom.xml
510         Set<String> actualSubprojects = new HashSet<>();
511         try {
512             if (Files.exists(parentDir) && Files.isDirectory(parentDir)) {
513                 try (Stream<Path> children = Files.list(parentDir)) {
514                     children.filter(Files::isDirectory)
515                             .filter(dir -> Files.exists(dir.resolve(POM_XML)))
516                             .forEach(dir ->
517                                     actualSubprojects.add(dir.getFileName().toString()));
518                 }
519             }
520         } catch (Exception e) {
521             // If we can't read the directory, assume not redundant
522             return false;
523         }
524 
525         // Lists are redundant if they match exactly
526         return declaredSubprojects.equals(actualSubprojects);
527     }
528 
529     /**
530      * Helper method to remove managed dependencies from a specific dependencies section.
531      */
532     private boolean removeManagedDependenciesFromSection(
533             UpgradeContext context, Element dependencies, Namespace namespace, Set<GAV> allGAVs, String sectionName) {
534         List<Element> dependencyElements = dependencies.getChildren(DEPENDENCY, namespace);
535         List<Element> toRemove = new ArrayList<>();
536 
537         for (Element dependency : dependencyElements) {
538             String groupId = getChildText(dependency, GROUP_ID, namespace);
539             String artifactId = getChildText(dependency, ARTIFACT_ID, namespace);
540 
541             if (groupId != null && artifactId != null) {
542                 // Check if this dependency matches any project artifact
543                 boolean isProjectArtifact = allGAVs.stream()
544                         .anyMatch(gav ->
545                                 Objects.equals(gav.groupId(), groupId) && Objects.equals(gav.artifactId(), artifactId));
546 
547                 if (isProjectArtifact) {
548                     toRemove.add(dependency);
549                     context.detail("Removed: " + "managed dependency " + groupId + ":" + artifactId + " from "
550                             + sectionName + " (project artifact)");
551                 }
552             }
553         }
554 
555         // Remove project artifacts while preserving formatting
556         for (Element dependency : toRemove) {
557             removeElementWithFormatting(dependency);
558         }
559 
560         return !toRemove.isEmpty();
561     }
562 
563     /**
564      * Helper method to remove dependency inference redundancy from a specific dependencies section.
565      */
566     private boolean removeDependencyInferenceFromSection(
567             UpgradeContext context,
568             Element dependencies,
569             Namespace namespace,
570             Map<Path, Document> pomMap,
571             String sectionName) {
572         List<Element> dependencyElements = dependencies.getChildren(DEPENDENCY, namespace);
573         boolean hasChanges = false;
574 
575         for (Element dependency : dependencyElements) {
576             String groupId = getChildText(dependency, GROUP_ID, namespace);
577             String artifactId = getChildText(dependency, ARTIFACT_ID, namespace);
578             String version = getChildText(dependency, VERSION, namespace);
579 
580             if (artifactId != null) {
581                 // Try to find the dependency POM in our pomMap
582                 Document dependencyPom = findDependencyPom(context, pomMap, groupId, artifactId);
583                 if (dependencyPom != null) {
584                     // Check if we can infer groupId
585                     if (groupId != null && canInferDependencyGroupId(context, dependencyPom, groupId)) {
586                         Element groupIdElement = dependency.getChild(GROUP_ID, namespace);
587                         if (groupIdElement != null) {
588                             removeElementWithFormatting(groupIdElement);
589                             context.detail("Removed: " + "dependency groupId " + groupId + " from " + sectionName
590                                     + " (can be inferred from project)");
591                             hasChanges = true;
592                         }
593                     }
594 
595                     // Check if we can infer version
596                     if (version != null && canInferDependencyVersion(context, dependencyPom, version)) {
597                         Element versionElement = dependency.getChild(VERSION, namespace);
598                         if (versionElement != null) {
599                             removeElementWithFormatting(versionElement);
600                             context.detail("Removed: " + "dependency version " + version + " from " + sectionName
601                                     + " (can be inferred from project)");
602                             hasChanges = true;
603                         }
604                     }
605                 }
606             }
607         }
608 
609         return hasChanges;
610     }
611 
612     /**
613      * Finds a dependency POM in the pomMap by groupId and artifactId.
614      */
615     private Document findDependencyPom(
616             UpgradeContext context, Map<Path, Document> pomMap, String groupId, String artifactId) {
617         for (Document pomDocument : pomMap.values()) {
618             GAV gav = GAVUtils.extractGAVWithParentResolution(context, pomDocument);
619             if (gav != null && Objects.equals(gav.groupId(), groupId) && Objects.equals(gav.artifactId(), artifactId)) {
620                 return pomDocument;
621             }
622         }
623         return null;
624     }
625 
626     /**
627      * Determines if a dependency version can be inferred from the project artifact.
628      */
629     private boolean canInferDependencyVersion(UpgradeContext context, Document dependencyPom, String declaredVersion) {
630         GAV projectGav = GAVUtils.extractGAVWithParentResolution(context, dependencyPom);
631         if (projectGav == null || projectGav.version() == null) {
632             return false;
633         }
634 
635         // We can infer the version if the declared version matches the project version
636         return Objects.equals(declaredVersion, projectGav.version());
637     }
638 
639     /**
640      * Determines if a dependency groupId can be inferred from the project artifact.
641      */
642     private boolean canInferDependencyGroupId(UpgradeContext context, Document dependencyPom, String declaredGroupId) {
643         GAV projectGav = GAVUtils.extractGAVWithParentResolution(context, dependencyPom);
644         if (projectGav == null || projectGav.groupId() == null) {
645             return false;
646         }
647 
648         // We can infer the groupId if the declared groupId matches the project groupId
649         return Objects.equals(declaredGroupId, projectGav.groupId());
650     }
651 
652     /**
653      * Helper method to get child text content.
654      */
655     private String getChildText(Element parent, String childName, Namespace namespace) {
656         Element child = parent.getChild(childName, namespace);
657         return child != null ? child.getTextTrim() : null;
658     }
659 
660     /**
661      * Removes an element while preserving surrounding formatting.
662      */
663     private void removeElementWithFormatting(Element element) {
664         Element parent = element.getParentElement();
665         if (parent != null) {
666             int index = parent.indexOf(element);
667             parent.removeContent(element);
668 
669             // Remove preceding whitespace if it exists
670             if (index > 0) {
671                 Content prevContent = parent.getContent(index - 1);
672                 if (prevContent instanceof Text text && text.getTextTrim().isEmpty()) {
673                     parent.removeContent(prevContent);
674                 }
675             }
676         }
677     }
678 }