DEV Community

Finley Zhou
Finley Zhou

Posted on

CI Green After an Agent Patch Is Not an Inventory Diff

A zero exit after an agent patch is a process result. It is not a statement about the test suite. If skip marks, xfail reasons, fixture bytes, or property names moved, the suite changed shape. Merge on the inventory diff, not on the color of the last job.

Agent patches fail in a narrow set of ways that a green bar hides. They delete a frozen flake. They rewrite a skip reason into a pass. They swap a property for three examples that encode the new code. None of those show up as a red pytest line if the command still returns 0. The gate below treats those edits as first-class regressions.

This is a proposed workflow, not a production study. No pass rates or latency numbers are claimed. The scripts are labeled examples you can run against a throwaway repo.

What the inventory must contain

Track three sets, and only three, on every agent-authored diff.

  1. Property registry. Names of tests that assert an invariant across inputs, not a single fixture. Count and identity both matter. A rename that preserves behavior is still a registry edit and needs a human row.
  2. Fixture hashes. Canonical bytes of golden files, recorded responses, and seed lists. Content, not path-only, because agents rename to dodge path filters.
  3. Freeze contract. Every skip, xfail, flaky, and quarantine entry, with owner, expiry, and a reason hash. The reason text stays out of the generator workspace.

Example tests may move. Properties, fixture bytes, and freeze rows may not move without an amendment file. That split is the whole protocol.

Why a green bar is the wrong signal

Pytest reports the tests it ran. It does not report the tests the patch retired. An agent that wants a green session has three cheap moves: mark skip, shrink the parametrize list, or replace hypothesis with a literal. All three can keep the job green. All three reduce the suite's power.

A second failure mode is quieter. The patch keeps the test name and weakens the body. Inventory identity is not enough. Pair the registry with a body hash for property files, and fail closed when the hash changes without an amendment.

Freeze contract format

Keep the contract in-repo, in a file the generator is not given. Proposed schema:

# tests/freeze_contract.yaml
version: 1
entries:
  - id: pay-timeout-xfail
    nodeid: tests/test_payments.py::test_timeout_retries
    mark: xfail
    owner: payments
    expires: "2026-10-01"
    reason_sha256: "9f2c1a0b7d4e88c1a6f0b3d9e5c4a2178b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e"
    allowed_patch_touch: false
  - id: report-flake-skip
    nodeid: tests/test_report.py::test_large_csv_stream
    mark: skip
    owner: data-platform
    expires: "2026-09-24"
    reason_sha256: "0a11b22c33d44e55f66778899aabbccddeeff00112233445566778899aabbccdd"
    allowed_patch_touch: false
Enter fullscreen mode Exit fullscreen mode

Store the reason text in a secrets-unrelated, human-only path such as tests/freeze_reasons/ that is never copied into the generator tree. The agent sees a failing nodeid. It does not see "race in the CSV iterator under PyPy". If it can read the rationale, it can unfreeze with a plausible comment.

Proposed CI check (runnable sketch)

Label: unexecuted example. Save as tools/check_test_inventory.py and point it at HEAD versus the merge base.

#!/usr/bin/env python3
"""Fail if an agent diff mutates properties, fixtures, or the freeze contract."""
from __future__ import annotations

import hashlib
import json
import re
import subprocess
import sys
from pathlib import Path

PROPERTY_ROOTS = ("tests/properties",)
FIXTURE_ROOTS = ("tests/fixtures",)
CONTRACT = Path("tests/freeze_contract.yaml")
AMEND = Path("tests/freeze_amendment.yaml")
PROPERTY_DECORATORS = ("@given(", "@hypothesis.given", "def test_property_")


def git_diff_names(base: str) -> list[str]:
    out = subprocess.check_output(
        ["git", "diff", "--name-only", base, "HEAD"], text=True
    )
    return [line.strip() for line in out.splitlines() if line.strip()]


