---
name: jackson3-rewrite
description: >
  Runs the OpenRewrite recipes that handle the mechanical Jackson 2→3 and
  Spring Boot 4 migration changes (package renames, class renames, exception
  renames, etc.) plus the local gap-filler recipes built into this repo. Use
  this skill when the user asks to "run the openrewrite passes", "execute the
  openrewrite recipes", "do the rewrite migration", "apply the openrewrite
  upgrade", "run rewrite-jackson", "run rewrite-spring", or just "run the
  recipes" in the context of a Jackson/Spring Boot upgrade. Trigger this
  skill whenever the user wants the bulk-mechanical phase of the migration
  done — the part that comes BEFORE applying the residue from
  JACKSON3_FIXES.json. This is one of three companion skills:
  `jackson3-migration` analyzes, this one rewrites mechanically, and
  `jackson3-apply` finishes off whatever's left in JACKSON3_FIXES.json.
---

# Jackson 3 / Spring Boot 4 OpenRewrite Skill

You orchestrate the mechanical-rewrite phase of the migration. Two things
**must** be detected from the target project before any recipe runs, because
both choices are non-optional once known:

1. **Is this a Spring Boot project?** — picks which bulk recipe to run.
2. **Does it ship a build wrapper (`./mvnw` / `./gradlew`)?** — picks which
   command to invoke. Wrappers are *not* optional when present; they pin
   build-tool versions for reproducibility, which is exactly what you want
   during a migration.

Get both answers before printing a single command.

## Recipes available

| Recipe | Maven coordinates | Active recipe |
|---|---|---|
| Jackson 2 → 3 | `org.openrewrite.recipe:rewrite-jackson:LATEST` | `org.openrewrite.java.jackson.UpgradeJackson_2_3` |
| Spring Boot 4 | `org.openrewrite.recipe:rewrite-spring:LATEST` | `org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0` |
| Spring Boot 3.5 (intermediate) | `org.openrewrite.recipe:rewrite-spring:LATEST` | `org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_5` |
| Gap-fillers (local) | `org.anonhym.jackson3:jackson3-rewrite-gap-fillers:0.1.0-SNAPSHOT` | `org.anonhym.jackson3.UpgradeJackson3GapFillers` |

**If the project is on Spring Boot 3.0–3.4, run `UpgradeSpringBoot_3_5` first**, ship the result to a stable state, then run `UpgradeSpringBoot_4_0`. Spring's official guidance is to step through 3.5 before 4.0 to separate intermediate breakage from Boot 4 breakage. See [SPRING_BOOT_4_REFERENCE.md](../../../SPRING_BOOT_4_REFERENCE.md) §5 for the full sequencing and version table.

## Workflow

### Keep tool output lean

Maven and Gradle invocations produce a lot of noise (download progress,
per-test logs, dependency resolution chatter). Pipe every `$MVN` /
`$GRADLE` invocation through `tail -N` or use `-q`/`--quiet` so only the
result lines come back. Concrete patterns used throughout this skill:

- `mvn install` / non-interactive runs → add `-q` (errors only) or
  `2>&1 | tail -20` for the summary
- `mvn test` → `2>&1 | grep -E 'Tests run|BUILD|FAIL' | tail -20`
- `mvn rewrite:dryRun` / `:run` → don't `Read` the full
  `target/rewrite/rewrite.patch`; use `wc -l`, `grep -c '^---'`, and
  `head -80` to summarize
- `mvn dependency:tree` → already piped through `grep` in this skill
- `mvn checkstyle:check` → `2>&1 | tail -40` (full report only on failure)

For surefire test failures, identify the failing class via
`grep -l 'FAILURE!' target/surefire-reports/*.txt`, then `Read` only those
specific report files — not the entire `surefire-reports/` directory.

The same principle applies to OpenRewrite patches and `git diff`: prefer
`--stat` summaries over full diffs, and `head`/`tail` over reading whole
artifacts the user never needs to see.

### Step 1 — Detect build tool, wrapper, and Spring Boot use

Run these checks *first*, before deciding anything else.

```bash
# Build tool
ls pom.xml build.gradle build.gradle.kts 2>/dev/null
# Wrapper presence (use the wrapper if EITHER exists; fall back to system tool only when neither is there)
ls mvnw mvnw.cmd gradlew gradlew.bat 2>/dev/null
# Spring Boot use (look in the build file, not just imports)
grep -l 'spring-boot\|org\.springframework\.boot' pom.xml build.gradle build.gradle.kts 2>/dev/null
```

