DEV Community

Dakota Huang
Dakota Huang

Posted on

Lock the Public Surface Before the Refactor: A Manifest Gate for AI-Proposed Diffs

Tests can pass while the public surface quietly changes. A rename, a new required argument, a fresh exception type — green build, broken callers. The fix is a gate, not more tests.

This is the second half of a messy-repo refactor. First you pin behavior with characterization tests. Then you allow exactly one small change. The gate below makes "small" and "safe" measurable instead of aspirational.

Why tests alone miss the smallest safe change

Characterization tests freeze what the code does for the inputs you recorded. They say nothing about what the module exposes to callers you never imported. A diff can be behavior-identical and API-hostile at the same time.

Two checks close that gap. A public surface manifest, dumped before and after. A diff budget, enforced with the same script. Both are cheap and both fail loudly.

Step 1 — Cut a baseline worktree

Never refactor on a dirty tree. Create a throwaway worktree at the commit you trust.

git worktree add ../repo-base HEAD
cd ../repo-base
python -m pytest -q tests/characterization | tail -3
python -m pytest --collect-only -q tests/characterization | tail -1
Enter fullscreen mode Exit fullscreen mode

You now have an untouched tree, a pass count, and a collected-test count. Save the collected count. A refactor that "fixes" tests by deleting them will drop that number, and you want to see it drop.

Step 2 — Dump the public surface manifest

This script records exported functions, their argument names, required-argument counts, decorators, and raised exception types. Classes get bases and public methods. Everything private is ignored by convention.

Requires Python 3.9+ for ast.unparse. Save as tools/surface.py.

import ast
import json
import sys
from pathlib import Path


def surface_of(path: Path) -> dict:
    tree = ast.parse(path.read_text(encoding="utf-8"))
    public = {}
    for node in tree.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            if node.name.startswith("_"):
                continue
            args = node.args
            public[node.name] = {
                "kind": "function",
                "args": [a.arg for a in args.args],
                "kwonly": [a.arg for a, d in zip(args.kwonlyargs, args.kw_defaults) if d is None],
                "required": len(args.args) - len(args.defaults),
                "decorators": [ast.unparse(d) for d in node.decorator_list],
                "raises": sorted(
                    {ast.unparse(h.type) if h.type else "bare"
                     for h in ast.walk(node) if isinstance(h, ast.Raise)}
                ),
            }
        elif isinstance(node, ast.ClassDef) and not node.name.startswith("_"):
            public[node.name] = {
                "kind": "class",
                "bases": [ast.unparse(b) for b in node.bases],
                "methods": sorted(
                    n.name for n in node.body
                    if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
                    and not n.name.startswith("_")
                ),
            }
    return {"module": path.stem, "public": public}


def manifest(root: Path) -> dict:
    return {
        str(p.relative_to(root)): surface_of(p)
        for p in sorted(root.rglob("*.py"))
        if "__pycache__" not in p.parts and not p.name.startswith("surface")
    }


if __name__ == "__main__":
    root = Path(sys.argv[1])
    out = Path(sys.argv[2])
    out.write_text(json.dumps(manifest(root), indent=2, sort_keys=True), encoding="utf-8")
    print(f"wrote {out}")
Enter fullscreen mode Exit fullscreen mode

Run it in the baseline tree. Commit the output or stash it somewhere the candidate tree can read.

cd ../repo-base
python tools/surface.py src surface.base.json
Enter fullscreen mode Exit fullscreen mode

Step 3 — Pin behavior with three kinds of test

Keep this boring and fast. Characterization tests are a recording, not a design document.

  • Golden output for the main entry point, byte-for-byte.
  • The exception types and messages the code raises today.
  • One test per side effect you can observe without a network call.

Run them against the baseline until they pass. Then stop. If a test needs a rewrite to pass, the test is wrong, not the code.

Step 4 — Ask for the smallest patch

Now get a candidate diff. Free model access works fine here because the model proposes and the gate disposes. You are not asking for correctness. You are asking for one narrow change you will judge mechanically.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. The free model access and free server option referenced here are operator-supplied availability claims; verify current limits in the product itself before relying on them.

A prompt shape that produces reviewable diffs:

Goal: extract the date parsing in report.py into parse_date().
Constraints:
- Do not change any public name, signature, or raised exception.
- Touch at most 2 files.
- Keep the diff under 60 changed lines.
- Return only a unified diff. No prose.
Enter fullscreen mode Exit fullscreen mode

Apply the patch to a second worktree, not to your working copy.

