用静态扫描拦截 AI 生成的 Rust 隐患
rustgrade 基于 syn 扫描 Rust 代码,无需调用模型、联网或构建项目,可识别非测试代码中的 unwrap、跨 await 持锁及循环内克隆等问题。作者说明演示中的 unwrap 是刻意要求生成的,不能据此判断模型的实际错误率。
AI 深度解读
编译通过、测试全绿,AI 写出的 Rust 仍可能留下 panic、异步锁和多余拷贝。作者为此做了 rustgrade:在 agent 每次编辑后运行语法扫描,把问题直接送回它的工作流程。
它值得关注的地方,是检查介入的时机:代码刚写完就反馈。作者还做了 27 次 Claude Code 实验,不过结果也提醒我们,一段明确的项目指令,可能已经能解决部分问题。
为什么测试过了,还要在每次编辑后检查
作者让 coding agent 写了几个月 Rust,反复看到几类问题:库代码里使用 .unwrap(),持有 std::sync::Mutex 的锁时执行 .await,以及为了通过借用检查到处加 .clone()。
这些写法未必触发编译错误,测试也未必覆盖对应路径。agent 收到的反馈如果只有「构建成功」,就缺少继续修改的理由。
rustgrade 基于 syn 扫描语法,不调用 LLM、不访问网络,也不需要构建 crate。它目前有 8 条规则,覆盖测试外的 unwrap/expect、跨 .await 持锁、缺少 // SAFETY: 注释的 unsafe、无界通道、公共库 API 中的 anyhow、循环内克隆与分配,以及 &String、&Vec<T> 这类参数。
作者机器上,一个 445 行文件的扫描约耗时 20 ms。这只是单机样例,但解释了它为什么适合接在编辑动作后面。
这张演示图有个必须保留的前提:作者特意要求 agent 使用 unwrap,方便展示拦截过程。不加这条要求时,agent 自己就写出了 Result。它能证明反馈链路跑通,不能证明 agent 总会犯这个错。
它能指出危险写法,但无法理解全部语义
跨 .await 持锁,是文中最有解释价值的例子。下面通过缩小锁守卫的作用域,让数据库等待发生在释放锁之后:
// Flagged
async fn bump(state: &std::sync::Mutex<Counter>, db: &Db) {
let mut c = state.lock().unwrap();
c.n += 1;
db.save(c.n).await; // guard is still alive here
}
// Better
async fn bump(state: &std::sync::Mutex<Counter>, db: &Db) {
let n = {
let mut c = state.lock().unwrap();
c.n += 1;
c.n
}; // guard dropped before the await
db.save(n).await;
}
要看的是第二段的作用域结束位置:执行 db.save(n).await 前,guard 已经释放。第一种写法会让 future 成为 !Send;如果同一线程上的另一个任务尝试获取该锁,还可能死锁。修正版仍保留了 .unwrap(),所以它只演示持锁问题的修复。
另外两类改动也很直接:解析端口时,用 Result<u16, std::num::ParseIntError> 传播错误;函数只需要字符串切片时,把参数从 &String 改为 &str,避免要求调用方必须持有 String。
代价在于,语法扫描缺少类型信息。clone-in-loop 无法判断克隆的是成本较低的 Arc,还是较大的 Vec,也不会检查宏内部。命中规则只能说明需要检查,不能直接判定代码有错。
遇到合理例外,可以在行内写 // rustgrade: allow(unbounded-channel) shutdown signal, at most one message per worker。这里的理由是关闭信号每个 worker 最多发送一次。理由会保留在 JSON 输出里,方便 CI 审查。想了解规则边界,可以运行 rustgrade rules --explain <rule-id>。
27 次实验说明了什么,又没说明什么
作者用 Claude Code 配合 Sonnet 跑了 3 个任务,总计 27 次,每种配置 9 次。统计通过 cargo clippy --lib 完成,只计算库代码中的 unwrap/expect,不把测试代码算进去。
结果是:
- 不使用 rustgrade,残留数量为 5。
- 在 CLAUDE.md 加一小段 rustgrade 指令,残留为 0。
- 只启用 hook,不加该段指令,残留也为 0。
三组每次运行成本分别为 $0.23、$0.21、$0.22,变化很小。原文没有明确这 5 处问题如何分布在任务和运行之间,也没有给出 Sonnet 的具体版本。
这次实验里,项目指令与 hook 的观测结果相同。
hook 的区别在执行方式。作者观察到,hook 组里的 agent 没有主动加载 skill,也没有自行运行扫描器,反馈来自每次编辑后的自动扫描。但长会话里是否更有效、另外七条规则是否同样有效,作者还准备继续测。
作者还扫描了 tokio、serde、regex、syn 等 15 个知名 crate,评分落在 A 到 C 之间。这能提供真实代码中的争议样例,却不能单凭评级证明规则准确。
另一个发现来自 /harden:它用恶意输入探索问题,只修复失败测试证明存在的缺陷。作者称,它在 agent 编写的线程池中找到了死锁等真实 bug,这些实现此前通过了隐藏测试;作者随后单独复现了问题。这个结果值得关注,但原文没有给出可推广的检出率。
我的判断
我赞同把检查提前到编辑环节。对 AI 编程来说,反馈越接近刚发生的改动,越方便 agent 找到原因并修复。
不过,rustgrade 与 Clippy 有明显重叠。unwrap_used、await_holding_lock、undocumented_unsafe_blocks、ptr_arg、borrowed_box 已覆盖其中一些问题。rustgrade 的取舍是用较少的上下文换取更早的反馈;Clippy 需要构建,但能利用更多信息。
因此,普通团队可以先写清项目规则,再评估是否需要 hook。原文并没有证明自动扫描优于项目指令,更没有证明启用全部规则就能提高整体质量。20 ms 也不能直接推到整个工作区。
它采用 MIT 许可证。对于想接入的团队,我更看重误报能否解释、例外能否留痕,以及检查是否打断正常开发。作者主动征集真实 crate 的误报,符合这类启发式工具需要持续校准的特点。
能拿来做什么
- 如果你用 AI 写 Rust,可以先执行
cargo binstall rustgrade安装,再用rustgrade scan检查现有代码,挑出团队认可的规则。 - 在 CLAUDE.md 中明确库代码的错误处理要求,先观察 agent 是否遵守;反复出现的问题再接入自动反馈。
- 在 CI 中使用
rustgrade audit,结合 Clippy 和测试检查,并通过--fail-on阈值控制失败条件;需要 GitHub code scanning 时可输出 SARIF。 - 在 Claude Code、Cursor、Codex CLI、Gemini CLI、Copilot 或 Windsurf 中按支持方式安装 skill 和命令,用
/audit获取 A 到 F 的评级,用/harden探索边界输入;原文明确提供逐次编辑 hook 的是 Claude Code。