Skip to main content

aprender_contracts_cli/commands/
kaizen.rs

1//! `pv kaizen` — fleet-wide contract enforcement improvement.
2
3use provable_contracts::binding::{parse_binding, BindingRegistry, ImplStatus};
4use provable_contracts::codegen;
5use std::path::{Path, PathBuf};
6
7#[derive(Clone)]
8struct RepoReport {
9    name: String,
10    bindings: usize,
11    call_sites_before: usize,
12    call_sites_after: usize,
13    e0: usize,
14    e1: usize,
15    e2: usize,
16    assertions_before: usize,
17    assertions_after: usize,
18    #[allow(dead_code)]
19    codegen_ok: bool,
20    check_ok: Option<bool>,
21    injection_count: usize,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25enum ELevel {
26    E0,
27    E1,
28    E2,
29}
30
31struct CallSite {
32    #[allow(dead_code)]
33    file: PathBuf,
34    #[allow(dead_code)]
35    line: usize,
36    macro_name: String,
37    level: ELevel,
38}
39#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
40pub fn run(
41    contract_dir: &Path,
42    src_root: &Path,
43    repo_filter: Option<&str>,
44    dry_run: bool,
45    do_codegen: bool,
46    do_fix: bool,
47    json_output: bool,
48    min_score: Option<f64>,
49) -> Result<(), Box<dyn std::error::Error>> {
50    let repos = collect_repo_paths(contract_dir, src_root, repo_filter)?;
51    if repos.is_empty() {
52        return Err("no repos found with binding.yaml and sibling directory".into());
53    }
54
55    print_kaizen_banner(repos.len(), do_fix, do_codegen);
56
57    let mut reports: Vec<RepoReport> = Vec::new();
58    for (name, repo_path, binding_path) in &repos {
59        if let Some(report) = process_repo(
60            name,
61            repo_path,
62            binding_path,
63            contract_dir,
64            do_codegen,
65            do_fix,
66        ) {
67            reports.push(report);
68        }
69    }
70
71    if json_output {
72        print_json_report(&reports);
73    } else {
74        print_text_report(&reports, dry_run, do_fix);
75    }
76
77    enforce_min_score(&reports, min_score);
78    Ok(())
79}
80
81/// Enumerate every `<contract_dir>/<repo>/binding.yaml` and resolve its sibling source tree.
82fn collect_repo_paths(
83    contract_dir: &Path,
84    src_root: &Path,
85    repo_filter: Option<&str>,
86) -> Result<Vec<(String, PathBuf, PathBuf)>, Box<dyn std::error::Error>> {
87    let Ok(entries) = std::fs::read_dir(contract_dir) else {
88        return Err(format!("cannot read {}", contract_dir.display()).into());
89    };
90
91    let mut repos: Vec<(String, PathBuf, PathBuf)> = Vec::new();
92    for entry in entries.flatten() {
93        if let Some(repo) = classify_repo_entry(&entry, src_root, repo_filter) {
94            repos.push(repo);
95        }
96    }
97    repos.sort_by(|a, b| a.0.cmp(&b.0));
98    Ok(repos)
99}
100
101/// Return `Some((name, repo_path, binding_path))` if `entry` is a valid repo for kaizen.
102fn classify_repo_entry(
103    entry: &std::fs::DirEntry,
104    src_root: &Path,
105    repo_filter: Option<&str>,
106) -> Option<(String, PathBuf, PathBuf)> {
107    let path = entry.path();
108    if !path.is_dir() {
109        return None;
110    }
111    let name = path
112        .file_name()
113        .and_then(|n| n.to_str())
114        .unwrap_or("")
115        .to_string();
116    if matches!(name.as_str(), "kaizen" | "legacy" | "pipelines") {
117        return None;
118    }
119    let binding_path = path.join("binding.yaml");
120    if !binding_path.exists() {
121        return None;
122    }
123    if let Some(filter) = repo_filter {
124        if name != filter {
125            return None;
126        }
127    }
128    let repo_path = resolve_repo_path(src_root, &name, &binding_path);
129    if !repo_path.exists() {
130        return None;
131    }
132    Some((name, repo_path, binding_path))
133}
134
135/// Print the header with the configured operating mode and repo count.
136fn print_kaizen_banner(num_repos: usize, do_fix: bool, do_codegen: bool) {
137    println!("pv kaizen — fleet enforcement improvement");
138    println!("==========================================\n");
139    let mode = if do_fix {
140        "fix (inject + validate)"
141    } else if do_codegen {
142        "codegen (regenerate macros)"
143    } else {
144        "measure (dry-run)"
145    };
146    println!("Mode: {mode}");
147    println!("Repos: {num_repos}\n");
148}
149
150/// Produce a `RepoReport` for one repo, or `None` if the binding failed to parse.
151fn process_repo(
152    name: &str,
153    repo_path: &Path,
154    binding_path: &Path,
155    contract_dir: &Path,
156    do_codegen: bool,
157    do_fix: bool,
158) -> Option<RepoReport> {
159    let binding = match parse_binding(binding_path) {
160        Ok(b) => b,
161        Err(e) => {
162            eprintln!("  warning: {name}: {e}");
163            return None;
164        }
165    };
166
167    let implemented_bindings = binding
168        .bindings
169        .iter()
170        .filter(|b| b.status == ImplStatus::Implemented)
171        .count();
172
173    let src_dir = repo_path.join("src");
174    let scan_dirs = collect_scan_dirs(repo_path, &src_dir);
175    if scan_dirs.is_empty() {
176        return None;
177    }
178
179    let gen_path = src_dir.join("generated_contracts.rs");
180    let sites_before = scan_and_classify(&scan_dirs, &gen_path);
181    let assertions_before =
182        count_assertions(&std::fs::read_to_string(&gen_path).unwrap_or_default());
183
184    let mut report =
185        build_initial_report(name, implemented_bindings, &sites_before, assertions_before);
186
187    if do_codegen || do_fix {
188        apply_codegen_step(contract_dir, &gen_path, name, &mut report);
189    }
190
191    if do_fix {
192        apply_fix_step(
193            &src_dir,
194            &scan_dirs,
195            &gen_path,
196            repo_path,
197            &binding,
198            name,
199            &mut report,
200        );
201    }
202
203    Some(report)
204}
205
206/// Collect every scannable source directory below `repo_path` (top-level + workspace subcrates).
207fn collect_scan_dirs(repo_path: &Path, src_dir: &Path) -> Vec<PathBuf> {
208    let mut scan_dirs: Vec<PathBuf> = Vec::new();
209    if src_dir.exists() {
210        scan_dirs.push(src_dir.to_path_buf());
211    }
212    let crates_dir = repo_path.join("crates");
213    if crates_dir.exists() {
214        if let Ok(crate_entries) = std::fs::read_dir(&crates_dir) {
215            for crate_entry in crate_entries.flatten() {
216                let crate_src = crate_entry.path().join("src");
217                if crate_src.exists() {
218                    scan_dirs.push(crate_src);
219                }
220            }
221        }
222    }
223    if let Ok(top_entries) = std::fs::read_dir(repo_path) {
224        for entry in top_entries.flatten() {
225            if let Some(member_src) = workspace_member_src(&entry) {
226                scan_dirs.push(member_src);
227            }
228        }
229    }
230    scan_dirs
231}
232
233/// Return the `src/` directory for a top-level workspace-member crate, if `entry` is one.
234fn workspace_member_src(entry: &std::fs::DirEntry) -> Option<PathBuf> {
235    let path = entry.path();
236    if !path.is_dir() {
237        return None;
238    }
239    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
240    if matches!(name, "target" | ".git" | "crates" | "src") {
241        return None;
242    }
243    let member_src = path.join("src");
244    if member_src.exists() && path.join("Cargo.toml").exists() {
245        Some(member_src)
246    } else {
247        None
248    }
249}
250
251/// Scan every dir in `scan_dirs` for call sites, then classify each site's E-level via `gen_path`.
252fn scan_and_classify(scan_dirs: &[PathBuf], gen_path: &Path) -> Vec<CallSite> {
253    let mut sites: Vec<CallSite> = Vec::new();
254    for dir in scan_dirs {
255        scan_call_sites(dir, &mut sites);
256    }
257    let gen_content = std::fs::read_to_string(gen_path).unwrap_or_default();
258    for site in &mut sites {
259        site.level = classify_macro(&site.macro_name, &gen_content);
260    }
261    sites
262}
263
264/// Count `ELevel::E0/E1/E2` occurrences in `sites` and return them as `(e0, e1, e2)`.
265fn count_levels(sites: &[CallSite]) -> (usize, usize, usize) {
266    let e0 = sites.iter().filter(|s| s.level == ELevel::E0).count();
267    let e1 = sites.iter().filter(|s| s.level == ELevel::E1).count();
268    let e2 = sites.iter().filter(|s| s.level == ELevel::E2).count();
269    (e0, e1, e2)
270}
271
272/// Build the baseline `RepoReport` from the pre-fix scan.
273fn build_initial_report(
274    name: &str,
275    implemented_bindings: usize,
276    sites_before: &[CallSite],
277    assertions_before: usize,
278) -> RepoReport {
279    let (e0, e1, e2) = count_levels(sites_before);
280    RepoReport {
281        name: name.to_string(),
282        bindings: implemented_bindings,
283        call_sites_before: sites_before.len(),
284        call_sites_after: sites_before.len(),
285        e0,
286        e1,
287        e2,
288        assertions_before,
289        assertions_after: assertions_before,
290        codegen_ok: true,
291        check_ok: None,
292        injection_count: 0,
293    }
294}
295
296/// Run codegen, write the generated contracts module, and update report assertion counts.
297fn apply_codegen_step(contract_dir: &Path, gen_path: &Path, name: &str, report: &mut RepoReport) {
298    let contracts = codegen::generate_all(contract_dir);
299    if contracts.is_empty() {
300        return;
301    }
302    match codegen::write_rust_module(&contracts, gen_path) {
303        Ok(()) => {
304            let new_content = std::fs::read_to_string(gen_path).unwrap_or_default();
305            report.assertions_after = count_assertions(&new_content);
306            report.codegen_ok = true;
307        }
308        Err(e) => {
309            eprintln!("  {name}: codegen failed: {e}");
310            report.codegen_ok = false;
311        }
312    }
313}
314
315/// Inject call sites, re-scan to refresh level counts, and run `cargo check` to validate.
316#[allow(clippy::too_many_arguments)]
317fn apply_fix_step(
318    src_dir: &Path,
319    scan_dirs: &[PathBuf],
320    gen_path: &Path,
321    repo_path: &Path,
322    binding: &BindingRegistry,
323    name: &str,
324    report: &mut RepoReport,
325) {
326    let gen_content_new = std::fs::read_to_string(gen_path).unwrap_or_default();
327    report.injection_count = inject_call_sites(src_dir, binding, &gen_content_new);
328
329    let sites_after = scan_and_classify(scan_dirs, gen_path);
330    let (e0, e1, e2) = count_levels(&sites_after);
331    report.call_sites_after = sites_after.len();
332    report.e0 = e0;
333    report.e1 = e1;
334    report.e2 = e2;
335
336    report.check_ok = Some(run_cargo_check(repo_path, name));
337}
338
339/// Run `cargo check` in `repo_path`; log a diagnostic and return false on failure.
340fn run_cargo_check(repo_path: &Path, name: &str) -> bool {
341    let check = std::process::Command::new("cargo")
342        .args(["check", "--message-format=short"])
343        .current_dir(repo_path)
344        .output();
345    match check {
346        Ok(output) => {
347            if !output.status.success() {
348                eprintln!("  {name}: cargo check failed, reverting injections");
349            }
350            output.status.success()
351        }
352        Err(e) => {
353            eprintln!("  {name}: cargo check error: {e}");
354            false
355        }
356    }
357}
358
359/// Fail the command if the computed fleet score falls below an optional threshold.
360fn enforce_min_score(reports: &[RepoReport], min_score: Option<f64>) {
361    if let Some(threshold) = min_score {
362        let fleet_score = compute_fleet_score(reports);
363        if fleet_score < threshold {
364            eprintln!("\nEnforcement score {fleet_score:.4} below threshold {threshold:.4}");
365            std::process::exit(1);
366        }
367    }
368}
369
370/// Scan `.rs` files recursively for contract macro call sites.
371fn scan_call_sites(dir: &Path, sites: &mut Vec<CallSite>) {
372    let Ok(entries) = std::fs::read_dir(dir) else {
373        return;
374    };
375    for entry in entries.flatten() {
376        let path = entry.path();
377        if path.is_dir() {
378            scan_call_sites(&path, sites);
379        } else if path.extension().and_then(|e| e.to_str()) == Some("rs")
380            && path
381                .file_name()
382                .and_then(|n| n.to_str())
383                .is_some_and(|n| n != "generated_contracts.rs")
384        {
385            let Ok(content) = std::fs::read_to_string(&path) else {
386                continue;
387            };
388            for (i, line) in content.lines().enumerate() {
389                let trimmed = line.trim();
390                if trimmed.starts_with("//") || trimmed.starts_with('#') {
391                    continue;
392                }
393                for prefix in &["contract_pre_", "contract_post_"] {
394                    if let Some(pos) = line.find(prefix) {
395                        let rest = &line[pos..];
396                        let end = rest.find('!').unwrap_or(rest.len());
397                        let macro_name = rest[..end].to_string();
398                        sites.push(CallSite {
399                            file: path.clone(),
400                            line: i + 1,
401                            macro_name,
402                            level: ELevel::E0,
403                        });
404                    }
405                }
406            }
407        }
408    }
409}
410
411/// Classify a macro's enforcement level by inspecting the generated file.
412fn classify_macro(macro_name: &str, gen_content: &str) -> ELevel {
413    let pattern = format!("macro_rules! {macro_name} {{");
414    let Some(start) = gen_content.find(&pattern) else {
415        return ELevel::E0;
416    };
417    let body: String = gen_content[start..]
418        .lines()
419        .take(20)
420        .collect::<Vec<_>>()
421        .join("\n");
422
423    let has_domain_pre = body.contains("is_finite")
424        || body.contains("len() >")
425        || body.contains("len() %")
426        || body.contains("len() ==")
427        || body.contains("is_empty()")
428        || body.contains("size_of_val");
429
430    let post_name = macro_name.replace("contract_pre_", "contract_post_");
431    let has_post = gen_content.contains(&format!("macro_rules! {post_name} {{"));
432
433    if has_domain_pre && has_post {
434        ELevel::E2
435    } else if has_domain_pre {
436        ELevel::E1
437    } else {
438        ELevel::E0
439    }
440}
441
442/// Count `debug_assert` lines in generated contracts file.
443fn count_assertions(content: &str) -> usize {
444    content
445        .lines()
446        .filter(|l| l.contains("debug_assert!"))
447        .count()
448}
449
450/// Inject contract call sites into functions that have bindings but no existing call site.
451fn inject_call_sites(src_dir: &Path, binding: &BindingRegistry, gen_content: &str) -> usize {
452    let mut injected = 0;
453
454    let mut existing: Vec<CallSite> = Vec::new();
455    scan_call_sites(src_dir, &mut existing);
456    let existing_macros: std::collections::HashSet<String> =
457        existing.iter().map(|s| s.macro_name.clone()).collect();
458
459    for b in &binding.bindings {
460        if b.status != ImplStatus::Implemented {
461            continue;
462        }
463
464        let eq = b.equation.replace('-', "_").to_lowercase();
465        let macro_name = format!("contract_pre_{eq}");
466
467        if !gen_content.contains(&format!("macro_rules! {macro_name} {{")) {
468            continue;
469        }
470
471        if existing_macros.contains(&macro_name) {
472            continue;
473        }
474
475        let fn_name = match &b.function {
476            Some(f) => f.clone(),
477            None => continue,
478        };
479
480        if let Some((file, insert_line, arg)) = find_function_insertion_point(src_dir, &fn_name) {
481            let Ok(content) = std::fs::read_to_string(&file) else {
482                continue;
483            };
484            let lines: Vec<&str> = content.lines().collect();
485            if insert_line == 0 || insert_line > lines.len() {
486                continue;
487            }
488
489            let indent = detect_indent(lines.get(insert_line).unwrap_or(&""));
490            let injection = format!("{indent}{macro_name}!({arg});");
491
492            let mut new_lines: Vec<String> = Vec::with_capacity(lines.len() + 1);
493            for (i, line) in lines.iter().enumerate() {
494                new_lines.push((*line).to_string());
495                if i + 1 == insert_line {
496                    new_lines.push(injection.clone());
497                }
498            }
499
500            let new_content = new_lines.join("\n");
501            let new_content = if content.ends_with('\n') {
502                format!("{new_content}\n")
503            } else {
504                new_content
505            };
506
507            if std::fs::write(&file, new_content).is_ok() {
508                injected += 1;
509            }
510        }
511    }
512
513    injected
514}
515
516/// Find where to inject a contract macro call in a function.
517fn find_function_insertion_point(
518    src_dir: &Path,
519    fn_name: &str,
520) -> Option<(PathBuf, usize, String)> {
521    let mut rs_files = Vec::new();
522    collect_rs_files(src_dir, &mut rs_files);
523
524    let fn_pattern = format!("fn {fn_name}");
525
526    for file in &rs_files {
527        let fname = file.file_name().and_then(|n| n.to_str()).unwrap_or("");
528        if fname == "generated_contracts.rs" || fname.contains("test") {
529            continue;
530        }
531
532        let Ok(content) = std::fs::read_to_string(file) else {
533            continue;
534        };
535
536        let line_count = content.lines().count();
537
538        for (i, line) in content.lines().enumerate() {
539            let trimmed = line.trim();
540            if trimmed.starts_with("//") {
541                continue;
542            }
543            if !trimmed.contains(&fn_pattern) || !trimmed.contains("fn ") {
544                continue;
545            }
546
547            let param = extract_first_param(&content, i);
548
549            let mut brace_line = i;
550            let search_end = line_count.min(i + 10);
551            for j in i..search_end {
552                if content.lines().nth(j).is_some_and(|l| l.contains('{')) {
553                    brace_line = j;
554                    break;
555                }
556            }
557
558            let insert_line = skip_early_returns(&content, brace_line + 1);
559
560            return Some((file.clone(), insert_line, param));
561        }
562    }
563    None
564}
565
566/// Extract the first meaningful parameter name from a function signature.
567fn extract_first_param(full_content: &str, line_idx: usize) -> String {
568    let mut sig = String::new();
569    for line in full_content.lines().skip(line_idx).take(10) {
570        sig.push_str(line);
571        if line.contains(')') {
572            break;
573        }
574    }
575
576    let Some(paren_start) = sig.find('(') else {
577        return "input".to_string();
578    };
579    let Some(paren_end) = sig[paren_start..].find(')') else {
580        return "input".to_string();
581    };
582    let params = &sig[paren_start + 1..paren_start + paren_end];
583
584    for param in params.split(',') {
585        let param = param.trim();
586        if param.is_empty() || param.starts_with("self") || param == "&self" || param == "&mut self"
587        {
588            continue;
589        }
590        let name = param
591            .split(':')
592            .next()
593            .unwrap_or("input")
594            .trim()
595            .trim_start_matches('&')
596            .trim_start_matches("mut ")
597            .trim();
598        if !name.is_empty() {
599            return name.to_string();
600        }
601    }
602
603    "input".to_string()
604}
605
606/// Skip past early-return guard clauses to find the right insertion point.
607fn skip_early_returns(content: &str, start_line: usize) -> usize {
608    let lines: Vec<&str> = content.lines().collect();
609    let mut line = start_line;
610
611    while line < lines.len() {
612        let trimmed = lines[line].trim();
613        if trimmed.is_empty() || trimmed.starts_with("//") {
614            line += 1;
615            continue;
616        }
617        if trimmed.starts_with("if ") && trimmed.contains("return") {
618            line += 1;
619            continue;
620        }
621        if trimmed.starts_with("if ") {
622            let mut has_return = false;
623            let guard_end = lines.len().min(line + 5);
624            for guard_line in &lines[line + 1..guard_end] {
625                if guard_line.trim().starts_with("return") {
626                    has_return = true;
627                }
628                if guard_line.contains('}') {
629                    break;
630                }
631            }
632            if has_return {
633                let brace_end = lines.len().min(line + 10);
634                for (offset, brace_line) in lines[line + 1..brace_end].iter().enumerate() {
635                    if brace_line.trim().starts_with('}') || brace_line.contains('}') {
636                        line = line + 1 + offset + 1;
637                        break;
638                    }
639                }
640                continue;
641            }
642        }
643        break;
644    }
645
646    line
647}
648
649fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
650    let Ok(entries) = std::fs::read_dir(dir) else {
651        return;
652    };
653    for entry in entries.flatten() {
654        let path = entry.path();
655        if path.is_dir() {
656            collect_rs_files(&path, out);
657        } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
658            out.push(path);
659        }
660    }
661}
662
663fn detect_indent(line: &str) -> String {
664    let trimmed = line.trim_start();
665    let indent_len = line.len() - trimmed.len();
666    line[..indent_len].to_string()
667}
668
669fn enforcement_grade(score: f64) -> &'static str {
670    if score >= 0.60 {
671        "Grade A"
672    } else if score >= 0.40 {
673        "Grade B"
674    } else if score >= 0.25 {
675        "Grade C"
676    } else if score >= 0.10 {
677        "Grade D"
678    } else {
679        "Grade F"
680    }
681}
682
683/// Penetration-only grade for tool tier (E0 is acceptable).
684fn pen_grade(pen: f64) -> &'static str {
685    if pen >= 0.90 {
686        "Grade A"
687    } else if pen >= 0.75 {
688        "Grade B"
689    } else if pen >= 0.50 {
690        "Grade C"
691    } else if pen >= 0.25 {
692        "Grade D"
693    } else {
694        "Grade F"
695    }
696}
697
698/// Per-repo grade based on its own enforcement score.
699#[allow(clippy::cast_precision_loss)]
700fn repo_grade(r: &RepoReport) -> String {
701    if r.bindings == 0 {
702        return "-".to_string();
703    }
704    let sites = r.call_sites_after;
705    if sites == 0 {
706        return "F".to_string();
707    }
708    let pen = sites as f64 / r.bindings as f64;
709    let qual = (r.e0 as f64 * 0.1 + r.e1 as f64 * 0.5 + r.e2 as f64) / sites as f64;
710    let score = pen * qual;
711    if score >= 0.60 {
712        "A"
713    } else if score >= 0.40 {
714        "B"
715    } else if score >= 0.25 {
716        "C"
717    } else if score >= 0.10 {
718        "D"
719    } else {
720        "F"
721    }
722    .to_string()
723}
724
725/// Compute fleet-wide enforcement score.
726#[allow(clippy::cast_precision_loss)]
727fn compute_fleet_score(reports: &[RepoReport]) -> f64 {
728    let total_bindings: usize = reports.iter().map(|r| r.bindings).sum();
729    let total_sites: usize = reports.iter().map(|r| r.call_sites_after).sum();
730    let total_e0: usize = reports.iter().map(|r| r.e0).sum();
731    let total_e1: usize = reports.iter().map(|r| r.e1).sum();
732    let total_e2: usize = reports.iter().map(|r| r.e2).sum();
733
734    if total_bindings == 0 || total_sites == 0 {
735        return 0.0;
736    }
737
738    let penetration = total_sites as f64 / total_bindings as f64;
739    let quality = (total_e0 as f64 * 0.1 + total_e1 as f64 * 0.5 + total_e2 as f64 * 1.0)
740        / total_sites as f64;
741
742    penetration * quality
743}
744
745/// Print text report.
746#[allow(clippy::cast_precision_loss)]
747#[allow(clippy::too_many_lines)]
748fn print_text_report(reports: &[RepoReport], _dry_run: bool, do_fix: bool) {
749    let total_bindings: usize = reports.iter().map(|r| r.bindings).sum();
750    let total_before: usize = reports.iter().map(|r| r.call_sites_before).sum();
751    let total_after: usize = reports.iter().map(|r| r.call_sites_after).sum();
752    let total_e0: usize = reports.iter().map(|r| r.e0).sum();
753    let total_e1: usize = reports.iter().map(|r| r.e1).sum();
754    let total_e2: usize = reports.iter().map(|r| r.e2).sum();
755    let total_injected: usize = reports.iter().map(|r| r.injection_count).sum();
756    let assertions_before: usize = reports.iter().map(|r| r.assertions_before).sum();
757    let assertions_after: usize = reports.iter().map(|r| r.assertions_after).sum();
758    let check_failures: usize = reports.iter().filter(|r| r.check_ok == Some(false)).count();
759
760    #[allow(clippy::cast_precision_loss)]
761    let pen_before = if total_bindings > 0 {
762        total_before as f64 / total_bindings as f64 * 100.0
763    } else {
764        0.0
765    };
766    #[allow(clippy::cast_precision_loss)]
767    let pen_after = if total_bindings > 0 {
768        total_after as f64 / total_bindings as f64 * 100.0
769    } else {
770        0.0
771    };
772
773    let fleet_score = compute_fleet_score(reports);
774
775    println!("\nFleet Enforcement Report");
776    println!("========================\n");
777    println!("  Repos:              {}", reports.len());
778    println!("  Total bindings:     {total_bindings}");
779
780    if do_fix {
781        println!("  Call sites:         {total_before} -> {total_after} (+{total_injected})");
782        println!("  Penetration:        {pen_before:.1}% -> {pen_after:.1}%");
783    } else {
784        println!("  Call sites:         {total_after}");
785        println!("  Penetration:        {pen_after:.1}%");
786    }
787
788    println!();
789    println!("  E0 (generic):       {total_e0}");
790    println!("  E1 (domain pre):    {total_e1}");
791    println!("  E2 (pre + post):    {total_e2}");
792    println!();
793
794    if assertions_before == assertions_after {
795        println!("  Assertions:         {assertions_after}");
796    } else {
797        println!("  Assertions:         {assertions_before} -> {assertions_after}");
798    }
799
800    println!(
801        "  Enforcement:        {fleet_score:.4} ({})",
802        enforcement_grade(fleet_score)
803    );
804
805    if check_failures > 0 {
806        println!("  Check failures:     {check_failures}");
807    }
808
809    // Tiered scoring: kernel repos vs tool repos
810    let kernel_repos = ["aprender", "entrenar", "realizar", "trueno"];
811    let kernel: Vec<&RepoReport> = reports
812        .iter()
813        .filter(|r| kernel_repos.contains(&r.name.as_str()))
814        .collect();
815    let tool: Vec<&RepoReport> = reports
816        .iter()
817        .filter(|r| !kernel_repos.contains(&r.name.as_str()))
818        .collect();
819
820    if !kernel.is_empty() {
821        let k_bind: usize = kernel.iter().map(|r| r.bindings).sum();
822        let k_sites: usize = kernel.iter().map(|r| r.call_sites_after).sum();
823        let k_e0: usize = kernel.iter().map(|r| r.e0).sum();
824        let k_e1: usize = kernel.iter().map(|r| r.e1).sum();
825        let k_e2: usize = kernel.iter().map(|r| r.e2).sum();
826        #[allow(clippy::cast_precision_loss)]
827        let k_pen = if k_bind > 0 {
828            k_sites as f64 / k_bind as f64
829        } else {
830            0.0
831        };
832        #[allow(clippy::cast_precision_loss)]
833        let k_qual = if k_sites > 0 {
834            (k_e0 as f64 * 0.1 + k_e1 as f64 * 0.5 + k_e2 as f64) / k_sites as f64
835        } else {
836            0.0
837        };
838        #[allow(clippy::cast_precision_loss)]
839        let k_e2_pct = if k_sites > 0 {
840            k_e2 as f64 / k_sites as f64 * 100.0
841        } else {
842            0.0
843        };
844        let k_score = k_pen * k_qual;
845
846        let t_bind: usize = tool.iter().map(|r| r.bindings).sum();
847        let t_sites: usize = tool.iter().map(|r| r.call_sites_after).sum();
848        #[allow(clippy::cast_precision_loss)]
849        let t_pen = if t_bind > 0 {
850            t_sites as f64 / t_bind as f64 * 100.0
851        } else {
852            0.0
853        };
854
855        println!();
856        println!("  Tiered:");
857        println!(
858            "    Kernel (4 repos):  {} — {k_sites}/{k_bind} sites, E2 {k_e2_pct:.0}%, pen {:.1}%",
859            enforcement_grade(k_score),
860            k_pen * 100.0
861        );
862        println!(
863            "    Tool ({} repos):   {} — {t_sites}/{t_bind} sites, pen {t_pen:.1}%",
864            tool.len(),
865            pen_grade(t_pen / 100.0)
866        );
867    }
868
869    println!();
870    println!(
871        "  {:<20} {:>8} {:>8} {:>5} {:>5} {:>5} {:>6}",
872        "Repo", "Bindings", "Sites", "E0", "E1", "E2", "Grade"
873    );
874    println!("  {}", "-".repeat(63));
875
876    for r in reports {
877        let grade = if let Some(ok) = r.check_ok {
878            if ok {
879                repo_grade(r)
880            } else {
881                "FAIL".to_string()
882            }
883        } else {
884            repo_grade(r)
885        };
886
887        let sites_str = if do_fix && r.injection_count > 0 {
888            format!("{}>{}", r.call_sites_before, r.call_sites_after)
889        } else {
890            format!("{}", r.call_sites_after)
891        };
892
893        println!(
894            "  {:<20} {:>8} {:>8} {:>5} {:>5} {:>5} {:>6}",
895            r.name, r.bindings, sites_str, r.e0, r.e1, r.e2, grade
896        );
897    }
898}
899
900/// Print JSON report for CI integration.
901#[allow(clippy::cast_precision_loss)]
902fn print_json_report(reports: &[RepoReport]) {
903    let fleet_score = compute_fleet_score(reports);
904    let total_bindings: usize = reports.iter().map(|r| r.bindings).sum();
905    let total_sites: usize = reports.iter().map(|r| r.call_sites_after).sum();
906
907    let kernel_repos = ["aprender", "entrenar", "realizar", "trueno"];
908    let kernel: Vec<&RepoReport> = reports
909        .iter()
910        .filter(|r| kernel_repos.contains(&r.name.as_str()))
911        .collect();
912    let k_bind: usize = kernel.iter().map(|r| r.bindings).sum();
913    let k_sites: usize = kernel.iter().map(|r| r.call_sites_after).sum();
914    let k_e2: usize = kernel.iter().map(|r| r.e2).sum();
915    let k_e2_pct = if k_sites > 0 {
916        k_e2 as f64 / k_sites as f64
917    } else {
918        0.0
919    };
920    let kernel_score = compute_fleet_score(&kernel.iter().copied().cloned().collect::<Vec<_>>());
921
922    println!("{{");
923    println!("  \"fleet_score\": {fleet_score:.4},");
924    println!("  \"kernel_score\": {kernel_score:.4},");
925    println!("  \"kernel_e2_pct\": {k_e2_pct:.4},");
926    println!("  \"total_bindings\": {total_bindings},");
927    println!("  \"total_call_sites\": {total_sites},");
928    println!("  \"kernel_bindings\": {k_bind},");
929    println!("  \"kernel_call_sites\": {k_sites},");
930    println!("  \"kernel_e2\": {k_e2},");
931    println!("  \"repos\": [");
932    for (i, r) in reports.iter().enumerate() {
933        let comma = if i + 1 < reports.len() { "," } else { "" };
934        println!(
935            "    {{\"name\": \"{}\", \"bindings\": {}, \"call_sites\": {}, \
936             \"e0\": {}, \"e1\": {}, \"e2\": {}, \"injection_count\": {}}}{comma}",
937            r.name, r.bindings, r.call_sites_after, r.e0, r.e1, r.e2, r.injection_count
938        );
939    }
940    println!("  ]");
941    println!("}}");
942}
943
944fn resolve_repo_path(src_root: &Path, name: &str, binding_path: &Path) -> PathBuf {
945    std::fs::read_to_string(binding_path)
946        .ok()
947        .and_then(|c| {
948            c.lines().find_map(|l| {
949                l.trim()
950                    .strip_prefix("source_dir:")
951                    .map(|v| src_root.join(v.trim()))
952            })
953        })
954        .filter(|p| p.exists())
955        .unwrap_or_else(|| src_root.join(name))
956}