DEV Community

Dakota Huang
Dakota Huang

Posted on

Hash the Output Tree Before You Extract One Helper

A messy repo does not need a rewrite first.
It needs a frozen output tree as the oracle.
Then you extract one pure helper. Stop there.

AI diffs look tidy and still change behavior.
Cheap generation does not make cheap verification.
A directory hash catches drift before reviews do.

The actual failure

Brownfield scripts mix I/O, scoring, and prints.
Callers depend on files, not function names.
Internal unit tests miss those file contracts.

A full rewrite usually moves every seam at once.
That hides which edit broke the tree.
One helper plus one hash keeps blame local.

What the oracle must lock

Lock three facts only on pass one.

  1. Process exit code after a fixture run.
  2. Relative paths of every produced file.
  3. SHA-256 digest of each produced file.

Do not lock timestamps, PID strings, or cwd.
Do not lock import graphs or private names.
Those change during a safe extract.

Stdout can wait until the tree is stable.
Logs are noisy. Files are the product.
Hash files first. Then consider log pins.

A messy script worth pinning

The sample below is a stocktake CLI.
It globs CSVs, scores rows, and writes JSON.
Scoring sits inside I/O. That is the mess.

# stocktake.py — characterization target, not production advice
from __future__ import annotations

import csv
import json
import sys
from pathlib import Path


def run(argv: list[str]) -> int:
    if len(argv) != 3:
        print("usage: stocktake.py IN_DIR OUT_DIR", file=sys.stderr)
        return 2
    in_dir = Path(argv[1])
    out_dir = Path(argv[2])
    out_dir.mkdir(parents=True, exist_ok=True)
    rows = []
    for path in sorted(in_dir.glob("*.csv")):
        with path.open(newline="", encoding="utf-8") as handle:
            for raw in csv.DictReader(handle):
                sku = (raw.get("sku") or "").strip()
                qty = int(raw.get("qty") or "0")
                price = float(raw.get("price") or "0")
                flag = (raw.get("flag") or "").lower()
                score = qty * price
                if flag == "haz":
                    score *= 1.25
                elif flag == "bulk":
                    score *= 0.85
                if qty == 0:
                    score = 0.0
                rows.append({"sku": sku, "score": round(score, 2), "src": path.name})
    payload = {"count": len(rows), "items": rows}
    target = out_dir / "stocktake.json"
    target.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    print(f"wrote {target}")
    return 0


if __name__ == "__main__":
    raise SystemExit(run(sys.argv))
Enter fullscreen mode Exit fullscreen mode

The scoring rules are the later extract target.
Leave them nested until the tree hash is green.
That order is the whole method.

Numbered workflow

1. Build a tiny fixture tree

Use two CSVs and one empty output folder.
Keep rows ugly. Real mess beats pretty samples.
Commit the fixture. Never generate it later.

fixtures/stocktake/in/alpha.csv
fixtures/stocktake/in/beta.csv
fixtures/stocktake/out/          # empty, gitkeep only
Enter fullscreen mode Exit fullscreen mode
sku,qty,price,flag
A-1,2,10.00,haz
A-2,0,9.50,bulk
Enter fullscreen mode Exit fullscreen mode
sku,qty,price,flag
B-9,4,3.25,
B-8,1,100.00,BULK
Enter fullscreen mode Exit fullscreen mode

Note the mixed case on BULK.
The script lowercases flags. The hash must keep that.

2. Record a golden tree, not a vibe

Run the script against a temp copy.
Hash every file under the output directory.
Store the manifest next to the fixture.

python3 stocktake.py fixtures/stocktake/in /tmp/stock-out
python3 hash_tree.py /tmp/stock-out > fixtures/stocktake/golden.sha256
Enter fullscreen mode Exit fullscreen mode

Re-run twice. The two manifests must match.
If they do not, strip time and host fields.
Non-determinism is a blocker, not a style note.

3. Fail closed on any hash drift

The test copies the fixture, runs the CLI, hashes.
Any new path, missing path, or digest change fails.
Exit code is checked before file compares.

4. Extract one helper only

Move the score math. Leave glob and JSON in place.
Do not rename output keys in the same patch.
Do not add logging in the same patch.

5. Re-run the same oracle

Green hash means behavior held for this fixture.
Red hash means the extract leaked a rule change.
Do not “fix” the golden file to silence it.

Characterization harness

Save this as hash_tree.py beside the script.
It is the oracle. Keep it boring and local.

# hash_tree.py
from __future__ import annotations

import hashlib
import sys
from pathlib import Path


