JavaScript / TypeScript Codemod:从 AST 改写到可回滚迁移
一次埋点 SDK 升级要求把 legacyTrack(name, payload) 改成 telemetry.track({ name, payload })。仓库里既有 .js、.ts 和 .tsx,也有一个同名的测试辅助函数;有些调用从别名导入,有些参数经过泛型包装。第一次用正则替换,开发者很快得到两千多处 diff,但 JSX 属性被误改、注释里的示例被重写,另有几十个别名调用完全漏掉。更麻烦的是,第二次执行脚本又产生了一批新改动。
Codemod 的价值不是“批量写文件”,而是把源码解析成结构,按可解释条件找到节点,再生成受编译器和测试约束的补丁。真正可靠的迁移还要回答:当前文件由哪个 parser 读取,匹配是否依赖类型身份,变更何时落盘,旧节点引用何时失效,重复执行是否稳定,以及失败后怎样撤回已经写出的状态。
先按所需证据选择执行引擎
三种常见入口都能遍历语法树,却解决不同层次的问题。jscodeshift 提供多进程 runner、基于 Recast 的集合 API、parser 选择、dry run、统计和 fixture 工具,适合“单文件结构足以判定”的 JavaScript/TypeScript 改写。它努力保留未触碰代码的原始风格,但不会因为选了 ts 或 tsx parser 就自动拥有完整项目类型信息。
TypeScript Compiler API 直接暴露 Program、SourceFile、TypeChecker、factory、transformer 和 printer。需要精确控制编译选项、模块解析、symbol 身份或生成过程,而且团队愿意承担较底层节点操作与版本兼容成本时,直接使用它最透明。Compiler API 的 Symbol 是类型系统中的声明身份,不是 JavaScript 运行时的 Symbol。
ts-morph 在 Compiler API 上提供项目加载、导航和修改 API,适合跨文件、需要类型信息并希望减少工厂函数样板的迁移。代价是整个 Project 和 compiler objects 会占用更多内存;修改节点后,原 wrapper 可能被遗忘,保存又是显式阶段。它不是另一套类型系统,最终可信度仍由 tsconfig.json、文件集合、模块解析和当前 TypeScript 版本决定。
| 判断问题 | 优先入口 | 典型信号 |
|---|---|---|
| 单文件语法形状足够吗 | jscodeshift | 改调用形状、导入声明、JSX 属性,fixture 能穷举变体 |
| 必须确认符号来自哪个包吗 | ts-morph 或 Compiler API | 同名函数、别名导入、重导出、重载和泛型影响匹配 |
| 要完全控制 AST 生成和打印吗 | Compiler API | 自定义 host、诊断、transformer、emit 或极致性能调优 |
| 仓库巨大且规则简单吗 | jscodeshift 分片 | 文件可独立处理,runner 并发收益大于项目图价值 |
用项目内 Node 工具链固定运行入口
先在实验分支确认仓库规定的 Node 版本与包管理器。Codemod 应作为开发依赖进入锁文件,避免某位开发者的全局安装决定 AST 形状或打印结果。版本也不能只写 latest:npm 注册表当前发行线是 jscodeshift 17.4.0、TypeScript 7.0.2 和 ts-morph 28.0.0,但 ts-morph 28.0.0 的发布说明明确以 TypeScript 6.0 为编译器基线,不能因为三个包都能安装就认定它们共享同一 AST/API 版本。
需要 ts-morph 的实验可固定在 jscodeshift 17.4.0、TypeScript 6.0.3、ts-morph 28.0.0;已经升级到 TypeScript 7 的应用,应优先直接使用该项目锁定版本的 Compiler API,或等待并验证声明支持 TypeScript 7 的 ts-morph 发行线。jscodeshift 与 ts-morph 使用 MIT 许可证,TypeScript 使用 Apache-2.0;它们允许在企业迁移工具中使用,但仍要保留许可证通知、通过依赖准入并审查传递依赖。能力与许可证分别以 jscodeshift 仓库、TypeScript 仓库 和 ts-morph 28.0.0 发布说明 为依据。
下面准备一组编译器基线一致的实验依赖:
npm install --save-dev jscodeshift@17.4.0 ts-morph@28.0.0 typescript@6.0.3 tsx @types/node
npx jscodeshift --version
npx tsc --versiontsx 只负责运行 TypeScript 脚本;生产构建仍使用项目自己的编译命令。依赖安装后检查 lockfile diff,并确认 npm 脚本不会从不受信任的网络位置下载 transform。企业仓库还应通过制品代理、lockfile 完整性和依赖准入约束这组开发工具。
建立一个只服务迁移脚本的 codemods/tsconfig.json,避免脚本的 Node 环境与浏览器应用配置互相污染:
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": ["node"],
"noEmit": true
},
"include": ["./**/*.ts"]
}这里通过 extends 继承项目当前 ECMAScript target,只覆盖 codemod 在 Node 环境运行所需的模块解析、类型和输出选项;若脚本目录层级不同,应把相对路径改到真实项目配置。应用源码仍由仓库根部或各 package 的 tsconfig.json 建模。moduleResolution 必须匹配真正执行代码的 Node 或 bundler;paths 只改变 TypeScript 的查找,不会重写 emit 中的 import,因此类型检查通过并不能证明运行时能解析同一别名,详见 TypeScript Modules Reference。
jscodeshift --parser=tsx 只解决 TSX 语法能否被解析,不会读取 path alias 后判断符号来源;ts-morph 的 Project({ tsConfigFilePath }) 会读取该配置的 compiler options 和文件集合,并解析已加入文件的依赖。solution-style tsconfig 只有 references 而没有自己的输入文件时,不要假定一个 Project 会自动装入所有被引用工程;TypeScript 的 tsc -b 会按引用图构建,不代表任意 AST 包装器也会复制这套加载行为。应逐个 reference/workspace 的 tsconfig 建立 Project,或显式调用 addSourceFilesFromTsConfig,并把每个项目的诊断分别设为门禁。解析失败时先记录文件路径与语法类别,不要退化成正则后继续批量写入。
用 jscodeshift 完成第一条单文件变换
先处理容易证明的形状:只改从 @acme/legacy-telemetry 命名导入且本地名为 legacyTrack 的直接调用,并要求文件已经从 @acme/telemetry 命名导入本地绑定 telemetry。transform 同时检查两个导入、调用绑定和参数个数;只看到文件里存在目标 import 并不足以证明某个同名调用引用它,因为函数参数或局部变量仍可能遮蔽该名字。
// codemods/legacy-track.cjs
module.exports = function transform(file, api) {
const j = api.jscodeshift;
const root = j(file.source);
const imported = root.find(j.ImportDeclaration, {
source: { value: "@acme/legacy-telemetry" },
}).find(j.ImportSpecifier, {
imported: { name: "legacyTrack" },
local: { name: "legacyTrack" },
});
const targetImported = root.find(j.ImportDeclaration, {
source: { value: "@acme/telemetry" },
}).find(j.ImportSpecifier, {
imported: { name: "telemetry" },
local: { name: "telemetry" },
});
if (imported.size() !== 1 || targetImported.size() !== 1) return undefined;
const importScope = imported.paths()[0].scope.lookup("legacyTrack");
const telemetryImportScope = targetImported.paths()[0].scope.lookup("telemetry");
let changed = false;
root.find(j.CallExpression, {
callee: { type: "Identifier", name: "legacyTrack" },
}).filter((path) =>
path.node.arguments.length === 2 &&
path.scope.lookup("legacyTrack") === importScope &&
path.scope.lookup("telemetry") === telemetryImportScope
)
.forEach((path) => {
const [name, payload] = path.node.arguments;
path.node.callee = j.memberExpression(
j.identifier("telemetry"),
j.identifier("track"),
);
path.node.arguments = [j.objectExpression([
j.property("init", j.identifier("name"), name),
j.property("init", j.identifier("payload"), payload),
])];
changed = true;
});
return changed ? root.toSource({ quote: "single" }) : undefined;
};
module.exports.parser = "tsx";这个版本故意只接受已经存在的命名导入,不自动猜测 telemetry 应来自默认导入、命名导入还是依赖注入对象;目标绑定不存在、不唯一或在调用点被局部变量遮蔽时,候选保持不变并进入拒绝统计。第一轮先把源函数与目标对象两侧的遮蔽规则和新调用形状证明正确,再为其他导入策略分别增加 fixture。一个 transform 同时承担匹配、跨模块判定、导入整理、格式化和兼容兜底,会让失败难以分型。
先只对 fixture 运行 dry run:
npx jscodeshift codemods/fixtures/positive.tsx \
--transform codemods/legacy-track.cjs \
--parser tsx --dry --print --run-in-band --fail-on-error--dry 禁止写文件,--print 展示结果,--run-in-band 让调试输出保持顺序,--fail-on-error 让解析或 transform 错误返回非零退出码。正式候选集再加 --extensions=js,jsx,ts,tsx --gitignore;执行目录必须是预期仓库根,因为 --gitignore 从当前目录读取规则。
正反 fixture 证明命中和拒绝都正确
正向样例至少包含普通 TypeScript、TSX 内调用、换行参数和已有括号;反向样例包含本地同名函数、另一模块导入、参数数量不符、字符串与注释。不能只断言“改了一个文件”,应比较完整输出,并检查拒绝样例字节不变。
// codemods/fixtures/positive.tsx
import { legacyTrack } from "@acme/legacy-telemetry";
import { telemetry } from "@acme/telemetry";
export function BuyButton() {
const onClick = () => legacyTrack("buy", { source: "detail" });
return <button onClick={onClick}>Buy</button>;
}
// codemods/fixtures/negative.ts
import { legacyTrack as vendorTrack } from "another-package";
const legacyTrack = (name: string) => name;
legacyTrack("local");
vendorTrack("vendor", {});用 jscodeshift/dist/testUtils 或仓库现有测试框架保存 .input / .output fixture。除了输出快照,再加入第二次执行断言:把第一次输出重新交给 transform,结果必须为 undefined 或与输入完全一致。反向 fixture 还要加入函数参数或块级变量遮蔽 legacyTrack、调用点局部变量遮蔽 telemetry、缺少目标绑定、重复导入等情况。对 alias import,当前 transform 应明确拒绝并计入待处理统计;如果业务要求支持 alias,再写独立正向 fixture,并从 ImportSpecifier.local.name 推导本地标识符。
反向实验可以把 module.exports.parser 临时改成 babel 后处理含 TypeScript 类型的 fixture。预期 runner 报解析错误并以非零状态退出,而不是静默写出部分文件。再把候选目录混入 dist/,分别比较有无 --gitignore 时的文件计数;两次差值证明生成物是否被纳入执行面。
类型身份出现时升级到 ts-morph 或 Compiler API
当调用可能经过别名、barrel export 或重导出,仅检查局部 import 文本会漏掉真实引用,也可能改到同名包。此时从应用的 tsconfig.json 建立 Project,再用 declaration 或 symbol 确认 callee 最终来自目标模块。下面示例展示缓冲修改和集中保存的骨架:
// codemods/typed-legacy-track.ts
import { Node, Project, SyntaxKind } from "ts-morph";
const project = new Project({
tsConfigFilePath: "tsconfig.json",
skipAddingFilesFromTsConfig: false,
});
const diagnostics = project.getPreEmitDiagnostics();
if (diagnostics.length > 0) {
throw new Error(`Refusing to modify a project with ${diagnostics.length} diagnostics`);
}
let changed = 0;
for (const sourceFile of project.getSourceFiles()) {
if (sourceFile.isDeclarationFile()) continue;
const hasTelemetryBinding = sourceFile.getImportDeclarations().some((declaration) =>
declaration.getModuleSpecifierValue() === "@acme/telemetry" &&
declaration.getNamedImports().some((specifier) =>
specifier.getName() === "telemetry" && specifier.getAliasNode() == null
)
);
if (!hasTelemetryBinding) continue;
while (true) {
const call = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).find((candidate) => {
const expression = candidate.getExpression();
if (!Node.isIdentifier(expression) || candidate.getArguments().length !== 2) return false;
const symbol = expression.getSymbol();
const target = symbol?.isAlias() ? symbol.getAliasedSymbol() : symbol;
return target?.getDeclarations().some((node) =>
node.getSourceFile().getFilePath().includes("@acme/legacy-telemetry")
) ?? false;
});
if (!call) break;
const [name, payload] = call.getArguments().map((arg) => arg.getText());
call.replaceWithText(`telemetry.track({ name: ${name}, payload: ${payload} })`);
changed += 1;
}
}
console.log(JSON.stringify({ changed }));
if (process.argv.includes("--write")) await project.save();Project 内的修改先存在内存中,sourceFile.save() 只保存该文件,project.save() 会把 Project 中待处理的保存、移动、复制和删除集中落到文件系统。它降低脚本在变换中途主动保存造成的半写风险,却不是跨文件事务:磁盘空间、权限或进程中断仍可能留下部分已写文件,所以真正的原子恢复边界仍是隔离工作树和 Git 批次。dry run 不调用保存,并不代表零资源消耗:脚本仍读取整个项目、解析依赖并建立 compiler objects。示例在任何修改前拒绝已有 diagnostics,并在每次替换后重新查询下一候选,避免继续遍历可能已失效的 wrapper;真实脚本还要检查候选文件数和预期上限,任何异常都应阻断 save()。示例用声明文件路径识别演示包,实际仓库应把 pnpm/PnP、path mapping、exports 条件和源码工作区纳入 fixture,不能把一个 includes 判断冒充通用包身份算法。保存与节点失效语义可对照 ts-morph 的 Manipulating Source Files。
直接使用 Compiler API 时,可以通过 ts.findConfigFile、ts.readConfigFile 和 ts.parseJsonConfigFileContent 得到真实 root names 与 options,再用 ts.createProgram 和 program.getTypeChecker() 查询 symbol。factory 与 printer 适合构造节点,但打印整个 SourceFile 可能带来大面积格式 diff;需要最大程度保留原文时,应限定替换区域,或继续让 Recast/ts-morph 承担文本操纵。选择的关键是证据与成本,不是 API 层级越低越专业。
缓冲保存和节点遗忘决定脚本能否稳定运行
ts-morph wrapper 绑定当前 compiler tree。调用 replaceWithText、remove、set 或某些结构化修改后,被替换节点以及它的后代可能被 forgotten;继续读取旧引用会抛出“node was forgotten”一类错误。上例在替换前先把两个参数转成字符串,并在替换后重新查询下一候选,就是为了不在树被替换后继续访问旧参数或预先缓存的调用节点。
常见坏写法是先缓存整个项目的 descendant 数组,再边遍历边替换祖先节点。数组后半段可能已经属于旧树。更稳的做法是按文件、按最深层节点执行;一次替换后重新查询受影响区域;跨阶段只保存稳定身份,如文件路径、起止位置、符号名和自定义业务键,不长期持有 node wrapper。
大项目还要主动释放不再使用的树。可以在完成并保存一批文件后调用适当的 forget API,或通过 project.forgetNodesCreatedInBlock 控制临时导航产生的 wrapper;调用前必须确认后续阶段不会再访问这些节点。释放 compiler objects 会节省内存,却可能让后续类型查询重新建模。用代表性仓库测量峰值 RSS、每千文件耗时和 GC 停顿,再决定批大小。
保存也要设清晰闸门:先收集候选与诊断,再在内存完成一批修改,输出将写文件清单和摘要,最后一次性保存。不要每改一个调用就 saveSync();同步频繁落盘会放大 I/O,并让失败停在难以辨认的半写状态。若必须分批保存,则每批绑定独立 Git commit 或工作树快照,使批次可以单独撤回。
幂等不是口号,要由第二次执行证明
幂等意味着同一基线经一次变换达到目标状态,第二次运行不再产生语义或格式变化。规则应识别目标形状,例如先排除已经是 telemetry.track({ name, payload }) 的调用,插入 import 前检查同源 specifier,排序和格式化只运行一次且配置固定。
把完整验证做成项目命令,避免操作者漏掉第二轮:
{
"scripts": {
"codemod:track:preview": "jscodeshift src --transform codemods/legacy-track.cjs --extensions=js,jsx,ts,tsx --gitignore --dry --print --fail-on-error",
"codemod:track:write": "jscodeshift src --transform codemods/legacy-track.cjs --extensions=js,jsx,ts,tsx --gitignore --fail-on-error",
"verify:codemod": "npm run format:check && npm run typecheck && npm test"
}
}实际写入后记录 git diff --stat、变更文件清单和 diff 哈希,按仓库固定版本运行 formatter,再执行格式检查、应用各自的 tsc -p 或 tsc -b、单元测试、契约测试与构建测试。以格式化后的树作为第二轮输入,再次运行 write 命令;第二轮后的 diff 哈希必须与第一轮格式化完成时一致。仅看到 runner 报 0 errors 不够:它可能把不该改的文件成功改写,也可能把目标全部归为 skipped。fixture 测试负责 AST 输入输出,类型测试负责模块图与重载,运行测试负责副作用与参数求值顺序,三者不能互相替代。
漏命中用独立查询发现。保留旧 API 的文本搜索、import 来源搜索、编译期弃用诊断和运行时埋点可形成互补证据。迁移完成后旧包依赖数、旧 API 静态命中数和运行时旧事件数都应收敛;动态 require、字符串注册表和生成代码需要专门处理,AST 工具不会凭空恢复运行时关系。
接入真实项目时把变换当作一次发布
先冻结目标提交和候选文件清单,在小型 package 上试跑,再扩大到代表性 package,最后处理全仓。monorepo 不应默认从根 tsconfig.json 加载一切:逐个 project reference 或 workspace 建模,能降低峰值内存,也能让失败落到明确 owner。共享类型包和 barrel export 则要进入较早批次,否则后续 package 可能在旧新 API 间反复震荡。
把 transform、fixture、parser 选择、TypeScript 版本、运行命令和拒绝原因一起纳入代码评审。CI 先跑 fixture 与 dry run;真正写文件通常由受控工作分支上的一次性作业或开发者执行,产出普通 PR。不要让每次 CI 都对主工作区原地重写,否则不同 runner、缓存和行尾设置会制造不可预测 diff。
回滚分三层处理。尚未保存时丢弃内存中的 Project 即可;已经写入但未提交时,先保存 diff 证据,再只恢复本批文件或丢弃隔离工作树;已经合并时用正常 revert,并重新执行构建与发布验证。若迁移伴随依赖升级、代码生成、schema 或线上双写,源码 revert 只是其中一步,还要按系统状态执行兼容开关和数据补偿。
权限、敏感代码与容量需要独立预算
codemod 只需要读取候选源码并写当前工作树,不需要生产凭证、发布权限或组织管理员 token。运行身份应受仓库 ACL 约束;跨仓自动化把“读取私有源码”“创建分支”“发起 PR”拆成不同权限。transform 能执行任意 Node 代码,来自外部包或远程 URL 的脚本必须先固定版本、审查源码,并在隔离环境禁用生产网络与云元数据访问。
日志和失败快照可能含私有路径、源码片段、客户标识或硬编码秘密。默认只输出文件相对路径、规则编号、计数和错误类型;需要打印源码时限制在受控调试会话,并按仓库敏感等级设置保留期。fixture 使用最小合成样例,不要把真实客户代码复制进公共工具仓库。
容量由文件数、平均 AST 大小、类型图、并发 worker、打印与格式化成本共同决定。jscodeshift 的 worker 数适合无共享状态的单文件变换;盲目拉满 CPU 会同时冲击磁盘、杀毒扫描和 CI 邻居。ts-morph/Compiler API 更受项目图与 compiler object 内存影响,应按 workspace 分片并限制并发。观察每批读取文件数、解析失败数、修改数、拒绝数、峰值 RSS、耗时、第二轮新增 diff 和回滚耗时,阈值来自仓库基线与 CI 容量,而不是复制一个固定数字。
长期维护时,每条 codemod 都应有 owner、目标依赖版本、parser 基线、fixture 集、适用 package、最大批次和删除时机。迁移完成后撤销临时写权限、删除一次性凭证和执行缓存;仍有复用价值的 transform 保留版本与测试,其余脚本随迁移记录归档,避免几年后被人误当成当前项目模型再次运行。
