Refactor legacy code with AI without breaking production

How to refactor legacy code with AI agents safely: characterization tests first, small reversible steps, JS to TypeScript and framework migrations, review and rollback.

Solviera Teknoloji 11 min read

To refactor legacy code with AI is to hand a very fast, very confident worker the part of your codebase that nobody fully understands. That can go well. Agents are patient with 3,000-line files, they don’t mind renaming a function in two hundred places, and they read old code without sighing. It can also go badly: a “cleanup” that changes a rounding rule, drops a side effect nobody documented, or turns into a rewrite that is impossible to review.

This guide is about the first outcome. It covers how to build a safety net before the first change, how to keep each step small and reversible, how to run migrations like JavaScript to TypeScript or a framework upgrade, and how to review and roll back what the agent did.

Why legacy code is hard for agents, too

Legacy code is code you are afraid to change, usually because it has few tests and many unwritten rules. The same things that make it hard for people make it hard for an agent:

  • Behaviour lives in odd places. A date format in a helper, a retry in a decorator, a global that something else reads.
  • Some bugs are features. A downstream system may depend on a wrong value. “Fixing” it breaks an integration you’ve never seen.
  • Dead code looks alive and live code looks dead. Reflection, string-based dispatch, cron jobs and templates call functions no search finds.
  • The context window is finite. An agent sees the files it reads. It won’t know about the batch job in another repo that imports this module.

An agent also has one habit that is dangerous here: it improves things. Ask it to extract a function and it may also fix the edge case it noticed, rename variables and modernize the syntax. Each change might be fine. Together they make a diff where you can’t tell what was meant to change.

Step 1: build the safety net first

Don’t let the agent change a line of production code until the behaviour you care about is pinned down by tests. For legacy code that means characterization tests: tests that record what the code does now, not what it should do.

A prompt that works:

Don’t change any code under src/. Write characterization tests for src/orders/pricing.js that record its current behaviour, including anything that looks wrong. Cover each branch you can find, including error paths. Where behaviour looks like a bug, keep the test asserting the current output and add a comment starting with SUSPICIOUS:. Run the tests and show me they pass.

A few tips:

  • Test through the public surface. Tests that call the module the way the rest of the app does survive refactoring. Tests that reach into private helpers break when you move them.
  • Use real inputs. Sample payloads from logs (cleaned of personal data), fixture files or a copy of a staging database give much better coverage of weird cases than invented inputs.
  • Golden files are fine here. For a report generator or an export, recording the whole output and comparing it is a quick and honest characterization test, as long as the output is deterministic.
  • Check that the tests can fail. Break the code by hand in a couple of places, or run a mutation testing tool on the module. If the tests stay green, the net has holes.

The write unit tests with AI guide covers characterization tests, coverage and mutation testing in more depth.

The SUSPICIOUS: notes are valuable. They are the bugs you’ll fix later, on purpose, in their own commits, with someone deciding the new behaviour is right.

Step 2: refactor in small, reversible steps

With the net in place, keep each step small enough to review in a few minutes and to undo without touching anything else.

Separate structure from behaviour

The most useful rule: a refactor commit changes structure only, and a behaviour change gets its own commit. Tell the agent outright:

This is a pure refactor. Don’t change behaviour, don’t fix bugs, don’t change public signatures. If you see something that should change, list it at the end instead.

When the characterization tests change in a refactor commit, something is wrong. That gives you an easy check: git diff --stat main -- tests/ should show no edits to existing tests.

One kind of change per step

Good step sizes:

  • Extract one function or class.
  • Move one module and update its imports.
  • Replace one deprecated API across the codebase.
  • Introduce one interface at a boundary so the old and new code can live side by side.

Ask the agent to run the tests after each step and commit with a clear message before moving on. A plan helps: “Propose the refactor as a numbered list of steps, each one leaving the tests green. Wait for my OK before starting.” Most coding agents have a planning mode for this; Claude Code’s plan mode, for example, explores and proposes without editing.

