Skip to main content

provable_contracts/lint/
mod.rs

1//! Contract quality gate: validate + audit + score in one pass.
2//!
3//! Runs three sequential gates across all contracts in a directory:
4//! 1. **validate** — schema completeness (SCHEMA-001..013, PROVABILITY-001)
5//! 2. **audit** — traceability chain (paper→equation→obligation→test→proof)
6//! 3. **score** — 5-dimension quality score vs threshold
7//!
8//! Extended with SARIF output, rule catalog, config file, and findings.
9//! Spec: `docs/specifications/sub/lint.md`
10
11pub 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/// Result of a single gate execution.
40#[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/// Gate-specific detail payload.
50#[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/// Overall lint report.
106#[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    /// Per-contract processing times: `(contract_stem, duration_ms)`.
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub contract_timings: Vec<(String, u64)>,
118}
119
120/// Configuration for `pv lint`.
121pub 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    /// Optional crate directory for reverse coverage gate (Gate 7).
134    pub crate_dir: Option<&'a Path>,
135    /// Minimum enforcement level for Gate 6 (from `--min-level`).
136    pub min_level: Option<crate::schema::EnforcementLevel>,
137    /// Enable Gate 9 (strict test-binding, PV-VER-002). Issue #1510.
138    pub strict_test_binding: bool,
139}
140
141impl<'a> LintConfig<'a> {
142    /// Create a basic config (backward compatible).
143    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/// Run all lint gates across a contract directory.
164#[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    // Gate 1: validate
182    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    // Gate 2: audit (skip if validation failed)
187    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    // Gate 3: score (skip if validation failed)
196    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    // Gate 4: verify (source code fulfillment)
206    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    // Gate 5: enforce (equations must have preconditions/postconditions)
216    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    // Gate 6: enforcement level (Section 17, Gap 1 + Gap 5 level lock)
225    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    // Gate 7: reverse coverage (optional — skip if no binding or crate dir)
237    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    // Gate 8: composition (assumes/guarantees chain verification)
253    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    // Gate 9: strict test-binding (Issue #1510, opt-in via --strict-test-binding)
262    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    // Per-contract timing: measure how long each contract's findings take to process
281    if validation_passed {
282        for (stem, contract) in &contracts {
283            let ct_start = Instant::now();
284            // Validate
285            let _ = crate::schema::validate_contract(contract);
286            // Audit
287            let _ = crate::audit::audit_contract(contract);
288            // Score
289            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        // Sort by duration descending
294        contract_timings.sort_by(|a, b| b.1.cmp(&a.1));
295    }
296
297    // Stale suppression detection (PV-SUP-001, Section 17 Gap 2)
298    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    // Issue lifecycle: mark each finding as new or pre-existing
306    mark_new_findings(&mut all_findings, config.contract_dir);
307
308    // Cache: store findings per-contract for future runs
309    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, severity overrides, strict mode, and severity filter
331    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
380/// Resolve the `.pv/` state directory relative to the contract directory's parent.
381fn pv_state_dir(contract_dir: &Path) -> std::path::PathBuf {
382    contract_dir.parent().unwrap_or(contract_dir).join(".pv")
383}
384
385/// Load previous fingerprints, compare with current findings, mark new ones,
386/// and persist the current fingerprint set for the next run.
387fn 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    // Load previous fingerprints (empty set if file missing or unreadable)
392    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    // Compute current fingerprints and mark new findings
398    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    // Persist current fingerprints for the next run
408    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(&current) {
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;