diff --git a/build.gradle.kts b/build.gradle.kts
index 8325cd360..f49fddc73 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -75,6 +75,7 @@ val rewriteVersion = rewriteRecipe.rewriteVersion.get()
dependencies {
implementation(platform("org.openrewrite:rewrite-bom:${rewriteVersion}"))
implementation("org.openrewrite:rewrite-java")
+ implementation("org.openrewrite:rewrite-xml")
implementation("org.openrewrite:rewrite-gradle")
implementation("org.openrewrite:rewrite-maven")
implementation("org.openrewrite.recipe:rewrite-java-dependencies:${rewriteVersion}")
@@ -98,6 +99,7 @@ dependencies {
testImplementation("org.openrewrite:rewrite-java-25")
testImplementation("org.openrewrite:rewrite-groovy")
testImplementation("org.openrewrite:rewrite-test")
+ testImplementation("org.openrewrite:rewrite-xml")
testImplementation("org.openrewrite:rewrite-kotlin")
testImplementation("org.openrewrite.gradle.tooling:model:${rewriteVersion}")
testRuntimeOnly(gradleApi())
diff --git a/src/main/java/org/openrewrite/java/testing/pmd/PmdXml6to7Migration.java b/src/main/java/org/openrewrite/java/testing/pmd/PmdXml6to7Migration.java
new file mode 100644
index 000000000..a7d30d072
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/testing/pmd/PmdXml6to7Migration.java
@@ -0,0 +1,239 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * 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.java.testing.pmd;
+
+import org.jspecify.annotations.Nullable;
+import org.openrewrite.ExecutionContext;
+import org.openrewrite.Recipe;
+import org.openrewrite.TreeVisitor;
+import org.openrewrite.xml.XmlIsoVisitor;
+import org.openrewrite.xml.tree.Content;
+import org.openrewrite.xml.tree.Xml;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public class PmdXml6to7Migration extends Recipe {
+
+ private static final String PREFIX = "category/java/";
+
+ /**
+ * Java rules removed in PMD 7 that have a single unambiguous successor, mapped to that successor.
+ * Note that a rule's replacement often lives in a different category than the rule it replaces.
+ *
+ * @see PMD 7.0.0 release notes, "Removed Rules"
+ */
+ private static final Map REPLACED_RULES = new LinkedHashMap<>();
+
+ /**
+ * Java rules removed in PMD 7 with no successor. PMD 7 fails on a `ref` it cannot resolve, so these are
+ * dropped from the ruleset rather than left behind.
+ */
+ private static final Set REMOVED_RULES = new HashSet<>(Arrays.asList(
+ PREFIX + "codestyle.xml/AvoidFinalLocalVariable",
+ PREFIX + "performance.xml/AvoidUsingShortType",
+ PREFIX + "errorprone.xml/CloneThrowsCloneNotSupportedException",
+ PREFIX + "performance.xml/SimplifyStartsWith"));
+
+ static {
+ replaced("codestyle.xml/AbstractNaming", "codestyle.xml/ClassNamingConventions");
+ replaced("codestyle.xml/AvoidPrefixingMethodParameters", "codestyle.xml/FormalParameterNamingConventions");
+ replaced("errorprone.xml/BadComparison", "errorprone.xml/ComparisonWithNaN");
+ replaced("errorprone.xml/BeanMembersShouldSerialize", "errorprone.xml/NonSerializableClass");
+ replaced("errorprone.xml/DataflowAnomalyAnalysis", "bestpractices.xml/UnusedAssignment");
+ replaced("codestyle.xml/DefaultPackage", "codestyle.xml/CommentDefaultAccessModifier");
+ replaced("errorprone.xml/DoNotCallSystemExit", "errorprone.xml/DoNotTerminateVM");
+ replaced("codestyle.xml/DontImportJavaLang", "codestyle.xml/UnnecessaryImport");
+ replaced("errorprone.xml/DontImportSun", "errorprone.xml/UnsupportedJdkApiUsage");
+ replaced("codestyle.xml/DuplicateImports", "codestyle.xml/UnnecessaryImport");
+ replaced("errorprone.xml/EmptyFinallyBlock", "codestyle.xml/EmptyControlStatement");
+ replaced("errorprone.xml/EmptyIfStmt", "codestyle.xml/EmptyControlStatement");
+ replaced("errorprone.xml/EmptyInitializer", "codestyle.xml/EmptyControlStatement");
+ replaced("errorprone.xml/EmptyStatementBlock", "codestyle.xml/EmptyControlStatement");
+ replaced("errorprone.xml/EmptyStatementNotInLoop", "codestyle.xml/UnnecessarySemicolon");
+ replaced("errorprone.xml/EmptySwitchStatements", "codestyle.xml/EmptyControlStatement");
+ replaced("errorprone.xml/EmptySynchronizedBlock", "codestyle.xml/EmptyControlStatement");
+ replaced("errorprone.xml/EmptyTryBlock", "codestyle.xml/EmptyControlStatement");
+ replaced("errorprone.xml/EmptyWhileStmt", "codestyle.xml/EmptyControlStatement");
+ replaced("design.xml/ExcessiveClassLength", "design.xml/NcssCount");
+ replaced("design.xml/ExcessiveMethodLength", "design.xml/NcssCount");
+ replaced("codestyle.xml/ForLoopsMustUseBraces", "codestyle.xml/ControlStatementBraces");
+ replaced("codestyle.xml/IfElseStmtsMustUseBraces", "codestyle.xml/ControlStatementBraces");
+ replaced("codestyle.xml/IfStmtsMustUseBraces", "codestyle.xml/ControlStatementBraces");
+ replaced("errorprone.xml/ImportFromSamePackage", "codestyle.xml/UnnecessaryImport");
+ replaced("errorprone.xml/InvalidSlf4jMessageFormat", "errorprone.xml/InvalidLogMessageFormat");
+ replaced("errorprone.xml/LoggerIsNotStaticFinal", "errorprone.xml/ProperLogger");
+ replaced("errorprone.xml/MissingBreakInSwitch", "errorprone.xml/ImplicitSwitchFallThrough");
+ replaced("design.xml/ModifiedCyclomaticComplexity", "design.xml/CyclomaticComplexity");
+ replaced("design.xml/NcssConstructorCount", "design.xml/NcssCount");
+ replaced("design.xml/NcssMethodCount", "design.xml/NcssCount");
+ replaced("design.xml/NcssTypeCount", "design.xml/NcssCount");
+ replaced("bestpractices.xml/PositionLiteralsFirstInCaseInsensitiveComparisons", "bestpractices.xml/LiteralsFirstInComparisons");
+ replaced("bestpractices.xml/PositionLiteralsFirstInComparisons", "bestpractices.xml/LiteralsFirstInComparisons");
+ replaced("errorprone.xml/ReturnEmptyArrayRatherThanNull", "errorprone.xml/ReturnEmptyCollectionRatherThanNull");
+ replaced("design.xml/SimplifyBooleanAssertion", "bestpractices.xml/SimplifiableTestAssertion");
+ replaced("design.xml/StdCyclomaticComplexity", "design.xml/CyclomaticComplexity");
+ replaced("codestyle.xml/SuspiciousConstantFieldName", "codestyle.xml/FieldNamingConventions");
+ replaced("performance.xml/UnnecessaryWrapperObjectCreation", "codestyle.xml/UnnecessaryBoxing");
+ replaced("multithreading.xml/UnsynchronizedStaticDateFormatter", "multithreading.xml/UnsynchronizedStaticFormatter");
+ replaced("bestpractices.xml/UnusedImports", "codestyle.xml/UnnecessaryImport");
+ replaced("bestpractices.xml/UseAssertEqualsInsteadOfAssertTrue", "bestpractices.xml/SimplifiableTestAssertion");
+ replaced("bestpractices.xml/UseAssertNullInsteadOfAssertEquals", "bestpractices.xml/SimplifiableTestAssertion");
+ replaced("bestpractices.xml/UseAssertSameInsteadOfAssertEquals", "bestpractices.xml/SimplifiableTestAssertion");
+ replaced("bestpractices.xml/UseAssertTrueInsteadOfAssertEquals", "bestpractices.xml/SimplifiableTestAssertion");
+ replaced("codestyle.xml/WhileLoopsMustUseBraces", "codestyle.xml/ControlStatementBraces");
+
+ // Deliberately not mapped, because PMD 7 splits each of these across several rules and picking one
+ // for the reader would silently change what the ruleset covers. They are left in place for a human:
+ // performance.xml/{Boolean,Byte,Integer,Long,Short}Instantiation
+ // -> codestyle.xml/UnnecessaryBoxing and bestpractices.xml/PrimitiveWrapperInstantiation
+ // codestyle.xml/MIsLeadingVariableName, codestyle.xml/VariableNamingConventions
+ // -> codestyle.xml/{Field,FormalParameter,LocalVariable}NamingConventions
+ }
+
+ private static void replaced(String pmd6Rule, String pmd7Rule) {
+ REPLACED_RULES.put(PREFIX + pmd6Rule, PREFIX + pmd7Rule);
+ }
+
+ @Override
+ public String getDisplayName() {
+ return "Migrate PMD 6 rulesets to PMD 7";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Update references to Java rules that were removed in PMD 7 in a PMD `ruleset` XML file. Rules " +
+ "with a single PMD 7 successor are repointed at it, rules removed without a successor are dropped, " +
+ "and a successor that the ruleset already selects is not added twice. Rules that PMD 7 splits " +
+ "across several successors are left alone, since choosing one would change what the ruleset covers.";
+ }
+
+ @Override
+ public TreeVisitor, ExecutionContext> getVisitor() {
+ return new XmlIsoVisitor() {
+ @Override
+ public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) {
+ // A PMD ruleset is always a `ruleset` document; skip any other XML rather than matching a
+ // `ruleset` tag that happens to appear somewhere else.
+ if (!"ruleset".equals(document.getRoot().getName())) {
+ return document;
+ }
+ return super.visitDocument(document, ctx);
+ }
+
+ @Override
+ public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) {
+ Xml.Tag t = super.visitTag(tag, ctx);
+ if (!"ruleset".equals(t.getName()) || t.getContent() == null) {
+ return t;
+ }
+
+ List content = new ArrayList<>(t.getContent());
+ boolean changed = false;
+ for (int i = content.size() - 1; i >= 0; i--) {
+ Xml.Tag rule = asRule(content.get(i));
+ String ref = rule == null ? null : ref(rule);
+ if (ref == null) {
+ continue;
+ }
+ if (REMOVED_RULES.contains(ref)) {
+ content.remove(i);
+ changed = true;
+ continue;
+ }
+ String replacement = REPLACED_RULES.get(ref);
+ if (replacement == null) {
+ continue;
+ }
+ content.remove(i);
+ changed = true;
+ if (!selects(content, replacement)) {
+ insertSorted(content, withRef(rule, replacement));
+ }
+ }
+ return changed ? t.withContent(content) : t;
+ }
+
+ /**
+ * Insert a rule at the position that keeps it in `ref` order relative to the rules already present,
+ * taking the indentation of whichever rule it is placed next to. Rules that are not being replaced
+ * are never reordered, so a ruleset that happens to be unsorted stays as its author wrote it.
+ */
+ private void insertSorted(List content, Xml.Tag rule) {
+ int lastRule = -1;
+ for (int i = 0; i < content.size(); i++) {
+ Xml.Tag existing = asRule(content.get(i));
+ if (existing == null) {
+ continue;
+ }
+ String existingRef = ref(existing);
+ if (existingRef != null && existingRef.compareTo(ref(rule)) > 0) {
+ content.add(i, rule.withPrefix(existing.getPrefix()));
+ return;
+ }
+ lastRule = i;
+ }
+ if (lastRule == -1) {
+ content.add(rule);
+ } else {
+ content.add(lastRule + 1, rule.withPrefix(content.get(lastRule).getPrefix()));
+ }
+ }
+
+ private boolean selects(List content, String ref) {
+ for (Content c : content) {
+ Xml.Tag rule = asRule(c);
+ if (rule != null && ref.equals(ref(rule))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private Xml.@Nullable Tag asRule(Content content) {
+ if (content instanceof Xml.Tag && "rule".equals(((Xml.Tag) content).getName())) {
+ return (Xml.Tag) content;
+ }
+ return null;
+ }
+
+ private @Nullable String ref(Xml.Tag rule) {
+ for (Xml.Attribute attribute : rule.getAttributes()) {
+ if ("ref".equals(attribute.getKeyAsString())) {
+ return attribute.getValueAsString();
+ }
+ }
+ return null;
+ }
+
+ private Xml.Tag withRef(Xml.Tag rule, String ref) {
+ List attributes = new ArrayList<>(rule.getAttributes());
+ for (int i = 0; i < attributes.size(); i++) {
+ Xml.Attribute attribute = attributes.get(i);
+ if ("ref".equals(attribute.getKeyAsString())) {
+ attributes.set(i, attribute.withValue(attribute.getValue().withValue(ref)));
+ }
+ }
+ return rule.withAttributes(attributes);
+ }
+ };
+ }
+}
diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv
index a22bab8aa..75b006525 100644
--- a/src/main/resources/META-INF/rewrite/recipes.csv
+++ b/src/main/resources/META-INF/rewrite/recipes.csv
@@ -247,6 +247,7 @@ maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.tes
maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.testing.mockito.SimplifyMockitoVerifyWhenGiven,"Call to Mockito method ""verify"", ""when"" or ""given"" should be simplified","Fixes Sonar issue `java:S6068`: Call to Mockito method ""verify"", ""when"" or ""given"" should be simplified.",1,Mockito,Testing,Java,,,Basic building blocks for transforming Java code.,,
maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.testing.mockito.ThenThrowCheckedExceptionToRuntimeException,Replace undeclared checked exceptions in `thenThrow` with `RuntimeException`,"In Mockito 3+, `thenThrow()` validates that checked exceptions are declared in the mocked method's `throws` clause. This recipe replaces checked exception class literals in `thenThrow()` calls with `RuntimeException.class` when the mocked method does not declare the exception.",1,Mockito,Testing,Java,,,Basic building blocks for transforming Java code.,,
maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.testing.mockito.VerifyZeroToNoMoreInteractions,Replace `verifyZeroInteractions()` with `verifyNoMoreInteractions()`,Replaces `verifyZeroInteractions()` with `verifyNoMoreInteractions()` in Mockito tests when migration when using a Mockito version < 3.x.,1,Mockito,Testing,Java,,,Basic building blocks for transforming Java code.,,
+maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.testing.pmd.PmdXml6to7Migration,Migrate PMD 6 rulesets to PMD 7,"Update references to Java rules that were removed in PMD 7 in a PMD `ruleset` XML file. Rules with a single PMD 7 successor are repointed at it, rules removed without a successor are dropped, and a successor that the ruleset already selects is not added twice. Rules that PMD 7 splits across several successors are left alone, since choosing one would change what the ruleset covers.",1,Pmd,Testing,Java,,,Basic building blocks for transforming Java code.,,
maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.testing.testcontainers.AddTestcontainersAnnotations,Adopt `@Container` and add `@Testcontainers`,Convert Testcontainers `@Rule`/`@ClassRule` to JUnit 5 `@Container` and add `@Testcontainers`.,1,Testcontainers,Testing,Java,Recipes for [Testcontainers](https://testcontainers.com/) integration testing with Docker.,,Basic building blocks for transforming Java code.,,
maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.testing.testcontainers.ConvertToRawType,Remove parameterized type arguments from a Java class,Convert parameterized types of a specified Java class to their raw types.,1,Testcontainers,Testing,Java,Recipes for [Testcontainers](https://testcontainers.com/) integration testing with Docker.,,Basic building blocks for transforming Java code.,"[{""name"":""fullyQualifiedTypeName"",""type"":""String"",""displayName"":""Fully qualified type name"",""description"":""The fully qualified name of the Java class to convert to its raw type."",""example"":""org.testcontainers.containers.PostgreSQLContainer"",""required"":true}]",
maven,org.openrewrite.recipe:rewrite-testing-frameworks,org.openrewrite.java.testing.testcontainers.ExplicitContainerImage,Add image argument to container constructor,"Set the image to use for a container explicitly if unset, rather than relying on the default image for the container class.",1,Testcontainers,Testing,Java,Recipes for [Testcontainers](https://testcontainers.com/) integration testing with Docker.,,Basic building blocks for transforming Java code.,"[{""name"":""containerClass"",""type"":""String"",""displayName"":""Container class"",""description"":""The fully qualified name of the container class to use."",""example"":""org.testcontainers.containers.NginxContainer"",""required"":true},{""name"":""image"",""type"":""String"",""displayName"":""Image to use"",""description"":""The image to use for the container."",""example"":""nginx:1.9.4"",""required"":true},{""name"":""parseImage"",""type"":""Boolean"",""displayName"":""Parse image"",""description"":""Whether to call `DockerImageName.parse(image)`.""}]",
diff --git a/src/test/java/org/openrewrite/java/testing/pmd/PmdXml6to7MigrationTest.java b/src/test/java/org/openrewrite/java/testing/pmd/PmdXml6to7MigrationTest.java
new file mode 100644
index 000000000..788b17bab
--- /dev/null
+++ b/src/test/java/org/openrewrite/java/testing/pmd/PmdXml6to7MigrationTest.java
@@ -0,0 +1,256 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * 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.java.testing.pmd;
+
+import org.junit.jupiter.api.Test;
+import org.openrewrite.DocumentExample;
+import org.openrewrite.InMemoryExecutionContext;
+import org.openrewrite.java.JavaParser;
+import org.openrewrite.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+
+import static org.openrewrite.xml.Assertions.xml;
+
+public class PmdXml6to7MigrationTest
+ implements RewriteTest {
+
+ @Override
+ public void defaults(RecipeSpec spec) {
+ spec
+ .parser(JavaParser.fromJavaVersion()
+ .classpathFromResources(new InMemoryExecutionContext()))
+ .recipe(new PmdXml6to7Migration());
+ }
+
+ @DocumentExample
+ @Test
+ void shouldMigrateXml6toXml7() {
+ //language=java
+ rewriteRun(
+ xml(
+"""
+
+
+
+ This is a description
+
+
+
+""",
+ """
+
+
+
+ This is a description
+
+
+
+ """
+ ));
+ }
+
+ @Test
+ void noChanges_on_empty_ruleset() {
+ //language=java
+ rewriteRun(
+ xml(
+ """
+
+
+ This is a description
+
+ """
+ ));
+ }
+
+ @Test
+ void preserve_custom_rule() {
+ //language=java
+ rewriteRun(
+ xml(
+ """
+
+
+ This is a description
+
+
+ """
+ ));
+ }
+
+ @Test
+ void replacementMovesToAnotherCategory() {
+ rewriteRun(
+ xml(
+ """
+
+
+
+
+
+ """,
+ """
+
+
+
+
+
+ """
+ ));
+ }
+
+ @Test
+ void dropRuleRemovedWithoutReplacement() {
+ rewriteRun(
+ xml(
+ """
+
+
+
+
+
+ """,
+ """
+
+
+
+
+ """
+ ));
+ }
+
+ @Test
+ void severalRemovedRulesShareOneReplacement() {
+ rewriteRun(
+ xml(
+ """
+
+
+
+
+
+ """,
+ """
+
+
+
+
+ """
+ ));
+ }
+
+ @Test
+ void doNotAddReplacementTheRulesetAlreadySelects() {
+ rewriteRun(
+ xml(
+ """
+
+
+
+
+
+ """,
+ """
+
+
+
+
+ """
+ ));
+ }
+
+ @Test
+ void retainNestedPropertiesOnReplacedRule() {
+ rewriteRun(
+ xml(
+ """
+
+
+
+
+
+
+
+
+ """,
+ """
+
+
+
+
+
+
+
+
+ """
+ ));
+ }
+
+ @Test
+ void leaveRulesPmd7SplitsAcrossSeveralSuccessors() {
+ rewriteRun(
+ xml(
+ """
+
+
+
+
+
+ """
+ ));
+ }
+
+ @Test
+ void leaveRulesetTagThatIsNotTheDocumentRoot() {
+ rewriteRun(
+ xml(
+ """
+
+
+
+
+
+
+ """
+ ));
+ }
+
+ @Test
+ void rulesStillValidInPmd7AreLeftAlone() {
+ rewriteRun(
+ xml(
+ """
+
+
+
+
+
+ """
+ ));
+ }
+}