DEV Community

Dakota Huang
Dakota Huang

Posted on

Refactor on a Budget: Why Your Diff Size Should Be a Hard Limit

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
Enter fullscreen mode Exit fullscreen mode

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}) == {}
Enter fullscreen mode Exit fullscreen mode

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*' .
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Does the change alter any edge-case behavior that the tests missed?
  2. Does it touch more files than necessary?
  3. 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)