Emit 与重写
ttsc 有两条 emit 路径,它们解决同一个问题——"在 tsgo emit 出的 JS 里植入插件生成的代码"——但机制完全不同:
- 文本级重写(
driver/rewrite.go):旧机制。让 tsgo 正常 emit,再在输出文本里用正则定位每个被识别的插件调用点,把调用表达式 splice 成消费者生成的 JS。 - AST 集成 emit(
driver/emit_plugin.go):新机制。插件返回 AST 而非文本,在与内建 transformer 同一个EmitContext里运行,由 tsgo 的 module-transform 自己别名注入的 import。
本页讲清两者的设计、并行 emit 的串行化、以及文本 splice 为什么脆弱。
为什么是两条路径
历史上 ttsc 沿用 tsgonest 的"emit 时重写"模式:编译期 transformer 阶段被移到原生编译器之外,所以 tsgo emit 出的 .js 里插件调用表达式原样保留,ttsc 拦截 WriteFile 回调,找到调用点替换(rewrite.go:1 包注释)。这条路径只操作输出文本,靠调用方预先生成 (file, call, emittedJS) 三元组列表。
文本 splice 的根本脆弱在于:它在 tsgo 打印完 JS 之后用正则匹配 <alias>.<method>(...)。emit 后的别名带去重后缀(typia_1.default、typia_2),跨行属性访问链会被保留换行,于是匹配逻辑必须容忍这些变体(见下文 callRegexFor)。这是把语义问题当文本问题解,注定要不断打补丁。
新路径 EmitWithPluginTransformers(emit_plugin.go:139)把问题搬回 AST 层:插件用 ec.Factory 构造节点、用 ec.SetOriginal 关联,tsgo 的内建 module-transform 自己负责发 require 并给引用起别名。插件返回 AST,不返回文本。这是文本 splice 的替代品(plugins.go:42 的 EmitTransformPlugin 注释)。
路径一:文本级重写(rewrite.go)
关键数据结构
Rewrite(rewrite.go:33):一次 splice,含File、RootName、Namespaces、Method、Replacement、ConsumeParens。RewriteSet(rewrite.go:43):按文件路径分组的 rewrite,保留源顺序。RewriteSentinel = "/* @ttsc-rewritten */"(rewrite.go:70):插在已 patch 文件顶部,让对已重写文件重跑 emit 成为 no-op(watch/重建循环与重跑 emit 的测试需要)。
匹配逻辑的脆弱面与缓解
callRegexFor(rewrite.go:368)编译松散匹配正则:因为 tsgo emit 保留源码换行,源侧 typia.misc\n .literals<T>() 落到输出里是 typia_1.default.misc\n .literals()。字面 needle 会漏,于是正则允许段间任意空白。candidateRoots(rewrite.go:428)把裸模块名展开成 tsgo CommonJS emit 可能产出的标识符集合(typia、typia_1.default、typia_2.default、typia.default、typia_1、typia_2),用 alternation 匹配。
findSourceForOutput(rewrite.go:258)解决另一个真实 bug:用源相对路径(相对所有注册源的公共目录)做锚定后缀匹配,比通用后缀匹配更严,避免 barrel 文件 lib/api/x/index.js 误撞无关的 src/.../y/index.ts(注释记录了这是 typia 在 shopping-backend 的 nestia 生成 barrel 上踩到的 could not locate typia.random(…) call bug)。歧义匹配(两个及以上同尾源)返回 no-match。
matchParen(rewrite.go:473)是一个手写的括号匹配器,能跳过嵌套括号、字符串、模板字面量、注释、正则字面量——canStartRegexLiteral(rewrite.go:603)用"前一个非空白字符"区分除法与正则字面量开头。这套 mini-scanner 是文本路径复杂度的集中体现。
路径二:AST 集成 emit(emit_plugin.go)
PluginTransform 契约
形状刻意镜像经典的 ts.TransformerFactory(SourceFile→SourceFile),让已有的基于节点的 transformer 只要接受 EmitContext 就能接入(emit_plugin.go:66)。返回 nil 表示不改。
EmitWithPluginTransformers(emit_plugin.go:139)绕过 tsgo 自己的 emitter,从 shim 部件手工组装 emit 管线,把插件 transformer 放在最前、与内建链(type-erase、import-elision、module-transform……)在同一个 EmitContext 里跑。因为绕过了 tsgo 的 emitter,它必须自己复现 emitter 的 source-map 步骤——用 PrintFileWithSourceMap 让 sourceMap/inlineSourceMap 构建仍能产出 .js.map 与 //# sourceMappingURL= 尾注,即使 transform 把一行源展开成多行。
两个对 tsgo 行为的脆弱补丁
这两处是 tsgo 升级时最该回归的地方:
guardedEmitResolver(emit_plugin.go:47):tsgo 的 const-enum 内联器对它访问的每个属性/元素访问调GetConstantValue——包括插件注入的合成节点;tsgo checker 在为这种节点算上下文类型时可能 nil-panic。失败只意味着"不是 const enum",所以这里 recover 成 nil、保留节点原样。restoreOriginalDeclarationSymbols(emit_plugin.go:112):插件重写嵌在 class/interface/enum 里的节点(如 controller 方法上的装饰器调用)时,visitor 会重建每个祖先容器以容纳改动的子节点;这些重建容器带original链(emit context 设的)但不带 binder symbol(update hook 只记 original 不拷DeclarationBase.Symbol)。tsgo 的 emit resolver 在MarkLinkedReferencesRecursively里解析穿过这些容器的标识符时会调getSymbolOfDeclaration(container),读container.Symbol()然后在重建的容器上 nil-panic。这里从 original 把 symbol 拷回(symbol 对象是共享、与节点无关的查找键),让 resolver 能像在 parse 树上那样标记引用。
并行 emit 的串行化(系统级不变量)
tsgo 的 emit 是并行的——每个源文件一个 goroutine 调一次 WriteFile。ttsc 在两条路径都用一把 wfMu 把整个回调串行化:
EmitAllRaw(rewrite.go:94):wfMu保护用户writeFile(如@nestia/core的 per-file 重写游标与 runtime-alias 缓存会触发并发 map 崩溃)。emit(rewrite.go:130):wfMu保护cursorsmap 与包装的writeFile。
注释(rewrite.go:86、rewrite.go:141)说明了契约:emit 阶段是 ttsc 保证单线程跑的阶段,插件作者可以在 WriteFile 里放 per-file 游标或输出 map,由 ttsc 拥有串行化,而不是把 goroutine 安全压给每个插件作者。回调是廉价 I/O,串行化几乎不花钱,parse/check/emit-text 仍并行。
cursors map(rewrite.go:140)跟踪每个源路径已应用多少 rewrite,让增量 watch 重建从正确偏移续上而非从零重扫。
emit 的几个入口
api-compile(cmd/ttsc/api_compile.go:96)用 EmitAll + 自定义 writeFile 把输出捕获进内存 map(按项目相对路径键控),不碰 outDir——这是公共编程式 API 不写文件到用户树的实现方式。
sourcemap 修正
source preamble(如链接进 typia 宿主的 @ttsc/banner)会移动 map 的源坐标。EmitWithPluginTransformers 在 preamble 非空时对外部 .js.map 与内联 base64 map 都调 AdjustEmittedSourceMap,按 preamble 行数 dropLines 修正(emit_plugin.go:173)。这样"preamble + transform"组合不会留下未修正的 map(否则只有 utility host 的 WriteFile 会 patch map)。
维护者提示
- 新增插件 emit 行为优先走 AST 路径(
EmitTransformPlugin),别扩文本 splice。 - 改
matchParen/callRegexFor时务必加负向 twin 测试(一个相邻、必须不匹配的例子),文本匹配的过度匹配在没有反例时不可见(见.codex/skills/development/SKILL.md的"覆盖而非 happy path")。 - tsgo 升级后,先跑会触发
guardedEmitResolver与restoreOriginalDeclarationSymbols的 typia/nestia transform 测试。