Report a generic nullability mismatch as a marked diff of the two types - #1828
Report a generic nullability mismatch as a marked diff of the two types#1828vlsi wants to merge 3 commits into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: 📒 Files selected for processing (4)
Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review. WalkthroughNullAway now explains implicit wildcard upper bounds in JSpecify generic conversion diagnostics. It reports provenance for captured, unbounded, and super-bounded wildcards. It suggests explicit Suggested reviewers: Priority: ➖ Normal — Impact reflects medium issue severity. Severity of issue fixed: Medium Merge Risk: ⚪ Minimal · up to This change improves JSpecify wildcard incompatibility diagnostics and adds explicit-bound suggestions where applicable. The documented behavior is covered by targeted tests, with no concrete current-head merge risk identified. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 56.25% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 3 files. (1 skipped: 1 unsupported.)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
daa16a1 to
60b00c5
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #1828 +/- ##
============================================
- Coverage 87.74% 87.65% -0.09%
- Complexity 3449 3527 +78
============================================
Files 110 110
Lines 11461 11690 +229
Branches 2353 2413 +60
============================================
+ Hits 10056 10247 +191
- Misses 648 661 +13
- Partials 757 782 +25 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
60b00c5 to
13c891a
Compare
13c891a to
0294e03
Compare
6adae68 to
da87527
Compare
…ard is required A Box<T> declared with T extends @nullable Object was accepted where a Box<? extends Object> was required, so a null could reach a position that rejects one. A wildcard in that position is judged by its effective upper bound; a type variable was judged by the use site, which carries no nullness of its own, and every such call passed. The defect has no diagnostic to grep for: the symptom is the silence. CheckIdenticalNullabilityVisitor now runs the actual type argument of an extends-bound containment through typeComparedForNullness, which takes a type variable carrying no nullness annotation as its declared upper bound and every other type as written. A requirement that names a type variable is not read that way. ? extends T admits whatever T is instantiated as, which is not what T's declared bound admits, so no bound stands in for it and the two types are compared as written. That is what keeps Box<T> and Box<S extends T> assignable to a Box<? extends T>, and substituting the bound there instead would silence the report that Box<@nullable T> into Box<? extends T> draws today. Whether either side may be null is then decided separately, by admitsNull, so a parametric requirement is not a hole: a Box<S> declared with S extends @nullable T holds a null where a Box<? extends T> may not when T itself cannot be null, however the two names relate, and that is now reported. So is a Box<T> passed where a Box<? extends @nonnull T> is required. A captured actual is the exception, and the reason is capture conversion: javac's capture of ? extends @nonnull V prints as capture of ? extends V, so the actual carries no nullness of its own to compare and is taken as written. A use that carries its own nullness is compared as written, since @nullable T and @nonnull T each say what the declaration alone does not. GenericsUtils.typeVariableUpperBound holds the rule that decides whether a bound admits null: an explicit @nullable, a library model that overrides the bound, or a declaration in unannotated code. It is factored out of wildcardUpperBound so the two cannot drift apart. admitsNull asks upperBoundIsNullable directly rather than through it, since the two agree and the boolean needs no annotated type built to carry it. This reports code that compiled clean before. Nothing in the test suite and nothing in NullAway's own sources was relying on the silence. Assisted-by: Claude Code (claude-opus-5)
da87527 to
07f667d
Compare
Passing a List<?> to Collection.addAll in @NullMarked code was reported as "incompatible types: List<?> cannot be converted to Collection<? extends Object> (List<?> is a subtype of Collection<?>)". The report is correct: an unbounded wildcard takes the upper bound of the type variable it instantiates, and E of List has a @nullable Object bound in the JSpecify JDK model, while ? extends Object requires a non-null element. Nothing in the message said so, and the parenthetical read as a contradiction (uber#1822). The shape was the wider problem: two types printed in full, one difference named out of however many failed, and nothing to diff them by once the difference is a @nullable three levels down. The message is now built from the positions where the two types differ in nullness, which a traversal of the target and the source records as the smallest pair of nodes that still differs. It carries a summary, the two types with a caret under each position at fault, an entry per position naming it in words, and the types to write instead. The summary opens "incompatible nullability:", so the leading text of every generic nullness diagnostic changes, and the two types move into found and required below it. It states the two nodes rather than calling one unassignable to the other, because the difference runs both ways; in the direction that would read as a false subtype claim it says first that a type argument must match exactly. A caret marks the smallest node that differs, in both types. Which node of the source as written it belongs under has two answers, since the comparison views that source as the target's supertype: where the view is the same class, the position is the same position, and where it is another class, only a node the substitution carried over rather than rebuilt can be pointed at. Where neither answers, the view is printed as "found as" and carries the carets. A path line names the position in words and wraps at its separators, and a note says where a wildcard takes an implicit bound from, naming the type parameter where that parameter is one the message prints and stating the bound alone where it is not. Two repairs are offered, and each is checked before it is printed: the candidate goes to the predicate that rejected the assignment, it has to print as Java a reader could write, and it has to survive printing with its type-use annotations. They differ in what they presume. Writing an explicit bound on a wildcard the reader already wrote repairs that wildcard, so it is offered wherever it works. Changing the required type is a claim about which side of a contract is wrong, so it is offered only where that type is written at the diagnostic itself, and it only ever adds @nullable. Where the traversal locates no difference, which is what a ? super containment failure looks like from here, the message falls back to the two types and the subtype relation between them. Fixes uber#1822 Assisted-by: Claude Code (claude-fable-5-1) Assisted-by: Claude Code (claude-opus-5)
The check rejects a Box<T> declared with T extends @nullable Object where a Box<? extends Object> is required, and the message said only that the two types were incompatible: the traversal that finds the differing positions judged the type variable by its use site, found no difference, and left the reporter with the fallback text. It now takes the same step the check takes, so the reader gets the position and the reason: the caret stays on the T they wrote, found names the bound the variable was declared with, and a note says the nullness comes from there. A use that states its own nullness keeps it, and gets no note, since the printed @nullable T already says what the note would. A captured wildcard is a Type.TypeVar as well, so the note is reached only after the wildcard arms have had their say, and a capture keeps the provenance note it had. Assisted-by: Claude Code (claude-opus-5)
07f667d to
f3ecbb5
Compare
Why
In JSpecify mode, passing a
List<?>toCollection.addAllin@NullMarkedcode was reported asincompatible types: List<?> cannot be converted to Collection<? extends Object> (List<?> is a subtype of Collection<?>). The report is correct: an unbounded wildcard takes the upper bound of the type variable it instantiates, andEofListhas a@Nullable Objectbound in the JSpecify JDK model, while? extends Objectrequires a non-null element. Nothing in the message said so, and the parenthetical read as a contradiction (#1822).The shape of the message was the wider problem. It printed the two types in full and left the reader to diff two strings, which is hopeless once the difference is a
@Nullablethree levels down, and it named at most one position out of however many failed.What
The diagnostic is rebuilt around the positions where the two types differ in nullness. A parallel traversal of the target and the source records every such position, as the smallest pair of nodes that still differs, and the message is rendered from that list: a summary, both types with a caret under each position at fault, one entry per position naming it in words, and the types to write instead.
The summary line names the difference, not the types. It begins
incompatible nullability:, so the leading text of every generic nullness diagnostic changes; the two types move into thefound:andrequired:lines below. With one difference the summary states it (found @Nullable Object, required Object); with several it states how many. It saysfound/requiredrather than calling one unassignable to the other because the difference runs both ways: a type argument the source states outright has to match, soList<String>fails againstList<@Nullable String>as surely as the reverse. That direction is the one a reader can misread as a false subtype claim, since everyStringis a@Nullable String, so there the line opensa type argument must match exactly.The caret marks the smallest node that still differs,
@Nullable Stringrather than theCollection<@Nullable String>around it, and it is drawn on both types. Where the position is one the source does not print, as with the bound an unbounded wildcard leaves implicit, the caret stays on the wildcard and anote:line says which type parameter the bound is inherited from. That note is what #1822 was missing. Which node of the source as written a caret belongs under has two answers, since the comparison views the source as the target's supertype: where that view is the same class, the position is the same position, and where it is another class, only a node the substitution carried over rather than rebuilt can be pointed at. Where neither answers, the view is printed asfound asand carries the carets, so a caret always marks a type the check actually compared. A note there states the bound and stops: naming the type parameter it came from would mean naming a declaration the message does not print, and answering in coordinates of its own, sopath: Base type argument Bwould stand besidetype parameter S of Suband read as a contradiction. Where the wildcard does stand at a position of the type the reader wrote, the note still names the parameter, which is what makes the message in #1822 readable.A
path:line names the position in words, asMap type argument V -> List type argument E -> wildcard upper bound -> Collection type argument E, and wraps at its separators for a deeply nested type. It is the channel that survives when the caret does not: a message pasted through a chat client, re-indented by a log collector, or read by a tool that never had the columns.Every failing position is reported, numbered where there is more than one, up to five, after which the message says how many are left. The old message named one and left the rest for the next compile. A single mismatch and a numbered entry carry the same fields in the same order, so a reader who has learned one message has learned the other.
Two repairs are offered, and each is checked before it is offered. The source type rewritten with explicit wildcard bounds, as before; and the required type carrying the source's nullness, which is new. The second is a claim about which side of a contract is wrong, so it comes with two limits: it appears only where the required type is written at the diagnostic itself, as it is in a variable declaration, and it only ever adds
@Nullable. At a call the required type comes from a signature somewhere else, and proposing an edit to it would be a guess at what that signature is for; taking a@Nullableaway would be a guess of the same kind about a contract the author stated on purpose. A candidate is also withheld where printing it would drop a type-use annotation, since the printer shows@Nullableand nothing else: that costs a described type nothing and costs a pasted one whatever was left out. Both candidates are passed tosubtypeParameterNullability, the predicate that rejected the assignment, and both must be denotable, since a type the check accepts may still printcapture of ?, which no declaration may contain.The format is written down where the next reporter will look. The rendering lives in
NullabilityMismatchMessage, whose class comment states the contract a message follows: the four words it uses, the rule for the first line, when a caret is drawn and when it is declined, and what each of the two repairs presumes. A reporter that adopts the format inherits those rules; one that builds its own message text is not bound by them and reads worse for it.GenericsChecksends the change 161 lines shorter than it started, having lost the old message machinery and gained none of the new.Where the traversal locates no difference, which is what a
? supercontainment failure looks like from here, the message falls back toincompatible types: A cannot be converted to Bwith the subtype relation, as before.Two mechanisms are worth naming for review. The target is traversed as the reader wrote it, so a position in it is a path, and the same path tells the printer where to draw the caret; the source is viewed as the target's supertype at every level, which is a type the reader never wrote, so the source node is carried by identity and located in the printed source afterwards. And javac's
WildcardType.boundis overwritten by each substitution duringasSuper, so the type parameter named in a note is found by the wildcard's position in the source rather than through that field, which after viewingList<?>as aCollectionnamesEof an intermediate supertype.A type variable is explained rather than printed twice. #1834 makes the check reject a
Box<T>declared withT extends @Nullable Objectwhere aBox<? extends Object>is required. The traversal that finds the differing positions judged such a variable by its use site, found no difference, and left the reporter with the fallback text, so the reader was told only that two types were incompatible. It now takes the same step the check takes: the caret stays on theTthey wrote,foundnames the bound the variable was declared with, and a note says the nullness comes from there. A use that states its own nullness keeps it and gets no note, since the printed@Nullable Talready says what the note would.Examples
The issue's own case, where writing the bound out repairs the assignment:
A difference nested four levels down, where the caret does the work no prose can:
Two positions at fault, one of them a concrete type argument that no wildcard bound explains:
The source viewed as the supertype it was compared against, where the type as written shows nothing:
A variable declaration, where the required type is written at the diagnostic and the reader can change either side:
Verification
WildcardTestsholds 118 tests, 53 of them added here, covering the rendering and both repair decisions: the caret at the smallest differing node, in a nested type argument, an array element and an enclosing type; a source instance that fills two type arguments, and one the comparison reached in a view of the source; the numbering, and the cut-off at five with the count of the rest; a source repair withdrawn for an explicit-bound mismatch, for bounds differing below the top level, for a concrete type argument, for an unrewritable? super, for a candidate holding a capture, and for one whose printed form would drop a type-use annotation; a required-type repair offered at a variable declaration and withheld at a call, in the direction that would drop a@Nullable, and where printing it would drop an annotation. Nine tests declare the type a repair suggests in the same source and assert it draws no diagnostic, one for each shape of repair the message offers; the tests that pin a repair only as message text rely on that coverage rather than repeating it. Every test named for the absence of a repair asserts that absence rather than stopping short of it.Thirteen deliberate breakages were run against the suite, each failing the test named for it:
aTypeVariableWhoseBoundAdmitsNullFailsANonNullWildcardRequirementnoSuggestionWhenAnExplicitBoundMismatchRemainsunboundedWildcardErrorMessageSuggestsExplicitBoundnoSuggestionWhenBoundsDifferBelowTheTopLevelnoSuggestedRequiredTypeAtACallSiteinvariantTypeArgumentIsReportedWhenTheSourceIsLessNullableThanRequired@NullableinvariantTypeArgumentIsReportedWhenTheSourceIsLessNullableThanRequirednoSuggestedRequiredTypeWhereItsPrintedFormWouldDropAnnotationsnoSourceRepairWhereItsPrintedFormWouldDropAnnotationstheNoteStatesTheBoundAndNamesNoTypeParameterAcrossAViewtheNoteStatesTheBoundAndNamesNoTypeParameterAcrossAView@Nullablewritten at the use sitenoInheritedBoundNoteWhereTheUseStatesItsOwnNullnessnoInheritedBoundNoteWhereTheUseStatesItsOwnNullnessAbout a hundred expectations across the JSpecify suites changed with the message text;
:nullaway:testand:nullaway:buildWithNullAwaypass.Scope
This branch is stacked on #1834, which makes the check reject the type-variable case this message then explains. Until that merges, the diff here carries its commit as well; the two touch different files apart from the tests they share.
The
incompatible typesmessage is shared by the assignment, return, and parameter reporters, and all three carry the new rendering. The ternary, method-reference, and override reporters build their own messages and are unchanged, so aList<?>in one of those positions still gets the old text; those reporters have their own mechanics and their own diagnostics, and a separate pull request will bring them across.The message text is not API, but anything matching on it, such as a golden-output test in a downstream build, sees
incompatible nullability:where it sawincompatible types:.Three branches have no test because no input was found that reaches them: the class-type requirement on the bound to write out, in
rewriteWithExplicitBounds; three of the four node kindsisDenotablerejects; and the root position inmismatchDetail, which would printpath: the type itself, since a nullness difference at the root of the two types is reported by another check before this one is reached. All three are recorded in the code rather than claimed as covered.Fixes #1822