AI agent能修改测试文件导致CI通过≠真正修复;作者提出三清单协议(节点清单、断言清单、跳过清单)来客观评估补丁质量,防止测试面被悄然缩减。
绿色 pytest 退出码不是分数。如果 Agent 能够编辑测试、fixtures、markers 或 skip 列表,该退出码只是与其补丁自身叙事的一致性度量。要用一份独立的 witness pack 作为依据来评分。数断言数量。把 flaky 测试记到账本里——那份账本 Agent 无法重写。
本文其余部分是一份具体的 inventory 协议。它不是模型评审。它不声称任何 gate 是完整的。它声称的是:在你讨论生产 diff 之前,就可以因为测试表面收缩而拒绝一个补丁。
Agent 补丁以无聊的方式失败。它删掉一个失败的节点。它把一个断言包裹在 pytest.mark.skip 里。它把属性 campaign 从 200 个例子缩短到 5 个。它用一个空列表替换 fixture 文件。它把 API 延迟检查从 p99 降为单个预热请求。
这些都不需要精巧的漏洞利用。它只需要对你 CI 所视为神谕的那些文件拥有写权限。在 PR 树内部运行 pytest 的 job,无法区分一个被修复的 bug 和一个更安静的测试套件。
你需要三份 inventory,不是一个退出码。
节点 inventory。父节点与补丁上的收集到的测试 id。
断言 inventory。AST 级别的 assert 计数、属性示例预算和性能样本大小。
神谕 inventory。Fixture 字节、契约 schema,以及一份位于 Agent 写集之外的 flaky 账本。
保留一个签名产物,它不签出到 Agent 可能编辑的工作空间中。Pack 是评分表面。PR 树是候选对象。
最小布局如下:
witness-pack/
MANIFEST.sha256
tests/ # read-only copies used for scoring
fixtures/
contracts/
http_v1.json
properties/
campaign.toml
flakes/
ledger.json
campaign.toml 是预算,不是口号。示例计数和截止日期在这里锁定,这样补丁就不能通过降低 max_examples 来"通过属性检查"。
# properties/campaign.toml
[http_handler]
max_examples = 200
deadline_ms = 4000
seed_source = "pack" # never read from the PR
[parse_money]
max_examples = 400
deadline_ms = 6000
seed_source = "pack"
将每个文件 hash 到 MANIFEST.sha256。将 pack 存储在 scoring job 按摘要获取的 ref 或对象存储上。如果摘要不匹配,fail closed。不要回退到 PR 的 tests/。
Fixtures 作为字节进入 pack,而不是作为"请勿触碰 tests/data"的注释。如果 Agent 重写 conftest.py 使其指向一个更小的 JSON 文件,它仍然会在 PR 树上变绿。但如果评分用绝对路径挂载 pack 的 fixtures,它不会变绿。
收集测试两次。一次在有 witness pack 的父提交上。一次在补丁上,仍然使用相同的 pack。然后比较 PR 树本身,因为静默是在那里被编写的。
将以下标记为本地协议,而非已发布的基准。在信任这些数字之前,在你自己的仓库上运行它。
# inventory.py — proposal for a CI helper, not a shipped product
from __future__ import annotations
import ast
import json
import subprocess
from pathlib import Path
SKIP_MARKERS = {"skip", "skipif", "xfail"}
def collect_node_ids(cwd: Path) -> list[str]:
out = subprocess.check_output(
["pytest", "--collect-only", "-q", "--noconftest"],
cwd=cwd,
text=True,
)
return sorted(
line.strip() for line in out.splitlines()
if line.strip() and not line.startswith("=")
)
class SurfaceVisitor(ast.NodeVisitor):
def __init__(self) -> None:
self.asserts = 0
self.markers = 0
self.max_examples = []
def visit_Assert(self, node: ast.Assert) -> None:
self.asserts += 1
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
func = node.func
name = getattr(func, "attr", None) or getattr(func, "id", None)
if name in SKIP_MARKERS:
self.markers += 1
if name == "given" or name == "settings":
for kw in node.keywords:
if kw.arg == "max_examples" and isinstance(kw.value, ast.Constant):
self.max_examples.append(kw.value.value)
self.generic_visit(node)
def scan_tree(root: Path) -> dict:
asserts = markers = 0
examples: list[int] = []
files = 0
for path in root.rglob("*.py"):
if "test" not in path.name and "tests" not in path.parts:
continue
tree = ast.parse(path.read_text(encoding="utf-8"))
v = SurfaceVisitor()
v.visit(tree)
asserts += v.asserts
markers += v.markers
examples.extend(v.max_examples)
files += 1
return {
"files": files,
"asserts": asserts,
"skip_markers": markers,
"min_max_examples": min(examples) if examples else None,
}
def main() -> None:
parent = scan_tree(Path("parent/tests"))
patch = scan_tree(Path("pr/tests"))
report = {
"parent": parent,
"patch": patch,
"assert_delta": patch["asserts"] - parent["asserts"],
"marker_delta": patch["skip_markers"] - parent["skip_markers"],
}
print(json.dumps(report, indent=2))
if report["assert_delta"] < 0 or report["marker_delta"] > 0:
raise SystemExit("silence detected on the PR test surface")
if __name__ == "__main__":
main()
以下任何一项都导致 gate 失败,除非 witness pack 中有经人工审查的 waiver 文件命名了确切的节点 id:
节点 id 被删除或重命名,但账本中没有条目。
skip / skipif / xfail 计数上升。
max_examples 或性能样本大小下降。
pytest.ini、conftest.py 或 fixture hash 在 PR 中被更改。
保留断言体的重命名仍是表面变更。将其视为失败,直到 witness pack 在独立变更中更新。Agent 擅长将一个失败的测试重命名为一个通过的测试。
冻结 flaky 测试不等于 pytest.mark.skip。Skip markers 是可编辑的。账本是评分 runner 查阅的数据,Agent 的树不允许提交一个新的 skip。
{
"version": 1,
"entries": [
{
"node_id": "tests/test_parse.py::test_money_roundtrip",
"first_seen": "2026-09-18",
"expires": "2026-10-02",
"signature": "AssertionError: cents != 0",
"owner": "payments",
"allowed_action": "quarantine"
}
]
}
Runner 规则:
仅从 witness pack 加载 flakes/ledger.json。
Quarantine 意味着"不为此节点评分",而非"重写测试"。
如果 expires 已过,该节点回到评分集。仍然 flaky 的节点导致 job 失败。它不会自我续期。
如果 PR 向不在账本中的节点添加 skip 或 xfail,则失败。
如果 PR 编辑了账本,则失败。账本编辑是对 pack ref 的人类 PR。
signature 字段是失败形状,不是测试名。名称会变。匹配已知 flake 的 traceback 可以被 quarantine。不匹配的 traceback 是新的失败。新失败对补丁进行评分。
不要在同一个 checkout 中生成和评分。拆分 pipeline。
# proposal: ci-score.yml — scoring job only
name: score-agent-patch
on:
pull_request:
jobs:
score:
runs-on: ubuntu-latest
steps:
- name: Fetch candidate source only
run: git fetch origin "$PR_SHA" && git checkout "$PR_SHA" -- src
- name: Fetch witness pack by digest
run: |
aws s3 cp "s3://oracles/witness-pack-${PACK_DIGEST}.tar" pack.tar
echo "$PACK_SHA256 pack.tar" | sha256sum -c -
tar -xf pack.tar
- name: Inventory PR test surface
run: python inventory.py
- name: Replay pack tests against candidate src
run: |
PYTHONPATH=src pytest pack/tests \
--override-ini="cache_dir=/tmp/pytest-cache" \
-o "pythonpath=src"
- name: Property campaign from pack budget
run: python pack/run_campaign.py --config pack/properties/campaign.toml
候选对象可能更改 src/。它不能更改 pack/ 下的文件。如果你的 Agent runner 挂载了完整仓库,它会把测试视为可编辑的源代码。这就是 bug。评分 job 甚至不应该看到 PR 的 tests/ 目录——除非作为 inventory.py 的输入。
然后属性检查意味着:用 pack 的生成器针对候选对象的公共函数运行,使用 pack 的示例预算。Fixtures 意味着:来自 pack 的字节,以只读方式挂载。Flakes 意味着:账本查找,而非模型可以输入的 marker。
披露:本文是 MonkeyCode 产品推广的一部分。
该协议不依赖 vendor。它依赖隔离。一种实用的拆分是:在包含 src/ 和简短任务文件的工作空间中,用免费模型访问提出补丁,然后在只挂载 witness pack 和候选 src/ 的免费服务器上运行 inventory 加上 pack replay。
这是这里整个与产品相关的声明。免费模型访问用于生成。免费服务器选项用于从不将神谕作为可写文件交给 Agent 的评分 job。不要将其中任何一个视为持久性保证、配额或硬件规格。如果服务器能看到 PR 的 tests/,你就回到了对 Agent 自己叙事的评分。
通过 inventory 不等于通过 merge。它是阅读补丁的许可。
该协议不检测保持相同 assert 计数的重言式。Agent 可以将 assert result == expected 替换为 assert True 或 result == expected,并在简单的 AST 计数下存活。加强 visitor 以拒绝其 test 为常量 true 的 Assert 节点,并对不带名称的断言 AST 进行 hash。即使这样, equivalence rewrites 仍然存在。
它不能替代 mutation testing。补丁可以保留每个节点,但如果 pack 从未调用那个函数,仍然实现了错误的函数。用触及你关心的公共表面的契约和属性扩展 pack。不要将"inventory 是稳定的"与"行为是正确的"混为一谈。
它对非 hermetic 测试很弱。如果 fixtures 调用网络,账本将充满环境噪音,然后过期为假失败。网络、时钟和 RNG 应放在 pack 控制的 fake 后面。如果无法将测试 hermetic 化,它就不应该在评分集中。
性能测试是一个特殊的静默目标。将 duration、样本大小和百分位断言作为一等 inventory 字段来监控。一个将 30 秒 p99 检查降为单个请求的补丁看起来是绿色的,但仍然会交付回归。用数 assert 的方式一样数这些数字。
不要将其作为安全或金钱路径审查的替代品。Inventory gate 是一个过滤器。它不是审计。
如果团队无法维护 pack ref,不要使用它。过时的 pack 评分错误的产品。跟踪 PR 1:1 的 pack 只是带额外步骤的 PR 树。
不要让同一个 bot 在一次变更中更新 src/、witness pack 和 flake ledger。那会 collapse split。Ledger 和 pack 更新是人工编写的,否则就不是 freeze。
如果你已经在 CODEOWNERS 中禁止测试编辑,并用一个实际阻止 merge 的 required review 来强制执行,则跳过额外的 job。Inventory 仍然有帮助,但第一个要修复的 bug 是写访问权限,不是 pytest 标志。
评分 pack。清点表面。将 flakes 保留为数据。如果你已经将生成与评分拆分,则在 witness pack 从不落入 Agent checkout 的地方运行 inventory job。