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