def digest_file(path: Path) -> str:
    hasher = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(65536), b""):
            hasher.update(chunk)
    return hasher.hexdigest()


def manifest(root: Path) -> str:
    lines = []
    for path in sorted(p for p in root.rglob("*") if p.is_file()):
        rel = path.relative_to(root).as_posix()
        lines.append(f"{digest_file(path)}  {rel}")
    return "\n".join(lines) + ("\n" if lines else "")


if __name__ == "__main__":
    print(manifest(Path(sys.argv[1])), end="")
Enter fullscreen mode Exit fullscreen mode

The test driver stays equally small.
No network. No model. No extra plugins.

# test_stocktake_tree.py
from __future__ import annotations

import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent
GOLDEN = (ROOT / "fixtures/stocktake/golden.sha256").read_text(encoding="utf-8")


def test_stocktake_output_tree(tmp_path: Path) -> None:
    out_dir = tmp_path / "out"
    proc = subprocess.run(
        [sys.executable, str(ROOT / "stocktake.py"), str(ROOT / "fixtures/stocktake/in"), str(out_dir)],
        check=False,
        capture_output=True,
        text=True,
    )
    assert proc.returncode == 0, proc.stderr
    hashed = subprocess.check_output(
        [sys.executable, str(ROOT / "hash_tree.py"), str(out_dir)],
        text=True,
    )
    assert hashed == GOLDEN
Enter fullscreen mode Exit fullscreen mode

Run it with one command.

python3 -m pytest test_stocktake_tree.py -q
Enter fullscreen mode Exit fullscreen mode

A failing assert prints two manifests.
Diff those strings. Do not read the model patch first.

Decision table

Signal Action Stop condition
Exit code flips Revert the extract Fixture cannot start
Path set grows Revert, then inspect writes Surprise file appeared
Digest drifts Diff JSON, then revert Score or key changed
Hash holds, names changed Keep the extract Public tree unchanged
Hash holds, extra logs Optional later pin Files still match
Need a second helper New patch, same oracle One concern per diff

Use the table during review, not after merge.
One red cell blocks the extract.
Green cells do not license a rewrite.

The smallest safe change

After the golden file exists, extract scoring only.
Keep argument order and rounding identical.
Do not “clean” flag handling in this patch.

def score_item(qty: int, price: float, flag: str) -> float:
    score = qty * price
    normalized = flag.lower()
    if normalized == "haz":
        score *= 1.25
    elif normalized == "bulk":
        score *= 0.85
    if qty == 0:
        score = 0.0
    return round(score, 2)
Enter fullscreen mode Exit fullscreen mode

Wire it in with a one-line call.
Leave CSV reading and JSON writing untouched.
Re-run pytest. The tree hash must match.

That is the entire refactor for day one.
A second helper waits for a second green run.
Scope is a safety control, not a style preference.

Where a free model can sit

A model may propose the extract text.
It must not author the golden manifest.
Humans, or the harness, own the oracle.

MonkeyCode provides free model access and a free server option for that proposal step. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Keep generation on the server if the laptop is busy. Keep hashes on disk you control.

Feed the model the messy function and the test.
Do not feed it a request to “improve scoring.”
Improved scoring is a behavior change. Reject it.

If the patch touches JSON keys, discard it.
If the patch adds default flags, discard it.
If the patch only relocates score_item, review it.

Limitations

This oracle is only as wide as the fixture.
Unseen flags, encodings, and empty dirs slip through.
Add rows when a production file surprises you.

SHA-256 ignores meaning. It only detects bytes.
Equivalent JSON with different key order will fail.
That is intended. Stabilize dumps with sort_keys.

The harness will not catch performance regressions.
It will not catch permission errors on other OS users.
It will not certify tax or safety rules.

Floating point remains a fixture problem.
Pin round(..., 2) in both code and samples.
Do not compare raw floats across machines.

Who should not use this

Skip this if the script has no file outputs.
Skip this if outputs include live timestamps.
Skip this if you cannot copy production-like CSVs.

Do not use directory hashes for secret material.
Do not commit customer dumps as fixtures.
Redact, then hash. Never hash raw PII.

Teams without a test runner gain little here.
The method needs a repeatable command.
A screenshot of “it worked” is not an oracle.

Close the loop

Start with a fixture and a tree hash.
Extract one pure helper. Re-hash the same tree.
Ship that patch. Schedule the next seam later.

If you publish a follow-up, paste the two manifests.
Skip the narrative about confidence.
The bytes either matched or they did not.

Top comments (0)