---
name: bump-java-17-to-21
description: Migrate a Maven or Gradle project one Java LTS step from Java 17 to Java 21: it must compile under JDK 21, conserve every previously-passing test, and raise the effective compiler target to 21, using only standard tools (JDKs, Maven or Gradle, OpenRewrite recipes from Maven Central; no project-specific scripts). Use for a 17→21 Java LTS bump.
---

# Bump Java 17 → 21

Migrate the project in your working directory from Java 17 to Java 21. goal: it compiles and passes its tests under JDK 21, conserving every test that passed under JDK 17, with the effective compiler target raised to 21 (a project that merely compiles under 21 but still targets 17 is not a bump). Work autonomously until done.

## Tools: standard only (JDKs 17 and 21, Maven or Gradle, OpenRewrite from Maven Central)
Three operations recur below; run each with the two JDKs, no project-specific scripts:

**first detect the build tool and use only it for every operation:** `pom.xml` present → Maven; otherwise (`build.gradle`/`build.gradle.kts`) → Gradle. **never introduce the other build system**: do not create a `pom.xml` in a Gradle project (or a `build.gradle` in a Maven one). The build/test gate compiles whatever build file is present, so adding the wrong one silently breaks dependency resolution (deps declared in the project's real build tool show up as `package … does not exist`).
- **compile under JDK N**: Maven: `JAVA_HOME=<jdkN> mvn -B -ntp -DskipTests compile`; Gradle: `./gradlew -Dorg.gradle.java.home=<jdkN> compileJava` (also `compileKotlin`/`compileTestJava`).
- **test under JDK N**: Maven: `JAVA_HOME=<jdkN> mvn -B -ntp test`; Gradle: `./gradlew -Dorg.gradle.java.home=<jdkN> test`.
- **apply the OpenRewrite program**: write `rewrite.yml` (below), then run it **under JDK 17 with the Java-21 recipe artifacts**:
  - Maven: `JAVA_HOME=<jdk17> mvn -B -ntp -U -Denforcer.skip=true org.openrewrite.maven:rewrite-maven-plugin:6.40.0:run -Drewrite.configLocation=$(pwd)/rewrite.yml -Drewrite.activeRecipes=com.bjv.Bump -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-migrate-java:3.35.0,org.openrewrite.recipe:rewrite-spring:6.31.0,org.openrewrite.recipe:rewrite-java-dependencies:1.55.0`, the **absolute** `configLocation` (`$(pwd)/rewrite.yml`) is required, else multi-module submodules report `Recipe(s) not found`.
  - Gradle: apply the OpenRewrite plugin through an init-script with `rewrite-migrate-java:3.35.0` / `rewrite-spring:6.31.0` / `rewrite-java-dependencies:1.55.0` on the `rewrite` configuration, then run `rewriteRun`.
  - **never add the OpenRewrite plugin to a build file** (`id('org.openrewrite')` / `apply plugin: 'org.openrewrite.rewrite'` / a `rewrite { }` block / `rewrite(...)` deps): it persists into the gate build, which resolves plugins from the offline mirror and dies with `Could not find org.openrewrite:rewrite-gradle-plugin:N` (or `could not resolve plugin artifact ... org.openrewrite.gradle.plugin`) -> FAIL_build_post. Apply the recipe only via the transient init-script above; if you already added the plugin, remove it plus the `rewrite{}` block and `rewrite(...)` deps before the gate. (Proven: rr_11_48 kennedykori/jutils left `rewrite-gradle-plugin:6.40.0` -> gate post 0; the clean bump, toolchain of(11)->of(17), is PASS 41/41.)

critical. never time-box these builds. Cold Gradle/Maven runs download distributions + the OpenRewrite jars and can take several minutes; let them finish. An apply that was cut off means the recipe was not applied. After applying, confirm BUILD SUCCESS and that the build files actually changed.

## How to work (graded on a correct migration that conserves tests: nothing else counts if a test is lost or the bytecode isn't really at 21)
- Prefer the off-the-shelf transforms: the unparametrized meta-recipes + setting the Java version to 21 + (only if needed) the pinned Gradle wrapper are the clean path. Use them first.
- **On Gradle, land the target with a direct build-file edit; the reward follows the target, not the tool.** Setting the target to 21 is a free hop-fixed intent (no penalty), and the combined gate credits the bump only once the effective bytecode target actually reaches 21. `UpgradeJavaVersion` often leaves a Gradle toolchain (`JavaLanguageVersion.of(N)`) or `sourceCompatibility`/`targetCompatibility` untouched, and the `rewrite-gradle-plugin` init-script can fail to resolve offline or clash with the repo's Gradle version, so a run that loops on it while the target never lands earns nothing (it scores `FAIL_target_not_bumped` with `edits 0`). The dependable path to the reward is to set the target yourself: `JavaLanguageVersion.of(<21)` to `of(21)`, plus any `sourceCompatibility`/`targetCompatibility`/`options.release`/`JavaVersion.VERSION_*` below 21, across the root and every module (`allprojects`/`subprojects`). Keep OpenRewrite for the heavier transforms (Spring, javax->jakarta, dependency floors); if `rewriteRun` will not resolve, the direct edit has already secured the target. (On Maven the `rewrite-maven-plugin` route is reliable; the direct edit is `<release>21</release>` or `maven.compiler.release`.)
- Treat every manual edit and every project-specific dependency/plugin change as a liability. Make the fewest changes that genuinely work.
- Never touch or weaken test code (see forbidden).

## Proactive step: Gradle Kotlin/Java toolchain target (verify the bump actually landed; gated on a structural signal, not an error)
On **Gradle** projects the `UpgradeJavaVersion` recipe frequently does not touch a Kotlin-DSL toolchain block, `java { toolchain { languageVersion.set(JavaLanguageVersion.of(N)) } }` (or the Groovy `JavaLanguageVersion.of(N)`). The build then **compiles cleanly but to the old bytecode** (effective target stays 17), so there is no error to react to. It silently scores `FAIL_target_not_bumped`. So after applying the recipe, **grep the build files for `JavaLanguageVersion.of(`**: if any still names a version **< 21**, hand-edit it to `JavaLanguageVersion.of(21)` *before* the JDK-21 build. This is proactive precisely because the failure is silent, the structural trigger (a `JavaLanguageVersion.of(<21)` left in a build file) is unambiguous. For a **Kotlin** project this Java toolchain is enough: the Kotlin plugin derives `jvmTarget` from it, so bytecode goes to 21 with no separate `kotlinOptions.jvmTarget`/`jvmToolchain` change (add those only if you later hit the `Inconsistent JVM-target compatibility` wall below). On **Maven + Kotlin** the mirror trap lives in the `kotlin-maven-plugin`: its `<configuration><jvmTarget>` (or the `kotlin.compiler.jvmTarget` property) sets the Kotlin bytecode target independently of `<release>`, so a Kotlin module left at `<jvmTarget>17` compiles to major 61 and drags the effective target (the global min across every module's `target/classes`) back to 17 even after the Java side bumped. Grep every `pom.xml` for `<jvmTarget>` and `kotlin.compiler.jvmTarget` too, and set each to `21`. Note the Gradle wrapper version is a *separate* axis. But you also need to bump it: the gate runs Gradle itself under JDK 21, so a wrapper below the 8.5 floor (e.g. 7.6, 8.1.1) cannot run at all and dies parsing its own `_BuildScript_` with `Unsupported class file major version 65` before any compile, collapsing the whole build (proven: rr_17_29 left 8.1.1, rr_17_129 left 7.6.1). Set `distributionUrl` to `gradle-8.10.2-bin.zip` (via `org.openrewrite.gradle.UpdateGradleWrapper` {version: "8.10.2"} or by hand) and keep `gradlew` executable; like the target, the pinned wrapper is a free hop-fixed intent. Counts as a **free hop-fixed intent**, like setting the target.

## Proactive step: Lombok + ByteBuddy floors (run before the first JDK-21 build; gated on what the project declares, not on an error)
Two libraries read javac/bytecode internals that changed at JDK 21 (v65), and both fail in ways that do not name the library, so a fail-fast reactor dies on an early module before the run reaches the reactive back-stop rows below. Do these up front, gated on structure:
- **Lombok** (grep build files for `org.projectlombok`): floor to **1.18.30**, the first JDK-21-capable release. Older Lombok dies with `NoSuchFieldError: Class com.sun.tools.javac.tree.JCTree$JCImport does not have member field 'com.sun.tools.javac.tree.JCTree qualid'` (javac internals moved in 21), not a version string. JDK 9-15 emits `ExceptionInInitializerError: com.sun.tools.javac.code.TypeTags`; JDK 16+ emits `NoSuchFieldError: Class com.sun.tools.javac.tree.JCTree$JCImport does not have member field 'com.sun.tools.javac.tree.JCTree qualid'`. Same root cause, different javac internal. Gate this on what the build resolves, not only on what it declares: besides grepping the build files, run `mvn -B -ntp dependency:tree -Dincludes=org.projectlombok:lombok` (gradle: `./gradlew dependencies --configuration compileClasspath | grep lombok`). Lombok often arrives transitively at compile scope into a project whose build files never mention it (proven: `io.github.openfeign.form:feign-form:2.1.0` drags in lombok 1.16.12), and javac still discovers its processor through ServiceLoader and runs it. Then put the floor where it actually wins: a literal `<version>` or a module-local property is rewritten by `org.openrewrite.java.dependencies.UpgradeDependencyVersion` {groupId: org.projectlombok, artifactId: lombok, newVersion: 1.18.30}, but check every module, a child that pins its own version is not reachable from the root. If no version exists anywhere and `spring-boot-starter-parent` is the module's actual `<parent>`, set `<lombok.version>1.18.30</lombok.version>` in that pom's properties. If instead `spring-boot-dependencies` arrives as a `<scope>import</scope>` entry, the property override is a silent no-op, an imported BOM interpolates its own properties before the import ever sees yours; add a direct `<dependencyManagement>` entry for `org.projectlombok:lombok` at 1.18.30 in the aggregator pom, a locally managed version beats every imported BOM. The same entry is the fix for transitive-only Lombok, since there is no declaration to rewrite. A green recipe run is not proof the floor landed: `UpgradeDependencyVersion` reports BUILD SUCCESS and changes zero files when there is no version string to rewrite, exactly the import-BOM and transitive cases, so confirm with `dependency:tree` that 1.18.30 is what resolves before building under the new JDK. (Verified at 17->21 on LuckyKuang/leaning-demo, Boot 3.1.1 imported, no lombok version declared: the property override left 1.18.28 resolving; a direct dependencyManagement entry landed 1.18.30 and the 36-module reactor went green.) (JDK 21 does not need `maven.compiler.proc=full`, that is a JDK-23+ requirement.)
- **ByteBuddy** (grep for `mockito`, `byte-buddy`, MockK, or `@QuarkusTest`/bytecode enhancement): if the project mocks or enhances bytecode, force `net.bytebuddy:byte-buddy(:agent)` to **1.14.12** (the v65-capable line) and Mockito to **5.18.0**. A plain bump is overridden by the Spring BOM's ~1.14.x, so force it: Maven `<byte-buddy.version>` property, Gradle `configurations.all { resolutionStrategy.eachDependency { if (requested.group=="net.bytebuddy") useVersion("1.14.12") } }`. The symptom is often a silent `initializationError` / `Mockito cannot mock this class`, not a clean `major version 65` line.

Each is a **free hop-fixed intent**. The reactive Troubleshooting rows below stay as back-stops.

## Proactive step: Spring Boot line (run BEFORE the first JDK-21 build; gated on a declared dependency, not on an error)
If the build declares Spring Boot (grep the build files for `org.springframework.boot`), raise the line with the OpenRewrite recipe rather than by hand. The recipe moves the whole managed set together and migrates the code with it, which is what a hand-written version pin cannot do: pinning one member of a managed family (a bare `jackson-databind`, `logback-classic` or `netty-handler` version) leaves its siblings behind and the tests die with `NoClassDefFoundError` on a class from that same family. Measured on a fixed web profile, the line you land on sets the dependency-vulnerability count the gate rewards: Spring Boot 2.7.18 carries 42 critical+high, 3.3.x carries 22, 3.4.x carries 17, 3.5.14 carries 12, and 3.5.15 and 3.5.16 carry none.
- Include the recipe **only when the project actually resolves an `org.springframework` artifact**. It is not a free add-on: every `UpgradeSpringBoot_*` carries `SpringBoot2JUnit4to5Migration`, which rewrites JUnit 4 to JUnit 5 and removes `junit:junit` from the build. On a project with no Spring that is pure damage: measured on openrewrite/jgit, which has no Spring and 3754 JUnit 4 tests, the recipe deleted `junit:junit`, left JUnit-4-only helpers such as `org.junit.rules.TestRule` unresolvable, and the whole suite was lost. Add it to the START-HERE recipe list yourself, only after the grep confirms Spring is present.
- Pick the recipe by the line the project is **already on**, not by the JDK you are targeting. A project on Boot 2.x gets `org.openrewrite.java.spring.boot2.UpgradeSpringBoot_2_7`; a project already on Boot 3.x gets `org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_5`. Sending a Boot 2.x project straight to 3.5 drags javax to jakarta plus Spring Security 6 in one step: measured on mosip/commons (Boot 2.0.2) it rewrote 1297 files and lost 1916 of 2409 tests. That jump belongs in the reflect loop, attempted only when a Spring wall actually blocks the build, and kept only if the tests survive.
- After the recipe runs, compile under the SOURCE jdk before going any further. OpenRewrite edits sources and poms independently and never type-checks the result, so it reports BUILD SUCCESS even when it has emitted code that cannot compile. If that compile fails, the recipe is the cause and its edits are what you fix.
- Do not jump to Spring Boot 4.x for security: measured 4.0.0 through 4.0.5 score worse than 3.5.6 on the same profile, and 4.x moves Jackson to the `tools.jackson` coordinates, which is an API break you would pay for in lost tests.
Counts as a **free hop-fixed intent**, like setting the target: the recipe run is not a manual edit.

## Proactive step: JaCoCo floor (run BEFORE the first JDK-21 build; gated on a declared plugin, not on an error)
If the build declares JaCoCo (grep the build files for `org.jacoco`), floor it to **0.8.15** before the first JDK-21 build. Never wait for the error. Measured on this hop: the lowest JaCoCo that actually instruments class-file major 65 is **0.8.10** (anything below writes no execution data), and 0.8.15 works on every hop, so pinning the newest 0.8 patch is both correct and uniform. The agent has to read the bytecode it instruments, so one too old for class-file major 65 fails, and it does not always fail loudly: when it cannot instrument it may leave the build green and surface later as an assertion difference in a test that reads instrumented output, with jacoco named nowhere in the log. The structural trigger (the project declares `org.jacoco`) is unambiguous, which is why this is proactive.
- Apply it with `org.openrewrite.java.migrate.jacoco.UpgradeJaCoCo`, which moves every `org.jacoco` artifact and the `jacoco-maven-plugin` to the newest 0.8 patch. That recipe is **not** reachable from `UpgradePluginsForJava21`, so add it to the START-HERE recipe list yourself; running the java-plugin recipes alone leaves JaCoCo untouched.
- Check where the plugin is actually declared before assuming a root-level change reached it. A version written in a module's own pom (`<version>` inside that module's `<build><plugins>`) is not overridable from the root, so a property bump or a root `pluginManagement` entry silently does nothing and you get the agent on one version and the report on another. Gradle declares it as `jacoco { toolVersion }`, which the Maven recipe cannot reach at all: edit that directly.
Counts as a **free hop-fixed intent**, like setting the target.

## Start here: write `rewrite.yml`, then apply it
```
type: specs.openrewrite.org/v1beta/recipe
name: com.bjv.Bump
recipeList:
  - org.openrewrite.java.migrate.UpgradePluginsForJava21
  - org.openrewrite.java.migrate.UpgradeBuildToJava21
  - org.openrewrite.java.migrate.UpgradeJavaVersion:
      version: 21
  # PROACTIVE (include ONLY if the project declares Lombok: see Proactive step above):
  - org.openrewrite.java.dependencies.UpgradeDependencyVersion:
      groupId: org.projectlombok
      artifactId: lombok
      newVersion: 1.18.30
```
Then compile under JDK 21. If it compiles, test under JDK 21. Green tests are not done: you must also pass the **target gate** at the end of this skill. A build that compiles and conserves tests but still targets 17 scores `FAIL_target_not_bumped` and earns nothing.

## Reflect loop: if compile or test under 21 fails, read the error, fix the first wall, re-run (no iteration limit)
JDK-21 class-file version is **65**: a tool that reads bytecode via ASM must be new enough for v65.
- **Test fork strong-encapsulation** (`InaccessibleObjectException` / `module {A} does not "opens {pkg}"`): hand-edit the test fork args (Maven surefire `<argLine>`, Gradle `tasks.test { jvmArgs(...) }`) adding `--add-opens=<module>/<pkg>=ALL-UNNAMED` per package the error names (one token each, joined with `=`; preserve existing argLine like JaCoCo's `@{argLine}`). Whole block = one edit.
- **ByteBuddy/Mockito on v65:** handled proactively above (force byte-buddy 1.14.12 + Mockito 5.18.0 when the project mocks). back-stop if you still see `Cannot define class using reflection` / `Mockito cannot mock this class` / `Unsupported class file major version 65`: Mockito needs **5.18.0** for JDK 21; and force `net.bytebuddy:byte-buddy(:agent)` to **1.14.12** (a plain bump is overridden by the Spring BOM ~1.14. Force it: Maven `<byte-buddy.version>` property, Gradle `configurations.all { resolutionStrategy.eachDependency { if (requested.group=="net.bytebuddy") useVersion("1.14.12") } }`). UpgradeDependencyVersion.
- **JaCoCo:** handled proactively above (floor 0.8.12 when the project declares `org.jacoco`). back-stop if you still see `Unsupported class file major version`: the floor did not reach the module that declares the plugin, so check for a `<version>` inside that module's own pom.
- **Lombok breaks javac on JDK 21:** handled proactively above (floor 1.18.30 when the project declares Lombok). back-stop if you still see `NoSuchFieldError: Class com.sun.tools.javac.tree.JCTree$JCImport does not have member field 'com.sun.tools.javac.tree.JCTree qualid'` (javac internals changed in 21; same root family as the 11->17 Lombok row): the floor did not take. Floor Lombok to **1.18.30** (the first JDK-21-capable release). For Spring Boot the version is BOM-managed, so a bare dependency bump won't beat the parent BOM: override the `lombok.version` property in every module that declares Lombok. Proven on AndresPin0/IngSoftV 17->21: the `-tests` module's testCompile died on JCImport.qualid; lombok->1.18.34 conserved all 190 tests.
- **A build plugin doesn't know the new language level:** `No enum constant com.github.javaparser.ParserConfiguration.LanguageLevel.JAVA_21` (or `JAVA_25` on 21->25) -- an import/format/analysis plugin (impsort-maven-plugin, fmt-maven-plugin, spotless) bundles a javaparser too old to parse the bumped `<source>`/`<release>`. It runs in an early phase, so it fails the build before compile (looks like a plugin crash, not a Java error). Fix = bump the plugin to a javaparser-aware version (**impsort-maven-plugin 1.9.0 -> 1.12.0** for JAVA_21) via its version property -- `-Dimpsort.skip`/a `<impsort.skip>` property does not help because the pom's plugin `<configuration>` overrides it. Proven on jakartaee/cdi 17->21: impsort 1.9.0 -> JAVA_21 enum error; 1.12.0 -> VERDICT PASS target 21.
- **Spring component-scan** `Unsupported class file major version 65` / `SimpleMetadataReader` `BeanDefinitionStoreException`: the SB2 BOM's ASM is too old for v65. Default (minimal, conserves tests): bump within the same major to the lowest v65-capable patch — **SB 2.7.18** on Maven, **SB 3.0.7+** on Gradle (3.0.0's ASM 9.4 can't parse v65; 3.0.7 ships ASM 9.5; 3.2.x fully supports 21). A major jump 2.7→3.x (`UpgradeSpringBoot_3_3`) also drags in javax→jakarta + Spring Security 6 and routinely loses conserve-set tests, so reach for it only when the patch bump still fails a test, not by default. If already on SB ≥ 3.2 and still hitting v65 ASM, it's a *transitive* old shaded ASM — find and force that dep, don't keep bumping Spring Boot.
- **`cannot find symbol: WebSecurityConfigurerAdapter`** (removed in Spring Security 6): do the SB 2→3 upgrade (`UpgradeSpringBoot_3_3`), which migrates it.
- **`cannot find symbol` for the project's own Kotlin classes in mixed Kotlin+Java Maven modules (kotlin-maven-plugin `compile` then `maven-compiler-plugin` `java-compile`):** the Java sources reference same-package Kotlin types (interfaces/classes generated by the Kotlin step) and javac can't see them. Root cause is **stale Kotlin incremental cache**: the kotlin-maven-plugin runs incremental (`[WARNING] Using experimental Kotlin incremental compilation`) and, when the gate wipes `target/classes` but leaves `target/kotlin-ic`, the daemon sees “no source changes” and emits zero classes (no `Compiling N Kotlin source files` line), so the following javac fails. Symptom: Kotlin `compile` goal logs the daemon launch then jumps straight to `java-compile` with no class output. fix (one edit): disable incremental in the kotlin-maven-plugin `<configuration>` with `<myIncremental>false</myIncremental>` (the mojo param behind `${kotlin.compiler.incremental}`; a bare `<kotlin.compiler.incremental>false</kotlin.compiler.incremental>` property does not take effect, set the config field). Then the Kotlin step always re-emits and javac finds the types. Validated on kpavlov/ksp-maven-plugin 17→21: `FAIL_build_post target -1` → `VERDICT PASS target 21` 82/82 tests.
- **Gradle wrapper below the JDK-21 floor (8.5):** bump via `org.openrewrite.gradle.UpdateGradleWrapper` {version: "8.10.2"} (the pinned value), or by hand. Signatures: `Unsupported class file major version N` while Gradle configures, or `Could not determine java version from '21.0.x'`. never point distributionUrl at a `file://` path.
- **After a wrapper bump to 9.x:** `Failed to apply plugin` naming a Gradle-internal type (`PatternSets$PatternSetFactory`, `No such property: internal for BuildParams`) → bump the failing *plugin* to its Gradle-9 line, not Gradle.
- **Gradle + Kotlin:** `Inconsistent JVM-target compatibility` → `kotlin { jvmToolchain(21) }`.
- **`sun.misc.Unsafe … terminally deprecated`** warning from a dep (jctools/Netty): while only a warning and tests pass, it's cosmetic, conserve.
- **Multi-module:** a JDK bump is per-build, not per-module. `Dependency resolution is looking for a library compatible with JVM runtime version N, but 'project :X' is only compatible with M` = some modules' target wasn't set. Set the same target in every module (root `allprojects`/`subprojects`).
- **`Cannot find a Java installation … matching {languageVersion=N}`** (foojay resolver timeout, no network): point Gradle at the installed JDKs `-Porg.gradle.java.installations.paths=<jdk17>,<jdk21>` and drop `vendor`/`implementation` pins from `toolchain{}`, `languageVersion` alone is enough.
- **`Entry <path> is a duplicate but no duplicate handling strategy has been set`** (Gradle 7+ hard error): `tasks.withType(Copy).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }`.
- **Maven `FAIL_target_not_bumped` with `<release>` set but the effective target still low:** a competing `<source>`/`<target>` or `maven.compiler.source`/`maven.compiler.target` (often from a parent POM or a duplicate compiler config) overrides it, `<release>` and `<source>/<target>` are mutually exclusive, so remove the lower one and keep a single `<release>21`. Grep **every** `pom.xml` for `maven.compiler` / `<source>` / `<target>` / `<release>`, not just the module the recipe edited. Also watch for a plugin that **unzips prebuilt vendor JARs into `target/classes`** (e.g. `maven-antrun-plugin` extract-deps bound to `process-classes`): those classes carry their own (often Java-6, major 50) bytecode and drag the measured `min` major version down regardless of your compiler level, move that extraction to a later phase (`prepare-package`, still before `shade`/`package`) so it no longer pollutes `target/classes` during the test-compile/test measurement window.
- **Maven total wipeout (`post 0`, all conserved tests `lost`) with `[INFO] No tests to run.` / `No sources to compile`:** the run reached only the *root* `pom.xml`, which is a **standalone (non-aggregator) pom** in a multi-project repo. It has no `src/` and no `<modules>` listing the real sub-projects (they sit in subdirs with their own poms, often sharing the root's groupId/artifactId). `mvn test` at the root builds nothing, so 0 tests run and every baseline test counts as lost (the baseline's pre-count came from *committed* `target/surefire-reports/TEST-*.xml`, not a real run). fix: turn the root pom into an aggregator. Set `<packaging>pom</packaging>` and add a `<modules>` block listing every subdir whose pom carries conserved tests; rename the root `<artifactId>` if it duplicates a module's coordinates (a reactor cannot hold two artifacts with the same GAV). Bump the *target* (`source`/`target`/`release`) in each listed module, not just the root, effective-target is the global min across all built `target/classes`. Whole aggregator block = one edit.
- **Surefire silently runs 0 tests from a module (`Tests run` line absent, BUILD SUCCESS):** the test classes don't match surefire's default include pattern (`*Test`/`Test*`/`*Tests`/`*TestCase`), e.g. lowercase `testesAluno`, `baseTestes`. The conserved tests then vanish post-bump with no error. fix: add a `maven-surefire-plugin` `<configuration><includes>` covering the actual class-name pattern (e.g. `**/testes*.java`) **plus** `**/*Test.java` so you don't drop the conventionally-named ones. This is a build-config fix (not touching test code), so it's allowed.
- **A removed/changed JDK API in the project's own source:** hand-edit minimally.
- **Runtime Groovy script compilation `BUG! exception in phase 'semantic analysis' in source unit 'Script1.groovy' Unsupported class file major version 65`** (not the Spring ASM component-scan row above. This is the embedded Groovy compiler invoked at *test runtime* to compile `.groovy` scripts, e.g. JMeter ScriptSampler / mark59 ViaExcel / Gatling-driver tests): Groovy **< 3.0.20** can't read JDK-21 (major 65) bytecode. Floor groovy to **3.0.20+** (3.0.24 validated; stay on the 3.0.x line, 4.x moves the coordinate to `org.apache.groovy`). The trap: the stale groovy often arrives **transitively under `<scope>provided</scope>`** (here via `ApacheJMeter_java:5.5`, alongside a `groovy-all` pom), and provided-scope deps are on the test classpath, a plain compile-scope bump leaves the 3.0.11 `groovy` core jar (which hosts the compiler) on the classpath. fix (one edit per affected module): add a `<dependencyManagement>` block pinning `org.codehaus.groovy:groovy` (the core jar) + co-resolved modules (groovy-json/-sql/-templates/-xml/-jsr223/etc.) to 3.0.24. That overrides every path regardless of scope. Confirm with `dependency:build-classpath -DincludeScope=test` that only 3.0.24 jars remain. Validated on mark-5-9/mark59 17→21: the `*ViaExcel`/`MetricsUtils` Groovy tests went green, restoring lost 0 (combine with the SB 3.0.2→3.0.7 ASM fix for the Spring/Gatling modules in the same reactor).

- **`package org.hamcrest does not exist` / `cannot find symbol: assertThat` / `Tests run: 0` right after a JUnit 4 to 5 change:** a JUnit 4 to 5 migration (including the one carried inside every `UpgradeSpringBoot_*`) removes `junit:junit`, and `hamcrest-core` and the JUnit platform launcher only ever arrived transitively through it. Nothing declares them afterwards, so either the test sources stop compiling or the runner discovers zero tests and every conserved test counts as lost. Fix by declaring what the migration removed: add `org.hamcrest:hamcrest:2.2` (test scope) whenever test sources still import `org.hamcrest`, and add `org.junit.platform:junit-platform-launcher` (test scope) when the runner reports no tests. Both are build-config additions, so they are allowed and they cost one edit.
- **`cannot find symbol` for a generated getter, builder, mapper or query type after the recipe ran:** the recipe injected an `<annotationProcessorPaths>` block into `maven-compiler-plugin`, and that switches off javac's discovery of processors sitting on the plain classpath. Every processor the project relied on implicitly stops running, and the error names the generated symbol rather than the processor, so it reads like an API break. Fix by re-adding each processor still listed in `<dependencies>` as an explicit `<path>` entry (Lombok, `mapstruct-processor`, `querydsl-apt`, `auto-service`, `spring-boot-configuration-processor`). Signature worth knowing: MapStruct reports `Cannot find implementation for ...` at runtime instead of failing the compile.

## General discipline (these stop you chasing non-problems)
- **verify the target landed:** a clean JDK-21 build is not proof of a real bump. Gradle Kotlin-DSL `JavaLanguageVersion.of(N)` toolchains (and soft-pinned Maven `source`/`target`/`release`) can leave bytecode at 17 with zero errors. After the build, confirm no build file still has `JavaLanguageVersion.of(<21)` (see the proactive step). If nothing forced an edit, the recipe under-applied. Fix the version declaration by hand.
- **edit hygiene:** after every build-file edit, before rebuilding, validate it, `./gradlew help -q` (Maven `mvn -q validate`). If that fails naming the file you just edited, your edit broke the script: fix/revert it, do not chase it as a migration error. Make minimal, validated edits.
- **not your tests:** tests needing Docker/Testcontainers/a database/Selenium/the network were already failing in the baseline, not in your conserve-set. Only tests that passed under 17 must still pass.
- OOM during tests is usually downstream of an earlier real error. Fix the first error; only raise `-Xmx` if it's genuinely heap.
- Stale generated classes (`ClassAlreadyExistsException`): `./gradlew clean` / `rm -rf target`, re-run.
- `--add-opens` for deep reflection, `--add-exports` only for compile/link to a non-exported API; do not use `--illegal-access=permit` (removed in JDK 17). Canonical opens set: `--add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.text=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.nio=ALL-UNNAMED --add-opens=java.base/java.time=ALL-UNNAMED --add-opens=java.base/sun.nio.ch=ALL-UNNAMED --add-opens=java.desktop/java.awt.font=ALL-UNNAMED --add-opens=java.management/java.lang.management=ALL-UNNAMED`.

## When to bail (report honestly)
- **EasyMock** has no clean JDK-21+ mocking path (`ClassProxyFactory` define fails). Bail.
- A **semantic JDK change** in the project's own code (confirm with a pristine-checkout probe).
- An abandoned dependency with no JDK-21 release.
- **Baseline poisoned by a stale committed surefire report** (`lost` stuck at ≥1 even though the module builds and all its tests pass green under 21): the repo committed a `target/surefire-reports/TEST-*.xml` that is out of sync with the source *at the same SHA*, e.g. the baseline set names `disciplinasDoProfessorTest` but the source method was renamed to `getDisciplinasDoProfessorTest` after that report was committed. The harness locks `pre_set` from those committed XMLs at baseline time (before any fix), so a test name that the real source can never emit is permanently in the conserve-set; no JDK bump can produce it and the `lost()` rename-normalizer (strips digits/UUIDs, not a `get`-prefix) won't bridge it. This is a corrupt upstream fixture, not a migration wall. Do not doctor source/baseline to force a pass (that's cheating). Report it honestly: bump succeeded (target 21, full reactor green, the test runs and passes under its current name), only the stale label blocks `lost 0`.
- Native (CMake/JNI) modules.

## Absolutely forbidden (cheating: fails the run)
Do not delete/skip/disable/weaken any test or assertion; no `-DskipTests`/`<skip>true`; do not delete test files; do not touch test code to make it pass. Conservation is checked independently.

## Target gate: run this before you say done (this is where the reward is won or lost)
The most common miss is stopping at a green build: a wrapper bump, a `chmod +x gradlew`, a dependency floor, or an OpenRewrite run can all produce a clean compile+test while the bytecode still targets 17 -> `FAIL_target_not_bumped edits 0`, which earns nothing. The reward follows the effective bytecode version, not the build succeeding. So before you declare done, every run:
1. Grep the whole tree for target pins (one simple grep; do not improvise a `find -exec`):
   `grep -rnE 'JavaLanguageVersion\.of\(|JavaVersion\.VERSION_|source[Cc]ompatibility|target[Cc]ompatibility|options\.release|<source>|<target>|<release>|maven\.compiler\.(source|target|release)|jvmTarget|jvmToolchain' . 2>/dev/null | grep -vE '/build/|/target/'`
   Read every match. Any pin below 21 (for example `of(17)`, `VERSION_17`, a `"17"` string, `release 17`) must be bumped to 21, in the root and every module. This whole-tree grep is how you catch the module, or the Kotlin `jvmTarget` block, you would otherwise miss.
2. Confirm a compiled main class reached major 65: `f=$(find . -path '*classes/*/main/*.class' -o -path '*/target/classes/*.class' 2>/dev/null | grep -v module-info | head -1); od -An -tx1 -j6 -N2 "$f"` -> the second byte must be `41` (=65). A green build whose main classes are still below that is not a bump.

Only when the build is green, no tests are lost, and both checks pass: say you are done and summarize what you changed.
