See unit test(s) below, with analysis from Claude.
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junitpioneer.jupiter.ExpectedToFail
import org.openrewrite.java.Assertions.mavenProject
import org.openrewrite.maven.Assertions.pomXml
import org.openrewrite.test.RewriteTest
import org.openrewrite.test.SourceSpecs.other
/*
Description: `org.openrewrite.java.migrate.UpgradeToJava25` can leave a Kotlin module pinned at
Java 24 while stamping it with a self-contradictory comment that blames "Kotlin before 2.3" —
even though the module's own Kotlin was already upgraded to 2.3 by the same recipe run.
The Java 25 migration handles Kotlin modules with this chain (rewrite-migrate-java
`META-INF/rewrite/java-version-25.yml`):
- `UpgradeKotlinForJava25` bumps a module's directly-declared Kotlin 2.0-2.2 to 2.3, then
floors it at Java 24 as a safety net.
- `UpgradeBuildToJava25ForKotlin` raises Kotlin modules to Java 25, gated on precondition
`ModuleHasDependency(kotlin-stdlib*, [0,2.3), invertMarking:true)`
— i.e. "no kotlin-stdlib* below 2.3 is present".
- `CommentKotlinModulesCappedAtJava24` → `CommentJava24KotlinCap` annotates any Kotlin-source
module still at Java 24.
Two coupled defects interact:
(1) `UpgradeBuildToJava25ForKotlin`'s precondition is satisfied by ANY `kotlin-stdlib*` artifact
below 2.3, including a purely TRANSITIVE legacy sibling such as `kotlin-stdlib-jdk8:1.8.21`
pulled in by an unrelated dependency (e.g. `com.squareup.okhttp3:okhttp:4.12.0`). Such a
transitive artifact is never bumped (the Kotlin upgrade only touches directly-declared
versions), so it permanently blocks the Java 25 raise even after the module's own compile
Kotlin reached 2.3. This is the recurrence of the "transitive kotlin-stdlib" class of bug that
- `ModuleHasKotlinSource` (PR #1066) was meant to close for the *cap* recipes, but the *raise*
recipe still keys off the transitive dependency graph.
(2) `CommentJava24KotlinCap.capsJavaAt24()` adds the "Capped at Java 24 … Kotlin before 2.3 cannot
target Java 25" comment based ONLY on `maven.compiler.release == 24` plus the presence of
Kotlin sources. It never verifies the resolved Kotlin is actually < 2.3, and it names the FIRST
`kotlin-stdlib*` it finds — which after the bump is the 2.3 artifact. The result is a comment
that names `kotlin-stdlib 2.3.x` in the very sentence that says "Kotlin before 2.3", and tells
the user to "Upgrade Kotlin … to 2.3 or later" when they are already there.
Expected behavior: A Kotlin-source module whose own Kotlin is (or is upgraded to) 2.3 should be
raised to Java 25 and should NOT carry a "Capped at Java 24 … Kotlin before 2.3" comment. A merely
transitive pre-2.3 `kotlin-stdlib*` (which the module does not compile against) must not block the
Java 25 raise, and the cap comment must be gated on the actual resolved Kotlin version being < 2.3.
Actual behavior: The module's `kotlin-stdlib` is bumped 2.2.0 → 2.3.21, but `maven.compiler.release`
stays 24, and this contradictory comment is emitted (verbatim, reproduced 2026-08-26 against
rewrite-migrate-java 3.41.0):
<!-- Capped at Java 24: this module compiles Kotlin and depends on kotlin-stdlib 2.3.21, and
Kotlin before 2.3 cannot target Java 25 bytecode. Upgrade Kotlin (kotlin-stdlib and the
Kotlin compiler) to 2.3 or later, then re-run "Migrate to Java 25" to move this module to
Java 25. -->
Version first seen: org.openrewrite.recipe:rewrite-migrate-java:3.41.0
(via io.moderne.recipe:moderne-recipe-bom:0.41.0 → org.openrewrite.recipe:rewrite-recipe-bom:3.36.0).
- The contradictory-comment behavior became reachable when the cap comment (PR #1137) and the
- Kotlin 2.0-2.2 → 2.3 bump (PR #1139) both landed on 2026-06-22.
External references:
- PR #1137 "Explain why Kotlin modules are capped at Java 24 during Java 25 migration" (adds CommentJava24KotlinCap)
- PR #1139 "Bump Kotlin 2.0-2.2 to 2.3 so Kotlin modules can reach Java 25" (adds UpgradeKotlinForJava25 / UpgradeBuildToJava25ForKotlin)
- PR #1066 "Gate Kotlin-based Java version cap on actual Kotlin sources" (ModuleHasKotlinSource)
Proposed fix (upstream): (a) scope `UpgradeBuildToJava25ForKotlin`'s "still below 2.3" check to the
module's own compile-scope Kotlin rather than the whole transitive graph (or exclude transitive-only
kotlin-stdlib* the way ModuleHasKotlinSource excludes transitive stdlib for the cap); and
(b) gate `CommentJava24KotlinCap` on the resolved kotlin-stdlib actually being < 2.3, so a module on
2.3 is never told it is "before 2.3".
*/
class UpgradeToJava25TransitiveKotlinStdlibPinsJava24Test : RewriteTest {
/**
* End-to-end reproduction matching the reported symptom. A Kotlin-source module on
* `kotlin-stdlib:2.2.0` that also depends on `okhttp:4.12.0` (which transitively brings
* `kotlin-stdlib-jdk8:1.8.21`). `.after` asserts the CORRECT outcome — the module reaches Java 25
* and carries no cap comment. `@ExpectedToFail` because it currently stays at Java 24 with the
* self-contradictory "Kotlin before 2.3" comment naming kotlin-stdlib 2.3.21.
*/
@Test
@ExpectedToFail("UpgradeToJava25 pins a Kotlin-2.3 module at Java 24 when a transitive pre-2.3 kotlin-stdlib* is present, and adds a comment blaming 'Kotlin before 2.3'; needs upstream OpenRewrite/Moderne fix")
fun transitivePre23KotlinStdlibShouldNotPinJava24OrMislabelKotlinVersion() {
rewriteRun(
{ spec -> spec.recipeFromResources("org.openrewrite.java.migrate.UpgradeToJava25") },
mavenProject(
"project",
pomXml(
"""
<project>
<groupId>com.mycompany.app</groupId>
<artifactId>my-app</artifactId>
<version>1</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
</dependencies>
</project>
""".trimIndent(),
) { spec ->
spec.after { actual ->
assertThat(actual)
// The module's own Kotlin is upgraded to 2.3, so it should reach Java 25 ...
.contains("<maven.compiler.release>25</maven.compiler.release>")
// ... and must NOT be told it is capped because "Kotlin before 2.3".
.doesNotContain("Capped at Java 24")
actual
}
},
other("fun main() {}") { it.path("src/main/kotlin/App.kt") },
),
)
}
/**
* Baseline for the end-to-end case: identical module but WITHOUT the `okhttp` dependency, so no
* transitive pre-2.3 `kotlin-stdlib*` is present. Here the recipe behaves correctly — the module's
* Kotlin is bumped to 2.3 and it reaches Java 25 with no cap comment. Passing; confirms the
* transitive dependency is the specific trigger for the Java 24 pin above.
*/
@Test
fun baselineWithoutTransitiveDepReachesJava25() {
rewriteRun(
{ spec ->
spec.recipeFromResources("org.openrewrite.java.migrate.UpgradeToJava25")
.expectedCyclesThatMakeChanges(2)
},
mavenProject(
"project",
pomXml(
"""
<project>
<groupId>com.mycompany.app</groupId>
<artifactId>my-app</artifactId>
<version>1</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>2.2.0</version>
</dependency>
</dependencies>
</project>
""".trimIndent(),
) { spec ->
spec.after { actual ->
assertThat(actual)
.contains("<maven.compiler.release>25</maven.compiler.release>")
.containsPattern("kotlin-stdlib</artifactId>\\s*<version>2\\.3\\.")
.doesNotContain("Capped at Java 24")
actual
}
},
other("fun main() {}") { it.path("src/main/kotlin/App.kt") },
),
)
}
/**
* Root-cause isolation for defect (2), deterministic and self-contained. Running only the comment
* recipe on a module already on `kotlin-stdlib:2.3.0` at Java 24: the correct behavior is to add
* NO comment (single-arg `pomXml` asserts no change), because Kotlin 2.3 CAN target Java 25 so the
* "before 2.3" explanation is false. `@ExpectedToFail` because the recipe adds the comment anyway,
* naming kotlin-stdlib 2.3.0 while claiming "Kotlin before 2.3 cannot target Java 25".
*/
@Test
@ExpectedToFail("CommentJava24KotlinCap adds a 'Kotlin before 2.3' cap comment to a module on kotlin-stdlib 2.3 because it only checks maven.compiler.release == 24, not the resolved Kotlin version")
fun commentRecipeMustNotCapModuleAlreadyOnKotlin23() {
rewriteRun(
{ spec -> spec.recipeFromResources("org.openrewrite.java.migrate.CommentKotlinModulesCappedAtJava24") },
mavenProject(
"project",
pomXml(
"""
<project>
<groupId>com.mycompany.app</groupId>
<artifactId>my-app</artifactId>
<version>1</version>
<properties>
<maven.compiler.release>24</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>2.3.0</version>
</dependency>
</dependencies>
</project>
""".trimIndent(),
),
other("fun main() {}") { it.path("src/main/kotlin/App.kt") },
),
)
}
/**
* Baseline for the comment recipe: a module genuinely on `kotlin-stdlib:2.2.0` (< 2.3) at Java 24.
* Here the cap comment IS correct — Kotlin 2.2 really cannot target Java 25 — so the recipe should
* (and does) add it, naming kotlin-stdlib 2.2.0. Passing; confirms the defect above is specifically
* the missing "resolved Kotlin actually < 2.3" guard, not the comment recipe itself.
*/
@Test
fun baselineCommentIsCorrectWhenKotlinReallyBelow23() {
rewriteRun(
{ spec -> spec.recipeFromResources("org.openrewrite.java.migrate.CommentKotlinModulesCappedAtJava24") },
mavenProject(
"project",
pomXml(
"""
<project>
<groupId>com.mycompany.app</groupId>
<artifactId>my-app</artifactId>
<version>1</version>
<properties>
<maven.compiler.release>24</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>2.2.0</version>
</dependency>
</dependencies>
</project>
""".trimIndent(),
"""
<project>
<groupId>com.mycompany.app</groupId>
<artifactId>my-app</artifactId>
<version>1</version>
<properties>
<!-- Capped at Java 24: this module compiles Kotlin and depends on kotlin-stdlib 2.2.0, and Kotlin before 2.3 cannot target Java 25 bytecode. Upgrade Kotlin (kotlin-stdlib and the Kotlin compiler) to 2.3 or later, then re-run "Migrate to Java 25" to move this module to Java 25. -->
<maven.compiler.release>24</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>2.2.0</version>
</dependency>
</dependencies>
</project>
""".trimIndent(),
),
other("fun main() {}") { it.path("src/main/kotlin/App.kt") },
),
)
}
}
See unit test(s) below, with analysis from Claude.