Mahoo Space

起点:同一个 User,写了两遍

先说一件小事。我遇到最多的一类问题跟时间有关:后端用 time.Time 序列化,前端拿到的却是一个字符串,某次联调时页面上的时间整整差了八个小时,我从 Network 面板一路翻到后端日志,最后发现是前端把它当成本地时间解析了。修完那个 bug,我在两边各写了一遍 User 的定义,心里很清楚这不是第一次,也不会是最后一次。

TypeScript
interface User {
  id: string;
  name: string;
  createdAt: string; // 后端是 time.Time,序列化之后到底是什么格式?谁说了算
}
Go
type User struct {
	ID        string    `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"createdAt"`
}

这两段代码摆在一起,看着挺像,实际上毫无关系。字段改名了、name 从必填变成可空、createdAt 从 RFC3339 换成毫秒数,编译器都不会吭一声:TypeScript 只认识前端那份,Go 只认识后端那份,中间那段路靠的是人的记性,或者上线之后用户帮你发现。

OpenAPI 我试过,把类型写成 proto 再各生成一遍也试过。它们确实省掉了手抄,但省不掉“两份”这件事:生成器吐出来的还是两份代码,谁先改、谁后生成、生成物要不要进仓库,全是流程问题。工具能把重复劳动搬走,搬不走“同一个事实被表达了两次”这个根源。

更让我耿耿于怀的是另一件事。TypeScript 的类型系统其实相当能打,结构类型、字面量类型、条件类型、模板字面量类型,表达力比很多工业语言都强,可它只在编辑器里活着。tsc 检查完,类型就被整体擦掉,运行时一个字节都不剩。而 Go 的类型是要落到内存布局、调用约定和接口表上的,是能换来机器码的东西。

一边有表达力却没有实体,一边有实体却不愿意多写。我想要的其实是中间那个东西:同一份类型,既能用 TypeScript 的写法描述,又能真的参与编译,最后变成一段能跑的原生代码。

一个想法:语法借 TypeScript,后端借 Go

顺着这个念头往下想,问题很快就变成“后端怎么办”。自己写代码生成?寄存器分配、调用约定、栈增长、GC 写屏障,每一样都能单独耗掉几个月,而且我大概率会写出一个“能跑但偶尔崩”的东西。上 LLVM 呢?那得先把类型系统降到 LLVM IR,再自己接一套运行时,工作量只是换了个地方堆。

Go 在这里几乎是现成的答案。标准编译器已经把目标文件生成、指令选择、寄存器分配、链接、栈增长和垃圾回收全做完了,我要做的只是“把函数塞进去”;再加上编译快、产物是自包含的静态二进制、交叉编译一条命令,正好是我想要的那种东西——写完丢到服务器上就能跑,不用先装个运行时。

所以项目最后长成这样:写 TypeScript 的语法,编译出原生二进制,运行时用 Go 的,名字叫 Tynel。

需要提前说清楚它不是什么,免得期待错位。它不打算替代 Node、Deno 或 Bun,不打算支持整个 TypeScript 语言和 npm 生态,也不是奔着生产级编译器去的。它是一个学习型项目,目标只有一条:把 TypeScript 到原生二进制这条路真正走通一次,每一步都能解释、能复现、能被反汇编核对。

熟悉 Go 的读者大概会立刻想到 cgo 或者 TinyGo,这两条路我都没走。不走 cgo,是不想把 C 工具链和 FFI 边界引进来;不走 TinyGo,是因为它自己实现运行时,而我看中的恰恰是标准 Go 运行时那套现成的 GC 和调度。代价是产物里带着一整个 Go runtime——那 2.4 MB 里大部分是它,我觉得这笔账挺划算。

和 GPT 聊出来的六个阶段

想法挺美,落到“从哪下手”就卡住了。一个人闷头写编译器,最常见的结局是铺开十几个包、每个都写了一半,然后某天发现它们互相之间对不上。我把想法丢给 GPT 聊了几轮,重点不是让它写代码,而是逼自己回答一个问题:怎么证明这一步真的成了?

聊出来的原则有三条,事后看每一条都救过我:

  1. 一次只增加一种能力。每个阶段结束都要有一个能看见、能核对的结果,而不是“内部结构更完善了”这种自己骗自己的话。
  2. 先固定后端,再接前端。最早的那个阶段完全不读 TypeScript,原因后面会讲。
  3. 每个阶段留一个标签。标签指向的提交可以独立复现,阶段之间不互相污染,出了问题能退回去。

