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