Skip to main content

aver/checker/
intent.rs

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