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, 云安全监控, 代码质量分析, 依赖管理, 安全插件, 开发工具, 数据可视化, 暗色界面, 版本兼容性测试, 自定义脚本, 静态分析