diff --git a/build.gradle.kts b/build.gradle.kts
index fb0e89dd79..76db8c0a79 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -97,6 +97,7 @@ dependencies {
testRuntimeOnly("org.apache.groovy:groovy:4.+")
testRuntimeOnly("org.jboss.logging:jboss-logging:3.6.0.Final")
testRuntimeOnly("jakarta.annotation:jakarta.annotation-api:2.1.1")
+ testRuntimeOnly("io.micrometer:micrometer-core:1.15.1")
testRuntimeOnly("org.springframework:spring-core:6.1.13")
testRuntimeOnly("io.micronaut:micronaut-core:4.10.8")
testRuntimeOnly("com.google.code.findbugs:jsr305:3.0.2")
diff --git a/src/main/resources/META-INF/rewrite/jspecify.yml b/src/main/resources/META-INF/rewrite/jspecify.yml
index 8a8d807ec3..76cd78d7ba 100644
--- a/src/main/resources/META-INF/rewrite/jspecify.yml
+++ b/src/main/resources/META-INF/rewrite/jspecify.yml
@@ -25,6 +25,16 @@ preconditions:
- org.openrewrite.Singleton
recipeList:
- org.openrewrite.java.jspecify.MigrateToJSpecify
+# The static analysis recipes below introduce `org.jspecify.annotations.*`, and the Spring
+# migration leaf is deliberately disabled in MigrateToJSpecify, so Spring projects still need
+# the JSpecify dependency.
+- org.openrewrite.java.dependencies.AddDependency:
+ groupId: org.jspecify
+ artifactId: jspecify
+ version: 1.0.0
+ # *ull* matches Nullable, NonNull, NonNullApi and NonNullFields
+ onlyIfUsing: org.springframework.lang.*ull*
+ acceptTransitive: true
- org.openrewrite.staticanalysis.AnnotateNullableMethods
- org.openrewrite.staticanalysis.AnnotateNullableParameters
- org.openrewrite.staticanalysis.AnnotateRequiredParameters
@@ -149,7 +159,8 @@ recipeList:
groupId: org.jspecify
artifactId: jspecify
version: 1.0.0
- onlyIfUsing: org.springframework.lang.*ull*
+ # *ull* matches Nullable, NonNull, NonNullApi and NonNullFields
+ onlyIfUsing: io.micrometer.core.lang.*ull*
acceptTransitive: true
- org.openrewrite.java.migrate.jspecify.MoveAnnotationToArrayType:
annotationType: io.micrometer.core.lang.*ull*
diff --git a/src/test/java/org/openrewrite/java/migrate/jspecify/JSpecifyBestPracticesTest.java b/src/test/java/org/openrewrite/java/migrate/jspecify/JSpecifyBestPracticesTest.java
index 207f2bdd7d..3032623c9a 100644
--- a/src/test/java/org/openrewrite/java/migrate/jspecify/JSpecifyBestPracticesTest.java
+++ b/src/test/java/org/openrewrite/java/migrate/jspecify/JSpecifyBestPracticesTest.java
@@ -23,6 +23,8 @@
import org.openrewrite.test.RewriteTest;
import static org.openrewrite.java.Assertions.*;
+import static org.openrewrite.gradle.Assertions.buildGradle;
+import static org.openrewrite.gradle.toolingapi.Assertions.withToolingApi;
import static org.openrewrite.maven.Assertions.pomXml;
@SuppressWarnings("NotNullFieldNotInitialized")
@@ -38,6 +40,7 @@ public void defaults(RecipeSpec spec) {
"jakarta.annotation-api",
"annotations",
"spring-core",
+ "micrometer-core",
"micronaut-core"));
}
@@ -501,6 +504,372 @@ class Bar {
);
}
+ @Test
+ void migrateFromMicrometerAnnotationsToJspecify() {
+ rewriteRun(
+ mavenProject("foo",
+ //language=java
+ srcMainJava(
+ java(
+ """
+ import io.micrometer.core.lang.NonNull;
+ import io.micrometer.core.lang.Nullable;
+
+ public class Test {
+ @NonNull
+ public String field1;
+ @Nullable
+ public String field2;
+ @Nullable
+ public Foo.Bar foobar;
+ }
+
+ interface Foo {
+ class Bar {
+ @NonNull
+ public String barField;
+ }
+ }
+ """,
+ """
+ import org.jspecify.annotations.NonNull;
+ import org.jspecify.annotations.Nullable;
+
+ public class Test {
+ @NonNull
+ public String field1;
+ @Nullable
+ public String field2;
+ public Foo.@Nullable Bar foobar;
+ }
+
+ interface Foo {
+ class Bar {
+ @NonNull
+ public String barField;
+ }
+ }
+ """
+ )
+ ),
+ //language=xml
+ pomXml(
+ """
+
+ 4.0.0
+ com.example.foobar
+ foobar-core
+ 1.0.0
+
+
+ io.micrometer
+ micrometer-core
+ 1.15.1
+
+
+
+ """,
+ """
+
+ 4.0.0
+ com.example.foobar
+ foobar-core
+ 1.0.0
+
+
+ io.micrometer
+ micrometer-core
+ 1.15.1
+
+
+ org.jspecify
+ jspecify
+ 1.0.0
+
+
+
+ """
+ )
+ )
+ );
+ }
+
+ @Test
+ void migrateFromMicrometerAnnotationsWithExistingJspecifyDependency() {
+ rewriteRun(
+ mavenProject("foo",
+ //language=java
+ srcMainJava(
+ java(
+ """
+ import io.micrometer.core.lang.Nullable;
+
+ public class Test {
+ @Nullable
+ public String field1;
+ }
+ """,
+ """
+ import org.jspecify.annotations.Nullable;
+
+ public class Test {
+ @Nullable
+ public String field1;
+ }
+ """
+ )
+ ),
+ //language=xml
+ pomXml(
+ """
+
+ 4.0.0
+ com.example.foobar
+ foobar-core
+ 1.0.0
+
+
+ io.micrometer
+ micrometer-core
+ 1.15.1
+
+
+ org.jspecify
+ jspecify
+ 1.0.0
+
+
+
+ """
+ )
+ )
+ );
+ }
+
+ @Test
+ void micrometerProjectBuiltWithGradleAlsoGetsTheJspecifyDependency() {
+ rewriteRun(
+ spec -> spec.beforeRecipe(withToolingApi()),
+ mavenProject("foo",
+ //language=java
+ srcMainJava(
+ java(
+ """
+ import io.micrometer.core.lang.Nullable;
+
+ public class Test {
+ @Nullable
+ public String field;
+ }
+ """,
+ """
+ import org.jspecify.annotations.Nullable;
+
+ public class Test {
+ @Nullable
+ public String field;
+ }
+ """
+ )
+ ),
+ //language=groovy
+ buildGradle(
+ """
+ plugins {
+ id "java-library"
+ }
+
+ repositories {
+ mavenCentral()
+ }
+
+ dependencies {
+ implementation "io.micrometer:micrometer-core:1.15.1"
+ }
+ """,
+ """
+ plugins {
+ id "java-library"
+ }
+
+ repositories {
+ mavenCentral()
+ }
+
+ dependencies {
+ implementation "io.micrometer:micrometer-core:1.15.1"
+ implementation "org.jspecify:jspecify:1.0.0"
+ }
+ """
+ )
+ )
+ );
+ }
+
+ @Test
+ void micrometerRecipeDoesNotActivateOnSpringAnnotations() {
+ rewriteRun(
+ spec -> spec.recipeFromResource(
+ "/META-INF/rewrite/jspecify.yml",
+ "org.openrewrite.java.jspecify.MigrateFromMicrometerAnnotations"),
+ mavenProject("foo",
+ //language=java
+ srcMainJava(
+ java(
+ """
+ import org.springframework.lang.Nullable;
+
+ public class Test {
+ @Nullable
+ public String field1;
+ }
+ """
+ )
+ ),
+ //language=xml
+ pomXml(
+ """
+
+ 4.0.0
+ com.example.foobar
+ foobar-core
+ 1.0.0
+
+
+ org.springframework
+ spring-core
+ 6.1.13
+
+
+
+ """
+ )
+ )
+ );
+ }
+
+ @Test
+ void micrometerRecipeDoesNotActivateWithoutNullnessAnnotations() {
+ rewriteRun(
+ spec -> spec.recipeFromResource(
+ "/META-INF/rewrite/jspecify.yml",
+ "org.openrewrite.java.jspecify.MigrateFromMicrometerAnnotations"),
+ mavenProject("foo",
+ //language=java
+ srcMainJava(
+ java(
+ """
+ public class Test {
+ public String field1;
+ }
+ """
+ )
+ ),
+ //language=xml
+ pomXml(
+ """
+
+ 4.0.0
+ com.example.foobar
+ foobar-core
+ 1.0.0
+
+
+ io.micrometer
+ micrometer-core
+ 1.15.1
+
+
+
+ """
+ )
+ )
+ );
+ }
+
+ @Test
+ void addJspecifyDependencyOnSpringOnlyProject() {
+ // MigrateToJSpecify disables the Spring leaf, but JSpecifyBestPractices still introduces
+ // `org.jspecify.annotations.*`, so a Spring-only project still needs the dependency
+ rewriteRun(
+ mavenProject("foo",
+ //language=java
+ srcMainJava(
+ java(
+ """
+ import org.springframework.lang.Nullable;
+
+ public class Test {
+ @Nullable
+ public String field1;
+
+ public String maybe() {
+ if (field1 != null) {
+ return field1;
+ }
+ return null;
+ }
+ }
+ """,
+ """
+ import org.springframework.lang.Nullable;
+
+ public class Test {
+ @Nullable
+ public String field1;
+
+ public @org.jspecify.annotations.Nullable String maybe() {
+ if (field1 != null) {
+ return field1;
+ }
+ return null;
+ }
+ }
+ """
+ )
+ ),
+ //language=xml
+ pomXml(
+ """
+
+ 4.0.0
+ com.example.foobar
+ foobar-core
+ 1.0.0
+
+
+ org.springframework
+ spring-core
+ 6.1.13
+
+
+
+ """,
+ """
+
+ 4.0.0
+ com.example.foobar
+ foobar-core
+ 1.0.0
+
+
+ org.jspecify
+ jspecify
+ 1.0.0
+
+
+ org.springframework
+ spring-core
+ 6.1.13
+
+
+
+ """
+ )
+ )
+ );
+ }
+
@Test
void migrateFromMicronautAnnotationToJspecify() {
rewriteRun(