DEV Community

Dakota Huang
Dakota Huang

Posted on

A Behavior Ledger Makes One-File Refactors Honest

A messy function is not ready for a rewrite. Observed behavior must become a replayable ledger first. Then the next patch may touch exactly one file.

Unscoped diffs fail this one-file rule constantly. They rename helpers and alter edge cases together. A ledger catches drift during later replay.

Why brownfield rewrites lose contracts

Brownfield functions hide implicit caller contracts. Error types, rounding, and missing keys all matter. A clean rewrite often drops one quiet dependency.

Characterization tests record what the code does today. They do not claim that behavior is ideal. They only require the next edit to stay equivalent.

Michael Feathers documented this approach for legacy code. This artifact is a JSONL behavior ledger. Each line stores one call, result, or error.

The method below is a proposed local harness. Treat the code as a template, not a library. Adapt serialization to the types you actually ship.

Pick one function, not a package

Choose a single function with mixed rules. Prefer local calculation over a network facade. Name that function in every ledger row.

A good target mixes discounts, tax, and rounding. Callers already depend on those current quirks. Do not “fix” policy during the first edit.

Step 1: Add a canonical JSONL recorder

The wrapper canonicalizes inputs and outputs. It appends one JSON object per observed call. Failures store exception type and message text.

# char_ledger.py
from __future__ import annotations

import json
import functools
from pathlib import Path
from typing import Any, Callable

LEDGER_PATH = Path("tmp/behavior_ledger.jsonl")


def _canon(value: Any) -> Any:
    text = json.dumps(value, sort_keys=True, default=str)
    return json.loads(text)


def record(fn: Callable[..., Any]) -> Callable[..., Any]:
    @functools.wraps(fn)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        LEDGER_PATH.parent.mkdir(parents=True, exist_ok=True)
        row: dict[str, Any] = {
            "name": fn.__qualname__,
            "input": _canon({"args": args, "kwargs": kwargs}),
        }
        try:
            result = fn(*args, **kwargs)
        except Exception as exc:
            row["ok"] = False
            row["error_type"] = type(exc).__name__
            row["error_msg"] = str(exc)
            with LEDGER_PATH.open("a", encoding="utf-8") as handle:
                handle.write(json.dumps(row, sort_keys=True) + "\n")
            raise
        row["ok"] = True
        row["result"] = _canon(result)
        with LEDGER_PATH.open("a", encoding="utf-8") as handle:
            handle.write(json.dumps(row, sort_keys=True) + "\n")
        return result

    return wrapper
Enter fullscreen mode Exit fullscreen mode

Apply the decorator only on the target. Keep it off hot production request paths. A local fixture run covers most messy refactors.

# billing.py
from char_ledger import record

CACHE: dict[str, float] = {}


@record
def compute_total(subtotal: float, coupon: str | None, tax_bps: int) -> dict:
    if subtotal < 0:
        raise ValueError("subtotal must be >= 0")

    key = f"{subtotal}:{coupon}:{tax_bps}"
    if key in CACHE:
        discount = CACHE[key]
    else:
        discount = 0.0
        if coupon == "SAVE10":
            discount = round(subtotal * 0.10, 2)
        elif coupon == "FLAT5":
            discount = 5.0
        CACHE[key] = discount

    taxable = subtotal - discount
    taxed = round(taxable * (1 + tax_bps / 10000), 2)
    return {"coupon": coupon, "discount": discount, "total": taxed}
Enter fullscreen mode Exit fullscreen mode

That cache is part of the observed contract. Hidden state will collide in the ledger. Collisions mean you must characterize state, not ignore it.

Step 2: Fill the ledger from current paths

Drive the function through a small script. Do not invent extra business cases yet. You are sampling present behavior, not desired behavior.

# fill_ledger.py
from billing import CACHE, compute_total

CASES = [
    (100.0, None, 825),
    (100.0, "SAVE10", 825),
    (19.99, "SAVE10", 0),
    (0.0, None, 825),
    (50.0, "UNKNOWN", 825),
    (50.0, "FLAT5", 825),
    (-1.0, None, 825),
    (100.0, "SAVE10", 825),  # cache hit
]

if __name__ == "__main__":
    CACHE.clear()
    for subtotal, coupon, tax_bps in CASES:
        try:
            print(compute_total(subtotal, coupon, tax_bps))
        except Exception as exc:
            print(type(exc).__name__, exc)
Enter fullscreen mode Exit fullscreen mode
rm -f tmp/behavior_ledger.jsonl
python fill_ledger.py
wc -l tmp/behavior_ledger.jsonl
sort tmp/behavior_ledger.jsonl | uniq -c | head
Enter fullscreen mode Exit fullscreen mode

Deduplicate rows before freezing the fixture. Identical inputs should map to identical outputs. A mismatch here means hidden mutable state remains.

Clear process-local caches at the recording boundary. Then replay with the same reset rule. Otherwise cache hits become unreproducible noise.

Step 3: Freeze the ledger and replay it

Copy the JSONL into a committed fixture. Replay must never append new ledger rows. Compare canonical JSON, not Python object identity.

mkdir -p tests/fixtures
cp tmp/behavior_ledger.jsonl tests/fixtures/compute_total.ledger.jsonl
Enter fullscreen mode Exit fullscreen mode
# replay_ledger.py
from __future__ import annotations

import json
from pathlib import Path

from billing import CACHE, compute_total

