Google's migration research and the Airbnb/Uber migration writeups converge on the same architecture: deterministic transforms (codemods, OpenRewrite recipes, ts-migrate) handle the mechanical majority, an LLM handles the ambiguous residue, and a compile/test loop is the only judge of done.
Tier: Advanced · Workflow area: Refactoring & Migration
This playbook runs that hybrid for both cases — a breaking-change library upgrade driven by its changelog, and a language/typing migration slice (JS→TS, Java→Kotlin) — because they are the same playbook at different scales.
Not for: upgrades with no breaking changes (just bump it) or rewrites where behavior is supposed to change (that is a feature project, not a migration).
jscodeshift/ts-migrate (JS/TS), pyupgrade/Bowler (Python), plus any vendor-provided upgrade codemods.Changelog → migration matrix. Feed the actual upgrade guide plus your repo's usage inventory:
grep -rn "from sqlalchemy" --include="*.py" src/ | cut -d: -f3 | sort | uniq -c | sort -rn > /tmp/usage.txt
Here is the official 1.4→2.0 migration guide [pasted] and our usage
inventory [pasted]. Build a migration matrix: for each breaking change,
(a) does it affect us — cite usage lines, (b) is the fix MECHANICAL
(same rewrite everywhere) or JUDGMENT (depends on local semantics),
(c) estimated blast radius in files. Mark changes that don't affect us
as N/A with evidence. Flag anything in the guide you're uncertain about.
The MECHANICAL/JUDGMENT split is the whole strategy: it decides what gets a codemod versus LLM attention.
Run deterministic transforms for every MECHANICAL row. Prefer existing recipes (OpenRewrite's Spring Boot recipes, ts-migrate, vendor codemods); have the LLM write a codemod where none exists:
Write a jscodeshift codemod that rewrites `moment(x).format('YYYY-MM-DD')`
to `formatISODate(x)` importing from '@/lib/dates', preserving all other
moment usage. Include 5 test fixtures: 3 that must transform, 2 that must
NOT (moment with other formats, moment in comments).
Codemods are reviewable once and correct everywhere — an LLM editing 300 call sites freehand is 300 chances to hallucinate. Commit each codemod's output as its own commit for reviewability.
Let the agent grind the compile/test loop on the residue. Now hand over the JUDGMENT rows:
Branch: upgrade/sqlalchemy-2. Codemods have handled [list]. Remaining
breaking changes: [JUDGMENT rows from the matrix]. Work file by file:
fix, then run `pytest -x --ff`; do not proceed past a failure. Rules:
- Consult the migration guide (MIGRATION.md in repo root) before inventing
an API — do not use methods not present in the guide or the installed
package.
- If a fix requires changing test assertions or behavior, STOP and list it
for me instead of changing it.
- Keep a log: file, change made, guide section relied on.
The stop-rule is critical: an agent that "fixes" a failing test by editing the assertion has converted your upgrade into an unreviewed behavior change.
Review the residue like a hostile PR, sample the mechanical. Review every JUDGMENT-row diff fully (this is where semantics live — e.g., SQLAlchemy 2.0's autocommit removal changing transaction boundaries, or a TS migration choosing any versus a real type). For codemod output, review the codemod plus a random 10-file sample per commit. For TS/Kotlin slices specifically: reject any/!! beyond an agreed budget — count them:
grep -rn ": any\|as any" --include="*.ts" src/migrated-slice/ | wc -l
Verify beyond the unit suite, then ship in slices. Run integration tests, boot the app, and hit the golden paths — upgrade breakage loves runtime config and startup (deprecation warnings promoted to errors, changed defaults). Diff dependency trees (pip freeze | diff, mvn dependency:tree) for surprise transitive bumps. For migrations, merge slice-by-slice behind green CI rather than one mega-branch — Airbnb's TS migration ran file-set by file-set for months; mega-branches rot.
The agent's most dangerous move in an upgrade is inventing a plausible replacement API that does not exist in the new version. Pinning it to the in-repo migration guide and the installed package — and making CI the judge — is what keeps the loop honest.
ts-migrate + jscodeshift play the same role for TS.git diff main...HEAD -- '**/test*' | grep '^-.*assert' reviewed line by line).any/!! count within budget; new-language idioms verified on the sampled files.