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 single deterministic edit to a document's raw text.
81///
82/// Ranges are byte offsets into `ParsedDocument::raw`. Multiple `TextEdit`s
83/// against one file must not overlap; the fix pipeline sorts descending by
84/// `range.start` and refuses to apply a fileset whose edits collide.
85#[derive(Debug, Clone, Serialize)]
86pub struct TextEdit {
87 /// Byte range in the original document to replace.
88 pub range: std::ops::Range<usize>,
89 /// Replacement text; may be empty to delete the range.
90 pub replacement: String,
91}
92
93/// A concrete finding produced by a rule.
94#[derive(Debug, Clone, Serialize)]
95pub struct Violation {
96 /// Rule that produced this finding.
97 pub rule_id: RuleId,
98 /// Effective severity (config overrides applied).
99 pub severity: Severity,
100 /// Human-readable description of the finding.
101 pub message: String,
102 /// File the finding was raised against.
103 pub file: PathBuf,
104 /// 1-based line number, if known.
105 pub line: Option<usize>,
106 /// 1-based column, if known.
107 pub column: Option<usize>,
108 /// Suggested remediation, if the rule offers one.
109 pub fix_hint: Option<String>,
110 /// Offending source excerpt, if captured.
111 pub snippet: Option<String>,
112 /// Link to the rule's documentation page.
113 pub source_url: Option<String>,
114 /// Varying finding-specific detail (link target, offending phrase,
115 /// duplicate line ref, YAML error text). Reporters show this once per
116 /// row instead of repeating a boilerplate prefix on every message.
117 pub detail: Option<String>,
118 /// Deterministic edits that, if applied, resolve this finding. Empty
119 /// when no automated fix is available.
120 #[serde(skip_serializing_if = "Vec::is_empty", default)]
121 pub fixes: Vec<TextEdit>,
122}
123
124impl Violation {
125 /// Create a violation with no location or hint attached.
126 pub fn new(
127 rule_id: RuleId,
128 severity: Severity,
129 file: PathBuf,
130 message: impl Into<String>,
131 ) -> Self {
132 Self {
133 rule_id,
134 severity,
135 file,
136 message: message.into(),
137 line: None,
138 column: None,
139 fix_hint: None,
140 snippet: None,
141 source_url: None,
142 detail: None,
143 fixes: Vec::new(),
144 }
145 }
146
147 /// Attach a 1-based line and column.
148 pub fn at(mut self, line: usize, column: usize) -> Self {
149 self.line = Some(line);
150 self.column = Some(column);
151 self
152 }
153
154 /// Attach the varying finding-specific detail.
155 pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
156 self.detail = Some(detail.into());
157 self
158 }
159
160 /// Attach a deterministic text edit that resolves this finding.
161 pub fn with_fix(mut self, fix: TextEdit) -> Self {
162 self.fixes.push(fix);
163 self
164 }
165}
166
167/// Per-invocation context passed to a rule's `run` method.
168#[derive(Debug)]
169pub struct RuleContext<'a> {
170 /// Full resolved configuration.
171 pub config: &'a crate::config::Config,
172 /// Rule-specific options, keyed under this rule's slug in `RulesConfig::options`.
173 pub options: Option<&'a serde_yaml::Value>,
174 /// Severity to use — the rule's default may be overridden via config.
175 pub severity: Severity,
176}
177
178/// Trait implemented by every per-document lint rule.
179pub trait Rule: Send + Sync {
180 /// This rule's stable identifier.
181 fn id(&self) -> RuleId;
182 /// Severity when no config override applies.
183 fn default_severity(&self) -> Severity;
184 /// One-line human description of what the rule enforces. Reporters show
185 /// this once per rule group so an auditor knows *why* it fired.
186 fn description(&self) -> &'static str;
187 /// One-line suggested remediation. Empty string means the rule has no
188 /// generic hint (the caller should look at the finding detail instead).
189 fn fix_hint(&self) -> &'static str;
190 /// Inspect one document and return any findings.
191 fn run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation>;
192 /// Whether this rule should run against files of the given type. Defaults
193 /// to "AI guidance files only" — rules that also apply to generic
194 /// Markdown / YAML must override this.
195 fn applies_to(&self, file_type: FileType) -> bool {
196 file_type.is_ai_guidance()
197 }
198}
199
200/// Trait implemented by rules that need the full corpus at once
201/// (cross-file consistency checks).
202pub trait BatchRule: Send + Sync {
203 /// This rule's stable identifier.
204 fn id(&self) -> RuleId;
205 /// Severity when no config override applies.
206 fn default_severity(&self) -> Severity;
207 /// One-line human description of what the rule enforces.
208 fn description(&self) -> &'static str;
209 /// One-line suggested remediation.
210 fn fix_hint(&self) -> &'static str;
211 /// Inspect the whole corpus at once and return any findings.
212 fn run_batch(&self, docs: &[ParsedDocument], ctx: &RuleContext<'_>) -> Vec<Violation>;
213 /// Filter applied to each document before the batch rule sees it.
214 /// Defaults to "AI guidance files only".
215 fn applies_to(&self, file_type: FileType) -> bool {
216 file_type.is_ai_guidance()
217 }
218}