From 25b30307d3fe821b86090884ae58735d3148b89b Mon Sep 17 00:00:00 2001 From: Marius Barbulescu Date: Mon, 11 May 2026 12:46:03 +0200 Subject: [PATCH 1/8] add ExtractVersionsAsProperties recipe --- .../maven/ExtractVersionsAsProperties.java | 216 ++++++ .../org/openrewrite/maven/MavenVisitor.java | 5 + .../ExtractVersionsAsPropertiesTest.java | 702 ++++++++++++++++++ 3 files changed, 923 insertions(+) create mode 100644 rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java create mode 100644 rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java b/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java new file mode 100644 index 00000000000..75ede39510e --- /dev/null +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java @@ -0,0 +1,216 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed 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 + *

+ * https://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.openrewrite.maven; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.xml.ChangeTagValueVisitor; +import org.openrewrite.xml.tree.Xml; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Value +@EqualsAndHashCode(callSuper = false) +public class ExtractVersionsAsProperties extends Recipe { + + @Override + public String getDisplayName() { + return "Extract Maven dependency versions as properties"; + } + + @Override + public String getDescription() { + return "Extracts inlined dependency versions into the `` section and replaces them with `${property}` references."; + } + + @Override + public TreeVisitor getVisitor() { + return new VersionExtractionVisitor(); + } + + private static Stream allDescendants(Xml.Tag tag) { + return Stream.concat(Stream.of(tag), tag.getChildren().stream().flatMap(ExtractVersionsAsProperties::allDescendants)); + } + + private static class VersionExtractionVisitor extends MavenIsoVisitor { + private PropertyResolver propertyResolver; + + @Override + public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) { + Map existingProps = loadExistingProperties(document.getRoot()); + Map groupSharedVersion = GroupVersionAnalyzer.analyze(document.getRoot(), existingProps); + propertyResolver = new PropertyResolver(groupSharedVersion, existingProps); + schedulePropertyRenames(document.getRoot(), existingProps, groupSharedVersion); + return super.visitDocument(document, ctx); + } + + private static Map loadExistingProperties(Xml.Tag root) { + return root.getChild("properties") + .map(VersionExtractionVisitor::collectPropertiesFrom) + .orElseGet(LinkedHashMap::new); + } + + private static Map collectPropertiesFrom(Xml.Tag propsTag) { + return propsTag.getChildren().stream() + .filter(child -> child.getValue().isPresent()) + .collect(Collectors.toMap( + Xml.Tag::getName, + child -> child.getValue().get(), + (a, b) -> a, + LinkedHashMap::new)); + } + + private void schedulePropertyRenames(Xml.Tag root, Map existingProps, + Map groupSharedVersion) { + PropertyRenamer.findRenames(root, existingProps, groupSharedVersion) + .forEach((oldKey, newKey) -> applyRename(oldKey, newKey, existingProps)); + } + + private void applyRename(String oldKey, String newKey, Map existingProps) { + doAfterVisit(new RenamePropertyKey(oldKey, newKey).getVisitor()); + propertyResolver.registerKey(newKey, existingProps.get(oldKey)); + } + + @Override + public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) { + if (isDependencyTag() || isManagedDependencyTag() || isPluginTag() || isPluginDependencyTag()) { + Optional versionTag = tag.getChild("version"); + if (versionTag.isPresent()) { + String version = versionTag.get().getValue().orElse(null); + if (version != null && !PropertyResolver.isPropertyRef(version)) { + String groupId = tag.getChildValue("groupId").orElse(null); + String artifactId = tag.getChildValue("artifactId").orElse(null); + if (artifactId != null) { + String propertyKey = propertyResolver.resolvePropertyKey(groupId, artifactId, version); + doAfterVisit(new AddPropertyVisitor(propertyKey, version, true)); + doAfterVisit(new ChangeTagValueVisitor<>(versionTag.get(), "${" + propertyKey + "}")); + } + } + } + } + return super.visitTag(tag, ctx); + } + } + + private static class PropertyResolver { + private final Map propertyKeyToVersion = new LinkedHashMap<>(); + private final Map groupSharedVersion; + + PropertyResolver(Map groupSharedVersion, Map existingProps) { + this.groupSharedVersion = groupSharedVersion; + this.propertyKeyToVersion.putAll(existingProps); + } + + static boolean isPropertyRef(String version) { + String trimmedVersion = version.trim(); + return trimmedVersion.startsWith("${") && trimmedVersion.endsWith("}"); + } + + static String resolveToLiteral(String version, Map existingProps) { + if (isPropertyRef(version)) { + String trimmedVersion = version.trim(); + return existingProps.get(trimmedVersion.substring(2, trimmedVersion.length() - 1)); + } + return version; + } + + void registerKey(String key, String version) { + propertyKeyToVersion.put(key, version); + } + + String resolvePropertyKey(String groupId, String artifactId, String version) { + String baseKey = groupId != null && groupSharedVersion.containsKey(groupId) + ? groupId + ".version" + : artifactId + ".version"; + String key = baseKey; + int suffix = 1; + while (propertyKeyToVersion.containsKey(key) && !propertyKeyToVersion.get(key).equals(version)) { + key = baseKey + "." + suffix++; + } + propertyKeyToVersion.put(key, version); + return key; + } + } + + private static class GroupVersionAnalyzer { + // Returns groupId → version for groups where every dep with a resolvable version shares the same version. + static Map analyze(Xml.Tag root, Map existingProps) { + return allDescendants(root) + .filter(tag -> "dependency".equals(tag.getName()) || "plugin".equals(tag.getName())) + .filter(tag -> tag.getChildValue("groupId").isPresent()) + .collect(Collectors.groupingBy( + tag -> tag.getChildValue("groupId").get(), + Collectors.toList())) + .entrySet().stream() + .flatMap(groupEntry -> toSharedVersionEntry(groupEntry, existingProps)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private static Stream> toSharedVersionEntry( + Map.Entry> groupEntry, Map existingProps) { + List resolvedVersions = groupEntry.getValue().stream() + .map(tag -> tag.getChild("version").flatMap(Xml.Tag::getValue).orElse(null)) + .filter(Objects::nonNull) + .map(v -> PropertyResolver.resolveToLiteral(v, existingProps)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + if (resolvedVersions.size() > 1 && new HashSet<>(resolvedVersions).size() == 1) { + return Stream.of(new AbstractMap.SimpleEntry<>(groupEntry.getKey(), resolvedVersions.get(0))); + } + return Stream.empty(); + } + } + + private static class PropertyRenamer { + // For deps in a shared-version group that already reference a non-standard ${propName}, + // returns oldKey→newKey pairs so the visitor can schedule RenamePropertyKey for each. + static Map findRenames(Xml.Tag root, Map existingProps, + Map groupSharedVersion) { + return allDescendants(root) + .filter(tag -> "dependency".equals(tag.getName()) || "plugin".equals(tag.getName())) + .flatMap(tag -> toNonStandardRenameEntry(tag, existingProps, groupSharedVersion)) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (a, b) -> a, + LinkedHashMap::new)); + } + + private static Stream> toNonStandardRenameEntry( + Xml.Tag tag, Map existingProps, Map groupSharedVersion) { + String groupId = tag.getChildValue("groupId").orElse(null); + if (groupId == null || !groupSharedVersion.containsKey(groupId)) { + return Stream.empty(); + } + String standardKey = groupId + ".version"; + String version = tag.getChild("version").flatMap(Xml.Tag::getValue).orElse(null); + if (version == null || !PropertyResolver.isPropertyRef(version)) { + return Stream.empty(); + } + String trimmedVersion = version.trim(); + String propRef = trimmedVersion.substring(2, trimmedVersion.length() - 1); + if (propRef.equals(standardKey) || !existingProps.containsKey(propRef)) { + return Stream.empty(); + } + return Stream.of(new AbstractMap.SimpleEntry<>(propRef, standardKey)); + } + } +} diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenVisitor.java b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenVisitor.java index 7521fd82e7b..342e69f1178 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/MavenVisitor.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/MavenVisitor.java @@ -163,6 +163,11 @@ public boolean isDependencyTag(String groupId, String artifactId) { return false; } + public boolean isPluginDependencyTag() { + return isTag("dependency") && + (PLUGIN_DEPENDENCY_MATCHER.matches(getCursor()) || PROFILE_PLUGIN_DEPENDENCY_MATCHER.matches(getCursor())); + } + public boolean isPluginDependencyTag(String groupId, String artifactId) { if (!isTag("dependency") || !PLUGIN_DEPENDENCY_MATCHER.matches(getCursor()) && diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java new file mode 100644 index 00000000000..08e2052fa53 --- /dev/null +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java @@ -0,0 +1,702 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed 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 + *

