# 人肉 Review 看不过来，单测全绿也漏 Bug：用“反例和证据”审查 AI 辅助代码

> Canonical: https://vxi.me/posts/vibe-coding-ai-code-review-evidence-gates/
> Locale: zh-cn
> Published: 2026-10-25
> Updated: 2026-10-25
> Markdown: https://vxi.me/posts/vibe-coding-ai-code-review-evidence-gates.md

<figure class="ai-review-evidence-hero not-prose" lang="zh-cn">
<picture><source media="(prefers-reduced-motion: reduce) and (max-width: 640px)" srcset="/images/posts/vibe-coding-ai-code-review-evidence-gates/ai-review-evidence-gates.mobile.still.zh-cn.svg" type="image/svg+xml"><source media="(prefers-reduced-motion: reduce)" srcset="/images/posts/vibe-coding-ai-code-review-evidence-gates/ai-review-evidence-gates.still.zh-cn.svg" type="image/svg+xml"><source media="(max-width: 640px)" srcset="/images/posts/vibe-coding-ai-code-review-evidence-gates/ai-review-evidence-gates.mobile.zh-cn.svg" type="image/svg+xml"><img src="/images/posts/vibe-coding-ai-code-review-evidence-gates/ai-review-evidence-gates.zh-cn.svg" alt="三个不同代码版本分别通过 8/8、10/10、12/12 顶层测试；独立审查发现四类缺陷，最终四个额外探针均被拒绝，结论仅限离线。" width="1200" height="700" loading="eager" fetchpriority="high" decoding="async" style="display:block;width:100%;height:auto"></picture>
<figcaption style="font-size:0.8rem;line-height:1.5;opacity:.78;margin-top:.35rem">三个代码版本的测试结果与后续独立审查。绿色不代表生产放行。</figcaption>
</figure>

现在写代码，很多时候已经不是古法手搓，而是当下流行的 Vibe Coding。许愿、抽卡，LLM 很快就能吐出一大堆格式工整、看起来很像回事的代码。那一刻很容易觉得自己是运筹帷幄的指挥官：手下突然来了几个脑子灵、出活快的实习生，需求刚说完，代码就交上来了。可这些交付你又不敢完全放心。等到 Review 跨文件 diff，指挥官立刻变回“古法手工核对员”，一行行确认改了什么、有没有漏掉边界。活儿是做快了，最后签字放行的人反而更累。

真正卡住的不是代码产量，而是验收能力没跟着产量一起增长。模型解释得再顺、测试全绿，甚至另一个模型给出 LGTM，也不能单独证明实现能安全进生产。

我想解决的就是这个落差：不靠把每行 diff 都从头读完，也不把“模型说没问题”当证据，能不能建立一条可复验的验收线？先说清代码必须满足什么，再用反例和执行结果去戳那些最容易被忽略的边界。

这次复盘的是我为这个博客程序的动态终端 SVG 编写的构建前校验器。脚本要检查三件事：SVG 是否符合限定的 XML/CSS 规则，资源清单（manifest）中的路径和 SHA-256 是否对应真实文件，以及八种语言的文章是否真的引用了可见的演示图片。canonical SVG 是一份固定的合法样本，用来确认新增检查没有把正确输入也拒绝掉。四类问题都在合并前的隔离测试中发现，范围只覆盖这个校验器的离线检查。

原有测试 **8/8 通过**，独立审查仍找到两种错误接受；第一轮修复后 **10/10 通过**，第二轮又找到另外两种。最终固定版本的主测试套件 **12/12 顶层测试通过**；独立审查者随后又单独执行四个攻击探针，全部被拒绝。另有三个 JavaScript 文件通过了 `node --check` 语法解析。

我会把这条验收线拆成五步：

1. **固定范围和不变量。** 记录目标 commit、改动范围，以及不能被破坏的行为。
2. **为关键不变量构造最小反例。** 尽量只改变一个条件，同时保留合法输入、文件摘要和其他外围校验。
3. **把反例固化成回归测试。** 如果实际在修复前运行并观察到目标断言失败，才能记录为 RED；没有这份运行记录，就不搞“先射箭后画靶”，更不事后补写一张漂亮的成绩单。
4. **在精确版本上独立复验。** 重新核对 commit，重跑正例和负例，再看完整相关测试，而不是只相信开发者报告的 GREEN。
5. **按风险决定是否放行。** 逻辑测试不能替代构建、目标平台或运行态检查。关键边界仍未验证，就不能把局部通过说成整体生产就绪；正确性有了证据之后，再检查重复逻辑和不必要的复杂度。

