DEV Community

Finley Zhou
Finley Zhou

Posted on

Unclassified Tests Cannot Green an Agent Patch

An agent patch is not green because a runner printed green. It is green only when every passing test it cites already has a class in a ledger: locked fixture, property check, or quarantine. Unclassified tests are not evidence. They are unlabeled noise the model can reshape until the suite agrees.

That rule is the whole merge policy. The rest of this article is a concrete ledger, a gate script, and a decision table you can run before git merge.

Agent patches fail this policy in a narrow way. They do not need to delete assertions. They only need to move a test from "unknown" to "passed" by rewriting a golden file, catching a broader exception, or skipping a flake that used to fail the shard. If the suite has no class field, CI cannot tell a real invariant from a rewritten snapshot.

What the ledger is for

The ledger is not a test runner. It is an evidence filter. Production diffs may land only when the JUnit report's passing cases are already classified, and when quarantine rows stay out of the pass column.

Three classes are enough. More classes create review theater. Fewer classes let fixture rewrites masquerade as properties.

  1. locked_fixture — byte-stable golden input or output. The digest is the contract.
  2. property — an invariant plus a seed corpus path. The seed may grow. The invariant name may not silently change.
  3. quarantine — known flake or order-dependent case. It cannot be cited as a pass. It cannot be deleted by the agent to "clean CI."

A fourth row type exists only as a staging area: draft. Agent-authored tests start there. Humans promote them. Drafts never green a production diff.

Ledger schema

Keep the file next to the suite. YAML is enough. One row per pytest nodeid.

# tests/ledger.yaml
version: 1
rows:
  - id: tests/test_parse.py::test_header_roundtrip
    class: locked_fixture
    digest: sha256:9f3c1a0b7d2e44aa88c1f0c0a1b2c3d4e5f60718293a4b5c6d7e8f9012345678
    artifact: tests/goldens/header.bin
  - id: tests/test_parse.py::test_length_never_negative
    class: property
    invariant: non_negative_length
    seeds: tests/properties/seeds/length/
  - id: tests/test_parse.py::test_concurrent_reload
    class: quarantine
    opened: 2026-09-10
    reason: order-dependent on module import cache
    owner: human
  - id: tests/test_parse.py::test_agent_added_prefix_strip
    class: draft
    source: agent
    proposed_class: property
Enter fullscreen mode Exit fullscreen mode

The digest is a sha256 of the fixture bytes, not of the test file. Hashing the test file lets the agent rewrite both the assertion and the golden in one commit and keep the hash "consistent." Hash the artifact the test reads.

Gate script

Label the following as a proposed local gate. It does not claim production metrics. It fails closed when the report and the ledger disagree.

#!/usr/bin/env python3
"""ledger_gate.py — refuse pass evidence from unclassified or quarantined tests."""
from __future__ import annotations

import hashlib
import json
import sys
import xml.etree.ElementTree as ET
from pathlib import Path

import yaml

ALLOWED_PASS = {"locked_fixture", "property"}
BLOCK_PASS = {"quarantine", "draft"}


def load_ledger(path: Path) -> dict[str, dict]:
    data = yaml.safe_load(path.read_text())
    rows = {}
    for row in data["rows"]:
        if row["id"] in rows:
            raise SystemExit(f"duplicate ledger id: {row['id']}")
        rows[row["id"]] = row
    return rows


def parse_junit(path: Path) -> list[tuple[str, str]]:
    root = ET.parse(path).getroot()
    cases = []
    for case in root.iter("testcase"):
        nodeid = f"{case.attrib.get('file', case.attrib.get('classname'))}::{case.attrib['name']}"
        if case.find("failure") is not None or case.find("error") is not None:
            status = "fail"
        elif case.find("skipped") is not None:
            status = "skip"
        else:
            status = "pass"
        cases.append((nodeid, status))
    return cases


def fixture_digest(path: Path) -> str:
    h = hashlib.sha256(path.read_bytes()).hexdigest()
    return f"sha256:{h}"


def main(argv: list[str]) -> int:
    ledger = load_ledger(Path(argv[1]))
    report = parse_junit(Path(argv[2]))
    errors: list[str] = []

    for nodeid, status in report:
        row = ledger.get(nodeid)
        if row is None:
            errors.append(f"UNCLASSIFIED {status}: {nodeid}")
            continue
        klass = row["class"]
        if status == "pass" and klass in BLOCK_PASS:
            errors.append(f"{klass.upper()} cited as pass: {nodeid}")
        if status == "pass" and klass not in ALLOWED_PASS and klass not in BLOCK_PASS:
            errors.append(f"unknown class {klass}: {nodeid}")
        if klass == "locked_fixture":
            art = Path(row["artifact"])
            if not art.is_file():
                errors.append(f"missing fixture: {art}")
            elif fixture_digest(art) != row["digest"]:
                errors.append(f"digest mismatch: {nodeid}")
        if klass == "property":
            seeds = Path(row["seeds"])
            if not seeds.is_dir() or not any(seeds.iterdir()):
                errors.append(f"empty property seeds: {nodeid}")
        if klass == "quarantine" and status == "fail":
            # Failures in quarantine are expected. Do not promote them.
            pass

    if errors:
        print("ledger gate failed:")
        for line in errors:
            print(f"  - {line}")
        return 1
    print(f"ledger gate ok: {len(report)} cases, {len(ledger)} classified")
    return 0


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

Run it after pytest, not instead of pytest. The suite still executes. The ledger only decides whether a pass is admissible.

