Skip to content

Fix JSpecify false negative when override narrows method type variable bound - #1682

Open
arimu1 wants to merge 5 commits into
uber:masterfrom
arimu1:fix/jspecify-override-method-typevar-bound-1512
Open

Fix JSpecify false negative when override narrows method type variable bound#1682
arimu1 wants to merge 5 commits into
uber:masterfrom
arimu1:fix/jspecify-override-method-typevar-bound-1512

Conversation

@arimu1

@arimu1 arimu1 commented Aug 7, 2026

Copy link
Copy Markdown

Summary

Fixes #1512.

In JSpecify mode, NullAway did not compare method type-variable upper-bound nullability between an overriding method and the method it overrides. That allowed unsound overrides such as:

@NullMarked
interface Foo {
  <T extends @Nullable Object> void bar(T arg);
}

@NullMarked
class Baz implements Foo {
  @Override
  public <T> void bar(T arg) { arg.hashCode(); } // was accepted
}

Callers can still invoke the method via the super type with a @Nullable type argument (e.g. f.<@Nullable String>bar(null)), so treating the override's parameter as non-null is incorrect.

This change, in GenericsChecks.checkTypeParameterNullnessForMethodOverriding, compares upper-bound nullability of corresponding method type variables (using GenericsUtils.upperBoundIsNullable) and reports WRONG_OVERRIDE_PARAM_GENERIC when they differ—whether the override narrows @Nullable → non-null or widens non-null → @Nullable.

Tests

  • overrideNarrowsNullableMethodTypeVariableBound — issue JSpecify: False negative when overriding narrows @Nullable type variable bound #1512 repro (param position)
  • overrideWidensNonNullMethodTypeVariableBound — reverse mismatch
  • overridePreservesNullableMethodTypeVariableBound / overridePreservesNonNullMethodTypeVariableBound — matching bounds remain legal
  • overrideNarrowsNullableMethodTypeVariableBoundOnReturn — return-only type variable
./gradlew :nullaway:test --tests "com.uber.nullaway.jspecify.GenericMethodTests"
./gradlew :nullaway:test --tests "com.uber.nullaway.jspecify.*"

(JDK 21)

AI disclosure

I used AI tools (Grok) to help draft the fix and tests. I reviewed all changes, ran the tests above, and understand the code.

Summary by CodeRabbit

  • Bug Fixes

    • Improved validation of generic method overrides with nullable and non-null type-variable bounds.
    • Reports clearer diagnostics when an override narrows or widens a bound incompatibly.
    • Correctly accepts overrides that preserve compatible bounds, including substituted nullable types and return-type variables.
    • Skips bound validation for unannotated methods and mismatched type-variable declarations.
  • Tests

    • Added regression coverage for narrowed, widened, and preserved generic nullability bounds across parameters and return types.

@CLAassistant

CLAassistant commented Aug 7, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bd153fad-7ac9-423f-a8e8-9ac9cfee27f8

📥 Commits

Reviewing files that changed from the base of the PR and between acbf609 and 4e0871c.

📒 Files selected for processing (1)
  • nullaway/src/main/java/com/uber/nullaway/generics/GenericsChecks.java

Walkthrough

Override checks compare nullability on corresponding method type-variable upper bounds. The checks resolve substituted bounds, skip unannotated methods and mismatched type-variable counts, and report WRONG_OVERRIDE_PARAM_GENERIC diagnostics for mismatches. Regression tests cover narrowed and widened bounds, preserved bounds, substituted class bounds, return bounds, and @NullUnmarked methods.

Possibly related PRs

  • uber/NullAway#1345: Modifies GenericsChecks and method type-variable upper-bound handling for generic nullability validation.
  • uber/NullAway#1520: Modifies generic nullability subtype logic in GenericsChecks.java for wildcard type-argument containment.

Suggested labels: jspecify

Suggested reviewers: msridhar, yuxincs

