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

# Bump Java 21 → 25

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

## Tools: standard only (JDKs 21 and 25, 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 21 with the Java-25 recipe artifacts**:
  - Maven: `JAVA_HOME=<jdk21> mvn -B -ntp -U -Denforcer.skip=true org.openrewrite.maven:rewrite-maven-plugin:6.41.0:run -Drewrite.configLocation=$(pwd)/rewrite.yml -Drewrite.activeRecipes=com.bjv.Bump -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-migrate-java:3.36.0,org.openrewrite.recipe:rewrite-spring:6.31.0,org.openrewrite.recipe:rewrite-java-dependencies:1.55.3`, 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.36.0` / `rewrite-spring` / `rewrite-java-dependencies` 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 take 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 25)
- Prefer the off-the-shelf transforms: the unparametrized meta-recipes + setting the Java version to 25 + (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 25 is a free hop-fixed intent (no penalty), and the combined gate credits the bump only once the effective bytecode target actually reaches 25. `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(<25)` to `of(25)`, plus any `sourceCompatibility`/`targetCompatibility`/`options.release`/`JavaVersion.VERSION_*` below 25, 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>25</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 steps: do these before the first build, gated on what the project uses (not on an error)
- **Lombok (two changes, do both up front):** if the build declares Lombok (grep build files for `org.projectlombok`), then as part of the start-here recipe: **(1) floor Lombok to 1.18.46**: the JDK-25-capable release; 1.18.3x/1.18.40 predate JDK 25 and silently fail. **(2) force annotation processing**: JDK 23+ no longer auto-runs a processor found only on the classpath, so a plain `lombok` dependency stops generating: for **Maven** set property `maven.compiler.proc=full`; for **Gradle** ensure Lombok is on the processor path (`annotationProcessor`/`testAnnotationProcessor "org.projectlombok:lombok"`, or the `io.freefair.lombok` plugin). Do not wait for a compile error, an old or un-run Lombok fails open-endedly and never names Lombok: `cannot find symbol getX()`/`builder()`/`log`, `constructor … cannot be applied`, different symbols per project, indistinguishable from ordinary API-change errors. The floor alone is not enough, without proc=full it still fails; do both. 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.46}, 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.46</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.46 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.46 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.)

- **ByteBuddy (do it up front if the project mocks or enhances bytecode):** if the build declares `mockito`, `byte-buddy`, MockK, or `@QuarkusTest`/Hibernate bytecode enhancement (grep the build + test files), force `net.bytebuddy:byte-buddy(:agent)` to **1.17.6** (the v69-capable line) and Mockito to **5.18.0**, before the first JDK-25 test run. 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.17.6") } }`. Do not wait for the error: the symptom is often a silent `initializationError` / `Mockito cannot mock this class`, not a clean `major version 69` line, and a fail-fast reactor dies on an early module before reaching the reactive back-stop row. Counts as a **free hop-fixed intent**.

- **Gradle wrapper floor (run before the first JDK-25 build; gated on the structural wrapper version, not an error):** if the build tool is **Gradle**, read the wrapper version in `gradle/wrapper/gradle-wrapper.properties` (the `gradle-<N>-bin.zip` in `distributionUrl`). **If it is below 9.1** (the JDK-25 floor), do both of these *before* applying the recipe / building under JDK 25. Never wait for the error: **(1)** set `distributionUrl` to **`gradle-9.1.0-bin.zip`** (the pinned value; via `org.openrewrite.gradle.UpdateGradleWrapper` {version: "9.1.0"} or by editing the line directly. never a `file://` path), and **(2)** ensure the wrapper script is executable (**`chmod +x gradlew`**). Gradle itself runs on the build JDK, and Gradle **8.x cannot run on JDK 25**: it dies during *configuration* with `BUG! ... Unsupported class file major version 69` in `_BuildScript_` while parsing `settings.gradle`/`build.gradle`, before any project code is touched (Gradle 8.5/8.9/8.10 all fail; 9.0.0 also fails; **9.1.0** is the first that runs on JDK 25, verified). That v69 text is the same signature JaCoCo/ByteBuddy/Mockito also emit, so reactive error-matching cannot reliably attribute it to the wrapper, but the **structural trigger (wrapper version < 9.1) is unambiguous**, which is exactly why this is proactive (same test as the Lombok proactive step). The `chmod +x` matters: if `gradlew` is checked in without the executable bit, the build silently falls back to a system `gradle` (e.g. 8.10.2) that the `distributionUrl` edit can never influence, so a wrapper bump that “looks applied” still dies on v69 until `gradlew` is made executable. The reactive Troubleshooting row below stays as a back-stop.

