Skip to content

Java 25 with Kotlin - can keep jdk 24 pin, even if recipe upgraded kotlin above 2.3 #1240

Description

@nmck257

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") },
            ),
        )
    }
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions