Skip to main content

slopguard_core/rule/
mod.rs

1use std::fmt;
2use std::io::Error as IoError;
3use std::path::{Component, Path, PathBuf};
4use std::str::Utf8Error;
5
6use ast_grep_language::SupportLang;
7use serde::{Deserialize, Serialize};
8use serde_yaml::Value;
9use strum::{Display, EnumIter, EnumString};
10use thiserror::Error;
11
12use crate::cross_file::CrossFileKind;
13use crate::fix::FixerKind;
14use crate::metric::Metric;
15use crate::resolution::ResolutionKind;
16use crate::source::RuleOrigin;
17
18mod load;
19
20pub use load::{
21    is_rule_active, load_all_rules, load_builtin_rules, load_custom_rules, load_effective_rules,
22    load_source_rules, read_fixture, validate_unique_ids,
23};
24
25#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub struct RuleId(pub String);
27
28impl fmt::Display for RuleId {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        f.write_str(&self.0)
31    }
32}
33
34impl RuleId {
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl From<String> for RuleId {
41    fn from(s: String) -> Self {
42        Self(s)
43    }
44}
45
46impl From<&str> for RuleId {
47    fn from(s: &str) -> Self {
48        Self(s.to_string())
49    }
50}
51
52#[derive(Debug, Display, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54#[strum(serialize_all = "lowercase")]
55pub enum Severity {
56    Error,
57    Warning,
58}
59
60#[derive(Debug, Display, EnumIter, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62#[strum(serialize_all = "lowercase")]
63pub enum Language {
64    Rust,
65    TypeScript,
66}
67
68impl Language {
69    /// The ast-grep languages a rule written for this language is compiled
70    /// against. TypeScript rules also cover TSX files.
71    pub fn ast_grep_langs(&self) -> &'static [SupportLang] {
72        match self {
73            Language::Rust => &[SupportLang::Rust],
74            Language::TypeScript => &[SupportLang::TypeScript, SupportLang::Tsx],
75        }
76    }
77}
78
79#[derive(Debug, Display, EnumString, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "lowercase")]
81#[strum(serialize_all = "lowercase")]
82pub enum Category {
83    Slop,
84    Security,
85    #[default]
86    Correctness,
87}
88
89impl Category {
90    /// Derive the category from the first directory of a rule path relative to
91    /// its ruleset root (e.g. `security/no-debug.yml`). Returns `None` when the
92    /// path has no directory or the directory is not a known category.
93    fn from_rule_path(path: &Path) -> Option<Self> {
94        match path.components().next()? {
95            Component::Normal(dir) => dir.to_str()?.parse().ok(),
96            _ => None,
97        }
98    }
99}
100
101/// Whether an autofix preserves behavior. `scan --fix` applies `Safe` fixes;
102/// `Unsafe` ones also need `--unsafe-fixes`.
103#[derive(Debug, Display, Clone, Copy, PartialEq, Eq)]
104#[strum(serialize_all = "lowercase")]
105pub enum FixSafety {
106    Safe,
107    Unsafe,
108}
109
110/// One rewrite assertion for an autofixable rule: `slopguard test` applies the
111/// rule's `rewrite` to `before` and requires the result to equal `after`. This
112/// is what proves a `rewrite` is correct, alongside `should_match`.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct FixCase {
115    pub before: String,
116    pub after: String,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct RuleTests {
121    #[serde(default)]
122    pub should_match: Vec<String>,
123    #[serde(default)]
124    pub should_not_match: Vec<String>,
125    /// Rewrite assertions checked by `slopguard test` for autofixable rules.
126    /// Each case's `before` is rewritten with the rule's `rewrite` and must
127    /// equal `after`. Ignored for rules that are not autofixable.
128    #[serde(default)]
129    pub should_fix: Vec<FixCase>,
130    /// Whole-file fixtures for metric rules, resolved against the embedded
131    /// ruleset root (builtin rules) or the custom rule directory the rule was
132    /// loaded from. A metric cannot be measured on a snippet.
133    #[serde(default)]
134    pub should_match_files: Vec<String>,
135    #[serde(default)]
136    pub should_not_match_files: Vec<String>,
137}
138
139/// How the `reason` (the finding's `note`) is produced when an `ai_check` rule
140/// fires. See [`AiCheck::reason`].
141#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
142#[serde(rename_all = "lowercase")]
143#[strum(serialize_all = "lowercase")]
144pub enum ReasonMode {
145    /// Use the rule's static `note` as the reason. No generative LLM call.
146    #[default]
147    Static,
148    /// Escalate to the generative LLM to write a per-instance reason. Reserved
149    /// for relational rules whose message depends on the specific match.
150    Generated,
151}
152
153/// Optional AI confirmation step for a rule. When present, the rule's `rule`
154/// AST pattern acts as a cheap pre-filter: matches become candidates that an
155/// LLM (or the System One classifier) must confirm before they are reported.
156/// The AST layer never executes this field; the AI pipeline in `slopguard-ai`
157/// reads it.
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159pub struct AiCheck {
160    /// Prompt template. Double use: the generative LLM prompt (variables
161    /// `{{code}}`, `{{filename}}`, `{{rule_context}}`), and the `instructions`
162    /// of the System One classifier's noul question.
163    pub prompt: String,
164    /// Model override for this rule. Falls back to `[ai].model` in config.
165    #[serde(default)]
166    pub model: Option<String>,
167    /// How the finding's reason is produced: a `static` note (default) or a
168    /// per-instance `generated` note written by the LLM.
169    #[serde(default)]
170    pub reason: ReasonMode,
171    /// Per-rule classifier probability threshold, overriding
172    /// `[ai.classifier].threshold`. Fires when `p >= threshold`.
173    #[serde(default)]
174    pub threshold: Option<f64>,
175    /// Classifier calibration: what a "yes" (issue) looks like. Maps to the
176    /// noul question's `criteria.true`.
177    #[serde(default)]
178    pub if_true: Option<String>,
179    /// Classifier calibration: what a "no" (not an issue) looks like. Maps to
180    /// the noul question's `criteria.false`.
181    #[serde(default)]
182    pub if_false: Option<String>,
183}
184
185fn default_enabled() -> bool {
186    true
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct Rule {
191    pub id: RuleId,
192    pub language: Language,
193    pub severity: Severity,
194    pub message: String,
195    #[serde(default)]
196    pub note: Option<String>,
197    #[serde(default)]
198    pub category: Option<Category>,
199    #[serde(default)]
200    pub fix: Option<String>,
201    /// ast-grep rewrite (fix) template applied to this rule's match by
202    /// `scan --fix`. Distinct from `fix`, which is a free-text human message:
203    /// `rewrite` carries the replacement pattern (it may reference the rule's
204    /// metavariables). The AST detection pass never reads this field.
205    #[serde(default)]
206    pub rewrite: Option<String>,
207    /// Builtin fixer applied by `scan --fix` instead of a `rewrite` template,
208    /// for rewrites that edit several nodes (an item and its attributes, two
209    /// match arms) or read another file. Mutually exclusive with `rewrite`. The
210    /// kind selects builtin Rust logic, so an unknown name is a parse error.
211    #[serde(default)]
212    pub fixer: Option<FixerKind>,
213    /// Opt-in marker that this rule's `rewrite` (or `fixer`) is safe to apply
214    /// automatically (idempotent, no semantic change). `scan --fix` ignores the
215    /// rewrite of any rule without it or `autofix_unsafe`. Defaults to `false`.
216    /// On a rule from `custom_dirs` or `[[rules.sources]]` it is not enough:
217    /// the rule id must also be listed in the user config's
218    /// `fix.allow_external`.
219    #[serde(default)]
220    pub autofix_safe: bool,
221    /// Opt-in marker for a rewrite that deliberately changes behavior (it
222    /// recovers a poisoned lock instead of panicking, it lets a derived
223    /// `Display` honor width flags). Applied only by `scan --fix
224    /// --unsafe-fixes`. Mutually exclusive with `autofix_safe`.
225    #[serde(default)]
226    pub autofix_unsafe: bool,
227    /// Matcher used only by `scan --fix` to locate nodes to rewrite, when it must
228    /// be narrower than the detection `rule` (detect broadly, fix narrowly). A
229    /// rule can flag every variant of a pattern while auto-rewriting only the
230    /// subset whose `rewrite` is provably safe. `null` (the default) means
231    /// `--fix` reuses `rule`. The detection pass never reads this field.
232    /// `--fix` only rewrites an `autofix_rule` match whose range lies inside a
233    /// match of the detection `rule` (with its `constraints`), so it can narrow
234    /// but never widen what detection reports.
235    #[serde(default, skip_serializing_if = "Value::is_null")]
236    pub autofix_rule: Value,
237    #[serde(default)]
238    pub rule: Value,
239    /// ast-grep metavariable constraints, a sibling of `rule`. Passed through
240    /// verbatim so a rule can restrict a captured metavariable (e.g. bind the
241    /// receiver of `$R.clone()` to an already-owned value). `null` when unset.
242    #[serde(default, skip_serializing_if = "Value::is_null")]
243    pub constraints: Value,
244    /// File-level metric evaluated instead of `rule`. Mutually exclusive with it.
245    #[serde(default)]
246    pub metric: Option<Metric>,
247    /// The value `metric` must exceed for the rule to fire. Required with `metric`.
248    #[serde(default)]
249    pub threshold: Option<f64>,
250    /// Minimum file length in lines before a metric rule is evaluated. Below it
251    /// the file is skipped, so ratios are not distorted by tiny files (a small
252    /// module full of required doc comments). `None` means no floor.
253    #[serde(default)]
254    pub min_lines: Option<usize>,
255    /// Project-wide analysis evaluated instead of `rule` or `metric`, and
256    /// mutually exclusive with both. The kind selects builtin Rust logic; the
257    /// YAML only carries the rule's identity, severity and user-facing text.
258    #[serde(default)]
259    pub cross_file: Option<CrossFileKind>,
260    /// Per-file import resolution evaluated instead of `rule`, `metric` or
261    /// `cross_file`, and mutually exclusive with all three. The kind selects
262    /// builtin resolution logic; the YAML only carries the rule's identity,
263    /// severity and user-facing text. `$import` in `message` is replaced with
264    /// the unresolved specifier.
265    #[serde(default)]
266    pub resolution: Option<ResolutionKind>,
267    /// Directory a custom rule was loaded from, used to resolve test fixture
268    /// paths. `None` for builtin rules, which resolve against `BuiltinRules`.
269    #[serde(skip)]
270    pub source_dir: Option<PathBuf>,
271    /// Provenance of this rule (builtin, git source, or local path), stamped at
272    /// load time and reported by `slopguard list`. Never present in YAML.
273    #[serde(skip)]
274    pub origin: RuleOrigin,
275    #[serde(default)]
276    pub files: Option<Vec<String>>,
277    #[serde(default)]
278    pub ignores: Option<Vec<String>>,
279    /// Drop findings located inside `#[cfg(test)]` blocks of scanned Rust files.
280    /// Complements `ignores`, which only filters whole files by path.
281    #[serde(default = "default_enabled")]
282    pub enabled: bool,
283    #[serde(default)]
284    pub skip_test_code: bool,
285    #[serde(default)]
286    pub tests: Option<RuleTests>,
287    /// When set, matches of `rule` are candidates confirmed by an LLM before
288    /// being reported. See [`AiCheck`]. The AST scanner ignores this field.
289    #[serde(default)]
290    pub ai_check: Option<AiCheck>,
291}
292
293impl Rule {
294    /// The rule's category, resolving an unset category to the default
295    /// (`Correctness`), the way the scanner treats it.
296    pub fn effective_category(&self) -> Category {
297        self.category.clone().unwrap_or_default()
298    }
299
300    /// Whether this rule measures a file-level metric instead of matching AST.
301    pub fn is_metric(&self) -> bool {
302        self.metric.is_some()
303    }
304
305    /// The metric and threshold of a file-level rule, `None` for AST rules.
306    pub fn metric_spec(&self) -> Option<(Metric, f64)> {
307        Some((self.metric?, self.threshold?))
308    }
309
310    /// Whether this rule is evaluated by the project-wide pass instead of per file.
311    pub fn is_cross_file(&self) -> bool {
312        self.cross_file.is_some()
313    }
314
315    /// Whether this rule is evaluated by the per-file import resolution pass.
316    pub fn is_resolution(&self) -> bool {
317        self.resolution.is_some()
318    }
319
320    /// How safe this rule's autofix is: `None` when `scan --fix` never rewrites
321    /// its matches (no `rewrite` or `fixer`, or neither `autofix_safe` nor
322    /// `autofix_unsafe`). An external (non-builtin) rule additionally needs its
323    /// id in `fix.allow_external`, which `scan --fix` checks on top of this.
324    pub fn fix_safety(&self) -> Option<FixSafety> {
325        if self.rewrite.is_none() && self.fixer.is_none() {
326            return None;
327        }
328        if self.autofix_safe {
329            Some(FixSafety::Safe)
330        } else if self.autofix_unsafe {
331            Some(FixSafety::Unsafe)
332        } else {
333            None
334        }
335    }
336
337    /// Whether `scan --fix` may rewrite this rule's matches at some safety level.
338    pub fn is_autofixable(&self) -> bool {
339        self.fix_safety().is_some()
340    }
341
342    /// The matcher `scan --fix` uses to find nodes to rewrite: the dedicated
343    /// `autofix_rule` when set, otherwise the detection `rule`.
344    pub fn fix_matcher(&self) -> &Value {
345        if self.autofix_rule.is_null() {
346            &self.rule
347        } else {
348            &self.autofix_rule
349        }
350    }
351
352    /// The cross-file analysis this rule requests, `None` for AST and metric rules.
353    // Read-only accessor for a private field.
354    // slopguard-disable-next-line no-trivial-function
355    pub fn cross_file_kind(&self) -> Option<CrossFileKind> {
356        self.cross_file
357    }
358
359    /// The resolution analysis this rule requests, `None` for all other rules.
360    // Read-only accessor for a private field.
361    // slopguard-disable-next-line no-trivial-function
362    pub fn resolution_kind(&self) -> Option<ResolutionKind> {
363        self.resolution
364    }
365}
366
367#[derive(Debug, Error)]
368pub enum RuleError {
369    #[error("failed to parse rule YAML: {0}")]
370    Parse(#[from] serde_yaml::Error),
371
372    #[error("rule '{id}': rule field must not be empty")]
373    EmptyRule { id: RuleId },
374
375    #[error("rule '{id}': 'metric' and 'rule' are mutually exclusive")]
376    MetricAndRule { id: RuleId },
377
378    #[error("rule '{id}': 'cross_file' and 'rule' are mutually exclusive")]
379    CrossFileAndRule { id: RuleId },
380
381    #[error("rule '{id}': 'cross_file' and 'metric' are mutually exclusive")]
382    CrossFileAndMetric { id: RuleId },
383
384    #[error("rule '{id}': 'resolution' and 'rule' are mutually exclusive")]
385    ResolutionAndRule { id: RuleId },
386
387    #[error("rule '{id}': 'resolution' and 'metric' are mutually exclusive")]
388    ResolutionAndMetric { id: RuleId },
389
390    #[error("rule '{id}': 'resolution' and 'cross_file' are mutually exclusive")]
391    ResolutionAndCrossFile { id: RuleId },
392
393    #[error("rule '{id}': a metric rule requires a 'threshold'")]
394    MissingThreshold { id: RuleId },
395
396    #[error("rule '{id}': 'rewrite' and 'fixer' are mutually exclusive")]
397    RewriteAndFixer { id: RuleId },
398
399    #[error("rule '{id}': a 'fixer' needs an ast-grep 'rule' to locate its matches")]
400    FixerWithoutRule { id: RuleId },
401
402    #[error("rule '{id}': 'autofix_safe' and 'autofix_unsafe' are mutually exclusive")]
403    SafeAndUnsafeFix { id: RuleId },
404
405    #[error("rule '{id}': test fixture files are only supported for metric rules")]
406    FixturesOnAstRule { id: RuleId },
407
408    #[error("rule '{id}': fixture path '{path}' must be relative and must not contain '..'")]
409    InvalidFixturePath { id: RuleId, path: String },
410
411    #[error("rule '{id}': fixture '{path}' not found")]
412    FixtureNotFound { id: RuleId, path: String },
413
414    #[error("duplicate rule id: '{id}'")]
415    DuplicateId { id: RuleId },
416
417    #[error("rule '{id}' from source '{origin}' reuses a builtin rule id; rename it (external sources may not shadow builtin rules)")]
418    SourceReusesBuiltinId { id: RuleId, origin: String },
419
420    #[error("failed to read rule file '{path}': {source}")]
421    Io {
422        path: String,
423        #[source]
424        source: IoError,
425    },
426
427    #[error("rule file '{path}' is not valid UTF-8: {source}")]
428    Utf8 {
429        path: String,
430        #[source]
431        source: Utf8Error,
432    },
433}
434
435impl RuleError {
436    fn io(path: &Path, source: IoError) -> Self {
437        Self::Io {
438            path: path.display().to_string(),
439            source,
440        }
441    }
442}
443
444/// Parse a single YAML string into a Rule, validating required fields.
445///
446/// A rule carries exactly one matcher: an ast-grep `rule`, a file-level
447/// `metric` plus its `threshold`, or a project-wide `cross_file` kind.
448pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
449    let rule: Rule = serde_yaml::from_str(yaml)?;
450    let has_rule = !rule.rule.is_null();
451    let has_metric = rule.metric.is_some();
452    let has_cross = rule.cross_file.is_some();
453    let has_resolution = rule.resolution.is_some();
454    if has_resolution {
455        if has_rule {
456            return Err(RuleError::ResolutionAndRule { id: rule.id });
457        }
458        if has_metric {
459            return Err(RuleError::ResolutionAndMetric { id: rule.id });
460        }
461        if has_cross {
462            return Err(RuleError::ResolutionAndCrossFile { id: rule.id });
463        }
464    }
465    match (has_cross, has_metric, has_rule) {
466        (true, _, true) => return Err(RuleError::CrossFileAndRule { id: rule.id }),
467        (true, true, _) => return Err(RuleError::CrossFileAndMetric { id: rule.id }),
468        (false, true, true) => return Err(RuleError::MetricAndRule { id: rule.id }),
469        (false, true, false) if rule.threshold.is_none() => {
470            return Err(RuleError::MissingThreshold { id: rule.id })
471        }
472        (false, false, false) if !has_resolution => {
473            return Err(RuleError::EmptyRule { id: rule.id })
474        }
475        _ => {}
476    }
477    if rule.fixer.is_some() {
478        if rule.rewrite.is_some() {
479            return Err(RuleError::RewriteAndFixer { id: rule.id });
480        }
481        if !has_rule {
482            return Err(RuleError::FixerWithoutRule { id: rule.id });
483        }
484    }
485    if rule.autofix_safe && rule.autofix_unsafe {
486        return Err(RuleError::SafeAndUnsafeFix { id: rule.id });
487    }
488    if !rule.is_metric() {
489        if let Some(tests) = &rule.tests {
490            if !tests.should_match_files.is_empty() || !tests.should_not_match_files.is_empty() {
491                return Err(RuleError::FixturesOnAstRule { id: rule.id });
492            }
493        }
494    }
495    Ok(rule)
496}
497
498#[cfg(test)]
499mod load_tests;
500#[cfg(test)]
501mod parse_tests;