Skip to content

refactor: declare org.testng.collections, org.testng.cli and org.testng.cli.jcommander null-marked - #3365

Merged
krmahadevan merged 3 commits into
juherr/enable-nullawayfrom
juherr/null-marked-collections-and-cli
Aug 16, 2026
Merged

refactor: declare org.testng.collections, org.testng.cli and org.testng.cli.jcommander null-marked#3365
krmahadevan merged 3 commits into
juherr/enable-nullawayfrom
juherr/null-marked-collections-and-cli

Conversation

@juherr

@juherr juherr commented Aug 15, 2026

Copy link
Copy Markdown
Member

Stacked on #3367 — review only the three commits above refactor(log4testng): declare the package null-marked.

#3367 wires NullAway and takes one package through it. That first package sits in testng-core-api, so nothing there exercises the mechanism across a module boundary. This takes the next three, chosen for one property: each lives in exactly one module.

That constraint is not cosmetic. @NullMarked applies to a package, and a package split across modules gets its unmarked half marked anyway through the compile classpath. It is why org.testng.util stays out even though Strings sits in testng-collections: RetryAnalyzerCount and TimeUtils carry the same package in testng-core.

The annotation rule

An @Nullable is written only when one of two things is true, and the reason was checked one annotation at a time by deleting it and recompiling:

  • required — the compile fails without it;
  • contract — the method body is a null test, or the field really is null in normal operation. Leaving it off would turn existing code into dead code.

Everything else stays bare. Nothing on Lists, Sets, Maps, ListMultiMap, SetMultiMap, CliConfigurer, JCommanderCliRunner, nor on any field that carries a default.

required contract total
org.testng.collections 1 5 6
org.testng.cli 23 1 24
org.testng.cli.jcommander 24 0 24

Forty-six of the fifty-four are one field per command line option. An option that was not passed leaves its field null — that is the mechanism CliConfigurer reads with a dozen != null tests, and it was nowhere stated until now.

Commit 1 — org.testng.collections

MultiMap.removeAll hands back Map.remove, which NullAway models @Nullable; MultiMapTest.removeAllOnAnAbsentKeyReturnsNull had already pinned that null, so the annotation just says out loud what the test asserts.

toString dereferenced m_objects.get(i) and is rejected even though the keys come from keySet(). It now goes through get(K), the class's own accessor: every key is already present, so computeIfAbsent inserts nothing and toStringRendersOneIndentedLinePerKey is unchanged.

The five contract annotations are the parameters of CollectionUtils.hasElements (both overloads, called from ConfigurationGroupMethods with the result of a Map.get) and of Objects.ToStringHelper.add (both overloads plus the private s() they share, called from TestResult.toString).

Commit 2 — org.testng.cli

CliOptions: 23 fields with no initializer, each null when its option is absent. The one contract annotation is AbstractCliRunner.run, whose body already tests null != listener.

Commit 3 — org.testng.cli.jcommander

This is where the boundary reports something. JCommanderCliRunner.parse wraps a ParameterException as new CliParseException(ex.getMessage(), ex), and Throwable.getMessage() is modelled @Nullable, so the constructor marked in commit 2 rejected the call. That overload exists to carry another exception's message, so widening it is the truthful fix. Its single-argument sibling stays non-null — every caller has a real message, and the check does not ask for more.

Converter.m_files is the one field where an annotation would have been a lie: @Parameter(required = true) means JCommander never leaves it unset. It is seeded with an empty list instead. Verified against jcommander 2.0 bytecode, and now characterized in ConverterMainParameterTest:

  • JCommander.initMainParameterValue reuses a main parameter list it finds in place, clearing it before the first value;
  • JCommander.validateOptions decides required from ParameterDescription.isAssigned(), not from the field being null.

Verification

./gradlew build green: 15948 tests, 0 failures, testng-test-osgi included.

The check was proved live rather than inert on each of the three packages: a throwaway return null; in a marked class of each made the compile fail with [NullAway], and was removed. The six contract annotations were each deleted and recompiled green — none is there to satisfy the compiler.

Noticed, not fixed

Two pre-existing defects in org.testng.collections, filed as #3366 because fixing either changes toString() output:

  • ToStringHelper.omitNulls() and omitEmptyStrings() are both no-ops — the value is stringified before the filters see it, so ValueHolder.isNull() and isEmptyString() are always false. TestResult.toString() asks for both, and so prints output={null}.
  • MultiMap.toString() iterates keySet(), which copies into a HashSet and so loses the ordering of a sorted multimap.