六个阶段排下来是这样的:

阶段主题可观察的结果
01一份加法描述怎样成为二进制手写中间表示 → 原生程序输出 42,改一个数字得到 43
02从真实 TypeScript 生成二进制tynel build add.ts 完成检查、降低和链接
03编译条件选择而不多执行一步比较、分支、布尔短路、三元式的结果与求值顺序正确
04编译会更新状态的程序局部赋值、while、递归,退出路径正确
05从多个源文件构建一个程序模块解析、声明身份、import type、初始化顺序
06把原型交付为可使用的编译器稳定命令、诊断、干净重建、产物独立运行

每个阶段再拆成 7 到 9 个子节点,一共 48 个。每个子节点都要写清依赖、边界、交付物,以及正向和负向验收:正向是“能编译加法”,负向是“调用一个不存在的函数必须在编译前被拒绝”。写计划的时候我觉得负向验收有点形式主义,真做起来才发现它才是分水岭——只测正向,你交付的可能只是一个刚好能跑通某个输入的脚本。

第一阶段刻意不碰 TypeScript,这一点当时纠结了很久。直觉上应该从“读源码”开始,毕竟那才是编译器该干的事。可那样一上来就有三个未知数:TypeScript 前端、中间表示、Go 后端,任何一处出错都得在三个地方找原因。把后端先钉死,之后前端只要吐出同一份中间表示,整条链路就应该原样工作;反过来先做前端的话,我连“错在类型检查还是错在代码生成”都分不清。

第一阶段:先让后端交出 42

于是第一阶段的输入不是 .ts 文件,而是一份手写的 JSON,描述 add(40, 2) 这个调用:

JSON
{
  "schemaVersion": 1,
  "entry": "main",
  "functions": [
    {
      "name": "add",
      "params": [
        { "name": "a", "type": "number" },
        { "name": "b", "type": "number" }
      ],
      "result": "number",
      "body": [
        {
          "op": "return",
          "value": {
            "op": "binary",
            "operator": "+",
            "left": { "op": "param", "name": "a" },
            "right": { "op": "param", "name": "b" }
          }
        }
      ]
    },
    {
      "name": "main",
      "params": [],
      "result": "void",
      "body": [
        {
          "op": "print",
          "value": {
            "op": "call",
            "function": "add",
            "args": [
              { "op": "number", "bits": "0x4044000000000000" },
              { "op": "number", "bits": "0x4000000000000000" }
            ]
          }
        }
      ]
    }
  ]
}

跑起来是几条命令,第一次还要先准备一份隔离的 Go 工具链,那一步比较磨人,这里先略过:

Shell
# 构建修改版编译器和开发入口(需要先准备好隔离的 Go 工具链)
./bridge/go/build
./tools/build

# 把描述编译成原生程序,产物写到 out/add
./bin/tynel build-ir examples/ir/add.nir.json -o out/add

# 运行刚生成的程序
./out/add
Text
$ ./out/add
42

第一次看到这个 42,我盯着终端看了一会儿。它太普通了,普通到不像是我写出来的东西——没有 Node 的启动日志,没有 V8 版本号,敲下去就是一行数字,然后进程退出。产物 2.4 MB 左右,不依赖 TypeScript 源文件,不依赖 Node,也不依赖编译器本身。

但 42 本身说明不了什么,万一它是在编译期算好、直接写进数据段的呢?所以真正让我放心的不是这个 42,而是把 40 改成 41 之后输出变成 43:

Shell
# 复制一份描述,把第一个实参从 40 改成 41
cp examples/ir/add.nir.json out/add-41.nir.json
perl -pi -e 's/0x4044000000000000/0x4044800000000000/' out/add-41.nir.json

./bin/tynel build-ir out/add-41.nir.json -o out/add-41
./out/add-41
Text
$ ./out/add-41
43

顺带说一句,这条 perl -pi -e 最初写的是 sed -i '',在我这台机器的 zsh 上直接报错,试了两次才改掉——命令行教程里的每一行,都值得亲手跑一遍再抄进文档。

build-ir 还会打印输入和编译器的哈希,并在产物旁边写一份构建记录:

Text
已生成 out/add(2429842 字节)
  输入     c9f89f9d0444
  编译器   6907329847eb
  构建记录 out/add.build.json

这两个哈希后面还会出场,它们是“这个产物到底是哪次构建出来的”的唯一凭据。

