JSpecify mode: `? super E` containment accepts a non-null argument when the receiver is `Sink<? extends V>` and `V` may be null

Open
#1,944 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
22/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
java
Domain
tooling

Research direction

Start with CheckIdenticalNullabilityVisitor.superWildcardContains and TypeSubstitutionUtils.memberType, which the report says hands the containment check a wildcard lower bound where a capture is expected. Add a test in nullaway/src/test/java/com/uber/nullaway/jspecify/ modeled on WildcardTests, covering the throughWildcardReceiver case, and confirm it reports the same error as the direct case. Done means the Sink<? extends V> receiver case is flagged without changing the existing direct-receiver results.

Written by the indexing model from the issue text.

Description

false negative jspecify wildcards

With JSpecify mode on, passing a Box<Object> to a method that takes Box<? super E> is not reported when the method is called on a Sink<? extends V> receiver and V is declared V extends @Nullable Object. The method may store a null E into the box, so a null reaches a Box<Object> that excludes one. The same call on a Sink<V> receiver is reported once #1834 is applied.

Versions: NullAway master at 2b25d0fc8 (0.14.3-SNAPSHOT), the 0.14.2 release, and the head of #1834 (b84ffe738), where only the direct-receiver line below is reported. Error Prone 2.50.0, JDK 21.0.9. Flags: -XepOpt:NullAway:JSpecifyMode=true -XDaddTypeAnnotationsToSymbol=true -XepOpt:NullAway:JSpecifyExperimental=true -XepOpt:NullAway:OnlyNullMarked=true.

Code sample. I ran it as a test in nullaway/src/test/java/com/uber/nullaway/jspecify/, with the helper WildcardTests uses: makeTestHelperWithArgs(JSpecifyJavacConfig.withJSpecifyModeArgs(Arrays.asList("-XepOpt:NullAway:OnlyNullMarked=true"))).

import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@NullMarked
class Test {
  static class Box<E extends @Nullable Object> {
    E e;
    Box(E e) { this.e = e; }
    void set(E e) { this.e = e; }
    E get() { return e; }
  }
  interface Sink<E extends @Nullable Object> {
    void put(Box<? super E> b);
  }
  static class C<V extends @Nullable Object> {
    void throughWildcardReceiver(Sink<? extends V> s, Box<Object> b) {
      s.put(b);
    }
    void direct(Sink<V> s, Box<Object> b) {
      s.put(b);
    }
  }
}

Expected: a diagnostic on the s.put(b) line of throughWildcardReceiver (line 16), of the form #1834 gives the direct line (line 19):

incompatible types: Box<Object> cannot be converted to Box<? super V>

The member type of put on a Sink<? extends V> is put(Box<? super capture of ? extends V>), and under JSpecify containment a ? super S contains Object only where S is a nullness subtype of Object. The capture's upper bound is V, whose declared bound admits null, so it is not. JSpecify's own samples/SuperVsObject.java marks a Receiver<Object> returned as Receiver<? super T> with T extends @Nullable Object as jspecify_nullness_mismatch; this is the same containment one receiver further out.

Actual: no diagnostic on either line on master and on 0.14.2. With #1834 applied, line 19 is reported and line 16 is not. With a // BUG: Diagnostic contains: incompatible types marker inserted above line 16, which shifts it to line 17, the test fails with:

Did not see an error on line 17 matching incompatible types. There were no errors.

Analysis, a guess; the report stands without it. With a print added to CheckIdenticalNullabilityVisitor.superWildcardContains on the head of #1834, the formal parameter type that reaches the containment check for line 16 is Test.Box<? super ? extends V>: TypeSubstitutionUtils.memberType replaces E with the receiver's wildcard rather than with its capture, so the lower bound of the formal is the wildcard ? extends V. The nullness check has no case for a wildcard in that position and treats it as a class type, which is null-exclusive. For line 19 the lower bound is V itself and is judged by its declared bound. A fix could capture-convert the receiver before memberType, or resolve a wildcard lower bound to its effective upper bound before the nullness check.

Related: #1834 reports the direct-receiver case. #1854 and #1847 are also about values seen through a wildcard receiver, in the other direction: a nullable upper bound lost on a field read through ? super, and a false positive on Consumer<@Nullable V> through ?, ? extends, and ? super receivers. #1846 (closed) is the crash on a value read through a ? super receiver.

Assisted-by: Claude Code (claude-fable-5-1)

Dominant language
Java
Stars
4.1k
Forks
379
Avg merge
1d 2h
Merged PRs (30d)
106

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from uber/NullAway

All issues in uber/NullAway

Similar issues

More Java issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.