DEV Community

Dakota Huang
Dakota Huang

Posted on

Hash Every Write Before You Extract a Helper

Messy repos fail at the first cleanup commit. Untracked writes hide regressions that tests never saw. Hash those writes first. Then change one helper.

A refactor is valid only when output hashes stay identical. Pretty file trees do not prove behavior. A ledger of paths, bytes, and return values does.

The failure this workflow blocks

AI-assisted edits make internal moves cheap. Cheap moves still mutate scripts that write files. Agents also assume helpers are pure when they are not.

Brownfield dumpers mix parsing, I/O, and formatting. One extract can change key order or trailing newlines. Reviewers miss that. A hash does not.

This article is a method, not a memoir. Treat the code as a labeled proposal. Run it on a copy of your repo.

What the ledger must freeze

Record three facts for every fixture run.

  1. Relative output paths under a sandbox root.
  2. SHA-256 of each written file's bytes.
  3. SHA-256 of stdout, stderr, and the JSON return.

Do not record wall-clock timestamps. Do not record absolute host paths. Those fields are noise, not product behavior.

Skip network calls in the first freeze. Stub them with fixture files. Network jitter will poison every hash.

Worked example: a brownfield dumper

The script below is a compact, runnable proposal. It models a report dumper, not a framework. It writes JSON and a CSV sidecar.

# dump_report.py — proposed example, not production code
from __future__ import annotations

import csv
import json
from pathlib import Path
from typing import Any


