DEV Community

Dakota Huang
Dakota Huang

Posted on

A Diff Budget Gate for Refactors: Fail the Branch Before Review Does

A Diff Budget Gate for Refactors: Fail the Branch Before Review Does

Green tests prove behavior survived. They say nothing about blast radius.

A refactor branch can keep every assertion passing and still edit forty files nobody asked for. Reviewers then spend their attention on where the diff went, not whether it is correct. The fix is small: a diff budget gate that fails the branch mechanically, before a human has to notice.

The gap that test suites leave open

Test suites are scoped to behavior you thought to assert. Blast radius is scoped to files, dependencies, and lines changed. Those are different measurements.

So a branch can pass CI while quietly modifying a shared helper, a config default, or a second module's import path. Nothing in the test output flags it, because no test was written for "this file should not move."

A budget gate closes that gap with two cheap numbers and one frozen list.

What the gate enforces

Four rules, each answerable with git diff:

  1. Scope. Every changed file must live under a declared prefix.
  2. Budget. Total changed lines must stay under a declared limit.
  3. File count. The diff must touch no more than N files.
  4. Frozen paths. Recorded behavior files must not be edited in the same branch.

Rule 4 is the important one. If someone edits the test that pins current behavior so the new code passes, the gate fails. That edit is a redefinition of truth, not a refactor.

The artifact

Two files. No dependencies beyond the standard library and git.

scope.json — the declared blast radius, committed alongside the branch:

{
  "baseline": "9f2c1ab",
  "allowed_prefixes": ["src/billing/"],
  "frozen_prefixes": ["tests/characterization/"],
  "max_changed_lines": 120,
  "max_files": 4
}
Enter fullscreen mode Exit fullscreen mode

gate.py — the checker:

#!/usr/bin/env python3
"""Fail a refactor branch that leaves its declared blast radius.

Usage:
    python gate.py scope.json
    python gate.py scope.json --baseline 9f2c1ab
"""
from __future__ import annotations

import argparse
import json
import pathlib
import subprocess
import sys


def git(*args: str) -> str:
    result = subprocess.run(
        ["git", *args], check=True, capture_output=True, text=True
    )
    return result.stdout


def changed_files(baseline: str) -> list[tuple[str, int, int]]:
    """Return (path, added, deleted) for each file in baseline...HEAD."""
    rows: list[tuple[str, int, int]] = []
    diff = git("diff", "--numstat", f"{baseline}...HEAD", "--")
    for line in diff.splitlines():
        if not line.strip():
            continue
        added, deleted, path = line.split("\t", 2)
        rows.append((
            path,
            int(added) if added.isdigit() else 0,
            int(deleted) if deleted.isdigit() else 0,
        ))
    return rows


def under_any(path: str, prefixes: list[str]) -> bool:
    return any(path == p or path.startswith(p) for p in prefixes)


def check(spec: dict, baseline: str) -> list[str]:
    allow = spec.get("allowed_prefixes", [])
    frozen = spec.get("frozen_prefixes", [])
    budget = spec.get("max_changed_lines", 0)
    max_files = spec.get("max_files", len(allow))

    rows = changed_files(baseline)
    failures: list[str] = []
    total = 0

    for path, added, deleted in rows:
        total += added + deleted
        if frozen and under_any(path, frozen):
            failures.append(f"frozen path edited: {path} (+{added}/-{deleted})")
        elif not under_any(path, allow):
            failures.append(f"outside scope: {path} (+{added}/-{deleted})")

    if total > budget:
        failures.append(f"line budget: {total} changed lines > {budget}")
    if len(rows) > max_files:
        failures.append(f"file budget: {len(rows)} files > {max_files}")

    print(f"baseline {baseline}: {len(rows)} files, {total} changed lines")
    return failures


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("manifest")
    parser.add_argument("--baseline")
    args = parser.parse_args()

    spec = json.loads(pathlib.Path(args.manifest).read_text())
    baseline = args.baseline or spec["baseline"]
    failures = check(spec, baseline)

    for failure in failures:
        print(f"FAIL {failure}")
    if failures:
        return 1
    print("PASS diff inside declared scope")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

The three-dot range (baseline...HEAD) diffs from the merge base, which is what you want on a branch. A two-dot range would mix in unrelated upstream commits.

Step-by-step workflow

1. Record the baseline before you touch anything.

git rev-parse HEAD
# -> 9f2c1ab...  put this in scope.json
Enter fullscreen mode Exit fullscreen mode

2. Declare the radius, not the change. Write allowed_prefixes as the narrowest directory that could plausibly hold the work. If you cannot name it, the change is not planned yet.

3. Add frozen paths. Point frozen_prefixes at the tests or fixtures that pin current behavior. Everything else is negotiable.

4. Run the gate locally.

python gate.py scope.json --baseline "$(git merge-base origin/main HEAD)"
Enter fullscreen mode Exit fullscreen mode

5. Wire it into CI. One step, no secrets:

- name: Refactor scope gate
  run: |
    BASE=$(git merge-base origin/main HEAD)
    python gate.py scope.json --baseline "$BASE"
Enter fullscreen mode Exit fullscreen mode

6. Enforce it as a required check. A gate that only warns on a wiki page is documentation, not a gate.

Reading the failures

Gate result What it usually means Cheapest correct action
outside scope The edit leaked into a second module Split the commit, or revert that file
frozen path edited The behavior record was bent to fit new code Revert the test edit, re-pin, then decide
line budget The change is not the smallest one Cut to a single behavior per branch
file budget Two refactors were bundled Land them separately, in order
PASS Diff matches the declared radius Send it for human review

Notice that every failure has a design answer, not a tooling answer. That is the point. The gate surfaces decisions while they are still cheap.

Limitations you should write down

  • Prefix matching does not detect semantic spread inside an allowed directory.
  • Renames arrive as a delete plus an add, inflating line counts.
  • Binary files report - in --numstat; this script counts them as zero lines.
  • Generated files and lockfiles inflate budgets. Exclude or freeze them explicitly.
  • The thresholds are policy, not physics. They need tuning per repository.
  • The gate is a tripwire. It proves nothing about correctness.

Who should not use this

Skip it for intentional repo-wide codemods, where thousands of files are the expected outcome. Skip it in repositories without a stable main branch or CI, because the baseline has no meaning there. Skip it if you are the only committer and you already review every diff — the gate adds ceremony with no second reader to protect.

Where a hosted runner fits

If your project has no CI runner at all, the gate is still usable. MonkeyCode offers free model access and a free server option, per the operator, so the script can run there and a free model can draft the scope.json manifest from a proposed diff. Drafting is the right job for a model here: it proposes prefixes and a budget, and a human edits both before the manifest is committed. Treat the manifest as reviewable input, never as the source of truth.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Availability claims above come from the operator. No quotas, hardware, or performance characteristics are asserted here, because none were measured for this article.

What to do next

Commit scope.json and gate.py before your next refactor branch, not during it. Run the gate once locally and once in CI.

The first failure is almost always a file you forgot you touched. That is exactly the information a green test suite withholds.

Top comments (0)