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