Skip to content

refactor: declare org.testng.internal null-marked - #3395

Closed
juherr wants to merge 11 commits into
testng-team:juherr/internal-nullness-contractsfrom
juherr:juherr/nullmarked-internal
Closed

refactor: declare org.testng.internal null-marked#3395
juherr wants to merge 11 commits into
testng-team:juherr/internal-nullness-contractsfrom
juherr:juherr/nullmarked-internal

Conversation

@juherr

@juherr juherr commented Aug 19, 2026

Copy link
Copy Markdown
Member

Adds testng-core-api/src/main/java/org/testng/internal/package-info.java and resolves the 111
NullAway diagnostics
it opens. #3393 wrote the nullness contracts into the signatures of
org.testng.internal and deliberately left the mark out; this is the half that turns them from
documentation into assertions.

Coverage

org.testng.internal spans four modules, and one module's package-info.class reaches another only
through a compile dependency. A throwaway private static Object nullAwayProbe() { return null; }
went into one file of each, as AGENTS.md requires:

module probe host before the mark after the mark
testng-core-api TestNGDeadLockException clean [NullAway] returning @Nullable expression from method with @NonNull return type at :12
testng-core InstanceInfo clean same, at :25
testng-runner-api XmlTestUtils clean same, at :31
testng-yaml YamlSchema clean same, at :484

grep ' error: ' LOG | grep -v '[NullAway]' was empty in both runs, so no ordinary javac error
silenced the pass in either direction.

Counters

diagnostics resolved 111 — testng-core 105, testng-runner-api 3, testng-core-api 3
@Nullable added 94 across 45 files — testng-core 86, testng-collections 6, testng-runner-api 2
— required by the checker (E) 83
— required by Kotlin 6
— required to compile (C) 0
— not demanded 5, each justified below
requireNonNull added 13, every one with a constant message
restructurings replacing an annotation 1 (ClonedMethod, in its own ! commit)
behaviour changes 5, all listed in CHANGES.txt

Every @Nullable in the diff was deleted on its own and the four modules recompiled from scratch —
98 runs. A verdict of (E) was accepted only when the returning error named the exact member or the
exact argument: of the 83, 57 are reported in the declaring file and 26 at the call site that passes
the value, which is where a widened parameter is necessarily demanded. Three annotations that
nothing asked for were deleted rather than justified (initBeforeAfterGroups's parameter and two
local variables); they are the drop the annotations the classification pass did not justify commit.

Ripple into packages that are already marked

org.testng.internal.reflect, org.testng.internal.objects.pojo, org.testng.internal.annotations,
org.testng.internal.invokers, org.testng.collections and org.testng.xml read these types, so
what they receive had to be answered in the same pass. The rule applied: widen the callee where its
implementation already stores or tolerates the null, assert at the boundary where the callee
dereferences it.

  • Widened: ReflectionRecipes.inject (both overloads — nativelyInject already tested context != null),
    MethodMatcherContext (handed a literal null), CreationAttributes (its field and getter were
    already @Nullable), MethodInvocationHelper.invokeDataProvider's fedInstance,
    MultiMap's key.
  • Asserted: TestNGClassFinder and ParameterHandler dispense instances through an object factory,
    so they say so once rather than threading the absence down to SimpleObjectDispenser;
    MethodParameters.requireContext() for the data-provider path, where every real caller supplies a
    context.

Fixes that came out of the pass

Five null dereferences that were reachable before this branch, each with a CHANGES.txt entry:

  • TestNGMethod.clone() wrapped getTestClass() in a NoOpTestClass, which dereferences it on
    the spot. A method cloned before setTestClass has run threw there. ConfigurationMethod.clone()
    already propagated the absence; now both do.
  • TestNGMethodFinder wrote null into m_beforeGroups/m_afterGroups, whose declaration says
    {}. Every configuration method that is not a group one carried a null array past
    MethodGroupsHelper, which iterates it without testing.
  • ClonedMethod.toString() read getDeclaringClass() off its own getConstructorOrMethod(),
    which answered null — so printing a ClonedMethod always threw.
  • MethodInstance.SORT_BY_INDEX compared two <test> names without either being guaranteed to
    have one.
  • XmlPackage handed a null package name to PackageUtils.findClassesInPackage, which reads its
    first character. A <package> tag with no name attribute is now reported the way an unreadable
    one already was.