## Proactive step: Spring Boot line (run BEFORE the first JDK-25 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-25 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-25 build. Never wait for the error. Measured on this hop: the lowest JaCoCo that actually instruments class-file major 69 is **0.8.13** (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 69 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 `UpgradePluginsForJava25`, 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.UpgradePluginsForJava25
  - org.openrewrite.java.migrate.UpgradeBuildToJava25
  - org.openrewrite.java.migrate.UpgradeJavaVersion:
      version: 25
  # PROACTIVE (include ONLY if the project declares Lombok: see Proactive steps):
  - org.openrewrite.java.dependencies.UpgradeDependencyVersion:
      groupId: org.projectlombok
      artifactId: lombok
      newVersion: 1.18.46
  # PROACTIVE (Maven + Lombok only): JDK 23+ won't auto-run a classpath-only processor
  - org.openrewrite.maven.AddProperty:
      key: maven.compiler.proc
      value: full
```
Then compile under JDK 25. If it compiles, test under JDK 25. 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 21 scores `FAIL_target_not_bumped` and earns nothing.

## Reflect loop: if compile or test under 25 fails, read the error, fix the first wall, re-run (no iteration limit)
JDK-25 class-file version is **69**: a tool that reads bytecode via ASM must be new enough for v69.
- **Lombok:** handled proactively above (floor 1.18.46 + force annotation processing). back-stop: if you still see open-ended `cannot find symbol getX()`/`builder()` / `constructor … cannot be applied` from a Lombok-annotated class, either the floor or proc=full did not take. Verify the pom has lombok **1.18.46** and `<maven.compiler.proc>full</maven.compiler.proc>` (Gradle: lombok on the annotationProcessor path), then re-apply and rebuild.
- **ByteBuddy/Mockito on v69:** handled proactively above (force byte-buddy 1.17.6 + Mockito 5.18.0 when the project mocks). back-stop if you still see `Mockito cannot mock this class` / `Cannot define class using reflection` / `Unsupported class file major version 69`: Mockito needs **5.18.0**; and force `net.bytebuddy:byte-buddy(:agent)` to **1.17.6** (a plain bump is overridden by the Spring BOM ~1.14. Force it: Maven `<byte-buddy.version>`, Gradle `resolutionStrategy.eachDependency { if (requested.group=="net.bytebuddy") useVersion("1.17.6") }`). Triage: `-Dnet.bytebuddy.experimental=true` makes mocks work on an unsupported-but-close JDK, if it does, ship the version bump, not the flag.
- **Spring component-scan ASM on v69 (Spring Boot apps):** a `@SpringBootTest`/component-scan test fails as `SomeApplicationTests > initializationError` with `org.springframework.core.type.classreading.ClassFormatException` -> `java.lang.IllegalArgumentException at ClassReader.java:200` (Spring’s own bundled ASM in `spring-core`, reading your v69 app classes during classpath scanning). This is distinct from the Mockito/ByteBuddy mock failures and is not fixed by forcing `byte-buddy`/`mockito`. Those fix the mock-creation tests, but this initializationError remains. Fix by bumping **Spring Boot itself to a v69-capable line (≥ 3.5.5**, which ships spring-core 6.2.x with a JDK-25 ASM); for Gradle that is the `id("org.springframework.boot") version "…"` plugin, for Maven the parent/BOM version. Bonus: the Spring Boot bump also raises managed Mockito/ByteBuddy to v69-capable versions, so an explicit byte-buddy/mockito force is usually then redundant. Prefer the single Spring Boot bump over three separate forces. (Verified on gracekarinn/adpro 21→25: wrapper 9.1.0 + toolchain 25 + lombok pin + Spring Boot 3.4.2→3.5.5 = 37/37 PASS; forcing only byte-buddy 1.17.6 + mockito 5.18.0 left 1 failing initializationError.)
- **JaCoCo:** handled proactively above (floor 0.8.15 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.
- **A build plugin doesn't know the new language level:** `No enum constant com.github.javaparser.ParserConfiguration.LanguageLevel.JAVA_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, failing the build before compile (looks like a plugin crash, not a Java error). Fix = bump the plugin to a javaparser-aware version via its version property; a skip property usually does not help because the pom plugin `<configuration>` overrides it. (Same wall family proven on jakartaee/cdi 17->21: impsort-maven-plugin 1.9.0 -> 1.12.0 cleared the JAVA_21 enum error -> PASS.)
- **Test fork strong-encapsulation** (`InaccessibleObjectException`): add `--add-opens=<module>/<pkg>=ALL-UNNAMED` per package the error names to the test fork args (one token each; preserve existing argLine).
- **Gradle + Kotlin:** emitting **JVM 25 bytecode needs Kotlin ≥ 2.3.x** (verified: 2.3.20/2.4.0 emit major-69). **Kotlin 2.2.x, including 2.2.20, silently falls back to JVM 24** logging `does not yet support 25 JDK target, falling back to JVM_24`; that is not harmless. It leaves the effective target at 24 and fails the target gate. Bump every `kotlin("...")` plugin id to **≥ 2.3.20** and set `kotlin { jvmToolchain(25) }`; do not accept the JVM_24 fallback. Older Kotlin (2.1.x) lacks the `JvmTarget.JVM_25` enum entirely (`Unresolved reference 'JVM_25'` at build-script compile), same fix. **KSP, if present, is version-locked to Kotlin and uses a unified version (not `<kotlin>-<ksp>`): for Kotlin 2.3.20 use `com.google.devtools.ksp` and `symbol-processing-api` `2.3.9`** (the KSP2 release that targets Kotlin 2.3.20). Do not guess a `<kotlin>-2.1.0`-style suffix (those don't exist); confirm the exact version against the repository's `maven-metadata.xml`. In **Quarkus** the Kotlin version is pinned by the platform BOM. Bump the **Quarkus platform**, not Kotlin directly.
- **Gradle wrapper below the JDK-25 floor (9.1):** Gradle 8.x dies during configuration with `BUG! ... Unsupported class file major version 69` in `_BuildScript_` (and can't parse a JDK-25 toolchain version string like `25.0.3`). 9.0.0 also fails, **9.1.0** is the first that runs on JDK 25. Bump via `org.openrewrite.gradle.UpdateGradleWrapper` {version: "9.1.0"}, or set `distributionUrl` to `gradle-9.1.0-bin.zip`, and make the wrapper executable (`chmod +x gradlew`), without the exec bit the build silently falls back to a system `gradle` your `distributionUrl` edit cannot influence. Hard-gate: `JAVA_HOME=<jdk25> ./gradlew --version` must succeed first; `Unsupported class file major version 69` in `_BuildScript_` = the wrapper, not your code. never point `distributionUrl` at a `file://` path.
- **After the 9.x wrapper bump:** `Failed to apply plugin` naming a Gradle-internal type → bump the failing *plugin* to its Gradle-9 line (nebula `ospackage` ≥ 12.3, `com.gradleup.shadow` ≥ 9.x with `enableRelocation`→`enableAutoRelocation`, `io.freefair.lombok` ≥ 9.x, spotless `googleJavaFormat` ≥ 1.34). Bump only the failing plugin; repeat per plugin.
- **Multi-module (Gradle):** 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` = set the same target in every module (root `allprojects`/`subprojects`).
- **Multi-module (Maven), silent target miss:** if each submodule redeclares `<java.version>`/`<maven.compiler.source>`/`<maven.compiler.target>` in its own `<properties>`, `UpgradeJavaVersion` bumps only the root pom, the submodules' local props win, so they still compile at 21 and the build succeeds while the effective target stays < 25 (fails the target gate even though nothing errors). A green build does not prove the bump. fix: force every module's properties to 25 via `org.openrewrite.maven.ChangePropertyValue`, one entry each for `java.version`, `maven.compiler.source`, `maven.compiler.target` (newValue: "25"); these update all modules in one run. Verify each module's bytecode is at 25, not just that the build passed.
- **`Cannot find a Java installation … matching {languageVersion=N}`** (foojay resolver timeout): point Gradle at the installed JDKs `-Porg.gradle.java.installations.paths=<jdk21>,<jdk25>` and drop `vendor`/`implementation` pins from `toolchain{}`.
- **`Entry <path> is a duplicate but no duplicate handling strategy has been set`** (Gradle 7+ hard error): `tasks.withType(Copy).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }`.
- **`jctools … sun.misc.Unsafe`** failing (not just warning) on 25: use the Unsafe-free path the lib already ships, `org.jctools.queues.atomic.*` instead of `org.jctools.queues.*` (a newer jctools often does not fix it). To merely silence the warning on the test fork: `--sun-misc-unsafe-memory-access=allow`.
- **A removed/changed JDK API in the project's own source:** hand-edit minimally.

- **`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)
- **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, don't chase it as a migration error.
- **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 21 must still pass.
- OOM during tests is usually downstream of an earlier real error. Fix the first error first.
- 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 JDK-25 path. Bail (its proxy class-injection is broken on 25 and `EasyMockToMockito` is lossy).
- Hibernate/Quarkus proxy-gen ByteBuddy at build time may need a framework bump or `-Dnet.bytebuddy.experimental=true`.
- A **semantic JDK change** in the project's own code (confirm with a pristine-checkout probe).
- An abandoned dependency with no JDK-25 release, or a needed version not present in the repository.
- 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 21 -> `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 25 (for example `of(21)`, `VERSION_21`, a `"21"` string, `release 21`) must be bumped to 25, 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 69: `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 `45` (=69). 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.
