CCLearn

Refactoring safely: pin the behaviour first

verified against Claude Code 2.1.261 · 2026-09-0916 min

By the end you can

  • Make the behaviour a refactor must not break executable before the refactor starts
  • Choose how hard the check gates the stop for an unattended refactor
  • Sequence a change that spans several packages so the decisions stay consistent
  • Demand evidence from both sides of the change rather than an assertion that it held
  • Keep the plan alive across a session long enough to compact
  • Build a check for behaviour a test suite does not observe, and know where that inference breaks down

The refactor came back. Forty files, one shared type threaded through six packages, and a closing message saying behaviour is preserved. The suite is green — the same suite that was green before you started, because none of it ever ran the branch you just rewrote.

Nothing here failed. That is the problem. Level 5 taught you to give the session something that can come back no, and a refactor is the case where that is hardest to do, because the thing you want protected is not new behaviour. It is the behaviour that is already there, mostly undocumented, and about to move.

Why a refactor breaks the Level 5 recipe

Claude stops when the work looks done. Without a check it can run, "looks done" is the only signal available, and you become the verification loop. — Best practices

For a feature the check writes itself: the new thing either works or it does not. For a refactor the specification is behave exactly as before, and there is no new behaviour to demonstrate. Two comfortable signals arrive instead, and neither is a check. The existing suite passing covers what it covered yesterday, and the uncovered paths are the ones nobody thought about — a fair description of the paths a refactor quietly changes. Clean diagnostics from Lesson 11.2's language server tell you the code type-checks; they have no opinion about what it does. Both are true and both are compatible with a broken result: Lesson 5.5's trust-then-verify gap in the costume that hides it best.

Say it in the prompt — then make it executable

The documented refactor recipe puts the constraint in the request, and you should too:

text
refactor utils.js to use ES2024 features while maintaining the same behavior

That clause — while maintaining the same behavior — is the documented phrasing (Common workflows), and it is worth typing. But be clear about what it is: an instruction, not a check — it does not reach rung one of Lesson 5.2's ladder, which already has Claude running a check and iterating on the result. Instructions cannot fail. Nothing in the session will come back and say the behaviour moved.

The documented shape for making a claim executable comes from the bug-fix prompt, and the ordering is the whole point:

write a failing test that reproduces the issue, then fix it — Best practices

The test exists first, and is seen failing first. Apply that order to a refactor and you get the practice this course calls a characterization test: a check that records what the code does now, is watched passing against the untouched code, and is then run again after the move.

Lesson 5.1 established what counts as a check, and the documented list of instruments is short: "a test suite, a build exit code, a linter, a script that diffs output against a fixture, or a browser screenshot compared against a design" (Best practices). Point each one at old behaviour rather than new. The right-hand column is the part people skip:

What is about to moveInstrumentSeen to work when
A pure functionA test suite over its real inputs, taken from callers or logsIt passes against the current code
A CLI or a transformA script that diffs its output against a fixture captured todayToday's diff is empty
A request handlerThe same fixture diff, over recorded requests and responsesThe replay is clean before you touch anything
A rendered viewA screenshot compared against the view as it stands nowThe comparison finds no differences today

That column exists because a check you have only ever seen pass after the change is indistinguishable from a check that cannot fail.

The trap: tests written from the refactored code

Refactor first, then ask for tests, and you get tests derived from the code as it now stands. They pass on the first run. They will keep passing. What they encode is whatever the new code happens to do — including the case it now gets wrong — and because the suite was never observed failing, nothing has been demonstrated except that the code agrees with itself.

Lesson 5.4 handed over the failing test before the fix so that green meant the code moved. The refactor version is stricter: green has to mean the code did not move. Either way the check has to predate the change.

Small increments, each one verified

The documented recipe runs in four steps — identify the legacy code, get recommendations, apply the change while maintaining the same behavior, and then run tests for the refactored code. The fourth is a step of its own, not a clause tacked onto the third. One of the tips attached to the recipe: "Do refactoring in small, testable increments" (Common workflows).

"Testable" is doing the work in that sentence. An increment is the right size when the check can run at the end of it. Ten files converted in one turn have one verdict between them; ten increments have ten, and the first red one names the conversion that was wrong.

