1use std::collections::{BTreeMap, HashMap, HashSet};
19use std::path::Path;
20
21use crate::ast::TopLevel;
22use crate::ir::hir::{ResolvedFnDef, ResolvedTopLevel};
23use crate::ir::{PipelineConfig, TypecheckMode};
24use crate::types::Type;
25
26#[cfg(feature = "tty-render")]
35use colored::Colorize as _shape_colorize;
36#[cfg(not(feature = "tty-render"))]
37use shape_color::Colorize as _shape_colorize;
38
39#[cfg(not(feature = "tty-render"))]
40mod shape_color {
41 pub trait Colorize {
46 fn bold(&self) -> String;
47 fn cyan(&self) -> String;
48 fn yellow(&self) -> String;
49 fn magenta(&self) -> String;
50 fn dimmed(&self) -> String;
51 }
52 impl<S: AsRef<str> + ?Sized> Colorize for S {
53 fn bold(&self) -> String {
54 self.as_ref().to_string()
55 }
56 fn cyan(&self) -> String {
57 self.as_ref().to_string()
58 }
59 fn yellow(&self) -> String {
60 self.as_ref().to_string()
61 }
62 fn magenta(&self) -> String {
63 self.as_ref().to_string()
64 }
65 fn dimmed(&self) -> String {
66 self.as_ref().to_string()
67 }
68 }
69}
70
71use crate::analysis::shape::{Archetype, ModulePattern, analyze_program_with_modules};
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum Purity {
86 Pure,
87 ClassifiedEffectful,
88 ShellEffectful,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Entry {
93 None,
94 Main,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum StateShape {
99 Stateless,
100 PureStateMachine,
101 ExternalWorld,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum TypeSurface {
106 NoTypes,
107 PlainDataTypes,
108 UserOpaque,
109 RuntimeHandle,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum ApiShape {
114 Closed,
115 ExposedLibrary,
116 ServiceBoundary,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub struct ModuleShape {
121 pub purity: Purity,
122 pub entry: Entry,
123 pub state_shape: StateShape,
124 pub type_surface: TypeSurface,
125 pub api_shape: ApiShape,
126}
127
128impl Purity {
129 pub fn as_str(&self) -> &'static str {
130 match self {
131 Purity::Pure => "Pure",
132 Purity::ClassifiedEffectful => "ClassifiedEffectful",
133 Purity::ShellEffectful => "ShellEffectful",
134 }
135 }
136
137 pub fn description(&self) -> &'static str {
141 match self {
142 Purity::Pure => "no effects declared",
143 Purity::ClassifiedEffectful => {
144 "all effects are Oracle-classified (one-shot req/resp shape)"
145 }
146 Purity::ShellEffectful => {
147 "contains at least one shell/lifecycle effect (e.g. HttpServer.listen) — Oracle skips by design"
148 }
149 }
150 }
151}
152
153impl Entry {
154 pub fn as_str(&self) -> &'static str {
155 match self {
156 Entry::None => "None",
157 Entry::Main => "Main",
158 }
159 }
160}
161
162impl StateShape {
163 pub fn as_str(&self) -> &'static str {
164 match self {
165 StateShape::Stateless => "Stateless",
166 StateShape::PureStateMachine => "PureStateMachine",
167 StateShape::ExternalWorld => "ExternalWorld",
168 }
169 }
170}
171
172impl TypeSurface {
173 pub fn as_str(&self) -> &'static str {
174 match self {
175 TypeSurface::NoTypes => "NoTypes",
176 TypeSurface::PlainDataTypes => "PlainDataTypes",
177 TypeSurface::UserOpaque => "UserOpaque",
178 TypeSurface::RuntimeHandle => "RuntimeHandle",
179 }
180 }
181}
182
183impl ApiShape {
184 pub fn as_str(&self) -> &'static str {
185 match self {
186 ApiShape::Closed => "Closed",
187 ApiShape::ExposedLibrary => "ExposedLibrary",
188 ApiShape::ServiceBoundary => "ServiceBoundary",
189 }
190 }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
194pub enum Kind {
195 PureHelpers,
196 DataModule,
197 SmartConstructor,
198 Library,
199 Orchestration,
200 ServiceClient,
201 EffectfulLibrary,
202 EffectfulShell,
203}
204
205impl Kind {
206 pub fn as_str(&self) -> &'static str {
207 match self {
208 Kind::PureHelpers => "PureHelpers",
209 Kind::DataModule => "DataModule",
210 Kind::SmartConstructor => "SmartConstructor",
211 Kind::Library => "Library",
212 Kind::Orchestration => "Orchestration",
213 Kind::ServiceClient => "ServiceClient",
214 Kind::EffectfulLibrary => "EffectfulLibrary",
215 Kind::EffectfulShell => "EffectfulShell",
216 }
217 }
218
219 pub fn rule(&self) -> &'static str {
222 match self {
223 Kind::PureHelpers => "pure module, no exposed surface, plain types",
224 Kind::DataModule => "pure module exposing library API over plain data types",
225 Kind::SmartConstructor => "pure module exposing an opaque user type",
226 Kind::Library => "classified effects with exposed API, no main",
227 Kind::Orchestration => "classified effects with main entry point",
228 Kind::ServiceClient => "classified effects threaded through a runtime handle",
229 Kind::EffectfulLibrary => "effectful library — exposed surface, no main",
230 Kind::EffectfulShell => {
231 "shell/lifecycle effects (e.g. HttpServer.listen) — long-running, not Oracle-classified"
232 }
233 }
234 }
235}
236
237const EFFECTFUL_FN_RATIO_FOR_ORCHESTRATION: f64 = 0.3;
242
243pub fn derive_kind(shape: &ModuleShape, effectful_fn_ratio: f64) -> Kind {
244 if !matches!(shape.purity, Purity::Pure) {
251 if matches!(shape.api_shape, ApiShape::ServiceBoundary) {
257 return Kind::ServiceClient;
258 }
259 if matches!(shape.entry, Entry::Main) {
260 if effectful_fn_ratio >= EFFECTFUL_FN_RATIO_FOR_ORCHESTRATION {
270 return Kind::Orchestration;
271 }
272 }
274 if matches!(shape.api_shape, ApiShape::ExposedLibrary) {
275 return Kind::Library;
276 }
277 if matches!(shape.purity, Purity::ShellEffectful) {
278 return Kind::EffectfulShell;
279 }
280 if matches!(shape.entry, Entry::Main)
284 && effectful_fn_ratio < EFFECTFUL_FN_RATIO_FOR_ORCHESTRATION
285 {
286 return Kind::PureHelpers;
287 }
288 return Kind::EffectfulLibrary;
289 }
290 if matches!(shape.type_surface, TypeSurface::UserOpaque) {
292 return Kind::SmartConstructor;
293 }
294 if matches!(
295 shape.api_shape,
296 ApiShape::ExposedLibrary | ApiShape::ServiceBoundary
297 ) {
298 return Kind::DataModule;
299 }
300 Kind::PureHelpers
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum VerifyLevel {
307 None,
308 Cases,
309 Laws,
310 Trace,
311 Mixed,
312}
313
314impl VerifyLevel {
315 pub fn as_str(&self) -> &'static str {
316 match self {
317 VerifyLevel::None => "None",
318 VerifyLevel::Cases => "Cases",
319 VerifyLevel::Laws => "Laws",
320 VerifyLevel::Trace => "Trace",
321 VerifyLevel::Mixed => "Mixed",
322 }
323 }
324}
325
326#[derive(Debug, Clone)]
327pub struct VerifyReport {
328 pub level: VerifyLevel,
329 pub blocks: usize,
330 pub covered_fns: usize,
331 pub eligible_fns: usize,
332}
333
334#[derive(Debug, Clone, Default)]
337pub struct Histogram {
338 pub counts: BTreeMap<Archetype, usize>,
339 pub total_fns: usize,
340}
341
342impl Histogram {
343 pub fn percentage(&self, archetype: Archetype) -> f64 {
344 if self.total_fns == 0 {
345 return 0.0;
346 }
347 let c = self.counts.get(&archetype).copied().unwrap_or(0) as f64;
348 100.0 * c / self.total_fns as f64
349 }
350
351 pub fn sorted(&self) -> Vec<(Archetype, usize, f64)> {
353 let mut entries: Vec<(Archetype, usize)> = self
354 .counts
355 .iter()
356 .filter(|(_, c)| **c > 0)
357 .map(|(k, c)| (*k, *c))
358 .collect();
359 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.as_str().cmp(b.0.as_str())));
360 entries
361 .into_iter()
362 .map(|(name, count)| {
363 let pct = if self.total_fns == 0 {
364 0.0
365 } else {
366 100.0 * count as f64 / self.total_fns as f64
367 };
368 (name, count, pct)
369 })
370 .collect()
371 }
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
377pub enum Layer {
378 Domain,
379 Parse,
380 Command,
381 AiStrategy,
382 RenderUi,
383 Infra,
384}
385
386impl Layer {
387 pub fn as_str(&self) -> &'static str {
388 match self {
389 Layer::Domain => "Domain",
390 Layer::Parse => "Parse",
391 Layer::Command => "Command",
392 Layer::AiStrategy => "AiStrategy",
393 Layer::RenderUi => "RenderUi",
394 Layer::Infra => "Infra",
395 }
396 }
397
398 pub fn parse(s: &str) -> Option<Layer> {
399 match s {
400 "Domain" => Some(Layer::Domain),
401 "Parse" => Some(Layer::Parse),
402 "Command" => Some(Layer::Command),
403 "AiStrategy" | "AI-strategy" | "AIStrategy" => Some(Layer::AiStrategy),
404 "RenderUi" | "RenderUI" | "render-UI" => Some(Layer::RenderUi),
405 "Infra" => Some(Layer::Infra),
406 _ => None,
407 }
408 }
409
410 pub fn all() -> &'static [Layer] {
411 &[
412 Layer::Domain,
413 Layer::Parse,
414 Layer::Command,
415 Layer::AiStrategy,
416 Layer::RenderUi,
417 Layer::Infra,
418 ]
419 }
420}
421
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
425pub enum Bucket {
426 Match,
427 Recursion,
428 Pipeline,
429 Orchestration,
430 Helpers,
431}
432
433impl Bucket {
434 pub fn as_str(&self) -> &'static str {
435 match self {
436 Bucket::Match => "match",
437 Bucket::Recursion => "recursion",
438 Bucket::Pipeline => "pipeline",
439 Bucket::Orchestration => "orchestration",
440 Bucket::Helpers => "helpers",
441 }
442 }
443}
444
445pub fn archetype_to_bucket(archetype: Archetype) -> Option<Bucket> {
446 match archetype {
447 Archetype::MatchDispatcher | Archetype::MatchOnValue => Some(Bucket::Match),
448 Archetype::SccMutual | Archetype::StructuralRecursion => Some(Bucket::Recursion),
449 Archetype::PipelineResult | Archetype::LetPipeline | Archetype::ManualResultAdapter => {
450 Some(Bucket::Pipeline)
451 }
452 Archetype::Orchestration | Archetype::EffectfulLeaf => Some(Bucket::Orchestration),
453 Archetype::TrivialHelper
454 | Archetype::PureExpression
455 | Archetype::ConstructorWrapper
456 | Archetype::DataAsFunction
457 | Archetype::RendererFormatter => Some(Bucket::Helpers),
458 Archetype::Unclassified => None,
459 }
460}
461
462#[derive(Debug, Clone)]
463pub struct LayerFingerprint {
464 pub layer: Layer,
465 pub buckets: [(Bucket, f64); 5],
467}
468
469pub fn fingerprints_from_config(
487 entries: &[crate::config::ShapeLayerFingerprint],
488) -> Result<Vec<LayerFingerprint>, String> {
489 use Bucket::*;
490 entries
491 .iter()
492 .map(|e| {
493 let layer = Layer::parse(&e.name).ok_or_else(|| {
494 format!(
495 "aver.toml: [[shape.layer]] name '{}' is not a known Layer (expected one of: Domain, Parse, Command, AiStrategy, RenderUi, Infra)",
496 e.name
497 )
498 })?;
499 Ok(LayerFingerprint {
500 layer,
501 buckets: [
502 (Match, e.match_pct),
503 (Recursion, e.recursion_pct),
504 (Pipeline, e.pipeline_pct),
505 (Orchestration, e.orchestration_pct),
506 (Helpers, e.helpers_pct),
507 ],
508 })
509 })
510 .collect()
511}
512
513pub fn builtin_v0_layer_fingerprints() -> Vec<LayerFingerprint> {
514 use Bucket::*;
515 let mk = |layer, m, r, p, o, h| LayerFingerprint {
516 layer,
517 buckets: [
518 (Match, m),
519 (Recursion, r),
520 (Pipeline, p),
521 (Orchestration, o),
522 (Helpers, h),
523 ],
524 };
525 vec![
526 mk(Layer::Domain, 40.0, 25.0, 0.0, 5.0, 30.0),
527 mk(Layer::Parse, 15.0, 10.0, 65.0, 10.0, 0.0),
528 mk(Layer::Command, 10.0, 5.0, 75.0, 10.0, 0.0),
529 mk(Layer::AiStrategy, 60.0, 15.0, 0.0, 10.0, 15.0),
530 mk(Layer::RenderUi, 50.0, 5.0, 0.0, 25.0, 20.0),
531 mk(Layer::Infra, 20.0, 10.0, 30.0, 40.0, 0.0),
532 ]
533}
534
535fn histogram_to_buckets(hist: &Histogram) -> [(Bucket, f64); 5] {
536 use Bucket::*;
537 let mut counts: HashMap<Bucket, usize> = HashMap::new();
538 for (arch, c) in &hist.counts {
539 if let Some(b) = archetype_to_bucket(*arch) {
540 *counts.entry(b).or_insert(0) += c;
541 }
542 }
543 let total = hist.total_fns.max(1) as f64;
544 let pct = |b: Bucket| 100.0 * counts.get(&b).copied().unwrap_or(0) as f64 / total;
545 [
546 (Match, pct(Match)),
547 (Recursion, pct(Recursion)),
548 (Pipeline, pct(Pipeline)),
549 (Orchestration, pct(Orchestration)),
550 (Helpers, pct(Helpers)),
551 ]
552}
553
554#[derive(Debug, Clone)]
555pub struct LayerVerdict {
556 pub layer: Layer,
557 pub confidence: f64,
562 pub margin: f64,
566 pub uncertain: bool,
570 pub candidates: Vec<(Layer, f64)>,
574 pub basis: String,
575 pub support_fns: usize,
576}
577
578const UNCERTAIN_CONFIDENCE_THRESHOLD: f64 = 0.4;
580const UNCERTAIN_MARGIN_THRESHOLD: f64 = 10.0;
584
585pub fn classify_layer(
589 hist: &Histogram,
590 fingerprints: &[LayerFingerprint],
591 basis: &str,
592) -> Option<LayerVerdict> {
593 if fingerprints.is_empty() || hist.total_fns == 0 {
594 return None;
595 }
596 let observed = histogram_to_buckets(hist);
597 let mut scored: Vec<(Layer, f64)> = Vec::new();
598 for fp in fingerprints {
599 let mut sq = 0.0_f64;
600 for ((b1, p1), (b2, p2)) in observed.iter().zip(fp.buckets.iter()) {
601 debug_assert_eq!(b1, b2);
602 let d = p1 - p2;
603 sq += d * d;
604 }
605 scored.push((fp.layer, sq.sqrt()));
606 }
607 scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
608 let (best_layer, best_dist) = scored[0];
609 let max_dist = (5.0_f64 * 100.0 * 100.0).sqrt();
611 let raw_conf = ((max_dist - best_dist) / max_dist).clamp(0.0, 1.0);
612 let n = hist.total_fns;
613 let small_n_penalty = if n < 5 {
614 0.2_f64.min(raw_conf)
616 } else if n < 10 {
617 raw_conf * 0.7
619 } else {
620 raw_conf
621 };
622 let margin = if scored.len() >= 2 {
627 scored[1].1 - scored[0].1
628 } else {
629 f64::INFINITY
630 };
631 let uncertain =
632 small_n_penalty < UNCERTAIN_CONFIDENCE_THRESHOLD || margin < UNCERTAIN_MARGIN_THRESHOLD;
633 let candidates: Vec<(Layer, f64)> = scored.into_iter().take(3).collect();
634 Some(LayerVerdict {
635 layer: best_layer,
636 confidence: small_n_penalty,
637 margin,
638 uncertain,
639 candidates,
640 basis: basis.to_string(),
641 support_fns: n,
642 })
643}
644
645#[derive(Debug, Clone)]
648pub struct FnShape {
649 pub name: String,
650 pub primary: Archetype,
651 pub labels: Vec<Archetype>,
652}
653
654#[derive(Debug, Clone)]
655pub struct ShapeReport {
656 pub module: String,
657 pub file: String,
658 pub depends: Vec<String>,
659 pub effects: Vec<String>,
660 pub exposes_opaque: Vec<String>,
661 pub has_main: bool,
662 pub shape: ModuleShape,
663 pub kind: Kind,
664 pub effectful_fn_ratio: f64,
669 pub verify: VerifyReport,
670 pub histogram: Histogram,
671 pub layer: Option<LayerVerdict>,
672 pub fns: Vec<FnShape>,
673 pub patterns: Vec<ModulePattern>,
679}
680
681pub fn analyze_path(path: &Path, module_root_hint: Option<&str>) -> Result<ShapeReport, String> {
682 analyze_path_with(
683 path,
684 module_root_hint,
685 &builtin_v0_layer_fingerprints(),
686 "built-in v0",
687 )
688}
689
690pub fn analyze_path_with(
694 path: &Path,
695 module_root_hint: Option<&str>,
696 fingerprints: &[LayerFingerprint],
697 basis: &str,
698) -> Result<ShapeReport, String> {
699 let source = std::fs::read_to_string(path).map_err(|e| format!("read: {}", e))?;
700 let module_root = match module_root_hint {
701 Some(r) => r.to_string(),
702 None => path
703 .parent()
704 .and_then(|p| p.to_str())
705 .map(|s| s.to_string())
706 .unwrap_or_else(|| ".".to_string()),
707 };
708 let file_label = path.to_string_lossy().to_string();
709 let fallback_module_name = path
710 .file_stem()
711 .and_then(|s| s.to_str())
712 .unwrap_or("<unnamed>")
713 .to_string();
714 analyze_source_with(
715 &source,
716 &module_root,
717 &file_label,
718 &fallback_module_name,
719 fingerprints,
720 basis,
721 )
722}
723
724pub fn analyze_source_with(
731 source: &str,
732 module_root: &str,
733 file_label: &str,
734 fallback_module_name: &str,
735 fingerprints: &[LayerFingerprint],
736 basis: &str,
737) -> Result<ShapeReport, String> {
738 let mut items = crate::source::parse_source(source).map_err(|e| format!("parse: {}", e))?;
739 let module_root = module_root.to_string();
740
741 let dep_modules = crate::source::load_compile_deps(&items, &module_root)
742 .map_err(|e| format!("deps: {}", e))?;
743 let pipeline_result = crate::ir::pipeline::run(
744 &mut items,
745 PipelineConfig {
746 typecheck: Some(TypecheckMode::Full {
747 base_dir: Some(&module_root),
748 }),
749 dep_modules: &dep_modules,
750 ..Default::default()
751 },
752 );
753 if let Some(tc) = pipeline_result.typecheck.as_ref()
754 && !tc.errors.is_empty()
755 {
756 let first = tc
757 .errors
758 .first()
759 .map(|e| e.message.clone())
760 .unwrap_or_default();
761 return Err(format!("typecheck errors: {first}"));
762 }
763
764 let module = items.iter().find_map(|i| match i {
765 TopLevel::Module(m) => Some(m.clone()),
766 _ => None,
767 });
768 let (module_name, depends, effects, exposes_opaque, exposes) = match module {
769 Some(m) => (
770 m.name.clone(),
771 m.depends.clone(),
772 m.effects.clone().unwrap_or_default(),
773 m.exposes_opaque.clone(),
774 m.exposes.clone(),
775 ),
776 None => (
777 fallback_module_name.to_string(),
778 vec![],
779 vec![],
780 vec![],
781 vec![],
782 ),
783 };
784
785 let resolved_fns: Vec<&ResolvedFnDef> = pipeline_result
786 .resolved_items
787 .iter()
788 .filter_map(|t| match t {
789 ResolvedTopLevel::FnDef(fd) => Some(fd),
790 _ => None,
791 })
792 .collect();
793
794 let program_shape = analyze_program_with_modules(&resolved_fns, &items, &dep_modules);
803
804 let exposes_set: HashSet<&str> = exposes.iter().map(|s| s.as_str()).collect();
805 let mut fn_shapes = Vec::with_capacity(resolved_fns.len());
806 let mut histogram = Histogram::default();
807 let mut has_main = false;
808 let mut classified_uses_handle = false;
809 let mut exposed_uses_handle = false;
810 let mut non_main_fns = 0usize;
811 let mut effectful_non_main_fns = 0usize;
812
813 for fd in &resolved_fns {
814 if fd.name == "main" {
815 has_main = true;
816 } else {
817 non_main_fns += 1;
818 if !fd.effects.is_empty() {
819 effectful_non_main_fns += 1;
820 }
821 }
822 let this_uses_handle = uses_runtime_handle(fd);
823 if this_uses_handle {
824 classified_uses_handle = true;
825 if exposes_set.contains(fd.name.as_str()) {
826 exposed_uses_handle = true;
827 }
828 }
829 let recognition = program_shape
830 .for_fn(fd.fn_id)
831 .expect("analyze_program populates per_fn for every resolved fn");
832 let primary = recognition.primary;
833 let labels = recognition.labels.clone();
834 if fd.name != "main" {
835 *histogram.counts.entry(primary).or_insert(0) += 1;
836 histogram.total_fns += 1;
837 }
838 fn_shapes.push(FnShape {
839 name: fd.name.clone(),
840 primary,
841 labels,
842 });
843 }
844
845 let effectful_fn_ratio = if non_main_fns > 0 {
846 effectful_non_main_fns as f64 / non_main_fns as f64
847 } else {
848 0.0
851 };
852
853 let verify = compute_verify_report(&items, &resolved_fns);
854
855 let shape = derive_shape(
856 &effects,
857 has_main,
858 &exposes_opaque,
859 classified_uses_handle,
860 exposed_uses_handle,
861 &exposes,
862 );
863 let kind = derive_kind(&shape, effectful_fn_ratio);
864 let layer = classify_layer(&histogram, fingerprints, basis);
865
866 Ok(ShapeReport {
867 module: module_name,
868 file: file_label.to_string(),
869 depends,
870 effects,
871 exposes_opaque,
872 has_main,
873 shape,
874 kind,
875 effectful_fn_ratio,
876 verify,
877 histogram,
878 layer,
879 fns: fn_shapes,
880 patterns: program_shape.patterns,
881 })
882}
883
884fn uses_runtime_handle(fd: &ResolvedFnDef) -> bool {
885 fd.params.iter().any(|(_, ty)| is_runtime_handle_type(ty))
886 || is_runtime_handle_type(&fd.return_type)
887}
888
889fn is_runtime_handle_type(t: &Type) -> bool {
890 use crate::types::checker::effect_classification::is_verify_fabricable_handle;
891 walk_type_named(t, &is_verify_fabricable_handle)
892}
893
894fn walk_type_named(t: &Type, pred: &dyn Fn(&str) -> bool) -> bool {
895 match t {
896 Type::Named { name, .. } => pred(name),
897 Type::Result(a, b) | Type::Map(a, b) => {
898 walk_type_named(a, pred) || walk_type_named(b, pred)
899 }
900 Type::Option(a) | Type::List(a) | Type::Vector(a) => walk_type_named(a, pred),
901 Type::Tuple(xs) => xs.iter().any(|x| walk_type_named(x, pred)),
902 Type::Fn(args, ret, _) => {
903 args.iter().any(|a| walk_type_named(a, pred)) || walk_type_named(ret, pred)
904 }
905 _ => false,
906 }
907}
908
909fn compute_verify_report(items: &[TopLevel], resolved_fns: &[&ResolvedFnDef]) -> VerifyReport {
910 use crate::ast::VerifyKind;
911 let mut blocks = 0usize;
912 let mut covered: HashSet<String> = HashSet::new();
913 let mut has_cases = false;
914 let mut has_laws = false;
915 let mut has_trace = false;
916 for it in items {
917 if let TopLevel::Verify(v) = it {
918 blocks += 1;
919 covered.insert(v.fn_name.clone());
920 if v.trace {
921 has_trace = true;
922 } else {
923 match &v.kind {
924 VerifyKind::Cases => has_cases = true,
925 VerifyKind::Law(_) => has_laws = true,
926 }
927 }
928 }
929 }
930 let level = match (has_cases, has_laws, has_trace) {
931 (false, false, false) => VerifyLevel::None,
932 (true, false, false) => VerifyLevel::Cases,
933 (false, true, false) => VerifyLevel::Laws,
934 (false, false, true) => VerifyLevel::Trace,
935 _ => VerifyLevel::Mixed,
936 };
937 let eligible_names: HashSet<String> = resolved_fns
940 .iter()
941 .filter(|f| f.name != "main")
942 .map(|f| f.name.clone())
943 .collect();
944 let covered_count = eligible_names.intersection(&covered).count();
945 VerifyReport {
946 level,
947 blocks,
948 covered_fns: covered_count,
949 eligible_fns: eligible_names.len(),
950 }
951}
952
953fn derive_shape(
954 effects: &[String],
955 has_main: bool,
956 exposes_opaque: &[String],
957 classified_uses_handle: bool,
958 exposed_uses_handle: bool,
959 exposes: &[String],
960) -> ModuleShape {
961 use crate::types::checker::effect_classification::is_classified;
962
963 let any_effect = !effects.is_empty();
964 let all_classified = any_effect
965 && effects
966 .iter()
967 .all(|e| is_classified_effect_or_namespace(e, &is_classified));
968 let purity = if !any_effect {
969 Purity::Pure
970 } else if all_classified {
971 Purity::ClassifiedEffectful
972 } else {
973 Purity::ShellEffectful
974 };
975 let entry = if has_main { Entry::Main } else { Entry::None };
976 let state_shape = match purity {
977 Purity::Pure => StateShape::Stateless,
978 _ if classified_uses_handle => StateShape::PureStateMachine,
979 _ => StateShape::ExternalWorld,
980 };
981 let type_surface = if classified_uses_handle {
982 TypeSurface::RuntimeHandle
983 } else if !exposes_opaque.is_empty() {
984 TypeSurface::UserOpaque
985 } else if !exposes.is_empty() {
986 TypeSurface::PlainDataTypes
987 } else {
988 TypeSurface::NoTypes
989 };
990 let api_shape = if exposed_uses_handle {
997 ApiShape::ServiceBoundary
998 } else if !exposes.is_empty() {
999 ApiShape::ExposedLibrary
1000 } else {
1001 ApiShape::Closed
1002 };
1003 ModuleShape {
1004 purity,
1005 entry,
1006 state_shape,
1007 type_surface,
1008 api_shape,
1009 }
1010}
1011
1012fn is_classified_effect_or_namespace(eff: &str, is_classified: &dyn Fn(&str) -> bool) -> bool {
1013 if is_classified(eff) {
1014 return true;
1015 }
1016 let with_dot = format!("{eff}.");
1019 use crate::types::checker::effect_classification::classifications_for_proof_subset;
1020 classifications_for_proof_subset()
1021 .iter()
1022 .any(|c| c.method.starts_with(&with_dot))
1023}
1024
1025pub struct RenderOptions {
1028 pub summary: bool,
1029}
1030
1031fn render_module_pattern_line(p: &ModulePattern) -> String {
1037 let scoped = |scope: &Option<String>, name: &str| -> String {
1040 match scope {
1041 Some(prefix) => format!("{prefix}::{name}"),
1042 None => name.to_string(),
1043 }
1044 };
1045 match p {
1046 ModulePattern::RefinementSmartConstructor {
1047 scope,
1048 type_name,
1049 carrier_field,
1050 carrier_type,
1051 constructor_fn,
1052 ..
1053 } => format!(
1054 "{} {}({}: {}) via {}",
1055 "RefinementSmartConstructor".magenta().bold(),
1056 scoped(scope, type_name).bold(),
1057 carrier_field,
1058 carrier_type.cyan(),
1059 scoped(scope, constructor_fn).bold(),
1060 ),
1061 ModulePattern::WrapperOverRecursion {
1062 wrapper_scope,
1063 wrapper_fn,
1064 inner_scope,
1065 inner_fn,
1066 } => format!(
1067 "{} {} → {}",
1068 "WrapperOverRecursion".magenta().bold(),
1069 scoped(wrapper_scope, wrapper_fn).bold(),
1070 scoped(inner_scope, inner_fn).bold(),
1071 ),
1072 ModulePattern::ResultPipelineChain {
1073 scope,
1074 fn_name,
1075 step_count,
1076 ..
1077 } => format!(
1078 "{} {} ({} steps)",
1079 "ResultPipelineChain".magenta().bold(),
1080 scoped(scope, fn_name).bold(),
1081 step_count.to_string().yellow(),
1082 ),
1083 ModulePattern::RendererFormatter { scope, fn_name } => format!(
1084 "{} {}",
1085 "RendererFormatter".magenta().bold(),
1086 scoped(scope, fn_name).bold(),
1087 ),
1088 ModulePattern::MatchDispatcherFold {
1089 scope,
1090 fn_name,
1091 list_param,
1092 } => format!(
1093 "{} {} (over {})",
1094 "MatchDispatcherFold".magenta().bold(),
1095 scoped(scope, fn_name).bold(),
1096 list_param.cyan(),
1097 ),
1098 ModulePattern::AccumulatorFold {
1099 scope,
1100 wrapper_fn,
1101 loop_fn,
1102 step_fn,
1103 step_op,
1104 finish_fn,
1105 ..
1106 } => {
1107 let step = step_fn
1108 .clone()
1109 .unwrap_or_else(|| step_op.map(|o| format!("{o:?}")).unwrap_or_default());
1110 let finish = finish_fn.clone().unwrap_or_else(|| "id".to_string());
1111 format!(
1112 "{} {} → {} (step {}, finish {})",
1113 "AccumulatorFold".magenta().bold(),
1114 scoped(scope, wrapper_fn).bold(),
1115 scoped(scope, loop_fn).bold(),
1116 step.cyan(),
1117 finish.cyan(),
1118 )
1119 }
1120 }
1121}
1122
1123pub fn render_text(report: &ShapeReport, opts: &RenderOptions) -> String {
1124 let mut out = String::new();
1128 out.push_str(&format!("{} {}\n", "Module:".bold(), report.module.bold()));
1134 out.push_str(&format!(
1135 "{} {}\n\n",
1136 "Kind:".bold(),
1137 report.kind.as_str().cyan().bold()
1138 ));
1139 out.push_str(&format!("{}\n", "ModuleShape:".bold()));
1140 out.push_str(&format!(
1141 " purity {}\n",
1142 report.shape.purity.as_str()
1143 ));
1144 out.push_str(&format!(
1145 " entry {}\n",
1146 report.shape.entry.as_str()
1147 ));
1148 out.push_str(&format!(
1149 " state_shape {}\n",
1150 report.shape.state_shape.as_str()
1151 ));
1152 out.push_str(&format!(
1153 " type_surface {}\n",
1154 report.shape.type_surface.as_str()
1155 ));
1156 out.push_str(&format!(
1157 " api_shape {}\n\n",
1158 report.shape.api_shape.as_str()
1159 ));
1160
1161 if report.verify.blocks == 0 {
1164 out.push_str(&format!("{} no verify blocks\n\n", "Verification:".bold()));
1165 } else {
1166 out.push_str(&format!(
1167 "{} {} {} block{}, {}/{} fns covered\n\n",
1168 "Verification:".bold(),
1169 report.verify.blocks.to_string().yellow(),
1170 match report.verify.level {
1171 VerifyLevel::None => "(no)",
1172 VerifyLevel::Cases => "cases",
1173 VerifyLevel::Laws => "law",
1174 VerifyLevel::Trace => "trace",
1175 VerifyLevel::Mixed => "mixed",
1176 },
1177 if report.verify.blocks == 1 { "" } else { "s" },
1178 report.verify.covered_fns.to_string().yellow(),
1179 report.verify.eligible_fns.to_string().yellow(),
1180 ));
1181 }
1182
1183 if report.histogram.total_fns == 0 {
1184 out.push_str(&format!(
1185 "{} no classifiable fns (module only has `main` or no fns)\n",
1186 "Histogram:".bold()
1187 ));
1188 } else {
1189 out.push_str(&format!(
1190 "{} ({} fns):\n",
1191 "Histogram".bold(),
1192 report.histogram.total_fns.to_string().yellow()
1193 ));
1194 let sorted = report.histogram.sorted();
1195 let name_w = sorted
1196 .iter()
1197 .map(|(n, _, _)| n.as_str().len())
1198 .max()
1199 .unwrap_or(0)
1200 .max(20);
1201 for (name, count, pct) in &sorted {
1202 let bar = histogram_bar(*pct, 20);
1203 out.push_str(&format!(
1204 " {:<width$} {} {:>3} ({})\n",
1205 name.as_str(),
1206 bar.cyan(),
1207 format!("{:.0}%", pct).yellow(),
1208 count.to_string().dimmed(),
1209 width = name_w,
1210 ));
1211 }
1212 }
1213 if let Some(verdict) = &report.layer {
1214 if verdict.uncertain {
1215 out.push_str(&format!(
1216 "\n{} {} (best: {}, confidence {}, margin Δ{}, basis: {})\n",
1217 "Layer:".bold(),
1218 "uncertain".yellow().bold(),
1219 verdict.layer.as_str().cyan().bold(),
1220 format!("{:.2}", verdict.confidence).yellow(),
1221 format!("{:.1}", verdict.margin).yellow(),
1222 verdict.basis.dimmed(),
1223 ));
1224 } else {
1225 out.push_str(&format!(
1226 "\n{} {} (confidence {}, margin Δ{}, basis: {})\n",
1227 "Layer:".bold(),
1228 verdict.layer.as_str().cyan().bold(),
1229 format!("{:.2}", verdict.confidence).yellow(),
1230 format!("{:.1}", verdict.margin).yellow(),
1231 verdict.basis.dimmed(),
1232 ));
1233 }
1234 let runners: Vec<String> = verdict
1239 .candidates
1240 .iter()
1241 .skip(1)
1242 .map(|(layer, dist)| format!("{} Δ{:.1}", layer.as_str(), dist))
1243 .collect();
1244 if !runners.is_empty() {
1245 out.push_str(&format!(
1246 " {} {}\n",
1247 "next:".dimmed(),
1248 runners.join(", ").dimmed()
1249 ));
1250 }
1251 } else {
1252 out.push_str(&format!("\n{} insufficient data\n", "Layer:".bold()));
1253 }
1254
1255 if !report.patterns.is_empty() {
1256 out.push_str(&format!("\n{}\n", "Module patterns:".bold()));
1257 for p in &report.patterns {
1258 out.push_str(" ");
1259 out.push_str(&render_module_pattern_line(p));
1260 out.push('\n');
1261 }
1262 }
1263
1264 if !opts.summary {
1265 out.push_str(&format!("\n{}\n", "Functions:".bold()));
1266 let name_w = report
1267 .fns
1268 .iter()
1269 .map(|f| f.name.len())
1270 .max()
1271 .unwrap_or(0)
1272 .max(16);
1273 for f in &report.fns {
1274 let labels_str = if f.labels.is_empty() {
1275 "unclassified".to_string()
1276 } else {
1277 f.labels
1278 .iter()
1279 .map(|a| a.as_str())
1280 .collect::<Vec<_>>()
1281 .join(", ")
1282 };
1283 out.push_str(&format!(
1284 " {:<width$} {}\n",
1285 f.name,
1286 labels_str.dimmed(),
1287 width = name_w,
1288 ));
1289 }
1290 }
1291
1292 out
1293}
1294
1295fn histogram_bar(pct: f64, width: usize) -> String {
1296 let filled = ((pct / 100.0) * width as f64).round() as usize;
1297 let filled = filled.min(width);
1298 let mut s = String::with_capacity(width * 3);
1299 for _ in 0..filled {
1300 s.push('█');
1301 }
1302 for _ in filled..width {
1303 s.push('░');
1304 }
1305 s
1306}
1307
1308pub fn render_json(report: &ShapeReport) -> serde_json::Value {
1309 use serde_json::json;
1310 let layer = report.layer.as_ref().map(|v| {
1311 let candidates: Vec<serde_json::Value> = v
1312 .candidates
1313 .iter()
1314 .map(|(layer, dist)| json!({"name": layer.as_str(), "distance": dist}))
1315 .collect();
1316 json!({
1317 "name": v.layer.as_str(),
1318 "confidence": v.confidence,
1319 "margin": v.margin,
1320 "uncertain": v.uncertain,
1321 "candidates": candidates,
1322 "basis": v.basis,
1323 "support_fns": v.support_fns,
1324 })
1325 });
1326 let histogram_counts: serde_json::Value = report
1327 .histogram
1328 .counts
1329 .iter()
1330 .filter(|(_, c)| **c > 0)
1331 .map(|(k, c)| (k.as_str().to_string(), serde_json::Value::from(*c)))
1332 .collect::<serde_json::Map<_, _>>()
1333 .into();
1334 let fns: Vec<serde_json::Value> = report
1335 .fns
1336 .iter()
1337 .map(|f| {
1338 let labels: Vec<&str> = f.labels.iter().map(|a| a.as_str()).collect();
1339 json!({
1340 "name": f.name,
1341 "primary": f.primary.as_str(),
1342 "labels": labels,
1343 })
1344 })
1345 .collect();
1346 let patterns: Vec<serde_json::Value> =
1347 report.patterns.iter().map(module_pattern_to_json).collect();
1348 json!({
1349 "module": report.module,
1350 "file": report.file,
1351 "facts": {
1352 "fn_count": report.fns.len(),
1353 "has_main": report.has_main,
1354 "exposes_opaque": report.exposes_opaque,
1355 "depends": report.depends,
1356 "effects": report.effects,
1357 },
1358 "vector": {
1359 "purity": report.shape.purity.as_str(),
1360 "entry": report.shape.entry.as_str(),
1361 "state_shape": report.shape.state_shape.as_str(),
1362 "type_surface": report.shape.type_surface.as_str(),
1363 "api_shape": report.shape.api_shape.as_str(),
1364 },
1365 "kind": {
1366 "name": report.kind.as_str(),
1367 "rule": report.kind.rule(),
1368 },
1369 "verification": {
1370 "level": report.verify.level.as_str(),
1371 "blocks": report.verify.blocks,
1372 "covered_fns": report.verify.covered_fns,
1373 "eligible_fns": report.verify.eligible_fns,
1374 },
1375 "histogram": {
1376 "counts": histogram_counts,
1377 "total_fns": report.histogram.total_fns,
1378 },
1379 "layer": layer,
1380 "fns": fns,
1381 "patterns": patterns,
1382 })
1383}
1384
1385fn module_pattern_to_json(p: &ModulePattern) -> serde_json::Value {
1390 use serde_json::json;
1391 let scope_json = |s: &Option<String>| match s {
1392 Some(prefix) => serde_json::Value::String(prefix.clone()),
1393 None => serde_json::Value::Null,
1394 };
1395 match p {
1396 ModulePattern::RefinementSmartConstructor {
1397 scope,
1398 type_name,
1399 carrier_field,
1400 carrier_type,
1401 constructor_fn,
1402 param_name,
1403 ..
1404 } => json!({
1405 "kind": "RefinementSmartConstructor",
1406 "scope": scope_json(scope),
1407 "type_name": type_name,
1408 "carrier_field": carrier_field,
1409 "carrier_type": carrier_type,
1410 "constructor_fn": constructor_fn,
1411 "param_name": param_name,
1412 }),
1413 ModulePattern::WrapperOverRecursion {
1414 wrapper_scope,
1415 wrapper_fn,
1416 inner_scope,
1417 inner_fn,
1418 } => json!({
1419 "kind": "WrapperOverRecursion",
1420 "wrapper_scope": scope_json(wrapper_scope),
1421 "wrapper_fn": wrapper_fn,
1422 "inner_scope": scope_json(inner_scope),
1423 "inner_fn": inner_fn,
1424 }),
1425 ModulePattern::ResultPipelineChain {
1426 scope,
1427 fn_name,
1428 step_count,
1429 step_fns,
1430 } => json!({
1431 "kind": "ResultPipelineChain",
1432 "scope": scope_json(scope),
1433 "fn_name": fn_name,
1434 "step_count": step_count,
1435 "step_fns": step_fns,
1436 }),
1437 ModulePattern::RendererFormatter { scope, fn_name } => json!({
1438 "kind": "RendererFormatter",
1439 "scope": scope_json(scope),
1440 "fn_name": fn_name,
1441 }),
1442 ModulePattern::MatchDispatcherFold {
1443 scope,
1444 fn_name,
1445 list_param,
1446 } => json!({
1447 "kind": "MatchDispatcherFold",
1448 "scope": scope_json(scope),
1449 "fn_name": fn_name,
1450 "list_param": list_param,
1451 }),
1452 ModulePattern::AccumulatorFold {
1453 scope,
1454 wrapper_fn,
1455 loop_fn,
1456 list_param,
1457 acc_param,
1458 step_fn,
1459 step_op,
1460 finish_fn,
1461 driver_type,
1462 step_value_first,
1463 } => json!({
1464 "kind": "AccumulatorFold",
1465 "scope": scope_json(scope),
1466 "wrapper_fn": wrapper_fn,
1467 "loop_fn": loop_fn,
1468 "list_param": list_param,
1469 "acc_param": acc_param,
1470 "step_fn": step_fn,
1471 "step_op": step_op.map(|o| format!("{o:?}")),
1472 "finish_fn": finish_fn,
1473 "driver_type": driver_type,
1474 "step_value_first": step_value_first,
1475 }),
1476 }
1477}
1478
1479#[derive(Debug, Clone)]
1489pub enum CorpusEntry {
1490 Analyzed {
1491 rel_path: String,
1492 report: Box<ShapeReport>,
1493 },
1494 Skipped {
1495 rel_path: String,
1496 reason: String,
1497 },
1498}
1499
1500pub fn analyze_dir(
1504 root: &Path,
1505 module_root_hint: Option<&str>,
1506 fingerprints: &[LayerFingerprint],
1507 basis: &str,
1508) -> Result<Vec<CorpusEntry>, String> {
1509 let mut files = Vec::new();
1510 collect_av_files(root, &mut files)?;
1511 files.sort();
1512 let effective_module_root = match module_root_hint {
1513 Some(r) => r.to_string(),
1514 None => root.to_string_lossy().to_string(),
1515 };
1516 let mut entries = Vec::with_capacity(files.len());
1517 for path in files {
1518 let rel = path
1519 .strip_prefix(root)
1520 .unwrap_or(&path)
1521 .to_string_lossy()
1522 .to_string();
1523 match analyze_path_with(&path, Some(&effective_module_root), fingerprints, basis) {
1524 Ok(report) => entries.push(CorpusEntry::Analyzed {
1525 rel_path: rel,
1526 report: Box::new(report),
1527 }),
1528 Err(reason) => entries.push(CorpusEntry::Skipped {
1529 rel_path: rel,
1530 reason,
1531 }),
1532 }
1533 }
1534 Ok(entries)
1535}
1536
1537fn collect_av_files(path: &Path, out: &mut Vec<std::path::PathBuf>) -> Result<(), String> {
1538 if path.is_file() {
1539 if path.extension().and_then(|s| s.to_str()) == Some("av") {
1540 out.push(path.to_path_buf());
1541 }
1542 return Ok(());
1543 }
1544 let entries = std::fs::read_dir(path).map_err(|e| {
1545 format!(
1546 "aver shape: cannot read directory '{}': {}",
1547 path.display(),
1548 e
1549 )
1550 })?;
1551 for entry in entries {
1552 let entry = entry
1553 .map_err(|e| format!("aver shape: read_dir error in '{}': {}", path.display(), e))?;
1554 let name = entry.file_name();
1555 let name_str = name.to_string_lossy();
1556 if name_str.starts_with('.')
1558 || name_str == "target"
1559 || name_str == "node_modules"
1560 || name_str == "pkg"
1561 {
1562 continue;
1563 }
1564 let p = entry.path();
1565 if p.is_dir() {
1566 collect_av_files(&p, out)?;
1567 } else if p.extension().and_then(|s| s.to_str()) == Some("av") {
1568 out.push(p);
1569 }
1570 }
1571 Ok(())
1572}
1573
1574#[derive(Debug, Clone, Default)]
1576pub struct CorpusSummary {
1577 pub total_files: usize,
1578 pub analyzed_files: usize,
1579 pub skipped_files: usize,
1580 pub total_fns: usize,
1581 pub kind_counts: BTreeMap<Kind, usize>,
1582 pub layer_counts: BTreeMap<Layer, usize>,
1583 pub archetype_counts: BTreeMap<Archetype, usize>,
1584}
1585
1586pub fn summarize_corpus(entries: &[CorpusEntry]) -> CorpusSummary {
1587 let mut s = CorpusSummary {
1588 total_files: entries.len(),
1589 ..Default::default()
1590 };
1591 for e in entries {
1592 match e {
1593 CorpusEntry::Analyzed { report, .. } => {
1594 s.analyzed_files += 1;
1595 s.total_fns += report.histogram.total_fns;
1596 *s.kind_counts.entry(report.kind).or_insert(0) += 1;
1597 if let Some(v) = &report.layer {
1598 *s.layer_counts.entry(v.layer).or_insert(0) += 1;
1599 }
1600 for (arch, c) in &report.histogram.counts {
1601 *s.archetype_counts.entry(*arch).or_insert(0) += c;
1602 }
1603 }
1604 CorpusEntry::Skipped { .. } => {
1605 s.skipped_files += 1;
1606 }
1607 }
1608 }
1609 s
1610}
1611
1612pub fn render_corpus_text(entries: &[CorpusEntry], opts: &RenderOptions) -> String {
1613 let mut out = String::new();
1614 let summary = summarize_corpus(entries);
1615
1616 if !opts.summary {
1617 out.push_str(&format!(
1618 "Corpus: {} files ({} analyzed, {} skipped)\n\n",
1619 summary.total_files, summary.analyzed_files, summary.skipped_files,
1620 ));
1621 let path_w = entries
1622 .iter()
1623 .map(|e| match e {
1624 CorpusEntry::Analyzed { rel_path, .. } | CorpusEntry::Skipped { rel_path, .. } => {
1625 rel_path.len()
1626 }
1627 })
1628 .max()
1629 .unwrap_or(0)
1630 .max(30);
1631 for e in entries {
1632 match e {
1633 CorpusEntry::Analyzed { rel_path, report } => {
1634 let layer = report
1639 .layer
1640 .as_ref()
1641 .map(|v| {
1642 let suffix = format!(
1643 "{} (conf {:.2}, Δ{:.1})",
1644 v.layer.as_str(),
1645 v.confidence,
1646 v.margin
1647 );
1648 if v.uncertain {
1649 format!("uncertain — {}", suffix)
1650 } else {
1651 suffix
1652 }
1653 })
1654 .unwrap_or_else(|| "—".to_string());
1655 out.push_str(&format!(
1656 " {:<width$} {:<16} layer: {}\n",
1657 rel_path,
1658 report.kind.as_str(),
1659 layer,
1660 width = path_w,
1661 ));
1662 }
1663 CorpusEntry::Skipped { rel_path, reason } => {
1664 out.push_str(&format!(
1665 " {:<width$} SKIPPED ({})\n",
1666 rel_path,
1667 reason,
1668 width = path_w,
1669 ));
1670 }
1671 }
1672 }
1673 out.push('\n');
1674 }
1675
1676 out.push_str(&format!(
1679 "Corpus summary: {}/{} analyzed, {} fns classified\n",
1680 summary.analyzed_files, summary.total_files, summary.total_fns,
1681 ));
1682 if !summary.kind_counts.is_empty() {
1683 out.push_str("\n Kind distribution:\n");
1684 let mut kinds: Vec<_> = summary.kind_counts.iter().collect();
1685 kinds.sort_by(|a, b| b.1.cmp(a.1));
1686 for (kind, count) in kinds {
1687 out.push_str(&format!(" {:<24} {}\n", kind.as_str(), count));
1688 }
1689 }
1690 if !summary.layer_counts.is_empty() {
1691 out.push_str("\n Layer distribution:\n");
1692 let mut layers: Vec<_> = summary.layer_counts.iter().collect();
1693 layers.sort_by(|a, b| b.1.cmp(a.1));
1694 for (layer, count) in layers {
1695 out.push_str(&format!(" {:<24} {}\n", layer.as_str(), count));
1696 }
1697 }
1698 if !summary.archetype_counts.is_empty() {
1699 out.push_str("\n Archetype distribution (across all fns):\n");
1700 let mut archs: Vec<_> = summary
1701 .archetype_counts
1702 .iter()
1703 .filter(|(_, c)| **c > 0)
1704 .collect();
1705 archs.sort_by(|a, b| b.1.cmp(a.1));
1706 let name_w = archs
1707 .iter()
1708 .map(|(n, _)| n.as_str().len())
1709 .max()
1710 .unwrap_or(0)
1711 .max(20);
1712 for (arch, count) in archs {
1713 let pct = if summary.total_fns == 0 {
1714 0.0
1715 } else {
1716 100.0 * *count as f64 / summary.total_fns as f64
1717 };
1718 let bar = histogram_bar(pct, 20);
1719 out.push_str(&format!(
1720 " {:<width$} {} {:>3.0}% ({})\n",
1721 arch.as_str(),
1722 bar,
1723 pct,
1724 count,
1725 width = name_w,
1726 ));
1727 }
1728 }
1729 let skipped: Vec<(&str, &str)> = entries
1733 .iter()
1734 .filter_map(|e| match e {
1735 CorpusEntry::Skipped { rel_path, reason } => Some((rel_path.as_str(), reason.as_str())),
1736 _ => None,
1737 })
1738 .collect();
1739 if !skipped.is_empty() {
1740 out.push_str(&format!("\n Skipped ({} files):\n", skipped.len()));
1741 let path_w = skipped
1742 .iter()
1743 .map(|(p, _)| p.len())
1744 .max()
1745 .unwrap_or(0)
1746 .max(30);
1747 for (path, reason) in skipped {
1748 let one_line = reason.lines().next().unwrap_or(reason);
1751 out.push_str(&format!(
1752 " {:<width$} {}\n",
1753 path,
1754 one_line,
1755 width = path_w
1756 ));
1757 }
1758 }
1759 out
1760}
1761
1762pub fn render_corpus_json(entries: &[CorpusEntry]) -> Vec<serde_json::Value> {
1763 use serde_json::json;
1764 entries
1765 .iter()
1766 .map(|e| match e {
1767 CorpusEntry::Analyzed { rel_path, report } => {
1768 let mut v = render_json(report);
1769 if let Some(obj) = v.as_object_mut() {
1770 obj.insert("rel_path".to_string(), json!(rel_path));
1771 }
1772 v
1773 }
1774 CorpusEntry::Skipped { rel_path, reason } => json!({
1775 "rel_path": rel_path,
1776 "skipped": true,
1777 "reason": reason,
1778 }),
1779 })
1780 .collect()
1781}