← All posts

Guide

Migrating a large codebase with an agent

Migrating a large codebase with an agent

A migration is not one task. It is a thousand nearly identical ones. That single fact changes everything about how you drive an agent through it — how you prompt, how you review, how you commit, and whether you should be using an agent for that particular file at all.

JavaScript to TypeScript. Class components to hooks. Enzyme to Testing Library. Python 2 to 3. Moment to date-fns. These are the jobs where an agent looks most impressive in a demo — watch it convert a file, flawlessly, in nine seconds — and fails hardest in practice, because the demo converted one file and your repo has 1,200. The failure is not that the agent is bad at the transformation. It is that the shape of the work is wrong, and nobody tells you until you are forty files in with a broken build and a pull request no human will ever read.

one transform, two ways — 109 files, 23,330 lines, 65 declarations one line of sed 65 rewritten tsc: 8 errors AST codemod 63 rewritten tsc: clean · 189 ms the 2 it refused are the only two worth a human — or an agent
Measured on this repo, not simulated. The mechanical tier finishes in under a fifth of a second; the interesting part is the sliver on the right.

The unit of work is wrong

"Migrate this codebase to TypeScript" is a prompt with no unit of work in it. The agent reads a file, converts it, reads the next, converts it, and by file thirty it has forgotten the naming convention it invented at file four, because the decision was never written down anywhere except a conversation that has since been compacted. You end up with three different spellings of the same generic, two competing patterns for the same event handler, and no way to tell which is intentional.

This is not an agent defect, it is a context defect, and it is the predictable one. A long session loses exactly the things you most need held constant across a thousand files — see managing context in a long Claude Code session for what survives compaction and what does not. The fix is not a bigger window. The fix is to stop treating the migration as a conversation and start treating it as a pipeline.

Three properties fall out of "a thousand nearly identical tasks", and each one dictates a practice:

The shape that works: pilot, rules, batches

1. A pilot on a handful of files

Pick five to ten files chosen for variety, not for being easy: the simplest one, the gnarliest one, one with an unusual pattern you know exists, one that touches the boundary you are least sure about. Have the agent convert them, one at a time, and argue with it. This stage is slow on purpose. You are not producing migrated files, you are producing decisions.

Then review the agent's output as a specification, not as a diff. This is the move most people skip and it is the whole trick. Reviewing a diff asks "is this change correct?" Reviewing a spec asks "is this the rule I want applied twelve hundred times?" Those are different questions with different answers. A conversion that is fine in one file can be actively wrong as a policy — a cast that is harmless here becomes a thousand casts that hide a thousand real type errors. Make the agent state the rule it followed, in words, and correct the rule rather than the file.

2. Write the rules down

The output of the pilot is a file — MIGRATION.md in the repo root, or a path-scoped rules file — that every subsequent batch is pointed at. Terse, specific, decision-shaped:

# Migration rules: Enzyme → Testing Library

- Query by role first, then label, then text. Never by class name or test id
  unless the element has no accessible name; if it has none, that is a bug —
  fix the component, do not add a test id.
- shallow() has no equivalent. Render the real tree. If that pulls in a
  provider, wrap with renderWithProviders from test/utils.
- Anything asserting on state, props or instance methods does not get
  translated. Delete the assertion and leave a TODO(migration) naming what
  user-visible behaviour should be asserted instead.
- One file per commit. Do not touch the component under test.
- If a test cannot be translated under these rules, stop and list it.
  Do not improvise.

That last line matters more than the rest. A migration's worst failure mode is an agent quietly inventing a rule at file 340 because the situation was not covered. "Stop and list it" converts a silent divergence into a five-line message you can answer once, for every file it applies to.

3. Batches, not a queue

Ten to forty files per batch, one commit per batch, the build green at the end of every one. Draw batch boundaries along dependency boundaries — one module, one feature directory — rather than alphabetically, so that a batch is a thing that can be reasoned about and, crucially, independently reverted.

Never a 400-file pull request. A diff that large is not reviewed, it is approved, and the difference between those two words is the entire value of review. This is not a stylistic preference: in Google's own experience report on LLM-driven internal migrations, the constraint that bit was not model quality or generation speed — it was human review capacity. They wrote that they "needed to slow down some of the migration work to avoid overwhelming the teams that had to do the reviews." If Google's reviewers are the bottleneck, yours are too.

The part people skip: a codemod beats the agent for the mechanical 80%

