Skip to content

Improve AnnotateNullableParameters to avoid duplicate annotations and annotation placement issues#843

Merged
timtebeek merged 3 commits into
openrewrite:mainfrom
stefanodallapalma:fix-annotate-nullable-params
Mar 31, 2026
Merged

Improve AnnotateNullableParameters to avoid duplicate annotations and annotation placement issues#843
timtebeek merged 3 commits into
openrewrite:mainfrom
stefanodallapalma:fix-annotate-nullable-params

Conversation

@stefanodallapalma
Copy link
Copy Markdown
Contributor

@stefanodallapalma stefanodallapalma commented Mar 30, 2026

Problem

The AnnotateNullableParameters recipe has two issues when used with nullable annotations from different frameworks:

1. Duplicate annotations when a different nullable annotation is already present

The recipe only checks for the target annotation before adding it. If a parameter already carries a different nullable annotation (e.g. @CheckForNull), the recipe blindly adds the target on top, producing inconsistent code:

// Before: parameter already annotated with @CheckForNull
public void process(@CheckForNull String name) { ... }

// After: ❌ duplicate nullable semantics
public void process(@CheckForNull @Nullable String name) { ... }

This is problematic because users often want to run the recipe as a first step before a separate annotation-replacement recipe (e.g. @CheckForNull@Nullable). The duplication forces manual cleanup.

2. Compilation error with non-TYPE_USE annotations on nested types

The recipe unconditionally calls MoveFieldAnnotationToType, which repositions the annotation before the inner type of a nested type (Outer.@Nullable Inner). This is only valid for @Target(TYPE_USE) annotations. Declaration-target annotations like @CheckForNull cannot appear in that position:

// ❌ Compilation error: @CheckForNull not applicable in this type context
public boolean hasConfig(Account account, Units.@CheckForNull Currencies currency) { ... }

Solution

1. Skip parameters that already carry a known nullable annotation

Added a KNOWN_NULLABLE_ANNOTATIONS list covering well-known nullable annotations across frameworks. findCandidateParameters now checks against all of them, not just the target:

  • org.jspecify.annotations.Nullable
  • javax.annotation.Nullable / jakarta.annotation.Nullable
  • javax.annotation.CheckForNull
  • edu.umd.cs.findbugs.annotations.Nullable / CheckForNull
  • org.checkerframework.checker.nullness.qual.Nullable
  • org.eclipse.jdt.annotation.Nullable
  • org.jetbrains.annotations.Nullable
  • org.springframework.lang.Nullable
  • android.support.annotation.Nullable / androidx.annotation.Nullable
// @CheckForNull already present → parameter is skipped
public void process(@CheckForNull String name) { ... }  // ✅ No change

// Only unannotated parameters get the target annotation
public void process(@CheckForNull String first, String last) { ... }
// becomes:
public void process(@CheckForNull String first, @Nullable String last) { ... }  // ✅ Only `last` annotated

2. Position annotation correctly based on @Target type

Added a TYPE_USE_NULLABLE_ANNOTATIONS set (jspecify, Jakarta, Checker Framework, Eclipse). Array bracket positioning (String @Nullable[]) and nested type positioning (Outer.@Nullable Inner) are now gated behind this check. Declaration-target annotations stay as leading annotations:

// TYPE_USE annotation (jspecify) → positioned before inner type
public void process(Units.@Nullable Currencies currency) { ... }  // ✅

// Declaration annotation (CheckForNull) → stays as leading annotation
public void process(@CheckForNull Units.Currencies currency) { ... }  // ✅

Updated recipe description

Removed the @Target(TYPE_USE) requirement from the option and recipe descriptions, since both TYPE_USE and declaration annotations are now properly supported.

Testing

  • ✅ Parameterized test covering 6 known annotations × 2 recipe configurations (default + custom) = 12 combinations for skip-when-already-annotated
  • ✅ Mixed parameter test: one param with @CheckForNull (skipped), one without (annotated)
  • ✅ Nested type with TYPE_USE annotation → B.@Nullable C positioning
  • ✅ Nested type with non-TYPE_USE annotation → @CheckForNull B.C positioning
  • ✅ All existing tests pass unchanged

Requested reviewer

@timtebeek

Co-authored with claude.ai - Opus 4.6

fix annotation position
Copy link
Copy Markdown
Member

@timtebeek timtebeek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey thanks! Makes sense, mostly. Had a quick look: on our side we avoid the duplicate annotations by running migrate to JSpecify before adding annotations. Are you using something else that you saw this?

