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