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