到这里实验的骨架算是立住了。但“有个程序输出了 42”离“我理解它为什么输出 42”还差得远,接下来要回答四个问题:这份 JSON 到底描述了什么、谁在读它、读完之后发生了什么、以及为什么不必先生成一份 Go 源码。

中间表示:把两种知识分开

先看它描述了什么。JSON 里没有 TypeScript,也没有 Go,只有程序结构:

结构在文件里的样子
模块schemaVersion、入口函数名 entry、函数列表
函数名字、参数表、返回类型(number 或 void)、语句列表
参数名字与类型,首版只有 number
语句return 与 print
表达式数字字面量、参数引用、直接调用、二元运算

这份结构叫 NIR,中间表示。名字不重要,重要的是它解决的那个具体麻烦:前端懂 TypeScript 的类型和语法,后端懂机器码和调用约定,让这两个东西直接对话,等于把两套知识搅成一锅粥。中间插一层双方都能读的结构,前端只负责把源码翻译成它,后端只负责把它翻译成机器码,谁也不用理解对方那一半。

两边读者的类比不太一样。写过 Go 的,可以把它当成 go/ast 的裁剪版:有节点、有类型、有引用,但不携带任何一方编译器内部的指针。写过 TypeScript 的,可以把它当成“AST 去掉语法糖之后的形态”:括号、分号、const 和 let 的区别、return 后面能不能省分号,这些东西在 NIR 里一概不存在。两种说法指向同一件事,它只保留“程序要做什么”,不保留“代码长什么样”。

它的形状很像 C 的 tagged union:每个节点先看 op,再由 op 决定哪些字段有意义,其余字段共用同一批槽位。麻烦在于 JSON 没有联合类型,字段只能平铺,于是文件里天然会出现“某个 op 不该有的字段”。解码器的处理是直接拒绝,而不是忽略,否则 {"op":"number","name":"x"} 这种输入会悄悄变成一个既像字面量又像参数的东西,等后端发现的时候又是一场灾难。

选 JSON 纯粹是因为手写方便、能进版本库、出错时一眼看出是哪个字段坏了,跟“工业级”三个字没有关系。现在还没有 TypeScript 前端,所以这份文件是我手写的;接上前端之后它由前端生成,格式不变。这一点后来变得很关键:NIR 同时是内存结构和文件格式,意味着后端可以在前端还不存在的时候被单独驱动和验证——第一阶段能成立,靠的就是这个性质。

数字为什么存成位模式

如果你点开 examples/ir/add.nir.json,第一反应大概是“40 去哪了”。文件里没有 40,只有 "bits": "0x4044000000000000"。

这不是为了显得硬核。TypeScript 的 number(也就是 JavaScript 的 number)是 binary64:1 位符号、11 位指数、52 位尾数,Go 的 float64 是同一个格式,这是两边能对上的基础。但这个格式里有几种值,用十进制文本写不出来,或者写出来会变:

  • 负零和正零。十进制里都写作 0,位模式不同:0x8000000000000000 与 0x0000000000000000。1 / -0 是 -Infinity,1 / 0 是 Infinity,这个区别在数值计算里需要保留。
  • NaN。它不是一个数,没有十进制字面量,而且还有载荷位:位模式不同,比较起来却全都是 NaN,只有保留原始位模式才能区分。
  • 正负无穷。同样没有字面量,1e999 这种写法在解析阶段就变成了无穷,跟“原样存下来”是两回事。
  • 十进制往返。文本解析再格式化,可能改动最后一位;位模式不会。

所以 NIR 干脆存 64 位,nir.BitsOf 和 nir.Float64 是这两个方向唯一的转换点。测试里专门钉死了负零、带载荷的 NaN、正负无穷和最小非零数,要求它们存一次、读回来还是同一个位模式。这类 bug 最阴险的地方在于不会让程序崩,只会让结果在某个边界上悄悄偏一格,等你发现的时候早就不知道是哪一步引入的了。

存位模式还有一个当时没想到的副作用:编译期不能偷懒。Go 的 go/constant 表示不了 NaN 和无穷,还会把负零归一成正零。如果所有数字都走常量字面量,print(-0) 会安静地变成 print(0),print(NaN) 干脆编不过。最后的办法是分两路:普通有限值仍然用字面量,负零、NaN 和无穷改走一个按位构造的支持函数,由 math.Float64frombits 在运行时还原。绕了一圈,还是回到了“别让工具替你做它做不到的决定”这句话上。

谁在读这份文件

