Tynel(二):从一份 TypeScript 源码到原生二进制
兑现承诺:把手写的 JSON 拿掉
在上一篇的结尾,我们用一份手工捏出来的 JSON 中间表示(NIR),通过修改版 Go 编译器和自包含的运行时支持包,成功编译出了能直接在操作系统上执行的机器码,并在反汇编里看到了扎扎实实的浮点加法指令 FADDD。
当时我留下了一句话:
“前端换了,NIR 没换,后端就还是那个后端,同一个
build-ir、同一个修改版compile、同一个链接器。当初坚持‘先做后端’受的那些罪,在这里一次性还回来了。”
这一篇,就是来兑现这个承诺的。
我们要把第一阶段那份手写的 examples/ir/add.nir.json 彻底收起来,换成一份货真价实的 TypeScript 源码 examples/add.ts。命令从开发调试用的 tynel build-ir,变成真正像个编译器入口的 tynel build。
而最让人踏实的地方在于:第一阶段辛辛苦苦调通的代码生成、Go bridge、符号注入与链接链路,这一篇一行代码都不用改。前端的职责只有一个——把一段合法的 TypeScript 源码,精准、确定、无副作用地翻译成上一篇那份 NIR 结构。
但“读源码”这三个字说起来轻巧,真动手做起来,哪怕只是编译一个最简单的加法,也会立刻撞上一连串只有写编译器才会碰到的真问题:
- 源文件在什么时候被固定?如果编译进行到一半磁盘上的文件被改了怎么办?报错信息里的行列号,遇到中文注释和换行时是怎么算出来的?
- 前端交给后端的到底是什么?为什么绝不能直接把 AST(抽象语法树)传过去?当两个函数里都有一个叫
a的参数时,编译器靠什么区分谁是谁? - 为什么 TypeScript 类型检查通过了,代码依然可能无法编译?
- 从前端提取出来的“事实”降级到 NIR,丢掉了什么,又保住了什么?
- 为什么控制台打印格式必须是一份严格的契约?以及——怎么向自己证明,前端自动生成的程序和上一篇手写的程序真的丝毫不差?
这一篇,我们沿着一段代码从磁盘字符变成原生二进制的全过程,把这五个问题逐个拆开。
先看结果:一条命令出二进制
照例,先看可运行、可验证的结果。
这一阶段的贯穿示例是 examples/add.ts,总共六行代码:
// 加法:把两个数相加并打印结果。
function add(a: number, b: number): number {
return a + b;
}
console.log(add(40, 2));它包含了这一阶段支持的核心要素:一个顶层具名函数、两个 number 类型的必填参数、一条二元加法返回语句,以及模块顶层的一次函数调用和一次单参数控制台打印。
在项目根目录下构建编译器入口,并编译这个源文件:
# 构建 bin/tynel 命令行工具(需要 01 阶段准备好的隔离 Go 工具链)
./tools/build
# 直接从 TypeScript 源码编译出原生二进制程序
./bin/tynel build examples/add.ts -o out/add
# 运行刚生成的原生程序,预期输出 42
./out/add终端里的输出如下:
$ ./bin/tynel build examples/add.ts -o out/add
已生成 out/add(2429986 字节)
源码 examples/add.ts(12269d671d8c)
输入 4b599cc831a0
编译器 1ac6e26642e2
构建记录 out/add.build.json
$ ./out/add
42注意这三行哈希:第一行是 TypeScript 源码的哈希,第二行是前端提取并降级出来的 NIR 输入哈希,第三行是参与构建的修改版 Go 编译器哈希。它们连同最终产物自身的哈希,被原原本本地记录在旁边的 out/add.build.json 里。
产物大小依然是 2.4 MB 左右——那里面绝大部分依然是为我们提供垃圾回收、栈增长和调度能力的 Go 运行时。
同样地,验证编译器没有“把结果写死”的最直接办法,就是改动输入:
# 复制一份源码,把第一个实参从 40 改成 41
cp examples/add.ts out/add-41.ts
perl -pi -e 's/add\(40, 2\)/add(41, 2)/' out/add-41.ts
# 重新编译并执行,预期输出 43
./bin/tynel build out/add-41.ts -o out/add-41
./out/add-41$ ./out/add-41
43源码哈希变了,NIR 输入哈希跟着变了,最终输出从 42 变成了 43。
为了日常开发顺手,命令行里还加了一个 run 子命令,把编译和运行合成一步,产物固定写入 .work/run/app:
# 编译并立即运行本次产物,预期输出 42
./bin/tynel run examples/add.ts42从外部看,使用者只需要准备一份 .ts 文件,敲下 tynel build,就能拿到一个独立的原生可执行文件。现在,我们把这台机器的外壳掀开,看看里面的齿轮是怎么咬合的。
第一关:源文件只读一次,与位置计算的深坑
编译器的第一步,不是直接去调用词法分析器,而是读盘。
但这里有一条非常严格的架构铁律:整个编译生命周期内,源文件只允许从磁盘读取一次。
主命令行接收到文件路径后,宿主环境只做一次读取:把它读成一份不可变的字节快照(Snapshot),计算出它的 SHA-256 哈希值与字节数。在这之后,后续无论是类型检查、语法提取、错误诊断还是构建记录登记,全部基于内存里的这份快照,严禁任何组件再次从磁盘重读文件。
这条规则在单人写个小 demo 时看似无关紧要,但在真实的工程场景里却至关重要。大型项目构建通常需要几百毫秒甚至数秒,如果开发者在编辑器里按下了 Cmd + S 存了一个半成品,而编译器的某个后置流程又重新去读了一次磁盘,就会造成“类型检查看的是旧文件、降级处理看的是新文件”的灾难性撕裂。
有了内存快照,无论外界怎么变,本次构建看到的永远是一致的切片。想知道“手上这个二进制是用哪版源码编出来的”,核对记录里的 source.sha256 永远不会撒谎。
中文字符与 UTF-8 字节偏移换算
快照带来的另一个硬核任务是:精准计算报错的行列位置。
TypeScript 前端编译器(上游检查器)给出的所有诊断信息,都是基于 UTF-8 的字节偏移量(Byte Offset)。但人看的是从 1 开始的“第几行第几列”:
$ ./bin/tynel check tests/source/testdata/errors/type.ts
…/tests/source/testdata/errors/type.ts:2:7: error TS2322: Type 'string' is not assignable to type 'number'.如何把一个裸的数字(如 126)换算成 2:7?
如果直接按字节数数,你会立刻掉进大坑里:中文在 UTF-8 里占 3 个字节,emoji 表情占 4 个字节。假设源代码第 1 行写了一句中文注释:
// 加法:把两个数相加并打印结果。
function add(a: number, b: number): number { ... }如果拿字节数去当列号,第 2 行的列号就会被这句中文注释平白无故往后推十几个位置,错误光标直接指到了爪哇国。
所以,快照的 Snapshot.Position 必须严格按照 Unicode 字符(Rune)进行换算:行号根据换行符切分,列号逐字符计算。在测试套件里,我们专门写了 TestPositionAfterChinesePrefix 和 TestPositionCountsRunesNotBytes 来锁定这个行为,确保带中文、带特殊字符的代码在报错时,列号丝毫不偏。
踩坑实录:node.Pos() 带来的“幽灵空白”
哪怕有了字符级换算,在复查阶段依然抓出了一个极其隐蔽的位置 Bug。
在最初把语法骨架接入事实时,节点的起始位置直接调用了上游 AST 的 node.Pos()。但许多人不了解的是,在 TypeScript 官方 AST 实现中,node.Pos() 返回的是所谓“完整起点(Full Start)”——它包含了该节点前面的所有空白字符和注释!
这导致了一个令人啼笑皆非的现象:在一段包含条件分支的测试文件里,if 语句报能力限制错误时,错误高亮居然落在了上一行大括号 { 后面的换行符上(2:26);如果一个函数头顶写了注释,函数声明的范围会硬生生把头顶那段注释也算进自己的体积里。
排查出这个细节后,我们把位置提取全部改成“获取第一个有效记号的起点(First Token Position)”。同一个错误瞬间从荒谬的 2:26 归位到了正中靶心的 3:3,也就是 if 关键字真正出现的地方。
编译器如果连报错位置都指不准,开发者就得去错误的代码上下文里找原因,那种挫败感是最消耗心智的。
第二关:tsgo 交出了什么?是“事实”,不是语法树
有了源码快照,接下来该请出前端了。
Tynel 的前端采用了 tsgo——这是 TypeScript 官方尝试用 Go 语言重写 TypeScript 编译器的原生项目。既然底层能跑在 Go 原生进程里,我们自然免去了启动臃肿的 Node 进程的开销。
但前端在这里并没有作为一个 Go 库直接 import 进编译器主模块。相反,它是一个独立的子进程(Subprocess)。
原因非常现实且工程化:tsgo 上游的所有核心代码包都在 Go 的 internal/ 目录下,根据 Go 语言规范,外部模块根本无法直接引用它们;更重要的是,如果编译器主程序直接绑定上游仓库,任何拉取 Tynel 源码的人都必须预先配齐 tsgo 的源码与特定依赖,否则连本地跑一次 go test ./... 都会直接挂掉。
因此,前端宿主 bridge/tsgo/consumer 被独立拆成一个单独模块,主命令行通过标准输入输出传输 JSON 与它对话。
宿主交回来的东西,不是抽象语法树(AST),而是一份经过清洗的数据,我们称之为“事实(Facts)”。
+-------------------+ spawn +----------------------+
| | -------------> | bridge/tsgo/consumer |
| tynel CLI (主进) | JSON (stdin) | (tsgo 独立宿主进程) |
| | <------------- +----------------------+
+-------------------+ Facts (stdout)为什么坚决不传 AST?
一棵 AST 身上背负了太多的包袱:每个节点都充斥着宿主内存里的指针、循环引用、复杂的内部状态标志,不仅在跨进程序列化时极其臃肿缓慢,而且一旦宿主会话关闭,这些指针全部沦为废纸。
更根本的原因是:AST 描述的是“代码写成了什么样”(语法结构),而编译器降低阶段关心的是“代码到底声明了什么、谁引用了谁”(语义事实)。
事实是一份纯粹的数据结构:
| 部分 | 内容 | 解决什么问题 |
|---|---|---|
declarations | 顶层具名函数、参数、顶层 const,每条带一个声明 ID | 记录程序里诞生了哪些具名实体 |
references | 表达式中每次名字的出现,显式附带其指向的声明 ID | 消除变量同名与遮蔽混淆 |
calls | 调用位置、目标函数 ID、实参类型列表、返回值类型 | 核对调用合法性与参数个数 |
expressions | 数字字面量、二元运算、标识符,各带精简类型摘要 | 告诉后端类型类别与数值位模式 |
body | 裁剪后的极简函数体与初始化表达式语法骨架 | 剥离语法糖后的控制流与求值关系 |
核心灵魂:声明 ID 如何解决两个 a 的困惑
在事实层,最关键的设计是声明 ID(Declaration ID)。
看下面这段代码:
const answer: number = add(40, 2);
function add(a: number, b: number): number {
return a + b;
}
// 这里的 a,和 add 里的 a 毫无关系
function scale(a: number): number {
return a * 2;
}
const doubled: number = scale(answer);这段代码里有三个对编译器的考验:
add里有一个参数叫a,scale里也有一个参数叫a。add(40, 2)出现在函数声明之前(TypeScript 允许函数提升)。scale紧接着被调用。
如果编译器只按“名字”去匹配,当分析到 return a * 2 时,它凭什么知道这个 a 应该读哪个局部槽位?如果只看“源码位置”,return a + b 里的 a 是“第 167 个字节”,这只是一次出现的坐标,根本无法表达它与形参的从属关系。
而在事实数据里,所有具名实体从诞生起就被分配了一个全局自增的整数 ID:
{ "id": 2, "kind": "function", "name": "add", "parameters": [3, 4] }
{ "id": 3, "kind": "parameter", "name": "a", "type": {"kind":"number"} }
{ "id": 4, "kind": "parameter", "name": "b", "type": {"kind":"number"} }
{ "id": 5, "kind": "function", "name": "scale", "parameters": [6] }
{ "id": 6, "kind": "parameter", "name": "a", "type": {"kind":"number"} }
{ "name": "a", "start": 167, "target": 3 }
{ "name": "a", "start": 294, "target": 6 }在 add 函数体内部,a 的引用的 target 明确写着 3;在 scale 内部,引用的 target 明确写着 6。
名字相同、源码位置不同、声明 ID 不同。三者同时成立,后端的降级器闭着眼睛都能知道当前这个变量该去哪一个寄存器或栈帧偏移里拿值。
至于前向调用,前端的符号分析早已在会话里把提升处理完毕,第一行的 add(40, 2) 调用的 callee 直接指向 2,后端根本不需要处理语法提升的逻辑。
类型摘要:只要类别,不要字符串
事实里的类型字段同样经过了深度过滤。它不保留给人类看的类型签名字符串(比如 "number" 或者 "42"),而是由检查器标志位提炼出来的纯枚举类别:number、void、boolean、string 等。
为什么?因为像 const answer = 42,在 TypeScript 类型系统内部它的类型是字面量类型 "42",但它在原生硬件眼里的物理事实就是 number。如果后端靠解析类型字符串来生成代码,光是处理字面量类型、联合类型和类型别名就会让后端彻底陷入泥潭。
事实也可以通过命令直接 dump 出来看:
# 输出前端提取到的声明、引用、调用与语法骨架
./bin/tynel dump facts examples/add.ts这一步完全不需要后端,也不需要知道机器码是什么。它回答且仅回答一个问题:前端有没有理解错代码。把前端的语义理解与后端的代码生成隔离开,出 Bug 时排查范围立刻缩小了一半。
第三关:类型检查通过,不等于能编译
在很多人的潜意识里,只要 tsc 不报错,代码就应该是一路通途。
但在面向原生的编译器里,现实要残酷得多。看这段代码:
function identity(value: any): any {
return value;
}这段代码在 TypeScript 里合法得无可挑剔,tsc 连一个警告都不会发。
但 Tynel 原生编译器当前能编译它吗?绝对不能。
因为 any 在底层硬件上没有物理实体。原生的 CPU 寄存器和内存布局需要明确知道一个值是 64 位的浮点数、一个指针,还是一个结构体。支持 any 意味着必须提前引入一套带类型标签的装箱系统(NaN-boxing 或 Tagged Union)以及一套运行时对象模型。在现阶段,我们根本还没有做这套东西。
所以,Tynel 必须明确区分两类性质完全不同的错误:
| 错误类别 | 谁负责检查 | 退出状态码 | 含义与处置 |
|---|---|---|---|
| 源码错误 | 前端类型检查器 | 1 | 语义有错(如把 string 赋给 number),需要开发者改代码 |
| 能力限制 | 编译器能力白名单 | 3 | 代码合法,但原生后端尚不支持,需要等编译器实现或换写法 |
把这两者混在一起统一报个“编译失败”,是极其偷懒且不负责任的做法。
在 Tynel 里,如果写了当前不支持的语法,会得到非常清晰的出口:
# 取余运算符不在本阶段子集内
./bin/tynel check tests/source/testdata/errors/modulo.ts
# 输出:…/modulo.ts:2:13: 能力限制 cap.bad-operator: 运算符 "%" 不在本阶段支持的 + - * / 之内
# 退出码:3
# 条件控制流不在本阶段子集内
./bin/tynel check tests/source/testdata/errors/control.ts
# 输出:…/control.ts:3:3: 能力限制 cap.unsupported-syntax: 控制流 IfStatement 不在本阶段子集内
# 退出码:3
# any 类型缺乏原生硬件表示
./bin/tynel check tests/source/testdata/errors/any.ts
# 输出:…/any.ts:1:1: 能力限制 cap.any: 函数 "identity" 的返回类型是 any 或 unknown;本阶段只支持 number 和 void
# 退出码:3绝不做“自作聪明”的可达性跳过
在做能力白名单(compiler/capability)时,有一个很重要的架构抉择:如果一个函数写了不支持的语法,但这个函数从来没有被调用过,我们要不要报错?
function unused(): number {
return 10 % 3; // 不支持的取余操作
}
console.log(42);直觉上,既然 unused 根本没进执行流,跳过去不就得了?
坚决不行。
因为“当前没有被调用”只是这版代码的一个偶发现象,而不是这段代码本身的合法性保证。如果今天因为没调用而悄悄放行,明天开发者顺手加了一行调用,编译突然在更底层的代码生成阶段以一种莫名其妙的内部崩溃(如 panic)炸开,开发者的第一反应必然是“我刚才就加了行调用,编译器怎么崩了”。
不仅如此,一次完整的构建应该把源码中所有超出现阶段能力边界的问题全部报出来,而不是让开发者改完一个,再跑一遍发现下一个。因此,能力检查会遍历事实里的每一个函数体、每一个常量表达式,无论它是否可达。
同时,console.log 也被死死盯住:它只接受一个参数,且参数类型必须是 number。更进一步,如果开发者定义了一个叫 console 的局部变量(如 function log(console: number)),白名单能精准识别出内置符号被遮蔽,报出 cap.shadowed-console,绝不把一个普通的局部变量调用误当成运行时打印。
第四关:事实变成 NIR,取舍的艺术
通过了类型检查和能力白名单,代码终于来到了降级阶段(Lowering)。
降级的输入极为纯粹:只有第一步生成的源码快照,和第二步提取的事实。降级器不需要、也不被允许去重新查询检查器,因为前端宿主会话早就关闭了。这个设计保证了降级阶段可以被 100% 离线重放。
我们再把 examples/add.ts 拿出来,看它经历的“三态变形”:
// 1. 源码形态(TypeScript)
function add(a: number, b: number): number {
return a + b;
}
console.log(add(40, 2));// 2. 事实形态(Facts,节选关键结构)
{
"declarations": [
{ "id": 1, "kind": "function", "name": "add", "result": {"kind":"number"}, "parameters": [2, 3],
"body": { "kind": "Block", "children": [
{ "kind": "ReturnStatement", "children": [
{ "kind": "BinaryExpression", "op": "+", "children": [
{ "kind": "Identifier", "name": "a", "target": 2 },
{ "kind": "Identifier", "name": "b", "target": 3 }
]}
]}
]}
}
]
}// 3. 中间表示形态(NIR,与上一篇手写的一模一样)
{
"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" }
}
}
]
}在这个转换过程中,有些东西被主动丢弃了,因为后端不需要;有些东西被坚决保留了,因为那是计算语义的基石。
丢掉了什么?
- 声明 ID 消失了:事实里的 ID 2、ID 3 在 NIR 里全部还原成了简单的名字
"a"和"b"。为什么?因为在同一个函数内部,形参和局部变量的名字是天然唯一的;跨函数的全局识别由唯一的函数名承担。ID 的历史使命(解决歧义)已经在前端完成,到了函数内部就不需要再带着这层包袱。 - 顶层语句收敛成了
main入口函数:NIR 是纯函数的中间表示,根本没有所谓“顶层语句”这种概念。降级器会自动构造一个无参数、返回void的main函数,把顶层const转成局部let,把顶层console.log转成print。如果开发者自己写了一个叫main的函数,会立刻收到lower.entry-conflict的错误。
保留了什么?
-
浮点数位模式原样传递:
40在事实里就已经以 binary64 的十六进制位模式出现(0x4044000000000000)。降级器不做任何十进制解析,更不做自作聪明的常量折叠,原封不动地把它放进 NIR 的bits字段。 -
严格的表达式求值顺序:在 JavaScript 中,实参求值必须严格遵守从左到右的顺序。例如
console.log(add(step1(), step2())),必须先执行step1,再执行step2,最后执行add。降级器完好地保留了整个表达式树的结构,后端 Go bridge 生成 Go AST 时,实参同样按照从左到右的严格顺序下发。 -
区分三类“单行表达式”: 在源码里,下面三句长得很像,但降级到 NIR 时是完全不同的三件事:
源码写法 降级为 NIR 实际行为 announce(7);discard调用执行函数以保留其副作用,但返回值被丢弃 answer;discard局部值计算该值,然后悄无声息地丢弃,绝对不会打到控制台 console.log(answer);print执行计算,并向标准输出写入一行文本 discard绝不是“空操作”。对于可能产生副作用的调用,求值必须发生;但对于一条独立的表达式语句,它的语义就是“值没人要”,绝对不能顺手把结果输出出来。
第五关:打印输出是一份严谨契约
在这一阶段,console.log 只能打印一个 number。但正因为范围小,输出格式更必须被当作契约钉死。
否则,回归测试里“输出对不对”就无法做到字节级的断言。
| 浮点数值类别 | Tynel 输出规定 | 说明与格式化来源 |
|---|---|---|
| 有限数值 | 最短可回读十进制 | 由 strconv.FormatFloat(v, 'g', -1, 64) 生成 |
| 负零 | -0 | 区分符号位,不输出 0 |
| 正无穷 | +Inf | 原生浮点拼写 |
| 负无穷 | -Inf | 原生浮点拼写 |
| 非数 | NaN | 原生浮点拼写 |
常规有限数值采用“最短可回读十进制写法(Shortest Round-trip)”,比如 0.1 + 0.2 会精确输出 0.30000000000000004,非常大的数如 1e21 输出 1e+21。
而特殊值(负零、NaN、无穷大)则直接由支持包通过固定的位模式进行独立判断,不走通用的格式化逻辑。
[!NOTE] 关于
+Inf与Infinity的坦诚说明 细心的读者会发现,JavaScript 规范里正无穷叫Infinity,而 Go 运行时输出的是+Inf。 我们并没有在支持包里强行把它替换成Infinity。在测试集tests/source/reference.json中,这类用例被专门标记为spelling(拼写差异):只要数值逻辑一致,仅允许这类既定的特殊拼写差异存在。 编译器的早期阶段,切忌为了迎合外部环境而胡乱抹平差异。记录真实差异,比伪造一个看似兼容的表面要诚实得多。
终极核对:存下 NIR 回放,见证逐字节相同的产物
现在,整个链条已经闭环。
但我们怎么向自己证明:通过真实 TypeScript 前端解析、能力过滤、降级生成的这套程序,和上一篇我们手写的 NIR 程序,在底层走的是完全同一条路?
Tynel 提供了一个无可辩驳的验证方式——中间表示回放(NIR Replay):
# 1. 用 dump 命令把从 examples/add.ts 降级出来的 NIR 保存为文件
./bin/tynel dump nir examples/add.ts > out/add.nir.json
# 2. 完全跳过 TypeScript 前端,直接用上一篇的 build-ir 编译这份导出的 NIR
./bin/tynel build-ir out/add.nir.json -o out/add-replay
# 3. 运行回放产物,确认输出依然是 42
./out/add-replay
# 4. 终极验证:对比直接 build 出的二进制和回放 build 出的二进制的 SHA-256
shasum -a 256 out/add out/add-replay看一下终端里的真实对比:
$ ./out/add-replay
42
$ shasum -a 256 out/add out/add-replay
1217c68216f64e5bc771538f457fd548da969dc23d31df1c63bada45c695f542 out/add
1217c68216f64e5bc771538f457fd548da969dc23d31df1c63bada45c695f542 out/add-replay1217c682...,哈希完全一致,逐字节完全相同!
这个哈希相同不是侥幸。因为 tynel build 内部在完成降级后,也是先把 NIR 快照序列化,再喂给后端的;build-ir 只是直接从外部文件读取了同一份快照。
从源码到 NIR,再到二进制,三者形成了一条坚不可摧的哈希证据链:
源码哈希 (12269d67...)
↓ (tsgo 提取事实 + 白名单检查 + 降级)
NIR 输入哈希 (4b599cc8...)
↓ (修改版 Go 编译器 compile + 符号链接)
产物二进制哈希 (1217c682...)这也意味着:今后无论前端如何重构、如何增加新的语法糖,只要它降级出来的 NIR 哈希不变,后端的二进制行为就绝对不会发生任何漂移。
出错的时候:给错误一个精确的出口
一个成熟的编译器,它的价值有一半体现在“代码写错时能说清为什么错”。
在这一阶段,错误状态码被进一步严谨规范,互不混淆:
# 1. 源码类型错误 -> 退出码 1
$ ./bin/tynel check tests/source/testdata/errors/type.ts
…/type.ts:2:7: error TS2322: Type 'string' is not assignable to type 'number'.
$ echo $?
1
# 2. 超出当前能力限制 -> 退出码 3
$ ./bin/tynel check tests/source/testdata/errors/modulo.ts
…/modulo.ts:2:13: 能力限制 cap.bad-operator: 运算符 "%" 不在本阶段支持的 + - * / 之内
$ echo $?
3
# 3. 环境工具缺失或文件找不到 -> 退出码 4
$ ./bin/tynel check tests/source/testdata/errors/missing.ts
tynel: 环境缺失 (host.failed): 前端宿主执行失败(退出状态 4)
原因: tynel: 环境缺失 (source.io): 无法读取源文件 …/errors/missing.ts
原因: open …/errors/missing.ts: no such file or directory
$ echo $?
4至于编译器自身内部崩溃(Panic)或外部子进程非正常退出的内部故障,统一归入退出码 5。
这种分类不是洁癖:
- 看到
1,开发者知道该改业务代码逻辑; - 看到
3,开发者知道代码合法但 Tynel 还没做这个功能,该绕道换写法; - 看到
4,知道该去检查本地环境或者文件路径; - 看到
5,知道是编译器自己出了 Bug。
同时,构建失败绝不会删除或覆盖已经存在的旧产物。更不会把旧产物当成本次成功的产物——构建记录里的 input.sha256 永远是本次编译的唯一凭证。
这一阶段做到了什么,还差什么
到这里,第二阶段的目标全部达成。
现在我们能够做到:
- 接收一个真实的
.ts源文件作为输入; - 完成基于不可变快照的内存读取与字符级行列定位;
- 通过独立子进程驱动 tsgo,提取纯数据事实,依靠声明 ID 彻底解决同名符号绑定;
- 执行严格的能力白名单过滤,阻断不支持的语法;
- 将事实无损降级为 NIR,并复用第一阶段的后端直接产出原生程序;
- 保证
console.log的严格输出契约,并实现逐字节一致的 NIR 回放。
但同样要坦白交代的,是这一阶段我们依然还没做到的东西:
- 没有布尔值:不能定义
boolean变量,没有true和false; - 没有比较与分支:没有
<、>、===,没有if和else; - 没有循环与状态更新:没有局部可变的
let赋值,没有while; - 没有复合数据:没有字符串(除了顶层 log 的浮点格式化)、没有数组、没有对象;
- 没有多文件模块:一个文件就是一个独立宇宙,写
import会被直接拒绝。
整个语言目前依然是一个“只能一条路走到黑的纯算术计算器”。
下一步
接下来的一章,我们要给这个计算器注入逻辑选择的能力。
在第三阶段,我们将引入完整的 boolean 标量类型,实现严格比较运算符(<、<=、>、>=、===、!==)、分支结构(if / else if / else)、逻辑运算符的短路求值(&&、||),以及三元表达式(? :)。
我们要让程序不仅能算加法,还能根据运算结果自己决定走哪条路。
而在那一阶段,我们今天写好的这套真实源码编译链路,依然会稳固地站在那里,等待下一批语法的扩展。