Skip to content

refactor: declare org.testng.annotations and org.testng.internal.protocols null-marked - #3370

Merged
krmahadevan merged 2 commits into
masterfrom
juherr/null-marked-annotations-and-protocols
Aug 16, 2026
Merged

refactor: declare org.testng.annotations and org.testng.internal.protocols null-marked#3370
krmahadevan merged 2 commits into
masterfrom
juherr/null-marked-annotations-and-protocols

Conversation

@juherr

@juherr juherr commented Aug 15, 2026

Copy link
Copy Markdown
Member

Stacked on #3365 — review only the two commits above refactor(jcommander): declare the package null-marked.

These are the last two packages of testng-core-api that live in exactly one module, which is the
constraint the whole stack is built on: @NullMarked applies to a package, and a package split
across modules gets its unmarked half marked anyway through package-info.class on the compile
classpath. Between them they are the 41 files left in that module that could be taken without
dragging in a second one.

The annotation rule

An @Nullable is written only when one of two things is true, and the reason was checked 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.

Everything else stays bare. Restructuring wins over annotating when it costs nothing.

required contract total
org.testng.annotations 0 6 6
org.testng.internal.protocols 0 0 0

Zero required is not the check going quiet. Between them these two packages contain three method
bodies — IConfigurationAnnotation.isBeforeGroups, isAfterGroups, both returning boolean, and
the Processor hierarchy — and the one thing NullAway does demand is answered without an
annotation. See the negative control below.

Commit 1 — org.testng.annotations

Thirty-four files: 22 annotation types, 11 interfaces, one enum. Annotation types and enums carry
no method body, so package-info.java lands green on its own.

The six annotations are all contract, and they go on the interfaces because that is the only place
the nullness is stated at all. The implementations live in org.testng.internal.annotations, which
spans testng-core-api and testng-core and so cannot be marked one half at a time. NullAway does
not check it, and no already marked package references these types — so annotating here cannot
break anything today, and becomes binding the day that package is marked. Which is exactly why each
one had to be true rather than decorative:

Signature Why
ITestOrConfiguration.getDescription / setDescription JDK15TagFactory.findInherited returns null and createTestTag feeds it straight in — the null that TestNGMethod.getDescription and IgnoreListener.onBeforeClass already test for
ITestAnnotation.getDataProviderClass / setDataProviderClass same findInherited path; TestAnnotation leaves the field null and Parameters guards it four times
IFactoryAnnotation.getIndices FactoryAnnotation never initialises the field; FactoryMethod tests indices == null twice
IFactoryAnnotation.setLazy FactoryAnnotation.setLazy is the null test: m_lazy = lazy == null ? Lazy.UNSET : lazy. The getter stays non-null

Dropping all six compiles green, so none of them is there to satisfy the compiler.

What stays bare is the more interesting half. Every getter whose field starts out null but is
always assigned before anyone can read it keeps its non-null contract:
getRetryAnalyzerClass, IDataProviderAnnotation.getIndices (unlike its IFactoryAnnotation
namesake — that asymmetry is real, createDataProviderTag sets it and createFactoryTag is the
one that can be overridden to null), getName, getDataProvider, retryUsing, getSuiteName,
getTestName, getExpectedExceptionsMessageRegExp, IFactoryAnnotation.getLazy, and every array
getter — those either initialise to {} or take their value from a Java annotation member, which
cannot be null.

Commit 2 — org.testng.internal.protocols

Seven files, and the check demands one thing that is not an annotation:

Input.java:56: error: [NullAway] initializer method does not guarantee @NonNull fields
'included' (line 49), 'excluded' (line 50), 'packageWithoutWildCards' (line 51),
'packageDirName' (line 53), 'packageName' (line 54) are initialized along all control-flow paths

