Messy repository trees break at observable edges first. Folder names and comments are not the edge. Seed a fixture corpus against the current entrypoint.
Hash stdout, stderr, exit codes, and written files. Extract one module only after every digest still matches.
The failure this workflow targets
Visual diffs hide reordered logs and quieter exit codes. Large model cleanups often change those quiet contracts. A golden digest set makes that class of break loud.
Characterization does not prove a better design. It only pins today's observable public contract. That pin is the gate for the smallest extract.
Callers care about help text, files, and status. They do not care about your new folder story. Treat the current entrypoint as the public API.
What to freeze, and what to ignore
Freeze behavior that callers already depend on today. Skip internal helper names during the first pass. Callers rarely depend on a private function identifier.
Include these four channels in every fixture run:
- Combined stdout bytes after a fixed locale.
- Combined stderr bytes after a fixed locale.
- Process exit code as a decimal integer.
- A sorted map of output file digests.
Exclude clocks, random ids, and network payloads. Those fields need stubs or they thrash the goldens. Stub them before the first freeze, not after.
Do not hash Python object ids or memory addresses. Do not hash timestamps in default log formats. Strip or freeze those fields in a wrapper.
Layout the corpus first
Keep fixtures outside the messy package tree. A sibling freeze/ directory is enough. Each case needs a name, argv, env, and stdin file.
Use this layout as a starting point:
freeze/
cases/
help_flag/
argv.txt
env.txt
stdin.txt
missing_input/
argv.txt
env.txt
stdin.txt
happy_path/
argv.txt
env.txt
stdin.txt
extra_files/
sample.csv
goldens/
help_flag.json
missing_input.json
happy_path.json
outbox/
stubs/
charter.py
The argv file holds one argument per line. The env file holds KEY=value pairs only. Empty stdin files remain valid characterization cases here.
Copy input artifacts into the extra_files directory. The runner mirrors them into a sandbox. The messy script then reads local relative paths.
Step 1: Inventory the real entrypoint
Do not guess the command users actually run. Read the package docs and the CI script. Record the exact module or binary path.
Run one manual probe before writing goldens:
python -m messy_tool --help
python -m messy_tool process sample.csv
echo $?
ls -la
Write the working directory rule next. Relative output paths are common in messy scripts. Pin cwd to a temp folder per case.
Confirm the tool creates files, not only logs. List the directory after each probe command. Those paths belong in the file digest map.
Step 2: Normalize the process environment
Unset user-specific variables that leak into logs. Set PYTHONHASHSEED=0 for CPython runs. Set TZ=UTC and LANG=C.
A minimal wrapper looks like this:
export PYTHONHASHSEED=0
export TZ=UTC
export LANG=C
export LC_ALL=C
export HOSTNAME=freeze
export HOME="$PWD/freeze/outbox/home"
mkdir -p "$HOME"
If the script prints absolute paths, rewrite them. Replace the temp root with $SANDBOX before hashing. Otherwise every machine produces a new digest.
Home directory expansion is another silent leak. Set HOME to the sandbox for the run. Config files then cannot escape the case.
Stub time, uuid, and host names
Messy scripts often log wall-clock timestamps. That stamp will break every recorded freeze. Patch time at the process edge.
Set SOURCE_DATE_EPOCH=0 when the tool honors it. Otherwise prepend a tiny stub to PYTHONPATH. Keep that stub under freeze/stubs/.
# freeze/stubs/sitecustomize.py — worked example, not a library
import datetime as _dt
import uuid as _uuid
class _FrozenDateTime(_dt.datetime):
@classmethod
def now(cls, tz=None):
return cls(2020, 1, 1, 0, 0, 0, tzinfo=tz)
@classmethod
def utcnow(cls):
return cls(2020, 1, 1, 0, 0, 0)
_dt.datetime = _FrozenDateTime # type: ignore[misc]
_uuid.uuid4 = lambda: _uuid.UUID(int=0) # type: ignore[assignment]
Hostnames leak through error messages too. Set HOSTNAME=freeze in the case env. Replace remaining host bytes inside normalize().
Argparse help is a contract
Help text is part of the CLI surface. Wrapping main() can change usage lines. Freeze --help as its own case.
Watch for prog= changes after a module move. Argparse uses sys.argv[0] by default. Pass prog="messy_tool" if you own that code.
Do not improve help during the extract. Wording changes are a later contract commit. The first extract must keep help bytes stable.
Step 3: Record golden digests
The recorder must be boring and deterministic. It launches the entrypoint with the case files. It captures the four channels into JSON.
Treat the next block as a worked example. Point the ENTRY constant at your module path. Do not treat this script as production tooling.
#!/usr/bin/env python3
"""Worked example: freeze an entrypoint contract with SHA-256 digests."""
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
CASES = ROOT / "cases"
GOLDENS = ROOT / "goldens"
STUBS = ROOT / "stubs"
ENTRY = [sys.executable, "-m", "messy_tool"]
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def load_lines(path: Path) -> list[str]:
if not path.exists():
return []
return path.read_text(encoding="utf-8").splitlines()
def normalize(text: bytes, sandbox: Path) -> bytes:
token = str(sandbox).encode("utf-8")
return text.replace(token, b"$SANDBOX")
def file_digests(folder: Path, sandbox: Path) -> dict[str, str]:
result: dict[str, str] = {}
if not folder.exists():
return result
for path in sorted(folder.rglob("*")):
if path.is_file():
rel = str(path.relative_to(sandbox))
result[rel] = sha256_bytes(path.read_bytes())
return result
def run_case(name: str) -> dict:
case = CASES / name
sandbox = ROOT / "outbox" / name
if sandbox.exists():
for p in sorted(sandbox.rglob("*"), reverse=True):
if p.is_file():
p.unlink()
else:
p.rmdir()
sandbox.mkdir(parents=True, exist_ok=True)
argv = load_lines(case / "argv.txt")
env_pairs = load_lines(case / "env.txt")
stdin_path = case / "stdin.txt"
stdin = stdin_path.read_bytes() if stdin_path.exists() else b""
extra = case / "extra_files"
if extra.exists():
for src in extra.rglob("*"):
if src.is_file():
dest = sandbox / src.relative_to(extra)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(src.read_bytes())
env = os.environ.copy()
env.update(
{
"PYTHONHASHSEED": "0",
"TZ": "UTC",
"LANG": "C",
"LC_ALL": "C",
"HOSTNAME": "freeze",
"HOME": str(sandbox / "home"),
"SOURCE_DATE_EPOCH": "0",
"PYTHONPATH": str(STUBS) + os.pathsep + env.get("PYTHONPATH", ""),
}
)
(sandbox / "home").mkdir(exist_ok=True)
for pair in env_pairs:
if "=" in pair:
key, value = pair.split("=", 1)
env[key] = value
proc = subprocess.run(
ENTRY + argv,
input=stdin,
cwd=sandbox,
env=env,
capture_output=True,
check=False,
)
return {
"name": name,
"exit_code": proc.returncode,
"stdout_sha256": sha256_bytes(normalize(proc.stdout, sandbox)),
"stderr_sha256": sha256_bytes(normalize(proc.stderr, sandbox)),
"files": file_digests(sandbox, sandbox),
}
def main(mode: str) -> int:
GOLDENS.mkdir(exist_ok=True)
names = sorted(p.name for p in CASES.iterdir() if p.is_dir())
failed = 0
for name in names:
got = run_case(name)
golden_path = GOLDENS / f"{name}.json"
if mode == "record":
text = json.dumps(got, indent=2, sort_keys=True) + "\n"
golden_path.write_text(text, encoding="utf-8")
print(f"recorded {name}")
continue
if not golden_path.exists():
print(f"missing golden: {name}")
failed += 1
continue
expected = json.loads(golden_path.read_text(encoding="utf-8"))
if got != expected:
print(f"MISMATCH {name}")
print("expected", json.dumps(expected, sort_keys=True))
print("got ", json.dumps(got, sort_keys=True))
failed += 1
else:
print(f"ok {name}")
return 1 if failed else 0
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "replay"
raise SystemExit(main(mode))
Record once against the messy tree:
python freeze/charter.py record
git add freeze/goldens freeze/cases freeze/charter.py freeze/stubs
git commit -m "freeze entrypoint digests before extract"
python freeze/charter.py replay
Replay must remain the default command path. Recording should stay a conscious local act. Keep the golden JSON files in git.
If record and replay differ on a clean tree, stop. Your environment is not yet deterministic. Fix stubs before any extract work.
Step 4: Choose the smallest extract
The extract is a file move plus import fix. The extract must not become a behavior rewrite. Keep function bodies byte-close to the original.
Use this order:
- Pick one cohesive helper cluster with one concern.
- Create a new module with no new behavior.
- Re-export the old names from the original file.
- Replay the freeze harness to completion.
- Stop if any digest moves.
A safe first extract is a pure parser. I/O wrappers belong in a later change. Mixing both in one move hides the fault.
Prefer a cluster with few importers. Wide sharing makes a split noisy. Leave shared logging utilities where they sit.
A concrete extract looks like this:
# messy_tool/parse_csv.py — new file, old bodies copied unchanged
def parse_row(line: str) -> dict[str, str]:
...
# messy_tool/__init__.py — old path keeps the public names
from messy_tool.parse_csv import parse_row # noqa: F401
Do not rename arguments during that copy. Do not inline a helper "while you are here." Those extras belong in a later commit.
Step 5: Replay, then read the mismatch
A mismatch is data, not a vibe. Compare the JSON keys from the mismatch in order. Exit code shifts remain the cheapest first clue.
Typical causes rank like this:
- Working directory changed, so relative files moved.
- Log line order changed after an import shuffle.
- Exception text changed after a wrap.
- Help text gained or lost a trailing newline.
Fix the extract or the stub next. Do not update the golden files first. Golden updates are for true contract changes only.
Print both JSON blobs with sorted keys. Human eyes miss a single newline byte. The hasher will not miss that byte.
python freeze/charter.py replay
diff -u freeze/goldens/happy_path.json /dev/stdin <<'EOF'
# paste the "got" blob only when debugging a mismatch
EOF
Decision table
| Signal | Action | Stop condition |
|---|---|---|
| Seeded corpus missing | Build cases, do not extract | Fewer than three cases |
| Record succeeds, replay dirty | Fix env stubs and path rewrites | Hash still drifts |
| Replay green, helper is pure | Extract one module | Any golden breaks |
| Replay green, helper does I/O | Defer extract; add seams | Need extra stubs |
| Model suggests a rewrite | Reject the patch | Diff touches many files |
| Help text is the only drift | Check argparse prog
|
Unrelated files also drift |
| Exit code flipped | Restore exception paths | Goldens rewritten to match |
Three cases is a floor, not a target. Cover help, error, and one happy path. Add the last shipped bug as a fourth case.
If two cases already disagree before the extract, stop. The tree is not frozen yet. An extract on a dirty freeze proves nothing.
Where a free coding model belongs
The model runs after the freeze is green. It does not choose the first patch. Feed it the candidate cluster and the public names only.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option. Use the model to sketch the new module skeleton.
Use the free server when a laptop env would pollute hashes. Replay the same charter.py command in both places. If you already have free model access, spend it on the extract sketch, not a rewrite.
Do not paste secrets into the model prompt. Do not ask for a full tree rewrite. The useful request is a file split that preserves names.
A tight prompt shape looks like this:
Move parse_row and split_header into parse_csv.py.
Keep function signatures and bodies unchanged.
Re-export the old names from the original module.
Do not edit help text, logging, or file I/O.
Then run python freeze/charter.py replay. Accept the patch only on a green harness. Delete the branch if any digest moves.
Limitations
This freeze cannot observe hidden internal process state. Hidden caches can still rot after a clean replay. Threads and network calls need extra seams.
SHA-256 equality is brittle by design. Locale leaks will fail the recorded suite. That brittleness is the point for refactors.
The harness does not replace unit tests. New behavior still needs explicit new assertions. Characterization only guards the extract step.
Binary outputs need the file map, not stdout. Huge artifacts will slow git history. Store hashes, not copies, for large blobs.
Windows line endings will shift every digest. Pick one newline policy and enforce it. .gitattributes can pin eol=lf for goldens.
# .gitattributes
freeze/goldens/*.json text eol=lf
freeze/cases/**/*.txt text eol=lf
Who should not use this
Skip this workflow if CI already pins the contract. Extra goldens then add noise without signal. Use the existing suite as the freeze.
Do not freeze production data that contains secrets. Redact fixtures before they land in git. Synthetic rows are enough for most CLIs.
Skip it for long-running servers without a finite task. You need a bounded entrypoint for this method. Wrap one request or one job instead.
Do not use a model-led rewrite as the first move. The hashes exist to forbid that move. Reviewers should reject unfrozen cleanup diffs.
After the extract holds
Keep the old re-exports for one release. Callers should not notice the file split. Delete the shims only after importers move.
Add one new unit test for the extracted module. The freeze stays at the entrypoint edge. The unit test covers the new internal seam.
If a later change must alter output, record it. Name that commit as a contract change. Do not mix that change with a file split.
The next safe step is another small extract. It is not a layer redesign. Repeat the freeze, extract, replay loop.
Top comments (0)