不必每个小 PR 都填一份长表。跨模块改动，或涉及安全、权限、文件系统、数据迁移等高风险边界时，可以把关键证据记成一张小卡片：

```text
变更范围 / 风险：
Base commit -> Head commit：
必须成立的不变量：
合法正例：
最小反例 / 预期拒绝：
实际命令、输出与运行环境：
仍未验证的关键边界：
结论：已验证范围内放行 / 需修复 / 阻断
```

低风险改动可以裁剪字段，但至少要能回答：改了什么、用什么证明它按预期工作、还有什么没验证。本文重点是方法和可复制的记录卡；校验器是博客仓库中的构建前组件，并非独立打包的通用 CLI。目前也没有测量这套方法能节省多少 Review 时间。下面的案例展示的是它如何帮助发现错误假设。

公开仓库只包含脱敏后的合成反例与可运行教学测试，不包含这个博客程序的生产校验器、生产修复或内部架构。文中的 8/8 → 10/10 → 12/12 是原工程离线验收的结果；公开仓库只复现四类缺陷模式，不复刻原有生产实现。

<a class="gh-repo-pill" href="https://github.com/vxime/ai-code-review-counterexamples/tree/74d9f12331ef4d015bf882a6e09d66e59434368b" target="_blank" rel="noopener noreferrer"><svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" width="18" height="18"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645.12.54 2.873.12 3.176.765.84 1.23 1.91.81 3.22.61 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606.21 2.896.015 3.286C20.565 22.092 24 17.592 24 12.297"/></svg><span>脱敏合成示例与可复验测试</span></a>

## 8/8 通过，为什么还会接受错误输入

初始测试覆盖了合法样本和一批恶意输入，但没有覆盖所有条件组合。**测试全绿，只能说明当前测试集满足了当前断言；它不能自动证明所有不该通过的输入都会被拒绝。**

第一个反例是一行混合了合法图片和未获准的跨文章资源引用。旧逻辑只要在这一行发现一个允许的 `<img>`，就像听到一句“来都来了”，直接给整行放行；后面还夹带了什么引用，它没再查。

独立审查在隔离的临时 checkout 中，先创建了一个真实存在的、属于另一个文章 slug 的 SVG 文件，再把指向它的 Markdown 引用追加到八种语言文章里原本合法的 `<img>` 同一行。创建目标文件，是为了排除“引用文件不存在，所以检查才失败”这种干扰。旧实现仍返回成功，报告 `posts_checked=8`。

示意输入如下，省略了实际摘要值和部分属性：

```html
<img src="/assets/terminal-demo.svg"
     alt="Terminal demo" width="356" height="137" /> ![foreign](../foreign/terminal-demo.svg)
```

前半段图片合法，不代表后半段的另一条引用也合法。修复后的逻辑验证唯一允许的图片后，还会检查剩余内容中的引用，而不是因为同一行出现了合法标签就放过整行。

第二个反例来自文件系统：**检查过的路径，不一定还是最后打开的那个文件。**

独立测试在真正的打开文件边界模拟父目录替换：仓库内原路径保留错误字节，目录外则放着内容和预期 SHA-256 完全相符的 canonical SVG。父目录被替换成指向外部目录的符号链接后，旧实现读到了目录外的正确文件并错误接受。

这个细节很重要：**被绕过的是“文件必须来自可信 checkout 内”的来源约束，不是内容摘要校验。** 外部文件的内容本来就是正确的，因此只验证哈希抓不住它。

最终实现的 Linux 分支先打开可信 checkout 根目录，再逐层从已打开的父目录文件描述符定位下一层。中间组件要求是目录并使用 `O_NOFOLLOW`，最终文件也拒绝符号链接；随后用 `fstat` 检查实际打开的文件类型、链接数和大小，并在读取前后比较设备号、inode 和大小，最后另行验证文件摘要。这样，路径名即使被换成指向外部目录的符号链接，打开操作仍相对于已经固定的父目录句柄进行。

