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