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}