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    /// Run only gate 1 (validate, the precondition) and the strict test-binding gate; gates 2-22 do not
575    /// run and nothing is cached (aprender#4974). Every armed gate that did not run meets as `NotRun`,
576    /// so the report can never read as a full-lint pass. Requires `strict_test_binding`.
577    pub strict_test_binding_only: bool,
578}
579
580impl<'a> LintConfig<'a> {
581    /// Create a basic config (backward compatible).
582    pub fn new(contract_dir: &'a Path, binding_path: Option<&'a Path>, min_score: f64) -> Self {
583        Self {
584            contract_dir,
585            binding_path,
586            min_score,
587            severity_filter: None,
588            severity_overrides: HashMap::new(),
589            suppressed_findings: Vec::new(),
590            suppressed_rules: Vec::new(),
591            suppressed_files: Vec::new(),
592            strict: false,
593            no_cache: false,
594            cache_stats: false,
595            crate_dir: None,
596            min_level: None,
597            strict_test_binding: false,
598            strict_test_binding_only: false,
599        }
600    }
601}
602
603/// Run one gate and record it, or record it as skipped when gate 1 (validate) failed.
604///
605/// Every gate after validate repeats the same `if validation_passed { run } else
606/// { skipped_gate(name, "validation failed") }` shape; this is that shape, once.
607fn push_gate<F>(
608    gates: &mut Vec<GateResult>,
609    all_findings: &mut Vec<LintFinding>,
610    run: F,
611    name: &str,
612    validation_passed: bool,
613) where
614    F: FnOnce() -> (GateResult, Vec<LintFinding>),
615{
616    let (result, mut findings) = if validation_passed {
617        run()
618    } else {
619        (skipped_gate(name, "validation failed"), Vec::new())
620    };
621    gates.push(result);
622    all_findings.append(&mut findings);
623}
624
625/// Per-contract timing: measure how long each contract's findings take to process,
626/// sorted by duration descending.
627fn per_contract_timings(
628    contracts: &[(String, crate::schema::Contract)],
629    binding: Option<&crate::binding::BindingRegistry>,
630) -> Vec<(String, u64)> {
631    let mut contract_timings: Vec<(String, u64)> = Vec::with_capacity(contracts.len());
632    for (stem, contract) in contracts {
633        let ct_start = Instant::now();
634        // Validate
635        let _ = crate::schema::validate_contract(contract);
636        // Audit
637        let _ = crate::audit::audit_contract(contract);
638        // Score
639        let _ = crate::scoring::score_contract(contract, binding, stem);
640        let ct_ms = u64::try_from(ct_start.elapsed().as_micros() / 1000).unwrap_or(0);
641        contract_timings.push((format!("{stem}.yaml"), ct_ms));
642    }
643    // Sort by duration descending
644    contract_timings.sort_by_key(|b| std::cmp::Reverse(b.1));
645    contract_timings
646}
647
648/// Cache: store findings per-contract for future runs.
649fn store_findings_in_cache(
650    root: &std::path::Path,
651    config: &LintConfig,
652    contracts: &[(String, crate::schema::Contract)],
653    all_findings: &[LintFinding],
654    stats: &mut cache::CacheStats,
655) {
656    let rule_cfg = format!("{:?}{:?}", config.severity_overrides, config.strict);
657    for (stem, _) in contracts {
658        stats.total += 1;
659        let yaml_path = config.contract_dir.join(format!("{stem}.yaml"));
660        let yaml_content = std::fs::read_to_string(&yaml_path).unwrap_or_default();
661        let hash = cache::content_hash(&yaml_content, &rule_cfg);
662        if cache::cache_get(root, &hash).is_some() {
663            stats.hits += 1;
664        } else {
665            stats.misses += 1;
666            let contract_findings: Vec<_> = all_findings
667                .iter()
668                .filter(|f| f.contract_stem.as_deref() == Some(stem.as_str()))
669                .cloned()
670                .collect();
671            let _ = cache::cache_put(root, &hash, &contract_findings);
672        }
673    }
674}
675
676/// Gates 2-9: audit, score, verify, enforce, enforcement-level, reverse-coverage, duplicate-stems, composition.
677/// Each is skipped (not run) when `validation_passed` is false, per `push_gate`'s contract.
678fn run_gates_2_to_9(
679    config: &LintConfig,
680    contracts: &[(String, crate::schema::Contract)],
681    binding: Option<&crate::binding::BindingRegistry>,
682    validation_passed: bool,
683    gates: &mut Vec<GateResult>,
684    all_findings: &mut Vec<LintFinding>,
685) {
686    // Gate 2: audit (skip if validation failed)
687    push_gate(
688        gates,
689        all_findings,
690        || run_audit_gate(contracts),
691        "audit",
692        validation_passed,
693    );
694
695    // Gate 3: score (skip if validation failed)
696    push_gate(
697        gates,
698        all_findings,
699        || run_score_gate(contracts, binding, config.min_score),
700        "score",
701        validation_passed,
702    );
703
704    // Gate 4: verify (source code fulfillment)
705    push_gate(
706        gates,
707        all_findings,
708        || {
709            let project_root = config.contract_dir.parent().unwrap_or(config.contract_dir);
710            run_verify_gate(contracts, project_root)
711        },
712        "verify",
713        validation_passed,
714    );
715
716    // Gate 5: enforce (equations must have preconditions/postconditions)
717    push_gate(
718        gates,
719        all_findings,
720        || run_enforce_gate(contracts),
721        "enforce",
722        validation_passed,
723    );
724
725    // Gate 6: enforcement level (Section 17, Gap 1 + Gap 5 level lock)
726    push_gate(
727        gates,
728        all_findings,
729        || {
730            let min_level = config
731                .min_level
732                .unwrap_or(crate::schema::EnforcementLevel::Standard);
733            run_enforcement_level_gate(contracts, min_level)
734        },
735        "enforcement-level",
736        validation_passed,
737    );
738
739    // Gate 7: reverse coverage (optional — skip if no binding or crate dir)
740    push_gate(
741        gates,
742        all_findings,
743        || match (config.binding_path, config.crate_dir) {
744            (Some(bp), Some(cd)) => run_reverse_coverage_gate(bp, cd),
745            _ => (
746                skipped_gate("reverse-coverage", "no --binding or --crate-dir provided"),
747                Vec::new(),
748            ),
749        },
750        "reverse-coverage",
751        validation_passed,
752    );
753
754    // Gate 8: duplicate stems (PV-DUP-001). Must run BEFORE composition — it tells
755    // the composition gate which stems are unresolvable, which is the difference
756    // between a defined verdict and one decided by `read_dir` order.
757    let duplicates = duplicate_stems::scan_duplicate_stems(config.contract_dir);
758    let ambiguous = duplicate_stems::ambiguous_stems(&duplicates);
759    push_gate(
760        gates,
761        all_findings,
762        || {
763            let project_root = config.contract_dir.parent().unwrap_or(config.contract_dir);
764            let baseline = duplicate_stems::read_baseline(project_root);
765            duplicate_stems::run_duplicate_stem_gate(&duplicates, &baseline)
766        },
767        "duplicate-stems",
768        validation_passed,
769    );
770
771    // Gate 9: composition (assumes/guarantees chain verification)
772    push_gate(
773        gates,
774        all_findings,
775        || composition_gate::run_composition_gate(contracts, &ambiguous),
776        "composition",
777        validation_passed,
778    );
779}
780
781/// Gates 10-22: the ONT-* ontology gates. Same R-8 shape throughout — each is computed on every run and armed
782/// per repo, never skipped by `validation_passed` the way gates 2-9 are.
783fn run_ontology_gates_10_to_22(
784    contract_dir: &Path,
785    validation_passed: bool,
786    gates: &mut Vec<GateResult>,
787    all_findings: &mut Vec<LintFinding>,
788) {
789    // Gate 10: sigma (ONT-2b). R-8: a new gate is COMPUTED everywhere and armed per repo — so it runs here as
790    // well as under `--gate sigma`, or `armed_gates` could name a gate no run ever computes.
791    let (sigma_gate_result, mut sigma_findings) = sigma_result(contract_dir, validation_passed);
792    gates.push(sigma_gate_result);
793    all_findings.append(&mut sigma_findings);
794
795    // Gate 11: relations (ONT-4). Same R-8 shape as sigma: computed in every run, armed per repo.
796    let (relations_gate_result, mut relations_findings) =
797        relations_result(contract_dir, validation_passed);
798    gates.push(relations_gate_result);
799    all_findings.append(&mut relations_findings);
800
801    // Gate 12: shapes (ONT-4b). Same R-8 shape: computed in every run, armed per repo.
802    let (shapes_gate_result, mut shapes_findings) = shapes_result(contract_dir, validation_passed);
803    gates.push(shapes_gate_result);
804    all_findings.append(&mut shapes_findings);
805
806    // Gate 13: valid-under (ONT-7). Same R-8 shape: computed in every run, armed per repo.
807    let (valid_under_gate_result, mut valid_under_findings) =
808        valid_under_result(contract_dir, validation_passed);
809    gates.push(valid_under_gate_result);
810    all_findings.append(&mut valid_under_findings);
811
812    // Gates 14, 15, 16: theorem-pairing, depends-on-present (PVL-001 EV-11), proved-is-derived (EV-8a). Same R-8
813    // shape: computed in every run, armed per repo.
814    for (name, run) in RATCHET_GATES {
815        let (result, mut findings) = ratchet_result(contract_dir, validation_passed, name, run);
816        gates.push(result);
817        all_findings.append(&mut findings);
818    }
819
820    // Gate 17: challenge-fresh (PVL-001 EV-7a). Same R-8 shape: computed wherever a Lean theorem base exists,
821    // armed per repo (only through `make ont-ratchet`).
822    gates.push(challenge_result(contract_dir));
823
824    // Gate 18 (numbered by agreement: ONT-7 13, EV-11 14-16, EV-7a 17): ont-consistency (ONT-5). Same R-8 shape:
825    // computed in every run, armed per repo.
826    let (consistency_gate_result, mut consistency_findings) =
827        consistency_result(contract_dir, validation_passed);
828    gates.push(consistency_gate_result);
829    all_findings.append(&mut consistency_findings);
830
831    // Gate 19: refines (ONT-4e, R-20). Same R-8 shape: computed in every run, armed per repo.
832    let (refines_gate_result, mut refines_findings) =
833        refines_result(contract_dir, validation_passed);
834    gates.push(refines_gate_result);
835    all_findings.append(&mut refines_findings);
836
837    // Gate 20: bindings (ONT-3a). Same R-8 shape: computed in every run, armed per repo.
838    let (bindings_gate_result, mut bindings_findings) = ratchet_result(
839        contract_dir,
840        validation_passed,
841        bindings_gate::GATE,
842        bindings_gate::run_bindings_gate,
843    );
844    gates.push(bindings_gate_result);
845    all_findings.append(&mut bindings_findings);
846
847    // Gate 21: refinement (ONT-3b). Same R-8 shape.
848    let (refinement_gate_result, mut refinement_findings) = ratchet_result(
849        contract_dir,
850        validation_passed,
851        refinement_gate::GATE,
852        refinement_gate::run_refinement_gate,
853    );
854    gates.push(refinement_gate_result);
855    all_findings.append(&mut refinement_findings);
856
857    // Gate 22: evidence (ONT-8). Same R-8 shape: computed in every run, armed per repo.
858    let (evidence_gate_result, mut evidence_findings) =
859        evidence_result(contract_dir, validation_passed);
860    gates.push(evidence_gate_result);
861    all_findings.append(&mut evidence_findings);
862}
863
864/// Gate 9 (again, by original numbering): strict test-binding (Issue #1510), opt-in via `--strict-test-binding`.
865fn run_strict_test_binding_gate_if_enabled(
866    config: &LintConfig,
867    contracts: &[(String, crate::schema::Contract)],
868    validation_passed: bool,
869    gates: &mut Vec<GateResult>,
870    all_findings: &mut Vec<LintFinding>,
871) {
872    if !config.strict_test_binding {
873        return;
874    }
875    push_gate(
876        gates,
877        all_findings,
878        || {
879            let project_root = config.contract_dir.parent().unwrap_or(config.contract_dir);
880            strict_test_binding::run_strict_test_binding_gate(
881                contracts,
882                project_root,
883                config.strict,
884            )
885        },
886        "strict-test-binding",
887        validation_passed,
888    );
889}
890
891/// Post-gate passes: fold in the validate gate's own findings, time contracts, detect stale suppressions, mark
892/// new-vs-pre-existing findings, cache them, then apply suppressions/overrides/severity filter.
893#[allow(clippy::too_many_arguments)]
894fn finalize_findings(
895    config: &LintConfig,
896    contracts: &[(String, crate::schema::Contract)],
897    binding: Option<&crate::binding::BindingRegistry>,
898    validation_passed: bool,
899    cache_root: Option<&std::path::Path>,
900    stats: &mut cache::CacheStats,
901    all_findings: &mut Vec<LintFinding>,
902    mut validate_findings: Vec<LintFinding>,
903) -> Vec<(String, u64)> {
904    all_findings.append(&mut validate_findings);
905
906    // Per-contract timing: measure how long each contract's findings take to process
907    let contract_timings = if validation_passed {
908        per_contract_timings(contracts, binding)
909    } else {
910        Vec::new()
911    };
912
913    // Stale suppression detection (PV-SUP-001, Section 17 Gap 2)
914    let mut stale_findings = check_stale_suppressions(
915        all_findings,
916        &config.suppressed_rules,
917        &config.suppressed_findings,
918    );
919    all_findings.append(&mut stale_findings);
920
921    // Issue lifecycle: mark each finding as new or pre-existing
922    mark_new_findings(all_findings, config.contract_dir);
923
924    // Cache: store findings per-contract for future runs
925    if let Some(root) = cache_root {
926        store_findings_in_cache(root, config, contracts, all_findings, stats);
927    }
928
929    // Apply suppressions, severity overrides, strict mode, and severity filter
930    apply_suppressions(all_findings, config);
931    apply_severity_overrides(all_findings, config);
932    if let Some(min_sev) = config.severity_filter {
933        all_findings.retain(|f| f.severity >= min_sev);
934    }
935
936    contract_timings
937}
938
939/// Run all lint gates across a contract directory.
940pub fn run_lint(config: &LintConfig) -> LintReport {
941    let overall_start = Instant::now();
942    let mut gates = Vec::with_capacity(3);
943    let mut all_findings = Vec::new();
944    let mut stats = cache::CacheStats::default();
945
946    // A scoped run's findings are a subset; caching them would store a partial answer per contract.
947    let cache_root = if config.no_cache || config.strict_test_binding_only {
948        None
949    } else {
950        Some(cache::cache_dir(config.contract_dir))
951    };
952
953    let (contracts, parse_errors) = load_contracts(config.contract_dir);
954    let binding = load_binding(config.binding_path);
955
956    // Gate 1: validate
957    let (validate_result, validate_findings) = run_validate_gate(&contracts, &parse_errors);
958    let validation_passed = validate_result.passed;
959    gates.push(validate_result);
960
961    if !config.strict_test_binding_only {
962        run_gates_2_to_9(
963            config,
964            &contracts,
965            binding.as_ref(),
966            validation_passed,
967            &mut gates,
968            &mut all_findings,
969        );
970        run_ontology_gates_10_to_22(
971            config.contract_dir,
972            validation_passed,
973            &mut gates,
974            &mut all_findings,
975        );
976    }
977    run_strict_test_binding_gate_if_enabled(
978        config,
979        &contracts,
980        validation_passed,
981        &mut gates,
982        &mut all_findings,
983    );
984
985    let contract_timings = finalize_findings(
986        config,
987        &contracts,
988        binding.as_ref(),
989        validation_passed,
990        cache_root.as_deref(),
991        &mut stats,
992        &mut all_findings,
993        validate_findings,
994    );
995
996    let passed = gates.iter().all(|g| g.passed || g.skipped);
997
998    let mut report = LintReport {
999        passed,
1000        gates,
1001        total_duration_ms: u64::try_from(overall_start.elapsed().as_millis()).unwrap_or(u64::MAX),
1002        findings: all_findings,
1003        cache_stats: stats,
1004        contract_timings,
1005        verdict: Verdict::Unknown(crate::ontology::verdict::Reason::NotArmed),
1006        armed_gates: Vec::new(),
1007        not_armed: Vec::new(),
1008        armed_monotone: None,
1009        armed_shapes_monotone: None,
1010    };
1011    // The default set. `pv lint` re-arms from the corpus's `lint-baseline.json` (ONT-001 §3.9): a gate a flag
1012    // ran but the declaration does not arm is printed and excluded, like every other unarmed gate.
1013    report.arm(&ArmedGates::default_set());
1014    report
1015}
1016
1017/// ONT-2b: what `--gate <name>` answered. Only `Ran` and `Sigma(Ran)` are verdicts about the corpus.
1018pub enum NamedGateOutcome {
1019    /// `--gate` was given a name this build does not compute alone.
1020    UnknownGate,
1021    /// The `sigma` gate, which has two non-verdict answers of its own (no Σ, malformed Σ).
1022    Sigma(sigma_gate::SigmaOutcome),
1023    /// The `relations` gate (ONT-4), with three non-verdict answers (no Σ, malformed Σ, no typed relations).
1024    Relations(relations_gate::RelationsOutcome),
1025    /// The `shapes` gate (ONT-4b), with four non-verdict answers (unsupported shape, no shapes, no focus, control failed).
1026    Shapes(shapes_gate::ShapesOutcome),
1027    /// The `ont-consistency` gate (ONT-5): no Σ, malformed Σ, nothing checkable, a control that did not fire, a stale witness.
1028    Consistency(consistency_gate::ConsistencyOutcome),
1029    /// The `refines` gate (ONT-4e): no Σ, malformed Σ, a control that did not fire, a stale witness.
1030    Refines(refines_gate::RefinesOutcome),
1031    /// The `tbox` gate (ONT-2c): advisory classification. It has no Pass answer at all (R-7).
1032    Tbox(tbox_gate::TboxOutcome),
1033    /// The `evidence` gate (ONT-8), with three non-verdict answers (no Σ, malformed Σ, no evidence block).
1034    Evidence(evidence_gate::EvidenceOutcome),
1035    /// The `valid-under` gate (ONT-7), with three non-verdict answers (no Σ, malformed Σ, no kernel contract).
1036    ValidUnder(valid_under_gate::ValidUnderOutcome),
1037    /// A PVL-001 EV-11 ratchet (`theorem-pairing`, `depends-on-present`), whose one non-verdict answer is a decline.
1038    Ratchet(ratchet_gates::RatchetOutcome),
1039    /// A gate that ran and judged the corpus.
1040    Ran {
1041        result: Box<GateResult>,
1042        findings: Vec<LintFinding>,
1043    },
1044}
1045
1046/// Run ONE named gate and nothing else (ONT-001 §5 ONT-2b).
1047///
1048/// Only the gates that are meaningful ALONE are offered: `sigma` reads Σ and the corpus, `validate` parses the
1049/// corpus. The rest of `run_lint`'s gates are skipped when validation fails, so running one of them by itself would
1050/// report a verdict whose precondition nobody checked — `UnknownGate` is the honest answer, not a silent pass.
1051#[must_use]
1052pub fn run_named_gate(contract_dir: &Path, name: &str) -> NamedGateOutcome {
1053    run_named_gate_with(contract_dir, name, &shapes_gate::ShapesOptions::default())
1054}
1055
1056/// [`run_named_gate`] with the shapes gate's `--shape` / `--release-*` options (aprender#3715).
1057pub fn run_named_gate_with(
1058    contract_dir: &Path,
1059    name: &str,
1060    shapes_opts: &shapes_gate::ShapesOptions,
1061) -> NamedGateOutcome {
1062    match name {
1063        "evidence" => NamedGateOutcome::Evidence(evidence_gate::run_evidence_gate(contract_dir)),
1064        "relations" => {
1065            NamedGateOutcome::Relations(relations_gate::run_relations_gate(contract_dir))
1066        }
1067        "shapes" => {
1068            NamedGateOutcome::Shapes(shapes_gate::run_shapes_gate_with(contract_dir, shapes_opts))
1069        }
1070        "sigma" => NamedGateOutcome::Sigma(sigma_gate::run_sigma_gate(contract_dir)),
1071        consistency_gate::GATE => {
1072            NamedGateOutcome::Consistency(consistency_gate::run_consistency_gate(contract_dir))
1073        }
1074        refines_gate::GATE => {
1075            NamedGateOutcome::Refines(refines_gate::run_refines_gate(contract_dir))
1076        }
1077        bindings_gate::GATE => {
1078            NamedGateOutcome::Ratchet(bindings_gate::run_bindings_gate(contract_dir))
1079        }
1080        refinement_gate::GATE => {
1081            NamedGateOutcome::Ratchet(refinement_gate::run_refinement_gate(contract_dir))
1082        }
1083        "tbox" => NamedGateOutcome::Tbox(tbox_gate::run_tbox_gate(contract_dir)),
1084        "valid-under" => {
1085            NamedGateOutcome::ValidUnder(valid_under_gate::run_valid_under_gate(contract_dir))
1086        }
1087        "theorem-pairing" => {
1088            NamedGateOutcome::Ratchet(ratchet_gates::run_theorem_pairing_gate(contract_dir))
1089        }
1090        "depends-on-present" => {
1091            NamedGateOutcome::Ratchet(ratchet_gates::run_depends_on_present_gate(contract_dir))
1092        }
1093        "proved-is-derived" => {
1094            NamedGateOutcome::Ratchet(ratchet_gates::run_proved_is_derived_gate(contract_dir))
1095        }
1096        "validate" => {
1097            let (contracts, parse_errors) = load_contracts(contract_dir);
1098            let (result, findings) = run_validate_gate(&contracts, &parse_errors);
1099            NamedGateOutcome::Ran {
1100                result: Box::new(result),
1101                findings,
1102            }
1103        }
1104        _ => NamedGateOutcome::UnknownGate,
1105    }
1106}
1107
1108/// The gate names `--gate` computes alone, for the refusal message.
1109pub const NAMED_GATES: [&str; 14] = [
1110    bindings_gate::GATE,
1111    refinement_gate::GATE,
1112    "depends-on-present",
1113    "evidence",
1114    consistency_gate::GATE,
1115    "proved-is-derived",
1116    refines_gate::GATE,
1117    "relations",
1118    "shapes",
1119    "sigma",
1120    "tbox",
1121    "theorem-pairing",
1122    "valid-under",
1123    "validate",
1124];
1125
1126/// The EV-11 ratchet gates, by name, in the order the full run computes them.
1127type RatchetRun = fn(&Path) -> ratchet_gates::RatchetOutcome;
1128const RATCHET_GATES: [(&str, RatchetRun); 3] = [
1129    ("theorem-pairing", ratchet_gates::run_theorem_pairing_gate),
1130    (
1131        "depends-on-present",
1132        ratchet_gates::run_depends_on_present_gate,
1133    ),
1134    (
1135        "proved-is-derived",
1136        ratchet_gates::run_proved_is_derived_gate,
1137    ),
1138];
1139
1140/// An EV-11 ratchet as `run_lint` reports it. Its decline becomes a SKIPPED gate here, as sigma's non-verdict
1141/// answers do — under `--gate <name>` it is an exit of its own (2).
1142fn ratchet_result(
1143    contract_dir: &Path,
1144    validation_passed: bool,
1145    name: &str,
1146    run: RatchetRun,
1147) -> (GateResult, Vec<LintFinding>) {
1148    if !validation_passed {
1149        return (skipped_gate(name, "validation failed"), Vec::new());
1150    }
1151    match run(contract_dir) {
1152        ratchet_gates::RatchetOutcome::Ran { result, findings } => (*result, findings),
1153        ratchet_gates::RatchetOutcome::Declined(why) => (skipped_gate(name, &why), Vec::new()),
1154    }
1155}
1156
1157/// The `sigma` gate as `run_lint` reports it. Σ's two non-verdict answers become SKIPPED gates here — under
1158/// `--gate sigma` they are an exit of their own (decline / error), but inside a full run "skipped" is how the
1159/// lattice already says "not measured".
1160fn sigma_result(contract_dir: &Path, validation_passed: bool) -> (GateResult, Vec<LintFinding>) {
1161    if !validation_passed {
1162        return (skipped_gate("sigma", "validation failed"), Vec::new());
1163    }
1164    match sigma_gate::run_sigma_gate(contract_dir) {
1165        sigma_gate::SigmaOutcome::Ran { result, findings } => (*result, findings),
1166        sigma_gate::SigmaOutcome::NoSigma => (
1167            skipped_gate("sigma", "no contracts/ontology.yaml"),
1168            Vec::new(),
1169        ),
1170        sigma_gate::SigmaOutcome::Malformed(e) => (
1171            skipped_gate("sigma", &format!("Σ is malformed: {e}")),
1172            Vec::new(),
1173        ),
1174    }
1175}
1176
1177/// The `evidence` gate as `run_lint` reports it (ONT-8). Its three non-verdict answers become SKIPPED gates here, as
1178/// sigma's do — under `--gate evidence` they are exits of their own (decline / error).
1179fn evidence_result(contract_dir: &Path, validation_passed: bool) -> (GateResult, Vec<LintFinding>) {
1180    if !validation_passed {
1181        return (skipped_gate("evidence", "validation failed"), Vec::new());
1182    }
1183    match evidence_gate::run_evidence_gate(contract_dir) {
1184        evidence_gate::EvidenceOutcome::Ran { result, findings } => (*result, findings),
1185        evidence_gate::EvidenceOutcome::NoSigma => (
1186            skipped_gate("evidence", "no contracts/ontology.yaml"),
1187            Vec::new(),
1188        ),
1189        evidence_gate::EvidenceOutcome::Malformed(e) => (
1190            skipped_gate("evidence", &format!("Σ is malformed: {e}")),
1191            Vec::new(),
1192        ),
1193        evidence_gate::EvidenceOutcome::NoEvidence { contracts_checked } => (
1194            skipped_gate(
1195                "evidence",
1196                &format!(
1197                    "no evidence block in {contracts_checked} contracts — R-2: zero is a decline"
1198                ),
1199            ),
1200            Vec::new(),
1201        ),
1202    }
1203}
1204
1205/// The `valid-under` gate as `run_lint` reports it (ONT-7). Its three non-verdict answers become SKIPPED gates
1206/// here, as sigma's do — under `--gate valid-under` they are exits of their own (decline / error).
1207fn valid_under_result(
1208    contract_dir: &Path,
1209    validation_passed: bool,
1210) -> (GateResult, Vec<LintFinding>) {
1211    if !validation_passed {
1212        return (skipped_gate("valid-under", "validation failed"), Vec::new());
1213    }
1214    match valid_under_gate::run_valid_under_gate(contract_dir) {
1215        valid_under_gate::ValidUnderOutcome::Ran { result, findings } => (*result, findings),
1216        valid_under_gate::ValidUnderOutcome::NoSigma => (
1217            skipped_gate("valid-under", "no contracts/ontology.yaml"),
1218            Vec::new(),
1219        ),
1220        valid_under_gate::ValidUnderOutcome::Malformed(e) => (
1221            skipped_gate("valid-under", &format!("Σ is malformed: {e}")),
1222            Vec::new(),
1223        ),
1224        valid_under_gate::ValidUnderOutcome::NoKernels { contracts_checked } => (
1225            skipped_gate(
1226                "valid-under",
1227                &format!("no kernel-kind contract and no valid_under in {contracts_checked} contracts — R-2: zero is a decline"),
1228            ),
1229            Vec::new(),
1230        ),
1231    }
1232}
1233
1234/// The `relations` gate as `run_lint` reports it. Its three non-verdict answers become SKIPPED gates here, as
1235/// sigma's do — under `--gate relations` they are exits of their own (decline / error).
1236fn relations_result(
1237    contract_dir: &Path,
1238    validation_passed: bool,
1239) -> (GateResult, Vec<LintFinding>) {
1240    if !validation_passed {
1241        return (skipped_gate("relations", "validation failed"), Vec::new());
1242    }
1243    match relations_gate::run_relations_gate(contract_dir) {
1244        relations_gate::RelationsOutcome::Ran { result, findings } => (*result, findings),
1245        relations_gate::RelationsOutcome::NoSigma => (
1246            skipped_gate("relations", "no contracts/ontology.yaml"),
1247            Vec::new(),
1248        ),
1249        relations_gate::RelationsOutcome::Malformed(e) => (
1250            skipped_gate("relations", &format!("Σ is malformed: {e}")),
1251            Vec::new(),
1252        ),
1253        relations_gate::RelationsOutcome::NoRelations {
1254            contracts_checked,
1255            legacy_depends_on,
1256        } => (
1257            skipped_gate(
1258                "relations",
1259                &format!("no typed relations in {contracts_checked} contracts ({legacy_depends_on} legacy metadata.depends_on edges) — R-2: zero is a decline"),
1260            ),
1261            Vec::new(),
1262        ),
1263    }
1264}
1265
1266/// The `shapes` gate as `run_lint` reports it. Its four non-verdict answers become SKIPPED gates here (under
1267/// `--gate shapes` they are exits of their own); an `Unknown{Warn}` run is reported as the gate returned it.
1268fn shapes_result(contract_dir: &Path, validation_passed: bool) -> (GateResult, Vec<LintFinding>) {
1269    if !validation_passed {
1270        return (skipped_gate("shapes", "validation failed"), Vec::new());
1271    }
1272    match shapes_gate::run_shapes_gate(contract_dir) {
1273        shapes_gate::ShapesOutcome::Ran { result, findings } => (*result, findings),
1274        shapes_gate::ShapesOutcome::Unsupported(e) => (skipped_gate("shapes", &format!("{e}")), Vec::new()),
1275        shapes_gate::ShapesOutcome::ExtractFailed(e) => (skipped_gate("shapes", &format!("{e}")), Vec::new()),
1276        shapes_gate::ShapesOutcome::NoShapes { contracts_checked } => (
1277            skipped_gate("shapes", &format!("no `shape:` block in {contracts_checked} contracts — R-2: zero is a decline")),
1278            Vec::new(),
1279        ),
1280        shapes_gate::ShapesOutcome::NoFocus { shapes_n } => (
1281            skipped_gate("shapes", &format!("{shapes_n} shape(s), no focus node")),
1282            Vec::new(),
1283        ),
1284        shapes_gate::ShapesOutcome::WrongCorpus { shapes_n, expected, found, refused } => (
1285            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("; ")) })),
1286            Vec::new(),
1287        ),
1288        shapes_gate::ShapesOutcome::HarnessBroken { causes } => (
1289            skipped_gate("shapes", &format!("the CRUX harness measured itself, not apr: {}", causes.join("; "))),
1290            Vec::new(),
1291        ),
1292        shapes_gate::ShapesOutcome::NoReceipts { shapes_n, dir } => (
1293            skipped_gate("shapes", &format!("{shapes_n} shape(s) resolve receipts and the tree holds none under {dir}/ — R-2: unmeasured is a decline")),
1294            Vec::new(),
1295        ),
1296        shapes_gate::ShapesOutcome::EmptyDomain { shapes_n } => (
1297            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")),
1298            Vec::new(),
1299        ),
1300        shapes_gate::ShapesOutcome::PositiveControlFailed { shapes_n, focus_nodes_n, which } => (
1301            skipped_gate("shapes", &format!("positive control {which} did not fire ({shapes_n} shape(s), {focus_nodes_n} focus node(s)) — the gate cannot reject")),
1302            Vec::new(),
1303        ),
1304        shapes_gate::ShapesOutcome::Differential { shapes_n, focus_nodes_n, passed, n, failed } => {
1305            let mut g = skipped_gate(
1306                "shapes",
1307                &format!(
1308                    "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",
1309                    failed.join(" | ")
1310                ),
1311            );
1312            g.verdict = Verdict::Unknown(crate::ontology::verdict::Reason::Differential);
1313            (g, Vec::new())
1314        }
1315    }
1316}
1317
1318/// The `ont-consistency` gate as `run_lint` reports it. Its declines keep their lattice reason here (a stale witness
1319/// is `Unknown{WitnessStale}`, not a bare skip), so the meet says what could not be checked.
1320fn consistency_result(
1321    contract_dir: &Path,
1322    validation_passed: bool,
1323) -> (GateResult, Vec<LintFinding>) {
1324    if !validation_passed {
1325        return (
1326            skipped_gate(consistency_gate::GATE, "validation failed"),
1327            Vec::new(),
1328        );
1329    }
1330    let outcome = consistency_gate::run_consistency_gate(contract_dir);
1331    if let consistency_gate::ConsistencyOutcome::Ran { result, findings } = outcome {
1332        return (*result, findings);
1333    }
1334    let mut g = skipped_gate(consistency_gate::GATE, &consistency_gate::why(&outcome));
1335    if let Some(reason) = consistency_gate::decline_reason(&outcome) {
1336        g.verdict = Verdict::Unknown(reason);
1337    }
1338    (g, Vec::new())
1339}
1340
1341/// The `refines` gate as `run_lint` reports it; declines keep their lattice reason, as `ont-consistency`'s do.
1342fn refines_result(contract_dir: &Path, validation_passed: bool) -> (GateResult, Vec<LintFinding>) {
1343    if !validation_passed {
1344        return (
1345            skipped_gate(refines_gate::GATE, "validation failed"),
1346            Vec::new(),
1347        );
1348    }
1349    let outcome = refines_gate::run_refines_gate(contract_dir);
1350    if let refines_gate::RefinesOutcome::Ran { result, findings } = outcome {
1351        return (*result, findings);
1352    }
1353    let mut g = skipped_gate(refines_gate::GATE, &refines_gate::why(&outcome));
1354    if let Some(reason) = refines_gate::decline_reason(&outcome) {
1355        g.verdict = Verdict::Unknown(reason);
1356    }
1357    (g, Vec::new())
1358}
1359
1360/// The `challenge-fresh` gate: `pv challenge check` as a gate. No Lean theorem base, a tree that does not load, or
1361/// zero challenges is SKIPPED (not measured), never green.
1362fn challenge_result(contract_dir: &Path) -> GateResult {
1363    const NAME: &str = "challenge-fresh";
1364    let start = Instant::now();
1365    let root = crate::ontology::extract::repo_root(contract_dir);
1366    let Some(lean) = crate::ontology::extract::lean::base_under(&root) else {
1367        return skipped_gate(NAME, "no Lean theorem base under the repo root");
1368    };
1369    let r = match crate::discharge::challenge::render(&lean, contract_dir) {
1370        Ok(r) => r,
1371        Err(e) => return skipped_gate(NAME, &format!("the Lean tree does not load: {e}")),
1372    };
1373    if r.files.is_empty() && r.unrestated.is_empty() {
1374        return skipped_gate(
1375            NAME,
1376            "zero challenges: no contract binds a theorem — R-2: zero is a decline",
1377        );
1378    }
1379    let stale = crate::discharge::challenge::diff(&lean, &r);
1380    let unrestated: Vec<String> = r
1381        .unrestated
1382        .iter()
1383        .map(|(c, fqn, why)| format!("{c}: {fqn}: {why}"))
1384        .collect();
1385    let passed = stale.is_empty() && unrestated.is_empty();
1386    GateResult {
1387        name: NAME.into(),
1388        passed,
1389        skipped: false,
1390        verdict: Verdict::from_gate(passed, false),
1391        duration_ms: u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
1392        // `GateDetail` is FROZEN at the 0.3.1 variants: the shape is borrowed, the payload rides in `GateExtra`.
1393        detail: GateDetail::Validate {
1394            contracts: r.files.len(),
1395            errors: stale.len() + unrestated.len(),
1396            warnings: 0,
1397            error_messages: stale.iter().chain(&unrestated).cloned().collect(),
1398        },
1399        extra: Some(GateExtra::ChallengeFresh {
1400            lean_dir: lean.display().to_string(),
1401            files: r.files.len(),
1402            stale,
1403            unrestated,
1404        }),
1405    }
1406}
1407
1408fn skipped_gate(name: &str, reason: &str) -> GateResult {
1409    GateResult {
1410        name: name.into(),
1411        passed: false,
1412        skipped: true,
1413        verdict: Verdict::from_gate(false, true),
1414        duration_ms: 0,
1415        detail: GateDetail::Skipped {
1416            reason: reason.into(),
1417        },
1418        extra: None,
1419    }
1420}
1421
1422fn apply_suppressions(findings: &mut [LintFinding], config: &LintConfig) {
1423    for f in findings.iter_mut() {
1424        if config.suppressed_rules.iter().any(|r| r == &f.rule_id) {
1425            f.suppressed = true;
1426            f.suppression_reason = Some("Suppressed by --suppress-rule".into());
1427        }
1428        if let Some(ref stem) = f.contract_stem {
1429            if config.suppressed_findings.iter().any(|s| s == stem) {
1430                f.suppressed = true;
1431                f.suppression_reason = Some("Suppressed by --suppress".into());
1432            }
1433        }
1434        if config.suppressed_files.iter().any(|p| f.file.contains(p)) {
1435            f.suppressed = true;
1436            f.suppression_reason = Some("Suppressed by --suppress-file".into());
1437        }
1438    }
1439}
1440
1441/// Resolve the `.pv/` state directory relative to the contract directory's parent.
1442fn pv_state_dir(contract_dir: &Path) -> std::path::PathBuf {
1443    contract_dir.parent().unwrap_or(contract_dir).join(".pv")
1444}
1445
1446/// Load previous fingerprints, compare with current findings, mark new ones,
1447/// and persist the current fingerprint set for the next run.
1448fn mark_new_findings(findings: &mut [LintFinding], contract_dir: &Path) {
1449    let state_dir = pv_state_dir(contract_dir);
1450    let previous_path = state_dir.join("lint-previous.json");
1451
1452    // Load previous fingerprints (empty set if file missing or unreadable)
1453    let previous: HashSet<String> = std::fs::read_to_string(&previous_path)
1454        .ok()
1455        .and_then(|s| serde_json::from_str(&s).ok())
1456        .unwrap_or_default();
1457
1458    // Compute current fingerprints and mark new findings
1459    let mut current = HashSet::new();
1460    for f in findings.iter_mut() {
1461        let fp = f.fingerprint();
1462        if !previous.contains(&fp) {
1463            f.is_new = true;
1464        }
1465        current.insert(fp);
1466    }
1467
1468    // Persist current fingerprints for the next run
1469    if let Err(e) = std::fs::create_dir_all(&state_dir) {
1470        eprintln!("pv lint: cannot create {}: {e}", state_dir.display());
1471        return;
1472    }
1473    if let Ok(json) = serde_json::to_string(&current) {
1474        let _ = std::fs::write(&previous_path, json);
1475    }
1476}
1477
1478fn apply_severity_overrides(findings: &mut [LintFinding], config: &LintConfig) {
1479    for f in findings.iter_mut() {
1480        if let Some(&sev) = config.severity_overrides.get(&f.rule_id) {
1481            f.severity = sev;
1482        }
1483    }
1484    if config.strict {
1485        for f in findings.iter_mut() {
1486            if f.severity == RuleSeverity::Warning {
1487                f.severity = RuleSeverity::Error;
1488            }
1489        }
1490    }
1491}
1492
1493#[cfg(test)]
1494#[path = "mod_tests.rs"]
1495mod tests;