Increments also give you the cheapest sentence in Lesson 2.3 for free. Once the first module is converted and its check is green, stop describing the target style and point at it:

text
convert the remaining files in src/adapters/ the same way you converted
src/adapters/stripe.ts. after each file, run the recorded-fixture diff for
that adapter and paste the result before moving to the next one.

You have replaced a description of the intended shape with a reference to an instance of it, and you have named where the check runs.

How hard the check should gate the stop

Lesson 5.2's ladder applies unchanged; what changes is how easy it is to write a condition that cannot be judged. For a refactor you leave running:

Across a session: set the check as a /goal condition. A separate evaluator re-checks it after every turn and Claude keeps working until the goal resolves. If Claude stalls, Claude Code eventually stops the run with the goal still set. — Best practices

and the constraint that decides whether your condition is any good:

The evaluator judges your condition against what Claude has surfaced in the conversation. It doesn't run commands or read files independently. — Keep Claude working toward a goal

So "behaviour is unchanged" is unjudgeable — nothing in a transcript demonstrates it. "pnpm test passes and scripts/golden-diff.sh prints no differences" is judgeable, because Claude runs both and the output lands where the evaluator reads.

Two things the docs are explicit about before you walk away from a goal. It does not change your permission mode: to let goal turns run unattended you run /goal in auto mode, because in Manual mode Claude still asks before tool calls your settings do not already allow — your test command included. And the loop has a floor: if Claude keeps answering the evaluator without using tools for several turns in a row, Claude Code stops the loop, prints a warning, and hands control back to you with the goal still set.

One fact to hold about the deterministic rung, since a refactor is exactly the job that fails the same way repeatedly: a Stop hook is a gate with a ceiling. After 8 consecutive blocks Claude Code overrides it and the turn ends anyway, with your characterization suite still failing — Lesson 9.4 has the mechanics and the setting that moves the cap.

A change that spans packages

The shared-type refactor — one edit plus every call site — has two documented handling rules, and both are about sequencing rather than tooling:

Give Claude the whole change in one session: handing over the shared edit and its call sites together keeps the decisions behind each edit consistent, rather than re-deriving them per package — Set up Claude Code in a monorepo or large codebase

Splitting it per package looks tidier and costs more: each session re-derives the decisions from a different starting context instead of carrying them across, which is the inconsistency the rule exists to prevent.

The second rule is why a single long session survives being long. Lesson 3.2's compaction table said anything on disk comes back and anything only in the conversation is summarised with loss. Plan first in plan mode and Claude writes the plan to a file, which puts it on the winning side of that table:

Claude Code re-injects the plan file after each compaction, so the plan survives where conversation history may not — Set up Claude Code in a monorepo or large codebase

Which gives the practical instruction: for a cross-package refactor, the increments and their checks belong in the plan file, not in a message you sent three hours ago.

Evidence, not assertion

Have Claude show evidence rather than asserting success: the test output, the command it ran and what it returned, or a screenshot of the result. — Best practices

A refactor needs that from both sides of the change: the check's output from before the edits and after, in the same message, so you can read them against each other. A single green block at the end is equally compatible with a suite written after the fact, a suite that skipped, and a suite that never exercised the moved code — and you cannot tell which by looking at it.

The rule underneath is stated without qualification. The documented fix for the trust-then-verify gap is "always provide verification (tests, scripts, screenshots). If you can't verify it, don't ship it" (Best practices).

When tests are not the right instrument

Sometimes the behaviour you are protecting is not the kind a suite observes: the app starts, the page renders, the daemon comes up with its config intact. There is a bundled skill for exactly that:

Confirm a code change does what it should by building your project's app, running it, and observing the result, rather than relying on tests or type checks. — Commands

The best-practices guide puts /verify on your side of the loop: run it yourself after Claude's own check passes, to confirm the change against the running app. That is now the tool's own rule and not just advice — /verify "runs only when you invoke it", where "before v2.1.215, Claude could also run /verify on its own" (Commands). The skill no longer fires on its own initiative: if you want the change checked against the running app, you invoke it.

