JSpecify mode: inference substitutes a synthetic `@NonNull` for an unconstrained type variable, so a `List<T>` with `T extends @Nullable Object` flows into a `List<Object>`

Open
#1,945 1 comment 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
5/5
Estimated time
Over a week
Newbie friendliness
12/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
java

Research direction

Start in ConstraintSolverImpl.solve, where UNKNOWN nullness of inferred type variables maps to NONNULL, and in TypeSubstitutionUtils.substituteInferredNullabilityForTypeVariables, which stamps the synthetic @NonNull. Reproduce with the JSpecify test helper in nullaway/src/test/java/com/uber/nullaway/jspecify/ using the diamond and idBox cases. Done means a diagnostic on lines 12 and 15 without breaking the new ArrayList/HashSet cases #1834 keeps compiling; the fix needs a third NO_CHANGE result, so it is design work.

Written by the indexing model from the issue text.

Description

false negative jspecify

With JSpecify mode on, new ArrayList<>(in) with in a List<T> and T extends @Nullable Object is accepted where a List<Object> is required, so the caller below throws NullPointerException at copy(in).get(0).toString() with no diagnostic anywhere. The same List<T> passed directly to a Collection<? extends Object> parameter is reported once #1834 is applied; the diamond, and any generic method whose type variable is instantiated by that argument, are not.

Versions: NullAway master at 2b25d0fc8 (0.14.3-SNAPSHOT), the 0.14.2 release, and the head of #1834 (b84ffe738). 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 java.util.ArrayList;
import java.util.List;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@NullMarked
class Test {
  static class Box<E extends @Nullable Object> {}
  static <U extends @Nullable Object> Box<U> idBox(Box<U> b) {
    return b;
  }
  static <T extends @Nullable Object> List<Object> copy(List<T> in) {
    return new ArrayList<>(in);
  }
  static <T extends @Nullable Object> void boxed(Box<T> box) {
    Box<? extends Object> x = idBox(box);
  }
  static void caller() {
    List<@Nullable String> in = new ArrayList<>();
    in.add(null);
    copy(in).get(0).toString();
  }
}

Expected: a diagnostic on the return new ArrayList<>(in); line (line 12) and on the Box<? extends Object> x = idBox(box); line (line 15), of the form #1834 gives a Box<T> passed straight to a Box<? extends Object> parameter:

incompatible types: Box<T> cannot be converted to Box<? extends Object>

Under JSpecify subtyping a bare T whose declared bound is @Nullable Object is not null-exclusive, so List<T> is contained by Collection<? extends E> only where E admits null; ArrayList<Object> has E as Object, and Box<? extends Object> requires the same of the U that idBox returns. The caller method shows the consequence: T is instantiated as @Nullable String, which NullAway accepts, and the List<Object> that comes back holds a null.

Actual: no diagnostic on any line, on master, on 0.14.2, and with #1834 applied. With a // BUG: Diagnostic contains: incompatible types marker inserted above line 12, which shifts it to line 13, the test fails with:

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

Analysis, a guess; the report stands without it. For the diamond and for idBox, javac infers the type variable as T and NullAway's ConstraintSolverImpl infers its nullness separately. Nothing in the call constrains it: T is neither @Nullable nor known non-null, so the variable stays UNKNOWN, and solve() maps UNKNOWN to NONNULL ("we infer NONNULL arbitrarily", ConstraintSolverImpl.solve). TypeSubstitutionUtils.substituteInferredNullabilityForTypeVariables then stamps a synthetic @NonNull on the variable, so the checks see ? extends @NonNull E on the formal and Box<@NonNull U> on the result, and a bare T passes against both. #1834 has to read that synthetic @NonNull as "no decision" to keep List<T> copy = copyOf(in) and new HashSet<>(in) compiling, which is correct for those and is what hides this case.

A result with two values cannot express what javac inferred here: the nullness of E is the nullness of T, whatever T is instantiated as. A third result, "same as the javac-inferred type" (the specification's NO_CHANGE), would substitute no annotation at all, and the containment check would then judge T against ? extends Object by its declared bound, as it does for the direct call. The dereference first(b).toString() with <U extends @Nullable Object> U first(Box<? extends U> b) goes unreported for the same reason, but it also meets the bare-T dereference gap of #1727, so it is not the reproducer here.

Related: #1834 reports the direct, non-inferred shape and names this gap in its description. #1551 asks for inference failures to be reported by default, where the default @NonNull hides an error; this report is the case where inference does not fail and the default still hides one. #1585 proposes mapping type variables to full nullability-enhanced types, which is where a NO_CHANGE result would live. #1727 is the NO_CHANGE operator on the dereference and sink side.

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.