☰
t3code:基于tree-sitter的三层静态分析,让Code Review边界可执行
2026/10/9 9:18:45 网站建设 项目流程

最早写 t3code,是因为一次让我非常难堪的 Code Review。同事交了一个 2000 行的 controller,里面既查了数据库、又调了第三方接口、还把返回值包装成了视图模型,我憋了十分钟只挤出一句“这个函数有点长,拆一下哈”。他很客气地回了一句:“拆行,但你告诉我按什么边界拆?”就是这句话,让我意识到——我们缺的不是 lint 规则,而是一套能把边界、依赖、可追溯性量化执行的验收标准。t3code 就是在那个晚上立项的。

t3code 是我自己维护的一个静态分析工具,用 Go 编写,基于 tree-sitter 解析 TypeScript、Python、Java 等语言的源码。它的目标很简单:把 Code Review 里那些靠“感觉”才能发现的问题,变成可重复、可仲裁、能进 CI 的检查项。如果你所在的团队已经有 ESLint、Checkstyle,但依然觉得模块边界越来越乱、需求变更经常找不到对应代码、代码评审靠吵架,那这篇文章里讲的思路和踩坑经历,应该对你有用。

1. 从“拆不拆”的争论到三级模型:t3code 把规范分成了三个可执行层级

1.1 传统 lint 为什么管不住“拆函数”这种问题

ESLint、Checkstyle、Pylint 这些工具很强,能抓住命名、格式、未使用变量、魔法数字等等。但这些工具默认只处理一个文件内部的语法树,它们不关心 controller 是不是在直接调 repository,也不关心一个改动有没有对应需求单号。更关键的是,它们的规则大多是“局部形状”规则——一个函数太长、一个文件太大、一个 if 嵌套太深——而“拆函数”真正的难点是“按什么边界拆”,这是依赖关系和语义边界问题,靠行数阈值是解决不了的。

我当时的痛点就在这:代码规范文档写了三四十页,但没有一条是可自动校验的。评审人只能靠个人审美给出“我觉得这里该拆”的意见,写代码的人也可以理直气壮地说“我觉得不用拆”。这种争论消耗信任,而且没有判定标准。t3code 想做的事情,就是把最常见的边界争论点,抽象成三级规则模型,让每一级的检查都有明确的依据。

1.2 T1、T2、T3 分别管什么事

t3code 的三级模型是这样设计的:

层级关注点典型规则违规后的处理
T1代码卫生函数圈复杂度不超过 10、文件行数不超过 300、禁止 TODO 直接合入error,阻断合入
T2模块边界禁止上层模块反向依赖下层、禁止绕过中间层直接调用、禁止循环依赖warning,自动评论且需人工确认
T3可追溯性关键模块的改动必须带有需求单号或变更说明、核心函数必须有归属标记info,只提示不阻断

T1 解决的问题是“一眼就能看出来但总没人改”的脏活。这类规则信噪比高,但价值其实不大,因为现在编辑器自带的 Inspections 很多都能做到。T2 是 t3code 的核心,它构建的是模块与模块之间的依赖契约。T3 则比较特殊,它检查的不是代码质量,而是代码和需求、文档、变更记录之间的关联关系。

为什么叫 t3code?不是某个英文缩写的强行解释,而是“三层代码规范”的意思。我把每一层当成一个独立治理阶段:先跑 T1 建立信任,再开 T2 约束边界,最后用 T3 把代码和业务目标挂上钩。这样团队不需要一次性接受一大堆规则,可以一步步来。

1.3 三层模型背后的治理逻辑

拆开来看,每一层解决的是不同时间尺度的问题。T1 是秒级反馈,让人舒服;T2 是周级图景,防止架构腐化;T3 是月级甚至季度级的追溯,让改代码的人能回答“为什么要这么改”。很多时候团队不是不想遵守规范,而是规范颗粒度太粗,要么管不到关键点,要么规则太密让人反感。t3code 的三层设计,本质上是在回答“规范到底要批量多细”的问题:太细没人看,太粗没约束,那就分三档,每档用不同的执行强度。

