Messy-repo refactors fail without a public behavior pin.
Capture multi-step scenarios before you edit any file.
Then apply one private change behind that pin.
This workflow treats a tangled package as a black box.
It records public call sequences, not private helpers.
The suite must stay green after each tiny edit.
The failure mode
Large diffs often rewrite several files at once.
Tangled modules hide side effects across those files.
A green unit test can still miss a public sequence.
Single-function checks miss cross-file coupling.
A messy package needs scenario pins instead.
That gap is what this method closes.
Public names are the only stable seam today.
Private helpers will move during a later extract.
Pinning helpers would freeze the mess in place.
What you pin
Pin only the public entry points of the package.
Ignore private helpers until that pin is stable.
Record ordered calls, return values, and error names.
Store each scenario as a frozen fixture file.
Replay the same calls after every candidate patch.
Any drift is a failed refactor, not style.
Do not pin wall-clock timestamps or raw UUIDs.
Do not pin unordered set iteration order either.
Normalize those values before writing a pin file.
Worked example (unexecuted)
The listing below is a proposal, not a live run.
It models a tangled invoicing package in one repo.
Do not treat the figures as production metrics.
1. Map the messy package
List public modules before you touch any source.
find messy_ops -name '*.py' | sort
rg -n "^def |^class " messy_ops --glob '*.py'
rg -n "from messy_ops|import messy_ops" --glob '*.py'
Keep a caller list for later fan-out work.
This pass only records who imports the package.
2. Sketch the public surface
# messy_ops/api.py — current public facade
from messy_ops.invoice import draft
from messy_ops.tax import apply_tax
from messy_ops.discount import apply_codes
def quote(cart, codes, region):
lines = draft(cart)
lines = apply_codes(lines, codes)
total = apply_tax(lines, region)
return {"lines": lines, "total": total}
Private files may share globals or mutate carts.
The pin must not import those private modules.
Scenarios must call the public quote path only.
3. Write scenario fixtures
{
"name": "quote_basic",
"calls": [
{
"fn": "quote",
"args": {
"cart": [{"sku": "A", "qty": 2, "price": 10}],
"codes": [],
"region": "US"
}
}
]
}
Add empty cart, stacked codes, and unknown region cases.
Keep each fixture tied to one public function.
Name files after the behavior, not the internals.
A second fixture should cover a raised error path.
Record the exception class name, not a full traceback.
Tracebacks include paths and change across machines.
4. Add a record-mode pytest option
# tests/conftest.py
import pytest
def pytest_addoption(parser):
parser.addoption("--record-pins", action="store_true", default=False)
@pytest.fixture
def record_mode(request):
return request.config.getoption("--record-pins")
Record mode writes pins. Default mode compares them.
Never leave record mode enabled in CI jobs.
CI should fail when a committed pin is missing.
5. Build the characterization runner
# tests/test_characterize_messy_ops.py
import json
from pathlib import Path
import pytest
from messy_ops.api import quote
SCENARIO_DIR = Path(__file__).parent / "scenarios"
FNS = {"quote": quote}
def load_scenarios():
paths = sorted(SCENARIO_DIR.glob("*.json"))
for path in paths:
if path.name.endswith(".pin.json"):
continue
yield path, json.loads(path.read_text())
def run_call(call):
fn = FNS[call["fn"]]
try:
value = fn(**call["args"])
return {"ok": True, "value": value, "error": None}
except Exception as exc:
return {
"ok": False,
"value": None,
"error": type(exc).__name__,
"message": str(exc),
}
@pytest.mark.parametrize("path,spec", list(load_scenarios()))
def test_scenario_pin(path, spec, record_mode):
results = [run_call(c) for c in spec["calls"]]
pin_path = path.with_suffix(".pin.json")
payload = json.dumps(results, indent=2, sort_keys=True)
if record_mode:
pin_path.write_text(payload + "\n")
pytest.skip("recorded pin; re-run without --record-pins")
expected = pin_path.read_text()
assert payload + "\n" == expected, path.name
Record once on a known-good checkout only.
Commit the .pin.json files with the tests.
Later edits must leave those pin files unchanged.
6. Record, then freeze
git status --porcelain
pytest tests/test_characterize_messy_ops.py --record-pins
pytest tests/test_characterize_messy_ops.py
git add tests/scenarios tests/conftest.py tests/test_characterize_messy_ops.py
git commit -m "pin messy_ops public scenarios"
Refuse to record on a dirty working tree.
Refuse to record after an unreviewed generated patch.
Pins must come from the current known package behavior.
7. Apply the smallest safe change
Pick one private concern after the pin is green.
One example is extracting tax lookup from apply_tax.
Do not rename public quote in the same patch.
Use these patch rules in order:
- Touch one private file when that is possible.
- Keep every public signature byte-stable in this patch.
- Add no new public names and no new re-exports.
- Leave every scenario pin file byte-identical.
- Revert at once if any pin comparison fails.
pytest tests/test_characterize_messy_ops.py
git diff --stat
rg -n "def quote\(" messy_ops
The stat output should list one or two files.
If the diff spans the public facade, stop immediately.
Split the work and re-run the pin suite.
Decision table
| Signal | Action |
|---|---|
| No pin for a public path | Record the path. Do not edit. |
| Pins green, one private smell | Extract or rename internals only. |
| Pins green, public name is wrong | Add a facade method. Keep the old name. |
| Pins red after a candidate patch | Revert. Shrink the patch. Re-run. |
| Scenario needs time, IO, or entropy | Inject a clock, stub, or seed first. |
| Callers live outside the package | Do not change those callers in this patch. |
Use the table before you open an editor.
The table beats any large generated refactor diff.
Test plan for every candidate
Run this plan on every candidate change.
-
pytest tests/test_characterize_messy_ops.pyconfirms every pin. -
git diff --statstays inside one private area. -
rg -n "def quote"shows an unchanged public signature. -
rg -n "__all__|from messy_ops.api import"shows no new surface. - Pin files still contain stable error class names only.
Fail the change if any step fails.
Do not compensate with extra unit tests yet.
Add focused tests only after the pin holds.
Where a coding model fits
A coding model helps only after scenario pins exist.
It should propose only the smallest private extract.
It should not rewrite the messy package in one pass.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode provides free model access and a free server option.
Draft one private extract there, then judge the patch with local pins.
The operator loop should look like the steps below.
- Freeze scenarios on the current clean branch.
- State the one-file extract in the prompt.
- Apply the result on a throwaway branch only.
- Re-run the characterization suite on your machine.
- Keep the patch only if every pin stays identical.
Do not paste secrets into any prompt.
Do not ask the model to invent new pins.
Pins come from the running package, not text.
The free server option stays optional, not required.
The local suite remains the source of truth.
Reject any multi-file rewrite on sight.
Limitations
This method still does not prove functional correctness.
It only detects drift from the recorded behavior.
A wrong pin will freeze wrong behavior forever.
The pin runner still struggles with unordered collections.
It also struggles with timestamps and fresh UUIDs.
Floating-point jitter will break naive equality checks.
It does not replace contract tests with peer services.
It does not replace load tests or security review.
It does not authorize edits in a repo you do not own.
Skip this approach in the cases below.
- You cannot run the package inside a harness.
- Behavior is intentionally nondeterministic by design.
- The public surface is still undefined or unstable.
- You need a one-shot rewrite for an immovable deadline.
- Policy blocks sending code to a third-party host.
In those cases, write explicit tests first.
Or isolate the module behind a new API.
Do not record noise and call it safety.
What smallest actually means
Smallest is not the fewest changed characters.
Smallest is the fewest public behaviors at risk.
A forty-line private extract can be smaller risk.
Count changed pins, not changed lines.
Zero pin changes is the pass condition.
Any pin change needs a written reason.
If a generated patch spans several private files, split it.
Ask again for one private extract only.
Re-run the suite after that extract lands.
Public scenario pins are the gate for a messy package.
Change one private path. Re-run the pins.
Repeat until the mess is actually smaller.
Top comments (0)