build-ir 是驱动,按顺序做四件事:

  1. 读入 NIR,解码并检查格式与语义。坏输入在这一步就停下,不会启动编译器。
  2. 把字节写成一份快照,放在 .work/build/snapshots/ 下,文件名是内容哈希的前 16 位。编译器读快照,不读用户手里的原文件。
  3. 找到三样工具:隔离 SDK 的 go、修改版 compile、以及一个工具包装程序。
  4. 让产物先写到目标目录的临时文件,成功后才改名发布。

快照和临时文件都是为失败准备的:构建期间你去改源文件,不影响这一次的结果;构建失败时,目标位置原来那个能跑的产物还在原地,不会变成半截东西。

工具这一层有四个角色,第一次看很容易混:

Text
bin/tynel            驱动:解析参数、准备快照、决定退出状态
bridge/go/toolexec   包装程序:只转发,自己不编译任何东西
pkg/tool/.../compile 实际编译器:修改版,读 NIR 造节点并生成目标文件
pkg/tool/.../link    链接器:标准 Go 链接器,把目标文件连成可执行文件

注意驱动调用的是 go build,不是编译器本身。go build 负责算依赖、排序、传参数;加 -toolexec 之后,它把每次工具调用先交给包装程序,并把真正的工具路径作为第一个参数传进去。包装程序认出 compile 就换成修改版,其余(包括 link)原样转交。说白了,go build 是工头,包装程序是门卫,真正干活的是 compile 和 link。

没写过 Go 工具链的话,可以把 -toolexec 理解成一个进程级拦截器:go build 不关心工具是谁,只负责按依赖顺序调用;拦截器拿到“真实工具路径 + 原始参数”,有机会把其中某一个换掉。想知道它到底调了什么,go build -x 会把每一条命令行都打出来。

这个机制有三个后果,每一个我都是踩到之后才记住的:

  • 每个工具都会被包装。compile 和 link 都会经过这里,有汇编时 asm 也会;所以默认分支必须原样转交,不能只处理 compile 就结束。
  • 不需要复制工具链。替换发生在构建期,$GOROOT 下的文件一个都不改;去掉 -toolexec 就回到标准编译器。这比复制一份 270 MB 的 SDK 再替换 pkg/tool 里的二进制轻得多,也不会破坏之前校验过的副本。
  • 判定入口包不看导入路径。注入要回答的是“这个包是不是程序入口”,所以 bridge 看的是 types.LocalPkg.Name == "main",而不是 TOOLEXEC_IMPORTPATH。包名是编译器已经知道的事实,导入路径则要每个调用方自己维护一张表。

除此之外还有个更阴的坑:-toolexec 不参与 Go 的缓存键。我给空函数体补上隐式 return、重新编了 bridge,再跑空入口用例,报的还是一模一样的旧错误。那一瞬间我怀疑自己是不是忘了保存,检查了三遍文件时间戳才反应过来——go build 命中了旧对象,它压根没重新编译。最后的权宜之计是把 NIR 内容和编译器二进制的 sha256 前缀都塞进 build tag(tynelapp tynel_<nir8>_<compile8>),输入变了、bridge 变了,缓存键跟着变。这不优雅,但管用,正式的缓存策略留给后面的阶段。

固定支持包:先造一个包,再往里填函数

编译的对象不是用户写的 Go 文件,而是一个对所有输入都一样的支持包:

Go
package main

func tynelNumberFromBits(bits uint64) float64 { return math.Float64frombits(bits) }
func tynelPrintFloat64(v float64)            { fmt.Fprintln(os.Stdout, v) }

这个文件短得有点可疑,但它把两件事定死了:包名是 main,所以注入进来的用户函数和它同属一个包,符号前缀是 main.;print 语句有地方可去,tynelPrintFloat64 负责把 binary64 写到标准输出。

它不提供 main 函数,这一点是故意的。main.main 要由描述里的入口函数生成,如果支持文件自己写了 main,bridge 会因为符号已存在而拒绝——等于用一条报错把“谁负责入口”这件事钉死。

输出格式在这里也能看出阶段的边界。fmt.Fprintln 打印浮点数用的是 Go 的默认格式,无穷写作 +Inf、-Inf,而 JavaScript 那边是 Infinity、-Infinity。现在无所谓,反正还没有真实前端;等要做差分测试的时候,这套格式得整个换掉。

bridge:在内存里造节点

这是第一阶段的核心,也是最容易被误解的地方:bridge 不生成 Go 源码。

