Skip to content

build: adopt OpenRewrite with a hand-curated recipe list - #3308

Merged
juherr merged 7 commits into
testng-team:masterfrom
juherr:build/openrewrite
Jul 29, 2026
Merged

build: adopt OpenRewrite with a hand-curated recipe list#3308
juherr merged 7 commits into
testng-team:masterfrom
juherr:build/openrewrite

Conversation

@juherr

@juherr juherr commented Jul 29, 2026

Copy link
Copy Markdown
Member

Related to #3292, where @vlsi mentioned planning to move the Java part of the formatting setup to
OpenRewrite. This PR does not touch Autostyle — it adds OpenRewrite alongside it, for
refactoring rather than formatting, and makes the two agree on import layout.

What

Adds OpenRewrite to the build and applies a hand-picked set of recipes to the main sources only.
Five commits, reviewable independently:

Commit Content
build: add OpenRewrite with a hand-curated recipe list config only, no source change
build: make the skipAutostyle build parameter actually work pre-existing bug, 1 file
ci: fail the build when OpenRewrite still has changes to make new OpenRewrite job
refactor: apply the OpenRewrite recipes to the main sources 45 files, all src/main
docs: document the OpenRewrite workflow CONTRIBUTING + CHANGES

Recipes applied

The full list lives in rewrite.yml. What actually matched:

Recipe Files
NeedBraces 22
UnnecessaryParentheses 22
InlineVariable 11
EqualsAvoidsNull 3
RemoveExtraSemicolons 2
MultipleVariableDeclarations 1
ReplaceStringConcatenationWithStringValueOf 1
org.openrewrite.java.migrate.nio.file.RedundantUtf8Charset 1

The other 44 recipes in the list matched nothing and are kept so future contributions are covered
by the same list.

Honest note on the Java 8 → Java 11 migration: it is worth one file. Every other leaf of
Java8toJava11 matched nothing, because this codebase was already clean of the idioms it targets.

Why the upstream composites are not used