Decisions from those outputs:

| Build file present | Use |
|---|---|
| `pom.xml` | Maven path |
| `build.gradle` or `build.gradle.kts` | Gradle path |
| Neither | Ask the user |

| Wrapper script present | Invocation |
|---|---|
| `./mvnw` | `./mvnw <args>` (Maven, Unix; use `mvnw.cmd` on Windows) |
| `./gradlew` | `./gradlew <args>` (Gradle, Unix; use `gradlew.bat` on Windows) |
| Neither | Fall back to `mvn` / `gradle` from PATH |

Set a shell variable so the rest of the workflow stays portable:

```bash
# Maven projects
[ -x ./mvnw ] && MVN=./mvnw || MVN=mvn

# Gradle projects
[ -x ./gradlew ] && GRADLE=./gradlew || GRADLE=gradle
```

Use `$MVN` / `$GRADLE` everywhere below — never hard-code `mvn` or `gradle`
once the wrapper detection has run.

### Step 2 — Pick the bulk recipe (this is a hard branch, not a preference)

The Spring Boot 4 recipe is *composed*: internally it runs the Jackson 2→3
recipe plus a long list of Spring-specific transformations (starter renames,
property file migration, autoconfigure package moves, etc.). Running both
the standalone Jackson recipe AND the Spring Boot 4 recipe is **redundant
and may produce duplicate or conflicting changes** — pick exactly one.

```
spring-boot detected in build file?
├─ YES  →  USE  org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0
│          DO NOT also run UpgradeJackson_2_3 — it's already inside.
└─ NO   →  USE  org.openrewrite.java.jackson.UpgradeJackson_2_3
           (libraries, plain Java apps, non-Spring Boot Spring projects)
```

There is no "use the Jackson recipe even though Spring Boot is present"
case. If Spring Boot is detected, the Spring Boot 4 recipe is the only
correct bulk recipe. If you find yourself reaching for `UpgradeJackson_2_3`
on a Spring Boot project, stop and re-check Step 1.

### Step 3 — Build the local gap-filler module (idempotent — always run it)

The gap-filler module lives inside this skill repo, not in the target
project. Build and install it from there. `-q` keeps the output to errors
only; on success Maven prints just the BUILD SUCCESS summary, which is
the only line the model needs:

```bash
mvn -B -q -f .claude/skills/jackson3-migration/openrewrite/pom.xml install
```

This installs the recipe artifact to whatever the **local Maven
repository** is for the user (default `~/.m2/repository`, but configurable
via `~/.m2/settings.xml` or `MAVEN_REPO_LOCAL`). Don't try to inspect that
path — Maven knows where to put the artifact and where to find it on the
next run.

The install is idempotent and finishes in a few seconds with deps already
cached. Always run it once at the start of the workflow rather than checking
"is it already there?" — that's faster than the check.

**JDK requirement:** OpenRewrite 8.41 needs **JDK 17 or 21** (24+ is not
supported by the parser at this version). If `java -version` reports
something else, the user needs to either install one (e.g. `brew install
openjdk@21` on macOS, `apt install openjdk-21-jdk` on Debian/Ubuntu, or via
SDKMAN) or set `JAVA_HOME` to point at one. Don't hardcode a path — the
right location varies by OS and install method.

### Step 4 — Dry run first (always)

Run the bulk recipe with `:dryRun`, then the gap-fillers with `:dryRun`.
Output goes to `target/rewrite/rewrite.patch` (some plugin versions write to
`target/site/rewrite/rewrite.patch` — check both).

**Don't `Read` the whole patch file** — on a real codebase it's 100s of KB
of diffs and reading it all into context is pure waste. Summarize with:

```bash
PATCH=target/rewrite/rewrite.patch
[ -f "$PATCH" ] || PATCH=target/site/rewrite/rewrite.patch
echo "Files affected: $(grep -c '^--- ' "$PATCH")"
echo "Total diff lines: $(wc -l < "$PATCH")"
head -80 "$PATCH"  # representative sample
```

Show the user that summary plus a few sample changes — that's enough to
decide whether to apply.

**Spring Boot project (most common):**