生成 add.go 再编译,当然也能得到同样的机器码,不少同类项目就是这么干的。但那样用户算法要经过一遍文本再解析:类型、求值顺序、错误位置,都得在文本里重新表达一遍,而文本本身又会引入新的歧义——运算符优先级、字面量格式、标识符转义,每一样都得在生成器里再处理一次。我第一次认真考虑这件事,是在想“如果用户函数名叫 func,我该怎么转义”的时候。

所以 bridge 直接在编译器内存里构造 Go 的高层节点。描述里的类型和顺序就是编译器的输入,中间没有第二套语法。

注入点在 gc.Main 里,位置在内联和包装器生成之后、逃逸分析之前。这个位置不是设计出来的,是被两个硬约束夹出来的:

  • 不能更早。内联阶段会为每个函数查找 unified IR 的函数体序列化位置(bodyReader)。新建的函数没有这份数据,早注入会触发 must have bodyReader 的内部断言。
  • 不能更晚。之后的逃逸分析、walk 和 SSA 都按 typecheck.Target.Funcs 遍历,晚了就没人处理新函数。

构造过程分三步。先建签名:ir.NewFunc 加 DeclareParams,参数类型是 float64。再建函数体,每种 NIR 结构对应一种节点:

描述里的结构内存里的节点
numberir.NewBasicLit,值是 constant.MakeFloat64
param参数自己的 *ir.Name
binaryir.NewBinaryExpr,+ 对应 OADD
callir.NewCallExpr,操作码 OCALL
returnir.NewReturnStmt
print调用支持函数的 OCALL,作为语句放进函数体

最后交给 typecheck.Stmts 做类型检查,把函数加进编译目标。从这里开始,它和编译器自己解析出来的函数没有任何区别:同一套逃逸分析、同一套 SSA、同一套指令选择。这也是整个方案最省心的地方——我只需要保证“进去的节点是对的”,后面那几万行代码一行都不用碰。

踩得最深的一个坑跟节点无关,跟“空”有关。编译器把“没有函数体”当成外部声明,len(fn.Body) == 0 时只生成符号、不生成代码。一份空的 void 入口如果原样传进去,链接阶段会报 function main is undeclared in the main package。这个报错发生在链接期,离真正的原因隔了整整一个编译流程,我对着它看了半天才想到“函数体是空的”。修法很朴素:给空的 void 函数补一条空 return。函数正常结束本来就是返回,补上之后它才是可执行函数,而不是一个待链接的外部符号。

从节点到指令

节点建好之后,剩下的全部是 Go 编译器原本的流程:

Text
高层节点 → walk(展开成更简单的形式)→ SSA(静态单赋值)
        → 指令选择 → 寄存器分配 → 目标文件 → 链接

walk 把复合结构拆成基本操作,SSA 把函数变成一张数据流图,指令选择为每个操作挑一条目标指令,寄存器分配决定哪个值放哪个寄存器,最后写出目标文件,由链接器拼成可执行文件。Tynel 没有第二套后端,这些步骤一行都没有自己实现,这也是当初选 Go 的唯一理由,现在它兑现了。

如果没接触过编译器后端:SSA 可以理解成“每个值只被赋值一次”的中间形式。x = a + b; x = x * 2 会被拆成两个临时值 t1 = a + b; t2 = t1 * 2。好处是每个值的来源唯一,优化和指令选择都能按图遍历,不必考虑“这个变量在第几行被谁改过”。

回到反汇编,就能对上每一步的结果:

Shell
# 用隔离 SDK 里的 objdump,-s 按符号名过滤
GO=.work/toolchain/go1.27.3-sdk-darwin-arm64/bin/go
"$GO" tool objdump -s '^main\.add$' out/add
Text
TEXT main.add(SB) <autogenerated>
  <autogenerated>:1  FADDD F1, F0, F0
  <autogenerated>:1  RET

FADDD 是 AArch64 的浮点加法:两个 double 相加,结果放回 F0。按 AArch64 的调用约定,前两个浮点参数分别在 F0 和 F1 里,返回值也在 F0,所以 F1, F0, F0 读作“F0 = F0 + F1”,紧接着 RET 把结果带回去。整个函数只有一条加法和一条返回。如果这条加法是在编译期算完的,这里根本不会有 FADDD,只会剩下一个常量——这也是我为什么坚持看反汇编,而不是只看输出对不对。

main.main 稍长一些,做的是准备实参、调用、打印(节选):

