1pub mod cache;
12mod composition_gate;
13pub mod config;
14pub mod diff;
15pub mod duplicate_stems;
16pub mod finding;
17mod gates;
18pub use gates::collect_yaml_files;
19mod gates_extended;
20pub mod rules;
21pub mod sarif;
22mod strict_test_binding;
23pub mod trend;
24
25use std::collections::{HashMap, HashSet};
26use std::path::Path;
27use std::time::Instant;
28
29use serde::Serialize;
30
31use self::finding::LintFinding;
32use self::gates::{
33 load_binding, load_contracts, run_audit_gate, run_score_gate, run_validate_gate,
34};
35use self::gates_extended::{
36 check_stale_suppressions, run_enforce_gate, run_enforcement_level_gate,
37 run_reverse_coverage_gate, run_verify_gate,
38};
39use self::rules::RuleSeverity;
40
41#[derive(Debug, Clone, Serialize)]
43pub struct GateResult {
44 pub name: String,
45 pub passed: bool,
46 pub skipped: bool,
47 pub duration_ms: u64,
48 pub detail: GateDetail,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub extra: Option<GateExtra>,
57}
58
59#[derive(Debug, Clone, Serialize)]
78#[serde(tag = "type")]
79pub enum GateDetail {
80 #[serde(rename = "validate")]
81 Validate {
82 contracts: usize,
83 errors: usize,
84 warnings: usize,
85 error_messages: Vec<String>,
86 },
87 #[serde(rename = "audit")]
88 Audit {
89 contracts: usize,
90 findings: usize,
91 finding_messages: Vec<String>,
92 },
93 #[serde(rename = "score")]
94 Score {
95 contracts: usize,
96 min_score: f64,
97 mean_score: f64,
98 threshold: f64,
99 below_threshold: Vec<String>,
100 },
101 #[serde(rename = "verify")]
102 Verify {
103 total_refs: usize,
104 existing: usize,
105 missing: usize,
106 },
107 #[serde(rename = "enforce")]
108 Enforce {
109 equations_total: usize,
110 equations_with_pre: usize,
111 equations_with_post: usize,
112 equations_with_lean: usize,
113 },
114 #[serde(rename = "reverse_coverage")]
115 ReverseCoverage {
116 total_pub_fns: usize,
117 bound_fns: usize,
118 unbound_fns: usize,
119 coverage_pct: f64,
120 threshold_pct: f64,
121 },
122 #[serde(rename = "composition")]
123 Composition {
124 edges_checked: usize,
125 edges_satisfied: usize,
126 edges_broken: usize,
127 },
128 #[serde(rename = "skipped")]
129 Skipped { reason: String },
130}
131
132#[derive(Debug, Clone, Serialize)]
140#[serde(tag = "type")]
141#[non_exhaustive]
142pub enum GateExtra {
143 #[serde(rename = "duplicate_stems")]
145 DuplicateStems {
146 divergent: usize,
148 baselined: usize,
150 unbaselined: Vec<String>,
152 stale: Vec<String>,
154 divergent_stems: Vec<String>,
156 },
157}
158
159#[derive(Debug, Clone, Serialize)]
161pub struct LintReport {
162 pub passed: bool,
163 pub gates: Vec<GateResult>,
164 pub total_duration_ms: u64,
165 #[serde(skip_serializing_if = "Vec::is_empty")]
166 pub findings: Vec<LintFinding>,
167 #[serde(skip)]
168 pub cache_stats: cache::CacheStats,
169 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 pub contract_timings: Vec<(String, u64)>,
172}
173
174pub struct LintConfig<'a> {
176 pub contract_dir: &'a Path,
177 pub binding_path: Option<&'a Path>,
178 pub min_score: f64,
179 pub severity_filter: Option<RuleSeverity>,
180 pub severity_overrides: HashMap<String, RuleSeverity>,
181 pub suppressed_findings: Vec<String>,
182 pub suppressed_rules: Vec<String>,
183 pub suppressed_files: Vec<String>,
184 pub strict: bool,
185 pub no_cache: bool,
186 pub cache_stats: bool,
187 pub crate_dir: Option<&'a Path>,
189 pub min_level: Option<crate::schema::EnforcementLevel>,
191 pub strict_test_binding: bool,
193}
194
195impl<'a> LintConfig<'a> {
196 pub fn new(contract_dir: &'a Path, binding_path: Option<&'a Path>, min_score: f64) -> Self {
198 Self {
199 contract_dir,
200 binding_path,
201 min_score,
202 severity_filter: None,
203 severity_overrides: HashMap::new(),
204 suppressed_findings: Vec::new(),
205 suppressed_rules: Vec::new(),
206 suppressed_files: Vec::new(),
207 strict: false,
208 no_cache: false,
209 cache_stats: false,
210 crate_dir: None,
211 min_level: None,
212 strict_test_binding: false,
213 }
214 }
215}
216
217#[allow(clippy::too_many_lines)]
219pub fn run_lint(config: &LintConfig) -> LintReport {
220 let overall_start = Instant::now();
221 let mut gates = Vec::with_capacity(3);
222 let mut all_findings = Vec::new();
223 let mut stats = cache::CacheStats::default();
224 let mut contract_timings: Vec<(String, u64)> = Vec::new();
225
226 let cache_root = if config.no_cache {
227 None
228 } else {
229 Some(cache::cache_dir(config.contract_dir))
230 };
231
232 let (contracts, parse_errors) = load_contracts(config.contract_dir);
233 let binding = load_binding(config.binding_path);
234
235 let (validate_result, mut validate_findings) = run_validate_gate(&contracts, &parse_errors);
237 let validation_passed = validate_result.passed;
238 gates.push(validate_result);
239
240 if validation_passed {
242 let (audit_result, mut audit_findings) = run_audit_gate(&contracts);
243 gates.push(audit_result);
244 all_findings.append(&mut audit_findings);
245 } else {
246 gates.push(skipped_gate("audit", "validation failed"));
247 }
248
249 if validation_passed {
251 let (score_result, mut score_findings) =
252 run_score_gate(&contracts, binding.as_ref(), config.min_score);
253 gates.push(score_result);
254 all_findings.append(&mut score_findings);
255 } else {
256 gates.push(skipped_gate("score", "validation failed"));
257 }
258
259 if validation_passed {
261 let project_root = config.contract_dir.parent().unwrap_or(config.contract_dir);
262 let (verify_result, mut verify_findings) = run_verify_gate(&contracts, project_root);
263 gates.push(verify_result);
264 all_findings.append(&mut verify_findings);
265 } else {
266 gates.push(skipped_gate("verify", "validation failed"));
267 }
268
269 if validation_passed {
271 let (enforce_result, mut enforce_findings) = run_enforce_gate(&contracts);
272 gates.push(enforce_result);
273 all_findings.append(&mut enforce_findings);
274 } else {
275 gates.push(skipped_gate("enforce", "validation failed"));
276 }
277
278 if validation_passed {
280 let min_level = config
281 .min_level
282 .unwrap_or(crate::schema::EnforcementLevel::Standard);
283 let (level_result, mut level_findings) = run_enforcement_level_gate(&contracts, min_level);
284 gates.push(level_result);
285 all_findings.append(&mut level_findings);
286 } else {
287 gates.push(skipped_gate("enforcement-level", "validation failed"));
288 }
289
290 if validation_passed {
292 if let (Some(bp), Some(cd)) = (config.binding_path, config.crate_dir) {
293 let (rev_result, mut rev_findings) = run_reverse_coverage_gate(bp, cd);
294 gates.push(rev_result);
295 all_findings.append(&mut rev_findings);
296 } else {
297 gates.push(skipped_gate(
298 "reverse-coverage",
299 "no --binding or --crate-dir provided",
300 ));
301 }
302 } else {
303 gates.push(skipped_gate("reverse-coverage", "validation failed"));
304 }
305
306 let duplicates = duplicate_stems::scan_duplicate_stems(config.contract_dir);
310 let ambiguous = duplicate_stems::ambiguous_stems(&duplicates);
311 if validation_passed {
312 let project_root = config.contract_dir.parent().unwrap_or(config.contract_dir);
313 let baseline = duplicate_stems::read_baseline(project_root);
314 let (dup_result, mut dup_findings) =
315 duplicate_stems::run_duplicate_stem_gate(&duplicates, &baseline);
316 gates.push(dup_result);
317 all_findings.append(&mut dup_findings);
318 } else {
319 gates.push(skipped_gate("duplicate-stems", "validation failed"));
320 }
321
322 if validation_passed {
324 let (comp_result, mut comp_findings) =
325 composition_gate::run_composition_gate(&contracts, &ambiguous);
326 gates.push(comp_result);
327 all_findings.append(&mut comp_findings);
328 } else {
329 gates.push(skipped_gate("composition", "validation failed"));
330 }
331
332 if config.strict_test_binding {
334 if validation_passed {
335 let project_root = config.contract_dir.parent().unwrap_or(config.contract_dir);
336 let (binding_result, mut binding_findings) =
337 strict_test_binding::run_strict_test_binding_gate(
338 &contracts,
339 project_root,
340 config.strict,
341 );
342 gates.push(binding_result);
343 all_findings.append(&mut binding_findings);
344 } else {
345 gates.push(skipped_gate("strict-test-binding", "validation failed"));
346 }
347 }
348
349 all_findings.append(&mut validate_findings);
350
351 if validation_passed {
353 for (stem, contract) in &contracts {
354 let ct_start = Instant::now();
355 let _ = crate::schema::validate_contract(contract);
357 let _ = crate::audit::audit_contract(contract);
359 let _ = crate::scoring::score_contract(contract, binding.as_ref(), stem);
361 let ct_ms = u64::try_from(ct_start.elapsed().as_micros() / 1000).unwrap_or(0);
362 contract_timings.push((format!("{stem}.yaml"), ct_ms));
363 }
364 contract_timings.sort_by_key(|b| std::cmp::Reverse(b.1));
366 }
367
368 let mut stale_findings = check_stale_suppressions(
370 &all_findings,
371 &config.suppressed_rules,
372 &config.suppressed_findings,
373 );
374 all_findings.append(&mut stale_findings);
375
376 mark_new_findings(&mut all_findings, config.contract_dir);
378
379 if let Some(ref root) = cache_root {
381 let rule_cfg = format!("{:?}{:?}", config.severity_overrides, config.strict);
382 for (stem, _) in &contracts {
383 stats.total += 1;
384 let yaml_path = config.contract_dir.join(format!("{stem}.yaml"));
385 let yaml_content = std::fs::read_to_string(&yaml_path).unwrap_or_default();
386 let hash = cache::content_hash(&yaml_content, &rule_cfg);
387 if cache::cache_get(root, &hash).is_some() {
388 stats.hits += 1;
389 } else {
390 stats.misses += 1;
391 let contract_findings: Vec<_> = all_findings
392 .iter()
393 .filter(|f| f.contract_stem.as_deref() == Some(stem.as_str()))
394 .cloned()
395 .collect();
396 let _ = cache::cache_put(root, &hash, &contract_findings);
397 }
398 }
399 }
400
401 apply_suppressions(&mut all_findings, config);
403 apply_severity_overrides(&mut all_findings, config);
404 if let Some(min_sev) = config.severity_filter {
405 all_findings.retain(|f| f.severity >= min_sev);
406 }
407
408 let passed = gates.iter().all(|g| g.passed || g.skipped);
409
410 LintReport {
411 passed,
412 gates,
413 total_duration_ms: u64::try_from(overall_start.elapsed().as_millis()).unwrap_or(u64::MAX),
414 findings: all_findings,
415 cache_stats: stats,
416 contract_timings,
417 }
418}
419
420fn skipped_gate(name: &str, reason: &str) -> GateResult {
421 GateResult {
422 name: name.into(),
423 passed: false,
424 skipped: true,
425 duration_ms: 0,
426 detail: GateDetail::Skipped {
427 reason: reason.into(),
428 },
429 extra: None,
430 }
431}
432
433fn apply_suppressions(findings: &mut [LintFinding], config: &LintConfig) {
434 for f in findings.iter_mut() {
435 if config.suppressed_rules.iter().any(|r| r == &f.rule_id) {
436 f.suppressed = true;
437 f.suppression_reason = Some("Suppressed by --suppress-rule".into());
438 }
439 if let Some(ref stem) = f.contract_stem {
440 if config.suppressed_findings.iter().any(|s| s == stem) {
441 f.suppressed = true;
442 f.suppression_reason = Some("Suppressed by --suppress".into());
443 }
444 }
445 if config.suppressed_files.iter().any(|p| f.file.contains(p)) {
446 f.suppressed = true;
447 f.suppression_reason = Some("Suppressed by --suppress-file".into());
448 }
449 }
450}
451
452fn pv_state_dir(contract_dir: &Path) -> std::path::PathBuf {
454 contract_dir.parent().unwrap_or(contract_dir).join(".pv")
455}
456
457fn mark_new_findings(findings: &mut [LintFinding], contract_dir: &Path) {
460 let state_dir = pv_state_dir(contract_dir);
461 let previous_path = state_dir.join("lint-previous.json");
462
463 let previous: HashSet<String> = std::fs::read_to_string(&previous_path)
465 .ok()
466 .and_then(|s| serde_json::from_str(&s).ok())
467 .unwrap_or_default();
468
469 let mut current = HashSet::new();
471 for f in findings.iter_mut() {
472 let fp = f.fingerprint();
473 if !previous.contains(&fp) {
474 f.is_new = true;
475 }
476 current.insert(fp);
477 }
478
479 if let Err(e) = std::fs::create_dir_all(&state_dir) {
481 eprintln!("pv lint: cannot create {}: {e}", state_dir.display());
482 return;
483 }
484 if let Ok(json) = serde_json::to_string(¤t) {
485 let _ = std::fs::write(&previous_path, json);
486 }
487}
488
489fn apply_severity_overrides(findings: &mut [LintFinding], config: &LintConfig) {
490 for f in findings.iter_mut() {
491 if let Some(&sev) = config.severity_overrides.get(&f.rule_id) {
492 f.severity = sev;
493 }
494 }
495 if config.strict {
496 for f in findings.iter_mut() {
497 if f.severity == RuleSeverity::Warning {
498 f.severity = RuleSeverity::Error;
499 }
500 }
501 }
502}
503
504#[cfg(test)]
505#[path = "mod_tests.rs"]
506mod tests;