改一处漏三处:AI 改代码最常翻的车

wescode · 2026-10-06 · 影响范围 / AI编程 / 痛点

利益声明:本文作者参与了 wescode 的开发。文中涉及 wescode 的技术描述基于内部测试数据;涉及其他工具的描述基于各家公开文档和社区反馈。

你让 AI 帮你把一个函数的参数从 string 改成 string | null。

它改了函数定义。改了两个调用方。TypeScript 编译通过。测试全绿。

你合进了主干。

三天后,QA 提了一个 bug:一个页面在特定条件下白屏。

追到最后你发现——有个组件通过一个 hook 间接调用了那个函数,AI 没看见它。那个 hook 没做 null 处理,拿到 null 直接丢给了 DOM 渲染,白屏。

AI 改了 2 处。实际影响 5 处。漏了 3 处。


这不是 AI 不够聪明

你可能第一反应是"AI 水平不行"。但你自己做这件事,一样可能漏。

区别在于:

你改代码的速度是一小时改 3 个文件。 中间你会习惯性地搜一下 Find References,翻一翻调用方,脑子里过一遍"还有哪会用到这个"。

AI 改代码的速度是 30 秒改 8 个文件。 它改完就告诉你"done"。它不会习惯性地多搜一遍——因为它不知道自己可能漏了。

这就是问题:AI 改得越快,漏改的后果扩散得也越快。 如果你一下午只改了 3 个函数,漏一个影响有限。如果 AI 半小时改了 40 个函数,漏掉的那几个可能散落在你不知道的角落。


漏改的三种形式

第一种:接口调用——名字对不上

代码里写的不是函数名,而是接口方法名。看这个例子:

// payment-gateway.ts
interface PaymentGateway {
  process(order: Order): Promise<Receipt>
}

class StripeGateway implements PaymentGateway {
  async process(order: Order): Promise<Receipt> {
    // 真正的实现在这里
    return stripe.charges.create({ amount: order.total })
  }
}

// checkout.ts — 调用方
async function checkout(gateway: PaymentGateway, order: Order) {
  const receipt = await gateway.process(order)  // 调的是接口方法
  await sendEmail(receipt)
}

AI(和 grep)搜 StripeGateway 或者搜 processPayment 都搜不到 checkout 这个调用方。因为 checkout 调的是 PaymentGateway.process,不是 StripeGateway 的具体方法名。

你改了 StripeGateway.process 的签名,checkout 不会出现在搜索结果里——直到运行时才炸。

第二种:高阶函数传递——函数被当变量传了

// actions.ts
import { validateOrder } from './validate'
import { processPayment } from './payment'
import { sendReceipt } from './receipt'

// 三个函数被放进一个数组
const orderPipeline = [validateOrder, processPayment, sendReceipt]

export async function executeOrder(order: Order) {
  for (const step of orderPipeline) {
    await step(order)  // 依次执行每个步骤
  }
}

搜 processPayment 能找到 import 那一行——但你知道 executeOrder 里的 step(order) 其实也在调用 processPayment 吗?

更隐蔽的是:改了 processPayment 的签名(比如加了第二个参数),validateOrder 和 sendReceipt 的签名也得一起改——否则 step(order) 对其中一个函数传的参数就不够。类型系统可能报错,也可能不报错(如果新参数是可选的)。

第三种:配置文件引用——字符串里的定时炸弹

# cron.yaml
jobs:
  - name: daily-settle
    handler: processPayment
    schedule: "0 2 * * *"

Cron Job 通过字符串名引用了这个函数。你改了函数名或签名,这个 YAML 文件不会报错——直到凌晨两点定时任务跑失败,第二天早上客户追过来才发现。

这三种漏改形式有个共性: 用文本搜索找不全。接口调用的函数名对不上,高阶函数传递的调用关系是隐式的,配置文件引用完全脱离了类型系统。


grep 和 Find References 为什么不够

VS Code 的 Find All References 基于 TypeScript Language Server,只在编译单元内有效。它找不到:

grep 更直接——搜字面字符串。但 grep 搜 processPayment 返回 47 个结果,真正的生产调用只有 5 个。剩下 42 个是注释、测试 mock、字符串常量、名字相近的其他函数(processPaymentLegacy、processPaymentV2)。你得一个个排除。