Text
FMOVD 3280(R27), F0     把第一个常量装进 F0
FMOVD 3272(R27), F1     把第二个常量装进 F1
CALL main.add(SB)       调用加法
CALL main.tynelPrintFloat64(SB)

两条 FMOVD 从只读数据段取常量,CALL 的目标分别是描述里的 add 和支持包里的输出函数。没有解释器,也没有运行时按名字查找函数,CALL main.add(SB) 就是一条直跳。反汇编里的 <autogenerated>:1 是位置信息:节点用 base.AutogeneratedPos 定位,因为 NIR 的 pos 还没映射到 Go 的 src.XPos,这一步留给后面的阶段。所以现在如果出错,错误位置会统一指向 <autogenerated>,看不出是哪一行,这笔债后面得还。

出错的时候

一个编译器在正常输入上跑通说明不了太多,真正区分“编译器”和“能跑通一个例子的脚本”的,是错误输入。

Shell
# 调用一个不存在的函数,预期在编译前被拒绝
./bin/tynel build-ir bridge/go/testdata/bad-call.nir.json -o out/bad
Text
tynel: 源码错误 (nir.unknown-function): 调用了不存在的函数 "missing"

退出状态是 1,out/bad 没有被创建。这个错误来自驱动里的验证器,发生在编译器启动之前,也就是说,坏描述根本没有机会变成一张参数数量对不上的图。

为什么非得在这里停?以调用为例:add 需要两个实参,NIR 里只给了一个。如果放到后面才发现,结局无非两种,后端在生成代码时崩溃,或者生成一段在运行时打印“编译器尚未实现”的分支。前者把内部结构不一致暴露成崩溃,后者把编译期错误拖到运行期,还伪装成能力不足,两种都让“源码错误”和“编译器缺陷”混成一团,而这恰恰是调试时最不想遇到的情况。

所以错误被分成几类,退出状态各不相同:

状态类别典型原因
0成功命令完成
1源码错误语法、类型、未定义名称
2用法错误未知命令、缺少参数、多余的参数
3能力限制写法合法,但当前原生后端不支持
4环境缺失工具不可用、编译器过期、输出目录不可写
5内部故障编译器自身或它调用的外部进程失败

分开的理由很朴素:不能让“工具没装好”看起来像“代码写错了”。外部进程以非零状态退出时,按内部故障上报,并保留它的 stderr 作为原因。这一条救过我很多次,go build 的真实报错比我自己编的任何提示都有用。

失败时旧产物不会被覆盖,但也不能把上一次的结果当成本次结果。构建记录里的输入哈希就是用来区分这件事的:哈希没变,说明你手上这个产物还是上一次的。这个设计在演示的时候特别有用,因为最容易发生的尴尬是——改完输入跑了一遍,失败了,然后顺手运行旧产物,看到输出(其实没变),以为成功了。

这一阶段没做到的

现在能做到:手写一份 NIR,编译成原生程序,得到正确的数值结果,并用反汇编核对加法确实执行了。听着不多,但这条链路上每一环都是真的,没有哪一步是“先假装能跑,以后再补”。

还不能做到的,列清楚比较好:

  • 读 TypeScript。描述是手写的,tynel build 仍然是未知命令。
  • 分支、循环和局部变量。描述里只有直筒的 return 与 print。
  • 完整的 console.log 协议。输出沿用 Go 的默认浮点格式,无穷写作 +Inf。
  • 跨平台。只在 darwin/arm64 上验证过,Windows 和其他平台是后续工作。
  • 字节级可重复构建。现在只比较输出文本,没有断言两次构建的产物完全相同。

这份清单不是免责声明,它更像下一阶段的待办事项,只不过每一条后面都跟着一个明确的验收方式。

下一步

第二阶段接上真实源码:用 tsgo(TypeScript 官方用 Go 重写的原生编译器)解析和检查 TypeScript,把 add.ts 降低成第一阶段手写的那份 NIR。

这里有个让我挺安心的性质:本章的产物链路一行都不用改。前端换了,NIR 没换,后端就还是那个后端,同一份 build-ir、同一个修改版 compile、同一个链接器。当初坚持“先做后端”受的那些罪,在这里一次性还回来了。

再往后,阶段三加分支和短路求值,阶段四加局部变量和循环,阶段五加模块,阶段六把它交付成一个能用的命令。每篇会照着同样的路子写:先给一个能看见的结果,再解释它为什么成立,最后老实交代这一步还差什么。

如果这篇让你想起自己项目里那两份永远对不齐的类型定义,那它的目的就达到了。