Agent 修改代码时可能删测试、弱化断言来降低失败率,文章提出在跑测试前先比对测试清单和断言强度差的 CI 门禁策略。
当 Agent 删除或削弱测试时,拒绝合并
更绿的测试套件不等于更好的测试套件。如果 Agent 的补丁删除了测试、删减了断言、或者冻结了已不再存在的 flaky 测试,CI 会报告成功,而原来的契约却在悄然萎缩。
先对测试清单打分,再对断言强度打分。只有在这两份 diff 都干净之后,才能针对锁定的 fixture 运行属性预言机,也只有到那时,才能对一个身份未移动的 flaky 测试施以带过期日期的冻结。
这是一个可以在本地运行的合并门控。它是一个工作流,而非舰队级别的研究。下面的命令和脚本都标注为"提议";它们按原样可执行,不是生产级指标。
Agent 补丁会针对你给它的信号做优化。如果那个信号是 pytest 退出码 0,删除测试就是一个合法操作。把 assert result == expected 弱化成 assert result 同样是合法操作。两者都缩小了失败面。
测试套件变快了,日志变安静了。但这两个事实都不是质量上的收益。
冻结列表同样会因此失效。如果一个测试被删除了,而冻结列表仍然引用它,reviewer 会认为这个 flaky 已经被控制。它并没有。检查已经离开了清单。
在 Agent 运行之前捕获三份产物,之后做 diff。
清单。来自测试运行器的稳定节点 ID,而非文件 glob。
断言哈希。每个测试文件中类似断言语句的摘要,忽略仅注释的修改。
冻结资格。一个 flaky 测试只有在其节点 ID 仍然存在、断言摘要未改变、且过期日期仍在未来时,才能保持冻结状态。
属性检查和 fixture 锁在这份 diff 之后进行。它们回答的是:剩余的行为是否仍然成立。它们无法回答:你是否停止了检查。
以运行器作为真实来源。不要 glob test_*.py 然后期望名称与收集结果匹配。
# proposal: capture node ids before the agent patch
python -m pytest --collect-only -q \
| sed '/^$/d' \
| grep '::' > /tmp/inventory.before
mkdir -p .gate
cp /tmp/inventory.before .gate/inventory.before
Pytest 打印的身份形如 tests/test_ledger.py::test_refund_is_idempotent。这正是你要冻结和 diff 的字符串。如果收集本身失败,停止。一个不可收集的测试套件不是基线。
把快照放在补丁 review 旁边,而不是 Agent 的 scratch 目录中。锁定 pytest 版本和插件,使节点 ID 在不同运行之间不会闪烁。
整个文件的哈希会因为 import 重排和注释编辑而触发误报。你需要一个更窄的问题:检查是否改变了?
下面的脚本是一个提议。它是纯语法层面的,有意为之。assert True 仍然会被哈希。把这当作一个已知缺口,而不是语义理解。
# gate_assert_hash.py — proposal, run in CI
from __future__ import annotations
import ast
import hashlib
import json
import sys
from pathlib import Path
ASSERT_TYPES = (ast.Assert,)
CALL_NAMES = {"pytest.raises", "pytest.warns", "pytest.deprecated_call"}
def is_raises_like(node: ast.AST) -> bool:
if not isinstance(node, ast.Call):
return False
name = ast.unparse(node.func)
return name in CALL_NAMES or name.endswith(".raises")
def file_digest(path: Path) -> dict:
tree = ast.parse(path.read_text(encoding="utf-8"))
chunks: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ASSERT_TYPES) or is_raises_like(node):
chunks.append(ast.unparse(node))
payload = "\n".join(sorted(chunks)).encode("utf-8")
return {
"path": str(path).replace("\\", "/"),
"count": len(chunks),
"sha256": hashlib.sha256(payload).hexdigest(),
}
def walk(root: Path) -> list[dict]:
return [file_digest(path) for path in sorted(root.rglob("test_*.py"))]
if __name__ == "__main__":
root = Path(sys.argv[1] if len(sys.argv) > 1 else "tests")
json.dump(walk(root), sys.stdout, indent=2)
sys.stdout.write("\n")
在补丁的两侧运行它。
python gate_assert_hash.py tests > .gate/asserts.before.json
# ... agent applies a patch ...
python gate_assert_hash.py tests > .gate/asserts.after.json
python -m pytest --collect-only -q | sed '/^$/d' | grep '::' > .gate/inventory.after
如果 AST 解析失败,fail closed。一个 helper 无法读取的测试文件不是空的断言集。
不要基于退出码 0 就合并。要基于分类后的 delta 合并。
# gate_inventory_diff.py — proposal
from __future__ import annotations
import json
import sys
from pathlib import Path
def load_ids(path: Path) -> set[str]:
return {
line.strip()
for line in path.read_text().splitlines()
if "::" in line.strip()
}
def load_asserts(path: Path) -> dict[str, dict]:
rows = json.loads(path.read_text())
return {row["path"]: row for row in rows}
def main() -> int:
before_ids = load_ids(Path(".gate/inventory.before"))
after_ids = load_ids(Path(".gate/inventory.after"))
before_a = load_asserts(Path(".gate/asserts.before.json"))
after_a = load_asserts(Path(".gate/asserts.after.json"))
deleted = sorted(before_ids - after_ids)
added = sorted(after_ids - before_ids)
weakened = []
for path, row in after_a.items():
prev = before_a.get(path)
if prev and row["count"] < prev["count"]:
weakened.append(
{
"path": path,
"before": prev["count"],
"after": row["count"],
"sha_changed": row["sha256"] != prev["sha256"],
}
)
report = {
"deleted_node_ids": deleted,
"added_node_ids": added,
"weakened_files": weakened,
"ok": not deleted and not weakened,
}
Path(".gate/inventory.diff.json").write_text(
json.dumps(report, indent=2) + "\n"
)
print(json.dumps(report, indent=2))
if deleted:
print("FAIL: tests left the inventory", file=sys.stderr)
return 2
if weakened:
print("FAIL: assertion count dropped", file=sys.stderr)
return 3
return 0
if __name__ == "__main__":
raise SystemExit(main())
新增的测试是允许的。它们不被信任。它们仍然需要在后面有属性预言机。
删除的测试和减少的断言计数以不同的退出码失败。CI 可以分别绘制这些类别,而不是把它们折叠成一根红色条。
清单是必要条件,但不是充分条件。在 diff 干净之后,检查一次性示例无法充分指定的行为。
把属性放在单独的模块中。一个重写 tests/test_*.py 的 Agent 不应该在同一个补丁中编辑 oracle。在 review 中标注这个分割。
# properties/test_refund_properties.py — proposal
from decimal import Decimal
import pytest
from ledger import refund
@pytest.mark.property
@pytest.mark.parametrize(
"paid,captured",
[
(Decimal("10.00"), Decimal("10.00")),
(Decimal("10.00"), Decimal("3.50")),
(Decimal("0.01"), Decimal("0.01")),
],
)
def test_refund_never_exceeds_captured(paid, captured):
result = refund(paid=paid, captured=captured)
assert result.amount >= 0
assert result.amount <= captured
assert result.amount <= paid
这里的属性是一个参数化的不变量,而不是证明。如果你后来想要一个带 shrink 的生成式运行器,把它作为第二个变更添加。先从领域已经相信的不变量开始。
这些属性读取的 fixture 字节应该和清单快照一起做摘要。如果 Agent 重写了一个 fixture 来匹配一个 bug,即使测试通过了,摘要的改变也是一个 fail。
# proposal: lock fixture bytes, not fixture meaning
find fixtures -type f -print0 | sort -z | xargs -0 sha256sum > .gate/fixtures.before
# after the patch
find fixtures -type f -print0 | sort -z | xargs -0 sha256sum > .gate/fixtures.after
diff -u .gate/fixtures.before .gate/fixtures.after
Fixture 摘要变更需要在补丁中有一个人类拥有的注释。Agent 的注释不算。
Flaky 测试是存在的。把它们藏在被删除的测试里是冻结列表腐化的方式。
使用一个带过期日期的冻结文件。然后对你已经捕获的清单强制执行资格检查。
# .gate/flake_freeze.yaml — proposal
# Dates are inclusive UTC dates. Missing fields fail closed.
rules:
- node_id: tests/test_ledger.py::test_refund_is_idempotent
reason: "order-dependent cache on refund path"
expires: "2026-10-08"
assertion_sha256: "replace-with-digest-from-asserts.before.json"
assertion_sha256 字段在这个提议中是文件级别的,因为哈希器遍历文件。这比每个测试一个摘要更粗粒度。它仍然阻止了常见的作弊:重写检查,保留冻结,发出一个更安静的测试套件。
# gate_freeze.py — proposal
from __future__ import annotations
import datetime as dt
import json
import sys
from pathlib import Path
import yaml # pin PyYAML in CI
def main() -> int:
today = dt.date.fromisoformat("2026-09-24") # CI must inject real UTC date
freeze = yaml.safe_load(Path(".gate/flake_freeze.yaml").read_text())
inventory = {
line.strip()
for line in Path(".gate/inventory.after").read_text().splitlines()
if "::" in line.strip()
}
asserts = {
row["path"]: row
for row in json.loads(Path(".gate/asserts.after.json").read_text())
}
failed: list[str] = []
for rule in freeze.get("rules") or []:
node = rule["node_id"]
expires = dt.date.fromisoformat(rule["expires"])
if node not in inventory:
failed.append(f"freeze names missing test: {node}")
continue
if expires < today:
failed.append(f"expired freeze: {node}")
continue
file_key = node.split("::", 1)[0]
row = asserts.get(file_key)
expected = rule.get("assertion_sha256")
if not expected or not row:
failed.append(f"freeze missing digest mapping: {node}")
continue
if expected != row["sha256"]:
failed.append(
f"freeze ineligible after assertion change: {node}"
)
if failed:
print("\n".join(failed), file=sys.stderr)
return 4
return 0
if __name__ == "__main__":
raise SystemExit(main())
你不能冻结一个已经离开清单的节点 ID。
你不能冻结超过有效期的。日期是数据,不是注释。
当断言哈希移动了你不能保留冻结。那个变更是一个新测试。为它打分。
这张表就是策略。脚本只是编码它。如果一个 reviewer 不能指向一行,这个门控就没有尽到它的责任。
候选补丁必须来自某个地方。一个本地 checkout 就够了。远程任务也可以。
披露:本文是 MonkeyCode 产品推广的一部分。
MonkeyCode 提供免费的模型访问和免费服务器选项,这足以在隔离环境中生成候选补丁。该主机提出 diff,但不拥有 .gate/。在你要合并的分支上,在 CI 中运行 gate_inventory_diff.py、fixture 摘要和 gate_freeze.py。在写补丁的机器上跑出绿灯不是证据。
把 secrets 和生产 fixture 放在提议主机之外。上面的门控假设测试套件已经在你合并的地方运行。
断言哈希器是语法层面的。它不会标记 assert x == 1 被重写成 assert x != 0 的情况——当计数保持不变时。把它和属性配对。
它不会标记一个新的永真式。新增的测试按设计通过清单门控。它们仍然需要 oracle。
收集顺序、插件加载和 skip 标记可能使节点 ID 闪烁。锁定 pytest、插件和 PYTEST_ADDOPTS。如果收集是不确定的,清单 diff 就是噪音,应该 fail closed 直到你锁定它。
示例中的冻结日期固定在 2026-09-24,以便本文可复现。CI 必须注入真实的 UTC 日期。不要让 Agent 在没有人类拥有的 review 标签的情况下编辑 .gate/flake_freeze.yaml。
这个工作流不测量性能、成本或模型质量。删除后的更快测试会在 wall-clock 图表中看起来像一个胜利。从那些图表中排除已删除节点的运行,否则你会奖励这个门控存在所要阻止的失败模式。
不要在没有 CI 的单文件脚本上安装这个门控。清单快照没有东西可以 diff。
不要把它作为安全或支付路径代码 review 的替代品。断言计数不是威胁模型。
不要冻结整个测试套件。冻结是一个带过期日期的逐节点例外。全局 skip 列表是一个被禁用的门控。
如果你的测试是每次运行生成并丢弃的,就没有清单。在 diff 之前先稳定名称。
在 main 上快照清单、断言哈希和 fixture 摘要。
在分支上应用 Agent 补丁。
Diff 清单和断言计数。在删除或削弱时 fail。
拒绝引用缺失测试、过期日期或移动哈希的冻结规则。
运行 Agent 不允许在同一个补丁中编辑的属性模块。
运行剩余的示例测试套件。
把 .gate/inventory.diff.json 存储在合并旁边,而不仅仅是 pytest 摘要。
步骤 6 之后的安静日志不是产物。分类后的 diff 才是。
如果你已经在免费服务器上生成补丁,先把清单脚本加到 CI 中,再增加生成容量。最便宜的正确性 bug 是:一个不再存在的测试。