# 生成式改动审查

Source: https://codewiki.com/zh/ai-era/generated-change-review/

> - **what**: 生成式改动审查要检验补丁在整个代码库中的行为，而不是判断每个编辑片段单独看是否合理。
> - **trap**: 生成的测试即使通过，也可能遗漏过期调用方、假值与空输入、共享状态变化、被删除的检查，以及模型上下文之外的文件。
> - **fix**: 重建契约，追踪每个受影响边界，用反例挑战实现，并以独立证据作为批准依据。

## 是什么，为什么存在

生成式改动审查，是把 AI 生成的补丁当作运行中代码库的一次变化来检查。你先对照补丁与任务，再沿调用方、数据、状态、配置、测试和运维路径追踪影响。审查单位是差异所改变的行为，不是生成补丁的文字，也不是单个文件。

生成式补丁经常局部看起来很可信。一个函数可以语法正确、命名整洁，也有新的正常路径测试，但调用方仍然期待旧返回类型。模型优化了眼前片段，审查者必须补回外围系统。

先从 API 契约（API contract）入手：接受的输入、返回值、抛出的错误、修改、顺序、时序和副作用。类型签名只能表达契约的一部分。`number` 没有说明零是否有意义，`Promise` 也没有说明失败是拒绝还是 `{ ok: false }`。

代码库约定也是契约。服务可能要求两次写入放在同一事务中，要求分页顺序稳定，要求在架构边界（architecture boundary）使用特定错误类，或要求生成文件必须由源文件刷新。忽视这些规则的代码即使通过孤立的单元测试，仍然可能不符合代码库要求。

只要工具修改了代码、测试、配置、模式、依赖或构建文件，就应审查生成式改动。这套方法对多文件补丁、公共接口、持久化数据、授权、并发和清理尤其重要。小补丁也要用较小规模回答同样的问题，因为一个条件变化仍可能颠倒原有保证。

审查者不是要证明生成代码格外不可靠。人工补丁也会产生同类缺陷。生成改变了先验条件：一致格式和自信解释的成本很低，因此视觉上的完善比过去更难证明作者真正理解了代码。

批准意味着剩余风险已被理解并且与收益相称，而不是排除了每个可想象的缺陷。有效的审查会明确证据和剩余不确定性。若缺少必要环境、迁移演练或领域决策，就记录缺口，不要把缺少证据改写成信心。

## 工作原理

审查从意图走向影响，再走向证据。每个阶段分别缩小一种不确定性：什么应该改变、变化会传播到哪里、哪些行为可能损坏，以及哪些观察支持最终决定。直接跳到运行测试会丢掉选择正确测试所需的推理。

### 确定基线与任务契约

解释差异前，先确定确切的基线提交或工作树状态。列出每个修改、新增、删除、重命名和生成的路径，包括锁文件与快照。若改动列表与授权范围不符，应先停止并解释额外路径，再评判实现细节。

把请求改写成可观察的验收条件。把必要行为与实现建议分开，并明确非目标。像“支持可选限制值”这样的请求还不完整，必须先决定省略、零、负数、小数和超大限制值分别表示什么。

应当对照请求检查实现，而不是对照模型摘要。摘要只是导航辅助，不是证据：它可能遗漏被删除的守卫，也可能描述补丁并未实现的预期行为。阅读从基线到最终状态的实际差异，包括删除内容和看似机械的修改。

### 阅读完整改动，而非孤立差异块

展开每个差异块，直到理解所在函数、输入、状态、错误处理和清理逻辑。然后阅读整个修改文件，找出差异视图隐藏的导入、模块初始化、导出和约定。一行修改也可能改变几十行之外建立的控制流。

讨论风格前，先给每项语义修改分类。实用类别包括契约、控制流、数据转换、状态修改、持久化、权限、并发、可观测性、依赖、配置和测试。这份清单会暴露被包装在合理“重构”中的无关行为。