Cursor 用的 Embedding 向量检索更聪明——但它回答的是"哪些代码语义相似",不是"谁在调用这个函数"。搜 processPayment 的调用方,它会给你 refundPayment、cancelPayment——因为名字里都有 payment。真正在调用 processPayment 的 OrderService.checkout(),因为名字和 payment 没关系,反而排在后面。

三种方式都在搜"像"的代码,没有一种在回答"谁在调用它"。


调用图:精确回答"改了这里还影响哪里"

调用图不搜文本,也不算相似度。它通过语法解析(tree-sitter)直接提取代码之间的结构关系——谁调用了谁、谁实现了哪个接口、谁依赖了谁。

CKG 代码知识图谱

wescode 的 CKG(代码知识图谱)建立六种结构关系:

关系含义例子
CALLSA 调用了 Bcheckout() 调用 processPayment()
IMPLEMENTSA 实现了接口 BStripeGateway 实现 PaymentGateway
EMBEDS/EXTENDSA 嵌入或继承 BPremiumOrder 继承 Order
IMPORTS包 A 依赖包 Border/ 导入 payment/
DEFINES文件 A 定义符号 Bgateway.ts 定义 processPayment
OVERRIDESA 覆盖父类方法 BStripeGateway.process() 覆盖基类

CKG 的索引过程:10-Pass 管线

CKG 不是一遍扫完就建好的——它用 10-Pass 管线逐层构建,每一层比上一层"理解"更深:

CKG 10-Pass 索引管线

前几遍是基础工作:扫描文件、提取符号定义(函数、类、接口)、建立文件级依赖。中间几遍开始建调用边——"谁调用了谁"。后面几遍处理更复杂的关系:接口实现关系(IMPLEMENTS)、多态分派(同名方法在不同类上的多个实现)、跨文件的间接调用链。

最后一遍做消歧:如果 store.Save 这个调用在项目里有三个不同包的 Save 方法,CKG 会尝试通过 import 路径和类型上下文确定调的是哪一个。确定不了的,标记为 ambiguous 并保留所有候选——宁可多列不漏掉。

关键的是: 这些关系在你打开项目时一次性构建完毕,存在本地 SQLite 里。之后每次保存文件,增量更新只需要几百毫秒。

同一个任务:5 个调用方的完整追踪

安全重构:CKG 提供完整影响面

回到开头的例子——你要改 processPayment 的参数类型从 string 到 string | null。CKG 给出的完整影响链:

调用方 1:直接调用

// payment-controller.ts
async function handlePayment(req: Request) {
  const orderId = req.params.id  // 这里原来假设是 string
  await processPayment(orderId)  // 现在可能是 null,需要处理
}

调用方 2:通过接口间接调用

// order-service.ts
class OrderService {
  constructor(private gateway: PaymentGateway) {}
  
  async processOrder(order: Order) {
    await this.gateway.process(order)  // 通过接口调用
  }
}

调用方 3:高阶函数传递

// pipeline.ts
const actions = [validateOrder, processPayment, sendReceipt]
// processPayment 被当变量传递

调用方 4:Hook 封装(开头白屏的那个)

// usePayment.ts
function usePayment() {
  const result = processPayment(currentOrderId)
  return <div>{result.receiptNumber}</div>  // null 时白屏!
}

调用方 5:定时任务引用

// settlement.ts
const settlementActions = {
  'daily-settle': processPayment,  // 数组/对象引用
}

没有 CKG 的做法:

1. grep -rn processPayment → 47 个结果
2. 手动排除注释、mock、字符串常量 → 剩 12 个
3. 逐个确认哪些是调用方、哪些是定义 → 15 分钟
4. 发现有些调用通过接口 → 搜 PaymentGateway → 10 分钟
5. 追踪 usePaymentHook → 5 分钟
6. 检查 cron.yaml 配置 → 5 分钟
7. 总共 35 分钟,仍不确定有没有遗漏

使用 wescode + CKG 的做法:

1. 选中 processPayment → 影响分析 → 2 秒
2. CKG 返回 5 个影响点(含接口间接调用和 Hook)
3. 确认并逐个修改 → 12 分钟

不同工具在这个任务上的表现

