Do not extract a helper from a messy module yet. First lock three observable channels with characterization tests. Only then apply the smallest structural change that still compiles.
Why this order holds
Messy functions hide contracts inside prints and in-place edits. A rename can look pure while stdout quietly changes. Reviewers then argue about style instead of behavior.
Characterization tests do not certify that behavior is correct. They only freeze what the module does today. That freeze is the permit for one extract.
Guessing invariants from reading the file is slower. The file lies by omission more than by syntax. Observed channels remain the only honest contract here.
The three channels you must pin
Pin the return payload using canonical JSON encoding. Pin captured stdout and stderr as exact text. Pin a deep copy of mutated input containers after the call.
Do not pin wall-clock duration in this suite. Do not pin log lines that embed the current timestamp. Those fields make the lock flake without teaching behavior.
If a channel is unused, store an explicit null. Missing keys make later characterization diffs hard to read. Explicit nulls keep the golden schema stable across extracts.
Artifact: golden channels plus a digest
The artifact is a golden JSON document plus a SHA-256. Store both files next to the messy module under test. Commit them before any extract lands on the branch.
Use one fixture directory per messy entry point. Name fixtures after the behavior, not after the helper. Helper names will change; behavior names should not.
Example layout for a Python module under retry_alloc:
retry_alloc/
messy.py
test_characterize_allocate.py
goldens/
mixed_budget.channels.json
mixed_budget.sha256
silent_mode.channels.json
silent_mode.sha256
A messy entry point to lock
The following module is a compact teaching stand-in. Treat this file as unlabeled production debt for practice. Do not refactor it until the lock is green.
# retry_alloc/messy.py
from __future__ import annotations
def allocate_retries(jobs: list[dict], budget: int, verbose: bool = True) -> dict:
leftover = budget
assigned = []
for job in jobs:
need = int(job.get("retries", 0) or 0)
if need <= 0:
job["status"] = "skip"
if verbose:
print(f"skip {job.get('id')}")
continue
take = need if need <= leftover else leftover
leftover -= take
job["retries"] = need - take
job["granted"] = take
job["status"] = "partial" if job["retries"] else "full"
assigned.append({"id": job.get("id"), "granted": take})
if verbose:
print(f"grant {job.get('id')} {take}")
if leftover == 0:
break
return {"leftover": leftover, "assigned": assigned, "count": len(assigned)}
That function mutates jobs while printing and returning a dict. All three channels can drift during a casual extract. The lock must cover each channel on every fixture.
Characterization harness
Label this harness as a local, reproducible example. Wire it to pytest if that is your runner. Keep the canonical dump independent of assertion libraries.
# retry_alloc/test_characterize_allocate.py
from __future__ import annotations
import hashlib
import io
import json
from copy import deepcopy
from contextlib import redirect_stdout, redirect_stderr
from pathlib import Path
from retry_alloc.messy import allocate_retries
GOLDEN_DIR = Path(__file__).parent / "goldens"
UPDATE = False # True only for an intended product change
def _canonical(data) -> str:
return json.dumps(
data, sort_keys=True, separators=(",", ":"), ensure_ascii=True
)
def _capture(jobs, budget, verbose=True):
incoming = deepcopy(jobs)
stdout = io.StringIO()
stderr = io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
result = allocate_retries(incoming, budget, verbose=verbose)
channels = {
"return": result,
"stdout": stdout.getvalue(),
"stderr": stderr.getvalue(),
"mutated_jobs": incoming,
}
text = _canonical(channels)
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
return channels, text, digest
def _load_jobs():
return [
{"id": "a", "retries": 2},
{"id": "b", "retries": 0},
{"id": "c", "retries": 5},
{"id": "d", "retries": 1},
]
def _assert_lock(name, jobs, budget, verbose):
GOLDEN_DIR.mkdir(exist_ok=True)
channels, text, digest = _capture(jobs, budget, verbose=verbose)
json_path = GOLDEN_DIR / f"{name}.channels.json"
sha_path = GOLDEN_DIR / f"{name}.sha256"
if UPDATE or not json_path.exists():
json_path.write_text(text + "\n", encoding="utf-8")
sha_path.write_text(digest + "\n", encoding="utf-8")
return channels
expected_text = json_path.read_text(encoding="utf-8").strip()
expected_digest = sha_path.read_text(encoding="utf-8").strip()
assert digest == expected_digest
assert text == expected_text
return channels
def test_mixed_budget_three_channels():
channels = _assert_lock("mixed_budget", _load_jobs(), 4, True)
assert channels["return"]["leftover"] == 0
def test_silent_mode_empty_stdout():
channels = _assert_lock("silent_mode", _load_jobs(), 4, False)
assert channels["stdout"] == ""
Run the suite once to create the golden files. Run it again to prove the lock is stable. Do not edit goldens to silence a failed extract.
python -m pytest retry_alloc/test_characterize_allocate.py -q
sha256sum retry_alloc/goldens/mixed_budget.channels.json
git add retry_alloc/goldens retry_alloc/test_characterize_allocate.py
The second command is a manual human cross-check. The characterization test already stores the SHA-256 digest. Mismatched files mean the lock was edited by hand.
Fixture selection rules
Pick fixtures that hit skip, partial, full, and budget-zero paths. One happy path will not lock the break. The mixed budget fixture above covers four job states.
Add a second fixture with verbose set to false. Silent mode is a real stdout contract. Empty stdout must be stored as an empty string.
Do not generate random job ids inside the fixture. Randomness defeats SHA-256 comparison on contact. Freeze identifiers in the test helper.
Cover leftover-positive input as a third fixture later. A leftover of four against need two is too narrow. Add a budget larger than total demand before the next extract.
Numbered workflow: lock, then one extract
- Choose one messy entry point and one fixture set.
- Capture return, stdio, and mutated inputs as canonical JSON.
- Commit the golden file and the SHA-256 digest together.
- Extract one function only, with no behavior edits.
- Re-run the characterization suite on the same fixtures.
- Keep the extract only when both files still match.
- Stop the branch after that single structural move.
- Open a new branch if a second extract is required.
Step four remains the entire refactor budget on the branch. Extra cleanups belong on later branches, not this one. Mixed intent is how characterization locks rot in review.
The smallest extract that should pass
The first legal extract is a private grant helper. It must receive the job dict and leftover integer. It must mutate the same job object in place.
It must print the same grant line when verbose is true. It must return the new leftover integer only. The outer loop still owns skip, break, and assigned.
Example extract (proposal, not executed here):
def _grant(job: dict, leftover: int, verbose: bool) -> int:
need = int(job.get("retries", 0) or 0)
take = need if need <= leftover else leftover
leftover -= take
job["retries"] = need - take
job["granted"] = take
job["status"] = "partial" if job["retries"] else "full"
if verbose:
print(f"grant {job.get('id')} {take}")
return leftover
Keep allocate_retries as the only public entry point. Do not export _grant in this branch. Re-run the characterization tests immediately after the edit.
If mixed_budget.sha256 still matches, keep the helper. If it drifts, revert and shrink the extract. A smaller extract is cheaper than a new golden.
Decision table for a red lock
| Observation | Meaning | Action |
|---|---|---|
| Digest mismatch, stdout only | Print contract drifted | Revert extract or accept and re-golden |
| Digest mismatch, mutated_jobs only | In-place contract drifted | Revert; do not rewrite callers yet |
| Digest mismatch, return only | Payload keys or types drifted | Revert; inspect nested key order |
| JSON matches, SHA-256 mismatches | File encoding or newline drift | Rebuild golden with _canonical
|
| Both match after extract | Structure-only change | Keep the extract and merge |
Update goldens only when product behavior should change. A structure-only extract is not a product change. Re-golden during extract is how silent bugs ship.
How to read a digest miss
- Diff the JSON with a stable, sort-friendly tool.
- Note which top-level channel key moved first.
- Map that key back to the extract hunk.
- Decide between revert and an intended behavior change.
- Never edit the SHA-256 file by itself.
git diff -- retry_alloc/goldens/mixed_budget.channels.json
git checkout -- retry_alloc/messy.py # if the extract leaked behavior
Do not add a silent update flag for extract work. The harness uses a constant UPDATE set to False. Flip it only for deliberate product changes.
Where a free model can enter
A model is useful after the lock is green, not before. It can propose a single extract against the frozen module. A model cannot replace the three-channel characterization suite.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that proposal step. Paste the messy function, the golden schema, and the rule "change structure only." Reject any diff that touches prints, mutation, or return keys.
Re-run the harness on your machine after the proposal. Remote hosting does not make the characterization lock optional. The digest is still the merge gate for the branch.
Failure analysis: common extract leaks
Extracting a loop body often drops a break path. Stdout then gains extra grant lines for later jobs. The digest fails on the stdout channel first.
Moving mutation into a helper can copy instead of edit. Callers then see stale retries fields on the input jobs. The mutated_jobs channel fails while return still looks fine.
Sorting assigned rows inside the new helper changes JSON. Canonical encoding will then hash a new payload. That is a behavior change, not a style win.
Pulling skip prints into _grant also breaks the lock. Skip rows never enter the grant helper in the original. A "helpful" merge of both paths is still a behavior change.
Limitations
This lock ignores filesystem writes that happen outside stdout. It also ignores network calls and database commits. Add separate fakes if those are load-bearing.
Canonical JSON will not see datetime object identity. Convert those values to strings before dumping channels. Unconverted objects make the harness crash, not flake.
Deep copy will miss objects that share mutable graphs. Document shared identity as a fourth channel if needed. This article keeps the contract at three channels.
The SHA-256 digest does not explain a failure. Always diff the JSON when the digest breaks. Humans still need to read the channel that moved.
Who should not use this approach
Do not use this workflow on greenfield modules with no callers. There is no behavior to freeze in that case. Write ordinary unit tests that state intent instead.
Do not use it as a substitute for security review. Characterization tests will faithfully lock a leak in place. Frozen leaks are still leaks after the extract.
Do not batch five extracts behind one golden update. The table above becomes fiction at that point. One extract per green digest is the rule.
Stop condition
The branch is done when one helper exists and the digest matches. Do not reformat nearby modules on the same branch. Characterization only protects the channels you actually captured.
Keep the golden files in version control with the module. Delete them only after true unit tests replace each channel. Until then, the three-channel lock is the contract.
Apply the harness to one entry point before the next extract.
Top comments (0)