Refactoring legacy code with Cursor
PLRefactoring legacy code with Cursor is a sequenced workflow: map the codebase in Plan Mode, pin current behaviour with characterization tests, then let the agent change one function or module per commit. Each step is accepted only when the pinned tests pass unchanged and the diff stays inside its scope, so nobody reads every line.
You inherited a five-year-old Express API: about 50 JavaScript files, callbacks nested four deep, business rules inside route handlers, and no tests. Real users depend on every quirk, including the undocumented ones. Asking the agent to “modernize this” produces a 3,000-line diff that nobody can review and that changes behaviour in ways nobody notices until production. This page is for developers doing that refactor in Cursor, and for tech leads who have to approve the result.
What a safe legacy refactor in Cursor gives you
Section titled “What a safe legacy refactor in Cursor gives you”- A read-only mapping prompt and a critical-path trace that tell you where the risk is before any file changes.
- A characterization-test prompt that pins what the code does today, bugs included, plus two checks that prove those tests can catch a change.
- Scoped prompts for the three mechanical moves: callbacks to
async/await, service extraction, and incremental TypeScript. - A gate you run after every step (locked tests, scoped diff, type check, cycle check) and a pre-PR evidence prompt a reviewer can approve without reading the whole diff.
- Recovery steps for the six ways this refactor usually goes wrong.
This page is the Cursor implementation. The tool-independent programme (slicing, parallel waves, and cut-over evidence) is legacy modernization with agents, and the Codex equivalent is codebase modernization with Codex, and in Claude Code see refactoring with Claude Code.
Why does the order of a legacy refactor matter?
Section titled “Why does the order of a legacy refactor matter?”Every step below depends on the one before it. Tests before refactoring, because without them “the behaviour did not change” is an opinion. Mechanical changes before structural ones, because a pure async conversion is easy to verify and makes the later extraction smaller. Bug fixes last, in separate commits, because a refactor commit that also fixes a bug cannot be verified by the tests that pin the old behaviour.
| Step | Cursor surface | What the agent may change | Gate that accepts it |
|---|---|---|---|
| 1. Map the codebase | Plan Mode | Nothing | A committed map where every finding cites a file or is marked “inferred” |
| 2. Trace critical paths | Plan Mode | Nothing | You can name the three riskiest paths and the edge cases each one needs |
| 3. Pin behaviour | Agent | New test files only | Tests pass on the untouched code, twice, and fail when you break it on purpose |
| 4. Convert callbacks | Inline edit, one function | One function | Locked tests pass, diff touches only that file |
| 5. Extract services | Agent, one module | The named module, its new service file and its errors file | Locked tests pass, git status --short lists only those files, no new import cycle |
| 6. Add types | Agent, one layer | Renamed files and their importers, after a setup-only commit | tsc --noEmit clean, locked tests pass |
| 7. Fix bugs and queries | Agent | Code and the matching test expectation | The test change and its fix land together in one fix: commit |
How do you refactor legacy code with Cursor, step by step?
Section titled “How do you refactor legacy code with Cursor, step by step?”Before the first prompt, set up the safety net. Create a branch, commit the current state, and write down the one command that runs the tests. Add a project rule such as: “During this refactor, change only the files named in the prompt. Never edit files under tests/characterization/. Never change a response body, status code or error message.” A rule is an instruction, not a fence. The fence is the gate you run after every step (first shown in Step 4), which the agent cannot talk its way past.
Step 1: Map the codebase in Plan Mode
Section titled “Step 1: Map the codebase in Plan Mode”Plan Mode “creates detailed implementation plans before writing any code”, so it is a safe place to read an unfamiliar codebase. Ask, Cursor’s read-only mode (Cursor’s help, checked 2026-10-12), works here too. How to feed a large repository to the agent without flooding its context is covered in context management.
Save the report as docs/refactor/map.md and commit it. The “inferred” markers tell you which findings to check yourself before you rely on them.
Step 2: Trace the critical paths
Section titled “Step 2: Trace the critical paths”Pick the two or three flows that would hurt most if they broke, for example login, checkout, and payment. Trace each one end to end before any test is written, because the trace tells you which edge cases the tests must pin.
Step 3: Pin current behaviour with characterization tests
Section titled “Step 3: Pin current behaviour with characterization tests”A characterization test records what the code does today, not what it should do. If a refactor changes a result, the test goes red, and you know behaviour moved. The general technique (golden masters, approval tests, choosing seams) is in characterization tests; here is the Cursor prompt for one module.
Read the tests, not the implementation. You are checking that each assertion is exact and that the edge cases from the trace are there. Then prove the suite is an oracle and not decoration:
-
Run the suite twice in a row. A test that passes once and fails once is flaky; fix or delete it now, or it will cause false alarms in every later step.
-
Break the code on purpose. Change one status code or one price calculation in
src/, run the suite, and confirm at least one test fails. Revert the change withgit checkout -- src/. -
For a stronger signal on the modules you will touch first, run a scoped mutation test. Stryker 10.0.0 (npm, checked 2026-09-26) changes the code in small ways and reports every change your tests missed:
Terminal window # Terminal, repo rootnpm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runnernpx stryker run --testRunner jest --mutate src/controllers/orders.js --concurrency 1--concurrency 1runs one test runner at a time. Stryker runs several in parallel by default, and parallel mutants writing to one shared test database produce survivors and kills that have nothing to do with your tests. If you want parallel runs, give each Stryker worker its own database instead.A surviving mutant in code you plan to change means the tests cannot see that change. Write one extra test per surviving mutant in
tests/characterization/, re-run the suite twice, and only then commit in the next step. If a mutant cannot change observable behaviour (an equivalent mutant), note it in the Stryker report instead of writing a test; no test can kill it.Add
reports/,stryker.logand.stryker-tmp/to.gitignorebefore the commit in the next step. -
Commit the tests on their own:
git commit -m "test: pin current order behaviour". Note the commit hash; this isBASELINEin every gate below.
How strong is your oracle explains what score to aim for.
Step 4: Convert callbacks one function at a time
Section titled “Step 4: Convert callbacks one function at a time”Use inline edit on a single selected function, so the edit is aimed at that selection; the git status --short gate below confirms it stayed there. Select a single callback-based function and give it this instruction:
If the function leans on module-level state or helpers outside the selection, inline edit may miss them. Switch to the agent for that function and reference the whole file with @. After each function, run the gate:
# Terminal, repo root. BASELINE is the commit that added the characterization tests.git diff --exit-code BASELINE -- tests/characterization/ # the tests were not touchednpx jest tests/characterization # behaviour did not movegit status --short # only the intended file changed, and no new untracked filesgit status --short rather than git diff --stat, because the diff ignores untracked files, and a new file is exactly what an over-eager agent leaves behind. All three pass, commit. Any one fails, run git restore . && git clean -n to discard the edits and list the untracked files, then git clean -f after checking that list. Redo that function with a narrower selection or a more explicit instruction. One function per commit feels slow, but each commit is trivially reviewable and bisectable. For the general refactoring patterns behind these moves, see refactoring strategies.
Step 5: Extract a service layer, one module per run
Section titled “Step 5: Extract a service layer, one module per run”Once a module is async, move its business logic out of the route handler. This is a multi-file change, so use the agent, and fence the scope in the prompt.
Run the same gate as in Step 4 (git status --short must list only the three named files), and add a cycle check, because extraction is where circular imports appear:
# madge 8.0.0 (npm, checked 2026-09-26): exits 1 and prints 'Found … circular dependency' when a cycle existsnpx madge --circular src/Step 6: Add TypeScript from the bottom up
Section titled “Step 6: Add TypeScript from the bottom up”Convert models first, then services, then controllers, then routes. Each layer depends on the one below it, so typing the foundation first gives the upper layers real types to check against.
Set up the toolchain before the first rename, or the gate goes red for a toolchain reason rather than a behaviour reason: Jest cannot load a .ts module and Node cannot require one.
# Terminal, repo root. ts-jest 29.4.14 accepts TypeScript >=4.3 <7 and npm's latest# TypeScript is 7.0.2 (both checked 2026-09-26), so pin TypeScript 6.npm install --save-dev typescript@6 @types/node @types/express @types/jest ts-jest tsxnpx tsc --initnpx ts-jest config:initThen edit tsconfig.json:
- Set
allowJs: trueso.jsand.tsfiles coexist,checkJs: falseso untouched JavaScript is not type-checked yet, andnoEmit: truesotsconly checks. Keepstrict: true. - TypeScript 6’s
tsc --initwrites"types": []andverbatimModuleSyntax: true. Change them to"types": ["node", "jest"]and, for a CommonJS codebase that usesrequire,verbatimModuleSyntax: false. - Add
"rootDir": "."; without it ts-jest stops with error TS5011.
ts-jest config:init writes a jest.config.js whose transform covers .ts files only, so the existing .js files load as before. If the project already has a Jest config, add the ts-jest transform to it instead. For the app, start it with tsx (for example npx tsx src/server.js in the start script), which resolves an extensionless require('./models/order') to the renamed .ts file; a tsc build step that emits to dist/ works too.
Run npx jest tests/characterization and npx tsc --noEmit. Both must pass before any file is renamed; commit this setup on its own (chore: add TypeScript toolchain), so a later red test points at a conversion, not at the toolchain.
The TYPE-FINDING comments are your bug backlog. They go into step 7, not into this commit.
Step 7: Fix bugs and slow queries in separate commits
Section titled “Step 7: Fix bugs and slow queries in separate commits”Now change behaviour on purpose. Take one PINNED: looks like a bug test or one TYPE-FINDING at a time: change the test expectation first and run the suite to watch it fail, so you know the test sees the bug. Then ask the agent to make it pass, and commit the test change and the fix together under a fix: message that names the old and the new behaviour. Do not commit the red test on its own: it breaks the “every commit is bisectable” promise from Step 4, and it fails CI on the branch. The reviewer still reads the test change first, because the pre-PR evidence below lists every commit that changed a test expectation, and that change is a one-line diff under tests/characterization/ stating the new behaviour. After each fix: commit, point BASELINE at that commit (for example git tag -f refactor-baseline) so the per-step gate guards the new expectation; the pre-PR evidence prompt still compares against the original characterization commit.
Query work splits the same way. An N+1 query or a full-table read can be fixed without changing what a function returns, so the characterization tests still guard it. A missing WHERE clause on an UPDATE is a bug fix and follows the red-test route above. For query profiling after the refactor, see performance optimization.
How do you prove the refactor without reading every line?
Section titled “How do you prove the refactor without reading every line?”The reviewer’s job becomes checking evidence, not diffs. Ask the agent to assemble it before you open the pull request.
Paste the table into the pull request description. The reviewer approves when every row passes, reads only the commits that changed a test on purpose, and spot-checks any file flagged in row 4. The developer signs off on the characterization tests in step 3; the tech lead signs off on the evidence table. An automated reviewer such as Bugbot can add a second pass on the diff; it does not replace the pinned tests. The full format for this record is the evidence bundle.
Tests only cover the paths you pinned. For a high-traffic service, ship the refactored path behind a feature flag and run it in shadow mode: serve the old response, compute the new one, log any difference, and cut over only after a period with no differences you cannot explain. Keep the old path in the code until then, so rollback is a flag change, not a deploy.
What breaks in a Cursor legacy refactor, and how do you recover?
Section titled “What breaks in a Cursor legacy refactor, and how do you recover?”The agent “improves” code during a mechanical step. It adds validation, rewrites an error message or fixes an edge case while converting a callback. Recovery: the characterization tests catch changed responses; git status --short catches changed and new files. Run git restore . && git clean -n, then git clean -f after checking the list, and repeat the prompt with the forbidden changes listed (“no validation, no new messages”).
The agent edits a test to get to green. Recovery: git diff --exit-code BASELINE -- tests/characterization/ exits non-zero. Restore the tests with git checkout BASELINE -- tests/characterization/ and re-run the prompt with “the test directory is read-only”. To stop it happening at all, add a CI job that fails the pull request when files under tests/characterization/ change without a test: or fix: commit; protecting the oracle shows the patterns.
The agent rewrites half the repository. A prompt that references @src for a change, not a question, invites a sweeping edit. Recovery: git restore . and git clean -n, then git clean -f after checking the list. Re-run with an explicit list of files the agent may change.
Tests pass, production breaks. The input that triggers the difference was never pinned. Recovery: flip the feature flag back, capture the failing request from logs, add it as a characterization test against the old code, then redo the step.
A circular import appears after extraction. Two services now import each other. Recovery: npx madge --circular src/ names the cycle. Ask the agent, in Plan Mode, to propose where the shared types or functions should live, then move them to a separate module in their own commit.
Characterization tests are flaky. Time, random IDs, or test-order dependence make the oracle lie. Recovery: freeze the clock and seed the random source in the fixture, give each test its own data, and re-run the suite twice before trusting it again.