python -m pytest tests --junitxml=build/junit.xml
python ledger_gate.py tests/ledger.yaml build/junit.xml
Enter fullscreen mode Exit fullscreen mode

If you need nodeids that match pytest exactly, collect them first and store those strings in the ledger. Guessing from JUnit classname is a common source of false UNCLASSIFIED rows.

python -m pytest --collect-only -q tests > build/collected.txt
Enter fullscreen mode Exit fullscreen mode

Numbered workflow

  1. Freeze the current suite into the ledger. Every existing nodeid gets a class. Anything you cannot defend as a property or a locked fixture goes to quarantine, not draft.
  2. Compute fixture digests with a single command and paste them into the YAML. Do not let the agent write the digest field on a file it also edited.
python - <<'PY'
from pathlib import Path
import hashlib
for p in Path("tests/goldens").glob("*"):
    print(p, "sha256:" + hashlib.sha256(p.read_bytes()).hexdigest())
PY
Enter fullscreen mode Exit fullscreen mode
  1. Give the coding agent a write path only under tests/agent_drafts/ plus permission to append class: draft rows. Deny edits to tests/ledger.yaml classes other than appending drafts.
  2. Re-run pytest and the gate. Production files plus a draft-only report must fail the gate. That is the point.
  3. A human promotes a draft. Promotion is a class change, not a comment. Property promotion requires an invariant name and a non-empty seed directory. Fixture promotion requires a digest of bytes the human inspected.
  4. Quarantine rows get an opened date and an owner. The agent may not remove them, rename them, or mark them skipped to recover a green shard.

Where a free coding agent belongs

Draft generation is the only step that benefits from a disposable model endpoint. The ledger, the digests, and the quarantine list stay human-owned files in git.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can host that draft step: propose new property tests, suggest a proposed_class, and emit YAML rows. They do not get to flip quarantine to property, and they do not get to refresh a fixture digest. If the free server is unavailable, the gate still runs. The workflow degrades to human-authored drafts. It does not degrade to "trust the pass column."

Keep the prompt boring and checkable.

Append draft rows only. Do not edit class, digest, invariant, or seeds
on existing rows. Each draft must include a pytest nodeid, a proposed_class
of property or locked_fixture, and a one-line invariant or artifact path.
Do not add skips. Do not touch tests/ledger.yaml rows with class quarantine.
Enter fullscreen mode Exit fullscreen mode

Decision table

Signal in the patch or report Ledger class Gate result Human action
Pass on existing unit test, digest unchanged locked_fixture admit none
Pass, golden bytes changed, digest not updated locked_fixture reject inspect bytes or revert
Pass, golden bytes changed, digest updated in same PR locked_fixture admit only after review treat as a contract change
Pass on invariant with seeds present property admit optional seed growth review
Pass on invariant, seed directory empty property reject restore seeds
Pass on a quarantined nodeid quarantine reject keep failing or unfreeze manually
Skip or delete of a quarantined nodeid by the agent quarantine reject restore the row
Pass on agent-added test draft reject promote or drop
Pass on a nodeid missing from YAML none reject classify before merge

The table is the review script. If a reviewer cannot point at a row, the evidence does not exist.

Failure analysis the gate is meant to catch

Unclassified suites leak three merge bugs. First, snapshot tests become writable oracles: the agent updates header.bin and the assertion still compares equal. Second, flakes become negative space: the agent deletes test_concurrent_reload and the shard goes green. Third, tautological drafts enter as if they were properties: assert parse(x) is not None with no seed and no invariant name.

The ledger does not prove the production patch is correct. It only stops those three evidence tricks. Pair it with a hold-out suite or a mutation pass if you need a stronger claim. This article does not reuse those gates. It only filters which passing tests may be mentioned in the merge note.

A cheap extra check belongs in CI around fixture paths:

# Fail if production diff and golden diff land together without a ledger digest bump.
git diff --name-only origin/main...HEAD > build/changed.txt
python - <<'PY'
from pathlib import Path
changed = Path("build/changed.txt").read_text().splitlines()
prod = [p for p in changed if p.startswith("src/")]
gold = [p for p in changed if p.startswith("tests/goldens/")]
ledger = Path("tests/ledger.yaml").read_text()
if prod and gold and "digest:" not in Path("tests/ledger.yaml").read_text():
    raise SystemExit("production+golden diff without ledger context")
print("changed production", len(prod), "goldens", len(gold))
PY
Enter fullscreen mode Exit fullscreen mode

Tighten that stub against your real digest-diff. The idea is mechanical: two-sided edits need a classified contract change, not a silent golden refresh.

Limitations

This workflow assumes deterministic nodeids, file-backed fixtures, and a human who will refuse promotion. It will not classify Playwright timing flakes into a digest. It will not replace contract tests that need a real network. It will not stop an agent that is allowed to edit ledger.yaml class fields.

Do not use it as the only control on medical, avionics, or payments code. Do not use it when the suite is 90% end-to-end UI. Do not use it to justify skipping quarantine forever; an opened date without an owner is just a parked failure. Do not publish the ledger as proof of coverage. Coverage is a different number, and unclassified high coverage is how weak patches get a chart.

If your team cannot block agent writes to golden files and to the ledger class column, stop at step 1. A YAML file the model can rewrite is not a gate.

What to merge instead of a slogan

Ship the YAML, the gate, and the collect-only list. Require the gate on production diffs. Leave draft generation optional. If you already run a free coding agent on a throwaway server, point it at draft rows rather than at the pass column. The merge token is the classified report, not the model's summary of why the suite looks green.

Top comments (0)