生成式改动审查

跨文件审查生成式差异,判断正确性、边界情况、可维护性和意外行为。

难度 进阶 时长 标准深度约 15分钟
版本 Node 24
what

生成式改动审查要检验补丁在整个代码库中的行为,而不是判断每个编辑片段单独看是否合理。

trap

生成的测试即使通过,也可能遗漏过期调用方、假值与空输入、共享状态变化、被删除的检查,以及模型上下文之外的文件。

fix

重建契约,追踪每个受影响边界,用反例挑战实现,并以独立证据作为批准依据。

是什么,为什么存在

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

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

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

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

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

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

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

工作原理

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

确定基线与任务契约

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

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

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

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

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

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

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

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

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

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

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

继续之前,要核对这些安静的表面:

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

构建影响锥

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

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

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

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

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

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

恢复不变量并构造反例

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

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

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

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

让验证与风险匹配

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

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

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

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

作出决定并沟通

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

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

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

示例

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

正常路径隐藏的假值边界

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

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));
default: [ 'A', 'C' ]
one: [ 'A' ]
zero: [ 'A', 'C' ]

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

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

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

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

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"));
status: reserved
remaining: -1

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

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

逃出函数的意外修改

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

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));
generated plan: [ 'cable', 'battery' ]
generated input: [ 'cable', 'battery' ]
reviewed plan: [ 'cable', 'battery' ]
reviewed input: [ 'battery', 'cable' ]

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

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

陷阱

只审查修改行

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

把生成测试当成独立证据

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

只搜索强类型引用

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

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

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

把绿色命令当作完整证明

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

先把审查精力花在风格上

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

深入 从差异块到代码库行为

从差异块到代码库行为

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

把补丁建模为变更图

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

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

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

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

区分契约变化与实现变化

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

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

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

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

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

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

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

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

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

判断测试独立性

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

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

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

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

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

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

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

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

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

用风险账本结束审查

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

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

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

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

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?