diff --git a/src/main/java/org/openrewrite/java/migrate/maven/AddAnnotationProcessorToEffectivePom.java b/src/main/java/org/openrewrite/java/migrate/maven/AddAnnotationProcessorToEffectivePom.java
new file mode 100644
index 0000000000..c2bedfe32e
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/maven/AddAnnotationProcessorToEffectivePom.java
@@ -0,0 +1,287 @@
+/*
+ * Copyright 2025 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.migrate.maven;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+import org.jspecify.annotations.Nullable;
+import org.openrewrite.*;
+import org.openrewrite.internal.ListUtils;
+import org.openrewrite.maven.AddPluginVisitor;
+import org.openrewrite.maven.ChangePropertyValue;
+import org.openrewrite.maven.MavenIsoVisitor;
+import org.openrewrite.maven.trait.MavenPlugin;
+import org.openrewrite.maven.tree.MavenResolutionResult;
+import org.openrewrite.maven.tree.Plugin;
+import org.openrewrite.maven.tree.Pom;
+import org.openrewrite.maven.tree.ResolvedPom;
+import org.openrewrite.semver.Semver;
+import org.openrewrite.semver.VersionComparator;
+import org.openrewrite.xml.XmlIsoVisitor;
+import org.openrewrite.xml.tree.Xml;
+
+import java.nio.file.Path;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Adds an annotation processor to the maven-compiler-plugin configuration, but only if
+ * the processor is not already present in the effective POM (including configurations
+ * inherited from parent POMs outside the reactor).
+ *
+ * The behavior differs based on project structure:
+ *
+ * - Single module: Adds to build/plugins
+ * - Multi-module with parent in reactor: Adds to parent's build/pluginManagement/plugins
+ * - Orphan module (no parent in reactor): Adds to build/plugins
+ *
+ */
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class AddAnnotationProcessorToEffectivePom extends ScanningRecipe {
+ private static final String MAVEN_COMPILER_PLUGIN_GROUP_ID = "org.apache.maven.plugins";
+ private static final String MAVEN_COMPILER_PLUGIN_ARTIFACT_ID = "maven-compiler-plugin";
+
+ @Option(displayName = "Group",
+ description = "The first part of the coordinate 'org.projectlombok:lombok-mapstruct-binding:0.2.0' of the processor to add.",
+ example = "org.projectlombok")
+ String groupId;
+
+ @Option(displayName = "Artifact",
+ description = "The second part of a coordinate 'org.projectlombok:lombok-mapstruct-binding:0.2.0' of the processor to add.",
+ example = "lombok-mapstruct-binding")
+ String artifactId;
+
+ @Option(displayName = "Version",
+ description = "The third part of a coordinate 'org.projectlombok:lombok-mapstruct-binding:0.2.0' of the processor to add. " +
+ "Note that an exact version is expected",
+ example = "0.2.0")
+ String version;
+
+ String displayName = "Add an annotation processor to `maven-compiler-plugin` if not in effective POM";
+
+ String description = "Add an annotation processor path to the `maven-compiler-plugin` configuration, " +
+ "skipping POMs where the annotation processor is already present in the effective POM " +
+ "(including configurations inherited from parent POMs outside the reactor). " +
+ "For modules with an in-reactor parent, adds to the parent's `build/pluginManagement/plugins` section. " +
+ "For modules without a parent or with a parent outside the reactor, adds directly to `build/plugins`.";
+
+ public static class Scanned {
+ Set parentPomPaths = new HashSet<>();
+ Set candidateOrphanPaths = new HashSet<>();
+ Set aggregatorPaths = new HashSet<>();
+
+ /**
+ * Paths of POMs where the annotation processor is already present in the effective POM
+ * (via a parent POM outside the reactor), but not in the POM's own XML.
+ * These POMs should be skipped to avoid redundant configuration.
+ */
+ Set alreadyConfiguredInEffectivePomPaths = new HashSet<>();
+
+ Set getOrphanPomPaths() {
+ Set result = new HashSet<>(candidateOrphanPaths);
+ for (Path aggregatorPath : aggregatorPaths) {
+ if (!parentPomPaths.contains(aggregatorPath)) {
+ result.remove(aggregatorPath);
+ }
+ }
+ return result;
+ }
+ }
+
+ @Override
+ public Scanned getInitialValue(ExecutionContext ctx) {
+ return new Scanned();
+ }
+
+ @Override
+ public TreeVisitor, ExecutionContext> getScanner(Scanned acc) {
+ return new MavenIsoVisitor() {
+ @Override
+ public Xml.Document visitDocument(Xml.Document document, ExecutionContext ctx) {
+ MavenResolutionResult mrr = getResolutionResult();
+ ResolvedPom resolvedPom = mrr.getPom();
+ Path sourcePath = resolvedPom.getRequested().getSourcePath();
+
+ // Check if the annotation processor is already in the effective POM (merged from parent POMs)
+ // but NOT in the current POM's own XML. In that case, skip this POM to avoid adding
+ // redundant configuration that duplicates what the parent already provides.
+ boolean inEffectivePom = hasAnnotationProcessor(resolvedPom.getPlugins()) ||
+ hasAnnotationProcessor(resolvedPom.getPluginManagement());
+ Pom requestedPom = resolvedPom.getRequested();
+ boolean inCurrentPomXml = hasAnnotationProcessor(requestedPom.getPlugins()) ||
+ hasAnnotationProcessor(requestedPom.getPluginManagement());
+ if (sourcePath != null && inEffectivePom && !inCurrentPomXml) {
+ acc.alreadyConfiguredInEffectivePomPaths.add(sourcePath);
+ }
+
+ if (mrr.parentPomIsProjectPom()) {
+ MavenResolutionResult parent = mrr.getParent();
+ if (parent != null) {
+ Path parentPath = parent.getPom().getRequested().getSourcePath();
+ if (parentPath != null) {
+ acc.parentPomPaths.add(parentPath);
+ }
+ }
+ } else {
+ if (sourcePath != null) {
+ acc.candidateOrphanPaths.add(sourcePath);
+ }
+ }
+
+ List subprojects = resolvedPom.getSubprojects();
+ if (sourcePath != null && subprojects != null && !subprojects.isEmpty()) {
+ acc.aggregatorPaths.add(sourcePath);
+ }
+
+ return document;
+ }
+ };
+ }
+
+ @Override
+ public TreeVisitor, ExecutionContext> getVisitor(Scanned acc) {
+ return new TreeVisitor() {
+ @Override
+ public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) {
+ if (tree == null) {
+ return null;
+ }
+
+ MavenResolutionResult mrr = tree.getMarkers().findFirst(MavenResolutionResult.class).orElse(null);
+ if (mrr == null) {
+ return tree;
+ }
+
+ Path sourcePath = mrr.getPom().getRequested().getSourcePath();
+ if (sourcePath == null) {
+ return tree;
+ }
+
+ boolean isParent = acc.parentPomPaths.contains(sourcePath);
+ if (!isParent && !acc.getOrphanPomPaths().contains(sourcePath)) {
+ return tree;
+ }
+
+ // Skip POMs where the annotation processor is already configured via a parent POM
+ if (acc.alreadyConfiguredInEffectivePomPaths.contains(sourcePath)) {
+ return tree;
+ }
+
+ tree = new AddPluginVisitor(isParent,
+ MAVEN_COMPILER_PLUGIN_GROUP_ID, MAVEN_COMPILER_PLUGIN_ARTIFACT_ID, null,
+ "", null, null, null
+ ).visit(tree, ctx);
+
+ return new MavenIsoVisitor() {
+ @Override
+ public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) {
+ Xml.Tag plugins = super.visitTag(tag, ctx);
+ plugins = (Xml.Tag) new MavenPlugin.Matcher(isParent, MAVEN_COMPILER_PLUGIN_GROUP_ID, MAVEN_COMPILER_PLUGIN_ARTIFACT_ID).asVisitor(plugin -> {
+ MavenResolutionResult currentMrr = getResolutionResult();
+ AtomicReference> maybePropertyUpdate = new AtomicReference<>();
+
+ Xml.Tag modifiedPlugin = new XmlIsoVisitor() {
+ @Override
+ public Xml.Tag visitTag(Xml.Tag tag, ExecutionContext ctx) {
+ Xml.Tag tg = super.visitTag(tag, ctx);
+
+ if (!"annotationProcessorPaths".equals(tg.getName())) {
+ return tg;
+ }
+
+ for (int i = 0; i < tg.getChildren().size(); i++) {
+ Xml.Tag child = tg.getChildren().get(i);
+ if (!groupId.equals(child.getChildValue("groupId").orElse(null)) ||
+ !artifactId.equals(child.getChildValue("artifactId").orElse(null))) {
+ continue;
+ }
+
+ if (!version.equals(child.getChildValue("version").orElse(null))) {
+ String oldVersion = child.getChildValue("version").orElse("");
+ boolean oldVersionUsesProperty = oldVersion.startsWith("${");
+ String lookupVersion = oldVersionUsesProperty ?
+ currentMrr.getPom().getValue(oldVersion.trim()) : oldVersion;
+ VersionComparator comparator = Semver.validate(lookupVersion, null).getValue();
+ if (comparator.compare(version, lookupVersion) > 0) {
+ if (oldVersionUsesProperty) {
+ maybePropertyUpdate.set(new ChangePropertyValue(oldVersion, version, null, null).getVisitor());
+ } else {
+ List tags = tg.getChildren();
+ tags.set(i, child.withChildValue("version", version));
+ return tg.withContent(tags);
+ }
+ }
+ }
+
+ return tg;
+ }
+
+ return tg.withContent(ListUtils.concat(tg.getChildren(), Xml.Tag.build(String.format(
+ "\n%s\n%s\n%s\n",
+ groupId, artifactId, version))));
+ }
+ }.visitTag(plugin.getTree(), ctx);
+
+ if (maybePropertyUpdate.get() != null) {
+ doAfterVisit(maybePropertyUpdate.get());
+ }
+
+ return modifiedPlugin;
+ }).visitNonNull(plugins, 0);
+
+ if (plugins != tag) {
+ plugins = autoFormat(plugins, ctx);
+ }
+ return plugins;
+ }
+ }.visit(tree, ctx);
+ }
+ };
+ }
+
+ private boolean hasAnnotationProcessor(List plugins) {
+ for (Plugin plugin : plugins) {
+ if (!MAVEN_COMPILER_PLUGIN_GROUP_ID.equals(plugin.getGroupId()) ||
+ !MAVEN_COMPILER_PLUGIN_ARTIFACT_ID.equals(plugin.getArtifactId())) {
+ continue;
+ }
+ JsonNode config = plugin.getConfiguration();
+ if (config == null || config.isMissingNode()) {
+ continue;
+ }
+ JsonNode paths = config.path("annotationProcessorPaths").path("path");
+ if (paths.isArray()) {
+ for (JsonNode path : paths) {
+ if (isMatchingProcessor(path)) {
+ return true;
+ }
+ }
+ } else if (!paths.isMissingNode() && isMatchingProcessor(paths)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean isMatchingProcessor(JsonNode path) {
+ return groupId.equals(path.path("groupId").asText("")) &&
+ artifactId.equals(path.path("artifactId").asText(""));
+ }
+}
diff --git a/src/main/resources/META-INF/rewrite/java-version-25.yml b/src/main/resources/META-INF/rewrite/java-version-25.yml
index c0cb0d3925..6d3c8bdca1 100644
--- a/src/main/resources/META-INF/rewrite/java-version-25.yml
+++ b/src/main/resources/META-INF/rewrite/java-version-25.yml
@@ -177,7 +177,7 @@ preconditions:
artifactIdPattern: lombok
- org.openrewrite.Singleton
recipeList:
- - org.openrewrite.maven.AddAnnotationProcessor:
+ - org.openrewrite.java.migrate.maven.AddAnnotationProcessorToEffectivePom:
groupId: org.projectlombok
artifactId: lombok
version: 1.18.40
diff --git a/src/test/java/org/openrewrite/java/migrate/UpgradeToJava25Test.java b/src/test/java/org/openrewrite/java/migrate/UpgradeToJava25Test.java
index b5a009e56b..099be36d8d 100644
--- a/src/test/java/org/openrewrite/java/migrate/UpgradeToJava25Test.java
+++ b/src/test/java/org/openrewrite/java/migrate/UpgradeToJava25Test.java
@@ -139,4 +139,78 @@ void addsLombokAnnotationProcessor() {
)
);
}
+
+ @Test
+ void doesNotDuplicateLombokAnnotationProcessorWhenAlreadyInParentPluginManagement() {
+ rewriteRun(
+ spec -> spec.cycles(1).expectedCyclesThatMakeChanges(1),
+ //language=xml
+ pomXml(
+ """
+
+ com.mycompany.app
+ parent
+ 1
+ pom
+
+ child
+
+
+ 17
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.40
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.40
+
+
+
+
+
+
+
+
+ """,
+ spec -> spec.after(actual ->
+ assertThat(actual)
+ .contains("25")
+ // annotation processor path for lombok should appear exactly once, not duplicated
+ .satisfies(content -> assertThat(content.split("", -1).length - 1)
+ .as("annotationProcessorPaths should have exactly one entry")
+ .isEqualTo(1))
+ .actual()
+ )
+ ),
+ mavenProject("child",
+ //language=xml
+ pomXml(
+ """
+
+
+ com.mycompany.app
+ parent
+ 1
+
+ child
+
+ """
+ )
+ )
+ );
+ }
}
diff --git a/src/test/java/org/openrewrite/java/migrate/maven/AddAnnotationProcessorToEffectivePomTest.java b/src/test/java/org/openrewrite/java/migrate/maven/AddAnnotationProcessorToEffectivePomTest.java
new file mode 100644
index 0000000000..422db4e540
--- /dev/null
+++ b/src/test/java/org/openrewrite/java/migrate/maven/AddAnnotationProcessorToEffectivePomTest.java
@@ -0,0 +1,275 @@
+/*
+ * Copyright 2025 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.migrate.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.java.Assertions.mavenProject;
+import static org.openrewrite.maven.Assertions.pomXml;
+
+class AddAnnotationProcessorToEffectivePomTest implements RewriteTest {
+
+ @Override
+ public void defaults(RecipeSpec spec) {
+ spec.recipe(new AddAnnotationProcessorToEffectivePom(
+ "org.projectlombok",
+ "lombok",
+ "1.18.40"
+ ));
+ }
+
+ @DocumentExample
+ @Test
+ void addToSingleModule() {
+ rewriteRun(
+ pomXml(
+ //language=xml
+ """
+
+ com.example
+ my-app
+ 1
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+
+
+
+
+ """,
+ //language=xml
+ """
+
+ com.example
+ my-app
+ 1
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.40
+
+
+
+
+
+
+
+ """
+ )
+ );
+ }
+
+ @Test
+ void doesNotDuplicateWhenAlreadyInOwnXml() {
+ rewriteRun(
+ pomXml(
+ //language=xml
+ """
+
+ com.example
+ my-app
+ 1
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.40
+
+
+
+
+
+
+
+ """
+ )
+ );
+ }
+
+ @Test
+ void addToParentPluginManagementInMultiModule() {
+ rewriteRun(
+ //language=xml
+ pomXml(
+ """
+
+ com.example
+ parent
+ 1
+ pom
+
+ child
+
+
+ """,
+ //language=xml
+ """
+
+ com.example
+ parent
+ 1
+ pom
+
+ child
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.40
+
+
+
+
+
+
+
+
+ """
+ ),
+ mavenProject("child",
+ //language=xml
+ pomXml(
+ """
+
+
+ com.example
+ parent
+ 1
+
+ child
+
+ """
+ )
+ )
+ );
+ }
+
+ /**
+ * Tests that the recipe does not add an annotation processor that is already present
+ * in the effective POM via an ancestor, even when the intermediate parent POM does not
+ * have it in its own XML. In this 3-level hierarchy:
+ *
+ * - Grandparent defines lombok in {@code pluginManagement}
+ * - Intermediate parent inherits it via effective POM but has no own configuration
+ * - Child depends on the intermediate parent
+ *
+ * The intermediate parent should NOT have lombok added again.
+ */
+ @Test
+ void doesNotDuplicateWhenAlreadyInEffectivePomViaAncestor() {
+ rewriteRun(
+ //language=xml
+ pomXml(
+ // Grandparent already has lombok configured in pluginManagement
+ """
+
+ com.example
+ grandparent
+ 1
+ pom
+
+ parent-module
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.40
+
+
+
+
+
+
+
+
+ """
+ ),
+ mavenProject("parent-module",
+ //language=xml
+ pomXml(
+ // Intermediate parent: inherits lombok from grandparent via effective POM,
+ // but does NOT have it in its own XML
+ """
+
+
+ com.example
+ grandparent
+ 1
+
+ parent-module
+ pom
+
+ child-module
+
+
+ """
+ )
+ ),
+ mavenProject("child-module",
+ //language=xml
+ pomXml(
+ """
+
+
+ com.example
+ parent-module
+ 1
+
+ child-module
+
+ """
+ )
+ )
+ );
+ }
+}