Skip to main content

aver/checker/
intent.rs

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