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.
Summary
When a wildcard's
extendsbound 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 whenT 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/BaseTypeVisitorhas no equivalent check in the qualifier dimension.Repro (Signedness Checker's
Signed/Unsigneddiamond lattice)SignedandUnsignedare both direct subtypes ofUnknownSignedness, but incomparable with each other;SignednessGlbis their actual meet (@SubtypeOf({Unsigned, Signed})).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:confirming CF silently computed GLB(
Signed,Unsigned) =SignednessGlbfor the capture and moved on, with@Unsigned(as written) never checked againstT's declared@Signedbound 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.visitWildcardonly checks the wildcard's own two bounds against each other, never against the type parameter's declaration;checkCapturedWildcardBoundschecks 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,javacitself silently accepts and narrows an analogous base-type wildcard (Lib<? extends Object>forT extends Numbercompiles clean, narrowing the capture toNumberwith no diagnostic) — so CF's identical behavior for@Nullable/@NonNullmatchesjavac, 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 whatjavac'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 eachAnnotatedWildcardTypewith a source-writtenextendsbound, 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 theParameterizedTypeTree, not just theAnnotatedTypeMirror.? super Xhas a symmetric question against the lower bound. Blast radius should be measured (./gradlew alltestsplus any downstream checkers) before deciding on a default; a-Alintopt-in may be the right first step, matching how-AcheckCastElementTypegates its own extra strictness.Not being worked on right now — filing for tracking.