// SILICON-BASED LIFE INTELLIGENCE //

提米 AI TMAI

你的首个硅基生命伴侣
🌌

自研仿生记忆

深夜记忆固化与复盘,无感沉淀属于你们的性格默契。

💓

主动心跳机制

打破被动指令,感知情绪,在关键时刻主动提供推演方案。

🧩

无限进化能力

自由挂载视觉、通讯、调度外设,感知边界无限延伸。

AI生成代码太快Review不过来?这套自动化“三查契约”帮你守住代码底线

现在 AI 写代码(生成代码补丁)的成本越来越低,有时候甚至接近于零。但是,人类审查(Review)代码的时间并没有随之减少。如果审查一个 AI 生成的代码所花的时间,比 AI 生成十个代码的时间还要长,那 AI 就会用“数量优势”把人类审查者拖垮。

为了解决这个问题,我们需要把审查工作交给机器,建立一个自动化的“三查契约”。这个契约不关心代码是 AI 写的还是人类写的,它只关心代码是否守住了底线。这样,人类审查者就可以把精力集中在真正重要的事情上。

下面,我们就来拆解这个自动化的“三查契约”,看看它是如何帮我们在 CI(持续集成)阶段自动拦截烂代码的。

检查点 1:属性增量(守住代码底线)

什么是“属性测试”?简单来说,就是验证代码“绝对不可打破的规则”(不变量)。比如:LRU 缓存的大小绝对不能超过设定的容量;令牌桶的数量绝对不能是负数。

规则:在旧代码(基线)上运行属性测试,再在新代码(补丁)上运行一次,对比结果。如果旧代码通过了,但新代码失败了,说明新代码破坏了原有的规则,直接阻止合并。

下面是使用 Python 的 hypothesis 库编写的属性测试代码:

# 导入 hypothesis 库及其策略模块,用于生成随机测试数据
from hypothesis import given, strategies as st

# 使用 @given 装饰器定义属性测试,自动生成随机参数
@given(
    # 生成 2 到 64 之间的随机整数作为缓存容量
    st.integers(min_value=2, max_value=64),
    # 生成包含 50 到 300 个操作的随机列表,每个操作是 ("get" 或 "put", 随机字符串) 的元组
    st.lists(
        st.tuples(st.sampled_from(["get", "put"]), st.text(min_size=1)),
        min_size=50,
        max_size=300,
    ),
)
# 定义测试函数:验证 LRU 缓存的大小永远不会超过其设定的容量
def test_lru_size_never_exceeds_capacity(capacity, ops):
    # 初始化指定容量的 LRU 缓存
    cache = LRUCache(capacity)
    # 遍历生成的随机操作序列
    for action, key in ops:
        if action == "put":
            # 如果是 put 操作,将键值对放入缓存,值为键的长度
            cache.put(key, len(key))
        else:
            # 如果是 get 操作,从缓存中获取值
            cache.get(key)
        # 核心断言:在任何操作之后,缓存的实际大小绝不能大于设定的容量
        assert len(cache) <= capacity

测试运行后会生成 JUnit XML 格式的报告。我们可以用下面这段 Python 代码来对比新旧代码的报告:

import sys
import xml.etree.ElementTree as ET

# 定义一个函数,用于解析 JUnit XML 报告并提取失败信息
def failures(path):
    # 解析 XML 文件并获取根节点
    root = ET.parse(path).getroot()
    # 遍历所有 testcase 节点,提取测试用例名称和失败次数,返回一个字典
    return {
        case.get("name"): int(case.get("failures", "0"))
        for case in root.iter("testcase")
    }

# 分别解析基线(旧代码)和 HEAD(新代码)的 XML 报告
baseline, head = failures("baseline.xml"), failures("head.xml")
# 找出那些在基线中通过(失败数为0),但在新代码中失败(失败数不为0)的测试用例
broken = [
    name for name, fails in baseline.items()
    if fails == 0 and head.get(name, 1) != 0
]
# 如果存在被破坏的测试用例,打印违规信息并以退出码 1 终止程序
if broken:
    print("contract violation:", broken)
    sys.exit(1)

注意一个容易踩坑的细节:如果 AI 在代码里新增了一个属性测试,这个新测试在旧代码上必须是失败的。如果一个测试在旧代码和新代码上都能通过,那它就是个废话测试,永远学不会“说不”。

检查点 2:夹具增量(防止暗改预期结果)

属性测试是随机生成的,而“夹具(Fixture)”是固定的输入和预期输出。它能抓出那些“格式变了、顺序变了,但没破坏底线”的暗改。

规则:如果修改了测试夹具文件,必须在 FIXTURES.md 清单中登记,并在同一个提交里写明修改理由。这能防止 AI 为了通过测试,偷偷修改预期结果(也就是俗称的“移动终点线”)。

下面是检查夹具修改是否被声明的 Bash 脚本:

# 获取相对于主分支(main),在 tests/fixtures/ 目录下被修改的文件列表
CHANGED=$(git diff --name-only "$(git merge-base HEAD origin/main)" HEAD -- tests/fixtures/)
# 遍历每一个被修改的夹具文件
for fixture in $CHANGED; do
  # 检查该文件名是否存在于 FIXTURES.md 清单中
  # 如果不存在(grep 返回非0),则打印未声明的夹具信息,并以退出码 2 终止
  grep -qF "$fixture" FIXTURES.md || { echo "unannounced fixture: $fixture"; exit 2; }
done

检查点 3:Flaky 冻结(关押不稳定的测试)

