Skip to content

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 Stop hook 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 /rewind cannot 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 changeExecutionCanonical page
Restructure code across many files with no behaviour change: extract a module, enforce layers, callbacks to async/await, JavaScript to TypeScriptOne session, batch by batch, in dependency orderThis page
One mechanical edit repeated across independent files/batch, a claude -p loop or a codemodBatch operations
A rename whose callers must change in the same commitOne planned sessionMulti-file workflows
A library, framework or schema upgradePhased migrationDatabase and code migrations
One pattern in one file, such as extract methodTest-first loopRefactoring patterns
A whole legacy system, over monthsA modernization programLegacy 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:

  1. Characterization tests record what the code does today, including its odd behaviour. See characterization tests for how to generate them without locking in bugs.
  2. 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-cruiser npm package, v18.4.0 on 2026-09-26, whose command is depcruise).
  3. An API report, for packages other teams import. API Extractor (@microsoft/api-extractor, v7.59.2) writes the exported surface to an .api.md file. Install it with npm install --save-dev @microsoft/api-extractor, then run npx api-extractor init to create api-extractor.json. The unscoped api-extractor package on npm is an empty placeholder, so npx api-extractor does 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 --noEmit across 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”
  1. 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.

  2. Inventory in plan mode. Start Claude with claude --permission-mode plan, or type /plan in 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 to docs/refactor/auth-module.md, so the plan survives /clear.

  3. Write the architecture rule. Install the tool with npm install --save-dev dependency-cruiser, then run npx depcruise --init once if the repository has no config. Init writes .dependency-cruiser.js in a CommonJS package and .dependency-cruiser.cjs in an ESM package; this page uses the .cjs name, so if init wrote the .js file, use that name in the rule and in every --config flag below. Install first: without the dev dependency, npx depcruise downloads the unrelated depcruise package, 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.cjs and note the violation count. It reaches zero when the extraction is done.

  4. 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.

  5. 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.json if 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 match Edit(path) and Read(path) rules only, and a Write(path) rule is never matched. An Edit deny rule also covers the file commands Claude Code recognizes in Bash, such as sed and tee, and redirections such as > file. It does not cover a script that opens files itself, such as a Node codemod or a python -c one-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.ts rule and rely on check 1b of the verification script, which accepts test diffs that change import lines only.

  6. 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 Stop and PostToolUse, stdout from a hook that exits 0 goes to the debug log, and Claude never sees it. A hook such as npx tsc --noEmit | head -20 therefore always exits 0 through head, 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. cwd also follows any cd Claude runs, so resolve the repository root from it with git 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 0
if ! 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 2
fi

The 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.

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 branch
set -euo pipefail
BASE=${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 1
fi
# 1b. Tests: a renamed or edited test may differ in import lines only
if 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 1
fi
# 2. Behaviour: types and the full suite, including the characterization tests
npx tsc --noEmit
npx jest --ci
# 3. Structure: the target architecture holds, zero violations
npx depcruise src --config .dependency-cruiser.cjs
# 4. Completeness: nothing still reaches into the module's internals
if git grep -nE "from ['\"][./]*.*modules/auth/(service|middleware|types|constants)['\"]" -- src ':!src/modules/auth'; then
echo "Deep imports into src/modules/auth remain"; exit 1
fi
# 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 placeholder
if [ -f api-extractor.json ]; then npx api-extractor run --print-api-report-diff; fi

Then 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:

WhoSigns off on
The developer who ran the refactorThe plan, the behaviour list the plan excluded, and every stop the agent reported
CIThe verification script and the full suite on every pull request
The reviewerThe oracle pull request line by line; on refactor pull requests, only the flagged sites and the check output
The tech leadBatch 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.

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”