1pub mod cache;
12mod composition_gate;
13pub mod config;
14pub mod diff;
15pub mod finding;
16mod gates;
17mod gates_extended;
18pub mod rules;
19pub mod sarif;
20pub mod trend;
21
22use std::collections::{HashMap, HashSet};
23use std::path::Path;
24use std::time::Instant;
25
26use serde::Serialize;
27
28use self::finding::LintFinding;
29use self::gates::{
30 load_binding, load_contracts, run_audit_gate, run_score_gate, run_validate_gate,
31};
32use self::gates_extended::{
33 check_stale_suppressions, run_enforce_gate, run_enforcement_level_gate,
34 run_reverse_coverage_gate, run_verify_gate,
35};
36use self::rules::RuleSeverity;
37
38#[derive(Debug, Clone, Serialize)]
40pub struct GateResult {
41 pub name: String,
42 pub passed: bool,
43 pub skipped: bool,
44 pub duration_ms: u64,
45 pub detail: GateDetail,
46}
47
48#[derive(Debug, Clone, Serialize)]
50#[serde(tag = "type")]
51pub enum GateDetail {
52 #[serde(rename = "validate")]
53 Validate {
54 contracts: usize,
55 errors: usize,
56 warnings: usize,
57 error_messages: Vec<String>,
58 },
59 #[serde(rename = "audit")]
60 Audit {
61 contracts: usize,
62 findings: usize,
63 finding_messages: Vec<String>,
64 },
65 #[serde(rename = "score")]
66 Score {
67 contracts: usize,
68 min_score: f64,
69 mean_score: f64,
70 threshold: f64,
71 below_threshold: Vec<String>,
72 },
73 #[serde(rename = "verify")]
74 Verify {
75 total_refs: usize,
76 existing: usize,
77 missing: usize,
78 },
79 #[serde(rename = "enforce")]
80 Enforce {
81 equations_total: usize,
82 equations_with_pre: usize,
83 equations_with_post: usize,
84 equations_with_lean: usize,
85 },
86 #[serde(rename = "reverse_coverage")]
87 ReverseCoverage {
88 total_pub_fns: usize,
89 bound_fns: usize,
90 unbound_fns: usize,
91 coverage_pct: f64,
92 threshold_pct: f64,
93 },
94 #[serde(rename = "composition")]
95 Composition {
96 edges_checked: usize,
97 edges_satisfied: usize,
98 edges_broken: usize,
99 },
100 #[serde(rename = "skipped")]
101 Skipped { reason: String },
102}
103
104#[derive(Debug, Clone, Serialize)]
106pub struct LintReport {
107 pub passed: bool,
108 pub gates: Vec<GateResult>,
109 pub total_duration_ms: u64,
110 #[serde(skip_serializing_if = "Vec::is_empty")]
111 pub findings: Vec<LintFinding>,
112 #[serde(skip)]
113 pub cache_stats: cache::CacheStats,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub contract_timings: Vec<(String, u64)>,
117}
118
119pub struct LintConfig<'a> {
121 pub contract_dir: &'a Path,
122 pub binding_path: Option<&'a Path>,
123 pub min_score: f64,
124 pub severity_filter: Option<RuleSeverity>,
125 pub severity_overrides: HashMap<String, RuleSeverity>,
126 pub suppressed_findings: Vec<String>,
127 pub suppressed_rules: Vec<String>,
128 pub suppressed_files: Vec<String>,
129 pub strict: bool,
130 pub no_cache: bool,
131 pub cache_stats: bool,
132 pub crate_dir: Option<&'a Path>,
134 pub min_level: Option<crate::schema::EnforcementLevel>,
136}
137
138impl<'a> LintConfig<'a> {
139 pub fn new(contract_dir: &'a Path, binding_path: Option<&'a Path>, min_score: f64) -> Self {
141 Self {
142 contract_dir,
143 binding_path,
144 min_score,
145 severity_filter: None,
146 severity_overrides: HashMap::new(),
147 suppressed_findings: Vec::new(),
148 suppressed_rules: Vec::new(),
149 suppressed_files: Vec::new(),
150 strict: false,
151 no_cache: false,
152 cache_stats: false,
153 crate_dir: None,
154 min_level: None,
155 }
156 }
157}
158
159#[allow(clippy::too_many_lines)]
161pub fn run_lint(config: &LintConfig) -> LintReport {
162 let overall_start = Instant::now();
163 let mut gates = Vec::with_capacity(3);
164 let mut all_findings = Vec::new();
165 let mut stats = cache::CacheStats::default();
166 let mut contract_timings: Vec<(String, u64)> = Vec::new();
167
168 let cache_root = if config.no_cache {
169 None
170 } else {
171 Some(cache::cache_dir(config.contract_dir))
172 };
173
174 let (contracts, parse_errors) = load_contracts(config.contract_dir);
175 let binding = load_binding(config.binding_path);
176
177 let (validate_result, mut validate_findings) = run_validate_gate(&contracts, &parse_errors);
179 let validation_passed = validate_result.passed;
180 gates.push(validate_result);
181
182 if validation_passed {
184 let (audit_result, mut audit_findings) = run_audit_gate(&contracts);
185 gates.push(audit_result);
186 all_findings.append(&mut audit_findings);
187 } else {
188 gates.push(skipped_gate("audit", "validation failed"));
189 }
190
191 if validation_passed {
193 let (score_result, mut score_findings) =
194 run_score_gate(&contracts, binding.as_ref(), config.min_score);
195 gates.push(score_result);
196 all_findings.append(&mut score_findings);
197 } else {
198 gates.push(skipped_gate("score", "validation failed"));
199 }
200
201 if validation_passed {
203 let project_root = config.contract_dir.parent().unwrap_or(config.contract_dir);
204 let (verify_result, mut verify_findings) = run_verify_gate(&contracts, project_root);
205 gates.push(verify_result);
206 all_findings.append(&mut verify_findings);
207 } else {
208 gates.push(skipped_gate("verify", "validation failed"));
209 }
210
211 if validation_passed {
213 let (enforce_result, mut enforce_findings) = run_enforce_gate(&contracts);
214 gates.push(enforce_result);
215 all_findings.append(&mut enforce_findings);
216 } else {
217 gates.push(skipped_gate("enforce", "validation failed"));
218 }
219
220 if validation_passed {
222 let min_level = config
223 .min_level
224 .unwrap_or(crate::schema::EnforcementLevel::Standard);
225 let (level_result, mut level_findings) = run_enforcement_level_gate(&contracts, min_level);
226 gates.push(level_result);
227 all_findings.append(&mut level_findings);
228 } else {
229 gates.push(skipped_gate("enforcement-level", "validation failed"));
230 }
231
232 if validation_passed {
234 if let (Some(bp), Some(cd)) = (config.binding_path, config.crate_dir) {
235 let (rev_result, mut rev_findings) = run_reverse_coverage_gate(bp, cd);
236 gates.push(rev_result);
237 all_findings.append(&mut rev_findings);
238 } else {
239 gates.push(skipped_gate(
240 "reverse-coverage",
241 "no --binding or --crate-dir provided",
242 ));
243 }
244 } else {
245 gates.push(skipped_gate("reverse-coverage", "validation failed"));
246 }
247
248 if validation_passed {
250 let (comp_result, mut comp_findings) = composition_gate::run_composition_gate(&contracts);
251 gates.push(comp_result);
252 all_findings.append(&mut comp_findings);
253 } else {
254 gates.push(skipped_gate("composition", "validation failed"));
255 }
256
257 all_findings.append(&mut validate_findings);
258
259 if validation_passed {
261 for (stem, contract) in &contracts {
262 let ct_start = Instant::now();
263 let _ = crate::schema::validate_contract(contract);
265 let _ = crate::audit::audit_contract(contract);
267 let _ = crate::scoring::score_contract(contract, binding.as_ref(), stem);
269 let ct_ms = u64::try_from(ct_start.elapsed().as_micros() / 1000).unwrap_or(0);
270 contract_timings.push((format!("{stem}.yaml"), ct_ms));
271 }
272 contract_timings.sort_by(|a, b| b.1.cmp(&a.1));
274 }
275
276 let mut stale_findings = check_stale_suppressions(
278 &all_findings,
279 &config.suppressed_rules,
280 &config.suppressed_findings,
281 );
282 all_findings.append(&mut stale_findings);
283
284 mark_new_findings(&mut all_findings, config.contract_dir);
286
287 if let Some(ref root) = cache_root {
289 let rule_cfg = format!("{:?}{:?}", config.severity_overrides, config.strict);
290 for (stem, _) in &contracts {
291 stats.total += 1;
292 let yaml_path = config.contract_dir.join(format!("{stem}.yaml"));
293 let yaml_content = std::fs::read_to_string(&yaml_path).unwrap_or_default();
294 let hash = cache::content_hash(&yaml_content, &rule_cfg);
295 if cache::cache_get(root, &hash).is_some() {
296 stats.hits += 1;
297 } else {
298 stats.misses += 1;
299 let contract_findings: Vec<_> = all_findings
300 .iter()
301 .filter(|f| f.contract_stem.as_deref() == Some(stem.as_str()))
302 .cloned()
303 .collect();
304 let _ = cache::cache_put(root, &hash, &contract_findings);
305 }
306 }
307 }
308
309 apply_suppressions(&mut all_findings, config);
311 apply_severity_overrides(&mut all_findings, config);
312 if let Some(min_sev) = config.severity_filter {
313 all_findings.retain(|f| f.severity >= min_sev);
314 }
315
316 let passed = gates.iter().all(|g| g.passed || g.skipped);
317
318 LintReport {
319 passed,
320 gates,
321 total_duration_ms: u64::try_from(overall_start.elapsed().as_millis()).unwrap_or(u64::MAX),
322 findings: all_findings,
323 cache_stats: stats,
324 contract_timings,
325 }
326}
327
328fn skipped_gate(name: &str, reason: &str) -> GateResult {
329 GateResult {
330 name: name.into(),
331 passed: false,
332 skipped: true,
333 duration_ms: 0,
334 detail: GateDetail::Skipped {
335 reason: reason.into(),
336 },
337 }
338}
339
340fn apply_suppressions(findings: &mut [LintFinding], config: &LintConfig) {
341 for f in findings.iter_mut() {
342 if config.suppressed_rules.iter().any(|r| r == &f.rule_id) {
343 f.suppressed = true;
344 f.suppression_reason = Some("Suppressed by --suppress-rule".into());
345 }
346 if let Some(ref stem) = f.contract_stem {
347 if config.suppressed_findings.iter().any(|s| s == stem) {
348 f.suppressed = true;
349 f.suppression_reason = Some("Suppressed by --suppress".into());
350 }
351 }
352 if config.suppressed_files.iter().any(|p| f.file.contains(p)) {
353 f.suppressed = true;
354 f.suppression_reason = Some("Suppressed by --suppress-file".into());
355 }
356 }
357}
358
359fn pv_state_dir(contract_dir: &Path) -> std::path::PathBuf {
361 contract_dir.parent().unwrap_or(contract_dir).join(".pv")
362}
363
364fn mark_new_findings(findings: &mut [LintFinding], contract_dir: &Path) {
367 let state_dir = pv_state_dir(contract_dir);
368 let previous_path = state_dir.join("lint-previous.json");
369
370 let previous: HashSet<String> = std::fs::read_to_string(&previous_path)
372 .ok()
373 .and_then(|s| serde_json::from_str(&s).ok())
374 .unwrap_or_default();
375
376 let mut current = HashSet::new();
378 for f in findings.iter_mut() {
379 let fp = f.fingerprint();
380 if !previous.contains(&fp) {
381 f.is_new = true;
382 }
383 current.insert(fp);
384 }
385
386 if let Err(e) = std::fs::create_dir_all(&state_dir) {
388 eprintln!("pv lint: cannot create {}: {e}", state_dir.display());
389 return;
390 }
391 if let Ok(json) = serde_json::to_string(¤t) {
392 let _ = std::fs::write(&previous_path, json);
393 }
394}
395
396fn apply_severity_overrides(findings: &mut [LintFinding], config: &LintConfig) {
397 for f in findings.iter_mut() {
398 if let Some(&sev) = config.severity_overrides.get(&f.rule_id) {
399 f.severity = sev;
400 }
401 }
402 if config.strict {
403 for f in findings.iter_mut() {
404 if f.severity == RuleSeverity::Warning {
405 f.severity = RuleSeverity::Error;
406 }
407 }
408 }
409}
410
411#[cfg(test)]
412#[path = "mod_tests.rs"]
413mod tests;