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