The Move-Only Contract: A Three-Step Refactor for a Messy Module
Big refactors fail in the first commit. The usual cause is not skill. It is sequence. Teams rewrite the riskiest code before they lock its current behavior. A messy module needs an autopsy first. This post is a three-step method to find the smallest safe change and prove it. Budget about one hour for the first slice.
The core rule is simple. Pick a leaf. Lock it. Move it. Prove the diff. I call the last step the move-only contract. No edits are allowed inside the moved body. If you need to change something, you picked the wrong slice.
Step 1: Find the leaf functions
A leaf function has the property you can exploit. Nothing else in the module calls it. It receives input and returns output. It usually touches no global state. That makes it the cheapest behavior to lock.
The script below parses a Python module and lists every function with at most one caller:
# leaves.py — find functions with at most one caller
import ast
import sys
from collections import Counter
source = open(sys.argv[1]).read()
tree = ast.parse(source)
names = [n.name for n in ast.walk(tree)
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
calls = Counter()
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
calls[node.func.id] += 1
for name in names:
if calls.get(name, 0) <= 1:
print(name)
Run it on the ugly module:
python leaves.py legacy_payments.py
The output is your shortlist. Zero callers means dead code. One caller means an extraction candidate. Ten callers means shared risk. Shared risk gets locked later, never first. The script sees only this file, so external callers are invisible. Treat the list as a hint, not a verdict.
Step 2: Lock behavior with a snapshot
Characterization tests record what code does today. They do not judge whether it is correct. That distinction is the whole point. You are capturing reality, not defending it.
Build a small corpus of inputs first. Production logs are the best source. Error paths matter more than happy paths. If the module has a JSON schema, generate cases from it too. Fifty inputs beat five, but only if they cover the branches the function actually has.
grep "normalize(" access.log | head -200 \
| jq -c '{amount, currency}' > corpus.json
Then snapshot every output, including exceptions:
# lock.py — record every output, including exceptions
import json
import sys
from importlib import import_module
module_name, func_name, corpus_path = sys.argv[1:4]
func = getattr(import_module(module_name), func_name)
corpus = json.load(open(corpus_path))
records = []
for case in corpus:
try:
records.append({"input": case, "output": func(**case)})
except Exception as exc:
records.append({"input": case, "error": type(exc).__name__})
json.dump(records, open("snapshot.json", "w"), indent=2, default=str)
Run it before you touch anything:
python lock.py legacy_payments normalize corpus.json
cp snapshot.json before.json
Store before.json in git. That file is the contract for this slice.
Step 3: Extract, then prove the diff is movement
The smallest safe change is usually an extraction. Take the leaf body and move it. Change nothing else. No renamed variables. No "cleanups" on the way.
Here is the pattern on a tiny case. The legacy module has a leaf that normalizes money values:
# legacy_payments.py
def normalize(amount, currency):
if currency == "USD":
return round(float(amount), 2)
if currency == "BTC":
return round(float(amount), 8)
raise ValueError(f"unsupported currency: {currency}")
The extraction moves the body into a new module, byte for byte:
# money.py
def normalize(amount, currency):
if currency == "USD":
return round(float(amount), 2)
if currency == "BTC":
return round(float(amount), 8)
raise ValueError(f"unsupported currency: {currency}")
The old file keeps only the import:
# legacy_payments.py
from money import normalize
After the move, rerun the lock:
python lock.py refactored_payments normalize corpus.json
diff <(jq -S . before.json) <(jq -S . snapshot.json) \
&& echo "behavior locked"
Then inspect the staged diff for movement, not rewriting:
git add -A
git diff --cached --color-moved=zebra
The zebra coloring marks moved blocks. White lines are edits. The only white lines here are the import change and the deleted copy. Both live outside the moved body. Any other white line is a behavior risk. Treat it as a failure, not a cleanup.
Decide the slice with a table
Not every function deserves to be first. The decision is structural:
| Property of the slice | Take it first | Defer it |
|---|---|---|
| Leaf, one caller | Yes | — |
| Pure function, no I/O | Yes | — |
| More than five callers | — | Later |
| Reads global mutable state | — | Lock the state first |
| Random or time-dependent output | — | Seed it first |
A function that scores "defer" is not a failure. It is a larger contract. Lock it after the leaves have reduced the module's surface. The table also tells you when to stop. If every function is deferred, the module needs a plan, not a slice.
Three failures to expect
The first failure is a thin corpus. Fifty happy-path inputs will not catch a changed error branch. Add malformed values and boundary numbers.
The second failure is a broad slice. The extraction touched two state variables, so no snapshot can prove equivalence. Narrow the slice until the leaf has no external reads.
The third failure is an opportunistic edit. A developer sees a bug inside the moved block and fixes it. That is a behavior change wearing a refactor costume. Revert the fix and open a separate ticket. The move-only contract exists to make this failure visible.
Where free tooling fits
Corpus building is tedious. A free coding model can draft candidate inputs from the function signature and past error messages. Those candidates are hypotheses, not facts. Every generated case stays unverified until the runner executes it. The order matters: model proposes, script disposes.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode's free model access fits the proposal step. Its free server option can run the lock script without you provisioning infrastructure. The model should never edit the locked slice. Its job is generating inputs and reviewing the zebra diff. The move itself stays human and mechanical.
What this method does not do
Snapshot tests lock bugs as well as behavior. A thin corpus makes a wrong extraction look safe. Move-only diffs catch edits, not wrong-but-identical movement. If the function output is random, seed the RNG or skip the slice.
Extraction adds indirection. A 300-line function with ten state mutations will not yield a clean leaf. That code needs deeper tests and a state-machine plan. Skip this method if your suite already covers the module. Skip it if the function is a hot path where one extra call matters. Skip it if the team will not accept a move-only diff on its own merits.
Conclusion
Every messy module has a leaf. The autopsy finds it. The snapshot locks it. The zebra diff proves the move. That is a complete, shippable slice in under an hour.
Next time you open a file nobody dares to touch, run the autopsy first. The smallest safe slice is still work, but it lands.
Top comments (0)