Large-scale refactoring with Claude Code: change structure, prove behaviour
Large-scale refactoring with Claude Code changes the structure of dozens or hundreds of files, such as extracting a module or converting JavaScript to TypeScript, while behaviour stays identical. It works when behaviour is pinned with tests first, the target structure is encoded as an architecture rule, and gates, not reviewers, prove each batch.
This page is for developers who own a refactor that spans a codebase and for tech leads who have to merge it. The situation: the utils directory has 47 files, half the code uses callbacks and half uses async/await, and authentication logic is spread across six files. The last manual rename missed one dynamic call site, and the bug report arrived on a Friday afternoon.
Everything below was checked against Claude Code v2.1.283 (the latest channel) on 2026-09-26.
What you get from a gated large-scale refactor
Section titled “What you get from a gated large-scale refactor”- A decision table that sends each kind of change to the right mechanism and the right page.
- Three oracles the agent cannot edit: characterization tests, an architecture rule and, for libraries, an API report.
- A
Stophook that keeps Claude working until the type check and tests pass. - Copy-paste prompts for the refactors teams postpone longest: module extraction, callbacks to async/await, and JavaScript to TypeScript.
- A verification script that proves scope, structure and behaviour without reading every line.
- Recovery steps for missed call sites, edited tests, hook loops and codemods that
/rewindcannot undo.
Which kind of large change are you making?
Section titled “Which kind of large change are you making?”A refactor changes structure and keeps behaviour. Several neighbouring pages cover changes that look similar but need a different workflow, so check the table before you start.
| Your change | Execution | Canonical page |
|---|---|---|
| Restructure code across many files with no behaviour change: extract a module, enforce layers, callbacks to async/await, JavaScript to TypeScript | One session, batch by batch, in dependency order | This page |
| One mechanical edit repeated across independent files | /batch, a claude -p loop or a codemod | Batch operations |
| A rename whose callers must change in the same commit | One planned session | Multi-file workflows |
| A library, framework or schema upgrade | Phased migration | Database and code migrations |
| One pattern in one file, such as extract method | Test-first loop | Refactoring patterns |
| A whole legacy system, over months | A modernization program | Legacy modernization |
Why the distinction matters: in a batch operation, units are independent and can run in parallel. In a structural refactor, batches usually depend on each other, because moving a type breaks every file that imports it. Serial execution in dependency order is the default here, and parallelism is the exception.
Why does a structural refactor need its own oracles?
Section titled “Why does a structural refactor need its own oracles?”Tests prove behaviour, but they say nothing about structure. After a module extraction, the suite can be green while half the codebase still imports src/modules/auth/service.ts directly and bypasses the public entry point. The agent also has an easy way to turn a red suite green: it edits the test.
So a large refactor runs against three oracles, all written before the first production file changes:
- Characterization tests record what the code does today, including its odd behaviour. See characterization tests for how to generate them without locking in bugs.
- An architecture rule describes the target structure and fails until the refactor is complete. Its violation count is your progress meter. Architecture fitness functions cover the general technique; this page uses dependency-cruiser (the
dependency-cruisernpm package, v18.4.0 on 2026-09-26, whose command isdepcruise). - An API report, for packages other teams import. API Extractor (
@microsoft/api-extractor, v7.59.2) writes the exported surface to an.api.mdfile. Install it withnpm install --save-dev @microsoft/api-extractor, then runnpx api-extractor initto createapi-extractor.json. The unscopedapi-extractorpackage on npm is an empty placeholder, sonpx api-extractordoes nothing useful without the scoped dev dependency. In a CI run (without--local), a changed surface fails the run instead of silently updating the report. For application code,npx tsc --noEmitacross every consumer does the same job.
Land these oracles as their own pull request and merge it before the refactor branch starts. Then the refactor branch has one clean rule: it changes no test body, snapshot or rule file.
Set up the refactor: from green baseline to a gated session
Section titled “Set up the refactor: from green baseline to a gated session”-
Start from green. Run the type check, linter and full test suite on a clean branch. If anything is already red, fix it or record it first. A refactor measured against a broken baseline can prove nothing.
-
Inventory in plan mode. Start Claude with
claude --permission-mode plan, or type/planin a running session (or press Shift+Tab until the mode indicator reads plan), so it can read and search but not edit. Paste the planning prompt below and save the approved plan todocs/refactor/auth-module.md, so the plan survives/clear. -
Write the architecture rule. Install the tool with
npm install --save-dev dependency-cruiser, then runnpx depcruise --initonce if the repository has no config. Init writes.dependency-cruiser.jsin a CommonJS package and.dependency-cruiser.cjsin an ESM package; this page uses the.cjsname, so if init wrote the.jsfile, use that name in the rule and in every--configflag below. Install first: without the dev dependency,npx depcruisedownloads the unrelateddepcruisepackage, a dependency-confusion placeholder that prints a warning, exits 0 and creates nothing. Add the target structure to the config file. This rule makes the auth module reachable only through its public entry point:.dependency-cruiser.cjs module.exports = {forbidden: [{name: 'auth-public-api-only',comment: 'Import the auth module through src/modules/auth/index.ts only.',severity: 'error',from: { pathNot: '^src/modules/auth/' },to: { path: '^src/modules/auth/', pathNot: '^src/modules/auth/index\\.ts$' },},],options: { tsConfig: { fileName: 'tsconfig.json' } },};Run
npx depcruise src --config .dependency-cruiser.cjsand note the violation count. It reaches zero when the extraction is done. -
Add characterization tests where coverage is thin. The plan’s inventory names the files with no tests. Pin their current output before any batch touches them. In the same pull request, repoint every test that imports auth internals to import through
src/modules/auth/index.ts, so the refactor never has to touch a test when files move. Keep test files where they are during the refactor, then merge the oracle pull request. -
Put the oracles out of the agent’s reach. On the refactor branch, add deny rules to
.claude/settings.local.json(or to the shared.claude/settings.jsonif the whole team works the refactor):{"permissions": {"deny": ["Edit(**/*.test.ts)","Edit(**/__snapshots__/**)","Edit(/.dependency-cruiser.cjs)","Edit(**/*.api.md)"]}}Use
Edit(...)rules for paths: in v2.1.283, file permission checks matchEdit(path)andRead(path)rules only, and aWrite(path)rule is never matched. AnEditdeny rule also covers the file commands Claude Code recognizes in Bash, such assedandtee, and redirections such as> file. It does not cover a script that opens files itself, such as a Node codemod or apython -cone-liner. In auto mode (the starting mode in v2.1.283) the classifier also tries to block such workarounds, but it is probabilistic, so keep checks 1a and 1b of the verification script and the sandbox as the enforcement. The verification script later catches that case, and the sandbox enforces a path at the OS level. If some tests import internals you could not repoint in the oracle pull request, drop the*.test.tsrule and rely on check 1b of the verification script, which accepts test diffs that change import lines only. -
Add the stop gate. Configure the hook in the next section, so Claude cannot end a turn while the type check or the tests are red.
Make Claude finish only when the gates are green
Section titled “Make Claude finish only when the gates are green”A Stop hook runs when Claude finishes its response. Exit code 2 prevents the stop and shows your stderr to Claude, so it keeps working on the failure. Two details from the v2.1.283 hooks reference make the difference between a gate and a decoration:
- On
StopandPostToolUse, stdout from a hook that exits 0 goes to the debug log, and Claude never sees it. A hook such asnpx tsc --noEmit | head -20therefore always exits 0 throughhead, and its errors never reach the agent. Write failures to stderr and exit 2. ${CLAUDE_PROJECT_DIR}keeps pointing at the project root where the session started, the main checkout, when a session enters a worktree (hooks reference). Read the session’s working directory from the hook input (cwd) instead.cwdalso follows anycdClaude runs, so resolve the repository root from it withgit rev-parse --show-toplevel.
#!/usr/bin/env bash# .claude/hooks/refactor-gate.sh: Stop hook. Claude cannot end its turn while a gate is red.input=$(cat)
# Already blocked once in this stop sequence: let Claude stop and report, instead of looping.[ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ] && exit 0
cd "$(git -C "$(jq -r '.cwd' <<<"$input")" rev-parse --show-toplevel)" || exit 0if ! out=$( { npx tsc --noEmit && npx jest --ci --changedSince=origin/main; } 2>&1 ); then printf '%s\n' "$out" | tail -n 40 >&2 echo "Refactor gate is red. Fix the production code, not the tests, then finish." >&2 exit 2fiThe examples on this page use Jest. With Vitest, replace the Jest command with npx vitest run --changed origin/main.
Register it on the refactor branch, and make it executable with chmod +x .claude/hooks/refactor-gate.sh:
{ "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/refactor-gate.sh" } ] } ] }}${CLAUDE_PROJECT_DIR} resolves to the main checkout, so the script must also exist on the branch checked out there. If the refactor runs in a worktree while the main checkout stays on main, commit the hook script to main first. Otherwise the path does not exist, the hook fails as a non-blocking error, and the gate silently disappears.
The gate leaves out the architecture rule on purpose. The rule is red for the whole refactor until the last batch lands, so it belongs in the progress report and the final verification, not in a check that runs after every turn. Hooks automation covers the other events, including a PostToolUse type check after each edit. On a large repository, that check can cost more time than it saves.
Run the refactor batch by batch
Section titled “Run the refactor batch by batch”Execute one batch per turn, commit it, and start the next from the saved plan. The Stop gate keeps each turn honest, and one commit per batch makes every batch revertible and bisectable on its own.
After two or three batches, run /clear and restart from the plan file with finished batches marked done. Compaction in a long session summarises earlier decisions, and the plan file is the durable record.
When a batch is purely syntactic, such as rewriting 200 import paths after a move, ask Claude to write a codemod instead of editing each file. You then review 30 lines of ast-grep rule or jscodeshift transform, not 200 edited files. A codemod runs through the shell, so /rewind cannot undo it: commit before you run one. When batches really are independent, such as one directory per unit with no shared files, hand them to /batch, as batch operations describes.
Prompts for the refactors teams postpone longest
Section titled “Prompts for the refactors teams postpone longest”Each prompt assumes the setup above: an approved plan, oracles merged, deny rules and the stop gate active.
For a legacy pattern that cannot be replaced in one branch, such as homegrown validation spread across 30 files, use a strangler fig. Build the new path next to the old one, run both and log every disagreement, switch callers once the log is empty, and delete the old path in a final pull request. The disagreement log is the behavioural oracle, so review its contents, not the diff that produced it.
How do you prove a refactor changed nothing without reading every diff?
Section titled “How do you prove a refactor changed nothing without reading every diff?”A 150-file refactor is past the point where line-by-line review is honest. Replace reading with checks that fail loudly, then read only where they point. Run this script on the refactor branch, locally or as a CI job:
#!/usr/bin/env bash# verify-refactor.sh: run from the repo root on the refactor branchset -euo pipefailBASE=${BASE:-origin/main}
# 1a. Oracle intact: no snapshot, rule or API report was added, changed, moved or deleted# (--no-renames reports a move as a delete plus an add, so a moved snapshot is caught too)if git diff --name-only --no-renames "$BASE"...HEAD -- '*__snapshots__/*' .dependency-cruiser.cjs '*.api.md' | grep .; then echo "Oracle files changed on the refactor branch: review these by hand"; exit 1fi
# 1b. Tests: a renamed or edited test may differ in import lines onlyif git diff -U0 -M "$BASE"...HEAD -- '*.test.ts' | grep -E '^[+-][^+-]' | grep -vE '^[+-]\s*(import |\} from )'; then echo "Test bodies changed on the refactor branch: review these by hand"; exit 1fi
# 2. Behaviour: types and the full suite, including the characterization testsnpx tsc --noEmitnpx jest --ci
# 3. Structure: the target architecture holds, zero violationsnpx depcruise src --config .dependency-cruiser.cjs
# 4. Completeness: nothing still reaches into the module's internalsif git grep -nE "from ['\"][./]*.*modules/auth/(service|middleware|types|constants)['\"]" -- src ':!src/modules/auth'; then echo "Deep imports into src/modules/auth remain"; exit 1fi
# 5. Public surface (library packages only): fails if the exported API changed# Needs the @microsoft/api-extractor dev dependency; the unscoped npx name is a placeholderif [ -f api-extractor.json ]; then npx api-extractor run --print-api-report-diff; fiThen point one reviewer agent at correctness. Run /code-review on the branch. It reviews the diff for correctness bugs, not style. Review automation puts the same check on every pull request.
Sign-off splits by role:
| Who | Signs off on |
|---|---|
| The developer who ran the refactor | The plan, the behaviour list the plan excluded, and every stop the agent reported |
| CI | The verification script and the full suite on every pull request |
| The reviewer | The oracle pull request line by line; on refactor pull requests, only the flagged sites and the check output |
| The tech lead | Batch size and merge order, agreed before the first batch, so the team is not rebasing onto a moving target |
A tech lead measures the refactor by what it removed, not by lines changed. Useful numbers are the violation count reaching zero, the count of any types or callback signatures left, and the size of the module’s public entry point. Keeping an agent-written codebase healthy turns those into trends. For context, GitClear’s June 2026 report “The Maintainability Gap” (secondary source) says moved or refactored code fell from 13% of changed lines in 2023 to 3.8% in 2026. The study is correlational, but it matches the drift that makes these refactors necessary.
What breaks in a large-scale refactor, and how do you recover?
Section titled “What breaks in a large-scale refactor, and how do you recover?”A rename or move missed dynamic call sites
Section titled “A rename or move missed dynamic call sites”Symptom: everything is green, and production fails with undefined is not a function on a path the tests never exercise. Cause: access through obj[name], a string in a config file, a DI token or a template. The type checker cannot see these. Recovery: revert the batch with git revert, then ask Claude to search for the old name as a string in every file type, including YAML, JSON, SQL and templates. Add a characterization test for the path before you redo the batch.
The suite turned green because a test changed
Section titled “The suite turned green because a test changed”Symptom: check 1a or 1b of the verification script fails, or a snapshot file appears in the diff. Cause: the agent met a failing test and changed the expectation, often through a script that writes files itself, which the Edit deny rule does not cover. Recovery: restore the test from the main branch (git checkout origin/main -- src/auth/session.test.ts), rerun it, and treat the failure as a real behaviour change: fix the code, or move the change out of the refactor. Protecting the oracle covers stronger enforcement.
The stop gate loops or never lets Claude finish
Section titled “The stop gate loops or never lets Claude finish”Symptom: Claude repeats the same fix, or the turn never ends. Cause: a failure the agent cannot fix within the batch, such as a flaky test or a missing dependency. Recovery: the stop_hook_active check lets Claude stop after one blocked retry and report. Without that check, Claude Code still ends the turn after eight consecutive stop-hook continuations (v2.1.283), which is eight wasted fix attempts, not a safeguard to rely on. Fix the flaky test on the main branch, or shrink the batch. If the hook blocks every stop, check that it exits 2 only on real failures and that jq is installed.
/rewind did not undo a codemod
Section titled “/rewind did not undo a codemod”Symptom: you rewind to a checkpoint, and the files the codemod touched stay changed. Cause: checkpoints track Claude’s file edits, not files changed by Bash commands. Recovery: git restore . or git reset --hard to the last batch commit. Commit before every codemod run. Error recovery covers what checkpoints do and do not restore.
Async conversion changed behaviour the tests did not pin
Section titled “Async conversion changed behaviour the tests did not pin”Symptom: tests pass, but logs show errors swallowed or operations completing out of order. Cause: an await inside a loop that used to run callbacks in parallel, or a catch that logs instead of rethrowing. Recovery: add characterization tests at the service boundary that assert on ordering and error propagation, then rerun the batch. The async prompt above forbids both changes, so compare the batch against rules 2 and 3.
Teammates keep merging into the files you are moving
Section titled “Teammates keep merging into the files you are moving”Symptom: every rebase conflicts on the same barrel and router files. Recovery: merge small batches often instead of one long-lived branch, and land the shared files, such as index.ts and the router, in the first batch. For more than a week of work, agree on an owner for the affected directories during the move, or use the strangler fig so old and new code coexist.
Where to go next with large-scale refactoring
Section titled “Where to go next with large-scale refactoring”- Testing with Claude Code is the prerequisite: a refactor is only as safe as the suite that pins it.
- Refactoring patterns covers the single-file moves (extract method, replace conditional with polymorphism) that each batch is made of.
- Batch operations takes over when the units are independent and
/batchcan run them in parallel. - Large codebases and monorepos covers scoping Claude to one package of a repository too large to load.
- Custom subagents let you define a read-only research agent that maps call sites without filling the main context.
- The sibling pages for other tools are refactoring with Codex and refactoring strategies in Cursor.