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 relations_gate;
21pub mod rules;
22pub mod sarif;
23pub mod shapes_gate;
24pub mod sigma_gate;
25pub mod sigma_symbols;
26mod strict_test_binding;
27pub mod trend;
28
29use std::collections::{HashMap, HashSet};
30use std::path::Path;
31use std::time::Instant;
32
33use serde::Serialize;
34
35use self::finding::LintFinding;
36use self::gates::{
37    load_binding, load_contracts, run_audit_gate, run_score_gate, run_validate_gate,
38};
39use self::gates_extended::{
40    check_stale_suppressions, run_enforce_gate, run_enforcement_level_gate,
41    run_reverse_coverage_gate, run_verify_gate,
42};
43use self::rules::RuleSeverity;
44use crate::ontology::arming::{meet_armed, ArmedGates};
45use crate::ontology::verdict::Verdict;
46
47/// Result of a single gate execution.
48#[derive(Debug, Clone, Serialize)]
49pub struct GateResult {
50    pub name: String,
51    pub passed: bool,
52    pub skipped: bool,
53    /// ONT-001 §3.4: this gate's element of the one verdict lattice (`Verdict::from_gate(passed, skipped)`).
54    pub verdict: Verdict,
55    pub duration_ms: u64,
56    pub detail: GateDetail,
57    /// Structured payload for gates invented AFTER `GateDetail` was frozen.
58    ///
59    /// See [`GateExtra`] for why this second channel exists. Adding a field to a
60    /// struct is invisible to a `match` on `GateDetail`, which is the property
61    /// the 0.3.1 compatibility corpus depends on; adding a *variant* is not.
62    /// Serialised only when present, so existing JSON output is unchanged.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub extra: Option<GateExtra>,
65}
66
67/// Gate-specific detail payload.
68///
69/// # This enum is FROZEN at the eight variants published in `provable-contracts`
70/// 0.3.1 — do not add a ninth
71///
72/// `GateDetail` is public, is not `#[non_exhaustive]`, and 0.3.1 shipped 28 example
73/// programs that `match` it exhaustively. Those programs are vendored verbatim under
74/// `crates/facades/provable-contracts/compat/0.3.1/`, sha256-verified against their
75/// published checksums by `scripts/check_facade_compat.sh` (row R6), and compiled in
76/// the `ci` job that the required `gate` check depends on. A ninth variant is
77/// `error[E0004]` in that corpus, and the corpus cannot be edited to accommodate it —
78/// being uneditable is the whole point of a compatibility contract. Marking the enum
79/// `#[non_exhaustive]` does not help either: it makes the *existing* exhaustive
80/// matches non-exhaustive, which is the same compile error for the same reason.
81///
82/// So the vocabulary is closed. A gate added after 0.3.1 picks the truest of these
83/// eight for `detail` and carries its own shape in [`GateResult::extra`], which is a
84/// type 0.3.1 never names and is therefore free to grow.
85#[derive(Debug, Clone, Serialize)]
86#[serde(tag = "type")]
87pub enum GateDetail {
88    #[serde(rename = "validate")]
89    Validate {
90        contracts: usize,
91        errors: usize,
92        warnings: usize,
93        error_messages: Vec<String>,
94    },
95    #[serde(rename = "audit")]
96    Audit {
97        contracts: usize,
98        findings: usize,
99        finding_messages: Vec<String>,
100    },
101    #[serde(rename = "score")]
102    Score {
103        contracts: usize,
104        min_score: f64,
105        mean_score: f64,
106        threshold: f64,
107        below_threshold: Vec<String>,
108    },
109    #[serde(rename = "verify")]
110    Verify {
111        total_refs: usize,
112        existing: usize,
113        missing: usize,
114    },
115    #[serde(rename = "enforce")]
116    Enforce {
117        equations_total: usize,
118        equations_with_pre: usize,
119        equations_with_post: usize,
120        equations_with_lean: usize,
121    },
122    #[serde(rename = "reverse_coverage")]
123    ReverseCoverage {
124        total_pub_fns: usize,
125        bound_fns: usize,
126        unbound_fns: usize,
127        coverage_pct: f64,
128        threshold_pct: f64,
129    },
130    #[serde(rename = "composition")]
131    Composition {
132        edges_checked: usize,
133        edges_satisfied: usize,
134        edges_broken: usize,
135    },
136    #[serde(rename = "skipped")]
137    Skipped { reason: String },
138}
139
140/// Out-of-band structured detail for gates that post-date the frozen [`GateDetail`]
141/// vocabulary.
142///
143/// This type did not exist in `provable-contracts` 0.3.1, so no 0.3.1 program names
144/// it and none can `match` it. That is what makes it extensible where `GateDetail` is
145/// not, and it is `#[non_exhaustive]` from birth so the *next* post-0.3.1 gate does
146/// not have to repeat this exercise.
147#[derive(Debug, Clone, Serialize)]
148#[serde(tag = "type")]
149#[non_exhaustive]
150pub enum GateExtra {
151    /// PV-DUP-001: contract stems claimed by several files with divergent content.
152    #[serde(rename = "duplicate_stems")]
153    DuplicateStems {
154        /// Total ambiguous stems found in the tree.
155        divergent: usize,
156        /// How many of those are recorded in the ratchet baseline.
157        baselined: usize,
158        /// Ambiguous stems NOT in the baseline — these fail the gate.
159        unbaselined: Vec<String>,
160        /// Baseline entries that no longer diverge — these fail the gate too.
161        stale: Vec<String>,
162        /// Every ambiguous stem, with its variant count and paths, for the report.
163        divergent_stems: Vec<String>,
164    },
165    /// ONT-2b: what Σ declares and what the corpus was checked against.
166    #[serde(rename = "sigma")]
167    Sigma {
168        /// `entity_types` Σ declares.
169        entity_types: usize,
170        /// `roles` Σ declares.
171        roles: usize,
172        /// `symbols` Σ declares.
173        symbols: usize,
174        /// Contract files read (never counting `ontology.yaml` itself).
175        contracts_checked: usize,
176        /// Findings: undeclared entity types, undeclared roles, undeclared glyphs.
177        violations: usize,
178        /// `formal:` expressions read.
179        formal_total: usize,
180        /// Of those, how many carry NO symbol Σ declares — the `formal_prose` debt, shrink-only.
181        formal_prose: usize,
182    },
183    /// ONT-4: the corpus's typed relations, checked against Σ's roles.
184    #[serde(rename = "relations")]
185    Relations {
186        /// Typed edges after symmetric closure.
187        relations_n: usize,
188        /// Contracts carrying a `relations:` block.
189        contracts_with_relations: usize,
190        /// `role=count` per role used.
191        roles_used: Vec<String>,
192        /// Cycles through acyclic roles, as `role: a -> b -> a`.
193        cycles: Vec<String>,
194        /// `metadata.depends_on` edges, read and counted, never rewritten (R-5).
195        legacy_depends_on: usize,
196        /// Of those, how many name no contract — ratcheted shrink-only in the baseline.
197        legacy_unresolved_depends_on: usize,
198        /// Findings.
199        violations: usize,
200    },
201    /// ONT-4b: the shapes gate — every `shape:` block over the extracted graph, with the plant.
202    #[serde(rename = "shapes")]
203    Shapes {
204        /// `shape:` blocks read.
205        shapes_n: usize,
206        /// Distinct focus nodes across the shapes (the plant excluded).
207        focus_nodes_n: usize,
208        /// `fired` — the plant drew a violation; the gate never reaches `Ran` otherwise.
209        pc_shape: String,
210        /// How many violations the plant drew.
211        plant_violations: usize,
212        /// Corpus violations (the plant excluded).
213        violations: usize,
214        /// Corpus warnings.
215        warnings: usize,
216        /// `shape=focus-count` per shape.
217        by_shape: Vec<String>,
218        /// Triples in the extracted graph.
219        triples: usize,
220        /// ONT-4c1 (§3.9 per-shape arming): shapes that feed the verdict, in corpus order.
221        armed_shapes: Vec<String>,
222        /// Shapes computed and reported but not armed — their violations are in `unarmed_violations`.
223        not_armed_shapes: Vec<String>,
224        /// Violations from unarmed shapes (named in the findings as warnings; never in the meet).
225        unarmed_violations: usize,
226        /// Focus nodes each extractor produced: `pv-contract`, `gguf`, `apr-model`.
227        by_entity_type: std::collections::BTreeMap<String, usize>,
228        /// The extractor positive controls: `gguf` (corrupt magic refused), `apr-model` (lying header refused).
229        pc_extract: std::collections::BTreeMap<String, String>,
230        /// Ladder receipt files read under `evidence/dogfood/models/`.
231        receipts: usize,
232        /// Receipt rows whose `sha256` equals a rung's.
233        witnesses: usize,
234        /// Receipt rows whose `sha256` differs from the rung's.
235        hex_mismatches: usize,
236        /// Receipt rows without a `sha256` (never a witness).
237        unmeasured_rows: usize,
238        /// ONT-4b2: vendored W3C SHACL-Core cases that passed this run, and how many are vendored.
239        w3c_cases_passed: usize,
240        w3c_cases_n: usize,
241        /// ONT-4b2: bound Rust symbols the `syn` walk resolved / could not resolve.
242        symbols_resolved: usize,
243        symbols_unresolved: usize,
244        /// ONT-4b2: Lean theorems extracted, and contract `lean_theorem:` references naming none of them.
245        lean_statements: usize,
246        lean_refs_unresolved: usize,
247        /// aprender#3715: what `extract:release-evidence` derived — absent unless a release subject was given.
248        #[serde(skip_serializing_if = "Option::is_none")]
249        release: Option<Box<crate::ontology::extract::release_evidence::ReleaseStats>>,
250    },
251}
252
253/// Overall lint report.
254#[derive(Debug, Clone, Serialize)]
255pub struct LintReport {
256    pub passed: bool,
257    pub gates: Vec<GateResult>,
258    pub total_duration_ms: u64,
259    #[serde(skip_serializing_if = "Vec::is_empty")]
260    pub findings: Vec<LintFinding>,
261    #[serde(skip)]
262    pub cache_stats: cache::CacheStats,
263    /// Per-contract processing times: `(contract_stem, duration_ms)`.
264    #[serde(default, skip_serializing_if = "Vec::is_empty")]
265    pub contract_timings: Vec<(String, u64)>,
266    /// ONT-001 §3.9: the meet over the armed gates — what the exit code reports. `passed` is the legacy
267    /// every-gate-passed-or-skipped flag and is kept unchanged for existing readers.
268    pub verdict: Verdict,
269    /// Each armed gate, in armed order, with the verdict it contributed (`Unknown(NotRun)` if it did not run).
270    pub armed_gates: Vec<ArmedGateVerdict>,
271    /// Gates that ran but are not armed: printed as `Unknown(NotArmed)` and excluded from the meet.
272    pub not_armed: Vec<String>,
273    /// The `armed_gates` monotone check, as the CLI measured it; `None` when nothing checked it.
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub armed_monotone: Option<String>,
276    /// ONT-4c1: the `armed_shapes` monotone check, as the CLI measured it.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub armed_shapes_monotone: Option<String>,
279}
280
281/// One armed gate's contribution to [`LintReport::verdict`].
282#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
283pub struct ArmedGateVerdict {
284    pub name: String,
285    pub verdict: Verdict,
286}
287
288impl LintReport {
289    /// Recompute the meet over `armed`. Gates that ran but are not armed are listed in `not_armed`.
290    pub fn arm(&mut self, armed: &ArmedGates) {
291        let results: Vec<(String, Verdict)> = self
292            .gates
293            .iter()
294            .map(|g| (g.name.clone(), g.verdict))
295            .collect();
296        let meet = meet_armed(&results, armed);
297        self.verdict = meet.verdict;
298        self.armed_gates = meet
299            .armed
300            .into_iter()
301            .map(|(name, verdict)| ArmedGateVerdict { name, verdict })
302            .collect();
303        self.not_armed = meet.not_armed;
304    }
305}
306
307/// Configuration for `pv lint`.
308pub struct LintConfig<'a> {
309    pub contract_dir: &'a Path,
310    pub binding_path: Option<&'a Path>,
311    pub min_score: f64,
312    pub severity_filter: Option<RuleSeverity>,
313    pub severity_overrides: HashMap<String, RuleSeverity>,
314    pub suppressed_findings: Vec<String>,
315    pub suppressed_rules: Vec<String>,
316    pub suppressed_files: Vec<String>,
317    pub strict: bool,
318    pub no_cache: bool,
319    pub cache_stats: bool,
320    /// Optional crate directory for reverse coverage gate (Gate 7).
321    pub crate_dir: Option<&'a Path>,
322    /// Minimum enforcement level for Gate 6 (from `--min-level`).
323    pub min_level: Option<crate::schema::EnforcementLevel>,
324    /// Enable Gate 9 (strict test-binding, PV-VER-002). Issue #1510.
325    pub strict_test_binding: bool,
326}
327
328impl<'a> LintConfig<'a> {
329    /// Create a basic config (backward compatible).
330    pub fn new(contract_dir: &'a Path, binding_path: Option<&'a Path>, min_score: f64) -> Self {
331        Self {
332            contract_dir,
333            binding_path,
334            min_score,
335            severity_filter: None,
336            severity_overrides: HashMap::new(),
337            suppressed_findings: Vec::new(),
338            suppressed_rules: Vec::new(),
339            suppressed_files: Vec::new(),
340            strict: false,
341            no_cache: false,
342            cache_stats: false,
343            crate_dir: None,
344            min_level: None,
345            strict_test_binding: false,
346        }
347    }
348}
349
350/// Run one gate and record it, or record it as skipped when gate 1 (validate) failed.
351///
352/// Every gate after validate repeats the same `if validation_passed { run } else
353/// { skipped_gate(name, "validation failed") }` shape; this is that shape, once.
354fn push_gate<F>(
355    gates: &mut Vec<GateResult>,
356    all_findings: &mut Vec<LintFinding>,
357    run: F,
358    name: &str,
359    validation_passed: bool,
360) where
361    F: FnOnce() -> (GateResult, Vec<LintFinding>),
362{
363    let (result, mut findings) = if validation_passed {
364        run()
365    } else {
366        (skipped_gate(name, "validation failed"), Vec::new())
367    };
368    gates.push(result);
369    all_findings.append(&mut findings);
370}
371
372/// Per-contract timing: measure how long each contract's findings take to process,
373/// sorted by duration descending.
374fn per_contract_timings(
375    contracts: &[(String, crate::schema::Contract)],
376    binding: Option<&crate::binding::BindingRegistry>,
377) -> Vec<(String, u64)> {
378    let mut contract_timings: Vec<(String, u64)> = Vec::with_capacity(contracts.len());
379    for (stem, contract) in contracts {
380        let ct_start = Instant::now();
381        // Validate
382        let _ = crate::schema::validate_contract(contract);
383        // Audit
384        let _ = crate::audit::audit_contract(contract);
385        // Score
386        let _ = crate::scoring::score_contract(contract, binding, stem);
387        let ct_ms = u64::try_from(ct_start.elapsed().as_micros() / 1000).unwrap_or(0);
388        contract_timings.push((format!("{stem}.yaml"), ct_ms));
389    }
390    // Sort by duration descending
391    contract_timings.sort_by_key(|b| std::cmp::Reverse(b.1));
392    contract_timings
393}
394
395/// Cache: store findings per-contract for future runs.
396fn store_findings_in_cache(
397    root: &std::path::Path,
398    config: &LintConfig,
399    contracts: &[(String, crate::schema::Contract)],
400    all_findings: &[LintFinding],
401    stats: &mut cache::CacheStats,
402) {
403    let rule_cfg = format!("{:?}{:?}", config.severity_overrides, config.strict);
404    for (stem, _) in contracts {
405        stats.total += 1;
406        let yaml_path = config.contract_dir.join(format!("{stem}.yaml"));
407        let yaml_content = std::fs::read_to_string(&yaml_path).unwrap_or_default();
408        let hash = cache::content_hash(&yaml_content, &rule_cfg);
409        if cache::cache_get(root, &hash).is_some() {
410            stats.hits += 1;
411        } else {
412            stats.misses += 1;
413            let contract_findings: Vec<_> = all_findings
414                .iter()
415                .filter(|f| f.contract_stem.as_deref() == Some(stem.as_str()))
416                .cloned()
417                .collect();
418            let _ = cache::cache_put(root, &hash, &contract_findings);
419        }
420    }
421}
422
423/// Run all lint gates across a contract directory.
424#[allow(clippy::too_many_lines)]
425pub fn run_lint(config: &LintConfig) -> LintReport {
426    let overall_start = Instant::now();
427    let mut gates = Vec::with_capacity(3);
428    let mut all_findings = Vec::new();
429    let mut stats = cache::CacheStats::default();
430    let mut contract_timings: Vec<(String, u64)> = Vec::new();
431
432    let cache_root = if config.no_cache {
433        None
434    } else {
435        Some(cache::cache_dir(config.contract_dir))
436    };
437
438    let (contracts, parse_errors) = load_contracts(config.contract_dir);
439    let binding = load_binding(config.binding_path);
440
441    // Gate 1: validate
442    let (validate_result, mut validate_findings) = run_validate_gate(&contracts, &parse_errors);
443    let validation_passed = validate_result.passed;
444    gates.push(validate_result);
445
446    // Gate 2: audit (skip if validation failed)
447    push_gate(
448        &mut gates,
449        &mut all_findings,
450        || run_audit_gate(&contracts),
451        "audit",
452        validation_passed,
453    );
454
455    // Gate 3: score (skip if validation failed)
456    push_gate(
457        &mut gates,
458        &mut all_findings,
459        || run_score_gate(&contracts, binding.as_ref(), config.min_score),
460        "score",
461        validation_passed,
462    );
463
464    // Gate 4: verify (source code fulfillment)
465    push_gate(
466        &mut gates,
467        &mut all_findings,
468        || {
469            let project_root = config.contract_dir.parent().unwrap_or(config.contract_dir);
470            run_verify_gate(&contracts, project_root)
471        },
472        "verify",
473        validation_passed,
474    );
475
476    // Gate 5: enforce (equations must have preconditions/postconditions)
477    push_gate(
478        &mut gates,
479        &mut all_findings,
480        || run_enforce_gate(&contracts),
481        "enforce",
482        validation_passed,
483    );
484
485    // Gate 6: enforcement level (Section 17, Gap 1 + Gap 5 level lock)
486    push_gate(
487        &mut gates,
488        &mut all_findings,
489        || {
490            let min_level = config
491                .min_level
492                .unwrap_or(crate::schema::EnforcementLevel::Standard);
493            run_enforcement_level_gate(&contracts, min_level)
494        },
495        "enforcement-level",
496        validation_passed,
497    );
498
499    // Gate 7: reverse coverage (optional — skip if no binding or crate dir)
500    push_gate(
501        &mut gates,
502        &mut all_findings,
503        || match (config.binding_path, config.crate_dir) {
504            (Some(bp), Some(cd)) => run_reverse_coverage_gate(bp, cd),
505            _ => (
506                skipped_gate("reverse-coverage", "no --binding or --crate-dir provided"),
507                Vec::new(),
508            ),
509        },
510        "reverse-coverage",
511        validation_passed,
512    );
513
514    // Gate 8: duplicate stems (PV-DUP-001). Must run BEFORE composition — it tells
515    // the composition gate which stems are unresolvable, which is the difference
516    // between a defined verdict and one decided by `read_dir` order.
517    let duplicates = duplicate_stems::scan_duplicate_stems(config.contract_dir);
518    let ambiguous = duplicate_stems::ambiguous_stems(&duplicates);
519    push_gate(
520        &mut gates,
521        &mut all_findings,
522        || {
523            let project_root = config.contract_dir.parent().unwrap_or(config.contract_dir);
524            let baseline = duplicate_stems::read_baseline(project_root);
525            duplicate_stems::run_duplicate_stem_gate(&duplicates, &baseline)
526        },
527        "duplicate-stems",
528        validation_passed,
529    );
530
531    // Gate 9: composition (assumes/guarantees chain verification)
532    push_gate(
533        &mut gates,
534        &mut all_findings,
535        || composition_gate::run_composition_gate(&contracts, &ambiguous),
536        "composition",
537        validation_passed,
538    );
539
540    // Gate 10: sigma (ONT-2b). R-8: a new gate is COMPUTED everywhere and armed per repo — so it runs here as
541    // well as under `--gate sigma`, or `armed_gates` could name a gate no run ever computes.
542    let (sigma_gate_result, mut sigma_findings) =
543        sigma_result(config.contract_dir, validation_passed);
544    gates.push(sigma_gate_result);
545    all_findings.append(&mut sigma_findings);
546
547    // Gate 11: relations (ONT-4). Same R-8 shape as sigma: computed in every run, armed per repo.
548    let (relations_gate_result, mut relations_findings) =
549        relations_result(config.contract_dir, validation_passed);
550    gates.push(relations_gate_result);
551    all_findings.append(&mut relations_findings);
552
553    // Gate 12: shapes (ONT-4b). Same R-8 shape: computed in every run, armed per repo.
554    let (shapes_gate_result, mut shapes_findings) =
555        shapes_result(config.contract_dir, validation_passed);
556    gates.push(shapes_gate_result);
557    all_findings.append(&mut shapes_findings);
558
559    // Gate 9: strict test-binding (Issue #1510, opt-in via --strict-test-binding)
560    if config.strict_test_binding {
561        push_gate(
562            &mut gates,
563            &mut all_findings,
564            || {
565                let project_root = config.contract_dir.parent().unwrap_or(config.contract_dir);
566                strict_test_binding::run_strict_test_binding_gate(
567                    &contracts,
568                    project_root,
569                    config.strict,
570                )
571            },
572            "strict-test-binding",
573            validation_passed,
574        );
575    }
576
577    all_findings.append(&mut validate_findings);
578
579    // Per-contract timing: measure how long each contract's findings take to process
580    if validation_passed {
581        contract_timings = per_contract_timings(&contracts, binding.as_ref());
582    }
583
584    // Stale suppression detection (PV-SUP-001, Section 17 Gap 2)
585    let mut stale_findings = check_stale_suppressions(
586        &all_findings,
587        &config.suppressed_rules,
588        &config.suppressed_findings,
589    );
590    all_findings.append(&mut stale_findings);
591
592    // Issue lifecycle: mark each finding as new or pre-existing
593    mark_new_findings(&mut all_findings, config.contract_dir);
594
595    // Cache: store findings per-contract for future runs
596    if let Some(ref root) = cache_root {
597        store_findings_in_cache(root, config, &contracts, &all_findings, &mut stats);
598    }
599
600    // Apply suppressions, severity overrides, strict mode, and severity filter
601    apply_suppressions(&mut all_findings, config);
602    apply_severity_overrides(&mut all_findings, config);
603    if let Some(min_sev) = config.severity_filter {
604        all_findings.retain(|f| f.severity >= min_sev);
605    }
606
607    let passed = gates.iter().all(|g| g.passed || g.skipped);
608
609    let mut report = LintReport {
610        passed,
611        gates,
612        total_duration_ms: u64::try_from(overall_start.elapsed().as_millis()).unwrap_or(u64::MAX),
613        findings: all_findings,
614        cache_stats: stats,
615        contract_timings,
616        verdict: Verdict::Unknown(crate::ontology::verdict::Reason::NotArmed),
617        armed_gates: Vec::new(),
618        not_armed: Vec::new(),
619        armed_monotone: None,
620        armed_shapes_monotone: None,
621    };
622    // The default set. `pv lint` re-arms from the corpus's `lint-baseline.json` (ONT-001 §3.9): a gate a flag
623    // ran but the declaration does not arm is printed and excluded, like every other unarmed gate.
624    report.arm(&ArmedGates::default_set());
625    report
626}
627
628/// ONT-2b: what `--gate <name>` answered. Only `Ran` and `Sigma(Ran)` are verdicts about the corpus.
629pub enum NamedGateOutcome {
630    /// `--gate` was given a name this build does not compute alone.
631    UnknownGate,
632    /// The `sigma` gate, which has two non-verdict answers of its own (no Σ, malformed Σ).
633    Sigma(sigma_gate::SigmaOutcome),
634    /// The `relations` gate (ONT-4), with three non-verdict answers (no Σ, malformed Σ, no typed relations).
635    Relations(relations_gate::RelationsOutcome),
636    /// The `shapes` gate (ONT-4b), with four non-verdict answers (unsupported shape, no shapes, no focus, control failed).
637    Shapes(shapes_gate::ShapesOutcome),
638    /// A gate that ran and judged the corpus.
639    Ran {
640        result: Box<GateResult>,
641        findings: Vec<LintFinding>,
642    },
643}
644
645/// Run ONE named gate and nothing else (ONT-001 §5 ONT-2b).
646///
647/// Only the gates that are meaningful ALONE are offered: `sigma` reads Σ and the corpus, `validate` parses the
648/// corpus. The rest of `run_lint`'s gates are skipped when validation fails, so running one of them by itself would
649/// report a verdict whose precondition nobody checked — `UnknownGate` is the honest answer, not a silent pass.
650#[must_use]
651pub fn run_named_gate(contract_dir: &Path, name: &str) -> NamedGateOutcome {
652    run_named_gate_with(contract_dir, name, &shapes_gate::ShapesOptions::default())
653}
654
655/// [`run_named_gate`] with the shapes gate's `--shape` / `--release-*` options (aprender#3715).
656pub fn run_named_gate_with(
657    contract_dir: &Path,
658    name: &str,
659    shapes_opts: &shapes_gate::ShapesOptions,
660) -> NamedGateOutcome {
661    match name {
662        "relations" => {
663            NamedGateOutcome::Relations(relations_gate::run_relations_gate(contract_dir))
664        }
665        "shapes" => {
666            NamedGateOutcome::Shapes(shapes_gate::run_shapes_gate_with(contract_dir, shapes_opts))
667        }
668        "sigma" => NamedGateOutcome::Sigma(sigma_gate::run_sigma_gate(contract_dir)),
669        "validate" => {
670            let (contracts, parse_errors) = load_contracts(contract_dir);
671            let (result, findings) = run_validate_gate(&contracts, &parse_errors);
672            NamedGateOutcome::Ran {
673                result: Box::new(result),
674                findings,
675            }
676        }
677        _ => NamedGateOutcome::UnknownGate,
678    }
679}
680
681/// The gate names `--gate` computes alone, for the refusal message.
682pub const NAMED_GATES: [&str; 4] = ["relations", "shapes", "sigma", "validate"];
683
684/// The `sigma` gate as `run_lint` reports it. Σ's two non-verdict answers become SKIPPED gates here — under
685/// `--gate sigma` they are an exit of their own (decline / error), but inside a full run "skipped" is how the
686/// lattice already says "not measured".
687fn sigma_result(contract_dir: &Path, validation_passed: bool) -> (GateResult, Vec<LintFinding>) {
688    if !validation_passed {
689        return (skipped_gate("sigma", "validation failed"), Vec::new());
690    }
691    match sigma_gate::run_sigma_gate(contract_dir) {
692        sigma_gate::SigmaOutcome::Ran { result, findings } => (*result, findings),
693        sigma_gate::SigmaOutcome::NoSigma => (
694            skipped_gate("sigma", "no contracts/ontology.yaml"),
695            Vec::new(),
696        ),
697        sigma_gate::SigmaOutcome::Malformed(e) => (
698            skipped_gate("sigma", &format!("Σ is malformed: {e}")),
699            Vec::new(),
700        ),
701    }
702}
703
704/// The `relations` gate as `run_lint` reports it. Its three non-verdict answers become SKIPPED gates here, as
705/// sigma's do — under `--gate relations` they are exits of their own (decline / error).
706fn relations_result(
707    contract_dir: &Path,
708    validation_passed: bool,
709) -> (GateResult, Vec<LintFinding>) {
710    if !validation_passed {
711        return (skipped_gate("relations", "validation failed"), Vec::new());
712    }
713    match relations_gate::run_relations_gate(contract_dir) {
714        relations_gate::RelationsOutcome::Ran { result, findings } => (*result, findings),
715        relations_gate::RelationsOutcome::NoSigma => (
716            skipped_gate("relations", "no contracts/ontology.yaml"),
717            Vec::new(),
718        ),
719        relations_gate::RelationsOutcome::Malformed(e) => (
720            skipped_gate("relations", &format!("Σ is malformed: {e}")),
721            Vec::new(),
722        ),
723        relations_gate::RelationsOutcome::NoRelations {
724            contracts_checked,
725            legacy_depends_on,
726        } => (
727            skipped_gate(
728                "relations",
729                &format!("no typed relations in {contracts_checked} contracts ({legacy_depends_on} legacy metadata.depends_on edges) — R-2: zero is a decline"),
730            ),
731            Vec::new(),
732        ),
733    }
734}
735
736/// The `shapes` gate as `run_lint` reports it. Its four non-verdict answers become SKIPPED gates here (under
737/// `--gate shapes` they are exits of their own); an `Unknown{Warn}` run is reported as the gate returned it.
738fn shapes_result(contract_dir: &Path, validation_passed: bool) -> (GateResult, Vec<LintFinding>) {
739    if !validation_passed {
740        return (skipped_gate("shapes", "validation failed"), Vec::new());
741    }
742    match shapes_gate::run_shapes_gate(contract_dir) {
743        shapes_gate::ShapesOutcome::Ran { result, findings } => (*result, findings),
744        shapes_gate::ShapesOutcome::Unsupported(e) => (skipped_gate("shapes", &format!("{e}")), Vec::new()),
745        shapes_gate::ShapesOutcome::ExtractFailed(e) => (skipped_gate("shapes", &format!("{e}")), Vec::new()),
746        shapes_gate::ShapesOutcome::NoShapes { contracts_checked } => (
747            skipped_gate("shapes", &format!("no `shape:` block in {contracts_checked} contracts — R-2: zero is a decline")),
748            Vec::new(),
749        ),
750        shapes_gate::ShapesOutcome::NoFocus { shapes_n } => (
751            skipped_gate("shapes", &format!("{shapes_n} shape(s), no focus node")),
752            Vec::new(),
753        ),
754        shapes_gate::ShapesOutcome::WrongCorpus { shapes_n, expected, found, refused } => (
755            skipped_gate("shapes", &format!("extract:parity-receipt matched {found} focus node(s) and evidence/parity/EXPECTED_RECEIPTS says {expected} ({shapes_n} shape(s)){} — an extractor that saw the wrong corpus reports the same \"no violations\" as one that saw all of it", if refused.is_empty() { String::new() } else { format!("; refused: {}", refused.join("; ")) })),
756            Vec::new(),
757        ),
758        shapes_gate::ShapesOutcome::NoReceipts { shapes_n, dir } => (
759            skipped_gate("shapes", &format!("{shapes_n} shape(s) resolve receipts and the tree holds none under {dir}/ — R-2: unmeasured is a decline")),
760            Vec::new(),
761        ),
762        shapes_gate::ShapesOutcome::PositiveControlFailed { shapes_n, focus_nodes_n, which } => (
763            skipped_gate("shapes", &format!("positive control {which} did not fire ({shapes_n} shape(s), {focus_nodes_n} focus node(s)) — the gate cannot reject")),
764            Vec::new(),
765        ),
766        shapes_gate::ShapesOutcome::Differential { shapes_n, focus_nodes_n, passed, n, failed } => {
767            let mut g = skipped_gate(
768                "shapes",
769                &format!(
770                    "W3C SHACL-Core differential: {passed} of {n} vendored case(s) pass; failed: {} ({shapes_n} shape(s), {focus_nodes_n} focus node(s)) — the validator disagrees with the standard, so no corpus verdict",
771                    failed.join(" | ")
772                ),
773            );
774            g.verdict = Verdict::Unknown(crate::ontology::verdict::Reason::Differential);
775            (g, Vec::new())
776        }
777    }
778}
779
780fn skipped_gate(name: &str, reason: &str) -> GateResult {
781    GateResult {
782        name: name.into(),
783        passed: false,
784        skipped: true,
785        verdict: Verdict::from_gate(false, true),
786        duration_ms: 0,
787        detail: GateDetail::Skipped {
788            reason: reason.into(),
789        },
790        extra: None,
791    }
792}
793
794fn apply_suppressions(findings: &mut [LintFinding], config: &LintConfig) {
795    for f in findings.iter_mut() {
796        if config.suppressed_rules.iter().any(|r| r == &f.rule_id) {
797            f.suppressed = true;
798            f.suppression_reason = Some("Suppressed by --suppress-rule".into());
799        }
800        if let Some(ref stem) = f.contract_stem {
801            if config.suppressed_findings.iter().any(|s| s == stem) {
802                f.suppressed = true;
803                f.suppression_reason = Some("Suppressed by --suppress".into());
804            }
805        }
806        if config.suppressed_files.iter().any(|p| f.file.contains(p)) {
807            f.suppressed = true;
808            f.suppression_reason = Some("Suppressed by --suppress-file".into());
809        }
810    }
811}
812
813/// Resolve the `.pv/` state directory relative to the contract directory's parent.
814fn pv_state_dir(contract_dir: &Path) -> std::path::PathBuf {
815    contract_dir.parent().unwrap_or(contract_dir).join(".pv")
816}
817
818/// Load previous fingerprints, compare with current findings, mark new ones,
819/// and persist the current fingerprint set for the next run.
820fn mark_new_findings(findings: &mut [LintFinding], contract_dir: &Path) {
821    let state_dir = pv_state_dir(contract_dir);
822    let previous_path = state_dir.join("lint-previous.json");
823
824    // Load previous fingerprints (empty set if file missing or unreadable)
825    let previous: HashSet<String> = std::fs::read_to_string(&previous_path)
826        .ok()
827        .and_then(|s| serde_json::from_str(&s).ok())
828        .unwrap_or_default();
829
830    // Compute current fingerprints and mark new findings
831    let mut current = HashSet::new();
832    for f in findings.iter_mut() {
833        let fp = f.fingerprint();
834        if !previous.contains(&fp) {
835            f.is_new = true;
836        }
837        current.insert(fp);
838    }
839
840    // Persist current fingerprints for the next run
841    if let Err(e) = std::fs::create_dir_all(&state_dir) {
842        eprintln!("pv lint: cannot create {}: {e}", state_dir.display());
843        return;
844    }
845    if let Ok(json) = serde_json::to_string(&current) {
846        let _ = std::fs::write(&previous_path, json);
847    }
848}
849
850fn apply_severity_overrides(findings: &mut [LintFinding], config: &LintConfig) {
851    for f in findings.iter_mut() {
852        if let Some(&sev) = config.severity_overrides.get(&f.rule_id) {
853            f.severity = sev;
854        }
855    }
856    if config.strict {
857        for f in findings.iter_mut() {
858            if f.severity == RuleSeverity::Warning {
859                f.severity = RuleSeverity::Error;
860            }
861        }
862    }
863}
864
865#[cfg(test)]
866#[path = "mod_tests.rs"]
867mod tests;