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