DEV Community

Dakota Huang
Dakota Huang

Posted on

Stop Extracting Helpers Until Return Shapes Are Pinned

A tiny extract still breaks callers when return shapes drift. Pin those shapes before any helper leaves the messy module.

Messy packages hide structure inside nested dicts and tuples. Full golden files stay brittle against locale and timestamps. A shape ledger records keys, types, and nullability only.

Why shape ledgers beat blind helper extracts

Callers depend on keys more than on pretty formatting. A renamed nested field fails far from the rounding line. Characterization of shapes catches that class of breakage.

String snapshots fail on timestamps, locale, and whitespace. Shape records ignore those unstable leaves by design. Reviewers still hash a few stable numeric fields for safety.

This method does not prove new business rules. It only freezes what today's fixtures already return. That freeze is the gate for one small extract.

The messy target

Consider a billing module with mixed tax logic. The public function returns a nested invoice dictionary. Nobody owns tests, and the file spans thousands of lines.

Do not start by moving the rounding function. Do not ask a model to clean the whole module. Build a runtime shape ledger against frozen fixtures.

The example below is labeled as a teaching fixture. It is not production billing code or legal advice. Replace names with your own package paths later.

# messy_billing.py — teaching fixture, not production code
from decimal import Decimal

def build_invoice(subtotal, region, discounts=None):
    discounts = discounts or []
    tax_rate = Decimal("0.08875") if region == "NY" else Decimal("0.00")
    disc = sum(Decimal(str(d)) for d in discounts)
    taxable = Decimal(str(subtotal)) - disc
    if taxable < 0:
        taxable = Decimal("0")
    tax = (taxable * tax_rate).quantize(Decimal("0.01"))
    return {
        "currency": "USD",
        "region": region,
        "lines": {
            "subtotal": str(Decimal(str(subtotal))),
            "discount_total": str(disc),
            "tax": str(tax),
            "total": str(taxable + tax),
        },
        "meta": {
            "tax_rate": str(tax_rate),
            "zero_rated": tax_rate == 0,
            "warnings": [] if disc >= 0 else ["negative_discount"],
        },
    }
Enter fullscreen mode Exit fullscreen mode

Artifact: the shape ledger

The ledger walks any JSON-like Python return value. It records type names, sorted keys, and optional nulls. It also hashes a whitelist of stable numeric paths.

# shape_ledger.py — proposed characterization harness
from __future__ import annotations

import hashlib
import json
from decimal import Decimal
from typing import Any

STABLE_PATHS = (
    "lines.tax",
    "lines.total",
    "meta.tax_rate",
)


def shape_of(value: Any) -> Any:
    if value is None:
        return {"t": "null"}
    if isinstance(value, bool):
        return {"t": "bool"}
    if isinstance(value, int):
        return {"t": "int"}
    if isinstance(value, float):
        return {"t": "float"}
    if isinstance(value, (str, Decimal)):
        return {"t": "str"}
    if isinstance(value, list):
        if not value:
            return {"t": "list", "empty": True}
        return {"t": "list", "item": shape_of(value[0])}
    if isinstance(value, dict):
        return {
            "t": "dict",
            "keys": sorted(value.keys()),
            "fields": {k: shape_of(value[k]) for k in sorted(value)},
        }
    return {"t": type(value).__name__}


def at_path(value: Any, path: str) -> str:
    cur = value
    for part in path.split("."):
        cur = cur[part]
    return str(cur)


def ledger_row(name: str, value: Any) -> dict:
    stable = {p: at_path(value, p) for p in STABLE_PATHS}
    blob = json.dumps(stable, sort_keys=True).encode()
    return {
        "name": name,
        "shape": shape_of(value),
        "stable": stable,
        "stable_sha256": hashlib.sha256(blob).hexdigest(),
    }
Enter fullscreen mode Exit fullscreen mode

Store one JSON file per fixture under the ledgers directory. Commit those files and treat any diff as failure.

# record_shapes.py — proposed runner, unexecuted here
import json
from pathlib import Path

from messy_billing import build_invoice
from shape_ledger import ledger_row

FIXTURES = [
    ("ny_no_disc", dict(subtotal="100.00", region="NY", discounts=[])),
    ("ny_one_disc", dict(subtotal="100.00", region="NY", discounts=["5.00"])),
    ("zero_region", dict(subtotal="40.00", region="ZZ", discounts=[])),
    ("over_disc", dict(subtotal="10.00", region="NY", discounts=["12.00"])),
]


def main() -> None:
    out = Path("ledgers")
    out.mkdir(exist_ok=True)
    for name, kwargs in FIXTURES:
        row = ledger_row(name, build_invoice(**kwargs))
        (out / f"{name}.json").write_text(
            json.dumps(row, indent=2, sort_keys=True) + "\n"
        )


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

How to choose fixtures and stable paths

Pick four to eight inputs that already appear in logs. Do not invent happy paths the module never executes. Keep regions, zeros, and discounts from real samples.

Stable paths must be values reviewers already treat as contracts. Tax amounts and totals usually belong on that list. Warning prose and generated IDs usually do not belong.

Optional keys need an extra fixture that omits them. The walker records missing keys as a different key set. That difference is exactly the drift the gate should catch.

Numbered workflow

1. Freeze the fixture table

Write the table before any code moves between files. Keep keyword arguments in one obvious Python list. Name each row after the behavior it already shows.

2. Record shapes on a clean working tree

Run the recorder on the current tree with no extra diffs. Do not mix formatting commits with ledger creation. Commit the JSON files as the characterization baseline.

