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