Input.Builder declares five reference fields with no initialiser and a constructor that assigns
nothing. Annotating them would be a lie, the same lie Converter.m_files would have been in #3365:
PackageUtils.findClassesInPackage is the only caller in the repository, it calls all six setters,
and no test builds an Input. They are seeded with empty values instead — five initialisers, zero
annotations, and none of the cascade that @Nullable on those fields would have pushed into
Input's constructor and on into all four processors.

The one difference is what build() yields when a setter is skipped: a NullPointerException out
of Collections.unmodifiableList before, empty values now. No caller takes that path.

Nothing else in the package reports, and three near-misses are worth recording because they look
like they should. File.listFiles and Method.invoke are not in NullAway 0.13.8's JDK models, so
the existing dirfiles == null test in findClassesInDirPackage stands unremarked and
BundledResourceProcessor keeps dereferencing the reflected URL. Utils.log and the java.net
calls cross into unmarked code, which NullAway reads optimistically. NullAway:JSpecifyMode is off,
so List<String> type arguments are not checked either.

Verification

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

The check was proved live rather than inert on each package separately. A throwaway
default String probe() { return null; } on IAnnotation, and a throwaway return null; in
NoOpProcessor.process, each failed the compile:

IAnnotation.java:6: error: [NullAway] returning @Nullable expression from method with @NonNull return type
NoOpProcessor.java:10: error: [NullAway] returning @Nullable expression from method with @NonNull return type

Both were removed. The six contract annotations were deleted together and recompiled green.

Noticed, not fixed

FactoryAnnotation.getDataProviderDynamicClass() returns null for every @Factory, while
IDataProvidable declares it non-null and TestAnnotation holds "". Filed as #3369. It does not
NPE today only because Parameters short-circuits on dataProviderClass == null, which
createFactoryTag happens to guarantee — a guarantee an IAnnotationTransformer can remove. The
fix is one initialiser, but it is a behaviour change, so it does not belong here.

That is also why IDataProvidable itself is untouched: it sits in org.testng.internal.annotations,
the split package, and marking it is a later step.

No CHANGES.txt entry: no behaviour changes on any reachable path.

Summary by CodeRabbit

  • Improvements
    • Added clear nullability annotations and documentation across annotation APIs.
    • Clarified when optional descriptions, provider classes, indices, and lazy settings may be absent.
    • Applied package-wide nullness checking to annotation and protocol APIs.
    • Protocol builders now initialize optional collections and text fields with safe empty defaults.

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

coderabbitai Bot commented Aug 15, 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: b2878524-14f7-43ad-9a55-61414a3e1185

📥 Commits

Reviewing files that changed from the base of the PR and between 3ad4d59 and 3b4a8cb.

📒 Files selected for processing (6)
  • testng-core-api/src/main/java/org/testng/annotations/IFactoryAnnotation.java
  • testng-core-api/src/main/java/org/testng/annotations/ITestAnnotation.java
  • testng-core-api/src/main/java/org/testng/annotations/ITestOrConfiguration.java
  • testng-core-api/src/main/java/org/testng/annotations/package-info.java
  • testng-core-api/src/main/java/org/testng/internal/protocols/Input.java
  • testng-core-api/src/main/java/org/testng/internal/protocols/package-info.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 annotations and package-level @NullMarked declarations to TestNG APIs. It also initializes Input.Builder collection and string fields with empty defaults.

Changes

API contracts and protocol defaults

Layer / File(s) Summary
Nullable annotation API contracts
testng-core-api/src/main/java/org/testng/annotations/...
Annotation interfaces document nullable descriptions, data-provider classes, factory indices, and lazy values. The package is marked @NullMarked.
Protocol package markers and builder defaults
testng-core-api/src/main/java/org/testng/internal/protocols/...
The protocols package is marked @NullMarked. Input.Builder now uses empty lists and strings by default, while recursive remains false.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 3b4a8

The PR adds package nullness declarations while preserving the existing non-null empty-value behavior expected by consumers. No actionable merge-blocking risk remains after normal checks and review.

