Skip to main content

slopguard_core/rule/
mod.rs

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