workflowMigrations at Scale10 min read
Codemod-First Migrations
A migration is not one hard problem. It is one easy problem four hundred times, and an agent is the worst tool you own for the four hundredth repetition. Stop asking it to perform the change. Ask it to write the change, then run that.
The shape every credible large-scale migration converged on: deterministic tooling finds the change sites and performs the mechanical edits, the model is spent only on the residue that needs judgement, and a per-file validation gate with a bounded retry loop decides what counts as done. Airbnb, Google and Island arrived at it separately, on completely different problems.
The Problem
Point a coding agent at "migrate our 900 components off the old button" and it will do a genuinely good job on the first twelve files. Then something drifts. File 40 gets a slightly different import order. File 90 gets a prop renamed one way and file 240 gets it renamed another, because by then the earlier files are no longer in the window. File 300 is quietly skipped and nobody notices until a designer opens the page.
None of that is a model failure. It is the wrong unit of work. You are paying a reasoning engine to re-derive an identical transformation four hundred times, at four hundred chances to derive it differently, and you are paying by the token for the privilege. Island tried exactly this across a design system of more than 12,000 tokens and reported the failure precisely: the model skipped files, mapped tokens incorrectly, and applied inconsistent logic across files, because the job did not fit the window.
Meanwhile the reason nobody wrote a codemod instead is that codemods were miserable to author. You had to know jscodeshift or the TypeScript compiler API, and by the time you had a working transform you could have hand-edited half the files. That was a real tradeoff, and it is the part that stopped being true. Writing an AST transform is exactly the kind of small, self-contained, verifiable program an agent is good at, and it is the one artifact in this workflow that gets more reliable the more times you run it.
So the migration splits in two. The mechanical half becomes a program. The model works only on what is left.
The Triage
Before anything runs, sort the work into three buckets. This sort is the whole methodology, and it is a human decision, not an agent one.
- Mechanical.A one-to-one, unambiguous rewrite. Renamed import, renamed prop, changed call signature, deprecated hex swapped for a token. This is a codemod. It is free after it is written, it is identical on file 1 and file 400, and its diff is reviewable as a rule rather than as four hundred changes.
- Patterned.Structurally repetitive but semantically variable. An Enzyme
shallow()test that has to become a React Testing Library test, or a class component that has to become a hook. A transform can carry the scaffolding, but each file needs a judgement call. This is the model's bucket, one file at a time, behind a gate. - Judgement.Genuinely one-off. The component with the custom animation, the one with a business rule buried in a
useEffect, the one three teams depend on. This is a human, and there are always fewer of these than the team fears. Naming them up front is what stops them from blocking the other two buckets.
The trap is treating the whole migration as bucket three because bucket three exists. Island's answer was a tier: a mechanical codemod for the unambiguous one-to-one primitive mappings, and an AI-driven migration matrix for the ambiguous, multi-intent tokens that a codemod cannot decide.
The Gate
For bucket two, the unit of work is one file, and the agent does not get to declare it finished. A validation gate does.
Airbnb modelled their Enzyme to React Testing Library migration as a state machine: a file advances to the next state only after the check on the current state passes. Refactor off Enzyme, then make Jest pass, then make lint and tsc pass, then mark complete. When a step fails, the errors go straight back to the model with the source file and it tries again, bounded by a maximum number of attempts. Not a conversation. A loop with an exit condition.
Two details from that run are worth stealing outright. First, they moved 75% of all files in about four hours at a ceiling of ten retries, then spent most of the remaining effort on the stubborn tail. Second, the thing that fixed the tail was not better prompting, it was more context: prompts grew to between 40,000 and 100,000 tokens, pulling in as many as 50 related files plus hand-written examples of good, passing tests from the same project. Overall they reported a 97% automated success rate across roughly 3,500 files, with the last 3% finished by hand using the generated code as the starting point, against an original manual estimate of 18 months.
Google's version names the other half explicitly. Their FSE 2025 industry paper pairs change location discovery with the LLM: static tooling determines where the change belongs, the model determines what it should be. Across 39 migrations run by three developers over twelve months, 595 changes and 93,574 edits were submitted, 74.45% of the changes and 69.46% of the edits generated by the model, with developers estimating the total time roughly halved against comparable manual migrations. In their earlier report, the Ads 32-bit to 64-bit ID migration had 80% of the code changes fully AI-authored, and a JUnit3 to JUnit4 migration converted 5,359 files and over 149,000 lines in three months with about 87% of generated code committed unmodified.
Different companies, different problems, same architecture: deterministic where the work is deterministic, model where it is not, and a machine check between every step.
Getting It Running
The engines are small and boring, which is the point.
ast-grep (opens in a new tab) is a Rust CLI that matches and rewrites abstract syntax trees using tree-sitter, with patterns written as code rather than as regex. $UPPERCASE is a metavariable that matches any single node:
npm install --global @ast-grep/cli# find every legacy Button usage, structurally, immune to formattingast-grep --pattern '<Button variant="primary" $$$REST />' --lang tsx# rewrite it, everywhereast-grep --pattern '<Button variant="primary" $$$REST />' \ --rewrite '<Button tone="brand" $$$REST />' \ --lang tsx --update-allThat is a rule your reviewer can read in one line and then confirm against a diff, instead of four hundred edits they have to trust.
Codemod (opens in a new tab) (Apache-2.0) is the layer above it: scaffold a transform, run it as a multi-step workflow, test it, and publish it so the next team runs yours instead of writing their own. It has first-class ast-grep support, and JSSG lets you write the transform in TypeScript with pattern matching rather than in a DSL, which matters when the migration is genuinely conditional.
npx codemod # interactive launchernpx codemod init my-codemod # scaffold a transformnpx codemod workflow run -w ./path # run it locally against the reponpx codemod publish my-codemod # share it with the orgnpx codemod ai # install the MCP server and skills for your agentThat last command is the interesting one, and it is what makes this a workflow rather than a tooling recommendation. It installs Codemod's MCP server and skills into your agent, so the agent has AST analysis and codemod authoring as tools. The behaviour you want out of it is: the agent reads three representative files, writes a transform, runs it against those three, diffs the result, fixes the transform, then runs it against the repository. The model's output is a program you can review. The four hundred file edits are that program's output, and you review them by reviewing it.
The Rollout Is the Risky Part
The transform is the easy half. Landing it is where migrations die, and every case above solved it the same way: never big-bang.
- Make the old thing keep working.Island aliased legacy tokens to the new theme CSS variables, so unmigrated code degraded gracefully instead of breaking. That single decision is what turned a big-bang pull request into weeks of ordinary merges.
- Pick the wave where a mistake is invisible.They migrated light mode first because it was the live production state: if the matrix picked the wrong theme token, the underlying hex still matched and the page looked identical. Dark mode came after that validated. Find your equivalent of light mode and go there first.
- One wave, one pull request, one reviewer pass.A wave is a slice a reviewer can actually hold: one directory, one component family, one prop. If the diff is unreviewable the migration is unverifiable, whatever the tests say.
- Keep the rollback cheap.A feature flag, or a revert that touches one wave. The whole reason to do this in waves is that wave 7 going wrong should not endanger waves 1 through 6.
Why This Matters for Senior Teams
- It converts token spend into an asset.A chat migration produces four hundred diffs and nothing else. A codemod-first migration produces a transform that is committed, versioned, and runnable again on the branch that was in flight during the migration, on the repository nobody remembered, and by the next team with the same problem. Same budget, one durable artifact instead of zero.
- It puts the review back in reach.This hub keeps returning to the merge bottleneck, and migrations are its purest form: generation was never the constraint, four hundred diffs landing on one reviewer was. Reviewing a rule plus a spot-check is a different job from reviewing four hundred changes, and it is the only version of this that scales.
- It makes "how much is left" answerable.Bucket sizes and wave counts are a real burn-down. "The agent is working on it" is not, and it is the reason migrations quietly become permanent.
- It is the honest answer to the freeze.The reason legacy patterns survive for years is that removing them requires a coordinated stop. Waves plus graceful degradation of the old path means the migration runs alongside feature work rather than instead of it, which is usually the actual blocker, not the code.
Where It Fits in the Stack
- The gate is Golden-PR Evals applied to a transform.Before you run a codemod on four hundred files, run it on the five you already know the correct output for. Same discipline, and here the fixture set is free because the migration defines it.
- The Context Budget explains why the naive version fails.Bucket one is exactly the work that should never enter a context window, and moving it out is the largest single reduction available on this kind of task.
- The Browser Verification Loop is the check
tsccannot do.A design system or component migration can be green on every type and unit check and still look wrong. Put a visual check in the gate for any wave that touches rendering. - The Agent-Readable Design System is the migration you will run next.Every argument there for tokens and a registry over prose applies double here, because a machine-readable token file is the mapping table a codemod needs.
- Agent Teams parallelise bucket two, not bucket one.Never fan out agents across a mechanical rewrite. Fan them out across independent waves, each in its own worktree.
- Continuous AI stops the backslide.Once a wave lands, a CI rule that fails the build on the old pattern is what stops the next pull request from reintroducing it. A migration without that rule runs forever.
- Write the triage down as a spec.Spec-Driven Development is the right home for the bucket boundaries and the wave order, because those are the decisions that must survive a compaction and a change of engineer.
When Not to Use It
- Not when the change is genuinely semantic.If every site needs a different answer, there is no transform to write and the codemod step is pure overhead. Sort honestly.
- Not under about fifty sites.Writing and testing a transform has a fixed cost. Below that threshold, one careful agent pass with a good gate is cheaper and you should just do that.
- Not without a test suite you trust.The retry loop is only as good as the check it retries against. If your gate is
tscalone, you will migrate four hundred files into code that compiles and does not work. Building the missing coverage is part of the migration, not a prerequisite you get to skip. - Not as a rewrite in disguise.This moves an existing behaviour to a new API. The moment someone proposes improving the component while migrating it, the diff stops being mechanical and the entire review argument collapses. Migrate first, improve after, in a separate pull request.
Source
- ast-grepgithub.com (opens in a new tab)
- codemod/codemod, the codemod CLI and workflow enginegithub.com (opens in a new tab)
- Accelerating Large-Scale Test Migration with LLMs, Airbnb Engineeringairbnb.tech (opens in a new tab)
- Migrating Code At Scale With LLMs At Google, FSE 2025arxiv.org (opens in a new tab)
- How is Google using AI for internal code migrations?arxiv.org (opens in a new tab)
- Teaching AI to Migrate 12,000+ Design Tokens to Dark Mode, Islandisland.io (opens in a new tab)
