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