Skip to content

Enhance the NullAway configuration - #68

Open
ThoSap wants to merge 2 commits into
enable-javac-lint-categories-and-fix-deprecationsfrom
enhance-nullaway-configuration
Open

Enhance the NullAway configuration#68
ThoSap wants to merge 2 commits into
enable-javac-lint-categories-and-fix-deprecationsfrom
enhance-nullaway-configuration

Conversation

@ThoSap

@ThoSap ThoSap commented Sep 6, 2026

Copy link
Copy Markdown
Member

Enhance the NullAway configuration

Two changes that reinforce each other:

  1. Four NullAway options are enabled in errorprone.args. All four measure zero findings.
  2. jOOQ now writes JSpecify nullability annotations into the generated sources, so AcknowledgeRestrictiveAnnotations has real data to work with instead of guessing.

See the NullAway wiki, Adopting NullAway, Configuration and JSpecify Support for details.

Why now

The NullAway 0.14.1 configuration surface has 39 options. Only two were set.
I read the full list out of ErrorProneCLIFlagsConfig in the 0.14.1 sources and measured every candidate that could apply to a JSpecify project, rather than picking from the wiki by eye.

What changed

Commit Change
enable additional NullAway checks in errorprone.args Four options in the # Nullness section
configure jOOQ code-generation to generate nullability annotations generated/build.gradle.kts, the regenerated sources, and a README section

The four options

Option What it does Why it fits this project
AcknowledgeRestrictiveAnnotations Honours a @Nullable annotation found in unannotated code, instead of ignoring it The operator calls fabric8, Quarkus and jOOQ APIs all day. The codegen change below gives this option teeth immediately
CheckOptionalEmptiness Reports Optional.get() where the value was never checked as present Optional appears in 7 main source files
ExhaustiveOverride Performance. NullAway skips the overridden-method lookup for a method that carries no Override annotation Safe only because MissingOverride runs at ERROR, which guarantees the annotation is present. This is the setting the wiki page already linked from build.gradle.kts describes
HandleTestAssertionLibraries Teaches NullAway that assertThat(x).isNotNull() establishes non-nullness AssertJ is used in 11 test files. This option can only remove false positives, never add findings

The jOOQ codegen change

Every generated accessor now carries the real nullness of its column, so NullAway models the persistence layer instead of assuming it.

