Skip to content

NullableOnMethodReturnType: only move annotations applicable to TYPE_USE - #968

Merged
timtebeek merged 5 commits into
openrewrite:mainfrom
martinfrancois:fix/nullable-on-method-return-type-declaration-only
Aug 11, 2026
Merged

timtebeek merged 5 commits into
openrewrite:mainfrom
martinfrancois:fix/nullable-on-method-return-type-declaration-only

Conversation

@martinfrancois

@martinfrancois martinfrancois commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

What's changed?

NullableOnMethodReturnType
now moves a @Nullable annotation onto the return type only when that annotation is allowed in a
type context. An annotation declared @Target(ElementType.METHOD), or with no @Target, stays on
the method. A constructor is also left alone, because it has no return type.

With this annotation declaration:

@Target(ElementType.METHOD)
public @interface Nullable {}

and this method:

class Test {
    @Nullable
    public String[] value() { return null; }
}

current main produces public String @Nullable[] value(). With this change the method is left alone.

A new helper, isApplicableToTypeUse, reads the @Target meta-annotation from the type of the
@Nullable annotation itself, not from the method's return type. If the annotation's own type does
not resolve, the annotation is not moved.

What's your motivation?

javac rejects the output of current main with error: annotation @Nullable not applicable in this type context: the recipe turns a file that compiles into one that does not. It hits every
declaration-only @Nullable, which includes JSR-305 javax.annotation.Nullable, Spring's
org.springframework.lang.Nullable, AndroidX's androidx.annotation.Nullable and any @Nullable a
project declares for itself without @Target(ElementType.TYPE_USE).

Current main has a second defect, on constructors. It produces no compile error, but it silently
loses the annotation: the annotation is removed from the method before the recipe checks whether
there is a return type to move it onto, and a constructor has no return type. This change checks for
the return type first. Both defects reproduce on 2.40.0 and on 2.41.0-SNAPSHOT built from main.

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

One other recipe needed a small change. AnnotateNullableMethods gives the annotation it adds a
type by parsing a small stub of that annotation's declaration, then calls NullableOnMethodReturnType
to place it. On this branch that stub declares @Target(ElementType.TYPE_USE) whenever the
annotation being added is one of the four names in that recipe's own TYPE_USE_NULLABLE_ANNOTATIONS
set. Without that addition the stub would carry no @Target, the new guard would decline to move the
annotation, and AnnotateNullableMethods would stop placing it on the return type.

That @Target therefore comes from TYPE_USE_NULLABLE_ANNOTATIONS, not from the real declaration on
the classpath. If one of the four names ever stops being @Target(TYPE_USE), the stub will still say
it is.

Three limits:

Have you considered any alternatives or workarounds?

Yes. The new check could apply only when the return type is an array, so that a declaration-only
annotation would still be moved in front of a non-array return type.

That is possible because the two positions differ. An annotation written directly before a non-array
return type, public @Nullable String test(), is still parsed as a declaration annotation on the
method when the annotation type is not applicable to type contexts, so that shape compiles even for a
declaration-only annotation. In public String @Nullable[] test() the annotation can only be a type
annotation, and there a declaration-only annotation does not compile. The array position is the one
current main uses for an array return type, and the one javac rejects.

The narrower version would keep the recipe doing something for JSR-305 and Spring instead of nothing.
Against it: its output would read as though the annotation applied to the return type, while in Java
it would still apply to the method, so the code would say something other than what it means.

Switching is small: move !isApplicableToTypeUse(...) into the instanceof J.ArrayType branch and
update one expectation, in noChangeForDeclarationOnlyAnnotation, a test this branch adds: its
non-array scalar() method would then be rewritten, its String[][] one still not.

Any additional context

This change adds 8 tests to NullableOnMethodReturnTypeTest. Without the code change in this pull
request, 4 of them fail: noChangeForAnnotationWithoutTarget, noChangeForConstructor,
noChangeForDeclarationOnlyAnnotation and noChangeForDeclarationOnlyAnnotationOnArrayType. No
existing test expectation changed; the test file is additions only.

The other 4 pass either way. moveTypeUseAnnotationToArrayType and
moveAnnotationTargetingBothMethodAndTypeUse assert that @Target(TYPE_USE) and
@Target({METHOD, TYPE_USE}) annotations are still moved onto the return type;
noChangeForDeclarationOnlyAnnotationInModifierPosition and noChangeForUnknownAnnotationType
assert that an annotation written among the modifiers, and one whose type does not resolve, are still
left alone. The class holds 16 tests.

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

Checklist

  • I've added unit tests to cover both positive and negative cases
  • I've read and applied the recipe conventions and best practices
  • I've used the IntelliJ IDEA auto-formatter on affected files

The recipe matched any annotation whose name ends in `Nullable` and
moved it into the return type without checking that the annotation is
legal in a type context. For an array return the annotation lands on the
array dimension, so a declaration-only annotation, one declared
`@Target(ElementType.METHOD)`, turned `@Nullable public String[] value()`
into `public String @nullable[] value()`, which no longer compiles.

Read `@Target` from the annotation type and move the annotation only
when `TYPE_USE` is among its element types, accepting both the single
constant and the array form. An annotation with no `@Target`, with only
declaration targets, or whose declaration cannot be resolved is left on
the method, so the recipe stays conservative when type attribution is
missing. JSpecify style `@Nullable` is `TYPE_USE` and still moves.

A constructor has no return type expression, and the recipe removed the
leading annotation before discovering that, silently deleting it. It now
bails out before the removal.

`AnnotateNullableMethods` delegates to this recipe for the method it has
just annotated, and the `JavaTemplate` stub it parses is the only source
of type attribution for that annotation. The stub now declares
`@Target(ElementType.TYPE_USE)` for the annotations that recipe already
classifies as type-use, so its output is unchanged. No existing test
expectation changed.
@timtebeek
timtebeek merged commit 9699a7e into openrewrite:main Aug 11, 2026
1 check passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in OpenRewrite Aug 11, 2026
mergify Bot added a commit to robfrank/linklift that referenced this pull request Aug 20, 2026
… 2.40.0 to 2.41.0 [skip ci]

Bumps [org.openrewrite.recipe:rewrite-static-analysis](https://github.com/openrewrite/rewrite-static-analysis) from 2.40.0 to 2.41.0.
Release notes

*Sourced from [org.openrewrite.recipe:rewrite-static-analysis's releases](https://github.com/openrewrite/rewrite-static-analysis/releases).*

> 2.41.0
> ------
>
> What's Changed
> --------------
>
> * Pre-install the JavaScript RPC npm package before running tests by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#956](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/956)
> * UnnecessaryExplicitTypeArguments: retain witness for a return-only ty… by [`@​neil-mushell`](https://github.com/neil-mushell) in [openrewrite/rewrite-static-analysis#958](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/958)
> * Do not delete expressions that may have side effects by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#959](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/959)
> * OpenRewrite recipe best practices by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#960](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/960)
> * UseLambdaForFunctionalInterface: only convert when the anonymous class implements the SAM by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#962](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/962)
> * ReplaceStringBuilderWithString: wrap `char[]` appends in `String.valueOf` by [`@​martinfrancois`](https://github.com/martinfrancois) in [openrewrite/rewrite-static-analysis#977](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/977)
> * RemoveMethodsOnlyCallSuper: keep synchronized and deprecated overrides by [`@​martinfrancois`](https://github.com/martinfrancois) in [openrewrite/rewrite-static-analysis#971](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/971)
> * Make sure ModifierOrder doesn't alter Python's def quasi-modifier by [`@​greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#981](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/981)
> * NullableOnMethodReturnType: only move annotations applicable to TYPE\_USE by [`@​martinfrancois`](https://github.com/martinfrancois) in [openrewrite/rewrite-static-analysis#968](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/968)
> * `FallThrough` to work only for Java files by [`@​greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#982](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/982)
> * Retry a failed npx warm, and survive a machine without Node by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#984](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/984)
> * Make `DefaultComesLast` apply only to Java by [`@​greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#983](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/983)
>
> New Contributors
> ----------------
>
> * [`@​martinfrancois`](https://github.com/martinfrancois) made their first contribution in [openrewrite/rewrite-static-analysis#977](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/977)
>
> **Full Changelog**: <openrewrite/rewrite-static-analysis@v2.40.0...v2.41.0>


Commits

* [`43b51de`](openrewrite/rewrite-static-analysis@43b51de) Make DefaultComesLast apply only to Java ([#983](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/983))
* [`5d08edd`](openrewrite/rewrite-static-analysis@5d08edd) Retry a failed npx warm, and survive a machine without Node ([#984](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/984))
* [`49ddf78`](openrewrite/rewrite-static-analysis@49ddf78) FallThrough to work only for Java files ([#982](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/982))
* [`9699a7e`](openrewrite/rewrite-static-analysis@9699a7e) NullableOnMethodReturnType: only move annotations applicable to TYPE\_USE ([#968](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/968))
* [`0c6f970`](openrewrite/rewrite-static-analysis@0c6f970) Make sure ModifierOrder doesn't alter Python's def quasi-modifier ([#981](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/981))
* [`6ef24c5`](openrewrite/rewrite-static-analysis@6ef24c5) RemoveMethodsOnlyCallSuper: keep synchronized and deprecated overrides ([#971](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/971))
* [`0d8f7d7`](openrewrite/rewrite-static-analysis@0d8f7d7) ReplaceStringBuilderWithString: wrap `char[]` appends in `String.valueOf` ([#977](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/977))
* [`9a15a92`](openrewrite/rewrite-static-analysis@9a15a92) OpenRewrite recipe best practices
* [`a394f54`](openrewrite/rewrite-static-analysis@a394f54) UnnecessaryExplicitTypeArguments: regression test for witness on varargs meth...
* [`0ecb6d8`](openrewrite/rewrite-static-analysis@0ecb6d8) Update Gradle wrapper to 9.7.0
* Additional commits viewable in [compare view](openrewrite/rewrite-static-analysis@v2.40.0...v2.41.0)
  
[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility\_score?dependency-name=org.openrewrite.recipe:rewrite-static-analysis&package-manager=maven&previous-version=2.40.0&new-version=2.41.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
  
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot show  ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

2 participants