Skip to main content

bock_types/
capabilities.rs

1//! Capability computation — CAP-AIR pass.
2//!
3//! Computes capability requirements for each function in a module by:
4//!
5//! 1. **Annotation extraction** — `@requires(Capability.Network, ...)` annotations
6//!    are parsed from each function's annotation list.
7//! 2. **Effect correlation** — IO effects imply platform capabilities
8//!    (e.g. `Log` → `Io.Stdout`, `Http` → `Io.Network`).
9//! 3. **Call-graph propagation** — required capabilities from callees are
10//!    unioned into the caller's capability set.
11//! 4. **Verification** — declared (`@requires`) capabilities are checked
12//!    against inferred requirements per strictness level.
13//!
14//! # Strictness
15//!
16//! - `Sketch`: no diagnostics; capabilities are inferred silently.
17//! - `Development`: missing `@requires` on *public* functions produces warnings.
18//! - `Production`: missing `@requires` on *all* functions produces errors.
19//!
20//! # @requires additivity
21//!
22//! Per spec, `@requires` is **additive**: a declaration's capability set is
23//! the union of the module-level `@requires` and its own `@requires`.
24//! This pass operates purely at the function level; module-level annotations
25//! are collected first and unioned into every declaration's declared set.
26
27use std::collections::{HashMap, HashSet};
28
29use bock_air::{AIRNode, AirInterpolationPart, NodeKind};
30use bock_ast::{Expr, Visibility};
31use bock_errors::{DiagnosticBag, DiagnosticCode, Span};
32
33pub use bock_air::stubs::{Capability, EffectRef};
34use bock_air::NodeId;
35
36use crate::AIRModule;
37pub use crate::Strictness;
38
39// ─── Diagnostic codes ─────────────────────────────────────────────────────────
40
41const E_MISSING_CAPABILITY: DiagnosticCode = DiagnosticCode {
42    prefix: 'E',
43    number: 7001,
44};
45const W_MISSING_CAPABILITY: DiagnosticCode = DiagnosticCode {
46    prefix: 'W',
47    number: 7002,
48};
49const E_PROPAGATED_CAPABILITY: DiagnosticCode = DiagnosticCode {
50    prefix: 'E',
51    number: 7003,
52};
53const W_PROPAGATED_CAPABILITY: DiagnosticCode = DiagnosticCode {
54    prefix: 'W',
55    number: 7004,
56};
57
58// ─── Public types ─────────────────────────────────────────────────────────────
59
60/// The set of capabilities required by a node.
61pub type CapabilitySet = HashSet<Capability>;
62
63// ─── Annotation / expression helpers ─────────────────────────────────────────
64
65/// Attempt to extract a dotted capability name from an annotation argument
66/// expression.
67///
68/// Handles:
69/// - `Expr::Identifier { name }` → `"name"` (e.g. bare `Network`)
70/// - `Expr::FieldAccess { object, field }` → recursive join with `.`
71///   (e.g. `Capability.Network` → `"Capability.Network"`)
72fn expr_to_capability_name(expr: &Expr) -> Option<String> {
73    match expr {
74        Expr::FieldAccess { object, field, .. } => {
75            let prefix = expr_to_capability_name(object)?;
76            Some(format!("{}.{}", prefix, field.name))
77        }
78        Expr::Identifier { name, .. } => Some(name.name.clone()),
79        _ => None,
80    }
81}
82
83/// Extract capability names declared in a `@requires(...)` annotation.
84///
85/// Each argument is walked with [`expr_to_capability_name`]; arguments that
86/// cannot be parsed are silently skipped (resilient to syntax variations).
87fn extract_requires_annotation(annotations: &[bock_ast::Annotation]) -> CapabilitySet {
88    let mut caps = CapabilitySet::new();
89    for ann in annotations {
90        if ann.name.name == "requires" {
91            for arg in &ann.args {
92                if let Some(name) = expr_to_capability_name(&arg.value) {
93                    caps.insert(Capability::new(name));
94                }
95            }
96        }
97    }
98    caps
99}
100
101// ─── Effect → capability correlation ─────────────────────────────────────────
102
103/// Map an effect name to the platform capability it implies, if any.
104///
105/// IO effects imply a correlated capability per the spec.
106fn capability_for_effect(effect: &EffectRef) -> Option<Capability> {
107    let name = effect.name.to_lowercase();
108    if name.contains("log") || name.contains("print") || name.contains("console") {
109        Some(Capability::new("Io.Stdout"))
110    } else if name.contains("http") || name.contains("net") || name.contains("socket") {
111        Some(Capability::new("Io.Network"))
112    } else if name.contains("file") || name.contains("fs") || name.contains("disk") {
113        Some(Capability::new("Io.FileSystem"))
114    } else if name.contains("clock") || name.contains("time") || name.contains("date") {
115        Some(Capability::new("Io.Clock"))
116    } else if name.contains("env") || name.contains("os") || name.contains("process") {
117        Some(Capability::new("Io.Process"))
118    } else {
119        None
120    }
121}
122
123// ─── Effect / call collector ──────────────────────────────────────────────────
124
125/// Walk `node` and collect:
126/// - `used_effects`: every `EffectOp` effect directly invoked.
127/// - `called_fns`: every function name called (for propagation).
128fn collect_effects_and_calls(
129    node: &AIRNode,
130    used_effects: &mut HashSet<EffectRef>,
131    called_fns: &mut HashSet<String>,
132) {
133    match &node.kind {
134        NodeKind::EffectOp { effect, args, .. } => {
135            let name = effect
136                .segments
137                .iter()
138                .map(|s| s.name.as_str())
139                .collect::<Vec<_>>()
140                .join(".");
141            used_effects.insert(EffectRef::new(name));
142            for arg in args {
143                collect_effects_and_calls(&arg.value, used_effects, called_fns);
144            }
145        }
146
147        NodeKind::Call {
148            callee,
149            args,
150            type_args,
151        } => {
152            if let NodeKind::Identifier { name } = &callee.kind {
153                called_fns.insert(name.name.clone());
154            }
155            collect_effects_and_calls(callee, used_effects, called_fns);
156            for arg in args {
157                collect_effects_and_calls(&arg.value, used_effects, called_fns);
158            }
159            for ta in type_args {
160                collect_effects_and_calls(ta, used_effects, called_fns);
161            }
162        }
163
164        NodeKind::MethodCall { receiver, args, .. } => {
165            collect_effects_and_calls(receiver, used_effects, called_fns);
166            for arg in args {
167                collect_effects_and_calls(&arg.value, used_effects, called_fns);
168            }
169        }
170
171        // Handling block: effects handled here don't propagate.
172        NodeKind::HandlingBlock { handlers, body } => {
173            let handled: HashSet<String> = handlers
174                .iter()
175                .map(|h| {
176                    h.effect
177                        .segments
178                        .iter()
179                        .map(|s| s.name.as_str())
180                        .collect::<Vec<_>>()
181                        .join(".")
182                })
183                .collect();
184
185            let mut body_effects = HashSet::new();
186            let mut body_calls = HashSet::new();
187            collect_effects_and_calls(body, &mut body_effects, &mut body_calls);
188
189            for e in body_effects {
190                if !handled.contains(&e.name) {
191                    used_effects.insert(e);
192                }
193            }
194            called_fns.extend(body_calls);
195        }
196
197        // Nested FnDecl: opaque — don't leak inner effects.
198        NodeKind::FnDecl { .. } => {}
199
200        NodeKind::Lambda { body, .. } => {
201            collect_effects_and_calls(body, used_effects, called_fns);
202        }
203
204        NodeKind::Block { stmts, tail } => {
205            for s in stmts {
206                collect_effects_and_calls(s, used_effects, called_fns);
207            }
208            if let Some(t) = tail {
209                collect_effects_and_calls(t, used_effects, called_fns);
210            }
211        }
212
213        NodeKind::LetBinding { value, .. } => {
214            collect_effects_and_calls(value, used_effects, called_fns);
215        }
216
217        NodeKind::Assign { target, value, .. } => {
218            collect_effects_and_calls(target, used_effects, called_fns);
219            collect_effects_and_calls(value, used_effects, called_fns);
220        }
221
222        NodeKind::If {
223            condition,
224            then_block,
225            else_block,
226            ..
227        } => {
228            collect_effects_and_calls(condition, used_effects, called_fns);
229            collect_effects_and_calls(then_block, used_effects, called_fns);
230            if let Some(e) = else_block {
231                collect_effects_and_calls(e, used_effects, called_fns);
232            }
233        }
234
235        NodeKind::Guard {
236            let_pattern,
237            condition,
238            else_block,
239        } => {
240            if let Some(pat) = let_pattern {
241                collect_effects_and_calls(pat, used_effects, called_fns);
242            }
243            collect_effects_and_calls(condition, used_effects, called_fns);
244            collect_effects_and_calls(else_block, used_effects, called_fns);
245        }
246
247        NodeKind::Match { scrutinee, arms } => {
248            collect_effects_and_calls(scrutinee, used_effects, called_fns);
249            for arm in arms {
250                collect_effects_and_calls(arm, used_effects, called_fns);
251            }
252        }
253
254        NodeKind::MatchArm { guard, body, .. } => {
255            if let Some(g) = guard {
256                collect_effects_and_calls(g, used_effects, called_fns);
257            }
258            collect_effects_and_calls(body, used_effects, called_fns);
259        }
260
261        NodeKind::For { iterable, body, .. } => {
262            collect_effects_and_calls(iterable, used_effects, called_fns);
263            collect_effects_and_calls(body, used_effects, called_fns);
264        }
265
266        NodeKind::While { condition, body } => {
267            collect_effects_and_calls(condition, used_effects, called_fns);
268            collect_effects_and_calls(body, used_effects, called_fns);
269        }
270
271        NodeKind::Loop { body } => {
272            collect_effects_and_calls(body, used_effects, called_fns);
273        }
274
275        NodeKind::Return { value: Some(v) } | NodeKind::Break { value: Some(v) } => {
276            collect_effects_and_calls(v, used_effects, called_fns);
277        }
278
279        NodeKind::Return { value: None } | NodeKind::Break { value: None } => {}
280
281        NodeKind::BinaryOp { left, right, .. } => {
282            collect_effects_and_calls(left, used_effects, called_fns);
283            collect_effects_and_calls(right, used_effects, called_fns);
284        }
285
286        NodeKind::UnaryOp { operand, .. } => {
287            collect_effects_and_calls(operand, used_effects, called_fns);
288        }
289
290        NodeKind::FieldAccess { object, .. } => {
291            collect_effects_and_calls(object, used_effects, called_fns);
292        }
293
294        NodeKind::Index { object, index } => {
295            collect_effects_and_calls(object, used_effects, called_fns);
296            collect_effects_and_calls(index, used_effects, called_fns);
297        }
298
299        NodeKind::Propagate { expr } => {
300            collect_effects_and_calls(expr, used_effects, called_fns);
301        }
302
303        NodeKind::Await { expr } => {
304            collect_effects_and_calls(expr, used_effects, called_fns);
305        }
306
307        NodeKind::Borrow { expr } | NodeKind::MutableBorrow { expr } | NodeKind::Move { expr } => {
308            collect_effects_and_calls(expr, used_effects, called_fns);
309        }
310
311        NodeKind::Pipe { left, right } | NodeKind::Compose { left, right } => {
312            collect_effects_and_calls(left, used_effects, called_fns);
313            collect_effects_and_calls(right, used_effects, called_fns);
314        }
315
316        NodeKind::Range { lo, hi, .. } => {
317            collect_effects_and_calls(lo, used_effects, called_fns);
318            collect_effects_and_calls(hi, used_effects, called_fns);
319        }
320
321        NodeKind::RecordConstruct { fields, spread, .. } => {
322            for f in fields {
323                if let Some(v) = &f.value {
324                    collect_effects_and_calls(v, used_effects, called_fns);
325                }
326            }
327            if let Some(s) = spread {
328                collect_effects_and_calls(s, used_effects, called_fns);
329            }
330        }
331
332        NodeKind::ListLiteral { elems }
333        | NodeKind::SetLiteral { elems }
334        | NodeKind::TupleLiteral { elems } => {
335            for e in elems {
336                collect_effects_and_calls(e, used_effects, called_fns);
337            }
338        }
339
340        NodeKind::MapLiteral { entries } => {
341            for e in entries {
342                collect_effects_and_calls(&e.key, used_effects, called_fns);
343                collect_effects_and_calls(&e.value, used_effects, called_fns);
344            }
345        }
346
347        NodeKind::Interpolation { parts } => {
348            for p in parts {
349                if let AirInterpolationPart::Expr(e) = p {
350                    collect_effects_and_calls(e, used_effects, called_fns);
351                }
352            }
353        }
354
355        NodeKind::ResultConstruct { value: Some(v), .. } => {
356            collect_effects_and_calls(v, used_effects, called_fns);
357        }
358
359        // Leaf nodes and others.
360        _ => {}
361    }
362}
363
364// ─── Internal function record ─────────────────────────────────────────────────
365
366/// Summary of a function gathered during phase 1.
367struct FnRecord {
368    node_id: NodeId,
369    span: Span,
370    is_public: bool,
371    /// Capabilities explicitly declared via `@requires`.
372    declared: CapabilitySet,
373    /// Capabilities inferred from directly-used IO effects.
374    from_effects: CapabilitySet,
375    /// Names of functions directly called in the body.
376    called_fns: HashSet<String>,
377}
378
379// ─── Capability engine ────────────────────────────────────────────────────────
380
381struct CapabilityEngine {
382    /// Records keyed by function name.
383    records: HashMap<String, FnRecord>,
384    /// Module-level `@requires` capabilities (additive base).
385    module_caps: CapabilitySet,
386}
387
388impl CapabilityEngine {
389    fn new() -> Self {
390        Self {
391            records: HashMap::new(),
392            module_caps: CapabilitySet::new(),
393        }
394    }
395
396    // ── Phase 1: collect ──────────────────────────────────────────────────
397
398    fn collect(&mut self, module: &AIRModule) {
399        match &module.kind {
400            NodeKind::Module { items, .. } => {
401                // Extract module-level @requires annotations if the module
402                // node itself carries annotations (not always the case, but
403                // some compilers attach them to the Module node via metadata).
404                // For now we look for a Module-level FnDecl-style annotation
405                // by checking if there's an annotation list attached to the
406                // module kind. The AIR Module node does not carry annotations
407                // directly, so module_caps stays empty unless we extend later.
408                for item in items {
409                    self.collect_item(item);
410                }
411            }
412            _ => self.collect_item(module),
413        }
414    }
415
416    fn collect_item(&mut self, node: &AIRNode) {
417        match &node.kind {
418            NodeKind::FnDecl {
419                name,
420                annotations,
421                visibility,
422                body,
423                ..
424            } => {
425                let declared = {
426                    let mut caps = extract_requires_annotation(annotations);
427                    // Add module-level capabilities (additive per spec).
428                    caps.extend(self.module_caps.iter().cloned());
429                    caps
430                };
431
432                let mut used_effects = HashSet::new();
433                let mut called_fns = HashSet::new();
434                collect_effects_and_calls(body, &mut used_effects, &mut called_fns);
435
436                let from_effects: CapabilitySet = used_effects
437                    .iter()
438                    .filter_map(capability_for_effect)
439                    .collect();
440
441                let record = FnRecord {
442                    node_id: node.id,
443                    span: node.span,
444                    is_public: matches!(visibility, Visibility::Public),
445                    declared,
446                    from_effects,
447                    called_fns,
448                };
449                self.records.insert(name.name.clone(), record);
450            }
451
452            NodeKind::ImplBlock { methods, .. } | NodeKind::TraitDecl { methods, .. } => {
453                for m in methods {
454                    self.collect_item(m);
455                }
456            }
457
458            NodeKind::ClassDecl { methods, .. } => {
459                for m in methods {
460                    self.collect_item(m);
461                }
462            }
463
464            _ => {}
465        }
466    }
467
468    // ── Phase 2: propagate ────────────────────────────────────────────────
469
470    /// Compute the *required* capability set for function `name`, including
471    /// capabilities propagated from its callees (one-pass BFS/DFS).
472    ///
473    /// To avoid cycles we track the current visitation path.
474    fn required_caps(&self, name: &str, visiting: &mut HashSet<String>) -> CapabilitySet {
475        if !visiting.insert(name.to_string()) {
476            // Already computing (cycle) — return empty to break the loop.
477            return CapabilitySet::new();
478        }
479
480        let mut caps = CapabilitySet::new();
481
482        if let Some(rec) = self.records.get(name) {
483            // Direct capabilities from IO effects.
484            caps.extend(rec.from_effects.iter().cloned());
485
486            // Propagate from callees.
487            let callees: Vec<String> = rec.called_fns.iter().cloned().collect();
488            for callee in &callees {
489                let callee_caps = self.required_caps(callee, visiting);
490                caps.extend(callee_caps);
491            }
492        }
493
494        visiting.remove(name);
495        caps
496    }
497
498    // ── Phase 3: build map ────────────────────────────────────────────────
499
500    /// Build the `NodeId → CapabilitySet` map for all collected functions.
501    ///
502    /// The set for each node is the *full required* capability set (direct +
503    /// propagated from callees).
504    fn build_map(&self) -> HashMap<NodeId, CapabilitySet> {
505        let mut map = HashMap::new();
506        for (name, rec) in &self.records {
507            let mut visiting = HashSet::new();
508            let caps = self.required_caps(name, &mut visiting);
509            map.insert(rec.node_id, caps);
510        }
511        map
512    }
513
514    // ── Phase 4: verify ───────────────────────────────────────────────────
515
516    fn verify(&self, strictness: Strictness) -> DiagnosticBag {
517        let mut diags = DiagnosticBag::new();
518
519        if strictness == Strictness::Sketch {
520            return diags;
521        }
522
523        let use_errors = strictness == Strictness::Production;
524
525        for (name, rec) in &self.records {
526            let should_check = match strictness {
527                Strictness::Development => rec.is_public,
528                Strictness::Production => true,
529                Strictness::Sketch => false,
530            };
531
532            if !should_check {
533                continue;
534            }
535
536            let mut visiting = HashSet::new();
537            let required = self.required_caps(name, &mut visiting);
538
539            // Check: capabilities required by direct effects but not declared.
540            for cap in rec.from_effects.iter() {
541                if !rec.declared.contains(cap) {
542                    let msg = format!(
543                        "function `{name}` requires capability `{}` (from IO effects) \
544                         but does not declare it via `@requires`",
545                        cap.name
546                    );
547                    let code = if use_errors {
548                        E_MISSING_CAPABILITY
549                    } else {
550                        W_MISSING_CAPABILITY
551                    };
552                    if use_errors {
553                        diags.error(code, msg, rec.span);
554                    } else {
555                        diags.warning(code, msg, rec.span);
556                    }
557                }
558            }
559
560            // Check: capabilities propagated from callees but not declared.
561            let propagated: CapabilitySet = required
562                .iter()
563                .filter(|c| !rec.from_effects.contains(*c) && !rec.declared.contains(*c))
564                .cloned()
565                .collect();
566
567            for cap in &propagated {
568                // Find which callee introduced this capability.
569                let callee_name = rec
570                    .called_fns
571                    .iter()
572                    .find(|c| {
573                        let mut v = HashSet::new();
574                        self.required_caps(c, &mut v).contains(cap)
575                    })
576                    .cloned()
577                    .unwrap_or_default();
578
579                let msg = format!(
580                    "function `{name}` calls `{callee_name}` which requires capability `{}`, \
581                     but `{name}` does not declare it via `@requires`",
582                    cap.name
583                );
584                let code = if use_errors {
585                    E_PROPAGATED_CAPABILITY
586                } else {
587                    W_PROPAGATED_CAPABILITY
588                };
589                if use_errors {
590                    diags.error(code, msg, rec.span);
591                } else {
592                    diags.warning(code, msg, rec.span);
593                }
594            }
595        }
596
597        diags
598    }
599}
600
601// ─── Public API ───────────────────────────────────────────────────────────────
602
603/// Compute capability requirements for every function in `module`.
604///
605/// Returns a map from [`NodeId`] to [`CapabilitySet`]. Each set is the *full*
606/// required capability set for that function, including capabilities propagated
607/// from callees through the call graph.
608///
609/// Capabilities are derived from:
610/// - `@requires` annotations on the function (or the containing module).
611/// - IO effect usage correlated to platform capabilities.
612/// - Propagation from directly-called functions.
613#[must_use]
614pub fn compute_capabilities(module: &AIRModule) -> HashMap<NodeId, CapabilitySet> {
615    let mut engine = CapabilityEngine::new();
616    engine.collect(module);
617    engine.build_map()
618}
619
620/// Verify capability declarations in `module` against actual usage.
621///
622/// Emits diagnostics when a function uses or calls into code that requires a
623/// capability but does not declare it via `@requires`, according to
624/// `strictness`:
625///
626/// - [`Strictness::Sketch`] — no diagnostics.
627/// - [`Strictness::Development`] — warnings on public functions only.
628/// - [`Strictness::Production`] — errors on all functions.
629#[must_use]
630pub fn verify_capabilities(module: &AIRModule, strictness: Strictness) -> DiagnosticBag {
631    let mut engine = CapabilityEngine::new();
632    engine.collect(module);
633    engine.verify(strictness)
634}
635
636// ─── Tests ────────────────────────────────────────────────────────────────────
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use bock_air::{AIRNode, AirHandlerPair, NodeIdGen, NodeKind};
642    use bock_ast::{Annotation, Ident, TypePath, Visibility};
643    use bock_errors::{FileId, Severity, Span};
644
645    fn dummy_span() -> Span {
646        Span {
647            file: FileId(0),
648            start: 0,
649            end: 0,
650        }
651    }
652
653    fn dummy_ident(name: &str) -> Ident {
654        Ident {
655            name: name.to_string(),
656            span: dummy_span(),
657        }
658    }
659
660    fn dummy_type_path(name: &str) -> TypePath {
661        TypePath {
662            segments: vec![dummy_ident(name)],
663            span: dummy_span(),
664        }
665    }
666
667    fn make_node(gen: &NodeIdGen, kind: NodeKind) -> AIRNode {
668        AIRNode::new(gen.next(), dummy_span(), kind)
669    }
670
671    fn empty_block(gen: &NodeIdGen) -> AIRNode {
672        make_node(
673            gen,
674            NodeKind::Block {
675                stmts: vec![],
676                tail: None,
677            },
678        )
679    }
680
681    fn make_effect_op(gen: &NodeIdGen, effect: &str) -> AIRNode {
682        make_node(
683            gen,
684            NodeKind::EffectOp {
685                effect: dummy_type_path(effect),
686                operation: dummy_ident("op"),
687                args: vec![],
688            },
689        )
690    }
691
692    /// Build a `@requires(cap1, cap2, ...)` annotation using the canonical
693    /// capability names (e.g. `"Io.Stdout"`, `"Io.Network"`).
694    fn make_requires_annotation(caps: &[&str]) -> Annotation {
695        use bock_ast::AnnotationArg;
696        use bock_ast::Expr;
697        use bock_ast::NodeId as AstNodeId;
698
699        let args = caps
700            .iter()
701            .map(|cap| AnnotationArg {
702                label: None,
703                value: Expr::Identifier {
704                    id: 0 as AstNodeId,
705                    span: dummy_span(),
706                    name: dummy_ident(cap),
707                },
708            })
709            .collect();
710
711        Annotation {
712            id: 0,
713            span: dummy_span(),
714            name: dummy_ident("requires"),
715            args,
716        }
717    }
718
719    fn make_fn(
720        gen: &NodeIdGen,
721        name: &str,
722        annotations: Vec<Annotation>,
723        body: AIRNode,
724        vis: Visibility,
725    ) -> AIRNode {
726        make_node(
727            gen,
728            NodeKind::FnDecl {
729                annotations,
730                visibility: vis,
731                is_async: false,
732                name: dummy_ident(name),
733                generic_params: vec![],
734                params: vec![],
735                return_type: None,
736                effect_clause: vec![],
737                where_clause: vec![],
738                body: Box::new(body),
739            },
740        )
741    }
742
743    fn make_module(gen: &NodeIdGen, items: Vec<AIRNode>) -> AIRNode {
744        make_node(
745            gen,
746            NodeKind::Module {
747                path: None,
748                annotations: vec![],
749                imports: vec![],
750                items,
751            },
752        )
753    }
754
755    fn warning_count(bag: &DiagnosticBag) -> usize {
756        bag.iter()
757            .filter(|d| d.severity == Severity::Warning)
758            .count()
759    }
760
761    // ── expr_to_capability_name ────────────────────────────────────────────
762
763    #[test]
764    fn expr_capability_name_identifier() {
765        use bock_ast::Expr;
766        let expr = Expr::Identifier {
767            id: 0,
768            span: dummy_span(),
769            name: dummy_ident("Network"),
770        };
771        assert_eq!(expr_to_capability_name(&expr), Some("Network".into()));
772    }
773
774    #[test]
775    fn expr_capability_name_field_access() {
776        use bock_ast::Expr;
777        let expr = Expr::FieldAccess {
778            id: 0,
779            span: dummy_span(),
780            object: Box::new(Expr::Identifier {
781                id: 0,
782                span: dummy_span(),
783                name: dummy_ident("Capability"),
784            }),
785            field: dummy_ident("Network"),
786        };
787        assert_eq!(
788            expr_to_capability_name(&expr),
789            Some("Capability.Network".into())
790        );
791    }
792
793    #[test]
794    fn expr_capability_name_unknown_returns_none() {
795        use bock_ast::{Expr, Literal};
796        let expr = Expr::Literal {
797            id: 0,
798            span: dummy_span(),
799            lit: Literal::Bool(true),
800        };
801        assert_eq!(expr_to_capability_name(&expr), None);
802    }
803
804    // ── capability_for_effect ──────────────────────────────────────────────
805
806    #[test]
807    fn effect_log_gives_stdout_cap() {
808        let e = EffectRef::new("Log");
809        assert_eq!(
810            capability_for_effect(&e),
811            Some(Capability::new("Io.Stdout"))
812        );
813    }
814
815    #[test]
816    fn effect_http_gives_network_cap() {
817        let e = EffectRef::new("Http");
818        assert_eq!(
819            capability_for_effect(&e),
820            Some(Capability::new("Io.Network"))
821        );
822    }
823
824    #[test]
825    fn effect_clock_gives_clock_cap() {
826        let e = EffectRef::new("Clock");
827        assert_eq!(capability_for_effect(&e), Some(Capability::new("Io.Clock")));
828    }
829
830    #[test]
831    fn effect_pure_gives_no_cap() {
832        let e = EffectRef::new("Pure");
833        assert_eq!(capability_for_effect(&e), None);
834    }
835
836    // ── compute_capabilities ──────────────────────────────────────────────
837
838    #[test]
839    fn empty_fn_has_no_capabilities() {
840        let gen = NodeIdGen::new();
841        let body = empty_block(&gen);
842        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Private);
843        let module = make_module(&gen, vec![fn_node.clone()]);
844
845        let map = compute_capabilities(&module);
846        let caps = map.get(&fn_node.id).cloned().unwrap_or_default();
847        assert!(caps.is_empty());
848    }
849
850    #[test]
851    fn fn_with_log_effect_gets_stdout_cap() {
852        let gen = NodeIdGen::new();
853        let op = make_effect_op(&gen, "Log");
854        let body = make_node(
855            &gen,
856            NodeKind::Block {
857                stmts: vec![op],
858                tail: None,
859            },
860        );
861        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Public);
862        let module = make_module(&gen, vec![fn_node.clone()]);
863
864        let map = compute_capabilities(&module);
865        let caps = map.get(&fn_node.id).cloned().unwrap_or_default();
866        assert!(caps.contains(&Capability::new("Io.Stdout")));
867    }
868
869    #[test]
870    fn fn_with_http_effect_gets_network_cap() {
871        let gen = NodeIdGen::new();
872        let op = make_effect_op(&gen, "Http");
873        let body = make_node(
874            &gen,
875            NodeKind::Block {
876                stmts: vec![op],
877                tail: None,
878            },
879        );
880        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Public);
881        let module = make_module(&gen, vec![fn_node.clone()]);
882
883        let map = compute_capabilities(&module);
884        let caps = map.get(&fn_node.id).cloned().unwrap_or_default();
885        assert!(caps.contains(&Capability::new("Io.Network")));
886    }
887
888    #[test]
889    fn requires_annotation_included_in_capability_map() {
890        let gen = NodeIdGen::new();
891        let ann = make_requires_annotation(&["Storage"]);
892        let body = empty_block(&gen);
893        // Note: declared caps from @requires are NOT included in the
894        // "required" set (compute_capabilities returns required, not declared).
895        // The declared caps serve for verification only.
896        // This test just ensures the function is found in the map.
897        let fn_node = make_fn(&gen, "f", vec![ann], body, Visibility::Public);
898        let module = make_module(&gen, vec![fn_node.clone()]);
899
900        let map = compute_capabilities(&module);
901        assert!(map.contains_key(&fn_node.id));
902    }
903
904    #[test]
905    fn capability_propagates_through_call_graph() {
906        let gen = NodeIdGen::new();
907
908        // callee uses Http → needs Io.Network
909        let callee_op = make_effect_op(&gen, "Http");
910        let callee_body = make_node(
911            &gen,
912            NodeKind::Block {
913                stmts: vec![callee_op],
914                tail: None,
915            },
916        );
917        let callee = make_fn(&gen, "callee", vec![], callee_body, Visibility::Public);
918        let callee_id = callee.id;
919
920        // caller calls callee
921        let call_node = make_node(
922            &gen,
923            NodeKind::Call {
924                callee: Box::new(make_node(
925                    &gen,
926                    NodeKind::Identifier {
927                        name: dummy_ident("callee"),
928                    },
929                )),
930                args: vec![],
931                type_args: vec![],
932            },
933        );
934        let caller_body = make_node(
935            &gen,
936            NodeKind::Block {
937                stmts: vec![call_node],
938                tail: None,
939            },
940        );
941        let caller = make_fn(&gen, "caller", vec![], caller_body, Visibility::Public);
942        let caller_id = caller.id;
943
944        let module = make_module(&gen, vec![callee, caller]);
945        let map = compute_capabilities(&module);
946
947        // callee has Io.Network
948        let callee_caps = map.get(&callee_id).cloned().unwrap_or_default();
949        assert!(callee_caps.contains(&Capability::new("Io.Network")));
950
951        // caller also has Io.Network (propagated)
952        let caller_caps = map.get(&caller_id).cloned().unwrap_or_default();
953        assert!(caller_caps.contains(&Capability::new("Io.Network")));
954    }
955
956    #[test]
957    fn handling_block_suppresses_effect_capability() {
958        let gen = NodeIdGen::new();
959        let op = make_effect_op(&gen, "Log");
960        let inner_body = make_node(
961            &gen,
962            NodeKind::Block {
963                stmts: vec![op],
964                tail: None,
965            },
966        );
967        let handling = make_node(
968            &gen,
969            NodeKind::HandlingBlock {
970                handlers: vec![AirHandlerPair {
971                    effect: dummy_type_path("Log"),
972                    handler: Box::new(empty_block(&gen)),
973                }],
974                body: Box::new(inner_body),
975            },
976        );
977        let body = make_node(
978            &gen,
979            NodeKind::Block {
980                stmts: vec![handling],
981                tail: None,
982            },
983        );
984        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Public);
985        let module = make_module(&gen, vec![fn_node.clone()]);
986
987        let map = compute_capabilities(&module);
988        let caps = map.get(&fn_node.id).cloned().unwrap_or_default();
989        // Log is handled, so Io.Stdout should NOT appear.
990        assert!(!caps.contains(&Capability::new("Io.Stdout")));
991    }
992
993    // ── verify_capabilities ───────────────────────────────────────────────
994
995    #[test]
996    fn sketch_mode_no_diagnostics() {
997        let gen = NodeIdGen::new();
998        let op = make_effect_op(&gen, "Log");
999        let body = make_node(
1000            &gen,
1001            NodeKind::Block {
1002                stmts: vec![op],
1003                tail: None,
1004            },
1005        );
1006        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Public);
1007        let module = make_module(&gen, vec![fn_node]);
1008
1009        let bag = verify_capabilities(&module, Strictness::Sketch);
1010        assert_eq!(bag.error_count(), 0);
1011        assert_eq!(warning_count(&bag), 0);
1012    }
1013
1014    #[test]
1015    fn dev_mode_warns_public_missing_requires() {
1016        let gen = NodeIdGen::new();
1017        let op = make_effect_op(&gen, "Log");
1018        let body = make_node(
1019            &gen,
1020            NodeKind::Block {
1021                stmts: vec![op],
1022                tail: None,
1023            },
1024        );
1025        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Public);
1026        let module = make_module(&gen, vec![fn_node]);
1027
1028        let bag = verify_capabilities(&module, Strictness::Development);
1029        assert_eq!(bag.error_count(), 0);
1030        assert!(warning_count(&bag) > 0);
1031    }
1032
1033    #[test]
1034    fn dev_mode_no_warning_private_missing_requires() {
1035        let gen = NodeIdGen::new();
1036        let op = make_effect_op(&gen, "Log");
1037        let body = make_node(
1038            &gen,
1039            NodeKind::Block {
1040                stmts: vec![op],
1041                tail: None,
1042            },
1043        );
1044        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Private);
1045        let module = make_module(&gen, vec![fn_node]);
1046
1047        let bag = verify_capabilities(&module, Strictness::Development);
1048        assert_eq!(bag.error_count(), 0);
1049        assert_eq!(warning_count(&bag), 0);
1050    }
1051
1052    #[test]
1053    fn prod_mode_errors_all_missing_requires() {
1054        let gen = NodeIdGen::new();
1055        let op = make_effect_op(&gen, "Log");
1056        let body = make_node(
1057            &gen,
1058            NodeKind::Block {
1059                stmts: vec![op],
1060                tail: None,
1061            },
1062        );
1063        let fn_node = make_fn(&gen, "f", vec![], body, Visibility::Private);
1064        let module = make_module(&gen, vec![fn_node]);
1065
1066        let bag = verify_capabilities(&module, Strictness::Production);
1067        assert!(bag.error_count() > 0);
1068    }
1069
1070    #[test]
1071    fn declared_capability_suppresses_diagnostic() {
1072        let gen = NodeIdGen::new();
1073        let op = make_effect_op(&gen, "Log");
1074        let body = make_node(
1075            &gen,
1076            NodeKind::Block {
1077                stmts: vec![op],
1078                tail: None,
1079            },
1080        );
1081        // @requires(Io.Stdout) — canonical name matches what capability_for_effect
1082        // returns for the Log effect.
1083        let ann = make_requires_annotation(&["Io.Stdout"]);
1084        let fn_node = make_fn(&gen, "f", vec![ann], body, Visibility::Public);
1085        let module = make_module(&gen, vec![fn_node]);
1086
1087        let bag = verify_capabilities(&module, Strictness::Production);
1088        assert_eq!(bag.error_count(), 0);
1089    }
1090
1091    #[test]
1092    fn propagated_capability_missing_produces_error_in_prod() {
1093        let gen = NodeIdGen::new();
1094
1095        // callee uses Http → needs Io.Network
1096        let callee_op = make_effect_op(&gen, "Http");
1097        let callee_body = make_node(
1098            &gen,
1099            NodeKind::Block {
1100                stmts: vec![callee_op],
1101                tail: None,
1102            },
1103        );
1104        let callee = make_fn(&gen, "callee", vec![], callee_body, Visibility::Private);
1105
1106        // caller calls callee but doesn't declare @requires(Capability.Io.Network)
1107        let call_node = make_node(
1108            &gen,
1109            NodeKind::Call {
1110                callee: Box::new(make_node(
1111                    &gen,
1112                    NodeKind::Identifier {
1113                        name: dummy_ident("callee"),
1114                    },
1115                )),
1116                args: vec![],
1117                type_args: vec![],
1118            },
1119        );
1120        let caller_body = make_node(
1121            &gen,
1122            NodeKind::Block {
1123                stmts: vec![call_node],
1124                tail: None,
1125            },
1126        );
1127        let caller = make_fn(&gen, "caller", vec![], caller_body, Visibility::Public);
1128
1129        let module = make_module(&gen, vec![callee, caller]);
1130        let bag = verify_capabilities(&module, Strictness::Production);
1131        assert!(bag.error_count() > 0);
1132    }
1133
1134    #[test]
1135    fn propagated_capability_declared_ok() {
1136        let gen = NodeIdGen::new();
1137
1138        // callee uses Http and also declares @requires(Io.Network).
1139        let callee_op = make_effect_op(&gen, "Http");
1140        let callee_body = make_node(
1141            &gen,
1142            NodeKind::Block {
1143                stmts: vec![callee_op],
1144                tail: None,
1145            },
1146        );
1147        let callee_ann = make_requires_annotation(&["Io.Network"]);
1148        let callee = make_fn(
1149            &gen,
1150            "callee",
1151            vec![callee_ann],
1152            callee_body,
1153            Visibility::Private,
1154        );
1155
1156        let call_node = make_node(
1157            &gen,
1158            NodeKind::Call {
1159                callee: Box::new(make_node(
1160                    &gen,
1161                    NodeKind::Identifier {
1162                        name: dummy_ident("callee"),
1163                    },
1164                )),
1165                args: vec![],
1166                type_args: vec![],
1167            },
1168        );
1169        let caller_body = make_node(
1170            &gen,
1171            NodeKind::Block {
1172                stmts: vec![call_node],
1173                tail: None,
1174            },
1175        );
1176        // caller also declares @requires(Io.Network) for the propagated cap.
1177        let caller_ann = make_requires_annotation(&["Io.Network"]);
1178        let caller = make_fn(
1179            &gen,
1180            "caller",
1181            vec![caller_ann],
1182            caller_body,
1183            Visibility::Public,
1184        );
1185
1186        let module = make_module(&gen, vec![callee, caller]);
1187        let bag = verify_capabilities(&module, Strictness::Production);
1188        assert_eq!(bag.error_count(), 0);
1189    }
1190}