A weekly order reporter had grown into one 400-line script with three formats and a process-wide cache. Finance still parsed a side CSV by column position, while operators grepped a stdout banner that embedded a local timestamp. Cleanup pull requests kept failing for reasons that looked cosmetic until someone compared the files byte for byte. The useful move was not a repo-wide restyle, but a characterization suite that locked today's messy output and then one extracted mapping function.
This article treats that sequence as a worked example, not as a war story from a named production system. The reporter below is compact on purpose, and the tests are meant to be copied. If an agent later proposes a broader cleanup, the goldens decide whether the diff is still the smallest safe change.
Why inherited reporters punish large cleanups
Messy modules rarely hide a single defect that a rewrite would isolate cleanly. They hide several implicit contracts that nobody wrote down and that downstream tools still depend on.
Typical contracts in a reporter like this include the following load-bearing quirks:
- Status aliases that treat
"shipped "and"SHIPPED"as the same operator-facing label - A banner line that on-call grep recipes assume will appear on stdout
- A sidecar CSV whose column order is positional for a spreadsheet import
- A module-level cache that skips rereads when a batch wrapper calls the reporter twice
An agent that tidies the whole file will often normalize whitespace, rename helpers, and rewrite loops inside one diff. Each edit can look locally reasonable in isolation during review. Together they change more contracts than a reviewer can hold in working memory without a snapshot.
What counts as the smallest safe change
Treat the smallest safe change as a diff that satisfies all four of the checks below. If any check fails, split the work rather than arguing that the extra cleanup is obviously harmless.
- It alters one named unit, such as a status mapper, and does not touch an I/O path.
- Characterization goldens for stdout and sidecar files remain byte-identical after the diff.
- Non-determinism is already pinned, including clock, working directory, locale, and cache seed.
- Reviewers can explain the diff without opening a second production file for context.
If a proposed extract also trims trailing spaces inside labels, it is no longer the smallest safe change. Record that behavior change as a later ticket with its own expected output. Ship the extract only after the existing goldens stay green without edits.
Phase 1: freeze the messy reporter
The following module is a compact stand-in for an inherited reporter and is intentionally awkward. Do not clean aliases, caching, or banner formatting until characterization is committed and passing.
# order_reporter.py
from __future__ import annotations
import csv
from datetime import datetime
from pathlib import Path
STATUS_ALIASES = {
"shipped": "Shipped",
"SHIPPED": "Shipped",
"shipped ": "Shipped",
"queued": "Queued",
"hold": "On Hold",
"on-hold": "On Hold",
}
_CACHE: dict[str, list[dict[str, str]]] = {}
def load_rows(path: str) -> list[dict[str, str]]:
if path in _CACHE:
return _CACHE[path]
with open(path, newline="") as handle:
rows = list(csv.DictReader(handle))
_CACHE[path] = rows
return rows
def report(path: str, side_csv: str) -> None:
rows = load_rows(path)
stamp = datetime.now().strftime("%Y-%m-%d %H:%M")
print(f"ORDER REPORT @ {stamp}")
print(f"source={path}")
written: list[dict[str, str]] = []
for row in rows:
raw = row.get("status") or ""
stripped = raw.strip()
label = STATUS_ALIASES.get(raw, STATUS_ALIASES.get(stripped, stripped or "Unknown"))
sku = row.get("sku") or ""
print(f"{sku}:{label}")
written.append({"sku": sku, "status": label})
with open(side_csv, "w", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=["sku", "status"])
writer.writeheader()
writer.writerows(written)
The mapper already contains a trap that tidy-up diffs love to erase. Lookup uses the unstripped status first and only then the stripped value, so a key with a trailing space can win. Characterization must keep that order, because a later cleanup of .strip() calls will change labels without touching any call site that looks important.
A characterization harness you can run
Pin time, clear the module cache, and capture both stdout and the sidecar file. Compare each captured document against a committed golden rather than against a rewritten expectation in the same commit.
# test_characterize_reporter.py
from __future__ import annotations
import io
from datetime import datetime as real_datetime
from pathlib import Path
from unittest.mock import patch
import order_reporter
FIXTURES = Path(__file__).parent / "fixtures"
GOLDENS = Path(__file__).parent / "goldens"
def _run_report(tmp_path: Path) -> tuple[str, str]:
source = FIXTURES / "orders.csv"
side = tmp_path / "side.csv"
order_reporter._CACHE.clear()
buf = io.StringIO()
with patch("order_reporter.datetime") as mock_dt:
mock_dt.now.return_value = real_datetime(2026, 9, 18, 9, 0)
with patch("sys.stdout", buf):
order_reporter.report(str(source), str(side))
return buf.getvalue(), side.read_text(encoding="utf-8")
def test_stdout_matches_golden(tmp_path: Path) -> None:
stdout, _ = _run_report(tmp_path)
expected = (GOLDENS / "stdout.txt").read_text(encoding="utf-8")
assert stdout == expected
def test_sidecar_matches_golden(tmp_path: Path) -> None:
_, sidecar = _run_report(tmp_path)
expected = (GOLDENS / "side.csv").read_text(encoding="utf-8")
assert sidecar == expected
Use a fixture that exercises aliases, unknown labels, and the trailing-space key:
sku,status
A-100,shipped
B-200,shipped
C-300,hold
D-400,unknown
Generate goldens once from current behavior, then commit those files as the contract. Do not edit goldens in the same change that extracts a helper, even when the new output looks cleaner to a reviewer.
python - <<'PY'
from pathlib import Path
import tempfile
from test_characterize_reporter import _run_report, GOLDENS
GOLDENS.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
stdout, sidecar = _run_report(Path(tmp))
(GOLDENS / "stdout.txt").write_text(stdout, encoding="utf-8")
(GOLDENS / "side.csv").write_text(sidecar, encoding="utf-8")
print("goldens written")
PY
pytest test_characterize_reporter.py -q
If pytest is not already a project dependency, run the same two equality checks as a short script. The important property is byte equality, not a snapshot library that normalizes whitespace or line endings by default. Platform newline drift will otherwise get promoted into a fake product contract.
Phase 2: extract one mapping function
After both goldens are green on the messy module, extract only the status lookup. Leave load_rows, the banner, the cache, and CSV writing untouched in that commit.
def map_status(raw: str | None) -> str:
value = raw or ""
stripped = value.strip()
return STATUS_ALIASES.get(value, STATUS_ALIASES.get(stripped, stripped or "Unknown"))
Wire the helper into the existing loop with a one-line replacement and re-run the characterization tests immediately. If stdout or the sidecar drifts, revert the extract instead of repairing goldens. Do not fix forward by rewriting snapshots while the helper is still under review.
That extract is the entire first refactor on purpose. Resist adding file-wide type hints, renaming _CACHE, or sorting SKUs, because those edits expand the blast radius. Each of those ideas can become a later ticket with its own golden review once this seam is isolated.
Decision table for later cuts
Keep a short table next to the harness so humans and agents do not bundle obvious fixes once the file is open.
| Candidate change | Safe as the next diff? | Why |
|---|---|---|
Extract map_status only |
Yes | Pure function; existing goldens stay identical |
Strip keys inside STATUS_ALIASES
|
No | Changes how "shipped " is labeled |
| Remove the module cache | No | Alters repeated-read behavior in batch wrappers |
| Drop the banner timestamp | No | Operators grep the line; pin time instead |
| Reorder sidecar columns | No | Spreadsheet import is positional |
| Normalize aliases in a later ticket | Yes, after new goldens | That is an explicit behavior change |
The table is the review checklist, not decoration. A diff that touches two rows in that table is already larger than the smallest safe change.
Where a disposable agent workspace helps
Generating characterization fixtures by hand is slow when the messy module has many branches and alias keys. A coding agent can propose extra rows for the input CSV after it reads STATUS_ALIASES and the golden-generation script. The reviewer still decides which rows encode real contracts and which rows are synthetic noise.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source coding assistant with free model access and a free server option, and those two properties matter here only as a workspace constraint. The free server is a place to generate fixtures and run the harness without writing goldens into a laptop checkout that already has dirty files. Copy the resulting fixtures/ and goldens/ directories back into review, then discard the session rather than merging an in-place restyle.
The method does not depend on any particular product, and a second local clone with a clean virtualenv works the same way. The agent is optional scaffolding around fixture discovery. The goldens remain the contract that makes the later extract auditable.
Limitations and who should skip this
This approach is a poor fit when behavior is undefined on purpose, such as a prototype that must change daily and has no downstream parser. It is also the wrong tool for cryptographic code, concurrent writers, or modules whose useful output is a GUI rather than text. Golden files freeze encoding and newline details, so they will flake if one machine writes \r\n and another writes \n.
Pin newline="" and UTF-8 at every file boundary, or the harness will encode platform noise as a product contract. Do not use characterization-then-extract as cover for leaving secrets or customer rows in fixtures. Sample CSVs should be synthetic, and if the messy reporter talks to a network, fake the transport before you snapshot anything.
Teams that already have fast unit tests around pure functions do not need this ceremony for those functions. Save it for inherited scripts where tests never existed and the I/O itself is the product that finance and operators still consume.
Close the loop on the first cut
A messy repo becomes safer when the first cleanup diff is boring and easy to revert. Lock stdout and sidecar bytes, extract one mapper, and stop before the file looks tidy. The next implicit contract can wait for its own goldens cycle, including cache behavior and banner format, because those are separate seams. That sequence is slower than a generated restyle, and it is the sequence that keeps a spreadsheet import aligned with what operators still grep in logs.
Top comments (0)