Skip to content

[CALCITE-7736] Replace the Checker Framework with NullAway and JSpecify - #5213

Open
vlsi wants to merge 64 commits into
apache:mainfrom
vlsi:CALCITE-7736
Open

vlsi wants to merge 64 commits into
apache:mainfrom
vlsi:CALCITE-7736

Conversation

@vlsi

@vlsi vlsi commented Aug 23, 2026 •

Copy link
Copy Markdown
Contributor

Jira: CALCITE-7736

The Checker Framework verifies Calcite's nullness through a Gradle plugin of its own, 48 .astub files that patch the nullness of the JDK and of third-party libraries, and two dedicated CI jobs that cover :linq4j, :core, and :server. NullAway runs as an Error Prone check, ships nullness models for the JDK and common libraries, and reads JSpecify annotations, which other checkers read too.

What changes

  • NullAway 0.14.2 on Error Prone 2.50.0 verifies nullness, in JSpecify mode with generics support and CheckContracts. It covers the main code of every module that has code, 24 projects instead of 3, :annotations included. Test code is not verified.
  • Error Prone moves from 2.5.1 to 2.50.0. The checks added since 2.5.1 that report on today's code are disabled in the "Analyze issues, and enable the check" list of build.gradle.kts, to be enabled one at a time later. Every check that was active on main stays active; DoNotCall and ReturnValueIgnored report two places under 2.50.0, listed below.
  • The .astub files and both Checker Framework jobs are removed. ErrorProne and NullAway (JDK 25, latest Guava) is the only job that checks nullness, so a nullness error fails that job and not the test jobs. The stubs also described Avatica, Janino, and slf4j; NullAway treats those unannotated libraries as returning non-null.
  • @Nullable and @NonNull move from org.checkerframework.checker.nullness.qual to org.jspecify.annotations. Both are TYPE_USE, so no annotation changes position. Autostyle rewrites the old imports from now on.
  • Packages declare @NullMarked instead of @DefaultQualifier, and NullAway's RequireExplicitNullMarking check fails a class that no @NullMarked covers.
  • JSpecify defines nothing beyond those annotations, so @MonotonicNonNull, @RequiresNonNull, @EnsuresNonNull, @EnsuresNonNullIf, and @Contract are declared in a new calcite-annotations module, package org.apache.calcite.annotations, which depends on jspecify only. NullAway matches them by simple name, so Calcite takes no dependency on the checker.
  • @PolyNull on a generic method becomes the type variable itself: <T extends @Nullable Object> T first(@Nullable T v0, T v1) returns a non-null value for a non-null default to javac, NullAway, and Kotlin alike. This covers Util.first, ExtendedEnumerable.aggregate with a seed, and the six CalciteConnectionConfig plugin getters, which take Class<@NonNull T> so that a null default still infers. Elsewhere @PolyNull becomes @Nullable with a @Contract clause such as "!null, _ -> !null", 94 of them, and NullAway checks each clause against the method body. A receiver parameter, a varargs method, and a type argument cannot carry a clause, so they keep a plain @Nullable.
  • An artifact transform removes ElementType.MODULE from the @Target of JSpecify's @NullMarked before the jar reaches the compile classpath. Without it javac warns unknown enum constant ElementType.MODULE, and -Werror fails the build (A @NullMarked alternative without @Target(MODULE) jspecify/jspecify#795). javac 25 with --release 8 still warns, so the transform stays after [CALCITE-4553] Use Gradle toolchain to compile Calcite with JDK 25 and --release 8 #5278.

The part worth reviewing

The two tools read an unbounded type parameter differently. Under the Checker Framework, <T> means <T extends @Nullable Object>; under JSpecify and @NullMarked, it means a non-null Object. Calcite relied on the first reading: SqlShuttle is a SqlVisitor<@Nullable SqlNode>, and it is passed to SqlNode.accept(SqlVisitor<R>). The commit "Give type parameters the nullable bounds the Checker Framework inferred" writes the nullable bound out at 61 declarations, among them the Rex and Sql visitors, Pair, PairList, Enumerable, and the linq4j Function types. The erasure is unchanged, so the change is binary compatible.

The commits after it fix what NullAway reported, one area per commit, and the last ones add the modules to nullawayProjects one per commit. The rename commit, "Move the Checker Framework annotations to JSpecify", touches 1273 files and is worth skimming rather than reading. The last commits of the branch answer a review of the earlier ones: they restore what NullAway did not need, keep @PolyNull semantics for the generic methods, and simplify workarounds.

What the caller sees

