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 65 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. Fifty-six 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 56 general rules ship with every Rust project my scaffolding skill creates. A bundled demo crate covers all 14 cognitive-load rules: src/bad.rs fires each one, src/good.rs stays silent. The pre-commit hook promotes three rules to errors that block the commit: complex-conditional, deeply-nested-if, and factory-factory-naming. Six rules ship at severity: off as opt-in review triggers rather than standing checks. A 56-pair fixture harness regression-tests the whole catalog: every rule owns a before.rs that must fire and an after.rs that must stay silent. 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.

How severity levels drive automation

A severity is not a label on how much I dislike the code. It is the wiring that lets one rule set be a quiet reading aid in CI and a hard gate on a commit, without maintaining two copies of the catalog.

Plain sg scan reads each rule's severity field and does one of four things. hint and warning both print the finding and leave the exit code at zero, so they report and never fail a build; the split between them is how strongly I think the finding is worth acting on. error prints and exits non-zero, which fails the scan wherever it runs — CI, a hook, a terminal. off compiles and loads the rule but reports nothing. Across the 56 general rules that comes out to 1 error, 17 warnings, 32 hints, and 6 off.

One rule set, different teeth at each gate. The pre-commit hook runs the same catalog with three rules promoted on the command line, so exactly three findings can stop a commit:

pre-commit hooksg scan --error=complex-conditional \
        --error=deeply-nested-if \
        --error=factory-factory-naming

Those three earn it by being nearly false-positive free: a condition with three boolean operators, three levels of nested if, and a doubled abstraction noun are all decidable from the shape alone. CI runs plain sg scan with no flags, so in CI the same three are warnings again and the build stays green. The promotion lives in the hook, not in the rule file.

The 6 off rules are opt-in review triggers, not dead weight. A human, or an AI review agent working through a module, activates one by id: sg scan --warning=too-many-fn-params prints every hit and still exits 0 (advisory), while --error=too-many-fn-params turns the same rule into a gate. The six are god-struct-many-fields, magic-comparison-literal, too-many-fn-params, and deep-generic-type from the cognitive-load set, plus await-in-loop and untyped-json-return from the imperative set. Activating an opt-in rule does not make every hit mandatory. These are the blunt heuristics, and they fire on code that is fine — a 14-field config struct, a genuinely sequential await chain. Read each finding and record one of two outcomes: a fix, or an accepted exception with a reason. Never bulk-refactor a codebase to make an opt-in scan green; that is how you end up with worse code and a clean report.

The ladder exists because of what a blocking gate does to a reader. Put blunt heuristics behind a gate that fails the commit and people learn to bypass the gate, at which point the surgical rules stop working too. Keeping the blockers to three near-certain shapes means the gate is always right when it fires, and the heuristics stay one --warning= flag away for the moments when you actually want them.

This section exists because a reader — dpc — asked the obvious question I had skipped: if a rule ships at severity: off, how does it ever run in an automated setup?

Cognitive load

Fourteen rules built on the working-memory limit of about four chunks. They catch code shapes that force a reader to hold too much at once: deep nesting, dense conditions, magic numbers, opaque arguments, type signatures that need a diagram. Four ship at severity: off as opt-in review triggers — god-struct-many-fields, magic-comparison-literal, too-many-fn-params, and deep-generic-type. The three rules the pre-commit hook promotes to blocking errors all live here: complex-conditional, deeply-nested-if, and factory-factory-naming.

boolean-control-parameter

hint Rust
What it catches

A function whose bool parameter picks which branch of its own body runs, so the call site reads render(input, true) and never says which behavior true selected.

How the match works

The rule matches a function_item, and the body clause comes first on purpose: ast-grep binds a metavariable at the first candidate and does not backtrack into a sibling clause, so binding $FLAG from the parameter list made fn f(x: bool, y: bool) with if y invisible — it bound $FLAG = x and gave up. The body clause descends through the block for an if_expression whose condition is either !$FLAG or a bare identifier bound to $FLAG. !$FLAG is listed first for the same reason: a bare $FLAG swallows the whole !flag unary expression, after which no parameter can match it. The descent stops at closure_expression, function_item and async_block. Only then does a second clause require a parameter whose pattern is that same $FLAG and whose type is a primitive_type matching ^bool$. A final not skips anything inside a mod_item that follows a #[cfg(test)] attribute run.

rule (excerpt)rule:
          kind: function_item
          all:
            # body clause FIRST: ast-grep binds $FLAG at the first candidate
            # and does not backtrack into a sibling clause.
            - has:
                field: body
                kind: block
                has:
                  stopBy:
                    any:
                      - kind: closure_expression
                      - kind: function_item
                      - kind: async_block
                  kind: if_expression
                  has:
                    field: condition
                    any:
                      - pattern: '!$FLAG'   # before the bare identifier: a bare
                      - all:                # $FLAG swallows all of `!flag`
                          - kind: identifier
                          - pattern: $FLAG
            - has:
                field: parameters
                kind: parameters
                has:
                  kind: parameter
                  all:
                    - has:
                        field: pattern
                        pattern: $FLAG
                    - has:
                        field: type
                        kind: primitive_type
                        regex: "^bool$"
Why I have it

A bool that is a mode switch moves the decision to every call site and then hides it there. A named two-variant enum puts the mode back in the signature, and fixing the declaration fixes every call site at once. This is the declaration-site half of opaque-bool-call: that rule reports the caller passing a bare true, this one reports the signature that made the bare true possible. I keep it at hint because not every bool parameter is a control parameter — a flag that is only stored (fn open(frozen: bool) -> Account { Account { frozen } }), forwarded, or folded into a returned boolean is data, not a mode, and is not reported.

Beforefn render(input: &str, pretty: bool) -> String {
            if pretty { render_pretty(input) } else { render_compact(input) }
        }
Afterenum Format { Pretty, Compact }

        fn render(input: &str, format: Format) -> String {
            match format {
                Format::Pretty => render_pretty(input),
                Format::Compact => render_compact(input),
            }
        }
Documented limitsThe branch must test the parameter directly, if flag or if !flag; if flag && other, match flag { .. } and flag.then(..) are not reported. The type must be a bare bool, so &bool and Option<bool> parameters are missed. The branch must be in the function's own body — a switch inside a closure or a nested fn belongs to that scope, not this signature. #[cfg(test)] mod is skipped in any attribute order, but only when the #[cfg(test)] sits in the attribute run directly above the mod.

complex-conditional

warning Rust
What it catches

A branch condition or match guard that mixes && and || across three or more boolean operators.

How the match works

The rule matches a binary_expression whose own operator is && or ||, then reaches three operators through either of two shapes. The skewed shape is root to operand to operand, three levels deep, which catches a && b && c && (d || e). The balanced shape requires a boolean operator in each operand subtree, which catches (a && b) || (c && d) and !(a && b && (c || d)) — a depth-only counter misses those even though they carry the same three operators. Two further clauses require at least one && and at least one ||, so a homogeneous chain never matches. A not: inside then walks up through ancestors, not just the immediate parent, looking for an enclosing boolean binary_expression; an immediate-parent-only guard would let ((a && b && c) || d) && e report twice, because the inner chain's parent is a parenthesized_expression. Last, the chain must sit inside the condition field of an if_expression, a while_expression, or a match_pattern (the match-guard node). Every descent and the ancestor walk stop at closure_expression — and, in the balanced and outermost clauses, at function_item and async_block too — so a callback body cannot inflate the operator count of the condition around it. Inline #[cfg(test)] mod is skipped.

rule (excerpt)# (3) report the OUTERMOST chain only. The walk goes up through
        #     ANCESTORS, not just the immediate parent.
        - not:
            inside:
              stopBy:
                any:
                  - kind: closure_expression
                  - kind: function_item
                  - kind: async_block
              kind: binary_expression
              has:
                field: operator
                regex: "^(?:&&|\\|\\|)$"
        # (4) it really is a branch condition; match_pattern covers match guards.
        - inside:
            stopBy:
              kind: closure_expression
            field: condition
            any:
              - kind: if_expression
              - kind: while_expression
              - kind: match_pattern
Why I have it

A mixed chain makes me hold every sub-condition and the precedence tree at once. Three named predicates turn that into three labels I can skim. I require the mix because homogeneous chains are one chunk, not three: an audit of about 90k LOC (lines of code — raw source line count) of my first-party Rust found that chains like c == '#' || c == '`' || c == '*' were the bulk of this blocker's false positives. One caveat on the fix: && and || short-circuit and a let does not, so only hoist side-effect-free, always-safe operands. If a later operand guards an earlier one (i < len && v[i] == x), keep it inline or nest an if.

Beforeimpl Account {
            fn can_transfer(&self, amount: u64) -> bool {
                if self.balance >= amount
                    && !self.frozen
                    && self.kyc_verified
                    && (amount <= self.daily_limit || self.is_internal)
                {
                    return true;
                }
                false
            }
        }
Afterimpl Account {
            fn can_transfer(&self, amount: u64) -> bool {
                let has_funds = self.balance >= amount;
                let is_active = !self.frozen && self.kyc_verified;
                let within_limit = amount <= self.daily_limit || self.is_internal;
                if has_funds && is_active && within_limit {
                    return true;
                }
                false
            }
        }
Documented limitsOnly the outermost chain of a condition is reported, so one condition is one finding even when the sub-chains sit behind parentheses. Homogeneous chains are silent by design. Closure bodies are their own reasoning scope: operators inside a closure do not count toward the enclosing condition, and a chain written entirely inside a closure argument (items.iter().any(|i| a && b && c && (d || e))) is not reported at all. The chain must be the condition of if / while / a match guard — a boolean bound with let and used later is not reported. #[cfg(test)] mod is skipped only when the #[cfg(test)] sits in the attribute run directly above the mod; a test module reached some other way (a #[cfg(test)] on an enclosing mod, or include!) is still linted.

deep-generic-type

off Rust
What it catches

A generic type nested four or more layers deep, so the signature reads as container mechanics rather than intent. Ships at severity: off; see the severity system above.

How the match works

Matches a generic_type that has a generic_type that has a third that has a fourth — the shape A<..B<..C<..D<..>>>>. Each descent uses stopBy: end, so a layer counts wherever it sits in the subtree, not only as the immediate type argument. A not: inside with stopBy: end and kind: generic_type keeps only the outermost type, so a five-layer type is one finding instead of two. The usual #[cfg(test)] mod skip applies. Because it ships off, it is absent from the default gates — plain sg scan and the pre-commit hook never report it; turn it on per run with sg scan --warning=deep-generic-type src/ (advisory, exit 0) or --error=deep-generic-type (exit 1 on a hit).

Why I have it

Four layers means unwrapping the whole type before I know what one entry is. Aliases are transparent — the expanded type is identical and nothing about behavior or inference changes — so naming a middle layer is free and hands the reader a chunk they can hold. It stays opt-in because any four-deep generic fires, including ones with no better spelling (Arc<Mutex<HashMap<K, V>>> wrapped once more), and because only a real domain concept is worth a name: type StringVec = Vec<String> buys nothing and costs a jump to the definition, whereas TransactionOutcome names something the domain already talks about.

Beforetype Histories =
            HashMap<UserId, Vec<Result<Option<Transaction>, Error>>>;
Aftertype TransactionOutcome = Result<Option<Transaction>, Error>;
        type TransactionHistory = Vec<TransactionOutcome>;
        type Histories = HashMap<UserId, TransactionHistory>;
Documented limitsThe threshold is four layers, so HashMap<UserId, Vec<Transaction>> is silent. Only the outermost type is reported. Depth is textual, not expanded: a signature written with an alias that itself expands four layers deep is silent, which is the point, since the alias is the fix. Any four-deep generic fires whether or not a better spelling exists — read the hit, then fix or dismiss it. #[cfg(test)] mod is skipped only when the #[cfg(test)] sits in the attribute run directly above the mod.

deeply-nested-if

warning Rust
What it catches

Three plain boolean if expressions nested through their success branches.

How the match works

Matches an if_expression whose own condition is not a let_condition, whose consequence contains a second if_expression under the same plain-condition requirement, whose consequence contains a third. Scoping the let_condition exclusion to each participating if's own condition is the point of the current shape: a single subtree-wide exclusion let one unrelated if let silence a real pyramid. Both descents stop at closure_expression, function_item, async_block, for_expression, while_expression and loop_expression — a callback body and a loop body are separate reasoning scopes, and inverting into a guard clause with an early return is not the fix for a loop-body filter. Nesting is counted through consequence only, so an else if ladder never matches. Inline #[cfg(test)] mod is skipped.

Why I have it

Three nested conditions make me carry three facts to reach the body, and the body is where the work is. Guard clauses keep the happy path flat. But guard clauses are not always the fix: an early return only preserves behavior when failing the condition really should leave the whole scope. If work still has to run after the outer if — a cleanup step, an accumulator update, a loop iteration that continues — extract the nest into a helper that returns a value, or name the combined predicate (let eligible = account.is_active && account.kyc_verified;) so the trailing audit_log.record(..) still runs. That is why the message says to flatten only when failure should leave the scope.

Beforefn transfer(account: &mut Account, amount: u64) -> Result<(), Error> {
            if account.is_active {
                if account.kyc_verified {
                    if account.balance >= amount {
                        account.debit(amount);
                        return Ok(());
                    }
                    return Err(Error::Insufficient);
                }
                return Err(Error::KycRequired);
            }
            Err(Error::Frozen)
        }
Afterfn transfer(account: &mut Account, amount: u64) -> Result<(), Error> {
            if !account.is_active { return Err(Error::Frozen); }
            if !account.kyc_verified { return Err(Error::KycRequired); }
            if account.balance < amount { return Err(Error::Insufficient); }
            account.debit(amount);
            Ok(())
        }
Documented limitsNesting is counted only through the consequence branch, so an else if chain reads top-to-bottom and is not reported. The walk stops at closures, nested fn items, async blocks and loops: if a { spawn(|| { if b { if c { } } }) } and if a { if b { for x in xs { if c { } } } } are silent, and clippy::cognitive_complexity is the gate for those shapes. if let and let ... else chains are out of scope — every participating if must have a plain boolean condition, and deeply-nested-if-let covers the extraction pyramid. A four-deep nest reports twice, once per eligible outer if, which is intentional: each report names a different if you can flatten. #[cfg(test)] mod is skipped only when the #[cfg(test)] sits in the attribute run directly above the mod.

deeply-nested-if-let

hint Rust
What it catches

Three if let checks nested through their success branches, forming an extraction pyramid.

How the match works

The mirror image of deeply-nested-if: each of the three levels must be an if_expression that has a condition of kind let_condition, and each level is reached through the consequence of the one above it. Both descents stop at closure_expression, function_item, async_block, for_expression, while_expression and loop_expression, so a callback or loop body is a separate reasoning scope and while let is never counted as a level. Inline #[cfg(test)] mod is skipped. The rule exists because deeply-nested-if deliberately requires plain boolean conditions, which left a three-deep extraction pyramid reported by nothing in the set.

Why I have it

Three stacked if lets make me hold three half-unwrapped values at once, and the real result ends up buried three blocks deep. ? bails at exactly the point each if let fell through to the enclosing None, so the rewrite is behavior-equivalent only when every fall-through path produced the same value. It stays a hint for that reason: if the middle if let had an else doing real work, keep the work and use let ... else for that arm instead.

Beforefn owner_name(account: Option<&Account>) -> Option<&str> {
            if let Some(account) = account {
                if let Some(owner) = account.owner.as_ref() {
                    if let Some(name) = owner.display_name.as_deref() {
                        return Some(name);
                    }
                }
            }
            None
        }
Afterfn owner_name(account: Option<&Account>) -> Option<&str> {
            let account = account?;
            let owner = account.owner.as_ref()?;
            owner.display_name.as_deref()
        }
Documented limitsNesting is counted only through the consequence branch, so an else if let ladder is not reported. Every participating if must have a let condition; a mixed if let / plain if nest is out of scope here, and deeply-nested-if covers the all-boolean case. The walk stops at closures, nested fn items, async blocks and loops, and while let is a loop rather than an extraction pyramid. A four-deep nest reports twice, once per eligible outer if let, so each report names a different level you can flatten. #[cfg(test)] mod is skipped only when the #[cfg(test)] sits in the attribute run directly above the mod.

deref-polymorphism

hint Rust
What it catches

An impl Deref on a non-generic domain type, where the wrapper is faking inheritance instead of holding a value.

How the match works

Matches an impl_item whose trait field matches the regex ^(?:::)?(?:(?:std|core)::ops::|ops::)?Deref$, so a bare Deref, a qualified std::ops::Deref or core::ops::Deref, and the absolute path ::std::ops::Deref all hit. It matches Deref only, never DerefMut: a valid DerefMut impl requires Deref on the same type, so matching both reported every mutable wrapper twice for one design decision. A not clause then drops impls whose type_parameters contain a type_parameter or a const_parameter, since a generic wrapper is usually a real smart pointer, while lifetime-only impls stay in scope — impl<'a> Deref for Session<'a> is the textbook borrow-holding domain wrapper. This is the one rule here with no #[cfg(test)] clause; only the path ignores apply.

Why I have it

Deref on a domain type makes readers chase methods that are not declared on it — Admin "inherits" every User method, and &Admin coerces to &User whether or not that was intended. Explicit composition puts the reachable operations back in the type's own impl block. It stays a hint because newtype and smart-pointer Deref are legitimate, so treat a finding as a review trigger. Note the pair below is not a drop-in swap: forwarded calls preserve their computation, but removing all other method resolution and coercion is an intentional API (application programming interface — the surface other code calls) change. Every resulting compile error is a place where a reader could not have told which type owned the method; forward the ones you meant to expose, delete the calls you did not, and if you genuinely need the coercion, keep the impl and suppress the hint.

