这类移植在公开项目里有一套被反复验证的做法:把 TypeScript 实现按模块切开,让编码智能体一块一块翻译,每翻完一块就用「同一批输入分别喂给两套实现、比对输出」的方式卡住语义偏差,最后在 CI 里跑这个比对,通过了才允许合并。下面按这套流程走一遍,命令和配置都可以直接照抄改路径。
这套流程能做出什么
走完之后的产出是:
- 一个能编译、有单元测试的 Rust crate,公开 API 与 TypeScript 侧一一对应;
- 一个
fixtures/cases.jsonl,每行一个用例,覆盖所有已移植模块; - 一个对拍脚本:把同一批用例分别喂给 TS 和 Rust,逐条比对输出,不一致就非零退出;
- 一条干净的提交历史:一个模块一个 commit,每个 commit 单独 checkout 出来都能编译并通过对拍;
- 一条 CI 流水线:PR 上自动跑双实现比对,失败则红色,配合分支保护做到「对拍不过不许合并」。
验收标准建议写成可检查的形式,例如「MIGRATION.md 里所有模块状态为 done,make check 在干净 clone 上 5 分钟内跑完且退出码为 0」。不要用「感觉差不多了」当标准。
前置条件清单
- Node.js 与 npm:版本以 Node 官方文档当前 LTS 说明为准;
- Rust 工具链:用 rustup 安装,版本以 Rust 官方文档为准;
- 一个编码智能体:CLI 或 IDE 插件都行,要求能读仓库文件、能写文件、能执行命令并把输出读回去;
- git 基本操作(分支、commit、amend);
- 一个当前能跑通测试的 TypeScript 实现。如果它自己测试就是红的,先修红,再谈移植;
- 半小时用来写 harness 的耐心。这一步省掉,后面所有对拍都不可信。
步骤 1:冻结 TypeScript 侧,建立黄金基线
第一件事是把 TS 源码变成只读参照物。新建一个分支或打一个 tag,之后所有移植工作都不再改它:
```bash
git switch -c port/rust-freeze
git tag ts-baseline-$(date +%Y%m%d)
git push origin ts-baseline-$(date +%Y%m%d)
```
然后写一个「命令行入口」,让 TS 实现能从一个 stdin 逐行读 JSON、往 stdout 逐行写 JSON。这一步的意义是:把「函数调用」变成「可文件比对的数据流」。
```ts
// tools/golden.ts —— 逐行读 {id, input},逐行写 {id, ok|err}
import { createInterface } from "node:readline";
import { runCase } from "../src/api/runCase";
import { normalize } from "./normalize";
const rl = createInterface({ input: process.stdin });
rl.on("line", (line) => {
if (!line.trim()) return;
const { id, input } = JSON.parse(line);
try {
process.stdout.write(
JSON.stringify({ id, ok: normalize(runCase(input)) }) + "\n",
);
} catch (err) {
process.stdout.write(
JSON.stringify({ id, err: String((err as Error).message) }) + "\n",
);
}
});
```
runCase 是你自己写的一层薄封装,把「一次完整业务调用」暴露成一个纯函数。需要触碰时间、随机数、文件、网络的地方,在这里注入固定值。
步骤 2:把不确定性压平(决定成败的一步)
对拍失败的原因里,一大半不是移植错了,而是两边输出本来就不可比。先把这些压平:
```ts
// tools/normalize.ts —— 输出规范化,Rust 侧照抄同一套规则
export function normalize(v: unknown): unknown {
if (typeof v === "number") {
if (Number.isNaN(v)) return "NaN";
if (!Number.isFinite(v)) return v > 0 ? "Infinity" : "-Infinity";
return v; // 数值比较交给 compare.mjs 做容差,不做字符串化
}
if (Array.isArray(v)) return v.map(normalize);
if (v && typeof v === "object") {
const src = v as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const k of Object.keys(src).sort()) out[k] = normalize(src[k]);
return out;
}
return v;
}
```
三条规则:对象 key 一律按字典序排序(JavaScript 对象的插入顺序和 Rust 的 HashMap 遍历顺序天然不同);NaN / Infinity 转成字符串(JSON 表达不了它们);浮点不做字符串化,交给比对脚本按容差判断。
时间、UUID、随机数同理:在 runCase 里传入固定种子和固定时间戳,两侧使用同一份种子。
步骤 3:写对拍脚本
```js
// tools/compare.mjs —— 逐条比对两份 JSONL,容忍浮点误差
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
const EPS = 1e-9;
function load(path) {
return new Promise((resolve) => {
const map = new Map();
const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
rl.on("line", (l) => {
if (l.trim()) { const r = JSON.parse(l); map.set(r.id, r); }
});
rl.on("close", () => resolve(map));
});
}
function eq(a, b, path, out) {
if (typeof a === "number" && typeof b === "number") {
const d = Math.abs(a - b), scale = Math.max(Math.abs(a), Math.abs(b), 1);
if (d / scale > EPS) out.push(${path}: ${a} != ${b});
return;
}
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) { out.push(${path}: 长度 ${a.length} != ${b.length}); return; }
a.forEach((x, i) => eq(x, b[i], ${path}[${i}], out));
return;
}
if (a && b && typeof a === "object" && typeof b === "object") {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const k of keys) eq(a[k], b[k], ${path}.${k}, out);
return;
}
if (a !== b) out.push(${path}: ${JSON.stringify(a)} != ${JSON.stringify(b)});
}
const [goldenPath, actualPath] = process.argv.slice(2);
const golden = await load(goldenPath);
const actual = await load(actualPath);
const problems = [];
for (const [id, g] of golden) {
const a = actual.get(id);
if (!a) { problems.push(${id}: Rust 侧缺少该用例); continue; }
const diffs = [];
eq(g, a, id, diffs);
problems.push(...diffs);
}
for (const id of actual.keys()) if (!golden.has(id)) problems.push(${id}: 多余用例);
if (problems.length) {
console.error(对拍失败,共 ${problems.length} 处不一致:);
for (const p of problems.slice(0, 50)) console.error(" " + p);
if (problems.length > 50) console.error(" ...(只显示前 50 条)");
process.exit(1);
}
console.log(对拍通过:${golden.size} 条用例);
```
注意「缺少用例」和「多余用例」都要报错——它们同样意味着协议没对齐。
步骤 4:切分模块,画出移植地图
让智能体先做只读侦察,产出候选模块表,你人工确认后写进 MIGRATION.md。排序原则是:纯函数优先,叶子模块优先,I/O 最后。
```markdown
| 模块 | TS 源文件 | Rust 目标 | 依赖 | 状态 |
|---|---|---|---|---|
| normalize | src/normalize.ts | crates/core/src/normalize.rs | 无 | todo |
| parse | src/parse.ts | crates/core/src/parse.rs | normalize | todo |
| rules | src/rules/*.ts | crates/core/src/rules/ | parse | todo |
| io | src/io.ts | crates/cli/src/io.rs | 全部 | todo |
```
单模块超过两三百行的,继续拆。智能体一次吞太多,偏差会叠加,排查成本指数上升。
步骤 5:给每个模块写一张任务卡
任务卡是这套流程里最值钱的产物。模板如下,每开一个新模块就复制一份、改四行:
```markdown
任务卡:port src/normalize.ts → crates/core/src/normalize.rs
允许改动
- crates/core/src/normalize.rs
- crates/core/src/normalize_test.rs
禁止改动(只读参照)
- src/、fixtures/、tools/**
参考
- TS 源码:src/normalize.ts(不要"顺手优化")
- 关联用例:fixtures/cases.jsonl 中 id 以
normalize/开头的行
验收命令(全部通过才算完成)
1. cargo test -p tsrs-core normalize
2. make check
约束
- 不新增依赖,如需新增先在回复里说明理由
- 公开函数与 TS 导出一一对应,命名转 snake_case
- 不使用 unwrap()/expect(),错误按项目既有风格返回
```
「禁止改动」那一栏是对拍测试能活下来的前提。智能体遇到测试红了,最常见的自救动作就是改测试,你必须提前把这条路堵上。
步骤 6:让智能体逐块翻译
每个模块的对话节奏固定成三步,不要一次要最终代码:
1. 先出骨架:「读 src/normalize.ts 和对应 fixtures,只输出 Rust 函数签名和文档注释,函数体写 todo!(),不要写实现。」确认签名和错误类型对了再往下。
2. 再填实现:「现在填充函数体,一次只填一个函数,填完跑 cargo test -p tsrs-core normalize。」
3. 最后对拍:「跑 make check,把失败的前 3 条用例贴出来,逐条解释差异原因,再改代码。」
Rust 侧的入口和 TS 侧保持同一个协议:
```rust
// src/bin/golden.rs —— 协议与 tools/golden.ts 完全一致
use std::io::{self, BufRead, Write};
fn main() -> anyhow::Result<()> {
let stdin = io::stdin();
let stdout = io::stdout();
let mut out = io::BufWriter::new(stdout.lock());
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() { continue; }
let rec: serde_json::Value = serde_json::from_str(&line)?;
let id = rec["id"].clone();
let resp = match tsrs_core::run_case(rec["input"].clone()) {
Ok(v) => serde_json::json!({ "id": id, "ok": v }),
Err(e) => serde_json::json!({ "id": id, "err": e.to_string() }),
};
writeln!(out, "{}", serde_json::to_string(&resp)?)?;
}
Ok(())
}
```
用 Makefile 把三条命令串起来,人和智能体都用同一个入口:
```make
.PHONY: golden actual compare check
golden:
npm run --silent golden -- < fixtures/cases.jsonl > /tmp/golden.jsonl
actual:
cargo run --quiet --bin golden < fixtures/cases.jsonl > /tmp/actual.jsonl
compare:
node tools/compare.mjs /tmp/golden.jsonl /tmp/actual.jsonl
check: golden actual compare
cargo test --all --quiet
```
npm run golden 指向项目现有的 TS 运行方式即可(ts-node、tsx、或先编译再跑都行),不用纠结选哪个。
步骤 7:用对拍卡住语义偏差
对拍报错时,按这个顺序看,能覆盖大多数情况:
- 数字:JS 只有 f64,
i64/u64在边界上会对不上;大整数在 JS 侧早就丢精度了。Rust 侧跟着用f64,或显式做范围检查。 - 字符串:JS 的
length和charCodeAt按 UTF-16 码元算,Rust 的len()是字节数、chars()是码点。凡是按索引切字符串的代码,逐行重写而不是直译。 - 排序:JS 的
Array.prototype.sort默认按字符串比较,Rust 的sort()按值比较。数字数组要写sort_by(|a, b| a.partial_cmp(b).unwrap())并显式处理NaN。 - 正则:语法看着像,行为不一定像,尤其是后行断言、Unicode 属性类、
\d的范围。 - 异常:TS 抛异常的地方,Rust 要返回
Result;runCase里把Err的 message 也纳入比对,能顺带验证错误路径。 - 对象遍历顺序:已经在
normalize里排序了,如果还报错,说明 Rust 侧漏了排序这一步。
每发现一处偏差,先往 fixtures/cases.jsonl 加一条最小复现用例,再改代码。用例是回归资产,代码只是当下的答案。
步骤 8:增量提交的节奏
一个模块一个 commit,提交前 make check 通过。commit message 里写清楚对拍覆盖情况:
```bash
git add crates/core/src/normalize.rs crates/core/src/normalize_test.rs MIGRATION.md
git commit -m "port(normalize): TypeScript normalize.ts → Rust
- 对齐 key 排序与数值规范化规则
- 对拍用例 normalize/* 全部通过
- 未改动 TS 源码与 fixtures"
```
自查两个命令,防止智能体偷偷扩大改动面:
```bash
git diff --stat HEAD~1 # 确认只碰了该碰的文件
git stash && make check && git stash pop # 确认这个 commit 自己就是绿的
```
写歪了就用 git commit --amend 或 git rebase -i 整理,别把「修一下」「再修一下」留在历史里。
步骤 9:CI 里跑双实现比对
流水线的骨架就是:装两套环境 → 生成两份输出 → 比对 → 跑测试。
```yaml
name: port-parity
on:
pull_request:
push:
branches: [main]
jobs:
parity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # 版本以官方仓库当前说明为准
- uses: actions/setup-node@v4 # 同上
with:
node-version-file: .nvmrc
- uses: dtolnay/rust-toolchain@stable # 同上
- name: 安装 JS 依赖
run: npm ci
- name: 生成 TS 侧输出
run: npm run --silent golden -- < fixtures/cases.jsonl > /tmp/golden.jsonl
- name: 生成 Rust 侧输出
run: cargo run --quiet --bin golden < fixtures/cases.jsonl > /tmp/actual.jsonl
- name: 对拍
run: node tools/compare.mjs /tmp/golden.jsonl /tmp/actual.jsonl
- name: Rust 测试
run: cargo test --all
- name: 失败时保留两份输出
if: failure()
uses: actions/upload-artifact@v4 # 同上
with:
name: parity-output
path: |
/tmp/golden.jsonl
/tmp/actual.jsonl
```
最后一步很关键:对拍失败时把两份 JSONL 传成 artifact,你下载下来直接 diff,比在日志里翻五十行错误快得多。
然后在仓库设置里把 parity 设为 required status check,PR 不绿就合不进去。这一步做完,「语义一致」才从口头承诺变成机器保证。
常见坑与排错
对拍一上来就全红。 九成是 harness 本身有问题,不是移植有问题。先用同一个 TS 实现跑两遍(make golden 两次,比对两份文件),两次不一致说明输出还有随机性没压平,先修这个。
智能体改了测试或 fixtures。 每次提交前跑 git diff --stat,看到 fixtures/ 或 tools/ 就该警觉。更稳的做法是把这两个目录写进 CODEOWNERS,或在任务卡里明确列为只读。
模块太大,一次改出一堆错。 超过两三百行就继续拆。拆分成本远低于「一次改 40 个函数然后不知道哪错了」的排查成本。
Rust 编译太慢,智能体等不到结果就瞎猜。 在本地跑 cargo build 预热依赖,或把 target/ 缓存进 CI。让智能体先跑 cargo check 而不是 cargo test,反馈更快。
上下文超限,智能体开始忘事。 一个模块一个会话,任务卡贴在最前面。跨模块的约定写进 MIGRATION.md,别指望它记住三轮对话前说过的话。
浮点对不上但看起来一样。 先把容差调到 1e-6 看是否还红;还红就是算法路径不同(例如累加顺序不同导致误差累积),这时候应该对齐计算顺序,而不是继续放宽容差。
CI 上过、本地不过。 多半是 Node 版本或 locale 差异导致排序、数字格式化不同。用 .nvmrc 固定版本,排序一律显式传比较函数。
下一步建议
- 从对拍走向模糊对拍:写一个随机输入生成器,每次 CI 生成几百条用例,先只在 CI 日志里报不一致、不阻塞合并;稳定之后再转为阻塞。
- 属性测试:用
proptest之类的库,把「输出必须满足的恒等式」(例如解析后再序列化应等于规范化后的输入)写成属性。 - 性能对拍:用同一批 fixture 分别跑两套实现,记录耗时比值。语义对齐之后再谈性能,顺序别反。
- 先影子再切换:在生产里让 Rust 版本跟着跑但不返回结果,只记录差异;观察一段时间无异常,再真正切换流量。
- 把流程模板化:任务卡、Makefile、compare 脚本抽成仓库模板,下一个模块直接复制。移植第二个项目时,这套东西半天就能搭起来。
