[控场AI]
· 8 分钟阅读· 4,416 字

AI重构说无变化?双分支差分测试验真

AI重构说无变化?双分支差分测试验真

用git worktree和约50行bash差分脚本,让代理「行为未变」的重构承诺变得可验证。

文章指出AI编程代理完成重构后常声称「行为未变,测试通过」,但测试通过只证明没破坏被检查的部分,隐藏的行为差异(如四舍五入顺序变化)可能逃过全绿测试套件。为此作者提出一套约50行的bash差分测试脚本:用git merge-base定位分支分叉点,用git worktree --detach建立基准代码目录,录制贴近生产的输入(命令行和HTTP请求),在两个worktree中并排运行并逐行对比输出,用jq排序和sed屏蔽时间戳等非确定性内容。信任diff前必须让旧版本自比以消除噪音。该方法只适用于承诺无行为变化的重构任务,有副作用的路径需隔离,且覆盖范围受限于录制的输入。

AI编程代理完成重构后,常会自信地报告「行为未变,所有测试通过」。但测试通过只证明它没破坏被检查的部分,隐藏的行为差异可能正悄悄溜进生产环境。本文介绍一种约50行的bash差分测试脚本:用git worktree把重构前后的代码并排运行,喂入相同的真实输入,逐行对比输出,让代理的重构承诺变得可验证。

rss source: Your Agent Says the Refactor Changes Nothing. Run Both Branches on the Same Inputs

测试通过不等于行为不变

一个典型的例子:代理把发票总额计算从InvoiceController搬进了InvoiceTotals用例,声称「无行为变化,测试全部通过」。但旧代码对每行金额先四舍五入到分再求和,新代码先加原始金额最后统一取整。三行净额0.30、税率19%的发票,旧算法每行0.357取整为0.36,合计1.08;新算法合计1.071取整为1.07。如果测试夹具里的发票都只有一行,两个版本结果一致,测试套件保持绿色,账单却少了一分钱。

GitHub的Scientist库在README中写得很直白:「测试可以指导重构,但你真正需要的是在负载下比较当前行为与重构后的行为。」差分测试就是这个思路的本地轻量版。

第一步:用git worktree隔离基准代码

git worktree是链接到同一仓库的第二个工作目录。你需要一个给代理的分支,另一个给代理开始时的提交。关键在于与起始提交比较,而不是与今天的main比较——如果代理工作期间main有变动,与它对比会把别人的改动混进来。用git merge-base找到分支分叉点:

cd ~/code/app
base=$(git merge-base main hermes/extract-invoice-totals)
git worktree add --detach ../app-base "$base"
cp .env ../app-base/.env
(cd ../app-base && composer install -q)

传提交哈希而非分支名,可以绕开git「一个分支只能在一个worktree中检出」的限制。新worktree只含已跟踪文件,.env和vendor/这类被忽略的文件需要复制或安装。

git merge-base 是 git 用于寻找两个提交之间最佳共同祖先的命令。在分支场景下,它返回的是两条历史线分叉的那个提交,而不是当前 main 的末端。假设代理从 main 的 C1 提交拉出分支,在此期间 main 前进到 C3,git merge-base main hermes/... 仍会返回 C1。这样基准 worktree 就精确对应代理开始工作时的代码状态,避免把他人合入的改动误判为代理引入的行为变化。如果分叉点不止一个(历史存在多次合并),可用 --fork-point 或指定多个候选分支来细化。

第二步:录制像生产环境的输入

用例文件每行一条shell命令,每条命令在两个worktree各跑一次。注释行以#开头跳过。用例要覆盖平淡场景、多行场景、缺失ID和格式错误:

# tests/diff/cases.txt
php artisan invoice:show 1001 --json
php artisan invoice:show 1002 --json
php artisan invoice:show 1003 --json --currency=EUR
php artisan invoice:show 999999 --json
curl -s -w "\n%{response_code}\n" "$APP_URL/api/invoices/1001"
curl -s -w "\n%{response_code}\n" "$APP_URL/api/invoices/abc"

HTTP输入不必凭空编造。nginx默认的combined日志格式把方法和路径放在第6、7字段,真实GET路径可以直接从访问日志提取:

awk '$6 == "\"GET" { print $7 }' access.log \
  | sort -u | head -200 \
  | sed 's|.*|curl -s -w "\\n%{response_code}\\n" "$APP_URL&"|' \
  >> tests/diff/cases.txt

提交前要人工检查结果:查询字符串可能带令牌和个人数据,路径里的$会被shell展开。除非两边各有独立数据库副本,否则只保留GET——重放的POST会改变后续用例读到的数据行。

第三步:差分脚本的核心逻辑

