Skip to main content

bynk_check/
unit_signature.rs

1//! P8.1 (#1512): `UnitId` and `UnitSignature` — the load-bearing types this
2//! whole phase's firewall (R3.14) is stated in terms of. `UnitSignature`
3//! projects a [`UnitTable`] into design notes §15's four required-annotation
4//! categories — cross-context types, fn signatures (free functions and
5//! methods), handler signatures plus storage, capability sets — with every
6//! body/body-adjacent field excluded ([DECISION B],
7//! `design/tracks/incrementality.md` §3.4/Q4) rather than merely ignored by
8//! the comparison: `body`/`requires`/`ensures` never reach [`FnSignature`],
9//! `body` never reaches [`HandlerSignature`], `init`/`annotations` never
10//! reach [`StoreFieldSignature`] — the type system, not a comparison
11//! function's own discipline, makes "no body reachable from `UnitSignature`"
12//! a fact.
13//!
14//! Stability is proved by comparing [`UnitSignature::canonical`], not the raw
15//! struct: every included fragment still carries its own `Span` (`Ident`,
16//! `Param`, every `TypeRef` variant, `TypeDecl`'s own `trivia`/
17//! `documentation`), and editing a body shifts every later declaration's own
18//! spans in the same file (the byte-offset cascade PR #1509's bot review
19//! caught one level up, in `UnitSignature`'s own design). [DECISION C]
20//! extends `contract.rs`'s `canon_type`/`service_normal_form` (ADR 0200) —
21//! already a span-free canonical rendering, proven correct by
22//! `contract_hash.rs`'s own no-false-positive fixture — to reach the new
23//! signature shapes here, rather than inventing a second erasure scheme.
24//!
25//! **The R3.14 firewall has two directions, and this module is responsible
26//! for both**: a body edit must not move [`UnitSignature::canonical`] (the
27//! exclusions above), and a genuine signature edit MUST move it — a false
28//! "unchanged" verdict silently skips recomputing every downstream
29//! consumer, the more expensive failure mode for an incrementality firewall.
30//! PR #1517's own bot review caught three real gaps on the second direction
31//! before this module first merged: [`HandlerKind`] was dropped entirely
32//! (so renaming an HTTP route or changing `on GET` to `on POST` didn't move
33//! the form), methods never reached [`UnitSignature`] at all (`UnitTable.fns`
34//! only holds free functions — `FnName::Method` is filed under
35//! `UnitTable.methods` instead, `symbols.rs:596-619`), and a `capability`
36//! declaration's own operation signatures (`CapabilityDecl.ops`, itself
37//! already body-free — "signature only; no body", `ast.rs:564`) were never
38//! projected, so retyping a capability op left every consumer's own
39//! `UnitSignature` unchanged even though its compiled contract with that
40//! capability's provider did change. All three are fixed here; see each
41//! type's own doc comment for what closed the gap.
42//!
43//! **Deliberately still excluded, named explicitly so a future reviewer
44//! doesn't have to re-derive it:** `ProviderDecl.provider_name` (an internal
45//! selector used only in tests/config to pick an implementation — never part
46//! of a `Cap.op(...)` call site, so it carries no information a consumer's
47//! own compile depends on) and handler-position annotations (`@cache`, …,
48//! `Handler.annotations: Vec<Annotation>`) — an `AnnotationArg.value` is an
49//! arbitrary `Expr`, and canonicalising `Expr` the way `canon_type` already
50//! does for `TypeRef` is real, unscoped work this slice does not take on;
51//! flagged for whichever future slice needs it, the same "flag for the slice
52//! that will actually pin it" discipline this phase has applied throughout.
53//! `ServiceProtocol::Events`'s own `pattern`/`schema_dispatch` fields (the
54//! structural payload filter and `via schema(N)` clause) are captured only
55//! as "present or absent", not rendered field-by-field, for the same
56//! Expr/pattern-canonicalisation reason — narrower than ideal, but strictly
57//! better than the pre-fix state where the whole protocol was invisible.
58//!
59//! [`unit_signature_for`]'s only caller is `tests/unit_signature_stability.rs`
60//! (P8.2), and that is deliberate: this module is R3.14's own *proof* — the
61//! firewall stated as a type and checked by a test — not a production path.
62//! Phase 8's two structural consumers-to-be (`ProjectGraph`, P8.3, and the
63//! `DefId`-keyed `Body`/`TypeOf` queries, P8.5) were built beside it and
64//! deleted on 2 September 2026 (#1537, ADR in that PR's pending file): nothing
65//! called them and no scheduler existed to. This module stays because
66//! R3.15's trigger (#1523 — keystroke-to-diagnostic latency *attributed by
67//! level*) presupposes a unit level to attribute to; the gated
68//! `incremental_query_types` probe certifies it is present and its
69//! stability test exists.
70
71use std::collections::{BTreeMap, BTreeSet, HashMap};
72use std::sync::Arc;
73
74use bynk_syntax::ast::{CapRef, Handler, HandlerKind, Param, ServiceProtocol, TypeDecl, TypeRef};
75
76use crate::symbols::UnitTable;
77
78/// [DECISION A]: a stable, hashable identity for a unit, reusing the
79/// unit-name `String` every `UnitTable`/`combined_types_for` caller already
80/// keys on today, rather than introducing a fresh interned-integer scheme —
81/// no `index_vec` crate (or hand-rolled equivalent) exists anywhere in this
82/// codebase, confirmed by grep. `UnitSignature` only needs `UnitId` to be a
83/// stable, hashable, comparable identity for R3.14's own proof; it does not
84/// need to be dense or `IndexVec`-compatible. P8.3 resolved the fork this
85/// left open by adapting `ProjectGraph` to the string-keyed `UnitId` (ADR
86/// 0415); that graph was deleted by #1537, so if R3.15's trigger ever fires,
87/// ADR 0415's recorded shape is what a rebuild adapts to — this type stays
88/// a string newtype either way.
89#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
90pub struct UnitId(pub String);
91
92/// [DECISION B]: [`Param`], body-free (it already is — kept as its own type
93/// only so [`FnSignature`]/[`HandlerSignature`] don't reach back into
94/// `bynk-syntax::ast::Param` and risk a future field being added there and
95/// silently flowing through unexamined).
96#[derive(Debug, Clone)]
97pub struct ParamSignature {
98    pub name: String,
99    pub type_ref: TypeRef,
100}
101
102fn param_signatures(params: &[Param]) -> Vec<ParamSignature> {
103    params
104        .iter()
105        .map(|p| ParamSignature {
106            name: p.name.name.clone(),
107            type_ref: p.type_ref.clone(),
108        })
109        .collect()
110}
111
112/// [DECISION B]: [`bynk_syntax::ast::FnDecl`], body-free. Excludes `body`,
113/// `requires`, `ensures` (all body-adjacent — `requires`/`ensures` scope over
114/// parameters and the result but are not part of design notes §15's own
115/// required-annotation list, per Q4) and `documentation`. Used for both free
116/// functions ([`UnitSignature::fns`]) and methods ([`UnitSignature::methods`]).
117#[derive(Debug, Clone)]
118pub struct FnSignature {
119    pub type_params: Vec<String>,
120    pub name: String,
121    pub params: Vec<ParamSignature>,
122    pub return_type: TypeRef,
123    pub has_self: bool,
124}
125
126fn fn_signature(name: &str, decl: &bynk_syntax::ast::FnDecl) -> FnSignature {
127    FnSignature {
128        type_params: decl
129            .type_params
130            .iter()
131            .map(|tp| tp.name.name.clone())
132            .collect(),
133        name: name.to_string(),
134        params: param_signatures(&decl.params),
135        return_type: decl.return_type.clone(),
136        has_self: decl.has_self,
137    }
138}
139
140/// The `instance`/`statics` split PR #1517's bot review found missing
141/// entirely: `UnitTable.fns` (what [`UnitSignature::fns`] projects) only
142/// ever holds `FnName::Free` entries — `FnName::Method` is filed under
143/// `UnitTable.methods: HashMap<String, MethodTable>` instead
144/// (`symbols.rs:596-619`), keyed by the attached type's name, with instance
145/// and static methods in their own sub-maps (mirroring
146/// [`crate::resolver::MethodTable`]'s own shape exactly, rather than
147/// flattening into one map keyed by method name alone — an instance and a
148/// static method can share a name for the same type without colliding in
149/// `MethodTable` itself, so flattening here would silently drop one).
150#[derive(Debug, Clone, Default)]
151pub struct MethodTableSignature {
152    pub instance: BTreeMap<String, FnSignature>,
153    pub statics: BTreeMap<String, FnSignature>,
154}
155
156/// A closed, body-free mirror of [`HandlerKind`] — every field `HandlerKind`
157/// itself carries (`HttpMethod`, a route/cron-expression `String`) is already
158/// a plain value with nothing body-adjacent to exclude. PR #1517's own bot
159/// review: dropping this entirely let two handlers with the same params and
160/// return type but different HTTP methods or routes canonicalise identically
161/// — renaming a route is not a body edit, and R3.14's own firewall must
162/// treat it as a real signature change.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub enum HandlerKindSignature {
165    Call,
166    Http { method: String, path: String },
167    Cron { expr: String },
168    Message,
169    Open,
170    Close,
171    Event,
172}
173
174fn handler_kind_signature(k: &HandlerKind) -> HandlerKindSignature {
175    match k {
176        HandlerKind::Call => HandlerKindSignature::Call,
177        HandlerKind::Http { method, path } => HandlerKindSignature::Http {
178            method: method.as_str().to_string(),
179            path: path.clone(),
180        },
181        HandlerKind::Cron { expr } => HandlerKindSignature::Cron { expr: expr.clone() },
182        HandlerKind::Message => HandlerKindSignature::Message,
183        HandlerKind::Open => HandlerKindSignature::Open,
184        HandlerKind::Close => HandlerKindSignature::Close,
185        HandlerKind::Event => HandlerKindSignature::Event,
186    }
187}
188
189/// [DECISION B]: [`Handler`], body-free. Excludes `body`, `by_clause` (an
190/// actor binder, resolved at the call boundary rather than part of the
191/// wire-visible shape), `annotations` (see this module's own doc comment —
192/// deferred, needs `Expr` canonicalisation) and `documentation`. `given` is
193/// kept — it's this handler's own slice of the capability-set category.
194/// `kind` is kept (see [`HandlerKindSignature`]) — dropping it was PR
195/// #1517's own bot review finding #1.
196#[derive(Debug, Clone)]
197pub struct HandlerSignature {
198    pub kind: HandlerKindSignature,
199    pub method_name: Option<String>,
200    pub params: Vec<ParamSignature>,
201    pub return_type: TypeRef,
202    pub given: Vec<String>,
203}
204
205fn handler_signature(h: &Handler) -> HandlerSignature {
206    HandlerSignature {
207        kind: handler_kind_signature(&h.kind),
208        method_name: h.method_name.as_ref().map(|i| i.name.clone()),
209        params: param_signatures(&h.params),
210        return_type: h.return_type.clone(),
211        given: cap_ref_names(&h.given),
212    }
213}
214
215/// [DECISION B]: [`bynk_syntax::ast::StoreField`], body-free. Excludes `init`
216/// and `annotations` (both body-adjacent per Q4 — annotations govern the
217/// field's own storage behaviour, not its externally-relevant shape) and
218/// `documentation`.
219#[derive(Debug, Clone)]
220pub struct StoreFieldSignature {
221    pub name: String,
222    pub kind_head: String,
223    pub kind_args: Vec<TypeRef>,
224}
225
226/// A body-free mirror of [`bynk_syntax::ast::CapabilityOp`] — already
227/// "signature only; no body" in its own doc comment (`ast.rs:564`), so
228/// nothing here needs excluding beyond `documentation`/`span`/`trivia`.
229/// Added by PR #1517's own bot review (finding #3): a `capability`
230/// declaration's own ops are the abstract signature every consumer compiles
231/// its `Cap.op(...)` call sites against, not `ProviderDecl.ops` (which carry
232/// a real `body: Block` each — a provider's own implementation, correctly
233/// excluded, same as any other body).
234#[derive(Debug, Clone)]
235pub struct CapabilityOpSignature {
236    pub name: String,
237    pub type_params: Vec<String>,
238    pub params: Vec<ParamSignature>,
239    pub return_type: TypeRef,
240}
241
242/// The capability-set category (design notes §15's fourth): what a unit
243/// exports (`table.exported_capabilities`, already plain strings), what a
244/// unit itself declares (`table.capabilities`'s own op signatures — added by
245/// PR #1517's own bot review), and what each provider/service declares it
246/// needs (`given`/`default_given`). Every `CapRef` collapses to its rendered
247/// name (`context.capability`, or bare `capability` when local) — its own
248/// `Span` is dropped, matching every other category's span-erasure
249/// discipline.
250#[derive(Debug, Clone, Default)]
251pub struct CapabilitySignature {
252    pub exported: BTreeSet<String>,
253    /// Keyed by capability name (`table.capabilities`).
254    pub declared: BTreeMap<String, Vec<CapabilityOpSignature>>,
255    /// Keyed by the implemented capability's own name (`ProviderDecl.capability`).
256    pub provider_given: BTreeMap<String, Vec<String>>,
257    /// Keyed by service name (`ServiceDecl.default_given`).
258    pub service_given: BTreeMap<String, Vec<String>>,
259}
260
261/// A `CapRef`'s canonical name: `context.capability` when cross-context,
262/// bare `capability` when local. Spans are dropped — matching every other
263/// category's span-erasure discipline.
264fn cap_ref_name(c: &CapRef) -> String {
265    match &c.context {
266        Some(q) => format!("{}.{}", q.joined(), c.key()),
267        None => c.key().to_string(),
268    }
269}
270
271fn cap_ref_names(refs: &[CapRef]) -> Vec<String> {
272    refs.iter().map(cap_ref_name).collect()
273}
274
275/// A body-free mirror of [`ServiceProtocol`] — added by PR #1517's own bot
276/// review ("worth a look"): `from http` vs. `from queue(...)` vs.
277/// `from websocket(...)` changes a service's entire external surface, and
278/// nothing about that fact is a body or body-adjacent to a handler. `Events`'
279/// own `pattern`/`schema_dispatch` are collapsed to `bool` presence (see this
280/// module's own doc comment for why — deferred `Expr`/pattern
281/// canonicalisation) rather than dropped outright.
282///
283/// No `PartialEq`/`Eq` derive: `TypeRef` (used by `WebSocket`/`Events`) does
284/// not implement either — compare through [`crate::contract::canon_unit_signature`]'s
285/// own rendering, the same "compare the canonical form" posture
286/// [`UnitSignature`] itself takes.
287#[derive(Debug, Clone)]
288pub enum ProtocolSignature {
289    Call,
290    Http,
291    Cron,
292    Queue {
293        name: String,
294    },
295    WebSocket {
296        in_type: TypeRef,
297        out_type: TypeRef,
298    },
299    Events {
300        event_type: TypeRef,
301        has_pattern: bool,
302        has_schema_dispatch: bool,
303    },
304}
305
306fn protocol_signature(p: &ServiceProtocol) -> ProtocolSignature {
307    match p {
308        ServiceProtocol::Call => ProtocolSignature::Call,
309        ServiceProtocol::Http => ProtocolSignature::Http,
310        ServiceProtocol::Cron => ProtocolSignature::Cron,
311        ServiceProtocol::Queue { name } => ProtocolSignature::Queue { name: name.clone() },
312        ServiceProtocol::WebSocket { in_type, out_type } => ProtocolSignature::WebSocket {
313            in_type: in_type.clone(),
314            out_type: out_type.clone(),
315        },
316        ServiceProtocol::Events {
317            event_type,
318            pattern,
319            schema_dispatch,
320        } => ProtocolSignature::Events {
321            event_type: event_type.clone(),
322            has_pattern: pattern.is_some(),
323            has_schema_dispatch: schema_dispatch.is_some(),
324        },
325    }
326}
327
328/// P8.1 (#1512): a unit's stable signature — everything about it that must
329/// survive an edit inside a function/handler body untouched (R3.14, the
330/// phase's firewall).
331///
332/// Deliberately does not derive `PartialEq`: two signatures compare equal
333/// through [`UnitSignature::canonical`] ([DECISION C]), not by structural
334/// equality on the raw fields — the same "compare the canonical form, not
335/// the raw AST value" shape `contract_hash.rs`'s own fixture already trusts
336/// for ADR 0200. Comparing the raw struct would reintroduce exactly the
337/// span-instability this type exists to erase.
338#[derive(Debug, Clone)]
339pub struct UnitSignature {
340    pub id: UnitId,
341    /// `combined_types_for`'s own output, reused unchanged ([DECISION A] of
342    /// ADR 0412, Q1) — the cross-context-types category.
343    pub combined_types: HashMap<String, Arc<TypeDecl>>,
344    /// Free functions only — keyed by `FnName`'s own rendered form, matching
345    /// `UnitTable.fns`'s own key. Methods live in [`Self::methods`] instead
346    /// (see [`MethodTableSignature`]'s own doc comment for why).
347    pub fns: BTreeMap<String, FnSignature>,
348    /// Keyed by the attached type's name, matching `UnitTable.methods`.
349    pub methods: BTreeMap<String, MethodTableSignature>,
350    /// Keyed by owning service name; each maps to its handlers in
351    /// declaration order. Kept separate from [`Self::agent_handlers`] (PR
352    /// #1517's own bot review, lower-priority item): a flat shared namespace
353    /// let a `service Foo`'s handlers be silently overwritten by an
354    /// `agent Foo`'s own handlers, and the rendering couldn't distinguish
355    /// the two owners either.
356    pub service_handlers: BTreeMap<String, Vec<HandlerSignature>>,
357    /// Keyed by owning agent name.
358    pub agent_handlers: BTreeMap<String, Vec<HandlerSignature>>,
359    /// Keyed by service name (`ServiceDecl.protocol`).
360    pub service_protocols: BTreeMap<String, ProtocolSignature>,
361    /// Keyed by owning agent name; `UnitTable.services`/`.providers` carry no
362    /// store fields of their own.
363    pub store_fields: BTreeMap<String, Vec<StoreFieldSignature>>,
364    pub capabilities: CapabilitySignature,
365}
366
367/// Builds a [`UnitSignature`] for `name` from `table`, reusing `combined_types`
368/// (typically a fresh `combined_types_for(name, ..)` call, mirrored here as a
369/// parameter rather than recomputed so a caller building both alongside each
370/// other pays for one traversal, not two).
371///
372/// Every category below is a direct, field-by-field projection off
373/// `UnitTable`'s own real shapes — see this module's own doc comment and
374/// `design/tracks/incrementality.md` §3.4 (Q4) for which fields are excluded
375/// and why.
376pub fn unit_signature_for(
377    name: &str,
378    table: &UnitTable,
379    combined_types: HashMap<String, Arc<TypeDecl>>,
380) -> UnitSignature {
381    let fns = table
382        .fns
383        .iter()
384        .map(|(fname, decl)| (fname.clone(), fn_signature(fname, decl)))
385        .collect();
386
387    let methods = table
388        .methods
389        .iter()
390        .map(|(type_name, mt)| {
391            let sig = MethodTableSignature {
392                instance: mt
393                    .instance
394                    .iter()
395                    .map(|(mname, decl)| (mname.clone(), fn_signature(mname, decl)))
396                    .collect(),
397                statics: mt
398                    .statics
399                    .iter()
400                    .map(|(mname, decl)| (mname.clone(), fn_signature(mname, decl)))
401                    .collect(),
402            };
403            (type_name.clone(), sig)
404        })
405        .collect();
406
407    let mut service_handlers: BTreeMap<String, Vec<HandlerSignature>> = BTreeMap::new();
408    let mut service_protocols: BTreeMap<String, ProtocolSignature> = BTreeMap::new();
409    for (sname, sdecl) in &table.services {
410        service_handlers.insert(
411            sname.clone(),
412            sdecl.handlers.iter().map(handler_signature).collect(),
413        );
414        service_protocols.insert(sname.clone(), protocol_signature(&sdecl.protocol));
415    }
416
417    let mut agent_handlers: BTreeMap<String, Vec<HandlerSignature>> = BTreeMap::new();
418    let mut store_fields: BTreeMap<String, Vec<StoreFieldSignature>> = BTreeMap::new();
419    for (aname, adecl) in &table.agents {
420        agent_handlers.insert(
421            aname.clone(),
422            adecl.handlers.iter().map(handler_signature).collect(),
423        );
424        store_fields.insert(
425            aname.clone(),
426            adecl
427                .store_fields
428                .iter()
429                .map(|f| StoreFieldSignature {
430                    name: f.name.name.clone(),
431                    kind_head: f.kind.head.name.clone(),
432                    kind_args: f.kind.args.clone(),
433                })
434                .collect(),
435        );
436    }
437
438    let declared: BTreeMap<String, Vec<CapabilityOpSignature>> = table
439        .capabilities
440        .iter()
441        .map(|(cname, cdecl)| {
442            let ops = cdecl
443                .ops
444                .iter()
445                .map(|op| CapabilityOpSignature {
446                    name: op.name.name.clone(),
447                    type_params: op
448                        .type_params
449                        .iter()
450                        .map(|tp| tp.name.name.clone())
451                        .collect(),
452                    params: param_signatures(&op.params),
453                    return_type: op.return_type.clone(),
454                })
455                .collect();
456            (cname.clone(), ops)
457        })
458        .collect();
459
460    let mut provider_given: BTreeMap<String, Vec<String>> = BTreeMap::new();
461    for pdecl in table.providers.values() {
462        provider_given.insert(pdecl.capability.name.clone(), cap_ref_names(&pdecl.given));
463    }
464
465    let mut service_given: BTreeMap<String, Vec<String>> = BTreeMap::new();
466    for (sname, sdecl) in &table.services {
467        service_given.insert(sname.clone(), cap_ref_names(&sdecl.default_given));
468    }
469
470    UnitSignature {
471        id: UnitId(name.to_string()),
472        combined_types,
473        fns,
474        methods,
475        service_handlers,
476        agent_handlers,
477        service_protocols,
478        store_fields,
479        capabilities: CapabilitySignature {
480            exported: table.exported_capabilities.iter().cloned().collect(),
481            declared,
482            provider_given,
483            service_given,
484        },
485    }
486}
487
488impl UnitSignature {
489    /// [DECISION C]: the canonical, span-free rendering R3.14's own proof
490    /// compares (`unit_signature_before.canonical() ==
491    /// unit_signature_after.canonical()`), never the raw struct. Delegates
492    /// every fragment through `contract.rs`'s canonical renderers so this
493    /// type and `service_contract_hash` cannot silently diverge on what
494    /// "the same type" means.
495    pub fn canonical(&self) -> String {
496        crate::contract::canon_unit_signature(self)
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use std::path::PathBuf;
504
505    use bynk_project::{ParsedFile, UnitKind};
506    use bynk_syntax::ast::SourceUnit;
507
508    use crate::symbols::{build_unit_table, combined_types_for};
509
510    fn parsed(source_path: &str, src: &str) -> ParsedFile {
511        let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
512        let unit = bynk_syntax::parser::parse_unit(&tokens, src).expect("parse");
513        let kind = match &unit {
514            SourceUnit::Commons(_) => UnitKind::Commons,
515            SourceUnit::Context(_) => UnitKind::Context,
516            SourceUnit::Suite(_) | SourceUnit::Adapter(_) => unreachable!("fixture only"),
517        };
518        ParsedFile::new(
519            PathBuf::from(source_path),
520            PathBuf::from(source_path),
521            None,
522            src.to_string(),
523            unit,
524            kind,
525            false,
526        )
527    }
528
529    const SHARED_SRC: &str = r#"commons demo.shared
530
531type Widget = {
532  id: String,
533}
534"#;
535
536    const CONTEXT_SRC: &str = r#"context demo.svc
537
538uses demo.shared
539
540exports capability { Notifier }
541
542fn double(x: Int) -> Int {
543  x * 2
544}
545
546capability Notifier {
547  fn ping(seed: Int) -> Effect[Int]
548}
549
550provides Notifier = StubNotifier {
551  fn ping(seed: Int) -> Effect[Int] {
552    Effect.pure(seed + 1)
553  }
554}
555
556agent Counter {
557  key id: String
558
559  store count: Cell[Int]
560
561  on call increment() -> Effect[Int] {
562    count := count + 1
563    Effect.pure(count)
564  }
565}
566
567service api from http {
568  on GET("/widgets/:id") (id: String) -> Effect[HttpResult[Widget]] by Visitor given Notifier {
569    Ok(Widget { id: id })
570  }
571}
572"#;
573
574    fn build_signature() -> UnitSignature {
575        let shared = parsed("demo/shared.bynk", SHARED_SRC);
576        let context = parsed("demo/svc.bynk", CONTEXT_SRC);
577
578        let mut errors = Vec::new();
579        let shared_table = build_unit_table(
580            "demo.shared",
581            UnitKind::Commons,
582            &[0],
583            &[shared],
584            &mut errors,
585        );
586        let context_table =
587            build_unit_table("demo.svc", UnitKind::Context, &[0], &[context], &mut errors);
588        assert!(errors.is_empty(), "fixture must parse cleanly: {errors:?}");
589
590        let mut unit_tables = HashMap::new();
591        unit_tables.insert("demo.shared".to_string(), shared_table);
592        unit_tables.insert("demo.svc".to_string(), context_table.clone());
593        let mut unit_uses = HashMap::new();
594        unit_uses.insert("demo.svc".to_string(), vec!["demo.shared".to_string()]);
595
596        let combined = combined_types_for("demo.svc", &unit_tables, &unit_uses);
597        unit_signature_for("demo.svc", &context_table, combined)
598    }
599
600    /// P8.1 (#1512), the issue's own "Done when": builds a `UnitSignature`
601    /// for a fixture unit carrying a function (`double`), a handler in both
602    /// a service (`api`'s `GET` route) and an agent (`Counter`'s
603    /// `increment`), a `store` field (`Counter.count`) and a cross-context
604    /// type reference (`Widget`, reached via `uses demo.shared`) — every
605    /// category [DECISION B]'s field list names — and proves the
606    /// field-exclusion list is complete for all of them.
607    ///
608    /// The exclusion itself is a **compile-time fact, not a runtime
609    /// assertion**, per [DECISION B]: `FnSignature`/`HandlerSignature`/
610    /// `StoreFieldSignature` have no `body`/`requires`/`ensures`/`init`/
611    /// `annotations` field to hold one — this test's real job is proving the
612    /// builder actually reaches every category on real parsed source (a
613    /// hand-assembled AST fixture could hide a field the builder silently
614    /// skips), and asserting the shape that *does* survive is exactly the
615    /// externally-relevant one.
616    #[test]
617    fn unit_signature_excludes_every_body_and_body_adjacent_field() {
618        let sig = build_signature();
619
620        assert_eq!(sig.id, UnitId("demo.svc".to_string()));
621
622        // Cross-context type reference: `Widget` reached only through `uses`.
623        assert!(sig.combined_types.contains_key("Widget"));
624
625        // Fn signature.
626        let f = sig.fns.get("double").expect("double must be projected");
627        assert_eq!(f.params.len(), 1);
628        assert_eq!(f.params[0].name, "x");
629
630        // Service handler, including its own `given` and `kind`.
631        let api_handlers = sig
632            .service_handlers
633            .get("api")
634            .expect("api's handler must be projected");
635        assert_eq!(api_handlers.len(), 1);
636        assert_eq!(api_handlers[0].given, vec!["Notifier".to_string()]);
637        assert_eq!(
638            api_handlers[0].kind,
639            HandlerKindSignature::Http {
640                method: "GET".to_string(),
641                path: "/widgets/:id".to_string(),
642            }
643        );
644
645        // Service protocol.
646        assert!(matches!(
647            sig.service_protocols.get("api"),
648            Some(ProtocolSignature::Http)
649        ));
650
651        // Agent handler and store field.
652        let agent_handlers = sig
653            .agent_handlers
654            .get("Counter")
655            .expect("Counter's handler must be projected");
656        assert_eq!(agent_handlers.len(), 1);
657        assert_eq!(agent_handlers[0].method_name.as_deref(), Some("increment"));
658
659        let store = sig
660            .store_fields
661            .get("Counter")
662            .expect("store field must be projected");
663        assert_eq!(store.len(), 1);
664        assert_eq!(store[0].name, "count");
665        assert_eq!(store[0].kind_head, "Cell");
666
667        // Capability-set category: what's exported, what's declared, and what
668        // a provider needs.
669        assert!(sig.capabilities.exported.contains("Notifier"));
670        let ops = sig
671            .capabilities
672            .declared
673            .get("Notifier")
674            .expect("Notifier's own op signatures must be projected");
675        assert_eq!(ops.len(), 1);
676        assert_eq!(ops[0].name, "ping");
677        assert_eq!(
678            sig.capabilities.provider_given.get("Notifier"),
679            Some(&Vec::<String>::new())
680        );
681
682        // The canonical rendering must actually run over every category above
683        // without panicking, and must be deterministic.
684        let rendered = sig.canonical();
685        assert_eq!(rendered, sig.canonical());
686        assert!(rendered.contains("double"));
687        assert!(rendered.contains("Widget"));
688        assert!(rendered.contains("count"));
689        assert!(rendered.contains("Notifier"));
690        assert!(rendered.contains("ping"));
691    }
692
693    /// PR #1517's own bot review: every assertion in the fixture above is
694    /// positive ("this category got projected"), which cannot fail for a
695    /// signature-changing edit the canonical form fails to notice. These
696    /// four mutations are the other direction of R3.14's own firewall — each
697    /// is a real signature change (never a body edit) and each must move
698    /// `canonical()`, closing the review's three material findings (dropped
699    /// `HandlerKind`, missing methods — covered by the `double`/capability-op
700    /// assertions above plus the route/method/protocol mutations below — and
701    /// missing capability-op signatures). The service/agent handler
702    /// namespace collision (the review's lower-priority item) is closed
703    /// structurally instead, by [`UnitSignature`] now keeping
704    /// `service_handlers`/`agent_handlers` as separate fields — the same
705    /// "make it a compile-time fact, not a runtime assertion" posture
706    /// [DECISION B] already established for body-field exclusion.
707    #[test]
708    fn a_genuine_signature_edit_always_changes_the_canonical_form() {
709        let base = build_signature().canonical();
710
711        let renamed_route = CONTEXT_SRC.replacen("/widgets/:id", "/things/:id", 1);
712        assert_ne!(base, signature_for_context(&renamed_route), "route rename");
713
714        let different_method = CONTEXT_SRC.replacen("on GET(", "on POST(", 1);
715        assert_ne!(
716            base,
717            signature_for_context(&different_method),
718            "GET -> POST"
719        );
720
721        let retyped_cap_op = CONTEXT_SRC.replacen(
722            "fn ping(seed: Int) -> Effect[Int]",
723            "fn ping(seed: String) -> Effect[Int]",
724            1,
725        );
726        assert_ne!(
727            base,
728            signature_for_context(&retyped_cap_op),
729            "capability op retyped"
730        );
731
732        // The queue protocol requires `on message`, not `on GET`, so a real
733        // `from queue(...)` fixture would need its own handler shape; the
734        // protocol tag itself is checked directly at the `ServiceProtocol`
735        // projection level instead of through a full re-parse.
736        assert_ne!(
737            crate::contract::canon_unit_signature(&with_protocol(ServiceProtocol::Queue {
738                name: "q".to_string()
739            })),
740            crate::contract::canon_unit_signature(&with_protocol(ServiceProtocol::Http)),
741            "http -> queue"
742        );
743    }
744
745    fn signature_for_context(context_src: &str) -> String {
746        let shared = parsed("demo/shared.bynk", SHARED_SRC);
747        let context = parsed("demo/svc.bynk", context_src);
748
749        let mut errors = Vec::new();
750        let shared_table = build_unit_table(
751            "demo.shared",
752            UnitKind::Commons,
753            &[0],
754            &[shared],
755            &mut errors,
756        );
757        let context_table =
758            build_unit_table("demo.svc", UnitKind::Context, &[0], &[context], &mut errors);
759        assert!(
760            errors.is_empty(),
761            "mutated fixture must still parse cleanly: {errors:?}"
762        );
763
764        let mut unit_tables = HashMap::new();
765        unit_tables.insert("demo.shared".to_string(), shared_table);
766        unit_tables.insert("demo.svc".to_string(), context_table.clone());
767        let mut unit_uses = HashMap::new();
768        unit_uses.insert("demo.svc".to_string(), vec!["demo.shared".to_string()]);
769
770        let combined = combined_types_for("demo.svc", &unit_tables, &unit_uses);
771        unit_signature_for("demo.svc", &context_table, combined).canonical()
772    }
773
774    fn with_protocol(protocol: ServiceProtocol) -> UnitSignature {
775        let mut sig = build_signature();
776        sig.service_protocols
777            .insert("api".to_string(), protocol_signature(&protocol));
778        sig
779    }
780}