Skip to main content

aprender_contracts_cli/commands/
lint.rs

1use std::path::Path;
2
3use provable_contracts::lint::config::{find_config, load_config};
4use provable_contracts::lint::rules::RuleSeverity;
5use provable_contracts::lint::shapes_gate::ShapesOptions;
6use provable_contracts::lint::trend;
7use provable_contracts::lint::{run_lint, GateDetail, LintConfig, LintReport};
8use provable_contracts::ontology::verdict::Verdict;
9
10use crate::contract_walk::{LintDeclined, LintRejected, ZeroContracts};
11
12#[path = "lint_render.rs"]
13mod lint_render;
14
15#[path = "lint_html.rs"]
16mod lint_html;
17
18#[path = "lint_arming.rs"]
19mod lint_arming;
20
21/// Print long-form explanation for a lint rule.
22pub fn explain_rule(rule_id: &str) {
23    lint_render::print_explain(rule_id);
24}
25
26#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
27pub fn run(
28    contract_dir: &Path,
29    binding_path: Option<&Path>,
30    min_score: f64,
31    format: Option<&str>,
32    severity: Option<&str>,
33    strict: bool,
34    suppress: Option<&str>,
35    suppress_rule: Option<&str>,
36    suppress_file: Option<&str>,
37    rule_overrides: &[String],
38    config_path: Option<&Path>,
39    diff_ref: Option<&str>,
40    do_trend: bool,
41    show_trend: bool,
42    no_cache: bool,
43    cache_stats: bool,
44    coverage: bool,
45    min_coverage: Option<f64>,
46    crate_dir: Option<&Path>,
47    min_level: Option<&str>,
48    watch: bool,
49    strict_test_binding: bool,
50    armed_baseline_ref: Option<&str>,
51    gate: Option<&str>,
52    shapes_opts: ShapesOptions,
53) -> Result<(), Box<dyn std::error::Error>> {
54    refuse_missing_corpus(contract_dir)?;
55    // Both preconditions before any dispatch: a single file under
56    // --strict-test-binding is refused (this PR) whether or not --gate (ONT-6)
57    // is asked for, because run_single_gate would otherwise report every ref
58    // missing on exactly the input the refusal exists for.
59    refuse_single_file_strict_binding(contract_dir, strict_test_binding)?;
60    if let Some(name) = gate {
61        return run_single_gate(contract_dir, name, &shapes_opts);
62    }
63    if watch {
64        return run_watch(
65            contract_dir,
66            binding_path,
67            min_score,
68            format,
69            severity,
70            strict,
71            suppress,
72            suppress_rule,
73            suppress_file,
74            rule_overrides,
75            config_path,
76            no_cache,
77            cache_stats,
78            crate_dir,
79            min_level,
80            strict_test_binding,
81        );
82    }
83
84    if show_trend {
85        show_trend_history(contract_dir);
86        return Ok(());
87    }
88
89    if let Some(base) = diff_ref {
90        if let Some(result) = run_diff_check(contract_dir, base) {
91            return result;
92        }
93    }
94
95    let config = build_config(
96        contract_dir,
97        binding_path,
98        min_score,
99        format,
100        severity,
101        strict,
102        suppress,
103        suppress_rule,
104        suppress_file,
105        rule_overrides,
106        config_path,
107        no_cache,
108        cache_stats,
109        crate_dir,
110        min_level,
111        strict_test_binding,
112    );
113
114    // ONT-6 (PMAT-3451): the shrink check needs no lint run, so it refuses (exit 3) before one.
115    let arming = lint_arming::resolve(contract_dir, armed_baseline_ref)?;
116
117    let mut report = run_lint(&config);
118
119    // PVL-1 (PMAT-1099): an EMPTY corpus is refused (exit 2) before any report is
120    // printed — never `Result: PASS` over 0 contracts.
121    refuse_empty_corpus(&report, contract_dir)?;
122
123    report.arm(&arming.armed);
124    report.armed_monotone = Some(arming.monotone);
125    report.armed_shapes_monotone = Some(arming.shapes_monotone);
126
127    if cache_stats {
128        print_cache_stats(&report);
129    }
130    if do_trend {
131        record_trend(contract_dir, &report);
132    }
133
134    let effective_format = resolve_format(format, config_path, contract_dir);
135    print_report(&effective_format, &report)?;
136
137    // --coverage: compute and print aggregate contract coverage metric
138    if coverage {
139        report_coverage(contract_dir, min_coverage)?;
140    }
141
142    meet_exit(&report)
143}
144
145/// `--coverage`: print the aggregate contract-coverage metric, and refuse below `--min-coverage`.
146fn report_coverage(
147    contract_dir: &Path,
148    min_coverage: Option<f64>,
149) -> Result<(), Box<dyn std::error::Error>> {
150    let coverage_result = compute_contract_coverage(contract_dir);
151    println!(
152        "\nContract Coverage: {}/{} at Standard+ ({:.1}%)",
153        coverage_result.standard_plus, coverage_result.total, coverage_result.percentage,
154    );
155    match min_coverage {
156        Some(threshold) if coverage_result.percentage < threshold => Err(format!(
157            "contract coverage {:.1}% is below minimum {:.1}%",
158            coverage_result.percentage, threshold,
159        )
160        .into()),
161        _ => Ok(()),
162    }
163}
164
165/// aprender#3715: `--shape` and `--release-*` as the shapes gate's options. They mean something ONLY under
166/// `--gate shapes`; anywhere else they are refused (exit 3), because a release flag that was silently ignored is
167/// a release gate that silently did not run.
168pub fn shapes_options(
169    gate: Option<&str>,
170    shape: Option<String>,
171    release: &crate::cli::ReleaseArgs,
172) -> Result<ShapesOptions, crate::contract_walk::ReleaseArgsRefused> {
173    use crate::contract_walk::ReleaseArgsRefused;
174    if (shape.is_some() || release.any()) && gate != Some("shapes") {
175        return Err(ReleaseArgsRefused(
176            "--shape and --release-* / --receipts* / --kernel-receipts / --dogfood-receipt apply only to \
177             `--gate shapes`"
178                .into(),
179        ));
180    }
181    Ok(ShapesOptions {
182        only: shape,
183        release: release.subject().map_err(ReleaseArgsRefused)?,
184    })
185}
186
187/// One gate's report. NOT a `LintReport`: `--gate` answers about one gate, and a reader must be able to tell the
188/// two apart without counting keys.
189#[derive(serde::Serialize)]
190struct SingleGateReport<'a> {
191    gate: &'a str,
192    verdict: Verdict,
193    passed: bool,
194    duration_ms: u64,
195    extra: Option<&'a provable_contracts::lint::GateExtra>,
196    /// The same fields at the TOP level (ONT-4b): ONT-001 §5's probes read `.shapes_n`, `.focus_nodes_n`, `.pc_shape`,
197    /// `.w3c_cases_passed` from the single-gate report directly, beside `.verdict` — the way `.gate` and `.verdict`
198    /// already sit there. `extra` stays nested for anything that reads the structure; the flatten costs one `type` key.
199    #[serde(flatten)]
200    flat: Option<&'a provable_contracts::lint::GateExtra>,
201    findings: Vec<SingleGateFinding<'a>>,
202}
203
204#[derive(serde::Serialize)]
205struct SingleGateFinding<'a> {
206    rule_id: &'a str,
207    severity: String,
208    message: &'a str,
209    file: &'a str,
210}
211
212/// ONT-2b: `--gate <name>` runs ONE gate and reports only it, mapping its verdict through ONT-6's lattice —
213/// Pass 0 · Fail 1 `reject:` · no Σ 2 `decline:` (R-2, zero is a decline) · malformed Σ 3 `error:`.
214fn run_single_gate(
215    contract_dir: &Path,
216    name: &str,
217    shapes_opts: &ShapesOptions,
218) -> Result<(), Box<dyn std::error::Error>> {
219    let (result, findings) = decide_named_gate(contract_dir, name, shapes_opts)?;
220
221    let report = SingleGateReport {
222        gate: &result.name,
223        verdict: result.verdict,
224        passed: result.passed,
225        duration_ms: result.duration_ms,
226        extra: result.extra.as_ref(),
227        flat: result.extra.as_ref(),
228        findings: findings
229            .iter()
230            .map(|f| SingleGateFinding {
231                rule_id: &f.rule_id,
232                severity: format!("{:?}", f.severity),
233                message: &f.message,
234                file: &f.file,
235            })
236            .collect(),
237    };
238    println!("{}", serde_json::to_string_pretty(&report)?);
239
240    // The exit is the gate's VERDICT, not its `passed` bit: a gate that ran and answered `Unknown{Warn}` (ONT-4b, warnings
241    // and no violation) is a decline, exit 2 — `passed` alone would print 0 for a corpus nobody judged clean.
242    match result.verdict {
243        provable_contracts::ontology::verdict::Verdict::Pass => Ok(()),
244        provable_contracts::ontology::verdict::Verdict::Unknown(reason) => {
245            Err(LintDeclined { reason }.into())
246        }
247        provable_contracts::ontology::verdict::Verdict::Fail => Err(LintRejected {
248            passed: 0,
249            armed: 1,
250        }
251        .into()),
252    }
253}
254
255/// One gate's run, mapped to a report or to the refusal/decline that stands in its place. Every non-verdict
256/// answer says WHY on stderr before it returns, because an exit code without a reason is the thing this gate
257/// exists to refuse.
258type NamedGateAnswer = (
259    Box<provable_contracts::lint::GateResult>,
260    Vec<provable_contracts::lint::finding::LintFinding>,
261);
262
263fn decide_named_gate(
264    contract_dir: &Path,
265    name: &str,
266    shapes_opts: &ShapesOptions,
267) -> Result<NamedGateAnswer, Box<dyn std::error::Error>> {
268    use provable_contracts::lint::{
269        relations_gate::RelationsOutcome, sigma_gate::SigmaOutcome, NamedGateOutcome, NAMED_GATES,
270    };
271
272    match provable_contracts::lint::run_named_gate_with(contract_dir, name, shapes_opts) {
273        NamedGateOutcome::UnknownGate => Err(crate::contract_walk::UnknownGate {
274            asked: name.to_string(),
275            known: NAMED_GATES.iter().map(|g| (*g).to_string()).collect(),
276        }
277        .into()),
278        NamedGateOutcome::Sigma(SigmaOutcome::NoSigma) => Err(LintDeclined {
279            reason: provable_contracts::ontology::verdict::Reason::NoCheckable,
280        }
281        .into()),
282        NamedGateOutcome::Sigma(SigmaOutcome::Malformed(e)) => {
283            Err(crate::contract_walk::SigmaMalformed(e.to_string()).into())
284        }
285        NamedGateOutcome::Relations(
286            RelationsOutcome::NoSigma | RelationsOutcome::NoRelations { .. },
287        ) => Err(LintDeclined {
288            reason: provable_contracts::ontology::verdict::Reason::NoCheckable,
289        }
290        .into()),
291        NamedGateOutcome::Relations(RelationsOutcome::Malformed(e)) => {
292            Err(crate::contract_walk::SigmaMalformed(e.to_string()).into())
293        }
294        NamedGateOutcome::Shapes(outcome) => decide_shapes_gate(outcome),
295        NamedGateOutcome::Sigma(SigmaOutcome::Ran { result, findings })
296        | NamedGateOutcome::Relations(RelationsOutcome::Ran { result, findings })
297        | NamedGateOutcome::Ran { result, findings } => Ok((result, findings)),
298    }
299}
300
301/// The `shapes` gate's answers (ONT-4b, ONT-4c1, ONT-4b2). Only `Ran` is a verdict about the corpus; every
302/// other arm prints what could not be checked before it returns its decline or refusal.
303fn decide_shapes_gate(
304    outcome: provable_contracts::lint::shapes_gate::ShapesOutcome,
305) -> Result<NamedGateAnswer, Box<dyn std::error::Error>> {
306    use provable_contracts::lint::shapes_gate::ShapesOutcome;
307    use provable_contracts::ontology::verdict::Reason;
308
309    match outcome {
310        ShapesOutcome::Unsupported(e) => {
311            Err(crate::contract_walk::SigmaMalformed(e.to_string()).into())
312        }
313        ShapesOutcome::ExtractFailed(e) => {
314            Err(crate::contract_walk::SigmaMalformed(e.to_string()).into())
315        }
316        ShapesOutcome::NoShapes { .. } => Err(LintDeclined {
317            reason: Reason::NoShapes,
318        }
319        .into()),
320        ShapesOutcome::NoFocus { .. } => Err(LintDeclined {
321            reason: Reason::NoFocus,
322        }
323        .into()),
324        ShapesOutcome::WrongCorpus {
325            shapes_n,
326            expected,
327            found,
328            refused,
329        } => {
330            eprintln!(
331                "shapes: extract:parity-receipt matched {found} focus node(s);                  evidence/parity/EXPECTED_RECEIPTS says {expected} ({shapes_n} shape(s))"
332            );
333            for r in &refused {
334                eprintln!("shapes: refused {r}");
335            }
336            Err(LintDeclined {
337                reason: Reason::WrongCorpus,
338            }
339            .into())
340        }
341        ShapesOutcome::NoReceipts { shapes_n, dir } => {
342            // ONT-4c1: the WHY travels with the decline — the lattice has no ReceiptUnmeasured element (ONT-6's
343            // 15 reasons), so the reason is NoCheckable and this line says what could not be checked.
344            eprintln!(
345                "shapes: {shapes_n} shape(s) resolve receipts and the tree holds none under {dir}/"
346            );
347            Err(LintDeclined {
348                reason: Reason::NoCheckable,
349            }
350            .into())
351        }
352        ShapesOutcome::PositiveControlFailed { which, .. } => {
353            eprintln!("shapes: positive control {which} did not fire");
354            Err(LintDeclined {
355                reason: Reason::PositiveControlFailed,
356            }
357            .into())
358        }
359        ShapesOutcome::Differential {
360            passed, n, failed, ..
361        } => {
362            // ONT-4b2: the validator failed a vendored W3C case for a form it claims — no corpus verdict.
363            eprintln!(
364                "shapes: W3C SHACL-Core differential — {passed} of {n} vendored case(s) pass"
365            );
366            for f in &failed {
367                eprintln!("  {f}");
368            }
369            Err(LintDeclined {
370                reason: Reason::Differential,
371            }
372            .into())
373        }
374        ShapesOutcome::Ran { result, findings } => Ok((result, findings)),
375    }
376}
377
378/// ONT-001 §3.4: the exit is the armed meet — Pass 0, Fail 1 (`reject:`), Unknown 2 (`decline: <reason>`).
379/// `report.passed` (every gate passed or skipped) no longer decides it: a skipped armed gate is not a pass.
380fn meet_exit(report: &LintReport) -> Result<(), Box<dyn std::error::Error>> {
381    match report.verdict {
382        Verdict::Pass => Ok(()),
383        Verdict::Fail => Err(LintRejected {
384            passed: report
385                .armed_gates
386                .iter()
387                .filter(|g| g.verdict == Verdict::Pass)
388                .count(),
389            armed: report.armed_gates.len(),
390        }
391        .into()),
392        Verdict::Unknown(reason) => Err(LintDeclined { reason }.into()),
393    }
394}
395
396/// PVL-1 (PMAT-1099): the corpus is EMPTY when the gate NAMED `validate` counted
397/// nothing. Only that gate carries the count (parsed + parse errors):
398/// `duplicate-stems` reuses the `Validate` detail shape with zeros as a
399/// placeholder, so a shape-only match refused every VALID corpus — measured:
400/// one valid contract was refused while an invalid fixture, whose later gates
401/// are skipped, was counted. A corpus whose files all fail to parse is NOT
402/// empty (errors > 0): it was measured, and fails at exit 1.
403fn refuse_empty_corpus(report: &LintReport, contract_dir: &Path) -> Result<(), ZeroContracts> {
404    let empty = report.gates.iter().any(|g| {
405        g.name == "validate"
406            && matches!(
407                g.detail,
408                GateDetail::Validate {
409                    contracts: 0,
410                    errors: 0,
411                    ..
412                }
413            )
414    });
415    if empty {
416        return Err(ZeroContracts {
417            path: contract_dir.to_path_buf(),
418            filter: None,
419        });
420    }
421    Ok(())
422}
423
424struct CoverageResult {
425    standard_plus: usize,
426    total: usize,
427    percentage: f64,
428}
429
430/// Count contracts at `Standard+` level (have both `falsification_tests` AND `kani_harnesses`).
431fn compute_contract_coverage(contract_dir: &Path) -> CoverageResult {
432    let mut total = 0usize;
433    let mut standard_plus = 0usize;
434
435    let mut yaml_paths = Vec::new();
436    collect_yaml_files_lint(contract_dir, &mut yaml_paths);
437
438    for path in &yaml_paths {
439        let Ok(contract) = provable_contracts::schema::parse_contract(path) else {
440            continue;
441        };
442        // Coverage is a kernel-contract metric. Skip registries,
443        // model-family schemas, pattern contracts, and reference documents.
444        if !contract.requires_proofs() {
445            continue;
446        }
447        total += 1;
448        if !contract.falsification_tests.is_empty() && !contract.kani_harnesses.is_empty() {
449            standard_plus += 1;
450        }
451    }
452
453    #[allow(clippy::cast_precision_loss)]
454    let percentage = if total > 0 {
455        (standard_plus as f64 / total as f64) * 100.0
456    } else {
457        100.0
458    };
459
460    CoverageResult {
461        standard_plus,
462        total,
463        percentage,
464    }
465}
466
467fn show_trend_history(contract_dir: &Path) {
468    let trend_root = trend::trend_dir(contract_dir);
469    let snapshots = trend::load_snapshots(&trend_root);
470    if snapshots.is_empty() {
471        println!("No trend data. Run `pv lint --trend` to record snapshots.");
472    } else {
473        println!("{}", trend::format_trend(&snapshots, 30));
474    }
475}
476
477/// Returns `Some(Ok(()))` to short-circuit when no contracts changed,
478/// or `None` to continue with full lint.
479/// PVL-1 (PMAT-1099): refuse an empty corpus BEFORE anything touches the tree —
480/// the `.pv` cache directory, the diff probe, the watcher. The third review
481/// quorum on #3093 measured `pv lint /nonexistent` leaking `cannot create /.pv:
482/// Permission denied` AHEAD of the refusal: two stderr lines for one decline,
483/// three under `--diff`. `has_contract_files` does not parse; the post-report
484/// guard in `run` stays for a corpus that parses to nothing.
485/// `pv lint <one-file> --strict-test-binding` REFUSES rather than reporting a
486/// false negative (#3347).
487///
488/// The gate resolves cited test names against a source index rooted at the
489/// contract path's PARENT. For the directory form that parent is the repo
490/// root and the index finds `crates/`; for a single file it is `contracts/`,
491/// which holds no source at all, so every cited ref resolves to nothing and
492/// every one is reported missing.
493///
494/// Measured on `contracts/pv-artifact-kinds-v1.yaml`, a contract whose eight
495/// refs all resolve:
496///
497/// ```text
498/// pv lint contracts/pv-artifact-kinds-v1.yaml --strict-test-binding
499///     total_refs 8, existing 0, missing 8
500/// pv lint contracts/ --strict-test-binding
501///     total_refs 548, existing 521, missing 27   <- and none of the 27 is this contract
502/// ```
503///
504/// A control contract failing identically to a broken one is the definition
505/// of a gate that cannot discriminate, so the single-file form is refused.
506///
507/// REFUSED rather than repaired: the scan root is computed inside
508/// `provable_contracts::lint`, which this ticket does not own. A refusal is
509/// in the caller, is honest, and cannot be mistaken for a clean bill.
510///
511/// Exit 1, not the exit-2 `decline:` class: exit 2 belongs to `ZeroContracts`
512/// and its message ("0 contracts under ...") would be false here -- there IS
513/// a contract, it is the gate that cannot run over it.
514fn refuse_single_file_strict_binding(
515    path: &Path,
516    strict_test_binding: bool,
517) -> Result<(), Box<dyn std::error::Error>> {
518    if !strict_test_binding || !path.is_file() {
519        return Ok(());
520    }
521    let dir = path.parent().unwrap_or(Path::new("contracts"));
522    Err(format!(
523        "--strict-test-binding cannot run over a single contract file ({}): \
524         the gate resolves cited test names against a source tree rooted at \
525         that file's parent directory, which holds contracts and no source, \
526         so every reference would be reported missing -- including those that \
527         do resolve. Run the directory form instead: \
528         `pv lint {} --strict-test-binding`.",
529        path.display(),
530        dir.display(),
531    )
532    .into())
533}
534
535fn refuse_missing_corpus(contract_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
536    if crate::contract_walk::has_contract_files(contract_dir) {
537        return Ok(());
538    }
539    Err(crate::contract_walk::ZeroContracts {
540        path: contract_dir.to_path_buf(),
541        filter: None,
542    }
543    .into())
544}
545
546fn run_diff_check(
547    contract_dir: &Path,
548    base: &str,
549) -> Option<Result<(), Box<dyn std::error::Error>>> {
550    match provable_contracts::lint::diff::changed_contracts(contract_dir, base) {
551        Ok(changed) if changed.is_empty() => {
552            // PVL-1 (PMAT-1099): "nothing changed" over 0 contracts is the vacuous
553            // pass again — refuse a corpus with no contract file (exit 2) before
554            // saying so; the probe does not parse (nothing changed = fast path).
555            if !crate::contract_walk::has_contract_files(contract_dir) {
556                return Some(Err(crate::contract_walk::ZeroContracts {
557                    path: contract_dir.to_path_buf(),
558                    filter: None,
559                }
560                .into()));
561            }
562            println!("No contracts changed since {base}. Nothing to lint.");
563            Some(Ok(()))
564        }
565        Ok(changed) => {
566            println!(
567                "Diff-aware: {} contracts changed since {base}",
568                changed.len()
569            );
570            for stem in &changed {
571                println!("  {stem}");
572            }
573            println!();
574            None
575        }
576        Err(e) => {
577            eprintln!("Warning: diff-aware mode failed ({e}), linting all contracts");
578            None
579        }
580    }
581}
582
583fn print_cache_stats(report: &LintReport) {
584    eprintln!(
585        "Cache: {} total, {} hits, {} misses ({:.0}% hit rate)",
586        report.cache_stats.total,
587        report.cache_stats.hits,
588        report.cache_stats.misses,
589        report.cache_stats.hit_rate() * 100.0,
590    );
591}
592
593fn record_trend(contract_dir: &Path, report: &LintReport) {
594    let trend_root = trend::trend_dir(contract_dir);
595    let contracts_count = count_contracts(report);
596    match trend::record_snapshot(&trend_root, report, contracts_count) {
597        Ok(path) => eprintln!("Trend snapshot saved: {}", path.display()),
598        Err(e) => eprintln!("Warning: failed to save trend snapshot: {e}"),
599    }
600    let snapshots = trend::load_snapshots(&trend_root);
601    if let Some(drop) = trend::detect_drift(&snapshots, 0.05) {
602        eprintln!("Warning: quality drift detected (score dropped {drop:.3})");
603    }
604}
605
606fn print_report(format: &str, report: &LintReport) -> Result<(), Box<dyn std::error::Error>> {
607    match format {
608        "json" => lint_render::print_json(report)?,
609        "sarif" => lint_render::print_sarif(report),
610        "github" => lint_render::print_github(report),
611        "html" => println!("{}", lint_html::render_html(report)),
612        _ => lint_render::print_text(report),
613    }
614    Ok(())
615}
616
617fn count_contracts(report: &LintReport) -> usize {
618    for gate in &report.gates {
619        match &gate.detail {
620            GateDetail::Validate { contracts, .. }
621            | GateDetail::Audit { contracts, .. }
622            | GateDetail::Score { contracts, .. } => return *contracts,
623            GateDetail::Verify { .. }
624            | GateDetail::Enforce { .. }
625            | GateDetail::ReverseCoverage { .. }
626            | GateDetail::Composition { .. }
627            | GateDetail::Skipped { .. } => {}
628        }
629    }
630    0
631}
632
633/// Watch mode: polling-based re-lint every 5 seconds.
634#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
635fn run_watch(
636    contract_dir: &Path,
637    binding_path: Option<&Path>,
638    min_score: f64,
639    format: Option<&str>,
640    severity: Option<&str>,
641    strict: bool,
642    suppress: Option<&str>,
643    suppress_rule: Option<&str>,
644    suppress_file: Option<&str>,
645    rule_overrides: &[String],
646    config_path: Option<&Path>,
647    no_cache: bool,
648    cache_stats: bool,
649    crate_dir: Option<&Path>,
650    min_level: Option<&str>,
651    strict_test_binding: bool,
652) -> Result<(), Box<dyn std::error::Error>> {
653    loop {
654        let config = build_config(
655            contract_dir,
656            binding_path,
657            min_score,
658            format,
659            severity,
660            strict,
661            suppress,
662            suppress_rule,
663            suppress_file,
664            rule_overrides,
665            config_path,
666            no_cache,
667            cache_stats,
668            crate_dir,
669            min_level,
670            strict_test_binding,
671        );
672
673        let mut report = run_lint(&config);
674
675        // PVL-1 (PMAT-1099): watch mode is the same gate on a timer — an empty
676        // corpus is refused (exit 2) at the first tick, never reported over.
677        refuse_empty_corpus(&report, contract_dir)?;
678        // ONT-6: the declared arming, re-read each tick; the monotone check is a one-shot run's job.
679        report.arm(&lint_arming::declared(contract_dir)?);
680
681        if cache_stats {
682            print_cache_stats(&report);
683        }
684
685        let effective_format = resolve_format(format, config_path, contract_dir);
686        print_report(&effective_format, &report)?;
687
688        println!("\n--- Watching for changes (Ctrl+C to stop) ---\n");
689        std::thread::sleep(std::time::Duration::from_secs(5));
690    }
691}
692
693fn resolve_format(format: Option<&str>, config_path: Option<&Path>, contract_dir: &Path) -> String {
694    // CLI flag takes precedence when explicitly set
695    if let Some(f) = format {
696        return f.to_string();
697    }
698    // Fall back to config file
699    let pv_config = config_path
700        .and_then(|cp| load_config(cp).ok())
701        .or_else(|| find_config(contract_dir).and_then(|p| load_config(&p).ok()))
702        .unwrap_or_default();
703    pv_config.output.format.unwrap_or_else(|| "text".into())
704}
705
706#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
707fn build_config<'a>(
708    contract_dir: &'a Path,
709    binding_path: Option<&'a Path>,
710    min_score: f64,
711    _format: Option<&str>,
712    severity: Option<&str>,
713    strict: bool,
714    suppress: Option<&str>,
715    suppress_rule: Option<&str>,
716    suppress_file: Option<&str>,
717    rule_overrides: &[String],
718    config_path: Option<&Path>,
719    no_cache: bool,
720    cache_stats: bool,
721    crate_dir: Option<&'a Path>,
722    min_level: Option<&str>,
723    strict_test_binding: bool,
724) -> LintConfig<'a> {
725    let pv_config = config_path
726        .and_then(|cp| match load_config(cp) {
727            Ok(c) => {
728                if c.lint.min_score.is_none()
729                    && !c.lint.strict
730                    && c.lint.severity.is_none()
731                    && c.lint.rules.is_empty()
732                    && c.output.format.is_none()
733                {
734                    eprintln!(
735                        "Warning: config {} parsed but no lint settings found",
736                        cp.display()
737                    );
738                }
739                Some(c)
740            }
741            Err(e) => {
742                eprintln!("Warning: failed to load config {}: {e}", cp.display());
743                None
744            }
745        })
746        .or_else(|| find_config(contract_dir).and_then(|p| load_config(&p).ok()))
747        .unwrap_or_default();
748
749    let effective_min_score = if min_score > 0.0 {
750        min_score
751    } else {
752        pv_config.lint.min_score.unwrap_or(0.0)
753    };
754
755    let severity_filter = severity
756        .or(pv_config.lint.severity.as_deref())
757        .and_then(RuleSeverity::from_str_opt);
758
759    let effective_strict = strict || pv_config.lint.strict;
760
761    let suppressed_findings: Vec<String> = parse_csv(suppress)
762        .into_iter()
763        .chain(pv_config.lint.suppress.findings.iter().cloned())
764        .collect();
765    let suppressed_rules: Vec<String> = parse_csv(suppress_rule)
766        .into_iter()
767        .chain(pv_config.lint.suppress.rules.iter().cloned())
768        .collect();
769    let suppressed_files: Vec<String> = parse_csv(suppress_file)
770        .into_iter()
771        .chain(pv_config.lint.suppress.files.iter().cloned())
772        .collect();
773
774    let mut severity_overrides = std::collections::HashMap::new();
775    for entry in &pv_config.lint.rules {
776        if let Some(sev) = RuleSeverity::from_str_opt(entry.1) {
777            severity_overrides.insert(entry.0.clone(), sev);
778        }
779    }
780    for r in rule_overrides {
781        if let Some((id, sev_str)) = r.split_once('=') {
782            if let Some(sev) = RuleSeverity::from_str_opt(sev_str) {
783                severity_overrides.insert(id.to_string(), sev);
784            }
785        }
786    }
787
788    LintConfig {
789        contract_dir,
790        binding_path,
791        min_score: effective_min_score,
792        severity_filter,
793        severity_overrides,
794        suppressed_findings,
795        suppressed_rules,
796        suppressed_files,
797        strict: effective_strict,
798        no_cache,
799        cache_stats,
800        crate_dir,
801        min_level: min_level.and_then(parse_enforcement_level),
802        strict_test_binding,
803    }
804}
805
806fn parse_enforcement_level(s: &str) -> Option<provable_contracts::schema::EnforcementLevel> {
807    use provable_contracts::schema::EnforcementLevel;
808    match s.to_lowercase().as_str() {
809        "basic" => Some(EnforcementLevel::Basic),
810        "standard" => Some(EnforcementLevel::Standard),
811        "strict" => Some(EnforcementLevel::Strict),
812        "proven" => Some(EnforcementLevel::Proven),
813        _ => None,
814    }
815}
816
817fn parse_csv(s: Option<&str>) -> Vec<String> {
818    s.map(|v| {
819        v.split(',')
820            .map(|s| s.trim().to_string())
821            .filter(|s| !s.is_empty())
822            .collect()
823    })
824    .unwrap_or_default()
825}
826
827/// Recursively collect `.yaml` contract files, skipping non-contract directories.
828fn collect_yaml_files_lint(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
829    let Ok(entries) = std::fs::read_dir(dir) else {
830        return;
831    };
832    for entry in entries.flatten() {
833        let path = entry.path();
834        if path.is_dir() {
835            let dirname = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
836            if dirname == "kaizen" || dirname == "legacy" || dirname == "pipelines" {
837                continue;
838            }
839            collect_yaml_files_lint(&path, out);
840        } else if path.extension().and_then(|e| e.to_str()) == Some("yaml")
841            && !matches!(
842                path.file_name().and_then(|n| n.to_str()),
843                Some("binding.yaml" | "binding.yml")
844            )
845        {
846            out.push(path);
847        }
848    }
849}