Strangle, don’t rewrite

For a part that really needs replacing, put the new implementation next to the old one behind an interface or a flag, move callers over one at a time, and delete the old code once nothing uses it. This is the strangler fig pattern. It turns one risky switch into many small, boring ones, and agents are good at boring.

Step 3: migrations with agents

Migrations are where agents save the most time, because the work is wide and repetitive. They are also where the diffs get huge. The trick is to let tools do the mechanical part and the agent do the judgement calls.

JavaScript to TypeScript

A route that keeps the app working the whole time:

  1. Add TypeScript alongside JavaScript. In tsconfig.json, set allowJs: true so .js and .ts files can coexist, and run npx tsc --noEmit in CI as the type check.
  2. Start loose. Begin with strict: false. Optionally turn on checkJs or add // @ts-check to individual JS files to find problems before renaming them.
  3. Convert leaf modules first. Files with no internal imports, such as utilities and constants, are easiest. Then work up toward the entry points.
  4. One folder per step. Ask the agent to convert src/utils/ to .ts, add types, keep the tests green and make tsc --noEmit pass. Then the next folder.
  5. Tighten gradually. Once everything is .ts, turn on noImplicitAny, then strictNullChecks, then full strict, fixing errors in between.

Two rules to give the agent: no any without a comment explaining why, and no @ts-ignore to make errors go away. Otherwise the fastest path to a green compiler is to switch the compiler off line by line.

Framework and library upgrades

For a major version bump, the order matters:

  1. Read the migration guide first. Have the agent summarise the official upgrade guide and list the breaking changes that apply to your code, with file paths. This is a read-only step.
  2. Run the official codemod if there is one. Many projects ship codemods, for example npx @next/codemod for Next.js; for Java, OpenRewrite recipes cover many upgrades. A codemod is deterministic; an agent is not. Let the codemod do what it can.
  3. Let the agent handle what’s left. The cases the codemod couldn’t handle, the custom wrappers, the tests that relied on old behaviour.
  4. Upgrade one major version at a time. Going from 3 to 6 in one jump stacks every breaking change into one diff.

Pin the expected outcome in your prompt: “the build passes, the test suite passes, no new warnings in the console”. A vague “upgrade to the latest version” invites the agent to bump five other packages along the way.

Step 4: isolate the work with worktrees

A long refactor shouldn’t sit in your main working folder, and parallel steps shouldn’t share one. A git worktree gives each step its own branch and folder:

git worktree add ../app-ts-utils -b refactor/ts-utils
git worktree add ../app-ts-api -b refactor/ts-api

Now one agent converts utils, another converts api, and you keep working in the main folder. When a step is done and reviewed, merge it; when it goes nowhere, remove the worktree and the branch. More in git worktrees for parallel agents.

Be careful with parallel steps that touch the same files. Two agents renaming things in the same module will produce conflicts, and conflict resolution in a refactor is where behaviour sneaks in. Split the work by folder or by module, not by type of change.

Step 5: review every step

A refactor diff is easy to approve because it looks like it does nothing. That’s exactly why you read it. What to look for:

  • Did tests change? In a pure refactor, existing tests should not.
  • Did public signatures change? Exported functions, API responses, database columns, event names.
  • Did anything get deleted? Ask the agent why each removed function was unused. “No references found” is not proof when there is reflection or string dispatch.
  • Did error handling change? A swallowed exception that now propagates, or the other way around, is a behaviour change.
  • Did the diff grow beyond the step? A “move this module” step should not also reformat twenty other files. Ask for formatting in its own commit, or none at all.

A second model reviewing each turn catches a fair amount of this. See AI code review for how that works.

Step 6: plan the rollback before you need it

