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