2. 核心实现:tree-sitter 解析、符号表与依赖图

2.1 技术选型:为什么用 tree-sitter 而不是正则

写这类工具最关键的一步是解析代码。用正则去匹配 import、类、函数当然也能做到一部分,但只要代码写成多行、换行、嵌套泛型,正则就会漏掉或者误伤。t3code 从第一天起就决定不做“正则扫描器”,而是使用 tree-sitter。tree-sitter 是一个增量解析器,它能把源码解析成语法树,并且即使代码有语法错误也不会整个崩掉,这对 lint 场景太重要了——我们本来就是要扫描那些“不太干净”的代码。

Go 语言生态里调用 tree-sitter 的绑定比较成熟,运行时是一个独立的二进制,不需要部署 Node.js 或者其他解释环境,非常适合放到 CI 里。相比 CodeQL 之类的重型方案,tree-sitter 更轻,而且语法规则完全可控。严格说,t3code 并不依赖于某一个具体语言,而是依赖每种语言的 grammar,所以后续要接入新语言,只需要添加 grammar 并写对应的节点映射。

2.2 三段流水线:Parse、Index、Analyze

t3code 的执行流程分三步。

第一步是 Parse。读取目标目录下所有源码文件,用 tree-sitter 解析成 CST(具体语法树)。这里要注意,tree-sitter 的节点带字节范围,所以每个 AST 节点都能映射回源文件的行列位置,这是后续输出可定位告警的基础。

第二步是 Index。这一步不是简单地遍历语法树,而是要构建跨文件的符号表和依赖图。符号表记录每个文件里定义的函数、类、接口、变量;依赖图记录文件之间的 import、require,以及函数被调用的关系。一个常见的坑是:很多静态分析工具只看到“文件 A import 了文件 B”,但看不到“文件 A 的某个具体函数调用了文件 B 的某个具体函数”。t3code 在 Index 阶段会把调用点也记录下来,这样 T2 的跨层调用检查才能做到“行级定位”。

第三步是 Analyze。规则引擎会同时读取 CST 节点和依赖图,跑所有启用的规则。T1 规则大部分是单文件的,T2 规则需要依赖图,T3 规则还会读 Git 元数据和注释标记。整个管线用 channel 构建成 Producer-Consumer 模式,解析一个文件就可以立刻进入 Index,Index 完成后马上分析,这样能让大仓库的扫描时间控制在可接受的范围。

2.3 一条 T2 规则的实现思路

