ridl-diff 0.3.0

The `ridl diff` engine: compares two resolved IR snapshots and classifies every difference.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
//! The breaking/compatible classifier (docs/ROADMAP.md epic E2.8b).
//!
//! [`classify`] turns one structural [`Change`] from the walk into a directional
//! [`Verdict`]. Direction is judged from the consumer's side (ADR-0008 decision
//! 14): a change is breaking when it shifts or reuses a wire identity or narrows
//! a consumer-visible guarantee, and compatible when it only relaxes or appends.
//!
//! The classifier reads the resolved IR of both sides, never source, so every
//! bound it compares is the bound a backend sees. That is what makes the
//! `[defaults].timing` rule need no special case: the manifest default is
//! already resolved into every untimed interaction (ADR-0008 decision 12), so
//! editing it arrives here as an ordinary [`Category::TimingChanged`] on each
//! defaulted interaction and classifies by the bound rules below (ridl §9.1).
//!
//! Two properties are deliberate:
//!
//! - **No implication proving.** Contract clauses are carried as canonical
//!   source text (ADR-0008 decision 14), so the classifier compares text. Any
//!   `require` text change is breaking, and any `ensure` text change is
//!   breaking; it never tries to show that one clause implies another.
//! - **Unlisted is breaking.** A shape the table does not name is classified
//!   breaking. A false "breaking" costs a maintainer one review; a false
//!   "compatible" ships a wire break.

use ridl_ir::v2;

use crate::{Category, Change, Verdict, frozen};

#[cfg(test)]
mod classify_tests;

/// Classifies one change against the two snapshots it was drawn from.
///
/// `old` and `new` are the matched packages the change belongs to; the change's
/// path is resolved against them to recover the typed IR the direction is read
/// from. For a package present on only one side the caller passes that package
/// as both arguments — those changes classify on their category alone.
// A new variant must be given a real arm here, not swept into a
// catch-all: rustc forces *an* arm, and the arm its `help:` text
// proposes is `_ =>`, which classifies the new variant silently. The
// two lints below reject a wildcard over `Category` — the first when
// it covers several variants, the second when it covers exactly one,
// which is the case one added variant creates.
#[deny(
    clippy::wildcard_enum_match_arm,
    clippy::match_wildcard_for_single_variants
)]
pub fn classify(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
    match change.category {
        // Shifts or reuses a wire identity, or replaces a wire-carrying type.
        // Every one of these is breaking in either direction.
        Category::InteractionInserted
        | Category::InteractionReordered
        | Category::MemberReordered
        | Category::InteractionRemoved
        | Category::ReservedNameRedeclared
        | Category::KindChanged
        | Category::PayloadChanged
        | Category::ReturnChanged
        | Category::ParamsChanged
        | Category::WidthChanged
        | Category::ServiceChanged
        | Category::DeclRemoved => Verdict::Breaking,

        // An init is part of the published contract (ridl §9.1): consumers read
        // the pre-publish value. Neither reference states a compatible
        // direction, so the change is breaking.
        Category::InitChanged => Verdict::Breaking,

        // Doc comments, labels, and deprecation notes reach no consumer's build.
        // Visibility is not among them — it has its own category below.
        //
        // A tombstone in the retired interaction's own slot is the sanctioned
        // retirement (ridl §11); the walk only emits `InteractionRetired` when
        // the slot is preserved.
        Category::DocOnly | Category::InteractionRetired => Verdict::Compatible,

        // An interface's identity is its `interfaces.lock` number (lock
        // design §7). A rename that keeps the number moves nothing on the
        // wire — the routing key is the number — and is visible in source,
        // which the text report's heading says of it. A number the new side's
        // lock retires is the sanctioned removal: the entry keeps the number
        // forever, so it is never allocated again.
        Category::InterfaceRenamed | Category::InterfaceRetired => Verdict::Compatible,

        // A service's list is a set of interface references (ADR-0015
        // decision 19 as amended on 2026-09-15). The routing key does not
        // contain the service, so an interface joining or leaving the set
        // moves no wire identity: both directions are compatible. A removal
        // is still visible in source — the `service.member` addresses of that
        // interface stop resolving under the service — which is what the text
        // report's heading says of it.
        Category::ServiceInterfaceAdded | Category::ServiceInterfaceRemoved => Verdict::Compatible,

        Category::VisibilityChanged => visibility(change, old, new),
        Category::InteractionAppended => appended(change, old, new),
        Category::DeclAdded => added(change, old, new),
        Category::ConstraintChanged => constraint(change, old, new),
        Category::TimingChanged => timing(change, old, new),
        Category::RpcBoundChanged => rpc_bound(change, old, new),
        Category::ContractChanged => contract(change, old, new),
    }
}

// ==========================================================================
// Visibility.
// ==========================================================================

/// A change to the visibility a declaration is published at.
///
/// Visibility rides the surface grammar next to doc comments, but it is not
/// metadata to a consumer. `internal` maps to the target's package-private
/// mechanism — Rust `pub(crate)`, a non-exported TypeScript member (ADR-0002
/// §8) — so narrowing `public` to `internal` deletes the declaration from every
/// out-of-package consumer's build. That is the plainest form of "narrows a
/// consumer-visible guarantee" in ADR-0008 decision 14, even though the byte
/// layout on the wire never moves.
///
/// Widening `internal` to `public` only offers more, so it is compatible. Any
/// direction involving an unset visibility is breaking, following the module's
/// unlisted-is-breaking rule.
fn visibility(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
    let (Some(old_visibility), Some(new_visibility)) = (
        find_visibility(old, &change.path, |name| find_old_shape(old, new, name)),
        find_visibility(new, &change.path, |name| find_shape(new, name)),
    ) else {
        return Verdict::Breaking;
    };

    match (
        v2::Visibility::try_from(old_visibility),
        v2::Visibility::try_from(new_visibility),
    ) {
        (Ok(v2::Visibility::Internal), Ok(v2::Visibility::Public)) => Verdict::Compatible,
        _ => Verdict::Breaking,
    }
}

