AI 重构代码库翻车实录:200 行死代码被删,上线后老功能没了

0 阅读5分钟

先看结论:diff 少了 200 行,单测全绿,上线就挂了

上篇结尾我说过要写一个真正的翻车,就是这个。

事情发生在前两周。我们要把一个老模块从 CommonJS 迁移到 ESM,同时顺手调整目录结构。模块不大不小,手敲的话大概两天,我图省事把整个迁移任务丢给了 AI 编码 agent,让它"逐文件迁移并清理无用代码"。

AI 干得很快,效率不低,交了 diff。我 review 的时候注意到一个细节:diff 的净删行数明显大于预期,大概快两百行。 它给我的解释是"这批文件没有被引用,判定为死代码,已清理"。

我觉得有道理。毕竟它跑过死代码检测,单测也全绿。我点了合,上线。

第二天早上,线上一张跑了三年的报表功能开始报错。

现象:机器不跑活了,日志指向一个不存在的文件

报错长这样:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'D:\service\src\handlers\inventory.js' imported from D:\service\src\loader.js
    at async loadHandler (loader.js:18)
    at async Executor.run (executor.js:24)

inventory.js?我第一反应是路径配错了。打开目录一看——这个文件确实存在,checkout 之后在。但 git log 告诉我:这个文件在"那次 ESM 迁移"里被删掉了

我五分钟前还在用它的功能啊。它是怎么被删的?

$ git log --diff-filter=D --oneline src/handlers/
abc1234 refactor: migrate to ESM and remove dead code

把整件事的时间线拉出来,你会看得更清楚:

image.png

排查:找到那 200 行"没人要"的文件,发现它们全都活着

我把 abc1234 这个提交里删掉的文件列表拉出来,对着线上配置逐一看了一遍,后背开始冒汗。

这批文件是配置驱动的。系统里有一个定期任务的调度表,长这样:

// config/jobs.config.js
export const jobs = [
  { type: 'inventory', schedule: '0 3 * * *', handler: 'inventory' },
  { type: 'settlement', schedule: '0 5 * * *', handler: 'settlement' },
];

调度器拿到 handler: 'inventory',再去加载对应的执行文件。加载方式不是静态 import,而是运行时拼路径:

// 为什么看这段:这是整件事的根——handler 不是被 import 进来的,
// 是靠配置里的字符串 key 在运行时动态加载的
async function loadHandler(type) {
  return import(`./handlers/${type}.js`);
}

调用它,删除前后是两个完全不同的世界:

// AI 删除 handlers/inventory.js 之前:
$ node -e "import('./src/loader.js').then(m => m.loadHandler('inventory'))"
[Module: null prototype] { default: [Function: runReconciler] }

// AI 删除之后:
$ node -e "import('./src/loader.js').then(m => m.loadHandler('inventory'))"
Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'D:\service\src\handlers\inventory.js' imported from D:\service\src\loader.js

同一个函数,删除前能加载到 runReconciler,删除后直接抛错。静态分析管不了中间那段运行时拼接,但 Node 真正跑起来的一瞬间,一切现形。

关键就在这。handlers/inventory.js 从头到尾没有任何一个文件静态 import 过它,它只存在于配置的字符串里。AI 跑死代码检测的时候,静态扫描看到的答案是:这个文件没有任何引用,判定 dead code,删除。

把这条链路画出来,你就懂为什么静态分析是瞎的:

  config/jobs.config.js        运行时拼路径              目标文件
  ┌───────────────────┐      ┌────────────────┐     ┌──────────────────┐
  │ handler: 'inventory' │────▶│ import(`./handlers/ │────▶│ ./handlers/      │
  │   (字符串 key)       │      │   ${type}.js`)        │     │   inventory.js   │
  └───────────────────┘      └────────────────┘     └──────────────────┘
         ▲                                                  ▲
         │                                                  │
    配置里的 key 是"活的"                             静态扫描看不见这里
    (它驱动整个加载)                              (没有任何 import 指向它)

静态分析器只认中间的 import 语句,看不到"字符串 key"和"目标文件"之间那条虚线——因为虚线是靠运行时的 type 值连起来的,而工具数不出 type 的所有可能取值。

它在"没人用的文件"和"系统里还在跑的文件"之间,被 AI 划到了前一边,因为在它能看到的世界里,确实没人碰它。

把整个排查过程串起来,从收到报错到定位根因,是这样一条链路:

image.png

根因:AI 判断"有没有用",用的是双筒望远镜,其中一只是瞎的

这次事故回去之后我一直在想:为什么会删?

后来得出结论——AI 判断"这段代码有没有用",本质上是一个静态分析器在做符号引用计数。它数的是"import/export 有没有双向命中"。这套逻辑对正常代码库是有效的,但它有一个结构性盲区:

