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 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/// Result of a single gate execution.
42#[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    /// Structured payload for gates invented AFTER `GateDetail` was frozen.
50    ///
51    /// See [`GateExtra`] for why this second channel exists. Adding a field to a
52    /// struct is invisible to a `match` on `GateDetail`, which is the property
53    /// the 0.3.1 compatibility corpus depends on; adding a *variant* is not.
54    /// Serialised only when present, so existing JSON output is unchanged.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub extra: Option<GateExtra>,
57}
58
59/// Gate-specific detail payload.
60///
61/// # This enum is FROZEN at the eight variants published in `provable-contracts`
62/// 0.3.1 — do not add a ninth
63///
64/// `GateDetail` is public, is not `#[non_exhaustive]`, and 0.3.1 shipped 28 example
65/// programs that `match` it exhaustively. Those programs are vendored verbatim under
66/// `crates/facades/provable-contracts/compat/0.3.1/`, sha256-verified against their
67/// published checksums by `scripts/check_facade_compat.sh` (row R6), and compiled in
68/// the `ci` job that the required `gate` check depends on. A ninth variant is
69/// `error[E0004]` in that corpus, and the corpus cannot be edited to accommodate it —
70/// being uneditable is the whole point of a compatibility contract. Marking the enum
71/// `#[non_exhaustive]` does not help either: it makes the *existing* exhaustive
72/// matches non-exhaustive, which is the same compile error for the same reason.
73///
74/// So the vocabulary is closed. A gate added after 0.3.1 picks the truest of these
75/// eight for `detail` and carries its own shape in [`GateResult::extra`], which is a
76/// type 0.3.1 never names and is therefore free to grow.
77#[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/// Out-of-band structured detail for gates that post-date the frozen [`GateDetail`]
133/// vocabulary.
134///
135/// This type did not exist in `provable-contracts` 0.3.1, so no 0.3.1 program names
136/// it and none can `match` it. That is what makes it extensible where `GateDetail` is
137/// not, and it is `#[non_exhaustive]` from birth so the *next* post-0.3.1 gate does
138/// not have to repeat this exercise.
139#[derive(Debug, Clone, Serialize)]
140#[serde(tag = "type")]
141#[non_exhaustive]
142pub enum GateExtra {
143    /// PV-DUP-001: contract stems claimed by several files with divergent content.
144    #[serde(rename = "duplicate_stems")]
145    DuplicateStems {
146        /// Total ambiguous stems found in the tree.
147        divergent: usize,
148        /// How many of those are recorded in the ratchet baseline.
149        baselined: usize,
150        /// Ambiguous stems NOT in the baseline — these fail the gate.
151        unbaselined: Vec<String>,
152        /// Baseline entries that no longer diverge — these fail the gate too.
153        stale: Vec<String>,
154        /// Every ambiguous stem, with its variant count and paths, for the report.
155        divergent_stems: Vec<String>,
156    },
157}
158
159/// Overall lint report.
160#[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    /// Per-contract processing times: `(contract_stem, duration_ms)`.
170    #[serde(default, skip_serializing_if = "Vec::is_empty")]
171    pub contract_timings: Vec<(String, u64)>,
172}
173
174/// Configuration for `pv lint`.
175pub 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    /// Optional crate directory for reverse coverage gate (Gate 7).
188    pub crate_dir: Option<&'a Path>,
189    /// Minimum enforcement level for Gate 6 (from `--min-level`).
190    pub min_level: Option<crate::schema::EnforcementLevel>,
191    /// Enable Gate 9 (strict test-binding, PV-VER-002). Issue #1510.
192    pub strict_test_binding: bool,
193}
194
195impl<'a> LintConfig<'a> {
196    /// Create a basic config (backward compatible).
197    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/// Run all lint gates across a contract directory.
218#[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    // Gate 1: validate
236    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    // Gate 2: audit (skip if validation failed)
241    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    // Gate 3: score (skip if validation failed)
250    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    // Gate 4: verify (source code fulfillment)
260    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    // Gate 5: enforce (equations must have preconditions/postconditions)
270    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    // Gate 6: enforcement level (Section 17, Gap 1 + Gap 5 level lock)
279    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    // Gate 7: reverse coverage (optional — skip if no binding or crate dir)
291    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    // Gate 8: duplicate stems (PV-DUP-001). Must run BEFORE composition — it tells
307    // the composition gate which stems are unresolvable, which is the difference
308    // between a defined verdict and one decided by `read_dir` order.
309    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    // Gate 9: composition (assumes/guarantees chain verification)
323    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    // Gate 9: strict test-binding (Issue #1510, opt-in via --strict-test-binding)
333    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    // Per-contract timing: measure how long each contract's findings take to process
352    if validation_passed {
353        for (stem, contract) in &contracts {
354            let ct_start = Instant::now();
355            // Validate
356            let _ = crate::schema::validate_contract(contract);
357            // Audit
358            let _ = crate::audit::audit_contract(contract);
359            // Score
360            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        // Sort by duration descending
365        contract_timings.sort_by_key(|b| std::cmp::Reverse(b.1));
366    }
367
368    // Stale suppression detection (PV-SUP-001, Section 17 Gap 2)
369    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    // Issue lifecycle: mark each finding as new or pre-existing
377    mark_new_findings(&mut all_findings, config.contract_dir);
378
379    // Cache: store findings per-contract for future runs
380    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, severity overrides, strict mode, and severity filter
402    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
452/// Resolve the `.pv/` state directory relative to the contract directory's parent.
453fn pv_state_dir(contract_dir: &Path) -> std::path::PathBuf {
454    contract_dir.parent().unwrap_or(contract_dir).join(".pv")
455}
456
457/// Load previous fingerprints, compare with current findings, mark new ones,
458/// and persist the current fingerprint set for the next run.
459fn 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    // Load previous fingerprints (empty set if file missing or unreadable)
464    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    // Compute current fingerprints and mark new findings
470    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    // Persist current fingerprints for the next run
480    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(&current) {
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;