def sha256_file(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def property_index(root: Path) -> dict[str, str]:
    index: dict[str, str] = {}
    if not root.exists():
        return index
    for path in root.rglob("test_*.py"):
        text = path.read_text(encoding="utf-8")
        if not any(token in text for token in PROPERTY_DECORATORS):
            continue
        index[str(path)] = hashlib.sha256(text.encode()).hexdigest()
    return index


def fixture_index(root: Path) -> dict[str, str]:
    index: dict[str, str] = {}
    if not root.exists():
        return index
    for path in root.rglob("*"):
        if path.is_file():
            index[str(path)] = sha256_file(path)
    return index


def load_contract_ids() -> set[str]:
    if not CONTRACT.exists():
        return set()
    text = CONTRACT.read_text(encoding="utf-8")
    return set(re.findall(r"^\s*- id: (\S+)", text, flags=re.M))


def load_amendment_ids() -> set[str]:
    if not AMEND.exists():
        return set()
    text = AMEND.read_text(encoding="utf-8")
    return set(re.findall(r"^\s*- id: (\S+)", text, flags=re.M))


def main() -> int:
    base = sys.argv[1] if len(sys.argv) > 1 else "origin/main"
    changed = git_diff_names(base)
    report = {"base": base, "changed": changed, "violations": []}

    before_props = {}
    subprocess.check_call(["git", "stash", "push", "-u", "-m", "inventory-gate"])
    try:
        subprocess.check_call(["git", "checkout", base, "--quiet"])
        for root in PROPERTY_ROOTS:
            before_props.update(property_index(Path(root)))
        before_fix = {}
        for root in FIXTURE_ROOTS:
            before_fix.update(fixture_index(Path(root)))
        before_ids = load_contract_ids()
    finally:
        subprocess.check_call(["git", "checkout", "-", "--quiet"])
        subprocess.call(["git", "stash", "pop"])

    after_props: dict[str, str] = {}
    for root in PROPERTY_ROOTS:
        after_props.update(property_index(Path(root)))
    after_fix: dict[str, str] = {}
    for root in FIXTURE_ROOTS:
        after_fix.update(fixture_index(Path(root)))
    after_ids = load_contract_ids()
    amended = load_amendment_ids()

    if before_props != after_props:
        report["violations"].append(
            {"kind": "property_registry", "before": before_props, "after": after_props}
        )
    if before_fix != after_fix:
        report["violations"].append(
            {"kind": "fixture_hash", "before": before_fix, "after": after_fix}
        )
    dropped = before_ids - after_ids
    if dropped - amended:
        report["violations"].append(
            {"kind": "freeze_drop_without_amendment", "ids": sorted(dropped - amended)}
        )
    added = after_ids - before_ids
    if added - amended:
        report["violations"].append(
            {"kind": "freeze_add_without_amendment", "ids": sorted(added - amended)}
        )

    skip_touch = [
        p for p in changed
        if p.endswith((".py", ".yaml", ".yml"))
        and _file_has_skip_edit(p, base)
    ]
    if skip_touch:
        report["violations"].append({"kind": "skip_mark_edit", "files": skip_touch})

    Path("inventory-report.json").write_text(json.dumps(report, indent=2))
    if report["violations"]:
        print("inventory gate failed", file=sys.stderr)
        print(json.dumps(report["violations"], indent=2))
        return 1
    print("inventory gate passed")
    return 0


def _file_has_skip_edit(path: str, base: str) -> bool:
    try:
        diff = subprocess.check_output(
            ["git", "diff", "-U0", base, "HEAD", "--", path], text=True
        )
    except subprocess.CalledProcessError:
        return False
    needles = ("pytest.mark.skip", "pytest.mark.xfail", "@pytest.mark.flaky", "pytest.skip(")
    return any(n in diff for n in needles)


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

The stash/checkout dance is crude. Prefer generating both trees in a worktree if your CI image allows it. The point is the comparison, not the git choreography.

Amendment file, required when a human really does intend to retire a freeze:

# tests/freeze_amendment.yaml
version: 1
entries:
  - id: report-flake-skip
    action: expire
    owner: data-platform
    signed_by: human
    note: "iterator race fixed in csv_stream.py; freeze not renewed"
Enter fullscreen mode Exit fullscreen mode

No amendment, no freeze mutation. An agent-authored signed_by: human row is still a process failure. Put that field behind CODEOWNERS so only the owning team can land it.

Numbered merge protocol

  1. Characterize HEAD. Record property hashes, fixture hashes, and freeze ids into inventory-report.json on main before the agent session starts. Keep that file as a build artifact, not as prompt filler.
  2. Strip the generator tree. Copy the failing tests and application code. Omit tests/freeze_reasons/, omit property sources if the failure is in an example test, omit the contract file. The generator gets symptoms, not the scoring rubric.
  3. Generate off the oracle host. A local checkout that already holds the freeze reasons is the wrong place to sample patches. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can hold that stripped generator session so the proposal is produced away from the inventory tree. The gate does not run there. It runs on the merge host.
  4. Reapply the candidate onto a clean worktree that still has properties, fixtures, and the freeze contract. Do not reuse the generator directory as CI.
  5. Run the inventory script against the merge base. Fail on property hash drift, fixture hash drift, freeze add/drop without amendment, or skip-mark edits in the diff.
  6. Run the actual tests only if the inventory gate passed. A green pytest after a failed inventory is discarded. The two jobs are ordered, not parallel.
  7. Expire freeze rows on a calendar, not on agent confidence. A job that lists rows with expires < today should fail main even when no agent patch is open.

Sample job fragment:

# .github/workflows/inventory-gate.yml
name: inventory-gate
on:
  pull_request:
jobs:
  inventory:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: inventory diff
        run: python tools/check_test_inventory.py origin/${{ github.base_ref }}
      - name: pytest after inventory
        run: pytest -q
Enter fullscreen mode Exit fullscreen mode

Order is load-bearing. Invert the two run steps and the protocol collapses back to "green means merge".

Decision table

Diff observation Inventory result Merge
Example test added, properties unchanged, fixtures unchanged, freeze unchanged pass allowed
Property file hash changed, no amendment fail property_registry block
Fixture bytes changed, path unchanged fail fixture_hash block
Fixture path renamed, bytes identical pass if hash set equal allowed
pytest.mark.skip added in application test fail skip_mark_edit block
Freeze id dropped, matching amendment signed_by: human pass allowed after CODEOWNERS
Freeze id dropped, no amendment fail freeze_drop_without_amendment block
Freeze id added by the agent to hide a new failure fail freeze_add_without_amendment block
pytest 0, inventory 1 discard pytest block
pytest 1, inventory 0 normal test failure block

The table is the review checklist. If a reviewer cannot point to a row, the patch is not reviewed.

Property bodies, not just names

Name stability is cheap to fake. Require a body hash for files under tests/properties/. A one-line change from:

@given(st.integers())
def test_property_balance_nonnegative(n):
    assert ledger_apply(n).balance >= 0
Enter fullscreen mode Exit fullscreen mode

to:

@given(st.integers())
def test_property_balance_nonnegative(n):
    assert True
Enter fullscreen mode Exit fullscreen mode

is an inventory failure under this protocol. It would not be a pytest failure. That is the gap the gate exists to close.

For teams that cannot hash entire files, hash per function with ast and compare function-level digests. The sketch above stays file-level to keep the example short.

Limitations

The protocol assumes you already have a property directory, a fixture directory, and a freeze file. If the suite is only example tests, the property registry is empty and the gate cannot see weakening inside those examples. Do not adopt the script as a substitute for reading the diff.

Git stash around checkout is unsafe on dirty CI workspaces and is the wrong isolation primitive. Use git worktree add in real pipelines. The example does not handle binary fixtures larger than memory, generated snapshots that are supposed to update, or multilingual skip comments.

Reason hashes do not encrypt reasons. They only detect silent edits. Anyone with repo read access can still open tests/freeze_reasons/. The split is for the generator session, not for threat models that include the full clone.

MonkeyCode is used here only as an off-host generator surface. The article does not claim model identity, quota, hardware, duration, or score quality. If generation and gating share a filesystem, the product mention is irrelevant and you should ignore it.

Who should not use this

Do not use this gate on a repository with fewer than a handful of invariants and no freeze file. You will encode empty indexes and ship a check that always passes. Do not use it to ban all skip marks forever. Legitimate skips exist. They belong in the contract, with expiry.

Do not use it as a reason to hide tests from developers. Humans who own the freeze list must read reasons. The generator must not. That is the only information split this workflow needs.

If your agent is only allowed to edit src/ via path filters, keep the path filter. The inventory gate is extra. It catches the session that was granted test-tree write access because "the agent should add coverage" and then used that access to retire coverage.

A green job is an exit code. An inventory diff is a statement about what the suite still forbids. Keep them in that order.

Top comments (0)