Skip to content

Add JSpecify for Micrometer projects and keep it for Spring projects - #1192

Open
martinfrancois wants to merge 3 commits into
openrewrite:mainfrom
martinfrancois:fix/micrometer-jspecify-dependency-guard
Open

Add JSpecify for Micrometer projects and keep it for Spring projects#1192
martinfrancois wants to merge 3 commits into
openrewrite:mainfrom
martinfrancois:fix/micrometer-jspecify-dependency-guard

Conversation

@martinfrancois

@martinfrancois martinfrancois commented Aug 10, 2026

Copy link
Copy Markdown

Suggested review order: 3 of 52 (Score: 9)
Review first: #1204

What's changed?

The AddDependency step of MigrateFromMicrometerAnnotations now names the package that this recipe actually migrates:

-      onlyIfUsing: org.springframework.lang.*ull*
+      onlyIfUsing: io.micrometer.core.lang.*ull*

The source rewrite is unchanged: io.micrometer.core.lang.NonNull and Nullable become org.jspecify.annotations.* on main and on this branch alike. The change only corrects the generated build file.

JSpecifyBestPractices also gains its own AddDependency step, guarded on the Spring package, so a Spring project gets the same pom.xml from JSpecifyBestPractices as it gets on main today. That second half is not optional, for the reason given under alternatives.

What's your motivation?

Recipe: org.openrewrite.java.migrate.jakarta.MigrateFromMicrometerToJSpecify.

Before

<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-core</artifactId>
</dependency>

Actual after the recipe

<!-- annotations migrated, but no JSpecify dependency added -->

Expected after the recipe

<dependency>
  <groupId>org.jspecify</groupId>
  <artifactId>jspecify</artifactId>
  <version>1.0.0</version>
</dependency>

This recipe rewrites io.micrometer.core.lang.Nullable and io.micrometer.core.lang.NonNull to org.jspecify.annotations.*, but on main its AddDependency step is guarded on onlyIfUsing: org.springframework.lang.*ull*. That option adds the dependency only to a project whose source uses one of the types the pattern matches, here the org.springframework.lang types whose simple name contains ull. This recipe never matches, rewrites or imports a type from that package, so for a Micrometer project the guard never fires. Every other migration recipe in jspecify.yml is guarded on the package that the same recipe migrates.

The output therefore does not compile: the migrated source imports org.jspecify.annotations, which the migrated project's own build file does not provide, so javac reports error: package org.jspecify.annotations does not exist.

The wrong guard also produces the opposite error, a dependency added to a project that does not need one. On main this recipe adds org.jspecify:jspecify to a Spring-only project, one using the org.springframework.lang nullness annotations and no Micrometer ones, whose sources it never touches. MigrateToJSpecify deliberately leaves MigrateFromSpringFrameworkAnnotations out of its recipe list, so such a project run through MigrateToJSpecify gets the new dependency and not one changed source file. Both MigrateToJSpecify and JSpecifyBestPractices run this recipe, so both are affected. Reproduced on 3.40.0, 3.41.0 and 3.42.0-SNAPSHOT from main at 26f898e.

Affected code in real projects

  • facebook/fbthrift ThriftAbstractTimer.java: Thrift's Java runtime annotates its Micrometer timer with io.micrometer.core.lang.Nullable; the project uses no org.springframework.lang types and no JSpecify, so the recipe from main rewrites the annotations to org.jspecify.annotations.* while its misdirected AddDependency guard never fires, and the migrated source references a package the build does not provide.
  • rsocket/rsocket-java CompositeMetadataUtils.java: the rsocket-micrometer module marks return values with io.micrometer.core.lang.Nullable and the project uses neither org.springframework.lang nor JSpecify, so the recipe from main migrates the annotation but adds no org.jspecify:jspecify dependency, and the module no longer compiles.
  • CorfuDB/CorfuDB InfluxNamingConvention.java: CorfuDB's metrics naming conventions annotate parameters with io.micrometer.core.lang.Nullable; the repository contains no org.springframework.lang usage and no JSpecify dependency, so the recipe from main rewrites the annotations without adding the artifact they now need.

Anything in particular you'd like reviewers to focus on?

