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..fc3d524bdff
--- /dev/null
+++ b/rewrite-maven/src/main/java/org/openrewrite/maven/ExtractVersionsAsProperties.java
@@ -0,0 +1,238 @@
+/*
+ * 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.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
+@EqualsAndHashCode(callSuper = false)
+public class ExtractVersionsAsProperties extends Recipe {
+
+ String displayName = "Extract Maven dependency versions as properties";
+
+ String description = "Extracts inlined dependency versions into the `` section and replaces them with `${property}` references.";
+
+ @Override
+ public TreeVisitor, ExecutionContext> getVisitor() {
+ 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);
+ 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)
+ .orElseGet(LinkedHashMap::new);
+ }
+
+ private Map collectPropertiesFrom(Xml.Tag propsTag) {
+ return propsTag.getChildren().stream()
+ .filter(child -> child.getValue().isPresent())
+ .collect(toMap(
+ Xml.Tag::getName,
+ child -> child.getValue().get(),
+ (a, b) -> a,
+ LinkedHashMap::new));
+ }
+
+ 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 Stream allDescendants(Xml.Tag tag) {
+ return Stream.concat(Stream.of(tag), tag.getChildren().stream().flatMap(ExtractVersionsAsProperties::allDescendants));
+ }
+
+ 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(groupingBy(
+ tag -> tag.getChildValue("groupId").get(),
+ toList()))
+ .entrySet().stream()
+ .flatMap(groupEntry -> toSharedVersionEntry(groupEntry, existingProps))
+ .collect(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(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 groupSharedVersion,
+ Map existingProps, Set inheritedProps) {
+ return allDescendants(root)
+ .filter(tag -> "dependency".equals(tag.getName()) || "plugin".equals(tag.getName()))
+ .flatMap(tag -> toNonStandardRenameEntry(tag, existingProps, groupSharedVersion, inheritedProps))
+ .collect(toMap(
+ Map.Entry::getKey,
+ Map.Entry::getValue,
+ (a, b) -> a,
+ LinkedHashMap::new));
+ }
+
+ private static Stream> toNonStandardRenameEntry(
+ 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();
+ }
+ 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) || inheritedProps.contains(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/main/resources/META-INF/rewrite/recipes.csv b/rewrite-maven/src/main/resources/META-INF/rewrite/recipes.csv
index 416220af27f..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,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 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
new file mode 100644
index 00000000000..062a5c28edd
--- /dev/null
+++ b/rewrite-maven/src/test/java/org/openrewrite/maven/ExtractVersionsAsPropertiesTest.java
@@ -0,0 +1,863 @@
+/*
+ * 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());
+ }
+
+ @DocumentExample
+ @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 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(
+ 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}
+
+
+
+ """
+ )
+ );
+ }
+}