Skip to content

Handle nested restrictive annotations on type variables in @NullUnmarked code#1510

Open
officialasishkumar wants to merge 3 commits intouber:masterfrom
officialasishkumar:issue-1488
Open

Handle nested restrictive annotations on type variables in @NullUnmarked code#1510
officialasishkumar wants to merge 3 commits intouber:masterfrom
officialasishkumar:issue-1488

Conversation

@officialasishkumar
Copy link
Copy Markdown

@officialasishkumar officialasishkumar commented Apr 4, 2026

Fixes #1488

  • A description about what and why you are contributing, even if it's trivial.

  • The issue number(s) or PR number(s) in the description if you are contributing in response to those.

  • If applicable, unit tests.

Description

In JSpecify mode, @NullUnmarked methods with explicit @NonNull annotations on type parameters (e.g., Foo<@NonNull T>) were not enforcing those restrictions at call sites. This happened because compareGenericTypeParameterNullabilityForCall was only invoked for annotated methods, so restrictive annotations on type variables in @NullUnmarked code were silently ignored.

Changes

  1. NullAway.java: Moved the compareGenericTypeParameterNullabilityForCall invocation outside the if (isMethodAnnotated) guard so it also runs for @NullUnmarked methods. The checkGenericMethodCallTypeArguments call remains gated to annotated methods only.

  2. GenericsChecks.java: Added an isMethodAnnotated parameter to compareGenericTypeParameterNullabilityForCall and threaded it through subtypeParameterNullability and identicalTypeParameterNullability so the visitor knows when to apply lenient checking. Also improved the inference-failure path in substituteTypeArgsInGenericMethodType to restore explicit nullability annotations from the original method type, so restrictive annotations survive even when the constraint solver finds unsatisfiable constraints.

  3. CheckIdenticalNullabilityVisitor.java: Added an isMethodUnannotated flag. When true, the visitor skips nullability mismatches for type arguments that have no explicit nullness annotation (neither @Nullable nor @NonNull), since unannotated type arguments are unconstrained in @NullUnmarked code. Type arguments with explicit restrictive annotations (e.g., @NonNull) are still enforced.

Test cases

  • nullUnmarkedRestrictiveAnnotationsOnTypeVariables: Verifies that passing Foo<@Nullable String> to a @NullUnmarked method expecting Foo<@NonNull String> reports an error, both for non-generic and generic method cases.
  • nullUnmarkedUnannotatedTypeVariableNoError: Verifies that passing List<@Nullable String> to a @NullUnmarked method with unannotated type parameter List<T> does NOT report an error.

Summary by CodeRabbit

  • Bug Fixes

    • Strengthened generic method null-safety checks in JSpecify mode so type-argument and parameter nullability comparisons run reliably for methods with declared type parameters, with improved handling for unannotated method type parameters.
    • Improved generic method type inference to preserve explicit nullability annotations at call sites.
  • Tests

    • Added tests validating generic type-parameter nullability behavior for annotated and unannotated method scenarios in JSpecify mode.

…Unmarked code

In JSpecify mode, @NullUnmarked methods with explicit @nonnull annotations
on type parameters (e.g., Foo<@nonnull T>) were not enforcing those
restrictions at call sites. This happened because the generics type
parameter check was only performed for annotated methods.

This change:
- Moves compareGenericTypeParameterNullabilityForCall outside the
  isMethodAnnotated guard so it also runs for @NullUnmarked methods
- Threads an isMethodAnnotated flag through the check pipeline so
  that for @NullUnmarked methods, only explicit restrictive annotations
  (like @nonnull) trigger errors, while unannotated type arguments
  remain unconstrained
- Restores explicit nullability annotations from the original method
  type when generic method type inference fails, preventing loss of
  restrictive annotations in the inferred call-site type

Fixes uber#1488

Signed-off-by: Asish Kumar <officialasishkumar@gmail.com>
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 4, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 4, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8464f677-a07c-4bd4-9e08-e605ff4c4fd4

📥 Commits

Reviewing files that changed from the base of the PR and between 603babb and 4d35c90.

