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

# Bump Java 11 → 17

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

## Tools: standard only (JDKs 11 and 17, 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 11 with the Java-17 recipe artifacts**:
  - Maven: `JAVA_HOME=<jdk11> 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 take minutes; let them finish. An apply that was cut off means the recipe was not applied (tests may still pass at the old Java level, but the bytecode-target check fails). 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 17)
- Prefer the off-the-shelf transforms: the unparametrized meta-recipes + setting the Java version to 17 + (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 17 is a free hop-fixed intent (no penalty), and the combined gate credits the bump only once the effective bytecode target actually reaches 17. `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(<17)` to `of(17)`, plus any `sourceCompatibility`/`targetCompatibility`/`options.release`/`JavaVersion.VERSION_*` below 17, 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>17</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; reach for a hand edit or a project-specific recipe only when a real wall demands it.
- Never touch or weaken test code (see forbidden).

## Proactive step: Lombok floor (run before the first JDK-17 build; gated on a declared dependency, not an error)
If the build resolves Lombok, floor it to **1.18.30** as part of the start-here recipe, before the first JDK-17 build. Never wait for the error. 1.18.22 is the lowest release that compiles under JDK 17, but 1.18.30 is the floor the 17->21 hop needs, it is never worse at 17, and one uniform number removes a whole class of mistake. An out-of-date Lombok does not fail with a version string that names it: under JDK 17 it 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), and a fail-fast reactor aborts on that during an early module before the run ever reaches the reactive back-stop row. The structural trigger (project declares Lombok) is unambiguous, which is exactly why this is proactive. 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.) Counts as a **free hop-fixed intent**. The reactive Troubleshooting row below stays as a back-stop. (JDK 17 does not need `maven.compiler.proc=full`, that is a JDK-23+ requirement, see the 21->25 skill.)

## Proactive step: Gradle wrapper floor (run before the first JDK-17 build; gated on a structural signal, 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 7.3** (the JDK-17 floor), bump `distributionUrl` to **`gradle-7.6-bin.zip`** (the pinned value) *before* applying the recipe / building under JDK 17. Never wait for the error. Gradle itself runs on the build JDK, and Gradle **< 7.3 cannot run on JDK 17**: it dies during *configuration* with `Unsupported class file major version 61` while compiling `settings.gradle`/`build.gradle`, before any project code is touched. That v61 text is the same signature JaCoCo and old Spring/ASM emit, so reactive error-matching cannot reliably attribute it to the wrapper, but the **structural trigger (wrapper version < 7.3) is unambiguous**, which is exactly why this is proactive (same test as the Lombok proactive step). Apply via `org.openrewrite.gradle.UpdateGradleWrapper` {version: "7.6"} or by editing `distributionUrl` directly (never a `file://` path). Counts as a **free hop-fixed intent**, like setting the target. The reactive Troubleshooting row below stays as a back-stop.


## Proactive step: env-mutating test lib needs `--add-opens` (run before the first JDK-17 test; gated on a dependency, not an error)
If a test dependency mutates the **process environment** by deep reflection, JDK 17 strong encapsulation blocks it at runtime and every test using it fails with `InaccessibleObjectException` (the test class fails as `initializationError`, so the loss is large and silent at compile time). This is a reliable structural trigger, not a guess: it fires whenever the project depends on **`org.junit-pioneer:junit-pioneer`** (`@SetEnvironmentVariable`/`@ClearEnvironmentVariable`, whose `EnvironmentVariableUtils` reflects into `java.lang.ProcessEnvironment`), **`com.github.stefanbirkner:system-lambda`** or **`...:system-rules`** (`withEnvironmentVariable`/`EnvironmentVariables`), or a project util that reflects into `ProcessEnvironment` / `Collections$UnmodifiableMap`. When you see any of these in the test classpath, add the env-map opens to **every** test fork (prefer root `subprojects {}`, see below), before the first JDK-17 test run. Never wait for the error. The env map lives in `java.lang.ProcessEnvironment` + `java.util.Collections$UnmodifiableMap`, so the minimal set is exactly **`--add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang=ALL-UNNAMED`**. Gradle: preferably in root `subprojects { tasks.withType<Test>().configureEach { jvmArgs("--add-opens=java.base/java.util=ALL-UNNAMED", "--add-opens=java.base/java.lang=ALL-UNNAMED") } }` to cover all modules, a per-module placement under-covers: env-var tests in sibling modules stay broken (proven: jwuang/my-robocode loses 2 of 47 with a `bot-api/java`-only placement, 0 with root `subprojects`). Maven: append the two tokens to `maven-surefire-plugin` `<argLine>` (preserve any existing `@{argLine}`). This whole arg block = one edit; it counts like the wrapper floor. The reactive row below stays as a back-stop for opens the error names that this proactive set does not cover.