/// The visibility published at a change's path: an interaction inside an
/// interface or service shape, or a package-level declaration, interface, or
/// service. `shape_of` resolves the container name the path carries to this
/// side's shape — by name on the new side, and on the old side the way the
/// walk matched it ([`find_old_shape`]), since a renamed interface's path
/// carries only its new name.
fn find_visibility<'a>(
    package: &'a v2::Package,
    path: &str,
    shape_of: impl Fn(&str) -> Option<v2::InterfaceShape<'a>>,
) -> Option<i32> {
    let mut segments = path.split('/').skip(1);
    let name = segments.next()?;

    if let Some(member) = segments.next() {
        return shape_of(name)?
            .interface
            .interactions
            .iter()
            .find(|decl| decl.name == member)
            .map(|decl| decl.visibility);
    }
    if let Some(decl) = find_decl(package, name) {
        return Some(decl.visibility);
    }
    // `Package::shapes` answers for a named interface and for a service with an
    // inline shape, and `InterfaceShape::visibility` reads the authoritative
    // field in each case — the owning service's for an inline shape, whose own
    // `Interface.visibility` is `VISIBILITY_UNSPECIFIED` by construction.
    if let Some(shape) = shape_of(name) {
        return Some(shape.visibility());
    }
    // A service naming an interface after `:` carries no shape of its own, so
    // it is not in `shapes()`; its visibility is still the service's.
    package
        .services
        .iter()
        .find(|service| service.name == name)
        .map(|service| service.visibility)
}

// ==========================================================================
// Interaction append — the identity-reuse guard.
// ==========================================================================

/// An interaction (or a freshly minted tombstone) added after every slot that
/// existed before. Appending is compatible, but only when the slot it takes was
/// never occupied: an ordinal freed by an untombstoned removal and handed to a
/// new name **reuses a wire identity**, which ADR-0008 decision 14 lists first
/// among breaking changes.
///
/// The walk labels that case `InteractionAppended` because the new name does sit
/// after every surviving slot; the reuse is only visible by looking back at what
/// the old snapshot held at that ordinal, which is why the check lives here and
/// not in the walk.
fn appended(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
    let Some((container, member)) = member_path(change) else {
        return Verdict::Breaking;
    };
    let (Some(old_iface), Some(new_iface)) = (
        find_old_interface(old, new, container),
        find_interface(new, container),
    ) else {
        return Verdict::Breaking;
    };
    let Some(ordinal) = slot_ordinal(new_iface, member) else {
        return Verdict::Breaking;
    };

    for (name, old_ordinal) in slots(old_iface) {
        if old_ordinal == ordinal && name != member {
            return Verdict::Breaking;
        }
    }
    Verdict::Compatible
}

// ==========================================================================
// Additions — package level, and the append-only composite bodies.
// ==========================================================================

/// A declaration present only in the new snapshot.
///
/// A package-level addition — a new decl, interface, or service — is compatible:
/// nothing that existed moved. A composite member addition is compatible only
/// when it is a genuine append: typl §7.4 makes struct fields and union arms one
/// append-only rule ("new fields are added at the end of the struct or union"),
/// and an enum value appends by taking a number above every live and every
/// retired one.
fn added(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
    let Some((container, member)) = member_path(change) else {
        // One or two segments: a whole package, or a package-level decl,
        // interface, or service.
        return Verdict::Compatible;
    };
    let (Some(old_decl), Some(new_decl)) = (find_decl(old, container), find_decl(new, container))
    else {
        return Verdict::Breaking;
    };

    // The member name is not needed: the append test is a property of the whole
    // body, and reading the body is what catches a *surviving* member whose slot
    // moved — which the walk does not report alongside an addition.
    let _ = member;

    use v2::decl::Kind;
    let appended = match (&old_decl.kind, &new_decl.kind) {
        (Some(Kind::StructDef(old_def)), Some(Kind::StructDef(new_def))) => appended_slot(
            &struct_slots(old_def),
            &struct_reserved(old_def),
            &struct_slots(new_def),
        ),
        (Some(Kind::UnionDef(old_def)), Some(Kind::UnionDef(new_def))) => {
            // A result union's arms are its transport identity (ADR-0008
            // decision 4): any arm change flips it.
            !old_def.is_result
                && !new_def.is_result
                && appended_slot(
                    &union_slots(old_def),
                    &union_reserved(old_def),
                    &union_slots(new_def),
                )
        }
        (Some(Kind::EnumDef(old_def)), Some(Kind::EnumDef(new_def))) => appended_slot(
            &value_slots(&old_def.values),
            &reserved_values(&old_def.reserved),
            &value_slots(&new_def.values),
        ),
        (Some(Kind::EnumSetDef(old_def)), Some(Kind::EnumSetDef(new_def))) => appended_slot(
            &value_slots(&old_def.bits),
            &[],
            &value_slots(&new_def.bits),
        ),
        // A shape the table does not name classifies breaking.
        _ => false,
    };

    if appended {
        Verdict::Compatible
    } else {
        Verdict::Breaking
    }
}

/// Whether the new body is the old body with every pre-existing slot untouched
/// and every addition sitting above the highest slot ever used — live or
/// retired.
///
/// Both halves matter. A member whose slot number moved has had its wire
/// identity shifted even though its name survived, and the walk's composite
/// comparison does not report that alongside an addition. A member taking a
/// number at or below the old high-water mark is an insertion, or a reuse of a
/// retired number, which typl §7.4 forbids so a wire value never carries a new
/// meaning.
fn appended_slot(old: &[(String, i64)], old_retired: &[i64], new: &[(String, i64)]) -> bool {
    for (name, old_slot) in old {
        match new.iter().find(|(new_name, _)| new_name == name) {
            Some((_, new_slot)) if new_slot == old_slot => {}
            // A surviving member moved, or vanished alongside the addition.
            _ => return false,
        }
    }

    let high_water = old
        .iter()
        .map(|(_, slot)| *slot)
        .chain(old_retired.iter().copied())
        .max();
    let old_names: Vec<&String> = old.iter().map(|(name, _)| name).collect();
    for (name, slot) in new {
        if old_names.contains(&name) {
            continue;
        }
        match high_water {
            Some(mark) if *slot <= mark => return false,
            _ => {}
        }
    }
    true
}

