use std::collections::{BTreeMap, BTreeSet};
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],
encodings: &[&str],
) -> 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::StrictEnumAppend | Category::EnumClosed => Verdict::Breaking,
Category::EnumOpened => 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,
Category::PodbufInlineAppend
| Category::PodbufRemoteInlineGrowth
| Category::PodbufWidthChange
| Category::PodbufStrictEnumAppend => {
if encodings.contains(&"podbuf") {
Verdict::Breaking
} else {
Verdict::Compatible
}
}
Category::FlatbuffersWidthChange => {
if encodings.contains(&"flatbuffers") {
Verdict::Breaking
} else {
Verdict::Compatible
}
}
Category::Proto3WidthChange => {
if encodings.contains(&"proto3") {
Verdict::Breaking
} else {
Verdict::Compatible
}
}
}
}
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))) => {
new_def.open
&& 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))) => {
new_def.open
&& appended_slot(
&value_slots(&old_def.bits),
&[],
&value_slots(&new_def.bits),
)
}
_ => false,
};
if appended {
Verdict::Compatible
} else {
Verdict::Breaking
}
}
pub(crate) fn is_strict_enum_append(old_decl: &v2::Decl, new_decl: &v2::Decl) -> bool {
use v2::decl::Kind;
match (&old_decl.kind, &new_decl.kind) {
(Some(Kind::EnumDef(old_def)), Some(Kind::EnumDef(new_def))) => {
!new_def.open
&& 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))) => {
!new_def.open
&& appended_slot(
&value_slots(&old_def.bits),
&[],
&value_slots(&new_def.bits),
)
}
_ => false,
}
}
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;
};
ridl_ir::zero::field_type_holds_zero(r#type, |reference| resolve(home, scope, reference))
}
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 {
let mut new_slots: BTreeMap<&str, i64> = BTreeMap::new();
for (name, slot) in new {
new_slots.entry(name.as_str()).or_insert(*slot);
}
for (name, old_slot) in old {
match new_slots.get(name.as_str()) {
Some(new_slot) if new_slot == old_slot => {}
_ => return false,
}
}
let mark = old
.iter()
.map(|(_, slot)| *slot)
.chain(old_retired.iter().copied())
.max();
let old_names: BTreeSet<&str> = old.iter().map(|(name, _)| name.as_str()).collect();
for (name, slot) in new {
if old_names.contains(name.as_str()) {
continue;
}
if !is_above_high_water(*slot, mark) {
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))
}
pub(crate) 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"
),
Category::PodbufRemoteInlineGrowth => concat!(
"A remote declaration held inline by an old consumer has a larger podbuf size.\n",
"The path belongs to the consumer; the values name the remote declaration\n",
"and its old and new inline sizes. An inline member has no nested producer-size\n",
"boundary, so its growth can move the fields after it even when only a remote\n",
"pin changes and the local consumer declaration is unchanged. Producer-sized\n",
"roots, array elements, map entries and union arms may grow, but their inline\n",
"members may not. An append that fits entirely in the old head's pad bytes\n",
"keeps the head size and draws no line; verify does not check pads.\n",
"Shrinkage, other equal-size layout changes and general dependency semantic\n",
"changes are deferred; this category judges only remote inline growth.\n",
" breaking with `--encoding podbuf`\n",
" compatible otherwise; listed under the heading \"breaking under podbuf\""
),
Category::PodbufInlineAppend => concat!(
"A struct field appended, compatible under the generic rule, to a\n",
"struct another head holds inline on the old side — as a struct field,\n",
"a tuple member, or an element of an exact-length array, in a struct,\n",
"in the argument struct a call with several parameters induces, or in\n",
"a tuple reply — when the field does not fit entirely inside the old\n",
"head: the first-fit law places any of its bytes past the old head's\n",
"size, so that the head grows. A field the law places entirely inside\n",
"the old head, in its pad bytes, draws no line, because `verify` does\n",
"not check pads; a field that starts in the pads and ends past the\n",
"old size draws it. When several fields are appended at once, each is\n",
"judged by its own bytes. Only a holder of the old side counts, because\n",
"a holder new on the new side has no published copy;\n",
"the detail names each inline use of the old side. The search mirrors\n",
"the heads the encoder lays out, a declaration and a named induced\n",
"tuple: a struct that is a root of its own (a single named parameter, a\n",
"named reply, a signal, event or fixed payload), that is used only\n",
"behind a descriptor (the element of a bounded array, the key or value\n",
"of a map entry, a union arm), or only inside an exact-length array\n",
"that is itself a root (a fixed payload `[S; 2..2]`, which has no head)\n",
"is not reported: the root or the stride gives the producer's size, and\n",
"the fields past it read as 0. A tuple inside such a root is a head,\n",
"and a struct inside that tuple is a use.\n",
" breaking with `--encoding podbuf`: the inline copy cannot grow\n",
" without moving the fields after it\n",
" compatible otherwise; listed under the heading \"breaking under\n",
" podbuf\", so that a workspace that builds podbuf sees it"
),
Category::PodbufWidthChange => concat!(
"An enum value, an enum-set flag or a union arm appended, compatible\n",
"under the generic rule, after which the podbuf integer of the enum or\n",
"the enum set or the tag of the union is a different integer, by width\n",
"or by signedness. podbuf lays an enum out as the narrowest integer\n",
"that holds every declared value, signed when a value is negative\n",
"(ADR-0031 decision 5), an enum set as its width, the narrowest\n",
"unsigned integer that holds its highest bit, and a union's tag as the\n",
"narrowest unsigned integer that holds the largest arm ordinal: a value\n",
"above 255, a flag at bit 8 or an arm past ordinal 255 widens a `u8` to\n",
"a `u16`, a value above 65535 or a flag at bit 16 a `u16` to a `u32`,\n",
"and a value above 4294967295 or a flag at bit 32 a `u32` to a `u64`.\n",
"A negative value appended to an enum that has values is below its\n",
"highest value, an insert, which the generic rule reports as a breaking\n",
"`decl_added` line and not as a width line; the integer turns signed\n",
"only when an enum with no value gains values (`-1` gives an `i8`,\n",
"`-1` and `200` an `i16`). The line is reported once per declaration\n",
"and carries the old and the new integer. An append that keeps the\n",
"integer draws no such line.\n",
" breaking with `--encoding podbuf`: every head that holds the enum,\n",
" the enum set or the union lays out a different integer,\n",
" and an older reader misreads the value\n",
" compatible otherwise; listed under the\n",
" heading \"breaking under podbuf\", so that a workspace\n",
" that builds podbuf sees it. An enum-set flag that widens\n",
" the integer also widens the enum set's FlatBuffers\n",
" scalar, which `flatbuffers_width_change` reports, and a\n",
" flag at bit 32 also widens the proto3 field, which\n",
" `proto3_width_change` reports"
),
Category::PodbufStrictEnumAppend => concat!(
"Superseded by `strict_enum_append`, which is breaking for every\n",
"encoding. `ridl diff` no longer reports this category; the word stays\n",
"so that a report or a script that names it still reads.\n",
" breaking with `--encoding podbuf`\n",
" compatible otherwise"
),
Category::StrictEnumAppend => concat!(
"An enum value or an enum-set flag appended to an enum or an enum set\n",
"that is not declared `open`. A reader built from the old declaration\n",
"refuses a value or a flag it does not name: the `verify` of podbuf\n",
"does, and so does the codec of every encoding that checks the\n",
"declaration, so the append is breaking whatever encodings the\n",
"comparison is judged for. To let readers accept values a later\n",
"version adds, declare an open enum or an open enum set (the `open`\n",
"keyword) before the first release that readers depend on: an append\n",
"to an open enum or an open enum set is compatible. The line is reported once per\n",
"declaration, at `<package>/<decl>`, and the detail names the\n",
"appended values or flags.\n",
" breaking always, for every encoding\n",
" note a value or flag that is not an append (an insert below\n",
" the highest number, or a reused number) is decl_added,\n",
" breaking, instead"
),
Category::EnumOpened => concat!(
"An enum or an enum set that gained the `open` keyword. A reader built\n",
"from the old declaration already refuses nothing that the new one\n",
"names, and a reader built from the new declaration accepts any\n",
"integer, so no producer or reader of either side is refused by the\n",
"other. The path is the declaration's; there is no detail.\n",
" compatible always"
),
Category::EnumClosed => concat!(
"An enum or an enum set that lost the `open` keyword. A reader built\n",
"from the new declaration refuses a value that a producer of the old\n",
"declaration may still send, because the old declaration accepted any\n",
"integer. The path is the declaration's; there is no detail.\n",
" breaking always"
),
Category::FlatbuffersWidthChange => concat!(
"An enum value or an enum-set flag appended, compatible under the\n",
"generic rule, after which the FlatBuffers integer of the enum or the\n",
"enum set is a different integer. FlatBuffers stores an enum set as the\n",
"unsigned scalar of its width, the narrowest that holds its highest\n",
"bit: a flag at bit 8 widens a `u8` (`ubyte`) to a `u16` (`ushort`),\n",
"a flag at bit 16 a `u16` to a `u32` (`uint`), and a flag at bit 32 a\n",
"`u32` to a `u64` (`ulong`). Every enum is stored as `long` whatever\n",
"its values, so an appended enum value keeps the integer; a union's\n",
"type field is always `ubyte`. The line is reported once per\n",
"declaration and carries the old and the new integer. An append that\n",
"keeps the integer draws no such line.\n",
"Only per-side context entry points report a remote declaration reached\n",
"by a surviving old consumer use once per consumer path and remote\n",
"declaration, at the consumer path with `<remote declaration>: <integer>`\n",
"values; a side without remote context draws no such line.\n",
" breaking with `--encoding flatbuffers`: every table field and\n",
" vector element of the type changes its scalar, so a\n",
" reader of the new schema rejects or misreads an old\n",
" buffer and a reader of the old schema drops the flag\n",
" compatible otherwise; listed under the heading \"breaking under\n",
" flatbuffers\", so that a workspace that builds\n",
" FlatBuffers sees it"
),
Category::Proto3WidthChange => concat!(
"An enum-set flag appended, compatible under the generic rule, after\n",
"which the proto3 scalar of the enum set is a different scalar. proto3\n",
"has no 8-bit or 16-bit integer, so an enum set of width `u8`, `u16`\n",
"or `u32` is a `uint32` field and one of width `u64` is a `uint64`\n",
"field: a flag at bit 32 above a highest bit of 31 or below widens\n",
"`uint32` to `uint64`, and a flag at bit 8 or 16 keeps `uint32`. Every\n",
"enum is a proto3 `enum`, an `int32`, and the proto3 backend refuses a\n",
"value outside `int32`, so an appended enum value never changes it.\n",
"The line is reported once per declaration and carries the old and the\n",
"new scalar. An append that keeps the scalar draws no such line.\n",
"Only per-side context entry points report a remote declaration reached\n",
"by a surviving old consumer use once per consumer path and remote\n",
"declaration, at the consumer path with `<remote declaration>: <integer>`\n",
"values; a side without remote context draws no such line.\n",
" breaking with `--encoding proto3`: a reader of the old schema\n",
" refuses the value (the generated Rust codec) or truncates\n",
" it to 32 bits and drops the flag\n",
" compatible otherwise; listed under the heading \"breaking under\n",
" proto3\", so that a workspace that builds proto3 sees it"
),
}
}