def dump_report(src: Path, out_dir: Path) -> dict[str, Any]:
    raw = json.loads(src.read_text(encoding="utf-8"))
    rows = raw.get("rows") or []
    total = 0
    for row in rows:
        total += int(row.get("amount") or 0)

    out_dir.mkdir(parents=True, exist_ok=True)
    payload = {"count": len(rows), "total": total, "rows": rows}
    json_path = out_dir / "report.json"
    json_path.write_text(json.dumps(payload) + "\n", encoding="utf-8")

    csv_path = out_dir / "report.csv"
    with csv_path.open("w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=["id", "amount"])
        writer.writeheader()
        for row in rows:
            writer.writerow({"id": row.get("id"), "amount": row.get("amount")})

    return {"json": str(json_path.name), "csv": str(csv_path.name), "total": total}
Enter fullscreen mode Exit fullscreen mode

The bug surface is small and typical. Key order depends on json.dumps. CSV newlines depend on newline="". An extract can change either without failing a visual diff.

Step 1 — Pin one fixture

Create a tiny input that still hits both writers. Keep it in fixtures/in/sample.json.

{
  "rows": [
    {"id": "a", "amount": 10},
    {"id": "b", "amount": 25}
  ]
}
Enter fullscreen mode Exit fullscreen mode

One fixture is enough to start. Add a second fixture only after the first ledger is green. Extra fixtures without a green baseline just multiply noise.

Step 2 — Capture hashes, not objects

The harness below is a proposed characterization tool. It sandboxes writes and hashes bytes. It does not mock the production filesystem.

# ledger.py — proposed example, not production code
from __future__ import annotations

import hashlib
import io
import json
import sys
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from typing import Any, Callable

from dump_report import dump_report


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def hash_tree(root: Path) -> dict[str, str]:
    out: dict[str, str] = {}
    for path in sorted(root.rglob("*")):
        if path.is_file():
            rel = path.relative_to(root).as_posix()
            out[rel] = sha256_bytes(path.read_bytes())
    return out


def run_case(src: Path, sandbox: Path) -> dict[str, Any]:
    stdout_buf = io.StringIO()
    stderr_buf = io.StringIO()
    with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
        result = dump_report(src, sandbox)
    return {
        "files": hash_tree(sandbox),
        "stdout": sha256_bytes(stdout_buf.getvalue().encode("utf-8")),
        "stderr": sha256_bytes(stderr_buf.getvalue().encode("utf-8")),
        "result": sha256_bytes(
            json.dumps(result, sort_keys=True, separators=(",", ":")).encode("utf-8")
        ),
    }


def write_ledger(path: Path, payload: dict[str, Any]) -> None:
    path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
Enter fullscreen mode Exit fullscreen mode

sort_keys=True belongs in the ledger, not in production by default. The ledger must be stable. Production JSON may keep insertion order. Do not “fix” production during the freeze.

Step 3 — Freeze, then refuse edits

Run the freeze once on a clean tree. Commit the JSON ledger in the same change. After that, code edits must not touch the ledger unless behavior is intentional.

python - <<'PY'
from pathlib import Path
from ledger import run_case, write_ledger

src = Path("fixtures/in/sample.json")
sandbox = Path("fixtures/out")
if sandbox.exists():
    raise SystemExit("sandbox must be empty before freeze")
sandbox.mkdir(parents=True)
payload = run_case(src, sandbox)
write_ledger(Path("fixtures/ledger.sample.json"), payload)
print("froze", Path("fixtures/ledger.sample.json"))
PY
Enter fullscreen mode Exit fullscreen mode

Expected ledger shape looks like this. Hashes will differ on your machine only if bytes differ.

{
  "files": {
    "report.csv": "<sha256>",
    "report.json": "<sha256>"
  },
  "result": "<sha256>",
  "stderr": "<sha256>",
  "stdout": "<sha256>"
}
Enter fullscreen mode Exit fullscreen mode

If freeze output includes host paths, stop. Your function leaked environment into the contract. Fix the leak before any extract.

Step 4 — Add a failing check, not a hope

The check must be boring and strict. Missing files fail. Extra files fail. Hash mismatches fail.

# test_ledger.py — proposed example, not production code
import json
import shutil
from pathlib import Path

from ledger import run_case


def test_sample_fixture_holds(tmp_path: Path) -> None:
    expected = json.loads(Path("fixtures/ledger.sample.json").read_text(encoding="utf-8"))
    src = Path("fixtures/in/sample.json")
    sandbox = tmp_path / "out"
    sandbox.mkdir()
    actual = run_case(src, sandbox)
    assert actual == expected, (expected, actual)
Enter fullscreen mode Exit fullscreen mode
python -m pytest test_ledger.py -q
Enter fullscreen mode Exit fullscreen mode

Do not start the refactor if this test is red. Characterization that already fails is not a baseline. It is an unknown.

Step 5 — Extract one helper only

Change one internal concern after the ledger is green. Here the candidate is totaling. Leave JSON and CSV writers untouched in the same patch.

def sum_amounts(rows: list[dict]) -> int:
    total = 0
    for row in rows:
        total += int(row.get("amount") or 0)
    return total
Enter fullscreen mode Exit fullscreen mode

Wire it in dump_report and stop. Do not rename files in the same commit. Do not reformat JSON in the same commit. Do not “while we are here” the CSV header.

Re-run the same pytest command. A green ledger means the extract preserved bytes. A red ledger means the extract was not behavior-preserving.

Decision table when a hash moves

Use the table. Do not guess from the diff view.

Moving field Likely cause Safe next step
files.report.json only dumps separators, key order, or trailing newline Revert extract; keep writer code frozen
files.report.csv only newline, header set, or None rendering Revert extract; pin newline=""
result only Return dict keys or path strings changed Return names, not absolute paths
stdout or stderr Accidental print or warning Delete the print; do not update the ledger
Every field Fixture path or sandbox leak Destroy sandbox; freeze again on a copy

Update the ledger only when product behavior changed on purpose. Then treat it as a feature commit, not a refactor commit.

Where a free model belongs

The model proposes the extract after the ledger is green. It does not get to edit the ledger file. Paste the helper and the failing assertion, not the whole tree.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can run this loop when you lack a spare local runner. The ledger remains the gate. The model remains a patch source.

Reject any patch that touches fixtures/ledger.sample.json plus production code together. That pairing hides a behavior change inside a “cleanup.”

Command sequence you can copy

Keep the sequence short and repeatable. Abort at the first red step.

  1. git status --short and confirm a clean tree.
  2. Freeze the ledger on a throwaway sandbox.
  3. python -m pytest test_ledger.py -q and require green.
  4. Extract one helper. No extra formatting.
  5. Re-run pytest. Revert on any mismatch.
  6. git diff --stat and confirm the ledger file is untouched.

If git diff --stat shows the ledger, the refactor is already invalid. Restore the ledger from HEAD. Then restore the production file.

git restore --source=HEAD -- fixtures/ledger.sample.json dump_report.py
Enter fullscreen mode Exit fullscreen mode

Limitations

Hashes do not explain intent. They only detect byte drift. Two different bugs can share one hash collision in theory. SHA-256 makes that unlikely for this workflow. It does not make it a proof of correctness.

Nondeterministic dumps break the method. Random IDs, datetime.now(), unordered sets, and unfinished iteration all churn hashes. Freeze time and freeze iteration order first. If you cannot freeze them, do not use this ledger.

Binary files work only if they are bit-stable. Compressed archives with timestamps will fail forever. Image pipelines with encoder versions will fail forever. Prefer textual sidecars for the first freeze.

Concurrency is out of scope. Two writers in one sandbox race the tree hash. Characterize a single-threaded path first.

Who should not use this

Skip this if the module already has precise unit tests. A hash ledger is coarser than a real oracle. It is a bootstrap for untested dumpers.

Skip this if outputs contain secrets. Hashing does not remove secrets from disk. Fixtures in git would leak them. Redact first or do not freeze.

Skip this if the contract is time, money, or protocol correctness. Byte equality can still be the wrong business result. Use domain assertions there.

Skip this if you need to change format on purpose. A serializer migration is not a refactor. Split that work into an explicit compatibility commit.

What “smallest safe change” means here

Smallest means one helper, one commit, one re-run. Safe means the ledger file is bit-identical to HEAD. Anything larger is a product change wearing refactor clothes.

Public surface diffs still matter later. Fan-in still matters later. This workflow only answers one question. Did the messy writer emit the same bytes after the extract?

If you already have free model access, send ledger failures, not the entire messy tree. The hashes tell you whether the smallest change was actually safe.

Top comments (0)