Expo 单仓库公共 API 兼容性审查:导出映射、类型级破坏与 Changelog 版本策略
本文以 Expo 官方代码审查智能体 public-api.md 为核心,系统讲解 Expo 单仓库中「公共 API 破坏性变更」的识别标准:从 exports 映射的解析陷阱、在仓库内能编译但会在消费方项目里失败的类型级破坏,到 peerDependencies 收窄与 sideEffects 修剪,并结合发布工具 tools/src/publish-packages/helpers.ts 与 tools/src/Changelogs.ts 的源码,说明 Changelog 标题如何机械地决定 npm 包的 MAJOR/MINOR/PATCH 版本。读完后你能掌握一套可落地的公共 API 兼容性审查清单,以及 Expo 发布链路的底层原理。
为什么 Expo 需要专门的公共 API 审查
Expo 是一个包含约 140 个独立版本化包的单仓库:packages/ 下的每个包都以自己的版本号单独发布到 npm。任何一个导出符号的变更,本质上是变更了成千上万个应用依赖的契约。而 apps/ 下的 14 个应用与 tools/ 均标记为 "private": true,从不发布,因此 API 兼容性规则不适用于它们——审查范围严格限定在会发布到 npm 的包上。
该审查规则定义了整份指南最重要的约束:本仓库自己的构建和测试检测不出你所要发现的绝大多数问题。类型级破坏在单仓库内部往往源码兼容,只在消费方的项目里失败。因此「CI 是绿的」永远不能作为变更安全的结论。
这份规则位于 AI 代码审查系统 .expo-agents/code-review/ 中:config.jsonc 约定「agents/ 目录下的每个 markdown 文件即一位审查员」,coordinator.md 负责汇总去重并做最终裁决。public-api 审查员只判断标题与措辞,不重复机械检查已经覆盖的事项——这一分工在后面会反复出现。
有两个仓库事实塑造了审查工作,值得单独强调:
getMinReleaseType只在 Changelog 的Unpublished区块下存在精确标题🛠 Breaking changes的条目时才选择 MAJOR 升版。把破坏性变更写进🐛 Bug fixes小节,就会以 minor 或 patch 版本悄悄发布。标题是承重结构,不是装饰。- Metro 自 0.82 起默认启用
package.json的exports解析,而 Expo 仓库运行在较新的 Metro + React Native 之上(审查指南标注为 Metro 0.84.4 + React Native 0.86,具体版本以当前分支实际依赖为准)。这意味着编辑exports字段现在影响的是应用打包,而不只是 Node 的模块解析。
Changelog 标题驱动版本:源码级证据
先看版本决策的代码。helpers.ts 中的 getMinReleaseType 逻辑非常直白:
export function getMinReleaseType(changelogChanges: any): ReleaseType {
const unpublishedChanges = changelogChanges?.versions[Changelogs.UNPUBLISHED_VERSION_NAME];
const hasBreakingChanges = unpublishedChanges?.[Changelogs.ChangeType.BREAKING_CHANGES]?.length;
const hasNewFeatures = unpublishedChanges?.[Changelogs.ChangeType.NEW_FEATURES]?.length;
// For breaking changes and new features we follow semver.
if (hasBreakingChanges) {
return ReleaseType.MAJOR;
}
if (hasNewFeatures) {
return ReleaseType.MINOR;
}
return ReleaseType.PATCH;
}
它只读取 Unpublished 版本区块,并按精确字符串匹配小节标题。这些标题定义在 Changelogs.ts 的 ChangeType 枚举中:
export enum ChangeType {
LIBRARY_UPGRADES = '📚 3rd party library updates',
BREAKING_CHANGES = '🛠 Breaking changes', // 唯一能触发 MAJOR 的标题
NEW_FEATURES = '🎉 New features', // 触发 MINOR
BUG_FIXES = '🐛 Bug fixes',
NOTICES = '⚠️ Notices',
OTHERS = '💡 Others',
}
调用点位于 loadRequestedParcels.ts:minReleaseType: getMinReleaseType(changelogChanges)。也就是说,整个发布工具的升版建议完全由 Changelog 文本中的三级标题决定。
真实的 CHANGELOG 可以印证这套结构。以 packages/expo/CHANGELOG.md 为例,## Unpublished 之下依次是 ### 🛠 Breaking changes、### 🎉 New features、### 🐛 Bug fixes、### 💡 Others;每条变更带 PR 号与作者链接,例如「Raise minimum Node.js version to ^22.13.0 (#47202 by @kitten)」。
还有一个容易忽视的传播机制:helpers.ts 的 resolveReleaseTypeAndVersion 会调用 recursivelyAccumulateReleaseTypes 沿依赖树递归累积所有依赖包的 minReleaseType,再用 highestReleaseTypeReducer 取最高值。换句话说,依赖方被判定为 MAJOR,会把升版压力传递到当前包;此外从 SDK 分支发布时(分支名含 SDK 版本),即使累积出更高的类型也建议按 patch 处理(见 helpers.ts)。这两点决定了审查时不能只看单个包,还要看它在依赖图中的位置。
应标记的破坏性变更
一、破坏性变更写在错误的小节下
触发条件:diff 删除、重命名或改型了某个导出符号,把同步 API 改为异步,或改变了默认值/返回形状——但对应的 Unpublished 条目却挂在 🐛 Bug fixes、⚠️ Notices 或 💡 Others 之下。
审查时要读的是条目所在的三级标题,而不是「是否存在条目」。条目存在本身不够——存在性已由机械检查覆盖,只有标题驱动版本升版。这是该审查员职责边界的一个典型体现:机械工具管「有没有」,人工/模型判断管「挂在哪、说得对不对」。
二、exports 映射的解析陷阱
exports 字段的四类问题:
-
删除已有 key、把 key 重定向到别的文件、或用更窄的字面 key 替换一个宽泛的模式 key,且没有为旧 specifier 保留别名。审查时优先检查包末尾的通配符。以 packages/expo/package.json 为例,
exports的最后一项是:"./*": { "types": "./*.d.ts", "default": "./*.js" }它相对包根目录(而非
build/)解析。因此删除一个具名子路径后,请求通常会落进这个通配符,最终指向一个不存在的文件——错误在消费方解析时才爆发。 -
新增或修改的子路径在已发布形态下无法解析,具体形态包括:
- 没有
default条件; default被放在同一对象的其他条件之前(条件顺序有意义,default之后的内容全部是死代码);expo-source条件指向src/但没有build/下对应的构建目标,或反之;- 对于带
files白名单的包,目标路径的顶层段缺失于files数组。
packages/expo/package.json 的
files白名单就显式列出了build、internal、src、types、virtual等段,任何子路径目标若不落在这些段内,打包后就会缺失。 - 没有
-
条件顺序示例。同一文件中
.的types条件是这样组织的(package.json):".": { "types": { "expo-source": "./src/Expo.ts", "default": "./build/Expo.d.ts" }, "expo-source": "./src/Expo.ts", "default": "./build/Expo.js" }注意
expo-source是自定义解析条件,用于单仓库内部的类型解析(详见「不应标记」一节),default永远排在其后——顺序颠倒会让default之后的条件全部失效。
三、在本仓库仍能编译的类型级破坏
这是该审查规则中技术密度最高的部分,对应四类形态:
1. 符号从值位置退化为纯类型。 export { X } 被改成 export type { X };导出的 enum 被替换为类型别名或 as const 对象加联合类型;class 被替换为 interface。enum 提供值命名空间,而联合类型不提供,所以 EncodingType.UTF8 这种取值写法会直接编译失败。此外,任何对已有导出 enum 成员初始值的修改也应标记。
2. 消费方实现(而非调用)的类型上的参数加宽/返回收窄。 事件监听器与回调签名、hook 的选项回调、config-plugin 的函数类型、消费方需要实现或继承的接口上的方法——参数位置是逆变的:按窄参数写的处理器不再满足加宽后的签名。还要区分写法:方法语法成员(func(x: A): void)比属性语法成员(func: (x: A) => void)风险更高(属性语法在结构类型下允许放宽,方法语法则要求严格匹配),审查结论应说明你看到的是哪一种。
3. 三种「源码兼容但破坏消费方」的形状。 它们在本仓库里不报错,却在消费方项目里失败:
- 返回类型获得新成员:
T变成T | undefined或T | null; - 导出结果类型上的属性变为可选;
- 导出选项类型上的属性变为必填。
4. 泛型类型参数变更。 已有类型参数默认值改变、新增无默认值的类型参数、或类型参数约束收紧。修改默认值会静默改变裸引用的含义——不报错,类型却变了。
5. 子路径迁移。 导出从一个入口的 barrel 移除、改加到另一个子路径下——除非原入口保留该标识符并挂 @deprecated JSDoc 标签写明新 specifier。消息中指明新路径的抛错桩(throwing stub)算作迁移路径;静默搬家则不算。
四、包元数据变更
-
peerDependencies收窄:已有 peer 范围的下界提高、||范围中的某个分支被删除、或新增 peer 条目却没有peerDependenciesMeta.<name>.optional: true——除非同一 PR 还包含一条写明新必需版本的🛠 Breaking changes条目。npm 7+ 会自动安装 peer 并在依赖树无法解析时报错,因此收窄范围会直接变成安装失败。反向的(放宽范围)永不触发此规则。以 packages/expo/package.json 为参照:peerDependencies中@expo/dom-webview、react-dom、react-native-web等条目在peerDependenciesMeta中对应{ "optional": true },正是这套机制的标准用法。 -
sideEffects修剪:sideEffects被设为false,或数组中被删掉某个 glob,而被删 glob 匹配的文件仍在 import 期做工作(全局安装器、polyfill、原型补丁)。另外要标记新的 import 期副作用模块——裸import './Something.fx'、全局安装器、polyfill、原型补丁——出现在该数组任何 glob 都匹配不到的路径上。当数组携带成对的src/buildglob 时,两个必须同时具备。packages/expo/package.json 展示了成对写法:"sideEffects": [ "*.fx.tsx", "*.fx.web.tsx", "*.fx.js", "*.fx.web.js", "./src/winter/*.ts", "./build/winter/*.js", "./src/async-require/*.ts", "./build/async-require/*.js" ]
不应标记的情形(误报防线)
审查规则的一半价值在于抑制误报。以下五类明确不标记:
- 纯增量表面。 选项类型上新增可选属性、新导出符号、新
exports子路径、peer 范围放宽、把已有 peer 标记为可选——都不使现有消费方代码失效。同时永远不要要求作者修改包的version字段,版本由发布工具负责。 - 包自己实现、消费方只调用的函数上的参数加宽/返回收窄。 接受更宽的输入、返回更具体的值,对调用方都是向后兼容的。这是整个领域最可能的误报来源,报告前必须先确认该签名的角色:只有当同一签名同时是消费方实现、作为回调传递或继承的接口的一部分时,才升级处理。
- 刻意非公开的入口。
./internal/*子路径、src/internal/下的文件、unstable-前缀的子路径、或已带写明替代方案的@deprecated标记的符号——它们不携带稳定性承诺,不要为其要求破坏性变更条目或 major 升版。packages/expo/package.json 中的"./internal/*"通配子路径就是这类入口的实例。 expo-source条件指向src/的exports条目。 这是单仓库类型解析用的自定义条件,不是源码泄漏。不要要求把src加进files,也不要把files数组中的"!src"当错误看待。- API 变更后重新生成
docs/public/static/data/**。 文档工作流会自动完成,永远不要要求作者手动运行生成器。此外,「Changelog 条目缺失」或「条目缺 PR/作者链接」都已被机械检查强制——审查员只判断标题与措辞。
结论输出的证据标准
规则的最后一条是全篇的收束:必须指明哪段消费方代码会被破坏、如何被破坏。 如果你说不出具体哪个 import 或调用会停止工作,这个发现就不成熟。宁可为零发现,也不要给一条投机性的结论。这一标准与 coordinator.md 的汇总规则一脉相承——没有可追踪的失败路径的发现会被直接丢弃,与它声称的类别无关。
小结
| 审查维度 | 判定要点 | 源码/配置证据 |
|---|---|---|
| Changelog 标题 | 破坏性条目必须挂在 Unpublished 下的 🛠 Breaking changes 三级标题下 |
helpers.ts、Changelogs.ts |
| exports 映射 | 删除/重定向/收窄 key 需保留别名;条件顺序、default 存在性、files 白名单一致 |
packages/expo/package.json |
| 类型级破坏 | 值位置退化、逆变回调、三种源码兼容形状、泛型默认值 | 以 diff 中的类型声明为准 |
| 包元数据 | peer 收窄需 breaking 条目或 optional 标记;sideEffects glob 成对 | packages/expo/package.json |
| 误报抑制 | 增量表面、纯被调函数、内部/unstable 入口、expo-source 条件、机械检查项 | public-api.md |
对单仓库独立版本化发布的项目而言,这套规则的通用启示是:把「契约变更」与「机械可查项」分开,让工具守住存在性与格式,让审查者(人或模型)聚焦在标题语义、解析顺序与类型角色这三个 CI 盲区上,并用「说不出哪段消费方代码会坏就不算发现」作为输出的证据门槛。
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