Rollback is cheap when you prepare it:

  • Small commits. git revert <sha> removes one step cleanly. A 4,000-line commit can only be reverted as a whole.
  • Revert, don’t rewrite. Once something is on main, revert the commit or the merge instead of force-pushing over shared history.
  • Bisect when something slips through. With small green commits, git bisect finds the step that broke behaviour in a handful of runs.
  • Flags for risky switches. When old and new code live side by side, a flag turns the new path off without a deploy.
  • Undo inside a session. Before anything is committed, you can undo an agent’s last turn; see how to undo Claude Code changes.

Common mistakes

  • “Refactor this module” with no tests in place. You get a rewrite and no way to check it.
  • Mixing refactor and bug fixes. When behaviour changes, you can’t tell whether it was meant to.
  • One giant migration branch. It drifts from main, conflicts pile up and nobody can review it.
  • Trusting “unused”. Search doesn’t see dynamic calls, other repos or scheduled jobs.
  • Letting the agent silence the compiler. any, @ts-ignore and disabled lint rules hide the work instead of doing it.
  • Merging parallel steps without testing them together. Two green branches can be red once merged, which is what a merge queue is for.

Doing it in AgentVera

AgentVera runs coding-agent CLIs side by side per project, with git built in, which fits the step-by-step style of a legacy refactor.

  • A worktree per step. From the worktree panel, open a branch and folder for each step and start an agent right in it, Claude Code on one, Codex or Gemini CLI on another. Your main folder stays as it is.
  • Checkpoints for every turn. At the start of each turn the working folder is snapshotted to a hidden git ref, and the last 60 turns are kept per agent. See which files a turn changed and undo just that turn if the agent went too far.
  • Review at the end of each turn. Automatic code review reads the code the agent wrote with the Claude model you pick and lists findings with severity and file:line; optionally the findings go back to the agent, up to two rounds in a row.
  • Verify against your criteria. Write “pure refactor, all tests pass, no existing test file changed” in your message. Verify turns it into criteria and, when the turn ends, a separate verifier that can’t edit files runs the tests and marks each criterion with evidence.
  • Merge queue for parallel steps. Branches are merged one at a time onto the latest target and the check command runs on the merged result. A green branch waits for your approval by default.
  • Parallel team for big migrations. For a migration that splits into independent parts, a parallel team runs each part in its own worktree, merges the finished parts into an integration branch and runs the final check, so you review one pull request.
  • Git without leaving the app. The commit manager, diffs and graph history live in the right panel, so reviewing each step and reverting one is quick.

Wrapping up

AI doesn’t make legacy code safe to change; tests and small steps do. What AI changes is the cost of those steps. Writing characterization tests, converting a folder to TypeScript or running a codemod and cleaning up after it used to be the work nobody had time for. With agents it’s cheap, so you can afford to do it properly: net first, one change at a time, every step reviewed, every step reversible.

Read more:

To try AgentVera, start at the home page.

Questions

Can AI refactor legacy code safely?

It can, if you give it a safety net and small steps. Have the agent write characterization tests first, then make one structural change at a time, run the tests after each step and commit it. Large “clean up this module” requests are where agents quietly change behaviour.

What are characterization tests?

Tests that record what the code does today, including odd or buggy behaviour, rather than what it should do. They let you change the structure and notice right away if the behaviour moved.

Should I let an agent migrate a whole codebase to TypeScript at once?

No. Turn on allowJs, convert one module or folder at a time, keep the build and tests green between steps, and tighten compiler options gradually. A single giant change is very hard to review.

How do I roll back an AI refactor that broke something?

If each step is its own commit, git revert removes just that step. Before merging, AgentVera’s checkpoints can also undo a single agent turn. After a merge, revert the commit or the merge on main rather than rewriting history.

Which agent is best for refactoring?

Claude Code, Codex and Gemini CLI can all do it. The process matters more than the model: tests first, small steps, review every diff. In AgentVera you can run them side by side on separate steps.

Bring your agents to one desk.

Download AgentVera for free; your installed CLIs are ready to go.

More posts