格式化和重命名可能遮住语义变化。如果代码库工具支持，可以检查忽略空白的差异，但批准前要回到普通差异。修改后的注释、字符串、配置，以及空白过滤器可能隐藏的删除内容，仍然需要检查。

### 审查缺失内容与非代码表面

消失的内容可能比新增内容更重要。寻找被删除的验证、日志、指标、清理、重试、授权和断言。询问每处删除由什么新行为替代；“新辅助函数会处理”必须有一条真正到达该函数的路径。

生成产物需要双向审查。检查由人维护的源文件和生成输出，运行代码库自己的生成器，并确认干净重跑不会产生更多差异。手工编辑产物可能在本地通过，却在下次构建时消失。

配置与依赖变化可以在没有明显调用点的情况下改变行为。检查默认值、环境专用覆盖、脚本、锁文件解析和运行时要求。源码若使用了新 API，而声明的运行时仍允许旧版本，这项改动就不完整。

继续之前，要核对这些安静的表面：

- 被删除的分支与断言是否不再守护行为。
- 重命名后，旧字符串是否仍留在注册或配置中。
- 生成文件的源文件或再生成命令是否没有变化。
- 锁文件、构建脚本和默认值是否在主要实现之外改变。

### 构建影响锥

对每个改变的定义，查找直接调用方、再导出、适配器、实现、测试和配置。继续跨越边界，直到契约被转换为稳定的外部行为或存储表示。这个可达集合就是改动的影响锥。

同时使用语义搜索与文本搜索。语言服务器引用可以发现静态连接的使用方；文本搜索可以发现反射、依赖注入键、路由、序列化器、shell 调用、夹具和文档示例。在动态系统中，任何一种搜索都无法单独证明完整性。

两个方向都要追踪。向上游询问谁提供每项输入，以及此前已经完成哪些验证。向下游询问谁观察返回值、异常、修改、发出的事件、数据库写入、指标或顺序。

边界表能直接显出不匹配：

| 边界 | 之前 | 之后 | 已检查使用方 |
| --- | --- | --- | --- |
| 输入 | 省略限制值表示默认值 | 接受零 | HTTP 适配器与 CLI |
| 返回 | 布尔值 | 结果对象 | 结账逻辑与测试 |
| 状态 | 保留输入 | 就地排序输入 | 审计与缓存 |
| 失败 | 抛出 `StockError` | 返回 `{ ok: false }` | 错误中间件 |

任何无法解释的单元格都表示审查尚未完成。“未找到调用方”只是需要说明搜索方法的结果，不是保证。公共包和供外部使用的模式可能在代码库之外还有调用方。

### 恢复不变量并构造反例

不变量是所有受支持操作都必须保持的性质。类不变量（class invariant）可能要求余额永不为负；代码库不变量可能要求每个租户查询都带租户标识。先写下这些性质，再寻找单个缺陷。

把每个变化的条件转换成行为矩阵。包含边界本身、边界两侧各一个值，以及有意义时的空值、缺失值、重复值、畸形值、最大值和权限拒绝。对有状态代码，还要加入重复调用、交错所有者、重试、部分失败和取消。

使用能区分预期契约和生成实现的反例。如果两者产生相同输出，测试能教给你的东西很少。零能区分 `limit ?? 20` 与 `limit || 20`；失败的预留操作能区分检查 `result.ok` 与检查结果对象是否为真。

现有行为可能没有文档。特征测试（characterization test）会在补丁改变行为前记录基线的真实表现。这类测试并不宣称旧行为最理想，而是迫使审查者指出有意的破坏性变更（breaking change），避免接受意外变化。

### 让验证与风险匹配

先运行能够复现变化行为的最小检查，再逐步扩大范围。常见顺序是聚焦回归测试、受影响包测试、类型检查与 lint、集成或契约测试、构建，最后是整个代码库的检查。正确集合来自影响锥，而不是一份通用命令清单。

