1use ridl_ir::v2;
26
27use crate::{Category, Change, Verdict, frozen};
28
29#[cfg(test)]
30mod classify_tests;
31
32#[deny(
45 clippy::wildcard_enum_match_arm,
46 clippy::match_wildcard_for_single_variants
47)]
48pub fn classify(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
49 match change.category {
50 Category::InteractionInserted
53 | Category::InteractionReordered
54 | Category::MemberReordered
55 | Category::InteractionRemoved
56 | Category::ReservedNameRedeclared
57 | Category::KindChanged
58 | Category::PayloadChanged
59 | Category::ReturnChanged
60 | Category::ParamsChanged
61 | Category::WidthChanged
62 | Category::ServiceChanged
63 | Category::DeclRemoved => Verdict::Breaking,
64
65 Category::InitChanged => Verdict::Breaking,
69
70 Category::DocOnly | Category::InteractionRetired => Verdict::Compatible,
77
78 Category::InterfaceRenamed | Category::InterfaceRetired => Verdict::Compatible,
85
86 Category::ServiceInterfaceAdded | Category::ServiceInterfaceRemoved => Verdict::Compatible,
94
95 Category::VisibilityChanged => visibility(change, old, new),
96 Category::InteractionAppended => appended(change, old, new),
97 Category::DeclAdded => added(change, old, new),
98 Category::ConstraintChanged => constraint(change, old, new),
99 Category::TimingChanged => timing(change, old, new),
100 Category::RpcBoundChanged => rpc_bound(change, old, new),
101 Category::ContractChanged => contract(change, old, new),
102 }
103}
104
105fn visibility(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
123 let (Some(old_visibility), Some(new_visibility)) = (
124 find_visibility(old, &change.path, |name| find_old_shape(old, new, name)),
125 find_visibility(new, &change.path, |name| find_shape(new, name)),
126 ) else {
127 return Verdict::Breaking;
128 };
129
130 match (
131 v2::Visibility::try_from(old_visibility),
132 v2::Visibility::try_from(new_visibility),
133 ) {
134 (Ok(v2::Visibility::Internal), Ok(v2::Visibility::Public)) => Verdict::Compatible,
135 _ => Verdict::Breaking,
136 }
137}
138
139fn find_visibility<'a>(
146 package: &'a v2::Package,
147 path: &str,
148 shape_of: impl Fn(&str) -> Option<v2::InterfaceShape<'a>>,
149) -> Option<i32> {
150 let mut segments = path.split('/').skip(1);
151 let name = segments.next()?;
152
153 if let Some(member) = segments.next() {
154 return shape_of(name)?
155 .interface
156 .interactions
157 .iter()
158 .find(|decl| decl.name == member)
159 .map(|decl| decl.visibility);
160 }
161 if let Some(decl) = find_decl(package, name) {
162 return Some(decl.visibility);
163 }
164 if let Some(shape) = shape_of(name) {
169 return Some(shape.visibility());
170 }
171 package
174 .services
175 .iter()
176 .find(|service| service.name == name)
177 .map(|service| service.visibility)
178}
179
180fn appended(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
195 let Some((container, member)) = member_path(change) else {
196 return Verdict::Breaking;
197 };
198 let (Some(old_iface), Some(new_iface)) = (
199 find_old_interface(old, new, container),
200 find_interface(new, container),
201 ) else {
202 return Verdict::Breaking;
203 };
204 let Some(ordinal) = slot_ordinal(new_iface, member) else {
205 return Verdict::Breaking;
206 };
207
208 for (name, old_ordinal) in slots(old_iface) {
209 if old_ordinal == ordinal && name != member {
210 return Verdict::Breaking;
211 }
212 }
213 Verdict::Compatible
214}
215
216fn added(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
229 let Some((container, member)) = member_path(change) else {
230 return Verdict::Compatible;
233 };
234 let (Some(old_decl), Some(new_decl)) = (find_decl(old, container), find_decl(new, container))
235 else {
236 return Verdict::Breaking;
237 };
238
239 let _ = member;
243
244 use v2::decl::Kind;
245 let appended = match (&old_decl.kind, &new_decl.kind) {
246 (Some(Kind::StructDef(old_def)), Some(Kind::StructDef(new_def))) => appended_slot(
247 &struct_slots(old_def),
248 &struct_reserved(old_def),
249 &struct_slots(new_def),
250 ),
251 (Some(Kind::UnionDef(old_def)), Some(Kind::UnionDef(new_def))) => {
252 !old_def.is_result
255 && !new_def.is_result
256 && appended_slot(
257 &union_slots(old_def),
258 &union_reserved(old_def),
259 &union_slots(new_def),
260 )
261 }
262 (Some(Kind::EnumDef(old_def)), Some(Kind::EnumDef(new_def))) => appended_slot(
263 &value_slots(&old_def.values),
264 &reserved_values(&old_def.reserved),
265 &value_slots(&new_def.values),
266 ),
267 (Some(Kind::EnumSetDef(old_def)), Some(Kind::EnumSetDef(new_def))) => appended_slot(
268 &value_slots(&old_def.bits),
269 &[],
270 &value_slots(&new_def.bits),
271 ),
272 _ => false,
274 };
275
276 if appended {
277 Verdict::Compatible
278 } else {
279 Verdict::Breaking
280 }
281}
282
283fn appended_slot(old: &[(String, i64)], old_retired: &[i64], new: &[(String, i64)]) -> bool {
294 for (name, old_slot) in old {
295 match new.iter().find(|(new_name, _)| new_name == name) {
296 Some((_, new_slot)) if new_slot == old_slot => {}
297 _ => return false,
299 }
300 }
301
302 let high_water = old
303 .iter()
304 .map(|(_, slot)| *slot)
305 .chain(old_retired.iter().copied())
306 .max();
307 let old_names: Vec<&String> = old.iter().map(|(name, _)| name).collect();
308 for (name, slot) in new {
309 if old_names.contains(&name) {
310 continue;
311 }
312 match high_water {
313 Some(mark) if *slot <= mark => return false,
314 _ => {}
315 }
316 }
317 true
318}
319
320fn constraint(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
332 let mut segments = change.path.split('/').skip(1);
333 let (Some(name), None) = (segments.next(), segments.next()) else {
334 return Verdict::Breaking;
335 };
336 let (Some(old_decl), Some(new_decl)) = (find_decl(old, name), find_decl(new, name)) else {
337 return Verdict::Breaking;
338 };
339
340 use v2::decl::Kind;
341 let (Some(Kind::TypeDef(old_def)), Some(Kind::TypeDef(new_def))) =
342 (&old_decl.kind, &new_decl.kind)
343 else {
344 return Verdict::Breaking;
345 };
346
347 if narrows(old_def.constraint.as_ref(), new_def.constraint.as_ref()) {
348 Verdict::Breaking
349 } else {
350 Verdict::Compatible
351 }
352}
353
354fn narrows(old: Option<&v2::Constraint>, new: Option<&v2::Constraint>) -> bool {
360 let (Some(old), Some(new)) = (old, new) else {
361 return old.is_none() && new.is_some();
364 };
365
366 if raised(old.min.as_deref(), new.min.as_deref())
368 || lowered(old.max.as_deref(), new.max.as_deref())
369 {
370 return true;
371 }
372 if raised_u64(old.len_min, new.len_min) || lowered_u64(old.len_max, new.len_max) {
374 return true;
375 }
376 if new.step.is_some() && old.step != new.step {
379 return true;
380 }
381 if (new.pattern.is_some() && old.pattern != new.pattern)
384 || (new.pattern_const.is_some() && old.pattern_const != new.pattern_const)
385 {
386 return true;
387 }
388 false
389}
390
391fn raised(old: Option<&str>, new: Option<&str>) -> bool {
393 match (old, new) {
394 (None, Some(_)) => true,
395 (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_gt),
396 _ => false,
397 }
398}
399
400fn lowered(old: Option<&str>, new: Option<&str>) -> bool {
402 match (old, new) {
403 (None, Some(_)) => true,
404 (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_lt),
405 _ => false,
406 }
407}
408
409fn raised_u64(old: Option<u64>, new: Option<u64>) -> bool {
410 match (old, new) {
411 (None, Some(_)) => true,
412 (Some(old), Some(new)) => new > old,
413 _ => false,
414 }
415}
416
417fn lowered_u64(old: Option<u64>, new: Option<u64>) -> bool {
418 match (old, new) {
419 (None, Some(_)) => true,
420 (Some(old), Some(new)) => new < old,
421 _ => false,
422 }
423}
424
425fn cmp_decimal(left: &str, right: &str) -> Option<std::cmp::Ordering> {
435 use std::cmp::Ordering;
436
437 let (left_negative, left_digits) = split_sign(left)?;
438 let (right_negative, right_digits) = split_sign(right)?;
439 if left_negative != right_negative {
440 return Some(if left_negative {
443 Ordering::Less
444 } else {
445 Ordering::Greater
446 });
447 }
448
449 let magnitude = cmp_magnitude(&left_digits, &right_digits)?;
450 Some(if left_negative {
451 magnitude.reverse()
452 } else {
453 magnitude
454 })
455}
456
457fn split_sign(text: &str) -> Option<(bool, String)> {
460 let (negative, rest) = match text.strip_prefix('-') {
461 Some(rest) => (true, rest),
462 None => (false, text.strip_prefix('+').unwrap_or(text)),
463 };
464 if rest.is_empty() || !rest.chars().all(|c| c.is_ascii_digit() || c == '.') {
465 return None;
466 }
467 if rest.matches('.').count() > 1 {
468 return None;
469 }
470 Some((negative, rest.to_string()))
471}
472
473fn cmp_magnitude(left: &str, right: &str) -> Option<std::cmp::Ordering> {
476 use std::cmp::Ordering;
477
478 let (left_int, left_frac) = left.split_once('.').unwrap_or((left, ""));
479 let (right_int, right_frac) = right.split_once('.').unwrap_or((right, ""));
480
481 let left_int = left_int.trim_start_matches('0');
482 let right_int = right_int.trim_start_matches('0');
483 let by_length = left_int.len().cmp(&right_int.len());
484 if by_length != Ordering::Equal {
485 return Some(by_length);
486 }
487 let by_int = left_int.cmp(right_int);
488 if by_int != Ordering::Equal {
489 return Some(by_int);
490 }
491
492 let width = left_frac.len().max(right_frac.len());
494 let pad = |frac: &str| format!("{frac:0<width$}");
495 Some(pad(left_frac).cmp(&pad(right_frac)))
496}
497
498fn timing(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
510 let Some((container, member)) = member_path(change) else {
511 return Verdict::Breaking;
512 };
513 let (Some(old_decl), Some(new_decl)) = (
514 find_old_interaction(old, new, container, member),
515 find_interaction(new, container, member),
516 ) else {
517 return Verdict::Breaking;
518 };
519 let (Some(old_timing), Some(new_timing)) =
520 (interaction_timing(old_decl), interaction_timing(new_decl))
521 else {
522 return Verdict::Breaking;
532 };
533
534 let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
535 return Verdict::Breaking;
538 };
539
540 if old_timing.mode != new_timing.mode {
541 return Verdict::Breaking;
542 }
543 if lowered(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
545 || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
546 || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
547 || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
548 {
549 return Verdict::Breaking;
550 }
551 Verdict::Compatible
554}
555
556fn rpc_bound(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
575 let Some((container, member)) = member_path(change) else {
576 return Verdict::Breaking;
577 };
578 let (Some(old_decl), Some(new_decl)) = (
579 find_old_interaction(old, new, container, member),
580 find_interaction(new, container, member),
581 ) else {
582 return Verdict::Breaking;
583 };
584 let (Some(old_timing), Some(new_timing)) = (rpc_timing(old_decl), rpc_timing(new_decl)) else {
585 return Verdict::Breaking;
592 };
593
594 let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
595 return Verdict::Breaking;
598 };
599
600 if old_timing.mode != new_timing.mode {
603 return Verdict::Breaking;
604 }
605 if old_timing.default_applied != new_timing.default_applied {
612 return Verdict::Breaking;
613 }
614 if raised(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
619 || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
620 || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
621 || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
622 {
623 return Verdict::Breaking;
624 }
625 Verdict::Compatible
628}
629
630fn dropped(old: Option<&str>, new: Option<&str>) -> bool {
632 old.is_some() && new.is_none()
633}
634
635fn interaction_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
638 use v2::decl::Kind;
639 match &decl.kind {
640 Some(Kind::SignalDef(def)) => Some(def.timing.as_ref()),
641 Some(Kind::EventDef(def)) => Some(def.timing.as_ref()),
642 _ => None,
643 }
644}
645
646fn rpc_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
650 use v2::decl::Kind;
651 match &decl.kind {
652 Some(Kind::CommandDef(def)) => Some(def.timing.as_ref()),
653 Some(Kind::QueryDef(def)) => Some(def.timing.as_ref()),
654 _ => None,
655 }
656}
657
658fn contract(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
672 let Some((container, member)) = member_path(change) else {
673 return Verdict::Breaking;
674 };
675 let (Some(old_decl), Some(new_decl)) = (
676 find_old_interaction(old, new, container, member),
677 find_interaction(new, container, member),
678 ) else {
679 return Verdict::Breaking;
680 };
681 let (Some(old_clauses), Some(new_clauses)) = (contracts(old_decl), contracts(new_decl)) else {
682 return Verdict::Breaking;
683 };
684
685 let kind = |want: v2::ContractKind| {
686 move |clause: &&v2::Contract| v2::ContractKind::try_from(clause.kind) == Ok(want)
687 };
688 let sources = |clauses: &[v2::Contract], want: v2::ContractKind| -> Vec<String> {
689 let mut out: Vec<String> = clauses
690 .iter()
691 .filter(kind(want))
692 .map(|clause| clause.source.clone())
693 .collect();
694 out.sort();
695 out
696 };
697
698 let old_require = sources(old_clauses, v2::ContractKind::Require);
699 let new_require = sources(new_clauses, v2::ContractKind::Require);
700 let old_ensure = sources(old_clauses, v2::ContractKind::Ensure);
701 let new_ensure = sources(new_clauses, v2::ContractKind::Ensure);
702
703 if !covers(&old_require, &new_require) || !covers(&new_ensure, &old_ensure) {
704 return Verdict::Breaking;
705 }
706 Verdict::Compatible
707}
708
709fn covers(superset: &[String], subset: &[String]) -> bool {
711 let mut remaining: Vec<&String> = superset.iter().collect();
712 for clause in subset {
713 match remaining.iter().position(|held| *held == clause) {
714 Some(index) => {
715 remaining.swap_remove(index);
716 }
717 None => return false,
718 }
719 }
720 true
721}
722
723fn contracts(decl: &v2::Decl) -> Option<&[v2::Contract]> {
725 use v2::decl::Kind;
726 match &decl.kind {
727 Some(Kind::CommandDef(def)) => Some(&def.contracts),
728 Some(Kind::QueryDef(def)) => Some(&def.contracts),
729 _ => None,
730 }
731}
732
733fn member_path(change: &Change) -> Option<(&str, &str)> {
740 let mut segments = change.path.split('/').skip(1);
741 let container = segments.next()?;
742 let member = segments.next()?;
743 if segments.next().is_some() {
744 return None;
745 }
746 Some((container, member))
747}
748
749fn find_decl<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Decl> {
750 package.decls.iter().find(|decl| decl.name == name)
751}
752
753fn find_shape<'a>(package: &'a v2::Package, name: &str) -> Option<v2::InterfaceShape<'a>> {
758 package.shapes().find(|shape| shape.name == name)
759}
760
761fn find_old_shape<'a>(
768 old: &'a v2::Package,
769 new: &v2::Package,
770 name: &str,
771) -> Option<v2::InterfaceShape<'a>> {
772 let by_number = find_shape(new, name)
773 .filter(|shape| frozen(shape.interface))
774 .and_then(|shape| {
775 old.shapes().find(|candidate| {
776 frozen(candidate.interface) && candidate.interface.number == shape.interface.number
777 })
778 });
779 by_number.or_else(|| find_shape(old, name))
780}
781
782fn find_interface<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Interface> {
783 find_shape(package, name).map(|shape| shape.interface)
784}
785
786fn find_old_interface<'a>(
787 old: &'a v2::Package,
788 new: &v2::Package,
789 name: &str,
790) -> Option<&'a v2::Interface> {
791 find_old_shape(old, new, name).map(|shape| shape.interface)
792}
793
794fn find_interaction<'a>(
795 package: &'a v2::Package,
796 container: &str,
797 member: &str,
798) -> Option<&'a v2::Decl> {
799 find_interface(package, container)?
800 .interactions
801 .iter()
802 .find(|decl| decl.name == member)
803}
804
805fn find_old_interaction<'a>(
808 old: &'a v2::Package,
809 new: &v2::Package,
810 container: &str,
811 member: &str,
812) -> Option<&'a v2::Decl> {
813 find_old_interface(old, new, container)?
814 .interactions
815 .iter()
816 .find(|decl| decl.name == member)
817}
818
819fn slots(interface: &v2::Interface) -> Vec<(&str, u32)> {
831 interface
832 .interactions
833 .iter()
834 .filter_map(|decl| match &decl.kind {
835 Some(v2::decl::Kind::ReservedSlot(reserved)) => {
836 Some((reserved.name.as_deref().unwrap_or(""), decl.ordinal))
837 }
838 Some(_) => Some((decl.name.as_str(), decl.ordinal)),
839 None => None,
840 })
841 .collect()
842}
843
844fn slot_ordinal(interface: &v2::Interface, member: &str) -> Option<u32> {
845 slots(interface)
846 .into_iter()
847 .find(|(name, _)| *name == member)
848 .map(|(_, ordinal)| ordinal)
849}
850
851pub(crate) fn struct_slots(def: &v2::StructDef) -> Vec<(String, i64)> {
855 def.members
856 .iter()
857 .filter_map(|member| match &member.member {
858 Some(v2::struct_member::Member::Field(field)) => {
859 Some((field.name.clone(), i64::from(field.ordinal)))
860 }
861 _ => None,
862 })
863 .collect()
864}
865
866fn struct_reserved(def: &v2::StructDef) -> Vec<i64> {
867 def.members
868 .iter()
869 .filter_map(|member| match &member.member {
870 Some(v2::struct_member::Member::Reserved(reserved)) => {
871 Some(i64::from(reserved.ordinal))
872 }
873 _ => None,
874 })
875 .collect()
876}
877
878pub(crate) fn union_slots(def: &v2::UnionDef) -> Vec<(String, i64)> {
880 def.arms
881 .iter()
882 .map(|arm| (arm.name.clone(), i64::from(arm.ordinal)))
883 .collect()
884}
885
886fn union_reserved(def: &v2::UnionDef) -> Vec<i64> {
887 def.reserved
888 .iter()
889 .map(|reserved| i64::from(reserved.ordinal))
890 .collect()
891}
892
893fn value_slots(values: &[v2::EnumValue]) -> Vec<(String, i64)> {
896 values
897 .iter()
898 .map(|value| (value.name.clone(), value.value))
899 .collect()
900}
901
902fn reserved_values(reserved: &[v2::Reserved]) -> Vec<i64> {
903 reserved.iter().filter_map(|entry| entry.value).collect()
904}
905
906pub fn category_from_word(word: &str) -> Option<Category> {
913 crate::CATEGORIES
914 .into_iter()
915 .find(|category| crate::category_word(*category) == word)
916}
917
918#[deny(
928 clippy::wildcard_enum_match_arm,
929 clippy::match_wildcard_for_single_variants
930)]
931pub fn explain(category: Category) -> &'static str {
932 match category {
933 Category::DeclAdded => concat!(
934 "A declaration, interface, or service present only in the new snapshot.\n",
935 " compatible a new package-level decl, interface, or service; an enum\n",
936 " value appended above every live and retired number; a\n",
937 " struct field or union arm appended at the end of the body\n",
938 " (typl 7.4, append-only)\n",
939 " breaking a member inserted below the highest slot ever used, a member\n",
940 " taking a retired number, any addition that moves a surviving\n",
941 " member's slot, or any arm of a result union (ADR-0008 d4)\n",
942 " note an interface is matched by its interfaces.lock number (lock\n",
943 " design 7): a frozen number the old side never held is a new\n",
944 " interface, and so is every provisional one — a declaration\n",
945 " with no lock entry carries no identity, and a rename keeps its\n",
946 " number, so it is never a renamed interface (interface_renamed)"
947 ),
948 Category::DeclRemoved => concat!(
949 "A declaration, interface, or service present only in the old snapshot.\n",
950 " breaking a removed service, interface, decl, enum value, struct\n",
951 " field, or union arm withdraws something a consumer compiled\n",
952 " against\n",
953 " note an interface is matched by its interfaces.lock number (lock\n",
954 " design 7), so an interface here is one whose number is gone\n",
955 " from the new side and not retired there — a lock line deleted\n",
956 " by hand, or a number changed by hand, which is this row plus\n",
957 " decl_added. A number the new side's lock retires is\n",
958 " interface_retired instead, and `ridl baseline` refuses to\n",
959 " publish the removal of a number it does not find retired\n",
960 " (RIDL-412)\n",
961 " caveat a composite member retired the sanctioned way — replaced by\n",
962 " a `reserved` tombstone in its own slot (typl 7.4) — is also\n",
963 " reported breaking today. The body comparison is keyed on\n",
964 " member names and does not read the `reserved` list, so it\n",
965 " cannot yet tell that retirement from a bare deletion. This\n",
966 " errs on the safe side; carried as debt, see the note on\n",
967 " `diff_composite`. The interaction-level tombstone IS\n",
968 " recognised — see interaction_retired"
969 ),
970 Category::InterfaceRenamed => concat!(
971 "An interface whose interfaces.lock number is the same on both sides and\n",
972 "whose name changed.\n",
973 " compatible always — the number is the interface's identity and its\n",
974 " routing key (lock design 7, rsdl decision D-7), so nothing\n",
975 " moves on the wire. Visible in source: a rename changes the\n",
976 " generated identity-table names in both wire backends, which\n",
977 " is why the text report lists it under the heading\n",
978 " \"compatible on the wire, visible in source\". The path\n",
979 " carries the new name and the detail old -> new; changes\n",
980 " inside the interface are reported under the new name and\n",
981 " classified as any other interface's\n",
982 " note a provisional number is no identity: a declaration with no\n",
983 " lock entry is never matched, so a rename the lock does not\n",
984 " record is decl_removed plus decl_added, breaking, until\n",
985 " `ridl lock <pkg> --rename Old=New` records it"
986 ),
987 Category::InterfaceRetired => concat!(
988 "An interface whose number the old snapshot held is gone from the new\n",
989 "snapshot, whose interfaces.lock retires it.\n",
990 " compatible always — the sanctioned removal of an interface: the entry\n",
991 " keeps its number forever with the word `retired`, so the\n",
992 " number is never allocated again (lock design 4 and 7, rsdl\n",
993 " decision D-7). A number gone with no retired entry is\n",
994 " decl_removed, breaking, and `ridl baseline` refuses to publish\n",
995 " it (RIDL-412)"
996 ),
997 Category::MemberReordered => concat!(
998 "A surviving composite member whose slot in the body changed.\n",
999 " breaking always — a struct field or union arm takes its wire\n",
1000 " identity from its ordinal, its 1-based place in the body\n",
1001 " counting tombstones (typl 7.4), so a member whose ordinal\n",
1002 " changed has a new wire identity; the detail carries the old\n",
1003 " and new ordinal. An enum value or enum-set bit carries an\n",
1004 " explicit number instead (typl 8, 9), but the walk compares\n",
1005 " positions, not those numbers, so a textual reorder of an\n",
1006 " enum or enum-set body is reported breaking as well,\n",
1007 " conservatively, even when no number changed; the detail\n",
1008 " carries the old and new position\n",
1009 " note reported only when both bodies hold the same member names:\n",
1010 " a reorder in the same edit as an addition or a removal is\n",
1011 " reported through decl_added or decl_removed alone. A reorder\n",
1012 " in the same edit as an in-place change to the body is\n",
1013 " reported with constraint_changed on the container as well"
1014 ),
1015 Category::InteractionAppended => concat!(
1016 "An interaction added after every slot that existed before.\n",
1017 " compatible the slot it takes was never occupied\n",
1018 " breaking the slot was freed by an untombstoned removal and is now\n",
1019 " reused by a new name — a reused wire identity (ADR-0008 d14)"
1020 ),
1021 Category::InteractionInserted => concat!(
1022 "An interaction added before the end of the interface body.\n",
1023 " breaking always — every later ordinal shifts, and the ordinal is the\n",
1024 " transport identity (ridl 11)"
1025 ),
1026 Category::InteractionReordered => concat!(
1027 "A surviving interaction whose relative order in the body changed.\n",
1028 " breaking always — a reorder shifts wire identities (ridl 11)"
1029 ),
1030 Category::InteractionRemoved => concat!(
1031 "An interaction removed without a `reserved` tombstone holding its slot.\n",
1032 " breaking always — the freed ordinal is reusable, so the wire identity\n",
1033 " is no longer permanent (ridl 11)"
1034 ),
1035 Category::InteractionRetired => concat!(
1036 "An interaction retired to a `reserved` tombstone in its own ordinal slot.\n",
1037 " compatible always — the sanctioned retirement: the slot stays occupied\n",
1038 " and every later ordinal holds (ridl 11)"
1039 ),
1040 Category::KindChanged => concat!(
1041 "An interaction whose kind changed (signal, event, command, query, fixed).\n",
1042 " breaking any direction — the kind selects the transport shape"
1043 ),
1044 Category::PayloadChanged => concat!(
1045 "A signal, event, or fixed payload type changed.\n",
1046 " breaking any direction, a stream added or removed included"
1047 ),
1048 Category::ReturnChanged => concat!(
1049 "A query return shape changed.\n",
1050 " breaking any direction — an ok-arm change, an error arm added, removed,\n",
1051 " or retyped, a stream added or removed, or any other change to\n",
1052 " the synthesized inline `T | E` transport identity (ADR-0008 d4:\n",
1053 " interface + interaction ordinal + ordered arm types)"
1054 ),
1055 Category::ParamsChanged => concat!(
1056 "A command or query parameter list changed.\n",
1057 " breaking any direction — a parameter added, removed, renamed, retyped,\n",
1058 " or a stream added or removed on one"
1059 ),
1060 Category::TimingChanged => concat!(
1061 "A signal or event resolved timing changed (ADR-0008 d12).\n",
1062 " compatible min raised (a higher rate floor) or max lowered (a tighter\n",
1063 " staleness bound) with the mode unchanged; default_applied\n",
1064 " flipped over identical resolved bounds — a default made\n",
1065 " explicit\n",
1066 " breaking min lowered, max raised, a bound added where none was, a bound\n",
1067 " removed, or the strict-periodic/range mode flipped\n",
1068 " note editing `[defaults].timing` needs no special rule: diff\n",
1069 " compares resolved bounds, so it surfaces here on every\n",
1070 " defaulted interaction (ridl 9.1)"
1071 ),
1072 Category::RpcBoundChanged => concat!(
1073 "A command or query declared RPC bound changed (ADR-0015 d8).\n",
1074 " compatible min lowered (the caller may call more often) or max lowered\n",
1075 " (a stronger provider promise), with the mode unchanged\n",
1076 " breaking min raised — on an RPC, min is the call throttle and\n",
1077 " constrains the caller, so raising it withdraws a call rate\n",
1078 " the caller was entitled to use; max raised (a weaker provider\n",
1079 " promise); a bound added or removed, the whole annotation\n",
1080 " included\n",
1081 " note the min direction is the inverse of timing_changed's, which\n",
1082 " is why this is a category of its own rather than a branch:\n",
1083 " a missed branch would inherit the signal rule and call a\n",
1084 " raised RPC min compatible (ADR-0012 d9, fail closed).\n",
1085 " RPC bounds are never defaulted (ridl 9.1 does not apply)"
1086 ),
1087 Category::ContractChanged => concat!(
1088 "A command or query require/ensure clause set changed (ridl 13).\n",
1089 " compatible a require removed, or an ensure added\n",
1090 " breaking a require added or its text changed, or an ensure removed or\n",
1091 " its text changed. Clause text is compared verbatim: the\n",
1092 " classifier does not prove that one clause implies another\n",
1093 " (ADR-0008 d14)"
1094 ),
1095 Category::WidthChanged => concat!(
1096 "A derived wire width or scalar backing changed.\n",
1097 " breaking any IntWidth or FloatWidth change, uint64 versus int64\n",
1098 " included — the resolved width is part of the contract\n",
1099 " (typl 4.2, 5.6)"
1100 ),
1101 Category::ConstraintChanged => concat!(
1102 "A scalar constraint changed, or a composite body changed in place.\n",
1103 " compatible widened — min lowered, max raised, a length bound loosened,\n",
1104 " a step removed, a match pattern removed (by literal or by\n",
1105 " named constant), or the whole constraint dropped so the\n",
1106 " value is unbounded again\n",
1107 " breaking narrowed — min raised, max lowered, a length bound\n",
1108 " tightened, a step added or changed (divisibility is never\n",
1109 " proved), a match pattern added or rewritten (by literal or\n",
1110 " by named constant), or a constraint appearing where there\n",
1111 " was none, which bounds a previously unbounded value. A\n",
1112 " composite body changed in place is breaking: the walk\n",
1113 " does not say which member changed inside it\n",
1114 " note each facet is judged on its own and any one narrowing\n",
1115 " decides the change, so a mixed edit is breaking on the half\n",
1116 " that narrows. A widening that flips the resolved wire width\n",
1117 " is separately reported as width_changed, which is always\n",
1118 " breaking (typl 5.6)"
1119 ),
1120 Category::InitChanged => concat!(
1121 "A declared or resolved init value changed.\n",
1122 " breaking any direction — the init is the value a consumer reads before\n",
1123 " the first publish, so it is part of the contract (ridl 9.1)"
1124 ),
1125 Category::ReservedNameRedeclared => concat!(
1126 "A name retired by a `reserved` tombstone is live again — an interaction\n",
1127 "inside an interface body.\n",
1128 " breaking always — a retired identity is never reused (ridl 11,\n",
1129 " RIDL-401)"
1130 ),
1131 Category::ServiceChanged => concat!(
1132 "A service switched between the named list and an inline shape.\n",
1133 " breaking always — extraction rewrites the transport identity of every\n",
1134 " fallible query in the shape: an inline shape derives it from\n",
1135 " the service's dotted name, a named interface from its own name\n",
1136 " (ADR-0008 d4, ADR-0015 d15). A changed list is not this\n",
1137 " category: it is read as a set by the service_interface_*\n",
1138 " rows (ADR-0015 d19, as amended 2026-09-15)"
1139 ),
1140 Category::ServiceInterfaceAdded => concat!(
1141 "An interface joined a service's set of interfaces.\n",
1142 " compatible always — nothing that existed moved: an interface's number\n",
1143 " comes from its package's interfaces.lock, not from its place\n",
1144 " in the list, and the routing key does not contain the service\n",
1145 " (ADR-0015 d19, as amended 2026-09-15)"
1146 ),
1147 Category::ServiceInterfaceRemoved => concat!(
1148 "An interface left a service's set of interfaces.\n",
1149 " compatible always — on the wire: the routing key does not contain the\n",
1150 " service, so no identity moves, and a split into two services\n",
1151 " is a removal plus an addition. Visible in source: the\n",
1152 " service.member addresses of that interface stop resolving\n",
1153 " under this service, which is why the text report lists it\n",
1154 " under the heading \"compatible on the wire, visible in\n",
1155 " source\". A consumer that loses its only provider is a\n",
1156 " wiring error for rsdl, not a package diff (ADR-0015 d19, as\n",
1157 " amended 2026-09-15)"
1158 ),
1159 Category::DocOnly => concat!(
1160 "Only doc comment, labels, or deprecation metadata changed.\n",
1161 " compatible always — none of it reaches a consumer's build.\n",
1162 " Visibility is NOT in this category: see\n",
1163 " visibility_changed"
1164 ),
1165 Category::VisibilityChanged => concat!(
1166 "The visibility a declaration is published at changed.\n",
1167 " compatible internal -> public: the declaration is offered to more\n",
1168 " consumers than before\n",
1169 " breaking public -> internal: `internal` maps to the target's\n",
1170 " package-private mechanism — Rust `pub(crate)`, a\n",
1171 " non-exported TypeScript member (ADR-0002 8) — so the\n",
1172 " declaration disappears from every out-of-package\n",
1173 " consumer's build. The wire layout does not move, but the\n",
1174 " consumer-visible guarantee narrows (ADR-0008 d14). Any\n",
1175 " direction involving an unset visibility is breaking"
1176 ),
1177 }
1178}