Skip to main content

aver/diagnostics/
context.rs

1//! File-local context analysis.
2//!
3//! `aver context` is inherently multi-file: a project's context is the
4//! entry file **and** its dependency graph. This module provides the
5//! per-file building block. CLI wraps it with dependency traversal;
6//! the playground calls it for a single entry, so the returned
7//! [`FileContext`] has `depends` listed by name but no expanded child
8//! contexts — the caller is responsible for resolving them (or not).
9//!
10//! Runtime-neutral: pure computation over parsed items and source text.
11//! No filesystem access, no VM, wasm-safe.
12
13use std::collections::{HashMap, HashSet};
14
15use serde::Serialize;
16
17use crate::ast::{DecisionBlock, FnDef, TopLevel, TypeDef, VerifyBlock};
18use crate::call_graph::{
19    direct_calls, find_recursive_fns, recursive_callsite_counts, recursive_scc_ids,
20};
21use crate::checker::expr_to_str;
22use crate::types::checker::TypeCheckResult;
23use crate::verify_law::canonical_spec_ref;
24
25// ─── Canonical, CLI-shaped FileContext ────────────────────────────────────────
26
27#[derive(Clone)]
28pub struct FileContext {
29    pub source_file: String,
30    pub module_name: Option<String>,
31    pub intent: Option<String>,
32    pub depends: Vec<String>,
33    pub exposes: Vec<String>,
34    pub exposes_opaque: Vec<String>,
35    pub api_effects: Vec<String>,
36    pub module_effects: Vec<String>,
37    pub main_effects: Option<Vec<String>>,
38    /// Public fn defs only (filtered by `exposes` when present).
39    pub fn_defs: Vec<FnDef>,
40    /// Every fn def, unfiltered. Used by callers that need private fns too.
41    pub all_fn_defs: Vec<FnDef>,
42    pub fn_auto_memo: HashSet<String>,
43    pub fn_memo_qual: HashMap<String, Vec<String>>,
44    pub fn_auto_tco: HashSet<String>,
45    pub fn_recursive_callsites: HashMap<String, usize>,
46    pub fn_recursive_scc_id: HashMap<String, usize>,
47    pub fn_specs: HashMap<String, Vec<String>>,
48    pub fn_direct_calls: HashMap<String, Vec<String>>,
49    pub type_defs: Vec<TypeDef>,
50    pub verify_blocks: Vec<VerifyBlock>,
51    pub verify_counts: HashMap<String, usize>,
52    pub verify_samples: HashMap<String, Vec<String>>,
53    pub decisions: Vec<DecisionBlock>,
54}
55
56impl FileContext {
57    fn empty(source_file: impl Into<String>) -> Self {
58        Self {
59            source_file: source_file.into(),
60            module_name: None,
61            intent: None,
62            depends: vec![],
63            exposes: vec![],
64            exposes_opaque: vec![],
65            api_effects: vec![],
66            module_effects: vec![],
67            main_effects: None,
68            fn_defs: vec![],
69            all_fn_defs: vec![],
70            fn_auto_memo: HashSet::new(),
71            fn_memo_qual: HashMap::new(),
72            fn_auto_tco: HashSet::new(),
73            fn_recursive_callsites: HashMap::new(),
74            fn_recursive_scc_id: HashMap::new(),
75            fn_specs: HashMap::new(),
76            fn_direct_calls: HashMap::new(),
77            type_defs: vec![],
78            verify_blocks: vec![],
79            verify_counts: HashMap::new(),
80            verify_samples: HashMap::new(),
81            decisions: vec![],
82        }
83    }
84}
85
86// ─── Single-file entry point ──────────────────────────────────────────────────
87
88/// Build the per-file context record from parsed items and source text.
89///
90/// `module_root` is passed to the typechecker so it can resolve external
91/// references during signature inference; pass `None` for single-file
92/// analysis (playground). Callers that need the dependency graph wrap
93/// this with their own recursion.
94pub fn build_context_for_items(
95    items: &[TopLevel],
96    _source: &str,
97    file_label: impl Into<String>,
98    module_root: Option<&str>,
99) -> FileContext {
100    let mut ctx = FileContext::empty(file_label);
101
102    let mut declared_module_effects: Option<Vec<String>> = None;
103    for item in items {
104        match item {
105            TopLevel::Module(m) => {
106                ctx.module_name = Some(m.name.clone());
107                ctx.intent = if m.intent.is_empty() {
108                    None
109                } else {
110                    Some(m.intent.clone())
111                };
112                ctx.depends = m.depends.clone();
113                ctx.exposes = m.exposes.clone();
114                ctx.exposes_opaque = m.exposes_opaque.clone();
115                declared_module_effects = m.effects.clone();
116            }
117            TopLevel::FnDef(fd) => {
118                ctx.fn_defs.push(fd.clone());
119                ctx.all_fn_defs.push(fd.clone());
120            }
121            TopLevel::TypeDef(td) => ctx.type_defs.push(td.clone()),
122            TopLevel::Verify(vb) => ctx.verify_blocks.push(vb.clone()),
123            TopLevel::Decision(db) => ctx.decisions.push(db.clone()),
124            _ => {}
125        }
126    }
127
128    let flags = compute_context_fn_flags(items, module_root);
129    let ContextFnFlags {
130        auto_memo,
131        auto_tco,
132        memo_qual,
133        recursive_callsites,
134        recursive_scc_id,
135        fn_sigs,
136    } = flags;
137    ctx.fn_auto_memo = auto_memo;
138    ctx.fn_auto_tco = auto_tco;
139    ctx.fn_memo_qual = memo_qual;
140    ctx.fn_recursive_callsites = recursive_callsites;
141    ctx.fn_recursive_scc_id = recursive_scc_id;
142    ctx.fn_direct_calls = direct_calls(items);
143
144    for vb in &ctx.verify_blocks {
145        let crate::ast::VerifyKind::Law(law) = &vb.kind else {
146            continue;
147        };
148        let Some(spec_ref) = canonical_spec_ref(&vb.fn_name, law, &fn_sigs) else {
149            continue;
150        };
151        ctx.fn_specs
152            .entry(vb.fn_name.clone())
153            .or_default()
154            .push(spec_ref.spec_fn_name);
155    }
156    for specs in ctx.fn_specs.values_mut() {
157        specs.sort();
158        specs.dedup();
159    }
160
161    let (verify_counts, verify_samples) = build_verify_summaries(&ctx.verify_blocks, &fn_sigs);
162    ctx.verify_counts = verify_counts;
163    ctx.verify_samples = verify_samples;
164
165    // Prefer the module-level `effects [...]` declaration when the
166    // user spelled it out — it's the source-of-truth boundary, and
167    // may use namespace shorthand (`Disk` covers `Disk.*`) that the
168    // per-fn aggregation can't recover. Fall back to walking every
169    // fn's `! [...]` only for legacy (0.12-style) modules that omit
170    // the boundary.
171    ctx.module_effects = if let Some(declared) = declared_module_effects {
172        unique_sorted_effects(declared.iter())
173    } else {
174        unique_sorted_effects(
175            ctx.fn_defs
176                .iter()
177                .flat_map(|fd| fd.effects.iter().map(|e| &e.node)),
178        )
179    };
180    ctx.api_effects = unique_sorted_effects(
181        ctx.fn_defs
182            .iter()
183            .filter(|fd| ctx.exposes.contains(&fd.name))
184            .flat_map(|fd| fd.effects.iter().map(|e| &e.node)),
185    );
186    ctx.main_effects = ctx
187        .fn_defs
188        .iter()
189        .find(|fd| fd.name == "main")
190        .map(|fd| unique_sorted_effects(fd.effects.iter().map(|e| &e.node)));
191
192    if !ctx.exposes.is_empty() {
193        let exposes = ctx.exposes.clone();
194        ctx.fn_defs.retain(|fd| exposes.contains(&fd.name));
195    }
196
197    ctx
198}
199
200// ─── Serializable projection for playground / LSP ────────────────────────────
201
202#[derive(Clone, Debug, Serialize)]
203pub struct ContextSummary {
204    pub file_label: String,
205    pub module_name: Option<String>,
206    pub intent: Option<String>,
207    pub depends: Vec<String>,
208    pub exposes: Vec<String>,
209    pub exposes_opaque: Vec<String>,
210    pub api_effects: Vec<String>,
211    pub module_effects: Vec<String>,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub main_effects: Option<Vec<String>>,
214    pub functions: Vec<ContextFnSummary>,
215    pub types: Vec<ContextTypeSummary>,
216    pub decisions: Vec<ContextDecisionSummary>,
217}
218
219#[derive(Clone, Debug, Serialize)]
220pub struct ContextFnSummary {
221    pub name: String,
222    pub signature: String,
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub description: Option<String>,
225    pub effects: Vec<String>,
226    pub qualifiers: Vec<String>,
227    pub auto_memo: bool,
228    pub auto_tco: bool,
229    pub recursive_callsites: usize,
230    pub verify_count: usize,
231    pub verify_samples: Vec<String>,
232    pub is_exposed: bool,
233    pub specs: Vec<String>,
234    pub direct_calls: Vec<String>,
235}
236
237#[derive(Clone, Debug, Serialize)]
238pub struct ContextTypeSummary {
239    pub name: String,
240    pub kind: &'static str,
241    pub fields_or_variants: Vec<String>,
242}
243
244#[derive(Clone, Debug, Serialize)]
245pub struct ContextDecisionSummary {
246    pub name: String,
247    pub date: String,
248    pub reason_prefix: String,
249    pub impacts: Vec<String>,
250}
251
252/// Project a full [`FileContext`] into a serde-serializable summary.
253///
254/// The summary is lossy (signatures rendered as strings, private fns
255/// dropped, type bodies reduced to field/variant names). It's designed
256/// for JSON emission — playground Context panel, future LSP metadata.
257pub fn summarize(ctx: &FileContext) -> ContextSummary {
258    let functions = ctx
259        .all_fn_defs
260        .iter()
261        .map(|fd| {
262            let effects: Vec<String> = fd.effects.iter().map(|e| e.node.clone()).collect();
263            let qualifiers = ctx.fn_memo_qual.get(&fd.name).cloned().unwrap_or_default();
264            let description = fd.desc.clone();
265            let signature = render_signature(fd);
266            let is_exposed = ctx.exposes.is_empty() || ctx.exposes.contains(&fd.name);
267            let specs = ctx.fn_specs.get(&fd.name).cloned().unwrap_or_default();
268            let direct = ctx
269                .fn_direct_calls
270                .get(&fd.name)
271                .cloned()
272                .unwrap_or_default();
273            ContextFnSummary {
274                name: fd.name.clone(),
275                signature,
276                description,
277                effects,
278                qualifiers,
279                auto_memo: ctx.fn_auto_memo.contains(&fd.name),
280                auto_tco: ctx.fn_auto_tco.contains(&fd.name),
281                recursive_callsites: ctx
282                    .fn_recursive_callsites
283                    .get(&fd.name)
284                    .copied()
285                    .unwrap_or(0),
286                verify_count: ctx.verify_counts.get(&fd.name).copied().unwrap_or(0),
287                verify_samples: ctx
288                    .verify_samples
289                    .get(&fd.name)
290                    .cloned()
291                    .unwrap_or_default(),
292                is_exposed,
293                specs,
294                direct_calls: direct,
295            }
296        })
297        .collect();
298
299    let types = ctx
300        .type_defs
301        .iter()
302        .map(|td| match td {
303            TypeDef::Sum { name, variants, .. } => ContextTypeSummary {
304                name: name.clone(),
305                kind: "sum",
306                fields_or_variants: variants.iter().map(|v| v.name.clone()).collect(),
307            },
308            TypeDef::Product { name, fields, .. } => ContextTypeSummary {
309                name: name.clone(),
310                kind: "product",
311                fields_or_variants: fields.iter().map(|(n, _)| n.clone()).collect(),
312            },
313        })
314        .collect();
315
316    let decisions = ctx
317        .decisions
318        .iter()
319        .map(|d| {
320            let reason_prefix: String = d.reason.chars().take(60).collect();
321            let reason_prefix = if d.reason.len() > 60 {
322                format!("{}...", reason_prefix.trim_end())
323            } else {
324                reason_prefix
325            };
326            ContextDecisionSummary {
327                name: d.name.clone(),
328                date: d.date.clone(),
329                reason_prefix,
330                impacts: d
331                    .impacts
332                    .iter()
333                    .map(|i| i.node.text().to_string())
334                    .collect(),
335            }
336        })
337        .collect();
338
339    ContextSummary {
340        file_label: ctx.source_file.clone(),
341        module_name: ctx.module_name.clone(),
342        intent: ctx.intent.clone(),
343        depends: ctx.depends.clone(),
344        exposes: ctx.exposes.clone(),
345        exposes_opaque: ctx.exposes_opaque.clone(),
346        api_effects: ctx.api_effects.clone(),
347        module_effects: ctx.module_effects.clone(),
348        main_effects: ctx.main_effects.clone(),
349        functions,
350        types,
351        decisions,
352    }
353}
354
355/// Render a [`ContextSummary`] as markdown — same shape as the CLI's
356/// `aver context --md` for a single-entry project. Playground uses
357/// this for the ⬇ Download as .md button so the browser and the CLI
358/// emit identical files for identical source.
359pub fn render_context_md(summary: &ContextSummary) -> String {
360    let mut out = String::new();
361    out.push_str(&format!("# Aver Context — {}\n\n", summary.file_label));
362    out.push_str("_Generated by `aver context`_\n\n");
363
364    out.push_str("---\n\n");
365    if let Some(name) = &summary.module_name {
366        out.push_str(&format!("## Module: {}\n\n", name));
367    } else {
368        out.push_str(&format!("## {}\n\n", summary.file_label));
369    }
370
371    if let Some(intent) = &summary.intent {
372        out.push_str(&format!("> {}\n\n", intent));
373    }
374
375    if !summary.depends.is_empty() {
376        out.push_str(&format!("depends: `[{}]`  \n", summary.depends.join(", ")));
377    }
378    if !summary.exposes.is_empty() {
379        out.push_str(&format!("exposes: `[{}]`  \n", summary.exposes.join(", ")));
380    }
381    if !summary.exposes_opaque.is_empty() {
382        out.push_str(&format!(
383            "exposes opaque: `[{}]`  \n",
384            summary.exposes_opaque.join(", ")
385        ));
386    }
387
388    // Collapse api/module effects when equal, mirror CLI output.
389    if effects_equal(&summary.api_effects, &summary.module_effects) {
390        if !summary.module_effects.is_empty() {
391            out.push_str(&format!(
392                "effects: `[{}]`\n",
393                summary.module_effects.join(", ")
394            ));
395        }
396    } else {
397        out.push_str(&format!(
398            "api_effects: `[{}]`  \nmodule_effects: `[{}]`\n",
399            summary.api_effects.join(", "),
400            summary.module_effects.join(", ")
401        ));
402    }
403    if let Some(main_effects) = &summary.main_effects {
404        out.push_str(&format!("main_effects: `[{}]`\n", main_effects.join(", ")));
405    }
406    out.push('\n');
407
408    for ty in &summary.types {
409        let header = match ty.kind {
410            "record" => format!("### record {}\n", ty.name),
411            _ => format!("### type {}\n", ty.name),
412        };
413        out.push_str(&header);
414        if !ty.fields_or_variants.is_empty() {
415            out.push_str(&format!("`{}`\n\n", ty.fields_or_variants.join("` | `")));
416        } else {
417            out.push('\n');
418        }
419    }
420
421    for fd in &summary.functions {
422        if fd.name == "main" {
423            continue;
424        }
425        out.push_str(&format!("### `{}`\n", fd.signature));
426        if !fd.effects.is_empty() {
427            out.push_str(&format!("effects: `[{}]`  \n", fd.effects.join(", ")));
428        }
429        if fd.auto_memo {
430            out.push_str("memo: `true`  \n");
431        }
432        if fd.auto_tco {
433            out.push_str("tco: `true`  \n");
434        }
435        if !fd.specs.is_empty() {
436            let label = if fd.specs.len() == 1 { "spec" } else { "specs" };
437            out.push_str(&format!("{}: `[{}]`  \n", label, fd.specs.join(", ")));
438        }
439        if !fd.is_exposed {
440            out.push_str("visibility: `private`  \n");
441        }
442        if let Some(desc) = &fd.description {
443            out.push_str(&format!("> {}\n", desc));
444        }
445        if !fd.verify_samples.is_empty() {
446            let samples: Vec<String> = fd
447                .verify_samples
448                .iter()
449                .map(|s| format!("`{}`", s))
450                .collect();
451            out.push_str(&format!("verify: {}\n", samples.join(", ")));
452        }
453        out.push('\n');
454    }
455
456    if !summary.decisions.is_empty() {
457        out.push_str("---\n\n## Decisions\n\n");
458        for dec in &summary.decisions {
459            out.push_str(&format!("### {} ({})\n", dec.name, dec.date));
460            if !dec.reason_prefix.is_empty() {
461                out.push_str(&format!("> {}\n", dec.reason_prefix));
462            }
463            if !dec.impacts.is_empty() {
464                out.push_str(&format!("impacts: `{}`\n", dec.impacts.join("`, `")));
465            }
466            out.push('\n');
467        }
468    }
469
470    out
471}
472
473fn effects_equal(a: &[String], b: &[String]) -> bool {
474    if a.len() != b.len() {
475        return false;
476    }
477    let mut a = a.to_vec();
478    let mut b = b.to_vec();
479    a.sort();
480    b.sort();
481    a == b
482}
483
484fn render_signature(fd: &FnDef) -> String {
485    // Type-only signature: params + return type. Effects live on the
486    // separate `effects: [...]` JSON field so renderers (playground,
487    // LLMs) can show them alongside without duplicating on screen.
488    let params = fd
489        .params
490        .iter()
491        .map(|(name, type_str)| format!("{}: {}", name, type_str))
492        .collect::<Vec<_>>()
493        .join(", ");
494    format!("fn {}({}) -> {}", fd.name, params, fd.return_type)
495}
496
497// ─── Pure helpers exposed to CLI / playground ────────────────────────────────
498
499/// Functions that qualify for auto-memoization: pure, recursive with
500/// branching, and all parameter types memo-safe.
501///
502/// Reads recursion facts from `PipelineResult.analysis` when the pipeline
503/// has run; falls back to ad-hoc computation when the analysis isn't
504/// available (callers that haven't migrated to the pipeline). The fallback
505/// path will go away once every consumer reads from analysis directly.
506pub fn compute_memo_fns(
507    items: &[TopLevel],
508    tc_result: &TypeCheckResult,
509    analysis: Option<&crate::ir::AnalysisResult>,
510) -> HashSet<String> {
511    let mut memo = HashSet::new();
512
513    let memo_check = |fn_name: &str, recursive_calls: usize| -> bool {
514        let Some((params, _ret, effects)) = tc_result.fn_sigs.get(fn_name) else {
515            return false;
516        };
517        if !effects.is_empty() {
518            return false;
519        }
520        if recursive_calls < 2 {
521            return false;
522        }
523        params
524            .iter()
525            .all(|ty| is_memo_safe_type(ty, &tc_result.memo_safe_types))
526    };
527
528    if let Some(analysis) = analysis {
529        for fn_name in &analysis.recursive_fns {
530            let calls = analysis
531                .fn_analyses
532                .get(fn_name)
533                .map(|a| a.recursive_call_count)
534                .unwrap_or(0);
535            if memo_check(fn_name, calls) {
536                memo.insert(fn_name.clone());
537            }
538        }
539    } else {
540        let recursive = find_recursive_fns(items);
541        let recursive_calls = recursive_callsite_counts(items);
542        for fn_name in &recursive {
543            let calls = recursive_calls.get(fn_name).copied().unwrap_or(0);
544            if memo_check(fn_name, calls) {
545                memo.insert(fn_name.clone());
546            }
547        }
548    }
549
550    memo
551}
552
553pub fn is_memo_safe_type(ty: &crate::types::Type, safe_named: &HashSet<String>) -> bool {
554    use crate::types::Type;
555    match ty {
556        Type::Int | Type::Float | Type::Bool | Type::Unit => true,
557        Type::Str => false,
558        Type::Tuple(items) => items.iter().all(|item| is_memo_safe_type(item, safe_named)),
559        Type::List(_)
560        | Type::Vector(_)
561        | Type::Map(_, _)
562        | Type::Fn(_, _, _)
563        | Type::Invalid
564        | Type::Var(_) => false,
565        Type::Result(_, _) | Type::Option(_) => false,
566        Type::Named(name) => safe_named.contains(name),
567    }
568}
569
570// ─── Internal helpers ────────────────────────────────────────────────────────
571
572const VERIFY_SAMPLE_LIMIT: usize = 3;
573const VERIFY_CASE_MAX_LEN: usize = 150;
574
575fn unique_sorted_effects<'a, I>(effects: I) -> Vec<String>
576where
577    I: Iterator<Item = &'a String>,
578{
579    let mut uniq = effects
580        .cloned()
581        .collect::<HashSet<_>>()
582        .into_iter()
583        .collect::<Vec<_>>();
584    uniq.sort();
585    uniq
586}
587
588fn classify_verify_case(lhs: &str, rhs: &str, ret_category: Option<&str>) -> Vec<String> {
589    let combined = format!("{lhs} -> {rhs}");
590    let mut categories = Vec::new();
591
592    match ret_category {
593        Some("result") => {
594            if rhs.contains("Result.Ok(") || rhs.contains("Ok(") {
595                categories.push("ok".to_string());
596            }
597            if rhs.contains("Result.Err(") || rhs.contains("Err(") {
598                categories.push("err".to_string());
599            }
600        }
601        Some("option") => {
602            if rhs.contains("Option.Some(") || rhs.contains("Some(") {
603                categories.push("some".to_string());
604            }
605            if rhs.contains("Option.None") || rhs == "None" {
606                categories.push("none".to_string());
607            }
608        }
609        Some("bool") => {
610            if rhs == "true" {
611                categories.push("true".to_string());
612            }
613            if rhs == "false" {
614                categories.push("false".to_string());
615            }
616        }
617        _ => {}
618    }
619
620    if combined.contains("[]") || combined.contains("{}") {
621        categories.push("empty".to_string());
622    }
623    if combined.contains("-1") || combined.contains("(0 - ") {
624        categories.push("negative".to_string());
625    }
626    if combined.contains("(0)") || rhs == "0" {
627        categories.push("zero".to_string());
628    }
629    if combined.contains("\"\"") {
630        categories.push("empty-string".to_string());
631    }
632
633    if ret_category == Some("named")
634        && let Some(dot_pos) = rhs.find('.')
635    {
636        let after_dot = &rhs[dot_pos + 1..];
637        let ctor = after_dot.split('(').next().unwrap_or(after_dot);
638        categories.push(format!("ctor:{ctor}"));
639    }
640
641    categories.sort();
642    categories.dedup();
643    categories
644}
645
646fn base_verify_case_score(lhs: &str, rhs: &str) -> i32 {
647    let combined_len = lhs.len() + rhs.len();
648    let mut score = 400 - combined_len as i32;
649    let combined = format!("{lhs} -> {rhs}");
650    if rhs.contains("Result.Err(")
651        || rhs.contains("ParseResult.Err(")
652        || rhs.contains("Option.None")
653    {
654        score += 120;
655    }
656    if combined.contains("[]") || combined.contains("{}") {
657        score += 60;
658    }
659    if combined.contains("\"\"") {
660        score += 45;
661    }
662    if combined.contains("-1") || combined.contains("(0 - ") {
663        score += 45;
664    }
665    if combined.contains(", 0") || combined.contains("(0)") || rhs == "0" {
666        score += 30;
667    }
668    if rhs == "true" || rhs == "false" {
669        score += 20;
670    }
671    score
672}
673
674fn scored_verify_samples(cases: &[(String, String)], ret_category: Option<&str>) -> Vec<String> {
675    #[derive(Clone)]
676    struct ScoredVerifyCase {
677        rendered: String,
678        base_score: i32,
679        categories: Vec<String>,
680        original_index: usize,
681    }
682
683    let mut scored = cases
684        .iter()
685        .enumerate()
686        .filter_map(|(original_index, (lhs_text, rhs_text))| {
687            if lhs_text.len() + rhs_text.len() > VERIFY_CASE_MAX_LEN {
688                return None;
689            }
690            Some(ScoredVerifyCase {
691                rendered: format!("{lhs_text} => {rhs_text}"),
692                base_score: base_verify_case_score(lhs_text, rhs_text),
693                categories: classify_verify_case(lhs_text, rhs_text, ret_category),
694                original_index,
695            })
696        })
697        .collect::<Vec<_>>();
698
699    let mut selected = Vec::new();
700    let mut seen_categories: HashSet<String> = HashSet::new();
701    while selected.len() < VERIFY_SAMPLE_LIMIT && !scored.is_empty() {
702        let best_idx = scored
703            .iter()
704            .enumerate()
705            .max_by_key(|(_, case)| {
706                let novelty = case
707                    .categories
708                    .iter()
709                    .filter(|cat| !seen_categories.contains(cat.as_str()))
710                    .count() as i32;
711                (
712                    case.base_score + novelty * 35,
713                    case.base_score,
714                    -(case.original_index as i32),
715                )
716            })
717            .map(|(idx, _)| idx)
718            .expect("verify samples should be non-empty");
719        let chosen = scored.swap_remove(best_idx);
720        for category in &chosen.categories {
721            seen_categories.insert(category.clone());
722        }
723        selected.push(chosen.rendered);
724    }
725    selected
726}
727
728fn return_type_category(
729    fn_name: &str,
730    fn_sigs: &HashMap<String, (Vec<crate::types::Type>, crate::types::Type, Vec<String>)>,
731) -> Option<&'static str> {
732    let (_, ret, _) = fn_sigs.get(fn_name)?;
733    match ret {
734        crate::types::Type::Result(_, _) => Some("result"),
735        crate::types::Type::Option(_) => Some("option"),
736        crate::types::Type::Bool => Some("bool"),
737        crate::types::Type::List(_) => Some("list"),
738        crate::types::Type::Named(_) => Some("named"),
739        _ => None,
740    }
741}
742
743fn build_verify_summaries(
744    verify_blocks: &[VerifyBlock],
745    fn_sigs: &HashMap<String, (Vec<crate::types::Type>, crate::types::Type, Vec<String>)>,
746) -> (HashMap<String, usize>, HashMap<String, Vec<String>>) {
747    let mut cases_by_fn: HashMap<String, Vec<(String, String)>> = HashMap::new();
748    for block in verify_blocks {
749        let entry = cases_by_fn.entry(block.fn_name.clone()).or_default();
750        for (lhs, rhs) in &block.cases {
751            entry.push((expr_to_str(lhs), expr_to_str(rhs)));
752        }
753    }
754
755    let verify_counts = cases_by_fn
756        .iter()
757        .map(|(fn_name, cases)| (fn_name.clone(), cases.len()))
758        .collect::<HashMap<_, _>>();
759    let verify_samples = cases_by_fn
760        .into_iter()
761        .map(|(fn_name, cases)| {
762            let ret_cat = return_type_category(&fn_name, fn_sigs);
763            (fn_name, scored_verify_samples(&cases, ret_cat))
764        })
765        .collect::<HashMap<_, _>>();
766
767    (verify_counts, verify_samples)
768}
769
770struct ContextFnFlags {
771    auto_memo: HashSet<String>,
772    auto_tco: HashSet<String>,
773    memo_qual: HashMap<String, Vec<String>>,
774    recursive_callsites: HashMap<String, usize>,
775    recursive_scc_id: HashMap<String, usize>,
776    fn_sigs: HashMap<String, (Vec<crate::types::Type>, crate::types::Type, Vec<String>)>,
777}
778
779fn expr_has_tail_call(expr: &crate::ast::Spanned<crate::ast::Expr>) -> bool {
780    use crate::ast::Expr;
781    match &expr.node {
782        Expr::TailCall(_) => true,
783        Expr::Literal(_) | Expr::Ident(_) | Expr::Resolved { .. } => false,
784        Expr::Attr(obj, _) => expr_has_tail_call(obj),
785        Expr::FnCall(f, args) => expr_has_tail_call(f) || args.iter().any(expr_has_tail_call),
786        Expr::BinOp(_, l, r) => expr_has_tail_call(l) || expr_has_tail_call(r),
787        Expr::Match { subject, arms, .. } => {
788            expr_has_tail_call(subject) || arms.iter().any(|arm| expr_has_tail_call(&arm.body))
789        }
790        Expr::Constructor(_, arg) => arg.as_ref().is_some_and(|a| expr_has_tail_call(a)),
791        Expr::ErrorProp(inner) => expr_has_tail_call(inner),
792        Expr::InterpolatedStr(parts) => parts.iter().any(|part| match part {
793            crate::ast::StrPart::Literal(_) => false,
794            crate::ast::StrPart::Parsed(e) => expr_has_tail_call(e),
795        }),
796        Expr::List(items) | Expr::Tuple(items) | Expr::IndependentProduct(items, _) => {
797            items.iter().any(expr_has_tail_call)
798        }
799        Expr::MapLiteral(entries) => entries
800            .iter()
801            .any(|(k, v)| expr_has_tail_call(k) || expr_has_tail_call(v)),
802        Expr::RecordCreate { fields, .. } => fields.iter().any(|(_, e)| expr_has_tail_call(e)),
803        Expr::RecordUpdate { base, updates, .. } => {
804            expr_has_tail_call(base) || updates.iter().any(|(_, e)| expr_has_tail_call(e))
805        }
806    }
807}
808
809fn fn_has_tail_call(fd: &FnDef) -> bool {
810    fd.body.stmts().iter().any(|stmt| match stmt {
811        crate::ast::Stmt::Binding(_, _, expr) | crate::ast::Stmt::Expr(expr) => {
812            expr_has_tail_call(expr)
813        }
814    })
815}
816
817fn compute_context_fn_flags(items: &[TopLevel], module_root: Option<&str>) -> ContextFnFlags {
818    let mut transformed = items.to_vec();
819    crate::ir::pipeline::tco(&mut transformed);
820    let tco_fns = transformed
821        .iter()
822        .filter_map(|item| match item {
823            TopLevel::FnDef(fd) if fn_has_tail_call(fd) => Some(fd.name.clone()),
824            _ => None,
825        })
826        .collect::<HashSet<_>>();
827    let recursive = find_recursive_fns(&transformed);
828    let recursive_callsites = recursive_callsite_counts(&transformed);
829    let recursive_scc_id = recursive_scc_ids(&transformed);
830    let mut memo_qual = HashMap::new();
831
832    let tc_result = crate::ir::pipeline::typecheck(
833        &transformed,
834        &crate::ir::TypecheckMode::Full {
835            base_dir: module_root,
836        },
837    );
838    if !tc_result.errors.is_empty() {
839        for item in &transformed {
840            if let TopLevel::FnDef(fd) = item {
841                let mut qual = Vec::new();
842                if fd.effects.is_empty() {
843                    qual.push("PURE".to_string());
844                }
845                if recursive.contains(&fd.name) {
846                    qual.push("RECURSIVE".to_string());
847                }
848                memo_qual.insert(fd.name.clone(), qual);
849            }
850        }
851        return ContextFnFlags {
852            auto_memo: HashSet::new(),
853            auto_tco: tco_fns,
854            memo_qual,
855            recursive_callsites,
856            recursive_scc_id,
857            fn_sigs: tc_result.fn_sigs,
858        };
859    }
860
861    for item in &transformed {
862        if let TopLevel::FnDef(fd) = item {
863            let mut qual = Vec::new();
864            if let Some((params, _ret, effects)) = tc_result.fn_sigs.get(&fd.name) {
865                if effects.is_empty() {
866                    qual.push("PURE".to_string());
867                }
868                if recursive.contains(&fd.name) {
869                    qual.push("RECURSIVE".to_string());
870                }
871                let safe_args = params
872                    .iter()
873                    .all(|ty| is_memo_safe_type(ty, &tc_result.memo_safe_types));
874                if safe_args {
875                    qual.push("SAFE_ARGS".to_string());
876                }
877            }
878            memo_qual.insert(fd.name.clone(), qual);
879        }
880    }
881
882    ContextFnFlags {
883        // `aver context` doesn't run the full pipeline (its IR shape is
884        // pre-resolve/pre-analyze for diagnostic display), so pass `None`
885        // and let the fallback path compute recursion facts ad-hoc.
886        auto_memo: compute_memo_fns(&transformed, &tc_result, None),
887        auto_tco: tco_fns,
888        memo_qual,
889        recursive_callsites,
890        recursive_scc_id,
891        fn_sigs: tc_result.fn_sigs,
892    }
893}