Consider a 2,300-line pricing module that three teams call through a single public function, and an assistant that returns a 340-line diff touching eleven files. The diff may even be correct, but nobody on the review call can tell you which of those eleven files the change was supposed to touch. "Smallest safe change" is a nice principle and a terrible gate, because nothing in the pipeline defines what "smallest" means for this commit.
The practical fix is to write the intended scope down as data before you edit anything. Below is a workflow that freezes behavior at the module boundary, records an allowed blast radius in a file, and fails CI when the diff escapes it. It also adds a revert drill, because a change you cannot cleanly undo was never small.
Three ways "smallest safe change" quietly fails
Most refactor regressions I have seen reviewed share one of these shapes, and none of them are caught by reading the diff.
- Scope creep by convenience. The change touches a helper two directories away because the import was already there, and that helper has its own callers.
-
Behavior drift at the edges. Output looks right for the fixture in the PR description but changes for
None, empty lists, or rounding at the fourth decimal. -
Unprovable revert. The commit mixes a rename, a moved function, and a logic tweak, so
git revertconflicts and the rollback becomes its own project.
Each failure is a measurement problem rather than a taste problem, which means each one can be turned into a check.
Step 1: Freeze behavior at the boundary, not inside the module
Characterization tests are usually written against internals, which makes them break during exactly the refactor they were meant to protect. Put the corpus at the seam instead: call the module the way production calls it, capture the returned value and the raised exception, and normalize both into canonical JSON.
# refactor/corpus/runner.py (Python 3.11, stdlib only)
import hashlib
import importlib
import json
import sys
from decimal import Decimal
from pathlib import Path
def normalize(value):
"""Reduce a return value to a stable, comparable structure."""
if isinstance(value, Decimal):
return {"__decimal__": format(value, "f")}
if isinstance(value, float): # keep repr, never round silently
return {"__float__": repr(value)}
if isinstance(value, dict):
return {str(k): normalize(v) for k, v in sorted(value.items(), key=lambda kv: str(kv[0]))}
if isinstance(value, (list, tuple)):
return [normalize(v) for v in value]
if isinstance(value, (str, int, bool)) or value is None:
return value
return {"__type__": type(value).__name__}
def run(module_path, cases_path):
module = importlib.import_module(module_path)
cases = json.loads(Path(cases_path).read_text())
records = []
for case in cases:
entry = {"id": case["id"]}
try:
result = module.price_order(**case["args"])
entry["ok"] = normalize(result)
except Exception as exc: # exceptions are behavior too
entry["err"] = {"type": type(exc).__name__, "msg": str(exc)}
records.append(entry)
return records
if __name__ == "__main__":
records = run(sys.argv[1], sys.argv[2])
blob = json.dumps(records, sort_keys=True, indent=2)
Path(sys.argv[3]).write_text(blob + "\n")
print("snapshot sha256:", hashlib.sha256(blob.encode()).hexdigest()[:16])
print("cases:", len(records))
Run it on the untouched code and commit the snapshot as the golden file. From then on, the refactor must reproduce that file byte for byte, and any intentional difference becomes a reviewable line in the diff instead of a surprise in production.
Two rules keep this corpus honest over time:
- Include the ugly inputs you already know about — empty collections, zero quantity, duplicate line items, unknown currency codes.
- Regenerate the snapshot only in a separate commit with a second reviewer, so "update golden" never hides a behavior change inside a refactor.
Step 2: Write the blast radius down as a file
A scope file turns an intention into an artifact that CI can read. Keep it small, keep it next to the refactor, and treat edits to it as review-worthy events.
{
"base": "9f3c2ab",
"max_changed_lines": 180,
"allowed_globs": ["src/pricing/**", "refactor/corpus/**"],
"forbidden_globs": ["src/billing/**", "migrations/**", "pyproject.toml"]
}
The checker below is deliberately blunt: it lists changed files, compares them against the globs, and sums the diff size. Blunt is a feature here, because a reviewer can read the whole rule in under a minute.
# scripts/check_scope.py
import fnmatch
import json
import subprocess
import sys
scope = json.load(open("refactor/scope.json"))
base = scope["base"]
changed = subprocess.run(
["git", "diff", "--name-only", f"{base}...HEAD"],
capture_output=True, text=True, check=True,
).stdout.split()
numstat = subprocess.run(
["git", "diff", "--numstat", f"{base}...HEAD"],
capture_output=True, text=True, check=True,
).stdout.strip().splitlines()
added = sum(int(l.split("\t")[0]) for l in numstat if l.split("\t")[0].isdigit())
removed = sum(int(l.split("\t")[1]) for l in numstat if l.split("\t")[1].isdigit())
problems = []
for path in changed:
if any(fnmatch.fnmatch(path, p) for p in scope["forbidden_globs"]):
problems.append(f"forbidden path touched: {path}")
elif not any(fnmatch.fnmatch(path, p) for p in scope["allowed_globs"]):
problems.append(f"outside declared scope: {path}")
if added + removed > scope["max_changed_lines"]:
problems.append(f"diff size {added + removed} exceeds budget {scope['max_changed_lines']}")
for problem in problems:
print("FAIL", problem)
sys.exit(1 if problems else 0)
Wire it into the CI job that already runs your tests, and the conversation in review changes from "is this diff reasonable" to "does this diff match the declared intent".
Step 3: Prove the revert before you merge
The revert drill takes about two minutes and settles arguments that otherwise run for days. On the refactor branch, run these in order:
python -m refactor.corpus.runner src.pricing refactor/corpus/cases.json /tmp/after.json-
git diff --no-index refactor/corpus/golden.json /tmp/after.jsonand confirm it is empty. -
git revert --no-commit HEADand confirm the revert applies without conflicts. - Re-run the runner and confirm the snapshot matches golden again.
-
git revert --abortand continue with the original commit.
If step 3 conflicts, the commit is doing too many things at once, and splitting it is cheaper now than during an incident. If step 4 mismatches, the revert is incomplete, which means the commit carried state changes outside the files it edited.
Where free tooling fits without owning the decision
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The corpus is the part of this workflow that people skip, because writing thirty awkward inputs by hand is tedious. MonkeyCode's free model access is useful there as a candidate generator: paste the public function signature and docstring, ask for edge-case inputs, then keep only the ones you can justify against real callers. The model never writes the golden snapshot, and a human reviews every case before it lands in cases.json.
The free server option is a reasonable home for the runner and its report, so a reviewer on another machine hits the same endpoint instead of rebuilding a local environment for a twenty-minute check. Treat availability and terms as things to confirm on the project page before you plan around them, since free-tier offers change and this article should not be your source of truth for a quota.
When this workflow is the wrong tool
Be honest about the boundary conditions; forcing this pattern everywhere produces ceremony with no signal.
| Situation | Use this workflow? | Reason |
|---|---|---|
| Large module with stable public entry points | Yes | Boundary is real and cheap to snapshot |
| Module whose behavior is intentionally changing | No | Golden file churn hides real regressions |
| Heavily concurrent or time-dependent code | Rarely | Non-deterministic output defeats the snapshot |
| One-file, one-caller script | No | Review effort exceeds the risk |
| Code with no test harness at all | After, not before | You need some harness to capture anything |
The reviewer checklist
Before approval, four questions should have four answers, and none of them require reading every line of the diff.
- Does the scope file name the base commit, and is the diff inside it?
- Does the golden snapshot match, with no "regenerated" commit mixed into the refactor?
- Did the revert drill pass, including the post-revert snapshot comparison?
- Is there one stated intent for this commit, written in the PR body in a single sentence?
None of this makes a refactor safe by itself, and the corpus only knows the inputs somebody thought to include. What it does give you is a cheap, mechanical answer to the two questions that stall real reviews: how far was this allowed to reach, and can we put it back.
Top comments (0)