Skip to main content

aver/
checker.rs

1use colored::Colorize;
2use std::collections::BTreeSet;
3
4use crate::ast::{
5    DecisionBlock, DecisionImpact, Expr, FnBody, FnDef, Stmt, TopLevel, VerifyBlock,
6    VerifyGivenDomain, VerifyKind,
7};
8use crate::interpreter::{Interpreter, aver_repr};
9use crate::types::{Type, parse_type_str_strict};
10use crate::value::{RuntimeError, Value};
11use crate::verify_law::{canonical_spec_ref, named_law_function};
12
13pub struct VerifyResult {
14    #[allow(dead_code)]
15    pub fn_name: String,
16    pub passed: usize,
17    pub failed: usize,
18    #[allow(dead_code)]
19    pub failures: Vec<(String, String, String)>, // (expr_src, expected, actual)
20}
21
22pub struct ModuleCheckFindings {
23    pub errors: Vec<CheckFinding>,
24    pub warnings: Vec<CheckFinding>,
25}
26
27type FnSigSummary = (Vec<Type>, Type, Vec<String>);
28type FnSigMap = std::collections::HashMap<String, FnSigSummary>;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct CheckFinding {
32    pub line: usize,
33    pub module: Option<String>,
34    pub file: Option<String>,
35    pub message: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39enum VerifyOutputShape {
40    BoolTrue,
41    BoolFalse,
42    Some,
43    None,
44    Ok,
45    Err,
46    Variant(String),
47}
48
49impl VerifyOutputShape {
50    fn display(&self) -> String {
51        match self {
52            VerifyOutputShape::BoolTrue => "true".to_string(),
53            VerifyOutputShape::BoolFalse => "false".to_string(),
54            VerifyOutputShape::Some => "Option.Some".to_string(),
55            VerifyOutputShape::None => "Option.None".to_string(),
56            VerifyOutputShape::Ok => "Result.Ok".to_string(),
57            VerifyOutputShape::Err => "Result.Err".to_string(),
58            VerifyOutputShape::Variant(name) => name.clone(),
59        }
60    }
61}
62
63struct VerifyShapeContract {
64    return_type: Type,
65    expected: Vec<VerifyOutputShape>,
66    seen: std::collections::HashSet<VerifyOutputShape>,
67}
68
69impl VerifyShapeContract {
70    fn observe(&mut self, value: &Value) {
71        if let Some(shape) = observed_output_shape_for_type(&self.return_type, value) {
72            self.seen.insert(shape);
73        }
74    }
75
76    fn observe_shape(&mut self, shape: VerifyOutputShape) {
77        self.seen.insert(shape);
78    }
79
80    fn missing(&self) -> Vec<VerifyOutputShape> {
81        self.expected
82            .iter()
83            .filter(|shape| !self.seen.contains(*shape))
84            .cloned()
85            .collect()
86    }
87}
88
89fn build_verify_shape_contract(
90    block: &VerifyBlock,
91    interp: &Interpreter,
92) -> Option<VerifyShapeContract> {
93    let fn_val = interp.lookup(&block.fn_name).ok()?;
94    let Value::Fn {
95        return_type, body, ..
96    } = fn_val
97    else {
98        return None;
99    };
100    let ret_ty = parse_type_str_strict(&return_type).ok()?;
101
102    let all_shapes = expected_output_shapes_for_type(&ret_ty, interp)?;
103    let mut declared_shapes = std::collections::HashSet::new();
104    collect_declared_output_shapes_from_body(body.as_ref(), &ret_ty, &mut declared_shapes);
105    let expected: Vec<VerifyOutputShape> = all_shapes
106        .into_iter()
107        .filter(|shape| declared_shapes.contains(shape))
108        .collect();
109    if expected.len() < 2 {
110        return None;
111    }
112
113    Some(VerifyShapeContract {
114        return_type: ret_ty,
115        expected,
116        seen: std::collections::HashSet::new(),
117    })
118}
119
120fn expected_output_shapes_for_type(
121    ty: &Type,
122    interp: &Interpreter,
123) -> Option<Vec<VerifyOutputShape>> {
124    match ty {
125        Type::Bool => Some(vec![
126            VerifyOutputShape::BoolTrue,
127            VerifyOutputShape::BoolFalse,
128        ]),
129        Type::Option(_) => Some(vec![VerifyOutputShape::Some, VerifyOutputShape::None]),
130        Type::Result(_, _) => Some(vec![VerifyOutputShape::Ok, VerifyOutputShape::Err]),
131        Type::Named(type_name) => {
132            let ns = interp.lookup(type_name).ok()?;
133            let Value::Namespace { members, .. } = ns else {
134                return None;
135            };
136
137            let ctor_prefix = format!("__ctor:{}:", type_name);
138            let mut variants = std::collections::BTreeSet::new();
139
140            for (member_name, member_value) in members {
141                match member_value {
142                    Value::Variant { type_name: t, .. } if t == type_name.as_str() => {
143                        variants.insert(member_name);
144                    }
145                    Value::Builtin(builtin_name) if builtin_name.starts_with(&ctor_prefix) => {
146                        variants.insert(member_name);
147                    }
148                    _ => {}
149                }
150            }
151
152            if variants.is_empty() {
153                return None;
154            }
155
156            Some(
157                variants
158                    .into_iter()
159                    .map(VerifyOutputShape::Variant)
160                    .collect(),
161            )
162        }
163        _ => None,
164    }
165}
166
167fn observed_output_shape_for_type(ty: &Type, value: &Value) -> Option<VerifyOutputShape> {
168    match ty {
169        Type::Bool => match value {
170            Value::Bool(true) => Some(VerifyOutputShape::BoolTrue),
171            Value::Bool(false) => Some(VerifyOutputShape::BoolFalse),
172            _ => None,
173        },
174        Type::Option(_) => match value {
175            Value::Some(_) => Some(VerifyOutputShape::Some),
176            Value::None => Some(VerifyOutputShape::None),
177            _ => None,
178        },
179        Type::Result(_, _) => match value {
180            Value::Ok(_) => Some(VerifyOutputShape::Ok),
181            Value::Err(_) => Some(VerifyOutputShape::Err),
182            _ => None,
183        },
184        Type::Named(type_name) => match value {
185            Value::Variant {
186                type_name: actual_type,
187                variant,
188                ..
189            } if actual_type == type_name => Some(VerifyOutputShape::Variant(variant.clone())),
190            _ => None,
191        },
192        _ => None,
193    }
194}
195
196fn collect_declared_output_shapes_from_body(
197    body: &FnBody,
198    ret_ty: &Type,
199    out: &mut std::collections::HashSet<VerifyOutputShape>,
200) {
201    if let Some(expr) = body.tail_expr() {
202        collect_declared_output_shapes_from_tail_expr(expr, ret_ty, out);
203    }
204}
205
206fn collect_declared_output_shapes_from_tail_expr(
207    expr: &Expr,
208    ret_ty: &Type,
209    out: &mut std::collections::HashSet<VerifyOutputShape>,
210) {
211    match expr {
212        Expr::Match { arms, .. } => {
213            for arm in arms {
214                collect_declared_output_shapes_from_tail_expr(&arm.body, ret_ty, out);
215            }
216        }
217        _ => {
218            if let Some(shape) = declared_output_shape_from_expr(ret_ty, expr) {
219                out.insert(shape);
220            }
221        }
222    }
223}
224
225fn declared_output_shape_from_expr(ret_ty: &Type, expr: &Expr) -> Option<VerifyOutputShape> {
226    match ret_ty {
227        Type::Bool => match expr {
228            Expr::Literal(crate::ast::Literal::Bool(true)) => Some(VerifyOutputShape::BoolTrue),
229            Expr::Literal(crate::ast::Literal::Bool(false)) => Some(VerifyOutputShape::BoolFalse),
230            _ => None,
231        },
232        Type::Option(_) => match expr {
233            Expr::FnCall(callee, _) => match dotted_name(callee) {
234                Some(path) if path == "Option.Some" => Some(VerifyOutputShape::Some),
235                _ => None,
236            },
237            _ => match dotted_name(expr) {
238                Some(path) if path == "Option.None" => Some(VerifyOutputShape::None),
239                _ => None,
240            },
241        },
242        Type::Result(_, _) => match expr {
243            Expr::FnCall(callee, _) => match dotted_name(callee) {
244                Some(path) if path == "Result.Ok" => Some(VerifyOutputShape::Ok),
245                Some(path) if path == "Result.Err" => Some(VerifyOutputShape::Err),
246                _ => None,
247            },
248            _ => None,
249        },
250        Type::Named(type_name) => {
251            let prefix = format!("{}.", type_name);
252            match expr {
253                Expr::Attr(_, _) => {
254                    let path = dotted_name(expr)?;
255                    let variant = path.strip_prefix(&prefix)?;
256                    if variant.is_empty() {
257                        None
258                    } else {
259                        Some(VerifyOutputShape::Variant(variant.to_string()))
260                    }
261                }
262                Expr::FnCall(callee, _) => {
263                    let path = dotted_name(callee)?;
264                    let variant = path.strip_prefix(&prefix)?;
265                    if variant.is_empty() {
266                        None
267                    } else {
268                        Some(VerifyOutputShape::Variant(variant.to_string()))
269                    }
270                }
271                _ => None,
272            }
273        }
274        _ => None,
275    }
276}
277
278fn dotted_name(expr: &Expr) -> Option<String> {
279    match expr {
280        Expr::Ident(name) => Some(name.clone()),
281        Expr::Attr(base, field) => {
282            let mut prefix = dotted_name(base)?;
283            prefix.push('.');
284            prefix.push_str(field);
285            Some(prefix)
286        }
287        _ => None,
288    }
289}
290
291fn verify_case_uses_error_prop_on_target(expr: &Expr, fn_name: &str) -> bool {
292    match expr {
293        Expr::ErrorProp(inner) => {
294            verify_case_calls_target(inner, fn_name)
295                || verify_case_uses_error_prop_on_target(inner, fn_name)
296        }
297        Expr::FnCall(callee, args) => {
298            verify_case_uses_error_prop_on_target(callee, fn_name)
299                || args
300                    .iter()
301                    .any(|arg| verify_case_uses_error_prop_on_target(arg, fn_name))
302        }
303        Expr::BinOp(_, left, right) => {
304            verify_case_uses_error_prop_on_target(left, fn_name)
305                || verify_case_uses_error_prop_on_target(right, fn_name)
306        }
307        Expr::Match { subject, arms, .. } => {
308            verify_case_uses_error_prop_on_target(subject, fn_name)
309                || arms
310                    .iter()
311                    .any(|arm| verify_case_uses_error_prop_on_target(&arm.body, fn_name))
312        }
313        Expr::Constructor(_, Some(inner)) => verify_case_uses_error_prop_on_target(inner, fn_name),
314        Expr::List(elems) => elems
315            .iter()
316            .any(|elem| verify_case_uses_error_prop_on_target(elem, fn_name)),
317        Expr::Tuple(items) => items
318            .iter()
319            .any(|item| verify_case_uses_error_prop_on_target(item, fn_name)),
320        Expr::MapLiteral(entries) => entries.iter().any(|(k, v)| {
321            verify_case_uses_error_prop_on_target(k, fn_name)
322                || verify_case_uses_error_prop_on_target(v, fn_name)
323        }),
324        Expr::Attr(obj, _) => verify_case_uses_error_prop_on_target(obj, fn_name),
325        Expr::RecordCreate { fields, .. } => fields
326            .iter()
327            .any(|(_, expr)| verify_case_uses_error_prop_on_target(expr, fn_name)),
328        Expr::RecordUpdate { base, updates, .. } => {
329            verify_case_uses_error_prop_on_target(base, fn_name)
330                || updates
331                    .iter()
332                    .any(|(_, expr)| verify_case_uses_error_prop_on_target(expr, fn_name))
333        }
334        Expr::TailCall(boxed) => {
335            boxed.0 == fn_name
336                || boxed
337                    .1
338                    .iter()
339                    .any(|arg| verify_case_uses_error_prop_on_target(arg, fn_name))
340        }
341        Expr::Literal(_)
342        | Expr::Ident(_)
343        | Expr::InterpolatedStr(_)
344        | Expr::Resolved(_)
345        | Expr::Constructor(_, None) => false,
346    }
347}
348
349fn verify_given_domain_to_str(domain: &VerifyGivenDomain) -> String {
350    match domain {
351        VerifyGivenDomain::IntRange { start, end } => format!("{start}..{end}"),
352        VerifyGivenDomain::Explicit(values) => {
353            let parts: Vec<String> = values.iter().map(expr_to_str).collect();
354            format!("[{}]", parts.join(", "))
355        }
356    }
357}
358
359pub fn run_verify(block: &VerifyBlock, interp: &mut Interpreter) -> VerifyResult {
360    let mut passed = 0;
361    let mut failed = 0;
362    let mut failures = Vec::new();
363    let is_law = matches!(block.kind, VerifyKind::Law(_));
364    let mut shape_contract = if is_law {
365        None
366    } else {
367        build_verify_shape_contract(block, interp)
368    };
369
370    match &block.kind {
371        VerifyKind::Cases => println!("Verify: {}", block.fn_name.cyan()),
372        VerifyKind::Law(law) => {
373            if let Ok(Value::Fn { effects, .. }) = interp.lookup(&law.name)
374                && effects.is_empty()
375                && crate::verify_law::canonical_spec_shape(&block.fn_name, law, &law.name)
376            {
377                println!("Verify: {} spec {}", block.fn_name.cyan(), law.name.cyan());
378            } else {
379                println!("Verify: {} law {}", block.fn_name.cyan(), law.name.cyan());
380            }
381            for given in &law.givens {
382                println!(
383                    "  {} {}: {} = {}",
384                    "given".dimmed(),
385                    given.name,
386                    given.type_name,
387                    verify_given_domain_to_str(&given.domain)
388                );
389            }
390            println!(
391                "  {} {} == {}",
392                "law".dimmed(),
393                expr_to_str(&law.lhs),
394                expr_to_str(&law.rhs)
395            );
396            println!("  {} {}", "cases".dimmed(), block.cases.len());
397        }
398    }
399    if !is_law {
400        interp.start_verify_match_coverage(&block.fn_name);
401    }
402
403    for (idx, (left_expr, right_expr)) in block.cases.iter().enumerate() {
404        let case_str = format!("{} == {}", expr_to_str(left_expr), expr_to_str(right_expr));
405        let case_label = if is_law {
406            format!("case {}/{}", idx + 1, block.cases.len())
407        } else {
408            case_str.clone()
409        };
410        let failure_case = if is_law {
411            format!("{} [{}]", case_label, case_str)
412        } else {
413            case_str.clone()
414        };
415
416        let left_result = interp.eval_expr(left_expr);
417        let right_result = interp.eval_expr(right_expr);
418
419        if let Ok(left_val) = &left_result
420            && let Some(contract) = shape_contract.as_mut()
421        {
422            contract.observe(left_val);
423        }
424        if verify_case_uses_error_prop_on_target(left_expr, &block.fn_name)
425            && let Some(contract) = shape_contract.as_mut()
426            && matches!(contract.return_type, Type::Result(_, _))
427        {
428            match &left_result {
429                Ok(_) => contract.observe_shape(VerifyOutputShape::Ok),
430                Err(RuntimeError::ErrProp(_)) => contract.observe_shape(VerifyOutputShape::Err),
431                Err(_) => {}
432            }
433        }
434
435        match (left_result, right_result) {
436            (Ok(left_val), Ok(right_val)) => {
437                if interp.aver_eq(&left_val, &right_val) {
438                    passed += 1;
439                    if !is_law {
440                        println!("  {} {}", "✓".green(), case_label);
441                    }
442                } else {
443                    failed += 1;
444                    println!("  {} {}", "✗".red(), case_label);
445                    if is_law {
446                        println!("      expanded: {}", case_str);
447                    }
448                    let expected = aver_repr(&right_val);
449                    let actual = aver_repr(&left_val);
450                    println!("      expected: {}", expected);
451                    println!("      got:      {}", actual);
452                    failures.push((failure_case, expected, actual));
453                }
454            }
455            // `?` in a verify case hitting Err produces ErrProp — treat as test failure.
456            (Err(RuntimeError::ErrProp(err_val)), _) | (_, Err(RuntimeError::ErrProp(err_val))) => {
457                failed += 1;
458                println!("  {} {}", "✗".red(), case_label);
459                if is_law {
460                    println!("      expanded: {}", case_str);
461                }
462                println!("      ? hit Result.Err({})", aver_repr(&err_val));
463                failures.push((
464                    failure_case,
465                    String::new(),
466                    format!("? hit Result.Err({})", aver_repr(&err_val)),
467                ));
468            }
469            (Err(e), _) | (_, Err(e)) => {
470                failed += 1;
471                println!("  {} {}", "✗".red(), case_label);
472                if is_law {
473                    println!("      expanded: {}", case_str);
474                }
475                println!("      error: {}", e);
476                failures.push((failure_case, String::new(), format!("ERROR: {}", e)));
477            }
478        }
479    }
480
481    if !is_law {
482        let coverage_misses = interp.finish_verify_match_coverage();
483        for miss in coverage_misses {
484            failed += 1;
485            let missing_1_based: Vec<String> = miss
486                .missing_arms
487                .iter()
488                .map(|idx| (idx + 1).to_string())
489                .collect();
490            let msg = format!(
491                "match at line {} missing covered arm(s): {} (of {})",
492                miss.line,
493                missing_1_based.join(", "),
494                miss.total_arms
495            );
496            println!("  {} {}", "✗".red(), msg);
497            failures.push((
498                format!("match-coverage:{}", miss.line),
499                format!("all {} arms covered", miss.total_arms),
500                msg,
501            ));
502        }
503    }
504
505    if let Some(contract) = shape_contract {
506        let missing = contract.missing();
507        if !missing.is_empty() {
508            failed += 1;
509            let missing_labels: Vec<String> =
510                missing.iter().map(VerifyOutputShape::display).collect();
511            let expected_labels: Vec<String> = contract
512                .expected
513                .iter()
514                .map(VerifyOutputShape::display)
515                .collect();
516            let msg = format!(
517                "missing output shape(s) for {}: {}",
518                contract.return_type.display(),
519                missing_labels.join(", ")
520            );
521            println!("  {} {}", "✗".red(), msg);
522            failures.push((
523                format!("shape-coverage:{}", block.fn_name),
524                format!("covered output shapes: {}", expected_labels.join(", ")),
525                msg,
526            ));
527        }
528    }
529
530    let total = passed + failed;
531    if is_law && failed == 0 {
532        println!(
533            "  {} all {} generated case(s) passed",
534            "✓".green(),
535            block.cases.len()
536        );
537    }
538    if failed == 0 {
539        println!("  {}", format!("{}/{} passed", passed, total).green());
540    } else {
541        println!("  {}", format!("{}/{} passed", passed, total).red());
542    }
543
544    VerifyResult {
545        fn_name: block.fn_name.clone(),
546        passed,
547        failed,
548        failures,
549    }
550}
551
552pub fn index_decisions(items: &[TopLevel]) -> Vec<&DecisionBlock> {
553    items
554        .iter()
555        .filter_map(|item| {
556            if let TopLevel::Decision(d) = item {
557                Some(d)
558            } else {
559                None
560            }
561        })
562        .collect()
563}
564
565pub fn merge_verify_blocks(items: &[TopLevel]) -> Vec<VerifyBlock> {
566    let mut merged: Vec<VerifyBlock> = Vec::new();
567    let mut by_fn_cases: std::collections::HashMap<String, usize> =
568        std::collections::HashMap::new();
569
570    for item in items {
571        let TopLevel::Verify(vb) = item else {
572            continue;
573        };
574        match &vb.kind {
575            VerifyKind::Cases => {
576                if let Some(&idx) = by_fn_cases.get(&vb.fn_name) {
577                    merged[idx].cases.extend(vb.cases.clone());
578                } else {
579                    by_fn_cases.insert(vb.fn_name.clone(), merged.len());
580                    merged.push(vb.clone());
581                }
582            }
583            VerifyKind::Law(_) => {
584                merged.push(vb.clone());
585            }
586        }
587    }
588
589    merged
590}
591
592/// Returns true if a function requires a ? description annotation.
593/// All functions except main() require one.
594fn fn_needs_desc(f: &FnDef) -> bool {
595    f.name != "main"
596}
597
598/// Missing verify warning policy:
599/// - skip `main`
600/// - skip effectful functions (tested through replay/recording flow)
601/// - skip trivial pure pass-through wrappers
602/// - require verify for the rest (pure, non-trivial logic)
603fn fn_needs_verify(f: &FnDef) -> bool {
604    if f.name == "main" {
605        return false;
606    }
607    if !f.effects.is_empty() {
608        return false;
609    }
610    !is_trivial_passthrough_wrapper(f)
611}
612
613fn is_trivial_passthrough_wrapper(f: &FnDef) -> bool {
614    let param_names: Vec<&str> = f.params.iter().map(|(name, _)| name.as_str()).collect();
615
616    if f.body.stmts().len() != 1 {
617        return false;
618    }
619    f.body
620        .tail_expr()
621        .is_some_and(|expr| expr_is_passthrough(expr, &param_names))
622}
623
624fn expr_is_passthrough(expr: &Expr, param_names: &[&str]) -> bool {
625    match expr {
626        // `fn id(x) = x`
627        Expr::Ident(name) => param_names.len() == 1 && name == param_names[0],
628        // `fn wrap(a,b) = inner(a,b)` (no argument transformation)
629        Expr::FnCall(_, args) => args_match_params(args, param_names),
630        // `fn some(x) = Option.Some(x)` style
631        Expr::Constructor(_, Some(arg)) => {
632            if param_names.len() != 1 {
633                return false;
634            }
635            matches!(arg.as_ref(), Expr::Ident(name) if name == param_names[0])
636        }
637        _ => false,
638    }
639}
640
641fn args_match_params(args: &[Expr], param_names: &[&str]) -> bool {
642    if args.len() != param_names.len() {
643        return false;
644    }
645    args.iter()
646        .zip(param_names.iter())
647        .all(|(arg, expected)| matches!(arg, Expr::Ident(name) if name == *expected))
648}
649
650fn verify_case_calls_target(left: &Expr, fn_name: &str) -> bool {
651    match left {
652        Expr::FnCall(callee, args) => {
653            callee_is_target(callee, fn_name)
654                || verify_case_calls_target(callee, fn_name)
655                || args
656                    .iter()
657                    .any(|arg| verify_case_calls_target(arg, fn_name))
658        }
659        Expr::BinOp(_, left_expr, right_expr) => {
660            verify_case_calls_target(left_expr, fn_name)
661                || verify_case_calls_target(right_expr, fn_name)
662        }
663        Expr::Match { subject, arms, .. } => {
664            verify_case_calls_target(subject, fn_name)
665                || arms
666                    .iter()
667                    .any(|arm| verify_case_calls_target(&arm.body, fn_name))
668        }
669        Expr::Constructor(_, Some(inner)) => verify_case_calls_target(inner, fn_name),
670        Expr::ErrorProp(inner) => verify_case_calls_target(inner, fn_name),
671        Expr::List(elems) => elems
672            .iter()
673            .any(|elem| verify_case_calls_target(elem, fn_name)),
674        Expr::Tuple(items) => items
675            .iter()
676            .any(|item| verify_case_calls_target(item, fn_name)),
677        Expr::MapLiteral(entries) => entries.iter().any(|(k, v)| {
678            verify_case_calls_target(k, fn_name) || verify_case_calls_target(v, fn_name)
679        }),
680        Expr::Attr(obj, _) => verify_case_calls_target(obj, fn_name),
681        Expr::RecordCreate { fields, .. } => fields
682            .iter()
683            .any(|(_, expr)| verify_case_calls_target(expr, fn_name)),
684        Expr::RecordUpdate { base, updates, .. } => {
685            verify_case_calls_target(base, fn_name)
686                || updates
687                    .iter()
688                    .any(|(_, expr)| verify_case_calls_target(expr, fn_name))
689        }
690        Expr::TailCall(boxed) => {
691            boxed.0 == fn_name
692                || boxed
693                    .1
694                    .iter()
695                    .any(|arg| verify_case_calls_target(arg, fn_name))
696        }
697        Expr::Literal(_) | Expr::Ident(_) | Expr::InterpolatedStr(_) | Expr::Resolved(_) => false,
698        Expr::Constructor(_, None) => false,
699    }
700}
701
702fn callee_is_target(callee: &Expr, fn_name: &str) -> bool {
703    matches!(callee, Expr::Ident(name) if name == fn_name)
704}
705
706fn collect_used_effects_expr(expr: &Expr, fn_sigs: &FnSigMap, out: &mut BTreeSet<String>) {
707    match expr {
708        Expr::FnCall(callee, args) => {
709            if let Some(callee_name) = dotted_name(callee)
710                && let Some((_, _, effects)) = fn_sigs.get(&callee_name)
711            {
712                for effect in effects {
713                    out.insert(effect.clone());
714                }
715            }
716            collect_used_effects_expr(callee, fn_sigs, out);
717            for arg in args {
718                collect_used_effects_expr(arg, fn_sigs, out);
719            }
720        }
721        Expr::TailCall(boxed) => {
722            let (target, args) = boxed.as_ref();
723            if let Some((_, _, effects)) = fn_sigs.get(target) {
724                for effect in effects {
725                    out.insert(effect.clone());
726                }
727            }
728            for arg in args {
729                collect_used_effects_expr(arg, fn_sigs, out);
730            }
731        }
732        Expr::BinOp(_, left, right) => {
733            collect_used_effects_expr(left, fn_sigs, out);
734            collect_used_effects_expr(right, fn_sigs, out);
735        }
736        Expr::Match { subject, arms, .. } => {
737            collect_used_effects_expr(subject, fn_sigs, out);
738            for arm in arms {
739                collect_used_effects_expr(&arm.body, fn_sigs, out);
740            }
741        }
742        Expr::ErrorProp(inner) => collect_used_effects_expr(inner, fn_sigs, out),
743        Expr::List(items) | Expr::Tuple(items) => {
744            for item in items {
745                collect_used_effects_expr(item, fn_sigs, out);
746            }
747        }
748        Expr::MapLiteral(entries) => {
749            for (key, value) in entries {
750                collect_used_effects_expr(key, fn_sigs, out);
751                collect_used_effects_expr(value, fn_sigs, out);
752            }
753        }
754        Expr::Attr(obj, _) => collect_used_effects_expr(obj, fn_sigs, out),
755        Expr::RecordCreate { fields, .. } => {
756            for (_, expr) in fields {
757                collect_used_effects_expr(expr, fn_sigs, out);
758            }
759        }
760        Expr::RecordUpdate { base, updates, .. } => {
761            collect_used_effects_expr(base, fn_sigs, out);
762            for (_, expr) in updates {
763                collect_used_effects_expr(expr, fn_sigs, out);
764            }
765        }
766        Expr::Constructor(_, Some(inner)) => collect_used_effects_expr(inner, fn_sigs, out),
767        Expr::Literal(_)
768        | Expr::Ident(_)
769        | Expr::InterpolatedStr(_)
770        | Expr::Resolved(_)
771        | Expr::Constructor(_, None) => {}
772    }
773}
774
775fn collect_used_effects(f: &FnDef, fn_sigs: &FnSigMap) -> BTreeSet<String> {
776    let mut used = BTreeSet::new();
777    for stmt in f.body.stmts() {
778        match stmt {
779            Stmt::Binding(_, _, expr) | Stmt::Expr(expr) => {
780                collect_used_effects_expr(expr, fn_sigs, &mut used)
781            }
782        }
783    }
784    used
785}
786
787fn collect_broad_effect_replacements(
788    declared_effects: &[String],
789    used_effects: &BTreeSet<String>,
790) -> Vec<(String, Vec<String>)> {
791    let declared_unique: BTreeSet<String> = declared_effects.iter().cloned().collect();
792    let mut out = Vec::new();
793    for declared in declared_unique {
794        if declared.contains('.') {
795            continue;
796        }
797        let prefix = format!("{}.", declared);
798        let matched_children: Vec<String> = used_effects
799            .iter()
800            .filter(|used| used.starts_with(&prefix))
801            .cloned()
802            .collect();
803        if !matched_children.is_empty() {
804            out.push((declared, matched_children));
805        }
806    }
807    out
808}
809
810fn collect_declared_symbols(items: &[TopLevel]) -> std::collections::HashSet<String> {
811    let mut out = std::collections::HashSet::new();
812    for item in items {
813        match item {
814            TopLevel::FnDef(f) => {
815                out.insert(f.name.clone());
816            }
817            TopLevel::Module(m) => {
818                out.insert(m.name.clone());
819            }
820            TopLevel::TypeDef(t) => match t {
821                crate::ast::TypeDef::Sum { name, .. }
822                | crate::ast::TypeDef::Product { name, .. } => {
823                    out.insert(name.clone());
824                }
825            },
826            TopLevel::Decision(d) => {
827                out.insert(d.name.clone());
828            }
829            TopLevel::EffectSet { name, .. } => {
830                out.insert(name.clone());
831            }
832            TopLevel::Verify(_) | TopLevel::Stmt(_) => {}
833        }
834    }
835    out
836}
837
838fn collect_known_effect_symbols(fn_sigs: Option<&FnSigMap>) -> std::collections::HashSet<String> {
839    let mut out = std::collections::HashSet::new();
840    for builtin in ["Console", "Http", "Disk", "Tcp", "HttpServer"] {
841        out.insert(builtin.to_string());
842    }
843    if let Some(sigs) = fn_sigs {
844        for (_, _, effects) in sigs.values() {
845            for effect in effects {
846                out.insert(effect.clone());
847            }
848        }
849    }
850    out
851}
852
853fn decision_symbol_known(
854    name: &str,
855    declared_symbols: &std::collections::HashSet<String>,
856    known_effect_symbols: &std::collections::HashSet<String>,
857) -> bool {
858    declared_symbols.contains(name) || known_effect_symbols.contains(name)
859}
860
861pub fn check_module_intent(items: &[TopLevel]) -> ModuleCheckFindings {
862    check_module_intent_with_sigs(items, None)
863}
864
865pub fn check_module_intent_with_sigs(
866    items: &[TopLevel],
867    fn_sigs: Option<&FnSigMap>,
868) -> ModuleCheckFindings {
869    check_module_intent_with_sigs_in(items, fn_sigs, None)
870}
871
872pub fn check_module_intent_with_sigs_in(
873    items: &[TopLevel],
874    fn_sigs: Option<&FnSigMap>,
875    source_file: Option<&str>,
876) -> ModuleCheckFindings {
877    let mut errors = Vec::new();
878    let mut warnings = Vec::new();
879    let declared_symbols = collect_declared_symbols(items);
880    let known_effect_symbols = collect_known_effect_symbols(fn_sigs);
881    let module_name = items.iter().find_map(|item| {
882        if let TopLevel::Module(m) = item {
883            Some(m.name.clone())
884        } else {
885            None
886        }
887    });
888
889    let mut verified_fns: std::collections::HashSet<&str> = std::collections::HashSet::new();
890    let mut spec_fns: std::collections::HashSet<String> = std::collections::HashSet::new();
891    let mut empty_verify_fns: std::collections::HashSet<&str> = std::collections::HashSet::new();
892    let mut invalid_verify_fns: std::collections::HashSet<&str> = std::collections::HashSet::new();
893    for item in items {
894        if let TopLevel::Verify(v) = item {
895            if v.cases.is_empty() {
896                errors.push(CheckFinding {
897                    line: v.line,
898                    module: module_name.clone(),
899                    file: source_file.map(|s| s.to_string()),
900                    message: format!(
901                        "Verify block '{}' must contain at least one case",
902                        v.fn_name
903                    ),
904                });
905                empty_verify_fns.insert(v.fn_name.as_str());
906            } else {
907                let mut block_valid = true;
908                if matches!(v.kind, VerifyKind::Cases) {
909                    for (idx, (left, _right)) in v.cases.iter().enumerate() {
910                        if !verify_case_calls_target(left, &v.fn_name) {
911                            errors.push(CheckFinding {
912                                line: v.line,
913                                module: module_name.clone(),
914                                file: source_file.map(|s| s.to_string()),
915                                message: format!(
916                                    "Verify block '{}' case #{} must call '{}' on the left side",
917                                    v.fn_name,
918                                    idx + 1,
919                                    v.fn_name
920                                ),
921                            });
922                            block_valid = false;
923                        }
924                    }
925                    for (idx, (_left, right)) in v.cases.iter().enumerate() {
926                        if verify_case_calls_target(right, &v.fn_name) {
927                            errors.push(CheckFinding {
928                                line: v.line,
929                                module: module_name.clone(),
930                                file: source_file.map(|s| s.to_string()),
931                                message: format!(
932                                    "Verify block '{}' case #{} must not call '{}' on the right side",
933                                    v.fn_name,
934                                    idx + 1,
935                                    v.fn_name
936                                ),
937                            });
938                            block_valid = false;
939                        }
940                    }
941                }
942                if let VerifyKind::Law(law) = &v.kind
943                    && let Some(sigs) = fn_sigs
944                    && let Some(named_fn) = named_law_function(law, sigs)
945                {
946                    if !named_fn.is_pure {
947                        errors.push(CheckFinding {
948                            line: v.line,
949                            module: module_name.clone(),
950                            file: source_file.map(|s| s.to_string()),
951                            message: format!(
952                                "Verify law '{}.{}' resolves to effectful function '{}'; spec functions must be pure",
953                                v.fn_name, law.name, named_fn.name
954                            ),
955                        });
956                        block_valid = false;
957                    } else if let Some(spec_ref) = canonical_spec_ref(&v.fn_name, law, sigs) {
958                        spec_fns.insert(spec_ref.spec_fn_name);
959                    } else {
960                        warnings.push(CheckFinding {
961                            line: v.line,
962                            module: module_name.clone(),
963                            file: source_file.map(|s| s.to_string()),
964                            message: format!(
965                                "Verify law '{}.{}' names pure function '{}' but the law body never calls it; use '{}' in the assertion or rename the law",
966                                v.fn_name, law.name, named_fn.name, named_fn.name
967                            ),
968                        });
969                    }
970                }
971                if block_valid {
972                    verified_fns.insert(v.fn_name.as_str());
973                } else {
974                    invalid_verify_fns.insert(v.fn_name.as_str());
975                }
976            }
977        }
978    }
979
980    for item in items {
981        match item {
982            TopLevel::Module(m) => {
983                if m.intent.is_empty() {
984                    warnings.push(CheckFinding {
985                        line: m.line,
986                        module: Some(m.name.clone()),
987                        file: source_file.map(|s| s.to_string()),
988                        message: format!("Module '{}' has no intent block", m.name),
989                    });
990                }
991            }
992            TopLevel::FnDef(f) => {
993                if f.desc.is_none() && fn_needs_desc(f) {
994                    warnings.push(CheckFinding {
995                        line: f.line,
996                        module: module_name.clone(),
997                        file: source_file.map(|s| s.to_string()),
998                        message: format!("Function '{}' has no description (?)", f.name),
999                    });
1000                }
1001                if let Some(sigs) = fn_sigs
1002                    && let Some((_, _, declared_effects)) = sigs.get(&f.name)
1003                    && !declared_effects.is_empty()
1004                {
1005                    let used_effects = collect_used_effects(f, sigs);
1006                    let broad_replacements =
1007                        collect_broad_effect_replacements(declared_effects, &used_effects);
1008                    let unused_effects: Vec<String> = declared_effects
1009                        .iter()
1010                        .filter(|declared| {
1011                            // A declared effect is "used" if it satisfies any used effect
1012                            // e.g. declared "Console" satisfies used "Console.print"
1013                            !used_effects
1014                                .iter()
1015                                .any(|used| crate::effects::effect_satisfies(declared, used))
1016                        })
1017                        .cloned()
1018                        .collect();
1019                    if !unused_effects.is_empty() {
1020                        let used = if used_effects.is_empty() {
1021                            "none".to_string()
1022                        } else {
1023                            used_effects.into_iter().collect::<Vec<_>>().join(", ")
1024                        };
1025                        warnings.push(CheckFinding {
1026                            line: f.line,
1027                            module: module_name.clone(),
1028                            file: source_file.map(|s| s.to_string()),
1029                            message: format!(
1030                                "Function '{}' declares unused effect(s): {} (used: {})",
1031                                f.name,
1032                                unused_effects.join(", "),
1033                                used
1034                            ),
1035                        });
1036                    }
1037                    for (parent, children) in broad_replacements {
1038                        warnings.push(CheckFinding {
1039                                    line: f.line,
1040                                    module: module_name.clone(),
1041                                    file: source_file.map(|s| s.to_string()),
1042                                    message: format!(
1043                                        "Function '{}' declares broad effect '{}'. Prefer granular sub-effects: {}",
1044                                        f.name,
1045                                        parent,
1046                                        children.join(", ")
1047                                    ),
1048                                });
1049                    }
1050                }
1051                if fn_needs_verify(f)
1052                    && !verified_fns.contains(f.name.as_str())
1053                    && !spec_fns.contains(&f.name)
1054                    && !empty_verify_fns.contains(f.name.as_str())
1055                    && !invalid_verify_fns.contains(f.name.as_str())
1056                {
1057                    errors.push(CheckFinding {
1058                        line: f.line,
1059                        module: module_name.clone(),
1060                        file: source_file.map(|s| s.to_string()),
1061                        message: format!("Function '{}' has no verify block", f.name),
1062                    });
1063                }
1064            }
1065            TopLevel::Decision(d) => {
1066                if let DecisionImpact::Symbol(name) = &d.chosen
1067                    && !decision_symbol_known(name, &declared_symbols, &known_effect_symbols)
1068                {
1069                    errors.push(CheckFinding {
1070                            line: d.line,
1071                            module: module_name.clone(),
1072                            file: source_file.map(|s| s.to_string()),
1073                            message: format!(
1074                                "Decision '{}' references unknown chosen symbol '{}'. Use quoted string for semantic chosen value.",
1075                                d.name, name
1076                            ),
1077                        });
1078                }
1079                for rejected in &d.rejected {
1080                    if let DecisionImpact::Symbol(name) = rejected
1081                        && !decision_symbol_known(name, &declared_symbols, &known_effect_symbols)
1082                    {
1083                        errors.push(CheckFinding {
1084                                line: d.line,
1085                                module: module_name.clone(),
1086                                file: source_file.map(|s| s.to_string()),
1087                                message: format!(
1088                                    "Decision '{}' references unknown rejected symbol '{}'. Use quoted string for semantic rejected value.",
1089                                    d.name, name
1090                                ),
1091                            });
1092                    }
1093                }
1094                for impact in &d.impacts {
1095                    if let DecisionImpact::Symbol(name) = impact
1096                        && !decision_symbol_known(name, &declared_symbols, &known_effect_symbols)
1097                    {
1098                        errors.push(CheckFinding {
1099                                line: d.line,
1100                                module: module_name.clone(),
1101                                file: source_file.map(|s| s.to_string()),
1102                                message: format!(
1103                                    "Decision '{}' references unknown impact symbol '{}'. Use quoted string for semantic impact.",
1104                                    d.name, name
1105                                ),
1106                            });
1107                    }
1108                }
1109            }
1110            _ => {}
1111        }
1112    }
1113
1114    ModuleCheckFindings { errors, warnings }
1115}
1116
1117pub fn expr_to_str(expr: &crate::ast::Expr) -> String {
1118    use crate::ast::Expr;
1119    use crate::ast::Literal;
1120
1121    match expr {
1122        Expr::Literal(lit) => match lit {
1123            Literal::Int(i) => i.to_string(),
1124            Literal::Float(f) => f.to_string(),
1125            Literal::Str(s) => format!("\"{}\"", s),
1126            Literal::Bool(b) => if *b { "true" } else { "false" }.to_string(),
1127        },
1128        Expr::Ident(name) => name.clone(),
1129        Expr::FnCall(fn_expr, args) => {
1130            let fn_str = expr_to_str(fn_expr);
1131            let args_str = args.iter().map(expr_to_str).collect::<Vec<_>>().join(", ");
1132            format!("{}({})", fn_str, args_str)
1133        }
1134        Expr::Constructor(name, arg) => match arg {
1135            None => name.clone(),
1136            Some(a) => format!("{}({})", name, expr_to_str(a)),
1137        },
1138        Expr::BinOp(op, left, right) => {
1139            use crate::ast::BinOp;
1140            let op_str = match op {
1141                BinOp::Add => "+",
1142                BinOp::Sub => "-",
1143                BinOp::Mul => "*",
1144                BinOp::Div => "/",
1145                BinOp::Eq => "==",
1146                BinOp::Neq => "!=",
1147                BinOp::Lt => "<",
1148                BinOp::Gt => ">",
1149                BinOp::Lte => "<=",
1150                BinOp::Gte => ">=",
1151            };
1152            format!("{} {} {}", expr_to_str(left), op_str, expr_to_str(right))
1153        }
1154        Expr::InterpolatedStr(parts) => {
1155            use crate::ast::StrPart;
1156            let mut inner = String::new();
1157            for part in parts {
1158                match part {
1159                    StrPart::Literal(s) => inner.push_str(s),
1160                    StrPart::Parsed(e) => {
1161                        inner.push('{');
1162                        inner.push_str(&expr_to_str(e));
1163                        inner.push('}');
1164                    }
1165                }
1166            }
1167            format!("\"{}\"", inner)
1168        }
1169        Expr::List(elements) => {
1170            let parts: Vec<String> = elements.iter().map(expr_to_str).collect();
1171            format!("[{}]", parts.join(", "))
1172        }
1173        Expr::Tuple(items) => {
1174            let parts: Vec<String> = items.iter().map(expr_to_str).collect();
1175            format!("({})", parts.join(", "))
1176        }
1177        Expr::MapLiteral(entries) => {
1178            let parts = entries
1179                .iter()
1180                .map(|(key, value)| format!("{} => {}", expr_to_str(key), expr_to_str(value)))
1181                .collect::<Vec<_>>();
1182            format!("{{{}}}", parts.join(", "))
1183        }
1184        Expr::ErrorProp(inner) => format!("{}?", expr_to_str(inner)),
1185        Expr::Attr(obj, field) => format!("{}.{}", expr_to_str(obj), field),
1186        Expr::RecordCreate { type_name, fields } => {
1187            let flds: Vec<String> = fields
1188                .iter()
1189                .map(|(name, expr)| format!("{} = {}", name, expr_to_str(expr)))
1190                .collect();
1191            format!("{}({})", type_name, flds.join(", "))
1192        }
1193        Expr::RecordUpdate {
1194            type_name,
1195            base,
1196            updates,
1197        } => {
1198            let upds: Vec<String> = updates
1199                .iter()
1200                .map(|(name, expr)| format!("{} = {}", name, expr_to_str(expr)))
1201                .collect();
1202            format!(
1203                "{}.update({}, {})",
1204                type_name,
1205                expr_to_str(base),
1206                upds.join(", ")
1207            )
1208        }
1209        Expr::TailCall(boxed) => {
1210            let (target, args) = boxed.as_ref();
1211            let a = args.iter().map(expr_to_str).collect::<Vec<_>>().join(", ");
1212            format!("<tail-call:{}>({})", target, a)
1213        }
1214        Expr::Resolved(_) => "<resolved>".to_string(),
1215        Expr::Match { subject, arms, .. } => {
1216            let s = expr_to_str(subject);
1217            let arms_str: Vec<String> = arms
1218                .iter()
1219                .map(|arm| format!("{:?} -> {}", arm.pattern, expr_to_str(&arm.body)))
1220                .collect();
1221            format!("match {} {}", s, arms_str.join(", "))
1222        }
1223    }
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::*;
1229    use crate::lexer::Lexer;
1230    use crate::parser::Parser;
1231
1232    fn parse_items(src: &str) -> Vec<TopLevel> {
1233        let mut lexer = Lexer::new(src);
1234        let tokens = lexer.tokenize().expect("lex failed");
1235        let mut parser = Parser::new(tokens);
1236        parser.parse().expect("parse failed")
1237    }
1238
1239    #[test]
1240    fn no_verify_warning_for_effectful_function() {
1241        let items = parse_items(
1242            r#"
1243fn log(x: Int) -> Unit
1244    ! [Console]
1245    Console.print(x)
1246"#,
1247        );
1248        let findings = check_module_intent(&items);
1249        assert!(
1250            !findings
1251                .warnings
1252                .iter()
1253                .any(|w| w.message.contains("no verify block"))
1254                && !findings
1255                    .errors
1256                    .iter()
1257                    .any(|e| e.message.contains("no verify block")),
1258            "unexpected findings: errors={:?}, warnings={:?}",
1259            findings.errors,
1260            findings.warnings
1261        );
1262    }
1263
1264    #[test]
1265    fn warns_on_unused_declared_effects() {
1266        let items = parse_items(
1267            r#"
1268fn log(x: Int) -> Unit
1269    ! [Console, Http]
1270    Console.print(x)
1271"#,
1272        );
1273        let tc = crate::types::checker::run_type_check_full(&items, None);
1274        assert!(
1275            tc.errors.is_empty(),
1276            "unexpected type errors: {:?}",
1277            tc.errors
1278        );
1279        let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
1280        assert!(
1281            findings.warnings.iter().any(|w| {
1282                w.message.contains("declares unused effect(s)")
1283                    && w.message.contains("Http")
1284                    && w.message.contains("used: Console.print")
1285            }),
1286            "expected unused-effect warning, got errors={:?}, warnings={:?}",
1287            findings.errors,
1288            findings.warnings
1289        );
1290    }
1291
1292    #[test]
1293    fn no_unused_effect_warning_when_declared_effects_are_minimal() {
1294        let items = parse_items(
1295            r#"
1296fn log(x: Int) -> Unit
1297    ! [Console.print]
1298    Console.print(x)
1299"#,
1300        );
1301        let tc = crate::types::checker::run_type_check_full(&items, None);
1302        assert!(
1303            tc.errors.is_empty(),
1304            "unexpected type errors: {:?}",
1305            tc.errors
1306        );
1307        let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
1308        assert!(
1309            !findings
1310                .warnings
1311                .iter()
1312                .any(|w| w.message.contains("declares unused effect(s)")),
1313            "did not expect unused-effect warning, got errors={:?}, warnings={:?}",
1314            findings.errors,
1315            findings.warnings
1316        );
1317        assert!(
1318            !findings
1319                .warnings
1320                .iter()
1321                .any(|w| w.message.contains("declares broad effect")),
1322            "did not expect broad-effect warning, got errors={:?}, warnings={:?}",
1323            findings.errors,
1324            findings.warnings
1325        );
1326    }
1327
1328    #[test]
1329    fn warns_on_broad_effects_when_sub_effects_are_used() {
1330        let items = parse_items(
1331            r#"
1332fn fetch(url: String) -> Result<HttpResponse, String>
1333    ! [Http]
1334    Http.get(url)
1335"#,
1336        );
1337        let tc = crate::types::checker::run_type_check_full(&items, None);
1338        assert!(
1339            tc.errors.is_empty(),
1340            "unexpected type errors: {:?}",
1341            tc.errors
1342        );
1343        let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
1344        assert!(
1345            findings.warnings.iter().any(|w| {
1346                w.message.contains("declares broad effect 'Http'") && w.message.contains("Http.get")
1347            }),
1348            "expected broad-effect warning, got errors={:?}, warnings={:?}",
1349            findings.errors,
1350            findings.warnings
1351        );
1352    }
1353
1354    #[test]
1355    fn no_verify_warning_for_trivial_passthrough_wrapper() {
1356        let items = parse_items(
1357            r#"
1358fn passthrough(x: Int) -> Int
1359    inner(x)
1360"#,
1361        );
1362        let findings = check_module_intent(&items);
1363        assert!(
1364            !findings
1365                .warnings
1366                .iter()
1367                .any(|w| w.message.contains("no verify block"))
1368                && !findings
1369                    .errors
1370                    .iter()
1371                    .any(|e| e.message.contains("no verify block")),
1372            "unexpected findings: errors={:?}, warnings={:?}",
1373            findings.errors,
1374            findings.warnings
1375        );
1376    }
1377
1378    #[test]
1379    fn verify_error_for_pure_non_trivial_logic() {
1380        let items = parse_items(
1381            r#"
1382fn add1(x: Int) -> Int
1383    x + 1
1384"#,
1385        );
1386        let findings = check_module_intent(&items);
1387        assert!(
1388            findings
1389                .errors
1390                .iter()
1391                .any(|e| e.message == "Function 'add1' has no verify block"),
1392            "expected verify error, got errors={:?}, warnings={:?}",
1393            findings.errors,
1394            findings.warnings
1395        );
1396    }
1397
1398    #[test]
1399    fn empty_verify_block_is_rejected() {
1400        let items = parse_items(
1401            r#"
1402fn add1(x: Int) -> Int
1403    x + 1
1404
1405verify add1
1406"#,
1407        );
1408        let findings = check_module_intent(&items);
1409        assert!(
1410            findings
1411                .errors
1412                .iter()
1413                .any(|e| e.message == "Verify block 'add1' must contain at least one case"),
1414            "expected empty verify error, got errors={:?}, warnings={:?}",
1415            findings.errors,
1416            findings.warnings
1417        );
1418        assert!(
1419            !findings
1420                .errors
1421                .iter()
1422                .any(|e| e.message == "Function 'add1' has no verify block"),
1423            "expected no duplicate missing-verify error, got errors={:?}, warnings={:?}",
1424            findings.errors,
1425            findings.warnings
1426        );
1427    }
1428
1429    #[test]
1430    fn verify_case_must_call_verified_function_on_left_side() {
1431        let items = parse_items(
1432            r#"
1433fn add1(x: Int) -> Int
1434    x + 1
1435
1436verify add1
1437    true => true
1438"#,
1439        );
1440        let findings = check_module_intent(&items);
1441        assert!(
1442            findings.errors.iter().any(|e| {
1443                e.message
1444                    .contains("Verify block 'add1' case #1 must call 'add1' on the left side")
1445            }),
1446            "expected verify-case-call error, got errors={:?}, warnings={:?}",
1447            findings.errors,
1448            findings.warnings
1449        );
1450        assert!(
1451            !findings
1452                .errors
1453                .iter()
1454                .any(|e| e.message == "Function 'add1' has no verify block"),
1455            "expected no duplicate missing-verify error, got errors={:?}, warnings={:?}",
1456            findings.errors,
1457            findings.warnings
1458        );
1459    }
1460
1461    #[test]
1462    fn verify_case_must_not_call_verified_function_on_right_side() {
1463        let items = parse_items(
1464            r#"
1465fn add1(x: Int) -> Int
1466    x + 1
1467
1468verify add1
1469    add1(1) => add1(1)
1470"#,
1471        );
1472        let findings = check_module_intent(&items);
1473        assert!(
1474            findings.errors.iter().any(|e| {
1475                e.message
1476                    .contains("Verify block 'add1' case #1 must not call 'add1' on the right side")
1477            }),
1478            "expected verify-case-rhs error, got errors={:?}, warnings={:?}",
1479            findings.errors,
1480            findings.warnings
1481        );
1482    }
1483
1484    #[test]
1485    fn verify_law_skips_left_right_call_heuristics() {
1486        let items = parse_items(
1487            r#"
1488fn add1(x: Int) -> Int
1489    x + 1
1490
1491verify add1 law reflexive
1492    given x: Int = [1, 2, 3]
1493    x => x
1494"#,
1495        );
1496        let findings = check_module_intent(&items);
1497        assert!(
1498            !findings
1499                .errors
1500                .iter()
1501                .any(|e| e.message.contains("must call 'add1' on the left side")),
1502            "did not expect lhs-call heuristic for law verify, got errors={:?}",
1503            findings.errors
1504        );
1505        assert!(
1506            !findings
1507                .errors
1508                .iter()
1509                .any(|e| e.message.contains("must not call 'add1' on the right side")),
1510            "did not expect rhs-call heuristic for law verify, got errors={:?}",
1511            findings.errors
1512        );
1513        assert!(
1514            !findings
1515                .errors
1516                .iter()
1517                .any(|e| e.message == "Function 'add1' has no verify block"),
1518            "law verify should satisfy verify requirement, got errors={:?}",
1519            findings.errors
1520        );
1521    }
1522
1523    #[test]
1524    fn verify_law_named_effectful_function_is_an_error() {
1525        let items = parse_items(
1526            r#"
1527fn add1(x: Int) -> Int
1528    x + 1
1529
1530fn specFn(x: Int) -> Int
1531    ! [Console.print]
1532    Console.print("{x}")
1533    x
1534
1535verify add1 law specFn
1536    given x: Int = [1, 2]
1537    add1(x) => add1(x)
1538"#,
1539        );
1540        let tc = crate::types::checker::run_type_check_full(&items, None);
1541        let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
1542        assert!(
1543            findings.errors.iter().any(|e| e.message.contains(
1544                "Verify law 'add1.specFn' resolves to effectful function 'specFn'; spec functions must be pure"
1545            )),
1546            "expected effectful-spec error, got errors={:?}, warnings={:?}",
1547            findings.errors,
1548            findings.warnings
1549        );
1550    }
1551
1552    #[test]
1553    fn verify_law_named_pure_function_must_appear_in_law_body() {
1554        let items = parse_items(
1555            r#"
1556fn add1(x: Int) -> Int
1557    x + 1
1558
1559fn add1Spec(x: Int) -> Int
1560    x + 1
1561
1562verify add1 law add1Spec
1563    given x: Int = [1, 2]
1564    add1(x) => x + 1
1565"#,
1566        );
1567        let tc = crate::types::checker::run_type_check_full(&items, None);
1568        let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
1569        assert!(
1570            findings.warnings.iter().any(|w| w.message.contains(
1571                "Verify law 'add1.add1Spec' names pure function 'add1Spec' but the law body never calls it"
1572            )),
1573            "expected unused-spec warning, got errors={:?}, warnings={:?}",
1574            findings.errors,
1575            findings.warnings
1576        );
1577    }
1578
1579    #[test]
1580    fn canonical_spec_function_does_not_need_its_own_verify_block() {
1581        let items = parse_items(
1582            r#"
1583fn add1(x: Int) -> Int
1584    x + 1
1585
1586fn add1Spec(x: Int) -> Int
1587    x + 1
1588
1589verify add1 law add1Spec
1590    given x: Int = [1, 2]
1591    add1(x) => add1Spec(x)
1592"#,
1593        );
1594        let tc = crate::types::checker::run_type_check_full(&items, None);
1595        let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
1596        assert!(
1597            !findings
1598                .errors
1599                .iter()
1600                .any(|e| e.message == "Function 'add1Spec' has no verify block"),
1601            "spec function should not need its own verify block, got errors={:?}, warnings={:?}",
1602            findings.errors,
1603            findings.warnings
1604        );
1605    }
1606
1607    #[test]
1608    fn merge_verify_blocks_coalesces_cases_by_function() {
1609        let items = parse_items(
1610            r#"
1611fn f(x: Int) -> Int
1612    x
1613
1614verify f
1615    f(1) => 1
1616
1617verify f
1618    f(2) => 2
1619"#,
1620        );
1621        let merged = merge_verify_blocks(&items);
1622        assert_eq!(merged.len(), 1);
1623        assert_eq!(merged[0].fn_name, "f");
1624        assert_eq!(merged[0].cases.len(), 2);
1625    }
1626
1627    #[test]
1628    fn merge_verify_blocks_keeps_law_blocks_separate() {
1629        let items = parse_items(
1630            r#"
1631fn f(x: Int) -> Int
1632    x
1633
1634verify f
1635    f(1) => 1
1636
1637verify f law l1
1638    given x: Int = [1]
1639    x => x
1640
1641verify f law l2
1642    given x: Int = [2]
1643    x => x
1644
1645verify f
1646    f(2) => 2
1647"#,
1648        );
1649        let merged = merge_verify_blocks(&items);
1650        assert_eq!(merged.len(), 3);
1651        assert!(matches!(merged[0].kind, VerifyKind::Cases));
1652        assert_eq!(merged[0].cases.len(), 2);
1653        assert!(matches!(merged[1].kind, VerifyKind::Law(_)));
1654        assert!(matches!(merged[2].kind, VerifyKind::Law(_)));
1655    }
1656
1657    #[test]
1658    fn decision_unknown_symbol_impact_is_error() {
1659        let items = parse_items(
1660            r#"
1661module M
1662    intent =
1663        "x"
1664
1665fn existing() -> Int
1666    1
1667
1668verify existing
1669    existing() => 1
1670
1671decision D
1672    date = "2026-03-05"
1673    reason =
1674        "x"
1675    chosen = "ExistingChoice"
1676    rejected = []
1677    impacts = [existing, missingThing]
1678"#,
1679        );
1680        let findings = check_module_intent(&items);
1681        assert!(
1682            findings.errors.iter().any(|e| e
1683                .message
1684                .contains("Decision 'D' references unknown impact symbol 'missingThing'")),
1685            "expected unknown-impact error, got errors={:?}, warnings={:?}",
1686            findings.errors,
1687            findings.warnings
1688        );
1689    }
1690
1691    #[test]
1692    fn decision_semantic_string_impact_is_allowed() {
1693        let items = parse_items(
1694            r#"
1695module M
1696    intent =
1697        "x"
1698
1699fn existing() -> Int
1700    1
1701
1702verify existing
1703    existing() => 1
1704
1705decision D
1706    date = "2026-03-05"
1707    reason =
1708        "x"
1709    chosen = "ExistingChoice"
1710    rejected = []
1711    impacts = [existing, "error handling strategy"]
1712"#,
1713        );
1714        let findings = check_module_intent(&items);
1715        assert!(
1716            !findings
1717                .errors
1718                .iter()
1719                .any(|e| e.message.contains("references unknown impact symbol")),
1720            "did not expect unknown-impact error, got errors={:?}, warnings={:?}",
1721            findings.errors,
1722            findings.warnings
1723        );
1724    }
1725
1726    #[test]
1727    fn decision_unknown_chosen_symbol_is_error() {
1728        let items = parse_items(
1729            r#"
1730module M
1731    intent =
1732        "x"
1733
1734fn existing() -> Int
1735    1
1736
1737verify existing
1738    existing() => 1
1739
1740decision D
1741    date = "2026-03-05"
1742    reason =
1743        "x"
1744    chosen = MissingChoice
1745    rejected = []
1746    impacts = [existing]
1747"#,
1748        );
1749        let findings = check_module_intent(&items);
1750        assert!(
1751            findings
1752                .errors
1753                .iter()
1754                .any(|e| e.message.contains("unknown chosen symbol 'MissingChoice'")),
1755            "expected unknown-chosen error, got errors={:?}, warnings={:?}",
1756            findings.errors,
1757            findings.warnings
1758        );
1759    }
1760
1761    #[test]
1762    fn decision_unknown_rejected_symbol_is_error() {
1763        let items = parse_items(
1764            r#"
1765module M
1766    intent =
1767        "x"
1768
1769fn existing() -> Int
1770    1
1771
1772verify existing
1773    existing() => 1
1774
1775decision D
1776    date = "2026-03-05"
1777    reason =
1778        "x"
1779    chosen = "Keep"
1780    rejected = [MissingAlternative]
1781    impacts = [existing]
1782"#,
1783        );
1784        let findings = check_module_intent(&items);
1785        assert!(
1786            findings.errors.iter().any(|e| e
1787                .message
1788                .contains("unknown rejected symbol 'MissingAlternative'")),
1789            "expected unknown-rejected error, got errors={:?}, warnings={:?}",
1790            findings.errors,
1791            findings.warnings
1792        );
1793    }
1794
1795    #[test]
1796    fn decision_semantic_string_chosen_and_rejected_are_allowed() {
1797        let items = parse_items(
1798            r#"
1799module M
1800    intent =
1801        "x"
1802
1803fn existing() -> Int
1804    1
1805
1806verify existing
1807    existing() => 1
1808
1809decision D
1810    date = "2026-03-05"
1811    reason =
1812        "x"
1813    chosen = "Keep explicit context"
1814    rejected = ["Closure capture", "Global mutable state"]
1815    impacts = [existing]
1816"#,
1817        );
1818        let findings = check_module_intent(&items);
1819        assert!(
1820            !findings
1821                .errors
1822                .iter()
1823                .any(|e| e.message.contains("unknown chosen symbol")
1824                    || e.message.contains("unknown rejected symbol")),
1825            "did not expect chosen/rejected symbol errors, got errors={:?}, warnings={:?}",
1826            findings.errors,
1827            findings.warnings
1828        );
1829    }
1830
1831    #[test]
1832    fn decision_builtin_effect_impact_is_allowed() {
1833        let items = parse_items(
1834            r#"
1835module M
1836    intent =
1837        "x"
1838
1839fn existing() -> Int
1840    1
1841
1842verify existing
1843    existing() => 1
1844
1845decision D
1846    date = "2026-03-05"
1847    reason =
1848        "x"
1849    chosen = "ExistingChoice"
1850    rejected = []
1851    impacts = [existing, Tcp]
1852"#,
1853        );
1854        let tc = crate::types::checker::run_type_check_full(&items, None);
1855        let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
1856        assert!(
1857            !findings
1858                .errors
1859                .iter()
1860                .any(|e| e.message.contains("references unknown impact symbol 'Tcp'")),
1861            "did not expect Tcp impact error, got errors={:?}, warnings={:?}",
1862            findings.errors,
1863            findings.warnings
1864        );
1865    }
1866
1867    #[test]
1868    fn decision_effect_alias_impact_is_allowed() {
1869        let items = parse_items(
1870            r#"
1871module M
1872    intent =
1873        "x"
1874
1875effects AppIO = [Console, Disk]
1876
1877fn existing() -> Int
1878    1
1879
1880verify existing
1881    existing() => 1
1882
1883decision D
1884    date = "2026-03-05"
1885    reason =
1886        "x"
1887    chosen = "ExistingChoice"
1888    rejected = []
1889    impacts = [existing, AppIO]
1890"#,
1891        );
1892        let findings = check_module_intent(&items);
1893        assert!(
1894            !findings.errors.iter().any(|e| e
1895                .message
1896                .contains("references unknown impact symbol 'AppIO'")),
1897            "did not expect AppIO impact error, got errors={:?}, warnings={:?}",
1898            findings.errors,
1899            findings.warnings
1900        );
1901    }
1902}