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 `contract_types_for(unit, …)` — *this* unit's own type
19//! namespace: own declarations, its `uses` targets', and (#2073) the types of
20//! the contexts it consumes, which its own signatures may name — the exact
21//! table `own_contract_hashes` (`bynk-emit/src/project.rs`) hashes a
22//! service's contract through. Every name resolvable through it is this
23//! unit's *own* view of its boundary, so the peek reports
24//! `Provenance::Owned` throughout, a borrowed consumed type included. A
25//! caller's view of another unit's boundary is out of scope for a peek that
26//! 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::{FileId, 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 {
55        method: HttpMethod,
56        path: String,
57    },
58    Call,
59    Cron {
60        expr: String,
61    },
62    Message,
63    Open,
64    Close,
65    Event,
66    /// `on rejected` (#2341): reached only by the router's own rejections.
67    Rejected,
68}
69
70impl BoundaryKind {
71    fn from_handler(h: &Handler) -> Self {
72        match &h.kind {
73            HandlerKind::Http { method, path } => BoundaryKind::Http {
74                method: *method,
75                path: path.clone(),
76            },
77            HandlerKind::Call => BoundaryKind::Call,
78            HandlerKind::Cron { expr } => BoundaryKind::Cron { expr: expr.clone() },
79            HandlerKind::Message => BoundaryKind::Message,
80            HandlerKind::Open => BoundaryKind::Open,
81            HandlerKind::Close => BoundaryKind::Close,
82            HandlerKind::Event => BoundaryKind::Event,
83            HandlerKind::Rejected => BoundaryKind::Rejected,
84        }
85    }
86}
87
88/// The request shape a handler's caller sends. **Three cases, not two** —
89/// `bynk-emit/src/emitter/workers_entry.rs`'s `h.params.len() == 1` branch is
90/// the *only* bare-value case; everything else, including **zero** params,
91/// takes the object branch. Rendering only `Bare`/`Keyed` would misstate a
92/// zero-arg service as accepting an (empty) object body it never inspects,
93/// when what actually happens is the body is not read at all.
94#[derive(Debug, Clone)]
95pub enum Envelope {
96    /// No params: the request body is not read.
97    Empty,
98    /// Exactly one param: the request body **is** the value of `param`,
99    /// with no wrapping object/key.
100    Bare { param: String, shape: WireRef },
101    /// Two or more params: an object keyed by parameter name, in
102    /// declaration order.
103    Keyed { params: Vec<(String, WireRef)> },
104    /// A `@raw(contentType:)` route: the request body is the octets of `param`
105    /// (a `Bytes`), sent under `content_type` (`"*/*"` for any) — not JSON, not
106    /// base64, and not wrapped in an object whatever the other params.
107    Raw { param: String, content_type: String },
108}
109
110/// The canonical form + hash of an `on call` handler's contract — the same
111/// projection `bynk-emit/src/project.rs`'s `own_contract_hashes` builds, so
112/// the hash this peek shows is provably the hash the emitted
113/// `X-Bynk-Contract` constant stamps.
114#[derive(Debug, Clone)]
115pub struct ContractForm {
116    pub normal_form: String,
117    pub hash: String,
118}
119
120/// Why a response was reachable — so a renderer can tell an author-declared
121/// outcome from one the boundary injects on their behalf without their
122/// having written a line for it.
123#[derive(Debug, Clone, PartialEq)]
124pub enum ResponseOrigin {
125    /// The handler's declared return type, `Effect[HttpResult[T]]` stripped
126    /// to its `Ok`/200 case.
127    DeclaredSuccess,
128    /// A variant literally constructed somewhere in the handler body, at
129    /// this span.
130    Constructed { span: Span },
131    /// A response the body never names — injected by the boundary itself.
132    BoundaryImplicit { why: &'static str },
133}
134
135/// One reachable HTTP outcome.
136#[derive(Debug, Clone, PartialEq)]
137pub struct HttpResponse {
138    pub status: u16,
139    /// The `HttpResult` variant name (`"Ok"`, `"TooManyRequests"`), or a
140    /// representative `BoundaryError` `kind` for a boundary-implicit
141    /// response with no variant of its own (`"StructuralMismatch"`).
142    pub variant: String,
143    pub origin: ResponseOrigin,
144}
145
146/// Why there is no cross-context contract to show for this handler.
147/// Rendered on the panel only — hover stays quiet about an absence (Part 4).
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum NoCrossContextReason {
150    /// Not an `on call` handler — HTTP/cron/message/websocket/event
151    /// handlers are never reached by another context's call.
152    NotACallHandler,
153    /// The project has only this one context/adapter — there is no *other*
154    /// context that could call in, so the question does not arise.
155    SingleContext,
156}
157
158/// The wire contract of one handler: its request envelope, its reachable
159/// response set (HTTP handlers only), its cross-context contract form + hash
160/// (`on call` only), and the boundary type shapes both reference.
161#[derive(Debug, Clone)]
162pub struct WireContractModel {
163    pub unit: String,
164    pub service: String,
165    pub kind: BoundaryKind,
166    pub handler_span: Span,
167    /// 1-indexed line of `handler_span`'s start, against the text this
168    /// model was built from.
169    pub handler_line: usize,
170    pub envelope: Envelope,
171    /// `on call` only; `None` whenever [`Self::no_cross_context`] is `Some`.
172    pub contract: Option<ContractForm>,
173    /// The boundary types this handler's envelope + responses reference,
174    /// resolved through the retained [`ContextBoundaryInfo`] — the same IR
175    /// `bynk-emit`'s codec generation renders.
176    pub boundary: WireModel,
177    /// Declaration span for every named type in `boundary.types`, keyed by
178    /// name — click-to-code for the panel's per-type blocks.
179    pub type_sites: HashMap<String, Span>,
180    /// Empty for a non-HTTP handler.
181    pub responses: Vec<HttpResponse>,
182    pub no_cross_context: Option<NoCrossContextReason>,
183}
184
185/// How many **real** contexts/adapters a project has — the input
186/// [`wire_contract_at`]/[`wire_contract_for_service`]'s `context_count`
187/// wants, deciding [`NoCrossContextReason::SingleContext`].
188///
189/// **Must** be computed this way, not as a bare `boundary_info.len()`:
190/// `boundary_info` (mirroring `sequence_info`, #846) retains an entry for
191/// every `UnitKind::Context | UnitKind::Adapter`, which includes the
192/// synthetic toolchain-injected `bynk` capability-surface unit — so a
193/// project with nothing but `consumes bynk { Clock }` would count *two*
194/// "contexts" and `SingleContext` would almost never fire. `unit_sources`
195/// (ADR 0095) already excludes synthetic units for exactly this reason
196/// (document links, consumed-context navigation); intersecting against it
197/// reuses that existing filter rather than a bespoke `name == "bynk"` check.
198///
199/// A free function (not buried in a caller's own helper) precisely so
200/// `bynk-lsp`'s `bynk/wireContract` handler calls this — the one place the
201/// filter is encoded — instead of re-deriving `boundary_info.len()` from
202/// the plan's literal parameter name and reintroducing the bug.
203pub fn real_context_count(
204    boundary_info: &HashMap<String, ContextBoundaryInfo>,
205    unit_sources: &HashMap<String, Vec<PathBuf>>,
206) -> usize {
207    boundary_info
208        .keys()
209        .filter(|k| unit_sources.contains_key(k.as_str()))
210        .count()
211}
212
213/// Locate the `Handler` enclosing `offset` in `text` and build its wire
214/// contract. Mirrors `sequence_request::sequence_model_at`'s re-parse
215/// convention. `info` is the owning unit's retained boundary table (Phase
216/// 3); `expr_types` is that same file's checked expression types (empty for
217/// a file with errors — the `Ok`-overload disambiguation then falls back to
218/// the declared return type, see `ResponseWalk::is_http_result_expr` below).
219///
220/// `context_count` is how many **real** contexts/adapters the project has —
221/// build it with [`real_context_count`], not a bare `boundary_info.len()`
222/// (see that function's doc for why the difference matters).
223///
224/// Scoped to service handlers (see the module doc); an agent handler at
225/// `offset` answers `None`.
226pub fn wire_contract_at(
227    unit: &str,
228    text: &str,
229    offset: usize,
230    info: &ContextBoundaryInfo,
231    expr_types: &[(Span, TyId)],
232    tys: &Types,
233    context_count: usize,
234) -> Option<WireContractModel> {
235    let tokens = bynk_syntax::lexer::tokenize(text).ok()?;
236    let (parsed, _errs) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text);
237    let items: &[CommonsItem] = match parsed.as_ref()? {
238        SourceUnit::Context(c) => &c.items,
239        SourceUnit::Adapter(a) => &a.items,
240        SourceUnit::Commons(_) | SourceUnit::Suite(_) => return None,
241    };
242    for item in items {
243        if let CommonsItem::Service(s) = item
244            && let Some(h) = handler_at(&s.handlers, offset)
245        {
246            return wire_contract_for_service(
247                unit,
248                text,
249                &s.name.name,
250                h,
251                info,
252                expr_types,
253                tys,
254                context_count,
255            );
256        }
257    }
258    None
259}
260
261fn handler_at(handlers: &[Handler], offset: usize) -> Option<&Handler> {
262    handlers
263        .iter()
264        .find(|h| h.span.start <= offset && offset < h.span.end)
265}
266
267/// Build the wire contract for one already-located handler. The pure half of
268/// [`wire_contract_at`] — the part that never touches the offset/re-parse
269/// machinery — split out the same way `sequence_model_at` delegates to
270/// `sequence::sequence_model`.
271///
272/// `service_name` must name an entry in `info.services` (the owning unit's
273/// retained service table) — the handler is spliced into a clone of that
274/// declaration (a synthetic single-handler service) so
275/// `bynk_check::wire::collect_boundary_types` narrows its walk to exactly
276/// this handler's params/return, not the whole service's. `None` if the
277/// service is not (yet) in the retained table — a live-buffer/committed-round
278/// mismatch (the same class of staleness `sequence_model_at` accepts for
279/// `sequence_info`).
280#[allow(clippy::too_many_arguments)]
281pub fn wire_contract_for_service(
282    unit: &str,
283    text: &str,
284    service_name: &str,
285    handler: &Handler,
286    info: &ContextBoundaryInfo,
287    expr_types: &[(Span, TyId)],
288    tys: &Types,
289    context_count: usize,
290) -> Option<WireContractModel> {
291    let real = info.services.get(service_name)?;
292    let mut synthetic = real.clone();
293    synthetic.handlers = vec![handler.clone()];
294    let services: HashMap<String, ServiceDecl> =
295        HashMap::from([(service_name.to_string(), synthetic)]);
296    let agents: HashMap<String, AgentDecl> = HashMap::new();
297
298    let boundary_names = wire::collect_boundary_types(&info.types, &services, &agents, &[]);
299    let insts =
300        wire::collect_generic_instantiations(&services, &agents, &boundary_names, &info.types, &[]);
301    // Every name reachable through `info.types` is this unit's own view of
302    // its boundary — see the module doc's Provenance note.
303    let boundary = wire::boundary_model(&boundary_names, &info.types, insts, |_| {
304        wire::Provenance::Owned
305    });
306
307    let type_sites: HashMap<String, Span> = boundary_names
308        .iter()
309        .filter_map(|n| info.types.get(n).map(|d| (n.clone(), d.span)))
310        .collect();
311
312    let envelope = envelope_for(handler, &info.types);
313    let kind = BoundaryKind::from_handler(handler);
314
315    let no_cross_context = if context_count <= 1 {
316        Some(NoCrossContextReason::SingleContext)
317    } else if handler.kind != HandlerKind::Call {
318        Some(NoCrossContextReason::NotACallHandler)
319    } else {
320        None
321    };
322    let contract = if no_cross_context.is_none() {
323        Some(contract_form(service_name, handler, &info.types))
324    } else {
325        None
326    };
327
328    let responses = if matches!(kind, BoundaryKind::Http { .. }) {
329        http_responses(handler, real.span.file, expr_types, tys)
330    } else {
331        Vec::new()
332    };
333
334    Some(WireContractModel {
335        unit: unit.to_string(),
336        service: service_name.to_string(),
337        kind,
338        handler_span: handler.span,
339        handler_line: line_of(text, handler.span.start),
340        envelope,
341        contract,
342        boundary,
343        type_sites,
344        responses,
345        no_cross_context,
346    })
347}
348
349/// 1-indexed line number of `offset` in `text`.
350fn line_of(text: &str, offset: usize) -> usize {
351    text.get(..offset).unwrap_or(text).matches('\n').count() + 1
352}
353
354fn envelope_for(handler: &Handler, types: &HashMap<String, std::sync::Arc<TypeDecl>>) -> Envelope {
355    if let Some(content_type) = handler.raw_content_type() {
356        return Envelope::Raw {
357            param: "body".to_string(),
358            content_type: content_type.to_string(),
359        };
360    }
361    match handler.params.as_slice() {
362        [] => Envelope::Empty,
363        [p] => Envelope::Bare {
364            param: p.name.name.clone(),
365            shape: wire::wire_ref(&p.type_ref, types),
366        },
367        params => Envelope::Keyed {
368            params: params
369                .iter()
370                .map(|p| (p.name.name.clone(), wire::wire_ref(&p.type_ref, types)))
371                .collect(),
372        },
373    }
374}
375
376/// The same `CrossContextService` projection `own_contract_hashes`
377/// (`bynk-emit/src/project.rs`) builds, canonicalised through the same
378/// `info.types` table — so the hash this peek shows is provably the hash
379/// the emitted `X-Bynk-Contract` constant stamps.
380fn contract_form(
381    service_name: &str,
382    handler: &Handler,
383    types: &HashMap<String, std::sync::Arc<TypeDecl>>,
384) -> ContractForm {
385    let svc = CrossContextService {
386        name: service_name.to_string(),
387        params: handler
388            .params
389            .iter()
390            .map(|p| (p.name.name.clone(), p.type_ref.clone()))
391            .collect(),
392        return_type: handler.return_type.clone(),
393        span: handler.span,
394        foreign_callers: Vec::new(),
395    };
396    let normal_form = contract::service_normal_form(&svc, types);
397    let hash = contract::contract_hash(&normal_form);
398    ContractForm { normal_form, hash }
399}
400
401/// The reachable HTTP response set: the declared success case, every
402/// variant literally constructed in the body, and the boundary-implicit
403/// cases the body never names (400 on any param, 404 on an `Option?`
404/// short-circuit — ADR 0177). 401 on a caller binding is deferred (plan
405/// risk 8; needs actor/`by` resolution this phase does not do).
406///
407/// `file` is the handler's own file — the retained service declaration's
408/// `FileId`, since a service lives in one file — which scopes the
409/// `expr_types` lookup (see `ResponseWalk::file`).
410fn http_responses(
411    handler: &Handler,
412    file: FileId,
413    expr_types: &[(Span, TyId)],
414    tys: &Types,
415) -> Vec<HttpResponse> {
416    let declared_is_http_result =
417        matches!(strip_effect(&handler.return_type), TypeRef::HttpResult(..));
418
419    let mut out = Vec::new();
420    if declared_is_http_result {
421        out.push(HttpResponse {
422            status: 200,
423            variant: "Ok".to_string(),
424            origin: ResponseOrigin::DeclaredSuccess,
425        });
426    }
427
428    let mut walk = ResponseWalk {
429        expr_types,
430        file,
431        tys,
432        declared_is_http_result,
433        seen: out.iter().map(response_key).collect(),
434        saw_option_question: false,
435        out: Vec::new(),
436    };
437    walk.walk_block(&handler.body);
438    out.extend(walk.out);
439
440    if !handler.params.is_empty() {
441        out.push(HttpResponse {
442            status: 400,
443            variant: "StructuralMismatch".to_string(),
444            origin: ResponseOrigin::BoundaryImplicit {
445                why: "every param is structurally re-validated on the way in; a malformed or \
446                      refinement-violating value fails closed with a 400 the handler body \
447                      never names",
448            },
449        });
450    }
451    if handler.raw_content_type().is_some() {
452        out.push(HttpResponse {
453            status: 413,
454            variant: "PayloadTooLarge".to_string(),
455            origin: ResponseOrigin::BoundaryImplicit {
456                why: "a raw body is counted as it is read and cut off at the route's cap",
457            },
458        });
459        out.push(HttpResponse {
460            status: 415,
461            variant: "UnsupportedMediaType".to_string(),
462            origin: ResponseOrigin::BoundaryImplicit {
463                why: "the request's Content-Type is not the media type `@raw` declares",
464            },
465        });
466    }
467    if walk.saw_option_question {
468        out.push(HttpResponse {
469            status: 404,
470            variant: "NotFound".to_string(),
471            origin: ResponseOrigin::BoundaryImplicit {
472                why: "an `Option?` short-circuits to 404 on `None` (ADR 0177)",
473            },
474        });
475    }
476    out
477}
478
479/// Strip `Effect[_]` to expose the inner type — mirrors
480/// `bynk-emit/src/emitter/workers_entry.rs`'s `http_result_inner`, one level
481/// short of unwrapping `HttpResult` itself (the caller checks that).
482fn strip_effect(t: &TypeRef) -> &TypeRef {
483    match t {
484        TypeRef::Effect(inner, _) => inner.as_ref(),
485        other => other,
486    }
487}
488
489/// The handler-body expression walk finding every literally-constructed
490/// `HttpResult` variant, plus whether an `Option?` short-circuit is present
491/// (the boundary-implicit 404). Structured after `bynk-ide/src/sequence.rs`'s
492/// `Builder` — a small piece of walk state threaded through recursive
493/// `walk_*` methods — but this walk is a *full* expression descent (via
494/// `bynk_syntax::ast::expr_children`), not `sequence.rs`'s statement-level
495/// control-flow-only walk: a response can be constructed anywhere in an
496/// expression tree (a call argument, a record field), not only in tail
497/// position.
498struct ResponseWalk<'a> {
499    expr_types: &'a [(Span, TyId)],
500    /// The handler's own file. A file's `expr_types` is not only its own
501    /// spans: `check_pipeline` merges a sibling file's methods on a type
502    /// declared here into this file's checked view, so their bodies' spans
503    /// (byte offsets into the *sibling's* text) land in this vector too.
504    /// Only entries stamped with this `FileId` are this file's.
505    file: FileId,
506    /// T3.6b (R4.1): the table `expr_types`' ids resolve against.
507    tys: &'a Types,
508    /// #855 risk 7 ("the `Ok` overload"): the declared-return-type fallback
509    /// used whenever `expr_types` has no entry for a span (an expression the
510    /// checker never typed — ADR 0094's partial types still miss those) —
511    /// mirrors `bynk-emit/src/emitter/lower.rs`'s own `Ok`/`HttpResult` overload
512    /// disambiguation, which has the identical ambiguity and the identical
513    /// (checker-backed, so never actually degraded there) resolution.
514    /// Applied to `Ok` and bare `Call` (see `is_http_result_expr` below) but
515    /// **not** a bare `Ident` (see `is_http_result_ident` below), which has
516    /// no concrete-shaped signal to fall back onto and would misreport an
517    /// ordinary local whose name collides with a variant name.
518    declared_is_http_result: bool,
519    seen: std::collections::HashSet<String>,
520    saw_option_question: bool,
521    out: Vec<HttpResponse>,
522}
523
524impl<'a> ResponseWalk<'a> {
525    /// The checker-recorded type at `span`: an entry in [`Self::file`] over
526    /// the same byte range. `span`'s own `FileId` is ignored — the handler
527    /// may come from [`wire_contract_at`]'s reparse, which stamps
528    /// `FileId::UNKNOWN`, while `expr_types` carries the analysis round's
529    /// real ids (T3.5, #1062), so `Span`'s derived `PartialEq` would never
530    /// match. A recorded `Ty::Error` is "no type", so it degrades to the
531    /// same fallback as a missing entry.
532    fn expr_ty(&self, span: Span) -> Option<std::sync::Arc<Ty>> {
533        self.expr_types
534            .iter()
535            .find(|(s, _)| s.file == self.file && s.start == span.start && s.end == span.end)
536            .map(|(_, t)| self.tys.get(*t))
537            .filter(|t| !matches!(**t, Ty::Error))
538    }
539
540    /// Whether `span`'s expression checked as `HttpResult[_]` — the same
541    /// disambiguation `lower.rs:720-731` performs for the `Ok` overload,
542    /// degrading to the declared-return heuristic when the checker recorded
543    /// nothing (see `declared_is_http_result`'s doc). Used for `Ok` and
544    /// `Call`: a bare `Call { name, .. }` already carries a concrete
545    /// variant-shaped name (`TooManyRequests(...)`), so the same fallback
546    /// is low-risk there. **Not** used for a bare `Ident` — see
547    /// `is_http_result_ident` below.
548    fn is_http_result_expr(&self, span: Span) -> bool {
549        match self.expr_ty(span).as_deref() {
550            Some(Ty::HttpResult(_)) => true,
551            Some(_) => false,
552            None => self.declared_is_http_result,
553        }
554    }
555
556    /// The stricter check for a bare `Ident` — never falls back to the
557    /// declared-return heuristic. `HTTP_VARIANTS` includes names an author
558    /// can plausibly bind as an ordinary local (`Found`, `Gone`, `Conflict`,
559    /// `Accepted`, `Created`, `Raw`, `Streaming`): with the same fallback as
560    /// `is_http_result_expr`, a file with errors and a `let found = …;
561    /// found` tail on an `HttpResult`-returning handler would misreport a
562    /// 302 that was never constructed. `Ok`/`Call` need the fallback because
563    /// degrading them means answering nothing at all for the *one*
564    /// expression the plan's risk 7 is about; a bare identifier read has no
565    /// such asymmetry — unknown stays unknown.
566    fn is_http_result_ident(&self, span: Span) -> bool {
567        matches!(self.expr_ty(span).as_deref(), Some(Ty::HttpResult(_)))
568    }
569
570    fn push(&mut self, variant: HttpVariant, span: Span) {
571        self.push_status(variant, variant.status, span);
572    }
573
574    /// #2341 (L4): a `RawError` answers the status its first argument names,
575    /// so each distinct status is its own response.
576    fn push_raw_error(&mut self, variant: HttpVariant, args: &[Expr], span: Span) {
577        if let Some(status) = args.first().and_then(raw_error_status) {
578            self.push_status(variant, status, span);
579        }
580    }
581
582    fn push_status(&mut self, variant: HttpVariant, status: u16, span: Span) {
583        if self.seen.insert(variant_key(variant.name, status)) {
584            self.out.push(HttpResponse {
585                status,
586                variant: variant.name.to_string(),
587                origin: ResponseOrigin::Constructed { span },
588            });
589        }
590    }
591
592    fn walk_block(&mut self, b: &Block) {
593        for s in &b.statements {
594            let mut exprs = Vec::new();
595            statement_exprs(s, &mut exprs);
596            for e in exprs {
597                self.walk_expr(e);
598            }
599        }
600        self.walk_expr(&b.tail);
601    }
602
603    fn walk_expr(&mut self, e: &Expr) {
604        self.classify(e);
605        for child in expr_children(e) {
606            self.walk_expr(child);
607        }
608    }
609
610    /// The four recognition shapes `lower.rs` renders through
611    /// (`HttpResult.Variant(args)`/`HttpResult.Variant` qualified forms at
612    /// `:1417`/`:4149`, the bare `Ident`/`Call` forms disambiguated by
613    /// checker type at `:3781`/`:3834`), plus the `Ok` overload
614    /// (`:720-731`) and the `?`-on-`Option` boundary-implicit 404
615    /// (`:764-768`, ADR 0177).
616    fn classify(&mut self, e: &Expr) {
617        match &e.kind {
618            ExprKind::MethodCall {
619                receiver,
620                method,
621                args,
622                ..
623            } => {
624                if let ExprKind::Ident(id) = &receiver.kind
625                    && id.name == HTTP_RESULT
626                    && let Some(v) = http_variant(&method.name)
627                {
628                    if v.status == AUTHOR_CHOSEN_STATUS {
629                        self.push_raw_error(v, args, e.span);
630                    } else {
631                        self.push(v, e.span);
632                    }
633                }
634            }
635            ExprKind::FieldAccess { receiver, field } => {
636                if let ExprKind::Ident(id) = &receiver.kind
637                    && id.name == HTTP_RESULT
638                    && let Some(v) = http_variant(&field.name)
639                {
640                    self.push(v, e.span);
641                }
642            }
643            ExprKind::Ident(id) => {
644                if self.is_http_result_ident(e.span)
645                    && let Some(v) = http_variant(&id.name)
646                {
647                    self.push(v, e.span);
648                }
649            }
650            ExprKind::Call { name, args, .. } => {
651                if self.is_http_result_expr(e.span)
652                    && let Some(v) = http_variant(&name.name)
653                {
654                    if v.status == AUTHOR_CHOSEN_STATUS {
655                        self.push_raw_error(v, args, e.span);
656                    } else {
657                        self.push(v, e.span);
658                    }
659                }
660            }
661            ExprKind::Ok(_) => {
662                if self.is_http_result_expr(e.span)
663                    && let Some(v) = http_variant("Ok")
664                {
665                    self.push(v, e.span);
666                }
667            }
668            ExprKind::Question(inner) => {
669                if matches!(self.expr_ty(inner.span).as_deref(), Some(Ty::Option(_))) {
670                    self.saw_option_question = true;
671                }
672            }
673            _ => {}
674        }
675    }
676}
677
678/// The dedupe key of a response: its variant, plus the status for a variant
679/// whose status is chosen per construction (`RawError`, #2341).
680fn variant_key(variant: &str, status: u16) -> String {
681    match http_variant(variant) {
682        Some(v) if v.status == AUTHOR_CHOSEN_STATUS => format!("{variant} {status}"),
683        _ => variant.to_string(),
684    }
685}
686
687fn response_key(r: &HttpResponse) -> String {
688    variant_key(&r.variant, r.status)
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694    use std::path::PathBuf;
695
696    /// Same convention as `sequence.rs`/`architecture.rs`'s `setup_project`:
697    /// a temp dir unique to the test name, self-contained fixtures only
698    /// (never `examples/` — `bynk-ide` is published standalone).
699    fn setup_project(test_name: &str, files: &[(&str, &str)]) -> PathBuf {
700        let root = std::env::temp_dir().join(format!(
701            "bynk-ide-wire-contract-test-{test_name}-{}",
702            std::process::id()
703        ));
704        let _ = std::fs::remove_dir_all(&root);
705        std::fs::create_dir_all(&root).expect("create test root");
706        for (rel, contents) in files {
707            let p = root.join(rel);
708            if let Some(parent) = p.parent() {
709                std::fs::create_dir_all(parent).expect("create parent");
710            }
711            std::fs::write(&p, contents).expect("write file");
712        }
713        root
714    }
715
716    // -- Fixture: examples/rate-limiter's `GET /check/:client`, reproduced
717    // -- self-contained (not read from `examples/` — see `setup_project`)
718    // -- and simplified to a single file (no `uses window` — irrelevant to
719    // -- what this module resolves).
720    const RATELIMIT_SRC: &str = r#"context ratelimit
721
722consumes bynk { Clock }
723
724type ClientId = String where NonEmpty
725
726type RateView = {
727  allowed:   Bool,
728  remaining: Int,
729  resetAt:   Int,
730}
731
732agent Limiter {
733  key client: ClientId
734
735  store count: Cell[Int]
736
737  on call hit(now: Int) -> Effect[RateView] {
738    let _ <- count.update((c) => c + 1)
739    RateView { allowed: count < 10, remaining: 10 - count, resetAt: now }
740  }
741}
742
743service api from http {
744  on GET("/check/:client") (client: ClientId) -> Effect[HttpResult[RateView]] by Visitor given Clock {
745    let now  <- Clock.now()
746    let view <- Limiter(client).hit(now.toEpochMillis())
747    if view.allowed {
748      Ok(view)
749    } else {
750      TooManyRequests("rate limit exceeded")
751    }
752  }
753}
754"#;
755
756    fn find_offset(text: &str, needle: &str) -> usize {
757        text.find(needle)
758            .unwrap_or_else(|| panic!("`{needle}` not found in fixture"))
759    }
760
761    /// Thin wrapper over the module's own [`real_context_count`] — exercises
762    /// the exact function `bynk-lsp`'s `bynk/wireContract` handler must call
763    /// (Phase 5), rather than a test-local re-derivation that could drift
764    /// from it.
765    fn real_context_count(diag: &crate::ProjectDiagnostics) -> usize {
766        super::real_context_count(&diag.boundary_info, &diag.unit_sources)
767    }
768
769    const RAW_SRC: &str = r#"context uploads
770
771service api from http {
772  limits { maxBody: 1000 }
773
774  @raw(contentType: "image/png")
775  on PUT("/avatars/:user") (user: String, body: Bytes) -> Effect[HttpResult[String]] by Visitor {
776    Ok(user)
777  }
778}
779"#;
780
781    #[test]
782    fn a_raw_route_is_a_raw_envelope_whatever_its_other_params_with_413_and_415() {
783        let root = setup_project("uploads", &[("uploads.bynk", RAW_SRC)]);
784        let diag = crate::testkit::diagnose_project(&root);
785        let info = diag
786            .boundary_info
787            .get("uploads")
788            .expect("boundary_info entry for uploads");
789        let offset = find_offset(RAW_SRC, "PUT(\"/avatars/:user\")");
790        let model = wire_contract_at(
791            "uploads",
792            RAW_SRC,
793            offset,
794            info,
795            &[],
796            &diag.ty_intern,
797            real_context_count(&diag),
798        )
799        .expect("a wire contract at the PUT handler's header");
800
801        match &model.envelope {
802            Envelope::Raw {
803                param,
804                content_type,
805            } => {
806                assert_eq!(param, "body");
807                assert_eq!(content_type, "image/png");
808            }
809            other => panic!("expected Envelope::Raw, got {other:?}"),
810        }
811        let statuses: Vec<u16> = model.responses.iter().map(|r| r.status).collect();
812        assert!(statuses.contains(&413), "{statuses:?}");
813        assert!(statuses.contains(&415), "{statuses:?}");
814    }
815
816    #[test]
817    fn rate_limiter_get_check_client_is_a_bare_envelope_with_a_revalidated_client_id() {
818        let root = setup_project("ratelimit", &[("ratelimit.bynk", RATELIMIT_SRC)]);
819        let diag = crate::testkit::diagnose_project(&root);
820        let info = diag
821            .boundary_info
822            .get("ratelimit")
823            .expect("boundary_info entry for ratelimit");
824
825        let offset = find_offset(RATELIMIT_SRC, "GET(\"/check/:client\")");
826        let model = wire_contract_at(
827            "ratelimit",
828            RATELIMIT_SRC,
829            offset,
830            info,
831            &[],
832            &diag.ty_intern,
833            real_context_count(&diag),
834        )
835        .expect("a wire contract at the GET handler's header");
836
837        assert_eq!(model.unit, "ratelimit");
838        assert_eq!(model.service, "api");
839        assert_eq!(
840            model.kind,
841            BoundaryKind::Http {
842                method: HttpMethod::Get,
843                path: "/check/:client".to_string(),
844            }
845        );
846
847        // One param (`client`) sends the bare value — not wrapped in an
848        // object, and not the two-case envelope the issue text describes.
849        let (param, shape) = match &model.envelope {
850            Envelope::Bare { param, shape } => (param, shape),
851            other => panic!("expected Envelope::Bare, got {other:?}"),
852        };
853        assert_eq!(param, "client");
854        assert!(
855            matches!(shape, WireRef::Named { name } if name == "ClientId"),
856            "the bare param's shape should resolve to the named ClientId type: {shape:?}"
857        );
858
859        // `ClientId` is in the boundary type set, owned (declared in this
860        // same context), refined `NonEmpty`, revalidated via its own
861        // constructor.
862        let client_id = model
863            .boundary
864            .types
865            .iter()
866            .find(|t| t.name == "ClientId")
867            .expect("ClientId is a boundary type");
868        assert_eq!(client_id.provenance, bynk_check::wire::Provenance::Owned);
869        let bynk_check::wire::WireBody::Scalar(scalar) = &client_id.body else {
870            panic!("ClientId should be a scalar, got {:?}", client_id.body);
871        };
872        assert!(
873            scalar
874                .predicates
875                .iter()
876                .any(|p| matches!(p, PredKind::NonEmpty)),
877            "ClientId's predicates should include NonEmpty: {:?}",
878            scalar.predicates
879        );
880        assert_eq!(
881            scalar.revalidation,
882            bynk_check::wire::Revalidation::ViaConstructor
883        );
884        assert!(model.type_sites.contains_key("ClientId"));
885
886        // A single-context project: even though `api`'s handler is `Http`
887        // (never `NotACallHandler` would even get a chance to fire), the
888        // more fundamental "there is no other context" reason wins.
889        assert_eq!(
890            model.no_cross_context,
891            Some(NoCrossContextReason::SingleContext)
892        );
893        assert!(model.contract.is_none());
894    }
895
896    /// [`ResponseWalk::expr_ty`]'s answer for every expression in the
897    /// handler at `offset`, keyed by its source text — reparsed the same way
898    /// [`wire_contract_at`] reparses, so the probe of whether the walk's
899    /// primary, checker-typed path is actually taken rather than the
900    /// declared-return fallback.
901    fn walk_expr_tys(
902        text: &str,
903        offset: usize,
904        file: FileId,
905        expr_types: &[(Span, TyId)],
906        tys: &Types,
907    ) -> Vec<(String, Option<std::sync::Arc<Ty>>)> {
908        fn collect<'e>(e: &'e Expr, out: &mut Vec<&'e Expr>) {
909            out.push(e);
910            for c in expr_children(e) {
911                collect(c, out);
912            }
913        }
914        let tokens = bynk_syntax::lexer::tokenize(text).expect("lexes");
915        let (parsed, _) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text);
916        let Some(SourceUnit::Context(c)) = parsed else {
917            panic!("fixture is a context");
918        };
919        let handler = c
920            .items
921            .iter()
922            .find_map(|i| match i {
923                CommonsItem::Service(s) => handler_at(&s.handlers, offset),
924                _ => None,
925            })
926            .expect("a handler at offset");
927        let mut exprs = Vec::new();
928        for s in &handler.body.statements {
929            statement_exprs(s, &mut exprs);
930        }
931        exprs.push(&handler.body.tail);
932        let mut all = Vec::new();
933        for e in exprs {
934            collect(e, &mut all);
935        }
936        let walk = ResponseWalk {
937            expr_types,
938            file,
939            tys,
940            declared_is_http_result: true,
941            seen: Default::default(),
942            saw_option_question: false,
943            out: Vec::new(),
944        };
945        all.iter()
946            .map(|e| {
947                (
948                    text[e.span.start..e.span.end].to_string(),
949                    walk.expr_ty(e.span),
950                )
951            })
952            .collect()
953    }
954
955    #[test]
956    fn rate_limiter_response_set_has_declared_constructed_and_boundary_implicit() {
957        let root = setup_project("ratelimit-responses", &[("ratelimit.bynk", RATELIMIT_SRC)]);
958        let diag = crate::testkit::diagnose_project(&root);
959        let info = diag.boundary_info.get("ratelimit").expect("entry");
960
961        // Drive with the round's own retained `expr_types` for this file —
962        // the primary path (not the degraded fallback).
963        let rel = diag
964            .files
965            .iter()
966            .find(|f| {
967                f.source_path
968                    .file_name()
969                    .is_some_and(|n| n == "ratelimit.bynk")
970            })
971            .map(|f| f.source_path.clone())
972            .expect("ratelimit.bynk in the round's files");
973        let expr_types: &[(Span, TyId)] = diag
974            .expr_types
975            .get(&rel)
976            .map(|v| v.as_slice())
977            .unwrap_or(&[]);
978
979        assert!(
980            !expr_types.is_empty(),
981            "the clean fixture recorded no expr types"
982        );
983        let offset = find_offset(RATELIMIT_SRC, "GET(\"/check/:client\")");
984        // The constructed responses themselves resolve to `HttpResult`, not
985        // just some incidental sub-expression — otherwise the walk took the
986        // degraded fallback for exactly the expressions it classifies.
987        let walked = walk_expr_tys(
988            RATELIMIT_SRC,
989            offset,
990            info.services["api"].span.file,
991            expr_types,
992            &diag.ty_intern,
993        );
994        for src in ["Ok(view)", "TooManyRequests(\"rate limit exceeded\")"] {
995            let ty = walked
996                .iter()
997                .find(|(t, _)| t == src)
998                .unwrap_or_else(|| panic!("`{src}` not walked: {walked:?}"))
999                .1
1000                .as_deref();
1001            assert!(
1002                matches!(ty, Some(Ty::HttpResult(_))),
1003                "`{src}` should resolve to a recorded HttpResult, got {ty:?}"
1004            );
1005        }
1006        let model = wire_contract_at(
1007            "ratelimit",
1008            RATELIMIT_SRC,
1009            offset,
1010            info,
1011            expr_types,
1012            &diag.ty_intern,
1013            real_context_count(&diag),
1014        )
1015        .expect("a wire contract at the GET handler's header");
1016
1017        let statuses: Vec<(u16, &str)> = model
1018            .responses
1019            .iter()
1020            .map(|r| (r.status, r.variant.as_str()))
1021            .collect();
1022        assert!(
1023            statuses.contains(&(200, "Ok")),
1024            "declared success missing: {statuses:?}"
1025        );
1026        assert!(
1027            statuses.contains(&(429, "TooManyRequests")),
1028            "constructed TooManyRequests missing: {statuses:?}"
1029        );
1030        assert!(
1031            statuses.iter().any(|&(s, _)| s == 400),
1032            "boundary-implicit 400 (the handler has a param) missing: {statuses:?}"
1033        );
1034        assert!(
1035            model
1036                .responses
1037                .iter()
1038                .find(|r| r.status == 200)
1039                .is_some_and(|r| r.origin == ResponseOrigin::DeclaredSuccess)
1040        );
1041        assert!(
1042            model.responses.iter().any(|r| matches!(
1043                r.origin,
1044                ResponseOrigin::BoundaryImplicit { .. }
1045            ) && r.status == 400)
1046        );
1047    }
1048
1049    // -- Fixture 2: a two-context project with a real `on call` boundary —
1050    // -- the contract form + hash + Envelope::Keyed path (multi-param).
1051    const PROVIDER_SRC: &str = r#"context billing
1052
1053type Quote = { amount: Int, currency: String }
1054
1055service Pricing {
1056  on call(sku: String, qty: Int) -> Effect[Quote] {
1057    Quote { amount: qty * 100, currency: "USD" }
1058  }
1059}
1060"#;
1061    const CONSUMER_SRC: &str = r#"context storefront
1062
1063consumes billing
1064
1065service checkout {
1066  on call(sku: String, qty: Int) -> Effect[Int] {
1067    let q <- billing.Pricing(sku, qty)
1068    q.amount
1069  }
1070}
1071"#;
1072
1073    #[test]
1074    fn two_context_call_handler_has_a_keyed_envelope_and_a_contract_hash() {
1075        let root = setup_project(
1076            "two-context",
1077            &[
1078                ("billing.bynk", PROVIDER_SRC),
1079                ("storefront.bynk", CONSUMER_SRC),
1080            ],
1081        );
1082        let diag = crate::testkit::diagnose_project(&root);
1083        let info = diag
1084            .boundary_info
1085            .get("billing")
1086            .expect("boundary_info entry for billing");
1087
1088        let offset = find_offset(PROVIDER_SRC, "on call(");
1089        let model = wire_contract_at(
1090            "billing",
1091            PROVIDER_SRC,
1092            offset,
1093            info,
1094            &[],
1095            &diag.ty_intern,
1096            real_context_count(&diag),
1097        )
1098        .expect("a wire contract at the `price` handler");
1099
1100        assert_eq!(model.kind, BoundaryKind::Call);
1101        let params = match &model.envelope {
1102            Envelope::Keyed { params } => params,
1103            other => panic!("expected Envelope::Keyed for a two-param call, got {other:?}"),
1104        };
1105        assert_eq!(
1106            params.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
1107            vec!["sku", "qty"],
1108            "keyed params stay in declaration order"
1109        );
1110
1111        assert!(model.no_cross_context.is_none(), "two contexts + on call");
1112        let contract = model.contract.expect("on call gets a contract form");
1113        assert_eq!(
1114            contract.hash,
1115            bynk_check::contract::contract_hash(&contract.normal_form)
1116        );
1117
1118        // Independent re-derivation via the exact projection
1119        // `own_contract_hashes` uses, against the same retained table — the
1120        // hovered hash must equal what the emitter would stamp.
1121        let svc = CrossContextService {
1122            name: "Pricing".to_string(),
1123            params: vec![
1124                (
1125                    "sku".to_string(),
1126                    TypeRef::Base(BaseType::String, Span::new(0, 0)),
1127                ),
1128                (
1129                    "qty".to_string(),
1130                    TypeRef::Base(BaseType::Int, Span::new(0, 0)),
1131                ),
1132            ],
1133            return_type: TypeRef::Named(Ident {
1134                name: "Quote".to_string(),
1135                span: Span::new(0, 0),
1136            }),
1137            span: Span::new(0, 0),
1138            foreign_callers: Vec::new(),
1139        };
1140        let independent_form = contract::service_normal_form(&svc, &info.types);
1141        assert_eq!(contract.normal_form, independent_form);
1142        assert_eq!(contract.hash, contract::contract_hash(&independent_form));
1143    }
1144
1145    #[test]
1146    fn zero_param_call_handler_is_the_empty_envelope() {
1147        let src = r#"context solo
1148
1149service Ping {
1150  on call() -> Effect[Int] {
1151    1
1152  }
1153}
1154"#;
1155        let root = setup_project("zero-param", &[("solo.bynk", src)]);
1156        let diag = crate::testkit::diagnose_project(&root);
1157        let info = diag.boundary_info.get("solo").expect("entry");
1158
1159        let offset = find_offset(src, "on call()");
1160        let model = wire_contract_at(
1161            "solo",
1162            src,
1163            offset,
1164            info,
1165            &[],
1166            &diag.ty_intern,
1167            real_context_count(&diag),
1168        )
1169        .expect("a wire contract at the `Ping` handler");
1170
1171        assert!(
1172            matches!(model.envelope, Envelope::Empty),
1173            "zero params: the request body is not read, not an empty keyed object"
1174        );
1175        // Single-context project: `on call` still answers SingleContext, not
1176        // a real contract.
1177        assert_eq!(
1178            model.no_cross_context,
1179            Some(NoCrossContextReason::SingleContext)
1180        );
1181    }
1182
1183    // -- Regression: the `Ok`-overload `expr_types` fallback (plan risk 7)
1184    // -- must not extend to a bare `Ident`. `HTTP_VARIANTS` includes names
1185    // -- an author can plausibly bind as an ordinary local (`Found`, `Gone`,
1186    // -- `Conflict`, …) — without a recorded expr type, a bare `Found` read
1187    // -- must stay unknown, not get misreported as a constructed 302.
1188    #[test]
1189    fn bare_ident_collision_with_a_variant_name_is_not_misreported_without_expr_types() {
1190        const SRC: &str = r#"context oddnames
1191
1192service api from http {
1193  on GET("/x") () -> Effect[HttpResult[Int]] by Visitor {
1194    let Found = 1
1195    Found
1196  }
1197}
1198"#;
1199        let root = setup_project("ident-collision", &[("oddnames.bynk", SRC)]);
1200        let diag = crate::testkit::diagnose_project(&root);
1201        let info = diag
1202            .boundary_info
1203            .get("oddnames")
1204            .expect("boundary_info entry for oddnames");
1205
1206        let offset = find_offset(SRC, "GET(\"/x\")");
1207        // Deliberately pass an empty `expr_types` — the degraded path (an
1208        // expression the checker never typed, see `expr_types` above) this
1209        // fixture is standing in for, even though it type-checks cleanly on
1210        // its own.
1211        let model = wire_contract_at(
1212            "oddnames",
1213            SRC,
1214            offset,
1215            info,
1216            &[],
1217            &diag.ty_intern,
1218            real_context_count(&diag),
1219        )
1220        .expect("a wire contract at the GET handler");
1221
1222        assert!(
1223            model.responses.iter().all(|r| r.variant != "Found"),
1224            "a bare `Found` local must not be misreported as HttpResult.Found \
1225             without a recorded expr type: {:?}",
1226            model.responses
1227        );
1228    }
1229
1230    // -- A bare-`Ident` variant never falls back to the declared-return
1231    // -- heuristic, so it is reported only when `expr_ty` finds the
1232    // -- checker's recorded type. That lookup used to compare whole `Span`s,
1233    // -- `FileId` included: the round's spans carry a real `FileId` (T3.5,
1234    // -- #1062), the reparse's carry `FileId::UNKNOWN`, so it never matched
1235    // -- and a tail `NoContent` silently dropped out of the response set.
1236    #[test]
1237    fn bare_ident_variant_is_reported_from_real_expr_types() {
1238        const SRC: &str = r#"context flags
1239
1240service api from http {
1241  on DELETE("/flags/:name") (name: String) -> Effect[HttpResult[String]] by Visitor {
1242    NoContent
1243  }
1244}
1245"#;
1246        let root = setup_project("bare-ident-variant", &[("flags.bynk", SRC)]);
1247        let diag = crate::testkit::diagnose_project(&root);
1248        let info = diag.boundary_info.get("flags").expect("entry");
1249        let expr_types: &[(Span, TyId)] = diag
1250            .files
1251            .iter()
1252            .find(|f| f.source_path.file_name().is_some_and(|n| n == "flags.bynk"))
1253            .and_then(|f| diag.expr_types.get(&f.source_path))
1254            .map(|v| v.as_slice())
1255            .expect("flags.bynk's recorded expr types");
1256        assert!(
1257            expr_types.iter().all(|(s, _)| s.file != FileId::UNKNOWN),
1258            "the premise: analysis spans carry a real FileId"
1259        );
1260
1261        let offset = find_offset(SRC, "DELETE(");
1262        let model = wire_contract_at(
1263            "flags",
1264            SRC,
1265            offset,
1266            info,
1267            expr_types,
1268            &diag.ty_intern,
1269            real_context_count(&diag),
1270        )
1271        .expect("a wire contract at the DELETE handler");
1272
1273        assert!(
1274            model
1275                .responses
1276                .iter()
1277                .any(|r| r.status == 204 && r.variant == "NoContent"),
1278            "a tail `NoContent` must be reported as constructed: {:?}",
1279            model.responses
1280        );
1281    }
1282
1283    /// A recorded `Ty::Error` is "no type": it must degrade to the
1284    /// declared-return fallback exactly like a missing entry, not count as a
1285    /// definite non-`HttpResult` answer.
1286    #[test]
1287    fn expr_ty_matches_by_range_and_treats_error_as_no_type() {
1288        let tys = Types::new();
1289        let file = FileId(0);
1290        let error = tys.intern(Ty::Error);
1291        let http = tys.intern(Ty::HttpResult(error));
1292        let expr_types = [
1293            (Span::new_in(file, 10, 20), http),
1294            (Span::new_in(file, 30, 40), error),
1295            (Span::new_in(FileId(1), 50, 60), http),
1296        ];
1297        let walk = ResponseWalk {
1298            expr_types: &expr_types,
1299            file,
1300            tys: &tys,
1301            declared_is_http_result: true,
1302            seen: Default::default(),
1303            saw_option_question: false,
1304            out: Vec::new(),
1305        };
1306
1307        // A reparsed span (`FileId::UNKNOWN`) over the same range matches.
1308        assert!(matches!(
1309            walk.expr_ty(Span::new(10, 20)).as_deref(),
1310            Some(Ty::HttpResult(_))
1311        ));
1312        assert!(walk.is_http_result_ident(Span::new(10, 20)));
1313
1314        // `Ty::Error` is no type: the ident check stays unknown, the expr
1315        // check takes the declared-return fallback.
1316        assert!(walk.expr_ty(Span::new(30, 40)).is_none());
1317        assert!(!walk.is_http_result_ident(Span::new(30, 40)));
1318        assert!(walk.is_http_result_expr(Span::new(30, 40)));
1319
1320        // Another file's entry over the same range is not this file's type.
1321        assert!(walk.expr_ty(Span::new(50, 60)).is_none());
1322    }
1323
1324    /// `name`'s recorded expr types from a round that must be error-free —
1325    /// so a fixture that stops type-checking fails loudly here rather than
1326    /// silently degrading the walk to its fallback.
1327    fn clean_expr_types<'d>(diag: &'d crate::ProjectDiagnostics, name: &str) -> &'d [(Span, TyId)] {
1328        for f in &diag.files {
1329            assert!(
1330                f.diagnostics.is_empty(),
1331                "{:?} has diagnostics: {:?}",
1332                f.source_path,
1333                f.diagnostics
1334            );
1335        }
1336        diag.files
1337            .iter()
1338            .find(|f| f.source_path.file_name().is_some_and(|n| n == name))
1339            .and_then(|f| diag.expr_types.get(&f.source_path))
1340            .map(|v| v.as_slice())
1341            .unwrap_or_else(|| panic!("{name}'s recorded expr types"))
1342    }
1343
1344    // -- The primary-path twin of the degraded-path collision test above:
1345    // -- with the real `expr_types` (so the fixture must check cleanly,
1346    // -- hence `Ok(Found)`), `Found` is recorded as `Int`, and that recorded
1347    // -- type is what keeps it out of the response set.
1348    #[test]
1349    fn bare_ident_collision_with_a_variant_name_is_rejected_by_its_recorded_type() {
1350        const SRC: &str = r#"context oddnames
1351
1352service api from http {
1353  on GET("/x") () -> Effect[HttpResult[Int]] by Visitor {
1354    let Found = 1
1355    Ok(Found)
1356  }
1357}
1358"#;
1359        let root = setup_project("ident-collision-typed", &[("oddnames.bynk", SRC)]);
1360        let diag = crate::testkit::diagnose_project(&root);
1361        let info = diag.boundary_info.get("oddnames").expect("entry");
1362        let expr_types = clean_expr_types(&diag, "oddnames.bynk");
1363
1364        let offset = find_offset(SRC, "GET(\"/x\")");
1365        let walked = walk_expr_tys(
1366            SRC,
1367            offset,
1368            info.services["api"].span.file,
1369            expr_types,
1370            &diag.ty_intern,
1371        );
1372        assert!(
1373            walked.iter().any(|(t, ty)| t == "Found"
1374                && ty
1375                    .as_deref()
1376                    .is_some_and(|ty| !matches!(ty, Ty::HttpResult(_)))),
1377            "the tail `Found` should resolve to its recorded non-HttpResult type: {walked:?}"
1378        );
1379
1380        let model = wire_contract_at(
1381            "oddnames",
1382            SRC,
1383            offset,
1384            info,
1385            expr_types,
1386            &diag.ty_intern,
1387            real_context_count(&diag),
1388        )
1389        .expect("a wire contract at the GET handler");
1390        assert!(
1391            model.responses.iter().all(|r| r.variant != "Found"),
1392            "a bare `Found` local must not be reported as HttpResult.Found: {:?}",
1393            model.responses
1394        );
1395    }
1396
1397    // -- `classify`'s `Option?` arm has no fallback, so — like the bare
1398    // -- `Ident` arm — the boundary-implicit 404 (ADR 0177) was unreachable
1399    // -- while `expr_ty` never matched.
1400    #[test]
1401    fn option_question_in_a_handler_adds_the_boundary_implicit_404() {
1402        const SRC: &str = r#"context lookup
1403
1404fn find(n: String) -> Option[Int] {
1405  Some(1)
1406}
1407
1408service api from http {
1409  on GET("/x/:n") (n: String) -> Effect[HttpResult[Int]] by Visitor {
1410    let v = find(n)?
1411    Ok(v)
1412  }
1413}
1414"#;
1415        let root = setup_project("option-question", &[("lookup.bynk", SRC)]);
1416        let diag = crate::testkit::diagnose_project(&root);
1417        let info = diag.boundary_info.get("lookup").expect("entry");
1418        let expr_types = clean_expr_types(&diag, "lookup.bynk");
1419
1420        let model = wire_contract_at(
1421            "lookup",
1422            SRC,
1423            find_offset(SRC, "GET("),
1424            info,
1425            expr_types,
1426            &diag.ty_intern,
1427            real_context_count(&diag),
1428        )
1429        .expect("a wire contract at the GET handler");
1430        assert!(
1431            model.responses.iter().any(|r| r.status == 404
1432                && r.variant == "NotFound"
1433                && matches!(r.origin, ResponseOrigin::BoundaryImplicit { .. })),
1434            "an `Option?` short-circuit must add the boundary-implicit 404: {:?}",
1435            model.responses
1436        );
1437    }
1438
1439    // -- A file's `expr_types` also carries a sibling file's spans: the
1440    // -- checker merges `b.bynk`'s methods on a type declared in `a.bynk`
1441    // -- into `a.bynk`'s checked view. `b.bynk` is padded so its `Ok(1)`
1442    // -- (an `HttpResult`) sits at exactly the byte range of `a.bynk`'s
1443    // -- `Found` local; a range-only match would mint a 302 the handler never
1444    // -- constructs.
1445    #[test]
1446    fn a_sibling_files_span_at_the_same_range_is_not_this_files_type() {
1447        const A: &str = r#"context shop
1448
1449type Q = { n: Int }
1450
1451service api from http {
1452  on GET("/x") () -> Effect[HttpResult[Int]] by Visitor {
1453    let Found = 1
1454    Ok(Found)
1455  }
1456}
1457"#;
1458        let target = find_offset(A, "Ok(Found)") + "Ok(".len();
1459        let head = "context shop\n\nfn Q.r(self) -> HttpResult[Int] {\n";
1460        let pad = target
1461            .checked_sub(head.len())
1462            .expect("a.bynk's `Found` sits past b.bynk's header");
1463        let b = format!("{head}{}Ok(1)\n}}\n", " ".repeat(pad));
1464        assert_eq!(
1465            b.find("Ok(1)"),
1466            Some(target),
1467            "the padding lines the spans up"
1468        );
1469
1470        let root = setup_project("sibling-span", &[("shop/a.bynk", A), ("shop/b.bynk", &b)]);
1471        let diag = crate::testkit::diagnose_project(&root);
1472        let info = diag.boundary_info.get("shop").expect("entry");
1473        let expr_types = clean_expr_types(&diag, "a.bynk");
1474        let file = info.services["api"].span.file;
1475        assert!(
1476            expr_types.iter().any(|(s, t)| s.file != file
1477                && (s.start, s.end) == (target, target + 5)
1478                && matches!(*diag.ty_intern.get(*t), Ty::HttpResult(_))),
1479            "the premise: a.bynk's expr types hold b.bynk's `Ok(1)` at the same range"
1480        );
1481
1482        let model = wire_contract_at(
1483            "shop",
1484            A,
1485            find_offset(A, "GET("),
1486            info,
1487            expr_types,
1488            &diag.ty_intern,
1489            real_context_count(&diag),
1490        )
1491        .expect("a wire contract at the GET handler");
1492        assert!(
1493            model.responses.iter().all(|r| r.variant != "Found"),
1494            "b.bynk's `Ok(1)` must not type a.bynk's `Found`: {:?}",
1495            model.responses
1496        );
1497    }
1498}