Annotations the checker did not demand

Five, each kept for a stated reason:

  • MultiMap.containsKey, remove, removeAll, putAllput and get are demanded (a
    method with no instance has no instance id, and both the instance dependency graph and the
    per-instance workers already grouped those methods under a null key). A map that accepts a null key
    through put but cannot be asked about it through containsKey would be incoherent.
  • FilteredParameters implements Iterator<Object @Nullable []>next() was already declared to
    answer null and the implements clause said the opposite. A type-use annotation does not change type
    identity for javac, so this touches neither ParameterHolder nor the published
    IDataProviderInterceptor, and NullAway reports nothing either way.

The six Kotlin ones are the setters of already-nullable getters —
BaseTestMethod.setTestClass/setMissingGroup/setDescription/setXmlTest, ClonedMethod.setId,
TestResult.setTestName. Kotlin only synthesises a mutable property when both halves agree; without
them six properties become read-only for every Kotlin caller. Each was proven load-bearing by
deleting it and recompiling a throwaway Kotlin file that assigns all six.

That Kotlin evidence is only worth anything because the control failed as it should: assigning
ClassHelper.forName(...) to a non-null Class<*> gives
Initializer type mismatch: expected 'Class<*>', actual 'Class<*>?'. Kotlin does read the
annotations.

What this pre-commits

Marking org.testng next will have to widen these, because the implementations annotated here
already answer null: ITestNGMethod.getTestClass, getInstance, getMissingGroup,
getRetryAnalyzer, getDescription, getXmlTest, getDataProviderMethod (the only one whose
javadoc already documents it) and the deprecated getFactoryMethodParamsInfo; and on ITestResult,
getMethod, getName, getThrowable. getConstructorOrMethod is the one member saved from that
list, by the ! commit.

The largest downstream cost to budget for is MethodInstance:42-55, which dereferences
o1.getMethod().getTestClass() and starts reporting the moment getTestClass() is annotated.

Left alone on purpose

  • FilteredParameters still answers null for a filtered-out row. Restructuring the producer to
    skip instead was considered and rejected: hasNext() is handed to setMoreInvocationChecker and is
    called outside the iteration by ConfigurationGroupMethods and TestNgMethodUtils, so pre-fetching
    would pull rows from a possibly Stream-backed CloseableIterator during configuration
    scheduling — and MethodRunner counts the nulls to keep the reported parameter index aligned with
    -invocationnumbers. No test covers that alignment.
  • Residue, reported not removed. ConfigurationMethod:377 and :392 still test
    m_beforeGroups != null, and XmlMethodSelector:112-114 still wraps getBeforeGroups() in
    Optional.ofNullable. Both are now unreachable in-tree, but ITestNGMethod is not marked yet, so a
    third-party implementation can still return null. They belong to the org.testng batch. The guards
    already noted in PropertyUtils and ClassHelper.forName are untouched.
  • MultiMap was widened rather than given a shared non-null token. A NO_INSTANCE sentinel in
    IInstanceIdentity.getInstanceId(Object) would keep the collection's key non-null and preserve the
    grouping, at the cost of changing that helper's contract and the six sites that test its result for
    null — four of them outside this diff. Worth doing, but not inside a nullness PR.
  • ITestNGMethod.getConstructorOrMethod()'s 59 call sites were checked: not one tests the result,
    which is what makes the ! commit safe.
  • ConfigurationGroupMethods' latch protocol was raised in review previously and set aside: a race
    predating this batch, no test, outside the scope of a nullness PR.

Verification

./gradlew build          # BUILD SUCCESSFUL in 5m 49s
                         # :testng-core:test 16938 completed, 0 failed, 12 skipped
                         # 0 failed in every other module

The four modules compile clean under a forced run of the guard set at the real severity, with the
scaffolding used to collect the diagnostics reverted (git status --porcelain build-logic empty):