CommonStaticAnalysis, CodeCleanup and Java8toJava11 are not activated. OpenRewrite has no
way to exclude a single sub-recipe from a composite — exclusion() filters file paths only, and
the upstream request is closed as won't-fix
(openrewrite/rewrite#1714).

Measured on src/main, CommonStaticAnalysis touches 101 files. About half overlaps with the
list we want; the rest comes from recipes that must not run here:

Recipe Files Problem
ExplicitInitialization 46 field-declaration churn, no benefit
FinalClass 12 adds final to OSGi-exported classes
FinalizePrivateFields 10 adds final to fields TestNG writes reflectively
AbstractClassPublicConstructor 6 narrows constructors on exported classes
ReplaceLambdaWithMethodReference 4 changes synthetic class shape
SimplifyBooleanExpression 2 unsound on float comparisons (see below)
OrderImports 1 fights google-java-format

There is no japicmp/revapi gate protecting the exported org.testng.* packages, so the API- and
reflection-affecting recipes are disqualifying on their own. Java8toJava11 is rejected separately:
it would add JAXB, JAX-WS, javax.inject and javax.annotation runtime dependencies to a
library that deliberately has almost none.

Also rejected: rewrite-testing-frameworks (its only TestNG recipe migrates TestNG off its own
API), OwaspTopTen (pulls in Spring, Jackson, servlet — none used), Gradle/dependency-update
recipes (Dependabot covers this), and anything needing Java 15+/16+ since the target stays Java 11.

Making OpenRewrite and Autostyle agree

OpenRewrite auto-detects a formatting style when none is declared, and here it infers a layout that
groups java.*/javax.* separately — not what google-java-format produces. Declaring an
ImportLayoutStyle fixes it:

OrderImports over src/main Files changed
auto-detected style (default) 34
declared org.testng.build.ImportLayout 1

So import ordering is not a reason to avoid the composites — it is configurable, and configured.

SimplifyBooleanExpression stays out, deliberately

It rewrites !(a <= b) to a > b, which is not equivalent for NaN: every comparison against NaN
is false, so the negated form fails an assertion as intended while the > form silently passes.

The assertion code that made this concrete — AssertJUnit.assertEquals(double, double, double) and
Assert.areEqual — has since moved to the separate org.testng:testng-asserts artifact, and a
dry-run today shows the recipe touching only two benign isEmpty() ternaries. It stays excluded
anyway: the gain is cosmetic, while enabling it would put a rewrite that is unsound on
floating-point comparisons behind a CI gate that rewrites new code automatically.

The CI gate

A dedicated OpenRewrite job runs rewriteDryRun with failOnRewriteDryRun=true. The gate is the
Gradle task itself, so ./gradlew rewriteDryRun -PfailOnRewriteDryRun=true reproduces it exactly —
unlike a shell check that only exists inside GitHub Actions.

It is its own job rather than a matrix row (the matrix produces 8 jobs; this would be 8× the cost
for identical output) and has no needs:, so wall-clock impact is nil. Test compilation and Error
Prone are skipped — test sources are excluded from rewriting so compiling them feeds nothing into
the LST, and Error Prone is a javac plugin OpenRewrite never sees. Measured ~86s → ~41s locally,
with the gate still failing on drift.

Scope: main sources only

**/src/test/** is excluded. The test tree mixes real tests with fixture classes whose method
names, declaration order and finalize() presence are exactly what the surrounding tests assert on,
and testng-core/src/test/resources/testng.xml references test classes by fully qualified name, so
a moved class would silently drop tests instead of failing. Because no test file is touched, that
hazard is unreachable here:

git diff --name-only upstream/master... | grep -c '/src/test/'   # 0

Verification

  • ./gradlew build green: 12624 tests, 0 failures.
  • ./gradlew rewriteDryRun produces an empty patch — the recipes are idempotent and the
    committed config matches the committed sources.
  • No file outside src/main is modified except the config, workflow and docs this PR adds.
  • The skipAutostyle fix verified both ways against a deliberate formatting violation.
  • Every diff hunk was read; the non-syntactic ones are explained in the refactor: commit message.

Non-goals

No Gradle or JDK changes, no Autostyle → Spotless swap, no CI rework beyond the added job.
rewriteRun is not wired into check or build.

Summary by CodeRabbit

  • New Features
    • Added automated code modernization to the build and CI, including a dry-run that can fail when pending updates remain.
  • Bug Fixes
    • Improved null-safety in HTML/suite navigation and safer URL/protocol/scheme handling to reduce risk of runtime errors.
  • Documentation
    • Documented how to preview, apply, and troubleshoot modernization changes, including CI behavior and recommended Gradle command usage.
  • Style
    • Applied widespread formatting/readability cleanups across the codebase without intended behavior changes.

juherr added 5 commits July 29, 2026 14:51
Wire the OpenRewrite Gradle plugin at the root project and pin the recipe list in
rewrite.yml. No source changes.

The upstream composites are deliberately not activated. OpenRewrite offers no way
to exclude a single sub-recipe from a composite -- exclusion() filters file paths
only, and the upstream request is closed as won't-fix (openrewrite/rewrite#1714).
Measured on src/main, CommonStaticAnalysis touches 101 files; about half overlaps
with the list we want, and the rest comes from recipes that must not run here:
ExplicitInitialization (46), FinalClass (12), FinalizePrivateFields (10),
AbstractClassPublicConstructor (6) and ReplaceLambdaWithMethodReference (4) alter
an OSGi-exported API with no binary-compatibility gate, or add final to fields
TestNG writes reflectively. Java8toJava11 is rejected separately: it would add
JAXB, JAX-WS, javax.inject and javax.annotation runtime dependencies to a library
that deliberately has almost none.

Two things make the setup behave:

An ImportLayoutStyle matching google-java-format is declared, because OpenRewrite
otherwise auto-detects a layout that groups java.*/javax.* separately. Measured,
OrderImports rewrites 34 files under auto-detection and 1 with the style active.

failOnDryRunResults is wired to a new failOnRewriteDryRun build parameter rather
than hard-coded, so the CI gate is the Gradle task itself and
./gradlew rewriteDryRun -PfailOnRewriteDryRun=true reproduces it locally. This
also requires build-logic.build-params at the root, which was not applied there.

Scope is src/main only. Test sources are excluded because test/** mixes real tests
with fixture classes whose method names, declaration order and finalize() presence
are what the surrounding tests assert on, and because testng.xml references test
classes by fully qualified name, so a moved class would silently drop tests.
skipAutostyle has been declared in build-parameters since it was introduced, but
nothing ever read it: testng.java applied testng.style unconditionally from its
plugins block. Passing -PskipAutostyle=true therefore did nothing at all, while
the neighbouring -PskipErrorProne=true worked, because that one is applied
conditionally at the bottom of the same file.

Apply testng.style the same way Error Prone is applied, so the two parameters
behave consistently.

Verified by appending a deliberate formatting violation to a source file:

  ./gradlew :testng-collections:check -PskipAutostyle=true   ->  passes (was: failed)
  ./gradlew :testng-collections:check                        ->  fails on the violation

Note this means the autostyle tasks do not exist when the parameter is set, so
invoking autostyleCheck explicitly alongside it fails with "task not found". That
matches how skipErrorProne already behaves; the parameter is meant for whole-build
invocations such as ./gradlew build -PskipAutostyle=true.
Without a gate the cleanup decays: the next pull request reintroduces the idioms
the recipes just removed, and nothing notices.

The gate is the Gradle task, not shell glue -- the job passes
failOnRewriteDryRun=true, so ./gradlew rewriteDryRun -PfailOnRewriteDryRun=true
reproduces it exactly. The shell step only summarises the patch on failure, and
the full patch is uploaded as an artifact.

Design notes:

  * It is its own job, not part of the build matrix, which produces 8 jobs across
    OS/JDK/timezone/locale. It has no "needs", so it starts immediately and runs
    alongside the matrix; wall-clock impact is nil.
  * rewriteDryRun stays out of check/build, so a normal local build is unaffected.
  * compileTestJava/compileTestKotlin are excluded. rewriteDryRun dependsOn every
    source set's compileJava, but test sources are excluded from rewriting, so
    compiling them feeds nothing into the LST. Error Prone is skipped for the same
    reason -- it is a javac plugin OpenRewrite never sees, and the matrix runs it.
    Measured roughly 86s -> 41s locally, with the gate still failing on drift.
  * -Xmx3g is provisioned: rewriteDryRun builds a type-attributed LST of every
    main source file and OOMed once under Gradle's default heap.

One caveat worth stating: this trains contributors to run rewriteRun to make CI
green. That is only acceptable because the recipe list is curated and each entry
was reviewed -- a hard gate on a composite recipe would be teaching people to
apply unreviewed semantic rewrites.
Apply org.testng.build.ModernizeMainSources, then ./gradlew autostyleApply so
google-java-format has the last word on formatting. 45 files, all under src/main.

Recipes that matched:

  NeedBraces                                  22
  UnnecessaryParentheses                      22
  InlineVariable                              11
  EqualsAvoidsNull                             3
  RemoveExtraSemicolons                        2
  MultipleVariableDeclarations                 1
  ReplaceStringConcatenationWithStringValueOf  1
  org.openrewrite.java.migrate.nio.file.RedundantUtf8Charset  1

The other 44 recipes in the list matched nothing. That is the honest yield: the
Java 8 to Java 11 API migration is worth exactly one file, because this codebase
was already clean of the idioms those recipes target.

Notes on the hunks that are not purely syntactic:

EqualsAvoidsNull flips "value.equals(LITERAL)" to "LITERAL.equals(value)". Where
value is null this yields false instead of throwing NullPointerException; every
call site here is a filter predicate or a branch condition where false is the
correct answer, and in one case it subsumes an explicit null check.

UnnecessaryParentheses only drops parentheses redundant within their own
expression, keeping the grouping pair, so "a + ((x == null) ? 0 : y)" becomes
"a + (x == null ? 0 : y)" rather than losing precedence.

RedundantUtf8Charset drops the explicit UTF_8 argument from
Files.newBufferedWriter, which documents UTF-8 as its default.

No test source and no build script is touched, so the set of executed tests is
structurally unchanged. Full build green: 12624 tests, 0 failures.
Describe rewriteDryRun/rewriteRun in CONTRIBUTING.md, stress that autostyleApply
must run afterwards as a separate Gradle invocation, give the exact command that
reproduces the CI gate, point at rewrite.yml as the recipe list, and note the
configuration-cache incompatibility. Add a CHANGES.txt entry.
@juherr
juherr requested a review from krmahadevan as a code owner July 29, 2026 13:30
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 62e9ee61-ca64-4569-9a05-ce2228c2cb9c

📥 Commits

Reviewing files that changed from the base of the PR and between c04c23c and 0014fdc.

📒 Files selected for processing (2)
  • CHANGES.txt
  • testng-core-api/src/main/java/org/testng/xml/XmlTest.java

📝 Walkthrough

Walkthrough

OpenRewrite is configured with selected recipes and import formatting, integrated into Gradle and CI, documented for contributors, and applied to Java main-source files through syntax, null-safety, control-flow, and formatting changes.

Changes

OpenRewrite modernization

Layer / File(s) Summary
Recipe and import-layout definitions
rewrite.yml
Defines modernization recipes, exclusions, import layout, and safety constraints.
Gradle configuration and source application
build.gradle.kts, build-logic/...
Adds OpenRewrite dependencies and configuration, wires dry-run failure handling, and conditionally applies autostyle.
CI validation and contributor workflow
.github/workflows/test.yml, .github/CONTRIBUTING.md, CHANGES.txt
Adds the rewrite dry-run job, patch artifact handling, contributor instructions, and changelog entry.
Core API source modernization
testng-collections/..., testng-core-api/...
Applies formatting and expression rewrites to core API, reporter, and XML model classes.
Core runtime and reporting modernization
testng-core/..., testng-runner-api/...
Applies syntax, null-safety, control-flow, expression, and output-format rewrites across runtime and reporting sources.

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

Sequence Diagram(s)

sequenceDiagram
  participant RewriteCI
  participant GradleRewrite
  participant RewriteRecipes
  participant RewritePatch
  RewriteCI->>GradleRewrite: run rewriteDryRun with failOnRewriteDryRun=true
  GradleRewrite->>RewriteRecipes: load ModernizeMainSources and ImportLayout
  RewriteRecipes->>RewritePatch: generate rewrite.patch for remaining changes
  RewriteCI->>RewritePatch: upload patch artifact when the job fails
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.63% 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 matches the main change: adding OpenRewrite to the build with a curated recipe list.
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
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/CONTRIBUTING.md:
- Around line 117-120: Label the fenced shell command block in the contributing
documentation by adding the shell language identifier to its opening fence,
while leaving the commands unchanged.

In @.github/workflows/test.yml:
- Around line 40-42: Add a job-level permissions block to the rewrite job,
identified by the rewrite key, granting only contents: read. Keep the existing
job name and runner configuration unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2acd06bb-c6e7-4711-95d3-eaca9e6f59b4

📥 Commits

Reviewing files that changed from the base of the PR and between 5f79329 and 56c7bac.

📒 Files selected for processing (52)
  • .github/CONTRIBUTING.md
  • .github/workflows/test.yml
  • CHANGES.txt
  • build-logic/build-parameters/build.gradle.kts
  • build-logic/jvm/src/main/kotlin/testng.java.gradle.kts
  • build.gradle.kts
  • rewrite.yml
  • testng-collections/src/main/java/org/testng/collections/Objects.java
  • testng-core-api/src/main/java/org/testng/internal/AutoCloseableLock.java
  • testng-core-api/src/main/java/org/testng/internal/ConstructorOrMethod.java
  • testng-core-api/src/main/java/org/testng/internal/KeyAwareAutoCloseableLock.java
  • testng-core-api/src/main/java/org/testng/internal/Utils.java
  • testng-core-api/src/main/java/org/testng/reporters/FileStringBuffer.java
  • testng-core-api/src/main/java/org/testng/reporters/XMLUtils.java
  • testng-core-api/src/main/java/org/testng/xml/XmlClass.java
  • testng-core-api/src/main/java/org/testng/xml/XmlDefine.java
  • testng-core-api/src/main/java/org/testng/xml/XmlInclude.java
  • testng-core-api/src/main/java/org/testng/xml/XmlMethodSelector.java
  • testng-core-api/src/main/java/org/testng/xml/XmlPackage.java
  • testng-core-api/src/main/java/org/testng/xml/XmlSuite.java
  • testng-core-api/src/main/java/org/testng/xml/XmlTest.java
  • testng-core/src/main/java/org/testng/Converter.java
  • testng-core/src/main/java/org/testng/JarFileUtils.java
  • testng-core/src/main/java/org/testng/SuiteRunner.java
  • testng-core/src/main/java/org/testng/TestRunner.java
  • testng-core/src/main/java/org/testng/internal/ClassBasedWrapper.java
  • testng-core/src/main/java/org/testng/internal/IObject.java
  • testng-core/src/main/java/org/testng/internal/MethodHelper.java
  • testng-core/src/main/java/org/testng/internal/MethodInstance.java
  • testng-core/src/main/java/org/testng/internal/Parameters.java
  • testng-core/src/main/java/org/testng/internal/TestNGClassFinder.java
  • testng-core/src/main/java/org/testng/internal/WrappedTestNGMethod.java
  • testng-core/src/main/java/org/testng/internal/XmlMethodSelector.java
  • testng-core/src/main/java/org/testng/internal/Yaml.java
  • testng-core/src/main/java/org/testng/internal/annotations/AnnotationHelper.java
  • testng-core/src/main/java/org/testng/internal/collections/Pair.java
  • testng-core/src/main/java/org/testng/internal/invokers/ConfigInvoker.java
  • testng-core/src/main/java/org/testng/internal/invokers/MethodRunner.java
  • testng-core/src/main/java/org/testng/internal/invokers/ParameterHandler.java
  • testng-core/src/main/java/org/testng/internal/objects/GuiceHelper.java
  • testng-core/src/main/java/org/testng/internal/objects/ObjectFactoryImpl.java
  • testng-core/src/main/java/org/testng/internal/reflect/ReflectionRecipes.java
  • testng-core/src/main/java/org/testng/reporters/EmailableReporter2.java
  • testng-core/src/main/java/org/testng/reporters/JUnitReportReporter.java
  • testng-core/src/main/java/org/testng/reporters/MethodInvocationKey.java
  • testng-core/src/main/java/org/testng/reporters/SuiteHTMLReporter.java
  • testng-core/src/main/java/org/testng/reporters/VerboseReporter.java
  • testng-core/src/main/java/org/testng/reporters/jq/Model.java
  • testng-core/src/main/java/org/testng/reporters/jq/NavigatorPanel.java
  • testng-core/src/main/java/org/testng/reporters/jq/TestNgXmlPanel.java
  • testng-core/src/main/java/org/testng/xml/TestNGContentHandler.java
  • testng-runner-api/src/main/java/org/testng/internal/LiteWeightTestNGMethod.java

Comment thread .github/CONTRIBUTING.md Outdated
Comment thread .github/workflows/test.yml
The job only checks out the repository and uploads an artifact, so grant it
contents: read explicitly rather than letting it inherit the workflow default.
Other workflows here already declare permissions (combine-prs, label-commenter);
test.yml did not.

Also label the shell block added to CONTRIBUTING.md, matching the bash fence the
file already uses for its other command block.
@juherr

juherr commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

@krmahadevan LGTM, ready for your review

@juherr
juherr merged commit 581a90e into testng-team:master Jul 29, 2026
3 of 4 checks passed
@juherr
juherr deleted the build/openrewrite branch July 29, 2026 16: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