Possibly related PRs

Suggested labels: testng-api

Suggested reviewers: krmahadevan

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% 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 and concisely describes the main change: declaring both packages as null-marked.
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/null-marked-annotations-and-protocols

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 force-pushed the juherr/null-marked-annotations-and-protocols branch from e8489d5 to 2e0d81c Compare August 16, 2026 03:27
Base automatically changed from juherr/null-marked-collections-and-cli to master August 16, 2026 03:27
juherr added 2 commits August 16, 2026 08:57
org.testng.annotations lives only in testng-core-api, so it can be marked
without dragging an unmarked half in from another module through
package-info.class on the compile classpath.

Nothing in the package is demanded by the check. Twenty-two of the thirty-four
files are annotation types and one is an enum, none of which carry a method
body; the only bodies are IConfigurationAnnotation.isBeforeGroups and
isAfterGroups, which return a boolean. The compile is green the moment
package-info.java lands.

The six annotations are all contract, and they sit on the interfaces because
that is the only place the nullness is stated at all. Their implementations
live in org.testng.internal.annotations, which spans testng-core-api and
testng-core and so cannot be marked one half at a time; NullAway does not check
it, and no already marked package references these types. Annotating here
breaks nothing today and becomes binding the day that package is marked, which
is why each one had to be true rather than decorative:

  ITestOrConfiguration.getDescription and setDescription --
  JDK15TagFactory.findInherited returns null and createTestTag feeds it
  straight in, which is what TestNGMethod.getDescription and
  IgnoreListener.onBeforeClass already test for.

  ITestAnnotation.getDataProviderClass and setDataProviderClass -- same
  findInherited path; TestAnnotation leaves the field null and Parameters
  guards it four times over.

  IFactoryAnnotation.getIndices -- FactoryAnnotation never initialises the
  field, and FactoryMethod tests indices == null twice.

  IFactoryAnnotation.setLazy -- FactoryAnnotation.setLazy *is* the null test:
  m_lazy = lazy == null ? Lazy.UNSET : lazy. The getter stays non-null.

Dropping all six compiles green, so none is there to satisfy the compiler.

Everything else stays bare, including the getters whose field starts out null
but is always assigned before anyone can read it: getRetryAnalyzerClass,
IDataProviderAnnotation.getIndices, getName, getDataProvider, retryUsing, and
every array getter, which either initialises to {} or takes its value from a
Java annotation member that cannot be null.

No behaviour changes -- annotations only.
org.testng.internal.protocols lives only in testng-core-api, the last package
of that module to sit in exactly one place. It is also the last one small
enough to take in a single pass.

The check demands one thing and it is not an annotation. Input.Builder declares
five reference fields with no initialiser and a constructor that assigns
nothing, so NullAway refuses all five at once. Annotating them would be a lie:
PackageUtils.findClassesInPackage is the only caller in the repository, it
calls all six setters, and no test builds an Input. They are seeded with empty
values instead, the way Converter.m_files was.

The one difference is what build() yields when a setter is skipped -- a
NullPointerException out of Collections.unmodifiableList before, empty values
now. No caller takes that path.

Nothing else in the package reports. File.listFiles and Method.invoke are not
in NullAway's JDK models, so the existing dirfiles == null test in
findClassesInDirPackage stands unremarked and BundledResourceProcessor keeps
dereferencing the reflected URL; Utils.log and the java.net calls cross into
unmarked code, which NullAway reads optimistically.

No behaviour changes on any reachable path -- annotations only.
@krmahadevan
krmahadevan force-pushed the juherr/null-marked-annotations-and-protocols branch from 2e0d81c to 3b4a8cb Compare August 16, 2026 03:27
@krmahadevan
krmahadevan merged commit 659de81 into master Aug 16, 2026
17 of 18 checks passed
@krmahadevan
krmahadevan deleted the juherr/null-marked-annotations-and-protocols branch August 16, 2026 07:50
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