改一处漏三处: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,只在编译单元内有效。它找不到:
- 跨语言调用(前端
fetch('/api/payment')→ 后端 handler) - 配置文件里的字符串引用
- 动态调用(
obj[methodName]()) - 通过接口间接调用的实现方
grep 更直接——搜字面字符串。但 grep 搜 processPayment 返回 47 个结果,真正的生产调用只有 5 个。剩下 42 个是注释、测试 mock、字符串常量、名字相近的其他函数(processPaymentLegacy、processPaymentV2)。你得一个个排除。
Cursor 用的 Embedding 向量检索更聪明——但它回答的是"哪些代码语义相似",不是"谁在调用这个函数"。搜 processPayment 的调用方,它会给你 refundPayment、cancelPayment——因为名字里都有 payment。真正在调用 processPayment 的 OrderService.checkout(),因为名字和 payment 没关系,反而排在后面。
三种方式都在搜"像"的代码,没有一种在回答"谁在调用它"。
调用图:精确回答"改了这里还影响哪里"
调用图不搜文本,也不算相似度。它通过语法解析(tree-sitter)直接提取代码之间的结构关系——谁调用了谁、谁实现了哪个接口、谁依赖了谁。

wescode 的 CKG(代码知识图谱)建立六种结构关系:
| 关系 | 含义 | 例子 |
|---|---|---|
| CALLS | A 调用了 B | checkout() 调用 processPayment() |
| IMPLEMENTS | A 实现了接口 B | StripeGateway 实现 PaymentGateway |
| EMBEDS/EXTENDS | A 嵌入或继承 B | PremiumOrder 继承 Order |
| IMPORTS | 包 A 依赖包 B | order/ 导入 payment/ |
| DEFINES | 文件 A 定义符号 B | gateway.ts 定义 processPayment |
| OVERRIDES | A 覆盖父类方法 B | StripeGateway.process() 覆盖基类 |
CKG 的索引过程:10-Pass 管线
CKG 不是一遍扫完就建好的——它用 10-Pass 管线逐层构建,每一层比上一层"理解"更深:

前几遍是基础工作:扫描文件、提取符号定义(函数、类、接口)、建立文件级依赖。中间几遍开始建调用边——"谁调用了谁"。后面几遍处理更复杂的关系:接口实现关系(IMPLEMENTS)、多态分派(同名方法在不同类上的多个实现)、跨文件的间接调用链。
最后一遍做消歧:如果 store.Save 这个调用在项目里有三个不同包的 Save 方法,CKG 会尝试通过 import 路径和类型上下文确定调的是哪一个。确定不了的,标记为 ambiguous 并保留所有候选——宁可多列不漏掉。
关键的是: 这些关系在你打开项目时一次性构建完毕,存在本地 SQLite 里。之后每次保存文件,增量更新只需要几百毫秒。
同一个任务:5 个调用方的完整追踪

回到开头的例子——你要改 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 References | Cursor(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)天然找不全调用方。而调用图通过语法解析直接建出完整的关系网络,从根上解决了这个问题。
你可以做什么
不管用什么工具,改完之后多做一步:
- 搜一遍函数名——不只是
Find References,也包括 grep 搜字符串(配置文件里的引用 Find References 找不到) - 想一想接口——如果你改的函数实现了某个接口,搜那个接口的其他实现和调用方
- 跑的不只是单元测试——如果有集成测试或 E2E 测试,改完跑一遍。单元测试只能覆盖它写到的路径
这三步在 AI 出现之前也是好习惯。只是 AI 改代码的速度让"跳过这三步"的诱惑变大了——AI 30 秒改完 8 个文件,你真的会花 35 分钟做上面三步吗?
更现实的做法是让工具替你做。 wescode 的 CKG 在你改代码之前就把影响链算好了——35 分钟的人工排查变成 2 秒的图遍历。你要做的不是自己搜,而是确认 CKG 列出来的影响点对不对。