Say it plainly, because the whole industry is currently pretending otherwise: for the mechanical majority of a migration, a codemod is better than an agent in every dimension that matters. It is deterministic, so batch 12 behaves exactly like batch 1. It is free. It is thousands of times faster. It is reviewable once instead of per file. And when it cannot handle a case it fails loudly rather than guessing plausibly.

I wanted a number rather than an opinion, so I ran one. The corpus is the Electron overlay in this repo: 109 TypeScript files, 23,330 lines, 902,586 bytes, containing 65 interface declarations. The transform is a real one people do — interface X { … } to type X = { … } — chosen because it looks completely trivial and is not. I did it twice.

First, the way everyone actually reaches for it at 11pm — one line of sed:

find src -name '*.ts' -o -name '*.tsx' | xargs sed -i '' -E \
  's/^([[:space:]]*)(export )?interface ([A-Za-z0-9_]+) \{/\1\2type \3 = {/'

Then a 116-line codemod using the TypeScript compiler API, which walks the AST, and which refuses three specific cases: a declaration whose name appears twice in the same file (declaration merging, which a type alias cannot express), a declaration inside declare module or declare global (which relies on merging with an ambient type), and a declaration with an extends clause (expressible as an intersection, but not with identical semantics — a judgment call, not a rewrite). Both were run against an identical copy of the corpus, and tsc --noEmit was run before and after each.

One line of sedAST codemod
Declarations rewritten6563
Declarations refused02
Files changed4645
Wall clockinstant189 ms
tsc --noEmit beforecleanclean
tsc --noEmit after8 errorsclean

The eight errors all trace to one line the regex could not see the meaning of. A file contains declare global { interface Window { popup: … } } — an augmentation that works precisely because interfaces merge with the built-in Window. Rewritten to a type alias it becomes Duplicate identifier 'Window', and every property access that depended on the augmentation fails behind it. Widen the same sed to include the one .d.ts file in the tree, which also augments Window, and the count goes from 8 errors to 214. One character of context the regex had no way to know about, two hundred failures downstream.

That is the argument for the codemod in miniature: not that it is clever, but that it operates on structure and therefore knows what it does not know. The two declarations mine refused are exactly the two that deserve a human's attention, and they arrived as a list rather than as a silent breakage.

The same split, at three companies that published numbers

This is not one person's preference. Every organization that has done a migration at scale with LLMs and then written about it landed on a hybrid, and the numbers are worth knowing.

WhoMigrationWhat they found
Slack 15,000+ Enzyme tests → React Testing Library AST codemod alone converted 45%. An LLM alone (Claude 2.1) fluctuated between 40% and 60%. Feeding the codemod's partial output into the LLM prompt reached 80%.
Google JUnit3 → JUnit4, int32 → int64 IDs 80% of code modifications in landed changelists were fully AI-authored, but only alongside deterministic AST tooling: "a combination of AST-based techniques, heuristics, and LLMs are needed to achieve success." The JUnit migration moved 5,359 files and 149,000+ lines in three months.
Airbnb 6M-line frontend monorepo → TypeScript Pure codemod, pre-LLM. ts-migrate converted projects of 50,000+ lines and 1,000+ files in a day — by inserting any and suppression comments wherever it could not infer, so the project compiles first and the thinking happens after.

Slack's number is the load-bearing one. The codemod alone got 45%; the model alone got 40–60% and was unstable; the two composed got 80%. The gain came from a specific mechanic worth stealing: they ran the AST codemod first and passed its partially-converted output plus its annotations into the model's prompt, rather than handing the model a raw file. The codemod does the boring part and leaves comments naming what it could not do; the model starts from there. Google's report puts the same conclusion negatively: "the use of LLMs alone through simple prompting is not sufficient for anything but the simplest of migrations."

Airbnb's design decision is the other one to steal. ts-migrate does not try to produce good TypeScript. It produces compiling TypeScript, with any where it cannot infer and a suppression comment on every remaining violation. That sounds like cheating and it is the correct move: it converts an open-ended migration into a finite, greppable worklist. Every @ts-expect-error is a ticket. The agent's job is to close them.

Where the agent earns its cost: the 20%

The residual is not the leftovers. It is the part of the migration that was always going to be real work, and it is where a model is genuinely better than anything else available — because it requires reading intent out of code, which is exactly the thing rules cannot do.

