Skip to content

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

Open
arimu1 wants to merge 4 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 4 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

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: cecc07af-789f-4245-8c8c-d63c1810011f

📥 Commits

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

📒 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

Walkthrough

Override checks now 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: Adds library-model support used by method type-variable bound validation.
  • uber/NullAway#1558: Updates generic nullability analysis in GenericsChecks.java for wildcard bound handling.

Suggested labels: jspecify

Suggested reviewers: msridhar, 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 clearly and concisely describes the primary fix for JSpecify override bound narrowing.
Linked Issues check ✅ Passed The implementation addresses issue #1512 by diagnosing incompatible nullability changes in overridden method type-variable bounds.
Out of Scope Changes check ✅ Passed The implementation and regression tests remain within the linked issue scope.
✨ 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 and others added 3 commits August 8, 2026 11:15
…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.
@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 80.76923% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.84%. Comparing base (741fbad) to head (acbf609).

Files with missing lines Patch % Lines
...ava/com/uber/nullaway/generics/GenericsChecks.java 80.76% 4 Missing and 6 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #1682      +/-   ##
============================================
- Coverage     87.87%   87.84%   -0.04%     
- Complexity     3167     3180      +13     
============================================
  Files           109      109              
  Lines         10739    10791      +52     
  Branches       2171     2184      +13     
============================================
+ Hits           9437     9479      +42     
- Misses          617      621       +4     
- Partials        685      691       +6     

☔ 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?

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

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

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?

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