Code that worked on main and fails or behaves differently with this change:

  • SqlOrderBy.setOperand(0, null) and setOperand(1, null) throw NullPointerException (operand, orderList), as SqlSelect.setOperand already does for its required operands.
  • TimeExtractionFunction.translateCastToTimeExtract(rexNode, null) throws NullPointerException. The parameter was already non-null by default on main; only a defensive check went.
  • PigTable.scan throws UnsupportedOperationException: PigTable cannot be scanned instead of returning null, which every caller dereferenced.
  • ChainedSqlOperatorTable.add (deprecated) throws UnsupportedOperationException: ChainedSqlOperatorTable is immutable; use SqlOperatorTables.chain where it threw a message-less UnsupportedOperationException from ImmutableList.add.
  • Schema factories check required operands with requireNonNull, so a missing operand fails with NullPointerException naming it (host for Cassandra, database for MongoDB, sqlFilePath and ibdDataFileBasePath for InnoDB, locatorHost and locatorPort for Geode) instead of IllegalArgumentException, NumberFormatException, or a wrapped RuntimeException. A model that worked before still works.

Defects fixed along the way, each needed for NullAway to accept the code:

  • A no-argument lambda called through FunctionExpression.getFunction() no longer fails with NullPointerException (the proxy hands it a null args array); ExpressionTest.testLambdaWithoutParametersViaGetFunction is new.
  • FilterProjectTransposeRule uses the default distribution when the input lacks the distribution trait, where it failed with NullPointerException in RelDistributionTraitDef.canonize.
  • DruidQuery.isValidLeafCast and DruidTable.isValidParentKind return false for a type without a family and for a null parent, where they threw NullPointerException.

Not changed:

What downstream users see

  • Every published module declares org.jspecify:jspecify 1.0.1 as an API dependency, and calcite-core and calcite-linq4j also declare the new calcite-annotations. Before, 12 modules declared org.checkerframework:checker-qual, two of them as an implementation dependency, and the other modules got it through calcite-core or calcite-linq4j. Code that imports org.checkerframework annotations and gets checker-qual only through Calcite stops compiling until it declares that dependency itself.
  • Tools that read nullness annotations from Calcite's jars see JSpecify annotations and @NullMarked packages. Kotlin 2.x reads JSpecify strictly by default: a parameter without @Nullable no longer accepts null, and a method whose @PolyNull became @Nullable plus @Contract returns a nullable type, because Kotlin does not read @Contract. That applies to the 94 non-generic methods, among them RexShuttle.apply, SqlUtil.stripAs, RelOptUtil.toString(RelNode, SqlExplainLevel, boolean), RexLiteral.fromJdbcString, NumberUtil.add, and most of SqlFunctions; the generic ones keep a non-null result for a non-null argument. Kotlin users can turn these errors into warnings with -Xjspecify-annotations=warn.
  • ExtendedEnumerable.correlateJoin takes Function2<TSource, ? super @Nullable TInner, TResult>, so a class that overrides it with the old parameter type no longer compiles; callers are not affected. JsonFunctions.jsonObjectAggAdd and jsonArrayAggAdd take Map<String, @Nullable Object> and List<@Nullable Object> instead of raw types.
  • Method signatures in the bytecode are unchanged.

Workarounds for NullAway 0.14.2 and the MongoDB driver

  • NullAway 0.14.2 crashes with a NullPointerException in TypeSubstitutionUtils on sarg.rangeSet.asRanges() read through Sarg<?> in the Elasticsearch PredicateAnalyzer, because Guava declares Range<C extends Comparable> with a raw bound. Skip nested annotation restoration from raw source types uber/NullAway#1898 fixes it after 0.14.2; until a release has it, PredicateAnalyzer reads the ranges through a generic helper.
  • :mongodb declares compileOnly("com.google.code.findbugs:jsr305"). The driver's com.mongodb.lang.Nullable carries @javax.annotation.Nonnull(when = When.MAYBE), and the driver's POM does not declare jsr305. With javac 25 and NullAway, javac reports unknown enum constant When.MAYBE, which -Werror turns into an error; javac 21 with NullAway and javac 25 without it compile cleanly. The driver's move to JSpecify is JAVA-5938, planned for a major release.

Verification

./gradlew -PenableErrorprone classes jmhClasses

This is what the nullness job runs. It compiles with the JDK 25 toolchain that #5278 introduced; Gradle 8.14 cannot run on JDK 25, so the CI job runs Gradle on JDK 21. It passes in CI, as do the test jobs on Linux (JDK 8, 11, 17, 21, 24), Windows (JDK 8, 17, 21), and macOS (JDK 21); the bytecodeCheck failures on javac 8 and 11 are gone now that everything compiles with javac 25.

PigRelBuilderTest in :piglet is new and fails on the earlier head of this branch with NullPointerException: pigOp.

The Kotlin claim was checked with kotlinc 2.4.20 against the compiled classes of :core and :linq4j: val s: String = Util.first(x, ""), config.parserFactory(SqlParserImplFactory::class.java, default) assigned to a non-null type, and aggregate(0) { a, b -> a + b } returning Int compile on this head and fail with return type mismatch: expected 'String', actual 'String?' and two more such errors on 5d171ba.

🤖 Generated with Claude Code