📒 Files selected for processing (2)
  • nullaway/src/main/java/com/uber/nullaway/NullAway.java
  • nullaway/src/main/java/com/uber/nullaway/generics/CheckIdenticalNullabilityVisitor.java
👮 Files not reviewed due to content moderation or server errors (2)
  • nullaway/src/main/java/com/uber/nullaway/generics/CheckIdenticalNullabilityVisitor.java
  • nullaway/src/main/java/com/uber/nullaway/NullAway.java

Walkthrough

The PR changes JSpecify-mode generics nullability checks: NullAway now calls genericsChecks.checkGenericMethodCallTypeArguments(tree, state) only when the invoked method declares type parameters, and always calls genericsChecks.compareGenericTypeParameterNullabilityForCall(methodSymbol, tree, state, isMethodAnnotated) passing the method-annotation status. It threads an isMethodUnannotated flag through GenericsChecks and CheckIdenticalNullabilityVisitor to conditionally suppress some nullability mismatches for unannotated methods, and restores explicit nullability annotations after generic-method inference failures. Two JSpecify-mode tests were added.

Possibly related PRs

Suggested labels

jspecify

Suggested reviewers

  • yuxincs
  • lazaroclapp
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main objective: handling nested restrictive annotations on type variables in @NullUnmarked code, which is the core change across all modified files.
Linked Issues check ✅ Passed The PR fully addresses issue #1488 by enforcing restrictive nullability annotations on type variables in @NullUnmarked methods, with supporting tests demonstrating the fix for the reported scenario.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing issue #1488: refactoring generic type-parameter nullability checks to run for @NullUnmarked methods and adding lenient handling for unannotated type arguments while enforcing explicit restrictive annotations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@nullaway/src/main/java/com/uber/nullaway/generics/CheckIdenticalNullabilityVisitor.java`:
- Around line 84-95: The nullability mismatch gating logic (the
isMethodUnannotated && !isLHSNullableAnnotated &&
!Nullness.hasNonNullAnnotation(..., config) check) is duplicated; extract it
into a helper on CheckIdenticalNullabilityVisitor (e.g.,
shouldSkipUnannotatedMismatch or similar) and replace both occurrences (the
current block around the LHS/RHS check and the similar block at lines 128-135)
with calls to that helper; the helper should accept the lhsTypeArgument,
isMethodUnannotated flag, and config (or access config from the visitor) and
return a boolean indicating whether to skip reporting the mismatch so both class
and array paths share the same policy.

In `@nullaway/src/main/java/com/uber/nullaway/generics/GenericsChecks.java`:
- Around line 1953-1958: Add a regression unit test that forces an
InferenceFailure in the GenericsChecks path and asserts that explicit
restrictive annotations are preserved by the fallback call to
TypeSubstitutionUtils.restoreExplicitNullabilityAnnotations(...). Create a
minimal unsatisfiable-inference example (e.g., conflicting type-argument
nullability constraints) that exercises the branch returning
restoreExplicitNullabilityAnnotations(methodType, methodTypeAtCallSite, config,
Collections.emptyMap()).asMethodType(), invoke the checker to trigger the
InferenceFailure path, and assert the resulting method type at call site still
carries the original `@NonNull/`@Nullable annotations on type arguments.

In `@nullaway/src/main/java/com/uber/nullaway/NullAway.java`:
- Around line 2092-2097: compareGenericTypeParameterNullabilityForCall currently
applies explicit type-argument substitution only for MethodInvocationTree, so
generic constructor calls with explicit type arguments (NewClassTree) are
skipped; update compareGenericTypeParameterNullabilityForCall (or its shared
caller handleInvocation) to detect NewClassTree and extract/apply its explicit
type arguments the same way as for MethodInvocationTree (ensure substitution
logic used around lines handling diamond constructors is reused for
NewClassTree), and add a regression test that constructs a generic type via new
with explicit type arguments calling an `@NullUnmarked` constructor to prevent
future regressions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5962bc87-5d70-4562-a8c4-64df961b7108

