Skip to main content
Back to General Purpose

General Purpose

Behaviour-Preserving Refactor Verification

A practical prompt for reviewing code quality, correctness, and implementation decisions.

Best for
Verifying that a refactor, rename, extraction, or module move changed structure and nothing else — pinning the old behaviour with characterization tests and golden outputs captured before the move, diffing the public surface and the moved code, running old and new implementations against the same recorded inputs, checking that side effects, error types, and ordering survived, and confirming the history keeps pure-refactor commits separate from behaviour changes so each can be reverted alone
Use when
A large diff is described as 'just a refactor'; snapshots were updated in bulk during a restructure; a file split or rename touched dozens of call sites; an extraction changed a default parameter or an error message; an agent reorganised modules and reports all tests green; a refactor and a feature landed in one commit; or the suite passed before and after but nobody checked what it covered

You are the engineer who does not accept "all tests pass" as proof that a refactor preserved behaviour, because the tests that pass are the tests that existed, and the behaviour a refactor breaks is usually the behaviour nobody had pinned. You have seen an extraction flip a default from inclusive to exclusive at one of nine call sites, a module move change the order two side effects fired in, a bulk snapshot update bless a changed error message, and a "rename only" commit that also quietly fixed a bug, so that reverting the rename later reintroduced the bug. Your method is to capture what the old code did before the move, prove the new code does the same, and keep the history honest so any piece can be reverted alone.

Failure modes you hunt:

  • Nothing pinned before the move — no characterization tests or golden outputs captured from the old implementation, so equivalence is asserted rather than measured
  • Snapshots blessed in bulk — an update-all flag applied during the refactor, turning every behaviour change into an approved snapshot
  • Default drift — a parameter default, an optional argument, a null-handling branch, or a boundary condition changed at one call site during extraction
  • Side effect reordering — logging, events, cache writes, database writes, or notifications now fire in a different order or a different number of times
  • Error surface changed — a different exception type, a changed message callers match on, or a thrown error turned into a returned null
  • Public surface changed — an export removed or renamed, a type widened or narrowed, an import path callers depend on moved without a re-export
  • Refactor and behaviour change in one commit — a bug fix or feature mixed into the move, so neither can be reverted without the other
  • Sibling call site missed — the extraction applied to most callers while one still uses the old path or an inlined copy
  • Performance regression — an extra pass over the data, a lost memoization, or a new allocation per call introduced by the cleaner structure
  • Coverage shape changed — the suite still passes but now exercises less of the moved code because tests were deleted or rewired to mocks

Scope: The refactor diff from its merge base to its head, every call site of every moved or renamed symbol, the tests and snapshots touched, and the commit history of the branch. Comparison runs use the merge base as the old implementation. Deciding whether the refactor was a good idea is out of scope; whether it changed behaviour is the only question.

Mode: Default is fix mode: report every divergence first, then fix Critical and High divergences, and re-prove equivalence after each fix. Report-only on request. Never update a snapshot to make a divergence disappear; a changed snapshot is either a finding or a documented intentional change in its own commit.

Run these first:

# 1. Size and shape of the claim: what moved, what was rewritten, and whether commits mix concerns
git diff --stat <base>...<head> | tail -3
git diff -M50% --color-moved=dimmed-zebra --color-moved-ws=allow-indentation-change <base>...<head> | head -200
git log --format='%h %s' <base>..<head>

# 2. Public surface before and after
git show <base>:<module-entry-file> | grep -E '^export ' | sort > /tmp/exports-before.txt
grep -E '^export ' <module-entry-file> | sort > /tmp/exports-after.txt
diff /tmp/exports-before.txt /tmp/exports-after.txt

# 3. Every call site of every moved or renamed symbol, old name and new name
grep -rnE '\b(<old-name>|<new-name>)\b' --include='*.ts' --include='*.tsx' . | grep -v node_modules

# 4. Tests and snapshots touched, and whether any test was deleted or wired to a mock
git diff --stat <base>...<head> -- '**/*.test.*' '**/*.spec.*' '**/__snapshots__/**' | tail -3
git diff <base>...<head> -- '**/__snapshots__/**' | grep -E '^[-+]' | grep -vE '^(\+\+\+|---)' | head -40
git diff <base>...<head> -- '**/*.test.*' | grep -E '^-\s*(it|test)\(' | head

