DEV Community

Dakota Wu
Dakota Wu

Posted on

Declare a Blast Radius Before You Refactor: Keep the Smallest Safe Change Actually Small

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 revert conflicts 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))
Enter fullscreen mode Exit fullscreen mode

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:

  1. Include the ugly inputs you already know about — empty collections, zero quantity, duplicate line items, unknown currency codes.
  2. 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"]
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. python -m refactor.corpus.runner src.pricing refactor/corpus/cases.json /tmp/after.json
  2. git diff --no-index refactor/corpus/golden.json /tmp/after.json and confirm it is empty.
  3. git revert --no-commit HEAD and confirm the revert applies without conflicts.
  4. Re-run the runner and confirm the snapshot matches golden again.
  5. git revert --abort and 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)