FROZEN = Path("tests/fixtures/compute_total.ledger.jsonl")


def replay() -> int:
    failures = 0
    CACHE.clear()
    for line in FROZEN.read_text(encoding="utf-8").splitlines():
        if not line.strip():
            continue
        row = json.loads(line)
        args = row["input"]["args"]
        kwargs = row["input"]["kwargs"]
        try:
            result = compute_total(*args, **kwargs)
            observed = {
                "ok": True,
                "result": json.loads(
                    json.dumps(result, sort_keys=True, default=str)
                ),
            }
        except Exception as exc:
            observed = {
                "ok": False,
                "error_type": type(exc).__name__,
                "error_msg": str(exc),
            }
        if observed["ok"] != row["ok"]:
            failures += 1
            print("ok-mismatch", row, observed)
            continue
        if row["ok"] and observed["result"] != row["result"]:
            failures += 1
            print("result-mismatch", row["result"], observed["result"])
        if not row["ok"]:
            if observed.get("error_type") != row.get("error_type"):
                failures += 1
                print("error-type-mismatch", row, observed)
            if observed.get("error_msg") != row.get("error_msg"):
                failures += 1
                print("error-msg-mismatch", row, observed)
    return failures


if __name__ == "__main__":
    count = replay()
    raise SystemExit(1 if count else 0)
Enter fullscreen mode Exit fullscreen mode

A green replay is the only oracle. No generated patch can override a red ledger. Revert first, then inspect the mismatched row.

Step 4: Make the smallest same-file change

After replay is green, edit one path. Extract a helper only inside that file. Keep the public signature and return shape unchanged.

def _discount_for(subtotal: float, coupon: str | None) -> float:
    if coupon == "SAVE10":
        return round(subtotal * 0.10, 2)
    if coupon == "FLAT5":
        return 5.0
    return 0.0
Enter fullscreen mode Exit fullscreen mode

Wire _discount_for from compute_total only. Leave CACHE in the original module. Moving the cache now would be a second project.

Run the replay after that single extraction. If totals drift, the helper is not equivalent. Fix the helper, or delete the extraction.

Step 5: Enforce a one-file git gate

The gate is mechanical and local. Count changed paths, then reject extras. Also ignore ephemeral tmp/ ledger noise.

#!/usr/bin/env bash
# one_file_gate.sh
set -euo pipefail

python replay_ledger.py

count=$(git diff --name-only HEAD | awk '!/^tmp\// && NF {c++} END {print c+0}')

if [ "$count" -ne 1 ]; then
  echo "expected exactly one changed file, got ${count}:"
  git diff --name-only HEAD
  exit 1
fi

echo "one-file gate passed"
git diff --name-only HEAD
Enter fullscreen mode Exit fullscreen mode
chmod +x one_file_gate.sh
./one_file_gate.sh
Enter fullscreen mode Exit fullscreen mode

Two files means the patch is too wide. Split it, even when both edits look related. Characterization does not license a cleanup spree.

When a model may propose the edit

Models help only after the oracle exists. They are not a substitute for replay. Prompt them to edit one named file.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access and a free server option. Those availability notes are the only product claims used here.

A practical split is easy to keep honest. Run fill and replay on your existing checkout. Then draft the one-file patch in a free server session if local setup is noisy.

Give that session three inputs only: the frozen ledger, the current target file, and one_file_gate.sh. Accept a diff only when replay stays green. The gate still rejects multi-file cleanup.

Decision table for the gate

Signal Action
Ledger is empty Do not edit the target function
Same input, different output Find hidden state, then resample
Replay is red Revert the patch immediately
Replay green, changed files > 1 Split the patch before review
Replay green, changed files = 1 Merge the extraction

Empty ledgers are not conservative. They are blind. Blind refactors are guesses with extra steps.

Limitations

JSON canonicalization drops object identity. default=str can hide real type changes. Decimal and float values need an explicit codec.

Floating point remains a brittle fixture. Prefer integer cents when the domain allows it. Do not freeze wall-clock timestamps without a clock seam.

The ledger is not a product specification. It also freezes today’s accidental bugs. Schedule a later change to break behavior on purpose.

Concurrent appends can interleave ledger lines. Record inside one process for this workflow. Do not attach the wrapper to threaded production traffic.

PII does not belong in committed JSONL. Redact before the fixture is reviewed. If redaction is impossible, do not record those calls.

Who should not use this

Skip this workflow on greenfield modules. You can write intent tests there first. A ledger would fossilize an unfinished public API.

Skip it for time and randomness dominated code. Inject a clock or RNG seam first. Otherwise every replay becomes a flake source.

Skip it when the change must break a contract. Write explicit tests for the new contract. Do not force the ledger to bless a break.

Skip it if the original function cannot run. Characterization needs real calls against current code. Guessed rows are fiction, not a safety net.

What this workflow does not replace

A ledger does not replace design review. It does not replace typed public APIs. It only prices the next one-file edit.

It also does not measure coverage quality. Five similar rows can miss a branch. Add rows from real failing production shapes later.

Keep the first change boring on purpose. Equivalent extraction is the only allowed move. Behavior change is a second, separately gated patch.

Closing sequence

The core sequence stays short and testable. Record. Freeze. Replay. Edit one file. Anything larger is a different project.

The ledger is the characterization test. The path count is the blast-radius signal. Together they make a messy-repo refactor measurable.

Top comments (0)