1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize)]
12pub struct LintRule {
13 pub id: &'static str,
14 pub category: RuleCategory,
15 pub default_severity: RuleSeverity,
16 pub description: &'static str,
17 pub effort_minutes: u32,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "lowercase")]
24pub enum RuleCategory {
25 Validate,
26 Audit,
27 Score,
28 Provability,
29 Trend,
30 Suppression,
31 Enforcement,
32 Verify,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
37#[serde(rename_all = "lowercase")]
38pub enum RuleSeverity {
39 Off,
40 Info,
41 Warning,
42 Error,
43}
44
45impl RuleSeverity {
46 pub fn from_str_opt(s: &str) -> Option<Self> {
47 match s {
48 "off" => Some(Self::Off),
49 "info" => Some(Self::Info),
50 "warning" | "warn" => Some(Self::Warning),
51 "error" => Some(Self::Error),
52 _ => None,
53 }
54 }
55
56 pub fn as_str(self) -> &'static str {
57 match self {
58 Self::Off => "off",
59 Self::Info => "info",
60 Self::Warning => "warning",
61 Self::Error => "error",
62 }
63 }
64
65 pub fn sarif_level(self) -> &'static str {
66 match self {
67 Self::Off => "none",
68 Self::Info => "note",
69 Self::Warning => "warning",
70 Self::Error => "error",
71 }
72 }
73}
74
75pub static RULES: &[LintRule] = &[
77 LintRule {
79 id: "PV-VAL-001",
80 category: RuleCategory::Validate,
81 default_severity: RuleSeverity::Error,
82 description: "Schema validation error (parse failure or missing section)",
83 effort_minutes: 5,
84 },
85 LintRule {
86 id: "PV-VAL-002",
87 category: RuleCategory::Validate,
88 default_severity: RuleSeverity::Error,
89 description: "Missing metadata.version",
90 effort_minutes: 5,
91 },
92 LintRule {
93 id: "PV-VAL-003",
94 category: RuleCategory::Validate,
95 default_severity: RuleSeverity::Warning,
96 description: "Missing metadata.created",
97 effort_minutes: 5,
98 },
99 LintRule {
100 id: "PV-VAL-004",
101 category: RuleCategory::Validate,
102 default_severity: RuleSeverity::Error,
103 description: "Empty equation formula",
104 effort_minutes: 5,
105 },
106 LintRule {
107 id: "PV-VAL-005",
108 category: RuleCategory::Validate,
109 default_severity: RuleSeverity::Error,
110 description: "Empty proof obligation property",
111 effort_minutes: 5,
112 },
113 LintRule {
114 id: "PV-VAL-006",
115 category: RuleCategory::Validate,
116 default_severity: RuleSeverity::Warning,
117 description: "Duplicate formal predicate in proof obligations",
118 effort_minutes: 5,
119 },
120 LintRule {
122 id: "PV-AUD-001",
123 category: RuleCategory::Audit,
124 default_severity: RuleSeverity::Warning,
125 description: "Obligation without falsification test",
126 effort_minutes: 15,
127 },
128 LintRule {
129 id: "PV-AUD-002",
130 category: RuleCategory::Audit,
131 default_severity: RuleSeverity::Info,
132 description: "Missing paper reference",
133 effort_minutes: 15,
134 },
135 LintRule {
136 id: "PV-AUD-003",
137 category: RuleCategory::Audit,
138 default_severity: RuleSeverity::Warning,
139 description: "Obligation ID not referenced by any test",
140 effort_minutes: 15,
141 },
142 LintRule {
143 id: "PV-AUD-004",
144 category: RuleCategory::Audit,
145 default_severity: RuleSeverity::Warning,
146 description: "Equation without domain specification",
147 effort_minutes: 15,
148 },
149 LintRule {
150 id: "PV-AUD-005",
151 category: RuleCategory::Audit,
152 default_severity: RuleSeverity::Warning,
153 description: "Missing tolerance for numerical obligation",
154 effort_minutes: 15,
155 },
156 LintRule {
158 id: "PV-SCR-001",
159 category: RuleCategory::Score,
160 default_severity: RuleSeverity::Error,
161 description: "Composite score below threshold",
162 effort_minutes: 30,
163 },
164 LintRule {
165 id: "PV-SCR-002",
166 category: RuleCategory::Score,
167 default_severity: RuleSeverity::Warning,
168 description: "Missing binding entry for equation",
169 effort_minutes: 30,
170 },
171 LintRule {
172 id: "PV-SCR-003",
173 category: RuleCategory::Score,
174 default_severity: RuleSeverity::Info,
175 description: "Kani coverage below 50%",
176 effort_minutes: 30,
177 },
178 LintRule {
179 id: "PV-SCR-004",
180 category: RuleCategory::Score,
181 default_severity: RuleSeverity::Info,
182 description: "Lean coverage at 0%",
183 effort_minutes: 30,
184 },
185 LintRule {
187 id: "PV-PRV-001",
188 category: RuleCategory::Provability,
189 default_severity: RuleSeverity::Error,
190 description: "Kernel contract without Kani harnesses",
191 effort_minutes: 60,
192 },
193 LintRule {
194 id: "PV-PRV-002",
195 category: RuleCategory::Provability,
196 default_severity: RuleSeverity::Error,
197 description: "Kernel contract without falsification tests",
198 effort_minutes: 60,
199 },
200 LintRule {
201 id: "PV-PRV-003",
202 category: RuleCategory::Provability,
203 default_severity: RuleSeverity::Warning,
204 description: "Kani harness count < obligation count",
205 effort_minutes: 60,
206 },
207 LintRule {
208 id: "PV-PRV-004",
209 category: RuleCategory::Provability,
210 default_severity: RuleSeverity::Info,
211 description: "No Lean theorems (L5 not attempted)",
212 effort_minutes: 60,
213 },
214 LintRule {
216 id: "PV-TRD-001",
217 category: RuleCategory::Trend,
218 default_severity: RuleSeverity::Warning,
219 description: "Mean score dropped >5% from 7-day rolling average",
220 effort_minutes: 10,
221 },
222 LintRule {
223 id: "PV-TRD-002",
224 category: RuleCategory::Trend,
225 default_severity: RuleSeverity::Info,
226 description: "Error count increased from previous snapshot",
227 effort_minutes: 10,
228 },
229 LintRule {
231 id: "PV-SUP-001",
232 category: RuleCategory::Suppression,
233 default_severity: RuleSeverity::Warning,
234 description: "Stale suppression — the finding no longer fires",
235 effort_minutes: 2,
236 },
237 LintRule {
239 id: "PV-ENF-001",
240 category: RuleCategory::Enforcement,
241 default_severity: RuleSeverity::Warning,
242 description: "Equation without preconditions",
243 effort_minutes: 20,
244 },
245 LintRule {
246 id: "PV-ENF-002",
247 category: RuleCategory::Enforcement,
248 default_severity: RuleSeverity::Warning,
249 description: "Equation without lean_theorem",
250 effort_minutes: 20,
251 },
252 LintRule {
253 id: "PV-LCK-001",
254 category: RuleCategory::Enforcement,
255 default_severity: RuleSeverity::Error,
256 description: "Contract regressed below locked verification level",
257 effort_minutes: 5,
258 },
259 LintRule {
263 id: "PV-VER-002",
264 category: RuleCategory::Verify,
265 default_severity: RuleSeverity::Warning,
266 description: "Dangling falsification_test reference — cited fn not found in source",
267 effort_minutes: 10,
268 },
269];
270
271pub fn find_rule(id: &str) -> Option<&'static LintRule> {
273 RULES.iter().find(|r| r.id == id)
274}
275
276pub fn rules_for_category(cat: RuleCategory) -> Vec<&'static LintRule> {
278 RULES.iter().filter(|r| r.category == cat).collect()
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn rule_catalog_not_empty() {
287 assert!(!RULES.is_empty());
288 assert!(RULES.len() >= 21, "Expected 21+ rules, got {}", RULES.len());
289 }
290
291 #[test]
292 fn all_rule_ids_unique() {
293 let mut seen = std::collections::HashSet::new();
294 for rule in RULES {
295 assert!(seen.insert(rule.id), "Duplicate rule ID: {}", rule.id);
296 }
297 }
298
299 #[test]
300 fn find_rule_by_id() {
301 let rule = find_rule("PV-VAL-001").unwrap();
302 assert_eq!(rule.category, RuleCategory::Validate);
303 assert_eq!(rule.default_severity, RuleSeverity::Error);
304 }
305
306 #[test]
307 fn find_rule_missing() {
308 assert!(find_rule("PV-FAKE-999").is_none());
309 }
310
311 #[test]
312 fn rules_for_category_validate() {
313 let vals = rules_for_category(RuleCategory::Validate);
314 assert!(vals.len() >= 6);
315 assert!(vals.iter().all(|r| r.category == RuleCategory::Validate));
316 }
317
318 #[test]
319 fn severity_from_str() {
320 assert_eq!(
321 RuleSeverity::from_str_opt("error"),
322 Some(RuleSeverity::Error)
323 );
324 assert_eq!(
325 RuleSeverity::from_str_opt("warning"),
326 Some(RuleSeverity::Warning)
327 );
328 assert_eq!(
329 RuleSeverity::from_str_opt("warn"),
330 Some(RuleSeverity::Warning)
331 );
332 assert_eq!(RuleSeverity::from_str_opt("info"), Some(RuleSeverity::Info));
333 assert_eq!(RuleSeverity::from_str_opt("off"), Some(RuleSeverity::Off));
334 assert_eq!(RuleSeverity::from_str_opt("bogus"), None);
335 }
336
337 #[test]
338 fn severity_as_str_roundtrip() {
339 for sev in [
340 RuleSeverity::Off,
341 RuleSeverity::Info,
342 RuleSeverity::Warning,
343 RuleSeverity::Error,
344 ] {
345 let s = sev.as_str();
346 assert_eq!(RuleSeverity::from_str_opt(s), Some(sev));
347 }
348 }
349
350 #[test]
351 fn severity_sarif_level() {
352 assert_eq!(RuleSeverity::Error.sarif_level(), "error");
353 assert_eq!(RuleSeverity::Warning.sarif_level(), "warning");
354 assert_eq!(RuleSeverity::Info.sarif_level(), "note");
355 assert_eq!(RuleSeverity::Off.sarif_level(), "none");
356 }
357
358 #[test]
359 fn severity_ordering() {
360 assert!(RuleSeverity::Off < RuleSeverity::Info);
361 assert!(RuleSeverity::Info < RuleSeverity::Warning);
362 assert!(RuleSeverity::Warning < RuleSeverity::Error);
363 }
364
365 #[test]
366 fn rule_serializes() {
367 let json = serde_json::to_string(&RULES[0]).unwrap();
368 assert!(json.contains("PV-VAL-001"));
369 assert!(json.contains("validate"));
370 }
371}