DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin Package Output Before You Change One Branch

Messy repos fail at the package boundary, not inside helpers. Function tests miss sibling files that share hidden state. Pin the entrypoint outputs with golden files first. Then change exactly one control-flow branch after that.

Why function pins miss the repo

A function pin cannot see import-time file writes. A function pin cannot see CLI argument parsing bugs. A function pin cannot see sibling modules that mutate globals. Package goldens see all three without reading internals.

That is why this workflow starts outside the files. You treat the package as a black box first. Internals become eligible only after the box is pinned.

The failure mode

A helper can look covered and still lie. A second module can write files the helper never sees. Import side effects can change results without a call.

Your refactor then ships a quiet package-level break. This failure is not a style problem. This is a missing lock at the process edge.

Two locks, in order

The first lock freezes every observable package output. The second lock limits the edit to one branch. Skip either lock and the change is a guess.

Record the goldens and commit them first. Only then apply the single-branch source edit.

Example package (illustrative)

The tree below is a teaching fixture, not production code. Treat every command as a local, reproducible example.

messy_quote/
  pyproject.toml
  src/messy_quote/
    __init__.py
    cli.py
    quote.py
    discounts.py
    io_util.py
  tests/
    test_golden_entrypoint.py
    record_goldens.py
    goldens/
  fixtures/
    case_retail.json
    case_wholesale.json
    case_zero.json
Enter fullscreen mode Exit fullscreen mode

The quote module mixes tax, discounts, and logging. The discount module keeps a dead seasonal branch.

The CLI module is the only supported public entrypoint. That entrypoint is the only characterization surface.

Step 1 — Name the only public surface

List commands and imports your other packages already use. Ignore internal helpers during this whole pass. Write the surface on one line and stop.

python -m messy_quote.cli --input fixtures/CASE.json --out ./out
Enter fullscreen mode Exit fullscreen mode

If two entrypoints exist, freeze both of them. Do not freeze private functions in this workflow.

Step 2 — Build a tiny fixture pack

Three cases beat a dozen vague ones. Cover a normal path, a boundary, and an empty input.

{
  "id": "retail-001",
  "items": [{"sku": "A-1", "qty": 2, "unit_cents": 499}],
  "region": "US-CA",
  "flags": ["loyalty"]
}
Enter fullscreen mode Exit fullscreen mode

Label these input files as synthetic fixtures. Do not copy production records into the repo.

Step 3 — Record goldens at the process edge

Capture stdout, stderr, and exit code together. Also capture any files the CLI writes under out. Store those captures as committed golden files.

The recorder below is a proposed local harness. Run it only on a revision you already trust.

# tests/record_goldens.py
from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
FIX = ROOT / "fixtures"
GOLD = ROOT / "tests" / "goldens"
OUT = ROOT / "out"


def run_case(name: str) -> dict:
    out_dir = OUT / name
    if out_dir.exists():
        for path in out_dir.glob("*"):
            path.unlink()
    out_dir.mkdir(parents=True, exist_ok=True)
    proc = subprocess.run(
        [
            sys.executable,
            "-m",
            "messy_quote.cli",
            "--input",
            str(FIX / f"{name}.json"),
            "--out",
            str(out_dir),
        ],
        cwd=ROOT,
        capture_output=True,
        text=True,
        check=False,
    )
    written = sorted(path.name for path in out_dir.iterdir())
    return {
        "exit_code": proc.returncode,
        "stdout": proc.stdout,
        "stderr": proc.stderr,
        "files": written,
    }


def main() -> None:
    GOLD.mkdir(parents=True, exist_ok=True)
    for case in ("case_retail", "case_wholesale", "case_zero"):
        payload = run_case(case)
        target = GOLD / f"{case}.json"
        target.write_text(
            json.dumps(payload, indent=2, sort_keys=True) + "\n",
            encoding="utf-8",
        )


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run the recorder once on a known-good revision. Keep that revision free of unfinished refactor work.

python tests/record_goldens.py
git add tests/goldens fixtures
git commit -m "test: freeze messy_quote entrypoint goldens"
Enter fullscreen mode Exit fullscreen mode

That recorder commit is your first lock. Do not edit source files in the same commit.

Step 4 — Assert goldens on every later change

The test must fail on any process-edge drift. Do not assert internal helper return values here.

# tests/test_golden_entrypoint.py
from __future__ import annotations

import json
from pathlib import Path

from record_goldens import run_case

GOLD = Path(__file__).parent / "goldens"
CASES = ("case_retail", "case_wholesale", "case_zero")


def test_entrypoint_matches_goldens() -> None:
    for case in CASES:
        actual = run_case(case)
        expected = json.loads((GOLD / f"{case}.json").read_text())
        assert actual["exit_code"] == expected["exit_code"]
        assert actual["stdout"] == expected["stdout"]
        assert actual["stderr"] == expected["stderr"]
        assert actual["files"] == expected["files"]
