DuplicateElementStrategy.java
/*
* 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.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import eu.maveniverse.domtrip.Document;
import eu.maveniverse.domtrip.Element;
import org.apache.maven.api.cli.mvnup.UpgradeOptions;
import org.apache.maven.api.di.Named;
import org.apache.maven.api.di.Priority;
import org.apache.maven.api.di.Singleton;
import org.apache.maven.cling.invoker.mvnup.UpgradeContext;
/**
* Strategy for removing duplicate XML elements in POM files.
*
* <p>Maven 4's stricter POM parser rejects duplicate XML elements (such as
* {@code <artifactId>}, {@code <properties>}, {@code <version>}, etc.) that
* Maven 3 silently accepted using last-wins semantics. This strategy scans
* each POM element's children and removes duplicates, keeping only the last
* occurrence of each element name.
*
* <p>This strategy only targets "scalar" elements at well-known POM schema
* positions ��� elements that should appear at most once within their parent
* according to the Maven POM schema. Elements inside list containers
* (e.g., {@code <dependency>} inside {@code <dependencies>},
* {@code <plugin>} inside {@code <plugins>}) are not affected by this strategy
* since duplicate dependencies and plugins are handled by
* {@link DeduplicateDependenciesStrategy}.
*
* <p>Plugin {@code <configuration>} elements are skipped entirely, because they
* contain free-form, plugin-specific XML whose schema is not known to this tool.
* Treating same-named children as duplicates in configuration sections (e.g.,
* multiple {@code <arg>} entries inside {@code <compilerArgs>}) would silently
* remove valid list entries and break builds.
*
* @see <a href="https://github.com/apache/maven/issues/12530">#12530</a>
*/
@Named
@Singleton
@Priority(21)
public class DuplicateElementStrategy extends AbstractUpgradeStrategy {
/**
* Parent element names whose children are expected to repeat (list containers).
* These are skipped when checking for duplicate children since their child
* elements are naturally repeated (e.g., multiple {@code <dependency>} in
* {@code <dependencies>}).
*/
static final Set<String> LIST_CONTAINER_ELEMENTS = Set.of(
"dependencies",
"plugins",
"modules",
"subprojects",
"profiles",
"repositories",
"pluginRepositories",
"extensions",
"exclusions",
"executions",
"resources",
"testResources",
"notifiers",
"contributors",
"developers",
"licenses",
"mailingLists",
"goals",
"otherArchives",
"includes",
"excludes",
"filters",
"roles",
"reports");
@Override
public boolean isApplicable(UpgradeContext context) {
UpgradeOptions options = getOptions(context);
return isOptionEnabled(options, options.model(), true);
}
@Override
public String getDescription() {
return "Removing duplicate XML elements";
}
@Override
protected UpgradeResult doApply(UpgradeContext context, Map<Path, Document> pomMap) {
Set<Path> processedPoms = new HashSet<>();
Set<Path> modifiedPoms = new HashSet<>();
Set<Path> errorPoms = new HashSet<>();
for (Map.Entry<Path, Document> entry : pomMap.entrySet()) {
Path pomPath = entry.getKey();
Document pomDocument = entry.getValue();
processedPoms.add(pomPath);
context.info(pomPath + " (checking for duplicate XML elements)");
context.indent();
try {
boolean hasIssues = removeDuplicateElements(pomDocument.root(), context);
if (hasIssues) {
context.success("Duplicate XML elements removed");
modifiedPoms.add(pomPath);
} else {
context.success("No duplicate XML elements found");
}
} catch (Exception e) {
context.failure("Failed to remove duplicate XML elements: " + e.getMessage());
errorPoms.add(pomPath);
} finally {
context.unindent();
}
}
return new UpgradeResult(processedPoms, modifiedPoms, errorPoms);
}
/**
* Parent element names whose contents are free-form, plugin-specific XML.
* Deduplication is skipped entirely for these elements and their descendants,
* because same-named children (e.g., multiple {@code <arg>} in
* {@code <compilerArgs>}) are list entries, not schema-level duplicates.
*/
static final Set<String> FREEFORM_ELEMENTS = Set.of("configuration");
/**
* Recursively scans an element's children for duplicates and removes them.
* Uses last-wins semantics (consistent with Maven 3's behavior).
*
* @param element the element to scan
* @param context the upgrade context for logging
* @return true if any duplicates were removed
*/
private boolean removeDuplicateElements(Element element, UpgradeContext context) {
boolean removed = false;
// Skip free-form plugin configuration elements ��� their XML schema is
// plugin-specific and unknown to this tool. Treating same-named children
// as duplicates here would silently remove valid list entries (e.g.,
// <arg> elements inside <compilerArgs>) and break builds.
if (FREEFORM_ELEMENTS.contains(element.name())) {
return false;
}
// Skip list container elements ��� their children naturally repeat
if (LIST_CONTAINER_ELEMENTS.contains(element.name())) {
// Still recurse into each child to check for duplicates within them
for (Element child : element.childElements().toList()) {
removed |= removeDuplicateElements(child, context);
}
return removed;
}
// Collect children, tracking last occurrence of each name
List<Element> children = element.childElements().toList();
Map<String, Element> lastSeen = new HashMap<>();
List<Element> duplicates = new ArrayList<>();
for (Element child : children) {
String name = child.name();
Element previous = lastSeen.put(name, child);
if (previous != null) {
// Previous occurrence is the duplicate (last-wins)
duplicates.add(previous);
}
}
// Remove duplicates
for (Element duplicate : duplicates) {
String elementPath = buildElementPath(element);
context.detail("Removed duplicate <" + duplicate.name() + "> element in " + elementPath);
DomUtils.removeElement(duplicate);
removed = true;
}
// Recurse into surviving children
List<Element> survivingChildren = element.childElements().toList();
for (Element child : survivingChildren) {
removed |= removeDuplicateElements(child, context);
}
return removed;
}
/**
* Builds a human-readable path for the element for logging purposes.
*/
private String buildElementPath(Element element) {
List<String> segments = new ArrayList<>();
Element current = element;
while (current != null) {
segments.add(0, current.name());
current = current.parent() instanceof Element p ? p : null;
}
return String.join("/", segments);
}
}