动态引用。 凡是不靠 import 而靠字符串、反射、配置、约定文件路径触发的引用,静态分析默认看不见。import(\./handlers/${type}.js`)` 在工具眼里只是一个"模糊的表达式",它不知道 type 会有哪些值,自然不知道 target 文件该活着。

这个盲区不是我独创的,knip(v6.32)、ts-prune、ESLint(v10.8)的 no-unused-vars 全都有这道线。只是它们分不清,AI 也分不清,而你一旦让 AI 拿着"清理无用代码"的剑,它就把自己看不见的代码全判了死刑。

把常见的引用方式拉成一张表,静态分析能识别到哪一步,一目了然:

引用方式例子静态分析默认能否识别
静态 importimport x from './a.js'✅ 能
动态 import,固定路径import('./a.js')✅ 能
动态 import,运行时拼路径import(\./handlers/${type}.js`)`❌ 不能(就是我这次踩的)
配置字符串 keyconfig.handler = 'inventory'❌ 不能
反射 / 方法名调用obj[methodName]()❌ 不能
约定文件路径 / 目录扫描fs.readdir(dir) 后按文件名加载❌ 不能

你注意看,凡是"编译期能确定引用关系的",静态分析都能看见;凡是"要等运行时才知道找谁"的,它全都看不见。 这跟 AI 的缺陷是同构的——它们都活在一个"不需要真正跑起来"的世界里。你要是想深挖,参考一下 knip 官方文档 里对"unresolved imports"和动态加载的那几段,坦白讲,它连自己支持哪些模式都写得很克制,这种盲区是根上的,不是补个规则能补完的。

有意思的是,人重构的时候遇到同样的情况,会有完全不同的行为。我 team 里一个老前端改到一个"看起来没人 import"的文件,第一反应是问一句:"这个没人引用,要不要删?"——人会把"我没看懂"当作一个信号。AI 不会,它把"我看不见引用"直接当成了"它确实死了"。这是人跟 AI 在重构场景下最本质的差别。

修复:三步,先止血,再立规矩

事发之后我做了三件事。

第一步,恢复。 git revert abc1234 --no-commit 把被删文件原样恢复,然后只手动保留真正安全的迁移部分。数据没丢、没算错账,因为功能是开机调度型的,挂了半天只有报表缺了当天的数据,补跑一次就回来了。算是不幸中的万幸。

第二步,补了一条针对"配置驱动"的测试。 以前测试只覆盖了调度器主流程,没覆盖"配置里每个 handler 都能被加载到"这个链路。现在加了一条:

// 为什么看这段:把"配置里声明的 handler 必须真的存在"变成自动化检查
// 防止以后再有人/有 AI 删掉一个"静态分析觉得没人用"的文件
import { jobs } from '../../config/jobs.config.js';

test('配置中声明的所有 handler 均可被加载', () => {
  for (const job of jobs) {
    expect(async () => import(`../../handlers/${job.handler}.js`)).not.toThrow();
  }
});
PASS  src/__tests__/handler-loading.test.js
✓ 配置中声明的所有 handler 均可被加载 (3 ms)

这条测试为什么能拦住 AI?因为它跑的是真实的运行时加载,而不是静态扫描——import() 真的去磁盘上找文件,找不到就抛错。AI 的静态分析看不见配置字符串和文件之间的虚线,但 Node 跑起来的一瞬间,一切现形。以后再有 AI 想删"静态分析觉得没人用"的文件,这条测试会第一个站出来说"不"。

第三步,也是最硬的规矩:删除必须人签收。 现在凡是 diff 里带 --diff-filter=D(删除文件)的提交,AI 一律不许自己合。不管它报的删除理由是什么,"死代码""无用文件""重构清理",合并前必须有一个人手动确认"我查过引用链,这文件确实死了"。我在 CI 里加了一个早退卡点:PR 中出现删除文件而没有 reviewer 的 explicit approval,直接 build fail。

边界:AI 重构不是不能碰,是这几个条件缺一不可

这次翻车之后我没把 AI 重构一棍子打死。它确实快。但我心里有数了,会先问三个问题:

  • 代码库里有没有配置驱动 / 字符串引用 / 反射这类动态触发机制? 有,就先标注,或者干脆把该目录挪出 AI 的清理范围。
  • 测试是不是覆盖了"每条配置入口",而不只是主流程? 只覆盖主流程,等于只证明"主线能走通",证明不了"别的线还活着"。这次事故的测试要不是全绿,我可能当场就能发现 diff 有问题。
  • commit 是不是小步的? AI 一次迁移几百个文件的一个大 diff,出了问题很难定位。切成小步、每步一个模块,风险可控得多。

这套边界守住以后,我上周又让 AI 做了一个纯目录重构(没有动态加载机制、覆盖了入口测试),是成功的。问题不在 AI 能不能重构,在你知不知道它在哪些东西面前是瞎子。

总结

回本篇开头。你可能会问:既然 AI 会犯这种错,为什么不彻底禁用 AI 重构?

我现在的答案,跟上一篇分档的逻辑是同一个:不是"信不信",是"把话说到位"。 这批被删的 200 行,放在上篇的三档表里,属于"动态引用型——写错了你根本不知道"的那类,按规矩应该走 C 档,人签收。我当初把它划成了 A 档("静态分析也觉得它没用"),这个误判是我 relax 而不是 AI 的锅。

所以最后沉淀下来就一句话:让 AI 动手之前,先回答一个问题——这段代码在 AI 眼睛里是"活着的",还是"查无此人"的? 如果它在 AI 的视野盲区里,那对不起,这道工序不能外包。

下次让 AI 重构前,先做这三件事:

  • 扫一遍代码库里有没有配置驱动 / 字符串引用 / 反射 / 目录扫描这类动态触发机制,有就先标注,或把该目录挪出 AI 的清理范围。
  • 确认测试覆盖了"每条配置入口",而不只是主流程——只证明主线能走通,证明不了别的线还活着。
  • 删除文件必须人签收,不管 AI 报的理由是"死代码"还是"无用文件",合并前手动查一遍引用链。