🚥 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 clearly describes the main change: fixing a JSpecify false negative for narrowed method type-variable bounds.
Linked Issues check ✅ Passed The implementation and regression tests address issue #1512 by diagnosing incompatible nullability changes in overriding method type-variable bounds.
Out of Scope Changes check ✅ Passed The changes are limited to generic override validation and related regression tests, with no unrelated scope identified.
✨ 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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@nullaway/src/main/java/com/uber/nullaway/generics/GenericsChecks.java`:
- Around line 2400-2423: Add Javadoc to
reportMismatchedMethodTypeVariableBoundError describing that it reports an error
when overriding and overridden method type variables have mismatched nullability
upper bounds, and document errorTree, overridingTv, overridingNullable,
overriddenMethod, overriddenNullable, and state.
- Around line 2350-2351: Update
checkMethodTypeVariableUpperBoundNullnessForOverriding and its call from
checkTypeParameterNullnessForMethodOverriding to accept the overridden method
type after member-type substitution in the overriding class context. Read
type-variable upper bounds from this contextual type instead of
overriddenMethod.getTypeParameters(), preserving correct nullable/non-null
instantiation behavior and JDK suppression handling. Add regressions covering
both nullable and non-null enclosing-class type-variable instantiations.
🪄 Autofix

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 Plus

Run ID: 909e93f4-3d4b-4773-bd3f-a2f2fe8a8d7b

📥 Commits

Reviewing files that changed from the base of the PR and between d52c586 and ca200a1.

📒 Files selected for processing (2)
  • nullaway/src/main/java/com/uber/nullaway/generics/GenericsChecks.java
  • nullaway/src/test/java/com/uber/nullaway/jspecify/GenericMethodTests.java

Comment thread nullaway/src/main/java/com/uber/nullaway/generics/GenericsChecks.java Outdated

@msridhar msridhar left a comment

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.

Thanks for the contribution! Beyond the comment below, if you run ./gradlew :nullaway:buildWithNullAway you'll see several new warnings, and also integration tests are failing. I think we may need to special-case overrides of methods from @NullUnmarked code; if you could take a look that'd be great

Comment thread nullaway/src/main/java/com/uber/nullaway/generics/GenericsChecks.java Outdated
@arimu1
arimu1 force-pushed the fix/jspecify-override-method-typevar-bound-1512 branch from d3497a0 to baebf51 Compare August 8, 2026 04:19
@arimu1

arimu1 commented Aug 8, 2026

Copy link
Copy Markdown
Author

@msridhar Thanks for the review and the overridePreservesSubstitutedNullableMethodTypeVariableBound test.

Fixed on the branch tip baebf51d (rebased onto latest master):

  • Substituted bounds — overridden method type-var upper bounds are now taken from the method type after member-type substitution in the overriding class (same as return/param invariance checks), so enclosing-class type-variable instantiations no longer false-positive.
  • @NullUnmarked — skip this check when the overridden method is unannotated, so unmarked bounds do not force @Nullable against normal <T> overrides in marked code (buildWithNullAway + integration tests clean again).

Local verification (JDK 21): GenericMethodTests, :nullaway:buildWithNullAway, jdk-integration-test, jar-infer nullaway-integration-test all green.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@nullaway/src/main/java/com/uber/nullaway/generics/GenericsChecks.java`:
- Around line 2386-2389: Update
checkMethodTypeVariableUpperBoundNullnessForOverriding so differing
type-parameter counts do not bypass validation for erasure-compatible overrides
such as a concrete Object parameter overriding a generic T parameter. Validate
the erased parameter and return nullness contracts before any unsupported-count
exit, while preserving the existing compiler-handled behavior for genuinely
incompatible overrides. Add a regression covering `@Override` void bar(Object arg)
{ arg.hashCode(); } with a nullable generic call path.
🪄 Autofix

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 Plus

Run ID: acc6975b-f749-46c6-bbea-a8d6e3dfeb6b

📥 Commits

Reviewing files that changed from the base of the PR and between d3497a0 and baebf51.

📒 Files selected for processing (2)
  • nullaway/src/main/java/com/uber/nullaway/generics/GenericsChecks.java
  • nullaway/src/test/java/com/uber/nullaway/jspecify/GenericMethodTests.java

@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.95652% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.82%. Comparing base (89359bd) to head (585a1fb).

Files with missing lines Patch % Lines
...ava/com/uber/nullaway/generics/GenericsChecks.java 86.95% 3 Missing and 3 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #1682      +/-   ##
============================================
- Coverage     87.82%   87.82%   -0.01%     
- Complexity     3181     3193      +12     
============================================
  Files           109      109              
  Lines         10809    10855      +46     
  Branches       2185     2196      +11     