git worktree add ../repo-cand HEAD
cd ../repo-cand
git apply --check candidate.diff && git apply candidate.diff
Enter fullscreen mode Exit fullscreen mode

If you are running the model on the free server option, keep the repo slice small and exclude secrets. A free tier is a convenience, not a trust boundary.

Step 5 — Run the gate

The gate compares two manifests and the diff size. Save as tools/gate.py.

import json
import subprocess
import sys
from pathlib import Path

BUDGET_LINES = 60
BUDGET_FILES = 2


def load(p: str) -> dict:
    return json.loads(Path(p).read_text(encoding="utf-8"))


def surface_reasons(base: dict, cand: dict) -> list:
    reasons = []
    for mod, info in base.items():
        if mod not in cand:
            reasons.append(f"module removed: {mod}")
            continue
        for name, sig in info["public"].items():
            new = cand[mod]["public"].get(name)
            if new is None:
                reasons.append(f"public symbol removed: {mod}::{name}")
                continue
            if sig["kind"] != new["kind"]:
                reasons.append(f"kind changed: {mod}::{name}")
                continue
            if sig["kind"] == "function":
                if new["args"][: len(sig["args"])] != sig["args"]:
                    reasons.append(f"arg order changed: {mod}::{name}")
                if new["required"] > sig["required"]:
                    reasons.append(f"new required parameter: {mod}::{name}")
                added = set(new["raises"]) - set(sig["raises"])
                if added:
                    reasons.append(f"new exception type: {mod}::{name} {sorted(added)}")
            else:
                lost = set(sig["methods"]) - set(new["methods"])
                if lost:
                    reasons.append(f"method removed: {mod}::{name} {sorted(lost)}")
    return reasons


def diff_budget() -> list:
    out = subprocess.run(
        ["git", "diff", "--numstat", "HEAD", "--", "."],
        capture_output=True, text=True, check=True,
    ).stdout
    lines = files = 0
    for row in out.splitlines():
        add, rem, _ = row.split("\t")
        if not add.isdigit() or not rem.isdigit():
            return ["binary file in diff"]
        lines += int(add) + int(rem)
        files += 1
    reasons = []
    if lines > BUDGET_LINES:
        reasons.append(f"diff too large: {lines} > {BUDGET_LINES} lines")
    if files > BUDGET_FILES:
        reasons.append(f"too many files: {files} > {BUDGET_FILES}")
    return reasons


if __name__ == "__main__":
    reasons = surface_reasons(load("surface.base.json"), load("surface.candidate.json"))
    reasons += diff_budget()
    if reasons:
        print("REJECT")
        for r in reasons:
            print(f"  - {r}")
        sys.exit(1)
    print("ACCEPT")
Enter fullscreen mode Exit fullscreen mode

Run it in the candidate worktree, after generating the candidate manifest and the tests.

cd ../repo-cand
python tools/surface.py src surface.candidate.json
python -m pytest -q tests/characterization | tail -1
python tools/gate.py
Enter fullscreen mode Exit fullscreen mode

Step 6 — Read the rejection reasons as data

Three rejections are common and each means something specific.

Reason Usual cause Response
new required parameter Model added a config flag Ask for a default value
new exception type Error handling drifted Pin the old exception in a test
diff too large Task was too broad Split into two refactors

The write-only part is important. Append each rejection to a JSONL log with the prompt hash. After ten attempts you can see which prompt shapes produce oversized diffs, and fix the prompt rather than the model.

When this gate is the wrong tool

Some repos should skip it.

  • Dynamic re-exports through __getattr__ or globals(): the manifest will be empty or wrong.
  • Deliberate public API changes: the gate cannot tell a planned break from an accidental one. Bump the version and update the baseline instead.
  • Generated code: regenerate first, then take the baseline. Otherwise every run rejects.
  • C extensions or code that only exists after import hooks: static parsing misses it.
  • Teams without a golden-output corpus: write those tests first. The gate protects a surface, not a behavior.

Also skip this if the change is really a feature. A feature needs new behavior, and a behavior lock will fight you the whole way.

What you get

One refactor per branch. A baseline manifest you can diff by eye. A hard cap on diff size. A rejection log that improves your prompts over time.

That is the whole loop: record behavior, lock the surface, propose one change, gate it. The model writes the patch. Your harness decides whether the patch ever touches main.

If you want to run steps 4 through 6 without a paid seat, the free model access and free server option are enough to try the loop end to end.

Top comments (0)