You do not understand the messy function. That is fine. The snapshot does not care about your understanding. Golden files turn "I think this is safe" into "the diff says so." This loop has four commands: record, verify, move, commit. Each move is one semantic change. The snapshot judges every move.
Why review guessing fails
A 600-line function hides its contract. Callers see the return value. They also see database writes, emails, and exceptions. Human reviewers guess about those side effects. AI reviewers guess with more confidence. Neither can prove the behavior is identical. A golden file can.
The golden file is a recorded behavior. It stores the return value and side effects for a fixed input. It is not a unit test. It encodes no intent. It only says: this is what the code did on this input. That is enough for a refactor.
Step 1: Pick the boundary
Choose one entry point. This walkthrough uses import_orders(raw_rows) from a legacy module. The function parses rows, writes to a database, sends emails, and returns a list. That is the boundary contract.
def import_orders(raw_rows):
orders = []
for row in raw_rows:
if not row.get("sku"):
continue
qty = int(row.get("qty") or 1) # legacy default: None -> 1
if qty < 0:
qty = 1
order = {"sku": row["sku"], "qty": qty}
# 200 more lines: logging, dedup, DB writes, mail
orders.append(order)
return orders
Record four things per input: the return value, the DB writes, the emails, and the exception type. The recorder replaces the module's side effects with logging stubs.
# tools/characterize.py
import json
import sys
from pathlib import Path
import legacy # the messy module
def run_case(payload):
events = []
legacy.db.insert = lambda row: events.append(("db", row))
legacy.mailer.send = lambda mail: events.append(("mail", mail))
try:
result = legacy.import_orders(payload)
return {"return": result, "events": events, "error": None}
except Exception as exc:
return {"return": None, "events": events, "error": type(exc).__name__}
def main(action, snapshot_dir, fixture_dir):
snap = Path(snapshot_dir)
for case in sorted(Path(fixture_dir).glob("*.json")):
record = run_case(json.loads(case.read_text()))
golden = snap / f"{case.stem}.golden.json"
if action == "record":
golden.write_text(json.dumps(record, sort_keys=True, indent=2))
continue
if golden.read_text() != json.dumps(record, sort_keys=True, indent=2):
print(f"BEHAVIOR CHANGED: {case.stem}", file=sys.stderr)
sys.exit(1)
print("snapshot verified")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2], sys.argv[3])
The stubs assume the function references module attributes. If it imports helpers directly, patch those helpers instead. The principle stays the same: record, do not trust.
Step 2: Build the fixture corpus
The fixtures determine the value of the whole loop. Replay logged production payloads if you have them. One week of real rows beats ten hand-written cases. Weak fixtures create fake confidence. Make sure the corpus hits every branch, every error path, and the None quantity default. The snapshot can only judge what the corpus exercises.
python tools/characterize.py record snapshots fixtures
python tools/characterize.py verify snapshots fixtures
# -> snapshot verified
Why not write unit tests instead? Unit tests encode expected behavior. Golden files record actual behavior. For a messy function, intent is exactly what you lack. Recording is faster and more honest than guessing the contract in advance.
Now the current behavior exists as files on disk. From this point, the snapshot is the specification.
Step 3: Define the smallest safe change
"Smallest" needs a measurable definition. Use a diff budget. The budget allows one file and a bounded number of changed lines per move. This shell gate enforces it:
# tools/refactor.sh
#!/usr/bin/env bash
set -euo pipefail
MAX_CHURN=80
verify() { python tools/characterize.py verify snapshots fixtures; }
budget() {
local churn files
churn=$(git diff --numstat -- '*.py' | awk '{a+=$1; d+=$2} END {print a+d}')
files=$(git diff --name-only -- '*.py' | wc -l)
if (( churn > MAX_CHURN )); then
echo "budget exceeded: $churn lines" >&2; exit 1
fi
if (( files > 1 )); then
echo "one file per move" >&2; exit 1
fi
}
"$@"
The loop becomes mechanical:
git checkout -b refactor/validate-first
# apply one model-proposed move
./tools/refactor.sh verify
./tools/refactor.sh budget
git add legacy.py
git commit -m "extract row validation into a pure function"
Each commit message names exactly one move. "Extract validation." "Rename order to parsed." "Split the import loop." One verb per commit. Never "cleanup." Never "refactor import_orders."
Step 4: Let the model propose, let the snapshot dispose
Every move starts as a narrow prompt. One prompt asks for exactly one change: "Extract the validation block into a pure function. Change nothing else." This loop runs dozens of prompts per refactor. Free model access keeps that prompt cost at zero. MonkeyCode's free model access covers these small proposals. The model proposes; the snapshot disposes.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
If verify turns red, the patch is wrong for this codebase. Model confidence does not matter. The golden file does. If you want the experiment fully isolated, MonkeyCode's free server option offers a clean environment for the verify step. Apply the candidate patch there, run the check, inspect the diff. Promote the accepted move to your branch only after it passes. Keep the prompt narrow. One move per prompt. If the model returns a multi-move patch, split it or reject it. The budget gate will reject it anyway.
What the snapshot catches
Here is a failure mode this loop catches reliably. The legacy code treats qty=None as 1. The model's extraction "fixed" it to raise ValueError. Return-value tests stayed green. The golden file caught the new exception immediately. The bug was in the patch, not in the code. This is the whole point of the loop.
Move risk table
| Move | Snapshot sensitivity | Risk |
|---|---|---|
| Extract a pure block | low | low |
| Rename a local variable | none | negligible |
| Reorder two DB writes | high | medium |
| Change an exception path | high | high |
| Inline a cached value | medium | medium |
Pure extraction and renames are cheap. Reordering side effects is not. Exception paths are the most dangerous. The snapshot records all of them.
Limitations
Golden files freeze bugs too. If the refactor must fix a bug, update the golden file deliberately. Write the reason in the commit message. Do not let the snapshot bless the bug and the fix at the same time.
Flaky behavior breaks the loop. Timestamps, random IDs, and network calls poison golden files. Seed randomness and stub time before recording. If the boundary is genuinely nondeterministic, this loop is the wrong tool.
The diff budget is a proxy, not a proof. An 80-line change can still break behavior. A 400-line move can be perfectly safe. The budget enforces discipline, not correctness. Run the verify command in CI. Add it to the pre-commit hook. The snapshot becomes a regression net for the whole branch.
Who should not use this? Greenfield code. Tiny functions with two callers. Urgent behavior changes. In those cases, ordinary tests beat golden files. Snapshot-first is for code bases where fear is the dominant emotion.
The loop, in four commands
git checkout -b refactor/one-move
./tools/refactor.sh verify # red before you start? fix the harness first
# apply one model-proposed move
./tools/refactor.sh verify # green? the move is safe
./tools/refactor.sh budget # within budget? commit it
git commit -m "one move"
Pick the function you avoid opening. Run this loop once. The golden files will argue with you. Let them win.
Top comments (0)