Skip to main content

aver/checker/
intent.rs

1use std::collections::BTreeSet;
2
3use crate::ast::{DecisionBlock, DecisionImpact, Expr, FnDef, Stmt, TopLevel, TypeDef, VerifyKind};
4use crate::verify_law::{canonical_spec_ref, named_law_function};
5
6use super::{CheckFinding, FnSigMap, ModuleCheckFindings, dotted_name, verify_case_calls_target};
7
8/// Returns true if a function requires a ? description annotation.
9/// All functions except main() require one.
10fn fn_needs_desc(f: &FnDef) -> bool {
11    f.name != "main"
12}
13
14/// Missing verify warning policy:
15/// - skip `main`
16/// - skip effectful functions (tested through replay/recording flow)
17/// - skip trivial pure pass-through wrappers
18/// - require verify for the rest (pure, non-trivial logic)
19fn fn_needs_verify(f: &FnDef) -> bool {
20    if f.name == "main" {
21        return false;
22    }
23    if !f.effects.is_empty() {
24        return false;
25    }
26    !is_trivial_passthrough_wrapper(f)
27}
28
29fn is_trivial_passthrough_wrapper(f: &FnDef) -> bool {
30    let param_names: Vec<&str> = f.params.iter().map(|(name, _)| name.as_str()).collect();
31
32    if f.body.stmts().len() != 1 {
33        return false;
34    }
35    f.body
36        .tail_expr()
37        .is_some_and(|expr| expr_is_passthrough(expr, &param_names))
38}
39
40fn expr_is_passthrough(expr: &Expr, param_names: &[&str]) -> bool {
41    match expr {
42        // `fn id(x) = x`
43        Expr::Ident(name) => param_names.len() == 1 && name == param_names[0],
44        // `fn wrap(a,b) = inner(a,b)` (no argument transformation)
45        Expr::FnCall(_, args) => args_match_params(args, param_names),
46        // `fn some(x) = Option.Some(x)` style
47        Expr::Constructor(_, Some(arg)) => {
48            if param_names.len() != 1 {
49                return false;
50            }
51            matches!(arg.as_ref(), Expr::Ident(name) if name == param_names[0])
52        }
53        _ => false,
54    }
55}
56
57fn args_match_params(args: &[Expr], param_names: &[&str]) -> bool {
58    if args.len() != param_names.len() {
59        return false;
60    }
61    args.iter()
62        .zip(param_names.iter())
63        .all(|(arg, expected)| matches!(arg, Expr::Ident(name) if name == *expected))
64}
65
66fn collect_used_effects_expr(expr: &Expr, fn_sigs: &FnSigMap, out: &mut BTreeSet<String>) {
67    match expr {
68        Expr::FnCall(callee, args) => {
69            if let Some(callee_name) = dotted_name(callee)
70                && let Some((_, _, effects)) = fn_sigs.get(&callee_name)
71            {
72                for effect in effects {
73                    out.insert(effect.clone());
74                }
75            }
76            collect_used_effects_expr(callee, fn_sigs, out);
77            for arg in args {
78                collect_used_effects_expr(arg, fn_sigs, out);
79            }
80        }
81        Expr::TailCall(boxed) => {
82            let (target, args) = boxed.as_ref();
83            if let Some((_, _, effects)) = fn_sigs.get(target) {
84                for effect in effects {
85                    out.insert(effect.clone());
86                }
87            }
88            for arg in args {
89                collect_used_effects_expr(arg, fn_sigs, out);
90            }
91        }
92        Expr::BinOp(_, left, right) => {
93            collect_used_effects_expr(left, fn_sigs, out);
94            collect_used_effects_expr(right, fn_sigs, out);
95        }
96        Expr::Match { subject, arms, .. } => {
97            collect_used_effects_expr(subject, fn_sigs, out);
98            for arm in arms {
99                collect_used_effects_expr(&arm.body, fn_sigs, out);
100            }
101        }
102        Expr::ErrorProp(inner) => collect_used_effects_expr(inner, fn_sigs, out),
103        Expr::List(items) | Expr::Tuple(items) => {
104            for item in items {
105                collect_used_effects_expr(item, fn_sigs, out);
106            }
107        }
108        Expr::MapLiteral(entries) => {
109            for (key, value) in entries {
110                collect_used_effects_expr(key, fn_sigs, out);
111                collect_used_effects_expr(value, fn_sigs, out);
112            }
113        }
114        Expr::Attr(obj, _) => collect_used_effects_expr(obj, fn_sigs, out),
115        Expr::RecordCreate { fields, .. } => {
116            for (_, expr) in fields {
117                collect_used_effects_expr(expr, fn_sigs, out);
118            }
119        }
120        Expr::RecordUpdate { base, updates, .. } => {
121            collect_used_effects_expr(base, fn_sigs, out);
122            for (_, expr) in updates {
123                collect_used_effects_expr(expr, fn_sigs, out);
124            }
125        }
126        Expr::Constructor(_, Some(inner)) => collect_used_effects_expr(inner, fn_sigs, out),
127        Expr::Literal(_)
128        | Expr::Ident(_)
129        | Expr::InterpolatedStr(_)
130        | Expr::Resolved(_)
131        | Expr::Constructor(_, None) => {}
132    }
133}
134
135fn collect_used_effects(f: &FnDef, fn_sigs: &FnSigMap) -> BTreeSet<String> {
136    let mut used = BTreeSet::new();
137    for stmt in f.body.stmts() {
138        match stmt {
139            Stmt::Binding(_, _, expr) | Stmt::Expr(expr) => {
140                collect_used_effects_expr(expr, fn_sigs, &mut used)
141            }
142        }
143    }
144    used
145}
146
147fn collect_declared_symbols(items: &[TopLevel]) -> std::collections::HashSet<String> {
148    let mut out = std::collections::HashSet::new();
149    for item in items {
150        match item {
151            TopLevel::FnDef(f) => {
152                out.insert(f.name.clone());
153            }
154            TopLevel::Module(m) => {
155                out.insert(m.name.clone());
156            }
157            TopLevel::TypeDef(t) => match t {
158                crate::ast::TypeDef::Sum { name, .. }
159                | crate::ast::TypeDef::Product { name, .. } => {
160                    out.insert(name.clone());
161                }
162            },
163            TopLevel::Decision(d) => {
164                out.insert(d.name.clone());
165            }
166            TopLevel::Verify(_) | TopLevel::Stmt(_) => {}
167        }
168    }
169    out
170}
171
172fn collect_known_effect_symbols(fn_sigs: Option<&FnSigMap>) -> std::collections::HashSet<String> {
173    let mut out = std::collections::HashSet::new();
174    for builtin in ["Console", "Http", "Disk", "Tcp", "HttpServer"] {
175        out.insert(builtin.to_string());
176    }
177    if let Some(sigs) = fn_sigs {
178        for (_, _, effects) in sigs.values() {
179            for effect in effects {
180                out.insert(effect.clone());
181            }
182        }
183    }
184    out
185}
186
187fn decision_symbol_known(
188    name: &str,
189    declared_symbols: &std::collections::HashSet<String>,
190    known_effect_symbols: &std::collections::HashSet<String>,
191    dep_modules: &std::collections::HashSet<String>,
192) -> bool {
193    if declared_symbols.contains(name) || known_effect_symbols.contains(name) {
194        return true;
195    }
196    // Allow qualified names like "Logic.GameState" when "Logic" is a known dependency.
197    if let Some(prefix) = name.split('.').next()
198        && dep_modules.contains(prefix)
199    {
200        return true;
201    }
202    false
203}
204
205pub fn check_module_intent(items: &[TopLevel]) -> ModuleCheckFindings {
206    check_module_intent_with_sigs(items, None)
207}
208
209pub fn check_module_intent_with_sigs(
210    items: &[TopLevel],
211    fn_sigs: Option<&FnSigMap>,
212) -> ModuleCheckFindings {
213    check_module_intent_with_sigs_in(items, fn_sigs, None)
214}
215
216pub fn check_module_intent_with_sigs_in(
217    items: &[TopLevel],
218    fn_sigs: Option<&FnSigMap>,
219    source_file: Option<&str>,
220) -> ModuleCheckFindings {
221    let mut errors = Vec::new();
222    let mut warnings = Vec::new();
223    let declared_symbols = collect_declared_symbols(items);
224    let known_effect_symbols = collect_known_effect_symbols(fn_sigs);
225    let dep_modules: std::collections::HashSet<String> = items
226        .iter()
227        .filter_map(|item| {
228            if let TopLevel::Module(m) = item {
229                Some(m.depends.iter().map(|d| {
230                    // "Data.Fibonacci" → last segment "Fibonacci" is the namespace name
231                    d.rsplit('.').next().unwrap_or(d).to_string()
232                }))
233            } else {
234                None
235            }
236        })
237        .flatten()
238        .collect();
239    let module_name = items.iter().find_map(|item| {
240        if let TopLevel::Module(m) = item {
241            Some(m.name.clone())
242        } else {
243            None
244        }
245    });
246
247    let mut verified_fns: std::collections::HashSet<&str> = std::collections::HashSet::new();
248    let mut spec_fns: std::collections::HashSet<String> = std::collections::HashSet::new();
249    let mut empty_verify_fns: std::collections::HashSet<&str> = std::collections::HashSet::new();
250    let mut invalid_verify_fns: std::collections::HashSet<&str> = std::collections::HashSet::new();
251    for item in items {
252        if let TopLevel::Verify(v) = item {
253            if v.cases.is_empty() {
254                errors.push(CheckFinding {
255                    line: v.line,
256                    module: module_name.clone(),
257                    file: source_file.map(|s| s.to_string()),
258                    message: format!(
259                        "Verify block '{}' must contain at least one case",
260                        v.fn_name
261                    ),
262                });
263                empty_verify_fns.insert(v.fn_name.as_str());
264            } else {
265                let mut block_valid = true;
266                if matches!(v.kind, VerifyKind::Cases) {
267                    for (idx, (left, _right)) in v.cases.iter().enumerate() {
268                        if !verify_case_calls_target(left, &v.fn_name) {
269                            errors.push(CheckFinding {
270                                line: v.line,
271                                module: module_name.clone(),
272                                file: source_file.map(|s| s.to_string()),
273                                message: format!(
274                                    "Verify block '{}' case #{} must call '{}' on the left side",
275                                    v.fn_name,
276                                    idx + 1,
277                                    v.fn_name
278                                ),
279                            });
280                            block_valid = false;
281                        }
282                    }
283                    for (idx, (_left, right)) in v.cases.iter().enumerate() {
284                        if verify_case_calls_target(right, &v.fn_name) {
285                            errors.push(CheckFinding {
286                                line: v.line,
287                                module: module_name.clone(),
288                                file: source_file.map(|s| s.to_string()),
289                                message: format!(
290                                    "Verify block '{}' case #{} must not call '{}' on the right side",
291                                    v.fn_name,
292                                    idx + 1,
293                                    v.fn_name
294                                ),
295                            });
296                            block_valid = false;
297                        }
298                    }
299                }
300                if let VerifyKind::Law(law) = &v.kind
301                    && let Some(sigs) = fn_sigs
302                    && let Some(named_fn) = named_law_function(law, sigs)
303                {
304                    if !named_fn.is_pure {
305                        errors.push(CheckFinding {
306                            line: v.line,
307                            module: module_name.clone(),
308                            file: source_file.map(|s| s.to_string()),
309                            message: format!(
310                                "Verify law '{}.{}' resolves to effectful function '{}'; spec functions must be pure",
311                                v.fn_name, law.name, named_fn.name
312                            ),
313                        });
314                        block_valid = false;
315                    } else if let Some(spec_ref) = canonical_spec_ref(&v.fn_name, law, sigs) {
316                        spec_fns.insert(spec_ref.spec_fn_name);
317                    } else {
318                        warnings.push(CheckFinding {
319                            line: v.line,
320                            module: module_name.clone(),
321                            file: source_file.map(|s| s.to_string()),
322                            message: format!(
323                                "Verify law '{}.{}' names pure function '{}' but the law body never calls it; use '{}' in the assertion or rename the law",
324                                v.fn_name, law.name, named_fn.name, named_fn.name
325                            ),
326                        });
327                    }
328                }
329                if block_valid {
330                    verified_fns.insert(v.fn_name.as_str());
331                } else {
332                    invalid_verify_fns.insert(v.fn_name.as_str());
333                }
334            }
335        }
336    }
337
338    for item in items {
339        match item {
340            TopLevel::Module(m) => {
341                if m.intent.is_empty() {
342                    warnings.push(CheckFinding {
343                        line: m.line,
344                        module: Some(m.name.clone()),
345                        file: source_file.map(|s| s.to_string()),
346                        message: format!("Module '{}' has no intent block", m.name),
347                    });
348                }
349                // Validate exposes_opaque: each name must be a TypeDef.
350                if !m.exposes_opaque.is_empty() {
351                    let type_names: std::collections::HashSet<&str> = items
352                        .iter()
353                        .filter_map(|item| match item {
354                            TopLevel::TypeDef(TypeDef::Sum { name, .. })
355                            | TopLevel::TypeDef(TypeDef::Product { name, .. }) => {
356                                Some(name.as_str())
357                            }
358                            _ => None,
359                        })
360                        .collect();
361                    let exposed_set: std::collections::HashSet<&str> =
362                        m.exposes.iter().map(|s| s.as_str()).collect();
363                    for opaque_name in &m.exposes_opaque {
364                        if !type_names.contains(opaque_name.as_str()) {
365                            errors.push(CheckFinding {
366                                line: m.line,
367                                module: Some(m.name.clone()),
368                                file: source_file.map(|s| s.to_string()),
369                                message: format!(
370                                    "'{}' in exposes opaque is not a type defined in this module",
371                                    opaque_name
372                                ),
373                            });
374                        }
375                        if exposed_set.contains(opaque_name.as_str()) {
376                            errors.push(CheckFinding {
377                                line: m.line,
378                                module: Some(m.name.clone()),
379                                file: source_file.map(|s| s.to_string()),
380                                message: format!(
381                                    "'{}' cannot be in both exposes and exposes opaque",
382                                    opaque_name
383                                ),
384                            });
385                        }
386                    }
387                }
388            }
389            TopLevel::FnDef(f) => {
390                if f.desc.is_none() && fn_needs_desc(f) {
391                    warnings.push(CheckFinding {
392                        line: f.line,
393                        module: module_name.clone(),
394                        file: source_file.map(|s| s.to_string()),
395                        message: format!("Function '{}' has no description (?)", f.name),
396                    });
397                }
398                if let Some(sigs) = fn_sigs
399                    && let Some((_, _, declared_effects)) = sigs.get(&f.name)
400                    && !declared_effects.is_empty()
401                {
402                    let used_effects = collect_used_effects(f, sigs);
403                    let unused_effects: Vec<String> = declared_effects
404                        .iter()
405                        .filter(|declared| {
406                            !used_effects
407                                .iter()
408                                .any(|used| crate::effects::effect_satisfies(declared, used))
409                        })
410                        .cloned()
411                        .collect();
412                    if !unused_effects.is_empty() {
413                        let used = if used_effects.is_empty() {
414                            "none".to_string()
415                        } else {
416                            used_effects.iter().cloned().collect::<Vec<_>>().join(", ")
417                        };
418                        warnings.push(CheckFinding {
419                            line: f.line,
420                            module: module_name.clone(),
421                            file: source_file.map(|s| s.to_string()),
422                            message: format!(
423                                "Function '{}' declares unused effect(s): {} (used: {})",
424                                f.name,
425                                unused_effects.join(", "),
426                                used
427                            ),
428                        });
429                    }
430                    // Suggest granular effects when namespace shorthand could be narrowed
431                    for declared in declared_effects {
432                        if !declared.contains('.') {
433                            let prefix = format!("{}.", declared);
434                            let mut matching: Vec<&str> = used_effects
435                                .iter()
436                                .filter(|u| u.starts_with(&prefix))
437                                .map(|s| s.as_str())
438                                .collect();
439                            matching.sort();
440                            if !matching.is_empty() && !used_effects.contains(declared) {
441                                errors.push(CheckFinding {
442                                    line: f.line,
443                                    module: module_name.clone(),
444                                    file: source_file.map(|s| s.to_string()),
445                                    message: format!(
446                                        "Function '{}' declares '{}' — only uses {}; consider granular `! [{}]`",
447                                        f.name,
448                                        declared,
449                                        matching.join(", "),
450                                        matching.join(", ")
451                                    ),
452                                });
453                            }
454                        }
455                    }
456                }
457                if fn_needs_verify(f)
458                    && !verified_fns.contains(f.name.as_str())
459                    && !spec_fns.contains(&f.name)
460                    && !empty_verify_fns.contains(f.name.as_str())
461                    && !invalid_verify_fns.contains(f.name.as_str())
462                {
463                    errors.push(CheckFinding {
464                        line: f.line,
465                        module: module_name.clone(),
466                        file: source_file.map(|s| s.to_string()),
467                        message: format!("Function '{}' has no verify block", f.name),
468                    });
469                }
470            }
471            TopLevel::Decision(d) => {
472                if let DecisionImpact::Symbol(name) = &d.chosen
473                    && !decision_symbol_known(
474                        name,
475                        &declared_symbols,
476                        &known_effect_symbols,
477                        &dep_modules,
478                    )
479                {
480                    errors.push(CheckFinding {
481                            line: d.line,
482                            module: module_name.clone(),
483                            file: source_file.map(|s| s.to_string()),
484                            message: format!(
485                                "Decision '{}' references unknown chosen symbol '{}'. Use quoted string for semantic chosen value.",
486                                d.name, name
487                            ),
488                        });
489                }
490                for rejected in &d.rejected {
491                    if let DecisionImpact::Symbol(name) = rejected
492                        && !decision_symbol_known(
493                            name,
494                            &declared_symbols,
495                            &known_effect_symbols,
496                            &dep_modules,
497                        )
498                    {
499                        errors.push(CheckFinding {
500                                line: d.line,
501                                module: module_name.clone(),
502                                file: source_file.map(|s| s.to_string()),
503                                message: format!(
504                                    "Decision '{}' references unknown rejected symbol '{}'. Use quoted string for semantic rejected value.",
505                                    d.name, name
506                                ),
507                            });
508                    }
509                }
510                for impact in &d.impacts {
511                    if let DecisionImpact::Symbol(name) = impact
512                        && !decision_symbol_known(
513                            name,
514                            &declared_symbols,
515                            &known_effect_symbols,
516                            &dep_modules,
517                        )
518                    {
519                        errors.push(CheckFinding {
520                                line: d.line,
521                                module: module_name.clone(),
522                                file: source_file.map(|s| s.to_string()),
523                                message: format!(
524                                    "Decision '{}' references unknown impact symbol '{}'. Use quoted string for semantic impact.",
525                                    d.name, name
526                                ),
527                            });
528                    }
529                }
530            }
531            _ => {}
532        }
533    }
534
535    ModuleCheckFindings { errors, warnings }
536}
537
538pub fn index_decisions(items: &[TopLevel]) -> Vec<&DecisionBlock> {
539    items
540        .iter()
541        .filter_map(|item| {
542            if let TopLevel::Decision(d) = item {
543                Some(d)
544            } else {
545                None
546            }
547        })
548        .collect()
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use crate::lexer::Lexer;
555    use crate::parser::Parser;
556
557    fn parse_items(src: &str) -> Vec<TopLevel> {
558        let mut lexer = Lexer::new(src);
559        let tokens = lexer.tokenize().expect("lex failed");
560        let mut parser = Parser::new(tokens);
561        parser.parse().expect("parse failed")
562    }
563
564    #[test]
565    fn no_verify_warning_for_effectful_function() {
566        let items = parse_items(
567            r#"
568fn log(x: Int) -> Unit
569    ! [Console]
570    Console.print(x)
571"#,
572        );
573        let findings = check_module_intent(&items);
574        assert!(
575            !findings
576                .warnings
577                .iter()
578                .any(|w| w.message.contains("no verify block"))
579                && !findings
580                    .errors
581                    .iter()
582                    .any(|e| e.message.contains("no verify block")),
583            "unexpected findings: errors={:?}, warnings={:?}",
584            findings.errors,
585            findings.warnings
586        );
587    }
588
589    #[test]
590    fn warns_on_unused_declared_effects() {
591        let items = parse_items(
592            r#"
593fn log(x: Int) -> Unit
594    ! [Console.print, Http.get]
595    Console.print(x)
596"#,
597        );
598        let tc = crate::types::checker::run_type_check_full(&items, None);
599        assert!(
600            tc.errors.is_empty(),
601            "unexpected type errors: {:?}",
602            tc.errors
603        );
604        let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
605        assert!(
606            findings.warnings.iter().any(|w| {
607                w.message.contains("declares unused effect(s)")
608                    && w.message.contains("Http")
609                    && w.message.contains("used: Console.print")
610            }),
611            "expected unused-effect warning, got errors={:?}, warnings={:?}",
612            findings.errors,
613            findings.warnings
614        );
615    }
616
617    #[test]
618    fn no_unused_effect_warning_when_declared_effects_are_minimal() {
619        let items = parse_items(
620            r#"
621fn log(x: Int) -> Unit
622    ! [Console.print]
623    Console.print(x)
624"#,
625        );
626        let tc = crate::types::checker::run_type_check_full(&items, None);
627        assert!(
628            tc.errors.is_empty(),
629            "unexpected type errors: {:?}",
630            tc.errors
631        );
632        let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
633        assert!(
634            !findings
635                .warnings
636                .iter()
637                .any(|w| w.message.contains("declares unused effect(s)")),
638            "did not expect unused-effect warning, got errors={:?}, warnings={:?}",
639            findings.errors,
640            findings.warnings
641        );
642        assert!(
643            !findings
644                .warnings
645                .iter()
646                .any(|w| w.message.contains("declares broad effect")),
647            "did not expect broad-effect warning, got errors={:?}, warnings={:?}",
648            findings.errors,
649            findings.warnings
650        );
651    }
652
653    #[test]
654    fn no_granular_warning_when_namespace_effect_is_also_required_transitively() {
655        let items = parse_items(
656            r#"
657fn inner() -> Unit
658    ! [Console]
659    Unit
660
661fn outer() -> Unit
662    ! [Console]
663    Console.print("hi")
664    inner()
665"#,
666        );
667        let tc = crate::types::checker::run_type_check_full(&items, None);
668        assert!(
669            tc.errors.is_empty(),
670            "unexpected type errors: {:?}",
671            tc.errors
672        );
673        let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
674        assert!(
675            !findings
676                .errors
677                .iter()
678                .any(|e| e.message.contains("Function 'outer' declares 'Console'")),
679            "did not expect granular suggestion for outer, got errors={:?}, warnings={:?}",
680            findings.errors,
681            findings.warnings
682        );
683    }
684
685    #[test]
686    fn no_verify_warning_for_trivial_passthrough_wrapper() {
687        let items = parse_items(
688            r#"
689fn passthrough(x: Int) -> Int
690    inner(x)
691"#,
692        );
693        let findings = check_module_intent(&items);
694        assert!(
695            !findings
696                .warnings
697                .iter()
698                .any(|w| w.message.contains("no verify block"))
699                && !findings
700                    .errors
701                    .iter()
702                    .any(|e| e.message.contains("no verify block")),
703            "unexpected findings: errors={:?}, warnings={:?}",
704            findings.errors,
705            findings.warnings
706        );
707    }
708
709    #[test]
710    fn verify_error_for_pure_non_trivial_logic() {
711        let items = parse_items(
712            r#"
713fn add1(x: Int) -> Int
714    x + 1
715"#,
716        );
717        let findings = check_module_intent(&items);
718        assert!(
719            findings
720                .errors
721                .iter()
722                .any(|e| e.message == "Function 'add1' has no verify block"),
723            "expected verify error, got errors={:?}, warnings={:?}",
724            findings.errors,
725            findings.warnings
726        );
727    }
728
729    #[test]
730    fn empty_verify_block_is_rejected() {
731        let items = parse_items(
732            r#"
733fn add1(x: Int) -> Int
734    x + 1
735
736verify add1
737"#,
738        );
739        let findings = check_module_intent(&items);
740        assert!(
741            findings
742                .errors
743                .iter()
744                .any(|e| e.message == "Verify block 'add1' must contain at least one case"),
745            "expected empty verify error, got errors={:?}, warnings={:?}",
746            findings.errors,
747            findings.warnings
748        );
749        assert!(
750            !findings
751                .errors
752                .iter()
753                .any(|e| e.message == "Function 'add1' has no verify block"),
754            "expected no duplicate missing-verify error, got errors={:?}, warnings={:?}",
755            findings.errors,
756            findings.warnings
757        );
758    }
759
760    #[test]
761    fn verify_case_must_call_verified_function_on_left_side() {
762        let items = parse_items(
763            r#"
764fn add1(x: Int) -> Int
765    x + 1
766
767verify add1
768    true => true
769"#,
770        );
771        let findings = check_module_intent(&items);
772        assert!(
773            findings.errors.iter().any(|e| {
774                e.message
775                    .contains("Verify block 'add1' case #1 must call 'add1' on the left side")
776            }),
777            "expected verify-case-call error, got errors={:?}, warnings={:?}",
778            findings.errors,
779            findings.warnings
780        );
781        assert!(
782            !findings
783                .errors
784                .iter()
785                .any(|e| e.message == "Function 'add1' has no verify block"),
786            "expected no duplicate missing-verify error, got errors={:?}, warnings={:?}",
787            findings.errors,
788            findings.warnings
789        );
790    }
791
792    #[test]
793    fn verify_case_must_not_call_verified_function_on_right_side() {
794        let items = parse_items(
795            r#"
796fn add1(x: Int) -> Int
797    x + 1
798
799verify add1
800    add1(1) => add1(1)
801"#,
802        );
803        let findings = check_module_intent(&items);
804        assert!(
805            findings.errors.iter().any(|e| {
806                e.message
807                    .contains("Verify block 'add1' case #1 must not call 'add1' on the right side")
808            }),
809            "expected verify-case-rhs error, got errors={:?}, warnings={:?}",
810            findings.errors,
811            findings.warnings
812        );
813    }
814
815    #[test]
816    fn verify_law_skips_left_right_call_heuristics() {
817        let items = parse_items(
818            r#"
819fn add1(x: Int) -> Int
820    x + 1
821
822verify add1 law reflexive
823    given x: Int = [1, 2, 3]
824    x => x
825"#,
826        );
827        let findings = check_module_intent(&items);
828        assert!(
829            !findings
830                .errors
831                .iter()
832                .any(|e| e.message.contains("must call 'add1' on the left side")),
833            "did not expect lhs-call heuristic for law verify, got errors={:?}",
834            findings.errors
835        );
836        assert!(
837            !findings
838                .errors
839                .iter()
840                .any(|e| e.message.contains("must not call 'add1' on the right side")),
841            "did not expect rhs-call heuristic for law verify, got errors={:?}",
842            findings.errors
843        );
844        assert!(
845            !findings
846                .errors
847                .iter()
848                .any(|e| e.message == "Function 'add1' has no verify block"),
849            "law verify should satisfy verify requirement, got errors={:?}",
850            findings.errors
851        );
852    }
853
854    #[test]
855    fn verify_law_when_must_have_bool_type() {
856        let items = parse_items(
857            r#"
858fn add1(x: Int) -> Int
859    x + 1
860
861verify add1 law ordered
862    given x: Int = [1, 2]
863    when add1(x)
864    x => x
865"#,
866        );
867        let tc = crate::types::checker::run_type_check_full(&items, None);
868        assert!(
869            tc.errors
870                .iter()
871                .any(|e| e.message.contains("when condition must have type Bool")),
872            "expected Bool type error for when, got errors={:?}",
873            tc.errors
874        );
875    }
876
877    #[test]
878    fn verify_law_when_must_be_pure() {
879        let items = parse_items(
880            r#"
881fn add1(x: Int) -> Int
882    x + 1
883
884fn noisyPositive(x: Int) -> Bool
885    ! [Console.print]
886    Console.print("{x}")
887    x > 0
888
889verify add1 law ordered
890    given x: Int = [1, 2]
891    when noisyPositive(x)
892    x => x
893"#,
894        );
895        let tc = crate::types::checker::run_type_check_full(&items, None);
896        assert!(
897            tc.errors.iter().any(|e| e.message.contains(
898                "Function '<verify:add1>' calls 'noisyPositive' which has effect 'Console.print'"
899            )),
900            "expected purity error for when, got errors={:?}",
901            tc.errors
902        );
903    }
904
905    #[test]
906    fn verify_law_named_effectful_function_is_an_error() {
907        let items = parse_items(
908            r#"
909fn add1(x: Int) -> Int
910    x + 1
911
912fn specFn(x: Int) -> Int
913    ! [Console.print]
914    Console.print("{x}")
915    x
916
917verify add1 law specFn
918    given x: Int = [1, 2]
919    add1(x) => add1(x)
920"#,
921        );
922        let tc = crate::types::checker::run_type_check_full(&items, None);
923        let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
924        assert!(
925            findings.errors.iter().any(|e| e.message.contains(
926                "Verify law 'add1.specFn' resolves to effectful function 'specFn'; spec functions must be pure"
927            )),
928            "expected effectful-spec error, got errors={:?}, warnings={:?}",
929            findings.errors,
930            findings.warnings
931        );
932    }
933
934    #[test]
935    fn verify_law_named_pure_function_must_appear_in_law_body() {
936        let items = parse_items(
937            r#"
938fn add1(x: Int) -> Int
939    x + 1
940
941fn add1Spec(x: Int) -> Int
942    x + 1
943
944verify add1 law add1Spec
945    given x: Int = [1, 2]
946    add1(x) => x + 1
947"#,
948        );
949        let tc = crate::types::checker::run_type_check_full(&items, None);
950        let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
951        assert!(
952            findings.warnings.iter().any(|w| w.message.contains(
953                "Verify law 'add1.add1Spec' names pure function 'add1Spec' but the law body never calls it"
954            )),
955            "expected unused-spec warning, got errors={:?}, warnings={:?}",
956            findings.errors,
957            findings.warnings
958        );
959    }
960
961    #[test]
962    fn canonical_spec_function_does_not_need_its_own_verify_block() {
963        let items = parse_items(
964            r#"
965fn add1(x: Int) -> Int
966    x + 1
967
968fn add1Spec(x: Int) -> Int
969    x + 1
970
971verify add1 law add1Spec
972    given x: Int = [1, 2]
973    add1(x) => add1Spec(x)
974"#,
975        );
976        let tc = crate::types::checker::run_type_check_full(&items, None);
977        let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
978        assert!(
979            !findings
980                .errors
981                .iter()
982                .any(|e| e.message == "Function 'add1Spec' has no verify block"),
983            "spec function should not need its own verify block, got errors={:?}, warnings={:?}",
984            findings.errors,
985            findings.warnings
986        );
987    }
988
989    #[test]
990    fn decision_unknown_symbol_impact_is_error() {
991        let items = parse_items(
992            r#"
993module M
994    intent =
995        "x"
996
997fn existing() -> Int
998    1
999
1000verify existing
1001    existing() => 1
1002
1003decision D
1004    date = "2026-03-05"
1005    reason =
1006        "x"
1007    chosen = "ExistingChoice"
1008    rejected = []
1009    impacts = [existing, missingThing]
1010"#,
1011        );
1012        let findings = check_module_intent(&items);
1013        assert!(
1014            findings.errors.iter().any(|e| e
1015                .message
1016                .contains("Decision 'D' references unknown impact symbol 'missingThing'")),
1017            "expected unknown-impact error, got errors={:?}, warnings={:?}",
1018            findings.errors,
1019            findings.warnings
1020        );
1021    }
1022
1023    #[test]
1024    fn decision_semantic_string_impact_is_allowed() {
1025        let items = parse_items(
1026            r#"
1027module M
1028    intent =
1029        "x"
1030
1031fn existing() -> Int
1032    1
1033
1034verify existing
1035    existing() => 1
1036
1037decision D
1038    date = "2026-03-05"
1039    reason =
1040        "x"
1041    chosen = "ExistingChoice"
1042    rejected = []
1043    impacts = [existing, "error handling strategy"]
1044"#,
1045        );
1046        let findings = check_module_intent(&items);
1047        assert!(
1048            !findings
1049                .errors
1050                .iter()
1051                .any(|e| e.message.contains("references unknown impact symbol")),
1052            "did not expect unknown-impact error, got errors={:?}, warnings={:?}",
1053            findings.errors,
1054            findings.warnings
1055        );
1056    }
1057
1058    #[test]
1059    fn decision_unknown_chosen_symbol_is_error() {
1060        let items = parse_items(
1061            r#"
1062module M
1063    intent =
1064        "x"
1065
1066fn existing() -> Int
1067    1
1068
1069verify existing
1070    existing() => 1
1071
1072decision D
1073    date = "2026-03-05"
1074    reason =
1075        "x"
1076    chosen = MissingChoice
1077    rejected = []
1078    impacts = [existing]
1079"#,
1080        );
1081        let findings = check_module_intent(&items);
1082        assert!(
1083            findings
1084                .errors
1085                .iter()
1086                .any(|e| e.message.contains("unknown chosen symbol 'MissingChoice'")),
1087            "expected unknown-chosen error, got errors={:?}, warnings={:?}",
1088            findings.errors,
1089            findings.warnings
1090        );
1091    }
1092
1093    #[test]
1094    fn decision_unknown_rejected_symbol_is_error() {
1095        let items = parse_items(
1096            r#"
1097module M
1098    intent =
1099        "x"
1100
1101fn existing() -> Int
1102    1
1103
1104verify existing
1105    existing() => 1
1106
1107decision D
1108    date = "2026-03-05"
1109    reason =
1110        "x"
1111    chosen = "Keep"
1112    rejected = [MissingAlternative]
1113    impacts = [existing]
1114"#,
1115        );
1116        let findings = check_module_intent(&items);
1117        assert!(
1118            findings.errors.iter().any(|e| e
1119                .message
1120                .contains("unknown rejected symbol 'MissingAlternative'")),
1121            "expected unknown-rejected error, got errors={:?}, warnings={:?}",
1122            findings.errors,
1123            findings.warnings
1124        );
1125    }
1126
1127    #[test]
1128    fn decision_semantic_string_chosen_and_rejected_are_allowed() {
1129        let items = parse_items(
1130            r#"
1131module M
1132    intent =
1133        "x"
1134
1135fn existing() -> Int
1136    1
1137
1138verify existing
1139    existing() => 1
1140
1141decision D
1142    date = "2026-03-05"
1143    reason =
1144        "x"
1145    chosen = "Keep explicit context"
1146    rejected = ["Closure capture", "Global mutable state"]
1147    impacts = [existing]
1148"#,
1149        );
1150        let findings = check_module_intent(&items);
1151        assert!(
1152            !findings
1153                .errors
1154                .iter()
1155                .any(|e| e.message.contains("unknown chosen symbol")
1156                    || e.message.contains("unknown rejected symbol")),
1157            "did not expect chosen/rejected symbol errors, got errors={:?}, warnings={:?}",
1158            findings.errors,
1159            findings.warnings
1160        );
1161    }
1162
1163    #[test]
1164    fn decision_builtin_effect_impact_is_allowed() {
1165        let items = parse_items(
1166            r#"
1167module M
1168    intent =
1169        "x"
1170
1171fn existing() -> Int
1172    1
1173
1174verify existing
1175    existing() => 1
1176
1177decision D
1178    date = "2026-03-05"
1179    reason =
1180        "x"
1181    chosen = "ExistingChoice"
1182    rejected = []
1183    impacts = [existing, Tcp]
1184"#,
1185        );
1186        let tc = crate::types::checker::run_type_check_full(&items, None);
1187        let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
1188        assert!(
1189            !findings
1190                .errors
1191                .iter()
1192                .any(|e| e.message.contains("references unknown impact symbol 'Tcp'")),
1193            "did not expect Tcp impact error, got errors={:?}, warnings={:?}",
1194            findings.errors,
1195            findings.warnings
1196        );
1197    }
1198
1199    #[test]
1200    fn decision_removed_effect_alias_impact_is_error() {
1201        let items = parse_items(
1202            r#"
1203module M
1204    intent =
1205        "x"
1206
1207fn existing() -> Int
1208    1
1209
1210verify existing
1211    existing() => 1
1212
1213decision D
1214    date = "2026-03-05"
1215    reason =
1216        "x"
1217    chosen = "ExistingChoice"
1218    rejected = []
1219    impacts = [existing, AppIO]
1220"#,
1221        );
1222        let findings = check_module_intent(&items);
1223        assert!(
1224            findings.errors.iter().any(|e| e
1225                .message
1226                .contains("references unknown impact symbol 'AppIO'")),
1227            "expected AppIO impact error, got errors={:?}, warnings={:?}",
1228            findings.errors,
1229            findings.warnings
1230        );
1231    }
1232}