```bash
# Pass A — Spring Boot 4 (this includes Jackson 2→3 internally)
$MVN -U org.openrewrite.maven:rewrite-maven-plugin:dryRun \
  -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:LATEST \
  -Drewrite.activeRecipes=org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0

# Pass B — local gap-fillers (run AFTER pass A; they expect renamed classes)
$MVN -U org.openrewrite.maven:rewrite-maven-plugin:dryRun \
  -Drewrite.recipeArtifactCoordinates=org.anonhym.jackson3:jackson3-rewrite-gap-fillers:0.1.0-SNAPSHOT \
  -Drewrite.activeRecipes=org.anonhym.jackson3.UpgradeJackson3GapFillers
```

**Non-Spring or library project:**

```bash
# Pass A — Jackson 2 → 3
$MVN -U org.openrewrite.maven:rewrite-maven-plugin:dryRun \
  -Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-jackson:LATEST \
  -Drewrite.activeRecipes=org.openrewrite.java.jackson.UpgradeJackson_2_3

# Pass B — local gap-fillers
$MVN -U org.openrewrite.maven:rewrite-maven-plugin:dryRun \
  -Drewrite.recipeArtifactCoordinates=org.anonhym.jackson3:jackson3-rewrite-gap-fillers:0.1.0-SNAPSHOT \
  -Drewrite.activeRecipes=org.anonhym.jackson3.UpgradeJackson3GapFillers
```

### Step 5 — Confirm and apply

Once the user has reviewed the dry-run patch, re-run the same commands but
swap `:dryRun` for `:run`. **Always run pass A before pass B** — the
gap-fillers operate on classes/imports that the bulk recipe already renamed.
Running them out of order produces a nearly-empty diff because the gap-
fillers don't find what they're looking for.

Run pass A, wait for it to finish, then run pass B. Don't combine them into
one mvn invocation — debugging a failure is easier when the passes are
discrete, and the gap-fillers genuinely depend on pass A's output.

### Step 6 — Restore Jackson 2 defaults (compatibility shim)

Once the recipes have applied, the project is on Jackson 3 / Spring Boot 4
with their new defaults. Several of those defaults differ from Jackson 2 in
behavior-changing ways: dates serialize as ISO-8601 strings instead of
epoch timestamps, properties sort alphabetically instead of declaration
order, trailing tokens cause deserialization to throw, etc.

To separate **API-rename risk** (handled by the recipes) from **behavior-
drift risk** (the default changes), pin the runtime to Jackson 2-compatible
defaults via Spring properties for the duration of the migration window.
This lets the user validate the mechanical migration in isolation, then
flip each default back one at a time with proper validation.

Detect which config format the project uses, then add (or merge into) the
appropriate file:

```bash
ls src/main/resources/application.properties \
   src/main/resources/application.yml \
   src/main/resources/application.yaml 2>/dev/null
```

**For `application.properties`:**

```properties
# === Jackson 2 compatibility shim — added by jackson3-rewrite ============
# Pins behavior to Jackson 2 defaults during the migration window so tests
# only catch API/rename regressions, not default-flip drift. Remove these
# one at a time AFTER the migration is mechanically verified, validating
# each removal against the test suite.

# Dates: Jackson 2 wrote epoch timestamps; Jackson 3 default is ISO-8601 strings
spring.jackson.serialization.write-dates-as-timestamps=true

# Property ordering: Jackson 2 used declaration order; Jackson 3 sorts alphabetically
spring.jackson.mapper.sort-properties-alphabetically=false

# Trailing tokens: Jackson 2 was lenient; Jackson 3 throws on trailing content
spring.jackson.deserialization.fail-on-trailing-tokens=false

# Unknown properties: keep whatever the project relied on. Most Spring Boot
# apps had `false` (lenient); strict APIs may have had `true`. Set to match
# the pre-migration behavior — don't change it as part of the migration.
# spring.jackson.deserialization.fail-on-unknown-properties=false
# =========================================================================
```

**For `application.yml` / `application.yaml`:**

```yaml
# === Jackson 2 compatibility shim — added by jackson3-rewrite ===
# Pins behavior to Jackson 2 defaults during the migration window so
# tests only catch API/rename regressions, not default-flip drift.
# Remove these one at a time AFTER the migration is verified.
spring:
  jackson:
    serialization:
      write-dates-as-timestamps: true
    mapper:
      sort-properties-alphabetically: false
    deserialization:
      fail-on-trailing-tokens: false
      # fail-on-unknown-properties: false  # uncomment + set to match pre-migration value
```

