The safest refactor isn't the cleverest one. It's the smallest one that keeps the tests green. When an AI tool suggests a 200-line rewrite, you should usually say no. Set a diff budget first.
The Problem: Big Diffs Are Slow to Review
Every changed line is a risk. The more lines you touch, the more time reviewers need. They also lose context faster.
AI makes this worse. A coding model can generate a clever 300-line redesign in seconds. That doesn't mean you should merge it.
A hard diff budget forces you to ask: can I do this in 30 lines? If not, you're not refactoring. You're rewriting.
The Artifact: A Diff Budget
A diff budget is a maximum number of changed lines per commit. It is not a rule for greenfield code. It is for messy, legacy, or high-fan-in functions.
For this workflow, use 30 lines changed maximum per refactor commit. That includes additions, deletions, and modifications. Whitespace changes count too.
Prerequisites: Characterization Tests
You cannot safely refactor without a behavior oracle. Write characterization tests before touching the function. These tests lock the current behavior, including the weird parts.
Here's a messy function we want to refactor:
def load_config(raw):
if not isinstance(raw, dict):
return {}
cfg = {}
for k, v in raw.items():
if k == "host":
cfg["host"] = v.strip() if isinstance(v, str) else v
elif k == "port":
cfg["port"] = int(v) if isinstance(v, str) else v
elif k == "timeout":
cfg["timeout"] = float(v) if isinstance(v, str) else v
return cfg
Write tests that capture edge cases:
def test_default_empty():
assert load_config({}) == {}
def test_accepts_non_dict():
assert load_config(None) == {}
def test_strips_host():
assert load_config({"host": " example.com "})["host"] == "example.com"
def test_parses_port_string():
assert load_config({"port": "8080"})["port"] == 8080
def test_unknown_keys_ignored():
assert load_config({"debug": True}) == {}
Run them. They should pass. If they fail, fix the tests to match reality—but never silently adjust expectations.
Step 1: Measure Fan-In First
Before you refactor, know who calls this function. Use ripgrep or your editor's search:
rg -n "load_config\(" --glob '!test*' .
If more than 10 callers exist, keep the public signature identical. Do not rename parameters. Do not change return types. Your diff budget will keep you honest.
Step 2: Set the Budget and Make It Visible
Create a simple check that fails when the diff exceeds 30 lines:
git diff --numstat | awk '{add+=$1; del+=$2} END {total=add+del; print total; if (total > 30) exit 1}'
Save it as scripts/check_diff_budget.sh. Run it on every refactor branch. This is your guardrail.
Step 3: Ask AI for the Smallest Change
Now you can involve an AI model. I use MonkeyCode's free model access for this kind of constrained task. The model gets a tight prompt with explicit limits:
Given this function and its characterization tests, propose the smallest change that preserves behavior.
Constraints:
- Do not change the public function signature.
- Do not modify the test expectations.
- Keep the diff under 30 changed lines.
- Explain the change in two sentences.
A good output looks like this:
_CASTS = {
"host": lambda v: v.strip() if isinstance(v, str) else v,
"port": lambda v: int(v) if isinstance(v, str) else v,
"timeout": lambda v: float(v) if isinstance(v, str) else v,
}
def load_config(raw):
if not isinstance(raw, dict):
return {}
return {k: _CASTS[k](v) for k, v in raw.items() if k in _CASTS}
Count the lines. This refactor touches roughly 15 lines. It fits the budget.
Step 4: Run Tests and a Mutation Spot-Check
Run the characterization tests first. If they pass, run a mutation spot-check to make sure the tests actually catch injected faults. For example, break one cast line:
"port": lambda v: int(v) if isinstance(v, str) else v # then delete this fallback
Your test for load_config({"port": 8080}) should fail. If the tests stay green even after mutation, your tests are too weak. Do not refactor until they catch the mutation.
You can run the full cycle on MonkeyCode's free server option. It gives you a disposable environment to execute tests without touching your local machine. That's useful when you are working on a messy repo with weird dependencies.
Step 5: Review the Diff, Not Just the Tests
Passing tests are not enough. Read the diff line by line and ask three questions:
- Does the change alter any edge-case behavior that the tests missed?
- Does it touch more files than necessary?
- Does it leave the callers unaffected?
Use a decision table to make acceptance objective:
| Condition | Accept? |
|---|---|
| Tests pass | Required |
| Mutation spot-check fails as expected | Required |
| Diff <= 30 lines | Required |
| Public surface unchanged | Required |
| All callers unaffected | Required |
If any condition is false, reject the diff and ask for a smaller alternative.
Limitations: When This Approach Fails
Diff budgets do not solve architectural rot. If your function is 500 lines long and tangled across global state, a 30-line refactor may be impossible. You need a bigger plan first.
Do not use this workflow for greenfield features. New code benefits from larger, well-structured changes. The budget is a safety tool for legacy code, not a straitjacket for fresh designs.
Also, characterization tests only capture what you observe. If the function has rare side effects or timing-dependent behavior, your tests may miss them. Add logging and manual checks for those paths.
Who Should Not Use This
Teams with zero test coverage should not start here. First, build a test harness around the function. Then set a budget.
People who expect a single AI prompt to fix everything will be disappointed. The model is a generator. You are the reviewer. The diff budget is your review gate.
The Takeaway
Small refactors are easier to verify. Small refactors are easier to revert. Small refactors build trust with your team and with your test suite.
Set a limit. Enforce it with a script. Use AI inside that constraint, not outside it.
If you want to try this workflow with a free model and a disposable server, MonkeyCode's free tier is a reasonable place to start. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Top comments (0)