// ==========================================================================
// Constraints — narrowed versus widened.
// ==========================================================================

/// A scalar constraint change on a named type. Widening keeps every value the
/// old contract admitted legal, so it is compatible; narrowing rejects values a
/// consumer may already be sending.
///
/// A composite body changed in place reaches here with no member path and no
/// rendered values — the walk does not say which member changed inside it — and
/// classifies breaking.
fn constraint(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
    let mut segments = change.path.split('/').skip(1);
    let (Some(name), None) = (segments.next(), segments.next()) else {
        return Verdict::Breaking;
    };
    let (Some(old_decl), Some(new_decl)) = (find_decl(old, name), find_decl(new, name)) else {
        return Verdict::Breaking;
    };

    use v2::decl::Kind;
    let (Some(Kind::TypeDef(old_def)), Some(Kind::TypeDef(new_def))) =
        (&old_decl.kind, &new_decl.kind)
    else {
        return Verdict::Breaking;
    };

    if narrows(old_def.constraint.as_ref(), new_def.constraint.as_ref()) {
        Verdict::Breaking
    } else {
        Verdict::Compatible
    }
}

/// Whether the constraint moved in the narrowing direction on any facet.
///
/// Each facet is judged independently and any narrowing decides the whole
/// change, so a mixed edit — a lowered `min` with a lowered `max` — is breaking
/// on the half that narrows.
fn narrows(old: Option<&v2::Constraint>, new: Option<&v2::Constraint>) -> bool {
    let (Some(old), Some(new)) = (old, new) else {
        // A constraint appearing where there was none bounds a previously
        // unbounded value; dropping one entirely only widens.
        return old.is_none() && new.is_some();
    };

    // A raised floor or a lowered ceiling rejects values that were legal.
    if raised(old.min.as_deref(), new.min.as_deref())
        || lowered(old.max.as_deref(), new.max.as_deref())
    {
        return true;
    }
    // Length bounds are the same rule over character and byte counts.
    if raised_u64(old.len_min, new.len_min) || lowered_u64(old.len_max, new.len_max) {
        return true;
    }
    // A step quantizes: added or changed at all it can exclude values that were
    // legal, and the classifier does not prove divisibility. Removed, it widens.
    if new.step.is_some() && old.step != new.step {
        return true;
    }
    // A pattern added or rewritten can reject strings that matched before.
    // Removed, it widens.
    if (new.pattern.is_some() && old.pattern != new.pattern)
        || (new.pattern_const.is_some() && old.pattern_const != new.pattern_const)
    {
        return true;
    }
    false
}

/// Whether a lower bound was added or moved up.
fn raised(old: Option<&str>, new: Option<&str>) -> bool {
    match (old, new) {
        (None, Some(_)) => true,
        (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_gt),
        _ => false,
    }
}

/// Whether an upper bound was added or moved down.
fn lowered(old: Option<&str>, new: Option<&str>) -> bool {
    match (old, new) {
        (None, Some(_)) => true,
        (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_lt),
        _ => false,
    }
}

fn raised_u64(old: Option<u64>, new: Option<u64>) -> bool {
    match (old, new) {
        (None, Some(_)) => true,
        (Some(old), Some(new)) => new > old,
        _ => false,
    }
}

fn lowered_u64(old: Option<u64>, new: Option<u64>) -> bool {
    match (old, new) {
        (None, Some(_)) => true,
        (Some(old), Some(new)) => new < old,
        _ => false,
    }
}

/// Orders two canonical decimal strings exactly, without going through a float
/// (the IR carries exact decimals for precisely this reason, ADR-0007 decision
/// 9). Returns `None` when either side is not a plain decimal, which callers
/// read as "cannot prove this relaxes".
///
/// The `None` case is not dead. `ridlc` only ever writes canonical decimals, but
/// [`load_ir_json`](crate::load_ir_json) deserializes a snapshot off disk and a
/// bound is a plain string there, so a hand-edited or foreign `.ir.json` can
/// carry an exponent form or any other spelling this function does not read.
fn cmp_decimal(left: &str, right: &str) -> Option<std::cmp::Ordering> {
    use std::cmp::Ordering;

    let (left_negative, left_digits) = split_sign(left)?;
    let (right_negative, right_digits) = split_sign(right)?;
    if left_negative != right_negative {
        // Zero is signless in canonical form, so a sign disagreement is a real
        // ordering: the negative side is the smaller one.
        return Some(if left_negative {
            Ordering::Less
        } else {
            Ordering::Greater
        });
    }

    let magnitude = cmp_magnitude(&left_digits, &right_digits)?;
    Some(if left_negative {
        magnitude.reverse()
    } else {
        magnitude
    })
}

/// Splits an optional leading sign from a decimal, rejecting anything that is
/// not sign-digits-optional-fraction.
fn split_sign(text: &str) -> Option<(bool, String)> {
    let (negative, rest) = match text.strip_prefix('-') {
        Some(rest) => (true, rest),
        None => (false, text.strip_prefix('+').unwrap_or(text)),
    };
    if rest.is_empty() || !rest.chars().all(|c| c.is_ascii_digit() || c == '.') {
        return None;
    }
    if rest.matches('.').count() > 1 {
        return None;
    }
    Some((negative, rest.to_string()))
}