If the project has `application-<profile>.{properties,yml}` files for
specific profiles (dev/test/prod), add the shim to the **base**
`application.{properties,yml}` so it applies everywhere by default. Profile-
specific overrides will still work.

**HUMAN_DECISION items in `JACKSON3_FIXES.json` defer to this shim.** When
the apply skill surfaces "Changed Default" items for the user's review,
they can be answered with "leave the default; the compat shim covers it
for now" — and revisited once the shim is being removed.

After writing the shim, recommend the user re-run their tests:

```bash
$MVN test
```

If the test-gen skill's snapshot fixtures were captured BEFORE the
migration, they should match what Jackson 3 produces with the shim active.
Any snapshot failure now is a real API-rename regression, not a default
flip.

### Step 7 — Post-migration dependency audit

Two checks against the resolved dependency tree catch silent reach-through
issues the recipes can't handle on their own. Run both before declaring
the rewrite phase complete.

**a) Transitive Jackson 2 reach-through.** A library somewhere in the tree
may still pull `com.fasterxml.jackson.*` classes — usually because that
library hasn't released a Jackson 3-compatible version yet. The compile
will succeed (Jackson 2 jars are still resolvable as deps), but at runtime
that library uses different `ObjectMapper` types than the migrated code.
Common offenders: older `springdoc-openapi`, some Kafka client wrappers,
the Elasticsearch high-level REST client, certain logging encoders.

```bash
$MVN dependency:tree | grep -E 'com\.fasterxml\.jackson|tools\.jackson'
```

After the migration, only `tools.jackson.*` lines should appear. Any
`com.fasterxml.jackson.*` line is a transitive reach-through; surface the
*owning* dependency (the parent in the tree), find a Jackson 3-compatible
version (or wait for one), or pin an exclusion.

**b) Servlet vs reactive starter conflict.** Spring Boot 4 splits
`spring-boot-starter-web` into separate `webmvc` and `webflux` starters.
If a transitive dep brings the other half, both auto-configurations
activate and startup is non-deterministic.

```bash
$MVN dependency:tree | grep -E 'spring-boot-starter-(webmvc|webflux|web)\b'
```

Expected: exactly one of `webmvc` or `webflux`. The legacy
`spring-boot-starter-web` line should be gone after the rewrite (the
recipe renames it). If you see both `webmvc` and `webflux`, find which
dependency pulls the unintended one and exclude it.

For Gradle, swap `$MVN dependency:tree` for `$GRADLE :dependencies
--configuration runtimeClasspath` and apply the same `grep`.

Print a one-line conclusion for each:
- `Jackson 2 reach-through: clean ✓`  /  `Jackson 2 reach-through: <N> deps still on com.fasterxml.jackson — investigate <list>`
- `Servlet stack: webmvc ✓`  /  `Both webmvc and webflux present — investigate <dep>`

**c) Checkstyle (only if the project has a `checkstyle.xml`).** OpenRewrite's
output is functionally correct but its formatting may not match the
project's house style — quote choice, line wrapping, blank-line policy,
import ordering all vary. If the project ships a `checkstyle.xml` and has
the Checkstyle plugin configured (the user confirms this is a precondition
of running this skill on a real project), run it before declaring the
migration complete. **Treat any failure here as a blocker.**

```bash
# Detect a Checkstyle config anywhere within four levels of the repo root
if find . -maxdepth 4 -name "checkstyle.xml" -print -quit 2>/dev/null | grep -q .; then
  # tail keeps the tail summary visible; on failure that's where the violation
  # count lands. Read full reports from target/checkstyle-result.xml only if
  # there's a failure to investigate.
  $MVN -B checkstyle:check 2>&1 | tail -40
  # Gradle equivalent: $GRADLE checkstyleMain checkstyleTest 2>&1 | tail -40
fi
```