./gradlew :testng-core-api:compileJava :testng-core:compileJava :testng-runner-api:compileJava \
          :testng-yaml:compileJava :testng-test-kit:compileJava \
          :testng-test-kit:compileKotlin :testng-core:compileTestKotlin --rerun-tasks
# 0 [NullAway], 0 javac errors

Dropping the ! commit leaves the branch green — verified by rebasing it out and building:
BUILD SUCCESSFUL, 16938 completed, 0 failed, 12 skipped. It replaces a @Nullable landed earlier
in the branch, which is what makes that possible.

juherr added 11 commits August 19, 2026 13:43
Marking org.testng.internal reaches four modules, and the packages that were
already marked answer for what they receive from it.

XmlPackage no longer hands a null package name to PackageUtils: an unnamed
<package> tag is now reported the way an unreadable one already was, instead of
raising a NullPointerException from inside findClassesInPackage.

The other three are checker-visibility fixes with no behaviour change:
defaultIfStringEmpty inlines its predicate because a nullness test hidden behind
a call does not refine the argument on the other branch, XmlWeaver tests the
class directly instead of through a boolean local, and getSkipCausedBy reads the
method and the context into locals it already had in hand.
…ations

BaseTestMethod.m_instance was declared non-null while its own getInstanceId()
answered through ofNullable(...).orElse(null) and getFactoryParameterInfo()
tested it. Saying so resolves the constructors of TestNGMethod,
ConfigurationMethod and FactoryMethod in one move, and carries through to
getInstanceId() and IInstanceIdentity.

Three latent NullPointerExceptions surfaced while writing the contracts down:

- TestNGMethod.clone() wrapped getTestClass() in a NoOpTestClass, which
  dereferences it on the spot. A method cloned before setTestClass has run threw
  there; it now propagates the absence, which is what ConfigurationMethod.clone()
  already did.
- TestNGMethodFinder wrote null into m_beforeGroups/m_afterGroups, whose
  declaration says {}. Every configuration method that is not a group one carried
  a null array past MethodGroupsHelper, which iterates it. The default is used
  instead, and the guards that anticipated the null in XmlMethodSelector and
  ConfigurationMethod become residue.
- MethodInstance.SORT_BY_INDEX compared two <test> names without either being
  guaranteed to have one.

BaseTestMethod.getTestClass() stays nullable: setTestClass is called late in the
lifecycle by code outside TestNG, so findMethodParameters answers an absent test
class with the suite and <test> parameters - what XmlTestUtils computes anyway
when no <class> tag matches.

The setters of the nullable getters are widened with them. Kotlin only
synthesises a mutable property when both halves agree; leaving setTestClass,
setMissingGroup, setDescription and setXmlTest non-null would turn four
properties into read-only ones for every Kotlin caller.
Parameters carried the largest share of the diagnostics, behind seven causes
rather than twenty-six sites.

MethodParameters.context and .testResult are absent on the constructor
injection path, which SimpleObjectDispenser reaches before any test context
exists. The callees that store the null answer for it - ReflectionRecipes.inject
already tested the context internally, MethodMatcherContext was handed a literal
null, and CreationAttributes held it in an already nullable field. The one that
dereferences it, invokeDataProvider, is given a context asserted at the boundary
instead.

ConstructorOrMethod gains requireConstructor(), the twin of requireMethod(): the
three sites that reach for the constructor have already established the wrapper
holds one, and widening IAnnotationFinder.findOptionalValues or
ReflectionRecipes.getConstructorParameters to say otherwise would loosen
contracts that third parties implement.

Two facts had been recorded in a second variable and were lost on the way:
the retry analyzer's existence lived in shouldRetry, and the data provider
iterator's in thrownException. Both are now read from the value itself. The
retry analyzer also gets a message: the dispenser can answer null, and the
NullPointerException now names what could not be created.

The remaining sites are private helpers whose callers already tested their
result, and a nullness test that NullAway cannot follow through a boolean local.
…and graphs

The rest of the package: class discovery, method collection, the dependency
graphs and the invokers.

Two facts had to stop travelling through a field. Graph.m_independentNodes was
built lazily and read back off the field afterwards, so any call in between
invalidated what the initialiser had just established; initializeIndependentNodes
now hands the map back. ConfigurationGroupMethods.m_afterGroupsMap had the same
shape, read from two lambdas that ran after the assignment.

