Skip to main content

ridl_diff/
classify.rs

1//! The breaking/compatible classifier.
2//!
3//! [`classify`] turns one structural [`Change`] from the walk into a directional
4//! [`Verdict`]. Direction is judged from the consumer's side (ADR-0008 decision
5//! 14): a change is breaking when it shifts or reuses a wire identity or narrows
6//! a consumer-visible guarantee, and compatible when it only relaxes or appends.
7//!
8//! The classifier reads the resolved IR of both sides, never source, so every
9//! bound it compares is the bound a backend sees. That is what makes the
10//! `[defaults].timing` rule need no special case: the manifest default is
11//! already resolved into every untimed interaction (ADR-0008 decision 12), so
12//! editing it arrives here as an ordinary [`Category::TimingChanged`] on each
13//! defaulted interaction and classifies by the bound rules below (ridl §9.1).
14//!
15//! Two properties are deliberate:
16//!
17//! - **No implication proving.** Contract clauses are carried as canonical
18//!   source text (ADR-0008 decision 14), so the classifier compares text. Any
19//!   `require` text change is breaking, and any `ensure` text change is
20//!   breaking; it never tries to show that one clause implies another.
21//! - **Unlisted is breaking.** A shape the table does not name is classified
22//!   breaking. A false "breaking" costs a maintainer one review; a false
23//!   "compatible" ships a wire break.
24
25use ridl_ir::v2;
26
27use crate::{Category, Change, Verdict, frozen};
28
29#[cfg(test)]
30mod classify_tests;
31
32/// Classifies one change against the two snapshots it was drawn from.
33///
34/// `old` and `new` are the matched packages the change belongs to; the change's
35/// path is resolved against them to recover the typed IR the direction is read
36/// from. For a package present on only one side the caller passes that package
37/// as both arguments — those changes classify on their category alone.
38///
39/// A reference to a declaration of another package is not resolved here, and a
40/// type the diff cannot resolve is reported as breaking: an appended struct
41/// field typed from another package classifies breaking.
42/// [`diff_sets_in`](crate::diff_sets_in) resolves such a reference against every
43/// package of the new snapshot and the context packages, such as `ridl.std`.
44pub fn classify(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
45    classify_in(change, old, new, &[])
46}
47
48/// [`classify`], with `scope` — every package of the new snapshot, and the
49/// context packages such as `ridl.std` — to resolve a reference to another
50/// package's declaration. Only the appended struct field reads it: whether its
51/// type is legal at 0 is decided by the declaration the new side names
52/// (driftsys/ridl#598).
53// A new variant must be given a real arm here, not swept into a
54// catch-all: rustc forces *an* arm, and the arm its `help:` text
55// proposes is `_ =>`, which classifies the new variant silently. The
56// two lints below reject a wildcard over `Category` — the first when
57// it covers several variants, the second when it covers exactly one,
58// which is the case one added variant creates.
59#[deny(
60    clippy::wildcard_enum_match_arm,
61    clippy::match_wildcard_for_single_variants
62)]
63pub(crate) fn classify_in(
64    change: &Change,
65    old: &v2::Package,
66    new: &v2::Package,
67    scope: &[&v2::Package],
68) -> Verdict {
69    match change.category {
70        // Shifts or reuses a wire identity, or replaces a wire-carrying type.
71        // Every one of these is breaking in either direction.
72        Category::InteractionInserted
73        | Category::InteractionReordered
74        | Category::MemberReordered
75        | Category::InteractionRemoved
76        | Category::ReservedNameRedeclared
77        | Category::KindChanged
78        | Category::PayloadChanged
79        | Category::ReturnChanged
80        | Category::ParamsChanged
81        | Category::WidthChanged
82        | Category::ServiceChanged
83        | Category::DeclRemoved => Verdict::Breaking,
84
85        // An init is part of the published contract (ridl §9.1): consumers read
86        // the pre-publish value. Neither reference states a compatible
87        // direction, so the change is breaking.
88        Category::InitChanged => Verdict::Breaking,
89
90        // Doc comments, labels, and deprecation notes reach no consumer's build.
91        // Visibility is not among them — it has its own category below.
92        //
93        // A tombstone in the retired interaction's own slot is the sanctioned
94        // retirement (ridl §11); the walk only emits `InteractionRetired` when
95        // the slot is preserved.
96        Category::DocOnly | Category::InteractionRetired => Verdict::Compatible,
97
98        // An interface's identity is its `interfaces.lock` number (lock
99        // design §7). A rename that keeps the number moves nothing on the
100        // wire — the routing key is the number — and is visible in source,
101        // which the text report's heading says of it. A number the new side's
102        // lock retires is the sanctioned removal: the entry keeps the number
103        // forever, so it is never allocated again.
104        Category::InterfaceRenamed | Category::InterfaceRetired => Verdict::Compatible,
105
106        // `ridl lock` freezing a provisional number in place keeps the
107        // routing key, so nothing moves on the wire; the catalog hash, which
108        // covers the flag, changes, which the text report's heading says of
109        // it. A number that differs between the two sides is a new routing
110        // key for the same interface.
111        Category::InterfaceFrozen => Verdict::Compatible,
112        Category::InterfaceNumberChanged => Verdict::Breaking,
113
114        // An enum value or enum-set bit takes its identity from its explicit
115        // number (typl §8, §9), so a body that differs only in order moves
116        // nothing on the wire. The catalog hash covers that order when an
117        // interface reaches the enum, which the text report's heading says of
118        // it.
119        Category::EnumReordered => Verdict::Compatible,
120
121        // A service's list is a set of interface references (ADR-0015
122        // decision 19 as amended on 2026-09-15). The routing key does not
123        // contain the service, so an interface joining or leaving the set
124        // moves no wire identity: both directions are compatible. A removal
125        // is still visible in source — the `service.member` addresses of that
126        // interface stop resolving under the service — which is what the text
127        // report's heading says of it.
128        Category::ServiceInterfaceAdded | Category::ServiceInterfaceRemoved => Verdict::Compatible,
129
130        Category::VisibilityChanged => visibility(change, old, new),
131        Category::InteractionAppended => appended(change, old, new),
132        Category::DeclAdded => added(change, old, new, scope),
133        Category::ConstraintChanged => constraint(change, old, new),
134        Category::TimingChanged => timing(change, old, new),
135        Category::RpcBoundChanged => rpc_bound(change, old, new),
136        Category::ContractChanged => contract(change, old, new),
137        // A family or shape is part of the contract a consumer compiled
138        // against; a change moves it in both directions (ADR-0012 decision
139        // 12).
140        Category::InteractionClassChanged => Verdict::Breaking,
141    }
142}
143
144// ==========================================================================
145// Visibility.
146// ==========================================================================
147
148/// A change to the visibility a declaration is published at.
149///
150/// Visibility rides the surface grammar next to doc comments, but it is not
151/// metadata to a consumer. `internal` maps to the target's package-private
152/// mechanism — Rust `pub(crate)`, a non-exported TypeScript member (ADR-0002
153/// §8) — so narrowing `public` to `internal` deletes the declaration from every
154/// out-of-package consumer's build. That is the plainest form of "narrows a
155/// consumer-visible guarantee" in ADR-0008 decision 14, even though the byte
156/// layout on the wire never moves.
157///
158/// Widening `internal` to `public` only offers more, so it is compatible. Any
159/// direction involving an unset visibility is breaking, following the module's
160/// unlisted-is-breaking rule.
161fn visibility(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
162    let (Some(old_visibility), Some(new_visibility)) = (
163        find_visibility(old, &change.path, |name| find_old_shape(old, new, name)),
164        find_visibility(new, &change.path, |name| find_shape(new, name)),
165    ) else {
166        return Verdict::Breaking;
167    };
168
169    match (
170        v2::Visibility::try_from(old_visibility),
171        v2::Visibility::try_from(new_visibility),
172    ) {
173        (Ok(v2::Visibility::Internal), Ok(v2::Visibility::Public)) => Verdict::Compatible,
174        _ => Verdict::Breaking,
175    }
176}
177
178/// The visibility published at a change's path: an interaction inside an
179/// interface or service shape, or a package-level declaration, interface, or
180/// service. `shape_of` resolves the container name the path carries to this
181/// side's shape — by name on the new side, and on the old side the way the
182/// walk matched it ([`find_old_shape`]), since a renamed interface's path
183/// carries only its new name.
184fn find_visibility<'a>(
185    package: &'a v2::Package,
186    path: &str,
187    shape_of: impl Fn(&str) -> Option<v2::InterfaceShape<'a>>,
188) -> Option<i32> {
189    let mut segments = path.split('/').skip(1);
190    let name = segments.next()?;
191
192    if let Some(member) = segments.next() {
193        return shape_of(name)?
194            .interface
195            .interactions
196            .iter()
197            .find(|decl| decl.name == member)
198            .map(|decl| decl.visibility);
199    }
200    if let Some(decl) = find_decl(package, name) {
201        return Some(decl.visibility);
202    }
203    // `Package::shapes` answers for a named interface and for a service with an
204    // inline shape, and `InterfaceShape::visibility` reads the authoritative
205    // field in each case — the owning service's for an inline shape, whose own
206    // `Interface.visibility` is `VISIBILITY_UNSPECIFIED` by construction.
207    if let Some(shape) = shape_of(name) {
208        return Some(shape.visibility());
209    }
210    // A service naming an interface after `:` carries no shape of its own, so
211    // it is not in `shapes()`; its visibility is still the service's.
212    package
213        .services
214        .iter()
215        .find(|service| service.name == name)
216        .map(|service| service.visibility)
217}
218
219// ==========================================================================
220// Interaction append — the identity-reuse guard.
221// ==========================================================================
222
223/// An interaction (or a freshly minted tombstone) added after every slot that
224/// existed before. Appending is compatible, but only when the slot it takes was
225/// never occupied: an ordinal freed by an untombstoned removal and handed to a
226/// new name **reuses a wire identity**, which ADR-0008 decision 14 lists first
227/// among breaking changes.
228///
229/// The walk labels that case `InteractionAppended` because the new name does sit
230/// after every surviving slot; the reuse is only visible by looking back at what
231/// the old snapshot held at that ordinal, which is why the check lives here and
232/// not in the walk.
233fn appended(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
234    let Some((container, member)) = member_path(change) else {
235        return Verdict::Breaking;
236    };
237    let (Some(old_iface), Some(new_iface)) = (
238        find_old_interface(old, new, container),
239        find_interface(new, container),
240    ) else {
241        return Verdict::Breaking;
242    };
243    let Some(ordinal) = slot_ordinal(new_iface, member) else {
244        return Verdict::Breaking;
245    };
246
247    for (name, old_ordinal) in slots(old_iface) {
248        if old_ordinal == ordinal && name != member {
249            return Verdict::Breaking;
250        }
251    }
252    Verdict::Compatible
253}
254
255// ==========================================================================
256// Additions — package level, and the append-only composite bodies.
257// ==========================================================================
258
259/// A declaration present only in the new snapshot.
260///
261/// A package-level addition — a new decl, interface, or service — is compatible:
262/// nothing that existed moved. A composite member addition is compatible only
263/// when it is a genuine append: typl §7.4 makes struct fields and union arms one
264/// append-only rule ("new fields are added at the end of the struct or union"),
265/// and an enum value appends by taking a number above every live and every
266/// retired one.
267///
268/// An appended struct field must also be readable from an old payload, which
269/// does not carry it ([`absence_reads_as_legal`], driftsys/ridl#598).
270fn added(change: &Change, old: &v2::Package, new: &v2::Package, scope: &[&v2::Package]) -> Verdict {
271    let Some((container, member)) = member_path(change) else {
272        // One or two segments: a whole package, or a package-level decl,
273        // interface, or service.
274        return Verdict::Compatible;
275    };
276    let (Some(old_decl), Some(new_decl)) = (find_decl(old, container), find_decl(new, container))
277    else {
278        return Verdict::Breaking;
279    };
280
281    // The append test is a property of the whole body, and reading the body is
282    // what catches a *surviving* member whose slot moved — which the walk does
283    // not report alongside an addition. The member name is read only for a
284    // struct field, whose type decides whether an old payload stays readable.
285    use v2::decl::Kind;
286    let appended = match (&old_decl.kind, &new_decl.kind) {
287        (Some(Kind::StructDef(old_def)), Some(Kind::StructDef(new_def))) => {
288            appended_slot(
289                &struct_slots(old_def),
290                &struct_reserved(old_def),
291                &struct_slots(new_def),
292            ) && absence_reads_as_legal(new_def, member, new, scope)
293        }
294        (Some(Kind::UnionDef(old_def)), Some(Kind::UnionDef(new_def))) => {
295            // A result union's arms are its transport identity (ADR-0008
296            // decision 4): any arm change flips it.
297            !old_def.is_result
298                && !new_def.is_result
299                && appended_slot(
300                    &union_slots(old_def),
301                    &union_reserved(old_def),
302                    &union_slots(new_def),
303                )
304        }
305        (Some(Kind::EnumDef(old_def)), Some(Kind::EnumDef(new_def))) => appended_slot(
306            &value_slots(&old_def.values),
307            &reserved_values(&old_def.reserved),
308            &value_slots(&new_def.values),
309        ),
310        (Some(Kind::EnumSetDef(old_def)), Some(Kind::EnumSetDef(new_def))) => appended_slot(
311            &value_slots(&old_def.bits),
312            &[],
313            &value_slots(&new_def.bits),
314        ),
315        // A shape the table does not name classifies breaking.
316        _ => false,
317    };
318
319    if appended {
320        Verdict::Compatible
321    } else {
322        Verdict::Breaking
323    }
324}
325
326/// Whether a reader built against `new_set` refuses a payload in which the
327/// field `member` of struct `container`, in package `package`, is absent —
328/// the rule that makes an appended field breaking for its type
329/// (driftsys/ridl#598). `context` holds packages that references resolve
330/// against without being compared, such as `ridl.std`. `false` when
331/// `container` is not a struct of that package. The `ridl check` desk check
332/// reads it to say so in its RIDL-407 message for an append that also sits
333/// beside a moved sibling.
334pub fn absence_refused(
335    new_set: &[v2::Package],
336    context: &[v2::Package],
337    package: &str,
338    container: &str,
339    member: &str,
340) -> bool {
341    let scope: Vec<&v2::Package> = new_set.iter().chain(context).collect();
342    let Some(home) = new_set.iter().find(|candidate| candidate.name == package) else {
343        return false;
344    };
345    match find_decl(home, container).and_then(|decl| decl.kind.as_ref()) {
346        Some(v2::decl::Kind::StructDef(def)) => !absence_reads_as_legal(def, member, home, &scope),
347        _ => false,
348    }
349}
350
351/// Whether a reader built against the new struct body accepts a payload the old
352/// body wrote, in which the appended field `member` is absent
353/// (driftsys/ridl#598).
354///
355/// An optional field reads as absent. A non-optional field reads as the
356/// FlatBuffers default, 0, when its type is a scalar, an enum or an enum set and
357/// 0 is a legal value of that type. That is the condition under which the
358/// FlatBuffers codec of `ridl-backend-rust` reads the absent field instead of
359/// refusing it as a missing required field (driftsys/ridl#472), and the two
360/// share its definition, [`ridl_ir::zero`]. Every other absent non-optional
361/// field is refused: a string, bytes, struct, union, tuple, array or map field
362/// is an offset with no default, and a scalar or enum whose type excludes 0 has
363/// no legal value to read.
364///
365/// A type the classifier cannot resolve reads as refused, following the module's
366/// unlisted-is-breaking rule: a type the diff cannot resolve is reported as
367/// breaking. That includes a reference to another package when `scope` does
368/// not hold it. No snapshot carries `ridl.std`, so it resolves only when the
369/// caller passes it as context, as the `ridl` CLI does.
370fn absence_reads_as_legal(
371    def: &v2::StructDef,
372    member: &str,
373    home: &v2::Package,
374    scope: &[&v2::Package],
375) -> bool {
376    let Some(r#type) = def.members.iter().find_map(|slot| match &slot.member {
377        Some(v2::struct_member::Member::Field(field)) if field.name == member => {
378            field.r#type.as_ref()
379        }
380        _ => None,
381    }) else {
382        return false;
383    };
384    if r#type.optional {
385        return true;
386    }
387    use v2::field_type::Kind;
388    match &r#type.kind {
389        Some(Kind::Primitive(primitive)) => matches!(
390            v2::PrimitiveType::try_from(*primitive),
391            Ok(v2::PrimitiveType::Boolean | v2::PrimitiveType::Integer | v2::PrimitiveType::Float)
392        ),
393        Some(Kind::InlineScalar(def)) => scalar_holds_zero(def),
394        Some(Kind::Named(reference)) => {
395            use v2::decl::Kind as Decl;
396            match resolve(home, scope, reference).and_then(|decl| decl.kind.as_ref()) {
397                Some(Decl::TypeDef(def)) => scalar_holds_zero(def),
398                Some(Decl::EnumDef(def)) => ridl_ir::zero::enum_zero_member(&def.values).is_some(),
399                // An enum set at 0 is the empty set.
400                Some(Decl::EnumSetDef(_)) => true,
401                _ => false,
402            }
403        }
404        _ => false,
405    }
406}
407
408/// Whether a named or inline scalar is legal at 0: a boolean, integer, float or
409/// unit backing whose constraint holds 0. A string or bytes backing has no
410/// default at all.
411fn scalar_holds_zero(def: &v2::TypeDef) -> bool {
412    let numeric = match def
413        .backing
414        .as_ref()
415        .and_then(|backing| backing.kind.as_ref())
416    {
417        Some(v2::backing::Kind::Unit(_)) => true,
418        Some(v2::backing::Kind::Primitive(primitive)) => matches!(
419            v2::PrimitiveType::try_from(*primitive),
420            Ok(v2::PrimitiveType::Boolean | v2::PrimitiveType::Integer | v2::PrimitiveType::Float)
421        ),
422        None => false,
423    };
424    numeric
425        && def.constraint.as_ref().is_none_or(|constraint| {
426            ridl_ir::zero::range_holds_zero(
427                constraint.min.as_deref(),
428                constraint.max.as_deref(),
429                constraint.step.as_deref(),
430            )
431        })
432}
433
434/// The declaration a type reference names on the new side, in the IR's
435/// canonical form: a bare `Name` in `home`, and `pkg.Name` in the package of
436/// that name — `home` itself or one of `scope`.
437fn resolve<'a>(
438    home: &'a v2::Package,
439    scope: &[&'a v2::Package],
440    reference: &str,
441) -> Option<&'a v2::Decl> {
442    match reference.rsplit_once('.') {
443        Some((package, name)) => std::iter::once(home)
444            .chain(scope.iter().copied())
445            .find(|candidate| candidate.name == package)
446            .and_then(|candidate| find_decl(candidate, name)),
447        None => find_decl(home, reference),
448    }
449}
450
451/// Whether the new body is the old body with every pre-existing slot untouched
452/// and every addition sitting above the highest slot ever used — live or
453/// retired.
454///
455/// Both halves matter. A member whose slot number moved has had its wire
456/// identity shifted even though its name survived, and the walk's composite
457/// comparison does not report that alongside an addition. A member taking a
458/// number at or below the old high-water mark is an insertion, or a reuse of a
459/// retired number, which typl §7.4 forbids so a wire value never carries a new
460/// meaning.
461fn appended_slot(old: &[(String, i64)], old_retired: &[i64], new: &[(String, i64)]) -> bool {
462    for (name, old_slot) in old {
463        match new.iter().find(|(new_name, _)| new_name == name) {
464            Some((_, new_slot)) if new_slot == old_slot => {}
465            // A surviving member moved, or vanished alongside the addition.
466            _ => return false,
467        }
468    }
469
470    let high_water = old
471        .iter()
472        .map(|(_, slot)| *slot)
473        .chain(old_retired.iter().copied())
474        .max();
475    let old_names: Vec<&String> = old.iter().map(|(name, _)| name).collect();
476    for (name, slot) in new {
477        if old_names.contains(&name) {
478            continue;
479        }
480        match high_water {
481            Some(mark) if *slot <= mark => return false,
482            _ => {}
483        }
484    }
485    true
486}
487
488// ==========================================================================
489// Constraints — narrowed versus widened.
490// ==========================================================================
491
492/// A scalar constraint change on a named type. Widening keeps every value the
493/// old contract admitted legal, so it is compatible; narrowing rejects values a
494/// consumer may already be sending.
495///
496/// A composite body changed in place reaches here with no member path and no
497/// rendered values — the walk does not say which member changed inside it — and
498/// classifies breaking.
499fn constraint(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
500    let mut segments = change.path.split('/').skip(1);
501    let (Some(name), None) = (segments.next(), segments.next()) else {
502        return Verdict::Breaking;
503    };
504    let (Some(old_decl), Some(new_decl)) = (find_decl(old, name), find_decl(new, name)) else {
505        return Verdict::Breaking;
506    };
507
508    use v2::decl::Kind;
509    let (Some(Kind::TypeDef(old_def)), Some(Kind::TypeDef(new_def))) =
510        (&old_decl.kind, &new_decl.kind)
511    else {
512        return Verdict::Breaking;
513    };
514
515    if narrows(old_def.constraint.as_ref(), new_def.constraint.as_ref()) {
516        Verdict::Breaking
517    } else {
518        Verdict::Compatible
519    }
520}
521
522/// Whether the constraint moved in the narrowing direction on any facet.
523///
524/// Each bound is judged independently and any narrowing decides the whole
525/// change, so a mixed edit — a lowered `min` with a lowered `max` — is breaking
526/// on the half that narrows. A stepped minimum also defines the grid origin,
527/// so a shifted grid is breaking even when the interval widens.
528fn narrows(old: Option<&v2::Constraint>, new: Option<&v2::Constraint>) -> bool {
529    let (Some(old), Some(new)) = (old, new) else {
530        // A constraint appearing where there was none bounds a previously
531        // unbounded value; dropping one entirely only widens.
532        return old.is_none() && new.is_some();
533    };
534
535    // A raised floor or a lowered ceiling rejects values that were legal.
536    if raised(old.min.as_deref(), new.min.as_deref())
537        || lowered(old.max.as_deref(), new.max.as_deref())
538    {
539        return true;
540    }
541    // Length bounds are the same rule over character and byte counts.
542    if raised_u64(old.len_min, new.len_min) || lowered_u64(old.len_max, new.len_max) {
543        return true;
544    }
545    // A step quantizes: added or changed at all it can exclude values that were
546    // legal, and the classifier does not prove divisibility. Removed, it widens.
547    if new.step.is_some() && old.step != new.step {
548        return true;
549    }
550    // A minimum is also the origin of a stepped grid. Widening the interval
551    // only preserves old values when the origin moves by whole steps.
552    if let Some(step) = new.step.as_deref()
553        && old.min != new.min
554        && !same_grid(old.min.as_deref(), new.min.as_deref(), step)
555    {
556        return true;
557    }
558    // A pattern added or rewritten can reject strings that matched before.
559    // Removed, it widens.
560    if (new.pattern.is_some() && old.pattern != new.pattern)
561        || (new.pattern_const.is_some() && old.pattern_const != new.pattern_const)
562    {
563        return true;
564    }
565    false
566}
567
568/// Whether two origins differ by a whole positive step, in exact decimal
569/// arithmetic. An omitted minimum has origin zero (typl §4.3). Unknown
570/// spellings fail closed, as do invalid steps from foreign IR snapshots.
571fn same_grid(old: Option<&str>, new: Option<&str>, step: &str) -> bool {
572    use num_bigint::BigInt;
573
574    let read = |text: &str| -> Option<(BigInt, usize)> {
575        let (negative, digits) = split_sign(text)?;
576        let (whole, fraction) = digits.split_once('.').unwrap_or((&digits, ""));
577        if whole.is_empty() || (digits.contains('.') && fraction.is_empty()) {
578            return None;
579        }
580        let coefficient = BigInt::parse_bytes(format!("{whole}{fraction}").as_bytes(), 10)?;
581        Some((
582            if negative { -coefficient } else { coefficient },
583            fraction.len(),
584        ))
585    };
586    let (Some(old), Some(new), Some(step)) = (
587        read(old.unwrap_or("0")),
588        read(new.unwrap_or("0")),
589        read(step),
590    ) else {
591        return false;
592    };
593    let scale = old.1.max(new.1).max(step.1);
594    let rescale = |(coefficient, places): (BigInt, usize)| -> Option<BigInt> {
595        Some(coefficient * BigInt::from(10).pow(u32::try_from(scale - places).ok()?))
596    };
597    let (Some(old), Some(new), Some(step)) = (rescale(old), rescale(new), rescale(step)) else {
598        return false;
599    };
600    step > BigInt::from(0) && (old - new) % step == BigInt::from(0)
601}
602
603/// Whether a lower bound was added or moved up.
604fn raised(old: Option<&str>, new: Option<&str>) -> bool {
605    match (old, new) {
606        (None, Some(_)) => true,
607        (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_gt),
608        _ => false,
609    }
610}
611
612/// Whether an upper bound was added or moved down.
613fn lowered(old: Option<&str>, new: Option<&str>) -> bool {
614    match (old, new) {
615        (None, Some(_)) => true,
616        (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_lt),
617        _ => false,
618    }
619}
620
621fn raised_u64(old: Option<u64>, new: Option<u64>) -> bool {
622    match (old, new) {
623        (None, Some(_)) => true,
624        (Some(old), Some(new)) => new > old,
625        _ => false,
626    }
627}
628
629fn lowered_u64(old: Option<u64>, new: Option<u64>) -> bool {
630    match (old, new) {
631        (None, Some(_)) => true,
632        (Some(old), Some(new)) => new < old,
633        _ => false,
634    }
635}
636
637/// Orders two canonical decimal strings exactly, without going through a float
638/// (the IR carries exact decimals for precisely this reason, ADR-0007 decision
639/// 9). Returns `None` when either side is not a plain decimal, which callers
640/// read as "cannot prove this relaxes".
641///
642/// The `None` case is not dead. `ridlc` only ever writes canonical decimals, but
643/// [`load_ir_json`](crate::load_ir_json) deserializes a snapshot off disk and a
644/// bound is a plain string there, so a hand-edited or foreign `.ir.json` can
645/// carry an exponent form or any other spelling this function does not read.
646fn cmp_decimal(left: &str, right: &str) -> Option<std::cmp::Ordering> {
647    use std::cmp::Ordering;
648
649    let (left_negative, left_digits) = split_sign(left)?;
650    let (right_negative, right_digits) = split_sign(right)?;
651    if left_negative != right_negative {
652        // Zero is signless in canonical form, so a sign disagreement is a real
653        // ordering: the negative side is the smaller one.
654        return Some(if left_negative {
655            Ordering::Less
656        } else {
657            Ordering::Greater
658        });
659    }
660
661    let magnitude = cmp_magnitude(&left_digits, &right_digits)?;
662    Some(if left_negative {
663        magnitude.reverse()
664    } else {
665        magnitude
666    })
667}
668
669/// Splits an optional leading sign from a decimal, rejecting anything that is
670/// not sign-digits-optional-fraction.
671fn split_sign(text: &str) -> Option<(bool, String)> {
672    let (negative, rest) = match text.strip_prefix('-') {
673        Some(rest) => (true, rest),
674        None => (false, text.strip_prefix('+').unwrap_or(text)),
675    };
676    if rest.is_empty() || !rest.chars().all(|c| c.is_ascii_digit() || c == '.') {
677        return None;
678    }
679    if rest.matches('.').count() > 1 {
680        return None;
681    }
682    Some((negative, rest.to_string()))
683}
684
685/// Orders two unsigned decimal magnitudes by integer part then fraction, so
686/// `9` < `10` and `1.5` < `1.50001`.
687fn cmp_magnitude(left: &str, right: &str) -> Option<std::cmp::Ordering> {
688    use std::cmp::Ordering;
689
690    let (left_int, left_frac) = left.split_once('.').unwrap_or((left, ""));
691    let (right_int, right_frac) = right.split_once('.').unwrap_or((right, ""));
692
693    let left_int = left_int.trim_start_matches('0');
694    let right_int = right_int.trim_start_matches('0');
695    let by_length = left_int.len().cmp(&right_int.len());
696    if by_length != Ordering::Equal {
697        return Some(by_length);
698    }
699    let by_int = left_int.cmp(right_int);
700    if by_int != Ordering::Equal {
701        return Some(by_int);
702    }
703
704    // Compare fractions digit by digit, padding the shorter with zeros.
705    let width = left_frac.len().max(right_frac.len());
706    let pad = |frac: &str| format!("{frac:0<width$}");
707    Some(pad(left_frac).cmp(&pad(right_frac)))
708}
709
710// ==========================================================================
711// Timing.
712// ==========================================================================
713
714/// A resolved timing change on a signal or event (ADR-0008 decision 12).
715///
716/// `min` is the rate floor and `max` the staleness bound, so the consumer-facing
717/// guarantee strengthens when `min` rises or `max` falls and weakens when either
718/// moves the other way. A bound added or removed, or the strict-periodic/range
719/// mode flipped, changes what a consumer may assume at all — rmdl clocks key on
720/// strict — and is breaking in both directions.
721fn timing(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
722    let Some((container, member)) = member_path(change) else {
723        return Verdict::Breaking;
724    };
725    let (Some(old_decl), Some(new_decl)) = (
726        find_old_interaction(old, new, container, member),
727        find_interaction(new, container, member),
728    ) else {
729        return Verdict::Breaking;
730    };
731    let (Some(old_timing), Some(new_timing)) =
732        (interaction_timing(old_decl), interaction_timing(new_decl))
733    else {
734        // An interaction kind that carries no timing at all. The walk cannot
735        // produce this: it emits `TimingChanged` only inside its signal and
736        // event arms, so both sides are already a timed kind by the time a
737        // change reaches here. It is still reachable, because [`classify`] is
738        // public and takes any hand-built `Change` — so the arm stays, follows
739        // the module's unlisted-is-breaking rule, and carries a test of its own.
740        // It is deliberately not a `debug_assert!`: a caller passing a category
741        // the walk would not have emitted is asking a question, not committing
742        // a bug, and aborting a debug build over it would be wrong.
743        return Verdict::Breaking;
744    };
745
746    let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
747        // Timing appearing where there was none, or dropped, is a bound added
748        // or removed.
749        return Verdict::Breaking;
750    };
751
752    if old_timing.mode != new_timing.mode {
753        return Verdict::Breaking;
754    }
755    // A floor lowered, a ceiling raised, or either bound added or removed.
756    if lowered(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
757        || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
758        || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
759        || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
760    {
761        return Verdict::Breaking;
762    }
763    // What remains is a floor raised, a ceiling lowered, or only
764    // `default_applied` flipped over identical bounds — a default made explicit.
765    Verdict::Compatible
766}
767
768/// A declared RPC-bound change on a command or query (ADR-0015 decision 8).
769///
770/// The two bounds keep their generic §9 meaning — `min` the rate floor, `max`
771/// the staleness bound — but on an RPC `min` is the **call throttle** and
772/// constrains the consumer, so its direction inverts against [`timing`]'s
773/// convention: raising it withdraws a call rate the caller was entitled to
774/// use (breaking), and lowering it leaves the caller less constrained
775/// (compatible). `max` is the response bound, a provider promise, and keeps
776/// the provider-side rule: raised is a weaker promise (breaking), lowered a
777/// stronger one (compatible). A bound added or removed — the whole annotation
778/// included — is breaking in both directions, exactly as in [`timing`]:
779/// callers size timeouts against a declared bound, so its appearance and its
780/// disappearance both change what a consumer may assume at all.
781///
782/// This is a distinct category rather than a kind-aware branch inside
783/// [`timing`] so that a missed branch fails closed (ADR-0012 decision 9): the
784/// deny lints above turn a missing arm into a compile error rather than a
785/// silently inherited signal rule that calls a raised RPC `min` compatible.
786fn rpc_bound(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
787    let Some((container, member)) = member_path(change) else {
788        return Verdict::Breaking;
789    };
790    let (Some(old_decl), Some(new_decl)) = (
791        find_old_interaction(old, new, container, member),
792        find_interaction(new, container, member),
793    ) else {
794        return Verdict::Breaking;
795    };
796    let (Some(old_timing), Some(new_timing)) = (rpc_timing(old_decl), rpc_timing(new_decl)) else {
797        // An interaction kind that is not an RPC. The walk cannot produce
798        // this — it emits `RpcBoundChanged` only inside its command and query
799        // arms — but [`classify`] is public and takes any hand-built
800        // [`Change`], so the arm stays and follows the module's
801        // unlisted-is-breaking rule (see [`timing`] for why it is not a
802        // `debug_assert!`).
803        return Verdict::Breaking;
804    };
805
806    let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
807        // The annotation appearing where there was none, or dropped, is a
808        // bound added or removed — breaking in both directions.
809        return Verdict::Breaking;
810    };
811
812    // The mode is always `Range` on an RPC (ADR-0015 decision 7); anything
813    // else in a snapshot is erroneous IR, and a flip is breaking regardless.
814    if old_timing.mode != new_timing.mode {
815        return Verdict::Breaking;
816    }
817    // A throttle raised, a response bound raised, or either bound added or
818    // removed. `raised` covers the added case (`None` → `Some`), `dropped` the
819    // removed one. This is [`timing`]'s predicate with the `min` direction
820    // inverted — `raised` where the signal rule reads `lowered`.
821    if raised(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
822        || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
823        || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
824        || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
825    {
826        return Verdict::Breaking;
827    }
828    // What remains is a throttle lowered, a response bound lowered, or only
829    // `default_applied` flipped over identical bounds — a default made
830    // explicit (ADR-0015 decision 7), the rule [`timing`] gives a signal. The
831    // caller is less constrained, or the provider promises more, or neither
832    // changed.
833    Verdict::Compatible
834}
835
836/// Whether a bound present before is absent now.
837fn dropped(old: Option<&str>, new: Option<&str>) -> bool {
838    old.is_some() && new.is_none()
839}
840
841/// The resolved timing of a signal or event; `None` for interaction kinds that
842/// carry none, and `Some(None)` for a timed kind with the field unset.
843fn interaction_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
844    use v2::decl::Kind;
845    match &decl.kind {
846        Some(Kind::SignalDef(def)) => Some(def.timing.as_ref()),
847        Some(Kind::EventDef(def)) => Some(def.timing.as_ref()),
848        _ => None,
849    }
850}
851
852/// The declared timing of a command or query; `None` for other kinds, and
853/// `Some(None)` for an RPC with no bound at all. An RPC bound may come from a
854/// default (ADR-0015 decisions 4 and 7, as amended).
855fn rpc_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
856    use v2::decl::Kind;
857    match &decl.kind {
858        Some(Kind::CommandDef(def)) => Some(def.timing.as_ref()),
859        Some(Kind::QueryDef(def)) => Some(def.timing.as_ref()),
860        _ => None,
861    }
862}
863
864// ==========================================================================
865// Contracts.
866// ==========================================================================
867
868/// A `require`/`ensure` clause-set change on a command or query.
869///
870/// A `require` is a precondition the caller must meet: adding one, or rewriting
871/// one, rejects callers that were legal. An `ensure` is a postcondition the
872/// caller may rely on: removing one, or rewriting one, withdraws a guarantee.
873/// Clause text is compared verbatim — the classifier never tries to prove that
874/// one clause implies another (ADR-0008 decision 14), so a rewrite reads as an
875/// addition on the `require` side and as a removal on the `ensure` side, and
876/// both are breaking.
877fn contract(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
878    let Some((container, member)) = member_path(change) else {
879        return Verdict::Breaking;
880    };
881    let (Some(old_decl), Some(new_decl)) = (
882        find_old_interaction(old, new, container, member),
883        find_interaction(new, container, member),
884    ) else {
885        return Verdict::Breaking;
886    };
887    let (Some(old_clauses), Some(new_clauses)) = (contracts(old_decl), contracts(new_decl)) else {
888        return Verdict::Breaking;
889    };
890
891    let kind = |want: v2::ContractKind| {
892        move |clause: &&v2::Contract| v2::ContractKind::try_from(clause.kind) == Ok(want)
893    };
894    let sources = |clauses: &[v2::Contract], want: v2::ContractKind| -> Vec<String> {
895        let mut out: Vec<String> = clauses
896            .iter()
897            .filter(kind(want))
898            .map(|clause| clause.source.clone())
899            .collect();
900        out.sort();
901        out
902    };
903
904    let old_require = sources(old_clauses, v2::ContractKind::Require);
905    let new_require = sources(new_clauses, v2::ContractKind::Require);
906    let old_ensure = sources(old_clauses, v2::ContractKind::Ensure);
907    let new_ensure = sources(new_clauses, v2::ContractKind::Ensure);
908
909    if !covers(&old_require, &new_require) || !covers(&new_ensure, &old_ensure) {
910        return Verdict::Breaking;
911    }
912    Verdict::Compatible
913}
914
915/// Whether every clause in `subset` appears in `superset`, counting duplicates.
916fn covers(superset: &[String], subset: &[String]) -> bool {
917    let mut remaining: Vec<&String> = superset.iter().collect();
918    for clause in subset {
919        match remaining.iter().position(|held| *held == clause) {
920            Some(index) => {
921                remaining.swap_remove(index);
922            }
923            None => return false,
924        }
925    }
926    true
927}
928
929/// The contract clauses of a command or query; `None` for kinds that carry none.
930fn contracts(decl: &v2::Decl) -> Option<&[v2::Contract]> {
931    use v2::decl::Kind;
932    match &decl.kind {
933        Some(Kind::CommandDef(def)) => Some(&def.contracts),
934        Some(Kind::QueryDef(def)) => Some(&def.contracts),
935        _ => None,
936    }
937}
938
939// ==========================================================================
940// Path resolution and IR lookups.
941// ==========================================================================
942
943/// Splits a `package/container/member` path into its container and member.
944/// Returns `None` for the shorter package-level paths.
945fn member_path(change: &Change) -> Option<(&str, &str)> {
946    let mut segments = change.path.split('/').skip(1);
947    let container = segments.next()?;
948    let member = segments.next()?;
949    if segments.next().is_some() {
950        return None;
951    }
952    Some((container, member))
953}
954
955fn find_decl<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Decl> {
956    package.decls.iter().find(|decl| decl.name == name)
957}
958
959/// The shape a name refers to — a top-level interface, or the inline shape of
960/// a service, which the walk descends into under the service's own dotted
961/// name. `Package::shapes` keys both on exactly that identity, so one lookup
962/// covers them.
963fn find_shape<'a>(package: &'a v2::Package, name: &str) -> Option<v2::InterfaceShape<'a>> {
964    package.shapes().find(|shape| shape.name == name)
965}
966
967/// The old side of the shape a diff path names, found the way the walk
968/// matched it (lock design §7; plan decision PD-14): by the frozen
969/// `interfaces.lock` number of the new side's shape when it has one, and by
970/// name otherwise — the new side has no identity, or does not hold the name
971/// at all. A renamed interface's member changes therefore classify as any
972/// other interface's, although their paths carry only the new name.
973fn find_old_shape<'a>(
974    old: &'a v2::Package,
975    new: &v2::Package,
976    name: &str,
977) -> Option<v2::InterfaceShape<'a>> {
978    let by_number = find_shape(new, name)
979        .filter(|shape| frozen(shape.interface))
980        .and_then(|shape| {
981            old.shapes().find(|candidate| {
982                frozen(candidate.interface) && candidate.interface.number == shape.interface.number
983            })
984        });
985    by_number.or_else(|| find_shape(old, name))
986}
987
988fn find_interface<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Interface> {
989    find_shape(package, name).map(|shape| shape.interface)
990}
991
992fn find_old_interface<'a>(
993    old: &'a v2::Package,
994    new: &v2::Package,
995    name: &str,
996) -> Option<&'a v2::Interface> {
997    find_old_shape(old, new, name).map(|shape| shape.interface)
998}
999
1000fn find_interaction<'a>(
1001    package: &'a v2::Package,
1002    container: &str,
1003    member: &str,
1004) -> Option<&'a v2::Decl> {
1005    find_interface(package, container)?
1006        .interactions
1007        .iter()
1008        .find(|decl| decl.name == member)
1009}
1010
1011/// The old side's interaction under a `<package>/<container>/<member>` path,
1012/// with the container resolved by [`find_old_shape`].
1013fn find_old_interaction<'a>(
1014    old: &'a v2::Package,
1015    new: &v2::Package,
1016    container: &str,
1017    member: &str,
1018) -> Option<&'a v2::Decl> {
1019    find_old_interface(old, new, container)?
1020        .interactions
1021        .iter()
1022        .find(|decl| decl.name == member)
1023}
1024
1025/// Every slot of an interface body as (name, ordinal), tombstones included — a
1026/// tombstone occupies its ordinal exactly so the slot is never reused
1027/// (ridl §11).
1028///
1029/// A tombstone whose name is unset still holds its slot. The interface-body
1030/// `reserved` form accepts a bare ordinal or a string literal as well as a
1031/// name, and both lower to `Reserved { name: None }`; dropping those from the
1032/// slot list would leave their ordinals looking free, and a new interaction
1033/// taking one would classify as a clean append. The empty name never equals a
1034/// real interaction name, so the slot is held against every reuse without
1035/// matching anything.
1036fn slots(interface: &v2::Interface) -> Vec<(&str, u32)> {
1037    interface
1038        .interactions
1039        .iter()
1040        .filter_map(|decl| match &decl.kind {
1041            Some(v2::decl::Kind::ReservedSlot(reserved)) => {
1042                Some((reserved.name.as_deref().unwrap_or(""), decl.ordinal))
1043            }
1044            Some(_) => Some((decl.name.as_str(), decl.ordinal)),
1045            None => None,
1046        })
1047        .collect()
1048}
1049
1050fn slot_ordinal(interface: &v2::Interface, member: &str) -> Option<u32> {
1051    slots(interface)
1052        .into_iter()
1053        .find(|(name, _)| *name == member)
1054        .map(|(_, ordinal)| ordinal)
1055}
1056
1057/// Every live struct field with its ordinal — the 1-based place in the body,
1058/// counting tombstones, that typl §7.4 makes the wire identity. Shared with the
1059/// walk, which reports a reorder by these ordinals.
1060pub(crate) fn struct_slots(def: &v2::StructDef) -> Vec<(String, i64)> {
1061    def.members
1062        .iter()
1063        .filter_map(|member| match &member.member {
1064            Some(v2::struct_member::Member::Field(field)) => {
1065                Some((field.name.clone(), i64::from(field.ordinal)))
1066            }
1067            _ => None,
1068        })
1069        .collect()
1070}
1071
1072fn struct_reserved(def: &v2::StructDef) -> Vec<i64> {
1073    def.members
1074        .iter()
1075        .filter_map(|member| match &member.member {
1076            Some(v2::struct_member::Member::Reserved(reserved)) => {
1077                Some(i64::from(reserved.ordinal))
1078            }
1079            _ => None,
1080        })
1081        .collect()
1082}
1083
1084/// Every union arm with its ordinal, on the same rule as [`struct_slots`].
1085pub(crate) fn union_slots(def: &v2::UnionDef) -> Vec<(String, i64)> {
1086    def.arms
1087        .iter()
1088        .map(|arm| (arm.name.clone(), i64::from(arm.ordinal)))
1089        .collect()
1090}
1091
1092fn union_reserved(def: &v2::UnionDef) -> Vec<i64> {
1093    def.reserved
1094        .iter()
1095        .map(|reserved| i64::from(reserved.ordinal))
1096        .collect()
1097}
1098
1099/// Enum values and enum-set bits key on their integer value, not a declaration
1100/// ordinal: the number is the wire identity (typl §7.4, §8).
1101pub(crate) fn value_slots(values: &[v2::EnumValue]) -> Vec<(String, i64)> {
1102    values
1103        .iter()
1104        .map(|value| (value.name.clone(), value.value))
1105        .collect()
1106}
1107
1108fn reserved_values(reserved: &[v2::Reserved]) -> Vec<i64> {
1109    reserved.iter().filter_map(|entry| entry.value).collect()
1110}
1111
1112// ==========================================================================
1113// `--explain` — the rule row for one category.
1114// ==========================================================================
1115
1116/// Parses the snake_case word a report prints back into its category, so
1117/// `ridl diff --explain <category>` takes exactly what the report shows.
1118pub fn category_from_word(word: &str) -> Option<Category> {
1119    crate::CATEGORIES
1120        .into_iter()
1121        .find(|category| crate::category_word(*category) == word)
1122}
1123
1124/// The rule row for a category: the classification table of ADR-0008 decision
1125/// 14 as text. This is the CI-facing documentation of record until the E4 error
1126/// index publishes it.
1127// A new variant must be given a real arm here, not swept into a
1128// catch-all: rustc forces *an* arm, and the arm its `help:` text
1129// proposes is `_ =>`, which classifies the new variant silently. The
1130// two lints below reject a wildcard over `Category` — the first when
1131// it covers several variants, the second when it covers exactly one,
1132// which is the case one added variant creates.
1133#[deny(
1134    clippy::wildcard_enum_match_arm,
1135    clippy::match_wildcard_for_single_variants
1136)]
1137pub fn explain(category: Category) -> &'static str {
1138    match category {
1139        Category::DeclAdded => concat!(
1140            "A declaration, interface, or service present only in the new snapshot.\n",
1141            "  compatible  a new package-level decl, interface, or service; an enum\n",
1142            "              value appended above every live and retired number; a\n",
1143            "              union arm appended at the end of the body (typl 7.4,\n",
1144            "              append-only); a struct field appended at the end of the body\n",
1145            "              when it is optional, or when it is a scalar, enum, or enum set\n",
1146            "              whose type allows the value 0\n",
1147            "  breaking    a member inserted below the highest slot ever used, a member\n",
1148            "              taking a retired number, any addition that moves a surviving\n",
1149            "              member's slot, or any arm of a result union (ADR-0008 d4); a\n",
1150            "              non-optional struct field appended whose type does not allow\n",
1151            "              0 — a string, bytes, struct, union, tuple, array, or map, a\n",
1152            "              scalar whose range or step excludes 0, an enum with no zero\n",
1153            "              member, or a type the diff cannot resolve (ridl diff\n",
1154            "              resolves ridl.std types). A reader of the new version\n",
1155            "              refuses every payload of the old one, which does not carry\n",
1156            "              the field (typl 7.4). Declare the new field optional instead\n",
1157            "              (`field : T?`): an absent optional field reads as absent\n",
1158            "  note        an interface is matched by its interfaces.lock number (lock\n",
1159            "              design 7): a frozen number the old side never held is a new\n",
1160            "              interface, and so is every provisional one — a declaration\n",
1161            "              with no lock entry carries no identity, and a rename keeps its\n",
1162            "              number, so it is never a renamed interface (interface_renamed)"
1163        ),
1164        Category::DeclRemoved => concat!(
1165            "A declaration, interface, or service present only in the old snapshot.\n",
1166            "  breaking    a removed service, interface, decl, enum value, struct\n",
1167            "              field, or union arm withdraws something a consumer compiled\n",
1168            "              against\n",
1169            "  note        an interface is matched by its interfaces.lock number (lock\n",
1170            "              design 7), so an interface here is one whose number is gone\n",
1171            "              from the new side and not retired there — a lock line deleted\n",
1172            "              by hand, or a number changed by hand, which is this row plus\n",
1173            "              decl_added. A number the new side's lock retires is\n",
1174            "              interface_retired instead, and `ridl baseline` refuses to\n",
1175            "              publish the removal of a number it does not find retired\n",
1176            "              (RIDL-412)\n",
1177            "  caveat      a composite member retired the sanctioned way — replaced by\n",
1178            "              a `reserved` tombstone in its own slot (typl 7.4) — is also\n",
1179            "              reported breaking today. The body comparison is keyed on\n",
1180            "              member names and does not read the `reserved` list, so it\n",
1181            "              cannot yet tell that retirement from a bare deletion. This\n",
1182            "              errs on the safe side; carried as debt, see the note on\n",
1183            "              `diff_composite`. The interaction-level tombstone IS\n",
1184            "              recognised — see interaction_retired"
1185        ),
1186        Category::InterfaceRenamed => concat!(
1187            "An interface whose interfaces.lock number is the same on both sides and\n",
1188            "whose name changed.\n",
1189            "  compatible  always — the number is the interface's identity and its\n",
1190            "              routing key (lock design 7, rsdl decision D-7), so nothing\n",
1191            "              moves on the wire. Visible in source: a rename changes the\n",
1192            "              generated identity-table names in both wire backends, which\n",
1193            "              is why the text report lists it under the heading\n",
1194            "              \"compatible on the wire, visible in source\". The path\n",
1195            "              carries the new name and the detail old -> new; changes\n",
1196            "              inside the interface are reported under the new name and\n",
1197            "              classified as any other interface's\n",
1198            "  note        a provisional number is no identity: a declaration with no\n",
1199            "              lock entry is never matched, so a rename the lock does not\n",
1200            "              record is decl_removed plus decl_added, breaking, until\n",
1201            "              `ridl lock <pkg> --rename Old=New` records it"
1202        ),
1203        Category::InterfaceRetired => concat!(
1204            "An interface whose number the old snapshot held is gone from the new\n",
1205            "snapshot, whose interfaces.lock retires it.\n",
1206            "  compatible  always — the sanctioned removal of an interface: the entry\n",
1207            "              keeps its number forever with the word `retired`, so the\n",
1208            "              number is never allocated again (lock design 4 and 7, rsdl\n",
1209            "              decision D-7). A number gone with no retired entry is\n",
1210            "              decl_removed, breaking, and `ridl baseline` refuses to publish\n",
1211            "              it (RIDL-412)"
1212        ),
1213        Category::InterfaceFrozen => concat!(
1214            "An interface matched by name whose number is the same on both sides and\n",
1215            "whose provisional flag went from true to false: `ridl lock` froze the\n",
1216            "number in place.\n",
1217            "  compatible  always — the number is the routing key and it did not\n",
1218            "              change, so nothing moves on the wire. The catalog hash\n",
1219            "              changes, because it covers each interface's number and its\n",
1220            "              provisional flag, and a generated face refuses a port whose\n",
1221            "              catalog hash is another one (ADR-0023 decision 8): a peer\n",
1222            "              built before the lock and a peer built after it refuse each\n",
1223            "              other. This is why the text report lists it under the\n",
1224            "              heading \"compatible on the wire, may change the catalog\n",
1225            "              hash\".\n",
1226            "              The detail carries the number before and after, marked\n",
1227            "              (provisional) on the old side\n",
1228            "  note        a baseline never holds a provisional number (`ridl baseline`\n",
1229            "              refuses one, RIDL-411), so this change is seen between two\n",
1230            "              builds, or in a reversed comparison whose old side is a\n",
1231            "              build. A number 0 on either side, from a snapshot written\n",
1232            "              before the lock existed, is not compared"
1233        ),
1234        Category::InterfaceNumberChanged => concat!(
1235            "An interface matched across the two sides whose number differs.\n",
1236            "  breaking    always — the number is the interface's routing key, so a\n",
1237            "              peer built against the old number cannot reach the\n",
1238            "              interface at the new one. Seen between two provisional\n",
1239            "              builds when a sibling interface added before this one in\n",
1240            "              byte order moves its provisional number, and when `ridl\n",
1241            "              lock` freezes a provisional number as another number. The\n",
1242            "              detail carries the old and the new number, each marked\n",
1243            "              (provisional) when it is\n",
1244            "  note        two frozen numbers are matched by number, so they never\n",
1245            "              differ here: a frozen number changed by hand is\n",
1246            "              decl_removed plus decl_added. A number 0 on either side,\n",
1247            "              from a snapshot written before the lock existed, is not\n",
1248            "              compared.\n",
1249            "              Run `ridl lock` to freeze the numbers, so that a later\n",
1250            "              sibling cannot move them"
1251        ),
1252        Category::MemberReordered => concat!(
1253            "A surviving composite member whose slot in the body changed.\n",
1254            "  breaking    always — a struct field or union arm takes its wire\n",
1255            "              identity from its ordinal, its 1-based place in the body\n",
1256            "              counting tombstones (typl 7.4), so a member whose ordinal\n",
1257            "              changed has a new wire identity; the detail carries the old\n",
1258            "              and new ordinal. An enum value or enum-set bit takes its\n",
1259            "              identity from the explicit number it declares (typl 8, 9),\n",
1260            "              so one whose number changed has a new wire identity; the\n",
1261            "              detail carries the old and new value or bit. An enum or\n",
1262            "              enum-set body reordered with no number changed is\n",
1263            "              enum_reordered, compatible, instead\n",
1264            "  note        reported only when both bodies hold the same member names:\n",
1265            "              a reorder in the same edit as an addition or a removal is\n",
1266            "              reported through decl_added or decl_removed alone. A reorder\n",
1267            "              in the same edit as an in-place change to the body is\n",
1268            "              reported with constraint_changed on the container as well.\n",
1269            "              An enum or enum-set body whose only differences are changed\n",
1270            "              numbers, in any order, reports member_reordered alone"
1271        ),
1272        Category::EnumReordered => concat!(
1273            "An enum or enum-set body whose difference is only order: its values or\n",
1274            "bits reordered with no number changed, the enum's reserved list\n",
1275            "reordered with no entry added, removed or changed, or both.\n",
1276            "  compatible  always — a value or bit takes its identity from the\n",
1277            "              explicit number it declares (typl 8, 9), so nothing moves\n",
1278            "              on the wire. The catalog hash covers only the declarations\n",
1279            "              an interface reaches. When an interface reaches the enum or\n",
1280            "              enum set, the order of its values or bits, and of an enum's\n",
1281            "              reserved list, is part of the hash, so the hash changes\n",
1282            "              then, and a generated face refuses a port whose catalog hash\n",
1283            "              is another one (ADR-0023 decision 8). This is why the text\n",
1284            "              report lists it under the heading \"compatible on the wire,\n",
1285            "              may change the catalog hash\". The path is the container's;\n",
1286            "              there is no detail\n",
1287            "  note        a value or bit whose number changed is member_reordered,\n",
1288            "              breaking, not enum_reordered"
1289        ),
1290        Category::InteractionAppended => concat!(
1291            "An interaction added after every slot that existed before.\n",
1292            "  compatible  the slot it takes was never occupied\n",
1293            "  breaking    the slot was freed by an untombstoned removal and is now\n",
1294            "              reused by a new name — a reused wire identity (ADR-0008 d14)"
1295        ),
1296        Category::InteractionInserted => concat!(
1297            "An interaction added before the end of the interface body.\n",
1298            "  breaking    always — every later ordinal shifts, and the ordinal is the\n",
1299            "              transport identity (ridl 11)"
1300        ),
1301        Category::InteractionReordered => concat!(
1302            "A surviving interaction whose relative order in the body changed.\n",
1303            "  breaking    always — a reorder shifts wire identities (ridl 11)"
1304        ),
1305        Category::InteractionRemoved => concat!(
1306            "An interaction removed without a `reserved` tombstone holding its slot.\n",
1307            "  breaking    always — the freed ordinal is reusable, so the wire identity\n",
1308            "              is no longer permanent (ridl 11)"
1309        ),
1310        Category::InteractionRetired => concat!(
1311            "An interaction retired to a `reserved` tombstone in its own ordinal slot.\n",
1312            "  compatible  always — the sanctioned retirement: the slot stays occupied\n",
1313            "              and every later ordinal holds (ridl 11)"
1314        ),
1315        Category::KindChanged => concat!(
1316            "An interaction whose kind changed (signal, event, command, query, fixed).\n",
1317            "  breaking    any direction — the kind selects the transport shape"
1318        ),
1319        Category::PayloadChanged => concat!(
1320            "A signal, event, or fixed payload type changed.\n",
1321            "  breaking    any direction, a stream added or removed included"
1322        ),
1323        Category::ReturnChanged => concat!(
1324            "A query return shape changed.\n",
1325            "  breaking    any direction — an ok-arm change, an error arm added, removed,\n",
1326            "              or retyped, a stream added or removed, or any other change to\n",
1327            "              the synthesized inline `T | E` transport identity (ADR-0008 d4:\n",
1328            "              interface + interaction ordinal + ordered arm types)"
1329        ),
1330        Category::ParamsChanged => concat!(
1331            "A command or query parameter list changed.\n",
1332            "  breaking    any direction — a parameter added, removed, renamed, retyped,\n",
1333            "              or a stream added or removed on one"
1334        ),
1335        Category::TimingChanged => concat!(
1336            "A signal or event resolved timing changed (ADR-0008 d12).\n",
1337            "  compatible  min raised (a higher rate floor) or max lowered (a tighter\n",
1338            "              staleness bound) with the mode unchanged; default_applied\n",
1339            "              flipped over identical resolved bounds — a default made\n",
1340            "              explicit\n",
1341            "  breaking    min lowered, max raised, a bound added where none was, a bound\n",
1342            "              removed, or the strict-periodic/range mode flipped\n",
1343            "  note        editing `[defaults].timing` needs no special rule: diff\n",
1344            "              compares resolved bounds, so it surfaces here on every\n",
1345            "              defaulted interaction (ridl 9.1)"
1346        ),
1347        Category::RpcBoundChanged => concat!(
1348            "A command or query declared RPC bound changed (ADR-0015 d8).\n",
1349            "  compatible  min lowered (the caller may call more often) or max lowered\n",
1350            "              (a stronger provider promise), with the mode unchanged;\n",
1351            "              default_applied flipped over identical resolved bounds — a\n",
1352            "              default made explicit\n",
1353            "  breaking    min raised — on an RPC, min is the call throttle and\n",
1354            "              constrains the caller, so raising it withdraws a call rate\n",
1355            "              the caller was entitled to use; max raised (a weaker provider\n",
1356            "              promise); a bound added or removed, the whole annotation\n",
1357            "              included\n",
1358            "  note        the min direction is the inverse of timing_changed's, which\n",
1359            "              is why this is a category of its own rather than a branch:\n",
1360            "              a missed branch would inherit the signal rule and call a\n",
1361            "              raised RPC min compatible (ADR-0012 d9, fail closed).\n",
1362            "              an RPC bound may come from a default, and diff compares\n",
1363            "              resolved bounds, so editing a default surfaces here on\n",
1364            "              every defaulted command or query (ridl 9.1)"
1365        ),
1366        Category::ContractChanged => concat!(
1367            "A command or query require/ensure clause set changed (ridl 13).\n",
1368            "  compatible  a require removed, or an ensure added\n",
1369            "  breaking    a require added or its text changed, or an ensure removed or\n",
1370            "              its text changed. Clause text is compared verbatim: the\n",
1371            "              classifier does not prove that one clause implies another\n",
1372            "              (ADR-0008 d14)"
1373        ),
1374        Category::InteractionClassChanged => concat!(
1375            "A name-paired interaction's family or operation shape changed\n",
1376            "(ADR-0012 d6).\n",
1377            "  breaking    any change, in either direction: the family selects the\n",
1378            "              obligations a consumer compiled against, and the shape\n",
1379            "              the gesture an intent command is. No source writes a\n",
1380            "              family other than dispatch yet; the walk compares the\n",
1381            "              fields from the start so the diff fails closed (ADR-0012 d9)"
1382        ),
1383        Category::WidthChanged => concat!(
1384            "A derived wire width or scalar backing changed.\n",
1385            "  breaking    any IntWidth or FloatWidth change, uint64 versus int64\n",
1386            "              included — the resolved width is part of the contract\n",
1387            "              (typl 4.2, 5.6)"
1388        ),
1389        Category::ConstraintChanged => concat!(
1390            "A scalar constraint changed, or a composite body changed in place.\n",
1391            "  compatible  widened — min lowered without shifting a stepped grid, max\n",
1392            "              raised, a length bound loosened,\n",
1393            "              a step removed, a match pattern removed (by literal or by\n",
1394            "              named constant), or the whole constraint dropped so the\n",
1395            "              value is unbounded again\n",
1396            "  breaking    narrowed — min raised, max lowered, a length bound\n",
1397            "              tightened, a stepped grid shifted, a step added or changed\n",
1398            "              (divisibility between different steps is never\n",
1399            "              proved), a match pattern added or rewritten (by literal or\n",
1400            "              by named constant), or a constraint appearing where there\n",
1401            "              was none, which bounds a previously unbounded value. A\n",
1402            "              composite body changed in place is breaking: the walk\n",
1403            "              does not say which member changed inside it\n",
1404            "  note        each facet is judged on its own and any one narrowing\n",
1405            "              decides the change, so a mixed edit is breaking on the half\n",
1406            "              that narrows. A widening that flips the resolved wire width\n",
1407            "              is separately reported as width_changed, which is always\n",
1408            "              breaking (typl 5.6)"
1409        ),
1410        Category::InitChanged => concat!(
1411            "A declared or resolved init value changed.\n",
1412            "  breaking    any direction — the init is the value a consumer reads before\n",
1413            "              the first publish, so it is part of the contract (ridl 9.1)"
1414        ),
1415        Category::ReservedNameRedeclared => concat!(
1416            "A name retired by a `reserved` tombstone is live again — an interaction\n",
1417            "inside an interface body.\n",
1418            "  breaking    always — a retired identity is never reused (ridl 11,\n",
1419            "              RIDL-401)"
1420        ),
1421        Category::ServiceChanged => concat!(
1422            "A service switched between the named list and an inline shape.\n",
1423            "  breaking    always — extraction rewrites the transport identity of every\n",
1424            "              fallible query in the shape: an inline shape derives it from\n",
1425            "              the service's dotted name, a named interface from its own name\n",
1426            "              (ADR-0008 d4, ADR-0015 d15). A changed list is not this\n",
1427            "              category: it is read as a set by the service_interface_*\n",
1428            "              rows (ADR-0015 d19, as amended 2026-09-15)"
1429        ),
1430        Category::ServiceInterfaceAdded => concat!(
1431            "An interface joined a service's set of interfaces.\n",
1432            "  compatible  always — nothing that existed moved: an interface's number\n",
1433            "              comes from its unit's interfaces.lock, not from its place\n",
1434            "              in the list, and the routing key does not contain the service\n",
1435            "              (ADR-0015 d19, as amended 2026-09-15)"
1436        ),
1437        Category::ServiceInterfaceRemoved => concat!(
1438            "An interface left a service's set of interfaces.\n",
1439            "  compatible  always — on the wire: the routing key does not contain the\n",
1440            "              service, so no identity moves, and a split into two services\n",
1441            "              is a removal plus an addition. Visible in source: the\n",
1442            "              service.member addresses of that interface stop resolving\n",
1443            "              under this service, which is why the text report lists it\n",
1444            "              under the heading \"compatible on the wire, visible in\n",
1445            "              source\". A consumer that loses its only provider is a\n",
1446            "              wiring error for rsdl, not a package diff (ADR-0015 d19, as\n",
1447            "              amended 2026-09-15)"
1448        ),
1449        Category::DocOnly => concat!(
1450            "Only doc comment, labels, or deprecation metadata changed.\n",
1451            "  compatible  always — none of it reaches a consumer's build.\n",
1452            "              Visibility is NOT in this category: see\n",
1453            "              visibility_changed"
1454        ),
1455        Category::VisibilityChanged => concat!(
1456            "The visibility a declaration is published at changed.\n",
1457            "  compatible  internal -> public: the declaration is offered to more\n",
1458            "              consumers than before\n",
1459            "  breaking    public -> internal: `internal` maps to the target's\n",
1460            "              package-private mechanism — Rust `pub(crate)`, a\n",
1461            "              non-exported TypeScript member (ADR-0002 8) — so the\n",
1462            "              declaration disappears from every out-of-package\n",
1463            "              consumer's build. The wire layout does not move, but the\n",
1464            "              consumer-visible guarantee narrows (ADR-0008 d14). Any\n",
1465            "              direction involving an unset visibility is breaking"
1466        ),
1467    }
1468}