Comment on lines +48 to +62
private static final List<String> KNOWN_NULLABLE_ANNOTATIONS = Arrays.asList(
"android.support.annotation.Nullable",
"androidx.annotation.Nullable",
"edu.umd.cs.findbugs.annotations.Nullable",
"edu.umd.cs.findbugs.annotations.CheckForNull",
"javax.annotation.Nullable",
"jakarta.annotation.Nullable",
"javax.annotation.CheckForNull",
"org.checkerframework.checker.nullness.qual.Nullable",
"org.checkerframework.checker.nullness.compatqual.NullableDecl",
"org.eclipse.jdt.annotation.Nullable",
"org.jetbrains.annotations.Nullable",
"org.jspecify.annotations.Nullable",
"org.springframework.lang.Nullable"
);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would we even need to be exhaustive here? Or can we match any annotation that contains Null? Want to avoid that we have this explicit list that we need to maintain, or folks asking to add their own when this heuristic is unlikely to have mismatches.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. But I'd be a little more specific by checking against the simple names CheckForNull and Nullable. I've seen parameters annotated with NotNull while still being checked against null. Whether that's correct, wrong, or just too defensive, if we only match any annotation that contains "Null", those parameters may end up being annotated as @Nullable @NotNull param, which is confusing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I would have thought if we see any annotation containing Null then we skip the whole thing, and assume the user knows what they're doing, or at least wouldn't appreciate a second annotation added.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forget about it, my brain is not braining atm. So, if we check against contains("null"), parameters annotated with NotNull and similar annotations will be skipped, although they still check against null. Which is ok, as the code will be null-safe while letting static analysis tools like NullAway know that the param shall not be null.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I would have thought if we see any annotation containing Null then we skip the whole thing, and assume the user knows what they're doing, or at least wouldn't appreciate a second annotation added.

100%. I could not see your comment while typing my answer

