Structural lint rules

Rules that read code by its shape

ast-grep matches source by structure, not by text, so it tells a match arm from a comment or a string that just contains "match". It parses every file into an AST (Abstract Syntax Tree — the tree a compiler builds after parsing) and matches that tree. Plain text search gets this wrong in practice; the AST reads the structure instead.

These 49 rules are written in YAML (YAML Ain't Markup Language — the indentation-based config format ast-grep reads) and run with sg scan in pre-commit hooks and CI (Continuous Integration — the checks that run on every push) on every project my tooling scaffolds. Forty are general Rust rules; nine are security guardrails for a Rust and Rails codebase reachable only over a tailnet (a Tailscale private network — the set of machines joined to one Tailscale account, addressable only by each other).

How I enforce the catalog. The 40 general rules ship with every Rust project my scaffolding skill creates. A bundled demo crate covers the 8 cognitive-load rules: src/bad.rs fires each one, src/good.rs stays silent. The pre-commit hook promotes three cognitive-load rules to errors that block the commit: complex-conditional, deeply-nested-if, and factory-factory-naming. The 9 security rules are not part of the scaffold; they live in one project's repo. Rules carry per-rule ignore lists that generally exclude tests, benches, and vendored code, though the exact globs vary by rule.

Cognitive load

Inspired by the working-memory limit of about four chunks. These rules catch code shapes that force a reader to hold too much at once: deep nesting, dense conditions, magic numbers, opaque arguments. Two ship at severity: off as opt-in review triggers.

complex-conditional

warning Rust
What it catches

An if or while condition that chains three or more boolean operators (&& / ||).

How the match works

The rule matches a binary_expression whose operator is && or ||, that has a nested second boolean binary_expression, which itself has a third. Each has uses stopBy: end so it searches all the way down the subtree, not just direct children. The whole thing must sit inside the condition field of an if_expression or while_expression, so a boolean built as a plain value doesn't trip it.

rule (excerpt)rule:
  kind: binary_expression
  all:
    - any:
        - has:
            field: operator
            regex: "^&&$"
        - has:
            field: operator
            regex: "^\\|\\|$"
    - has:
        stopBy: end
        kind: binary_expression   # a 2nd operator, which itself has a 3rd
    - inside:
        field: condition
        any:
          - kind: if_expression
          - kind: while_expression
Why I have it

Working memory holds about four chunks. A condition with four sub-clauses plus operator precedence blows past that in one line. Named intermediate booleans turn a dense expression into a few labels the reader can skim.

Beforeif self.balance >= amount && !self.frozen
    && self.kyc_verified && amount <= self.daily_limit { ... }
Afterlet has_funds = self.balance >= amount;
let is_active = !self.frozen;
let is_allowed = self.kyc_verified && amount <= self.daily_limit;
if has_funds && is_active && is_allowed { ... }

deeply-nested-if

warning Rust
What it catches

Three levels of if nested through their bodies (plain conditions, not if let).

How the match works

Matches an if_expression whose consequence has an if_expression whose consequence has a third, each search using stopBy: end. A not: has: kind: let_condition clause excludes if let chains, which are a different shape with different fixes.

Why I have it

Three nested conditions make the reader carry three facts to reach the body. Invert each into a guard clause with an early return to keep the happy path flat and one level deep.

Beforefn transfer(a: &Account, amt: u64) -> Result<(), Error> {
    if a.is_active {
        if a.kyc_verified {
            if a.balance >= amt { a.debit(amt); }
        }
    }
    Ok(())
}
Afterfn transfer(a: &Account, amt: u64) -> Result<(), Error> {
    if !a.is_active { return Err(Error::Frozen); }
    if !a.kyc_verified { return Err(Error::KycRequired); }
    if a.balance < amt { return Err(Error::Insufficient); }
    a.debit(amt);
    Ok(())
}

deref-polymorphism

hint Rust
What it catches

An impl of Deref or DerefMut on a domain type.

How the match works

Matches an impl_item that has a trait field matching a regex for Deref/DerefMut (optionally namespaced as std::ops:: / core::ops::). A not clause excludes any generic type_parameters node, so generic smart-pointer impls stay clear and what remains is mostly concrete domain types.

Why I have it

Deref on a domain type fakes inheritance: admin.freeze_account() silently resolves on the inner User, so the reader chases methods that aren't declared on Admin. Hold the inner value in a field and forward only the methods you want to expose. It's a hint, not an error, because newtype and smart-pointer Deref are legitimate.

Beforestruct Admin { user: User }
impl Deref for Admin {
    type Target = User;
    fn deref(&self) -> &User { &self.user }
}
Afterstruct Admin { user: User }
impl Admin {
    fn kyc_verified(&self) -> bool { self.user.kyc_verified }
    fn freeze_account(&mut self) { self.user.freeze_account() }
}
Documented limitsAliased imports (use std::ops::Deref as D) evade the textual match. The rule documents this in its own note.

factory-factory-naming

warning Rust
What it catches

A type or identifier named with a doubled abstraction noun: FactoryFactory, ManagerManager, ProviderProvider, and friends.

How the match works

The simplest rule in the set: match any type_identifier or identifier whose text matches a regex listing the doubled nouns (FactoryFactory, WrapperWrapper, HandlerHandler, ServiceService, BuilderBuilder, and so on).

Why I have it

A stacked-abstraction name carries more cognitive load than the code behind it usually justifies. Collapse it into one concrete type or a plain function: AccountRegistry, or a free fn make_transfer(...). This is one of the three rules the pre-commit hook promotes to a hard error.

god-struct-many-fields

off Rust
What it catches

A struct with 13 or more fields. Ships at severity: off; enable it with --error=god-struct-many-fields.

How the match works

Matches a struct_item that has a field_declaration_list that has a field_declaration at nthChild: position: 13. The ofRule: kind: field_declaration makes the counter count only field declarations, so a 13th field means there are at least 13. Generated files (schema.rs, *.pb.rs, generated/) are ignored.

rule (excerpt)rule:
  kind: struct_item
  has:
    kind: field_declaration_list
    has:
      kind: field_declaration
      nthChild: { position: 13, ofRule: { kind: field_declaration } }
Why I have it

Many flat fields force a reader to track them all at once. It's a generous review trigger, not a hard cap: a struct that is one coherent module is fine. So it stays off until you opt in; when it fires, group related fields into value objects (Balances, AccountStatus, TransferLimits).

magic-comparison-literal

off Rust
What it catches

A == or != comparison against an integer literal other than 0 or 1. Ships at severity: off.

How the match works

Matches a binary_expression that has an ==/!= operator and has an integer_literal whose text is not 0 or 1 (those two are almost never magic). It does no context tracking, so it's deliberately blunt and ships opt-in.

Why I have it

if response.status == 418 forces the reader to memorize a number-to-meaning map. A named const or enum variant reads itself: AccountKind::Savings, const TEAPOT_BANNED: u16 = 418.

Beforeif account.kind == 3 { freeze(account); }      // what is 3?
if response.status == 418 { ban_user(); }
Afterif account.kind == AccountKind::Savings { freeze(account); }
const TEAPOT_BANNED: u16 = 418;
if response.status == TEAPOT_BANNED { ban_user(); }

nested-match-pyramid

hint Rust
What it catches

A match nested three or more levels deep — an Option/Result pyramid.

How the match works

Matches a match_expression whose body match_block has a match_arm whose value is another match_expression, whose block has an arm whose value is a third match_expression. The match nests all the way down, so it needs no stopBy tricks; the nesting is the match.

Why I have it

Every arm the reader has to hold is a chunk. Flatten the pyramid with ?, ok_or, and let-else so each guard reads top to bottom, one chunk at a time.

Beforematch accounts.get(&id) {
    Some(a) => match a.balance.checked_sub(amt) {
        Some(nb) => match kyc.verify(&a.user) {
            Ok(()) => Ok(nb),
            Err(e) => Err(e),
        },
        None => Err(Error::Insufficient),
    },
    None => Err(Error::NoAccount),
}
Afterlet a = accounts.get(&id).ok_or(Error::NoAccount)?;
let nb = a.balance.checked_sub(amt).ok_or(Error::Insufficient)?;
kyc.verify(&a.user)?;
Ok(nb)

opaque-bool-call

hint Rust
What it catches

A bare true or false passed as a call argument.

How the match works

Matches a boolean_literal inside an arguments node inside a call_expression, both with stopBy: end to reach through nesting. The called function must be not one of a whitelist: new, Some, then, then_some, unwrap_or, map_or, is_some_and, filter, the assert family, and so on. A bool reads fine at those call sites.

Why I have it

transfer(&from, &to, amount, true, false) tells the reader nothing; they have to open the callee to learn what the flags mean. A two-variant enum read at the call site is self-descriptive.

Beforetransfer(&from, &to, amount, true, false);
Aftertransfer(&from, &to, amount, Overdraft::Allow, Notify::Skip);

Imperative → functional

Thirty-two rules that push hand-rolled loops toward iterator and combinator style, tidy up verbose Option/Result handling, and catch error handling that silently drops failures. Grouped by the shape they target.

Loop → combinator · 14 rules

continue-filter-loop

warning Rust
What it catches

A for loop that opens with a guard and continue.

How the match works

Two patterns: if !$COND { continue; } $$REST and its inverse if $COND { continue; }. The $$REST metavariable captures the rest of the loop body, whatever it is, so the match doesn't depend on what comes after the guard.

Why I have it

An early continue is the imperative form of .filter(). Pull the predicate upstream; once the guard is gone the remaining body usually collapses to map or for_each. The inverse guard flips to .filter(|x| !cond).

Beforefor x in iter {
    if !pred(&x) { continue; }
    out.push(f(x));
}
Afterlet out: Vec<_> = iter.filter(|x| pred(x)).map(|x| f(x)).collect();

filter-in-loop

warning Rust
What it catches

A for loop that pushes only when a condition holds.

How the match works

A single pattern: for $X in $ITER { if $COND { $VEC.push($EXPR); } }. The metavariables bind the iterator, the guard, and the pushed expression so the whole conditional-append shape matches as one unit.

Why I have it

That's .filter(...).map(...).collect(), or .filter_map() when the test and the transform overlap.

Beforefor x in iter { if cond { vec.push(expr); } }
Afterlet vec: Vec<_> = iter.filter(|x| cond).map(|x| expr).collect();

hashmap-entry-grouping-loop

hint Rust
What it catches

A loop that groups items into a map with entry().or_default().push().

How the match works

Three patterns cover the common spellings: or_default(), or_insert_with(Vec::new), and or_insert_with(|| Vec::new()), each followed by .push($VAL).

Why I have it

It's a fold into a map, or itertools::into_group_map_by if that crate is already in the tree. It's hint-only, because the imperative grouping loop is often the clearest shape in Rust. Switch only when the surrounding code is already iterator-chained.

Beforefor item in iter {
    map.entry(key_of(&item)).or_default().push(item);
}
Afterlet by_key = iter.fold(HashMap::new(), |mut acc, item| {
    acc.entry(key_of(&item)).or_default().push(item);
    acc
});

if-let-ok-push-loop

warning Rust
What it catches

A loop that keeps only the Ok values and pushes them.

How the match works

Pattern for $X in $ITER { if let Ok($Y) = $EXPR { $VEC.push($PUSH); } }. The pushed $PUSH is independent of the bound $Y, so the rule matches any push inside the Ok branch, not only push(y).

Why I have it

The common case is .filter_map(|x| f(x).ok()). When the push transforms the binding, the closure carries the transform: .filter_map(|x| f(x).ok().map(transform)). The errors vanish either way, so if a failed parse should leave a trace, log it first with .inspect_err(|e| warn!(%e)).ok().

Beforefor x in iter {
    if let Ok(y) = f(x) { out.push(y); }
}
Afterlet out: Vec<_> = iter.filter_map(|x| f(x).ok()).collect();

if-let-some-push-loop

warning Rust
What it catches

A loop that keeps only the Some values and pushes them.

How the match works

Pattern for $X in $ITER { if let Some($Y) = $EXPR { $VEC.push($PUSH); } }, the Option twin of if-let-ok-push-loop. The pushed $PUSH is independent of $Y, so any push inside the Some branch matches.

Why I have it

The common case is .filter_map(|x| f(x)), or out.extend(iter.filter_map(...)) when the vec already exists. When the push transforms the binding, the closure carries the transform. If you need to handle the None branch too, keep the explicit loop.

Beforefor x in iter {
    if let Some(y) = f(x) { out.push(y); }
}
Afterlet out: Vec<_> = iter.filter_map(|x| f(x)).collect();

manual-all-loop

warning Rust
What it catches

A hand-rolled all: a bool set to true, then flipped to false on the first failing element.

How the match works

This is the flag-and-follows trick. The rule matches an expression_statement that has the loop for $X in $ITER { if !$COND { $FLAG = false; break; } } and follows the declaration let mut $FLAG = true;. The follows relation and the shared $FLAG metavariable tie the loop to the accumulator line right before it and force one variable name. The inverse body (if cond { flag = false; break; }) is caught too.

rule (excerpt)all:
  - kind: expression_statement
  - has: { pattern: "for $X in $ITER { if !$COND { $FLAG = false; break; } }" }
  - follows: { pattern: "let mut $FLAG = true;" }
Why I have it

iter.all(|x| pred(x)) says the same thing in one line and short-circuits identically. Flip the predicate for the inverse shape.

Beforelet mut ok = true;
for x in iter {
    if !pred(&x) { ok = false; break; }
}
Afterlet ok = iter.all(|x| pred(x));

manual-any-loop

warning Rust
What it catches

A hand-rolled any: a bool set to false, flipped to true on the first match.

How the match works

Same flag-and-follows shape as manual-all-loop, mirrored: the loop if $COND { $FLAG = true; break; } must follows a let mut $FLAG = false; declaration.

Why I have it

iter.any(|x| pred(x)) short-circuits on the first match exactly like the break does.

Beforelet mut found = false;
for x in iter {
    if pred(&x) { found = true; break; }
}
Afterlet found = iter.any(|x| pred(x));

manual-find-loop

warning Rust
What it catches

A hand-rolled find: None, then Some(x) on the first match.

How the match works

The loop if $COND { $FOUND = Some($VAL); break; } must follows let mut $FOUND = None;. The stored $VAL is any expression, not necessarily the loop variable. A constraints block pins X to kind: identifier so a tuple-destructuring loop (the position shape) doesn't match here.

Why I have it

The usual fix is iter.find(|x| pred(x)), which returns the first match. When the stored value transforms the item, chain .find(...).map(...). If you only need yes/no, .any(...) reads more directly.

Beforelet mut found = None;
for x in iter {
    if pred(&x) { found = Some(x); break; }
}
Afterlet found = iter.find(|x| pred(x));

manual-position-loop

warning Rust
What it catches

A hand-rolled position: None, then Some(index) on the first match.

How the match works

The loop destructures a tuple (for ($I, $X) in $ITER { if $COND { $FOUND = Some($I); break; } }) and must follows let mut $FOUND = None;. The pattern matches any tuple loop; it does not require $ITER to be .enumerate(). Storing $I rather than the element separates it from manual-find-loop.

Why I have it

When the iterator is .enumerate(), the fix is iter.position(|x| pred(x)), which returns the index of the first match and short-circuits. Use .rposition() to walk back to front.

Beforelet mut idx = None;
for (i, x) in iter.enumerate() {
    if pred(&x) { idx = Some(i); break; }
}
Afterlet idx = iter.position(|x| pred(x));

map-collect-loop

warning Rust
What it catches

An unconditional push-only loop.

How the match works

Pattern for $X in $ITER { $VEC.push($EXPR); }, with two filters. First, not: inside: if_expression, so a conditional push belongs to filter-in-loop instead. Second, a constraints rule that $VEC is not a call_expression, which excludes map.entry(k).or_default().push(...); that buckets into a HashMap rather than appending to one Vec.

Why I have it

iter.map(|x| expr).collect(), or vec.extend(iter.map(...)) when the vec already exists.

Beforefor x in iter { vec.push(expr); }
Afterlet vec: Vec<_> = iter.map(|x| expr).collect();

map-collect-loop-with-let

hint Rust
What it catches

A push-only loop with one intermediate let binding.

How the match works

Pattern for $X in $ITER { let $Y = $LET_EXPR; $VEC.push($PUSH); }, with the same not inside if_expression guard and the $VEC-is-not-a-call constraint as map-collect-loop.

Why I have it

Usually a .map(|x| { let y = ...; use_y(y) }).collect(). It's rated hint, not warning, because the body might rely on a side effect between the let and the push. Skim before accepting.

Beforefor x in iter {
    let y = transform(x);
    out.push(use_y(y));
}
Afterlet out: Vec<_> = iter.map(|x| {
    let y = transform(x);
    use_y(y)
}).collect();

match-ok-push-loop

warning Rust
What it catches

A loop that matches a Result and pushes the Ok value, dropping Err.

How the match works

Three patterns cover the arm orderings: Ok => push, Err(_) => {}; the two arms flipped; and Ok => push, _ => {}. The pushed value is independent of the Ok binding, so any push in the success arm matches.

Why I have it

Same as if-let-ok-push-loop, written with match. The common case is .filter_map(|x| f(x).ok()); when the push transforms the binding, the closure carries the transform. Errors disappear silently; log with .inspect_err(...) if they matter.

Beforefor x in iter {
    match f(x) {
        Ok(y) => out.push(y),
        Err(_) => {}
    }
}
Afterlet out: Vec<_> = iter.filter_map(|x| f(x).ok()).collect();

match-some-push-loop

warning Rust
What it catches

A loop that matches an Option and pushes the Some value.

How the match works

Three patterns for the arm orderings: Some => push, None => {}; flipped; and Some => push, _ => {}. The pushed value is independent of the Some binding, so any push in the success arm matches.

Why I have it

The match spelling of if-let-some-push-loop. The common case is .filter_map(|x| f(x)).collect(); when the push transforms the binding, the closure carries the transform.

Beforefor x in iter {
    match f(x) {
        Some(y) => out.push(y),
        None => {}
    }
}
Afterlet out: Vec<_> = iter.filter_map(|x| f(x)).collect();

mut-fold-accumulator

warning Rust
What it catches

A mutable accumulator updated in a loop.

How the match works

Three loop bodies each must follows a let mut $ACC = $INIT; declaration: $ACC += $RHS, $ACC = $ACC + $RHS, and $ACC = $ACC.$OP(...). The follows plus shared $ACC ties the loop to the accumulator it mutates. The right-hand side need not reference the loop item, so a loop that ignores $X still matches.

Why I have it

The usual fix is iter.fold(init, |acc, x| acc.op(x)). When it's an += from 0 use iter.sum(); from 1 with *= use iter.product().

Beforelet mut acc = init;
for x in iter { acc = acc.op(x); }
Afterlet acc = iter.fold(init, |acc, x| acc.op(x));

Option / Result ergonomics · 6 rules

is-some-then-unwrap

warning Rust
What it catches

if x.is_some() guarding an x.unwrap() in the body.

How the match works

An all of two things: the pattern if $OPT.is_some() { $$ }, and has: pattern: $OPT.unwrap() with stopBy: end. Because both spellings use the same $OPT metavariable, they match only when the receiver text is identical. That is a textual match, not a proof that it's one value, but in practice it is.

Why I have it

if let Some(v) = x binds once, removes the panic site, and lets the borrow checker reason about the inner value.

Beforeif opt.is_some() {
    ... opt.unwrap() ...
}
Afterif let Some(v) = opt {
    ... v ...
}

is-ok-then-unwrap

warning Rust
What it catches

if r.is_ok() guarding an r.unwrap().

How the match works

Same shape as is-some-then-unwrap: if $RES.is_ok() { $$ } combined with has: $RES.unwrap() on the same receiver.

Why I have it

if let Ok(v) = r binds the value once instead of testing then re-unwrapping.

Beforeif res.is_ok() { ... res.unwrap() ... }
Afterif let Ok(v) = res { ... v ... }

is-err-then-unwrap-err

warning Rust
What it catches

if r.is_err() guarding an r.unwrap_err().

How the match works

The Err sibling: if $RES.is_err() { $$ } plus has: $RES.unwrap_err() on the same receiver.

Why I have it

if let Err(e) = r binds the error once.

Beforeif res.is_err() { ... res.unwrap_err() ... }
Afterif let Err(e) = res { ... e ... }

if-let-some-else

warning Rust
What it catches

An if let Some/else where both branches produce a value.

How the match works

Matches if let Some($X) = $Y { ... } else { ... }, but not when the body has a return (stopBy: end). A returning branch is control flow, not a value, so combinators wouldn't fit. The only hard exclusion is return; arms with side effects, ?, break, or continue still match. Treat it as a syntax-level review prompt, not a guaranteed rewrite, and expect some false positives.

Why I have it

When both arms yield a value it's usually opt.map_or(default, |x| ...), map_or_else for a lazy default, or map(...).unwrap_or(default).

Beforelet label = if let Some(x) = opt { fmt(x) } else { default() };
Afterlet label = opt.map_or_else(default, fmt);

match-option-verbose

warning Rust
What it catches

A two-arm match on an Option that could be a combinator.

How the match works

An any of four arm orderings (Some/None, None/Some, Some/_, and Some(ref x)/None), combined with not: has: return so a match whose arms return early is left alone. That return is the only hard exclusion; arms with side effects, ?, break, or continue still match. It's a syntax-level review prompt, so expect some false positives.

Why I have it

opt.map_or(default, |x| ...), map_or_else, unwrap_or, or unwrap_or_else depending on which arms use their binding. For match opt.as_ref(), write opt.as_ref().map(...) instead.

Beforematch opt {
    Some(x) => transform(x),
    None => default,
}
Afteropt.map_or(default, |x| transform(x))

match-result-verbose

warning Rust
What it catches

A two-arm match on a Result that could be a combinator.

How the match works

Four arm orderings (Ok/Err, Err/Ok, and the two Err(_) variants), again with not: has: return. That return is the only hard exclusion; arms with side effects, ?, break, or continue still match. It's a syntax-level review prompt, so expect some false positives.

Why I have it

res.map_or(default, |v| ...), map_or_else(|e| ..., |v| ...), unwrap_or, or unwrap_or_else. Keep the explicit match when an arm has side effects, uses ?, or returns from the function.

Beforematch res {
    Ok(v) => v,
    Err(_) => default,
}
Afterres.unwrap_or(default)

Silent error handling · 7 rules

silent-ok-discard

warning Rust
What it catches

A bare expr.ok() used as a statement, throwing the error away.

How the match works

Pattern $EXPR.ok() inside an expression_statement with stopBy: neighbor. So the .ok() is the whole statement, not part of a larger expression. It's also excluded inside test modules (see the cfg(test) idiom below).

Why I have it

.ok() as a statement converts a Result's error to None and drops it on the floor. Propagate with ?, log it, or write let _ = expr; with a comment saying why the failure is ignorable.

silent-map-err

hint Rust
What it catches

.map_err(|_| ...) that throws the original error away.

How the match works

Pattern $EXPR.map_err(|_| $$BODY). The closure argument is literally _, so the source error is discarded before the replacement is built. Excluded in test modules.

Why I have it

An error replaced without its context makes failures harder to diagnose. Prefer .map_err(|e| MyError::Foo(e)) or .map_err(MyError::from); for unhelpful parse errors, add .inspect_err() or include the offending input in the message. Hint-only because the pattern is sometimes genuinely fine.

silent-filter-map-ok

warning Rust
What it catches

.filter_map(Result::ok) — silently drops every Err from an iterator.

How the match works

Two patterns: $EXPR.filter_map(Result::ok) and $EXPR.filter_map(|$X| $X.ok()). Excluded in test modules.

Why I have it

If any row fails to parse or deserialize, it vanishes with no trace. Prefer .filter_map(|r| r.inspect_err(|e| warn!(%e, "skipped")).ok()) so the drop is at least logged.

Beforelet rows: Vec<_> = raw.into_iter().filter_map(Result::ok).collect();
Afterlet rows: Vec<_> = raw.into_iter()
    .filter_map(|r| r.inspect_err(|e| warn!(%e, "skipped")).ok())
    .collect();

silent-unwrap-or-else

warning Rust
What it catches

.unwrap_or_else(|_| ...) that discards the error and returns a default.

How the match works

Pattern $EXPR.unwrap_or_else(|_| $$BODY). The _ closure argument marks the error as thrown away. Excluded in test modules.

Why I have it

If this operation fails, the bug is invisible: you get a default and no signal. Log first with .inspect_err(|e| warn!(%e, "context")), or propagate with ?.

let-underscore-call

warning Rust
What it catches

let _ = some_call(); — binding a call's result to _.

How the match works

Pattern let _ = $EXPR; with a constraints block requiring EXPR to be a call_expression (so let _ = value; to drop a lock guard doesn't trip it). It's exempted inside test modules with the idiom not: inside: mod_item, follows: attribute_item regex cfg(test). That clause means "not inside a mod that a #[cfg(test)] attribute directly precedes". That's how test-only modules are marked.

rule (excerpt)rule:
  all:
    - pattern: let _ = $EXPR;
    - not:
        inside:
          stopBy: end
          kind: mod_item
          follows: { kind: attribute_item, regex: "cfg\\(test\\)" }
constraints:
  EXPR: { kind: call_expression }
Why I have it

let _ = expr() suppresses the unused-Result warning and silently drops any error. If the call can fail meaningfully, handle or log it; if the failure is truly ignorable, keep the let _ but add a comment saying so.

unwrap-in-prod

error Rust
What it catches

.unwrap() anywhere outside test code.

How the match works

Pattern $EXPR.unwrap() with the not inside a cfg-test mod exemption. Here the regex is cfg(.*\btest\b, which also catches #[cfg(all(test, ...))] and similar. Test directories, benches, examples, and test_utils.rs are ignored by path on top of that.

Why I have it

An .unwrap() in production is a panic waiting for the wrong input. Prefer .expect("reason") for a diagnosable message, ? to propagate, or a combinator like .unwrap_or / .ok_or_else. This is one of the two error-severity general rules.

unwrap-or-default

hint Rust
What it catches

.unwrap_or_default() — the quietest fallback of all.

How the match works

Pattern $EXPR.unwrap_or_default(), excluded in test modules.

Why I have it

On a Result this returns the Default value (empty string, zero, empty vec) and hides the error completely. Prefer .expect("reason"), .inspect_err(|e| warn!(%e)).unwrap_or_default(), or ?. Hint-only because on an Option the default is often exactly what you want.

Stringly-typed code · 2 rules

stringly-typed-dispatch

warning Rust
What it catches

An if / else if chain that dispatches on .contains("...") checks.

How the match works

Matches an if_expression whose condition is $EXPR.contains($LIT) and whose else_clause has another if_expression testing a second .contains(). A not: inside: function_item regex fn (from_str|parse) clause exempts the one place this pattern is legitimate: an actual string parser.

Why I have it

Chained .contains() checks simulate enum matching with substrings, which is fragile and unexhaustive. Define an enum, implement FromStr, and match on the parsed value.

Beforeif name.contains("opus") { ... }
else if name.contains("haiku") { ... }
else { ... }
Afterlet model = Model::from_str(name).unwrap_or(Model::Sonnet);
match model { Model::Opus => ..., Model::Haiku => ..., _ => ... }

stringly-typed-match

warning Rust
What it catches

A match on expr.as_str() with string-literal arms.

How the match works

Matches a match_expression whose value is $EXPR.as_str(), with four not inside exemptions: impl FromStr, impl TryFrom<&str>, impl TryFrom<String>, and any fn from_str/parse. Those are exactly the places a string-to-enum match belongs.

Why I have it

A string-literal match loses exhaustiveness checking. Add a variant and the compiler won't remind you to handle it. Parse into an enum once, then match the enum.

Beforematch kind.as_str() {
    "savings" => ...,
    "checking" => ...,
    _ => ...,
}
Afterlet kind = AccountKind::from_str_loose(input).unwrap_or_default();
match kind { AccountKind::Savings => ..., AccountKind::Checking => ... }

Performance · 3 rules

push-str-format

error Rust
What it catches

buf.push_str(&format!(...)), which builds a throwaway String just to append it.

How the match works

A single tight pattern: $VAR.push_str(&format!($$)). The $$ swallows every argument to format!, so any format string and args match.

Why I have it

format! allocates a String, then push_str copies it in and drops it. write!(&mut buf, ...) formats straight into the existing buffer with no intermediate allocation. Writing to a String is infallible, so .expect(...) states that instead of the bare .unwrap() the catalog's own unwrap-in-prod rule forbids. One of the two error-severity general rules.

Beforebuf.push_str(&format!("{}: {}", key, val));
Afteruse std::fmt::Write;
write!(&mut buf, "{}: {}", key, val).expect("write to String cannot fail");

with-capacity-push-loop

warning Rust
What it catches

Vec::with_capacity(...) followed by a push loop.

How the match works

Matches the loop for $X in $ITER { $V.push($EXPR); } that follows the declaration let mut $V = Vec::with_capacity($CAP);. The same follows-plus-shared-metavariable trick as the accumulator rules ties the loop to the vec it fills.

Why I have it

iter.map(...).collect() is shorter and calls size_hint() automatically, so the capacity is pre-allocated when the iterator is an ExactSizeIterator. You don't have to wire the hint through by hand.

Beforelet mut v = Vec::with_capacity(iter.len());
for x in iter { v.push(expr); }
Afterlet v: Vec<_> = iter.map(|x| expr).collect();

for-range-len

warning Rust
What it catches

Index-based iteration over 0..v.len().

How the match works

Six range patterns cover the variants: 0..len, 0..=len, start..len, start..=len, and the len - N forms. Each matches a for loop whose range ends in $V.len().

Why I have it

v[i] carries a bounds check the compiler cannot always elide, and it keeps the borrow checker from helping you. Iterating directly removes the question: with &v, .iter().enumerate(), .iter_mut(), or .windows(2) / .chunks(n) for the offset variants, there is no index to bounds-check.

Beforefor i in 0..v.len() { ... v[i] ... }
Afterfor x in &v { ... }
for (i, x) in v.iter().enumerate() { ... }
for w in v.windows(2) { ... w[0], w[1] ... }   // for 0..v.len() - 1

Security guardrails

Nine project-specific rules for a Rust and Rails codebase that is reachable only over a tailnet, with no trusted reverse proxy in front of it. They enforce one architecture. Identity comes from the real TCP (Transmission Control Protocol — the connection the server actually accepts) peer, never a forwarded header. Every router carries the tailnet gate. Uploads byte-sniff their MIME (Multipurpose Internet Mail Extensions — the file-type label). One boundary sanitizes HTML (HyperText Markup Language). CSRF (Cross-Site Request Forgery — tricking a logged-in browser into sending an unwanted request) protection stays on.

no-forwarded-ip-auth

error Rust
What it catches

Reading a forwarded-IP (Internet Protocol — the address a packet claims to come from) header (X-Forwarded-For / X-Real-IP) in Rust.

How the match works

An any of four patterns: $H.get("x-forwarded-for"), $H.get("X-Forwarded-For"), and the two x-real-ip cases. Both letter cases are listed because header lookups are usually case-insensitive but the source text is not.

Why I have it

With no trusted reverse proxy, the client sets X-Forwarded-For and X-Real-IP, so a caller can put anything there. Feed one into the auth decision and you have an unauthenticated bypass: an attacker just claims a tailnet address. The real boundary is the TCP peer via ConnectInfo<SocketAddr> -> require_tailnet. If you read the header for logging, keep it out of any allow/deny. Error severity: this is the sharpest edge in the codebase.

Documented limitsThe four patterns are exact $H.get("...") forms in both letter cases. A different header spelling, a variable that holds the header name, or any indirection through a helper evades the match.

no-forwarded-ip-auth-ruby

error Ruby
What it catches

The Ruby sibling: reading a forwarded IP in Rails, including request.remote_ip.

How the match works

An any over env["HTTP_X_FORWARDED_FOR"], env["HTTP_X_REAL_IP"], request.headers["X-Forwarded-For"], request.headers["X-Real-IP"], and, deliberately, request.remote_ip.

Why I have it

request.remote_ip looks safe but ActionDispatch::RemoteIp trusts X-Forwarded-For under the hood, so it is itself a forwarded-IP footgun. Source identity must come from env["REMOTE_ADDR"], the actual TCP peer, which is what TailnetGate reads on purpose.

Beforeip = request.remote_ip           # trusts X-Forwarded-For
ip = env["HTTP_X_FORWARDED_FOR"]
Afterip = env["REMOTE_ADDR"]           # the real TCP peer
Documented limitsThe five patterns are exact: the two env[...] reads, the two request.headers[...] reads, and request.remote_ip. A different casing, a variable holding the key, or an indirection through a helper evades them.

axum-router-missing-tailnet-gate

warning Rust
What it catches

An axum router-builder function that wires routes but no require_tailnet gate.

How the match works

Matches a function_item that has Router::new() and has a $A.route($$) call, but does not has the text require_tailnet anywhere in it (a regex search over the function body). All three sub-checks use stopBy: end to scan the whole function.

rule (excerpt)rule:
  kind: function_item
  all:
    - has: { stopBy: end, pattern: Router::new() }
    - has: { stopBy: end, pattern: $A.route($$) }
    - not: { has: { stopBy: end, regex: "require_tailnet" } }
Why I have it

Every agstaff server is tailnet-only; the boundary is a .layer(from_fn(auth::require_tailnet)) on the router. A router with routes and no gate is an open server. It's a warning, not an error, because the checks have known blind spots (below). A visible warning beats a hard block that trips on false alarms.

Documented limitsTwo documented limits. First, a require_tailnet left in a comment defeats the regex, a false negative. Second, a router split across builder functions (idiomatic axum scaling) false-positives on the sub-builder. Drop an inline // ast-grep-ignore there.

axum-serve-without-connect-info

warning Rust
What it catches

Serving an axum app with bare .into_make_service().

How the match works

Two patterns: axum::serve($L, $X.into_make_service()) and $R.serve($X.into_make_service()).

Why I have it

require_tailnet and agmemory's identity resolver extract ConnectInfo<SocketAddr> to learn the peer's address. The bare .into_make_service() doesn't attach that connection info, so the code compiles but the gate fails at runtime. It fails silently, which is the dangerous part. Use .into_make_service_with_connect_info::<SocketAddr>(). This is the serve-side companion to the router-gate rule.

Beforeaxum::serve(listener, app.into_make_service()).await?;
Afteraxum::serve(
    listener,
    app.into_make_service_with_connect_info::<SocketAddr>(),
).await?;

agmemory-no-implicit-operator

warning Rust
What it catches

agmemory operator status derived from "has a login" or "has a tag" instead of the operator allow-list.

How the match works

An any of patterns built on Identity { $$, operator: ... }. The $$ matches the other struct fields. Then operator: is tested against $X.is_some(), $X.as_deref().is_some(), or !$X.is_empty(), all "any peer with a value = operator" shapes. The operator: true case is guarded by not inside an if_expression that checks $ADDR.is_loopback(), so a legitimate hardcoded operator on a loopback caller isn't flagged.

rule (excerpt)rule:
  any:
    - pattern: "Identity { $$, operator: $X.is_some() }"
    - pattern: "Identity { $$, operator: !$X.is_empty() }"
    - all:
        - pattern: "Identity { $$, operator: true }"
        - not: { inside: { stopBy: end, kind: if_expression,
                 has: { stopBy: end, pattern: "$ADDR.is_loopback()" } } }
Why I have it

Operator status must come only from the AGMEMORY_OPERATORS allow-list via listed(value, operators), never from a peer that merely has a login or a tag. "Any logged-in or tagged peer is an operator" was the exact privilege escalation v2 removed, because seats share tag:chief.

Documented limitsOnly catches operator set inside the Identity {} literal. The canonical safe path computes let operator = ... above and passes it by field shorthand. A regression on that line is not caught. The note calls for a companion pattern (let operator = $X.is_some();) scoped to crates/agmemory/**. Self-aware by design; it's a partial tripwire, not a proof.

document-upload-must-sniff-mime

warning Ruby
What it catches

An ActiveStorage .file.attach() that takes filename or content type straight from the request.

How the match works

Pattern $RECV.file.attach($$ARGS) that has a pair (a key/value in the options hash). The key is content_type or filename, and the value comes from params[$K] or $X.original_filename. The stopBy: end lets the key and value sit anywhere in the argument tree.

Why I have it

The CRM (Customer Relationship Management — the sales contact system) allow-list gate and the download Content-Type both derive from the stored MIME and filename. If a client can set those, it can mislabel an executable as a PDF (Portable Document Format) and slip past the gate. Sniff the actual bytes with Marcel::MimeType.for and File.basename the name (as Document.ingest! / sniff_mime do), then store the derived values.

Documented limitsThe value check deliberately omits $X.content_type, which would false-positive on the safe sniffed.content_type case, where the content type is already the sniffed result.

plan-html-sanitize-before-store

warning Ruby
What it catches

Unsanitized HTML reaching a Rails response through html_safe or raw().

How the match works

An any of four patterns: render html: $X.html_safe, render html: raw($X), render inline: $X, and a bare raw($X). The one sanctioned file, plans_controller.rb, is listed in ignores so it doesn't flag itself.

Why I have it

Agstaff::PlanSanitizer sanitizes agent-authored plan HTML on write, and only PlansController#show renders it. Any other html_safe or raw() bypasses that single boundary and reopens a stored XSS (Cross-Site Scripting — injecting script into a page other users load) hole. Render through the sanctioned path, or sanitize first.

Documented limitsIt matches only the render-API call shapes (render html:, render inline:, and raw(...)). It cannot tell whether $X was already sanitized, so a value cleaned upstream still flags, and HTML that reaches the response by a different route is missed.

rails-no-csrf-disable

warning Ruby
What it catches

CSRF protection turned off in a Rails controller.

How the match works

An any over the disabling spellings: skip_before_action :verify_authenticity_token (with or without extra args), skip_forgery_protection, and protect_from_forgery with: :null_session.

Why I have it

The tailnet is not a CSRF boundary. A page open in any tailnet browser can forge a same-origin POST, and disabling the token check lets it through. Cookieless JSON endpoints belong under ActionController::API, which has no forgery protection to disable. So there's no reason to reach for skip_forgery_protection on a normal controller.

rails-tailnet-gate-installed

error Ruby
What it catches

A Rails Application class that never installs TailnetGate at the front of the middleware stack.

How the match works

Matches a class that has config.load_defaults $V (the marker of a Rails application config) but does not has config.middleware.insert_before 0, TailnetGate. Both checks use stopBy: end to scan the class body.

Why I have it

The Tailscale source IP is the only auth boundary for these Rails apps: no Devise, no trusted proxy. If TailnetGate isn't at index 0, requests reach the app before anything checks where they came from. Error severity: a missing gate is a wide-open app.

Before# config/application.rb — MISSING the gate
class Application < Rails::Application
  config.load_defaults 7.1
end
Afterrequire_relative "../lib/middleware/tailnet_gate"
class Application < Rails::Application
  config.load_defaults 7.1
  config.middleware.insert_before 0, TailnetGate
end