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::trend;
6use provable_contracts::lint::{run_lint, GateDetail, LintConfig, LintReport};
7
8#[path = "lint_render.rs"]
9mod lint_render;
10
11#[path = "lint_html.rs"]
12mod lint_html;
13
14/// Print long-form explanation for a lint rule.
15pub fn explain_rule(rule_id: &str) {
16    lint_render::print_explain(rule_id);
17}
18
19#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
20pub fn run(
21    contract_dir: &Path,
22    binding_path: Option<&Path>,
23    min_score: f64,
24    format: Option<&str>,
25    severity: Option<&str>,
26    strict: bool,
27    suppress: Option<&str>,
28    suppress_rule: Option<&str>,
29    suppress_file: Option<&str>,
30    rule_overrides: &[String],
31    config_path: Option<&Path>,
32    diff_ref: Option<&str>,
33    do_trend: bool,
34    show_trend: bool,
35    no_cache: bool,
36    cache_stats: bool,
37    coverage: bool,
38    min_coverage: Option<f64>,
39    crate_dir: Option<&Path>,
40    min_level: Option<&str>,
41    watch: bool,
42    strict_test_binding: bool,
43) -> Result<(), Box<dyn std::error::Error>> {
44    if watch {
45        return run_watch(
46            contract_dir,
47            binding_path,
48            min_score,
49            format,
50            severity,
51            strict,
52            suppress,
53            suppress_rule,
54            suppress_file,
55            rule_overrides,
56            config_path,
57            no_cache,
58            cache_stats,
59            crate_dir,
60            min_level,
61            strict_test_binding,
62        );
63    }
64
65    if show_trend {
66        show_trend_history(contract_dir);
67        return Ok(());
68    }
69
70    if let Some(base) = diff_ref {
71        if let Some(result) = run_diff_check(contract_dir, base) {
72            return result;
73        }
74    }
75
76    let config = build_config(
77        contract_dir,
78        binding_path,
79        min_score,
80        format,
81        severity,
82        strict,
83        suppress,
84        suppress_rule,
85        suppress_file,
86        rule_overrides,
87        config_path,
88        no_cache,
89        cache_stats,
90        crate_dir,
91        min_level,
92        strict_test_binding,
93    );
94
95    let report = run_lint(&config);
96
97    if cache_stats {
98        print_cache_stats(&report);
99    }
100    if do_trend {
101        record_trend(contract_dir, &report);
102    }
103
104    let effective_format = resolve_format(format, config_path, contract_dir);
105    print_report(&effective_format, &report)?;
106
107    // --coverage: compute and print aggregate contract coverage metric
108    if coverage {
109        let coverage_result = compute_contract_coverage(contract_dir);
110        println!(
111            "\nContract Coverage: {}/{} at Standard+ ({:.1}%)",
112            coverage_result.standard_plus, coverage_result.total, coverage_result.percentage,
113        );
114        if let Some(threshold) = min_coverage {
115            if coverage_result.percentage < threshold {
116                return Err(format!(
117                    "contract coverage {:.1}% is below minimum {:.1}%",
118                    coverage_result.percentage, threshold,
119                )
120                .into());
121            }
122        }
123    }
124
125    if report.passed {
126        Ok(())
127    } else {
128        let passed_count = report.gates.iter().filter(|g| g.passed).count();
129        Err(format!(
130            "lint failed ({}/{} gates passed)",
131            passed_count,
132            report.gates.len()
133        )
134        .into())
135    }
136}
137
138struct CoverageResult {
139    standard_plus: usize,
140    total: usize,
141    percentage: f64,
142}
143
144/// Count contracts at `Standard+` level (have both `falsification_tests` AND `kani_harnesses`).
145fn compute_contract_coverage(contract_dir: &Path) -> CoverageResult {
146    let mut total = 0usize;
147    let mut standard_plus = 0usize;
148
149    let mut yaml_paths = Vec::new();
150    collect_yaml_files_lint(contract_dir, &mut yaml_paths);
151
152    for path in &yaml_paths {
153        let Ok(contract) = provable_contracts::schema::parse_contract(path) else {
154            continue;
155        };
156        // Coverage is a kernel-contract metric. Skip registries,
157        // model-family schemas, pattern contracts, and reference documents.
158        if !contract.requires_proofs() {
159            continue;
160        }
161        total += 1;
162        if !contract.falsification_tests.is_empty() && !contract.kani_harnesses.is_empty() {
163            standard_plus += 1;
164        }
165    }
166
167    #[allow(clippy::cast_precision_loss)]
168    let percentage = if total > 0 {
169        (standard_plus as f64 / total as f64) * 100.0
170    } else {
171        100.0
172    };
173
174    CoverageResult {
175        standard_plus,
176        total,
177        percentage,
178    }
179}
180
181fn show_trend_history(contract_dir: &Path) {
182    let trend_root = trend::trend_dir(contract_dir);
183    let snapshots = trend::load_snapshots(&trend_root);
184    if snapshots.is_empty() {
185        println!("No trend data. Run `pv lint --trend` to record snapshots.");
186    } else {
187        println!("{}", trend::format_trend(&snapshots, 30));
188    }
189}
190
191/// Returns `Some(Ok(()))` to short-circuit when no contracts changed,
192/// or `None` to continue with full lint.
193fn run_diff_check(
194    contract_dir: &Path,
195    base: &str,
196) -> Option<Result<(), Box<dyn std::error::Error>>> {
197    match provable_contracts::lint::diff::changed_contracts(contract_dir, base) {
198        Ok(changed) if changed.is_empty() => {
199            println!("No contracts changed since {base}. Nothing to lint.");
200            Some(Ok(()))
201        }
202        Ok(changed) => {
203            println!(
204                "Diff-aware: {} contracts changed since {base}",
205                changed.len()
206            );
207            for stem in &changed {
208                println!("  {stem}");
209            }
210            println!();
211            None
212        }
213        Err(e) => {
214            eprintln!("Warning: diff-aware mode failed ({e}), linting all contracts");
215            None
216        }
217    }
218}
219
220fn print_cache_stats(report: &LintReport) {
221    eprintln!(
222        "Cache: {} total, {} hits, {} misses ({:.0}% hit rate)",
223        report.cache_stats.total,
224        report.cache_stats.hits,
225        report.cache_stats.misses,
226        report.cache_stats.hit_rate() * 100.0,
227    );
228}
229
230fn record_trend(contract_dir: &Path, report: &LintReport) {
231    let trend_root = trend::trend_dir(contract_dir);
232    let contracts_count = count_contracts(report);
233    match trend::record_snapshot(&trend_root, report, contracts_count) {
234        Ok(path) => eprintln!("Trend snapshot saved: {}", path.display()),
235        Err(e) => eprintln!("Warning: failed to save trend snapshot: {e}"),
236    }
237    let snapshots = trend::load_snapshots(&trend_root);
238    if let Some(drop) = trend::detect_drift(&snapshots, 0.05) {
239        eprintln!("Warning: quality drift detected (score dropped {drop:.3})");
240    }
241}
242
243fn print_report(format: &str, report: &LintReport) -> Result<(), Box<dyn std::error::Error>> {
244    match format {
245        "json" => lint_render::print_json(report)?,
246        "sarif" => lint_render::print_sarif(report),
247        "github" => lint_render::print_github(report),
248        "html" => println!("{}", lint_html::render_html(report)),
249        _ => lint_render::print_text(report),
250    }
251    Ok(())
252}
253
254fn count_contracts(report: &LintReport) -> usize {
255    for gate in &report.gates {
256        match &gate.detail {
257            GateDetail::Validate { contracts, .. }
258            | GateDetail::Audit { contracts, .. }
259            | GateDetail::Score { contracts, .. } => return *contracts,
260            GateDetail::Verify { .. }
261            | GateDetail::Enforce { .. }
262            | GateDetail::ReverseCoverage { .. }
263            | GateDetail::Composition { .. }
264            | GateDetail::Skipped { .. } => {}
265        }
266    }
267    0
268}
269
270/// Watch mode: polling-based re-lint every 5 seconds.
271#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
272fn run_watch(
273    contract_dir: &Path,
274    binding_path: Option<&Path>,
275    min_score: f64,
276    format: Option<&str>,
277    severity: Option<&str>,
278    strict: bool,
279    suppress: Option<&str>,
280    suppress_rule: Option<&str>,
281    suppress_file: Option<&str>,
282    rule_overrides: &[String],
283    config_path: Option<&Path>,
284    no_cache: bool,
285    cache_stats: bool,
286    crate_dir: Option<&Path>,
287    min_level: Option<&str>,
288    strict_test_binding: bool,
289) -> Result<(), Box<dyn std::error::Error>> {
290    loop {
291        let config = build_config(
292            contract_dir,
293            binding_path,
294            min_score,
295            format,
296            severity,
297            strict,
298            suppress,
299            suppress_rule,
300            suppress_file,
301            rule_overrides,
302            config_path,
303            no_cache,
304            cache_stats,
305            crate_dir,
306            min_level,
307            strict_test_binding,
308        );
309
310        let report = run_lint(&config);
311
312        if cache_stats {
313            print_cache_stats(&report);
314        }
315
316        let effective_format = resolve_format(format, config_path, contract_dir);
317        print_report(&effective_format, &report)?;
318
319        println!("\n--- Watching for changes (Ctrl+C to stop) ---\n");
320        std::thread::sleep(std::time::Duration::from_secs(5));
321    }
322}
323
324fn resolve_format(format: Option<&str>, config_path: Option<&Path>, contract_dir: &Path) -> String {
325    // CLI flag takes precedence when explicitly set
326    if let Some(f) = format {
327        return f.to_string();
328    }
329    // Fall back to config file
330    let pv_config = config_path
331        .and_then(|cp| load_config(cp).ok())
332        .or_else(|| find_config(contract_dir).and_then(|p| load_config(&p).ok()))
333        .unwrap_or_default();
334    pv_config.output.format.unwrap_or_else(|| "text".into())
335}
336
337#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
338fn build_config<'a>(
339    contract_dir: &'a Path,
340    binding_path: Option<&'a Path>,
341    min_score: f64,
342    _format: Option<&str>,
343    severity: Option<&str>,
344    strict: bool,
345    suppress: Option<&str>,
346    suppress_rule: Option<&str>,
347    suppress_file: Option<&str>,
348    rule_overrides: &[String],
349    config_path: Option<&Path>,
350    no_cache: bool,
351    cache_stats: bool,
352    crate_dir: Option<&'a Path>,
353    min_level: Option<&str>,
354    strict_test_binding: bool,
355) -> LintConfig<'a> {
356    let pv_config = config_path
357        .and_then(|cp| match load_config(cp) {
358            Ok(c) => {
359                if c.lint.min_score.is_none()
360                    && !c.lint.strict
361                    && c.lint.severity.is_none()
362                    && c.lint.rules.is_empty()
363                    && c.output.format.is_none()
364                {
365                    eprintln!(
366                        "Warning: config {} parsed but no lint settings found",
367                        cp.display()
368                    );
369                }
370                Some(c)
371            }
372            Err(e) => {
373                eprintln!("Warning: failed to load config {}: {e}", cp.display());
374                None
375            }
376        })
377        .or_else(|| find_config(contract_dir).and_then(|p| load_config(&p).ok()))
378        .unwrap_or_default();
379
380    let effective_min_score = if min_score > 0.0 {
381        min_score
382    } else {
383        pv_config.lint.min_score.unwrap_or(0.0)
384    };
385
386    let severity_filter = severity
387        .or(pv_config.lint.severity.as_deref())
388        .and_then(RuleSeverity::from_str_opt);
389
390    let effective_strict = strict || pv_config.lint.strict;
391
392    let suppressed_findings: Vec<String> = parse_csv(suppress)
393        .into_iter()
394        .chain(pv_config.lint.suppress.findings.iter().cloned())
395        .collect();
396    let suppressed_rules: Vec<String> = parse_csv(suppress_rule)
397        .into_iter()
398        .chain(pv_config.lint.suppress.rules.iter().cloned())
399        .collect();
400    let suppressed_files: Vec<String> = parse_csv(suppress_file)
401        .into_iter()
402        .chain(pv_config.lint.suppress.files.iter().cloned())
403        .collect();
404
405    let mut severity_overrides = std::collections::HashMap::new();
406    for entry in &pv_config.lint.rules {
407        if let Some(sev) = RuleSeverity::from_str_opt(entry.1) {
408            severity_overrides.insert(entry.0.clone(), sev);
409        }
410    }
411    for r in rule_overrides {
412        if let Some((id, sev_str)) = r.split_once('=') {
413            if let Some(sev) = RuleSeverity::from_str_opt(sev_str) {
414                severity_overrides.insert(id.to_string(), sev);
415            }
416        }
417    }
418
419    LintConfig {
420        contract_dir,
421        binding_path,
422        min_score: effective_min_score,
423        severity_filter,
424        severity_overrides,
425        suppressed_findings,
426        suppressed_rules,
427        suppressed_files,
428        strict: effective_strict,
429        no_cache,
430        cache_stats,
431        crate_dir,
432        min_level: min_level.and_then(parse_enforcement_level),
433        strict_test_binding,
434    }
435}
436
437fn parse_enforcement_level(s: &str) -> Option<provable_contracts::schema::EnforcementLevel> {
438    use provable_contracts::schema::EnforcementLevel;
439    match s.to_lowercase().as_str() {
440        "basic" => Some(EnforcementLevel::Basic),
441        "standard" => Some(EnforcementLevel::Standard),
442        "strict" => Some(EnforcementLevel::Strict),
443        "proven" => Some(EnforcementLevel::Proven),
444        _ => None,
445    }
446}
447
448fn parse_csv(s: Option<&str>) -> Vec<String> {
449    s.map(|v| {
450        v.split(',')
451            .map(|s| s.trim().to_string())
452            .filter(|s| !s.is_empty())
453            .collect()
454    })
455    .unwrap_or_default()
456}
457
458/// Recursively collect `.yaml` contract files, skipping non-contract directories.
459fn collect_yaml_files_lint(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
460    let Ok(entries) = std::fs::read_dir(dir) else {
461        return;
462    };
463    for entry in entries.flatten() {
464        let path = entry.path();
465        if path.is_dir() {
466            let dirname = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
467            if dirname == "kaizen" || dirname == "legacy" || dirname == "pipelines" {
468                continue;
469            }
470            collect_yaml_files_lint(&path, out);
471        } else if path.extension().and_then(|e| e.to_str()) == Some("yaml")
472            && !matches!(
473                path.file_name().and_then(|n| n.to_str()),
474                Some("binding.yaml" | "binding.yml")
475            )
476        {
477            out.push(path);
478        }
479    }
480}