scriptc 差分测试方法论:如何做到与 Node.js 逐字节输出一致
【免费下载链接】scriptcTypeScript-to-Native Compiler项目地址: https://gitcode.com/GitHub_Trending/sc/scriptc
scriptc 是一个 TypeScript 到原生的编译器(TypeScript-to-Native Compiler),它把 TypeScript/JavaScript 直接编译为可读 C、LLVM IR、汇编乃至原生可执行文件,且运行结果必须与 Node.js 完全一致。这套"与 Node 逐字节对齐"的承诺,靠的是一整套差分测试(Differential Testing)方法论:Node.js 本身就是唯一的裁判(oracle),编译器产物与 Node 直接跑的结果逐字节比对,任何一方出现偏差都会被立即捕获。
核心思想:没有黄金文件,Node 就是期望输出
传统测试常用"黄金文件"(golden file)保存预期输出,但黄金文件会随时间漂移——环境一变,预期就过期了。scriptc 的做法更激进:
Node IS the expected output, so tests can't drift.(Node 就是期望输出,所以测试永远不会漂移。)
每个语料(corpus)程序同时跑两遍:
- 在 Node 下直接运行,拿到 stdout、stderr 与退出码;
- 用 scriptc 编译为原生二进制再运行,拿到同样三样东西。
然后按字节(Buffer.equals)比较两边输出。没有快照、没有容差、没有"差不多就行"——stdout 永远必须一致,exit-0 的程序 stderr 也必须一致,退出码必须与程序头部声明的// @exit:指令吻合。核心实现在 differential.test.ts:
/* The oracle: every corpus program runs under Node AND as a scriptc-compiled * native binary; stdout AND stderr must match byte-for-byte and exit codes * must agree. No golden files — Node IS the expected output. */测试车道:同一套语料,四条并行验证
harness 的 README 描述了这套"车道"(lane)体系——同一个语料库会被不同方式反复验证:
| 车道 | 触发方式 | 验证什么 |
|---|---|---|
| 常规车道 | pnpm test | 全语料 stdout/stderr/退出码逐字节对齐 Node |
| 净化车道 | SCRIPTC_SAN=1 pnpm test | ASan + 运行时引用计数审计,整个语料变成内存安全测试 |
| LLVM 后端车道 | 内置差分 | C 后端与 LLVM 后端产物也须彼此逐字节一致 |
| Linux / Windows 车道 | 环境变量门控 | 交叉编译后,在真实目标系统里与本地 Node 再次逐字节比对 |
提交前必须双车道全绿:常规 + 净化,缺一不可。
双后端差分:不允许"悄悄降级"
llvm-differential.test.ts 实现了一个巧妙的设计——层级成员是自动发现的:每个语料程序都尝试走--backend=llvm编译。
- 如果 LLVM 后端"认领"了这个程序,那它必须与 C 后端、与 Node 三方输出完全一致;
- 如果程序超出 LLVM 层能力,编译器必须响亮地拒绝,产出且仅产出一条
SC3001诊断,指明第一个不支持的 IR 构造——绝不生成错误的代码,绝不静默回退。
每次运行结束会打印"认领计数 + 拒绝直方图",后者天然成为下一阶段的待办队列。这种"要么做对、要么明说做不到"的契约,是差分测试能长期可信的关键。
语料目录指令:用注释声明测试契约
语料程序用文件头部的两行注释(directive head)声明自己的特殊需求,例如// @exit: 1(声明非零退出码)、// @dynamic(嵌入 JS 引擎)、// @transform-types(Node 侧改用 transform 模式)。指令解析逻辑见 differential.test.ts 的 directiveHead。
这些指令让"不一致"变得显式且受控,而不是被静默忽略。比如未捕获异常的 stderr 报告格式是文档化的差异点,所以// @exit:程序只比对 stdout——每条豁免都有名字、有文档、有出处。
处理确定性难题:缓存、归一化与"易变宿主状态"
逐字节测试最大的敌人是非确定性。scriptc 对三类情况给出了工程化答案:
1. 实时程序不缓存
语料中 18/298 个程序用到setTimeout/setInterval/Promise.race——它们的输出是定时器交错序列,只有 Node 与原生二进制在同一瞬间负载下才一致。这类程序被明确排除出 oracle 缓存,每次都实时启动 Node(见 usesVolatileHostState)。同理,os.networkInterfaces、系统证书库等"宿主机易变状态"也不走缓存——一次被记录的负载倾斜交错,会一直失败到缓存淘汰为止。
2. node:test 输出做"文档化归一化"
node:test的 spec 报告器在每一行都嵌入真实耗时,任何 node:test 程序在 Node 自己跑时 stdout 都不确定。因此 node-test-normalize.ts 对两侧施加同一个文档化的归一化(耗时→Xms、栈帧、inspect 属性块),而符号、缩进、汇总计数、失败位置等其余一切内容,仍必须逐字节一致。
3. 容器里挂载到"自己的绝对路径"
Linux 车道在 Docker 里验证(linux-differential.test.ts),有个细节很见功力:编译出的二进制会把宿主路径"烧"进__dirname、动态导入错误信息等输出里,所以仓库必须挂载进容器的同一路径——换一个中性挂载点,所有带路径的输出都会系统性偏离。这不是绕过测试,而是消除假阳性。
跨平台:Windows 车道"零归一化"
Windows 车道(SCRIPTC_WIN=1)把每个.exe加源码 scp 到 Windows 机器,通过 ssh 在目标机器本机的 Node上做裁判,逐字节比较 stdout 与退出码——什么都不归一化。确实故意在 Windows 上偏离的程序(如依赖/bin子进程的 spawn 程序)会列进文件内的WINDOWS_SKIPS清单,与跨平台门控原因一起,构成移植工作的"待办清单"。
测试加速:缓存不碰比较,只跳过编译
逐字节跑几百个程序,clang 编译是瓶颈。harness 的内容寻址构建缓存(binary/library/runtime objects/oracle 四层)让重复运行飞快,但有一条铁律:
命中缓存跳过的是原生代码生成与链接,二进制仍然真实运行;比较与 sanitizer 覆盖永远不会被跳过。
Node 侧的 oracle 结果也按"程序字节 + Node 版本 + shim 内容 + 调用形态"做键缓存,但"只跳过 spawn,比较本身永不变"。pnpm test:cache-identity还会跑三遍(无缓存→填充→带缓存),逐条 diff 每个测试的状态与失败输出,防止缓存本身引入漂移。
从方法论到实践:这套体系的可借鉴之处
scriptc 的差分测试方法论,可以浓缩为五条可迁移的原则:
- 选一个不会漂移的裁判——用真实的 Node 而非快照当期望输出;
- 同一资产多车道复用——C 后端、LLVM 后端、净化构建、交叉目标,共用一个语料库;
- 不一致必须显式——豁免有指令、有文档、有清单,"响亮拒绝"优于静默降级;
- 非确定性被工程化识别——实时程序、易变宿主状态按正则特征识别并排除出缓存;
- 速度不牺牲正确性——缓存只省编译,不省运行与比较。
如果你想亲手验证,最快的路径是进入仓库后运行过滤后的单条差分测试(pnpm exec vitest run tests/harness/differential.test.ts -t <名称>),再用完整双车道(pnpm test与SCRIPTC_SAN=1 pnpm test)作为提交门禁——这正是 harness README 定义的"迭代时过滤、门禁时全量"工作流。语料库入口位于 tests/corpus/,兼容性矩阵证据保存在 internal/compatibility/,fetch 兼容性的版本化单一事实来源则是 fetch-profile.ts——新行为没有对应 fixture 或登记场景,直接让套件失败。
这就是 scriptc 与 Node.js 逐字节一致的底气:不是"测过了",而是"结构上无法悄悄不一致"。
【免费下载链接】scriptcTypeScript-to-Native Compiler项目地址: https://gitcode.com/GitHub_Trending/sc/scriptc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考