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
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}")
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
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.
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
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")
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
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__orglobals(): 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)