跳到主内容
快讯直播
AI智模界
教程

用编码智能体移植 TS 到 Rust:对拍测试与增量提交

这类移植在公开项目里有一套被反复验证的做法:把 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 目标依赖状态
normalizesrc/normalize.tscrates/core/src/normalize.rs无todo
parsesrc/parse.tscrates/core/src/parse.rsnormalizetodo
rulessrc/rules/*.tscrates/core/src/rules/parsetodo
iosrc/io.tscrates/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 脚本抽成仓库模板,下一个模块直接复制。移植第二个项目时,这套东西半天就能搭起来。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。