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/06/23 17:46:18 UTC
(maven) branch feature/gh-11904-dependency-id-attribute created (now e83a24fb1e)
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a change to branch feature/gh-11904-dependency-id-attribute
in repository https://gitbox.apache.org/repos/asf/maven.git
at e83a24fb1e [GH-11904] Add mvnup strategy to collapse dependencies into id attributes
This branch includes the following new commits:
new 5a8fcdb9df [GH-11904] Add dependency id attribute support for Maven 4.2.0
new e83a24fb1e [GH-11904] Add mvnup strategy to collapse dependencies into id attributes
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(maven) 02/02: [GH-11904] Add mvnup strategy to collapse dependencies into id attributes
Posted by gn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch feature/gh-11904-dependency-id-attribute
in repository https://gitbox.apache.org/repos/asf/maven.git
commit e83a24fb1ee990b5b250c72b665eefe2506a31bf
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Tue Jun 23 19:46:05 2026 +0200
[GH-11904] Add mvnup strategy to collapse dependencies into id attributes
Add DependencyIdStrategy that migrates verbose dependency/exclusion
child elements into compact `id` attributes when upgrading to model
version 4.2.0 via mvnup.
The strategy runs at @Priority(25), after InferenceStrategy (30), so
only dependencies that still have full explicit coordinates after
inference are collapsed. Triggered by --model-version 4.2.0 or --all.
Supported transformations:
- <dependency> g:a:v / g:a:type:v / g:a:type:classifier:v
- <exclusion> g:a
- Processes all sections: dependencies, dependencyManagement, profiles,
plugin dependencies
Inferred ids (partial coordinates like `:a` or `g:a` without version)
are intentionally not supported — the `id` attribute is a shorthand
for explicit coordinates, not a replacement for Maven's inference
mechanism. Dependencies with missing coordinates after inference are
left in their expanded form.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../invoker/mvnup/goals/DependencyIdStrategy.java | 294 +++++++
.../mvnup/goals/DependencyIdStrategyTest.java | 873 +++++++++++++++++++++
2 files changed, 1167 insertions(+)
diff --git a/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/DependencyIdStrategy.java b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/DependencyIdStrategy.java
new file mode 100644
index 0000000000..3b77c1e6bf
--- /dev/null
+++ b/impl/maven-cli/src/main/java/org/apache/maven/cling/invoker/mvnup/goals/DependencyIdStrategy.java
@@ -0,0 +1,294 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.cling.invoker.mvnup.goals;
+
+import java.nio.file.Path;
+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;
+
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.ARTIFACT_ID;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.BUILD;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.CLASSIFIER;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.DEPENDENCIES;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.DEPENDENCY;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.DEPENDENCY_MANAGEMENT;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.EXCLUSION;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.EXCLUSIONS;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.GROUP_ID;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGIN;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PLUGINS;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PROFILE;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.PROFILES;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.TYPE;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.Elements.VERSION;
+import static eu.maveniverse.domtrip.maven.MavenPomElements.ModelVersions.MODEL_VERSION_4_2_0;
+
+/**
+ * Strategy for collapsing dependency coordinate child elements into compact {@code id} attributes.
+ * Only applies to POMs at model version 4.2.0 or higher.
+ *
+ * <p>Transforms verbose dependency declarations:
+ * <pre>{@code
+ * <dependency>
+ * <groupId>org.example</groupId>
+ * <artifactId>lib</artifactId>
+ * <version>1.0</version>
+ * </dependency>
+ * }</pre>
+ * into compact form:
+ * <pre>{@code
+ * <dependency id="org.example:lib:1.0"/>
+ * }</pre>
+ *
+ * <p>Supports three formats:
+ * <ul>
+ * <li>{@code g:a:v} (3-part, default type "jar")</li>
+ * <li>{@code g:a:type:v} (4-part, non-default type)</li>
+ * <li>{@code g:a:type:classifier:v} (5-part, with classifier)</li>
+ * </ul>
+ *
+ * <p>Also collapses exclusions from {@code <groupId>}/{@code <artifactId>} into {@code id="g:a"}.
+ */
+@Named
+@Singleton
+@Priority(25)
+public class DependencyIdStrategy extends AbstractUpgradeStrategy {
+
+ private static final String DEFAULT_TYPE = "jar";
+
+ @Override
+ public boolean isApplicable(UpgradeContext context) {
+ UpgradeOptions options = getOptions(context);
+
+ if (options.all().orElse(false)) {
+ return true;
+ }
+
+ String modelVersion = options.modelVersion().orElse(null);
+ return MODEL_VERSION_4_2_0.equals(modelVersion);
+ }
+
+ @Override
+ public String getDescription() {
+ return "Collapsing dependency coordinates into id attributes";
+ }
+
+ @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);
+
+ String currentVersion = ModelVersionUtils.detectModelVersion(pomDocument);
+ context.info(pomPath + " (current: " + currentVersion + ")");
+ context.indent();
+
+ try {
+ if (!MODEL_VERSION_4_2_0.equals(currentVersion) && !ModelVersionUtils.isNewerThan410(currentVersion)) {
+ context.success("Skipping (model version " + currentVersion + " < 4.2.0)");
+ continue;
+ }
+ if (!MODEL_VERSION_4_2_0.equals(currentVersion)) {
+ context.success("Skipping (model version " + currentVersion + " is not 4.2.0)");
+ continue;
+ }
+
+ boolean hasChanges = collapseDependencies(context, pomDocument);
+
+ if (hasChanges) {
+ modifiedPoms.add(pomPath);
+ context.success("Dependency coordinates collapsed into id attributes");
+ } else {
+ context.success("No dependencies to collapse");
+ }
+ } catch (Exception e) {
+ context.failure("Failed to collapse dependency coordinates: " + e.getMessage());
+ errorPoms.add(pomPath);
+ } finally {
+ context.unindent();
+ }
+ }
+
+ return new UpgradeResult(processedPoms, modifiedPoms, errorPoms);
+ }
+
+ private boolean collapseDependencies(UpgradeContext context, Document pomDocument) {
+ Element root = pomDocument.root();
+ boolean hasChanges = false;
+
+ // Process <dependencies>
+ hasChanges |= processDependenciesSection(
+ context, root.childElement(DEPENDENCIES).orElse(null));
+
+ // Process <dependencyManagement><dependencies>
+ hasChanges |= root.childElement(DEPENDENCY_MANAGEMENT)
+ .flatMap(dm -> dm.childElement(DEPENDENCIES))
+ .map(deps -> processDependenciesSection(context, deps))
+ .orElse(false);
+
+ // Process <profiles><profile> dependencies and dependencyManagement
+ hasChanges |= root.childElement(PROFILES).stream()
+ .flatMap(profiles -> profiles.childElements(PROFILE))
+ .map(profile -> {
+ boolean profileChanges = false;
+ profileChanges |= profile.childElement(DEPENDENCIES)
+ .map(deps -> processDependenciesSection(context, deps))
+ .orElse(false);
+ profileChanges |= profile.childElement(DEPENDENCY_MANAGEMENT)
+ .flatMap(dm -> dm.childElement(DEPENDENCIES))
+ .map(deps -> processDependenciesSection(context, deps))
+ .orElse(false);
+ return profileChanges;
+ })
+ .reduce(false, Boolean::logicalOr);
+
+ // Process <build><plugins><plugin><dependencies>
+ hasChanges |= root.childElement(BUILD).flatMap(build -> build.childElement(PLUGINS)).stream()
+ .flatMap(plugins -> plugins.childElements(PLUGIN))
+ .map(plugin -> plugin.childElement(DEPENDENCIES)
+ .map(deps -> processDependenciesSection(context, deps))
+ .orElse(false))
+ .reduce(false, Boolean::logicalOr);
+
+ return hasChanges;
+ }
+
+ private boolean processDependenciesSection(UpgradeContext context, Element dependenciesElement) {
+ if (dependenciesElement == null) {
+ return false;
+ }
+
+ List<Element> dependencyElements =
+ dependenciesElement.childElements(DEPENDENCY).toList();
+ boolean hasChanges = false;
+
+ for (Element dependency : dependencyElements) {
+ hasChanges |= collapseDependency(context, dependency);
+ hasChanges |= collapseExclusions(context, dependency);
+ }
+
+ return hasChanges;
+ }
+
+ private boolean collapseDependency(UpgradeContext context, Element dependency) {
+ if (dependency.attribute("id") != null) {
+ return false;
+ }
+
+ String groupId = dependency.childText(GROUP_ID);
+ String artifactId = dependency.childText(ARTIFACT_ID);
+ String version = dependency.childText(VERSION);
+
+ if (groupId == null || artifactId == null || version == null) {
+ return false;
+ }
+
+ String type = dependency.childText(TYPE);
+ String classifier = dependency.childText(CLASSIFIER);
+
+ String id = buildIdValue(groupId, artifactId, version, type, classifier);
+ dependency.attribute("id", id);
+
+ removeChildElement(dependency, GROUP_ID);
+ removeChildElement(dependency, ARTIFACT_ID);
+ removeChildElement(dependency, VERSION);
+
+ if (classifier != null) {
+ removeChildElement(dependency, CLASSIFIER);
+ removeChildElement(dependency, TYPE);
+ } else if (type != null && !DEFAULT_TYPE.equals(type)) {
+ removeChildElement(dependency, TYPE);
+ } else if (type != null) {
+ // default type "jar" — remove it as it's implied
+ removeChildElement(dependency, TYPE);
+ }
+
+ context.detail("Collapsed: " + id);
+ return true;
+ }
+
+ private boolean collapseExclusions(UpgradeContext context, Element dependency) {
+ Element exclusionsElement = dependency.childElement(EXCLUSIONS).orElse(null);
+ if (exclusionsElement == null) {
+ return false;
+ }
+
+ List<Element> exclusionElements =
+ exclusionsElement.childElements(EXCLUSION).toList();
+ boolean hasChanges = false;
+
+ for (Element exclusion : exclusionElements) {
+ hasChanges |= collapseExclusion(context, exclusion);
+ }
+
+ return hasChanges;
+ }
+
+ private boolean collapseExclusion(UpgradeContext context, Element exclusion) {
+ if (exclusion.attribute("id") != null) {
+ return false;
+ }
+
+ String groupId = exclusion.childText(GROUP_ID);
+ String artifactId = exclusion.childText(ARTIFACT_ID);
+
+ if (groupId == null || artifactId == null) {
+ return false;
+ }
+
+ String id = groupId + ":" + artifactId;
+ exclusion.attribute("id", id);
+
+ removeChildElement(exclusion, GROUP_ID);
+ removeChildElement(exclusion, ARTIFACT_ID);
+
+ context.detail("Collapsed exclusion: " + id);
+ return true;
+ }
+
+ static String buildIdValue(String groupId, String artifactId, String version, String type, String classifier) {
+ if (classifier != null && !classifier.isEmpty()) {
+ String effectiveType = (type != null && !type.isEmpty()) ? type : DEFAULT_TYPE;
+ return groupId + ":" + artifactId + ":" + effectiveType + ":" + classifier + ":" + version;
+ } else if (type != null && !type.isEmpty() && !DEFAULT_TYPE.equals(type)) {
+ return groupId + ":" + artifactId + ":" + type + ":" + version;
+ } else {
+ return groupId + ":" + artifactId + ":" + version;
+ }
+ }
+
+ private static void removeChildElement(Element parent, String childName) {
+ parent.childElement(childName).ifPresent(DomUtils::removeElement);
+ }
+}
diff --git a/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/DependencyIdStrategyTest.java b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/DependencyIdStrategyTest.java
new file mode 100644
index 0000000000..5c8b9abc9c
--- /dev/null
+++ b/impl/maven-cli/src/test/java/org/apache/maven/cling/invoker/mvnup/goals/DependencyIdStrategyTest.java
@@ -0,0 +1,873 @@
+/*
+ * 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.HashMap;
+import java.util.Map;
+
+import eu.maveniverse.domtrip.Document;
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@DisplayName("DependencyIdStrategy")
+class DependencyIdStrategyTest {
+
+ private DependencyIdStrategy strategy;
+
+ @BeforeEach
+ void setUp() {
+ strategy = new DependencyIdStrategy();
+ }
+
+ @Nested
+ @DisplayName("Applicability")
+ class ApplicabilityTests {
+
+ @Test
+ @DisplayName("should not be applicable by default")
+ void shouldNotBeApplicableByDefault() {
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createDefaultOptions());
+ assertFalse(strategy.isApplicable(context));
+ }
+
+ @Test
+ @DisplayName("should be applicable with --model-version 4.2.0")
+ void shouldBeApplicableWithModelVersion420() {
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ assertTrue(strategy.isApplicable(context));
+ }
+
+ @Test
+ @DisplayName("should not be applicable with --model-version 4.1.0")
+ void shouldNotBeApplicableWithModelVersion410() {
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.1.0"));
+ assertFalse(strategy.isApplicable(context));
+ }
+
+ @Test
+ @DisplayName("should be applicable with --all")
+ void shouldBeApplicableWithAll() {
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithAll(true));
+ assertTrue(strategy.isApplicable(context));
+ }
+
+ @Test
+ @DisplayName("should not be applicable with --all=false")
+ void shouldNotBeApplicableWithAllFalse() {
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithAll(false));
+ assertFalse(strategy.isApplicable(context));
+ }
+ }
+
+ @Nested
+ @DisplayName("Dependency Collapsing")
+ class DependencyCollapsingTests {
+
+ @Test
+ @DisplayName("should collapse g:a:v dependency")
+ void shouldCollapseGav() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <version>1.0</version>
+ </dependency>
+ </dependencies>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ Map<Path, Document> pomMap = Map.of(Paths.get("pom.xml"), doc);
+
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(pomMap));
+
+ Element dependency = doc.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow();
+
+ assertEquals("org.example:lib:1.0", dependency.attribute("id"));
+ assertNull(DomUtils.findChildElement(dependency, "groupId"));
+ assertNull(DomUtils.findChildElement(dependency, "artifactId"));
+ assertNull(DomUtils.findChildElement(dependency, "version"));
+ }
+
+ @Test
+ @DisplayName("should collapse g:a:type:v dependency with non-default type")
+ void shouldCollapseGavWithType() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <type>pom</type>
+ <version>1.0</version>
+ </dependency>
+ </dependencies>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ Element dependency = doc.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow();
+
+ assertEquals("org.example:lib:pom:1.0", dependency.attribute("id"));
+ assertNull(DomUtils.findChildElement(dependency, "type"));
+ }
+
+ @Test
+ @DisplayName("should collapse g:a:type:classifier:v dependency")
+ void shouldCollapseGavWithClassifier() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <type>jar</type>
+ <classifier>sources</classifier>
+ <version>1.0</version>
+ </dependency>
+ </dependencies>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ Element dependency = doc.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow();
+
+ assertEquals("org.example:lib:jar:sources:1.0", dependency.attribute("id"));
+ assertNull(DomUtils.findChildElement(dependency, "type"));
+ assertNull(DomUtils.findChildElement(dependency, "classifier"));
+ }
+
+ @Test
+ @DisplayName("should use default type jar in 5-part format when type absent but classifier present")
+ void shouldUseDefaultTypeWhenClassifierPresent() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <classifier>sources</classifier>
+ <version>1.0</version>
+ </dependency>
+ </dependencies>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ Element dependency = doc.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow();
+
+ assertEquals("org.example:lib:jar:sources:1.0", dependency.attribute("id"));
+ }
+
+ @Test
+ @DisplayName("should not include default type jar in 3-part format")
+ void shouldNotIncludeDefaultTypeInId() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <type>jar</type>
+ <version>1.0</version>
+ </dependency>
+ </dependencies>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ Element dependency = doc.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow();
+
+ assertEquals("org.example:lib:1.0", dependency.attribute("id"));
+ assertNull(DomUtils.findChildElement(dependency, "type"));
+ }
+
+ @Test
+ @DisplayName("should preserve scope when collapsing")
+ void shouldPreserveScopeWhenCollapsing() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <version>1.0</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ Element dependency = doc.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow();
+
+ assertEquals("org.example:lib:1.0", dependency.attribute("id"));
+ assertNotNull(DomUtils.findChildElement(dependency, "scope"));
+ assertEquals("test", dependency.childText("scope"));
+ }
+
+ @Test
+ @DisplayName("should preserve optional when collapsing")
+ void shouldPreserveOptionalWhenCollapsing() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <version>1.0</version>
+ <optional>true</optional>
+ </dependency>
+ </dependencies>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ Element dependency = doc.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow();
+
+ assertEquals("org.example:lib:1.0", dependency.attribute("id"));
+ assertNotNull(DomUtils.findChildElement(dependency, "optional"));
+ }
+
+ @Test
+ @DisplayName("should skip dependency without version")
+ void shouldSkipDependencyWithoutVersion() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ </dependency>
+ </dependencies>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ Element dependency = doc.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow();
+
+ assertNull(dependency.attribute("id"));
+ assertNotNull(DomUtils.findChildElement(dependency, "groupId"));
+ assertNotNull(DomUtils.findChildElement(dependency, "artifactId"));
+ }
+
+ @Test
+ @DisplayName("should skip dependency that already has id attribute")
+ void shouldSkipDependencyWithExistingId() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency id="org.example:lib:1.0"/>
+ </dependencies>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ assertEquals(
+ "org.example:lib:1.0",
+ doc.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow()
+ .attribute("id"));
+ }
+
+ @Test
+ @DisplayName("should collapse multiple dependencies")
+ void shouldCollapseMultipleDependencies() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib-a</artifactId>
+ <version>1.0</version>
+ </dependency>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib-b</artifactId>
+ <version>2.0</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ var deps = doc.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElements("dependency")
+ .toList();
+
+ assertEquals(2, deps.size());
+ assertEquals("org.example:lib-a:1.0", deps.get(0).attribute("id"));
+ assertEquals("org.example:lib-b:2.0", deps.get(1).attribute("id"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Exclusion Collapsing")
+ class ExclusionCollapsingTests {
+
+ @Test
+ @DisplayName("should collapse exclusion into id attribute")
+ void shouldCollapseExclusion() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <version>1.0</version>
+ <exclusions>
+ <exclusion>
+ <groupId>org.unwanted</groupId>
+ <artifactId>bad</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ </dependencies>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ Element dependency = doc.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow();
+
+ assertEquals("org.example:lib:1.0", dependency.attribute("id"));
+
+ Element exclusion = dependency
+ .childElement("exclusions")
+ .orElseThrow()
+ .childElement("exclusion")
+ .orElseThrow();
+
+ assertEquals("org.unwanted:bad", exclusion.attribute("id"));
+ assertNull(DomUtils.findChildElement(exclusion, "groupId"));
+ assertNull(DomUtils.findChildElement(exclusion, "artifactId"));
+ }
+
+ @Test
+ @DisplayName("should collapse multiple exclusions")
+ void shouldCollapseMultipleExclusions() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <version>1.0</version>
+ <exclusions>
+ <exclusion>
+ <groupId>org.a</groupId>
+ <artifactId>bad-a</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>org.b</groupId>
+ <artifactId>bad-b</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ </dependencies>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ var exclusions = doc.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow()
+ .childElement("exclusions")
+ .orElseThrow()
+ .childElements("exclusion")
+ .toList();
+
+ assertEquals(2, exclusions.size());
+ assertEquals("org.a:bad-a", exclusions.get(0).attribute("id"));
+ assertEquals("org.b:bad-b", exclusions.get(1).attribute("id"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Section Processing")
+ class SectionProcessingTests {
+
+ @Test
+ @DisplayName("should process dependencyManagement dependencies")
+ void shouldProcessDependencyManagement() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencyManagement>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <version>1.0</version>
+ </dependency>
+ </dependencies>
+ </dependencyManagement>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ Element dependency = doc.root()
+ .childElement("dependencyManagement")
+ .orElseThrow()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow();
+
+ assertEquals("org.example:lib:1.0", dependency.attribute("id"));
+ }
+
+ @Test
+ @DisplayName("should process profile dependencies")
+ void shouldProcessProfileDependencies() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <profiles>
+ <profile>
+ <id>test-profile</id>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <version>1.0</version>
+ </dependency>
+ </dependencies>
+ </profile>
+ </profiles>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ Element dependency = doc.root()
+ .childElement("profiles")
+ .orElseThrow()
+ .childElement("profile")
+ .orElseThrow()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow();
+
+ assertEquals("org.example:lib:1.0", dependency.attribute("id"));
+ }
+
+ @Test
+ @DisplayName("should process profile dependencyManagement")
+ void shouldProcessProfileDependencyManagement() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <profiles>
+ <profile>
+ <id>test-profile</id>
+ <dependencyManagement>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <version>1.0</version>
+ </dependency>
+ </dependencies>
+ </dependencyManagement>
+ </profile>
+ </profiles>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ Element dependency = doc.root()
+ .childElement("profiles")
+ .orElseThrow()
+ .childElement("profile")
+ .orElseThrow()
+ .childElement("dependencyManagement")
+ .orElseThrow()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow();
+
+ assertEquals("org.example:lib:1.0", dependency.attribute("id"));
+ }
+
+ @Test
+ @DisplayName("should process plugin dependencies")
+ void shouldProcessPluginDependencies() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <version>3.11.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <version>1.0</version>
+ </dependency>
+ </dependencies>
+ </plugin>
+ </plugins>
+ </build>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ Element dependency = doc.root()
+ .childElement("build")
+ .orElseThrow()
+ .childElement("plugins")
+ .orElseThrow()
+ .childElement("plugin")
+ .orElseThrow()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow();
+
+ assertEquals("org.example:lib:1.0", dependency.attribute("id"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Model Version Filtering")
+ class ModelVersionFilteringTests {
+
+ @Test
+ @DisplayName("should skip non-4.2.0 POMs")
+ void shouldSkipNon420Poms() {
+ String pomXml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.1.0">
+ <modelVersion>4.1.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <version>1.0</version>
+ </dependency>
+ </dependencies>
+ </project>
+ """;
+
+ Document doc = Document.of(pomXml);
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ UpgradeResult result = strategy.doApply(context, new HashMap<>(Map.of(Paths.get("pom.xml"), doc)));
+
+ Element dependency = doc.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow();
+
+ assertNull(dependency.attribute("id"));
+ assertNotNull(DomUtils.findChildElement(dependency, "groupId"));
+ assertEquals(0, result.modifiedCount());
+ }
+
+ @Test
+ @DisplayName("should process only 4.2.0 POMs in mixed map")
+ void shouldProcessOnly420PomsInMixedMap() {
+ String pom410Xml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.1.0">
+ <modelVersion>4.1.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>old-module</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <version>1.0</version>
+ </dependency>
+ </dependencies>
+ </project>
+ """;
+
+ String pom420Xml = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <project xmlns="http://maven.apache.org/POM/4.2.0">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>com.example</groupId>
+ <artifactId>new-module</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib</artifactId>
+ <version>1.0</version>
+ </dependency>
+ </dependencies>
+ </project>
+ """;
+
+ Document doc410 = Document.of(pom410Xml);
+ Document doc420 = Document.of(pom420Xml);
+ Map<Path, Document> pomMap = new HashMap<>();
+ pomMap.put(Paths.get("old", "pom.xml"), doc410);
+ pomMap.put(Paths.get("new", "pom.xml"), doc420);
+
+ UpgradeContext context = TestUtils.createMockContext(TestUtils.createOptionsWithModelVersion("4.2.0"));
+ UpgradeResult result = strategy.doApply(context, pomMap);
+
+ // 4.1.0 POM should be untouched
+ assertNull(doc410.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow()
+ .attribute("id"));
+
+ // 4.2.0 POM should be collapsed
+ assertEquals(
+ "org.example:lib:1.0",
+ doc420.root()
+ .childElement("dependencies")
+ .orElseThrow()
+ .childElement("dependency")
+ .orElseThrow()
+ .attribute("id"));
+
+ assertEquals(1, result.modifiedCount());
+ }
+ }
+
+ @Nested
+ @DisplayName("buildIdValue")
+ class BuildIdValueTests {
+
+ @Test
+ void threePartFormat() {
+ assertEquals("g:a:1.0", DependencyIdStrategy.buildIdValue("g", "a", "1.0", null, null));
+ }
+
+ @Test
+ void threePartFormatWithDefaultType() {
+ assertEquals("g:a:1.0", DependencyIdStrategy.buildIdValue("g", "a", "1.0", "jar", null));
+ }
+
+ @Test
+ void fourPartFormat() {
+ assertEquals("g:a:pom:1.0", DependencyIdStrategy.buildIdValue("g", "a", "1.0", "pom", null));
+ }
+
+ @Test
+ void fivePartFormat() {
+ assertEquals("g:a:jar:sources:1.0", DependencyIdStrategy.buildIdValue("g", "a", "1.0", "jar", "sources"));
+ }
+
+ @Test
+ void fivePartFormatWithNonDefaultType() {
+ assertEquals(
+ "g:a:test-jar:tests:1.0", DependencyIdStrategy.buildIdValue("g", "a", "1.0", "test-jar", "tests"));
+ }
+
+ @Test
+ void fivePartFormatClassifierNoType() {
+ assertEquals("g:a:jar:sources:1.0", DependencyIdStrategy.buildIdValue("g", "a", "1.0", null, "sources"));
+ }
+ }
+}
(maven) 01/02: [GH-11904] Add dependency id attribute support for Maven 4.2.0
Posted by gn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch feature/gh-11904-dependency-id-attribute
in repository https://gitbox.apache.org/repos/asf/maven.git
commit 5a8fcdb9df105b262b6dec971001c9e6781cd306
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Tue Jun 23 19:45:51 2026 +0200
[GH-11904] Add dependency id attribute support for Maven 4.2.0
Add compact `id` XML attribute to Dependency, Exclusion, and Mixin
elements in the Maven model (4.2.0+). Supported formats:
- Dependency: g:a:v, g:a:type:v, g:a:type:classifier:v
- Exclusion: g:a
- Mixin (as `gav`/`id`): g:a:v
The normalizer expands `id` into individual fields and clears it so
consumer POMs never contain the attribute. Uses forceCopy=true in
builders to ensure `id(null)` actually clears the field.
Includes comprehensive unit tests (normalizer, validator), consumer
POM transformation tests, and integration test verifying no id
attributes leak into deployed POMs.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
api/maven-api-model/src/main/mdo/maven.mdo | 44 ++++
.../impl/ConsumerPomArtifactTransformerTest.java | 39 +++
.../projects/transform/jar-with-id/after.pom | 39 +++
.../projects/transform/jar-with-id/before.pom | 20 ++
.../maven/impl/model/DefaultModelNormalizer.java | 180 +++++++++++++-
.../maven/impl/model/DefaultModelObjectPool.java | 4 +-
.../maven/impl/model/DefaultModelValidator.java | 120 +++++++++
.../impl/model/DefaultModelNormalizerTest.java | 268 +++++++++++++++++++++
.../impl/model/DefaultModelValidatorTest.java | 51 ++++
.../poms/validation/dependency-id-3parts-valid.xml | 11 +
.../poms/validation/dependency-id-4parts-valid.xml | 11 +
.../poms/validation/dependency-id-5parts-valid.xml | 11 +
.../poms/validation/dependency-id-bad-format.xml | 11 +
.../validation/dependency-id-conflict-groupId.xml | 13 +
.../validation/dependency-id-conflict-type.xml | 13 +
.../poms/validation/exclusion-id-bad-format.xml | 15 ++
.../poms/validation/exclusion-id-valid.xml | 15 ++
.../MavenITgh11904DependencyIdAttributeTest.java | 91 +++++++
.../gh-11904-dependency-id-attribute/.mvn/.gitkeep | 0
.../gh-11904-dependency-id-attribute/child/pom.xml | 20 ++
.../child/src/main/java/Test.java | 1 +
.../gh-11904-dependency-id-attribute/lib-a/pom.xml | 15 ++
.../gh-11904-dependency-id-attribute/pom.xml | 21 ++
23 files changed, 1006 insertions(+), 7 deletions(-)
diff --git a/api/maven-api-model/src/main/mdo/maven.mdo b/api/maven-api-model/src/main/mdo/maven.mdo
index eb5618d9af..00681ebe4f 100644
--- a/api/maven-api-model/src/main/mdo/maven.mdo
+++ b/api/maven-api-model/src/main/mdo/maven.mdo
@@ -1205,6 +1205,22 @@
]]>
</description>
<fields>
+ <field xml.attribute="true">
+ <name>id</name>
+ <version>4.2.0+</version>
+ <description>
+ <![CDATA[
+ Combined coordinates in one of the following formats:
+ {@code groupId:artifactId:version},
+ {@code groupId:artifactId:type:version}, or
+ {@code groupId:artifactId:type:classifier:version}.
+ When specified, it is split into the corresponding fields during model normalization.
+ The individual child elements should not be specified when using this attribute.
+ @since Maven 4.2
+ ]]>
+ </description>
+ <type>String</type>
+ </field>
<field>
<name>groupId</name>
<version>3.0.0+</version>
@@ -1441,6 +1457,20 @@
]]>
</description>
<fields>
+ <field xml.attribute="true">
+ <name>id</name>
+ <version>4.2.0+</version>
+ <description>
+ <![CDATA[
+ Combined coordinates as {@code groupId:artifactId}.
+ When specified, it is split into the {@code groupId} and {@code artifactId}
+ fields during model normalization.
+ The individual child elements should not be specified when using this attribute.
+ @since Maven 4.2
+ ]]>
+ </description>
+ <type>String</type>
+ </field>
<field>
<name>groupId</name>
<version>4.0.0+</version>
@@ -1864,6 +1894,20 @@
<version>4.1.0+</version>
<superClass>Parent</superClass>
<fields>
+ <field xml.attribute="true" xml.tagName="id">
+ <name>gav</name>
+ <version>4.2.0+</version>
+ <description>
+ <![CDATA[
+ Combined coordinates as {@code groupId:artifactId:version}.
+ When specified, it is split into the {@code groupId}, {@code artifactId},
+ and {@code version} fields during model normalization.
+ The individual child elements should not be specified when using this attribute.
+ @since Maven 4.2
+ ]]>
+ </description>
+ <type>String</type>
+ </field>
<field>
<name>classifier</name>
<version>4.1.0+</version>
diff --git a/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomArtifactTransformerTest.java b/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomArtifactTransformerTest.java
index 81870abd0f..b5610e3148 100644
--- a/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomArtifactTransformerTest.java
+++ b/impl/maven-core/src/test/java/org/apache/maven/internal/transformation/impl/ConsumerPomArtifactTransformerTest.java
@@ -29,6 +29,7 @@
import org.apache.maven.api.Constants;
import org.apache.maven.api.services.Sources;
+import org.apache.maven.impl.model.DefaultModelNormalizer;
import org.apache.maven.model.Model;
import org.apache.maven.model.v4.MavenStaxReader;
import org.apache.maven.project.MavenProject;
@@ -114,6 +115,44 @@ void transformJarConsumerPom() throws Exception {
assertFalse(diff.hasDifferences(), "XML files should be identical: " + diff.toString());
}
+ @Test
+ void transformJarConsumerPomWithDependencyId() throws Exception {
+ RepositorySystemSession systemSessionMock = Mockito.mock(RepositorySystemSession.class);
+ SessionData sessionDataMock = Mockito.mock(SessionData.class);
+ when(systemSessionMock.getData()).thenReturn(sessionDataMock);
+
+ Path beforePomFile = Paths.get("src/test/resources/projects/transform/jar-with-id/before.pom")
+ .toAbsolutePath();
+ Path afterPomFile = Paths.get("src/test/resources/projects/transform/jar-with-id/after.pom")
+ .toAbsolutePath();
+ Path tempFile = Files.createTempFile("", ".pom");
+ Files.delete(tempFile);
+ DefaultModelNormalizer normalizer = new DefaultModelNormalizer();
+ try (InputStream expected = Files.newInputStream(beforePomFile)) {
+ org.apache.maven.api.model.Model rawModel = new MavenStaxReader().read(expected);
+ // Normalize: expand id attributes and clear them
+ rawModel = normalizer.mergeDuplicates(rawModel, null, null);
+ Model model = new Model(rawModel);
+ MavenProject project = new MavenProject(model);
+ project.setOriginalModel(model);
+ ConsumerPomArtifactTransformer t = new ConsumerPomArtifactTransformer((s, p, f) -> {
+ try (InputStream is = f.openStream()) {
+ org.apache.maven.api.model.Model m = new MavenStaxReader().read(is);
+ m = normalizer.mergeDuplicates(m, null, null);
+ return DefaultConsumerPomBuilder.transformNonPom(m, project);
+ }
+ });
+
+ t.transform(project, systemSessionMock, Sources.buildSource(beforePomFile), tempFile);
+ }
+ Diff diff = DiffBuilder.compare(afterPomFile.toFile())
+ .withTest(tempFile.toFile())
+ .ignoreComments()
+ .ignoreWhitespace()
+ .build();
+ assertFalse(diff.hasDifferences(), "XML files should be identical: " + diff.toString());
+ }
+
@Test
void injectTransformedArtifactsWithoutPomShouldNotInjectAnyArtifacts() throws IOException {
MavenProject emptyProject = new MavenProject();
diff --git a/impl/maven-core/src/test/resources/projects/transform/jar-with-id/after.pom b/impl/maven-core/src/test/resources/projects/transform/jar-with-id/after.pom
new file mode 100644
index 0000000000..6c3ef9035a
--- /dev/null
+++ b/impl/maven-core/src/test/resources/projects/transform/jar-with-id/after.pom
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <groupId>io.github.test</groupId>
+ <artifactId>jar-with-id</artifactId>
+ <version>1.0.0</version>
+ <name>jar-with-id</name>
+ <dependencies>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ <version>2.0.17</version>
+ </dependency>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib-b</artifactId>
+ <version>1.0</version>
+ <type>pom</type>
+ <scope>import</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib-c</artifactId>
+ <version>1.0</version>
+ <classifier>sources</classifier>
+ </dependency>
+ <dependency>
+ <groupId>org.example</groupId>
+ <artifactId>lib-d</artifactId>
+ <version>1.0</version>
+ <exclusions>
+ <exclusion>
+ <groupId>org.unwanted</groupId>
+ <artifactId>bad-lib</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ </dependencies>
+</project>
diff --git a/impl/maven-core/src/test/resources/projects/transform/jar-with-id/before.pom b/impl/maven-core/src/test/resources/projects/transform/jar-with-id/before.pom
new file mode 100644
index 0000000000..26ba0dfaed
--- /dev/null
+++ b/impl/maven-core/src/test/resources/projects/transform/jar-with-id/before.pom
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.2.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.2.0 https://maven.apache.org/xsd/maven-4.2.0.xsd">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>io.github.test</groupId>
+ <artifactId>jar-with-id</artifactId>
+ <version>1.0.0</version>
+ <name>jar-with-id</name>
+ <dependencies>
+ <dependency id="org.slf4j:slf4j-api:2.0.17"/>
+ <dependency id="org.example:lib-b:pom:1.0">
+ <scope>import</scope>
+ </dependency>
+ <dependency id="org.example:lib-c:jar:sources:1.0"/>
+ <dependency id="org.example:lib-d:1.0">
+ <exclusions>
+ <exclusion id="org.unwanted:bad-lib"/>
+ </exclusions>
+ </dependency>
+ </dependencies>
+</project>
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelNormalizer.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelNormalizer.java
index f36918f770..a36992f33f 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelNormalizer.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelNormalizer.java
@@ -29,8 +29,12 @@
import org.apache.maven.api.di.Singleton;
import org.apache.maven.api.model.Build;
import org.apache.maven.api.model.Dependency;
+import org.apache.maven.api.model.DependencyManagement;
+import org.apache.maven.api.model.Exclusion;
+import org.apache.maven.api.model.Mixin;
import org.apache.maven.api.model.Model;
import org.apache.maven.api.model.Plugin;
+import org.apache.maven.api.model.Profile;
import org.apache.maven.api.services.ModelBuilderRequest;
import org.apache.maven.api.services.ModelProblemCollector;
import org.apache.maven.api.services.model.ModelNormalizer;
@@ -49,6 +53,8 @@ public class DefaultModelNormalizer implements ModelNormalizer {
public Model mergeDuplicates(Model model, ModelBuilderRequest request, ModelProblemCollector problems) {
Model.Builder builder = Model.newBuilder(model);
+ builder.mixins(injectList(model.getMixins(), this::expandMixinGav));
+
Build build = model.getBuild();
if (build != null) {
List<Plugin> plugins = build.getPlugins();
@@ -76,15 +82,21 @@ public Model mergeDuplicates(Model model, ModelBuilderRequest request, ModelProb
* the way 2.x works. When we're in strict mode, the removal of duplicates just saves other merging steps from
* aftereffects and bogus error messages.
*/
- List<Dependency> dependencies = model.getDependencies();
- Map<String, Dependency> normalized = new LinkedHashMap<>(dependencies.size() * 2);
+ builder.dependencies(expandAndDeduplicateDependencies(model.getDependencies()));
- for (Dependency dependency : dependencies) {
- normalized.put(dependency.getManagementKey(), dependency);
+ DependencyManagement mgmt = model.getDependencyManagement();
+ if (mgmt != null) {
+ List<Dependency> expandedMgmt = expandAndDeduplicateDependencies(mgmt.getDependencies());
+ if (expandedMgmt != null) {
+ builder.dependencyManagement(DependencyManagement.newBuilder(mgmt)
+ .dependencies(expandedMgmt)
+ .build());
+ }
}
- if (dependencies.size() != normalized.size()) {
- builder.dependencies(normalized.values());
+ List<Profile> profiles = injectList(model.getProfiles(), this::expandProfileDependencyIds);
+ if (profiles != null) {
+ builder.profiles(profiles);
}
return builder.build();
@@ -144,4 +156,160 @@ private <T> List<T> injectList(List<T> list, UnaryOperator<T> modifer) {
}
return newList;
}
+
+ private List<Dependency> expandAndDeduplicateDependencies(List<Dependency> dependencies) {
+ List<Dependency> expanded = injectList(dependencies, this::expandDependencyId);
+ if (expanded != null) {
+ dependencies = expanded;
+ }
+ Map<String, Dependency> normalized = new LinkedHashMap<>(dependencies.size() * 2);
+ for (Dependency dependency : dependencies) {
+ normalized.put(dependency.getManagementKey(), dependency);
+ }
+ if (expanded != null || dependencies.size() != normalized.size()) {
+ return new ArrayList<>(normalized.values());
+ }
+ return null;
+ }
+
+ private Profile expandProfileDependencyIds(Profile profile) {
+ Profile.Builder pb = null;
+
+ List<Dependency> deps = expandAndDeduplicateDependencies(profile.getDependencies());
+ if (deps != null) {
+ pb = Profile.newBuilder(profile);
+ pb.dependencies(deps);
+ }
+
+ DependencyManagement mgmt = profile.getDependencyManagement();
+ if (mgmt != null) {
+ List<Dependency> mgmtDeps = expandAndDeduplicateDependencies(mgmt.getDependencies());
+ if (mgmtDeps != null) {
+ if (pb == null) {
+ pb = Profile.newBuilder(profile);
+ }
+ pb.dependencyManagement(DependencyManagement.newBuilder(mgmt)
+ .dependencies(mgmtDeps)
+ .build());
+ }
+ }
+
+ return pb != null ? pb.build() : profile;
+ }
+
+ /**
+ * Expands the {@code id} attribute on a dependency into its component fields.
+ * Supported formats:
+ * <ul>
+ * <li>{@code groupId:artifactId:version} (3 parts)</li>
+ * <li>{@code groupId:artifactId:type:version} (4 parts)</li>
+ * <li>{@code groupId:artifactId:type:classifier:version} (5 parts)</li>
+ * </ul>
+ */
+ Dependency expandDependencyId(Dependency d) {
+ String id = d.getId();
+ if (id == null || id.isEmpty()) {
+ List<Exclusion> expanded = injectList(d.getExclusions(), this::expandExclusionId);
+ return expanded != null ? d.withExclusions(expanded) : d;
+ }
+ String[] parts = id.split(":");
+ if (parts.length < 3 || parts.length > 5) {
+ return d;
+ }
+ Dependency.Builder builder = Dependency.newBuilder(d, true);
+ builder.id(null);
+ if (isNullOrEmpty(d.getGroupId())) {
+ builder.groupId(parts[0]);
+ }
+ if (isNullOrEmpty(d.getArtifactId())) {
+ builder.artifactId(parts[1]);
+ }
+ switch (parts.length) {
+ case 3:
+ // g:a:v
+ if (isNullOrEmpty(d.getVersion())) {
+ builder.version(parts[2]);
+ }
+ break;
+ case 4:
+ // g:a:type:v
+ if (isNullOrEmptyOrDefault(d.getType())) {
+ builder.type(parts[2]);
+ }
+ if (isNullOrEmpty(d.getVersion())) {
+ builder.version(parts[3]);
+ }
+ break;
+ case 5:
+ // g:a:type:classifier:v
+ if (isNullOrEmptyOrDefault(d.getType())) {
+ builder.type(parts[2]);
+ }
+ if (isNullOrEmpty(d.getClassifier())) {
+ builder.classifier(parts[3]);
+ }
+ if (isNullOrEmpty(d.getVersion())) {
+ builder.version(parts[4]);
+ }
+ break;
+ default:
+ break;
+ }
+ List<Exclusion> expanded = injectList(d.getExclusions(), this::expandExclusionId);
+ if (expanded != null) {
+ builder.exclusions(expanded);
+ }
+ return builder.build();
+ }
+
+ Exclusion expandExclusionId(Exclusion e) {
+ String id = e.getId();
+ if (id == null || id.isEmpty()) {
+ return e;
+ }
+ String[] parts = id.split(":");
+ if (parts.length != 2) {
+ return e;
+ }
+ Exclusion.Builder builder = Exclusion.newBuilder(e, true);
+ builder.id(null);
+ if (isNullOrEmpty(e.getGroupId())) {
+ builder.groupId(parts[0]);
+ }
+ if (isNullOrEmpty(e.getArtifactId())) {
+ builder.artifactId(parts[1]);
+ }
+ return builder.build();
+ }
+
+ Mixin expandMixinGav(Mixin m) {
+ String gav = m.getGav();
+ if (gav == null || gav.isEmpty()) {
+ return m;
+ }
+ String[] parts = gav.split(":");
+ if (parts.length != 3) {
+ return m;
+ }
+ Mixin.Builder builder = Mixin.newBuilder(m, true);
+ builder.gav(null);
+ if (isNullOrEmpty(m.getGroupId())) {
+ builder.groupId(parts[0]);
+ }
+ if (isNullOrEmpty(m.getArtifactId())) {
+ builder.artifactId(parts[1]);
+ }
+ if (isNullOrEmpty(m.getVersion())) {
+ builder.version(parts[2]);
+ }
+ return builder.build();
+ }
+
+ private static boolean isNullOrEmpty(String s) {
+ return s == null || s.isEmpty();
+ }
+
+ private static boolean isNullOrEmptyOrDefault(String type) {
+ return type == null || type.isEmpty() || "jar".equals(type);
+ }
}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelObjectPool.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelObjectPool.java
index bec34fe0fa..03791aaee4 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelObjectPool.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelObjectPool.java
@@ -237,7 +237,8 @@ private static boolean objectsEqual(Object obj1, Object obj2) {
*/
private static boolean dependenciesEqual(
org.apache.maven.api.model.Dependency dep1, org.apache.maven.api.model.Dependency dep2) {
- return Objects.equals(dep1.getGroupId(), dep2.getGroupId())
+ return Objects.equals(dep1.getId(), dep2.getId())
+ && Objects.equals(dep1.getGroupId(), dep2.getGroupId())
&& Objects.equals(dep1.getArtifactId(), dep2.getArtifactId())
&& Objects.equals(dep1.getVersion(), dep2.getVersion())
&& Objects.equals(dep1.getType(), dep2.getType())
@@ -286,6 +287,7 @@ private static int computeHashCode(Object obj) {
*/
private static int dependencyHashCode(org.apache.maven.api.model.Dependency dep) {
return Objects.hash(
+ dep.getId(),
dep.getGroupId(),
dep.getArtifactId(),
dep.getVersion(),
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelValidator.java b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelValidator.java
index 8cc8cb3459..72e65e1864 100644
--- a/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelValidator.java
+++ b/impl/maven-impl/src/main/java/org/apache/maven/impl/model/DefaultModelValidator.java
@@ -1143,6 +1143,11 @@ private void validate20RawDependencies(
for (Dependency dependency : dependencies) {
String key = dependency.getManagementKey();
+ validateDependencyIdAttribute(problems, dependency, prefix + prefix2);
+ for (Exclusion exclusion : dependency.getExclusions()) {
+ validateExclusionIdAttribute(problems, exclusion, prefix + prefix2);
+ }
+
if ("import".equals(dependency.getScope())) {
if (!"pom".equals(dependency.getType())) {
addViolation(
@@ -1258,6 +1263,116 @@ private void validate20RawDependencies(
}
}
+ private void validateDependencyIdAttribute(ModelProblemCollector problems, Dependency dependency, String prefix) {
+ String id = dependency.getId();
+ if (id == null || id.isEmpty()) {
+ return;
+ }
+ int colonCount = id.length() - id.replace(":", "").length();
+ if (colonCount < 2 || colonCount > 4) {
+ addViolation(
+ problems,
+ Severity.ERROR,
+ Version.V42,
+ prefix + "id",
+ null,
+ "has invalid format '" + id + "', must be 'groupId:artifactId:version', "
+ + "'groupId:artifactId:type:version', or "
+ + "'groupId:artifactId:type:classifier:version'.",
+ dependency);
+ return;
+ }
+ if (!isNullOrEmpty(dependency.getGroupId())) {
+ addViolation(
+ problems,
+ Severity.ERROR,
+ Version.V42,
+ prefix + "groupId",
+ null,
+ "must not be specified when 'id' attribute is used.",
+ dependency);
+ }
+ if (!isNullOrEmpty(dependency.getArtifactId())) {
+ addViolation(
+ problems,
+ Severity.ERROR,
+ Version.V42,
+ prefix + "artifactId",
+ null,
+ "must not be specified when 'id' attribute is used.",
+ dependency);
+ }
+ if (!isNullOrEmpty(dependency.getVersion())) {
+ addViolation(
+ problems,
+ Severity.ERROR,
+ Version.V42,
+ prefix + "version",
+ null,
+ "must not be specified when 'id' attribute is used.",
+ dependency);
+ }
+ if (colonCount >= 3 && !isNullOrEmpty(dependency.getType()) && !"jar".equals(dependency.getType())) {
+ addViolation(
+ problems,
+ Severity.ERROR,
+ Version.V42,
+ prefix + "type",
+ null,
+ "must not be specified when 'id' attribute includes type.",
+ dependency);
+ }
+ if (colonCount >= 4 && !isNullOrEmpty(dependency.getClassifier())) {
+ addViolation(
+ problems,
+ Severity.ERROR,
+ Version.V42,
+ prefix + "classifier",
+ null,
+ "must not be specified when 'id' attribute includes classifier.",
+ dependency);
+ }
+ }
+
+ private void validateExclusionIdAttribute(ModelProblemCollector problems, Exclusion exclusion, String prefix) {
+ String id = exclusion.getId();
+ if (id == null || id.isEmpty()) {
+ return;
+ }
+ int colonCount = id.length() - id.replace(":", "").length();
+ if (colonCount != 1) {
+ addViolation(
+ problems,
+ Severity.ERROR,
+ Version.V42,
+ prefix + "exclusions.exclusion.id",
+ null,
+ "has invalid format '" + id + "', must be 'groupId:artifactId'.",
+ exclusion);
+ return;
+ }
+ if (!isNullOrEmpty(exclusion.getGroupId())) {
+ addViolation(
+ problems,
+ Severity.ERROR,
+ Version.V42,
+ prefix + "exclusions.exclusion.groupId",
+ null,
+ "must not be specified when 'id' attribute is used.",
+ exclusion);
+ }
+ if (!isNullOrEmpty(exclusion.getArtifactId())) {
+ addViolation(
+ problems,
+ Severity.ERROR,
+ Version.V42,
+ prefix + "exclusions.exclusion.artifactId",
+ null,
+ "must not be specified when 'id' attribute is used.",
+ exclusion);
+ }
+ }
+
private void validate20RawDependenciesSelfReferencing(
ModelProblemCollector problems, Model model, List<Dependency> dependencies, String prefix) {
// We only check for groupId/artifactId/version/classifier cause if there is another
@@ -1513,6 +1628,7 @@ private void validateEffectiveDependency(
if (validationLevel >= ModelValidator.VALIDATION_LEVEL_MAVEN_2_0) {
for (Exclusion exclusion : dependency.getExclusions()) {
+ validateExclusionIdAttribute(problems, exclusion, prefix);
if (validationLevel < ModelValidator.VALIDATION_LEVEL_MAVEN_3_0) {
validateCoordinatesId(
prefix,
@@ -2509,4 +2625,8 @@ static SourceHint resourceDirectory(Resource resource) {
return resource::getDirectory;
}
}
+
+ private static boolean isNullOrEmpty(String s) {
+ return s == null || s.isEmpty();
+ }
}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelNormalizerTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelNormalizerTest.java
new file mode 100644
index 0000000000..e4a84dbbb3
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelNormalizerTest.java
@@ -0,0 +1,268 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.impl.model;
+
+import java.util.List;
+
+import org.apache.maven.api.model.Dependency;
+import org.apache.maven.api.model.DependencyManagement;
+import org.apache.maven.api.model.Exclusion;
+import org.apache.maven.api.model.Mixin;
+import org.apache.maven.api.model.Model;
+import org.apache.maven.api.model.Profile;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class DefaultModelNormalizerTest {
+
+ private final DefaultModelNormalizer normalizer = new DefaultModelNormalizer();
+
+ @Test
+ void testExpandDependencyId3parts() {
+ Dependency dep =
+ Dependency.newBuilder(false).id("org.slf4j:slf4j-api:2.0.17").build();
+
+ Dependency result = normalizer.expandDependencyId(dep);
+
+ assertEquals("org.slf4j", result.getGroupId());
+ assertEquals("slf4j-api", result.getArtifactId());
+ assertEquals("2.0.17", result.getVersion());
+ assertNull(result.getId());
+ }
+
+ @Test
+ void testExpandDependencyId4parts() {
+ Dependency dep =
+ Dependency.newBuilder(false).id("org.example:lib-b:pom:1.0").build();
+
+ Dependency result = normalizer.expandDependencyId(dep);
+
+ assertEquals("org.example", result.getGroupId());
+ assertEquals("lib-b", result.getArtifactId());
+ assertEquals("pom", result.getType());
+ assertEquals("1.0", result.getVersion());
+ assertNull(result.getId());
+ }
+
+ @Test
+ void testExpandDependencyId5parts() {
+ Dependency dep = Dependency.newBuilder(false)
+ .id("org.example:lib-c:jar:sources:1.0")
+ .build();
+
+ Dependency result = normalizer.expandDependencyId(dep);
+
+ assertEquals("org.example", result.getGroupId());
+ assertEquals("lib-c", result.getArtifactId());
+ assertEquals("jar", result.getType());
+ assertEquals("sources", result.getClassifier());
+ assertEquals("1.0", result.getVersion());
+ assertNull(result.getId());
+ }
+
+ @Test
+ void testExpandDependencyIdDoesNotOverrideExistingFields() {
+ Dependency dep = Dependency.newBuilder(false)
+ .id("org.slf4j:slf4j-api:2.0.17")
+ .groupId("org.override")
+ .build();
+
+ Dependency result = normalizer.expandDependencyId(dep);
+
+ assertEquals("org.override", result.getGroupId());
+ assertEquals("slf4j-api", result.getArtifactId());
+ assertEquals("2.0.17", result.getVersion());
+ assertNull(result.getId());
+ }
+
+ @Test
+ void testExpandDependencyIdInvalidFormat() {
+ Dependency dep = Dependency.newBuilder(false).id("invalid-no-colons").build();
+
+ Dependency result = normalizer.expandDependencyId(dep);
+
+ assertNull(result.getGroupId());
+ assertNull(result.getArtifactId());
+ }
+
+ @Test
+ void testExpandDependencyIdNull() {
+ Dependency dep = Dependency.newBuilder()
+ .groupId("org.example")
+ .artifactId("my-lib")
+ .version("1.0")
+ .build();
+
+ Dependency result = normalizer.expandDependencyId(dep);
+
+ assertEquals("org.example", result.getGroupId());
+ assertEquals("my-lib", result.getArtifactId());
+ assertEquals("1.0", result.getVersion());
+ }
+
+ @Test
+ void testExpandDependencyIdAlsoExpandsExclusions() {
+ Exclusion exc = Exclusion.newBuilder(false).id("com.example:unwanted").build();
+ Dependency dep = Dependency.newBuilder(false)
+ .id("org.slf4j:slf4j-api:2.0.17")
+ .exclusions(List.of(exc))
+ .build();
+
+ Dependency result = normalizer.expandDependencyId(dep);
+
+ assertEquals("org.slf4j", result.getGroupId());
+ assertEquals("slf4j-api", result.getArtifactId());
+ assertEquals("2.0.17", result.getVersion());
+ assertNull(result.getId());
+ assertEquals(1, result.getExclusions().size());
+ assertEquals("com.example", result.getExclusions().get(0).getGroupId());
+ assertEquals("unwanted", result.getExclusions().get(0).getArtifactId());
+ assertNull(result.getExclusions().get(0).getId());
+ }
+
+ @Test
+ void testExpandExclusionId() {
+ Exclusion exc =
+ Exclusion.newBuilder(false).id("com.example:unwanted-lib").build();
+
+ Exclusion result = normalizer.expandExclusionId(exc);
+
+ assertEquals("com.example", result.getGroupId());
+ assertEquals("unwanted-lib", result.getArtifactId());
+ assertNull(result.getId());
+ }
+
+ @Test
+ void testExpandExclusionIdWildcard() {
+ Exclusion exc = Exclusion.newBuilder(false).id("*:*").build();
+
+ Exclusion result = normalizer.expandExclusionId(exc);
+
+ assertEquals("*", result.getGroupId());
+ assertEquals("*", result.getArtifactId());
+ assertNull(result.getId());
+ }
+
+ @Test
+ void testExpandExclusionIdNull() {
+ Exclusion exc =
+ Exclusion.newBuilder().groupId("org.example").artifactId("lib").build();
+
+ Exclusion result = normalizer.expandExclusionId(exc);
+
+ assertEquals("org.example", result.getGroupId());
+ assertEquals("lib", result.getArtifactId());
+ }
+
+ @Test
+ void testExpandMixinGav() {
+ Mixin mixin = Mixin.newBuilder(false)
+ .gav("com.example.mixins:java-mixin:1.0.0")
+ .build();
+
+ Mixin result = normalizer.expandMixinGav(mixin);
+
+ assertEquals("com.example.mixins", result.getGroupId());
+ assertEquals("java-mixin", result.getArtifactId());
+ assertEquals("1.0.0", result.getVersion());
+ assertNull(result.getGav());
+ }
+
+ @Test
+ void testExpandMixinGavNull() {
+ Mixin mixin = Mixin.newBuilder()
+ .groupId("com.example")
+ .artifactId("my-mixin")
+ .version("2.0")
+ .build();
+
+ Mixin result = normalizer.expandMixinGav(mixin);
+
+ assertEquals("com.example", result.getGroupId());
+ assertEquals("my-mixin", result.getArtifactId());
+ assertEquals("2.0", result.getVersion());
+ }
+
+ @Test
+ void testMergeDuplicatesExpandsDependencyManagement() {
+ Dependency dep =
+ Dependency.newBuilder(false).id("org.slf4j:slf4j-api:2.0.17").build();
+ Model model = Model.newBuilder()
+ .dependencyManagement(DependencyManagement.newBuilder()
+ .dependencies(List.of(dep))
+ .build())
+ .build();
+
+ Model result = normalizer.mergeDuplicates(model, null, null);
+
+ assertEquals(1, result.getDependencyManagement().getDependencies().size());
+ Dependency expanded = result.getDependencyManagement().getDependencies().get(0);
+ assertEquals("org.slf4j", expanded.getGroupId());
+ assertEquals("slf4j-api", expanded.getArtifactId());
+ assertEquals("2.0.17", expanded.getVersion());
+ assertNull(expanded.getId());
+ }
+
+ @Test
+ void testMergeDuplicatesExpandsProfileDependencies() {
+ Dependency dep =
+ Dependency.newBuilder(false).id("org.slf4j:slf4j-api:2.0.17").build();
+ Profile profile = Profile.newBuilder()
+ .id("test-profile")
+ .dependencies(List.of(dep))
+ .build();
+ Model model = Model.newBuilder().profiles(List.of(profile)).build();
+
+ Model result = normalizer.mergeDuplicates(model, null, null);
+
+ assertEquals(1, result.getProfiles().size());
+ assertEquals(1, result.getProfiles().get(0).getDependencies().size());
+ Dependency expanded = result.getProfiles().get(0).getDependencies().get(0);
+ assertEquals("org.slf4j", expanded.getGroupId());
+ assertEquals("slf4j-api", expanded.getArtifactId());
+ assertEquals("2.0.17", expanded.getVersion());
+ assertNull(expanded.getId());
+ }
+
+ @Test
+ void testMergeDuplicatesExpandsProfileDependencyManagement() {
+ Dependency dep =
+ Dependency.newBuilder(false).id("org.slf4j:slf4j-api:2.0.17").build();
+ Profile profile = Profile.newBuilder()
+ .id("test-profile")
+ .dependencyManagement(DependencyManagement.newBuilder()
+ .dependencies(List.of(dep))
+ .build())
+ .build();
+ Model model = Model.newBuilder().profiles(List.of(profile)).build();
+
+ Model result = normalizer.mergeDuplicates(model, null, null);
+
+ assertEquals(1, result.getProfiles().size());
+ DependencyManagement mgmt = result.getProfiles().get(0).getDependencyManagement();
+ assertEquals(1, mgmt.getDependencies().size());
+ Dependency expanded = mgmt.getDependencies().get(0);
+ assertEquals("org.slf4j", expanded.getGroupId());
+ assertEquals("slf4j-api", expanded.getArtifactId());
+ assertEquals("2.0.17", expanded.getVersion());
+ assertNull(expanded.getId());
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelValidatorTest.java b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelValidatorTest.java
index 591191e892..3514ee326f 100644
--- a/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelValidatorTest.java
+++ b/impl/maven-impl/src/test/java/org/apache/maven/impl/model/DefaultModelValidatorTest.java
@@ -1022,4 +1022,55 @@ void profileActivationConditionWithBasedirExpression() throws Exception {
"raw-model/profile-activation-condition-with-basedir.xml", ModelValidator.VALIDATION_LEVEL_STRICT);
assertViolations(result, 0, 0, 0);
}
+
+ @Test
+ void testDependencyId3partsValid() throws Exception {
+ SimpleProblemCollector result = validateFile("dependency-id-3parts-valid.xml");
+ assertViolations(result, 0, 0, 0);
+ }
+
+ @Test
+ void testDependencyId4partsValid() throws Exception {
+ SimpleProblemCollector result = validateFile("dependency-id-4parts-valid.xml");
+ assertViolations(result, 0, 0, 0);
+ }
+
+ @Test
+ void testDependencyId5partsValid() throws Exception {
+ SimpleProblemCollector result = validateFile("dependency-id-5parts-valid.xml");
+ assertViolations(result, 0, 0, 0);
+ }
+
+ @Test
+ void testDependencyIdBadFormat() throws Exception {
+ SimpleProblemCollector result = validateFile("dependency-id-bad-format.xml");
+ assertViolations(result, 0, 1, 0);
+ assertContains(result.getErrors().get(0), "invalid format");
+ }
+
+ @Test
+ void testDependencyIdConflictGroupId() throws Exception {
+ SimpleProblemCollector result = validateFile("dependency-id-conflict-groupId.xml");
+ assertViolations(result, 0, 1, 0);
+ assertContains(result.getErrors().get(0), "must not be specified when 'id' attribute is used");
+ }
+
+ @Test
+ void testDependencyIdConflictType() throws Exception {
+ SimpleProblemCollector result = validateFile("dependency-id-conflict-type.xml");
+ assertViolations(result, 0, 1, 0);
+ assertContains(result.getErrors().get(0), "must not be specified when 'id' attribute includes type");
+ }
+
+ @Test
+ void testExclusionIdValid() throws Exception {
+ SimpleProblemCollector result = validateFile("exclusion-id-valid.xml");
+ assertViolations(result, 0, 0, 0);
+ }
+
+ @Test
+ void testExclusionIdBadFormat() throws Exception {
+ SimpleProblemCollector result = validateFile("exclusion-id-bad-format.xml");
+ assertViolations(result, 0, 1, 0);
+ }
}
diff --git a/impl/maven-impl/src/test/resources/poms/validation/dependency-id-3parts-valid.xml b/impl/maven-impl/src/test/resources/poms/validation/dependency-id-3parts-valid.xml
new file mode 100644
index 0000000000..448265d0db
--- /dev/null
+++ b/impl/maven-impl/src/test/resources/poms/validation/dependency-id-3parts-valid.xml
@@ -0,0 +1,11 @@
+<project xmlns="http://maven.apache.org/POM/4.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.2.0 https://maven.apache.org/xsd/maven-4.2.0.xsd">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>test</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency id="org.example:lib-a:1.0"/>
+ </dependencies>
+</project>
diff --git a/impl/maven-impl/src/test/resources/poms/validation/dependency-id-4parts-valid.xml b/impl/maven-impl/src/test/resources/poms/validation/dependency-id-4parts-valid.xml
new file mode 100644
index 0000000000..05baf6afb3
--- /dev/null
+++ b/impl/maven-impl/src/test/resources/poms/validation/dependency-id-4parts-valid.xml
@@ -0,0 +1,11 @@
+<project xmlns="http://maven.apache.org/POM/4.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.2.0 https://maven.apache.org/xsd/maven-4.2.0.xsd">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>test</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency id="org.example:lib-b:pom:1.0"/>
+ </dependencies>
+</project>
diff --git a/impl/maven-impl/src/test/resources/poms/validation/dependency-id-5parts-valid.xml b/impl/maven-impl/src/test/resources/poms/validation/dependency-id-5parts-valid.xml
new file mode 100644
index 0000000000..133c0480d9
--- /dev/null
+++ b/impl/maven-impl/src/test/resources/poms/validation/dependency-id-5parts-valid.xml
@@ -0,0 +1,11 @@
+<project xmlns="http://maven.apache.org/POM/4.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.2.0 https://maven.apache.org/xsd/maven-4.2.0.xsd">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>test</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency id="org.example:lib-c:jar:sources:1.0"/>
+ </dependencies>
+</project>
diff --git a/impl/maven-impl/src/test/resources/poms/validation/dependency-id-bad-format.xml b/impl/maven-impl/src/test/resources/poms/validation/dependency-id-bad-format.xml
new file mode 100644
index 0000000000..e53959b8b0
--- /dev/null
+++ b/impl/maven-impl/src/test/resources/poms/validation/dependency-id-bad-format.xml
@@ -0,0 +1,11 @@
+<project xmlns="http://maven.apache.org/POM/4.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.2.0 https://maven.apache.org/xsd/maven-4.2.0.xsd">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>test</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency id="invalid-no-colons"/>
+ </dependencies>
+</project>
diff --git a/impl/maven-impl/src/test/resources/poms/validation/dependency-id-conflict-groupId.xml b/impl/maven-impl/src/test/resources/poms/validation/dependency-id-conflict-groupId.xml
new file mode 100644
index 0000000000..171ded5355
--- /dev/null
+++ b/impl/maven-impl/src/test/resources/poms/validation/dependency-id-conflict-groupId.xml
@@ -0,0 +1,13 @@
+<project xmlns="http://maven.apache.org/POM/4.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.2.0 https://maven.apache.org/xsd/maven-4.2.0.xsd">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>test</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency id="org.example:lib:1.0">
+ <groupId>org.other</groupId>
+ </dependency>
+ </dependencies>
+</project>
diff --git a/impl/maven-impl/src/test/resources/poms/validation/dependency-id-conflict-type.xml b/impl/maven-impl/src/test/resources/poms/validation/dependency-id-conflict-type.xml
new file mode 100644
index 0000000000..f1fb01b3df
--- /dev/null
+++ b/impl/maven-impl/src/test/resources/poms/validation/dependency-id-conflict-type.xml
@@ -0,0 +1,13 @@
+<project xmlns="http://maven.apache.org/POM/4.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.2.0 https://maven.apache.org/xsd/maven-4.2.0.xsd">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>test</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency id="org.example:lib:war:1.0">
+ <type>pom</type>
+ </dependency>
+ </dependencies>
+</project>
diff --git a/impl/maven-impl/src/test/resources/poms/validation/exclusion-id-bad-format.xml b/impl/maven-impl/src/test/resources/poms/validation/exclusion-id-bad-format.xml
new file mode 100644
index 0000000000..0459776025
--- /dev/null
+++ b/impl/maven-impl/src/test/resources/poms/validation/exclusion-id-bad-format.xml
@@ -0,0 +1,15 @@
+<project xmlns="http://maven.apache.org/POM/4.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.2.0 https://maven.apache.org/xsd/maven-4.2.0.xsd">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>test</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency id="org.example:lib-a:1.0">
+ <exclusions>
+ <exclusion id="only-one-part"/>
+ </exclusions>
+ </dependency>
+ </dependencies>
+</project>
diff --git a/impl/maven-impl/src/test/resources/poms/validation/exclusion-id-valid.xml b/impl/maven-impl/src/test/resources/poms/validation/exclusion-id-valid.xml
new file mode 100644
index 0000000000..3cd9825f22
--- /dev/null
+++ b/impl/maven-impl/src/test/resources/poms/validation/exclusion-id-valid.xml
@@ -0,0 +1,15 @@
+<project xmlns="http://maven.apache.org/POM/4.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.2.0 https://maven.apache.org/xsd/maven-4.2.0.xsd">
+ <modelVersion>4.2.0</modelVersion>
+ <groupId>test</groupId>
+ <artifactId>test</artifactId>
+ <version>1.0</version>
+ <dependencies>
+ <dependency id="org.example:lib-a:1.0">
+ <exclusions>
+ <exclusion id="org.unwanted:bad-lib"/>
+ </exclusions>
+ </dependency>
+ </dependencies>
+</project>
diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11904DependencyIdAttributeTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11904DependencyIdAttributeTest.java
new file mode 100644
index 0000000000..41a5ba5a2f
--- /dev/null
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITgh11904DependencyIdAttributeTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.it;
+
+import java.io.Reader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.apache.maven.api.model.Model;
+import org.apache.maven.model.v4.MavenStaxReader;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration test for <a href="https://github.com/apache/maven/pull/11904">GH-11904</a>.
+ * Verifies that dependency {@code id} attributes are expanded into individual
+ * GAV elements in the consumer POM and that no {@code id} attribute remains.
+ */
+class MavenITgh11904DependencyIdAttributeTest extends AbstractMavenIntegrationTestCase {
+
+ MavenITgh11904DependencyIdAttributeTest() {
+ super("[4.0.0-rc-3-SNAPSHOT,)");
+ }
+
+ @Test
+ void testConsumerPomDoesNotContainIdAttributes() throws Exception {
+ Path basedir = extractResources("/gh-11904-dependency-id-attribute");
+
+ Verifier verifier = newVerifier(basedir);
+ verifier.addCliArguments("install");
+ verifier.execute();
+ verifier.verifyErrorFreeLog();
+
+ // Verify consumer POM for the child module
+ Path consumerPomPath = verifier.getArtifactPath(
+ "org.apache.maven.its.gh-11904", "child", "1.0.0-SNAPSHOT", "pom");
+ assertTrue(Files.exists(consumerPomPath), "consumer POM should exist at " + consumerPomPath);
+
+ // Verify raw XML does not contain any id= attribute on dependency or exclusion elements
+ List<String> consumerPomLines;
+ try (Stream<String> lines = Files.lines(consumerPomPath)) {
+ consumerPomLines = lines.toList();
+ }
+ assertTrue(
+ consumerPomLines.stream()
+ .noneMatch(line -> line.contains("<dependency ") && line.contains("id=")),
+ "Consumer POM should not contain 'id' attribute on <dependency> elements");
+ assertTrue(
+ consumerPomLines.stream()
+ .noneMatch(line -> line.contains("<exclusion ") && line.contains("id=")),
+ "Consumer POM should not contain 'id' attribute on <exclusion> elements");
+
+ // Parse and verify the consumer POM model
+ Model consumerModel;
+ try (Reader r = Files.newBufferedReader(consumerPomPath)) {
+ consumerModel = new MavenStaxReader().read(r);
+ }
+
+ // Verify dependency was expanded correctly
+ assertNotNull(consumerModel.getDependencies(), "Consumer POM should have dependencies");
+ assertEquals(1, consumerModel.getDependencies().size(), "Consumer POM should have one dependency");
+
+ var dep = consumerModel.getDependencies().get(0);
+ assertNull(dep.getId(), "Dependency id attribute should be null in consumer POM");
+ assertEquals("org.apache.maven.its.gh-11904", dep.getGroupId());
+ assertEquals("lib-a", dep.getArtifactId());
+ assertEquals("1.0.0-SNAPSHOT", dep.getVersion());
+ }
+}
diff --git a/its/core-it-suite/src/test/resources/gh-11904-dependency-id-attribute/.mvn/.gitkeep b/its/core-it-suite/src/test/resources/gh-11904-dependency-id-attribute/.mvn/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/its/core-it-suite/src/test/resources/gh-11904-dependency-id-attribute/child/pom.xml b/its/core-it-suite/src/test/resources/gh-11904-dependency-id-attribute/child/pom.xml
new file mode 100644
index 0000000000..834fd8b271
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-11904-dependency-id-attribute/child/pom.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.2.0 https://maven.apache.org/xsd/maven-4.2.0.xsd">
+ <modelVersion>4.2.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.maven.its.gh-11904</groupId>
+ <artifactId>parent</artifactId>
+ <version>1.0.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>child</artifactId>
+
+ <dependencies>
+ <!-- Basic 3-part id: groupId:artifactId:version -->
+ <dependency id="org.apache.maven.its.gh-11904:lib-a:1.0.0-SNAPSHOT"/>
+ </dependencies>
+
+</project>
diff --git a/its/core-it-suite/src/test/resources/gh-11904-dependency-id-attribute/child/src/main/java/Test.java b/its/core-it-suite/src/test/resources/gh-11904-dependency-id-attribute/child/src/main/java/Test.java
new file mode 100644
index 0000000000..ef22a69f19
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-11904-dependency-id-attribute/child/src/main/java/Test.java
@@ -0,0 +1 @@
+class Test {}
diff --git a/its/core-it-suite/src/test/resources/gh-11904-dependency-id-attribute/lib-a/pom.xml b/its/core-it-suite/src/test/resources/gh-11904-dependency-id-attribute/lib-a/pom.xml
new file mode 100644
index 0000000000..26ab3ed3cd
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-11904-dependency-id-attribute/lib-a/pom.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.2.0 https://maven.apache.org/xsd/maven-4.2.0.xsd">
+ <modelVersion>4.2.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.maven.its.gh-11904</groupId>
+ <artifactId>parent</artifactId>
+ <version>1.0.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>lib-a</artifactId>
+
+</project>
diff --git a/its/core-it-suite/src/test/resources/gh-11904-dependency-id-attribute/pom.xml b/its/core-it-suite/src/test/resources/gh-11904-dependency-id-attribute/pom.xml
new file mode 100644
index 0000000000..185a839584
--- /dev/null
+++ b/its/core-it-suite/src/test/resources/gh-11904-dependency-id-attribute/pom.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.2.0 https://maven.apache.org/xsd/maven-4.2.0.xsd">
+ <modelVersion>4.2.0</modelVersion>
+
+ <groupId>org.apache.maven.its.gh-11904</groupId>
+ <artifactId>parent</artifactId>
+ <version>1.0.0-SNAPSHOT</version>
+ <packaging>pom</packaging>
+
+ <subprojects>
+ <subproject>lib-a</subproject>
+ <subproject>child</subproject>
+ </subprojects>
+
+ <properties>
+ <maven.compiler.release>17</maven.compiler.release>
+ </properties>
+
+</project>