If `checkstyle:check` reports violations:
- They are almost always in files the recipes touched (the analyzer's
  output, the gap-fillers' added annotations/constructors, or the apply
  agent's edits).
- Don't try to suppress them via `<suppressions>`; fix the formatting in
  place. The user's existing code presumably passes the same rules, so the
  migrated code should too.
- If the user uses `mvn formatter:format` or Spotless, run that first
  (`$MVN formatter:format` / `$MVN spotless:apply` / `$GRADLE spotlessApply`)
  before re-running `checkstyle:check`. Many violations resolve mechanically.

If no `checkstyle.xml` is present, skip silently — not every project
enforces a style. Don't introduce one.

**d) Application property file audit (legacy `mongodb.uri`).** The
official OpenRewrite Spring Boot 4 recipe migrates *standard* Spring
property keys (`spring.data.mongodb.uri`, `spring.datasource.url`, etc.)
but doesn't touch non-standard or in-house keys. A common gotcha: a
project that wired MongoDB through a top-level `mongodb.uri` (instead of
the canonical `spring.data.mongodb.uri`) still has the legacy key after
the rewrite, and Spring Boot 4's autoconfigure won't read it. The app
starts but never connects to Mongo.

Grep all `application*.{properties,yml,yaml}` files for the legacy key
and flag any matches:

```bash
# Properties form: bare `mongodb.uri=...`
find . -name 'application*.properties' -not -path '*/target/*' -not -path '*/build/*' \
  | xargs grep -nH '^mongodb\.uri\s*=' 2>/dev/null

# YAML form: top-level `mongodb:` with a `uri:` child (not under spring.data.mongodb)
find . \( -name 'application*.yml' -o -name 'application*.yaml' \) \
       -not -path '*/target/*' -not -path '*/build/*' \
  | xargs grep -nH -B0 -A1 '^mongodb:' 2>/dev/null
```

For each match: rewrite the property to `spring.data.mongodb.uri` (or the
appropriate `spring.data.mongodb.*` subkey) and re-run startup smoke
tests. If a match has been deliberate (e.g., a custom
`@ConfigurationProperties("mongodb")` bean), surface it and ask the user
before changing — it's a real config decision, not a rote rename.

Apply the same audit to other commonly-misnamed config keys if the
project uses them: `redis.url` (canonical: `spring.data.redis.url`),
`kafka.bootstrap-servers` (canonical:
`spring.kafka.bootstrap-servers`), `datasource.url` (canonical:
`spring.datasource.url`). Treat any non-`spring.*` key that names a
Spring-managed resource as suspect.

Print a one-line conclusion:
- `Property keys: clean ✓` / `Property keys: <N> non-canonical references found in <files> — investigate`

**e) GitNexus impact analysis (only if GitNexus tools are available).**
The recipes touched a finite set of classes — package renames, the
`Jackson2ObjectMapperBuilder` → `JacksonObjectMapperBuilder` rename,
the new `@Jacksonized` / no-arg ctor / `@JsonProperty` annotations.
GitNexus's knowledge graph can show the **transitive blast radius**
of those changes: who depends on the renamed class, who calls the
modified method, who used the old type as a generic parameter
somewhere.

Detect availability by checking for `mcp__gitnexus__*` tools. If
present, delegate the impact query to a Haiku subagent (it's
mechanical):

```
Agent({
  model: "haiku",
  description: "Compute migration blast radius via GitNexus impact graph",
  prompt: """
    Working directory: <path>. The Jackson 2→3 / Spring Boot 4 OpenRewrite
    recipes just ran and touched the following classes:
    <list from `git diff --name-only`>

    For each touched class, run `mcp__gitnexus__impact` to find its
    transitive callers. Also run `mcp__gitnexus__impact` on the
    canonical removed APIs that the recipes can't always reach (e.g.,
    code calling `mapper.canDeserialize`, classes that previously
    extended `Jackson2ObjectMapperBuilder` directly).

    Return ONLY a JSON document:

    {
      "rewriteCoverage": {"touchedClasses": <int>, "totalDependents": <int>},
      "outsideRewriteScope": [
        {"caller": "com.example.X", "callee": "<removed API>", "file": "..."},
        ...
      ]
    }

    "outsideRewriteScope" is the actionable list — code that depends on
    something the recipes changed or removed but wasn't itself touched
    (because the dependency was via reflection, generics, or a string
    literal).
  """
})
```

If the report has any entries in `outsideRewriteScope`, surface them as
items requiring manual review — they're the silent runtime breakage that
the apply skill's JACKSON3_FIXES.json doesn't catch (because the
analyzer's grep didn't reach them either).

If GitNexus isn't available, skip this audit and rely on the
test-suite (Phase 5) + the `mvn dependency:tree` grep audits in 7a/7b
to surface gaps. Skip silently — don't pretend to do the impact analysis.

### Step 8 — Report what changed

After both passes apply and the compat shim is in place:

