DEV Community

Dakota Huang
Dakota Huang

Posted on

Capture Three Channels, Then Change One Concern

Most messy-repo risk lives in silent caller contracts. Capture current observable outputs before any helper edit. Then apply the smallest safe behavior change available.

A large cleanup quietly moves those contracts around. A three-channel capture keeps each silent contract visible. Style rewrites without that capture remain unsafe guesses.

The core rule

Pick one helper and freeze three output channels today. Record the return value for every listed fixture. Record stdout text and post-call mutations as well.

A later patch may change one named concern only. It must not disturb the other recorded channels. That single constraint is the entire safety rail.

When this workflow applies

Use this workflow on brownfield helpers with thin tests. Apply it before any model-generated cleanup patch lands. Apply it when callers depend on accidental helper output.

Skip greenfield modules that still lack production callers. Skip work that already has an explicit written spec. Skip helpers whose results must change on purpose.

Step 1: Isolate one messy helper

Choose a function that mixes policy rules and I/O. Prefer helpers with dict mutation or extra logging. Avoid capturing a whole package in one pass.

The pricing helper below is an illustrative target only. It prints JSON, rounds money, and mutates input. Treat every literal as an example, not evidence.

# examples/messy_price.py — illustrative only, unexecuted
import json
import sys


def quote_line(item, tax_rate, log=None):
    log = log if log is not None else sys.stdout
    name = item.get("name") or "item"
    qty = int(item.get("qty") or 1)
    unit = float(item.get("unit") or 0)
    raw = qty * unit
    if item.get("bulk") and qty >= 10:
        raw *= 0.9
    tax = round(raw * float(tax_rate), 2)
    total = round(raw + tax, 2)
    rec = {"name": name, "raw": raw, "tax": tax, "total": total}
    log.write(json.dumps(rec) + "\n")
    item["quoted"] = True
    return total
Enter fullscreen mode Exit fullscreen mode

Three side effects hide inside one return value. Downstream callers may depend on any one effect. A rewrite that only returns a dict will break them.

Step 2: Write a small fixture table

Collect inputs that resemble current production traffic closely. Keep the fixture table short and fully explicit. Prefer traces from bugs over synthetic extra volume.

# examples/fixtures.py — illustrative only, unexecuted
CASES = [
    {"id": "empty-ish", "item": {}, "tax_rate": 0.0},
    {
        "id": "single",
        "item": {"name": "bolt", "qty": 1, "unit": 2.5},
        "tax_rate": 0.07,
    },
    {
        "id": "bulk-on",
        "item": {"name": "bolt", "qty": 10, "unit": 2.5, "bulk": True},
        "tax_rate": 0.07,
    },
    {
        "id": "bulk-off",
        "item": {"name": "bolt", "qty": 10, "unit": 2.5, "bulk": False},
        "tax_rate": 0.07,
    },
    {
        "id": "string-qty",
        "item": {"name": "nut", "qty": "3", "unit": "1.10"},
        "tax_rate": "0.1",
    },
]
Enter fullscreen mode Exit fullscreen mode

These five rows cover empty input and bulk discount. They also cover string-to-number coercion on edge paths. Add further rows only from real incident traces.

Step 3: Capture return, stdout, and mutation

Return value alone is a weak oracle here. Logs and mutations also bind downstream callers tightly. The harness below freezes all three observed channels.

# examples/capture_quote.py — illustrative only, unexecuted
from io import StringIO
import copy
import hashlib
import json
from pathlib import Path

from fixtures import CASES
from messy_price import quote_line

OUT = Path("captures/quote_line.json")


def run_case(case):
    item = copy.deepcopy(case["item"])
    buf = StringIO()
    result = quote_line(item, case["tax_rate"], log=buf)
    return {
        "id": case["id"],
        "input": case,
        "return": result,
        "stdout": buf.getvalue(),
        "item_after": item,
    }


def main():
    rows = [run_case(c) for c in CASES]
    blob = json.dumps(rows, sort_keys=True, indent=2) + "\n"
    digest = hashlib.sha256(blob.encode()).hexdigest()
    OUT.parent.mkdir(parents=True, exist_ok=True)
    OUT.write_text(blob)
    print(f"wrote {OUT} sha256={digest} cases={len(rows)}")


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

Run the harness once against the current HEAD. Commit the JSON file before any source edit. That commit becomes the local characterization oracle file.

mkdir -p captures examples
python examples/capture_quote.py
git add captures/quote_line.json
git commit -m "capture quote_line before refactor"
Enter fullscreen mode Exit fullscreen mode

Do not edit the helper in the same commit. Split capture work and behavior change apart cleanly. Pull request reviewers then see the oracle land first.

Step 4: Assert the capture in CI

A golden file without a test will rot. Load that file in a characterization assertion instead. Fail the build on any unexpected capture drift.

# tests/test_quote_characterization.py — illustrative only, unexecuted
import json
from pathlib import Path

from capture_quote import run_case
from fixtures import CASES

GOLDEN = Path("captures/quote_line.json")


def test_quote_line_matches_capture():
    expected = json.loads(GOLDEN.read_text())
    actual = [run_case(c) for c in CASES]
    assert actual == expected
Enter fullscreen mode Exit fullscreen mode
python -m pytest tests/test_quote_characterization.py -q
Enter fullscreen mode Exit fullscreen mode

A red test means the capture file is stale. Re-record it only with a reviewed written reason. Never re-record a capture just to silence a model.

Step 5: Name one allowed behavior change

Write the intended change as one plain sentence. One example intent: stop mutating the input dict. Return value and stdout must remain byte identical.

