DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin the Written Tree Before One Internal Extract

Do not extract helpers from a file-writing module first.
Pin every relative path the module writes first.
Then change one internal function, nothing else.

A messy writer hides coupling inside directories, not return values.
Timestamps, temp names, and relative paths leak into that tree.
A green unit test can still ship a different folder layout.

This workflow treats the output tree as the public contract.
The oracle is sorted relative paths, byte sizes, and SHA-256 digests.
Freeze clock and working directory before any extract.

The failure mode

File writers mix naming, formatting, and filesystem I/O.
Teams extract format_row because it looks local and pure.
The extract then changes a filename, subdirectory, or trailing newline.

Return-shape tests miss that class of break.
Stdout transcripts miss files the process never prints.
You need the artifact tree itself as the merge gate.

Dated folders make the trap worse.
datetime.now() rewrites both path and payload every run.
Without a frozen clock, no fixture can stay stable.

What you pin

Pin four facts before you touch internals.

  1. Working directory at invocation time.
  2. Clock, timezone, and locale inputs.
  3. Relative output paths under a sandbox root.
  4. Byte size and SHA-256 of each written file.

Do not pin absolute paths from the host.
Do not pin usernames, home directories, or temp prefixes.
Those values are environment, not product behavior.

Skip empty directories unless a tool depends on them.
Most writers only contract on files that exist.
Record deletions as missing paths, not extra assertions.

Illustrative messy writer

The module below is a labeled proposal, not production code.
It writes a dated JSON report and a sidecar checksum file.
The public return value is one Path object only.

# report_writer.py — illustrative messy module
from __future__ import annotations

import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path


def write_daily_report(rows: list[dict], out_dir: Path) -> Path:
    now = datetime.now(timezone.utc)
    day = now.strftime("%Y-%m-%d")
    stamp = now.strftime("%H%M%S")
    batch = out_dir / day
    batch.mkdir(parents=True, exist_ok=True)
    report = batch / f"report-{stamp}.json"
    payload = {
        "generated_at": now.isoformat(),
        "count": len(rows),
        "rows": rows,
    }
    text = json.dumps(payload, indent=2, sort_keys=True) + "\n"
    report.write_text(text, encoding="utf-8")
    digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
    sidecar = batch / f"report-{stamp}.sha256"
    sidecar.write_text(digest + "\n", encoding="utf-8")
    return report
Enter fullscreen mode Exit fullscreen mode

The real contract is two files under a dated folder.
Extracting JSON formatting without a tree oracle is unsafe.
Path construction is part of behavior, not a private detail.

Artifact: tree inventory helper

Keep the inventory in tests, not in the product module.
The helper walks a sandbox root and emits a stable list.
Sorted relative paths remove readdir order noise.

# tests/tree_oracle.py — illustrative
from __future__ import annotations

import hashlib
import json
from pathlib import Path


def inventory_tree(root: Path) -> list[dict]:
    records: list[dict] = []
    for path in sorted(root.rglob("*")):
        if not path.is_file():
            continue
        data = path.read_bytes()
        rel = path.relative_to(root).as_posix()
        records.append(
            {
                "path": rel,
                "bytes": len(data),
                "sha256": hashlib.sha256(data).hexdigest(),
            }
        )
    return records


def format_inventory(records: list[dict]) -> str:
    lines = [
        f"{row['bytes']:8d}  {row['sha256'][:12]}  {row['path']}"
        for row in records
    ]
    return "\n".join(lines) + "\n"


def dump_fixture(root: Path, fixture: Path) -> None:
    payload = inventory_tree(root)
    fixture.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
Enter fullscreen mode Exit fullscreen mode

Byte length catches silent newline drift.
The short hex prefix is for failure diffs only.
The fixture still stores the full digest.

Freeze time before the snapshot

Clock drift creates a new filename on every run.
Patch datetime at the module that calls now.
Do not patch the stdlib globally across tests.

# tests/test_report_writer_tree.py — illustrative
from __future__ import annotations

import json
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import patch

from report_writer import write_daily_report
from tree_oracle import format_inventory, inventory_tree

FROZEN = datetime(2026, 9, 9, 14, 30, 0, tzinfo=timezone.utc)
ROWS = [{"id": 1, "sku": "A-100", "qty": 2}]
FIXTURE = Path(__file__).parent / "fixtures" / "report_tree.json"


class FrozenDateTime:
    @staticmethod
    def now(tz=None):
        return FROZEN


def _run_writer(tmp_path: Path) -> None:
    with patch("report_writer.datetime", FrozenDateTime):
        write_daily_report(ROWS, tmp_path)


def test_written_tree_matches_fixture(tmp_path: Path) -> None:
    _run_writer(tmp_path)
    got = inventory_tree(tmp_path)
    expected = json.loads(FIXTURE.read_text(encoding="utf-8"))
    if got != expected:
        raise AssertionError(
            "tree mismatch\n"
            f"expected:\n{format_inventory(expected)}"
            f"got:\n{format_inventory(got)}"
        )
Enter fullscreen mode Exit fullscreen mode

Label SHA values as first-run captures, not designs.
Run the messy module once under the frozen clock.
Paste the inventory JSON. Then lock the fixture file.

Do not compute expected hashes by hand.
The first run is characterization, not target architecture.
A later hash change needs an explicit fixture commit.

Numbered workflow

1. Sandbox the writer

Give every characterization run a fresh temp root.
Pass that root in. Do not write into the repo tree.

If the module mkdirs relative to cwd only, wrap the call.
Change cwd inside the test process for that case.
Record the cwd rule next to the fixture, in comments.

mkdir -p tests/fixtures
TZ=UTC LANG=C PYTHONHASHSEED=0 python -m pytest tests/test_report_writer_tree.py -q
Enter fullscreen mode Exit fullscreen mode

PYTHONHASHSEED=0 removes randomized key order in some paths.
This writer already sets sort_keys=True, which is safer.
Keep the seed anyway when nested dicts skip that flag.

2. Freeze clock and locale

Set TZ=UTC in the test environment.
Patch datetime on the module that calls now.
Keep LANG=C if names ever include locale month strings.

Patching is brittle across import styles.
from datetime import datetime needs report_writer.datetime.
import datetime as dt needs report_writer.dt.datetime.

Do not inject a clock parameter yet.
Injection is a behavior change. Capture first.

3. Capture the tree

Run the messy function once against the sandbox.
Write inventory_tree output to a committed JSON fixture.
That fixture is now API. Treat edits as contract changes.

[
  {
    "path": "2026-09-09/report-143000.json",
    "bytes": 0,
    "sha256": "PASTE_FROM_FIRST_RUN"
  },
  {
    "path": "2026-09-09/report-143000.sha256",
    "bytes": 0,
    "sha256": "PASTE_FROM_FIRST_RUN"
  }
]
Enter fullscreen mode Exit fullscreen mode

Replace the zeros after the first green capture.
Commit the fixture in the same change as the test.
Do not generate it during CI after that commit.

4. Reject extra files

Assert file count equals the fixture length.
A new .bak, .tmp, or .json~ is a contract break.
Hidden editor files outside the sandbox do not count.

Compare path lists before comparing hashes.
A missing sidecar is a different failure than a byte drift.
Split those messages in the assertion helper.

5. Make the smallest internal change

Only after the fixture is green, extract one function.
Keep write paths and naming schemes untouched in that diff.
Re-run the tree test. Hashes must match exactly.

# smallest extract — illustrative, after the fixture is locked
def _encode_report(payload: dict) -> str:
    return json.dumps(payload, indent=2, sort_keys=True) + "\n"
Enter fullscreen mode Exit fullscreen mode

That extract does not choose folders.
It does not format timestamps.
It only freezes encoding rules already pinned by the tree.

Stop after one extract.
Do not also inject a clock in the same diff.
Do not rename output files "while you are there."

Decision table

Observation after extract Meaning Action
Extra path in inventory Naming or mkdir changed Revert the extract
Missing path in inventory A write was skipped Revert the extract
Same paths, new sha256 Bytes changed Diff newline and key order
Same sha256, new bytes field Inventory helper bug Fix the test helper
Hashes match, count matches Tree contract held Keep the extract

Use the table as a merge checklist.
Do not argue about code taste until the row is green.
A prettier helper that moves one path is still a break.

Model drafts after the oracle exists

A coding model is useful only after step 3.
It does not invent the contract. The tree does.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode offers free model access and a free server option.
Those two facts matter here as a bounded draft surface.
Paste the messy module and the locked tree fixture.
Ask for one internal extract that must not change paths.

Do not ask the model to design filenames.
Do not ask it to clean up the folder layout.
If tree hashes move, discard the entire diff.

The optional server is scaffolding for the edit session.
The oracle stays in your local test runner.
If you already pin trees this way, a free-model pass is optional, not the source of truth.

Commands for a failed oracle

When hashes drift, print both inventories first.
Then copy the two files out of the sandbox and diff them.

# illustrative local debug, after a failed pytest run
python - <<'PY'
from pathlib import Path
from tests.tree_oracle import format_inventory, inventory_tree
root = Path("/tmp/inspect-tree")
print(format_inventory(inventory_tree(root)), end="")
PY
diff -u expected.json got.json
Enter fullscreen mode Exit fullscreen mode

JSON indent changes show up as large hash jumps.
A single trailing newline also jumps the sidecar digest.
Fix encoding before you revert a good extract by mistake.

Binary files belong in the same inventory.
Do not round-trip them through text mode.
read_bytes() already keeps PDF and PNG writers honest.

What this does not cover

This oracle ignores directory mtime values.
It ignores Unix permission bits unless you add a field.
It ignores files deleted outside the sandbox root.

It also ignores stdout and stderr.
If the writer prints progress lines, pin those later.
Do not merge stream contracts into the tree assertion.

Live clocks remain untested on purpose.
The freeze proves layout, not "now" correctness.
Add one separate clock test after injection exists.

Who should not use this

Skip this workflow for pure functions with no I/O.
Skip it for streaming writers that never close a tree.
Skip it when output is an unbounded log of live events.

Do not use it to justify a large rewrite.
One extract per green tree. That is the limit.
A storage-layout migration needs a new fixture by design.

Teams changing report names on purpose should not fight the oracle.
Update the fixture in that product change, then stop.
Do not mix a rename with a helper extract.

Limitations

The first fixture encodes current bugs.
If the messy module writes duplicate reports, you pin that.
Fixing the bug is a later, deliberate fixture edit.

Hash mismatches have no stack traces of their own.
You must diff the two files on disk.
Keep format_inventory in the assertion for a readable path list.

datetime patches break when imports move.
Prefer injecting a clock after the tree is already stable.
Injection is the second change, not the first change.

Characterization is not documentation of intent.
It is a snapshot of bytes the repo already ships.
Readers should not treat the fixture as a design spec.

Close

Start with the written tree, not the helper list.
Freeze time. Hash relative paths. Then extract one function.

A model can propose that extract after the pin exists.
The fixture still decides whether the change is safe.

Top comments (0)