Skip to main content

ailint_core/rules/
mod.rs

1//! The rule engine: rule trait, IDs, severity, violations, and the registry
2//! that dispatches to the concrete rule modules.
3
4pub mod consistency;
5pub mod registry;
6pub mod security;
7pub mod semantic;
8pub mod structural;
9
10use std::fmt;
11use std::path::PathBuf;
12
13use serde::Serialize;
14
15use crate::file_type::FileType;
16use crate::parser::ParsedDocument;
17
18/// Parse an embedded dictionary asset: one entry per line, skipping blank
19/// lines and `#` comments.
20pub(crate) fn dictionary_lines(raw: &'static str) -> Vec<&'static str> {
21    raw.lines()
22        .map(str::trim)
23        .filter(|l| !l.is_empty() && !l.starts_with('#'))
24        .collect()
25}
26
27/// Numeric-code + slug identifier for a rule (e.g. `AIL001` /
28/// `no-frontmatter-schema-error`). Every rule owns exactly one `RuleId`.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
30pub struct RuleId {
31    /// Numeric part of the `AILNNN` code.
32    pub code: u16,
33    /// Kebab-case human-readable name.
34    pub slug: &'static str,
35}
36
37impl RuleId {
38    /// Const constructor so rules can define their ID as a `const`.
39    pub const fn new(code: u16, slug: &'static str) -> Self {
40        Self { code, slug }
41    }
42
43    /// Format the code as `AILNNN`.
44    pub fn code_str(&self) -> String {
45        format!("AIL{:03}", self.code)
46    }
47}
48
49impl fmt::Display for RuleId {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        write!(f, "{}({})", self.code_str(), self.slug)
52    }
53}
54
55/// Severity of a violation.
56#[derive(
57    Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize, schemars::JsonSchema,
58)]
59#[serde(rename_all = "lowercase")]
60pub enum Severity {
61    /// Fails the run (non-zero exit).
62    Error,
63    /// Reported; fails only past `--max-warnings`.
64    Warning,
65    /// Advisory only.
66    Info,
67}
68
69impl Severity {
70    /// Lowercase name, matching the serde representation.
71    pub fn as_str(self) -> &'static str {
72        match self {
73            Self::Error => "error",
74            Self::Warning => "warning",
75            Self::Info => "info",
76        }
77    }
78}
79
80/// A concrete finding produced by a rule.
81#[derive(Debug, Clone, Serialize)]
82pub struct Violation {
83    /// Rule that produced this finding.
84    pub rule_id: RuleId,
85    /// Effective severity (config overrides applied).
86    pub severity: Severity,
87    /// Human-readable description of the finding.
88    pub message: String,
89    /// File the finding was raised against.
90    pub file: PathBuf,
91    /// 1-based line number, if known.
92    pub line: Option<usize>,
93    /// 1-based column, if known.
94    pub column: Option<usize>,
95    /// Suggested remediation, if the rule offers one.
96    pub fix_hint: Option<String>,
97    /// Offending source excerpt, if captured.
98    pub snippet: Option<String>,
99    /// Link to the rule's documentation page.
100    pub source_url: Option<String>,
101    /// Varying finding-specific detail (link target, offending phrase,
102    /// duplicate line ref, YAML error text). Reporters show this once per
103    /// row instead of repeating a boilerplate prefix on every message.
104    pub detail: Option<String>,
105}
106
107impl Violation {
108    /// Create a violation with no location or hint attached.
109    pub fn new(
110        rule_id: RuleId,
111        severity: Severity,
112        file: PathBuf,
113        message: impl Into<String>,
114    ) -> Self {
115        Self {
116            rule_id,
117            severity,
118            file,
119            message: message.into(),
120            line: None,
121            column: None,
122            fix_hint: None,
123            snippet: None,
124            source_url: None,
125            detail: None,
126        }
127    }
128
129    /// Attach a 1-based line and column.
130    pub fn at(mut self, line: usize, column: usize) -> Self {
131        self.line = Some(line);
132        self.column = Some(column);
133        self
134    }
135
136    /// Attach the varying finding-specific detail.
137    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
138        self.detail = Some(detail.into());
139        self
140    }
141}
142
143/// Per-invocation context passed to a rule's `run` method.
144#[derive(Debug)]
145pub struct RuleContext<'a> {
146    /// Full resolved configuration.
147    pub config: &'a crate::config::Config,
148    /// Rule-specific options, keyed under this rule's slug in `RulesConfig::options`.
149    pub options: Option<&'a serde_yaml::Value>,
150    /// Severity to use — the rule's default may be overridden via config.
151    pub severity: Severity,
152}
153
154/// Trait implemented by every per-document lint rule.
155pub trait Rule: Send + Sync {
156    /// This rule's stable identifier.
157    fn id(&self) -> RuleId;
158    /// Severity when no config override applies.
159    fn default_severity(&self) -> Severity;
160    /// One-line human description of what the rule enforces. Reporters show
161    /// this once per rule group so an auditor knows *why* it fired.
162    fn description(&self) -> &'static str;
163    /// One-line suggested remediation. Empty string means the rule has no
164    /// generic hint (the caller should look at the finding detail instead).
165    fn fix_hint(&self) -> &'static str;
166    /// Inspect one document and return any findings.
167    fn run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation>;
168    /// Whether this rule should run against files of the given type. Defaults
169    /// to "AI guidance files only" — rules that also apply to generic
170    /// Markdown / YAML must override this.
171    fn applies_to(&self, file_type: FileType) -> bool {
172        file_type.is_ai_guidance()
173    }
174}
175
176/// Trait implemented by rules that need the full corpus at once
177/// (cross-file consistency checks).
178pub trait BatchRule: Send + Sync {
179    /// This rule's stable identifier.
180    fn id(&self) -> RuleId;
181    /// Severity when no config override applies.
182    fn default_severity(&self) -> Severity;
183    /// One-line human description of what the rule enforces.
184    fn description(&self) -> &'static str;
185    /// One-line suggested remediation.
186    fn fix_hint(&self) -> &'static str;
187    /// Inspect the whole corpus at once and return any findings.
188    fn run_batch(&self, docs: &[ParsedDocument], ctx: &RuleContext<'_>) -> Vec<Violation>;
189    /// Filter applied to each document before the batch rule sees it.
190    /// Defaults to "AI guidance files only".
191    fn applies_to(&self, file_type: FileType) -> bool {
192        file_type.is_ai_guidance()
193    }
194}