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    Rejected,
173}
174
175fn handler_kind_signature(k: &HandlerKind) -> HandlerKindSignature {
176    match k {
177        HandlerKind::Call => HandlerKindSignature::Call,
178        HandlerKind::Http { method, path } => HandlerKindSignature::Http {
179            method: method.as_str().to_string(),
180            path: path.clone(),
181        },
182        HandlerKind::Cron { expr } => HandlerKindSignature::Cron { expr: expr.clone() },
183        HandlerKind::Message => HandlerKindSignature::Message,
184        HandlerKind::Open => HandlerKindSignature::Open,
185        HandlerKind::Close => HandlerKindSignature::Close,
186        HandlerKind::Event => HandlerKindSignature::Event,
187        HandlerKind::Rejected => HandlerKindSignature::Rejected,
188    }
189}
190
191/// [DECISION B]: [`Handler`], body-free. Excludes `body`, `by_clause` (an
192/// actor binder, resolved at the call boundary rather than part of the
193/// wire-visible shape), `annotations` (see this module's own doc comment —
194/// deferred, needs `Expr` canonicalisation) and `documentation`. `given` is
195/// kept — it's this handler's own slice of the capability-set category.
196/// `kind` is kept (see [`HandlerKindSignature`]) — dropping it was PR
197/// #1517's own bot review finding #1.
198#[derive(Debug, Clone)]
199pub struct HandlerSignature {
200    pub kind: HandlerKindSignature,
201    pub method_name: Option<String>,
202    pub params: Vec<ParamSignature>,
203    pub return_type: TypeRef,
204    pub given: Vec<String>,
205}
206
207fn handler_signature(h: &Handler) -> HandlerSignature {
208    HandlerSignature {
209        kind: handler_kind_signature(&h.kind),
210        method_name: h.method_name.as_ref().map(|i| i.name.clone()),
211        params: param_signatures(&h.params),
212        return_type: h.return_type.clone(),
213        given: cap_ref_names(&h.given),
214    }
215}
216
217/// [DECISION B]: [`bynk_syntax::ast::StoreField`], body-free. Excludes `init`
218/// and `annotations` (both body-adjacent per Q4 — annotations govern the
219/// field's own storage behaviour, not its externally-relevant shape) and
220/// `documentation`.
221#[derive(Debug, Clone)]
222pub struct StoreFieldSignature {
223    pub name: String,
224    pub kind_head: String,
225    pub kind_args: Vec<TypeRef>,
226}
227
228/// A body-free mirror of [`bynk_syntax::ast::CapabilityOp`] — already
229/// "signature only; no body" in its own doc comment (`ast.rs:564`), so
230/// nothing here needs excluding beyond `documentation`/`span`/`trivia`.
231/// Added by PR #1517's own bot review (finding #3): a `capability`
232/// declaration's own ops are the abstract signature every consumer compiles
233/// its `Cap.op(...)` call sites against, not `ProviderDecl.ops` (which carry
234/// a real `body: Block` each — a provider's own implementation, correctly
235/// excluded, same as any other body).
236#[derive(Debug, Clone)]
237pub struct CapabilityOpSignature {
238    pub name: String,
239    pub type_params: Vec<String>,
240    pub params: Vec<ParamSignature>,
241    pub return_type: TypeRef,
242}
243
244/// The capability-set category (design notes §15's fourth): what a unit
245/// exports (`table.exported_capabilities`, already plain strings), what a
246/// unit itself declares (`table.capabilities`'s own op signatures — added by
247/// PR #1517's own bot review), and what each provider/service declares it
248/// needs (`given`/`default_given`). Every `CapRef` collapses to its rendered
249/// name (`context.capability`, or bare `capability` when local) — its own
250/// `Span` is dropped, matching every other category's span-erasure
251/// discipline.
252#[derive(Debug, Clone, Default)]
253pub struct CapabilitySignature {
254    pub exported: BTreeSet<String>,
255    /// Keyed by capability name (`table.capabilities`).
256    pub declared: BTreeMap<String, Vec<CapabilityOpSignature>>,
257    /// Keyed by the implemented capability's own name (`ProviderDecl.capability`).
258    pub provider_given: BTreeMap<String, Vec<String>>,
259    /// Keyed by service name (`ServiceDecl.default_given`).
260    pub service_given: BTreeMap<String, Vec<String>>,
261}
262
263/// A `CapRef`'s canonical name: `context.capability` when cross-context,
264/// bare `capability` when local. Spans are dropped — matching every other
265/// category's span-erasure discipline.
266fn cap_ref_name(c: &CapRef) -> String {
267    match &c.context {
268        Some(q) => format!("{}.{}", q.joined(), c.key()),
269        None => c.key().to_string(),
270    }
271}
272
273fn cap_ref_names(refs: &[CapRef]) -> Vec<String> {
274    refs.iter().map(cap_ref_name).collect()
275}
276
277/// A body-free mirror of [`ServiceProtocol`] — added by PR #1517's own bot
278/// review ("worth a look"): `from http` vs. `from queue(...)` vs.
279/// `from websocket(...)` changes a service's entire external surface, and
280/// nothing about that fact is a body or body-adjacent to a handler. `Events`'
281/// own `pattern`/`schema_dispatch` are collapsed to `bool` presence (see this
282/// module's own doc comment for why — deferred `Expr`/pattern
283/// canonicalisation) rather than dropped outright.
284///
285/// No `PartialEq`/`Eq` derive: `TypeRef` (used by `WebSocket`/`Events`) does
286/// not implement either — compare through [`crate::contract::canon_unit_signature`]'s
287/// own rendering, the same "compare the canonical form" posture
288/// [`UnitSignature`] itself takes.
289#[derive(Debug, Clone)]
290pub enum ProtocolSignature {
291    Call,
292    Http,
293    Cron,
294    Queue {
295        name: String,
296    },
297    WebSocket {
298        in_type: TypeRef,
299        out_type: TypeRef,
300    },
301    Events {
302        event_type: TypeRef,
303        has_pattern: bool,
304        has_schema_dispatch: bool,
305    },
306}
307
308fn protocol_signature(p: &ServiceProtocol) -> ProtocolSignature {
309    match p {
310        ServiceProtocol::Call => ProtocolSignature::Call,
311        ServiceProtocol::Http => ProtocolSignature::Http,
312        ServiceProtocol::Cron => ProtocolSignature::Cron,
313        ServiceProtocol::Queue { name } => ProtocolSignature::Queue { name: name.clone() },
314        ServiceProtocol::WebSocket { in_type, out_type } => ProtocolSignature::WebSocket {
315            in_type: in_type.clone(),
316            out_type: out_type.clone(),
317        },
318        ServiceProtocol::Events {
319            event_type,
320            pattern,
321            schema_dispatch,
322        } => ProtocolSignature::Events {
323            event_type: event_type.clone(),
324            has_pattern: pattern.is_some(),
325            has_schema_dispatch: schema_dispatch.is_some(),
326        },
327    }
328}
329
330/// P8.1 (#1512): a unit's stable signature — everything about it that must
331/// survive an edit inside a function/handler body untouched (R3.14, the
332/// phase's firewall).
333///
334/// Deliberately does not derive `PartialEq`: two signatures compare equal
335/// through [`UnitSignature::canonical`] ([DECISION C]), not by structural
336/// equality on the raw fields — the same "compare the canonical form, not
337/// the raw AST value" shape `contract_hash.rs`'s own fixture already trusts
338/// for ADR 0200. Comparing the raw struct would reintroduce exactly the
339/// span-instability this type exists to erase.
340#[derive(Debug, Clone)]
341pub struct UnitSignature {
342    pub id: UnitId,
343    /// `combined_types_for`'s own output, reused unchanged ([DECISION A] of
344    /// ADR 0412, Q1) — the cross-context-types category.
345    pub combined_types: HashMap<String, Arc<TypeDecl>>,
346    /// Free functions only — keyed by `FnName`'s own rendered form, matching
347    /// `UnitTable.fns`'s own key. Methods live in [`Self::methods`] instead
348    /// (see [`MethodTableSignature`]'s own doc comment for why).
349    pub fns: BTreeMap<String, FnSignature>,
350    /// Keyed by the attached type's name, matching `UnitTable.methods`.
351    pub methods: BTreeMap<String, MethodTableSignature>,
352    /// Keyed by owning service name; each maps to its handlers in
353    /// declaration order. Kept separate from [`Self::agent_handlers`] (PR
354    /// #1517's own bot review, lower-priority item): a flat shared namespace
355    /// let a `service Foo`'s handlers be silently overwritten by an
356    /// `agent Foo`'s own handlers, and the rendering couldn't distinguish
357    /// the two owners either.
358    pub service_handlers: BTreeMap<String, Vec<HandlerSignature>>,
359    /// Keyed by owning agent name.
360    pub agent_handlers: BTreeMap<String, Vec<HandlerSignature>>,
361    /// Keyed by service name (`ServiceDecl.protocol`).
362    pub service_protocols: BTreeMap<String, ProtocolSignature>,
363    /// Keyed by owning agent name; `UnitTable.services`/`.providers` carry no
364    /// store fields of their own.
365    pub store_fields: BTreeMap<String, Vec<StoreFieldSignature>>,
366    pub capabilities: CapabilitySignature,
367}
368
369/// Builds a [`UnitSignature`] for `name` from `table`, reusing `combined_types`
370/// (typically a fresh `combined_types_for(name, ..)` call, mirrored here as a
371/// parameter rather than recomputed so a caller building both alongside each
372/// other pays for one traversal, not two).
373///
374/// Every category below is a direct, field-by-field projection off
375/// `UnitTable`'s own real shapes — see this module's own doc comment and
376/// `design/tracks/incrementality.md` §3.4 (Q4) for which fields are excluded
377/// and why.
378pub fn unit_signature_for(
379    name: &str,
380    table: &UnitTable,
381    combined_types: HashMap<String, Arc<TypeDecl>>,
382) -> UnitSignature {
383    let fns = table
384        .fns
385        .iter()
386        .map(|(fname, decl)| (fname.clone(), fn_signature(fname, decl)))
387        .collect();
388
389    let methods = table
390        .methods
391        .iter()
392        .map(|(type_name, mt)| {
393            let sig = MethodTableSignature {
394                instance: mt
395                    .instance
396                    .iter()
397                    .map(|(mname, decl)| (mname.clone(), fn_signature(mname, decl)))
398                    .collect(),
399                statics: mt
400                    .statics
401                    .iter()
402                    .map(|(mname, decl)| (mname.clone(), fn_signature(mname, decl)))
403                    .collect(),
404            };
405            (type_name.clone(), sig)
406        })
407        .collect();
408
409    let mut service_handlers: BTreeMap<String, Vec<HandlerSignature>> = BTreeMap::new();
410    let mut service_protocols: BTreeMap<String, ProtocolSignature> = BTreeMap::new();
411    for (sname, sdecl) in &table.services {
412        service_handlers.insert(
413            sname.clone(),
414            sdecl.handlers.iter().map(handler_signature).collect(),
415        );
416        service_protocols.insert(sname.clone(), protocol_signature(&sdecl.protocol));
417    }
418
419    let mut agent_handlers: BTreeMap<String, Vec<HandlerSignature>> = BTreeMap::new();
420    let mut store_fields: BTreeMap<String, Vec<StoreFieldSignature>> = BTreeMap::new();
421    for (aname, adecl) in &table.agents {
422        agent_handlers.insert(
423            aname.clone(),
424            adecl.handlers.iter().map(handler_signature).collect(),
425        );
426        store_fields.insert(
427            aname.clone(),
428            adecl
429                .store_fields
430                .iter()
431                .map(|f| StoreFieldSignature {
432                    name: f.name.name.clone(),
433                    kind_head: f.kind.head.name.clone(),
434                    kind_args: f.kind.args.clone(),
435                })
436                .collect(),
437        );
438    }
439
440    let declared: BTreeMap<String, Vec<CapabilityOpSignature>> = table
441        .capabilities
442        .iter()
443        .map(|(cname, cdecl)| {
444            let ops = cdecl
445                .ops
446                .iter()
447                .map(|op| CapabilityOpSignature {
448                    name: op.name.name.clone(),
449                    type_params: op
450                        .type_params
451                        .iter()
452                        .map(|tp| tp.name.name.clone())
453                        .collect(),
454                    params: param_signatures(&op.params),
455                    return_type: op.return_type.clone(),
456                })
457                .collect();
458            (cname.clone(), ops)
459        })
460        .collect();
461
462    let mut provider_given: BTreeMap<String, Vec<String>> = BTreeMap::new();
463    for pdecl in table.providers.values() {
464        provider_given.insert(pdecl.capability.name.clone(), cap_ref_names(&pdecl.given));
465    }
466
467    let mut service_given: BTreeMap<String, Vec<String>> = BTreeMap::new();
468    for (sname, sdecl) in &table.services {
469        service_given.insert(sname.clone(), cap_ref_names(&sdecl.default_given));
470    }
471
472    UnitSignature {
473        id: UnitId(name.to_string()),
474        combined_types,
475        fns,
476        methods,
477        service_handlers,
478        agent_handlers,
479        service_protocols,
480        store_fields,
481        capabilities: CapabilitySignature {
482            exported: table.exported_capabilities.iter().cloned().collect(),
483            declared,
484            provider_given,
485            service_given,
486        },
487    }
488}
489
490impl UnitSignature {
491    /// [DECISION C]: the canonical, span-free rendering R3.14's own proof
492    /// compares (`unit_signature_before.canonical() ==
493    /// unit_signature_after.canonical()`), never the raw struct. Delegates
494    /// every fragment through `contract.rs`'s canonical renderers so this
495    /// type and `service_contract_hash` cannot silently diverge on what
496    /// "the same type" means.
497    pub fn canonical(&self) -> String {
498        crate::contract::canon_unit_signature(self)
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use std::path::PathBuf;
506
507    use bynk_project::{ParsedFile, UnitKind};
508    use bynk_syntax::ast::SourceUnit;
509
510    use crate::symbols::{build_unit_table, combined_types_for};
511
512    fn parsed(source_path: &str, src: &str) -> ParsedFile {
513        let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
514        let unit = bynk_syntax::parser::parse_unit(&tokens, src).expect("parse");
515        let kind = match &unit {
516            SourceUnit::Commons(_) => UnitKind::Commons,
517            SourceUnit::Context(_) => UnitKind::Context,
518            SourceUnit::Suite(_) | SourceUnit::Adapter(_) => unreachable!("fixture only"),
519        };
520        ParsedFile::new(
521            PathBuf::from(source_path),
522            PathBuf::from(source_path),
523            None,
524            src.to_string(),
525            unit,
526            kind,
527            false,
528        )
529    }
530
531    const SHARED_SRC: &str = r#"commons demo.shared
532
533type Widget = {
534  id: String,
535}
536"#;
537
538    const CONTEXT_SRC: &str = r#"context demo.svc
539
540uses demo.shared
541
542exports capability { Notifier }
543
544fn double(x: Int) -> Int {
545  x * 2
546}
547
548capability Notifier {
549  fn ping(seed: Int) -> Effect[Int]
550}
551
552provides Notifier = StubNotifier {
553  fn ping(seed: Int) -> Effect[Int] {
554    Effect.pure(seed + 1)
555  }
556}
557
558agent Counter {
559  key id: String
560
561  store count: Cell[Int]
562
563  on call increment() -> Effect[Int] {
564    count := count + 1
565    Effect.pure(count)
566  }
567}
568
569service api from http {
570  on GET("/widgets/:id") (id: String) -> Effect[HttpResult[Widget]] by Visitor given Notifier {
571    Ok(Widget { id: id })
572  }
573}
574"#;
575
576    fn build_signature() -> UnitSignature {
577        let shared = parsed("demo/shared.bynk", SHARED_SRC);
578        let context = parsed("demo/svc.bynk", CONTEXT_SRC);
579
580        let mut errors = Vec::new();
581        let shared_table = build_unit_table(
582            "demo.shared",
583            UnitKind::Commons,
584            &[0],
585            &[shared],
586            &mut errors,
587        );
588        let context_table =
589            build_unit_table("demo.svc", UnitKind::Context, &[0], &[context], &mut errors);
590        assert!(errors.is_empty(), "fixture must parse cleanly: {errors:?}");
591
592        let mut unit_tables = HashMap::new();
593        unit_tables.insert("demo.shared".to_string(), shared_table);
594        unit_tables.insert("demo.svc".to_string(), context_table.clone());
595        let mut unit_uses = HashMap::new();
596        unit_uses.insert("demo.svc".to_string(), vec!["demo.shared".to_string()]);
597
598        let combined = combined_types_for("demo.svc", &unit_tables, &unit_uses);
599        unit_signature_for("demo.svc", &context_table, combined)
600    }
601
602    /// P8.1 (#1512), the issue's own "Done when": builds a `UnitSignature`
603    /// for a fixture unit carrying a function (`double`), a handler in both
604    /// a service (`api`'s `GET` route) and an agent (`Counter`'s
605    /// `increment`), a `store` field (`Counter.count`) and a cross-context
606    /// type reference (`Widget`, reached via `uses demo.shared`) — every
607    /// category [DECISION B]'s field list names — and proves the
608    /// field-exclusion list is complete for all of them.
609    ///
610    /// The exclusion itself is a **compile-time fact, not a runtime
611    /// assertion**, per [DECISION B]: `FnSignature`/`HandlerSignature`/
612    /// `StoreFieldSignature` have no `body`/`requires`/`ensures`/`init`/
613    /// `annotations` field to hold one — this test's real job is proving the
614    /// builder actually reaches every category on real parsed source (a
615    /// hand-assembled AST fixture could hide a field the builder silently
616    /// skips), and asserting the shape that *does* survive is exactly the
617    /// externally-relevant one.
618    #[test]
619    fn unit_signature_excludes_every_body_and_body_adjacent_field() {
620        let sig = build_signature();
621
622        assert_eq!(sig.id, UnitId("demo.svc".to_string()));
623
624        // Cross-context type reference: `Widget` reached only through `uses`.
625        assert!(sig.combined_types.contains_key("Widget"));
626
627        // Fn signature.
628        let f = sig.fns.get("double").expect("double must be projected");
629        assert_eq!(f.params.len(), 1);
630        assert_eq!(f.params[0].name, "x");
631
632        // Service handler, including its own `given` and `kind`.
633        let api_handlers = sig
634            .service_handlers
635            .get("api")
636            .expect("api's handler must be projected");
637        assert_eq!(api_handlers.len(), 1);
638        assert_eq!(api_handlers[0].given, vec!["Notifier".to_string()]);
639        assert_eq!(
640            api_handlers[0].kind,
641            HandlerKindSignature::Http {
642                method: "GET".to_string(),
643                path: "/widgets/:id".to_string(),
644            }
645        );
646
647        // Service protocol.
648        assert!(matches!(
649            sig.service_protocols.get("api"),
650            Some(ProtocolSignature::Http)
651        ));
652
653        // Agent handler and store field.
654        let agent_handlers = sig
655            .agent_handlers
656            .get("Counter")
657            .expect("Counter's handler must be projected");
658        assert_eq!(agent_handlers.len(), 1);
659        assert_eq!(agent_handlers[0].method_name.as_deref(), Some("increment"));
660
661        let store = sig
662            .store_fields
663            .get("Counter")
664            .expect("store field must be projected");
665        assert_eq!(store.len(), 1);
666        assert_eq!(store[0].name, "count");
667        assert_eq!(store[0].kind_head, "Cell");
668
669        // Capability-set category: what's exported, what's declared, and what
670        // a provider needs.
671        assert!(sig.capabilities.exported.contains("Notifier"));
672        let ops = sig
673            .capabilities
674            .declared
675            .get("Notifier")
676            .expect("Notifier's own op signatures must be projected");
677        assert_eq!(ops.len(), 1);
678        assert_eq!(ops[0].name, "ping");
679        assert_eq!(
680            sig.capabilities.provider_given.get("Notifier"),
681            Some(&Vec::<String>::new())
682        );
683
684        // The canonical rendering must actually run over every category above
685        // without panicking, and must be deterministic.
686        let rendered = sig.canonical();
687        assert_eq!(rendered, sig.canonical());
688        assert!(rendered.contains("double"));
689        assert!(rendered.contains("Widget"));
690        assert!(rendered.contains("count"));
691        assert!(rendered.contains("Notifier"));
692        assert!(rendered.contains("ping"));
693    }
694
695    /// PR #1517's own bot review: every assertion in the fixture above is
696    /// positive ("this category got projected"), which cannot fail for a
697    /// signature-changing edit the canonical form fails to notice. These
698    /// four mutations are the other direction of R3.14's own firewall — each
699    /// is a real signature change (never a body edit) and each must move
700    /// `canonical()`, closing the review's three material findings (dropped
701    /// `HandlerKind`, missing methods — covered by the `double`/capability-op
702    /// assertions above plus the route/method/protocol mutations below — and
703    /// missing capability-op signatures). The service/agent handler
704    /// namespace collision (the review's lower-priority item) is closed
705    /// structurally instead, by [`UnitSignature`] now keeping
706    /// `service_handlers`/`agent_handlers` as separate fields — the same
707    /// "make it a compile-time fact, not a runtime assertion" posture
708    /// [DECISION B] already established for body-field exclusion.
709    #[test]
710    fn a_genuine_signature_edit_always_changes_the_canonical_form() {
711        let base = build_signature().canonical();
712
713        let renamed_route = CONTEXT_SRC.replacen("/widgets/:id", "/things/:id", 1);
714        assert_ne!(base, signature_for_context(&renamed_route), "route rename");
715
716        let different_method = CONTEXT_SRC.replacen("on GET(", "on POST(", 1);
717        assert_ne!(
718            base,
719            signature_for_context(&different_method),
720            "GET -> POST"
721        );
722
723        let retyped_cap_op = CONTEXT_SRC.replacen(
724            "fn ping(seed: Int) -> Effect[Int]",
725            "fn ping(seed: String) -> Effect[Int]",
726            1,
727        );
728        assert_ne!(
729            base,
730            signature_for_context(&retyped_cap_op),
731            "capability op retyped"
732        );
733
734        // The queue protocol requires `on message`, not `on GET`, so a real
735        // `from queue(...)` fixture would need its own handler shape; the
736        // protocol tag itself is checked directly at the `ServiceProtocol`
737        // projection level instead of through a full re-parse.
738        assert_ne!(
739            crate::contract::canon_unit_signature(&with_protocol(ServiceProtocol::Queue {
740                name: "q".to_string()
741            })),
742            crate::contract::canon_unit_signature(&with_protocol(ServiceProtocol::Http)),
743            "http -> queue"
744        );
745    }
746
747    fn signature_for_context(context_src: &str) -> String {
748        let shared = parsed("demo/shared.bynk", SHARED_SRC);
749        let context = parsed("demo/svc.bynk", context_src);
750
751        let mut errors = Vec::new();
752        let shared_table = build_unit_table(
753            "demo.shared",
754            UnitKind::Commons,
755            &[0],
756            &[shared],
757            &mut errors,
758        );
759        let context_table =
760            build_unit_table("demo.svc", UnitKind::Context, &[0], &[context], &mut errors);
761        assert!(
762            errors.is_empty(),
763            "mutated fixture must still parse cleanly: {errors:?}"
764        );
765
766        let mut unit_tables = HashMap::new();
767        unit_tables.insert("demo.shared".to_string(), shared_table);
768        unit_tables.insert("demo.svc".to_string(), context_table.clone());
769        let mut unit_uses = HashMap::new();
770        unit_uses.insert("demo.svc".to_string(), vec!["demo.shared".to_string()]);
771
772        let combined = combined_types_for("demo.svc", &unit_tables, &unit_uses);
773        unit_signature_for("demo.svc", &context_table, combined).canonical()
774    }
775
776    fn with_protocol(protocol: ServiceProtocol) -> UnitSignature {
777        let mut sig = build_signature();
778        sig.service_protocols
779            .insert("api".to_string(), protocol_signature(&protocol));
780        sig
781    }
782}