============================================
+ Hits           9493     9533      +40     
- Misses          622      625       +3     
- Partials        694      697       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@msridhar msridhar left a comment

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.

Thanks for the revisions! I have some more feedback

substitutedMethodTypeVarUpperBoundIsNullable(
overriddenTypeVar, overriddenMethod, i, state);
if (overridingNullable != overriddenNullable) {
Tree errorTree = i < typeParameterTrees.size() ? typeParameterTrees.get(i) : tree;

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.

Why do we need this i < typeParameterTrees.size() check here?

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 — unnecessary. In a valid MethodTree, getTypeParameters() matches the overriding method type-parameter count. Dropped the guard and always use typeParameterTrees.get(i).

if (methodType instanceof Type.ForAll forAll) {
return forAll.tvars;
}
return com.sun.tools.javac.util.List.nil();

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.

Is this actually reachable? This method is only used to get the type variables of the overridden method. I guess it might be reached if the overriding method introduces a type variable?

In any case, assuming this is reachable, then rather than using this method please explicitly check for no type variables in the caller and bail out there

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.

Yes — mainly the non-generic overridden case (not a Type.ForAll). Per your suggestion I removed getMethodTypeVariables and now bail in the caller with if (!(overriddenMethodType instanceof Type.ForAll forAll)) return; before reading forAll.tvars.

if (handler.onOverrideMethodTypeVariableUpperBound(overriddenMethod, typeVarIndex, state)) {
return true;
}
if (!(substitutedTypeVar instanceof Type.TypeVar typeVar)) {

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.

Just make the declared type of the parameter Type.TypeVar rather than having this bailout

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.

Done — parameter is now Type.TypeVar, with an explicit cast from forAll.tvars at the call site.

Comment on lines +2532 to +2545
// javac member-type substitution can drop type-use annotations on method type-variable
// bounds. If the original bound was a concrete type with an explicit @Nullable, honor that
// declaration. Do not consult original bounds that are still type variables — those must be
// resolved via substitution (or the free type-var path above).
List<Symbol.TypeVariableSymbol> originalTypeParams = overriddenMethod.getTypeParameters();
if (typeVarIndex >= 0 && typeVarIndex < originalTypeParams.size()) {
Type originalBound =
(Type) ((TypeVariable) originalTypeParams.get(typeVarIndex).asType()).getUpperBound();
if (originalBound.getKind() != TypeKind.TYPEVAR
&& Nullness.hasNullableAnnotation(
originalBound.getAnnotationMirrors().stream(), config)) {
return true;
}
}

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.

I don't understand when this code would apply. Can you please explain it?

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.

This path covers cases where member-type substitution (asMemberOf) leaves a concrete upper bound (e.g. Object) but drops the type-use @Nullable that was on the original method type-variable declaration.

Example that still needs it (covered by overridePreservesNullableMethodTypeVariableBound):

interface Foo {
  <T extends @Nullable Object> void bar(T arg);
}
class Baz implements Foo {
  public <T extends @Nullable Object> void bar(T arg) {}
}

After substitution the overridden bound can appear as plain Object with no annotation mirrors. Without reading the original declaration’s @Nullable we would treat the overridden bound as non-null and false-positive on a matching @Nullable override.

We intentionally skip original bounds that are still type variables — those must go through substitution / the free type-var path above (the enclosing-class X cases).

@arimu1

arimu1 commented Aug 12, 2026

Copy link
Copy Markdown
Author

@msridhar Thanks for the follow-up review — addressed on tip 4d6bc7e4:

  1. Dropped the i < typeParameterTrees.size() guard.
  2. Removed getMethodTypeVariables; caller bails when the overridden type is not Type.ForAll.
  3. substitutedMethodTypeVarUpperBoundIsNullable now takes Type.TypeVar.
  4. Expanded the comment (and replied on-thread) for when the original-declaration @Nullable fallback applies — substitution can strip type-use annotations from a concrete bound.

./gradlew :nullaway:test --tests com.uber.nullaway.jspecify.GenericMethodTests is green on JDK 21.

@msridhar msridhar left a comment

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.

Thanks a lot! One more minor comment.

Also, you'll see that several of our integration tests, like for junit, fail with this change. Can you eyeball the new errors and check that they look like valid issues? You can see our CI config if you want to re-run the checks locally.

// false-positive on a matching @Nullable override. Skip original bounds that are still type
// variables — those must be resolved via substitution (or the free type-var path above).
List<Symbol.TypeVariableSymbol> originalTypeParams = overriddenMethod.getTypeParameters();
if (typeVarIndex >= 0 && typeVarIndex < originalTypeParams.size()) {

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.

Is it possible for typeVarIndex to be out of bounds in a valid override? I don't think so?

Comment on lines +2516 to +2524
// Member-type substitution (asMemberOf) can strip type-use @Nullable from a concrete method
// type-variable bound while leaving the bound type itself (e.g. Object). Example that needs
// this fallback:
// interface Foo { <T extends @Nullable Object> void bar(T arg); }
// class Baz implements Foo { public <T extends @Nullable Object> void bar(T arg) {} }
// After substitution the bound may look like plain Object with no annotation mirrors; without
// consulting the original declaration we would treat the overridden bound as non-null and
// false-positive on a matching @Nullable override. Skip original bounds that are still type
// variables — those must be resolved via substitution (or the free type-var path above).

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.

👍

arimu1 and others added 5 commits August 14, 2026 07:10
…e bound

In JSpecify mode, compare upper-bound nullability of corresponding method
type variables between an overriding method and the method it overrides.
Narrowing `<T extends @nullable Object>` to `<T>` (or the reverse) is
unsound because callers can still instantiate the type variable via the
overridden signature.

Fixes uber#1512
…thods

Read overridden method type-variable upper bounds from the method type
after member-type substitution in the overriding class, so bounds that
reference enclosing-class type variables compare correctly after
instantiation (e.g. <T extends X> on Foo<@nullable Object>).

Skip the check when the overridden method is from @NullUnmarked /
unannotated code to avoid false positives from unmarked bounds.

Add regressions for nullable and non-null enclosing-class instantiations,
narrowing after substitution, and NullUnmarked overrides.
- Bail out in the caller when overridden type is not ForAll
- Drop defensive typeParameterTrees size guard
- Take Type.TypeVar in substituted bound helper
- Document when original-declaration @nullable fallback applies
Valid overrides always have matching type-parameter counts; the caller
already bails when counts differ, so typeVarIndex is always in range.
@arimu1
arimu1 force-pushed the fix/jspecify-override-method-typevar-bound-1512 branch from 4e0871c to 585a1fb Compare August 14, 2026 00:16
@arimu1

arimu1 commented Aug 14, 2026

Copy link
Copy Markdown
Author

Thanks for the follow-up!

typeVarIndex bounds guard: Removed — the caller already returns early when overriding/overridden type-parameter counts differ, and the loop index is always in range for valid overrides.

Rebase: Rebased onto latest master (includes #1691 integration-test snapshot script update).

Integration test analysis: Re-ran the CI integration jobs locally (publishToMavenLocal + snapshot init script + assemble):

Project New NullAway findings Assessment
junit-framework 1 — ClasspathAlignmentCheckingLauncherInterceptor.intercept: override <T> vs interface <T extends @Nullable Object> Valid. Both sides are in @NullMarked packages; the override narrows a nullable method type-var bound (same shape as #1512).
spring-framework 2 — TaskExecutorAdapter.submit and SimpleAsyncTaskExecutor.submit: override <T> vs AsyncTaskExecutor.submit with @Nullable upper bound Valid. @NullMarked override of a JSpecify-annotated interface method; narrowing the bound is unsound for callers using the super signature.
caffeine 8 — BoundedLocalCache overrides of Policy.Eviction / FixedExpiration / VarExpiration methods (coldest, hottest, oldest, youngest): override <T> vs @Nullable-bounded interface type vars Valid. Same pattern — marked implementation narrows nullable generic method bounds from the policy interfaces.

No false positives from @NullUnmarked overrides observed; those continue to be skipped via isSymbolUnannotated on the overridden method.

Tests (JDK 21):

  • ./gradlew :nullaway:buildWithNullAway — pass
  • ./gradlew :nullaway:test --tests "com.uber.nullaway.jspecify.*" — pass

Tip: 585a1fbf

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.

JSpecify: False negative when overriding narrows @Nullable type variable bound

3 participants