boolean-control-parameter
hint
Rust
What it catchesA 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 worksThe 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 itA 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 catchesA branch condition or match guard that mixes && and || across three or more boolean operators.
How the match worksThe 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 itA 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 catchesA 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 worksMatches 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 itFour 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 catchesThree plain boolean if expressions nested through their success branches.
How the match worksMatches 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 itThree 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 catchesThree if let checks nested through their success branches, forming an extraction pyramid.
How the match worksThe 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 itThree 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 catchesAn impl Deref on a non-generic domain type, where the wrapper is faking inheritance instead of holding a value.
How the match worksMatches 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 itDeref 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 catchesA declaration named with a doubled abstraction noun: AccountManagerManager, TransferProviderProvider, handler_handler.
How the match worksA 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 itStacked 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 catchesA struct with 13 or more fields, braced or tuple. Ships at severity: off; see the severity system above.
How the match worksA 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 itWorking 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 catchesA function whose return type is a tuple with four or more elements.
How the match worksA 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 itFour 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 catchesAn else-if ladder with four or more conditions.
How the match worksAn 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 itBy 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 catchesAn == or != comparison against an integer literal other than zero or one. Ships at severity: off; see the severity system above.
How the match worksA 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 itif 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 catchesThree match expressions nested inside one execution scope, usually an Option/Result pyramid.
How the match worksA 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 itEvery 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 catchesA 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 worksA 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 ittransfer(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 catchesA function or method taking seven or more parameters, not counting self. Ships at severity: off; see the severity system above.
How the match worksA 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 itSeven 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.