/// Orders two unsigned decimal magnitudes by integer part then fraction, so
/// `9` < `10` and `1.5` < `1.50001`.
fn cmp_magnitude(left: &str, right: &str) -> Option<std::cmp::Ordering> {
    use std::cmp::Ordering;

    let (left_int, left_frac) = left.split_once('.').unwrap_or((left, ""));
    let (right_int, right_frac) = right.split_once('.').unwrap_or((right, ""));

    let left_int = left_int.trim_start_matches('0');
    let right_int = right_int.trim_start_matches('0');
    let by_length = left_int.len().cmp(&right_int.len());
    if by_length != Ordering::Equal {
        return Some(by_length);
    }
    let by_int = left_int.cmp(right_int);
    if by_int != Ordering::Equal {
        return Some(by_int);
    }

    // Compare fractions digit by digit, padding the shorter with zeros.
    let width = left_frac.len().max(right_frac.len());
    let pad = |frac: &str| format!("{frac:0<width$}");
    Some(pad(left_frac).cmp(&pad(right_frac)))
}

// ==========================================================================
// Timing.
// ==========================================================================

/// A resolved timing change on a signal or event (ADR-0008 decision 12).
///
/// `min` is the rate floor and `max` the staleness bound, so the consumer-facing
/// guarantee strengthens when `min` rises or `max` falls and weakens when either
/// moves the other way. A bound added or removed, or the strict-periodic/range
/// mode flipped, changes what a consumer may assume at all — rmdl clocks key on
/// strict — and is breaking in both directions.
fn timing(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
    let Some((container, member)) = member_path(change) else {
        return Verdict::Breaking;
    };
    let (Some(old_decl), Some(new_decl)) = (
        find_old_interaction(old, new, container, member),
        find_interaction(new, container, member),
    ) else {
        return Verdict::Breaking;
    };
    let (Some(old_timing), Some(new_timing)) =
        (interaction_timing(old_decl), interaction_timing(new_decl))
    else {
        // An interaction kind that carries no timing at all. The walk cannot
        // produce this: it emits `TimingChanged` only inside its signal and
        // event arms, so both sides are already a timed kind by the time a
        // change reaches here. It is still reachable, because [`classify`] is
        // public and takes any hand-built `Change` — so the arm stays, follows
        // the module's unlisted-is-breaking rule, and carries a test of its own.
        // It is deliberately not a `debug_assert!`: a caller passing a category
        // the walk would not have emitted is asking a question, not committing
        // a bug, and aborting a debug build over it would be wrong.
        return Verdict::Breaking;
    };

    let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
        // Timing appearing where there was none, or dropped, is a bound added
        // or removed.
        return Verdict::Breaking;
    };

    if old_timing.mode != new_timing.mode {
        return Verdict::Breaking;
    }
    // A floor lowered, a ceiling raised, or either bound added or removed.
    if lowered(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
        || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
        || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
        || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
    {
        return Verdict::Breaking;
    }
    // What remains is a floor raised, a ceiling lowered, or only
    // `default_applied` flipped over identical bounds — a default made explicit.
    Verdict::Compatible
}

/// A declared RPC-bound change on a command or query (ADR-0015 decision 8).
///
/// The two bounds keep their generic §9 meaning — `min` the rate floor, `max`
/// the staleness bound — but on an RPC `min` is the **call throttle** and
/// constrains the consumer, so its direction inverts against [`timing`]'s
/// convention: raising it withdraws a call rate the caller was entitled to
/// use (breaking), and lowering it leaves the caller less constrained
/// (compatible). `max` is the response bound, a provider promise, and keeps
/// the provider-side rule: raised is a weaker promise (breaking), lowered a
/// stronger one (compatible). A bound added or removed — the whole annotation
/// included — is breaking in both directions, exactly as in [`timing`]:
/// callers size timeouts against a declared bound, so its appearance and its
/// disappearance both change what a consumer may assume at all.
///
/// This is a distinct category rather than a kind-aware branch inside
/// [`timing`] so that a missed branch fails closed (ADR-0012 decision 9): the
/// deny lints above turn a missing arm into a compile error rather than a
/// silently inherited signal rule that calls a raised RPC `min` compatible.
fn rpc_bound(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
    let Some((container, member)) = member_path(change) else {
        return Verdict::Breaking;
    };
    let (Some(old_decl), Some(new_decl)) = (
        find_old_interaction(old, new, container, member),
        find_interaction(new, container, member),
    ) else {
        return Verdict::Breaking;
    };
    let (Some(old_timing), Some(new_timing)) = (rpc_timing(old_decl), rpc_timing(new_decl)) else {
        // An interaction kind that is not an RPC. The walk cannot produce
        // this — it emits `RpcBoundChanged` only inside its command and query
        // arms — but [`classify`] is public and takes any hand-built
        // [`Change`], so the arm stays and follows the module's
        // unlisted-is-breaking rule (see [`timing`] for why it is not a
        // `debug_assert!`).
        return Verdict::Breaking;
    };

    let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
        // The annotation appearing where there was none, or dropped, is a
        // bound added or removed — breaking in both directions.
        return Verdict::Breaking;
    };

    // The mode is always `Range` on an RPC (ADR-0015 decision 7); anything
    // else in a snapshot is erroneous IR, and a flip is breaking regardless.
    if old_timing.mode != new_timing.mode {
        return Verdict::Breaking;
    }
    // `default_applied` is always false on an RPC, because an RPC bound is
    // never defaulted (ADR-0015 decision 7); anything else in a snapshot is
    // erroneous IR, and a flip is breaking regardless — never the "default
    // made explicit" compatibility [`timing`] grants, which would report
    // compatible on IR the classifier does not understand (ADR-0012
    // decision 9).
    if old_timing.default_applied != new_timing.default_applied {
        return Verdict::Breaking;
    }
    // A throttle raised, a response bound raised, or either bound added or
    // removed. `raised` covers the added case (`None` → `Some`), `dropped` the
    // removed one. This is [`timing`]'s predicate with the `min` direction
    // inverted — `raised` where the signal rule reads `lowered`.
    if raised(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
        || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
        || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
        || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
    {
        return Verdict::Breaking;
    }
    // What remains is a throttle lowered or a response bound lowered — the
    // caller is less constrained, or the provider promises more.
    Verdict::Compatible
}