## Proactive step: Gradle target pin the recipe leaves at 11 (verify the bump actually landed; gated on a structural signal, not an error)
On **Gradle** projects the `UpgradeJavaVersion` recipe frequently does not touch the build file's target pin, a `java { toolchain { languageVersion = JavaLanguageVersion.of(11) } }` block (Groovy or Kotlin DSL), a `tasks.compileJava { options.release = 11 }` / `options.release.set(11)`, a `sourceCompatibility`/`targetCompatibility = JavaVersion.VERSION_11`, or, on a **Kotlin** project, a `kotlin { jvmToolchain { languageVersion.set(JavaLanguageVersion.of(11)) } }` plus a `kotlinOptions.jvmTarget = "11"`. The build then **compiles cleanly but to the old bytecode** (effective target stays 11), so there is no error to react to. It silently scores `FAIL_target_not_bumped`. So after applying the recipe, **grep every build file** (`build.gradle`, `build.gradle.kts`) for `JavaLanguageVersion.of(`, `JavaVersion.VERSION_`, `sourceCompatibility`/`targetCompatibility`, `options.release`, and (Kotlin) `jvmTarget`: if any still names a version **< 17**, hand-edit it to 17 (`JavaLanguageVersion.of(17)`, `JavaVersion.VERSION_17`, `options.release = 17`, `jvmTarget = "17"`) *before* the JDK-17 build, and bump **every** pin you find, a project can have two (e.g. a `toolchain` block and an `options.release`, or a `jvmToolchain` block and a `kotlinOptions.jvmTarget`) and missing one leaves the bytecode at 11. This is proactive precisely because the failure is silent, the structural trigger (a target pin left `< 17` in a build file) is unambiguous. For a **Kotlin** project the Java toolchain alone is usually enough (the Kotlin plugin derives `jvmTarget` from it), but if the build sets an explicit `kotlinOptions.jvmTarget`/`jvmTarget` it overrides the toolchain. Bump that too. On **Maven + Kotlin** the same silent trap lives in the `kotlin-maven-plugin`: its `<configuration><jvmTarget>` (or the `kotlin.compiler.jvmTarget` property) sets the Kotlin bytecode target independently of `<release>`/`<maven.compiler.release>`, so a Kotlin module left at `<jvmTarget>11` compiles to major 55 and drags the effective target (the global min across every module's `target/classes`) back to 11 even though the Java side bumped. So grep every `pom.xml` for `<jvmTarget>` and `kotlin.compiler.jvmTarget` as well, and bump each to `17`. Counts as a **free hop-fixed intent**, like setting the target. (Proven: viktoriia-sokolenko/exploding-kittens `of(11)` + `options.release = 11`, masson-rafael/R4.02_TestsPokeBagarre `of(11)`, dbottillo/NotionAssistantIntegration `of(11)` + `jvmTarget = "11"`, all three FAIL_target_not_bumped `target 11` with 0 edits, all three -> VERDICT PASS `target 17` reward 1.0 after hand-bumping the pins.)

## Proactive step: Spring Boot line (run BEFORE the first JDK-17 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-17 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-17 build. Never wait for the error. Measured on this hop: the lowest JaCoCo that actually instruments class-file major 61 is 0.8.7, anything below writes no execution data, and 0.8.15 works on every hop, so 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 61 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 `UpgradePluginsForJava17`, 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.UpgradePluginsForJava17
  - org.openrewrite.java.migrate.UpgradeBuildToJava17
  - org.openrewrite.java.migrate.UpgradeJavaVersion:
      version: 17
  # 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 17. If it compiles, test under JDK 17. 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 11 scores `FAIL_target_not_bumped` and earns nothing.

