架构总览

本页解释 ttsc 的系统边界、四类宿主进程、组件之间的依赖方向,以及为什么是这个形状。读完应能在不打开源码树的情况下复述 ttsc 的体系结构。

系统边界

ttsc 把自己定位成 typescript-go 之上的工具链宿主,而不是又一个编译器。它从不重写类型检查器;它包装 tsgo,并在 tsgo 已经建立的 ProgramChecker 之上叠加四件事:插件 transform、lint/format、代码图谱、LSP 增强。

边界因此落在三个轴上:

  1. JS 与 Go 的边界。JavaScript 负责"决策与编排"(解析哪个 tsconfig、发现哪些插件、惰性把哪段 Go 源构建成二进制、用什么参数 spawn 谁、如何把退出码与诊断翻译回结构化结果);Go 负责"重活"(建 Program、跑 Checker、emit、重写、lint 规则遍历、图谱构建、LSP 代理)。两层用 进程边界 + JSON/CLI 协议 + 环境变量握手 连接,不共享内存。
  2. ttsc 与 typescript-go 的边界。Go 侧只有一个包允许直接 import shim/*packages/ttsc/driver(见包注释 driver/host.go:1)。其余 Go 代码消费 driver 暴露的、与 shim 无关的小接口(主要是 *driver.Program)。这把"typescript-go 长什么样"的知识收敛在 driver 与 shim 两处。
  3. ttsc 与插件作者的边界。插件作者只能触碰 shim/* 再导出的 typescript-go 表面,永远不能直接 import tsgo 的 internal/(Go 语言禁止跨模块)。一个缺失的再导出被定义为 ttsc 的 bug,不是插件的 bug(见 .codex/skills/typescript-go-sync/SKILL.md)。

四类宿主进程

ttsc 不是单一进程,而是按职责拆成四类原生宿主,全部由 JS 启动层 spawn:

  • cmd/ttscpackages/ttsc/cmd/ttsc/main.go):原生编译宿主。子命令 buildcheckapi-compileapi-transformdemo。它本身不加载项目插件;插件选定的旁车由 JS 在它之前调用。它通过 driver.LoadProgram 建 Program,按需 emit,并经 driver 报告诊断。
  • cmd/utility-hostpackages/ttsc/cmd/utility-host/main.go + packages/ttsc/utility/):链接插件的通用宿主。当项目有"链接式"(非 main 包)transform 插件时,这些 Go 源被静态链接进它,再通过 driver.RegisterPlugin 注册。它还实现常驻 serve 协议(utility/serve.go),即 TtscService 的后端。
  • cmd/ttscserverpackages/ttsc/cmd/ttscserver/main.go):LSP 宿主。它只做参数解析、版本元数据、构造 NativePluginSource,然后委托给 internal/lspserver.RunLSPServer,后者在编辑器与 tsgo --lsp --stdio 之间做字节级代理。
  • @ttsc/graph / cmd/ttscgraph:把 internal/graph 构建的代码图谱 dump 成 JSON,或作为 MCP 服务器暴露给编码代理。

第五个进程是 tsgo 自己:无插件构建时 JS 直接 spawn tsgo(runBuild.ts::runTsgoBuild),LSP 模式下 ttscserver 把它当上游 LSP server。

依赖方向

关键约束:

  • 公共 npm 表面故意很小src/index.ts 只导出 TtscCompilerTtscServicestructures/;CLI launcher、二进制解析、项目解析、原生构建辅助全部内部化(见 src/index.ts:1 注释)。这让公共契约稳定,给重构留余地。
  • Go 侧 cmd/*internal/*utility/* 依赖 driver,但直接依赖 shim。只有 driver 越过 shim 边界。
  • shim 子模块各自独立(每个有自己的 go.mod),由同级 go.work 串联;详见 shim 设计

设计叙事:为什么是这个形状

为什么不把所有东西都写进一个 Go 二进制? 因为插件是 用户项目里的 Go 源码,必须按需、按用户项目的依赖图编译,且要跨进程隔离编译失败。JS 是 Node 生态的天然入口(npx ttsc),它擅长解析 package.json/tsconfig、做 require.resolve、管理 npm 依赖与缓存目录。把"编排"留在 JS、把"编译重活"留在 Go,是顺着两套生态各自的强项切的。

为什么用进程边界而不是 cgo / FFI? 进程边界让编译失败、panic、内存问题被天然隔离;让 emit 的并行性可以被 ttsc 用一把锁串行化(见 Emit 与重写);也让"惰性构建 + 缓存二进制"成为可能——一个二进制构建一次,之后被多个进程并发复用(见 go build 缓存)。代价是每次调用的 spawn 开销与 JSON 序列化,ttsc 用常驻宿主(TtscService / utility serve)来摊薄热路径。

为什么 shim 要逐个再导出而不是 fork tsgo? Go 的模块规则禁止跨模块 import internal/。fork 整个 tsgo 会让版本同步变成噩梦。逐个再导出 + 机械完整性闸门(shim_audit)把"缺失再导出"这类反复出现的 bug 变成 CI 可强制的不变量(见 shim 审计与同步)。

耦合集中在哪里、风险在哪里?

  • driver 是 typescript-go 知识的唯一汇聚点,也是最脆弱的升级点:一次 tsgo 升级最可能在这里炸(emit 管线、emit resolver、const-enum 内联)。
  • rewrite.go 的文本级 splice 是历史遗留的脆弱面(正则匹配 emit 后的调用点);新路径已转向 AST 集成的 EmitWithPluginTransformers(见 Emit 与重写)。
  • 单 checker 约束(forceSingleChecker)是一个正确性与并行度的取舍:ttsc 的串行 transform/rewrite 阶段要求同一个 checker,于是把 checker 池钉到 1,而把 parse/emit 留在并行(见 Program 与 Checker)。

不变量(系统级)

  • emit 阶段的 WriteFile 回调单线程:tsgo 并行 emit,但 ttsc 把整个回调用一把 wfMu 串行化,让插件的输出重写器永远看到一个写入者(driver/rewrite.go:109driver/rewrite.go:149)。
  • 链接插件按构建顺序配对:driver 用注册顺序(init 顺序)而非包名把链接 Go 包与 manifest 条目配对(driver/plugins.go:65)。
  • 公共 transform 是源到源,公共 compile 才产 JSTtscCompiler.transform() 只返回 TypeScript 文本,绝不返回 JS/d.ts/sourcemap;那些属于 compile()src/TtscCompiler.ts:143)。
  • JS transform 函数不是公共契约:插件描述符是 JS,transform 逻辑必须是 Go;带 transformSource/transformOutput 的描述符被显式拒绝(loadProjectPlugins.ts::rejectJsTransformFunctions)。

接下来