One caveat is recorded in the README.
jOOQ does not officially support the TYPE_USE positioning that JSpecify requires (jOOQ#10759).
The positioning is correct here only because the generated code uses no generics, collections, maps, arrays or forced types with inner classes.
If the includes list ever grows such an object, the annotation positions need a review.

That review will not stay manual. NullAway's wiki documents a JSpecifyUnrecognizedAnnotationLocation checker that reports a JSpecify annotation in any location the specification does not read,
and ships a suggested fix so Error Prone's patch mode can correct them in bulk.
It is documented as version 0.14.2, and 0.14.2 is not released - 0.14.1 is the latest, and the class is absent from its sources.
The wiki tracks master. Worth enabling the moment it ships, because it checks exactly the property this caveat depends on.

Considered and not adopted

TreatGeneratedAsUnannotated - rejected, and it would now do harm

It was in the first draft of this PR and came out again. Two independent reasons:

  1. It cannot do what it looks like it does. One might expect it to be the reason :generated:compileJava does not fail on RequireExplicitNullMarking, since no generated class carries @NullMarked.
    It is not. RequireExplicitNullMarking is a standalone BugChecker that never reads NullAway's Config, while the option lives in CodeAnnotationInfo.shouldTreatAsUnannotated and only feeds the NullAway check's model.
    Setting it or clearing it changes nothing here.
  2. It would discard the annotations this PR adds. From RestrictiveAnnotationHandler: "with the generated-as-unannotated option enabled, we want to ignore annotations in generated code no matter what" - including under AcknowledgeRestrictiveAnnotations=true.
    NullAway's own source notes the gap: "In the future, we might want finer grain controls to distinguish code that is generated with nullability info and without." This project is now exactly that case.

OnlyNullMarked - tried, measured, and rejected

The JSpecify-native alternative to AnnotatedPackages: only @NullMarked code counts as annotated.
It reads better, because @NullMarked lives in the source where JSpecify tooling and IntelliJ read it, while AnnotatedPackages is a NullAway-only setting no other tool understands.
It was switched on during this PR and switched back.

It is strictly weaker here, because it silently turns off checking of the generated module. jOOQ annotates the column accessors only.
120 generated methods have unannotated parameters - as(String alias), rename(Name name), where(Condition condition) and their siblings - and this project calls such methods 32 times in operator/src/main/java.

Setting PG_AUTHID.as((String) null)
AnnotatedPackages error - "passing @nullable parameter '(String) null' where @nonnull is required"
OnlyNullMarked accepted, 0 errors

Under AnnotatedPackages the generated module is annotated code, so an unannotated parameter means non-null and NullAway enforces it.
Under OnlyNullMarked the module falls back to unannotated, where NullAway is optimistic and lets null through. AcknowledgeRestrictiveAnnotations does not close the gap:
it reads annotations that make an API stricter, and an absent annotation carries nothing to read.

The strictness can be restored under OnlyNullMarked, but not cheaply.
@NullMarked on a package does not apply to subpackages - verified both ways:
a package-info.java in ...persistence while the class sits in ...persistence.tables reports nothing, while the same file in ...persistence.tables reports the error.
That means four package-info.java files, one per generated package, which must also survive jOOQ's cleaning of src/main/java on every regeneration.
That is more machinery than the tidiness is worth, so AnnotatedPackages stays.

JSpecifyExperimental - a follow-up PR

It turns on three things at once: the jspecify/jdk standard-library models, wildcard generics, and inference-failure warnings. Measured on this codebase it reports 12 findings, and they are genuine signal rather than noise:

Finding Count
Passing a @Nullable value where @NonNull is required 5
A method reference returns @Nullable, but Function.apply returns @NonNull 2
Inference failure: type variable U is constrained to be @Nullable 2
Dereferencing a @Nullable expression 1
Returning a @Nullable expression from a @NonNull method 1
Other 1

One is worth naming: matcher.group(1) is passed where non-null is required. The JDK model knows that method can return null, so this is a latent NPE that nothing catches today.

Upstream plans to make JSpecifyExperimental the default in a future release, so the 12 findings must be fixed sooner or later. A follow-up PR fixes them and enables the flag.

That follow-up need not be one step. Each of the three features has its own flag - JSpecifyJDKModels, HandleWildcardGenerics and WarnOnGenericInferenceFailure - and the wiki notes that most new errors come from the JDK models.
The 12 findings can therefore be taken a flag at a time rather than in one hit.

Rejected outright

Option Reason
AssertsEnabled The project has zero assert statements
CheckContracts, CustomContractAnnotations The project has zero @Contract annotations

Review notes

  • RequireExplicitNullMarking correctly uses -Xep:, not -XepOpt:. It is a check, not an option: NullAway 0.14.1 ships exactly two @BugPattern checkers, NullAway and RequireExplicitNullMarking. Writing it as -XepOpt:NullAway:RequireExplicitNullMarking:ERROR parses as an unknown option with no =, is silently ignored, and lets the check fall back to its default SUGGESTION severity, where it reports nothing. Verified by compiling a scratch class with no @NullMarked: the -XepOpt form builds successfully with 0 hits, the -Xep form fails the build with 2. Please do not "fix" this line.
  • Why the generated module is exempt from every check. Not by configuration: every generated class carries @SuppressWarnings({"all", "unchecked", "rawtypes", "this-escape"}), and Error Prone honours that annotation for checks at ERROR too. Verified by deleting that one line from Routines.java and recompiling: RequireExplicitNullMarking ×1, PrivateConstructorForUtilityClass ×2 and Varifier ×6 fire immediately. The README records this, so nobody hunts for a flag that does not exist.
  • ExhaustiveOverride has a dependency. If MissingOverride is ever lowered from ERROR, this option must come out with it, or NullAway will silently stop checking overrides that lack the annotation. The comment in errorprone.args records this.
  • HandleTestAssertionLibraries cannot break the build. It only teaches NullAway to trust an assertion, so it removes findings.
  • AcknowledgeRestrictiveAnnotations is the one that can bite later. It is clean today, but a dependency bump that adds @Nullable annotations to fabric8 or jOOQ will surface new errors. That is the point of the option, and the errors will be real.

Test scope

  • Each option was measured individually, with NullAway lowered to WARN so nothing was hidden, on :operator:compileJava, :operator:compileTestJava and :generated:compileJava with --rerun-tasks. All four report 0.
  • With all four enabled and the annotated sources in place: --rerun-tasks on the same three tasks - BUILD SUCCESSFUL, 0 warnings.
  • ./gradlew build -x test - BUILD SUCCESSFUL.

@ThoSap
ThoSap requested a review from stplasim September 6, 2026 21:46
@ThoSap ThoSap self-assigned this Sep 6, 2026
@ThoSap ThoSap added the enhancement New feature or request label Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant