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;
12pub mod capability_cells_gate;
13mod composition_gate;
14pub mod config;
15pub mod consistency_gate;
16pub mod diff;
17pub mod duplicate_stems;
18pub mod evidence_gate;
19pub mod finding;
20mod gates;
21pub use gates::collect_yaml_files;
22pub mod bindings_gate;
23mod gates_extended;
24pub mod ratchet_gates;
25pub mod refinement_gate;
26pub mod refines_gate;
27pub mod relations_gate;
28pub mod rules;
29pub mod sarif;
30pub mod shapes_gate;
31pub mod sigma_gate;
32pub mod sigma_symbols;
33mod strict_test_binding;
34pub mod subsumption;
35pub mod tbox_gate;
36pub mod trend;
37pub mod valid_under_gate;
38
39use std::collections::{HashMap, HashSet};
40use std::path::Path;
41use std::time::Instant;
42
43use serde::Serialize;
44
45use self::finding::LintFinding;
46use self::gates::{
47    load_binding, load_contracts, run_audit_gate, run_score_gate, run_validate_gate,
48};
49use self::gates_extended::{
50    check_stale_suppressions, run_enforce_gate, run_enforcement_level_gate,
51    run_reverse_coverage_gate, run_verify_gate,
52};
53use self::rules::RuleSeverity;
54use crate::ontology::arming::{meet_armed, ArmedGates};
55use crate::ontology::verdict::Verdict;
56
57/// Result of a single gate execution.
58#[derive(Debug, Clone, Serialize)]
59pub struct GateResult {
60    pub name: String,
61    pub passed: bool,
62    pub skipped: bool,
63    /// ONT-001 §3.4: this gate's element of the one verdict lattice (`Verdict::from_gate(passed, skipped)`).
64    pub verdict: Verdict,
65    pub duration_ms: u64,
66    pub detail: GateDetail,
67    /// Structured payload for gates invented AFTER `GateDetail` was frozen.
68    ///
69    /// See [`GateExtra`] for why this second channel exists. Adding a field to a
70    /// struct is invisible to a `match` on `GateDetail`, which is the property
71    /// the 0.3.1 compatibility corpus depends on; adding a *variant* is not.
72    /// Serialised only when present, so existing JSON output is unchanged.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub extra: Option<GateExtra>,
75}
76
77/// Gate-specific detail payload.
78///
79/// # This enum is FROZEN at the eight variants published in `provable-contracts`
80/// 0.3.1 — do not add a ninth
81///
82/// `GateDetail` is public, is not `#[non_exhaustive]`, and 0.3.1 shipped 28 example
83/// programs that `match` it exhaustively. Those programs are vendored verbatim under
84/// `crates/facades/provable-contracts/compat/0.3.1/`, sha256-verified against their
85/// published checksums by `scripts/check_facade_compat.sh` (row R6), and compiled in
86/// the `ci` job that the required `gate` check depends on. A ninth variant is
87/// `error[E0004]` in that corpus, and the corpus cannot be edited to accommodate it —
88/// being uneditable is the whole point of a compatibility contract. Marking the enum
89/// `#[non_exhaustive]` does not help either: it makes the *existing* exhaustive
90/// matches non-exhaustive, which is the same compile error for the same reason.
91///
92/// So the vocabulary is closed. A gate added after 0.3.1 picks the truest of these
93/// eight for `detail` and carries its own shape in [`GateResult::extra`], which is a
94/// type 0.3.1 never names and is therefore free to grow.
95#[derive(Debug, Clone, Serialize)]
96#[serde(tag = "type")]
97pub enum GateDetail {
98    #[serde(rename = "validate")]
99    Validate {
100        contracts: usize,
101        errors: usize,
102        warnings: usize,
103        error_messages: Vec<String>,
104    },
105    #[serde(rename = "audit")]
106    Audit {
107        contracts: usize,
108        findings: usize,
109        finding_messages: Vec<String>,
110    },
111    #[serde(rename = "score")]
112    Score {
113        contracts: usize,
114        min_score: f64,
115        mean_score: f64,
116        threshold: f64,
117        below_threshold: Vec<String>,
118    },
119    #[serde(rename = "verify")]
120    Verify {
121        total_refs: usize,
122        existing: usize,
123        missing: usize,
124    },
125    #[serde(rename = "enforce")]
126    Enforce {
127        equations_total: usize,
128        equations_with_pre: usize,
129        equations_with_post: usize,
130        equations_with_lean: usize,
131    },
132    #[serde(rename = "reverse_coverage")]
133    ReverseCoverage {
134        total_pub_fns: usize,
135        bound_fns: usize,
136        unbound_fns: usize,
137        coverage_pct: f64,
138        threshold_pct: f64,
139    },
140    #[serde(rename = "composition")]
141    Composition {
142        edges_checked: usize,
143        edges_satisfied: usize,
144        edges_broken: usize,
145    },
146    #[serde(rename = "skipped")]
147    Skipped { reason: String },
148}
149
150/// The `shapes` gate's positive controls and ONT-4c5's cell report — one boxed, flattened block of
151/// [`GateExtra::Shapes`].
152#[derive(Debug, Clone, Serialize)]
153pub struct ShapesControls {
154    /// The extractor positive controls: `gguf` (corrupt magic refused), `apr-model` (lying header refused).
155    pub pc_extract: std::collections::BTreeMap<String, String>,
156    /// ONT-4c5: per-shape positive controls (`capability-cells` → `fired`); the gate never reaches `Ran` with
157    /// one that is not.
158    pub pc_shapes: std::collections::BTreeMap<String, String>,
159    /// ONT-4c5: the capability-cell domain D and its NotRun cells at the current release — absent unless a
160    /// contract declares the `capability-cells` shape.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub capability_cells: Option<capability_cells_gate::CapabilityCellsReport>,
163}
164
165/// Out-of-band structured detail for gates that post-date the frozen [`GateDetail`]
166/// vocabulary.
167///
168/// This type did not exist in `provable-contracts` 0.3.1, so no 0.3.1 program names
169/// it and none can `match` it. That is what makes it extensible where `GateDetail` is
170/// not, and it is `#[non_exhaustive]` from birth so the *next* post-0.3.1 gate does
171/// not have to repeat this exercise.
172// `Shapes` is 304 bytes against a 88-byte second-largest, and adding `declines` to it is what
173// crossed the threshold — `origin/main` at 237fbc32f lints clean, this branch does not, so the
174// finding is this PR's and not inherited.
175//
176// ALLOWED RATHER THAN BOXED, deliberately. `large_enum_variant` is a cost heuristic about copying
177// and stack size: a `GateExtra` is built ONCE PER GATE RUN, moved a handful of times, and then
178// serialised. There is no hot path here for 216 bytes to matter on, so the lint is measuring a cost
179// this type does not pay.
180//
181// The alternatives are worse. Boxing one field (clippy suggests `pc_extract`) reclaims 16 bytes and
182// does not clear the ratio, so it would be churn that silences nothing. Boxing the whole payload —
183// `Shapes(Box<ShapesExtra>)` — turns a struct variant into a newtype variant, which CHANGES THE
184// SERDE REPRESENTATION of a `#[serde(tag = "type")]` enum that downstream consumers parse; the SLK
185// gate reads this JSON. Breaking a wire format to satisfy a stack-size heuristic is the wrong trade.
186//
187// If `GateExtra` ever ends up in a loop or a large collection, this allow is the thing to revisit.
188#[allow(clippy::large_enum_variant)]
189#[derive(Debug, Clone, Serialize)]
190// One value per `pv lint` run, built once and serialised: the Shapes variant's size is not a hot-path cost.
191#[serde(tag = "type")]
192#[non_exhaustive]
193pub enum GateExtra {
194    /// PV-DUP-001: contract stems claimed by several files with divergent content.
195    #[serde(rename = "duplicate_stems")]
196    DuplicateStems {
197        /// Total ambiguous stems found in the tree.
198        divergent: usize,
199        /// How many of those are recorded in the ratchet baseline.
200        baselined: usize,
201        /// Ambiguous stems NOT in the baseline — these fail the gate.
202        unbaselined: Vec<String>,
203        /// Baseline entries that no longer diverge — these fail the gate too.
204        stale: Vec<String>,
205        /// Every ambiguous stem, with its variant count and paths, for the report.
206        divergent_stems: Vec<String>,
207    },
208    /// ONT-2b: what Σ declares and what the corpus was checked against.
209    #[serde(rename = "sigma")]
210    Sigma {
211        /// `entity_types` Σ declares.
212        entity_types: usize,
213        /// `roles` Σ declares.
214        roles: usize,
215        /// `symbols` Σ declares.
216        symbols: usize,
217        /// Contract files read (never counting `ontology.yaml` itself).
218        contracts_checked: usize,
219        /// Findings: undeclared entity types, undeclared roles, undeclared glyphs.
220        violations: usize,
221        /// `formal:` expressions read.
222        formal_total: usize,
223        /// Of those, how many carry NO symbol Σ declares — the `formal_prose` debt, shrink-only.
224        formal_prose: usize,
225    },
226    /// ONT-4: the corpus's typed relations, checked against Σ's roles.
227    #[serde(rename = "relations")]
228    Relations {
229        /// Typed edges after symmetric closure.
230        relations_n: usize,
231        /// Contracts carrying a `relations:` block.
232        contracts_with_relations: usize,
233        /// `role=count` per role used.
234        roles_used: Vec<String>,
235        /// Cycles through acyclic roles, as `role: a -> b -> a`.
236        cycles: Vec<String>,
237        /// `metadata.depends_on` edges, read and counted, never rewritten (R-5).
238        legacy_depends_on: usize,
239        /// Of those, how many name no contract — ratcheted shrink-only in the baseline.
240        legacy_unresolved_depends_on: usize,
241        /// Findings.
242        violations: usize,
243    },
244    /// ONT-8: one evidence block — PROV-O names, one L-enum, every entity type.
245    #[serde(rename = "evidence")]
246    Evidence {
247        /// Contract files read.
248        contracts_checked: usize,
249        /// Of those, how many carry an `evidence` block.
250        contracts_with_evidence: usize,
251        /// Distinct `entity.type` among the contracts carrying evidence — counted, never branched on (R-17).
252        entity_types_checked: usize,
253        /// Those types, sorted.
254        entity_types: Vec<String>,
255        /// `enum`: levels are parsed through `ProofLevel`, never matched against a list kept in the gate.
256        levels_source: String,
257        /// `level=count` over the evidence blocks that passed every rule.
258        by_level: Vec<String>,
259        /// The census `git_sha` a `[V]` claim binds to; `None` (the 2026-09-16 ruling) binds it to the repository.
260        census_git_sha: Option<String>,
261        /// `[V]` shas git could not look for — non-zero makes the verdict `Unknown{ToolAbsent}`, never `Pass`.
262        unresolved_git_shas: usize,
263        /// Findings.
264        violations: usize,
265    },
266    /// ONT-7: kernel-kind contracts carry a world index (`metadata.valid_under.world`, a key of Σ's `worlds`).
267    #[serde(rename = "valid_under")]
268    ValidUnder {
269        /// The worlds Σ declares — the index a `valid_under.world` must resolve into.
270        worlds: Vec<String>,
271        /// Contract files read.
272        contracts_checked: usize,
273        /// Of those, kernel-kind and not a registry — the class the row obliges.
274        kernel_contracts: usize,
275        /// Contracts (any kind) carrying `metadata.valid_under`.
276        contracts_with_valid_under: usize,
277        /// Kernel-kind contracts carrying none — the debt, shrink-only against the baseline.
278        contracts_without_valid_under: usize,
279        /// The top-level `contracts_without_valid_under` in `lint-baseline.json`; `None` = not recorded.
280        baseline: Option<usize>,
281        /// `world=count` over the contracts whose `valid_under` passed every rule.
282        by_world: Vec<String>,
283        /// Findings.
284        violations: usize,
285    },
286    /// PVL-001 EV-11: Lean theorem modules named by a book page — the `unpaired_theorem_modules` ratchet.
287    #[serde(rename = "theorem_pairing")]
288    TheoremPairing {
289        /// The Lean base the modules were read under.
290        lean_base: String,
291        /// `.md` pages read under the book roots.
292        book_pages: usize,
293        /// `.lean` files under `<base>/ProvableContracts/Theorems/`.
294        theorem_modules: usize,
295        /// Of those, named in full by at least one book page.
296        paired: usize,
297        /// The debt, shrink-only against the baseline.
298        unpaired_theorem_modules: usize,
299        /// The top-level `unpaired_theorem_modules` in `lint-baseline.json`; `None` = not recorded (reported only).
300        baseline: Option<usize>,
301        /// The unpaired modules, dotted, in byte order.
302        unpaired: Vec<String>,
303        /// Findings.
304        violations: usize,
305    },
306    /// PVL-001 EV-11: kernel-kind contracts with an empty `metadata.depends_on` — the ratchet.
307    #[serde(rename = "depends_on_present")]
308    DependsOnPresent {
309        /// Contract files parsed.
310        contracts_checked: usize,
311        /// Of those, kernel-kind and not a registry.
312        kernel_contracts: usize,
313        /// Kernel-kind contracts whose `metadata.depends_on` is empty — the debt, shrink-only.
314        contracts_without_depends_on: usize,
315        /// The top-level `contracts_without_depends_on` in `lint-baseline.json`; `None` = not recorded (reported only).
316        baseline: Option<usize>,
317        /// Findings.
318        violations: usize,
319    },
320    /// PVL-001 EV-8a (#4202): `status: proved` claims no green `discharge-summary.json` derives — the ratchet.
321    #[serde(rename = "proved_is_derived")]
322    ProvedIsDerived {
323        /// The summary read (beside the Lean base); `None` = no Lean base.
324        summary: Option<String>,
325        /// Why it derived nothing when it could not be read; `None` = it was read.
326        summary_error: Option<String>,
327        /// Every Lean step ran and passed. Only a green summary derives a claim.
328        summary_green: bool,
329        /// Contract files parsed.
330        contracts_checked: usize,
331        /// `proof_obligations[].lean.status: proved` claims.
332        proved_claims: usize,
333        /// Of those, not derived — the debt, shrink-only against the baseline, and it must reach 0.
334        underived_proved_claims: usize,
335        /// The top-level `underived_proved_claims` in `lint-baseline.json`; `None` = not recorded (reported only).
336        baseline: Option<usize>,
337        /// The underived claims, as `contract: theorem`, in contract order.
338        underived: Vec<String>,
339        /// Findings.
340        violations: usize,
341    },
342    /// PVL-001 EV-7a (#4200): `<lean>/Challenge/` against its regeneration (`pv challenge check`).
343    #[serde(rename = "challenge_fresh")]
344    ChallengeFresh {
345        /// The Lean dir judged.
346        lean_dir: String,
347        /// Challenge files the contracts render.
348        files: usize,
349        /// Extra, missing or differing files, one line each.
350        stale: Vec<String>,
351        /// Bound roots whose statement could not be lifted, as `contract: fqn: why`.
352        unrestated: Vec<String>,
353    },
354    /// ONT-4b: the shapes gate — every `shape:` block over the extracted graph, with the plant.
355    #[serde(rename = "shapes")]
356    Shapes {
357        /// `shape:` blocks read.
358        shapes_n: usize,
359        /// Distinct focus nodes across the shapes (the plant excluded).
360        focus_nodes_n: usize,
361        /// `fired` — the plant drew a violation; the gate never reaches `Ran` otherwise.
362        pc_shape: String,
363        /// How many violations the plant drew.
364        plant_violations: usize,
365        /// Corpus violations (the plant excluded).
366        violations: usize,
367        /// Corpus warnings.
368        warnings: usize,
369        /// `shape=focus-count` per shape.
370        by_shape: Vec<String>,
371        /// Triples in the extracted graph.
372        triples: usize,
373        /// ONT-4c1 (§3.9 per-shape arming): shapes that feed the verdict, in corpus order.
374        armed_shapes: Vec<String>,
375        /// Shapes computed and reported but not armed — their violations are in `unarmed_violations`.
376        not_armed_shapes: Vec<String>,
377        /// #3610: shapes that graded ZERO focus nodes, named — the reach the gate did not have.
378        ///
379        /// A separate list from `not_armed_shapes` on purpose. "Not armed by policy" and "armed and
380        /// measured nothing" are different facts, and folding the second into the first would file a
381        /// vacuity as a deliberate choice — which is how the defect hid in the first place.
382        ///
383        /// **This carries EVERY shape that graded zero, armed or unarmed**, and it is the only list
384        /// any of them appears in: a vacuity is in neither `armed_shapes` (the tool's claim about
385        /// what it MEASURED) nor `not_armed_shapes` (a policy choice it never made).
386        ///
387        /// The two differ in what they do to the verdict, not in whether they are listed here. An
388        /// ARMED vacuity drives the verdict to `Unknown(NoFocus)` and the run declines — unless an
389        /// armed shape that DID grade something found a violation, in which case the verdict is
390        /// `Fail` (a measured violation outranks a vacuity, #3622). An UNARMED one leaves the
391        /// verdict alone, because it never fed it. Both are named, because a reader
392        /// needs to know the gate looked at nothing for them either way.
393        ///
394        /// An earlier draft of this comment said an armed vacuity "does not reach here at all",
395        /// which was false — nothing returns early at the verdict, and both kinds reach this field.
396        /// A quorum lane caught the sentence; the code beside it had the matching bug.
397        declines: Vec<String>,
398        /// Violations from unarmed shapes (named in the findings as warnings; never in the meet).
399        unarmed_violations: usize,
400        /// Focus nodes each extractor produced: `pv-contract`, `gguf`, `apr-model`.
401        by_entity_type: std::collections::BTreeMap<String, usize>,
402        /// The positive controls (`pc_extract`, `pc_shapes`) and ONT-4c5's `capability_cells`, boxed and
403        /// FLATTENED: the JSON keys stay top-level where the §5 probes read them, and the variant stays under
404        /// clippy's large-enum-variant bound (it sat exactly at it before ONT-4c5).
405        #[serde(flatten)]
406        controls: Box<ShapesControls>,
407        /// Ladder receipt files read under `evidence/dogfood/models/`.
408        receipts: usize,
409        /// Receipt rows whose `sha256` equals a rung's.
410        witnesses: usize,
411        /// Receipt rows whose `sha256` differs from the rung's.
412        hex_mismatches: usize,
413        /// Receipt rows without a `sha256` (never a witness).
414        unmeasured_rows: usize,
415        /// ONT-4b2: vendored W3C SHACL-Core cases that passed this run, and how many are vendored.
416        w3c_cases_passed: usize,
417        w3c_cases_n: usize,
418        /// The ONT-4b2 extractor counters and ONT-4d's inheritance, FLATTENED into this object: the JSON keys
419        /// (`symbols_resolved`, …, `inherited_shapes_applied`) are unchanged. They are boxed only so the variant
420        /// stays under clippy's `large_enum_variant` once ONT-4d's two fields joined it.
421        #[serde(flatten)]
422        counters: Box<ShapesCounters>,
423        /// aprender#3715: what `extract:release-evidence` derived — absent unless a release subject was given.
424        #[serde(skip_serializing_if = "Option::is_none")]
425        release: Option<Box<crate::ontology::extract::release_evidence::ReleaseStats>>,
426        /// ONT-4c (v4.14): the README claim commands a merge-path workflow runs — the MEASURED set F-33 compares.
427        readme: MeasuredSet,
428        /// ONT-4c (v4.14): the same for `CLAUDE.md` (`extract:llm-context`).
429        claude_md: MeasuredSet,
430        /// ONT-4c (v4.14): whether the measured-set ratchets (F-33 and F-34) were checked this run.
431        ratchets: Ratchets,
432    },
433    /// ONT-5: the typed relations are jointly satisfiable, by a witness pv-sat wrote and this run re-checked.
434    #[serde(rename = "ont_consistency")]
435    Consistency {
436        /// Clauses a typed relation produced (`depends_on`/`refines` implications, `contradicts` conflicts).
437        checkable_n: usize,
438        /// Contracts asserted in force (every id no contract supersedes).
439        units: usize,
440        /// Typed edges through a role the encoding does not name.
441        unencoded_edges: usize,
442        /// `fired` — the checker refused the corrupt-core fixture this run.
443        pc_checker: String,
444        /// The contracts of a checked unsat core; empty when the witness is a model.
445        core: Vec<String>,
446        /// Findings.
447        violations: usize,
448        /// The witness this run checked.
449        witness: consistency_gate::WitnessReport,
450    },
451    /// ONT-4e (R-20): every `A refines B` is Liskov, by a witness pv-sat wrote and this run re-checked.
452    #[serde(rename = "refines")]
453    Refines(Box<refines_gate::RefinesCounters>),
454    /// ONT-3a: every implemented/partial binding resolves to workspace code, or the allowlist names it.
455    #[serde(rename = "bindings")]
456    Bindings(Box<bindings_gate::BindingsCounters>),
457    /// ONT-3b: every `model_of` resolves, and the unrefined-module count holds its shrink-only baseline.
458    #[serde(rename = "refinement")]
459    Refinement(Box<refinement_gate::RefinementCounters>),
460}
461
462/// Counters a `shapes` run reports, flattened into [`GateExtra::Shapes`]'s JSON.
463#[derive(Debug, Clone, Default, Serialize)]
464pub struct ShapesCounters {
465    /// ONT-4b2: bound Rust symbols the `syn` walk resolved / could not resolve.
466    pub symbols_resolved: usize,
467    pub symbols_unresolved: usize,
468    /// ONT-4b2: Lean theorems extracted, and contract `lean_theorem:` references naming none of them.
469    pub lean_statements: usize,
470    pub lean_refs_unresolved: usize,
471    /// ONT-4d (R-19): applications of a shape to instances of a strict sub-concept of its target, summed over shapes. Such a
472    /// node may ALSO be typed the target directly (`pv_contract` asserts `ont:Contract` on every contract), so
473    /// this counts the hierarchy being applied, not reach that exists only through it. The fixture
474    /// `subsumption-inherit` is where inheritance is the ONLY path.
475    pub inherited_shapes_applied: usize,
476    /// `<shape> <- <sub-concept>=<n>` for each shape inherited down the hierarchy, sorted.
477    pub inherited_by_shape: Vec<String>,
478}
479
480/// ONT-4c: one measured claim set, sorted.
481#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
482pub struct MeasuredSet {
483    pub verified_commands: Vec<String>,
484}
485
486/// ONT-4c: `measured_sets` is `checked` only when F-34 ran against a comparand (the CLI resolves it from git);
487/// outside a work tree it is `not-checked`, which a probe must read as RED, never as a pass.
488#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
489pub struct Ratchets {
490    pub measured_sets: String,
491}
492
493impl Default for Ratchets {
494    fn default() -> Self {
495        Self {
496            measured_sets: "not-checked".into(),
497        }
498    }
499}
500
501/// Overall lint report.
502#[derive(Debug, Clone, Serialize)]
503pub struct LintReport {
504    pub passed: bool,
505    pub gates: Vec<GateResult>,
506    pub total_duration_ms: u64,
507    #[serde(skip_serializing_if = "Vec::is_empty")]
508    pub findings: Vec<LintFinding>,
509    #[serde(skip)]
510    pub cache_stats: cache::CacheStats,
511    /// Per-contract processing times: `(contract_stem, duration_ms)`.
512    #[serde(default, skip_serializing_if = "Vec::is_empty")]
513    pub contract_timings: Vec<(String, u64)>,
514    /// ONT-001 §3.9: the meet over the armed gates — what the exit code reports. `passed` is the legacy
515    /// every-gate-passed-or-skipped flag and is kept unchanged for existing readers.
516    pub verdict: Verdict,
517    /// Each armed gate, in armed order, with the verdict it contributed (`Unknown(NotRun)` if it did not run).
518    pub armed_gates: Vec<ArmedGateVerdict>,
519    /// Gates that ran but are not armed: printed as `Unknown(NotArmed)` and excluded from the meet.
520    pub not_armed: Vec<String>,
521    /// The `armed_gates` monotone check, as the CLI measured it; `None` when nothing checked it.
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub armed_monotone: Option<String>,
524    /// ONT-4c1: the `armed_shapes` monotone check, as the CLI measured it.
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub armed_shapes_monotone: Option<String>,
527}
528
529/// One armed gate's contribution to [`LintReport::verdict`].
530#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
531pub struct ArmedGateVerdict {
532    pub name: String,
533    pub verdict: Verdict,
534}
535
536impl LintReport {
537    /// Recompute the meet over `armed`. Gates that ran but are not armed are listed in `not_armed`.
538    pub fn arm(&mut self, armed: &ArmedGates) {
539        let results: Vec<(String, Verdict)> = self
540            .gates
541            .iter()
542            .map(|g| (g.name.clone(), g.verdict))
543            .collect();
544        let meet = meet_armed(&results, armed);
545        self.verdict = meet.verdict;
546        self.armed_gates = meet
547            .armed
548            .into_iter()
549            .map(|(name, verdict)| ArmedGateVerdict { name, verdict })
550            .collect();
551        self.not_armed = meet.not_armed;
552    }
553}
554
555/// Configuration for `pv lint`.
556pub struct LintConfig<'a> {
557    pub contract_dir: &'a Path,
558    pub binding_path: Option<&'a Path>,
559    pub min_score: f64,
560    pub severity_filter: Option<RuleSeverity>,
561    pub severity_overrides: HashMap<String, RuleSeverity>,
562    pub suppressed_findings: Vec<String>,
563    pub suppressed_rules: Vec<String>,
564    pub suppressed_files: Vec<String>,
565    pub strict: bool,
566    pub no_cache: bool,
567    pub cache_stats: bool,
568    /// Optional crate directory for reverse coverage gate (Gate 7).
569    pub crate_dir: Option<&'a Path>,
570    /// Minimum enforcement level for Gate 6 (from `--min-level`).
571    pub min_level: Option<crate::schema::EnforcementLevel>,
572    /// Enable Gate 9 (strict test-binding, PV-VER-002). Issue #1510.
573    pub strict_test_binding: bool,
574}
575
576impl<'a> LintConfig<'a> {
577    /// Create a basic config (backward compatible).
578    pub fn new(contract_dir: &'a Path, binding_path: Option<&'a Path>, min_score: f64) -> Self {
579        Self {
580            contract_dir,
581            binding_path,
582            min_score,
583            severity_filter: None,
584            severity_overrides: HashMap::new(),
585            suppressed_findings: Vec::new(),
586            suppressed_rules: Vec::new(),
587            suppressed_files: Vec::new(),
588            strict: false,
589            no_cache: false,
590            cache_stats: false,
591            crate_dir: None,
592            min_level: None,
593            strict_test_binding: false,
594        }
595    }
596}
597
598/// Run one gate and record it, or record it as skipped when gate 1 (validate) failed.
599///
600/// Every gate after validate repeats the same `if validation_passed { run } else
601/// { skipped_gate(name, "validation failed") }` shape; this is that shape, once.
602fn push_gate<F>(
603    gates: &mut Vec<GateResult>,
604    all_findings: &mut Vec<LintFinding>,
605    run: F,
606    name: &str,
607    validation_passed: bool,
608) where
609    F: FnOnce() -> (GateResult, Vec<LintFinding>),
610{
611    let (result, mut findings) = if validation_passed {
612        run()
613    } else {
614        (skipped_gate(name, "validation failed"), Vec::new())
615    };
616    gates.push(result);
617    all_findings.append(&mut findings);
618}
619
620/// Per-contract timing: measure how long each contract's findings take to process,
621/// sorted by duration descending.
622fn per_contract_timings(
623    contracts: &[(String, crate::schema::Contract)],
624    binding: Option<&crate::binding::BindingRegistry>,
625) -> Vec<(String, u64)> {
626    let mut contract_timings: Vec<(String, u64)> = Vec::with_capacity(contracts.len());
627    for (stem, contract) in contracts {
628        let ct_start = Instant::now();
629        // Validate
630        let _ = crate::schema::validate_contract(contract);
631        // Audit
632        let _ = crate::audit::audit_contract(contract);
633        // Score
634        let _ = crate::scoring::score_contract(contract, binding, stem);
635        let ct_ms = u64::try_from(ct_start.elapsed().as_micros() / 1000).unwrap_or(0);
636        contract_timings.push((format!("{stem}.yaml"), ct_ms));
637    }
638    // Sort by duration descending
639    contract_timings.sort_by_key(|b| std::cmp::Reverse(b.1));
640    contract_timings
641}
642
643/// Cache: store findings per-contract for future runs.
644fn store_findings_in_cache(
645    root: &std::path::Path,
646    config: &LintConfig,
647    contracts: &[(String, crate::schema::Contract)],
648    all_findings: &[LintFinding],
649    stats: &mut cache::CacheStats,
650) {
651    let rule_cfg = format!("{:?}{:?}", config.severity_overrides, config.strict);
652    for (stem, _) in contracts {
653        stats.total += 1;
654        let yaml_path = config.contract_dir.join(format!("{stem}.yaml"));
655        let yaml_content = std::fs::read_to_string(&yaml_path).unwrap_or_default();
656        let hash = cache::content_hash(&yaml_content, &rule_cfg);
657        if cache::cache_get(root, &hash).is_some() {
658            stats.hits += 1;
659        } else {
660            stats.misses += 1;
661            let contract_findings: Vec<_> = all_findings
662                .iter()
663                .filter(|f| f.contract_stem.as_deref() == Some(stem.as_str()))
664                .cloned()
665                .collect();
666            let _ = cache::cache_put(root, &hash, &contract_findings);
667        }
668    }
669}
670
671/// Gates 2-9: audit, score, verify, enforce, enforcement-level, reverse-coverage, duplicate-stems, composition.
672/// Each is skipped (not run) when `validation_passed` is false, per `push_gate`'s contract.
673fn run_gates_2_to_9(
674    config: &LintConfig,
675    contracts: &[(String, crate::schema::Contract)],
676    binding: Option<&crate::binding::BindingRegistry>,
677    validation_passed: bool,
678    gates: &mut Vec<GateResult>,
679    all_findings: &mut Vec<LintFinding>,
680) {
681    // Gate 2: audit (skip if validation failed)
682    push_gate(
683        gates,
684        all_findings,
685        || run_audit_gate(contracts),
686        "audit",
687        validation_passed,
688    );
689
690    // Gate 3: score (skip if validation failed)
691    push_gate(
692        gates,
693        all_findings,
694        || run_score_gate(contracts, binding, config.min_score),
695        "score",
696        validation_passed,
697    );
698
699    // Gate 4: verify (source code fulfillment)
700    push_gate(
701        gates,
702        all_findings,
703        || {
704            let project_root = config.contract_dir.parent().unwrap_or(config.contract_dir);
705            run_verify_gate(contracts, project_root)
706        },
707        "verify",
708        validation_passed,
709    );
710
711    // Gate 5: enforce (equations must have preconditions/postconditions)
712    push_gate(
713        gates,
714        all_findings,
715        || run_enforce_gate(contracts),
716        "enforce",
717        validation_passed,
718    );
719
720    // Gate 6: enforcement level (Section 17, Gap 1 + Gap 5 level lock)
721    push_gate(
722        gates,
723        all_findings,
724        || {
725            let min_level = config
726                .min_level
727                .unwrap_or(crate::schema::EnforcementLevel::Standard);
728            run_enforcement_level_gate(contracts, min_level)
729        },
730        "enforcement-level",
731        validation_passed,
732    );
733
734    // Gate 7: reverse coverage (optional — skip if no binding or crate dir)
735    push_gate(
736        gates,
737        all_findings,
738        || match (config.binding_path, config.crate_dir) {
739            (Some(bp), Some(cd)) => run_reverse_coverage_gate(bp, cd),
740            _ => (
741                skipped_gate("reverse-coverage", "no --binding or --crate-dir provided"),
742                Vec::new(),
743            ),
744        },
745        "reverse-coverage",
746        validation_passed,
747    );
748
749    // Gate 8: duplicate stems (PV-DUP-001). Must run BEFORE composition — it tells
750    // the composition gate which stems are unresolvable, which is the difference
751    // between a defined verdict and one decided by `read_dir` order.
752    let duplicates = duplicate_stems::scan_duplicate_stems(config.contract_dir);
753    let ambiguous = duplicate_stems::ambiguous_stems(&duplicates);
754    push_gate(
755        gates,
756        all_findings,
757        || {
758            let project_root = config.contract_dir.parent().unwrap_or(config.contract_dir);
759            let baseline = duplicate_stems::read_baseline(project_root);
760            duplicate_stems::run_duplicate_stem_gate(&duplicates, &baseline)
761        },
762        "duplicate-stems",
763        validation_passed,
764    );
765
766    // Gate 9: composition (assumes/guarantees chain verification)
767    push_gate(
768        gates,
769        all_findings,
770        || composition_gate::run_composition_gate(contracts, &ambiguous),
771        "composition",
772        validation_passed,
773    );
774}
775
776/// Gates 10-22: the ONT-* ontology gates. Same R-8 shape throughout — each is computed on every run and armed
777/// per repo, never skipped by `validation_passed` the way gates 2-9 are.
778fn run_ontology_gates_10_to_22(
779    contract_dir: &Path,
780    validation_passed: bool,
781    gates: &mut Vec<GateResult>,
782    all_findings: &mut Vec<LintFinding>,
783) {
784    // Gate 10: sigma (ONT-2b). R-8: a new gate is COMPUTED everywhere and armed per repo — so it runs here as
785    // well as under `--gate sigma`, or `armed_gates` could name a gate no run ever computes.
786    let (sigma_gate_result, mut sigma_findings) = sigma_result(contract_dir, validation_passed);
787    gates.push(sigma_gate_result);
788    all_findings.append(&mut sigma_findings);
789
790    // Gate 11: relations (ONT-4). Same R-8 shape as sigma: computed in every run, armed per repo.
791    let (relations_gate_result, mut relations_findings) =
792        relations_result(contract_dir, validation_passed);
793    gates.push(relations_gate_result);
794    all_findings.append(&mut relations_findings);
795
796    // Gate 12: shapes (ONT-4b). Same R-8 shape: computed in every run, armed per repo.
797    let (shapes_gate_result, mut shapes_findings) = shapes_result(contract_dir, validation_passed);
798    gates.push(shapes_gate_result);
799    all_findings.append(&mut shapes_findings);
800
801    // Gate 13: valid-under (ONT-7). Same R-8 shape: computed in every run, armed per repo.
802    let (valid_under_gate_result, mut valid_under_findings) =
803        valid_under_result(contract_dir, validation_passed);
804    gates.push(valid_under_gate_result);
805    all_findings.append(&mut valid_under_findings);
806
807    // Gates 14, 15, 16: theorem-pairing, depends-on-present (PVL-001 EV-11), proved-is-derived (EV-8a). Same R-8
808    // shape: computed in every run, armed per repo.
809    for (name, run) in RATCHET_GATES {
810        let (result, mut findings) = ratchet_result(contract_dir, validation_passed, name, run);
811        gates.push(result);
812        all_findings.append(&mut findings);
813    }
814
815    // Gate 17: challenge-fresh (PVL-001 EV-7a). Same R-8 shape: computed wherever a Lean theorem base exists,
816    // armed per repo (only through `make ont-ratchet`).
817    gates.push(challenge_result(contract_dir));
818
819    // Gate 18 (numbered by agreement: ONT-7 13, EV-11 14-16, EV-7a 17): ont-consistency (ONT-5). Same R-8 shape:
820    // computed in every run, armed per repo.
821    let (consistency_gate_result, mut consistency_findings) =
822        consistency_result(contract_dir, validation_passed);
823    gates.push(consistency_gate_result);
824    all_findings.append(&mut consistency_findings);
825
826    // Gate 19: refines (ONT-4e, R-20). Same R-8 shape: computed in every run, armed per repo.
827    let (refines_gate_result, mut refines_findings) =
828        refines_result(contract_dir, validation_passed);
829    gates.push(refines_gate_result);
830    all_findings.append(&mut refines_findings);
831
832    // Gate 20: bindings (ONT-3a). Same R-8 shape: computed in every run, armed per repo.
833    let (bindings_gate_result, mut bindings_findings) = ratchet_result(
834        contract_dir,
835        validation_passed,
836        bindings_gate::GATE,
837        bindings_gate::run_bindings_gate,
838    );
839    gates.push(bindings_gate_result);
840    all_findings.append(&mut bindings_findings);
841
842    // Gate 21: refinement (ONT-3b). Same R-8 shape.
843    let (refinement_gate_result, mut refinement_findings) = ratchet_result(
844        contract_dir,
845        validation_passed,
846        refinement_gate::GATE,
847        refinement_gate::run_refinement_gate,
848    );
849    gates.push(refinement_gate_result);
850    all_findings.append(&mut refinement_findings);
851
852    // Gate 22: evidence (ONT-8). Same R-8 shape: computed in every run, armed per repo.
853    let (evidence_gate_result, mut evidence_findings) =
854        evidence_result(contract_dir, validation_passed);
855    gates.push(evidence_gate_result);
856    all_findings.append(&mut evidence_findings);
857}
858
859/// Gate 9 (again, by original numbering): strict test-binding (Issue #1510), opt-in via `--strict-test-binding`.
860fn run_strict_test_binding_gate_if_enabled(
861    config: &LintConfig,
862    contracts: &[(String, crate::schema::Contract)],
863    validation_passed: bool,
864    gates: &mut Vec<GateResult>,
865    all_findings: &mut Vec<LintFinding>,
866) {
867    if !config.strict_test_binding {
868        return;
869    }
870    push_gate(
871        gates,
872        all_findings,
873        || {
874            let project_root = config.contract_dir.parent().unwrap_or(config.contract_dir);
875            strict_test_binding::run_strict_test_binding_gate(
876                contracts,
877                project_root,
878                config.strict,
879            )
880        },
881        "strict-test-binding",
882        validation_passed,
883    );
884}
885
886/// Post-gate passes: fold in the validate gate's own findings, time contracts, detect stale suppressions, mark
887/// new-vs-pre-existing findings, cache them, then apply suppressions/overrides/severity filter.
888#[allow(clippy::too_many_arguments)]
889fn finalize_findings(
890    config: &LintConfig,
891    contracts: &[(String, crate::schema::Contract)],
892    binding: Option<&crate::binding::BindingRegistry>,
893    validation_passed: bool,
894    cache_root: Option<&std::path::Path>,
895    stats: &mut cache::CacheStats,
896    all_findings: &mut Vec<LintFinding>,
897    mut validate_findings: Vec<LintFinding>,
898) -> Vec<(String, u64)> {
899    all_findings.append(&mut validate_findings);
900
901    // Per-contract timing: measure how long each contract's findings take to process
902    let contract_timings = if validation_passed {
903        per_contract_timings(contracts, binding)
904    } else {
905        Vec::new()
906    };
907
908    // Stale suppression detection (PV-SUP-001, Section 17 Gap 2)
909    let mut stale_findings = check_stale_suppressions(
910        all_findings,
911        &config.suppressed_rules,
912        &config.suppressed_findings,
913    );
914    all_findings.append(&mut stale_findings);
915
916    // Issue lifecycle: mark each finding as new or pre-existing
917    mark_new_findings(all_findings, config.contract_dir);
918
919    // Cache: store findings per-contract for future runs
920    if let Some(root) = cache_root {
921        store_findings_in_cache(root, config, contracts, all_findings, stats);
922    }
923
924    // Apply suppressions, severity overrides, strict mode, and severity filter
925    apply_suppressions(all_findings, config);
926    apply_severity_overrides(all_findings, config);
927    if let Some(min_sev) = config.severity_filter {
928        all_findings.retain(|f| f.severity >= min_sev);
929    }
930
931    contract_timings
932}
933
934/// Run all lint gates across a contract directory.
935pub fn run_lint(config: &LintConfig) -> LintReport {
936    let overall_start = Instant::now();
937    let mut gates = Vec::with_capacity(3);
938    let mut all_findings = Vec::new();
939    let mut stats = cache::CacheStats::default();
940
941    let cache_root = if config.no_cache {
942        None
943    } else {
944        Some(cache::cache_dir(config.contract_dir))
945    };
946
947    let (contracts, parse_errors) = load_contracts(config.contract_dir);
948    let binding = load_binding(config.binding_path);
949
950    // Gate 1: validate
951    let (validate_result, validate_findings) = run_validate_gate(&contracts, &parse_errors);
952    let validation_passed = validate_result.passed;
953    gates.push(validate_result);
954
955    run_gates_2_to_9(
956        config,
957        &contracts,
958        binding.as_ref(),
959        validation_passed,
960        &mut gates,
961        &mut all_findings,
962    );
963    run_ontology_gates_10_to_22(
964        config.contract_dir,
965        validation_passed,
966        &mut gates,
967        &mut all_findings,
968    );
969    run_strict_test_binding_gate_if_enabled(
970        config,
971        &contracts,
972        validation_passed,
973        &mut gates,
974        &mut all_findings,
975    );
976
977    let contract_timings = finalize_findings(
978        config,
979        &contracts,
980        binding.as_ref(),
981        validation_passed,
982        cache_root.as_deref(),
983        &mut stats,
984        &mut all_findings,
985        validate_findings,
986    );
987
988    let passed = gates.iter().all(|g| g.passed || g.skipped);
989
990    let mut report = LintReport {
991        passed,
992        gates,
993        total_duration_ms: u64::try_from(overall_start.elapsed().as_millis()).unwrap_or(u64::MAX),
994        findings: all_findings,
995        cache_stats: stats,
996        contract_timings,
997        verdict: Verdict::Unknown(crate::ontology::verdict::Reason::NotArmed),
998        armed_gates: Vec::new(),
999        not_armed: Vec::new(),
1000        armed_monotone: None,
1001        armed_shapes_monotone: None,
1002    };
1003    // The default set. `pv lint` re-arms from the corpus's `lint-baseline.json` (ONT-001 §3.9): a gate a flag
1004    // ran but the declaration does not arm is printed and excluded, like every other unarmed gate.
1005    report.arm(&ArmedGates::default_set());
1006    report
1007}
1008
1009/// ONT-2b: what `--gate <name>` answered. Only `Ran` and `Sigma(Ran)` are verdicts about the corpus.
1010pub enum NamedGateOutcome {
1011    /// `--gate` was given a name this build does not compute alone.
1012    UnknownGate,
1013    /// The `sigma` gate, which has two non-verdict answers of its own (no Σ, malformed Σ).
1014    Sigma(sigma_gate::SigmaOutcome),
1015    /// The `relations` gate (ONT-4), with three non-verdict answers (no Σ, malformed Σ, no typed relations).
1016    Relations(relations_gate::RelationsOutcome),
1017    /// The `shapes` gate (ONT-4b), with four non-verdict answers (unsupported shape, no shapes, no focus, control failed).
1018    Shapes(shapes_gate::ShapesOutcome),
1019    /// The `ont-consistency` gate (ONT-5): no Σ, malformed Σ, nothing checkable, a control that did not fire, a stale witness.
1020    Consistency(consistency_gate::ConsistencyOutcome),
1021    /// The `refines` gate (ONT-4e): no Σ, malformed Σ, a control that did not fire, a stale witness.
1022    Refines(refines_gate::RefinesOutcome),
1023    /// The `tbox` gate (ONT-2c): advisory classification. It has no Pass answer at all (R-7).
1024    Tbox(tbox_gate::TboxOutcome),
1025    /// The `evidence` gate (ONT-8), with three non-verdict answers (no Σ, malformed Σ, no evidence block).
1026    Evidence(evidence_gate::EvidenceOutcome),
1027    /// The `valid-under` gate (ONT-7), with three non-verdict answers (no Σ, malformed Σ, no kernel contract).
1028    ValidUnder(valid_under_gate::ValidUnderOutcome),
1029    /// A PVL-001 EV-11 ratchet (`theorem-pairing`, `depends-on-present`), whose one non-verdict answer is a decline.
1030    Ratchet(ratchet_gates::RatchetOutcome),
1031    /// A gate that ran and judged the corpus.
1032    Ran {
1033        result: Box<GateResult>,
1034        findings: Vec<LintFinding>,
1035    },
1036}
1037
1038/// Run ONE named gate and nothing else (ONT-001 §5 ONT-2b).
1039///
1040/// Only the gates that are meaningful ALONE are offered: `sigma` reads Σ and the corpus, `validate` parses the
1041/// corpus. The rest of `run_lint`'s gates are skipped when validation fails, so running one of them by itself would
1042/// report a verdict whose precondition nobody checked — `UnknownGate` is the honest answer, not a silent pass.
1043#[must_use]
1044pub fn run_named_gate(contract_dir: &Path, name: &str) -> NamedGateOutcome {
1045    run_named_gate_with(contract_dir, name, &shapes_gate::ShapesOptions::default())
1046}
1047
1048/// [`run_named_gate`] with the shapes gate's `--shape` / `--release-*` options (aprender#3715).
1049pub fn run_named_gate_with(
1050    contract_dir: &Path,
1051    name: &str,
1052    shapes_opts: &shapes_gate::ShapesOptions,
1053) -> NamedGateOutcome {
1054    match name {
1055        "evidence" => NamedGateOutcome::Evidence(evidence_gate::run_evidence_gate(contract_dir)),
1056        "relations" => {
1057            NamedGateOutcome::Relations(relations_gate::run_relations_gate(contract_dir))
1058        }
1059        "shapes" => {
1060            NamedGateOutcome::Shapes(shapes_gate::run_shapes_gate_with(contract_dir, shapes_opts))
1061        }
1062        "sigma" => NamedGateOutcome::Sigma(sigma_gate::run_sigma_gate(contract_dir)),
1063        consistency_gate::GATE => {
1064            NamedGateOutcome::Consistency(consistency_gate::run_consistency_gate(contract_dir))
1065        }
1066        refines_gate::GATE => {
1067            NamedGateOutcome::Refines(refines_gate::run_refines_gate(contract_dir))
1068        }
1069        bindings_gate::GATE => {
1070            NamedGateOutcome::Ratchet(bindings_gate::run_bindings_gate(contract_dir))
1071        }
1072        refinement_gate::GATE => {
1073            NamedGateOutcome::Ratchet(refinement_gate::run_refinement_gate(contract_dir))
1074        }
1075        "tbox" => NamedGateOutcome::Tbox(tbox_gate::run_tbox_gate(contract_dir)),
1076        "valid-under" => {
1077            NamedGateOutcome::ValidUnder(valid_under_gate::run_valid_under_gate(contract_dir))
1078        }
1079        "theorem-pairing" => {
1080            NamedGateOutcome::Ratchet(ratchet_gates::run_theorem_pairing_gate(contract_dir))
1081        }
1082        "depends-on-present" => {
1083            NamedGateOutcome::Ratchet(ratchet_gates::run_depends_on_present_gate(contract_dir))
1084        }
1085        "proved-is-derived" => {
1086            NamedGateOutcome::Ratchet(ratchet_gates::run_proved_is_derived_gate(contract_dir))
1087        }
1088        "validate" => {
1089            let (contracts, parse_errors) = load_contracts(contract_dir);
1090            let (result, findings) = run_validate_gate(&contracts, &parse_errors);
1091            NamedGateOutcome::Ran {
1092                result: Box::new(result),
1093                findings,
1094            }
1095        }
1096        _ => NamedGateOutcome::UnknownGate,
1097    }
1098}
1099
1100/// The gate names `--gate` computes alone, for the refusal message.
1101pub const NAMED_GATES: [&str; 14] = [
1102    bindings_gate::GATE,
1103    refinement_gate::GATE,
1104    "depends-on-present",
1105    "evidence",
1106    consistency_gate::GATE,
1107    "proved-is-derived",
1108    refines_gate::GATE,
1109    "relations",
1110    "shapes",
1111    "sigma",
1112    "tbox",
1113    "theorem-pairing",
1114    "valid-under",
1115    "validate",
1116];
1117
1118/// The EV-11 ratchet gates, by name, in the order the full run computes them.
1119type RatchetRun = fn(&Path) -> ratchet_gates::RatchetOutcome;
1120const RATCHET_GATES: [(&str, RatchetRun); 3] = [
1121    ("theorem-pairing", ratchet_gates::run_theorem_pairing_gate),
1122    (
1123        "depends-on-present",
1124        ratchet_gates::run_depends_on_present_gate,
1125    ),
1126    (
1127        "proved-is-derived",
1128        ratchet_gates::run_proved_is_derived_gate,
1129    ),
1130];
1131
1132/// An EV-11 ratchet as `run_lint` reports it. Its decline becomes a SKIPPED gate here, as sigma's non-verdict
1133/// answers do — under `--gate <name>` it is an exit of its own (2).
1134fn ratchet_result(
1135    contract_dir: &Path,
1136    validation_passed: bool,
1137    name: &str,
1138    run: RatchetRun,
1139) -> (GateResult, Vec<LintFinding>) {
1140    if !validation_passed {
1141        return (skipped_gate(name, "validation failed"), Vec::new());
1142    }
1143    match run(contract_dir) {
1144        ratchet_gates::RatchetOutcome::Ran { result, findings } => (*result, findings),
1145        ratchet_gates::RatchetOutcome::Declined(why) => (skipped_gate(name, &why), Vec::new()),
1146    }
1147}
1148
1149/// The `sigma` gate as `run_lint` reports it. Σ's two non-verdict answers become SKIPPED gates here — under
1150/// `--gate sigma` they are an exit of their own (decline / error), but inside a full run "skipped" is how the
1151/// lattice already says "not measured".
1152fn sigma_result(contract_dir: &Path, validation_passed: bool) -> (GateResult, Vec<LintFinding>) {
1153    if !validation_passed {
1154        return (skipped_gate("sigma", "validation failed"), Vec::new());
1155    }
1156    match sigma_gate::run_sigma_gate(contract_dir) {
1157        sigma_gate::SigmaOutcome::Ran { result, findings } => (*result, findings),
1158        sigma_gate::SigmaOutcome::NoSigma => (
1159            skipped_gate("sigma", "no contracts/ontology.yaml"),
1160            Vec::new(),
1161        ),
1162        sigma_gate::SigmaOutcome::Malformed(e) => (
1163            skipped_gate("sigma", &format!("Σ is malformed: {e}")),
1164            Vec::new(),
1165        ),
1166    }
1167}
1168
1169/// The `evidence` gate as `run_lint` reports it (ONT-8). Its three non-verdict answers become SKIPPED gates here, as
1170/// sigma's do — under `--gate evidence` they are exits of their own (decline / error).
1171fn evidence_result(contract_dir: &Path, validation_passed: bool) -> (GateResult, Vec<LintFinding>) {
1172    if !validation_passed {
1173        return (skipped_gate("evidence", "validation failed"), Vec::new());
1174    }
1175    match evidence_gate::run_evidence_gate(contract_dir) {
1176        evidence_gate::EvidenceOutcome::Ran { result, findings } => (*result, findings),
1177        evidence_gate::EvidenceOutcome::NoSigma => (
1178            skipped_gate("evidence", "no contracts/ontology.yaml"),
1179            Vec::new(),
1180        ),
1181        evidence_gate::EvidenceOutcome::Malformed(e) => (
1182            skipped_gate("evidence", &format!("Σ is malformed: {e}")),
1183            Vec::new(),
1184        ),
1185        evidence_gate::EvidenceOutcome::NoEvidence { contracts_checked } => (
1186            skipped_gate(
1187                "evidence",
1188                &format!(
1189                    "no evidence block in {contracts_checked} contracts — R-2: zero is a decline"
1190                ),
1191            ),
1192            Vec::new(),
1193        ),
1194    }
1195}
1196
1197/// The `valid-under` gate as `run_lint` reports it (ONT-7). Its three non-verdict answers become SKIPPED gates
1198/// here, as sigma's do — under `--gate valid-under` they are exits of their own (decline / error).
1199fn valid_under_result(
1200    contract_dir: &Path,
1201    validation_passed: bool,
1202) -> (GateResult, Vec<LintFinding>) {
1203    if !validation_passed {
1204        return (skipped_gate("valid-under", "validation failed"), Vec::new());
1205    }
1206    match valid_under_gate::run_valid_under_gate(contract_dir) {
1207        valid_under_gate::ValidUnderOutcome::Ran { result, findings } => (*result, findings),
1208        valid_under_gate::ValidUnderOutcome::NoSigma => (
1209            skipped_gate("valid-under", "no contracts/ontology.yaml"),
1210            Vec::new(),
1211        ),
1212        valid_under_gate::ValidUnderOutcome::Malformed(e) => (
1213            skipped_gate("valid-under", &format!("Σ is malformed: {e}")),
1214            Vec::new(),
1215        ),
1216        valid_under_gate::ValidUnderOutcome::NoKernels { contracts_checked } => (
1217            skipped_gate(
1218                "valid-under",
1219                &format!("no kernel-kind contract and no valid_under in {contracts_checked} contracts — R-2: zero is a decline"),
1220            ),
1221            Vec::new(),
1222        ),
1223    }
1224}
1225
1226/// The `relations` gate as `run_lint` reports it. Its three non-verdict answers become SKIPPED gates here, as
1227/// sigma's do — under `--gate relations` they are exits of their own (decline / error).
1228fn relations_result(
1229    contract_dir: &Path,
1230    validation_passed: bool,
1231) -> (GateResult, Vec<LintFinding>) {
1232    if !validation_passed {
1233        return (skipped_gate("relations", "validation failed"), Vec::new());
1234    }
1235    match relations_gate::run_relations_gate(contract_dir) {
1236        relations_gate::RelationsOutcome::Ran { result, findings } => (*result, findings),
1237        relations_gate::RelationsOutcome::NoSigma => (
1238            skipped_gate("relations", "no contracts/ontology.yaml"),
1239            Vec::new(),
1240        ),
1241        relations_gate::RelationsOutcome::Malformed(e) => (
1242            skipped_gate("relations", &format!("Σ is malformed: {e}")),
1243            Vec::new(),
1244        ),
1245        relations_gate::RelationsOutcome::NoRelations {
1246            contracts_checked,
1247            legacy_depends_on,
1248        } => (
1249            skipped_gate(
1250                "relations",
1251                &format!("no typed relations in {contracts_checked} contracts ({legacy_depends_on} legacy metadata.depends_on edges) — R-2: zero is a decline"),
1252            ),
1253            Vec::new(),
1254        ),
1255    }
1256}
1257
1258/// The `shapes` gate as `run_lint` reports it. Its four non-verdict answers become SKIPPED gates here (under
1259/// `--gate shapes` they are exits of their own); an `Unknown{Warn}` run is reported as the gate returned it.
1260fn shapes_result(contract_dir: &Path, validation_passed: bool) -> (GateResult, Vec<LintFinding>) {
1261    if !validation_passed {
1262        return (skipped_gate("shapes", "validation failed"), Vec::new());
1263    }
1264    match shapes_gate::run_shapes_gate(contract_dir) {
1265        shapes_gate::ShapesOutcome::Ran { result, findings } => (*result, findings),
1266        shapes_gate::ShapesOutcome::Unsupported(e) => (skipped_gate("shapes", &format!("{e}")), Vec::new()),
1267        shapes_gate::ShapesOutcome::ExtractFailed(e) => (skipped_gate("shapes", &format!("{e}")), Vec::new()),
1268        shapes_gate::ShapesOutcome::NoShapes { contracts_checked } => (
1269            skipped_gate("shapes", &format!("no `shape:` block in {contracts_checked} contracts — R-2: zero is a decline")),
1270            Vec::new(),
1271        ),
1272        shapes_gate::ShapesOutcome::NoFocus { shapes_n } => (
1273            skipped_gate("shapes", &format!("{shapes_n} shape(s), no focus node")),
1274            Vec::new(),
1275        ),
1276        shapes_gate::ShapesOutcome::WrongCorpus { shapes_n, expected, found, refused } => (
1277            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("; ")) })),
1278            Vec::new(),
1279        ),
1280        shapes_gate::ShapesOutcome::HarnessBroken { causes } => (
1281            skipped_gate("shapes", &format!("the CRUX harness measured itself, not apr: {}", causes.join("; "))),
1282            Vec::new(),
1283        ),
1284        shapes_gate::ShapesOutcome::NoReceipts { shapes_n, dir } => (
1285            skipped_gate("shapes", &format!("{shapes_n} shape(s) resolve receipts and the tree holds none under {dir}/ — R-2: unmeasured is a decline")),
1286            Vec::new(),
1287        ),
1288        shapes_gate::ShapesOutcome::EmptyDomain { shapes_n } => (
1289            skipped_gate("shapes", &format!("capability-cells: the required-cell domain D is empty ({shapes_n} shape(s)) — R-2: zero is a decline, never a pass")),
1290            Vec::new(),
1291        ),
1292        shapes_gate::ShapesOutcome::PositiveControlFailed { shapes_n, focus_nodes_n, which } => (
1293            skipped_gate("shapes", &format!("positive control {which} did not fire ({shapes_n} shape(s), {focus_nodes_n} focus node(s)) — the gate cannot reject")),
1294            Vec::new(),
1295        ),
1296        shapes_gate::ShapesOutcome::Differential { shapes_n, focus_nodes_n, passed, n, failed } => {
1297            let mut g = skipped_gate(
1298                "shapes",
1299                &format!(
1300                    "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",
1301                    failed.join(" | ")
1302                ),
1303            );
1304            g.verdict = Verdict::Unknown(crate::ontology::verdict::Reason::Differential);
1305            (g, Vec::new())
1306        }
1307    }
1308}
1309
1310/// The `ont-consistency` gate as `run_lint` reports it. Its declines keep their lattice reason here (a stale witness
1311/// is `Unknown{WitnessStale}`, not a bare skip), so the meet says what could not be checked.
1312fn consistency_result(
1313    contract_dir: &Path,
1314    validation_passed: bool,
1315) -> (GateResult, Vec<LintFinding>) {
1316    if !validation_passed {
1317        return (
1318            skipped_gate(consistency_gate::GATE, "validation failed"),
1319            Vec::new(),
1320        );
1321    }
1322    let outcome = consistency_gate::run_consistency_gate(contract_dir);
1323    if let consistency_gate::ConsistencyOutcome::Ran { result, findings } = outcome {
1324        return (*result, findings);
1325    }
1326    let mut g = skipped_gate(consistency_gate::GATE, &consistency_gate::why(&outcome));
1327    if let Some(reason) = consistency_gate::decline_reason(&outcome) {
1328        g.verdict = Verdict::Unknown(reason);
1329    }
1330    (g, Vec::new())
1331}
1332
1333/// The `refines` gate as `run_lint` reports it; declines keep their lattice reason, as `ont-consistency`'s do.
1334fn refines_result(contract_dir: &Path, validation_passed: bool) -> (GateResult, Vec<LintFinding>) {
1335    if !validation_passed {
1336        return (
1337            skipped_gate(refines_gate::GATE, "validation failed"),
1338            Vec::new(),
1339        );
1340    }
1341    let outcome = refines_gate::run_refines_gate(contract_dir);
1342    if let refines_gate::RefinesOutcome::Ran { result, findings } = outcome {
1343        return (*result, findings);
1344    }
1345    let mut g = skipped_gate(refines_gate::GATE, &refines_gate::why(&outcome));
1346    if let Some(reason) = refines_gate::decline_reason(&outcome) {
1347        g.verdict = Verdict::Unknown(reason);
1348    }
1349    (g, Vec::new())
1350}
1351
1352/// The `challenge-fresh` gate: `pv challenge check` as a gate. No Lean theorem base, a tree that does not load, or
1353/// zero challenges is SKIPPED (not measured), never green.
1354fn challenge_result(contract_dir: &Path) -> GateResult {
1355    const NAME: &str = "challenge-fresh";
1356    let start = Instant::now();
1357    let root = crate::ontology::extract::repo_root(contract_dir);
1358    let Some(lean) = crate::ontology::extract::lean::base_under(&root) else {
1359        return skipped_gate(NAME, "no Lean theorem base under the repo root");
1360    };
1361    let r = match crate::discharge::challenge::render(&lean, contract_dir) {
1362        Ok(r) => r,
1363        Err(e) => return skipped_gate(NAME, &format!("the Lean tree does not load: {e}")),
1364    };
1365    if r.files.is_empty() && r.unrestated.is_empty() {
1366        return skipped_gate(
1367            NAME,
1368            "zero challenges: no contract binds a theorem — R-2: zero is a decline",
1369        );
1370    }
1371    let stale = crate::discharge::challenge::diff(&lean, &r);
1372    let unrestated: Vec<String> = r
1373        .unrestated
1374        .iter()
1375        .map(|(c, fqn, why)| format!("{c}: {fqn}: {why}"))
1376        .collect();
1377    let passed = stale.is_empty() && unrestated.is_empty();
1378    GateResult {
1379        name: NAME.into(),
1380        passed,
1381        skipped: false,
1382        verdict: Verdict::from_gate(passed, false),
1383        duration_ms: u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
1384        // `GateDetail` is FROZEN at the 0.3.1 variants: the shape is borrowed, the payload rides in `GateExtra`.
1385        detail: GateDetail::Validate {
1386            contracts: r.files.len(),
1387            errors: stale.len() + unrestated.len(),
1388            warnings: 0,
1389            error_messages: stale.iter().chain(&unrestated).cloned().collect(),
1390        },
1391        extra: Some(GateExtra::ChallengeFresh {
1392            lean_dir: lean.display().to_string(),
1393            files: r.files.len(),
1394            stale,
1395            unrestated,
1396        }),
1397    }
1398}
1399
1400fn skipped_gate(name: &str, reason: &str) -> GateResult {
1401    GateResult {
1402        name: name.into(),
1403        passed: false,
1404        skipped: true,
1405        verdict: Verdict::from_gate(false, true),
1406        duration_ms: 0,
1407        detail: GateDetail::Skipped {
1408            reason: reason.into(),
1409        },
1410        extra: None,
1411    }
1412}
1413
1414fn apply_suppressions(findings: &mut [LintFinding], config: &LintConfig) {
1415    for f in findings.iter_mut() {
1416        if config.suppressed_rules.iter().any(|r| r == &f.rule_id) {
1417            f.suppressed = true;
1418            f.suppression_reason = Some("Suppressed by --suppress-rule".into());
1419        }
1420        if let Some(ref stem) = f.contract_stem {
1421            if config.suppressed_findings.iter().any(|s| s == stem) {
1422                f.suppressed = true;
1423                f.suppression_reason = Some("Suppressed by --suppress".into());
1424            }
1425        }
1426        if config.suppressed_files.iter().any(|p| f.file.contains(p)) {
1427            f.suppressed = true;
1428            f.suppression_reason = Some("Suppressed by --suppress-file".into());
1429        }
1430    }
1431}
1432
1433/// Resolve the `.pv/` state directory relative to the contract directory's parent.
1434fn pv_state_dir(contract_dir: &Path) -> std::path::PathBuf {
1435    contract_dir.parent().unwrap_or(contract_dir).join(".pv")
1436}
1437
1438/// Load previous fingerprints, compare with current findings, mark new ones,
1439/// and persist the current fingerprint set for the next run.
1440fn mark_new_findings(findings: &mut [LintFinding], contract_dir: &Path) {
1441    let state_dir = pv_state_dir(contract_dir);
1442    let previous_path = state_dir.join("lint-previous.json");
1443
1444    // Load previous fingerprints (empty set if file missing or unreadable)
1445    let previous: HashSet<String> = std::fs::read_to_string(&previous_path)
1446        .ok()
1447        .and_then(|s| serde_json::from_str(&s).ok())
1448        .unwrap_or_default();
1449
1450    // Compute current fingerprints and mark new findings
1451    let mut current = HashSet::new();
1452    for f in findings.iter_mut() {
1453        let fp = f.fingerprint();
1454        if !previous.contains(&fp) {
1455            f.is_new = true;
1456        }
1457        current.insert(fp);
1458    }
1459
1460    // Persist current fingerprints for the next run
1461    if let Err(e) = std::fs::create_dir_all(&state_dir) {
1462        eprintln!("pv lint: cannot create {}: {e}", state_dir.display());
1463        return;
1464    }
1465    if let Ok(json) = serde_json::to_string(&current) {
1466        let _ = std::fs::write(&previous_path, json);
1467    }
1468}
1469
1470fn apply_severity_overrides(findings: &mut [LintFinding], config: &LintConfig) {
1471    for f in findings.iter_mut() {
1472        if let Some(&sev) = config.severity_overrides.get(&f.rule_id) {
1473            f.severity = sev;
1474        }
1475    }
1476    if config.strict {
1477        for f in findings.iter_mut() {
1478            if f.severity == RuleSeverity::Warning {
1479                f.severity = RuleSeverity::Error;
1480            }
1481        }
1482    }
1483}
1484
1485#[cfg(test)]
1486#[path = "mod_tests.rs"]
1487mod tests;