From e6a74b2f4939e31943097659f6161f8c2b15ab39 Mon Sep 17 00:00:00 2001 From: Vladimir Sitnikov Date: Fri, 10 Jul 2026 00:06:08 +0300 Subject: [PATCH 1/4] build: add OpenRewrite code-cleanup integration (rewrite plugin 7.35.0) Rebuild the OpenRewrite integration from PR #6217 on top of current master. The 2024 attempt hit a hard blocker: with rewrite-gradle-plugin 6.6.3 the Java parser could not resolve Kotlin classes declared in the same module (e.g. JFactory.java -> JEditableCheckBox.kt failed with "cannot find symbol"). Plugin 7.35.0 on Gradle 9.2.1 no longer has that problem. The Java parser sees the module's compiled Kotlin output through compileClasspath, so mixed Java/Kotlin modules (jorphan, components, core) parse with full type information. FindMissingTypes reports zero missing types on the Java side. The old approach is reduced to a single convention plugin plus rewrite.yml: - build-logic.openrewrite applies org.openrewrite.rewrite and the static-analysis recipes, which run cleanly on both Java and Kotlin; - build-logic.style applies it to every non-root project (the plugin infects allprojects when applied to the root) and wires rewriteRun/rewriteDryRun into the style/styleCheck tasks; - a skipOpenrewrite build parameter gates the whole thing. Two residual issues, both narrower than the original blocker: - rewrite-kotlin still leaves some Kotlin idioms without type attribution (scope-function apply {} constructors, labelled return@); non-fatal. - the rewrite-testing-frameworks JUnit 5 recipes are Java-only and throw on Kotlin test sources, so they are gated to modules without src/test/kotlin. skipOpenrewrite defaults to true because the OpenRewrite plugin and recipe artifacts are not yet in gradle/verification-metadata.xml. Until they are, run with -PskipOpenrewrite=false --dependency-verification=lenient. Refs: https://github.com/apache/jmeter/pull/6217 Co-Authored-By: Claude Opus 4.8 --- build-logic/build-parameters/build.gradle.kts | 4 + build-logic/jvm/build.gradle.kts | 1 + .../kotlin/build-logic.openrewrite.gradle.kts | 54 ++++++ .../main/kotlin/build-logic.style.gradle.kts | 44 ++++- config/openrewrite/rewrite.yml | 163 ++++++++++++++++++ 5 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts create mode 100644 config/openrewrite/rewrite.yml diff --git a/build-logic/build-parameters/build.gradle.kts b/build-logic/build-parameters/build.gradle.kts index a7cb17d8bfc..9b450361865 100644 --- a/build-logic/build-parameters/build.gradle.kts +++ b/build-logic/build-parameters/build.gradle.kts @@ -82,6 +82,10 @@ buildParameters { defaultValue.set(true) description.set("Skip forbidden-apis verifications") } + bool("skipOpenrewrite") { + defaultValue.set(true) + description.set("Skip OpenRewrite code cleanup (rewriteRun/rewriteDryRun). Enabled off by default until the recipe artifacts are added to gradle/verification-metadata.xml") + } bool("suppressPomMetadataWarnings") { defaultValue.set(true) description.set("Skip suppressPomMetadataWarningsFor warnings triggered by inability to map test fixtures dependences to Maven pom.xml") diff --git a/build-logic/jvm/build.gradle.kts b/build-logic/jvm/build.gradle.kts index ec1429c55b0..978d27813fd 100644 --- a/build-logic/jvm/build.gradle.kts +++ b/build-logic/jvm/build.gradle.kts @@ -28,4 +28,5 @@ dependencies { api("org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin:2.2.21") api("org.jetbrains.kotlin.kapt:org.jetbrains.kotlin.kapt.gradle.plugin:2.2.21") api("org.jetbrains.dokka-javadoc:org.jetbrains.dokka-javadoc.gradle.plugin:2.1.0") + api("org.openrewrite.rewrite:org.openrewrite.rewrite.gradle.plugin:7.35.0") } diff --git a/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts b/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts new file mode 100644 index 00000000000..e810dedef48 --- /dev/null +++ b/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("org.openrewrite.rewrite") +} + +dependencies { + // rewrite-kotlin, rewrite-java, rewrite-groovy and the other language parsers + // ship with the Gradle plugin, so only the recipe modules are declared here. + rewrite(platform("org.openrewrite.recipe:rewrite-recipe-bom:latest.release")) + rewrite("org.openrewrite.recipe:rewrite-static-analysis") + rewrite("org.openrewrite.recipe:rewrite-testing-frameworks") +} + +rewrite { + configFile = rootProject.file("config/openrewrite/rewrite.yml") + // Report violations instead of failing the dry run so the aggregate + // styleCheck task can collect the reports from every module. + failOnDryRunResults = false + + activeStyle("org.apache.jmeter.style.Style") + + // See config/openrewrite/rewrite.yml + // These static-analysis recipes run cleanly on both Java and Kotlin sources. + activeRecipe("org.apache.jmeter.staticanalysis.CodeCleanup") + activeRecipe("org.apache.jmeter.staticanalysis.CommonStaticAnalysis") + + // The JUnit 5 recipes from rewrite-testing-frameworks are Java-only: their + // JavaTemplate-based rewrites throw on Kotlin test sources, e.g. + // AssertThrowsOnLastStatement fails on jorphan's RandomStringGeneratorTest.kt + // with "Expected a template that would generate exactly one statement ...". + // Enable them only for modules whose tests are pure Java. + if (!file("src/test/kotlin").isDirectory) { + plugins.withId("build-logic.test-junit5") { + activeRecipe("org.openrewrite.java.testing.junit5.JUnit5BestPractices") + activeRecipe("org.openrewrite.java.testing.junit5.CleanupAssertions") + } + } +} diff --git a/build-logic/verification/src/main/kotlin/build-logic.style.gradle.kts b/build-logic/verification/src/main/kotlin/build-logic.style.gradle.kts index 4ecf9ca094d..65541d7033b 100644 --- a/build-logic/verification/src/main/kotlin/build-logic.style.gradle.kts +++ b/build-logic/verification/src/main/kotlin/build-logic.style.gradle.kts @@ -15,6 +15,7 @@ * limitations under the License. */ +import com.github.autostyle.gradle.AutostyleTask import org.gradle.kotlin.dsl.apply import org.gradle.language.base.plugins.LifecycleBasePlugin @@ -26,6 +27,14 @@ if (!buildParameters.skipAutostyle) { apply(plugin = "build-logic.autostyle") } +// The OpenRewrite Gradle plugin applies its recipes to allprojects when it is +// applied to the root project, so the workaround is to avoid applying it to the root. +val skipOpenrewrite = project == rootProject || buildParameters.skipOpenrewrite + +if (!skipOpenrewrite) { + apply(plugin = "build-logic.openrewrite") +} + val skipCheckstyle = buildParameters.skipCheckstyle || run { logger.info("Checkstyle requires Java 11+") buildParameters.buildJdkVersion < 11 @@ -55,10 +64,27 @@ if (!skipCheckstyle && !buildParameters.skipAutostyle) { } } -if (!buildParameters.skipAutostyle || !skipCheckstyle || !buildParameters.skipForbiddenApis) { +// OpenRewrite fixes many warnings, so it should run before the other verifications +if (!skipOpenrewrite) { + if (!buildParameters.skipAutostyle) { + tasks.withType().configureEach { + mustRunAfter("rewriteRun", "rewriteDryRun") + } + } + if (!skipCheckstyle) { + tasks.withType().configureEach { + mustRunAfter("rewriteRun", "rewriteDryRun") + } + } +} + +if (!buildParameters.skipAutostyle || !skipCheckstyle || !buildParameters.skipForbiddenApis || !skipOpenrewrite) { tasks.register("style") { group = LifecycleBasePlugin.VERIFICATION_GROUP description = "Formats code (license header, import order, whitespace at end of line, ...) and executes Checkstyle verifications" + if (!skipOpenrewrite) { + dependsOn("rewriteRun") + } if (!buildParameters.skipAutostyle) { dependsOn("autostyleApply") } @@ -69,6 +95,22 @@ if (!buildParameters.skipAutostyle || !skipCheckstyle || !buildParameters.skipFo dependsOn("forbiddenApis") } } + tasks.register("styleCheck") { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Reports code style violations (license header, import order, whitespace at end of line, ...)" + if (!skipOpenrewrite) { + dependsOn("rewriteDryRun") + } + if (!buildParameters.skipAutostyle) { + dependsOn("autostyleCheck") + } + if (!skipCheckstyle) { + dependsOn("checkstyleAll") + } + if (!buildParameters.skipForbiddenApis) { + dependsOn("forbiddenApis") + } + } } tasks.register("checkstyle") { diff --git a/config/openrewrite/rewrite.yml b/config/openrewrite/rewrite.yml new file mode 100644 index 00000000000..1a388670669 --- /dev/null +++ b/config/openrewrite/rewrite.yml @@ -0,0 +1,163 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +--- +# See reference at https://docs.openrewrite.org/concepts-explanations/styles#declarative-styles +type: specs.openrewrite.org/v1beta/style +name: org.apache.jmeter.style.Style +styleConfigs: + - org.openrewrite.java.style.TabsAndIndentsStyle: + # See https://github.com/openrewrite/rewrite/issues/3666 + # For now, it should be the same as in .editorconfig + useTabCharacter: false + tabSize: 2 + indentSize: 2 + continuationIndent: 4 + - org.openrewrite.java.style.SpacesStyle: + # See https://github.com/openrewrite/rewrite/blob/1d0aeec11e2d7f239f2f656bfd964b17ca8e4d17/rewrite-java/src/main/java/org/openrewrite/java/style/SpacesStyle.java#L26C14-L26C25 + other: + afterForSemicolon: true + - org.openrewrite.java.style.NoWhitespaceAfterStyle: + # This is to workaround https://github.com/openrewrite/rewrite/issues/3869 + arrayDeclarator: false + - org.openrewrite.java.style.ImportLayoutStyle: + classCountToUseStarImport: 999 + nameCountToUseStarImport: 999 + # The order of the imports should match ij_java_imports_layout in .editorconfig + layout: + - import static all other imports + - + - import java.* + - + - import javax.* + - + - import org.* + - + - import net.* + - + - import com.* + - + - import all other imports +--- +type: specs.openrewrite.org/v1beta/recipe +name: org.apache.jmeter.staticanalysis.CodeCleanup +displayName: Code cleanup +description: Automatically cleanup code, e.g. remove unnecessary parentheses, simplify expressions. +recipeList: + # The list is taken from https://github.com/openrewrite/rewrite-static-analysis/blob/8c803a9c50b480841a4af031f60bac5ee443eb4e/src/main/resources/META-INF/rewrite/static-analysis.yml#L16C1-L42 + - org.openrewrite.staticanalysis.DefaultComesLast + - org.openrewrite.staticanalysis.EmptyBlock + - org.openrewrite.java.format.EmptyNewlineAtEndOfFile + - org.openrewrite.staticanalysis.ForLoopControlVariablePostfixOperators + - org.openrewrite.staticanalysis.FinalizePrivateFields + - org.openrewrite.java.format.MethodParamPad + - org.openrewrite.java.format.NoWhitespaceAfter + - org.openrewrite.java.format.NoWhitespaceBefore + - org.openrewrite.java.format.PadEmptyForLoopComponents + - org.openrewrite.staticanalysis.TypecastParenPad + - org.openrewrite.staticanalysis.EqualsAvoidsNull + - org.openrewrite.staticanalysis.ExplicitInitialization + - org.openrewrite.staticanalysis.FallThrough + # It breaks backward compatibility, so + # - org.openrewrite.staticanalysis.HideUtilityClassConstructor + - org.openrewrite.staticanalysis.NeedBraces + - org.openrewrite.staticanalysis.OperatorWrap + - org.openrewrite.staticanalysis.UnnecessaryParentheses + - org.openrewrite.staticanalysis.ReplaceThreadRunWithThreadStart + - org.openrewrite.staticanalysis.ChainStringBuilderAppendCalls + - org.openrewrite.staticanalysis.ReplaceStringBuilderWithString + - org.openrewrite.java.ShortenFullyQualifiedTypeReferences + - org.openrewrite.staticanalysis.MissingOverrideAnnotation + - org.openrewrite.java.OrderImports +--- +# Copied from https://github.com/openrewrite/rewrite-static-analysis/blob/8c803a9c50b480841a4af031f60bac5ee443eb4e/src/main/resources/META-INF/rewrite/common-static-analysis.yml#L17-L93 +type: specs.openrewrite.org/v1beta/recipe +name: org.apache.jmeter.staticanalysis.CommonStaticAnalysis +displayName: Common static analysis issues +description: Resolve common static analysis issues discovered through 3rd party tools. +recipeList: + # - org.openrewrite.staticanalysis.AddSerialVersionUidToSerializable + - org.openrewrite.staticanalysis.AtomicPrimitiveEqualsUsesGet + - org.openrewrite.staticanalysis.BigDecimalRoundingConstantsToEnums + - org.openrewrite.staticanalysis.BooleanChecksNotInverted + - org.openrewrite.staticanalysis.CaseInsensitiveComparisonsDoNotChangeCase + - org.openrewrite.staticanalysis.CatchClauseOnlyRethrows + - org.openrewrite.staticanalysis.ChainStringBuilderAppendCalls + - org.openrewrite.staticanalysis.CovariantEquals + - org.openrewrite.staticanalysis.DefaultComesLast + - org.openrewrite.staticanalysis.EmptyBlock + - org.openrewrite.staticanalysis.EqualsAvoidsNull + - org.openrewrite.staticanalysis.ExplicitInitialization + - org.openrewrite.staticanalysis.ExternalizableHasNoArgsConstructor + - org.openrewrite.staticanalysis.FinalizePrivateFields + # pgjdbc: see https://github.com/openrewrite/rewrite/issues/3668 + # - org.openrewrite.staticanalysis.FallThrough + # pgjdbc: it might break backward compatibility + # - org.openrewrite.staticanalysis.FinalClass + - org.openrewrite.staticanalysis.FixStringFormatExpressions + - org.openrewrite.staticanalysis.ForLoopIncrementInUpdate + # - org.openrewrite.staticanalysis.HideUtilityClassConstructor + - org.openrewrite.staticanalysis.IndexOfChecksShouldUseAStartPosition + - org.openrewrite.staticanalysis.IndexOfReplaceableByContains + - org.openrewrite.staticanalysis.IndexOfShouldNotCompareGreaterThanZero + - org.openrewrite.staticanalysis.InlineVariable + - org.openrewrite.staticanalysis.IsEmptyCallOnCollections + - org.openrewrite.staticanalysis.LambdaBlockToExpression + # - org.openrewrite.staticanalysis.LowercasePackage + - org.openrewrite.staticanalysis.MethodNameCasing + - org.openrewrite.staticanalysis.MinimumSwitchCases + - org.openrewrite.staticanalysis.ModifierOrder + - org.openrewrite.staticanalysis.MultipleVariableDeclarations + - org.openrewrite.staticanalysis.NeedBraces + - org.openrewrite.staticanalysis.NestedEnumsAreNotStatic + - org.openrewrite.staticanalysis.NewStringBuilderBufferWithCharArgument + - org.openrewrite.staticanalysis.NoDoubleBraceInitialization + - org.openrewrite.staticanalysis.NoEmptyCollectionWithRawType + - org.openrewrite.staticanalysis.NoEqualityInForCondition + - org.openrewrite.staticanalysis.NoFinalizer + - org.openrewrite.staticanalysis.NoPrimitiveWrappersForToStringOrCompareTo + - org.openrewrite.staticanalysis.NoRedundantJumpStatements + - org.openrewrite.staticanalysis.NoToStringOnStringType + - org.openrewrite.staticanalysis.NoValueOfOnStringType + - org.openrewrite.staticanalysis.ObjectFinalizeCallsSuper + - org.openrewrite.staticanalysis.PrimitiveWrapperClassConstructorToValueOf + - org.openrewrite.staticanalysis.RedundantFileCreation + - org.openrewrite.staticanalysis.RemoveExtraSemicolons + # - org.openrewrite.staticanalysis.RemoveRedundantTypeCast + - org.openrewrite.java.RemoveUnusedImports + # - org.openrewrite.staticanalysis.RemoveUnusedLocalVariables + # - org.openrewrite.staticanalysis.RemoveUnusedPrivateMethods + - org.openrewrite.staticanalysis.RenameLocalVariablesToCamelCase + - org.openrewrite.staticanalysis.RenameMethodsNamedHashcodeEqualOrTostring + - org.openrewrite.staticanalysis.RenamePrivateFieldsToCamelCase + - org.openrewrite.staticanalysis.ReplaceLambdaWithMethodReference + - org.openrewrite.staticanalysis.ReplaceStringBuilderWithString + - org.openrewrite.staticanalysis.SimplifyBooleanExpression + - org.openrewrite.staticanalysis.SimplifyBooleanReturn + - org.openrewrite.staticanalysis.StaticMethodNotFinal + - org.openrewrite.staticanalysis.StringLiteralEquality + - org.openrewrite.staticanalysis.UnnecessaryCloseInTryWithResources + - org.openrewrite.staticanalysis.UnnecessaryExplicitTypeArguments + - org.openrewrite.staticanalysis.UnnecessaryParentheses + - org.openrewrite.staticanalysis.UnnecessaryPrimitiveAnnotations + - org.openrewrite.staticanalysis.UpperCaseLiteralSuffixes + # - org.openrewrite.staticanalysis.UnnecessaryThrows + # - org.openrewrite.staticanalysis.UseCollectionInterfaces + - org.openrewrite.staticanalysis.UseDiamondOperator + - org.openrewrite.staticanalysis.UseJavaStyleArrayDeclarations + # https://github.com/openrewrite/rewrite-static-analysis/issues/10 + # - org.openrewrite.staticanalysis.UseLambdaForFunctionalInterface + # - org.openrewrite.staticanalysis.UseStringReplace + - org.openrewrite.staticanalysis.WhileInsteadOfFor + - org.openrewrite.staticanalysis.WriteOctalValuesAsDecimal From 7bb2e7be13e7859dc765153a1d098b19cae0cc6d Mon Sep 17 00:00:00 2001 From: Vladimir Sitnikov Date: Fri, 10 Jul 2026 00:43:11 +0300 Subject: [PATCH 2/4] build: replace stock OpenRewrite plugin with an incremental in-house runner The stock org.openrewrite.rewrite plugin re-parses a module on every rewriteDryRun (~19s for jorphan even with no changes) and, when applied to the root project, analyses the whole repository at once. Port the custom runner started in PR #6217 to current master and finish it: - build-logic/openrewrite runs each module's recipes in an isolated worker and declares the sources, compile classpath and config as typed task inputs, so rewriteDryRun is up-to-date (~2s) when nothing changed. - rewriteRun now writes the results: it creates, deletes, moves and updates files in place. The WIP only logged them. - rewriteDryRun writes a unified diff to build/reports/openrewrite and fails only when failOnDryRunResults is set. The worker loads recipes from META-INF/rewrite/*.yml on the recipe classpath instead of Environment.scanClassLoader, avoiding the ClassGraph scan that OOMs on large classpaths (https://github.com/openrewrite/rewrite/issues/3899). Kotlin processing is off by default: rewrite-kotlin does not round-trip Kotlin (it prints `x as Foo` as `x asFoo`), so applying recipes corrupts untouched code, and the stock plugin has the same bug. The Java parser still resolves types declared in Kotlin because the module's compiled Kotlin classes are on its compile classpath, so Java cleanup stays correct and compiles. Refs: https://github.com/apache/jmeter/pull/6217 Co-Authored-By: Claude Opus 4.8 --- build-logic/jvm/build.gradle.kts | 2 +- .../kotlin/build-logic.openrewrite.gradle.kts | 47 +-- build-logic/openrewrite/build.gradle.kts | 32 ++ .../build-logic.openrewrite-base.gradle.kts | 104 ++++++ .../openrewrite/OpenRewriteExtension.kt | 47 +++ .../openrewrite/OpenRewriteTasks.kt | 137 ++++++++ .../buildtools/openrewrite/OpenRewriteWork.kt | 328 ++++++++++++++++++ build-logic/settings.gradle.kts | 1 + 8 files changed, 674 insertions(+), 24 deletions(-) create mode 100644 build-logic/openrewrite/build.gradle.kts create mode 100644 build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts create mode 100644 build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteExtension.kt create mode 100644 build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteTasks.kt create mode 100644 build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteWork.kt diff --git a/build-logic/jvm/build.gradle.kts b/build-logic/jvm/build.gradle.kts index 978d27813fd..5c22b45f753 100644 --- a/build-logic/jvm/build.gradle.kts +++ b/build-logic/jvm/build.gradle.kts @@ -28,5 +28,5 @@ dependencies { api("org.jetbrains.kotlin.jvm:org.jetbrains.kotlin.jvm.gradle.plugin:2.2.21") api("org.jetbrains.kotlin.kapt:org.jetbrains.kotlin.kapt.gradle.plugin:2.2.21") api("org.jetbrains.dokka-javadoc:org.jetbrains.dokka-javadoc.gradle.plugin:2.1.0") - api("org.openrewrite.rewrite:org.openrewrite.rewrite.gradle.plugin:7.35.0") + api(projects.openrewrite) } diff --git a/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts b/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts index e810dedef48..966eb36bce3 100644 --- a/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts +++ b/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts @@ -16,39 +16,40 @@ */ plugins { - id("org.openrewrite.rewrite") + id("build-logic.openrewrite-base") } dependencies { - // rewrite-kotlin, rewrite-java, rewrite-groovy and the other language parsers - // ship with the Gradle plugin, so only the recipe modules are declared here. - rewrite(platform("org.openrewrite.recipe:rewrite-recipe-bom:latest.release")) - rewrite("org.openrewrite.recipe:rewrite-static-analysis") - rewrite("org.openrewrite.recipe:rewrite-testing-frameworks") + // rewrite-core, rewrite-java, rewrite-kotlin and the other parsers arrive transitively + // with the recipe modules below; the isolated worker resolves everything from here. + "openrewrite"(platform("org.openrewrite.recipe:rewrite-recipe-bom:3.34.0")) + "openrewrite"("org.openrewrite.recipe:rewrite-static-analysis") + "openrewrite"("org.openrewrite.recipe:rewrite-testing-frameworks") + // JavaParser.fromJavaVersion() picks the parser backend matching the worker JVM, so the + // version-specific parsers must be on the recipe classpath. + "openrewrite"("org.openrewrite:rewrite-java-17") + "openrewrite"("org.openrewrite:rewrite-java-21") + "openrewrite"("org.openrewrite:rewrite-java-25") } -rewrite { - configFile = rootProject.file("config/openrewrite/rewrite.yml") - // Report violations instead of failing the dry run so the aggregate - // styleCheck task can collect the reports from every module. - failOnDryRunResults = false +openrewrite { + configFile.set(rootProject.file("config/openrewrite/rewrite.yml")) + // Report violations instead of failing the dry run so the aggregate styleCheck task can + // collect the reports from every module. + failOnDryRunResults.set(false) - activeStyle("org.apache.jmeter.style.Style") + activeStyles.add("org.apache.jmeter.style.Style") - // See config/openrewrite/rewrite.yml - // These static-analysis recipes run cleanly on both Java and Kotlin sources. - activeRecipe("org.apache.jmeter.staticanalysis.CodeCleanup") - activeRecipe("org.apache.jmeter.staticanalysis.CommonStaticAnalysis") + // See config/openrewrite/rewrite.yml. These static-analysis recipes run on Java and Kotlin. + activeRecipes.add("org.apache.jmeter.staticanalysis.CodeCleanup") + activeRecipes.add("org.apache.jmeter.staticanalysis.CommonStaticAnalysis") - // The JUnit 5 recipes from rewrite-testing-frameworks are Java-only: their - // JavaTemplate-based rewrites throw on Kotlin test sources, e.g. - // AssertThrowsOnLastStatement fails on jorphan's RandomStringGeneratorTest.kt - // with "Expected a template that would generate exactly one statement ...". - // Enable them only for modules whose tests are pure Java. + // The JUnit 5 recipes are Java-only: their JavaTemplate rewrites throw on Kotlin test + // sources, so enable them only for modules whose tests are pure Java. if (!file("src/test/kotlin").isDirectory) { plugins.withId("build-logic.test-junit5") { - activeRecipe("org.openrewrite.java.testing.junit5.JUnit5BestPractices") - activeRecipe("org.openrewrite.java.testing.junit5.CleanupAssertions") + activeRecipes.add("org.openrewrite.java.testing.junit5.JUnit5BestPractices") + activeRecipes.add("org.openrewrite.java.testing.junit5.CleanupAssertions") } } } diff --git a/build-logic/openrewrite/build.gradle.kts b/build-logic/openrewrite/build.gradle.kts new file mode 100644 index 00000000000..8361eabbc95 --- /dev/null +++ b/build-logic/openrewrite/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("build-logic.kotlin-dsl-gradle-plugin") +} + +dependencies { + // The OpenRewrite libraries are needed only to COMPILE the worker: at runtime the worker + // runs in an isolated process and gets rewrite-core/java/kotlin plus the recipe modules + // from the project's `openrewrite` configuration. Keeping them compileOnly avoids pulling + // OpenRewrite (and ClassGraph) onto the Gradle build-logic classpath. + compileOnly(platform("org.openrewrite.recipe:rewrite-recipe-bom:3.34.0")) + compileOnly("org.openrewrite:rewrite-core") + compileOnly("org.openrewrite:rewrite-java") + compileOnly("org.openrewrite:rewrite-kotlin") + compileOnly("org.openrewrite:rewrite-xml") +} diff --git a/build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts b/build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts new file mode 100644 index 00000000000..341df7759b7 --- /dev/null +++ b/build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.apache.jmeter.buildtools.openrewrite.OpenRewriteBaseTask +import org.apache.jmeter.buildtools.openrewrite.OpenRewriteDryRunTask +import org.apache.jmeter.buildtools.openrewrite.OpenRewriteExtension +import org.apache.jmeter.buildtools.openrewrite.OpenRewriteRunTask +import org.gradle.api.attributes.Bundling +import org.gradle.api.attributes.java.TargetJvmEnvironment +import org.gradle.api.file.SourceDirectorySet +import org.gradle.api.tasks.SourceSet +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.language.base.plugins.LifecycleBasePlugin + +val openrewrite by configurations.creating { + description = "OpenRewrite recipe dependencies" + isCanBeConsumed = false + isCanBeResolved = false +} + +val openrewriteClasspath by configurations.creating { + description = "Resolvable OpenRewrite recipe classpath" + isCanBeConsumed = false + isCanBeResolved = true + extendsFrom(openrewrite) + attributes { + attribute( + Bundling.BUNDLING_ATTRIBUTE, + objects.named(Bundling::class.java, Bundling.EXTERNAL) + ) + attribute( + TargetJvmEnvironment.TARGET_JVM_ENVIRONMENT_ATTRIBUTE, + objects.named(TargetJvmEnvironment::class.java, TargetJvmEnvironment.STANDARD_JVM) + ) + } +} + +val openrewriteExtension = extensions.create(OpenRewriteExtension.NAME) + +fun configureCommon(task: OpenRewriteBaseTask) { + task.group = "openrewrite" + task.activeRecipes.set(openrewriteExtension.activeRecipes) + task.activeStyles.set(openrewriteExtension.activeStyles) + task.rewriteClasspath.from(openrewriteClasspath) + task.configFile.set(openrewriteExtension.configFile) + task.projectRootDir.set(layout.projectDirectory) + task.logCompilationWarningsAndErrors.convention(false) + task.processKotlin.set(openrewriteExtension.processKotlin) +} + +tasks.register("rewriteRun") { + description = "Applies the active OpenRewrite recipes in place" + configureCommon(this) +} + +tasks.register("rewriteDryRun") { + description = "Reports the changes the active OpenRewrite recipes would make" + configureCommon(this) + reportDir.set(layout.buildDirectory.dir("reports/openrewrite")) + failOnDryRunResults.set(openrewriteExtension.failOnDryRunResults) +} + +plugins.withId("java") { + the().sourceSets.all { + val sourceSet: SourceSet = this + val javaRelease = tasks.named(sourceSet.compileJavaTaskName) + .flatMap { it.options.release } + tasks.withType().configureEach { + sourceSets.maybeCreate(sourceSet.name).apply { + this.javaRelease.set(javaRelease) + // Include the compiled classes (Java AND Kotlin output) so the Java parser can + // resolve types declared in Kotlin sources of the same module. + compileClasspath.from(sourceSet.compileClasspath) + compileClasspath.from(sourceSet.output.classesDirs) + srcDirSets.maybeCreate("java").files.from(sourceSet.java) + } + } + } +} + +plugins.withId("org.jetbrains.kotlin.jvm") { + the().sourceSets.all { + val sourceSet: SourceSet = this + val kotlin = (sourceSet as ExtensionAware).extensions.getByName("kotlin") as SourceDirectorySet + tasks.withType().configureEach { + sourceSets.maybeCreate(sourceSet.name) + .srcDirSets.maybeCreate("kotlin").files.from(kotlin) + } + } +} diff --git a/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteExtension.kt b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteExtension.kt new file mode 100644 index 00000000000..3e0a0a725ed --- /dev/null +++ b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteExtension.kt @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.buildtools.openrewrite + +import org.gradle.api.model.ObjectFactory +import org.gradle.kotlin.dsl.property +import org.gradle.kotlin.dsl.setProperty +import javax.inject.Inject + +open class OpenRewriteExtension @Inject constructor(objects: ObjectFactory) { + companion object { + const val NAME = "openrewrite" + } + + val configFile = objects.fileProperty() + + val activeStyles = objects.setProperty() + + val activeRecipes = objects.setProperty() + + val failOnDryRunResults = objects.property().convention(true) + + /** + * Whether to parse and rewrite Kotlin sources. + * + * Off by default: rewrite-kotlin does not round-trip Kotlin faithfully yet (for example it + * prints `x as Foo` back as `x asFoo`), so applying recipes corrupts otherwise untouched code. + * The Java parser still resolves types declared in Kotlin because the module's compiled Kotlin + * classes are on its compile classpath, so Java cleanup stays correct with this off. + */ + val processKotlin = objects.property().convention(false) +} diff --git a/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteTasks.kt b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteTasks.kt new file mode 100644 index 00000000000..79017af5402 --- /dev/null +++ b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteTasks.kt @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.buildtools.openrewrite + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.NamedDomainObjectContainer +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFile +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.provider.SetProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Nested +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.kotlin.dsl.submit +import org.gradle.workers.WorkerExecutor +import javax.inject.Inject + +abstract class OpenRewriteBaseTask @Inject constructor( + objects: ObjectFactory, + private val executor: WorkerExecutor, +) : DefaultTask() { + @get:Input + abstract val activeRecipes: SetProperty + + @get:Input + abstract val activeStyles: SetProperty + + @get:Classpath + abstract val rewriteClasspath: ConfigurableFileCollection + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val configFile: RegularFileProperty + + @get:Internal + abstract val projectRootDir: DirectoryProperty + + @get:Input + abstract val logCompilationWarningsAndErrors: Property + + @get:Input + abstract val processKotlin: Property + + @get:Nested + val sourceSets: NamedDomainObjectContainer = + objects.domainObjectContainer(SourceSetConfig::class.java) + + protected fun runRewrite(applyChanges: Boolean, patch: Provider?) { + val snapshots = sourceSets.map { it.toSnapshot() } + val rewriteCp = rewriteClasspath.files + val queue = executor.processIsolation { + classpath.from(rewriteClasspath) + } + queue.submit(OpenRewriteWork::class) { + this.applyChanges.set(applyChanges) + this.projectRoot.set(projectRootDir) + patch?.let { this.patchFile.set(it) } + this.configFile.set(this@OpenRewriteBaseTask.configFile) + this.activeRecipes.set(this@OpenRewriteBaseTask.activeRecipes) + this.activeStyles.set(this@OpenRewriteBaseTask.activeStyles) + this.sourceSets.set(snapshots) + this.rewriteClasspath.set(rewriteCp) + this.logCompilationWarningsAndErrors.set(this@OpenRewriteBaseTask.logCompilationWarningsAndErrors) + this.processKotlin.set(this@OpenRewriteBaseTask.processKotlin) + } + queue.await() + } +} + +/** Applies the active recipes in place, creating, deleting, moving and updating source files. */ +abstract class OpenRewriteRunTask @Inject constructor( + objects: ObjectFactory, + executor: WorkerExecutor, +) : OpenRewriteBaseTask(objects, executor) { + @TaskAction + fun run() { + runRewrite(applyChanges = true, patch = null) + } +} + +/** Reports the changes the active recipes would make as a unified diff, without touching sources. */ +@CacheableTask +abstract class OpenRewriteDryRunTask @Inject constructor( + objects: ObjectFactory, + executor: WorkerExecutor, +) : OpenRewriteBaseTask(objects, executor) { + @get:OutputDirectory + abstract val reportDir: DirectoryProperty + + @get:Input + abstract val failOnDryRunResults: Property + + @TaskAction + fun run() { + val patch = reportDir.file(PATCH_NAME) + runRewrite(applyChanges = false, patch = patch) + val patchFile = patch.get().asFile + if (failOnDryRunResults.get() && patchFile.length() > 0) { + throw GradleException( + "OpenRewrite found code that violates the active recipes. " + + "Run ./gradlew ${path.replace("DryRun", "Run")} to apply the changes:\n" + + patchFile.readText() + ) + } + } + + companion object { + const val PATCH_NAME = "rewrite.patch" + } +} diff --git a/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteWork.kt b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteWork.kt new file mode 100644 index 00000000000..96e6e745b1c --- /dev/null +++ b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteWork.kt @@ -0,0 +1,328 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.buildtools.openrewrite + +import org.gradle.api.Named +import org.gradle.api.NamedDomainObjectContainer +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.SetProperty +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Nested +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.workers.WorkAction +import org.gradle.workers.WorkParameters +import org.openrewrite.ExecutionContext +import org.openrewrite.InMemoryExecutionContext +import org.openrewrite.Parser +import org.openrewrite.Result +import org.openrewrite.SourceFile +import org.openrewrite.Tree +import org.openrewrite.config.Environment +import org.openrewrite.config.OptionDescriptor +import org.openrewrite.config.RecipeDescriptor +import org.openrewrite.config.YamlResourceLoader +import org.openrewrite.internal.InMemoryLargeSourceSet +import org.openrewrite.java.JavaParser +import org.openrewrite.java.internal.JavaTypeCache +import org.openrewrite.java.marker.JavaVersion +import org.openrewrite.kotlin.KotlinParser +import org.slf4j.LoggerFactory +import java.io.File +import java.io.Serializable +import java.net.URL +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.util.Properties +import java.util.zip.ZipFile + +abstract class SourceSetConfig( + private val name: String, +) : Named, Serializable { + @Internal + override fun getName(): String = name + + @get:Input + abstract val javaRelease: Property + + @get:Nested + abstract val srcDirSets: NamedDomainObjectContainer + + @get:Classpath + abstract val compileClasspath: ConfigurableFileCollection +} + +abstract class SourceDirectorySetConfig( + private val name: String, +) : Named, Serializable { + @Internal + override fun getName(): String = name + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val files: ConfigurableFileCollection +} + +fun SourceSetConfig.toSnapshot() = + SourceSetSnapshot( + name = name, + javaRelease = javaRelease.get(), + srcDirSets = srcDirSets.associateBy({ it.name }) { it.files.files }, + compileClasspath = compileClasspath.files.filterTo(mutableSetOf()) { it.exists() }, + ) + +data class SourceSetSnapshot( + val name: String, + val javaRelease: Int, + val srcDirSets: Map>, + val compileClasspath: Set, +) : Serializable + +interface OpenRewriteParameters : WorkParameters { + /** When true, apply the changes in place; when false, only write a patch to [patchFile]. */ + val applyChanges: Property + + /** Directory the results' source paths are resolved against (the project directory). */ + val projectRoot: DirectoryProperty + + /** Unified diff of the proposed changes, written in dry-run mode. */ + val patchFile: RegularFileProperty + + val configFile: RegularFileProperty + val activeRecipes: SetProperty + val activeStyles: SetProperty + val sourceSets: SetProperty + val rewriteClasspath: SetProperty + val logCompilationWarningsAndErrors: Property + val processKotlin: Property +} + +abstract class OpenRewriteWork : WorkAction { + companion object { + val logger = LoggerFactory.getLogger(OpenRewriteWork::class.java) as org.gradle.api.logging.Logger + } + + override fun execute() { + val ctx: ExecutionContext = InMemoryExecutionContext { t -> logger.warn("Error while rewriting", t) } + val results = listResults(ctx) + if (parameters.applyChanges.get()) { + applyResults(results) + } else { + writePatch(results) + } + } + + private fun listResults(ctx: ExecutionContext): List { + val env = createEnvironment() + val recipe = env.activateRecipes(parameters.activeRecipes.get()) + if (recipe.recipeList.isEmpty()) { + logger.warn( + "No recipes were activated. Add a recipe with openrewrite.activeRecipes in the build file." + ) + return emptyList() + } + val styles = env.activateStyles(parameters.activeStyles.get()) + + val sourceFiles = parse(ctx) + .map { source -> + if (styles.isEmpty()) { + source + } else { + source.withMarkers(styles.fold(source.markers) { markers, style -> markers.add(style) }) + } + } + .toList() + + val recipeRun = recipe.run(InMemoryLargeSourceSet(sourceFiles), ctx) + return recipeRun.changeset.allResults + } + + private fun applyResults(results: List) { + val root = parameters.projectRoot.get().asFile.toPath() + var changes = 0 + for (result in results) { + val before = result.before + val after = result.after + when { + before == null && after != null -> { + logger.lifecycle("Generated new file {} by:", after.sourcePath) + logRecipesThatMadeChanges(result) + writeAfter(root, after) + changes++ + } + + before != null && after == null -> { + logger.lifecycle("Deleted file {} by:", before.sourcePath) + logRecipesThatMadeChanges(result) + Files.deleteIfExists(root.resolve(before.sourcePath)) + changes++ + } + + before != null && after != null && before.sourcePath != after.sourcePath -> { + logger.lifecycle("Moved file {} to {} by:", before.sourcePath, after.sourcePath) + logRecipesThatMadeChanges(result) + Files.deleteIfExists(root.resolve(before.sourcePath)) + writeAfter(root, after) + changes++ + } + + before != null && after != null -> { + logger.lifecycle("Changed file {} by:", after.sourcePath) + logRecipesThatMadeChanges(result) + writeAfter(root, after) + changes++ + } + } + } + logger.lifecycle("OpenRewrite applied changes to {} file(s)", changes) + } + + private fun writeAfter(root: Path, after: SourceFile) { + val target = root.resolve(after.sourcePath) + Files.createDirectories(target.parent) + val charset = after.charset ?: StandardCharsets.UTF_8 + Files.newBufferedWriter(target, charset).use { writer -> + writer.write(after.printAll()) + } + } + + private fun writePatch(results: List) { + val patch = parameters.patchFile.get().asFile + patch.parentFile.mkdirs() + val diffs = results.mapNotNull { result -> + result.diff().takeIf { it.isNotBlank() }?.also { + val path = (result.after ?: result.before)?.sourcePath + logger.lifecycle("These recipes would make changes to {}:", path) + logRecipesThatMadeChanges(result) + } + } + patch.writeText(if (diffs.isEmpty()) "" else diffs.joinToString("\n") + "\n") + } + + private fun logRecipesThatMadeChanges(result: Result) { + var prefix = " " + for (recipeDescriptor in result.recipeDescriptorsThatMadeChanges) { + logRecipe(recipeDescriptor, prefix) + prefix += " " + } + } + + private fun logRecipe(rd: RecipeDescriptor, prefix: String) { + val recipeString = StringBuilder(prefix + rd.name) + val opts = rd.options + .mapNotNull { option: OptionDescriptor -> option.value?.let { "${option.name}=$it" } } + .joinToString(", ") + if (opts.isNotEmpty()) { + recipeString.append(": {").append(opts).append("}") + } + logger.lifecycle("{}", recipeString) + for (child in rd.recipeList) { + logRecipe(child, "$prefix ") + } + } + + private fun createEnvironment(): Environment = + Environment.builder() + .apply { + // Load declarative recipes from META-INF/rewrite/*.yml on the recipe classpath. + // This avoids Environment.scanClassLoader (ClassGraph), which OOMs on large + // classpaths (see https://github.com/openrewrite/rewrite/issues/3899); compiled + // recipes referenced by the YAML are still instantiated by name at activation. + parameters.rewriteClasspath.get().forEach { cpFile -> + if (cpFile.isFile && cpFile.path.endsWith(".jar", ignoreCase = true)) { + val baseUrl = URL(cpFile.toURI().toURL(), "!/") + ZipFile(cpFile).use { zip -> + zip.entries().asSequence().forEach { ze -> + if (ze.name.startsWith("META-INF/rewrite/") && + (ze.name.endsWith(".yml", true) || ze.name.endsWith(".yaml", true)) + ) { + zip.getInputStream(ze).use { stream -> + val uri = URL(baseUrl, ze.name).toURI() + load(YamlResourceLoader(stream, uri, Properties(), javaClass.classLoader)) + } + } + } + } + } + } + val configFile = parameters.configFile.asFile.get() + configFile.inputStream().use { stream -> + load(YamlResourceLoader(stream, configFile.toURI(), Properties(), javaClass.classLoader)) + } + } + .build() + + private fun parse(ctx: ExecutionContext): Sequence { + val root = parameters.projectRoot.get().asFile.toPath() + var res = sequenceOf() + for (sourceSet in parameters.sourceSets.get()) { + val javaTypeCache = JavaTypeCache() + val classpath = sourceSet.compileClasspath.map { it.toPath() } + + val javaVersion = JavaVersion( + Tree.randomId(), + System.getProperty("java.runtime.version"), + System.getProperty("java.vm.vendor"), + sourceSet.javaRelease.toString(), + sourceSet.javaRelease.toString() + ) + + fun collect(name: String, extension: String): List = + sourceSet.srcDirSets[name].orEmpty() + .filter { it.path.endsWith(extension, ignoreCase = true) } + .map { it.toPath() } + + fun Parser.parseInto(files: List) { + if (files.isEmpty()) { + return + } + logger.info("OpenRewrite parsing {} files under {}", files.size, root) + res += parse(files, root, ctx).iterator().asSequence() + .map { it.withMarkers(it.markers.add(javaVersion)) } + } + + val javaFiles = collect("java", ".java") + if (javaFiles.isNotEmpty()) { + JavaParser.fromJavaVersion() + .classpath(classpath) + .typeCache(javaTypeCache) + .logCompilationWarningsAndErrors(parameters.logCompilationWarningsAndErrors.get()) + .build() + .parseInto(javaFiles) + } + + val kotlinFiles = if (parameters.processKotlin.get()) collect("kotlin", ".kt") else emptyList() + if (kotlinFiles.isNotEmpty()) { + KotlinParser.builder() + .classpath(classpath) + .typeCache(javaTypeCache) + .logCompilationWarningsAndErrors(parameters.logCompilationWarningsAndErrors.get()) + .build() + .parseInto(kotlinFiles) + } + } + return res + } +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index 6693f40e417..d9023c084da 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -36,6 +36,7 @@ include("basics") include("batchtest") include("build-parameters") include("jvm") +include("openrewrite") include("publishing") include("root-build") include("verification") From f3334970c526a2c12e5d4846a3bbbf1c677769dc Mon Sep 17 00:00:00 2001 From: Vladimir Sitnikov Date: Fri, 10 Jul 2026 11:50:52 +0300 Subject: [PATCH 3/4] build: add per-recipe disable switch and a rewriteDiagnose task Two follow-ups to the in-house OpenRewrite runner. disabledRecipes lets a single recipe be switched off by name, e.g. openrewrite { disabledRecipes.add("org.openrewrite.java.testing.junit5.AssertThrowsOnLastStatement") } The compiled composites (JUnit5BestPractices and friends) expose an immutable recipeList, so the runner wraps the recipe tree in a FilteringRecipe that hides the disabled names instead of mutating the list. This removes the need to fork a recipe module or drop a whole composite; the JUnit 5 recipes are now enabled for every test module and only AssertThrowsOnLastStatement is switched off, pending https://github.com/openrewrite/rewrite-testing-frameworks/pull/1048 rewriteDiagnose runs each active leaf recipe on its own and reports whether it fails, would change files, or does nothing, so the problematic recipes can be found without reading a full run. On jorphan: 183 leaf recipes, 0 fail in Java mode, 21 would make changes. Note that a recipe which only fails as part of an ordered composite (AssertThrowsOnLastStatement on Kotlin) passes in isolation, so the per-leaf report is a lower bound; the composite run surfaces the rest as non-fatal "Error while rewriting" warnings thanks to the lenient ExecutionContext. -PopenrewriteKotlin=true flips Kotlin processing on from the command line for experimenting with rewrite-kotlin. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/build-logic.openrewrite.gradle.kts | 15 +- .../build-logic.openrewrite-base.gradle.kts | 15 +- .../openrewrite/OpenRewriteExtension.kt | 3 + .../openrewrite/OpenRewriteTasks.kt | 22 ++- .../buildtools/openrewrite/OpenRewriteWork.kt | 138 +++++++++++++++--- 5 files changed, 166 insertions(+), 27 deletions(-) diff --git a/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts b/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts index 966eb36bce3..f01519eec6f 100644 --- a/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts +++ b/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts @@ -44,12 +44,13 @@ openrewrite { activeRecipes.add("org.apache.jmeter.staticanalysis.CodeCleanup") activeRecipes.add("org.apache.jmeter.staticanalysis.CommonStaticAnalysis") - // The JUnit 5 recipes are Java-only: their JavaTemplate rewrites throw on Kotlin test - // sources, so enable them only for modules whose tests are pure Java. - if (!file("src/test/kotlin").isDirectory) { - plugins.withId("build-logic.test-junit5") { - activeRecipes.add("org.openrewrite.java.testing.junit5.JUnit5BestPractices") - activeRecipes.add("org.openrewrite.java.testing.junit5.CleanupAssertions") - } + plugins.withId("build-logic.test-junit5") { + activeRecipes.add("org.openrewrite.java.testing.junit5.JUnit5BestPractices") + activeRecipes.add("org.openrewrite.java.testing.junit5.CleanupAssertions") } + + // Recipes that crash or corrupt output are disabled by name here instead of dropping the + // whole composite. AssertThrowsOnLastStatement throws on Kotlin sources; see the fix in + // https://github.com/openrewrite/rewrite-testing-frameworks/pull/1048 + disabledRecipes.add("org.openrewrite.java.testing.junit5.AssertThrowsOnLastStatement") } diff --git a/build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts b/build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts index 341df7759b7..ebd50975639 100644 --- a/build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts +++ b/build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts @@ -16,6 +16,7 @@ */ import org.apache.jmeter.buildtools.openrewrite.OpenRewriteBaseTask +import org.apache.jmeter.buildtools.openrewrite.OpenRewriteDiagnoseTask import org.apache.jmeter.buildtools.openrewrite.OpenRewriteDryRunTask import org.apache.jmeter.buildtools.openrewrite.OpenRewriteExtension import org.apache.jmeter.buildtools.openrewrite.OpenRewriteRunTask @@ -54,12 +55,18 @@ val openrewriteExtension = extensions.create(OpenRewriteEx fun configureCommon(task: OpenRewriteBaseTask) { task.group = "openrewrite" task.activeRecipes.set(openrewriteExtension.activeRecipes) + task.disabledRecipes.set(openrewriteExtension.disabledRecipes) task.activeStyles.set(openrewriteExtension.activeStyles) task.rewriteClasspath.from(openrewriteClasspath) task.configFile.set(openrewriteExtension.configFile) task.projectRootDir.set(layout.projectDirectory) task.logCompilationWarningsAndErrors.convention(false) - task.processKotlin.set(openrewriteExtension.processKotlin) + // Allow flipping Kotlin processing from the command line, e.g. -PopenrewriteKotlin=true + task.processKotlin.set( + providers.gradleProperty("openrewriteKotlin") + .map { it.toBoolean() } + .orElse(openrewriteExtension.processKotlin) + ) } tasks.register("rewriteRun") { @@ -74,6 +81,12 @@ tasks.register("rewriteDryRun") { failOnDryRunResults.set(openrewriteExtension.failOnDryRunResults) } +tasks.register("rewriteDiagnose") { + description = "Runs each active leaf recipe on its own and reports failures and no-ops" + configureCommon(this) + reportDir.set(layout.buildDirectory.dir("reports/openrewrite")) +} + plugins.withId("java") { the().sourceSets.all { val sourceSet: SourceSet = this diff --git a/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteExtension.kt b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteExtension.kt index 3e0a0a725ed..c7ef90a803f 100644 --- a/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteExtension.kt +++ b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteExtension.kt @@ -33,6 +33,9 @@ open class OpenRewriteExtension @Inject constructor(objects: ObjectFactory) { val activeRecipes = objects.setProperty() + /** Fully qualified recipe names to prune from the active recipe tree, e.g. ones that crash. */ + val disabledRecipes = objects.setProperty() + val failOnDryRunResults = objects.property().convention(true) /** diff --git a/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteTasks.kt b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteTasks.kt index 79017af5402..bc6d101a83b 100644 --- a/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteTasks.kt +++ b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteTasks.kt @@ -49,6 +49,9 @@ abstract class OpenRewriteBaseTask @Inject constructor( @get:Input abstract val activeRecipes: SetProperty + @get:Input + abstract val disabledRecipes: SetProperty + @get:Input abstract val activeStyles: SetProperty @@ -72,7 +75,7 @@ abstract class OpenRewriteBaseTask @Inject constructor( val sourceSets: NamedDomainObjectContainer = objects.domainObjectContainer(SourceSetConfig::class.java) - protected fun runRewrite(applyChanges: Boolean, patch: Provider?) { + protected fun runRewrite(applyChanges: Boolean, patch: Provider?, diagnose: Boolean = false) { val snapshots = sourceSets.map { it.toSnapshot() } val rewriteCp = rewriteClasspath.files val queue = executor.processIsolation { @@ -80,10 +83,12 @@ abstract class OpenRewriteBaseTask @Inject constructor( } queue.submit(OpenRewriteWork::class) { this.applyChanges.set(applyChanges) + this.diagnose.set(diagnose) this.projectRoot.set(projectRootDir) patch?.let { this.patchFile.set(it) } this.configFile.set(this@OpenRewriteBaseTask.configFile) this.activeRecipes.set(this@OpenRewriteBaseTask.activeRecipes) + this.disabledRecipes.set(this@OpenRewriteBaseTask.disabledRecipes) this.activeStyles.set(this@OpenRewriteBaseTask.activeStyles) this.sourceSets.set(snapshots) this.rewriteClasspath.set(rewriteCp) @@ -135,3 +140,18 @@ abstract class OpenRewriteDryRunTask @Inject constructor( const val PATCH_NAME = "rewrite.patch" } } + +/** Runs each active leaf recipe on its own and reports which fail, change, or do nothing. */ +abstract class OpenRewriteDiagnoseTask @Inject constructor( + objects: ObjectFactory, + executor: WorkerExecutor, +) : OpenRewriteBaseTask(objects, executor) { + @get:OutputDirectory + abstract val reportDir: DirectoryProperty + + @TaskAction + fun run() { + runRewrite(applyChanges = false, patch = reportDir.file("diagnose.txt"), diagnose = true) + logger.lifecycle("OpenRewrite diagnose report: {}", reportDir.file("diagnose.txt").get().asFile) + } +} diff --git a/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteWork.kt b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteWork.kt index 96e6e745b1c..865794f6448 100644 --- a/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteWork.kt +++ b/build-logic/openrewrite/src/main/kotlin/org/apache/jmeter/buildtools/openrewrite/OpenRewriteWork.kt @@ -110,8 +110,15 @@ interface OpenRewriteParameters : WorkParameters { /** Unified diff of the proposed changes, written in dry-run mode. */ val patchFile: RegularFileProperty + /** When true, run each leaf recipe on its own and report which ones fail or make changes. */ + val diagnose: Property + val configFile: RegularFileProperty val activeRecipes: SetProperty + + /** Fully qualified recipe names to prune from the active recipe tree before running. */ + val disabledRecipes: SetProperty + val activeStyles: SetProperty val sourceSets: SetProperty val rewriteClasspath: SetProperty @@ -125,26 +132,18 @@ abstract class OpenRewriteWork : WorkAction { } override fun execute() { - val ctx: ExecutionContext = InMemoryExecutionContext { t -> logger.warn("Error while rewriting", t) } - val results = listResults(ctx) - if (parameters.applyChanges.get()) { - applyResults(results) - } else { - writePatch(results) - } - } - - private fun listResults(ctx: ExecutionContext): List { val env = createEnvironment() - val recipe = env.activateRecipes(parameters.activeRecipes.get()) - if (recipe.recipeList.isEmpty()) { - logger.warn( - "No recipes were activated. Add a recipe with openrewrite.activeRecipes in the build file." - ) - return emptyList() + val activated = env.activateRecipes(parameters.activeRecipes.get()) + val disabled = parameters.disabledRecipes.get() + val diagnose = parameters.diagnose.get() + + if (activated.recipeList.isEmpty()) { + logger.warn("No recipes were activated. Add a recipe with openrewrite.activeRecipes in the build file.") + return } val styles = env.activateStyles(parameters.activeStyles.get()) + val ctx: ExecutionContext = InMemoryExecutionContext { t -> logger.warn("Error while rewriting", t) } val sourceFiles = parse(ctx) .map { source -> if (styles.isEmpty()) { @@ -155,10 +154,88 @@ abstract class OpenRewriteWork : WorkAction { } .toList() - val recipeRun = recipe.run(InMemoryLargeSourceSet(sourceFiles), ctx) - return recipeRun.changeset.allResults + // In diagnose we test everything (disabled recipes are still reported, annotated) so the + // report is a complete picture. In normal runs we prune the disabled recipes from the tree. + if (diagnose) { + diagnose(activated, sourceFiles, disabled) + return + } + + val recipe = if (disabled.isEmpty()) { + activated + } else { + val present = collectNames(activated).intersect(disabled) + logger.lifecycle("OpenRewrite disabled {} recipe(s): {}", present.size, present.sorted()) + FilteringRecipe(activated, disabled) + } + + val results = recipe.run(InMemoryLargeSourceSet(sourceFiles), ctx).changeset.allResults + if (parameters.applyChanges.get()) { + applyResults(results) + } else { + writePatch(results) + } + } + + private fun collectNames(recipe: org.openrewrite.Recipe): Set { + val names = mutableSetOf() + fun visit(r: org.openrewrite.Recipe) { + names.add(r.name) + r.recipeList.forEach { visit(it) } + } + visit(recipe) + return names + } + + /** Runs each leaf recipe individually so a single failure does not hide the others. */ + private fun diagnose(recipe: org.openrewrite.Recipe, sourceFiles: List, disabled: Set) { + fun leaves(r: org.openrewrite.Recipe): List = + if (r.recipeList.isEmpty()) listOf(r) else r.recipeList.flatMap { leaves(it) } + + val leaves = leaves(recipe).distinctBy { it.name } + val report = StringBuilder() + var failed = 0 + var changed = 0 + var clean = 0 + for (leaf in leaves) { + val tag = if (leaf.name in disabled) " [disabled]" else "" + val errors = mutableListOf() + val ctx: ExecutionContext = InMemoryExecutionContext { errors.add(it) } + val line = try { + val results = leaf.run(InMemoryLargeSourceSet(sourceFiles), ctx).changeset.allResults + when { + errors.isNotEmpty() -> { + failed++ + "FAILED ${leaf.name}$tag -> ${errors.first().firstMessageLine()}" + } + results.isNotEmpty() -> { + changed++ + "changes:${results.size.toString().padStart(3)} ${leaf.name}$tag" + } + else -> { + clean++ + "ok ${leaf.name}$tag" + } + } + } catch (t: Throwable) { + failed++ + "FAILED ${leaf.name}$tag -> ${t.firstMessageLine()}" + } + report.appendLine(line) + logger.lifecycle(line) + } + val summary = "OpenRewrite diagnose: ${leaves.size} recipes -> " + + "$failed failed, $changed would change, $clean no-op" + report.appendLine().appendLine(summary) + logger.lifecycle(summary) + val out = parameters.patchFile.get().asFile + out.parentFile.mkdirs() + out.writeText(report.toString()) } + private fun Throwable.firstMessageLine(): String = + (message ?: this::class.java.simpleName).lineSequence().first().take(200) + private fun applyResults(results: List) { val root = parameters.projectRoot.get().asFile.toPath() var changes = 0 @@ -326,3 +403,28 @@ abstract class OpenRewriteWork : WorkAction { return res } } + +/** + * Wraps a recipe tree and hides recipes whose name is in [disabled], so a crashing or unwanted + * recipe can be switched off by name without editing the third-party composite that declares it. + * The compiled composites expose an immutable recipeList, so filtering by wrapping is the reliable + * way to remove a recipe from the run. + */ +private class FilteringRecipe( + private val delegate: org.openrewrite.Recipe, + private val disabled: Set, +) : org.openrewrite.Recipe() { + private val filtered: List by lazy { + delegate.recipeList + .filter { it.name !in disabled } + .map { FilteringRecipe(it, disabled) } + } + + override fun getName(): String = delegate.name + override fun getDisplayName(): String = delegate.displayName + override fun getDescription(): String = + delegate.description?.takeIf { it.isNotBlank() } ?: delegate.displayName + override fun getVisitor(): org.openrewrite.TreeVisitor<*, org.openrewrite.ExecutionContext> = + delegate.visitor + override fun getRecipeList(): List = filtered +} From 6ce0b42ba6e360db6d394f9a8524a9c14a5014e2 Mon Sep 17 00:00:00 2001 From: Vladimir Sitnikov Date: Sat, 11 Jul 2026 00:39:24 +0300 Subject: [PATCH 4/4] build: add -PopenrewriteDisable and document Kotlin-unsafe recipes Add an additive -PopenrewriteDisable= switch so recipes can be turned off from the command line without editing the build, and record which Java recipes corrupt Kotlin when -PopenrewriteKotlin=true is used. Applying the active recipes to Kotlin and compiling the result shows exactly three Java recipes that produce non-compiling Kotlin: ShortenFullyQualified- TypeReferences (drops a qualifier without the import), TypecastParenPad (`x as Foo` -> `x asFoo`, root cause in openrewrite/rewrite#8236) and OperatorWrap (leading `+` becomes a unary plus). All three are safe on Java, so they stay active; Kotlin stays off by default. Co-Authored-By: Claude Opus 4.8 --- .../main/kotlin/build-logic.openrewrite.gradle.kts | 11 +++++++++++ .../kotlin/build-logic.openrewrite-base.gradle.kts | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts b/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts index f01519eec6f..81150a18ed4 100644 --- a/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts +++ b/build-logic/jvm/src/main/kotlin/build-logic.openrewrite.gradle.kts @@ -53,4 +53,15 @@ openrewrite { // whole composite. AssertThrowsOnLastStatement throws on Kotlin sources; see the fix in // https://github.com/openrewrite/rewrite-testing-frameworks/pull/1048 disabledRecipes.add("org.openrewrite.java.testing.junit5.AssertThrowsOnLastStatement") + + // Kotlin processing is off by default (see build-logic.openrewrite-base). When it is enabled + // via -PopenrewriteKotlin=true, these Java recipes still corrupt Kotlin and must also be + // disabled — verified by applying them and compiling the result: + // - ShortenFullyQualifiedTypeReferences drops a qualifier without adding the import + // (`API.Status.EXPERIMENTAL` -> `Status.EXPERIMENTAL`); + // - TypecastParenPad mangles `x as Foo` into `x asFoo` (root cause fixed in + // https://github.com/openrewrite/rewrite/pull/8236); + // - OperatorWrap moves a binary `+` to the start of the next line, which Kotlin parses as a + // unary plus and breaks the expression. + // They are left active by default because they are safe and useful on the Java sources. } diff --git a/build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts b/build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts index ebd50975639..35207903b0a 100644 --- a/build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts +++ b/build-logic/openrewrite/src/main/kotlin/build-logic.openrewrite-base.gradle.kts @@ -56,6 +56,13 @@ fun configureCommon(task: OpenRewriteBaseTask) { task.group = "openrewrite" task.activeRecipes.set(openrewriteExtension.activeRecipes) task.disabledRecipes.set(openrewriteExtension.disabledRecipes) + // Additively disable more recipes from the command line, e.g. + // -PopenrewriteDisable=org.openrewrite.java.ShortenFullyQualifiedTypeReferences,org.openrewrite.staticanalysis.TypecastParenPad + task.disabledRecipes.addAll( + providers.gradleProperty("openrewriteDisable") + .map { prop -> prop.split(",").map { it.trim() }.filter { it.isNotEmpty() } } + .orElse(emptyList()) + ) task.activeStyles.set(openrewriteExtension.activeStyles) task.rewriteClasspath.from(openrewriteClasspath) task.configFile.set(openrewriteExtension.configFile)