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::InterfaceFrozen => Verdict::Compatible,
Category::InterfaceNumberChanged => Verdict::Breaking,
Category::LimitChanged => Verdict::Breaking,
Category::EnumReordered => 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),
Category::InteractionClassChanged => Verdict::Breaking,
}
}
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 held = || {
old.iter()
.map(|(_, slot)| *slot)
.chain(old_retired.iter().copied())
};
let old_names: Vec<&String> = old.iter().map(|(name, _)| name).collect();
for (name, slot) in new {
if old_names.contains(&name) {
continue;
}
if !is_above_high_water(*slot, held()) {
return false;
}
}
true
}
pub fn is_above_high_water(slot: i64, held: impl IntoIterator<Item = i64>) -> bool {
held.into_iter().max().is_none_or(|mark| slot > mark)
}
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 let Some(step) = new.step.as_deref()
&& old.min != new.min
&& !same_grid(old.min.as_deref(), new.min.as_deref(), 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 same_grid(old: Option<&str>, new: Option<&str>, step: &str) -> bool {
use num_bigint::BigInt;
let read = |text: &str| -> Option<(BigInt, usize)> {
let (negative, digits) = split_sign(text)?;
let (whole, fraction) = digits.split_once('.').unwrap_or((&digits, ""));
if whole.is_empty() || (digits.contains('.') && fraction.is_empty()) {
return None;
}
let coefficient = BigInt::parse_bytes(format!("{whole}{fraction}").as_bytes(), 10)?;
Some((
if negative { -coefficient } else { coefficient },
fraction.len(),
))
};
let (Some(old), Some(new), Some(step)) = (
read(old.unwrap_or("0")),
read(new.unwrap_or("0")),
read(step),
) else {
return false;
};
let scale = old.1.max(new.1).max(step.1);
let rescale = |(coefficient, places): (BigInt, usize)| -> Option<BigInt> {
Some(coefficient * BigInt::from(10).pow(u32::try_from(scale - places).ok()?))
};
let (Some(old), Some(new), Some(step)) = (rescale(old), rescale(new), rescale(step)) else {
return false;
};
step > BigInt::from(0) && (old - new) % step == BigInt::from(0)
}
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()
}
pub(crate) 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::InterfaceFrozen => concat!(
"An interface matched by name whose number is the same on both sides and\n",
"whose provisional flag went from true to false: `ridl lock` froze the\n",
"number in place.\n",
" compatible always — the number is the routing key and it did not\n",
" change, so nothing moves on the wire. The catalog hash\n",
" changes, because it covers each interface's number and its\n",
" provisional flag, and a generated face refuses a port whose\n",
" catalog hash is another one (ADR-0023 decision 8): a peer\n",
" built before the lock and a peer built after it refuse each\n",
" other. This is why the text report lists it under the\n",
" heading \"compatible on the wire, may change the catalog\n",
" hash\".\n",
" The detail carries the number before and after, marked\n",
" (provisional) on the old side\n",
" note a baseline never holds a provisional number (`ridl baseline`\n",
" refuses one, RIDL-411), so this change is seen between two\n",
" builds, or in a reversed comparison whose old side is a\n",
" build. A number 0 on either side, from a snapshot written\n",
" before the lock existed, is not compared"
),
Category::InterfaceNumberChanged => concat!(
"An interface matched across the two sides whose number differs.\n",
" breaking always — the number is the interface's routing key, so a\n",
" peer built against the old number cannot reach the\n",
" interface at the new one. Seen between two provisional\n",
" builds when a sibling interface added before this one in\n",
" byte order moves its provisional number, and when `ridl\n",
" lock` freezes a provisional number as another number. The\n",
" detail carries the old and the new number, each marked\n",
" (provisional) when it is\n",
" note two frozen numbers are matched by number, so they never\n",
" differ here: a frozen number changed by hand is\n",
" decl_removed plus decl_added. A number 0 on either side,\n",
" from a snapshot written before the lock existed, is not\n",
" compared.\n",
" Run `ridl lock` to freeze the numbers, so that a later\n",
" sibling cannot move them"
),
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 takes its\n",
" identity from the explicit number it declares (typl 8, 9),\n",
" so one whose number changed has a new wire identity; the\n",
" detail carries the old and new value or bit. An enum or\n",
" enum-set body reordered with no number changed is\n",
" enum_reordered, compatible, instead\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.\n",
" An enum or enum-set body whose only differences are changed\n",
" numbers, in any order, reports member_reordered alone"
),
Category::EnumReordered => concat!(
"An enum or enum-set body whose difference is only order: its values or\n",
"bits reordered with no number changed, the enum's reserved list\n",
"reordered with no entry added, removed or changed, or both.\n",
" compatible always — a value or bit takes its identity from the\n",
" explicit number it declares (typl 8, 9), so nothing moves\n",
" on the wire. The catalog hash covers only the declarations\n",
" an interface reaches. When an interface reaches the enum or\n",
" enum set, the order of its values or bits, and of an enum's\n",
" reserved list, is part of the hash, so the hash changes\n",
" then, and a generated face refuses a port whose catalog hash\n",
" is another one (ADR-0023 decision 8). This is why the text\n",
" report lists it under the heading \"compatible on the wire,\n",
" may change the catalog hash\". The path is the container's;\n",
" there is no detail\n",
" note a value or bit whose number changed is member_reordered,\n",
" breaking, not enum_reordered"
),
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::LimitChanged => concat!(
"The payload limit of the interaction changed; every change of a limit is\n",
"breaking, ADR-0030 decision 13.\n",
" breaking a limit raised, lowered, added where none was, or removed"
),
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::InteractionClassChanged => concat!(
"A name-paired interaction's family or operation shape changed\n",
"(ADR-0012 d6).\n",
" breaking any change, in either direction: the family selects the\n",
" obligations a consumer compiled against, and the shape\n",
" the gesture an intent command is. No source writes a\n",
" family other than dispatch yet; the walk compares the\n",
" fields from the start so the diff fails closed (ADR-0012 d9)"
),
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 without shifting a stepped grid, max\n",
" 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 stepped grid shifted, a step added or changed\n",
" (divisibility between different steps 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 unit'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"
),
}
}