Enter fullscreen mode Exit fullscreen mode
pytest tests/test_golden_entrypoint.py -q
Enter fullscreen mode Exit fullscreen mode

A failing golden is a stopped refactor, not a style note. Rerun the suite after every source touch. Do not weaken assertions to keep the suite green.

Step 5 — Score the smallest safe change

Use the table for the first source edit. Do not improvise a larger cleanup in that PR.

Candidate edit First PR? Why
Rewrite quote.py No Touches many branches at once
Rename public CLI flags No Changes the frozen surface
Extract three helpers No Moves behavior without a pin win
Delete unused import Yes No behavior path if goldens hold
Remove one dead branch Yes Single control-flow change
Flip one predicate Yes Only if goldens still match
Add logging format fields No Stderr is part of the lock
Change tax rounding No Output cents will drift

The allowed first edit is one branch. One predicate, one return path, one deleted if. Anything larger waits for a second PR.

Step 6 — Make the one-branch edit

The seasonal discount branch never matches any fixture. Delete only that unreachable branch in this pass.

# src/messy_quote/discounts.py
# Before (illustrative)

def apply_discounts(cents: int, flags: list[str], month: int) -> int:
    if "loyalty" in flags:
        cents = cents - 50
    if month == 99:  # unreachable seasonal leftover
        cents = cents - 200
    if "wholesale" in flags:
        cents = int(cents * 0.9)
    return max(cents, 0)
Enter fullscreen mode Exit fullscreen mode

Leave loyalty and wholesale branches exactly as they are. Do not reformat the rest of the file yet.

# After (illustrative)

def apply_discounts(cents: int, flags: list[str], month: int) -> int:
    if "loyalty" in flags:
        cents = cents - 50
    if "wholesale" in flags:
        cents = int(cents * 0.9)
    return max(cents, 0)
Enter fullscreen mode Exit fullscreen mode

Re-run goldens before any further cleanup work. If stdout cents change, restore the branch. The golden file is the oracle, not your intent.

Step 7 — Prove the branch was the only hunk

Diff the working tree against lock one. Count behavioral hunks, not cosmetic line noise.

git diff --stat
git diff src/messy_quote/discounts.py
Enter fullscreen mode Exit fullscreen mode

Accept the PR only when three checks hold.

  1. Goldens still match on all fixture cases.
  2. The src diff touches one branch in one file.
  3. CLI flags, file names, and exit codes are unchanged.

If the diff spills into quote.py, revert. Split that work into a later, smaller PR.

Step 8 — Read a golden failure without cheating

Open the failed case file under tests/goldens. Diff the actual stdout against the committed stdout. Classify the drift before you touch source again.

  1. Whitespace drift means you restore print separators now.
  2. Integer cents means the branch was not dead.
  3. File list change means io_util moved a write.
  4. Exit code change means CLI parsing or an uncaught raise.
  5. Stderr change means a log line became behavior.

Do not regenerate goldens to hide a red test. Regenerate only after you confirm an intended surface change. This workflow forbids intended surface changes in PR one.

Where a free model belongs

A model should not choose the first lock. Humans record goldens on a known-good revision. A model may propose the single-branch deletion after that.

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 only against the decision table above. Paste the golden test and one source file only.

Reject any patch that edits two behavior paths. Run the same pytest command on a clean server. Local hidden files often fake a green suite.

A free server run is a second environment, not a trophy. Keep the command identical to your local golden run.

What this workflow does not claim

It does not prove the quote math is correct. It proves the package still emits the same bytes. Wrong goldens will freeze wrong business rules.

It does not replace unit tests for new code. It does not handle clocks, network, or random seeds. Stub time and I/O before you record, or skip this method.

It does not make large rewrites safe in one PR. The smallest-change rule exists because models over-edit. Treat extra hunks as a failed patch.

Who should not use this

Skip this if you have no stable entrypoint. Skip this for security-sensitive parsers without review. Skip this when output is intentionally non-deterministic.

Skip this if the package has no fixture rights. Greenfield modules need design tests, not goldens. If you can specify behavior, write that spec instead.

Limitations

Golden files rot when you change log format. Stderr locks will block harmless debug lines. Large binary outputs do not belong in git goldens.

Three fixtures will miss rare regions and tax codes. Add a case only when a production bug names it. Do not farm hundreds of synthetic cases on day one.

Process-edge tests are slow compared to unit tests. Keep this golden suite tiny and strict. Put fast helper tests in a later, separate layer.

Closing rule

Freeze the package and commit the golden files. Edit exactly one branch after those files land. If the golden moves, the change was not small.

Restore the branch and pick a smaller hunk. Do not negotiate with a drifting golden file.

Top comments (0)