DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin Mixed Outputs Before You Split a God Module

Do not split a god module before pinning mixed outputs.
Characterization comes before any extract or rename.
The smallest safe change is the only next step.
Anything larger is still an unmeasured guess.

God modules fail naive refactors for one reason.
They blend returns with prints and file writes.
A helper extract often moves two channels together.
Return-only checks then hide the real drift.

This tutorial freezes four mixed-output channels first.
It then allows one in-place pure extraction.
The gold file is the gate, not an assistant.

Mixed output is a four-channel contract

Return values are not the full contract.
Printed lines are also observable behavior.
Written files are also observable behavior.
Working directory changes are also observable behavior.

Pin all four channels on every fixture row.

  1. Return payload or exception class name.
  2. Captured stdout text and stderr text.
  3. A recursive hash of the output tree.
  4. Cwd identity before and after the call.

Drop one channel and the later split lies.
Keep this schema stable for the whole edit series.

Artifact: a mixed-output pin table

The artifact is a committed gold document.
Each key is a fixture name.
Each value holds mixed-output pins.
Production code stays untouched until that file exists.

The sample below is a labeled example.
It is not taken from a live system.
Rename files to match your local module.

Labeled messy module

# invoice_blob.py — labeled example, not production
from pathlib import Path
import json

def run(job: dict, out_dir: str) -> dict:
    out = Path(out_dir)
    out.mkdir(parents=True, exist_ok=True)
    items = job.get("items") or []
    total = 0
    for row in items:
        qty = int(row.get("qty") or 0)
        price = float(row.get("price") or 0)
        total += qty * price
        print(f"line {row.get('sku')}: {qty * price:.2f}")
    if job.get("vip"):
        total *= 0.9
        print("vip discount applied")
    payload = {"total": round(total, 2), "n": len(items)}
    (out / "total.json").write_text(json.dumps(payload), encoding="utf-8")
    return payload
Enter fullscreen mode Exit fullscreen mode

This module computes, prints, writes, and returns.
Those four actions share one function body.
That sharing is the refactor hazard.
Do not split it on readability grounds alone.

Harness that records the four channels

# pin_mixed.py — labeled example
from hashlib import sha256
from io import StringIO
from pathlib import Path
import json, sys, tempfile, unittest
import invoice_blob

def hash_tree(root: Path) -> str:
    h = sha256()
    if not root.exists():
        return h.hexdigest()
    for path in sorted(root.rglob("*")):
        rel = path.relative_to(root).as_posix()
        h.update(rel.encode())
        if path.is_file():
            h.update(path.read_bytes())
    return h.hexdigest()

def pin_call(job: dict) -> dict:
    cwd = Path.cwd()
    buf_out, buf_err = StringIO(), StringIO()
    old_out, old_err = sys.stdout, sys.stderr
    with tempfile.TemporaryDirectory() as td:
        sys.stdout, sys.stderr = buf_out, buf_err
        try:
            result = invoice_blob.run(job, td)
            err = None
        except Exception as exc:
            result, err = None, type(exc).__name__
        finally:
            sys.stdout, sys.stderr = old_out, old_err
        tree = hash_tree(Path(td))
    return {
        "result": result,
        "error": err,
        "stdout": buf_out.getvalue(),
        "stderr": buf_err.getvalue(),
        "tree": tree,
        "cwd_unchanged": Path.cwd() == cwd,
    }

FIXTURES = [
    {"name": "empty", "job": {"items": []}},
    {"name": "one_line", "job": {"items": [{"sku": "A", "qty": 2, "price": 3}]}},
    {"name": "vip", "job": {"items": [{"sku": "A", "qty": 2, "price": 3}], "vip": True}},
    {"name": "missing_qty", "job": {"items": [{"sku": "B", "price": 5}]}},
]

class PinMixedOutputs(unittest.TestCase):
    def test_write_or_compare_pins(self):
        gold = Path("pins.json")
        rows = {row["name"]: pin_call(row["job"]) for row in FIXTURES}
        if not gold.exists():
            gold.write_text(json.dumps(rows, indent=2, sort_keys=True), encoding="utf-8")
            self.fail("wrote pins.json; re-run to characterize")
        expected = json.loads(gold.read_text(encoding="utf-8"))
        self.assertEqual(expected, rows)

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

Execute the harness twice before any production edit.

python pin_mixed.py
python pin_mixed.py
Enter fullscreen mode Exit fullscreen mode

The first run writes pins.json and fails.
The second run compares live mixed outputs.
Both runs must agree before a split starts.

Numbered workflow

Use the five steps in listed order.
Do not start at the extract.

Step 1 — Inventory every effect

