架构总览
本页解释 ttsc 的系统边界、四类宿主进程、组件之间的依赖方向,以及为什么是这个形状。读完应能在不打开源码树的情况下复述 ttsc 的体系结构。
系统边界
ttsc 把自己定位成 typescript-go 之上的工具链宿主,而不是又一个编译器。它从不重写类型检查器;它包装 tsgo,并在 tsgo 已经建立的 Program 与 Checker 之上叠加四件事:插件 transform、lint/format、代码图谱、LSP 增强。
边界因此落在三个轴上:
- JS 与 Go 的边界。JavaScript 负责"决策与编排"(解析哪个 tsconfig、发现哪些插件、惰性把哪段 Go 源构建成二进制、用什么参数 spawn 谁、如何把退出码与诊断翻译回结构化结果);Go 负责"重活"(建 Program、跑 Checker、emit、重写、lint 规则遍历、图谱构建、LSP 代理)。两层用 进程边界 + JSON/CLI 协议 + 环境变量握手 连接,不共享内存。
- ttsc 与 typescript-go 的边界。Go 侧只有一个包允许直接 import
shim/*:packages/ttsc/driver(见包注释driver/host.go:1)。其余 Go 代码消费 driver 暴露的、与 shim 无关的小接口(主要是*driver.Program)。这把"typescript-go 长什么样"的知识收敛在 driver 与 shim 两处。 - ttsc 与插件作者的边界。插件作者只能触碰
shim/*再导出的 typescript-go 表面,永远不能直接 import tsgo 的internal/(Go 语言禁止跨模块)。一个缺失的再导出被定义为 ttsc 的 bug,不是插件的 bug(见.codex/skills/typescript-go-sync/SKILL.md)。
四类宿主进程
ttsc 不是单一进程,而是按职责拆成四类原生宿主,全部由 JS 启动层 spawn:
cmd/ttsc(packages/ttsc/cmd/ttsc/main.go):原生编译宿主。子命令build、check、api-compile、api-transform、demo。它本身不加载项目插件;插件选定的旁车由 JS 在它之前调用。它通过driver.LoadProgram建 Program,按需 emit,并经 driver 报告诊断。cmd/utility-host(packages/ttsc/cmd/utility-host/main.go+packages/ttsc/utility/):链接插件的通用宿主。当项目有"链接式"(非main包)transform 插件时,这些 Go 源被静态链接进它,再通过driver.RegisterPlugin注册。它还实现常驻serve协议(utility/serve.go),即TtscService的后端。cmd/ttscserver(packages/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只导出TtscCompiler、TtscService和structures/;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:109、driver/rewrite.go:149)。 - 链接插件按构建顺序配对:driver 用注册顺序(init 顺序)而非包名把链接 Go 包与 manifest 条目配对(
driver/plugins.go:65)。 - 公共 transform 是源到源,公共 compile 才产 JS:
TtscCompiler.transform()只返回 TypeScript 文本,绝不返回 JS/d.ts/sourcemap;那些属于compile()(src/TtscCompiler.ts:143)。 - JS transform 函数不是公共契约:插件描述符是 JS,transform 逻辑必须是 Go;带
transformSource/transformOutput的描述符被显式拒绝(loadProjectPlugins.ts::rejectJsTransformFunctions)。