From 4e41061ed42a519f7a8eaecc817c0a4bfc40716a Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 23 Oct 2026 00:03:03 +0100 Subject: [PATCH] sausage-interface: compatibility check for imports (PROTOCOL.md 10.2) Params go from consumer to provider, results from provider to consumer. Sender objects are closed, receiver objects open unless they reject additional properties. Covers the Draft 7 subset with refs, cycles, nullable values, string enums, arrays, and integer ranges, rejects other constructs, and gives counterexamples. Property test over random subset schemas. --- interface/src/compat.rs | 550 +++++++++++++++++++++++++++++++++++++- interface/tests/compat.rs | 374 ++++++++++++++++++++++++++ 2 files changed, 922 insertions(+), 2 deletions(-) create mode 100644 interface/tests/compat.rs diff --git a/interface/src/compat.rs b/interface/src/compat.rs index 8bbff24..f7d1040 100644 --- a/interface/src/compat.rs +++ b/interface/src/compat.rs @@ -1,2 +1,548 @@ -//! The compatibility check between a consumer's import and a provider's -//! interface (PROPOSAL.md, section 11.2). +//! Checks imports against provider interfaces under PROTOCOL.md section 10.2. +//! Every sender value must fit the receiver schema. Sender objects are closed, +//! while receiver objects are open unless they reject additional properties. +use serde_json::{Map, Value, json}; +use std::{collections::HashSet, fmt}; + +/// One incompatible method, with a sender value when a finite example exists. +#[derive(Clone, Debug, PartialEq)] +pub struct Problem { + pub method: String, + pub message: String, + pub counterexample: Option, +} + +impl fmt::Display for Problem { + /// Formats the failure and identifies the receiver of its counterexample. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "- {}: {}.", self.method, self.message)?; + if let Some(value) = &self.counterexample { + write!( + formatter, + "\n Counterexample: {value} is not valid for {}.", + if self.message.starts_with("the provider's params") { + "the provider's params" + } else { + "the consumer's result" + } + )?; + } + Ok(()) + } +} + +/// Checks every imported method, including endpoint methods. Extra provider methods are harmless. +pub fn check(expected: &Value, actual: &Value) -> Vec { + let mut problems = Vec::new(); + methods(expected, actual, expected, actual, "", &mut problems); + if let Some(endpoints) = expected["x-sausage-endpoints"].as_object() { + for (name, interface) in endpoints { + methods( + interface, + &actual["x-sausage-endpoints"][name], + expected, + actual, + &format!("{name}."), + &mut problems, + ); + } + } + problems +} + +/// Builds an object schema from the OpenRPC by-name parameter list. +fn params(method: &Value) -> Value { + let mut properties = Map::new(); + let mut required = Vec::new(); + for param in method["params"].as_array().into_iter().flatten() { + let name = param["name"].as_str().unwrap_or_default(); + properties.insert(name.into(), param["schema"].clone()); + if param["required"] == true { + required.push(name); + } + } + json!({"type":"object", "properties":properties, "required":required}) +} + +/// Checks imported methods in both directions and records contextual diagnostics. +fn methods( + expected: &Value, + actual: &Value, + expected_doc: &Value, + actual_doc: &Value, + prefix: &str, + problems: &mut Vec, +) { + for method in expected["methods"].as_array().into_iter().flatten() { + let name = method["name"].as_str().unwrap_or_default(); + let label = format!("{prefix}{name}"); + let Some(provider) = actual["methods"] + .as_array() + .into_iter() + .flatten() + .find(|m| m["name"] == name) + else { + problems.push(Problem { + method: label, + message: "the provider has no such method".into(), + counterexample: None, + }); + continue; + }; + for (part, sender, receiver, sender_doc, receiver_doc) in [ + ( + "params", + params(method), + params(provider), + expected_doc, + actual_doc, + ), + ( + "result", + provider["result"]["schema"].clone(), + method["result"]["schema"].clone(), + actual_doc, + expected_doc, + ), + ] { + let failure = supported(&sender, sender_doc, &mut HashSet::new()) + .and_then(|()| supported(&receiver, receiver_doc, &mut HashSet::new())) + .err() + .map(|message| (message, None)) + .or_else(|| { + subset( + &sender, + &receiver, + sender_doc, + receiver_doc, + &mut HashSet::new(), + ) + .err() + }); + if let Some((message, counterexample)) = failure { + problems.push(Problem { + method: label.clone(), + message: { + let (sender_name, receiver_name) = if part == "params" { + ("consumer", "provider") + } else { + ("provider", "consumer") + }; + let detail = message + .replace("the receiver", &format!("the {receiver_name}")) + .replace("the sender", &format!("the {sender_name}")); + let detail = if part == "params" { + detail.replacen("the field", "the param", 1) + } else { + detail + }; + let verb = if part == "params" { "are" } else { "is" }; + format!("the {receiver_name}'s {part} {verb} incompatible: {detail}") + }, + counterexample, + }); + } + } + } +} + +/// Resolves references in one document and stops at reference cycles. +fn resolve<'document>(sender: &'document Value, document: &'document Value) -> &'document Value { + let mut sender = sender; + let mut seen = HashSet::new(); + while let Some(reference) = sender["$ref"].as_str() { + if !seen.insert(reference) { + break; + } + let Some(next) = reference + .strip_prefix('#') + .and_then(|p| document.pointer(p)) + else { + break; + }; + sender = next; + } + sender +} + +/// Rejects constructs outside the supported subset before comparison. +fn supported(sender: &Value, document: &Value, seen: &mut HashSet) -> Result<(), String> { + if !seen.insert(sender as *const Value as usize) { + return Ok(()); + } + if sender.is_boolean() { + return Ok(()); + } + let Some(object) = sender.as_object() else { + return Err("unsupported schema construct non-schema".into()); + }; + for (key, value) in object { + match key.as_str() { + "$ref" => { + let target = resolve(sender, document); + if std::ptr::eq(target, sender) { + return Err("unsupported schema construct unresolved $ref".into()); + } + supported(target, document, seen)?; + } + "properties" | "definitions" | "$defs" => { + for child in value.as_object().into_iter().flat_map(|m| m.values()) { + supported(child, document, seen)?; + } + } + "items" => supported(value, document, seen)?, + "anyOf" | "oneOf" => { + let branches = value + .as_array() + .ok_or_else(|| format!("unsupported schema construct {key}"))?; + if !branches.iter().all(|b| strings(b).is_some()) + && !(branches.len() == 2 && branches.iter().any(|b| b["type"] == "null")) + { + return Err(format!("unsupported schema construct {key}")); + } + for child in branches { + supported(child, document, seen)?; + } + } + "additionalProperties" if value.is_boolean() => (), + "enum" + if value + .as_array() + .is_some_and(|v| v.iter().all(Value::is_string)) => {} + "const" if value.is_string() => (), + "type" => { + let types: Vec<_> = value + .as_array() + .map(|actual| actual.iter().collect()) + .unwrap_or_else(|| vec![value]); + if !types.iter().all(|t| { + matches!( + t.as_str(), + Some( + "object" + | "array" + | "string" + | "integer" + | "number" + | "null" + | "boolean" + ) + ) + }) { + return Err("unsupported schema construct type".into()); + } + } + "required" | "minimum" | "maximum" | "title" | "description" | "default" + | "examples" | "format" | "$schema" | "$id" | "deprecated" | "readOnly" + | "writeOnly" => (), + _ => return Err(format!("unsupported schema construct {key}")), + } + } + Ok(()) +} + +/// Expands nullable schemas and string unions into individual alternatives. +fn alternatives(sender: &Value) -> Vec { + if let Some(types) = sender["type"].as_array() { + return types + .iter() + .map(|t| { + let mut alternative = sender.clone(); + alternative["type"] = t.clone(); + alternative + }) + .collect(); + } + for key in ["anyOf", "oneOf"] { + if let Some(branches) = sender[key].as_array() { + return branches.iter().flat_map(alternatives).collect(); + } + } + vec![sender.clone()] +} +/// Returns the primitive type, including the implicit type of string enums. +fn kind(sender: &Value) -> &str { + sender["type"].as_str().unwrap_or( + if sender["const"].is_string() || sender["enum"].is_array() { + "string" + } else { + "any" + }, + ) +} +/// Returns the finite string values of an enum or constant schema. +fn strings(sender: &Value) -> Option> { + sender["enum"] + .as_array() + .cloned() + .or_else(|| sender.get("const").map(|v| vec![v.clone()])) +} +/// Constructs a finite sender value, or returns None for an unavoidable cycle. +fn sample(sender: &Value, document: &Value, seen: &mut HashSet) -> Option { + let sender = resolve(sender, document); + if !seen.insert(sender as *const Value as usize) { + return None; + } + let result = alternatives(sender).into_iter().find_map(|sender| { + if sender == false { + return None; + } + if let Some(values) = strings(&sender) { + return values.first().cloned(); + } + Some(match kind(&sender) { + "object" => { + let mut object = Map::new(); + for key in sender["required"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + { + object.insert( + key.into(), + sample(&sender["properties"][key], document, seen)?, + ); + } + Value::Object(object) + } + "array" => json!([]), + "string" => json!("milk"), + "boolean" => json!(false), + "integer" | "number" => sender + .get("minimum") + .or(sender.get("maximum")) + .cloned() + .unwrap_or(json!(0)), + _ => Value::Null, + }) + }); + seen.remove(&(sender as *const Value as usize)); + result +} + +type Failure = (String, Option); +/// Checks sender inclusion in the receiver and assumes active reference pairs fit. +fn subset( + sender: &Value, + receiver: &Value, + sender_doc: &Value, + receiver_doc: &Value, + active: &mut HashSet<(usize, usize)>, +) -> Result<(), Failure> { + let (sender, receiver) = (resolve(sender, sender_doc), resolve(receiver, receiver_doc)); + let pair = ( + sender as *const Value as usize, + receiver as *const Value as usize, + ); + if !active.insert(pair) { + return Ok(()); + } + let result = (|| { + if sender == false || receiver == true { + return Ok(()); + } + let sender_alternatives: Vec<_> = alternatives(sender) + .into_iter() + .flat_map(|v| { + if let Some(values) = v["enum"].as_array() { + values.iter().map(|value| json!({"const": value})).collect() + } else { + vec![v] + } + }) + .collect(); + let mut receiver_alternatives = alternatives(receiver); + if receiver_alternatives.iter().all(|v| strings(v).is_some()) { + receiver_alternatives = vec![ + json!({"enum": receiver_alternatives.iter().flat_map(|v| strings(v).unwrap()).collect::>()}), + ]; + } + for sender_variant in &sender_alternatives { + let mut failure = None; + let mut accepted = false; + for receiver_variant in &receiver_alternatives { + match compare_variants( + sender_variant, + receiver_variant, + sender_doc, + receiver_doc, + active, + ) { + Ok(()) => { + accepted = true; + break; + } + Err(problem) => failure = Some(problem), + } + } + if !accepted { + return Err(failure.unwrap_or(( + "no receiver alternative accepts the sender value".into(), + sample(sender_variant, sender_doc, &mut HashSet::new()), + ))); + } + } + Ok(()) + })(); + active.remove(&pair); + result +} +/// Compares individual alternatives and wraps nested counterexamples in their parent values. +fn compare_variants( + sender: &Value, + receiver: &Value, + sender_doc: &Value, + receiver_doc: &Value, + active: &mut HashSet<(usize, usize)>, +) -> Result<(), Failure> { + if sender.get("$ref").is_some() || receiver.get("$ref").is_some() { + return subset(sender, receiver, sender_doc, receiver_doc, active); + } + let fail = |message: String| (message, sample(sender, sender_doc, &mut HashSet::new())); + let (sender_type, receiver_type) = (kind(sender), kind(receiver)); + if sender_type == "number" && receiver_type == "integer" { + let low = sender["minimum"] + .as_f64() + .unwrap_or_else(|| sender["maximum"].as_f64().map_or(0.0, |v| v.floor() - 1.0)); + let high = sender["maximum"].as_f64().unwrap_or(low + 2.0); + let value = if low.fract() != 0.0 { low } else { low + 0.5 }; + return Err(( + "the sender sends number, but the receiver expects integer".into(), + (value <= high).then(|| json!(value)), + )); + } + if receiver == false + || (receiver_type != "any" + && sender_type != receiver_type + && !(sender_type == "integer" && receiver_type == "number")) + { + return Err(fail(format!( + "the sender sends {sender_type}, but the receiver expects {receiver_type}" + ))); + } + if receiver_type == "any" { + return Ok(()); + } + match sender_type { + "object" => { + for key in receiver["required"].as_array().into_iter().flatten() { + if !sender["required"] + .as_array() + .is_some_and(|v| v.contains(key)) + { + return Err(fail(format!( + "the receiver requires the field {key} ({}), but the sender does not send it", + kind(resolve( + &receiver["properties"][key.as_str().unwrap_or_default()], + receiver_doc + )) + ))); + } + } + for (key, child) in sender["properties"] + .as_object() + .into_iter() + .flat_map(|m| m.iter()) + { + let error = if let Some(other) = receiver["properties"].get(key) { + subset(child, other, sender_doc, receiver_doc, active).err() + } else if receiver["additionalProperties"] == false { + Some(( + format!( + "the receiver rejects the field {key:?} ({}), which the sender can send", + kind(resolve(child, sender_doc)) + ), + sample(child, sender_doc, &mut HashSet::new()), + )) + } else { + None + }; + if let Some((message, example)) = error { + let mut object = sample(sender, sender_doc, &mut HashSet::new()); + if let (Some(Value::Object(object)), Some(value)) = (&mut object, example) { + object.insert(key.clone(), value); + } else { + object = None; + } + return Err(( + format!("the field {key:?} is incompatible: {message}"), + object, + )); + } + } + } + "array" => { + if let Err((message, example)) = subset( + sender.get("items").unwrap_or(&Value::Bool(true)), + receiver.get("items").unwrap_or(&Value::Bool(true)), + sender_doc, + receiver_doc, + active, + ) { + return Err((message, example.map(|v| json!([v])))); + } + } + "integer" | "number" => { + for (key, low) in [("minimum", true), ("maximum", false)] { + if let Some(bound) = receiver[key].as_f64() + && sender[key].as_f64().is_none_or(|v| { + let integer = |v: &Value| { + v.as_i64() + .map(i128::from) + .or_else(|| v.as_u64().map(i128::from)) + }; + if let (Some(sender), Some(receiver)) = + (integer(&sender[key]), integer(&receiver[key])) + { + if low { + sender < receiver + } else { + sender > receiver + } + } else if low { + v < bound + } else { + v > bound + } + }) + { + let witness = sender + .get(key) + .cloned() + .unwrap_or_else(|| json!(if low { bound - 1.0 } else { bound + 1.0 })); + return Err(( + format!( + "the sender sends {sender_type} outside the receiver's {key} of {}", + receiver[key] + ), + Some(witness), + )); + } + } + } + "string" => { + if let Some(allowed) = strings(receiver) { + let bad = match strings(sender) { + Some(values) => values.into_iter().find(|v| !allowed.contains(v)), + None => { + let mut text = "milk".to_string(); + while allowed.contains(&json!(text)) { + text.push('!'); + } + Some(json!(text)) + } + }; + if let Some(value) = bad { + return Err(( + "the sender sends a string variant outside the receiver's string enum" + .into(), + Some(value), + )); + } + } + } + _ => (), + } + Ok(()) +} diff --git a/interface/tests/compat.rs b/interface/tests/compat.rs new file mode 100644 index 0000000..6117d8b --- /dev/null +++ b/interface/tests/compat.rs @@ -0,0 +1,374 @@ +//! Draft 7 schemas and randomized subset values exercise the compatibility contract. +#![allow(dead_code)] +use proptest::prelude::*; +use sausage_interface::compat::check; +use schemars::{JsonSchema, generate::SchemaSettings}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +/// Generates a shared-definition schema under the selected serde contract. +fn schema(serialize: bool) -> Value { + let settings = SchemaSettings::draft07().with(|settings| { + settings.definitions_path = "/components/schemas/".into(); + }); + let mut generator = if serialize { + settings.for_serialize() + } else { + settings.for_deserialize() + } + .into_generator(); + let root = serde_json::to_value(generator.subschema_for::()).unwrap(); + let definitions = generator.take_definitions(true); + let mut root = root["$ref"] + .as_str() + .and_then(|reference| reference.strip_prefix("#/components/schemas/")) + .and_then(|name| definitions.get(name)) + .cloned() + .unwrap_or(root); + root["definitions"] = json!(definitions); + root +} + +/// Builds a method document with deserialize params and a serialize result. +fn doc() -> Value { + let params_schema = schema::(false); + let mut result_schema = schema::(true); + let mut definitions = params_schema["definitions"] + .as_object() + .cloned() + .unwrap_or_default(); + definitions.extend( + result_schema + .as_object_mut() + .unwrap() + .remove("definitions") + .unwrap() + .as_object() + .unwrap() + .clone(), + ); + let params: Vec<_> = params_schema["properties"].as_object().into_iter().flat_map(|properties| properties.iter()).map(|(name, schema)| { + json!({"name":name,"required":params_schema["required"].as_array().is_some_and(|required| required.contains(&json!(name))),"schema":schema}) + }).collect(); + json!({"methods":[{"name":"add","params":params,"result":{"name":"result","schema":result_schema}}],"components":{"schemas":definitions}}) +} +#[derive(JsonSchema, Serialize, Deserialize)] +struct Text { + text: String, +} +#[derive(JsonSchema, Serialize, Deserialize)] +struct Body { + body: String, +} +#[derive(JsonSchema, Serialize, Deserialize)] +struct Optional { + text: String, + extra: Option, +} +#[derive(JsonSchema, Serialize, Deserialize)] +struct Nullable { + text: Option, +} +#[derive(JsonSchema, Serialize, Deserialize)] +struct Defaulted { + text: String, + #[serde(default)] + extra: String, +} +#[derive(JsonSchema, Serialize, Deserialize)] +struct Nested { + value: Text, +} +#[derive(JsonSchema, Serialize, Deserialize)] +struct Recursive { + next: Option>, +} +#[derive(JsonSchema, Serialize, Deserialize)] +enum Small { + A, +} +#[derive(JsonSchema, Serialize, Deserialize)] +enum Large { + A, + /// The other variant. + B, +} + +/// A renamed required param produces a concrete diagnostic for the provider's params. +#[test] +fn compat_renamed_param() { + let failures = check(&doc::(), &doc::()); + assert_eq!(failures.len(), 1); + assert_eq!(failures[0].counterexample, Some(json!({"text":"milk"}))); + assert_eq!( + failures[0].to_string(), + "- add: the provider's params are incompatible: the provider requires the param \"body\" (string), but the consumer does not send it.\n Counterexample: {\"text\":\"milk\"} is not valid for the provider's params." + ); +} +/// The provider can add an optional param. +#[test] +fn compat_optional_param() { + assert!(check(&doc::(), &doc::()).is_empty()); +} +/// The provider can add a result field because the consumer accepts unknown fields. +#[test] +fn compat_added_result_field() { + assert!(check(&doc::(), &doc::()).is_empty()); +} +/// A removed method breaks its consumer. +#[test] +fn compat_removed_method() { + let expected = doc::(); + let mut actual = expected.clone(); + actual["methods"] = json!([]); + assert_eq!(check(&expected, &actual)[0].method, "add"); +} +/// A changed result type identifies the consumer as the receiver. +#[test] +fn compat_changed_type() { + let failures = check(&doc::(), &doc::()); + assert!( + failures[0] + .message + .contains("the provider sends integer, but the consumer expects string") + ); + assert!( + failures[0] + .to_string() + .ends_with("is not valid for the consumer's result.") + ); +} +/// A new required param rejects an existing consumer call. +#[test] +fn compat_required_param() { + let expected = doc::(); + let mut actual = expected.clone(); + actual["methods"][0]["params"] + .as_array_mut() + .unwrap() + .push(json!({"name":"extra","required":true,"schema":{"type":"string"}})); + assert!(!check(&expected, &actual).is_empty()); +} +/// Option fields permit omission and null, which a required string rejects. +#[test] +fn compat_option_fields() { + assert!(!check(&doc::(), &doc::()).is_empty()); +} +/// A serde default makes a new param optional for deserialization. +#[test] +fn compat_default_fields() { + assert!(check(&doc::(), &doc::()).is_empty()); +} +/// Nested struct definitions resolve within each document. +#[test] +fn compat_nested_refs() { + assert!(check(&doc::(), &doc::()).is_empty()); +} +/// Recursive references terminate through active schema pairs. +#[test] +fn compat_recursive_types() { + assert!( + check( + &doc::(), + &doc::() + ) + .is_empty() + ); +} +/// Documented enum variants support safe narrowing but reject new result variants. +#[test] +fn compat_enums() { + assert!(check(&doc::<(), Large>(), &doc::<(), Small>()).is_empty()); + assert!(!check(&doc::<(), Small>(), &doc::<(), Large>()).is_empty()); +} +/// Integer ranges preserve u8 versus u32 and exact bounds above the f64 integer range. +#[test] +fn compat_integer_ranges() { + assert!(check(&doc::<(), u32>(), &doc::<(), u8>()).is_empty()); + assert!(!check(&doc::<(), u8>(), &doc::<(), u32>()).is_empty()); + assert!(!fits( + json!({"type":"integer","maximum":9007199254740993u64}), + json!({"type":"integer","maximum":9007199254740992u64}) + )); +} +/// Endpoint methods use the same rules and include the endpoint name in problems. +#[test] +fn compat_endpoint_methods() { + let expected = json!({"methods":[],"x-sausage-endpoints":{"control":doc::()}}); + let mut actual = expected.clone(); + actual["x-sausage-endpoints"]["control"]["methods"] = json!([]); + assert_eq!(check(&expected, &actual)[0].method, "control.add"); +} +/// Unsupported constructs fail explicitly, including constructs behind references. +#[test] +fn compat_unsupported_construct() { + let mut actual = result_doc(json!({"$ref":"#/definitions/Text"})); + actual["definitions"] = json!({"Text":{"type":"string","pattern":"x"}}); + assert!( + check(&result_doc(json!({"type":"string"})), &actual)[0] + .message + .contains("unsupported schema construct pattern") + ); +} +/// Nullable union spellings accept the same values, but plain strings reject null. +#[test] +fn compat_nullable_unions() { + assert!(fits( + json!({"anyOf":[{"type":"string"},{"type":"null"}]}), + json!({"type":["string","null"]}) + )); + assert!(!fits( + json!({"type":["string","null"]}), + json!({"type":"string"}) + )); +} +/// Nested nullable unions still reject values outside every branch. +#[test] +fn compat_nested_nullable_unions() { + assert!(!fits( + json!({"type":"integer","minimum":0,"maximum":0}), + json!({"anyOf":[{"anyOf":[{"type":"string"},{"type":"null"}]},{"type":"null"}]}) + )); +} +/// A closed receiver rejects extra fields that an open receiver accepts. +#[test] +fn compat_closed_objects() { + let sender = json!({"type":"object","properties":{"extra":{"type":"string"}}}); + assert!(fits(sender.clone(), json!({"type":"object"}))); + assert!(!fits( + sender, + json!({"type":"object","additionalProperties":false}) + )); +} +/// Integers fit numbers, but numbers do not fit integers. +#[test] +fn compat_numeric_types() { + assert!(fits(json!({"type":"integer"}), json!({"type":"number"}))); + assert!(!fits(json!({"type":"number"}), json!({"type":"integer"}))); +} +/// Legacy definitions references resolve in the sender document. +#[test] +fn compat_definitions_ref() { + let mut actual = result_doc(json!({"$ref":"#/definitions/Text"})); + actual["definitions"] = json!({"Text":{"type":"string"}}); + assert!(check(&result_doc(json!({"type":"string"})), &actual).is_empty()); +} +/// Wraps one result schema in a minimal OpenRPC document. +fn result_doc(schema: Value) -> Value { + json!({"methods":[{"name":"value","params":[],"result":{"schema":schema}}]}) +} +/// Checks inclusion in the result direction. +fn fits(sender: Value, receiver: Value) -> bool { + check(&result_doc(receiver), &result_doc(sender)).is_empty() +} + +/// Generates bounded recursive schemas across the supported data shapes. +fn random_schema() -> BoxedStrategy { + let leaf = prop_oneof![ + Just(json!({"type":"string"})), + Just(json!({"type":"boolean"})), + Just(json!({"type":"null"})), + (-20i64..20, 0i64..40).prop_map( + |(minimum, width)| json!({"type":"integer","minimum":minimum,"maximum":minimum+width}) + ), + proptest::collection::vec("[a-c]{1,3}", 1..5) + .prop_map(|values| json!({"type":"string","enum":values})), + ]; + leaf.prop_recursive(3, 32, 4, |inner| prop_oneof![ + inner.clone().prop_map(|items|json!({"type":"array","items":items})), + inner.clone().prop_map(|schema|json!({"anyOf":[schema,{"type":"null"}]})), + (inner.clone(),inner,any::(),any::(),any::()).prop_map(|(first,second,require_first,require_second,closed)| { + let required: Vec<_> = [("first",require_first),("second",require_second)].into_iter().filter_map(|(name,required)|required.then_some(name)).collect(); + json!({"type":"object","properties":{"first":first,"second":second},"required":required,"additionalProperties":!closed}) + }), + ]).boxed() +} +/// Independently validates generated schemas with open receiver object semantics. +fn valid(schema: &Value, value: &Value) -> bool { + if let Some(branches) = schema["anyOf"].as_array() { + return branches.iter().any(|branch| valid(branch, value)); + } + if let Some(values) = schema["enum"].as_array() { + return values.contains(value); + } + match schema["type"].as_str().unwrap() { + "null" => value.is_null(), + "string" => value.is_string(), + "boolean" => value.is_boolean(), + "integer" => value.as_i64().is_some_and(|number| { + number >= schema["minimum"].as_i64().unwrap() + && number <= schema["maximum"].as_i64().unwrap() + }), + "array" => value + .as_array() + .is_some_and(|items| items.iter().all(|item| valid(&schema["items"], item))), + "object" => value.as_object().is_some_and(|object| { + schema["required"] + .as_array() + .unwrap() + .iter() + .all(|name| object.contains_key(name.as_str().unwrap())) + && object.iter().all(|(name, value)| { + schema["properties"] + .get(name) + .map_or(schema["additionalProperties"] != false, |child| { + valid(child, value) + }) + }) + }), + _ => unreachable!(), + } +} +/// Generates a valid sender value from random choices without discarding optional fields globally. +fn generate_value(schema: &Value, choices: &mut impl Iterator) -> Value { + let choice = choices.next().unwrap_or(0) as usize; + if let Some(branches) = schema["anyOf"].as_array() { + return generate_value(&branches[choice % branches.len()], choices); + } + if let Some(values) = schema["enum"].as_array() { + return values[choice % values.len()].clone(); + } + match schema["type"].as_str().unwrap() { + "null" => Value::Null, + "string" => json!(format!("text{choice}")), + "boolean" => json!(choice.is_multiple_of(2)), + "integer" => { + let minimum = schema["minimum"].as_i64().unwrap(); + let maximum = schema["maximum"].as_i64().unwrap(); + json!(minimum + choice as i64 % (maximum - minimum + 1)) + } + "array" => json!( + (0..choice % 4) + .map(|_| generate_value(&schema["items"], choices)) + .collect::>() + ), + "object" => { + let mut object = serde_json::Map::new(); + for (name, child) in schema["properties"].as_object().unwrap() { + if schema["required"] + .as_array() + .unwrap() + .contains(&json!(name)) + || choices.next().unwrap_or(0).is_multiple_of(2) + { + object.insert(name.clone(), generate_value(child, choices)); + } + } + Value::Object(object) + } + _ => unreachable!(), + } +} +proptest! { + #![proptest_config(ProptestConfig { failure_persistence: None, ..ProptestConfig::default() })] + /// Random supported schemas preserve every sampled sender value after successful inclusion checks. + #[test] + fn compat_subset_property(sender in random_schema(), independent_receiver in random_schema(), reuse_sender in any::(), choices in proptest::collection::vec(proptest::collection::vec(any::(),64..128),1..12)) { + let receiver = if reuse_sender { sender.clone() } else { independent_receiver }; + for choices in choices { + let value = generate_value(&sender,&mut choices.into_iter()); + prop_assert!(valid(&sender,&value)); + if fits(sender.clone(),receiver.clone()) { prop_assert!(valid(&receiver,&value), "sender={sender}, receiver={receiver}, value={value}"); } + } + } +}