*/
public void request(QueryType queryType, String data, Sink sink,
List<String> fieldNames, List<ColumnMetaData.Rep> fieldTypes,
List<String> fieldNames, List<ColumnMetaData.@Nullable Rep> fieldTypes,

@vlsi vlsi Aug 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might better be List<? extends ColumnMetaData.@Nullable Rep>, however, AFAIK it would change public API signature, so it would not be 100% backward compatible

Comment thread testkit/src/main/java/org/apache/calcite/sql/test/SqlTests.java Fixed
@vlsi
vlsi force-pushed the CALCITE-7736 branch 3 times, most recently from 5df3a4d to a8777a6 Compare August 29, 2026 05:54
@snuyanzin

snuyanzin commented Aug 29, 2026 •

Copy link
Copy Markdown
Contributor

nit: Apache recommends using Generated-by: rather than Co-authored-by: as mentioned in Apache website https://www.apache.org/legal/generative-tooling.html

@vlsi

vlsi commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

@snuyanzin , thanks for the reference. I've filed #5230 for it.

@sonarqubecloud

Copy link
Copy Markdown

vlsi added a commit to vlsi/NullAway that referenced this pull request Sep 3, 2026
…ecognize

JSpecify defines the locations where `@Nullable` and `@NonNull` carry
meaning and states that an annotation anywhere else has none. Nothing
reports one. An author who writes `@Nullable int count` or
`List<@nullable ?> values` gets no diagnostic and a false sense of safety,
and the specification asks source-analysis tools to offer exactly this
diagnostic.

Error Prone's own checks reach 4 of the 17 locations the specification
lists: NullablePrimitive, NullableWildcard, NullableTypeParameter and
NullableOnContainingClass. Measured on the Apache Calcite branch that
moves from the Checker Framework to NullAway and JSpecify
(apache/calcite#5213), those four reach none of the 161 annotations in
unrecognized locations that branch carries, because all 161 sit on the
root type of a local variable or of a cast.

JSpecifyUnrecognizedAnnotationLocation reports those 17 locations and two
more the specification's catch-all reaches: a constructor's result type
and a method reference's root type. One check rather than a set of
per-construct checks, because the rule is about position: everything not
listed as recognized is unrecognized. That is what separates
`(List<@nullable String>) o`, which is clean, from `(@nullable String) o`,
which is not, and each per-construct check would have to re-derive that a
type argument inside an unrecognized root is still recognized. One check
is also one severity and one `@SuppressWarnings` name.

It reads org.jspecify.annotations.Nullable and .NonNull by qualified name
and no other annotation. Reading any annotation named `Nullable` was
rejected: the Checker Framework reads its own on a local variable's and a
cast's root type, and IntelliJ reads JetBrains' on a local, so telling
those users the annotation has no meaning where their own tool reads it
would be wrong.

The check ships at SUGGESTION and reports nothing there, not even a note,
so upgrading NullAway without asking for it changes nothing;
-Xep:JSpecifyUnrecognizedAnnotationLocation:WARN or :ERROR turns it on.
Error Prone cannot ship a plugin check off outright
(google/error-prone#5387). -XepOpt:...:CheckLocalVariableRootType=false
silences the one location a codebase arriving from JetBrains' annotations
carries in bulk; IntelliJ added the same switch in IDEA-374631. It is one
key per location rather than one list, because ErrorProneFlags holds one
value per key and two convention plugins appending to a shared list would
leave whichever ran last, silently.

Fixes move the annotation to a wildcard or type parameter bound, onto the
last name of a qualified type, and after an array's element type, so
`@Nullable Bounds.Inner` and `@Nullable String[]` become
`Bounds.@nullable Inner` and `String @nullable []` rather than losing it.
They remove it where no single recognized form expresses the intent.

The changelog entry is one line, matching the house format. Note that this
repository has never carried an `Unreleased` section: every one of the
twelve commits that touched CHANGELOG.md is a dedicated "Release notes for
X" pull request, so entries are curated at release rather than written per
change. Fold the section into the release notes or drop it if that flow is
preferred. What a reader needs beyond the check's name -- that it ships at
SUGGESTION, reports nothing there, and is raised with
-Xep:JSpecifyUnrecognizedAnnotationLocation:WARN -- belongs in the release
paragraph above the bullet list, which is written at release time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vlsi added a commit to vlsi/NullAway that referenced this pull request Sep 3, 2026
…ecognize

JSpecify defines the locations where `@Nullable` and `@NonNull` carry
meaning and states that an annotation anywhere else has none. Nothing
reports one. An author who writes `@Nullable int count` or
`List<@nullable ?> values` gets no diagnostic and a false sense of safety,
and the specification asks source-analysis tools to offer exactly this
diagnostic.

Error Prone's own checks reach 4 of the 17 locations the specification
lists: NullablePrimitive, NullableWildcard, NullableTypeParameter and
NullableOnContainingClass. Measured on the Apache Calcite branch that
moves from the Checker Framework to NullAway and JSpecify
(apache/calcite#5213), those four reach none of the 161 annotations in
unrecognized locations that branch carries, because all 161 sit on the
root type of a local variable or of a cast.

JSpecifyUnrecognizedAnnotationLocation reports those 17 locations and two
more the specification's catch-all reaches: a constructor's result type
and a method reference's root type. One check rather than a set of
per-construct checks, because the rule is about position: everything not
listed as recognized is unrecognized. That is what separates
`(List<@nullable String>) o`, which is clean, from `(@nullable String) o`,
which is not, and each per-construct check would have to re-derive that a
type argument inside an unrecognized root is still recognized. One check
is also one severity and one `@SuppressWarnings` name.

It reads org.jspecify.annotations.Nullable and .NonNull by qualified name
and no other annotation. Reading any annotation named `Nullable` was
rejected: the Checker Framework reads its own on a local variable's and a
cast's root type, and IntelliJ reads JetBrains' on a local, so telling
those users the annotation has no meaning where their own tool reads it
would be wrong.

The check ships at SUGGESTION and reports nothing there, not even a note,
so upgrading NullAway without asking for it changes nothing;
-Xep:JSpecifyUnrecognizedAnnotationLocation:WARN or :ERROR turns it on.
Error Prone cannot ship a plugin check off outright
(google/error-prone#5387). -XepOpt:...:CheckLocalVariableRootType=false
silences the one location a codebase arriving from JetBrains' annotations
carries in bulk; IntelliJ added the same switch in IDEA-374631. It is one
key per location rather than one list, because ErrorProneFlags holds one
value per key and two convention plugins appending to a shared list would
leave whichever ran last, silently.

Fixes move the annotation to a wildcard or type parameter bound, onto the
last name of a qualified type, and after an array's element type, so
`@Nullable Bounds.Inner` and `@Nullable String[]` become
`Bounds.@nullable Inner` and `String @nullable []` rather than losing it.
They remove it where no single recognized form expresses the intent.

The changelog entry is one line, matching the house format. Note that this
repository has never carried an `Unreleased` section: every one of the
twelve commits that touched CHANGELOG.md is a dedicated "Release notes for
X" pull request, so entries are curated at release rather than written per
change. Fold the section into the release notes or drop it if that flow is
preferred. What a reader needs beyond the check's name -- that it ships at
SUGGESTION, reports nothing there, and is raised with
-Xep:JSpecifyUnrecognizedAnnotationLocation:WARN -- belongs in the release
paragraph above the bullet list, which is written at release time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vlsi added a commit to vlsi/NullAway that referenced this pull request Sep 3, 2026
…ecognize

JSpecify defines the locations where `@Nullable` and `@NonNull` carry
meaning and states that an annotation anywhere else has none. Nothing
reports one. An author who writes `@Nullable int count` or
`List<@nullable ?> values` gets no diagnostic and a false sense of safety,
and the specification asks source-analysis tools to offer exactly this
diagnostic.

Error Prone's own checks reach 4 of the 17 locations the specification
lists: NullablePrimitive, NullableWildcard, NullableTypeParameter and
NullableOnContainingClass. Measured on the Apache Calcite branch that
moves from the Checker Framework to NullAway and JSpecify
(apache/calcite#5213), those four reach none of the 161 annotations in
unrecognized locations that branch carries, because all 161 sit on the
root type of a local variable or of a cast.

JSpecifyUnrecognizedAnnotationLocation reports those 17 locations and two
more the specification's catch-all reaches: a constructor's result type
and a method reference's root type. One check rather than a set of
per-construct checks, because the rule is about position: everything not
listed as recognized is unrecognized. That is what separates
`(List<@nullable String>) o`, which is clean, from `(@nullable String) o`,
which is not, and each per-construct check would have to re-derive that a
type argument inside an unrecognized root is still recognized. One check
is also one severity and one `@SuppressWarnings` name.

It reads org.jspecify.annotations.Nullable and .NonNull by qualified name
and no other annotation. Reading any annotation named `Nullable` was
rejected: the Checker Framework reads its own on a local variable's and a
cast's root type, and IntelliJ reads JetBrains' on a local, so telling
those users the annotation has no meaning where their own tool reads it
would be wrong.

The check ships at SUGGESTION and reports nothing there, not even a note,
so upgrading NullAway without asking for it changes nothing;
-Xep:JSpecifyUnrecognizedAnnotationLocation:WARN or :ERROR turns it on.
Error Prone cannot ship a plugin check off outright
(google/error-prone#5387). -XepOpt:...:CheckLocalVariableRootType=false
silences the one location a codebase arriving from JetBrains' annotations
carries in bulk; IntelliJ added the same switch in IDEA-374631. It is one
key per location rather than one list, because ErrorProneFlags holds one
value per key and two convention plugins appending to a shared list would
leave whichever ran last, silently.

Fixes move the annotation to a wildcard or type parameter bound, onto the
last name of a qualified type, and after an array's element type, so
`@Nullable Bounds.Inner` and `@Nullable String[]` become
`Bounds.@nullable Inner` and `String @nullable []` rather than losing it.
They remove it where no single recognized form expresses the intent.

The changelog entry is one line, matching the house format. Note that this
repository has never carried an `Unreleased` section: every one of the
twelve commits that touched CHANGELOG.md is a dedicated "Release notes for
X" pull request, so entries are curated at release rather than written per
change. Fold the section into the release notes or drop it if that flow is
preferred. What a reader needs beyond the check's name -- that it ships at
SUGGESTION, reports nothing there, and is raised with
-Xep:JSpecifyUnrecognizedAnnotationLocation:WARN -- belongs in the release
paragraph above the bullet list, which is written at release time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
msridhar pushed a commit to uber/NullAway that referenced this pull request Sep 4, 2026
…ecognize (#1787)

## Why

JSpecify defines the locations where `@Nullable` and `@NonNull` carry
meaning and states that an annotation anywhere else has none. `@Nullable
int count` and `List<@nullable ?> values` read as claims about nullness
and are not, so an author who writes one holds a false sense of safety
and nothing says otherwise. The specification asks source-analysis tools
to offer exactly this diagnostic.

Error Prone's existing checks reach 4 of the 17 locations the
specification lists. Compiling one file that holds an example of each
through the default checks reports `NullablePrimitive` on the primitive
type, `NullableWildcard` on the wildcard, `NullableTypeParameter` on the
type parameter, and `NullableOnContainingClass` on the outer type
qualifying an inner type. The 13 they do not reach are the local
variable root type, cast root type, `instanceof` type, pattern type,
object creation, array creation, supertype, thrown type, exception
parameter, receiver parameter, class declaration, enum constant, and
annotation interface member return.

Measured on a real migration: the Apache Calcite branch that replaces
the Checker Framework with NullAway and JSpecify
([apache/calcite#5213](apache/calcite#5213))
carries 161 annotations in unrecognized locations, all of them local
variable root types (141) and cast root types (20). The four existing
checks reach none of the 161. **40 of the 161 were introduced by the
migration branch itself**, established both by `git blame` against the
branch's commit range and by comparing each line against the merge base.
So the check catches the mistake while it is being made, not only the
backlog a project inherits. Every one carries a suggested fix:
`-XepPatchChecks:JSpecifyUnrecognizedAnnotationLocation` makes 161
replacements across 74 files and the rebuild reports zero.

## What

`JSpecifyUnrecognizedAnnotationLocation` reports the 17 unrecognized
locations the specification lists, and two more its catch-all reaches:
the result type of a constructor, which `NullableConstructor` already
reports, and the root type of a method reference, which nothing else
reaches.

It reads `org.jspecify.annotations.Nullable` and
`org.jspecify.annotations.NonNull` by their qualified names and no other
annotation. Reading any annotation named `Nullable` was rejected: the
tools that define the others read them in some of these very locations.
The Checker Framework reads its `@Nullable` on the root type of a local
variable and of a cast; IntelliJ reads JetBrains' on a local variable.
Telling those users the annotation has no meaning where their own tool
does read it would be wrong. Supporting another annotation is left to a
flag on this check.

It is one check rather than a documented set of per-construct checks
because JSpecify's rule is about position, not about constructs:
everything not listed as recognized is unrecognized. That is what
separates `(List<@nullable String>) o`, which is clean, from `(@nullable
String) o`, which is not, and `new ArrayList<@nullable String>()` from
`new @nullable ArrayList<String>()`. Each per-construct check has to
re-derive that a type argument inside an unrecognized root is still
recognized. One check is also one severity and one
`@SuppressWarnings("JSpecifyUnrecognizedAnnotationLocation")`, where a
documented set costs N flags and N suppression names and grows silently
as locations are covered.

The check ships at `SUGGESTION` and reports nothing there, not even a
note, so a user who upgrades NullAway without asking for it sees no
change. `-Xep:JSpecifyUnrecognizedAnnotationLocation:WARN` or `:ERROR`
turns it on. Error Prone cannot ship a plugin check turned off outright
([google/error-prone#5387](google/error-prone#5387)),
and this is how close to off a default can get.


`-XepOpt:JSpecifyUnrecognizedAnnotationLocation:CheckLocalVariableRootType=false`
silences the root type of a local variable, the one location a codebase
arriving from JetBrains' annotations carries in bulk; IntelliJ added the
same switch in
[IDEA-374631](https://youtrack.jetbrains.com/issue/IDEA-374631). It
reports by default, so that switching the check on reports every
location the specification lists.

Fixes move the annotation to the bound of a wildcard or type parameter,
onto the last name of a qualified type, and after the element type of an
array, so `@Nullable Bounds.Inner` and `@Nullable String[]` become
`Bounds.@nullable Inner` and `String @nullable []` rather than losing
the annotation. They remove it where no single recognized form expresses
the intent, such as a lower-bounded wildcard or an intersection bound,
and where a nullness annotation already stands at the destination.

Running the check over NullAway's own sources found one real defect,
fixed here: `GenericsTests.nestedGenericTypes` wrote `@Nullable
Wrapper<String>.Fn<String>`, which annotates the outer `Wrapper` rather
than the `Fn` the test means to make nullable. The JSpecify-correct
`Wrapper<String>.@nullable Fn<String>` passes every assertion that test
already made. One test source in `JSpecifyArrayTests` annotates an
unrecognized location on purpose and now says so with
`@SuppressWarnings`.

## Verification

66 tests in `JSpecifyUnrecognizedAnnotationLocationTest` establish one
diagnostic per location, that the recognized locations nested inside
unrecognized ones stay unreported, a fix per fix shape, and exact report
counts for the annotations two tree paths reach, which a `// BUG:`
marker cannot tell apart from one reported once;
`UnrecognizedAnnotationLocationInTestSourcesTest` (new) fails if the
harness stops running the check over the sources every other test
compiles.

## Scope

A construct the check cannot name is left alone rather than reported
under the specification's catch-all, so the failure mode is a missed
diagnostic rather than a wrong one.

A codebase still on the Checker Framework's or JetBrains' annotations
gets no diagnostic until it has switched. That is the narrowing working
as intended; the follow-up is a flag on this check that maps another
annotation onto JSpecify's.

The severity is per check rather than per location, so a build cannot
take `@Nullable Outer.Inner`, which contradicts a property the
specification calls intrinsic, as an error while leaving another
location a warning. `Description.Builder.overrideSeverity` would express
that and is `@RestrictedApi`: "Overriding the severity for individual
Descriptions causes any command line options to be ignored, which is
potentially very confusing." Measured: with it, a user who writes
`-Xep:JSpecifyUnrecognizedAnnotationLocation:WARN` gets errors anyway. A
build that wants the strict reading of the outer type has it from
`NullableOnContainingClass`, which reports it as an error today.

`CheckLocalVariableRootType` is the only location switch, and it is one
key per location rather than one list of locations because
`ErrorProneFlags` holds one value per key: two convention plugins each
appending to a shared list would leave whichever ran last, silently.

---------

Assisted-by: Claude Opus 5 <noreply@anthropic.com>
vlsi and others added 21 commits October 14, 2026 09:49
The file adapter now declares its package @NullMarked, and NullAway verifies
it. A CSV cell, a table name in a model, and a field configuration are all
absent-able, and the signatures now say so: field() reads a row whose cells may
be absent, and the row converters carry a nullable element type.

Two lazily populated fields were the reason for the remaining reports.
FileReader.getTable wrote its result into tableElement and returned nothing, so
no caller could see that the field was populated; it is now readTable, which
returns the element it read. The bad-source-column check in FileRowConverter
looked the heading up twice, once to validate and once to take the index, and
now does both at once.

A model that names no file for a CSV table, or no url for an HTML table, was
already a NullPointerException deeper in; requireNonNull names the missing
operand instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The model that the adapter's own test uses names neither bootstrap.servers nor
topic.name, because a table that injects its own consumer needs neither, so
both options are optional and KafkaTableOptions now says so. That reaches
KafkaRowConverter.rowDataType, whose topic name is absent for such a table;
neither implementation looks at it. The bootstrap servers are required on the
path that builds a consumer, and requireNonNull there names the missing operand
rather than letting the Kafka client report it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SparkValues read the rowType field, which AbstractRelNode keeps as a lazily
computed cache, where it meant the row type its constructor was given;
getRowType() is the accessor that always has one. EnumerableToSparkConverter
throws before it reaches its unfinished body, so the body is gone and the
comment that describes what it would generate stays.

RexToLixTranslator.translateCondition passes its correlates argument straight
to setCorrelates, which has always accepted null, so the parameter says so now.
That is what lets SparkCalc convert a program that has no correlates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call factory for Babel's CREATE TABLE takes the collection type out of a
symbol literal, and SqlLiteral.symbolValue returns null for a literal that
holds no symbol. The parser always writes one, so requireNonNull states that
rather than leaving the constructor to find out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Redis schema with no password is the ordinary case, and it reaches the pool
config through RedisConfig and RedisJedisManager, both of which say so now.
RedisSchema validated its operands through isEmptyObject, whose result NullAway
cannot connect back to the value, so the checks name the value they read and
read it once. RedisTable had a RedisEnumerator field that nothing ever read.

The anonymous Enumerable in RedisTable.scan is now a named inner class. NullAway
checks an anonymous class's overrides against the erased supertype, dropping the
@nullable on its type argument, so it reported the enumerator() override as a
nullability mismatch; a named subclass with the same type argument is accepted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The push-down rule builds a search string from whichever of the two projections
and the two row types the match happened to have, so those parameters are
optional and the signature says so. A literal that is neither numeric nor CHAR
yields no search text, which is how getFilter already reads the result.

SplunkResultEnumerator reads the CSV header in its constructor, and a header it
could not read leaves the field names absent; moveNext now stops instead of
dereferencing them. close() swallowed the NullPointerException it raised on a
null Closeable, and returns early instead.

When the caller wants a single field that the header does not have, the
enumerator yields a null element for every row. Its element type, and that of
SplunkConnection.getSearchResultEnumerator, is @nullable Object to say so, and
Linq4j.emptyEnumerator accepts a nullable element type, so the error path still
returns an empty enumerator of that type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A MongoDB document has no value for a field it does not carry, so a projection
of one field enumerates nulls, and the getter, the enumerator and the enumerable
that carry it now say so. That needed the element type of AbstractEnumerable to
admit null, which Enumerable and Queryable have admitted all along; the four
interfaces between them said otherwise, and now agree.

The filter translator builds its documents with JsonBuilder, whose maps and
lists hold absent values, and it passes a null operator to mean equality, which
translateOp2 has always read that way.

The two anonymous Enumerables in MongoTable are named classes, to avoid
uber/NullAway#1746.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Cassandra row has no value for a column it never set, so the enumerator and
the enumerable that carries it enumerate nulls. The tuple components a STRUCT
holds are already collected through requireNonNull, which is where the comment
saying null cannot appear inside a collection lives.

The enumerable is a named class rather than an anonymous one, to avoid
uber/NullAway#1746.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Pig rel nodes look down the tree for the table they act on, and a tree with
no table underneath returns none, which is what RelNode.getTable has always
said. PigToEnumerableConverter read the rowType field, AbstractRelNode's lazily
computed cache, where it meant this node's row type.

A model that names no file or no columns for a Pig table reached the File and
the array with a null; requireNonNull names the missing operand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An Arrow vector holds a null wherever the column has no value, which getValue
returns for a timestamp and the enumerator hands on, so the enumerator, the
enumerable and the query that builds it carry a nullable element type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An InnoDB row has no value for a column that holds none, which the enumerator
returns and the row array carries. The implementor and the internal expression
node are filled in as the translation proceeds, so their fields are marked
NullAway.Init rather than pretending a half-built object never exists. A model
that names no sql file or no data file path reached the schema with a null;
requireNonNull names the missing operand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Piglet keeps four maps from a relation to its alias and its Pig operator, and
looking a name up in them can miss, which the getters now say. Handler looked
its relations up the same way and pushed whatever came back, including nothing;
it now reports the unknown name instead of failing later in the builder.

PigTable.scan returned null rather than an enumerable, and nothing could have
used it; it throws. SqlUserDefinedFunction declared its operand type inference
non-null though SqlFunction below it has always accepted none, which is what
PigUserDefinedFunction passes.

PigRelBuilder.updateAlias takes a nullable operator and alias and records only
what it is given, so collect() still accepts an input that no Pig operator was
registered for, such as a plain scan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Geode entry has no value for a field it does not carry, which the converters
return and the enumerator hands on. The two schema factories read three
required operands out of the model and passed them on unchecked;
requireNonNull names whichever one is missing. The lazily built table maps and
the limit an implement context may not have say so.

The fourth operand, pdxSerializablePackagePath, is optional, and
createClientCache takes it as @nullable. It passes the operand to Geode as main
does, so a new client cache without it still fails in Geode's
ReflectionBasedAutoSerializer; that defect of main is outside this change.

GeodeTable and autodetectRelTypeFromRegion spell Region's wildcards as
`? extends Object`; see uber/NullAway#1732.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An Elasticsearch hit carries either _source or fields and never both, so each
of the two is absent half the time, and a document has no value for a field it
does not carry. That runs through the getters, the row converters and the
aggregation buckets, whose key is absent for a missing bucket.

The predicate analyzer reads a literal that may hold no value: a range bound
needs one and says so, while a term query writes whatever it got. A LIKE with
no escape, a projection that is not an item reference, and an expression the
analyzer cannot convert are all absent results the callers already handled.

The schema factory takes the credentials and the path prefix from the model,
where they are optional.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An os table function returns a row whose columns are absent wherever the
command printed nothing, which the enumerators and the line parsers now carry.
Ten of them built the same anonymous enumerable over an osquery table; they
share OsQueryEnumerable instead, which also avoids uber/NullAway#1746, as do
the named enumerator in the stdin function and the named line parser in vmstat.

os.name is absent on a JVM that does not publish it, and the table functions
switch on it. SqlShell prints a column that has no value, and looks a column
label up in a map that may not hold it. The Avatica server for Chinook holds
its server and its meta instance from the point it starts them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…verification

A model that names no directory for a CSV schema, and no file for a CSV table,
reached the File with a null; requireNonNull names the missing operand. The
filterable table pushed down a literal that may hold no value. The maze
enumerates without a solution set when the table is asked for the maze alone,
which is what the null argument means.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test fixtures carry absent values everywhere a SQL value can be null: the
expected result of checkScalar and checkString, the column a result set reads,
the origins and unique keys the metadata query may not know, and the alias a
Pig relation may not have. The mock catalog and the mock planner fill their
fields in as registration proceeds, so those are marked NullAway.Init.

Twelve classes took the @nullable argument that Object.equals has always
allowed. DiffRepository reads a DOM, where a node list yields no node past its
length and an attribute may carry no value; the reads that cannot miss say so
by name. The eight schemata packages that had no package-info.java now have
one.

BaseQueryable in :linq4j required a provider, though Smalls builds one that
overrides enumerator() and never asks a provider to execute it; getProvider
still refuses to return null. The anonymous enumerables in Smalls and
InfiniteOrdersTable stay anonymous: an explicit type argument or a static
helper avoids uber/NullAway#1746.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The benchmarks keep their sources in the jmh source set rather than a main one,
so the verification follows them there, and the errorprone CI job builds
jmhClasses alongside classes. A JMH state class is filled in by the @setup
methods and the @PARAM values, which is what NullAway.Init says; the rest is the
usual: a statistics map that a phase may not have produced, an employee with no
commission, and an edge the graph may not hold.

Two Error Prone warnings that the jmh source set had never been built against
are fixed rather than suppressed: a helper that reads no instance state is
static, and a parse failure during setup is thrown rather than printed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NullAway 0.14.2 infers wildcard bounds and array types more precisely
than 0.14.1, so 55 @SuppressWarnings("NullAway") and
@SuppressWarnings("NullAway.Init") annotations no longer suppress
anything. The suppression-remover script that ships with NullAway found
them by removing each one and recompiling. The List::toArray method
reference that the workaround for uber/NullAway#1728 avoided compiles
too; that issue is closed.

The new version also reports two places that 0.14.1 accepted:
Util.cast passed an argument it already treats as non-null to
castNonNull, and ArrayTable searched the array that holds a trailing
null instead of the non-null alias it sorts.

NullAway 0.14.2 crashes with a NullPointerException in
TypeSubstitutionUtils when it reads sarg.rangeSet through Sarg<?> in the
Elasticsearch PredicateAnalyzer, because Guava declares
Range<C extends Comparable> with a raw bound. uber/NullAway#1898 fixes
it after 0.14.2; until a release has it, PredicateAnalyzer reads the
ranges through a generic helper.

0.14.2 also ships the JSpecifyUnrecognizedAnnotationLocation check, which
reports a nullness annotation in a location JSpecify gives no meaning to,
such as the root type of a local variable or of a cast. It is enabled as an
error wherever NullAway runs. The Checker Framework did read @nullable in
those locations, so the migration had carried 163 of them over; each one is
removed in the commit that introduced it, or in the commit that moves the
annotations to JSpecify when it predates this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@contract, @MonotonicNonNull, @RequiresNonNull, @EnsuresNonNull and
@EnsuresNonNullIf lived in calcite-linq4j, in
org.apache.calcite.linq4j.annotations, only because linq4j is the
lowest module that core depends on. They have nothing to do with LINQ,
and the package is public API, so moving it after a release would break
code that imports it.

They now live in a new calcite-annotations module, package
org.apache.calcite.annotations. The module depends on jspecify only.
calcite-linq4j and calcite-core declare it as an API dependency, so a
project that depends on either of them still gets the annotations.

The commit also drops a comment in linq4j/build.gradle.kts that
described a dependency that is no longer there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every module that NullAway verifies declares @NullMarked on its
packages, and @nullable appears in their public signatures, so their
API needs org.jspecify:jspecify. Ten modules declared it as an API
dependency, elasticsearch and piglet as an implementation dependency,
which keeps it off a consumer's compile classpath in Gradle, and ten
modules did not declare it and got it only through calcite-core or
calcite-linq4j.

All published modules now declare it with api(). ubenchmark, which has
only JMH sources and is not published, declares it as
jmhImplementation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vlsi
vlsi requested a review from rubenada October 14, 2026 07:13
@vlsi

vlsi commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

@rubenada, thank you for the review. I should have caught that before requesting review. I've reworked the code so it is backward compatible (e.g. does not add excessive requireNonNull), and I added tests that pass nulls (e.g. SplunkResultEnumeratorTest). I've extracted #5325 / CALCITE-7852 so we can review it separately.

…By is built

An earlier commit in this series made SqlOrderBy.setOperand refuse a null
query (operand 0) and a null ORDER BY list (operand 1), since both fields
are declared non-null. The constructor and SqlOrderBy.OPERATOR.createCall
still stored a null, so the same value was accepted when the call was
created and refused when an operand was replaced; createCall carried
@SuppressWarnings("NullAway") to let it through.

The constructor and createCall now check both operands as setOperand does,
and the suppression is gone. The parser always supplies a query and uses
SqlNodeList.EMPTY for a missing ORDER BY, so parsed SQL is unaffected.

Caller sees:
NullPointerException "query" from new SqlOrderBy(...) or SqlOrderBy.OPERATOR.createCall(...) when the query is null
NullPointerException "orderList" from the same two when the ORDER BY list is null

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@rubenada

Copy link
Copy Markdown
Contributor

@vlsi any idea why the "Build Website and Javadoc" CI is failing?

@vlsi

vlsi commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

why the "Build Website and Javadoc" CI is failing?

The issue is that the job uses "latest" version of jekyll or something like that.
In other words, the failure is not related to the recent PRs.
It is just yet another issue to fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants