Skip to main content

bynk_ide/
sequence.rs

1//! #846: the sequence-diagram query.
2//!
3//! Classifies a handler body's calls into runtime-participant lifelines —
4//! consumed capabilities, calls into consumed contexts, and agents (including
5//! same-context agents) — for the "Show Sequence Diagram" VS Code feature.
6//! Everything else (commons fns, context-local fns, methods, constructors)
7//! folds into the entry participant's own activation: no message is emitted
8//! for the call, and — because this repo's resolver does not inline call
9//! bodies either — a lifeline call written inside a commons `fn`'s own body is
10//! invisible to this walk. That is a stated Tier-1 limitation, not a bug: see
11//! `design/pending/sequence-diagram-846.md`.
12//!
13//! A pure, read-only IDE query: it never touches the checker's hot path
14//! (`bynk_check::checker::check_handler_body`) and is built on the exhaustive
15//! `expr_children`/`statement_exprs` walkers only insofar as this module's own
16//! statement dispatch mirrors their coverage of [`Statement`] — a new
17//! `Statement` variant is a compile error here, in `Builder::walk_block`'s
18//! `match`, not a silent gap.
19//!
20//! #2194: in an agent's own handler, a write to the agent's state — a `:=`
21//! or a mutating store operation on one of its `store` fields — is not a
22//! lifeline either, but it is not folded away: it is a
23//! [`MessageKind::StateWrite`] from the entry to itself, rendered as a note.
24//!
25//! Cross-context/agent calls are boundary-stop (Decision C): one `Call` +
26//! one `Return` message: the callee's own body is never walked, even where
27//! reachable (an agent's handlers are visible via [`ContextSequenceInfo::agents`]).
28
29use std::collections::HashMap;
30
31use bynk_check::analysis::ContextSequenceInfo;
32use bynk_check::ast_text::{branch_outcome, pattern_summary, peel_paren};
33use bynk_syntax::ast::*;
34use bynk_syntax::span::Span;
35
36/// Which declaration owns the handler being diagrammed.
37#[derive(Debug, Clone, Copy)]
38pub enum HandlerOwner<'a> {
39    Service(&'a str),
40    Agent(&'a str),
41}
42
43/// Nesting budget for rendered `if`/`match` blocks (issue #846: "~2 levels").
44/// Beyond this depth the walk stops classifying calls and emits a single
45/// [`AltKind::Collapsed`] marker instead of recursing further.
46const MAX_BLOCK_DEPTH: u32 = 2;
47
48#[derive(Debug, Clone, Default, PartialEq)]
49pub struct SequenceModel {
50    pub participants: Vec<Participant>,
51    pub messages: Vec<Message>,
52    pub blocks: Vec<AltBlock>,
53}
54
55#[derive(Debug, Clone, PartialEq)]
56pub struct Participant {
57    pub id: u32,
58    pub kind: ParticipantKind,
59    pub name: String,
60    /// `None` for `Entry` — it has no single declaration site to jump to
61    /// (it *is* the handler).
62    pub span: Option<Span>,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum ParticipantKind {
67    Entry,
68    Capability,
69    Context,
70    Agent,
71    /// The handler's **principal** — the `by <Actor>` role (v0.45) that
72    /// originates the request. Rendered leftmost, as the sender of the initial
73    /// inbound message into the entry, and the recipient of the handler's
74    /// replies. Present only for a handler that declares (or inherits) a `by`
75    /// clause — HTTP/WebSocket service handlers; agents have no principal.
76    Actor,
77}
78
79#[derive(Debug, Clone, PartialEq)]
80pub struct Message {
81    pub from: u32,
82    pub to: u32,
83    pub kind: MessageKind,
84    pub label: String,
85    pub span: Span,
86    pub block: Option<u32>,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum MessageKind {
91    Call,
92    Return,
93    /// `~>` fire-and-forget — no paired `Return`.
94    Send,
95    /// #2194: an agent handler's write to its own state — a `:=` or a
96    /// mutating store operation (`items.put(…)`, `count.update(…)`). Not a
97    /// message across a boundary (the store is the agent's own storage, not an
98    /// ADR 0258 lifeline): `from == to ==` the entry, and a renderer draws it
99    /// as a note over the entry lifeline, in source order within its branch.
100    StateWrite,
101}
102
103#[derive(Debug, Clone, PartialEq)]
104pub struct AltBlock {
105    pub id: u32,
106    pub kind: AltKind,
107    /// Empty for `Collapsed`.
108    pub branches: Vec<Branch>,
109    pub span: Span,
110    pub parent: Option<u32>,
111    /// Which of `parent`'s branches this block is nested under — `None` iff
112    /// `parent` is `None`. Needed to render nesting correctly: a parent
113    /// branch can be entirely empty of messages (the rate-limiter's `if`/
114    /// `else` gating only a return, for one), so a renderer walking
115    /// `Message.block` alone would have no way to place a nested block whose
116    /// own branches are also message-free.
117    pub parent_branch: Option<u32>,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum AltKind {
122    If,
123    Match,
124    Collapsed,
125}
126
127#[derive(Debug, Clone, PartialEq)]
128pub struct Branch {
129    pub label: String,
130    pub message_ids: Vec<usize>,
131    /// The value the handler yields on this branch — its rendered tail
132    /// expression (`Ok(view)`, `TooManyRequests(...)`). This is the "reply to
133    /// its own caller" ADR 0260 names as always present, but which the
134    /// original model never actually emitted: a return-gating `if`/`match`
135    /// whose branches call no lifeline produced two empty branches, and an
136    /// empty `alt` renders as a mangled zero-width box (issue: rate-limiter's
137    /// `GET /check/:client`). Carrying the outcome here gives the block real
138    /// content to render as a note over the entry lifeline.
139    ///
140    /// `None` when the tail carries no distinguishable signal: a unit `()`
141    /// tail (an else-less `if`'s synthesised branch), or control flow
142    /// (`if`/`match`/block) that is itself already rendered as nested
143    /// structure — a note duplicating it would be noise.
144    pub reply: Option<String>,
145}
146
147/// Whether an expression sits directly under an effect operator
148/// (`<-`/`~>`/`do`) — only then can it be a lifeline call, since a bare
149/// (pure) `let` cannot bind an `Effect[_]` value in this language.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151enum Arrow {
152    /// `let x <- expr` / `do expr` — awaited; a Call+Return pair.
153    Awaited,
154    /// `~> expr` — fire-and-forget; a Send only.
155    FireAndForget,
156}
157
158const ENTRY_ID: u32 = 0;
159
160/// The block/branch a walk is currently nested inside — `None` at the
161/// handler-body top level. Threaded through every walk method so a nested
162/// block can record `parent_branch` and a message can record its `block`.
163#[derive(Debug, Clone, Copy)]
164struct BlockCtx {
165    id: u32,
166    branch: u32,
167}
168
169/// `default_given` is the owning service's service-level `given` default
170/// (v0.155, `ServiceDecl.default_given`) — a handler that declares no `given`
171/// of its own inherits it. This classifier walks a freshly-parsed AST that
172/// has *not* been through `bynk-emit`'s `inject_service_defaults`
173/// normalization pass (which is what mutates `handler.given` in the compile
174/// pipeline), so the fallback has to be applied here or a handler relying on
175/// the service default would drop every capability lifeline. Pass `&[]` when
176/// there is no default (always the case for `HandlerOwner::Agent` — agents
177/// have no service-level `given`).
178/// `default_by` is the owning service's service-level `by` default (v0.155,
179/// `ServiceDecl.default_by`) — a handler that declares no `by` of its own
180/// inherits it, exactly as it does the `given` default. Pass `None` for an
181/// agent (agents have no principal).
182pub fn sequence_model(
183    handler: &Handler,
184    owner: HandlerOwner<'_>,
185    default_given: &[CapRef],
186    default_by: Option<&ByClause>,
187    info: Option<&ContextSequenceInfo>,
188) -> SequenceModel {
189    let given = if handler.given.is_empty() {
190        default_given
191    } else {
192        &handler.given
193    };
194    let by = handler.by_clause.as_ref().or(default_by);
195    let mut b = Builder::new(entry_label(handler, owner, by.is_some()), given, info);
196    if let HandlerOwner::Agent(name) = owner
197        && let Some(agent) = info.and_then(|i| i.agents.get(name))
198    {
199        b.store = bynk_check::store_write::store_kinds(agent);
200        // A parameter named like a store field shadows it for the whole body.
201        b.bound = handler.params.iter().map(|p| p.name.name.clone()).collect();
202    }
203    if let Some(by) = by {
204        // The principal originates the request: a leftmost actor lifeline that
205        // sends the initial inbound message (the route/method) into the entry.
206        // The entry label is bare (`api`) in this case — the request descriptor
207        // rides on the message rather than doubling into the entry box.
208        b.add_actor(actor_name(by), by.span);
209        b.emit_request(discriminator(handler), handler.span);
210    }
211    // The whole body is in the handler's return position: its tail (and each
212    // return-gating branch's tail) is what the handler replies to the actor.
213    b.walk_block(&handler.body, None, 0, true);
214    b.finish()
215}
216
217/// The handler's request descriptor — its method + path (`GET /check/:client`),
218/// method name, or lifecycle kind. Labels the initial actor→entry message, and
219/// (when there is no actor) the entry box's own discriminator suffix.
220fn discriminator(handler: &Handler) -> String {
221    match &handler.kind {
222        HandlerKind::Call => match handler.method_name.as_ref() {
223            Some(m) => m.name.clone(),
224            None => "call".to_string(),
225        },
226        HandlerKind::Http { method, path } => format!("{} {}", method.as_str(), path),
227        HandlerKind::Cron { expr } => format!("cron \"{expr}\""),
228        HandlerKind::Message => "message".to_string(),
229        HandlerKind::Open => "open".to_string(),
230        HandlerKind::Close => "close".to_string(),
231        HandlerKind::Event => "event".to_string(),
232        HandlerKind::Rejected => "rejected".to_string(),
233    }
234}
235
236/// The actor participant's name — the referenced actor contract(s). More than
237/// one (`by who: A | B`, an ordered sum of peer actors) joins with `|`.
238fn actor_name(by: &ByClause) -> String {
239    by.actors
240        .iter()
241        .map(|a| a.name.as_str())
242        .collect::<Vec<_>>()
243        .join(" | ")
244}
245
246/// The entry lifeline's label. With an actor present the request descriptor
247/// moves onto the initial message, so the entry is just its owner name
248/// (`api`); without one it keeps the self-describing combined form
249/// (`api GET /check/:client`, `Limiter.hit`).
250fn entry_label(handler: &Handler, owner: HandlerOwner<'_>, has_actor: bool) -> String {
251    match owner {
252        HandlerOwner::Service(name) if has_actor => name.to_string(),
253        HandlerOwner::Agent(name) if has_actor => name.to_string(),
254        HandlerOwner::Service(name) => format!("{name} {}", discriminator(handler)),
255        HandlerOwner::Agent(name) => format!("{name}.{}", discriminator(handler)),
256    }
257}
258
259struct Builder<'a> {
260    given: &'a [CapRef],
261    info: Option<&'a ContextSequenceInfo>,
262    participants: Vec<Participant>,
263    messages: Vec<Message>,
264    blocks: Vec<AltBlock>,
265    /// The principal's participant id, once [`Builder::add_actor`] has run.
266    /// Its presence is also the switch for reply routing: with an actor, a
267    /// return-position outcome is emitted as a `Return` message *to the actor*;
268    /// without one it stays a note on the branch (`Branch::reply`).
269    actor: Option<u32>,
270    /// #2194: the owning agent's store fields (name → kind head, `"Map"`,
271    /// `"Cell"`, …). Empty for a service, which emits no state writes.
272    store: HashMap<String, String>,
273    /// #2194: names bound in scope (handler parameters, `let`s, match-arm
274    /// bindings) — a store field shadowed by one is not a store operation.
275    bound: Vec<String>,
276}
277
278impl<'a> Builder<'a> {
279    fn new(
280        entry_label: String,
281        given: &'a [CapRef],
282        info: Option<&'a ContextSequenceInfo>,
283    ) -> Self {
284        Builder {
285            given,
286            info,
287            participants: vec![Participant {
288                id: ENTRY_ID,
289                kind: ParticipantKind::Entry,
290                name: entry_label,
291                span: None,
292            }],
293            messages: Vec::new(),
294            blocks: Vec::new(),
295            actor: None,
296            store: HashMap::new(),
297            bound: Vec::new(),
298        }
299    }
300
301    /// Add the principal as the leftmost participant. Inserted at index 0 so it
302    /// renders left of the entry, while the entry keeps [`ENTRY_ID`] (`0`) —
303    /// participant ids are stable labels, independent of array position, so
304    /// every `from: ENTRY_ID` reference stays correct.
305    fn add_actor(&mut self, name: String, span: Span) {
306        let id = self.participants.len() as u32;
307        self.participants.insert(
308            0,
309            Participant {
310                id,
311                kind: ParticipantKind::Actor,
312                name,
313                span: Some(span),
314            },
315        );
316        self.actor = Some(id);
317    }
318
319    /// The initial inbound request: a lone `Call` from the actor to the entry
320    /// (no paired `Return` — the handler's replies are the branch outcomes).
321    /// Spanned at the handler declaration so it sorts ahead of every body
322    /// message and click-navigates to the handler.
323    fn emit_request(&mut self, label: String, handler_span: Span) {
324        let Some(actor) = self.actor else { return };
325        self.messages.push(Message {
326            from: actor,
327            to: ENTRY_ID,
328            kind: MessageKind::Call,
329            label,
330            span: handler_span,
331            block: None,
332        });
333    }
334
335    /// A return-position outcome replied to the principal — a `Return` message
336    /// from the entry to the actor. A no-op when there is no actor (the outcome
337    /// stays a branch note instead).
338    fn emit_reply(&mut self, outcome: String, span: Span, current_block: Option<BlockCtx>) {
339        let Some(actor) = self.actor else { return };
340        self.messages.push(Message {
341            from: ENTRY_ID,
342            to: actor,
343            kind: MessageKind::Return,
344            label: outcome,
345            span,
346            block: current_block.map(|c| c.id),
347        });
348    }
349
350    fn finish(self) -> SequenceModel {
351        SequenceModel {
352            participants: self.participants,
353            messages: self.messages,
354            blocks: self.blocks,
355        }
356    }
357
358    fn participant_id(&mut self, kind: ParticipantKind, name: &str, span: Span) -> u32 {
359        if let Some(p) = self
360            .participants
361            .iter()
362            .find(|p| p.kind == kind && p.name == name)
363        {
364            return p.id;
365        }
366        let id = self.participants.len() as u32;
367        self.participants.push(Participant {
368            id,
369            kind,
370            name: name.to_string(),
371            span: Some(span),
372        });
373        id
374    }
375
376    /// Walk a block's statements, then its tail — the traversal spine every
377    /// call site (top-level body, `if`/`match` branch) shares. `ret` is whether
378    /// this block sits in the handler's **return position**: only then is its
379    /// tail a value the handler replies to the caller. Statements are never in
380    /// return position (their values are bound or discarded); only the tail
381    /// inherits the block's `ret`.
382    fn walk_block(
383        &mut self,
384        block: &Block,
385        current_block: Option<BlockCtx>,
386        depth: u32,
387        ret: bool,
388    ) {
389        // A `let` binds for the rest of its own block only.
390        let scope = self.bound.len();
391        for stmt in &block.statements {
392            match stmt {
393                Statement::EffectLet(l) => {
394                    self.walk_value(&l.value, current_block, depth, Some(Arrow::Awaited), false);
395                    self.bound.push(l.name.name.clone());
396                }
397                Statement::Do(d) => {
398                    self.walk_value(&d.value, current_block, depth, Some(Arrow::Awaited), false)
399                }
400                Statement::Send(s) => self.walk_value(
401                    &s.value,
402                    current_block,
403                    depth,
404                    Some(Arrow::FireAndForget),
405                    false,
406                ),
407                Statement::Let(l) => {
408                    self.walk_value(&l.value, current_block, depth, None, false);
409                    self.bound.push(l.name.name.clone());
410                }
411                Statement::Expect(e) => {
412                    self.walk_value(&e.value, current_block, depth, None, false)
413                }
414                Statement::Assign(a) => {
415                    self.walk_value(&a.value, current_block, depth, None, false);
416                    self.emit_assign(a, current_block);
417                }
418                Statement::Advance(a) => {
419                    self.walk_value(&a.amount, current_block, depth, None, false)
420                }
421            }
422        }
423        self.walk_value(&block.tail, current_block, depth, None, ret);
424        self.bound.truncate(scope);
425    }
426
427    /// Classify one expression reached directly under a statement (or a
428    /// block's tail): a lifeline call (only possible when `arrow.is_some()`,
429    /// since a pure `let`/tail cannot bind an `Effect[_]`), or `if`/`match`
430    /// control flow to recurse into. Anything else — plain computation,
431    /// constructors, nested calls buried in argument expressions — folds into
432    /// the current activation with no further descent (Tier-1 scope: only
433    /// handler-body-level control flow is diagrammed, not arbitrary
434    /// expression-level branching).
435    fn walk_value(
436        &mut self,
437        expr: &Expr,
438        current_block: Option<BlockCtx>,
439        depth: u32,
440        arrow: Option<Arrow>,
441        ret: bool,
442    ) {
443        let inner = peel_paren(expr);
444        match &inner.kind {
445            // Control flow in return position keeps the `ret` flag: each
446            // branch's own tail is what the handler ultimately returns.
447            ExprKind::If { .. } => self.walk_if(inner, current_block, depth, ret),
448            ExprKind::Match { arms, .. } => {
449                self.walk_match(inner.span, arms, current_block, depth, ret)
450            }
451            ExprKind::Block(b) => self.walk_block(b, current_block, depth, ret),
452            ExprKind::Call { .. } | ExprKind::MethodCall { .. } => {
453                // A write to the agent's own store is a note, not a lifeline —
454                // under `<-`/`do`, or as a tail (`balance.update(…)` as a
455                // handler's last expression, `Ok(a) => items.put(a, n)`). Its
456                // value is `()`, so it is not also a reply.
457                if self.emit_store_op(inner, current_block) {
458                    return;
459                }
460                if let Some(arrow) = arrow {
461                    self.classify_call(inner, arrow, current_block);
462                }
463                self.maybe_reply(inner, current_block, ret);
464            }
465            _ => self.maybe_reply(inner, current_block, ret),
466        }
467    }
468
469    /// A value tail in return position is the handler's reply to the actor.
470    /// A no-op off the return path, on a signal-free outcome (unit), or with
471    /// no actor (`emit_reply` itself then does nothing).
472    fn maybe_reply(&mut self, expr: &Expr, current_block: Option<BlockCtx>, ret: bool) {
473        if !ret {
474            return;
475        }
476        if let Some(outcome) = branch_outcome(expr) {
477            self.emit_reply(outcome, expr.span, current_block);
478        }
479    }
480
481    /// `if_expr` is the (paren-peeled) `ExprKind::If` — destructured here rather
482    /// than in the caller to keep the argument list small.
483    fn walk_if(&mut self, if_expr: &Expr, current_block: Option<BlockCtx>, depth: u32, ret: bool) {
484        let ExprKind::If {
485            cond,
486            then_block,
487            else_block,
488        } = &if_expr.kind
489        else {
490            return;
491        };
492        let span = if_expr.span;
493        if depth >= MAX_BLOCK_DEPTH {
494            self.push_collapsed(span, current_block);
495            return;
496        }
497        let id = self.blocks.len() as u32;
498        self.blocks.push(AltBlock {
499            id,
500            kind: AltKind::If,
501            branches: Vec::new(),
502            span,
503            parent: current_block.map(|c| c.id),
504            parent_branch: current_block.map(|c| c.branch),
505        });
506        let then_start = self.messages.len();
507        self.walk_block(then_block, Some(BlockCtx { id, branch: 0 }), depth + 1, ret);
508        let then_ids = (then_start..self.messages.len()).collect();
509        // The condition itself labels the `then` branch (`alt view.allowed`)
510        // rather than a bare "then" — the branch predicate is the signal.
511        let mut branches = vec![Branch {
512            label: bynk_fmt::expr_to_string(cond),
513            message_ids: then_ids,
514            reply: self.branch_reply(&then_block.tail),
515        }];
516        // An else-less `if` has a synthesised `()` else block: no messages, no
517        // nested blocks, no outcome. Rendering it as a second (empty) branch
518        // gains nothing — a single-branch block renders as an `opt`, which is
519        // exactly the right shape for a guard with no alternative. A written
520        // `else` is always walked and always contributes its branch.
521        if !else_block.is_synth_unit() {
522            let else_start = self.messages.len();
523            self.walk_block(else_block, Some(BlockCtx { id, branch: 1 }), depth + 1, ret);
524            branches.push(Branch {
525                label: "otherwise".to_string(),
526                message_ids: (else_start..self.messages.len()).collect(),
527                reply: self.branch_reply(&else_block.tail),
528            });
529        }
530        self.blocks[id as usize].branches = branches;
531    }
532
533    /// A branch's `reply` note — its outcome, but only when there is no actor.
534    /// With an actor the outcome is instead emitted as a `Return` message to it
535    /// (by the return-position walk of the branch tail), so a note would double
536    /// it.
537    fn branch_reply(&self, tail: &Expr) -> Option<String> {
538        if self.actor.is_some() || self.store_op_label(peel_paren(tail)).is_some() {
539            // A tail store write is already a state-write note.
540            None
541        } else {
542            branch_outcome(tail)
543        }
544    }
545
546    fn walk_match(
547        &mut self,
548        span: Span,
549        arms: &[MatchArm],
550        current_block: Option<BlockCtx>,
551        depth: u32,
552        ret: bool,
553    ) {
554        if depth >= MAX_BLOCK_DEPTH {
555            self.push_collapsed(span, current_block);
556            return;
557        }
558        let id = self.blocks.len() as u32;
559        self.blocks.push(AltBlock {
560            id,
561            kind: AltKind::Match,
562            branches: Vec::new(),
563            span,
564            parent: current_block.map(|c| c.id),
565            parent_branch: current_block.map(|c| c.branch),
566        });
567        let mut branches = Vec::with_capacity(arms.len());
568        for (arm_index, arm) in arms.iter().enumerate() {
569            let start = self.messages.len();
570            let branch_ctx = Some(BlockCtx {
571                id,
572                branch: arm_index as u32,
573            });
574            // The arm's tail — the value it yields — is its outcome, whether
575            // the body is a bare expression or a block. It is in the match's
576            // own return position, so a return-position arm replies to the actor.
577            let scope = self.bound.len();
578            self.bound.extend(
579                arm.pattern
580                    .bound_names()
581                    .into_iter()
582                    .map(|n| n.name.clone()),
583            );
584            let reply = match &arm.body {
585                MatchBody::Expr(e) => {
586                    self.walk_value(e, branch_ctx, depth + 1, None, ret);
587                    self.branch_reply(e)
588                }
589                MatchBody::Block(b) => {
590                    self.walk_block(b, branch_ctx, depth + 1, ret);
591                    self.branch_reply(&b.tail)
592                }
593            };
594            self.bound.truncate(scope);
595            branches.push(Branch {
596                label: pattern_summary(&arm.pattern),
597                message_ids: (start..self.messages.len()).collect(),
598                reply,
599            });
600        }
601        self.blocks[id as usize].branches = branches;
602    }
603
604    /// #2194: a `:=` in an agent handler is a state write. The checker only
605    /// admits `:=` against a `store Cell` (`bynk.cell.invalid_target`), so the
606    /// target needs no resolution here.
607    fn emit_assign(&mut self, a: &AssignStmt, current_block: Option<BlockCtx>) {
608        // Only a known store field — the checker guarantees this for checked
609        // code, but a file with errors could carry any `:=`.
610        if !self.store.contains_key(&a.target.name) {
611            return;
612        }
613        let label = format!(
614            "{} := {}",
615            a.target.name,
616            truncate(&bynk_fmt::expr_to_string(&a.value))
617        );
618        self.push_state_write(label, a.span, current_block);
619    }
620
621    /// #2194: emit a state write for a mutating store operation on one of the
622    /// agent's own fields (`items.put(id, …)`) — a bare-name receiver that is
623    /// a store field, not shadowed, calling an op the registry marks as
624    /// mutating for that field's kind. Returns whether `expr` was one.
625    fn emit_store_op(&mut self, expr: &Expr, current_block: Option<BlockCtx>) -> bool {
626        let Some(label) = self.store_op_label(expr) else {
627            return false;
628        };
629        self.push_state_write(label, expr.span, current_block);
630        true
631    }
632
633    /// The state-write label for `expr` when it is a mutating store operation
634    /// on one of the agent's own, unshadowed fields; `None` otherwise.
635    fn store_op_label(&self, expr: &Expr) -> Option<String> {
636        let w = bynk_check::store_write::store_write(&self.store, &self.bound, expr)?;
637        Some(format!(
638            "{}.{}",
639            w.field.name,
640            op_call_label(&w.method.name, w.args)
641        ))
642    }
643
644    fn push_state_write(&mut self, label: String, span: Span, current_block: Option<BlockCtx>) {
645        self.messages.push(Message {
646            from: ENTRY_ID,
647            to: ENTRY_ID,
648            kind: MessageKind::StateWrite,
649            label,
650            span,
651            block: current_block.map(|c| c.id),
652        });
653    }
654
655    fn push_collapsed(&mut self, span: Span, current_block: Option<BlockCtx>) {
656        let id = self.blocks.len() as u32;
657        self.blocks.push(AltBlock {
658            id,
659            kind: AltKind::Collapsed,
660            branches: Vec::new(),
661            span,
662            parent: current_block.map(|c| c.id),
663            parent_branch: current_block.map(|c| c.branch),
664        });
665    }
666
667    fn classify_call(&mut self, expr: &Expr, arrow: Arrow, current_block: Option<BlockCtx>) {
668        let Some((target, label)) = self.classify_target(expr) else {
669            // Local computation (commons/context-local fn, plain method,
670            // constructor) — folds into the entry activation, no message.
671            return;
672        };
673        let block = current_block.map(|c| c.id);
674        match arrow {
675            Arrow::FireAndForget => {
676                self.messages.push(Message {
677                    from: ENTRY_ID,
678                    to: target,
679                    kind: MessageKind::Send,
680                    label,
681                    span: expr.span,
682                    block,
683                });
684            }
685            Arrow::Awaited => {
686                self.messages.push(Message {
687                    from: ENTRY_ID,
688                    to: target,
689                    kind: MessageKind::Call,
690                    label,
691                    span: expr.span,
692                    block,
693                });
694                self.messages.push(Message {
695                    from: target,
696                    to: ENTRY_ID,
697                    kind: MessageKind::Return,
698                    label: String::new(),
699                    span: expr.span,
700                    block,
701                });
702            }
703        }
704    }
705
706    /// Resolve a call expression's target lifeline, per Decision A:
707    /// consumed Capability > Agent > consumed Context. Returns the
708    /// participant id and a rendered call label, or `None` when the call is
709    /// local computation.
710    ///
711    /// `TypeName.method(args)` (`Clock.now()`, a local capability op) and
712    /// `Consumed.service(args)` (a cross-context call) are syntactically
713    /// identical to an ordinary instance method call — the parser has no
714    /// static-vs-instance distinction at the receiver, so *every* qualified
715    /// call parses uniformly as `ExprKind::MethodCall` with an `Ident`
716    /// receiver (the resolver/checker make the static-vs-instance call from
717    /// context, which this classifier reimplements against `given`/`agents`/
718    /// `cross_context` instead). `Agent(key).method(args)` is the one
719    /// receiver shape that differs structurally: the receiver is itself an
720    /// `ExprKind::Call` (the agent construction), not a bare `Ident`.
721    fn classify_target(&mut self, expr: &Expr) -> Option<(u32, String)> {
722        match &expr.kind {
723            ExprKind::MethodCall {
724                receiver,
725                method,
726                args,
727                ..
728            } => match &receiver.kind {
729                ExprKind::Call { name, .. }
730                    if self.info.is_some_and(|i| i.agents.contains_key(&name.name)) =>
731                {
732                    let id = self.participant_id(ParticipantKind::Agent, &name.name, name.span);
733                    Some((id, call_label(&method.name, args)))
734                }
735                ExprKind::Ident(id) => self.classify_static(&id.name, &method.name, args, id.span),
736                _ => None,
737            },
738            _ => None,
739        }
740    }
741
742    /// Classify a bare qualified call `Name.method(args)`: a local
743    /// capability op when `Name` is in the handler's effective `given`,
744    /// otherwise a cross-context call when `Name` resolves as a consumed
745    /// context (or alias). Anything else (a static method on an ordinary
746    /// type, a sum-type variant constructor) is local — `None`.
747    fn classify_static(
748        &mut self,
749        name: &str,
750        method: &str,
751        args: &[Expr],
752        span: Span,
753    ) -> Option<(u32, String)> {
754        if self.given.iter().any(|c| c.key() == name) {
755            let id = self.participant_id(ParticipantKind::Capability, name, span);
756            return Some((id, call_label(method, args)));
757        }
758        self.classify_cross_context(name, method, args, span)
759    }
760
761    fn classify_cross_context(
762        &mut self,
763        prefix: &str,
764        method: &str,
765        args: &[Expr],
766        span: Span,
767    ) -> Option<(u32, String)> {
768        let ctx_name = self.info?.cross_context.resolve_prefix(prefix)?;
769        let id = self.participant_id(ParticipantKind::Context, &ctx_name, span);
770        Some((id, call_label(method, args)))
771    }
772}
773
774/// The longest rendered value a state-write note shows before eliding.
775const WRITE_LABEL_MAX: usize = 32;
776
777/// Shorten a rendered value to [`WRITE_LABEL_MAX`] characters, ending in `…`.
778fn truncate(text: &str) -> String {
779    if text.chars().count() <= WRITE_LABEL_MAX {
780        text.to_string()
781    } else {
782        let kept: String = text.chars().take(WRITE_LABEL_MAX - 1).collect();
783        format!("{kept}…")
784    }
785}
786
787/// A store operation's label (#2194, DECISION B): the first argument when it
788/// is a plain name or literal — usually the key (`put(id, …)`) or the item
789/// (`add(x)`) — and every other argument elided to `…`, since values and
790/// combiner lambdas are long and the note only has to say *what* is written.
791fn op_call_label(method: &str, args: &[Expr]) -> String {
792    let mut rendered = Vec::with_capacity(args.len());
793    for (i, arg) in args.iter().enumerate() {
794        let simple = matches!(
795            peel_paren(arg).kind,
796            ExprKind::Ident(_)
797                | ExprKind::IntLit { .. }
798                | ExprKind::StrLit(_)
799                | ExprKind::BoolLit(_)
800        );
801        if i == 0 && simple {
802            rendered.push(truncate(&bynk_fmt::expr_to_string(arg)));
803        } else {
804            rendered.push("…".to_string());
805            break;
806        }
807    }
808    format!("{method}({})", rendered.join(", "))
809}
810
811fn call_label(method: &str, args: &[Expr]) -> String {
812    let rendered: Vec<String> = args.iter().map(bynk_fmt::expr_to_string).collect();
813    format!("{method}({})", rendered.join(", "))
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819    use std::fs;
820    use std::path::PathBuf;
821
822    /// Same convention as `symbols.rs`'s `setup_project`: a temp dir unique to
823    /// the test name, populated with `(relative_path, contents)` files.
824    /// Self-contained fixtures only (never `examples/`) — `bynk-ide` is
825    /// published standalone, and a test reaching outside the crate would fail
826    /// a `cargo test` on the released tarball.
827    fn setup_project(test_name: &str, files: &[(&str, &str)]) -> PathBuf {
828        let root = std::env::temp_dir().join(format!(
829            "bynk-ide-sequence-test-{test_name}-{}",
830            std::process::id()
831        ));
832        let _ = fs::remove_dir_all(&root);
833        fs::create_dir_all(&root).expect("create test root");
834        for (rel, contents) in files {
835            let p = root.join(rel);
836            if let Some(parent) = p.parent() {
837                fs::create_dir_all(parent).expect("create parent");
838            }
839            fs::write(&p, contents).expect("write file");
840        }
841        root
842    }
843
844    fn parse_context(text: &str) -> Context {
845        let tokens = bynk_syntax::lexer::tokenize(text).expect("tokenize");
846        let (unit, errs) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text);
847        assert!(errs.is_empty(), "parse errors: {errs:?}");
848        match unit.expect("parsed unit") {
849            SourceUnit::Context(c) => c,
850            _ => panic!("expected a context"),
851        }
852    }
853
854    fn find_service<'a>(ctx: &'a Context, name: &str) -> &'a ServiceDecl {
855        ctx.items
856            .iter()
857            .find_map(|i| match i {
858                CommonsItem::Service(s) if s.name.name == name => Some(s),
859                _ => None,
860            })
861            .unwrap_or_else(|| panic!("service {name} not found"))
862    }
863
864    // -- Fixture 1: examples/rate-limiter's `GET /check/:client`, reproduced
865    // -- self-contained (not read from `examples/` — see `setup_project`).
866    // -- Capability (Clock) + Agent (Limiter) + a return-gating `if` whose
867    // -- branches call nothing lifeline-worthy — the regression fixture for
868    // -- the corrected AltBlock rule (see the plan's "Corrected extractor
869    // -- rule" note): the issue's own worked example renders this block.
870    const RATELIMIT_SRC: &str = r#"context ratelimit
871
872consumes bynk { Clock }
873
874type ClientId = String where NonEmpty
875
876type RateView = {
877  allowed:   Bool,
878  remaining: Int,
879  resetAt:   Int,
880}
881
882agent Limiter {
883  key client: ClientId
884
885  store count: Cell[Int]
886
887  on call hit(now: Int) -> Effect[RateView] {
888    let _ <- count.update((c) => c + 1)
889    RateView { allowed: count < 10, remaining: 10 - count, resetAt: now }
890  }
891}
892
893service api from http {
894  on GET("/check/:client") (client: ClientId) -> Effect[HttpResult[RateView]] by Visitor given Clock {
895    let now  <- Clock.now()
896    let view <- Limiter(client).hit(now.toEpochMillis())
897    if view.allowed {
898      Ok(view)
899    } else {
900      TooManyRequests("rate limit exceeded")
901    }
902  }
903}
904"#;
905
906    #[test]
907    fn rate_limiter_get_check_client_classifies_capability_and_agent_and_gates_the_return() {
908        let root = setup_project("ratelimit", &[("ratelimit.bynk", RATELIMIT_SRC)]);
909        let diag = crate::testkit::diagnose_project(&root);
910        let info = diag
911            .sequence_info
912            .get("ratelimit")
913            .expect("sequence_info entry for ratelimit");
914
915        let ctx = parse_context(RATELIMIT_SRC);
916        let svc = find_service(&ctx, "api");
917        let handler = &svc.handlers[0];
918
919        let model = sequence_model(
920            handler,
921            HandlerOwner::Service("api"),
922            &svc.default_given,
923            svc.default_by.as_ref(),
924            Some(info),
925        );
926
927        // The handler's `by Visitor` principal is the leftmost participant and
928        // originates the request; the entry box drops to the bare owner name
929        // (`api`) since the route now rides on the inbound message.
930        let kinds: Vec<(ParticipantKind, &str)> = model
931            .participants
932            .iter()
933            .map(|p| (p.kind, p.name.as_str()))
934            .collect();
935        assert_eq!(
936            kinds,
937            vec![
938                (ParticipantKind::Actor, "Visitor"),
939                (ParticipantKind::Entry, "api"),
940                (ParticipantKind::Capability, "Clock"),
941                (ParticipantKind::Agent, "Limiter"),
942            ]
943        );
944        let actor_id = model.participants[0].id;
945
946        // The initial inbound request: Visitor -> api, labelled by the route.
947        let req = &model.messages[0];
948        assert_eq!(req.kind, MessageKind::Call);
949        assert_eq!((req.from, req.to), (actor_id, ENTRY_ID));
950        assert_eq!(req.label, "GET /check/:client");
951
952        // now() + reply, hit(...) + reply, then a reply-to-actor per branch.
953        assert_eq!(
954            model.messages.len(),
955            7,
956            "request + Clock Call/Return + Limiter Call/Return + a reply-to-actor per branch"
957        );
958
959        assert_eq!(model.blocks.len(), 1);
960        assert_eq!(model.blocks[0].kind, AltKind::If);
961        assert_eq!(model.blocks[0].branches.len(), 2);
962        // With an actor, each branch carries no *note* (`reply` is None) — its
963        // outcome is instead a `Return` message back to the actor, which the
964        // `then`/`else` branches own via their `message_ids`.
965        let labels: Vec<&str> = model.blocks[0]
966            .branches
967            .iter()
968            .map(|b| b.label.as_str())
969            .collect();
970        assert_eq!(labels, vec!["view.allowed", "otherwise"]);
971        assert!(
972            model.blocks[0].branches.iter().all(|b| b.reply.is_none()),
973            "with an actor the outcome is a message, not a note"
974        );
975        let branch_replies: Vec<(MessageKind, u32, u32, &str)> = model.blocks[0]
976            .branches
977            .iter()
978            .flat_map(|b| &b.message_ids)
979            .map(|&i| {
980                let m = &model.messages[i];
981                (m.kind, m.from, m.to, m.label.as_str())
982            })
983            .collect();
984        assert_eq!(
985            branch_replies,
986            vec![
987                (MessageKind::Return, ENTRY_ID, actor_id, "Ok(view)"),
988                (
989                    MessageKind::Return,
990                    ENTRY_ID,
991                    actor_id,
992                    "TooManyRequests(\"rate limit exceeded\")",
993                ),
994            ],
995            "each branch replies its outcome to the actor"
996        );
997    }
998
999    // -- Fixture 2: a consumed-context call — boundary-stop (Decision C).
1000    const PLATFORM_SRC: &str = r#"context platform
1001
1002service Pinger {
1003  on call(n: Int) -> Effect[Int] {
1004    n
1005  }
1006}
1007"#;
1008    const CONSUMER_SRC: &str = r#"context consumer
1009
1010consumes platform
1011
1012service api {
1013  on call(n: Int) -> Effect[Int] {
1014    let v <- platform.Pinger(n)
1015    v
1016  }
1017}
1018"#;
1019
1020    #[test]
1021    fn cross_context_call_is_boundary_stop() {
1022        let root = setup_project(
1023            "crossctx",
1024            &[
1025                ("platform.bynk", PLATFORM_SRC),
1026                ("consumer.bynk", CONSUMER_SRC),
1027            ],
1028        );
1029        let diag = crate::testkit::diagnose_project(&root);
1030        let info = diag
1031            .sequence_info
1032            .get("consumer")
1033            .expect("sequence_info entry for consumer");
1034
1035        let ctx = parse_context(CONSUMER_SRC);
1036        let svc = find_service(&ctx, "api");
1037        let handler = &svc.handlers[0];
1038        let model = sequence_model(
1039            handler,
1040            HandlerOwner::Service("api"),
1041            &svc.default_given,
1042            svc.default_by.as_ref(),
1043            Some(info),
1044        );
1045
1046        assert_eq!(model.participants.len(), 2, "Entry + the consumed context");
1047        assert_eq!(model.participants[1].kind, ParticipantKind::Context);
1048        assert_eq!(model.participants[1].name, "platform");
1049        assert_eq!(
1050            model.messages.len(),
1051            2,
1052            "one Call + one Return — the consumed service's own body is never walked"
1053        );
1054        assert_eq!(model.messages[0].kind, MessageKind::Call);
1055        assert_eq!(model.messages[1].kind, MessageKind::Return);
1056    }
1057
1058    // -- Fixtures 3-5: fire-and-forget send, a degenerate (local-only)
1059    // -- handler, and 3-level-nested `if` past the depth budget.
1060    const MISC_SRC: &str = r#"context misc
1061
1062consumes bynk { Clock, Logger }
1063
1064fn double(x: Int) -> Int {
1065  x * 2
1066}
1067
1068service fireService {
1069  on call(n: Int) -> Effect[()] given Logger {
1070    ~> Logger.info("hi")
1071    Effect.pure(())
1072  }
1073}
1074
1075service localService {
1076  on call(n: Int) -> Effect[Int] {
1077    double(n)
1078  }
1079}
1080
1081service nestedService {
1082  on call(n: Int) -> Effect[Int] given Clock {
1083    let now <- Clock.now()
1084    if n > 0 {
1085      if n > 10 {
1086        if n > 100 {
1087          now.toEpochMillis()
1088        } else {
1089          1
1090        }
1091      } else {
1092        2
1093      }
1094    } else {
1095      3
1096    }
1097  }
1098}
1099"#;
1100
1101    fn misc_info(diag: &crate::ProjectDiagnostics) -> bynk_check::analysis::ContextSequenceInfo {
1102        diag.sequence_info
1103            .get("misc")
1104            .cloned()
1105            .expect("sequence_info entry for misc")
1106    }
1107
1108    #[test]
1109    fn fire_and_forget_send_has_no_paired_return() {
1110        let root = setup_project("misc-send", &[("misc.bynk", MISC_SRC)]);
1111        let diag = crate::testkit::diagnose_project(&root);
1112        let info = misc_info(&diag);
1113
1114        let ctx = parse_context(MISC_SRC);
1115        let svc = find_service(&ctx, "fireService");
1116        let handler = &svc.handlers[0];
1117        let model = sequence_model(
1118            handler,
1119            HandlerOwner::Service("fireService"),
1120            &svc.default_given,
1121            svc.default_by.as_ref(),
1122            Some(&info),
1123        );
1124
1125        assert_eq!(model.participants.len(), 2);
1126        assert_eq!(model.participants[1].kind, ParticipantKind::Capability);
1127        assert_eq!(model.participants[1].name, "Logger");
1128        assert_eq!(model.messages.len(), 1, "a Send has no paired Return");
1129        assert_eq!(model.messages[0].kind, MessageKind::Send);
1130    }
1131
1132    #[test]
1133    fn degenerate_handler_with_only_local_calls_has_no_lifelines() {
1134        let root = setup_project("misc-local", &[("misc.bynk", MISC_SRC)]);
1135        let diag = crate::testkit::diagnose_project(&root);
1136        let info = misc_info(&diag);
1137
1138        let ctx = parse_context(MISC_SRC);
1139        let svc = find_service(&ctx, "localService");
1140        let handler = &svc.handlers[0];
1141        let model = sequence_model(
1142            handler,
1143            HandlerOwner::Service("localService"),
1144            &svc.default_given,
1145            svc.default_by.as_ref(),
1146            Some(&info),
1147        );
1148
1149        assert_eq!(model.participants.len(), 1);
1150        assert_eq!(model.participants[0].kind, ParticipantKind::Entry);
1151        assert!(model.messages.is_empty());
1152        assert!(model.blocks.is_empty());
1153    }
1154
1155    #[test]
1156    fn nested_if_collapses_past_the_depth_budget() {
1157        let root = setup_project("misc-nested", &[("misc.bynk", MISC_SRC)]);
1158        let diag = crate::testkit::diagnose_project(&root);
1159        let info = misc_info(&diag);
1160
1161        let ctx = parse_context(MISC_SRC);
1162        let svc = find_service(&ctx, "nestedService");
1163        let handler = &svc.handlers[0];
1164        let model = sequence_model(
1165            handler,
1166            HandlerOwner::Service("nestedService"),
1167            &svc.default_given,
1168            svc.default_by.as_ref(),
1169            Some(&info),
1170        );
1171
1172        // Depth 0 (`n > 0`) and depth 1 (`n > 10`) render as real blocks;
1173        // depth 2 (`n > 100`) is past `MAX_BLOCK_DEPTH` and collapses instead
1174        // of expanding further — click-to-code still works (the collapsed
1175        // marker's span points at the whole collapsed `if`), the diagram
1176        // just doesn't recurse into it.
1177        let kinds: Vec<AltKind> = model.blocks.iter().map(|b| b.kind).collect();
1178        assert_eq!(kinds, vec![AltKind::If, AltKind::If, AltKind::Collapsed]);
1179        assert!(
1180            model.blocks[2].branches.is_empty(),
1181            "a Collapsed block carries no branches"
1182        );
1183        // Regression: each nested block must record which branch of its
1184        // parent it sits under, not just the parent id — both the middle
1185        // and innermost `if` are nested in their parent's *first* ("then")
1186        // branch, and a renderer needs that even when the branch itself
1187        // carries no messages of its own (only the nested block does).
1188        assert_eq!(model.blocks[0].parent, None);
1189        assert_eq!(model.blocks[0].parent_branch, None);
1190        assert_eq!(model.blocks[1].parent, Some(0));
1191        assert_eq!(model.blocks[1].parent_branch, Some(0));
1192        assert_eq!(model.blocks[2].parent, Some(1));
1193        assert_eq!(model.blocks[2].parent_branch, Some(0));
1194    }
1195
1196    // -- Fixture 6 (#861 review): a service declares a service-level `given`
1197    // -- default (v0.155) and the handler omits its own `given`, inheriting it.
1198    // -- The classifier walks a freshly-parsed AST that never ran
1199    // -- `inject_service_defaults`, so it must apply the fallback itself — or
1200    // -- the Clock capability lifeline is silently dropped.
1201    const SERVICE_GIVEN_SRC: &str = r#"context svcgiven
1202
1203consumes bynk { Clock }
1204
1205service api from http by Visitor given Clock {
1206  on GET("/now") () -> Effect[HttpResult[Int]] {
1207    let now <- Clock.now()
1208    Ok(now.toEpochMillis())
1209  }
1210}
1211"#;
1212
1213    #[test]
1214    fn service_level_given_default_is_inherited_by_a_handler_without_its_own() {
1215        let root = setup_project("svcgiven", &[("svcgiven.bynk", SERVICE_GIVEN_SRC)]);
1216        let diag = crate::testkit::diagnose_project(&root);
1217        let info = diag
1218            .sequence_info
1219            .get("svcgiven")
1220            .expect("sequence_info entry for svcgiven");
1221
1222        let ctx = parse_context(SERVICE_GIVEN_SRC);
1223        let svc = find_service(&ctx, "api");
1224        let handler = &svc.handlers[0];
1225        // Precondition: the freshly-parsed handler carries no `given` of its
1226        // own — it relies entirely on the service-level default.
1227        assert!(
1228            handler.given.is_empty(),
1229            "fixture handler must omit its own `given`"
1230        );
1231        assert_eq!(svc.default_given.len(), 1, "service-level `given Clock`");
1232        assert!(
1233            handler.by_clause.is_none() && svc.default_by.is_some(),
1234            "fixture handler must inherit the service-level `by Visitor`"
1235        );
1236
1237        let model = sequence_model(
1238            handler,
1239            HandlerOwner::Service("api"),
1240            &svc.default_given,
1241            svc.default_by.as_ref(),
1242            Some(info),
1243        );
1244
1245        let kinds: Vec<(ParticipantKind, &str)> = model
1246            .participants
1247            .iter()
1248            .map(|p| (p.kind, p.name.as_str()))
1249            .collect();
1250        assert_eq!(
1251            kinds,
1252            vec![
1253                // Both service-level defaults are inherited: `by Visitor` (the
1254                // actor) and `given Clock` (the capability lifeline).
1255                (ParticipantKind::Actor, "Visitor"),
1256                (ParticipantKind::Entry, "api"),
1257                (ParticipantKind::Capability, "Clock"),
1258            ],
1259            "Clock must classify via the inherited `given`, and Visitor via the \
1260             inherited `by` — with only `handler.given`/`handler.by_clause` both \
1261             would be dropped"
1262        );
1263
1264        // A non-branching handler tail still replies to the actor: the last
1265        // message is the `Ok(...)` return, entry -> Visitor.
1266        let actor_id = model.participants[0].id;
1267        let last = model.messages.last().expect("at least one message");
1268        assert_eq!(last.kind, MessageKind::Return);
1269        assert_eq!((last.from, last.to), (ENTRY_ID, actor_id));
1270        assert_eq!(last.label, "Ok(now.toEpochMillis())");
1271    }
1272
1273    // -- Fixture 7 (issue): branch outcomes + else-less-if shape. An else-less
1274    // -- `if` renders as an `opt` (one branch, not an empty second branch), and
1275    // -- a `match` captures each arm's yielded value as its reply — the content
1276    // -- that keeps a return-gating block from rendering as an empty box.
1277    const OUTCOME_SRC: &str = r#"context outcome
1278
1279consumes bynk { Logger }
1280
1281service guardSvc {
1282  on call(flag: Bool) -> Effect[()] given Logger {
1283    if flag {
1284      ~> Logger.info("hi")
1285    }
1286  }
1287}
1288
1289service routeSvc {
1290  on call(x: Int) -> Effect[Int] {
1291    match x {
1292      0 => 100
1293      _ => x
1294    }
1295  }
1296}
1297"#;
1298
1299    #[test]
1300    fn else_less_if_is_a_single_branch_opt_and_match_arms_capture_replies() {
1301        // `info` is `None` here: this fixture references only a capability
1302        // (`given Logger`) and control flow — no agent or cross-context call —
1303        // so classification needs no project table, and this stays a pure
1304        // parse-only test (the full project wouldn't type-check anyway).
1305        let ctx = parse_context(OUTCOME_SRC);
1306        let guard_svc = find_service(&ctx, "guardSvc");
1307        let route_svc = find_service(&ctx, "routeSvc");
1308
1309        // `guard`: an else-less `if` — one branch (an `opt`), labelled by its
1310        // condition. Its `then` carries the Send but its tail is unit, so it
1311        // has no reply of its own.
1312        let guard = &guard_svc.handlers[0];
1313        let gm = sequence_model(
1314            guard,
1315            HandlerOwner::Service("guardSvc"),
1316            &guard_svc.default_given,
1317            guard_svc.default_by.as_ref(),
1318            None,
1319        );
1320        assert_eq!(gm.blocks.len(), 1);
1321        assert_eq!(gm.blocks[0].kind, AltKind::If);
1322        assert_eq!(
1323            gm.blocks[0].branches.len(),
1324            1,
1325            "an else-less `if` renders as an `opt`, not an `alt` with an empty second branch"
1326        );
1327        assert_eq!(gm.blocks[0].branches[0].label, "flag");
1328        assert_eq!(
1329            gm.blocks[0].branches[0].reply, None,
1330            "a unit tail has no reply"
1331        );
1332        assert_eq!(
1333            gm.blocks[0].branches[0].message_ids.len(),
1334            1,
1335            "the `~>` Send"
1336        );
1337
1338        // `route`: a `match` whose arms yield distinct values — each captured
1339        // as that branch's reply.
1340        let route = &route_svc.handlers[0];
1341        let rm = sequence_model(
1342            route,
1343            HandlerOwner::Service("routeSvc"),
1344            &route_svc.default_given,
1345            route_svc.default_by.as_ref(),
1346            None,
1347        );
1348        assert_eq!(rm.blocks.len(), 1);
1349        assert_eq!(rm.blocks[0].kind, AltKind::Match);
1350        let replies: Vec<Option<&str>> = rm.blocks[0]
1351            .branches
1352            .iter()
1353            .map(|b| b.reply.as_deref())
1354            .collect();
1355        assert_eq!(replies, vec![Some("100"), Some("x")]);
1356    }
1357
1358    // -- #2194: an agent's *own* handler — state writes as notes.
1359    const ORDER_SRC: &str = r#"context orders
1360
1361type OrderStatus = enum { Pending, Placed, Cancelled }
1362type OrderError  = enum { AlreadyPlaced, AlreadyCancelled }
1363
1364agent Counter {
1365  key id: String
1366
1367  store total: Cell[Int]
1368
1369  on call bump() -> Effect[Int] {
1370    total := total + 1
1371    total
1372  }
1373}
1374
1375agent Order {
1376  key id: String
1377
1378  store status: Cell[OrderStatus] = Pending
1379  store items:  Map[String, Int]
1380  store tally:  Cell[Int]
1381
1382  on call place() -> Effect[Result[(), OrderError]] {
1383    match status {
1384      Pending => {
1385        status := Placed
1386        Ok(())
1387      }
1388      Placed    => Err(AlreadyPlaced)
1389      Cancelled => Err(AlreadyCancelled)
1390    }
1391  }
1392
1393  on call add(sku: String, qty: Int) -> Effect[Bool] {
1394    let n <- Counter(sku).bump()
1395    let _ <- items.put(sku, qty + n)
1396    let _ <- tally.update((t) => t + qty)
1397    let known <- items.contains(sku)
1398    known
1399  }
1400
1401  on call bumpTally(n: Int) -> Effect[()] {
1402    tally.update((t) => t + n)
1403  }
1404
1405  on call putIfSome(k: String, found: Option[Int]) -> Effect[()] {
1406    match found {
1407      Some(v) => items.put(k, v),
1408      None    => Effect.pure(()),
1409    }
1410  }
1411}
1412"#;
1413
1414    fn find_agent<'a>(ctx: &'a Context, name: &str) -> &'a AgentDecl {
1415        ctx.items
1416            .iter()
1417            .find_map(|i| match i {
1418                CommonsItem::Agent(a) if a.name.name == name => Some(a),
1419                _ => None,
1420            })
1421            .unwrap_or_else(|| panic!("agent {name} not found"))
1422    }
1423
1424    fn agent_model(test_name: &str, handler_src: &str, agent: &str, method: &str) -> SequenceModel {
1425        let root = setup_project(test_name, &[("orders.bynk", ORDER_SRC)]);
1426        let diag = crate::testkit::diagnose_project(&root);
1427        let info = diag
1428            .sequence_info
1429            .get("orders")
1430            .expect("sequence_info for orders");
1431        let ctx = parse_context(handler_src);
1432        let a = find_agent(&ctx, agent);
1433        let h = a
1434            .handlers
1435            .iter()
1436            .find(|h| h.method_name.as_ref().is_some_and(|m| m.name == method))
1437            .expect("handler");
1438        sequence_model(h, HandlerOwner::Agent(agent), &[], None, Some(info))
1439    }
1440
1441    fn writes(model: &SequenceModel) -> Vec<(&str, Option<u32>)> {
1442        model
1443            .messages
1444            .iter()
1445            .filter(|m| m.kind == MessageKind::StateWrite)
1446            .map(|m| (m.label.as_str(), m.block))
1447            .collect()
1448    }
1449
1450    #[test]
1451    fn an_assignment_in_an_agent_handler_is_a_state_write_inside_its_branch() {
1452        let model = agent_model("assign", ORDER_SRC, "Order", "place");
1453        assert_eq!(writes(&model), [("status := Placed", Some(0))]);
1454        let w = &model.messages[0];
1455        assert_eq!((w.from, w.to), (ENTRY_ID, ENTRY_ID));
1456        // The write is a member of the `Pending` branch, ahead of its reply.
1457        let pending = &model.blocks[0].branches[0];
1458        assert_eq!(pending.label, "Pending");
1459        assert_eq!(pending.message_ids, [0]);
1460        assert_eq!(pending.reply.as_deref(), Some("Ok(())"));
1461        // The refusing branches write nothing.
1462        assert!(model.blocks[0].branches[1].message_ids.is_empty());
1463    }
1464
1465    #[test]
1466    fn mutating_store_ops_are_state_writes_in_order_and_reads_are_not() {
1467        let model = agent_model("storeops", ORDER_SRC, "Order", "add");
1468        let kinds: Vec<(MessageKind, &str)> = model
1469            .messages
1470            .iter()
1471            .map(|m| (m.kind, m.label.as_str()))
1472            .collect();
1473        assert_eq!(
1474            kinds,
1475            [
1476                (MessageKind::Call, "bump()"),
1477                (MessageKind::Return, ""),
1478                (MessageKind::StateWrite, "items.put(sku, …)"),
1479                (MessageKind::StateWrite, "tally.update(…)"),
1480            ],
1481            "the agent call keeps its lifeline; `contains` is a read and shows nothing"
1482        );
1483    }
1484
1485    #[test]
1486    fn an_assignment_in_another_agent_is_still_a_write_of_its_own() {
1487        let model = agent_model("counter", ORDER_SRC, "Counter", "bump");
1488        assert_eq!(writes(&model), [("total := total + 1", None)]);
1489    }
1490
1491    #[test]
1492    fn a_store_field_shadowed_by_a_parameter_or_let_is_not_a_store_op() {
1493        // Parsed only — the store fields come from ORDER_SRC's checked `Order`.
1494        let shadowing = r#"context orders
1495agent Order {
1496  key id: String
1497  store items: Map[String, Int]
1498  on call byParam(items: List[Int]) -> Effect[()] {
1499    let _ <- items.put("a", 1)
1500    ()
1501  }
1502  on call byLet() -> Effect[()] {
1503    let tally = Thing()
1504    let _ <- tally.update((t) => t)
1505    let _ <- items.remove("a")
1506    ()
1507  }
1508}
1509"#;
1510        assert!(writes(&agent_model("shadow-param", shadowing, "Order", "byParam")).is_empty());
1511        assert_eq!(
1512            writes(&agent_model("shadow-let", shadowing, "Order", "byLet")),
1513            [("items.remove(\"a\")", None)],
1514            "only the unshadowed field's write is a note"
1515        );
1516    }
1517
1518    #[test]
1519    fn a_long_right_hand_side_is_truncated() {
1520        assert_eq!(truncate("short"), "short");
1521        let long = "a".repeat(40);
1522        let t = truncate(&long);
1523        assert_eq!(t.chars().count(), WRITE_LABEL_MAX);
1524        assert!(t.ends_with('…'));
1525    }
1526
1527    #[test]
1528    fn a_service_handler_emits_no_state_writes() {
1529        let root = setup_project("svc-nowrites", &[("ratelimit.bynk", RATELIMIT_SRC)]);
1530        let diag = crate::testkit::diagnose_project(&root);
1531        let info = diag.sequence_info.get("ratelimit").expect("info");
1532        let ctx = parse_context(RATELIMIT_SRC);
1533        let svc = find_service(&ctx, "api");
1534        let model = sequence_model(
1535            &svc.handlers[0],
1536            HandlerOwner::Service("api"),
1537            &svc.default_given,
1538            svc.default_by.as_ref(),
1539            Some(info),
1540        );
1541        assert!(writes(&model).is_empty());
1542    }
1543
1544    #[test]
1545    fn the_rate_limiters_own_hit_handler_shows_its_cell_update() {
1546        let root = setup_project("limiter-hit", &[("ratelimit.bynk", RATELIMIT_SRC)]);
1547        let diag = crate::testkit::diagnose_project(&root);
1548        let info = diag.sequence_info.get("ratelimit").expect("info");
1549        let ctx = parse_context(RATELIMIT_SRC);
1550        let a = find_agent(&ctx, "Limiter");
1551        let model = sequence_model(
1552            &a.handlers[0],
1553            HandlerOwner::Agent("Limiter"),
1554            &[],
1555            None,
1556            Some(info),
1557        );
1558        assert_eq!(writes(&model), [("count.update(…)", None)]);
1559    }
1560
1561    #[test]
1562    fn a_store_write_in_tail_position_is_a_note_and_not_also_a_reply() {
1563        // `Wallet.topUp`'s shape: the write is the handler's last expression.
1564        let model = agent_model("tail-write", ORDER_SRC, "Order", "bumpTally");
1565        assert_eq!(writes(&model), [("tally.update(…)", None)]);
1566        assert_eq!(model.messages.len(), 1, "no reply duplicating the write");
1567    }
1568
1569    #[test]
1570    fn a_store_write_as_an_expression_match_arm_is_a_note_in_that_arm() {
1571        // `Ledger.open`'s shape: `Ok(acct) => balances.put(acct, n)`.
1572        let model = agent_model("arm-write", ORDER_SRC, "Order", "putIfSome");
1573        assert_eq!(writes(&model), [("items.put(k, …)", Some(0))]);
1574        let arms = &model.blocks[0].branches;
1575        assert_eq!(arms[0].message_ids, [0]);
1576        assert_eq!(
1577            arms[0].reply, None,
1578            "the write is the arm's note, not its reply"
1579        );
1580        assert!(arms[1].message_ids.is_empty());
1581        assert_eq!(arms[1].reply.as_deref(), Some("Effect.pure(())"));
1582    }
1583
1584    #[test]
1585    fn an_assignment_to_a_name_that_is_not_a_store_field_is_not_labelled() {
1586        // Parsed only: a file with errors can carry a `:=` the checker refuses.
1587        let src = r#"context orders
1588agent Order {
1589  key id: String
1590  store items: Map[String, Int]
1591  on call bad() -> Effect[()] {
1592    nope := 1
1593    ()
1594  }
1595}
1596"#;
1597        assert!(writes(&agent_model("bad-assign", src, "Order", "bad")).is_empty());
1598    }
1599}