diff --git a/src/main/java/org/openrewrite/java/migrate/jakarta/HasNoJakartaAnnotations.java b/src/main/java/org/openrewrite/java/migrate/jakarta/HasNoJakartaAnnotations.java
new file mode 100644
index 0000000000..528200eaa9
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/jakarta/HasNoJakartaAnnotations.java
@@ -0,0 +1,83 @@
+/*
+ * 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.jakarta;
+
+import lombok.Value;
+import org.openrewrite.ExecutionContext;
+import org.openrewrite.ScanningRecipe;
+import org.openrewrite.Tree;
+import org.openrewrite.TreeVisitor;
+import org.openrewrite.java.marker.JavaProject;
+import org.openrewrite.java.search.FindAnnotations;
+import org.openrewrite.java.tree.J;
+import org.openrewrite.marker.SearchResult;
+
+import java.util.HashSet;
+import java.util.Set;
+
+public class HasNoJakartaAnnotations extends ScanningRecipe {
+ @Override
+ public String getDisplayName() {
+ return "Project has no Jakarta annotations";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Mark all source as found per `JavaProject` where no Jakarta annotations are found. " +
+ "This is useful mostly as a precondition for recipes that require Jakarta annotations to be present";
+ }
+
+ @Value
+ public static class Accumulator {
+ Set projectsWithDependency;
+ }
+
+ @Override
+ public Accumulator getInitialValue(ExecutionContext ctx) {
+ return new Accumulator(new HashSet<>());
+ }
+
+ @Override
+ public TreeVisitor, ExecutionContext> getScanner(HasNoJakartaAnnotations.Accumulator acc) {
+ return new TreeVisitor() {
+ @Override
+ public Tree preVisit(Tree tree, ExecutionContext ctx) {
+ stopAfterPreVisit();
+ if (tree instanceof J) {
+ tree.getMarkers().findFirst(JavaProject.class)
+ .filter(jp -> !acc.getProjectsWithDependency().contains(jp))
+ .filter(jp -> !FindAnnotations.find((J) tree, "@jakarta.annotation.*", true).isEmpty())
+ .ifPresent(jp -> acc.getProjectsWithDependency().add(jp));
+ }
+ return tree;
+ }
+ };
+ }
+
+ @Override
+ public TreeVisitor, ExecutionContext> getVisitor(HasNoJakartaAnnotations.Accumulator acc) {
+ return new TreeVisitor() {
+ @Override
+ public Tree preVisit(Tree tree, ExecutionContext ctx) {
+ stopAfterPreVisit();
+ return tree.getMarkers().findFirst(JavaProject.class)
+ .filter(it -> !acc.getProjectsWithDependency().contains(it))
+ .map(__ -> SearchResult.found(tree, "Project has no Jakarta annotations"))
+ .orElse(tree);
+ }
+ };
+ }
+}
diff --git a/src/main/resources/META-INF/rewrite/jakarta-ee-9.yml b/src/main/resources/META-INF/rewrite/jakarta-ee-9.yml
index f9783487b7..69d3657524 100644
--- a/src/main/resources/META-INF/rewrite/jakarta-ee-9.yml
+++ b/src/main/resources/META-INF/rewrite/jakarta-ee-9.yml
@@ -1083,6 +1083,7 @@ name: org.openrewrite.java.migrate.jakarta.RemoveJakartaAnnotationDependency
displayName: Remove `jakarta.annotation-api` dependency when managed by Spring Boot
description: Counteract the `jakarta.annotation-api` added by `org.openrewrite.java.migrate.javax.AddCommonAnnotationsDependencies` for Spring Boot applications.
preconditions:
+ - org.openrewrite.java.migrate.jakarta.HasNoJakartaAnnotations
- org.openrewrite.java.dependencies.DependencyInsight:
groupIdPattern: org.springframework.boot
artifactIdPattern: spring-boot-starter
diff --git a/src/test/java/org/openrewrite/java/migrate/jakarta/JavaxToJakartaTest.java b/src/test/java/org/openrewrite/java/migrate/jakarta/JavaxToJakartaTest.java
index 94b2f2fe17..f57b4f7f24 100644
--- a/src/test/java/org/openrewrite/java/migrate/jakarta/JavaxToJakartaTest.java
+++ b/src/test/java/org/openrewrite/java/migrate/jakarta/JavaxToJakartaTest.java
@@ -24,6 +24,9 @@
import org.openrewrite.test.RecipeSpec;
import org.openrewrite.test.RewriteTest;
+import static org.openrewrite.gradle.Assertions.buildGradle;
+import static org.openrewrite.gradle.Assertions.settingsGradle;
+import static org.openrewrite.gradle.toolingapi.Assertions.withToolingApi;
import static org.openrewrite.java.Assertions.*;
import static org.openrewrite.maven.Assertions.pomXml;
import static org.openrewrite.xml.Assertions.xml;
@@ -60,6 +63,16 @@ public void foo() {}
}
""";
+ @Language("java")
+ private static final String jakartaAnnotation =
+ """
+ package jakarta.annotation;
+ public @interface Nonnull {
+ }
+ public @interface Nullable {
+ }
+ """;
+
@Override
public void defaults(RecipeSpec spec) {
spec.recipe(
@@ -442,9 +455,7 @@ void projectWithSpringBootStarterWeb() {
//language=xml
pomXml(
"""
-
-
+
4.0.0
org.springframework.boot
@@ -511,9 +522,7 @@ void projectWithSpringBoot3StarterWebShouldRemoveJakartaDependency() {
//language=xml
pomXml(
"""
-
-
+
4.0.0
org.springframework.boot
@@ -538,9 +547,7 @@ void projectWithSpringBoot3StarterWebShouldRemoveJakartaDependency() {
""",
"""
-
-
+
4.0.0
org.springframework.boot
@@ -574,6 +581,242 @@ public class TestApplication {
);
}
+ @Test
+ void projectWithSpringBoot3StarterWebShouldNotRemoveJakartaDependencyWhenUsingNonnullAnnotation() {
+ rewriteRun(
+ spec -> spec.parser(JavaParser.fromJavaVersion().dependsOn(javaxServlet, jakartaAnnotation)),
+ mavenProject(
+ "Sample",
+ //language=xml
+ pomXml(
+ """
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.5
+
+
+ com.example
+ demo
+ 0.0.1-SNAPSHOT
+
+
+ jakarta.annotation
+ jakarta.annotation-api
+ 1.3.5
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ """,
+ """
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.5
+
+
+ com.example
+ demo
+ 0.0.1-SNAPSHOT
+
+
+ jakarta.annotation
+ jakarta.annotation-api
+ 2.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ """
+ ),
+ srcMainJava(
+ //language=java
+ java(
+ """
+ import jakarta.annotation.Nonnull;
+
+ public class TestApplication {
+ @Nonnull
+ public String upperCase(@Nonnull String input) {
+ return input.toUpperCase();
+ }
+ }
+ """
+ )
+ )
+ )
+ );
+ }
+
+ @Test
+ void projectWithSpringBoot3StarterWebShouldNotRemoveJakartaDependencyWhenUsingNullableAnnotation() {
+ rewriteRun(
+ spec -> spec.parser(JavaParser.fromJavaVersion().dependsOn(javaxServlet, jakartaAnnotation)),
+ mavenProject(
+ "Sample",
+ //language=xml
+ pomXml(
+ """
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.5
+
+
+ com.example
+ demo
+ 0.0.1-SNAPSHOT
+
+
+ jakarta.annotation
+ jakarta.annotation-api
+ 1.3.5
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ """,
+ """
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.5
+
+
+ com.example
+ demo
+ 0.0.1-SNAPSHOT
+
+
+ jakarta.annotation
+ jakarta.annotation-api
+ 2.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ """
+ ),
+ srcMainJava(
+ //language=java
+ java(
+ """
+ import jakarta.annotation.Nullable;
+
+ public class TestApplication {
+ @Nullable
+ public String safeUpperCase(@Nullable String input) {
+ return input == null ? null : input.toUpperCase();
+ }
+ }
+ """
+ )
+ )
+ )
+ );
+ }
+
+ @Test
+ void multiProjectWithSpringBoot3StarterWebShouldRemoveJakartaDependencyWhenUsingNullableAnnotationWhenApplicable() {
+ rewriteRun(
+ spec -> spec.beforeRecipe(withToolingApi()).parser(JavaParser.fromJavaVersion().dependsOn(javaxServlet, jakartaAnnotation)),
+ mavenProject("multi-project-build",
+ //language=groovy
+ settingsGradle("""
+ include 'project-with-null-annotations'
+ include 'project-without-null-annotations'
+ """),
+ mavenProject("project-with-null-annotations",
+ //language=groovy
+ buildGradle(
+ """
+ plugins {
+ id 'java'
+ }
+
+ repositories {
+ mavenCentral()
+ }
+
+ dependencies {
+ implementation 'jakarta.annotation:jakarta.annotation-api:1.3.5'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ }
+ """,
+ """
+ plugins {
+ id 'java'
+ }
+
+ repositories {
+ mavenCentral()
+ }
+
+ dependencies {
+ implementation 'jakarta.annotation:jakarta.annotation-api:2.0.0'
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ }
+ """
+ ),
+ srcMainJava(
+ //language=java
+ java(
+ """
+ import jakarta.annotation.Nullable;
+
+ public class TestApplication {
+ @Nullable
+ public String safeUpperCase(@Nullable String input) {
+ return input == null ? null : input.toUpperCase();
+ }
+ }
+ """
+ )
+ )
+ ),
+ mavenProject("project-without-null-annotations",
+ //language=groovy
+ buildGradle(
+ """
+ plugins {
+ id 'java'
+ }
+
+ repositories {
+ mavenCentral()
+ }
+
+ dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ }
+ """
+ )
+ )
+ )
+ );
+ }
+
@Test
void upgradeAnnotationApiFromV1ToV2() {
rewriteRun(