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}