Mixed I/O and encoding should not move together.
Freeze the output tree before any helper extract.
A tangled write_report function looks like an easy cleanup.
It resolves paths, builds a dict, dumps JSON, and replaces a file.
Those four jobs fail in very different ways.
One naive helper extract usually changes output bytes.
Callers then fail on disk after a green unit test.
The failure mode
Cleanup refactors often keep the public function name.
They still change file names, key order, or trailing newlines.
Reviewers miss those shifts inside a pure rename diff.
AI-assisted edits often amplify this exact pattern.
The model sees mixed concerns and proposes a large helper.
Your tests still pass if they only check True.
What this article freezes
This worked example pins three separate disk observables.
They are path set, byte size, and content digest.
Timestamps stay out of the frozen contract.
The smallest safe change is one encoder function.
Path policy and replace policy both stay put.
The messy module
Treat the next file as a worked example, not telemetry.
Save it as report_writer.py beside your tests.
# report_writer.py
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
def write_report(root: Path, name: str, rows: list[dict[str, Any]]) -> Path:
root = root.resolve()
out_dir = root / "reports" / name
out_dir.mkdir(parents=True, exist_ok=True)
payload = {
"name": name,
"count": len(rows),
"rows": rows,
}
text = json.dumps(payload, indent=2, sort_keys=True)
text += "\n"
target = out_dir / "latest.json"
tmp = out_dir / ".latest.json.tmp"
tmp.write_text(text, encoding="utf-8")
os.replace(tmp, target)
sidecar = out_dir / "count.txt"
sidecar.write_text(str(len(rows)) + "\n", encoding="utf-8")
return target
That function mixes filesystem policy with JSON formatting.
sort_keys=True and the trailing newline are load-bearing.
The sidecar file is easy to drop during a cleanup.
Observables worth pinning
Number these observables before you touch any helpers.
Keep volatile metadata out of the assertion set.
- Collect relative paths under
root, then sort them. - Record the byte length of each written file.
- Store a SHA-256 digest of each file body.
- Pin the return path relative to
root. - Assert leftover
.tmpfiles do not remain.
Do not pin absolute paths from the host.
Do not pin directory mtime or inode numbers.
Those values change across machines and test reruns.
Characterization harness
Treat the next module as an unexecuted pytest example.
Run that file under pytest on your machine.
# test_report_writer_char.py
from __future__ import annotations
import hashlib
from pathlib import Path
from report_writer import write_report
# Replace both after one local run against this fixture.
PINNED_JSON_SIZE = 0
PINNED_JSON_SHA256 = "replace-after-one-local-run"
def _tree_snapshot(root: Path) -> dict[str, dict[str, int | str]]:
snap: dict[str, dict[str, int | str]] = {}
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(root).as_posix()
data = path.read_bytes()
snap[rel] = {
"size": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
}
return snap
def test_write_report_tree_is_stable(tmp_path: Path) -> None:
rows = [
{"id": 2, "ok": False, "label": "α"},
{"id": 1, "ok": True, "label": "β"},
]
returned = write_report(tmp_path, "alpha", rows)
snap = _tree_snapshot(tmp_path)
json_bytes = (tmp_path / "reports/alpha/latest.json").read_bytes()
assert returned.relative_to(tmp_path).as_posix() == "reports/alpha/latest.json"
assert set(snap) == {
"reports/alpha/latest.json",
"reports/alpha/count.txt",
}
assert snap["reports/alpha/count.txt"]["size"] == 2
assert snap["reports/alpha/count.txt"]["sha256"] == hashlib.sha256(b"2\n").hexdigest()
assert json_bytes.endswith(b"\n")
assert b"\\u03b1" in json_bytes
assert b"\\u03b2" in json_bytes
assert not list(tmp_path.rglob("*.tmp"))
json_meta = snap["reports/alpha/latest.json"]
if PINNED_JSON_SIZE == 0:
raise AssertionError(
f"pin size={json_meta['size']} sha256={json_meta['sha256']}"
)
assert json_meta["size"] == PINNED_JSON_SIZE
assert json_meta["sha256"] == PINNED_JSON_SHA256
Run this pytest command before any production edit.
pytest test_report_writer_char.py -q
Record the snapshot keys in the failure output.
Do not start the extract while this test is red.
Copy pin size and pin sha256 into the constants.
Run pytest a second time until the harness stays green.
Commit that test file with no production diff.
Why a tree hash beats one file assert
The write_report helper writes two files, not one.
A latest.json equality test misses sidecar file deletion.
Tree hashing makes that deletion a red test.
Byte size catches dropped trailing newlines very fast.
SHA-256 then catches key reorder and escape changes.
Together they are cheaper than parsing JSON in every case.
Non-ASCII rows belong in the freeze
The default json.dumps call will escape non-ASCII characters.
A later ensure_ascii=False switch rewrites every file digest.
Pin a row that contains a non-ASCII name.
The harness above uses α and β for that reason.
Escaped forms must remain \u03b1 and \u03b2.
Pretty-printer refactors usually break those two substrings.
Decision table
Use this table before you open a diff.
Extract only the row that keeps bytes identical.
| Candidate extract | Freeze first | Extract now? |
|---|---|---|
| JSON encoder only | exact bytes, trailing newline, Unicode escapes | Yes, if tests stay green |
Path layout (reports/<name>) |
relative path set | No, unless callers demand it |
os.replace tmp dance |
leftover tmp absence | No, it is a crash contract |
Sidecar count.txt
|
size plus digest | No, it is observable output |
mkdir(parents=True) |
missing parent behavior | No, it is filesystem policy |
ensure_ascii default |
\u03b1 bytes in latest.json
|
No, it is a digest contract |
The JSON encoder is the only small seam.
Everything else remains user-visible policy on disk.
Numbered workflow
Follow this numbered order without skipping steps.
Each skipped step usually widens the extract.
- Copy the messy function onto a throwaway branch.
- Add the tree-hash characterization test beside it.
- Run pytest until the snapshot assertion stays green.
- Commit the test with no production code diff.
- Extract only
encode_payloadthat returns the JSON text. - Keep indent,
sort_keys, and the trailing newline. - Run pytest again against the same snapshot.
- Reject extra files, renames, or JSON key reorders.
Here is the only allowed production change.
Leave every path string exactly as written.
def encode_payload(payload: dict[str, Any]) -> str:
text = json.dumps(payload, indent=2, sort_keys=True)
return text + "\n"
def write_report(root: Path, name: str, rows: list[dict[str, Any]]) -> Path:
root = root.resolve()
out_dir = root / "reports" / name
out_dir.mkdir(parents=True, exist_ok=True)
payload = {
"name": name,
"count": len(rows),
"rows": rows,
}
target = out_dir / "latest.json"
tmp = out_dir / ".latest.json.tmp"
tmp.write_text(encode_payload(payload), encoding="utf-8")
os.replace(tmp, target)
sidecar = out_dir / "count.txt"
sidecar.write_text(str(len(rows)) + "\n", encoding="utf-8")
return target
That production diff should stay tiny and local.
If the patch touches mkdir, revert the edit.
A patch you should reject
Label the next block as a rejected extract example.
It looks cleaner and still breaks the tree hash.
def write_report(root: Path, name: str, rows: list[dict[str, Any]]) -> Path:
out_dir = root / "reports" / name
out_dir.mkdir(parents=True, exist_ok=True)
payload = {"name": name, "count": len(rows), "rows": rows}
target = out_dir / "latest.json"
target.write_text(json.dumps(payload, indent=2), encoding="utf-8")
return target
That version drops os.replace and the sidecar file.
It also drops sort_keys, so key order can drift.
The characterization test should fail on three snapshot fields.
Where a coding model fits
A model is useful after step 4, not before.
It can propose the encoder extract against a green harness.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access and a free server option.
Those are relevant when your laptop cannot host a local assistant.
Keep pytest running on your machine either way.
Paste the frozen test and the messy function only.
Ask for the smallest function extract that keeps bytes identical.
Do not ask it to clean up the module.
Discard any patch that changes those path strings.
Discard any patch that drops the count.txt sidecar.
Discard any patch that removes the sort_keys flag.
Limits of tree-hash characterization
This harness freezes bugs as well as features.
A wrong sidecar stays wrong until you add an intent test.
SHA-256 will fail on any new pretty-printer default.
The same harness also ignores concurrent directory writers.
Two writers in one directory need a different contract.
It ignores permissions, umask, and filesystem case folding.
JSON key order depends on the sort_keys flag.
Turn that flag off and the digest moves.
That is the point of pinning bytes.
Who should not use this
Do not use this as a substitute for review.
Do not extract helpers on a shared production branch.
Do not skip the commit that contains tests only.
Skip this approach if you lack an isolated temp root.
Skip it if the writer must stream to a network socket.
Skip it if the payload contains secrets you cannot hash in CI.
Practical checks after the extract
Run these commands on the same working branch.
Stop if the second command lists extra files.
pytest test_report_writer_char.py -q
git diff --stat
git diff -U3 -- report_writer.py
Expect one new function and no path edits.
Expect test files unchanged after the first commit.
If the stat output lists extra modules, revert.
The extract has grown too large to trust.
Closing
Freeze the report tree before any helper extract.
Extract the JSON encoder only after that freeze.
Leave path policy and replace policy fully unchanged.
That review order survives messy application repositories well.
It also survives patches drafted by a coding model.
If you run the harness, keep the extract smaller than the snapshot.
A larger extract usually means the freeze was ignored.
Top comments (0)