以“controller 禁止直接调用 repository”为例。常规 lint 工具很难做这条规则,因为需要知道 controller、repository 分别属于哪个分层,还需要知道 import 链是否绕过了 domain。在 t3code 里,这个检查分三步实现:

  • 通过配置读取分层路径,比如controllers/**属于 interface 层,repositories/**属于 infrastructure 层,domain/**属于核心领域层。
  • 在依赖图里找到controller文件对外的所有 import 边。
  • 如果存在一条边从 interface 层直接指向 infrastructure 层,并且跨越了 domain,就报一个 T2 层级的 warning。

配置可以写成这样:

layers: interface: - "controllers/**" - "handlers/**" domain: - "domain/**" infrastructure: - "repositories/**" - "clients/**" dependencies: allowed: - from: interface to: domain - from: infrastructure to: domain

规则引擎看到from: interface to: infrastructure不在允许列表里,就生成一条告警。这里的核心不是写正则去匹配路径,而是让用户通过配置描述“边界是什么”,工具只负责检查依赖图里有没有违反边界。

2.4 T3 可追溯性:让代码和需求单号产生关联

T3 是 t3code 最有争议也最有意思的部分。它检查的不是代码质量,而是“这段代码为什么存在”。常见做法是要求关键函数或关键模块的注释里包含需求单号,t3code 会扫描 CST 中的 comment 节点,匹配req:或story:前缀。

实现上有个细节:tree-sitter 里的注释节点不是普通子节点,它们往往作为附加 token 存在。如果直接遍历树,很容易漏掉。t3code 的做法是先遍历所有叶子节点,找出类型为 COMMENT 的节点,然后向上找最近的声明节点(函数、类、导出语句),把注释归属到对应的声明上。这样每个函数都能拿到自己的“需求标签”。

除了注释,T3 还会读取 Git 记录。t3code 在分析某个文件时,会执行git log -L或者读取当前提交的变更集,把 commit message 里的单号提取出来,和该文件的 T3 注释做交叉比对。如果改动了核心模块但没有新增或更新需求标签,就生成 info 级别的提示。这个级别我不会设成 error,否则在快速迭代时会被当成假阳性骂死。

3. 接进 CI 之前,先解决三个真坑

3.1 注释在 AST 里“居无定所”

第一版 t3code 最大的 bug 出现在 T3 规则上。当时用正则直接匹配整个源码文本里的需求单号,结果发现大量误报:一个注释写在上一个函数末尾,却被归到了下一个函数头上。问题的根源在于 tree-sitter 的注释节点只作为 trivia 存在,不在常规 child 节点遍历范围里。解决方式分两步:第一步,专门扫描 COMMENT token;第二步,用节点行号做“最近前置声明归属”——一个注释如果在一个函数声明开始之前,中间没有其他声明,就归属于这个函数。

这个坑给我的教训是:任何基于 AST 的工具,都不要偷懒用正则去解析语义,哪怕是一段注释。注释的“归属”也是一种语义,必须通过邻近规则计算。

3.2 规则膨胀会让工具死在第一周

我最初写了 30 条规则,觉得越多越有安全感。结果在试运行阶段就翻车了:一大半规则要么误报率极高,要么修复建议说不清楚,团队直接选择忽略输出。后来我把规则砍到 12 条,并给每条规则定了三个标准:必须能定位到具体行、必须给出明确的修复建议、误报率必须低于 20%。那些达不到标准的规则,默认关闭,调好了再打开。

留下来的核心规则包括:

  • 函数圈复杂度大于 10
  • 文件 import 语句超过 30 个
  • 嵌套层级超过 5 层
  • 日志中直接接收外部用户输入
  • 模块之间存在循环依赖
  • 跨层调用未被显式豁免

这些规则的特征是“改了就有明确好处,不改也能解释为什么不改”。规则不是越多越好,而是每一个都能拿得出手。

3.3 误报分级与豁免清单

即使规则再小心,也会有误报。比如“禁止循环依赖”这条,有时团队明确知道两个模块互相依赖是历史原因,会在下一轮重构中拆开。这时候如果把告警设成 error,重构还没开始,CI 先红了。t3code 的做法是每一条告警都分 error、warning、info 三个等级,并且提供块级豁免注释:

// t3code:ignore issue=circular_dependency 因为这里是双向回调,重构后再拆

但我不提供全局 ignore,不能让你在配置里写一行ignore_all: true把整个仓库屏掉。块级豁免的好处是,豁免理由随代码一起走,后面代码重构时看到警告,能顺着注释找到历史原因。事实证明,这个设计比单纯的静默抑制更能推动技术债清理。

4. 一份能让团队不反感的接入方案

4.1 配置文件的结构

t3code 的配置不是让用户从零写规则,而是通过 YAML 声明分层、规则开关和豁免。一份最小可用配置大致长这样:

version: 2 languages: [typescript, python] layers: interface: - "apps/**" - "controllers/**" domain: - "domain/**" infrastructure: - "repositories/**" - "clients/**" rules: function_complexity: level: error max: 10 no_skip_layer: level: warning circular_dependency: level: warning trace: enabled: true required_on: ["domain/**", "apps/**"] token_pattern: "(req|story|issue):\\s*[A-Z]+-\\d+"

最核心的是layers和rules两段。layers决定了 T2 规则怎么判断边界;rules控制哪些规则打开、什么级别。trace段配置 T3 的需求标签格式。这份配置从接入到稳定,我只推荐分三阶段推进。

4.2 第一阶段:只跑 T1,输出参考报告

前两周,不要想着在 CI 里设置硬性失败。先让 t3code 在 CI 的定时任务或者手动 job 里跑起来,输出 JSON 和 Markdown 报告,发到群里做周知。这个阶段的目标是让团队对工具本身脱敏,看到告警不用紧张,知道它只是“多了一个机器人点评”。

T1 和编辑器提示大量重复其实没有关系。这个阶段的真正价值,是让大家开始习惯 t3code 的输出格式和定位风格。等没有人再问“这个工具是干嘛的”,就可以进入第二阶段。

4.3 第二阶段:打开 T2,用自动评论代替阻断

第二阶段很重要,我会把 T2 规则设成 warning,集成到 MR 流水线里。t3code 分析完变更文件后,会在 MR 下方自动发一条评论,列出 warning 级别的告警。评论里必须带着修复建议,不能只抛问题。比如对于跨层调用,评论会直接建议“把这段调用抽到 application service 中引入 domain 接口”。

这个阶段的技巧在于:不要设置流水线硬失败,因为跨层调用往往不是单点问题,一旦硬失败就会阻塞整个 MR,情绪对抗会立刻出现。改成自动评论之后,写代码的人有机会在 24 小时内自行处理,处理不了也要在讨论区给出解释。有解释就说明工具有效,哪怕问题没修。

4.4 第三阶段:T3 只在核心模块开启强制

等 T2 跑了两三周,大家开始习惯这个流程后,再开 T3。T3 的required_on只匹配核心业务目录,比如domain/**、apps/**,不要一开始就全仓扫描。对于核心模块,如果改动了带 T3 标记的函数却没有更新需求标签,会提示一个 warning;其他模块的 T3 保持 info。

还有一个实操建议:把t3code check接进 Git pre-commit 钩子,这样本地提交时就能快速发现 T1 问题,避免把问题带到 CI。但 pre-commit 里只放 T1,别放 T2 和 T3。T2、T3 的计算需要跨文件依赖图,在 pre-commit 的小范围 diff 场景下跑又慢又不准。

5. 三个月的运行复盘:效率、误报与团队反馈

5.1 真实效果和误报率

t3code 在我们内部跑了一个季度,扫描了 40 多个模块,累计发现循环依赖 23 处、跨层调用 18 处、T3 标签缺失 47 处。把这些告警和人工 Code Review 的结果对照,真正有重构价值的约 31 处,误报 11 处,误报率压到了 27%。这个数值不算好看,但比第一版的 60% 好太多了,而且误报集中在我没有料到的高层调用场景上。

比如“controller 调 repository”这条规则,在 Java 项目里很常见,但在 TypeScript 项目里,由于大多数脚本代码没有明显的分层目录,误报率会飙升。针对这种差异,我后来在语言层做了不同的默认规则集,TypeScript 项目默认关掉部分严格的层依赖检查,先保证不误伤。

5.2 团队反馈里最有价值的几句话

有同事说:“以前 code review 靠面子,你说得对但对方不听;现在工具替我说了,反而能平心静气讨论。”这句话让我意识到,t3code 真正的价值不是替代人,而是把“个人审美”变成“团队契约”。当告警规则本身是公开、可配置、可争论的,讨论就从“你的感觉有问题”变成了“我们的这条规则还合不合适”。

另一条反馈是:“T3 让新人在核心模块改代码时,会主动去查需求单号了。”这是没想到的附加值。以前新人只是埋头改代码,不知道这段逻辑背后对应哪个业务目标;有了 T3 标记,他至少会去看一眼需求描述,理解代码为什么长这样。

5.3 后续会做的改进

目前 t3code 在增量缓存上还很原始,每次全量扫描一个大仓库仍需要十几秒,后续计划用文件哈希做增量索引。另外我在考虑把它封装成 VS Code 插件,让 T2 的边界提醒能直接在编辑器里显示,而不是等 CI 跑完才看到。新语言方面,打算先支持 Go 和 Java,这两个语言在团队里呼声最高。

如果让我重新做一次 t3code,我不会先把 T1 打磨得那么精细,而会从 T3 的单一来源开始。因为 T1 的问题,编辑器早就提示了;真正拖垮项目的,是模块边界和需求关联的失控。工具最终会过时,但“三层规范”这个思考框架,比任何一条具体规则都经得起时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询