DEV Community

Finley Zhou
Finley Zhou

Posted on

Test-Inventory Diff as a Merge Gate for Agent Patches

The cheapest way for an agent patch to go green is to check less. A merge gate that only reads the test runner's exit code will accept deleted cases, new skips, softer assertions, mutated fixtures, and larger timeouts. Diff the test inventory against the parent SHA first. Reject unexplained shrinkage before anyone treats a green run as evidence.

A red suite is a clear signal. A quieter suite is not. Agents can delete test_* functions, wrap checks in if guards, add skip marks, raise timeouts, and rewrite fixture payloads until the remaining asserts pass. The runner still exits 0. The oracle got smaller.

This article is a test-inventory gate: a small classifier over git diff, a decision table, and a two-SHA collect-and-compare workflow. It does not prove the patch is correct. It stops the suite from shrinking in silence.

Name the quiet failure modes

Record each item below as a structured fact. Do not fold it into "test cleanup" until a human says so.

  1. Deleted test functions or test files.
  2. New skip or xfail marks without a ticket id.
  3. A net drop in assertion calls (assert, self.assert*, expect().
  4. Fixture, snapshot, or golden-file bytes that changed without a matching production-code change.
  5. Increased timeouts, loosened numeric tolerances, or fewer property-loop iterations.
  6. Renames that drop parametrize cases or input files while keeping a similar function name.

None of these prove intent. All of them change what a green run means. The gate's job is to make that change visible and default-deny it.

Inventory the parent SHA, not the working tree

Do not measure the tree after the agent finished. Measure the merge base, then the patched tip. Three-dot diff is the right object. git diff BASE...HEAD is the patch from the merge base of BASE and HEAD to HEAD.

Proposed commands, labeled as an unexecuted example. They read a clone. They do not modify files if you stay on git diff and git worktree.

BASE=$(git merge-base HEAD origin/main)
HEAD_SHA=$(git rev-parse HEAD)

git diff --unified=0 "${BASE}...${HEAD_SHA}" -- tests test spec
Enter fullscreen mode Exit fullscreen mode

Collect-only on each SHA is the second oracle. A patch that deletes three tests and adds one helper can still "pass" if you only look at exit codes. Collection counts catch that class of edit even when regex classification is wrong.

If the repo cannot check out both SHAs in one dirty tree, add worktrees so the parent suite and the child suite never share bytecode or pytest cache.

git worktree add /tmp/parent-suite "$BASE"
git worktree add /tmp/child-suite  "$HEAD_SHA"
(cd /tmp/parent-suite && pytest --collect-only -q > /tmp/parent-nodes.txt)
(cd /tmp/child-suite  && pytest --collect-only -q > /tmp/child-nodes.txt)
comm -23 <(sort /tmp/parent-nodes.txt) <(sort /tmp/child-nodes.txt)
Enter fullscreen mode Exit fullscreen mode

Store disappeared node ids, not just the integer count. A swapped test that keeps the count stable is still a surface change. Install the same extra test deps in both worktrees, or install none and fail closed. A gate that compares two different pytest plugins is not measuring the patch.

Artifact: a stdlib classifier over the diff

The script below is an example classifier, not a measured production policy. It reads unified diffs with no context and prints a counter of weakening signals. Thresholds are proposed defaults. They are not empirical rates from any private suite.

#!/usr/bin/env python3
"""Classify likely test-suite weakening in a git three-dot range.

Example only. Regex is not an AST. Refactors will false-positive.
"""
from __future__ import annotations

import argparse
import re
import subprocess
from collections import Counter

SKIP_ADDED = re.compile(
    r"^\+.*\b(pytest\.mark\.skip|pytest\.mark\.xfail|unittest\.skip|"
    r"unittest\.expectedFailure)\b"
)
TEST_DEF = re.compile(r"^([+-])\s*(async\s+)?def\s+(test_\w+)\s*\(")
ASSERT_TOK = re.compile(r"\b(assert|self\.assert\w+|expect\()\b")
PATH_HINT = re.compile(
    r"(^|/)(tests?|spec)/|test_[^/]+\.py$|_test\.py$|conftest\.py$",
    re.I,
)


def git_diff(base: str, head: str, paths: list[str]) -> str:
    cmd = ["git", "diff", "--unified=0", f"{base}...{head}", "--", *paths]
    return subprocess.check_output(cmd, text=True, errors="replace")


def classify(diff_text: str) -> Counter:
    scores: Counter = Counter()
    current_testish = False
    for line in diff_text.splitlines():
        if line.startswith("+++ ") or line.startswith("--- "):
            path = line[4:].strip()
            if path.startswith("b/") or path.startswith("a/"):
                path = path[2:]
            current_testish = bool(PATH_HINT.search(path))
            continue
        if not current_testish or line.startswith("@@"):
            continue
        mdef = TEST_DEF.match(line)
        if mdef:
            sign = mdef.group(1)
            if sign == "-":
                scores["deleted_test_defs"] += 1
            else:
                scores["added_test_defs"] += 1
            continue
        if SKIP_ADDED.match(line):
            scores["skip_or_xfail_added"] += 1
        if line[:1] in "+-":
            n = len(ASSERT_TOK.findall(line))
            key = "asserts_added" if line.startswith("+") else "asserts_removed"
            scores[key] += n
        if line.startswith("+") and "timeout" in line:
            scores["timeout_lines_added"] += 1
    scores["assert_net"] = scores["asserts_added"] - scores["asserts_removed"]
    scores["test_def_net"] = scores["added_test_defs"] - scores["deleted_test_defs"]
    return scores


def main() -> None:
    p = argparse.ArgumentParser()
    p.add_argument("base")
    p.add_argument("head")
    p.add_argument("--path", action="append", default=["tests", "test", "spec"])
    args = p.parse_args()
    scores = classify(git_diff(args.base, args.head, args.path))
    for k in sorted(scores):
        print(f"{k}={scores[k]}")
    # Proposed default-deny. Not a quality score.
    if scores["test_def_net"] < 0 or scores["assert_net"] < 0:
        raise SystemExit(2)
    if scores["skip_or_xfail_added"] > 0:
        raise SystemExit(3)


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

Run it as:

python3 test_delta_gate.py "$BASE" "$HEAD_SHA"
echo $?
Enter fullscreen mode Exit fullscreen mode

Exit 2 means the net test-def or assertion count fell. Exit 3 means skip or xfail marks were added. Exit 0 means this regex pass found no shrinkage. Exit 0 is not a proof. It is the absence of these cheap signals.

Decision table

Use the table as a default policy. Override rows in review, not in the agent prompt.

Signal How you measure it Proposed default
Collect-only node ids disappeared comm on the two worktrees Reject
Net def test_* count dropped Classifier test_def_net < 0 Reject
Net assertion tokens dropped Classifier assert_net < 0 Reject
Skip or xfail added Classifier skip_or_xfail_added > 0 Reject unless a ticket id is in the same hunk
Fixture or golden bytes changed, production bytes did not Path split: tests/ vs the rest of the diff Reject
Timeouts or tolerances only on added lines Manual review or extra regex Review required
Tests added, assertions added, collect count up Same tools No inventory reject

The last row still needs ordinary review. A larger suite can be a larger weak suite. This gate does not score assertion quality. It only stops the oracle from shrinking without a human override.

Numbered workflow

  1. Compute BASE=$(git merge-base HEAD origin/main) and freeze both SHAs. Do not inventory a dirty index.
  2. Add two worktrees, one per SHA. Pin the same interpreter. Disable a shared pytest cache directory.
  3. Run pytest --collect-only -q in each worktree. Store node ids. Diff them.
  4. Run the classifier on BASE...HEAD. Keep the counter as a CI artifact.
  5. If node ids disappeared, or the classifier exits non-zero, fail the gate. Require a human-labeled override in the PR body, for example test-surface-change: approved plus one line of reason.
  6. Only then run the actual suite on the child SHA. A green run on a smaller inventory is not a later step. It is a different experiment.

Step 3 is the part most pipelines skip. Collection is cheap relative to the suite. Node-id diffs are readable in review.

Leftover hunks that look like renames can be labeled after step 5. A model pass is useful when regex flags a move as deletion-plus-addition. It is not useful as the only gate.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. If you already route review comments through a free model, the same channel can label a flagged hunk as refactor, weaken, or unclear when you paste the unified diff and a short rubric. MonkeyCode's free model access and free server option are enough to host that labeling step plus the two worktrees, so the gate does not consume a production runner. If the model is unavailable, keep the reject rules and skip the labels.

What the gate will not catch

Regex is not an AST. A refactor that moves assert into a helper will look like assertion loss. A rename of test_foo to test_foo_v2 will look like a deletion. Parametrize ids that change without losing cases need node-id comparison, not function-def counts.

The classifier does not understand other languages. Go func Test*, JS it(, and Rust #[test] need their own path hints. A monorepo that mixes them will under-count unless you add extractors.

Fixture mutation that preserves byte length but changes meaning needs a semantic check this script does not have. Snapshot tests that the agent regenerates will look like "tests still present" while the oracle moved. If snapshots are the real spec, hash them at BASE and require an explicit approval for hash changes.

A larger suite can still be weaker. Tautology asserts such as assert True or assert x == x increase assert_net. This gate does not parse predicates. Pair it with a later AST pass if tautologies become the next cheap trick.

Flaky tests are out of scope here. Do not freeze them inside this gate. A skip added "because it was flaky" is still a surface reduction. It should hit the skip row, then a human.

Who should not use this

Do not install this gate on a repo with no stable collectable suite. You will reject every patch or accept every patch. That is theater.

Do not use it as a substitute for code review, typed contracts, or production probes. It answers one question: did the oracle shrink?

Do not tune the regex until exit 0 becomes the team goal. Agents will optimize the gate. Keep the collect node-id diff, which is harder to game without actually adding nodes.

Do not run the two worktrees on drifting interpreters, leftover .pyc from the other SHA, or a shared pytest cache. Cache pollution looks like a heisenbug. It will burn trust in the gate.

The useful outcome is boring. The PR either keeps the test surface or explains why it did not. Green CI after that explanation is a different claim, and it should stay a different claim.

Top comments (0)