The Change Budget: A Decision Table for Messy Repo Refactors
You inherited a messy module. It has no tests. It has four callers. You still need to change it.
Most refactors fail at the wrong granularity. They move structure and behavior at the same time. The result is a broken commit and a long revert. A change budget fixes this. It limits each commit to one seam and one observable behavior.
What Is a Change Budget?
A change budget is a spend limit. It says: one seam per commit, one characterization test per seam. You do not refactor a whole module in one day. You move one boundary. Then you stop. This makes every commit easy to review, easy to revert, and easy to blame.
Step 1: Map the Seams
A seam is a function boundary you can change without touching its callers. You need to find those boundaries first. Mapping them by hand is slow. This Python script does it for you.
import ast, json, sys
from pathlib import Path
def seam_map(path: Path) -> dict:
tree = ast.parse(path.read_text())
seams = {}
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
seams[node.name] = {
"lineno": node.lineno,
"args": [a.arg for a in node.args.args],
"callers": []
}
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
if node.func.id in seams:
seams[node.func.id]["callers"].append(node.lineno)
return seams
if __name__ == "__main__":
print(json.dumps(seam_map(Path(sys.argv[1])), indent=2))
Save it as seam_map.py. Run it against your messy module.
python seam_map.py order_processor.py
The output is a JSON map. It lists every function and every local caller. This is your battlefield map. Keep it updated after each commit.
Step 2: Set the Budget
Now use the map to set a spend limit. The caller count changes your strategy. Here is the decision table.
| Caller count | Existing tests | Budget |
|---|---|---|
| 0 | no | Delete or quarantine the function |
| 1 | no | Inline it or rename it |
| 2-5 | no | Write a characterization test, then extract |
| 6+ | no | Freeze the output, then refactor |
| any | yes | Refactor with the test as supervision |
Zero-caller functions are dead weight. Do not refactor them. Delete them or quarantine them. One-caller functions are easy to inline. Two-to-five callers need protection. Six-plus callers need a freeze before anything touches them.
Step 3: Characterize the Risk
A characterization test records the current behavior. It does not judge the behavior. It does not say whether the behavior is correct. It only pins it down.
def test_apply_discount_matches_current_behavior():
order = load_fixture("legacy_order.json")
result = apply_discount(order)
assert result.total == 149.25
Run the test. If it fails, your fixture is wrong. Fix the fixture. Do not fix the code. One test per invariant keeps the budget small.
Worked Example
Your module has apply_discount. It has three callers. The seam map shows them. The budget says: characterize, then extract.
def discount_rate(order):
if order.total >= 500:
return 0.1
if order.coupon:
return 0.05
return 0.0
Extract discount_rate from apply_discount. Keep the caller signature the same. Run the characterization test. Commit with a clear message: extract discount_rate from apply_discount.
Step 4: Spend the Budget
Pick one seam from the map. Write one characterization test. Refactor that seam only. Run the test. Commit. This is the whole loop.
- Pick one seam.
- Write one characterization test.
- Change only that seam.
- Run the test.
- Commit and update the map.
If the test fails, revert the change. Do not patch the test to make it pass. A failing test inside a refactor means your change was too large. Split it into a smaller spend.
Where Free Models Help
This is where MonkeyCode's free model access is useful. Paste the seam map JSON into the chat. Ask it to list hidden assumptions. It may surface branches you did not see. For example, a date parser may assume UTC inside an order total. The seam map alone will not show that.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
You can do the same review manually. The free model just makes it faster. Use the answer as a checklist, not as gospel. Verify every assumption with a test.
Running It in a Disposable Environment
Legacy code often refuses to run on your machine. It needs old dependencies. It needs a specific OS. MonkeyCode's free server option gives you a disposable place to run the same commands. You can run the seam map and the characterization suite without polluting your laptop. That keeps the workflow reproducible and cheap.
Limitations
This approach has real limits. Do not use it for greenfield code. Do not use it for modules that already have solid tests. It will not teach you the domain. It only freezes the current behavior.
If the current behavior is wrong, a characterization test locks in the bug. That is fine during a refactor. Refactors preserve behavior. Bug fixes change behavior. Keep the two budgets separate.
Who Should Not Use This
You should skip this workflow if you are writing a new feature. You should also skip it if your module already has meaningful tests. The change budget works best on untested legacy code with many callers. That is its only job.
Messy refactors fail at scale. A change budget prevents that. Map the seams. Rank the risk. Characterize. Then spend one small commit at a time.
Try it on your ugliest module today.
Top comments (0)