That sentence is a contract addendum, not a vibe. Update only the item_after field when needed. Keep that golden update in the same commit.

Step 6: Apply the smallest patch

Change only one concern inside the helper body. Do not rename, extract, and reformat code together. Those three actions are three separate refactor steps.

# examples/messy_price.py — smallest change, illustrative only
import json
import sys


def quote_line(item, tax_rate, log=None):
    log = log if log is not None else sys.stdout
    name = item.get("name") or "item"
    qty = int(item.get("qty") or 1)
    unit = float(item.get("unit") or 0)
    raw = qty * unit
    if item.get("bulk") and qty >= 10:
        raw *= 0.9
    tax = round(raw * float(tax_rate), 2)
    total = round(raw + tax, 2)
    rec = {"name": name, "raw": raw, "tax": tax, "total": total}
    log.write(json.dumps(rec) + "\n")
    return total
Enter fullscreen mode Exit fullscreen mode

The only deleted line was item["quoted"] = True. Logging and returned totals stay on the old path. Callers that never read quoted keep the same numbers.

Re-run the characterization test after the edit. Revert immediately if return or stdout values drift. Inspect git diff on the capture file next.

python -m pytest tests/test_quote_characterization.py -q
git diff -- captures/quote_line.json
Enter fullscreen mode Exit fullscreen mode

Reject extra keys in the capture JSON diff. Reject float noise you did not request here. Reject import reorder mixed into the same patch.

Step 7: Diff capture files field by field

Line-oriented git diff hides nested JSON structure well. Flatten both captures and compare every leaf path. The script below is illustrative and still unexecuted.

# examples/diff_capture_fields.py — illustrative only, unexecuted
import json
import sys
from pathlib import Path


def flatten(prefix, value, out):
    if isinstance(value, dict):
        for key, child in sorted(value.items()):
            path = f"{prefix}.{key}" if prefix else key
            flatten(path, child, out)
    elif isinstance(value, list):
        for index, child in enumerate(value):
            flatten(f"{prefix}[{index}]", child, out)
    else:
        out[prefix] = value


def main(old_path, new_path):
    old = json.loads(Path(old_path).read_text())
    new = json.loads(Path(new_path).read_text())
    left, right = {}, {}
    flatten("", old, left)
    flatten("", new, right)
    for path in sorted(set(left) | set(right)):
        if left.get(path) != right.get(path):
            print(path)
            print(f"  - {left.get(path)!r}")
            print(f"  + {right.get(path)!r}")


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])
Enter fullscreen mode Exit fullscreen mode
python examples/diff_capture_fields.py captures/quote_line.json /tmp/new.json
Enter fullscreen mode Exit fullscreen mode

Read every printed path before you keep the patch. A changed return path is a product change. A changed stdout path is also a product change.

Only a planned item_after.quoted field change should appear. Anything else means the patch is too large. Split that patch or revert and start again.

Step 8: Optional model pass after the oracle exists

A coding model helps only after captures exist. Prompt it with the golden file plus intent. Ask for a diff that preserves two frozen channels.

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

MonkeyCode provides free model access and a free server option. Use that option to draft the smallest patch only. Paste the capture, the test, and the one-sentence intent.

Do not ask any model to clean the whole file. That prompt invites drive-by renames and extra formatting. Ask for one concern and a unified diff.

Treat all model output as untrusted text, always. Run the characterization test on your machine. Discard diffs that touch a second concern.

A free server is enough for this short loop. The task is a tiny patch proposal. Keep private repos off shared logs if policy requires.

If the capture is already green on HEAD, try one model-drafted patch against it.

Decision table

Observed signal Required action
No capture file on HEAD Do not edit the helper
Capture exists and the test is green Permit one named behavior change
Diff changes return or stdout Reject the patch
Diff also reformats the file Split the commit
Function is non-deterministic Seed it or skip capture
Callers need a new output field Add a test, then extend capture
Two people edit the same helper Serialize work or split the function

The table above is the process, not decoration. Skip a row and the safety rail fails. Print it next to the pull request template.

What this method does not prove

A capture is not a product specification document. It freezes old accidents along with intended rules. Wrong rounding stays wrong until you name it.

Five fixtures are not serious test coverage numbers. They are a smoke oracle for one helper. Add extra cases only from real incident traces.

The harness ignores time, network, and global RNG. Do not capture those channels without tight control. Unstable captures train whole teams to ignore failures.

This field-level equality also misses hidden performance regressions. It misses leaked file handles and extra queries. Pair this workflow with your existing integration tests.

Who should not use this approach

Skip this workflow on a brand-new module. Write a real spec and direct tests instead. Characterization copies the past, including its old bugs.

Skip it when the helper must change globally. A capture will fight a deliberate product change. Use explicit acceptance tests for that work.

Skip it during a wide rename across packages. The golden file will thrash on every path. Finish the rename, recapture, then change one behavior.

Skip it for cryptographic or financial audit work. Byte equality is not a proof of correctness. Get a spec review before touching those helpers.

Merge checklist

  1. Keep the capture file committed on the parent SHA.
  2. One written sentence names the allowed behavior change.
  3. Confirm the patch touches exactly one named concern.
  4. Keep the characterization test green on the branch.
  5. Review the field-level capture diff before merge.
  6. Ban drive-by rename or format changes here.

If any box is still open, do not merge. Revert the helper and recapture only with review. The next patch starts from a green oracle.

Closing note

Messy-repo risk still lives in silent contracts. A committed capture file makes those contracts loud. The smallest safe change then becomes locally obvious.

Keep any model on a short factual leash. Keep the capture harness inside ordinary CI jobs. Style cleanup can wait for a later commit.

Top comments (0)