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