Skip to main content

bynk_ide/
wire_contract.rs

1//! #855: the wire-contract peek — the model behind "hover a service handler"
2//! and "Show Wire Contract". Renders `bynk_check::wire::WireModel` (the
3//! same IR `bynk-emit`'s codec generation consumes) narrowed to *one*
4//! handler, plus the facts the IR does not carry: the request envelope
5//! shape, the cross-context contract form + hash (`on call` only), and the
6//! HTTP response set a route can actually answer with.
7//!
8//! **Handler resolution** mirrors `bynk-lsp/src/sequence_request.rs`'s
9//! `sequence_model_at`: re-parse the committed snapshot with
10//! `parse_unit_with_recovery`, find the `Handler` whose span contains the
11//! cursor. The re-parsed `Handler` supplies spans/body; the **retained**
12//! [`ContextBoundaryInfo`] supplies the type table — a single-file re-parse
13//! cannot see a `uses` target's types, which is the whole reason that table
14//! is retained project-wide (Phase 3).
15//!
16//! **Provenance.** [`bynk_check::wire::Provenance`] distinguishes a type this
17//! module owns from one reached through another unit. `ContextBoundaryInfo`'s
18//! `types` table is `combined_types_for(unit, …)` — *this* unit's own type
19//! namespace, own declarations plus its `uses` targets' — the exact table
20//! `own_contract_hashes` (`bynk-emit/src/project.rs`) hashes a service's
21//! contract through. Every name resolvable through it is therefore this
22//! unit's *own* view of its boundary: `Provenance::Owned` throughout. A
23//! genuinely `Consumed` type (reached through a `consumes <other context>`,
24//! not a `uses <commons>`) never appears here — that is a *caller's* view of
25//! another unit's boundary, not this handler's own, and is out of scope for
26//! a peek that answers "what does this handler send and receive".
27//!
28//! **Scope.** Service handlers only. An agent's `on call` handler crosses a
29//! Durable-Object RPC boundary too, but the issue's worked examples (an HTTP
30//! route, a cross-context `on call`) are both service handlers, and
31//! `ContextBoundaryInfo` does not retain agents' own handler bodies in a form
32//! this module needs. Left for a later slice if an agent peek is wanted.
33
34use std::collections::HashMap;
35use std::path::PathBuf;
36
37use bynk_check::analysis::ContextBoundaryInfo;
38use bynk_check::checker::{Ty, TyId, Types};
39use bynk_check::contract;
40use bynk_check::resolver::CrossContextService;
41use bynk_check::wire::{self, WireModel, WireRef};
42use bynk_syntax::ast::*;
43use bynk_syntax::span::Span;
44
45const HTTP_RESULT: &str = bynk_check::builtin_names::types::HTTP_RESULT;
46
47/// Which protocol hosts the hovered handler — the header facts
48/// `discriminator` (`bynk-ide/src/sequence.rs`) also renders, kept here as
49/// their own IDE-shaped type rather than re-exporting [`HandlerKind`]
50/// directly, so a future field this peek needs (that `HandlerKind` has no
51/// room for) is a local addition, not an upstream one.
52#[derive(Debug, Clone, PartialEq)]
53pub enum BoundaryKind {
54    Http { method: HttpMethod, path: String },
55    Call,
56    Cron { expr: String },
57    Message,
58    Open,
59    Close,
60    Event,
61}
62
63impl BoundaryKind {
64    fn from_handler(h: &Handler) -> Self {
65        match &h.kind {
66            HandlerKind::Http { method, path } => BoundaryKind::Http {
67                method: *method,
68                path: path.clone(),
69            },
70            HandlerKind::Call => BoundaryKind::Call,
71            HandlerKind::Cron { expr } => BoundaryKind::Cron { expr: expr.clone() },
72            HandlerKind::Message => BoundaryKind::Message,
73            HandlerKind::Open => BoundaryKind::Open,
74            HandlerKind::Close => BoundaryKind::Close,
75            HandlerKind::Event => BoundaryKind::Event,
76        }
77    }
78}
79
80/// The request shape a handler's caller sends. **Three cases, not two** —
81/// `bynk-emit/src/emitter/workers_entry.rs`'s `h.params.len() == 1` branch is
82/// the *only* bare-value case; everything else, including **zero** params,
83/// takes the object branch. Rendering only `Bare`/`Keyed` would misstate a
84/// zero-arg service as accepting an (empty) object body it never inspects,
85/// when what actually happens is the body is not read at all.
86#[derive(Debug, Clone)]
87pub enum Envelope {
88    /// No params: the request body is not read.
89    Empty,
90    /// Exactly one param: the request body **is** the value of `param`,
91    /// with no wrapping object/key.
92    Bare { param: String, shape: WireRef },
93    /// Two or more params: an object keyed by parameter name, in
94    /// declaration order.
95    Keyed { params: Vec<(String, WireRef)> },
96}
97
98/// The canonical form + hash of an `on call` handler's contract — the same
99/// projection `bynk-emit/src/project.rs`'s `own_contract_hashes` builds, so
100/// the hash this peek shows is provably the hash the emitted
101/// `X-Bynk-Contract` constant stamps.
102#[derive(Debug, Clone)]
103pub struct ContractForm {
104    pub normal_form: String,
105    pub hash: String,
106}
107
108/// Why a response was reachable — so a renderer can tell an author-declared
109/// outcome from one the boundary injects on their behalf without their
110/// having written a line for it.
111#[derive(Debug, Clone, PartialEq)]
112pub enum ResponseOrigin {
113    /// The handler's declared return type, `Effect[HttpResult[T]]` stripped
114    /// to its `Ok`/200 case.
115    DeclaredSuccess,
116    /// A variant literally constructed somewhere in the handler body, at
117    /// this span.
118    Constructed { span: Span },
119    /// A response the body never names — injected by the boundary itself.
120    BoundaryImplicit { why: &'static str },
121}
122
123/// One reachable HTTP outcome.
124#[derive(Debug, Clone, PartialEq)]
125pub struct HttpResponse {
126    pub status: u16,
127    /// The `HttpResult` variant name (`"Ok"`, `"TooManyRequests"`), or a
128    /// representative `BoundaryError` `kind` for a boundary-implicit
129    /// response with no variant of its own (`"StructuralMismatch"`).
130    pub variant: String,
131    pub origin: ResponseOrigin,
132}
133
134/// Why there is no cross-context contract to show for this handler.
135/// Rendered on the panel only — hover stays quiet about an absence (Part 4).
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum NoCrossContextReason {
138    /// Not an `on call` handler — HTTP/cron/message/websocket/event
139    /// handlers are never reached by another context's call.
140    NotACallHandler,
141    /// The project has only this one context/adapter — there is no *other*
142    /// context that could call in, so the question does not arise.
143    SingleContext,
144}
145
146/// The wire contract of one handler: its request envelope, its reachable
147/// response set (HTTP handlers only), its cross-context contract form + hash
148/// (`on call` only), and the boundary type shapes both reference.
149#[derive(Debug, Clone)]
150pub struct WireContractModel {
151    pub unit: String,
152    pub service: String,
153    pub kind: BoundaryKind,
154    pub handler_span: Span,
155    /// 1-indexed line of `handler_span`'s start, against the text this
156    /// model was built from.
157    pub handler_line: usize,
158    pub envelope: Envelope,
159    /// `on call` only; `None` whenever [`Self::no_cross_context`] is `Some`.
160    pub contract: Option<ContractForm>,
161    /// The boundary types this handler's envelope + responses reference,
162    /// resolved through the retained [`ContextBoundaryInfo`] — the same IR
163    /// `bynk-emit`'s codec generation renders.
164    pub boundary: WireModel,
165    /// Declaration span for every named type in `boundary.types`, keyed by
166    /// name — click-to-code for the panel's per-type blocks.
167    pub type_sites: HashMap<String, Span>,
168    /// Empty for a non-HTTP handler.
169    pub responses: Vec<HttpResponse>,
170    pub no_cross_context: Option<NoCrossContextReason>,
171}
172
173/// How many **real** contexts/adapters a project has — the input
174/// [`wire_contract_at`]/[`wire_contract_for_service`]'s `context_count`
175/// wants, deciding [`NoCrossContextReason::SingleContext`].
176///
177/// **Must** be computed this way, not as a bare `boundary_info.len()`:
178/// `boundary_info` (mirroring `sequence_info`, #846) retains an entry for
179/// every `UnitKind::Context | UnitKind::Adapter`, which includes the
180/// synthetic toolchain-injected `bynk` capability-surface unit — so a
181/// project with nothing but `consumes bynk { Clock }` would count *two*
182/// "contexts" and `SingleContext` would almost never fire. `unit_sources`
183/// (ADR 0095) already excludes synthetic units for exactly this reason
184/// (document links, consumed-context navigation); intersecting against it
185/// reuses that existing filter rather than a bespoke `name == "bynk"` check.
186///
187/// A free function (not buried in a caller's own helper) precisely so
188/// `bynk-lsp`'s `bynk/wireContract` handler calls this — the one place the
189/// filter is encoded — instead of re-deriving `boundary_info.len()` from
190/// the plan's literal parameter name and reintroducing the bug.
191pub fn real_context_count(
192    boundary_info: &HashMap<String, ContextBoundaryInfo>,
193    unit_sources: &HashMap<String, Vec<PathBuf>>,
194) -> usize {
195    boundary_info
196        .keys()
197        .filter(|k| unit_sources.contains_key(k.as_str()))
198        .count()
199}
200
201/// Locate the `Handler` enclosing `offset` in `text` and build its wire
202/// contract. Mirrors `sequence_request::sequence_model_at`'s re-parse
203/// convention. `info` is the owning unit's retained boundary table (Phase
204/// 3); `expr_types` is that same file's checked expression types (empty for
205/// a file with errors — the `Ok`-overload disambiguation then falls back to
206/// the declared return type, see `ResponseWalk::is_http_result_expr` below).
207///
208/// `context_count` is how many **real** contexts/adapters the project has —
209/// build it with [`real_context_count`], not a bare `boundary_info.len()`
210/// (see that function's doc for why the difference matters).
211///
212/// Scoped to service handlers (see the module doc); an agent handler at
213/// `offset` answers `None`.
214pub fn wire_contract_at(
215    unit: &str,
216    text: &str,
217    offset: usize,
218    info: &ContextBoundaryInfo,
219    expr_types: &[(Span, TyId)],
220    tys: &Types,
221    context_count: usize,
222) -> Option<WireContractModel> {
223    let tokens = bynk_syntax::lexer::tokenize(text).ok()?;
224    let (parsed, _errs) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text);
225    let items: &[CommonsItem] = match parsed.as_ref()? {
226        SourceUnit::Context(c) => &c.items,
227        SourceUnit::Adapter(a) => &a.items,
228        SourceUnit::Commons(_) | SourceUnit::Suite(_) => return None,
229    };
230    for item in items {
231        if let CommonsItem::Service(s) = item
232            && let Some(h) = handler_at(&s.handlers, offset)
233        {
234            return wire_contract_for_service(
235                unit,
236                text,
237                &s.name.name,
238                h,
239                info,
240                expr_types,
241                tys,
242                context_count,
243            );
244        }
245    }
246    None
247}
248
249fn handler_at(handlers: &[Handler], offset: usize) -> Option<&Handler> {
250    handlers
251        .iter()
252        .find(|h| h.span.start <= offset && offset < h.span.end)
253}
254
255/// Build the wire contract for one already-located handler. The pure half of
256/// [`wire_contract_at`] — the part that never touches the offset/re-parse
257/// machinery — split out the same way `sequence_model_at` delegates to
258/// `sequence::sequence_model`.
259///
260/// `service_name` must name an entry in `info.services` (the owning unit's
261/// retained service table) — the handler is spliced into a clone of that
262/// declaration (a synthetic single-handler service) so
263/// `bynk_check::wire::collect_boundary_types` narrows its walk to exactly
264/// this handler's params/return, not the whole service's. `None` if the
265/// service is not (yet) in the retained table — a live-buffer/committed-round
266/// mismatch (the same class of staleness `sequence_model_at` accepts for
267/// `sequence_info`).
268#[allow(clippy::too_many_arguments)]
269pub fn wire_contract_for_service(
270    unit: &str,
271    text: &str,
272    service_name: &str,
273    handler: &Handler,
274    info: &ContextBoundaryInfo,
275    expr_types: &[(Span, TyId)],
276    tys: &Types,
277    context_count: usize,
278) -> Option<WireContractModel> {
279    let real = info.services.get(service_name)?;
280    let mut synthetic = real.clone();
281    synthetic.handlers = vec![handler.clone()];
282    let services: HashMap<String, ServiceDecl> =
283        HashMap::from([(service_name.to_string(), synthetic)]);
284    let agents: HashMap<String, AgentDecl> = HashMap::new();
285
286    let boundary_names = wire::collect_boundary_types(&info.types, &services, &agents);
287    let insts =
288        wire::collect_generic_instantiations(&services, &agents, &boundary_names, &info.types);
289    // Every name reachable through `info.types` is this unit's own view of
290    // its boundary — see the module doc's Provenance note.
291    let boundary = wire::boundary_model(&boundary_names, &info.types, insts, |_| {
292        wire::Provenance::Owned
293    });
294
295    let type_sites: HashMap<String, Span> = boundary_names
296        .iter()
297        .filter_map(|n| info.types.get(n).map(|d| (n.clone(), d.span)))
298        .collect();
299
300    let envelope = envelope_for(handler, &info.types);
301    let kind = BoundaryKind::from_handler(handler);
302
303    let no_cross_context = if context_count <= 1 {
304        Some(NoCrossContextReason::SingleContext)
305    } else if handler.kind != HandlerKind::Call {
306        Some(NoCrossContextReason::NotACallHandler)
307    } else {
308        None
309    };
310    let contract = if no_cross_context.is_none() {
311        Some(contract_form(service_name, handler, &info.types))
312    } else {
313        None
314    };
315
316    let responses = if matches!(kind, BoundaryKind::Http { .. }) {
317        http_responses(handler, expr_types, tys)
318    } else {
319        Vec::new()
320    };
321
322    Some(WireContractModel {
323        unit: unit.to_string(),
324        service: service_name.to_string(),
325        kind,
326        handler_span: handler.span,
327        handler_line: line_of(text, handler.span.start),
328        envelope,
329        contract,
330        boundary,
331        type_sites,
332        responses,
333        no_cross_context,
334    })
335}
336
337/// 1-indexed line number of `offset` in `text`.
338fn line_of(text: &str, offset: usize) -> usize {
339    text.get(..offset).unwrap_or(text).matches('\n').count() + 1
340}
341
342fn envelope_for(handler: &Handler, types: &HashMap<String, std::sync::Arc<TypeDecl>>) -> Envelope {
343    match handler.params.as_slice() {
344        [] => Envelope::Empty,
345        [p] => Envelope::Bare {
346            param: p.name.name.clone(),
347            shape: wire::wire_ref(&p.type_ref, types),
348        },
349        params => Envelope::Keyed {
350            params: params
351                .iter()
352                .map(|p| (p.name.name.clone(), wire::wire_ref(&p.type_ref, types)))
353                .collect(),
354        },
355    }
356}
357
358/// The same `CrossContextService` projection `own_contract_hashes`
359/// (`bynk-emit/src/project.rs`) builds, canonicalised through the same
360/// `info.types` table — so the hash this peek shows is provably the hash
361/// the emitted `X-Bynk-Contract` constant stamps.
362fn contract_form(
363    service_name: &str,
364    handler: &Handler,
365    types: &HashMap<String, std::sync::Arc<TypeDecl>>,
366) -> ContractForm {
367    let svc = CrossContextService {
368        name: service_name.to_string(),
369        params: handler
370            .params
371            .iter()
372            .map(|p| (p.name.name.clone(), p.type_ref.clone()))
373            .collect(),
374        return_type: handler.return_type.clone(),
375        span: handler.span,
376    };
377    let normal_form = contract::service_normal_form(&svc, types);
378    let hash = contract::contract_hash(&normal_form);
379    ContractForm { normal_form, hash }
380}
381
382/// The reachable HTTP response set: the declared success case, every
383/// variant literally constructed in the body, and the boundary-implicit
384/// cases the body never names (400 on any param, 404 on an `Option?`
385/// short-circuit — ADR 0177). 401 on a caller binding is deferred (plan
386/// risk 8; needs actor/`by` resolution this phase does not do).
387fn http_responses(
388    handler: &Handler,
389    expr_types: &[(Span, TyId)],
390    tys: &Types,
391) -> Vec<HttpResponse> {
392    let declared_is_http_result =
393        matches!(strip_effect(&handler.return_type), TypeRef::HttpResult(..));
394
395    let mut out = Vec::new();
396    if declared_is_http_result {
397        out.push(HttpResponse {
398            status: 200,
399            variant: "Ok".to_string(),
400            origin: ResponseOrigin::DeclaredSuccess,
401        });
402    }
403
404    let mut walk = ResponseWalk {
405        expr_types,
406        tys,
407        declared_is_http_result,
408        seen: out.iter().map(|r| r.variant.clone()).collect(),
409        saw_option_question: false,
410        out: Vec::new(),
411    };
412    walk.walk_block(&handler.body);
413    out.extend(walk.out);
414
415    if !handler.params.is_empty() {
416        out.push(HttpResponse {
417            status: 400,
418            variant: "StructuralMismatch".to_string(),
419            origin: ResponseOrigin::BoundaryImplicit {
420                why: "every param is structurally re-validated on the way in; a malformed or \
421                      refinement-violating value fails closed with a 400 the handler body \
422                      never names",
423            },
424        });
425    }
426    if walk.saw_option_question {
427        out.push(HttpResponse {
428            status: 404,
429            variant: "NotFound".to_string(),
430            origin: ResponseOrigin::BoundaryImplicit {
431                why: "an `Option?` short-circuits to 404 on `None` (ADR 0177)",
432            },
433        });
434    }
435    out
436}
437
438/// Strip `Effect[_]` to expose the inner type — mirrors
439/// `bynk-emit/src/emitter/workers_entry.rs`'s `http_result_inner`, one level
440/// short of unwrapping `HttpResult` itself (the caller checks that).
441fn strip_effect(t: &TypeRef) -> &TypeRef {
442    match t {
443        TypeRef::Effect(inner, _) => inner.as_ref(),
444        other => other,
445    }
446}
447
448/// The handler-body expression walk finding every literally-constructed
449/// `HttpResult` variant, plus whether an `Option?` short-circuit is present
450/// (the boundary-implicit 404). Structured after `bynk-ide/src/sequence.rs`'s
451/// `Builder` — a small piece of walk state threaded through recursive
452/// `walk_*` methods — but this walk is a *full* expression descent (via
453/// `bynk_syntax::ast::expr_children`), not `sequence.rs`'s statement-level
454/// control-flow-only walk: a response can be constructed anywhere in an
455/// expression tree (a call argument, a record field), not only in tail
456/// position.
457struct ResponseWalk<'a> {
458    expr_types: &'a [(Span, TyId)],
459    /// T3.6b (R4.1): the table `expr_types`' ids resolve against.
460    tys: &'a Types,
461    /// #855 risk 7 ("the `Ok` overload"): the declared-return-type fallback
462    /// used whenever `expr_types` has no entry for a span (a file with
463    /// errors, ADR 0063's clean-file ceiling) — mirrors
464    /// `bynk-emit/src/emitter/lower.rs`'s own `Ok`/`HttpResult` overload
465    /// disambiguation, which has the identical ambiguity and the identical
466    /// (checker-backed, so never actually degraded there) resolution.
467    /// Applied to `Ok` and bare `Call` (see `is_http_result_expr` below) but
468    /// **not** a bare `Ident` (see `is_http_result_ident` below), which has
469    /// no concrete-shaped signal to fall back onto and would misreport an
470    /// ordinary local whose name collides with a variant name.
471    declared_is_http_result: bool,
472    seen: std::collections::HashSet<String>,
473    saw_option_question: bool,
474    out: Vec<HttpResponse>,
475}
476
477impl<'a> ResponseWalk<'a> {
478    fn expr_ty(&self, span: Span) -> Option<std::sync::Arc<Ty>> {
479        self.expr_types
480            .iter()
481            .find(|(s, _)| *s == span)
482            .map(|(_, t)| self.tys.get(*t))
483    }
484
485    /// Whether `span`'s expression checked as `HttpResult[_]` — the same
486    /// disambiguation `lower.rs:720-731` performs for the `Ok` overload,
487    /// degrading to the declared-return heuristic when the checker recorded
488    /// nothing (see `declared_is_http_result`'s doc). Used for `Ok` and
489    /// `Call`: a bare `Call { name, .. }` already carries a concrete
490    /// variant-shaped name (`TooManyRequests(...)`), so the same fallback
491    /// is low-risk there. **Not** used for a bare `Ident` — see
492    /// `is_http_result_ident` below.
493    fn is_http_result_expr(&self, span: Span) -> bool {
494        match self.expr_ty(span).as_deref() {
495            Some(Ty::HttpResult(_)) => true,
496            Some(_) => false,
497            None => self.declared_is_http_result,
498        }
499    }
500
501    /// The stricter check for a bare `Ident` — never falls back to the
502    /// declared-return heuristic. `HTTP_VARIANTS` includes names an author
503    /// can plausibly bind as an ordinary local (`Found`, `Gone`, `Conflict`,
504    /// `Accepted`, `Created`, `Raw`, `Streaming`): with the same fallback as
505    /// `is_http_result_expr`, a file with errors and a `let found = …;
506    /// found` tail on an `HttpResult`-returning handler would misreport a
507    /// 302 that was never constructed. `Ok`/`Call` need the fallback because
508    /// degrading them means answering nothing at all for the *one*
509    /// expression the plan's risk 7 is about; a bare identifier read has no
510    /// such asymmetry — unknown stays unknown.
511    fn is_http_result_ident(&self, span: Span) -> bool {
512        matches!(self.expr_ty(span).as_deref(), Some(Ty::HttpResult(_)))
513    }
514
515    fn push(&mut self, variant: HttpVariant, span: Span) {
516        if self.seen.insert(variant.name.to_string()) {
517            self.out.push(HttpResponse {
518                status: variant.status,
519                variant: variant.name.to_string(),
520                origin: ResponseOrigin::Constructed { span },
521            });
522        }
523    }
524
525    fn walk_block(&mut self, b: &Block) {
526        for s in &b.statements {
527            let mut exprs = Vec::new();
528            statement_exprs(s, &mut exprs);
529            for e in exprs {
530                self.walk_expr(e);
531            }
532        }
533        self.walk_expr(&b.tail);
534    }
535
536    fn walk_expr(&mut self, e: &Expr) {
537        self.classify(e);
538        for child in expr_children(e) {
539            self.walk_expr(child);
540        }
541    }
542
543    /// The four recognition shapes `lower.rs` renders through
544    /// (`HttpResult.Variant(args)`/`HttpResult.Variant` qualified forms at
545    /// `:1417`/`:4149`, the bare `Ident`/`Call` forms disambiguated by
546    /// checker type at `:3781`/`:3834`), plus the `Ok` overload
547    /// (`:720-731`) and the `?`-on-`Option` boundary-implicit 404
548    /// (`:764-768`, ADR 0177).
549    fn classify(&mut self, e: &Expr) {
550        match &e.kind {
551            ExprKind::MethodCall {
552                receiver, method, ..
553            } => {
554                if let ExprKind::Ident(id) = &receiver.kind
555                    && id.name == HTTP_RESULT
556                    && let Some(v) = http_variant(&method.name)
557                {
558                    self.push(v, e.span);
559                }
560            }
561            ExprKind::FieldAccess { receiver, field } => {
562                if let ExprKind::Ident(id) = &receiver.kind
563                    && id.name == HTTP_RESULT
564                    && let Some(v) = http_variant(&field.name)
565                {
566                    self.push(v, e.span);
567                }
568            }
569            ExprKind::Ident(id) => {
570                if self.is_http_result_ident(e.span)
571                    && let Some(v) = http_variant(&id.name)
572                {
573                    self.push(v, e.span);
574                }
575            }
576            ExprKind::Call { name, .. } => {
577                if self.is_http_result_expr(e.span)
578                    && let Some(v) = http_variant(&name.name)
579                {
580                    self.push(v, e.span);
581                }
582            }
583            ExprKind::Ok(_) => {
584                if self.is_http_result_expr(e.span)
585                    && let Some(v) = http_variant("Ok")
586                {
587                    self.push(v, e.span);
588                }
589            }
590            ExprKind::Question(inner) => {
591                if matches!(self.expr_ty(inner.span).as_deref(), Some(Ty::Option(_))) {
592                    self.saw_option_question = true;
593                }
594            }
595            _ => {}
596        }
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use std::path::PathBuf;
604
605    /// Same convention as `sequence.rs`/`architecture.rs`'s `setup_project`:
606    /// a temp dir unique to the test name, self-contained fixtures only
607    /// (never `examples/` — `bynk-ide` is published standalone).
608    fn setup_project(test_name: &str, files: &[(&str, &str)]) -> PathBuf {
609        let root = std::env::temp_dir().join(format!(
610            "bynk-ide-wire-contract-test-{test_name}-{}",
611            std::process::id()
612        ));
613        let _ = std::fs::remove_dir_all(&root);
614        std::fs::create_dir_all(&root).expect("create test root");
615        for (rel, contents) in files {
616            let p = root.join(rel);
617            if let Some(parent) = p.parent() {
618                std::fs::create_dir_all(parent).expect("create parent");
619            }
620            std::fs::write(&p, contents).expect("write file");
621        }
622        root
623    }
624
625    // -- Fixture: examples/rate-limiter's `GET /check/:client`, reproduced
626    // -- self-contained (not read from `examples/` — see `setup_project`)
627    // -- and simplified to a single file (no `uses window` — irrelevant to
628    // -- what this module resolves).
629    const RATELIMIT_SRC: &str = r#"context ratelimit
630
631consumes bynk { Clock }
632
633type ClientId = String where NonEmpty
634
635type RateView = {
636  allowed:   Bool,
637  remaining: Int,
638  resetAt:   Int,
639}
640
641agent Limiter {
642  key client: ClientId
643
644  store count: Cell[Int]
645
646  on call hit(now: Int) -> Effect[RateView] {
647    let _ <- count.update((c) => c + 1)
648    RateView { allowed: count < 10, remaining: 10 - count, resetAt: now }
649  }
650}
651
652service api from http {
653  on GET("/check/:client") (client: ClientId) -> Effect[HttpResult[RateView]] by Visitor given Clock {
654    let now  <- Clock.now()
655    let view <- Limiter(client).hit(now.toEpochMillis())
656    if view.allowed {
657      Ok(view)
658    } else {
659      TooManyRequests("rate limit exceeded")
660    }
661  }
662}
663"#;
664
665    fn find_offset(text: &str, needle: &str) -> usize {
666        text.find(needle)
667            .unwrap_or_else(|| panic!("`{needle}` not found in fixture"))
668    }
669
670    /// Thin wrapper over the module's own [`real_context_count`] — exercises
671    /// the exact function `bynk-lsp`'s `bynk/wireContract` handler must call
672    /// (Phase 5), rather than a test-local re-derivation that could drift
673    /// from it.
674    fn real_context_count(diag: &crate::ProjectDiagnostics) -> usize {
675        super::real_context_count(&diag.boundary_info, &diag.unit_sources)
676    }
677
678    #[test]
679    fn rate_limiter_get_check_client_is_a_bare_envelope_with_a_revalidated_client_id() {
680        let root = setup_project("ratelimit", &[("ratelimit.bynk", RATELIMIT_SRC)]);
681        let diag = crate::testkit::diagnose_project(&root);
682        let info = diag
683            .boundary_info
684            .get("ratelimit")
685            .expect("boundary_info entry for ratelimit");
686
687        let offset = find_offset(RATELIMIT_SRC, "GET(\"/check/:client\")");
688        let model = wire_contract_at(
689            "ratelimit",
690            RATELIMIT_SRC,
691            offset,
692            info,
693            &[],
694            &diag.ty_intern,
695            real_context_count(&diag),
696        )
697        .expect("a wire contract at the GET handler's header");
698
699        assert_eq!(model.unit, "ratelimit");
700        assert_eq!(model.service, "api");
701        assert_eq!(
702            model.kind,
703            BoundaryKind::Http {
704                method: HttpMethod::Get,
705                path: "/check/:client".to_string(),
706            }
707        );
708
709        // One param (`client`) sends the bare value — not wrapped in an
710        // object, and not the two-case envelope the issue text describes.
711        let (param, shape) = match &model.envelope {
712            Envelope::Bare { param, shape } => (param, shape),
713            other => panic!("expected Envelope::Bare, got {other:?}"),
714        };
715        assert_eq!(param, "client");
716        assert!(
717            matches!(shape, WireRef::Named { name } if name == "ClientId"),
718            "the bare param's shape should resolve to the named ClientId type: {shape:?}"
719        );
720
721        // `ClientId` is in the boundary type set, owned (declared in this
722        // same context), refined `NonEmpty`, revalidated via its own
723        // constructor.
724        let client_id = model
725            .boundary
726            .types
727            .iter()
728            .find(|t| t.name == "ClientId")
729            .expect("ClientId is a boundary type");
730        assert_eq!(client_id.provenance, bynk_check::wire::Provenance::Owned);
731        let bynk_check::wire::WireBody::Scalar(scalar) = &client_id.body else {
732            panic!("ClientId should be a scalar, got {:?}", client_id.body);
733        };
734        assert!(
735            scalar
736                .predicates
737                .iter()
738                .any(|p| matches!(p, PredKind::NonEmpty)),
739            "ClientId's predicates should include NonEmpty: {:?}",
740            scalar.predicates
741        );
742        assert_eq!(
743            scalar.revalidation,
744            bynk_check::wire::Revalidation::ViaConstructor
745        );
746        assert!(model.type_sites.contains_key("ClientId"));
747
748        // A single-context project: even though `api`'s handler is `Http`
749        // (never `NotACallHandler` would even get a chance to fire), the
750        // more fundamental "there is no other context" reason wins.
751        assert_eq!(
752            model.no_cross_context,
753            Some(NoCrossContextReason::SingleContext)
754        );
755        assert!(model.contract.is_none());
756    }
757
758    #[test]
759    fn rate_limiter_response_set_has_declared_constructed_and_boundary_implicit() {
760        let root = setup_project("ratelimit-responses", &[("ratelimit.bynk", RATELIMIT_SRC)]);
761        let diag = crate::testkit::diagnose_project(&root);
762        let info = diag.boundary_info.get("ratelimit").expect("entry");
763
764        // Drive with the round's own retained `expr_types` for this file —
765        // the primary path (not the degraded fallback).
766        let rel = diag
767            .files
768            .iter()
769            .find(|f| {
770                f.source_path
771                    .file_name()
772                    .is_some_and(|n| n == "ratelimit.bynk")
773            })
774            .map(|f| f.source_path.clone())
775            .expect("ratelimit.bynk in the round's files");
776        let expr_types: &[(Span, TyId)] = diag
777            .expr_types
778            .get(&rel)
779            .map(|v| v.as_slice())
780            .unwrap_or(&[]);
781
782        let offset = find_offset(RATELIMIT_SRC, "GET(\"/check/:client\")");
783        let model = wire_contract_at(
784            "ratelimit",
785            RATELIMIT_SRC,
786            offset,
787            info,
788            expr_types,
789            &diag.ty_intern,
790            real_context_count(&diag),
791        )
792        .expect("a wire contract at the GET handler's header");
793
794        let statuses: Vec<(u16, &str)> = model
795            .responses
796            .iter()
797            .map(|r| (r.status, r.variant.as_str()))
798            .collect();
799        assert!(
800            statuses.contains(&(200, "Ok")),
801            "declared success missing: {statuses:?}"
802        );
803        assert!(
804            statuses.contains(&(429, "TooManyRequests")),
805            "constructed TooManyRequests missing: {statuses:?}"
806        );
807        assert!(
808            statuses.iter().any(|&(s, _)| s == 400),
809            "boundary-implicit 400 (the handler has a param) missing: {statuses:?}"
810        );
811        assert!(
812            model
813                .responses
814                .iter()
815                .find(|r| r.status == 200)
816                .is_some_and(|r| r.origin == ResponseOrigin::DeclaredSuccess)
817        );
818        assert!(
819            model.responses.iter().any(|r| matches!(
820                r.origin,
821                ResponseOrigin::BoundaryImplicit { .. }
822            ) && r.status == 400)
823        );
824    }
825
826    // -- Fixture 2: a two-context project with a real `on call` boundary —
827    // -- the contract form + hash + Envelope::Keyed path (multi-param).
828    const PROVIDER_SRC: &str = r#"context billing
829
830type Quote = { amount: Int, currency: String }
831
832service Pricing {
833  on call(sku: String, qty: Int) -> Effect[Quote] {
834    Quote { amount: qty * 100, currency: "USD" }
835  }
836}
837"#;
838    const CONSUMER_SRC: &str = r#"context storefront
839
840consumes billing
841
842service checkout {
843  on call(sku: String, qty: Int) -> Effect[Int] {
844    let q <- billing.Pricing(sku, qty)
845    q.amount
846  }
847}
848"#;
849
850    #[test]
851    fn two_context_call_handler_has_a_keyed_envelope_and_a_contract_hash() {
852        let root = setup_project(
853            "two-context",
854            &[
855                ("billing.bynk", PROVIDER_SRC),
856                ("storefront.bynk", CONSUMER_SRC),
857            ],
858        );
859        let diag = crate::testkit::diagnose_project(&root);
860        let info = diag
861            .boundary_info
862            .get("billing")
863            .expect("boundary_info entry for billing");
864
865        let offset = find_offset(PROVIDER_SRC, "on call(");
866        let model = wire_contract_at(
867            "billing",
868            PROVIDER_SRC,
869            offset,
870            info,
871            &[],
872            &diag.ty_intern,
873            real_context_count(&diag),
874        )
875        .expect("a wire contract at the `price` handler");
876
877        assert_eq!(model.kind, BoundaryKind::Call);
878        let params = match &model.envelope {
879            Envelope::Keyed { params } => params,
880            other => panic!("expected Envelope::Keyed for a two-param call, got {other:?}"),
881        };
882        assert_eq!(
883            params.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
884            vec!["sku", "qty"],
885            "keyed params stay in declaration order"
886        );
887
888        assert!(model.no_cross_context.is_none(), "two contexts + on call");
889        let contract = model.contract.expect("on call gets a contract form");
890        assert_eq!(
891            contract.hash,
892            bynk_check::contract::contract_hash(&contract.normal_form)
893        );
894
895        // Independent re-derivation via the exact projection
896        // `own_contract_hashes` uses, against the same retained table — the
897        // hovered hash must equal what the emitter would stamp.
898        let svc = CrossContextService {
899            name: "Pricing".to_string(),
900            params: vec![
901                (
902                    "sku".to_string(),
903                    TypeRef::Base(BaseType::String, Span::new(0, 0)),
904                ),
905                (
906                    "qty".to_string(),
907                    TypeRef::Base(BaseType::Int, Span::new(0, 0)),
908                ),
909            ],
910            return_type: TypeRef::Named(Ident {
911                name: "Quote".to_string(),
912                span: Span::new(0, 0),
913            }),
914            span: Span::new(0, 0),
915        };
916        let independent_form = contract::service_normal_form(&svc, &info.types);
917        assert_eq!(contract.normal_form, independent_form);
918        assert_eq!(contract.hash, contract::contract_hash(&independent_form));
919    }
920
921    #[test]
922    fn zero_param_call_handler_is_the_empty_envelope() {
923        let src = r#"context solo
924
925service Ping {
926  on call() -> Effect[Int] {
927    1
928  }
929}
930"#;
931        let root = setup_project("zero-param", &[("solo.bynk", src)]);
932        let diag = crate::testkit::diagnose_project(&root);
933        let info = diag.boundary_info.get("solo").expect("entry");
934
935        let offset = find_offset(src, "on call()");
936        let model = wire_contract_at(
937            "solo",
938            src,
939            offset,
940            info,
941            &[],
942            &diag.ty_intern,
943            real_context_count(&diag),
944        )
945        .expect("a wire contract at the `Ping` handler");
946
947        assert!(
948            matches!(model.envelope, Envelope::Empty),
949            "zero params: the request body is not read, not an empty keyed object"
950        );
951        // Single-context project: `on call` still answers SingleContext, not
952        // a real contract.
953        assert_eq!(
954            model.no_cross_context,
955            Some(NoCrossContextReason::SingleContext)
956        );
957    }
958
959    // -- Regression: the `Ok`-overload `expr_types` fallback (plan risk 7)
960    // -- must not extend to a bare `Ident`. `HTTP_VARIANTS` includes names
961    // -- an author can plausibly bind as an ordinary local (`Found`, `Gone`,
962    // -- `Conflict`, …) — without a recorded expr type, a bare `Found` read
963    // -- must stay unknown, not get misreported as a constructed 302.
964    #[test]
965    fn bare_ident_collision_with_a_variant_name_is_not_misreported_without_expr_types() {
966        const SRC: &str = r#"context oddnames
967
968service api from http {
969  on GET("/x") () -> Effect[HttpResult[Int]] by Visitor {
970    let Found = 1
971    Found
972  }
973}
974"#;
975        let root = setup_project("ident-collision", &[("oddnames.bynk", SRC)]);
976        let diag = crate::testkit::diagnose_project(&root);
977        let info = diag
978            .boundary_info
979            .get("oddnames")
980            .expect("boundary_info entry for oddnames");
981
982        let offset = find_offset(SRC, "GET(\"/x\")");
983        // Deliberately pass an empty `expr_types` — the degraded path (a
984        // file with errors, ADR 0063's clean-file ceiling) this fixture is
985        // standing in for, even though it type-checks cleanly on its own.
986        let model = wire_contract_at(
987            "oddnames",
988            SRC,
989            offset,
990            info,
991            &[],
992            &diag.ty_intern,
993            real_context_count(&diag),
994        )
995        .expect("a wire contract at the GET handler");
996
997        assert!(
998            model.responses.iter().all(|r| r.variant != "Found"),
999            "a bare `Found` local must not be misreported as HttpResult.Found \
1000             without a recorded expr type: {:?}",
1001            model.responses
1002        );
1003    }
1004}