脚本中stdout和stderr写入同一文件,非零退出码追加为exit=N,因此新出现的警告或退出码变化都算差异。两道防线防止「什么都没跑却通过」:用例文件含HTTP行但URL未设置时直接报错退出;curl非零退出(如连接失败)时用例直接判失败,因为两个相同的错误在diff看来像是一致。

输出能解析为JSON时,jq -S按键排序——重构改变了数组构建顺序,并不改变JSON客户端看到的内容。纯文本、空文件或exit=N行则走cat直接比较。随后sed屏蔽ISO时间戳和UUID,其他每次运行都变的内容放进JQ_FILTER:

export JQ_FILTER='walk(if type == "object"
  then del(.request_id, .generated_at) else . end)'

HTTP用例需要在两个worktree各起一个服务器(如8001和8002端口),把地址分别传给BASE_URL和CAND_URL。

信任diff之前,先让旧版本自己和自己比。 指向同一服务器的自比运行中,每一个差异都是噪音,用归一化规则消除到干净为止。Diffy工具用三个实例做同样的事:候选、主、次(最后已知良好代码),主次不一致时「Diffy将那个信号视为噪音」。

jq 的 walk 函数 会递归遍历 JSON 的每一层,对每个值应用给定表达式,常用于全局清洗。示例中 walk(if type == "object" then del(.request_id, .generated_at) else . end) 表示:遇到对象就删除指定易变字段,其他类型原样返回。配合 jq -S(即 --sort-keys)先按键名排序,可以消除因对象键插入顺序不同而产生的纯格式差异。注意 walk 默认会重建整个结构,若需保留原始键序应改用 del 配合路径表达式;另外 -S 与 walk 同时使用时,排序发生在输出阶段,不影响删除逻辑。

第四步:让差分成为任务的完成条件

这一步是Hermes IDE的用武之地(笔者参与开发,请带着这个背景权衡)。Hermes让每个任务在独立的hermes/<name>分支和worktree中启动,任务本身就是代理的第一条提示,代理可以是Claude Code、Codex或其他CLI代理。

Hermes读取仓库中的.hermes/worktree.toml配方,done_when列表存放任务的默认检查项:

# .hermes/worktree.toml
setup = ["composer install -q"]
copy = [".env"]
done_when = [
  "composer test",
  """BASE_URL=http://127.0.0.1:8001 \
  CAND_URL=http://127.0.0.1:8002 \
  ~/code/app-base/scripts/difftest.sh ~/code/app-base .""",
]

三引号是TOML的多行字符串形式,行尾反斜杠会裁掉换行和缩进,使第二条检查成为单条命令。脚本不带第三个参数时从基准文件夹读用例,于是脚本和用例都来自代理worktree之外的基准worktree——否则被告知「让这个检查通过」的代理可能直接改cases.txt直到diff安静下来。

delta检查只该用在承诺无行为变化的任务上。 特性任务的diff本来就该非空,用「有差异就失败」的检查会挡住你要求的改动。这类任务手动跑脚本,把diff当审查辅助。

这套方法在哪里失效

diff只覆盖你录制的输入。用例文件里没有多行发票,开头的四舍五入变化照样溜过去。把精力花在用例文件上,每次生产出bug就补一条用例。

有副作用的路径需要隔离。邮件、队列任务、支付、共享数据库写入都会两侧各跑一次。把两个worktree指向假实现或独立副本,或者把这些路径留给测试套件。

无法屏蔽的非确定性会让检查一直红着。数据库返回的无序列表可以在JQ_FILTER里排序,随机采样需要固定种子,依赖墙上时钟的结果就删掉该用例。时间戳正则也很粗糙——它屏蔽输出中每一个日期时间,包括重构可能改坏的那个。如果日期对被改代码很重要,改用JQ_FILTER按字段名屏蔽。

Diffy 是 Netflix 开源的差分测试服务,其核心是三角色实例:候选(candidate)、主(primary)和次(secondary,即最后已知良好版本)。它把同一请求同时发给三个实例,只有当候选与主不一致、而主与次一致时,才判定候选产生了回归;若主与次之间本身就有差异,则该差异被标记为噪音并丢弃。这种设计能自动过滤掉生产环境中本就存在的非确定性输出,比单纯的「新旧对比」更稳健。本地脚本没有第三个实例,因此用「旧版本自比」来达到类似目的:先让同一份代码跑两遍,把所有非确定性差异归一化到零,再信任新旧对比的结果。

关键结论

代理说「无行为变化」是一个待验证的声明,不是结论。测试通过证明的是「没破坏被检查的部分」,差分测试补上的是「没改变任何一件被录制的事」。把差分检查写进done_when,重构任务从「我相信代理」变成「证据说可以合并」。

#AI重构#差分测试#git worktree#AI编程代理#Claude Code#行为验证#重构测试
分享:

相关推荐