1use 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
81fn 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
101fn 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
135fn 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
150fn 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
206fn 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
233fn 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
251fn 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
264fn 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
272fn 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
296fn 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#[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
339fn 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
359fn 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
370fn 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
411fn 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
442fn count_assertions(content: &str) -> usize {
444 content
445 .lines()
446 .filter(|l| l.contains("debug_assert!"))
447 .count()
448}
449
450fn 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(¯o_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
516fn 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
566fn 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
606fn 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
683fn 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#[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#[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#[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 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#[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}