+ * https://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.openrewrite.maven; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.maven.Assertions.pomXml; + +class ExtractVersionsAsPropertiesTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new ExtractVersionsAsProperties()); + } + + @Test + void extractsSingleDependencyVersionIntoNewPropertiesBlock() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + junit + junit + 4.13.2 + test + + + + """, + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + + + + junit + junit + ${junit.version} + test + + + + """ + ) + ); + } + + @Test + void appendsToExistingPropertiesBlock() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + value + + + + junit + junit + 4.13.2 + + + + """, + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + value + + + + junit + junit + ${junit.version} + + + + """ + ) + ); + } + + @Test + void extractsMultipleDependencyVersions() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + junit + junit + 4.13.2 + test + + + org.slf4j + slf4j-api + 1.7.36 + + + + """, + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + 1.7.36 + + + + junit + junit + ${junit.version} + test + + + org.slf4j + slf4j-api + ${slf4j-api.version} + + + + """ + ) + ); + } + + @Test + void renamesNonStandardPropertyToStandardGroupNameWhenOtherDepHasLiteralVersion() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + 5.3.0 + + + + org.springframework + spring-core + ${spring.version} + + + org.springframework + spring-context + 5.3.0 + + + + """, + """ + + com.example + my-app + 1.0.0 + + 5.3.0 + + + + org.springframework + spring-core + ${org.springframework.version} + + + org.springframework + spring-context + ${org.springframework.version} + + + + """ + ) + ); + } + + @Test + void dependenciesWithSameGroupIdShareOneVersionProperty() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + org.springframework + spring-core + 5.3.0 + + + org.springframework + spring-context + 5.3.0 + + + + """, + """ + + com.example + my-app + 1.0.0 + + 5.3.0 + + + + org.springframework + spring-core + ${org.springframework.version} + + + org.springframework + spring-context + ${org.springframework.version} + + + + """ + ) + ); + } + + @Test + void skipsVersionsAlreadyUsingPropertyPlaceholders() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + + + + junit + junit + ${junit.version} + + + + """ + ) + ); + } + + @Test + void reusesExistingPropertyWhenNameAndValueMatch() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + + + + junit + junit + 4.13.2 + + + + """, + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + + + + junit + junit + ${junit.version} + + + + """ + ) + ); + } + + @Test + void generatesUniquePropertyNameWhenExistingPropertyHasDifferentValue() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + 4.12 + + + + junit + junit + 4.13.2 + + + + """, + """ + + com.example + my-app + 1.0.0 + + 4.12 + 4.13.2 + + + + junit + junit + ${junit.version.1} + + + + """ + ) + ); + } + + @Test + void extractsVersionsFromDependencyManagement() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + + junit + junit + 4.13.2 + test + + + + + """, + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + + + + + junit + junit + ${junit.version} + test + + + + + """ + ) + ); + } + + @Test + void extractsPluginVersions() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + + + """, + """ + + com.example + my-app + 1.0.0 + + 3.11.0 + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + + + """ + ) + ); + } + + @Test + void extractsPluginDependencyVersions() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0 + + + org.junit.jupiter + junit-jupiter-engine + 5.9.1 + + + + + + + """, + """ + + com.example + my-app + 1.0.0 + + 5.9.1 + 3.0.0 + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + org.junit.jupiter + junit-jupiter-engine + ${junit-jupiter-engine.version} + + + + + + + """ + ) + ); + } + + @Test + void isNoOpForMinimalPomWithNoDependencies() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + """ + ) + ); + } + + @Test + void skipsDepWithNoVersionTagWhenManagedByDependencyManagement() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + + junit + junit + 4.13.2 + + + + + + junit + junit + + + + """, + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + + + + + junit + junit + ${junit.version} + + + + + + junit + junit + + + + """ + ) + ); + } + + @Test + void isNoOpForDepAlreadyUsingPropertyWithIndirectValue() { + // lib.version's value is itself a property ref; the recipe's resolveVersion() only goes one + // level deep, returning the intermediate ref rather than the final literal. The dep already + // uses a property placeholder so the recipe must leave it alone regardless. + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + ${junit.base.version} + 4.13.2 + + + + junit + junit + ${junit.version} + + + + """ + ) + ); + } + + @Test + void extractsPluginVersionInPluginManagement() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + + + + """, + """ + + com.example + my-app + 1.0.0 + + 3.11.0 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + + + + """ + ) + ); + } + + @Test + void isNoOpWhenAllVersionsAlreadyExtracted() { + rewriteRun( + pomXml( + """ + + com.example + my-app + 1.0.0 + + 4.13.2 + 1.7.36 + + + + junit + junit + ${junit.version} + test + + + org.slf4j + slf4j-api + ${slf4j-api.version} + + + + """ + ) + ); + } +} From 08af5b8e59bc710fd52d5053830fc1aa331ca25d Mon Sep 17 00:00:00 2001 From: Marius Barbulescu Date: Tue, 12 May 2026 21:13:50 +0200 Subject: [PATCH 2/8] add the new recipe to recipes.csv --- rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv b/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv index 416220af27f..556992ddb73 100644 --- a/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv +++ b/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv @@ -100,3 +100,4 @@ maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.search.ParentPomInsigh maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.search.EffectiveManagedDependencies,Effective managed dependencies,Emit the data of binary dependency relationships.,1,Search,Maven,,"[{""name"":""org.openrewrite.maven.table.ManagedDependencyGraph"",""displayName"":""Managed dependency graph"",""instanceName"":""Managed dependency graph"",""description"":""Relationships between POMs and their ancestors that define managed dependencies."",""columns"":[{""name"":""from"",""type"":""String"",""displayName"":""From dependency"",""description"":""What depends on the 'to' dependency.""},{""name"":""to"",""type"":""String"",""displayName"":""From dependency"",""description"":""A dependency.""}]}]" maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.security.UseHttpsForRepositories,Use HTTPS for repositories,Use HTTPS for repository URLs.,1,Security,Maven,, maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.utilities.PrintMavenAsDot,Print Maven dependency hierarchy in DOT format,The DOT language format is specified [here](https://graphviz.org/doc/info/lang.html).,1,Utilities,Maven,, +maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.ExtractVersionsAsProperties,Extract dependency versions to Maven properties,Extract dependency versions to Maven properties,1,"",Maven,, From c0decf6b60e6602f4aed814b3f3dc7256b26f707 Mon Sep 17 00:00:00 2001 From: Marius Barbulescu Date: Tue, 12 May 2026 21:17:49 +0200 Subject: [PATCH 3/8] fix: append . to description for ExtractVersionsAsProperties recipe in recipes.csv --- rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv b/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv index 556992ddb73..f6bd1b2680c 100644 --- a/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv +++ b/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv @@ -100,4 +100,4 @@ maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.search.ParentPomInsigh maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.search.EffectiveManagedDependencies,Effective managed dependencies,Emit the data of binary dependency relationships.,1,Search,Maven,,"[{""name"":""org.openrewrite.maven.table.ManagedDependencyGraph"",""displayName"":""Managed dependency graph"",""instanceName"":""Managed dependency graph"",""description"":""Relationships between POMs and their ancestors that define managed dependencies."",""columns"":[{""name"":""from"",""type"":""String"",""displayName"":""From dependency"",""description"":""What depends on the 'to' dependency.""},{""name"":""to"",""type"":""String"",""displayName"":""From dependency"",""description"":""A dependency.""}]}]" maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.security.UseHttpsForRepositories,Use HTTPS for repositories,Use HTTPS for repository URLs.,1,Security,Maven,, maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.utilities.PrintMavenAsDot,Print Maven dependency hierarchy in DOT format,The DOT language format is specified [here](https://graphviz.org/doc/info/lang.html).,1,Utilities,Maven,, -maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.ExtractVersionsAsProperties,Extract dependency versions to Maven properties,Extract dependency versions to Maven properties,1,"",Maven,, +maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.ExtractVersionsAsProperties,Extract dependency versions to Maven properties,Extract dependency versions to Maven properties.,1,"",Maven,, From 46d5426b76b9d6e1ba0ef20d54ed61419b18f869 Mon Sep 17 00:00:00 2001 From: Marius Barbulescu Date: Thu, 14 May 2026 10:02:20 +0200 Subject: [PATCH 4/8] show more details about failing tests --- build.gradle.kts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 371ce2df11e..a64c5bed33a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,6 +15,16 @@ allprojects { } subprojects { + tasks.withType().configureEach { + testLogging { + events("failed") + showExceptions = true + showCauses = true + showStackTraces = true + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + } + tasks.withType().configureEach { if (name == "generateAntlrSources") { doLast { From 5c1510473a94cd22812f7ba3a22af9f8754bebcc Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Sat, 20 Jun 2026 22:56:33 +0200 Subject: [PATCH 5/8] Address review feedback on ExtractVersionsAsProperties - Sync recipes.csv display name and description with the recipe code - Add @DocumentExample to the representative test - Use @Value-generated fields for display name and description - Inline the visitor as an anonymous instance - Revert unrelated build.gradle.kts testLogging change --- build.gradle.kts | 10 -- .../maven/ExtractVersionsAsProperties.java | 120 ++++++++---------- .../resources/META-INF/rewrite/recipes.csv | 2 +- .../ExtractVersionsAsPropertiesTest.java | 1 + 4 files changed, 58 insertions(+), 75 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a64c5bed33a..371ce2df11e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,16 +15,6 @@ allprojects { } subprojects { - tasks.withType().configureEach { - testLogging { - events("failed") - showExceptions = true - showCauses = true - showStackTraces = true - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - } - } - tasks.withType().configureEach { if (name == "generateAntlrSources") { doLast { diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java b/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java index 75ede39510e..48f5c71aca3 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java @@ -31,83 +31,75 @@ @EqualsAndHashCode(callSuper = false) public class ExtractVersionsAsProperties extends Recipe { - @Override - public String getDisplayName() { - return "Extract Maven dependency versions as properties"; - } + String displayName = "Extract Maven dependency versions as properties"; - @Override - public String getDescription() { - return "Extracts inlined dependency versions into the `` section and replaces them with `${property}` references."; - } + String description = "Extracts inlined dependency versions into the `` section and replaces them with `${property}` references."; @Override public TreeVisitor getVisitor() { - return new VersionExtractionVisitor(); - } - - private static Stream allDescendants(Xml.Tag tag) { - return Stream.concat(Stream.of(tag), tag.getChildren().stream().flatMap(ExtractVersionsAsProperties::allDescendants)); - } - - private static class VersionExtractionVisitor extends MavenIsoVisitor { - private PropertyResolver propertyResolver; - - @Override - public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) { - Map existingProps = loadExistingProperties(document.getRoot()); - Map groupSharedVersion = GroupVersionAnalyzer.analyze(document.getRoot(), existingProps); - propertyResolver = new PropertyResolver(groupSharedVersion, existingProps); - schedulePropertyRenames(document.getRoot(), existingProps, groupSharedVersion); - return super.visitDocument(document, ctx); - } + return new MavenIsoVisitor() { + private PropertyResolver propertyResolver; + + @Override + public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) { + Map existingProps = loadExistingProperties(document.getRoot()); + Map groupSharedVersion = GroupVersionAnalyzer.analyze(document.getRoot(), existingProps); + propertyResolver = new PropertyResolver(groupSharedVersion, existingProps); + schedulePropertyRenames(document.getRoot(), existingProps, groupSharedVersion); + return super.visitDocument(document, ctx); + } - private static Map loadExistingProperties(Xml.Tag root) { - return root.getChild("properties") - .map(VersionExtractionVisitor::collectPropertiesFrom) - .orElseGet(LinkedHashMap::new); - } + private Map loadExistingProperties(Xml.Tag root) { + return root.getChild("properties") + .map(this::collectPropertiesFrom) + .orElseGet(LinkedHashMap::new); + } - private static Map collectPropertiesFrom(Xml.Tag propsTag) { - return propsTag.getChildren().stream() - .filter(child -> child.getValue().isPresent()) - .collect(Collectors.toMap( - Xml.Tag::getName, - child -> child.getValue().get(), - (a, b) -> a, - LinkedHashMap::new)); - } + private Map collectPropertiesFrom(Xml.Tag propsTag) { + return propsTag.getChildren().stream() + .filter(child -> child.getValue().isPresent()) + .collect(Collectors.toMap( + Xml.Tag::getName, + child -> child.getValue().get(), + (a, b) -> a, + LinkedHashMap::new)); + } - private void schedulePropertyRenames(Xml.Tag root, Map existingProps, - Map groupSharedVersion) { - PropertyRenamer.findRenames(root, existingProps, groupSharedVersion) - .forEach((oldKey, newKey) -> applyRename(oldKey, newKey, existingProps)); - } + private void schedulePropertyRenames(Xml.Tag root, Map existingProps, + Map groupSharedVersion) { + PropertyRenamer.findRenames(root, existingProps, groupSharedVersion) + .forEach((oldKey, newKey) -> applyRename(oldKey, newKey, existingProps)); + } - private void applyRename(String oldKey, String newKey, Map existingProps) { - doAfterVisit(new RenamePropertyKey(oldKey, newKey).getVisitor()); - propertyResolver.registerKey(newKey, existingProps.get(oldKey)); - } + private void applyRename(String oldKey, String newKey, Map existingProps) { + doAfterVisit(new RenamePropertyKey(oldKey, newKey).getVisitor()); + propertyResolver.registerKey(newKey, existingProps.get(oldKey)); + } - @Override - public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) { - if (isDependencyTag() || isManagedDependencyTag() || isPluginTag() || isPluginDependencyTag()) { - Optional versionTag = tag.getChild("version"); - if (versionTag.isPresent()) { - String version = versionTag.get().getValue().orElse(null); - if (version != null && !PropertyResolver.isPropertyRef(version)) { - String groupId = tag.getChildValue("groupId").orElse(null); - String artifactId = tag.getChildValue("artifactId").orElse(null); - if (artifactId != null) { - String propertyKey = propertyResolver.resolvePropertyKey(groupId, artifactId, version); - doAfterVisit(new AddPropertyVisitor(propertyKey, version, true)); - doAfterVisit(new ChangeTagValueVisitor<>(versionTag.get(), "${" + propertyKey + "}")); + @Override + public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) { + if (isDependencyTag() || isManagedDependencyTag() || isPluginTag() || isPluginDependencyTag()) { + Optional versionTag = tag.getChild("version"); + if (versionTag.isPresent()) { + String version = versionTag.get().getValue().orElse(null); + if (version != null && !PropertyResolver.isPropertyRef(version)) { + String groupId = tag.getChildValue("groupId").orElse(null); + String artifactId = tag.getChildValue("artifactId").orElse(null); + if (artifactId != null) { + String propertyKey = propertyResolver.resolvePropertyKey(groupId, artifactId, version); + doAfterVisit(new AddPropertyVisitor(propertyKey, version, true)); + doAfterVisit(new ChangeTagValueVisitor<>(versionTag.get(), "${" + propertyKey + "}")); + } } } } + return super.visitTag(tag, ctx); } - return super.visitTag(tag, ctx); - } + }; + } + + private static Stream allDescendants(Xml.Tag tag) { + return Stream.concat(Stream.of(tag), tag.getChildren().stream().flatMap(ExtractVersionsAsProperties::allDescendants)); } private static class PropertyResolver { diff --git a/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv b/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv index f6bd1b2680c..2ae91fda1fa 100644 --- a/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv +++ b/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv @@ -100,4 +100,4 @@ maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.search.ParentPomInsigh maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.search.EffectiveManagedDependencies,Effective managed dependencies,Emit the data of binary dependency relationships.,1,Search,Maven,,"[{""name"":""org.openrewrite.maven.table.ManagedDependencyGraph"",""displayName"":""Managed dependency graph"",""instanceName"":""Managed dependency graph"",""description"":""Relationships between POMs and their ancestors that define managed dependencies."",""columns"":[{""name"":""from"",""type"":""String"",""displayName"":""From dependency"",""description"":""What depends on the 'to' dependency.""},{""name"":""to"",""type"":""String"",""displayName"":""From dependency"",""description"":""A dependency.""}]}]" maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.security.UseHttpsForRepositories,Use HTTPS for repositories,Use HTTPS for repository URLs.,1,Security,Maven,, maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.utilities.PrintMavenAsDot,Print Maven dependency hierarchy in DOT format,The DOT language format is specified [here](https://graphviz.org/doc/info/lang.html).,1,Utilities,Maven,, -maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.ExtractVersionsAsProperties,Extract dependency versions to Maven properties,Extract dependency versions to Maven properties.,1,"",Maven,, +maven,org.openrewrite:rewrite-maven,org.openrewrite.maven.ExtractVersionsAsProperties,Extract Maven dependency versions as properties,Extracts inlined dependency versions into the `` section and replaces them with `${property}` references.,1,"",Maven,, diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java index 08e2052fa53..cf6ac8a7b2b 100644 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java @@ -29,6 +29,7 @@ public void defaults(RecipeSpec spec) { spec.recipe(new ExtractVersionsAsProperties()); } + @DocumentExample @Test void extractsSingleDependencyVersionIntoNewPropertiesBlock() { rewriteRun( From 9cb81920d1ea33f8801b99ad8d3d447b28045f4a Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Sat, 20 Jun 2026 23:07:57 +0200 Subject: [PATCH 6/8] Use static imports for Collectors in ExtractVersionsAsProperties --- .../maven/ExtractVersionsAsProperties.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java b/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java index 48f5c71aca3..fe75155e230 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java @@ -24,9 +24,10 @@ import org.openrewrite.xml.tree.Xml; import java.util.*; -import java.util.stream.Collectors; import java.util.stream.Stream; +import static java.util.stream.Collectors.*; + @Value @EqualsAndHashCode(callSuper = false) public class ExtractVersionsAsProperties extends Recipe { @@ -58,7 +59,7 @@ private Map loadExistingProperties(Xml.Tag root) { private Map collectPropertiesFrom(Xml.Tag propsTag) { return propsTag.getChildren().stream() .filter(child -> child.getValue().isPresent()) - .collect(Collectors.toMap( + .collect(toMap( Xml.Tag::getName, child -> child.getValue().get(), (a, b) -> a, @@ -148,12 +149,12 @@ static Map analyze(Xml.Tag root, Map existingPro return allDescendants(root) .filter(tag -> "dependency".equals(tag.getName()) || "plugin".equals(tag.getName())) .filter(tag -> tag.getChildValue("groupId").isPresent()) - .collect(Collectors.groupingBy( + .collect(groupingBy( tag -> tag.getChildValue("groupId").get(), - Collectors.toList())) + toList())) .entrySet().stream() .flatMap(groupEntry -> toSharedVersionEntry(groupEntry, existingProps)) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); } private static Stream> toSharedVersionEntry( @@ -163,7 +164,7 @@ private static Stream> toSharedVersionEntry( .filter(Objects::nonNull) .map(v -> PropertyResolver.resolveToLiteral(v, existingProps)) .filter(Objects::nonNull) - .collect(Collectors.toList()); + .collect(toList()); if (resolvedVersions.size() > 1 && new HashSet<>(resolvedVersions).size() == 1) { return Stream.of(new AbstractMap.SimpleEntry<>(groupEntry.getKey(), resolvedVersions.get(0))); } @@ -179,7 +180,7 @@ static Map findRenames(Xml.Tag root, Map existin return allDescendants(root) .filter(tag -> "dependency".equals(tag.getName()) || "plugin".equals(tag.getName())) .flatMap(tag -> toNonStandardRenameEntry(tag, existingProps, groupSharedVersion)) - .collect(Collectors.toMap( + .collect(toMap( Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, From d25ea730eb5b4f6b4f968f139b1d442cc8109177 Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Sat, 20 Jun 2026 23:11:34 +0200 Subject: [PATCH 7/8] Address review feedback: static Collectors imports, inline method, arg order --- .../maven/ExtractVersionsAsProperties.java | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java b/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java index fe75155e230..9a8505f5a79 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java @@ -46,7 +46,8 @@ public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) { Map existingProps = loadExistingProperties(document.getRoot()); Map groupSharedVersion = GroupVersionAnalyzer.analyze(document.getRoot(), existingProps); propertyResolver = new PropertyResolver(groupSharedVersion, existingProps); - schedulePropertyRenames(document.getRoot(), existingProps, groupSharedVersion); + PropertyRenamer.findRenames(document.getRoot(), groupSharedVersion, existingProps) + .forEach((oldKey, newKey) -> applyRename(oldKey, newKey, existingProps)); return super.visitDocument(document, ctx); } @@ -66,12 +67,6 @@ private Map collectPropertiesFrom(Xml.Tag propsTag) { LinkedHashMap::new)); } - private void schedulePropertyRenames(Xml.Tag root, Map existingProps, - Map groupSharedVersion) { - PropertyRenamer.findRenames(root, existingProps, groupSharedVersion) - .forEach((oldKey, newKey) -> applyRename(oldKey, newKey, existingProps)); - } - private void applyRename(String oldKey, String newKey, Map existingProps) { doAfterVisit(new RenamePropertyKey(oldKey, newKey).getVisitor()); propertyResolver.registerKey(newKey, existingProps.get(oldKey)); @@ -175,8 +170,8 @@ private static Stream> toSharedVersionEntry( private static class PropertyRenamer { // For deps in a shared-version group that already reference a non-standard ${propName}, // returns oldKey→newKey pairs so the visitor can schedule RenamePropertyKey for each. - static Map findRenames(Xml.Tag root, Map existingProps, - Map groupSharedVersion) { + static Map findRenames(Xml.Tag root, Map groupSharedVersion, + Map existingProps) { return allDescendants(root) .filter(tag -> "dependency".equals(tag.getName()) || "plugin".equals(tag.getName())) .flatMap(tag -> toNonStandardRenameEntry(tag, existingProps, groupSharedVersion)) From 2c917b69bc80f1aad50f7b5fb4e5177d8faed260 Mon Sep 17 00:00:00 2001 From: Tim te Beek Date: Tue, 30 Jun 2026 21:15:00 +0200 Subject: [PATCH 8/8] Do not rename properties inherited from a parent POM Spring Boot's spring-boot-dependencies declares version properties (e.g. mockito.version) that the parent's dependency management references by name; redeclaring one locally is the override mechanism. The group-standardizing rename would rewrite such a property to groupId.version and silently break that wiring outside this pom.xml. Skip renaming any property inherited from an ancestor POM, resolving the parent hierarchy like RemoveRedundantProperties. Also adds tests covering version extraction under spring-boot-starter-parent. --- .../maven/ExtractVersionsAsProperties.java | 44 ++++- .../ExtractVersionsAsPropertiesTest.java | 160 ++++++++++++++++++ 2 files changed, 199 insertions(+), 5 deletions(-) diff --git a/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java b/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java index 9a8505f5a79..fc3d524bdff 100644 --- a/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java +++ b/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java @@ -20,12 +20,17 @@ import org.openrewrite.ExecutionContext; import org.openrewrite.Recipe; import org.openrewrite.TreeVisitor; +import org.openrewrite.maven.internal.MavenPomDownloader; +import org.openrewrite.maven.tree.MavenResolutionResult; import org.openrewrite.xml.ChangeTagValueVisitor; import org.openrewrite.xml.tree.Xml; import java.util.*; import java.util.stream.Stream; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.emptySet; import static java.util.stream.Collectors.*; @Value @@ -46,11 +51,39 @@ public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) { Map existingProps = loadExistingProperties(document.getRoot()); Map groupSharedVersion = GroupVersionAnalyzer.analyze(document.getRoot(), existingProps); propertyResolver = new PropertyResolver(groupSharedVersion, existingProps); - PropertyRenamer.findRenames(document.getRoot(), groupSharedVersion, existingProps) + Set inheritedProps = inheritedPropertyKeys(ctx); + PropertyRenamer.findRenames(document.getRoot(), groupSharedVersion, existingProps, inheritedProps) .forEach((oldKey, newKey) -> applyRename(oldKey, newKey, existingProps)); return super.visitDocument(document, ctx); } + // Property names declared by an ancestor POM (e.g. Spring Boot's `spring-boot-dependencies`). + // These must not be renamed: the parent's dependency management still references them by name, + // so a local rename would silently break that wiring outside the scope of this pom.xml. + private Set inheritedPropertyKeys(ExecutionContext ctx) { + MavenResolutionResult mrr = getResolutionResult(); + if (mrr.getParent() != null) { + return mrr.getParent().getPom().getProperties().keySet(); + } + if (mrr.getPom().getRequested().getParent() == null) { + return emptySet(); + } + MavenPomDownloader downloader = new MavenPomDownloader( + mrr.getProjectPoms(), ctx, mrr.getMavenSettings(), mrr.getActiveProfiles()); + try { + return mrr.getPom().getRequested() + .withProperties(emptyMap()) + .withDependencies(emptyList()) + .withDependencyManagement(emptyList()) + .withPlugins(emptyList()) + .withPluginManagement(emptyList()) + .resolve(mrr.getActiveProfiles(), downloader, ctx) + .getProperties().keySet(); + } catch (MavenDownloadingException e) { + return emptySet(); + } + } + private Map loadExistingProperties(Xml.Tag root) { return root.getChild("properties") .map(this::collectPropertiesFrom) @@ -171,10 +204,10 @@ private static class PropertyRenamer { // For deps in a shared-version group that already reference a non-standard ${propName}, // returns oldKey→newKey pairs so the visitor can schedule RenamePropertyKey for each. static Map findRenames(Xml.Tag root, Map groupSharedVersion, - Map existingProps) { + Map existingProps, Set inheritedProps) { return allDescendants(root) .filter(tag -> "dependency".equals(tag.getName()) || "plugin".equals(tag.getName())) - .flatMap(tag -> toNonStandardRenameEntry(tag, existingProps, groupSharedVersion)) + .flatMap(tag -> toNonStandardRenameEntry(tag, existingProps, groupSharedVersion, inheritedProps)) .collect(toMap( Map.Entry::getKey, Map.Entry::getValue, @@ -183,7 +216,8 @@ static Map findRenames(Xml.Tag root, Map groupSh } private static Stream> toNonStandardRenameEntry( - Xml.Tag tag, Map existingProps, Map groupSharedVersion) { + Xml.Tag tag, Map existingProps, Map groupSharedVersion, + Set inheritedProps) { String groupId = tag.getChildValue("groupId").orElse(null); if (groupId == null || !groupSharedVersion.containsKey(groupId)) { return Stream.empty(); @@ -195,7 +229,7 @@ private static Stream> toNonStandardRenameEntry( } String trimmedVersion = version.trim(); String propRef = trimmedVersion.substring(2, trimmedVersion.length() - 1); - if (propRef.equals(standardKey) || !existingProps.containsKey(propRef)) { + if (propRef.equals(standardKey) || !existingProps.containsKey(propRef) || inheritedProps.contains(propRef)) { return Stream.empty(); } return Stream.of(new AbstractMap.SimpleEntry<>(propRef, standardKey)); diff --git a/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java b/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java index cf6ac8a7b2b..062a5c28edd 100644 --- a/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java +++ b/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java @@ -669,6 +669,166 @@ void extractsPluginVersionInPluginManagement() { ); } + @Test + void extractsInlinedVersionUnderSpringBootStarterParentReusingInheritedPropertyName() { + // spring-boot-starter-parent's own parent (spring-boot-dependencies) declares a `h2.version` + // property and manages com.h2database:h2 via `${h2.version}`. Those inherited properties live in + // the parent hierarchy, not in this document, so the recipe never sees them. It derives a local + // property name purely from the artifactId ("h2" -> "h2.version"), which here happens to match + // the inherited property name. Because Spring Boot's override convention is to redeclare that same + // property locally, the extracted `` not only de-inlines the version but also becomes + // the idiomatic override of the managed version. + rewriteRun( + pomXml( + """ + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + com.example + my-app + 1.0.0 + + + com.h2database + h2 + 2.1.214 + + + + """, + """ + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + com.example + my-app + 1.0.0 + + 2.1.214 + + + + com.h2database + h2 + ${h2.version} + + + + """ + ) + ); + } + + @Test + void extractsInlinedVersionUnderSpringBootStarterParentWithoutReusingInheritedPropertyName() { + // Counterpart to the h2 case: spring-boot-dependencies manages org.mockito:mockito-core via a + // `mockito.version` property, but the recipe names its local property after the artifactId + // ("mockito-core" -> "mockito-core.version") rather than after the inherited `mockito.version`. + // The dependency still resolves to the intended version through the explicit `${mockito-core.version}` + // reference, but the extracted property does not line up with Spring Boot's override convention. + rewriteRun( + pomXml( + """ + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + com.example + my-app + 1.0.0 + + + org.mockito + mockito-core + 4.5.1 + + + + """, + """ + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + com.example + my-app + 1.0.0 + + 4.5.1 + + + + org.mockito + mockito-core + ${mockito-core.version} + + + + """ + ) + ); + } + + @Test + void doesNotRenameInheritedSpringBootPropertyToGroupStandardName() { + // `mockito.version` is declared by spring-boot-dependencies and the parent's dependency management + // references it by that exact name; redeclaring it locally is Spring Boot's override mechanism. + // Both mockito dependencies share that property, so the group-standardizing rename would normally + // rewrite it to `org.mockito.version` - but that would break the parent's `${mockito.version}` + // wiring, which lives outside this pom.xml. The recipe must leave the inherited property untouched. + rewriteRun( + pomXml( + """ + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + com.example + my-app + 1.0.0 + + 4.5.1 + + + + org.mockito + mockito-core + ${mockito.version} + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + + + + """ + ) + ); + } + @Test void isNoOpWhenAllVersionsAlreadyExtracted() { rewriteRun(