python -m py_compile shape_ledger.py record_shapes.py
python record_shapes.py
ls ledgers
git add ledgers shape_ledger.py record_shapes.py
git commit -m "test: pin invoice return shapes before extract"
Enter fullscreen mode Exit fullscreen mode

3. Add a compare command that fails closed

Comparison must fail on key drift or hash drift. Do not pretty-print away missing nested dictionary fields. Exit nonzero so automation can block the extract.

# compare_shapes.py — proposed gate, unexecuted here
import json
import sys
from pathlib import Path

from messy_billing import build_invoice
from record_shapes import FIXTURES
from shape_ledger import ledger_row


def main() -> int:
    failed = 0
    for name, kwargs in FIXTURES:
        path = Path("ledgers") / f"{name}.json"
        expected = json.loads(path.read_text())
        actual = ledger_row(name, build_invoice(**kwargs))
        if actual != expected:
            print(f"SHAPE DRIFT: {name}")
            failed += 1
    return 1 if failed else 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode
python -m py_compile compare_shapes.py
python compare_shapes.py
echo exit:$?
git diff -- ledgers
Enter fullscreen mode Exit fullscreen mode

4. Choose the smallest safe change

The allowed edit is one pure helper without I/O. The helper must not add or drop dictionary keys. Rounding is a candidate; region lookup is not yet.

Reject the change if the helper can alter key sets. Reject the change if the helper needs new fixtures. Revert immediately when the compare command turns red.

5. Extract the rounding helper inside the module

Move quantize into a tiny function in the same file. Keep the import surface identical for every current caller. Re-run the compare command after that single move.

# proposed extract — still inside messy_billing.py
from decimal import Decimal

def _quantize_cents(value):
    return value.quantize(Decimal("0.01"))
Enter fullscreen mode Exit fullscreen mode

Wire the helper into the existing tax line only. Do not relocate the function to a new package yet. Do not rename public build_invoice during this first step.

python compare_shapes.py
git add messy_billing.py
git commit -m "refactor: extract in-module cent rounding helper"
Enter fullscreen mode Exit fullscreen mode

6. Promote the helper only after a green ledger

A second commit may move the helper into money.py. The shape ledger must stay green across that move. Any extra key inside meta counts as a failed refactor.

python compare_shapes.py
git diff --stat -- ledgers
Enter fullscreen mode Exit fullscreen mode

What a drift report should look like

A missing zero_rated key fails the sorted keys list. A tax string of 8.9 instead of 8.90 still matches shape. The stable hash then fails on the lines.tax path.

Print the fixture name first, then the mismatched section. Do not dump the entire messy module during the failure. Reviewers need the shape delta, not a wall of dicts.

Proposed output stays short and machine-greppable in logs. One line per drifted fixture is enough for CI. Save full JSON compare for local debugging only.

Optional model pass after the gate is green

A free coding model can draft the one-helper patch. It should not invent fixtures or rewrite the ledger.

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

MonkeyCode offers free model access and a free server option. Those options can host the draft after the ledger exists. They do not replace the compare command or fixture table.

Keep the prompt inside one file and one function name. Paste the current helper and any ledger mismatch only. Reject any patch that touches record_shapes.py or ledgers/.

If that split already matches the editor, pin compare_shapes.py in CI.

# proposed ci snippet, not a measured production workflow
name: shape-ledger
on: [push, pull_request]
jobs:
  compare:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: python compare_shapes.py
Enter fullscreen mode Exit fullscreen mode

Decision table

Change Allowed on first commit Ledger must stay
Extract _quantize_cents in-module Yes Identical shapes and hashes
Move helper to money.py After green extract Identical
Add meta.tax_engine key No Would be shape drift
Change tax rate table No Stable hashes would move
Reformat warnings list type No List shape would change
Model rewrites the whole module No Out of scope

What the ledger will not catch

Shape records skip most string contents by design. A wrong tax rate that keeps two decimals can pass. That is why stable numeric paths are hashed.

The list walker uses only the first item shape. Heterogeneous lists still need one extra explicit fixture. Empty lists only record emptiness, not future item types.

Floats and Decimals both collapse to str after formatting. That collapse is deliberate for this invoice dictionary contract. Do not reuse this walker on raw scientific numeric data.

Time, random, and network values still need separate freezes. This workflow does not pin clocks or sockets. Add those extra oracles before characterizing such modules.

Who should not use this approach

Skip this when the package already has contract tests. Skip this on greenfield modules with no current callers. Skip this for cryptographic or access-control code paths.

Security checks need explicit adversarial cases, not shapes. A shape ledger will not prove authorization behavior. Do not treat green shapes as a ship signal there.

Teams without real fixtures should collect production logs first. Synthetic happy paths will freeze the wrong public contract. The later extract will then preserve a convenient fiction.

Limits of the smallest-safe-change rule

One helper per commit keeps the resulting diff reviewable. It is slower than a sweeping automated rewrite. That slowness is the entire point of the gate.

If two helpers must move together, stop the extract. Split the work or widen the ledger first. Do not hide a key change inside a file move.

Public signatures stay untouched during this shape workflow. Return-value shapes are the only check in scope. Signature diffs would need a different characterization artifact.

Recap

Pin runtime shapes before any extract leaves the file. Hash a short whitelist of stable numeric fields. Allow one pure helper, then re-run the compare gate.

The messy module remains the specification source of truth. The model, if used, stays a one-file patch editor. The ledger, not the diff size, decides whether the change merges.

Top comments (0)