
t3code 这个项目最初并不是为了做一个“又一个新的代码检查工具”。它是在一次大型重构里被逼出来的——当时我需要在一个有几十个微服务、跨语言调用密集的仓库里批量调整接口字段改完之后类型检查是全绿的但一上测试环境就炸了三处运行时报错。那种“编译器说没问题业务却告诉你出事了”的割裂感是我做 t3code 最直接的出发点。简单说t3code 是一个以类型流type flow为核心的代码分析与验证工具。它不满足于“某个变量的类型是什么”而是追踪类型在函数调用、模块边界、异步任务之间流转的过程再基于这套流转路径执行可组合的自定义规则。它能做的事情大致可以概括为在编译/语言服务之后在测试之前多补一道针对业务约束的检查层。如果你是做后端服务、前端中大型应用或者正在维护一个跨团队协作的复杂仓库这篇文章会对你很有用。我会把 t3code 的设计取舍、接入方式、实测结果以及我踩过的坑全部摊开来讲。不保证每个结论都普适但至少你读完能判断它适不适合你的场景。1. 为什么会有 t3code一次重构迁移让我意识到的问题1.1 类型检查过了业务逻辑却崩了先还原一下当时的具体场景。我们有一个订单域的中台服务内部接口之间通过一个共享的 DTO 包通信。某次版本升级需要把原来userId这个字段拆成ownerId和operatorId语义上更准确也方便权限模型下沉。这个改动从类型系统的角度看是非常健康的所有相关的结构体定义、序列化注解、数据库映射都同步改了编译和单测全部通过。但上线之后有三处运行时报错非常诡异一个 Kafka 消费者反序列化时字段丢失一个前端调用网关时拿不到期望的数据结构还有一个内部 RPC 服务在反射调用时把ownerId映射到了错误的旧字段上。复盘原因其实不复杂这三处都不是“直接引用 DTO 字段”的代码而是经过了中间传递——Kafka 消息的 JSON 序列化配置没有显式声明字段映射前端调用网关时经过了一层 DTO 转换器RPC 服务则依赖了一套老的反射元数据。类型系统在这三处都“断链”了它认为你还是userId但你的运行时配置和外部契约已经切换到了新字段。这种问题传统的静态检查工具是发现不了的。ESLint 只能做语法层和少量 AST 层的规则TypeScript 编译器只关心类型是否匹配它们都不会去追问一个问题这个类型在跨越“当前代码文件边界”之后还是不是调用方所期待的那个语义我需要的不是再一个 lint 规则而是一个能追踪类型流、并在追踪中嵌入业务约束的验证层。1.2 我并不想做另一个静态检查器市面上其实有不少代码分析工具比如 SonarQube、CodeClimate、Semgrep还有各种语言自带的 formatter 和 linter。它们做得都很好但在我这个场景里总觉得不够顺手。Semgrep 适合做模式匹配但你得把“字段在跨模块后丢失”这种问题表达成什么样才算规则SonarQube 覆盖广但规则的启发式味道很重误报需要团队花大量精力去消化。我不太想再做一个“全领域通用”的静态检查器因为那个赛道已经很拥挤。我更想做的是一种面向业务语义的代码验证框架你告诉它你要验证什么规则它在类型流上执行验证然后给出带有证据链的报告。你可以把它理解成一种“可编程的契约检查层”。这也是 t3code 和静态检查器的本质区别——它关注的不只是代码长什么样更是代码在数据流动中被如何使用。从结果上看t3code 最后做出来的样子也确实更像一个“验证工具”而不是“lint 工具”。它的输出不是error/warning的简单列表而是一条条包含起点定义、流转路径、终点消费位置的追踪记录。这样当用户怀疑一个报错是不是误报时可以顺着路径去核对而不是盲目地ignore。2. t3code 的底层设计三个取舍决定了它的形态2.1 设计理念以类型流为核心而不是语法匹配t3code 最关键的一个决策是不把 AST抽象语法树当作第一公民。绝大多数代码分析工具的第一层抽象都是 AST因为语法结构是最容易拿到的信息。但 AST 有一个天然缺陷它只能反映“代码写了什么”很难反映“数据去了哪里”。举个例子下面这段代码在 AST 上只会被解析成“一个函数调用参数是一个变量”const order fetchOrder(id); const result processOrder(order);但在类型流视角下这条代码链的真正含义是fetchOrder返回了OrderDTO这个OrderDTO又被processOrder消费。如果OrderDTO的字段结构发生了变更那么必须检查processOrder内部是否使用了变更前的旧字段。如果中间再隔着几层函数调用或者异步队列AST 模式匹配就很难处理这种“长跳”问题但类型流可以。具体实现上t3code 会先借助语言服务或编译器前端拿到每个符号的类型绑定然后构建一张数据流图。这张图的节点是变量、函数参数、返回值、对象属性边是赋值、调用、传递、合并等操作。规则引擎跑在这张图上面而不是跑在树上面。这就是 t3code 和传统 lint 规则在底层视角上的最大不同。2.2 规则组合允许项目长出“自己的规范”我见过很多团队用静态检查工具最后都会遇到同一个困境工具的默认规则集太通用不贴合团队自己的代码约定。为了不看到满屏误报只能一个规则一个规则地关最后留下来的规则少得可怜。t3code 的设计思路不一样。它把规则拆成更细粒度的“验证原语”允许项目在基础规则之上组合出属于自己的规范。举个例子基础层面它提供了“字段访问追踪”“函数返回类型追踪”“跨模块引用追踪”这样的能力层而规则只是将这些能力按业务场景组合起来。比如你可以这样写一条规则在核心交易模块中所有对外暴露的 DTO 字段必须在门口层entry layer显式声明序列化策略不允许依赖默认反射。这条规则如果放在传统工具里需要同时打通语法分析、类型分析、模块依赖分析三层基本上只能靠自定义插件工程量很大。但在 t3code 里因为类型流图上本身就能看到“字段从哪里定义、在何处被序列化消费”规则写起来就会短得多。这个取舍意味着 t3code 的学习曲线是有的——用它的团队不能只靠“开箱即用”必须花一点时间去理解这张类型流图对你的项目意味着什么。但换来的东西也明确规则能真正贴近业务报告里反映的是你们项目的真实约束而不是一堆网上抄来的通用建议。2.3 增量执行让扫描快到自己都不信代码分析工具最被人诟病的一点就是慢尤其在大仓库里跑一次全量扫描动辄几分钟团队根本不会想把它接进 pre-commit 钩子。t3code 从设计上就把性能当成一等公民考虑采用的做法是增量执行。原理其实不复杂。t3code 会把分析结果切片成以文件为单位的缓存同时维护一个依赖倒排表记录每个文件依赖哪些类型定义、哪些模块符号。当某个文件变更时它能立刻算出影响范围只重跑受影响的那部分数据流图而不是把整个仓库重新扫一遍。实测下来在一个大概 20 万行、跨 40 个模块的 Node.js 项目里全量首次扫描大约是 42 秒但增量扫描通常在 2 到 4 秒之间。这个速度对 pre-commit 来说已经完全够用了。我以前一直觉得代码分析工具跑得慢是理所当然的直到试过增量设计之后才发现不是大家不愿意做快了是这个“影响范围计算”要做到精确本来就很麻烦。t3code 选择了“精确到类型级依赖”的做法初期开发成本高一点但后期体验提升非常明显。3. 接入指南从安装到跑出第一份有效报告3.1 最小配置样例t3code 的安装方式比较简单无论你是全局安装还是本地安装都行。下面是我的推荐配置结构以一个 TypeScript 项目为例npm install -D t3code然后创建一个配置文件t3code.config.json。没有配置文件时它会走默认规则集但那样意义不大因为默认规则集只做最基本的类型一致性验证无法体现业务约束。我建议一开始就显式声明项目范围和入口。{ projectType: typescript, include: [src/**/*.ts], exclude: [src/**/*.test.ts, src/generated/**], rules: { fieldSerialization: { level: error, modules: [src/order/**] } }, cache: { enabled: true, storePath: .t3code-cache } }这只是一个最小样例但已经体现了两个我很在意的设计第一rules是可以针对模块区分的而不是全局一刀切第二cache默认开启因为增量执行必须依赖稳定缓存。跑一次命令看输出npx t3code check如果一切正常你会看到类似T3C-1002这样的规则编号和对应的文件位置。我建议第一次跑的时候先不要急于处理所有报告而是先花时间核对它的“证据链”——每一条报告都会附带类型流转路径你需要确认这些路径符合你对项目的理解。这个过程本质上也是在校准规则的准确性。3.2 写一条真实的自定义规则配置文件里的rules字段引用的是内置规则真正有意思的是自定义规则。t3code 的自定义规则使用 JavaScript 或 TypeScript 写导出一个对象即可// rules/noUnboundDtoSerialization.ts import { Rule, TracingContext } from t3code; export default { id: no-unbound-dto-serialization, description: 核心交易模块的 DTO 字段必须显式声明序列化策略, async check(context: TracingContext) { const issues []; unpackFieldDefinition(); // 核心逻辑从数据流图中找出从 unbounded module 流入到 // JSON.stringify / class-transformer 的类型节点 return issues; }, } satisfies Rule;规则本身不复杂。真正有价值的是TracingContext它提供了几个核心 API跟踪字段的起始定义、追踪它的传播路径、查看它在哪些点被序列化消费。你不需要知道文件里具体是怎么写的只需要关心类型流到了哪里这个抽象才是 t3code 真正的价值所在。如果你不熟悉这种“数据流式”规则写法我特别建议先拿一个真实 bug 来练手找一个曾经线上出过问题的字段改动然后把“如果这个字段发生变更哪些消费方会受影响”写成规则。这样写出来的规则比任何从开源仓库抄来的规则都要贴合你的项目也更容易被团队接受。3.3 在 CI 中怎么用才不会变成噪音很多工具接入 CI 的失败不是工具本身不行而是它生成的报告太像噪音。团队每天看到 200 条告警但没人处理久而久之就把整个检查流程当成了摆设。t3code 在 CI 集成上我有几个非常个人的建议。第一不要把全量规则集直接丢进 CI。按模块分阶段开启规则第一周只检查最核心的支付模块第二周再加订单模块。这样每一条新出现的报错都是“新增的”处理起来才有紧迫感和清晰度。第二在 CI 里区分“错误”和“建议”的阈值。t3code 的规则可以配置level但它在 CI 模式下的默认行为是只有error级别的规则才会导致流水线失败warning级别只是写进报告。这个设计我很支持因为它让团队在推进规则落地时有一个渐进过程而不是一次性要求所有历史问题清零。第三接入一个baseline机制。t3code 会把上一次全量扫描的问题集合作为一个基线新报告只需要展示“相比基线新增的问题”。这非常实用因为历史存量问题不应该阻碍你做持续改进。你可以先记录一个基线然后花三周逐步消化最后再收紧阈值让基线清零。CI 里的最小命令也很简单npx t3code check --baseline .t3code-baseline.json --fail-on new-error这个--fail-on new-error很关键它只让你因为“新增的错误”失败而不是因为“历史存量”失败。用这个方法我们团队在一个老仓库里接入 t3code 时几乎没有引起任何关于“规则太严、阻碍发布”的负面反馈。4. 定位效果我在三个不同规模项目里看到的结果4.1 中台接口异步协调项目改字段的连锁反应第一个项目是我前面提到的那次重构事故后的重建项目。它是一个中台服务内部有 60 多个异步消息处理器共享一个消息体结构。旧代码里消息体字段和处理器字段是一一对应的但后来因为业务扩展消息体里开始出现嵌套结构处理器之间的依赖也不再是简单的“读字段”而是“读取字段后二次转发”。我把 t3code 接进去之后最直接的效果是把“字段变更影响面”这件事变成了可以提前看见的东西。比如你修改了消息体中owner的嵌套结构它立刻会给出所有“把owner作为参数继续向下传递”的路径而不只是那些直接引用owner文件的位置。这里有个很典型的案例我们有个老接口调了另一个微服务的GetOwnerInfo返回值里有个address字段。某次我们决定不再返回完整地址只返回城市级别但这个改动只改了服务端接口和调用方的类型声明却没有改 Kafka 消息里的一个冗余快照字段。t3code 在追踪时发现address在类型流中经过了一次“字段转存”操作被从一个接口数据拷贝到消息结构里随即给出了告警。这个链条用肉眼排查至少要翻四五个文件但 t3code 在几秒内就把它串起来了。4.2 旧前端项目过度封装后的隐式依赖第二个项目是一个历史超过五年的 React 应用Redux 和本地 state 混用中间还有一层很厚的 service 层。前端项目里的类型流问题通常更隐蔽因为你在组件里看到的往往不是数据源头而是经过重重 mapStateToProps、service 包装、hook 返回后的结果。t3code 在这个项目里表现最好的一点是它能把“状态切面”追踪到初始 store 定义。有一回我们计划把user.profile.preferences改成一个独立的preferenceStore原本以为引用点不会太多。但跑完 t3code 之后所有报告指向了 8 个文件其中有 3 个是通过一个自定义usePreferencehook 间接引用的这个 hook 的返回类型居然被定义成了一个宽泛的Recordstring, unknown导致 TypeScript 根本不会报错。这个场景很有代表性——前端代码里到处是这种“为了灵活性而牺牲类型安全”的封装。t3code 在这个案例里的输出不仅指出了位置还通过类型流图展示了这个宽泛类型是从哪几层剥出来的。团队最后决定优先收敛这个 hook 的类型定义而不是直接对所有调用点做批量替换。这个决策如果没有数据支持光靠人肉看是极难达成的。4.3 微服务仓跨模块调用中的类型坍缩第三个项目是一个微服务仓涉及 10 个服务每个服务都是一个独立的 npm 包。它们之间通过 protobuf 生成 TypeScript 类型共享。这种情况下类型通常是一致的因为由代码生成器产出但有一个问题很多开发者在跨服务调用时为了防止编译错误会顺手把类型断言成as any或者做一层非常薄的 DTO 复制。t3code 在微服务仓里的价值是“类型坍缩检测”。什么是坍缩就是原本一个精确的接口类型经过一层as any或者Object.assign之后在目标服务里变成了一个不可追溯的宽泛类型。坍缩之后的代码可能编译正常但运行时一旦契约变化就会以非常难查的方式失败。我抓到一个很典型的例子。一个订单服务在调用用户服务时开发者没有直接导入 protobuf 生成的UserDTO而是拿到结果后手动构造了一个局部接口UserLite只保留几个字段然后再传进下一个服务。这个UserLite在类型上“看起来”很安全但一旦用户服务在响应里删了某个字段这里会静默丢字段。t3code 横跨两个服务的代码流里通过追踪函数返回值和后续消费逻辑发现了这个“自定义中间接口”的存在。这种问题在常规的 lint 工具和编译检查里几乎不可能被发现因为它不是语法错误也不是类型错误而是一个跨模块契约的一致性风险。5. 踩坑记录与当前边界5.1 版本漂移的幻觉错误用 t3code 过程中遇到的最大一坑是版本漂移导致的幻觉错误。项目的package.json里锁着 TypeScript 4.5但某个模块因为某种原因在本地 node_modules 里装的是 4.9于是语言服务给出的类型解析结果和 CI 上的环境并不完全一致。两条不一样的分析路径会在某一个类型定义上产生分叉导致 t3code 报了一个只存在于本地环境的问题CI 上完全复现不了。这种问题排查起来最折磨人因为你检查代码没有任何问题清缓存也没用最后发现是两个 TypeScript 版本之间的推断逻辑差异。用过类型追踪类工具的人应该懂这类工具对语言服务版本极其敏感。我的应对策略是在package.json里显式固定typescript版本并且 CI 和本地使用同一份 lockfile。t3code 其实提供了环境检查命令t3code doctor它会比对分析路径上的编译器版本、缓存指纹和配置哈希。但这东西是后验证的不如从一开始就统一版本。5.2 误报治理我如何让报告“可辩护”任何会跑在类型流上的工具都逃不过误报问题。t3code 的误报来源通常是“类型流被过度抽象”。举个例子如果你用了一个非常动态的函数比如Reflect.construct或者Function.applyt3code 很难准确追踪参数流向它会保守地认为“所有参数都可能有影响”。保守推断的结果就是给出大范围的“可能受影响”报告。这种报告并非没有价值但直接丢给团队一定会被当成乱报。我的处理办法是把 t3code 规则分成两个等级验证型规则和探索型规则。验证型规则只在路径非常明确的情况下报错宁可漏报也要保证准确探索型规则允许保存路径索引供人工分析使用但默认不在 CI 里触发失败。这个思路其实借鉴了编译器设计里“sound vs complete”的取舍。t3code 虽然没有提供显式的“探索模式”但你可以通过自定义规则的判定逻辑来灵活控制。另外很重要的一点是为每一条报告附带一个“可解释性说明”。我强烈建议团队在跑 t3code 时不要只把T3C-1002这样的编号写在报告里而是同时生成对应规则描述和路径说明。t3code 默认会生成一份 Markdown 报告带完整上下文这个功能在团队推广时非常加分——大家看得到为什么被报而不是看到一个冷冰冰的编号。5.3 性能上限不是解析是缓存失效前面我提到增量扫描很快但增量设计也有一个天花板当类型定义频繁变动时缓存失效范围可能迅速扩大扫描时间会退化到接近全量扫描。我遇到过最极端的场景是一个前端项目里有个全局类型文件types.ts几乎什么乱七八糟的业务类型都往里塞。一旦这个文件变化几乎整个 src 目录都会被标记为受影响增量直接变成全量。这个性能问题虽是 t3code 触发的但根因其实是项目结构本身不健康。t3code 的表现反而更像一面镜子把“挖掘类型依赖”这个技术债务摊开了给你看。最后我们花了点时间把公共类型按领域拆分成多个小文件再跑增量扫描时间从 20 多秒降到了 3 秒。这让我意识到一点分析工具的增量性能一定程度上取决于你的代码依赖是否清爽。工具暴露问题你改架构最终双方受益。6. 后面计划怎么走6.1 与 LSP 结合的实时提示目前 t3code 主要是命令行工具配置文件改完才能跑。下一步我最想做的方向是把分析能力嵌入到编辑器里通过 LSP 提供实时提示。不是简单在诊断面板里输出文字而是在你修改某个 DTO 字段时直接标出“哪些跨模块消费方会受影响”甚至提供一个跳转路径给到下游位置。这个体验如果能做出来会非常爽——相当于在你的 IDE 里内置了一个“类型流地图”而不是让你一次次在命令行里跑报告。目前 t3code 已经预留了分析结果序列化 API可以用 JSON 格式把类型流图导出。LSP 插件只需要订阅文件变更事件调用增量分析接口然后把结果映射回诊断。技术上没有本质阻碍主要是工程量和体验细节打磨的问题。6.2 团队规则仓库与分层配置t3code 目前的自定义规则是跟项目代码放在一起的团队可以共享配置但依然存在一个问题规则的组织和复用。我计划做一个类似“团队规则仓库”的能力让一个组织可以维护一份中心化的规则包各个项目按需引用。规则包内部可以有基线规则和扩展规则项目可以通过继承和覆盖实现分层配置。这个想法的出发点是我作为团队 tech lead 的实际需求我希望所有项目都遵守最基本的跨模块契约规范但又不希望每个项目的人各自复制粘贴一份。这种中心化与本地化的平衡需要谨慎设计。规则太紧项目难以落地规则太松又变成摆设。我的初步方案是支持规则权重与审计日志让每次“规则偏离”都有记录方便 review 阶段明确讨论。6.3 从“检查代码”走向“解释代码”t3code 目前在做的事情其实已经不止于“找问题”了。它构建出来的那张类型流图天然蕴含了代码的“调用关系和契约脉络”。我觉得这个东西有很大的衍生价值——可以演化为自动生成数据流向文档或者用于新人 onboarding 时快速了解一个业务模块的输入输出链路。我甚至设想过把类型流图和团队的知识库连接起来当某字段发生变更时自动触达相关的负责人和下游消费方。方向很多但核心没有变只做“解释代码结构”这件事不做“评价代码好坏”的事。从开始构思 t3code 到现在我最大的体会是代码分析工具的价值不在规则数量多少而在它能不能回答“这处代码改动对别处意味着什么”。如果你也一直觉得现有工具离这个目标差了一点t3code 这个思路大概率能给你一点启发。别急着全量接入可以先找一个最多坑的模块写一条贴合业务的规则跑起来看看。工具这东西只有在真实代码上产生的反馈才能真正反哺你对项目结构的理解。