代码理解深度对比

同样是"改 processPayment 的签名,找到所有影响方",不同工具的表现差异很大:

维度grep / Find ReferencesCursor(Embedding 检索)Claude Code(grep + read)wescode(CKG 调用图)
直接调用✅ 能找到✅ 能找到✅ 能找到✅ 能找到
接口间接调用不支持(函数名对不上)⚠️ 语义相似才找到不支持(grep 搜不到)支持(IMPLEMENTS 边)
高阶函数传递⚠️ 找到 import 行,不知道在哪被调用⚠️ 取决于上下文窗口⚠️ 需要多轮推理支持(CALLS 边追踪)
配置文件引用✅ grep 能搜字符串不支持(不索引 YAML/JSON)✅ grep 能搜⚠️ 文本搜索兜底
噪音量高(47 结果里有用的 5 个)中(语义相似但不是调用方)中(需要人确认)低(精确的图遍历结果)

核心区别: grep 和 Embedding 在搜"像"的代码,CKG 在回答"谁调用了它"。前者的结果里包含正确答案但也包含大量噪音,后者直接给出精确的调用链。


CKG 的能力边界——它做不到什么

CKG 不是万能的。它基于静态语法分析,有几件事它做不了:

1. 动态派发

const methodName = getMethodFromConfig()  // 运行时才知道调哪个
service[methodName]()  // CKG 不知道这是在调用什么

方法名是运行时计算出来的,语法分析只能看到 service[变量],不知道变量的值是什么。

2. 配置文件引用

前面提到的 cron.yaml 里的 handler: processPayment——CKG 的图是从代码的 AST 建的,YAML 不在 AST 里。不过 wescode 会用文本搜索做兜底:CKG 图遍历完之后,再对函数名做一遍 grep,补上配置文件里的字符串引用。

3. 跨语言 FFI

前端 TypeScript 调后端 Python 的 API(通过 HTTP),CKG 目前不跨语言。它能覆盖的是同一语言内的完整调用链。

4. 运行时反射

Reflect.apply(targetFunction, thisArg, args)  // CKG 看不懂反射调用

好消息是:真实项目里,上面 4 种情况加起来占总调用量的不到 5%(内部测试数据)。CKG 精确覆盖 95%(内部测试数据)的调用关系,剩下的部分用文本搜索兜底——这比 100% 靠 grep 然后手动排除 42 个无关结果好得多。

索引速度

项目规模首次索引增量更新(文件保存后)
1 万行< 2 秒< 50ms
10 万行5-15 秒100-200ms
50 万行30-60 秒200-500ms

以上索引速度为内部测试数据,实际表现因硬件和项目结构而异。

索引构建和更新全在本地完成——不上传代码到任何服务器。你的代码不出你的电脑。


这件事每天都在发生

不是偶尔。根据使用 AI 编程工具的开发者反馈,"修复 Bug 时只改一处漏了其他三处"是最常被提到的痛点之一。

掘金上一篇 Cursor 实战文章,开头就列了这个痛点:

"AI 生成的函数参数总用 any、组件乱放目录、修复 Bug 时只改一处漏了其他三处。"

这不是个别现象。这是当前 AI 编程工具的结构性局限——它们理解代码的方式(向量检索或 grep)天然找不全调用方。而调用图通过语法解析直接建出完整的关系网络,从根上解决了这个问题。


你可以做什么

不管用什么工具,改完之后多做一步:

  1. 搜一遍函数名——不只是 Find References,也包括 grep 搜字符串(配置文件里的引用 Find References 找不到)
  2. 想一想接口——如果你改的函数实现了某个接口,搜那个接口的其他实现和调用方
  3. 跑的不只是单元测试——如果有集成测试或 E2E 测试,改完跑一遍。单元测试只能覆盖它写到的路径

这三步在 AI 出现之前也是好习惯。只是 AI 改代码的速度让"跳过这三步"的诱惑变大了——AI 30 秒改完 8 个文件,你真的会花 35 分钟做上面三步吗?

更现实的做法是让工具替你做。 wescode 的 CKG 在你改代码之前就把影响链算好了——35 分钟的人工排查变成 2 秒的图遍历。你要做的不是自己搜,而是确认 CKG 列出来的影响点对不对。