Skip to main content

aver/diagnostics/
shape.rs

1//! `aver shape` — module-shape presentation layer.
2//!
3//! Stage 1 of issue #232 (0.23 "Shape") split this file into two
4//! tiers:
5//!
6//! - **Recognition** (`aver::analysis::shape`) — per-fn facts walk,
7//!   archetype classifier, call-graph SCC. Stable typed primitives.
8//! - **Presentation** (this module) — `ModuleShape` vector + `Kind` +
9//!   verify report + histogram + Layer fingerprint distance + corpus
10//!   walker + renderer + `aver.toml [[shape.layer]]` / `[[shape.expected]]`
11//!   bridges. Everything the CLI / LSP / JSON consumers see.
12//!
13//! The recognition primitives are re-exported below so existing
14//! callers (and the research test) keep working through this module
15//! during the transition; downstream stages will migrate them to
16//! import directly from `aver::analysis::shape`.
17
18use 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/// ANSI styling shim for the `render_text` renderer. With
27/// `tty-render` enabled (the default for the CLI / LSP / playground
28/// builds), `Colorize` is re-exported from the `colored` crate so
29/// calls like `"X".bold().cyan()` produce the actual escape
30/// sequences. Without `tty-render` (the fuzz crates and any
31/// future `--no-default-features` consumer), the trait still
32/// resolves but every method is a no-op that returns the input as
33/// `String` — the renderer keeps working, just emits plain text.
34#[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    /// No-op stand-in for `colored::Colorize` when the `tty-render`
42    /// feature is off. Every method returns the input as `String`,
43    /// so `"X".bold().cyan()` chains the same way it does with the
44    /// real crate — just without escapes.
45    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
71// Recognition primitives moved to `aver::analysis::shape` in stage 1
72// (#232). Stage 2 drops the re-exports — callers import from
73// `analysis::shape` directly so the presentation tier doesn't double
74// as an API entry point for the recognition tier. This file holds
75// only the presentation surface from here on: `ModuleShape` vector +
76// `Kind` + Layer fingerprints + verify report + histogram + corpus
77// walker + renderers + `aver.toml` bridges. Internal use of the
78// recognition API in this file goes through `crate::analysis::shape`
79// directly (see imports below).
80use crate::analysis::shape::{Archetype, ModulePattern, analyze_program_with_modules};
81
82// ─── ModuleShape vector + derived Kind ───────────────────────────────────────
83
84#[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    /// One-line user-facing explanation of what each purity value means.
138    /// Surfaced in CLI help / hover text — answers "what makes this
139    /// module ShellEffectful vs ClassifiedEffectful?".
140    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    /// Plain-English explanation of the rule that picked this Kind from
220    /// the vector. Used as JSON `rule` field and as a hover snippet.
221    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
237/// Threshold for "this module is genuinely effectful": at least this
238/// fraction of its non-main fns must declare effects in their `! [...]`.
239/// Below the threshold, a `main` that prints `Console.print("done")`
240/// after running pure code shouldn't drag the module into `Orchestration`.
241const EFFECTFUL_FN_RATIO_FOR_ORCHESTRATION: f64 = 0.3;
242
243pub fn derive_kind(shape: &ModuleShape, effectful_fn_ratio: f64) -> Kind {
244    // Effectful Kinds (both Classified and Shell flavors): a Shell effect
245    // is a long-running lifecycle (HttpServer.listen, Tcp.listen) — Oracle
246    // skips it by design, not because the classifier doesn't recognize it.
247    // From the user's perspective a module with a shell-effect entrypoint
248    // is still Orchestration (or ServiceClient if it threads a handle);
249    // the `purity` field tells the rest of the story.
250    if !matches!(shape.purity, Purity::Pure) {
251        // ServiceClient = module that re-exports its handle-using API
252        // surface (api_shape == ServiceBoundary, i.e. some exposed fn
253        // takes/returns the handle). Threading a handle internally is
254        // not enough — those modules show up as Orchestration / Library
255        // depending on whether they have `main`.
256        if matches!(shape.api_shape, ApiShape::ServiceBoundary) {
257            return Kind::ServiceClient;
258        }
259        if matches!(shape.entry, Entry::Main) {
260            // "Has main + has effects" used to unconditionally pick
261            // Orchestration. That dragged pure algorithm modules with
262            // a demo `main(); Console.print(result)` (quicksort,
263            // calculator, …) into Orchestration even though only one
264            // fn out of N actually touches an effect. Now we only
265            // commit to Orchestration when ≥30% of non-main fns are
266            // effectful — below that the module is library-shaped
267            // with a demo entry, and falls through to the api-shape
268            // and pure paths below.
269            if effectful_fn_ratio >= EFFECTFUL_FN_RATIO_FOR_ORCHESTRATION {
270                return Kind::Orchestration;
271            }
272            // Fall through: treat as library-with-demo by api/purity rules.
273        }
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        // Pure-dominated module with a demo main and no exposes —
281        // ergonomically the same as PureHelpers, even though one fn
282        // has effects.
283        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    // Pure from here.
291    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// ─── Verification report (orthogonal to Kind mapping) ────────────────────────
304
305#[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// ─── Histogram ──────────────────────────────────────────────────────────────
335
336#[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    /// Returns archetype-percentage pairs in descending count order.
352    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// ─── Layer fingerprint + distance ────────────────────────────────────────────
375
376#[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/// Five-bucket histogram a `Layer` fingerprint is defined against.
423/// Mapped from the 14 archetype labels via `archetype_to_bucket`.
424#[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    /// Bucket percentages summing to ~100.
466    pub buckets: [(Bucket, f64); 5],
467}
468
469/// Built-in v0 fingerprints sourced from the casual single-author
470/// corpus measurement that landed on the tweet. Concrete numbers:
471///
472/// ```text
473/// LAYER       match  recursion  pipeline  orchestration  helpers
474/// Domain      ~40%   ~25%       ~0%       ~5%            ~30%
475/// Parse       ~15%   ~10%       ~65%      ~10%           ~0%
476/// Command     ~10%    ~5%       ~75%      ~10%           ~0%
477/// AiStrategy  ~60%   ~15%       ~0%       ~10%           ~15%
478/// RenderUi    ~50%    ~5%       ~0%       ~25%           ~20%
479/// Infra       ~20%   ~10%       ~30%      ~40%            ~0%
480/// ```
481/// Translate `aver.toml [[shape.layer]]` entries into the runtime
482/// fingerprint representation. Returns an error if `name` doesn't match
483/// any known `Layer` variant. Unknown layers are deliberately rejected
484/// here, not silently dropped — typo-detection is cheap and a silently
485/// missing fingerprint would change the nearest-neighbor result.
486pub 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    /// Absolute fit: how close `layer` is to the observed histogram,
558    /// normalised against the max possible Euclidean distance in the
559    /// 5-bucket simplex. Penalised on small modules (<5 fns capped at
560    /// 0.2, <10 fns softened by 0.7×).
561    pub confidence: f64,
562    /// Distance gap between the chosen layer and the runner-up.
563    /// High margin = clear winner. Low margin = classifier is hesitating
564    /// between multiple fingerprints — read with care.
565    pub margin: f64,
566    /// True when the verdict shouldn't be read as a hard label —
567    /// either confidence is low OR margin to the runner-up is small.
568    /// Renderers should surface this with explicit "uncertain" wording.
569    pub uncertain: bool,
570    /// Top three nearest layers with their raw distances. Lets the
571    /// caller show "next: Domain Δ4.1, RenderUi Δ8.3" so the verdict
572    /// is auditable without pretending the second-best didn't exist.
573    pub candidates: Vec<(Layer, f64)>,
574    pub basis: String,
575    pub support_fns: usize,
576}
577
578/// Confidence threshold below which a verdict is marked `uncertain`.
579const UNCERTAIN_CONFIDENCE_THRESHOLD: f64 = 0.4;
580/// Margin (distance gap to runner-up) below which a verdict is marked
581/// `uncertain` even at high confidence — fingerprints are close to
582/// each other and the classifier could plausibly pick either.
583const UNCERTAIN_MARGIN_THRESHOLD: f64 = 10.0;
584
585/// Nearest fingerprint by Euclidean distance over bucket %.
586/// Confidence is `(max_dist - chosen_dist) / max_dist`, penalized by
587/// small `total_fns` (< 5 fns flattens confidence regardless of fit).
588pub 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    // Max meaningful Euclidean distance across 5 bucket pcts is sqrt(5 * 100^2) ≈ 223.6.
610    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        // Hard-cap confidence for tiny modules — 3 fns is noise.
615        0.2_f64.min(raw_conf)
616    } else if n < 10 {
617        // Soft-penalize medium modules at most ~30% of headroom.
618        raw_conf * 0.7
619    } else {
620        raw_conf
621    };
622    // Margin = how far ahead the winner is from the second-best.
623    // Captures ambivalence the confidence number alone misses
624    // (two fingerprints both 0.2 fit is ambivalent; one at 0.5 + one
625    // at 0.1 is decisive even if confidence is the same).
626    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// ─── Top-level analysis ──────────────────────────────────────────────────────
646
647#[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    /// Fraction of non-`main` fns that declare effects in their
665    /// `! [...]`. Drives the Orchestration vs PureHelpers decision in
666    /// `derive_kind` so a pure module with a demo `main()` doesn't
667    /// read as Orchestration.
668    pub effectful_fn_ratio: f64,
669    pub verify: VerifyReport,
670    pub histogram: Histogram,
671    pub layer: Option<LayerVerdict>,
672    pub fns: Vec<FnShape>,
673    /// Module-level typed patterns recognized by `analysis::shape`
674    /// (stage 6 of #232). Populated from `program_shape.patterns`;
675    /// the renderer surfaces them in their own section so callers see
676    /// every refinement / wrapper / pipeline / renderer / fold the
677    /// substrate identified.
678    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
690/// Same as `analyze_path` but lets the caller supply a custom layer
691/// fingerprint table + basis label (used by the CLI when `aver.toml` has
692/// `[[shape.layer]]` overrides).
693pub 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
724/// Analyze a source string directly, without touching the filesystem
725/// for the entry file. Used by the LSP (editor buffer may differ from
726/// on-disk content) and any future embedder that holds the program in
727/// memory. `module_root` resolves `depends [...]`; `file_label` is the
728/// path string surfaced in the report's `file` field; `fallback_module_name`
729/// is used when the source has no `module` declaration.
730pub 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    // Stage 4/6 of #232: recognition substrate.
795    // `analyze_program_with_modules` runs facts walk + SCC + classify
796    // and also populates `patterns` (module-level typed patterns:
797    // `RefinementSmartConstructor`, `WrapperOverRecursion`,
798    // `ResultPipelineChain`, `RendererFormatter`,
799    // `MatchDispatcherFold`). Presentation tier reads both —
800    // per-fn archetypes drive the histogram + Kind / Layer
801    // scaffolding; patterns get their own section in the renderer.
802    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        // No non-main fns to classify — `derive_kind` falls through to
849        // the pure/api-shape paths anyway, so the value here is moot.
850        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    // Eligible = non-main fns. Coverage = eligible fns that appear in at
938    // least one verify block under their name.
939    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    // ServiceBoundary only when the handle actually reaches the exposed
991    // surface — i.e. at least one fn in `exposes [...]` takes/returns the
992    // handle. A module that threads `Tcp.Connection` only internally
993    // (a webapp that talks to Redis but doesn't re-export its client)
994    // stays at `ExposedLibrary` so its Kind doesn't get mis-labeled as
995    // `ServiceClient`.
996    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    // Namespace shorthand: `Disk`, `Tcp`, `Http`, etc. Accept if at least
1017    // one classified method has this prefix.
1018    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
1025// ─── Renderers ───────────────────────────────────────────────────────────────
1026
1027pub struct RenderOptions {
1028    pub summary: bool,
1029}
1030
1031/// One human-readable line for a single `ModulePattern`. Format:
1032/// `<VariantName>  <key facts>` with the variant on the left so a
1033/// `grep RefinementSmartConstructor` over the corpus output works.
1034/// Scope-qualified names are rendered as `prefix::name` when the
1035/// pattern lives in a dep module.
1036fn render_module_pattern_line(p: &ModulePattern) -> String {
1037    // `_shape_colorize` is in module scope above — its methods
1038    // (`bold`, `cyan`, …) are usable directly without a local import.
1039    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    // `_shape_colorize` is in module scope above (see top-of-file
1125    // shim). Auto-disables coloring on non-TTY pipes and when the
1126    // `tty-render` feature is off (e.g. fuzz / mutator builds).
1127    let mut out = String::new();
1128    // Header: `Module:` and `Kind:` share the value column. `Module:` is
1129    // 7 chars + space, `Kind:` is 5 chars + 3 spaces — same total width.
1130    // Colors auto-disable on non-TTY (the `colored` crate detects
1131    // pipes and the NO_COLOR env var), so tests + JSON consumers see
1132    // plain text.
1133    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    // Verification: collapse the zero-block case to a single line — the
1162    // "0 (no) blocks, 0/0 fns covered" reading was confusing.
1163    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        // Runners-up (top 3 minus best). Hides nothing — even when
1235        // confidence is high the user sees how far the alternatives
1236        // are. "Δ" is the raw Euclidean distance in the 5-bucket %
1237        // simplex; higher = further from observed histogram.
1238        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
1385/// JSON encoding of one [`ModulePattern`]. Each variant becomes an
1386/// object with a `kind` tag plus the variant's typed payload, mirror
1387/// of the text renderer's grep-friendly format. `null` instead of
1388/// `None` keeps the schema explicit for downstream tooling.
1389fn 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// ─── Corpus (directory) mode ────────────────────────────────────────────────
1480
1481/// Outcome of analyzing a single file inside a corpus walk. Files that
1482/// fail typecheck or parse get the error attached instead of being
1483/// silently skipped — the corpus view should show what's broken.
1484///
1485/// `report` is boxed because `ShapeReport` carries the full per-fn
1486/// listing — a large size delta between the two variants would inflate
1487/// every `Vec<CorpusEntry>` with padding (clippy's `large_enum_variant`).
1488#[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
1500/// Walk a directory recursively and analyze every `.av` file.
1501/// `root` is also used as the default `module_root` for dep resolution
1502/// — callers that want a different module root can pass it explicitly.
1503pub 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        // Skip hidden + common build/dep directories.
1557        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/// Per-Kind / per-Layer aggregate over a corpus.
1575#[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                    // Layer cell carries confidence + margin always; when
1635                    // uncertain, prefix with "uncertain — " so the user
1636                    // can scan a corpus column and see ambivalent verdicts
1637                    // without losing the best-fit name.
1638                    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    // Aggregate block — printed in both modes; --summary just omits the
1677    // per-file table above.
1678    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    // Skipped files: list per-file reason so the user sees what got
1730    // dropped and why. Without this the summary's "X analyzed, Y
1731    // skipped" line gave a count but no actionable info.
1732    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            // Trim reason: typecheck errors can carry full module-line
1749            // diagnostics; one-liner is enough at corpus summary level.
1750            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}