像检查生产代码一样仔细检查测试。生成的测试可能复述实现、削弱断言、不加解释地更新快照、模拟掉损坏的边界，或悄悄用正常路径替换回归测试。只有当测试判据来自契约时，它才算独立证据。

记录确切命令、工作目录、运行时、退出状态和有意义的输出。注明跳过、不稳定、截断、缓存或无法运行的检查。缺少这些上下文的“测试通过”无法复现，也可能指向错误的包，甚至根本没有实际执行。

静态分析与运行时测试回答不同问题。类型检查能找出过期的强类型调用方，却找不到类型合法的零值错误。集成测试可以练习接线，但仍可能遗漏罕见错误路径；选择路径仍然需要人工推理。

### 作出决定并沟通

按后果与确信程度排列发现。正确性、数据丢失、安全、隐私、兼容性和运维危险优先于命名偏好。不要把库存负数埋在十条次要格式评论下面。

有效的发现应包含位置、违反的契约、触发输入或顺序、观察到的后果，以及最小可接受修复方向。“这里看起来有风险”很难执行。“在 `checkout` 中，`{ ok: false }` 为真，因此库存为二时预留三个会存入 `-1`；应根据 `result.ok` 分支，并用 `result.remaining` 更新”则可以测试。

作出一种明确决定：批准、要求修改，或按照团队政策在明确跟踪后续事项的前提下批准。列出已运行命令和剩余缺口。生成式补丁不能批准自己，作者摘要也不能代替审查者负责。

## 示例

这些示例使用 Node 24，把较大代码库中常见的失败模式压缩成小程序。每个程序都能独立运行，你可以先复现观察结果，再把它映射回真实改动中的文件和调用方。

### 正常路径隐藏的假值边界

生成的辅助函数看起来符合惯用写法，默认值与正数限制示例也能工作。契约还允许零表示“不返回产品”，但 `||` 把零当成缺失并换成默认值。

<!-- quick -->

```js
// file: catalog_limit.js
function visibleProducts(products, limit = 20) {
  const published = products.filter((product) => product.published);
  return published.slice(0, limit || 20);
}

const catalog = [
  { sku: "A", published: true },
  { sku: "B", published: false },
  { sku: "C", published: true },
];

console.log("default:", visibleProducts(catalog).map((p) => p.sku));
console.log("one:", visibleProducts(catalog, 1).map((p) => p.sku));
console.log("zero:", visibleProducts(catalog, 0).map((p) => p.sku));
```

```text
default: [ 'A', 'C' ]
one: [ 'A' ]
zero: [ 'A', 'C' ]
```


<!-- /quick -->

前两个输出支持模型可能采用的正常路径推理；第三个输出则否定了约定的零值契约。只有定义负数与小数的验证规则后，才能把 `limit || 20` 换成 `limit ?? 20`。修复还应配一条聚焦零值的测试。

在真实代码库中，还要检查解析限制值的 HTTP 或 CLI 适配器。适配器可能把省略的字符串变成 `undefined`、`0`、`NaN` 或异常。只测试辅助函数会漏掉这个转换边界。

### 返回契约改变，而调用方过期

假设 `reserve` 过去返回布尔值，而生成式重构把它改成信息更丰富的结果对象。新函数内部一致，但未修改的调用方会把每个对象都当成成功。

```js
// file: reservation_contract.js
function reserve(stock, quantity) {
  return {
    ok: quantity <= stock,
    remaining: quantity <= stock ? stock - quantity : stock,
  };
}

function checkout(inventory, sku, quantity) {
  const result = reserve(inventory.get(sku), quantity);

  // 这个调用方仍然期待旧的布尔返回值。
  if (result) {
    inventory.set(sku, inventory.get(sku) - quantity);
    return "reserved";
  }
  return "out of stock";
}

const inventory = new Map([["battery", 2]]);
console.log("status:", checkout(inventory, "battery", 3));
console.log("remaining:", inventory.get("battery"));
```

