Agent pull requests fail review when they mix a requested change with unsolicited cleanup. Treat that mix as two or more patches, not one. Map scope before you read a single hunk. Then decide, label by label, what to trust, what to revert, and what still needs tests.
Cheap generation makes extra edits cheap. Extra edits are not free for reviewers. Unsolicited refactors hide contract changes, expand blast radius, and let a green suite cover the wrong behavior.
The failure mode is mixed intent, not "AI style"
Most agent PRs that look too large are not large because the model is verbose. They are large because the agent treated the ticket as a license to tidy adjacent files. A 12-line bugfix arrives with a renamed helper, a reformatted module, and a new utility that nothing in the ticket named.
That mix breaks three review invariants.
- Attribution. You cannot say which lines implement the request.
- Reversibility. Reverting the bugfix also reverts the cleanup, or vice versa.
- Test meaning. A passing suite may exercise the refactor and miss the original defect.
Style nits are not the issue. Coupling is. If a rename and a behavior change share a commit, a test that still passes is not evidence that the behavior change is safe.
Build a scope map before the diff
Do not start in the GitHub files tab. Start with three inputs you already have.
- The ticket or prompt: the requested behavior, in one sentence.
- An allowlist of paths the change is allowed to touch.
- The raw diff, including renames (
git diff --find-renames).
Assign every changed path exactly one label.
| Label | Meaning |
|---|---|
REQUESTED |
Path named in the ticket, or the minimum module that can implement it |
TEST_FOR_REQUEST |
Tests that assert the requested behavior |
INCIDENTAL |
Formatting, imports, comments; no behavior |
UNSOLICITED_REFACTOR |
Renames, extractions, cleanup outside the request |
CONTRACT |
Public APIs, schemas, flags, serialized payloads |
SUPPLY_CHAIN |
Lockfiles, CI, images, permissions |
UNRELATED |
Files with no path or semantic link to the ticket |
The map is the review unit. The diff is evidence for the map. If a file can take two labels, pick the higher-risk one. CONTRACT beats INCIDENTAL. UNSOLICITED_REFACTOR beats REQUESTED when the file was not required for the fix.
A reproducible scope-map script
The following script is a proposed local helper. It does not understand semantics. It encodes the allowlist rule so classification stays repeatable across reviewers.
#!/usr/bin/env python3
"""scope_map.py — classify paths in a git diff against an allowlist.
Usage:
git diff --name-status origin/main...HEAD > /tmp/names.txt
python3 scope_map.py /tmp/names.txt scope-allowlist.txt
"""
from __future__ import annotations
import fnmatch
import sys
from pathlib import Path
CONTRACT_HINTS = ("openapi", "schema", "migration", "proto", "flag", "compat")
SUPPLY_HINTS = (
"package-lock.json", "pnpm-lock.yaml", "yarn.lock", "go.sum",
"Cargo.lock", "poetry.lock", "uv.lock", ".github/workflows",
"Dockerfile", "compose.yml",
)
TEST_HINTS = ("test", "spec", "fixtures")
HIGH_RISK = {"UNRELATED", "UNSOLICITED_REFACTOR", "SUPPLY_CHAIN", "CONTRACT"}
def load_allowlist(path: Path) -> list[str]:
lines = path.read_text().splitlines()
return [ln.strip() for ln in lines if ln.strip() and not ln.startswith("#")]
def matches(path: str, patterns: list[str]) -> bool:
for pat in patterns:
if fnmatch.fnmatch(path, pat):
return True
root = pat.rstrip("/")
if path == root or path.startswith(root + "/"):
return True
return False
def classify(status: str, path: str, allow: list[str]) -> str:
base = path.lower()
if any(h in base for h in SUPPLY_HINTS):
return "SUPPLY_CHAIN"
if any(h in base for h in CONTRACT_HINTS):
return "CONTRACT"
if status.startswith("R") and not matches(path, allow):
return "UNSOLICITED_REFACTOR"
looks_like_test = any(h in base for h in TEST_HINTS)
if looks_like_test:
return "TEST_FOR_REQUEST" if matches(path, allow) else "UNRELATED"
if matches(path, allow):
return "REQUESTED"
return "UNRELATED"
def main() -> int:
names, allow_path = Path(sys.argv[1]), Path(sys.argv[2])
allow = load_allowlist(allow_path)
print("| status | path | label |")
print("| --- | --- | --- |")
flagged = 0
for raw in names.read_text().splitlines():
if not raw.strip():
continue
parts = raw.split("\t")
status, path = parts[0], parts[-1]
label = classify(status, path, allow)
print(f"| {status} | `{path}` | `{label}` |")
if label in HIGH_RISK:
flagged += 1
print(f"\nflagged_paths={flagged}")
return 1 if flagged else 0
if __name__ == "__main__":
raise SystemExit(main())
Check an allowlist in next to the ticket. Keep it boring.
# scope-allowlist.txt — only paths the ticket named
src/billing/invoice.py
src/billing/invoice_test.py
Commands that feed the script:
git fetch origin main
git diff --find-renames --name-status origin/main...HEAD > /tmp/names.txt
python3 scope_map.py /tmp/names.txt scope-allowlist.txt
echo "exit=$?"
# Line volume, including whitespace-only noise
git diff --find-renames --numstat origin/main...HEAD
git diff -w --stat origin/main...HEAD
If flagged_paths is non-zero, stop reading prose in the PR body. Split first. Review second.
Trust, revert, test — by label
Apply the same three actions to every label. Do not apply them to the PR as a whole.
Trust
Trust only what the map marked REQUESTED or TEST_FOR_REQUEST, and only after the tests pin the ticket. Trust is narrow.
- The production path named in the ticket changes in the direction the ticket described.
- New tests fail on
origin/mainand pass on the branch (the inversion check). -
INCIDENTALedits thatgit diff -wcan erase.
Run the inversion check before you argue about style. The sequence below is a proposed workflow. Label results as unexecuted until you run them on the branch.
# 1) Does the new test fail on main?
git switch --detach origin/main
git checkout BRANCH -- src/billing/invoice_test.py
pytest src/billing/invoice_test.py -q
# expected: fail (otherwise the test does not encode the ticket)
# 2) Restore the branch and expect pass
git switch BRANCH
pytest src/billing/invoice_test.py -q
If the new tests pass on main, they are not tests for this ticket. Reclassify them. Drop them or rewrite them until inversion holds.
Revert
Revert anything that is not required to make the requested tests pass. Order matters.
-
UNRELATEDfiles — restore fromorigin/main. -
UNSOLICITED_REFACTOR— especially renames. Renames destroy review context andgit blame. -
SUPPLY_CHAINunless the ticket is a dependency change. -
CONTRACTunless the ticket is a contract change. Accidental schema edits are merge blockers.
# Restore out-of-scope paths from the merge base
git checkout origin/main -- src/helpers/utils.py src/legacy/format.py
git commit -m "revert: drop unsolicited agent edits outside billing/invoice"
If the agent extracted a helper that the requested file now imports, you have a choice. Keep the extraction in a follow-up PR, or inline it back. Do not keep it because the tests still pass. Tests written by the same agent often assert the helper's existence, not the original defect.
Test
After the split, you still owe tests the agent usually skips.
- One failing-on-main test for the ticket itself.
- One test for each
CONTRACTpath you decided to keep. - Negative cases: empty input, timeout, duplicate id, unauthorized caller.
- A characterization test if you keep any refactor: snapshot the public return value before and after.
Proposed test skeleton (unexecuted example):
def test_invoice_void_rejects_already_voided():
invoice = Invoice(status="void")
with pytest.raises(InvalidTransition):
invoice.void(actor="system")
def test_invoice_void_does_not_change_unrelated_totals():
invoice = Invoice(status="open", total=1050)
invoice.void(actor="ops")
assert invoice.total == 1050
The second test exists because unsolicited refactors often "simplify" adjacent fields. Happy-path tests will not catch that.
Hypothetical walkthrough
Labeled example, not a production incident. Ticket: "voiding an invoice must be idempotent."
Agent diff touches four files:
| path | agent story | map label | action |
|---|---|---|---|
src/billing/invoice.py |
add idempotent void()
|
REQUESTED |
trust after inversion check |
src/billing/invoice_test.py |
one happy-path test | TEST_FOR_REQUEST |
keep, then add a negative case |
src/billing/totals.py |
extract round_cents()
|
UNSOLICITED_REFACTOR |
revert from this PR |
src/api/openapi.yaml |
"document void" | CONTRACT |
revert unless product asked |
The correct merge is two files, not four. The extraction can be a separate PR with characterization tests. The OpenAPI edit can be a separate PR with a contract test. Mixing them makes every later revert expensive.
A second classification pass on a throwaway server
Local allowlists catch path errors. They miss semantic extras: a function body that starts logging PII, a retry loop that changes timing, a default argument that flips compatibility.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
A useful extra step is to run the same map, plus a short classify-this-hunk prompt, on a machine that is not your laptop. MonkeyCode's free model access and free server option fit that step: put the allowlist, the ticket sentence, and the diff on a disposable server, and ask only for labels plus a split plan. Do not ask it to approve the merge. Treat the output as a second map to diff against your own.
Keep the prompt small and mechanical.
Ticket: voiding an invoice must be idempotent.
Allowlist: src/billing/invoice.py, src/billing/invoice_test.py
For each hunk, return: path, label
(REQUESTED|TEST_FOR_REQUEST|INCIDENTAL|UNSOLICITED_REFACTOR|CONTRACT|SUPPLY_CHAIN|UNRELATED),
one-line reason.
Do not suggest extra features. Do not rewrite the patch.
Compare the model labels to scope_map.py. Human review owns disagreements. The server is disposable so the diff does not sit in a long-lived workspace.
Decision table
| Map result | Trust? | Revert? | Test still owed? |
|---|---|---|---|
Only REQUESTED + failing-on-main tests |
Production paths, after inversion | Nothing | Negative cases |
INCIDENTAL only (git diff -w empty) |
Formatting | Optional | None |
Any UNRELATED
|
None of those files | Restore from main | Re-run the suite after restore |
UNSOLICITED_REFACTOR mixed in |
Requested files only | Split to a follow-up | Characterization if you keep it |
CONTRACT without a ticket |
Nothing | Block merge | Contract test if product later accepts it |
SUPPLY_CHAIN without a ticket |
Nothing | Block merge | Install/CI dry-run after revert |
New tests pass on main
|
None of those tests | Drop or rewrite | Real inversion tests |
Limitations
Path allowlists cannot see semantic coupling. A one-line change in an allowed file can still alter a global singleton. The script will label that file REQUESTED and be wrong in spirit.
Rename detection depends on git diff --find-renames. Below the similarity threshold, a rename looks like a delete plus an add. You will over-flag or under-flag.
Generated code, vendored files, and snapshot tests will trip CONTRACT or TEST_FOR_REQUEST without being either. Maintain an ignore list.
This protocol does not replace security review, license review, or load testing. It only stops mixed-intent PRs from being scored as a single object.
Who should not use this
Skip the map if the PR is already one file and under roughly fifty lines. The overhead exceeds the risk.
Do not use label-based trust on authentication, crypto, payments settlement, or anything that can exfiltrate data. Those diffs need line-by-line human reading, even when every path is in the allowlist.
Do not use a second model pass as a merge oracle. Models that wrote the patch are correlated with models that classify the patch. Disagreement is a signal. Agreement is not proof.
If your process forbids splitting PRs (release trains, single-commit legal sign-off), run the map as a comment checklist. Still revert out-of-scope files before approval. Do not accept the cleanup to save a cycle.
The core conclusion does not change with the tooling. Mixed-intent agent PRs should be split. Trust the requested behavior only after tests fail on main. Revert the rest, or promote it to its own review.
Top comments (0)