498. Qodana reads a reviewed profile, and a bound value may be null
Date: 2026-09-30
Status
Accepted. Carries out docs/static-analysis-plan.md, item 6 of
docs/refactor-2026-09-30.md. Applies
ADR-0497’s rule for
records everywhere Qodana found the old form, and adds to
ADR-0341’s
table rather than replacing it.
Context
The plan was written against 5878e577, where Qodana reported 408 findings. By
the time it was carried out, item 5 of the same refactor had marked every
package @NullMarked, and a run at 6dd0cde9 reported 683. The growth was
entirely ConstantValue (127 to 345) and DataFlowIssue (107 to 163). Under
@NullMarked every parameter is non-null by contract, so every
x = x == null ? DEFAULT : x in the 128 newly marked packages became a check
IntelliJ calls dead. 351 of the 508 nullness findings were inside 114 compact
record constructors.
Three other things the plan found needed a decision rather than a fix:
qodana.yamlasked for an inspection that does not exist. It includedUnusedDeclaration, and the Java inspection’s ID isunused. It had never run. Turned on as written, it reported 445 findings, 242 of them public methods of a toolkit whose public API mostly has no caller in its own repository, and 70 of those were theinflatemethod the woven catalog calls.- 101
AutoCloseableResourcefindings were one false positive. A getter hands out aFont, aBackendor aMediaPlayerthat its owner closes, and the inspection reads every such call as a leak. Observable<T>said its value was never null. A model field that is not loaded yet is null, and so is aPropertymade empty. Fivecase nullarms and a dozenvalue == nullchecks on bound values were reported as dead, and they are exactly the checks a reader of a binding needs.
Decision
Qodana’s profile is a file in the repository, config/qodana/profile.yaml,
based on qodana.starter and named by qodana.yaml. It changes four things,
each with its reason beside it:
unusedis on, limited to private and package-private declarations. Public API is the toolkit’s product, and “no caller here” is not “dead”. The entry points the build reaches without a Java call are declared in.idea/misc.xml, where the IDE reads them too:@Bindand@Actionmembers, and every widget’sinflate.AutoCloseableResourceignores the types a window, a backend, a player or a tree owns, or that live as long as the application. The list keeps IntelliJ’s own defaults, because setting the option replaces them.Subscriptionis not on it: the plan listed it as owned, and one of its two findings was a real leak, below.OptionalUsedAsFieldOrParameterTypeis off. It is a style opinion this codebase decided against.EmptyStatementBodycounts a comment as content.
The dead exclusions LongMethod, OverlyComplexMethod and
NonBooleanMethodNameMayNotStartWithQuestion are gone. None of them is in
qodana.starter.
A false positive is answered where it is, never in the profile. A
//noinspection comment or @SuppressWarnings on the narrowest declaration,
with the reason in a sentence beside it. IntelliJ’s suppression ID is not
always the rule ID the SARIF shows: MismatchedArrayReadWrite is suppressed as
MismatchedReadAndWriteOfArray, and AutoCloseableResource as resource.
Observable<T extends @Nullable Object>, and Property and BoundField
with it. Validator.of takes a predicate over @Nullable T, because a
validator is asked about a field with nothing in it. Validator.parsing takes a
parser that may answer null, as its documentation already said. NullAway does
not check type-argument nullness in this build, so no caller had to change.
IntelliJ does check it, and in three places it reads the nullable bound instead
of a declared non-null argument (Property<List<Overlay>>). Those three carry a
suppression that says so.
Consequences
- Qodana went from 683 findings, 676 of them high, to 249, none of them high:
247 unused declarations at weak-warning severity and two
whileloops the plan leaves alone. The baseline was regenerated from that run, so the gate starts clean. - ADR-0497’s record rule is now applied everywhere, not only where NullAway
asked for it. 121 files were rewritten by a script, then formatted and
compiled with NullAway on. The script handles a qualified type
(
Outer.@Nullable Inner), an array or varargs, and a component comment that contains a comma. - Three real bugs came out of it:
- A
Content-RangeorRangewith more digits than alongor anintholds threwNumberFormatExceptionout ofHttpIOand the showcase’s server. The reader now fails with anIOException, and the server reads an over-long bound as past the end, which is what RFC 9110 means by it. - The launcher subscribed every model’s restyle and repaint listeners and
never closed the subscriptions. A model that outlived one launch went on
asking a closed window for frames. They are closed in
shutDown, andModels.frameListenerCountlets a test say so. - An
@Actiontaking adoubleand handed null threw a bareNullPointerExceptionout ofDouble.valueOf, while one taking anintrefused it by name. Both now refuse it by name.
- A
DownloaderandAssetCacheareAutoCloseable, so the standard downloader’sHttpClientis closed.PngEncodercloses itsDeflaterwith try-with-resources, which JDK 24 made possible.Subtitles.parselost its unusedformatparameter.:mediais published as a snapshot only, so no release had it.