Comment on lines +147 to +150
// TYPE_USE annotations can be positioned on array brackets and before inner types
// of nested types; declaration-target annotations stay as leading annotations
if (TYPE_USE_NULLABLE_ANNOTATIONS.contains(fullyQualifiedName)) {
// For array types, move annotation from leading annotations to array brackets
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense, thanks. Don't see an immediate good way to avoid the existing collection here, as the new fullyQualifiedName might not be on the recipe classpath.

@timtebeek timtebeek added enhancement New feature or request recipe labels Mar 30, 2026
@stefanodallapalma
Copy link
Copy Markdown
Contributor Author

Hey thanks! Makes sense, mostly. Had a quick look: on our side we avoid the duplicate annotations by running migrate to JSpecify before adding annotations. Are you using something else that you saw this?

Running "migrate to JSpecify" before AnnotateNullable* makes sense, but in a large codebase like ours we prefer using a more conservative approach: annotate first and then decide whether and if makes sense migrate to jspecify or other annotations based on needs and constraints. Plus, codebases that still rely on FindBugs with @CheckForNull may not benefit from this recipe in its current state

Instead of maintaining a hardcoded list of 13 nullable annotation FQNs,
detect any annotation whose simple name contains "null" (case-insensitive).
This automatically covers all frameworks and prevents duplication/conflicts.
Copy link
Copy Markdown
Member

@timtebeek timtebeek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the help here! Good to avoid duplicates no matter how we'd arrive at those.

@github-project-automation github-project-automation Bot moved this from In Progress to Ready to Review in OpenRewrite Mar 31, 2026
@timtebeek timtebeek merged commit 3f3cd50 into openrewrite:main Mar 31, 2026
1 check passed
@github-project-automation github-project-automation Bot moved this from Ready to Review to Done in OpenRewrite Mar 31, 2026
mergify Bot added a commit to robfrank/linklift that referenced this pull request May 3, 2026
… 2.30.0 to 2.34.0 [skip ci]

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

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

> 2.34.0
> ------
>
> What's Changed
> --------------
>
> * bugfix: false positive in AnnotateNullableParameters when parameter … by [`@​stefanodallapalma`](https://github.com/stefanodallapalma) in [openrewrite/rewrite-static-analysis#865](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/865)
>
> **Full Changelog**: <openrewrite/rewrite-static-analysis@v2.33.0...v2.34.0>
>
> 2.33.1
> ------
>
> **Full Changelog**: <openrewrite/rewrite-static-analysis@v2.33.0...v2.33.1>
>
> 2.33.0
> ------
>
> What's Changed
> --------------
>
> * A new test case for `SimplifyBooleanExpression` by [`@​greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#848](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/848)
> * `InstanceOfPatternMatch` to avoid providing invalid variable definitions pointing to itself by [`@​greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#849](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/849)
> * Enrich recipe descriptions with rationale by [`@​jkschneider`](https://github.com/jkschneider) in [openrewrite/rewrite-static-analysis#850](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/850)
> * Fix EmptyBlock, FinalClass, HideUtilityClassConstructor; add regression tests by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#851](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/851)
> * Fix switch-related recipe bugs ([#6](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/6), [#9](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/9), [#14](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/14), [#687](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/687)) by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#852](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/852)
> * Add space after // in single-line comments by [`@​AVIMTA`](https://github.com/AVIMTA) in [openrewrite/rewrite-static-analysis#854](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/854)
> * Fix InstanceOfPatternMatch duplicate pattern variable names with flow scoping by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#855](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/855)
> * Fix RenameExceptionInEmptyCatch crash on Kotlin/Groovy files by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#856](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/856)
> * Fix FinalizeLocalVariables crash on Kotlin/Groovy top-level variables by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#857](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/857)
> * Fix UnnecessaryExplicitTypeArguments within lambda return statements by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#858](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/858)
> * Add SillyEqualsCheck recipe (RSPEC-S2159) by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#834](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/834)
> * Add RemoveUnusedLabels recipe (RSPEC-S1065) by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#835](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/835)
> * Add S2209/S3252 recipe: Static members via class name by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#836](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/836)
> * Add S1185 recipe: Remove methods that only call super by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#837](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/837)
> * NeedBraces: add test for if-else, fix do-while by [`@​greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#859](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/859)
> * Add tests confirming [#20](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/20) is fixed by [`@​greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#860](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/860)
> * Handle chained addition in PreferIncrementOperator by [`@​greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#816](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/816)
> * Fix UnnecessaryCatch for nested try-with-resources close() by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#863](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/863)
> * Fix FinalizePrivateFields breaking code with lambda reads in field initializers by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#862](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/862)
> * Regression test for `ReplaceStackWithDeque` crash on `this` argument by [`@​knutwannheden`](https://github.com/knutwannheden) in [openrewrite/rewrite-static-analysis#864](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/864)
>
> New Contributors
> ----------------
>
> * [`@​AVIMTA`](https://github.com/AVIMTA) made their first contribution in [openrewrite/rewrite-static-analysis#854](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/854)
>
> **Full Changelog**: <openrewrite/rewrite-static-analysis@v2.32.0...v2.33.0>
>
> 2.32.0
> ------
>
> What's Changed
> --------------
>
> * Remove [`@​Disabled`](https://github.com/Disabled) tests in `ReplaceStackWithDequeTest` by [`@​greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#840](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/840)
> * A couple of test cases in `RemoveExtraSemicolonsTest`  are no longer expected to fail by [`@​greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#841](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/841)
> * Fix compilation after new args added to Cs.CompilationUnit by [`@​greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#842](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/842)
> * Improve AnnotateNullableParameters to avoid duplicate annotations and annotation placement issues by [`@​stefanodallapalma`](https://github.com/stefanodallapalma) in [openrewrite/rewrite-static-analysis#843](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/843)
> * Inline JavaTemplate fields at call sites by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#844](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/844)
> * Use `JavaTemplate.apply()` static method by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#846](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/846)
> * Improve AnnotateNullableMethods to avoid duplicate annotations and annotation placement issues by [`@​stefanodallapalma`](https://github.com/stefanodallapalma) in [openrewrite/rewrite-static-analysis#845](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/845)
> * Skip `InstanceOfPatternMatch` for try-with-resources casts by [`@​timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#847](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/847)

... (truncated)


Commits

* [`90e4a60`](openrewrite/rewrite-static-analysis@90e4a60) bugfix: false positive in AnnotateNullableParameters when parameter … ([#865](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/865))
* [`b315bbc`](openrewrite/rewrite-static-analysis@b315bbc) Extract documentation examples from tests
* [`9ba38b4`](openrewrite/rewrite-static-analysis@9ba38b4) Add regression test for `ReplaceStackWithDeque` crash on `this` argument ([#864](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/864))
* [`78afc25`](openrewrite/rewrite-static-analysis@78afc25) Fix FinalizePrivateFields breaking compilation when field is read in a lambda...
* [`3b2d847`](openrewrite/rewrite-static-analysis@3b2d847) OpenRewrite recipe best practices
* [`2a0baac`](openrewrite/rewrite-static-analysis@2a0baac) Fix UnnecessaryCatch removing IOException for nested try-with-resources close...
* [`47094c2`](openrewrite/rewrite-static-analysis@47094c2) Handle chained addition in PreferIncrementOperator ([#816](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/816))
* [`e097fab`](openrewrite/rewrite-static-analysis@e097fab) Add tests confirming [#20](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/20) is fixed ([#860](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/860))
* [`3710118`](openrewrite/rewrite-static-analysis@3710118) Test for if-else, fix do-while NeedBraces ([#859](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/859))
* [`c6ebfd4`](openrewrite/rewrite-static-analysis@c6ebfd4) Add S1185 recipe: Remove methods that only call super ([#837](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/837))
* Additional commits viewable in [compare view](openrewrite/rewrite-static-analysis@v2.30.0...v2.34.0)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request recipe

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

3 participants