Beforestruct Admin { user: User }
        impl Deref for Admin {
            type Target = User;
            fn deref(&self) -> &User { &self.user }
        }
        impl DerefMut for Admin {
            fn deref_mut(&mut self) -> &mut User { &mut 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 limitsOnly Deref is matched, not DerefMut, because a correct DerefMut impl requires Deref on the same type — fixing the Deref hit means removing the DerefMut impl with it. The trait is matched textually, so an aliased import (use std::ops::Deref as D; impl D for Admin) evades the rule. Impls with type or const generics (impl<T> Deref for Wrapper<T>, impl<const N: usize> Deref for Arr<N>) are skipped on purpose; lifetime-only impls are checked.

factory-factory-naming

warning Rust
What it catches

A declaration named with a doubled abstraction noun: AccountManagerManager, TransferProviderProvider, handler_handler.

How the match works

A case-insensitive regex over an enumerated list of 24 doubled nouns, each written as X_?X so one alternative covers CamelCase (ManagerManager), snake_case (manager_manager) and SCREAMING_SNAKE (MANAGER_MANAGER). The list has to be enumerated because the Rust regex crate ast-grep uses has no backreferences, so a generic ([A-Z][a-z]+)\1 detector cannot be written. The match is then anchored to declaration sites: a type_identifier inside the name field of a struct_item, enum_item, union_item, trait_item or type_item, or an identifier inside the name field of a function_item, mod_item, const_item, static_item or enum_variant. Matching bare identifiers made one bad name emit a finding at every usage — the use path, the impl header, each parameter, the return type, every constructor call — which flooded a commit blocker with duplicates pointing at code where nothing was wrong. Inline #[cfg(test)] mod is skipped, since test modules name throwaway helpers.

rule (excerpt)rule:
          all:
            - regex: "(?i)(factory_?factory|provider_?provider|manager_?manager|...)"
            # Declaration sites only: bare identifiers emitted one finding per usage.
            - any:
                - kind: type_identifier
                  inside:
                    field: name
                    any:
                      - kind: struct_item
                      - kind: enum_item
                      - kind: union_item
                      - kind: trait_item
                      - kind: type_item
                - kind: identifier
                  inside:
                    field: name
                    any:
                      - kind: function_item
                      - kind: mod_item
                      - kind: const_item
                      - kind: static_item
                      - kind: enum_variant
Why I have it

Stacked abstraction nouns force me to decode layers that usually do not exist in the code. One concrete noun is the whole fix — same fields, same types. Separately, if the inner type carries no state at all, delete it and expose a free function (fn make_transfer(..) -> Transfer), but that is a real design change, not the rename this rule asks for. Audits of about 90k LOC of my first-party Rust found zero hits: this is cheap insurance, not a frequent finding, so do not read silence as "not running".

Beforestruct AccountManagerManager { accounts: HashMap<u64, Account> }
        struct TransferProviderProvider { ledger: Ledger }
Afterstruct AccountRegistry { accounts: HashMap<u64, Account> }
        struct TransferSource { ledger: Ledger }
Documented limitsThe noun list is enumerated, not inferred; add nouns to the alternation as you meet them. Declarations only — struct, enum, union, trait, type, fn, mod, const, static, enum variants — so a doubled name used as a field name, a local binding, or a macro-generated type is not reported. That is why BuilderBuilder is safe to keep in the list even in crates that use derive_builder: generated types never appear in the source text. #[cfg(test)] mod is skipped only when the #[cfg(test)] sits in the attribute run directly above the mod.

god-struct-many-fields

off Rust
What it catches

A struct with 13 or more fields, braced or tuple. Ships at severity: off; see the severity system above.

How the match works

A struct_item under an any of two shapes. A braced struct needs a field_declaration_list holding a field_declaration at nthChild position 13, counted with ofRule: kind: field_declaration. A tuple struct needs an ordered_field_declaration_list whose 13th child is counted under an ofRule that excludes visibility_modifier, attribute_item, line_comment and block_comment — a bare nthChild counts those too, which made a 7-field pub struct T(/* id */ pub u64, ...) look like 13+. A closing not: follows skips clap CLI (Command Line Interface — the argument surface a program parses out of the shell) definitions: the attribute run directly above the struct is read with (?s)derive\(.*(?:Parser|Args|Subcommand), and the (?s) is load-bearing, since . does not cross newlines and a rustfmt-split multiline #[derive(...)] was otherwise not exempted while the same derive on one line was. Serialize, Deserialize and FromRow are deliberately not exempt; checked-in generated code is excluded by path instead (**/schema.rs, **/*.pb.rs, **/generated/**).

rule (excerpt)# tuple struct: 13th positional field
        - has:
            kind: ordered_field_declaration_list
            has:
              nthChild:
                position: 13
                ofRule:
                  not:
                    any:
                      - kind: visibility_modifier
                      - kind: attribute_item
                      - kind: line_comment
                      - kind: block_comment
Why I have it

Working memory holds about four chunks, and a struct with many flat fields asks a reader to carry all of them at once. I keep this as a generous review trigger rather than a cap: a large struct that is one coherent deep module — a simple interface over complex hidden state — is fine, and I do not split one just to hit a number. Because it ships off, it is absent from the default gates and I turn it on for a single pass: sg scan --warning=god-struct-many-fields src/ for an advisory read, --error= when I want a nonzero exit.

Beforepub struct Account {
            id: u64,
            owner_id: u64,
            balance: i64,
            available_balance: i64,
            pending_balance: i64,
            credit_limit: i64,
            debit_limit: i64,
            frozen: bool,
            kyc_verified: bool,
            daily_transfer_limit: i64,
            monthly_transfer_limit: i64,
            last_transfer_at: u64,
            overdraft_count: u32,
        }
Afterpub struct Balances { total: i64, available: i64, pending: i64 }
        pub struct TransferLimits { credit: i64, debit: i64, daily: i64, monthly: i64 }
        pub struct AccountStatus { frozen: bool, kyc_verified: bool, overdraft_count: u32 }

        pub struct Account {
            id: u64,
            owner_id: u64,
            balances: Balances,
            limits: TransferLimits,
            status: AccountStatus,
            last_transfer_at: u64,
        }
Documented limitsIt counts fields, not chunks: a 13-field struct whose fields are already value objects (database: DatabaseConfig, llm: LlmConfig, ...) still fires, even though that shape is already the after state — read the hit and dismiss it. Only clap Parser / Args / Subcommand derives are skipped, and only when they sit in the attribute run directly above the struct. Serializable structs are not skipped, because being serializable does not stop a struct accreting responsibilities; a hand-written wire struct you cannot split is a hit to dismiss. Enum variants are not counted, so a 15-field enum E { V { .. } } is silent. Tuple structs count the same as braced ones — at this size a tuple struct is worse, since the reader has only positions to go on.

large-tuple-return

hint Rust
What it catches

A function whose return type is a tuple with four or more elements.

How the match works

A function_item whose return_type field is a tuple_type containing an element at nthChild position 4. Both the matched element and the ofRule that does the counting require regex: ".+" and exclude line_comment and block_comment: comments are named children, so a bare nthChild: 4 read a rustfmt-style commented 3-tuple as six elements, and the .+ keeps empty and anonymous nodes out of the count. A closing not: inside skips any mod_item that follows an attribute run matching cfg\(test\), so inline unit tests are not counted as production reading load.

Why I have it

Four positional slots make every caller memorize positions, and any two same-typed neighbours can be swapped without the compiler noticing — in (Money, Money, bool, UserId), .0 and .1 are interchangeable to the compiler and indistinguishable to the reader. A named record costs one struct and turns that swap into a type error.

Beforefn snapshot(a: &Account) -> (Money, Money, bool, UserId) {
            (a.total, a.pending, a.frozen, a.owner_id)
        }
Afterstruct AccountSnapshot {
            total: Money,
            pending: Money,
            frozen: bool,
            owner_id: UserId,
        }

        fn snapshot(a: &Account) -> AccountSnapshot {
            AccountSnapshot {
                total: a.total,
                pending: a.pending,
                frozen: a.frozen,
                owner_id: a.owner_id,
            }
        }
Documented limitsThe threshold is 4; pairs and triples are left alone, because (T, bool) and (usize, usize, usize) read fine. Only the return type node is inspected, so a tuple wrapped in another type (-> Result<(A, B, C, D), E>, -> Option<(A, B, C, D)>) is not reported even though the caller still faces four positions. Type aliases hide the shape: type Snapshot = (A, B, C, D); used as the return type is silent, because the signature names one type. The #[cfg(test)] mod ... skip works in any attribute order, but only when the #[cfg(test)] sits in the attribute run directly above the mod.

long-else-if-chain

hint Rust
What it catches

An else-if ladder with four or more conditions.

How the match works

An if_expression whose alternative else_clause holds an if_expression, three times over — one if plus three else if. Three rungs read fine and stay silent. A not: inside: kind: else_clause reports the head of the ladder only: every else if is itself an if_expression whose immediate parent is an else_clause, so that single clause turns a six-rung ladder into one finding. The same cfg(test) module skip as its neighbours applies — a not: inside any mod_item that follows a #[cfg(test)] attribute run.

Why I have it

By the last rung the reader is carrying every failed condition above it: "not Vip, not Domestic, not Partner" is what the Expedited arm actually means. A match gives each case its own standing, and the compiler can check exhaustiveness once the wildcard goes. A ladder is not always wrong, though — match only replaces it cleanly when the rungs test the same subject. Rungs testing unrelated things (if a.is_empty() { .. } else if b > limit { .. }) stay a ladder, and I name the conditions instead so each rung reads as one predicate.

Beforefn fee(tier: Tier) -> u8 {
            if tier == Tier::Vip {
                0
            } else if tier == Tier::Domestic {
                2
            } else if tier == Tier::Partner {
                3
            } else if tier == Tier::Expedited {
                8
            } else {
                5
            }
        }
Afterfn fee(tier: Tier) -> u8 {
            match tier {
                Tier::Vip => 0,
                Tier::Domestic => 2,
                Tier::Partner => 3,
                Tier::Expedited => 8,
                _ => 5,
            }
        }
Documented limitsThe threshold is 4 rungs; a 3-rung ladder is silent. Only the head of a ladder is reported, so a 6-rung chain is one finding. Sequential guard clauses (if .. { return } four times in a row) are not a ladder and are not reported — each one leaves the scope. else if let ladders count the same as boolean ones. The #[cfg(test)] mod ... skip works in any attribute order, but only when the #[cfg(test)] sits in the attribute run directly above the mod.

magic-comparison-literal

off Rust
What it catches

An == or != comparison against an integer literal other than zero or one. Ships at severity: off; see the severity system above.

How the match works

A binary_expression whose operator field matches ^(==|!=)$ and which holds a literal reachable through nothing but parentheses and a leading -. Every descent uses stopBy set to not: kind: parenthesized_expression, which is what keeps foo(42) == x and x == y + 42 out. Two branches sit under an any: a positive integer_literal whose text does not match the zero/one carve-out, and a negative unary_expression starting with - whose literal is not a spelling of zero — splitting them is what lets 1 stay exempt while -1 fires. The carve-out is matched against the literal's text, so it has to tolerate radix prefixes, leading zeros, underscores and integer suffixes; a bare ^(0|1)$ silently failed on 0u8, 1usize, 0x0, 0b0 and 1_u32. A final not: has drops comparisons against a .len() call_expression, parentheses included.

rule (excerpt)# positive literal through parentheses; 0 and 1 are exempt
        - has:
            stopBy:
              not:
                kind: parenthesized_expression
            kind: integer_literal
            not:
              regex: "^(0[xXoObB])?0*[01]_*([iu](8|16|32|64|128|size))?$"
Why I have it

if response.status == 418 makes the reader memorize a number-to-meaning map that appears nowhere in the source; a named const carries the meaning right at the comparison. The .len() carve-out is not cosmetic — an audit of roughly 90k LOC (Lines Of Code) found arity checks like parts.len() == 2 and hex.len() != 6 were about half the hits, and they have no mapping to memorize. Turning a kind: u8 field into an enum is the better end state, but that is a domain migration rather than a rename — u8 can hold values the enum cannot, and the field's decoding changes with it — so I do it as its own change with pinned discriminants.

Beforestruct Account { kind: u8, /* ... */ }

        fn check(account: &Account, response: &Response) {
            if account.kind == 3 { freeze(account); }        // what is 3?
            if response.status == 418 { ban_user(); }        // the reader must memorize 418
        }
Afterconst SAVINGS_KIND: u8 = 3;
        const TEAPOT_BANNED: u16 = 418;

        struct Account { kind: u8, /* ... */ }

        fn check(account: &Account, response: &Response) {
            if account.kind == SAVINGS_KIND { freeze(account); }
            if response.status == TEAPOT_BANNED { ban_user(); }
        }
Documented limitsEquality and inequality only (==, !=); ordered comparisons (x > 100), floats, chars and match patterns are out of scope. 0 and 1 are excluded in every spelling (0u8, 1usize, 0x0, 0b0, 1_u32, 00), but negative literals are read on their own: x == -1 does fire, because a negative one is usually a sentinel, and only -0 is exempt. .len() comparisons are excluded. HTTP (HyperText Transfer Protocol — the request/response protocol behind the web) status literals such as response.status != 200 still fire; they sit at the edge of what anyone would bother naming, so dismiss them if you disagree. Only a literal directly under the comparison, through - and parentheses, is seen — x == y + 42 and x == SIZE * 42 are not reported.

nested-match-pyramid

hint Rust
What it catches

Three match expressions nested inside one execution scope, usually an Option/Result pyramid.

How the match works

A match_expression whose body match_block has a match_arm containing a second match_expression, whose block has an arm containing a third. The inner lookups are subtree searches, not field: value: requiring the inner match to be the arm's value meant the rule only fired on one-line expression arms, which is almost never, since rustfmt writes a real pyramid with block arms. Every descent carries stopBy over closure_expression, function_item and async_block, so a two-level match inside an iterator adapter or a spawned future does not read as a three-deep pyramid. A not: inside with the same execution boundaries reports the topmost match only, which is why a four-deep nest is one finding and a pyramid living inside a closure is still reported on its own. The usual cfg(test) module skip applies.

Why I have it

Every arm the reader holds open is a chunk, and a three-level pyramid holds all of them at once. ? flattens it and preserves the ordering that matters — it short-circuits at the first failure exactly where the None and Err arms did. It stays at hint because not every nested match is a pyramid: state machines and protocol decoders nest legitimately, so I read the hit before flattening.

Beforefn debit(
            accounts: &HashMap<u64, Account>,
            id: u64,
            amount: u64,
            kyc: &Kyc,
        ) -> Result<u64, Error> {
            match accounts.get(&id) {
                Some(account) => match account.balance.checked_sub(amount) {
                    Some(new_balance) => match kyc.verify(&account.user) {
                        Ok(()) => Ok(new_balance),
                        Err(e) => Err(e),
                    },
                    None => Err(Error::Insufficient),
                },
                None => Err(Error::NoAccount),
            }
        }
Afterfn debit(
            accounts: &HashMap<u64, Account>,
            id: u64,
            amount: u64,
            kyc: &Kyc,
        ) -> Result<u64, Error> {
            let account = accounts.get(&id).ok_or(Error::NoAccount)?;
            let new_balance = account.balance.checked_sub(amount).ok_or(Error::Insufficient)?;
            kyc.verify(&account.user)?;
            Ok(new_balance)
        }
Documented limitsmatch only. A three-deep if let pyramid is not reported here, and deeply-nested-if skips let conditions too, so nothing in this rule set covers it — flatten those with ?, ok_or or let ... else on your own recognizance. The walk stops at closures, nested fn items and async blocks, so a two-level match inside an iterator adapter body is silent; that body is a separate reasoning scope. Only the topmost match of a pyramid is reported, so a four-deep nest gives one finding, not two. Nesting through the error-side arms counts the same as through the happy arm — the rule measures depth, not which branch you took. The #[cfg(test)] mod ... skip works in any attribute order, but only when the #[cfg(test)] sits in the attribute run directly above the mod.

opaque-bool-call

hint Rust
What it catches

A bare true or false passed as a direct argument to a multi-argument call whose callee name does not explain the flag.

How the match works

A boolean_literal inside an arguments node with no stopBy, so the bool must be a direct argument — one nested deeper is already labelled by whatever encloses it: a struct field initialiser, a closure return, a vec! or tuple element, a map entry. The argument list must have a second argument (nthChild: position: 2, counted under an ofRule that excludes comment nodes, so only_one(true /* allow overdraft */) is not read as two arguments), because positional ambiguity needs at least two slots. Then the enclosing call_expression's function field must not match a single-line allowlist regex covering three families: Option/Result and iterator combinators (Some, Ok, Err, then, then_some, unwrap_or*, map_or*, is_some_and, is_ok_and, filter, fold, try_fold), writes where the bool is the datum (store, swap, set, insert, replace, fetch_or, fetch_and, fetch_xor, compare_exchange*), and self-labeling setters (set_*, with_*, enable_*, disable_*, allow_*, deny_*). That regex has to stay on one physical line — a folded YAML scalar would turn the line breaks into spaces inside the alternation and break it. The usual cfg(test) module skip closes the rule.

rule (excerpt)kind: boolean_literal
        all:
          - inside:
              kind: arguments
              all:
                - has:
                    nthChild:
                      position: 2
                      ofRule:
                        not:
                          any:
                            - kind: line_comment
                            - kind: block_comment
Why I have it

transfer(from, to, 100, true, false) tells the reader nothing; they have to open the callee to learn what the two flags mean, which is extraneous cognitive load with no payoff. A two-variant enum read at the call site is self-descriptive. It stays at hint because a bool argument sometimes genuinely is the value being passed rather than a mode flag, and the rule cannot tell the difference — I escalate it to warning per project once that codebase is clean, not before.

Beforefn transfer(
            from: &Account,
            to: &Account,
            amount: i64,
            allow_overdraft: bool,
            notify: bool,
        ) -> bool {
            let covered = allow_overdraft || from.balance >= amount;
            if covered && notify {
                send_receipt(to);
            }
            covered && !to.frozen
        }

        fn run(from: &Account, to: &Account) -> bool {
            transfer(from, to, 100, true, false)
        }
After#[derive(Clone, Copy)]
        enum Overdraft { Allow, Forbid }
        #[derive(Clone, Copy)]
        enum Notify { Send, Skip }

        fn transfer(
            from: &Account,
            to: &Account,
            amount: i64,
            overdraft: Overdraft,
            notify: Notify,
        ) -> bool {
            let covered = matches!(overdraft, Overdraft::Allow) || from.balance >= amount;
            if covered && matches!(notify, Notify::Send) {
                send_receipt(to);
            }
            covered && !to.frozen
        }

        fn run(from: &Account, to: &Account) -> bool {
            transfer(from, to, 100, Overdraft::Allow, Notify::Skip)
        }
Documented limitsOnly direct arguments count, so run(Config { verbose: true }), .map(|_| true), takes(vec![true, false]) and HashMap::from([("a", true)]) are silent — the field name, closure or collection already labels the bool. Single-argument calls are exempt, which keeps builder setters (.read(true), .truncate(false)) and Mutex::new(false) / AtomicBool::new(true) quiet; Client::new(true, false) is not exempt and does fire. Atomic and container writes are allowlisted, as are self-labeling setters: set_enabled(user_id, true) already names the flag in the callee, so there is nothing to look up. Macros are out of scope by construction — assert_eq!(x, true) parses as a token tree, not a call, so no allowlist entry is needed or possible. The #[cfg(test)] mod ... skip works in any attribute order, but only when the #[cfg(test)] sits in the attribute run directly above the mod.

too-many-fn-params

off Rust
What it catches

A function or method taking seven or more parameters, not counting self. Ships at severity: off; see the severity system above.

How the match works

A function_item whose parameters field holds a parameter at nthChild position 7, counted with ofRule: kind: parameter. Nothing else guards it: self is a distinct tree-sitter kind (self_parameter, not parameter), so it drops out of the count for free and a method fires at seven non-self parameters. There is no cfg(test) clause on this one — only the shared ignores globs for tests, benches, examples, vendor, target and generated code.

Why I have it

Seven positional slots is more than a caller can hold, and same-typed neighbours can be swapped with the compiler saying nothing. I keep this alongside clippy::too_many_arguments for two reasons: it starts one argument earlier (clippy fires at 8+, blocking under -D warnings), and ast-grep does not honour #[allow(...)], so a function that was silenced instead of fixed still surfaces for a human or a review agent. The before example is exactly that shape — a 14-argument function wearing #[allow(clippy::too_many_arguments)], with user_id and session_id both Uuid and adjacent. The threshold is generous on purpose: a builder's with_* chain or an FFI (Foreign Function Interface — the glue layer that calls into non-Rust code) shim can legitimately be wide, so I split only when the arguments form coherent clusters.

Before#[allow(clippy::too_many_arguments)]
        pub async fn run_agent_streaming(
            pool: PgPool,
            user_id: Uuid,
            session_id: Uuid,
            prompt: String,
            images: Vec<Image>,
            model: &str,
            max_tokens: u32,
            temperature: f32,
            tools: Vec<Tool>,
            cost_sink: CostSink,
            tx: Sender<Event>,
            timezone: Tz,
            entitlement: Tier,
            trace_id: String,
        ) -> Result<Summary, Error> { /* body unchanged */ }
Afterpub struct AgentRequest {
            pub prompt: String,
            pub images: Vec<Image>,
            pub timezone: Tz,
        }
        pub struct ModelParams<'a> {
            pub model: &'a str,
            pub max_tokens: u32,
            pub temperature: f32,
            pub tools: Vec<Tool>,
        }
        pub struct SessionCtx {
            pub pool: PgPool,
            pub user_id: Uuid,
            pub session_id: Uuid,
            pub entitlement: Tier,
            pub trace_id: String,
        }

        pub async fn run_agent_streaming(
            ctx: SessionCtx,
            req: AgentRequest,
            params: ModelParams<'_>,
            cost_sink: CostSink,
            tx: Sender<Event>,
        ) -> Result<Summary, Error> { /* body unchanged */ }
Documented limitsself is a distinct tree-sitter kind (self_parameter, not parameter), so methods correctly get self for free. The count is syntactic, so a function taking one 7-field struct reads as one parameter here even though the caller still has to fill seven slots; that is the intended trade, since the slots are now named.

Imperative → functional

Forty-two rules covering four shapes: hand-rolled loops that want to be iterator chains, verbose Option/Result handling, error handling that drops failures on the floor, and error plumbing that throws away a typed cause. The severity mix leans hint and warning after a noise audit against a production Rust corpus — only unwrap-in-prod ships as an error, and await-in-loop and untyped-json-return ship off. Grouped by the shape they target.

Loop → combinator · 16 rules

continue-filter-loop

hint Rust
What it catches

A for loop that opens with a guard that continues, skipping the rest of the body for some elements.

How the match works

Four for $X in $ITER { .. } alternatives sit under an any: the guard spelled with continue; and with a semicolon-less continue, each either as the loop's first statement or preceded by exactly one let binding. A $$$HEAD wildcard used to stand in for those leading statements and swept up loops whose leading work was effectful (tick(&x); if bad(&x) { continue; }), which .filter() would drop. Four not clauses follow: the continue may not be the body's last statement, found via nthChild with position: 1 and reverse: true (nothing after the guard means nothing to turn into a combinator); no continue_expression may carry a label, since continue 'outer exits a different loop; no if_expression may hold both a continue and an else_clause; and no try_expression, break_expression, return_expression, yield_expression or await_expression may appear anywhere in the body. Constraints finish the job: COND may not be a let_condition or let_chain, and X must be a plain identifier, which excludes for mut x in items while leaving destructuring patterns alone.

rule (excerpt)rule:
          all:
            - any:
                - pattern: |
                    for $X in $ITER {
                      if $COND {
                        continue;
                      }
                      $$$REST
                    }
                # three more spellings: bare `continue`, and each of the
                # two preceded by exactly one `let` binding
            # Nothing after the guard means there is nothing to turn into a combinator.
            - not:
                has:
                  field: body
                  has:
                    kind: expression_statement
                    nthChild:
                      position: 1
                      reverse: true
                    has:
                      kind: continue_expression
                      stopBy: end
Why I have it

The early continue is the imperative spelling of filter. Once the guard moves into .filter(), the rest of the body usually collapses into map, for_each, or another combinator, and a single leading let moves into the chain as a .map(). I ship it as a hint because in a scan of roughly 90k LOC (lines of code — raw source lines, a rough size proxy) of real code, every hit was a long loop body where the guard-and-continue read better than a combinator chain.

Beforefn keep(x: &i32) -> bool {
            *x > 0
        }
        fn f(x: i32) -> i32 {
            x * 2
        }

        fn doubled(items: Vec<i32>) -> Vec<i32> {
            let mut out = Vec::new();
            for x in items {
                if !keep(&x) {
                    continue;
                }
                out.push(f(x));
            }
            out
        }
Afterfn doubled(items: Vec<i32>) -> Vec<i32> {
            items.into_iter().filter(|x| keep(x)).map(|x| f(x)).collect()
        }
Documented limitsA labeled loop ('outer: for x in ..) never fires, even when its own guard uses a bare continue: the label is a child of the for-expression and the for $X in $ITER pattern cannot reach past it. The guard must be the loop's first statement or follow exactly one let binding — any other leading statement means the rewrite would drop work that ran for every element, so the rule stays quiet rather than guessing which statements are pure. A body containing ?, break, return, yield or .await anywhere is skipped, including when those sit in a nested loop that could have been left alone; return in particular has no .filter() form, because a closure returns from itself, not from the enclosing function. for mut x in .. is skipped, since .filter() only ever sees &Item. if let and let-chain guards are a different rewrite and are out of scope.

filter-in-loop

warning Rust
What it catches

A for loop whose last statement is an if that does nothing but push into a vec.

How the match works

One pattern carries the shape — for $X in $ITER { $$$HEAD if $COND { $VEC.push($EXPR); } } — so the if must be the loop's last statement and the push its only statement. Five not clauses keep it honest. An if_expression that both pushes to $VEC and has an else_clause is .partition(), not .filter(). Any body-level expression_statement that is not an if means $$$HEAD caught effectful work, so only let bindings may precede the guard. An await_expression anywhere means the rewrite needs a Stream, not an iterator chain. The condition may not read $VEC (if out.len() < 10), and neither may the value of a leading let_declaration (let n = out.len();) — both fail to borrow-check against the collect/extend that is writing through the same destination. Constraints add three more: COND may not be a let_condition or let_chain (those belong to the if-let-*-push-loop rules), VEC may not be a call_expression (that excludes map.entry(k).or_default().push(v)), and EXPR may not read $VEC.

Why I have it

Of the imperative loop shapes I lint for, this is the one that most often hides a dropped element, so I keep it at warning rather than hint even though a custom type with an inherent push can match — the VEC constraint already removes the grouping-loop class. The rewrite is .filter().map().collect(), or .filter_map() when the test and the transform are one step, or .partition() when an else arm pushes into a second vec.

Beforefn keep(x: &i32) -> bool {
            *x > 0
        }
        fn expr(x: i32) -> i32 {
            x * 2
        }

        fn doubled(items: Vec<i32>) -> Vec<i32> {
            let mut out = Vec::new();
            for x in items {
                if keep(&x) {
                    out.push(expr(x));
                }
            }
            out
        }
Afterfn doubled(items: Vec<i32>) -> Vec<i32> {
            items.into_iter().filter(|x| keep(x)).map(expr).collect()
        }
Documented limitsThe push must be the only statement inside the if — a log line before it silences the rule — and the if must be the last statement of the loop body. Statements before the guard are allowed only when they are let bindings that do not read the destination; an effectful statement there means the rewrite would drop work, so the rule stays quiet. It also stays quiet whenever the loop reads its accumulator, in the predicate, in a leading binding, or in the pushed value, and whenever the body .awaits.

for-range-len

hint Rust
What it catches

A for loop that walks a ..v.len() range and then indexes v[i] in its body.

How the match works

Two alternatives under any: the plain form for $I in $RANGE { .. } and the reversed form for $I in ($REV).rev() { .. }. Each requires the loop's value field to be, or to contain, a range_expression holding $V.len(), which covers every $START..$V.len() $OP $N spelling, inclusive or not. Each then requires a $V[$I] somewhere in the body, and each carries its own not against $V[$I - $N] and $V[$I + $N]: an adjacent-index dependency has no iterator form, because the borrow checker forbids reading one element while writing another through the same iterator. Those sub-conditions must stay nested inside each all arm — any does not backtrack, so hoisting them into a top-level constraints block would make the second alternative unreachable.

Why I have it

An index loop makes the reader check the bounds by hand; the iterator form carries the bound in its type. Hint-only, because clippy::needless_range_loop (style, default-on) already covers most of this and runs earlier in the same pre-commit hook, and what it misses is usually a deliberate index algorithm. Two behavior changes are worth knowing before rewriting: use .skip(start) and not &v[start..] for the offset form, since the loop runs zero iterations when start > v.len() while the slice panics; and v.windows(2) yields nothing on an empty slice where 0..v.len() - 1 panics with a usize underflow. The AFTER below will in turn draw a mut-fold-accumulator hint pointing at v.iter().sum() — that is the next step of the same cleanup, not a contradiction.

Beforefn sum(v: &[i32]) -> i32 {
            let mut total = 0;
            for i in 0..v.len() {
                total += v[i];
            }
            total
        }
Afterfn sum(v: &[i32]) -> i32 {
            let mut total = 0;
            for x in v {
                total += *x;
            }
            total
        }
Documented limitsThe rule fires only when the body indexes the same value with the loop variable and never indexes a neighbour of it. A body containing v[i - 1] or v[i + 1] is skipped outright — those are the index algorithms worth keeping, and reporting them produced the majority of this rule's real-world false positives. Types whose only accessor is index-shaped (archive.by_index(i), v.get(i)) are skipped deliberately, since they have no iterator to switch to. for _ in 0..v.len() never fires, because _ is not a binding.

hashmap-entry-grouping-loop

hint Rust
What it catches

A for loop that groups values into a map with entry(k).or_default().push(v), or the or_insert / or_insert_with spellings of the same line.

How the match works

A for $X in $ITER { $$$BODY } pattern paired with a has on the body field that matches one of the three entry spellings as a direct statement of the loop. It is a has-based body match rather than a whole-body pattern for two reasons: the key is often bound on a preceding line, and a $$$ metavariable placed directly before a metavariable-headed statement makes the pattern parse as a tree-sitter ERROR node, which then matches only when the $$$ binds nothing. The PUSH constraint is the regex ^(push|insert)$, so only collection-growing calls count and set-valued grouping (.or_default().insert(item)) is included.

rule (excerpt)rule:
          all:
            - pattern: |
                for $X in $ITER {
                  $$$BODY
                }
            - has:
                field: body
                has:
                  any:
                    - pattern: $MAP.entry($KEY).or_default().$PUSH($VAL);
                    - pattern: $MAP.entry($KEY).or_insert($INIT).$PUSH($VAL);
                    - pattern: $MAP.entry($KEY).or_insert_with($INIT).$PUSH($VAL);
        constraints:
          PUSH:
            regex: '^(push|insert)$'
Why I have it

This one reports a shape without prescribing a fix, because the imperative grouping loop is idiomatic Rust and usually the most readable form. I still want to see it for two reasons. First, map.entry(k).or_insert(Vec::new()).push(v) evaluates its argument on every iteration, allocating a Vec it immediately drops on every occupied hit; or_default() or or_insert_with(|| make(k)) fixes that with the same map out and one fewer allocation. Second, when the surrounding code is already an iterator chain, fold removes the let mut and the rebinding, and itertools' into_group_map / into_group_map_by does it in one call. Otherwise I keep the loop: trading three lines for a closure that must thread and return the accumulator is ceremony, not safety and not speed.

Beforeuse std::collections::HashMap;

        fn group(items: Vec<(String, u32)>) -> HashMap<String, Vec<u32>> {
            let mut grouped = HashMap::new();
            for (key, value) in items {
                grouped.entry(key).or_default().push(value);
            }
            grouped
        }
Afteruse std::collections::HashMap;

        fn group(items: Vec<(String, u32)>) -> HashMap<String, Vec<u32>> {
            items.into_iter().fold(HashMap::new(), |mut grouped, (key, value)| {
                grouped.entry(key).or_default().push(value);
                grouped
            })
        }
Documented limitsThe entry line must be a direct statement of the loop body, so a grouping call nested inside an if is not reported. Counter loops (*map.entry(k).or_insert(0) += 1;) are a different idiom and are deliberately out of scope. Hint-only, with a deliberately non-prescriptive message: without type information the rule cannot tell an eager or_insert(Vec::new()) from a cheap or_insert(0).

if-let-ok-push-loop

warning Rust
What it catches

A for loop that pushes the payload of a matching if let Ok(y) = .. into a vec and silently discards every Err.

How the match works

One tight pattern: for $X in $ITER { if let Ok($Y) = $EXPR { $VEC.push($Y); } }. The $VEC.push($Y) back-references the bound payload, so pushing a transform of it (out.push(format!("{x}-{y}"))) does not fire, and neither does a nested pattern such as Ok(Some(y)), which needs .ok().flatten() instead. A not: has: kind: else_clause with stopBy: end is required rather than optional, because ast-grep matches an else-less if pattern against an if that has an else; matching on the node kind also catches else if chains that a } else { $$$ } pattern would miss. The EXPR constraint rejects a scrutinee that reads the destination (out.pop().ok_or(())), which cannot move into the filter_map closure while extend holds out mutably borrowed (E0502).

Why I have it

The matched loop drops every Err without a trace, which is why this stays a warning while its Option sibling stays a hint. The direct rewrite is .filter_map(|x| f(x).ok()); when the errors matter, log them on the way past with .inspect_err(|e| warn!(%e)).ok() (needs Rust 1.76+, and the template pins stable). Expect silent-filter-map-ok to report the plain rewrite — correctly, since the loop it replaced was already swallowing errors silently. The .inspect_err(..) form is what clears both rules.

Beforefn parse(x: i32) -> Result<i32, ()> {
            if x > 0 { Ok(x * 2) } else { Err(()) }
        }

        fn doubled(items: Vec<i32>) -> Vec<i32> {
            let mut out = Vec::new();
            for x in items {
                if let Ok(y) = parse(x) {
                    out.push(y);
                }
            }
            out
        }
Afterfn doubled(items: Vec<i32>) -> Vec<i32> {
            items.into_iter().filter_map(|x| parse(x).ok()).collect()
        }
Documented limitsThe rule stays quiet when the if let has an else arm, since .filter_map() would drop that work — keep the loop, or split it with .partition_result(). It also stays quiet when the push is not the bound payload itself, when the if body holds any statement besides the push, and when the scrutinee reads the destination vec.

if-let-some-push-loop

hint Rust
What it catches

A for loop that pushes the payload of a matching if let Some(y) = .. into a vec.

How the match works

The Option twin of if-let-ok-push-loop, built the same way: for $X in $ITER { if let Some($Y) = $EXPR { $VEC.push($Y); } }, where $VEC.push($Y) back-references the bound payload. Pushing a combination (out.push(x + y)) or a nested pattern (Some(Ok(y))) therefore does not fire, and the same back-reference also drops the loop-invariant-scrutinee and read-the-accumulator shapes, which do not borrow-check as a chain. A not: has: kind: else_clause with stopBy: end excludes the else arm explicitly — ast-grep would otherwise match an else-less if pattern against an if that has one, and the rule would contradict its own note; the node kind also covers else if chains. The EXPR constraint rejects a scrutinee that reads the destination (out.pop(), out.last()), since extend holds out mutably borrowed while the iterator runs (E0502).

Why I have it

if let Some(y) = f(x) { out.push(y) } is filter_map written out longhand, and the chain reads shorter without losing anything. Hint-only: this is Option plumbing, not an error-handling defect, which is the one thing that separates it from the Result sibling if-let-ok-push-loop at warning.

Beforefn half(x: i32) -> Option<i32> {
            if x % 2 == 0 { Some(x / 2) } else { None }
        }

        fn halves(items: Vec<i32>) -> Vec<i32> {
            let mut out = Vec::new();
            for x in items {
                if let Some(y) = half(x) {
                    out.push(y);
                }
            }
            out
        }
Afterfn halves(items: Vec<i32>) -> Vec<i32> {
            items.into_iter().filter_map(half).collect()
        }
Documented limitsThe rule stays quiet when the if let has an else arm, since both Some and None are then handled and the explicit loop is the right shape. It is also quiet when the push is not the bound payload itself, when the if body holds any statement besides the push, and when the scrutinee reads the vec being pushed into.

manual-all-loop

hint Rust
What it catches

A let mut ok = true; flag that a loop clears and breaks out of — Iterator::all written by hand.

How the match works

The match anchors on an expression_statement that has the pattern for $X in $ITER { if $COND { $FLAG = false; break; } }. One branch is enough for both polarities, since $COND binds a negated condition just as happily as a plain one. That statement must then follow a let mut $FLAG = true; declaration, or its type-annotated spelling, with stopBy set to not: any: [line_comment, block_comment] — comments between the declaration and the loop are fine, an executable statement is not. An earlier stopBy: end crossed arbitrary statements, so a read or a reassignment of the flag between the two lines still reported and the combinator rewrite would have dropped it. A final not: inside: clause skips anything within an inline #[cfg(test)] mod tests; the attribute regex also accepts cfg(all(test, ..)) and cfg(any(test, ..)), tolerates one extra attribute or a comment between the attribute and the mod, and deliberately does not match cfg(not(test)), which gates production code.

rule (excerpt)# Comments between the declaration and the loop are fine; an executable
        # statement is not. `stopBy: end` crossed arbitrary statements, so a read
        # or a reassignment of the flag between the two lines still reported.
        - follows:
            stopBy:
              not:
                any:
                  - kind: line_comment
                  - kind: block_comment
            any:
              - pattern: let mut $FLAG = true;
              - pattern: "let mut $FLAG: bool = true;"
Why I have it

.all() names what the loop is doing, drops the mutable flag, and short-circuits on the first failure exactly as the break did — same signature, same result for every input. The inverse shape folds into the same rewrite by flipping the predicate: if bad(x) { ok = false; break; } becomes .all(|x| !bad(x)). One thing to watch on the way across: Iterator::all takes FnMut(Self::Item) -> bool, the item by value, unlike find, which hands you a reference — over items.iter() the item already is &i32, but over an owning iterator you need pred(&x). I keep it at hint because clippy already catches the standard form and the shape never appeared once in a ~90k LOC (lines of code) scan of real code.

Beforefn all_positive(items: &[i32]) -> bool {
            let mut ok = true;
            for x in items {
                if !pred(x) {
                    ok = false;
                    break;
                }
            }
            ok
        }
Afterfn all_positive(items: &[i32]) -> bool {
            items.iter().all(|x| pred(x))
        }
Documented limitsOnly the tight shape is detected. The if must be the loop's only statement and the assignment must be followed by a plain break — a labelled break 'outer, a no-break variant, or any extra statement in the body hides the match. The let mut flag declaration must be the loop's immediate predecessor in the same block; comments in between are fine, any executable statement is not, because reading or reassigning the flag before the loop means .all() would drop work.

manual-any-loop

hint Rust
What it catches

A let mut found = false; flag that a loop sets and breaks out of — Iterator::any written by hand.

How the match works

Identical machinery to manual-all-loop with the polarity flipped. An expression_statement must has the pattern for $X in $ITER { if $COND { $FLAG = true; break; } } and must follow a let mut $FLAG = false; declaration, or its type-annotated spelling, with stopBy set to not: any: [line_comment, block_comment] so that comments may sit between the declaration and the loop but executable statements may not — a plain stopBy: end crossed arbitrary statements, and a read or reassignment of the flag in between still reported even though the rewrite would have dropped it. The same not: inside: clause skips inline #[cfg(test)] mod tests, including cfg(all(test, ..)) and cfg(any(test, ..)), tolerating one extra attribute or a comment before the mod, and deliberately not matching cfg(not(test)).

Why I have it

.any() states the intent directly and short-circuits on the first match exactly as the break did, with the same signature and the same result for every input. Same closure caveat as its sibling: Iterator::any takes FnMut(Self::Item) -> bool, the item by value, unlike find, which hands you a reference — over items.iter() the item already is &i32, but an owning iterator needs pred(&x), and a Vec or slice has to go through .iter() / .into_iter() first, which the for loop did implicitly. Hint-only: clippy already catches the standard form, and the shape never appeared once in a ~90k LOC (lines of code) scan of real code.

Beforefn has_positive(items: &[i32]) -> bool {
            let mut found = false;
            for x in items {
                if pred(x) {
                    found = true;
                    break;
                }
            }
            found
        }
Afterfn has_positive(items: &[i32]) -> bool {
            items.iter().any(|x| pred(x))
        }
Documented limitsOnly the tight shape is detected. The if must be the loop's only statement and the assignment must be followed by a plain break — a labelled break 'outer, a no-break variant, or any extra statement in the body hides the match. The let mut flag declaration must be the loop's immediate predecessor in the same block; comments in between are fine, any executable statement is not, because reading or reassigning the flag before the loop means .any() would drop work.

manual-find-loop

hint Rust
What it catches

A for loop that stores the first matching element in a let mut ... = None flag declared just above it and then breaks — a hand-written .find().

How the match works

Matches an expression_statement that has the whole-loop pattern for $X in $ITER { if $COND { $FOUND = Some($VAL); break; } }, so the if has to be the loop's only statement and the assignment has to be followed by a plain break. The loop must follows a let mut $FOUND = None; declaration in either the bare or the type-annotated spelling, and the stopBy is not a line_comment or block_comment — comments between the declaration and the loop are tolerated, any executable statement between them halts the traversal and the rule stays quiet. $X is constrained to an identifier and $VAL to $X, $X.$FIELD or $X.clone(), so a loop that parks an unrelated constant in the flag gets no .find(...) advice. A not: inside: mod_item clause with a cfg(test) regex skips inline test modules (also cfg(all(test, ..)) and cfg(any(test, ..)), tolerating one extra attribute or a comment before the mod); it deliberately does not match cfg(not(test)), which gates production code.

rule (excerpt)- follows:
            stopBy:
              not:
                any:
                  - kind: line_comment
                  - kind: block_comment
            any:
              - pattern: let mut $FOUND = None;
              - pattern: "let mut $FOUND: Option<$T> = None;"
        constraints:
          VAL:
            any:
              - pattern: $X
              - pattern: $X.$FIELD
              - pattern: $X.clone()
Why I have it

.find() short-circuits on the first match exactly like the break did, so the rewrite keeps the signature and the result for every input and drops the mutable flag. The detail worth knowing is the argument mode: Iterator::find takes FnMut(&Self::Item) -> bool, a reference, unlike all/any/position, which pass the item by value — that is why the loop's pred(&x) becomes pred(x) in the closure. When the loop stored a projection rather than the item, the answer is .find(..).map(..) or .find_map(..), not a bare .find(). It ships at hint because the shape never appeared once in a ~90k LOC (lines of code) scan of real code.

Beforefn first_positive(items: Vec<i32>) -> Option<i32> {
            let mut found = None;
            for x in items {
                if pred(&x) {
                    found = Some(x);
                    break;
                }
            }
            found
        }
Afterfn first_positive(items: Vec<i32>) -> Option<i32> {
            items.into_iter().find(|x| pred(x))
        }
Documented limitsOnly the tight shape is detected. A labelled break 'outer, a no-break variant, or any extra statement in the loop body hides the match. The stored value must be the loop variable, a field of it, or a clone of it; anything else is not a .find(). The let mut declaration must be the loop's immediate predecessor in the same block — comments in between are fine, an executable statement is not, because reading or reassigning the accumulator before the loop means .find() would drop work.

manual-position-loop

hint Rust
What it catches

A for loop over .enumerate() that stores the index of the first match in a let mut ... = None flag and breaks — a hand-written .position().

How the match works

Matches an expression_statement that has the whole-loop pattern for ($I, $X) in $ITER { if $COND { $FOUND = Some($I); break; } }. The load-bearing guard is the constraint on $ITER, which must match $BASE.enumerate(): without it the rule fires on any tuple-destructured loop, and over a HashMap the first binding is a key rather than an index, so .position() would be wrong advice — those shapes belong to manual-find-loop. Same comment-tolerant follows on let mut $FOUND = None; (bare or type-annotated) as manual-find-loop, with the stopBy set to not a line_comment or block_comment, and the same cfg(test) module skip.

Why I have it

.position() short-circuits on the first match exactly like the break did, and it counts for you, so the .enumerate() goes away with the flag. It takes FnMut(Self::Item) -> bool, the item by value — the opposite of find — so over items.iter() the closure still receives &i32 and pred(x) is right, while over an owning iterator you would need pred(&x). Use .rposition() if you walk back-to-front. Hint-only: like the find shape, this never appeared once in a ~90k LOC (lines of code) scan of real code.

Beforefn first_positive_index(items: &[i32]) -> Option<usize> {
            let mut idx = None;
            for (i, x) in items.iter().enumerate() {
                if pred(x) {
                    idx = Some(i);
                    break;
                }
            }
            idx
        }
Afterfn first_positive_index(items: &[i32]) -> Option<usize> {
            items.iter().position(|x| pred(x))
        }
Documented limitsThe loop must iterate something.enumerate(); a tuple loop over a map or over pairs binds a key, not an index, and is manual-find-loop territory. The if must be the loop's only statement with a plain break, so labelled breaks, no-break variants and extra statements are all missed. A hand-rolled counter (let mut i = 0; ... i += 1;) is not recognised. The let mut declaration must be the loop's immediate predecessor in the same block, comments aside.

map-collect-loop

hint Rust
What it catches

A for loop whose entire body is one push onto a collection.

How the match works

Matches an expression_statement that has the pattern for $X in $ITER { $VEC.push($EXPR); }. Three guards narrow it. A not: has: stopBy: end kind: await_expression clause drops loops whose pushed expression awaits, since there is no async closure for Iterator::map on stable and the suggested rewrite would not compile. $VEC is constrained to not be a call_expression, which excludes chained receivers such as map.entry(k).or_default().push(v) — those bucket into a HashMap instead of appending to one Vec. $EXPR must neither be nor contain $VEC, because out.push(out.len()) would need a closure that reads the collection collect() is filling. Finally a not: follows: clause, with the same comment-tolerant stopBy used elsewhere in this set, steps aside for any preceding Vec::with_capacity(..) declaration in all four spellings (plain, typed let, turbofish, both), so a pre-sized push loop never collects two diagnostics with two different messages.

Why I have it

The loop and items.into_iter().map(..).collect() return the same Vec in the same order; the mutable binding is bookkeeping the iterator already does. When the destination exists and must keep its current contents, the honest rewrite is out.extend(items.into_iter().map(..)) rather than a rebind, and a fallible body collects into Result<_, _> and keeps the ?. It stays at hint because without type information the receiver of .push(..) could be any type with an inherent push, which makes this a spelling change rather than a correctness fix.

Beforefn double_all(items: Vec<i32>) -> Vec<i32> {
            let mut out = Vec::new();
            for x in items {
                out.push(x * 2);
            }
            out
        }
Afterfn double_all(items: Vec<i32>) -> Vec<i32> {
            items.into_iter().map(|x| x * 2).collect()
        }
Documented limitsLoops whose pushed expression contains .await are skipped, as no correct rewrite exists on stable. A push that reads the destination (out.push(out.len())) is skipped: the closure cannot borrow the collection collect() is filling (rustc error E0502). Any preceding Vec::with_capacity(..) declaration silences this rule in all four spellings and across comments; when the capacity was measured from the iterated source with-capacity-push-loop reports instead, and when it was picked by hand neither rule reports, because collect() will not reproduce it. The filter-then-push variant never matches here — the loop body is an if, not a push — and belongs to filter-in-loop.

map-collect-loop-with-let

hint Rust
What it catches

A for loop whose body is exactly one let binding followed by one push.

How the match works

An any of two whole-loop patterns covers the bare let $Y = $LET_EXPR; and the annotated let $Y: $T = $LET_EXPR; spellings, each followed by $VEC.push($PUSH); and nothing else. A not: has: stopBy: end clause rejects any await_expression or try_expression in the body. $VEC must not be a call_expression. $PUSH must be or contain $Y, so a let that exists for its side effect is left alone — hoisting it into a lazy closure would change when it runs and when it drops — and it must not be or contain $VEC; $LET_EXPR carries the same no-$VEC constraint one line up. The cfg(test) module skip is the same one used by manual-find-loop.

Why I have it

This is the map-collect-loop shape with one intermediate binding: the let moves inside the closure and the let mut out line goes away, with the same elements in the same order. I trust it less than the plain version, because the body may rely on a side effect sitting between the let and the push, so it ships as a hint and asks to be skimmed before accepting. The .await/? exclusion is empirical rather than theoretical: in a ~90k LOC (lines of code) scan every hit of that shape was a false positive.

Beforefn build(items: Vec<i32>) -> Vec<i32> {
            let mut out = Vec::new();
            for x in items {
                let y = transform(x);
                out.push(use_y(y));
            }
            out
        }
Afterfn build(items: Vec<i32>) -> Vec<i32> {
            items
                .into_iter()
                .map(|x| {
                    let y = transform(x);
                    use_y(y)
                })
                .collect()
        }
Documented limitsLoop bodies containing .await or ? are skipped — the closure changes the desugaring and the rewrite does not carry over. The pushed expression must mention the let binding, so a body whose let is a RAII (Resource Acquisition Is Initialization — the idiom where a guard object does work in its destructor) guard such as let _guard = lock(); is not reported. Neither the binding nor the pushed value may read the destination Vec: let n = out.len(); out.push(n); cannot become out.extend(..), because extend holds out mutably borrowed while the closure runs (rustc error E0502). Exactly two statements, let then push; a filter wrapped around them is a different shape and is not reported here.

match-ok-push-loop

warning Rust
What it catches

A for loop whose only statement is a match that pushes the Ok payload and silently discards the Err.

How the match works

Two exact whole-loop patterns, one per arm order, that back-reference the Ok payload. Only the comma-less spelling of each arm list is listed: ast-grep treats the arm separator as an unnamed node, so the comma-less pattern matches both Ok(y) => out.push(y), and rustfmt's block-bodied arm, while the comma-ful pattern would match strictly fewer shapes. A not: has: kind: match_arm clause reaching the condition field of a match_pattern excludes guarded arms, since a bare metavariable in pattern position matches Ok(y) if cond too and a guard is a filter, not a filter_map. The constraints do the rest: $OKBODY must be $VEC.push($Y) or a block containing it with no second expression_statement, $ERRPAT is regex-limited to Err(_), Err(..) or _, and $ERRBODY to an empty block, () or continue.

rule (excerpt)constraints:
          OKBODY:
            any:
              - pattern: $VEC.push($Y)
              - all:
                  - kind: block
                  - has: { pattern: "$VEC.push($Y);" }
                  - not: { has: { kind: expression_statement, nthChild: 2 } }
          ERRPAT:
            regex: "^(Err\\(_\\)|Err\\(\\.\\.\\)|_)$"
          ERRBODY:
            any:
              - { kind: block, regex: "^\\{\\s*\\}$" }
              - { kind: unit_expression }
              - { kind: continue_expression }
Why I have it

The loop drops errors silently and so does the .filter_map(|x| f(x).ok()) it becomes, so the rewrite changes spelling, not behaviour. It earns a warning because the four-line match disguises the discard: once it is a single .ok(), silent-filter-map-ok can see it and ask whether you meant to log on the way past (.inspect_err(|e| warn!(%e, "skipped"))) or to stop dropping errors at all (.collect::<Result<_, _>>()?). An earlier relational version of this rule — "some arm contains a push" — was tried and removed: it also reported Ok(y) => out.push(0), Ok(y) => { audit(y); out.push(y); } and loops doing more work after the match, none of which .filter_map(..) reproduces.

Beforefn parse_all(items: Vec<String>) -> Vec<i32> {
            let mut out = Vec::new();
            for x in items {
                match x.parse::<i32>() {
                    Ok(y) => out.push(y),
                    Err(_) => {}
                }
            }
            out
        }
Afterfn parse_all(items: Vec<String>) -> Vec<i32> {
            items
                .into_iter()
                .filter_map(|x| x.parse::<i32>().ok())
                .collect()
        }
Documented limitsThe match must be the loop body's only statement; work after it is not reported, because the plain .filter_map(..) rewrite would drop it. The Ok arm must push the bound payload and nothing else, so a constant (out.push(0)), a transform (out.push(g(y))) and an extra effect are all skipped. The Err arm must be Err(_), Err(..) or _ with a body of {}, () or continue — anything else is doing work. A third arm or a guarded arm is not reported, because .filter_map() cannot express either. Nested payload patterns (Ok(Some(y))) are not reported: .ok() alone yields Option<Option<_>> there, so the advice would be wrong.

match-some-push-loop

hint Rust
What it catches

A for loop whose only statement is a match that pushes the Some payload and ignores None.

How the match works

The Option twin of match-ok-push-loop: two exact whole-loop patterns, one per arm order, back-referencing the Some payload, in the comma-less arm spelling that also covers rustfmt's block-bodied arm. Guarded arms are excluded by a not: has: kind: match_arm clause that reaches the condition field of the match_pattern, because Some(y) if cond is a filter rather than a filter_map and a bare metavariable would otherwise match it. $SOMEBODY must be $VEC.push($Y) or a block containing it with no second expression_statement, $NONEPAT is regex-limited to None or _, and $NONEBODY to an empty block, () or continue.

Why I have it

Same collapse as the Ok version, one level down: the match is doing what .filter_map(..) already means. When the loop calls a named function and pushes the binding unchanged, the closure is redundant too — items.into_iter().filter_map(f).collect(). It sits at hint rather than warning because this is Option plumbing rather than an error-handling defect, and the shape did not occur once in a ~90k LOC (lines of code) scan of real code. It is the same shape as if-let-some-push-loop, written with match.

Beforefn doubled(items: Vec<i32>) -> Vec<i32> {
            let mut out = Vec::new();
            for x in items {
                match x.checked_mul(2) {
                    Some(y) => out.push(y),
                    None => {}
                }
            }
            out
        }
Afterfn doubled(items: Vec<i32>) -> Vec<i32> {
            items.into_iter().filter_map(|x| x.checked_mul(2)).collect()
        }
Documented limitsThe match must be the loop body's only statement; work after it is not reported, because the plain rewrite would drop it. The Some arm must push the bound payload and nothing else, so a constant, a transform (out.push(g(y))) and an extra effect are all skipped. The None arm must be None or _ with a body of {}, () or continue. A third arm or a guarded arm is not reported, because .filter_map() cannot express either. Nested payload patterns (Some(Ok(y))) are not reported: those need .and_then(|r| r.ok()), not a bare .filter_map().

mut-fold-accumulator

hint Rust
What it catches

A for loop whose only statement folds each item into a let mut accumulator declared on the line above.

How the match works

Matches an expression_statement that has one of eight whole-loop patterns: the compound assignments $ACC += $RHS, -= and *=; their expanded forms $ACC = $ACC + $RHS, - and *; the method form $ACC = $ACC.$OP($$$ARGS); and the function form $ACC = $FN($ACC, $$$ARGS). The method form is only accepted when $OP matches a fixed regex list of arithmetic and set operators (add, sub, mul, div, pow, min, max, the checked_/saturating_/wrapping_ families, bitor, union, merge and friends); without that constraint it matched every builder re-assignment in a loop, such as query = query.bind(..) or request = request.header(..), which is method chaining rather than accumulation. The loop must follows a let mut $ACC = $INIT; declaration, bare or type-annotated, with the stopBy set to not a line_comment or block_comment.

Why I have it

The mechanical translation of the loop is |mut acc, x| { acc += x; acc }, not |acc, x| acc + x. They agree for i32, but AddAssign and Add are separate traits: a type can implement one and not the other (String += &str compiles, String + String does not), and a type implementing both may implement them differently, so acc + x is a guess about the type. Before reaching for fold, check the three specializations that say it better when they fit — .sum() when the accumulator starts at 0 and you += x, .product() when it starts at 1 and you *= x, and .count() (or just .len()) when it starts at 0 and you += 1. Hint-only for a concrete reason: every hit in a ~90k LOC (lines of code) scan of real code was a builder re-assignment rather than an accumulation.

Beforefn total(items: Vec<i32>) -> i32 {
            let mut acc = 0;
            for x in items {
                acc += x;
            }
            acc
        }
Afterfn total(items: Vec<i32>) -> i32 {
            items.into_iter().fold(0, |mut acc, x| {
                acc += x;
                acc
            })
        }
Documented limitsMethod-form accumulation (acc = acc.op(x)) is only reported for the fixed operator list, so a fold over a domain-specific method is never reported — the price of leaving builder chains alone. The let mut binding must be the loop's immediate predecessor in the same block: comments in between are fine, but any executable statement is not, since a read or a reassignment of the accumulator between the two lines means the fold would not reproduce the loop. An accumulator declared in an outer scope is not reported either.

with-capacity-push-loop

hint Rust
What it catches

A Vec::with_capacity(src.len()) declaration followed by a loop over src that does nothing but push.

How the match works

Matches an expression_statement that has a one-push loop in either block spelling, with and without the trailing semicolon. A not: has: stopBy: end clause rejects any try_expression or await_expression, since ? needs collect::<Result<Vec<_>, _>>() and .await needs a Stream. The loop must follows one of four Vec::with_capacity($SOURCE.len()) declaration spellings — plain, type-annotated, turbofish, and both — because a type annotation, a turbofish or a comment between the let and the loop all defeat a plain follows and those are the common real-world spellings. The pairing is enforced by the $ITER constraint: the loop has to walk something that mentions $SOURCE, so for r in rows, for r in &rows and for r in rows.iter() all qualify while an unrelated capacity does not. $EXPR must not read $V, and a not: inside: clause skips mod, fn and impl items behind a cfg(test)-style attribute.

rule (excerpt)- follows:
            stopBy:
              not:
                any:
                  - kind: line_comment
                  - kind: block_comment
            any:
              - pattern: let mut $V = Vec::with_capacity($SOURCE.len());
              - pattern: "let mut $V: $T = Vec::with_capacity($SOURCE.len());"
              - pattern: "let mut $V = Vec::<$T>::with_capacity($SOURCE.len());"
              - pattern: "let mut $V: $T = Vec::<$T2>::with_capacity($SOURCE.len());"
        constraints:
          ITER:
            any:
              - pattern: $SOURCE
              - has:
                  pattern: $SOURCE
                  stopBy: end
Why I have it

The hand-wired sizing is the usual reason these loops survive a cleanup, and in the paired case it survives the rewrite too: collect() pre-allocates from size_hint(), which is exact for slices, Vec, and any other ExactSizeIterator. It is not a promise, though — FromIterator guarantees the elements, not the capacity, and a filtering adapter in the chain reduces the hint to a lower bound. That is why this is a hint: if a caller reads v.capacity() or you are counting allocations in a benchmark, keep the explicit sizing. When you do rewrite, match whatever the for loop did so ownership is unchanged — .iter() if it borrowed, .into_iter() if it consumed.

Beforefn names(rows: &[Row]) -> Vec<String> {
            let mut v = Vec::with_capacity(rows.len());
            for r in rows {
                v.push(r.name());
            }
            v
        }
Afterfn names(rows: &[Row]) -> Vec<String> {
            rows.iter().map(|r| r.name()).collect()
        }
Documented limitsOnly the paired shape is reported. An unrelated capacity (Vec::with_capacity(cap), Vec::with_capacity(64)) is a deliberate sizing decision that collect() will not reproduce, so it is skipped — and map-collect-loop steps aside for it too, which means a hand-picked capacity draws no diagnostic from either rule. A loop body using ? or .await is skipped. A push that reads the Vec being built (v.push(v.len())) is skipped, because the closure cannot borrow the collection collect() is filling. The body must be exactly the one push: a comment inside the loop body, or a second statement, silences the rule, since patterns match block contents structurally.

Option/Result verbosity · 6 rules

if-let-some-else

hint Rust
What it catches

An if let Some(x) = opt { .. } else { .. } whose two arms each produce a value rather than run statements.

How the match works

One pattern for the two-arm if let Some shape, then five not guards. Anything under it that escapes the expression — break_expression, continue_expression, try_expression, await_expression, yield_expression, searched with stopBy: end — disqualifies it, because no combinator carries those out. A return_expression counts only when it is reachable from the arms without crossing a closure boundary: the search uses stopBy: kind: closure_expression, so traversal halts at a closure. That makes the boundary depend on where the return sits relative to this if, not on whether some ancestor happens to be a closure — the older not: inside: closure_expression spelling had it backwards and reported let f = |o| { if let Some(x) = o { return x } else { 0 } };. A macro_invocation matching ^(panic|unreachable|todo|unimplemented)! is excluded, since else { panic!(..) } wants .expect(..) or let-else. Finally either arm containing an expression_statement — directly under the consequence field, or under a block in the alternative field — is excluded as side-effecting.

rule (excerpt)# A `return` reachable from the arms without crossing a closure boundary
        # exits the enclosing body and blocks the rewrite; one nested inside a
        # closure in an arm returns from that closure and is harmless.
        - not:
            has:
              kind: return_expression
              stopBy:
                kind: closure_expression
Why I have it

The lazy map_or_else rewrite is a straight refactor — same signature, same result, same allocations, because the default is still built only on the None path. I keep it at hint because it is a readability preference, and it was the third-noisiest rule in the catalog on real code: 147 hits across ~90k LOC (lines of code). Most of those arms read better as an if let than as a combinator. The eager opt.map_or(expensive(), ..) form is a different animal — it runs the default on both paths, which is a behavior change, not a refactor.

Beforefn label(opt: Option<i32>) -> String {
            if let Some(x) = opt {
                format!("got {x}")
            } else {
                "none".to_string()
            }
        }
Afterfn label(opt: Option<i32>) -> String {
            opt.map_or_else(|| "none".to_string(), |x| format!("got {x}"))
        }
Documented limitsThe "value arm" test is syntactic. An arm whose tail is a block-shaped expression used as a value (a nested if or match) parses as a statement and is skipped, while an arm that only binds with let before its tail expression still fires. The closure rule cuts both ways: if let Some(x) = opt { call(|| return x) } else { 0 } is reported, and the same if/else placed inside a closure is not.

is-err-then-unwrap-err

warning Rust
What it catches

An if r.is_err() branch that then reaches back for r.unwrap_err() or r.expect_err(..) on the same binding.

How the match works

An any of two condition patterns, if $RES.is_err() { $$$ } and if !$RES.is_ok() { $$$ }, paired with an any of four has probes at stopBy: end: $RES.unwrap_err(), $RES.expect_err($$$), and the .as_ref() forms of both. Unlike its Ok and Some siblings, those probes are not scoped to the consequence field — they search the whole if_expression. A constraints block pins $RES to kind: identifier, so the receiver must be a plain binding: if make().is_err() { make().unwrap_err() } calls the fallible thing twice, and collapsing that is a behavior change, not a refactor. A not clause scoped to consequence drops branches that reassign or shadow the receiver — $RES = $NEW, let $RES = $NEW;, let mut $RES = $NEW;, let $RES: $NT = $NEW; — because there the unwrap_err() no longer inspects the value is_err() tested.

Why I have it

is_err() plus unwrap_err() inspects the Result twice and re-panics if the two checks ever drift apart — a later refactor that changes the condition turns a logged error into a panic. if let Err(e) binds once and cannot panic. expect_err("checked") is the same defect with a nicer message. When the block only propagates, I reach for res? or res.map_err(..)? instead. This is the Err sibling of is-ok-then-unwrap and is-some-then-unwrap.

Beforefn error_len(result: Result<u32, String>) -> Option<usize> {
            if result.is_err() {
                Some(result.unwrap_err().len())
            } else {
                None
            }
        }
Afterfn error_len(result: Result<u32, String>) -> Option<usize> {
            if let Err(error) = result {
                Some(error.len())
            } else {
                None
            }
        }
Documented limitsThe receiver must be a plain identifier, and a branch that reassigns or shadows it is skipped. An unwrap_err() inside a macro argument (tracing::error!("{}", res.unwrap_err())) is invisible, because tree-sitter parses macro bodies as opaque token trees; a textual token-tree match was tried and removed, since it could not back-reference the receiver and reported unwraps on unrelated variables and even on string contents. Pair it with clippy::unnecessary_unwrap, which is default-on, type-aware, and runs earlier in the same pre-commit hook.

is-ok-then-unwrap

warning Rust
What it catches

An if r.is_ok() branch that then calls r.unwrap() or r.expect(..) on the same binding.

How the match works

Only the bare if $RES.is_ok() { $$$ } form is matched. The conjunction variants were removed: if res.is_ok() && x cannot become if let Ok(v) = res on its own, because x has to stay nested or turn into a let-chain, and the rule cannot pick which. The unwrap probe is scoped to the consequence field with stopBy: end and accepts $RES.unwrap(), $RES.expect($$$), $RES.as_ref().unwrap(), $RES.as_mut().unwrap(), $RES.as_ref().expect($$$); an unwrap in the else arm is a different shape that if let Ok does not fix, and .clone().unwrap() is left out on purpose because neither direction of that rewrite is straight. A constraints block pins $RES to kind: identifier. A not clause drops branches that reassign or shadow the receiver. Finally a not: inside guard skips inline #[cfg(test)] mod tests — matching cfg(all(test, ..)) and cfg(any(test, ..)) too, tolerating one extra attribute or a comment between the attribute and the mod, and deliberately not matching cfg(not(test)), which gates production code.

Why I have it

The value gets bound once instead of tested and then re-unwrapped, which deletes the panic site. Ownership costs nothing: both forms move res on the Ok path, since unwrap() takes self by value. If the original borrowed with res.as_ref().unwrap() and I still need res afterwards, I keep borrowing — if let Ok(v) = res.as_ref() binds v: &T and leaves res intact. The else arm is not dead either; res is still usable there.

Beforefn doubled(result: Result<u32, String>) -> Option<u32> {
            if result.is_ok() {
                Some(result.unwrap().saturating_mul(2))
            } else {
                None
            }
        }
Afterfn doubled(result: Result<u32, String>) -> Option<u32> {
            if let Ok(value) = result {
                Some(value.saturating_mul(2))
            } else {
                None
            }
        }
Documented limitsThe receiver must be a plain identifier, and a branch that reassigns or shadows it is skipped. Conjunctions such as if res.is_ok() && x are not recognised, .clone().unwrap() is not reported, and an unwrap in the else branch is intentionally out of scope. An unwrap inside a macro argument (println!("{}", res.unwrap())) is invisible, since tree-sitter parses macro bodies as unparsed token trees. clippy::unnecessary_unwrap is default-on and runs earlier in the pre-commit hook, so treat this as a backstop.

is-some-then-unwrap

warning Rust
What it catches

An if x.is_some() or if !x.is_none() branch that then calls x.unwrap() or x.expect(..) on the same binding.

How the match works

An any of the two bare tests, if $OPT.is_some() { $$$ } and if !$OPT.is_none() { $$$ }. Conjunctions were removed for the same reason as the Result twin — if let Some(v) = opt alone drops the second operand. The unwrap probe is scoped to the consequence field with stopBy: end: $OPT.unwrap(), $OPT.expect($$$), $OPT.as_ref().unwrap(), $OPT.as_mut().unwrap(), $OPT.take().unwrap(), $OPT.as_ref().expect($$$). The .take() form stays in because it has an exact rewrite, if let Some(v) = opt.take(); .clone().unwrap() is out because it leaves the original usable and the rewrite does not. $OPT is constrained to kind: identifier, a not clause drops branches that reassign or shadow the receiver, and a not: inside guard skips inline #[cfg(test)] mod tests (including cfg(all(test, ..)) / cfg(any(test, ..)), tolerating one intervening attribute or comment, and never cfg(not(test))).

Why I have it

Binding once instead of testing and re-unwrapping deletes the panic site, with the same signature and the same return value for every input. Both forms move opt on the Some path, so the rewrite costs nothing the original kept; if the original borrowed with opt.as_ref().unwrap(), if let Some(v) = opt.as_ref() keeps opt intact. The rewritten code will in turn draw an if-let-some-else hint pointing at map_or_else — that is the next step of the same cleanup, not a contradiction. Bind once first, then decide whether a combinator reads better.

Beforefn doubled(value: Option<u32>) -> Option<u32> {
            if value.is_some() {
                Some(value.unwrap().saturating_mul(2))
            } else {
                None
            }
        }
Afterfn doubled(value: Option<u32>) -> Option<u32> {
            if let Some(value) = value {
                Some(value.saturating_mul(2))
            } else {
                None
            }
        }
Documented limitsThe receiver must be a plain identifier, and a branch that reassigns or shadows it is skipped — .take() is the one exception, and it has its own exact rewrite. Only if opt.is_some() and if !opt.is_none() are recognised; conjunctions are not. .clone().unwrap() is not reported, an unwrap in the else branch is intentionally out of scope, and an unwrap inside a macro argument is invisible to tree-sitter. clippy::unnecessary_unwrap usually fires first.

match-option-verbose

hint Rust
What it catches

A two-arm match on an Option where both arms simply produce a value.

How the match works

Three patterns cover the arm orderings: Some($X) then None, None then Some($X), and Some($X) then _. They are written without trailing arm commas on purpose — the comma-less spelling matches both layouts, including rustfmt's block-bodied arms (Some(x) => { .. } with no comma) that the comma-ful patterns missed. Four not guards follow. A match sitting inside a for_expression that also has a $PUSH_VEC.push($PUSHED) belongs to match-some-push-loop, so it is excluded rather than reported twice with different advice. An arm whose value is a block containing a second statement (pattern: $STMT2, nthChild: 2) is doing work, not producing a value; single-statement and empty blocks stay. Arms containing try_expression, await_expression, break_expression, continue_expression, or return_expression are excluded — that check is scoped to the arms, so ? or .await in the scrutinee is still fine. And a guarded arm, a match_pattern with a condition field, is a filter rather than a map, so it is skipped.

Why I have it

The combinator form says the same thing with less ceremony, and the lazy spelling keeps it a pure refactor. I ship it at hint rather than warning because an explicit two-arm Option match is often clearer than a combinator, and the rule produced 79 hits on a maintained-crate corpus plus 75 on a ~90k LOC (lines of code) private scan with no clear defect behind any of them. "Both arms are pure" is not enough to justify the eager form either — the fallback in opt.map_or(expensive(), |x| ..) runs on the Some path too.

Beforefn score(value: Option<u32>) -> u32 {
            match value {
                Some(value) => value.saturating_mul(2),
                None => 7,
            }
        }
Afterfn score(value: Option<u32>) -> u32 {
            value.map_or_else(|| 7, |value| value.saturating_mul(2))
        }
Documented limitsArms whose body is a multi-statement block are not reported — those are running side effects — though single-statement blocks and {} still are. Arms containing ?, .await, break, continue, or return are not reported, since no combinator closure carries those out of the match; the same constructs in the scrutinee are fine. Guarded arms and matches with three or more arms are skipped, as is a match inside a for loop whose arm pushes onto a Vec, which match-some-push-loop owns.

match-result-verbose

hint Rust
What it catches

A two-arm match on a Result where both arms simply produce a value.

How the match works

Matched relationally rather than by literal token layout: a match_expression whose body is a match_block that has one match_arm whose match_pattern matches ^Ok\b and another matching ^(Err\b|_$). The comma-less trick that works for match-option-verbose does not work here, because $OK_BODY Err($E) parses as a binary expression, and the comma-ful patterns missed rustfmt's block-bodied arms, Err-first orderings, and every arm list without a trailing comma on the last arm. Exactly two arms are required, enforced as not: has: { kind: match_arm, nthChild: 3 }. Guarded arms are excluded. So is the wire-response shape where both arms' value is a struct_expressionmap_or_else is legal there but puts the error closure first and reads worse. The remaining guards mirror the Option rule: the for-loop-plus-push shape goes to match-ok-push-loop, multi-statement block arms are dropped, and arm-scoped ?, .await, break, continue, and return disqualify the match while the same constructs in the scrutinee do not.

rule (excerpt)- has:
            field: body
            kind: match_block
            all:
              - has:
                  kind: match_arm
                  has: { field: pattern, kind: match_pattern, regex: "^Ok\\b" }
              - has:
                  kind: match_arm
                  has: { field: pattern, kind: match_pattern, regex: "^(Err\\b|_$)" }
              # exactly two arms — a third arm is doing something no combinator does
              - not: { has: { kind: match_arm, nthChild: 3 } }
Why I have it

Same signature, same value for every input — only the spelling changes, and the lazy form keeps it honest. Hint rather than warning because of what the scans showed: 66 corpus hits and 44 private-scan hits, dominated by panic-recovery and contextual-error code where the explicit match reads better than putting the error closure first. Watch the argument order when you do rewrite: map_or_else takes the error closure first.

Beforefn score(result: Result<u32, u32>) -> u32 {
            match result {
                Ok(value) => value.saturating_mul(2),
                Err(error) => error.saturating_add(100),
            }
        }
Afterfn score(result: Result<u32, u32>) -> u32 {
            result.map_or_else(
                |error| error.saturating_add(100),
                |value| value.saturating_mul(2),
            )
        }
Documented limitsArms whose body is a multi-statement block are not reported; single-statement blocks and {} still are. Arms containing ?, .await, break, continue, or return are not reported, though the same constructs in the scrutinee are fine. Guarded arms and matches with three or more arms are skipped. Matches whose two arms both build a struct literal are skipped as the wire-response shape. A match inside a for loop whose arm pushes onto a Vec is left to match-ok-push-loop.

Silent error handling · 7 rules

let-underscore-call

hint Rust
What it catches

A let _ = ...; that throws away the result of a call or an .await.

How the match works

Two patterns, let _ = $EXPR; and let _: $T = $EXPR;, with EXPR constrained to call_expression or await_expression — a plain value binding such as let _ = guard; is not a match. Two not clauses prune the rest. The statement must not follows a line_comment or block_comment: a comment on the line directly above is the acknowledgement escape hatch, and it silences the rule. And it must not sit inside a mod_item that follows an attribute_item matching cfg(test), cfg(all(test, ..)) or cfg(any(test, ..)), tolerating one extra attribute or a comment between the attribute and the mod. cfg(not(test)) is deliberately not matched — it gates production code.

rule (excerpt)rule:
          all:
            - any:
                - pattern: let _ = $EXPR;
                - pattern: "let _: $T = $EXPR;"
            - not:
                follows:
                  any: [ { kind: line_comment }, { kind: block_comment } ]
        constraints:
          EXPR:
            any: [ { kind: call_expression }, { kind: await_expression } ]
Why I have it

It ships at hint because ast-grep has no type information: it cannot tell a dropped Result from a unit-returning call, an Option, or a bool. In a scan of about 90k LOC (lines of code) of real code the 66 hits were overwhelmingly idiomatic best-effort sends and setup calls, and a production-crate corpus left 24 more standing. I keep it because the cheap fix is the valuable one — the decision to ignore an error gets written down next to the error instead of being implied by an underscore. It also catches a genuine trap: let _ = m.lock(); drops the temporary immediately and releases the guard on that same line, where let _guard = m.lock(); holds it.

Beforefn publish_best_effort(sender: &Sender<u32>, event: u32) {
            let _ = sender.send(event);
        }
Afterfn publish_best_effort(sender: &Sender<u32>, event: u32) {
            if let Err(_ignored) = sender.send(event) {
                // The receiver may already be gone during shutdown.
            }
        }
Documented limitsOnly call_expression and await_expression right-hand sides are reported; let _ = write!(f, "x"); and other macro_invocation forms are excluded on purpose, because including them swept up pure-expression macros such as let _ = matches!(x, Some(_)). let _ = fallible()?; is not reported — the ? already propagates. The acknowledgement comment must sit immediately above the statement: a trailing same-line comment is a separate sibling node that ast-grep cannot tell apart from a comment introducing the next statement, so it does not silence the rule. And cfg(all(feature = "x", test)), with test in trailing position, is not recognised as a test module.

silent-filter-map-ok

warning Rust
What it catches

An iterator step that drops every Err out of the sequence with no trace — .filter_map(Result::ok) and its closure spellings.

How the match works

Six patterns: $EXPR.filter_map(Result::ok), the path-qualified $EXPR.filter_map($$$PATH::Result::ok), and filter_map / flat_map closures in both expression and braced-block form (|$X| $INNER.ok(), |$X| { $INNER.ok() }). Capturing the closure body as $INNER instead of unifying it with the parameter is what makes the common case visible: the shipped |$X| $X.ok() pattern only fired when the iterator item was already a Result, so |s| s.parse().ok() was invisible. The INNER constraint suppresses the report when an .inspect_err(..) sits on the Result being dropped, and only there — a blanket has over the whole chain was tried and removed, because it silenced iter.map(|r| r.inspect_err(..)).filter_map(|r| r.ok()), where the filter_map still drops a different, unlogged Result. EXPR is exempt by regex when the receiver mentions read_dir or WalkDir, and test code is skipped on mod_item, function_item and impl_item, including #[test] and #[tokio::test] functions.

rule (excerpt)rule:
          any:
            - pattern: $EXPR.filter_map(|$X| $INNER.ok())
            - pattern: $EXPR.flat_map(|$X| $INNER.ok())
        constraints:
          INNER:
            not:
              any:
                - pattern: $A.inspect_err($$$IA)
                - has: { stopBy: end, pattern: $A.inspect_err($$$IA) }
Why I have it

A row that fails to parse or deserialize simply vanishes: the collection comes back short and nothing says which row or why. I keep it at warning rather than hint because every match here discards an error, which is a defect shape and not a style preference. Deliberate drops are fine — I want either a comment saying it was a decision, or a line in the log for the rows that got rejected.

Beforefn parse_all(raw: &[String]) -> Vec<i32> {
            raw.iter().filter_map(|r| r.parse().ok()).collect()
        }
Afterfn parse_all(raw: &[String]) -> Vec<i32> {
            raw.iter()
                .filter_map(|r| r.parse().inspect_err(|e| eprintln!("skipped row: {e}")).ok())
                .collect()
        }
Documented limits.inspect_err(..) suppresses the warning only when it sits inside the closure, upstream of the .ok(); an inspect_err elsewhere in the chain does not, because the dropped Result there is a different one. .map_err(..) never suppresses it — converting an error before dropping it is still dropping it. Receiver chains mentioning read_dir or WalkDir are exempt by constraint, because that directory-walk idiom comes straight from the std docs and an unreadable entry mid-iteration is genuinely skippable.

silent-map-err

hint Rust
What it catches

A .map_err(|_| ...) — also |_e| or |_ignored| — that replaces an error without preserving its cause.

How the match works

Patterns $EXPR.map_err(|_| $$$BODY) and $EXPR.map_err(|$IGN| $$$BODY), with IGN constrained to regex: ^_ so only an explicitly ignored binding matches. Three not clauses carve out the cases where nothing is lost. Replacement errors spelled (), fmt::Error or std::fmt::Error cannot carry a cause. A closure_expression containing an interpolating format! rebuilds the context itself; interpolation is detected on the macro_invocation's token_tree with regex: (,|\{\s*[A-Za-z_]), meaning a positional argument or an inline capture, so a bare format!("failed") still reports. And the usual test-code exclusion covers mod_item, function_item and impl_item. EXPR is separately exempt when the receiver text contains timeout(: its Elapsed is a unit struct with no cause to keep, and the duration is already at the call site.

Why I have it

An error stripped of its cause cannot be diagnosed from the log — the reader gets ConfigError::Unreadable and no path, no errno. Where the variant can carry the source, .map_err(MyError::Variant) costs one line; where the enum has to stay as it is, interpolating the failing input into the message is the cheaper fix and also silences the rule. It stays a hint because erasing a cause at an abstraction or a security boundary is often deliberate, and the rule cannot tell that from an oversight.

Beforeenum ConfigError { Unreadable }

        fn load(path: &Path) -> Result<String, ConfigError> {
            fs::read_to_string(path).map_err(|_| ConfigError::Unreadable)
        }
Afterenum ConfigError { Unreadable(std::io::Error) }

        fn load(path: &Path) -> Result<String, ConfigError> {
            fs::read_to_string(path).map_err(ConfigError::Unreadable)
        }
Documented limitsast-grep cannot see inside macro token trees, so tracing::warn!("{}", r.map_err(|_| E)?) is not scanned at all; pair this with clippy. Only an interpolating format! counts as context-preserving — a bare format!("failed") drops the source and tells the reader nothing the call site did not already know. Information-free error types are exempted only when spelled (), fmt::Error, or reached through a timeout(..) receiver; other empty error types still fire and need an #[allow] with a comment. The after example is a small migration, not a rename: adding the source to the variant changes every pattern match on it, its Debug output, and its size.

silent-ok-discard

warning Rust
What it catches

A bare expr.ok(); statement, which turns a Result's error into None and throws it away.

How the match works

The pattern $EXPR.ok() must be inside an expression_statement with stopBy: neighbor, so the .ok() has to be the entire statement rather than one link of a larger expression. The only other clause is the test-code exclusion: not inside a mod_item, function_item or impl_item preceded by a test attribute (#[cfg(test)] and its all / any forms, #[test], #[tokio::test]), stepping over intervening attributes and comments. There is deliberately no write! / writeln! / flush() exemption: with no type information the rule cannot tell an infallible write!(&mut String, ..) from write!(file, ..) or socket.flush(), where the discarded error is the only report that a write was lost.

Why I have it

Every match discards a Result, so this one stays a warning: the failure leaves no trace anywhere, and the next reader cannot tell whether that was considered. If the failure really is ignorable, let _ = tx.send(Msg::Stop); is exactly behavior-equivalent to the before — put the reason on the line directly above it, or the sibling rule let-underscore-call asks for it in turn.

Beforefn shutdown(tx: &Sender<Msg>) {
            tx.send(Msg::Stop).ok();
        }
Afterfn shutdown(tx: &Sender<Msg>) {
            if let Err(e) = tx.send(Msg::Stop) {
                // receiver already dropped during shutdown; nothing to recover
                tracing::debug!(error = %e, "shutdown notify failed");
            }
        }
Documented limitsOnly expr.ok(); in statement position is seen. A tail-position expr.ok() in a -> () function is the same bug and is not reported, because widening to block tails false-positives on the legitimate fn f(s: &str) -> Option<u32> { s.parse().ok() }; clippy's unused_results covers that case. write!(..).ok(), writeln!(..).ok() and flush().ok() are reported by design — when the target really is a String, say so with .expect("writing to a String cannot fail"). With no types, an inherent ok() method of your own that returns () is reported too.

silent-unwrap-or-else

hint Rust
What it catches

A .unwrap_or_else(|_| ...) that throws the error away and returns a fallback, so a failing call looks exactly like a succeeding one.

How the match works

Patterns $EXPR.unwrap_or_else(|_| $$$BODY) and $EXPR.unwrap_or_else(|$IGN| $$$BODY), with IGN constrained to regex: ^_. That constraint is what keeps Option::unwrap_or_else(|| ..) out (no parameter, no error) along with poison recovery such as mutex.lock().unwrap_or_else(|p| p.into_inner()), which names its parameter. Three exclusions follow: a chain already shaped $E2.inspect_err($$$I).unwrap_or_else($$$REST), so the fix the rule prescribes does not report itself; any macro_invocation found inside the match with regex: ^(panic|unreachable|todo|unimplemented)!, because a diverging fallback enforces an invariant rather than returning a default; and the standard test-code exclusion. EXPR is exempt when the receiver ends in try_from_default_env() or from_default_env(), where the Err only means RUST_LOG is unset.

Why I have it

The fallback is returned and nothing is recorded, so the bug is invisible at exactly the moment it matters. It ships at hint because a best-effort fallback is frequently the intended API: the rule produced 62 hits on a roughly 90k LOC (lines of code) private scan, dominated by boilerplate such as EnvFilter::try_from_default_env().unwrap_or_else(..). One .inspect_err(..) in front of the call is the whole fix, and it is also what silences the rule.

Beforefn port() -> Result<u16, ParseIntError> {
            env::var("PORT").unwrap_or_else(|_| "8080".into()).parse()
        }
Afterfn port() -> Result<u16, ParseIntError> {
            env::var("PORT")
                .inspect_err(|e| tracing::warn!(%e, "PORT unset, defaulting to 8080"))
                .unwrap_or_else(|_| "8080".into())
                .parse()
        }
Documented limitsresult.unwrap_or(fallback) swallows the error in exactly the same way and is not reported here, or anywhere else in this rule set: the same shape on an Option is idiomatic and far more common, and ast-grep has no types to separate them. Infallible-in-practice fallbacks such as serde_json::to_string(&x).unwrap_or_else(|_| "{}".into()) still fire. Macro token trees are invisible. Hint-only for the same reason throughout: without types, an intentional default and a swallowed error look identical.

unwrap-in-prod

error Rust
What it catches

A .unwrap() anywhere outside test code.

How the match works

One pattern, $EXPR.unwrap(), and one exclusion: not inside a mod_item, function_item or impl_item that follows an attribute_item matching the test regex — #[cfg(test)], cfg(all(test, ..)), cfg(any(test, ..)), a trailing , test, and #[test] / #[tokio::test]. That follows carries stopBy: not: any: [attribute_item, line_comment, block_comment], so extra attributes or comments between the attribute and the item do not break the skip. cfg(not(test)) deliberately does not match, since it gates production code. On top of the rule, tests/, benches/, examples/, test_utils.rs and build.rs are ignored by path.

Why I have it

An .unwrap() panic names only the file and line, so the operator reads called `Option::unwrap()` on a `None` value and learns nothing about what was missing. Severity stays error on purpose — this is the catalog's one hard gate, and the point is that a fresh .unwrap() cannot reach main without someone typing a reason or an #[allow]. The swap below is not fully behavior-preserving: it panics on exactly the same inputs, but the payload changes, so a catch_unwind, a custom panic hook, or a test asserting on the panic string needs updating with it.

Beforefn bind_addr(raw: &str) -> SocketAddr {
            raw.parse().unwrap()
        }
Afterfn bind_addr(raw: &str) -> SocketAddr {
            raw.parse().expect("BIND_ADDR must be host:port")
        }
Documented limitsast-grep cannot see inside macro token trees, so tracing::info!("{}", x.unwrap()) and format!("{}", x.unwrap()) are not reported; the rule is a floor, not a gate, and pairs with #![cfg_attr(not(test), deny(clippy::unwrap_used))], which is type-aware and macro-aware. write!(&mut string, ..).unwrap() is infallible in practice but sits outside the token tree, so it is reported — spell it .expect("writing to a String cannot fail"). Modules gated on a test-ish feature such as #[cfg(feature = "testing")] are still scanned; only test proper is recognised. With no type information, an inherent unwrap() of your own — on a builder, a newtype, a lock wrapper — is reported too. And the prescribed .expect(..) still panics: this rule makes the panic legible, it does not remove it.

unwrap-or-default

hint Rust
What it catches

A .unwrap_or_default(), or a hand-written equivalent such as .unwrap_or(String::new()), on a receiver that looks fallible.

How the match works

Four patterns — $EXPR.unwrap_or_default(), $EXPR.unwrap_or(Default::default()), $EXPR.unwrap_or(String::new()) and $EXPR.unwrap_or(Vec::new()) — but the discrimination is entirely in the EXPR constraint. The receiver has to look fallible: it is an await_expression or a try_expression, or it has one nested inside it (stopBy: end), or its text matches a regex of conventionally Result-returning calls (parse, try_, from_str, from_value, from_slice, from_reader, read_to_string, decode, deserialize), with an optional turbofish. Two not clauses finish it: the already-logged shape $E2.inspect_err($$$I).unwrap_or_default(), and the test-code exclusion.

rule (excerpt)constraints:
          EXPR:
            any:
              - kind: await_expression
              - kind: try_expression
              - has: { stopBy: end, kind: await_expression }
              - has: { stopBy: end, kind: try_expression }
              - regex: "(parse|try_|from_str|from_value|from_slice|from_reader|read_to_string|decode|deserialize)\\s*(::<[^>]*>)?\\s*\\("
Why I have it

On a Result this hands back "", 0 or vec![], which is indistinguishable from a real success: the caller gets an empty config and carries on. It stays a hint because Option::unwrap_or_default() — "absent means Default" — is idiomatic and hides no error, and with no type information the receiver-shape constraint is the closest approximation I can get to telling the two apart.

Beforefn retries(raw: &str) -> u32 {
            raw.parse().unwrap_or_default()
        }
Afterfn retries(raw: &str) -> u32 {
            raw.parse()
                .inspect_err(|e| tracing::warn!(%e, raw, "bad retry count, using 0"))
                .unwrap_or_default()
        }
Documented limitsIt deliberately does not fire on plain Option receivers (row.status.unwrap_or_default(), map.get(k).copied().unwrap_or_default()), and the trade is real: a fallible receiver whose name gives no hint, such as resp.text().unwrap_or_default(), is missed with them. In the other direction, .await receivers are reported even when the awaited value is an Option rather than a Result — there is no type information to tell them apart. That is the rule's known false-positive class, and hint severity is the price of the trade.

Strings & dispatch · 3 rules

push-str-format

warning Rust
What it catches

Appending to a String through push_str(&format!(..)), which builds a throwaway String first.

How the match works

An any of three spellings: $VAR.push_str(&format!($$$A1)), $VAR.push_str(format!($$$A2).as_str()), and the += form. That last one needs the context/selector spelling — context: "fn __sg_ctx() { $VAR += &format!($$$A3); }" with selector: compound_assignment_expr — because a bare $VAR += &format!($$$) pattern fails to load and would abort the whole scan. A not: inside guard skips code in a mod_item that follows an attribute_item matching cfg\(.*\btest\b, and the ignores globs add **/test_utils.rs on top of the usual tests, benches, examples, vendor, and target paths.

Why I have it

One avoidable heap allocation per append, and write! formats straight into the buffer. It is an optimization of the successful path, not an exact refactor: when every Display impl involved succeeds — the normal case — both forms append the same bytes. They diverge only on failure, because format! builds the whole String first, so an impl that writes half its output and then panics leaves buf untouched, while write! has already appended the partial output. I downgraded this from error, since one allocation is not a correctness bug and at error it hard-failed CI (continuous integration) on in-file test modules. Clippy's equivalent, clippy::format_push_string, is allow-by-default restriction. Never .unwrap() the result here — unwrap-in-prod is severity: error and would fail CI on the replacement.

Beforefn greet(buf: &mut String, name: &str) {
            buf.push_str(&format!("hello {name}\n"));
        }
Afteruse std::fmt::Write;

        fn greet(buf: &mut String, name: &str) {
            let _ = write!(buf, "hello {name}\n");
        }
Documented limits$VAR interpolates in message: but not in note:, so the note uses a concrete buf rather than a metavariable. There is no type information, so a custom type with an inherent push_str is reported even though it may not implement fmt::Write and write! would not compile against it — check the receiver before applying the fix. One-shot string building outside a loop costs a single allocation, and the rule cannot tell hot paths from cold ones, which is part of why it is a warning rather than an error.

stringly-typed-dispatch

hint Rust
What it catches

An if / else if chain that classifies the same string receiver with .contains(), .starts_with(), or .ends_with() against string literals.

How the match works

An if_expression whose condition either is one of the three call patterns or has one at stopBy: end — both spellings are needed, because a child search alone never tests the condition node itself, so a negated or compound condition would slip through. Its else_clause must hold an if_expression testing the same $EXPR with a second literal. Independent receivers were tried and reverted: path.contains("api") followed by name.contains("admin") is two unrelated tests, and no single enum replaces them. $LIT and $LIT2 are constrained to string_literal or raw_string_literal, without which the rule fires on slice.contains(&id) and range chains — the opposite of stringly typed. $EXPR carries not: regex: "(^|_)err(or)?($|_)", word-bounded so it exempts err, error_str, and raw_err while still reporting current and berry; a bare (?i)err substring had suppressed all of those. A not: inside with stopBy: neighbor on else_clause reports a three-or-more-link chain once, at its head. The escape hatch is a not: inside: function_item anchored on the function's name field — not its text, so a comment mentioning fn parse() does not exempt the whole function — covering from_str, from_*, parse, parse_*, try_from, classify*, categorize*, detect*, kind_of, *_kind, *_category, map_*_error, and *_from_error. A final guard skips cfg(test)-gated modules, functions, and impls.

Why I have it

A chain like this makes every caller re-run the string guessing, and nothing tells you when a case is missing. Parsing once into an enum moves the guessing to one place and lets the compiler check the match. I keep it at hint because open-ended substring classification is frequently deliberate: 3 of the 4 hits in a ~90k LOC (lines of code) scan were classifying a third-party string where no upstream enum can exist. Note that impl FromStr is the wrong home for this particular rewrite — FromStr implies exact parsing and would stop matching "claude-opus-4-20250514", which is a behavior change rather than a refactor.

Beforefn pick(name: &str) -> Cost {
            if name.contains("gpt") {
                Cost::Low
            } else if name.contains("opus") {
                Cost::High
            } else {
                Cost::Mid
            }
        }
Afterenum Model {
            Gpt,
            Opus,
            Other,
        }

        impl Model {
            /// Substring match, not exact: ids look like "claude-opus-4-20250514".
            fn from_name(name: &str) -> Self {
                if name.contains("gpt") {
                    Self::Gpt
                } else if name.contains("opus") {
                    Self::Opus
                } else {
                    Self::Other
                }
            }
        }

        fn pick(name: &str) -> Cost {
            match Model::from_name(name) {
                Model::Gpt => Cost::Low,
                Model::Opus => Cost::High,
                Model::Other => Cost::Mid,
            }
        }
Documented limitsThe constructor's name is the escape hatch, so moving the chain into anything outside the exempt list makes the rule fire again. Only the if / else if spelling is detected — the guard-clause form (if name.contains("gpt") { return Cost::Low; } followed by a second if) is the same smell and is not reported, which matters because the cognitive-load rules push code toward early returns. Both links must test the same receiver. Receivers whose name is or contains the word err or error are exempt on purpose, since no enum can exist upstream of someone else's error text. A chain reports once, on its first if, so a nested-but-unrelated chain inside an else { ... } block is still reported on its own.

stringly-typed-match

hint Rust
What it catches

A match on a string scrutinee with two or more string-literal arms, outside a parsing boundary.

How the match works

A match_expression whose value field is $EXPR.as_str(), $EXPR.as_ref(), $EXPR.trim(), &*$EXPR, or &$EXPR[..]. It then requires at least two string-literal arms, counted with precedes rather than nthChild: nthChild counts unnamed children, so a comment between arms would shift every position. The two-arm floor exists because the message's claim about exhaustiveness only bites once there are two literals — a single-literal lookup such as match model.as_str() { "text-embedding-3-large" => 3072, _ => 1536 } is a table over identifiers someone else owns, and it was the rule's largest false-positive class. Escape hatches: a not: inside: impl_item whose regex ^impl(<[^>]*>)?\s+(\w+::)*(FromStr|TryFrom)\b tolerates lifetimes and paths, and a not: inside: function_item anchored on the name field covering from_str, try_from, parse, parse_*, from_*, *_from_str, plus the serde parser boundary deserialize, deserialize_*, visit_str, visit_string, and visit_borrowed_str. A final guard skips cfg(test)-gated modules, functions, and impls.

rule (excerpt)# Counted with `precedes` rather than `nthChild`: nthChild counts unnamed
        # children, so a comment between arms shifts every position.
        - has:
            stopBy: end
            all:
              - kind: match_arm
              - has:
                  field: pattern
                  has: { stopBy: end, kind: string_literal }
              - precedes:
                  stopBy: end
                  all:
                    - kind: match_arm
                    - has:
                        field: pattern
                        has: { stopBy: end, kind: string_literal }
Why I have it

Matching a &str against literal arms gives no exhaustiveness checking: add a case to the domain and the compiler stays quiet while the wildcard arm silently absorbs it. Parsing into an enum once, at the boundary, hands that check back to the compiler. Hint-only, because at a real parsing boundary the string literals are the interface and the rule cannot tell that boundary from a leaked one — 37 hits on a ~90k LOC (lines of code) scan were dominated by hand-rolled CLI (command-line interface) argument parsing and by lookup tables over third-party identifiers that would need an enum edit on every vendor release. In the rewrite, unwrap_or(Level::Warn) reproduces the original _ => 2 arm exactly and avoids the unwrap_or_default() that the sibling rule would flag.

Beforefn threshold(input: String) -> u8 {
            match input.as_str() {
                "debug" => 0,
                "info" => 1,
                _ => 2,
            }
        }
Afterenum Level {
            Debug,
            Info,
            Warn,
        }

        impl std::str::FromStr for Level {
            type Err = ();

            fn from_str(input: &str) -> Result<Self, Self::Err> {
                match input {
                    "debug" => Ok(Self::Debug),
                    "info" => Ok(Self::Info),
                    _ => Err(()),
                }
            }
        }

        fn threshold(input: String) -> u8 {
            match input.parse::<Level>().unwrap_or(Level::Warn) {
                Level::Debug => 0,
                Level::Info => 1,
                Level::Warn => 2,
            }
        }
Documented limitsRecognised scrutinees are .as_str(), .as_ref(), .trim(), &*s, and &s[..]. A plain match s where s: &str is the same smell but is indistinguishable from matching an enum without type information, so it is not reported. At least two string-literal arms are required, so one-literal lookups over foreign identifiers are skipped. impl FromStr / impl TryFrom blocks and the parser-named functions above are exempt, which is why hand-rolled argument parsing belongs in a parse_args-shaped function.

Error plumbing & panics · 6 rules

boxed-dyn-error-return

hint Rust
What it catches

A function whose return type hands back a Box<dyn Error> instead of a named error type.

How the match works

Matches a function_item whose return_type field contains, at any depth (stopBy: end), a generic_type whose type field is Box and which also contains a dynamic_type whose trait is either a type_identifier named Error or a scoped_type_identifier ending in error::Error. The Box is the load-bearing part of the pattern: it is what keeps fn source(&self) -> Option<&(dyn Error + 'static)> — the signature std::error::Error::source is required to have — from being reported as a smell.

rule (excerpt)rule:
          kind: function_item
          has:
            field: return_type
            has:
              stopBy: end
              kind: generic_type          # must be BOXED, not a bare `dyn Error`
              all:
                - has: { field: type, regex: '^Box$' }
                - has:
                    stopBy: end
                    kind: dynamic_type
                    has:
                      field: trait
                      stopBy: end
                      any:
                        - kind: type_identifier
                          regex: '^Error$'
                        - kind: scoped_type_identifier
                          regex: '(std::)?error::Error$'
Why I have it

Box<dyn Error> erases the error type right at the API boundary. A caller cannot match on what went wrong, cannot write a From impl for it, and cannot recover from one failure while propagating another — the only moves left are print-and-die or blind retry. I want a thiserror enum for the module, or anyhow::Error when the caller genuinely is only going to report the failure. It stays a hint because that second case is real.

Beforefn signed_tx_to_psbt(new: Psbt, mut psbt: Psbt) -> Result<Psbt, Box<dyn std::error::Error>> {
            if new.inputs.len() != psbt.inputs.len() {
                return Err(format!(
                    "input count mismatch: {} vs {}",
                    psbt.inputs.len(),
                    new.inputs.len()
                )
                .into());
            }
            merge(&mut psbt, new)?;
            Ok(psbt)
        }

        // caller can only do this:
        match signed_tx_to_psbt(a, b) {
            Ok(p) => p,
            Err(e) => { toast(format!("{e}")); return; }
        }
After#[derive(Debug, thiserror::Error)]
        pub enum PsbtError {
            #[error("input count mismatch: {expected} vs {actual}")]
            InputMismatch { expected: usize, actual: usize },
            #[error("merge failed")]
            Merge(#[source] bdk::Error),
        }

        fn signed_tx_to_psbt(new: Psbt, mut psbt: Psbt) -> Result<Psbt, PsbtError> {
            if new.inputs.len() != psbt.inputs.len() {
                return Err(PsbtError::InputMismatch {
                    expected: psbt.inputs.len(),
                    actual: new.inputs.len(),
                });
            }
            merge(&mut psbt, new).map_err(PsbtError::Merge)?;
            Ok(psbt)
        }

        // caller can now recover selectively:
        match signed_tx_to_psbt(a, b) {
            Ok(p) => p,
            Err(PsbtError::InputMismatch { .. }) => resync_inputs(),
            Err(e) => { toast(format!("{e}")); return; }
        }
Documented limitsThe dyn Error has to sit inside a Box in the written signature, which is what keeps the mandatory Error::source signature quiet. A type alias hides the Box from the syntax and is not matched: type BoxError = Box<dyn Error>; fn f() -> Result<T, BoxError> passes clean.

error-printed-to-stdout

warning Rust
What it catches

A println! used to report a failure from inside an error-handling branch.

How the match works

Matches a macro_invocation whose macro field is println and which sits inside (with stopBy: end) one of three error contexts: a match_arm whose pattern is a tuple_struct_pattern of type Err; an if_expression whose condition is a let_condition destructuring Err(..); or a call_expression whose function is a field_expression with field unwrap_or_else, map_err, inspect_err, or or_else. A not: inside clause skips an inline #[cfg(test)] mod tests, including cfg(all(test, ..)) and cfg(any(test, ..)), tolerating one comment or one extra attribute between the attribute and the mod. It deliberately does not treat cfg(not(test)) as test code, since that gates production.

Why I have it

Stdout is the wrong channel for a diagnostic: it corrupts piped output, carries no level to filter on, has no span or context attached, and log aggregation never sees it. For an agent-native CLI it is worse than untidy — stdout is the machine-readable channel, so one stray line makes the JSON envelope unparseable. The pair below keeps the signature and only moves the diagnostic. The better fix is to hand the failure back as a Result and log once at the top of the stack, which does change the signature: callers that only wanted presence write read_file_bytes(path).ok(), and in exchange the reason survives as a value instead of being printed and dropped.

Beforepub fn read_file_bytes(path: &str) -> Option<Vec<u8>> {
            let mut file = match File::open(path) {
                Ok(f) => f,
                Err(e) => {
                    println!("Failed to read aoit file: {}", e);
                    return None;
                }
            };
            let mut contents = Vec::new();
            if let Err(e) = file.read_to_end(&mut contents) {
                println!("Failed to read aoit as bytes: {}", e);
                return None;
            }
            Some(contents)
        }
Afterpub fn read_file_bytes(path: &str) -> Option<Vec<u8>> {
            let mut file = match File::open(path) {
                Ok(f) => f,
                Err(e) => {
                    tracing::error!(%e, path, "failed to read aoit file");
                    return None;
                }
            };
            let mut contents = Vec::new();
            if let Err(e) = file.read_to_end(&mut contents) {
                tracing::error!(%e, path, "failed to read aoit as bytes");
                return None;
            }
            Some(contents)
        }
Documented limitsThe #[cfg(test)] exclusion tolerates exactly one comment or extra attribute between #[cfg(test)] and mod tests; two or more defeat it and the rule fires inside that module. eprintln! is deliberately not matched, because stderr is an acceptable channel for a top-level diagnostic, and neither is a println! reached through a helper (fn warn(msg: &str) { println!(..) } called from an Err arm) — the call site is all the rule can see.

map-err-stringifies-source

hint Rust
What it catches

A .map_err closure that reads the source error and flattens it into text.

How the match works

Two alternatives. The first is the pattern $RECV.map_err(|$E| $E.to_string()), where the repeated $E forces the stringified value to be the bound error itself. The second matches a call_expression whose function is a field_expression with field map_err and whose arguments hold a closure_expression that both binds an identifier in its closure_parameters — so the |_| form belongs to silent-map-err, not here — and contains a macro_invocation of format or anyhow (bare or path-qualified) whose text contains a {, meaning it actually interpolates. The shared not: inside clause skips an inline #[cfg(test)] mod tests, tolerating one comment or extra attribute before the mod.

Why I have it

Once the cause is interpolated into a string, std::error::Error::source() returns None from that point up: the chain stops, the backtrace is gone, and no caller can downcast to decide whether the failure is retryable. The sample is a barcode lookup against OFF (Open Food Facts — the public food-product database), where "was that a timeout or a 404?" is exactly the question the caller needs to answer. The rewrite loses nothing in the logs: {} now prints just barcode OFF lookup, and {:#} prints barcode OFF lookup: <cause>, which is byte-for-byte the string the format! built. When callers must branch on the cause, name it with a thiserror variant carrying #[source] instead — that one does change the error type, which is the point.

Beforeasync fn barcode(client: &Client, code: &str) -> Result<Food, AppError> {
            let food = lookup_off(client, code)
                .await
                .map_err(|e| AppError::Internal(anyhow::anyhow!("barcode OFF lookup: {e}")))?;
            Ok(food)
        }
Afteruse anyhow::Context;

        async fn barcode(client: &Client, code: &str) -> Result<Food, AppError> {
            let food = lookup_off(client, code)
                .await
                .context("barcode OFF lookup")
                .map_err(AppError::Internal)?;
            Ok(food)
        }
Documented limitsThe #[cfg(test)] exclusion is syntactic: one comment or extra attribute between #[cfg(test)] and mod tests is tolerated, two or more defeat it and the rule fires inside that module — silence those lines with // ast-grep-ignore: map-err-stringifies-source. A helper module gated from elsewhere (#[path = ..] mod, or a cfg(feature = "testing") module) is not recognised as test code at all.

panic-in-library

warning Rust
What it catches

An explicit panic!, todo!, or unimplemented! in library code.

How the match works

Matches a macro_invocation whose macro field is panic, todo, or unimplemented, then subtracts three contexts with not: inside. First, an inline #[cfg(test)] mod tests (also cfg(all(test, ..)) / cfg(any(test, ..)), never cfg(not(test))). Second, a function_item that follows a #[test], #[tokio::test], #[rstest], or #[proptest] attribute — with one intervening comment or attribute allowed, which is what lets #[should_panic] sit between the two. Third, any function_item whose name is main, where aborting is just an exit path. unreachable! is deliberately absent from the macro list.

Why I have it

This is the same defect unwrap-in-prod blocks, written out longhand: the process aborts and the caller never gets a chance to recover, attach context, or return a useful status. unreachable!() stays legal because it documents a state the type system cannot express, which is a different claim from "not handled yet". In the pair below every input that installed before still installs, unpacking the same archive into the same directory — split_once('.') yields the same stem the split('.').collect() and parts[0] pair did. The return type going from () to Result<(), InstallError> is the change being asked for: the two failure cases become values, so main picks the exit code and prints a diagnostic instead of dumping a backtrace.

Beforepub fn install_deb(name: &str) {
            let expected = read_hash(name);
            if hash(name) != expected {
                panic!("check sha256 failed");
            }
            let parts: Vec<&str> = name.split('.').collect();
            let dir = if parts.len() >= 2 {
                parts[0].to_string()
            } else {
                panic!("filename error, standard files should end with aoit");
            };
            unpack(name, &dir);
        }
After#[derive(Debug, thiserror::Error)]
        pub enum InstallError {
            #[error("sha256 mismatch for {0}")]
            HashMismatch(String),
            #[error("{0} is not an .aoit archive")]
            BadFilename(String),
        }

        pub fn install_deb(name: &str) -> Result<(), InstallError> {
            let expected = read_hash(name);
            if hash(name) != expected {
                return Err(InstallError::HashMismatch(name.to_string()));
            }
            let dir = name
                .split_once('.')
                .map(|(stem, _)| stem.to_string())
                .ok_or_else(|| InstallError::BadFilename(name.to_string()))?;
            unpack(name, &dir);
            Ok(())
        }
Documented limitsThe #[cfg(test)]-module and #[test]-function exclusions each tolerate exactly one comment or extra attribute (such as #[should_panic]) between the attribute and the item; two or more defeat the exclusion and the rule fires on a test-only panic!, which needs // ast-grep-ignore: panic-in-library. assert! and assert_eq! are not matched even though they panic — they state an invariant rather than an unfinished path — and .unwrap() belongs to unwrap-in-prod.

terse-expect-message

hint Rust
What it catches

An .expect() or .expect_err() whose message is two words or fewer, so it names a value instead of stating a reason.

How the match works

Matches a call_expression whose function is a field_expression with field expect or expect_err and whose arguments contain a string_literal whose string_content matches a word-count regex — an optional first word, an optional second, and nothing else, over a restricted character class (letters, digits, and _ ' . / & : ! ( ) -). A third word, or any character outside that class, takes the message out of the match. The usual not: inside clause skips an inline #[cfg(test)] mod tests, tolerating one comment or extra attribute before the mod.

Why I have it

When this panics in production the log line reads panicked at 'midnight' and nobody on call can tell what invariant was violated. My convention is that the message completes the sentence "this cannot fail because ...", so the reader can check the claim rather than guess at it. Panic behaviour is byte-for-byte identical after the rewrite; only the payload string changes.

Beforefn day_start(date: NaiveDate) -> NaiveDateTime {
            date.and_hms_opt(0, 0, 0).expect("midnight")
        }

        fn limiter() -> GovernorConfig {
            GovernorConfigBuilder::default().finish().expect("governor config")
        }

        fn parse(raw: &str) -> i32 {
            raw.parse::<i32>().expect("number")
        }
Afterfn day_start(date: NaiveDate) -> NaiveDateTime {
            date.and_hms_opt(0, 0, 0)
                .expect("0:00:00 is a valid time of day")
        }

        fn limiter() -> GovernorConfig {
            GovernorConfigBuilder::default()
                .finish()
                .expect("governor defaults are non-zero so finish always returns Some")
        }

        fn parse(raw: &str) -> i32 {
            raw.parse::<i32>()
                .expect("raw was validated as ASCII digits above")
        }
Documented limitsThe threshold is word count, not meaning. It passes real justifications like "static regex compiles" and "arena holds valid utf8", and it equally passes a three-word non-reason like "the config file" — a floor on effort, not a judge of content. The #[cfg(test)] exclusion tolerates one comment or extra attribute; two or more defeat it. A #[test] function outside a #[cfg(test)] module is not excluded here, unlike in panic-in-library.

untyped-json-return

off Rust
What it catches

A fallible function that returns serde_json::Value instead of a named type. Ships at severity: off; see the severity system above.

How the match works

Matches a function_item whose return_type field is directly a generic_type whose type is Result or AppResult, and which contains anywhere beneath it (stopBy: end) either a type_identifier named Value or a scoped_type_identifier ending in serde_json::Value. Requiring the Result wrapper is what keeps a deliberate projection helper out of the results. The match reads the written return type only, with nothing resolved.

Why I have it

The shape of a row or a response becomes invisible to the compiler. Every caller re-discovers field names and types by hand, a typo surfaces at runtime, and renaming a column breaks nothing until production. I want a named struct with #[derive(Serialize)] and the serialisation pushed out to the HTTP or model-facing edge — the wire bytes are identical for these fields, so the JSON and every error path are unchanged and only the in-process type moves. It ships off because legitimate untyped boundaries exist: an LLM (Large Language Model) tool envelope, a passthrough proxy, a dev-only query endpoint. Run it deliberately with sg scan --error=untyped-json-return and let a human or a review agent decide; automation does not fail the build on it.

Beforepub async fn list_visits(tx: &mut Tx, limit: i64) -> Result<Vec<Value>, Error> {
            let rows: Vec<VisitRow> = sqlx::query_as(SQL).bind(limit).fetch_all(tx).await?;
            Ok(rows
                .into_iter()
                .map(|r| json!({ "id": r.id, "restaurant_name": r.restaurant_name }))
                .collect())
        }
After#[derive(Serialize)]
        pub struct Visit {
            pub id: Uuid,
            pub restaurant_name: String,
        }

        pub async fn list_visits(tx: &mut Tx, limit: i64) -> Result<Vec<Visit>, Error> {
            let rows: Vec<VisitRow> = sqlx::query_as(SQL).bind(limit).fetch_all(tx).await?;
            Ok(rows
                .into_iter()
                .map(|r| Visit { id: r.id, restaurant_name: r.restaurant_name })
                .collect())
        }
Documented limitsScoped to a Value inside a Result, so a deliberate projection helper (fn row_to_json(r: Row) -> Value) stays silent. Because the match is syntactic on the written return type, an alias that hides the Value (type ApiJson = Result<Value, Error>; fn f() -> ApiJson) is not matched, while a Value in the error half (Result<u8, serde_json::Value>) is matched even though the success type is typed — rare, and arguably still worth a look.

Correctness & performance · 4 rules

await-in-loop

off Rust
What it catches

A for loop over a collection that awaits a call on every element — the N+1 shape, one round trip per row on top of the query that fetched the rows. Ships at severity: off; see the severity system above.

How the match works

Matches a for_expression whose body contains an await_expression that itself has a call_expression — so f(x).await matches but for h in handles { h.await }, the join half of a fan-out that is already concurrent, does not. Two not clauses carry the rest of the precision: the loop's value field must not be a range_expression, because for i in 0..n is a retry or backoff loop and sequential on purpose, and the body must not contain an async_block, because a tokio::spawn(async move { .. }) push loop has already moved the await into a spawned future.

rule (excerpt)rule:
          kind: for_expression
          all:
            - has:                        # `f(x).await`, not `handle.await`
                field: body
                has:
                  kind: await_expression
                  stopBy: end
                  has: { kind: call_expression }
            - not:                        # `for i in 0..n` is a retry loop
                has: { field: value, kind: range_expression }
            - not:                        # already concurrent via `tokio::spawn`
                has:
                  field: body
                  has: { kind: async_block, stopBy: end }
Why I have it

Latency here is O(n * rtt) where it could be O(rtt), and the shape hides well: the loop body reads like ordinary code and the cost only shows up under a real row count. The pair below is the batched fix — same signature, same values in the same order, same first error propagated, n round trips collapsed to one. The other fix, a concurrent fan-out with stream::iter(rows).map(..).buffered(8).try_collect(), keeps output order and the same error value but is not side-effect equivalent: up to eight requests are in flight, so calls that the sequential version would never have made after an early failure do get issued. Inside a transaction, batch instead. Sequential awaits are sometimes required — ordered writes, rate-limited upstreams, early exit on first hit — so this runs as sg scan --error=await-in-loop and a reviewer decides.

Beforeasync fn list_visits(tx: &mut Tx, rows: Vec<VisitRow>) -> Result<Vec<Json>, Error> {
            let mut out = Vec::with_capacity(rows.len());
            for r in rows {
                let meal_ids = visit_meal_ids(tx, r.id).await?;
                out.push(to_json(r, meal_ids));
            }
            Ok(out)
        }
Afterasync fn list_visits(tx: &mut Tx, rows: Vec<VisitRow>) -> Result<Vec<Json>, Error> {
            let ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
            let mut by_visit = visit_meal_ids_batch(tx, &ids).await?;
            let out = rows
                .into_iter()
                .map(|r| {
                    let meal_ids = by_visit.remove(&r.id).unwrap_or_default();
                    to_json(r, meal_ids)
                })
                .collect();
            Ok(out)
        }
Documented limitsOnly fires when the awaited expression is a call, and never on for i in 0..n. A sequential await hidden behind a helper that takes a closure — try_for_each, for_each_concurrent — is outside the pattern entirely.

clone-then-project

warning Rust
What it catches

A .clone() made only to read from the copy, which is then dropped at the end of the statement.

How the match works

Two alternatives. The first matches a call_expression whose function is a field_expression whose value matches the pattern $RECV.clone() and whose field is drawn from a fixed list of borrowing, read-only methods: iter, iter_mut, len, is_empty, to_string, to_owned, as_str, as_slice, as_ref, as_deref, contains, contains_key, starts_with, ends_with, first, last, keys, values, chars, lines, bytes, count, trim, to_vec. The second matches a bare field_expressionx.clone().field, a whole struct deep-copied to read one field — guarded by not: inside a call_expression's function field, so a method call on the clone is left to the first alternative and its curated list rather than matching here as a field read.

Why I have it

The clone allocates an entire value whose only job is to be borrowed from and then dropped, and in a repaint loop that happens every frame. Borrow the original, or clone the single field you actually need — in the pair below name is still an owned String, cloned from one field instead of from the whole struct, so the signature, the values, the order and the side effects are unchanged. I keep this as a rule rather than leaning on Clippy because redundant_clone is nursery-only and does not run under -D warnings, so this shape survives the normal Clippy gate.

Beforefn render(&self, ui: &mut Ui) -> usize {
            for (idx, vault) in self.vaults.clone().iter().enumerate() {
                self.display_psbt(idx, vault, ui);
            }
            let n = self.vaults.clone().len();
            let name = self.selected.clone().name;
            ui.label(name);
            n
        }
Afterfn render(&self, ui: &mut Ui) -> usize {
            for (idx, vault) in self.vaults.iter().enumerate() {
                self.display_psbt(idx, vault, ui);
            }
            let n = self.vaults.len();
            let name = self.selected.name.clone();
            ui.label(name);
            n
        }
Documented limitsThe read-only method list is fixed, so x.clone().my_read() on a project's own borrowing method is not flagged. A .clone() whose result is moved or mutated (let mut v = xs.clone(); v.push(y);) is correctly left alone.

float-arith-cast-to-int

warning Rust
What it catches

Floating-point arithmetic truncated by an as cast to an integer type.

How the match works

Matches a type_cast_expression under three conditions and one exception. The type field must be a primitive_type naming an integer (u8 through usize, i8 through isize); the value must be a parenthesized_expression or a binary_expression, so the cast is applied to arithmetic rather than to a single binding; and the value must contain, at any depth, evidence that the arithmetic is floating point — either a float_literal or a nested type_cast_expression to f32/f64. The exception is a not clause on any field_identifier named round, round_ties_even, floor, ceil, or trunc inside the value: once the author has stated the rounding, the truncation is intent and the rule goes quiet.

Why I have it

A float-to-int as makes two decisions silently at once: truncate toward zero, and saturate on overflow or NaN. f64::NAN as u32 is 0 and 1e30 as u32 is u32::MAX, and neither shows up at the call site — so a $9.99 total renders as $9 and a NaN price renders as $0, with no error anywhere. For money, doses and counts that is a wrong answer that never announces itself. The pair below is the quiet version: as already truncates toward zero and saturates, so .trunc() changes nothing at runtime and simply records that truncation was a choice. If truncation was the bug, the fix is bigger and deliberately changes results — check is_finite, then u32::try_from(dollars.round() as i64) with an error return, and the signature changes to carry it. That is_finite check is load-bearing: f64::NAN.round() as i64 is 0, so try_from alone would happily return Ok(0) for a NaN price. Clippy's cast_possible_truncation is pedantic-only and does not run under the standard -D warnings gate, so this shape otherwise reaches production unflagged.

Beforefn dollar_value(btc_usd: f32, sats: u64) -> u32 {
            (btc_usd as f64 * Amount::from_sat(sats).to_btc()) as u32
        }
Afterfn dollar_value(btc_usd: f32, sats: u64) -> u32 {
            (f64::from(btc_usd) * Amount::from_sat(sats).to_btc()).trunc() as u32
        }
Documented limitsThe float evidence is syntactic — a float literal or an explicit as f32/as f64 somewhere inside the expression. (a * b) as u32 where a and b are already-typed f64 bindings carries no such marker and does not fire.

sql-format-interpolation

warning Rust
What it catches

SQL (Structured Query Language — the statement text a database executes) assembled with format! and a runtime value spliced into it.

How the match works

Matches a macro_invocation of format, write, or writeln that satisfies two case-insensitive regexes and a third, deliberately case-sensitive one, over the invocation text. The first anchors on the opening quote, so the literal must begin with a statement keyword (SELECT, INSERT INTO, UPDATE, DELETE FROM, WITH, CREATE/DROP/ALTER TABLE, TRUNCATE) — that anchoring is what keeps prose merely containing the word "select" out of the results. The second requires a clause keyword (FROM, WHERE, VALUES, SET, JOIN, INTO, ORDER BY, GROUP BY) somewhere after it. The third requires a hole that names a runtime binding: a positional {}, an indexed {0}, or a lower-case inline capture {user_id}. Dropping the (?i) on that third regex is what excludes SCREAMING_CASE captures such as {MEAL_FIELDS}: a const &str fragment cannot carry request data.

rule (excerpt)rule:
          kind: macro_invocation
          all:
            - has: { field: macro, regex: '^(format|write|writeln)$' }
            # the literal OPENS with a statement keyword (anchored on the quote)
            - regex: '(?i)"[\s\\]*(SELECT|INSERT +INTO|UPDATE|DELETE +FROM|WITH|CREATE +TABLE|DROP +TABLE|ALTER +TABLE|TRUNCATE)\b'
            # ... a clause keyword appears after it
            - regex: '(?i)\b(FROM|WHERE|VALUES|SET|JOIN|INTO|ORDER +BY|GROUP +BY)\b'
            # ... and a hole names a runtime binding: {}, {0}, {user_id}
            - regex: '\{(\}|[0-9]|[a-z_][A-Za-z0-9_]*[:}])'
Why I have it

Anything that reaches the interpolated binding becomes SQL, so a single quote in user input rewrites the statement. Binding the value as a parameter — numbered placeholders on Postgres, ? on SQLite and MySQL — hands the escaping decision to the driver, and the rewrite below returns the same rows in the same order with the same error type. When the dynamic part is genuinely an identifier, a table or column name, parameters cannot help: check the request against a whitelist (const SORTABLE: &[&str] = &["logged_at", "calories"];), interpolate the matched constant, and suppress that one line with // ast-grep-ignore: sql-format-interpolation, since the rule cannot see the whitelist.

Beforeasync fn by_kind(tx: &mut Tx, kind: &str, limit: i64) -> Result<Vec<MealRow>, Error> {
            let sql = format!(
                "SELECT id, name FROM coach_meals WHERE meal_type = '{kind}' LIMIT {limit}"
            );
            sqlx::query_as(&sql).fetch_all(tx.as_mut()).await
        }
Afterasync fn by_kind(tx: &mut Tx, kind: &str, limit: i64) -> Result<Vec<MealRow>, Error> {
            sqlx::query_as("SELECT id, name FROM coach_meals WHERE meal_type = $1 LIMIT $2")
                .bind(kind)
                .bind(limit)
                .fetch_all(tx.as_mut())
                .await
        }
Documented limitswrite! and writeln! building a statement into a buffer are matched on the same terms as format!, but concatenation that never goes through a macro (sql.push_str(&kind), "SELECT ".to_owned() + k) is invisible to this rule. The SQL sniff is textual over the whole macro invocation, so a format! whose literal opens with a statement keyword fires even when the only interpolated hole sits in a different argument — read the hit before suppressing it.

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