No existing test expectation changed. The only deleted line in the whole change is the onlyIfUsing line shown above, and defaults(RecipeSpec) gains "micrometer-core" on the shared parser classpath.

The new step in JSpecifyBestPractices is guarded on the Spring package rather than on JSpecify itself, because AddDependency does not see annotations that other recipes insert during the same run.

Two limits:

  • JSpecifyBestPractices can still write org.jspecify.annotations.* into a project that uses no nullness library at all. No AddDependency guard in jspecify.yml matches such a project, so no JSpecify dependency is added and the annotations just written do not resolve. That is a separate defect and this change does not address it.
  • There is no multi-module test.

Have you considered any alternatives or workarounds?

Change only the onlyIfUsing line and add nothing to JSpecifyBestPractices. That is a one-line diff, and a regression for Spring users. JSpecifyBestPractices runs AnnotateNullableMethods, AnnotateNullableParameters, AnnotateRequiredParameters and NullableOnMethodReturnType after MigrateToJSpecify, and those four write org.jspecify.annotations.* into a project whatever nullness library it uses, Spring projects included. On main the wrongly guarded AddDependency is the only step in JSpecifyBestPractices that adds the JSpecify dependency to a Spring project, so correcting the guard and putting nothing in its place would leave Spring projects with those annotations in their source and without the dependency they need.

Leave micrometer-core off the test classpath. It was not declared in build.gradle.kts, so the tests resolved it transitively through rewrite-core, at 1.9.17, while spring-core and micronaut-core are pinned there with testRuntimeOnly. The version matters, because the io.micrometer.core.lang package was removed in Micrometer 1.16, so a future bump of that transitive version would silently break these tests. This change pins testRuntimeOnly("io.micrometer:micrometer-core:1.15.1") alongside the existing two. 1.15.1 rather than 1.9.17, because it is what the new tests' build file fixtures declare, and 1.15.x is the last minor line that still contains io.micrometer.core.lang.

Any additional context

This change adds 6 tests to JSpecifyBestPracticesTest. Without the code change in this pull request, these 3 new tests fail:

  • migrateFromMicrometerAnnotationsToJspecify: the Micrometer Maven project does not get the JSpecify dependency.
  • micrometerProjectBuiltWithGradleAlsoGetsTheJspecifyDependency: the Micrometer Gradle project does not get the JSpecify dependency.
  • micrometerRecipeDoesNotActivateOnSpringAnnotations: the Micrometer recipe changes a Spring-only project.

The other 3 new tests pass without the code change.

The Gradle test needs spec.beforeRecipe(withToolingApi()), and the sources and the build file have to sit in the same project, wrapped in mavenProject(..). Without that wrapper the onlyIfUsing option never matches, because the Java sources are not attributed to the Gradle project, and AddDependency then silently does nothing.

The other 3 pass either way and pin down behaviour this change keeps as it is: migrateFromMicrometerAnnotationsWithExistingJspecifyDependency, micrometerRecipeDoesNotActivateWithoutNullnessAnnotations and addJspecifyDependencyOnSpringOnlyProject.

This change was prepared with AI assistance (Claude Code). I reviewed the code, the tests and this description.

Checklist

MigrateFromMicrometerAnnotations rewrites io.micrometer.core.lang
nullness annotations to org.jspecify.annotations, but guarded its
AddDependency step on org.springframework.lang.*ull*. A project that
used only Micrometer had its source rewritten and never received
org.jspecify:jspecify, so the output did not compile. Point the guard
at io.micrometer.core.lang.*ull*, the types the recipe migrates.

That guard was also the only path adding JSpecify for Spring
projects. MigrateFromSpringFrameworkAnnotations is deliberately
disabled in MigrateToJSpecify, yet the static analysis recipes in
JSpecifyBestPractices still insert org.jspecify.annotations.* into
Spring sources. JSpecifyBestPractices therefore gets its own
AddDependency step guarded on org.springframework.lang.*ull*. That
guard has to match the pre-migration Spring annotations, because
AddDependency does not see annotations other recipes insert during
the same run.

JSpecifyBestPracticesTest covers the Micrometer dependency, an
existing JSpecify dependency, two negative controls, and the
Spring-only composite case. The shared parser classpath in that
class now includes micrometer-core.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants