Harness 工程:设计、实现与可借鉴点
这篇文章主要探讨仓库里 rust/ 目录下那个兼容性测试工具(harness)的目标、结构及具体实现方式。同时,也会提炼出一些对大型代码库迁移、双语言共存这类项目有参考价值的思路。需要先说明的是,主程序真正的源代码仍位于 ../src/(TypeScript)侧;Rust 端当前的目标非常明确——实现“可证明的抽取”以及“与上游接口对齐的骨架”,而非构建一个完整的命令行运行时。
1. 先说说“Harness-first”这个理念怎么落地
rust/README.md 里引用了一份产品需求文档,其核心思路可以概括为四个步骤:
- 先抽取“可观测的事实”:从现有上游源码中稳定地读取命令表、工具表、启动阶段这些信息,而不是一上来就闷头编写一套自以为与上游对齐的运行时。
- 边界命名与上游保持一致:crate 的划分要紧贴主仓库已有的接口,比如
commands、tools、runtime 等。这样两边对照查看、后续往里填充内容都非常方便。 - 先证明,再扩张:通过测试和一个小型命令行子命令,将抽取结果固定在持续集成里。新功能必须先在这个测试层中拿出证据,证明走通之后,再考虑行为兼容问题。
- 公开承认缺口:在里程碑描述中明确说明现阶段不做“完全替代”的全量兼容。这能有效避免对外界做出过度承诺。
本质上,这是一种“兼容性优先的脚手架”策略,其核心价值在于降低迁移过程中的不确定性与回归成本,而不是第一天就把主程序换掉。
2. 工作空间的依赖关系
来看一下目录结构:
rust/
├── Cargo.toml # 工作区,members = crates/*,统一 lint 规则
├── README.md
└── crates/
├── rusty-claude-cli/ # 二进制入口(很薄的一层)
├── compat-harness/ # 读取 TypeScript 源、做抽取的唯一实现 crate
├── runtime/ # BootstrapPhase / BootstrapPlan(没有 I/O)
├── commands/ # CommandRegistry 等纯数据类型 crate
└── tools/ # ToolRegistry 等纯数据类型 crate
依赖关系清晰,构成了一个有向无环图。其中,commands 和 tools 之间没有依赖关系——清单彼此独立,避免类型相互拖累。compat-harness 负责聚合解析逻辑和文件读取(I/O),而其他 crate 则保持为可单独测试、可复用的小型库。
3. 上游路径契约:UpstreamPaths
compat-harness 假定仓库的布局是固定的,与当前的单一仓库保持一致:
| 方法 | 指向 |
|---|---|
commands_path() | repo_root/src/commands.ts |
tools_path() | repo_root/src/tools.ts |
cli_path() | repo_root/src/entrypoints/cli.tsx |
from_workspace_dir 这个方法通过 canonicalize 加 parent(),能从 rust/crates/compat-harness/../../ 推导出 repo_root 的位置。这里有一个设计值得借鉴:把“真源文件的位置”封装成一个类型,后续若要改路径,只需改这一个地方即可。测试时则用 CARGO_MANIFEST_DIR 来定位测试用的固定数据文件。
4. 抽取是怎么做的?(启发式逐行扫描,不是 TypeScript 的抽象语法树)
4.1 设计上的取舍
并没有一上来就用 swc 或 TypeScript 的解析器,而是针对那些注册表类型的文件(里面大量充斥着 import、export const … = [、feature() ? require 这类模式)采用了逐行扫描并搭配简单字符串规则的方式。
| 优点 | 缺点 |
|---|---|
| 不依赖第三方解析库、实现代码短、编译速度快 | 上游如果大幅度改格式,可能会误报或漏报 |
| 与“清单级别”的目标匹配,不做语义分析 | 无法替代类型检查或真实的 import 依赖图 |
用在这个第一阶段刚刚好。后续如果需求细化到要精确分析导出图或循环依赖,可以再增量式地接入抽象语法树或 ts-morph,而且不需要推翻已有的 crate 划分。
4.2 命令清单(extract_commands)
- 内置命令:匹配
import … from './commands/...'模式,导入的符号记录为CommandSource::Builtin。 - 内部列表:在
INTERNAL_ONLY_COMMANDS = [和对应的闭合]之间,按行提取标识符,标记为InternalOnly。 - 特性门控命令:如果一行中同时出现
feature('和./commands/,就取赋值操作左侧的变量名,标记为FeatureGated。 - 去重:最终以
(name, source)这个二元组为键进行去重,形成CommandRegistry。
4.3 工具清单(extract_tools)
- 基础工具:匹配
./tools/的导入,且导入的符号以Tool或Tools结尾,记录为ToolSource::Base。 - 有条件工具:匹配
feature('...')并与Tool相关的赋值语句,记录为Conditional。
4.4 启动阶段(extract_bootstrap_plan)
对 cli.tsx 文件全文进行子串探测——比如查找 --version、startupProfiler、--daemon-worker 这些关键词。然后按固定顺序追加 BootstrapPhase 枚举项,最后再加上 MainRuntime。这种方法与真实运行时“检测到某功能就启用快速路径”的语义大致对应,但这只是一个粗粒度的静态近似分析。
5. 二进制入口:rusty-claude-cli
目前这个命令行工具只做三件事(代码在 main.rs 里):
| 子命令 | 行为 |
|---|---|
| (无参数) | 提示用户这只是基础版本,引导其查看 --help |
dump-manifests | 对当前工作区调用 extract_manifest,打印出命令、工具、启动阶段的数量 |
bootstrap-plan | 打印 BootstrapPlan::claude_code_default()(在 runtime 里定义的一个完整阶段骨架) |
--help | 显示使用说明 |
需要注意的是,bootstrap-plan 命令目前打印的是在 runtime 里写死的“全阶段列表”,而 dump-manifests 命令报告启动阶段数量时,使用的却是 extract_bootstrap_plan(cli.tsx) 这个启发式函数的结果子集。这两个命令的数据源目前还没有完全统一起来,这其实是“脚手架阶段”很常见的一种状态:一个命令展示的是“目标模型”,另一个展示的是“从上游扫描出来的近似结果”。后续可以把 bootstrap-plan 也改成走 extract_manifest 流程,或者让它同时输出两种结果,方便两边对比对齐。
6. 测试:既是“活文档”,也是回归护栏
在 compat-harness crate 里的集成测试(见 lib.rs 中的 #[cfg(test)] 模块)做得很实在:
- 从本仓库的
fixture_paths()里读取真实的commands.ts和tools.ts文件。 - 断言抽取出来的清单不为空。
- 断言一些已知的符号是存在的——比如
addDir、review、AgentTool、BashTool,同时确认不会像字符串字面量那样错误地提取出INTERNAL_ONLY_COMMANDS这类内部专用标记。
这里有一个巧妙的设计:测试没有对整个列表做快照比对——因为上游任何小的改动都可能引发大量 diff。而是采用“非空”加上“关键锚点符号”这种最小化的护栏,在稳定性和维护成本之间取得了平衡。
7. 工作空间的工程质量
- 使用
resolver = "2",统一了 edition、license 和 publish 配置。 - 工作区级别的 lint 规则中,设置了
unsafe_code = forbid,这跟项目的安全文化保持一致,确保测试工具里也不会偷偷使用unsafe代码。
8. 对类似项目的可借鉴点
如果你也在做类似的工作——比如从 TypeScript 或 Ja vaScript 的大单体向 Rust 或 Go 迁移——那么下面这几点或许会有所帮助:
- 按现有接口切分 crate:先对齐产品里已经存在的模块名(比如 commands、tools、bootstrap),而不是按 Rust 教科书上的分层方式重新命名,这样能减少团队的沟通成本。
- 把“抽取器”和“领域类型”分开:
commands、tools这些 crate 只放注册表的数据结构;文件读取和启发式解析的逻辑单独放在compat-harness里。将来要更换解析实现时,下游的类型定义依然稳定。 - 让“证明”跑在持续集成里:通过一个小测试和一个类似
dump-manifests的命令,把“还能读懂上游注册表”这件事变成一个合并的门禁,这比重写运行时本身要早得多。 - 给每个来源打上显式标签:像
Builtin、InternalOnly、FeatureGated以及Base、Conditional这样的枚举。这能让同一个符号在不同构建条件下的可见性有一个明确的表达方式,方便以后生成文档或者与新运行时做对齐。 - 接受第一版用“廉价的启发式方法”:在投入抽象语法树解析之前,用行级规则快速覆盖 80% 的清单内容。当然,要在文档里写清楚这种方法的脆弱之处。
- 坚持有向无环图的依赖关系:让
commands和tools互相不依赖,避免出现类型泥球。在大型项目上,这种依赖纪律带来的后期收益非常明显。 - 里程碑的话术要管理预期:README 里明确说明 harness-first 不等于特性完全对齐,这能有效管理相关人员的预期,避免测试工具被误认为“已经可以替代主程序了”。
9. 已知局限与后续方向
- 这里面没有 Anthropic API、没有 QueryEngine、也没有 Ink/UI——Rust 层目前完全不参与主产品的实际运行路径。
- 抽取过程中不验证 TypeScript 能否编译、import 是否成功解析、
feature()是否被死代码消除。 - 正如前面第 5 节提到的,
bootstrap-plan和extract_bootstrap_plan的输出还没有统一起来,这是一个可以改进的工程细节。 - 如果上游将来重命名了注册表文件,或者改成动态注册表的形式,就需要同步更新
UpstreamPaths和解析规则。
10. 与仓库内其他文档的关系
rust/README.md:权威的简短版介绍,包含里程碑和命令行用法。ARCHITECTURE.md:整个仓库(TypeScript + Rust)的架构总览。- 如果要扩展测试工具的功能,建议在
rust/README.md里更新里程碑内容,而不是仅修改本文。
(本文内容基于 rust/README.md、rust/crates/*/src/lib.rs、rusty-claude-cli/src/main.rs、rust/Cargo.toml 整理。)
