If you have watched a demo where an AI agent “modernizes” a codebase in one shot, you have watched a toy. Point the same agent at a real system - 200,000 lines of PHP 5 with no tests, three generations of ORM, and business logic smuggled inside stored procedures - and it will produce confident garbage. It will rename things, break the build in ways the build does not report, and quietly change behavior that nobody wrote down anywhere.

That does not mean AI is useless here. It means the naive approach is useless. The agent is a strong junior engineer with perfect recall and zero judgment about what matters. Used inside a structured workflow with hard context boundaries and human checkpoints, it gets you surprisingly far. Used as a rewrite button, it gets you a second legacy system that nobody understands.

Here is the honest playbook.

The naive approach and exactly where it breaks

The obvious first attempt looks like this:

Rewrite this legacy PHP module in modern TypeScript.
Keep the same behavior.

You paste in a file, or point the agent at a directory, and let it run. For a single 80-line utility function this works fine. For anything real it fails in four specific ways, and it is worth naming them because each one drives a design decision later.

1. The context window is smaller than the problem. A 200k-line codebase does not fit in any model’s context, and it will not fit for the foreseeable future no matter what the marketing says. The agent physically cannot see the whole system at once. So when it rewrites module A, it does not know that module B reaches into A’s internal state through a global, because B is not in the window. It produces code that is locally correct and globally wrong.

2. There are no tests, so “keep the same behavior” is undefined. The agent has no way to know what the current behavior actually is. Legacy code is full of behavior that is technically a bug but that some downstream consumer now depends on. The agent will “fix” the bug and break production. Without a test that pins the current output, correctness is a matter of opinion, and the agent’s opinion is not the one that matters.

3. It changes too much at once. Ask for a rewrite and you get a rewrite - a 4,000-line diff touching 30 files, mixing a framework upgrade, a naming convention change, and three actual logic changes in the same commit. No human can review that. You either merge it on faith or throw it away. Both are bad.

4. Hidden coupling is invisible until runtime. Legacy systems are held together by things that do not appear in the code you are looking at: cron jobs that assume a column exists, a reporting query that parses a log format, a mobile client three years out of date that still calls a deprecated endpoint. The agent cannot see any of this and neither can you until it breaks.

Every one of these is a context problem or a verification problem. The entire playbook below is built to attack those two things directly.

Reframe: migration is a pipeline, not a prompt

Stop thinking “rewrite the codebase.” Start thinking “move one seam at a time, and prove each move is safe before the next one.” The unit of work is not the project. It is the smallest piece of behavior you can characterize, transform, and verify independently.

  [ Legacy module ]
        |
        v
  1. Map dependencies (what calls this, what does this call)
        |
        v
  2. Characterize (write tests that pin CURRENT behavior)
        |
        v
  3. Transform (agent rewrites against the tests)
        |
        v
  4. Verify (tests pass, diff is human-reviewable)
        |
        v
  5. Checkpoint (human approves, merge, move on)
        |
        v
  [ Next module ]

The agent does real work in steps 2 and 3. Steps 1, 4, and 5 are where humans and tooling keep it honest. Miss any of the five and you are back to the naive approach with extra steps.

Step 1: Map dependencies before touching anything

Before the agent rewrites a line, it needs a map of what connects to what. This is a read-only task and agents are genuinely good at it, because reading and summarizing is exactly what LLMs do well.

The trick is to bound the question. Do not ask “understand the codebase.” Ask for one module’s blast radius:

For the file billing/invoice.php, produce a dependency report:

1. Every function/class defined here and its public signature
2. Every external symbol this file calls (with the file it lives in)
3. Every place in the repo that imports or calls INTO this file
4. Any global state, superglobals, or static vars read or written
5. Any direct SQL, and which tables/columns it touches

Output as a table. Do not suggest changes. Do not rewrite anything.

Run this with a coding agent that can grep the repo, not a bare chat model - it needs to actually search files, not guess. The output becomes your context boundary. If step 3 shows that 20 other files call into invoice.php, this module is not a safe first target. Pick a leaf node instead: something that many things call but that calls little itself, or nothing does. Leaf nodes migrate cleanly because their blast radius is small.

This map is also what you feed the agent in step 3 as explicit context, so it does not have to rediscover the coupling and get it wrong.

Step 2: Characterization tests come before the rewrite

This is the step everyone wants to skip and the step that makes the whole thing work. You cannot safely transform code whose behavior is not pinned down. So before any rewrite, you write tests that capture what the code does right now, bugs included. Michael Feathers named these “characterization tests” twenty years ago and they are more relevant with AI than they ever were.

The agent is well suited to writing them, because it does not need to understand the code - it needs to observe it. The prompt is deliberately about the present, not the ideal:

Write characterization tests for calculate_invoice_total() in billing/invoice.php.

Goal: capture CURRENT behavior exactly, including anything that looks
like a bug. Do NOT fix anything. Do NOT assume intended behavior.

For each test:
- Call the function with concrete inputs
- Assert on the EXACT current output (run it if unsure, then pin that)
- Cover: normal case, zero, negative amounts, null customer,
  currency rounding, the discount edge cases

If a result looks wrong, still assert the current value and add a
// SUSPICIOUS comment. We decide later whether it is a real bug.

That // SUSPICIOUS convention matters. It separates two decisions that the naive approach fatally merges: “does the new code match the old code” and “is the old code correct.” You want to answer the first now and defer the second to a human who can check whether anything depends on the bug.

A worked example of what pinning current behavior looks like:

def test_invoice_rounding_current_behavior():
    # Legacy rounds half-DOWN, which is technically wrong for INR,
    # but the finance export has depended on it since 2019.
    result = calculate_invoice_total(items=[Item(price=10.125, qty=1)])
    assert result.total == 10.12  # SUSPICIOUS: not banker's rounding
    # We are pinning this ON PURPOSE. Changing it is a separate,
    # reviewed decision - not a side effect of the migration.

Now you have a safety net. The rewrite is allowed to change how the code is written. It is not allowed to change what these tests assert, unless a human explicitly signs off on that specific behavior change.

Step 3: Transform against the tests, inside a context boundary

Only now does the agent rewrite anything. And the prompt is nothing like “rewrite this module.” It is bounded on every side: the exact scope, the dependency map from step 1, the tests from step 2 as the definition of correct, and an explicit ban on scope creep.

Port calculate_invoice_total() from billing/invoice.php to
services/billing/invoice.ts (TypeScript, strict mode).

Definition of done: every test in invoice.characterization.test.ts
passes against your new implementation, unchanged.

Context (do not rediscover, use this):
- Dependency map: [paste step 1 output]
- Callers expect the same return shape: { total, tax, currency }

Rules:
- Port ONLY this function and its private helpers.
- Do NOT change the characterization tests.
- Do NOT "improve" behavior the tests pin, even if it looks wrong.
- Do NOT touch other files. If you need a shared type, note it
  and stop - do not create it speculatively.
- Match the existing TS conventions in services/ (I have attached
  two example files).

The context boundary is the whole game. You are not asking the model to hold 200k lines in its head. You are handing it one function, its contract, its callers’ expectations, and a passing bar it can check itself against. That fits comfortably in context, which is why the output is good.

When the tests fail, you do not fix the code by hand. You paste the failure back and let the agent iterate against real output. This loop is tight and usually converges in two or three rounds for a well-scoped function. If it does not converge, the scope was too big - split it.

The rules file that keeps the agent on the rails

A migration runs across dozens of sessions, and you do not want to re-explain the target conventions every time. Put them in a persistent rules file at the repo root - a CLAUDE.md or the equivalent for whatever agent you use - so every session starts already knowing the destination.

# Migration Rules

## Target
- Legacy: PHP 5.6. Target: TypeScript 5.x, strict mode, Node 20.
- One module at a time. Never migrate across the module boundary
  in a single commit.

## Non-negotiable
- Characterization tests are written BEFORE any port and are never
  edited during a port.
- Preserve current behavior exactly, including pinned quirks.
  Behavior changes are separate, labeled commits.
- Do not touch files outside the named scope. If a shared type is
  needed, stop and ask - do not create it speculatively.

## Conventions in the new code
- Repository pattern for DB access. No raw SQL in services.
- All money is integer paise, never floats. Convert at the boundary.
- Errors are typed Result<T, E>, never thrown across module lines.

This file is doing the same job as the dependency map: it moves knowledge out of the model’s fragile short-term context and into something durable. The rules file from week six is far better than the one from week one, because every mistake the agent makes becomes a new line that prevents the next instance of it.

The data layer is where migrations actually die

Porting application code is the easy 80%. The stored procedures, the triggers, the reporting queries, and the schema itself are where legacy migrations quietly fail, because the coupling there is almost entirely invisible to a code-reading agent. A stored procedure that recalculates balances on a nightly cron does not appear anywhere in the PHP the agent is looking at.

The pipeline still applies, but characterization means something different at the data layer: you pin behavior by capturing real output on real data.

For the stored procedure sp_monthly_settlement:

1. Do NOT rewrite it. Read it and describe, in plain steps, every
   table it reads and writes and every business rule it encodes.
2. Propose a set of characterization checks: given a snapshot of
   input tables, what should the output rows be? Express these as
   SQL assertions I can run against a copy of production data.