/// Whether a bound present before is absent now.
fn dropped(old: Option<&str>, new: Option<&str>) -> bool {
    old.is_some() && new.is_none()
}

/// The resolved timing of a signal or event; `None` for interaction kinds that
/// carry none, and `Some(None)` for a timed kind with the field unset.
fn interaction_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
    use v2::decl::Kind;
    match &decl.kind {
        Some(Kind::SignalDef(def)) => Some(def.timing.as_ref()),
        Some(Kind::EventDef(def)) => Some(def.timing.as_ref()),
        _ => None,
    }
}

/// The declared timing of a command or query; `None` for other kinds, and
/// `Some(None)` for an RPC that declares no bounds (never defaulted,
/// ADR-0015 decision 4).
fn rpc_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
    use v2::decl::Kind;
    match &decl.kind {
        Some(Kind::CommandDef(def)) => Some(def.timing.as_ref()),
        Some(Kind::QueryDef(def)) => Some(def.timing.as_ref()),
        _ => None,
    }
}

// ==========================================================================
// Contracts.
// ==========================================================================

/// A `require`/`ensure` clause-set change on a command or query.
///
/// A `require` is a precondition the caller must meet: adding one, or rewriting
/// one, rejects callers that were legal. An `ensure` is a postcondition the
/// caller may rely on: removing one, or rewriting one, withdraws a guarantee.
/// Clause text is compared verbatim — the classifier never tries to prove that
/// one clause implies another (ADR-0008 decision 14), so a rewrite reads as an
/// addition on the `require` side and as a removal on the `ensure` side, and
/// both are breaking.
fn contract(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
    let Some((container, member)) = member_path(change) else {
        return Verdict::Breaking;
    };
    let (Some(old_decl), Some(new_decl)) = (
        find_old_interaction(old, new, container, member),
        find_interaction(new, container, member),
    ) else {
        return Verdict::Breaking;
    };
    let (Some(old_clauses), Some(new_clauses)) = (contracts(old_decl), contracts(new_decl)) else {
        return Verdict::Breaking;
    };

    let kind = |want: v2::ContractKind| {
        move |clause: &&v2::Contract| v2::ContractKind::try_from(clause.kind) == Ok(want)
    };
    let sources = |clauses: &[v2::Contract], want: v2::ContractKind| -> Vec<String> {
        let mut out: Vec<String> = clauses
            .iter()
            .filter(kind(want))
            .map(|clause| clause.source.clone())
            .collect();
        out.sort();
        out
    };

    let old_require = sources(old_clauses, v2::ContractKind::Require);
    let new_require = sources(new_clauses, v2::ContractKind::Require);
    let old_ensure = sources(old_clauses, v2::ContractKind::Ensure);
    let new_ensure = sources(new_clauses, v2::ContractKind::Ensure);

    if !covers(&old_require, &new_require) || !covers(&new_ensure, &old_ensure) {
        return Verdict::Breaking;
    }
    Verdict::Compatible
}

/// Whether every clause in `subset` appears in `superset`, counting duplicates.
fn covers(superset: &[String], subset: &[String]) -> bool {
    let mut remaining: Vec<&String> = superset.iter().collect();
    for clause in subset {
        match remaining.iter().position(|held| *held == clause) {
            Some(index) => {
                remaining.swap_remove(index);
            }
            None => return false,
        }
    }
    true
}

/// The contract clauses of a command or query; `None` for kinds that carry none.
fn contracts(decl: &v2::Decl) -> Option<&[v2::Contract]> {
    use v2::decl::Kind;
    match &decl.kind {
        Some(Kind::CommandDef(def)) => Some(&def.contracts),
        Some(Kind::QueryDef(def)) => Some(&def.contracts),
        _ => None,
    }
}

// ==========================================================================
// Path resolution and IR lookups.
// ==========================================================================

/// Splits a `package/container/member` path into its container and member.
/// Returns `None` for the shorter package-level paths.
fn member_path(change: &Change) -> Option<(&str, &str)> {
    let mut segments = change.path.split('/').skip(1);
    let container = segments.next()?;
    let member = segments.next()?;
    if segments.next().is_some() {
        return None;
    }
    Some((container, member))
}

fn find_decl<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Decl> {
    package.decls.iter().find(|decl| decl.name == name)
}

/// The shape a name refers to — a top-level interface, or the inline shape of
/// a service, which the walk descends into under the service's own dotted
/// name. `Package::shapes` keys both on exactly that identity, so one lookup
/// covers them.
fn find_shape<'a>(package: &'a v2::Package, name: &str) -> Option<v2::InterfaceShape<'a>> {
    package.shapes().find(|shape| shape.name == name)
}

/// The old side of the shape a diff path names, found the way the walk
/// matched it (lock design §7; plan decision PD-14): by the frozen
/// `interfaces.lock` number of the new side's shape when it has one, and by
/// name otherwise — the new side has no identity, or does not hold the name
/// at all. A renamed interface's member changes therefore classify as any
/// other interface's, although their paths carry only the new name.
fn find_old_shape<'a>(
    old: &'a v2::Package,
    new: &v2::Package,
    name: &str,
) -> Option<v2::InterfaceShape<'a>> {
    let by_number = find_shape(new, name)
        .filter(|shape| frozen(shape.interface))
        .and_then(|shape| {
            old.shapes().find(|candidate| {
                frozen(candidate.interface) && candidate.interface.number == shape.interface.number
            })
        });
    by_number.or_else(|| find_shape(old, name))
}

fn find_interface<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Interface> {
    find_shape(package, name).map(|shape| shape.interface)
}

fn find_old_interface<'a>(
    old: &'a v2::Package,
    new: &v2::Package,
    name: &str,
) -> Option<&'a v2::Interface> {
    find_old_shape(old, new, name).map(|shape| shape.interface)
}

