Skip to main content

bock_types/
effects.rs

1//! Effect tracking — E-AIR pass.
2//!
3//! Tracks algebraic-effect usage through the call graph. Verifies that
4//! declared effect clauses (`with Log, Clock`) match the effects actually
5//! used or propagated from called functions.
6//!
7//! # Algorithm
8//!
9//! 1. **Collect** — all top-level function declarations are entered into a map
10//!    together with their declared effect clause.
11//! 2. **Infer** — for each function, actual effects are collected by walking
12//!    the body for `EffectOp` invocations and calls to other known functions.
13//! 3. **Propagate** — effects from called functions are added to the caller's
14//!    inferred set (one-level; declaration map is built in phase 1).
15//! 4. **Check** — declared vs inferred effects are compared per strictness:
16//!    - `Sketch` mode: auto-infer (no diagnostics emitted).
17//!    - `Development` mode: warn for undeclared effects on *public* functions.
18//!    - `Production` mode: error for undeclared effects on *all* functions.
19//!
20//! # Effect-Capability Correlation
21//!
22//! IO effects correlate with capabilities (e.g. `Log` → `Io.Stdout`,
23//! `Http` → `Io.Network`). The correlation is recorded as a note on
24//! the diagnostic for consumption by downstream passes.
25
26use std::collections::{HashMap, HashSet};
27
28use bock_air::{AIRNode, AirInterpolationPart, NodeKind};
29use bock_ast::{TypePath, Visibility};
30use bock_errors::{DiagnosticBag, DiagnosticCode};
31
32use crate::AIRModule;
33pub use bock_air::stubs::EffectRef;
34
35// ─── Diagnostic codes ─────────────────────────────────────────────────────────
36
37const E_UNDECLARED_EFFECT: DiagnosticCode = DiagnosticCode {
38    prefix: 'E',
39    number: 6001,
40};
41const W_UNDECLARED_EFFECT: DiagnosticCode = DiagnosticCode {
42    prefix: 'W',
43    number: 6002,
44};
45const E_PROPAGATED_EFFECT: DiagnosticCode = DiagnosticCode {
46    prefix: 'E',
47    number: 6003,
48};
49const W_PROPAGATED_EFFECT: DiagnosticCode = DiagnosticCode {
50    prefix: 'W',
51    number: 6004,
52};
53
54// ─── Public types ─────────────────────────────────────────────────────────────
55
56/// Graduated strictness level for effect checking.
57///
58/// Controls how strictly undeclared or propagated effects are reported.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Strictness {
61    /// Sketch mode: effects are inferred automatically; no diagnostics emitted.
62    ///
63    /// Callers need not declare effects — they are computed and accepted as-is.
64    Sketch,
65
66    /// Development mode: undeclared effects on *public* functions produce
67    /// warnings. Private functions are not checked.
68    Development,
69
70    /// Production mode: undeclared effects on *all* functions (public and
71    /// private) produce errors.
72    Production,
73}
74
75// ─── Helpers ──────────────────────────────────────────────────────────────────
76
77/// Convert a [`TypePath`] to an [`EffectRef`] by joining segments with `.`.
78fn type_path_to_effect_ref(path: &TypePath) -> EffectRef {
79    let name = path
80        .segments
81        .iter()
82        .map(|s| s.name.as_str())
83        .collect::<Vec<_>>()
84        .join(".");
85    EffectRef::new(name)
86}
87
88/// Ambient effects are always available and never need to be declared.
89///
90/// Per spec: `Panic`, `Allocate`, and `Pure` are ambient effects.
91fn is_ambient(effect: &EffectRef) -> bool {
92    matches!(effect.name.as_str(), "Panic" | "Allocate" | "Pure")
93}
94
95/// Returns a best-effort capability name correlated with an IO effect.
96///
97/// Per the spec, IO effects correlate with platform capabilities. This mapping
98/// is used to emit informational notes alongside effect diagnostics.
99fn capability_for_effect(effect: &EffectRef) -> Option<String> {
100    let name = effect.name.to_lowercase();
101    if name.contains("log") || name.contains("print") || name.contains("console") {
102        Some("Io.Stdout".into())
103    } else if name.contains("http") || name.contains("net") || name.contains("socket") {
104        Some("Io.Network".into())
105    } else if name.contains("file") || name.contains("fs") || name.contains("disk") {
106        Some("Io.FileSystem".into())
107    } else if name.contains("clock") || name.contains("time") || name.contains("date") {
108        Some("Io.Clock".into())
109    } else if name.contains("env") || name.contains("os") || name.contains("process") {
110        Some("Io.Process".into())
111    } else {
112        None
113    }
114}
115
116// ─── Recursive effect / call collector ───────────────────────────────────────
117
118/// Walk `node` and collect:
119/// - `used_effects`: every `EffectOp` effect directly invoked.
120/// - `called_fns`: every function name called (for propagation).
121///
122/// `HandlingBlock` scopes suppress handled effects so they don't propagate.
123/// Nested `FnDecl` bodies are treated as opaque.
124fn collect_node_effects(
125    node: &AIRNode,
126    used_effects: &mut HashSet<EffectRef>,
127    called_fns: &mut HashSet<String>,
128) {
129    match &node.kind {
130        // Direct effect operation — always records the effect.
131        NodeKind::EffectOp { effect, args, .. } => {
132            used_effects.insert(type_path_to_effect_ref(effect));
133            for arg in args {
134                collect_node_effects(&arg.value, used_effects, called_fns);
135            }
136        }
137
138        // Function call — record callee name for propagation lookup.
139        NodeKind::Call {
140            callee,
141            args,
142            type_args,
143        } => {
144            if let NodeKind::Identifier { name } = &callee.kind {
145                called_fns.insert(name.name.clone());
146            }
147            collect_node_effects(callee, used_effects, called_fns);
148            for arg in args {
149                collect_node_effects(&arg.value, used_effects, called_fns);
150            }
151            for ta in type_args {
152                collect_node_effects(ta, used_effects, called_fns);
153            }
154        }
155
156        // Method call — receiver and arguments.
157        NodeKind::MethodCall { receiver, args, .. } => {
158            collect_node_effects(receiver, used_effects, called_fns);
159            for arg in args {
160                collect_node_effects(&arg.value, used_effects, called_fns);
161            }
162        }
163
164        // Handling block: effects handled here do NOT propagate to the caller.
165        NodeKind::HandlingBlock { handlers, body } => {
166            let handled: HashSet<EffectRef> = handlers
167                .iter()
168                .map(|h| type_path_to_effect_ref(&h.effect))
169                .collect();
170
171            let mut body_effects = HashSet::new();
172            let mut body_calls = HashSet::new();
173            collect_node_effects(body, &mut body_effects, &mut body_calls);
174
175            // Only propagate effects not suppressed by this handler.
176            for e in body_effects {
177                if !handled.contains(&e) {
178                    used_effects.insert(e);
179                }
180            }
181            called_fns.extend(body_calls);
182        }
183
184        // Nested FnDecl: treat as opaque — its effects don't escape.
185        NodeKind::FnDecl { .. } => {}
186
187        // Lambda body effects DO propagate to the enclosing function.
188        NodeKind::Lambda { body, .. } => {
189            collect_node_effects(body, used_effects, called_fns);
190        }
191
192        NodeKind::Block { stmts, tail } => {
193            for s in stmts {
194                collect_node_effects(s, used_effects, called_fns);
195            }
196            if let Some(t) = tail {
197                collect_node_effects(t, used_effects, called_fns);
198            }
199        }
200
201        NodeKind::LetBinding { value, .. } => {
202            collect_node_effects(value, used_effects, called_fns);
203        }
204
205        NodeKind::Assign { target, value, .. } => {
206            collect_node_effects(target, used_effects, called_fns);
207            collect_node_effects(value, used_effects, called_fns);
208        }
209
210        NodeKind::If {
211            condition,
212            then_block,
213            else_block,
214            ..
215        } => {
216            collect_node_effects(condition, used_effects, called_fns);
217            collect_node_effects(then_block, used_effects, called_fns);
218            if let Some(e) = else_block {
219                collect_node_effects(e, used_effects, called_fns);
220            }
221        }
222
223        NodeKind::Guard {
224            let_pattern,
225            condition,
226            else_block,
227        } => {
228            if let Some(pat) = let_pattern {
229                collect_node_effects(pat, used_effects, called_fns);
230            }
231            collect_node_effects(condition, used_effects, called_fns);
232            collect_node_effects(else_block, used_effects, called_fns);
233        }
234
235        NodeKind::Match { scrutinee, arms } => {
236            collect_node_effects(scrutinee, used_effects, called_fns);
237            for arm in arms {
238                collect_node_effects(arm, used_effects, called_fns);
239            }
240        }
241
242        NodeKind::MatchArm { guard, body, .. } => {
243            if let Some(g) = guard {
244                collect_node_effects(g, used_effects, called_fns);
245            }
246            collect_node_effects(body, used_effects, called_fns);
247        }
248
249        NodeKind::For { iterable, body, .. } => {
250            collect_node_effects(iterable, used_effects, called_fns);
251            collect_node_effects(body, used_effects, called_fns);
252        }
253
254        NodeKind::While { condition, body } => {
255            collect_node_effects(condition, used_effects, called_fns);
256            collect_node_effects(body, used_effects, called_fns);
257        }
258
259        NodeKind::Loop { body } => {
260            collect_node_effects(body, used_effects, called_fns);
261        }
262
263        NodeKind::Return { value: Some(v) } | NodeKind::Break { value: Some(v) } => {
264            collect_node_effects(v, used_effects, called_fns);
265        }
266
267        NodeKind::Return { value: None } | NodeKind::Break { value: None } => {}
268
269        NodeKind::BinaryOp { left, right, .. } => {
270            collect_node_effects(left, used_effects, called_fns);
271            collect_node_effects(right, used_effects, called_fns);
272        }
273
274        NodeKind::UnaryOp { operand, .. } => {
275            collect_node_effects(operand, used_effects, called_fns);
276        }
277
278        NodeKind::FieldAccess { object, .. } => {
279            collect_node_effects(object, used_effects, called_fns);
280        }
281
282        NodeKind::Index { object, index } => {
283            collect_node_effects(object, used_effects, called_fns);
284            collect_node_effects(index, used_effects, called_fns);
285        }
286
287        NodeKind::Propagate { expr } => {
288            collect_node_effects(expr, used_effects, called_fns);
289        }
290
291        NodeKind::Await { expr } => {
292            collect_node_effects(expr, used_effects, called_fns);
293        }
294
295        NodeKind::Borrow { expr } | NodeKind::MutableBorrow { expr } | NodeKind::Move { expr } => {
296            collect_node_effects(expr, used_effects, called_fns);
297        }
298
299        NodeKind::Pipe { left, right } | NodeKind::Compose { left, right } => {
300            collect_node_effects(left, used_effects, called_fns);
301            collect_node_effects(right, used_effects, called_fns);
302        }
303
304        NodeKind::Range { lo, hi, .. } => {
305            collect_node_effects(lo, used_effects, called_fns);
306            collect_node_effects(hi, used_effects, called_fns);
307        }
308
309        NodeKind::RecordConstruct { fields, spread, .. } => {
310            for f in fields {
311                if let Some(v) = &f.value {
312                    collect_node_effects(v, used_effects, called_fns);
313                }
314            }
315            if let Some(s) = spread {
316                collect_node_effects(s, used_effects, called_fns);
317            }
318        }
319
320        NodeKind::ListLiteral { elems }
321        | NodeKind::SetLiteral { elems }
322        | NodeKind::TupleLiteral { elems } => {
323            for e in elems {
324                collect_node_effects(e, used_effects, called_fns);
325            }
326        }
327
328        NodeKind::MapLiteral { entries } => {
329            for e in entries {
330                collect_node_effects(&e.key, used_effects, called_fns);
331                collect_node_effects(&e.value, used_effects, called_fns);
332            }
333        }
334
335        NodeKind::Interpolation { parts } => {
336            for p in parts {
337                if let AirInterpolationPart::Expr(e) = p {
338                    collect_node_effects(e, used_effects, called_fns);
339                }
340            }
341        }
342
343        NodeKind::ResultConstruct { value: Some(v), .. } => {
344            collect_node_effects(v, used_effects, called_fns);
345        }
346
347        NodeKind::ResultConstruct { value: None, .. } => {}
348
349        // Leaf nodes: literals, identifiers, patterns, type exprs, etc.
350        _ => {}
351    }
352}
353
354// ─── Public API ───────────────────────────────────────────────────────────────
355
356/// Infer the effects used in a function node without checking declarations.
357///
358/// Walks the function body and collects all directly-invoked effects via
359/// `EffectOp` nodes. This is the inference used in sketch mode and by
360/// downstream passes that want to auto-populate effect annotations.
361///
362/// Returns an empty set if `fn_node` is not a [`NodeKind::FnDecl`].
363#[must_use]
364pub fn infer_effects(fn_node: &AIRNode) -> HashSet<EffectRef> {
365    let mut effects = HashSet::new();
366    if let NodeKind::FnDecl { body, .. } = &fn_node.kind {
367        let mut called_fns = HashSet::new();
368        collect_node_effects(body, &mut effects, &mut called_fns);
369    }
370    effects
371}
372
373/// Track effect usage through the call graph of `module` and emit diagnostics
374/// according to `strictness`.
375///
376/// See the [module-level docs](self) for the algorithm.
377///
378/// Returns a [`DiagnosticBag`] with all emitted diagnostics.
379#[must_use]
380pub fn track_effects(module: &AIRModule, strictness: Strictness) -> DiagnosticBag {
381    let mut tracker = EffectTracker::new(strictness);
382    tracker.collect_declarations(module);
383    tracker.check_module(module);
384    tracker.diags
385}
386
387// ─── Internal tracker ─────────────────────────────────────────────────────────
388
389struct EffectTracker {
390    diags: DiagnosticBag,
391    strictness: Strictness,
392    /// Declared effects keyed by simple function name.
393    fn_declared: HashMap<String, HashSet<EffectRef>>,
394    /// Composite effect expansions: `IO` → `{Log, Clock}`.
395    composite_effects: HashMap<String, HashSet<EffectRef>>,
396}
397
398impl EffectTracker {
399    fn new(strictness: Strictness) -> Self {
400        Self {
401            diags: DiagnosticBag::new(),
402            strictness,
403            fn_declared: HashMap::new(),
404            composite_effects: HashMap::new(),
405        }
406    }
407
408    /// Expand a set of effects by replacing composite effects with their
409    /// components. For example, if `IO = Log + Clock`, then `{IO}` becomes
410    /// `{Log, Clock}`.
411    fn expand_effects(&self, effects: &HashSet<EffectRef>) -> HashSet<EffectRef> {
412        let mut expanded = HashSet::new();
413        for eff in effects {
414            if let Some(components) = self.composite_effects.get(&eff.name) {
415                expanded.extend(components.iter().cloned());
416            } else {
417                expanded.insert(eff.clone());
418            }
419        }
420        expanded
421    }
422
423    // ── Phase 1: collect declarations ─────────────────────────────────────
424
425    fn collect_declarations(&mut self, module: &AIRModule) {
426        match &module.kind {
427            NodeKind::Module { items, .. } => {
428                for item in items {
429                    self.collect_item_declaration(item);
430                }
431            }
432            _ => self.collect_item_declaration(module),
433        }
434    }
435
436    fn collect_item_declaration(&mut self, node: &AIRNode) {
437        match &node.kind {
438            NodeKind::FnDecl {
439                name,
440                effect_clause,
441                ..
442            } => {
443                let declared: HashSet<EffectRef> =
444                    effect_clause.iter().map(type_path_to_effect_ref).collect();
445                self.fn_declared.insert(name.name.clone(), declared);
446            }
447            NodeKind::EffectDecl {
448                name, components, ..
449            } if !components.is_empty() => {
450                let component_refs: HashSet<EffectRef> =
451                    components.iter().map(type_path_to_effect_ref).collect();
452                self.composite_effects
453                    .insert(name.name.clone(), component_refs);
454            }
455            NodeKind::ImplBlock { methods, .. } | NodeKind::TraitDecl { methods, .. } => {
456                for m in methods {
457                    self.collect_item_declaration(m);
458                }
459            }
460            NodeKind::ClassDecl { methods, .. } => {
461                for m in methods {
462                    self.collect_item_declaration(m);
463                }
464            }
465            _ => {}
466        }
467    }
468
469    // ── Phase 2: check functions ───────────────────────────────────────────
470
471    fn check_module(&mut self, module: &AIRModule) {
472        match &module.kind {
473            NodeKind::Module { items, .. } => {
474                for item in items {
475                    self.check_item(item);
476                }
477            }
478            _ => self.check_item(module),
479        }
480    }
481
482    fn check_item(&mut self, node: &AIRNode) {
483        match &node.kind {
484            NodeKind::FnDecl { .. } => self.check_fn(node),
485            NodeKind::ImplBlock { methods, .. } | NodeKind::TraitDecl { methods, .. } => {
486                for m in methods {
487                    self.check_item(m);
488                }
489            }
490            NodeKind::ClassDecl { methods, .. } => {
491                for m in methods {
492                    self.check_item(m);
493                }
494            }
495            _ => {}
496        }
497    }
498
499    fn check_fn(&mut self, fn_node: &AIRNode) {
500        let NodeKind::FnDecl {
501            name,
502            effect_clause,
503            body,
504            visibility,
505            ..
506        } = &fn_node.kind
507        else {
508            return;
509        };
510
511        let fn_span = fn_node.span;
512        let fn_name = &name.name;
513        let is_public = matches!(visibility, Visibility::Public);
514
515        let raw_declared: HashSet<EffectRef> =
516            effect_clause.iter().map(type_path_to_effect_ref).collect();
517
518        // Expand composite effects: `with IO` where `IO = Log + Clock` → `{Log, Clock}`.
519        let declared = self.expand_effects(&raw_declared);
520
521        // Collect effects directly used in the body.
522        let mut used_effects = HashSet::new();
523        let mut called_fns = HashSet::new();
524        collect_node_effects(body, &mut used_effects, &mut called_fns);
525
526        // Propagate: effects declared by called functions must also be declared
527        // by this function (effect propagation through call graph).
528        let mut propagated: HashSet<EffectRef> = HashSet::new();
529        for callee in &called_fns {
530            if let Some(callee_effects) = self.fn_declared.get(callee) {
531                // Expand callee effects in case they also use composites.
532                let expanded_callee = self.expand_effects(callee_effects);
533                for eff in expanded_callee {
534                    if !is_ambient(&eff) && !declared.contains(&eff) {
535                        propagated.insert(eff);
536                    }
537                }
538            }
539        }
540
541        if self.strictness == Strictness::Sketch {
542            // Sketch mode: auto-infer. No diagnostics.
543            return;
544        }
545
546        let should_check = match self.strictness {
547            Strictness::Development => is_public,
548            Strictness::Production => true,
549            Strictness::Sketch => false,
550        };
551
552        if !should_check {
553            return;
554        }
555
556        let use_errors = self.strictness == Strictness::Production;
557
558        // Check: direct undeclared effects.
559        for eff in used_effects
560            .iter()
561            .filter(|e| !is_ambient(e) && !declared.contains(*e))
562        {
563            let msg = format!(
564                "function `{fn_name}` uses effect `{}` but does not declare it in its `with` clause",
565                eff.name
566            );
567            let code = if use_errors {
568                E_UNDECLARED_EFFECT
569            } else {
570                W_UNDECLARED_EFFECT
571            };
572            let diag = if use_errors {
573                self.diags.error(code, msg, fn_span)
574            } else {
575                self.diags.warning(code, msg, fn_span)
576            };
577            if let Some(cap) = capability_for_effect(eff) {
578                diag.note(format!(
579                    "effect `{}` correlates with capability `{cap}`",
580                    eff.name
581                ));
582            }
583        }
584
585        // Check: propagated undeclared effects (from called functions).
586        for eff in &propagated {
587            let callee_name = called_fns
588                .iter()
589                .find(|c| self.fn_declared.get(*c).is_some_and(|e| e.contains(eff)))
590                .cloned()
591                .unwrap_or_default();
592
593            let msg = format!(
594                "function `{fn_name}` calls `{callee_name}` which requires effect `{}`, \
595                 but `{fn_name}` does not declare it",
596                eff.name
597            );
598            let code = if use_errors {
599                E_PROPAGATED_EFFECT
600            } else {
601                W_PROPAGATED_EFFECT
602            };
603            let diag = if use_errors {
604                self.diags.error(code, msg, fn_span)
605            } else {
606                self.diags.warning(code, msg, fn_span)
607            };
608            if let Some(cap) = capability_for_effect(eff) {
609                diag.note(format!(
610                    "effect `{}` correlates with capability `{cap}`",
611                    eff.name
612                ));
613            }
614        }
615    }
616}
617
618// ─── Tests ────────────────────────────────────────────────────────────────────
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use bock_air::{AIRNode, AirHandlerPair, NodeIdGen, NodeKind};
624    use bock_ast::{Ident, TypePath, Visibility};
625    use bock_errors::{FileId, Severity, Span};
626
627    fn dummy_span() -> Span {
628        Span {
629            file: FileId(0),
630            start: 0,
631            end: 0,
632        }
633    }
634
635    fn dummy_ident(name: &str) -> Ident {
636        Ident {
637            name: name.to_string(),
638            span: dummy_span(),
639        }
640    }
641
642    fn dummy_type_path(name: &str) -> TypePath {
643        TypePath {
644            segments: vec![dummy_ident(name)],
645            span: dummy_span(),
646        }
647    }
648
649    fn make_node(gen: &NodeIdGen, kind: NodeKind) -> AIRNode {
650        AIRNode::new(gen.next(), dummy_span(), kind)
651    }
652
653    fn make_fn(
654        gen: &NodeIdGen,
655        name: &str,
656        effects: Vec<&str>,
657        body: AIRNode,
658        vis: Visibility,
659    ) -> AIRNode {
660        make_node(
661            gen,
662            NodeKind::FnDecl {
663                annotations: vec![],
664                visibility: vis,
665                is_async: false,
666                name: dummy_ident(name),
667                generic_params: vec![],
668                params: vec![],
669                return_type: None,
670                effect_clause: effects.into_iter().map(dummy_type_path).collect(),
671                where_clause: vec![],
672                body: Box::new(body),
673            },
674        )
675    }
676
677    fn make_effect_op(gen: &NodeIdGen, effect: &str) -> AIRNode {
678        make_node(
679            gen,
680            NodeKind::EffectOp {
681                effect: dummy_type_path(effect),
682                operation: dummy_ident("op"),
683                args: vec![],
684            },
685        )
686    }
687
688    fn make_module(gen: &NodeIdGen, items: Vec<AIRNode>) -> AIRNode {
689        make_node(
690            gen,
691            NodeKind::Module {
692                path: None,
693                annotations: vec![],
694                imports: vec![],
695                items,
696            },
697        )
698    }
699
700    fn empty_block(gen: &NodeIdGen) -> AIRNode {
701        make_node(
702            gen,
703            NodeKind::Block {
704                stmts: vec![],
705                tail: None,
706            },
707        )
708    }
709
710    fn warning_count(bag: &DiagnosticBag) -> usize {
711        bag.iter()
712            .filter(|d| d.severity == Severity::Warning)
713            .count()
714    }
715
716    // ── infer_effects ─────────────────────────────────────────────────────────
717
718    #[test]
719    fn infer_effects_empty_body() {
720        let gen = NodeIdGen::new();
721        let body = empty_block(&gen);
722        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Private);
723        let effects = infer_effects(&fn_node);
724        assert!(effects.is_empty());
725    }
726
727    #[test]
728    fn infer_effects_direct_effect_op() {
729        let gen = NodeIdGen::new();
730        let op = make_effect_op(&gen, "Log");
731        let body = make_node(
732            &gen,
733            NodeKind::Block {
734                stmts: vec![op],
735                tail: None,
736            },
737        );
738        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Private);
739        let effects = infer_effects(&fn_node);
740        assert!(effects.contains(&EffectRef::new("Log")));
741    }
742
743    #[test]
744    fn infer_effects_multiple_effects() {
745        let gen = NodeIdGen::new();
746        let log_op = make_effect_op(&gen, "Log");
747        let clock_op = make_effect_op(&gen, "Clock");
748        let body = make_node(
749            &gen,
750            NodeKind::Block {
751                stmts: vec![log_op, clock_op],
752                tail: None,
753            },
754        );
755        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Private);
756        let effects = infer_effects(&fn_node);
757        assert_eq!(effects.len(), 2);
758        assert!(effects.contains(&EffectRef::new("Log")));
759        assert!(effects.contains(&EffectRef::new("Clock")));
760    }
761
762    #[test]
763    fn infer_effects_handling_block_suppresses_handled() {
764        let gen = NodeIdGen::new();
765        let op = make_effect_op(&gen, "Log");
766        let inner_body = make_node(
767            &gen,
768            NodeKind::Block {
769                stmts: vec![op],
770                tail: None,
771            },
772        );
773        let handling = make_node(
774            &gen,
775            NodeKind::HandlingBlock {
776                handlers: vec![AirHandlerPair {
777                    effect: dummy_type_path("Log"),
778                    handler: Box::new(empty_block(&gen)),
779                }],
780                body: Box::new(inner_body),
781            },
782        );
783        let body = make_node(
784            &gen,
785            NodeKind::Block {
786                stmts: vec![handling],
787                tail: None,
788            },
789        );
790        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Private);
791        // Log is handled inside the block — must NOT appear in inferred set.
792        assert!(!infer_effects(&fn_node).contains(&EffectRef::new("Log")));
793    }
794
795    #[test]
796    fn infer_effects_returns_empty_for_non_fn() {
797        let gen = NodeIdGen::new();
798        let node = empty_block(&gen);
799        assert!(infer_effects(&node).is_empty());
800    }
801
802    // ── track_effects ─────────────────────────────────────────────────────────
803
804    #[test]
805    fn sketch_mode_no_diagnostics_for_undeclared() {
806        let gen = NodeIdGen::new();
807        let op = make_effect_op(&gen, "Log");
808        let body = make_node(
809            &gen,
810            NodeKind::Block {
811                stmts: vec![op],
812                tail: None,
813            },
814        );
815        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Public);
816        let module = make_module(&gen, vec![fn_node]);
817
818        let bag = track_effects(&module, Strictness::Sketch);
819        assert_eq!(bag.error_count(), 0);
820        assert_eq!(warning_count(&bag), 0);
821    }
822
823    #[test]
824    fn dev_mode_warns_public_undeclared() {
825        let gen = NodeIdGen::new();
826        let op = make_effect_op(&gen, "Log");
827        let body = make_node(
828            &gen,
829            NodeKind::Block {
830                stmts: vec![op],
831                tail: None,
832            },
833        );
834        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Public);
835        let module = make_module(&gen, vec![fn_node]);
836
837        let bag = track_effects(&module, Strictness::Development);
838        assert_eq!(bag.error_count(), 0);
839        assert!(warning_count(&bag) > 0);
840    }
841
842    #[test]
843    fn dev_mode_no_warning_for_private() {
844        let gen = NodeIdGen::new();
845        let op = make_effect_op(&gen, "Log");
846        let body = make_node(
847            &gen,
848            NodeKind::Block {
849                stmts: vec![op],
850                tail: None,
851            },
852        );
853        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Private);
854        let module = make_module(&gen, vec![fn_node]);
855
856        let bag = track_effects(&module, Strictness::Development);
857        assert_eq!(bag.error_count(), 0);
858        assert_eq!(warning_count(&bag), 0);
859    }
860
861    #[test]
862    fn prod_mode_errors_all_undeclared() {
863        let gen = NodeIdGen::new();
864        let op = make_effect_op(&gen, "Log");
865        let body = make_node(
866            &gen,
867            NodeKind::Block {
868                stmts: vec![op],
869                tail: None,
870            },
871        );
872        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Private);
873        let module = make_module(&gen, vec![fn_node]);
874
875        let bag = track_effects(&module, Strictness::Production);
876        assert!(bag.error_count() > 0);
877    }
878
879    #[test]
880    fn declared_effect_produces_no_diagnostic() {
881        let gen = NodeIdGen::new();
882        let op = make_effect_op(&gen, "Log");
883        let body = make_node(
884            &gen,
885            NodeKind::Block {
886                stmts: vec![op],
887                tail: None,
888            },
889        );
890        let fn_node = make_fn(&gen, "f", vec!["Log"], body, Visibility::Public);
891        let module = make_module(&gen, vec![fn_node]);
892
893        let bag = track_effects(&module, Strictness::Production);
894        assert_eq!(bag.error_count(), 0);
895    }
896
897    #[test]
898    fn ambient_effect_never_flagged() {
899        let gen = NodeIdGen::new();
900        let op = make_effect_op(&gen, "Panic");
901        let body = make_node(
902            &gen,
903            NodeKind::Block {
904                stmts: vec![op],
905                tail: None,
906            },
907        );
908        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Public);
909        let module = make_module(&gen, vec![fn_node]);
910
911        let bag = track_effects(&module, Strictness::Production);
912        assert_eq!(bag.error_count(), 0);
913    }
914
915    #[test]
916    fn propagation_caller_must_declare_callee_effects() {
917        let gen = NodeIdGen::new();
918
919        // callee declares Log
920        let callee_body = empty_block(&gen);
921        let callee = make_fn(&gen, "callee", vec!["Log"], callee_body, Visibility::Public);
922
923        // caller calls callee but doesn't declare Log
924        let call_node = make_node(
925            &gen,
926            NodeKind::Call {
927                callee: Box::new(make_node(
928                    &gen,
929                    NodeKind::Identifier {
930                        name: dummy_ident("callee"),
931                    },
932                )),
933                args: vec![],
934                type_args: vec![],
935            },
936        );
937        let caller_body = make_node(
938            &gen,
939            NodeKind::Block {
940                stmts: vec![call_node],
941                tail: None,
942            },
943        );
944        let caller = make_fn(&gen, "caller", vec![], caller_body, Visibility::Public);
945
946        let module = make_module(&gen, vec![callee, caller]);
947        let bag = track_effects(&module, Strictness::Production);
948        assert!(bag.error_count() > 0);
949    }
950
951    #[test]
952    fn propagation_caller_declares_callee_effects_ok() {
953        let gen = NodeIdGen::new();
954
955        let callee_body = empty_block(&gen);
956        let callee = make_fn(&gen, "callee", vec!["Log"], callee_body, Visibility::Public);
957
958        let call_node = make_node(
959            &gen,
960            NodeKind::Call {
961                callee: Box::new(make_node(
962                    &gen,
963                    NodeKind::Identifier {
964                        name: dummy_ident("callee"),
965                    },
966                )),
967                args: vec![],
968                type_args: vec![],
969            },
970        );
971        let caller_body = make_node(
972            &gen,
973            NodeKind::Block {
974                stmts: vec![call_node],
975                tail: None,
976            },
977        );
978        // caller also declares Log
979        let caller = make_fn(&gen, "caller", vec!["Log"], caller_body, Visibility::Public);
980
981        let module = make_module(&gen, vec![callee, caller]);
982        let bag = track_effects(&module, Strictness::Production);
983        assert_eq!(bag.error_count(), 0);
984    }
985
986    // ── Composite effect expansion ───────────────────────────────────────────
987
988    fn make_effect_decl(gen: &NodeIdGen, name: &str, components: Vec<&str>) -> AIRNode {
989        make_node(
990            gen,
991            NodeKind::EffectDecl {
992                annotations: vec![],
993                visibility: Visibility::Public,
994                name: dummy_ident(name),
995                generic_params: vec![],
996                components: components.into_iter().map(dummy_type_path).collect(),
997                operations: vec![],
998            },
999        )
1000    }
1001
1002    #[test]
1003    fn composite_effect_expands_to_components() {
1004        let gen = NodeIdGen::new();
1005
1006        // effect IO = Log + Clock
1007        let io_decl = make_effect_decl(&gen, "IO", vec!["Log", "Clock"]);
1008
1009        // fn f() with IO { perform Log.op }
1010        let op = make_effect_op(&gen, "Log");
1011        let body = make_node(
1012            &gen,
1013            NodeKind::Block {
1014                stmts: vec![op],
1015                tail: None,
1016            },
1017        );
1018        let fn_node = make_fn(&gen, "f", vec!["IO"], body, Visibility::Public);
1019
1020        let module = make_module(&gen, vec![io_decl, fn_node]);
1021        let bag = track_effects(&module, Strictness::Production);
1022        // Log is covered by IO = Log + Clock, so no error.
1023        assert_eq!(bag.error_count(), 0);
1024    }
1025
1026    #[test]
1027    fn composite_effect_covers_all_components() {
1028        let gen = NodeIdGen::new();
1029
1030        // effect IO = Log + Clock
1031        let io_decl = make_effect_decl(&gen, "IO", vec!["Log", "Clock"]);
1032
1033        // fn f() with IO { perform Log.op; perform Clock.op }
1034        let log_op = make_effect_op(&gen, "Log");
1035        let clock_op = make_effect_op(&gen, "Clock");
1036        let body = make_node(
1037            &gen,
1038            NodeKind::Block {
1039                stmts: vec![log_op, clock_op],
1040                tail: None,
1041            },
1042        );
1043        let fn_node = make_fn(&gen, "f", vec!["IO"], body, Visibility::Public);
1044
1045        let module = make_module(&gen, vec![io_decl, fn_node]);
1046        let bag = track_effects(&module, Strictness::Production);
1047        assert_eq!(bag.error_count(), 0);
1048    }
1049
1050    #[test]
1051    fn composite_effect_does_not_cover_unrelated() {
1052        let gen = NodeIdGen::new();
1053
1054        // effect IO = Log + Clock
1055        let io_decl = make_effect_decl(&gen, "IO", vec!["Log", "Clock"]);
1056
1057        // fn f() with IO { perform Http.op } — Http not in IO
1058        let op = make_effect_op(&gen, "Http");
1059        let body = make_node(
1060            &gen,
1061            NodeKind::Block {
1062                stmts: vec![op],
1063                tail: None,
1064            },
1065        );
1066        let fn_node = make_fn(&gen, "f", vec!["IO"], body, Visibility::Public);
1067
1068        let module = make_module(&gen, vec![io_decl, fn_node]);
1069        let bag = track_effects(&module, Strictness::Production);
1070        assert!(bag.error_count() > 0);
1071    }
1072
1073    #[test]
1074    fn composite_effect_propagation_through_call_graph() {
1075        let gen = NodeIdGen::new();
1076
1077        // effect IO = Log + Clock
1078        let io_decl = make_effect_decl(&gen, "IO", vec!["Log", "Clock"]);
1079
1080        // fn callee() with Log { ... }
1081        let callee_body = empty_block(&gen);
1082        let callee = make_fn(&gen, "callee", vec!["Log"], callee_body, Visibility::Public);
1083
1084        // fn caller() with IO { callee() }
1085        // IO expands to Log + Clock, which covers callee's Log.
1086        let call_node = make_node(
1087            &gen,
1088            NodeKind::Call {
1089                callee: Box::new(make_node(
1090                    &gen,
1091                    NodeKind::Identifier {
1092                        name: dummy_ident("callee"),
1093                    },
1094                )),
1095                args: vec![],
1096                type_args: vec![],
1097            },
1098        );
1099        let caller_body = make_node(
1100            &gen,
1101            NodeKind::Block {
1102                stmts: vec![call_node],
1103                tail: None,
1104            },
1105        );
1106        let caller = make_fn(&gen, "caller", vec!["IO"], caller_body, Visibility::Public);
1107
1108        let module = make_module(&gen, vec![io_decl, callee, caller]);
1109        let bag = track_effects(&module, Strictness::Production);
1110        assert_eq!(bag.error_count(), 0);
1111    }
1112}