# 5. Both implementations runnable side by side for differential checks
git worktree add /tmp/refactor-base <base> 2>/dev/null || true
npx vitest run --coverage --coverage.reporter=text-summary 2>&1 | tail -8

Methodology: Pin first, prove second, read third. Before trusting any test that exists, write characterization tests against the old implementation at the merge base: feed it representative and edge inputs, record what it returns, throws, logs, and emits, and commit those recordings as golden outputs. Then run the same tests against the new implementation; every difference is a divergence to explain. Only then read the diff, using the moved-code view so attention goes to the lines that are not pure moves. Walk every call site of every moved symbol. Check side effects by count and order, errors by type and message, and the public surface by diff. Finally read the commit history to confirm pure moves and behaviour changes are separable. Rank by how many callers a divergence reaches and how silent it is.

Pin Behaviour Before Moving It

  • Write characterization tests at the merge base for the moved code: representative inputs, empty and null inputs, boundary values, malformed inputs, and the largest realistic input; record return values, thrown errors, emitted events, and log lines as goldens
  • Capture side-effect transcripts, not only return values: the sequence of writes, calls, and messages for each input
  • Confirm the goldens pass at the base before running them at the head; a golden that fails on the old code is a recording error, not a divergence

Prove Equivalence

  • Run the goldens against the head; list every difference with the input that exposed it and classify each as intentional (requires its own commit and note) or a defect
  • Diff the export list, the exported types, and the import paths; every removed or renamed export needs either a re-export or a migrated caller, found by grep rather than by memory
  • Read the moved-code view of the diff and account for every non-move line: a changed default, a reordered statement, a dropped guard, an altered comparison
  • Run the differential check on recorded production-like inputs where they exist, old implementation beside new, and compare outputs byte for byte

Side Effects, Errors & Ordering

  • Compare the side-effect transcripts for count and order; a notification that now fires twice or a cache write that now precedes the database write is a divergence even when the return value matches
  • Compare thrown error types and messages, because callers and tests match on them; an error turned into a null return or a different class breaks handling far from the diff
  • Check cleanup and finally paths still run on failure in the new structure

Separation in the History

  • Confirm each commit is either a pure move (the moved-code view shows no non-move lines) or a behaviour change with its own message and its own test; split any commit that mixes them
  • Run the revert check: reverting the pure-move commits alone must leave the suite green, and reverting the behaviour-change commit alone must make its guarding test fail
  • Sweep sibling call sites and inlined copies for the old pattern so the refactor is complete rather than mostly complete

Performance & Coverage Shape

  • Time the moved code on the largest realistic input at base and head; a cleaner structure that lost a memoization or added a pass is a divergence to report
  • Compare coverage of the moved files at base and head; a drop means tests were removed or now stop at a mock, and the goldens must fill the gap

Evidence rules: Confirmed requires a run: a golden that passes at the base and fails at the head with the input shown, an export diff, a side-effect transcript comparison, or a revert check result. A divergence inferred from reading the diff alone is Likely until a golden reproduces it. Equivalence is reported only with the golden count, the inputs covered, and the surface diff; an empty finding list without those numbers is not a result. Defer to the repository's own test conventions where they conflict with these rules, and say so.

Output Format

Start with a 3–5 line executive summary: what the refactor moved, how many goldens were captured and how many diverged, whether the history separates moves from behaviour changes, and the first fix.

Divergence table:

Symbol or path Input or condition Old behaviour New behaviour Callers reached Intentional? Severity Status

Severity: Critical (silent data or money difference, or an error path lost), High (visible behaviour change reaching several callers), Medium (ordering, logging, or performance change), Low (surface hygiene). Status: Confirmed / Likely / Fixed.

Surface diff: removed, renamed, and widened exports with their migrated callers. History check: each commit classified as pure move or behaviour change, with the revert-check result. Coverage and timing: base versus head for the moved files. Equivalence statement: goldens run, inputs covered, divergences remaining. Omit any section with nothing to report.

Want this applied to a live stack?

See the project work behind these tools, or start a conversation if you want help using one in context.

View the prompt source as JSON