Most messy-repo risk lives in silent caller contracts. Capture current observable outputs before any helper edit. Then apply the smallest safe behavior change available.
A large cleanup quietly moves those contracts around. A three-channel capture keeps each silent contract visible. Style rewrites without that capture remain unsafe guesses.
The core rule
Pick one helper and freeze three output channels today. Record the return value for every listed fixture. Record stdout text and post-call mutations as well.
A later patch may change one named concern only. It must not disturb the other recorded channels. That single constraint is the entire safety rail.
When this workflow applies
Use this workflow on brownfield helpers with thin tests. Apply it before any model-generated cleanup patch lands. Apply it when callers depend on accidental helper output.
Skip greenfield modules that still lack production callers. Skip work that already has an explicit written spec. Skip helpers whose results must change on purpose.
Step 1: Isolate one messy helper
Choose a function that mixes policy rules and I/O. Prefer helpers with dict mutation or extra logging. Avoid capturing a whole package in one pass.
The pricing helper below is an illustrative target only. It prints JSON, rounds money, and mutates input. Treat every literal as an example, not evidence.
# examples/messy_price.py — illustrative only, unexecuted
import json
import sys
def quote_line(item, tax_rate, log=None):
log = log if log is not None else sys.stdout
name = item.get("name") or "item"
qty = int(item.get("qty") or 1)
unit = float(item.get("unit") or 0)
raw = qty * unit
if item.get("bulk") and qty >= 10:
raw *= 0.9
tax = round(raw * float(tax_rate), 2)
total = round(raw + tax, 2)
rec = {"name": name, "raw": raw, "tax": tax, "total": total}
log.write(json.dumps(rec) + "\n")
item["quoted"] = True
return total
Three side effects hide inside one return value. Downstream callers may depend on any one effect. A rewrite that only returns a dict will break them.
Step 2: Write a small fixture table
Collect inputs that resemble current production traffic closely. Keep the fixture table short and fully explicit. Prefer traces from bugs over synthetic extra volume.
# examples/fixtures.py — illustrative only, unexecuted
CASES = [
{"id": "empty-ish", "item": {}, "tax_rate": 0.0},
{
"id": "single",
"item": {"name": "bolt", "qty": 1, "unit": 2.5},
"tax_rate": 0.07,
},
{
"id": "bulk-on",
"item": {"name": "bolt", "qty": 10, "unit": 2.5, "bulk": True},
"tax_rate": 0.07,
},
{
"id": "bulk-off",
"item": {"name": "bolt", "qty": 10, "unit": 2.5, "bulk": False},
"tax_rate": 0.07,
},
{
"id": "string-qty",
"item": {"name": "nut", "qty": "3", "unit": "1.10"},
"tax_rate": "0.1",
},
]
These five rows cover empty input and bulk discount. They also cover string-to-number coercion on edge paths. Add further rows only from real incident traces.
Step 3: Capture return, stdout, and mutation
Return value alone is a weak oracle here. Logs and mutations also bind downstream callers tightly. The harness below freezes all three observed channels.
# examples/capture_quote.py — illustrative only, unexecuted
from io import StringIO
import copy
import hashlib
import json
from pathlib import Path
from fixtures import CASES
from messy_price import quote_line
OUT = Path("captures/quote_line.json")
def run_case(case):
item = copy.deepcopy(case["item"])
buf = StringIO()
result = quote_line(item, case["tax_rate"], log=buf)
return {
"id": case["id"],
"input": case,
"return": result,
"stdout": buf.getvalue(),
"item_after": item,
}
def main():
rows = [run_case(c) for c in CASES]
blob = json.dumps(rows, sort_keys=True, indent=2) + "\n"
digest = hashlib.sha256(blob.encode()).hexdigest()
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(blob)
print(f"wrote {OUT} sha256={digest} cases={len(rows)}")
if __name__ == "__main__":
main()
Run the harness once against the current HEAD. Commit the JSON file before any source edit. That commit becomes the local characterization oracle file.
mkdir -p captures examples
python examples/capture_quote.py
git add captures/quote_line.json
git commit -m "capture quote_line before refactor"
Do not edit the helper in the same commit. Split capture work and behavior change apart cleanly. Pull request reviewers then see the oracle land first.
Step 4: Assert the capture in CI
A golden file without a test will rot. Load that file in a characterization assertion instead. Fail the build on any unexpected capture drift.
# tests/test_quote_characterization.py — illustrative only, unexecuted
import json
from pathlib import Path
from capture_quote import run_case
from fixtures import CASES
GOLDEN = Path("captures/quote_line.json")
def test_quote_line_matches_capture():
expected = json.loads(GOLDEN.read_text())
actual = [run_case(c) for c in CASES]
assert actual == expected
python -m pytest tests/test_quote_characterization.py -q
A red test means the capture file is stale. Re-record it only with a reviewed written reason. Never re-record a capture just to silence a model.
Step 5: Name one allowed behavior change
Write the intended change as one plain sentence. One example intent: stop mutating the input dict. Return value and stdout must remain byte identical.
That sentence is a contract addendum, not a vibe. Update only the item_after field when needed. Keep that golden update in the same commit.
Step 6: Apply the smallest patch
Change only one concern inside the helper body. Do not rename, extract, and reformat code together. Those three actions are three separate refactor steps.
# examples/messy_price.py — smallest change, illustrative only
import json
import sys
def quote_line(item, tax_rate, log=None):
log = log if log is not None else sys.stdout
name = item.get("name") or "item"
qty = int(item.get("qty") or 1)
unit = float(item.get("unit") or 0)
raw = qty * unit
if item.get("bulk") and qty >= 10:
raw *= 0.9
tax = round(raw * float(tax_rate), 2)
total = round(raw + tax, 2)
rec = {"name": name, "raw": raw, "tax": tax, "total": total}
log.write(json.dumps(rec) + "\n")
return total
The only deleted line was item["quoted"] = True. Logging and returned totals stay on the old path. Callers that never read quoted keep the same numbers.
Re-run the characterization test after the edit. Revert immediately if return or stdout values drift. Inspect git diff on the capture file next.
python -m pytest tests/test_quote_characterization.py -q
git diff -- captures/quote_line.json
Reject extra keys in the capture JSON diff. Reject float noise you did not request here. Reject import reorder mixed into the same patch.
Step 7: Diff capture files field by field
Line-oriented git diff hides nested JSON structure well. Flatten both captures and compare every leaf path. The script below is illustrative and still unexecuted.
# examples/diff_capture_fields.py — illustrative only, unexecuted
import json
import sys
from pathlib import Path
def flatten(prefix, value, out):
if isinstance(value, dict):
for key, child in sorted(value.items()):
path = f"{prefix}.{key}" if prefix else key
flatten(path, child, out)
elif isinstance(value, list):
for index, child in enumerate(value):
flatten(f"{prefix}[{index}]", child, out)
else:
out[prefix] = value
def main(old_path, new_path):
old = json.loads(Path(old_path).read_text())
new = json.loads(Path(new_path).read_text())
left, right = {}, {}
flatten("", old, left)
flatten("", new, right)
for path in sorted(set(left) | set(right)):
if left.get(path) != right.get(path):
print(path)
print(f" - {left.get(path)!r}")
print(f" + {right.get(path)!r}")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
python examples/diff_capture_fields.py captures/quote_line.json /tmp/new.json
Read every printed path before you keep the patch. A changed return path is a product change. A changed stdout path is also a product change.
Only a planned item_after.quoted field change should appear. Anything else means the patch is too large. Split that patch or revert and start again.
Step 8: Optional model pass after the oracle exists
A coding model helps only after captures exist. Prompt it with the golden file plus intent. Ask for a diff that preserves two frozen channels.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode provides free model access and a free server option. Use that option to draft the smallest patch only. Paste the capture, the test, and the one-sentence intent.
Do not ask any model to clean the whole file. That prompt invites drive-by renames and extra formatting. Ask for one concern and a unified diff.
Treat all model output as untrusted text, always. Run the characterization test on your machine. Discard diffs that touch a second concern.
A free server is enough for this short loop. The task is a tiny patch proposal. Keep private repos off shared logs if policy requires.
If the capture is already green on HEAD, try one model-drafted patch against it.
Decision table
| Observed signal | Required action |
|---|---|
| No capture file on HEAD | Do not edit the helper |
| Capture exists and the test is green | Permit one named behavior change |
Diff changes return or stdout
|
Reject the patch |
| Diff also reformats the file | Split the commit |
| Function is non-deterministic | Seed it or skip capture |
| Callers need a new output field | Add a test, then extend capture |
| Two people edit the same helper | Serialize work or split the function |
The table above is the process, not decoration. Skip a row and the safety rail fails. Print it next to the pull request template.
What this method does not prove
A capture is not a product specification document. It freezes old accidents along with intended rules. Wrong rounding stays wrong until you name it.
Five fixtures are not serious test coverage numbers. They are a smoke oracle for one helper. Add extra cases only from real incident traces.
The harness ignores time, network, and global RNG. Do not capture those channels without tight control. Unstable captures train whole teams to ignore failures.
This field-level equality also misses hidden performance regressions. It misses leaked file handles and extra queries. Pair this workflow with your existing integration tests.
Who should not use this approach
Skip this workflow on a brand-new module. Write a real spec and direct tests instead. Characterization copies the past, including its old bugs.
Skip it when the helper must change globally. A capture will fight a deliberate product change. Use explicit acceptance tests for that work.
Skip it during a wide rename across packages. The golden file will thrash on every path. Finish the rename, recapture, then change one behavior.
Skip it for cryptographic or financial audit work. Byte equality is not a proof of correctness. Get a spec review before touching those helpers.
Merge checklist
- Keep the capture file committed on the parent SHA.
- One written sentence names the allowed behavior change.
- Confirm the patch touches exactly one named concern.
- Keep the characterization test green on the branch.
- Review the field-level capture diff before merge.
- Ban drive-by rename or format changes here.
If any box is still open, do not merge. Revert the helper and recapture only with review. The next patch starts from a green oracle.
Closing note
Messy-repo risk still lives in silent contracts. A committed capture file makes those contracts loud. The smallest safe change then becomes locally obvious.
Keep any model on a short factual leash. Keep the capture harness inside ordinary CI jobs. Style cleanup can wait for a later commit.
Top comments (0)