One caveat before you lean on it. /verify infers the launch from your project type and from what is in your README, package.json or Makefile, and that inference "gets unreliable for projects that need anything beyond a standard launch: a database, an env file, a graphical session, a multi-step build" (Skills). For those, run /run-skill-generator once per project to record the recipe as a per-project skill, so /run, /verify and any other agent in the repo follow it instead of rediscovering it.

What actually makes a refactor safe

Not the promise in the prompt, and not the tooling. Increments, goals, the plan file and /verify are all in service of one thing: a signal that existed before you started, was watched passing against the old code, and can still come back red while the code moves underneath it.

Checkpoint

  1. A teammate reports a refactor with 94% coverage, all green, and asks you to approve it. What single question tells you whether the suite is evidence or decoration?

    Show answer

    "Did this check exist before the refactor, and was it watched passing against the untouched code?" A check you have only ever seen pass after the change is indistinguishable from a check that cannot fail, and neither the percentage nor the colour tells you which one you are looking at.

    The trap the question is built on: refactor first, then ask for tests, and you get tests derived from the code as it now stands. They pass on the first run and will keep passing, and what they encode is whatever the new code happens to do — including the case it now gets wrong. The existing suite passing has the same problem from the other end: it covers what it covered yesterday, and the uncovered paths are the ones nobody thought about, which is a fair description of the paths a refactor quietly changes. Ask for the check's output from both sides of the change, in the same message.

  2. You want to leave a six-package rename running under a /goal condition. Rewrite "the rename is complete and nothing broke" into something the evaluator can actually judge, and say why the original fails.

    Show answer

    Name the commands and let their output do the work — something like "pnpm test passes, scripts/golden-diff.sh prints no differences, and a search for the old name across the six packages returns no matches; paste each command's output". Every clause is now something Claude runs, so the result lands in the conversation where the evaluator reads it.

    The original fails on both halves. The evaluator judges your condition against what Claude has surfaced in the conversation — it does not run commands or read files independently — so "nothing broke" is unjudgeable, because nothing in a transcript demonstrates it, and "the rename is complete" is a claim Claude can simply assert. Two things to settle before you walk away: a goal does not change your permission mode, so run /goal in auto mode or Manual will still stop and ask before the test command; and the loop has a floor — if Claude answers the evaluator without using tools for several turns running, Claude Code stops the loop, prints a warning and hands control back with the goal still set.

  3. A refactor moves a file with git mv, then rewrites the imports with Claude's edit tools. Halfway through you want everything back. Which half does /rewind restore, and what should you have done before the run started?

    Show answer

    Only the import rewrites. Those went through Claude's own file editing tools, which is the one channel a rewind covers. git mv is a bash command, and a rewind takes back nothing a bash command did — so you land in the worst of both states: the file still sitting in its new location, with the imports pointing back at the old one. Git is the undo for that half.

    Commit before the run started. Refactors attract exactly the mechanisms rewind cannot touch — a codemod, a mass rename, a sed sweep, and a mass rename handed to a general-purpose subagent, which is a subagent edit and so also git's problem. Commit before the run, not after it goes wrong; Lesson 11.4 has the full boundary.

  4. The behaviour you are protecting is that the daemon comes up with its config intact, and it needs a database and an env file to start at all. Say what /verify does with that, who has to invoke it and what v2.1.215 changed about that, and the one command that stops it guessing.

    Show answer

    /verify is the right instrument and the wrong one to trust unaided here. It confirms a change by building your project's app, running it and observing the result rather than relying on tests or type checks — which is what you need, because "the daemon comes up with its config intact" is not behaviour a suite observes. But it infers the launch from your project type and from what is in your README, package.json or Makefile, and that inference gets unreliable for projects needing anything beyond a standard launch: a database, an env file, a graphical session, a multi-step build. Yours names two of the four, so expect it to guess.

    You invoke it — it runs only when you do, and the best-practices guide puts it on your side of the loop, after Claude's own check has passed. That is what v2.1.215 changed: before it, Claude could also run /verify on its own initiative, so the skill no longer fires without you.

    The command that stops the guessing is /run-skill-generator, run once per project to record the launch recipe as a per-project skill, so /run, /verify and any other agent in the repo follow it instead of rediscovering it.

Sources