///|
/// Compare two parsed contracts and explain compatibility changes.
///
/// Backward mode asks whether every payload accepted by `old_contract` is also
/// accepted by `new_contract`. Forward mode reverses that data flow. Full mode
/// checks both directions.
pub fn analyze(
old_contract : Contract,
new_contract : Contract,
mode? : CompatibilityMode = Full,
) -> AnalysisReport {
let changes : Array[Change] = []
if old_contract.name != new_contract.name {
let direction = mode.render()
changes.push(
breaking_change(
"CONTRACT_RENAMED",
direction,
"$",
"contract name changed from '" +
old_contract.name +
"' to '" +
new_contract.name +
"'",
"Keep the contract name stable or publish the new contract independently.",
make_witness(
"{}", old_contract, new_contract, "the payload belongs to a differently named contract",
),
),
)
} else {
match mode {
Backward =>
analyze_data_flow(old_contract, new_contract, "backward", changes)
Forward =>
analyze_data_flow(new_contract, old_contract, "forward", changes)
Full => {
analyze_data_flow(old_contract, new_contract, "backward", changes)
analyze_data_flow(new_contract, old_contract, "forward", changes)
}
}
}
{
contract_name: old_contract.name,
old_version: old_contract.version,
new_version: new_contract.version,
mode,
changes,
}
}
///|
/// Convenience API for callers that keep both contracts as text.
pub fn analyze_text(
old_source : String,
new_source : String,
mode? : CompatibilityMode = Full,
) -> Result[AnalysisReport, ParseError] {
let old_contract = match parse_contract(old_source) {
Ok(contract) => contract
Err(error) => return Err(error)
}
let new_contract = match parse_contract(new_source) {
Ok(contract) => contract
Err(error) => return Err(error)
}
Ok(analyze(old_contract, new_contract, mode~))
}
///|
/// Analyze whether all values produced under `source` remain valid under `target`.
fn analyze_data_flow(
source : Contract,
target : Contract,
direction : String,
changes : Array[Change],
) -> Unit {
for source_object in source.objects {
match target.find_object(source_object.name) {
None =>
changes.push(
breaking_change(
"TYPE_REMOVED",
direction,
"$." + source_object.name,
"type '" +
source_object.name +
"' is not present in the receiving contract",
"Retain the type until all producers using it have migrated.",
make_witness(
minimal_object_json(source, source_object),
source,
target,
"the receiver has no declaration for this object type",
),
),
)
Some(target_object) =>
analyze_object(
source, target, source_object, target_object, direction, changes,
)
}
}
for target_object in target.objects {
if source.find_object(target_object.name) is None {
changes.push(
info_change(
"TYPE_ADDED",
direction,
"$." + target_object.name,
"type '" +
target_object.name +
"' exists only in the receiving contract",
"Adding an independently addressable type does not reject existing payloads.",
),
)
}
}
}
///|
fn analyze_object(
source_contract : Contract,
target_contract : Contract,
source : ObjectType,
target : ObjectType,
direction : String,
changes : Array[Change],
) -> Unit {
let object_path = "$." + source.name
if source.open && !target.open {
changes.push(
breaking_change(
"OBJECT_CLOSED",
direction,
object_path,
"object changed from open to closed and now rejects unknown fields",
"Keep the object open during migration or explicitly declare every producer field.",
make_witness(
object_with_extra_field(source_contract, source),
source_contract,
target_contract,
"the additional field '__extra' is accepted only by the open object",
),
),
)
} else if !source.open && target.open {
changes.push(
info_change(
"OBJECT_OPENED", direction, object_path, "object changed from closed to open",
"The receiver now accepts additional fields, which widens its input set.",
),
)
}
for source_field in source.fields {
match target.find_field(source_field.name) {
None =>
if target.open {
changes.push(
info_change(
"FIELD_UNDECLARED_BUT_ACCEPTED",
direction,
field_path(source.name, source_field.name),
"field is no longer declared but remains accepted by the open object",
"Keep it documented if downstream users still depend on the field.",
),
)
} else {
let value = representative_value(source_contract, source_field)
changes.push(
breaking_change(
"FIELD_REMOVED",
direction,
field_path(source.name, source_field.name),
"field '" +
source_field.name +
"' was removed from a closed receiving object",
"Keep the field optional during a deprecation window before removing it.",
make_witness(
object_with_field(source_contract, source, source_field, value),
source_contract,
target_contract,
"the closed receiver rejects the now-unknown field",
),
),
)
}
Some(target_field) =>
analyze_field(
source_contract, target_contract, source, source_field, target_field, direction,
changes,
)
}
}
for target_field in target.fields {
if source.find_field(target_field.name) is None {
let path = field_path(source.name, target_field.name)
if target_field.required {
changes.push(
breaking_change(
"REQUIRED_FIELD_ADDED",
direction,
path,
"required field '" +
target_field.name +
"' is absent from source payloads",
"Add the field as optional first, migrate producers, then make it required.",
make_witness(
minimal_object_json(source_contract, source),
source_contract,
target_contract,
"a source-valid object may omit the newly required field",
),
),
)
} else {
changes.push(
info_change(
"OPTIONAL_FIELD_ADDED",
direction,
path,
"optional field '" + target_field.name + "' was added",
"Optional additions preserve the existing input set.",
),
)
}
}
}
}
///|
fn analyze_field(
source_contract : Contract,
target_contract : Contract,
source_object : ObjectType,
source : Field,
target : Field,
direction : String,
changes : Array[Change],
) -> Unit {
let path = field_path(source_object.name, source.name)
if !source.required && target.required {
changes.push(
breaking_change(
"FIELD_BECAME_REQUIRED",
direction,
path,
"optional field '" + source.name + "' became required",
"Keep the field optional until every producer always emits it.",
make_witness(
object_without_field(source_contract, source_object, source.name),
source_contract,
target_contract,
"the source accepts an object with this optional field omitted",
),
),
)
} else if source.required && !target.required {
changes.push(
info_change(
"FIELD_BECAME_OPTIONAL",
direction,
path,
"required field '" + source.name + "' became optional",
"The receiving contract accepts a wider set of objects.",
),
)
}
if !type_accepts(target.type_expr, source.type_expr) {
let code = if source.type_expr is EnumType(_) &&
target.type_expr is EnumType(_) {
"ENUM_VALUE_REMOVED"
} else {
"FIELD_TYPE_NARROWED"
}
let value = value_for_type_break(source_contract, source, target)
changes.push(
breaking_change(
code,
direction,
path,
"receiver type '" +
target.type_expr.render() +
"' does not accept every '" +
source.type_expr.render() +
"' value",
"Use a widening type change or coordinate producer migration before narrowing.",
make_witness(
object_with_field(source_contract, source_object, source, value),
source_contract,
target_contract,
"the selected field value is valid for the source type but invalid for the receiver type",
),
),
)
return
}
if source.type_expr != target.type_expr {
changes.push(
info_change(
"FIELD_TYPE_WIDENED",
direction,
path,
"field type widened from '" +
source.type_expr.render() +
"' to '" +
target.type_expr.render() +
"'",
"The receiving field accepts all source values.",
),
)
} else if source.type_expr is EnumType(source_values) &&
target.type_expr is EnumType(target_values) {
if source_values != target_values &&
source_values.all(value => target_values.contains(value)) {
changes.push(
info_change(
"ENUM_VALUE_ADDED", direction, path, "receiver enum contains additional values",
"Existing source values remain valid in this direction.",
),
)
}
}
analyze_constraints(
source_contract, target_contract, source_object, source, target, direction, changes,
)
}
///|
fn type_accepts(receiver : TypeExpr, produced : TypeExpr) -> Bool {
match (receiver, produced) {
(StringType, StringType) => true
(IntType, IntType) => true
(NumberType, NumberType | IntType) => true
(BoolType, BoolType) => true
(EnumType(receiver_values), EnumType(produced_values)) =>
produced_values.all(value => receiver_values.contains(value))
(RefType(receiver_name), RefType(produced_name)) =>
receiver_name == produced_name
(ListType(receiver_item), ListType(produced_item)) =>
receiver_item == produced_item
_ => false
}
}
///|
fn analyze_constraints(
source_contract : Contract,
target_contract : Contract,
source_object : ObjectType,
source : Field,
target : Field,
direction : String,
changes : Array[Change],
) -> Unit {
let path = field_path(source_object.name, source.name)
let mut found_break = false
match target.constraints.min_int {
Some(target_min) => {
let breaks = match source.constraints.min_int {
Some(source_min) => target_min > source_min
None => true
}
if breaks {
let value = match source.constraints.min_int {
Some(source_min) => source_min
None => target_min - 1
}
found_break = true
changes.push(
constraint_break(
"MIN_INCREASED",
direction,
path,
"minimum increased to " + target_min.to_string(),
value.to_string(),
source_contract,
target_contract,
source_object,
source,
),
)
}
}
None => ()
}
match target.constraints.max_int {
Some(target_max) => {
let breaks = match source.constraints.max_int {
Some(source_max) => target_max < source_max
None => true
}
if breaks {
found_break = true
changes.push(
constraint_break(
"MAX_DECREASED",
direction,
path,
"maximum decreased to " + target_max.to_string(),
(target_max + 1).to_string(),
source_contract,
target_contract,
source_object,
source,
),
)
}
}
None => ()
}
match target.constraints.min_len {
Some(target_min) => {
let source_min = source.constraints.min_len.unwrap_or(0)
if target_min > source_min {
found_break = true
changes.push(
constraint_break(
"MIN_LENGTH_INCREASED",
direction,
path,
"minimum length increased to " + target_min.to_string(),
length_value(source.type_expr, source_min),
source_contract,
target_contract,
source_object,
source,
),
)
}
}
None => ()
}
match target.constraints.max_len {
Some(target_max) => {
let breaks = match source.constraints.max_len {
Some(source_max) => target_max < source_max
None => true
}
if breaks {
found_break = true
changes.push(
constraint_break(
"MAX_LENGTH_DECREASED",
direction,
path,
"maximum length decreased to " + target_max.to_string(),
length_value(source.type_expr, target_max + 1),
source_contract,
target_contract,
source_object,
source,
),
)
}
}
None => ()
}
if !found_break && source.constraints != target.constraints {
changes.push(
info_change(
"CONSTRAINTS_CHANGED_COMPATIBLY", direction, path, "field constraints changed without narrowing accepted source values",
"Review the opposite compatibility direction when using full compatibility.",
),
)
}
}
///|
fn constraint_break(
code : String,
direction : String,
path : String,
message : String,
value : String,
source_contract : Contract,
target_contract : Contract,
source_object : ObjectType,
source_field : Field,
) -> Change {
breaking_change(
code,
direction,
path,
message,
"Relax the receiving bound or migrate producers before tightening it.",
make_witness(
object_with_field(source_contract, source_object, source_field, value),
source_contract,
target_contract,
"the boundary value is accepted by the source constraint but rejected by the receiver",
),
)
}
///|
fn length_value(type_expr : TypeExpr, length : Int) -> String {
match type_expr {
StringType => string_value(length)
ListType(item) => {
let value = match item {
"string" => "\"\""
"bool" => "false"
"int" | "number" => "0"
_ => "{}"
}
"[" + Array::make(length, value).join(",") + "]"
}
_ => "null"
}
}