Skip to content

Commit 6329102

Browse files
authored
Fix JSpecify false negative when override narrows method type variable bound (#1682)
## 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: ```java @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 #1512 repro (param position) - `overrideWidensNonNullMethodTypeVariableBound` — reverse mismatch - `overridePreservesNullableMethodTypeVariableBound` / `overridePreservesNonNullMethodTypeVariableBound` — matching bounds remain legal - `overrideNarrowsNullableMethodTypeVariableBoundOnReturn` — return-only type variable ```bash ./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. - [x] Description of what and why - [x] Issue number: #1512 - [x] Unit tests <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: arimu1 <19286898+arimu1@users.noreply.github.com>
1 parent 6bba0e5 commit 6329102

2 files changed

Lines changed: 403 additions & 0 deletions

File tree

nullaway/src/main/java/com/uber/nullaway/generics/GenericsChecks.java

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2451,6 +2451,163 @@ public void checkTypeParameterNullnessForMethodOverriding(
24512451

24522452
checkTypeParameterNullnessForOverridingMethodReturnType(tree, methodWithTypeParams, state);
24532453
checkTypeParameterNullnessForOverridingMethodParameterType(tree, methodWithTypeParams, state);
2454+
checkMethodTypeVariableUpperBoundNullnessForOverriding(
2455+
tree, overridingMethod, overriddenMethod, methodWithTypeParams, state);
2456+
}
2457+
2458+
/**
2459+
* Checks that corresponding method type variables have the same upper-bound nullability on an
2460+
* overriding method and the method it overrides.
2461+
*
2462+
* <p>Narrowing a {@code @Nullable} upper bound to a non-null upper bound (or the reverse) is
2463+
* unsound: callers can still instantiate the type variable via the overridden signature. See <a
2464+
* href="https://github.com/uber/NullAway/issues/1512">issue 1512</a>.
2465+
*
2466+
* <p>Overridden method type-variable bounds are read from {@code overriddenMethodType}, the
2467+
* overridden method type after member-type substitution in the overriding class context. That
2468+
* ensures bounds that reference enclosing-class type variables are compared after those variables
2469+
* have been instantiated (e.g. {@code <T extends X>} on {@code Foo<X>} becomes {@code <T
2470+
* extends @Nullable Object>} when overriding in a {@code Foo<@Nullable Object>} subtype).
2471+
*
2472+
* <p>Overrides of methods from {@code @NullUnmarked} / unannotated code are skipped: method
2473+
* type-variable bound nullness is not specified there, and treating unmarked bounds as nullable
2474+
* would false-positive against typical {@code <T>} overrides in marked code.
2475+
*
2476+
* @param tree tree for the overriding method
2477+
* @param overridingMethod symbol of the overriding method
2478+
* @param overriddenMethod symbol of the overridden method
2479+
* @param overriddenMethodType type of the overridden method after member-type substitution in the
2480+
* overriding class context
2481+
* @param state the visitor state
2482+
*/
2483+
private void checkMethodTypeVariableUpperBoundNullnessForOverriding(
2484+
MethodTree tree,
2485+
Symbol.MethodSymbol overridingMethod,
2486+
Symbol.MethodSymbol overriddenMethod,
2487+
Type overriddenMethodType,
2488+
VisitorState state) {
2489+
if (CodeAnnotationInfo.instance(state.context)
2490+
.isSymbolUnannotated(overriddenMethod, config, handler)) {
2491+
return;
2492+
}
2493+
// Generic methods are Type.ForAll; non-generic overridden methods have no method type vars.
2494+
if (!(overriddenMethodType instanceof Type.ForAll forAll)) {
2495+
return;
2496+
}
2497+
com.sun.tools.javac.util.List<Type> overriddenTypeVars = forAll.tvars;
2498+
List<Symbol.TypeVariableSymbol> overridingTypeParams = overridingMethod.getTypeParameters();
2499+
// If counts differ, javac would not treat this as a valid override; leave that to the
2500+
// compiler.
2501+
if (overridingTypeParams.size() != overriddenTypeVars.size()) {
2502+
return;
2503+
}
2504+
List<? extends Tree> typeParameterTrees = tree.getTypeParameters();
2505+
for (int i = 0; i < overridingTypeParams.size(); i++) {
2506+
Symbol.TypeVariableSymbol overridingTv = overridingTypeParams.get(i);
2507+
// ForAll.tvars are method type variables after member-type substitution.
2508+
Type.TypeVar overriddenTypeVar = (Type.TypeVar) overriddenTypeVars.get(i);
2509+
boolean overridingNullable =
2510+
GenericsUtils.upperBoundIsNullable(overridingTv, config, handler, state);
2511+
boolean overriddenNullable =
2512+
substitutedMethodTypeVarUpperBoundIsNullable(
2513+
overriddenTypeVar, overriddenMethod, i, state);
2514+
if (overridingNullable != overriddenNullable) {
2515+
reportMismatchedMethodTypeVariableBoundError(
2516+
typeParameterTrees.get(i),
2517+
overridingTv,
2518+
overridingNullable,
2519+
overriddenMethod,
2520+
overriddenNullable,
2521+
state);
2522+
}
2523+
}
2524+
}
2525+
2526+
/**
2527+
* Returns whether the upper bound of a method type variable, viewed after member-type
2528+
* substitution in the overriding class, should be treated as nullable.
2529+
*
2530+
* <p>Prefers annotations / nullability of the substituted bound so enclosing-class type variables
2531+
* are accounted for. Falls back to library models for the original type variable index when
2532+
* present.
2533+
*
2534+
* @param substitutedTypeVar type variable from the overridden method type after substitution
2535+
* @param overriddenMethod symbol of the overridden method (for library models)
2536+
* @param typeVarIndex index of the type variable on the overridden method
2537+
* @param state the visitor state
2538+
*/
2539+
private boolean substitutedMethodTypeVarUpperBoundIsNullable(
2540+
Type.TypeVar substitutedTypeVar,
2541+
Symbol.MethodSymbol overriddenMethod,
2542+
int typeVarIndex,
2543+
VisitorState state) {
2544+
if (handler.onOverrideMethodTypeVariableUpperBound(overriddenMethod, typeVarIndex, state)) {
2545+
return true;
2546+
}
2547+
Type upperBound = substitutedTypeVar.getUpperBound();
2548+
if (Nullness.hasNullableAnnotation(upperBound.getAnnotationMirrors().stream(), config)) {
2549+
return true;
2550+
}
2551+
// Bound may still be a free type variable (e.g. subclass keeps the enclosing type parameter).
2552+
// In that case, use the declaration-site nullability of that type variable's upper bound.
2553+
if (upperBound.getKind() == TypeKind.TYPEVAR) {
2554+
return GenericsUtils.upperBoundIsNullable(upperBound.asElement(), config, handler, state);
2555+
}
2556+
// Member-type substitution (asMemberOf) can strip type-use @Nullable from a concrete method
2557+
// type-variable bound while leaving the bound type itself (e.g. Object). Example that needs
2558+
// this fallback:
2559+
// interface Foo { <T extends @Nullable Object> void bar(T arg); }
2560+
// class Baz implements Foo { public <T extends @Nullable Object> void bar(T arg) {} }
2561+
// After substitution the bound may look like plain Object with no annotation mirrors; without
2562+
// consulting the original declaration we would treat the overridden bound as non-null and
2563+
// false-positive on a matching @Nullable override. Skip original bounds that are still type
2564+
// variables — those must be resolved via substitution (or the free type-var path above).
2565+
List<Symbol.TypeVariableSymbol> originalTypeParams = overriddenMethod.getTypeParameters();
2566+
Type originalBound =
2567+
(Type) ((TypeVariable) originalTypeParams.get(typeVarIndex).asType()).getUpperBound();
2568+
if (originalBound.getKind() != TypeKind.TYPEVAR
2569+
&& Nullness.hasNullableAnnotation(originalBound.getAnnotationMirrors().stream(), config)) {
2570+
return true;
2571+
}
2572+
return false;
2573+
}
2574+
2575+
/**
2576+
* Reports an error when an overriding method's type variable has a different upper-bound
2577+
* nullability than the corresponding type variable of the overridden method.
2578+
*
2579+
* @param errorTree tree to attach the diagnostic to (usually the overriding type parameter)
2580+
* @param overridingTv type variable of the overriding method
2581+
* @param overridingNullable whether the overriding type variable's upper bound is nullable
2582+
* @param overriddenMethod symbol of the overridden method
2583+
* @param overriddenNullable whether the overridden type variable's upper bound is nullable (in
2584+
* the overriding class context)
2585+
* @param state the visitor state
2586+
*/
2587+
private void reportMismatchedMethodTypeVariableBoundError(
2588+
Tree errorTree,
2589+
Symbol.TypeVariableSymbol overridingTv,
2590+
boolean overridingNullable,
2591+
Symbol.MethodSymbol overriddenMethod,
2592+
boolean overriddenNullable,
2593+
VisitorState state) {
2594+
ErrorBuilder errorBuilder = analysis.getErrorBuilder();
2595+
String overridingBound = overridingNullable ? "@Nullable" : "non-null";
2596+
String overriddenBound = overriddenNullable ? "@Nullable" : "non-null";
2597+
ErrorMessage errorMessage =
2598+
new ErrorMessage(
2599+
ErrorMessage.MessageTypes.WRONG_OVERRIDE_PARAM_GENERIC,
2600+
String.format(
2601+
"Method type variable %s has a %s upper bound, but corresponding type variable of"
2602+
+ " overridden method %s.%s has a %s upper bound",
2603+
overridingTv.name,
2604+
overridingBound,
2605+
ASTHelpers.enclosingClass(overriddenMethod),
2606+
overriddenMethod.name,
2607+
overriddenBound));
2608+
state.reportMatch(
2609+
errorBuilder.createErrorDescription(
2610+
errorMessage, analysis.buildDescription(errorTree), state, null));
24542611
}
24552612

24562613
/**

0 commit comments

Comments
 (0)