作者将140k行代码的遗留JS项目交由AI Agent迁移,保留人工审核和顺序控制,用三周完成原本六个月的计划,强调图遍历顺序是迁移成功的关键。
我有一个 800 文件的 JavaScript 代码库,不想花六个月做 TypeScript 迁移。我把机械性的部分交给 AI 编码 Agent,自己掌控顺序和棘轮机制,在三周的后台工作中完成了。下面是具体策略——以及我早期添加的一条让 Agent 效率反而下降的规则。
这个代码库是一个 Node.js 22 服务加 React 前端,大约 800 个 .js 和 .jsx 文件,约 14 万行代码。没有类型,有不错的测试覆盖率(服务端约 70%,UI 约 35%),以及八年积累的各种"聪明"代码。
我们之前尝试过两次迁移。两次都以同样的方式失败:
有人把一个叶子文件转换为 .ts。
TypeScript 要求该文件导入的所有东西都有类型。
那些导入又要求它们导入的模块也有类型。
于是 PR 变成了 60 个文件,无法审查,在 review 中腐烂三周。
这就是 TS 迁移的真正问题——它不是类型问题,而是穿着类型外衣的图遍历问题。每个你修改的文件都会拖累它的依赖项。以错误的顺序做,每个改动都巨大无比;以正确的顺序做,每个改动都无聊透顶。
无聊恰恰是 AI Agent 擅长的。所以我最终的分工是:
我:决定顺序、定义质量棘轮、review diff。
Agent:做 800 个无聊的转换,一次一批,绝不让棘轮倒退。
在让 Agent 碰任何文件之前,我提取了模块依赖图并做了拓扑排序。没有任何内部导入的文件优先——纯工具函数、常量、格式化器。它们依赖的东西不需要类型,因为它们什么都不依赖。
// scripts/import-graph.mjs — deliberately dumb, ~40 lines, good enough
import { readFileSync } from 'node:fs';
import { globSync } from 'node:fs';
import path from 'node:path';
const IMPORT_RE = /(?:from\s+|require\()\s*['"](\.[^'"]+)['"]/g;
const files = globSync('src/**/*.{js,jsx}');
const graph = new Map();
for (const file of files) {
const src = readFileSync(file, 'utf8');
const deps = [...src.matchAll(IMPORT_RE)]
.map((m) => path.resolve(path.dirname(file), m[1]))
.filter((p) => p.startsWith(path.resolve('src')));
graph.set(path.resolve(file), new Set(deps));
}
// Kahn's algorithm, but we only care about the layer number.
function layers(graph) {
const remaining = new Map(graph);
const out = [];
while (remaining.size) {
const layer = [...remaining.keys()].filter((f) =>
[...remaining.get(f)].every((d) => !remaining.has(d))
);
if (!layer.length) { out.push([...remaining.keys()]); break; } // cycle: dump it
layer.forEach((f) => remaining.delete(f));
out.push(layer);
}
return out;
}
console.log(JSON.stringify(layers(graph), null, 2));
这输出了 19 层。第 0 层有 94 个文件。第 18 层有 3 个文件(应用入口点,顺理成章)。这个 JSON 成了 Agent 的工作队列。
循环检测那行代码比看起来更重要:我有 31 个文件形成了相互导入的结。这 31 个文件我手动转换的,放在一个 PR 里,在开始之前。Agent 不擅长循环依赖的原因和人类一样——没有正确的起点。自己做那困难的 4%,Agent 就能获得干净的 96%。
每次 Agent 运行都收到类似这样的任务:
把这 12 个文件从 .js 转换为 .ts。它们导入的每个模块已经有类型了——在写注解之前先读每个导入的 .d.ts/.ts 文件。不要修改列表外的任何文件。不要改变运行时行为:不重排序、不做"既然来了"的重构、不改依赖关系。完成后,运行 npm run typecheck 和 npm test -- <related tests>,然后粘贴两个输出。
三个约束起了关键作用:
"每个导入已经有类型"——这就是拓扑排序的全部意义。Agent 永远不需要为依赖项发明类型;它可以去读真实的类型。这一条 alone 就把 hallucinated interfaces 降到了接近零。
"不要改变运行时行为"——没有这条,Agent 会把迁移当成现代化的邀请。第一批我就收到了 Promise.all 重写、var→const 扫荡、以及一个未经请求的错误处理重新设计。全都合理!全都在 12 个文件的类型 diff 里无法审查。
"粘贴两个输出"——强制 Agent 实际运行检查而不是声称成功。(我仍在 CI 中验证;我只是想在 review 前而不是 review 中捕获失败。)
15 个文件是我仍能在一小时内有效 review diff 的上限。低于约 8 个文件时,运行的开销就占主导了。这个区间会因代码库而异,但设置上限是必须的。
这是我会移植到任何迁移(不管用不用 Agent)的部分。
tsconfig.json 开始时是宽松的。然后一个 CI 检查使数字单调非递增:
{
"compilerOptions": {
"strict": false,
"noImplicitAny": false,
"allowJs": true,
"checkJs": false
}
}
#!/usr/bin/env bash
# scripts/ratchet.sh — fails CI if debt grows
set -euo pipefail
BASELINE_ANY=$(cat .ratchet/any-count)
BASELINE_JS=$(cat .ratchet/js-count)
CURRENT_ANY=$(grep -rEc ':\s*any\b|as any' src --include='*.ts' --include='*.tsx' | awk -F: '{s+=$2} END {print s+0}')
CURRENT_JS=$(find src -name '*.js' -o -name '*.jsx' | wc -l | tr -d ' ')
echo "any: $CURRENT_ANY (baseline $BASELINE_ANY) | js files: $CURRENT_JS (baseline $BASELINE_JS)"
[ "$CURRENT_ANY" -le "$BASELINE_ANY" ] || { echo "❌ 'any' count went up"; exit 1; }
[ "$CURRENT_JS" -le "$BASELINE_JS" ] || { echo "❌ new .js files added"; exit 1; }
# Ratchet down: today's numbers are tomorrow's ceiling.
echo "$CURRENT_ANY" > .ratchet/any-count
echo "$CURRENT_JS" > .ratchet/js-count
两个计数器,十二行 bash,迁移就不能倒退——不管是来自 Agent、并行发布功能的对友、还是晚上 11 点的我。当 js-count 达到 0 时,我翻转 strict: true 并做最后一轮清理。
棘轮是让增量迁移真正能终止的关键。没有它,你不是在迁移,你是在舀水。
每个 diff 都有两类改动:机械的(.js→.ts,加 : string)和语义的(这个参数实际是可选的、这个返回 T | null)。
我完全不再读机械改动了。带 word-diff 和只过滤注解行的 git diff 可以去掉 80% 的噪音:
git diff --word-diff=porcelain HEAD~1 -- '*.ts' | grep -E '^\+' | grep -vE '^\+\s*(:|as)\s' | less
我仔细读的,每次都读:每个新的 interface/type 声明,以及 Agent 引入的每个 ?、| null 和 | undefined。这些编码了关于运行时行为的主张。Bug 就藏在那里。
在整个迁移过程中我恰好捕获了两个真正的 bug。两者形态相同——Agent 把一个参数标为 required,而某个代码路径传入了 undefined,且测试没有覆盖那个路径。两者都在 review 一个不该存在的 ? 时浮出水面。
graph LR
A[Import graph] --> B[Topological layers]
B --> C[Batch: max 15 files]
C --> D[Agent converts]
D --> E[typecheck + tests]
E -->|fail| D
E -->|pass| F[Human reviews types only]
F --> G[Ratchet check in CI]
G --> C
顺序就是全部;Agent 是easy part。我花了两天做导入图和分批策略,写 agent prompts 总共花了约 40 分钟。三批之后 prompts 几乎没变过。如果一个机械重构感觉很难交给 Agent,问题几乎总是你还没找到让每个单元独立的排序方式。
一开始禁止 any 让 Agent 变差了。我的第一个棘轮禁止任何 any 的使用。结果不是更好的类型——而是自信的虚构:Agent 从未见过的第三方 payload 的 elaborate interfaces,像 Config 一样未知的 as,以及一个 40 行的 type 描述一个 webhook body,结果四个字段都错了。允许 any 并计数,把一个不可证伪的质量门槛变成了一个只会下降的数字。可以用 grep 找到的 any 比你无法辨别对错的错误类型好。
"不要在迁移时重构"需要作为明确规则,每批重复一次。我试过的每个模型都把打开的文件视为改进的邀请。不是恶意,是热心——这是让可审查的 diff 变得不可审查的最快方式。每次 prompt 里都说,即使感觉冗余。
循环的 4% 自己做。循环导入没有正确的起点,所以 Agent 会选一个,发明类型来打破循环,产生能通过类型检查但编码了架构谎言的东西。31 个文件的手动工作换来了 769 个文件的干净自动化。
棘轮比迁移活得更久。我以为迁移完成后会删除 ratchet.sh。结果它还在 CI 里,现在计数 any 和 @ts-expect-error。迁移是临时的;阻止倒退的机制是永久的。先交付棘轮,再做迁移。
还剩 61 个 any。我接受这个——它们都在真实的边界处(第三方 SDK、一个难搞的旧序列化器),它们被计数,而且数字只会下降。
我现在在做的两件事:
把这个模式应用到测试覆盖率推进。同样的结构:一个图(这次是按调用深度排列的未测试模块)、一个批次大小、和一个只能前进不能后退的覆盖率棘轮。迁移教会我这个模式泛化到任何机械的、有界的、可验证的东西上。
让 Agent 提议批次。现在我生成层然后交给它。显而易见的下一步是让它读图并选择自己的下一个切片,以批次大小上限作为硬约束。棘轮已经让这个尝试变得安全——最坏情况,CI 拒绝这个批次。
如果你正盯着一个一直推迟的迁移,要点不是"用 AI Agent"。是这个:找到让每一步独立的排序,然后构建阻止倒退的棘轮。做好这两件事,工作就变得无聊了——无聊的工作恰恰可以委托出去,委托给 Agent 或任何其他人。
给好奇者的技术栈:Node.js 22.x、TypeScript 5.x、Claude Code 作为 Agent、纯 bash 做棘轮。除了上面 40 行的图脚本外没有定制工具。
💬你有没有试过把大型机械重构交给 AI Agent?我真心想知道它在哪里 broke 了——失败模式比成功案例更有趣。留个评论吧。
🔔 关注我在 Dev.to——我每周写这种构建日志,主要关于让 AI 编码 Agent 在真实代码库上做真实工作而不搞坏它们。
🚀 如果你想自己试试:拿 Claude Code,从叶子模块开始,在转换任何一个文件之前先写棘轮。