lint 引擎

packages/lint/linthost/engine.go 是 lint 的核心遍历器:它把规则配置绑定到一个 Program,对每个源文件遍历一次 AST,把每个访问到的节点分派给关心它的规则。本页讲分派结构、并行/串行决策、per-file Context 复用、以及 panic 隔离——这些都是为大项目性能精心设计的。

三个核心抽象

engine.go:33 起定义了分层:

type Rule interface {
  Name() string              // 用户在 rules map 里写的 id
  Visits() []shimast.Kind    // 关心的 AST 节点类型
  Check(ctx *Context, node *shimast.Node)  // 每个相关节点调一次
}
  • Rule 是每条规则实现的契约,init 时注册、之后不可变。
  • Engine 对每个源文件遍历一次 AST,把每个访问节点分派给经 Visits() opt-in 的规则。
  • Context 是规则触发时收到的句柄,拥有回引擎的报告通道。

规则是跨文件无状态的:每次调用拿到全新 Context,不能保留对上一个文件的引用。这让引擎对并发友好。

按 Kind 索引的分派(性能核心)

Engine.rulesengine.go:256)是一个shimast.Kind 值索引的定长切片 [][]Rule,而非 map:

rules [][]Rule  // rules[int(kind)] = 关心该 kind 的规则列表

注释(engine.go:248)解释:KindCount(~350)小且有界,切片去掉了 per-node 的 map hash——一个 50k 节点文件的 walk 会做 50k 次分派查找。切片让热路径线性于活跃规则数而非总规则数。未用 kind 的条目是 nil。

NewEngineengine.go:291)构建时把每条启用规则的 Visits() 去重后追加进对应 kind 槽(engine.go:320,去重防止贡献者在 Visits() 里列重同一 kind 导致每节点触发两次)。

并行 vs 串行

Engine.Runengine.go:427)默认按 runtime.NumCPU() 并行 per-file,但在两种情况退到串行(runsSerialengine.go:283):

  1. 调用方 SetSerial(true)--singleThreaded 时宿主调);
  2. 有类型感知规则(needsTypeChecker)。

为什么类型感知规则强制串行:类型感知规则通过 Context.Checker 拿到单个共享 checker,而那个 checker 不是并发安全的。所以只要有一条规则要 checker,整个引擎退串行(engine.go:270 注释),调用方不必自己清 serial 标志。

并行路径(engine.go:439)用 runtime.NumCPU() 个 worker 的信号量,per-file 结果存进 perFile[idx],最后按源文件顺序合并,让诊断流跨运行确定(即使 per-file 工作乱序完成)。

per-file Context 复用

runFileengine.go:518)有一个关键性能设计:每个文件给每条活跃规则绑一个 Context,而非每个 (节点, 规则) 对一个。Context 的字段(File、Checker、文件解析后的 Severity、规则 Options blob、format 标志)在文件内不变,所以引擎在这里构建一次(engine.go:553)。

注释(engine.go:530)说明:早先形态给每个 (节点, 规则) 对分配新 Context,在大程序上意味着数百万短命堆分配与对应的 GC 压力。规则从不改自己的 Context,所以复用安全。ctxByRule map 还 memo 了"该文件此规则关闭"(nil 条目),让注册了多个 kind 的规则只解析一次 severity。

walker:缓存的 ForEachChild 回调

lintFileWalkerengine.go:487)存在是为了让 ForEachChild 回调能作为缓存的方法值(childCB)。注释(engine.go:481):朴素的嵌套闭包 walker 每次递归调用都重分配一个回调(它捕获 walking 函数变量),50k 节点文件就是 50k 次一次性闭包分配。缓存方法值把它降到每文件一次分配——pre-Opt-4 约 38% CPU 花在内层 ForEachChild 闭包上。

walkengine.go:495)后序遍历:先分派当前节点给注册该 kind 的规则,再经 childCB 递归子节点,让父节点看到已检查的子树。

声明文件优化

runFileengine.go:541)对声明文件(.d.ts)只绑定 opt-in 的规则(ruleVisitsDeclarationFiles)。值级规则在声明文件里永不触发(可执行语法不能出现在声明文件),所以分派给它们纯属开销。这对声明密集的树是显著优化。

panic 隔离

runRuleCheckengine.go:638)用 recover() 屏障包住每条规则的 Check,让一条崩溃的规则不中止整个 ttsc fix/check run。内建规则不预期崩,但第三方贡献规则经公共 rule.Context adapter 由任何人编写;保护引擎是 bound 一条坏规则爆炸半径的唯一方式。恢复的 panic 被表面成一条带规则名的 SeverityError finding,用户在正常诊断流里看到失败。

Finding 与报告

规则经 Context.Report / ReportFix / ReportRange / ReportRangeFixengine.go:148)报 FindingReport 的 pos 会跳过 leading trivia(shimscanner.SkipTrivia),让渲染的 path:line:col banner 指向冒犯 token 而非缩进起点。Findingengine.go:122)带 IsFormat 标志,镜像分派规则的类别,让 format 子命令的过滤器无需重查注册表就能路由(fix 子命令两类都应用,不过滤)。

inline-disable 过滤(filterInlineDisabledFindingsWithDirectivesengine.go:618)即使文件无语句列表也应用——SourceFile 级规则在 // ttsc-lint-disable 注释上触发时也要被尊重。collectUnknownDirectiveRules 记录指向未注册规则的指令名,作为配置警告。

不变量

  • 规则跨文件无状态,每次调用全新 Context。
  • 类型感知规则集永远串行(单共享 checker)。
  • 诊断流按源文件顺序确定,无论并行与否。
  • Context.Severity == Off 的 finding 在 Report 最终门被丢(防御性,引擎已先按 severity 过滤)。

失败模式

失败行为位置
规则 panic转成 SeverityError finding,附规则名runRuleCheck:638
贡献者 Visits 含越界 Kind防御性跳过,不 panicNewEngine:326
未知规则名(配置/指令)记录为 UnknownRules 警告UnknownRules:343

接下来