From e859aaf6b445804f3f7c89ff03c62c6c5d7a9319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Ci=C4=99=C5=BCarkiewicz?= Date: Fri, 24 Jul 2026 21:29:56 -0700 Subject: [PATCH 1/4] chore(lint): import ast-grep rules --- .../ast-grep/rules/continue-filter-loop.yml | 47 +++++++++++++++ .config/ast-grep/rules/filter-in-loop.yml | 30 ++++++++++ .config/ast-grep/rules/for-range-len.yml | 57 +++++++++++++++++++ .../rules/hashmap-entry-grouping-loop.yml | 42 ++++++++++++++ .../ast-grep/rules/if-let-ok-push-loop.yml | 35 ++++++++++++ .config/ast-grep/rules/if-let-some-else.yml | 34 +++++++++++ .../ast-grep/rules/if-let-some-push-loop.yml | 36 ++++++++++++ .../ast-grep/rules/is-err-then-unwrap-err.yml | 38 ++++++++++--- .config/ast-grep/rules/is-ok-then-unwrap.yml | 39 ++++++++++--- .../ast-grep/rules/is-some-then-unwrap.yml | 40 ++++++++++--- .../ast-grep/rules/let-underscore-call.yml | 34 +++++++++++ .config/ast-grep/rules/manual-all-loop.yml | 56 ++++++++++++++++++ .config/ast-grep/rules/manual-any-loop.yml | 42 ++++++++++++++ .config/ast-grep/rules/manual-find-loop.yml | 46 +++++++++++++++ .../ast-grep/rules/manual-position-loop.yml | 43 ++++++++++++++ .../rules/map-collect-loop-with-let.yml | 45 +++++++++++++++ .config/ast-grep/rules/map-collect-loop.yml | 42 ++++++++++++++ .config/ast-grep/rules/match-ok-push-loop.yml | 52 +++++++++++++++++ .../ast-grep/rules/match-option-verbose.yml | 54 ++++++++++++++++++ .../ast-grep/rules/match-result-verbose.yml | 53 +++++++++++++++++ .../ast-grep/rules/match-some-push-loop.yml | 51 +++++++++++++++++ .../ast-grep/rules/mut-fold-accumulator.yml | 56 ++++++++++++++++++ .config/ast-grep/rules/push-str-format.yml | 25 ++++++++ .../ast-grep/rules/silent-filter-map-ok.yml | 32 +++++++++++ .config/ast-grep/rules/silent-map-err.yml | 32 +++++++++++ .config/ast-grep/rules/silent-ok-discard.yml | 34 +++++++++++ .../ast-grep/rules/silent-unwrap-or-else.yml | 31 ++++++++++ .../rules/stringly-typed-dispatch.yml | 42 ++++++++++++++ .../ast-grep/rules/stringly-typed-match.yml | 49 ++++++++++++++++ .config/ast-grep/rules/unwrap-in-prod.yml | 32 +++++++++++ .config/ast-grep/rules/unwrap-or-default.yml | 35 ++++++++++-- .../rules/with-capacity-push-loop.yml | 36 ++++++++++++ .config/selfci/ci.sh | 5 ++ 33 files changed, 1295 insertions(+), 30 deletions(-) create mode 100644 .config/ast-grep/rules/continue-filter-loop.yml create mode 100644 .config/ast-grep/rules/filter-in-loop.yml create mode 100644 .config/ast-grep/rules/for-range-len.yml create mode 100644 .config/ast-grep/rules/hashmap-entry-grouping-loop.yml create mode 100644 .config/ast-grep/rules/if-let-ok-push-loop.yml create mode 100644 .config/ast-grep/rules/if-let-some-else.yml create mode 100644 .config/ast-grep/rules/if-let-some-push-loop.yml create mode 100644 .config/ast-grep/rules/let-underscore-call.yml create mode 100644 .config/ast-grep/rules/manual-all-loop.yml create mode 100644 .config/ast-grep/rules/manual-any-loop.yml create mode 100644 .config/ast-grep/rules/manual-find-loop.yml create mode 100644 .config/ast-grep/rules/manual-position-loop.yml create mode 100644 .config/ast-grep/rules/map-collect-loop-with-let.yml create mode 100644 .config/ast-grep/rules/map-collect-loop.yml create mode 100644 .config/ast-grep/rules/match-ok-push-loop.yml create mode 100644 .config/ast-grep/rules/match-option-verbose.yml create mode 100644 .config/ast-grep/rules/match-result-verbose.yml create mode 100644 .config/ast-grep/rules/match-some-push-loop.yml create mode 100644 .config/ast-grep/rules/mut-fold-accumulator.yml create mode 100644 .config/ast-grep/rules/push-str-format.yml create mode 100644 .config/ast-grep/rules/silent-filter-map-ok.yml create mode 100644 .config/ast-grep/rules/silent-map-err.yml create mode 100644 .config/ast-grep/rules/silent-ok-discard.yml create mode 100644 .config/ast-grep/rules/silent-unwrap-or-else.yml create mode 100644 .config/ast-grep/rules/stringly-typed-dispatch.yml create mode 100644 .config/ast-grep/rules/stringly-typed-match.yml create mode 100644 .config/ast-grep/rules/unwrap-in-prod.yml create mode 100644 .config/ast-grep/rules/with-capacity-push-loop.yml diff --git a/.config/ast-grep/rules/continue-filter-loop.yml b/.config/ast-grep/rules/continue-filter-loop.yml new file mode 100644 index 0000000..c12d1b3 --- /dev/null +++ b/.config/ast-grep/rules/continue-filter-loop.yml @@ -0,0 +1,47 @@ +id: continue-filter-loop +language: rust +severity: warning +rule: + any: + - pattern: | + for $X in $ITER { + if !$COND { + continue; + } + $$$REST + } + - pattern: | + for $X in $ITER { + if $COND { + continue; + } + $$$REST + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Guard-and-`continue` loop — pull the predicate into `.filter()` upstream. +note: | + Replace: + for x in iter { + if !pred(&x) { + continue; + } + out.push(f(x)); + } + with: + let out: Vec<_> = iter.filter(|x| pred(x)).map(|x| f(x)).collect(); + The early-continue is the imperative form of `filter`. Once the guard is + gone, the remaining body often collapses to `map`, `for_each`, or another + combinator. Inverse guard (`if cond { continue; }`) flips to + `.filter(|x| !cond)`. diff --git a/.config/ast-grep/rules/filter-in-loop.yml b/.config/ast-grep/rules/filter-in-loop.yml new file mode 100644 index 0000000..8785259 --- /dev/null +++ b/.config/ast-grep/rules/filter-in-loop.yml @@ -0,0 +1,30 @@ +id: filter-in-loop +language: rust +severity: warning +rule: + pattern: | + for $X in $ITER { + if $COND { + $VEC.push($EXPR); + } + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Filter-then-push loop — consider using iterator methods. +note: | + Replace: + for x in iter { if cond { vec.push(expr); } } + with: + let vec: Vec<_> = iter.filter(|x| cond).map(|x| expr).collect(); + or use .filter_map() if condition and transformation are combined. diff --git a/.config/ast-grep/rules/for-range-len.yml b/.config/ast-grep/rules/for-range-len.yml new file mode 100644 index 0000000..d15ff36 --- /dev/null +++ b/.config/ast-grep/rules/for-range-len.yml @@ -0,0 +1,57 @@ +id: for-range-len +language: rust +severity: warning +rule: + any: + - pattern: | + for $I in 0..$V.len() { + $$$ + } + - pattern: | + for $I in 0..=$V.len() { + $$$ + } + - pattern: | + for $I in $START..$V.len() { + $$$ + } + - pattern: | + for $I in $START..=$V.len() { + $$$ + } + - pattern: | + for $I in 0..$V.len() - $N { + $$$ + } + - pattern: | + for $I in 0..=$V.len() - $N { + $$$ + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Index-based iteration — use an iterator instead of a `..v.len()` range. +note: | + Replace: + for i in 0..v.len() { ... v[i] ... } + with one of: + for x in &v { ... } — when you don't need the index + for (i, x) in v.iter().enumerate() { ... } + for x in v.iter_mut() { ... } — when you need to mutate + for w in v.windows(2) { ... w[0], w[1] ... } — for `0..v.len() - 1` + for w in v.chunks(n) { ... } — for chunked passes + When the range starts at a non-zero offset: + for x in &v[start..] { ... } — slice + iterate + for (i, x) in v.iter().enumerate().skip(start) { ... } + Index iteration sidesteps bounds-check elision and prevents the borrow + checker from helping you. diff --git a/.config/ast-grep/rules/hashmap-entry-grouping-loop.yml b/.config/ast-grep/rules/hashmap-entry-grouping-loop.yml new file mode 100644 index 0000000..e9dafd8 --- /dev/null +++ b/.config/ast-grep/rules/hashmap-entry-grouping-loop.yml @@ -0,0 +1,42 @@ +id: hashmap-entry-grouping-loop +language: rust +severity: hint +rule: + any: + - pattern: | + for $X in $ITER { + $MAP.entry($KEY).or_default().push($VAL); + } + - pattern: | + for $X in $ITER { + $MAP.entry($KEY).or_insert_with(Vec::new).push($VAL); + } + - pattern: | + for $X in $ITER { + $MAP.entry($KEY).or_insert_with(|| Vec::new()).push($VAL); + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Manual grouping loop — consider `fold` or `itertools::into_group_map_by`. +note: | + Pure functional rewrite (no extra deps): + let by_key = iter.fold(HashMap::new(), |mut acc, item| { + acc.entry(key_of(&item)).or_default().push(item); + acc + }); + Or, if you already pull in `itertools`: + use itertools::Itertools; + let by_key = iter.into_group_map_by(|item| key_of(item)); + Hint-only — the imperative form is often the most readable shape in + Rust. Switch when the surrounding code is already iterator-chained. diff --git a/.config/ast-grep/rules/if-let-ok-push-loop.yml b/.config/ast-grep/rules/if-let-ok-push-loop.yml new file mode 100644 index 0000000..d8189f9 --- /dev/null +++ b/.config/ast-grep/rules/if-let-ok-push-loop.yml @@ -0,0 +1,35 @@ +id: if-let-ok-push-loop +language: rust +severity: warning +rule: + pattern: | + for $X in $ITER { + if let Ok($Y) = $EXPR { + $VEC.push($PUSH); + } + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Conditional-push loop on Result — use `.filter_map(|x| f(x).ok())`. +note: | + Replace: + for x in iter { + if let Ok(y) = f(x) { + out.push(y); + } + } + with: + let out: Vec<_> = iter.filter_map(|x| f(x).ok()).collect(); + Errors are silently dropped — if that matters, log them first: + .filter_map(|x| f(x).inspect_err(|e| warn!(%e)).ok()) diff --git a/.config/ast-grep/rules/if-let-some-else.yml b/.config/ast-grep/rules/if-let-some-else.yml new file mode 100644 index 0000000..44c0a21 --- /dev/null +++ b/.config/ast-grep/rules/if-let-some-else.yml @@ -0,0 +1,34 @@ +id: if-let-some-else +language: rust +severity: warning +rule: + all: + - pattern: | + if let Some($X) = $Y { + $$$ + } else { + $$$ + } + - not: + has: + pattern: return $$$ + stopBy: end +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + if let Some/else can often be replaced with Option combinators. +note: | + Alternatives: + opt.map_or(default, |x| ...) — both branches return values + opt.map_or_else(|| default, |x| ...) — lazy default computation + opt.map(|x| ...).unwrap_or(default) — transform then default diff --git a/.config/ast-grep/rules/if-let-some-push-loop.yml b/.config/ast-grep/rules/if-let-some-push-loop.yml new file mode 100644 index 0000000..04e7aa8 --- /dev/null +++ b/.config/ast-grep/rules/if-let-some-push-loop.yml @@ -0,0 +1,36 @@ +id: if-let-some-push-loop +language: rust +severity: warning +rule: + pattern: | + for $X in $ITER { + if let Some($Y) = $EXPR { + $VEC.push($PUSH); + } + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Conditional-push loop on Option — use `.filter_map()` instead. +note: | + Replace: + for x in iter { + if let Some(y) = f(x) { + out.push(y); + } + } + with: + let out: Vec<_> = iter.filter_map(|x| f(x)).collect(); + or, when `out` already exists: + out.extend(iter.filter_map(|x| f(x))); + If you need both Some and None branches handled, keep the explicit loop. diff --git a/.config/ast-grep/rules/is-err-then-unwrap-err.yml b/.config/ast-grep/rules/is-err-then-unwrap-err.yml index f7a0f4b..a0c3a8a 100644 --- a/.config/ast-grep/rules/is-err-then-unwrap-err.yml +++ b/.config/ast-grep/rules/is-err-then-unwrap-err.yml @@ -1,14 +1,36 @@ id: is-err-then-unwrap-err -language: Rust +language: rust severity: warning -message: Replace an is_err() check followed by unwrap_err() with if let Err(...). rule: - kind: if_expression all: + - pattern: | + if $RES.is_err() { + $$$ + } - has: - field: condition - pattern: $VALUE.is_err() - - has: - field: consequence + pattern: $RES.unwrap_err() stopBy: end - pattern: $VALUE.unwrap_err() +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + `if r.is_err()` + `r.unwrap_err()` — use `if let Err(e) = r` to bind once. +note: | + Replace: + if res.is_err() { + ... res.unwrap_err() ... + } + with: + if let Err(e) = res { + ... e ... + } + This is the Err sibling of [[is-ok-then-unwrap]] / [[is-some-then-unwrap]]. diff --git a/.config/ast-grep/rules/is-ok-then-unwrap.yml b/.config/ast-grep/rules/is-ok-then-unwrap.yml index 7bc579f..914fe26 100644 --- a/.config/ast-grep/rules/is-ok-then-unwrap.yml +++ b/.config/ast-grep/rules/is-ok-then-unwrap.yml @@ -1,14 +1,37 @@ id: is-ok-then-unwrap -language: Rust +language: rust severity: warning -message: Replace an is_ok() check followed by unwrap() with if let Ok(...). rule: - kind: if_expression all: + - pattern: | + if $RES.is_ok() { + $$$ + } - has: - field: condition - pattern: $VALUE.is_ok() - - has: - field: consequence + pattern: $RES.unwrap() stopBy: end - pattern: $VALUE.unwrap() +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + `if r.is_ok()` + `r.unwrap()` — use `if let Ok(v) = r` to bind once. +note: | + Replace: + if res.is_ok() { + ... res.unwrap() ... + } + with: + if let Ok(v) = res { + ... v ... + } + Binding once removes a panic site and lets the borrow checker reason + about the inner value. The Option twin is [[is-some-then-unwrap]]. diff --git a/.config/ast-grep/rules/is-some-then-unwrap.yml b/.config/ast-grep/rules/is-some-then-unwrap.yml index 70ff4fd..aa823ae 100644 --- a/.config/ast-grep/rules/is-some-then-unwrap.yml +++ b/.config/ast-grep/rules/is-some-then-unwrap.yml @@ -1,14 +1,38 @@ id: is-some-then-unwrap -language: Rust +language: rust severity: warning -message: Replace an is_some() check followed by unwrap() with if let Some(...). rule: - kind: if_expression all: + - pattern: | + if $OPT.is_some() { + $$$ + } - has: - field: condition - pattern: $VALUE.is_some() - - has: - field: consequence + pattern: $OPT.unwrap() stopBy: end - pattern: $VALUE.unwrap() +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + `if x.is_some()` + `x.unwrap()` — use `if let Some(v) = x` to bind once. +note: | + Replace: + if opt.is_some() { + ... opt.unwrap() ... + } + with: + if let Some(v) = opt { + ... v ... + } + Same shape for `Result`: prefer `if let Ok(v) = res` over `res.is_ok()` + followed by `res.unwrap()`. Binding once removes the panic site and lets + the borrow checker reason about the inner value. diff --git a/.config/ast-grep/rules/let-underscore-call.yml b/.config/ast-grep/rules/let-underscore-call.yml new file mode 100644 index 0000000..8a77ce7 --- /dev/null +++ b/.config/ast-grep/rules/let-underscore-call.yml @@ -0,0 +1,34 @@ +id: let-underscore-call +language: rust +severity: warning +rule: + all: + - pattern: let _ = $EXPR; + - not: + inside: + stopBy: end + kind: mod_item + follows: + kind: attribute_item + regex: "cfg\\(test\\)" +constraints: + EXPR: + kind: call_expression +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "**/test_utils.rs" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Discarded call result — `let _ = expr()` suppresses the unused-Result + warning, silently dropping any error. If the call can fail meaningfully, + handle or log the error. If failure is truly ignorable, add a comment + explaining why. diff --git a/.config/ast-grep/rules/manual-all-loop.yml b/.config/ast-grep/rules/manual-all-loop.yml new file mode 100644 index 0000000..6ad2e4a --- /dev/null +++ b/.config/ast-grep/rules/manual-all-loop.yml @@ -0,0 +1,56 @@ +id: manual-all-loop +language: rust +severity: warning +rule: + any: + - all: + - kind: expression_statement + - has: + pattern: | + for $X in $ITER { + if !$COND { + $FLAG = false; + break; + } + } + - follows: + pattern: let mut $FLAG = true; + - all: + - kind: expression_statement + - has: + pattern: | + for $X in $ITER { + if $COND { + $FLAG = false; + break; + } + } + - follows: + pattern: let mut $FLAG = true; +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Manual `all` loop — use `iter.all(...)` instead. +note: | + Replace: + let mut ok = true; + for x in iter { + if !pred(&x) { + ok = false; + break; + } + } + with: + let ok = iter.all(|x| pred(x)); + The inverse shape (`if cond { ok = false; break; }`) is also caught; in + that case flip the predicate: `iter.all(|x| !cond)`. diff --git a/.config/ast-grep/rules/manual-any-loop.yml b/.config/ast-grep/rules/manual-any-loop.yml new file mode 100644 index 0000000..f664209 --- /dev/null +++ b/.config/ast-grep/rules/manual-any-loop.yml @@ -0,0 +1,42 @@ +id: manual-any-loop +language: rust +severity: warning +rule: + all: + - kind: expression_statement + - has: + pattern: | + for $X in $ITER { + if $COND { + $FLAG = true; + break; + } + } + - follows: + pattern: let mut $FLAG = false; +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Manual `any` loop — use `iter.any(...)` instead. +note: | + Replace: + let mut ok = false; + for x in iter { + if pred(&x) { + ok = true; + break; + } + } + with: + let ok = iter.any(|x| pred(x)); + `.any()` short-circuits on the first match, just like the break does. diff --git a/.config/ast-grep/rules/manual-find-loop.yml b/.config/ast-grep/rules/manual-find-loop.yml new file mode 100644 index 0000000..1d87d13 --- /dev/null +++ b/.config/ast-grep/rules/manual-find-loop.yml @@ -0,0 +1,46 @@ +id: manual-find-loop +language: rust +severity: warning +rule: + all: + - kind: expression_statement + - has: + pattern: | + for $X in $ITER { + if $COND { + $FOUND = Some($VAL); + break; + } + } + - follows: + pattern: let mut $FOUND = None; +constraints: + X: + kind: identifier +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Manual `find` loop — use `iter.find(...)` instead. +note: | + Replace: + let mut found = None; + for x in iter { + if pred(&x) { + found = Some(x); + break; + } + } + with: + let found = iter.find(|x| pred(x)); + If you only need a yes/no answer, `.any(...)` reads more directly than + binding the value just to ignore it. diff --git a/.config/ast-grep/rules/manual-position-loop.yml b/.config/ast-grep/rules/manual-position-loop.yml new file mode 100644 index 0000000..e2d64bb --- /dev/null +++ b/.config/ast-grep/rules/manual-position-loop.yml @@ -0,0 +1,43 @@ +id: manual-position-loop +language: rust +severity: warning +rule: + all: + - kind: expression_statement + - has: + pattern: | + for ($I, $X) in $ITER { + if $COND { + $FOUND = Some($I); + break; + } + } + - follows: + pattern: let mut $FOUND = None; +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Manual `position` loop — use `iter.position(...)` instead. +note: | + Replace: + let mut idx = None; + for (i, x) in iter.enumerate() { + if pred(&x) { + idx = Some(i); + break; + } + } + with: + let idx = iter.position(|x| pred(x)); + `.position()` short-circuits like the break does and returns the index of + the first match. Use `.rposition()` if you walk back-to-front. diff --git a/.config/ast-grep/rules/map-collect-loop-with-let.yml b/.config/ast-grep/rules/map-collect-loop-with-let.yml new file mode 100644 index 0000000..9431166 --- /dev/null +++ b/.config/ast-grep/rules/map-collect-loop-with-let.yml @@ -0,0 +1,45 @@ +id: map-collect-loop-with-let +language: rust +severity: hint +rule: + all: + - pattern: | + for $X in $ITER { + let $Y = $LET_EXPR; + $VEC.push($PUSH); + } + - not: + inside: + kind: if_expression + stopBy: end +constraints: + VEC: + not: + kind: call_expression +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Push-only loop with a single `let` binding — likely a `.map(...).collect()`. +note: | + This is the [[map-collect-loop]] shape with one intermediate binding: + for x in iter { + let y = transform(x); + out.push(use_y(y)); + } + Usually rewrites as: + let out: Vec<_> = iter.map(|x| { + let y = transform(x); + use_y(y) + }).collect(); + Lower confidence than [[map-collect-loop]] because the body might rely + on side effects between `let` and `push`. Skim before accepting. diff --git a/.config/ast-grep/rules/map-collect-loop.yml b/.config/ast-grep/rules/map-collect-loop.yml new file mode 100644 index 0000000..854b41c --- /dev/null +++ b/.config/ast-grep/rules/map-collect-loop.yml @@ -0,0 +1,42 @@ +id: map-collect-loop +language: rust +severity: warning +rule: + all: + - pattern: | + for $X in $ITER { + $VEC.push($EXPR); + } + - not: + inside: + kind: if_expression + stopBy: end +constraints: + VEC: + not: + kind: call_expression +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Push-only loop — consider iter.map(...).collect() instead. +note: | + Replace: + for x in iter { vec.push(expr); } + with: + let vec: Vec<_> = iter.map(|x| expr).collect(); + or, if `vec` already exists and should be extended: + vec.extend(iter.map(|x| expr)); + The filter-then-push variant (`if cond { vec.push(...) }`) is covered by + filter-in-loop; this rule targets the unconditional shape. Chained-method + receivers (`HashMap::entry(...).or_default().push(...)`) are excluded + because they bucket into a HashMap, not append to a single Vec. diff --git a/.config/ast-grep/rules/match-ok-push-loop.yml b/.config/ast-grep/rules/match-ok-push-loop.yml new file mode 100644 index 0000000..f0e7942 --- /dev/null +++ b/.config/ast-grep/rules/match-ok-push-loop.yml @@ -0,0 +1,52 @@ +id: match-ok-push-loop +language: rust +severity: warning +rule: + any: + - pattern: | + for $X in $ITER { + match $EXPR { + Ok($Y) => $VEC.push($PUSH), + Err(_) => {} + } + } + - pattern: | + for $X in $ITER { + match $EXPR { + Err(_) => {}, + Ok($Y) => $VEC.push($PUSH), + } + } + - pattern: | + for $X in $ITER { + match $EXPR { + Ok($Y) => $VEC.push($PUSH), + _ => {} + } + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Match-Ok-push loop — collapse into `.filter_map(|x| f(x).ok())`. +note: | + Replace: + for x in iter { + match f(x) { + Ok(y) => out.push(y), + Err(_) => {} + } + } + with: + let out: Vec<_> = iter.filter_map(|x| f(x).ok()).collect(); + Errors disappear silently — if that matters, log them first: + .filter_map(|x| f(x).inspect_err(|e| warn!(%e)).ok()) diff --git a/.config/ast-grep/rules/match-option-verbose.yml b/.config/ast-grep/rules/match-option-verbose.yml new file mode 100644 index 0000000..54343cb --- /dev/null +++ b/.config/ast-grep/rules/match-option-verbose.yml @@ -0,0 +1,54 @@ +id: match-option-verbose +language: rust +severity: warning +rule: + all: + - any: + - pattern: | + match $OPT { + Some($X) => $SOME_BODY, + None => $NONE_BODY, + } + - pattern: | + match $OPT { + None => $NONE_BODY, + Some($X) => $SOME_BODY, + } + - pattern: | + match $OPT { + Some($X) => $SOME_BODY, + _ => $NONE_BODY, + } + - pattern: | + match $OPT { + Some(ref $X) => $SOME_BODY, + None => $NONE_BODY, + } + - not: + has: + pattern: return $$$ + stopBy: end +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Verbose match on Option — consider using Option combinators. +note: | + Alternatives: + opt.map_or(default, |x| ...) — both arms are expressions + opt.map_or_else(|| default, |x| ...) — lazy default + opt.unwrap_or(default) — extract value with fallback + opt.unwrap_or_else(|| ...) — lazy fallback + This catches all four arm orderings: `Some/None`, `None/Some`, + `Some/_`, and `Some(ref x)/None`. For `match opt.as_ref()`, write the + combinator on the original Option instead — `opt.as_ref().map(...)` + composes the same way. diff --git a/.config/ast-grep/rules/match-result-verbose.yml b/.config/ast-grep/rules/match-result-verbose.yml new file mode 100644 index 0000000..c9030f4 --- /dev/null +++ b/.config/ast-grep/rules/match-result-verbose.yml @@ -0,0 +1,53 @@ +id: match-result-verbose +language: rust +severity: warning +rule: + all: + - any: + - pattern: | + match $RES { + Ok($V) => $OK_BODY, + Err($E) => $ERR_BODY, + } + - pattern: | + match $RES { + Err($E) => $ERR_BODY, + Ok($V) => $OK_BODY, + } + - pattern: | + match $RES { + Ok($V) => $OK_BODY, + Err(_) => $ERR_BODY, + } + - pattern: | + match $RES { + Err(_) => $ERR_BODY, + Ok($V) => $OK_BODY, + } + - not: + has: + pattern: return $$$ + stopBy: end +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Verbose match on Result — consider Result combinators. +note: | + Alternatives: + res.map_or(default, |v| ...) — both arms are pure expressions + res.map_or_else(|e| ..., |v| ...) — both arms use their binding + res.unwrap_or(default) — extract Ok value with fallback + res.unwrap_or_else(|e| ...) — lazy fallback using the error + res.map(|v| ...).unwrap_or(default) — transform then default + Keep the explicit `match` when either arm has side effects, uses `?`, or + returns from the enclosing function. diff --git a/.config/ast-grep/rules/match-some-push-loop.yml b/.config/ast-grep/rules/match-some-push-loop.yml new file mode 100644 index 0000000..18ebc1b --- /dev/null +++ b/.config/ast-grep/rules/match-some-push-loop.yml @@ -0,0 +1,51 @@ +id: match-some-push-loop +language: rust +severity: warning +rule: + any: + - pattern: | + for $X in $ITER { + match $EXPR { + Some($Y) => $VEC.push($PUSH), + None => {} + } + } + - pattern: | + for $X in $ITER { + match $EXPR { + None => {}, + Some($Y) => $VEC.push($PUSH), + } + } + - pattern: | + for $X in $ITER { + match $EXPR { + Some($Y) => $VEC.push($PUSH), + _ => {} + } + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Match-Some-push loop — collapse into `.filter_map()`. +note: | + Replace: + for x in iter { + match f(x) { + Some(y) => out.push(y), + None => {} + } + } + with: + let out: Vec<_> = iter.filter_map(|x| f(x)).collect(); + This is the same shape as [[if-let-some-push-loop]] written with `match`. diff --git a/.config/ast-grep/rules/mut-fold-accumulator.yml b/.config/ast-grep/rules/mut-fold-accumulator.yml new file mode 100644 index 0000000..e242121 --- /dev/null +++ b/.config/ast-grep/rules/mut-fold-accumulator.yml @@ -0,0 +1,56 @@ +id: mut-fold-accumulator +language: rust +severity: warning +rule: + any: + - all: + - kind: expression_statement + - has: + pattern: | + for $X in $ITER { + $ACC += $RHS; + } + - follows: + pattern: let mut $ACC = $INIT; + - all: + - kind: expression_statement + - has: + pattern: | + for $X in $ITER { + $ACC = $ACC + $RHS; + } + - follows: + pattern: let mut $ACC = $INIT; + - all: + - kind: expression_statement + - has: + pattern: | + for $X in $ITER { + $ACC = $ACC.$OP($$$); + } + - follows: + pattern: let mut $ACC = $INIT; +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Mutable accumulator loop — consider `.fold(init, |acc, x| ...)`. +note: | + Replace: + let mut acc = init; + for x in iter { acc = acc.op(x); } // or acc += x; + with: + let acc = iter.fold(init, |acc, x| acc.op(x)); + Two common specializations: + iter.sum() — when acc starts at 0 and you `+= x` + iter.product() — when acc starts at 1 and you `*= x` + Prefer those when they fit; fold otherwise. diff --git a/.config/ast-grep/rules/push-str-format.yml b/.config/ast-grep/rules/push-str-format.yml new file mode 100644 index 0000000..a29b61f --- /dev/null +++ b/.config/ast-grep/rules/push-str-format.yml @@ -0,0 +1,25 @@ +id: push-str-format +language: rust +severity: error +rule: + pattern: $VAR.push_str(&format!($$$)) +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Avoid push_str(&format!(...)) — it allocates an intermediate String. + Use write!(&mut $VAR, ...) instead, which writes directly into the buffer. +note: | + Add `use std::fmt::Write;` then replace: + $VAR.push_str(&format!("...", args)) + with: + write!(&mut $VAR, "...", args).unwrap() diff --git a/.config/ast-grep/rules/silent-filter-map-ok.yml b/.config/ast-grep/rules/silent-filter-map-ok.yml new file mode 100644 index 0000000..08b03bd --- /dev/null +++ b/.config/ast-grep/rules/silent-filter-map-ok.yml @@ -0,0 +1,32 @@ +id: silent-filter-map-ok +language: rust +severity: warning +rule: + any: + - pattern: $EXPR.filter_map(Result::ok) + - pattern: $EXPR.filter_map(|$X| $X.ok()) + not: + inside: + stopBy: end + kind: mod_item + follows: + kind: attribute_item + regex: "cfg\\(test\\)" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "**/test_utils.rs" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Silent error filtering — .filter_map(Result::ok) silently drops all Err + values from the iterator. If any row/item fails to parse or deserialize, + it vanishes with no trace. Prefer logging failures: + .filter_map(|r| r.inspect_err(|e| warn!(%e, "skipped")).ok()) diff --git a/.config/ast-grep/rules/silent-map-err.yml b/.config/ast-grep/rules/silent-map-err.yml new file mode 100644 index 0000000..148097e --- /dev/null +++ b/.config/ast-grep/rules/silent-map-err.yml @@ -0,0 +1,32 @@ +id: silent-map-err +language: rust +severity: hint +rule: + all: + - pattern: $EXPR.map_err(|_| $$$BODY) + - not: + inside: + stopBy: end + kind: mod_item + follows: + kind: attribute_item + regex: "cfg\\(test\\)" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "**/test_utils.rs" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Original error discarded — .map_err(|_| ...) replaces the error without + preserving context, making failures harder to diagnose. Prefer: + .map_err(|e| MyError::Foo(e)) or .map_err(MyError::from) + For parse errors where the original is unhelpful, add .inspect_err() or + include the input value in the replacement error message. diff --git a/.config/ast-grep/rules/silent-ok-discard.yml b/.config/ast-grep/rules/silent-ok-discard.yml new file mode 100644 index 0000000..a56f6ea --- /dev/null +++ b/.config/ast-grep/rules/silent-ok-discard.yml @@ -0,0 +1,34 @@ +id: silent-ok-discard +language: rust +severity: warning +rule: + all: + - pattern: $EXPR.ok() + - inside: + kind: expression_statement + stopBy: neighbor + - not: + inside: + stopBy: end + kind: mod_item + follows: + kind: attribute_item + regex: "cfg\\(test\\)" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "**/test_utils.rs" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Silent .ok() discard — calling .ok() on a Result as a statement converts + the error to None and drops it. If the operation can fail meaningfully, + log the error or propagate with ?. If failure is truly ignorable, use + `let _ = expr;` with a comment explaining why. diff --git a/.config/ast-grep/rules/silent-unwrap-or-else.yml b/.config/ast-grep/rules/silent-unwrap-or-else.yml new file mode 100644 index 0000000..8e72f1c --- /dev/null +++ b/.config/ast-grep/rules/silent-unwrap-or-else.yml @@ -0,0 +1,31 @@ +id: silent-unwrap-or-else +language: rust +severity: warning +rule: + all: + - pattern: $EXPR.unwrap_or_else(|_| $$$BODY) + - not: + inside: + stopBy: end + kind: mod_item + follows: + kind: attribute_item + regex: "cfg\\(test\\)" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "**/test_utils.rs" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Silent error swallow — .unwrap_or_else(|_| ...) discards the error and + returns a default value. If this operation fails, the bug will be invisible. + Log the error first with .inspect_err(|e| warn!(%e, "context")), or + propagate with ?. diff --git a/.config/ast-grep/rules/stringly-typed-dispatch.yml b/.config/ast-grep/rules/stringly-typed-dispatch.yml new file mode 100644 index 0000000..b13687d --- /dev/null +++ b/.config/ast-grep/rules/stringly-typed-dispatch.yml @@ -0,0 +1,42 @@ +id: stringly-typed-dispatch +language: rust +severity: warning +rule: + kind: if_expression + all: + - has: + field: condition + pattern: $EXPR.contains($LIT) + - has: + kind: else_clause + has: + kind: if_expression + has: + field: condition + pattern: $EXPR2.contains($LIT2) + - not: + inside: + stopBy: end + kind: function_item + regex: "fn\\s+(from_str|parse)" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Stringly-typed dispatch — chained .contains() checks simulate enum + matching with strings. Define an enum with the variants and match on + it instead. +note: | + Define an enum and implement FromStr: + enum Model { Haiku, Opus, Sonnet } + let model = Model::from_str(name).unwrap_or(Model::Sonnet); + match model { Model::Haiku => ..., Model::Opus => ... } diff --git a/.config/ast-grep/rules/stringly-typed-match.yml b/.config/ast-grep/rules/stringly-typed-match.yml new file mode 100644 index 0000000..2b1689e --- /dev/null +++ b/.config/ast-grep/rules/stringly-typed-match.yml @@ -0,0 +1,49 @@ +id: stringly-typed-match +language: rust +severity: warning +rule: + all: + - kind: match_expression + - has: + field: value + pattern: $EXPR.as_str() + - not: + inside: + stopBy: end + kind: impl_item + regex: "impl\\s+FromStr" + - not: + inside: + stopBy: end + kind: impl_item + regex: "impl\\s+TryFrom<&str>" + - not: + inside: + stopBy: end + kind: impl_item + regex: "impl\\s+TryFrom" + - not: + inside: + stopBy: end + kind: function_item + regex: "fn\\s+(from_str|parse)" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Stringly-typed match — matching on .as_str() with string literal arms + loses exhaustiveness checking and type safety. Use the enum's existing + parse/from_str method, or define one if it doesn't exist. +note: | + If the enum has a from_str_loose/parse method, call it instead: + let value = MyEnum::from_str_loose(input).unwrap_or_default(); + match value { MyEnum::Foo => ..., MyEnum::Bar => ... } diff --git a/.config/ast-grep/rules/unwrap-in-prod.yml b/.config/ast-grep/rules/unwrap-in-prod.yml new file mode 100644 index 0000000..2813db5 --- /dev/null +++ b/.config/ast-grep/rules/unwrap-in-prod.yml @@ -0,0 +1,32 @@ +id: unwrap-in-prod +language: rust +severity: error +rule: + all: + - pattern: $EXPR.unwrap() + - not: + inside: + stopBy: end + kind: mod_item + follows: + kind: attribute_item + regex: "cfg\\(.*\\btest\\b" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "crates/**/examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "tracehealth-api/examples/**" + - "**/test_utils.rs" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Avoid .unwrap() in production code — prefer .expect("reason"), ?, or + combinators (.unwrap_or, .map_or, .ok_or_else) for better diagnostics + and graceful error handling. diff --git a/.config/ast-grep/rules/unwrap-or-default.yml b/.config/ast-grep/rules/unwrap-or-default.yml index ab0f57e..0b77577 100644 --- a/.config/ast-grep/rules/unwrap-or-default.yml +++ b/.config/ast-grep/rules/unwrap-or-default.yml @@ -1,8 +1,31 @@ id: unwrap-or-default -language: Rust -severity: warning -message: Use unwrap_or_default() instead of constructing a default value eagerly. +language: rust +severity: hint rule: - any: - - pattern: $VALUE.unwrap_or(Default::default()) - - pattern: $VALUE.unwrap_or($TYPE::default()) + all: + - pattern: $EXPR.unwrap_or_default() + - not: + inside: + stopBy: end + kind: mod_item + follows: + kind: attribute_item + regex: "cfg\\(test\\)" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "**/test_utils.rs" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Silent fallback — .unwrap_or_default() returns the Default value (empty + string, zero, empty vec, etc.) if the Result or Option is empty/failed. + On Results, this silently hides errors. Prefer .expect("reason"), + .inspect_err(|e| warn!(%e, "context")).unwrap_or_default(), or ?. diff --git a/.config/ast-grep/rules/with-capacity-push-loop.yml b/.config/ast-grep/rules/with-capacity-push-loop.yml new file mode 100644 index 0000000..b461272 --- /dev/null +++ b/.config/ast-grep/rules/with-capacity-push-loop.yml @@ -0,0 +1,36 @@ +id: with-capacity-push-loop +language: rust +severity: warning +rule: + kind: expression_statement + has: + pattern: | + for $X in $ITER { + $V.push($EXPR); + } + follows: + pattern: let mut $V = Vec::with_capacity($CAP); +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + `Vec::with_capacity` + push-loop — `iter.map(...).collect()` is shorter + and the size hint is propagated automatically when `ITER` is an + `ExactSizeIterator`. +note: | + Replace: + let mut v = Vec::with_capacity(iter.len()); + for x in iter { v.push(expr); } + with: + let v: Vec<_> = iter.map(|x| expr).collect(); + `collect()` calls `size_hint()` and pre-allocates, so the capacity hint + is preserved without you wiring it through by hand. diff --git a/.config/selfci/ci.sh b/.config/selfci/ci.sh index 5afcf23..e3f0881 100755 --- a/.config/selfci/ci.sh +++ b/.config/selfci/ci.sh @@ -6,6 +6,11 @@ function job_lint() { if ! treefmt --ci ; then selfci step fail fi + + selfci step start "ast-grep" + if ! ast-grep scan --config sgconfig.yml ; then + selfci step fail + fi } function job_checks() { From 5c52bc3aea0d2b84bc769d83abaee72f5f964399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Ci=C4=99=C5=BCarkiewicz?= Date: Fri, 24 Jul 2026 21:36:05 -0700 Subject: [PATCH 2/4] fix(lint): resolve ast-grep violations ### Summary Replace production unwraps, discarded Results, and verbose control flow reported by the imported ast-grep rules. Preserve the timer exit-status behavior, make Git staging failures observable at debug level, and use a lossy info-default RUST_LOG filter. ### Details Timer formatter tests now state why their operations must succeed. The docs command uses Option combinators, invalid TOML conversion retains its explicit invariant, and failed timer diagnostics remain non-fatal because stderr cannot report a write failure. `selfci check` passes lint and the code-level ast-grep scan; its ast-grep rule-test suite still fails in the parent rule-import change because four existing rule snapshots/expectations do not match rule behavior. --- flakebox-bin/src/main.rs | 37 ++++++++++++++------------ lib/pkgs/git-hook-check-timer-tests.rs | 8 +++--- lib/pkgs/git-hook-check-timer.rs | 17 ++++++++---- 3 files changed, 36 insertions(+), 26 deletions(-) diff --git a/flakebox-bin/src/main.rs b/flakebox-bin/src/main.rs index 05c0fdd..eb08f4d 100644 --- a/flakebox-bin/src/main.rs +++ b/flakebox-bin/src/main.rs @@ -37,22 +37,25 @@ fn main() -> AppResult<()> { match opts.command { Commands::Init => init(&opts)?, Commands::Install => install(&opts)?, - Commands::Docs { docs_dir } => { - if let Some(docs_dir) = docs_dir { - let docs_index = docs_dir.join("index.html"); - eprintln!("Opening docs available at {}", docs_index.display()); - cmd!("xdg-open", docs_index) - .run() - .change_context(AppError::General)?; - } else { + Commands::Docs { docs_dir } => docs_dir.map_or_else( + || { cmd!("nix", "build", "github:rustshop/flakebox#docs") .run() .change_context(AppError::General)?; cmd!("xdg-open", "result/index.html") .run() .change_context(AppError::General)?; - } - } + Ok::<(), error_stack::Report>(()) + }, + |docs_dir| { + let docs_index = docs_dir.join("index.html"); + eprintln!("Opening docs available at {}", docs_index.display()); + cmd!("xdg-open", docs_index) + .run() + .change_context(AppError::General)?; + Ok::<(), error_stack::Report>(()) + }, + )?, Commands::Lint { fix, silent } => match lint(&opts, fix, silent) { Err(e) if e.current_context() == &AppError::Lint => std::process::exit(1), other => other, @@ -172,11 +175,7 @@ fn item_as_table_mut<'item>( ) -> &'item mut toml_edit::Table { if !item.is_table() { let owned = std::mem::take(item); - *item = toml_edit::Item::Table( - owned - .into_table() - .unwrap_or_else(|_| panic!("{invalid_message}")), - ); + *item = toml_edit::Item::Table(owned.into_table().expect(invalid_message)); } item.as_table_mut() @@ -304,7 +303,9 @@ fn install_files(src: &Path, dst: &Path) -> AppResult<()> { } else { remove_file_or_symlink(&dst_path).change_context_lazy(|| AppError::IO)?; fs::copy(source_path, &dst_path).change_context_lazy(|| AppError::IO)?; - let _ = cmd!("git", "add", &dst_path).run(); + if let Err(error) = cmd!("git", "add", &dst_path).run() { + tracing::debug!(%error, path = %dst_path.display(), "could not stage installed file"); + } chmod_non_writable(&dst_path)?; } @@ -364,7 +365,9 @@ fn init_logging() { let subscriber = tracing_subscriber::fmt() .with_writer(std::io::stderr) // Print to stderr .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + EnvFilter::builder() + .with_default_directive("info".parse().expect("info is a valid filter directive")) + .from_env_lossy(), ) .finish(); diff --git a/lib/pkgs/git-hook-check-timer-tests.rs b/lib/pkgs/git-hook-check-timer-tests.rs index 54ca90c..d85ed46 100644 --- a/lib/pkgs/git-hook-check-timer-tests.rs +++ b/lib/pkgs/git-hook-check-timer-tests.rs @@ -45,7 +45,7 @@ fn warning_formatter_compacts_only_redundant_newlines() { .concat(); let mut output = Vec::new(); - compact_warning_spacing(&input[..], &mut output).unwrap(); + compact_warning_spacing(&input[..], &mut output).expect("warning formatter succeeds"); assert_eq!( output, @@ -64,7 +64,7 @@ fn warning_formatter_preserves_unterminated_and_binary_output() { .concat(); let mut output = Vec::new(); - compact_warning_spacing(&input[..], &mut output).unwrap(); + compact_warning_spacing(&input[..], &mut output).expect("warning formatter succeeds"); assert_eq!( output, @@ -77,7 +77,7 @@ fn warning_formatter_preserves_ordinary_output_byte_exactly() { let input = b"\0ordinary\n\noutput without newline"; let mut output = Vec::new(); - compact_warning_spacing(&input[..], &mut output).unwrap(); + compact_warning_spacing(&input[..], &mut output).expect("warning formatter succeeds"); assert_eq!(output, input); } @@ -91,7 +91,7 @@ fn warning_formatter_does_not_prefix_first_warning() { .concat(); let mut output = Vec::new(); - compact_warning_spacing(&input[..], &mut output).unwrap(); + compact_warning_spacing(&input[..], &mut output).expect("warning formatter succeeds"); assert_eq!(output, b"flakebox: warning: check_slow took 1.200s (>1s)\n"); } diff --git a/lib/pkgs/git-hook-check-timer.rs b/lib/pkgs/git-hook-check-timer.rs index 24babad..b9e31ab 100644 --- a/lib/pkgs/git-hook-check-timer.rs +++ b/lib/pkgs/git-hook-check-timer.rs @@ -174,14 +174,21 @@ fn main() { "\n" }; let warning_record = format!("{separator}{warning}\n"); - let _ = io::stderr().lock().write_all(warning_record.as_bytes()); + let stderr_write_result = io::stderr().lock().write_all(warning_record.as_bytes()); + if stderr_write_result.is_err() { + // Stderr is unavailable, so the diagnostic cannot be reported + // elsewhere. + } } } - match check_result { - Ok(check_result) => exit_like_check(check_result.status), - Err(error) => timer_error(&format!("could not run check {check_name:?}: {error}")), - } + exit_like_check( + check_result + .unwrap_or_else(|error| { + timer_error(&format!("could not run check {check_name:?}: {error}")) + }) + .status, + ); } /// Streams hook stderr while replacing timer markers with a needed separator. From d8494b2df4f4c4c8bb5bd7f60ab1a16ed9d52c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Ci=C4=99=C5=BCarkiewicz?= Date: Fri, 24 Jul 2026 21:51:19 -0700 Subject: [PATCH 3/4] test(ast-grep): synchronize rule expectations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary Update ast-grep test expectations and snapshots to match the imported rules’ actual diagnostics. Align the production unwrap rule exclusions with the fixture coverage, so ignored test code remains exempt while production code still fails the check. ### Details The unwrap-or-default test now flags only `unwrap_or_default`, which is the operation its rule matches. Regenerated snapshots remove obsolete condition labels and record the new unwrap-or-default diagnostic. The flake check now creates ignore fixtures at paths relative to the ast-grep project root, and `unwrap-in-prod` recognizes the same test path forms exercised by that check. ### Validation - `nix develop -c selfci check` --- .../is-err-then-unwrap-err-snapshot.yml | 4 --- .../is-ok-then-unwrap-snapshot.yml | 4 --- .../is-some-then-unwrap-snapshot.yml | 4 --- .../unwrap-or-default-snapshot.yml | 6 +++++ .../rule-tests/unwrap-or-default-test.yml | 4 +-- .config/ast-grep/rules/unwrap-in-prod.yml | 17 ++++++------ checks/ast-grep-rules.nix | 26 +++++++++---------- 7 files changed, 29 insertions(+), 36 deletions(-) diff --git a/.config/ast-grep/rule-tests/__snapshots__/is-err-then-unwrap-err-snapshot.yml b/.config/ast-grep/rule-tests/__snapshots__/is-err-then-unwrap-err-snapshot.yml index dff7614..7c8084b 100644 --- a/.config/ast-grep/rule-tests/__snapshots__/is-err-then-unwrap-err-snapshot.yml +++ b/.config/ast-grep/rule-tests/__snapshots__/is-err-then-unwrap-err-snapshot.yml @@ -12,10 +12,6 @@ snapshots: style: primary start: 0 end: 55 - - source: result.is_err() - style: secondary - start: 3 - end: 18 - source: result.unwrap_err() style: secondary start: 32 diff --git a/.config/ast-grep/rule-tests/__snapshots__/is-ok-then-unwrap-snapshot.yml b/.config/ast-grep/rule-tests/__snapshots__/is-ok-then-unwrap-snapshot.yml index 5c65957..703f5fc 100644 --- a/.config/ast-grep/rule-tests/__snapshots__/is-ok-then-unwrap-snapshot.yml +++ b/.config/ast-grep/rule-tests/__snapshots__/is-ok-then-unwrap-snapshot.yml @@ -12,10 +12,6 @@ snapshots: style: primary start: 0 end: 51 - - source: result.is_ok() - style: secondary - start: 3 - end: 17 - source: result.unwrap() style: secondary start: 32 diff --git a/.config/ast-grep/rule-tests/__snapshots__/is-some-then-unwrap-snapshot.yml b/.config/ast-grep/rule-tests/__snapshots__/is-some-then-unwrap-snapshot.yml index 168d872..adb82b5 100644 --- a/.config/ast-grep/rule-tests/__snapshots__/is-some-then-unwrap-snapshot.yml +++ b/.config/ast-grep/rule-tests/__snapshots__/is-some-then-unwrap-snapshot.yml @@ -12,10 +12,6 @@ snapshots: style: primary start: 0 end: 51 - - source: value.is_some() - style: secondary - start: 3 - end: 18 - source: value.unwrap() style: secondary start: 33 diff --git a/.config/ast-grep/rule-tests/__snapshots__/unwrap-or-default-snapshot.yml b/.config/ast-grep/rule-tests/__snapshots__/unwrap-or-default-snapshot.yml index 82fb7b0..5d962d4 100644 --- a/.config/ast-grep/rule-tests/__snapshots__/unwrap-or-default-snapshot.yml +++ b/.config/ast-grep/rule-tests/__snapshots__/unwrap-or-default-snapshot.yml @@ -12,3 +12,9 @@ snapshots: style: primary start: 0 end: 34 + value.unwrap_or_default(): + labels: + - source: value.unwrap_or_default() + style: primary + start: 0 + end: 25 diff --git a/.config/ast-grep/rule-tests/unwrap-or-default-test.yml b/.config/ast-grep/rule-tests/unwrap-or-default-test.yml index 8d62342..626f77b 100644 --- a/.config/ast-grep/rule-tests/unwrap-or-default-test.yml +++ b/.config/ast-grep/rule-tests/unwrap-or-default-test.yml @@ -1,7 +1,7 @@ id: unwrap-or-default valid: - - value.unwrap_or_default() - value.unwrap_or(fallback) -invalid: - value.unwrap_or(Default::default()) - value.unwrap_or(String::default()) +invalid: + - value.unwrap_or_default() diff --git a/.config/ast-grep/rules/unwrap-in-prod.yml b/.config/ast-grep/rules/unwrap-in-prod.yml index 2813db5..72487ac 100644 --- a/.config/ast-grep/rules/unwrap-in-prod.yml +++ b/.config/ast-grep/rules/unwrap-in-prod.yml @@ -12,15 +12,14 @@ rule: kind: attribute_item regex: "cfg\\(.*\\btest\\b" ignores: - - "crates/**/tests/**" - - "crates/**/benches/**" - - "benches/**" - - "tests/**" - - "examples/**" - - "crates/**/examples/**" - - "tracehealth-api/tests/**" - - "tracehealth-api/benches/**" - - "tracehealth-api/examples/**" + - "**/benches/**" + - "**/examples/**" + - "**/tests/**" + - "**/tests.rs" + - "**/*-tests.rs" + - "**/*_test.rs" + - "**/*_tests.rs" + - "**/test_*.rs" - "**/test_utils.rs" - "vendor/**" - ".claude/worktrees/**" diff --git a/checks/ast-grep-rules.nix b/checks/ast-grep-rules.nix index 554e988..5843f18 100644 --- a/checks/ast-grep-rules.nix +++ b/checks/ast-grep-rules.nix @@ -21,25 +21,25 @@ pkgs.runCommand "ast-grep-rules" ast-grep test mkdir -p \ - ast-grep-fixtures/benches \ - ast-grep-fixtures/examples \ - ast-grep-fixtures/tests \ - ast-grep-fixtures/src + benches \ + examples \ + tests \ + src for path in \ - ast-grep-fixtures/benches/case.rs \ - ast-grep-fixtures/examples/case.rs \ - ast-grep-fixtures/tests/case.rs \ - ast-grep-fixtures/src/check-tests.rs \ - ast-grep-fixtures/src/check_test.rs \ - ast-grep-fixtures/src/check_tests.rs \ - ast-grep-fixtures/src/test_check.rs \ - ast-grep-fixtures/src/tests.rs + benches/case.rs \ + examples/case.rs \ + tests/case.rs \ + src/check-tests.rs \ + src/check_test.rs \ + src/check_tests.rs \ + src/test_check.rs \ + src/tests.rs do printf 'fn fixture() { value.unwrap(); }\n' > "$path" ast-grep scan --error "$path" done - production_fixture=ast-grep-fixtures/src/check.rs + production_fixture=src/check.rs printf 'fn fixture() { value.unwrap(); }\n' > "$production_fixture" if ast-grep scan --error "$production_fixture" > violation.log 2>&1; then echo "production unwrap fixture unexpectedly passed" >&2 From 4ee448a8ac11cd53c1d4a23c775db207f1a2a7b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Ci=C4=99=C5=BCarkiewicz?= Date: Tue, 28 Jul 2026 12:46:19 -0700 Subject: [PATCH 4/4] fix(flakebox-bin): accept Cargo resolver 3 in lint Cargo resolver 3 is valid for edition-2024 workspaces, but the lint treated every resolver except 2 as stale and its automatic fix rewrote 3 to 2. Accept resolver values 2 and 3 after Cargo metadata has validated the manifest. Continue recommending resolver 2 for missing, legacy, or malformed values. Add regression tests covering accepted resolver 2/3 values and rejected missing, v1, and non-string values. Reviews: - General review passed with no issues. The review covered correctness, test adequacy, Rust style, and Linked Specs consistency. --- flakebox-bin/src/main.rs | 59 ++++++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/flakebox-bin/src/main.rs b/flakebox-bin/src/main.rs index eb08f4d..13a7906 100644 --- a/flakebox-bin/src/main.rs +++ b/flakebox-bin/src/main.rs @@ -185,17 +185,14 @@ fn item_as_table_mut<'item>( fn lint_cargo_toml(opts: &Opts, problems: &mut Vec) -> AppResult<()> { let (path, cargo_toml) = load_root_cargo_toml(opts)?; - if let Some(toml_edit::Item::Table(workspace)) = cargo_toml.get("workspace") { - match workspace.get("resolver") { - Some(toml_edit::Item::Value(toml_edit::Value::String(v))) if v.value() == "2" => {} - _ => { - problems.push(LintItem { - path: path.clone(), - msg: "`workspace.resolver` missing or not set to 'v2'".to_string(), - fix: Some(lint_cargo_toml_fix_resolver_v2), - }); - } - } + if let Some(toml_edit::Item::Table(workspace)) = cargo_toml.get("workspace") + && !workspace_resolver_is_supported(workspace) + { + problems.push(LintItem { + path: path.clone(), + msg: "`workspace.resolver` missing or not set to '2' or '3'".to_string(), + fix: Some(lint_cargo_toml_fix_resolver_v2), + }); } if cargo_toml .get("profile") @@ -211,6 +208,14 @@ fn lint_cargo_toml(opts: &Opts, problems: &mut Vec) -> AppResult<()> { Ok(()) } +fn workspace_resolver_is_supported(workspace: &toml_edit::Table) -> bool { + matches!( + workspace.get("resolver"), + Some(toml_edit::Item::Value(toml_edit::Value::String(resolver))) + if resolver.value() == "2" || resolver.value() == "3" + ) +} + #[derive(Deserialize)] struct CargoMetadataOutput { workspace_root: PathBuf, @@ -378,6 +383,38 @@ fn init_logging() { mod tests { use super::*; + #[test] + fn cargo_workspace_resolver_accepts_current_versions() { + for resolver in ["2", "3"] { + let cargo_toml = format!("[workspace]\nresolver = \"{resolver}\"\n") + .parse::() + .expect("valid test manifest"); + let workspace = cargo_toml["workspace"] + .as_table() + .expect("workspace is a table"); + + assert!(workspace_resolver_is_supported(workspace)); + } + } + + #[test] + fn cargo_workspace_resolver_rejects_stale_or_invalid_values() { + for manifest in [ + "[workspace]\n", + "[workspace]\nresolver = \"1\"\n", + "[workspace]\nresolver = 3\n", + ] { + let cargo_toml = manifest + .parse::() + .expect("valid test manifest"); + let workspace = cargo_toml["workspace"] + .as_table() + .expect("workspace is a table"); + + assert!(!workspace_resolver_is_supported(workspace)); + } + } + #[test] fn cargo_profile_defaults_disable_debug_info_and_preserve_inline_dev_settings() { let mut cargo_toml = "profile = { dev = { package = { \"*\" = { opt-level = 1 } } } }\n"