fn find_interaction<'a>(
    package: &'a v2::Package,
    container: &str,
    member: &str,
) -> Option<&'a v2::Decl> {
    find_interface(package, container)?
        .interactions
        .iter()
        .find(|decl| decl.name == member)
}

/// The old side's interaction under a `<package>/<container>/<member>` path,
/// with the container resolved by [`find_old_shape`].
fn find_old_interaction<'a>(
    old: &'a v2::Package,
    new: &v2::Package,
    container: &str,
    member: &str,
) -> Option<&'a v2::Decl> {
    find_old_interface(old, new, container)?
        .interactions
        .iter()
        .find(|decl| decl.name == member)
}

/// Every slot of an interface body as (name, ordinal), tombstones included — a
/// tombstone occupies its ordinal exactly so the slot is never reused
/// (ridl §11).
///
/// A tombstone whose name is unset still holds its slot. The interface-body
/// `reserved` form accepts a bare ordinal or a string literal as well as a
/// name, and both lower to `Reserved { name: None }`; dropping those from the
/// slot list would leave their ordinals looking free, and a new interaction
/// taking one would classify as a clean append. The empty name never equals a
/// real interaction name, so the slot is held against every reuse without
/// matching anything.
fn slots(interface: &v2::Interface) -> Vec<(&str, u32)> {
    interface
        .interactions
        .iter()
        .filter_map(|decl| match &decl.kind {
            Some(v2::decl::Kind::ReservedSlot(reserved)) => {
                Some((reserved.name.as_deref().unwrap_or(""), decl.ordinal))
            }
            Some(_) => Some((decl.name.as_str(), decl.ordinal)),
            None => None,
        })
        .collect()
}

fn slot_ordinal(interface: &v2::Interface, member: &str) -> Option<u32> {
    slots(interface)
        .into_iter()
        .find(|(name, _)| *name == member)
        .map(|(_, ordinal)| ordinal)
}

/// Every live struct field with its ordinal — the 1-based place in the body,
/// counting tombstones, that typl §7.4 makes the wire identity. Shared with the
/// walk, which reports a reorder by these ordinals.
pub(crate) fn struct_slots(def: &v2::StructDef) -> Vec<(String, i64)> {
    def.members
        .iter()
        .filter_map(|member| match &member.member {
            Some(v2::struct_member::Member::Field(field)) => {
                Some((field.name.clone(), i64::from(field.ordinal)))
            }
            _ => None,
        })
        .collect()
}

fn struct_reserved(def: &v2::StructDef) -> Vec<i64> {
    def.members
        .iter()
        .filter_map(|member| match &member.member {
            Some(v2::struct_member::Member::Reserved(reserved)) => {
                Some(i64::from(reserved.ordinal))
            }
            _ => None,
        })
        .collect()
}

/// Every union arm with its ordinal, on the same rule as [`struct_slots`].
pub(crate) fn union_slots(def: &v2::UnionDef) -> Vec<(String, i64)> {
    def.arms
        .iter()
        .map(|arm| (arm.name.clone(), i64::from(arm.ordinal)))
        .collect()
}

fn union_reserved(def: &v2::UnionDef) -> Vec<i64> {
    def.reserved
        .iter()
        .map(|reserved| i64::from(reserved.ordinal))
        .collect()
}

/// Enum values and enum-set bits key on their integer value, not a declaration
/// ordinal: the number is the wire identity (typl §7.4, §8).
fn value_slots(values: &[v2::EnumValue]) -> Vec<(String, i64)> {
    values
        .iter()
        .map(|value| (value.name.clone(), value.value))
        .collect()
}

fn reserved_values(reserved: &[v2::Reserved]) -> Vec<i64> {
    reserved.iter().filter_map(|entry| entry.value).collect()
}

// ==========================================================================
// `--explain` — the rule row for one category.
// ==========================================================================

/// Parses the snake_case word a report prints back into its category, so
/// `ridl diff --explain <category>` takes exactly what the report shows.
pub fn category_from_word(word: &str) -> Option<Category> {
    crate::CATEGORIES
        .into_iter()
        .find(|category| crate::category_word(*category) == word)
}

