关于 whyts
whyts 是面向 TypeScript 开发者的命令行工具,用于测量编译器性能并定位缓慢检查。它为已识别的模式提供证据和修复建议,并可比较修改前后的运行结果。
主要功能
- 01分析编译器诊断与跟踪记录
- 02检查导入关系
- 03识别已知性能模式
- 04为匹配模式提供修复建议
- 05比较修改前后的测量结果
适用场景
- 01定位缓慢的 TypeScript 检查
- 02调查递归类型实例化错误
- 03评估类型修改是否提升性能
MCP 相关
README 还介绍了配套 MCP 服务 `whyts-mcp`,可向 AI 编码 Agent 提供相同证据。可通过 `claude mcp add --transport stdio whyts -- npx -y whyts-mcp` 使用 stdio 添加。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
3收录的工具列表 · 更新于 1 周前
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| runs | integer | 否 | 用于稳定检查耗时的不带跟踪计时运行次数(默认值为 1)。每次运行都会额外执行一次完整类型检查。使用 3 次或更多次可计算中位数。 |
| project | string | 是 | 包含 tsconfig.json 的目录的绝对路径,或 tsconfig 文件的绝对路径。 |
| typescript | string | 否 | 要使用的 typescript 包目录路径,以替代项目中的版本(5.x、6.x 或 7.x)。 |
| maxOldSpaceMb | integer | 否 | 编译器进程的堆内存限制,单位为 MB。TypeScript 7 中忽略。 |
| timeoutSeconds | number | 否 | 每次类型检查的编译器超时秒数。默认值为 900。 |
详细介绍
whyts
找出 TypeScript 项目变慢的原因。
一款小巧的命令行工具,可将编译器诊断、跟踪信息和导入关系转化为可采取行动的依据。它会指出检查速度慢的地方;对于三种已知模式,还会说明原因和应对方法。无需账户、API 密钥或大模型。
npx whyts
在 tsconfig.json 所在文件夹中运行。它会运行一次真正的编译器,并最多打印三条操作建议。要证明类型变更确实有所帮助,请使用 npx whyts compare(见下文)。要将相同的依据提供给 AI 编码 Agent,请使用 claude mcp add --transport stdio whyts -- npx -y whyts-mcp。
whyts 发现的案例:一次耗时 7.4 s 的类型检查优化到了 0.06 s(TanStack Form),以及 vue-tsc 从 80 s 降至 0.56 s(Vue)。请参阅实际案例。
试用
需要 Node.js 20+,并且 TypeScript 项目已安装依赖。
npx whyts --project .
为确保运行结果可复现,请固定版本:npx [email protected] --project .
从检出目录运行:
git clone https://github.com/musatoktas/whyts.git
cd whyts
npm ci --ignore-scripts
npm run demo
node src/cli.js --project /path/to/your/tsconfig.json
诊断与解决方法
默认情况下,whyts 最多打印三条操作建议和一行摘要。每条建议都包含一种模式、一个位置、一条简短的解决方法和一个链接。上图是一次录制的运行结果。使用 --verbose 查看完整报告。
whyts 0.8.5 · TypeScript 5.9.3 · repro-project/tsconfig.json
1. [measured] Recursive type instantiation: DeepKeys<Values>
Where: k.ts:4:17
TS2589 at this place: Type instantiation is excessively deep and possibly infinite.
It hit the instantiation limit inside DeepKeysAndValuesImpl (../src/util-types.ts:151).
DeepKeysAndValuesImpl refers to itself: DeepKeysAndValuesImpl -> DeepKeyAndValueArray -> DeepKeysAndValuesImpl.
Type argument JsonData (k.ts:2): JsonData refers to itself.
Fix: Stop the expansion of DeepKeysAndValuesImpl when it meets a type it already visited. Or add a depth limit to it. Check that the fix keeps the same results.
Cost: 10082.6 ms, at most 98.8% of Check time (upper bound, not a saving)
Docs: https://github.com/microsoft/TypeScript/wiki/Performance
Check 10.20s · Total 10.80s · 65 files · 1 compiler error · 2 findings in the full report. Use --verbose to see them.
八种模式都有对应的解决方法。每种模式都是在真实项目中发现后才加入的。
| 模式 | 跟踪证据 | 解决方法 |
|---|---|---|
recursive-type-instantiation | TS2589 错误以及 instantiateType_DepthLimit 事件。whyts 会找到在达到限制时拥有类型参数的类型别名、别名之间按名称形成的引用循环,以及引用自身的类型参数 | 在已访问过的类型处停止展开,或添加深度限制 |
partial-constraint | 类型引用处出现 TS2589 错误,泛型类型内部出现 instantiateType_DepthLimit 事件。被引用声明的某个类型参数有一个约束,该约束为该泛型类型提供的类型参数少于其声明的数量,因此省略的参数会采用默认值 | 在约束中提供所有类型参数,例如每个参数都用 any。如果约束来自依赖项,请告知其维护者 |
instantiation-expression | 类型引用处出现 TS2589 错误,该引用指向一个主体为条件类型的类型别名,例如 ReturnType 或 Parameters。某个类型参数是实例化表达式(typeof f<T>),且 instantiateType_DepthLimit 事件不在该别名内部 | 将条件类型直接写在原处,并将该表达式作为被检查的类型。对于 ReturnType,可以改用 f 的声明返回类型 |
builder-chain | 方法调用链开头出现 TS2589 错误,类内部出现 instantiateType_DepthLimit 事件。沿变量回溯后发现,链中调用次数最多的方法以该类为参数并返回该类,且调用至少 32 次 | 将调用拆成多个独立值,每个值最多包含 32 次调用,然后用同一个方法连接它们。将链拆分到变量中并无帮助 |
nested-declaration | 类型处或声明名称处出现 TS2589 错误,泛型类型内部出现 instantiateType_DepthLimit 事件。通过类型别名、接口和 typeof,该类型最终到达一个值,而其在声明文件中的书面类型将该泛型类型嵌套至少 8 层 | 手动将数据类型写成具名接口,并在源代码中为该值标注一个引用这些接口名称的类型。如果声明来自依赖项,请告知其维护者 |
callback-inference | 检查链的焦点是一个耗时至少 200 毫秒的调用;记录到的类型比较和嵌套检查覆盖该调用的比例不超过 25%。跟踪中没有类型参数推断事件。该调用未提供类型参数,被调用方的泛型声明有一个使用其类型参数的参数类型,而传入该参数的是一个具有自身类型的函数:函数名称,或所有参数都有类型的函数 | 在声明中,将整个参数类型(对于剩余参数,则为其元素类型)包裹在 NoInfer<...> 中(TypeScript 5.4 或更高版本)。在调用处提供类型参数,或传入一个不带参数类型的箭头函数。如果声明来自依赖项,请告知其维护者 |
variance-computation | getVariancesWorker 事件包含类型 ID、类型参数数量和结果。whyts 会报告最外层事件、最大的嵌套事件及其总耗时 | 减少类型参数,简化返回类型。添加 in 或 out 注解并不能保证解决问题 |
program-load | 程序很大,而检查耗时不长。查看编译器计数器(Total time、Check time、Memory used、parse 和 bind 时间)以及导入图。whyts 会报告来自 node_modules 的文件、最大的包、引入其中最多文件的文件,以及引入它们的 typeof import() 引用 | 使用范围更窄的入口点,或请包的维护者将这些引用移到选择性启用的入口点。skipLibCheck 无济于事:它跳过检查,而不是加载 |
program-load 操作示例如下(vuetify 4.2.4,一个仅包含 import { createVuetify } from "vuetify" 的项目;以下行已缩短):
1. [measured] Program load: 421 dependency files, vuetify has 407; Check time is 0.01s of 4.22s
Where: ../node_modules/vuetify/lib/framework.d.ts:1:1
The program has 480 files: 421 in node_modules (13.8 MB of source), 58 compiler library files and 1 of your own. ... Memory used 577 MB in the traced run.
Largest packages by files: vuetify 407 (12 MB), @vuetify/v0 3 (0 MB), vue 2 (0 MB), ...
409 of them (11.9 MB) come only through 218 typeof import() type references in ../node_modules/vuetify/lib/framework.d.ts, to ../node_modules/vuetify/lib/components/index.d.ts (192), ...
Shortest import chain: index.ts -> ../node_modules/vuetify/lib/framework.d.ts -> ../node_modules/vuetify/lib/components/index.d.ts
Fix: Each typeof import() reference in ... loads its target for every project that imports the package. Ask the maintainers to move them to an opt-in entry point. skipLibCheck skips checking these files, not loading them. ...
规则:
- 当编译器记录了诊断信息(错误或跟踪事件)时,诊断结果才算作
measured。按名称遍历类型别名只是语法分析:如果循环经过无法按名称匹配的导入,或经过并非程序声明的类型,就可能无法发现。 - 方差模式要求联合类型的耗时总计达到 100 ms,且只统计耗时达到 50 ms 的事件。它只列出项目中声明的类型。依赖项中的类型不会提供修复建议,因为项目无法修改它。
- 耗时是该位置记录到的最长时间段,以不超过 Check 时间的 N% 表示。这是跟踪条件下的上限,不是预计可节省的时间。
- 在精简报告中,没有已知模式的慢速检查只有在耗时超过 Check 时间的 3% 时才会显示。如果某项诊断已按类型名称解释了某个检查,则不会重复列出。
- TypeScript 5.9、6.0 和 7.0 中都有这些跟踪事件(
instantiateType_DepthLimit、getVariancesWorker)。在 TypeScript 7 中,这些事件带有checkerId;whyts 使用一个 checker,因此类型 ID 保持唯一。 program-load模式需同时满足以下条件:来自node_modules的文件达到 200 个或以上;Total 时间达到 1 s 或以上;Check 时间不超过 1 s,且不超过 Total 时间的 10%;并且有一个文件单独引入的文件达到 100 个或以上(且占依赖文件的 25%)。编译器库文件和你自己的文件不计作依赖文件。包含正常规模自有代码的项目会有更长的 Check 时间,因此不会匹配。计数器来自跟踪运行,此时 Memory used 高于普通运行。program-load衡量的是程序,而非检查。这并不表示移除这些文件就能加快检查:此类程序的检查时间本来就很短。它会将声明文件中的typeof import('pkg')视为导入。如果某个文件也由常规导入引入,就不会计作仅由这些引用引入。callback-inference是根据调用形式以及跟踪未能解释的耗时进行匹配。跟踪没有推断事件,因此 whyts 无法证明耗时来自推断。它只读取被调用方声明和参数的语法,不知道编译器选择了哪个重载。即使类型参数在参数类型深处被NoInfer包裹,也仍会匹配:在 telegraf (#2016) 中,这种形式并未改变耗时,而将整个元素类型包在NoInfer中则改变了耗时。- 修复建议是简短的操作说明,用来指出一个方向。whyts 并未在你的项目上测量任何修复方案。
实际案例
whyts 在以下每个案例中都找到了问题所在。tRPC 的修复已合并。其他修复在相关项目中仍是开放的拉取请求,因此在维护者合并之前都只是提案。Vuetify 一行是在 issue 中进行的一项测量,没有修复方案。每个数字都来自我在所述编译器版本上的实测。
| 项目 | whyts 发现的问题 | 结果 |
|---|---|---|
| tRPC (#6997) | 当类型包含递归 JSON 类型(例如 Prisma.JsonValue)时,Serialize(serialize.ts:35)中出现 TS2589 | PR #7660 中的修复已于 2026-10-19 合并。复现用例在 main 上出现 TS2589,应用修复后不再报错。实例化次数从 89,700 降至 78,502。packages/server 的类型检查时间没有变化(在误差范围内) |
| TanStack Form (#1474) | DeepKeys 中出现 TS2589:类型别名通过 DeepKeyAndValueArray 引用了自身 | PR #2422 中的修复。复现用例的检查时间从 7.4 s 降至 0.06 s(whyts compare,分开运行 10 次)。form-core 的耗时变化在误差范围内 |
| TypeORM (#8559) | 对于包含自身的类型,QueryDeepPartialEntity(QueryPartialEntity.ts:17)中出现 TS2589 | PR #12943 中的修复。whyts 在更改前报告了诊断结果,更改后则没有报告 |
| Vue (#14323) | defineModel 配合一个以 void 结尾的 defineEmits 调用签名,会使 OverloadUnionRecursive(@vue/shared)运行至深度限制。这占检查时间的 100% | PR #15851 中的修复。使用 1、2 和 3 个 emits 时,vue-tsc 耗时分别从 31.1、55.3 和 80.0 s 降至 0.42、0.49 和 0.56 s(TypeScript 5.9.3)。包含 40 个重载和一个 model 的组件仍会触发 TS2589,与修复前一样 |
| Hono (#2399) | 使用递归 JSON 类型时,JSONParsed(src/utils/types.ts:53)中出现 TS2589 | issue #5543 中的修复会在遇到已访问类型时停止。深度限制会使 .d.ts 输出达到 313,638 字节;该修复将其保持在 762 字节 |
| node-redis (#2975) | 在一个结构比较中,两个 RedisClusterType 实例化耗费了 4.3 s 检查时间中的 4.1 s | 评论中给出了从 4.7.0 到 6.3.0 的测量数据 |
| Vuetify (#23005) | 一条 import { createVuetify } from "vuetify" 会使一个包含 480 个文件的程序(在 4.2.4 上,tsc:Memory used 535,557K,Check time 0.01 s,Total time 2.56 s)。program-load 指出了 lib/framework.d.ts:其中的 218 个 typeof import() 引用(包括 GlobalComponents 块)引入了 421 个依赖文件中的 409 个 | 该 issue 仍未关闭。这里没有提出修复方案。仅清空 GlobalComponents 块后,文件数变为 111,Memory used 为 101,701K。这只是对上限的测量,并非修复:它会移除全局组件类型。测量使用 4.2.4 和 TypeScript 5.9.3 |
检查结果
| 检查项 | 证据 | 不表示什么 |
|---|---|---|
| 源码检查链 | 按同一线程的时间嵌套关系分组的相关表达式/变量位置、链的总耗时、包含在链中的类型比较及其名称和声明位置 | 嵌套关系或其中包含的比较能够证明变慢的原因 |
| 广泛包含 | 生效的 include 和配置的根目录数量 | 每个广泛包含的文件都是不必要的 |
| 值得检查的根目录 | 没有观察到导入者的生成目录和输出目录。仅统计测试和 spec 目录 | 未被导入的文件可以安全删除 |
| 重复类型版本 | 程序中加载的多个版本的 @types 包 | 所有重复版本都可以安全地统一 |
| Barrel 影响范围 | 再导出数量、导入者、传递文件范围、已配置为根目录的被影响文件 | 检查耗时全部由此造成、打包器的行为或必然节省的耗时 |
| 耗时较长的文件检查 | 记录的 checkSourceFile 跟踪区间 | 对总耗时的完整归因或确切的高耗时类型 |
| 编译器错误 | 前 5 个错误、错误代码数量,以及未解析模块占主导时的警告 | 缺少依赖时计时结果仍然有效 |
| 递归类型实例化 | TS2589 的位置、达到实例化限制时的类型别名、基于名称的引用循环、自引用类型参数 | 列出的循环是唯一原因 |
| 方差计算 | 项目类型最外层的 getVariancesWorker 事件、嵌套类型、联合类型耗时 | 添加方差注解就能消除这部分耗时 |
检查结果标记为已测量、已观察或待检查。已测量的源码检查链会优先显示,并优先考虑项目代码而非依赖项。低于 10 ms 的链跨度和低于 100 ms 的文件跨度会保留在记录的数据中,但不会作为检查结果列出。结构性建议会列在单独的部分中。建议的更改需要重新测量并检查行为。whyts 绝不会凭空编造预测的提速效果。
从文件到表达式及其类型
npm run demo:types
这个 16 行示例将一个包含 676 条路由的映射 API 客户端赋值给公共客户端形状。报告可以显示赋值位置 client.ts:16:14、该检查中记录的 FullClient 与 PublicClient 的比较,以及它们在第 8 行和第 11 行的声明。例如,在一次本地 TypeScript 5.9.3 运行中,变量检查耗时 38.4 ms,这次比较耗时 31.8 ms。这些是该次运行中的包含式采样数据,并非稳定的基准测试结果或加速声明。快速运行可能不会采样到表达式/比较事件。
同一文件和跟踪线程中的嵌套检查会显示为一条链,为其他耗时检查留出空间。每条链都包含其入口位置,以及最多五个相关检查。焦点是记录时长至少占链记录时长 80% 的最深层非标识符检查;若没有,则选择链中时长最长的检查。这是一种导航启发式方法;延后执行的检查可能会访问更早的源代码行,而时间上的嵌套并不代表 AST 包含关系,也不能证明因果关系。各成员的包含式时长会重叠,不应相加。
类型 ID 会从编译器的 types.json 中有选择地解析,包括大于 128 MiB 的文件。whyts 会逐个读取描述符,并仅保留所选报告条目所需的 ID。命名类型、编译器标志(若有)时的联合/交叉成员数量,以及声明位置,都能提供具体的调查切入点。项目的编译器扫描器会跳过前导空白和注释,以显示声明的第一个标记。未解析的 ID 会明确保留。没有包含它的已记录表达式的比较会单独列出,不提供源代码归属信息。定义位置可能指向依赖项;它们仅提供上下文,并非建议的修复方案。
项目文件区间有单独的列表,因此大型依赖项检查不会掩盖所有项目文件。如果某个 barrel 所涉及的所有文件都已是配置的根文件,whyts 会说明为什么仅更改该导入无法将这些文件移出程序。
检查时间占比
每条测量的链和文件区间都会显示一个上限。终端报告会显示“最多占检查时间的 N%”。JSON 中对应的字段为 checkTimeShareUpperBoundPercent。
该值是包含式区间时长除以编译器的 Check time。它不是预测值。编译器在跟踪运行期间记录了该区间,而跟踪运行比正常运行更慢。更改代码并不会消除全部占比。
解释文件
node src/cli.js explain src/shared/value.ts --project /path/to/project
文件路径相对于所选的 tsconfig 目录。结果会显示一个配置根文件原因,或一条观测到的最短导入/引用链,以及直接导入方。导入解析会遵循 TypeScript paths、重新导出、动态导入以及 ESM/CJS 使用模式。
这是一个精简说明,并非 tsc --explainFiles 的完整实现。库文件引入、环境类型指令、符号链接和项目引用重定向可能需要使用编译器自身的说明。
JSON 和自动化
node src/cli.js --project apps/web/tsconfig.json --json > report.json
node src/cli.js --project apps/web --timeout 300 --no-color
0.7 版新增了附加字段 diagnoses,不会更改现有字段。每个条目包含 pattern、title、confidence(measured)、location、milliseconds、checkTimeShareUpperBoundPercent、evidence、remedy 和 docs。evidence 包含 event、typeName、location、时间和模式字段。compilerErrors 新增了列表 excessiveDepth,其中列出 TS2589 的位置。--verbose 选项只会更改终端输出。JSON 始终是完整的。
JSON 包含 schemaVersion、编译器版本、摘要、带单位的诊断信息、带证据的发现、热点和警告。0.3 版新增了 sourceGroups 和 typeDescriptors,同时保留架构版本 1 以及现有 hotspots、projectHotspots、sourceHotspots 和 typeHotspots 的含义。现在,实测源代码发现使用规则 source-check-chain 和链式证据。sourceGroups 包含 root、members、memberCount、包含式 milliseconds,以及所选表达式的 focusMilliseconds。0.8.10 版新增了 focusRecordedMilliseconds:即 focusMilliseconds 中处于已记录的类型比较和焦点内嵌套检查中的部分。typeDescriptors 会记录文件、扫描和保留的字节数,以及请求和解析的 ID 数量。源代码和类型记录包含以毫秒为单位的时长;源代码和声明位置使用从 1 开始的 line/character,源代码的 pos/end 则是编译器 UTF-16 偏移量。进度信息输出到 stderr。退出代码:
| 代码 | 含义 |
|---|---|
0 | 分析已完成,且类型检查成功 |
1 | 工具/配置失败、不支持的编译器或超时 |
2 | 报告可用,但编译器报告了错误 |
仅有发现不会导致 CI 失败。此版本会进行诊断,不会强制执行性能预算。
使用原生 TypeScript 7 运行时,JSON 会多出四个字段:compiler(native)、experimental(true)、graphTypescriptVersion 和 checkers(1)。typescriptVersion 是原生编译器的版本。
重复测量
一次运行的证据并不充分。在我们的测试中,同一代码树上的 Check time 在不同运行间最多相差 10%。使用 --runs 进行多次测量。
npx whyts --project . --runs 5
工作原理:
- whyts 首先执行常规的跟踪运行。所有关于链和文件的结果都来自这次运行。
- 然后 whyts 在不启用跟踪的情况下再运行 N 次。跟踪会拖慢编译器,并生成大量类型转储。
- 每次运行都会使用新的临时增量缓存。每次运行都会检查整个程序。
- 标志与跟踪运行时相同,但不包含
--generateTrace。你自己的构建缓存保持不变。 - 跟踪运行此前已读取所有文件。所有 N 次运行的文件缓存都是热的。在此模式下,whyts 不会添加预热运行。
- 报告会显示中位数、最小值、最大值和离散度。离散度为(最大值减去最小值)除以中位数,以百分比表示。
- 默认值为
--runs 1。运行一次不会额外增加运行次数,也不会包含timings字段。
报告首行中的 Check time 和 Total time 来自跟踪运行。进行前后对比时,请使用未跟踪运行的数值。
JSON 会新增一个 timings 字段。架构版本 1 保持不变。该字段包含 mode、runs、checkers、flags、compilerFiles、compilerExitCodes、checkTime 和 totalTime。每个时间对象都包含 unit(s)、runs、values、median、min、max 和 spreadPercent。编译器会输出四舍五入后的数值。JavaScript 编译器以 10 ms 为步长输出。
TypeScript 7:未跟踪运行使用 --checkers 1,与跟踪运行相同。我们对 tRPC packages/server 项目在每种模式下交错进行了 15 次 Check time 测量。使用一个检查器时,离散度为 18.1%;使用默认检查器时为 19.2%。我们没有发现默认模式能提高稳定性。使用一个检查器可使标志与跟踪运行保持一致,因此链时间和 Check time 描述的是相同工作。请勿将此时间与默认并行的 tsc 运行进行比较。此版本不支持多个检查器。
比较两个项目或两份报告
实时比较会在此计算机上运行两个项目:
npx whyts compare --baseline ./before --candidate ./after --runs 10
每一侧都可以是一个目录或 tsconfig 文件。--runs 是每一侧的测量运行次数。默认值为 5,范围为 1 到 100。--timeout、--max-old-space-size、--typescript 和 --json 选项也适用。共享的 --typescript 会让两侧使用同一个编译器。
离线比较会读取两份 JSON 报告:
npx whyts --project ./before --runs 10 --json > before.json
npx whyts --project ./after --runs 10 --json > after.json
npx whyts compare before.json after.json
顺序和预热
- 每一侧都会先运行一次,先基线,再候选。这些预热运行会记录在 JSON 的
warmup中,不计入数值。 - 首次运行会从磁盘读取所有文件。先运行的一侧会独自承担这项开销。在我们的测试中,文件缓存冷启动时的首次运行,其 Total time 比后续运行高出 10% 到 15%。Check time 没有变化。
- 随后成对进行测量运行,并且每对内部的顺序每次都会翻转:A B、B A、A B、B A。A 是基线。
- 按此顺序运行时,计算机速度缓慢变化不会让任何一侧占优。当 N 为奇数时,基线会比候选多先运行一次。
- 所有运行都使用
--runs的未跟踪模式。JSON 会在order中显示顺序。
噪声规则
规则很简单,并非统计检验。
- 对每一侧,取测量值中的最小值和最大值。
- 如果两个范围有重叠,结果为
within-noise。范围仅在边界处相接也算重叠。 - 如果两个范围没有重叠,结果为
separated。候选项为faster或slower。 - 每一侧至少需要 3 次测量运行。少于 3 次时,结果为
insufficient-runs。
中位数之差以毫秒和基线中位数的百分比表示。此规则不提供置信区间或效应量。
如果两侧具有相同的分布,且运行彼此独立,那么范围不重叠的概率为 2 除以 C(2N, N)。N = 3 时为 10%,N = 5 时为 0.79%,N = 10 时为 0.0011%。JSON 会在 rule.chanceWithoutDifference 中将其显示为一个分数。负载峰值或漂移会破坏这一假设。至少运行 5 次。进行比较期间,请停止机器上的其他高负载工作。
此规则较为保守。轻微变慢可能会得到 within-noise。运行次数越多,范围越宽,因此增加运行次数并不总能带来帮助。在我们的测试中,Check 时间增加约 10% 时有时会得到 within-noise,而增加约 30% 时总会得到 separated。具体数据请参阅 VALIDATION.md。结果未分离并不能证明不存在差异。
比较前的检查
出现以下情况时,whyts 会以退出代码 1 停止:
- 一侧使用原生编译器,而另一侧没有,
- 两侧的检查器计数不同,
- 测量运行的标志不同,
- 一份报告有
timings,而另一份没有, - 报告的架构版本未知,或文件不是 whyts 报告。
出现以下情况时,whyts 会打印警告并继续:
- TypeScript 版本不同,
- 两个程序的文件数不同(编译器的
Files行), - 实时比较发现两个 tsconfig 文件中的编译器选项不同,
- 两侧报告了相同的编译器错误(警告以
COMPILER ERRORS开头), - 未解析模块占大多数错误,
- 两份报告中的 whyts 版本不同。
编译器错误与可比较的两侧
编译器遇到错误时可能会提前停止,例如遇到 TS2589。此时编译器执行的工作较少,耗时也较短。执行了不同工作的两侧无法进行有效的时间比较。
出现以下情况时,whyts 会判定两侧不可比较:
- 一侧有编译器错误,而另一侧没有,
- 两侧都有错误,但错误数量或错误代码不同。
如果两侧不可比较,whyts 会打印 NOT COMPARABLE 和原因,并显示两侧的原始耗时及错误摘要。它不会显示差异或判定结果。
如果两侧有相同的错误,whyts 会保留判定结果并打印 COMPILER ERRORS 警告。没有错误数据的报告无法比较错误代码。无错误的一侧仍会决定结果。
离线比较中的链
whyts 按文件、行和字符匹配源码检查链,并按文件匹配文件检查区间。在某条链上方进行编辑会改变它的行号。此时,该链会显示为在旧行消失、在新行新增。结果会将每个条目列为新增、消失、增长、缩短或未变化。条目变化至少达到 10 ms 且至少达到 10% 时,才会被视为增长或缩短。这些是显示界限,不是统计标准。
这些数值分别来自每份报告的一次跟踪运行,属于样本。某条链若只出现在一份报告中,那么它在另一份报告中的耗时可能低于 10 ms,也可能不在该报告耗时最高的五条链中。如果另一份报告记录了这条链,whyts 会对其进行比较;如果没有记录,则会将其列为新增或消失。
输出和退出代码
--json 会输出比较结果。其中包含 kind(comparison)、mode、baseline、candidate、checkTime、totalTime 和 warnings。实时比较还会添加 runs、order、warmup 和 measuredRuns。离线比较则会添加 findings。进度信息会输出到 stderr。
Version 0.7.1 添加了三个字段。Schema version 1 保持不变。comparable 的值为 true 或 false。reason 的值为 null,或说明双方为何不可比较的文本。baseline.compilerErrors 和 candidate.compilerErrors 包含 count 和 codes;若报告没有错误数据,则为 null。当 comparable 为 false 时,checkTime 和 totalTime 的 verdict 为 not-comparable。差异字段为 null。时间摘要仍会保留。
| 代码 | 含义 |
|---|---|
0 | 比较已完成,且双方可比较。双方的编译器错误相同会显示警告,不会产生其他代码。 |
1 | 失败:参数错误、文件无法读取、编译器失败或超时,或比较被拒绝。双方不可比较也属于此类。 |
双方不可比较时,退出代码为 1,因为本次运行没有有效结果。只检查退出代码的脚本不得将其视为通过。输出仍然完整。请使用 JSON 中的 comparable 区分这种情况和失败。发生失败时会向 stderr 输出信息,且不会生成 JSON。
与 AI 编码 Agent 搭配使用
AI 编码 Agent 无法通过阅读代码找出类型检查缓慢的原因。原始跟踪数据对其上下文而言过于庞大。whyts 提供 MCP 服务(Model Context Protocol,通过 stdio),可向 Agent 提供几 KB 的实测 JSON 数据。
使用 npx -y whyts-mcp 启动。软件包 whyts-mcp 依赖 whyts 和 MCP 软件包,因此 npm install whyts 不会安装它们。可用工具如下:
| 工具 | 用途 | 耗时 |
|---|---|---|
analyze | 找出类型检查时间的去向。返回 Check time、编译器错误摘要、包含解决办法的 diagnoses、排名前 5 的实测发现以及警告。 | 一次跟踪类型检查,外加 runs 次未跟踪检查 |
compare | 验证某项更改是否让类型检查变快或变慢。返回 separated、within-noise 或 not-comparable,以及中位数和原因。 | 约 2 x runs 次检查 + 2 次类型检查(runs 默认为 5) |
explain | 说明某个文件为何在程序中:显示导入链和直接导入它的文件。 | 几秒钟。不会运行类型检查器。 |
每个结果都是一段 JSON 文本,长度不超过 12,000 个字符。如果 whyts 缩短了结果,truncated 字段会说明删减了哪些内容。路径均为绝对路径。发生错误时,会返回带有 kind 和 hint 的 isError。whyts 每次只运行一个调用,因为同时进行两次类型检查会扭曲计时结果。当客户端发送进度令牌时,whyts 每隔 15 秒以及每次计时运行时都会发送进度通知。
示例:compare 结果(runs 为 5):
{"tool":"whyts","mode":"compare","decision":"separated","direction":"faster",
"rationale":"Every run of the candidate was faster than every baseline run (5 runs per side; the min-max ranges do not overlap). ...",
"checkTime":{"baseline":{"median":0.21,"min":0.2,"max":0.22},"candidate":{"median":0.03,"min":0.03,"max":0.04},"deltaMilliseconds":-180,"deltaPercent":-85.7}}
设置客户端
Claude Code:
claude mcp add --transport stdio whyts -- npx -y whyts-mcp
添加 --scope project,将条目写入 .mcp.json,供团队使用。Claude Code 会根据条目的 timeout 字段(毫秒)限制每次工具调用。进度通知不会延长此限制。
Cursor,在 .cursor/mcp.json(项目级)或 ~/.cursor/mcp.json(所有项目)中:
{
"mcpServers": {
"whyts": { "command": "npx", "args": ["-y", "whyts-mcp"] }
}
}
VS Code 搭配 GitHub Copilot,在 .vscode/mcp.json 中:
{
"servers": {
"whyts": { "command": "npx", "args": ["-y", "whyts-mcp"] }
}
}
Codex:
codex mcp add whyts -- npx -y whyts-mcp
Codex 会在 60 秒后停止工具调用,并在 10 秒后停止启动服务。在 ~/.codex/config.toml 中调高这两个限制:
[mcp_servers.whyts]
command = "npx"
args = ["-y", "whyts-mcp"]
startup_timeout_sec = 60
tool_timeout_sec = 900
其他客户端:通过标准输入/输出运行命令 npx -y whyts-mcp。该服务支持 2025-11-25 协议和 2026-07-28 协议。
该服务已作为 io.github.musatoktas/whyts 发布到 MCP Registry。
将 docs/agents.md 中的简短规则提供给你的 Agent。
该服务会像 tsc 一样运行你指定项目的 TypeScript 编译器,不会编辑项目。typescript 输入用于指定要运行的编译器包,因此只向 Agent 提供你信任的路径。
MCP 包(@modelcontextprotocol/server 和 zod)不是 whyts 的依赖项。npm install whyts 仍然只会安装 typescript。whyts-mcp 包的版本与 whyts 相同,并依赖于完全相同的版本。命令 whyts mcp 仍然可用。如果缺少 MCP 包,它会输出 npx -y whyts-mcp。
或者,如果你想从 whyts 包启动服务:npx -y -p [email protected] -p @modelcontextprotocol/server@2 -p zod@4 whyts mcp。
大型项目的选项
| 选项 | 含义 |
|---|---|
--verbose | 输出完整报告。默认最多显示三个操作和一行摘要。 |
--timeout <seconds> | 编译器时间限制。默认值为 900。 |
--runs <N> | 在追踪运行后,再进行 N 次不追踪的运行。报告和 JSON 会显示中位数、最小值和最大值。默认值为 1。请参阅“重复测量”。 |
--max-old-space-size <MB> | 设置编译器进程的堆限制。范围为 256 至 1048576。TypeScript 7 会忽略此选项。 |
--typescript <path> | 使用此 TypeScript 包目录或其中的 lib/typescript.js,而不是项目编译器。版本可以是 5.x、6.x 或 7.x。 |
node src/cli.js --project packages/compiler --max-old-space-size 10240
node src/cli.js --project . --typescript node_modules/typescript
追踪会在检查完成后写入类型转储。编译器不会将转储计入 Total time。在 drizzle-orm 的 type-tests 项目中,Total time 为 13.58 s,Dump types time 为 249.3 s。编译器进程耗时 263.5 s。报告会分别显示墙钟时间和转储时间。
默认超时为 900 s,超过 0.4 测试中最长编译器进程耗时(263.5 s)的 3 倍。编译器运行期间,每 30 s 会向 stderr 输出一条进度提示。
如果信号导致编译器停止,错误信息会显示编译器 stderr 的最后几行。发生堆错误、SIGABRT、SIGSEGV 或 SIGKILL 时,错误信息会提示你使用 --max-old-space-size。操作系统的内存不足终止机制或原生崩溃也可能导致 SIGKILL 或 SIGABRT。
TypeSpec 编译器项目在默认堆限制下停止运行。将 --max-old-space-size 设为 10240 后,项目运行完成。
编译器错误
报告会显示前 5 个错误及其文件、行号和代码,还会显示每种错误代码的数量。
一半或更多的错误可能是无法解析的模块或声明(TS2307、TS2792、TS2688、TS7016、TS6053)。这时依赖项可能缺失或尚未构建,耗时数据也可能不准确。报告会显示警告。
示例:在构建前,TanStack Router 仓库中的 react-router 出现了 1887 个错误,检查耗时为 4.76 s。构建后,错误数为 0,耗时为 12.43 s。
JSON 版本 0.4 新增了这些字段。Schema 版本 1 和所有现有字段保持不变。
compilerErrors:total、first、codes、missingDependencyErrors、measurementMayBeInvalid。summary.dumpTypesSeconds。- 文件区间、源代码检查和链条中的
checkTimeShareUpperBoundPercent。 review-root-files中的evidence.testRootsExcluded。
summary.errorCount 现在统计已解析的编译器错误行数。
TypeScript 7(实验性)
whyts 可以使用原生 TypeScript 7 编译器分析项目。这项支持目前为实验性功能。
要使用此功能,请在项目中安装 typescript@7,或通过 --typescript 传入包目录。然后像往常一样运行 whyts。
npx whyts --project . --typescript node_modules/typescript
工作原理:
- whyts 使用
--checkers 1启动 TypeScript 7 包中的bin/tsc。 - 原生编译器会为每个检查器分别编号类型。检查器数量大于 1 时,跟踪记录中的类型 ID 会发生冲突。使用一个检查器可确保每个 ID 唯一。
- 原生跟踪记录将源代码位置存储为 UTF-8 字节偏移量。whyts 会将其转换为 UTF-16 偏移量。
- 原生
types_0.json文件会以小写形式存储声明路径。whyts 会将这些路径与实际文件名匹配。 - TypeScript 7 没有用于模块解析的 JavaScript API。whyts 会构建导入图,并使用自带的
typescript5.x 或 6.x 包读取 tsconfig。
已知限制:
- 报告和 JSON 会将本次运行标记为实验性。
compiler为native,experimental为true,checkers为1。 - 检查耗时来自一个检查器。默认的
tsc运行会使用更多检查器,因此检查耗时可能不同。请勿比较两者。 --max-old-space-size不起作用,因为原生编译器不是 Node.js 进程。whyts 会打印警告并忽略此选项。- 原生编译器不会打印
Dump types time行。报告不会显示此项,且summary.dumpTypesSeconds为null。 - 如果依赖图和原生编译器报告的文件数量不同,whyts 会发出警告。两个编译器的模块解析结果可能不同。此时请谨慎看待导入图分析结果。
- 如果 whyts 自带的 TypeScript 无法读取你的 tsconfig,whyts 会报错并停止。只有 TypeScript 7 才识别的设置可能导致此问题。
- 我们仅在 Linux x64 上测试了 TypeScript 7.0.2。未测试 Windows、macOS 或其他 7.x 版本。
工作原理
- 解析项目中安装的 TypeScript 编译器;如果未找到,则使用捆绑的 5.9 编译器。若安装了 TypeScript 7 包,则使用原生编译器。
- 使用编译器 API 读取生效的配置,包括 JSONC 和
extends。 - 构建源代码和导入图,并检查已加载的类型包。
- 使用
--noEmit、--extendedDiagnostics和--generateTrace运行该编译器。 - 在每个文件和线程中,将时间上嵌套的源代码检查分组;匹配包含在其中的类型比较;从
types.json中流式读取选定的类型 ID;并在结构性分析结果之前对测量数据进行排序。 - 删除临时跟踪记录和缓存文件。
编译器会使用一个新的临时增量缓存。现有构建缓存既不会被读取,也不会被覆盖。源文件和常规构建输出不会改变。不会调用包生命周期脚本,CLI 也不会发起网络请求。当然,安装依赖项或获取 CLI 可能会访问网络。
测量边界
- 测量的是使用全新缓存的跟踪类型检查。使用
--runs时,还会测量使用全新缓存的非跟踪检查。它不是 Next.js 构建、打包、测试或语言服务器的计时器。 - 生成跟踪和空缓存会影响计时。请使用相同的编译器、标志、硬件和跟踪模式比较运行结果。报告中的墙钟计时不包括之前的图分析。
- 记录的检查区间采用包含边界的计时,表达式/类型事件可能经过采样。同一文件、源代码检查、链根或类型对的重叠区间会合并。最大的五个文件检查和最大的五个项目文件检查会分别报告;此外还会报告五条源代码链(项目优先),每条链最多包含五个成员和三个包含的比较,以及五个全局类型比较。为兼容性,五个单独的源代码检查仍保留在 JSON 中。这些列表存在重叠,不能相加得出总检查时间。
- 只有当比较的整个时间跨度都落在同一进程/线程上的某个已记录源代码检查内时,该比较才会与最内层的已记录源代码检查关联。链也会收集其成员中包含的比较;这些比较可能发生在所选焦点表达式之外。未记录的检查、缺失的位置或不可用的类型描述符会降低细节程度;whyts 不会推断缺失的因果链。
- 如果同一文件中的单条源代码检查链覆盖该文件至少 80% 的已记录检查时间,则会省略
check-hotspot发现,因为链发现已报告相同时间。该文件仍会保留在“最大的已记录项目文件检查区间”列表中。阈值为src/analyze.js中的CHAIN_COVERAGE_THRESHOLD(0.8)。 - 桶文件传播计数统计观测到的源代码边,包括仅类型边。文件可能已经是配置的根目录,直接导入也可能不会改变程序规模。
- 为限制图遍历,最多检查 40 个桶文件候选项,并按重新导出数量优先排序;报告其中五个。不会解析超过 128 MiB 的跟踪文件。类型描述符以 64 KiB 的分块流式读取,扫描预算为 1 GiB,每条记录预算为 4 MiB,保留数据预算为 16 MiB。找到所选 ID 后即停止扫描,因此不会验证文件的其余部分。达到限制或缺少 ID 时会发出警告;仅文件大小不会禁用类型解析。编译器输出上限为 16 MiB。源代码片段最长为 240 个字符,类型标签最长为 160 个字符。
- 在 monorepo 中选择一个叶级 tsconfig。whyts 不会运行
tsc --build,也不会构建被引用的项目。缺失或过时的被引用声明输出可能会影响结果。 diagnostics中的Types、Instantiations和Memory used来自跟踪运行。跟踪会使这些数值变大。在 TypeSpec 编译器项目中,跟踪运行报告了 10,480,436 个类型。较早的一次无跟踪测试运行报告了 135,624 个类型。我们没有为 0.4 重复进行无跟踪运行。不要将这些数值与普通tsc --extendedDiagnostics运行的输出进行比较。- whyts 支持 5.x 和 6.x 的 JavaScript TypeScript 编译器。它也将原生 TypeScript 7 编译器作为实验性功能支持(参见 TypeScript 7 部分)。IDE 或单独的构建框架使用的插件不会进行性能分析。
- 静态图启发式方法无法确定某个类型或文件是否会导致特定的性能变慢。不会执行自动编辑或修复。
如需进一步探索跟踪,请使用 Microsoft 的 analyze-trace。编译器文档:extendedDiagnostics、generateTrace 和 性能指南。
开发
npm ci --ignore-scripts
npm run check
npm test
npm pack --dry-run
测试涵盖导入链、路径别名、循环、动态导入、继承的配置、实际加载的重复类型、嵌套和乱序的跟踪事件、跨源位置的链分组、线程/文件隔离、支持注释的声明、带 UTF-8 分块边界和预算的选择性描述符流式传输、缺失的描述符、项目优先级、barrel 根目录重叠、终端净化、真实跟踪检查、缓存保留、编译器错误、JSON 输出和超时。其他测试还涵盖运行顺序、预热排除、摘要值、噪声规则、比较前的检查,以及发现结果的离线匹配。MCP 测试通过原始 stdio 客户端启动 whyts mcp,并检查工具列表、精简结果、错误路径、进度通知,以及停止正在运行的编译器。GitHub Actions 在 Linux、Windows 和 macOS 上运行 Node 20/22/24。
实现采用纯 ESM JavaScript,因此检出代码后无需构建即可运行。TypeScript 是唯一必需的依赖。MCP 服务位于独立的包 whyts-mcp 中,该包额外引入两个包。
提交错误报告时,提供精简的复现示例、Node/TypeScript 版本,以及预期输出与实际输出会很有帮助。报告和跟踪信息可能包含私有文件名、源代码片段和类型名称;分享前请先检查。
MIT 许可证。由 Musa Toktas 创建。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。