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