为什么没有直接调用 `openat2`？当前实现只使用 Node 内置的 `fs` API，`fs.openSync` 没有暴露 Linux `openat2` 的 `RESOLVE_*` 路径解析参数。直接调用需要额外的 native binding 或系统调用封装；这份实现选择了 Linux 的 `/proc/self/fd` 目录句柄方案，并在没有可用 procfs 时失败关闭。这是实现取舍，不是说它在所有威胁模型下都优于 `openat2`。已验证的范围依赖可信 checkout 根目录和可用 procfs，不涵盖任意同 UID 或主机级对手。[Linux `openat2(2)` 文档](https://man7.org/linux/man-pages/man2/openat2.2.html)解释了 `O_NOFOLLOW` 只影响最后一个路径组件，而 `RESOLVE_NO_SYMLINKS` 可以约束整条路径的符号链接解析。

这类并发写入防护也不该机械复制到每个构建脚本里。若构建环境能保证 checkout 不可变、校验期间没有并发写入者，可以把这项威胁排除在适用范围之外；关键是明确并落实前提，而不是默认假设成立。

## 第二轮：哈希没错，内容也可能不可见

第一轮修复后，测试增加到 **10/10 通过**。独立审查继续追问另外两个问题。

第三个反例针对减少动态效果时的最终画面。这个 SVG 用多个内容帧逐步呈现终端输出；用户开启 reduced motion 后，动画可以停止，但最终结果仍然必须可见。

基础规则让所有帧默认透明：`.f{opacity:0;...}`。正常动画负责逐步揭示它们；如果 reduced-motion 规则只停掉动画，却没有显示最终帧，结果就会空白。下面是省略无关动画声明后的示意：

```css
/* 基础规则：所有 .f 帧默认透明 */
.f { opacity: 0; }

@media (prefers-reduced-motion: reduce) {
  #f0 { animation: none; opacity: 1; } /* 空的首帧被显示 */
  #f3 { animation: none; }            /* 仍继承 opacity: 0，最终帧隐藏 */
}
```

测试不只是改 CSS 后看哈希检查会不会报警。它还重新计算 SVG 的完整 SHA-256、字节数、内容寻址文件名和 canonical manifest，确保外围校验仍然合法，真正被测的是校验器有没有理解“最终结果必须看得见”这条契约。

[MDN 对 `prefers-reduced-motion` 的说明](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/%40media/prefers-reduced-motion)是检测用户是否偏好减少非必要动态效果。用户只是说“别晃了”，不是让你关灯；停止动画不能顺手把最终内容也藏起来。

第四个反例来自 Markdown。校验器需要确认文章里有一张可见的终端演示图片，但旧实现用正则计数 `<img>` 标签，没有排除 HTML 注释。把唯一图片包进注释：

```html
<!-- <img src="/assets/terminal-demo.svg"
     alt="Terminal demo" width="356" height="137" /> -->
```

标签文字还在，页面却不会显示图片；其他合法 marker 和 caption 仍然可以保留。旧逻辑仍把它计为一张图。[CommonMark 0.31.2 的 HTML block 规则](https://spec.commonmark.org/0.31.2/#html-blocks)明确规定，注释块里的标签不会成为可见图片。问题在于校验器把“源文本出现标签”误当成“页面存在可见元素”。

<figure class="ai-review-counterexample not-prose" lang="zh-cn">
<picture><source media="(prefers-reduced-motion: reduce) and (max-width: 640px)" srcset="/images/posts/vibe-coding-ai-code-review-evidence-gates/ai-review-counterexample.mobile.still.zh-cn.svg" type="image/svg+xml"><source media="(prefers-reduced-motion: reduce)" srcset="/images/posts/vibe-coding-ai-code-review-evidence-gates/ai-review-counterexample.still.zh-cn.svg" type="image/svg+xml"><source media="(max-width: 640px)" srcset="/images/posts/vibe-coding-ai-code-review-evidence-gates/ai-review-counterexample.mobile.zh-cn.svg" type="image/svg+xml"><img src="/images/posts/vibe-coding-ai-code-review-evidence-gates/ai-review-counterexample.zh-cn.svg" alt="首轮修复后的 10/10 测试通过后，独立审查仍发现 HTML 注释内的图片被误计为可见图片；最终 12/12 通过，四个独立探针被拒绝。" width="1200" height="700" loading="lazy" decoding="async" style="display:block;width:100%;height:auto"></picture>
<figcaption style="font-size:0.8rem;line-height:1.5;opacity:.78;margin-top:.35rem">测试全绿也会漏掉反例：注释里的图片标签不等于页面可见图片。</figcaption>
</figure>

## 把测试结果绑定到精确版本

把几个版本放在一起，才看得清每个数字证明了什么。

| 代码版本 | 主测试套件结果 | 随后的独立审查 |
|---|---|---|
| 原始版本 | 8/8 通过 | SEC-01、SEC-02 两类错误接受 |
| 第一轮修复版 | 10/10 通过 | SEC-03、SEC-04 两类错误接受 |
| 最终离线验收版 | 12/12 顶层测试通过 | 四个独立攻击探针另行执行，全部拒绝 |

最终版本的 12 个顶层测试中，包含 SEC-01 至 SEC-04 对应的回归测试组。独立审查者还使用另一份审查脚本，额外执行了四个攻击探针；这四次独立复验**不计入** 12/12。它们是两组互相补充的证据，不应相加写成 16/16，也不能把四个独立探针说成主测试套件的全部覆盖。

另有三个 JavaScript 文件（校验器模块、测试文件和命令行入口）通过了 `node --check`。这是语法解析检查，不能替代功能测试或安全探针。

还有一个过程细节值得保留：后两类回归测试的提交早于源码修复，但完整测试套件没有在那个“只有新测试、还没改源码”的提交上运行。因此，没有真实的 **10/12 RED** 运行回执。可以核对的是：独立审查在旧版本的完整校验器中复现了错误接受，最终固定版本的主测试套件和额外的独立攻击探针都通过了。

这也是为什么独立 Review 不能只看开发者贴出的绿色结果。审查者需要重新核对目标 commit 和测试文件，自己重跑关键正例与负例；如果代码版本变了，旧版本的结论不能自动继承到新版本。

正确性有了足够证据后，才轮到复杂度审查。逐个追踪 helper、分支、配置和调用点，确认它们保护什么不变量、是否重复实现、有没有实际消费者，再判断能否安全简化。**代码短不等于简单；删掉一个看似啰嗦的路径保护，也可能把已经关闭的竞态重新带回来。**


<!-- vxi-evidence-panel: ai-code-review-counterexamples -->

站内另外三篇文章分别讨论了相邻问题：[Mutation Testing](/posts/ai-generated-tests-mutation-testing/) 检查测试能否抓住人为注入的行为变化；[OpenQodex 的 pre-push 审阅失败边界](/posts/openqodex-prepush-review-fail-open/) 分析扫描、独立审阅与推送门禁的区别；[Diffle 的评论锚点](/posts/diffle-git-diff-comment-anchoring/) 讨论代码变化后审阅意见是否仍指向正确位置。本文的重点则是：实现本身究竟接受了什么、拒绝了什么。

## 从测试通过到生产放行，还差什么

最终独立复核覆盖的是 **Node.js v22.16.0、Linux x64 下的离线校验脚本**：主测试套件 12/12 通过，额外四个独立攻击探针全部被拒绝，合法 SVG、manifest 和八语言绑定正例通过；三个 JavaScript 文件也都通过了 `node --check`。

这足以支持一个有边界的结论：在上述环境和固定校验规则下，四类已知错误接受得到了修复。它仍不能单独证明整个博客可以发布到生产：原生 macOS 上的恶意并发文件系统写入尚未验证，整个站点的构建和最终页面运行验收也不在这次离线测试范围内。

真实项目的放行线应随改动风险分层：单元测试检查被覆盖的行为，静态检查寻找特定风险模式，构建和集成测试确认模块能否正确组合，目标平台与运行态验收则检查部署环境中的关键边界。只要一个关键风险边界仍未验证，就不能把局部通过写成整体生产就绪。

我真正想保留的结论很简单：**先用契约和反例找出最值得怀疑的行为，再让精确版本上的测试和独立证据决定下一步该深读哪里。** 这样做不是取消人工判断，而是让 Review 的结论从“看起来没问题”变成有证据、有范围、能复核的放行决定。

## Verification context

- Level: first-party
- Verified: 2026-10-25T00:00:00.000Z
- Environment: Node.js v22.16.0 / Linux x64
- Evidence mode: mixed
- Public repository: https://github.com/vxime/ai-code-review-counterexamples
- Evidence commit: 74d9f12331ef4d015bf882a6e09d66e59434368b
- Evidence receipt: https://vxi.me/evidence/vibe-coding-ai-code-review-evidence-gates.json
