Skip to content

Wildcard bound incomparable with type parameter's declared bound is silently discarded via GLB #1957

Description

@wmdietl

Summary

When a wildcard's extends bound is incomparable with the corresponding type parameter's declared upper bound (neither is a subtype of the other), capture conversion's GLB (JLS 5.1.10) silently replaces the written qualifier with the meet of the two — with no diagnostic anywhere. The GLB computation itself is sound (it's what keeps the captured type correct), but the user's written annotation is discarded and replaced by a different, unrelated qualifier they never wrote, with no way to discover this from source.

This mirrors javac's own JLS 4.5 check for the base-type dimension — Lib<? extends String> is rejected when T extends Number, not because accepting it would be unsound, but because no valid instantiation could satisfy both bounds, which is itself strong evidence of programmer error. BaseTypeValidator/BaseTypeVisitor has no equivalent check in the qualifier dimension.

Repro (Signedness Checker's Signed/Unsigned diamond lattice)

Signed and Unsigned are both direct subtypes of UnknownSignedness, but incomparable with each other; SignednessGlb is their actual meet (@SubtypeOf({Unsigned, Signed})).

import org.checkerframework.checker.signedness.qual.Signed;
import org.checkerframework.checker.signedness.qual.Unsigned;
import org.checkerframework.checker.signedness.qual.SignednessBottom;

public class SignednessLattice2 {
  static class Lib<T extends @Signed Integer> {
    T get() { throw new RuntimeException(); }
  }

  void incomparableExplicit(Lib<@Unsigned Integer> x) {}         // reported (as expected)

  void test(Lib<? extends @Unsigned Integer> x) {                // silent (this issue)
    @SignednessBottom Integer y = x.get();
  }
}
checker/bin/javac -processor org.checkerframework.checker.signedness.SignednessChecker -proc:only SignednessLattice2.java

incomparableExplicit's direct type argument is correctly reported (type.argument.type.incompatible). The wildcard case is silent; forcing the captured type's bound to the surface via a deliberately-incompatible assignment shows:

found   : capture#01 extends @SignednessGlb Integer
required: @SignednessBottom Integer

confirming CF silently computed GLB(Signed, Unsigned) = SignednessGlb for the capture and moved on, with @Unsigned (as written) never checked against T's declared @Signed bound anywhere.

Root cause

BaseTypeValidator.visitParameterizedType (framework/src/main/java/org/checkerframework/common/basetype/BaseTypeValidator.java) capture-converts the type arguments first, then checks only the resulting captures (BaseTypeVisitor.checkTypeArguments). By construction, a capture's upper bound is already the GLB of the wildcard's bound and the type parameter's declared bound, so this check is a tautology for every wildcard argument — a written bound that's incompatible with the declared bound has already been silently narrowed (or, for incomparable bounds, silently replaced by their meet) before anything looks at it. BaseTypeValidator.visitWildcard only checks the wildcard's own two bounds against each other, never against the type parameter's declaration; checkCapturedWildcardBounds checks the opposite (near-tautological) direction.

Scope note (important)

An earlier, broader version of this observation used comparable qualifier pairs (e.g. @Nullable/@NonNull) as its motivating example. That version doesn't hold up: for comparable pairs, javac itself silently accepts and narrows an analogous base-type wildcard (Lib<? extends Object> for T extends Number compiles clean, narrowing the capture to Number with no diagnostic) — so CF's identical behavior for @Nullable/@NonNull matches javac, it doesn't diverge from it. A fix must specifically detect incomparability between the wildcard's written bound and the type parameter's declared bound (mirroring what javac's own check actually tests), not merely "written bound differs from / is wider than declared bound" — the latter would flood ordinary, correct code with false positives on completely unremarkable wildcard narrowing.

Suggested direction (not attempted — filed for future work, not being pursued right now)

In BaseTypeValidator.visitParameterizedType, before/alongside the existing capture-based check, walk the uncaptured type arguments and, for each AnnotatedWildcardType with a source-written extends bound, check whether that bound and the corresponding type parameter's declared upper bound are comparable at all (rather than checking subtyping in either particular direction). Only a source-written bound should be checked — a wildcard's defaulted/implicit bound is routinely "incomparable-looking" without the user having written anything, and flagging those would be a flood; distinguishing written from defaulted needs the ParameterizedTypeTree, not just the AnnotatedTypeMirror. ? super X has a symmetric question against the lower bound. Blast radius should be measured (./gradlew alltests plus any downstream checkers) before deciding on a default; a -Alint opt-in may be the right first step, matching how -AcheckCastElementType gates its own extra strictness.

Not being worked on right now — filing for tracking.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions