DEV Community

Dakota Huang
Dakota Huang

Posted on

Hash the Entrypoint Before You Extract One Module

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:

  1. Combined stdout bytes after a fixed locale.
  2. Combined stderr bytes after a fixed locale.
  3. Process exit code as a decimal integer.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Pick one cohesive helper cluster with one concern.
  2. Create a new module with no new behavior.
  3. Re-export the old names from the original file.
  4. Replay the freeze harness to completion.
  5. 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Working directory changed, so relative files moved.
  2. Log line order changed after an import shuffle.
  3. Exception text changed after a wrap.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)