use ridl_ir::v2;
use crate::{Category, Change, Verdict, frozen};
#[cfg(test)]
mod classify_tests;
pub fn classify(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
classify_in(change, old, new, &[])
}
#[deny(
clippy::wildcard_enum_match_arm,
clippy::match_wildcard_for_single_variants
)]
pub(crate) fn classify_in(
change: &Change,
old: &v2::Package,
new: &v2::Package,
scope: &[&v2::Package],
) -> Verdict {
match change.category {
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,
Category::InitChanged => Verdict::Breaking,
Category::DocOnly | Category::InteractionRetired => Verdict::Compatible,
Category::InterfaceRenamed | Category::InterfaceRetired => Verdict::Compatible,
Category::ServiceInterfaceAdded | Category::ServiceInterfaceRemoved => Verdict::Compatible,
Category::VisibilityChanged => visibility(change, old, new),
Category::InteractionAppended => appended(change, old, new),
Category::DeclAdded => added(change, old, new, scope),
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),
}
}
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,
}
}
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);
}
if let Some(shape) = shape_of(name) {
return Some(shape.visibility());
}
package
.services
.iter()
.find(|service| service.name == name)
.map(|service| service.visibility)
}
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
}
fn added(change: &Change, old: &v2::Package, new: &v2::Package, scope: &[&v2::Package]) -> Verdict {
let Some((container, member)) = member_path(change) else {
return Verdict::Compatible;
};
let (Some(old_decl), Some(new_decl)) = (find_decl(old, container), find_decl(new, container))
else {
return Verdict::Breaking;
};
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),
) && absence_reads_as_legal(new_def, member, new, scope)
}
(Some(Kind::UnionDef(old_def)), Some(Kind::UnionDef(new_def))) => {
!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),
),
_ => false,
};
if appended {
Verdict::Compatible
} else {
Verdict::Breaking
}
}
pub fn absence_refused(
new_set: &[v2::Package],
context: &[v2::Package],
package: &str,
container: &str,
member: &str,
) -> bool {
let scope: Vec<&v2::Package> = new_set.iter().chain(context).collect();
let Some(home) = new_set.iter().find(|candidate| candidate.name == package) else {
return false;
};
match find_decl(home, container).and_then(|decl| decl.kind.as_ref()) {
Some(v2::decl::Kind::StructDef(def)) => !absence_reads_as_legal(def, member, home, &scope),
_ => false,
}
}
fn absence_reads_as_legal(
def: &v2::StructDef,
member: &str,
home: &v2::Package,
scope: &[&v2::Package],
) -> bool {
let Some(r#type) = def.members.iter().find_map(|slot| match &slot.member {
Some(v2::struct_member::Member::Field(field)) if field.name == member => {
field.r#type.as_ref()
}
_ => None,
}) else {
return false;
};
if r#type.optional {
return true;
}
use v2::field_type::Kind;
match &r#type.kind {
Some(Kind::Primitive(primitive)) => matches!(
v2::PrimitiveType::try_from(*primitive),
Ok(v2::PrimitiveType::Boolean | v2::PrimitiveType::Integer | v2::PrimitiveType::Float)
),
Some(Kind::InlineScalar(def)) => scalar_holds_zero(def),
Some(Kind::Named(reference)) => {
use v2::decl::Kind as Decl;
match resolve(home, scope, reference).and_then(|decl| decl.kind.as_ref()) {
Some(Decl::TypeDef(def)) => scalar_holds_zero(def),
Some(Decl::EnumDef(def)) => ridl_ir::zero::enum_zero_member(&def.values).is_some(),
Some(Decl::EnumSetDef(_)) => true,
_ => false,
}
}
_ => false,
}
}
fn scalar_holds_zero(def: &v2::TypeDef) -> bool {
let numeric = match def
.backing
.as_ref()
.and_then(|backing| backing.kind.as_ref())
{
Some(v2::backing::Kind::Unit(_)) => true,
Some(v2::backing::Kind::Primitive(primitive)) => matches!(
v2::PrimitiveType::try_from(*primitive),
Ok(v2::PrimitiveType::Boolean | v2::PrimitiveType::Integer | v2::PrimitiveType::Float)
),
None => false,
};
numeric
&& def.constraint.as_ref().is_none_or(|constraint| {
ridl_ir::zero::range_holds_zero(
constraint.min.as_deref(),
constraint.max.as_deref(),
constraint.step.as_deref(),
)
})
}
fn resolve<'a>(
home: &'a v2::Package,
scope: &[&'a v2::Package],
reference: &str,
) -> Option<&'a v2::Decl> {
match reference.rsplit_once('.') {
Some((package, name)) => std::iter::once(home)
.chain(scope.iter().copied())
.find(|candidate| candidate.name == package)
.and_then(|candidate| find_decl(candidate, name)),
None => find_decl(home, reference),
}
}
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 => {}
_ => 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
}
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
}
}
fn narrows(old: Option<&v2::Constraint>, new: Option<&v2::Constraint>) -> bool {
let (Some(old), Some(new)) = (old, new) else {
return old.is_none() && new.is_some();
};
if raised(old.min.as_deref(), new.min.as_deref())
|| lowered(old.max.as_deref(), new.max.as_deref())
{
return true;
}
if raised_u64(old.len_min, new.len_min) || lowered_u64(old.len_max, new.len_max) {
return true;
}
if new.step.is_some() && old.step != new.step {
return true;
}
if (new.pattern.is_some() && old.pattern != new.pattern)
|| (new.pattern_const.is_some() && old.pattern_const != new.pattern_const)
{
return true;
}
false
}
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,
}
}
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,
}
}
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 {
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
})
}
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()))
}
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);
}
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)))
}
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 {
return Verdict::Breaking;
};
let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
return Verdict::Breaking;
};
if old_timing.mode != new_timing.mode {
return Verdict::Breaking;
}
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;
}
Verdict::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 {
return Verdict::Breaking;
};
let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
return Verdict::Breaking;
};
if old_timing.mode != new_timing.mode {
return Verdict::Breaking;
}
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;
}
Verdict::Compatible
}
fn dropped(old: Option<&str>, new: Option<&str>) -> bool {
old.is_some() && new.is_none()
}
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,
}
}
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,
}
}
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
}
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
}
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,
}
}
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)
}
fn find_shape<'a>(package: &'a v2::Package, name: &str) -> Option<v2::InterfaceShape<'a>> {
package.shapes().find(|shape| shape.name == 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)
}
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)
}
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)
}
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()
}
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()
}
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()
}
pub fn category_from_word(word: &str) -> Option<Category> {
crate::CATEGORIES
.into_iter()
.find(|category| crate::category_word(*category) == word)
}
#[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",
" union arm appended at the end of the body (typl 7.4,\n",
" append-only); a struct field appended at the end of the body\n",
" when it is optional, or when it is a scalar, enum, or enum set\n",
" whose type allows the value 0\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); a\n",
" non-optional struct field appended whose type does not allow\n",
" 0 — a string, bytes, struct, union, tuple, array, or map, a\n",
" scalar whose range or step excludes 0, an enum with no zero\n",
" member, or a type the diff cannot resolve (ridl diff\n",
" resolves ridl.std types). A reader of the new version\n",
" refuses every payload of the old one, which does not carry\n",
" the field (typl 7.4). Declare the new field optional instead\n",
" (`field : T?`): an absent optional field reads as absent\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",
" default_applied flipped over identical resolved bounds — a\n",
" default made explicit\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",
" an RPC bound may come from a default, and diff compares\n",
" resolved bounds, so editing a default surfaces here on\n",
" every defaulted command or query (ridl 9.1)"
),
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"
),
}
}