PaulElM/blast-radius
GitHub: PaulElM/blast-radius
基于 TypeScript 语言服务解析公共代码仓库中的符号调用关系,帮助 npm 库发布者评估版本升级造成的破坏性影响范围。
Stars: 0 | Forks: 0
# blast-radius
**你可以看到下载量。但你看不到你的 API。**
一个准备发布 v2 版本的库发布者 —— 移除了一个导出,删掉了一个入口点,
更改了一个默认值 —— 面对“这会破坏谁?”这个问题时,只有一个老实的答案:发一篇 RFC,
然后祈祷。这个工具从公共生态系统中回答了这个问题:**谁调用了你的哪些
导出,通过类型系统解析,以及你计划发布的版本移除了哪些调用。**
## 报告
在阅读任何其他内容之前,请先阅读一份报告。以下三份报告均由本
仓库中的工具生成,数据来源于已发布的 npm 产物和公共 GitHub 代码。
| 报告 | 目标 | 结果 |
|---|---|---|
| [**`drizzle-orm` 0.45.2 → 1.0.0-rc.4**](reports/drizzle-orm-1.0.md) | 一个正在分阶段发布的主要版本 | **2,786 个已解析的代码库中有 277 个被破坏**,每个都标明了具体的文件和行号 |
| [**`typeorm` 0.3.31 → 1.1.0**](reports/typeorm-1.1.0.md) | 一个已经发布的主要版本 | **2,415 个已解析的代码库中有 441 个被破坏**,其中 343 个在运行时或导入时发生错误 |
| [`@supabase/supabase-js` 2.110.8 → 3.0.0-next.29](reports/supabase-js-negative-control.md) | 一个正在进行的重大版本预发布 | **146 个中有 0 个被破坏** —— 移除了 7 个导出,但没有人在使用它们 |
最后一份报告正是前两份报告的意义所在。一个只会报告破坏的工具,与
一个凭空捏造破坏的工具是无法区分的;阴性对照(negative control)才使得
阳性结果具有意义。所有报告都是使用同一个命令运行的。
**这两份阳性报告回答了不同的问题,在阅读它们之前,了解这种差异是
很有必要的。** drizzle 的版本在测量时仍处于预发布(staged)状态,因此该报告是一个警告:*这些是
你未发布的版本将会破坏的消费者。* typeorm v1 已于 2026-05-19 发布,因此该报告
是一个测量结果:*这些是尚未迁移的消费者* —— 即发布者尚未迁移的尾部用户,按名称和行号
详细列出。它统计的每一个 symbol 在 0.3.x 版本中都带有 `@deprecated` 标记,这一点在报告中
直言不讳地指出了,因为这会改变数字的含义。
drizzle 报告中有两件事是任何 changelog、下载数量或依赖图都无法展示给你的:
- **`relations` 从根入口点消失了** —— 231 个代码库,931 个
调用点 —— 仅在 `drizzle-orm/_relations` 中幸存。
- **[整个 dialect 被移除了,但没有任何 changelog 提及此事](reports/drizzle-orm.notes.md)**:
61 个 `gel-core` 入口点,而发布者自己在线的官方文档页面
仍然告诉用户去 `import ... from 'drizzle-orm/gel'`。
typeorm 报告中的那个问题,其形式相同但指向了
另一个不同的问题:旧版的全局连接 API —— `createConnection`(146 个代码库),`Connection`(117 个),`getRepository`(94 个),`ConnectionOptions`
(65 个),`getConnection`(58 个) —— 从根入口点消失了,并且除了
七个受影响的代码库外,没有其他入口点可以重新指向,
因此修复方案是重写代码而不是更改导入路径。
**[该报告的笔记](reports/typeorm.notes.md) 中说明了是哪七个代码库,以及
为什么核心数据代表的是两个人群而不是一个人群** —— 大部分被移除的
接口是 typeorm 以前重新导出的 `mongodb` 驱动程序类型定义,这是一个
实质性的破坏,但不是 typeorm 自身的设计。`ObjectId` 和 `Timestamp` 仍然
存在于 `mongodb` 包中,因此这些代码库只需修改一行导入代码即可。
**该报告还指出了其未涵盖的内容**:在 17,216 个候选
文件中,仅打开了 3,696 个,因此受影响的代码库列表只是一个下限(保底值),其中没有出现某个代码库,
并不能证明它是安全的。
## 四个层级
| 层级 | 能告诉你 | 不能告诉你 |
|---|---|---|
| npm 下载量 | 一个 tarball 被移动了多少次 | 是谁下载的,或者代码是否会运行 |
| dependents / "Used by" | 谁在清单中列出了你 | 他们使用了你的哪些 symbol |
| 跨公共代码进行 grep | 谁提到了你的字符串 | `format` 到底是你的还是本地的 |
| **blast-radius** | **谁调用了哪个导出,是解析出来的而非匹配出来的** | — |
证明第四个层级具有重要性的先例:Rust 在确认引入破坏性更改之前,会跨越约 44,000 个公共
crates 运行 `crater`,而 Chrome 不会移除使用率高于约 0.03% 的 Web API。
这两者都是在内部构建的,因为无法直接购买。
## 状态
两个预注册的破坏性测试(breaker),均针对可能悄无声息地产生
错误自信结果的层级。
| 破坏性测试 | 被测层级 | 阈值 | 结果 |
|---|---|---|---|
| **KT-A** | symbol 归因 | >20% 假阳性 → 停止 | **0 / 60** —— [完整审计](docs/audit-attribution.md) |
| **KT-C** | 导出接口差异对比 | >20% 假阳性 → 不予发布 | **0 / 25**,加上 76/76 入口点普查 —— [完整审计](docs/audit-surface-diff.md) |
KT-A 手动检查了两个具有**相反导入风格**的包中各 30 个随机归因结果
—— `zod`(命名空间风格的 `z.string()`)和 `date-fns`(作为普通英语单词调用的裸命名导出:`format`、`parse`、`add`)。第二个
包证明了该结论的有效性:一个被审计的文件从 date-fns 导入了
`{ format as formatDate }`,*并且* 在同一行有一个名为
`format` 的无关局部变量。该工具成功将调用归因于导入,并忽略了局部变量。
而基于文本匹配的实现则会将两者都计算在内。
**KT-A 是一个精确度测试,而精确度并不等同于实用性。** 它无法发现错误的
*计数*,因为错误计数背后的每一个归因
本身都是正确的。这就是为什么 census 层有其自己的测试,而 diff 层也需要
有其专属的破坏性测试。
## 为什么这不只是 grep
每一次归因都从 **import 绑定** 开始,并通过 TypeScript language service 进行了解析。已验证的行为(`test/attribute.test.mjs`):
| 情况 | 是否已处理 |
|---|---|
| `import { z as v } from 'zod'` | 归因为导出的 `z`,而不是本地的 `v` |
| `import * as zod from 'zod'` | 成员被解析:`zod.string()` → `string` |
| `import z from 'zod'` | 扎根于 `default`,然后进行规范化(见下文) |
| `const { z } = require('zod')` | 已归因 |
| 被遮蔽的绑定 (`function f(z)`) | **不**予归因 |
| `zod-validation-error`, `@hono/zod-validator`, `./zod` | **不**予归因 |
| 已导入但从未使用 | 报告为未使用,绝不计为使用 |
| `import type` / `{ type X }` | 标记为仅限类型,不计入运行时统计 |
| `export { z }` 重新导出 | 排除在普查之外 |
| `z.string().min(8)` | `z.string` —— 链条在调用处停止 |
| `z.coerce.number()` | `z.coerce.number` —— 两个层级均保留 |
## 流水线
```
(a) code search for candidate consumers src/github.mjs
(b) fetch each file (cached, resumable) src/github.mjs
(c) resolve imports -> symbols src/attribute.mjs
(d) census, canonicalised per entry point src/census.mjs
(e) public-API diff of the two versions src/surface.mjs
(f) report: markdown + machine-readable src/report.mjs
```
步骤 和 关闭了导致早期
所有计数仅为下限的两个缺陷:
- **根碎片化。** `z.string`(命名导出)、`string`(命名空间)和
`default.string`(默认导出)是同一个 symbol 却被计算了三次。
规范化(Canonicalisation)**并不是**字符串规范化 —— 将 `default.x` 合并为 `x`
仅在包未发布默认导出时才是正确的,这是一个针对特定包的事实,需从 artifact 中读取,而永远不是基于我们碰巧最先看到的那个包
硬编码的规则。
- **子路径扁平化。** `drizzle-orm/pg-core` 被并入 `drizzle-orm`。
对于发布者来说,这些是不同的导出接口 —— 而在第一个真实
目标上,76 个完整的入口点在下一个主要版本中消失了,因此将它们扁平化
将会掩盖最大的一类破坏。
## 接口是从已发布的 tarball 中读取的,而不是从仓库中读取的
在两个版本上运行 `npm install --ignore-scripts`,然后通过 TypeScript 编译器读取它们的 `.d.ts`,并跨越模块*和包*
边界追踪重新导出。tarball 才是消费者实际安装的内容;仓库树可能会
导出在构建时被剥离的内容,并且可能停留在无人发布的 commit 上。
## 用法
```
GITHUB_TOKEN=$(gh auth token) node bin/blast-radius.mjs report \
[--from latest] [--to next] [--languages typescript,javascript] \
[--pages N] [--max-files N] [--max-queries N]
```
`--to` 接受一个 dist-tag。一个处于 **比 `latest` 更高主版本号** 的 `next` / `beta` / `rc` 标签
正是本产品旨在应对的触发事件:发布者已经在一个公开的、机器可读的地方
做出了承诺。
`--languages` 接受一个列表,并且会搜索给定的每一种语言 —— 每个
查询使用一个 `language:` 限定符,所有这些都在一次运行中完成。无论你传入什么列表,报告的
limits 部分及其 JSON `coverage.scope` 都会将其命名;这两者
都不是固定的字符串。上述三份报告均为仅针对 typescript 的运行结果。
每个 API 响应都缓存在 `data/cache` 下,因此重新运行不会消耗任何请求,并且
中断的运行会恢复而不是重启。报告会存放在 `reports/` 中。
## 测量出的限制条件(经过实际验证,非假设)
- 代码搜索:**10 次请求/分钟**,每个查询**最多 1000 条结果**;核心 API 5000 次请求/小时
- `GET /repos/{o}/{r}/dependents` → **404,不存在该 API**(没有免费的替代品)
- **`total_count` 不能作为分母。** 它在同一语料库的分区中
是不可加的:`"drizzle-orm" language:typescript` 报告有 3,916 条,而单一区间
`size:2001..8000` 报告了 61,168 条。未分区的总数和
`size:0..100000` 完全一致,因此有界形式是合理的,而狭窄的区间则不然。**结论:任何报告都不得声明“占你消费者的百分比”这一数据。**
只能对指定的、枚举出的语料库进行绝对计数 —— 这正是购买者
想要的:一份清单,而不是统计数据。
- 使用**开放前缀**进行搜索(`"from 'drizzle-orm"`,没有闭合引号)是
必须的。闭合形式无法匹配 `from 'drizzle-orm/pg-core'`,因此对于
一个拥有 443 个入口点的包,旧查询在结构上对所有子路径导入都是盲目的。
## 已知限制 —— 在信任报告之前请务必阅读
1. **仅限公共代码。** 私有仓库、企业客户以及任何位于
VPN 之后的内容都是不可见的。所有这些都会将实际数字推**高**,永远不会推低 —— 每一个
计数都是一个下限。**上述三份报告仅扫描了
`language:typescript`,每一份都在各自的限制部分中说明了这一点** —— 语言覆盖范围是运行的一个属性,而不是工具的属性:`--languages`
接受一个列表,收集器会搜索每一种语言。
2. **是 `HEAD`,而不是发布标签。** 消费者可能已经在某个分支上完成了迁移,或者
在生产环境中锁定了旧版本。
3. **函数签名不在范围内。** 一个保留下来但具有不兼容
签名的 symbol 会被视为*未更改*。成员分析**仅深入一层**。
4. **单文件解析。** 一个通过消费者自己的
barrel 重新导出的 symbol 会被归因到 barrel 处,而不是最终的使用点。这会少算受影响的
文件;它不会将其错误归因。
5. **抽样框架偏差已披露,但未纠正。** GitHub 的代码
索引是否偏向于流行的代码库尚未经过测量。
测试
```
npm install && node --test 'test/*.test.mjs'
```
## 关于这些报告中提及的代码库
这些报告提及了公共 GitHub 代码库,并链接到了使用 symbol 的确切文件和行号。
这是刻意为之的:一份发布者可以打开并检查的清单,胜过一个他们必须信任的统计数据。所引用的
所有内容都是公共代码,通过公共 GitHub API 读取,并
链接回其源码。
这里的一切都不代表对这些代码库或其作者的任何评价。
使用一个库后来移除的 symbol 并不是一个错误 —— 在发布落地之前而不是之后被告知
才是全部的意义所在。如果你维护的代码库在报告中
被提及且你希望不被包含在内,请提交一个 issue,它将从已发布的 artifact 中被移除。
每份报告都是文件中记录的日期对应 `HEAD` 的快照。一个
代码库可能此后已经迁移,或者可能在生产环境中锁定了一个旧版本,
而这两者在这里都是不可见的。
标签:MITM代理, SOC Prime, TypeScript, 云安全监控, 代码质量分析, 依赖管理, 安全插件, 开发工具, 数据可视化, 暗色界面, 版本兼容性测试, 自定义脚本, 静态分析