rustgrade:檢查編碼智能體留下的 Rust 程式碼模式
rustgrade: a Rust scanner for the habits coding agents leave behind
Gabriel Zapata 推出 rustgrade,一款基於 syn 的 Rust 掃描器,提供 8 條規則,無需 LLM、網絡呼叫或建置 crate。
For a few months I've been letting coding agents write a lot of my Rust. The code compiles, the tests pass, and then I read it and find the same things over and over: .unwrap() in library code, a std Mutex held across an .await, .clone() sprinkled around until the borrow checker stops complaining.
None of that is a compile error, so the agent has no reason to stop. I wanted something that would make it stop. That turned into rustgrade.
(Full disclosure: in this demo I told the agent to use unwrap so the hook would have something to catch. When I ran it without that instruction, it just wrote the Result itself.)
What it looks for
At its core it's a scanner built on syn. No LLM, no network calls, and it doesn't need to build your crate. Right now it has 8 rules: unwrap/expect outside tests, locks held across .await, unsafe without a // SAFETY: comment, unbounded channels, anyhow in public library APIs, clones and allocations inside loops, and &String / &Vec<T> style parameters.
Here are the three I see agents get wrong most.
Holding a lock across an 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;
}
The first version makes the future !Send, and it can deadlock if another task on the same thread goes for the lock. It also compiles fine in a lot of setups, which is exactly why it slips through.
Unwrap in a library:
// Flagged
pub fn parse_port(s: &str) -> u16 {
s.parse().unwrap()
}
// Better
pub fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
s.parse()
}
Asking for &String when you mean &str:
// Flagged
pub fn greet(name: &String) -> String {
format!("hello, {name}")
}
// Better
pub fn greet(name: &str) -> String {
format!("hello, {name}")
}
If you want the reasoning behind any rule, rustgrade rules --explain <rule-id> prints a before/after and what the rule can't see.
The catch with reading only syntax
Skipping type information is what makes it fast, but it also means it guesses. clone-in-loop has no idea whether you're cloning an Arc (cheap) or a big Vec (not cheap), and it doesn't look inside macros. When it's wrong, you silence it inline, and you have to say why:
// rustgrade: allow(unbounded-channel) shutdown signal, at most one message per worker
let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
Those reasons stay in the JSON output, so they can be reviewed in CI instead of disappearing.
Yes, clippy already has lints for some of this (unwrap_used, await_holding_lock, undocumented_unsafe_blocks, ptr_arg, borrowed_box), and rustgrade audit runs clippy as well. The reason the scanner exists is timing. Clippy needs a build. The scanner took about 20 ms on a 445-line file on my machine, which is fast enough to run on every single edit an agent makes.
I also wanted to know how noisy it is on code written by people, so I ran it on 15 well-known crates (tokio, serde, regex, syn and others). They land between A and C, and every finding is in the repo if you want to argue with them.
Using it
You can use it on its own: rustgrade scan and rustgrade audit work in a terminal or in CI, with --fail-on thresholds and SARIF output for GitHub code scanning.
For agents, it installs as a skill plus a few commands in Claude Code, Cursor, Codex CLI, Gemini CLI, Copilot and Windsurf. /audit gives a crate a grade from A to F using the scanner, clippy and your tests. /harden throws hostile input at your code and only fixes what a failing test proves is broken.
In Claude Code there's also a hook that scans every edit and hands the findings straight back to the agent.
Did it actually help?
I didn't want to just assume it did, so I measured it. 27 Claude Code runs with Sonnet, three tasks, 9 runs per setup. I counted unwrap/expect in library code with cargo clippy --lib, so test code doesn't count.
- No rustgrade: 5 left over
- A short rustgrade section in
CLAUDE.md: 0 - Just the hook, no section: 0
Cost per run barely moved: $0.23, $0.21 and $0.22.
To be honest about it, a plain instruction in CLAUDE.md did just as well as the hook here. The difference is that the instruction only works if the agent follows it, while the hook runs no matter what. In the hook runs the agent never loaded the skill or ran the scanner on its own, so those zeros came from the hook. Whether that matters more in long sessions, or for the other seven rules, is the next thing I want to measure.
It's a small sample, so I'm treating it as a first signal, not a conclusion. One thing that did surprise me: /harden found real bugs, including deadlocks, in thread pools the agent wrote during these evals. Those pools had passed their hidden tests. I reproduced the bugs separately to make sure they were real. Everything is in the repo, including the runs where it found nothing.
I built it with help from Claude Code.
Try it
cargo binstall rustgrade
rustgrade scan
It's MIT-licensed and I'd love contributions: new rules, fixes for false positives, support for other agents. And since the rules are heuristics, the most useful thing you can send me is a false positive from a real crate, or a rule you think is just wrong.
Repo, numbers and demo: https://github.com/angeldevmobile/rustgrade
來源:DEV Community · dev.to
