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;
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/// Result of a single gate execution.
39#[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/// Gate-specific detail payload.
49#[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/// Overall lint report.
105#[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    /// Per-contract processing times: `(contract_stem, duration_ms)`.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub contract_timings: Vec<(String, u64)>,
117}
118
119/// Configuration for `pv lint`.
120pub 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    /// Optional crate directory for reverse coverage gate (Gate 7).
133    pub crate_dir: Option<&'a Path>,
134    /// Minimum enforcement level for Gate 6 (from `--min-level`).
135    pub min_level: Option<crate::schema::EnforcementLevel>,
136}
137
138impl<'a> LintConfig<'a> {
139    /// Create a basic config (backward compatible).
140    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/// Run all lint gates across a contract directory.
160#[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    // Gate 1: validate
178    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    // Gate 2: audit (skip if validation failed)
183    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    // Gate 3: score (skip if validation failed)
192    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    // Gate 4: verify (source code fulfillment)
202    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    // Gate 5: enforce (equations must have preconditions/postconditions)
212    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    // Gate 6: enforcement level (Section 17, Gap 1 + Gap 5 level lock)
221    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    // Gate 7: reverse coverage (optional — skip if no binding or crate dir)
233    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    // Gate 8: composition (assumes/guarantees chain verification)
249    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    // Per-contract timing: measure how long each contract's findings take to process
260    if validation_passed {
261        for (stem, contract) in &contracts {
262            let ct_start = Instant::now();
263            // Validate
264            let _ = crate::schema::validate_contract(contract);
265            // Audit
266            let _ = crate::audit::audit_contract(contract);
267            // Score
268            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        // Sort by duration descending
273        contract_timings.sort_by(|a, b| b.1.cmp(&a.1));
274    }
275
276    // Stale suppression detection (PV-SUP-001, Section 17 Gap 2)
277    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    // Issue lifecycle: mark each finding as new or pre-existing
285    mark_new_findings(&mut all_findings, config.contract_dir);
286
287    // Cache: store findings per-contract for future runs
288    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, severity overrides, strict mode, and severity filter
310    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
359/// Resolve the `.pv/` state directory relative to the contract directory's parent.
360fn pv_state_dir(contract_dir: &Path) -> std::path::PathBuf {
361    contract_dir.parent().unwrap_or(contract_dir).join(".pv")
362}
363
364/// Load previous fingerprints, compare with current findings, mark new ones,
365/// and persist the current fingerprint set for the next run.
366fn 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    // Load previous fingerprints (empty set if file missing or unreadable)
371    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    // Compute current fingerprints and mark new findings
377    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    // Persist current fingerprints for the next run
387    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(&current) {
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;