```text
status: reserved
remaining: -1
```

即使 `reserve` 与 `checkout` 通常位于不同模块，缺陷仍属于跨文件契约。修改调用方，让它根据 `result.ok` 分支并使用 `result.remaining`，然后搜索 `reserve` 的其他使用方。为了向后兼容（backward compatibility），可能需要暂时保留两种返回约定，但这座桥必须有明确的删除计划。

`reserve(2, 3)` 的单元测试可能通过，但结账逻辑仍会破坏库存。回归测试必须跨过变化的边界，同时断言用户可见状态和存储数量。这两项断言分别保护控制流与状态。

### 逃出函数的意外修改

按重量排序运输计划对打包是正确的，但 `.sort()` 还会重排调用方的数组。审查后的版本使用 Node 24 的复制式 `.toSorted()` 方法，以保留输入所有权。

```js
// file: shipment_sort.js
function generatedPlan(lines) {
  return lines.sort((left, right) => left.weight - right.weight);
}

function reviewedPlan(lines) {
  return lines.toSorted((left, right) => left.weight - right.weight);
}

const generatedInput = [
  { sku: "battery", weight: 8 },
  { sku: "cable", weight: 1 },
];
const reviewedInput = structuredClone(generatedInput);

console.log("generated plan:", generatedPlan(generatedInput).map((x) => x.sku));
console.log("generated input:", generatedInput.map((x) => x.sku));
console.log("reviewed plan:", reviewedPlan(reviewedInput).map((x) => x.sku));
console.log("reviewed input:", reviewedInput.map((x) => x.sku));
```

```text
generated plan: [ 'cable', 'battery' ]
generated input: [ 'cable', 'battery' ]
reviewed plan: [ 'cable', 'battery' ]
reviewed input: [ 'battery', 'cable' ]
```

两种实现返回的计划相同，因此只断言返回值无法发现回归。真正能区分它们的断言会在调用后检查输入。它遵循契约中的修改部分，而不只检查返回值部分。

复制不一定是正确修复。如果代码库明确记录了所有权转移，而且数组很大，就地排序可能是有意行为。先审查调用方契约，再根据数据所有权和嵌套方式选择修改、浅复制或更深层复制。

## 陷阱

### 只审查修改行

> **陷阱:** 差异块本身可能正确，却依赖可见上下文之外的导入、不变量、清理块或调用方行为。注意力跟着新增代码移动时，被删除的检查尤其容易漏掉。

**修复：**阅读所在函数与整个文件，检查每处删除，再从改变的符号追踪到调用方和作用。评论实现之前，写出新旧契约。

### 把生成测试当成独立证据

> **陷阱:** 模型可能按照同一个错误假设同时修改代码和测试。它可能断言刚更新的快照、复述分支条件，或删除原本会失败的断言。

**修复：**从需求、基线行为、协议文档或领域判据推导预期结果。断言发生变化时先审查测试差异，并增加一条在生成式解释下会失败的反例。

### 只搜索强类型引用

> **陷阱:** 引用搜索会漏掉字符串键、路由、序列化器、反射、模板、shell 脚本、夹具和外部使用方。因此，即使语言服务器没有发现问题，运行时调用方仍可能过期。

**修复：**把语义引用与符号名、导出字符串、路由名、模式字段和错误码的文本搜索结合。说明哪些动态或外部使用方无法搜索，并尽可能用契约测试覆盖其边界。

### 接受行为变化周围的重构噪声

> **陷阱:** 生成式补丁常把重命名、重排、格式化与行为变化混在一起。改动量会让单字符条件变化或守卫删除更难看见，也更难回退。

**修复：**要求把保持行为的清理与语义修改拆成不同提交或补丁。无法拆分时，应规范化空白、给语义修改分类，并要求明确解释每项行为变化。

### 把绿色命令当作完整证明

