Do not extract helpers from a messy file first.
Freeze every public export in a result lockfile.
Then change one private helper and then stop.
Messy files hide behavior in globals, coercion, and rare branches.
A shape-preserving diff can still flip a rounding path.
An export lockfile fails the build when any listed case moves.
This workflow treats the file as a sealed contract.
It does not try to understand the full call graph.
It only pins observable results of public names.
The failure mode this blocks
AI-assisted refactors often start inside the largest function.
That function usually mixes parsing, policy, and formatting.
The extract looks cleaner and still ships a silent miss.
Characterization of one inner function is not enough.
Callers may depend on import-time defaults and shared dicts.
You must pin every export the file currently exposes.
A second miss is order-dependent module cache state.
The first call writes a global; later calls read it.
A lockfile that ignores reload order will lie later.
What the lockfile records
Record only JSON-safe facts about each public callable.
Store the export name, the fixture id, and the outcome.
Outcome is a return value, or an exception type name.
Do not record wall-clock time or random memory ids.
Do not record full tracebacks or log line numbers.
Those fields churn and make the lockfile noisy.
The lockfile is a regression oracle, not a spec.
It encodes current behavior, including the ugly parts.
Ugly current behavior is what callers already survived.
Artifact: export lockfile harness
Label the module below as a teaching example.
It is a compact stand-in for a brownfield billing file.
Do not treat the numbers as production tax advice.
# messy_invoice.py
from decimal import Decimal, ROUND_HALF_EVEN
DEFAULT_REGION = "US-CA"
_CACHE = {}
def _norm(region):
if not region:
return DEFAULT_REGION
return str(region).strip().upper()
def tax_rate(region=None):
key = _norm(region)
if key in _CACHE:
return _CACHE[key]
table = {"US-CA": "0.0825", "US-NY": "0.08875", "DE-BE": "0.19"}
rate = Decimal(table.get(key, "0.00"))
_CACHE[key] = rate
return rate
def line_total(qty, unit_cents, region=None, exempt=False):
if qty is None or unit_cents is None:
raise ValueError("qty and unit_cents are required")
q = Decimal(str(qty))
u = Decimal(str(unit_cents))
if q < 0 or u < 0:
raise ValueError("negative amounts are not allowed")
subtotal = (q * u).quantize(Decimal("1"), rounding=ROUND_HALF_EVEN)
if exempt or q == 0:
return int(subtotal)
rate = tax_rate(region)
tax = (subtotal * rate).quantize(Decimal("1"), rounding=ROUND_HALF_EVEN)
return int(subtotal + tax)
def describe_line(qty, unit_cents, region=None, exempt=False):
total = line_total(qty, unit_cents, region, exempt)
return f"{_norm(region)}:{qty}x{unit_cents}={total}"
The file exports three callables and one module default.
tax_rate mutates a process-level cache on first use.
describe_line couples formatting to the tax policy.
Step 1: list the exports you will freeze
Run a tiny inspector before you write fixtures.
Unknown names mean the file is not yet characterized.
Stop the refactor until that list looks complete.
# list_exports.py
import importlib, inspect, json, sys
mod = importlib.import_module(sys.argv[1])
names = []
for name, obj in inspect.getmembers(mod):
if name.startswith("_"):
continue
if inspect.isfunction(obj) and obj.__module__ == mod.__name__:
names.append(name)
print(json.dumps(sorted(names), indent=2))
python list_exports.py messy_invoice
Expected names are describe_line, line_total, and tax_rate.
If this list surprises you, stop the refactor immediately.
An unknown export is an unowned contract with callers.
Step 2: write table-driven fixtures
Keep fixtures boring, explicit, and JSON serializable.
Cover zeros, blanks, unknown regions, and exception paths.
Cover cache hits with a separate dirty-process case later.
{
"module": "messy_invoice",
"cases": [
{"id": "rate-ca", "export": "tax_rate", "args": ["us-ca"]},
{"id": "rate-blank", "export": "tax_rate", "args": [""]},
{"id": "rate-unknown", "export": "tax_rate", "args": ["xx-zz"]},
{"id": "line-zero-qty", "export": "line_total", "args": [0, 199, "US-CA", false]},
{"id": "line-exempt", "export": "line_total", "args": [2, 199, "US-CA", true]},
{"id": "line-ny-round", "export": "line_total", "args": [3, 99, "us-ny", false]},
{"id": "line-missing", "export": "line_total", "args": [null, 10], "raises": "ValueError"},
{"id": "line-neg", "export": "line_total", "args": [-1, 10], "raises": "ValueError"},
{"id": "desc-ca", "export": "describe_line", "args": [1, 1000, "us-ca", false]}
]
}
Nine cases will not exhaust the file.
They pin the branches you already know are loaded.
Add a case when a production miss appears later.
Step 3: reload the module before every isolated case
Shared _CACHE makes case order part of the contract.
Isolated mode deletes the module and imports it again.
Use that mode for the default lockfile rows.
# freeze_exports.py
import importlib, json, sys, traceback
from decimal import Decimal
from pathlib import Path
def json_safe(value):
if isinstance(value, Decimal):
return str(value)
return value
def load_fresh(name):
doomed = [key for key in sys.modules if key == name or key.startswith(name + ".")]
for key in doomed:
del sys.modules[key]
return importlib.import_module(name)
def run_case(mod, case):
fn = getattr(mod, case["export"])
args = case.get("args", [])
kwargs = case.get("kwargs", {})
expected_exc = case.get("raises")
try:
value = fn(*args, **kwargs)
if expected_exc:
return {"id": case["id"], "ok": False, "error": "expected exception"}
return {
"id": case["id"],
"ok": True,
"export": case["export"],
"result": json_safe(value),
}
except Exception as exc:
name = type(exc).__name__
if expected_exc and name == expected_exc:
return {
"id": case["id"],
"ok": True,
"export": case["export"],
"raises": name,
}
return {
"id": case["id"],
"ok": False,
"export": case["export"],
"raises": name,
"message": str(exc),
"trace": traceback.format_exc(),
}
def main():
spec = json.loads(Path("fixtures.json").read_text())
rows = []
for case in spec["cases"]:
mod = load_fresh(spec["module"])
rows.append(run_case(mod, case))
failed = [row for row in rows if not row["ok"]]
if failed:
raise SystemExit(json.dumps(failed, indent=2))
lock = {"module": spec["module"], "mode": "isolated", "rows": rows}
Path("export.lock.json").write_text(json.dumps(lock, indent=2, sort_keys=True))
print(f"wrote {len(rows)} isolated rows")
if __name__ == "__main__":
main()
python freeze_exports.py
git add fixtures.json export.lock.json
git commit -m "test: freeze messy_invoice export table"
Commit the lockfile before any production edit.
The next command only compares, and never rewrites.
Rewrites belong to an explicit re-record step.
Step 4: pin one dirty-process cache path separately
Isolated rows will not catch cache reuse bugs.
Add one sequenced file that keeps the module loaded.
Do not mix sequenced rows into the isolated lockfile.
# freeze_cache_sequence.py
import importlib, json
from pathlib import Path
from freeze_exports import run_case
spec = json.loads(Path("fixtures.json").read_text())
mod = importlib.import_module(spec["module"])
sequence = [
{"id": "cache-miss-ca", "export": "tax_rate", "args": ["us-ca"]},
{"id": "cache-hit-ca", "export": "tax_rate", "args": ["US-CA"]},
{"id": "cache-blank-default", "export": "tax_rate", "args": [""]},
]
rows = [run_case(mod, case) for case in sequence]
Path("cache.lock.json").write_text(
json.dumps({"module": spec["module"], "mode": "sequenced", "rows": rows}, indent=2, sort_keys=True)
)
print("wrote sequenced cache rows")
The second call must match the first cached Decimal.
If an extract clears _CACHE too often, this file breaks.
If an extract forgets the cache, timings change later.
Step 5: compare, do not rewrite, on every run
# check_exports.py
import json
from pathlib import Path
from freeze_exports import load_fresh, run_case
spec = json.loads(Path("fixtures.json").read_text())
lock = json.loads(Path("export.lock.json").read_text())
rows = []
for case in spec["cases"]:
mod = load_fresh(spec["module"])
rows.append(run_case(mod, case))
current = json.dumps(
{"module": spec["module"], "mode": "isolated", "rows": rows},
indent=2,
sort_keys=True,
)
frozen = json.dumps(lock, indent=2, sort_keys=True)
if current != frozen:
raise SystemExit("export lockfile mismatch; revert or re-record explicitly")
print("export lockfile holds")
python check_exports.py
python freeze_cache_sequence.py
diff -u cache.lock.json <(python -c 'print(open("cache.lock.json").read())')
A mismatch means behavior moved for a listed export.
It does not tell you which design is better.
It tells you the refactor is no longer behavior-neutral.
Step 6: issue a one-file change permit
Characterization without a change budget still sprawls.
Limit the refactor to one private helper in one file.
Fail the check when the diff exceeds that budget.
#!/usr/bin/env bash
set -euo pipefail
python check_exports.py
changed=$(git diff --name-only HEAD -- '*.py' | grep -v -E '^(test_|check_|freeze_|list_)' || true)
count=$(printf '%s\n' "$changed" | sed '/^$/d' | wc -l | tr -d ' ')
if [ "$count" -gt 1 ]; then
echo "change permit exceeded:"
echo "$changed"
exit 1
fi
echo "change permit ok ($count production file)"
The permit is crude and that is the point.
It blocks drive-by cleanups in sibling modules.
Those cleanups belong to a later, separately locked change.
Step 7: extract one private helper only
Keep line_total signatures and branches intact.
Move only the quantize policy into a private helper.
Do not touch tax_rate cache behavior in the same diff.
def _cents(amount):
return amount.quantize(Decimal("1"), rounding=ROUND_HALF_EVEN)
def line_total(qty, unit_cents, region=None, exempt=False):
if qty is None or unit_cents is None:
raise ValueError("qty and unit_cents are required")
q = Decimal(str(qty))
u = Decimal(str(unit_cents))
if q < 0 or u < 0:
raise ValueError("negative amounts are not allowed")
subtotal = _cents(q * u)
if exempt or q == 0:
return int(subtotal)
rate = tax_rate(region)
tax = _cents(subtotal * rate)
return int(subtotal + tax)
Run the checker after the extract, before any review.
If export.lock.json would change, revert the helper.
A cleaner helper that moves a rounding row is a bug.
Re-record only with an explicit command
Do not overwrite locks from a failing checker run.
Re-record after you add a fixture, not after a refactor.
Keep that rule in the review checklist every time.
- Add one new fixture for a proven miss.
- Run
python freeze_exports.pyon an unchanged tree. - Commit fixtures and lockfiles with no production diff.
- Only then start the next one-helper extract.
If step three includes messy_invoice.py, stop immediately.
That commit mixed oracle changes with behavior changes.
Split it, or the next mismatch cannot be diagnosed.
Where a free model and free server fit
A model can propose the private extract after locks are green.
It should not choose fixtures, and it should not edit lockfiles.
Those files are the oracle, not another prompt target.
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 to draft the helper from locked behavior.
Use the free server option to run the checker on a clean tree.
Drop both tools and the lockfile workflow remains valid.
Decision table
| Observation | Action | Stop condition |
| freeze list misses an export | add fixtures, then re-record | list still incomplete |
| isolated lock mismatches after extract | revert the helper | mismatch remains |
| sequenced cache lock mismatches | restore _CACHE behavior | cache rows still move |
| diff touches two production files | split the work | permit still exceeded |
| model wants to fix rounding | keep the current lock | any row would move |
Read the table top to bottom on every pass.
Do not skip to a larger redesign when a row fails.
Failure means the current change is too large.
Limitations
This oracle only knows the cases you listed.
Unlisted branches can still move without a mismatch.
Import-time work beyond _CACHE is not fully pinned.
JSON cannot store NaN, sets, or live sockets.
Binary files, clocks, and network calls need other seams.
Do not coerce those values into strings and call them pinned.
The change permit uses filenames, not semantic diffs.
Renames and generated code can fool the budget script.
Treat it as a seatbelt, not as formal verification.
Decimal values are stored as strings in this harness.
That avoids float drift, not policy drift in tax tables.
A changed rate string is a real behavior change.
Who should not use this
Skip this workflow for greenfield modules with real unit tests.
Skip it for cryptographic code that must not freeze accidents.
Skip it when exports return unserializable live resources.
Do not use it as permission to keep a god file forever.
The lockfile is a temporary fence around one extract.
Plan a later split once several helpers exist and stay green.
Teams without a command runner should not adopt the permit script.
The value is the failed check, not the presence of JSON files.
If nobody runs check_exports.py, the fence is theater.
Closing
Messy-file refactors fail when the oracle is vibes.
Freeze isolated exports, then freeze one cache sequence.
Extract one private helper only after both locks hold.
Top comments (0)