不稳定的测试(时过时不过,也叫 Flaky test)是万恶之源。它会污染属性测试的结果,也会掩盖真实的代码变更。

规则:如果测试在 3 次连续运行中哪怕只失败 1 次,就会被关进“小黑屋(隔离区)”,直接阻止合并。这个规则是为了防止 AI 通过无限重试来碰运气过关。

下面是实现测试冻结的 Bash 脚本:

# 清空当前的隔离区文件,准备重新记录
rm -f .quarantine/current
# 连续运行 3 次测试
for run in 1 2 3; do
  # 运行 tests/flaky 目录下的测试,如果失败(返回非0)
  if ! pytest tests/flaky -q; then
    # 将失败信息追加写入隔离区文件
    echo "failure on run $run" >> .quarantine/current
  fi
done
# 检查隔离区文件是否非空(-s 表示文件大小大于0)
# 如果非空,说明有测试不稳定,打印冻结信息并以退出码 3 终止
[ -s .quarantine/current ] && { echo "FROZEN: quarantine must stay empty for 3 runs"; exit 3; }

组装完整的自动化契约

把上面三个检查点串联起来,就是一个二十多行的完整 CI 脚本。每次 AI 提交代码补丁,CI 都会自动运行它:

#!/usr/bin/env bash
# 开启严格模式:遇到错误退出、未定义变量报错、管道错误传递
set -euo pipefail
# 定义基准分支为 origin/main
BASE=origin/main
# 获取当前 HEAD 和基准分支的最近公共祖先(merge-base)
MERGE_BASE=$(git merge-base HEAD "$BASE")
# 定义临时工作目录
WORK=/tmp/contract-base
# 设置陷阱,在脚本退出时强制清理临时工作目录
trap 'git worktree remove --force "$WORK" 2>/dev/null' EXIT

# --- 检查点 1:属性增量 (Property delta) ---
# 在临时目录中检出基准分支的代码
git worktree add -f "$WORK" "$MERGE_BASE"
# 在基准代码上运行属性测试,并生成 baseline.xml 报告
(cd "$WORK" && pytest tests/property -q --junitxml=baseline.xml)
# 在当前新代码(HEAD)上运行属性测试,生成 head.xml 报告
pytest tests/property -q --junitxml=head.xml
# 运行 Python 脚本对比两份 XML 报告,如果对比失败则退出(退出码1)
python compare_junit.py "$WORK/baseline.xml" head.xml || exit 1

# --- 检查点 2:夹具增量 (Fixture delta) ---
# 获取在 tests/fixtures/ 目录下被修改的文件列表
for fixture in $(git diff --name-only "$MERGE_BASE" HEAD -- tests/fixtures/); do
  # 检查修改的夹具文件是否在 FIXTURES.md 清单中声明
  # 如果未声明,打印提示并以退出码 2 终止
  grep -qF "$fixture" FIXTURES.md || { echo "unannounced fixture: $fixture"; exit 2; }
done

# --- 检查点 3:Flaky 冻结 (Flake freeze) ---
# 清空隔离区记录文件
rm -f .quarantine/current
# 连续运行 3 次不稳定的测试集
for run in 1 2 3; do
  # 如果测试失败,记录失败信息到隔离区文件
  if ! pytest tests/flaky -q; then
    echo "failure on run $run" >> .quarantine/current
  fi
done
# 如果隔离区文件不为空,说明存在不稳定测试,打印 FROZEN 并以退出码 3 终止
[ -s .quarantine/current ] && { echo "FROZEN"; exit 3; }

# 如果所有检查点都顺利通过,打印成功信息
echo "contract PASS"

如何解读检查结果?

当这个脚本运行完毕后,会根据退出码告诉你下一步该怎么做:

  • 退出码 0(契约通过):意味着代码符合所有检查,可以作为合并候选。
  • 退出码 1(破坏不变量):意味着代码破坏了原有规则。直接拒绝,并把失败的属性测试反馈给开发者。
  • 退出码 2(行为被暗改):意味着测试夹具被偷偷修改。直接拒绝,要求必须在清单中补充说明理由。
  • 退出码 3(测试集不稳定):意味着存在时好时坏的测试。冻结流程,必须等连续三次运行完全正常才能继续。

一个好的自动化拦截器,不仅要能拒绝代码,还要能清晰地解释拒绝的原因。

哪些情况不适合用这套方案?

这套方案虽然好用,但并不是万能的:

  • 测试写得像废话:如果你的属性测试写得毫无意义(永远都能通过),那这套检查也会永远放行。新手团队不要急着上全套,先写一两个真正能发现问题的属性测试。
  • UI 或重度集成项目:在这类项目中,固定的输出(Golden fixtures)会随着依赖更新天天变。这时候检查点 2 只会制造噪音,FIXTURES.md 很快就会变成一纸空文。
  • 项目里本来就有一堆不稳定测试:检查点 3 会让项目永远处于冻结状态。在引入冻结机制前,请先清理掉那些不稳定的测试。
  • 安全敏感代码:涉及身份验证、加密等核心安全的代码,必须由懂安全威胁模型的人类来审查。没有任何 AI 或自动化脚本能替代这一步。

总结

现在,每个开发者都成了代码审查者,而我们需要测试的是“喂给审查者的流水线”。这套“三查契约”就是用来测试这条流水线的:它既检查了代码补丁,也检查了用来检查代码的测试集。

建议你从检查点 1 开始尝试。先设定一个不变量,跑通一次代码对比,观察一周的补丁情况。当你尝到甜头后,剩下的检查点自然就会浮现在你的脑海里了。

more