A patch that edits a test file is normal. A patch that changes the manifest of your test assets deserves a review. That one distinction — file content versus asset signature — is the cheapest gate I have found for agent-generated patches, and it covers three leaks at once: deleted tests, skipped assertions, and edited fixtures.
This is a testing strategy, not a linter rule. The manifest is a small JSON file. The gate is a diff over that file. Nothing about it depends on trusting the model that produced the patch.
Why a manifest instead of reading the diff
Line-by-line review does not scale to agent output. One agent run can touch thirty test files while you read two of them carefully.
A manifest compresses each test asset into a handful of stable signatures: the file hash, the test function names it defines, the pytest markers attached, the count of assert statements, and the hash of every fixture file or conftest.py. Comparing base manifest to head manifest yields a short list of verdicts, and each verdict maps to a fixed evidence requirement. The reviewer stops asking "does this diff look fine?" and starts asking "why did the fixture hash move?"
Step 1 — Build the manifest
The scanner below has no dependencies outside the standard library. Drop it in tools/.
# tools/test_manifest.py
from __future__ import annotations
import hashlib
import json
import re
import sys
from pathlib import Path
TEST_GLOBS = ('tests/**/*.py', 'test/**/*.py', '**/test_*.py')
FIXTURE_GLOBS = ('tests/fixtures/**/*', '**/conftest.py')
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
def scan(root: Path) -> dict:
tests, fixtures = {}, {}
for pattern in TEST_GLOBS:
for p in root.glob(pattern):
if not p.is_file():
continue
text = p.read_text(encoding='utf-8', errors='replace')
tests[p.relative_to(root).as_posix()] = {
'sha': digest(p),
'defs': sorted(re.findall(r'^\s*def (test_\w+)', text, re.M)),
'markers': sorted(set(re.findall(r'pytest\.mark\.(\w+)', text))),
'asserts': len(re.findall(r'^\s*assert\b', text, re.M)),
}
for pattern in FIXTURE_GLOBS:
for p in root.glob(pattern):
if p.is_file():
fixtures[p.relative_to(root).as_posix()] = digest(p)
return {'tests': tests, 'fixtures': fixtures}
if __name__ == '__main__':
Path(sys.argv[2]).write_text(
json.dumps(scan(Path(sys.argv[1])), indent=2, sort_keys=True)
)
Hash truncation to 16 hex characters is deliberate. It keeps the manifest readable in review and collisions are not a realistic threat model here; the manifest is a change detector, not a security control.
Run it against two trees, not two commits. A worktree for the base revision keeps the agent's uncommitted edits untouched:
git worktree add /tmp/base "$(git merge-base HEAD origin/main)"
python tools/test_manifest.py /tmp/base /tmp/base.json
python tools/test_manifest.py . /tmp/head.json
Step 2 — Classify the diff into verdicts
Five verdicts are enough to cover the failures I care about. Resist adding more until one of these produces a false positive you actually hit.
# tools/manifest_gate.py
import json
import sys
SKIP_MARKERS = {'skip', 'skipif', 'xfail'}
BLOCKING = {'T-DEL', 'T-GONE', 'T-SKIP', 'F-DRIFT'}
def classify(base: dict, head: dict) -> list:
out = []
for path, now in head['tests'].items():
before = base['tests'].get(path)
if before is None:
out.append(('T-ADD', path, 'new test file'))
continue
lost = set(before['defs']) - set(now['defs'])
if lost:
out.append(('T-DEL', path, 'removed: ' + ', '.join(sorted(lost))))
added_skips = (set(now['markers']) - set(before['markers'])) & SKIP_MARKERS
if added_skips:
out.append(('T-SKIP', path, 'markers: ' + ', '.join(sorted(added_skips))))
if now['sha'] != before['sha'] and not lost and now['asserts'] < before['asserts']:
out.append(('T-WEAK', path, f"asserts {before['asserts']} -> {now['asserts']}"))
for path in set(base['tests']) - set(head['tests']):
out.append(('T-GONE', path, 'test file removed'))
for path, before_hash in base['fixtures'].items():
if head['fixtures'].get(path) != before_hash:
out.append(('F-DRIFT', path, 'fixture changed or removed'))
return out
if __name__ == '__main__':
base = json.loads(open(sys.argv[1]).read())
head = json.loads(open(sys.argv[2]).read())
findings = classify(base, head)
for verdict, path, note in findings:
print(f'{verdict}\t{path}\t{note}')
sys.exit(1 if any(v in BLOCKING for v, _, _ in findings) else 0)
Example output on a toy repo with one agent patch, shown as illustration rather than a benchmark:
T-SKIP tests/test_reports.py markers: xfail
F-DRIFT tests/fixtures/reports/march.csv fixture changed or removed
That pair is the classic pattern. The agent could not make the assertion pass, so it marked the test xfail and edited the fixture until the remaining assertions agreed with it. Both moves are invisible in a green CI run.
Step 3 — Map each verdict to required evidence
The gate's job is not to reject. Its job is to demand a specific artifact for a specific class of change.
| Verdict | Usual cause | Evidence to attach | Gate action |
|---|---|---|---|
T-ADD |
agent wrote new tests | none | allow |
T-DEL / T-GONE
|
test dropped with the code it covered | replacement test id or written rationale | block |
T-SKIP |
skip/skipif/xfail added |
freeze record with an expiry date | block until ledger entry |
T-WEAK |
assertions removed, file still runs | reviewer note naming the property still covered | warn, queue review |
F-DRIFT |
fixture data edited | fixture diff plus reason | block |
T-WEAK is the weakest signal in the set, and it should stay a warning. Counting assert statements is a proxy; a patch can legitimately replace three assertions with one property check that covers more ground. Treat the count as a prompt to look, never as proof of weakening.
Step 4 — Make freezes expire and prove themselves
A flaky-test freeze with no expiry is a permanent mute button. Store freezes as records, and let the gate read the ledger on every run.
{
"tests/test_reports.py::test_prune_keeps_latest": {
"first_seen": "2026-10-14",
"expires": "2026-11-13",
"owner": "reports",
"repro": "for i in $(seq 1 50); do pytest tests/test_reports.py::test_prune_keeps_latest -q || break; done",
"last_reproduced": "2026-10-20"
}
}
Three rules keep the ledger honest. A freeze whose expires date is in the past fails the gate outright. A renewal is only accepted when last_reproduced falls within the last fourteen days, which forces someone to actually observe the failure again. And a freeze added inside the same patch that modifies the module under test is rejected, because that is quarantine-as-a-side-effect rather than quarantine-as-a-decision.
The repro field matters more than it looks. It turns "this test is flaky" into a command another engineer can run in ninety seconds, and it is the first thing anyone reads when the expiry arrives.
Step 5 — Run the gate where the patches are produced
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The manifest gate only pays off if it runs on every candidate patch, including the ones an agent generates in a loop. Two operator-supplied options make that loop easier to host: MonkeyCode offers free model access for generating candidate patches, and a free server option for running the surrounding tooling off a laptop.
Keep the trust boundary explicit when you wire them together. The model writes a patch. The gate computes the manifest from the resulting tree, not from the model's description of what it changed. A model that claims "only added tests" and a manifest that reports F-DRIFT are in disagreement, and the manifest wins by construction, because it never reads prose.
A minimal loop, with the free model access doing patch generation and the free server option hosting the worktree and gate:
- Check out the base revision into a fresh worktree.
- Request a candidate patch from the model.
- Apply it, then run both manifest scans and
manifest_gate.py. - On exit code 1, send the verdict list back as the next prompt's constraint.
- On exit code 0, run the actual test suite once and store both manifests as build artifacts.
Step 4 is where the gate earns its place in a loop. The verdict list is short, structured, and machine-readable, which makes it far better feedback than a truncated pytest log with forty lines of traceback.
Limitations and who should not use this
A manifest detects change, not correctness. It cannot tell a narrowed assertion from a rewritten one, so T-WEAK will stay noisy on suites that legitimately refactor tests often.
Dynamic tests defeat it. Anything generated at collection time — parametrization over data files, test factories, pytest_generate_tests hooks — will show up only as a changed file hash, and you will be classifying by eye again.
Shared fixtures produce F-DRIFT storms. If one conftest.py serves two hundred tests and changes weekly, hash the whole file and you will block every patch. Narrow the fixture globs before you widen the allowlist, not after.
Skip this approach if you have no test ownership, no CI, or a suite small enough that a human reads every diff anyway. The manifest is a scaling device. Below a certain patch volume it is just another file to maintain.
Start with the fixture hash. In most agent incidents I have seen described, the fixture moved before the assertion did, and that column costs one hashlib call to add.
Top comments (0)