WorkGive it toWhy
Rename a symbol across 1,200 filesCodemodA rule expresses it exactly; a model can only approximate it
Mechanical signature change, positional args → options objectCodemodDeterministic and verifiable at the AST level
.js.ts, add placeholder types, make it compileCodemodThe point is a green build and a worklist, not good types
Replace a placeholder type with the one the code impliesAgentRequires reading call sites and inferring intent
componentDidUpdate with branching conditions → useEffect depsAgentThe dependency array is a semantic claim, not a syntactic one
wrapper.state('open') → an assertion on visible behaviourAgentThere is no mechanical mapping — the test has to be re-conceived
Decide a side effect never belonged in that lifecycle at allAgent, then youThis is a design change wearing a migration's clothes

Class components to hooks is the cleanest illustration of why the second half of that table exists. React's own react-codemod ships nineteen transforms — pure-component, rename-unsafe-lifecycles, update-react-imports and so on — and after seven years there is still no class-to-hooks transform among them. The closest, pure-component, handles only classes that have nothing but a render method. That is not an oversight. The mapping from lifecycle methods to effects is not a mapping: useEffect merges componentDidMount, componentDidUpdate and componentWillUnmount, and the dependency array you write encodes a claim about which values the effect actually reads. Get it wrong and you have not broken the build, you have created an infinite render loop or a stale closure that shows up in production three weeks later.

Python 2 to 3 has the same shape and a sharper edge: the mechanical tool has been removed from the language. 2to3 and lib2to3 were deprecated in Python 3.11 and deleted in 3.13, alongside PEP 594's cull of the standard library, because lib2to3's LL(1) parser cannot parse modern Python syntax. The replacements are third-party — LibCST, parso. Meanwhile the part that was never mechanical, deciding whether a given str was always meant to be text or was really bytes, is still sitting there waiting for someone who understands the code.

The cost, since it is the actual argument

That 109-file corpus is 902,586 bytes. At roughly 3.5 characters per token for TypeScript source that is about 258,000 tokens — an estimate, not a measurement; I did not run a tokenizer. At Claude Opus 5's published $5 per million input tokens, having an agent read each file exactly once costs about $1.29. That is the floor: no output, no retries, and nothing resent. In a real session the conversation is resent on every turn, so the true figure is a multiple of it.

Scale that arithmetic to a migration-sized repo — 1,200 files at the same average size — and one cold read of the codebase is roughly 2.8 million input tokens, about $14, before the model writes a single character. The codemod did the same 63 rewrites in 189 milliseconds for nothing. Spending the $14 on the residual is an excellent trade. Spending it on renames is setting money on fire, and the deeper cost is not the dollars — it is that a nondeterministic process just touched 1,200 files you now have to review one at a time. Our breakdown of what an agent actually costs works the subscription-versus-API crossover if you want the full model.

Parallelism: the one case where it genuinely pays

Most "run five agents at once" advice is aspirational, because most tasks are not actually independent. Migration batches are — that is what made them batches. This is the workload parallel agents were invented for.

The mechanism is git worktrees. Two agents in the same checkout will destroy each other's work: one working tree, one index, one branch, and no error message when it goes wrong. Give each batch its own worktree on its own branch and the collision surface disappears:

git worktree add ../mig-auth   -b mig/auth
git worktree add ../mig-billing -b mig/billing
git worktree add ../mig-settings -b mig/settings

Four rules keep it from turning into a merge disaster:

Worktrees remove the file conflicts and then hand you a new bottleneck, which is that five agents all look identical while running and one of them stopped ninety seconds ago waiting for a decision. Running several agents at once covers where that ceiling actually sits — for most people around three to five, set by attention rather than by Git.

How you know you are done, and that it is correct

"The build is green" is not "the migration is correct." Airbnb's tool is explicit about this: it inserts placeholder types and suppression comments in order to make the build green before anyone has thought about the code. A green build after a migration means the syntax parses. It says nothing about behaviour.

The three signals worth actually having, in order of how much they cost you:

When there is no signal, the move is characterization tests, and the discipline is to write them against what the code does, not what it should do. Pick the highest-traffic paths, capture current behaviour — outputs, side effects, the shape of what comes back — and pin it. These tests are ugly, they encode bugs, and that is the point: a migration must not change behaviour, including the wrong behaviour. This is also the one part of the whole exercise where pointing an agent at untested legacy code and asking for tests is unambiguously the right call. It is not judgment work, it is volume work with a clear oracle, and the agent is fast at it.

One caveat I have not tested and will not pretend to have: an agent writing characterization tests will sometimes "fix" the behaviour it is supposed to be recording, because the code looks obviously wrong to it. Assert in your rules file that a characterization test records reality and never corrects it, and spot-check for tests that look suspiciously reasonable.

