Skip to main content

ridl_diff/
classify.rs

1//! The breaking/compatible classifier (docs/ROADMAP.md epic E2.8b).
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    // `default_applied` is always false on an RPC, because an RPC bound is
755    // never defaulted (ADR-0015 decision 7); anything else in a snapshot is
756    // erroneous IR, and a flip is breaking regardless — never the "default
757    // made explicit" compatibility [`timing`] grants, which would report
758    // compatible on IR the classifier does not understand (ADR-0012
759    // decision 9).
760    if old_timing.default_applied != new_timing.default_applied {
761        return Verdict::Breaking;
762    }
763    // A throttle raised, a response bound raised, or either bound added or
764    // removed. `raised` covers the added case (`None` → `Some`), `dropped` the
765    // removed one. This is [`timing`]'s predicate with the `min` direction
766    // inverted — `raised` where the signal rule reads `lowered`.
767    if raised(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
768        || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
769        || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
770        || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
771    {
772        return Verdict::Breaking;
773    }
774    // What remains is a throttle lowered or a response bound lowered — the
775    // caller is less constrained, or the provider promises more.
776    Verdict::Compatible
777}
778
779/// Whether a bound present before is absent now.
780fn dropped(old: Option<&str>, new: Option<&str>) -> bool {
781    old.is_some() && new.is_none()
782}
783
784/// The resolved timing of a signal or event; `None` for interaction kinds that
785/// carry none, and `Some(None)` for a timed kind with the field unset.
786fn interaction_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
787    use v2::decl::Kind;
788    match &decl.kind {
789        Some(Kind::SignalDef(def)) => Some(def.timing.as_ref()),
790        Some(Kind::EventDef(def)) => Some(def.timing.as_ref()),
791        _ => None,
792    }
793}
794
795/// The declared timing of a command or query; `None` for other kinds, and
796/// `Some(None)` for an RPC that declares no bounds (never defaulted,
797/// ADR-0015 decision 4).
798fn rpc_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
799    use v2::decl::Kind;
800    match &decl.kind {
801        Some(Kind::CommandDef(def)) => Some(def.timing.as_ref()),
802        Some(Kind::QueryDef(def)) => Some(def.timing.as_ref()),
803        _ => None,
804    }
805}
806
807// ==========================================================================
808// Contracts.
809// ==========================================================================
810
811/// A `require`/`ensure` clause-set change on a command or query.
812///
813/// A `require` is a precondition the caller must meet: adding one, or rewriting
814/// one, rejects callers that were legal. An `ensure` is a postcondition the
815/// caller may rely on: removing one, or rewriting one, withdraws a guarantee.
816/// Clause text is compared verbatim — the classifier never tries to prove that
817/// one clause implies another (ADR-0008 decision 14), so a rewrite reads as an
818/// addition on the `require` side and as a removal on the `ensure` side, and
819/// both are breaking.
820fn contract(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
821    let Some((container, member)) = member_path(change) else {
822        return Verdict::Breaking;
823    };
824    let (Some(old_decl), Some(new_decl)) = (
825        find_old_interaction(old, new, container, member),
826        find_interaction(new, container, member),
827    ) else {
828        return Verdict::Breaking;
829    };
830    let (Some(old_clauses), Some(new_clauses)) = (contracts(old_decl), contracts(new_decl)) else {
831        return Verdict::Breaking;
832    };
833
834    let kind = |want: v2::ContractKind| {
835        move |clause: &&v2::Contract| v2::ContractKind::try_from(clause.kind) == Ok(want)
836    };
837    let sources = |clauses: &[v2::Contract], want: v2::ContractKind| -> Vec<String> {
838        let mut out: Vec<String> = clauses
839            .iter()
840            .filter(kind(want))
841            .map(|clause| clause.source.clone())
842            .collect();
843        out.sort();
844        out
845    };
846
847    let old_require = sources(old_clauses, v2::ContractKind::Require);
848    let new_require = sources(new_clauses, v2::ContractKind::Require);
849    let old_ensure = sources(old_clauses, v2::ContractKind::Ensure);
850    let new_ensure = sources(new_clauses, v2::ContractKind::Ensure);
851
852    if !covers(&old_require, &new_require) || !covers(&new_ensure, &old_ensure) {
853        return Verdict::Breaking;
854    }
855    Verdict::Compatible
856}
857
858/// Whether every clause in `subset` appears in `superset`, counting duplicates.
859fn covers(superset: &[String], subset: &[String]) -> bool {
860    let mut remaining: Vec<&String> = superset.iter().collect();
861    for clause in subset {
862        match remaining.iter().position(|held| *held == clause) {
863            Some(index) => {
864                remaining.swap_remove(index);
865            }
866            None => return false,
867        }
868    }
869    true
870}
871
872/// The contract clauses of a command or query; `None` for kinds that carry none.
873fn contracts(decl: &v2::Decl) -> Option<&[v2::Contract]> {
874    use v2::decl::Kind;
875    match &decl.kind {
876        Some(Kind::CommandDef(def)) => Some(&def.contracts),
877        Some(Kind::QueryDef(def)) => Some(&def.contracts),
878        _ => None,
879    }
880}
881
882// ==========================================================================
883// Path resolution and IR lookups.
884// ==========================================================================
885
886/// Splits a `package/container/member` path into its container and member.
887/// Returns `None` for the shorter package-level paths.
888fn member_path(change: &Change) -> Option<(&str, &str)> {
889    let mut segments = change.path.split('/').skip(1);
890    let container = segments.next()?;
891    let member = segments.next()?;
892    if segments.next().is_some() {
893        return None;
894    }
895    Some((container, member))
896}
897
898fn find_decl<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Decl> {
899    package.decls.iter().find(|decl| decl.name == name)
900}
901
902/// The shape a name refers to — a top-level interface, or the inline shape of
903/// a service, which the walk descends into under the service's own dotted
904/// name. `Package::shapes` keys both on exactly that identity, so one lookup
905/// covers them.
906fn find_shape<'a>(package: &'a v2::Package, name: &str) -> Option<v2::InterfaceShape<'a>> {
907    package.shapes().find(|shape| shape.name == name)
908}
909
910/// The old side of the shape a diff path names, found the way the walk
911/// matched it (lock design §7; plan decision PD-14): by the frozen
912/// `interfaces.lock` number of the new side's shape when it has one, and by
913/// name otherwise — the new side has no identity, or does not hold the name
914/// at all. A renamed interface's member changes therefore classify as any
915/// other interface's, although their paths carry only the new name.
916fn find_old_shape<'a>(
917    old: &'a v2::Package,
918    new: &v2::Package,
919    name: &str,
920) -> Option<v2::InterfaceShape<'a>> {
921    let by_number = find_shape(new, name)
922        .filter(|shape| frozen(shape.interface))
923        .and_then(|shape| {
924            old.shapes().find(|candidate| {
925                frozen(candidate.interface) && candidate.interface.number == shape.interface.number
926            })
927        });
928    by_number.or_else(|| find_shape(old, name))
929}
930
931fn find_interface<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Interface> {
932    find_shape(package, name).map(|shape| shape.interface)
933}
934
935fn find_old_interface<'a>(
936    old: &'a v2::Package,
937    new: &v2::Package,
938    name: &str,
939) -> Option<&'a v2::Interface> {
940    find_old_shape(old, new, name).map(|shape| shape.interface)
941}
942
943fn find_interaction<'a>(
944    package: &'a v2::Package,
945    container: &str,
946    member: &str,
947) -> Option<&'a v2::Decl> {
948    find_interface(package, container)?
949        .interactions
950        .iter()
951        .find(|decl| decl.name == member)
952}
953
954/// The old side's interaction under a `<package>/<container>/<member>` path,
955/// with the container resolved by [`find_old_shape`].
956fn find_old_interaction<'a>(
957    old: &'a v2::Package,
958    new: &v2::Package,
959    container: &str,
960    member: &str,
961) -> Option<&'a v2::Decl> {
962    find_old_interface(old, new, container)?
963        .interactions
964        .iter()
965        .find(|decl| decl.name == member)
966}
967
968/// Every slot of an interface body as (name, ordinal), tombstones included — a
969/// tombstone occupies its ordinal exactly so the slot is never reused
970/// (ridl §11).
971///
972/// A tombstone whose name is unset still holds its slot. The interface-body
973/// `reserved` form accepts a bare ordinal or a string literal as well as a
974/// name, and both lower to `Reserved { name: None }`; dropping those from the
975/// slot list would leave their ordinals looking free, and a new interaction
976/// taking one would classify as a clean append. The empty name never equals a
977/// real interaction name, so the slot is held against every reuse without
978/// matching anything.
979fn slots(interface: &v2::Interface) -> Vec<(&str, u32)> {
980    interface
981        .interactions
982        .iter()
983        .filter_map(|decl| match &decl.kind {
984            Some(v2::decl::Kind::ReservedSlot(reserved)) => {
985                Some((reserved.name.as_deref().unwrap_or(""), decl.ordinal))
986            }
987            Some(_) => Some((decl.name.as_str(), decl.ordinal)),
988            None => None,
989        })
990        .collect()
991}
992
993fn slot_ordinal(interface: &v2::Interface, member: &str) -> Option<u32> {
994    slots(interface)
995        .into_iter()
996        .find(|(name, _)| *name == member)
997        .map(|(_, ordinal)| ordinal)
998}
999
1000/// Every live struct field with its ordinal — the 1-based place in the body,
1001/// counting tombstones, that typl §7.4 makes the wire identity. Shared with the
1002/// walk, which reports a reorder by these ordinals.
1003pub(crate) fn struct_slots(def: &v2::StructDef) -> Vec<(String, i64)> {
1004    def.members
1005        .iter()
1006        .filter_map(|member| match &member.member {
1007            Some(v2::struct_member::Member::Field(field)) => {
1008                Some((field.name.clone(), i64::from(field.ordinal)))
1009            }
1010            _ => None,
1011        })
1012        .collect()
1013}
1014
1015fn struct_reserved(def: &v2::StructDef) -> Vec<i64> {
1016    def.members
1017        .iter()
1018        .filter_map(|member| match &member.member {
1019            Some(v2::struct_member::Member::Reserved(reserved)) => {
1020                Some(i64::from(reserved.ordinal))
1021            }
1022            _ => None,
1023        })
1024        .collect()
1025}
1026
1027/// Every union arm with its ordinal, on the same rule as [`struct_slots`].
1028pub(crate) fn union_slots(def: &v2::UnionDef) -> Vec<(String, i64)> {
1029    def.arms
1030        .iter()
1031        .map(|arm| (arm.name.clone(), i64::from(arm.ordinal)))
1032        .collect()
1033}
1034
1035fn union_reserved(def: &v2::UnionDef) -> Vec<i64> {
1036    def.reserved
1037        .iter()
1038        .map(|reserved| i64::from(reserved.ordinal))
1039        .collect()
1040}
1041
1042/// Enum values and enum-set bits key on their integer value, not a declaration
1043/// ordinal: the number is the wire identity (typl §7.4, §8).
1044fn value_slots(values: &[v2::EnumValue]) -> Vec<(String, i64)> {
1045    values
1046        .iter()
1047        .map(|value| (value.name.clone(), value.value))
1048        .collect()
1049}
1050
1051fn reserved_values(reserved: &[v2::Reserved]) -> Vec<i64> {
1052    reserved.iter().filter_map(|entry| entry.value).collect()
1053}
1054
1055// ==========================================================================
1056// `--explain` — the rule row for one category.
1057// ==========================================================================
1058
1059/// Parses the snake_case word a report prints back into its category, so
1060/// `ridl diff --explain <category>` takes exactly what the report shows.
1061pub fn category_from_word(word: &str) -> Option<Category> {
1062    crate::CATEGORIES
1063        .into_iter()
1064        .find(|category| crate::category_word(*category) == word)
1065}
1066
1067/// The rule row for a category: the classification table of ADR-0008 decision
1068/// 14 as text. This is the CI-facing documentation of record until the E4 error
1069/// index publishes it.
1070// A new variant must be given a real arm here, not swept into a
1071// catch-all: rustc forces *an* arm, and the arm its `help:` text
1072// proposes is `_ =>`, which classifies the new variant silently. The
1073// two lints below reject a wildcard over `Category` — the first when
1074// it covers several variants, the second when it covers exactly one,
1075// which is the case one added variant creates.
1076#[deny(
1077    clippy::wildcard_enum_match_arm,
1078    clippy::match_wildcard_for_single_variants
1079)]
1080pub fn explain(category: Category) -> &'static str {
1081    match category {
1082        Category::DeclAdded => concat!(
1083            "A declaration, interface, or service present only in the new snapshot.\n",
1084            "  compatible  a new package-level decl, interface, or service; an enum\n",
1085            "              value appended above every live and retired number; a\n",
1086            "              union arm appended at the end of the body (typl 7.4,\n",
1087            "              append-only); a struct field appended at the end of the body\n",
1088            "              when it is optional, or when it is a scalar, enum, or enum set\n",
1089            "              whose type allows the value 0\n",
1090            "  breaking    a member inserted below the highest slot ever used, a member\n",
1091            "              taking a retired number, any addition that moves a surviving\n",
1092            "              member's slot, or any arm of a result union (ADR-0008 d4); a\n",
1093            "              non-optional struct field appended whose type does not allow\n",
1094            "              0 — a string, bytes, struct, union, tuple, array, or map, a\n",
1095            "              scalar whose range or step excludes 0, an enum with no zero\n",
1096            "              member, or a type the diff cannot resolve (ridl diff\n",
1097            "              resolves ridl.std types). A reader of the new version\n",
1098            "              refuses every payload of the old one, which does not carry\n",
1099            "              the field (typl 7.4). Declare the new field optional instead\n",
1100            "              (`field : T?`): an absent optional field reads as absent\n",
1101            "  note        an interface is matched by its interfaces.lock number (lock\n",
1102            "              design 7): a frozen number the old side never held is a new\n",
1103            "              interface, and so is every provisional one — a declaration\n",
1104            "              with no lock entry carries no identity, and a rename keeps its\n",
1105            "              number, so it is never a renamed interface (interface_renamed)"
1106        ),
1107        Category::DeclRemoved => concat!(
1108            "A declaration, interface, or service present only in the old snapshot.\n",
1109            "  breaking    a removed service, interface, decl, enum value, struct\n",
1110            "              field, or union arm withdraws something a consumer compiled\n",
1111            "              against\n",
1112            "  note        an interface is matched by its interfaces.lock number (lock\n",
1113            "              design 7), so an interface here is one whose number is gone\n",
1114            "              from the new side and not retired there — a lock line deleted\n",
1115            "              by hand, or a number changed by hand, which is this row plus\n",
1116            "              decl_added. A number the new side's lock retires is\n",
1117            "              interface_retired instead, and `ridl baseline` refuses to\n",
1118            "              publish the removal of a number it does not find retired\n",
1119            "              (RIDL-412)\n",
1120            "  caveat      a composite member retired the sanctioned way — replaced by\n",
1121            "              a `reserved` tombstone in its own slot (typl 7.4) — is also\n",
1122            "              reported breaking today. The body comparison is keyed on\n",
1123            "              member names and does not read the `reserved` list, so it\n",
1124            "              cannot yet tell that retirement from a bare deletion. This\n",
1125            "              errs on the safe side; carried as debt, see the note on\n",
1126            "              `diff_composite`. The interaction-level tombstone IS\n",
1127            "              recognised — see interaction_retired"
1128        ),
1129        Category::InterfaceRenamed => concat!(
1130            "An interface whose interfaces.lock number is the same on both sides and\n",
1131            "whose name changed.\n",
1132            "  compatible  always — the number is the interface's identity and its\n",
1133            "              routing key (lock design 7, rsdl decision D-7), so nothing\n",
1134            "              moves on the wire. Visible in source: a rename changes the\n",
1135            "              generated identity-table names in both wire backends, which\n",
1136            "              is why the text report lists it under the heading\n",
1137            "              \"compatible on the wire, visible in source\". The path\n",
1138            "              carries the new name and the detail old -> new; changes\n",
1139            "              inside the interface are reported under the new name and\n",
1140            "              classified as any other interface's\n",
1141            "  note        a provisional number is no identity: a declaration with no\n",
1142            "              lock entry is never matched, so a rename the lock does not\n",
1143            "              record is decl_removed plus decl_added, breaking, until\n",
1144            "              `ridl lock <pkg> --rename Old=New` records it"
1145        ),
1146        Category::InterfaceRetired => concat!(
1147            "An interface whose number the old snapshot held is gone from the new\n",
1148            "snapshot, whose interfaces.lock retires it.\n",
1149            "  compatible  always — the sanctioned removal of an interface: the entry\n",
1150            "              keeps its number forever with the word `retired`, so the\n",
1151            "              number is never allocated again (lock design 4 and 7, rsdl\n",
1152            "              decision D-7). A number gone with no retired entry is\n",
1153            "              decl_removed, breaking, and `ridl baseline` refuses to publish\n",
1154            "              it (RIDL-412)"
1155        ),
1156        Category::MemberReordered => concat!(
1157            "A surviving composite member whose slot in the body changed.\n",
1158            "  breaking    always — a struct field or union arm takes its wire\n",
1159            "              identity from its ordinal, its 1-based place in the body\n",
1160            "              counting tombstones (typl 7.4), so a member whose ordinal\n",
1161            "              changed has a new wire identity; the detail carries the old\n",
1162            "              and new ordinal. An enum value or enum-set bit carries an\n",
1163            "              explicit number instead (typl 8, 9), but the walk compares\n",
1164            "              positions, not those numbers, so a textual reorder of an\n",
1165            "              enum or enum-set body is reported breaking as well,\n",
1166            "              conservatively, even when no number changed; the detail\n",
1167            "              carries the old and new position\n",
1168            "  note        reported only when both bodies hold the same member names:\n",
1169            "              a reorder in the same edit as an addition or a removal is\n",
1170            "              reported through decl_added or decl_removed alone. A reorder\n",
1171            "              in the same edit as an in-place change to the body is\n",
1172            "              reported with constraint_changed on the container as well"
1173        ),
1174        Category::InteractionAppended => concat!(
1175            "An interaction added after every slot that existed before.\n",
1176            "  compatible  the slot it takes was never occupied\n",
1177            "  breaking    the slot was freed by an untombstoned removal and is now\n",
1178            "              reused by a new name — a reused wire identity (ADR-0008 d14)"
1179        ),
1180        Category::InteractionInserted => concat!(
1181            "An interaction added before the end of the interface body.\n",
1182            "  breaking    always — every later ordinal shifts, and the ordinal is the\n",
1183            "              transport identity (ridl 11)"
1184        ),
1185        Category::InteractionReordered => concat!(
1186            "A surviving interaction whose relative order in the body changed.\n",
1187            "  breaking    always — a reorder shifts wire identities (ridl 11)"
1188        ),
1189        Category::InteractionRemoved => concat!(
1190            "An interaction removed without a `reserved` tombstone holding its slot.\n",
1191            "  breaking    always — the freed ordinal is reusable, so the wire identity\n",
1192            "              is no longer permanent (ridl 11)"
1193        ),
1194        Category::InteractionRetired => concat!(
1195            "An interaction retired to a `reserved` tombstone in its own ordinal slot.\n",
1196            "  compatible  always — the sanctioned retirement: the slot stays occupied\n",
1197            "              and every later ordinal holds (ridl 11)"
1198        ),
1199        Category::KindChanged => concat!(
1200            "An interaction whose kind changed (signal, event, command, query, fixed).\n",
1201            "  breaking    any direction — the kind selects the transport shape"
1202        ),
1203        Category::PayloadChanged => concat!(
1204            "A signal, event, or fixed payload type changed.\n",
1205            "  breaking    any direction, a stream added or removed included"
1206        ),
1207        Category::ReturnChanged => concat!(
1208            "A query return shape changed.\n",
1209            "  breaking    any direction — an ok-arm change, an error arm added, removed,\n",
1210            "              or retyped, a stream added or removed, or any other change to\n",
1211            "              the synthesized inline `T | E` transport identity (ADR-0008 d4:\n",
1212            "              interface + interaction ordinal + ordered arm types)"
1213        ),
1214        Category::ParamsChanged => concat!(
1215            "A command or query parameter list changed.\n",
1216            "  breaking    any direction — a parameter added, removed, renamed, retyped,\n",
1217            "              or a stream added or removed on one"
1218        ),
1219        Category::TimingChanged => concat!(
1220            "A signal or event resolved timing changed (ADR-0008 d12).\n",
1221            "  compatible  min raised (a higher rate floor) or max lowered (a tighter\n",
1222            "              staleness bound) with the mode unchanged; default_applied\n",
1223            "              flipped over identical resolved bounds — a default made\n",
1224            "              explicit\n",
1225            "  breaking    min lowered, max raised, a bound added where none was, a bound\n",
1226            "              removed, or the strict-periodic/range mode flipped\n",
1227            "  note        editing `[defaults].timing` needs no special rule: diff\n",
1228            "              compares resolved bounds, so it surfaces here on every\n",
1229            "              defaulted interaction (ridl 9.1)"
1230        ),
1231        Category::RpcBoundChanged => concat!(
1232            "A command or query declared RPC bound changed (ADR-0015 d8).\n",
1233            "  compatible  min lowered (the caller may call more often) or max lowered\n",
1234            "              (a stronger provider promise), with the mode unchanged\n",
1235            "  breaking    min raised — on an RPC, min is the call throttle and\n",
1236            "              constrains the caller, so raising it withdraws a call rate\n",
1237            "              the caller was entitled to use; max raised (a weaker provider\n",
1238            "              promise); a bound added or removed, the whole annotation\n",
1239            "              included\n",
1240            "  note        the min direction is the inverse of timing_changed's, which\n",
1241            "              is why this is a category of its own rather than a branch:\n",
1242            "              a missed branch would inherit the signal rule and call a\n",
1243            "              raised RPC min compatible (ADR-0012 d9, fail closed).\n",
1244            "              RPC bounds are never defaulted (ridl 9.1 does not apply)"
1245        ),
1246        Category::ContractChanged => concat!(
1247            "A command or query require/ensure clause set changed (ridl 13).\n",
1248            "  compatible  a require removed, or an ensure added\n",
1249            "  breaking    a require added or its text changed, or an ensure removed or\n",
1250            "              its text changed. Clause text is compared verbatim: the\n",
1251            "              classifier does not prove that one clause implies another\n",
1252            "              (ADR-0008 d14)"
1253        ),
1254        Category::WidthChanged => concat!(
1255            "A derived wire width or scalar backing changed.\n",
1256            "  breaking    any IntWidth or FloatWidth change, uint64 versus int64\n",
1257            "              included — the resolved width is part of the contract\n",
1258            "              (typl 4.2, 5.6)"
1259        ),
1260        Category::ConstraintChanged => concat!(
1261            "A scalar constraint changed, or a composite body changed in place.\n",
1262            "  compatible  widened — min lowered, max raised, a length bound loosened,\n",
1263            "              a step removed, a match pattern removed (by literal or by\n",
1264            "              named constant), or the whole constraint dropped so the\n",
1265            "              value is unbounded again\n",
1266            "  breaking    narrowed — min raised, max lowered, a length bound\n",
1267            "              tightened, a step added or changed (divisibility is never\n",
1268            "              proved), a match pattern added or rewritten (by literal or\n",
1269            "              by named constant), or a constraint appearing where there\n",
1270            "              was none, which bounds a previously unbounded value. A\n",
1271            "              composite body changed in place is breaking: the walk\n",
1272            "              does not say which member changed inside it\n",
1273            "  note        each facet is judged on its own and any one narrowing\n",
1274            "              decides the change, so a mixed edit is breaking on the half\n",
1275            "              that narrows. A widening that flips the resolved wire width\n",
1276            "              is separately reported as width_changed, which is always\n",
1277            "              breaking (typl 5.6)"
1278        ),
1279        Category::InitChanged => concat!(
1280            "A declared or resolved init value changed.\n",
1281            "  breaking    any direction — the init is the value a consumer reads before\n",
1282            "              the first publish, so it is part of the contract (ridl 9.1)"
1283        ),
1284        Category::ReservedNameRedeclared => concat!(
1285            "A name retired by a `reserved` tombstone is live again — an interaction\n",
1286            "inside an interface body.\n",
1287            "  breaking    always — a retired identity is never reused (ridl 11,\n",
1288            "              RIDL-401)"
1289        ),
1290        Category::ServiceChanged => concat!(
1291            "A service switched between the named list and an inline shape.\n",
1292            "  breaking    always — extraction rewrites the transport identity of every\n",
1293            "              fallible query in the shape: an inline shape derives it from\n",
1294            "              the service's dotted name, a named interface from its own name\n",
1295            "              (ADR-0008 d4, ADR-0015 d15). A changed list is not this\n",
1296            "              category: it is read as a set by the service_interface_*\n",
1297            "              rows (ADR-0015 d19, as amended 2026-09-15)"
1298        ),
1299        Category::ServiceInterfaceAdded => concat!(
1300            "An interface joined a service's set of interfaces.\n",
1301            "  compatible  always — nothing that existed moved: an interface's number\n",
1302            "              comes from its package's interfaces.lock, not from its place\n",
1303            "              in the list, and the routing key does not contain the service\n",
1304            "              (ADR-0015 d19, as amended 2026-09-15)"
1305        ),
1306        Category::ServiceInterfaceRemoved => concat!(
1307            "An interface left a service's set of interfaces.\n",
1308            "  compatible  always — on the wire: the routing key does not contain the\n",
1309            "              service, so no identity moves, and a split into two services\n",
1310            "              is a removal plus an addition. Visible in source: the\n",
1311            "              service.member addresses of that interface stop resolving\n",
1312            "              under this service, which is why the text report lists it\n",
1313            "              under the heading \"compatible on the wire, visible in\n",
1314            "              source\". A consumer that loses its only provider is a\n",
1315            "              wiring error for rsdl, not a package diff (ADR-0015 d19, as\n",
1316            "              amended 2026-09-15)"
1317        ),
1318        Category::DocOnly => concat!(
1319            "Only doc comment, labels, or deprecation metadata changed.\n",
1320            "  compatible  always — none of it reaches a consumer's build.\n",
1321            "              Visibility is NOT in this category: see\n",
1322            "              visibility_changed"
1323        ),
1324        Category::VisibilityChanged => concat!(
1325            "The visibility a declaration is published at changed.\n",
1326            "  compatible  internal -> public: the declaration is offered to more\n",
1327            "              consumers than before\n",
1328            "  breaking    public -> internal: `internal` maps to the target's\n",
1329            "              package-private mechanism — Rust `pub(crate)`, a\n",
1330            "              non-exported TypeScript member (ADR-0002 8) — so the\n",
1331            "              declaration disappears from every out-of-package\n",
1332            "              consumer's build. The wire layout does not move, but the\n",
1333            "              consumer-visible guarantee narrows (ADR-0008 d14). Any\n",
1334            "              direction involving an unset visibility is breaking"
1335        ),
1336    }
1337}