> **陷阱:** 一个通过的测试集可能排除受影响包、复用缓存、跳过集成测试，或只练习默认输入。它无法说明未测试的迁移、权限、延迟或运维清理是否正确。

**修复：**把每项已识别风险映射到具体检查，并保留命令证据。根据影响锥组合类型检查、lint、聚焦测试、受影响集成测试、构建、迁移演练和人工检查。

### 先把审查精力花在风格上

> **陷阱:** 大量命名偏好可能遮住正确性或安全缺陷，并促成表面化的补丁反复修改。生成代码可以满足风格评论，却不修复被违反的契约。

**修复：**按风险顺序审查：范围与意图、契约与不变量、安全与数据作用、边界情况、证据，最后才是可维护性。标明可选建议，不要让它们与阻塞问题竞争。

<!-- deep -->

## 从差异块到代码库行为

生成式改动审查的难点，是证明有限检查覆盖了面临风险的行为。你无法阅读大型代码库的每条执行路径。你可以根据变化的契约、可达作用、不变量和证据，建立一条有依据的审查边界。

### 把补丁建模为变更图

把每个改变的符号、模式、配置键、依赖或持久化表示看成节点。为调用、导入、数据流、注册、序列化、共享状态、部署和代码生成增加有向边。这个图只需存在于推理中；审查里提交一张短表往往比图更实用。

从差异直接触及的节点出发，每次沿一条边展开。当分支到达契约仍然成立的未修改边界，或到达风险已明确记录的外部边界时，才停止。“测试通过”不是停止规则，因为它没有说明测试经过了图中的哪些边。

不同边需要不同发现方法。静态调用适合语言工具，事件注册适合文本搜索与运行时测试，持久化模式适合迁移和夹具检查，生成产物适合检查源文件与再生成命令。在边旁记录方法，让缺失覆盖清晰可见。

这个图还能显出耦合改动。如果返回类型节点改变，调用方与测试节点通常也应改变，除非能证明适配器隔离了变化。如果模式改变，却没有迁移、兼容适配器或版本化读取器的变化，那么这种缺失本身就是审查发现。

### 区分契约变化与实现变化

契约变化会改变编辑单元之外可观察的行为。实现变化会改变达成同一契约的方式。这一区分同时决定兼容性分析方式和所需证据强度。

| 变化 | 审查问题 | 典型证据 |
| --- | --- | --- |
| 输入域 | 新接受或拒绝哪些值？ | 边界矩阵与适配器测试 |
| 输出形状 | 哪些使用方解析它或据此分支？ | 调用点审计与契约测试 |
| 修改 | 谁还持有同一对象或状态？ | 别名测试与所有权文档 |
| 失败语义 | 抛出、拒绝、返回、重试还是吞掉？ | 负向路径集成测试 |
| 顺序或时序 | 哪个使用方假设它稳定？ | 确定性序列断言 |
| 仅实现 | 行为真的没有改变吗？ | 特征测试与差异检查 |

生成摘要经常因为没有导出名称改变，就把变化标成“内部”。当时序、SQL 查询、发出事件、缓存键或修改可以被观察时，这个结论并不安全。审查可观察性，而不是可见性修饰符。

如果改动有意改变契约，就明确兼容策略。可选方案包括代码库范围的原子式修改、版本化端点、适配期、功能开关、双读取器或分阶段数据迁移。每种策略都需要自己的删除条件和测试。

### 在高风险边界使用证明义务

证明义务是批准前必须由审查支持的具体陈述。对授权代码，它可能是“每个查询都受已认证租户约束”。对库存，它可能是“失败的预留不会改变库存”。义务同时指导检查与测试。

从资产和失败后果推导义务，不要只看修改文件名。认证、资金、持久化数据、并发、不可信输入或不可逆操作附近的变化需要更强证据。位于这些边界的两行生成式补丁，可能比孤立格式化器的一百行更值得审查。