3. Flag anything that depends on execution time, ordering, or
   session state - those will not survive a naive port.

Then you run those assertions against a restored copy of production before and after any change. If the row-level output matches, the port is safe. If it does not, you have found hidden coupling before it found you. Never let an agent rewrite a stored procedure and trust the result because the syntax looks right - the syntax is not the risk, the data is.

Step 4 and 5: Verify, then a human checkpoint that means something

Passing tests are necessary, not sufficient. Two more gates before this counts as done.

The diff has to be reviewable. If the agent produced a 900-line diff for one function, something went wrong - it invented helpers, restructured neighbors, or wandered. Reject it and re-scope. A good migration commit is small enough that a human can read every line and understand the transformation. That is not a nice-to-have. It is the only thing standing between you and merging behavior changes you never saw.

A human owns the SUSPICIOUS list. Before merge, someone looks at every // SUSPICIOUS marker and decides: keep the quirk (something depends on it), or fix it in a separate, clearly labeled commit. This is the checkpoint that AI cannot own, because it requires knowledge that lives outside the code - in the finance team’s spreadsheet, in the mobile app’s release cadence, in an SLA nobody wrote down.

Here is what to automate versus what to keep human, because getting this split wrong is how teams either move too slow or ship disasters.

TaskOwnerWhy
Dependency mappingAgentSearch and summarize is its strength
Writing characterization testsAgent + human spot-checkAgent drafts, human verifies edge coverage
The actual code transformAgentBounded, test-gated, low risk
Deciding if a SUSPICIOUS bug can be fixedHumanNeeds system knowledge outside the code
Choosing migration orderHumanStrategic, depends on business priority
Approving the mergeHumanAccountability cannot be delegated

What this looks like across a whole system

One function is a demo. The reason this scales is that the pipeline is repeatable and the safety net compounds. Every module you characterize adds tests that catch regressions in the modules you migrate next. Six weeks in, you have a growing suite pinning real behavior, and the migration gets safer as it goes rather than riskier.

A realistic cadence for a small team running this:

Week 1     Set up the pipeline. Map the top-level module graph.
           Pick the first 5 leaf modules. Establish the review bar.
Weeks 2-3  Characterize + migrate leaf modules. Slow on purpose.
           This is where you tune prompts and the CLAUDE.md rules.
Weeks 4-8  Throughput climbs. The test suite is now catching
           regressions for you. Migrate inward from the leaves.
Ongoing    The hard core - the 10% with the worst coupling - gets
           migrated last, by humans, with the agent assisting on
           tests and small pieces rather than driving.

Notice that the agent’s role shrinks as you approach the ugly center. That is correct. The messy, deeply-coupled core is exactly where system knowledge matters most and where the agent’s blindness to hidden coupling is most dangerous. Let it write tests and port small pieces there, but do not let it drive.

The failure modes to watch for

Even with the pipeline, a few things go wrong repeatedly.

Test theater. The agent writes tests that pass but assert almost nothing - checking that a function returns a non-null value instead of checking the value. Passing tests give false confidence. Spot-check that the tests would actually fail if you broke the logic. Mutation testing is worth running here.

Silent scope creep. Over a long session the agent starts “helpfully” touching adjacent code. Every diff review is also a scope-check. If it touched a file you did not name, that is a rejection, not a discussion.

The plausible hallucination. The agent invents an API that looks exactly like a real one - a method that should exist but does not, a config key with a sensible name that nothing reads. These pass a casual read and fail at runtime. The characterization tests catch most of them, which is the entire point of writing them first.

Behavior drift through “cleanup.” The agent normalizes a date format, trims a string, coerces a type - small, reasonable-looking changes that alter output. The tests catch these too, as long as the tests pin exact values rather than shapes.

Honest assessment

What works: the pipeline is real and it delivers. Mapping, characterization, and bounded transformation are genuinely faster with a capable agent, often several times faster than doing them by hand, and the quality is good because every piece is small and verified. Teams that run this ship steadily and sleep at night.

What does not work: pointing an agent at a repo and asking for a modern version. It never has, and the barrier is not model capability - it is that verification and hidden coupling are the actual hard problems, and a bigger context window does not solve either one. Anyone selling an overnight rewrite is selling the naive approach with better slides.

What to actually do: treat the AI as a fast, tireless junior who is excellent at reading, good at writing tests, competent at bounded transformation, and completely unaware of what matters. Build the pipeline around that reality. Spend your own time on the two things the agent cannot do - deciding migration order and owning the checkpoints where behavior changes get approved. Start with one leaf module this week. Characterize it, migrate it, review the diff line by line. If that loop feels solid, you have the whole method. Everything after is repetition, and repetition is exactly what the agent is for.