A messy function is not ready for a rewrite. Observed behavior must become a replayable ledger first. Then the next patch may touch exactly one file.
Unscoped diffs fail this one-file rule constantly. They rename helpers and alter edge cases together. A ledger catches drift during later replay.
Why brownfield rewrites lose contracts
Brownfield functions hide implicit caller contracts. Error types, rounding, and missing keys all matter. A clean rewrite often drops one quiet dependency.
Characterization tests record what the code does today. They do not claim that behavior is ideal. They only require the next edit to stay equivalent.
Michael Feathers documented this approach for legacy code. This artifact is a JSONL behavior ledger. Each line stores one call, result, or error.
The method below is a proposed local harness. Treat the code as a template, not a library. Adapt serialization to the types you actually ship.
Pick one function, not a package
Choose a single function with mixed rules. Prefer local calculation over a network facade. Name that function in every ledger row.
A good target mixes discounts, tax, and rounding. Callers already depend on those current quirks. Do not “fix” policy during the first edit.
Step 1: Add a canonical JSONL recorder
The wrapper canonicalizes inputs and outputs. It appends one JSON object per observed call. Failures store exception type and message text.
# char_ledger.py
from __future__ import annotations
import json
import functools
from pathlib import Path
from typing import Any, Callable
LEDGER_PATH = Path("tmp/behavior_ledger.jsonl")
def _canon(value: Any) -> Any:
text = json.dumps(value, sort_keys=True, default=str)
return json.loads(text)
def record(fn: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
LEDGER_PATH.parent.mkdir(parents=True, exist_ok=True)
row: dict[str, Any] = {
"name": fn.__qualname__,
"input": _canon({"args": args, "kwargs": kwargs}),
}
try:
result = fn(*args, **kwargs)
except Exception as exc:
row["ok"] = False
row["error_type"] = type(exc).__name__
row["error_msg"] = str(exc)
with LEDGER_PATH.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(row, sort_keys=True) + "\n")
raise
row["ok"] = True
row["result"] = _canon(result)
with LEDGER_PATH.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(row, sort_keys=True) + "\n")
return result
return wrapper
Apply the decorator only on the target. Keep it off hot production request paths. A local fixture run covers most messy refactors.
# billing.py
from char_ledger import record
CACHE: dict[str, float] = {}
@record
def compute_total(subtotal: float, coupon: str | None, tax_bps: int) -> dict:
if subtotal < 0:
raise ValueError("subtotal must be >= 0")
key = f"{subtotal}:{coupon}:{tax_bps}"
if key in CACHE:
discount = CACHE[key]
else:
discount = 0.0
if coupon == "SAVE10":
discount = round(subtotal * 0.10, 2)
elif coupon == "FLAT5":
discount = 5.0
CACHE[key] = discount
taxable = subtotal - discount
taxed = round(taxable * (1 + tax_bps / 10000), 2)
return {"coupon": coupon, "discount": discount, "total": taxed}
That cache is part of the observed contract. Hidden state will collide in the ledger. Collisions mean you must characterize state, not ignore it.
Step 2: Fill the ledger from current paths
Drive the function through a small script. Do not invent extra business cases yet. You are sampling present behavior, not desired behavior.
# fill_ledger.py
from billing import CACHE, compute_total
CASES = [
(100.0, None, 825),
(100.0, "SAVE10", 825),
(19.99, "SAVE10", 0),
(0.0, None, 825),
(50.0, "UNKNOWN", 825),
(50.0, "FLAT5", 825),
(-1.0, None, 825),
(100.0, "SAVE10", 825), # cache hit
]
if __name__ == "__main__":
CACHE.clear()
for subtotal, coupon, tax_bps in CASES:
try:
print(compute_total(subtotal, coupon, tax_bps))
except Exception as exc:
print(type(exc).__name__, exc)
rm -f tmp/behavior_ledger.jsonl
python fill_ledger.py
wc -l tmp/behavior_ledger.jsonl
sort tmp/behavior_ledger.jsonl | uniq -c | head
Deduplicate rows before freezing the fixture. Identical inputs should map to identical outputs. A mismatch here means hidden mutable state remains.
Clear process-local caches at the recording boundary. Then replay with the same reset rule. Otherwise cache hits become unreproducible noise.
Step 3: Freeze the ledger and replay it
Copy the JSONL into a committed fixture. Replay must never append new ledger rows. Compare canonical JSON, not Python object identity.
mkdir -p tests/fixtures
cp tmp/behavior_ledger.jsonl tests/fixtures/compute_total.ledger.jsonl
# replay_ledger.py
from __future__ import annotations
import json
from pathlib import Path
from billing import CACHE, compute_total
FROZEN = Path("tests/fixtures/compute_total.ledger.jsonl")
def replay() -> int:
failures = 0
CACHE.clear()
for line in FROZEN.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
row = json.loads(line)
args = row["input"]["args"]
kwargs = row["input"]["kwargs"]
try:
result = compute_total(*args, **kwargs)
observed = {
"ok": True,
"result": json.loads(
json.dumps(result, sort_keys=True, default=str)
),
}
except Exception as exc:
observed = {
"ok": False,
"error_type": type(exc).__name__,
"error_msg": str(exc),
}
if observed["ok"] != row["ok"]:
failures += 1
print("ok-mismatch", row, observed)
continue
if row["ok"] and observed["result"] != row["result"]:
failures += 1
print("result-mismatch", row["result"], observed["result"])
if not row["ok"]:
if observed.get("error_type") != row.get("error_type"):
failures += 1
print("error-type-mismatch", row, observed)
if observed.get("error_msg") != row.get("error_msg"):
failures += 1
print("error-msg-mismatch", row, observed)
return failures
if __name__ == "__main__":
count = replay()
raise SystemExit(1 if count else 0)
A green replay is the only oracle. No generated patch can override a red ledger. Revert first, then inspect the mismatched row.
Step 4: Make the smallest same-file change
After replay is green, edit one path. Extract a helper only inside that file. Keep the public signature and return shape unchanged.
def _discount_for(subtotal: float, coupon: str | None) -> float:
if coupon == "SAVE10":
return round(subtotal * 0.10, 2)
if coupon == "FLAT5":
return 5.0
return 0.0
Wire _discount_for from compute_total only. Leave CACHE in the original module. Moving the cache now would be a second project.
Run the replay after that single extraction. If totals drift, the helper is not equivalent. Fix the helper, or delete the extraction.
Step 5: Enforce a one-file git gate
The gate is mechanical and local. Count changed paths, then reject extras. Also ignore ephemeral tmp/ ledger noise.
#!/usr/bin/env bash
# one_file_gate.sh
set -euo pipefail
python replay_ledger.py
count=$(git diff --name-only HEAD | awk '!/^tmp\// && NF {c++} END {print c+0}')
if [ "$count" -ne 1 ]; then
echo "expected exactly one changed file, got ${count}:"
git diff --name-only HEAD
exit 1
fi
echo "one-file gate passed"
git diff --name-only HEAD
chmod +x one_file_gate.sh
./one_file_gate.sh
Two files means the patch is too wide. Split it, even when both edits look related. Characterization does not license a cleanup spree.
When a model may propose the edit
Models help only after the oracle exists. They are not a substitute for replay. Prompt them to edit one named file.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access and a free server option. Those availability notes are the only product claims used here.
A practical split is easy to keep honest. Run fill and replay on your existing checkout. Then draft the one-file patch in a free server session if local setup is noisy.
Give that session three inputs only: the frozen ledger, the current target file, and one_file_gate.sh. Accept a diff only when replay stays green. The gate still rejects multi-file cleanup.
Decision table for the gate
| Signal | Action |
|---|---|
| Ledger is empty | Do not edit the target function |
| Same input, different output | Find hidden state, then resample |
| Replay is red | Revert the patch immediately |
| Replay green, changed files > 1 | Split the patch before review |
| Replay green, changed files = 1 | Merge the extraction |
Empty ledgers are not conservative. They are blind. Blind refactors are guesses with extra steps.
Limitations
JSON canonicalization drops object identity. default=str can hide real type changes. Decimal and float values need an explicit codec.
Floating point remains a brittle fixture. Prefer integer cents when the domain allows it. Do not freeze wall-clock timestamps without a clock seam.
The ledger is not a product specification. It also freezes today’s accidental bugs. Schedule a later change to break behavior on purpose.
Concurrent appends can interleave ledger lines. Record inside one process for this workflow. Do not attach the wrapper to threaded production traffic.
PII does not belong in committed JSONL. Redact before the fixture is reviewed. If redaction is impossible, do not record those calls.
Who should not use this
Skip this workflow on greenfield modules. You can write intent tests there first. A ledger would fossilize an unfinished public API.
Skip it for time and randomness dominated code. Inject a clock or RNG seam first. Otherwise every replay becomes a flake source.
Skip it when the change must break a contract. Write explicit tests for the new contract. Do not force the ledger to bless a break.
Skip it if the original function cannot run. Characterization needs real calls against current code. Guessed rows are fiction, not a safety net.
What this workflow does not replace
A ledger does not replace design review. It does not replace typed public APIs. It only prices the next one-file edit.
It also does not measure coverage quality. Five similar rows can miss a branch. Add rows from real failing production shapes later.
Keep the first change boring on purpose. Equivalent extraction is the only allowed move. Behavior change is a second, separately gated patch.
Closing sequence
The core sequence stays short and testable. Record. Freeze. Replay. Edit one file. Anything larger is a different project.
The ledger is the characterization test. The path count is the blast-radius signal. Together they make a messy-repo refactor measurable.
Top comments (0)