Ghost 内部包 TypeScript 与 ESM 迁移实战:保留 Git 历史的三步转换工作流
本文基于 Ghost monorepo 中定义内部包迁移工作流的技能文档 SKILL.md 及其配套参考 history-and-verification.md 展开,完整讲解如何将 packages/ 下的遗留 JavaScript/CommonJS 内部包,按"金路径"(golden path)契约转换为 TypeScript + ESM 单一构建产物。读完本文,你将掌握迁移前的适用性判定、三个职责单一 commit 的拆分策略(lib 目录搬迁、扩展名改写、实现转换)、生产级类型规范、打包与兼容性验证清单,并能对照仓库中已完成的 admin-api-schema 案例与强制校验脚本 pnpm lint:packages 复现整个流程。
一、背景:Ghost 的 packages 金路径契约
Ghost 仓库中 packages/ 目录存放的是仅供 monorepo 内部使用的 Node.js 库。packages/README.md 是这些包整个生命周期内架构约定的权威来源,而本文讨论的迁移工作流文档只负责"怎么转换",不负责定义"转换成什么样"——这正是两份文档的分工边界。
金路径对新内部包的定义是:私有的、TypeScript 专属的 ESM 库,具体契约包括(见 packages/README.md#L11-L21):
- 包名使用
@tryghost/<name>前缀; "version": "0.0.0"且"private": true;- 声明
"ghostPackage": {"goldenPath": "compliant"}; "type": "module";- 源码放在
src/**/*.ts、测试放在test/**/*.ts; - 用
tsc将生产代码编译到build/; - 不做独立的 npm 发布。
每个私有包都必须在 ghostPackage.goldenPath 字段中声明生命周期状态(见 packages/README.md#L27-L41):
| 状态 | 含义 |
|---|---|
compliant |
该包已符合金路径,会被机械化规则逐条检查 |
migration |
过渡状态:保留历史的导入已合入,正在等待独立的现代化 PR |
exempt |
有意保留的长期例外(如纯测试辅助包、多运行时包) |
migration 与 exempt 都必须附带非空的 ghostPackage.reason。这些状态和所有可机械化执行的规则由 scripts/check-internal-packages.js 在 pnpm lint:packages 中统一校验——该脚本在 根 package.json#L52 中被挂到仓库级 lint 流程里。值得注意的是,该脚本对标准脚本与 devDependencies 的期望值是硬编码常量,注释中明确说明这是刻意与 _template 模板 解耦的,避免模板漂移后"自己批准自己"。
金路径的构建细节同样值得在迁移前读懂(见 packages/README.md#L128-L140):
- 共享 TypeScript 配置使用 NodeNext 语义,相对导入必须写真实的
.ts扩展名,编译器在输出时改写为.js; - Ghost Core 本身是 CommonJS,但运行在支持
require(esm)的 Node 版本上,因此单个 ESM 构建可同时服务import与require()两类消费者——前提是整个模块图不得出现顶层await(由 ESLint 强制执行); - 默认不添加 CommonJS 构建或转发 shim,只有经过验证的消费者无法使用标准 ESM 产物时才允许多格式。
仓库中的共享 TS 配置 configs/typescript/esm.json 把这些约定落实为具体编译器选项:module: "nodenext"、moduleResolution: "nodenext"、allowImportingTsExtensions: true、rewriteRelativeImportExtensions: true、resolveJsonModule: true、strict: true,并叠加 noUncheckedIndexedAccess、noUnusedLocals、noUnusedParameters 等严格开关。这些选项正是后文"生产级标准"在工具链层面的兜底。
二、迁移前:确认工作流适用
迁移文档要求:先完整阅读 packages/README.md,再检查目标包、它的消费者以及可对比的现行包,确认该包"仅内部使用"且能够采用文档化的单一构建 TypeScript + ESM 契约。
动手编辑之前必须完成四步准备(对应 SKILL.md#L19-L31):
- 找到该包的所有导入点、
require()调用、导出、运行时资源与路径引用; - 检查包元数据、构建与测试配置、发布/归档(archive)包含关系,以及任何动态模块加载;
- 运行包现有测试和有代表性的消费者检查,建立基线(baseline);
- 识别出那些无法加载金路径 ESM 产物的受支持消费者。
文档同时划定了停止条件:如果包拥有活跃的独立发布线、第三方支持契约,或需要与金路径冲突的输出格式,应停下来先为其确立支持契约,而不是机械套用本工作流。一个容易误判的细节是:历史上已废弃的 npm 版本本身不构成迁移障碍——只有当前的支持契约才能阻止转换。
此外,文档要求在规划 commit 之前完整阅读 references/history-and-verification.md。该参考文档提供的内容在下一节展开。
三、用三个聚焦 commit 保留文件历史
这是整个工作流的核心方法论:把"机械搬迁"与"语义改写"严格分离,让 Git 的重命名检测(rename detection)能够工作。参考文档解释了原理:Git 记录的是快照而非显式重命名操作,重命名识别依赖内容相似度;如果同一个 commit 里既移动文件又大幅改写内容,Git 很可能把它呈现为"删除 + 新增",历史就此断裂。
三个 commit 各自的要求如下(对应 SKILL.md#L37-L69):
1. 将源码从 lib 迁到 src
- 使用
git mv移动整棵源码树; - 只更新必须随路径变化的引用:测试导入、构建输入、包元数据;
- 本 commit 不改动模块语法、文件扩展名或任何实现;
- 若改动内容与之相符,commit subject 使用
Moved <package> sources from lib to src。
2. 将文件扩展名改为 TypeScript
- 用
git mv完成.js到.ts的批量重命名; - 只添加让重命名后的源码能够解析、让本 commit 保持自洽所必需的最小配置与语法调整;
- 保持行为不变,把有意义的类型标注和 ESM 转换推迟到第三个 commit;
- subject 使用
Changed <package> file extensions to TypeScript。
3. 将实现转换为 TypeScript 与 ESM
- 按 packages/README.md 的包契约落地:共享配置包、最小化的包内配置、ESM 元数据与 exports、标准脚本,以及单一编译产物(除非有已验证的消费者需要例外);
- 只有当所有金路径机械化检查全部通过后,才把包的
migration状态替换为ghostPackage.goldenPath: "compliant"; - subject 使用
Converted <package> to TypeScript。
文档对机械 commit 还有一条纪律:避免夹带格式化或顺手清理(opportunistic cleanup),让每个 commit 都能独立通过验证。
已验证的 commit 形态:admin-api-schema 案例
参考文档 history-and-verification.md 记录了 admin-api-schema 这次转换使用的三个 commit:
688807ca052 Moved Admin API schema sources from lib to srcbe31b36ffab Changed Admin API schema file extensions to TypeScriptb825dafdc19 Converted Admin API schemas to TypeScript
文档强调:这个顺序的价值在于让 Git 与评审者能够区分"重定位、机械改名、语义转换"三类变更;它是形态示例而非实现模板,不应照搬其中的包特定实现。
今天的 packages/admin-api-schema/ 正是转换完成后的样子,可以作为"目标态"的实物参照:
- package.json:
"type": "module"、"private": true、ghostPackage.goldenPath: "compliant",exports 按source→types→default顺序暴露./src/index.ts、./build/index.d.ts、./build/index.js,files只含build——与 packages/README.md#L51-L65 给出的契约模板逐字段一致; - tsconfig.json:继承
@internal/cfg-typescript/esm.json,仅覆写rootDir: "src"、outDir: "build",include只含生产源码; - test/tsconfig.json:继承源配置、
noEmit: true,同时 include 源码与测试,供test:types脚本使用; - vitest.config.ts:一行
createVitestConfig(),来自共享的@internal/cfg-vitest。
这些"包内配置保持最小"的样例,恰好呼应了 packages/README 中"只在行为偏离共享契约时才加 override"的原则。
四、转换必须达到的生产级标准
迁移文档列出的质量标准(SKILL.md#L71-L88)可以归纳为六条纪律,每一条都对应仓库里已有的工具链支撑:
1. 类型要建模真实形状,禁止用 any 兜底。 对输入、输出与注册表(registry)形状建模,不得以 any 替代缺失的类型。仓库的共享 TS 配置开了 strict 与 noUncheckedIndexedAccess(见 configs/typescript/esm.json),转换后代码必须在这套开关下干净通过。
2. unknown 只出现在真正不可信边界,且要立刻收窄。 这是防止"未知输入一路裸奔"的关键约束。
3. 禁止用宽泛断言、非空断言、@ts-ignore 或 lint disable 来"让编译器闭嘴"。 参考文档在最终 diff 审查清单中再次点名"unexplained unknown or casts"作为重点检查项。
4. 保持运行时 API 不变。 除非 API 变更被明确纳入范围且所有消费者同步更新,否则包对外暴露的行为必须原样保留。
5. NodeNext 下相对导入必须写显式 .ts 扩展名。 这不是风格偏好,而是 configs/typescript/esm.json 中 allowImportingTsExtensions + rewriteRelativeImportExtensions 的组合所要求的写法——编译器会在 emit 时把 .ts 改写为 .js。
6. ESM 表达不了旧的 CJS 动态加载模式时,用显式的类型化注册表替代。 典型场景是旧代码靠 __dirname + 目录扫描动态 require() 一堆模块,ESM 无法安全表达这种发现式加载,正确做法是改成静态、带类型的注册表结构。
此外还有两条面向产物的约束:
- JSON 等运行时资源必须被发射并可在
build/中找到。 packages/README.md#L142-L150 要求"生产行为只能依赖build/单独工作":编译器能拷贝的(如通过resolveJsonModule导入的 JSON)就直接从 TS 导入,否则加一个显式、可移植的拷贝步骤。仓库中 packages/admin-api-schema/src/schemas/ 下的大批量*.json校验模式(posts、pages、members 等 Admin API 请求 schema)正是这类运行时资源的典型实例; - 避免顶层
await,以便受支持的 CommonJS 消费者能够通过 Node 的require(esm)互操作直接require()该包——这是单一 ESM 构建同时服务两类消费者的前提。
最后一条红线:不得为了通过转换而放宽共享的 TypeScript、ESLint 或 Vitest 规则。 这一点由 scripts/check-internal-packages.js 机械化保障——它硬编码了标准脚本(build: "tsc"、test:unit、test:types、lint:code 等)和标准 devDependencies 的期望值,任何在包内私改共享规则或偷换脚本的行为都会在 pnpm lint:packages 中暴露。
五、验证体系:从工作区到打包产物
迁移的验证分四层,逐层收紧证据边界。
1. 标准命令
在包目录内运行 pnpm build、pnpm test、pnpm lint(即 packages/README "Verification" 一节要求的最小集合)。
2. 消费者侧双路径验证
参考文档要求按顺序做两件事(history-and-verification.md#L34-L50):
- 先在工作区(worktree)验证
source条件:确认某个使用source条件的消费者能在开发/测试中解析并加载原始 TypeScript("source": "./src/index.ts"这一条 exports 就是为此设计的,纯 Node 会忽略它而加载build/中的编译产物); - 再构建并
pack包,针对打包后的产物验证四件事:- 纯 Node 能 import 编译后的 ESM 入口;
- 现有的 CommonJS 消费者能在 Ghost 支持的 Node 版本上
require()编译入口; - 编译后的模块图中不存在顶层
await; - 产物中的
types、default、main目标都能解析到真实存在的文件。
文档同时警告:不要投机性地把第二个 CommonJS 构建加回去;任何必需的例外必须记录在包的 README 与配置中,并被测试覆盖。
3. 资源与打包检查
- 从干净的包输出开始构建,逐一检查
build/中每个运行时文件; - JSON schema 之类的资源,编译器能发射就从 TS 导入,否则用显式拷贝步骤;
- 复用仓库既有的 pack / 发布归档检查,确认三件事:生产执行不从
src/读文件;files包含构建产物、排除手写源码(除非包契约明确另有规定);消费者行为是针对产物而非仅工作区测试的。
4. 历史可读性与最终 diff 审查
对每个机械 commit 运行并检查重命名是否被正确识别:
git diff --summary HEAD^
git log --follow -- packages/<name>/src/<representative-file>.ts
参考文档补充了一个边界情形:一个小的 CommonJS 转发文件在新 ESM 入口替换它时,合理地显示为删除是可接受的——此时应验证实现代码的血缘,而不是硬造一个有误导性的 rename。
开 PR 前,除了总 diff,还要逐 commit 独立审查,重点排查:意外的格式化噪音、被削弱的编译器/lint 规则、来历不明的 unknown 或类型断言、过时的 CommonJS 配置、缺失的运行时资源、以及绕过包 exports 直取内部路径的消费者。
关于合并方式,SKILL.md 明确:当这些 commit 独立有效且顺序有意为之时,现代化 PR 可以 rebase-merge;这不影响前置子树历史导入所要求的 merge-commit 规则。
六、快速核对清单
结合仓库证据,执行一次完整迁移可按下表自查:
| 环节 | 动作 | 仓库依据 |
|---|---|---|
| 适用性 | 确认包为内部使用、无独立发布/第三方契约;废弃的历史 npm 版本不阻塞 | SKILL.md |
| 基线 | 跑通现有测试与代表性消费者 | 同上 |
| Commit 1 | git mv 将 lib/ 移到 src/,不动语法与扩展名 |
SKILL.md#L42-L49 |
| Commit 2 | git mv 批量 .js → .ts,只加最小解析配置 |
SKILL.md#L51-L58 |
| Commit 3 | 落地 TS + ESM 契约,检查全过后再改 goldenPath: "compliant" |
SKILL.md#L60-L69 |
| 类型纪律 | 真实建模、unknown 仅限边界、禁 any/断言/ts-ignore、保持运行时 API |
SKILL.md#L71-L88 |
| 配置形态 | 继承共享 cfg 包、source/types/default exports、标准脚本面 |
packages/README.md、packages/_template/package.json |
| 验证 | build/test/lint → worktree source 条件 → pack 产物四查 → 资源与归档检查 → git diff --summary / git log --follow |
history-and-verification.md |
| 机械化兜底 | pnpm lint:packages 校验脚本面、devDeps 与 goldenPath 状态 |
scripts/check-internal-packages.js、根 package.json#L52 |
七、总结
这套工作流的价值在于把一次"语言升级"拆成了可审计的历史事件:lib → src 的重定位、.js → .ts 的机械改名、以及真正的语义转换各自独立成立,Git 重命名检测因此全程有效,评审者与未来的 git log --follow 读者都能看到清晰的文件血缘。而 ghostPackage.goldenPath 状态机配合 scripts/check-internal-packages.js 的机械化校验,则保证"声明 compliant"与"实际通过金路径检查"是同一件事。对维护者而言,这套方法可以直接作为 Ghost packages/ 下任何遗留内部包现代化的操作手册;对读者而言,packages/admin-api-schema/ 是转换完成后的完整参照实现。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00