No CHANGES.txt entry: no behaviour changes, and #3367 added none.

@juherr
juherr requested a review from krmahadevan as a code owner August 15, 2026 20:07
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fed57a10-6eac-4b70-9e0d-6fa55b7b3702

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

juherr added 3 commits August 16, 2026 08:56
Second package through the NullAway wiring, and the first outside
testng-core-api. org.testng.collections qualifies for the same reason
org.testng.log4testng did: @NullMarked applies to a package, so a package
that appears in more than one module cannot be marked one half at a time.
This one lives only in testng-collections -- unlike org.testng.util, whose
Strings sits here while RetryAnalyzerCount and TimeUtils carry the same
package in testng-core.

Only one annotation is demanded by the check: removeAll hands back
Map.remove, which NullAway models as @nullable. No caller in the main
sources reads that result, so the null is documented rather than designed
away.

toString dereferenced m_objects.get(i) directly, which NullAway rejects even
though the keys come from keySet(). It now goes through get(K), the class's
own accessor; since every key is already present, computeIfAbsent inserts
nothing and the output is unchanged.

The five remaining annotations are contract, not compiler pressure -- each
one sits on a parameter whose method body *is* a null test:

  CollectionUtils.hasElements, both overloads, called from
  ConfigurationGroupMethods with the result of a Map.get;
  Objects.ToStringHelper.add, both overloads, and the private s() they
  share, called from TestResult.toString.

Dropping those five compiles green; keeping them stops the null tests from
reading as dead code.
org.testng.cli lives only in testng-cli, so the package can be marked
without dragging a half of itself in from another module.

Twenty-three of the twenty-four annotations are demanded by the check:
CliOptions holds one field per command line option, and an option that was
not passed leaves its field null. NullAway refuses a @nonnull field with no
initializer, which is the useful outcome here -- the class now states which
options are absent instead of leaving CliConfigurer's null tests
(cli.verbose != null, cli.threadCount != null, and a dozen more) as the only
record of it. Fields that do carry a default -- suiteFiles,
commandLineMethods, useDefaultListeners, xmlPathInJar, spiListenersToSkip,
suiteThreadPoolSize, randomizeSuites, alwaysRunListeners, the Boolean
toggles -- stay bare.

The twenty-fourth is contract: AbstractCliRunner.run already tests
null != listener, and overriding ITestNGCliRunner.run with a widened
parameter is what NullAway expects of an unannotated supertype. Dropping it
compiles green.

CliConfigurer needs nothing. Everything it hands to TestNG crosses into
unmarked code, which NullAway reads optimistically.
Third and last package of the batch, and the one that pays for the first
two: marking org.testng.cli.jcommander is what makes the cli boundary
report anything.

JCommanderOptions carries the same twenty-three fields as CliOptions, null
for the same reason -- JCommander only assigns an option that was passed --
and NullAway demands the same annotation for each.

The twenty-fourth annotation is the finding of the batch.
JCommanderCliRunner.parse wraps a ParameterException as

  new CliParseException(ex.getMessage(), ex)

and Throwable.getMessage() is modelled @nullable, so the constructor that
became marked in the previous commit rejected the call. Widening that
overload is the truthful fix: it exists to carry another exception's
message. Its single-argument sibling is left non-null; every caller has a
real message to give, and the check does not ask for more.

Converter.m_files was the one field where the annotation would have been a
lie -- @parameter(required = true) means JCommander never leaves it unset.
It is seeded with an empty list instead. That is invisible because
JCommander reuses a main parameter list it finds in place (clearing it
before the first value) and decides `required` from the parameter
description rather than from the field. Both halves are now characterized
in ConverterMainParameterTest, so a JCommander upgrade that changed either
would say so.
@krmahadevan
krmahadevan force-pushed the juherr/null-marked-collections-and-cli branch from e6bf205 to 68c87a9 Compare August 16, 2026 03:26
@krmahadevan
krmahadevan merged commit 3ad4d59 into master Aug 16, 2026
9 of 13 checks passed
@krmahadevan
krmahadevan deleted the juherr/null-marked-collections-and-cli branch August 16, 2026 03:27
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.

2 participants