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