📥 Commits

Reviewing files that changed from the base of the PR and between cf60f58 and d6acc8f.

📒 Files selected for processing (4)
  • nullaway/src/main/java/com/uber/nullaway/NullAway.java
  • nullaway/src/main/java/com/uber/nullaway/generics/CheckIdenticalNullabilityVisitor.java
  • nullaway/src/main/java/com/uber/nullaway/generics/GenericsChecks.java
  • nullaway/src/test/java/com/uber/nullaway/jspecify/NullMarkednessTests.java

Comment on lines +1953 to +1958
// inference failed; restore explicit nullability annotations from the original method
// type to the call-site type, so restrictive annotations (e.g. @NonNull on type
// arguments) are still enforced
return TypeSubstitutionUtils.restoreExplicitNullabilityAnnotations(
methodType, methodTypeAtCallSite, config, Collections.emptyMap())
.asMethodType();
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Apr 4, 2026

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Add a regression for the inference-failure fallback.

This branch now preserves explicit restrictive annotations after solver failure, but the PR’s listed tests do not appear to exercise an InferenceFailure. A small unsatisfiable-inference case would keep this from regressing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nullaway/src/main/java/com/uber/nullaway/generics/GenericsChecks.java` around
lines 1953 - 1958, Add a regression unit test that forces an InferenceFailure in
the GenericsChecks path and asserts that explicit restrictive annotations are
preserved by the fallback call to
TypeSubstitutionUtils.restoreExplicitNullabilityAnnotations(...). Create a
minimal unsatisfiable-inference example (e.g., conflicting type-argument
nullability constraints) that exercises the branch returning
restoreExplicitNullabilityAnnotations(methodType, methodTypeAtCallSite, config,
Collections.emptyMap()).asMethodType(), invoke the checker to trigger the
InferenceFailure path, and assert the resulting method type at call site still
carries the original `@NonNull/`@Nullable annotations on type arguments.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed that a dedicated inference-failure test would be ideal here. However, constructing a minimal unsatisfiable-inference scenario that reliably triggers InferenceFailure (rather than the solver just picking a fallback) is non-trivial and somewhat fragile since it depends on internal solver behavior.

The existing test nullUnmarkedRestrictiveAnnotationsOnTypeVariables does exercise the generic identity method path (which goes through substituteTypeArgsInGenericMethodType), and the change itself is conservative -- it restores explicit annotations from the original method type, which is the same pattern used in the success path and elsewhere in the codebase (e.g., lines 563, 570, 2034). Previously, inference failure silently dropped all explicit annotations, so this is strictly an improvement.

I will look into adding a targeted inference-failure test as a follow-up if I can find a reliable way to trigger that path. For now, the change is safe because restoreExplicitNullabilityAnnotations is well-tested through other code paths.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@officialasishkumar, that's a reasonable explanation. The UnsatisfiableConstraintsException path is indeed hard to trigger in a controlled way from a black-box test since it depends on the internal solver's constraint resolution, and a fragile test would be worse than no test.

One way to reliably hit the InferenceFailure branch without depending on solver internals would be to pass conflicting explicit type arguments — e.g., a call where the same type variable must simultaneously be @Nullable (from one argument) and @NonNull (from the return-type assignment context) with warnOnGenericInferenceFailure enabled. But that's exactly the kind of scenario that requires careful setup, so deferring makes sense.

If you'd like, I can open a follow-up issue to track adding that regression test. Just let me know!


🧠 Learnings used
Learnt from: msridhar
Repo: uber/NullAway PR: 1248
File: nullaway/src/main/java/com/uber/nullaway/generics/GenericsChecks.java:847-857
Timestamp: 2025-08-28T04:54:20.953Z
Learning: In NullAway's GenericsChecks.java, NewClassTree support for explicit type argument substitution requires more extensive changes beyond just modifying the conditional in compareGenericTypeParameterNullabilityForCall. The maintainers prefer to handle NewClassTree support in a separate follow-up rather than expanding the scope of PRs focused on specific issues like super constructor calls.

Learnt from: msridhar
Repo: uber/NullAway PR: 1507
File: nullaway/src/main/java/com/uber/nullaway/generics/GenericTypePrettyPrintingVisitor.java:66-79
Timestamp: 2026-04-01T15:40:47.197Z
Learning: In NullAway's `GenericTypePrettyPrintingVisitor` (nullaway/src/main/java/com/uber/nullaway/generics/GenericTypePrettyPrintingVisitor.java), the `appendNullableAnnotationIfPresent` helper intentionally prints ONLY `Nullable` type-use annotations and silently suppresses all other type-use annotations (including `NonNull`). This is by design: non-null is the default in NullAway's model and other type-use qualifiers are not relevant to NullAway diagnostics, so only `Nullable` is meaningful to display in error messages.

Learnt from: msridhar
Repo: uber/NullAway PR: 1316
File: jdk-javac-plugin/src/main/java/com/uber/nullaway/javacplugin/NullnessAnnotationSerializer.java:261-293
Timestamp: 2025-10-29T23:56:18.236Z
Learning: In NullAway's jdk-javac-plugin NullnessAnnotationSerializer, type variable bounds with annotations (e.g., `T extends Nullable Object`) are checked at their declaration sites by the typeParamHasAnnotation method for both class-level and method-level type parameters. The hasJSpecifyAnnotationDeep method is designed to check type uses (return types, parameters, etc.) and does not need a TYPEVAR case because type variable declaration bounds are already handled separately.

Learnt from: msridhar
Repo: uber/NullAway PR: 1485
File: nullaway/src/main/java/com/uber/nullaway/NullAway.java:2119-2128
Timestamp: 2026-03-20T15:34:10.657Z
Learning: In this repo (uber/NullAway), don’t annotate local variables with `Nullable` or `NonNull`. NullAway infers nullability for local variables automatically, so adding these annotations to locals is incorrect style for this codebase.

Learnt from: msridhar
Repo: uber/NullAway PR: 1245
File: guava-recent-unit-tests/src/test/java/com/uber/nullaway/guava/NullAwayGuavaParametricNullnessTests.java:101-102
Timestamp: 2025-08-14T18:50:06.159Z
Learning: In NullAway JSpecify tests, when JDK version requirements exist due to bytecode annotation reading capabilities, prefer failing tests over skipping them on unsupported versions to ensure CI catches regressions and enforces proper JDK version usage for developers.

Learnt from: msridhar
Repo: uber/NullAway PR: 1259
File: jdk-recent-unit-tests/src/test/java/com/uber/nullaway/jdk17/SwitchTests.java:318-321
Timestamp: 2025-08-29T18:41:43.584Z
Learning: Classes annotated with NullMarked are analyzed by NullAway even if they are not in packages specified by the AnnotatedPackages configuration. The NullMarked annotation guarantees NullAway analysis.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@officialasishkumar can you say more on why you can't write a test for this? It should be easy to reliably trigger an inference failure.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Extract the repeated isMethodUnannotated / explicit-nullness gate into a
shouldSkipMismatchInUnannotatedMethod helper in CheckIdenticalNullabilityVisitor.
This deduplicates the check that was inlined in both visitClassType and
visitArrayType. Also fix a minor Javadoc line-wrapping issue in GenericsChecks.

Signed-off-by: Asish Kumar <officialasishkumar@gmail.com>
Copy link
Copy Markdown
Collaborator

@msridhar msridhar 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 contribution @officialasishkumar! I'll take a closer look soon, but had one question to start.

Comment on lines +1953 to +1958
// inference failed; restore explicit nullability annotations from the original method
// type to the call-site type, so restrictive annotations (e.g. @NonNull on type
// arguments) are still enforced
return TypeSubstitutionUtils.restoreExplicitNullabilityAnnotations(
methodType, methodTypeAtCallSite, config, Collections.emptyMap())
.asMethodType();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@officialasishkumar can you say more on why you can't write a test for this? It should be easy to reliably trigger an inference failure.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Handle nested restrictive annotations on type variables in @NullUnmarked code

3 participants