Every codebase has the module people route around. Nobody opens it without a reason, changes to it get scheduled a sprint out, and the person who wrote it left in 2019. You know the one.
Michael Feathers gave that module its definition twenty-two years ago and nobody has improved on it since: legacy code is code without tests. Not old code, not ugly code, not PHP. Code you cannot change and know whether you broke something.
This is exactly the code a coding agent handles best. It is also exactly the code where an agent does the most damage, and for the same reason.
The case for pointing an agent at old code
Google published the numbers, which is rarer than it should be. In their int32-to-int64 migration, 80% of the code modifications in the landed changelists were AI-authored, and the total migration time dropped by an estimated 50%. The follow-up paper covering 39 migrations over twelve months puts it at 74.45% of changes and 69.46% of individual edits generated by the model.
Those are production migrations in a live monorepo, counted on changes that actually landed. And the shape of the work explains why it went well: mechanical, repetitive, fully specified, spread over thousands of files nobody wanted to open. That is the sweet spot.
There's a human factor too, and it matters more than people admit. Legacy work is unpopular because it is boring and thankless, so it gets deferred until it becomes a rewrite proposal. The agent doesn't get bored. It will read four thousand lines of a 2014 payment module at three in the afternoon with exactly as much enthusiasm as it had at nine.
Why it goes wrong: the agent refactors the meaning
Here is the finding that should govern how you use it. Researchers ran real refactorings from open-source Java projects through frontier models and checked the output. 7.4% of GPT-4's suggested refactorings were buggy, 6.6% for Gemini. Of the 22 unsafe results, 18 were semantic bugs: the code still compiled, and the behaviour had quietly changed.
One example from the paper: an inline-variable refactoring collapsed a safe conditional into a ternary and introduced a null pointer exception.
The paper's own explanation is the whole story in one line: generic LLMs pattern-match to what refactored code looks like, without validating that the input and output code are equivalent. Looking like a refactoring and being one are different properties, and only one of them is checkable.
A change that alters behaviour is a rewrite with a reassuring commit message.
The agentic study from November 2025 adds the other half: agents refactor more than humans, showing up in 24.9% of agent PRs versus 14.9% of human ones, but they skew hard to renames and type changes. Lots of motion, mostly cosmetic, occasionally load-bearing.
So the agent's speed is real and its judgement about equivalence is absent. That combination has exactly one safe resolution, and Feathers wrote it down before any of this existed.
Characterization tests are the whole trick
A characterization test asserts what the code currently does, bugs included, rather than what it should do. If the function returns -0.00 for a refund of zero, you pin -0.00. If it throws on an empty array instead of returning one, you pin the throw.
That feels wrong the first time. You are writing a test that encodes a bug as expected behaviour. Think of it as a photograph, taken so you can prove the bug survived the refactoring unchanged. You cannot tell whether a refactoring preserved behaviour unless you wrote down what the behaviour was.
And this is the task agents are unusually good at, because it needs zero design judgement. There is no "should", no architecture call, no naming debate. Feed the code in, enumerate the observable behaviour, generate cases until the branches are covered. Boring, exhaustive, verifiable: everything the model is good at and nothing it is bad at.
Approval testing makes it cheaper still. Rather than hand-writing assertions, you capture the output and approve it once as the golden master. ApprovalTests, Verify for .NET, syrupy for pytest, Jest snapshots.
The agent generates inputs, the tool records outputs, you review the recording. A thousand-line legacy function goes from untestable to pinned in an afternoon.
The loop I actually run
1. Pick one change point. The specific function you need to change next week. Legacy work only pays interest where someone is about to walk, and scoping to a whole file is how a refactor becomes a rewrite.
2. Let the agent map the seams. A seam, in Feathers' terms, is a place where you can alter behaviour without editing in that place. Constructor injection points, interfaces, module boundaries. Ask for the inventory: what this function touches, what touches it, where the hidden I/O is, where the global state lives. This is hours of cold reading compressed into minutes, and it only needs to be right in ways you can check.
3. Generate the characterization tests. Explicitly instructed to pin, not to correct. The prompt matters here: an agent asked to "write tests" for legacy code will write tests for how it thinks the code should behave, and then dutifully report failures that are actually the code working as it always has. Tell it the code is correct by definition and its job is to describe it.
4. Read the tests yourself. This is the step that cannot be delegated, and it's the same rule as never shipping code you don't understand: every pinned assertion is a claim about your system's behaviour that you are about to trust with a refactoring. Read each one and ask whether it pins reality or the agent's assumption about reality.
5. Check the pins would actually catch something. Green tests prove nothing on their own. Mutate the code deliberately, flip a comparison, change a rounding mode, and confirm the suite goes red. Mutation testing turns coverage into evidence, and on generated tests protecting legacy code it is not optional. A characterization suite that passes no matter what you break is worse than no suite, because it feels like a safety net.
6. Now refactor, one seam at a time. Small commits, full suite green between each. When something goes red, you have learned something specific instead of bisecting a thousand-line diff at midnight.
Steps 1 to 5 are most of the work. That's the point. The refactoring was never the hard part.
What I don't hand over
Deciding what the code was for. The tests capture what it does, and say nothing about why. That 2014 payment module rounds three different ways in three branches, and somewhere in the company one of those is a regulatory requirement and two are accidents. No amount of reading the code reveals which.
Anything with hidden I/O. A function that writes a file, sends a webhook, or touches a payment provider cannot be safely characterized by running it, and an agent will discover this the expensive way.
The decision to stop. Agents will keep finding things to improve indefinitely. Legacy work has to be scoped to the change you came for, or it eats the sprint.
There is a pleasant side effect to all of this: the code gets easier for the agent too. Pinned, decomposed, seam-friendly code is code the next session can work in without guessing, which is the same debt tax running in reverse.
Tests made legacy code safe to change back in 2004. What changed is that writing them stopped costing a week, and the excuse everyone has been using since went with it.
That module you route around: the tests for it are an afternoon's work now. The routing around is a choice.