## Reflect loop: if compile or test under 17 fails, read the error, fix the first wall, re-run (no iteration limit)
JDK-17 class-file version is **61**: a tool that reads bytecode via ASM must be new enough for v61.
- **Lombok breaks javac:** `NoSuchFieldError: JCTree$JCImport.qualid` / `Could not initialize class lombok.javac.Javac`. 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. Floor Lombok to **1.18.30** via `org.openrewrite.java.dependencies.UpgradeDependencyVersion` {org.projectlombok, lombok, 1.18.30} (and its annotationProcessor).
- **Test fork strong-encapsulation:** `InaccessibleObjectException` / `module {A} does not "opens {pkg}" to unnamed module` (deep reflection via setAccessible). Hand-edit the test fork args (Maven `maven-surefire-plugin` `<argLine>`, Gradle `tasks.test { jvmArgs(...) }`) adding the opens the error names, one token each joined with `=`, e.g. `--add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED` (also commonly java.lang.reflect, java.text, java.io, java.nio, java.time, sun.nio.ch). **Read the cause chain for the exact package:** an env-var test (junit-pioneer `EnvironmentVariableUtils` / system-lambda `withEnvironmentVariable`, error reads `InaccessibleObjectException at AccessibleObject.java:` wrapped in `ExtensionConfigurationException at EnvironmentVariableUtils.java`) is the process-env map → the fix is exactly `--add-opens=java.base/java.util=ALL-UNNAMED` + `--add-opens=java.base/java.lang=ALL-UNNAMED` (env lives in `java.lang.ProcessEnvironment` + `java.util.Collections$UnmodifiableMap`); the proactive step above should already have added these. Preserve any existing argLine (e.g. JaCoCo's `@{argLine}`). This whole arg block = one edit.
- **Spring component-scan fails:** `Unsupported class file major version 61`, or a bare `IllegalArgumentException at ClassReader` wrapped in `BeanDefinitionStoreException` → `SimpleMetadataReader` (no "major version" text). Spring < 5.3 (SB 2.0-2.4) bundles an ASM that can't read v61 → add `org.openrewrite.java.spring.boot2.UpgradeSpringBoot_2_7`. Do not hand-pick an intermediate < SB 2.5. It fails with the identical error and looks like "the bump didn't help".
- **Mockito** `cannot mock`/`Cannot define class using reflection`/`sun.misc.Unsafe.defineClass`: its shaded ByteBuddy is too old. Preferred fix: force `net.bytebuddy:byte-buddy(:agent)` to **1.14.12** (a plain bump is often overridden by the Spring BOM, so force it via a `<byte-buddy.version>` property or a Gradle `resolutionStrategy`). Raise Mockito itself only if that is not enough — and do not go to 5.x if any test imports `org.mockito.internal.*` (e.g. `FieldSetter`, which Mockito 5 removed): those tests stop compiling. Stay on the highest 3.x/4.x that keeps the symbol (Mockito **3.3.3** still ships `FieldSetter` and runs on JDK 17). UpgradeDependencyVersion.
- **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.
- **Pure-Groovy Gradle project pinned to Groovy 2.5** (`id 'groovy'`, `.groovy` main sources, often a Hubitat/`hubitat_ci` app): Groovy 2.5 cannot run on JDK 17, `:compileGroovy` dies with `NoClassDefFoundError: Could not initialize class org.codehaus.groovy.vmplugin.v7.Java7` (its vmplugin tops out at v7/v9). Reaching Java-17 bytecode requires Groovy **≥ 3.0.9** + Spock **2.x-groovy-3.0** (`useJUnitPlatform()`). Two gotchas after bumping: (1) Groovy 3 split the fat `groovy-all` jar. Switch to modular `org.codehaus.groovy:groovy:3.0.x` and add every non-core module the sources import (`groovy-json` for `JsonSlurper`/`JsonOutput`, `groovy-xml`, etc.), else `unable to resolve class groovy.json.JsonSlurper`; add a `configurations.all { resolutionStrategy.eachDependency { if (it.requested.group=='org.codehaus.groovy') it.useVersion '3.0.x' } }` to evict transitive 2.5. (2) On JDK 17 the test harness reflects into JDK internals → `IllegalAccessError ... sun.util.calendar.ZoneInfo`, fixed by the canonical test-task `--add-opens` set. But if a test dependency is itself a Groovy-2.5-only library with no Groovy-3 release (e.g. **`me.biocomp.hubitat_ci:0.17`**), Groovy 3 breaks it at runtime with `NoSuchMethodError: org.codehaus.groovy.runtime.DefaultGroovyMethods.plus(String,String)` (a removed-in-3.0 ABI) on every test that compiles a script through it, and there is no target-JDK that satisfies both. That is the **abandoned-dependency bail** below: you cannot get `target 17` and conserve the tests; report it honestly, do not force. (Proven on ljbotero/hubitat-flair-vents 11→17: clean clone bytecode is major 55; agent removing the `compileGroovy{ targetCompatibility }` blocks let Groovy 2.5 default-emit Java-7 major-51, the original `FAIL_target_not_bumped target 7`; the real bump compiles to `target 17` but loses 106/108 tests to hubitat_ci's Groovy-3 ABI break.)
- **Spock/Groovy test sources on JDK 17** (main is plain Java/Kotlin, but `src/test` is `.groovy` Spock specs, `id 'groovy'` only pulls in test specs): the `:compileTestGroovy` (or `:compileGroovy`) task dies with `NoClassDefFoundError: Could not initialize class org.codehaus.groovy.vmplugin.v7.Java7` / `Could not initialize class org.codehaus.groovy.reflection.ReflectionCache` / a bare `GroovyBugError`, the bundled **Groovy 2.x cannot run on JDK 17** (its vmplugin tops out at v7/v9). This is the test-only cousin of the pure-Groovy *main* row above (cross-ref it for the `groovy-all` split + the abandoned-dependency bail), here the *production* bytecode is already fine, so this is purely a test-compile wall. Fix = bump **Spock to `2.3-groovy-4.0`** (or `2.3-groovy-3.0`) **+ Groovy to `4.0.x` (`org.apache.groovy:groovy`)** or **`3.0.x` (`org.codehaus.groovy:groovy`)**: pick the pair that resolves from the mirror. Switch any fat `groovy-all` to the modular `groovy` artifact the specs import, and add **`test { useJUnitPlatform() }`** (Spock **2.x runs on the JUnit Platform**, not the old JUnit-4 runner, without it `:test` finds 0 specs). Spock 2.x also needs the **JaCoCo floor** (the agent attaches to the test JVM and throws `Unsupported class file major version 61` / `IllegalClassFormatException` while instrumenting JDK-17 CLDR classes, silently killing date/locale-touching specs. Bump `jacoco { toolVersion }` per the JaCoCo row). Final gotcha that masquerades as a test loss: Spock 1.x named unrolled iterations `feature[0]`, but Spock 2.x defaults to `feature [param: value, ..., #N]`, the embedded `#N` breaks the scorer's `class#method` split so every parametric iteration looks renamed -> `FAIL_test_conservation lost <K>` even though the build is green. Restore the 1.x naming with a classpath-root config file **`src/test/resources/SpockConfig.groovy`** containing `unroll { defaultPattern '#featureName[#iterationIndex]' }` (a config file, not a test source - it does not change the test set). Note: `--rerun-tasks` (now baked into the harness `jvmjob`) is what makes such a masked test-compile failure visible at all - without it every task goes `UP-TO-DATE` under JDK 17 and the specs never recompile/run, scoring a deceptive `post 0`. (Proven on qaware/cloud-cost-fitness 11->17: Spock `1.3-groovy-2.5` + `org.codehaus.groovy:groovy-all:2.5.4` + jacoco 0.8.6 -> after Spock `2.3-groovy-4.0` + `org.apache.groovy:groovy:4.0.21` + `useJUnitPlatform()` + jacoco 0.8.8 + the `SpockConfig.groovy` unroll pattern: VERDICT PASS `target 17` reward 1.0, all 47 tests conserved.)
- **google-java-format / fmt-maven-plugin / Spotless crash under JDK 16+:** at the format goal (`com.coveo:fmt-maven-plugin:*:format ... ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 0`, or `... format failed: An API incompatibility was encountered ... java.lang.IllegalAccessError`, or Spotless/`google-java-format` `IndexOutOfBounds`/`ClassCastException` naming `com.sun.tools.javac.*`). google-java-format reflects into `com.sun.tools.javac.*`, which JDK 16 strong-encapsulated. **A plugin/version floor alone does not fix it**: proven on ONSdigital/ssdc-rm-exception-manager that fmt-maven-plugin 2.13 (with google-java-format 1.13.0 and a forced override to 1.15.0) both still throw `IllegalAccessError: null` under JDK 17. The real fix is the javac add-exports/opens the formatter needs, given to the **Maven build JVM** (plugins run in it) via a `.mvn/jvm.config` file (Gradle: the equivalent `jvmArgs` on the format/check task). Write `.mvn/jvm.config` with: `--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED` (space- or newline-separated, one file, one edit). This is `.mvn/jvm.config`, not surefire `<argLine>`, the formatter runs at the `format` goal in the Maven JVM, not in a test fork. (Proven: ssdc-rm-exception-manager fmt:2.13:format `IllegalAccessError` FAIL_build_post post 0 lost 34 reward 0.0 -> with `.mvn/jvm.config` VERDICT PASS reward 1.0, 34 tests conserved.)
- **maven-plugin-plugin descriptor double-registers `help`** (only on a project that itself builds a Maven plugin, `<packaging>maven-plugin</packaging>`): `Error extracting plugin descriptor: 'Goal: help already exists in the plugin descriptor for prefix: <X>'` / `Existing implementation is: ...HelpMojo` / `Conflicting implementation is: ...HelpMojo` at `maven-plugin-plugin:<v>:descriptor (default-descriptor)`. The pom configures both the `helpmojo` goal (an explicit `<execution>` with `<goal>helpmojo</goal>`, which generates a `HelpMojo.java` for goal `help` into `target/generated-sources/plugin`) and the auto-bound `descriptor` goal, which under JDK 17 also scans that generated source and registers a second `help` goal → collision. Tell from the log: under JDK 11 the `java-annotations` extractor finds N descriptors and BUILD SUCCESS; under JDK 17 it finds **N+1** (the extra generated HelpMojo) and aborts. **Bumping maven-plugin-plugin does not fix it**: 3.7.0/3.8.2 emit the identical error; 3.9.0 only renames the generated package (`...autojdk_maven_plugin.HelpMojo`) but still collides on goal `help`. Fix = delete the redundant `helpmojo` execution from the pom (the `<execution>` whose `<goal>helpmojo</goal>`); the `descriptor` goal already supplies help. One hand edit, no plugin bump needed. (Proven on causalnet/autojdk-maven-plugin: FAIL_build_post → VERDICT PASS reward 1.0, 53 tests conserved.)
- **Embedded compiler** `target level should be in '1.1'...'N'` (AspectJ ajc / Eclipse ECJ doing the compiling): bump it (aspectjtools/aspectjrt/plugin ≥ 1.9.8 for 17), not the JDK flags.
- **Gradle wrapper below the JDK-17 floor (7.3):** bump via `org.openrewrite.gradle.UpdateGradleWrapper` {version: "7.6"} (the pinned value), or set gradle-wrapper.properties by hand. Two wrapper-too-old signatures: `Unsupported class file major version N` while Gradle configures (in `_BuildScript_`), and `Could not determine java version from '17.0.x'`. Never point distributionUrl at a `file://` path.
- **Gradle + Kotlin:** `Inconsistent JVM-target compatibility … compileJava (17) and compileKotlin (M)` → set `kotlin { jvmToolchain(17) }`, not just the Java toolchain.
- **`cannot find symbol: WebSecurityConfigurerAdapter`** etc. needing Spring Security 6 → that requires SB 3 (a 17→21-era jump); on the 11→17 hop, prefer staying on SB 2.7.
- **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` = you set the target in some modules but not others. Set the same target in every module (root `allprojects`/`subprojects`).
- **`Cannot find a Java installation … matching {languageVersion=N}`** (often a foojay resolver timeout, no network): point Gradle at the installed JDKs `-Porg.gradle.java.installations.paths=<jdk11>,<jdk17>` and drop any `vendor`/`implementation` pins from `toolchain{}`, `languageVersion` alone is enough.
- **`Entry <path> is a duplicate but no duplicate handling strategy has been set`** (Gradle 7 made this a hard error): `tasks.withType(Copy).configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }`.
- **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)
- **Verify the target landed:** a clean JDK-17 build is not proof of a real bump, Gradle `JavaLanguageVersion.of(N)` toolchains, `options.release`, `JavaVersion.VERSION_*`, Kotlin `jvmTarget`, and soft-pinned Maven `source`/`target`/`release` can leave bytecode at 11 with zero errors. After the build, confirm no build file still pins a version `< 17` (see the proactive target-pin step). If nothing forced a target edit, the recipe under-applied. Fix the version declaration(s) 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. They are not in your conserve-set. Don't try to fix them and don't count them as regressions; only tests that passed under 11 must still pass.
- OOM during tests is usually downstream of an earlier real error. Fix the first error; only raise the test `-Xmx` if it's genuinely heap.
- Stale generated classes (`ClassAlreadyExistsException`, jsonschema2pojo): `./gradlew clean` / `rm -rf target`, then re-run.
- Use `--add-opens` for deep reflection (`setAccessible`), `--add-exports` only for compile/link access to a non-exported API. Do not use `--illegal-access=permit`, JEP 403 removed it in JDK 17 (no-op/error). Canonical opens set if you can't tell which: `--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 instead of forcing)
- A **Spring-Boot-1.x app** whose custom code calls SB-2-removed APIs (won't compile on SB2).
- A **semantic JDK change** in the project's own code (its annotation processor crashes / `java.beans.Introspector`-derived behavior changed). Confirm with a pristine-checkout probe failing the same way under JDK 17.
- An abandoned dependency with no JDK-17 release.
- Native (CMake/JNI) modules that don't build in a plain JVM.

## 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 11 -> `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 17 (for example `of(11)`, `VERSION_11`, a `"11"` string, `release 11`) must be bumped to 17, 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 61: `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 `3d` (=61). 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.