为每项义务记录支持来源与观察结果。来源可以是需求、接口定义、现有测试、模式约束或负责人决定。观察可以是测试输出、静态分析结果、迁移演练或人工路径追踪。

没有支持的义务仍是警告或阻塞项。不要把不存在失败测试当成证明。正确做法可能是缩小补丁、增加可观测性、请求领域决策，或在有代表性的环境中测试。

### 判断测试独立性

当测试的预期值来自独立于实现的来源时，证据更强。协议示例、产品规则、过去的生产案例或手工计算的不变量，都比把生成表达式复制进断言更可靠。这就是判据独立性。

修改与状态缺陷需要返回值之外的观察。捕获调用前后的输入，查询持久状态，检查发出的事件，或让两个所有者交错执行。对于错误路径，要同时断言失败信号和不存在禁止的副作用。

面向性质的检查可以覆盖一组输入：库存永不为负，排序保持多重集，重试不会重复幂等写入，序列化可以往返受支持的值。它们补充具体回归测试，而不是取代后者，因为一项性质可能漏掉业务规则。

可行时，在基线上重跑聚焦检查。如果新回归测试在修复前就通过，它无法区分补丁，除非任务是在增加过去没有规定的行为。如果修改后的测试只因判据被重写才失败，就检查是什么需求授权了这次重写。

### 把可维护性当作未来正确性来审查

可维护性发现应把结构与可能发生的失败联系起来，而不是表达个人品味。重复验证可能漂移，无名称布尔值可能在调用方被颠倒，宽泛捕获可能抹掉失败语义，隐藏修改可能违反所有权。应在发现中解释这种机制。

生成代码倾向于重复局部可见模式，即使代码库已有中心抽象。接受平行实现前，先搜索现有验证器、适配器、错误类型、事务辅助函数或测试夹具。只有抽象契约真正匹配时，复用才有价值；外表相似并不够。

不要为了一个小用例要求新抽象。生成式补丁既可能重复，也可能过度概括。选择能够清楚表达所有权与不变量的最小设计，等出现第二个真实用例，再添加没有使用方的扩展点。

注释与命名应保存边界情况存在的原因。复述 `if (limit === 0)` 的注释价值很小；说明零来自公共分页契约的注释，可以保护代码不被未来简化。只有文档承载了表达式中没有的信息时，才要求补文档。

### 用风险账本结束审查

简洁的风险账本能让推理可审计：

| 风险 | 触发条件 | 后果 | 证据 | 状态 |
| --- | --- | --- | --- | --- |
| 把零当成缺失 | `limit = 0` | 返回多余产品 | 聚焦输出与回归测试 | 已修复 |
| 过期结果调用方 | 预留失败 | 库存为负 | 跨调用方的集成测试 | 已修复 |
| 修改输入别名 | 调用方复用明细 | 审计顺序改变 | 前后对照断言 | 已修复 |
| 外部使用方未知 | 旧布尔契约 | 下游损坏 | 只有发布说明审查 | 未解决 |

账本不能代替代码注释或问题跟踪。它是一份审查产物，把合理的失败模式与具体检查相连，也让开放风险难以藏在长对话中。账本要足够短，才能在补丁改变后及时更新。

修复后重新审查最终差异。一次修复可能引入新文件、削弱测试，或留下失效的兼容代码。批准针对最终代码库状态和记录的证据，而不是之前获得大部分注意力的版本。

<!-- /deep -->

[检查点: ai-era/generated-change-review](https://codewiki.com/zh/ai-era/generated-change-review/#checkpoint)

## 延伸阅读

- [Git 文档：`git diff`](https://git-scm.com/docs/git-diff)
- [GitHub 文档：审查拉取请求中的建议改动](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests)
- [Google 工程实践：如何进行代码审查](https://google.github.io/eng-practices/review/reviewer/)
- [OWASP 代码审查指南](https://owasp.org/www-project-code-review-guide/)