Read the god module with an effect filter.
Note prints, file writes, env reads, and returns.
Write that list above the fixture table.
The list is the pin schema for this module.

Step 2 — Select compact fixture rows

Choose rows that exercise hidden branches.
Empty input must appear as one row.
A single happy path is not coverage.
Add a coercion row when types are implicit.

Four to eight rows are usually enough.
Add a row only for a new branch.
Do not fuzz random jobs at this stage.
Random noise is not characterization evidence.

Step 3 — Freeze and commit pins.json

Commit gold data with the harness only.
Name the module in the commit message.
Keep this commit free of production edits.
That snapshot is the rollback point.

Step 4 — Filter edits through the table

Score each proposed edit against the table.
Reject the edit if two channels would move.
Reject the edit if a public name changes.
Add a fixture first when a branch is unpinned.

Step 5 — Change one body, then re-run

Edit a single function body at most.
Leave every I/O call in the original function.
Extract only a pure calculation helper.
Run python pin_mixed.py before any other file.

Revert immediately when pins turn red.
Do not rewrite gold to match a new guess.
Gold changes only with an explicit behavior change.

Decision table: smallest safe change

Proposed edit Allowed after pins? Why
Extract qty * price as a pure helper Yes I/O remains inside run
Move total.json into the helper No The file channel leaves the seam
Delete prints during the extract No Stdout is a pinned contract
Rename run for clarity No External callers sit outside this pin set
Remove the vip branch as unused No Absence needs a row, not a hunch
Add a rounding fixture, then extract round Yes The new row pins the rounding channel

Allowed edits share one measurable property.
They do not relocate an effect.
They shrink a calculation in place.
That property defines the smallest safe change.

A second extract waits for another green run.
Two extracts in one commit fail the protocol.
The protocol prefers boredom over speed.

Failure analysis: common red pins

Red pins cluster in a few patterns.
Map the pattern before you revert blindly.

  1. Stdout mismatch with equal returns.
  2. Tree hash mismatch with equal stdout.
  3. Exception class change on bad input.
  4. Cwd identity flipping to a temp path.

Stdout mismatch means prints moved or vanished.
Restore prints and keep the helper pure.
Tree mismatch means a write moved or duplicated.
Move the write back to the original function.

Exception class changes are contract breaks.
Do not swallow errors inside the new helper.
Cwd flips mean some path called chdir.
That call is never a smallest safe change.

Record the failing fixture name in the commit.
The name tells the next reader which branch drifted.
Do not batch several red fixtures into one fix.

After the pin gate

Coding assistants belong after the gate.
They do not replace mixed-output pins.
Ask for one pure helper, not a redesign.

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

MonkeyCode provides free model access and a free server option.
Run the same pin harness there after it is green locally.
Keep generation behind that gate.
Discard patches that rewrite gold without a new fixture.

A mechanical prompt beats a vague one.

Given pins.json and invoice_blob.py, propose one pure helper.
Do not move prints or file writes.
Do not rename run.
Keep pin_mixed.py green.
Enter fullscreen mode Exit fullscreen mode

Score the patch with the decision table.
Discard it when two channels move.
A human still owns the merge decision.

Limitations

The method records present behavior only.
Present behavior includes current bugs.
That freeze is useful for a brownfield split.
It does not prove the behavior is intended.

Tree hashes ignore most filesystem metadata.
Writes that differ only by timestamp can collide.
Stdout pins break under locale changes.
Force UTF-8 in the shell before each run.

PYTHONIOENCODING=utf-8 python pin_mixed.py
Enter fullscreen mode Exit fullscreen mode

The harness does not stub network calls.
Wrap HTTP before you pin those paths.
Threads and process pools need other probes.
Do not use this table to certify races.

Time-dependent fixtures rot without a clock seam.
Inject time when the module stamps output.
Skip that path when injection is impossible.

Who should skip this workflow

Skip it on greenfield modules.
Write intent tests there instead.
Skip it when a public API suite already pins behavior.
Skip it for cryptography and authentication code.
A characterization table can freeze a weak construction.

Skip it when the module cannot run locally.
An unexecuted pin is not evidence.
Skip it for binary vendor blobs.
You need source and a repeatable working directory.

Completion check

pins.json is committed beside the harness.
python pin_mixed.py passes on two consecutive runs.
The god module still performs every I/O call.
At most one pure helper was added.

No public rename landed in the same commit.
No write moved into the helper.
No print was deleted as cleanup.
That state is a finished smallest safe change.

Stop when the next split still looks wide.
Add one fixture row and retry.
The table should grow faster than the file shrinks.
Treat that ratio as the safety signal.

Top comments (0)