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, 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}
102
103impl Violation {
104 /// Create a violation with no location or hint attached.
105 pub fn new(
106 rule_id: RuleId,
107 severity: Severity,
108 file: PathBuf,
109 message: impl Into<String>,
110 ) -> Self {
111 Self {
112 rule_id,
113 severity,
114 file,
115 message: message.into(),
116 line: None,
117 column: None,
118 fix_hint: None,
119 snippet: None,
120 source_url: None,
121 }
122 }
123
124 /// Attach a 1-based line and column.
125 pub fn at(mut self, line: usize, column: usize) -> Self {
126 self.line = Some(line);
127 self.column = Some(column);
128 self
129 }
130}
131
132/// Per-invocation context passed to a rule's `run` method.
133#[derive(Debug)]
134pub struct RuleContext<'a> {
135 /// Full resolved configuration.
136 pub config: &'a crate::config::Config,
137 /// Rule-specific options, keyed under this rule's slug in `RulesConfig::options`.
138 pub options: Option<&'a serde_yaml::Value>,
139 /// Severity to use — the rule's default may be overridden via config.
140 pub severity: Severity,
141}
142
143/// Trait implemented by every per-document lint rule.
144pub trait Rule: Send + Sync {
145 /// This rule's stable identifier.
146 fn id(&self) -> RuleId;
147 /// Severity when no config override applies.
148 fn default_severity(&self) -> Severity;
149 /// Inspect one document and return any findings.
150 fn run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation>;
151 /// Whether this rule should run against files of the given type. Defaults
152 /// to "AI guidance files only" — rules that also apply to generic
153 /// Markdown / YAML must override this.
154 fn applies_to(&self, file_type: FileType) -> bool {
155 file_type.is_ai_guidance()
156 }
157}
158
159/// Trait implemented by rules that need the full corpus at once
160/// (cross-file consistency checks).
161pub trait BatchRule: Send + Sync {
162 /// This rule's stable identifier.
163 fn id(&self) -> RuleId;
164 /// Severity when no config override applies.
165 fn default_severity(&self) -> Severity;
166 /// Inspect the whole corpus at once and return any findings.
167 fn run_batch(&self, docs: &[ParsedDocument], ctx: &RuleContext<'_>) -> Vec<Violation>;
168 /// Filter applied to each document before the batch rule sees it.
169 /// Defaults to "AI guidance files only".
170 fn applies_to(&self, file_type: FileType) -> bool {
171 file_type.is_ai_guidance()
172 }
173}