MultiMap says what it is: a HashMap-backed multimap. A method that carries no
instance has no instance id, and both the instance dependency graph and the
per-instance workers already grouped those methods under a null key. Keying them
anywhere else would change how they are partitioned.

The rest are local: private helpers whose callers already tested their result,
lookups whose key came from the very map being read, an AtomicReference<Boolean>
holding a fact that is a boolean, and the reflective members that are a method
because their call site already established it is not a constructor.

Where the value must exist for the caller to work at all, it is asserted at the
boundary rather than carried further: TestNGClassFinder and ParameterHandler
dispense instances through an object factory, so they say so once instead of
threading the absence down to SimpleObjectDispenser.
Its next() was already declared to answer null; the implements clause said the
opposite. A type-use annotation does not change type identity for javac, so the
clause can now say the same thing without touching Parameters, ParameterHolder
or the published IDataProviderInterceptor, and NullAway reports nothing new.

The class comment names the three consumers that read the null, so the checks in
MethodRunner and FactoryMethod do not read as dead code. Restructuring the
producer to skip rather than answer null is deliberately left alone: hasNext() is
handed to setMoreInvocationChecker and is called outside the iteration by
ConfigurationGroupMethods and TestNgMethodUtils, so pre-fetching would pull rows
from a possibly Stream-backed CloseableIterator during configuration scheduling,
and MethodRunner needs the nulls to keep its reported parameter index aligned.

ClonedMethod.setId and TestResult.setTestName are widened alongside their already
nullable getters, so Kotlin keeps synthesising a mutable property for both.
…not justify

Deleting each @nullable one at a time and recompiling showed three that nothing
asks for: initBeforeAfterGroups is only ever handed the arrays a @BeforeGroups
or @AfterGroups annotation reports, and two local variables whose nullness the
checker infers on its own.
The four modules that host org.testng.internal compile clean under the check.
Coverage was proven per module with a throwaway probe, as AGENTS.md requires:
before this file each of the four compiled clean, after it each failed with
"[NullAway] returning @nullable expression from method with @nonnull return
type" at the probe's own line, with no other javac error in the log.
getConstructorOrMethod() answered null while the class held the
java.lang.reflect.Method it was built from, so the wrapper is now built once in
the constructor and handed back.

The null was never a contract. All 59 call sites of
ITestNGMethod.getConstructorOrMethod() in main dereference the result on the
spot, and ClonedMethod's own toString() reads getDeclaringClass() off it, so
printing a ClonedMethod threw a NullPointerException every time. Keeping the
member non-null is also what lets ITestNGMethod.getConstructorOrMethod() stay
non-null when org.testng is marked in turn - every other member of that
interface this batch touches has to widen.

Dropping this commit leaves the branch green: the annotation it replaces is the
@nullable landed earlier in the branch.
FactoryMethod unwrapped an IParameterInfo by hand where
IParameterInfo.embeddedInstance already does it, and four other sites call it.

XmlTestUtils.findMethodParameters answers an absent class name with the suite
and <test> parameters on its own, so BaseTestMethod states the rule by handing
the null down rather than reproducing the result.

TestNGMethodFinder builds its group arrays empty from the start instead of
creating a null and undoing it 67 lines later, ConfigurationGroupMethods caches
its group map through an accessor the way Graph does rather than threading it
through two private methods, and Parameters drops an initial value that is never
read along with the comment that had to explain it.
@juherr
juherr requested a review from krmahadevan as a code owner August 19, 2026 18:56
@coderabbitai

coderabbitai Bot commented Aug 19, 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: 2f4163d0-0306-4a26-95e9-afde48161f16

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

juherr commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Superseded by #3396, which carries the same 11 commits from a branch on this repository instead of the fork. A pull request whose head lives in a fork cannot be stacked on #3393, so the diff and the auto-retarget on merge only behave correctly from here.

-- Claude

@juherr juherr closed this Aug 19, 2026
@juherr
juherr deleted the juherr/nullmarked-internal branch August 19, 2026 19: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.

1 participant