1use std::path::Path;
4
5use std::collections::HashSet;
6
7use provable_contracts::binding::BindingRegistry;
8use provable_contracts::query::ContractIndex;
9use provable_contracts::schema::parse_contract;
10use provable_contracts::scoring;
11use provable_contracts::scoring::drift;
12use provable_contracts::scoring::pvscore_10dim;
13use provable_contracts::scoring::{CodebaseScore, ContractScore, Grade, ScoringWeights};
14use serde_json::Value;
15
16use crate::json_obj::obj;
17
18#[allow(clippy::too_many_arguments)]
19pub fn run(
20 path: &Path,
21 binding: Option<&Path>,
22 format: &str,
23 min_score: Option<f64>,
24 summary: bool,
25 top_gaps: usize,
26 weights_json: Option<&str>,
27 pvscore: bool,
28) -> Result<(), Box<dyn std::error::Error>> {
29 let binding_registry = binding
30 .map(|p| {
31 let content = std::fs::read_to_string(p)?;
32 let reg: BindingRegistry = serde_yaml::from_str(&content)?;
33 Ok::<_, Box<dyn std::error::Error>>(reg)
34 })
35 .transpose()?;
36
37 let weights = match weights_json {
38 Some(json) => serde_json::from_str::<ScoringWeights>(json)?,
39 None => ScoringWeights::default(),
40 };
41
42 if path.is_dir() {
43 run_directory(
44 path,
45 binding_registry.as_ref(),
46 binding,
47 format,
48 min_score,
49 summary,
50 top_gaps,
51 &weights,
52 pvscore,
53 )
54 } else {
55 run_single(path, binding_registry.as_ref(), format, min_score, &weights)
56 }
57}
58
59fn run_single(
60 path: &Path,
61 binding: Option<&BindingRegistry>,
62 format: &str,
63 min_score: Option<f64>,
64 weights: &ScoringWeights,
65) -> Result<(), Box<dyn std::error::Error>> {
66 let contract = parse_contract(path)?;
67 let stem = path
68 .file_stem()
69 .and_then(|s| s.to_str())
70 .unwrap_or("unknown");
71 let score = scoring::score_contract_weighted(&contract, binding, stem, weights);
72
73 match format {
74 "json" => println!("{}", serde_json::to_string_pretty(&score)?),
75 "markdown" => print!("{}", score_to_markdown(&score)),
76 _ => {
77 print!("{score}");
78 print_probes(&score);
79 }
80 }
81
82 if let Some(threshold) = min_score {
83 if score.composite < threshold {
84 return Err(format!(
85 "Score {:.2} below threshold {threshold:.2}",
86 score.composite,
87 )
88 .into());
89 }
90 }
91
92 Ok(())
93}
94
95#[allow(
96 clippy::too_many_arguments,
97 clippy::too_many_lines,
98 clippy::cast_precision_loss
99)]
100fn run_directory(
101 dir: &Path,
102 binding: Option<&BindingRegistry>,
103 binding_path: Option<&Path>,
104 format: &str,
105 min_score: Option<f64>,
106 summary: bool,
107 top_gaps: usize,
108 weights: &ScoringWeights,
109 pvscore: bool,
110) -> Result<(), Box<dyn std::error::Error>> {
111 let mut yaml_paths: Vec<std::path::PathBuf> = Vec::new();
112 collect_yaml_files(dir, &mut yaml_paths);
113 yaml_paths.sort();
114
115 let mut scores = Vec::new();
116 for path in &yaml_paths {
117 let Ok(contract) = parse_contract(path) else {
118 continue;
119 };
120 let stem = path
121 .file_stem()
122 .and_then(|s| s.to_str())
123 .unwrap_or("unknown");
124 scores.push(scoring::score_contract_weighted(
125 &contract, binding, stem, weights,
126 ));
127 }
128
129 let mean: f64 = if scores.is_empty() {
130 0.0
131 } else {
132 scores.iter().map(|s| s.composite).sum::<f64>() / scores.len() as f64
133 };
134
135 if summary {
136 print_summary_only(&scores, mean, format)?;
137 } else {
138 print_directory_scores(&scores, mean, format)?;
139 }
140
141 if top_gaps > 0 && !scores.is_empty() {
143 print_top_gaps(&scores, top_gaps, format);
144 }
145
146 if let Some(binding) = binding {
148 let mut parsed = Vec::new();
149 for path in &yaml_paths {
150 let Ok(contract) = parse_contract(path) else {
151 continue;
152 };
153 let stem = path
154 .file_name()
155 .and_then(|s| s.to_str())
156 .unwrap_or("unknown")
157 .to_string();
158 parsed.push((stem, contract));
159 }
160 let refs: Vec<_> = parsed.iter().map(|(s, c)| (s.clone(), c)).collect();
161
162 let pagerank = ContractIndex::from_directory(dir).ok().map(|idx| {
164 idx.entries
165 .iter()
166 .filter_map(|e| idx.cached_pagerank(&e.stem).map(|s| (e.stem.clone(), s)))
167 .collect::<std::collections::HashMap<String, f64>>()
168 });
169
170 let drift_score = binding_path.map(|bp| {
172 let bound_stems: HashSet<&str> = binding
173 .bindings
174 .iter()
175 .map(|b| b.contract.as_str())
176 .collect();
177 let stale = drift::detect_stale_contracts(dir, bp, &bound_stems);
178 drift::compute_drift(stale.len(), bound_stems.len())
179 });
180
181 let codebase = scoring::score_codebase_full(&refs, binding, pagerank.as_ref(), drift_score);
182
183 match format {
184 "json" => {
185 let output = obj([("codebase", serde_json::to_value(&codebase)?)]);
186 println!("{}", serde_json::to_string_pretty(&output)?);
187 }
188 "markdown" => {
189 println!("\n## Codebase Score\n");
190 println!("| Dimension | Value |");
191 println!("|-----------|-------|");
192 println!("| Coverage | {:.0}% |", codebase.contract_coverage * 100.0);
193 println!(
194 "| Binding | {:.0}% |",
195 codebase.binding_completeness * 100.0
196 );
197 println!("| Mean Score | {:.2} |", codebase.mean_contract_score);
198 println!("| Proof Depth | {:.2} |", codebase.proof_depth_dist);
199 println!("| Drift | {:.2} |", codebase.drift);
200 println!(
201 "\n**Composite:** {:.2} (Grade {})",
202 codebase.composite, codebase.grade
203 );
204 }
205 _ => println!("\n{codebase}"),
206 }
207
208 if pvscore {
209 print_pvscore(&codebase);
210 }
211 }
212
213 if let Some(threshold) = min_score {
214 if mean < threshold {
215 return Err(format!("Mean score {mean:.2} below threshold {threshold:.2}",).into());
216 }
217 }
218
219 Ok(())
220}
221
222#[allow(clippy::cast_precision_loss)]
223fn print_directory_scores(
224 scores: &[ContractScore],
225 mean: f64,
226 format: &str,
227) -> Result<(), Box<dyn std::error::Error>> {
228 match format {
229 "json" => {
230 let output = obj([
231 ("contracts", Value::from(scores.len())),
232 ("mean_score", Value::from(mean)),
233 (
234 "mean_grade",
235 Value::from(scoring::Grade::from_score(mean).to_string()),
236 ),
237 ("scores", serde_json::to_value(scores)?),
238 ]);
239 println!("{}", serde_json::to_string_pretty(&output)?);
240 }
241 "markdown" => {
242 println!("## Contract Scores\n");
243 println!("| Contract | Score | Grade | Spec | Falsify | Kani | Lean | Bind |");
244 println!("|----------|-------|-------|------|---------|------|------|------|");
245 for s in scores {
246 println!(
247 "| {} | {:.2} | {} | {:.2} | {:.2} | {:.2} | {:.2} | {:.2} |",
248 s.stem,
249 s.composite,
250 s.grade,
251 s.spec_depth,
252 s.falsification_coverage,
253 s.kani_coverage,
254 s.lean_coverage,
255 s.binding_coverage
256 );
257 }
258 println!(
259 "\n**{} contracts** — Mean: {mean:.2} (Grade {})",
260 scores.len(),
261 scoring::Grade::from_score(mean)
262 );
263 }
264 _ => {
265 for s in scores {
266 print!("{s}");
267 }
268 println!(
269 "\n{} contracts — Mean: {mean:.2} (Grade {})",
270 scores.len(),
271 scoring::Grade::from_score(mean)
272 );
273 }
274 }
275 Ok(())
276}
277
278#[allow(clippy::cast_precision_loss)]
279fn print_summary_only(
280 scores: &[ContractScore],
281 mean: f64,
282 format: &str,
283) -> Result<(), Box<dyn std::error::Error>> {
284 let grade = scoring::Grade::from_score(mean);
285 match format {
286 "json" => {
287 let output = obj([
288 ("contracts", Value::from(scores.len())),
289 ("mean_score", Value::from(mean)),
290 ("mean_grade", Value::from(grade.to_string())),
291 ]);
292 println!("{}", serde_json::to_string_pretty(&output)?);
293 }
294 "markdown" => {
295 println!(
296 "**{} contracts** — Mean: {mean:.2} (Grade {grade})",
297 scores.len()
298 );
299 }
300 _ => {
301 println!(
302 "{} contracts — Mean: {mean:.2} (Grade {grade})",
303 scores.len()
304 );
305 }
306 }
307 Ok(())
308}
309
310fn print_top_gaps(scores: &[ContractScore], n: usize, format: &str) {
311 let mut sorted: Vec<_> = scores.iter().collect();
312 sorted.sort_by(|a, b| {
313 a.composite
314 .partial_cmp(&b.composite)
315 .unwrap_or(std::cmp::Ordering::Equal)
316 });
317 let top: Vec<_> = sorted.into_iter().take(n).collect();
318
319 match format {
320 "json" => {} "markdown" => {
322 println!("\n### Top {} Gaps\n", top.len());
323 for s in &top {
324 println!("- **{}** — {:.2} ({})", s.stem, s.composite, s.grade);
325 }
326 }
327 _ => {
328 println!("\nTop {} gaps:", top.len());
329 for s in &top {
330 println!(" {} — {:.2} ({})", s.stem, s.composite, s.grade);
331 }
332 }
333 }
334}
335
336fn print_pvscore(codebase: &CodebaseScore) {
337 let pv = pvscore_10dim(codebase);
338 let grade = Grade::from_score(pv / 100.0);
339 println!("\nPVScore: {pv:.1} (Grade: {grade})");
340 println!(
341 " D1 Spec Depth: {:.1}",
342 codebase.contract_coverage * 100.0
343 );
344 println!(
345 " D2 Falsification: {:.1}",
346 codebase.binding_completeness * 100.0
347 );
348 println!(
349 " D3 Mean Score: {:.1}",
350 codebase.mean_contract_score * 100.0
351 );
352 println!(
353 " D4 Proof Depth: {:.1}",
354 codebase.proof_depth_dist * 100.0
355 );
356 println!(" D5 Drift: {:.1}", codebase.drift * 100.0);
357 println!(
358 " D6 Reverse Coverage: {:.1}{}",
359 codebase.reverse_coverage * 100.0,
360 default_suffix(codebase.reverse_coverage)
361 );
362 println!(
363 " D7 Mutation Testing: {:.1}{}",
364 codebase.mutation_testing * 100.0,
365 default_suffix(codebase.mutation_testing)
366 );
367 println!(
368 " D8 CI Pipeline: {:.1}{}",
369 codebase.ci_pipeline_depth * 100.0,
370 default_suffix(codebase.ci_pipeline_depth)
371 );
372 println!(
373 " D9 Proof Freshness: {:.1}{}",
374 codebase.proof_freshness * 100.0,
375 default_suffix(codebase.proof_freshness)
376 );
377 println!(
378 " D10 Defect Patterns: {:.1}{}",
379 codebase.defect_patterns * 100.0,
380 default_suffix(codebase.defect_patterns)
381 );
382}
383
384fn default_suffix(value: f64) -> &'static str {
385 if value == 0.0 {
386 " (default)"
387 } else {
388 ""
389 }
390}
391
392fn score_to_markdown(score: &ContractScore) -> String {
393 format!(
394 "### {}\n\n- **Score:** {:.2} (Grade {})\n- Spec: {:.2} | Falsify: {:.2} | Kani: {:.2} | Lean: {:.2} | Bind: {:.2}\n",
395 score.stem,
396 score.composite,
397 score.grade,
398 score.spec_depth,
399 score.falsification_coverage,
400 score.kani_coverage,
401 score.lean_coverage,
402 score.binding_coverage
403 )
404}
405
406fn print_probes(score: &ContractScore) {
411 if score.probes.is_empty() {
412 return;
413 }
414
415 let dimensions = [
417 ("spec_depth", "D1 Spec Depth", score.spec_depth),
418 (
419 "falsification",
420 "D2 Falsification",
421 score.falsification_coverage,
422 ),
423 ("kani", "D3 Kani", score.kani_coverage),
424 ("lean", "D4 Lean", score.lean_coverage),
425 ("binding", "D5 Binding", score.binding_coverage),
426 ];
427
428 println!(" Probes:");
429 for (dim_key, dim_label, dim_score) in &dimensions {
430 let dim_probes: Vec<_> = score
431 .probes
432 .iter()
433 .filter(|p| p.dimension == *dim_key)
434 .collect();
435 if dim_probes.is_empty() {
436 continue;
437 }
438 println!(" {dim_label:20} {dim_score:.2}");
439 for p in &dim_probes {
440 let icon = if p.outcome { "+" } else { "-" };
441 println!(" {icon} {}: {}", p.probe, p.detail);
442 }
443 }
444}
445
446fn collect_yaml_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
454 let Ok(entries) = std::fs::read_dir(dir) else {
455 return;
456 };
457 for entry in entries.flatten() {
458 let path = entry.path();
459 if path.is_dir() {
460 let dirname = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
461 if dirname == "kaizen" || dirname == "legacy" || dirname == "pipelines" {
463 continue;
464 }
465 collect_yaml_files(&path, out);
466 } else if path.extension().and_then(|e| e.to_str()) == Some("yaml")
467 && path.file_name().and_then(|n| n.to_str()) != Some("binding.yaml")
468 {
469 out.push(path);
470 }
471 }
472}