Skip to content

refactor: declare org.testng.internal.annotations null-marked - #3382

Merged
krmahadevan merged 1 commit into
masterfrom
juherr/nullmarked-internal-annotations
Aug 17, 2026
Merged

refactor: declare org.testng.internal.annotations null-marked#3382
krmahadevan merged 1 commit into
masterfrom
juherr/nullmarked-internal-annotations

Conversation

@juherr

@juherr juherr commented Aug 17, 2026

Copy link
Copy Markdown
Member

Continues the JSpecify/NullAway stack. Base is master#3380 and #3381 are merged, so this one
stands alone.

One package: org.testng.internal.annotations — thirty main files in testng-core and three
(DisabledRetryAnalyzer, IAnnotationFinder, IDataProvidable) in testng-core-api. No
sub-packages. Two things change at once compared with #3381: the minority half is more than a single
file for the first time, and this is the first package upstream of packages that are already
marked and merged. org.testng.internal.invokers (#3381), org.testng.internal.objects,
org.testng.annotations (#3374, #3375) and org.testng.internal.objects.pojo all read it. Marking
a leaf could only produce errors in its own files; this one could produce them in code already
shipped.

The coverage was proved, not assumed

testng-core declares api(projects.testngCoreApi), so the package-info.java goes in
testng-core-api and the mark travels downstream. That is what needed proving: put it on the wrong
side and the thirty-file majority compiles unchecked, which looks exactly like success.

Per the procedure in AGENTS.md, the negative control runs once per module traversed. A throwaway

private static Object nullAwayProbe() { return null; }

went into one file of each module. Before the package-info.java both compile clean, zero NullAway.
After it both fail with [NullAway] returning @Nullable expression from method with @NonNull return type:

module probe fails at
testng-core-api DisabledRetryAnalyzer.java:14
testng-core BaseAnnotation.java:9

Both reverted. The testng-core row is the one that matters, and it is red, so the counts below
mean something.

What the check reported

count
errors 49 (1 in testng-core-api, 48 in testng-core)
@Nullable 91
of which (E) 87
of which (C) 4
Objects.requireNonNull 2
restructured 9

Every annotation is classified by deletion and recompilation — one at a time, each stripped and
the modules recompiled. 87 bring back a named error.

The tag hierarchy decides the field errors

The shape is BaseAnnotation, then TestOrConfiguration and the leaves TestAnnotation,
FactoryAnnotation, DataProviderAnnotation, ListenersAnnotation, with one producer:
JDK15TagFactory constructs a tag and immediately fills every field from the Java annotation it
mirrors. That producer decides how the "field not initialized" errors are answered.

Where the interface already published a @Nullable getter the field takes the annotation. Where it
published a non-null one the field takes the default of the annotation member it mirrors, because
that is what the tag factory assigns a line later and what every reader already treats as absent:
"" for the data provider name and the two dataProvider strings, an empty list for indices,
DisableDataProviderRetries for retryUsing, an empty array for @Listeners, and
DisabledRetryAnalyzer for retryAnalyzer — the value BaseTestMethod.setRetryAnalyzerClass
already substitutes for null. Annotating those instead would have published nullity on six getters
whose every caller dereferences them.

IAnnotationFinder is the other half. Its javadoc has said "or null if none found" since it was
written, and JDK15AnnotationFinder returns a literal null in two of the six overloads, so the
implementation had to be @Nullable — and NullAway then rejected it against the non-null interface.
Those six returns are demanded, not documentation: NullAway is silent on type arguments, not on
a @Nullable return that happens to be a type variable.
AnnotationHelper's five delegates and
both findConfiguration overloads follow from the same source.

The new variable: errors in code already merged

Ten files across the four marked packages read this one. Two needed anything:

The second deferred finding of #3381 is settled

handleConfigurationFailure keeps null != annotation on one path and Objects.requireNonNull on
the other. Annotating the return makes that asymmetry compiler-visible instead of a reading, and
it does not reproduce: the parameter is null only when the throw happened before the assignment,
and the statements that precede it can only produce an NPE (requireNonNull on the instance) or a
TestNGException (the annotation finder), neither of which passes isSkipExceptionAndSkip — the
sole gate to the requireNonNull path. The remaining guard is unreachable rather than wrong, so no
issue is opened.

The minority half

DisabledRetryAnalyzer needs nothing, which is worth saying rather than leaving as a zero in a
table: it is public surface despite the package — Test#retryAnalyzer() names it — but its one
method overrides IRetryAnalyzer.retry(ITestResult), which is unannotated, and NullAway never
widens an implementation against an unannotated supertype. Same situation as RetryAnalyzerCount in
#3380.

IDataProvidable needed the getDataProviderClass pair, which its own subinterface ITestAnnotation
had already declared @Nullable and therefore contradicted. IAnnotationFinder needed the six
returns plus the one class its own overload passes null for. findOptionalValues stays as it is:
its javadoc describes null elements, which NullAway does not check.

The four that stay without a demand

Dropping any of them would leave a contract contradicting itself.

where why NullAway is silent
IAnnotationFinder's @Nullable Class<?> interface parameter; the matching one in JDK15AnnotationFinder is demanded by the literal null its own overload passes
IDataProvidable.setDataProviderClass interface parameter; ITestAnnotation already declares it @Nullable and both implementations are demanded
IgnoreListener.findAnnotation(Package) Package.getPackage is read optimistically by the JDK model
the private findAnnotation in JDK15AnnotationFinder Method.getAnnotation is read optimistically

The last two test their parameter on the first line of the body and are null on every lookup that
finds nothing.

Restructured rather than annotated

Six of the nine are the field defaults above. The other three: JDK15AnnotationFinder returns early
when findAnnotationInSuperClasses finds nothing, which is what the private overload it calls did on
its first line, and keeps Pair's constructor non-null; JDK15TagFactory asserts the method it was
handed when building a data provider tag — @DataProvider is @Target(METHOD), so it is never
looked up on a class; ListenersAnnotation starts from a shared empty array instead of null.

Verification

./gradlew build on the rebased branch: BUILD SUCCESSFUL, 16854 completed, 0 failed, 12 skipped — the twelve are the pre-existing environment-dependent skips (heap-dump support and a
listener sample). Every count in this description comes from a full recompile
(--rerun-tasks), never an incremental one.

One trap worth recording for the next lot: a plain javac error anywhere in the module — here a
generic array creation — stops the NullAway pass entirely, so the compiler reports zero NullAway
errors on code full of them. A clean reading means nothing until the compile itself is clean.

No behaviour change.

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of optional annotation, data-provider, description, and reflective metadata values.
    • Added clearer safeguards when required configuration metadata is missing, preventing ambiguous processing failures.
  • Improvements
    • Enhanced nullability documentation across annotation and transformation APIs, making optional values and expected behavior clearer.
    • Established more consistent defaults for data providers, retry handling, listeners, and related annotation settings.
    • Added package documentation describing annotation processing behavior.

Thirty files in testng-core, three in testng-core-api -- the first package whose
minority half is more than a single file, and the first one upstream of packages
already marked and merged: org.testng.internal.invokers,
org.testng.internal.objects, org.testng.annotations and
org.testng.internal.objects.pojo all read it. Marking a leaf could only produce
errors in its own files; this one could produce them in code already shipped.

The package-info goes in testng-core-api, which testng-core depends on, and the
per-module control confirms both halves are covered: a throwaway null-returning
method is clean in both modules before the file and fails after it, at
DisabledRetryAnalyzer.java:14 and, separately, at BaseAnnotation.java:9.

Forty-nine errors -- one in testng-core-api, forty-eight in testng-core.
91 @nullable, two Objects.requireNonNull and nine restructurings answer them.

The shape of the package is a tag hierarchy -- BaseAnnotation, then
TestOrConfiguration and the leaves TestAnnotation, FactoryAnnotation,
DataProviderAnnotation, ListenersAnnotation -- with one producer,
JDK15TagFactory, which constructs a tag and immediately fills every field from
the Java annotation it mirrors. That producer decides how the "field not
initialized" errors are answered. Where the interface already published a
@nullable getter the field takes the annotation; where it published a non-null
one the field takes the default of the annotation member it mirrors, because
that is what the tag factory assigns a line later and what every reader already
treats as absent: "" for the data provider name and the two dataProvider
strings, an empty list for indices, DisableDataProviderRetries for retryUsing,
an empty array for @listeners, and DisabledRetryAnalyzer for retryAnalyzer --
the value BaseTestMethod.setRetryAnalyzerClass already substitutes for null.
Annotating those instead would have published nullity on six getters whose
every caller dereferences them.

IAnnotationFinder is the other half. Its javadoc has said "or null if none
found" since it was written, and JDK15AnnotationFinder returns a literal null in
two of the six overloads, so the implementation had to be @nullable -- and
NullAway then rejected it against the non-null interface. Those six returns are
demanded, not documentation: NullAway is silent on type *arguments*, not on a
@nullable return that happens to be a type variable. AnnotationHelper's five
delegates and both findConfiguration overloads follow from the same source.

Downstream, ten files in the four marked packages read this package and two
needed anything. ClassBasedParallelWorker.isSequential already tested its
ITestAnnotation for null; #3380 had to read that guard as residue because
findAnnotation was non-null, and it is now demanded. ConfigInvoker:308 passes
the result of AnnotationHelper.findConfiguration to handleConfigurationSkip,
which #3381 tightened to non-null; requireNonNull there records that a
ConfigurationMethod only exists because TestNGMethodFinder:130 found a
configuration annotation on that very method, through the same lookup.

That settles the second deferred finding of #3381 -- handleConfigurationFailure
keeps `null != annotation` on one path and Objects.requireNonNull on the other.
Annotating the return makes the asymmetry compiler-visible instead of a reading,
and it does not reproduce: that parameter is null only when the throw happened
before the assignment, and the statements that precede it can only produce an
NPE (requireNonNull on the instance) or a TestNGException (the annotation
finder), neither of which passes isSkipExceptionAndSkip -- the sole gate to the
requireNonNull path. The remaining guard is unreachable rather than wrong, so no
issue is opened.

DisabledRetryAnalyzer needs nothing, which is worth saying rather than leaving
as a zero: it is public surface despite the package -- Test#retryAnalyzer()
names it -- but its one method overrides IRetryAnalyzer.retry(ITestResult),
which is unannotated, and NullAway never widens an implementation against an
unannotated supertype. Of the other two files in the minority, IDataProvidable
needed the getDataProviderClass pair, which its own subinterface ITestAnnotation
had already declared @nullable and therefore contradicted, and IAnnotationFinder
needed the six returns plus the one class its own overload passes null for.
findOptionalValues stays as it is: its javadoc describes null *elements*, which
NullAway does not check.

Three of the nine restructurings are not field defaults. JDK15AnnotationFinder
returns early when findAnnotationInSuperClasses finds nothing, which is what the
private overload it calls did on its first line, and keeps Pair's constructor
non-null. JDK15TagFactory asserts the method it was handed when building a data
provider tag -- @dataProvider is only ever looked up on a method, never on a
class. ListenersAnnotation starts from an empty array instead of null.

Every annotation is classified by deletion and recompilation, one at a time:
87 of the 91 bring back a named error. The four that do not stay, because
dropping them would leave a contract contradicting itself. Two are interface
parameters, which NullAway never checks an implementation against:
IAnnotationFinder's @nullable class, whose matching parameter in
JDK15AnnotationFinder is demanded by the literal null its own overload passes,
and IDataProvidable.setDataProviderClass, which ITestAnnotation already declares
@nullable and whose two implementations are demanded. The other two are JDK
models NullAway reads optimistically: IgnoreListener walks up package names with
Package.getPackage, and the private findAnnotation in JDK15AnnotationFinder
takes the result of Method.getAnnotation. Both test their parameter on the first
line of the body, and both are null on every lookup that finds nothing.

One trap worth recording: a plain javac error anywhere in the module -- here a
generic array creation -- stops the NullAway pass entirely, so the compiler
reports zero NullAway errors on code full of them. A clean reading means nothing
until the compile itself is clean.
@juherr
juherr requested a review from krmahadevan as a code owner August 17, 2026 07:39
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 419a594f-d002-45f0-b089-5f373cdda9d0

📥 Commits

Reviewing files that changed from the base of the PR and between 13cec4d and 820a627.

📒 Files selected for processing (19)
  • testng-core-api/src/main/java/org/testng/internal/annotations/IAnnotationFinder.java
  • testng-core-api/src/main/java/org/testng/internal/annotations/IDataProvidable.java
  • testng-core-api/src/main/java/org/testng/internal/annotations/package-info.java
  • testng-core/src/main/java/org/testng/internal/annotations/AnnotationHelper.java
  • testng-core/src/main/java/org/testng/internal/annotations/BaseAnnotation.java
  • testng-core/src/main/java/org/testng/internal/annotations/BaseBeforeAfter.java
  • testng-core/src/main/java/org/testng/internal/annotations/DataProviderAnnotation.java
  • testng-core/src/main/java/org/testng/internal/annotations/DefaultAnnotationTransformer.java
  • testng-core/src/main/java/org/testng/internal/annotations/FactoryAnnotation.java
  • testng-core/src/main/java/org/testng/internal/annotations/IAnnotationTransformer.java
  • testng-core/src/main/java/org/testng/internal/annotations/IBaseBeforeAfter.java
  • testng-core/src/main/java/org/testng/internal/annotations/IgnoreListener.java
  • testng-core/src/main/java/org/testng/internal/annotations/JDK15AnnotationFinder.java
  • testng-core/src/main/java/org/testng/internal/annotations/JDK15TagFactory.java
  • testng-core/src/main/java/org/testng/internal/annotations/ListenersAnnotation.java
  • testng-core/src/main/java/org/testng/internal/annotations/TestAnnotation.java
  • testng-core/src/main/java/org/testng/internal/annotations/TestOrConfiguration.java
  • testng-core/src/main/java/org/testng/internal/invokers/ClassBasedParallelWorker.java
  • testng-core/src/main/java/org/testng/internal/invokers/ConfigInvoker.java

Included review availability: Your plan includes up to 8 reviews per rolling hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The change adds JSpecify nullability contracts across annotation APIs and implementations. It also adds explicit annotation defaults, an early null return in annotation lookup, nullable tag creation inputs, and configuration-annotation validation.

Changes

Annotation nullability and defaults

Layer / File(s) Summary
API nullability contracts
testng-core-api/src/main/java/org/testng/internal/annotations/*
Annotation finder and data-provider APIs declare nullable values. The annotation package is marked @NullMarked.
Annotation state defaults and contracts
testng-core/src/main/java/org/testng/internal/annotations/*
Annotation state classes declare nullable fields, parameters, and results. Provider, retry, listener, and description defaults are initialized explicitly.
Annotation lookup and transformation
testng-core/src/main/java/org/testng/internal/annotations/AnnotationHelper.java, testng-core/src/main/java/org/testng/internal/annotations/JDK15AnnotationFinder.java, testng-core/src/main/java/org/testng/internal/annotations/JDK15TagFactory.java
Lookup and tag-creation paths declare nullable values and inputs. Class-level lookup returns when no annotation exists before conversion.
Invocation null validation
testng-core/src/main/java/org/testng/internal/invokers/ClassBasedParallelWorker.java, testng-core/src/main/java/org/testng/internal/invokers/ConfigInvoker.java
isSequential accepts a nullable annotation. Skipped-configuration handling now requires a non-null configuration annotation.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 820a6

The PR adds nullness annotations with localized call-site adjustments, and the reported full build succeeds without an actionable correctness concern. No merge-blocking risk remains beyond normal checks.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the refactor that marks org.testng.internal.annotations as null-marked, matching the pull request's main objective.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch juherr/nullmarked-internal-annotations

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.

@krmahadevan
krmahadevan merged commit cf80ac0 into master Aug 17, 2026
18 checks passed
@krmahadevan
krmahadevan deleted the juherr/nullmarked-internal-annotations branch August 17, 2026 09:03
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