1use colored::Colorize;
2use std::collections::BTreeSet;
3
4use crate::ast::{
5 DecisionBlock, DecisionImpact, Expr, FnDef, Pattern, Stmt, TopLevel, VerifyBlock,
6 VerifyGivenDomain, VerifyKind,
7};
8use crate::interpreter::{Interpreter, aver_repr};
9use crate::types::{Type, parse_type_str_strict};
10use crate::value::{RuntimeError, Value};
11use crate::verify_law::{canonical_spec_ref, named_law_function};
12
13pub struct VerifyResult {
14 #[allow(dead_code)]
15 pub fn_name: String,
16 pub passed: usize,
17 pub failed: usize,
18 pub skipped: usize,
19 #[allow(dead_code)]
20 pub failures: Vec<(String, String, String)>, }
22
23pub struct ModuleCheckFindings {
24 pub errors: Vec<CheckFinding>,
25 pub warnings: Vec<CheckFinding>,
26}
27
28type FnSigSummary = (Vec<Type>, Type, Vec<String>);
29type FnSigMap = std::collections::HashMap<String, FnSigSummary>;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct CheckFinding {
33 pub line: usize,
34 pub module: Option<String>,
35 pub file: Option<String>,
36 pub message: String,
37}
38
39fn module_name_for_items(items: &[TopLevel]) -> Option<String> {
40 items.iter().find_map(|item| {
41 if let TopLevel::Module(m) = item {
42 Some(m.name.clone())
43 } else {
44 None
45 }
46 })
47}
48
49fn dotted_name(expr: &Expr) -> Option<String> {
50 match expr {
51 Expr::Ident(name) => Some(name.clone()),
52 Expr::Attr(base, field) => {
53 let mut prefix = dotted_name(base)?;
54 prefix.push('.');
55 prefix.push_str(field);
56 Some(prefix)
57 }
58 _ => None,
59 }
60}
61
62fn normalize_constructor_tag(path: &str) -> Option<String> {
63 let mut parts = path.split('.').collect::<Vec<_>>();
64 if parts.len() < 2 {
65 return None;
66 }
67 let variant = parts.pop()?;
68 let type_name = parts.pop()?;
69 Some(format!("{}.{}", type_name, variant))
70}
71
72fn constructor_tag_from_pattern(pattern: &Pattern) -> Option<String> {
73 match pattern {
74 Pattern::Constructor(path, _) => normalize_constructor_tag(path),
75 _ => None,
76 }
77}
78
79fn constructor_tag_from_expr(expr: &Expr) -> Option<String> {
80 match expr {
81 Expr::Attr(_, _) => normalize_constructor_tag(&dotted_name(expr)?),
82 Expr::FnCall(callee, _) => normalize_constructor_tag(&dotted_name(callee)?),
83 Expr::Constructor(name, _) => normalize_constructor_tag(name),
84 _ => None,
85 }
86}
87
88fn collect_target_call_args<'a>(
89 expr: &'a Expr,
90 fn_name: &str,
91 arg_index: usize,
92 out: &mut Vec<&'a Expr>,
93) {
94 match expr {
95 Expr::FnCall(callee, args) => {
96 if callee_is_target(callee, fn_name)
97 && let Some(arg) = args.get(arg_index)
98 {
99 out.push(arg);
100 }
101 collect_target_call_args(callee, fn_name, arg_index, out);
102 for arg in args {
103 collect_target_call_args(arg, fn_name, arg_index, out);
104 }
105 }
106 Expr::BinOp(_, left, right) => {
107 collect_target_call_args(left, fn_name, arg_index, out);
108 collect_target_call_args(right, fn_name, arg_index, out);
109 }
110 Expr::Match { subject, arms, .. } => {
111 collect_target_call_args(subject, fn_name, arg_index, out);
112 for arm in arms {
113 collect_target_call_args(&arm.body, fn_name, arg_index, out);
114 }
115 }
116 Expr::Constructor(_, Some(inner)) | Expr::ErrorProp(inner) => {
117 collect_target_call_args(inner, fn_name, arg_index, out);
118 }
119 Expr::List(items) | Expr::Tuple(items) => {
120 for item in items {
121 collect_target_call_args(item, fn_name, arg_index, out);
122 }
123 }
124 Expr::MapLiteral(entries) => {
125 for (key, value) in entries {
126 collect_target_call_args(key, fn_name, arg_index, out);
127 collect_target_call_args(value, fn_name, arg_index, out);
128 }
129 }
130 Expr::Attr(obj, _) => collect_target_call_args(obj, fn_name, arg_index, out),
131 Expr::RecordCreate { fields, .. } => {
132 for (_, value) in fields {
133 collect_target_call_args(value, fn_name, arg_index, out);
134 }
135 }
136 Expr::RecordUpdate { base, updates, .. } => {
137 collect_target_call_args(base, fn_name, arg_index, out);
138 for (_, value) in updates {
139 collect_target_call_args(value, fn_name, arg_index, out);
140 }
141 }
142 Expr::TailCall(boxed) => {
143 let (target, args) = boxed.as_ref();
144 if target == fn_name
145 && let Some(arg) = args.get(arg_index)
146 {
147 out.push(arg);
148 }
149 for arg in args {
150 collect_target_call_args(arg, fn_name, arg_index, out);
151 }
152 }
153 Expr::Literal(_)
154 | Expr::Ident(_)
155 | Expr::InterpolatedStr(_)
156 | Expr::Resolved(_)
157 | Expr::Constructor(_, None) => {}
158 }
159}
160
161fn expr_is_result_err_case(expr: &Expr) -> bool {
162 match expr {
163 Expr::FnCall(callee, _) => dotted_name(callee)
164 .and_then(|path| normalize_constructor_tag(&path))
165 .is_some_and(|tag| tag == "Result.Err"),
166 Expr::Constructor(name, _) => {
167 normalize_constructor_tag(name).is_some_and(|tag| tag == "Result.Err")
168 }
169 _ => false,
170 }
171}
172
173fn expr_is_option_none_case(expr: &Expr) -> bool {
174 match expr {
175 Expr::Attr(_, _) => dotted_name(expr)
176 .and_then(|path| normalize_constructor_tag(&path))
177 .is_some_and(|tag| tag == "Option.None"),
178 Expr::Constructor(name, None) => {
179 normalize_constructor_tag(name).is_some_and(|tag| tag == "Option.None")
180 }
181 _ => false,
182 }
183}
184
185fn verify_cases_block_is_well_formed(block: &VerifyBlock) -> bool {
186 matches!(block.kind, VerifyKind::Cases)
187 && !block.cases.is_empty()
188 && block.cases.iter().all(|(left, right)| {
189 verify_case_calls_target(left, &block.fn_name)
190 && !verify_case_calls_target(right, &block.fn_name)
191 })
192}
193
194fn enum_match_coverage_target(f: &FnDef) -> Option<(usize, Vec<String>)> {
195 let Expr::Match { subject, arms, .. } = f.body.tail_expr()? else {
196 return None;
197 };
198 let Expr::Ident(subject_name) = subject.as_ref() else {
199 return None;
200 };
201 let param_index = f.params.iter().position(|(name, _)| name == subject_name)?;
202 let constructors: BTreeSet<String> = arms
203 .iter()
204 .filter_map(|arm| constructor_tag_from_pattern(&arm.pattern))
205 .collect();
206 if constructors.is_empty() {
207 None
208 } else {
209 Some((param_index, constructors.into_iter().collect()))
210 }
211}
212
213pub fn collect_verify_coverage_warnings(items: &[TopLevel]) -> Vec<CheckFinding> {
214 collect_verify_coverage_warnings_in(items, None)
215}
216
217pub fn collect_verify_coverage_warnings_in(
218 items: &[TopLevel],
219 source_file: Option<&str>,
220) -> Vec<CheckFinding> {
221 let module_name = module_name_for_items(items);
222 let fn_defs: std::collections::HashMap<&str, &FnDef> = items
223 .iter()
224 .filter_map(|item| {
225 if let TopLevel::FnDef(f) = item {
226 Some((f.name.as_str(), f))
227 } else {
228 None
229 }
230 })
231 .collect();
232
233 let mut warnings = Vec::new();
234 for block in merge_verify_blocks(items) {
235 if !verify_cases_block_is_well_formed(&block) {
236 continue;
237 }
238 let Some(f) = fn_defs.get(block.fn_name.as_str()).copied() else {
239 continue;
240 };
241
242 if let Ok(ret_ty) = parse_type_str_strict(&f.return_type) {
243 if matches!(ret_ty, Type::Result(_, _))
244 && !block
245 .cases
246 .iter()
247 .any(|(_, right)| expr_is_result_err_case(right))
248 {
249 warnings.push(CheckFinding {
250 line: block.line,
251 module: module_name.clone(),
252 file: source_file.map(|s| s.to_string()),
253 message: format!(
254 "verify examples for {} do not include any Result.Err case",
255 block.fn_name
256 ),
257 });
258 }
259
260 if matches!(ret_ty, Type::Option(_))
261 && !block
262 .cases
263 .iter()
264 .any(|(_, right)| expr_is_option_none_case(right))
265 {
266 warnings.push(CheckFinding {
267 line: block.line,
268 module: module_name.clone(),
269 file: source_file.map(|s| s.to_string()),
270 message: format!(
271 "verify examples for {} do not include any Option.None case",
272 block.fn_name
273 ),
274 });
275 }
276 }
277
278 if let Some((param_index, constructors)) = enum_match_coverage_target(f) {
279 let mut covered = BTreeSet::new();
280 for (left, _) in &block.cases {
281 let mut args = Vec::new();
282 collect_target_call_args(left, &block.fn_name, param_index, &mut args);
283 for arg in args {
284 if let Some(tag) = constructor_tag_from_expr(arg)
285 && constructors.contains(&tag)
286 {
287 covered.insert(tag);
288 }
289 }
290 }
291 if covered.len() < constructors.len() {
292 warnings.push(CheckFinding {
293 line: block.line,
294 module: module_name.clone(),
295 file: source_file.map(|s| s.to_string()),
296 message: format!(
297 "verify examples for {} cover {}/{} enum constructors",
298 block.fn_name,
299 covered.len(),
300 constructors.len()
301 ),
302 });
303 }
304 }
305 }
306
307 warnings
308}
309
310fn verify_given_domain_to_str(domain: &VerifyGivenDomain) -> String {
311 match domain {
312 VerifyGivenDomain::IntRange { start, end } => format!("{start}..{end}"),
313 VerifyGivenDomain::Explicit(values) => {
314 let parts: Vec<String> = values.iter().map(expr_to_str).collect();
315 format!("[{}]", parts.join(", "))
316 }
317 }
318}
319
320pub fn run_verify(block: &VerifyBlock, interp: &mut Interpreter) -> VerifyResult {
321 let mut passed = 0;
322 let mut failed = 0;
323 let mut skipped = 0;
324 let mut failures = Vec::new();
325 let is_law = matches!(block.kind, VerifyKind::Law(_));
326
327 match &block.kind {
328 VerifyKind::Cases => println!("Verify: {}", block.fn_name.cyan()),
329 VerifyKind::Law(law) => {
330 if let Ok(Value::Fn { effects, .. }) = interp.lookup(&law.name)
331 && effects.is_empty()
332 && crate::verify_law::canonical_spec_shape(&block.fn_name, law, &law.name)
333 {
334 println!("Verify: {} spec {}", block.fn_name.cyan(), law.name.cyan());
335 } else {
336 println!("Verify: {} law {}", block.fn_name.cyan(), law.name.cyan());
337 }
338 for given in &law.givens {
339 println!(
340 " {} {}: {} = {}",
341 "given".dimmed(),
342 given.name,
343 given.type_name,
344 verify_given_domain_to_str(&given.domain)
345 );
346 }
347 if let Some(when_expr) = &law.when {
348 println!(" {} {}", "when".dimmed(), expr_to_str(when_expr));
349 }
350 println!(
351 " {} {} == {}",
352 "law".dimmed(),
353 expr_to_str(&law.lhs),
354 expr_to_str(&law.rhs)
355 );
356 println!(" {} {}", "cases".dimmed(), block.cases.len());
357 }
358 }
359
360 for (idx, (left_expr, right_expr)) in block.cases.iter().enumerate() {
361 let case_str = format!("{} == {}", expr_to_str(left_expr), expr_to_str(right_expr));
362 let case_label = if is_law {
363 format!("case {}/{}", idx + 1, block.cases.len())
364 } else {
365 case_str.clone()
366 };
367 let failure_case = if is_law {
368 format!("{} [{}]", case_label, case_str)
369 } else {
370 case_str.clone()
371 };
372
373 if let VerifyKind::Law(law) = &block.kind
374 && let Some(sample_guard) = law.sample_guards.get(idx)
375 {
376 match interp.eval_expr(sample_guard) {
377 Ok(Value::Bool(true)) => {}
378 Ok(Value::Bool(false)) => {
379 skipped += 1;
380 println!(" {} {} (when false)", "·".dimmed(), case_label.dimmed());
381 continue;
382 }
383 Ok(other) => {
384 failed += 1;
385 println!(" {} {}", "✗".red(), case_label);
386 println!(" when did not evaluate to Bool: {}", aver_repr(&other));
387 failures.push((
388 failure_case,
389 "Bool".to_string(),
390 format!("when produced {}", aver_repr(&other)),
391 ));
392 continue;
393 }
394 Err(RuntimeError::ErrProp(err_val)) => {
395 failed += 1;
396 println!(" {} {}", "✗".red(), case_label);
397 println!(" when ? hit Result.Err({})", aver_repr(&err_val));
398 failures.push((
399 failure_case,
400 String::new(),
401 format!("when ? hit Result.Err({})", aver_repr(&err_val)),
402 ));
403 continue;
404 }
405 Err(e) => {
406 failed += 1;
407 println!(" {} {}", "✗".red(), case_label);
408 println!(" when error: {}", e);
409 failures.push((failure_case, String::new(), format!("WHEN ERROR: {}", e)));
410 continue;
411 }
412 }
413 }
414
415 let left_result = interp.eval_expr(left_expr);
416 let right_result = interp.eval_expr(right_expr);
417
418 match (left_result, right_result) {
419 (Ok(left_val), Ok(right_val)) => {
420 if interp.aver_eq(&left_val, &right_val) {
421 passed += 1;
422 if !is_law {
423 println!(" {} {}", "✓".green(), case_label);
424 }
425 } else {
426 failed += 1;
427 println!(" {} {}", "✗".red(), case_label);
428 if is_law {
429 println!(" expanded: {}", case_str);
430 }
431 let expected = aver_repr(&right_val);
432 let actual = aver_repr(&left_val);
433 println!(" expected: {}", expected);
434 println!(" got: {}", actual);
435 failures.push((failure_case, expected, actual));
436 }
437 }
438 (Err(RuntimeError::ErrProp(err_val)), _) | (_, Err(RuntimeError::ErrProp(err_val))) => {
440 failed += 1;
441 println!(" {} {}", "✗".red(), case_label);
442 if is_law {
443 println!(" expanded: {}", case_str);
444 }
445 println!(" ? hit Result.Err({})", aver_repr(&err_val));
446 failures.push((
447 failure_case,
448 String::new(),
449 format!("? hit Result.Err({})", aver_repr(&err_val)),
450 ));
451 }
452 (Err(e), _) | (_, Err(e)) => {
453 failed += 1;
454 println!(" {} {}", "✗".red(), case_label);
455 if is_law {
456 println!(" expanded: {}", case_str);
457 }
458 println!(" error: {}", e);
459 failures.push((failure_case, String::new(), format!("ERROR: {}", e)));
460 }
461 }
462 }
463
464 let total = passed + failed;
465 if is_law && failed == 0 {
466 if skipped == 0 {
467 println!(
468 " {} all {} generated case(s) passed",
469 "✓".green(),
470 block.cases.len()
471 );
472 } else {
473 println!(
474 " {} all {} active generated case(s) passed ({} skipped by when)",
475 "✓".green(),
476 passed,
477 skipped
478 );
479 }
480 }
481 if failed == 0 && skipped == 0 {
482 println!(" {}", format!("{}/{} passed", passed, total).green());
483 } else if failed == 0 {
484 println!(
485 " {}",
486 format!("{}/{} passed, {} skipped", passed, total + skipped, skipped).green()
487 );
488 } else if skipped == 0 {
489 println!(" {}", format!("{}/{} passed", passed, total).red());
490 } else {
491 println!(
492 " {}",
493 format!("{}/{} passed, {} skipped", passed, total + skipped, skipped).red()
494 );
495 }
496
497 VerifyResult {
498 fn_name: block.fn_name.clone(),
499 passed,
500 failed,
501 skipped,
502 failures,
503 }
504}
505
506pub fn index_decisions(items: &[TopLevel]) -> Vec<&DecisionBlock> {
507 items
508 .iter()
509 .filter_map(|item| {
510 if let TopLevel::Decision(d) = item {
511 Some(d)
512 } else {
513 None
514 }
515 })
516 .collect()
517}
518
519pub fn merge_verify_blocks(items: &[TopLevel]) -> Vec<VerifyBlock> {
520 let mut merged: Vec<VerifyBlock> = Vec::new();
521 let mut by_fn_cases: std::collections::HashMap<String, usize> =
522 std::collections::HashMap::new();
523
524 for item in items {
525 let TopLevel::Verify(vb) = item else {
526 continue;
527 };
528 match &vb.kind {
529 VerifyKind::Cases => {
530 if let Some(&idx) = by_fn_cases.get(&vb.fn_name) {
531 merged[idx].cases.extend(vb.cases.clone());
532 } else {
533 by_fn_cases.insert(vb.fn_name.clone(), merged.len());
534 merged.push(vb.clone());
535 }
536 }
537 VerifyKind::Law(_) => {
538 merged.push(vb.clone());
539 }
540 }
541 }
542
543 merged
544}
545
546fn fn_needs_desc(f: &FnDef) -> bool {
549 f.name != "main"
550}
551
552fn fn_needs_verify(f: &FnDef) -> bool {
558 if f.name == "main" {
559 return false;
560 }
561 if !f.effects.is_empty() {
562 return false;
563 }
564 !is_trivial_passthrough_wrapper(f)
565}
566
567fn is_trivial_passthrough_wrapper(f: &FnDef) -> bool {
568 let param_names: Vec<&str> = f.params.iter().map(|(name, _)| name.as_str()).collect();
569
570 if f.body.stmts().len() != 1 {
571 return false;
572 }
573 f.body
574 .tail_expr()
575 .is_some_and(|expr| expr_is_passthrough(expr, ¶m_names))
576}
577
578fn expr_is_passthrough(expr: &Expr, param_names: &[&str]) -> bool {
579 match expr {
580 Expr::Ident(name) => param_names.len() == 1 && name == param_names[0],
582 Expr::FnCall(_, args) => args_match_params(args, param_names),
584 Expr::Constructor(_, Some(arg)) => {
586 if param_names.len() != 1 {
587 return false;
588 }
589 matches!(arg.as_ref(), Expr::Ident(name) if name == param_names[0])
590 }
591 _ => false,
592 }
593}
594
595fn args_match_params(args: &[Expr], param_names: &[&str]) -> bool {
596 if args.len() != param_names.len() {
597 return false;
598 }
599 args.iter()
600 .zip(param_names.iter())
601 .all(|(arg, expected)| matches!(arg, Expr::Ident(name) if name == *expected))
602}
603
604fn verify_case_calls_target(left: &Expr, fn_name: &str) -> bool {
605 match left {
606 Expr::FnCall(callee, args) => {
607 callee_is_target(callee, fn_name)
608 || verify_case_calls_target(callee, fn_name)
609 || args
610 .iter()
611 .any(|arg| verify_case_calls_target(arg, fn_name))
612 }
613 Expr::BinOp(_, left_expr, right_expr) => {
614 verify_case_calls_target(left_expr, fn_name)
615 || verify_case_calls_target(right_expr, fn_name)
616 }
617 Expr::Match { subject, arms, .. } => {
618 verify_case_calls_target(subject, fn_name)
619 || arms
620 .iter()
621 .any(|arm| verify_case_calls_target(&arm.body, fn_name))
622 }
623 Expr::Constructor(_, Some(inner)) => verify_case_calls_target(inner, fn_name),
624 Expr::ErrorProp(inner) => verify_case_calls_target(inner, fn_name),
625 Expr::List(elems) => elems
626 .iter()
627 .any(|elem| verify_case_calls_target(elem, fn_name)),
628 Expr::Tuple(items) => items
629 .iter()
630 .any(|item| verify_case_calls_target(item, fn_name)),
631 Expr::MapLiteral(entries) => entries.iter().any(|(k, v)| {
632 verify_case_calls_target(k, fn_name) || verify_case_calls_target(v, fn_name)
633 }),
634 Expr::Attr(obj, _) => verify_case_calls_target(obj, fn_name),
635 Expr::RecordCreate { fields, .. } => fields
636 .iter()
637 .any(|(_, expr)| verify_case_calls_target(expr, fn_name)),
638 Expr::RecordUpdate { base, updates, .. } => {
639 verify_case_calls_target(base, fn_name)
640 || updates
641 .iter()
642 .any(|(_, expr)| verify_case_calls_target(expr, fn_name))
643 }
644 Expr::TailCall(boxed) => {
645 boxed.0 == fn_name
646 || boxed
647 .1
648 .iter()
649 .any(|arg| verify_case_calls_target(arg, fn_name))
650 }
651 Expr::Literal(_) | Expr::Ident(_) | Expr::InterpolatedStr(_) | Expr::Resolved(_) => false,
652 Expr::Constructor(_, None) => false,
653 }
654}
655
656fn callee_is_target(callee: &Expr, fn_name: &str) -> bool {
657 matches!(callee, Expr::Ident(name) if name == fn_name)
658}
659
660fn collect_used_effects_expr(expr: &Expr, fn_sigs: &FnSigMap, out: &mut BTreeSet<String>) {
661 match expr {
662 Expr::FnCall(callee, args) => {
663 if let Some(callee_name) = dotted_name(callee)
664 && let Some((_, _, effects)) = fn_sigs.get(&callee_name)
665 {
666 for effect in effects {
667 out.insert(effect.clone());
668 }
669 }
670 collect_used_effects_expr(callee, fn_sigs, out);
671 for arg in args {
672 collect_used_effects_expr(arg, fn_sigs, out);
673 }
674 }
675 Expr::TailCall(boxed) => {
676 let (target, args) = boxed.as_ref();
677 if let Some((_, _, effects)) = fn_sigs.get(target) {
678 for effect in effects {
679 out.insert(effect.clone());
680 }
681 }
682 for arg in args {
683 collect_used_effects_expr(arg, fn_sigs, out);
684 }
685 }
686 Expr::BinOp(_, left, right) => {
687 collect_used_effects_expr(left, fn_sigs, out);
688 collect_used_effects_expr(right, fn_sigs, out);
689 }
690 Expr::Match { subject, arms, .. } => {
691 collect_used_effects_expr(subject, fn_sigs, out);
692 for arm in arms {
693 collect_used_effects_expr(&arm.body, fn_sigs, out);
694 }
695 }
696 Expr::ErrorProp(inner) => collect_used_effects_expr(inner, fn_sigs, out),
697 Expr::List(items) | Expr::Tuple(items) => {
698 for item in items {
699 collect_used_effects_expr(item, fn_sigs, out);
700 }
701 }
702 Expr::MapLiteral(entries) => {
703 for (key, value) in entries {
704 collect_used_effects_expr(key, fn_sigs, out);
705 collect_used_effects_expr(value, fn_sigs, out);
706 }
707 }
708 Expr::Attr(obj, _) => collect_used_effects_expr(obj, fn_sigs, out),
709 Expr::RecordCreate { fields, .. } => {
710 for (_, expr) in fields {
711 collect_used_effects_expr(expr, fn_sigs, out);
712 }
713 }
714 Expr::RecordUpdate { base, updates, .. } => {
715 collect_used_effects_expr(base, fn_sigs, out);
716 for (_, expr) in updates {
717 collect_used_effects_expr(expr, fn_sigs, out);
718 }
719 }
720 Expr::Constructor(_, Some(inner)) => collect_used_effects_expr(inner, fn_sigs, out),
721 Expr::Literal(_)
722 | Expr::Ident(_)
723 | Expr::InterpolatedStr(_)
724 | Expr::Resolved(_)
725 | Expr::Constructor(_, None) => {}
726 }
727}
728
729fn collect_used_effects(f: &FnDef, fn_sigs: &FnSigMap) -> BTreeSet<String> {
730 let mut used = BTreeSet::new();
731 for stmt in f.body.stmts() {
732 match stmt {
733 Stmt::Binding(_, _, expr) | Stmt::Expr(expr) => {
734 collect_used_effects_expr(expr, fn_sigs, &mut used)
735 }
736 }
737 }
738 used
739}
740
741fn collect_declared_symbols(items: &[TopLevel]) -> std::collections::HashSet<String> {
742 let mut out = std::collections::HashSet::new();
743 for item in items {
744 match item {
745 TopLevel::FnDef(f) => {
746 out.insert(f.name.clone());
747 }
748 TopLevel::Module(m) => {
749 out.insert(m.name.clone());
750 }
751 TopLevel::TypeDef(t) => match t {
752 crate::ast::TypeDef::Sum { name, .. }
753 | crate::ast::TypeDef::Product { name, .. } => {
754 out.insert(name.clone());
755 }
756 },
757 TopLevel::Decision(d) => {
758 out.insert(d.name.clone());
759 }
760 TopLevel::EffectSet { .. } => {}
761 TopLevel::Verify(_) | TopLevel::Stmt(_) => {}
762 }
763 }
764 out
765}
766
767fn collect_known_effect_symbols(fn_sigs: Option<&FnSigMap>) -> std::collections::HashSet<String> {
768 let mut out = std::collections::HashSet::new();
769 for builtin in ["Console", "Http", "Disk", "Tcp", "HttpServer"] {
770 out.insert(builtin.to_string());
771 }
772 if let Some(sigs) = fn_sigs {
773 for (_, _, effects) in sigs.values() {
774 for effect in effects {
775 out.insert(effect.clone());
776 }
777 }
778 }
779 out
780}
781
782fn decision_symbol_known(
783 name: &str,
784 declared_symbols: &std::collections::HashSet<String>,
785 known_effect_symbols: &std::collections::HashSet<String>,
786) -> bool {
787 declared_symbols.contains(name) || known_effect_symbols.contains(name)
788}
789
790pub fn check_module_intent(items: &[TopLevel]) -> ModuleCheckFindings {
791 check_module_intent_with_sigs(items, None)
792}
793
794pub fn check_module_intent_with_sigs(
795 items: &[TopLevel],
796 fn_sigs: Option<&FnSigMap>,
797) -> ModuleCheckFindings {
798 check_module_intent_with_sigs_in(items, fn_sigs, None)
799}
800
801pub fn check_module_intent_with_sigs_in(
802 items: &[TopLevel],
803 fn_sigs: Option<&FnSigMap>,
804 source_file: Option<&str>,
805) -> ModuleCheckFindings {
806 let mut errors = Vec::new();
807 let mut warnings = Vec::new();
808 let declared_symbols = collect_declared_symbols(items);
809 let known_effect_symbols = collect_known_effect_symbols(fn_sigs);
810 let module_name = items.iter().find_map(|item| {
811 if let TopLevel::Module(m) = item {
812 Some(m.name.clone())
813 } else {
814 None
815 }
816 });
817
818 let mut verified_fns: std::collections::HashSet<&str> = std::collections::HashSet::new();
819 let mut spec_fns: std::collections::HashSet<String> = std::collections::HashSet::new();
820 let mut empty_verify_fns: std::collections::HashSet<&str> = std::collections::HashSet::new();
821 let mut invalid_verify_fns: std::collections::HashSet<&str> = std::collections::HashSet::new();
822 for item in items {
823 if let TopLevel::Verify(v) = item {
824 if v.cases.is_empty() {
825 errors.push(CheckFinding {
826 line: v.line,
827 module: module_name.clone(),
828 file: source_file.map(|s| s.to_string()),
829 message: format!(
830 "Verify block '{}' must contain at least one case",
831 v.fn_name
832 ),
833 });
834 empty_verify_fns.insert(v.fn_name.as_str());
835 } else {
836 let mut block_valid = true;
837 if matches!(v.kind, VerifyKind::Cases) {
838 for (idx, (left, _right)) in v.cases.iter().enumerate() {
839 if !verify_case_calls_target(left, &v.fn_name) {
840 errors.push(CheckFinding {
841 line: v.line,
842 module: module_name.clone(),
843 file: source_file.map(|s| s.to_string()),
844 message: format!(
845 "Verify block '{}' case #{} must call '{}' on the left side",
846 v.fn_name,
847 idx + 1,
848 v.fn_name
849 ),
850 });
851 block_valid = false;
852 }
853 }
854 for (idx, (_left, right)) in v.cases.iter().enumerate() {
855 if verify_case_calls_target(right, &v.fn_name) {
856 errors.push(CheckFinding {
857 line: v.line,
858 module: module_name.clone(),
859 file: source_file.map(|s| s.to_string()),
860 message: format!(
861 "Verify block '{}' case #{} must not call '{}' on the right side",
862 v.fn_name,
863 idx + 1,
864 v.fn_name
865 ),
866 });
867 block_valid = false;
868 }
869 }
870 }
871 if let VerifyKind::Law(law) = &v.kind
872 && let Some(sigs) = fn_sigs
873 && let Some(named_fn) = named_law_function(law, sigs)
874 {
875 if !named_fn.is_pure {
876 errors.push(CheckFinding {
877 line: v.line,
878 module: module_name.clone(),
879 file: source_file.map(|s| s.to_string()),
880 message: format!(
881 "Verify law '{}.{}' resolves to effectful function '{}'; spec functions must be pure",
882 v.fn_name, law.name, named_fn.name
883 ),
884 });
885 block_valid = false;
886 } else if let Some(spec_ref) = canonical_spec_ref(&v.fn_name, law, sigs) {
887 spec_fns.insert(spec_ref.spec_fn_name);
888 } else {
889 warnings.push(CheckFinding {
890 line: v.line,
891 module: module_name.clone(),
892 file: source_file.map(|s| s.to_string()),
893 message: format!(
894 "Verify law '{}.{}' names pure function '{}' but the law body never calls it; use '{}' in the assertion or rename the law",
895 v.fn_name, law.name, named_fn.name, named_fn.name
896 ),
897 });
898 }
899 }
900 if block_valid {
901 verified_fns.insert(v.fn_name.as_str());
902 } else {
903 invalid_verify_fns.insert(v.fn_name.as_str());
904 }
905 }
906 }
907 }
908
909 for item in items {
910 match item {
911 TopLevel::Module(m) => {
912 if m.intent.is_empty() {
913 warnings.push(CheckFinding {
914 line: m.line,
915 module: Some(m.name.clone()),
916 file: source_file.map(|s| s.to_string()),
917 message: format!("Module '{}' has no intent block", m.name),
918 });
919 }
920 }
921 TopLevel::FnDef(f) => {
922 if f.desc.is_none() && fn_needs_desc(f) {
923 warnings.push(CheckFinding {
924 line: f.line,
925 module: module_name.clone(),
926 file: source_file.map(|s| s.to_string()),
927 message: format!("Function '{}' has no description (?)", f.name),
928 });
929 }
930 if let Some(sigs) = fn_sigs
931 && let Some((_, _, declared_effects)) = sigs.get(&f.name)
932 && !declared_effects.is_empty()
933 {
934 let used_effects = collect_used_effects(f, sigs);
935 let unused_effects: Vec<String> = declared_effects
936 .iter()
937 .filter(|declared| {
938 !used_effects
939 .iter()
940 .any(|used| crate::effects::effect_satisfies(declared, used))
941 })
942 .cloned()
943 .collect();
944 if !unused_effects.is_empty() {
945 let used = if used_effects.is_empty() {
946 "none".to_string()
947 } else {
948 used_effects.into_iter().collect::<Vec<_>>().join(", ")
949 };
950 warnings.push(CheckFinding {
951 line: f.line,
952 module: module_name.clone(),
953 file: source_file.map(|s| s.to_string()),
954 message: format!(
955 "Function '{}' declares unused effect(s): {} (used: {})",
956 f.name,
957 unused_effects.join(", "),
958 used
959 ),
960 });
961 }
962 }
963 if fn_needs_verify(f)
964 && !verified_fns.contains(f.name.as_str())
965 && !spec_fns.contains(&f.name)
966 && !empty_verify_fns.contains(f.name.as_str())
967 && !invalid_verify_fns.contains(f.name.as_str())
968 {
969 errors.push(CheckFinding {
970 line: f.line,
971 module: module_name.clone(),
972 file: source_file.map(|s| s.to_string()),
973 message: format!("Function '{}' has no verify block", f.name),
974 });
975 }
976 }
977 TopLevel::Decision(d) => {
978 if let DecisionImpact::Symbol(name) = &d.chosen
979 && !decision_symbol_known(name, &declared_symbols, &known_effect_symbols)
980 {
981 errors.push(CheckFinding {
982 line: d.line,
983 module: module_name.clone(),
984 file: source_file.map(|s| s.to_string()),
985 message: format!(
986 "Decision '{}' references unknown chosen symbol '{}'. Use quoted string for semantic chosen value.",
987 d.name, name
988 ),
989 });
990 }
991 for rejected in &d.rejected {
992 if let DecisionImpact::Symbol(name) = rejected
993 && !decision_symbol_known(name, &declared_symbols, &known_effect_symbols)
994 {
995 errors.push(CheckFinding {
996 line: d.line,
997 module: module_name.clone(),
998 file: source_file.map(|s| s.to_string()),
999 message: format!(
1000 "Decision '{}' references unknown rejected symbol '{}'. Use quoted string for semantic rejected value.",
1001 d.name, name
1002 ),
1003 });
1004 }
1005 }
1006 for impact in &d.impacts {
1007 if let DecisionImpact::Symbol(name) = impact
1008 && !decision_symbol_known(name, &declared_symbols, &known_effect_symbols)
1009 {
1010 errors.push(CheckFinding {
1011 line: d.line,
1012 module: module_name.clone(),
1013 file: source_file.map(|s| s.to_string()),
1014 message: format!(
1015 "Decision '{}' references unknown impact symbol '{}'. Use quoted string for semantic impact.",
1016 d.name, name
1017 ),
1018 });
1019 }
1020 }
1021 }
1022 _ => {}
1023 }
1024 }
1025
1026 ModuleCheckFindings { errors, warnings }
1027}
1028
1029pub fn expr_to_str(expr: &crate::ast::Expr) -> String {
1030 use crate::ast::Expr;
1031 use crate::ast::Literal;
1032
1033 match expr {
1034 Expr::Literal(lit) => match lit {
1035 Literal::Int(i) => i.to_string(),
1036 Literal::Float(f) => f.to_string(),
1037 Literal::Str(s) => format!("\"{}\"", s),
1038 Literal::Bool(b) => if *b { "true" } else { "false" }.to_string(),
1039 Literal::Unit => "Unit".to_string(),
1040 },
1041 Expr::Ident(name) => name.clone(),
1042 Expr::FnCall(fn_expr, args) => {
1043 let fn_str = expr_to_str(fn_expr);
1044 let args_str = args.iter().map(expr_to_str).collect::<Vec<_>>().join(", ");
1045 format!("{}({})", fn_str, args_str)
1046 }
1047 Expr::Constructor(name, arg) => match arg {
1048 None => name.clone(),
1049 Some(a) => format!("{}({})", name, expr_to_str(a)),
1050 },
1051 Expr::BinOp(op, left, right) => {
1052 use crate::ast::BinOp;
1053 let op_str = match op {
1054 BinOp::Add => "+",
1055 BinOp::Sub => "-",
1056 BinOp::Mul => "*",
1057 BinOp::Div => "/",
1058 BinOp::Eq => "==",
1059 BinOp::Neq => "!=",
1060 BinOp::Lt => "<",
1061 BinOp::Gt => ">",
1062 BinOp::Lte => "<=",
1063 BinOp::Gte => ">=",
1064 };
1065 format!("{} {} {}", expr_to_str(left), op_str, expr_to_str(right))
1066 }
1067 Expr::InterpolatedStr(parts) => {
1068 use crate::ast::StrPart;
1069 let mut inner = String::new();
1070 for part in parts {
1071 match part {
1072 StrPart::Literal(s) => inner.push_str(s),
1073 StrPart::Parsed(e) => {
1074 inner.push('{');
1075 inner.push_str(&expr_to_str(e));
1076 inner.push('}');
1077 }
1078 }
1079 }
1080 format!("\"{}\"", inner)
1081 }
1082 Expr::List(elements) => {
1083 let parts: Vec<String> = elements.iter().map(expr_to_str).collect();
1084 format!("[{}]", parts.join(", "))
1085 }
1086 Expr::Tuple(items) => {
1087 let parts: Vec<String> = items.iter().map(expr_to_str).collect();
1088 format!("({})", parts.join(", "))
1089 }
1090 Expr::MapLiteral(entries) => {
1091 let parts = entries
1092 .iter()
1093 .map(|(key, value)| format!("{} => {}", expr_to_str(key), expr_to_str(value)))
1094 .collect::<Vec<_>>();
1095 format!("{{{}}}", parts.join(", "))
1096 }
1097 Expr::ErrorProp(inner) => format!("{}?", expr_to_str(inner)),
1098 Expr::Attr(obj, field) => format!("{}.{}", expr_to_str(obj), field),
1099 Expr::RecordCreate { type_name, fields } => {
1100 let flds: Vec<String> = fields
1101 .iter()
1102 .map(|(name, expr)| format!("{} = {}", name, expr_to_str(expr)))
1103 .collect();
1104 format!("{}({})", type_name, flds.join(", "))
1105 }
1106 Expr::RecordUpdate {
1107 type_name,
1108 base,
1109 updates,
1110 } => {
1111 let upds: Vec<String> = updates
1112 .iter()
1113 .map(|(name, expr)| format!("{} = {}", name, expr_to_str(expr)))
1114 .collect();
1115 format!(
1116 "{}.update({}, {})",
1117 type_name,
1118 expr_to_str(base),
1119 upds.join(", ")
1120 )
1121 }
1122 Expr::TailCall(boxed) => {
1123 let (target, args) = boxed.as_ref();
1124 let a = args.iter().map(expr_to_str).collect::<Vec<_>>().join(", ");
1125 format!("<tail-call:{}>({})", target, a)
1126 }
1127 Expr::Resolved(_) => "<resolved>".to_string(),
1128 Expr::Match { subject, arms, .. } => {
1129 let s = expr_to_str(subject);
1130 let arms_str: Vec<String> = arms
1131 .iter()
1132 .map(|arm| format!("{:?} -> {}", arm.pattern, expr_to_str(&arm.body)))
1133 .collect();
1134 format!("match {} {}", s, arms_str.join(", "))
1135 }
1136 }
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141 use super::*;
1142 use crate::lexer::Lexer;
1143 use crate::parser::Parser;
1144
1145 fn parse_items(src: &str) -> Vec<TopLevel> {
1146 let mut lexer = Lexer::new(src);
1147 let tokens = lexer.tokenize().expect("lex failed");
1148 let mut parser = Parser::new(tokens);
1149 parser.parse().expect("parse failed")
1150 }
1151
1152 #[test]
1153 fn no_verify_warning_for_effectful_function() {
1154 let items = parse_items(
1155 r#"
1156fn log(x: Int) -> Unit
1157 ! [Console]
1158 Console.print(x)
1159"#,
1160 );
1161 let findings = check_module_intent(&items);
1162 assert!(
1163 !findings
1164 .warnings
1165 .iter()
1166 .any(|w| w.message.contains("no verify block"))
1167 && !findings
1168 .errors
1169 .iter()
1170 .any(|e| e.message.contains("no verify block")),
1171 "unexpected findings: errors={:?}, warnings={:?}",
1172 findings.errors,
1173 findings.warnings
1174 );
1175 }
1176
1177 #[test]
1178 fn warns_on_unused_declared_effects() {
1179 let items = parse_items(
1180 r#"
1181fn log(x: Int) -> Unit
1182 ! [Console.print, Http.get]
1183 Console.print(x)
1184"#,
1185 );
1186 let tc = crate::types::checker::run_type_check_full(&items, None);
1187 assert!(
1188 tc.errors.is_empty(),
1189 "unexpected type errors: {:?}",
1190 tc.errors
1191 );
1192 let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
1193 assert!(
1194 findings.warnings.iter().any(|w| {
1195 w.message.contains("declares unused effect(s)")
1196 && w.message.contains("Http")
1197 && w.message.contains("used: Console.print")
1198 }),
1199 "expected unused-effect warning, got errors={:?}, warnings={:?}",
1200 findings.errors,
1201 findings.warnings
1202 );
1203 }
1204
1205 #[test]
1206 fn no_unused_effect_warning_when_declared_effects_are_minimal() {
1207 let items = parse_items(
1208 r#"
1209fn log(x: Int) -> Unit
1210 ! [Console.print]
1211 Console.print(x)
1212"#,
1213 );
1214 let tc = crate::types::checker::run_type_check_full(&items, None);
1215 assert!(
1216 tc.errors.is_empty(),
1217 "unexpected type errors: {:?}",
1218 tc.errors
1219 );
1220 let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
1221 assert!(
1222 !findings
1223 .warnings
1224 .iter()
1225 .any(|w| w.message.contains("declares unused effect(s)")),
1226 "did not expect unused-effect warning, got errors={:?}, warnings={:?}",
1227 findings.errors,
1228 findings.warnings
1229 );
1230 assert!(
1231 !findings
1232 .warnings
1233 .iter()
1234 .any(|w| w.message.contains("declares broad effect")),
1235 "did not expect broad-effect warning, got errors={:?}, warnings={:?}",
1236 findings.errors,
1237 findings.warnings
1238 );
1239 }
1240
1241 #[test]
1242 fn no_verify_warning_for_trivial_passthrough_wrapper() {
1243 let items = parse_items(
1244 r#"
1245fn passthrough(x: Int) -> Int
1246 inner(x)
1247"#,
1248 );
1249 let findings = check_module_intent(&items);
1250 assert!(
1251 !findings
1252 .warnings
1253 .iter()
1254 .any(|w| w.message.contains("no verify block"))
1255 && !findings
1256 .errors
1257 .iter()
1258 .any(|e| e.message.contains("no verify block")),
1259 "unexpected findings: errors={:?}, warnings={:?}",
1260 findings.errors,
1261 findings.warnings
1262 );
1263 }
1264
1265 #[test]
1266 fn verify_error_for_pure_non_trivial_logic() {
1267 let items = parse_items(
1268 r#"
1269fn add1(x: Int) -> Int
1270 x + 1
1271"#,
1272 );
1273 let findings = check_module_intent(&items);
1274 assert!(
1275 findings
1276 .errors
1277 .iter()
1278 .any(|e| e.message == "Function 'add1' has no verify block"),
1279 "expected verify error, got errors={:?}, warnings={:?}",
1280 findings.errors,
1281 findings.warnings
1282 );
1283 }
1284
1285 #[test]
1286 fn empty_verify_block_is_rejected() {
1287 let items = parse_items(
1288 r#"
1289fn add1(x: Int) -> Int
1290 x + 1
1291
1292verify add1
1293"#,
1294 );
1295 let findings = check_module_intent(&items);
1296 assert!(
1297 findings
1298 .errors
1299 .iter()
1300 .any(|e| e.message == "Verify block 'add1' must contain at least one case"),
1301 "expected empty verify error, got errors={:?}, warnings={:?}",
1302 findings.errors,
1303 findings.warnings
1304 );
1305 assert!(
1306 !findings
1307 .errors
1308 .iter()
1309 .any(|e| e.message == "Function 'add1' has no verify block"),
1310 "expected no duplicate missing-verify error, got errors={:?}, warnings={:?}",
1311 findings.errors,
1312 findings.warnings
1313 );
1314 }
1315
1316 #[test]
1317 fn verify_case_must_call_verified_function_on_left_side() {
1318 let items = parse_items(
1319 r#"
1320fn add1(x: Int) -> Int
1321 x + 1
1322
1323verify add1
1324 true => true
1325"#,
1326 );
1327 let findings = check_module_intent(&items);
1328 assert!(
1329 findings.errors.iter().any(|e| {
1330 e.message
1331 .contains("Verify block 'add1' case #1 must call 'add1' on the left side")
1332 }),
1333 "expected verify-case-call error, got errors={:?}, warnings={:?}",
1334 findings.errors,
1335 findings.warnings
1336 );
1337 assert!(
1338 !findings
1339 .errors
1340 .iter()
1341 .any(|e| e.message == "Function 'add1' has no verify block"),
1342 "expected no duplicate missing-verify error, got errors={:?}, warnings={:?}",
1343 findings.errors,
1344 findings.warnings
1345 );
1346 }
1347
1348 #[test]
1349 fn verify_case_must_not_call_verified_function_on_right_side() {
1350 let items = parse_items(
1351 r#"
1352fn add1(x: Int) -> Int
1353 x + 1
1354
1355verify add1
1356 add1(1) => add1(1)
1357"#,
1358 );
1359 let findings = check_module_intent(&items);
1360 assert!(
1361 findings.errors.iter().any(|e| {
1362 e.message
1363 .contains("Verify block 'add1' case #1 must not call 'add1' on the right side")
1364 }),
1365 "expected verify-case-rhs error, got errors={:?}, warnings={:?}",
1366 findings.errors,
1367 findings.warnings
1368 );
1369 }
1370
1371 #[test]
1372 fn verify_law_skips_left_right_call_heuristics() {
1373 let items = parse_items(
1374 r#"
1375fn add1(x: Int) -> Int
1376 x + 1
1377
1378verify add1 law reflexive
1379 given x: Int = [1, 2, 3]
1380 x => x
1381"#,
1382 );
1383 let findings = check_module_intent(&items);
1384 assert!(
1385 !findings
1386 .errors
1387 .iter()
1388 .any(|e| e.message.contains("must call 'add1' on the left side")),
1389 "did not expect lhs-call heuristic for law verify, got errors={:?}",
1390 findings.errors
1391 );
1392 assert!(
1393 !findings
1394 .errors
1395 .iter()
1396 .any(|e| e.message.contains("must not call 'add1' on the right side")),
1397 "did not expect rhs-call heuristic for law verify, got errors={:?}",
1398 findings.errors
1399 );
1400 assert!(
1401 !findings
1402 .errors
1403 .iter()
1404 .any(|e| e.message == "Function 'add1' has no verify block"),
1405 "law verify should satisfy verify requirement, got errors={:?}",
1406 findings.errors
1407 );
1408 }
1409
1410 #[test]
1411 fn verify_law_when_must_have_bool_type() {
1412 let items = parse_items(
1413 r#"
1414fn add1(x: Int) -> Int
1415 x + 1
1416
1417verify add1 law ordered
1418 given x: Int = [1, 2]
1419 when add1(x)
1420 x => x
1421"#,
1422 );
1423 let tc = crate::types::checker::run_type_check_full(&items, None);
1424 assert!(
1425 tc.errors
1426 .iter()
1427 .any(|e| e.message.contains("when condition must have type Bool")),
1428 "expected Bool type error for when, got errors={:?}",
1429 tc.errors
1430 );
1431 }
1432
1433 #[test]
1434 fn verify_law_when_must_be_pure() {
1435 let items = parse_items(
1436 r#"
1437fn add1(x: Int) -> Int
1438 x + 1
1439
1440fn noisyPositive(x: Int) -> Bool
1441 ! [Console.print]
1442 Console.print("{x}")
1443 x > 0
1444
1445verify add1 law ordered
1446 given x: Int = [1, 2]
1447 when noisyPositive(x)
1448 x => x
1449"#,
1450 );
1451 let tc = crate::types::checker::run_type_check_full(&items, None);
1452 assert!(
1453 tc.errors.iter().any(|e| e.message.contains(
1454 "Function '<verify:add1>' calls 'noisyPositive' which has effect 'Console.print'"
1455 )),
1456 "expected purity error for when, got errors={:?}",
1457 tc.errors
1458 );
1459 }
1460
1461 #[test]
1462 fn verify_law_named_effectful_function_is_an_error() {
1463 let items = parse_items(
1464 r#"
1465fn add1(x: Int) -> Int
1466 x + 1
1467
1468fn specFn(x: Int) -> Int
1469 ! [Console.print]
1470 Console.print("{x}")
1471 x
1472
1473verify add1 law specFn
1474 given x: Int = [1, 2]
1475 add1(x) => add1(x)
1476"#,
1477 );
1478 let tc = crate::types::checker::run_type_check_full(&items, None);
1479 let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
1480 assert!(
1481 findings.errors.iter().any(|e| e.message.contains(
1482 "Verify law 'add1.specFn' resolves to effectful function 'specFn'; spec functions must be pure"
1483 )),
1484 "expected effectful-spec error, got errors={:?}, warnings={:?}",
1485 findings.errors,
1486 findings.warnings
1487 );
1488 }
1489
1490 #[test]
1491 fn verify_law_named_pure_function_must_appear_in_law_body() {
1492 let items = parse_items(
1493 r#"
1494fn add1(x: Int) -> Int
1495 x + 1
1496
1497fn add1Spec(x: Int) -> Int
1498 x + 1
1499
1500verify add1 law add1Spec
1501 given x: Int = [1, 2]
1502 add1(x) => x + 1
1503"#,
1504 );
1505 let tc = crate::types::checker::run_type_check_full(&items, None);
1506 let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
1507 assert!(
1508 findings.warnings.iter().any(|w| w.message.contains(
1509 "Verify law 'add1.add1Spec' names pure function 'add1Spec' but the law body never calls it"
1510 )),
1511 "expected unused-spec warning, got errors={:?}, warnings={:?}",
1512 findings.errors,
1513 findings.warnings
1514 );
1515 }
1516
1517 #[test]
1518 fn canonical_spec_function_does_not_need_its_own_verify_block() {
1519 let items = parse_items(
1520 r#"
1521fn add1(x: Int) -> Int
1522 x + 1
1523
1524fn add1Spec(x: Int) -> Int
1525 x + 1
1526
1527verify add1 law add1Spec
1528 given x: Int = [1, 2]
1529 add1(x) => add1Spec(x)
1530"#,
1531 );
1532 let tc = crate::types::checker::run_type_check_full(&items, None);
1533 let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
1534 assert!(
1535 !findings
1536 .errors
1537 .iter()
1538 .any(|e| e.message == "Function 'add1Spec' has no verify block"),
1539 "spec function should not need its own verify block, got errors={:?}, warnings={:?}",
1540 findings.errors,
1541 findings.warnings
1542 );
1543 }
1544
1545 #[test]
1546 fn coverage_warns_when_result_verify_has_no_err_example() {
1547 let items = parse_items(
1548 r#"
1549fn mayFail(n: Int) -> Result<Int, String>
1550 match n
1551 0 -> Result.Err("zero")
1552 _ -> Result.Ok(n)
1553
1554verify mayFail
1555 mayFail(1) => Result.Ok(1)
1556"#,
1557 );
1558 let warnings = collect_verify_coverage_warnings(&items);
1559 assert!(
1560 warnings.iter().any(|w| {
1561 w.message == "verify examples for mayFail do not include any Result.Err case"
1562 }),
1563 "expected missing-err warning, got {:?}",
1564 warnings
1565 );
1566 }
1567
1568 #[test]
1569 fn coverage_warns_when_option_verify_has_no_none_example() {
1570 let items = parse_items(
1571 r#"
1572fn maybe(n: Int) -> Option<Int>
1573 match n
1574 0 -> Option.None
1575 _ -> Option.Some(n)
1576
1577verify maybe
1578 maybe(1) => Option.Some(1)
1579"#,
1580 );
1581 let warnings = collect_verify_coverage_warnings(&items);
1582 assert!(
1583 warnings.iter().any(|w| {
1584 w.message == "verify examples for maybe do not include any Option.None case"
1585 }),
1586 "expected missing-none warning, got {:?}",
1587 warnings
1588 );
1589 }
1590
1591 #[test]
1592 fn coverage_warns_when_enum_match_examples_cover_subset_of_constructors() {
1593 let items = parse_items(
1594 r#"
1595type Input
1596 Help
1597 Exit
1598 Echo(String)
1599
1600fn dispatch(input: Input) -> String
1601 match input
1602 Input.Help -> "help"
1603 Input.Exit -> "exit"
1604 Input.Echo(value) -> value
1605
1606verify dispatch
1607 dispatch(Input.Help) => "help"
1608"#,
1609 );
1610 let warnings = collect_verify_coverage_warnings(&items);
1611 assert!(
1612 warnings
1613 .iter()
1614 .any(|w| w.message == "verify examples for dispatch cover 1/3 enum constructors"),
1615 "expected enum-coverage warning, got {:?}",
1616 warnings
1617 );
1618 }
1619
1620 #[test]
1621 fn coverage_does_not_warn_when_enum_and_output_examples_are_present() {
1622 let items = parse_items(
1623 r#"
1624type Input
1625 Help
1626 Exit
1627
1628fn run(input: Input) -> Result<String, String>
1629 match input
1630 Input.Help -> Result.Ok("help")
1631 Input.Exit -> Result.Err("exit")
1632
1633verify run
1634 run(Input.Help) => Result.Ok("help")
1635 run(Input.Exit) => Result.Err("exit")
1636"#,
1637 );
1638 let warnings = collect_verify_coverage_warnings(&items);
1639 assert!(warnings.is_empty(), "unexpected warnings: {:?}", warnings);
1640 }
1641
1642 #[test]
1643 fn merge_verify_blocks_coalesces_cases_by_function() {
1644 let items = parse_items(
1645 r#"
1646fn f(x: Int) -> Int
1647 x
1648
1649verify f
1650 f(1) => 1
1651
1652verify f
1653 f(2) => 2
1654"#,
1655 );
1656 let merged = merge_verify_blocks(&items);
1657 assert_eq!(merged.len(), 1);
1658 assert_eq!(merged[0].fn_name, "f");
1659 assert_eq!(merged[0].cases.len(), 2);
1660 }
1661
1662 #[test]
1663 fn merge_verify_blocks_keeps_law_blocks_separate() {
1664 let items = parse_items(
1665 r#"
1666fn f(x: Int) -> Int
1667 x
1668
1669verify f
1670 f(1) => 1
1671
1672verify f law l1
1673 given x: Int = [1]
1674 x => x
1675
1676verify f law l2
1677 given x: Int = [2]
1678 x => x
1679
1680verify f
1681 f(2) => 2
1682"#,
1683 );
1684 let merged = merge_verify_blocks(&items);
1685 assert_eq!(merged.len(), 3);
1686 assert!(matches!(merged[0].kind, VerifyKind::Cases));
1687 assert_eq!(merged[0].cases.len(), 2);
1688 assert!(matches!(merged[1].kind, VerifyKind::Law(_)));
1689 assert!(matches!(merged[2].kind, VerifyKind::Law(_)));
1690 }
1691
1692 #[test]
1693 fn decision_unknown_symbol_impact_is_error() {
1694 let items = parse_items(
1695 r#"
1696module M
1697 intent =
1698 "x"
1699
1700fn existing() -> Int
1701 1
1702
1703verify existing
1704 existing() => 1
1705
1706decision D
1707 date = "2026-03-05"
1708 reason =
1709 "x"
1710 chosen = "ExistingChoice"
1711 rejected = []
1712 impacts = [existing, missingThing]
1713"#,
1714 );
1715 let findings = check_module_intent(&items);
1716 assert!(
1717 findings.errors.iter().any(|e| e
1718 .message
1719 .contains("Decision 'D' references unknown impact symbol 'missingThing'")),
1720 "expected unknown-impact error, got errors={:?}, warnings={:?}",
1721 findings.errors,
1722 findings.warnings
1723 );
1724 }
1725
1726 #[test]
1727 fn decision_semantic_string_impact_is_allowed() {
1728 let items = parse_items(
1729 r#"
1730module M
1731 intent =
1732 "x"
1733
1734fn existing() -> Int
1735 1
1736
1737verify existing
1738 existing() => 1
1739
1740decision D
1741 date = "2026-03-05"
1742 reason =
1743 "x"
1744 chosen = "ExistingChoice"
1745 rejected = []
1746 impacts = [existing, "error handling strategy"]
1747"#,
1748 );
1749 let findings = check_module_intent(&items);
1750 assert!(
1751 !findings
1752 .errors
1753 .iter()
1754 .any(|e| e.message.contains("references unknown impact symbol")),
1755 "did not expect unknown-impact error, got errors={:?}, warnings={:?}",
1756 findings.errors,
1757 findings.warnings
1758 );
1759 }
1760
1761 #[test]
1762 fn decision_unknown_chosen_symbol_is_error() {
1763 let items = parse_items(
1764 r#"
1765module M
1766 intent =
1767 "x"
1768
1769fn existing() -> Int
1770 1
1771
1772verify existing
1773 existing() => 1
1774
1775decision D
1776 date = "2026-03-05"
1777 reason =
1778 "x"
1779 chosen = MissingChoice
1780 rejected = []
1781 impacts = [existing]
1782"#,
1783 );
1784 let findings = check_module_intent(&items);
1785 assert!(
1786 findings
1787 .errors
1788 .iter()
1789 .any(|e| e.message.contains("unknown chosen symbol 'MissingChoice'")),
1790 "expected unknown-chosen error, got errors={:?}, warnings={:?}",
1791 findings.errors,
1792 findings.warnings
1793 );
1794 }
1795
1796 #[test]
1797 fn decision_unknown_rejected_symbol_is_error() {
1798 let items = parse_items(
1799 r#"
1800module M
1801 intent =
1802 "x"
1803
1804fn existing() -> Int
1805 1
1806
1807verify existing
1808 existing() => 1
1809
1810decision D
1811 date = "2026-03-05"
1812 reason =
1813 "x"
1814 chosen = "Keep"
1815 rejected = [MissingAlternative]
1816 impacts = [existing]
1817"#,
1818 );
1819 let findings = check_module_intent(&items);
1820 assert!(
1821 findings.errors.iter().any(|e| e
1822 .message
1823 .contains("unknown rejected symbol 'MissingAlternative'")),
1824 "expected unknown-rejected error, got errors={:?}, warnings={:?}",
1825 findings.errors,
1826 findings.warnings
1827 );
1828 }
1829
1830 #[test]
1831 fn decision_semantic_string_chosen_and_rejected_are_allowed() {
1832 let items = parse_items(
1833 r#"
1834module M
1835 intent =
1836 "x"
1837
1838fn existing() -> Int
1839 1
1840
1841verify existing
1842 existing() => 1
1843
1844decision D
1845 date = "2026-03-05"
1846 reason =
1847 "x"
1848 chosen = "Keep explicit context"
1849 rejected = ["Closure capture", "Global mutable state"]
1850 impacts = [existing]
1851"#,
1852 );
1853 let findings = check_module_intent(&items);
1854 assert!(
1855 !findings
1856 .errors
1857 .iter()
1858 .any(|e| e.message.contains("unknown chosen symbol")
1859 || e.message.contains("unknown rejected symbol")),
1860 "did not expect chosen/rejected symbol errors, got errors={:?}, warnings={:?}",
1861 findings.errors,
1862 findings.warnings
1863 );
1864 }
1865
1866 #[test]
1867 fn decision_builtin_effect_impact_is_allowed() {
1868 let items = parse_items(
1869 r#"
1870module M
1871 intent =
1872 "x"
1873
1874fn existing() -> Int
1875 1
1876
1877verify existing
1878 existing() => 1
1879
1880decision D
1881 date = "2026-03-05"
1882 reason =
1883 "x"
1884 chosen = "ExistingChoice"
1885 rejected = []
1886 impacts = [existing, Tcp]
1887"#,
1888 );
1889 let tc = crate::types::checker::run_type_check_full(&items, None);
1890 let findings = check_module_intent_with_sigs(&items, Some(&tc.fn_sigs));
1891 assert!(
1892 !findings
1893 .errors
1894 .iter()
1895 .any(|e| e.message.contains("references unknown impact symbol 'Tcp'")),
1896 "did not expect Tcp impact error, got errors={:?}, warnings={:?}",
1897 findings.errors,
1898 findings.warnings
1899 );
1900 }
1901
1902 #[test]
1903 fn decision_removed_effect_alias_impact_is_error() {
1904 let items = parse_items(
1905 r#"
1906module M
1907 intent =
1908 "x"
1909
1910fn existing() -> Int
1911 1
1912
1913verify existing
1914 existing() => 1
1915
1916decision D
1917 date = "2026-03-05"
1918 reason =
1919 "x"
1920 chosen = "ExistingChoice"
1921 rejected = []
1922 impacts = [existing, AppIO]
1923"#,
1924 );
1925 let findings = check_module_intent(&items);
1926 assert!(
1927 findings.errors.iter().any(|e| e
1928 .message
1929 .contains("references unknown impact symbol 'AppIO'")),
1930 "expected AppIO impact error, got errors={:?}, warnings={:?}",
1931 findings.errors,
1932 findings.warnings
1933 );
1934 }
1935}