```bash
git status --short
git diff --stat
```

Summarize the diff for the user (files touched, line counts) and call out
which file got the compat shim (`application.properties` /
`application.yml`). Then point at the natural next step:

> Run `jackson3-apply` (or "apply the JACKSON3_FIXES.json") to handle the
> remaining items the OpenRewrite recipes didn't cover. When that prompt
> reaches the apply skill, answer "yes" to its OpenRewrite question — that
> tells it to skip entries flagged `automated_by_openrewrite: true`.
>
> Once the migration is mechanically clean and the test suite passes,
> revisit the Jackson 2 compat shim block and remove the entries one at a
> time. Each removal flips one Jackson 3 default to its new behavior; run
> the tests after each to validate the change in isolation.

## Gradle invocation

The recipe IDs are identical; only the build tool wrapper differs. The same
hard branch from Step 2 applies: Spring Boot project ⇒ `UpgradeSpringBoot_4_0`,
otherwise ⇒ `UpgradeJackson_2_3`.

Add the OpenRewrite plugin to `build.gradle` (Spring Boot example):

```groovy
plugins {
    id 'org.openrewrite.rewrite' version '6.x.x'
}

rewrite {
    activeRecipe 'org.openrewrite.java.spring.boot4.UpgradeSpringBoot_4_0'
    activeRecipe 'org.anonhym.jackson3.UpgradeJackson3GapFillers'
}

dependencies {
    rewrite 'org.openrewrite.recipe:rewrite-spring:LATEST'
    rewrite 'org.anonhym.jackson3:jackson3-rewrite-gap-fillers:0.1.0-SNAPSHOT'
}
```

For non-Spring projects, swap `UpgradeSpringBoot_4_0` for
`UpgradeJackson_2_3` and `rewrite-spring` for `rewrite-jackson`.

Then run, using the wrapper if present:

```bash
$GRADLE rewriteDryRunNoFork
$GRADLE rewriteRunNoFork
```

## Tips

- **Don't combine all recipes into one invocation.** OpenRewrite supports
  `-Drewrite.activeRecipes=A,B`, but a failure in one recipe leaves you
  guessing which. Sequential passes are debuggable.
- **The gap-fillers depend on the bulk recipe having run.** They look for
  `tools.jackson.*` types and `ValueDeserializer` etc. — names that don't
  exist until pass A renames them. Out-of-order runs produce nearly-empty
  diffs and a confusing experience.
- **Pin recipe versions for reproducibility.** `LATEST` is fine for
  exploratory runs; for CI, replace it with a specific version
  (`rewrite-jackson:1.7.0`, etc.) so a future recipe update doesn't silently
  alter your output.
- **OpenRewrite needs a clean compile to reason about types.** If `$MVN
  compile` fails on the project, fix the compile errors first; recipes that
  rely on type information (most of them) will produce reduced or wrong
  output otherwise.
- **Don't run twice on the same code.** OpenRewrite is idempotent for most
  recipes, but the gap-fillers' annotation-add recipes can produce duplicate
  annotations on borderline cases. After applying, the right next step is
  the apply agent, not another OpenRewrite pass.

## Troubleshooting

- **"Could not find artifact org.anonhym.jackson3:..."** — the gap-filler
  install in Step 3 didn't run or failed. Re-run it and watch the output.
- **"ClassNotFoundException: com.sun.tools.javac.code.Type$UnknownType"** —
  the active JDK is 24+ and OpenRewrite 8.41's parser can't handle it. Run
  with JDK 17 or 21. Find one with `/usr/libexec/java_home -V` (macOS) or
  `update-java-alternatives -l` (Debian); set `JAVA_HOME` to it for the
  duration of the migration.
- **Recipe runs but produces no diff** — usually means pass A hasn't run
  yet, or the project doesn't actually use the patterns the recipe targets.
  Inspect `target/rewrite/rewrite.patch` (often empty) and verify a few of
  the project's imports actually use Jackson.
- **Dry-run patch is enormous** — normal for a real codebase upgrading from
  Jackson 2 to 3; package renames touch every Jackson import. Skim it rather
  than reading line by line.
- **`mvn: command not found` despite a `pom.xml` being present** — the
  project likely ships only the wrapper. Use `./mvnw` instead of `mvn`. The
  Step 1 detection should set `$MVN` for you; if you're seeing this error,
  the wrapper detection was skipped.
