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#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct RuleTests {
97    #[serde(default)]
98    pub should_match: Vec<String>,
99    #[serde(default)]
100    pub should_not_match: Vec<String>,
101    /// Whole-file fixtures for metric rules, resolved against the embedded
102    /// ruleset root (builtin rules) or the custom rule directory the rule was
103    /// loaded from. A metric cannot be measured on a snippet.
104    #[serde(default)]
105    pub should_match_files: Vec<String>,
106    #[serde(default)]
107    pub should_not_match_files: Vec<String>,
108}
109
110/// Optional AI confirmation step for a rule. When present, the rule's `rule`
111/// AST pattern acts as a cheap pre-filter: matches become candidates that an
112/// LLM must confirm before they are reported. The AST layer never executes
113/// this field; the AI pipeline in `slopguard-ai` reads it.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct AiCheck {
116    /// Prompt template sent to the model. Supports the variables `{{code}}`,
117    /// `{{filename}}`, and `{{rule_context}}`.
118    pub prompt: String,
119    /// Model override for this rule. Falls back to `[ai].model` in config.
120    #[serde(default)]
121    pub model: Option<String>,
122}
123
124fn default_enabled() -> bool {
125    true
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct Rule {
130    pub id: RuleId,
131    pub language: Language,
132    pub severity: Severity,
133    pub message: String,
134    #[serde(default)]
135    pub note: Option<String>,
136    #[serde(default)]
137    pub category: Option<Category>,
138    #[serde(default)]
139    pub fix: Option<String>,
140    #[serde(default)]
141    pub rule: Value,
142    /// File-level metric evaluated instead of `rule`. Mutually exclusive with it.
143    #[serde(default)]
144    pub metric: Option<Metric>,
145    /// The value `metric` must exceed for the rule to fire. Required with `metric`.
146    #[serde(default)]
147    pub threshold: Option<f64>,
148    /// Minimum file length in lines before a metric rule is evaluated. Below it
149    /// the file is skipped, so ratios are not distorted by tiny files (a small
150    /// module full of required doc comments). `None` means no floor.
151    #[serde(default)]
152    pub min_lines: Option<usize>,
153    /// Project-wide analysis evaluated instead of `rule` or `metric`, and
154    /// mutually exclusive with both. The kind selects builtin Rust logic; the
155    /// YAML only carries the rule's identity, severity and user-facing text.
156    #[serde(default)]
157    pub cross_file: Option<CrossFileKind>,
158    /// Directory a custom rule was loaded from, used to resolve test fixture
159    /// paths. `None` for builtin rules, which resolve against `BuiltinRules`.
160    #[serde(skip)]
161    pub source_dir: Option<PathBuf>,
162    #[serde(default)]
163    pub files: Option<Vec<String>>,
164    #[serde(default)]
165    pub ignores: Option<Vec<String>>,
166    /// Drop findings located inside `#[cfg(test)]` blocks of scanned Rust files.
167    /// Complements `ignores`, which only filters whole files by path.
168    #[serde(default = "default_enabled")]
169    pub enabled: bool,
170    #[serde(default)]
171    pub skip_test_code: bool,
172    #[serde(default)]
173    pub tests: Option<RuleTests>,
174    /// When set, matches of `rule` are candidates confirmed by an LLM before
175    /// being reported. See [`AiCheck`]. The AST scanner ignores this field.
176    #[serde(default)]
177    pub ai_check: Option<AiCheck>,
178}
179
180impl Rule {
181    /// The rule's category, resolving an unset category to the default
182    /// (`Correctness`), the way the scanner treats it.
183    pub fn effective_category(&self) -> Category {
184        self.category.clone().unwrap_or_default()
185    }
186
187    /// Whether this rule measures a file-level metric instead of matching AST.
188    pub fn is_metric(&self) -> bool {
189        self.metric.is_some()
190    }
191
192    /// The metric and threshold of a file-level rule, `None` for AST rules.
193    pub fn metric_spec(&self) -> Option<(Metric, f64)> {
194        Some((self.metric?, self.threshold?))
195    }
196
197    /// Whether this rule is evaluated by the project-wide pass instead of per file.
198    pub fn is_cross_file(&self) -> bool {
199        self.cross_file.is_some()
200    }
201
202    /// The cross-file analysis this rule requests, `None` for AST and metric rules.
203    // Read-only accessor for a private field.
204    // slopguard-disable-next-line no-trivial-function
205    pub fn cross_file_kind(&self) -> Option<CrossFileKind> {
206        self.cross_file
207    }
208}
209
210#[derive(Debug, Error)]
211pub enum RuleError {
212    #[error("failed to parse rule YAML: {0}")]
213    Parse(#[from] serde_yaml::Error),
214
215    #[error("rule '{id}': rule field must not be empty")]
216    EmptyRule { id: RuleId },
217
218    #[error("rule '{id}': 'metric' and 'rule' are mutually exclusive")]
219    MetricAndRule { id: RuleId },
220
221    #[error("rule '{id}': 'cross_file' and 'rule' are mutually exclusive")]
222    CrossFileAndRule { id: RuleId },
223
224    #[error("rule '{id}': 'cross_file' and 'metric' are mutually exclusive")]
225    CrossFileAndMetric { id: RuleId },
226
227    #[error("rule '{id}': a metric rule requires a 'threshold'")]
228    MissingThreshold { id: RuleId },
229
230    #[error("rule '{id}': test fixture files are only supported for metric rules")]
231    FixturesOnAstRule { id: RuleId },
232
233    #[error("rule '{id}': fixture path '{path}' must be relative and must not contain '..'")]
234    InvalidFixturePath { id: RuleId, path: String },
235
236    #[error("rule '{id}': fixture '{path}' not found")]
237    FixtureNotFound { id: RuleId, path: String },
238
239    #[error("duplicate rule id: '{id}'")]
240    DuplicateId { id: RuleId },
241
242    #[error("failed to read rule file '{path}': {source}")]
243    Io {
244        path: String,
245        #[source]
246        source: IoError,
247    },
248
249    #[error("rule file '{path}' is not valid UTF-8: {source}")]
250    Utf8 {
251        path: String,
252        #[source]
253        source: Utf8Error,
254    },
255}
256
257impl RuleError {
258    fn io(path: &Path, source: IoError) -> Self {
259        Self::Io {
260            path: path.display().to_string(),
261            source,
262        }
263    }
264}
265
266/// Parse a single YAML string into a Rule, validating required fields.
267///
268/// A rule carries exactly one matcher: an ast-grep `rule`, a file-level
269/// `metric` plus its `threshold`, or a project-wide `cross_file` kind.
270pub fn parse_rule(yaml: &str) -> Result<Rule, RuleError> {
271    let rule: Rule = serde_yaml::from_str(yaml)?;
272    let has_rule = !rule.rule.is_null();
273    let has_metric = rule.metric.is_some();
274    let has_cross = rule.cross_file.is_some();
275    match (has_cross, has_metric, has_rule) {
276        (true, _, true) => return Err(RuleError::CrossFileAndRule { id: rule.id }),
277        (true, true, _) => return Err(RuleError::CrossFileAndMetric { id: rule.id }),
278        (false, true, true) => return Err(RuleError::MetricAndRule { id: rule.id }),
279        (false, true, false) if rule.threshold.is_none() => {
280            return Err(RuleError::MissingThreshold { id: rule.id })
281        }
282        (false, false, false) => return Err(RuleError::EmptyRule { id: rule.id }),
283        _ => {}
284    }
285    if !rule.is_metric() {
286        if let Some(tests) = &rule.tests {
287            if !tests.should_match_files.is_empty() || !tests.should_not_match_files.is_empty() {
288                return Err(RuleError::FixturesOnAstRule { id: rule.id });
289            }
290        }
291    }
292    Ok(rule)
293}
294
295/// Read a whole-file test fixture referenced by `tests.should_match_files` or
296/// `tests.should_not_match_files`. Builtin rules resolve against the embedded
297/// ruleset; custom rules resolve against the directory they were loaded from.
298pub fn read_fixture(rule: &Rule, relative: &str) -> Result<String, RuleError> {
299    let path = Path::new(relative);
300    let traverses = path.components().any(|c| matches!(c, Component::ParentDir));
301    if path.is_absolute() || traverses {
302        return Err(RuleError::InvalidFixturePath {
303            id: rule.id.clone(),
304            path: relative.to_string(),
305        });
306    }
307
308    match &rule.source_dir {
309        Some(dir) => {
310            let full = dir.join(path);
311            read_to_string(&full).map_err(|e| {
312                if e.kind() == std::io::ErrorKind::NotFound {
313                    RuleError::FixtureNotFound {
314                        id: rule.id.clone(),
315                        path: relative.to_string(),
316                    }
317                } else {
318                    RuleError::io(&full, e)
319                }
320            })
321        }
322        None => {
323            let file = BuiltinRules::get(relative).ok_or_else(|| RuleError::FixtureNotFound {
324                id: rule.id.clone(),
325                path: relative.to_string(),
326            })?;
327            from_utf8(&file.data)
328                .map(str::to_string)
329                .map_err(|e| RuleError::Utf8 {
330                    path: relative.to_string(),
331                    source: e,
332                })
333        }
334    }
335}
336
337/// Parse a rule and fill in its category from the ruleset-relative path when
338/// the YAML omits it.
339fn parse_rule_at(yaml: &str, relative_path: &Path) -> Result<Rule, RuleError> {
340    let mut rule = parse_rule(yaml)?;
341    if rule.category.is_none() {
342        rule.category = Category::from_rule_path(relative_path);
343    }
344    Ok(rule)
345}
346
347fn is_rule_file(path: &Path) -> bool {
348    matches!(
349        path.extension().and_then(|e| e.to_str()),
350        Some("yml" | "yaml")
351    )
352}
353
354/// Load all builtin rules embedded via rust-embed.
355pub fn load_builtin_rules() -> Result<Vec<Rule>, RuleError> {
356    BuiltinRules::iter()
357        .filter(|path| is_rule_file(Path::new(path.as_ref())))
358        .filter_map(|path| BuiltinRules::get(&path).map(|file| (path, file)))
359        .map(|(path, file)| {
360            let yaml = from_utf8(&file.data).map_err(|e| RuleError::Utf8 {
361                path: path.to_string(),
362                source: e,
363            })?;
364            parse_rule_at(yaml, Path::new(path.as_ref()))
365        })
366        .collect()
367}
368
369/// Load rules from custom directories on the filesystem. Missing directories
370/// are skipped; subdirectories are searched recursively.
371pub fn load_custom_rules(dirs: &[PathBuf]) -> Result<Vec<Rule>, RuleError> {
372    let mut rules = Vec::new();
373    for dir in dirs.iter().filter(|dir| dir.is_dir()) {
374        // Custom rule directories are plain file trees: gitignore and
375        // hidden-file filtering would silently drop rules.
376        for entry in WalkBuilder::new(dir).standard_filters(false).build() {
377            let entry = entry.map_err(|e| RuleError::io(dir, IoError::other(e)))?;
378            let path = entry.path();
379            let is_file = entry.file_type().is_some_and(|t| t.is_file());
380            if !is_file || !is_rule_file(path) {
381                continue;
382            }
383            let yaml = read_to_string(path).map_err(|e| RuleError::io(path, e))?;
384            let relative = path.strip_prefix(dir).unwrap_or(path);
385            let mut rule = parse_rule_at(&yaml, relative)?;
386            rule.source_dir = Some(dir.clone());
387            rules.push(rule);
388        }
389    }
390    Ok(rules)
391}
392
393/// Whether a builtin rule takes part in a scan under `config`.
394///
395/// A rule listed in `rules.enable` is always active. Otherwise it must be
396/// enabled by default, belong to an active ruleset, and not be listed in
397/// `rules.disable`.
398pub fn is_rule_active(rule: &Rule, config: &crate::config::Config) -> bool {
399    let listed = |ids: &[String]| ids.iter().any(|id| id == rule.id.as_str());
400    if listed(&config.rules.enable) {
401        return true;
402    }
403    let ruleset_on = match rule.effective_category() {
404        Category::Slop => config.rulesets.slop,
405        Category::Security => config.rulesets.security,
406        Category::Correctness => config.rulesets.correctness,
407    };
408    ruleset_on && rule.enabled && !listed(&config.rules.disable)
409}
410
411/// Load all effective rules based on config: builtin (filtered by rulesets and
412/// the enable/disable lists) plus custom rules from configured directories.
413pub fn load_effective_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
414    let mut rules: Vec<Rule> = load_builtin_rules()?
415        .into_iter()
416        .filter(|rule| is_rule_active(rule, config))
417        .collect();
418
419    let custom = load_custom_rules(&config.rules.custom_dirs)?;
420    rules.extend(custom);
421    validate_unique_ids(&rules)?;
422    Ok(rules)
423}
424
425/// Load all rules (builtin + custom) without filtering by activation status.
426/// Used by `explain` to look up any rule regardless of config.
427pub fn load_all_rules(config: &crate::config::Config) -> Result<Vec<Rule>, RuleError> {
428    let mut rules = load_builtin_rules()?;
429    let custom = load_custom_rules(&config.rules.custom_dirs)?;
430    rules.extend(custom);
431    Ok(rules)
432}
433
434/// Validate that all rule (id, language) pairs are unique. The same rule id
435/// may appear for different languages (e.g. `no-todo-fixme` for both Rust and
436/// TypeScript), so a single `rules.disable` entry covers both variants.
437pub fn validate_unique_ids(rules: &[Rule]) -> Result<(), RuleError> {
438    let mut seen = HashSet::new();
439    for rule in rules {
440        if !seen.insert((&rule.id, &rule.language)) {
441            return Err(RuleError::DuplicateId {
442                id: rule.id.clone(),
443            });
444        }
445    }
446    Ok(())
447}
448
449#[cfg(test)]
450mod load_tests;
451#[cfg(test)]
452mod parse_tests;