/// The rule row for a category: the classification table of ADR-0008 decision
/// 14 as text. This is the CI-facing documentation of record until the E4 error
/// index publishes it.
// A new variant must be given a real arm here, not swept into a
// catch-all: rustc forces *an* arm, and the arm its `help:` text
// proposes is `_ =>`, which classifies the new variant silently. The
// two lints below reject a wildcard over `Category` — the first when
// it covers several variants, the second when it covers exactly one,
// which is the case one added variant creates.
#[deny(
    clippy::wildcard_enum_match_arm,
    clippy::match_wildcard_for_single_variants
)]
pub fn explain(category: Category) -> &'static str {
    match category {
        Category::DeclAdded => concat!(
            "A declaration, interface, or service present only in the new snapshot.\n",
            "  compatible  a new package-level decl, interface, or service; an enum\n",
            "              value appended above every live and retired number; a\n",
            "              struct field or union arm appended at the end of the body\n",
            "              (typl 7.4, append-only)\n",
            "  breaking    a member inserted below the highest slot ever used, a member\n",
            "              taking a retired number, any addition that moves a surviving\n",
            "              member's slot, or any arm of a result union (ADR-0008 d4)\n",
            "  note        an interface is matched by its interfaces.lock number (lock\n",
            "              design 7): a frozen number the old side never held is a new\n",
            "              interface, and so is every provisional one — a declaration\n",
            "              with no lock entry carries no identity, and a rename keeps its\n",
            "              number, so it is never a renamed interface (interface_renamed)"
        ),
        Category::DeclRemoved => concat!(
            "A declaration, interface, or service present only in the old snapshot.\n",
            "  breaking    a removed service, interface, decl, enum value, struct\n",
            "              field, or union arm withdraws something a consumer compiled\n",
            "              against\n",
            "  note        an interface is matched by its interfaces.lock number (lock\n",
            "              design 7), so an interface here is one whose number is gone\n",
            "              from the new side and not retired there — a lock line deleted\n",
            "              by hand, or a number changed by hand, which is this row plus\n",
            "              decl_added. A number the new side's lock retires is\n",
            "              interface_retired instead, and `ridl baseline` refuses to\n",
            "              publish the removal of a number it does not find retired\n",
            "              (RIDL-412)\n",
            "  caveat      a composite member retired the sanctioned way — replaced by\n",
            "              a `reserved` tombstone in its own slot (typl 7.4) — is also\n",
            "              reported breaking today. The body comparison is keyed on\n",
            "              member names and does not read the `reserved` list, so it\n",
            "              cannot yet tell that retirement from a bare deletion. This\n",
            "              errs on the safe side; carried as debt, see the note on\n",
            "              `diff_composite`. The interaction-level tombstone IS\n",
            "              recognised — see interaction_retired"
        ),
        Category::InterfaceRenamed => concat!(
            "An interface whose interfaces.lock number is the same on both sides and\n",
            "whose name changed.\n",
            "  compatible  always — the number is the interface's identity and its\n",
            "              routing key (lock design 7, rsdl decision D-7), so nothing\n",
            "              moves on the wire. Visible in source: a rename changes the\n",
            "              generated identity-table names in both wire backends, which\n",
            "              is why the text report lists it under the heading\n",
            "              \"compatible on the wire, visible in source\". The path\n",
            "              carries the new name and the detail old -> new; changes\n",
            "              inside the interface are reported under the new name and\n",
            "              classified as any other interface's\n",
            "  note        a provisional number is no identity: a declaration with no\n",
            "              lock entry is never matched, so a rename the lock does not\n",
            "              record is decl_removed plus decl_added, breaking, until\n",
            "              `ridl lock <pkg> --rename Old=New` records it"
        ),
        Category::InterfaceRetired => concat!(
            "An interface whose number the old snapshot held is gone from the new\n",
            "snapshot, whose interfaces.lock retires it.\n",
            "  compatible  always — the sanctioned removal of an interface: the entry\n",
            "              keeps its number forever with the word `retired`, so the\n",
            "              number is never allocated again (lock design 4 and 7, rsdl\n",
            "              decision D-7). A number gone with no retired entry is\n",
            "              decl_removed, breaking, and `ridl baseline` refuses to publish\n",
            "              it (RIDL-412)"
        ),
        Category::MemberReordered => concat!(
            "A surviving composite member whose slot in the body changed.\n",
            "  breaking    always — a struct field or union arm takes its wire\n",
            "              identity from its ordinal, its 1-based place in the body\n",
            "              counting tombstones (typl 7.4), so a member whose ordinal\n",
            "              changed has a new wire identity; the detail carries the old\n",
            "              and new ordinal. An enum value or enum-set bit carries an\n",
            "              explicit number instead (typl 8, 9), but the walk compares\n",
            "              positions, not those numbers, so a textual reorder of an\n",
            "              enum or enum-set body is reported breaking as well,\n",
            "              conservatively, even when no number changed; the detail\n",
            "              carries the old and new position\n",
            "  note        reported only when both bodies hold the same member names:\n",
            "              a reorder in the same edit as an addition or a removal is\n",
            "              reported through decl_added or decl_removed alone. A reorder\n",
            "              in the same edit as an in-place change to the body is\n",
            "              reported with constraint_changed on the container as well"
        ),
        Category::InteractionAppended => concat!(
            "An interaction added after every slot that existed before.\n",
            "  compatible  the slot it takes was never occupied\n",
            "  breaking    the slot was freed by an untombstoned removal and is now\n",
            "              reused by a new name — a reused wire identity (ADR-0008 d14)"
        ),
        Category::InteractionInserted => concat!(
            "An interaction added before the end of the interface body.\n",
            "  breaking    always — every later ordinal shifts, and the ordinal is the\n",
            "              transport identity (ridl 11)"
        ),
        Category::InteractionReordered => concat!(
            "A surviving interaction whose relative order in the body changed.\n",
            "  breaking    always — a reorder shifts wire identities (ridl 11)"
        ),
        Category::InteractionRemoved => concat!(
            "An interaction removed without a `reserved` tombstone holding its slot.\n",
            "  breaking    always — the freed ordinal is reusable, so the wire identity\n",
            "              is no longer permanent (ridl 11)"
        ),
        Category::InteractionRetired => concat!(
            "An interaction retired to a `reserved` tombstone in its own ordinal slot.\n",
            "  compatible  always — the sanctioned retirement: the slot stays occupied\n",
            "              and every later ordinal holds (ridl 11)"
        ),
        Category::KindChanged => concat!(
            "An interaction whose kind changed (signal, event, command, query, fixed).\n",
            "  breaking    any direction — the kind selects the transport shape"
        ),
        Category::PayloadChanged => concat!(
            "A signal, event, or fixed payload type changed.\n",
            "  breaking    any direction, a stream added or removed included"
        ),
        Category::ReturnChanged => concat!(
            "A query return shape changed.\n",
            "  breaking    any direction — an ok-arm change, an error arm added, removed,\n",
            "              or retyped, a stream added or removed, or any other change to\n",
            "              the synthesized inline `T | E` transport identity (ADR-0008 d4:\n",
            "              interface + interaction ordinal + ordered arm types)"
        ),
        Category::ParamsChanged => concat!(
            "A command or query parameter list changed.\n",
            "  breaking    any direction — a parameter added, removed, renamed, retyped,\n",
            "              or a stream added or removed on one"
        ),
        Category::TimingChanged => concat!(
            "A signal or event resolved timing changed (ADR-0008 d12).\n",
            "  compatible  min raised (a higher rate floor) or max lowered (a tighter\n",
            "              staleness bound) with the mode unchanged; default_applied\n",
            "              flipped over identical resolved bounds — a default made\n",
            "              explicit\n",
            "  breaking    min lowered, max raised, a bound added where none was, a bound\n",
            "              removed, or the strict-periodic/range mode flipped\n",
            "  note        editing `[defaults].timing` needs no special rule: diff\n",
            "              compares resolved bounds, so it surfaces here on every\n",
            "              defaulted interaction (ridl 9.1)"
        ),
        Category::RpcBoundChanged => concat!(
            "A command or query declared RPC bound changed (ADR-0015 d8).\n",
            "  compatible  min lowered (the caller may call more often) or max lowered\n",
            "              (a stronger provider promise), with the mode unchanged\n",
            "  breaking    min raised — on an RPC, min is the call throttle and\n",
            "              constrains the caller, so raising it withdraws a call rate\n",
            "              the caller was entitled to use; max raised (a weaker provider\n",
            "              promise); a bound added or removed, the whole annotation\n",
            "              included\n",
            "  note        the min direction is the inverse of timing_changed's, which\n",
            "              is why this is a category of its own rather than a branch:\n",
            "              a missed branch would inherit the signal rule and call a\n",
            "              raised RPC min compatible (ADR-0012 d9, fail closed).\n",
            "              RPC bounds are never defaulted (ridl 9.1 does not apply)"
        ),
        Category::ContractChanged => concat!(
            "A command or query require/ensure clause set changed (ridl 13).\n",
            "  compatible  a require removed, or an ensure added\n",
            "  breaking    a require added or its text changed, or an ensure removed or\n",
            "              its text changed. Clause text is compared verbatim: the\n",
            "              classifier does not prove that one clause implies another\n",
            "              (ADR-0008 d14)"
        ),
        Category::WidthChanged => concat!(
            "A derived wire width or scalar backing changed.\n",
            "  breaking    any IntWidth or FloatWidth change, uint64 versus int64\n",
            "              included — the resolved width is part of the contract\n",
            "              (typl 4.2, 5.6)"
        ),
        Category::ConstraintChanged => concat!(
            "A scalar constraint changed, or a composite body changed in place.\n",
            "  compatible  widened — min lowered, max raised, a length bound loosened,\n",
            "              a step removed, a match pattern removed (by literal or by\n",
            "              named constant), or the whole constraint dropped so the\n",
            "              value is unbounded again\n",
            "  breaking    narrowed — min raised, max lowered, a length bound\n",
            "              tightened, a step added or changed (divisibility is never\n",
            "              proved), a match pattern added or rewritten (by literal or\n",
            "              by named constant), or a constraint appearing where there\n",
            "              was none, which bounds a previously unbounded value. A\n",
            "              composite body changed in place is breaking: the walk\n",
            "              does not say which member changed inside it\n",
            "  note        each facet is judged on its own and any one narrowing\n",
            "              decides the change, so a mixed edit is breaking on the half\n",
            "              that narrows. A widening that flips the resolved wire width\n",
            "              is separately reported as width_changed, which is always\n",
            "              breaking (typl 5.6)"
        ),
        Category::InitChanged => concat!(
            "A declared or resolved init value changed.\n",
            "  breaking    any direction — the init is the value a consumer reads before\n",
            "              the first publish, so it is part of the contract (ridl 9.1)"
        ),
        Category::ReservedNameRedeclared => concat!(
            "A name retired by a `reserved` tombstone is live again — an interaction\n",
            "inside an interface body.\n",
            "  breaking    always — a retired identity is never reused (ridl 11,\n",
            "              RIDL-401)"
        ),
        Category::ServiceChanged => concat!(
            "A service switched between the named list and an inline shape.\n",
            "  breaking    always — extraction rewrites the transport identity of every\n",
            "              fallible query in the shape: an inline shape derives it from\n",
            "              the service's dotted name, a named interface from its own name\n",
            "              (ADR-0008 d4, ADR-0015 d15). A changed list is not this\n",
            "              category: it is read as a set by the service_interface_*\n",
            "              rows (ADR-0015 d19, as amended 2026-09-15)"
        ),
        Category::ServiceInterfaceAdded => concat!(
            "An interface joined a service's set of interfaces.\n",
            "  compatible  always — nothing that existed moved: an interface's number\n",
            "              comes from its package's interfaces.lock, not from its place\n",
            "              in the list, and the routing key does not contain the service\n",
            "              (ADR-0015 d19, as amended 2026-09-15)"
        ),
        Category::ServiceInterfaceRemoved => concat!(
            "An interface left a service's set of interfaces.\n",
            "  compatible  always — on the wire: the routing key does not contain the\n",
            "              service, so no identity moves, and a split into two services\n",
            "              is a removal plus an addition. Visible in source: the\n",
            "              service.member addresses of that interface stop resolving\n",
            "              under this service, which is why the text report lists it\n",
            "              under the heading \"compatible on the wire, visible in\n",
            "              source\". A consumer that loses its only provider is a\n",
            "              wiring error for rsdl, not a package diff (ADR-0015 d19, as\n",
            "              amended 2026-09-15)"
        ),
        Category::DocOnly => concat!(
            "Only doc comment, labels, or deprecation metadata changed.\n",
            "  compatible  always — none of it reaches a consumer's build.\n",
            "              Visibility is NOT in this category: see\n",
            "              visibility_changed"
        ),
        Category::VisibilityChanged => concat!(
            "The visibility a declaration is published at changed.\n",
            "  compatible  internal -> public: the declaration is offered to more\n",
            "              consumers than before\n",
            "  breaking    public -> internal: `internal` maps to the target's\n",
            "              package-private mechanism — Rust `pub(crate)`, a\n",
            "              non-exported TypeScript member (ADR-0002 8) — so the\n",
            "              declaration disappears from every out-of-package\n",
            "              consumer's build. The wire layout does not move, but the\n",
            "              consumer-visible guarantee narrows (ADR-0008 d14). Any\n",
            "              direction involving an unset visibility is breaking"
        ),
    }
}