The whole thing, in order

  1. Inventory. Count the files and count the distinct patterns. If you cannot name the patterns, the pilot has not happened yet.
  2. Characterization tests on the highest-risk paths, if you have no signal. Agent-suitable, high volume, low judgment.
  3. Pilot on 5–10 deliberately varied files. Argue. Review the output as a spec, not as a diff.
  4. Write the rules file. Include "stop and list it" for anything uncovered.
  5. Codemod the mechanical tier, on the base branch, in one reviewable commit. Placeholders and markers where it cannot decide. Build green.
  6. Batch the residual, 10–40 files, one worktree and one agent per batch, one commit per batch, build green at the end of each.
  7. Track the marker count down to zero. That number, not the build status, is the migration.
  8. Merge continuously. Never one 400-file pull request.

Steps 3 and 5 are where the time goes and where the outcome is decided, and neither is the part that looks like AI. That is the honest summary of the current state of the art: the agent is a superb tool for the hardest fifth of a migration and a wasteful one for the other four fifths, and the engineering is in knowing which file is in which pile.

What I ran, and what I did not

Verified on this machine on 7 August 2026: the 109-file corpus, the sed run, the AST codemod, and four tsc --noEmit runs (baseline clean, post-codemod clean, post-regex 8 errors on the identical file set, 214 when the tree's one .d.ts is included). The Slack, Google and Airbnb figures are from their published write-ups, not reproduced here. The token count is a characters-per-token estimate, not a tokenizer run, and the dollar figures are arithmetic over Anthropic's published Opus 5 rate of $5 per million input tokens. I did not run a full migration of a real legacy codebase end to end for this piece; the pipeline above is the shape that the measurements and the published reports both point at.

Frequently asked questions

Should I use a codemod or an AI agent for a large migration?
Both, in that order. Anything a rule can express — renames, mechanical signature changes, syntax swaps, inserting a suppression comment so the build compiles — belongs to a codemod: deterministic, free, and finished in under a second. The agent is worth its cost on what the codemod cannot express, roughly the last 20%: replacing a placeholder type with the one the code implies, turning a branching lifecycle method into effect dependencies, rewriting an assertion so it checks behaviour rather than internals.
How do I stop the agent from producing a 400-file pull request?
Do not ask for 400 files. Batches of 10 to 40, one commit each, build green at the end of every one. A 400-file diff is not reviewed, it is approved. Google reported that in their own LLM migrations the bottleneck was review capacity, not generation speed — they deliberately slowed the work down so reviewers were not overwhelmed.
How big should a migration batch be?
Small enough to actually read, and drawn along a dependency boundary rather than alphabetically. Ten to forty files is the usual range. The real constraint is that a batch must be independently revertible: if batch 7 is wrong you want to drop one commit, not unpick it from a merge.
Do I need a test suite before I start migrating?
You need something that fails when the migration is wrong. For JavaScript to TypeScript the compiler does part of that job; for Enzyme to Testing Library the tests are the thing being migrated, so they cannot check themselves; for Python 2 to 3 you typically have neither. When there is no signal, write characterization tests first — record what the code does today, not what it should do — and treat that as the contract the migration must not break.
Does running several agents in parallel actually help on a migration?
This is the case where it genuinely does, because the batches are independent by construction. Give each agent its own git worktree, assign one batch per worktree, and keep the batches file-disjoint so the merges are trivial. Most people run three to five before their own attention, not Git, becomes the limit.
Why did the migration compile but break at runtime?
Because compiling was the only thing you checked. Migration tools optimize for a green build, not for correct behaviour — Airbnb's ts-migrate inserts placeholder types and suppression comments specifically so the project compiles before anyone has looked at it. Those comments are a worklist, and the migration is not finished while any remain.

Where Backgrind fits

A migration run this way is four or five agents chewing through disjoint batches in separate worktrees, each of which will stop at some unpredictable moment to ask whether it may run the codemod, or to report a case the rules file did not cover. That is the exact workload Backgrind was built for: an always-on-top window with a tab per agent, each running your real CLI in its own worktree, kept alive by a background daemon so closing the window does not kill a batch. Only the tab that needs a decision pings — a ring and a chime — while the rest keep grinding, and you can approve from the toast without switching context. It does not do the migration for you. It stops you from being the thing that four agents are waiting on. Watch the loop in the live demo.