From 9ca0364eb48f835d732f3580be49e6863cde31a9 Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Mon, 27 Jul 2026 14:21:28 +0800 Subject: [PATCH 1/8] feat(compatibility): align schema evolution with GTS 0.13 - Compare resolved accepted-instance sets and expose compatible, incompatible, or unknown verdicts with diagnostics. - Honor dialect-aware content models and boolean-equivalent schemas while preserving nested definitions. - Pin the conformance suite to v0.13.0. Signed-off-by: Aviator 5 --- .gts-spec-version | 2 +- README.md | 50 +- gts-id/src/gts_id_pattern.rs | 31 +- gts-macros/README.md | 40 + gts-macros/src/lib.rs | 295 ++- gts-macros/tests/inheritance_tests.rs | 111 ++ gts/src/lib.rs | 16 +- gts/src/ops.rs | 61 +- gts/src/schema_cast.rs | 2491 +++++++++++++++++++++---- gts/src/schema_compat.rs | 173 +- gts/src/schema_semantics.rs | 68 + gts/src/schema_traits.rs | 2 +- gts/src/store.rs | 253 ++- gts/src/store_test.rs | 361 +++- 14 files changed, 3486 insertions(+), 468 deletions(-) create mode 100644 gts/src/schema_semantics.rs diff --git a/.gts-spec-version b/.gts-spec-version index 60d68b2..6345c21 100644 --- a/.gts-spec-version +++ b/.gts-spec-version @@ -1 +1 @@ -v0.12.2 +v0.13.0 diff --git a/README.md b/README.md index efaf2cf..2827ef1 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ gts --path ../gts-spec/examples resolve-relationships --gts-id "gts.x.core.event #### OP#8 - Compatibility Checking -Verify that schemas with different MINOR versions are compatible. +Verify schema evolution using GTS 0.13 accepted-instance set inclusion. ```bash # Check compatibility between schema versions @@ -325,12 +325,14 @@ gts --path ../gts-spec/examples compatibility \ "added_properties": [], "removed_properties": [], "changed_properties": [], - "is_fully_compatible": true, - "is_backward_compatible": true, - "is_forward_compatible": true, + "full_compatibility": "compatible", + "backward_compatibility": "compatible", + "forward_compatibility": "compatible", "incompatibility_reasons": [], "backward_errors": [], - "forward_errors": [] + "forward_errors": [], + "specification_version": "0.13", + "implementation_version": "0.11.0" } ``` @@ -359,9 +361,9 @@ gts --path ../gts-spec/examples cast \ "direction": "unknown", "added_properties": ["payload.new_field_in_v1_1"], "removed_properties": [], - "is_fully_compatible": true, - "is_backward_compatible": true, - "is_forward_compatible": true, + "full_compatibility": "compatible", + "backward_compatibility": "compatible", + "forward_compatibility": "compatible", "casted_entity": { "id": "7a1d2f34-5678-49ab-9012-abcdef123456", "type": "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.1~", @@ -505,7 +507,7 @@ All operations are available through the `GtsOps` API. #### Setup ```rust -use gts::{GtsId, GtsOps, GtsConfig, GtsIdPattern}; +use gts::{CompatibilityVerdict, GtsConfig, GtsId, GtsIdPattern, GtsOps}; use serde_json::json; // Initialize GTS operations with data paths @@ -677,27 +679,21 @@ let result = ops.compatibility( ); // OP#8.1 - Backward compatibility -if result.is_backward_compatible { - println!("Old instances work with new schema"); -} else { - println!("Backward incompatible:"); - for error in result.backward_errors { - println!(" - {}", error); - } +match result.backward_compatibility { + CompatibilityVerdict::Compatible => println!("Old instances work with new schema"), + CompatibilityVerdict::Incompatible => println!("Known backward-incompatible"), + CompatibilityVerdict::Unknown => println!("Backward compatibility could not be determined"), } // OP#8.2 - Forward compatibility -if result.is_forward_compatible { - println!("New instances work with old schema"); -} else { - println!("Forward incompatible:"); - for error in result.forward_errors { - println!(" - {}", error); - } +match result.forward_compatibility { + CompatibilityVerdict::Compatible => println!("New instances work with old schema"), + CompatibilityVerdict::Incompatible => println!("Known forward-incompatible"), + CompatibilityVerdict::Unknown => println!("Forward compatibility could not be determined"), } // OP#8.3 - Full compatibility -if result.is_fully_compatible { +if result.full_compatibility.is_compatible() { println!("Fully compatible in both directions"); } ``` @@ -722,8 +718,8 @@ if let Some(casted) = result.casted_entity { } // Check compatibility -if !result.is_backward_compatible { - println!("Warning: Not backward compatible"); +if !result.backward_compatibility.is_compatible() { + println!("Warning: backward compatibility is not established"); for reason in result.incompatibility_reasons { println!(" - {}", reason); } @@ -834,7 +830,7 @@ fn main() -> Result<(), Box> { "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~", "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.1~" ); - println!("Backward compatible: {}", compat.is_backward_compatible); + println!("Backward compatible: {}", compat.backward_compatibility); // OP#9: Cast instance (instance identified by UUID) let cast = ops.cast( diff --git a/gts-id/src/gts_id_pattern.rs b/gts-id/src/gts_id_pattern.rs index 3bc0280..176d7e3 100644 --- a/gts-id/src/gts_id_pattern.rs +++ b/gts-id/src/gts_id_pattern.rs @@ -87,12 +87,21 @@ impl GtsIdPattern { /// [`GtsId::matches_pattern`]: crate::GtsId::matches_pattern pub(crate) fn matches_views(&self, candidate: &[C]) -> bool { let pattern_segs = &self.segments; - // If pattern is longer than candidate, no match - if pattern_segs.len() > candidate.len() { + // A final bare `~*` may match an empty chain suffix. A wildcard that + // already specifies part of the next segment (for example `~abc.*`) + // still requires that segment to exist. + let matches_empty_suffix = pattern_segs + .last() + .is_some_and(|seg| seg.is_wildcard() && seg.raw() == "*"); + let required_candidate_len = pattern_segs.len() - usize::from(matches_empty_suffix); + if required_candidate_len > candidate.len() { return false; } for (i, p_seg) in pattern_segs.iter().enumerate() { + if i == candidate.len() { + return matches_empty_suffix && i == pattern_segs.len() - 1; + } let c_seg = &candidate[i]; // If pattern segment is a wildcard, only its specified (non-empty) @@ -309,6 +318,24 @@ mod tests { assert!(instance_candidate.matches_pattern(&pattern)); } + #[test] + fn test_trailing_chain_wildcard_matches_empty_suffix() { + let pattern = GtsIdPattern::try_new(>s_id("x.core.events.topic.v1~*")).expect("test"); + let exact = GtsId::try_new(>s_id("x.core.events.topic.v1~")).expect("test"); + let specific_minor = GtsId::try_new(>s_id("x.core.events.topic.v1.1~")).expect("test"); + + assert!(exact.matches_pattern(&pattern)); + assert!(specific_minor.matches_pattern(&pattern)); + } + + #[test] + fn test_prefixed_chain_wildcard_requires_a_suffix() { + let pattern = GtsIdPattern::try_new(>s_id("x.core.events.topic.v1~abc.*")).expect("test"); + let base = GtsId::try_new(>s_id("x.core.events.topic.v1~")).expect("test"); + + assert!(!base.matches_pattern(&pattern)); + } + #[test] fn test_gts_wildcard_type_suffix() { // Wildcard after ~ should match type IDs diff --git a/gts-macros/README.md b/gts-macros/README.md index e2e8e38..225b366 100644 --- a/gts-macros/README.md +++ b/gts-macros/README.md @@ -102,6 +102,46 @@ pub struct MyStructV1 { ... } pub struct MyStructV1 { ... } ``` +### Ordinary Nested Data Structs and Content Models + +The macro emits Draft-07 schemas and preserves `definitions` for ordinary nested Rust structs. + +Under GTS 0.13, adding an optional field to an **open** object is not backward compatible: the +old schema already accepted arbitrary values under that property name, so declaring it narrows +the set of accepted instances (gts-spec §4.4–§4.5). Schemars leaves a nested struct's object +level open unless it declares `#[serde(deny_unknown_fields)]`, so the macro closes those levels +itself — nested types stay evolvable in place without changing how Serde deserializes them at +runtime. + +Levels the macro closes: + +- the document root of a base type, and the level carrying a derived type's own properties + (it always did this); +- every nested object level that declares `properties` and states no content model of its own. + +Levels the macro deliberately leaves alone: + +| Level | Why | +|---|---| +| Generic GTS extension slot | §4.4.1 requires it open so derived types can extend it | +| Map types (`HashMap`, `BTreeMap`) | already partially open via a schema-valued `additionalProperties` | +| A struct that flattens a map | Schemars emits `additionalProperties: true`; closing would be wrong | +| Branches of `allOf`/`anyOf`/`oneOf`/`not`/`if` | `additionalProperties` only sees `properties` from the same schema object, so closing a branch would reject the properties its siblings declare | + +To keep a nested level open on purpose — as a designated extension point in the sense of +§4.4.1 — state the content model explicitly and the macro will not touch it: + +```rust +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[schemars(extend("additionalProperties" = true))] +pub struct ExtensionPoint { + pub label: String, +} +``` + +`#[serde(deny_unknown_fields)]` also still works and is the right choice when the wire contract +should reject unknown fields at deserialization time as well, not just during schema validation. + ### What Gets Validated | Check | Description | diff --git a/gts-macros/src/lib.rs b/gts-macros/src/lib.rs index 9464f76..039b1e7 100644 --- a/gts-macros/src/lib.rs +++ b/gts-macros/src/lib.rs @@ -1463,6 +1463,221 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream quote! {} }; + // Keep only definitions that remain reachable after the macro rewrites + // property schemas (notably replacing a generic extension slot with a + // plain object schema). Draft-07 `definitions` must otherwise retain the + // concrete generic argument even though the generated GTS base schema no + // longer references it. + let inline_gts_id_definitions = quote! { + fn inline_gts_id_refs(value: &mut serde_json::Value) { + let reference = value + .get("$ref") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + match reference.as_deref() { + Some("#/$defs/GtsInstanceId" | "#/definitions/GtsInstanceId") => { + *value = ::gts::GtsInstanceId::json_schema_value(); + return; + } + Some( + "#/$defs/GtsTypeId" + | "#/$defs/GtsSchemaId" + | "#/definitions/GtsTypeId" + | "#/definitions/GtsSchemaId", + ) => { + *value = ::gts::GtsTypeId::json_schema_value(); + return; + } + _ => {} + } + + match value { + serde_json::Value::Object(object) => { + for nested in object.values_mut() { + inline_gts_id_refs(nested); + } + } + serde_json::Value::Array(values) => { + for nested in values { + inline_gts_id_refs(nested); + } + } + _ => {} + } + } + + inline_gts_id_refs(&mut properties); + }; + + let prune_unused_definitions = quote! { + if let Some(definitions_object) = + definitions.as_mut().and_then(serde_json::Value::as_object_mut) + { + fn collect_definition_refs( + value: &serde_json::Value, + referenced: &mut ::std::collections::HashSet, + ) { + match value { + serde_json::Value::Object(object) => { + if let Some(reference) = object.get("$ref").and_then(|v| v.as_str()) { + let name = reference + .strip_prefix("#/definitions/") + .or_else(|| reference.strip_prefix("#/$defs/")); + if let Some(name) = name.and_then(|name| name.split('/').next()) { + referenced.insert(name.replace("~1", "/").replace("~0", "~")); + } + } + for nested in object.values() { + collect_definition_refs(nested, referenced); + } + } + serde_json::Value::Array(values) => { + for nested in values { + collect_definition_refs(nested, referenced); + } + } + _ => {} + } + } + + let mut referenced = ::std::collections::HashSet::new(); + collect_definition_refs(&properties, &mut referenced); + loop { + let count = referenced.len(); + for name in referenced.clone() { + if let Some(definition) = definitions_object.get(&name) { + collect_definition_refs(definition, &mut referenced); + } + } + if referenced.len() == count { + break; + } + } + definitions_object.retain(|name, _| referenced.contains(name)); + } + }; + + // Close the object levels Schemars leaves open. + // + // Under GTS 0.13 an open object level cannot gain an optional property + // backward compatibly, because the old schema already accepted arbitrary + // values under that name (gts-spec sec 4.4-4.5). The macro already closes + // every level it builds itself - the document root of a base type and the + // level carrying a derived type's own properties - but property subschemas + // come from `schemars::JsonSchema`, which emits + // `additionalProperties: false` only for a struct declaring + // `#[serde(deny_unknown_fields)]`. Closing those levels here makes + // macro-generated types evolvable in place without asking every nested data + // struct to opt into strict Serde handling, which would also change + // deserialization at runtime. + // + // Deliberately skipped: + // + // * a level that already states its content model through + // `additionalProperties` or `unevaluatedProperties`. This is the opt-out: + // `#[schemars(extend("additionalProperties" = true))]` keeps a level open + // as an extension point, and Schemars already emits + // `additionalProperties: true` for a struct that flattens a map, where + // closing would be wrong. + // * a level carrying a combinator (`allOf`/`anyOf`/`oneOf`/`not`/`if`) and + // the immediate branches of one. `additionalProperties` only sees + // `properties` declared in the same schema object, so closing a branch + // would reject the properties its sibling branches declare. Schemars + // already closes the branches of an externally tagged enum itself. + // * the generic extension field, which this macro replaces with a bare + // `{"type": "object"}` before this pass runs and which sec 4.4.1 requires + // to stay open so derived types can extend it. + let close_nested_object_levels = quote! { + { + // Keyword classification, so that a property literally named + // `properties` is never mistaken for a schema keyword. + const COMBINATORS: &[&str] = + &["allOf", "anyOf", "oneOf", "not", "if", "then", "else"]; + const SINGLE_SCHEMA: &[&str] = &[ + "additionalProperties", + "unevaluatedProperties", + "additionalItems", + "contains", + "propertyNames", + "not", + "if", + "then", + "else", + ]; + const SCHEMA_MAP: &[&str] = &[ + "properties", + "patternProperties", + "definitions", + "$defs", + "dependentSchemas", + ]; + const SCHEMA_LIST: &[&str] = &["allOf", "anyOf", "oneOf", "prefixItems"]; + + fn close_schema(value: &mut serde_json::Value, is_combinator_branch: bool) { + let Some(object) = value.as_object_mut() else { + return; + }; + + let has_combinator = COMBINATORS + .iter() + .any(|keyword| object.contains_key(*keyword)); + let states_content_model = object.contains_key("additionalProperties") + || object.contains_key("unevaluatedProperties"); + if object + .get("properties") + .is_some_and(serde_json::Value::is_object) + && !states_content_model + && !has_combinator + && !is_combinator_branch + { + object.insert( + "additionalProperties".to_owned(), + serde_json::Value::Bool(false), + ); + } + + for (keyword, nested) in object.iter_mut() { + let branch = COMBINATORS.contains(&keyword.as_str()); + if SINGLE_SCHEMA.contains(&keyword.as_str()) { + close_schema(nested, branch); + } else if SCHEMA_MAP.contains(&keyword.as_str()) { + close_schema_map(nested, branch); + } else if SCHEMA_LIST.contains(&keyword.as_str()) { + close_schema_list(nested, branch); + } else if keyword == "items" { + // Draft-07 allows both the single-schema and the tuple form. + if nested.is_array() { + close_schema_list(nested, branch); + } else { + close_schema(nested, branch); + } + } + } + } + + fn close_schema_map(value: &mut serde_json::Value, is_combinator_branch: bool) { + if let Some(object) = value.as_object_mut() { + for nested in object.values_mut() { + close_schema(nested, is_combinator_branch); + } + } + } + + fn close_schema_list(value: &mut serde_json::Value, is_combinator_branch: bool) { + if let Some(values) = value.as_array_mut() { + for nested in values { + close_schema(nested, is_combinator_branch); + } + } + } + + close_schema_map(&mut properties, false); + if let Some(definitions) = definitions.as_mut() { + close_schema_map(definitions, false); + } + } + }; + let gts_schema_impl = if has_generic { let generic_param = input.generics.type_params().next().unwrap(); let generic_ident = &generic_param.ident; @@ -1491,7 +1706,9 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream // If inner is just {"type": "object"} (from ()), return our own schema // schemars RootSchema serializes at root level (not under "schema" field) if inner.get("properties").is_none() { - let root_schema = schemars::schema_for!(Self); + let root_schema = schemars::generate::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::(); return serde_json::to_value(&root_schema).expect("schemars"); } inner @@ -1545,10 +1762,13 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream }; // Get THIS struct's schema (schemars will expand generic fields automatically) - let root_schema = schemars::schema_for!(Self); + let root_schema = schemars::generate::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::(); let schema_val = serde_json::to_value(&root_schema).expect("schemars"); let mut properties = schema_val.get("properties").cloned().unwrap_or(serde_json::json!({})); let required = schema_val.get("required").cloned().unwrap_or(serde_json::json!([])); + let mut definitions = schema_val.get("definitions").cloned(); // Replace the generic field with a simple {"type": "object"} placeholder // The generic field should not be expanded, regardless of the concrete type parameter @@ -1568,16 +1788,21 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream // dangling. Inline the canonical schema fragment instead so the // generated document is self-contained (same fix as the // non-generic branch below). - if let Some(props_obj) = properties.as_object_mut() { - for (_key, value) in props_obj.iter_mut() { - if let Some(ref_str) = value.get("$ref").and_then(|v| v.as_str()) { - if ref_str == "#/$defs/GtsInstanceId" { - *value = gts::GtsInstanceId::json_schema_value(); - } else if ref_str == "#/$defs/GtsTypeId" || ref_str == "#/$defs/GtsSchemaId" { - *value = gts::GtsTypeId::json_schema_value(); - } - } - } + #inline_gts_id_definitions + #prune_unused_definitions + #close_nested_object_levels + let definitions_are_empty = if let Some(definitions_object) = + definitions.as_mut().and_then(serde_json::Value::as_object_mut) + { + definitions_object.remove("GtsInstanceId"); + definitions_object.remove("GtsTypeId"); + definitions_object.remove("GtsSchemaId"); + definitions_object.is_empty() + } else { + false + }; + if definitions_are_empty { + definitions = None; } // If no parent (base type), return simple schema without allOf @@ -1595,6 +1820,9 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream if !required.as_array().map(|a| a.is_empty()).unwrap_or(true) { schema["required"] = required; } + if let Some(definitions) = definitions { + schema["definitions"] = definitions; + } #inject_root_traits return schema; } @@ -1645,6 +1873,9 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream } ] }); + if let Some(definitions) = definitions { + schema["definitions"] = definitions; + } // Trait/modifier keywords go at the document top level, never in // the allOf overlay. #inject_root_traits @@ -1663,7 +1894,9 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream } fn innermost_schema() -> serde_json::Value { // Return this type's schemars schema (RootSchema serializes at root level) - let root_schema = schemars::schema_for!(Self); + let root_schema = schemars::generate::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::(); serde_json::to_value(&root_schema).expect("schemars") } fn gts_schema_with_refs_allof() -> serde_json::Value { @@ -1682,24 +1915,32 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream }; // Get this type's schemars schema (RootSchema serializes at root level) - let root_schema = schemars::schema_for!(Self); + let root_schema = schemars::generate::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::(); let schema_val = serde_json::to_value(&root_schema).expect("schemars"); let mut properties = schema_val.get("properties").cloned().unwrap_or_else(|| serde_json::json!({})); let required = schema_val.get("required").cloned().unwrap_or_else(|| serde_json::json!([])); + let mut definitions = schema_val.get("definitions").cloned(); // Resolve internal $ref references to GtsInstanceId and GtsTypeId at compile time // This is needed for schemas validated directly (not through GtsStore) // Runtime resolution in GtsStore::resolve_schema_refs provides additional coverage - if let Some(props_obj) = properties.as_object_mut() { - for (_key, value) in props_obj.iter_mut() { - if let Some(ref_str) = value.get("$ref").and_then(|v| v.as_str()) { - if ref_str == "#/$defs/GtsInstanceId" { - *value = gts::GtsInstanceId::json_schema_value(); - } else if ref_str == "#/$defs/GtsTypeId" || ref_str == "#/$defs/GtsSchemaId" { - *value = gts::GtsTypeId::json_schema_value(); - } - } - } + #inline_gts_id_definitions + #prune_unused_definitions + #close_nested_object_levels + let definitions_are_empty = if let Some(definitions_object) = + definitions.as_mut().and_then(serde_json::Value::as_object_mut) + { + definitions_object.remove("GtsInstanceId"); + definitions_object.remove("GtsTypeId"); + definitions_object.remove("GtsSchemaId"); + definitions_object.is_empty() + } else { + false + }; + if definitions_are_empty { + definitions = None; } // If no parent (base type), return simple schema without allOf @@ -1716,6 +1957,9 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream if !required.as_array().map(|a| a.is_empty()).unwrap_or(true) { schema["required"] = required; } + if let Some(definitions) = definitions { + schema["definitions"] = definitions; + } #inject_root_traits return schema; } @@ -1743,6 +1987,9 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream } ] }); + if let Some(definitions) = definitions { + schema["definitions"] = definitions; + } // Trait/modifier keywords go at the document top level, never in // the allOf overlay. #inject_root_traits diff --git a/gts-macros/tests/inheritance_tests.rs b/gts-macros/tests/inheritance_tests.rs index 747753d..9c74fd2 100644 --- a/gts-macros/tests/inheritance_tests.rs +++ b/gts-macros/tests/inheritance_tests.rs @@ -78,6 +78,55 @@ pub struct SimplePayloadV1 { pub severity: u8, } +#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +pub struct NestedContact { + pub email: String, +} + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.test.nested.definition.v1~"), + description = "Schema containing an ordinary nested Rust struct", + properties = "schema_type,contact" +)] +#[derive(Debug)] +pub struct SchemaWithNestedContactV1 { + #[serde(rename = "type")] + pub schema_type: GtsTypeId, + pub contact: NestedContact, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +#[schemars(extend("additionalProperties" = true))] +pub struct OpenExtensionPoint { + pub label: String, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +#[serde(untagged)] +pub enum UntaggedChoice { + First { a: String }, + Second { b: String }, +} + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.test.nested.content_model.v1~"), + description = "Schema exercising nested content-model closure", + properties = "schema_type,contact,extension_point,choice,labels" +)] +#[derive(Debug)] +pub struct SchemaWithNestedContentModelV1 { + #[serde(rename = "type")] + pub schema_type: GtsTypeId, + pub contact: NestedContact, + pub extension_point: OpenExtensionPoint, + pub choice: UntaggedChoice, + pub labels: std::collections::HashMap, +} + /* ============================================================ Base struct ID field validation tests ============================================================ */ @@ -396,6 +445,68 @@ mod tests { ); } + #[test] + fn test_ordinary_nested_struct_keeps_draft_07_definition() { + let schema = SchemaWithNestedContactV1::gts_schema_with_refs(); + assert_eq!( + schema.get("$schema").and_then(serde_json::Value::as_str), + Some(gts::JSON_SCHEMA_DRAFT_07) + ); + assert_eq!( + schema + .pointer("/properties/contact/$ref") + .and_then(serde_json::Value::as_str), + Some("#/definitions/NestedContact") + ); + assert!( + schema.pointer("/definitions/NestedContact").is_some(), + "nested definition is missing:\n{}", + serde_json::to_string_pretty(&schema).unwrap() + ); + jsonschema::validator_for(&schema).expect("emitted Draft-07 schema must compile"); + } + + /// Nested object levels are closed so that a later definition of the type + /// can add an optional property backward compatibly (gts-spec sec 4.4-4.5), + /// while the levels where closing would be wrong are left alone. + #[test] + fn test_nested_object_levels_are_closed_except_where_unsafe() { + let schema = SchemaWithNestedContentModelV1::gts_schema_with_refs(); + let additional = |pointer: &str| { + schema + .pointer(pointer) + .unwrap_or_else(|| panic!("missing level '{pointer}' in {schema}")) + .get("additionalProperties") + .cloned() + }; + + // An ordinary nested struct is closed, so it stays evolvable in place. + assert_eq!( + additional("/definitions/NestedContact"), + Some(serde_json::json!(false)) + ); + + // `#[schemars(extend(...))]` is the per-level opt-out for a deliberate + // extension point. + assert_eq!( + additional("/definitions/OpenExtensionPoint"), + Some(serde_json::json!(true)) + ); + + // Closing an `anyOf` branch would reject the properties its sibling + // branches declare, so combinator branches are left untouched. + assert_eq!(additional("/definitions/UntaggedChoice/anyOf/0"), None); + assert_eq!(additional("/definitions/UntaggedChoice/anyOf/1"), None); + + // A map level is partially open; its existing constraint is preserved. + assert_eq!( + additional("/properties/labels"), + Some(serde_json::json!({"type": "string"})) + ); + + jsonschema::validator_for(&schema).expect("emitted Draft-07 schema must compile"); + } + #[test] fn test_schema_inheritance() { // Only base type can access schema methods directly diff --git a/gts/src/lib.rs b/gts/src/lib.rs index 0e3b698..716aa16 100644 --- a/gts/src/lib.rs +++ b/gts/src/lib.rs @@ -10,12 +10,19 @@ pub mod schema_modifiers; pub mod schema_narrow; pub mod schema_refs; pub mod schema_resolver; +mod schema_semantics; pub mod schema_traits; pub mod store; #[doc(hidden)] pub mod testing; pub mod x_gts_ref; +/// GTS specification revision implemented by compatibility and validation logic. +pub const GTS_SPECIFICATION_VERSION: &str = "0.13"; + +/// Version of this Rust implementation. +pub const GTS_IMPLEMENTATION_VERSION: &str = env!("CARGO_PKG_VERSION"); + // Re-export commonly used types pub use entities::{GtsConfig, GtsEntity, GtsFile, ValidationError, ValidationResult}; pub use files_reader::GtsFileReader; @@ -33,9 +40,14 @@ pub use schema::{ GtsSerialize, GtsSerializeWrapper, JSON_SCHEMA_DRAFT_07, TraitSchemaState, deserialize_gts, serialize_gts, strip_schema_metadata, }; -pub use schema_cast::{GtsEntityCastResult, SchemaCastError}; +pub use schema_cast::{ + CompatibilityDiagnostic, CompatibilityFinding, CompatibilityVerdict, ContentModel, + GtsEntityCastResult, ObjectLevel, SchemaCastError, +}; pub use schema_narrow::{NarrowError, try_narrow}; pub use schema_refs::{ExtractRefsError, InvalidRefReason, extract_gts_refs}; pub use schema_traits::{GtsTraitsSchema, inline_traits_schema_of}; -pub use store::{GtsReader, GtsStore, GtsStoreQueryResult, ResolvedType, StoreError}; +pub use store::{ + GtsReader, GtsStore, GtsStoreQueryResult, ResolvedType, SchemaComparison, StoreError, +}; pub use x_gts_ref::{XGtsRefValidationError, XGtsRefValidator}; diff --git a/gts/src/ops.rs b/gts/src/ops.rs index 97391a5..f80bd5e 100644 --- a/gts/src/ops.rs +++ b/gts/src/ops.rs @@ -8,7 +8,7 @@ use crate::entities::{GtsConfig, GtsEntity}; use crate::files_reader::GtsFileReader; use crate::gts::{GtsId, GtsIdPattern}; use crate::path_resolver::JsonPathResolver; -use crate::schema_cast::GtsEntityCastResult; +use crate::schema_cast::{CompatibilityVerdict, GtsEntityCastResult}; use crate::store::{GtsStore, GtsStoreQueryResult}; /// `is_schema` is `Some(true)` for schema/type IDs (ending with `~`), @@ -673,7 +673,7 @@ impl GtsOps { } pub fn compatibility(&mut self, old_type_id: &str, new_type_id: &str) -> GtsEntityCastResult { - self.store.is_minor_compatible(old_type_id, new_type_id) + self.store.is_compatible(old_type_id, new_type_id) } pub fn cast(&mut self, from_id: &str, to_type_id: &str) -> GtsEntityCastResult { @@ -688,12 +688,14 @@ impl GtsOps { added_properties: Vec::new(), removed_properties: Vec::new(), changed_properties: Vec::new(), - is_fully_compatible: false, - is_backward_compatible: false, - is_forward_compatible: false, + full_compatibility: CompatibilityVerdict::Unknown, + backward_compatibility: CompatibilityVerdict::Unknown, + forward_compatibility: CompatibilityVerdict::Unknown, incompatibility_reasons: Vec::new(), backward_errors: Vec::new(), forward_errors: Vec::new(), + specification_version: crate::GTS_SPECIFICATION_VERSION.to_owned(), + implementation_version: crate::GTS_IMPLEMENTATION_VERSION.to_owned(), casted_entity: None, error: Some(e.to_string()), }, @@ -1828,12 +1830,14 @@ mod tests { added_properties: vec!["email".to_owned()], removed_properties: vec![], changed_properties: vec![], - is_fully_compatible: true, - is_backward_compatible: true, - is_forward_compatible: false, + full_compatibility: CompatibilityVerdict::Incompatible, + backward_compatibility: CompatibilityVerdict::Compatible, + forward_compatibility: CompatibilityVerdict::Incompatible, incompatibility_reasons: vec![], backward_errors: vec![], forward_errors: vec![], + specification_version: crate::GTS_SPECIFICATION_VERSION.to_owned(), + implementation_version: crate::GTS_IMPLEMENTATION_VERSION.to_owned(), casted_entity: Some(json!({"name": "test"})), error: None, }; @@ -2105,7 +2109,7 @@ mod tests { let (is_backward, backward_errors) = GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); - assert!(!is_backward); + assert!(is_backward.is_incompatible()); assert!(!backward_errors.is_empty()); } @@ -2138,9 +2142,9 @@ mod tests { let (is_forward, _) = GtsEntityCastResult::check_forward_compatibility(&old_schema, &new_schema); - // Adding enum values is not backward compatible but is forward compatible - assert!(!is_backward); - assert!(is_forward); + // Expanding the accepted set is backward compatible, not forward compatible. + assert!(is_backward.is_compatible()); + assert!(is_forward.is_incompatible()); } #[test] @@ -2171,7 +2175,7 @@ mod tests { let (is_backward, backward_errors) = GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); - assert!(!is_backward); + assert!(is_backward.is_incompatible()); assert!(!backward_errors.is_empty()); } @@ -2203,7 +2207,7 @@ mod tests { let (is_backward, _) = GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); - assert!(!is_backward); + assert!(is_backward.is_incompatible()); } #[test] @@ -2234,7 +2238,7 @@ mod tests { let (is_backward, _) = GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); - assert!(!is_backward); + assert!(is_backward.is_incompatible()); } #[test] @@ -2260,7 +2264,7 @@ mod tests { let (is_backward, _) = GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); - assert!(!is_backward); + assert!(is_backward.is_incompatible()); } #[test] @@ -2286,7 +2290,7 @@ mod tests { let (is_forward, _) = GtsEntityCastResult::check_forward_compatibility(&old_schema, &new_schema); - assert!(!is_forward); + assert!(is_forward.is_incompatible()); } #[test] @@ -2313,7 +2317,7 @@ mod tests { let (is_forward, forward_errors) = GtsEntityCastResult::check_forward_compatibility(&old_schema, &new_schema); - assert!(!is_forward); + assert!(is_forward.is_incompatible()); assert!(!forward_errors.is_empty()); } @@ -2341,10 +2345,13 @@ mod tests { } }); - let (is_forward, forward_errors) = + let (is_backward, backward_errors) = + GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); + let (is_forward, _) = GtsEntityCastResult::check_forward_compatibility(&old_schema, &new_schema); - assert!(!is_forward); - assert!(!forward_errors.is_empty()); + assert!(is_backward.is_incompatible()); + assert!(!backward_errors.is_empty()); + assert!(is_forward.is_compatible()); } // Additional ops.rs coverage tests @@ -2692,8 +2699,8 @@ mod tests { "gts.vendor.package.namespace.type.v1.1~", ); - // Adding optional property is backward compatible - assert!(result.is_backward_compatible); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); } // Additional entities.rs coverage tests @@ -3291,14 +3298,14 @@ mod tests { #[test] fn test_validate_id_with_wildcard_schema() { // Test wildcard validation for pattern matching instances of a schema - // Note: gts.vendor.package.namespace.type.v1~* matches instances, not schemas + // A wildcard pattern is not itself a canonical type identifier. let result = GtsOps::validate_id("gts.vendor.package.namespace.type.v1~*"); assert!(result.valid, "Wildcard at end of schema should be valid"); assert!(result.is_wildcard); assert_eq!( result.is_type, Some(false), - "Pattern matches instances, not schemas" + "A wildcard pattern is not itself a canonical type identifier" ); } @@ -3358,7 +3365,7 @@ mod tests { #[test] fn test_parse_id_with_wildcard_schema() { // Test parse_id with wildcard pattern matching instances of a schema - // Note: gts.vendor.package.namespace.type.v1~* matches instances, not schemas + // A wildcard pattern is not itself a canonical type identifier. let result = GtsOps::parse_id("gts.vendor.package.namespace.type.v1~*"); assert!(result.ok, "Parsing valid wildcard should succeed"); assert!(result.is_wildcard); @@ -3380,7 +3387,7 @@ mod tests { assert_eq!( result.is_type, Some(false), - "Pattern matches instances, not schemas" + "A wildcard pattern is not itself a canonical type identifier" ); } diff --git a/gts/src/schema_cast.rs b/gts/src/schema_cast.rs index 0489e0f..c1cf81f 100644 --- a/gts/src/schema_cast.rs +++ b/gts/src/schema_cast.rs @@ -3,7 +3,76 @@ use serde_json::{Map, Value}; use std::collections::{HashMap, HashSet}; use thiserror::Error; -use crate::gts::GtsId; +use crate::{gts::GtsId, schema_semantics::boolean_schema_value}; + +/// Result of attempting to establish one schema-compatibility relation. +/// +/// `Unknown` is deliberately distinct from `Incompatible`: it means the +/// checker could not prove or disprove the required accepted-instance-set +/// inclusion. The caller, not this library, decides how that affects admission. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompatibilityVerdict { + Compatible, + Incompatible, + #[default] + Unknown, +} + +impl CompatibilityVerdict { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Compatible => "compatible", + Self::Incompatible => "incompatible", + Self::Unknown => "unknown", + } + } + + #[must_use] + pub const fn is_compatible(self) -> bool { + matches!(self, Self::Compatible) + } + + #[must_use] + pub const fn is_incompatible(self) -> bool { + matches!(self, Self::Incompatible) + } + + #[must_use] + pub const fn is_unknown(self) -> bool { + matches!(self, Self::Unknown) + } + + /// Derives full compatibility from the two directional verdicts. + #[must_use] + pub const fn full(backward: Self, forward: Self) -> Self { + match (backward, forward) { + (Self::Compatible, Self::Compatible) => Self::Compatible, + (Self::Incompatible, _) | (_, Self::Incompatible) => Self::Incompatible, + _ => Self::Unknown, + } + } + + fn from_diagnostics(diagnostics: &[CompatibilityDiagnostic]) -> Self { + if diagnostics.is_empty() { + Self::Compatible + } else if diagnostics + .iter() + .all(CompatibilityDiagnostic::is_inconclusive) + { + Self::Unknown + } else { + Self::Incompatible + } + } +} + +impl std::fmt::Display for CompatibilityVerdict { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} #[derive(Debug, Error)] pub enum SchemaCastError { @@ -19,7 +88,6 @@ pub enum SchemaCastError { CastError(String), } -#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GtsEntityCastResult { #[serde(rename = "from")] @@ -32,17 +100,316 @@ pub struct GtsEntityCastResult { pub added_properties: Vec, pub removed_properties: Vec, pub changed_properties: Vec>, - pub is_fully_compatible: bool, - pub is_backward_compatible: bool, - pub is_forward_compatible: bool, + pub full_compatibility: CompatibilityVerdict, + pub backward_compatibility: CompatibilityVerdict, + pub forward_compatibility: CompatibilityVerdict, pub incompatibility_reasons: Vec, pub backward_errors: Vec, pub forward_errors: Vec, + #[serde(default = "specification_version")] + pub specification_version: String, + #[serde(default = "implementation_version")] + pub implementation_version: String, pub casted_entity: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } +fn specification_version() -> String { + crate::GTS_SPECIFICATION_VERSION.to_owned() +} + +fn implementation_version() -> String { + crate::GTS_IMPLEMENTATION_VERSION.to_owned() +} + +/// Content model of one object level of a **resolved** effective schema. +/// +/// Classified per gts-spec §4.4, which requires the level to be judged after +/// `$ref` resolution and `allOf` composition rather than from a single authored +/// keyword. Use [`GtsEntityCastResult::classify_object_levels`] to obtain the +/// classification of every level of a document. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContentModel { + /// Accepts an undeclared property with any value. + Open, + /// Rejects every undeclared property. + Closed, + /// Accepts some undeclared property names, or constrains their values - for + /// example through a nontrivial schema-valued `additionalProperties`, + /// `patternProperties`, or `propertyNames`. + Partial, +} + +impl ContentModel { + const fn label(self) -> &'static str { + match self { + Self::Open => "open", + Self::Closed => "closed", + Self::Partial => "partially open", + } + } + + /// Whether a later definition may add an optional property at this level + /// and stay backward compatible. + /// + /// Only a closed level can: an open level already accepted arbitrary values + /// under the new property name, so declaring it narrows the accepted set + /// (§4.4). For a partially open level the answer depends on the constraint + /// that governs undeclared properties, so it is reported as not evolvable + /// rather than guessed. + #[must_use] + pub const fn is_evolvable_in_place(self) -> bool { + matches!(self, Self::Closed) + } +} + +impl std::fmt::Display for ContentModel { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.label()) + } +} + +/// One object level of a resolved schema, with its content model. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ObjectLevel { + /// Location of the level, `$` for the document root and dotted segments + /// below it, for example `$.payload` or `$.items[]`. + pub path: String, + /// How this level treats undeclared properties. + pub content_model: ContentModel, +} + +/// Machine-readable kind of a [`CompatibilityDiagnostic`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompatibilityFinding { + /// A property was declared at a level whose content model does not permit + /// the addition in this direction. + PropertyAdded, + /// A property declaration was dropped at a level whose content model does + /// not permit the removal in this direction. + PropertyRemoved, + /// The set of `required` properties changed. + RequiredChanged, + /// The content model of an object level changed. + ContentModelChanged, + /// The set of permitted `type` values is not an inclusion in this direction. + TypeChanged, + /// The `enum` constraint is not an inclusion in this direction. + EnumChanged, + /// A numeric bound moved in the direction this mode forbids. + BoundChanged, + /// A keyword that only narrows was added or removed. + NarrowingConstraintChanged, + /// A keyword whose values cannot be ordered by inclusion changed. + ConstraintChanged, + /// The declared JSON Schema dialect changed, so this checker cannot compare + /// the two documents under one stable set of keyword semantics. + DialectChanged, + /// Inclusion could not be established either way - an unresolved `$ref`, an + /// `allOf` intersection the checker cannot prove, a partially open level, or + /// two values of one keyword that this implementation cannot order. It is + /// reported distinctly so callers can apply their own admission policy. + NotProvable, +} + +/// Evidence explaining an incompatible or unknown directional verdict. +/// +/// Carries the schema location separately from the prose so that a caller can +/// report per object level without parsing the message. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompatibilityDiagnostic { + /// Location of the offending schema node, in the form used by + /// [`ObjectLevel::path`]. + pub path: String, + /// What kind of finding this is. + pub finding: CompatibilityFinding, + /// Human-readable detail, without the location prefix. + pub detail: String, +} + +impl CompatibilityDiagnostic { + fn new(path: &str, finding: CompatibilityFinding, detail: String) -> Self { + Self { + path: path.to_owned(), + finding, + detail, + } + } + + const fn is_inconclusive(&self) -> bool { + matches!( + self.finding, + CompatibilityFinding::NotProvable | CompatibilityFinding::DialectChanged + ) + } +} + +impl std::fmt::Display for CompatibilityDiagnostic { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "Schema at '{}' {}", self.path, self.detail) + } +} + +const UNPROVEN_INTERSECTION: &str = "x-gts-internal-unproven-intersection"; + +fn merge_schema_map(target: &mut Map, candidate: &Map) { + const ANNOTATIONS: &[&str] = &[ + "$id", + "$schema", + "title", + "description", + "default", + "examples", + "readOnly", + "writeOnly", + "deprecated", + "definitions", + "$defs", + "x-gts-abstract", + "x-gts-final", + "x-gts-traits", + "x-gts-traits-schema", + ]; + const MINIMUMS: &[&str] = &[ + "minimum", + "exclusiveMinimum", + "minLength", + "minItems", + "minProperties", + "minContains", + ]; + const MAXIMUMS: &[&str] = &[ + "maximum", + "exclusiveMaximum", + "maxLength", + "maxItems", + "maxProperties", + "maxContains", + ]; + + for (keyword, candidate_value) in candidate { + if ANNOTATIONS.contains(&keyword.as_str()) { + target.insert(keyword.clone(), candidate_value.clone()); + continue; + } + let Some(current) = target.get_mut(keyword) else { + target.insert(keyword.clone(), candidate_value.clone()); + continue; + }; + if current == candidate_value { + continue; + } + + match keyword.as_str() { + "properties" | "patternProperties" => { + if let (Some(current_map), Some(candidate_map)) = + (current.as_object_mut(), candidate_value.as_object()) + { + for (name, candidate_schema) in candidate_map { + if let Some(current_schema) = current_map.get_mut(name) { + merge_schema_intersection(current_schema, candidate_schema); + } else { + current_map.insert(name.clone(), candidate_schema.clone()); + } + } + } else { + record_unproven_intersection( + target, + format!("'{keyword}' has incompatible representations"), + ); + } + } + "required" => { + if let (Some(current_items), Some(candidate_items)) = + (current.as_array_mut(), candidate_value.as_array()) + { + for item in candidate_items { + if !current_items.contains(item) { + current_items.push(item.clone()); + } + } + } + } + "additionalProperties" + | "unevaluatedProperties" + | "items" + | "propertyNames" + | "contains" => merge_schema_intersection(current, candidate_value), + "enum" => { + if let (Some(current_values), Some(candidate_values)) = + (current.as_array_mut(), candidate_value.as_array()) + { + current_values.retain(|value| candidate_values.contains(value)); + if current_values.is_empty() { + record_unproven_intersection( + target, + "allOf enum intersection is empty".to_owned(), + ); + } + } + } + keyword if MINIMUMS.contains(&keyword) => { + if candidate_value.as_f64() > current.as_f64() { + *current = candidate_value.clone(); + } + } + keyword if MAXIMUMS.contains(&keyword) => { + if candidate_value.as_f64() < current.as_f64() { + *current = candidate_value.clone(); + } + } + "type" => { + if current.as_str() == Some("number") && candidate_value.as_str() == Some("integer") + { + *current = candidate_value.clone(); + } else if !(current.as_str() == Some("integer") + && candidate_value.as_str() == Some("number")) + { + let reason = format!( + "allOf has incompatible type constraints {current} and {candidate_value}" + ); + record_unproven_intersection(target, reason); + } + } + _ => record_unproven_intersection( + target, + format!("allOf has differing '{keyword}' constraints"), + ), + } + } +} + +fn merge_schema_intersection(target: &mut Value, candidate: &Value) { + match (&mut *target, candidate) { + (Value::Bool(false), _) | (_, Value::Bool(true)) => {} + (Value::Bool(true), value) => *target = value.clone(), + (_, Value::Bool(false)) => *target = Value::Bool(false), + (Value::Object(target_map), Value::Object(candidate_map)) => { + merge_schema_map(target_map, candidate_map); + } + _ => { + *target = Value::Object(Map::from_iter([( + UNPROVEN_INTERSECTION.to_owned(), + Value::Array(vec![target.clone(), candidate.clone()]), + )])); + } + } +} + +fn record_unproven_intersection(schema: &mut Map, reason: String) { + let marker = schema + .entry(UNPROVEN_INTERSECTION) + .or_insert_with(|| Value::Array(Vec::new())); + if let Some(reasons) = marker.as_array_mut() { + reasons.push(Value::String(reason)); + } else { + *marker = Value::Array(vec![Value::String(reason)]); + } +} + impl GtsEntityCastResult { /// Casts an instance from one schema to another. /// @@ -66,10 +433,12 @@ impl GtsEntityCastResult { let (old_schema, new_schema) = (from_schema_content, to_schema_content); // Check compatibility - let (is_backward, backward_errors) = + let (backward_compatibility, backward_errors) = Self::check_backward_compatibility(old_schema, new_schema); - let (is_forward, forward_errors) = + let (forward_compatibility, forward_errors) = Self::check_forward_compatibility(old_schema, new_schema); + let full_compatibility = + CompatibilityVerdict::full(backward_compatibility, forward_compatibility); // Apply casting rules to the instance let instance_obj = from_instance_content @@ -89,20 +458,20 @@ impl GtsEntityCastResult { added_properties: Vec::new(), removed_properties: Vec::new(), changed_properties: Vec::new(), - is_fully_compatible: false, - is_backward_compatible: is_backward, - is_forward_compatible: is_forward, + full_compatibility, + backward_compatibility, + forward_compatibility, incompatibility_reasons: vec![e.to_string()], backward_errors, forward_errors, + specification_version: specification_version(), + implementation_version: implementation_version(), casted_entity: None, error: None, }); } }; - // Validate the transformed instance against the FULL target schema - let is_fully_compatible = true; // Simplified for now let reasons = incompatibility_reasons; // TODO: Add full jsonschema validation with GTS ID tolerance @@ -124,12 +493,14 @@ impl GtsEntityCastResult { added_properties: added_sorted, removed_properties: removed_sorted, changed_properties: Vec::new(), - is_fully_compatible, - is_backward_compatible: is_backward, - is_forward_compatible: is_forward, + full_compatibility, + backward_compatibility, + forward_compatibility, incompatibility_reasons: reasons, backward_errors, forward_errors, + specification_version: specification_version(), + implementation_version: implementation_version(), casted_entity: Some(Value::Object(casted)), error: None, }) @@ -347,78 +718,73 @@ impl GtsEntityCastResult { #[must_use] pub fn flatten_schema(schema: &Value) -> Value { - let mut result = Map::new(); - result.insert("properties".to_owned(), Value::Object(Map::new())); - result.insert("required".to_owned(), Value::Array(Vec::new())); - - if let Some(obj) = schema.as_object() { - // Merge allOf schemas - if let Some(all_of) = obj.get("allOf") - && let Some(arr) = all_of.as_array() - { - for sub_schema in arr { - let flattened = Self::flatten_schema(sub_schema); - if let Some(flat_obj) = flattened.as_object() { - // Merge properties - if let Some(props) = flat_obj.get("properties") - && let Some(props_obj) = props.as_object() - && let Some(result_props) = - result.get_mut("properties").and_then(|p| p.as_object_mut()) - { - for (k, v) in props_obj { - result_props.insert(k.clone(), v.clone()); - } - } - // Merge required - if let Some(req) = flat_obj.get("required") - && let Some(req_arr) = req.as_array() - && let Some(result_req) = - result.get_mut("required").and_then(|r| r.as_array_mut()) - { - result_req.extend(req_arr.clone()); - } - // Preserve additionalProperties - if let Some(additional) = flat_obj.get("additionalProperties") { - result.insert("additionalProperties".to_owned(), additional.clone()); - } - } - } - } - - // Add direct properties and required - if let Some(props) = obj.get("properties") - && let Some(props_obj) = props.as_object() - && let Some(result_props) = - result.get_mut("properties").and_then(|p| p.as_object_mut()) - { - for (k, v) in props_obj { - result_props.insert(k.clone(), v.clone()); - } - } - if let Some(req) = obj.get("required") - && let Some(req_arr) = req.as_array() - && let Some(result_req) = result.get_mut("required").and_then(|r| r.as_array_mut()) - { - result_req.extend(req_arr.clone()); - } - // Preserve additionalProperties from top level - if let Some(additional) = obj.get("additionalProperties") { - result.insert("additionalProperties".to_owned(), additional.clone()); + let Some(schema_map) = schema.as_object() else { + return schema.clone(); + }; + let mut result = Value::Bool(true); + if let Some(all_of) = schema_map.get("allOf").and_then(Value::as_array) { + for branch in all_of { + merge_schema_intersection(&mut result, &Self::flatten_schema(branch)); } } + let direct = Value::Object( + schema_map + .iter() + .filter(|(keyword, _)| keyword.as_str() != "allOf") + .map(|(keyword, value)| (keyword.clone(), value.clone())) + .collect(), + ); + merge_schema_intersection(&mut result, &direct); + result + } - Value::Object(result) + /// Reports a bound keyword whose value is present but not a number. + /// + /// Draft-04 spells `exclusiveMinimum`/`exclusiveMaximum` as booleans that + /// modify `minimum`/`maximum`, so a numeric comparison would silently ignore + /// them. Fall back to exact equality for any non-numeric value rather than + /// guessing which direction it widens. + fn check_non_numeric_bound( + path: &str, + old_schema: &Map, + new_schema: &Map, + key: &str, + ) -> Option { + let non_numeric = |schema: &Map| { + schema + .get(key) + .is_some_and(|value| value.as_f64().is_none()) + }; + if (non_numeric(old_schema) || non_numeric(new_schema)) + && old_schema.get(key) != new_schema.get(key) + { + return Some(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + format!("changes non-numeric '{key}' constraint"), + )); + } + None } fn check_min_max_constraint( - prop: &str, + path: &str, old_schema: &Map, new_schema: &Map, min_key: &str, max_key: &str, check_tightening: bool, - ) -> Vec { + ) -> Vec { + let bound = |detail: String| { + CompatibilityDiagnostic::new(path, CompatibilityFinding::BoundChanged, detail) + }; let mut errors = Vec::new(); + errors.extend(Self::check_non_numeric_bound( + path, old_schema, new_schema, min_key, + )); + errors.extend(Self::check_non_numeric_bound( + path, old_schema, new_schema, max_key, + )); // Check minimum constraint let old_min = old_schema.get(min_key).and_then(Value::as_f64); @@ -426,20 +792,18 @@ impl GtsEntityCastResult { if let (Some(old_m), Some(new_m)) = (old_min, new_min) { if check_tightening && new_m > old_m { - errors.push(format!( - "Property '{prop}' {min_key} increased from {old_m} to {new_m}" - )); + errors.push(bound(format!( + "{min_key} increased from {old_m} -> {new_m}" + ))); } else if !check_tightening && new_m < old_m { - errors.push(format!( - "Property '{prop}' {min_key} decreased from {old_m} to {new_m}" - )); + errors.push(bound(format!( + "{min_key} decreased from {old_m} -> {new_m}" + ))); } } else if let (true, None, Some(new_m)) = (check_tightening, old_min, new_min) { - errors.push(format!( - "Property '{prop}' added {min_key} constraint: {new_m}" - )); + errors.push(bound(format!("adds {min_key} constraint: {new_m}"))); } else if !check_tightening && old_min.is_some() && new_min.is_none() { - errors.push(format!("Property '{prop}' removed {min_key} constraint")); + errors.push(bound(format!("removes {min_key} constraint"))); } // Check maximum constraint @@ -448,236 +812,866 @@ impl GtsEntityCastResult { if let (Some(old_m), Some(new_m)) = (old_max, new_max) { if check_tightening && new_m < old_m { - errors.push(format!( - "Property '{prop}' {max_key} decreased from {old_m} to {new_m}" - )); + errors.push(bound(format!( + "{max_key} decreased from {old_m} -> {new_m}" + ))); } else if !check_tightening && new_m > old_m { - errors.push(format!( - "Property '{prop}' {max_key} increased from {old_m} to {new_m}" - )); + errors.push(bound(format!( + "{max_key} increased from {old_m} -> {new_m}" + ))); } } else if let (true, None, Some(new_m)) = (check_tightening, old_max, new_max) { - errors.push(format!( - "Property '{prop}' added {max_key} constraint: {new_m}" - )); + errors.push(bound(format!("adds {max_key} constraint: {new_m}"))); } else if !check_tightening && old_max.is_some() && new_max.is_none() { - errors.push(format!("Property '{prop}' removed {max_key} constraint")); + errors.push(bound(format!("removes {max_key} constraint"))); } errors } fn check_constraint_compatibility( - prop: &str, + path: &str, old_prop_schema: &Map, new_prop_schema: &Map, check_tightening: bool, - ) -> Vec { - let mut errors = Vec::new(); - let prop_type = old_prop_schema.get("type").and_then(|t| t.as_str()); - - // Numeric constraints (for number/integer types) - if prop_type == Some("number") || prop_type == Some("integer") { - errors.extend(Self::check_min_max_constraint( - prop, - old_prop_schema, - new_prop_schema, - "minimum", - "maximum", - check_tightening, + ) -> Vec { + // Every pair is checked whenever either definition carries it, never + // gated on `type`. Gating on the old schema's `type` missed a real + // narrowing whenever `type` was absent or written as an array, which + // reported such a change as fully compatible - the one direction of + // error a registry cannot tolerate. + const BOUNDS: &[(&str, &str)] = &[ + ("minimum", "maximum"), + ("exclusiveMinimum", "exclusiveMaximum"), + ("minLength", "maxLength"), + ("minItems", "maxItems"), + ("minProperties", "maxProperties"), + ("minContains", "maxContains"), + ]; + + BOUNDS + .iter() + .filter(|(min_key, max_key)| { + [min_key, max_key].iter().any(|key| { + old_prop_schema.contains_key(**key) || new_prop_schema.contains_key(**key) + }) + }) + .flat_map(|(min_key, max_key)| { + Self::check_min_max_constraint( + path, + old_prop_schema, + new_prop_schema, + min_key, + max_key, + check_tightening, + ) + }) + .collect() + } + + /// Handles keywords that only ever narrow `Valid(S)` when present. + /// + /// Whether two different values of such a keyword include one another is + /// undecidable in general - no implementation can compare two regexes - but + /// presence alone is decidable: adding the constraint narrows the accepted + /// set, removing it widens it. That is exactly the shape of the "Relaxing / + /// Tightening constraints" rows of gts-spec sec 4.5, so reporting both + /// directions as incompatible (as plain equality does) contradicts the table + /// for the common case of adding or dropping one of these keywords. + fn check_narrowing_constraints( + path: &str, + old_schema: &Map, + new_schema: &Map, + check_backward: bool, + ) -> Vec { + const NARROWING: &[&str] = &["pattern", "format", "multipleOf"]; + + let mut errors: Vec = NARROWING + .iter() + .filter_map(|keyword| { + let old_value = old_schema.get(*keyword); + let new_value = new_schema.get(*keyword); + match (old_value, new_value) { + _ if old_value == new_value => None, + // Added: narrows, so forward-only. + (None, Some(_)) if check_backward => Some(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NarrowingConstraintChanged, + format!("adds '{keyword}' constraint"), + )), + // Removed: widens, so backward-only. + (Some(_), None) if !check_backward => Some(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NarrowingConstraintChanged, + format!("removes '{keyword}' constraint"), + )), + // Changed: inclusion between the two values is undecidable. + (Some(old_value), Some(new_value)) => Some(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + format!( + "changes '{keyword}' from {old_value} to {new_value}; inclusion \ + between the two cannot be proven" + ), + )), + // Added in the forward direction, or removed in the + // backward one: the change widens what this direction + // requires, so it is permitted. + (None, Some(_) | None) | (Some(_), None) => None, + } + }) + .collect(); + + // `uniqueItems` defaults to false, so its presence is not what matters: + // false -> true narrows and true -> false widens, both decidable. + let unique_items = |schema: &Map| { + schema + .get("uniqueItems") + .and_then(Value::as_bool) + .unwrap_or(false) + }; + let old_unique = unique_items(old_schema); + let new_unique = unique_items(new_schema); + if old_unique != new_unique && check_backward == new_unique { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NarrowingConstraintChanged, + format!( + "{} 'uniqueItems'", + if new_unique { "enables" } else { "disables" } + ), )); } - // String constraints - if prop_type == Some("string") { - errors.extend(Self::check_min_max_constraint( - prop, - old_prop_schema, - new_prop_schema, - "minLength", - "maxLength", - check_tightening, - )); + errors + } + + fn check_type_compatibility( + path: &str, + old_schema: &Map, + new_schema: &Map, + check_backward: bool, + ) -> Vec { + // `type` is a set of permitted primitive types, and an absent `type` + // permits all of them (an empty set stands for "unconstrained" below). + // Inclusion of the accepted-instance sets therefore follows inclusion of + // the type sets, which makes member order irrelevant and makes dropping + // a member - say the `null` of an `Option` - a narrowing rather than + // an unrelated change. + fn type_set(value: Option<&Value>) -> Option> { + match value { + None => Some(Vec::new()), + Some(Value::String(name)) => Some(vec![name.as_str()]), + Some(Value::Array(names)) => names + .iter() + .map(Value::as_str) + .collect::>>(), + Some(_) => None, + } } - // Array constraints - if prop_type == Some("array") { - errors.extend(Self::check_min_max_constraint( - prop, - old_prop_schema, - new_prop_schema, - "minItems", - "maxItems", - check_tightening, - )); + let old_type = old_schema.get("type"); + let new_type = new_schema.get("type"); + let (source, target) = if check_backward { + (old_type, new_type) + } else { + (new_type, old_type) + }; + + let compatible = match (type_set(source), type_set(target)) { + // A malformed `type` cannot be interpreted; fall back to equality. + (None, _) | (_, None) => source == target, + // An unconstrained target accepts every type the source permits. + (_, Some(target_names)) if target_names.is_empty() => true, + // An unconstrained source permits types the target may not. + (Some(source_names), Some(_)) if source_names.is_empty() => false, + (Some(source_names), Some(target_names)) => source_names.iter().all(|name| { + target_names.contains(name) + || (*name == "integer" && target_names.contains(&"number")) + }), + }; + + if compatible { + Vec::new() + } else { + vec![CompatibilityDiagnostic::new( + path, + CompatibilityFinding::TypeChanged, + format!( + "changes type incompatibly from {} to {}", + old_type.map_or_else(|| "any".to_owned(), Value::to_string), + new_type.map_or_else(|| "any".to_owned(), Value::to_string), + ), + )] } + } - errors + fn check_enum_compatibility( + path: &str, + old_schema: &Map, + new_schema: &Map, + check_backward: bool, + ) -> Vec { + let old_enum = old_schema.get("enum").and_then(Value::as_array); + let new_enum = new_schema.get("enum").and_then(Value::as_array); + + let incompatible_values: Vec<&Value> = match (old_enum, new_enum, check_backward) { + // Backward checks Valid(old) ⊆ Valid(new); forward checks the + // reverse inclusion. Expanding an enum is therefore backward-only. + (Some(old), Some(new), true) => { + old.iter().filter(|value| !new.contains(value)).collect() + } + (Some(old), Some(new), false) => { + new.iter().filter(|value| !old.contains(value)).collect() + } + (None, Some(_), true) | (Some(_), None, false) => { + return vec![CompatibilityDiagnostic::new( + path, + CompatibilityFinding::EnumChanged, + format!( + "{} enum constraint", + if old_enum.is_some() { + "removes" + } else { + "adds" + } + ), + )]; + } + _ => Vec::new(), + }; + + if incompatible_values.is_empty() { + Vec::new() + } else { + vec![CompatibilityDiagnostic::new( + path, + CompatibilityFinding::EnumChanged, + format!("changes enum incompatibly: {incompatible_values:?}"), + )] + } } - #[must_use] - pub fn check_backward_compatibility( - old_schema: &Value, - new_schema: &Value, - ) -> (bool, Vec) { - Self::check_schema_compatibility(old_schema, new_schema, true) + fn check_exact_constraints( + path: &str, + old_schema: &Map, + new_schema: &Map, + ) -> Vec { + // Keywords whose two values cannot be ordered by inclusion, so equality + // is the only thing that can be proven. Numeric bounds live in + // [`Self::check_constraint_compatibility`] and keywords that merely + // narrow when present live in [`Self::check_narrowing_constraints`]; + // listing either here would report both directions as incompatible and + // contradict the "Relaxing / Tightening constraints" rows of sec 4.5. + // + // `patternProperties`, `unevaluatedProperties` and `propertyNames` stay + // here on purpose: they also decide the content model in + // [`Self::classify_content_model`], and a level whose classification can + // change between two definitions is not something this checker attempts + // to reason about. + const EXACT_CONSTRAINTS: &[&str] = &[ + "const", + "additionalItems", + "prefixItems", + "patternProperties", + "unevaluatedProperties", + "contains", + "propertyNames", + "dependentRequired", + "dependentSchemas", + "dependencies", + "oneOf", + "anyOf", + "not", + "if", + "then", + "else", + "contentEncoding", + "contentMediaType", + ]; + + EXACT_CONSTRAINTS + .iter() + .filter(|keyword| old_schema.get(**keyword) != new_schema.get(**keyword)) + .map(|keyword| { + CompatibilityDiagnostic::new( + path, + CompatibilityFinding::ConstraintChanged, + format!("changes '{keyword}' constraint"), + ) + }) + .collect() } - #[must_use] - pub fn check_forward_compatibility( - old_schema: &Value, - new_schema: &Value, - ) -> (bool, Vec) { - Self::check_schema_compatibility(old_schema, new_schema, false) + /// Reports a `$ref` that survived resolution. + /// + /// `$defs`/`definitions` are deliberately absent from + /// [`Self::check_exact_constraints`]: in every dialect they are containers + /// reachable only through `$ref` and never contribute to `Valid(S)` (§4.3), + /// so comparing them would reject changes that alter no accepted instance. + /// The reference itself is what carries the constraint, and + /// [`crate::store::GtsStore::is_compatible`] resolves references before + /// comparing. A `$ref` that is still present therefore means this node was + /// never resolved and nothing can be proven about its target - unless both + /// definitions name the same reference, which needs no resolution. + fn check_unresolved_ref( + path: &str, + old_schema: &Map, + new_schema: &Map, + ) -> Vec { + let old_ref = old_schema.get("$ref").and_then(Value::as_str); + let new_ref = new_schema.get("$ref").and_then(Value::as_str); + if old_ref == new_ref { + return Vec::new(); + } + vec![CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + format!( + "has an unresolved '$ref' ({} vs {}); resolve the reference before comparing, \ + as compatibility depends on the effective resolved schemas", + old_ref.unwrap_or("none"), + new_ref.unwrap_or("none"), + ), + )] } - #[allow(clippy::too_many_lines)] - fn check_schema_compatibility( + fn check_schema_node_compatibility( old_schema: &Value, new_schema: &Value, + path: &str, check_backward: bool, - ) -> (bool, Vec) { - let mut errors = Vec::new(); + old_supports_unevaluated: bool, + new_supports_unevaluated: bool, + errors: &mut Vec, + ) { + let old_effective = if old_schema.get("allOf").is_some() { + Self::flatten_schema(old_schema) + } else { + old_schema.clone() + }; + let new_effective = if new_schema.get("allOf").is_some() { + Self::flatten_schema(new_schema) + } else { + new_schema.clone() + }; + + let (Some(old_map), Some(new_map)) = (old_effective.as_object(), new_effective.as_object()) + else { + if old_effective != new_effective { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::ConstraintChanged, + "changes a schema that is not an object".to_owned(), + )); + } + return; + }; + if old_map.contains_key(UNPROVEN_INTERSECTION) + || new_map.contains_key(UNPROVEN_INTERSECTION) + { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + "contains an allOf intersection that the compatibility checker cannot prove" + .to_owned(), + )); + return; + } + + errors.extend(Self::check_type_compatibility( + path, + old_map, + new_map, + check_backward, + )); + errors.extend(Self::check_enum_compatibility( + path, + old_map, + new_map, + check_backward, + )); + errors.extend(Self::check_exact_constraints(path, old_map, new_map)); + errors.extend(Self::check_unresolved_ref(path, old_map, new_map)); + errors.extend(Self::check_narrowing_constraints( + path, + old_map, + new_map, + check_backward, + )); + errors.extend(Self::check_constraint_compatibility( + path, + old_map, + new_map, + check_backward, + )); + + let is_object_schema = |schema: &Map| { + schema.get("type").and_then(Value::as_str) == Some("object") + || schema.contains_key("properties") + || schema.contains_key("required") + || schema.contains_key("additionalProperties") + || schema.contains_key("unevaluatedProperties") + || schema.contains_key("patternProperties") + || schema.contains_key("propertyNames") + }; + if is_object_schema(old_map) || is_object_schema(new_map) { + Self::check_object_compatibility( + old_map, + new_map, + path, + check_backward, + old_supports_unevaluated, + new_supports_unevaluated, + errors, + ); + } - // Flatten schemas to handle allOf - let old_flat = Self::flatten_schema(old_schema); - let new_flat = Self::flatten_schema(new_schema); + match (old_map.get("items"), new_map.get("items")) { + (Some(old_items), Some(new_items)) => Self::check_schema_node_compatibility( + old_items, + new_items, + &format!("{path}[]"), + check_backward, + old_supports_unevaluated, + new_supports_unevaluated, + errors, + ), + (None, Some(_)) if check_backward => { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::ConstraintChanged, + "adds an array items constraint".to_owned(), + )); + } + (Some(_), None) if !check_backward => errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::ConstraintChanged, + "removes an array items constraint".to_owned(), + )), + _ => {} + } + } - let old_props = old_flat + fn check_object_compatibility( + old_schema: &Map, + new_schema: &Map, + path: &str, + check_backward: bool, + old_supports_unevaluated: bool, + new_supports_unevaluated: bool, + errors: &mut Vec, + ) { + let empty = Map::new(); + let old_props = old_schema .get("properties") - .and_then(|p| p.as_object()) - .cloned() - .unwrap_or_default(); - let new_props = new_flat + .and_then(Value::as_object) + .unwrap_or(&empty); + let new_props = new_schema .get("properties") - .and_then(|p| p.as_object()) - .cloned() - .unwrap_or_default(); + .and_then(Value::as_object) + .unwrap_or(&empty); - let old_required: HashSet = old_flat + let old_required: HashSet<&str> = old_schema .get("required") - .and_then(|r| r.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(str::to_owned)) - .collect() - }) - .unwrap_or_default(); - - let new_required: HashSet = new_flat + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + let new_required: HashSet<&str> = new_schema .get("required") - .and_then(|r| r.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(str::to_owned)) - .collect() - }) - .unwrap_or_default(); - - // Check required properties changes - if check_backward { - // Backward: cannot add required properties - let newly_required: Vec<_> = new_required.difference(&old_required).collect(); - if !newly_required.is_empty() { - let props: Vec<_> = newly_required.iter().map(|s| s.as_str()).collect(); - errors.push(format!("Added required properties: {}", props.join(", "))); - } + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + + let mut required_difference: Vec<&str> = if check_backward { + new_required.difference(&old_required).copied().collect() } else { - // Forward: cannot remove required properties - let removed_required: Vec<_> = old_required.difference(&new_required).collect(); - if !removed_required.is_empty() { - let props: Vec<_> = removed_required.iter().map(|s| s.as_str()).collect(); - errors.push(format!("Removed required properties: {}", props.join(", "))); - } + old_required.difference(&new_required).copied().collect() + }; + required_difference.sort_unstable(); + if !required_difference.is_empty() { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::RequiredChanged, + format!( + "{} required properties: {required_difference:?}", + if check_backward { "adds" } else { "removes" } + ), + )); } - // Check properties that exist in both schemas - let old_keys: HashSet<_> = old_props.keys().collect(); - let new_keys: HashSet<_> = new_props.keys().collect(); - let common_props: Vec<_> = old_keys.intersection(&new_keys).collect(); - - for prop in common_props { - if let (Some(old_prop_schema), Some(new_prop_schema)) = - (old_props.get(*prop), new_props.get(*prop)) - { - // Check if type changed - let old_type = old_prop_schema.get("type").and_then(|t| t.as_str()); - let new_type = new_prop_schema.get("type").and_then(|t| t.as_str()); + let old_model = Self::classify_content_model(old_schema, old_supports_unevaluated); + let new_model = Self::classify_content_model(new_schema, new_supports_unevaluated); + let (source_model, target_model) = if check_backward { + (old_model, new_model) + } else { + (new_model, old_model) + }; + let partial_constraints_equal = Self::partial_content_constraints_equal( + old_schema, + new_schema, + old_supports_unevaluated, + new_supports_unevaluated, + ); + if !Self::content_model_is_subset(source_model, target_model) { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::ContentModelChanged, + format!( + "changes the content model incompatibly from {} to {}", + old_model.label(), + new_model.label(), + ), + )); + } else if source_model == ContentModel::Partial + && target_model == ContentModel::Partial + && !partial_constraints_equal + { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + "changes partially open content constraints; inclusion cannot be proven".to_owned(), + )); + } - if let (Some(ot), Some(nt)) = (old_type, new_type) - && ot != nt - { - errors.push(format!("Property '{prop}' type changed from {ot} to {nt}")); + for (name, old_property) in old_props { + let property_path = if path == "$" { + format!("$.{name}") + } else { + format!("{path}.{name}") + }; + if let Some(new_property) = new_props.get(name) { + Self::check_schema_node_compatibility( + old_property, + new_property, + &property_path, + check_backward, + old_supports_unevaluated, + new_supports_unevaluated, + errors, + ); + } else { + let incompatible_model = if check_backward { + new_model != ContentModel::Open + } else { + new_model != ContentModel::Closed + }; + if incompatible_model { + errors.push(Self::property_change_error(path, name, true, new_model)); } + } + } - // Check enum constraints - let old_enum = old_prop_schema.get("enum").and_then(|e| e.as_array()); - let new_enum = new_prop_schema.get("enum").and_then(|e| e.as_array()); - - if let (Some(old_e), Some(new_e)) = (old_enum, new_enum) { - let old_enum_set: HashSet = old_e - .iter() - .filter_map(|v| v.as_str().map(str::to_owned)) - .collect(); - let new_enum_set: HashSet = new_e - .iter() - .filter_map(|v| v.as_str().map(str::to_owned)) - .collect(); - - if check_backward { - // Backward: cannot add enum values - let added_enum_values: Vec<_> = - new_enum_set.difference(&old_enum_set).collect(); - if !added_enum_values.is_empty() { - let values: Vec<_> = - added_enum_values.iter().map(|s| s.as_str()).collect(); - errors.push(format!("Property '{prop}' added enum values: {values:?}")); - } - } else { - // Forward: cannot remove enum values - let removed_enum_values: Vec<_> = - old_enum_set.difference(&new_enum_set).collect(); - if !removed_enum_values.is_empty() { - let values: Vec<_> = - removed_enum_values.iter().map(|s| s.as_str()).collect(); - errors - .push(format!("Property '{prop}' removed enum values: {values:?}")); - } - } - } + for name in new_props + .keys() + .filter(|name| !old_props.contains_key(*name)) + { + let incompatible_model = if check_backward { + old_model != ContentModel::Closed + } else { + old_model != ContentModel::Open + }; + if incompatible_model { + errors.push(Self::property_change_error(path, name, false, old_model)); + } + } + } - // Check constraint compatibility - if let Some(old_obj) = old_prop_schema.as_object() - && let Some(new_obj) = new_prop_schema.as_object() - { - let constraint_errors = Self::check_constraint_compatibility( - prop, - old_obj, - new_obj, - check_backward, - ); - errors.extend(constraint_errors); - } + fn classify_content_model( + schema: &Map, + supports_unevaluated: bool, + ) -> ContentModel { + let pattern_properties = schema + .get("patternProperties") + .and_then(Value::as_object) + .filter(|patterns| !patterns.is_empty()); + let patterns_all_open = pattern_properties.is_some_and(|patterns| { + patterns + .values() + .all(|constraint| boolean_schema_value(constraint) == Some(true)) + }); + let patterns_all_closed = pattern_properties.is_some_and(|patterns| { + patterns + .values() + .all(|constraint| boolean_schema_value(constraint) == Some(false)) + }); + let property_names_model = schema.get("propertyNames").and_then(boolean_schema_value); + if property_names_model == Some(false) { + return ContentModel::Closed; + } - // Recursively check nested object properties - if old_type == Some("object") && new_type == Some("object") { - let (nested_compat, nested_errors) = Self::check_schema_compatibility( - old_prop_schema, - new_prop_schema, - check_backward, - ); - if !nested_compat { - for err in nested_errors { - errors.push(format!("Property '{prop}': {err}")); - } - } - } + // `unevaluatedProperties` is the fallback only when this level does not + // already evaluate unmatched names through `additionalProperties`. + let undeclared_fallback = schema.get("additionalProperties").or_else(|| { + supports_unevaluated + .then(|| schema.get("unevaluatedProperties")) + .flatten() + }); + let fallback_model = undeclared_fallback.map_or(Some(true), boolean_schema_value); + let constrains_property_names = + property_names_model.is_none() && schema.contains_key("propertyNames"); + let constrains_fallback = fallback_model.is_none(); + + if pattern_properties.is_some() { + if fallback_model == Some(false) && patterns_all_closed { + ContentModel::Closed + } else if fallback_model == Some(true) + && patterns_all_open + && !constrains_property_names + { + ContentModel::Open + } else { + ContentModel::Partial + } + } else if fallback_model == Some(false) { + ContentModel::Closed + } else if constrains_property_names || constrains_fallback { + ContentModel::Partial + } else { + ContentModel::Open + } + } + + const fn content_model_is_subset(source: ContentModel, target: ContentModel) -> bool { + matches!( + (source, target), + (ContentModel::Closed, _) + | (_, ContentModel::Open) + | (ContentModel::Partial, ContentModel::Partial) + ) + } + + fn partial_content_constraints_equal( + old_schema: &Map, + new_schema: &Map, + old_supports_unevaluated: bool, + new_supports_unevaluated: bool, + ) -> bool { + let normalize_additional = |schema: &Map| { + schema + .get("additionalProperties") + .cloned() + .unwrap_or(Value::Bool(true)) + }; + let normalize_unevaluated = |schema: &Map, supported: bool| { + if supported { + schema + .get("unevaluatedProperties") + .cloned() + .unwrap_or(Value::Bool(true)) + } else { + Value::Bool(true) } + }; + + normalize_additional(old_schema) == normalize_additional(new_schema) + && old_schema.get("patternProperties") == new_schema.get("patternProperties") + && old_schema.get("propertyNames") == new_schema.get("propertyNames") + && normalize_unevaluated(old_schema, old_supports_unevaluated) + == normalize_unevaluated(new_schema, new_supports_unevaluated) + } + + fn property_change_error( + path: &str, + property: &str, + removed: bool, + model: ContentModel, + ) -> CompatibilityDiagnostic { + let operation = if removed { "removes" } else { "adds" }; + if model == ContentModel::Partial { + CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + format!( + "{operation} property '{property}', but compatibility cannot be proven for \ + the partially open object level" + ), + ) + } else { + CompatibilityDiagnostic::new( + path, + if removed { + CompatibilityFinding::PropertyRemoved + } else { + CompatibilityFinding::PropertyAdded + }, + format!( + "{operation} property '{property}' in a {} model", + model.label() + ), + ) } + } - (errors.is_empty(), errors) + /// Checks `Valid(old) ⊆ Valid(new)` and renders each reason as a string. + /// + /// The two schemas MUST already be `$ref`-resolved; see + /// [`crate::store::GtsStore::compare_documents`], which resolves and then + /// calls this. Prefer [`Self::check_backward_diagnostics`] when the caller + /// needs the offending schema location rather than prose. + #[must_use] + pub fn check_backward_compatibility( + old_schema: &Value, + new_schema: &Value, + ) -> (CompatibilityVerdict, Vec) { + let (verdict, diagnostics) = Self::check_backward_diagnostics(old_schema, new_schema); + (verdict, render_diagnostics(&diagnostics)) + } + + /// Checks `Valid(new) ⊆ Valid(old)` and renders each reason as a string. + /// + /// See [`Self::check_backward_compatibility`] for the resolution + /// requirement. + #[must_use] + pub fn check_forward_compatibility( + old_schema: &Value, + new_schema: &Value, + ) -> (CompatibilityVerdict, Vec) { + let (verdict, diagnostics) = Self::check_forward_diagnostics(old_schema, new_schema); + (verdict, render_diagnostics(&diagnostics)) } + + /// Checks `Valid(old) ⊆ Valid(new)`, reporting each reason with its schema + /// location. + #[must_use] + pub fn check_backward_diagnostics( + old_schema: &Value, + new_schema: &Value, + ) -> (CompatibilityVerdict, Vec) { + Self::check_schema_compatibility(old_schema, new_schema, true) + } + + /// Checks `Valid(new) ⊆ Valid(old)`, reporting each reason with its schema + /// location. + #[must_use] + pub fn check_forward_diagnostics( + old_schema: &Value, + new_schema: &Value, + ) -> (CompatibilityVerdict, Vec) { + Self::check_schema_compatibility(old_schema, new_schema, false) + } + + fn check_schema_compatibility( + old_schema: &Value, + new_schema: &Value, + check_backward: bool, + ) -> (CompatibilityVerdict, Vec) { + let mut errors = Vec::new(); + let declared_old = old_schema.get("$schema").and_then(Value::as_str); + let declared_new = new_schema.get("$schema").and_then(Value::as_str); + + // Only a genuine change of declared dialect is reported. An omitted + // `$schema` means "whatever dialect the implementation applies" (sec 11 + // makes GTS dialect-agnostic), so it is read as the dialect the other + // definition declares rather than as a difference - otherwise merely + // starting to declare a dialect that was already in effect would be + // reported as incompatible in both directions. + if let (Some(old_dialect), Some(new_dialect)) = (declared_old, declared_new) + && old_dialect != new_dialect + { + errors.push(CompatibilityDiagnostic::new( + "$", + CompatibilityFinding::DialectChanged, + format!("changes JSON Schema dialect from {old_dialect} to {new_dialect}"), + )); + } + let effective_old = declared_old.or(declared_new); + let effective_new = declared_new.or(declared_old); + let supports_unevaluated = |dialect: Option<&str>| { + dialect.is_some_and(|value| value.contains("2019-09") || value.contains("2020-12")) + }; + Self::check_schema_node_compatibility( + old_schema, + new_schema, + "$", + check_backward, + supports_unevaluated(effective_old), + supports_unevaluated(effective_new), + &mut errors, + ); + (CompatibilityVerdict::from_diagnostics(&errors), errors) + } + + /// Classifies the content model of every object level of a schema. + /// + /// The schema MUST already be `$ref`-resolved: gts-spec §4.4 requires the + /// content model to be read from the fully resolved effective schema, + /// because `unevaluatedProperties`, `patternProperties`, `propertyNames`, a + /// nontrivial schema-valued `additionalProperties`, or a conjunctive + /// subschema reached through `allOf` or `$ref` can all decide whether + /// undeclared properties are accepted. + /// [`crate::store::GtsStore::compare_documents`] resolves before calling + /// this. + /// + /// A level is reported once, at the location where it appears in the + /// document. Levels reached only through `oneOf`, `anyOf`, `not`, or + /// `if`/`then`/`else` are not reported: an instance satisfies one branch + /// rather than all of them, so such a level has no single content model. + #[must_use] + pub fn classify_object_levels(schema: &Value) -> Vec { + let dialect = schema.get("$schema").and_then(Value::as_str); + let supports_unevaluated = + dialect.is_some_and(|value| value.contains("2019-09") || value.contains("2020-12")); + let mut levels = Vec::new(); + Self::collect_object_levels(schema, "$", supports_unevaluated, &mut levels); + levels + } + + fn collect_object_levels( + schema: &Value, + path: &str, + supports_unevaluated: bool, + levels: &mut Vec, + ) { + let effective = if schema.get("allOf").is_some() { + Self::flatten_schema(schema) + } else { + schema.clone() + }; + let Some(map) = effective.as_object() else { + return; + }; + + let declares_object = map.get("type").and_then(Value::as_str) == Some("object") + || map.contains_key("properties") + || map.contains_key("additionalProperties") + || map.contains_key("unevaluatedProperties") + || map.contains_key("patternProperties") + || map.contains_key("propertyNames"); + if declares_object { + levels.push(ObjectLevel { + path: path.to_owned(), + content_model: Self::classify_content_model(map, supports_unevaluated), + }); + } + + if let Some(properties) = map.get("properties").and_then(Value::as_object) { + for (name, property) in properties { + let property_path = if path == "$" { + format!("$.{name}") + } else { + format!("{path}.{name}") + }; + Self::collect_object_levels(property, &property_path, supports_unevaluated, levels); + } + } + if let Some(items) = map.get("items") { + Self::collect_object_levels(items, &format!("{path}[]"), supports_unevaluated, levels); + } + } +} + +fn render_diagnostics(diagnostics: &[CompatibilityDiagnostic]) -> Vec { + diagnostics + .iter() + .map(std::string::ToString::to_string) + .collect() } + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { @@ -685,12 +1679,12 @@ mod tests { use serde_json::json; // Helper struct for compatibility results - #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Default)] + #[allow(clippy::struct_field_names)] struct CompatibilityResult { - is_backward_compatible: bool, - is_forward_compatible: bool, - is_fully_compatible: bool, + backward_compatibility: CompatibilityVerdict, + forward_compatibility: CompatibilityVerdict, + full_compatibility: CompatibilityVerdict, } // Helper function to check schema compatibility @@ -698,16 +1692,17 @@ mod tests { old_schema: &serde_json::Value, new_schema: &serde_json::Value, ) -> CompatibilityResult { - let (is_backward, _) = + let (backward_compatibility, _) = GtsEntityCastResult::check_backward_compatibility(old_schema, new_schema); - let (is_forward, _) = + let (forward_compatibility, _) = GtsEntityCastResult::check_forward_compatibility(old_schema, new_schema); - let is_fully = is_backward && is_forward; + let full_compatibility = + CompatibilityVerdict::full(backward_compatibility, forward_compatibility); CompatibilityResult { - is_backward_compatible: is_backward, - is_forward_compatible: is_forward, - is_fully_compatible: is_fully, + backward_compatibility, + forward_compatibility, + full_compatibility, } } @@ -720,6 +1715,45 @@ mod tests { assert!(error.to_string().contains("cast error")); } + #[test] + fn test_compatibility_verdict_serialization_and_full_derivation() { + assert_eq!( + serde_json::to_value(CompatibilityVerdict::Compatible).expect("serialize verdict"), + json!("compatible") + ); + assert_eq!( + serde_json::to_value(CompatibilityVerdict::Incompatible).expect("serialize verdict"), + json!("incompatible") + ); + assert_eq!( + serde_json::to_value(CompatibilityVerdict::Unknown).expect("serialize verdict"), + json!("unknown") + ); + assert_eq!(CompatibilityVerdict::Unknown.to_string(), "unknown"); + + assert_eq!( + CompatibilityVerdict::full( + CompatibilityVerdict::Compatible, + CompatibilityVerdict::Compatible + ), + CompatibilityVerdict::Compatible + ); + assert_eq!( + CompatibilityVerdict::full( + CompatibilityVerdict::Compatible, + CompatibilityVerdict::Unknown + ), + CompatibilityVerdict::Unknown + ); + assert_eq!( + CompatibilityVerdict::full( + CompatibilityVerdict::Unknown, + CompatibilityVerdict::Incompatible + ), + CompatibilityVerdict::Incompatible + ); + } + #[test] fn test_json_entity_cast_result_infer_direction_up() { let direction = GtsEntityCastResult::infer_direction( @@ -759,12 +1793,14 @@ mod tests { added_properties: vec![], removed_properties: vec![], changed_properties: vec![], - is_fully_compatible: false, - is_backward_compatible: true, - is_forward_compatible: false, + full_compatibility: CompatibilityVerdict::Incompatible, + backward_compatibility: CompatibilityVerdict::Compatible, + forward_compatibility: CompatibilityVerdict::Incompatible, incompatibility_reasons: vec![], backward_errors: vec![], forward_errors: vec![], + specification_version: specification_version(), + implementation_version: implementation_version(), casted_entity: None, error: None, }; @@ -783,6 +1819,14 @@ mod tests { json.get("direction").expect("test").as_str().expect("test"), "up" ); + assert_eq!( + json.get("specification_version").and_then(Value::as_str), + Some(crate::GTS_SPECIFICATION_VERSION) + ); + assert_eq!( + json.get("implementation_version").and_then(Value::as_str), + Some(crate::GTS_IMPLEMENTATION_VERSION) + ); } #[test] @@ -795,9 +1839,9 @@ mod tests { }); let result = check_schema_compatibility(&schema1, &schema1); - assert!(result.is_backward_compatible); - assert!(result.is_forward_compatible); - assert!(result.is_fully_compatible); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_compatible()); } #[test] @@ -818,8 +1862,10 @@ mod tests { }); let result = check_schema_compatibility(&old_schema, &new_schema); - // Adding optional property is backward compatible - assert!(result.is_backward_compatible); + // An open model already accepted arbitrary `email` values; declaring + // it narrows that set. + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); } #[test] @@ -843,7 +1889,7 @@ mod tests { let result = check_schema_compatibility(&old_schema, &new_schema); // Adding required property is not backward compatible - assert!(!result.is_backward_compatible); + assert!(result.backward_compatibility.is_incompatible()); } #[test] @@ -864,8 +1910,8 @@ mod tests { }); let result = check_schema_compatibility(&old_schema, &new_schema); - // Removing property is forward compatible in current implementation - assert!(result.is_forward_compatible); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); } #[test] @@ -881,8 +1927,9 @@ mod tests { }); let result = check_schema_compatibility(&old_schema, &new_schema); - // Enum expansion: backward compatible (old values still valid) - assert!(result.is_backward_compatible); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); + assert!(result.full_compatibility.is_incompatible()); } #[test] @@ -898,8 +1945,9 @@ mod tests { }); let result = check_schema_compatibility(&old_schema, &new_schema); - // Enum reduction: backward compatible (new schema more restrictive) - assert!(result.is_backward_compatible); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_incompatible()); } #[test] @@ -912,11 +1960,9 @@ mod tests { "type": "number" }); - let _result = check_schema_compatibility(&old_schema, &new_schema); - // Type change - current implementation may not detect this as incompatible - // Just verify it runs without error - // assert!(!result.is_backward_compatible); - // assert!(!result.is_forward_compatible); + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_incompatible()); } #[test] @@ -932,8 +1978,8 @@ mod tests { }); let result = check_schema_compatibility(&old_schema, &new_schema); - // Tightening minimum is backward compatible (new schema more restrictive) - assert!(result.is_backward_compatible); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); } #[test] @@ -950,7 +1996,7 @@ mod tests { let result = check_schema_compatibility(&old_schema, &new_schema); // Relaxing maximum is backward compatible - assert!(result.is_backward_compatible); + assert!(result.backward_compatibility.is_compatible()); } #[test] @@ -981,8 +2027,8 @@ mod tests { }); let result = check_schema_compatibility(&old_schema, &new_schema); - // Adding optional nested property is backward compatible - assert!(result.is_backward_compatible); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); } #[test] @@ -1000,8 +2046,8 @@ mod tests { }); let result = check_schema_compatibility(&old_schema, &new_schema); - // Tightening string constraints is backward compatible - assert!(result.is_backward_compatible); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); } #[test] @@ -1019,26 +2065,26 @@ mod tests { }); let result = check_schema_compatibility(&old_schema, &new_schema); - // Tightening array constraints is backward compatible - assert!(result.is_backward_compatible); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); } #[test] fn test_compatibility_result_default() { let result = CompatibilityResult::default(); - assert!(!result.is_backward_compatible); - assert!(!result.is_forward_compatible); - assert!(!result.is_fully_compatible); + assert!(result.backward_compatibility.is_unknown()); + assert!(result.forward_compatibility.is_unknown()); + assert!(result.full_compatibility.is_unknown()); } #[test] fn test_compatibility_result_fully_compatible() { let result = CompatibilityResult { - is_backward_compatible: true, - is_forward_compatible: true, - is_fully_compatible: true, + backward_compatibility: CompatibilityVerdict::Compatible, + forward_compatibility: CompatibilityVerdict::Compatible, + full_compatibility: CompatibilityVerdict::Compatible, }; - assert!(result.is_fully_compatible); + assert!(result.full_compatibility.is_compatible()); } #[test] @@ -1054,9 +2100,9 @@ mod tests { }); let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.is_backward_compatible); - assert!(result.is_forward_compatible); - assert!(result.is_fully_compatible); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_compatible()); } #[test] @@ -1092,7 +2138,7 @@ mod tests { let result = check_schema_compatibility(&old_schema, &new_schema); // Adding nested required is not backward compatible - assert!(!result.is_backward_compatible); + assert!(result.backward_compatibility.is_incompatible()); } #[test] @@ -1122,14 +2168,14 @@ mod tests { // Either direction should be fully compatible let r1 = check_schema_compatibility(&direct, &via_allof); - assert!(r1.is_backward_compatible); - assert!(r1.is_forward_compatible); - assert!(r1.is_fully_compatible); + assert!(r1.backward_compatibility.is_compatible()); + assert!(r1.forward_compatibility.is_compatible()); + assert!(r1.full_compatibility.is_compatible()); let r2 = check_schema_compatibility(&via_allof, &direct); - assert!(r2.is_backward_compatible); - assert!(r2.is_forward_compatible); - assert!(r2.is_fully_compatible); + assert!(r2.backward_compatibility.is_compatible()); + assert!(r2.forward_compatibility.is_compatible()); + assert!(r2.full_compatibility.is_compatible()); } #[test] @@ -1147,7 +2193,7 @@ mod tests { let result = check_schema_compatibility(&old_schema, &new_schema); // Removing required is forward-incompatible - assert!(!result.is_forward_compatible); + assert!(result.forward_compatibility.is_incompatible()); } #[test] @@ -1239,4 +2285,803 @@ mod tests { assert!(casted.get("extra").is_none()); assert!(cast.removed_properties.iter().any(|p| p == "extra")); } + + #[test] + fn test_closed_model_optional_addition_is_not_fully_compatible() { + let old_schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"} + } + }); + let new_schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); + assert!(result.full_compatibility.is_incompatible()); + } + + #[test] + fn test_additional_properties_change_without_declared_properties_is_detected() { + let old_schema = json!({"type": "object"}); + let new_schema = json!({ + "type": "object", + "additionalProperties": false + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_incompatible()); + } + + #[test] + fn test_required_change_without_declared_properties_is_detected() { + let old_schema = json!({"type": "object"}); + let new_schema = json!({ + "type": "object", + "required": ["value"] + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_incompatible()); + } + + #[test] + fn test_removing_enum_constraint_is_not_fully_compatible() { + let old_schema = json!({ + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["active", "inactive"]} + } + }); + let new_schema = json!({ + "type": "object", + "properties": { + "status": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); + assert!(result.full_compatibility.is_incompatible()); + } + + #[test] + fn test_adding_enum_constraint_is_forward_only() { + let old_schema = json!({"type": "string"}); + let new_schema = json!({ + "type": "string", + "enum": ["active", "inactive"] + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_incompatible()); + } + + #[test] + fn test_closed_model_optional_removal_is_forward_only() { + let old_schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }); + let new_schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_incompatible()); + } + + #[test] + fn test_unevaluated_properties_closes_2020_12_object() { + let old_schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "unevaluatedProperties": false, + "properties": {"name": {"type": "string"}} + }); + let new_schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "unevaluatedProperties": false, + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); + } + + #[test] + fn test_unevaluated_properties_is_ignored_by_draft_07() { + let old_schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "unevaluatedProperties": false, + "properties": {"name": {"type": "string"}} + }); + let new_schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "unevaluatedProperties": false, + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + } + + #[test] + fn test_partial_content_model_change_is_conservative_and_names_path() { + let old_schema = json!({ + "type": "object", + "properties": { + "details": { + "type": "object", + "additionalProperties": {"type": "string"} + } + } + }); + let new_schema = json!({ + "type": "object", + "properties": { + "details": { + "type": "object", + "additionalProperties": {"type": "string"}, + "properties": {"count": {"type": "integer"}} + } + } + }); + + let (backward, backward_errors) = + GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); + let (forward, forward_errors) = + GtsEntityCastResult::check_forward_compatibility(&old_schema, &new_schema); + assert_eq!(backward, CompatibilityVerdict::Unknown); + assert_eq!(forward, CompatibilityVerdict::Unknown); + assert!( + backward_errors + .iter() + .any(|error| error.contains("$.details") && error.contains("partially open")) + ); + assert!( + forward_errors + .iter() + .any(|error| error.contains("$.details") && error.contains("partially open")) + ); + } + + #[test] + fn test_dialect_change_is_not_proven_compatible() { + let old_schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "string" + }); + let new_schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string" + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_unknown()); + assert!(result.forward_compatibility.is_unknown()); + } + + #[test] + fn test_all_of_inherited_closure_controls_property_addition() { + let old_schema = json!({ + "allOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": {"name": {"type": "string"}} + } + ] + }); + let new_schema = json!({ + "allOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + } + ] + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); + } + + #[test] + fn test_all_of_intersects_duplicate_property_schemas() { + let schema = json!({ + "allOf": [ + { + "type": "object", + "properties": { + "value": {"type": "string", "minLength": 1} + } + }, + { + "type": "object", + "properties": { + "value": {"type": "string", "maxLength": 10} + } + } + ] + }); + + let flattened = GtsEntityCastResult::flatten_schema(&schema); + assert_eq!( + flattened.pointer("/properties/value/minLength"), + Some(&json!(1)) + ); + assert_eq!( + flattened.pointer("/properties/value/maxLength"), + Some(&json!(10)) + ); + } + + #[test] + fn test_definitions_container_change_alone_is_fully_compatible() { + // `definitions` is reachable only through `$ref` and never contributes + // to Valid(S), so adding an entry nothing references changes nothing. + let old_schema = json!({ + "type": "object", + "additionalProperties": false, + "definitions": { + "Used": {"type": "object", "additionalProperties": false} + }, + "properties": {"u": {"type": "object", "additionalProperties": false}} + }); + let new_schema = json!({ + "type": "object", + "additionalProperties": false, + "definitions": { + "Used": {"type": "object", "additionalProperties": false}, + "NeverReferenced": {"type": "string"} + }, + "properties": {"u": {"type": "object", "additionalProperties": false}} + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.full_compatibility.is_compatible()); + } + + #[test] + fn test_resolved_nested_definition_addition_is_backward_only() { + // The shape `resolve_schema_refs` produces for a macro-generated + // document: the referenced level is inlined and closed, and the + // residual `definitions` container must not double-count the change. + let level = |extra: bool| { + let mut props = json!({"label": {"type": "string"}}); + if extra { + props["note"] = json!({"type": "string"}); + } + json!({ + "type": "object", + "additionalProperties": false, + "properties": props, + "required": ["label"] + }) + }; + let document = |extra: bool| { + json!({ + "type": "object", + "additionalProperties": false, + "definitions": {"Nested": level(extra)}, + "properties": {"nested": level(extra)}, + "required": ["nested"] + }) + }; + + let result = check_schema_compatibility(&document(false), &document(true)); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); + } + + #[test] + fn test_differing_unresolved_ref_is_reported_as_unresolved() { + let old_schema = json!({ + "type": "object", + "properties": {"target": {"$ref": "gts://gts.x.core.a.b.v1~"}} + }); + let new_schema = json!({ + "type": "object", + "properties": {"target": {"$ref": "gts://gts.x.core.a.b.v2~"}} + }); + + let (is_backward, backward_errors) = + GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); + assert!(is_backward.is_unknown()); + assert!( + backward_errors + .iter() + .any(|error| error.contains("$.target") && error.contains("unresolved '$ref'")), + "{backward_errors:?}" + ); + } + + #[test] + fn test_identical_unresolved_ref_needs_no_resolution() { + let schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": {"target": {"$ref": "gts://gts.x.core.a.b.v1~"}} + }); + + let result = check_schema_compatibility(&schema, &schema); + assert!(result.full_compatibility.is_compatible()); + } + + fn property_change(old_property: Value, new_property: Value) -> CompatibilityResult { + let document = |property: Value| { + json!({ + "type": "object", + "additionalProperties": false, + "properties": {"value": property}, + "required": ["value"] + }) + }; + check_schema_compatibility(&document(old_property), &document(new_property)) + } + + /// Bound keywords must be compared whenever present, never gated on `type`. + /// Gating on the old schema's `type` reported a real narrowing as fully + /// compatible whenever `type` was absent or written as an array. + #[test] + fn test_numeric_bounds_are_checked_without_a_type_keyword() { + let result = property_change(json!({"minimum": 0}), json!({"minimum": 5})); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + + let result = property_change( + json!({"type": ["integer"], "minimum": 0}), + json!({"type": ["integer"], "minimum": 5}), + ); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + } + + #[test] + fn test_exclusive_and_size_bounds_are_directional() { + for (min_key, max_key) in [ + ("exclusiveMinimum", "exclusiveMaximum"), + ("minProperties", "maxProperties"), + ] { + let relaxed = property_change(json!({max_key: 10}), json!({max_key: 100})); + assert!( + relaxed.backward_compatibility.is_compatible(), + "relaxing {max_key}" + ); + assert!( + relaxed.forward_compatibility.is_incompatible(), + "relaxing {max_key}" + ); + + let tightened = property_change(json!({min_key: 1}), json!({min_key: 5})); + assert!( + tightened.backward_compatibility.is_incompatible(), + "tightening {min_key}" + ); + assert!( + tightened.forward_compatibility.is_compatible(), + "tightening {min_key}" + ); + } + } + + /// Draft-04 spells `exclusiveMinimum` as a boolean modifier, which a numeric + /// comparison would silently ignore. + #[test] + fn test_boolean_exclusive_minimum_is_not_silently_ignored() { + let result = property_change( + json!({"type": "integer", "minimum": 1, "exclusiveMinimum": false}), + json!({"type": "integer", "minimum": 1, "exclusiveMinimum": true}), + ); + assert!(result.backward_compatibility.is_unknown()); + assert!(result.forward_compatibility.is_unknown()); + } + + #[test] + fn test_type_is_compared_as_a_set() { + // Dropping `null` from an `Option` union narrows the accepted set. + let narrowed = property_change( + json!({"type": ["string", "null"]}), + json!({"type": "string"}), + ); + assert!(narrowed.backward_compatibility.is_incompatible()); + assert!(narrowed.forward_compatibility.is_compatible()); + + // Member order carries no meaning. + let reordered = property_change( + json!({"type": ["string", "null"]}), + json!({"type": ["null", "string"]}), + ); + assert!(reordered.full_compatibility.is_compatible()); + + // Widening a union accepts everything the old union did. + let widened = property_change( + json!({"type": "string"}), + json!({"type": ["string", "null"]}), + ); + assert!(widened.backward_compatibility.is_compatible()); + assert!(widened.forward_compatibility.is_incompatible()); + + // `integer` remains a subset of `number` inside a union. + let promoted = property_change( + json!({"type": ["integer", "null"]}), + json!({"type": ["number", "null"]}), + ); + assert!(promoted.backward_compatibility.is_compatible()); + assert!(promoted.forward_compatibility.is_incompatible()); + } + + #[test] + fn test_narrowing_keyword_presence_is_directional() { + for keyword in ["pattern", "format", "multipleOf"] { + let value = if keyword == "multipleOf" { + json!(5) + } else if keyword == "format" { + json!("date-time") + } else { + json!("^a+$") + }; + + let added = property_change(json!({}), json!({keyword: value.clone()})); + assert!( + added.backward_compatibility.is_incompatible(), + "adding {keyword}" + ); + assert!( + added.forward_compatibility.is_compatible(), + "adding {keyword}" + ); + + let removed = property_change(json!({keyword: value}), json!({})); + assert!( + removed.backward_compatibility.is_compatible(), + "removing {keyword}" + ); + assert!( + removed.forward_compatibility.is_incompatible(), + "removing {keyword}" + ); + } + } + + /// Two different regexes cannot be ordered by inclusion, so neither + /// direction is provable - and the diagnostic must say so rather than imply + /// the change is breaking. + #[test] + fn test_changed_pattern_is_reported_as_unprovable() { + let (_, errors) = GtsEntityCastResult::check_backward_compatibility( + &json!({"type": "string", "pattern": "^a+$"}), + &json!({"type": "string", "pattern": "^[ab]+$"}), + ); + assert!( + errors + .iter() + .any(|error| error.contains("cannot be proven")), + "{errors:?}" + ); + } + + #[test] + fn test_unique_items_defaults_to_false() { + let enabled = property_change( + json!({"type": "array"}), + json!({"type": "array", "uniqueItems": true}), + ); + assert!(enabled.backward_compatibility.is_incompatible()); + assert!(enabled.forward_compatibility.is_compatible()); + + let disabled = property_change( + json!({"type": "array", "uniqueItems": true}), + json!({"type": "array", "uniqueItems": false}), + ); + assert!(disabled.backward_compatibility.is_compatible()); + assert!(disabled.forward_compatibility.is_incompatible()); + + // Spelling out the default changes no accepted instance. + let no_op = property_change( + json!({"type": "array", "uniqueItems": false}), + json!({"type": "array"}), + ); + assert!(no_op.full_compatibility.is_compatible()); + } + + /// An omitted `$schema` means "the dialect the implementation applies", so + /// starting to declare a dialect that was already in effect is not a change. + #[test] + fn test_declaring_a_previously_omitted_dialect_is_compatible() { + let result = check_schema_compatibility( + &json!({"type": "object", "additionalProperties": false}), + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false + }), + ); + assert!(result.full_compatibility.is_compatible()); + } + + /// The `unevaluatedProperties` decision must follow the dialect that is in + /// effect, including when only one definition spells it out. + #[test] + fn test_omitted_dialect_inherits_unevaluated_support() { + let result = check_schema_compatibility( + &json!({ + "type": "object", + "unevaluatedProperties": false, + "properties": {"name": {"type": "string"}} + }), + &json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "unevaluatedProperties": false, + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }), + ); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); + } + + #[test] + fn test_boolean_equivalent_property_schemas_classify_semantically() { + let additional_open = json!({ + "type": "object", + "additionalProperties": {} + }); + let additional_closed = json!({ + "type": "object", + "additionalProperties": {"not": {}} + }); + let property_names_open = json!({ + "type": "object", + "propertyNames": {} + }); + let property_names_closed = json!({ + "type": "object", + "propertyNames": {"not": {}} + }); + let closed_fallback_with_name_constraint = json!({ + "type": "object", + "additionalProperties": {"not": {}}, + "propertyNames": {"type": "string"} + }); + let closed_names_with_pattern = json!({ + "type": "object", + "propertyNames": {"not": {}}, + "patternProperties": {".*": {}} + }); + let open_pattern = json!({ + "type": "object", + "patternProperties": {"^x-": {}} + }); + let closed_pattern = json!({ + "type": "object", + "additionalProperties": {"not": {}}, + "patternProperties": {"^x-": {"not": {}}} + }); + let explicit_open_additional_precedes_unevaluated = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": {}, + "unevaluatedProperties": {"not": {}} + }); + + for (schema, expected) in [ + (additional_open, ContentModel::Open), + (additional_closed, ContentModel::Closed), + (property_names_open, ContentModel::Open), + (property_names_closed, ContentModel::Closed), + (closed_fallback_with_name_constraint, ContentModel::Closed), + (closed_names_with_pattern, ContentModel::Closed), + (open_pattern, ContentModel::Open), + (closed_pattern, ContentModel::Closed), + ( + explicit_open_additional_precedes_unevaluated, + ContentModel::Open, + ), + ] { + let levels = GtsEntityCastResult::classify_object_levels(&schema); + assert_eq!( + levels.first().map(|level| level.content_model), + Some(expected) + ); + } + } + + #[test] + fn test_boolean_equivalent_additional_properties_drive_compatibility() { + let added_property = |additional_properties: Value| { + check_schema_compatibility( + &json!({ + "type": "object", + "additionalProperties": additional_properties + }), + &json!({ + "type": "object", + "additionalProperties": additional_properties, + "properties": {"name": {"type": "string"}} + }), + ) + }; + + let open = added_property(json!({})); + assert!(open.backward_compatibility.is_incompatible()); + assert!(open.forward_compatibility.is_compatible()); + + let closed = added_property(json!({"not": {}})); + assert!(closed.backward_compatibility.is_compatible()); + assert!(closed.forward_compatibility.is_incompatible()); + } + + /// §4.4 requires the content model to be read per object level from the + /// resolved effective schema, and §4.4.1's closed-envelope shape puts the + /// level that decides evolvability inside an extension container rather + /// than at the document root. + #[test] + fn test_classify_object_levels_reports_every_level() { + let schema = json!({ + "$schema": "http://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "envelope_field": {"type": "string"}, + "payload": { + "type": "object", + "properties": { + "own": { + "type": "object", + "additionalProperties": false, + "properties": {"a": {"type": "string"}} + } + } + }, + "labels": { + "type": "object", + "additionalProperties": {"type": "string"} + }, + "closed_by_unevaluated": { + "type": "object", + "unevaluatedProperties": false, + "properties": {"b": {"type": "string"}} + }, + "rows": { + "type": "array", + "items": {"type": "object", "properties": {"c": {"type": "string"}}} + } + } + }); + + let levels: HashMap = + GtsEntityCastResult::classify_object_levels(&schema) + .into_iter() + .map(|level| (level.path, level.content_model)) + .collect(); + + assert_eq!(levels.get("$"), Some(&ContentModel::Closed)); + assert_eq!(levels.get("$.payload"), Some(&ContentModel::Open)); + assert_eq!(levels.get("$.payload.own"), Some(&ContentModel::Closed)); + assert_eq!(levels.get("$.labels"), Some(&ContentModel::Partial)); + assert_eq!( + levels.get("$.closed_by_unevaluated"), + Some(&ContentModel::Closed) + ); + assert_eq!(levels.get("$.rows[]"), Some(&ContentModel::Open)); + // A scalar property is not an object level. + assert!(!levels.contains_key("$.envelope_field")); + + // Evolvability is exactly closure. + assert!(ContentModel::Closed.is_evolvable_in_place()); + assert!(!ContentModel::Open.is_evolvable_in_place()); + assert!(!ContentModel::Partial.is_evolvable_in_place()); + } + + /// A level closed only through `allOf` composition must classify as closed, + /// not as the open level it looks like in isolation. + #[test] + fn test_classify_object_levels_uses_the_effective_schema() { + let schema = json!({ + "allOf": [ + {"type": "object", "additionalProperties": false}, + {"type": "object", "properties": {"a": {"type": "string"}}} + ] + }); + + let levels = GtsEntityCastResult::classify_object_levels(&schema); + assert_eq!( + levels.first().map(|level| level.content_model), + Some(ContentModel::Closed) + ); + } + + #[test] + fn test_diagnostics_carry_the_schema_location_and_kind() { + let old_schema = json!({ + "type": "object", + "properties": { + "payload": {"type": "object", "properties": {"a": {"type": "string"}}} + } + }); + let new_schema = json!({ + "type": "object", + "properties": { + "payload": { + "type": "object", + "properties": {"a": {"type": "string"}, "b": {"type": "string"}} + } + } + }); + + let (compatible, diagnostics) = + GtsEntityCastResult::check_backward_diagnostics(&old_schema, &new_schema); + assert!(compatible.is_incompatible()); + let finding = diagnostics + .iter() + .find(|diagnostic| diagnostic.path == "$.payload") + .expect("the offending level must be named, not the document root"); + assert_eq!(finding.finding, CompatibilityFinding::PropertyAdded); + assert_eq!( + finding.to_string(), + "Schema at '$.payload' adds property 'b' in a open model" + ); + } + + /// A caller that fails closed treats both alike, but an owner needs to tell + /// "we cannot decide this" from "this is known to break". + #[test] + fn test_undecidable_changes_are_reported_as_not_provable() { + let (_, diagnostics) = GtsEntityCastResult::check_backward_diagnostics( + &json!({"type": "string", "pattern": "^a+$"}), + &json!({"type": "string", "pattern": "^[ab]+$"}), + ); + assert!( + diagnostics + .iter() + .all(|diagnostic| diagnostic.finding == CompatibilityFinding::NotProvable), + "{diagnostics:?}" + ); + } } diff --git a/gts/src/schema_compat.rs b/gts/src/schema_compat.rs index f67fb1f..abec4d3 100644 --- a/gts/src/schema_compat.rs +++ b/gts/src/schema_compat.rs @@ -10,6 +10,7 @@ //! instance of the base schema. Concretely the derived schema may only //! **tighten** (never loosen) constraints on properties inherited from the base. +use crate::schema_semantics::boolean_schema_value; use serde_json::Value; use std::collections::{HashMap, HashSet}; @@ -23,23 +24,29 @@ pub(crate) struct EffectiveSchema { } /// Folds an `additionalProperties` value into an accumulator using a -/// closedness-preserving lattice: `false` (closed) is strongest, an object -/// (partial constraint) is in the middle, and `true` (open) is weakest. +/// closedness-preserving lattice: schemas equivalent to `false` (closed) are +/// strongest, nontrivial constraining schemas are in the middle, and schemas +/// equivalent to `true` (open) are weakest. /// /// This mirrors `allOf` composition, where the schema stays closed if **any** -/// branch sets `additionalProperties: false`, so a permissive overlay can never -/// loosen a closed base. Used both when flattening `allOf` during ref -/// resolution and when extracting the effective schema for compatibility checks. +/// branch gives `additionalProperties` a false-equivalent schema, so a +/// permissive overlay can never loosen a closed base. Used both when flattening +/// `allOf` during ref resolution and when extracting the effective schema for +/// compatibility checks. pub(crate) fn merge_additional_properties_constraint( current: &mut Option, candidate: &Value, ) { - match (current.as_ref(), candidate) { - (Some(Value::Bool(false)), _) => {} - (_, Value::Bool(false)) => *current = Some(Value::Bool(false)), - (None | Some(Value::Bool(true)), _) => *current = Some(candidate.clone()), - (Some(_), Value::Bool(true)) => {} - (Some(_), _) => *current = Some(candidate.clone()), + let candidate_boolean = boolean_schema_value(candidate); + if current.as_ref().and_then(boolean_schema_value) == Some(false) { + return; + } + if candidate_boolean == Some(false) { + *current = Some(candidate.clone()); + } else if candidate_boolean == Some(true) && current.is_some() { + // Intersecting an existing constraint with `true` changes nothing. + } else { + *current = Some(candidate.clone()); } } @@ -120,7 +127,7 @@ pub(crate) fn validate_schema_compatibility( /// Validates that a derived effective schema is compatible with its base. /// /// Rules checked: -/// - Derived cannot add properties if base has `additionalProperties: false` +/// - Derived cannot add properties if the base's `additionalProperties` rejects them /// - Derived cannot loosen constraints on existing properties /// - Derived cannot disable (`false`) properties that base defines /// - Derived enum must be a subset of base enum @@ -139,7 +146,11 @@ pub(crate) fn validate_effective_schema_compatibility( derived_id: &str, ) -> Vec { let mut errors = Vec::new(); - let base_disallows_additional = matches!(base.additional_properties, Some(Value::Bool(false))); + let base_disallows_additional = base + .additional_properties + .as_ref() + .and_then(boolean_schema_value) + == Some(false); for (prop_name, derived_prop) in &derived.properties { if let Some(base_prop) = base.properties.get(prop_name) { @@ -157,8 +168,12 @@ pub(crate) fn validate_effective_schema_compatibility( // New property in derived – check additionalProperties else if base_disallows_additional { errors.push(format!( - "property '{prop_name}': derived schema '{derived_id}' adds new property but base '{base_id}' has additionalProperties: false" + "property '{prop_name}': derived schema '{derived_id}' adds new property but base '{base_id}' has a closed additionalProperties constraint" )); + } else if let Some(base_additional) = &base.additional_properties + && boolean_schema_value(base_additional) != Some(true) + { + compare_property_constraints(base_additional, derived_prop, prop_name, &mut errors); } } @@ -168,8 +183,8 @@ pub(crate) fn validate_effective_schema_compatibility( // `additionalProperties` without a closed constraint surviving through // allOf composition. Omitting the keyword, or composing a permissive // overlay with a closed base, is **not** loosening: across JSON Schema - // dialects, the base's `additionalProperties: false` still applies to - // the same instance via `$ref`/`allOf` composition. + // dialects, the base's closed `additionalProperties` constraint still + // applies to the same instance via `$ref`/`allOf` composition. // // The per-property loop above already catches the only structurally // dangerous case (derived adds a new top-level property that base @@ -177,12 +192,12 @@ pub(crate) fn validate_effective_schema_compatibility( // to "explicit permissive declarations" is safe. if base_disallows_additional { let derived_explicitly_allows = match &derived.additional_properties { - Some(Value::Bool(false)) | None => false, - Some(_) => true, + Some(value) => boolean_schema_value(value) != Some(false), + None => false, }; if derived_explicitly_allows { errors.push(format!( - "derived schema '{derived_id}' loosens additionalProperties from false in base '{base_id}'" + "derived schema '{derived_id}' loosens additionalProperties from a closed constraint in base '{base_id}'" )); } } @@ -193,13 +208,13 @@ pub(crate) fn validate_effective_schema_compatibility( errors } -/// Validates branch-scoped `additionalProperties: false` in a descendant schema. +/// Validates branch-scoped closed `additionalProperties` in a descendant schema. /// /// Flattened compatibility catches closed ancestors that reject new descendant /// properties, but it cannot see the inverse `allOf` hazard: a descendant -/// branch can set `additionalProperties: false` without restating an ancestor -/// property at the same object path, making that ancestor property unusable in -/// the composed schema. This walks the raw/resolved descendant branches so that +/// branch can close `additionalProperties` without restating an ancestor property +/// at the same object path, making that ancestor property unusable in the +/// composed schema. This walks the raw/resolved descendant branches so that /// branch ownership is preserved. pub(crate) fn validate_closed_descendant_branches( ancestor_schema: &Value, @@ -244,7 +259,11 @@ fn collect_closed_descendant_branch_errors( }; let descendant_props = descendant_obj.get("properties").and_then(Value::as_object); - if descendant_obj.get("additionalProperties") == Some(&Value::Bool(false)) { + if descendant_obj + .get("additionalProperties") + .and_then(boolean_schema_value) + == Some(false) + { let mut orphaned: Vec<&str> = ancestor .properties .keys() @@ -256,7 +275,7 @@ fn collect_closed_descendant_branch_errors( let property_path = join_schema_path(path, name); errors.push(format!( "property '{property_path}': descendant schema '{descendant_label}' sets \ - additionalProperties: false but does not restate property defined in \ + a closed additionalProperties constraint but does not restate property defined in \ ancestor '{ancestor_label}', making it unusable under allOf composition" )); } @@ -327,6 +346,26 @@ fn compare_property_constraints( prop_name: &str, errors: &mut Vec, ) { + match ( + boolean_schema_value(base_prop), + boolean_schema_value(derived_prop), + ) { + (_, Some(false)) | (Some(true), _) => return, + (Some(false), _) => { + errors.push(format!( + "property '{prop_name}': derived schema accepts values but base schema rejects all values" + )); + return; + } + (_, Some(true)) => { + errors.push(format!( + "property '{prop_name}': derived schema accepts every value, loosening base constraints" + )); + return; + } + (None, None) => {} + } + // If base is not an object schema, it places no constraints to loosen. let Some(base_map) = base_prop.as_object() else { return; @@ -786,8 +825,92 @@ mod tests { assert_eq!(eff.additional_properties, Some(Value::Bool(false))); } + #[test] + fn test_extract_allof_boolean_equivalent_false_wins_over_true() { + let schema = json!({ + "type": "object", + "allOf": [ + {"additionalProperties": {"not": {}}}, + {"additionalProperties": {}} + ] + }); + let eff = extract_effective_schema(&schema); + assert_eq!(eff.additional_properties, Some(json!({"not": {}}))); + } + // -- validate_schema_compatibility ------------------------------------ + #[test] + fn test_partially_open_base_accepts_compatible_derived_property() { + let base = json!({ + "type": "object", + "additionalProperties": {"type": "string"} + }); + let derived = json!({ + "type": "object", + "additionalProperties": {"type": "string"}, + "properties": { + "foo": {"type": "string", "maxLength": 5} + } + }); + + let errors = validate_schema_compatibility(&base, &derived, "base", "derived"); + assert!( + errors.is_empty(), + "compatible refinement must be accepted: {errors:?}" + ); + } + + #[test] + fn test_partially_open_base_rejects_incompatible_derived_property() { + let base = json!({ + "type": "object", + "additionalProperties": {"type": "string"} + }); + let derived = json!({ + "type": "object", + "additionalProperties": {"type": "string"}, + "properties": { + "foo": {"type": "integer"} + } + }); + + let errors = validate_schema_compatibility(&base, &derived, "base", "derived"); + assert!( + errors + .iter() + .any(|error| error.contains("foo") && error.contains("changes type")), + "incompatible refinement must be rejected: {errors:?}" + ); + } + + #[test] + fn test_boolean_equivalent_additional_properties_control_derivation() { + let open_base = json!({ + "type": "object", + "additionalProperties": {} + }); + let closed_base = json!({ + "type": "object", + "additionalProperties": {"not": {}} + }); + let derived = json!({ + "type": "object", + "properties": { + "foo": {"type": "integer"} + } + }); + + assert!(validate_schema_compatibility(&open_base, &derived, "base", "derived").is_empty()); + let errors = validate_schema_compatibility(&closed_base, &derived, "base", "derived"); + assert!( + errors + .iter() + .any(|error| error.contains("foo") && error.contains("additionalProperties")), + "false-equivalent additionalProperties must close the model: {errors:?}" + ); + } + #[test] fn test_compatible_tightening() { let base = json!({ diff --git a/gts/src/schema_semantics.rs b/gts/src/schema_semantics.rs new file mode 100644 index 0000000..284e89f --- /dev/null +++ b/gts/src/schema_semantics.rs @@ -0,0 +1,68 @@ +use serde_json::Value; + +const NON_ASSERTION_KEYWORDS: &[&str] = &[ + "$anchor", + "$comment", + "$defs", + "$dynamicAnchor", + "$id", + "$schema", + "default", + "definitions", + "deprecated", + "description", + "examples", + "readOnly", + "title", + "writeOnly", +]; + +/// Returns the boolean value of a schema when it has a directly recognizable +/// boolean-equivalent form. +/// +/// JSON Schema permits boolean schemas to be written as objects. In particular, +/// `{}` is equivalent to `true`, and `{"not": {}}` is equivalent to `false`. +/// Annotation and identifier keywords do not change those equivalences. +pub fn boolean_schema_value(schema: &Value) -> Option { + match schema { + Value::Bool(value) => Some(*value), + Value::Object(map) => { + let mut assertions = map + .iter() + .filter(|(keyword, _)| !NON_ASSERTION_KEYWORDS.contains(&keyword.as_str())); + let first = assertions.next(); + if assertions.next().is_some() { + return None; + } + match first { + None => Some(true), + Some((keyword, inner)) if keyword == "not" => { + boolean_schema_value(inner).map(|value| !value) + } + Some(_) => None, + } + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::boolean_schema_value; + use serde_json::json; + + #[test] + fn recognizes_boolean_equivalent_object_schemas() { + assert_eq!(boolean_schema_value(&json!({})), Some(true)); + assert_eq!( + boolean_schema_value(&json!({"description": "anything"})), + Some(true) + ); + assert_eq!(boolean_schema_value(&json!({"not": {}})), Some(false)); + assert_eq!( + boolean_schema_value(&json!({"not": {"not": {}}})), + Some(true) + ); + assert_eq!(boolean_schema_value(&json!({"type": "string"})), None); + } +} diff --git a/gts/src/schema_traits.rs b/gts/src/schema_traits.rs index 5e86e3a..a36a3d7 100644 --- a/gts/src/schema_traits.rs +++ b/gts/src/schema_traits.rs @@ -192,7 +192,7 @@ pub trait GtsTraitsSchema: schemars::JsonSchema {} // an accept-anything trait schema that validates nothing. #[allow(clippy::expect_used)] pub fn inline_traits_schema_of() -> Value { - let mut generator = schemars::generate::SchemaSettings::default() + let mut generator = schemars::generate::SchemaSettings::draft07() .with(|s| s.inline_subschemas = true) .into_generator(); let schema = ::json_schema(&mut generator); diff --git a/gts/src/store.rs b/gts/src/store.rs index 9c95352..cd752de 100644 --- a/gts/src/store.rs +++ b/gts/src/store.rs @@ -5,7 +5,9 @@ use thiserror::Error; use crate::entities::GtsEntity; use crate::gts::{GtsId, GtsIdError, GtsIdPattern}; -use crate::schema_cast::GtsEntityCastResult; +use crate::schema_cast::{ + CompatibilityDiagnostic, CompatibilityVerdict, GtsEntityCastResult, ObjectLevel, +}; #[derive(Debug, Error)] pub enum StoreError { @@ -42,6 +44,84 @@ pub struct GtsStoreQueryResult { pub results: Vec, } +/// Result of comparing two Type Schema documents for schema evolution. +/// +/// Produced by [`GtsStore::compare_documents`], which resolves both documents +/// first. Both directions are computed in one pass; which one gates publication +/// is a policy decision for the caller, and gts-spec §6 leaves the enforced mode +/// to the implementation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SchemaComparison { + /// `Valid(old) ⊆ Valid(new)`: the new definition accepts every instance the + /// old one accepted. + pub backward_compatibility: CompatibilityVerdict, + /// `Valid(new) ⊆ Valid(old)`: the old definition accepts every instance the + /// new one accepts. + pub forward_compatibility: CompatibilityVerdict, + /// Evidence for an incompatible or unknown backward verdict, with the + /// offending schema location on each entry. + pub backward_diagnostics: Vec, + /// Evidence for an incompatible or unknown forward verdict. + pub forward_diagnostics: Vec, + /// Content model of every object level of the resolved **new** document. + /// + /// A caller admitting the new definition uses this to report, per level, + /// whether a later definition will be able to add an optional property + /// there - see [`crate::schema_cast::ContentModel::is_evolvable_in_place`]. + /// One flag for the + /// whole document would not do: in the closed-envelope shape recommended by + /// §4.4.1 the level that decides evolvability is inside an extension + /// container, not the document root. + pub candidate_object_levels: Vec, +} + +impl SchemaComparison { + /// `Valid(old) = Valid(new)`: both directions hold. + #[must_use] + pub const fn full_compatibility(&self) -> CompatibilityVerdict { + CompatibilityVerdict::full(self.backward_compatibility, self.forward_compatibility) + } + + /// Compares two documents whose references are already resolved. + fn of_resolved(old_schema: &Value, new_schema: &Value) -> Self { + let (backward_compatibility, backward_diagnostics) = + GtsEntityCastResult::check_backward_diagnostics(old_schema, new_schema); + let (forward_compatibility, forward_diagnostics) = + GtsEntityCastResult::check_forward_diagnostics(old_schema, new_schema); + Self { + backward_compatibility, + forward_compatibility, + backward_diagnostics, + forward_diagnostics, + candidate_object_levels: GtsEntityCastResult::classify_object_levels(new_schema), + } + } + + /// Object levels of the candidate that a later definition cannot extend + /// with an optional property. + #[must_use] + pub fn levels_not_evolvable_in_place(&self) -> Vec<&ObjectLevel> { + self.candidate_object_levels + .iter() + .filter(|level| !level.content_model.is_evolvable_in_place()) + .collect() + } + + fn backward_messages(&self) -> Vec { + self.backward_diagnostics + .iter() + .map(ToString::to_string) + .collect() + } + + fn forward_messages(&self) -> Vec { + self.forward_diagnostics + .iter() + .map(ToString::to_string) + .collect() + } +} + /// Fully-resolved, self-contained view of a GTS type. /// /// A pure value computed from store contents — the library holds **no cache** @@ -737,9 +817,29 @@ impl GtsStore { let instance_type_id = instance.type_id.clone().ok_or_else(|| { StoreError::InvalidEntity(format!("Instance '{instance_id}' has no type_id")) })?; - let from_schema = self.get_schema_entity(&instance_type_id)?.clone(); - - let target_schema = self.get_schema_entity(target_type_id)?.clone(); + let mut from_schema = self.get_schema_entity(&instance_type_id)?.clone(); + let mut target_schema = self.get_schema_entity(target_type_id)?.clone(); + + // Resolve both schemas before casting, exactly as `is_compatible` does. + // The compatibility verdicts this result carries are a property of the + // effective resolved schemas (sec 4.4); comparing unresolved documents + // here would let the same pair of schemas get one verdict through OP#8 + // and a different one through OP#9. Resolution also makes a base type's + // properties and `const` values visible to the cast itself. + from_schema.content = self + .resolve_schema_refs(&from_schema.content) + .map_err(|e| { + StoreError::SchemaNotFound(format!( + "Could not resolve source schema '{instance_type_id}': {e}" + )) + })?; + target_schema.content = self + .resolve_schema_refs(&target_schema.content) + .map_err(|e| { + StoreError::SchemaNotFound(format!( + "Could not resolve target schema '{target_type_id}': {e}" + )) + })?; // Create a resolver to handle $ref in schemas // TODO: Implement custom resolver @@ -750,15 +850,13 @@ impl GtsStore { .map_err(|e| StoreError::SchemaNotFound(e.to_string())) } - pub fn is_minor_compatible( - &mut self, - old_type_id: &str, - new_type_id: &str, - ) -> GtsEntityCastResult { + /// Checks GTS schema-evolution compatibility using accepted-instance set inclusion. + pub fn is_compatible(&mut self, old_type_id: &str, new_type_id: &str) -> GtsEntityCastResult { let old_entity = self.get(old_type_id).cloned(); let new_entity = self.get(new_type_id).cloned(); let (Some(old_ent), Some(new_ent)) = (old_entity, new_entity) else { + let message = "Schema not found".to_owned(); return GtsEntityCastResult { from_id: old_type_id.to_owned(), to_id: new_type_id.to_owned(), @@ -768,25 +866,71 @@ impl GtsStore { added_properties: Vec::new(), removed_properties: Vec::new(), changed_properties: Vec::new(), - is_fully_compatible: false, - is_backward_compatible: false, - is_forward_compatible: false, - incompatibility_reasons: vec!["Schema not found".to_owned()], - backward_errors: vec!["Schema not found".to_owned()], - forward_errors: vec!["Schema not found".to_owned()], + full_compatibility: CompatibilityVerdict::Unknown, + backward_compatibility: CompatibilityVerdict::Unknown, + forward_compatibility: CompatibilityVerdict::Unknown, + incompatibility_reasons: Vec::new(), + backward_errors: Vec::new(), + forward_errors: Vec::new(), + specification_version: crate::GTS_SPECIFICATION_VERSION.to_owned(), + implementation_version: crate::GTS_IMPLEMENTATION_VERSION.to_owned(), casted_entity: None, - error: None, + error: Some(message), }; }; - let old_schema = &old_ent.content; - let new_schema = &new_ent.content; + let resolution_failure = |message: String| GtsEntityCastResult { + from_id: old_type_id.to_owned(), + to_id: new_type_id.to_owned(), + old: old_type_id.to_owned(), + new: new_type_id.to_owned(), + direction: GtsEntityCastResult::infer_direction(old_type_id, new_type_id), + added_properties: Vec::new(), + removed_properties: Vec::new(), + changed_properties: Vec::new(), + full_compatibility: CompatibilityVerdict::Unknown, + backward_compatibility: CompatibilityVerdict::Unknown, + forward_compatibility: CompatibilityVerdict::Unknown, + incompatibility_reasons: Vec::new(), + backward_errors: Vec::new(), + forward_errors: Vec::new(), + specification_version: crate::GTS_SPECIFICATION_VERSION.to_owned(), + implementation_version: crate::GTS_IMPLEMENTATION_VERSION.to_owned(), + casted_entity: None, + error: Some(message), + }; + let old_schema = match self.resolve_schema_refs(&old_ent.content) { + Ok(schema) => schema, + Err(error) => { + return resolution_failure(format!( + "Could not resolve old schema '{old_type_id}': {error}" + )); + } + }; + let new_schema = match self.resolve_schema_refs(&new_ent.content) { + Ok(schema) => schema, + Err(error) => { + return resolution_failure(format!( + "Could not resolve new schema '{new_type_id}': {error}" + )); + } + }; - // Use the cast method's compatibility checking logic - let (is_backward, backward_errors) = - GtsEntityCastResult::check_backward_compatibility(old_schema, new_schema); - let (is_forward, forward_errors) = - GtsEntityCastResult::check_forward_compatibility(old_schema, new_schema); + let comparison = SchemaComparison::of_resolved(&old_schema, &new_schema); + let backward_compatibility = comparison.backward_compatibility; + let forward_compatibility = comparison.forward_compatibility; + let full_compatibility = comparison.full_compatibility(); + let backward_errors = comparison.backward_messages(); + let forward_errors = comparison.forward_messages(); + let incompatibility_reasons = backward_errors + .iter() + .map(|error| format!("backward: {error}")) + .chain( + forward_errors + .iter() + .map(|error| format!("forward: {error}")), + ) + .collect(); // Determine direction let direction = GtsEntityCastResult::infer_direction(old_type_id, new_type_id); @@ -800,17 +944,66 @@ impl GtsStore { added_properties: Vec::new(), removed_properties: Vec::new(), changed_properties: Vec::new(), - is_fully_compatible: is_backward && is_forward, - is_backward_compatible: is_backward, - is_forward_compatible: is_forward, - incompatibility_reasons: Vec::new(), + full_compatibility, + backward_compatibility, + forward_compatibility, + incompatibility_reasons, backward_errors, forward_errors, + specification_version: crate::GTS_SPECIFICATION_VERSION.to_owned(), + implementation_version: crate::GTS_IMPLEMENTATION_VERSION.to_owned(), casted_entity: None, error: None, } } + /// Compares two Type Schema **documents** rather than two registered + /// identifiers. + /// + /// [`Self::is_compatible`] requires both definitions to be addressable by + /// GTS Type Identifier, which the conformance API assumes (gts-spec §4.2). + /// An implementation that replaces a definition in place under an unchanged + /// identifier never has two such identifiers, and §4.2 leaves revision + /// addressing to that implementation. This entry point serves that case: it + /// takes the two documents, resolves their references against this store, + /// and returns both directions plus the per-level content model of the + /// candidate in one call. + /// + /// Resolution is not optional. §4.4 requires the content model to be read + /// from the fully resolved effective schema, so comparing authored + /// documents would misclassify a level that is closed only through a `$ref` + /// to its base. + /// + /// # Errors + /// [`StoreError::SchemaNotFound`] when either document has a reference this + /// store cannot resolve. Failing here rather than comparing unresolved + /// documents keeps an undecidable check from being reported as a verdict. + pub fn compare_documents( + &self, + old_schema: &Value, + new_schema: &Value, + ) -> Result { + let old_resolved = self.resolve_schema_refs(old_schema).map_err(|error| { + StoreError::SchemaNotFound(format!("Could not resolve the old document: {error}")) + })?; + let new_resolved = self.resolve_schema_refs(new_schema).map_err(|error| { + StoreError::SchemaNotFound(format!("Could not resolve the new document: {error}")) + })?; + Ok(SchemaComparison::of_resolved(&old_resolved, &new_resolved)) + } + + /// Legacy name retained for source compatibility. + /// + /// Compatibility is no longer defined specifically in terms of a minor + /// version change; callers should prefer [`Self::is_compatible`]. + pub fn is_minor_compatible( + &mut self, + old_type_id: &str, + new_type_id: &str, + ) -> GtsEntityCastResult { + self.is_compatible(old_type_id, new_type_id) + } + pub fn build_schema_graph(&mut self, gts_id: &str) -> Value { let mut seen_gts_ids = std::collections::HashSet::new(); self.gts2node(gts_id, &mut seen_gts_ids) @@ -1014,7 +1207,11 @@ impl GtsStore { exact_gts_id: Option<&GtsId>, ) -> bool { if is_wildcard && let Some(pattern) = wildcard_pattern { - return entity_id.matches_pattern(pattern); + // OP#4 allows a final bare `~*` to match an empty suffix, while + // OP#10 queries require the wildcard position to be present in the + // stored ID. Preserve that query-specific chain-depth constraint. + return entity_id.segments().len() >= pattern.segments().len() + && entity_id.matches_pattern(pattern); } // For non-wildcard patterns, use matches_pattern to support version flexibility diff --git a/gts/src/store_test.rs b/gts/src/store_test.rs index d7b6489..b154dca 100644 --- a/gts/src/store_test.rs +++ b/gts/src/store_test.rs @@ -423,8 +423,8 @@ fn test_gts_store_is_minor_compatible() { "gts.vendor.package.namespace.type.v1.1~", ); - // Adding optional property is backward compatible - assert!(result.is_backward_compatible); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); } #[test] @@ -714,7 +714,8 @@ fn test_gts_store_cast_entity_without_schema() { fn test_gts_store_is_minor_compatible_missing_schemas() { let mut store = GtsStore::new(); let result = store.is_minor_compatible("nonexistent1~", "nonexistent2~"); - assert!(!result.is_backward_compatible); + assert!(result.backward_compatibility.is_unknown()); + assert_eq!(result.error.as_deref(), Some("Schema not found")); } #[test] @@ -1337,7 +1338,7 @@ fn test_gts_store_cast_backward_incompatible() { let cast = result.expect("cast returns a compatibility report even when incompatible"); assert!( - !cast.is_backward_compatible, + cast.backward_compatibility.is_incompatible(), "adding required `age` must make the cast backward-incompatible" ); assert!( @@ -1404,8 +1405,9 @@ fn test_gts_store_compatibility_fully_compatible() { "gts.vendor.package.namespace.type.v1.1~", ); - // Adding optional property is backward compatible - assert!(result.is_backward_compatible); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_incompatible()); } #[test] @@ -1765,8 +1767,8 @@ fn test_gts_store_compatibility_with_removed_properties() { "gts.vendor.package.namespace.type.v1.1~", ); - // Removing optional properties is forward compatible in current implementation - assert!(result.is_forward_compatible); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); } #[test] @@ -5817,6 +5819,349 @@ fn test_resolve_schema_refs_uses_exact_gts_uri_lookup_without_minor_fallback() { )); } +#[test] +fn test_compatibility_resolves_referenced_schema_versions() { + let mut store = GtsStore::new(); + let draft = "http://json-schema.org/draft-07/schema#"; + for (id, values) in [ + ("gts.x.test.compat.target.v1.0~", json!(["a", "b"])), + ("gts.x.test.compat.target.v1.1~", json!(["a", "b", "c"])), + ] { + store + .register_schema( + id, + &json!({ + "$id": format!("gts://{id}"), + "$schema": draft, + "type": "object", + "required": ["code"], + "properties": { + "code": {"type": "string", "enum": values} + } + }), + ) + .expect("register referenced schema"); + } + + for (id, target) in [ + ( + "gts.x.test.compat.container.v1.0~", + "gts.x.test.compat.target.v1.0~", + ), + ( + "gts.x.test.compat.container.v1.1~", + "gts.x.test.compat.target.v1.1~", + ), + ] { + store + .register_schema( + id, + &json!({ + "$id": format!("gts://{id}"), + "$schema": draft, + "type": "object", + "required": ["detail"], + "properties": { + "detail": {"$ref": format!("gts://{target}")} + } + }), + ) + .expect("register container schema"); + } + + let result = store.is_minor_compatible( + "gts.x.test.compat.container.v1.0~", + "gts.x.test.compat.container.v1.1~", + ); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); + assert!(result.full_compatibility.is_incompatible()); +} + +#[test] +fn test_compatibility_inherits_closed_model_through_external_ref() { + let mut store = GtsStore::new(); + let base_id = "gts.x.test.compat.closed_base.v1~"; + store + .register_schema( + base_id, + &json!({ + "$id": format!("gts://{base_id}"), + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": {"name": {"type": "string"}} + }), + ) + .expect("register closed base"); + + let old_schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [{"$ref": format!("gts://{base_id}")}] + }); + let new_schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + {"$ref": format!("gts://{base_id}")}, + {"type": "object", "properties": {"email": {"type": "string"}}} + ] + }); + let old_resolved = store + .resolve_schema_refs(&old_schema) + .expect("resolve old derived schema"); + let new_resolved = store + .resolve_schema_refs(&new_schema) + .expect("resolve new derived schema"); + + let (backward, _) = + GtsEntityCastResult::check_backward_compatibility(&old_resolved, &new_resolved); + let (forward, _) = + GtsEntityCastResult::check_forward_compatibility(&old_resolved, &new_resolved); + assert!(backward.is_compatible()); + assert!(forward.is_incompatible()); +} + +/// The document-level entry point for an implementation that replaces a +/// definition in place under an unchanged identifier (gts-spec §4.2): the two +/// definitions are never simultaneously addressable, so they are passed as +/// documents and the store resolves them before comparing. +#[test] +fn test_compare_documents_resolves_and_reports_levels() { + let mut store = GtsStore::new(); + let base_id = "gts.x.test.docs.envelope.v1~"; + store + .register_schema( + base_id, + &json!({ + "$id": format!("gts://{base_id}"), + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string"}, + "payload": {"type": "object"} + }, + "required": ["id"] + }), + ) + .expect("register envelope"); + + // Closed envelope with a designated open container, per sec 4.4.1: the + // level carrying the definition's own properties is closed, the container + // that derived types extend stays open. + let revision = |extra: bool| { + let mut own = json!({"a": {"type": "string"}}); + if extra { + own["b"] = json!({"type": "string"}); + } + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + {"$ref": format!("gts://{base_id}")}, + { + "type": "object", + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "properties": own + } + } + } + ] + }) + }; + + let comparison = store + .compare_documents(&revision(false), &revision(true)) + .expect("both documents resolve against the store"); + + // Adding an optional property at a closed level is backward compatible and + // not forward compatible (sec 4.5). + assert!( + comparison.backward_compatibility.is_compatible(), + "{:?}", + comparison.backward_diagnostics + ); + assert!(comparison.forward_compatibility.is_incompatible()); + assert!(comparison.full_compatibility().is_incompatible()); + + // The root is closed only through the resolved `$ref` to the envelope. + let levels: std::collections::HashMap<&str, crate::ContentModel> = comparison + .candidate_object_levels + .iter() + .map(|level| (level.path.as_str(), level.content_model)) + .collect(); + assert_eq!(levels.get("$"), Some(&crate::ContentModel::Closed)); + assert_eq!(levels.get("$.payload"), Some(&crate::ContentModel::Closed)); + assert!( + comparison.levels_not_evolvable_in_place().is_empty(), + "{:?}", + comparison.levels_not_evolvable_in_place() + ); +} + +/// An open level is admitted normally but reported as not evolvable, and the +/// diagnostic names that level rather than the document root. +#[test] +fn test_compare_documents_names_the_open_level() { + let store = GtsStore::new(); + let revision = |extra: bool| { + let mut own = json!({"a": {"type": "string"}}); + if extra { + own["b"] = json!({"type": "string"}); + } + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": {"payload": {"type": "object", "properties": own}} + }) + }; + + let comparison = store + .compare_documents(&revision(false), &revision(true)) + .expect("documents without references resolve trivially"); + + assert!(comparison.backward_compatibility.is_incompatible()); + let diagnostic = comparison + .backward_diagnostics + .iter() + .find(|diagnostic| diagnostic.path == "$.payload") + .expect("the diagnostic must identify the open level, not the document root"); + assert_eq!( + diagnostic.finding, + crate::CompatibilityFinding::PropertyAdded + ); + + let not_evolvable: Vec<&str> = comparison + .levels_not_evolvable_in_place() + .iter() + .map(|level| level.path.as_str()) + .collect(); + assert_eq!(not_evolvable, vec!["$.payload"]); +} + +/// An unresolvable reference must fail rather than be compared as authored: a +/// level closed only through a `$ref` would otherwise classify as open. +#[test] +fn test_compare_documents_fails_on_unresolvable_reference() { + let store = GtsStore::new(); + let document = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [{"$ref": "gts://gts.x.test.docs.missing.v1~"}] + }); + + let error = store + .compare_documents(&document, &document) + .expect_err("an unresolved reference must not be reported as a verdict"); + assert!(matches!(error, StoreError::SchemaNotFound(_)), "{error:?}"); +} + +/// OP#8 and OP#9 must agree: both resolve `$ref` before comparing, so the same +/// pair of schemas cannot be compatible through one operation and incompatible +/// through the other. +#[test] +fn test_cast_and_compatibility_agree_on_referenced_schemas() { + let mut store = GtsStore::new(); + let base_id = "gts.x.test.agree.base.v1~"; + store + .register_schema( + base_id, + &json!({ + "$id": format!("gts://{base_id}"), + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string"}, + "type": {"type": "string"}, + "payload": {"type": "object"} + }, + "required": ["id", "type"] + }), + ) + .expect("register base"); + + // Plain (non-chained) type identifiers that reference the base through + // `allOf`, so the instance's type is unambiguous and the only thing under + // test is whether both operations resolve that reference. + let referencing = |minor: u32, extra: bool| { + let mut payload_properties = json!({"a": {"type": "string"}}); + if extra { + payload_properties["b"] = json!({"type": "string"}); + } + json!({ + "$id": format!("gts://gts.x.test.agree.doc.v1.{minor}~"), + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + {"$ref": format!("gts://{base_id}")}, + { + "type": "object", + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "properties": payload_properties + } + } + } + ] + }) + }; + let old_id = "gts.x.test.agree.doc.v1.0~".to_owned(); + let new_id = "gts.x.test.agree.doc.v1.1~".to_owned(); + store + .register_schema(&old_id, &referencing(0, false)) + .expect("register v1.0"); + store + .register_schema(&new_id, &referencing(1, true)) + .expect("register v1.1"); + + let compatibility = store.is_compatible(&old_id, &new_id); + assert!( + compatibility.backward_compatibility.is_compatible(), + "{:?}", + compatibility.backward_errors + ); + assert!(compatibility.forward_compatibility.is_incompatible()); + + let cfg = GtsConfig::default(); + let instance_id = "gts.x.test.agree.doc.v1.0".to_owned(); + let content = json!({ + "id": instance_id, + "type": old_id, + "payload": {"a": "value"} + }); + let entity = GtsEntity::new( + None, + None, + &content, + Some(&cfg), + None, + false, + String::new(), + None, + Some(old_id.clone()), + ); + store.register(entity).expect("register instance"); + + let cast = store + .cast(&instance_id, &new_id) + .expect("cast to the successor definition should succeed"); + assert_eq!( + (cast.backward_compatibility, cast.forward_compatibility), + ( + compatibility.backward_compatibility, + compatibility.forward_compatibility + ), + "cast verdicts {:?} disagree with compatibility verdicts", + (cast.backward_errors, cast.forward_errors) + ); +} + #[test] fn test_validate_instance_resolves_sibling_ref_in_allof() { let mut store = GtsStore::new(); From 47ad07d5b32a810b220de54ce939ac3fb82c7dde Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Wed, 29 Jul 2026 12:36:50 +0800 Subject: [PATCH 2/8] fix(gts-id): distinguish v0 from wildcard versions - Store major versions as optional values so an explicit v0 is not treated as unspecified. - Match and serialize v0 minor wildcards without leaking into other major versions. Signed-off-by: Aviator 5 --- gts-id/src/gts_id_pattern.rs | 17 +++++++++-- gts-id/src/gts_id_segment.rs | 59 ++++++++++++++++++++++++++++-------- gts/src/ops.rs | 18 ++++++----- 3 files changed, 72 insertions(+), 22 deletions(-) diff --git a/gts-id/src/gts_id_pattern.rs b/gts-id/src/gts_id_pattern.rs index 176d7e3..9d66e94 100644 --- a/gts-id/src/gts_id_pattern.rs +++ b/gts-id/src/gts_id_pattern.rs @@ -119,7 +119,9 @@ impl GtsIdPattern { if !p_seg.type_name().is_empty() && p_seg.type_name() != c_seg.type_name() { return false; } - if p_seg.ver_major() != 0 && p_seg.ver_major() != c_seg.ver_major() { + if let Some(p_major) = p_seg.ver_major_opt() + && Some(p_major) != c_seg.ver_major_opt() + { return false; } if let Some(p_minor) = p_seg.ver_minor() @@ -156,7 +158,7 @@ impl GtsIdPattern { } // Check version matching - if p_seg.ver_major() != c_seg.ver_major() { + if p_seg.ver_major_opt() != c_seg.ver_major_opt() { return false; } @@ -336,6 +338,17 @@ mod tests { assert!(!base.matches_pattern(&pattern)); } + #[test] + fn test_zero_major_minor_wildcard_is_scoped_to_v0() { + let pattern = GtsIdPattern::try_new(>s_id("x.core.events.topic.v0.*")).expect("test"); + let v0 = GtsId::try_new(>s_id("x.core.events.topic.v0.2~")).expect("test"); + let v1 = GtsId::try_new(>s_id("x.core.events.topic.v1.2~")).expect("test"); + + assert_eq!(pattern.segments()[0].ver_major_opt(), Some(0)); + assert!(v0.matches_pattern(&pattern)); + assert!(!v1.matches_pattern(&pattern)); + } + #[test] fn test_gts_wildcard_type_suffix() { // Wildcard after ~ should match type IDs diff --git a/gts-id/src/gts_id_segment.rs b/gts-id/src/gts_id_segment.rs index af09dc3..48181a7 100644 --- a/gts-id/src/gts_id_segment.rs +++ b/gts-id/src/gts_id_segment.rs @@ -23,8 +23,9 @@ use crate::parse::{expected_format, is_valid_segment_token, parse_u32_exact}; /// /// For a wildcard segment these fields hold the (possibly partial) prefix that /// precedes the `*` token — e.g. `x.core.*` fills `vendor` and `package` and -/// leaves the rest empty. Empty strings, a zero `ver_major`, and a `None` -/// `ver_minor` therefore mean "unspecified" in the wildcard case. +/// leaves the rest empty. Empty strings and `None` version components therefore +/// mean "unspecified" in the wildcard case. A present major version may +/// legitimately be zero. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct GtsIdSegmentParts { /// The raw segment string as it appeared in the source (including any @@ -34,7 +35,7 @@ pub struct GtsIdSegmentParts { package: String, namespace: String, type_name: String, - ver_major: u32, + ver_major: Option, ver_minor: Option, } @@ -72,8 +73,17 @@ impl GtsIdSegmentParts { } /// The major version, or `0` when unspecified in a wildcard segment. + /// + /// Use [`Self::ver_major_opt`] when the distinction between an unspecified + /// version and a real `v0` matters. #[must_use] pub fn ver_major(&self) -> u32 { + self.ver_major.unwrap_or(0) + } + + /// The major version when one was specified. + #[must_use] + pub fn ver_major_opt(&self) -> Option { self.ver_major } @@ -100,7 +110,7 @@ pub trait SegmentView { fn package(&self) -> &str; fn namespace(&self) -> &str; fn type_name(&self) -> &str; - fn ver_major(&self) -> u32; + fn ver_major_opt(&self) -> Option; fn ver_minor(&self) -> Option; fn is_type(&self) -> bool; fn uuid_tail(&self) -> Option<&str>; @@ -198,9 +208,18 @@ impl GtsIdSegment { } /// The major version, or `0` for a UUID tail. + /// + /// Use [`Self::ver_major_opt`] when the distinction between an absent + /// version and a real `v0` matters. #[must_use] pub fn ver_major(&self) -> u32 { - self.parts().map_or(0, |p| p.ver_major) + self.parts().map_or(0, GtsIdSegmentParts::ver_major) + } + + /// The major version when this is a named GTS segment. + #[must_use] + pub fn ver_major_opt(&self) -> Option { + self.parts().and_then(GtsIdSegmentParts::ver_major_opt) } /// The minor version, when present. @@ -329,11 +348,23 @@ impl GtsIdPatternSegment { } /// The major version, or `0` when unspecified. + /// + /// Use [`Self::ver_major_opt`] when the distinction between an unspecified + /// version wildcard and a real `v0` matters. #[must_use] pub fn ver_major(&self) -> u32 { match self { GtsIdPatternSegment::Segment(s) => s.ver_major(), - GtsIdPatternSegment::Wildcard(p) => p.ver_major, + GtsIdPatternSegment::Wildcard(p) => p.ver_major(), + } + } + + /// The major version when one was specified in this pattern segment. + #[must_use] + pub fn ver_major_opt(&self) -> Option { + match self { + GtsIdPatternSegment::Segment(s) => s.ver_major_opt(), + GtsIdPatternSegment::Wildcard(p) => p.ver_major_opt(), } } @@ -483,7 +514,7 @@ fn parse_segment_parts( package: String::new(), namespace: String::new(), type_name: String::new(), - ver_major: 0, + ver_major: None, ver_minor: None, }; @@ -539,8 +570,10 @@ fn parse_segment_parts( } let major_str = &tokens[4][1..]; - parts.ver_major = parse_u32_exact(major_str) - .ok_or_else(|| format!("Major version must be an integer, got '{major_str}'"))?; + parts.ver_major = Some( + parse_u32_exact(major_str) + .ok_or_else(|| format!("Major version must be an integer, got '{major_str}'"))?, + ); } if tokens.len() > 5 { @@ -573,8 +606,8 @@ impl SegmentView for GtsIdSegment { fn type_name(&self) -> &str { self.type_name() } - fn ver_major(&self) -> u32 { - self.ver_major() + fn ver_major_opt(&self) -> Option { + self.ver_major_opt() } fn ver_minor(&self) -> Option { self.ver_minor() @@ -600,8 +633,8 @@ impl SegmentView for GtsIdPatternSegment { fn type_name(&self) -> &str { self.type_name() } - fn ver_major(&self) -> u32 { - self.ver_major() + fn ver_major_opt(&self) -> Option { + self.ver_major_opt() } fn ver_minor(&self) -> Option { self.ver_minor() diff --git a/gts/src/ops.rs b/gts/src/ops.rs index f80bd5e..15a37be 100644 --- a/gts/src/ops.rs +++ b/gts/src/ops.rs @@ -63,13 +63,7 @@ impl From<&crate::gts::GtsIdPatternSegment> for GtsIdSegmentInfo { package: seg.package().to_owned(), namespace: seg.namespace().to_owned(), type_name: seg.type_name().to_owned(), - // For a wildcard segment, `ver_major() == 0` is the "unspecified" - // sentinel and must serialize as `null`. - ver_major: if seg.is_wildcard() && seg.ver_major() == 0 { - None - } else { - Some(seg.ver_major()) - }, + ver_major: seg.ver_major_opt(), ver_minor: seg.ver_minor(), is_type: seg.is_type(), } @@ -3362,6 +3356,16 @@ mod tests { assert_eq!(result.is_type, Some(false)); } + #[test] + fn test_parse_id_with_zero_major_minor_wildcard() { + let result = GtsOps::parse_id("gts.vendor.package.namespace.type.v0.*"); + assert!(result.ok, "Parsing a v0 minor wildcard should succeed"); + assert!(result.is_wildcard); + assert_eq!(result.segments.len(), 1); + assert_eq!(result.segments[0].ver_major, Some(0)); + assert_eq!(result.segments[0].ver_minor, None); + } + #[test] fn test_parse_id_with_wildcard_schema() { // Test parse_id with wildcard pattern matching instances of a schema From 6a8d74408fe5a3aafd47dba20fe1d5c9f7fa95ed Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Wed, 29 Jul 2026 13:51:59 +0800 Subject: [PATCH 3/8] fix: correct directional schema compatibility checks - inline GTS ID references in retained schema definitions - compare const, boolean, bounds, and inferred types by directional inclusion - add regression coverage for compatibility verdicts and dangling local refs Signed-off-by: Aviator 5 --- gts-macros/src/lib.rs | 3 + gts-macros/tests/integration_tests.rs | 60 ++++- gts/src/schema_cast.rs | 373 +++++++++++++++++++++++--- 3 files changed, 397 insertions(+), 39 deletions(-) diff --git a/gts-macros/src/lib.rs b/gts-macros/src/lib.rs index 039b1e7..18c4fd2 100644 --- a/gts-macros/src/lib.rs +++ b/gts-macros/src/lib.rs @@ -1507,6 +1507,9 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream } inline_gts_id_refs(&mut properties); + if let Some(definitions) = definitions.as_mut() { + inline_gts_id_refs(definitions); + } }; let prune_unused_definitions = quote! { diff --git a/gts-macros/tests/integration_tests.rs b/gts-macros/tests/integration_tests.rs index e604777..93a7020 100644 --- a/gts-macros/tests/integration_tests.rs +++ b/gts-macros/tests/integration_tests.rs @@ -9,7 +9,7 @@ mod inheritance_tests; -use gts::{GtsConfig, GtsEntity, GtsId, GtsInstanceId, GtsSchema}; +use gts::{GtsConfig, GtsEntity, GtsId, GtsInstanceId, GtsSchema, GtsTypeId}; use gts_macros::{gts_id, struct_to_gts_schema}; /// Event Topic (Stream) definition for testing GTS schema generation. /// Inspired by examples/examples/events/schemas/gts.x.core.events.topic.v1~.schema.json @@ -55,6 +55,25 @@ pub struct ProductV1 { pub warehouse_location: String, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +pub struct NestedGtsIds { + pub type_id: GtsTypeId, + pub instance_id: GtsInstanceId, +} + +#[derive(Debug, Clone)] +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.test.entities.nested_ids.v1~"), + description = "Entity whose retained definition contains GTS ID references", + properties = "id,nested" +)] +pub struct NestedGtsIdsV1 { + pub id: GtsInstanceId, + pub nested: NestedGtsIds, +} + // ============================================================================= // Tests for 3.a) GTS_SCHEMA_JSON - JSON Schema with proper $id // ============================================================================= @@ -140,6 +159,45 @@ fn test_schema_json_is_valid_json() { assert_eq!(product_schema["type"], "object"); } +#[test] +fn test_gts_id_refs_are_inlined_inside_retained_definitions() { + fn contains_gts_id_ref(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Object(object) => { + let is_gts_id_ref = object + .get("$ref") + .and_then(serde_json::Value::as_str) + .is_some_and(|reference| { + reference.ends_with("/GtsInstanceId") + || reference.ends_with("/GtsTypeId") + || reference.ends_with("/GtsSchemaId") + }); + is_gts_id_ref || object.values().any(contains_gts_id_ref) + } + serde_json::Value::Array(values) => values.iter().any(contains_gts_id_ref), + _ => false, + } + } + + let schema = NestedGtsIdsV1::gts_schema_with_refs(); + assert!( + schema["definitions"]["NestedGtsIds"].is_object(), + "the nested definition must remain reachable" + ); + assert!( + !contains_gts_id_ref(&schema), + "generated schema contains a dangling GTS-ID definition reference: {schema}" + ); + + let mut store = gts::GtsStore::new(); + store + .register_schema(NestedGtsIdsV1::TYPE_ID, &schema) + .expect("generated schema should register"); + store + .validate_schema(NestedGtsIdsV1::TYPE_ID) + .expect("generated schema should have no unresolved local references"); +} + #[test] fn test_schema_json_required_fields() { let topic_schema: serde_json::Value = diff --git a/gts/src/schema_cast.rs b/gts/src/schema_cast.rs index c1cf81f..263c294 100644 --- a/gts/src/schema_cast.rs +++ b/gts/src/schema_cast.rs @@ -829,6 +829,129 @@ impl GtsEntityCastResult { errors } + /// Returns the effective lower or upper numeric bound. + /// + /// Draft 6 and later allow an inclusive and an exclusive bound to coexist; + /// their intersection is the stricter of the two (with exclusive winning + /// when the numeric values are equal). Draft 4's boolean + /// `exclusiveMinimum`/`exclusiveMaximum` spelling is handled as a modifier + /// of the corresponding inclusive bound. + fn effective_numeric_bound( + schema: &Map, + inclusive_key: &str, + exclusive_key: &str, + is_lower: bool, + ) -> Result, ()> { + let inclusive = match schema.get(inclusive_key) { + Some(value) => Some((value.as_f64().ok_or(())?, false)), + None => None, + }; + let exclusive = match schema.get(exclusive_key) { + Some(Value::Bool(is_exclusive)) => inclusive.map(|(value, _)| (value, *is_exclusive)), + Some(value) => Some((value.as_f64().ok_or(())?, true)), + None => None, + }; + + Ok(match (inclusive, exclusive) { + (None, bound) | (bound, None) => bound, + (Some(inclusive), Some(exclusive)) => { + let ordering = exclusive.0.total_cmp(&inclusive.0); + let exclusive_is_stricter = if is_lower { + ordering.is_gt() + } else { + ordering.is_lt() + }; + if exclusive_is_stricter || (ordering.is_eq() && exclusive.1 && !inclusive.1) { + Some(exclusive) + } else { + Some(inclusive) + } + } + }) + } + + fn check_numeric_bounds( + path: &str, + old_schema: &Map, + new_schema: &Map, + check_backward: bool, + ) -> Vec { + let mut diagnostics = Vec::new(); + for (inclusive_key, exclusive_key, is_lower) in [ + ("minimum", "exclusiveMinimum", true), + ("maximum", "exclusiveMaximum", false), + ] { + if !old_schema.contains_key(inclusive_key) + && !old_schema.contains_key(exclusive_key) + && !new_schema.contains_key(inclusive_key) + && !new_schema.contains_key(exclusive_key) + { + continue; + } + + if (old_schema.get(exclusive_key).is_some_and(Value::is_boolean) + || new_schema.get(exclusive_key).is_some_and(Value::is_boolean)) + && (old_schema.get(inclusive_key) != new_schema.get(inclusive_key) + || old_schema.get(exclusive_key) != new_schema.get(exclusive_key)) + { + diagnostics.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + format!( + "changes Draft-04 boolean '{exclusive_key}' constraint; dialect semantics \ + cannot be inferred at this node" + ), + )); + continue; + } + + let old_bound = + Self::effective_numeric_bound(old_schema, inclusive_key, exclusive_key, is_lower); + let new_bound = + Self::effective_numeric_bound(new_schema, inclusive_key, exclusive_key, is_lower); + let (Ok(old_bound), Ok(new_bound)) = (old_bound, new_bound) else { + if old_schema.get(inclusive_key) != new_schema.get(inclusive_key) + || old_schema.get(exclusive_key) != new_schema.get(exclusive_key) + { + diagnostics.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + format!( + "changes non-numeric '{inclusive_key}'/'{exclusive_key}' constraints" + ), + )); + } + continue; + }; + + let (source, target) = if check_backward { + (old_bound, new_bound) + } else { + (new_bound, old_bound) + }; + let included = match (source, target) { + (_, None) => true, + (None, Some(_)) => false, + (Some(source), Some(target)) if is_lower => { + let ordering = source.0.total_cmp(&target.0); + ordering.is_gt() || (ordering.is_eq() && (!target.1 || source.1)) + } + (Some(source), Some(target)) => { + let ordering = source.0.total_cmp(&target.0); + ordering.is_lt() || (ordering.is_eq() && (!target.1 || source.1)) + } + }; + if !included { + diagnostics.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::BoundChanged, + format!("changes effective {inclusive_key}/{exclusive_key} bound incompatibly"), + )); + } + } + diagnostics + } + fn check_constraint_compatibility( path: &str, old_prop_schema: &Map, @@ -841,32 +964,34 @@ impl GtsEntityCastResult { // reported such a change as fully compatible - the one direction of // error a registry cannot tolerate. const BOUNDS: &[(&str, &str)] = &[ - ("minimum", "maximum"), - ("exclusiveMinimum", "exclusiveMaximum"), ("minLength", "maxLength"), ("minItems", "maxItems"), ("minProperties", "maxProperties"), ("minContains", "maxContains"), ]; - BOUNDS - .iter() - .filter(|(min_key, max_key)| { - [min_key, max_key].iter().any(|key| { - old_prop_schema.contains_key(**key) || new_prop_schema.contains_key(**key) + let mut diagnostics = + Self::check_numeric_bounds(path, old_prop_schema, new_prop_schema, check_tightening); + diagnostics.extend( + BOUNDS + .iter() + .filter(|(min_key, max_key)| { + [min_key, max_key].iter().any(|key| { + old_prop_schema.contains_key(**key) || new_prop_schema.contains_key(**key) + }) }) - }) - .flat_map(|(min_key, max_key)| { - Self::check_min_max_constraint( - path, - old_prop_schema, - new_prop_schema, - min_key, - max_key, - check_tightening, - ) - }) - .collect() + .flat_map(|(min_key, max_key)| { + Self::check_min_max_constraint( + path, + old_prop_schema, + new_prop_schema, + min_key, + max_key, + check_tightening, + ) + }), + ); + diagnostics } /// Handles keywords that only ever narrow `Valid(S)` when present. @@ -952,43 +1077,94 @@ impl GtsEntityCastResult { new_schema: &Map, check_backward: bool, ) -> Vec { - // `type` is a set of permitted primitive types, and an absent `type` - // permits all of them (an empty set stands for "unconstrained" below). + // `type` is a set of permitted primitive types. When it is absent, + // `const` and `enum` can still imply a finite set of effective types. // Inclusion of the accepted-instance sets therefore follows inclusion of // the type sets, which makes member order irrelevant and makes dropping // a member - say the `null` of an `Option` - a narrowing rather than // an unrelated change. - fn type_set(value: Option<&Value>) -> Option> { + enum TypeSet { + Any, + Set(Vec), + Invalid, + } + + fn value_type(value: &Value) -> &'static str { match value { - None => Some(Vec::new()), - Some(Value::String(name)) => Some(vec![name.as_str()]), + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(number) + if number.is_i64() + || number.is_u64() + || number + .as_f64() + .is_some_and(|value| value.fract().abs() < f64::EPSILON) => + { + "integer" + } + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } + } + + fn type_set(schema: &Map) -> TypeSet { + match schema.get("type") { + Some(Value::String(name)) => TypeSet::Set(vec![name.clone()]), Some(Value::Array(names)) => names .iter() .map(Value::as_str) - .collect::>>(), - Some(_) => None, + .collect::>>() + .map_or(TypeSet::Invalid, |names| { + TypeSet::Set(names.into_iter().map(str::to_owned).collect()) + }), + Some(_) => TypeSet::Invalid, + None => { + let values: Option> = if let Some(value) = schema.get("const") { + Some(vec![value]) + } else { + schema + .get("enum") + .and_then(Value::as_array) + .map(|values| values.iter().collect()) + }; + values.map_or(TypeSet::Any, |values| { + let mut names = Vec::new(); + for value in values { + let name = value_type(value).to_owned(); + if !names.contains(&name) { + names.push(name); + } + } + TypeSet::Set(names) + }) + } } } let old_type = old_schema.get("type"); let new_type = new_schema.get("type"); - let (source, target) = if check_backward { - (old_type, new_type) + let (source_schema, target_schema) = if check_backward { + (old_schema, new_schema) } else { - (new_type, old_type) + (new_schema, old_schema) }; - let compatible = match (type_set(source), type_set(target)) { + let compatible = match (type_set(source_schema), type_set(target_schema)) { // A malformed `type` cannot be interpreted; fall back to equality. - (None, _) | (_, None) => source == target, + (TypeSet::Invalid, _) | (_, TypeSet::Invalid) => old_type == new_type, // An unconstrained target accepts every type the source permits. - (_, Some(target_names)) if target_names.is_empty() => true, + (_, TypeSet::Any) => true, // An unconstrained source permits types the target may not. - (Some(source_names), Some(_)) if source_names.is_empty() => false, - (Some(source_names), Some(target_names)) => source_names.iter().all(|name| { - target_names.contains(name) - || (*name == "integer" && target_names.contains(&"number")) - }), + (TypeSet::Any, TypeSet::Set(_)) => false, + (TypeSet::Set(source_names), TypeSet::Set(target_names)) => { + source_names.iter().all(|name| { + target_names.contains(name) + || (name == "integer" + && target_names.iter().any(|target| target == "number")) + }) + } }; if compatible { @@ -1070,7 +1246,6 @@ impl GtsEntityCastResult { // change between two definitions is not something this checker attempts // to reason about. const EXACT_CONSTRAINTS: &[&str] = &[ - "const", "additionalItems", "prefixItems", "patternProperties", @@ -1103,6 +1278,37 @@ impl GtsEntityCastResult { .collect() } + fn check_const_compatibility( + path: &str, + old_schema: &Map, + new_schema: &Map, + check_backward: bool, + ) -> Vec { + let old_const = old_schema.get("const"); + let new_const = new_schema.get("const"); + if old_const == new_const { + return Vec::new(); + } + + let (source, target) = if check_backward { + (old_const, new_const) + } else { + (new_const, old_const) + }; + // A source constrained to one value is included in an unconstrained + // target. The reverse is not; two different singleton sets are + // disjoint and therefore incompatible in either direction. + if source.is_some() && target.is_none() { + return Vec::new(); + } + + vec![CompatibilityDiagnostic::new( + path, + CompatibilityFinding::ConstraintChanged, + "changes 'const' constraint incompatibly".to_owned(), + )] + } + /// Reports a `$ref` that survived resolution. /// /// `$defs`/`definitions` are deliberately absent from @@ -1156,6 +1362,25 @@ impl GtsEntityCastResult { new_schema.clone() }; + let (source, target) = if check_backward { + (&old_effective, &new_effective) + } else { + (&new_effective, &old_effective) + }; + let source_boolean = boolean_schema_value(source); + let target_boolean = boolean_schema_value(target); + if source_boolean == Some(false) || target_boolean == Some(true) { + return; + } + if source_boolean == Some(true) || target_boolean == Some(false) { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::ConstraintChanged, + "changes boolean schema incompatibly".to_owned(), + )); + return; + } + let (Some(old_map), Some(new_map)) = (old_effective.as_object(), new_effective.as_object()) else { if old_effective != new_effective { @@ -1191,6 +1416,12 @@ impl GtsEntityCastResult { new_map, check_backward, )); + errors.extend(Self::check_const_compatibility( + path, + old_map, + new_map, + check_backward, + )); errors.extend(Self::check_exact_constraints(path, old_map, new_map)); errors.extend(Self::check_unresolved_ref(path, old_map, new_map)); errors.extend(Self::check_narrowing_constraints( @@ -2373,6 +2604,38 @@ mod tests { assert!(result.full_compatibility.is_incompatible()); } + #[test] + fn test_adding_and_removing_const_are_directional() { + let added = property_change( + json!({"type": "integer"}), + json!({"type": "integer", "const": 1}), + ); + assert!(added.backward_compatibility.is_incompatible()); + assert!(added.forward_compatibility.is_compatible()); + + let removed = property_change( + json!({"type": "integer", "const": 1}), + json!({"type": "integer"}), + ); + assert!(removed.backward_compatibility.is_compatible()); + assert!(removed.forward_compatibility.is_incompatible()); + } + + #[test] + fn test_boolean_schemas_follow_set_inclusion() { + let narrowed = check_schema_compatibility(&json!(true), &json!(false)); + assert!(narrowed.backward_compatibility.is_incompatible()); + assert!(narrowed.forward_compatibility.is_compatible()); + + let widened = check_schema_compatibility(&json!(false), &json!(true)); + assert!(widened.backward_compatibility.is_compatible()); + assert!(widened.forward_compatibility.is_incompatible()); + + // Object spellings of the boolean schemas have identical semantics. + let equivalent = check_schema_compatibility(&json!(true), &json!({})); + assert!(equivalent.full_compatibility.is_compatible()); + } + #[test] fn test_closed_model_optional_removal_is_forward_only() { let old_schema = json!({ @@ -2707,6 +2970,23 @@ mod tests { } } + #[test] + fn test_inclusive_and_exclusive_bounds_are_compared_together() { + let lower = property_change( + json!({"type": "number", "minimum": 0}), + json!({"type": "number", "exclusiveMinimum": 0}), + ); + assert!(lower.backward_compatibility.is_incompatible()); + assert!(lower.forward_compatibility.is_compatible()); + + let upper = property_change( + json!({"type": "number", "maximum": 10}), + json!({"type": "number", "exclusiveMaximum": 10}), + ); + assert!(upper.backward_compatibility.is_incompatible()); + assert!(upper.forward_compatibility.is_compatible()); + } + /// Draft-04 spells `exclusiveMinimum` as a boolean modifier, which a numeric /// comparison would silently ignore. #[test] @@ -2753,6 +3033,23 @@ mod tests { assert!(promoted.forward_compatibility.is_incompatible()); } + #[test] + fn test_enum_and_const_imply_effective_types() { + let enum_narrowed = property_change(json!({"type": "string"}), json!({"enum": ["a"]})); + assert!(enum_narrowed.backward_compatibility.is_incompatible()); + assert!(enum_narrowed.forward_compatibility.is_compatible()); + + let const_narrowed = property_change(json!({"type": "string"}), json!({"const": "a"})); + assert!(const_narrowed.backward_compatibility.is_incompatible()); + assert!(const_narrowed.forward_compatibility.is_compatible()); + + // JSON Schema treats mathematically integral JSON numbers as integers, + // regardless of whether the source text contains a decimal point. + let integral_number = property_change(json!({"type": "integer"}), json!({"const": 1.0})); + assert!(integral_number.backward_compatibility.is_incompatible()); + assert!(integral_number.forward_compatibility.is_compatible()); + } + #[test] fn test_narrowing_keyword_presence_is_directional() { for keyword in ["pattern", "format", "multipleOf"] { From d3040c2f41bab6b883c7141bae896563c108f9fa Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Wed, 29 Jul 2026 16:36:45 +0800 Subject: [PATCH 4/8] fix: Align schema compatibility with JSON Schema semantics - compare const, enum, and numeric constraints by accepted values - handle exact mixed numeric equality and signed-zero bounds - apply the validator's default dialect to unevaluated properties --- Cargo.lock | 1 + Cargo.toml | 2 + gts-dylint/Cargo.lock | 1 + gts-macros/src/lib.rs | 131 +++++- gts-macros/tests/inheritance_tests.rs | 190 +++++++++ gts/Cargo.toml | 1 + gts/src/ops.rs | 25 +- gts/src/schema_cast.rs | 547 +++++++++++++++++++++----- gts/src/store.rs | 78 ++-- gts/src/store_test.rs | 59 ++- 10 files changed, 873 insertions(+), 162 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a8c0c2..b601b1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -544,6 +544,7 @@ version = "0.11.0" dependencies = [ "gts-id", "jsonschema", + "num-cmp", "schemars", "serde", "serde-saphyr", diff --git a/Cargo.toml b/Cargo.toml index 4a43beb..5a27a35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -183,6 +183,8 @@ chrono = "0.4" # JSON Schema validation jsonschema = { version = "0.40", default-features = false } +# Exact comparison between differently typed numbers, as used by `jsonschema`. +num-cmp = "0.1" # JSON Schema generation schemars = { version = "1.2", features = ["uuid1"] } diff --git a/gts-dylint/Cargo.lock b/gts-dylint/Cargo.lock index d8bdf84..a4b2dd9 100644 --- a/gts-dylint/Cargo.lock +++ b/gts-dylint/Cargo.lock @@ -639,6 +639,7 @@ version = "0.11.0" dependencies = [ "gts-id", "jsonschema", + "num-cmp", "schemars", "serde", "serde-saphyr", diff --git a/gts-macros/src/lib.rs b/gts-macros/src/lib.rs index 18c4fd2..b8add5a 100644 --- a/gts-macros/src/lib.rs +++ b/gts-macros/src/lib.rs @@ -1587,6 +1587,15 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream // `properties` declared in the same schema object, so closing a branch // would reject the properties its sibling branches declare. Schemars // already closes the branches of an externally tagged enum itself. + // * a `definitions` entry a combinator branch resolves to, because such an + // entry *is* the branch and closing it would reject the sibling branches' + // properties just the same. Reachability is computed first, over both the + // property subschemas and `definitions` itself, and is followed through + // chains of aliasing definitions (a top-level `$ref`). The granularity is + // the whole entry, so a definition used both as a combinator branch and as + // an ordinary property schema stays open everywhere: keeping the + // composition satisfiable wins over closing the ordinary use, which merely + // forfeits in-place evolution for that one level. // * the generic extension field, which this macro replaces with a bare // `{"type": "object"}` before this pass runs and which sec 4.4.1 requires // to stay open so derived types can extend it. @@ -1616,6 +1625,82 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream ]; const SCHEMA_LIST: &[&str] = &["allOf", "anyOf", "oneOf", "prefixItems"]; + fn local_definition_name(reference: &str) -> Option { + reference + .strip_prefix("#/definitions/") + .or_else(|| reference.strip_prefix("#/$defs/")) + .and_then(|name| name.split('/').next()) + .map(|name| name.replace("~1", "/").replace("~0", "~")) + } + + fn collect_combinator_definition_refs( + value: &serde_json::Value, + is_combinator_branch: bool, + referenced: &mut ::std::collections::HashSet, + ) { + let Some(object) = value.as_object() else { + return; + }; + + if is_combinator_branch + && let Some(name) = object + .get("$ref") + .and_then(serde_json::Value::as_str) + .and_then(local_definition_name) + { + referenced.insert(name); + } + + for (keyword, nested) in object { + let branch = COMBINATORS.contains(&keyword.as_str()); + if SINGLE_SCHEMA.contains(&keyword.as_str()) { + collect_combinator_definition_refs(nested, branch, referenced); + } else if SCHEMA_MAP.contains(&keyword.as_str()) { + collect_combinator_definition_refs_map(nested, branch, referenced); + } else if SCHEMA_LIST.contains(&keyword.as_str()) { + collect_combinator_definition_refs_list(nested, branch, referenced); + } else if keyword == "items" { + if nested.is_array() { + collect_combinator_definition_refs_list(nested, branch, referenced); + } else { + collect_combinator_definition_refs(nested, branch, referenced); + } + } + } + } + + fn collect_combinator_definition_refs_map( + value: &serde_json::Value, + is_combinator_branch: bool, + referenced: &mut ::std::collections::HashSet, + ) { + if let Some(object) = value.as_object() { + for nested in object.values() { + collect_combinator_definition_refs( + nested, + is_combinator_branch, + referenced, + ); + } + } + } + + fn collect_combinator_definition_refs_list( + value: &serde_json::Value, + is_combinator_branch: bool, + referenced: &mut ::std::collections::HashSet, + ) { + if let Some(values) = value.as_array() { + for nested in values { + collect_combinator_definition_refs( + nested, + is_combinator_branch, + referenced, + ); + } + } + } + fn close_schema(value: &mut serde_json::Value, is_combinator_branch: bool) { let Some(object) = value.as_object_mut() else { return; @@ -1674,9 +1759,51 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream } } + let mut combinator_definitions = ::std::collections::HashSet::new(); + collect_combinator_definition_refs_map( + &properties, + false, + &mut combinator_definitions, + ); + if let Some(definitions) = definitions.as_ref() { + collect_combinator_definition_refs_map( + definitions, + false, + &mut combinator_definitions, + ); + } + + // A definition whose top level is a bare `$ref` only aliases another + // one - Schemars emits that for a newtype struct carrying no doc + // comment - so the alias target is what actually contributes the + // branch's content model and has to stay open as well. Chase the + // alias chain to a fixed point; a name is enqueued only when it was + // newly inserted, so the walk terminates even on a `$ref` cycle. + let mut alias_queue: Vec = + combinator_definitions.iter().cloned().collect(); + while let Some(name) = alias_queue.pop() { + let alias = definitions + .as_ref() + .and_then(serde_json::Value::as_object) + .and_then(|object| object.get(&name)) + .and_then(|definition| definition.get("$ref")) + .and_then(serde_json::Value::as_str) + .and_then(local_definition_name); + if let Some(alias) = alias + && combinator_definitions.insert(alias.clone()) + { + alias_queue.push(alias); + } + } + close_schema_map(&mut properties, false); - if let Some(definitions) = definitions.as_mut() { - close_schema_map(definitions, false); + if let Some(definitions_object) = definitions + .as_mut() + .and_then(serde_json::Value::as_object_mut) + { + for (name, definition) in definitions_object { + close_schema(definition, combinator_definitions.contains(name)); + } } } }; diff --git a/gts-macros/tests/inheritance_tests.rs b/gts-macros/tests/inheritance_tests.rs index 9c74fd2..26ba76e 100644 --- a/gts-macros/tests/inheritance_tests.rs +++ b/gts-macros/tests/inheritance_tests.rs @@ -97,6 +97,95 @@ pub struct SchemaWithNestedContactV1 { pub contact: NestedContact, } +fn composed_contact_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let contact = generator.subschema_for::(); + serde_json::from_value(serde_json::json!({ + "allOf": [ + contact, + { + "type": "object", + "properties": { + "label": {"type": "string"} + }, + "required": ["label"] + } + ] + })) + .expect("test schema") +} + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.test.nested.composed_definition.v1~"), + description = "Schema composing a definition with sibling properties", + properties = "schema_type,composed" +)] +#[derive(Debug)] +pub struct SchemaWithComposedDefinitionV1 { + #[serde(rename = "type")] + pub schema_type: GtsTypeId, + #[schemars(schema_with = "composed_contact_schema")] + pub composed: serde_json::Value, +} + +// Newtype structs without a doc comment: Schemars emits each definition as a +// bare `{"$ref": ...}` alias, so the combinator branch only reaches +// `NestedContact` through two hops of aliasing. +#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +pub struct ContactAlias(pub NestedContact); + +#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +pub struct ContactAliasAlias(pub ContactAlias); + +fn composed_alias_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let alias = generator.subschema_for::(); + serde_json::from_value(serde_json::json!({ + "allOf": [ + alias, + { + "type": "object", + "properties": { + "label": {"type": "string"} + }, + "required": ["label"] + } + ] + })) + .expect("test schema") +} + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.test.nested.aliased_definition.v1~"), + description = "Schema composing an aliased definition with sibling properties", + properties = "schema_type,composed" +)] +#[derive(Debug)] +pub struct SchemaWithAliasedDefinitionV1 { + #[serde(rename = "type")] + pub schema_type: GtsTypeId, + #[schemars(schema_with = "composed_alias_schema")] + pub composed: serde_json::Value, +} + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.test.nested.shared_definition.v1~"), + description = "Schema using one definition as a combinator branch and as a property", + properties = "schema_type,composed,plain" +)] +#[derive(Debug)] +pub struct SchemaWithSharedDefinitionV1 { + #[serde(rename = "type")] + pub schema_type: GtsTypeId, + #[schemars(schema_with = "composed_contact_schema")] + pub composed: serde_json::Value, + pub plain: NestedContact, +} + #[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] #[schemars(extend("additionalProperties" = true))] pub struct OpenExtensionPoint { @@ -466,6 +555,107 @@ mod tests { jsonschema::validator_for(&schema).expect("emitted Draft-07 schema must compile"); } + #[test] + fn test_definition_referenced_by_combinator_branch_stays_open() { + let schema = SchemaWithComposedDefinitionV1::gts_schema_with_refs(); + assert_eq!( + schema.pointer("/properties/composed/allOf/0/$ref"), + Some(&serde_json::json!("#/definitions/NestedContact")) + ); + assert!( + schema + .pointer("/definitions/NestedContact/additionalProperties") + .is_none(), + "a definition composed with sibling properties must stay open:\n{}", + serde_json::to_string_pretty(&schema).unwrap() + ); + + let validator = + jsonschema::validator_for(&schema).expect("emitted Draft-07 schema must compile"); + let instance = serde_json::json!({ + "type": "gts.x.test.nested.composed_definition.v1~", + "composed": { + "email": "dev@example.com", + "label": "primary" + } + }); + assert!( + validator.is_valid(&instance), + "combinator siblings should not be rejected by a closed definition" + ); + } + + /// The branch may reach its definition through a chain of aliasing + /// definitions, which Schemars emits for newtype structs. + #[test] + fn test_definition_aliased_by_combinator_branch_stays_open() { + let schema = SchemaWithAliasedDefinitionV1::gts_schema_with_refs(); + assert_eq!( + schema.pointer("/definitions/ContactAlias/$ref"), + Some(&serde_json::json!("#/definitions/NestedContact")), + "test relies on Schemars emitting a bare $ref alias:\n{}", + serde_json::to_string_pretty(&schema).unwrap() + ); + assert!( + schema + .pointer("/definitions/NestedContact/additionalProperties") + .is_none(), + "a definition an alias chain composes with sibling properties must stay open:\n{}", + serde_json::to_string_pretty(&schema).unwrap() + ); + + let validator = + jsonschema::validator_for(&schema).expect("emitted Draft-07 schema must compile"); + let instance = serde_json::json!({ + "type": "gts.x.test.nested.aliased_definition.v1~", + "composed": { + "email": "dev@example.com", + "label": "primary" + } + }); + assert!( + validator.is_valid(&instance), + "combinator siblings should not be rejected through an alias chain" + ); + } + + /// Reachability is tracked per `definitions` entry, not per use site, so one + /// composed use keeps the entry open for its ordinary uses too. That trades + /// the in-place evolvability of the ordinary level for a satisfiable + /// composition - see the pass documentation in `gts-macros/src/lib.rs`. + #[test] + fn test_shared_definition_stays_open_for_its_ordinary_use() { + let schema = SchemaWithSharedDefinitionV1::gts_schema_with_refs(); + assert_eq!( + schema.pointer("/properties/plain/$ref"), + Some(&serde_json::json!("#/definitions/NestedContact")) + ); + assert!( + schema + .pointer("/definitions/NestedContact/additionalProperties") + .is_none(), + "a definition shared with a combinator branch must stay open:\n{}", + serde_json::to_string_pretty(&schema).unwrap() + ); + + let validator = + jsonschema::validator_for(&schema).expect("emitted Draft-07 schema must compile"); + let instance = serde_json::json!({ + "type": "gts.x.test.nested.shared_definition.v1~", + "composed": { + "email": "dev@example.com", + "label": "primary" + }, + "plain": { + "email": "ops@example.com" + } + }); + assert!( + validator.is_valid(&instance), + "both uses of the shared definition must still accept valid instances" + ); + } + /// Nested object levels are closed so that a later definition of the type /// can add an optional property backward compatibly (gts-spec sec 4.4-4.5), /// while the levels where closing would be wrong are left alone. diff --git a/gts/Cargo.toml b/gts/Cargo.toml index 331a6f2..dc6f3bd 100644 --- a/gts/Cargo.toml +++ b/gts/Cargo.toml @@ -21,6 +21,7 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true jsonschema.workspace = true +num-cmp.workspace = true schemars.workspace = true walkdir.workspace = true tracing.workspace = true diff --git a/gts/src/ops.rs b/gts/src/ops.rs index 15a37be..fc8dbf1 100644 --- a/gts/src/ops.rs +++ b/gts/src/ops.rs @@ -8,7 +8,9 @@ use crate::entities::{GtsConfig, GtsEntity}; use crate::files_reader::GtsFileReader; use crate::gts::{GtsId, GtsIdPattern}; use crate::path_resolver::JsonPathResolver; -use crate::schema_cast::{CompatibilityVerdict, GtsEntityCastResult}; +#[cfg(test)] +use crate::schema_cast::CompatibilityVerdict; +use crate::schema_cast::GtsEntityCastResult; use crate::store::{GtsStore, GtsStoreQueryResult}; /// `is_schema` is `Some(true)` for schema/type IDs (ending with `~`), @@ -673,26 +675,7 @@ impl GtsOps { pub fn cast(&mut self, from_id: &str, to_type_id: &str) -> GtsEntityCastResult { match self.store.cast(from_id, to_type_id) { Ok(result) => result, - Err(e) => GtsEntityCastResult { - from_id: from_id.to_owned(), - to_id: to_type_id.to_owned(), - old: from_id.to_owned(), - new: to_type_id.to_owned(), - direction: "unknown".to_owned(), - added_properties: Vec::new(), - removed_properties: Vec::new(), - changed_properties: Vec::new(), - full_compatibility: CompatibilityVerdict::Unknown, - backward_compatibility: CompatibilityVerdict::Unknown, - forward_compatibility: CompatibilityVerdict::Unknown, - incompatibility_reasons: Vec::new(), - backward_errors: Vec::new(), - forward_errors: Vec::new(), - specification_version: crate::GTS_SPECIFICATION_VERSION.to_owned(), - implementation_version: crate::GTS_IMPLEMENTATION_VERSION.to_owned(), - casted_entity: None, - error: Some(e.to_string()), - }, + Err(e) => GtsEntityCastResult::undecided(from_id, to_type_id, e.to_string()), } } diff --git a/gts/src/schema_cast.rs b/gts/src/schema_cast.rs index 263c294..7311ecf 100644 --- a/gts/src/schema_cast.rs +++ b/gts/src/schema_cast.rs @@ -1,3 +1,4 @@ +use num_cmp::NumCmp; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::collections::{HashMap, HashSet}; @@ -411,6 +412,42 @@ fn record_unproven_intersection(schema: &mut Map, reason: String) } impl GtsEntityCastResult { + /// Builds an error result for a compatibility or cast outcome that could not + /// be decided. + pub(crate) fn undecided(from_id: &str, to_id: &str, message: impl Into) -> Self { + Self::undecided_with_direction(from_id, to_id, "unknown", message) + } + + /// Same as [`Self::undecided`], retaining a direction already established + /// independently of the failed compatibility check. + pub(crate) fn undecided_with_direction( + from_id: &str, + to_id: &str, + direction: impl Into, + message: impl Into, + ) -> Self { + Self { + from_id: from_id.to_owned(), + to_id: to_id.to_owned(), + old: from_id.to_owned(), + new: to_id.to_owned(), + direction: direction.into(), + added_properties: Vec::new(), + removed_properties: Vec::new(), + changed_properties: Vec::new(), + full_compatibility: CompatibilityVerdict::Unknown, + backward_compatibility: CompatibilityVerdict::Unknown, + forward_compatibility: CompatibilityVerdict::Unknown, + incompatibility_reasons: Vec::new(), + backward_errors: Vec::new(), + forward_errors: Vec::new(), + specification_version: specification_version(), + implementation_version: implementation_version(), + casted_entity: None, + error: Some(message.into()), + } + } + /// Casts an instance from one schema to another. /// /// # Errors @@ -842,13 +879,20 @@ impl GtsEntityCastResult { exclusive_key: &str, is_lower: bool, ) -> Result, ()> { + // `total_cmp` orders `-0.0` below `0.0`, but the two denote the same JSON + // number and must compare equal, so the sign of zero is dropped as the + // bound is read. + let bound_value = |value: &Value| -> Result { + let value = value.as_f64().ok_or(())?; + Ok(if value == 0.0 { 0.0 } else { value }) + }; let inclusive = match schema.get(inclusive_key) { - Some(value) => Some((value.as_f64().ok_or(())?, false)), + Some(value) => Some((bound_value(value)?, false)), None => None, }; let exclusive = match schema.get(exclusive_key) { Some(Value::Bool(is_exclusive)) => inclusive.map(|(value, _)| (value, *is_exclusive)), - Some(value) => Some((value.as_f64().ok_or(())?, true)), + Some(value) => Some((bound_value(value)?, true)), None => None, }; @@ -1017,6 +1061,13 @@ impl GtsEntityCastResult { let old_value = old_schema.get(*keyword); let new_value = new_schema.get(*keyword); match (old_value, new_value) { + // `multipleOf` is a number, so the two spellings of one + // mathematical value are not a change. + (Some(old_value), Some(new_value)) + if json_values_equal(old_value, new_value) => + { + None + } _ if old_value == new_value => None, // Added: narrows, so forward-only. (None, Some(_)) if check_backward => Some(CompatibilityDiagnostic::new( @@ -1093,12 +1144,14 @@ impl GtsEntityCastResult { match value { Value::Null => "null", Value::Bool(_) => "boolean", + // JSON Schema's `integer` matches a number with a zero + // fractional part, so `1.0` is an integer. The test must be + // exact: a tolerance would also swallow tiny nonzero fractions + // such as `1e-20`, which no `integer` schema accepts. Value::Number(number) if number.is_i64() || number.is_u64() - || number - .as_f64() - .is_some_and(|value| value.fract().abs() < f64::EPSILON) => + || number.as_f64().is_some_and(|value| value.fract() == 0.0) => { "integer" } @@ -1121,17 +1174,12 @@ impl GtsEntityCastResult { }), Some(_) => TypeSet::Invalid, None => { - let values: Option> = if let Some(value) = schema.get("const") { - Some(vec![value]) - } else { - schema - .get("enum") - .and_then(Value::as_array) - .map(|values| values.iter().collect()) - }; + // With no `type`, the effective types are those of the + // values `const` and `enum` accept between them. + let values = GtsEntityCastResult::accepted_value_set(schema); values.map_or(TypeSet::Any, |values| { let mut names = Vec::new(); - for value in values { + for value in &values { let name = value_type(value).to_owned(); if !names.contains(&name) { names.push(name); @@ -1182,49 +1230,91 @@ impl GtsEntityCastResult { } } - fn check_enum_compatibility( + /// The finite set of instances a level accepts through `const` and `enum`, + /// or `None` when neither keyword constrains it. + /// + /// An instance must satisfy every keyword present, so two coexisting + /// keywords accept their intersection - possibly nothing at all. + fn accepted_value_set(schema: &Map) -> Option> { + // A non-array `enum` is not a valid constraint and nothing can be read + // from it, which is what `as_array` returning `None` expresses here. + let enumeration = schema.get("enum").and_then(Value::as_array); + match (schema.get("const"), enumeration) { + (None, None) => None, + (Some(constant), None) => Some(vec![constant.clone()]), + (None, Some(values)) => Some(values.clone()), + (Some(constant), Some(values)) => Some( + values + .iter() + .filter(|value| json_values_equal(value, constant)) + .cloned() + .collect(), + ), + } + } + + /// Compares the value sets `const` and `enum` impose, as one set. + /// + /// Both keywords restrict which concrete instances are accepted, so a + /// revision that moves between the two spellings only has a meaning when + /// they are read together: checking each keyword against its own + /// counterpart would read a keyword that is merely absent as an + /// unconstrained target and report the equivalent rewrite of + /// `{"const": 1}` into `{"enum": [1]}` as incompatible in both directions. + fn check_value_set_compatibility( path: &str, old_schema: &Map, new_schema: &Map, check_backward: bool, ) -> Vec { - let old_enum = old_schema.get("enum").and_then(Value::as_array); - let new_enum = new_schema.get("enum").and_then(Value::as_array); - - let incompatible_values: Vec<&Value> = match (old_enum, new_enum, check_backward) { - // Backward checks Valid(old) ⊆ Valid(new); forward checks the - // reverse inclusion. Expanding an enum is therefore backward-only. - (Some(old), Some(new), true) => { - old.iter().filter(|value| !new.contains(value)).collect() - } - (Some(old), Some(new), false) => { - new.iter().filter(|value| !old.contains(value)).collect() - } - (None, Some(_), true) | (Some(_), None, false) => { - return vec![CompatibilityDiagnostic::new( - path, - CompatibilityFinding::EnumChanged, - format!( - "{} enum constraint", - if old_enum.is_some() { - "removes" - } else { - "adds" - } - ), - )]; - } - _ => Vec::new(), + let old_values = Self::accepted_value_set(old_schema); + let new_values = Self::accepted_value_set(new_schema); + // Backward checks Valid(old) ⊆ Valid(new); forward checks the reverse + // inclusion. Expanding the set is therefore backward-only. + let (source, target) = if check_backward { + (old_values.as_deref(), new_values.as_deref()) + } else { + (new_values.as_deref(), old_values.as_deref()) }; - - if incompatible_values.is_empty() { - Vec::new() + let finding = if old_schema.contains_key("enum") || new_schema.contains_key("enum") { + CompatibilityFinding::EnumChanged } else { - vec![CompatibilityDiagnostic::new( + CompatibilityFinding::ConstraintChanged + }; + + match (source, target) { + // An unconstrained target accepts every value the source permits. + (_, None) => Vec::new(), + (None, Some(_)) => vec![CompatibilityDiagnostic::new( path, - CompatibilityFinding::EnumChanged, - format!("changes enum incompatibly: {incompatible_values:?}"), - )] + finding, + format!( + "{} the 'const'/'enum' value constraint", + if check_backward { "adds" } else { "removes" } + ), + )], + (Some(source), Some(target)) => { + let incompatible_values: Vec<&Value> = source + .iter() + .filter(|value| { + !target + .iter() + .any(|accepted| json_values_equal(value, accepted)) + }) + .collect(); + if incompatible_values.is_empty() { + Vec::new() + } else { + vec![CompatibilityDiagnostic::new( + path, + finding, + format!( + "changes the 'const'/'enum' value set incompatibly: \ + {incompatible_values:?}" + ), + )] + } + } } } @@ -1278,37 +1368,6 @@ impl GtsEntityCastResult { .collect() } - fn check_const_compatibility( - path: &str, - old_schema: &Map, - new_schema: &Map, - check_backward: bool, - ) -> Vec { - let old_const = old_schema.get("const"); - let new_const = new_schema.get("const"); - if old_const == new_const { - return Vec::new(); - } - - let (source, target) = if check_backward { - (old_const, new_const) - } else { - (new_const, old_const) - }; - // A source constrained to one value is included in an unconstrained - // target. The reverse is not; two different singleton sets are - // disjoint and therefore incompatible in either direction. - if source.is_some() && target.is_none() { - return Vec::new(); - } - - vec![CompatibilityDiagnostic::new( - path, - CompatibilityFinding::ConstraintChanged, - "changes 'const' constraint incompatibly".to_owned(), - )] - } - /// Reports a `$ref` that survived resolution. /// /// `$defs`/`definitions` are deliberately absent from @@ -1410,13 +1469,7 @@ impl GtsEntityCastResult { new_map, check_backward, )); - errors.extend(Self::check_enum_compatibility( - path, - old_map, - new_map, - check_backward, - )); - errors.extend(Self::check_const_compatibility( + errors.extend(Self::check_value_set_compatibility( path, old_map, new_map, @@ -1812,21 +1865,33 @@ impl GtsEntityCastResult { } let effective_old = declared_old.or(declared_new); let effective_new = declared_new.or(declared_old); - let supports_unevaluated = |dialect: Option<&str>| { - dialect.is_some_and(|value| value.contains("2019-09") || value.contains("2020-12")) - }; Self::check_schema_node_compatibility( old_schema, new_schema, "$", check_backward, - supports_unevaluated(effective_old), - supports_unevaluated(effective_new), + Self::dialect_supports_unevaluated(effective_old), + Self::dialect_supports_unevaluated(effective_new), &mut errors, ); (CompatibilityVerdict::from_diagnostics(&errors), errors) } + /// Whether `unevaluatedProperties` is evaluated under `dialect`. + /// + /// The keyword exists from Draft 2019-09 on; earlier dialects ignore it as + /// an unknown annotation. An omitted `$schema` means "whatever dialect the + /// implementation applies" - GTS is dialect-agnostic (sec 11) and names no + /// default - and this implementation validates instances with + /// [`jsonschema::validator_for`], which falls back to Draft 2020-12. Reading + /// an omitted dialect as pre-2019-09 would therefore make this checker + /// contradict the validator running in the same process: a level closed by + /// `unevaluatedProperties: false` would be classified open, which reverses + /// both verdicts for an added optional property. + fn dialect_supports_unevaluated(dialect: Option<&str>) -> bool { + dialect.is_none_or(|value| value.contains("2019-09") || value.contains("2020-12")) + } + /// Classifies the content model of every object level of a schema. /// /// The schema MUST already be `$ref`-resolved: gts-spec §4.4 requires the @@ -1845,8 +1910,7 @@ impl GtsEntityCastResult { #[must_use] pub fn classify_object_levels(schema: &Value) -> Vec { let dialect = schema.get("$schema").and_then(Value::as_str); - let supports_unevaluated = - dialect.is_some_and(|value| value.contains("2019-09") || value.contains("2020-12")); + let supports_unevaluated = Self::dialect_supports_unevaluated(dialect); let mut levels = Vec::new(); Self::collect_object_levels(schema, "$", supports_unevaluated, &mut levels); levels @@ -1896,6 +1960,96 @@ impl GtsEntityCastResult { } } +/// Compares two JSON values the way JSON Schema compares instances. +/// +/// `serde_json`'s `PartialEq` distinguishes the integer and float +/// representations of a number, but JSON Schema equality - the relation `const` +/// and `enum` are defined in terms of - compares numbers by mathematical +/// value, so `1` and `1.0` denote the same instance. Composites +/// compare member by member, which makes the numeric rule apply at any depth; +/// every other value type compares as `serde_json` already does. +fn json_values_equal(left: &Value, right: &Value) -> bool { + match (left, right) { + (Value::Number(left), Value::Number(right)) => json_numbers_equal(left, right), + (Value::Array(left), Value::Array(right)) => { + left.len() == right.len() + && left + .iter() + .zip(right.iter()) + .all(|(left, right)| json_values_equal(left, right)) + } + // Object member order carries no meaning, so equal length plus a match + // for every key of one side is equality. + (Value::Object(left), Value::Object(right)) => { + left.len() == right.len() + && left.iter().all(|(key, left)| { + right + .get(key) + .is_some_and(|right| json_values_equal(left, right)) + }) + } + _ => left == right, + } +} + +/// Compares two JSON numbers by mathematical value. +#[allow( + clippy::float_cmp, + reason = "JSON Schema equality is exact equality of the mathematical value" +)] +fn json_numbers_equal(left: &serde_json::Number, right: &serde_json::Number) -> bool { + // Integers are compared as integers: routing them through `f64` would round + // the 64-bit values a double cannot represent exactly and call two distinct + // numbers equal. + if let (Some(left), Some(right)) = (left.as_u64(), right.as_u64()) { + return left == right; + } + if let (Some(left), Some(right)) = (left.as_i64(), right.as_i64()) { + return left == right; + } + + let left_integer = left.is_u64() || left.is_i64(); + let right_integer = right.is_u64() || right.is_i64(); + // Two integers that neither comparison above could pair up are one negative + // value and one above `i64::MAX`, so they are not equal. + if left_integer && right_integer { + return false; + } + // One integer and one float. The pair is compared exactly rather than by + // converting both sides to `f64`, which would round `2^53 + 1` down to + // `2^53` and report two different mathematical values - two different + // accepted-instance sets - as equal. This is the comparator `jsonschema` + // applies to a mixed pair when it validates the same instance. + if left_integer { + return right + .as_f64() + .is_some_and(|right| integer_equals_float(left, right)); + } + if right_integer { + return left + .as_f64() + .is_some_and(|left| integer_equals_float(right, left)); + } + + match (left.as_f64(), right.as_f64()) { + (Some(left), Some(right)) => left == right, + // Not representable as `f64`, which needs `serde_json`'s + // `arbitrary_precision`; the stored representation is all that is left + // to compare. + _ => left == right, + } +} + +/// Compares an integer-valued JSON number to a float, exactly. +fn integer_equals_float(integer: &serde_json::Number, float: f64) -> bool { + if let Some(integer) = integer.as_u64() { + return NumCmp::num_eq(integer, float); + } + integer + .as_i64() + .is_some_and(|integer| NumCmp::num_eq(integer, float)) +} + fn render_diagnostics(diagnostics: &[CompatibilityDiagnostic]) -> Vec { diagnostics .iter() @@ -1994,6 +2148,38 @@ mod tests { assert_eq!(direction, "up"); } + #[test] + fn test_undecided_result_initializes_error_contract() { + let result = GtsEntityCastResult::undecided("old", "new", "could not decide"); + + assert_eq!(result.from_id, "old"); + assert_eq!(result.to_id, "new"); + assert_eq!(result.direction, "unknown"); + assert!(result.full_compatibility.is_unknown()); + assert!(result.backward_compatibility.is_unknown()); + assert!(result.forward_compatibility.is_unknown()); + assert!(result.added_properties.is_empty()); + assert!(result.removed_properties.is_empty()); + assert!(result.changed_properties.is_empty()); + assert!(result.incompatibility_reasons.is_empty()); + assert!(result.backward_errors.is_empty()); + assert!(result.forward_errors.is_empty()); + assert_eq!( + result.specification_version, + crate::GTS_SPECIFICATION_VERSION + ); + assert_eq!( + result.implementation_version, + crate::GTS_IMPLEMENTATION_VERSION + ); + assert!(result.casted_entity.is_none()); + assert_eq!(result.error.as_deref(), Some("could not decide")); + + let directed = + GtsEntityCastResult::undecided_with_direction("old", "new", "up", "resolution failed"); + assert_eq!(directed.direction, "up"); + } + #[test] fn test_json_entity_cast_result_infer_direction_down() { let direction = GtsEntityCastResult::infer_direction( @@ -2621,6 +2807,120 @@ mod tests { assert!(removed.forward_compatibility.is_incompatible()); } + /// `const` and `enum` constrain the same thing, so a revision that moves + /// between the two spellings must be read as one value set. + #[test] + fn test_const_and_enum_form_one_value_set() { + // Valid({"const": 1}) = Valid({"enum": [1]}) = {1}. + let rewritten = property_change(json!({"const": 1}), json!({"enum": [1]})); + assert!(rewritten.full_compatibility.is_compatible()); + + let rewritten_back = property_change(json!({"enum": [1]}), json!({"const": 1})); + assert!(rewritten_back.full_compatibility.is_compatible()); + + // Widening the singleton into a larger set is backward-only. + let widened = property_change(json!({"const": 1}), json!({"enum": [1, 2]})); + assert!(widened.backward_compatibility.is_compatible()); + assert!(widened.forward_compatibility.is_incompatible()); + + // Narrowing an enum down to one of its members is forward-only. + let narrowed = property_change(json!({"enum": [1, 2]}), json!({"const": 1})); + assert!(narrowed.backward_compatibility.is_incompatible()); + assert!(narrowed.forward_compatibility.is_compatible()); + + // A value outside the old set is incompatible in either direction. + let moved = property_change(json!({"const": 1}), json!({"enum": [2]})); + assert!(moved.backward_compatibility.is_incompatible()); + assert!(moved.forward_compatibility.is_incompatible()); + + // Both keywords at once accept only what satisfies both. + let intersected = property_change(json!({"const": 1, "enum": [1, 2]}), json!({"const": 1})); + assert!(intersected.full_compatibility.is_compatible()); + } + + /// JSON Schema compares values by mathematical value, so the integer and + /// float spellings of one number denote the same instance. + #[test] + fn test_value_sets_use_json_schema_equality() { + let respelled = property_change(json!({"const": 1}), json!({"enum": [1.0]})); + assert!(respelled.full_compatibility.is_compatible()); + + // The rule applies at any depth inside a composite value. + let nested = property_change( + json!({"const": {"a": [1, {"b": 2}]}}), + json!({"const": {"a": [1.0, {"b": 2.0}]}}), + ); + assert!(nested.full_compatibility.is_compatible()); + + // Narrowing still has to be seen through the respelling. + let narrowed = property_change(json!({"enum": [1, 2]}), json!({"const": 2.0})); + assert!(narrowed.backward_compatibility.is_incompatible()); + assert!(narrowed.forward_compatibility.is_compatible()); + + // Equal mathematical value is not equal representation of anything else: + // a different number, a different type, or a differing member count all + // remain distinct values. + for (old_value, new_value) in [ + (json!(1), json!(1.5)), + (json!(1), json!("1")), + (json!(1), json!(true)), + (json!([1]), json!([1, 1])), + (json!({"a": 1}), json!({"a": 1, "b": 1})), + ] { + let moved = property_change(json!({"const": old_value}), json!({"const": new_value})); + assert!( + moved.backward_compatibility.is_incompatible(), + "{old_value} vs {new_value}" + ); + assert!( + moved.forward_compatibility.is_incompatible(), + "{old_value} vs {new_value}" + ); + } + + // The same equality decides whether a narrowing keyword changed at all. + let respelled_multiple_of = + property_change(json!({"multipleOf": 5}), json!({"multipleOf": 5.0})); + assert!(respelled_multiple_of.full_compatibility.is_compatible()); + } + + /// Comparing a mixed integer/float pair has to be exact: rounding both sides + /// to `f64` would erase the difference between `2^53 + 1` and `2^53`. + #[test] + fn test_value_set_equality_is_exact_across_number_types() { + // 9007199254740993 is 2^53 + 1, which no `f64` represents. + let rounded = property_change( + json!({"const": 9_007_199_254_740_993_i64}), + json!({"enum": [9_007_199_254_740_992.0_f64]}), + ); + assert!(rounded.backward_compatibility.is_incompatible()); + assert!(rounded.forward_compatibility.is_incompatible()); + + // 2^53 itself is exactly representable, so its two spellings are one + // value and the comparison must still see that. + let exact = property_change( + json!({"const": 9_007_199_254_740_992_i64}), + json!({"enum": [9_007_199_254_740_992.0_f64]}), + ); + assert!(exact.full_compatibility.is_compatible()); + + // The same number kept as an integer on both sides. + let integral = property_change( + json!({"const": 9_007_199_254_740_993_i64}), + json!({"enum": [9_007_199_254_740_993_i64]}), + ); + assert!(integral.full_compatibility.is_compatible()); + + // A `u64` above `i64::MAX` and a negative number share no + // representation to be compared through, and are not equal. + let mixed_signedness = property_change( + json!({"const": 18_446_744_073_709_551_615_u64}), + json!({"const": -1_i64}), + ); + assert!(mixed_signedness.backward_compatibility.is_incompatible()); + assert!(mixed_signedness.forward_compatibility.is_incompatible()); + } + #[test] fn test_boolean_schemas_follow_set_inclusion() { let narrowed = check_schema_compatibility(&json!(true), &json!(false)); @@ -2987,6 +3287,23 @@ mod tests { assert!(upper.forward_compatibility.is_compatible()); } + /// `-0.0` and `0.0` denote the same JSON number, so respelling a bound + /// changes no accepted instance. + #[test] + fn test_signed_zero_bounds_are_equal() { + let lower = property_change( + json!({"type": "number", "minimum": -0.0}), + json!({"type": "number", "minimum": 0.0}), + ); + assert!(lower.full_compatibility.is_compatible()); + + let upper = property_change( + json!({"type": "number", "maximum": -0.0}), + json!({"type": "number", "maximum": 0.0}), + ); + assert!(upper.full_compatibility.is_compatible()); + } + /// Draft-04 spells `exclusiveMinimum` as a boolean modifier, which a numeric /// comparison would silently ignore. #[test] @@ -3048,6 +3365,13 @@ mod tests { let integral_number = property_change(json!({"type": "integer"}), json!({"const": 1.0})); assert!(integral_number.backward_compatibility.is_incompatible()); assert!(integral_number.forward_compatibility.is_compatible()); + + // A tiny nonzero fraction is not an integer, however close to one it + // lands: `{"const": 1e-20}` is the sole value the new schema accepts and + // `{"type": "integer"}` rejects it. + let tiny_fraction = property_change(json!({"type": "integer"}), json!({"const": 1e-20})); + assert!(tiny_fraction.backward_compatibility.is_incompatible()); + assert!(tiny_fraction.forward_compatibility.is_incompatible()); } #[test] @@ -3163,6 +3487,41 @@ mod tests { assert!(result.forward_compatibility.is_incompatible()); } + /// With no `$schema` anywhere the dialect is the one this implementation + /// applies when validating instances, which is Draft 2020-12 - so + /// `unevaluatedProperties` closes the level here too. + #[test] + fn test_undeclared_dialect_evaluates_unevaluated_properties() { + let old_schema = json!({ + "type": "object", + "unevaluatedProperties": false, + "properties": {"name": {"type": "string"}} + }); + let new_schema = json!({ + "type": "object", + "unevaluatedProperties": false, + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); + + // The instance validator this crate builds must agree with the verdict. + let validator = jsonschema::validator_for(&old_schema).expect("compile schema"); + assert!(!validator.is_valid(&json!({"name": "n", "email": "e"}))); + + // The same dialect decides the reported content model of a level. + let levels = GtsEntityCastResult::classify_object_levels(&old_schema); + assert_eq!( + levels.first().map(|level| level.content_model), + Some(ContentModel::Closed) + ); + } + #[test] fn test_boolean_equivalent_property_schemas_classify_semantically() { let additional_open = json!({ diff --git a/gts/src/store.rs b/gts/src/store.rs index cd752de..fda54b3 100644 --- a/gts/src/store.rs +++ b/gts/src/store.rs @@ -850,54 +850,44 @@ impl GtsStore { .map_err(|e| StoreError::SchemaNotFound(e.to_string())) } + /// Fetches one side of a compatibility comparison, rendering the failure as + /// the message the result carries. + /// + /// A missing schema keeps the historical `"Schema not found"` wording, which + /// clients match on; every other cause - a malformed type id, an id naming a + /// registered non-schema entity - reports itself, so the caller can tell an + /// unregistered type from a request it should not have made at all. + fn compared_schema_entity(&mut self, type_id: &str) -> Result { + self.get_schema_entity(type_id) + .cloned() + .map_err(|error| match error { + StoreError::SchemaNotFound(_) => "Schema not found".to_owned(), + error => error.to_string(), + }) + } + /// Checks GTS schema-evolution compatibility using accepted-instance set inclusion. pub fn is_compatible(&mut self, old_type_id: &str, new_type_id: &str) -> GtsEntityCastResult { - let old_entity = self.get(old_type_id).cloned(); - let new_entity = self.get(new_type_id).cloned(); - - let (Some(old_ent), Some(new_ent)) = (old_entity, new_entity) else { - let message = "Schema not found".to_owned(); - return GtsEntityCastResult { - from_id: old_type_id.to_owned(), - to_id: new_type_id.to_owned(), - old: old_type_id.to_owned(), - new: new_type_id.to_owned(), - direction: "unknown".to_owned(), - added_properties: Vec::new(), - removed_properties: Vec::new(), - changed_properties: Vec::new(), - full_compatibility: CompatibilityVerdict::Unknown, - backward_compatibility: CompatibilityVerdict::Unknown, - forward_compatibility: CompatibilityVerdict::Unknown, - incompatibility_reasons: Vec::new(), - backward_errors: Vec::new(), - forward_errors: Vec::new(), - specification_version: crate::GTS_SPECIFICATION_VERSION.to_owned(), - implementation_version: crate::GTS_IMPLEMENTATION_VERSION.to_owned(), - casted_entity: None, - error: Some(message), - }; + let entities = self + .compared_schema_entity(old_type_id) + .and_then(|old_ent| { + self.compared_schema_entity(new_type_id) + .map(|new_ent| (old_ent, new_ent)) + }); + let (old_ent, new_ent) = match entities { + Ok(entities) => entities, + Err(message) => { + return GtsEntityCastResult::undecided(old_type_id, new_type_id, message); + } }; - let resolution_failure = |message: String| GtsEntityCastResult { - from_id: old_type_id.to_owned(), - to_id: new_type_id.to_owned(), - old: old_type_id.to_owned(), - new: new_type_id.to_owned(), - direction: GtsEntityCastResult::infer_direction(old_type_id, new_type_id), - added_properties: Vec::new(), - removed_properties: Vec::new(), - changed_properties: Vec::new(), - full_compatibility: CompatibilityVerdict::Unknown, - backward_compatibility: CompatibilityVerdict::Unknown, - forward_compatibility: CompatibilityVerdict::Unknown, - incompatibility_reasons: Vec::new(), - backward_errors: Vec::new(), - forward_errors: Vec::new(), - specification_version: crate::GTS_SPECIFICATION_VERSION.to_owned(), - implementation_version: crate::GTS_IMPLEMENTATION_VERSION.to_owned(), - casted_entity: None, - error: Some(message), + let resolution_failure = |message: String| { + GtsEntityCastResult::undecided_with_direction( + old_type_id, + new_type_id, + GtsEntityCastResult::infer_direction(old_type_id, new_type_id), + message, + ) }; let old_schema = match self.resolve_schema_refs(&old_ent.content) { Ok(schema) => schema, diff --git a/gts/src/store_test.rs b/gts/src/store_test.rs index b154dca..a630df0 100644 --- a/gts/src/store_test.rs +++ b/gts/src/store_test.rs @@ -713,11 +713,68 @@ fn test_gts_store_cast_entity_without_schema() { #[test] fn test_gts_store_is_minor_compatible_missing_schemas() { let mut store = GtsStore::new(); - let result = store.is_minor_compatible("nonexistent1~", "nonexistent2~"); + let result = store.is_minor_compatible( + "gts.vendor.package.namespace.nonexistent1.v1~", + "gts.vendor.package.namespace.nonexistent2.v1~", + ); assert!(result.backward_compatibility.is_unknown()); assert_eq!(result.error.as_deref(), Some("Schema not found")); } +/// A malformed id is not an unregistered type, so it reports itself instead of +/// borrowing the "Schema not found" wording. +#[test] +fn test_gts_store_is_compatible_reports_malformed_type_id() { + let mut store = GtsStore::new(); + let result = store.is_compatible("nonexistent1~", "gts.vendor.package.namespace.type.v1~"); + assert!(result.backward_compatibility.is_unknown()); + let error = result.error.expect("a malformed id must be reported"); + assert!( + error.starts_with("Invalid GTS type id: "), + "expected the id parse error, got: {error}" + ); +} + +#[test] +fn test_gts_store_is_compatible_rejects_non_schema_entity() { + let mut store = GtsStore::new(); + let cfg = GtsConfig::default(); + let old_id = "gts.vendor.package.namespace.type.v1.0~"; + let new_id = "gts.vendor.package.namespace.type.v1.1~"; + let content = json!({ + "id": old_id, + "name": "not a schema" + }); + let entity = GtsEntity::new( + None, + None, + &content, + Some(&cfg), + Some(GtsId::try_new(old_id).expect("test")), + false, + String::new(), + None, + None, + ); + store.register(entity).expect("register instance"); + store + .register_schema( + new_id, + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object" + }), + ) + .expect("register schema"); + + let result = store.is_compatible(old_id, new_id); + assert!(result.full_compatibility.is_unknown()); + assert_eq!( + result.error.as_deref(), + Some("Entity is invalid: Entity 'gts.vendor.package.namespace.type.v1.0~' is not a schema") + ); +} + #[test] fn test_gts_store_validate_instance_with_refs() { let mut store = GtsStore::new(); From 6c0fcc616ea5a31da55666763e9fa0478d662d55 Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Wed, 5 Aug 2026 00:20:17 +0800 Subject: [PATCH 5/8] fix(traits): Stop materializing const values - Materialize absent traits from defaults only, leaving const as a validation assertion. - Treat required const-only traits as unresolved and cover const/default behavior. - Pin the conformance suite to v0.13.1. Signed-off-by: Aviator 5 --- .gts-spec-version | 2 +- gts/src/schema_traits.rs | 143 +++++++++++++++++++++++++-------------- gts/src/store_test.rs | 8 ++- 3 files changed, 97 insertions(+), 56 deletions(-) diff --git a/.gts-spec-version b/.gts-spec-version index 6345c21..b561134 100644 --- a/.gts-spec-version +++ b/.gts-spec-version @@ -1 +1 @@ -v0.13.0 +v0.13.1 diff --git a/gts/src/schema_traits.rs b/gts/src/schema_traits.rs index a36a3d7..483f539 100644 --- a/gts/src/schema_traits.rs +++ b/gts/src/schema_traits.rs @@ -23,10 +23,11 @@ //! preserved from the ancestor). //! - Arrays: replace wholesale (no element-wise merge). //! - `null` at any depth deletes the key, after which `materialize_traits` may -//! re-substitute a `const` or `default`. +//! re-substitute a `default` (never a `const` — see `materialize_traits`). //! - Locking publisher-controlled values is done via JSON Schema `const` in //! `x-gts-traits-schema`; the registry carries no GTS-specific immutability -//! rule. +//! rule. `const` locks the value but not the presence, so a lock that must +//! also survive deletion pairs `const` with `required` or with `default`. //! //! **Empty trait schemas:** If a schema in the chain declares //! `x-gts-traits-schema: {}` or `true`, it contributes an unconstrained @@ -65,7 +66,7 @@ const MAX_RECURSION_DEPTH: usize = 64; pub(crate) struct EffectiveTraits { /// Dialect-pinned, `allOf`-composed effective trait schema. pub schema: Value, - /// Chain-merged (RFC 7396) and const/default-materialized trait values. + /// Chain-merged (RFC 7396) and default-materialized trait values. pub values: Value, /// `$ref`-resolved `x-gts-traits-schema` subschemas, root → leaf — retained /// for per-index integrity checks and the closed-entity check. @@ -574,8 +575,8 @@ fn collect_traits_recursive( /// - Objects merge recursively (keys not restated by `patch` are preserved). /// - `null` values **delete** the corresponding key from `target`; if the /// target had no such key the null is a no-op (the key remains absent so -/// `materialize_traits` can later substitute a `const`/`default` from the -/// trait schema). +/// `materialize_traits` can later substitute a `default` from the trait +/// schema). /// /// This is the trait-merge primitive used to compose `x-gts-traits` along the /// `$id` chain (root → leaf). @@ -648,24 +649,27 @@ pub(crate) fn build_effective_traits_schema(schemas: &[Value]) -> Value { /// Materialize trait values from the effective trait schema onto the merged /// traits object, filling any property that is not yet present. /// -/// Resolution precedence for an absent property is **`const` → `default`**: a -/// `const` locks the value (it is the only value the schema accepts, so the -/// effective value is fully determined even when the chain never restates it), -/// and `default` fills the rest. A property already supplied by the chain is -/// left as-is — a value that conflicts with a `const`/enum is caught by the -/// later JSON Schema validation, which gives a clearer error than silently -/// overwriting it here. +/// An absent property is filled from its `default` only. `const` is deliberately +/// NOT materialized: per gts-spec README §9.7.5 and ADR-0003, materialization is +/// defined over `default` alone, and `const` is a JSON Schema *assertion* — it +/// constrains a value that is present rather than supplying a missing one. A +/// publisher who wants a locked value to also survive absence (including an +/// RFC 7396 `null` deletion) declares `default` alongside `const`. +/// +/// A property already supplied by the chain is left as-is — a value that +/// conflicts with a `const`/enum is caught by the later JSON Schema validation, +/// which gives a clearer error than silently overwriting it here. /// /// Handles nested object properties recursively: if a present trait property is -/// an object type with its own `properties`, nested `const`/`default` values -/// are materialized into the corresponding nested object. +/// an object type with its own `properties`, nested `default` values are +/// materialized into the corresponding nested object. fn materialize_traits(trait_schema: &Value, traits: &Value) -> Value { materialize_traits_recursive(trait_schema, traits, 0) } /// Per-property materialization view: (most-derived declaration, nearest -/// `const`, nearest `default`). -type PropResolution = (Value, Option, Option); +/// `default`). +type PropResolution = (Value, Option); fn materialize_traits_recursive(trait_schema: &Value, traits: &Value, depth: usize) -> Value { if depth >= MAX_RECURSION_DEPTH { @@ -683,8 +687,8 @@ fn materialize_traits_recursive(trait_schema: &Value, traits: &Value, depth: usi let mut all_props: Vec<(String, Value)> = Vec::new(); collect_props_recursive(trait_schema, &mut all_props, 0); - // Resolve each property once. `const`/`default` are taken from the *nearest* - // (most-derived) declaration that carries them — scanning leaf→root — because + // Resolve each property once. `default` is taken from the *nearest* + // (most-derived) declaration that carries it — scanning leaf→root — because // `default` does not participate in narrowing, so an ancestor default ripples // to descendants even when a descendant redeclares the property without one // (gts-spec §9.7.2, ADR-0003). The most-derived declaration also drives the @@ -696,28 +700,22 @@ fn materialize_traits_recursive(trait_schema: &Value, traits: &Value, depth: usi let obj = sch.as_object(); let entry = resolved.entry(name.clone()).or_insert_with(|| { order.push(name.clone()); - (sch.clone(), None, None) + (sch.clone(), None) }); if entry.1.is_none() - && let Some(const_val) = obj.and_then(|o| o.get("const")) - { - entry.1 = Some(const_val.clone()); - } - if entry.2.is_none() && let Some(default_val) = obj.and_then(|o| o.get("default")) { - entry.2 = Some(default_val.clone()); + entry.1 = Some(default_val.clone()); } } for name in &order { - let (prop_schema, nearest_const, nearest_default) = &resolved[name]; + let (prop_schema, nearest_default) = &resolved[name]; if !result.contains_key(name.as_str()) { - // Property is absent — a `const` locks the value (highest priority), - // otherwise fall back to the nearest `default` up the chain. - if let Some(const_val) = nearest_const { - result.insert(name.clone(), const_val.clone()); - } else if let Some(default_val) = nearest_default { + // Property is absent — fill from the nearest `default` up the chain. + // A `const` is not substituted here: it asserts the value of a + // present property, it does not supply a missing one. + if let Some(default_val) = nearest_default { result.insert(name.clone(), default_val.clone()); } } else if result.get(name.as_str()).is_some_and(Value::is_object) @@ -922,14 +920,15 @@ fn validate_traits_against_schema( let has_value = traits_obj.is_some_and(|m| m.contains_key(prop_name.as_str())); - // A `const` fully determines the value (materialized by - // `materialize_traits`), so it resolves the property just like a - // `default` does. - let has_default_or_const = prop_schema + // Only a `default` resolves an absent property (see + // `materialize_traits`). A `const` does not: it constrains a value that + // is present, so a required-and-`const` property with no value anywhere + // in the chain and no `default` is still an unresolved hole. + let has_default = prop_schema .as_object() - .is_some_and(|m| m.contains_key("default") || m.contains_key("const")); + .is_some_and(|m| m.contains_key("default")); - if !has_value && !has_default_or_const { + if !has_value && !has_default { let expected_type = prop_schema .as_object() .and_then(|m| m.get("type")) @@ -937,9 +936,9 @@ fn validate_traits_against_schema( .unwrap_or("any"); errors.push(format!( "trait property '{prop_name}' (type: {expected_type}) is not resolved: \ - no value provided and no default or const defined in the trait schema. \ - All traits must be resolved (via a {X_GTS_TRAITS} value in the chain \ - or a `default`/`const` in the trait schema) on non-abstract types; \ + no value provided and no default defined in the trait schema. \ + All required traits must be resolved (via a {X_GTS_TRAITS} value in \ + the chain or a `default` in the trait schema) on non-abstract types; \ otherwise mark the type abstract (x-gts-abstract: true)" )); } @@ -1093,10 +1092,14 @@ mod tests { } #[test] - fn test_const_only_required_trait_resolves_and_materializes() { - // A required trait whose schema pins a `const` (no default, no explicit - // x-gts-traits value) must (a) be materialized into the effective traits - // and (b) pass completeness — its value is fully determined by the lock. + fn test_const_only_required_trait_is_not_materialized() { + // gts-spec README §9.7.5 / ADR-0003: materialization is defined over + // `default` alone. A required trait whose schema only pins a `const` + // (no default, no explicit x-gts-traits value) must (a) stay absent from + // the effective traits and (b) fail completeness as an unresolved hole — + // `const` asserts the value of a property that is present, it does not + // supply a missing one. Publishers pair `const` with `default` to get a + // self-resolving lock. let schemas = vec![json!({ "type": "object", "additionalProperties": false, @@ -1110,29 +1113,65 @@ mod tests { &json!({}), Some("http://json-schema.org/draft-07/schema#"), ); - assert_eq!( - traits.values["channel"], "audit", - "const must be materialized into effective traits values" + assert!( + traits.values.get("channel").is_none(), + "const must not be materialized into effective traits values: {:?}", + traits.values ); + assert!( + traits.validate(true).is_err(), + "a required const-only trait with no value and no default is unresolved" + ); + } + + #[test] + fn test_const_with_matching_default_resolves_required_trait() { + // The spec-sanctioned self-resolving lock: `const` fixes the value, + // `default` supplies it when the chain never states it (and after an + // RFC 7396 `null` deletion). + let schemas = vec![json!({ + "type": "object", + "properties": { + "channel": {"type": "string", "const": "audit", "default": "audit"} + }, + "required": ["channel"] + })]; + let traits = build_effective_traits( + &schemas, + &json!({}), + Some("http://json-schema.org/draft-07/schema#"), + ); + assert_eq!(traits.values["channel"], "audit"); assert!( traits.validate(true).is_ok(), - "a const-locked required trait is fully resolved: {:?}", + "const + matching default is fully resolved: {:?}", traits.validate(true) ); } #[test] - fn test_const_takes_priority_over_default_in_materialization() { + fn test_default_contradicting_const_fails_validation() { + // Only `default` is materialized, so a `default` that violates the + // property's own `const` produces a type that fails validation rather + // than being silently corrected to the const value. let schemas = vec![json!({ "type": "object", "properties": { "mode": {"type": "string", "const": "locked", "default": "open"} } })]; - let traits = build_effective_traits(&schemas, &json!({}), None); + let traits = build_effective_traits( + &schemas, + &json!({}), + Some("http://json-schema.org/draft-07/schema#"), + ); assert_eq!( - traits.values["mode"], "locked", - "const wins over default when the value is absent" + traits.values["mode"], "open", + "the default is materialized as declared, const does not override it" + ); + assert!( + traits.validate(true).is_err(), + "a default contradicting const must fail validation" ); } diff --git a/gts/src/store_test.rs b/gts/src/store_test.rs index a630df0..cd3f797 100644 --- a/gts/src/store_test.rs +++ b/gts/src/store_test.rs @@ -5147,7 +5147,9 @@ fn test_op13_chain4_merge_defaults_consts_nulls_via_validate_schema() { // - tier: base "standard" -> l1 "premium" => leaf-most wins // - region: base "eu" -> l2 `null` (delete) => falls back to default "us" // - retention: only leaf "P90D" => overrides default - // - locked: never provided, schema `const: "X"` => const materializes + // - locked: never provided, schema `const: "X"` => stays ABSENT; a + // `const` is an assertion, not a source of values, and the + // property is optional so its absence is valid // - optional: never provided, schema `default: "d"` => default materializes let mut store = GtsStore::new(); let base = "gts.x.c4.tr.base.v1~"; @@ -5186,10 +5188,10 @@ fn test_op13_chain4_merge_defaults_consts_nulls_via_validate_schema() { "retention": "P90D", "tier": "premium", "region": "us", - "locked": "X", "optional": "d" }), - "merge across 4 levels must honor leaf-wins, null-delete->default, const, and default" + "merge across 4 levels must honor leaf-wins, null-delete->default, and \ + default-only materialization (no const substitution)" ); } From 3e712ee6d7e412462f3f2603c63dcfb91f1a1156 Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Wed, 5 Aug 2026 13:10:55 +0800 Subject: [PATCH 6/8] fix(macros): Preserve explicit additional properties models - Respect Schemars content models on base and derived GTS structs. - Keep automatically added derives before schemars helper attributes. - Document the behavior and add golden coverage for open, closed, map, and combinator schemas. Signed-off-by: Aviator 5 --- gts-macros/README.md | 26 ++++- gts-macros/src/lib.rs | 95 ++++++++++++++++--- .../additional_properties_content_models.rs | 38 ++++++++ ....test.golden.contentmodels.v1~.schema.json | 60 ++++++++++++ .../additional_properties_explicit_open.rs | 34 +++++++ ...x.test.golden.explicitopen.v1~.schema.json | 37 ++++++++ .../additional_properties_flattened_map.rs | 37 ++++++++ ...x.test.golden.flattenedmap.v1~.schema.json | 37 ++++++++ .../additional_properties_gts_derived_open.rs | 69 ++++++++++++++ ...ts.x.test.golden.openchain.v1~.schema.json | 24 +++++ ...in.v1~x.test.audit.payload.v1~.schema.json | 33 +++++++ ...ad.v1~x.test.final.payload.v1~.schema.json | 33 +++++++ .../additional_properties_gts_root_open.rs | 28 ++++++ ...gts.x.test.golden.rootopen.v1~.schema.json | 23 +++++ .../additional_properties_nested_closed.rs | 39 ++++++++ ...x.test.golden.nestedclosed.v1~.schema.json | 53 +++++++++++ gts-macros/tests/golden_tests.rs | 6 ++ 17 files changed, 656 insertions(+), 16 deletions(-) create mode 100644 gts-macros/tests/golden/additional_properties_content_models.rs create mode 100644 gts-macros/tests/golden/additional_properties_content_models/gts.x.test.golden.contentmodels.v1~.schema.json create mode 100644 gts-macros/tests/golden/additional_properties_explicit_open.rs create mode 100644 gts-macros/tests/golden/additional_properties_explicit_open/gts.x.test.golden.explicitopen.v1~.schema.json create mode 100644 gts-macros/tests/golden/additional_properties_flattened_map.rs create mode 100644 gts-macros/tests/golden/additional_properties_flattened_map/gts.x.test.golden.flattenedmap.v1~.schema.json create mode 100644 gts-macros/tests/golden/additional_properties_gts_derived_open.rs create mode 100644 gts-macros/tests/golden/additional_properties_gts_derived_open/gts.x.test.golden.openchain.v1~.schema.json create mode 100644 gts-macros/tests/golden/additional_properties_gts_derived_open/gts.x.test.golden.openchain.v1~x.test.audit.payload.v1~.schema.json create mode 100644 gts-macros/tests/golden/additional_properties_gts_derived_open/gts.x.test.golden.openchain.v1~x.test.audit.payload.v1~x.test.final.payload.v1~.schema.json create mode 100644 gts-macros/tests/golden/additional_properties_gts_root_open.rs create mode 100644 gts-macros/tests/golden/additional_properties_gts_root_open/gts.x.test.golden.rootopen.v1~.schema.json create mode 100644 gts-macros/tests/golden/additional_properties_nested_closed.rs create mode 100644 gts-macros/tests/golden/additional_properties_nested_closed/gts.x.test.golden.nestedclosed.v1~.schema.json diff --git a/gts-macros/README.md b/gts-macros/README.md index 225b366..6459c65 100644 --- a/gts-macros/README.md +++ b/gts-macros/README.md @@ -115,8 +115,8 @@ runtime. Levels the macro closes: -- the document root of a base type, and the level carrying a derived type's own properties - (it always did this); +- the document root of a base type, and the level carrying a derived type's own properties, + unless the source struct explicitly states an `additionalProperties` content model; - every nested object level that declares `properties` and states no content model of its own. Levels the macro deliberately leaves alone: @@ -128,7 +128,7 @@ Levels the macro deliberately leaves alone: | A struct that flattens a map | Schemars emits `additionalProperties: true`; closing would be wrong | | Branches of `allOf`/`anyOf`/`oneOf`/`not`/`if` | `additionalProperties` only sees `properties` from the same schema object, so closing a branch would reject the properties its siblings declare | -To keep a nested level open on purpose — as a designated extension point in the sense of +To keep an object level open on purpose — as a designated extension point in the sense of §4.4.1 — state the content model explicitly and the macro will not touch it: ```rust @@ -139,6 +139,23 @@ pub struct ExtensionPoint { } ``` +The same attribute is supported directly on a `struct_to_gts_schema` source struct. On a base +type it opens the document root; on a derived type it opens the nested object level that carries +that type's own properties: + +```rust +#[struct_to_gts_schema(/* ... */)] +#[derive(Debug)] +#[schemars(extend("additionalProperties" = true))] +pub struct DeliberatelyOpenType { + pub label: String, +} +``` + +Without an explicit content model, those macro-owned levels remain closed by default. The GTS +spec recommends a closed envelope with designated open containers for types that need both +in-place evolution and derivation, but an open root remains a valid authoring choice. + `#[serde(deny_unknown_fields)]` also still works and is the right choice when the wire contract should reject unknown fields at deserialization time as well, not just during schema validation. @@ -533,7 +550,8 @@ assert_eq!(schema1, schema2); // OK. Identical schemas **Schema structure:** -- **Base structs** (single-segment schema_id): Direct properties, no `allOf` +- **Base structs** (single-segment schema_id): Direct properties, no `allOf`; closed by default + unless the source struct explicitly declares an `additionalProperties` content model ```json { "$id": "gts://gts.x.core.events.type.v1~", diff --git a/gts-macros/src/lib.rs b/gts-macros/src/lib.rs index b8add5a..ac30124 100644 --- a/gts-macros/src/lib.rs +++ b/gts-macros/src/lib.rs @@ -508,9 +508,19 @@ fn add_missing_derives(input: &mut syn::DeriveInput, base: &BaseAttr) { let derives_str = derives_to_add.join(", "); let derives_tokens: proc_macro2::TokenStream = derives_str.parse().expect("Failed to parse derive tokens"); - input + let derive_attr = syn::parse_quote!(#[derive(#derives_tokens)]); + + // `schemars` is a derive-helper attribute. Keep the derive that + // introduces it before a container-level `#[schemars(...)]`; placing + // the automatically-added derive after the helper triggers + // `legacy_derive_helpers` in crates that deny future-incompatible + // lints. + let insert_at = input .attrs - .push(syn::parse_quote!(#[derive(#derives_tokens)])); + .iter() + .position(|attr| attr.path().is_ident("schemars")) + .unwrap_or(input.attrs.len()); + input.attrs.insert(insert_at, derive_attr); } } @@ -1564,10 +1574,11 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream // // Under GTS 0.13 an open object level cannot gain an optional property // backward compatibly, because the old schema already accepted arbitrary - // values under that name (gts-spec sec 4.4-4.5). The macro already closes - // every level it builds itself - the document root of a base type and the - // level carrying a derived type's own properties - but property subschemas - // come from `schemars::JsonSchema`, which emits + // values under that name (gts-spec sec 4.4-4.5). By default, the macro + // closes every level it builds itself - the document root of a base type + // and the level carrying a derived type's own properties - but preserves an + // `additionalProperties` content model explicitly emitted by Schemars for + // either level. Property subschemas come from `schemars::JsonSchema`, which emits // `additionalProperties: false` only for a struct declaring // `#[serde(deny_unknown_fields)]`. Closing those levels here makes // macro-generated types evolvable in place without asking every nested data @@ -1808,6 +1819,54 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream } }; + // A derived Rust struct is emitted as an overlay inside its parent's + // generic extension path rather than at the JSON document root. Preserve a + // content model explicitly declared on that struct by applying it to the + // object level that actually carries the struct's own properties. + let apply_declared_additional_properties_to_derived_level = quote! { + if let Some(declared_additional_properties) = declared_additional_properties { + fn set_additional_properties_at_path( + properties: &mut serde_json::Value, + path: &[&str], + additional_properties: serde_json::Value, + ) { + let Some((field, remaining)) = path.split_first() else { + return; + }; + let Some(schema) = properties + .as_object_mut() + .and_then(|object| object.get_mut(*field)) + else { + return; + }; + + if remaining.is_empty() { + if let Some(object) = schema.as_object_mut() { + object.insert( + "additionalProperties".to_owned(), + additional_properties, + ); + } + return; + } + + if let Some(nested_properties) = schema.get_mut("properties") { + set_additional_properties_at_path( + nested_properties, + remaining, + additional_properties, + ); + } + } + + set_additional_properties_at_path( + &mut nested_properties, + &path_refs, + declared_additional_properties, + ); + } + }; + let gts_schema_impl = if has_generic { let generic_param = input.generics.type_params().next().unwrap(); let generic_ident = &generic_param.ident; @@ -1899,6 +1958,8 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream let mut properties = schema_val.get("properties").cloned().unwrap_or(serde_json::json!({})); let required = schema_val.get("required").cloned().unwrap_or(serde_json::json!([])); let mut definitions = schema_val.get("definitions").cloned(); + let declared_additional_properties = + schema_val.get("additionalProperties").cloned(); // Replace the generic field with a simple {"type": "object"} placeholder // The generic field should not be expanded, regardless of the concrete type parameter @@ -1936,7 +1997,8 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream } // If no parent (base type), return simple schema without allOf - // Base types have additionalProperties: false at root level + // Base types are closed by default, while an explicit Schemars + // content model on the source struct is preserved. // Generic fields are just {"type": "object"} (will be extended by children) if parent_type_id.is_empty() { let mut schema = serde_json::json!({ @@ -1944,7 +2006,9 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream "$schema": ::gts::JSON_SCHEMA_DRAFT_07, "description": #description, "type": "object", - "additionalProperties": false, + "additionalProperties": declared_additional_properties + .clone() + .unwrap_or(serde_json::Value::Bool(false)), "properties": properties }); if !required.as_array().map(|a| a.is_empty()).unwrap_or(true) { @@ -1970,12 +2034,13 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream let path_refs: Vec<&str> = owned_path.iter().copied().collect(); let innermost_generic_field = <#generic_ident as ::gts::GtsSchema>::GENERIC_FIELD; - let nested_properties = Self::wrap_in_nesting_path( + let mut nested_properties = Self::wrap_in_nesting_path( &path_refs, properties, required.clone(), innermost_generic_field, ); + #apply_declared_additional_properties_to_derived_level // Child type - use allOf with $ref to parent. // @@ -2052,6 +2117,8 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream let mut properties = schema_val.get("properties").cloned().unwrap_or_else(|| serde_json::json!({})); let required = schema_val.get("required").cloned().unwrap_or_else(|| serde_json::json!([])); let mut definitions = schema_val.get("definitions").cloned(); + let declared_additional_properties = + schema_val.get("additionalProperties").cloned(); // Resolve internal $ref references to GtsInstanceId and GtsTypeId at compile time // This is needed for schemas validated directly (not through GtsStore) @@ -2074,14 +2141,17 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream } // If no parent (base type), return simple schema without allOf - // Non-generic base types have additionalProperties: false at root level + // Non-generic base types are closed by default, while an + // explicit Schemars content model on the source struct is preserved. if parent_type_id.is_empty() { let mut schema = serde_json::json!({ "$id": format!("gts://{}", type_id), "$schema": ::gts::JSON_SCHEMA_DRAFT_07, "description": #description, "type": "object", - "additionalProperties": false, + "additionalProperties": declared_additional_properties + .clone() + .unwrap_or(serde_json::Value::Bool(false)), "properties": properties }); if !required.as_array().map(|a| a.is_empty()).unwrap_or(true) { @@ -2101,7 +2171,8 @@ pub fn struct_to_gts_schema(attr: TokenStream, item: TokenStream) -> TokenStream // chain's `additionalProperties: false` honoured. let owned_path = Self::outer_generic_path(); let path_refs: Vec<&str> = owned_path.iter().copied().collect(); - let nested_properties = Self::wrap_in_nesting_path(&path_refs, properties, required, None); + let mut nested_properties = Self::wrap_in_nesting_path(&path_refs, properties, required, None); + #apply_declared_additional_properties_to_derived_level // No top-level `additionalProperties: false` here either - // see the matching comment in `gts_schema_for!` above. let mut schema = serde_json::json!({ diff --git a/gts-macros/tests/golden/additional_properties_content_models.rs b/gts-macros/tests/golden/additional_properties_content_models.rs new file mode 100644 index 0000000..9119815 --- /dev/null +++ b/gts-macros/tests/golden/additional_properties_content_models.rs @@ -0,0 +1,38 @@ +// Golden case: schema-valued map content models and combinator branches must +// remain untouched. Closing either as an ordinary object would change its JSON +// Schema meaning and reject otherwise valid instances. + +use std::collections::HashMap; + +use gts::{GtsSchema, GtsTypeId}; +use gts_macros::struct_to_gts_schema; +use schemars::JsonSchema; + +#[derive(Debug, JsonSchema, serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +pub enum Choice { + ByName { name: String }, + ByCode { code: u32 }, +} + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.test.golden.contentmodels.v1~"), + description = "Host preserving map and combinator content models", + properties = "schema_type,labels,choice", +)] +#[derive(Debug)] +pub struct ContentModelsHostV1 { + #[serde(rename = "type")] + pub schema_type: GtsTypeId, + pub labels: HashMap, + pub choice: Choice, +} + +pub fn schemas() -> Vec<(String, serde_json::Value)> { + vec![( + ContentModelsHostV1::TYPE_ID.to_owned(), + ContentModelsHostV1::gts_schema_with_refs(), + )] +} diff --git a/gts-macros/tests/golden/additional_properties_content_models/gts.x.test.golden.contentmodels.v1~.schema.json b/gts-macros/tests/golden/additional_properties_content_models/gts.x.test.golden.contentmodels.v1~.schema.json new file mode 100644 index 0000000..5fb204e --- /dev/null +++ b/gts-macros/tests/golden/additional_properties_content_models/gts.x.test.golden.contentmodels.v1~.schema.json @@ -0,0 +1,60 @@ +{ + "$id": "gts://gts.x.test.golden.contentmodels.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "Choice": { + "anyOf": [ + { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + { + "properties": { + "code": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "code" + ], + "type": "object" + } + ] + } + }, + "description": "Host preserving map and combinator content models", + "properties": { + "choice": { + "$ref": "#/definitions/Choice" + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": { + "description": "GTS type identifier", + "format": "gts-type-id", + "title": "GTS Type ID", + "type": "string", + "x-gts-ref": "gts.*" + } + }, + "required": [ + "type", + "labels", + "choice" + ], + "type": "object" +} diff --git a/gts-macros/tests/golden/additional_properties_explicit_open.rs b/gts-macros/tests/golden/additional_properties_explicit_open.rs new file mode 100644 index 0000000..d080624 --- /dev/null +++ b/gts-macros/tests/golden/additional_properties_explicit_open.rs @@ -0,0 +1,34 @@ +// Golden case: an explicitly open nested struct is a deliberate extension +// point. The macro must preserve its `additionalProperties: true` instead of +// replacing it with the default closed content model. + +use gts::{GtsSchema, GtsTypeId}; +use gts_macros::struct_to_gts_schema; +use schemars::JsonSchema; + +#[derive(Debug, JsonSchema, serde::Serialize, serde::Deserialize)] +#[schemars(extend("additionalProperties" = true))] +pub struct ExtensionPoint { + pub label: String, +} + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.test.golden.explicitopen.v1~"), + description = "Host with an explicitly open nested extension point", + properties = "schema_type,extension", +)] +#[derive(Debug)] +pub struct ExplicitOpenHostV1 { + #[serde(rename = "type")] + pub schema_type: GtsTypeId, + pub extension: ExtensionPoint, +} + +pub fn schemas() -> Vec<(String, serde_json::Value)> { + vec![( + ExplicitOpenHostV1::TYPE_ID.to_owned(), + ExplicitOpenHostV1::gts_schema_with_refs(), + )] +} diff --git a/gts-macros/tests/golden/additional_properties_explicit_open/gts.x.test.golden.explicitopen.v1~.schema.json b/gts-macros/tests/golden/additional_properties_explicit_open/gts.x.test.golden.explicitopen.v1~.schema.json new file mode 100644 index 0000000..464cecb --- /dev/null +++ b/gts-macros/tests/golden/additional_properties_explicit_open/gts.x.test.golden.explicitopen.v1~.schema.json @@ -0,0 +1,37 @@ +{ + "$id": "gts://gts.x.test.golden.explicitopen.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "ExtensionPoint": { + "additionalProperties": true, + "properties": { + "label": { + "type": "string" + } + }, + "required": [ + "label" + ], + "type": "object" + } + }, + "description": "Host with an explicitly open nested extension point", + "properties": { + "extension": { + "$ref": "#/definitions/ExtensionPoint" + }, + "type": { + "description": "GTS type identifier", + "format": "gts-type-id", + "title": "GTS Type ID", + "type": "string", + "x-gts-ref": "gts.*" + } + }, + "required": [ + "type", + "extension" + ], + "type": "object" +} diff --git a/gts-macros/tests/golden/additional_properties_flattened_map.rs b/gts-macros/tests/golden/additional_properties_flattened_map.rs new file mode 100644 index 0000000..c17d21e --- /dev/null +++ b/gts-macros/tests/golden/additional_properties_flattened_map.rs @@ -0,0 +1,37 @@ +// Golden case: flattening a map is the Serde/Schemars way to model an object +// with known properties plus arbitrary extension properties. Schemars emits +// `additionalProperties: true`; the macro must preserve that open model. + +use std::collections::HashMap; + +use gts::{GtsSchema, GtsTypeId}; +use gts_macros::struct_to_gts_schema; +use schemars::JsonSchema; + +#[derive(Debug, JsonSchema, serde::Serialize, serde::Deserialize)] +pub struct ExtensibleMetadata { + pub label: String, + #[serde(flatten)] + pub extensions: HashMap, +} + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.test.golden.flattenedmap.v1~"), + description = "Host with a nested struct opened by a flattened map", + properties = "schema_type,metadata", +)] +#[derive(Debug)] +pub struct FlattenedMapHostV1 { + #[serde(rename = "type")] + pub schema_type: GtsTypeId, + pub metadata: ExtensibleMetadata, +} + +pub fn schemas() -> Vec<(String, serde_json::Value)> { + vec![( + FlattenedMapHostV1::TYPE_ID.to_owned(), + FlattenedMapHostV1::gts_schema_with_refs(), + )] +} diff --git a/gts-macros/tests/golden/additional_properties_flattened_map/gts.x.test.golden.flattenedmap.v1~.schema.json b/gts-macros/tests/golden/additional_properties_flattened_map/gts.x.test.golden.flattenedmap.v1~.schema.json new file mode 100644 index 0000000..028010d --- /dev/null +++ b/gts-macros/tests/golden/additional_properties_flattened_map/gts.x.test.golden.flattenedmap.v1~.schema.json @@ -0,0 +1,37 @@ +{ + "$id": "gts://gts.x.test.golden.flattenedmap.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "ExtensibleMetadata": { + "additionalProperties": true, + "properties": { + "label": { + "type": "string" + } + }, + "required": [ + "label" + ], + "type": "object" + } + }, + "description": "Host with a nested struct opened by a flattened map", + "properties": { + "metadata": { + "$ref": "#/definitions/ExtensibleMetadata" + }, + "type": { + "description": "GTS type identifier", + "format": "gts-type-id", + "title": "GTS Type ID", + "type": "string", + "x-gts-ref": "gts.*" + } + }, + "required": [ + "type", + "metadata" + ], + "type": "object" +} diff --git a/gts-macros/tests/golden/additional_properties_gts_derived_open.rs b/gts-macros/tests/golden/additional_properties_gts_derived_open.rs new file mode 100644 index 0000000..68c7247 --- /dev/null +++ b/gts-macros/tests/golden/additional_properties_gts_derived_open.rs @@ -0,0 +1,69 @@ +// Golden case: on a derived GTS struct, an explicitly open Schemars content +// model applies to the nested object level that carries the derived type's own +// fields, not to the document-level `allOf` wrapper. + +use gts::{GtsSchema, GtsTypeId}; +use gts_macros::struct_to_gts_schema; + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.test.golden.openchain.v1~"), + description = "Abstract base with a payload extension slot", + properties = "schema_type,payload", + gts_abstract = true, +)] +#[derive(Debug)] +#[schemars(extend("additionalProperties" = true))] +pub struct OpenChainBaseV1

{ + #[serde(rename = "type")] + pub schema_type: GtsTypeId, + pub payload: P, +} + +#[struct_to_gts_schema( + dir_path = "schemas", + base = OpenChainBaseV1, + type_id = gts_id!("x.test.golden.openchain.v1~x.test.audit.payload.v1~"), + description = "Derived payload with an explicitly open content model", + properties = "label,data", + gts_abstract = true, +)] +#[derive(Debug)] +#[schemars(extend("additionalProperties" = true))] +pub struct OpenDerivedPayloadV1 { + pub label: String, + pub data: D, +} + +#[struct_to_gts_schema( + dir_path = "schemas", + base = OpenDerivedPayloadV1, + type_id = gts_id!( + "x.test.golden.openchain.v1~x.test.audit.payload.v1~x.test.final.payload.v1~" + ), + description = "Open non-generic leaf nested under the derived data slot", + properties = "value", +)] +#[derive(Debug)] +#[schemars(extend("additionalProperties" = true))] +pub struct OpenDerivedLeafV1 { + pub value: String, +} + +pub fn schemas() -> Vec<(String, serde_json::Value)> { + vec![ + ( + OpenChainBaseV1::<()>::TYPE_ID.to_owned(), + OpenChainBaseV1::<()>::gts_schema_with_refs(), + ), + ( + OpenDerivedPayloadV1::<()>::TYPE_ID.to_owned(), + OpenDerivedPayloadV1::<()>::gts_schema_with_refs(), + ), + ( + OpenDerivedLeafV1::TYPE_ID.to_owned(), + OpenDerivedLeafV1::gts_schema_with_refs(), + ), + ] +} diff --git a/gts-macros/tests/golden/additional_properties_gts_derived_open/gts.x.test.golden.openchain.v1~.schema.json b/gts-macros/tests/golden/additional_properties_gts_derived_open/gts.x.test.golden.openchain.v1~.schema.json new file mode 100644 index 0000000..60de9c7 --- /dev/null +++ b/gts-macros/tests/golden/additional_properties_gts_derived_open/gts.x.test.golden.openchain.v1~.schema.json @@ -0,0 +1,24 @@ +{ + "$id": "gts://gts.x.test.golden.openchain.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": true, + "description": "Abstract base with a payload extension slot", + "properties": { + "payload": { + "type": "object" + }, + "type": { + "description": "GTS type identifier", + "format": "gts-type-id", + "title": "GTS Type ID", + "type": "string", + "x-gts-ref": "gts.*" + } + }, + "required": [ + "type", + "payload" + ], + "type": "object", + "x-gts-abstract": true +} diff --git a/gts-macros/tests/golden/additional_properties_gts_derived_open/gts.x.test.golden.openchain.v1~x.test.audit.payload.v1~.schema.json b/gts-macros/tests/golden/additional_properties_gts_derived_open/gts.x.test.golden.openchain.v1~x.test.audit.payload.v1~.schema.json new file mode 100644 index 0000000..b9585ad --- /dev/null +++ b/gts-macros/tests/golden/additional_properties_gts_derived_open/gts.x.test.golden.openchain.v1~x.test.audit.payload.v1~.schema.json @@ -0,0 +1,33 @@ +{ + "$id": "gts://gts.x.test.golden.openchain.v1~x.test.audit.payload.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "gts://gts.x.test.golden.openchain.v1~" + }, + { + "properties": { + "payload": { + "additionalProperties": true, + "properties": { + "data": { + "type": "object" + }, + "label": { + "type": "string" + } + }, + "required": [ + "label", + "data" + ], + "type": "object" + } + }, + "type": "object" + } + ], + "description": "Derived payload with an explicitly open content model", + "type": "object", + "x-gts-abstract": true +} diff --git a/gts-macros/tests/golden/additional_properties_gts_derived_open/gts.x.test.golden.openchain.v1~x.test.audit.payload.v1~x.test.final.payload.v1~.schema.json b/gts-macros/tests/golden/additional_properties_gts_derived_open/gts.x.test.golden.openchain.v1~x.test.audit.payload.v1~x.test.final.payload.v1~.schema.json new file mode 100644 index 0000000..8565200 --- /dev/null +++ b/gts-macros/tests/golden/additional_properties_gts_derived_open/gts.x.test.golden.openchain.v1~x.test.audit.payload.v1~x.test.final.payload.v1~.schema.json @@ -0,0 +1,33 @@ +{ + "$id": "gts://gts.x.test.golden.openchain.v1~x.test.audit.payload.v1~x.test.final.payload.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "gts://gts.x.test.golden.openchain.v1~x.test.audit.payload.v1~" + }, + { + "properties": { + "payload": { + "properties": { + "data": { + "additionalProperties": true, + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ], + "type": "object" + } + }, + "type": "object" + } + }, + "type": "object" + } + ], + "description": "Open non-generic leaf nested under the derived data slot", + "type": "object" +} diff --git a/gts-macros/tests/golden/additional_properties_gts_root_open.rs b/gts-macros/tests/golden/additional_properties_gts_root_open.rs new file mode 100644 index 0000000..a50f8c4 --- /dev/null +++ b/gts-macros/tests/golden/additional_properties_gts_root_open.rs @@ -0,0 +1,28 @@ +// Golden case: `struct_to_gts_schema` preserves an explicitly open Schemars +// content model on the GTS base struct itself instead of applying its usual +// closed-root default. + +use gts::{GtsSchema, GtsTypeId}; +use gts_macros::struct_to_gts_schema; + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.test.golden.rootopen.v1~"), + description = "GTS base type with an explicitly open root", + properties = "schema_type,label", +)] +#[derive(Debug)] +#[schemars(extend("additionalProperties" = true))] +pub struct RootOpenHostV1 { + #[serde(rename = "type")] + pub schema_type: GtsTypeId, + pub label: String, +} + +pub fn schemas() -> Vec<(String, serde_json::Value)> { + vec![( + RootOpenHostV1::TYPE_ID.to_owned(), + RootOpenHostV1::gts_schema_with_refs(), + )] +} diff --git a/gts-macros/tests/golden/additional_properties_gts_root_open/gts.x.test.golden.rootopen.v1~.schema.json b/gts-macros/tests/golden/additional_properties_gts_root_open/gts.x.test.golden.rootopen.v1~.schema.json new file mode 100644 index 0000000..cd32554 --- /dev/null +++ b/gts-macros/tests/golden/additional_properties_gts_root_open/gts.x.test.golden.rootopen.v1~.schema.json @@ -0,0 +1,23 @@ +{ + "$id": "gts://gts.x.test.golden.rootopen.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": true, + "description": "GTS base type with an explicitly open root", + "properties": { + "label": { + "type": "string" + }, + "type": { + "description": "GTS type identifier", + "format": "gts-type-id", + "title": "GTS Type ID", + "type": "string", + "x-gts-ref": "gts.*" + } + }, + "required": [ + "type", + "label" + ], + "type": "object" +} diff --git a/gts-macros/tests/golden/additional_properties_nested_closed.rs b/gts-macros/tests/golden/additional_properties_nested_closed.rs new file mode 100644 index 0000000..00295fb --- /dev/null +++ b/gts-macros/tests/golden/additional_properties_nested_closed.rs @@ -0,0 +1,39 @@ +// Golden case: ordinary nested Rust structs are closed at every object level. +// Schemars emits them through Draft-07 `definitions`; the macro must preserve +// those definitions and add `additionalProperties: false` recursively. + +use gts::{GtsSchema, GtsTypeId}; +use gts_macros::struct_to_gts_schema; +use schemars::JsonSchema; + +#[derive(Debug, JsonSchema, serde::Serialize, serde::Deserialize)] +pub struct Contact { + pub email: String, +} + +#[derive(Debug, JsonSchema, serde::Serialize, serde::Deserialize)] +pub struct Profile { + pub display_name: String, + pub contact: Contact, +} + +#[struct_to_gts_schema( + dir_path = "schemas", + base = true, + type_id = gts_id!("x.test.golden.nestedclosed.v1~"), + description = "Host with recursively closed nested data structs", + properties = "schema_type,profile", +)] +#[derive(Debug)] +pub struct NestedClosedHostV1 { + #[serde(rename = "type")] + pub schema_type: GtsTypeId, + pub profile: Profile, +} + +pub fn schemas() -> Vec<(String, serde_json::Value)> { + vec![( + NestedClosedHostV1::TYPE_ID.to_owned(), + NestedClosedHostV1::gts_schema_with_refs(), + )] +} diff --git a/gts-macros/tests/golden/additional_properties_nested_closed/gts.x.test.golden.nestedclosed.v1~.schema.json b/gts-macros/tests/golden/additional_properties_nested_closed/gts.x.test.golden.nestedclosed.v1~.schema.json new file mode 100644 index 0000000..254c50c --- /dev/null +++ b/gts-macros/tests/golden/additional_properties_nested_closed/gts.x.test.golden.nestedclosed.v1~.schema.json @@ -0,0 +1,53 @@ +{ + "$id": "gts://gts.x.test.golden.nestedclosed.v1~", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "Contact": { + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "Profile": { + "additionalProperties": false, + "properties": { + "contact": { + "$ref": "#/definitions/Contact" + }, + "display_name": { + "type": "string" + } + }, + "required": [ + "display_name", + "contact" + ], + "type": "object" + } + }, + "description": "Host with recursively closed nested data structs", + "properties": { + "profile": { + "$ref": "#/definitions/Profile" + }, + "type": { + "description": "GTS type identifier", + "format": "gts-type-id", + "title": "GTS Type ID", + "type": "string", + "x-gts-ref": "gts.*" + } + }, + "required": [ + "type", + "profile" + ], + "type": "object" +} diff --git a/gts-macros/tests/golden_tests.rs b/gts-macros/tests/golden_tests.rs index e4b8bfd..aa6509b 100644 --- a/gts-macros/tests/golden_tests.rs +++ b/gts-macros/tests/golden_tests.rs @@ -136,6 +136,12 @@ macro_rules! golden_cases { } golden_cases!( + additional_properties_nested_closed, + additional_properties_explicit_open, + additional_properties_flattened_map, + additional_properties_content_models, + additional_properties_gts_root_open, + additional_properties_gts_derived_open, traits_inline_chain, traits_bool_true, traits_bool_false, From f887d22f7eab3a0bd08c7ba2265602900ba09551 Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Wed, 5 Aug 2026 14:03:28 +0800 Subject: [PATCH 7/8] fix: localize unprovable schema intersections - Track undecidable allOf intersections separately from flattened schemas. - Preserve compatibility diagnostics for decidable sibling properties. --- gts/src/schema_cast.rs | 258 +++++++++++++++++++++++++++++------------ 1 file changed, 183 insertions(+), 75 deletions(-) diff --git a/gts/src/schema_cast.rs b/gts/src/schema_cast.rs index 7311ecf..8db0641 100644 --- a/gts/src/schema_cast.rs +++ b/gts/src/schema_cast.rs @@ -1,7 +1,7 @@ use num_cmp::NumCmp; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use thiserror::Error; use crate::{gts::GtsId, schema_semantics::boolean_schema_value}; @@ -254,9 +254,42 @@ impl std::fmt::Display for CompatibilityDiagnostic { } } -const UNPROVEN_INTERSECTION: &str = "x-gts-internal-unproven-intersection"; +/// Locations, relative to the node being flattened, whose `allOf` intersection +/// could not be reduced to an exact single schema. +/// +/// The root of the flattened node is the empty string; a property extends the +/// location with `.name` and array items with `[]`, matching the paths +/// [`GtsEntityCastResult::check_schema_node_compatibility`] descends through. +/// Keywords the checker treats as node-level constraints (`additionalProperties`, +/// `patternProperties`, `propertyNames`, ...) are attributed to their owning +/// node: an intersection this checker cannot prove there makes the whole node +/// unprovable. +type UnprovenPaths = BTreeSet; + +/// Whether each side's effective dialect evaluates `unevaluatedProperties`. +#[derive(Debug, Clone, Copy)] +struct DialectSupport { + old_unevaluated: bool, + new_unevaluated: bool, +} -fn merge_schema_map(target: &mut Map, candidate: &Map) { +/// Narrows `unproven` to the locations inside `child`, rebased so that the +/// empty string denotes `child` itself. +fn unproven_below(unproven: &UnprovenPaths, child: &str) -> UnprovenPaths { + unproven + .iter() + .filter_map(|location| location.strip_prefix(child)) + .filter(|rest| rest.is_empty() || rest.starts_with('.') || rest.starts_with('[')) + .map(ToOwned::to_owned) + .collect() +} + +fn merge_schema_map( + target: &mut Map, + candidate: &Map, + path: &str, + unproven: &mut UnprovenPaths, +) { const ANNOTATIONS: &[&str] = &[ "$id", "$schema", @@ -306,21 +339,32 @@ fn merge_schema_map(target: &mut Map, candidate: &Map { + // The checker descends into named properties, so an unprovable + // property intersection stays local to that property. Pattern + // properties are compared as a node-level constraint instead. + let named = keyword == "properties"; if let (Some(current_map), Some(candidate_map)) = (current.as_object_mut(), candidate_value.as_object()) { for (name, candidate_schema) in candidate_map { if let Some(current_schema) = current_map.get_mut(name) { - merge_schema_intersection(current_schema, candidate_schema); + let property_path = if named { + format!("{path}.{name}") + } else { + path.to_owned() + }; + merge_schema_intersection( + current_schema, + candidate_schema, + &property_path, + unproven, + ); } else { current_map.insert(name.clone(), candidate_schema.clone()); } } } else { - record_unproven_intersection( - target, - format!("'{keyword}' has incompatible representations"), - ); + unproven.insert(path.to_owned()); } } "required" => { @@ -334,21 +378,19 @@ fn merge_schema_map(target: &mut Map, candidate: &Map merge_schema_intersection(current, candidate_value), + "items" => { + merge_schema_intersection(current, candidate_value, &format!("{path}[]"), unproven); + } + "additionalProperties" | "unevaluatedProperties" | "propertyNames" | "contains" => { + merge_schema_intersection(current, candidate_value, path, unproven); + } "enum" => { if let (Some(current_values), Some(candidate_values)) = (current.as_array_mut(), candidate_value.as_array()) { current_values.retain(|value| candidate_values.contains(value)); if current_values.is_empty() { - record_unproven_intersection( - target, - "allOf enum intersection is empty".to_owned(), - ); + unproven.insert(path.to_owned()); } } } @@ -369,48 +411,39 @@ fn merge_schema_map(target: &mut Map, candidate: &Map record_unproven_intersection( - target, - format!("allOf has differing '{keyword}' constraints"), - ), + _ => { + unproven.insert(path.to_owned()); + } } } } -fn merge_schema_intersection(target: &mut Value, candidate: &Value) { +fn merge_schema_intersection( + target: &mut Value, + candidate: &Value, + path: &str, + unproven: &mut UnprovenPaths, +) { match (&mut *target, candidate) { (Value::Bool(false), _) | (_, Value::Bool(true)) => {} (Value::Bool(true), value) => *target = value.clone(), (_, Value::Bool(false)) => *target = Value::Bool(false), (Value::Object(target_map), Value::Object(candidate_map)) => { - merge_schema_map(target_map, candidate_map); + merge_schema_map(target_map, candidate_map, path, unproven); } _ => { - *target = Value::Object(Map::from_iter([( - UNPROVEN_INTERSECTION.to_owned(), - Value::Array(vec![target.clone(), candidate.clone()]), - )])); + // Two branches that are not both object schemas have no + // representable intersection; leave the node unconstrained and let + // the caller decide what an unprovable location means. + unproven.insert(path.to_owned()); + *target = Value::Object(Map::new()); } } } -fn record_unproven_intersection(schema: &mut Map, reason: String) { - let marker = schema - .entry(UNPROVEN_INTERSECTION) - .or_insert_with(|| Value::Array(Vec::new())); - if let Some(reasons) = marker.as_array_mut() { - reasons.push(Value::String(reason)); - } else { - *marker = Value::Array(vec![Value::String(reason)]); - } -} - impl GtsEntityCastResult { /// Builds an error result for a compatibility or cast outcome that could not /// be decided. @@ -755,13 +788,25 @@ impl GtsEntityCastResult { #[must_use] pub fn flatten_schema(schema: &Value) -> Value { + Self::flatten_effective(schema).0 + } + + /// Flattens `allOf` and reports where the intersection could not be proven. + /// + /// The flattened schema is always a usable approximation; the returned + /// [`UnprovenPaths`] tell a compatibility checker which locations it must + /// not draw conclusions about. + fn flatten_effective(schema: &Value) -> (Value, UnprovenPaths) { + let mut unproven = UnprovenPaths::new(); let Some(schema_map) = schema.as_object() else { - return schema.clone(); + return (schema.clone(), unproven); }; let mut result = Value::Bool(true); if let Some(all_of) = schema_map.get("allOf").and_then(Value::as_array) { for branch in all_of { - merge_schema_intersection(&mut result, &Self::flatten_schema(branch)); + let (flattened_branch, branch_unproven) = Self::flatten_effective(branch); + unproven.extend(branch_unproven); + merge_schema_intersection(&mut result, &flattened_branch, "", &mut unproven); } } let direct = Value::Object( @@ -771,8 +816,8 @@ impl GtsEntityCastResult { .map(|(keyword, value)| (keyword.clone(), value.clone())) .collect(), ); - merge_schema_intersection(&mut result, &direct); - result + merge_schema_intersection(&mut result, &direct, "", &mut unproven); + (result, unproven) } /// Reports a bound keyword whose value is present but not a number. @@ -1406,17 +1451,24 @@ impl GtsEntityCastResult { new_schema: &Value, path: &str, check_backward: bool, - old_supports_unevaluated: bool, - new_supports_unevaluated: bool, + dialects: DialectSupport, + inherited_unproven: UnprovenPaths, errors: &mut Vec, ) { + // Locations an ancestor could not prove stay unprovable here; add + // whatever this node's own `allOf` composition leaves undecided. + let mut unproven = inherited_unproven; let old_effective = if old_schema.get("allOf").is_some() { - Self::flatten_schema(old_schema) + let (effective, paths) = Self::flatten_effective(old_schema); + unproven.extend(paths); + effective } else { old_schema.clone() }; let new_effective = if new_schema.get("allOf").is_some() { - Self::flatten_schema(new_schema) + let (effective, paths) = Self::flatten_effective(new_schema); + unproven.extend(paths); + effective } else { new_schema.clone() }; @@ -1451,9 +1503,7 @@ impl GtsEntityCastResult { } return; }; - if old_map.contains_key(UNPROVEN_INTERSECTION) - || new_map.contains_key(UNPROVEN_INTERSECTION) - { + if unproven.contains("") { errors.push(CompatibilityDiagnostic::new( path, CompatibilityFinding::NotProvable, @@ -1505,8 +1555,8 @@ impl GtsEntityCastResult { new_map, path, check_backward, - old_supports_unevaluated, - new_supports_unevaluated, + dialects, + &unproven, errors, ); } @@ -1517,8 +1567,8 @@ impl GtsEntityCastResult { new_items, &format!("{path}[]"), check_backward, - old_supports_unevaluated, - new_supports_unevaluated, + dialects, + unproven_below(&unproven, "[]"), errors, ), (None, Some(_)) if check_backward => { @@ -1542,8 +1592,8 @@ impl GtsEntityCastResult { new_schema: &Map, path: &str, check_backward: bool, - old_supports_unevaluated: bool, - new_supports_unevaluated: bool, + dialects: DialectSupport, + unproven: &UnprovenPaths, errors: &mut Vec, ) { let empty = Map::new(); @@ -1588,19 +1638,15 @@ impl GtsEntityCastResult { )); } - let old_model = Self::classify_content_model(old_schema, old_supports_unevaluated); - let new_model = Self::classify_content_model(new_schema, new_supports_unevaluated); + let old_model = Self::classify_content_model(old_schema, dialects.old_unevaluated); + let new_model = Self::classify_content_model(new_schema, dialects.new_unevaluated); let (source_model, target_model) = if check_backward { (old_model, new_model) } else { (new_model, old_model) }; - let partial_constraints_equal = Self::partial_content_constraints_equal( - old_schema, - new_schema, - old_supports_unevaluated, - new_supports_unevaluated, - ); + let partial_constraints_equal = + Self::partial_content_constraints_equal(old_schema, new_schema, dialects); if !Self::content_model_is_subset(source_model, target_model) { errors.push(CompatibilityDiagnostic::new( path, @@ -1634,8 +1680,8 @@ impl GtsEntityCastResult { new_property, &property_path, check_backward, - old_supports_unevaluated, - new_supports_unevaluated, + dialects, + unproven_below(unproven, &format!(".{name}")), errors, ); } else { @@ -1732,8 +1778,7 @@ impl GtsEntityCastResult { fn partial_content_constraints_equal( old_schema: &Map, new_schema: &Map, - old_supports_unevaluated: bool, - new_supports_unevaluated: bool, + dialects: DialectSupport, ) -> bool { let normalize_additional = |schema: &Map| { schema @@ -1755,8 +1800,8 @@ impl GtsEntityCastResult { normalize_additional(old_schema) == normalize_additional(new_schema) && old_schema.get("patternProperties") == new_schema.get("patternProperties") && old_schema.get("propertyNames") == new_schema.get("propertyNames") - && normalize_unevaluated(old_schema, old_supports_unevaluated) - == normalize_unevaluated(new_schema, new_supports_unevaluated) + && normalize_unevaluated(old_schema, dialects.old_unevaluated) + == normalize_unevaluated(new_schema, dialects.new_unevaluated) } fn property_change_error( @@ -1870,8 +1915,11 @@ impl GtsEntityCastResult { new_schema, "$", check_backward, - Self::dialect_supports_unevaluated(effective_old), - Self::dialect_supports_unevaluated(effective_new), + DialectSupport { + old_unevaluated: Self::dialect_supports_unevaluated(effective_old), + new_unevaluated: Self::dialect_supports_unevaluated(effective_new), + }, + UnprovenPaths::new(), &mut errors, ); (CompatibilityVerdict::from_diagnostics(&errors), errors) @@ -3740,4 +3788,64 @@ mod tests { "{diagnostics:?}" ); } + + /// An unprovable `allOf` intersection is the checker's own bookkeeping and + /// must never surface as a keyword: `flatten_schema` is public and its + /// output feeds instance casting and `additionalProperties` comparisons, + /// where a synthetic keyword reads as a real constraint difference. + #[test] + fn test_unprovable_intersection_leaves_no_synthetic_keyword() { + let flattened = GtsEntityCastResult::flatten_schema(&json!({ + "allOf": [ + {"type": "object", "additionalProperties": {"type": "string"}}, + {"type": "object", "additionalProperties": {"type": "number"}} + ] + })); + + let keys: Vec<&String> = flattened + .as_object() + .expect("flattening object branches yields an object") + .keys() + .collect(); + assert!( + keys.iter().all(|key| !key.starts_with("x-gts-internal")), + "{keys:?}" + ); + } + + /// The undecidable branch must stay local: reporting it must not swallow a + /// sibling that is decidably broken, or "unknown" would mask "incompatible". + #[test] + fn test_unprovable_property_does_not_mask_sibling_incompatibility() { + let schema_with = |sibling: Value| { + json!({ + "type": "object", + "allOf": [ + {"properties": {"undecidable": {"type": "string"}}}, + {"properties": {"undecidable": {"type": "integer"}}} + ], + "properties": {"sibling": sibling} + }) + }; + + let (verdict, diagnostics) = GtsEntityCastResult::check_backward_diagnostics( + &schema_with(json!({"type": "string"})), + &schema_with(json!({"type": "number"})), + ); + + assert!(verdict.is_incompatible(), "{diagnostics:?}"); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.path == "$.undecidable" + && diagnostic.finding == CompatibilityFinding::NotProvable), + "{diagnostics:?}" + ); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.path == "$.sibling"), + "{diagnostics:?}" + ); + } } From bd483dd3de4116fadc21d1cdf4716ee3d043630a Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Wed, 5 Aug 2026 16:55:29 +0800 Subject: [PATCH 8/8] refactor(schema): Separate derivation from evolution checks - Move accepted-set compatibility into a dedicated schema evolution module. - Reuse the shared inclusion engine for derivation admission while preserving derivation-specific rules. - Update casting, store, trait validation, and tests to use the focused APIs. Signed-off-by: Aviator 5 --- gts/src/lib.rs | 11 +- gts/src/ops.rs | 44 +- gts/src/schema_cast.rs | 3571 ++--------------------------- gts/src/schema_compat.rs | 1492 ------------ gts/src/schema_derivation.rs | 412 ++++ gts/src/schema_derivation_test.rs | 697 ++++++ gts/src/schema_evolution.rs | 1791 +++++++++++++++ gts/src/schema_evolution_test.rs | 1542 +++++++++++++ gts/src/schema_traits.rs | 10 +- gts/src/store.rs | 18 +- gts/src/store_test.rs | 4 +- 11 files changed, 4645 insertions(+), 4947 deletions(-) delete mode 100644 gts/src/schema_compat.rs create mode 100644 gts/src/schema_derivation.rs create mode 100644 gts/src/schema_derivation_test.rs create mode 100644 gts/src/schema_evolution.rs create mode 100644 gts/src/schema_evolution_test.rs diff --git a/gts/src/lib.rs b/gts/src/lib.rs index 716aa16..d4b118e 100644 --- a/gts/src/lib.rs +++ b/gts/src/lib.rs @@ -5,7 +5,8 @@ pub mod ops; pub mod path_resolver; pub mod schema; pub mod schema_cast; -pub mod schema_compat; +pub mod schema_derivation; +pub mod schema_evolution; pub mod schema_modifiers; pub mod schema_narrow; pub mod schema_refs; @@ -40,9 +41,11 @@ pub use schema::{ GtsSerialize, GtsSerializeWrapper, JSON_SCHEMA_DRAFT_07, TraitSchemaState, deserialize_gts, serialize_gts, strip_schema_metadata, }; -pub use schema_cast::{ - CompatibilityDiagnostic, CompatibilityFinding, CompatibilityVerdict, ContentModel, - GtsEntityCastResult, ObjectLevel, SchemaCastError, +pub use schema_cast::{GtsEntityCastResult, SchemaCastError}; +pub use schema_evolution::{ + CompatibilityDiagnostic, CompatibilityFinding, CompatibilityVerdict, ContentModel, ObjectLevel, + check_backward_compatibility, check_backward_diagnostics, check_forward_compatibility, + check_forward_diagnostics, }; pub use schema_narrow::{NarrowError, try_narrow}; pub use schema_refs::{ExtractRefsError, InvalidRefReason, extract_gts_refs}; diff --git a/gts/src/ops.rs b/gts/src/ops.rs index fc8dbf1..8e3aacb 100644 --- a/gts/src/ops.rs +++ b/gts/src/ops.rs @@ -8,9 +8,9 @@ use crate::entities::{GtsConfig, GtsEntity}; use crate::files_reader::GtsFileReader; use crate::gts::{GtsId, GtsIdPattern}; use crate::path_resolver::JsonPathResolver; -#[cfg(test)] -use crate::schema_cast::CompatibilityVerdict; use crate::schema_cast::GtsEntityCastResult; +#[cfg(test)] +use crate::schema_evolution::CompatibilityVerdict; use crate::store::{GtsStore, GtsStoreQueryResult}; /// `is_schema` is `Some(true)` for schema/type IDs (ending with `~`), @@ -2068,8 +2068,6 @@ mod tests { #[test] fn test_schema_compatibility_type_change() { - use crate::schema_cast::GtsEntityCastResult; - let old_schema = json!({ "type": "object", "properties": { @@ -2085,15 +2083,13 @@ mod tests { }); let (is_backward, backward_errors) = - GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); + crate::schema_evolution::check_backward_compatibility(&old_schema, &new_schema); assert!(is_backward.is_incompatible()); assert!(!backward_errors.is_empty()); } #[test] fn test_schema_compatibility_enum_changes() { - use crate::schema_cast::GtsEntityCastResult; - let old_schema = json!({ "type": "object", "properties": { @@ -2115,9 +2111,9 @@ mod tests { }); let (is_backward, _) = - GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); + crate::schema_evolution::check_backward_compatibility(&old_schema, &new_schema); let (is_forward, _) = - GtsEntityCastResult::check_forward_compatibility(&old_schema, &new_schema); + crate::schema_evolution::check_forward_compatibility(&old_schema, &new_schema); // Expanding the accepted set is backward compatible, not forward compatible. assert!(is_backward.is_compatible()); @@ -2126,8 +2122,6 @@ mod tests { #[test] fn test_schema_compatibility_numeric_constraints() { - use crate::schema_cast::GtsEntityCastResult; - let old_schema = json!({ "type": "object", "properties": { @@ -2151,15 +2145,13 @@ mod tests { }); let (is_backward, backward_errors) = - GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); + crate::schema_evolution::check_backward_compatibility(&old_schema, &new_schema); assert!(is_backward.is_incompatible()); assert!(!backward_errors.is_empty()); } #[test] fn test_schema_compatibility_string_constraints() { - use crate::schema_cast::GtsEntityCastResult; - let old_schema = json!({ "type": "object", "properties": { @@ -2183,14 +2175,12 @@ mod tests { }); let (is_backward, _) = - GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); + crate::schema_evolution::check_backward_compatibility(&old_schema, &new_schema); assert!(is_backward.is_incompatible()); } #[test] fn test_schema_compatibility_array_constraints() { - use crate::schema_cast::GtsEntityCastResult; - let old_schema = json!({ "type": "object", "properties": { @@ -2214,14 +2204,12 @@ mod tests { }); let (is_backward, _) = - GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); + crate::schema_evolution::check_backward_compatibility(&old_schema, &new_schema); assert!(is_backward.is_incompatible()); } #[test] fn test_schema_compatibility_added_constraint() { - use crate::schema_cast::GtsEntityCastResult; - let old_schema = json!({ "type": "object", "properties": { @@ -2240,14 +2228,12 @@ mod tests { }); let (is_backward, _) = - GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); + crate::schema_evolution::check_backward_compatibility(&old_schema, &new_schema); assert!(is_backward.is_incompatible()); } #[test] fn test_schema_compatibility_removed_constraint() { - use crate::schema_cast::GtsEntityCastResult; - let old_schema = json!({ "type": "object", "properties": { @@ -2266,14 +2252,12 @@ mod tests { }); let (is_forward, _) = - GtsEntityCastResult::check_forward_compatibility(&old_schema, &new_schema); + crate::schema_evolution::check_forward_compatibility(&old_schema, &new_schema); assert!(is_forward.is_incompatible()); } #[test] fn test_schema_compatibility_removed_required_property() { - use crate::schema_cast::GtsEntityCastResult; - let old_schema = json!({ "type": "object", "properties": { @@ -2293,15 +2277,13 @@ mod tests { }); let (is_forward, forward_errors) = - GtsEntityCastResult::check_forward_compatibility(&old_schema, &new_schema); + crate::schema_evolution::check_forward_compatibility(&old_schema, &new_schema); assert!(is_forward.is_incompatible()); assert!(!forward_errors.is_empty()); } #[test] fn test_schema_compatibility_enum_removed_values() { - use crate::schema_cast::GtsEntityCastResult; - let old_schema = json!({ "type": "object", "properties": { @@ -2323,9 +2305,9 @@ mod tests { }); let (is_backward, backward_errors) = - GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); + crate::schema_evolution::check_backward_compatibility(&old_schema, &new_schema); let (is_forward, _) = - GtsEntityCastResult::check_forward_compatibility(&old_schema, &new_schema); + crate::schema_evolution::check_forward_compatibility(&old_schema, &new_schema); assert!(is_backward.is_incompatible()); assert!(!backward_errors.is_empty()); assert!(is_forward.is_compatible()); diff --git a/gts/src/schema_cast.rs b/gts/src/schema_cast.rs index 8db0641..8950ec4 100644 --- a/gts/src/schema_cast.rs +++ b/gts/src/schema_cast.rs @@ -1,79 +1,12 @@ -use num_cmp::NumCmp; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{HashMap, HashSet}; use thiserror::Error; -use crate::{gts::GtsId, schema_semantics::boolean_schema_value}; - -/// Result of attempting to establish one schema-compatibility relation. -/// -/// `Unknown` is deliberately distinct from `Incompatible`: it means the -/// checker could not prove or disprove the required accepted-instance-set -/// inclusion. The caller, not this library, decides how that affects admission. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CompatibilityVerdict { - Compatible, - Incompatible, - #[default] - Unknown, -} - -impl CompatibilityVerdict { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Compatible => "compatible", - Self::Incompatible => "incompatible", - Self::Unknown => "unknown", - } - } - - #[must_use] - pub const fn is_compatible(self) -> bool { - matches!(self, Self::Compatible) - } - - #[must_use] - pub const fn is_incompatible(self) -> bool { - matches!(self, Self::Incompatible) - } - - #[must_use] - pub const fn is_unknown(self) -> bool { - matches!(self, Self::Unknown) - } - - /// Derives full compatibility from the two directional verdicts. - #[must_use] - pub const fn full(backward: Self, forward: Self) -> Self { - match (backward, forward) { - (Self::Compatible, Self::Compatible) => Self::Compatible, - (Self::Incompatible, _) | (_, Self::Incompatible) => Self::Incompatible, - _ => Self::Unknown, - } - } - - fn from_diagnostics(diagnostics: &[CompatibilityDiagnostic]) -> Self { - if diagnostics.is_empty() { - Self::Compatible - } else if diagnostics - .iter() - .all(CompatibilityDiagnostic::is_inconclusive) - { - Self::Unknown - } else { - Self::Incompatible - } - } -} - -impl std::fmt::Display for CompatibilityVerdict { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(self.as_str()) - } -} +use crate::gts::GtsId; +use crate::schema_evolution::{ + CompatibilityVerdict, check_backward_compatibility, check_forward_compatibility, flatten_schema, +}; #[derive(Debug, Error)] pub enum SchemaCastError { @@ -124,326 +57,6 @@ fn implementation_version() -> String { crate::GTS_IMPLEMENTATION_VERSION.to_owned() } -/// Content model of one object level of a **resolved** effective schema. -/// -/// Classified per gts-spec §4.4, which requires the level to be judged after -/// `$ref` resolution and `allOf` composition rather than from a single authored -/// keyword. Use [`GtsEntityCastResult::classify_object_levels`] to obtain the -/// classification of every level of a document. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ContentModel { - /// Accepts an undeclared property with any value. - Open, - /// Rejects every undeclared property. - Closed, - /// Accepts some undeclared property names, or constrains their values - for - /// example through a nontrivial schema-valued `additionalProperties`, - /// `patternProperties`, or `propertyNames`. - Partial, -} - -impl ContentModel { - const fn label(self) -> &'static str { - match self { - Self::Open => "open", - Self::Closed => "closed", - Self::Partial => "partially open", - } - } - - /// Whether a later definition may add an optional property at this level - /// and stay backward compatible. - /// - /// Only a closed level can: an open level already accepted arbitrary values - /// under the new property name, so declaring it narrows the accepted set - /// (§4.4). For a partially open level the answer depends on the constraint - /// that governs undeclared properties, so it is reported as not evolvable - /// rather than guessed. - #[must_use] - pub const fn is_evolvable_in_place(self) -> bool { - matches!(self, Self::Closed) - } -} - -impl std::fmt::Display for ContentModel { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(self.label()) - } -} - -/// One object level of a resolved schema, with its content model. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ObjectLevel { - /// Location of the level, `$` for the document root and dotted segments - /// below it, for example `$.payload` or `$.items[]`. - pub path: String, - /// How this level treats undeclared properties. - pub content_model: ContentModel, -} - -/// Machine-readable kind of a [`CompatibilityDiagnostic`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CompatibilityFinding { - /// A property was declared at a level whose content model does not permit - /// the addition in this direction. - PropertyAdded, - /// A property declaration was dropped at a level whose content model does - /// not permit the removal in this direction. - PropertyRemoved, - /// The set of `required` properties changed. - RequiredChanged, - /// The content model of an object level changed. - ContentModelChanged, - /// The set of permitted `type` values is not an inclusion in this direction. - TypeChanged, - /// The `enum` constraint is not an inclusion in this direction. - EnumChanged, - /// A numeric bound moved in the direction this mode forbids. - BoundChanged, - /// A keyword that only narrows was added or removed. - NarrowingConstraintChanged, - /// A keyword whose values cannot be ordered by inclusion changed. - ConstraintChanged, - /// The declared JSON Schema dialect changed, so this checker cannot compare - /// the two documents under one stable set of keyword semantics. - DialectChanged, - /// Inclusion could not be established either way - an unresolved `$ref`, an - /// `allOf` intersection the checker cannot prove, a partially open level, or - /// two values of one keyword that this implementation cannot order. It is - /// reported distinctly so callers can apply their own admission policy. - NotProvable, -} - -/// Evidence explaining an incompatible or unknown directional verdict. -/// -/// Carries the schema location separately from the prose so that a caller can -/// report per object level without parsing the message. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CompatibilityDiagnostic { - /// Location of the offending schema node, in the form used by - /// [`ObjectLevel::path`]. - pub path: String, - /// What kind of finding this is. - pub finding: CompatibilityFinding, - /// Human-readable detail, without the location prefix. - pub detail: String, -} - -impl CompatibilityDiagnostic { - fn new(path: &str, finding: CompatibilityFinding, detail: String) -> Self { - Self { - path: path.to_owned(), - finding, - detail, - } - } - - const fn is_inconclusive(&self) -> bool { - matches!( - self.finding, - CompatibilityFinding::NotProvable | CompatibilityFinding::DialectChanged - ) - } -} - -impl std::fmt::Display for CompatibilityDiagnostic { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "Schema at '{}' {}", self.path, self.detail) - } -} - -/// Locations, relative to the node being flattened, whose `allOf` intersection -/// could not be reduced to an exact single schema. -/// -/// The root of the flattened node is the empty string; a property extends the -/// location with `.name` and array items with `[]`, matching the paths -/// [`GtsEntityCastResult::check_schema_node_compatibility`] descends through. -/// Keywords the checker treats as node-level constraints (`additionalProperties`, -/// `patternProperties`, `propertyNames`, ...) are attributed to their owning -/// node: an intersection this checker cannot prove there makes the whole node -/// unprovable. -type UnprovenPaths = BTreeSet; - -/// Whether each side's effective dialect evaluates `unevaluatedProperties`. -#[derive(Debug, Clone, Copy)] -struct DialectSupport { - old_unevaluated: bool, - new_unevaluated: bool, -} - -/// Narrows `unproven` to the locations inside `child`, rebased so that the -/// empty string denotes `child` itself. -fn unproven_below(unproven: &UnprovenPaths, child: &str) -> UnprovenPaths { - unproven - .iter() - .filter_map(|location| location.strip_prefix(child)) - .filter(|rest| rest.is_empty() || rest.starts_with('.') || rest.starts_with('[')) - .map(ToOwned::to_owned) - .collect() -} - -fn merge_schema_map( - target: &mut Map, - candidate: &Map, - path: &str, - unproven: &mut UnprovenPaths, -) { - const ANNOTATIONS: &[&str] = &[ - "$id", - "$schema", - "title", - "description", - "default", - "examples", - "readOnly", - "writeOnly", - "deprecated", - "definitions", - "$defs", - "x-gts-abstract", - "x-gts-final", - "x-gts-traits", - "x-gts-traits-schema", - ]; - const MINIMUMS: &[&str] = &[ - "minimum", - "exclusiveMinimum", - "minLength", - "minItems", - "minProperties", - "minContains", - ]; - const MAXIMUMS: &[&str] = &[ - "maximum", - "exclusiveMaximum", - "maxLength", - "maxItems", - "maxProperties", - "maxContains", - ]; - - for (keyword, candidate_value) in candidate { - if ANNOTATIONS.contains(&keyword.as_str()) { - target.insert(keyword.clone(), candidate_value.clone()); - continue; - } - let Some(current) = target.get_mut(keyword) else { - target.insert(keyword.clone(), candidate_value.clone()); - continue; - }; - if current == candidate_value { - continue; - } - - match keyword.as_str() { - "properties" | "patternProperties" => { - // The checker descends into named properties, so an unprovable - // property intersection stays local to that property. Pattern - // properties are compared as a node-level constraint instead. - let named = keyword == "properties"; - if let (Some(current_map), Some(candidate_map)) = - (current.as_object_mut(), candidate_value.as_object()) - { - for (name, candidate_schema) in candidate_map { - if let Some(current_schema) = current_map.get_mut(name) { - let property_path = if named { - format!("{path}.{name}") - } else { - path.to_owned() - }; - merge_schema_intersection( - current_schema, - candidate_schema, - &property_path, - unproven, - ); - } else { - current_map.insert(name.clone(), candidate_schema.clone()); - } - } - } else { - unproven.insert(path.to_owned()); - } - } - "required" => { - if let (Some(current_items), Some(candidate_items)) = - (current.as_array_mut(), candidate_value.as_array()) - { - for item in candidate_items { - if !current_items.contains(item) { - current_items.push(item.clone()); - } - } - } - } - "items" => { - merge_schema_intersection(current, candidate_value, &format!("{path}[]"), unproven); - } - "additionalProperties" | "unevaluatedProperties" | "propertyNames" | "contains" => { - merge_schema_intersection(current, candidate_value, path, unproven); - } - "enum" => { - if let (Some(current_values), Some(candidate_values)) = - (current.as_array_mut(), candidate_value.as_array()) - { - current_values.retain(|value| candidate_values.contains(value)); - if current_values.is_empty() { - unproven.insert(path.to_owned()); - } - } - } - keyword if MINIMUMS.contains(&keyword) => { - if candidate_value.as_f64() > current.as_f64() { - *current = candidate_value.clone(); - } - } - keyword if MAXIMUMS.contains(&keyword) => { - if candidate_value.as_f64() < current.as_f64() { - *current = candidate_value.clone(); - } - } - "type" => { - if current.as_str() == Some("number") && candidate_value.as_str() == Some("integer") - { - *current = candidate_value.clone(); - } else if !(current.as_str() == Some("integer") - && candidate_value.as_str() == Some("number")) - { - unproven.insert(path.to_owned()); - } - } - _ => { - unproven.insert(path.to_owned()); - } - } - } -} - -fn merge_schema_intersection( - target: &mut Value, - candidate: &Value, - path: &str, - unproven: &mut UnprovenPaths, -) { - match (&mut *target, candidate) { - (Value::Bool(false), _) | (_, Value::Bool(true)) => {} - (Value::Bool(true), value) => *target = value.clone(), - (_, Value::Bool(false)) => *target = Value::Bool(false), - (Value::Object(target_map), Value::Object(candidate_map)) => { - merge_schema_map(target_map, candidate_map, path, unproven); - } - _ => { - // Two branches that are not both object schemas have no - // representable intersection; leave the node unconstrained and let - // the caller decide what an unprovable location means. - unproven.insert(path.to_owned()); - *target = Value::Object(Map::new()); - } - } -} - impl GtsEntityCastResult { /// Builds an error result for a compatibility or cast outcome that could not /// be decided. @@ -494,7 +107,7 @@ impl GtsEntityCastResult { _resolver: Option<&()>, ) -> Result { // Flatten target schema to merge allOf and get all properties including const values - let target_schema = Self::flatten_schema(to_schema_content); + let target_schema = flatten_schema(to_schema_content); // Determine direction by IDs let direction = Self::infer_direction(from_instance_id, to_type_id); @@ -504,9 +117,9 @@ impl GtsEntityCastResult { // Check compatibility let (backward_compatibility, backward_errors) = - Self::check_backward_compatibility(old_schema, new_schema); + check_backward_compatibility(old_schema, new_schema); let (forward_compatibility, forward_errors) = - Self::check_forward_compatibility(old_schema, new_schema); + check_forward_compatibility(old_schema, new_schema); let full_compatibility = CompatibilityVerdict::full(backward_compatibility, forward_compatibility); @@ -785,3067 +398,217 @@ impl GtsEntityCastResult { Ok((result, added, removed, incompatibility_reasons)) } +} - #[must_use] - pub fn flatten_schema(schema: &Value) -> Value { - Self::flatten_effective(schema).0 - } - - /// Flattens `allOf` and reports where the intersection could not be proven. - /// - /// The flattened schema is always a usable approximation; the returned - /// [`UnprovenPaths`] tell a compatibility checker which locations it must - /// not draw conclusions about. - fn flatten_effective(schema: &Value) -> (Value, UnprovenPaths) { - let mut unproven = UnprovenPaths::new(); - let Some(schema_map) = schema.as_object() else { - return (schema.clone(), unproven); - }; - let mut result = Value::Bool(true); - if let Some(all_of) = schema_map.get("allOf").and_then(Value::as_array) { - for branch in all_of { - let (flattened_branch, branch_unproven) = Self::flatten_effective(branch); - unproven.extend(branch_unproven); - merge_schema_intersection(&mut result, &flattened_branch, "", &mut unproven); - } - } - let direct = Value::Object( - schema_map - .iter() - .filter(|(keyword, _)| keyword.as_str() != "allOf") - .map(|(keyword, value)| (keyword.clone(), value.clone())) - .collect(), - ); - merge_schema_intersection(&mut result, &direct, "", &mut unproven); - (result, unproven) - } - - /// Reports a bound keyword whose value is present but not a number. - /// - /// Draft-04 spells `exclusiveMinimum`/`exclusiveMaximum` as booleans that - /// modify `minimum`/`maximum`, so a numeric comparison would silently ignore - /// them. Fall back to exact equality for any non-numeric value rather than - /// guessing which direction it widens. - fn check_non_numeric_bound( - path: &str, - old_schema: &Map, - new_schema: &Map, - key: &str, - ) -> Option { - let non_numeric = |schema: &Map| { - schema - .get(key) - .is_some_and(|value| value.as_f64().is_none()) - }; - if (non_numeric(old_schema) || non_numeric(new_schema)) - && old_schema.get(key) != new_schema.get(key) - { - return Some(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::NotProvable, - format!("changes non-numeric '{key}' constraint"), - )); - } - None - } - - fn check_min_max_constraint( - path: &str, - old_schema: &Map, - new_schema: &Map, - min_key: &str, - max_key: &str, - check_tightening: bool, - ) -> Vec { - let bound = |detail: String| { - CompatibilityDiagnostic::new(path, CompatibilityFinding::BoundChanged, detail) - }; - let mut errors = Vec::new(); - errors.extend(Self::check_non_numeric_bound( - path, old_schema, new_schema, min_key, - )); - errors.extend(Self::check_non_numeric_bound( - path, old_schema, new_schema, max_key, - )); - - // Check minimum constraint - let old_min = old_schema.get(min_key).and_then(Value::as_f64); - let new_min = new_schema.get(min_key).and_then(Value::as_f64); - - if let (Some(old_m), Some(new_m)) = (old_min, new_min) { - if check_tightening && new_m > old_m { - errors.push(bound(format!( - "{min_key} increased from {old_m} -> {new_m}" - ))); - } else if !check_tightening && new_m < old_m { - errors.push(bound(format!( - "{min_key} decreased from {old_m} -> {new_m}" - ))); - } - } else if let (true, None, Some(new_m)) = (check_tightening, old_min, new_min) { - errors.push(bound(format!("adds {min_key} constraint: {new_m}"))); - } else if !check_tightening && old_min.is_some() && new_min.is_none() { - errors.push(bound(format!("removes {min_key} constraint"))); - } - - // Check maximum constraint - let old_max = old_schema.get(max_key).and_then(Value::as_f64); - let new_max = new_schema.get(max_key).and_then(Value::as_f64); +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use serde_json::json; - if let (Some(old_m), Some(new_m)) = (old_max, new_max) { - if check_tightening && new_m < old_m { - errors.push(bound(format!( - "{max_key} decreased from {old_m} -> {new_m}" - ))); - } else if !check_tightening && new_m > old_m { - errors.push(bound(format!( - "{max_key} increased from {old_m} -> {new_m}" - ))); - } - } else if let (true, None, Some(new_m)) = (check_tightening, old_max, new_max) { - errors.push(bound(format!("adds {max_key} constraint: {new_m}"))); - } else if !check_tightening && old_max.is_some() && new_max.is_none() { - errors.push(bound(format!("removes {max_key} constraint"))); - } + #[test] + fn test_schema_cast_error_display() { + let error = SchemaCastError::InternalError("test error".to_owned()); + assert!(error.to_string().contains("test error")); - errors + let error = SchemaCastError::CastError("cast error".to_owned()); + assert!(error.to_string().contains("cast error")); } - /// Returns the effective lower or upper numeric bound. - /// - /// Draft 6 and later allow an inclusive and an exclusive bound to coexist; - /// their intersection is the stricter of the two (with exclusive winning - /// when the numeric values are equal). Draft 4's boolean - /// `exclusiveMinimum`/`exclusiveMaximum` spelling is handled as a modifier - /// of the corresponding inclusive bound. - fn effective_numeric_bound( - schema: &Map, - inclusive_key: &str, - exclusive_key: &str, - is_lower: bool, - ) -> Result, ()> { - // `total_cmp` orders `-0.0` below `0.0`, but the two denote the same JSON - // number and must compare equal, so the sign of zero is dropped as the - // bound is read. - let bound_value = |value: &Value| -> Result { - let value = value.as_f64().ok_or(())?; - Ok(if value == 0.0 { 0.0 } else { value }) - }; - let inclusive = match schema.get(inclusive_key) { - Some(value) => Some((bound_value(value)?, false)), - None => None, - }; - let exclusive = match schema.get(exclusive_key) { - Some(Value::Bool(is_exclusive)) => inclusive.map(|(value, _)| (value, *is_exclusive)), - Some(value) => Some((bound_value(value)?, true)), - None => None, - }; - - Ok(match (inclusive, exclusive) { - (None, bound) | (bound, None) => bound, - (Some(inclusive), Some(exclusive)) => { - let ordering = exclusive.0.total_cmp(&inclusive.0); - let exclusive_is_stricter = if is_lower { - ordering.is_gt() - } else { - ordering.is_lt() - }; - if exclusive_is_stricter || (ordering.is_eq() && exclusive.1 && !inclusive.1) { - Some(exclusive) - } else { - Some(inclusive) - } - } - }) + #[test] + fn test_json_entity_cast_result_infer_direction_up() { + let direction = GtsEntityCastResult::infer_direction( + "gts.vendor.package.namespace.type.v1.0~abc.app.custom.event.v1.0", + "gts.vendor.package.namespace.type.v1.1~abc.app.custom.event.v1.1", // v1.1 has higher minor version + ); + assert_eq!(direction, "up"); } - fn check_numeric_bounds( - path: &str, - old_schema: &Map, - new_schema: &Map, - check_backward: bool, - ) -> Vec { - let mut diagnostics = Vec::new(); - for (inclusive_key, exclusive_key, is_lower) in [ - ("minimum", "exclusiveMinimum", true), - ("maximum", "exclusiveMaximum", false), - ] { - if !old_schema.contains_key(inclusive_key) - && !old_schema.contains_key(exclusive_key) - && !new_schema.contains_key(inclusive_key) - && !new_schema.contains_key(exclusive_key) - { - continue; - } - - if (old_schema.get(exclusive_key).is_some_and(Value::is_boolean) - || new_schema.get(exclusive_key).is_some_and(Value::is_boolean)) - && (old_schema.get(inclusive_key) != new_schema.get(inclusive_key) - || old_schema.get(exclusive_key) != new_schema.get(exclusive_key)) - { - diagnostics.push(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::NotProvable, - format!( - "changes Draft-04 boolean '{exclusive_key}' constraint; dialect semantics \ - cannot be inferred at this node" - ), - )); - continue; - } + #[test] + fn test_undecided_result_initializes_error_contract() { + let result = GtsEntityCastResult::undecided("old", "new", "could not decide"); - let old_bound = - Self::effective_numeric_bound(old_schema, inclusive_key, exclusive_key, is_lower); - let new_bound = - Self::effective_numeric_bound(new_schema, inclusive_key, exclusive_key, is_lower); - let (Ok(old_bound), Ok(new_bound)) = (old_bound, new_bound) else { - if old_schema.get(inclusive_key) != new_schema.get(inclusive_key) - || old_schema.get(exclusive_key) != new_schema.get(exclusive_key) - { - diagnostics.push(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::NotProvable, - format!( - "changes non-numeric '{inclusive_key}'/'{exclusive_key}' constraints" - ), - )); - } - continue; - }; + assert_eq!(result.from_id, "old"); + assert_eq!(result.to_id, "new"); + assert_eq!(result.direction, "unknown"); + assert!(result.full_compatibility.is_unknown()); + assert!(result.backward_compatibility.is_unknown()); + assert!(result.forward_compatibility.is_unknown()); + assert!(result.added_properties.is_empty()); + assert!(result.removed_properties.is_empty()); + assert!(result.changed_properties.is_empty()); + assert!(result.incompatibility_reasons.is_empty()); + assert!(result.backward_errors.is_empty()); + assert!(result.forward_errors.is_empty()); + assert_eq!( + result.specification_version, + crate::GTS_SPECIFICATION_VERSION + ); + assert_eq!( + result.implementation_version, + crate::GTS_IMPLEMENTATION_VERSION + ); + assert!(result.casted_entity.is_none()); + assert_eq!(result.error.as_deref(), Some("could not decide")); - let (source, target) = if check_backward { - (old_bound, new_bound) - } else { - (new_bound, old_bound) - }; - let included = match (source, target) { - (_, None) => true, - (None, Some(_)) => false, - (Some(source), Some(target)) if is_lower => { - let ordering = source.0.total_cmp(&target.0); - ordering.is_gt() || (ordering.is_eq() && (!target.1 || source.1)) - } - (Some(source), Some(target)) => { - let ordering = source.0.total_cmp(&target.0); - ordering.is_lt() || (ordering.is_eq() && (!target.1 || source.1)) - } - }; - if !included { - diagnostics.push(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::BoundChanged, - format!("changes effective {inclusive_key}/{exclusive_key} bound incompatibly"), - )); - } - } - diagnostics + let directed = + GtsEntityCastResult::undecided_with_direction("old", "new", "up", "resolution failed"); + assert_eq!(directed.direction, "up"); } - fn check_constraint_compatibility( - path: &str, - old_prop_schema: &Map, - new_prop_schema: &Map, - check_tightening: bool, - ) -> Vec { - // Every pair is checked whenever either definition carries it, never - // gated on `type`. Gating on the old schema's `type` missed a real - // narrowing whenever `type` was absent or written as an array, which - // reported such a change as fully compatible - the one direction of - // error a registry cannot tolerate. - const BOUNDS: &[(&str, &str)] = &[ - ("minLength", "maxLength"), - ("minItems", "maxItems"), - ("minProperties", "maxProperties"), - ("minContains", "maxContains"), - ]; - - let mut diagnostics = - Self::check_numeric_bounds(path, old_prop_schema, new_prop_schema, check_tightening); - diagnostics.extend( - BOUNDS - .iter() - .filter(|(min_key, max_key)| { - [min_key, max_key].iter().any(|key| { - old_prop_schema.contains_key(**key) || new_prop_schema.contains_key(**key) - }) - }) - .flat_map(|(min_key, max_key)| { - Self::check_min_max_constraint( - path, - old_prop_schema, - new_prop_schema, - min_key, - max_key, - check_tightening, - ) - }), + #[test] + fn test_json_entity_cast_result_infer_direction_down() { + let direction = GtsEntityCastResult::infer_direction( + "gts.vendor.package.namespace.type.v1.1~abc.app.custom.event.v1.1", // v1.1 has higher minor version + "gts.vendor.package.namespace.type.v1.0~abc.app.custom.event.v1.0", ); - diagnostics + assert_eq!(direction, "down"); } - /// Handles keywords that only ever narrow `Valid(S)` when present. - /// - /// Whether two different values of such a keyword include one another is - /// undecidable in general - no implementation can compare two regexes - but - /// presence alone is decidable: adding the constraint narrows the accepted - /// set, removing it widens it. That is exactly the shape of the "Relaxing / - /// Tightening constraints" rows of gts-spec sec 4.5, so reporting both - /// directions as incompatible (as plain equality does) contradicts the table - /// for the common case of adding or dropping one of these keywords. - fn check_narrowing_constraints( - path: &str, - old_schema: &Map, - new_schema: &Map, - check_backward: bool, - ) -> Vec { - const NARROWING: &[&str] = &["pattern", "format", "multipleOf"]; - - let mut errors: Vec = NARROWING - .iter() - .filter_map(|keyword| { - let old_value = old_schema.get(*keyword); - let new_value = new_schema.get(*keyword); - match (old_value, new_value) { - // `multipleOf` is a number, so the two spellings of one - // mathematical value are not a change. - (Some(old_value), Some(new_value)) - if json_values_equal(old_value, new_value) => - { - None - } - _ if old_value == new_value => None, - // Added: narrows, so forward-only. - (None, Some(_)) if check_backward => Some(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::NarrowingConstraintChanged, - format!("adds '{keyword}' constraint"), - )), - // Removed: widens, so backward-only. - (Some(_), None) if !check_backward => Some(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::NarrowingConstraintChanged, - format!("removes '{keyword}' constraint"), - )), - // Changed: inclusion between the two values is undecidable. - (Some(old_value), Some(new_value)) => Some(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::NotProvable, - format!( - "changes '{keyword}' from {old_value} to {new_value}; inclusion \ - between the two cannot be proven" - ), - )), - // Added in the forward direction, or removed in the - // backward one: the change widens what this direction - // requires, so it is permitted. - (None, Some(_) | None) | (Some(_), None) => None, - } - }) - .collect(); - - // `uniqueItems` defaults to false, so its presence is not what matters: - // false -> true narrows and true -> false widens, both decidable. - let unique_items = |schema: &Map| { - schema - .get("uniqueItems") - .and_then(Value::as_bool) - .unwrap_or(false) - }; - let old_unique = unique_items(old_schema); - let new_unique = unique_items(new_schema); - if old_unique != new_unique && check_backward == new_unique { - errors.push(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::NarrowingConstraintChanged, - format!( - "{} 'uniqueItems'", - if new_unique { "enables" } else { "disables" } - ), - )); - } - - errors + #[test] + fn test_json_entity_cast_result_infer_direction_none() { + // Same minor version returns "none" + let direction = GtsEntityCastResult::infer_direction( + "gts.vendor.package.namespace.type.v1.0~abc.app.custom.event.v1.0", + "gts.vendor.package.namespace.type.v1.0~abc.app.custom.event.v1.0", + ); + assert_eq!(direction, "none"); } - fn check_type_compatibility( - path: &str, - old_schema: &Map, - new_schema: &Map, - check_backward: bool, - ) -> Vec { - // `type` is a set of permitted primitive types. When it is absent, - // `const` and `enum` can still imply a finite set of effective types. - // Inclusion of the accepted-instance sets therefore follows inclusion of - // the type sets, which makes member order irrelevant and makes dropping - // a member - say the `null` of an `Option` - a narrowing rather than - // an unrelated change. - enum TypeSet { - Any, - Set(Vec), - Invalid, - } + #[test] + fn test_json_entity_cast_result_serialization() { + let result = GtsEntityCastResult { + from_id: "gts.vendor.package.namespace.type.v1.0".to_owned(), + to_id: "gts.vendor.package.namespace.type.v2.0".to_owned(), + old: "gts.vendor.package.namespace.type.v1.0".to_owned(), + new: "gts.vendor.package.namespace.type.v2.0".to_owned(), + direction: "up".to_owned(), + added_properties: vec![], + removed_properties: vec![], + changed_properties: vec![], + full_compatibility: CompatibilityVerdict::Incompatible, + backward_compatibility: CompatibilityVerdict::Compatible, + forward_compatibility: CompatibilityVerdict::Incompatible, + incompatibility_reasons: vec![], + backward_errors: vec![], + forward_errors: vec![], + specification_version: specification_version(), + implementation_version: implementation_version(), + casted_entity: None, + error: None, + }; - fn value_type(value: &Value) -> &'static str { - match value { - Value::Null => "null", - Value::Bool(_) => "boolean", - // JSON Schema's `integer` matches a number with a zero - // fractional part, so `1.0` is an integer. The test must be - // exact: a tolerance would also swallow tiny nonzero fractions - // such as `1e-20`, which no `integer` schema accepts. - Value::Number(number) - if number.is_i64() - || number.is_u64() - || number.as_f64().is_some_and(|value| value.fract() == 0.0) => - { - "integer" - } - Value::Number(_) => "number", - Value::String(_) => "string", - Value::Array(_) => "array", - Value::Object(_) => "object", - } - } - - fn type_set(schema: &Map) -> TypeSet { - match schema.get("type") { - Some(Value::String(name)) => TypeSet::Set(vec![name.clone()]), - Some(Value::Array(names)) => names - .iter() - .map(Value::as_str) - .collect::>>() - .map_or(TypeSet::Invalid, |names| { - TypeSet::Set(names.into_iter().map(str::to_owned).collect()) - }), - Some(_) => TypeSet::Invalid, - None => { - // With no `type`, the effective types are those of the - // values `const` and `enum` accept between them. - let values = GtsEntityCastResult::accepted_value_set(schema); - values.map_or(TypeSet::Any, |values| { - let mut names = Vec::new(); - for value in &values { - let name = value_type(value).to_owned(); - if !names.contains(&name) { - names.push(name); - } - } - TypeSet::Set(names) - }) - } - } - } - - let old_type = old_schema.get("type"); - let new_type = new_schema.get("type"); - let (source_schema, target_schema) = if check_backward { - (old_schema, new_schema) - } else { - (new_schema, old_schema) - }; - - let compatible = match (type_set(source_schema), type_set(target_schema)) { - // A malformed `type` cannot be interpreted; fall back to equality. - (TypeSet::Invalid, _) | (_, TypeSet::Invalid) => old_type == new_type, - // An unconstrained target accepts every type the source permits. - (_, TypeSet::Any) => true, - // An unconstrained source permits types the target may not. - (TypeSet::Any, TypeSet::Set(_)) => false, - (TypeSet::Set(source_names), TypeSet::Set(target_names)) => { - source_names.iter().all(|name| { - target_names.contains(name) - || (name == "integer" - && target_names.iter().any(|target| target == "number")) - }) - } - }; - - if compatible { - Vec::new() - } else { - vec![CompatibilityDiagnostic::new( - path, - CompatibilityFinding::TypeChanged, - format!( - "changes type incompatibly from {} to {}", - old_type.map_or_else(|| "any".to_owned(), Value::to_string), - new_type.map_or_else(|| "any".to_owned(), Value::to_string), - ), - )] - } - } - - /// The finite set of instances a level accepts through `const` and `enum`, - /// or `None` when neither keyword constrains it. - /// - /// An instance must satisfy every keyword present, so two coexisting - /// keywords accept their intersection - possibly nothing at all. - fn accepted_value_set(schema: &Map) -> Option> { - // A non-array `enum` is not a valid constraint and nothing can be read - // from it, which is what `as_array` returning `None` expresses here. - let enumeration = schema.get("enum").and_then(Value::as_array); - match (schema.get("const"), enumeration) { - (None, None) => None, - (Some(constant), None) => Some(vec![constant.clone()]), - (None, Some(values)) => Some(values.clone()), - (Some(constant), Some(values)) => Some( - values - .iter() - .filter(|value| json_values_equal(value, constant)) - .cloned() - .collect(), - ), - } - } - - /// Compares the value sets `const` and `enum` impose, as one set. - /// - /// Both keywords restrict which concrete instances are accepted, so a - /// revision that moves between the two spellings only has a meaning when - /// they are read together: checking each keyword against its own - /// counterpart would read a keyword that is merely absent as an - /// unconstrained target and report the equivalent rewrite of - /// `{"const": 1}` into `{"enum": [1]}` as incompatible in both directions. - fn check_value_set_compatibility( - path: &str, - old_schema: &Map, - new_schema: &Map, - check_backward: bool, - ) -> Vec { - let old_values = Self::accepted_value_set(old_schema); - let new_values = Self::accepted_value_set(new_schema); - // Backward checks Valid(old) ⊆ Valid(new); forward checks the reverse - // inclusion. Expanding the set is therefore backward-only. - let (source, target) = if check_backward { - (old_values.as_deref(), new_values.as_deref()) - } else { - (new_values.as_deref(), old_values.as_deref()) - }; - let finding = if old_schema.contains_key("enum") || new_schema.contains_key("enum") { - CompatibilityFinding::EnumChanged - } else { - CompatibilityFinding::ConstraintChanged - }; - - match (source, target) { - // An unconstrained target accepts every value the source permits. - (_, None) => Vec::new(), - (None, Some(_)) => vec![CompatibilityDiagnostic::new( - path, - finding, - format!( - "{} the 'const'/'enum' value constraint", - if check_backward { "adds" } else { "removes" } - ), - )], - (Some(source), Some(target)) => { - let incompatible_values: Vec<&Value> = source - .iter() - .filter(|value| { - !target - .iter() - .any(|accepted| json_values_equal(value, accepted)) - }) - .collect(); - if incompatible_values.is_empty() { - Vec::new() - } else { - vec![CompatibilityDiagnostic::new( - path, - finding, - format!( - "changes the 'const'/'enum' value set incompatibly: \ - {incompatible_values:?}" - ), - )] - } - } - } - } - - fn check_exact_constraints( - path: &str, - old_schema: &Map, - new_schema: &Map, - ) -> Vec { - // Keywords whose two values cannot be ordered by inclusion, so equality - // is the only thing that can be proven. Numeric bounds live in - // [`Self::check_constraint_compatibility`] and keywords that merely - // narrow when present live in [`Self::check_narrowing_constraints`]; - // listing either here would report both directions as incompatible and - // contradict the "Relaxing / Tightening constraints" rows of sec 4.5. - // - // `patternProperties`, `unevaluatedProperties` and `propertyNames` stay - // here on purpose: they also decide the content model in - // [`Self::classify_content_model`], and a level whose classification can - // change between two definitions is not something this checker attempts - // to reason about. - const EXACT_CONSTRAINTS: &[&str] = &[ - "additionalItems", - "prefixItems", - "patternProperties", - "unevaluatedProperties", - "contains", - "propertyNames", - "dependentRequired", - "dependentSchemas", - "dependencies", - "oneOf", - "anyOf", - "not", - "if", - "then", - "else", - "contentEncoding", - "contentMediaType", - ]; - - EXACT_CONSTRAINTS - .iter() - .filter(|keyword| old_schema.get(**keyword) != new_schema.get(**keyword)) - .map(|keyword| { - CompatibilityDiagnostic::new( - path, - CompatibilityFinding::ConstraintChanged, - format!("changes '{keyword}' constraint"), - ) - }) - .collect() - } - - /// Reports a `$ref` that survived resolution. - /// - /// `$defs`/`definitions` are deliberately absent from - /// [`Self::check_exact_constraints`]: in every dialect they are containers - /// reachable only through `$ref` and never contribute to `Valid(S)` (§4.3), - /// so comparing them would reject changes that alter no accepted instance. - /// The reference itself is what carries the constraint, and - /// [`crate::store::GtsStore::is_compatible`] resolves references before - /// comparing. A `$ref` that is still present therefore means this node was - /// never resolved and nothing can be proven about its target - unless both - /// definitions name the same reference, which needs no resolution. - fn check_unresolved_ref( - path: &str, - old_schema: &Map, - new_schema: &Map, - ) -> Vec { - let old_ref = old_schema.get("$ref").and_then(Value::as_str); - let new_ref = new_schema.get("$ref").and_then(Value::as_str); - if old_ref == new_ref { - return Vec::new(); - } - vec![CompatibilityDiagnostic::new( - path, - CompatibilityFinding::NotProvable, - format!( - "has an unresolved '$ref' ({} vs {}); resolve the reference before comparing, \ - as compatibility depends on the effective resolved schemas", - old_ref.unwrap_or("none"), - new_ref.unwrap_or("none"), - ), - )] - } - - fn check_schema_node_compatibility( - old_schema: &Value, - new_schema: &Value, - path: &str, - check_backward: bool, - dialects: DialectSupport, - inherited_unproven: UnprovenPaths, - errors: &mut Vec, - ) { - // Locations an ancestor could not prove stay unprovable here; add - // whatever this node's own `allOf` composition leaves undecided. - let mut unproven = inherited_unproven; - let old_effective = if old_schema.get("allOf").is_some() { - let (effective, paths) = Self::flatten_effective(old_schema); - unproven.extend(paths); - effective - } else { - old_schema.clone() - }; - let new_effective = if new_schema.get("allOf").is_some() { - let (effective, paths) = Self::flatten_effective(new_schema); - unproven.extend(paths); - effective - } else { - new_schema.clone() - }; - - let (source, target) = if check_backward { - (&old_effective, &new_effective) - } else { - (&new_effective, &old_effective) - }; - let source_boolean = boolean_schema_value(source); - let target_boolean = boolean_schema_value(target); - if source_boolean == Some(false) || target_boolean == Some(true) { - return; - } - if source_boolean == Some(true) || target_boolean == Some(false) { - errors.push(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::ConstraintChanged, - "changes boolean schema incompatibly".to_owned(), - )); - return; - } - - let (Some(old_map), Some(new_map)) = (old_effective.as_object(), new_effective.as_object()) - else { - if old_effective != new_effective { - errors.push(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::ConstraintChanged, - "changes a schema that is not an object".to_owned(), - )); - } - return; - }; - if unproven.contains("") { - errors.push(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::NotProvable, - "contains an allOf intersection that the compatibility checker cannot prove" - .to_owned(), - )); - return; - } - - errors.extend(Self::check_type_compatibility( - path, - old_map, - new_map, - check_backward, - )); - errors.extend(Self::check_value_set_compatibility( - path, - old_map, - new_map, - check_backward, - )); - errors.extend(Self::check_exact_constraints(path, old_map, new_map)); - errors.extend(Self::check_unresolved_ref(path, old_map, new_map)); - errors.extend(Self::check_narrowing_constraints( - path, - old_map, - new_map, - check_backward, - )); - errors.extend(Self::check_constraint_compatibility( - path, - old_map, - new_map, - check_backward, - )); - - let is_object_schema = |schema: &Map| { - schema.get("type").and_then(Value::as_str) == Some("object") - || schema.contains_key("properties") - || schema.contains_key("required") - || schema.contains_key("additionalProperties") - || schema.contains_key("unevaluatedProperties") - || schema.contains_key("patternProperties") - || schema.contains_key("propertyNames") - }; - if is_object_schema(old_map) || is_object_schema(new_map) { - Self::check_object_compatibility( - old_map, - new_map, - path, - check_backward, - dialects, - &unproven, - errors, - ); - } - - match (old_map.get("items"), new_map.get("items")) { - (Some(old_items), Some(new_items)) => Self::check_schema_node_compatibility( - old_items, - new_items, - &format!("{path}[]"), - check_backward, - dialects, - unproven_below(&unproven, "[]"), - errors, - ), - (None, Some(_)) if check_backward => { - errors.push(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::ConstraintChanged, - "adds an array items constraint".to_owned(), - )); - } - (Some(_), None) if !check_backward => errors.push(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::ConstraintChanged, - "removes an array items constraint".to_owned(), - )), - _ => {} - } - } - - fn check_object_compatibility( - old_schema: &Map, - new_schema: &Map, - path: &str, - check_backward: bool, - dialects: DialectSupport, - unproven: &UnprovenPaths, - errors: &mut Vec, - ) { - let empty = Map::new(); - let old_props = old_schema - .get("properties") - .and_then(Value::as_object) - .unwrap_or(&empty); - let new_props = new_schema - .get("properties") - .and_then(Value::as_object) - .unwrap_or(&empty); - - let old_required: HashSet<&str> = old_schema - .get("required") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .collect(); - let new_required: HashSet<&str> = new_schema - .get("required") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .collect(); - - let mut required_difference: Vec<&str> = if check_backward { - new_required.difference(&old_required).copied().collect() - } else { - old_required.difference(&new_required).copied().collect() - }; - required_difference.sort_unstable(); - if !required_difference.is_empty() { - errors.push(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::RequiredChanged, - format!( - "{} required properties: {required_difference:?}", - if check_backward { "adds" } else { "removes" } - ), - )); - } - - let old_model = Self::classify_content_model(old_schema, dialects.old_unevaluated); - let new_model = Self::classify_content_model(new_schema, dialects.new_unevaluated); - let (source_model, target_model) = if check_backward { - (old_model, new_model) - } else { - (new_model, old_model) - }; - let partial_constraints_equal = - Self::partial_content_constraints_equal(old_schema, new_schema, dialects); - if !Self::content_model_is_subset(source_model, target_model) { - errors.push(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::ContentModelChanged, - format!( - "changes the content model incompatibly from {} to {}", - old_model.label(), - new_model.label(), - ), - )); - } else if source_model == ContentModel::Partial - && target_model == ContentModel::Partial - && !partial_constraints_equal - { - errors.push(CompatibilityDiagnostic::new( - path, - CompatibilityFinding::NotProvable, - "changes partially open content constraints; inclusion cannot be proven".to_owned(), - )); - } - - for (name, old_property) in old_props { - let property_path = if path == "$" { - format!("$.{name}") - } else { - format!("{path}.{name}") - }; - if let Some(new_property) = new_props.get(name) { - Self::check_schema_node_compatibility( - old_property, - new_property, - &property_path, - check_backward, - dialects, - unproven_below(unproven, &format!(".{name}")), - errors, - ); - } else { - let incompatible_model = if check_backward { - new_model != ContentModel::Open - } else { - new_model != ContentModel::Closed - }; - if incompatible_model { - errors.push(Self::property_change_error(path, name, true, new_model)); - } - } - } - - for name in new_props - .keys() - .filter(|name| !old_props.contains_key(*name)) - { - let incompatible_model = if check_backward { - old_model != ContentModel::Closed - } else { - old_model != ContentModel::Open - }; - if incompatible_model { - errors.push(Self::property_change_error(path, name, false, old_model)); - } - } - } - - fn classify_content_model( - schema: &Map, - supports_unevaluated: bool, - ) -> ContentModel { - let pattern_properties = schema - .get("patternProperties") - .and_then(Value::as_object) - .filter(|patterns| !patterns.is_empty()); - let patterns_all_open = pattern_properties.is_some_and(|patterns| { - patterns - .values() - .all(|constraint| boolean_schema_value(constraint) == Some(true)) - }); - let patterns_all_closed = pattern_properties.is_some_and(|patterns| { - patterns - .values() - .all(|constraint| boolean_schema_value(constraint) == Some(false)) - }); - let property_names_model = schema.get("propertyNames").and_then(boolean_schema_value); - if property_names_model == Some(false) { - return ContentModel::Closed; - } - - // `unevaluatedProperties` is the fallback only when this level does not - // already evaluate unmatched names through `additionalProperties`. - let undeclared_fallback = schema.get("additionalProperties").or_else(|| { - supports_unevaluated - .then(|| schema.get("unevaluatedProperties")) - .flatten() - }); - let fallback_model = undeclared_fallback.map_or(Some(true), boolean_schema_value); - let constrains_property_names = - property_names_model.is_none() && schema.contains_key("propertyNames"); - let constrains_fallback = fallback_model.is_none(); - - if pattern_properties.is_some() { - if fallback_model == Some(false) && patterns_all_closed { - ContentModel::Closed - } else if fallback_model == Some(true) - && patterns_all_open - && !constrains_property_names - { - ContentModel::Open - } else { - ContentModel::Partial - } - } else if fallback_model == Some(false) { - ContentModel::Closed - } else if constrains_property_names || constrains_fallback { - ContentModel::Partial - } else { - ContentModel::Open - } - } - - const fn content_model_is_subset(source: ContentModel, target: ContentModel) -> bool { - matches!( - (source, target), - (ContentModel::Closed, _) - | (_, ContentModel::Open) - | (ContentModel::Partial, ContentModel::Partial) - ) - } - - fn partial_content_constraints_equal( - old_schema: &Map, - new_schema: &Map, - dialects: DialectSupport, - ) -> bool { - let normalize_additional = |schema: &Map| { - schema - .get("additionalProperties") - .cloned() - .unwrap_or(Value::Bool(true)) - }; - let normalize_unevaluated = |schema: &Map, supported: bool| { - if supported { - schema - .get("unevaluatedProperties") - .cloned() - .unwrap_or(Value::Bool(true)) - } else { - Value::Bool(true) - } - }; - - normalize_additional(old_schema) == normalize_additional(new_schema) - && old_schema.get("patternProperties") == new_schema.get("patternProperties") - && old_schema.get("propertyNames") == new_schema.get("propertyNames") - && normalize_unevaluated(old_schema, dialects.old_unevaluated) - == normalize_unevaluated(new_schema, dialects.new_unevaluated) - } - - fn property_change_error( - path: &str, - property: &str, - removed: bool, - model: ContentModel, - ) -> CompatibilityDiagnostic { - let operation = if removed { "removes" } else { "adds" }; - if model == ContentModel::Partial { - CompatibilityDiagnostic::new( - path, - CompatibilityFinding::NotProvable, - format!( - "{operation} property '{property}', but compatibility cannot be proven for \ - the partially open object level" - ), - ) - } else { - CompatibilityDiagnostic::new( - path, - if removed { - CompatibilityFinding::PropertyRemoved - } else { - CompatibilityFinding::PropertyAdded - }, - format!( - "{operation} property '{property}' in a {} model", - model.label() - ), - ) - } - } - - /// Checks `Valid(old) ⊆ Valid(new)` and renders each reason as a string. - /// - /// The two schemas MUST already be `$ref`-resolved; see - /// [`crate::store::GtsStore::compare_documents`], which resolves and then - /// calls this. Prefer [`Self::check_backward_diagnostics`] when the caller - /// needs the offending schema location rather than prose. - #[must_use] - pub fn check_backward_compatibility( - old_schema: &Value, - new_schema: &Value, - ) -> (CompatibilityVerdict, Vec) { - let (verdict, diagnostics) = Self::check_backward_diagnostics(old_schema, new_schema); - (verdict, render_diagnostics(&diagnostics)) - } - - /// Checks `Valid(new) ⊆ Valid(old)` and renders each reason as a string. - /// - /// See [`Self::check_backward_compatibility`] for the resolution - /// requirement. - #[must_use] - pub fn check_forward_compatibility( - old_schema: &Value, - new_schema: &Value, - ) -> (CompatibilityVerdict, Vec) { - let (verdict, diagnostics) = Self::check_forward_diagnostics(old_schema, new_schema); - (verdict, render_diagnostics(&diagnostics)) - } - - /// Checks `Valid(old) ⊆ Valid(new)`, reporting each reason with its schema - /// location. - #[must_use] - pub fn check_backward_diagnostics( - old_schema: &Value, - new_schema: &Value, - ) -> (CompatibilityVerdict, Vec) { - Self::check_schema_compatibility(old_schema, new_schema, true) - } - - /// Checks `Valid(new) ⊆ Valid(old)`, reporting each reason with its schema - /// location. - #[must_use] - pub fn check_forward_diagnostics( - old_schema: &Value, - new_schema: &Value, - ) -> (CompatibilityVerdict, Vec) { - Self::check_schema_compatibility(old_schema, new_schema, false) - } - - fn check_schema_compatibility( - old_schema: &Value, - new_schema: &Value, - check_backward: bool, - ) -> (CompatibilityVerdict, Vec) { - let mut errors = Vec::new(); - let declared_old = old_schema.get("$schema").and_then(Value::as_str); - let declared_new = new_schema.get("$schema").and_then(Value::as_str); - - // Only a genuine change of declared dialect is reported. An omitted - // `$schema` means "whatever dialect the implementation applies" (sec 11 - // makes GTS dialect-agnostic), so it is read as the dialect the other - // definition declares rather than as a difference - otherwise merely - // starting to declare a dialect that was already in effect would be - // reported as incompatible in both directions. - if let (Some(old_dialect), Some(new_dialect)) = (declared_old, declared_new) - && old_dialect != new_dialect - { - errors.push(CompatibilityDiagnostic::new( - "$", - CompatibilityFinding::DialectChanged, - format!("changes JSON Schema dialect from {old_dialect} to {new_dialect}"), - )); - } - let effective_old = declared_old.or(declared_new); - let effective_new = declared_new.or(declared_old); - Self::check_schema_node_compatibility( - old_schema, - new_schema, - "$", - check_backward, - DialectSupport { - old_unevaluated: Self::dialect_supports_unevaluated(effective_old), - new_unevaluated: Self::dialect_supports_unevaluated(effective_new), - }, - UnprovenPaths::new(), - &mut errors, - ); - (CompatibilityVerdict::from_diagnostics(&errors), errors) - } - - /// Whether `unevaluatedProperties` is evaluated under `dialect`. - /// - /// The keyword exists from Draft 2019-09 on; earlier dialects ignore it as - /// an unknown annotation. An omitted `$schema` means "whatever dialect the - /// implementation applies" - GTS is dialect-agnostic (sec 11) and names no - /// default - and this implementation validates instances with - /// [`jsonschema::validator_for`], which falls back to Draft 2020-12. Reading - /// an omitted dialect as pre-2019-09 would therefore make this checker - /// contradict the validator running in the same process: a level closed by - /// `unevaluatedProperties: false` would be classified open, which reverses - /// both verdicts for an added optional property. - fn dialect_supports_unevaluated(dialect: Option<&str>) -> bool { - dialect.is_none_or(|value| value.contains("2019-09") || value.contains("2020-12")) - } - - /// Classifies the content model of every object level of a schema. - /// - /// The schema MUST already be `$ref`-resolved: gts-spec §4.4 requires the - /// content model to be read from the fully resolved effective schema, - /// because `unevaluatedProperties`, `patternProperties`, `propertyNames`, a - /// nontrivial schema-valued `additionalProperties`, or a conjunctive - /// subschema reached through `allOf` or `$ref` can all decide whether - /// undeclared properties are accepted. - /// [`crate::store::GtsStore::compare_documents`] resolves before calling - /// this. - /// - /// A level is reported once, at the location where it appears in the - /// document. Levels reached only through `oneOf`, `anyOf`, `not`, or - /// `if`/`then`/`else` are not reported: an instance satisfies one branch - /// rather than all of them, so such a level has no single content model. - #[must_use] - pub fn classify_object_levels(schema: &Value) -> Vec { - let dialect = schema.get("$schema").and_then(Value::as_str); - let supports_unevaluated = Self::dialect_supports_unevaluated(dialect); - let mut levels = Vec::new(); - Self::collect_object_levels(schema, "$", supports_unevaluated, &mut levels); - levels - } - - fn collect_object_levels( - schema: &Value, - path: &str, - supports_unevaluated: bool, - levels: &mut Vec, - ) { - let effective = if schema.get("allOf").is_some() { - Self::flatten_schema(schema) - } else { - schema.clone() - }; - let Some(map) = effective.as_object() else { - return; - }; - - let declares_object = map.get("type").and_then(Value::as_str) == Some("object") - || map.contains_key("properties") - || map.contains_key("additionalProperties") - || map.contains_key("unevaluatedProperties") - || map.contains_key("patternProperties") - || map.contains_key("propertyNames"); - if declares_object { - levels.push(ObjectLevel { - path: path.to_owned(), - content_model: Self::classify_content_model(map, supports_unevaluated), - }); - } - - if let Some(properties) = map.get("properties").and_then(Value::as_object) { - for (name, property) in properties { - let property_path = if path == "$" { - format!("$.{name}") - } else { - format!("{path}.{name}") - }; - Self::collect_object_levels(property, &property_path, supports_unevaluated, levels); - } - } - if let Some(items) = map.get("items") { - Self::collect_object_levels(items, &format!("{path}[]"), supports_unevaluated, levels); - } - } -} - -/// Compares two JSON values the way JSON Schema compares instances. -/// -/// `serde_json`'s `PartialEq` distinguishes the integer and float -/// representations of a number, but JSON Schema equality - the relation `const` -/// and `enum` are defined in terms of - compares numbers by mathematical -/// value, so `1` and `1.0` denote the same instance. Composites -/// compare member by member, which makes the numeric rule apply at any depth; -/// every other value type compares as `serde_json` already does. -fn json_values_equal(left: &Value, right: &Value) -> bool { - match (left, right) { - (Value::Number(left), Value::Number(right)) => json_numbers_equal(left, right), - (Value::Array(left), Value::Array(right)) => { - left.len() == right.len() - && left - .iter() - .zip(right.iter()) - .all(|(left, right)| json_values_equal(left, right)) - } - // Object member order carries no meaning, so equal length plus a match - // for every key of one side is equality. - (Value::Object(left), Value::Object(right)) => { - left.len() == right.len() - && left.iter().all(|(key, left)| { - right - .get(key) - .is_some_and(|right| json_values_equal(left, right)) - }) - } - _ => left == right, - } -} - -/// Compares two JSON numbers by mathematical value. -#[allow( - clippy::float_cmp, - reason = "JSON Schema equality is exact equality of the mathematical value" -)] -fn json_numbers_equal(left: &serde_json::Number, right: &serde_json::Number) -> bool { - // Integers are compared as integers: routing them through `f64` would round - // the 64-bit values a double cannot represent exactly and call two distinct - // numbers equal. - if let (Some(left), Some(right)) = (left.as_u64(), right.as_u64()) { - return left == right; - } - if let (Some(left), Some(right)) = (left.as_i64(), right.as_i64()) { - return left == right; - } - - let left_integer = left.is_u64() || left.is_i64(); - let right_integer = right.is_u64() || right.is_i64(); - // Two integers that neither comparison above could pair up are one negative - // value and one above `i64::MAX`, so they are not equal. - if left_integer && right_integer { - return false; - } - // One integer and one float. The pair is compared exactly rather than by - // converting both sides to `f64`, which would round `2^53 + 1` down to - // `2^53` and report two different mathematical values - two different - // accepted-instance sets - as equal. This is the comparator `jsonschema` - // applies to a mixed pair when it validates the same instance. - if left_integer { - return right - .as_f64() - .is_some_and(|right| integer_equals_float(left, right)); - } - if right_integer { - return left - .as_f64() - .is_some_and(|left| integer_equals_float(right, left)); - } - - match (left.as_f64(), right.as_f64()) { - (Some(left), Some(right)) => left == right, - // Not representable as `f64`, which needs `serde_json`'s - // `arbitrary_precision`; the stored representation is all that is left - // to compare. - _ => left == right, - } -} - -/// Compares an integer-valued JSON number to a float, exactly. -fn integer_equals_float(integer: &serde_json::Number, float: f64) -> bool { - if let Some(integer) = integer.as_u64() { - return NumCmp::num_eq(integer, float); - } - integer - .as_i64() - .is_some_and(|integer| NumCmp::num_eq(integer, float)) -} - -fn render_diagnostics(diagnostics: &[CompatibilityDiagnostic]) -> Vec { - diagnostics - .iter() - .map(std::string::ToString::to_string) - .collect() -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - use serde_json::json; - - // Helper struct for compatibility results - #[derive(Debug, Default)] - #[allow(clippy::struct_field_names)] - struct CompatibilityResult { - backward_compatibility: CompatibilityVerdict, - forward_compatibility: CompatibilityVerdict, - full_compatibility: CompatibilityVerdict, - } - - // Helper function to check schema compatibility - fn check_schema_compatibility( - old_schema: &serde_json::Value, - new_schema: &serde_json::Value, - ) -> CompatibilityResult { - let (backward_compatibility, _) = - GtsEntityCastResult::check_backward_compatibility(old_schema, new_schema); - let (forward_compatibility, _) = - GtsEntityCastResult::check_forward_compatibility(old_schema, new_schema); - let full_compatibility = - CompatibilityVerdict::full(backward_compatibility, forward_compatibility); - - CompatibilityResult { - backward_compatibility, - forward_compatibility, - full_compatibility, - } - } - - #[test] - fn test_schema_cast_error_display() { - let error = SchemaCastError::InternalError("test error".to_owned()); - assert!(error.to_string().contains("test error")); - - let error = SchemaCastError::CastError("cast error".to_owned()); - assert!(error.to_string().contains("cast error")); - } - - #[test] - fn test_compatibility_verdict_serialization_and_full_derivation() { - assert_eq!( - serde_json::to_value(CompatibilityVerdict::Compatible).expect("serialize verdict"), - json!("compatible") - ); - assert_eq!( - serde_json::to_value(CompatibilityVerdict::Incompatible).expect("serialize verdict"), - json!("incompatible") - ); - assert_eq!( - serde_json::to_value(CompatibilityVerdict::Unknown).expect("serialize verdict"), - json!("unknown") - ); - assert_eq!(CompatibilityVerdict::Unknown.to_string(), "unknown"); - - assert_eq!( - CompatibilityVerdict::full( - CompatibilityVerdict::Compatible, - CompatibilityVerdict::Compatible - ), - CompatibilityVerdict::Compatible - ); - assert_eq!( - CompatibilityVerdict::full( - CompatibilityVerdict::Compatible, - CompatibilityVerdict::Unknown - ), - CompatibilityVerdict::Unknown - ); - assert_eq!( - CompatibilityVerdict::full( - CompatibilityVerdict::Unknown, - CompatibilityVerdict::Incompatible - ), - CompatibilityVerdict::Incompatible - ); - } - - #[test] - fn test_json_entity_cast_result_infer_direction_up() { - let direction = GtsEntityCastResult::infer_direction( - "gts.vendor.package.namespace.type.v1.0~abc.app.custom.event.v1.0", - "gts.vendor.package.namespace.type.v1.1~abc.app.custom.event.v1.1", // v1.1 has higher minor version - ); - assert_eq!(direction, "up"); - } - - #[test] - fn test_undecided_result_initializes_error_contract() { - let result = GtsEntityCastResult::undecided("old", "new", "could not decide"); - - assert_eq!(result.from_id, "old"); - assert_eq!(result.to_id, "new"); - assert_eq!(result.direction, "unknown"); - assert!(result.full_compatibility.is_unknown()); - assert!(result.backward_compatibility.is_unknown()); - assert!(result.forward_compatibility.is_unknown()); - assert!(result.added_properties.is_empty()); - assert!(result.removed_properties.is_empty()); - assert!(result.changed_properties.is_empty()); - assert!(result.incompatibility_reasons.is_empty()); - assert!(result.backward_errors.is_empty()); - assert!(result.forward_errors.is_empty()); - assert_eq!( - result.specification_version, - crate::GTS_SPECIFICATION_VERSION - ); - assert_eq!( - result.implementation_version, - crate::GTS_IMPLEMENTATION_VERSION - ); - assert!(result.casted_entity.is_none()); - assert_eq!(result.error.as_deref(), Some("could not decide")); - - let directed = - GtsEntityCastResult::undecided_with_direction("old", "new", "up", "resolution failed"); - assert_eq!(directed.direction, "up"); - } - - #[test] - fn test_json_entity_cast_result_infer_direction_down() { - let direction = GtsEntityCastResult::infer_direction( - "gts.vendor.package.namespace.type.v1.1~abc.app.custom.event.v1.1", // v1.1 has higher minor version - "gts.vendor.package.namespace.type.v1.0~abc.app.custom.event.v1.0", - ); - assert_eq!(direction, "down"); - } - - #[test] - fn test_json_entity_cast_result_infer_direction_none() { - // Same minor version returns "none" - let direction = GtsEntityCastResult::infer_direction( - "gts.vendor.package.namespace.type.v1.0~abc.app.custom.event.v1.0", - "gts.vendor.package.namespace.type.v1.0~abc.app.custom.event.v1.0", - ); - assert_eq!(direction, "none"); - } - - #[test] - fn test_json_entity_cast_result_serialization() { - let result = GtsEntityCastResult { - from_id: "gts.vendor.package.namespace.type.v1.0".to_owned(), - to_id: "gts.vendor.package.namespace.type.v2.0".to_owned(), - old: "gts.vendor.package.namespace.type.v1.0".to_owned(), - new: "gts.vendor.package.namespace.type.v2.0".to_owned(), - direction: "up".to_owned(), - added_properties: vec![], - removed_properties: vec![], - changed_properties: vec![], - full_compatibility: CompatibilityVerdict::Incompatible, - backward_compatibility: CompatibilityVerdict::Compatible, - forward_compatibility: CompatibilityVerdict::Incompatible, - incompatibility_reasons: vec![], - backward_errors: vec![], - forward_errors: vec![], - specification_version: specification_version(), - implementation_version: implementation_version(), - casted_entity: None, - error: None, - }; - - let json_value = serde_json::to_value(&result).expect("test"); - let json = json_value.as_object().expect("test"); - assert_eq!( - json.get("from").expect("test").as_str().expect("test"), - "gts.vendor.package.namespace.type.v1.0" - ); - assert_eq!( - json.get("to").expect("test").as_str().expect("test"), - "gts.vendor.package.namespace.type.v2.0" - ); - assert_eq!( - json.get("direction").expect("test").as_str().expect("test"), - "up" - ); - assert_eq!( - json.get("specification_version").and_then(Value::as_str), - Some(crate::GTS_SPECIFICATION_VERSION) - ); - assert_eq!( - json.get("implementation_version").and_then(Value::as_str), - Some(crate::GTS_IMPLEMENTATION_VERSION) - ); - } - - #[test] - fn test_check_schema_compatibility_identical() { - let schema1 = json!({ - "type": "object", - "properties": { - "name": {"type": "string"} - } - }); - - let result = check_schema_compatibility(&schema1, &schema1); - assert!(result.backward_compatibility.is_compatible()); - assert!(result.forward_compatibility.is_compatible()); - assert!(result.full_compatibility.is_compatible()); - } - - #[test] - fn test_check_schema_compatibility_added_optional_property() { - let old_schema = json!({ - "type": "object", - "properties": { - "name": {"type": "string"} - } - }); - - let new_schema = json!({ - "type": "object", - "properties": { - "name": {"type": "string"}, - "email": {"type": "string"} - } - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - // An open model already accepted arbitrary `email` values; declaring - // it narrows that set. - assert!(result.backward_compatibility.is_incompatible()); - assert!(result.forward_compatibility.is_compatible()); - } - - #[test] - fn test_check_schema_compatibility_added_required_property() { - let old_schema = json!({ - "type": "object", - "properties": { - "name": {"type": "string"} - }, - "required": ["name"] - }); - - let new_schema = json!({ - "type": "object", - "properties": { - "name": {"type": "string"}, - "email": {"type": "string"} - }, - "required": ["name", "email"] - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - // Adding required property is not backward compatible - assert!(result.backward_compatibility.is_incompatible()); - } - - #[test] - fn test_check_schema_compatibility_removed_property() { - let old_schema = json!({ - "type": "object", - "properties": { - "name": {"type": "string"}, - "email": {"type": "string"} - } - }); - - let new_schema = json!({ - "type": "object", - "properties": { - "name": {"type": "string"} - } - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_compatible()); - assert!(result.forward_compatibility.is_incompatible()); - } - - #[test] - fn test_check_schema_compatibility_enum_expansion() { - let old_schema = json!({ - "type": "string", - "enum": ["active", "inactive"] - }); - - let new_schema = json!({ - "type": "string", - "enum": ["active", "inactive", "pending"] - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_compatible()); - assert!(result.forward_compatibility.is_incompatible()); - assert!(result.full_compatibility.is_incompatible()); - } - - #[test] - fn test_check_schema_compatibility_enum_reduction() { - let old_schema = json!({ - "type": "string", - "enum": ["active", "inactive", "pending"] - }); - - let new_schema = json!({ - "type": "string", - "enum": ["active", "inactive"] - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_incompatible()); - assert!(result.forward_compatibility.is_compatible()); - assert!(result.full_compatibility.is_incompatible()); - } - - #[test] - fn test_check_schema_compatibility_type_change() { - let old_schema = json!({ - "type": "string" - }); - - let new_schema = json!({ - "type": "number" - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_incompatible()); - assert!(result.forward_compatibility.is_incompatible()); - } - - #[test] - fn test_check_schema_compatibility_constraint_tightening() { - let old_schema = json!({ - "type": "number", - "minimum": 0 - }); - - let new_schema = json!({ - "type": "number", - "minimum": 10 - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_incompatible()); - assert!(result.forward_compatibility.is_compatible()); - } - - #[test] - fn test_check_schema_compatibility_constraint_relaxing() { - let old_schema = json!({ - "type": "number", - "maximum": 100 - }); - - let new_schema = json!({ - "type": "number", - "maximum": 200 - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - // Relaxing maximum is backward compatible - assert!(result.backward_compatibility.is_compatible()); - } - - #[test] - fn test_check_schema_compatibility_nested_objects() { - let old_schema = json!({ - "type": "object", - "properties": { - "user": { - "type": "object", - "properties": { - "name": {"type": "string"} - } - } - } - }); - - let new_schema = json!({ - "type": "object", - "properties": { - "user": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "email": {"type": "string"} - } - } - } - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_incompatible()); - assert!(result.forward_compatibility.is_compatible()); - } - - #[test] - fn test_check_schema_compatibility_string_length_constraints() { - let old_schema = json!({ - "type": "string", - "minLength": 1, - "maxLength": 100 - }); - - let new_schema = json!({ - "type": "string", - "minLength": 5, - "maxLength": 50 - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_incompatible()); - assert!(result.forward_compatibility.is_compatible()); - } - - #[test] - fn test_check_schema_compatibility_array_length_constraints() { - let old_schema = json!({ - "type": "array", - "minItems": 1, - "maxItems": 10 - }); - - let new_schema = json!({ - "type": "array", - "minItems": 2, - "maxItems": 5 - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_incompatible()); - assert!(result.forward_compatibility.is_compatible()); - } - - #[test] - fn test_compatibility_result_default() { - let result = CompatibilityResult::default(); - assert!(result.backward_compatibility.is_unknown()); - assert!(result.forward_compatibility.is_unknown()); - assert!(result.full_compatibility.is_unknown()); - } - - #[test] - fn test_compatibility_result_fully_compatible() { - let result = CompatibilityResult { - backward_compatibility: CompatibilityVerdict::Compatible, - forward_compatibility: CompatibilityVerdict::Compatible, - full_compatibility: CompatibilityVerdict::Compatible, - }; - assert!(result.full_compatibility.is_compatible()); - } - - #[test] - fn test_check_schema_compatibility_enum_reordered() { - let old_schema = json!({ - "type": "string", - "enum": ["a", "b", "c"] - }); - - let new_schema = json!({ - "type": "string", - "enum": ["c", "a", "b"] - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_compatible()); - assert!(result.forward_compatibility.is_compatible()); - assert!(result.full_compatibility.is_compatible()); - } - - #[test] - fn test_check_schema_compatibility_nested_required_added() { - let old_schema = json!({ - "type": "object", - "properties": { - "user": { - "type": "object", - "properties": { - "name": {"type": "string"} - }, - "required": ["name"] - } - }, - "required": ["user"] - }); - - let new_schema = json!({ - "type": "object", - "properties": { - "user": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "email": {"type": "string"} - }, - "required": ["name", "email"] - } - }, - "required": ["user"] - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - // Adding nested required is not backward compatible - assert!(result.backward_compatibility.is_incompatible()); - } - - #[test] - fn test_check_schema_compatibility_allof_flatten_equivalence() { - let direct = json!({ - "type": "object", - "properties": { - "id": {"type": "string"}, - "value": {"type": "number"} - }, - "required": ["id"] - }); - - let via_allof = json!({ - "allOf": [ - { - "type": "object", - "properties": {"id": {"type": "string"}}, - "required": ["id"] - }, - { - "type": "object", - "properties": {"value": {"type": "number"}} - } - ] - }); - - // Either direction should be fully compatible - let r1 = check_schema_compatibility(&direct, &via_allof); - assert!(r1.backward_compatibility.is_compatible()); - assert!(r1.forward_compatibility.is_compatible()); - assert!(r1.full_compatibility.is_compatible()); - - let r2 = check_schema_compatibility(&via_allof, &direct); - assert!(r2.backward_compatibility.is_compatible()); - assert!(r2.forward_compatibility.is_compatible()); - assert!(r2.full_compatibility.is_compatible()); - } - - #[test] - fn test_check_schema_compatibility_removed_required() { - let old_schema = json!({ - "type": "object", - "properties": {"name": {"type": "string"}}, - "required": ["name"] - }); - - let new_schema = json!({ - "type": "object", - "properties": {"name": {"type": "string"}} - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - // Removing required is forward-incompatible - assert!(result.forward_compatibility.is_incompatible()); - } - - #[test] - fn test_cast_adds_defaults_and_updates_gtsid_const() { - // Instance is missing optional 'region' and has an outdated GTS id const in 'typeRef' - let from_instance_id = "gts.vendor.pkg.ns.type.v1.0"; - let from_instance = json!({ - "name": "alice", - "typeRef": "gts.vendor.pkg.ns.subtype.v1.0~" - }); - - // From schema (minimal) - let from_schema = json!({ - "type": "object", - "properties": { - "name": {"type": "string"}, - "typeRef": {"type": "string"} - } - }); - - // To schema has default for optional 'region' and const for 'typeRef' to a newer ID - let to_type_id = "gts.vendor.pkg.ns.type.v1.1"; - let to_schema = json!({ - "type": "object", - "properties": { - "name": {"type": "string"}, - "region": {"type": "string", "default": "us-east"}, - "typeRef": {"type": "string", "const": "gts.vendor.pkg.ns.subtype.v1.1~"} - } - }); - - let cast = GtsEntityCastResult::cast( - from_instance_id, - to_type_id, - &from_instance, - &from_schema, - &to_schema, - None, - ) - .expect("cast ok"); - - // Defaults should be added - assert!(cast.added_properties.iter().any(|p| p == "region")); - - let casted = cast.casted_entity.expect("casted entity"); - assert_eq!( - casted.get("region").and_then(|v| v.as_str()), - Some("us-east") - ); - // typeRef should be updated to the const GTS ID - assert_eq!( - casted.get("typeRef").and_then(|v| v.as_str()), - Some("gts.vendor.pkg.ns.subtype.v1.1~") - ); - } - - #[test] - fn test_cast_removes_additional_properties_when_disallowed() { - let from_instance_id = "gts.vendor.pkg.ns.type.v1.0"; - let from_instance = json!({ - "name": "alice", - "extra": 123 - }); - - let from_schema = json!({ - "type": "object", - "properties": {"name": {"type": "string"}} - }); - - let to_type_id = "gts.vendor.pkg.ns.type.v1.1"; - let to_schema = json!({ - "type": "object", - "additionalProperties": false, - "properties": {"name": {"type": "string"}} - }); - - let cast = GtsEntityCastResult::cast( - from_instance_id, - to_type_id, - &from_instance, - &from_schema, - &to_schema, - None, - ) - .expect("cast ok"); - - // 'extra' should be removed - let casted = cast.casted_entity.expect("casted entity"); - assert!(casted.get("extra").is_none()); - assert!(cast.removed_properties.iter().any(|p| p == "extra")); - } - - #[test] - fn test_closed_model_optional_addition_is_not_fully_compatible() { - let old_schema = json!({ - "type": "object", - "additionalProperties": false, - "properties": { - "name": {"type": "string"} - } - }); - let new_schema = json!({ - "type": "object", - "additionalProperties": false, - "properties": { - "name": {"type": "string"}, - "email": {"type": "string"} - } - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_compatible()); - assert!(result.forward_compatibility.is_incompatible()); - assert!(result.full_compatibility.is_incompatible()); - } - - #[test] - fn test_additional_properties_change_without_declared_properties_is_detected() { - let old_schema = json!({"type": "object"}); - let new_schema = json!({ - "type": "object", - "additionalProperties": false - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_incompatible()); - assert!(result.forward_compatibility.is_compatible()); - assert!(result.full_compatibility.is_incompatible()); - } - - #[test] - fn test_required_change_without_declared_properties_is_detected() { - let old_schema = json!({"type": "object"}); - let new_schema = json!({ - "type": "object", - "required": ["value"] - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_incompatible()); - assert!(result.forward_compatibility.is_compatible()); - assert!(result.full_compatibility.is_incompatible()); - } - - #[test] - fn test_removing_enum_constraint_is_not_fully_compatible() { - let old_schema = json!({ - "type": "object", - "properties": { - "status": {"type": "string", "enum": ["active", "inactive"]} - } - }); - let new_schema = json!({ - "type": "object", - "properties": { - "status": {"type": "string"} - } - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_compatible()); - assert!(result.forward_compatibility.is_incompatible()); - assert!(result.full_compatibility.is_incompatible()); - } - - #[test] - fn test_adding_enum_constraint_is_forward_only() { - let old_schema = json!({"type": "string"}); - let new_schema = json!({ - "type": "string", - "enum": ["active", "inactive"] - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_incompatible()); - assert!(result.forward_compatibility.is_compatible()); - assert!(result.full_compatibility.is_incompatible()); - } - - #[test] - fn test_adding_and_removing_const_are_directional() { - let added = property_change( - json!({"type": "integer"}), - json!({"type": "integer", "const": 1}), - ); - assert!(added.backward_compatibility.is_incompatible()); - assert!(added.forward_compatibility.is_compatible()); - - let removed = property_change( - json!({"type": "integer", "const": 1}), - json!({"type": "integer"}), - ); - assert!(removed.backward_compatibility.is_compatible()); - assert!(removed.forward_compatibility.is_incompatible()); - } - - /// `const` and `enum` constrain the same thing, so a revision that moves - /// between the two spellings must be read as one value set. - #[test] - fn test_const_and_enum_form_one_value_set() { - // Valid({"const": 1}) = Valid({"enum": [1]}) = {1}. - let rewritten = property_change(json!({"const": 1}), json!({"enum": [1]})); - assert!(rewritten.full_compatibility.is_compatible()); - - let rewritten_back = property_change(json!({"enum": [1]}), json!({"const": 1})); - assert!(rewritten_back.full_compatibility.is_compatible()); - - // Widening the singleton into a larger set is backward-only. - let widened = property_change(json!({"const": 1}), json!({"enum": [1, 2]})); - assert!(widened.backward_compatibility.is_compatible()); - assert!(widened.forward_compatibility.is_incompatible()); - - // Narrowing an enum down to one of its members is forward-only. - let narrowed = property_change(json!({"enum": [1, 2]}), json!({"const": 1})); - assert!(narrowed.backward_compatibility.is_incompatible()); - assert!(narrowed.forward_compatibility.is_compatible()); - - // A value outside the old set is incompatible in either direction. - let moved = property_change(json!({"const": 1}), json!({"enum": [2]})); - assert!(moved.backward_compatibility.is_incompatible()); - assert!(moved.forward_compatibility.is_incompatible()); - - // Both keywords at once accept only what satisfies both. - let intersected = property_change(json!({"const": 1, "enum": [1, 2]}), json!({"const": 1})); - assert!(intersected.full_compatibility.is_compatible()); - } - - /// JSON Schema compares values by mathematical value, so the integer and - /// float spellings of one number denote the same instance. - #[test] - fn test_value_sets_use_json_schema_equality() { - let respelled = property_change(json!({"const": 1}), json!({"enum": [1.0]})); - assert!(respelled.full_compatibility.is_compatible()); - - // The rule applies at any depth inside a composite value. - let nested = property_change( - json!({"const": {"a": [1, {"b": 2}]}}), - json!({"const": {"a": [1.0, {"b": 2.0}]}}), - ); - assert!(nested.full_compatibility.is_compatible()); - - // Narrowing still has to be seen through the respelling. - let narrowed = property_change(json!({"enum": [1, 2]}), json!({"const": 2.0})); - assert!(narrowed.backward_compatibility.is_incompatible()); - assert!(narrowed.forward_compatibility.is_compatible()); - - // Equal mathematical value is not equal representation of anything else: - // a different number, a different type, or a differing member count all - // remain distinct values. - for (old_value, new_value) in [ - (json!(1), json!(1.5)), - (json!(1), json!("1")), - (json!(1), json!(true)), - (json!([1]), json!([1, 1])), - (json!({"a": 1}), json!({"a": 1, "b": 1})), - ] { - let moved = property_change(json!({"const": old_value}), json!({"const": new_value})); - assert!( - moved.backward_compatibility.is_incompatible(), - "{old_value} vs {new_value}" - ); - assert!( - moved.forward_compatibility.is_incompatible(), - "{old_value} vs {new_value}" - ); - } - - // The same equality decides whether a narrowing keyword changed at all. - let respelled_multiple_of = - property_change(json!({"multipleOf": 5}), json!({"multipleOf": 5.0})); - assert!(respelled_multiple_of.full_compatibility.is_compatible()); - } - - /// Comparing a mixed integer/float pair has to be exact: rounding both sides - /// to `f64` would erase the difference between `2^53 + 1` and `2^53`. - #[test] - fn test_value_set_equality_is_exact_across_number_types() { - // 9007199254740993 is 2^53 + 1, which no `f64` represents. - let rounded = property_change( - json!({"const": 9_007_199_254_740_993_i64}), - json!({"enum": [9_007_199_254_740_992.0_f64]}), - ); - assert!(rounded.backward_compatibility.is_incompatible()); - assert!(rounded.forward_compatibility.is_incompatible()); - - // 2^53 itself is exactly representable, so its two spellings are one - // value and the comparison must still see that. - let exact = property_change( - json!({"const": 9_007_199_254_740_992_i64}), - json!({"enum": [9_007_199_254_740_992.0_f64]}), - ); - assert!(exact.full_compatibility.is_compatible()); - - // The same number kept as an integer on both sides. - let integral = property_change( - json!({"const": 9_007_199_254_740_993_i64}), - json!({"enum": [9_007_199_254_740_993_i64]}), - ); - assert!(integral.full_compatibility.is_compatible()); - - // A `u64` above `i64::MAX` and a negative number share no - // representation to be compared through, and are not equal. - let mixed_signedness = property_change( - json!({"const": 18_446_744_073_709_551_615_u64}), - json!({"const": -1_i64}), - ); - assert!(mixed_signedness.backward_compatibility.is_incompatible()); - assert!(mixed_signedness.forward_compatibility.is_incompatible()); - } - - #[test] - fn test_boolean_schemas_follow_set_inclusion() { - let narrowed = check_schema_compatibility(&json!(true), &json!(false)); - assert!(narrowed.backward_compatibility.is_incompatible()); - assert!(narrowed.forward_compatibility.is_compatible()); - - let widened = check_schema_compatibility(&json!(false), &json!(true)); - assert!(widened.backward_compatibility.is_compatible()); - assert!(widened.forward_compatibility.is_incompatible()); - - // Object spellings of the boolean schemas have identical semantics. - let equivalent = check_schema_compatibility(&json!(true), &json!({})); - assert!(equivalent.full_compatibility.is_compatible()); - } - - #[test] - fn test_closed_model_optional_removal_is_forward_only() { - let old_schema = json!({ - "type": "object", - "additionalProperties": false, - "properties": { - "name": {"type": "string"}, - "email": {"type": "string"} - } - }); - let new_schema = json!({ - "type": "object", - "additionalProperties": false, - "properties": { - "name": {"type": "string"} - } - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_incompatible()); - assert!(result.forward_compatibility.is_compatible()); - assert!(result.full_compatibility.is_incompatible()); - } - - #[test] - fn test_unevaluated_properties_closes_2020_12_object() { - let old_schema = json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "unevaluatedProperties": false, - "properties": {"name": {"type": "string"}} - }); - let new_schema = json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "unevaluatedProperties": false, - "properties": { - "name": {"type": "string"}, - "email": {"type": "string"} - } - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_compatible()); - assert!(result.forward_compatibility.is_incompatible()); - } - - #[test] - fn test_unevaluated_properties_is_ignored_by_draft_07() { - let old_schema = json!({ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "unevaluatedProperties": false, - "properties": {"name": {"type": "string"}} - }); - let new_schema = json!({ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "unevaluatedProperties": false, - "properties": { - "name": {"type": "string"}, - "email": {"type": "string"} - } - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_incompatible()); - assert!(result.forward_compatibility.is_compatible()); - } - - #[test] - fn test_partial_content_model_change_is_conservative_and_names_path() { - let old_schema = json!({ - "type": "object", - "properties": { - "details": { - "type": "object", - "additionalProperties": {"type": "string"} - } - } - }); - let new_schema = json!({ - "type": "object", - "properties": { - "details": { - "type": "object", - "additionalProperties": {"type": "string"}, - "properties": {"count": {"type": "integer"}} - } - } - }); - - let (backward, backward_errors) = - GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); - let (forward, forward_errors) = - GtsEntityCastResult::check_forward_compatibility(&old_schema, &new_schema); - assert_eq!(backward, CompatibilityVerdict::Unknown); - assert_eq!(forward, CompatibilityVerdict::Unknown); - assert!( - backward_errors - .iter() - .any(|error| error.contains("$.details") && error.contains("partially open")) - ); - assert!( - forward_errors - .iter() - .any(|error| error.contains("$.details") && error.contains("partially open")) - ); - } - - #[test] - fn test_dialect_change_is_not_proven_compatible() { - let old_schema = json!({ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "string" - }); - let new_schema = json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "string" - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_unknown()); - assert!(result.forward_compatibility.is_unknown()); - } - - #[test] - fn test_all_of_inherited_closure_controls_property_addition() { - let old_schema = json!({ - "allOf": [ - { - "type": "object", - "additionalProperties": false, - "properties": {"name": {"type": "string"}} - } - ] - }); - let new_schema = json!({ - "allOf": [ - { - "type": "object", - "additionalProperties": false, - "properties": { - "name": {"type": "string"}, - "email": {"type": "string"} - } - } - ] - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_compatible()); - assert!(result.forward_compatibility.is_incompatible()); - } - - #[test] - fn test_all_of_intersects_duplicate_property_schemas() { - let schema = json!({ - "allOf": [ - { - "type": "object", - "properties": { - "value": {"type": "string", "minLength": 1} - } - }, - { - "type": "object", - "properties": { - "value": {"type": "string", "maxLength": 10} - } - } - ] - }); - - let flattened = GtsEntityCastResult::flatten_schema(&schema); + let json_value = serde_json::to_value(&result).expect("test"); + let json = json_value.as_object().expect("test"); assert_eq!( - flattened.pointer("/properties/value/minLength"), - Some(&json!(1)) + json.get("from").expect("test").as_str().expect("test"), + "gts.vendor.package.namespace.type.v1.0" ); assert_eq!( - flattened.pointer("/properties/value/maxLength"), - Some(&json!(10)) - ); - } - - #[test] - fn test_definitions_container_change_alone_is_fully_compatible() { - // `definitions` is reachable only through `$ref` and never contributes - // to Valid(S), so adding an entry nothing references changes nothing. - let old_schema = json!({ - "type": "object", - "additionalProperties": false, - "definitions": { - "Used": {"type": "object", "additionalProperties": false} - }, - "properties": {"u": {"type": "object", "additionalProperties": false}} - }); - let new_schema = json!({ - "type": "object", - "additionalProperties": false, - "definitions": { - "Used": {"type": "object", "additionalProperties": false}, - "NeverReferenced": {"type": "string"} - }, - "properties": {"u": {"type": "object", "additionalProperties": false}} - }); - - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.full_compatibility.is_compatible()); - } - - #[test] - fn test_resolved_nested_definition_addition_is_backward_only() { - // The shape `resolve_schema_refs` produces for a macro-generated - // document: the referenced level is inlined and closed, and the - // residual `definitions` container must not double-count the change. - let level = |extra: bool| { - let mut props = json!({"label": {"type": "string"}}); - if extra { - props["note"] = json!({"type": "string"}); - } - json!({ - "type": "object", - "additionalProperties": false, - "properties": props, - "required": ["label"] - }) - }; - let document = |extra: bool| { - json!({ - "type": "object", - "additionalProperties": false, - "definitions": {"Nested": level(extra)}, - "properties": {"nested": level(extra)}, - "required": ["nested"] - }) - }; - - let result = check_schema_compatibility(&document(false), &document(true)); - assert!(result.backward_compatibility.is_compatible()); - assert!(result.forward_compatibility.is_incompatible()); - } - - #[test] - fn test_differing_unresolved_ref_is_reported_as_unresolved() { - let old_schema = json!({ - "type": "object", - "properties": {"target": {"$ref": "gts://gts.x.core.a.b.v1~"}} - }); - let new_schema = json!({ - "type": "object", - "properties": {"target": {"$ref": "gts://gts.x.core.a.b.v2~"}} - }); - - let (is_backward, backward_errors) = - GtsEntityCastResult::check_backward_compatibility(&old_schema, &new_schema); - assert!(is_backward.is_unknown()); - assert!( - backward_errors - .iter() - .any(|error| error.contains("$.target") && error.contains("unresolved '$ref'")), - "{backward_errors:?}" - ); - } - - #[test] - fn test_identical_unresolved_ref_needs_no_resolution() { - let schema = json!({ - "type": "object", - "additionalProperties": false, - "properties": {"target": {"$ref": "gts://gts.x.core.a.b.v1~"}} - }); - - let result = check_schema_compatibility(&schema, &schema); - assert!(result.full_compatibility.is_compatible()); - } - - fn property_change(old_property: Value, new_property: Value) -> CompatibilityResult { - let document = |property: Value| { - json!({ - "type": "object", - "additionalProperties": false, - "properties": {"value": property}, - "required": ["value"] - }) - }; - check_schema_compatibility(&document(old_property), &document(new_property)) - } - - /// Bound keywords must be compared whenever present, never gated on `type`. - /// Gating on the old schema's `type` reported a real narrowing as fully - /// compatible whenever `type` was absent or written as an array. - #[test] - fn test_numeric_bounds_are_checked_without_a_type_keyword() { - let result = property_change(json!({"minimum": 0}), json!({"minimum": 5})); - assert!(result.backward_compatibility.is_incompatible()); - assert!(result.forward_compatibility.is_compatible()); - - let result = property_change( - json!({"type": ["integer"], "minimum": 0}), - json!({"type": ["integer"], "minimum": 5}), - ); - assert!(result.backward_compatibility.is_incompatible()); - assert!(result.forward_compatibility.is_compatible()); - } - - #[test] - fn test_exclusive_and_size_bounds_are_directional() { - for (min_key, max_key) in [ - ("exclusiveMinimum", "exclusiveMaximum"), - ("minProperties", "maxProperties"), - ] { - let relaxed = property_change(json!({max_key: 10}), json!({max_key: 100})); - assert!( - relaxed.backward_compatibility.is_compatible(), - "relaxing {max_key}" - ); - assert!( - relaxed.forward_compatibility.is_incompatible(), - "relaxing {max_key}" - ); - - let tightened = property_change(json!({min_key: 1}), json!({min_key: 5})); - assert!( - tightened.backward_compatibility.is_incompatible(), - "tightening {min_key}" - ); - assert!( - tightened.forward_compatibility.is_compatible(), - "tightening {min_key}" - ); - } - } - - #[test] - fn test_inclusive_and_exclusive_bounds_are_compared_together() { - let lower = property_change( - json!({"type": "number", "minimum": 0}), - json!({"type": "number", "exclusiveMinimum": 0}), - ); - assert!(lower.backward_compatibility.is_incompatible()); - assert!(lower.forward_compatibility.is_compatible()); - - let upper = property_change( - json!({"type": "number", "maximum": 10}), - json!({"type": "number", "exclusiveMaximum": 10}), - ); - assert!(upper.backward_compatibility.is_incompatible()); - assert!(upper.forward_compatibility.is_compatible()); - } - - /// `-0.0` and `0.0` denote the same JSON number, so respelling a bound - /// changes no accepted instance. - #[test] - fn test_signed_zero_bounds_are_equal() { - let lower = property_change( - json!({"type": "number", "minimum": -0.0}), - json!({"type": "number", "minimum": 0.0}), - ); - assert!(lower.full_compatibility.is_compatible()); - - let upper = property_change( - json!({"type": "number", "maximum": -0.0}), - json!({"type": "number", "maximum": 0.0}), - ); - assert!(upper.full_compatibility.is_compatible()); - } - - /// Draft-04 spells `exclusiveMinimum` as a boolean modifier, which a numeric - /// comparison would silently ignore. - #[test] - fn test_boolean_exclusive_minimum_is_not_silently_ignored() { - let result = property_change( - json!({"type": "integer", "minimum": 1, "exclusiveMinimum": false}), - json!({"type": "integer", "minimum": 1, "exclusiveMinimum": true}), - ); - assert!(result.backward_compatibility.is_unknown()); - assert!(result.forward_compatibility.is_unknown()); - } - - #[test] - fn test_type_is_compared_as_a_set() { - // Dropping `null` from an `Option` union narrows the accepted set. - let narrowed = property_change( - json!({"type": ["string", "null"]}), - json!({"type": "string"}), - ); - assert!(narrowed.backward_compatibility.is_incompatible()); - assert!(narrowed.forward_compatibility.is_compatible()); - - // Member order carries no meaning. - let reordered = property_change( - json!({"type": ["string", "null"]}), - json!({"type": ["null", "string"]}), - ); - assert!(reordered.full_compatibility.is_compatible()); - - // Widening a union accepts everything the old union did. - let widened = property_change( - json!({"type": "string"}), - json!({"type": ["string", "null"]}), - ); - assert!(widened.backward_compatibility.is_compatible()); - assert!(widened.forward_compatibility.is_incompatible()); - - // `integer` remains a subset of `number` inside a union. - let promoted = property_change( - json!({"type": ["integer", "null"]}), - json!({"type": ["number", "null"]}), - ); - assert!(promoted.backward_compatibility.is_compatible()); - assert!(promoted.forward_compatibility.is_incompatible()); - } - - #[test] - fn test_enum_and_const_imply_effective_types() { - let enum_narrowed = property_change(json!({"type": "string"}), json!({"enum": ["a"]})); - assert!(enum_narrowed.backward_compatibility.is_incompatible()); - assert!(enum_narrowed.forward_compatibility.is_compatible()); - - let const_narrowed = property_change(json!({"type": "string"}), json!({"const": "a"})); - assert!(const_narrowed.backward_compatibility.is_incompatible()); - assert!(const_narrowed.forward_compatibility.is_compatible()); - - // JSON Schema treats mathematically integral JSON numbers as integers, - // regardless of whether the source text contains a decimal point. - let integral_number = property_change(json!({"type": "integer"}), json!({"const": 1.0})); - assert!(integral_number.backward_compatibility.is_incompatible()); - assert!(integral_number.forward_compatibility.is_compatible()); - - // A tiny nonzero fraction is not an integer, however close to one it - // lands: `{"const": 1e-20}` is the sole value the new schema accepts and - // `{"type": "integer"}` rejects it. - let tiny_fraction = property_change(json!({"type": "integer"}), json!({"const": 1e-20})); - assert!(tiny_fraction.backward_compatibility.is_incompatible()); - assert!(tiny_fraction.forward_compatibility.is_incompatible()); - } - - #[test] - fn test_narrowing_keyword_presence_is_directional() { - for keyword in ["pattern", "format", "multipleOf"] { - let value = if keyword == "multipleOf" { - json!(5) - } else if keyword == "format" { - json!("date-time") - } else { - json!("^a+$") - }; - - let added = property_change(json!({}), json!({keyword: value.clone()})); - assert!( - added.backward_compatibility.is_incompatible(), - "adding {keyword}" - ); - assert!( - added.forward_compatibility.is_compatible(), - "adding {keyword}" - ); - - let removed = property_change(json!({keyword: value}), json!({})); - assert!( - removed.backward_compatibility.is_compatible(), - "removing {keyword}" - ); - assert!( - removed.forward_compatibility.is_incompatible(), - "removing {keyword}" - ); - } - } - - /// Two different regexes cannot be ordered by inclusion, so neither - /// direction is provable - and the diagnostic must say so rather than imply - /// the change is breaking. - #[test] - fn test_changed_pattern_is_reported_as_unprovable() { - let (_, errors) = GtsEntityCastResult::check_backward_compatibility( - &json!({"type": "string", "pattern": "^a+$"}), - &json!({"type": "string", "pattern": "^[ab]+$"}), - ); - assert!( - errors - .iter() - .any(|error| error.contains("cannot be proven")), - "{errors:?}" - ); - } - - #[test] - fn test_unique_items_defaults_to_false() { - let enabled = property_change( - json!({"type": "array"}), - json!({"type": "array", "uniqueItems": true}), - ); - assert!(enabled.backward_compatibility.is_incompatible()); - assert!(enabled.forward_compatibility.is_compatible()); - - let disabled = property_change( - json!({"type": "array", "uniqueItems": true}), - json!({"type": "array", "uniqueItems": false}), + json.get("to").expect("test").as_str().expect("test"), + "gts.vendor.package.namespace.type.v2.0" ); - assert!(disabled.backward_compatibility.is_compatible()); - assert!(disabled.forward_compatibility.is_incompatible()); - - // Spelling out the default changes no accepted instance. - let no_op = property_change( - json!({"type": "array", "uniqueItems": false}), - json!({"type": "array"}), + assert_eq!( + json.get("direction").expect("test").as_str().expect("test"), + "up" ); - assert!(no_op.full_compatibility.is_compatible()); - } - - /// An omitted `$schema` means "the dialect the implementation applies", so - /// starting to declare a dialect that was already in effect is not a change. - #[test] - fn test_declaring_a_previously_omitted_dialect_is_compatible() { - let result = check_schema_compatibility( - &json!({"type": "object", "additionalProperties": false}), - &json!({ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "additionalProperties": false - }), + assert_eq!( + json.get("specification_version").and_then(Value::as_str), + Some(crate::GTS_SPECIFICATION_VERSION) ); - assert!(result.full_compatibility.is_compatible()); - } - - /// The `unevaluatedProperties` decision must follow the dialect that is in - /// effect, including when only one definition spells it out. - #[test] - fn test_omitted_dialect_inherits_unevaluated_support() { - let result = check_schema_compatibility( - &json!({ - "type": "object", - "unevaluatedProperties": false, - "properties": {"name": {"type": "string"}} - }), - &json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "unevaluatedProperties": false, - "properties": { - "name": {"type": "string"}, - "email": {"type": "string"} - } - }), + assert_eq!( + json.get("implementation_version").and_then(Value::as_str), + Some(crate::GTS_IMPLEMENTATION_VERSION) ); - assert!(result.backward_compatibility.is_compatible()); - assert!(result.forward_compatibility.is_incompatible()); } - /// With no `$schema` anywhere the dialect is the one this implementation - /// applies when validating instances, which is Draft 2020-12 - so - /// `unevaluatedProperties` closes the level here too. #[test] - fn test_undeclared_dialect_evaluates_unevaluated_properties() { - let old_schema = json!({ - "type": "object", - "unevaluatedProperties": false, - "properties": {"name": {"type": "string"}} + fn test_cast_adds_defaults_and_updates_gtsid_const() { + // Instance is missing optional 'region' and has an outdated GTS id const in 'typeRef' + let from_instance_id = "gts.vendor.pkg.ns.type.v1.0"; + let from_instance = json!({ + "name": "alice", + "typeRef": "gts.vendor.pkg.ns.subtype.v1.0~" }); - let new_schema = json!({ + + // From schema (minimal) + let from_schema = json!({ "type": "object", - "unevaluatedProperties": false, "properties": { "name": {"type": "string"}, - "email": {"type": "string"} + "typeRef": {"type": "string"} } }); - let result = check_schema_compatibility(&old_schema, &new_schema); - assert!(result.backward_compatibility.is_compatible()); - assert!(result.forward_compatibility.is_incompatible()); - - // The instance validator this crate builds must agree with the verdict. - let validator = jsonschema::validator_for(&old_schema).expect("compile schema"); - assert!(!validator.is_valid(&json!({"name": "n", "email": "e"}))); - - // The same dialect decides the reported content model of a level. - let levels = GtsEntityCastResult::classify_object_levels(&old_schema); - assert_eq!( - levels.first().map(|level| level.content_model), - Some(ContentModel::Closed) - ); - } - - #[test] - fn test_boolean_equivalent_property_schemas_classify_semantically() { - let additional_open = json!({ - "type": "object", - "additionalProperties": {} - }); - let additional_closed = json!({ - "type": "object", - "additionalProperties": {"not": {}} - }); - let property_names_open = json!({ - "type": "object", - "propertyNames": {} - }); - let property_names_closed = json!({ - "type": "object", - "propertyNames": {"not": {}} - }); - let closed_fallback_with_name_constraint = json!({ - "type": "object", - "additionalProperties": {"not": {}}, - "propertyNames": {"type": "string"} - }); - let closed_names_with_pattern = json!({ - "type": "object", - "propertyNames": {"not": {}}, - "patternProperties": {".*": {}} - }); - let open_pattern = json!({ - "type": "object", - "patternProperties": {"^x-": {}} - }); - let closed_pattern = json!({ - "type": "object", - "additionalProperties": {"not": {}}, - "patternProperties": {"^x-": {"not": {}}} - }); - let explicit_open_additional_precedes_unevaluated = json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "additionalProperties": {}, - "unevaluatedProperties": {"not": {}} - }); - - for (schema, expected) in [ - (additional_open, ContentModel::Open), - (additional_closed, ContentModel::Closed), - (property_names_open, ContentModel::Open), - (property_names_closed, ContentModel::Closed), - (closed_fallback_with_name_constraint, ContentModel::Closed), - (closed_names_with_pattern, ContentModel::Closed), - (open_pattern, ContentModel::Open), - (closed_pattern, ContentModel::Closed), - ( - explicit_open_additional_precedes_unevaluated, - ContentModel::Open, - ), - ] { - let levels = GtsEntityCastResult::classify_object_levels(&schema); - assert_eq!( - levels.first().map(|level| level.content_model), - Some(expected) - ); - } - } - - #[test] - fn test_boolean_equivalent_additional_properties_drive_compatibility() { - let added_property = |additional_properties: Value| { - check_schema_compatibility( - &json!({ - "type": "object", - "additionalProperties": additional_properties - }), - &json!({ - "type": "object", - "additionalProperties": additional_properties, - "properties": {"name": {"type": "string"}} - }), - ) - }; - - let open = added_property(json!({})); - assert!(open.backward_compatibility.is_incompatible()); - assert!(open.forward_compatibility.is_compatible()); - - let closed = added_property(json!({"not": {}})); - assert!(closed.backward_compatibility.is_compatible()); - assert!(closed.forward_compatibility.is_incompatible()); - } - - /// §4.4 requires the content model to be read per object level from the - /// resolved effective schema, and §4.4.1's closed-envelope shape puts the - /// level that decides evolvability inside an extension container rather - /// than at the document root. - #[test] - fn test_classify_object_levels_reports_every_level() { - let schema = json!({ - "$schema": "http://json-schema.org/draft/2020-12/schema", + // To schema has default for optional 'region' and const for 'typeRef' to a newer ID + let to_type_id = "gts.vendor.pkg.ns.type.v1.1"; + let to_schema = json!({ "type": "object", - "additionalProperties": false, "properties": { - "envelope_field": {"type": "string"}, - "payload": { - "type": "object", - "properties": { - "own": { - "type": "object", - "additionalProperties": false, - "properties": {"a": {"type": "string"}} - } - } - }, - "labels": { - "type": "object", - "additionalProperties": {"type": "string"} - }, - "closed_by_unevaluated": { - "type": "object", - "unevaluatedProperties": false, - "properties": {"b": {"type": "string"}} - }, - "rows": { - "type": "array", - "items": {"type": "object", "properties": {"c": {"type": "string"}}} - } + "name": {"type": "string"}, + "region": {"type": "string", "default": "us-east"}, + "typeRef": {"type": "string", "const": "gts.vendor.pkg.ns.subtype.v1.1~"} } }); - let levels: HashMap = - GtsEntityCastResult::classify_object_levels(&schema) - .into_iter() - .map(|level| (level.path, level.content_model)) - .collect(); + let cast = GtsEntityCastResult::cast( + from_instance_id, + to_type_id, + &from_instance, + &from_schema, + &to_schema, + None, + ) + .expect("cast ok"); + + // Defaults should be added + assert!(cast.added_properties.iter().any(|p| p == "region")); - assert_eq!(levels.get("$"), Some(&ContentModel::Closed)); - assert_eq!(levels.get("$.payload"), Some(&ContentModel::Open)); - assert_eq!(levels.get("$.payload.own"), Some(&ContentModel::Closed)); - assert_eq!(levels.get("$.labels"), Some(&ContentModel::Partial)); + let casted = cast.casted_entity.expect("casted entity"); + assert_eq!( + casted.get("region").and_then(|v| v.as_str()), + Some("us-east") + ); + // typeRef should be updated to the const GTS ID assert_eq!( - levels.get("$.closed_by_unevaluated"), - Some(&ContentModel::Closed) + casted.get("typeRef").and_then(|v| v.as_str()), + Some("gts.vendor.pkg.ns.subtype.v1.1~") ); - assert_eq!(levels.get("$.rows[]"), Some(&ContentModel::Open)); - // A scalar property is not an object level. - assert!(!levels.contains_key("$.envelope_field")); - - // Evolvability is exactly closure. - assert!(ContentModel::Closed.is_evolvable_in_place()); - assert!(!ContentModel::Open.is_evolvable_in_place()); - assert!(!ContentModel::Partial.is_evolvable_in_place()); } - /// A level closed only through `allOf` composition must classify as closed, - /// not as the open level it looks like in isolation. #[test] - fn test_classify_object_levels_uses_the_effective_schema() { - let schema = json!({ - "allOf": [ - {"type": "object", "additionalProperties": false}, - {"type": "object", "properties": {"a": {"type": "string"}}} - ] + fn test_cast_removes_additional_properties_when_disallowed() { + let from_instance_id = "gts.vendor.pkg.ns.type.v1.0"; + let from_instance = json!({ + "name": "alice", + "extra": 123 }); - let levels = GtsEntityCastResult::classify_object_levels(&schema); - assert_eq!( - levels.first().map(|level| level.content_model), - Some(ContentModel::Closed) - ); - } - - #[test] - fn test_diagnostics_carry_the_schema_location_and_kind() { - let old_schema = json!({ + let from_schema = json!({ "type": "object", - "properties": { - "payload": {"type": "object", "properties": {"a": {"type": "string"}}} - } + "properties": {"name": {"type": "string"}} }); - let new_schema = json!({ + + let to_type_id = "gts.vendor.pkg.ns.type.v1.1"; + let to_schema = json!({ "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": {"a": {"type": "string"}, "b": {"type": "string"}} - } - } + "additionalProperties": false, + "properties": {"name": {"type": "string"}} }); - let (compatible, diagnostics) = - GtsEntityCastResult::check_backward_diagnostics(&old_schema, &new_schema); - assert!(compatible.is_incompatible()); - let finding = diagnostics - .iter() - .find(|diagnostic| diagnostic.path == "$.payload") - .expect("the offending level must be named, not the document root"); - assert_eq!(finding.finding, CompatibilityFinding::PropertyAdded); - assert_eq!( - finding.to_string(), - "Schema at '$.payload' adds property 'b' in a open model" - ); - } - - /// A caller that fails closed treats both alike, but an owner needs to tell - /// "we cannot decide this" from "this is known to break". - #[test] - fn test_undecidable_changes_are_reported_as_not_provable() { - let (_, diagnostics) = GtsEntityCastResult::check_backward_diagnostics( - &json!({"type": "string", "pattern": "^a+$"}), - &json!({"type": "string", "pattern": "^[ab]+$"}), - ); - assert!( - diagnostics - .iter() - .all(|diagnostic| diagnostic.finding == CompatibilityFinding::NotProvable), - "{diagnostics:?}" - ); - } - - /// An unprovable `allOf` intersection is the checker's own bookkeeping and - /// must never surface as a keyword: `flatten_schema` is public and its - /// output feeds instance casting and `additionalProperties` comparisons, - /// where a synthetic keyword reads as a real constraint difference. - #[test] - fn test_unprovable_intersection_leaves_no_synthetic_keyword() { - let flattened = GtsEntityCastResult::flatten_schema(&json!({ - "allOf": [ - {"type": "object", "additionalProperties": {"type": "string"}}, - {"type": "object", "additionalProperties": {"type": "number"}} - ] - })); - - let keys: Vec<&String> = flattened - .as_object() - .expect("flattening object branches yields an object") - .keys() - .collect(); - assert!( - keys.iter().all(|key| !key.starts_with("x-gts-internal")), - "{keys:?}" - ); - } - - /// The undecidable branch must stay local: reporting it must not swallow a - /// sibling that is decidably broken, or "unknown" would mask "incompatible". - #[test] - fn test_unprovable_property_does_not_mask_sibling_incompatibility() { - let schema_with = |sibling: Value| { - json!({ - "type": "object", - "allOf": [ - {"properties": {"undecidable": {"type": "string"}}}, - {"properties": {"undecidable": {"type": "integer"}}} - ], - "properties": {"sibling": sibling} - }) - }; - - let (verdict, diagnostics) = GtsEntityCastResult::check_backward_diagnostics( - &schema_with(json!({"type": "string"})), - &schema_with(json!({"type": "number"})), - ); + let cast = GtsEntityCastResult::cast( + from_instance_id, + to_type_id, + &from_instance, + &from_schema, + &to_schema, + None, + ) + .expect("cast ok"); - assert!(verdict.is_incompatible(), "{diagnostics:?}"); - assert!( - diagnostics - .iter() - .any(|diagnostic| diagnostic.path == "$.undecidable" - && diagnostic.finding == CompatibilityFinding::NotProvable), - "{diagnostics:?}" - ); - assert!( - diagnostics - .iter() - .any(|diagnostic| diagnostic.path == "$.sibling"), - "{diagnostics:?}" - ); + // 'extra' should be removed + let casted = cast.casted_entity.expect("casted entity"); + assert!(casted.get("extra").is_none()); + assert!(cast.removed_properties.iter().any(|p| p == "extra")); } } diff --git a/gts/src/schema_compat.rs b/gts/src/schema_compat.rs deleted file mode 100644 index abec4d3..0000000 --- a/gts/src/schema_compat.rs +++ /dev/null @@ -1,1492 +0,0 @@ -//! OP#12 – Schema-vs-schema compatibility validation. -//! -//! Given a chained GTS schema ID like `gts.A~B~C~`, this module validates that -//! each derived schema is compatible with its base: -//! -//! - B (derived from A) must be compatible with A -//! - C (derived from A~B) must be compatible with A~B -//! -//! "Compatible" means every valid instance of the derived schema is also a valid -//! instance of the base schema. Concretely the derived schema may only -//! **tighten** (never loosen) constraints on properties inherited from the base. - -use crate::schema_semantics::boolean_schema_value; -use serde_json::Value; -use std::collections::{HashMap, HashSet}; - -const MAX_RECURSION_DEPTH: usize = 64; - -/// Represents the effective (flattened) schema used for compatibility comparison. -pub(crate) struct EffectiveSchema { - pub properties: HashMap, - pub required: HashSet, - pub additional_properties: Option, -} - -/// Folds an `additionalProperties` value into an accumulator using a -/// closedness-preserving lattice: schemas equivalent to `false` (closed) are -/// strongest, nontrivial constraining schemas are in the middle, and schemas -/// equivalent to `true` (open) are weakest. -/// -/// This mirrors `allOf` composition, where the schema stays closed if **any** -/// branch gives `additionalProperties` a false-equivalent schema, so a -/// permissive overlay can never loosen a closed base. Used both when flattening -/// `allOf` during ref resolution and when extracting the effective schema for -/// compatibility checks. -pub(crate) fn merge_additional_properties_constraint( - current: &mut Option, - candidate: &Value, -) { - let candidate_boolean = boolean_schema_value(candidate); - if current.as_ref().and_then(boolean_schema_value) == Some(false) { - return; - } - if candidate_boolean == Some(false) { - *current = Some(candidate.clone()); - } else if candidate_boolean == Some(true) && current.is_some() { - // Intersecting an existing constraint with `true` changes nothing. - } else { - *current = Some(candidate.clone()); - } -} - -/// Extracts the effective schema properties, required fields, and -/// `additionalProperties` from a fully-resolved JSON Schema value. -/// -/// If the schema contains an `allOf` that was not already merged by the -/// resolver, the items are merged here (last-wins for properties). -pub(crate) fn extract_effective_schema(schema: &Value) -> EffectiveSchema { - let mut eff = EffectiveSchema { - properties: HashMap::new(), - required: HashSet::new(), - additional_properties: None, - }; - - if let Value::Object(map) = schema { - // Direct properties - if let Some(Value::Object(props)) = map.get("properties") { - for (k, v) in props { - eff.properties.insert(k.clone(), v.clone()); - } - } - - // Required - if let Some(Value::Array(req)) = map.get("required") { - for v in req { - if let Value::String(s) = v { - eff.required.insert(s.clone()); - } - } - } - - // additionalProperties - if let Some(ap) = map.get("additionalProperties") { - merge_additional_properties_constraint(&mut eff.additional_properties, ap); - } - - // allOf – merge from all items (for schemas that weren't fully flattened) - if let Some(Value::Array(all_of)) = map.get("allOf") { - for item in all_of { - let item_eff = extract_effective_schema(item); - eff.properties.extend(item_eff.properties); - eff.required.extend(item_eff.required); - if let Some(ap) = item_eff.additional_properties { - merge_additional_properties_constraint(&mut eff.additional_properties, &ap); - } - } - } - } - - eff -} - -/// Validates that a derived JSON Schema value is compatible with its base. -/// -/// This is the high-level compatibility entry point. It combines the flattened -/// effective-schema comparison with raw-branch checks that need `allOf` -/// ownership information. -pub(crate) fn validate_schema_compatibility( - base_schema: &Value, - derived_schema: &Value, - base_id: &str, - derived_id: &str, -) -> Vec { - let base = extract_effective_schema(base_schema); - let derived = extract_effective_schema(derived_schema); - - let mut errors = validate_effective_schema_compatibility(&base, &derived, base_id, derived_id); - errors.extend(validate_closed_descendant_branches( - base_schema, - derived_schema, - base_id, - derived_id, - )); - errors -} - -/// Validates that a derived effective schema is compatible with its base. -/// -/// Rules checked: -/// - Derived cannot add properties if the base's `additionalProperties` rejects them -/// - Derived cannot loosen constraints on existing properties -/// - Derived cannot disable (`false`) properties that base defines -/// - Derived enum must be a subset of base enum -/// - Derived cannot change property types -/// - Derived cannot redefine `const` to a different value -/// - Derived cannot change `pattern` -/// - Derived cannot remove fields from `required` -/// - Derived cannot change array `items` type -/// -/// Returns an empty `Vec` when the effective schemas are compatible, otherwise -/// a list of human-readable error descriptions. -pub(crate) fn validate_effective_schema_compatibility( - base: &EffectiveSchema, - derived: &EffectiveSchema, - base_id: &str, - derived_id: &str, -) -> Vec { - let mut errors = Vec::new(); - let base_disallows_additional = base - .additional_properties - .as_ref() - .and_then(boolean_schema_value) - == Some(false); - - for (prop_name, derived_prop) in &derived.properties { - if let Some(base_prop) = base.properties.get(prop_name) { - // Property exists in both – check for disabling - if *derived_prop == Value::Bool(false) { - errors.push(format!( - "property '{prop_name}': derived schema '{derived_id}' disables property defined in base '{base_id}'" - )); - continue; - } - - // Compare constraints - compare_property_constraints(base_prop, derived_prop, prop_name, &mut errors); - } - // New property in derived – check additionalProperties - else if base_disallows_additional { - errors.push(format!( - "property '{prop_name}': derived schema '{derived_id}' adds new property but base '{base_id}' has a closed additionalProperties constraint" - )); - } else if let Some(base_additional) = &base.additional_properties - && boolean_schema_value(base_additional) != Some(true) - { - compare_property_constraints(base_additional, derived_prop, prop_name, &mut errors); - } - } - - // Check if a direct derived schema loosens additionalProperties constraint. - // - // Derived "loosens" only when it *explicitly* declares a permissive - // `additionalProperties` without a closed constraint surviving through - // allOf composition. Omitting the keyword, or composing a permissive - // overlay with a closed base, is **not** loosening: across JSON Schema - // dialects, the base's closed `additionalProperties` constraint still - // applies to the same instance via `$ref`/`allOf` composition. - // - // The per-property loop above already catches the only structurally - // dangerous case (derived adds a new top-level property that base - // forbids), so collapsing the root-level "absent ≠ false" check - // to "explicit permissive declarations" is safe. - if base_disallows_additional { - let derived_explicitly_allows = match &derived.additional_properties { - Some(value) => boolean_schema_value(value) != Some(false), - None => false, - }; - if derived_explicitly_allows { - errors.push(format!( - "derived schema '{derived_id}' loosens additionalProperties from a closed constraint in base '{base_id}'" - )); - } - } - - // Check that derived doesn't remove fields from base's required set - check_required_removal(base, derived, base_id, derived_id, &mut errors); - - errors -} - -/// Validates branch-scoped closed `additionalProperties` in a descendant schema. -/// -/// Flattened compatibility catches closed ancestors that reject new descendant -/// properties, but it cannot see the inverse `allOf` hazard: a descendant -/// branch can close `additionalProperties` without restating an ancestor property -/// at the same object path, making that ancestor property unusable in the -/// composed schema. This walks the raw/resolved descendant branches so that -/// branch ownership is preserved. -pub(crate) fn validate_closed_descendant_branches( - ancestor_schema: &Value, - descendant_schema: &Value, - ancestor_label: &str, - descendant_label: &str, -) -> Vec { - let mut errors = Vec::new(); - collect_closed_descendant_branch_errors( - ancestor_schema, - descendant_schema, - "", - 0, - ancestor_label, - descendant_label, - &mut errors, - ); - errors -} - -fn collect_closed_descendant_branch_errors( - ancestor_schema: &Value, - descendant_schema: &Value, - path: &str, - depth: usize, - ancestor_label: &str, - descendant_label: &str, - errors: &mut Vec, -) { - if depth >= MAX_RECURSION_DEPTH { - errors.push(format!( - "schema compatibility check exceeded maximum nesting depth of \ - {MAX_RECURSION_DEPTH} at '{path}' between ancestor '{ancestor_label}' \ - and descendant '{descendant_label}'" - )); - return; - } - - let ancestor = extract_effective_schema(ancestor_schema); - let Some(descendant_obj) = descendant_schema.as_object() else { - return; - }; - let descendant_props = descendant_obj.get("properties").and_then(Value::as_object); - - if descendant_obj - .get("additionalProperties") - .and_then(boolean_schema_value) - == Some(false) - { - let mut orphaned: Vec<&str> = ancestor - .properties - .keys() - .filter(|name| !descendant_props.is_some_and(|props| props.contains_key(name.as_str()))) - .map(String::as_str) - .collect(); - orphaned.sort_unstable(); - for name in orphaned { - let property_path = join_schema_path(path, name); - errors.push(format!( - "property '{property_path}': descendant schema '{descendant_label}' sets \ - a closed additionalProperties constraint but does not restate property defined in \ - ancestor '{ancestor_label}', making it unusable under allOf composition" - )); - } - } - - if let Some(props) = descendant_props { - let mut common: Vec<&str> = props - .keys() - .filter(|name| ancestor.properties.contains_key(name.as_str())) - .map(String::as_str) - .collect(); - common.sort_unstable(); - - for name in common { - let Some(ancestor_prop) = ancestor.properties.get(name) else { - continue; - }; - let Some(descendant_prop) = props.get(name) else { - continue; - }; - - let next_path = join_schema_path(path, name); - collect_closed_descendant_branch_errors( - ancestor_prop, - descendant_prop, - &next_path, - depth + 1, - ancestor_label, - descendant_label, - errors, - ); - } - } - - if let Some(Value::Array(all_of)) = descendant_obj.get("allOf") { - for item in all_of { - collect_closed_descendant_branch_errors( - ancestor_schema, - item, - path, - depth + 1, - ancestor_label, - descendant_label, - errors, - ); - } - } -} - -fn join_schema_path(prefix: &str, name: &str) -> String { - if prefix.is_empty() { - name.to_owned() - } else { - format!("{prefix}.{name}") - } -} - -// --------------------------------------------------------------------------- -// Constraint comparison helpers -// --------------------------------------------------------------------------- - -/// Compares constraints between a base property schema and a derived property -/// schema. Pushes error strings into `errors` whenever the derived schema -/// loosens a constraint. -fn compare_property_constraints( - base_prop: &Value, - derived_prop: &Value, - prop_name: &str, - errors: &mut Vec, -) { - match ( - boolean_schema_value(base_prop), - boolean_schema_value(derived_prop), - ) { - (_, Some(false)) | (Some(true), _) => return, - (Some(false), _) => { - errors.push(format!( - "property '{prop_name}': derived schema accepts values but base schema rejects all values" - )); - return; - } - (_, Some(true)) => { - errors.push(format!( - "property '{prop_name}': derived schema accepts every value, loosening base constraints" - )); - return; - } - (None, None) => {} - } - - // If base is not an object schema, it places no constraints to loosen. - let Some(base_map) = base_prop.as_object() else { - return; - }; - - // If derived is a boolean `true` schema (or any non-object), it accepts - // everything and therefore loosens any constraint the base defines. - let Some(derived_map) = derived_prop.as_object() else { - errors.push(format!( - "property '{prop_name}': derived replaces schema object with a non-object value, \ - loosening base constraints" - )); - return; - }; - - // Type compatibility: if base specifies a type, derived must use the same type - check_type_compatibility(base_map, derived_map, prop_name, errors); - - // `const` and `enum` are "value-enumerating" constraints that fully specify the - // set of allowed values. When the derived schema introduces one of these, omitting - // bounds-type keywords (maxLength, minimum, ...) or pattern is NOT loosening because - // the allowed values are already a finite, explicit set. However, the enumerated - // values themselves must still satisfy the base bounds. - let derived_values = collect_derived_enumerated_values(derived_map); - let derived_enumerates_values = derived_values.is_some(); - - // const: if base has const, derived must have same const (not omit it). - // Exception: derived may replace const with enum that includes the const value - // (still tighter or equal). - check_const_compatibility(base_map, derived_map, prop_name, errors); - - if derived_enumerates_values { - // Derived enumerates values: skip keyword-level bounds/pattern checks but - // verify every enumerated value satisfies the base constraints. - check_enumerated_values_against_base( - base_map, - derived_values.as_deref().unwrap_or(&[]), - prop_name, - errors, - ); - } else { - // No enumeration: require keyword-level constraints to be preserved/tightened. - check_pattern_compatibility(base_map, derived_map, prop_name, errors); - - check_upper_bound(base_map, derived_map, "maxLength", prop_name, errors); - check_upper_bound(base_map, derived_map, "maximum", prop_name, errors); - check_upper_bound(base_map, derived_map, "maxItems", prop_name, errors); - - check_lower_bound(base_map, derived_map, "minLength", prop_name, errors); - check_lower_bound(base_map, derived_map, "minimum", prop_name, errors); - check_lower_bound(base_map, derived_map, "minItems", prop_name, errors); - } - - // enum: if base has enum, derived must have enum subset (or const within base enum) - check_enum_compatibility(base_map, derived_map, prop_name, errors); - - // Array items sub-schema comparison - check_items_compatibility(base_map, derived_map, prop_name, errors); - - // Recurse for nested object properties - if base_map.get("type") == Some(&Value::String("object".to_owned())) - && derived_map.get("type") == Some(&Value::String("object".to_owned())) - && base_map.contains_key("properties") - { - let base_nested = extract_effective_schema(base_prop); - let derived_nested = extract_effective_schema(derived_prop); - - let nested_errors = validate_effective_schema_compatibility( - &base_nested, - &derived_nested, - "base", - "derived", - ); - for err in nested_errors { - errors.push(format!("in nested object '{prop_name}': {err}")); - } - } -} - -/// Helper: check that derived does not change the `type` of a property. -/// -/// Allowed: same type, or base has no type (unconstrained). -/// Disallowed: changing type (e.g. "string" → "integer", "integer" → "number"). -fn check_type_compatibility( - base_map: &serde_json::Map, - derived_map: &serde_json::Map, - prop_name: &str, - errors: &mut Vec, -) { - if let (Some(base_type), Some(derived_type)) = (base_map.get("type"), derived_map.get("type")) - && base_type != derived_type - { - errors.push(format!( - "property '{prop_name}': derived changes type from {base_type} to {derived_type}" - )); - } -} - -/// Helper: check `const` compatibility. -/// -/// - Base has no `const`, derived adds one → OK (tightening) -/// - Base has `const`, derived has same `const` → OK (idempotent) -/// - Base has `const`, derived has different `const` → ERROR -/// - Base has `const`, derived omits it → ERROR (loosening) -fn check_const_compatibility( - base_map: &serde_json::Map, - derived_map: &serde_json::Map, - prop_name: &str, - errors: &mut Vec, -) { - if let Some(base_const) = base_map.get("const") { - match derived_map.get("const") { - Some(derived_const) if base_const != derived_const => { - errors.push(format!( - "property '{prop_name}': derived redefines const from {base_const} to {derived_const}" - )); - } - None => { - errors.push(format!( - "property '{prop_name}': derived omits const constraint ({base_const}) defined in base" - )); - } - _ => {} // Same const or derived adds tightening - } - } -} - -/// Helper: check `pattern` compatibility. -/// -/// If base defines a `pattern` and derived defines a different `pattern`, -/// the schemas are considered incompatible (we cannot determine subset -/// relationships between arbitrary regexes). -/// If base defines a `pattern` and derived omits it, that's also incompatible (loosening). -fn check_pattern_compatibility( - base_map: &serde_json::Map, - derived_map: &serde_json::Map, - prop_name: &str, - errors: &mut Vec, -) { - if let Some(base_pat) = base_map.get("pattern") { - match derived_map.get("pattern") { - Some(derived_pat) if base_pat != derived_pat => { - errors.push(format!( - "property '{prop_name}': derived changes pattern from {base_pat} to {derived_pat}" - )); - } - None => { - errors.push(format!( - "property '{prop_name}': derived omits pattern constraint ({base_pat}) defined in base" - )); - } - _ => {} // Same pattern - } - } -} - -/// Helper: check `enum` compatibility. -/// -/// If base defines an `enum`, derived must also define an `enum` that is a subset, -/// or define a `const` whose value is in the base enum (tightening from set to single value). -/// If base has `enum` and derived omits both `enum` and `const`, that's incompatible (loosening). -fn check_enum_compatibility( - base_map: &serde_json::Map, - derived_map: &serde_json::Map, - prop_name: &str, - errors: &mut Vec, -) { - if let Some(Value::Array(base_enum)) = base_map.get("enum") { - // Check if derived has enum (subset check) - if let Some(Value::Array(derived_enum)) = derived_map.get("enum") { - for val in derived_enum { - if !base_enum.contains(val) { - errors.push(format!( - "property '{prop_name}': derived enum contains value {val} not in base enum" - )); - } - } - return; - } - // Check if derived has const (must be in base enum — tightening from set to single) - if let Some(derived_const) = derived_map.get("const") { - if !base_enum.contains(derived_const) { - errors.push(format!( - "property '{prop_name}': derived const {derived_const} is not in base enum" - )); - } - return; - } - // Neither enum nor const — loosening - errors.push(format!( - "property '{prop_name}': derived omits enum constraint defined in base" - )); - } -} - -/// Helper: check array `items` sub-schema compatibility. -/// -/// If both base and derived have `items`, recursively compare them using the -/// same property-constraint logic (type changes, const, bounds, etc.). -/// If base has `items` and derived omits it, that's incompatible (loosening). -fn check_items_compatibility( - base_map: &serde_json::Map, - derived_map: &serde_json::Map, - prop_name: &str, - errors: &mut Vec, -) { - if let Some(base_items) = base_map.get("items") { - match derived_map.get("items") { - Some(derived_items) => { - // Reuse compare_property_constraints for the items sub-schema - let items_name = format!("{prop_name}.items"); - compare_property_constraints(base_items, derived_items, &items_name, errors); - } - None => { - errors.push(format!( - "property '{prop_name}': derived omits items constraint defined in base" - )); - } - } - } -} - -/// Helper: check that derived doesn't remove fields from base `required`. -/// -/// If the derived schema explicitly specifies a `required` array, every field -/// that is in the base `required` set must still be present. Derived may add -/// new required fields but never remove existing ones. -fn check_required_removal( - base: &EffectiveSchema, - derived: &EffectiveSchema, - base_id: &str, - derived_id: &str, - errors: &mut Vec, -) { - // Only check if derived explicitly declares any required fields - // (if derived doesn't declare required at all, allOf semantics inherit base's required) - if derived.required.is_empty() { - return; - } - for base_req in &base.required { - if !derived.required.contains(base_req) { - errors.push(format!( - "derived schema '{derived_id}' removes required field '{base_req}' defined in base '{base_id}'" - )); - } - } -} - -/// Helper: derived upper-bound constraint must be **<=** base. -/// If base has an upper bound and derived omits it, that's incompatible (loosening). -fn check_upper_bound( - base_map: &serde_json::Map, - derived_map: &serde_json::Map, - keyword: &str, - prop_name: &str, - errors: &mut Vec, -) { - if let Some(base_val) = base_map.get(keyword) { - match derived_map.get(keyword) { - Some(derived_val) => { - if let (Some(b), Some(d)) = (base_val.as_f64(), derived_val.as_f64()) - && d > b - { - errors.push(format!( - "property '{prop_name}': derived {keyword} ({d}) exceeds base {keyword} ({b})" - )); - } - } - None => { - errors.push(format!( - "property '{prop_name}': derived omits {keyword} constraint ({base_val}) defined in base" - )); - } - } - } -} - -/// Helper: derived lower-bound constraint must be **>=** base. -/// If base has a lower bound and derived omits it, that's incompatible (loosening). -fn check_lower_bound( - base_map: &serde_json::Map, - derived_map: &serde_json::Map, - keyword: &str, - prop_name: &str, - errors: &mut Vec, -) { - if let Some(base_val) = base_map.get(keyword) { - match derived_map.get(keyword) { - Some(derived_val) => { - if let (Some(b), Some(d)) = (base_val.as_f64(), derived_val.as_f64()) - && d < b - { - errors.push(format!( - "property '{prop_name}': derived {keyword} ({d}) is less than base {keyword} ({b})" - )); - } - } - None => { - errors.push(format!( - "property '{prop_name}': derived omits {keyword} constraint ({base_val}) defined in base" - )); - } - } - } -} - -/// Collect the concrete values that a derived property constrains to via `const` or `enum`. -/// Returns `None` if the derived schema uses neither keyword. -fn collect_derived_enumerated_values( - derived_map: &serde_json::Map, -) -> Option> { - if let Some(c) = derived_map.get("const") { - return Some(vec![c.clone()]); - } - if let Some(Value::Array(arr)) = derived_map.get("enum") { - return Some(arr.clone()); - } - None -} - -/// When the derived schema enumerates values (via `const` or `enum`), verify that -/// every enumerated value satisfies the base bounds and pattern constraints. -/// This replaces the keyword-level checks: instead of requiring the keywords to -/// be preserved, we verify the actual values are within the allowed range. -fn check_enumerated_values_against_base( - base_map: &serde_json::Map, - values: &[Value], - prop_name: &str, - errors: &mut Vec, -) { - // Check numeric lower bounds (minimum, minLength, minItems) - for keyword in &["minimum", "minLength", "minItems"] { - if let Some(base_val) = base_map.get(*keyword).and_then(Value::as_f64) { - for val in values { - let numeric: Option = match *keyword { - "minLength" => val - .as_str() - .and_then(|s| u32::try_from(s.len()).ok()) - .map(f64::from), - "minItems" => val - .as_array() - .and_then(|a| u32::try_from(a.len()).ok()) - .map(f64::from), - _ => val.as_f64(), - }; - if let Some(n) = numeric - && n < base_val - { - errors.push(format!( - "property '{prop_name}': derived const/enum value {val} violates \ - base {keyword} ({base_val})" - )); - } - } - } - } - - // Check numeric upper bounds (maximum, maxLength, maxItems) - for keyword in &["maximum", "maxLength", "maxItems"] { - if let Some(base_val) = base_map.get(*keyword).and_then(Value::as_f64) { - for val in values { - let numeric: Option = match *keyword { - "maxLength" => val - .as_str() - .and_then(|s| u32::try_from(s.len()).ok()) - .map(f64::from), - "maxItems" => val - .as_array() - .and_then(|a| u32::try_from(a.len()).ok()) - .map(f64::from), - _ => val.as_f64(), - }; - if let Some(n) = numeric - && n > base_val - { - errors.push(format!( - "property '{prop_name}': derived const/enum value {val} violates \ - base {keyword} ({base_val})" - )); - } - } - } - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - use serde_json::json; - - // -- extract_effective_schema ------------------------------------------ - - #[test] - fn test_extract_simple_schema() { - let schema = json!({ - "type": "object", - "required": ["a"], - "properties": { - "a": {"type": "string"}, - "b": {"type": "integer"} - }, - "additionalProperties": false - }); - let eff = extract_effective_schema(&schema); - assert_eq!(eff.properties.len(), 2); - assert!(eff.required.contains("a")); - assert_eq!(eff.additional_properties, Some(Value::Bool(false))); - } - - #[test] - fn test_extract_with_allof() { - let schema = json!({ - "type": "object", - "allOf": [ - { - "type": "object", - "required": ["x"], - "properties": {"x": {"type": "string"}} - }, - { - "type": "object", - "required": ["y"], - "properties": {"y": {"type": "number"}} - } - ] - }); - let eff = extract_effective_schema(&schema); - assert_eq!(eff.properties.len(), 2); - assert!(eff.required.contains("x")); - assert!(eff.required.contains("y")); - } - - #[test] - fn test_extract_allof_additional_properties_false_wins_over_true() { - let schema = json!({ - "type": "object", - "additionalProperties": true, - "allOf": [ - { - "type": "object", - "properties": {"a": {"type": "string"}}, - "additionalProperties": false - }, - { - "type": "object", - "properties": {"a": {"type": "string"}}, - "additionalProperties": true - } - ] - }); - let eff = extract_effective_schema(&schema); - assert_eq!(eff.additional_properties, Some(Value::Bool(false))); - } - - #[test] - fn test_extract_allof_boolean_equivalent_false_wins_over_true() { - let schema = json!({ - "type": "object", - "allOf": [ - {"additionalProperties": {"not": {}}}, - {"additionalProperties": {}} - ] - }); - let eff = extract_effective_schema(&schema); - assert_eq!(eff.additional_properties, Some(json!({"not": {}}))); - } - - // -- validate_schema_compatibility ------------------------------------ - - #[test] - fn test_partially_open_base_accepts_compatible_derived_property() { - let base = json!({ - "type": "object", - "additionalProperties": {"type": "string"} - }); - let derived = json!({ - "type": "object", - "additionalProperties": {"type": "string"}, - "properties": { - "foo": {"type": "string", "maxLength": 5} - } - }); - - let errors = validate_schema_compatibility(&base, &derived, "base", "derived"); - assert!( - errors.is_empty(), - "compatible refinement must be accepted: {errors:?}" - ); - } - - #[test] - fn test_partially_open_base_rejects_incompatible_derived_property() { - let base = json!({ - "type": "object", - "additionalProperties": {"type": "string"} - }); - let derived = json!({ - "type": "object", - "additionalProperties": {"type": "string"}, - "properties": { - "foo": {"type": "integer"} - } - }); - - let errors = validate_schema_compatibility(&base, &derived, "base", "derived"); - assert!( - errors - .iter() - .any(|error| error.contains("foo") && error.contains("changes type")), - "incompatible refinement must be rejected: {errors:?}" - ); - } - - #[test] - fn test_boolean_equivalent_additional_properties_control_derivation() { - let open_base = json!({ - "type": "object", - "additionalProperties": {} - }); - let closed_base = json!({ - "type": "object", - "additionalProperties": {"not": {}} - }); - let derived = json!({ - "type": "object", - "properties": { - "foo": {"type": "integer"} - } - }); - - assert!(validate_schema_compatibility(&open_base, &derived, "base", "derived").is_empty()); - let errors = validate_schema_compatibility(&closed_base, &derived, "base", "derived"); - assert!( - errors - .iter() - .any(|error| error.contains("foo") && error.contains("additionalProperties")), - "false-equivalent additionalProperties must close the model: {errors:?}" - ); - } - - #[test] - fn test_compatible_tightening() { - let base = json!({ - "type": "object", - "properties": { - "v": {"type": "string", "maxLength": 100} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "v": {"type": "string", "maxLength": 50} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~"); - assert!(errs.is_empty(), "tightening should be ok: {errs:?}"); - } - - #[test] - fn test_incompatible_loosening_max_length() { - let base = json!({ - "type": "object", - "properties": { - "v": {"type": "string", "maxLength": 100} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "v": {"type": "string", "maxLength": 200} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~"); - assert!(!errs.is_empty()); - } - - #[test] - fn test_incompatible_loosening_maximum() { - let base = json!({ - "type": "object", - "properties": { - "n": {"type": "integer", "maximum": 100} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "n": {"type": "integer", "maximum": 200} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!(!errs.is_empty()); - } - - #[test] - fn test_incompatible_loosening_minimum() { - let base = json!({ - "type": "object", - "properties": { - "n": {"type": "integer", "minimum": 10} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "n": {"type": "integer", "minimum": 5} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!(!errs.is_empty()); - } - - #[test] - fn test_enum_expansion_fails() { - let base = json!({ - "type": "object", - "properties": { - "s": {"type": "string", "enum": ["a", "b"]} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "s": {"type": "string", "enum": ["a", "b", "c"]} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!(!errs.is_empty()); - } - - #[test] - fn test_enum_subset_ok() { - let base = json!({ - "type": "object", - "properties": { - "s": {"type": "string", "enum": ["a", "b", "c"]} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "s": {"type": "string", "enum": ["a"]} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!(errs.is_empty(), "{errs:?}"); - } - - #[test] - fn test_additional_properties_false_blocks_new_prop() { - let base = json!({ - "type": "object", - "properties": {"a": {"type": "string"}}, - "additionalProperties": false - }); - let derived = json!({ - "type": "object", - "properties": { - "a": {"type": "string"}, - "b": {"type": "string"} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!(!errs.is_empty()); - } - - #[test] - fn test_value_compatibility_catches_closed_descendant_branch_orphan() { - let base = json!({ - "type": "object", - "properties": { - "routing": { - "type": "object", - "properties": { - "source": {"type": "string"} - } - } - } - }); - let derived = json!({ - "type": "object", - "allOf": [ - base, - { - "type": "object", - "properties": { - "routing": { - "type": "object", - "additionalProperties": false, - "properties": { - "target": {"type": "string"} - } - } - } - } - ] - }); - - let errs = validate_schema_compatibility(&base, &derived, "base", "derived"); - assert!( - errs.iter() - .any(|e| e.contains("routing.source") && e.contains("additionalProperties")), - "closed descendant branch should not orphan an ancestor property: {errs:?}" - ); - } - - #[test] - fn test_closed_descendant_branch_fails_when_depth_guard_is_hit() { - fn nested_object(depth: usize) -> Value { - let mut schema = json!({"type": "object", "properties": {}}); - for _ in 0..depth { - schema = json!({ - "type": "object", - "properties": { - "child": schema - } - }); - } - schema - } - - let base = nested_object(MAX_RECURSION_DEPTH); - let derived = nested_object(MAX_RECURSION_DEPTH); - - let errs = validate_closed_descendant_branches(&base, &derived, "base", "derived"); - assert!( - errs.iter() - .any(|err| err.contains("exceeded maximum nesting depth")), - "depth guard should fail closed instead of silently accepting: {errs:?}" - ); - } - - #[test] - fn test_additional_properties_inherited_via_allof_not_loosening() { - // Derived omits `additionalProperties` at its own root but its - // properties set is identical to base's (typical shape produced - // by the macro emitter after the allOf+$ref refactor — the - // derived overlay nests its new fields under base's generic - // slot, leaving the top-level property set unchanged). - // - // Per JSON Schema allOf composition, the base's - // `additionalProperties: false` is inherited via $ref, so this - // shape is **not** loosening and OP#12 must not flag it. - let base = json!({ - "type": "object", - "properties": {"a": {"type": "string"}}, - "additionalProperties": false - }); - let derived = json!({ - "type": "object", - "properties": {"a": {"type": "string"}} - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!( - errs.is_empty(), - "Derived inheriting closedness via $ref should not be flagged: {errs:?}" - ); - } - - #[test] - fn test_additional_properties_explicit_true_still_loosens() { - // A direct derived schema that has no inherited closed branch and - // explicitly says `additionalProperties: true` loosens a closed base. - let base = json!({ - "type": "object", - "properties": {"a": {"type": "string"}}, - "additionalProperties": false - }); - let derived = json!({ - "type": "object", - "properties": {"a": {"type": "string"}}, - "additionalProperties": true - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!( - errs.iter() - .any(|e| e.contains("loosens additionalProperties")), - "Explicit additionalProperties: true must still flag as loosening: {errs:?}" - ); - } - - #[test] - fn test_open_base_allows_new_prop() { - let base = json!({ - "type": "object", - "properties": {"a": {"type": "string"}} - }); - let derived = json!({ - "type": "object", - "properties": { - "a": {"type": "string"}, - "b": {"type": "string"} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!(errs.is_empty(), "{errs:?}"); - } - - #[test] - fn test_property_disabled_fails() { - let base = json!({ - "type": "object", - "required": ["x"], - "properties": {"x": {"type": "string"}} - }); - let derived = json!({ - "type": "object", - "properties": {"x": false} - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!(!errs.is_empty()); - } - - #[test] - fn test_nested_object_loosening_caught() { - let base = json!({ - "type": "object", - "properties": { - "inner": { - "type": "object", - "properties": { - "v": {"type": "integer", "maximum": 10} - } - } - } - }); - let derived = json!({ - "type": "object", - "properties": { - "inner": { - "type": "object", - "properties": { - "v": {"type": "integer", "maximum": 20} - } - } - } - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!(!errs.is_empty()); - } - - #[test] - fn test_boolean_true_schema_loosens_constrained_property() { - // Derived replaces a constrained property with boolean `true` schema - // (which accepts anything), silently loosening the contract. - let base = json!({ - "type": "object", - "properties": { - "age": {"type": "integer", "maximum": 120} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "age": true - } - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!( - !errs.is_empty(), - "Boolean true schema should be flagged as loosening: {errs:?}" - ); - } - - #[test] - fn test_boolean_true_schema_loosens_typed_property() { - // A boolean `true` derived property removes the base type constraint. - let base = json!({ - "type": "object", - "properties": { - "name": {"type": "string"} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "name": true - } - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!( - !errs.is_empty(), - "Boolean true schema replaces typed property - should flag" - ); - } - - #[test] - fn test_enum_tightening_allows_omitting_bounds() { - // Derived introduces enum, which is strictly tighter than maxLength. - // Omitting maxLength when adding enum is NOT loosening. - let base = json!({ - "type": "object", - "properties": { - "tier": {"type": "string", "maxLength": 100} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "tier": {"type": "string", "enum": ["gold", "platinum"]} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!( - errs.is_empty(), - "enum tightening should allow omitting maxLength: {errs:?}" - ); - } - - #[test] - fn test_const_tightening_allows_omitting_bounds_and_pattern() { - // Derived introduces const, which is the tightest possible constraint. - // Omitting bounds and pattern when adding const is NOT loosening. - let base = json!({ - "type": "object", - "properties": { - "v": {"type": "string", "maxLength": 100, "pattern": "^[a-z]+$"} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "v": {"type": "string", "const": "hello"} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!( - errs.is_empty(), - "const tightening should allow omitting maxLength and pattern: {errs:?}" - ); - } - - #[test] - fn test_enum_tightening_allows_omitting_numeric_bounds() { - // Derived introduces enum for an integer property, omitting min/max. - let base = json!({ - "type": "object", - "properties": { - "priority": {"type": "integer", "minimum": 0, "maximum": 100} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "priority": {"type": "integer", "enum": [1, 5, 10]} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!( - errs.is_empty(), - "enum tightening should allow omitting min/max: {errs:?}" - ); - } - - #[test] - fn test_omitting_bounds_without_enum_or_const_still_fails() { - // Derived omits maxLength without adding enum or const — still loosening. - let base = json!({ - "type": "object", - "properties": { - "code": {"type": "string", "maxLength": 100} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "code": {"type": "string"} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "b", "d"); - assert!( - !errs.is_empty(), - "Omitting maxLength without enum/const should still fail" - ); - } - - #[test] - fn test_derived_const_must_be_in_base_enum() { - // Base has enum, derived narrows to const — but const value must be in base enum. - let base = json!({ - "type": "object", - "properties": { - "status": {"type": "string", "enum": ["active", "inactive"]} - } - }); - let derived_ok = json!({ - "type": "object", - "properties": { - "status": {"type": "string", "const": "active"} - } - }); - let errs = validate_schema_compatibility(&base, &derived_ok, "b", "d"); - assert!(errs.is_empty(), "const in base enum should be ok: {errs:?}"); - - let derived_bad = json!({ - "type": "object", - "properties": { - "status": {"type": "string", "const": "deleted"} - } - }); - let errs = validate_schema_compatibility(&base, &derived_bad, "b", "d"); - assert!(!errs.is_empty(), "const NOT in base enum should fail"); - } - - #[test] - fn test_const_violates_minimum() { - // Base has minimum 42, derived sets const 32 — must fail. - let base = json!({ - "type": "object", - "properties": { - "score": {"type": "integer", "minimum": 42} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "score": {"type": "integer", "const": 32} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~"); - assert!( - !errs.is_empty(), - "const 32 < minimum 42 should fail: {errs:?}" - ); - assert!( - errs.iter() - .any(|e| e.contains("violates") && e.contains("minimum")), - "error should mention minimum violation: {errs:?}" - ); - } - - #[test] - fn test_const_satisfies_minimum() { - // Base has minimum 42, derived sets const 50 — should pass. - let base = json!({ - "type": "object", - "properties": { - "score": {"type": "integer", "minimum": 42} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "score": {"type": "integer", "const": 50} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~"); - assert!( - errs.is_empty(), - "const 50 >= minimum 42 should pass: {errs:?}" - ); - } - - #[test] - fn test_enum_value_violates_maximum() { - // Base has maximum 100, derived enum includes 200 — must fail. - let base = json!({ - "type": "object", - "properties": { - "score": {"type": "integer", "maximum": 100} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "score": {"type": "integer", "enum": [10, 50, 200]} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~"); - assert!( - !errs.is_empty(), - "enum value 200 > maximum 100 should fail: {errs:?}" - ); - } - - #[test] - fn test_enum_values_within_bounds() { - // Base has minimum 10 and maximum 100, all enum values within range — should pass. - let base = json!({ - "type": "object", - "properties": { - "score": {"type": "integer", "minimum": 10, "maximum": 100} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "score": {"type": "integer", "enum": [10, 50, 100]} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~"); - assert!( - errs.is_empty(), - "all enum values in range should pass: {errs:?}" - ); - } - - #[test] - fn test_const_string_violates_max_length() { - // Base has maxLength 5, derived const is "toolong" (7 chars) — must fail. - let base = json!({ - "type": "object", - "properties": { - "code": {"type": "string", "maxLength": 5} - } - }); - let derived = json!({ - "type": "object", - "properties": { - "code": {"type": "string", "const": "toolong"} - } - }); - let errs = validate_schema_compatibility(&base, &derived, "base~", "derived~"); - assert!( - !errs.is_empty(), - "const 'toolong' exceeds maxLength 5: {errs:?}" - ); - } -} diff --git a/gts/src/schema_derivation.rs b/gts/src/schema_derivation.rs new file mode 100644 index 0000000..beabb70 --- /dev/null +++ b/gts/src/schema_derivation.rs @@ -0,0 +1,412 @@ +//! OP#12 - Schema-vs-schema derivation admission. +//! +//! Given a chained GTS schema ID like `gts.A~B~C~`, this module validates that +//! each derived schema may be admitted under its base: +//! +//! - B (derived from A) must be admissible under A +//! - C (derived from A~B) must be admissible under A~B +//! +//! Admission requires `Valid(derived) ⊆ Valid(base)` - every valid instance of +//! the derived schema is also a valid instance of the base. That is the same +//! accepted-instance-set inclusion that schema evolution checks, so this module +//! owns no keyword semantics of its own: it calls the checker in +//! [`crate::schema_evolution`] and adds the two admission rules that inclusion alone +//! does not express. + +use crate::schema_evolution::{CompatibilityFinding, check_accepted_set_inclusion, flatten_schema}; +use crate::schema_semantics::boolean_schema_value; +use serde_json::Value; + +const MAX_RECURSION_DEPTH: usize = 64; + +const ADDITIONAL_PROPERTIES: &str = "additionalProperties"; + +/// Validates that a derived JSON Schema value may be admitted under its base. +/// +/// Combines set inclusion with the admission rules that inclusion does not +/// express; see [`validate_derivation`] and +/// [`validate_closed_descendant_branches`]. +pub(crate) fn validate_derivation_compatibility( + base_schema: &Value, + derived_schema: &Value, + base_id: &str, + derived_id: &str, +) -> Vec { + let mut errors = validate_derivation(base_schema, derived_schema, base_id, derived_id); + errors.extend(validate_closed_descendant_branches( + base_schema, + derived_schema, + base_id, + derived_id, + )); + errors +} + +/// Checks that the derived *declaration* is included in the base. +/// +/// Inclusion is the backward relation of the evolution checker with the derived +/// definition in the older position, so derivation and evolution decide the +/// same question through one engine. +/// +/// What differs is the input. A derived document embeds its base through +/// `allOf`/`$ref`, so intersecting the branches would ask a question whose +/// answer is always yes - composition can never widen the base. GTS instead +/// forbids a derivation from *declaring* a constraint looser than the one it +/// inherits, which is a statement about the most-derived declaration of each +/// property. Both sides are therefore reduced with [`declared_schema`] before +/// the shared checker compares them. +/// +/// Admission fails closed: a pair the checker reports as `Unknown` is rejected. +/// Evolution can hand an undecided verdict back to its caller, but admitting a +/// derivation whose inclusion nobody could prove is exactly how an instance of +/// the derived type ends up failing validation against its base. +pub(crate) fn validate_derivation( + base_schema: &Value, + derived_schema: &Value, + base_id: &str, + derived_id: &str, +) -> Vec { + let base = declared_schema(base_schema); + let mut derived = declared_schema(derived_schema); + let mut errors = Vec::new(); + + // An omitted `additionalProperties` is not a declaration: across dialects + // the base's constraint still applies to the same instance through + // `allOf`/`$ref` composition, so the derived level inherits it rather than + // reopening. Declaring a permissive value explicitly is a different act and + // is reported below. + if let (Some(derived_map), Some(inherited)) = ( + derived.as_object_mut(), + base.get(ADDITIONAL_PROPERTIES).cloned(), + ) && !derived_map.contains_key(ADDITIONAL_PROPERTIES) + { + derived_map.insert(ADDITIONAL_PROPERTIES.to_owned(), inherited); + } + if base + .get(ADDITIONAL_PROPERTIES) + .and_then(boolean_schema_value) + == Some(false) + && derived_schema + .get(ADDITIONAL_PROPERTIES) + .is_some_and(|declared| boolean_schema_value(declared) != Some(false)) + { + errors.push(format!( + "derived schema '{derived_id}' loosens additionalProperties from a closed constraint \ + in base '{base_id}'" + )); + } + + // A dialect difference is not an admission failure: GTS pins no draft and + // sets each schema's dialect from its own `$schema` (spec sec 11), so a + // derivation may declare a newer draft than the base it tightens. The + // dialect still governs how the rest of the comparison reads keywords. + let (_, diagnostics) = check_accepted_set_inclusion(&derived, &base); + errors.extend( + diagnostics + .iter() + .filter(|diagnostic| diagnostic.finding != CompatibilityFinding::DialectChanged) + .map(|diagnostic| { + format!( + "derived schema '{derived_id}' is not included in base '{base_id}': \ + {diagnostic}" + ) + }), + ); + collect_disabled_base_properties(&base, &derived, base_id, derived_id, &mut errors); + errors +} + +/// Reduces a schema to what it *declares*, with the innermost branch winning. +/// +/// `allOf` branches are folded in order and the last declaration of a property +/// replaces earlier ones, so a derived overlay that restates an inherited +/// property is read as that overlay's constraint rather than as its +/// intersection with the base. `additionalProperties` is the exception: it +/// folds through the closedness-preserving lattice, because a permissive +/// overlay cannot reopen a branch that closed the level. +fn declared_schema(schema: &Value) -> Value { + let Some(map) = schema.as_object() else { + return schema.clone(); + }; + let mut declared = serde_json::Map::new(); + let mut additional_properties = None; + + if let Some(branches) = map.get("allOf").and_then(Value::as_array) { + for branch in branches { + if let Some(branch) = declared_schema(branch).as_object() { + absorb_declaration(&mut declared, &mut additional_properties, branch); + } + } + } + absorb_declaration(&mut declared, &mut additional_properties, map); + + if let Some(additional_properties) = additional_properties { + declared.insert(ADDITIONAL_PROPERTIES.to_owned(), additional_properties); + } + Value::Object(declared) +} + +/// Folds one declaration level into the accumulated one. +fn absorb_declaration( + declared: &mut serde_json::Map, + additional_properties: &mut Option, + source: &serde_json::Map, +) { + for (keyword, value) in source { + match keyword.as_str() { + "allOf" => {} + ADDITIONAL_PROPERTIES => { + merge_additional_properties_constraint(additional_properties, value); + } + "properties" => { + let target = declared + .entry("properties") + .or_insert_with(|| Value::Object(serde_json::Map::new())); + if let (Some(target), Some(source)) = (target.as_object_mut(), value.as_object()) { + for (name, property) in source { + let property = declared_schema(property); + match target.get_mut(name) { + Some(inherited) => absorb_property(inherited, &property), + None => { + target.insert(name.clone(), property); + } + } + } + } + } + "required" => { + let target = declared + .entry("required") + .or_insert_with(|| Value::Array(Vec::new())); + if let (Some(target), Some(source)) = (target.as_array_mut(), value.as_array()) { + for name in source { + if !target.contains(name) { + target.push(name.clone()); + } + } + } + } + _ => { + declared.insert(keyword.clone(), value.clone()); + } + } + } +} + +/// Keywords that describe an object level's structure rather than the values a +/// single property accepts. +const STRUCTURAL_KEYWORDS: &[&str] = &["properties", "required", ADDITIONAL_PROPERTIES]; + +/// Folds an overlay's declaration of a property into the inherited one. +/// +/// A derivation that restates a property redeclares that property's own value +/// constraints: dropping the base's `maxLength` while restating `type` is a +/// looser declaration, not an inheritance of the bound. Object structure +/// composes instead - the nested `properties`, `required` and +/// `additionalProperties` of the base still apply through `allOf`, so an +/// overlay that specifies a nested object without repeating its `required` list +/// is not loosening anything. +fn absorb_property(inherited: &mut Value, overlay: &Value) { + let (Some(inherited_map), Some(overlay_map)) = (inherited.as_object(), overlay.as_object()) + else { + *inherited = overlay.clone(); + return; + }; + + let mut composed: serde_json::Map = overlay_map + .iter() + .filter(|(keyword, _)| !STRUCTURAL_KEYWORDS.contains(&keyword.as_str())) + .map(|(keyword, value)| (keyword.clone(), value.clone())) + .collect(); + let mut additional_properties = inherited_map.get(ADDITIONAL_PROPERTIES).cloned(); + for keyword in ["properties", "required"] { + if let Some(value) = inherited_map.get(keyword) { + composed.insert(keyword.to_owned(), value.clone()); + } + } + absorb_declaration(&mut composed, &mut additional_properties, overlay_map); + if let Some(additional_properties) = additional_properties { + composed.insert(ADDITIONAL_PROPERTIES.to_owned(), additional_properties); + } + *inherited = Value::Object(composed); +} + +/// Folds an `additionalProperties` value into an accumulator using a +/// closedness-preserving lattice: schemas equivalent to `false` (closed) are +/// strongest, nontrivial constraining schemas are in the middle, and schemas +/// equivalent to `true` (open) are weakest. +/// +/// This mirrors `allOf` composition, where the level stays closed if **any** +/// branch gives `additionalProperties` a false-equivalent schema, so a +/// permissive overlay can never loosen a closed base. +fn merge_additional_properties_constraint(current: &mut Option, candidate: &Value) { + if current.as_ref().and_then(boolean_schema_value) == Some(false) { + return; + } + if boolean_schema_value(candidate) == Some(true) && current.is_some() { + // Intersecting an existing constraint with `true` changes nothing. + return; + } + *current = Some(candidate.clone()); +} + +/// Rejects a derived schema that switches a base property off with `false`. +/// +/// Set inclusion permits it - rejecting every instance that carries the +/// property keeps the derived set inside the base set - but a derivation that +/// makes an inherited property unusable is not a valid specialization of the +/// base contract, so this is an admission rule rather than a compatibility one. +fn collect_disabled_base_properties( + base_schema: &Value, + derived_schema: &Value, + base_id: &str, + derived_id: &str, + errors: &mut Vec, +) { + let effective_properties = |schema: &Value| { + flatten_schema(schema) + .get("properties") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() + }; + let base_properties = effective_properties(base_schema); + for (name, derived_property) in effective_properties(derived_schema) { + if derived_property == Value::Bool(false) && base_properties.contains_key(&name) { + errors.push(format!( + "property '{name}': derived schema '{derived_id}' disables property defined in \ + base '{base_id}'" + )); + } + } +} + +/// Validates branch-scoped closed `additionalProperties` in a descendant schema. +/// +/// Flattened compatibility catches closed ancestors that reject new descendant +/// properties, but it cannot see the inverse `allOf` hazard: a descendant +/// branch can close `additionalProperties` without restating an ancestor property +/// at the same object path, making that ancestor property unusable in the +/// composed schema. This walks the raw/resolved descendant branches so that +/// branch ownership is preserved. +pub(crate) fn validate_closed_descendant_branches( + ancestor_schema: &Value, + descendant_schema: &Value, + ancestor_label: &str, + descendant_label: &str, +) -> Vec { + let mut errors = Vec::new(); + collect_closed_descendant_branch_errors( + ancestor_schema, + descendant_schema, + "", + 0, + ancestor_label, + descendant_label, + &mut errors, + ); + errors +} + +fn collect_closed_descendant_branch_errors( + ancestor_schema: &Value, + descendant_schema: &Value, + path: &str, + depth: usize, + ancestor_label: &str, + descendant_label: &str, + errors: &mut Vec, +) { + if depth >= MAX_RECURSION_DEPTH { + errors.push(format!( + "schema compatibility check exceeded maximum nesting depth of \ + {MAX_RECURSION_DEPTH} at '{path}' between ancestor '{ancestor_label}' \ + and descendant '{descendant_label}'" + )); + return; + } + + let ancestor = flatten_schema(ancestor_schema); + let ancestor_props = ancestor.get("properties").and_then(Value::as_object); + let Some(descendant_obj) = descendant_schema.as_object() else { + return; + }; + let descendant_props = descendant_obj.get("properties").and_then(Value::as_object); + + if descendant_obj + .get("additionalProperties") + .and_then(boolean_schema_value) + == Some(false) + { + let mut orphaned: Vec<&str> = ancestor_props + .into_iter() + .flatten() + .map(|(name, _)| name.as_str()) + .filter(|name| !descendant_props.is_some_and(|props| props.contains_key(*name))) + .collect(); + orphaned.sort_unstable(); + for name in orphaned { + let property_path = join_schema_path(path, name); + errors.push(format!( + "property '{property_path}': descendant schema '{descendant_label}' sets \ + a closed additionalProperties constraint but does not restate property defined in \ + ancestor '{ancestor_label}', making it unusable under allOf composition" + )); + } + } + + if let Some(props) = descendant_props { + let mut common: Vec<&str> = props + .keys() + .filter(|name| ancestor_props.is_some_and(|ancestor| ancestor.contains_key(*name))) + .map(String::as_str) + .collect(); + common.sort_unstable(); + + for name in common { + let Some(ancestor_prop) = ancestor_props.and_then(|props| props.get(name)) else { + continue; + }; + let Some(descendant_prop) = props.get(name) else { + continue; + }; + + let next_path = join_schema_path(path, name); + collect_closed_descendant_branch_errors( + ancestor_prop, + descendant_prop, + &next_path, + depth + 1, + ancestor_label, + descendant_label, + errors, + ); + } + } + + if let Some(Value::Array(all_of)) = descendant_obj.get("allOf") { + for item in all_of { + collect_closed_descendant_branch_errors( + ancestor_schema, + item, + path, + depth + 1, + ancestor_label, + descendant_label, + errors, + ); + } + } +} + +fn join_schema_path(prefix: &str, name: &str) -> String { + if prefix.is_empty() { + name.to_owned() + } else { + format!("{prefix}.{name}") + } +} + +#[cfg(test)] +#[path = "schema_derivation_test.rs"] +mod schema_derivation_test; diff --git a/gts/src/schema_derivation_test.rs b/gts/src/schema_derivation_test.rs new file mode 100644 index 0000000..a2a893a --- /dev/null +++ b/gts/src/schema_derivation_test.rs @@ -0,0 +1,697 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] +use super::*; +use serde_json::json; +// -- effective schema -------------------------------------------------- + +/// Closedness must survive `allOf` composition: a permissive overlay may +/// not reopen a branch that closed `additionalProperties`, or a derivation +/// could smuggle properties past a closed base. +#[test] +fn test_flatten_keeps_closed_additional_properties_through_allof() { + let schema = json!({ + "type": "object", + "additionalProperties": true, + "allOf": [ + {"additionalProperties": false}, + {"additionalProperties": true} + ] + }); + + assert_eq!( + flatten_schema(&schema).get("additionalProperties"), + Some(&Value::Bool(false)) + ); +} + +/// The same, spelled through schemas that are only boolean-*equivalent*. +#[test] +fn test_flatten_keeps_boolean_equivalent_closed_additional_properties() { + let schema = json!({ + "type": "object", + "allOf": [ + {"additionalProperties": {"not": {}}}, + {"additionalProperties": {}} + ] + }); + + let flattened = flatten_schema(&schema); + assert_eq!( + flattened + .get("additionalProperties") + .and_then(boolean_schema_value), + Some(false), + "{flattened}" + ); +} + +// -- validate_derivation_compatibility ------------------------------------ + +#[test] +fn test_partially_open_base_accepts_compatible_derived_property() { + let base = json!({ + "type": "object", + "additionalProperties": {"type": "string"} + }); + let derived = json!({ + "type": "object", + "additionalProperties": {"type": "string"}, + "properties": { + "foo": {"type": "string", "maxLength": 5} + } + }); + + let errors = validate_derivation_compatibility(&base, &derived, "base", "derived"); + assert!( + errors.is_empty(), + "compatible refinement must be accepted: {errors:?}" + ); +} + +#[test] +fn test_partially_open_base_rejects_incompatible_derived_property() { + let base = json!({ + "type": "object", + "additionalProperties": {"type": "string"} + }); + let derived = json!({ + "type": "object", + "additionalProperties": {"type": "string"}, + "properties": { + "foo": {"type": "integer"} + } + }); + + let errors = validate_derivation_compatibility(&base, &derived, "base", "derived"); + assert!( + errors + .iter() + .any(|error| error.contains("foo") && error.contains("changes type")), + "incompatible refinement must be rejected: {errors:?}" + ); +} + +#[test] +fn test_boolean_equivalent_additional_properties_control_derivation() { + let open_base = json!({ + "type": "object", + "additionalProperties": {} + }); + let closed_base = json!({ + "type": "object", + "additionalProperties": {"not": {}} + }); + let derived = json!({ + "type": "object", + "properties": { + "foo": {"type": "integer"} + } + }); + + assert!(validate_derivation_compatibility(&open_base, &derived, "base", "derived").is_empty()); + let errors = validate_derivation_compatibility(&closed_base, &derived, "base", "derived"); + assert!( + errors + .iter() + .any(|error| error.contains("foo") && error.contains("closed")), + "false-equivalent additionalProperties must close the model: {errors:?}" + ); +} + +#[test] +fn test_compatible_tightening() { + let base = json!({ + "type": "object", + "properties": { + "v": {"type": "string", "maxLength": 100} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "v": {"type": "string", "maxLength": 50} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "base~", "derived~"); + assert!(errs.is_empty(), "tightening should be ok: {errs:?}"); +} + +#[test] +fn test_incompatible_loosening_max_length() { + let base = json!({ + "type": "object", + "properties": { + "v": {"type": "string", "maxLength": 100} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "v": {"type": "string", "maxLength": 200} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "base~", "derived~"); + assert!(!errs.is_empty()); +} + +#[test] +fn test_incompatible_loosening_maximum() { + let base = json!({ + "type": "object", + "properties": { + "n": {"type": "integer", "maximum": 100} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "n": {"type": "integer", "maximum": 200} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!(!errs.is_empty()); +} + +#[test] +fn test_incompatible_loosening_minimum() { + let base = json!({ + "type": "object", + "properties": { + "n": {"type": "integer", "minimum": 10} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "n": {"type": "integer", "minimum": 5} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!(!errs.is_empty()); +} + +#[test] +fn test_enum_expansion_fails() { + let base = json!({ + "type": "object", + "properties": { + "s": {"type": "string", "enum": ["a", "b"]} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "s": {"type": "string", "enum": ["a", "b", "c"]} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!(!errs.is_empty()); +} + +#[test] +fn test_enum_subset_ok() { + let base = json!({ + "type": "object", + "properties": { + "s": {"type": "string", "enum": ["a", "b", "c"]} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "s": {"type": "string", "enum": ["a"]} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!(errs.is_empty(), "{errs:?}"); +} + +#[test] +fn test_additional_properties_false_blocks_new_prop() { + let base = json!({ + "type": "object", + "properties": {"a": {"type": "string"}}, + "additionalProperties": false + }); + let derived = json!({ + "type": "object", + "properties": { + "a": {"type": "string"}, + "b": {"type": "string"} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!(!errs.is_empty()); +} + +#[test] +fn test_value_compatibility_catches_closed_descendant_branch_orphan() { + let base = json!({ + "type": "object", + "properties": { + "routing": { + "type": "object", + "properties": { + "source": {"type": "string"} + } + } + } + }); + let derived = json!({ + "type": "object", + "allOf": [ + base, + { + "type": "object", + "properties": { + "routing": { + "type": "object", + "additionalProperties": false, + "properties": { + "target": {"type": "string"} + } + } + } + } + ] + }); + + let errs = validate_derivation_compatibility(&base, &derived, "base", "derived"); + assert!( + errs.iter() + .any(|e| e.contains("routing.source") && e.contains("additionalProperties")), + "closed descendant branch should not orphan an ancestor property: {errs:?}" + ); +} + +#[test] +fn test_closed_descendant_branch_fails_when_depth_guard_is_hit() { + fn nested_object(depth: usize) -> Value { + let mut schema = json!({"type": "object", "properties": {}}); + for _ in 0..depth { + schema = json!({ + "type": "object", + "properties": { + "child": schema + } + }); + } + schema + } + + let base = nested_object(MAX_RECURSION_DEPTH); + let derived = nested_object(MAX_RECURSION_DEPTH); + + let errs = validate_closed_descendant_branches(&base, &derived, "base", "derived"); + assert!( + errs.iter() + .any(|err| err.contains("exceeded maximum nesting depth")), + "depth guard should fail closed instead of silently accepting: {errs:?}" + ); +} + +#[test] +fn test_additional_properties_inherited_via_allof_not_loosening() { + // Derived omits `additionalProperties` at its own root but its + // properties set is identical to base's (typical shape produced + // by the macro emitter after the allOf+$ref refactor — the + // derived overlay nests its new fields under base's generic + // slot, leaving the top-level property set unchanged). + // + // Per JSON Schema allOf composition, the base's + // `additionalProperties: false` is inherited via $ref, so this + // shape is **not** loosening and OP#12 must not flag it. + let base = json!({ + "type": "object", + "properties": {"a": {"type": "string"}}, + "additionalProperties": false + }); + let derived = json!({ + "type": "object", + "properties": {"a": {"type": "string"}} + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!( + errs.is_empty(), + "Derived inheriting closedness via $ref should not be flagged: {errs:?}" + ); +} + +#[test] +fn test_additional_properties_explicit_true_still_loosens() { + // A direct derived schema that has no inherited closed branch and + // explicitly says `additionalProperties: true` loosens a closed base. + let base = json!({ + "type": "object", + "properties": {"a": {"type": "string"}}, + "additionalProperties": false + }); + let derived = json!({ + "type": "object", + "properties": {"a": {"type": "string"}}, + "additionalProperties": true + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!( + errs.iter() + .any(|e| e.contains("loosens additionalProperties")), + "Explicit additionalProperties: true must still flag as loosening: {errs:?}" + ); +} + +#[test] +fn test_open_base_allows_new_prop() { + let base = json!({ + "type": "object", + "properties": {"a": {"type": "string"}} + }); + let derived = json!({ + "type": "object", + "properties": { + "a": {"type": "string"}, + "b": {"type": "string"} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!(errs.is_empty(), "{errs:?}"); +} + +#[test] +fn test_property_disabled_fails() { + let base = json!({ + "type": "object", + "required": ["x"], + "properties": {"x": {"type": "string"}} + }); + let derived = json!({ + "type": "object", + "properties": {"x": false} + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!(!errs.is_empty()); +} + +#[test] +fn test_nested_object_loosening_caught() { + let base = json!({ + "type": "object", + "properties": { + "inner": { + "type": "object", + "properties": { + "v": {"type": "integer", "maximum": 10} + } + } + } + }); + let derived = json!({ + "type": "object", + "properties": { + "inner": { + "type": "object", + "properties": { + "v": {"type": "integer", "maximum": 20} + } + } + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!(!errs.is_empty()); +} + +#[test] +fn test_boolean_true_schema_loosens_constrained_property() { + // Derived replaces a constrained property with boolean `true` schema + // (which accepts anything), silently loosening the contract. + let base = json!({ + "type": "object", + "properties": { + "age": {"type": "integer", "maximum": 120} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "age": true + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!( + !errs.is_empty(), + "Boolean true schema should be flagged as loosening: {errs:?}" + ); +} + +#[test] +fn test_boolean_true_schema_loosens_typed_property() { + // A boolean `true` derived property removes the base type constraint. + let base = json!({ + "type": "object", + "properties": { + "name": {"type": "string"} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "name": true + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!( + !errs.is_empty(), + "Boolean true schema replaces typed property - should flag" + ); +} + +#[test] +fn test_enum_tightening_allows_omitting_bounds() { + // Derived introduces enum, which is strictly tighter than maxLength. + // Omitting maxLength when adding enum is NOT loosening. + let base = json!({ + "type": "object", + "properties": { + "tier": {"type": "string", "maxLength": 100} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "tier": {"type": "string", "enum": ["gold", "platinum"]} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!( + errs.is_empty(), + "enum tightening should allow omitting maxLength: {errs:?}" + ); +} + +#[test] +fn test_const_tightening_allows_omitting_bounds_and_pattern() { + // Derived introduces const, which is the tightest possible constraint. + // Omitting bounds and pattern when adding const is NOT loosening. + let base = json!({ + "type": "object", + "properties": { + "v": {"type": "string", "maxLength": 100, "pattern": "^[a-z]+$"} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "v": {"type": "string", "const": "hello"} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!( + errs.is_empty(), + "const tightening should allow omitting maxLength and pattern: {errs:?}" + ); +} + +#[test] +fn test_enum_tightening_allows_omitting_numeric_bounds() { + // Derived introduces enum for an integer property, omitting min/max. + let base = json!({ + "type": "object", + "properties": { + "priority": {"type": "integer", "minimum": 0, "maximum": 100} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "priority": {"type": "integer", "enum": [1, 5, 10]} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!( + errs.is_empty(), + "enum tightening should allow omitting min/max: {errs:?}" + ); +} + +#[test] +fn test_omitting_bounds_without_enum_or_const_still_fails() { + // Derived omits maxLength without adding enum or const — still loosening. + let base = json!({ + "type": "object", + "properties": { + "code": {"type": "string", "maxLength": 100} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "code": {"type": "string"} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "b", "d"); + assert!( + !errs.is_empty(), + "Omitting maxLength without enum/const should still fail" + ); +} + +#[test] +fn test_derived_const_must_be_in_base_enum() { + // Base has enum, derived narrows to const — but const value must be in base enum. + let base = json!({ + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["active", "inactive"]} + } + }); + let derived_ok = json!({ + "type": "object", + "properties": { + "status": {"type": "string", "const": "active"} + } + }); + let errs = validate_derivation_compatibility(&base, &derived_ok, "b", "d"); + assert!(errs.is_empty(), "const in base enum should be ok: {errs:?}"); + + let derived_bad = json!({ + "type": "object", + "properties": { + "status": {"type": "string", "const": "deleted"} + } + }); + let errs = validate_derivation_compatibility(&base, &derived_bad, "b", "d"); + assert!(!errs.is_empty(), "const NOT in base enum should fail"); +} + +#[test] +fn test_const_violates_minimum() { + // Base has minimum 42, derived sets const 32 — must fail. + let base = json!({ + "type": "object", + "properties": { + "score": {"type": "integer", "minimum": 42} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "score": {"type": "integer", "const": 32} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "base~", "derived~"); + assert!( + !errs.is_empty(), + "const 32 < minimum 42 should fail: {errs:?}" + ); + assert!( + errs.iter() + .any(|e| e.contains("$.score") && e.contains("minimum")), + "error should name the offending property and constraint: {errs:?}" + ); +} + +#[test] +fn test_const_satisfies_minimum() { + // Base has minimum 42, derived sets const 50 — should pass. + let base = json!({ + "type": "object", + "properties": { + "score": {"type": "integer", "minimum": 42} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "score": {"type": "integer", "const": 50} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "base~", "derived~"); + assert!( + errs.is_empty(), + "const 50 >= minimum 42 should pass: {errs:?}" + ); +} + +#[test] +fn test_enum_value_violates_maximum() { + // Base has maximum 100, derived enum includes 200 — must fail. + let base = json!({ + "type": "object", + "properties": { + "score": {"type": "integer", "maximum": 100} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "score": {"type": "integer", "enum": [10, 50, 200]} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "base~", "derived~"); + assert!( + !errs.is_empty(), + "enum value 200 > maximum 100 should fail: {errs:?}" + ); +} + +#[test] +fn test_enum_values_within_bounds() { + // Base has minimum 10 and maximum 100, all enum values within range — should pass. + let base = json!({ + "type": "object", + "properties": { + "score": {"type": "integer", "minimum": 10, "maximum": 100} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "score": {"type": "integer", "enum": [10, 50, 100]} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "base~", "derived~"); + assert!( + errs.is_empty(), + "all enum values in range should pass: {errs:?}" + ); +} + +#[test] +fn test_const_string_violates_max_length() { + // Base has maxLength 5, derived const is "toolong" (7 chars) — must fail. + let base = json!({ + "type": "object", + "properties": { + "code": {"type": "string", "maxLength": 5} + } + }); + let derived = json!({ + "type": "object", + "properties": { + "code": {"type": "string", "const": "toolong"} + } + }); + let errs = validate_derivation_compatibility(&base, &derived, "base~", "derived~"); + assert!( + !errs.is_empty(), + "const 'toolong' exceeds maxLength 5: {errs:?}" + ); +} diff --git a/gts/src/schema_evolution.rs b/gts/src/schema_evolution.rs new file mode 100644 index 0000000..8f288ee --- /dev/null +++ b/gts/src/schema_evolution.rs @@ -0,0 +1,1791 @@ +//! Type Schema Evolution Compatibility (spec sec 4.2, OP#8). +//! +//! Compares two definitions of one type identity by the instances they accept +//! and reports the backward, forward, and full verdicts. The relation is +//! `Valid(old) ⊆ Valid(new)` for backward and the reverse inclusion for +//! forward; sec 4.3 defines the modes. +//! +//! Derivation is a different relation over a different pair of schemas (sec +//! 4.1, `crate::schema_derivation`) and the spec is emphatic that the two must +//! not be conflated. What they share is the inclusion test itself, exposed +//! here as [`check_accepted_set_inclusion`] under a name that belongs to +//! neither relation. + +use crate::schema_semantics::boolean_schema_value; +use num_cmp::NumCmp; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::{BTreeSet, HashSet}; + +/// Result of attempting to establish one schema-compatibility relation. +/// +/// `Unknown` is deliberately distinct from `Incompatible`: it means the +/// checker could not prove or disprove the required accepted-instance-set +/// inclusion. The caller, not this library, decides how that affects admission. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompatibilityVerdict { + Compatible, + Incompatible, + #[default] + Unknown, +} + +impl CompatibilityVerdict { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Compatible => "compatible", + Self::Incompatible => "incompatible", + Self::Unknown => "unknown", + } + } + + #[must_use] + pub const fn is_compatible(self) -> bool { + matches!(self, Self::Compatible) + } + + #[must_use] + pub const fn is_incompatible(self) -> bool { + matches!(self, Self::Incompatible) + } + + #[must_use] + pub const fn is_unknown(self) -> bool { + matches!(self, Self::Unknown) + } + + /// Derives full compatibility from the two directional verdicts. + #[must_use] + pub const fn full(backward: Self, forward: Self) -> Self { + match (backward, forward) { + (Self::Compatible, Self::Compatible) => Self::Compatible, + (Self::Incompatible, _) | (_, Self::Incompatible) => Self::Incompatible, + _ => Self::Unknown, + } + } + + fn from_diagnostics(diagnostics: &[CompatibilityDiagnostic]) -> Self { + if diagnostics.is_empty() { + Self::Compatible + } else if diagnostics + .iter() + .all(CompatibilityDiagnostic::is_inconclusive) + { + Self::Unknown + } else { + Self::Incompatible + } + } +} + +impl std::fmt::Display for CompatibilityVerdict { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} +/// Content model of one object level of a **resolved** effective schema. +/// +/// Classified per gts-spec §4.4, which requires the level to be judged after +/// `$ref` resolution and `allOf` composition rather than from a single authored +/// keyword. Use [`classify_object_levels`] to obtain the +/// classification of every level of a document. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContentModel { + /// Accepts an undeclared property with any value. + Open, + /// Rejects every undeclared property. + Closed, + /// Accepts some undeclared property names, or constrains their values - for + /// example through a nontrivial schema-valued `additionalProperties`, + /// `patternProperties`, or `propertyNames`. + Partial, +} + +impl ContentModel { + const fn label(self) -> &'static str { + match self { + Self::Open => "open", + Self::Closed => "closed", + Self::Partial => "partially open", + } + } + + /// Whether a later definition may add an optional property at this level + /// and stay backward compatible. + /// + /// Only a closed level can: an open level already accepted arbitrary values + /// under the new property name, so declaring it narrows the accepted set + /// (§4.4). For a partially open level the answer depends on the constraint + /// that governs undeclared properties, so it is reported as not evolvable + /// rather than guessed. + #[must_use] + pub const fn is_evolvable_in_place(self) -> bool { + matches!(self, Self::Closed) + } +} + +impl std::fmt::Display for ContentModel { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.label()) + } +} + +/// One object level of a resolved schema, with its content model. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ObjectLevel { + /// Location of the level, `$` for the document root and dotted segments + /// below it, for example `$.payload` or `$.items[]`. + pub path: String, + /// How this level treats undeclared properties. + pub content_model: ContentModel, +} + +/// Machine-readable kind of a [`CompatibilityDiagnostic`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompatibilityFinding { + /// A property was declared at a level whose content model does not permit + /// the addition in this direction. + PropertyAdded, + /// A property declaration was dropped at a level whose content model does + /// not permit the removal in this direction. + PropertyRemoved, + /// The set of `required` properties changed. + RequiredChanged, + /// The content model of an object level changed. + ContentModelChanged, + /// The set of permitted `type` values is not an inclusion in this direction. + TypeChanged, + /// The `enum` constraint is not an inclusion in this direction. + EnumChanged, + /// A numeric bound moved in the direction this mode forbids. + BoundChanged, + /// A keyword that only narrows was added or removed. + NarrowingConstraintChanged, + /// A keyword whose values cannot be ordered by inclusion changed. + ConstraintChanged, + /// The declared JSON Schema dialect changed, so this checker cannot compare + /// the two documents under one stable set of keyword semantics. + DialectChanged, + /// Inclusion could not be established either way - an unresolved `$ref`, an + /// `allOf` intersection the checker cannot prove, a partially open level, or + /// two values of one keyword that this implementation cannot order. It is + /// reported distinctly so callers can apply their own admission policy. + NotProvable, +} + +/// Evidence explaining an incompatible or unknown directional verdict. +/// +/// Carries the schema location separately from the prose so that a caller can +/// report per object level without parsing the message. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompatibilityDiagnostic { + /// Location of the offending schema node, in the form used by + /// [`ObjectLevel::path`]. + pub path: String, + /// What kind of finding this is. + pub finding: CompatibilityFinding, + /// Human-readable detail, without the location prefix. + pub detail: String, +} + +impl CompatibilityDiagnostic { + fn new(path: &str, finding: CompatibilityFinding, detail: String) -> Self { + Self { + path: path.to_owned(), + finding, + detail, + } + } + + const fn is_inconclusive(&self) -> bool { + matches!( + self.finding, + CompatibilityFinding::NotProvable | CompatibilityFinding::DialectChanged + ) + } +} + +impl std::fmt::Display for CompatibilityDiagnostic { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "Schema at '{}' {}", self.path, self.detail) + } +} +/// Locations, relative to the node being flattened, whose `allOf` intersection +/// could not be reduced to an exact single schema. +/// +/// The root of the flattened node is the empty string; a property extends the +/// location with `.name` and array items with `[]`, matching the paths +/// [`check_schema_node_compatibility`] descends through. +/// Keywords the checker treats as node-level constraints (`additionalProperties`, +/// `patternProperties`, `propertyNames`, ...) are attributed to their owning +/// node: an intersection this checker cannot prove there makes the whole node +/// unprovable. +type UnprovenPaths = BTreeSet; + +/// Whether each side's effective dialect evaluates `unevaluatedProperties`. +#[derive(Debug, Clone, Copy)] +struct DialectSupport { + old_unevaluated: bool, + new_unevaluated: bool, +} + +/// Narrows `unproven` to the locations inside `child`, rebased so that the +/// empty string denotes `child` itself. +fn unproven_below(unproven: &UnprovenPaths, child: &str) -> UnprovenPaths { + unproven + .iter() + .filter_map(|location| location.strip_prefix(child)) + .filter(|rest| rest.is_empty() || rest.starts_with('.') || rest.starts_with('[')) + .map(ToOwned::to_owned) + .collect() +} + +fn merge_schema_map( + target: &mut Map, + candidate: &Map, + path: &str, + unproven: &mut UnprovenPaths, +) { + const ANNOTATIONS: &[&str] = &[ + "$id", + "$schema", + "title", + "description", + "default", + "examples", + "readOnly", + "writeOnly", + "deprecated", + "definitions", + "$defs", + "x-gts-abstract", + "x-gts-final", + "x-gts-traits", + "x-gts-traits-schema", + ]; + const MINIMUMS: &[&str] = &[ + "minimum", + "exclusiveMinimum", + "minLength", + "minItems", + "minProperties", + "minContains", + ]; + const MAXIMUMS: &[&str] = &[ + "maximum", + "exclusiveMaximum", + "maxLength", + "maxItems", + "maxProperties", + "maxContains", + ]; + + for (keyword, candidate_value) in candidate { + if ANNOTATIONS.contains(&keyword.as_str()) { + target.insert(keyword.clone(), candidate_value.clone()); + continue; + } + let Some(current) = target.get_mut(keyword) else { + target.insert(keyword.clone(), candidate_value.clone()); + continue; + }; + if current == candidate_value { + continue; + } + + match keyword.as_str() { + "properties" | "patternProperties" => { + // The checker descends into named properties, so an unprovable + // property intersection stays local to that property. Pattern + // properties are compared as a node-level constraint instead. + let named = keyword == "properties"; + if let (Some(current_map), Some(candidate_map)) = + (current.as_object_mut(), candidate_value.as_object()) + { + for (name, candidate_schema) in candidate_map { + if let Some(current_schema) = current_map.get_mut(name) { + let property_path = if named { + format!("{path}.{name}") + } else { + path.to_owned() + }; + merge_schema_intersection( + current_schema, + candidate_schema, + &property_path, + unproven, + ); + } else { + current_map.insert(name.clone(), candidate_schema.clone()); + } + } + } else { + unproven.insert(path.to_owned()); + } + } + "required" => { + if let (Some(current_items), Some(candidate_items)) = + (current.as_array_mut(), candidate_value.as_array()) + { + for item in candidate_items { + if !current_items.contains(item) { + current_items.push(item.clone()); + } + } + } + } + "items" => { + merge_schema_intersection(current, candidate_value, &format!("{path}[]"), unproven); + } + "additionalProperties" | "unevaluatedProperties" | "propertyNames" | "contains" => { + merge_schema_intersection(current, candidate_value, path, unproven); + } + "enum" => { + if let (Some(current_values), Some(candidate_values)) = + (current.as_array_mut(), candidate_value.as_array()) + { + current_values.retain(|value| candidate_values.contains(value)); + if current_values.is_empty() { + unproven.insert(path.to_owned()); + } + } + } + keyword if MINIMUMS.contains(&keyword) => { + if candidate_value.as_f64() > current.as_f64() { + *current = candidate_value.clone(); + } + } + keyword if MAXIMUMS.contains(&keyword) => { + if candidate_value.as_f64() < current.as_f64() { + *current = candidate_value.clone(); + } + } + "type" => { + if current.as_str() == Some("number") && candidate_value.as_str() == Some("integer") + { + *current = candidate_value.clone(); + } else if !(current.as_str() == Some("integer") + && candidate_value.as_str() == Some("number")) + { + unproven.insert(path.to_owned()); + } + } + _ => { + unproven.insert(path.to_owned()); + } + } + } +} + +fn merge_schema_intersection( + target: &mut Value, + candidate: &Value, + path: &str, + unproven: &mut UnprovenPaths, +) { + match (&mut *target, candidate) { + (Value::Bool(false), _) | (_, Value::Bool(true)) => {} + (Value::Bool(true), value) => *target = value.clone(), + (_, Value::Bool(false)) => *target = Value::Bool(false), + (Value::Object(target_map), Value::Object(candidate_map)) => { + merge_schema_map(target_map, candidate_map, path, unproven); + } + _ => { + // Two branches that are not both object schemas have no + // representable intersection; leave the node unconstrained and let + // the caller decide what an unprovable location means. + unproven.insert(path.to_owned()); + *target = Value::Object(Map::new()); + } + } +} +#[must_use] +pub fn flatten_schema(schema: &Value) -> Value { + flatten_effective(schema).0 +} + +/// Flattens `allOf` and reports where the intersection could not be proven. +/// +/// The flattened schema is always a usable approximation; the returned +/// [`UnprovenPaths`] tell a compatibility checker which locations it must +/// not draw conclusions about. +fn flatten_effective(schema: &Value) -> (Value, UnprovenPaths) { + let mut unproven = UnprovenPaths::new(); + let Some(schema_map) = schema.as_object() else { + return (schema.clone(), unproven); + }; + let mut result = Value::Bool(true); + if let Some(all_of) = schema_map.get("allOf").and_then(Value::as_array) { + for branch in all_of { + let (flattened_branch, branch_unproven) = flatten_effective(branch); + unproven.extend(branch_unproven); + merge_schema_intersection(&mut result, &flattened_branch, "", &mut unproven); + } + } + let direct = Value::Object( + schema_map + .iter() + .filter(|(keyword, _)| keyword.as_str() != "allOf") + .map(|(keyword, value)| (keyword.clone(), value.clone())) + .collect(), + ); + merge_schema_intersection(&mut result, &direct, "", &mut unproven); + (result, unproven) +} + +/// Reports a bound keyword whose value is present but not a number. +/// +/// Draft-04 spells `exclusiveMinimum`/`exclusiveMaximum` as booleans that +/// modify `minimum`/`maximum`, so a numeric comparison would silently ignore +/// them. Fall back to exact equality for any non-numeric value rather than +/// guessing which direction it widens. +fn check_non_numeric_bound( + path: &str, + old_schema: &Map, + new_schema: &Map, + key: &str, +) -> Option { + let non_numeric = |schema: &Map| { + schema + .get(key) + .is_some_and(|value| value.as_f64().is_none()) + }; + if (non_numeric(old_schema) || non_numeric(new_schema)) + && old_schema.get(key) != new_schema.get(key) + { + return Some(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + format!("changes non-numeric '{key}' constraint"), + )); + } + None +} + +fn check_min_max_constraint( + path: &str, + old_schema: &Map, + new_schema: &Map, + min_key: &str, + max_key: &str, + check_tightening: bool, +) -> Vec { + let bound = |detail: String| { + CompatibilityDiagnostic::new(path, CompatibilityFinding::BoundChanged, detail) + }; + let mut errors = Vec::new(); + errors.extend(check_non_numeric_bound( + path, old_schema, new_schema, min_key, + )); + errors.extend(check_non_numeric_bound( + path, old_schema, new_schema, max_key, + )); + + // Check minimum constraint + let old_min = old_schema.get(min_key).and_then(Value::as_f64); + let new_min = new_schema.get(min_key).and_then(Value::as_f64); + + if let (Some(old_m), Some(new_m)) = (old_min, new_min) { + if check_tightening && new_m > old_m { + errors.push(bound(format!( + "{min_key} increased from {old_m} -> {new_m}" + ))); + } else if !check_tightening && new_m < old_m { + errors.push(bound(format!( + "{min_key} decreased from {old_m} -> {new_m}" + ))); + } + } else if let (true, None, Some(new_m)) = (check_tightening, old_min, new_min) { + errors.push(bound(format!("adds {min_key} constraint: {new_m}"))); + } else if !check_tightening && old_min.is_some() && new_min.is_none() { + errors.push(bound(format!("removes {min_key} constraint"))); + } + + // Check maximum constraint + let old_max = old_schema.get(max_key).and_then(Value::as_f64); + let new_max = new_schema.get(max_key).and_then(Value::as_f64); + + if let (Some(old_m), Some(new_m)) = (old_max, new_max) { + if check_tightening && new_m < old_m { + errors.push(bound(format!( + "{max_key} decreased from {old_m} -> {new_m}" + ))); + } else if !check_tightening && new_m > old_m { + errors.push(bound(format!( + "{max_key} increased from {old_m} -> {new_m}" + ))); + } + } else if let (true, None, Some(new_m)) = (check_tightening, old_max, new_max) { + errors.push(bound(format!("adds {max_key} constraint: {new_m}"))); + } else if !check_tightening && old_max.is_some() && new_max.is_none() { + errors.push(bound(format!("removes {max_key} constraint"))); + } + + errors +} + +/// Returns the effective lower or upper numeric bound. +/// +/// Draft 6 and later allow an inclusive and an exclusive bound to coexist; +/// their intersection is the stricter of the two (with exclusive winning +/// when the numeric values are equal). Draft 4's boolean +/// `exclusiveMinimum`/`exclusiveMaximum` spelling is handled as a modifier +/// of the corresponding inclusive bound. +fn effective_numeric_bound( + schema: &Map, + inclusive_key: &str, + exclusive_key: &str, + is_lower: bool, +) -> Result, ()> { + // `total_cmp` orders `-0.0` below `0.0`, but the two denote the same JSON + // number and must compare equal, so the sign of zero is dropped as the + // bound is read. + let bound_value = |value: &Value| -> Result { + let value = value.as_f64().ok_or(())?; + Ok(if value == 0.0 { 0.0 } else { value }) + }; + let inclusive = match schema.get(inclusive_key) { + Some(value) => Some((bound_value(value)?, false)), + None => None, + }; + let exclusive = match schema.get(exclusive_key) { + Some(Value::Bool(is_exclusive)) => inclusive.map(|(value, _)| (value, *is_exclusive)), + Some(value) => Some((bound_value(value)?, true)), + None => None, + }; + + Ok(match (inclusive, exclusive) { + (None, bound) | (bound, None) => bound, + (Some(inclusive), Some(exclusive)) => { + let ordering = exclusive.0.total_cmp(&inclusive.0); + let exclusive_is_stricter = if is_lower { + ordering.is_gt() + } else { + ordering.is_lt() + }; + if exclusive_is_stricter || (ordering.is_eq() && exclusive.1 && !inclusive.1) { + Some(exclusive) + } else { + Some(inclusive) + } + } + }) +} + +fn check_numeric_bounds( + path: &str, + old_schema: &Map, + new_schema: &Map, + check_backward: bool, +) -> Vec { + let mut diagnostics = Vec::new(); + for (inclusive_key, exclusive_key, is_lower) in [ + ("minimum", "exclusiveMinimum", true), + ("maximum", "exclusiveMaximum", false), + ] { + if !old_schema.contains_key(inclusive_key) + && !old_schema.contains_key(exclusive_key) + && !new_schema.contains_key(inclusive_key) + && !new_schema.contains_key(exclusive_key) + { + continue; + } + + if (old_schema.get(exclusive_key).is_some_and(Value::is_boolean) + || new_schema.get(exclusive_key).is_some_and(Value::is_boolean)) + && (old_schema.get(inclusive_key) != new_schema.get(inclusive_key) + || old_schema.get(exclusive_key) != new_schema.get(exclusive_key)) + { + diagnostics.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + format!( + "changes Draft-04 boolean '{exclusive_key}' constraint; dialect semantics \ + cannot be inferred at this node" + ), + )); + continue; + } + + let old_bound = effective_numeric_bound(old_schema, inclusive_key, exclusive_key, is_lower); + let new_bound = effective_numeric_bound(new_schema, inclusive_key, exclusive_key, is_lower); + let (Ok(old_bound), Ok(new_bound)) = (old_bound, new_bound) else { + if old_schema.get(inclusive_key) != new_schema.get(inclusive_key) + || old_schema.get(exclusive_key) != new_schema.get(exclusive_key) + { + diagnostics.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + format!("changes non-numeric '{inclusive_key}'/'{exclusive_key}' constraints"), + )); + } + continue; + }; + + let (source, target) = if check_backward { + (old_bound, new_bound) + } else { + (new_bound, old_bound) + }; + let included = match (source, target) { + (_, None) => true, + (None, Some(_)) => false, + (Some(source), Some(target)) if is_lower => { + let ordering = source.0.total_cmp(&target.0); + ordering.is_gt() || (ordering.is_eq() && (!target.1 || source.1)) + } + (Some(source), Some(target)) => { + let ordering = source.0.total_cmp(&target.0); + ordering.is_lt() || (ordering.is_eq() && (!target.1 || source.1)) + } + }; + if !included { + diagnostics.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::BoundChanged, + format!("changes effective {inclusive_key}/{exclusive_key} bound incompatibly"), + )); + } + } + diagnostics +} + +fn check_constraint_compatibility( + path: &str, + old_prop_schema: &Map, + new_prop_schema: &Map, + check_tightening: bool, +) -> Vec { + // Every pair is checked whenever either definition carries it, never + // gated on `type`. Gating on the old schema's `type` missed a real + // narrowing whenever `type` was absent or written as an array, which + // reported such a change as fully compatible - the one direction of + // error a registry cannot tolerate. + const BOUNDS: &[(&str, &str)] = &[ + ("minLength", "maxLength"), + ("minItems", "maxItems"), + ("minProperties", "maxProperties"), + ("minContains", "maxContains"), + ]; + + let mut diagnostics = + check_numeric_bounds(path, old_prop_schema, new_prop_schema, check_tightening); + diagnostics.extend( + BOUNDS + .iter() + .filter(|(min_key, max_key)| { + [min_key, max_key].iter().any(|key| { + old_prop_schema.contains_key(**key) || new_prop_schema.contains_key(**key) + }) + }) + .flat_map(|(min_key, max_key)| { + check_min_max_constraint( + path, + old_prop_schema, + new_prop_schema, + min_key, + max_key, + check_tightening, + ) + }), + ); + diagnostics +} + +/// Handles keywords that only ever narrow `Valid(S)` when present. +/// +/// Whether two different values of such a keyword include one another is +/// undecidable in general - no implementation can compare two regexes - but +/// presence alone is decidable: adding the constraint narrows the accepted +/// set, removing it widens it. That is exactly the shape of the "Relaxing / +/// Tightening constraints" rows of gts-spec sec 4.5, so reporting both +/// directions as incompatible (as plain equality does) contradicts the table +/// for the common case of adding or dropping one of these keywords. +fn check_narrowing_constraints( + path: &str, + old_schema: &Map, + new_schema: &Map, + check_backward: bool, +) -> Vec { + const NARROWING: &[&str] = &["pattern", "format", "multipleOf"]; + + let mut errors: Vec = NARROWING + .iter() + .filter_map(|keyword| { + let old_value = old_schema.get(*keyword); + let new_value = new_schema.get(*keyword); + match (old_value, new_value) { + // `multipleOf` is a number, so the two spellings of one + // mathematical value are not a change. + (Some(old_value), Some(new_value)) if json_values_equal(old_value, new_value) => { + None + } + _ if old_value == new_value => None, + // Added: narrows, so forward-only. + (None, Some(_)) if check_backward => Some(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NarrowingConstraintChanged, + format!("adds '{keyword}' constraint"), + )), + // Removed: widens, so backward-only. + (Some(_), None) if !check_backward => Some(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NarrowingConstraintChanged, + format!("removes '{keyword}' constraint"), + )), + // Changed: inclusion between the two values is undecidable. + (Some(old_value), Some(new_value)) => Some(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + format!( + "changes '{keyword}' from {old_value} to {new_value}; inclusion \ + between the two cannot be proven" + ), + )), + // Added in the forward direction, or removed in the + // backward one: the change widens what this direction + // requires, so it is permitted. + (None, Some(_) | None) | (Some(_), None) => None, + } + }) + .collect(); + + // `uniqueItems` defaults to false, so its presence is not what matters: + // false -> true narrows and true -> false widens, both decidable. + let unique_items = |schema: &Map| { + schema + .get("uniqueItems") + .and_then(Value::as_bool) + .unwrap_or(false) + }; + let old_unique = unique_items(old_schema); + let new_unique = unique_items(new_schema); + if old_unique != new_unique && check_backward == new_unique { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NarrowingConstraintChanged, + format!( + "{} 'uniqueItems'", + if new_unique { "enables" } else { "disables" } + ), + )); + } + + errors +} + +fn check_type_compatibility( + path: &str, + old_schema: &Map, + new_schema: &Map, + check_backward: bool, +) -> Vec { + // `type` is a set of permitted primitive types. When it is absent, + // `const` and `enum` can still imply a finite set of effective types. + // Inclusion of the accepted-instance sets therefore follows inclusion of + // the type sets, which makes member order irrelevant and makes dropping + // a member - say the `null` of an `Option` - a narrowing rather than + // an unrelated change. + enum TypeSet { + Any, + Set(Vec), + Invalid, + } + + fn value_type(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + // JSON Schema's `integer` matches a number with a zero + // fractional part, so `1.0` is an integer. The test must be + // exact: a tolerance would also swallow tiny nonzero fractions + // such as `1e-20`, which no `integer` schema accepts. + Value::Number(number) + if number.is_i64() + || number.is_u64() + || number.as_f64().is_some_and(|value| value.fract() == 0.0) => + { + "integer" + } + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } + } + + fn type_set(schema: &Map) -> TypeSet { + match schema.get("type") { + Some(Value::String(name)) => TypeSet::Set(vec![name.clone()]), + Some(Value::Array(names)) => names + .iter() + .map(Value::as_str) + .collect::>>() + .map_or(TypeSet::Invalid, |names| { + TypeSet::Set(names.into_iter().map(str::to_owned).collect()) + }), + Some(_) => TypeSet::Invalid, + None => { + // With no `type`, the effective types are those of the + // values `const` and `enum` accept between them. + let values = accepted_value_set(schema); + values.map_or(TypeSet::Any, |values| { + let mut names = Vec::new(); + for value in &values { + let name = value_type(value).to_owned(); + if !names.contains(&name) { + names.push(name); + } + } + TypeSet::Set(names) + }) + } + } + } + + let old_type = old_schema.get("type"); + let new_type = new_schema.get("type"); + let (source_schema, target_schema) = if check_backward { + (old_schema, new_schema) + } else { + (new_schema, old_schema) + }; + + let compatible = match (type_set(source_schema), type_set(target_schema)) { + // A malformed `type` cannot be interpreted; fall back to equality. + (TypeSet::Invalid, _) | (_, TypeSet::Invalid) => old_type == new_type, + // An unconstrained target accepts every type the source permits. + (_, TypeSet::Any) => true, + // An unconstrained source permits types the target may not. + (TypeSet::Any, TypeSet::Set(_)) => false, + (TypeSet::Set(source_names), TypeSet::Set(target_names)) => { + source_names.iter().all(|name| { + target_names.contains(name) + || (name == "integer" && target_names.iter().any(|target| target == "number")) + }) + } + }; + + if compatible { + Vec::new() + } else { + vec![CompatibilityDiagnostic::new( + path, + CompatibilityFinding::TypeChanged, + format!( + "changes type incompatibly from {} to {}", + old_type.map_or_else(|| "any".to_owned(), Value::to_string), + new_type.map_or_else(|| "any".to_owned(), Value::to_string), + ), + )] + } +} + +/// The finite set of instances a level accepts through `const` and `enum`, +/// or `None` when neither keyword constrains it. +/// +/// An instance must satisfy every keyword present, so two coexisting +/// keywords accept their intersection - possibly nothing at all. +fn accepted_value_set(schema: &Map) -> Option> { + // A non-array `enum` is not a valid constraint and nothing can be read + // from it, which is what `as_array` returning `None` expresses here. + let enumeration = schema.get("enum").and_then(Value::as_array); + match (schema.get("const"), enumeration) { + (None, None) => None, + (Some(constant), None) => Some(vec![constant.clone()]), + (None, Some(values)) => Some(values.clone()), + (Some(constant), Some(values)) => Some( + values + .iter() + .filter(|value| json_values_equal(value, constant)) + .cloned() + .collect(), + ), + } +} + +/// Proves inclusion by validating a finite accepted-value set. +/// +/// A node that enumerates its instances through `const`/`enum` is included +/// in the target exactly when every enumerated value validates against the +/// target schema. Keyword comparison cannot see this: it reads a target +/// `minimum` that the enumerating side simply does not restate as a bound +/// that was widened, and reports a narrowing as a break. +/// +/// Only the positive answer is conclusive. A value that fails may still be +/// excluded by another constraint on the same node, so a caller that gets +/// `false` must fall back to comparing keywords. +fn enumerated_source_is_included(source: &Map, target: &Value) -> bool { + let Some(values) = accepted_value_set(source) else { + return false; + }; + let Ok(validator) = jsonschema::validator_for(target) else { + return false; + }; + values.iter().all(|value| validator.is_valid(value)) +} + +/// Compares the value sets `const` and `enum` impose, as one set. +/// +/// Both keywords restrict which concrete instances are accepted, so a +/// revision that moves between the two spellings only has a meaning when +/// they are read together: checking each keyword against its own +/// counterpart would read a keyword that is merely absent as an +/// unconstrained target and report the equivalent rewrite of +/// `{"const": 1}` into `{"enum": [1]}` as incompatible in both directions. +fn check_value_set_compatibility( + path: &str, + old_schema: &Map, + new_schema: &Map, + check_backward: bool, +) -> Vec { + let old_values = accepted_value_set(old_schema); + let new_values = accepted_value_set(new_schema); + // Backward checks Valid(old) ⊆ Valid(new); forward checks the reverse + // inclusion. Expanding the set is therefore backward-only. + let (source, target) = if check_backward { + (old_values.as_deref(), new_values.as_deref()) + } else { + (new_values.as_deref(), old_values.as_deref()) + }; + let finding = if old_schema.contains_key("enum") || new_schema.contains_key("enum") { + CompatibilityFinding::EnumChanged + } else { + CompatibilityFinding::ConstraintChanged + }; + + match (source, target) { + // An unconstrained target accepts every value the source permits. + (_, None) => Vec::new(), + (None, Some(_)) => vec![CompatibilityDiagnostic::new( + path, + finding, + format!( + "{} the 'const'/'enum' value constraint", + if check_backward { "adds" } else { "removes" } + ), + )], + (Some(source), Some(target)) => { + let incompatible_values: Vec<&Value> = source + .iter() + .filter(|value| { + !target + .iter() + .any(|accepted| json_values_equal(value, accepted)) + }) + .collect(); + if incompatible_values.is_empty() { + Vec::new() + } else { + vec![CompatibilityDiagnostic::new( + path, + finding, + format!( + "changes the 'const'/'enum' value set incompatibly: \ + {incompatible_values:?}" + ), + )] + } + } + } +} + +fn check_exact_constraints( + path: &str, + old_schema: &Map, + new_schema: &Map, +) -> Vec { + // Keywords whose two values cannot be ordered by inclusion, so equality + // is the only thing that can be proven. Numeric bounds live in + // [`check_constraint_compatibility`] and keywords that merely + // narrow when present live in [`check_narrowing_constraints`]; + // listing either here would report both directions as incompatible and + // contradict the "Relaxing / Tightening constraints" rows of sec 4.5. + // + // `patternProperties`, `unevaluatedProperties` and `propertyNames` stay + // here on purpose: they also decide the content model in + // [`classify_content_model`], and a level whose classification can + // change between two definitions is not something this checker attempts + // to reason about. + const EXACT_CONSTRAINTS: &[&str] = &[ + "additionalItems", + "prefixItems", + "patternProperties", + "unevaluatedProperties", + "contains", + "propertyNames", + "dependentRequired", + "dependentSchemas", + "dependencies", + "oneOf", + "anyOf", + "not", + "if", + "then", + "else", + "contentEncoding", + "contentMediaType", + ]; + + EXACT_CONSTRAINTS + .iter() + .filter(|keyword| old_schema.get(**keyword) != new_schema.get(**keyword)) + .map(|keyword| { + CompatibilityDiagnostic::new( + path, + CompatibilityFinding::ConstraintChanged, + format!("changes '{keyword}' constraint"), + ) + }) + .collect() +} + +/// Reports a `$ref` that survived resolution. +/// +/// `$defs`/`definitions` are deliberately absent from +/// [`check_exact_constraints`]: in every dialect they are containers +/// reachable only through `$ref` and never contribute to `Valid(S)` (§4.3), +/// so comparing them would reject changes that alter no accepted instance. +/// The reference itself is what carries the constraint, and +/// [`crate::store::GtsStore::is_compatible`] resolves references before +/// comparing. A `$ref` that is still present therefore means this node was +/// never resolved and nothing can be proven about its target - unless both +/// definitions name the same reference, which needs no resolution. +fn check_unresolved_ref( + path: &str, + old_schema: &Map, + new_schema: &Map, +) -> Vec { + let old_ref = old_schema.get("$ref").and_then(Value::as_str); + let new_ref = new_schema.get("$ref").and_then(Value::as_str); + if old_ref == new_ref { + return Vec::new(); + } + vec![CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + format!( + "has an unresolved '$ref' ({} vs {}); resolve the reference before comparing, \ + as compatibility depends on the effective resolved schemas", + old_ref.unwrap_or("none"), + new_ref.unwrap_or("none"), + ), + )] +} + +fn check_schema_node_compatibility( + old_schema: &Value, + new_schema: &Value, + path: &str, + check_backward: bool, + dialects: DialectSupport, + inherited_unproven: UnprovenPaths, + errors: &mut Vec, +) { + // Locations an ancestor could not prove stay unprovable here; add + // whatever this node's own `allOf` composition leaves undecided. + let mut unproven = inherited_unproven; + let old_effective = if old_schema.get("allOf").is_some() { + let (effective, paths) = flatten_effective(old_schema); + unproven.extend(paths); + effective + } else { + old_schema.clone() + }; + let new_effective = if new_schema.get("allOf").is_some() { + let (effective, paths) = flatten_effective(new_schema); + unproven.extend(paths); + effective + } else { + new_schema.clone() + }; + + let (source, target) = if check_backward { + (&old_effective, &new_effective) + } else { + (&new_effective, &old_effective) + }; + let source_boolean = boolean_schema_value(source); + let target_boolean = boolean_schema_value(target); + if source_boolean == Some(false) || target_boolean == Some(true) { + return; + } + if source_boolean == Some(true) || target_boolean == Some(false) { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::ConstraintChanged, + "changes boolean schema incompatibly".to_owned(), + )); + return; + } + + let (Some(old_map), Some(new_map)) = (old_effective.as_object(), new_effective.as_object()) + else { + if old_effective != new_effective { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::ConstraintChanged, + "changes a schema that is not an object".to_owned(), + )); + } + return; + }; + if unproven.contains("") { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + "contains an allOf intersection that the compatibility checker cannot prove".to_owned(), + )); + return; + } + + let source_map = if check_backward { old_map } else { new_map }; + if enumerated_source_is_included(source_map, target) { + return; + } + + errors.extend(check_type_compatibility( + path, + old_map, + new_map, + check_backward, + )); + errors.extend(check_value_set_compatibility( + path, + old_map, + new_map, + check_backward, + )); + errors.extend(check_exact_constraints(path, old_map, new_map)); + errors.extend(check_unresolved_ref(path, old_map, new_map)); + errors.extend(check_narrowing_constraints( + path, + old_map, + new_map, + check_backward, + )); + errors.extend(check_constraint_compatibility( + path, + old_map, + new_map, + check_backward, + )); + + let is_object_schema = |schema: &Map| { + schema.get("type").and_then(Value::as_str) == Some("object") + || schema.contains_key("properties") + || schema.contains_key("required") + || schema.contains_key("additionalProperties") + || schema.contains_key("unevaluatedProperties") + || schema.contains_key("patternProperties") + || schema.contains_key("propertyNames") + }; + if is_object_schema(old_map) || is_object_schema(new_map) { + check_object_compatibility( + old_map, + new_map, + path, + check_backward, + dialects, + &unproven, + errors, + ); + } + + match (old_map.get("items"), new_map.get("items")) { + (Some(old_items), Some(new_items)) => check_schema_node_compatibility( + old_items, + new_items, + &format!("{path}[]"), + check_backward, + dialects, + unproven_below(&unproven, "[]"), + errors, + ), + (None, Some(_)) if check_backward => { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::ConstraintChanged, + "adds an array items constraint".to_owned(), + )); + } + (Some(_), None) if !check_backward => errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::ConstraintChanged, + "removes an array items constraint".to_owned(), + )), + _ => {} + } +} + +fn check_object_compatibility( + old_schema: &Map, + new_schema: &Map, + path: &str, + check_backward: bool, + dialects: DialectSupport, + unproven: &UnprovenPaths, + errors: &mut Vec, +) { + let empty = Map::new(); + let old_props = old_schema + .get("properties") + .and_then(Value::as_object) + .unwrap_or(&empty); + let new_props = new_schema + .get("properties") + .and_then(Value::as_object) + .unwrap_or(&empty); + + let old_required: HashSet<&str> = old_schema + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + let new_required: HashSet<&str> = new_schema + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + + let mut required_difference: Vec<&str> = if check_backward { + new_required.difference(&old_required).copied().collect() + } else { + old_required.difference(&new_required).copied().collect() + }; + required_difference.sort_unstable(); + if !required_difference.is_empty() { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::RequiredChanged, + format!( + "{} required properties: {required_difference:?}", + if check_backward { "adds" } else { "removes" } + ), + )); + } + + let old_model = classify_content_model(old_schema, dialects.old_unevaluated); + let new_model = classify_content_model(new_schema, dialects.new_unevaluated); + let (source_model, target_model) = if check_backward { + (old_model, new_model) + } else { + (new_model, old_model) + }; + let partial_constraints_equal = + partial_content_constraints_equal(old_schema, new_schema, dialects); + if !content_model_is_subset(source_model, target_model) { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::ContentModelChanged, + format!( + "changes the content model incompatibly from {} to {}", + old_model.label(), + new_model.label(), + ), + )); + } else if source_model == ContentModel::Partial + && target_model == ContentModel::Partial + && !partial_constraints_equal + { + errors.push(CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + "changes partially open content constraints; inclusion cannot be proven".to_owned(), + )); + } + + for (name, old_property) in old_props { + let property_path = if path == "$" { + format!("$.{name}") + } else { + format!("{path}.{name}") + }; + if let Some(new_property) = new_props.get(name) { + check_schema_node_compatibility( + old_property, + new_property, + &property_path, + check_backward, + dialects, + unproven_below(unproven, &format!(".{name}")), + errors, + ); + } else if let Some(counterpart) = additional_properties_schema(new_schema) { + // A partially open counterpart still says something about this + // name through `additionalProperties`, so compare against that + // schema rather than reading the property as unmatched. + check_schema_node_compatibility( + old_property, + counterpart, + &property_path, + check_backward, + dialects, + UnprovenPaths::new(), + errors, + ); + } else { + let incompatible_model = if check_backward { + new_model != ContentModel::Open + } else { + new_model != ContentModel::Closed + }; + if incompatible_model { + errors.push(property_change_error(path, name, true, new_model)); + } + } + } + + for (name, new_property) in new_props + .iter() + .filter(|(name, _)| !old_props.contains_key(*name)) + { + if let Some(counterpart) = additional_properties_schema(old_schema) { + let property_path = if path == "$" { + format!("$.{name}") + } else { + format!("{path}.{name}") + }; + check_schema_node_compatibility( + counterpart, + new_property, + &property_path, + check_backward, + dialects, + UnprovenPaths::new(), + errors, + ); + continue; + } + let incompatible_model = if check_backward { + old_model != ContentModel::Closed + } else { + old_model != ContentModel::Open + }; + if incompatible_model { + errors.push(property_change_error(path, name, false, old_model)); + } + } +} + +/// The schema an object level applies to names it does not declare, when +/// that is an actual schema rather than an open or closed boolean. +fn additional_properties_schema(schema: &Map) -> Option<&Value> { + schema + .get("additionalProperties") + .filter(|value| boolean_schema_value(value).is_none()) +} + +fn classify_content_model(schema: &Map, supports_unevaluated: bool) -> ContentModel { + let pattern_properties = schema + .get("patternProperties") + .and_then(Value::as_object) + .filter(|patterns| !patterns.is_empty()); + let patterns_all_open = pattern_properties.is_some_and(|patterns| { + patterns + .values() + .all(|constraint| boolean_schema_value(constraint) == Some(true)) + }); + let patterns_all_closed = pattern_properties.is_some_and(|patterns| { + patterns + .values() + .all(|constraint| boolean_schema_value(constraint) == Some(false)) + }); + let property_names_model = schema.get("propertyNames").and_then(boolean_schema_value); + if property_names_model == Some(false) { + return ContentModel::Closed; + } + + // `unevaluatedProperties` is the fallback only when this level does not + // already evaluate unmatched names through `additionalProperties`. + let undeclared_fallback = schema.get("additionalProperties").or_else(|| { + supports_unevaluated + .then(|| schema.get("unevaluatedProperties")) + .flatten() + }); + let fallback_model = undeclared_fallback.map_or(Some(true), boolean_schema_value); + let constrains_property_names = + property_names_model.is_none() && schema.contains_key("propertyNames"); + let constrains_fallback = fallback_model.is_none(); + + if pattern_properties.is_some() { + if fallback_model == Some(false) && patterns_all_closed { + ContentModel::Closed + } else if fallback_model == Some(true) && patterns_all_open && !constrains_property_names { + ContentModel::Open + } else { + ContentModel::Partial + } + } else if fallback_model == Some(false) { + ContentModel::Closed + } else if constrains_property_names || constrains_fallback { + ContentModel::Partial + } else { + ContentModel::Open + } +} + +const fn content_model_is_subset(source: ContentModel, target: ContentModel) -> bool { + matches!( + (source, target), + (ContentModel::Closed, _) + | (_, ContentModel::Open) + | (ContentModel::Partial, ContentModel::Partial) + ) +} + +fn partial_content_constraints_equal( + old_schema: &Map, + new_schema: &Map, + dialects: DialectSupport, +) -> bool { + let normalize_additional = |schema: &Map| { + schema + .get("additionalProperties") + .cloned() + .unwrap_or(Value::Bool(true)) + }; + let normalize_unevaluated = |schema: &Map, supported: bool| { + if supported { + schema + .get("unevaluatedProperties") + .cloned() + .unwrap_or(Value::Bool(true)) + } else { + Value::Bool(true) + } + }; + + normalize_additional(old_schema) == normalize_additional(new_schema) + && old_schema.get("patternProperties") == new_schema.get("patternProperties") + && old_schema.get("propertyNames") == new_schema.get("propertyNames") + && normalize_unevaluated(old_schema, dialects.old_unevaluated) + == normalize_unevaluated(new_schema, dialects.new_unevaluated) +} + +fn property_change_error( + path: &str, + property: &str, + removed: bool, + model: ContentModel, +) -> CompatibilityDiagnostic { + let operation = if removed { "removes" } else { "adds" }; + if model == ContentModel::Partial { + CompatibilityDiagnostic::new( + path, + CompatibilityFinding::NotProvable, + format!( + "{operation} property '{property}', but compatibility cannot be proven for \ + the partially open object level" + ), + ) + } else { + CompatibilityDiagnostic::new( + path, + if removed { + CompatibilityFinding::PropertyRemoved + } else { + CompatibilityFinding::PropertyAdded + }, + format!( + "{operation} property '{property}' in a {} model", + model.label() + ), + ) + } +} + +/// Checks `Valid(old) ⊆ Valid(new)` and renders each reason as a string. +/// +/// The two schemas MUST already be `$ref`-resolved; see +/// [`crate::store::GtsStore::compare_documents`], which resolves and then +/// calls this. Prefer [`check_backward_diagnostics`] when the caller +/// needs the offending schema location rather than prose. +#[must_use] +pub fn check_backward_compatibility( + old_schema: &Value, + new_schema: &Value, +) -> (CompatibilityVerdict, Vec) { + let (verdict, diagnostics) = check_backward_diagnostics(old_schema, new_schema); + (verdict, render_diagnostics(&diagnostics)) +} + +/// Checks `Valid(new) ⊆ Valid(old)` and renders each reason as a string. +/// +/// See [`check_backward_compatibility`] for the resolution +/// requirement. +#[must_use] +pub fn check_forward_compatibility( + old_schema: &Value, + new_schema: &Value, +) -> (CompatibilityVerdict, Vec) { + let (verdict, diagnostics) = check_forward_diagnostics(old_schema, new_schema); + (verdict, render_diagnostics(&diagnostics)) +} + +/// Checks `Valid(old) ⊆ Valid(new)`, reporting each reason with its schema +/// location. +#[must_use] +pub fn check_backward_diagnostics( + old_schema: &Value, + new_schema: &Value, +) -> (CompatibilityVerdict, Vec) { + check_inclusion(old_schema, new_schema, true) +} + +/// Checks `Valid(new) ⊆ Valid(old)`, reporting each reason with its schema +/// location. +#[must_use] +pub fn check_forward_diagnostics( + old_schema: &Value, + new_schema: &Value, +) -> (CompatibilityVerdict, Vec) { + check_inclusion(old_schema, new_schema, false) +} + +/// Checks `Valid(subset) ⊆ Valid(superset)` and reports why it does not hold. +/// +/// This is the primitive both compatibility relations are built on, named after +/// neither: evolution reads it as one of its modes (spec sec 4.3) while +/// derivation reads it as the single relation it has (sec 4.1, which states +/// that a derivation is never qualified by a mode name). +#[must_use] +pub fn check_accepted_set_inclusion( + subset: &Value, + superset: &Value, +) -> (CompatibilityVerdict, Vec) { + check_inclusion(subset, superset, true) +} + +/// Reports inclusion between `old_schema` and `new_schema` in the direction +/// `check_backward` selects. +fn check_inclusion( + old_schema: &Value, + new_schema: &Value, + check_backward: bool, +) -> (CompatibilityVerdict, Vec) { + let mut errors = Vec::new(); + let declared_old = old_schema.get("$schema").and_then(Value::as_str); + let declared_new = new_schema.get("$schema").and_then(Value::as_str); + + // Only a genuine change of declared dialect is reported. An omitted + // `$schema` means "whatever dialect the implementation applies" (sec 11 + // makes GTS dialect-agnostic), so it is read as the dialect the other + // definition declares rather than as a difference - otherwise merely + // starting to declare a dialect that was already in effect would be + // reported as incompatible in both directions. + if let (Some(old_dialect), Some(new_dialect)) = (declared_old, declared_new) + && old_dialect != new_dialect + { + errors.push(CompatibilityDiagnostic::new( + "$", + CompatibilityFinding::DialectChanged, + format!("changes JSON Schema dialect from {old_dialect} to {new_dialect}"), + )); + } + let effective_old = declared_old.or(declared_new); + let effective_new = declared_new.or(declared_old); + check_schema_node_compatibility( + old_schema, + new_schema, + "$", + check_backward, + DialectSupport { + old_unevaluated: dialect_supports_unevaluated(effective_old), + new_unevaluated: dialect_supports_unevaluated(effective_new), + }, + UnprovenPaths::new(), + &mut errors, + ); + (CompatibilityVerdict::from_diagnostics(&errors), errors) +} + +/// Whether `unevaluatedProperties` is evaluated under `dialect`. +/// +/// The keyword exists from Draft 2019-09 on; earlier dialects ignore it as +/// an unknown annotation. An omitted `$schema` means "whatever dialect the +/// implementation applies" - GTS is dialect-agnostic (sec 11) and names no +/// default - and this implementation validates instances with +/// [`jsonschema::validator_for`], which falls back to Draft 2020-12. Reading +/// an omitted dialect as pre-2019-09 would therefore make this checker +/// contradict the validator running in the same process: a level closed by +/// `unevaluatedProperties: false` would be classified open, which reverses +/// both verdicts for an added optional property. +fn dialect_supports_unevaluated(dialect: Option<&str>) -> bool { + dialect.is_none_or(|value| value.contains("2019-09") || value.contains("2020-12")) +} + +/// Classifies the content model of every object level of a schema. +/// +/// The schema MUST already be `$ref`-resolved: gts-spec §4.4 requires the +/// content model to be read from the fully resolved effective schema, +/// because `unevaluatedProperties`, `patternProperties`, `propertyNames`, a +/// nontrivial schema-valued `additionalProperties`, or a conjunctive +/// subschema reached through `allOf` or `$ref` can all decide whether +/// undeclared properties are accepted. +/// [`crate::store::GtsStore::compare_documents`] resolves before calling +/// this. +/// +/// A level is reported once, at the location where it appears in the +/// document. Levels reached only through `oneOf`, `anyOf`, `not`, or +/// `if`/`then`/`else` are not reported: an instance satisfies one branch +/// rather than all of them, so such a level has no single content model. +#[must_use] +pub fn classify_object_levels(schema: &Value) -> Vec { + let dialect = schema.get("$schema").and_then(Value::as_str); + let supports_unevaluated = dialect_supports_unevaluated(dialect); + let mut levels = Vec::new(); + collect_object_levels(schema, "$", supports_unevaluated, &mut levels); + levels +} + +fn collect_object_levels( + schema: &Value, + path: &str, + supports_unevaluated: bool, + levels: &mut Vec, +) { + let effective = if schema.get("allOf").is_some() { + flatten_schema(schema) + } else { + schema.clone() + }; + let Some(map) = effective.as_object() else { + return; + }; + + let declares_object = map.get("type").and_then(Value::as_str) == Some("object") + || map.contains_key("properties") + || map.contains_key("additionalProperties") + || map.contains_key("unevaluatedProperties") + || map.contains_key("patternProperties") + || map.contains_key("propertyNames"); + if declares_object { + levels.push(ObjectLevel { + path: path.to_owned(), + content_model: classify_content_model(map, supports_unevaluated), + }); + } + + if let Some(properties) = map.get("properties").and_then(Value::as_object) { + for (name, property) in properties { + let property_path = if path == "$" { + format!("$.{name}") + } else { + format!("{path}.{name}") + }; + collect_object_levels(property, &property_path, supports_unevaluated, levels); + } + } + if let Some(items) = map.get("items") { + collect_object_levels(items, &format!("{path}[]"), supports_unevaluated, levels); + } +} +/// Compares two JSON values the way JSON Schema compares instances. +/// +/// `serde_json`'s `PartialEq` distinguishes the integer and float +/// representations of a number, but JSON Schema equality - the relation `const` +/// and `enum` are defined in terms of - compares numbers by mathematical +/// value, so `1` and `1.0` denote the same instance. Composites +/// compare member by member, which makes the numeric rule apply at any depth; +/// every other value type compares as `serde_json` already does. +fn json_values_equal(left: &Value, right: &Value) -> bool { + match (left, right) { + (Value::Number(left), Value::Number(right)) => json_numbers_equal(left, right), + (Value::Array(left), Value::Array(right)) => { + left.len() == right.len() + && left + .iter() + .zip(right.iter()) + .all(|(left, right)| json_values_equal(left, right)) + } + // Object member order carries no meaning, so equal length plus a match + // for every key of one side is equality. + (Value::Object(left), Value::Object(right)) => { + left.len() == right.len() + && left.iter().all(|(key, left)| { + right + .get(key) + .is_some_and(|right| json_values_equal(left, right)) + }) + } + _ => left == right, + } +} + +/// Compares two JSON numbers by mathematical value. +#[allow( + clippy::float_cmp, + reason = "JSON Schema equality is exact equality of the mathematical value" +)] +fn json_numbers_equal(left: &serde_json::Number, right: &serde_json::Number) -> bool { + // Integers are compared as integers: routing them through `f64` would round + // the 64-bit values a double cannot represent exactly and call two distinct + // numbers equal. + if let (Some(left), Some(right)) = (left.as_u64(), right.as_u64()) { + return left == right; + } + if let (Some(left), Some(right)) = (left.as_i64(), right.as_i64()) { + return left == right; + } + + let left_integer = left.is_u64() || left.is_i64(); + let right_integer = right.is_u64() || right.is_i64(); + // Two integers that neither comparison above could pair up are one negative + // value and one above `i64::MAX`, so they are not equal. + if left_integer && right_integer { + return false; + } + // One integer and one float. The pair is compared exactly rather than by + // converting both sides to `f64`, which would round `2^53 + 1` down to + // `2^53` and report two different mathematical values - two different + // accepted-instance sets - as equal. This is the comparator `jsonschema` + // applies to a mixed pair when it validates the same instance. + if left_integer { + return right + .as_f64() + .is_some_and(|right| integer_equals_float(left, right)); + } + if right_integer { + return left + .as_f64() + .is_some_and(|left| integer_equals_float(right, left)); + } + + match (left.as_f64(), right.as_f64()) { + (Some(left), Some(right)) => left == right, + // Not representable as `f64`, which needs `serde_json`'s + // `arbitrary_precision`; the stored representation is all that is left + // to compare. + _ => left == right, + } +} + +/// Compares an integer-valued JSON number to a float, exactly. +fn integer_equals_float(integer: &serde_json::Number, float: f64) -> bool { + if let Some(integer) = integer.as_u64() { + return NumCmp::num_eq(integer, float); + } + integer + .as_i64() + .is_some_and(|integer| NumCmp::num_eq(integer, float)) +} + +fn render_diagnostics(diagnostics: &[CompatibilityDiagnostic]) -> Vec { + diagnostics + .iter() + .map(std::string::ToString::to_string) + .collect() +} + +#[cfg(test)] +#[path = "schema_evolution_test.rs"] +mod schema_evolution_test; diff --git a/gts/src/schema_evolution_test.rs b/gts/src/schema_evolution_test.rs new file mode 100644 index 0000000..e72bd0a --- /dev/null +++ b/gts/src/schema_evolution_test.rs @@ -0,0 +1,1542 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] +use super::*; +use serde_json::json; +use std::collections::HashMap; + +// Helper struct for compatibility results +#[derive(Debug, Default)] +#[allow(clippy::struct_field_names)] +struct CompatibilityResult { + backward_compatibility: CompatibilityVerdict, + forward_compatibility: CompatibilityVerdict, + full_compatibility: CompatibilityVerdict, +} + +// Helper function to check schema compatibility +fn check_schema_compatibility( + old_schema: &serde_json::Value, + new_schema: &serde_json::Value, +) -> CompatibilityResult { + let (backward_compatibility, _) = check_backward_compatibility(old_schema, new_schema); + let (forward_compatibility, _) = check_forward_compatibility(old_schema, new_schema); + let full_compatibility = + CompatibilityVerdict::full(backward_compatibility, forward_compatibility); + + CompatibilityResult { + backward_compatibility, + forward_compatibility, + full_compatibility, + } +} + +#[test] +fn test_compatibility_verdict_serialization_and_full_derivation() { + assert_eq!( + serde_json::to_value(CompatibilityVerdict::Compatible).expect("serialize verdict"), + json!("compatible") + ); + assert_eq!( + serde_json::to_value(CompatibilityVerdict::Incompatible).expect("serialize verdict"), + json!("incompatible") + ); + assert_eq!( + serde_json::to_value(CompatibilityVerdict::Unknown).expect("serialize verdict"), + json!("unknown") + ); + assert_eq!(CompatibilityVerdict::Unknown.to_string(), "unknown"); + + assert_eq!( + CompatibilityVerdict::full( + CompatibilityVerdict::Compatible, + CompatibilityVerdict::Compatible + ), + CompatibilityVerdict::Compatible + ); + assert_eq!( + CompatibilityVerdict::full( + CompatibilityVerdict::Compatible, + CompatibilityVerdict::Unknown + ), + CompatibilityVerdict::Unknown + ); + assert_eq!( + CompatibilityVerdict::full( + CompatibilityVerdict::Unknown, + CompatibilityVerdict::Incompatible + ), + CompatibilityVerdict::Incompatible + ); +} + +#[test] +fn test_check_schema_compatibility_identical() { + let schema1 = json!({ + "type": "object", + "properties": { + "name": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&schema1, &schema1); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_compatible()); +} + +#[test] +fn test_check_schema_compatibility_added_optional_property() { + let old_schema = json!({ + "type": "object", + "properties": { + "name": {"type": "string"} + } + }); + + let new_schema = json!({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + // An open model already accepted arbitrary `email` values; declaring + // it narrows that set. + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); +} + +#[test] +fn test_check_schema_compatibility_added_required_property() { + let old_schema = json!({ + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"] + }); + + let new_schema = json!({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + }, + "required": ["name", "email"] + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + // Adding required property is not backward compatible + assert!(result.backward_compatibility.is_incompatible()); +} + +#[test] +fn test_check_schema_compatibility_removed_property() { + let old_schema = json!({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }); + + let new_schema = json!({ + "type": "object", + "properties": { + "name": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); +} + +#[test] +fn test_check_schema_compatibility_enum_expansion() { + let old_schema = json!({ + "type": "string", + "enum": ["active", "inactive"] + }); + + let new_schema = json!({ + "type": "string", + "enum": ["active", "inactive", "pending"] + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); + assert!(result.full_compatibility.is_incompatible()); +} + +#[test] +fn test_check_schema_compatibility_enum_reduction() { + let old_schema = json!({ + "type": "string", + "enum": ["active", "inactive", "pending"] + }); + + let new_schema = json!({ + "type": "string", + "enum": ["active", "inactive"] + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_incompatible()); +} + +#[test] +fn test_check_schema_compatibility_type_change() { + let old_schema = json!({ + "type": "string" + }); + + let new_schema = json!({ + "type": "number" + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_incompatible()); +} + +#[test] +fn test_check_schema_compatibility_constraint_tightening() { + let old_schema = json!({ + "type": "number", + "minimum": 0 + }); + + let new_schema = json!({ + "type": "number", + "minimum": 10 + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); +} + +#[test] +fn test_check_schema_compatibility_constraint_relaxing() { + let old_schema = json!({ + "type": "number", + "maximum": 100 + }); + + let new_schema = json!({ + "type": "number", + "maximum": 200 + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + // Relaxing maximum is backward compatible + assert!(result.backward_compatibility.is_compatible()); +} + +#[test] +fn test_check_schema_compatibility_nested_objects() { + let old_schema = json!({ + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + }); + + let new_schema = json!({ + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + } + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); +} + +#[test] +fn test_check_schema_compatibility_string_length_constraints() { + let old_schema = json!({ + "type": "string", + "minLength": 1, + "maxLength": 100 + }); + + let new_schema = json!({ + "type": "string", + "minLength": 5, + "maxLength": 50 + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); +} + +#[test] +fn test_check_schema_compatibility_array_length_constraints() { + let old_schema = json!({ + "type": "array", + "minItems": 1, + "maxItems": 10 + }); + + let new_schema = json!({ + "type": "array", + "minItems": 2, + "maxItems": 5 + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); +} + +#[test] +fn test_compatibility_result_default() { + let result = CompatibilityResult::default(); + assert!(result.backward_compatibility.is_unknown()); + assert!(result.forward_compatibility.is_unknown()); + assert!(result.full_compatibility.is_unknown()); +} + +#[test] +fn test_compatibility_result_fully_compatible() { + let result = CompatibilityResult { + backward_compatibility: CompatibilityVerdict::Compatible, + forward_compatibility: CompatibilityVerdict::Compatible, + full_compatibility: CompatibilityVerdict::Compatible, + }; + assert!(result.full_compatibility.is_compatible()); +} + +#[test] +fn test_check_schema_compatibility_enum_reordered() { + let old_schema = json!({ + "type": "string", + "enum": ["a", "b", "c"] + }); + + let new_schema = json!({ + "type": "string", + "enum": ["c", "a", "b"] + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_compatible()); +} + +#[test] +fn test_check_schema_compatibility_nested_required_added() { + let old_schema = json!({ + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"] + } + }, + "required": ["user"] + }); + + let new_schema = json!({ + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + }, + "required": ["name", "email"] + } + }, + "required": ["user"] + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + // Adding nested required is not backward compatible + assert!(result.backward_compatibility.is_incompatible()); +} + +#[test] +fn test_check_schema_compatibility_allof_flatten_equivalence() { + let direct = json!({ + "type": "object", + "properties": { + "id": {"type": "string"}, + "value": {"type": "number"} + }, + "required": ["id"] + }); + + let via_allof = json!({ + "allOf": [ + { + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"] + }, + { + "type": "object", + "properties": {"value": {"type": "number"}} + } + ] + }); + + // Either direction should be fully compatible + let r1 = check_schema_compatibility(&direct, &via_allof); + assert!(r1.backward_compatibility.is_compatible()); + assert!(r1.forward_compatibility.is_compatible()); + assert!(r1.full_compatibility.is_compatible()); + + let r2 = check_schema_compatibility(&via_allof, &direct); + assert!(r2.backward_compatibility.is_compatible()); + assert!(r2.forward_compatibility.is_compatible()); + assert!(r2.full_compatibility.is_compatible()); +} + +#[test] +fn test_check_schema_compatibility_removed_required() { + let old_schema = json!({ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"] + }); + + let new_schema = json!({ + "type": "object", + "properties": {"name": {"type": "string"}} + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + // Removing required is forward-incompatible + assert!(result.forward_compatibility.is_incompatible()); +} + +#[test] +fn test_closed_model_optional_addition_is_not_fully_compatible() { + let old_schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"} + } + }); + let new_schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); + assert!(result.full_compatibility.is_incompatible()); +} + +#[test] +fn test_additional_properties_change_without_declared_properties_is_detected() { + let old_schema = json!({"type": "object"}); + let new_schema = json!({ + "type": "object", + "additionalProperties": false + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_incompatible()); +} + +#[test] +fn test_required_change_without_declared_properties_is_detected() { + let old_schema = json!({"type": "object"}); + let new_schema = json!({ + "type": "object", + "required": ["value"] + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_incompatible()); +} + +#[test] +fn test_removing_enum_constraint_is_not_fully_compatible() { + let old_schema = json!({ + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["active", "inactive"]} + } + }); + let new_schema = json!({ + "type": "object", + "properties": { + "status": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); + assert!(result.full_compatibility.is_incompatible()); +} + +#[test] +fn test_adding_enum_constraint_is_forward_only() { + let old_schema = json!({"type": "string"}); + let new_schema = json!({ + "type": "string", + "enum": ["active", "inactive"] + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_incompatible()); +} + +#[test] +fn test_adding_and_removing_const_are_directional() { + let added = property_change( + json!({"type": "integer"}), + json!({"type": "integer", "const": 1}), + ); + assert!(added.backward_compatibility.is_incompatible()); + assert!(added.forward_compatibility.is_compatible()); + + let removed = property_change( + json!({"type": "integer", "const": 1}), + json!({"type": "integer"}), + ); + assert!(removed.backward_compatibility.is_compatible()); + assert!(removed.forward_compatibility.is_incompatible()); +} + +/// `const` and `enum` constrain the same thing, so a revision that moves +/// between the two spellings must be read as one value set. +#[test] +fn test_const_and_enum_form_one_value_set() { + // Valid({"const": 1}) = Valid({"enum": [1]}) = {1}. + let rewritten = property_change(json!({"const": 1}), json!({"enum": [1]})); + assert!(rewritten.full_compatibility.is_compatible()); + + let rewritten_back = property_change(json!({"enum": [1]}), json!({"const": 1})); + assert!(rewritten_back.full_compatibility.is_compatible()); + + // Widening the singleton into a larger set is backward-only. + let widened = property_change(json!({"const": 1}), json!({"enum": [1, 2]})); + assert!(widened.backward_compatibility.is_compatible()); + assert!(widened.forward_compatibility.is_incompatible()); + + // Narrowing an enum down to one of its members is forward-only. + let narrowed = property_change(json!({"enum": [1, 2]}), json!({"const": 1})); + assert!(narrowed.backward_compatibility.is_incompatible()); + assert!(narrowed.forward_compatibility.is_compatible()); + + // A value outside the old set is incompatible in either direction. + let moved = property_change(json!({"const": 1}), json!({"enum": [2]})); + assert!(moved.backward_compatibility.is_incompatible()); + assert!(moved.forward_compatibility.is_incompatible()); + + // Both keywords at once accept only what satisfies both. + let intersected = property_change(json!({"const": 1, "enum": [1, 2]}), json!({"const": 1})); + assert!(intersected.full_compatibility.is_compatible()); +} + +/// JSON Schema compares values by mathematical value, so the integer and +/// float spellings of one number denote the same instance. +#[test] +fn test_value_sets_use_json_schema_equality() { + let respelled = property_change(json!({"const": 1}), json!({"enum": [1.0]})); + assert!(respelled.full_compatibility.is_compatible()); + + // The rule applies at any depth inside a composite value. + let nested = property_change( + json!({"const": {"a": [1, {"b": 2}]}}), + json!({"const": {"a": [1.0, {"b": 2.0}]}}), + ); + assert!(nested.full_compatibility.is_compatible()); + + // Narrowing still has to be seen through the respelling. + let narrowed = property_change(json!({"enum": [1, 2]}), json!({"const": 2.0})); + assert!(narrowed.backward_compatibility.is_incompatible()); + assert!(narrowed.forward_compatibility.is_compatible()); + + // Equal mathematical value is not equal representation of anything else: + // a different number, a different type, or a differing member count all + // remain distinct values. + for (old_value, new_value) in [ + (json!(1), json!(1.5)), + (json!(1), json!("1")), + (json!(1), json!(true)), + (json!([1]), json!([1, 1])), + (json!({"a": 1}), json!({"a": 1, "b": 1})), + ] { + let moved = property_change(json!({"const": old_value}), json!({"const": new_value})); + assert!( + moved.backward_compatibility.is_incompatible(), + "{old_value} vs {new_value}" + ); + assert!( + moved.forward_compatibility.is_incompatible(), + "{old_value} vs {new_value}" + ); + } + + // The same equality decides whether a narrowing keyword changed at all. + let respelled_multiple_of = + property_change(json!({"multipleOf": 5}), json!({"multipleOf": 5.0})); + assert!(respelled_multiple_of.full_compatibility.is_compatible()); +} + +/// Comparing a mixed integer/float pair has to be exact: rounding both sides +/// to `f64` would erase the difference between `2^53 + 1` and `2^53`. +#[test] +fn test_value_set_equality_is_exact_across_number_types() { + // 9007199254740993 is 2^53 + 1, which no `f64` represents. + let rounded = property_change( + json!({"const": 9_007_199_254_740_993_i64}), + json!({"enum": [9_007_199_254_740_992.0_f64]}), + ); + assert!(rounded.backward_compatibility.is_incompatible()); + assert!(rounded.forward_compatibility.is_incompatible()); + + // 2^53 itself is exactly representable, so its two spellings are one + // value and the comparison must still see that. + let exact = property_change( + json!({"const": 9_007_199_254_740_992_i64}), + json!({"enum": [9_007_199_254_740_992.0_f64]}), + ); + assert!(exact.full_compatibility.is_compatible()); + + // The same number kept as an integer on both sides. + let integral = property_change( + json!({"const": 9_007_199_254_740_993_i64}), + json!({"enum": [9_007_199_254_740_993_i64]}), + ); + assert!(integral.full_compatibility.is_compatible()); + + // A `u64` above `i64::MAX` and a negative number share no + // representation to be compared through, and are not equal. + let mixed_signedness = property_change( + json!({"const": 18_446_744_073_709_551_615_u64}), + json!({"const": -1_i64}), + ); + assert!(mixed_signedness.backward_compatibility.is_incompatible()); + assert!(mixed_signedness.forward_compatibility.is_incompatible()); +} + +#[test] +fn test_boolean_schemas_follow_set_inclusion() { + let narrowed = check_schema_compatibility(&json!(true), &json!(false)); + assert!(narrowed.backward_compatibility.is_incompatible()); + assert!(narrowed.forward_compatibility.is_compatible()); + + let widened = check_schema_compatibility(&json!(false), &json!(true)); + assert!(widened.backward_compatibility.is_compatible()); + assert!(widened.forward_compatibility.is_incompatible()); + + // Object spellings of the boolean schemas have identical semantics. + let equivalent = check_schema_compatibility(&json!(true), &json!({})); + assert!(equivalent.full_compatibility.is_compatible()); +} + +#[test] +fn test_closed_model_optional_removal_is_forward_only() { + let old_schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }); + let new_schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + assert!(result.full_compatibility.is_incompatible()); +} + +#[test] +fn test_unevaluated_properties_closes_2020_12_object() { + let old_schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "unevaluatedProperties": false, + "properties": {"name": {"type": "string"}} + }); + let new_schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "unevaluatedProperties": false, + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); +} + +#[test] +fn test_unevaluated_properties_is_ignored_by_draft_07() { + let old_schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "unevaluatedProperties": false, + "properties": {"name": {"type": "string"}} + }); + let new_schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "unevaluatedProperties": false, + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); +} + +/// A partially open level still constrains the names it does not declare, +/// so a property added there is decidable against `additionalProperties` +/// rather than merely undecided. +#[test] +fn test_property_added_to_partial_level_is_checked_against_additional_properties() { + let old_schema = json!({ + "type": "object", + "properties": { + "details": { + "type": "object", + "additionalProperties": {"type": "string"} + } + } + }); + let new_schema = json!({ + "type": "object", + "properties": { + "details": { + "type": "object", + "additionalProperties": {"type": "string"}, + "properties": {"count": {"type": "integer"}} + } + } + }); + + let (backward, backward_errors) = check_backward_compatibility(&old_schema, &new_schema); + let (forward, forward_errors) = check_forward_compatibility(&old_schema, &new_schema); + assert_eq!( + backward, + CompatibilityVerdict::Incompatible, + "{backward_errors:?}" + ); + assert_eq!( + forward, + CompatibilityVerdict::Incompatible, + "{forward_errors:?}" + ); + assert!( + backward_errors + .iter() + .any(|error| error.contains("$.details.count")), + "{backward_errors:?}" + ); + assert!( + forward_errors + .iter() + .any(|error| error.contains("$.details.count")), + "{forward_errors:?}" + ); +} + +#[test] +fn test_dialect_change_is_not_proven_compatible() { + let old_schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "string" + }); + let new_schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string" + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_unknown()); + assert!(result.forward_compatibility.is_unknown()); +} + +#[test] +fn test_all_of_inherited_closure_controls_property_addition() { + let old_schema = json!({ + "allOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": {"name": {"type": "string"}} + } + ] + }); + let new_schema = json!({ + "allOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + } + ] + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); +} + +#[test] +fn test_all_of_intersects_duplicate_property_schemas() { + let schema = json!({ + "allOf": [ + { + "type": "object", + "properties": { + "value": {"type": "string", "minLength": 1} + } + }, + { + "type": "object", + "properties": { + "value": {"type": "string", "maxLength": 10} + } + } + ] + }); + + let flattened = flatten_schema(&schema); + assert_eq!( + flattened.pointer("/properties/value/minLength"), + Some(&json!(1)) + ); + assert_eq!( + flattened.pointer("/properties/value/maxLength"), + Some(&json!(10)) + ); +} + +#[test] +fn test_definitions_container_change_alone_is_fully_compatible() { + // `definitions` is reachable only through `$ref` and never contributes + // to Valid(S), so adding an entry nothing references changes nothing. + let old_schema = json!({ + "type": "object", + "additionalProperties": false, + "definitions": { + "Used": {"type": "object", "additionalProperties": false} + }, + "properties": {"u": {"type": "object", "additionalProperties": false}} + }); + let new_schema = json!({ + "type": "object", + "additionalProperties": false, + "definitions": { + "Used": {"type": "object", "additionalProperties": false}, + "NeverReferenced": {"type": "string"} + }, + "properties": {"u": {"type": "object", "additionalProperties": false}} + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.full_compatibility.is_compatible()); +} + +#[test] +fn test_resolved_nested_definition_addition_is_backward_only() { + // The shape `resolve_schema_refs` produces for a macro-generated + // document: the referenced level is inlined and closed, and the + // residual `definitions` container must not double-count the change. + let level = |extra: bool| { + let mut props = json!({"label": {"type": "string"}}); + if extra { + props["note"] = json!({"type": "string"}); + } + json!({ + "type": "object", + "additionalProperties": false, + "properties": props, + "required": ["label"] + }) + }; + let document = |extra: bool| { + json!({ + "type": "object", + "additionalProperties": false, + "definitions": {"Nested": level(extra)}, + "properties": {"nested": level(extra)}, + "required": ["nested"] + }) + }; + + let result = check_schema_compatibility(&document(false), &document(true)); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); +} + +#[test] +fn test_differing_unresolved_ref_is_reported_as_unresolved() { + let old_schema = json!({ + "type": "object", + "properties": {"target": {"$ref": "gts://gts.x.core.a.b.v1~"}} + }); + let new_schema = json!({ + "type": "object", + "properties": {"target": {"$ref": "gts://gts.x.core.a.b.v2~"}} + }); + + let (is_backward, backward_errors) = check_backward_compatibility(&old_schema, &new_schema); + assert!(is_backward.is_unknown()); + assert!( + backward_errors + .iter() + .any(|error| error.contains("$.target") && error.contains("unresolved '$ref'")), + "{backward_errors:?}" + ); +} + +#[test] +fn test_identical_unresolved_ref_needs_no_resolution() { + let schema = json!({ + "type": "object", + "additionalProperties": false, + "properties": {"target": {"$ref": "gts://gts.x.core.a.b.v1~"}} + }); + + let result = check_schema_compatibility(&schema, &schema); + assert!(result.full_compatibility.is_compatible()); +} + +fn property_change(old_property: Value, new_property: Value) -> CompatibilityResult { + let document = |property: Value| { + json!({ + "type": "object", + "additionalProperties": false, + "properties": {"value": property}, + "required": ["value"] + }) + }; + check_schema_compatibility(&document(old_property), &document(new_property)) +} + +/// Bound keywords must be compared whenever present, never gated on `type`. +/// Gating on the old schema's `type` reported a real narrowing as fully +/// compatible whenever `type` was absent or written as an array. +#[test] +fn test_numeric_bounds_are_checked_without_a_type_keyword() { + let result = property_change(json!({"minimum": 0}), json!({"minimum": 5})); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); + + let result = property_change( + json!({"type": ["integer"], "minimum": 0}), + json!({"type": ["integer"], "minimum": 5}), + ); + assert!(result.backward_compatibility.is_incompatible()); + assert!(result.forward_compatibility.is_compatible()); +} + +#[test] +fn test_exclusive_and_size_bounds_are_directional() { + for (min_key, max_key) in [ + ("exclusiveMinimum", "exclusiveMaximum"), + ("minProperties", "maxProperties"), + ] { + let relaxed = property_change(json!({max_key: 10}), json!({max_key: 100})); + assert!( + relaxed.backward_compatibility.is_compatible(), + "relaxing {max_key}" + ); + assert!( + relaxed.forward_compatibility.is_incompatible(), + "relaxing {max_key}" + ); + + let tightened = property_change(json!({min_key: 1}), json!({min_key: 5})); + assert!( + tightened.backward_compatibility.is_incompatible(), + "tightening {min_key}" + ); + assert!( + tightened.forward_compatibility.is_compatible(), + "tightening {min_key}" + ); + } +} + +#[test] +fn test_inclusive_and_exclusive_bounds_are_compared_together() { + let lower = property_change( + json!({"type": "number", "minimum": 0}), + json!({"type": "number", "exclusiveMinimum": 0}), + ); + assert!(lower.backward_compatibility.is_incompatible()); + assert!(lower.forward_compatibility.is_compatible()); + + let upper = property_change( + json!({"type": "number", "maximum": 10}), + json!({"type": "number", "exclusiveMaximum": 10}), + ); + assert!(upper.backward_compatibility.is_incompatible()); + assert!(upper.forward_compatibility.is_compatible()); +} + +/// `-0.0` and `0.0` denote the same JSON number, so respelling a bound +/// changes no accepted instance. +#[test] +fn test_signed_zero_bounds_are_equal() { + let lower = property_change( + json!({"type": "number", "minimum": -0.0}), + json!({"type": "number", "minimum": 0.0}), + ); + assert!(lower.full_compatibility.is_compatible()); + + let upper = property_change( + json!({"type": "number", "maximum": -0.0}), + json!({"type": "number", "maximum": 0.0}), + ); + assert!(upper.full_compatibility.is_compatible()); +} + +/// Draft-04 spells `exclusiveMinimum` as a boolean modifier, which a numeric +/// comparison would silently ignore. +#[test] +fn test_boolean_exclusive_minimum_is_not_silently_ignored() { + let result = property_change( + json!({"type": "integer", "minimum": 1, "exclusiveMinimum": false}), + json!({"type": "integer", "minimum": 1, "exclusiveMinimum": true}), + ); + assert!(result.backward_compatibility.is_unknown()); + assert!(result.forward_compatibility.is_unknown()); +} + +#[test] +fn test_type_is_compared_as_a_set() { + // Dropping `null` from an `Option` union narrows the accepted set. + let narrowed = property_change( + json!({"type": ["string", "null"]}), + json!({"type": "string"}), + ); + assert!(narrowed.backward_compatibility.is_incompatible()); + assert!(narrowed.forward_compatibility.is_compatible()); + + // Member order carries no meaning. + let reordered = property_change( + json!({"type": ["string", "null"]}), + json!({"type": ["null", "string"]}), + ); + assert!(reordered.full_compatibility.is_compatible()); + + // Widening a union accepts everything the old union did. + let widened = property_change( + json!({"type": "string"}), + json!({"type": ["string", "null"]}), + ); + assert!(widened.backward_compatibility.is_compatible()); + assert!(widened.forward_compatibility.is_incompatible()); + + // `integer` remains a subset of `number` inside a union. + let promoted = property_change( + json!({"type": ["integer", "null"]}), + json!({"type": ["number", "null"]}), + ); + assert!(promoted.backward_compatibility.is_compatible()); + assert!(promoted.forward_compatibility.is_incompatible()); +} + +#[test] +fn test_enum_and_const_imply_effective_types() { + let enum_narrowed = property_change(json!({"type": "string"}), json!({"enum": ["a"]})); + assert!(enum_narrowed.backward_compatibility.is_incompatible()); + assert!(enum_narrowed.forward_compatibility.is_compatible()); + + let const_narrowed = property_change(json!({"type": "string"}), json!({"const": "a"})); + assert!(const_narrowed.backward_compatibility.is_incompatible()); + assert!(const_narrowed.forward_compatibility.is_compatible()); + + // JSON Schema treats mathematically integral JSON numbers as integers, + // regardless of whether the source text contains a decimal point. + let integral_number = property_change(json!({"type": "integer"}), json!({"const": 1.0})); + assert!(integral_number.backward_compatibility.is_incompatible()); + assert!(integral_number.forward_compatibility.is_compatible()); + + // A tiny nonzero fraction is not an integer, however close to one it + // lands: `{"const": 1e-20}` is the sole value the new schema accepts and + // `{"type": "integer"}` rejects it. + let tiny_fraction = property_change(json!({"type": "integer"}), json!({"const": 1e-20})); + assert!(tiny_fraction.backward_compatibility.is_incompatible()); + assert!(tiny_fraction.forward_compatibility.is_incompatible()); +} + +#[test] +fn test_narrowing_keyword_presence_is_directional() { + for keyword in ["pattern", "format", "multipleOf"] { + let value = if keyword == "multipleOf" { + json!(5) + } else if keyword == "format" { + json!("date-time") + } else { + json!("^a+$") + }; + + let added = property_change(json!({}), json!({keyword: value.clone()})); + assert!( + added.backward_compatibility.is_incompatible(), + "adding {keyword}" + ); + assert!( + added.forward_compatibility.is_compatible(), + "adding {keyword}" + ); + + let removed = property_change(json!({keyword: value}), json!({})); + assert!( + removed.backward_compatibility.is_compatible(), + "removing {keyword}" + ); + assert!( + removed.forward_compatibility.is_incompatible(), + "removing {keyword}" + ); + } +} + +/// Two different regexes cannot be ordered by inclusion, so neither +/// direction is provable - and the diagnostic must say so rather than imply +/// the change is breaking. +#[test] +fn test_changed_pattern_is_reported_as_unprovable() { + let (_, errors) = check_backward_compatibility( + &json!({"type": "string", "pattern": "^a+$"}), + &json!({"type": "string", "pattern": "^[ab]+$"}), + ); + assert!( + errors + .iter() + .any(|error| error.contains("cannot be proven")), + "{errors:?}" + ); +} + +#[test] +fn test_unique_items_defaults_to_false() { + let enabled = property_change( + json!({"type": "array"}), + json!({"type": "array", "uniqueItems": true}), + ); + assert!(enabled.backward_compatibility.is_incompatible()); + assert!(enabled.forward_compatibility.is_compatible()); + + let disabled = property_change( + json!({"type": "array", "uniqueItems": true}), + json!({"type": "array", "uniqueItems": false}), + ); + assert!(disabled.backward_compatibility.is_compatible()); + assert!(disabled.forward_compatibility.is_incompatible()); + + // Spelling out the default changes no accepted instance. + let no_op = property_change( + json!({"type": "array", "uniqueItems": false}), + json!({"type": "array"}), + ); + assert!(no_op.full_compatibility.is_compatible()); +} + +/// An omitted `$schema` means "the dialect the implementation applies", so +/// starting to declare a dialect that was already in effect is not a change. +#[test] +fn test_declaring_a_previously_omitted_dialect_is_compatible() { + let result = check_schema_compatibility( + &json!({"type": "object", "additionalProperties": false}), + &json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false + }), + ); + assert!(result.full_compatibility.is_compatible()); +} + +/// The `unevaluatedProperties` decision must follow the dialect that is in +/// effect, including when only one definition spells it out. +#[test] +fn test_omitted_dialect_inherits_unevaluated_support() { + let result = check_schema_compatibility( + &json!({ + "type": "object", + "unevaluatedProperties": false, + "properties": {"name": {"type": "string"}} + }), + &json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "unevaluatedProperties": false, + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }), + ); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); +} + +/// With no `$schema` anywhere the dialect is the one this implementation +/// applies when validating instances, which is Draft 2020-12 - so +/// `unevaluatedProperties` closes the level here too. +#[test] +fn test_undeclared_dialect_evaluates_unevaluated_properties() { + let old_schema = json!({ + "type": "object", + "unevaluatedProperties": false, + "properties": {"name": {"type": "string"}} + }); + let new_schema = json!({ + "type": "object", + "unevaluatedProperties": false, + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + }); + + let result = check_schema_compatibility(&old_schema, &new_schema); + assert!(result.backward_compatibility.is_compatible()); + assert!(result.forward_compatibility.is_incompatible()); + + // The instance validator this crate builds must agree with the verdict. + let validator = jsonschema::validator_for(&old_schema).expect("compile schema"); + assert!(!validator.is_valid(&json!({"name": "n", "email": "e"}))); + + // The same dialect decides the reported content model of a level. + let levels = classify_object_levels(&old_schema); + assert_eq!( + levels.first().map(|level| level.content_model), + Some(ContentModel::Closed) + ); +} + +#[test] +fn test_boolean_equivalent_property_schemas_classify_semantically() { + let additional_open = json!({ + "type": "object", + "additionalProperties": {} + }); + let additional_closed = json!({ + "type": "object", + "additionalProperties": {"not": {}} + }); + let property_names_open = json!({ + "type": "object", + "propertyNames": {} + }); + let property_names_closed = json!({ + "type": "object", + "propertyNames": {"not": {}} + }); + let closed_fallback_with_name_constraint = json!({ + "type": "object", + "additionalProperties": {"not": {}}, + "propertyNames": {"type": "string"} + }); + let closed_names_with_pattern = json!({ + "type": "object", + "propertyNames": {"not": {}}, + "patternProperties": {".*": {}} + }); + let open_pattern = json!({ + "type": "object", + "patternProperties": {"^x-": {}} + }); + let closed_pattern = json!({ + "type": "object", + "additionalProperties": {"not": {}}, + "patternProperties": {"^x-": {"not": {}}} + }); + let explicit_open_additional_precedes_unevaluated = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": {}, + "unevaluatedProperties": {"not": {}} + }); + + for (schema, expected) in [ + (additional_open, ContentModel::Open), + (additional_closed, ContentModel::Closed), + (property_names_open, ContentModel::Open), + (property_names_closed, ContentModel::Closed), + (closed_fallback_with_name_constraint, ContentModel::Closed), + (closed_names_with_pattern, ContentModel::Closed), + (open_pattern, ContentModel::Open), + (closed_pattern, ContentModel::Closed), + ( + explicit_open_additional_precedes_unevaluated, + ContentModel::Open, + ), + ] { + let levels = classify_object_levels(&schema); + assert_eq!( + levels.first().map(|level| level.content_model), + Some(expected) + ); + } +} + +#[test] +fn test_boolean_equivalent_additional_properties_drive_compatibility() { + let added_property = |additional_properties: Value| { + check_schema_compatibility( + &json!({ + "type": "object", + "additionalProperties": additional_properties + }), + &json!({ + "type": "object", + "additionalProperties": additional_properties, + "properties": {"name": {"type": "string"}} + }), + ) + }; + + let open = added_property(json!({})); + assert!(open.backward_compatibility.is_incompatible()); + assert!(open.forward_compatibility.is_compatible()); + + let closed = added_property(json!({"not": {}})); + assert!(closed.backward_compatibility.is_compatible()); + assert!(closed.forward_compatibility.is_incompatible()); +} + +/// §4.4 requires the content model to be read per object level from the +/// resolved effective schema, and §4.4.1's closed-envelope shape puts the +/// level that decides evolvability inside an extension container rather +/// than at the document root. +#[test] +fn test_classify_object_levels_reports_every_level() { + let schema = json!({ + "$schema": "http://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "envelope_field": {"type": "string"}, + "payload": { + "type": "object", + "properties": { + "own": { + "type": "object", + "additionalProperties": false, + "properties": {"a": {"type": "string"}} + } + } + }, + "labels": { + "type": "object", + "additionalProperties": {"type": "string"} + }, + "closed_by_unevaluated": { + "type": "object", + "unevaluatedProperties": false, + "properties": {"b": {"type": "string"}} + }, + "rows": { + "type": "array", + "items": {"type": "object", "properties": {"c": {"type": "string"}}} + } + } + }); + + let levels: HashMap = classify_object_levels(&schema) + .into_iter() + .map(|level| (level.path, level.content_model)) + .collect(); + + assert_eq!(levels.get("$"), Some(&ContentModel::Closed)); + assert_eq!(levels.get("$.payload"), Some(&ContentModel::Open)); + assert_eq!(levels.get("$.payload.own"), Some(&ContentModel::Closed)); + assert_eq!(levels.get("$.labels"), Some(&ContentModel::Partial)); + assert_eq!( + levels.get("$.closed_by_unevaluated"), + Some(&ContentModel::Closed) + ); + assert_eq!(levels.get("$.rows[]"), Some(&ContentModel::Open)); + // A scalar property is not an object level. + assert!(!levels.contains_key("$.envelope_field")); + + // Evolvability is exactly closure. + assert!(ContentModel::Closed.is_evolvable_in_place()); + assert!(!ContentModel::Open.is_evolvable_in_place()); + assert!(!ContentModel::Partial.is_evolvable_in_place()); +} + +/// A level closed only through `allOf` composition must classify as closed, +/// not as the open level it looks like in isolation. +#[test] +fn test_classify_object_levels_uses_the_effective_schema() { + let schema = json!({ + "allOf": [ + {"type": "object", "additionalProperties": false}, + {"type": "object", "properties": {"a": {"type": "string"}}} + ] + }); + + let levels = classify_object_levels(&schema); + assert_eq!( + levels.first().map(|level| level.content_model), + Some(ContentModel::Closed) + ); +} + +#[test] +fn test_diagnostics_carry_the_schema_location_and_kind() { + let old_schema = json!({ + "type": "object", + "properties": { + "payload": {"type": "object", "properties": {"a": {"type": "string"}}} + } + }); + let new_schema = json!({ + "type": "object", + "properties": { + "payload": { + "type": "object", + "properties": {"a": {"type": "string"}, "b": {"type": "string"}} + } + } + }); + + let (compatible, diagnostics) = check_backward_diagnostics(&old_schema, &new_schema); + assert!(compatible.is_incompatible()); + let finding = diagnostics + .iter() + .find(|diagnostic| diagnostic.path == "$.payload") + .expect("the offending level must be named, not the document root"); + assert_eq!(finding.finding, CompatibilityFinding::PropertyAdded); + assert_eq!( + finding.to_string(), + "Schema at '$.payload' adds property 'b' in a open model" + ); +} + +/// A caller that fails closed treats both alike, but an owner needs to tell +/// "we cannot decide this" from "this is known to break". +#[test] +fn test_undecidable_changes_are_reported_as_not_provable() { + let (_, diagnostics) = check_backward_diagnostics( + &json!({"type": "string", "pattern": "^a+$"}), + &json!({"type": "string", "pattern": "^[ab]+$"}), + ); + assert!( + diagnostics + .iter() + .all(|diagnostic| diagnostic.finding == CompatibilityFinding::NotProvable), + "{diagnostics:?}" + ); +} + +/// An unprovable `allOf` intersection is the checker's own bookkeeping and +/// must never surface as a keyword: `flatten_schema` is public and its +/// output feeds instance casting and `additionalProperties` comparisons, +/// where a synthetic keyword reads as a real constraint difference. +#[test] +fn test_unprovable_intersection_leaves_no_synthetic_keyword() { + let flattened = flatten_schema(&json!({ + "allOf": [ + {"type": "object", "additionalProperties": {"type": "string"}}, + {"type": "object", "additionalProperties": {"type": "number"}} + ] + })); + + let keys: Vec<&String> = flattened + .as_object() + .expect("flattening object branches yields an object") + .keys() + .collect(); + assert!( + keys.iter().all(|key| !key.starts_with("x-gts-internal")), + "{keys:?}" + ); +} + +/// The undecidable branch must stay local: reporting it must not swallow a +/// sibling that is decidably broken, or "unknown" would mask "incompatible". +#[test] +fn test_unprovable_property_does_not_mask_sibling_incompatibility() { + let schema_with = |sibling: Value| { + json!({ + "type": "object", + "allOf": [ + {"properties": {"undecidable": {"type": "string"}}}, + {"properties": {"undecidable": {"type": "integer"}}} + ], + "properties": {"sibling": sibling} + }) + }; + + let (verdict, diagnostics) = check_backward_diagnostics( + &schema_with(json!({"type": "string"})), + &schema_with(json!({"type": "number"})), + ); + + assert!(verdict.is_incompatible(), "{diagnostics:?}"); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.path == "$.undecidable" + && diagnostic.finding == CompatibilityFinding::NotProvable), + "{diagnostics:?}" + ); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.path == "$.sibling"), + "{diagnostics:?}" + ); +} diff --git a/gts/src/schema_traits.rs b/gts/src/schema_traits.rs index 483f539..aa934d2 100644 --- a/gts/src/schema_traits.rs +++ b/gts/src/schema_traits.rs @@ -299,11 +299,9 @@ fn validate_trait_schema_compatibility( let ancestor_schema = build_effective_traits_schema(&resolved_trait_schemas[..i]); let descendant_schema = build_effective_traits_schema(&resolved_trait_schemas[..=i]); - let ancestor = crate::schema_compat::extract_effective_schema(&ancestor_schema); - let descendant = crate::schema_compat::extract_effective_schema(&descendant_schema); - let pair_errors = crate::schema_compat::validate_effective_schema_compatibility( - &ancestor, - &descendant, + let pair_errors = crate::schema_derivation::validate_derivation( + &ancestor_schema, + &descendant_schema, "ancestor trait schema", "descendant trait schema", ); @@ -312,7 +310,7 @@ fn validate_trait_schema_compatibility( format!("x-gts-traits-schema[{i}] is incompatible with ancestor trait schema: {err}") })); - let branch_errors = crate::schema_compat::validate_closed_descendant_branches( + let branch_errors = crate::schema_derivation::validate_closed_descendant_branches( &ancestor_schema, &resolved_trait_schemas[i], "ancestor trait schema", diff --git a/gts/src/store.rs b/gts/src/store.rs index fda54b3..a29547a 100644 --- a/gts/src/store.rs +++ b/gts/src/store.rs @@ -5,8 +5,10 @@ use thiserror::Error; use crate::entities::GtsEntity; use crate::gts::{GtsId, GtsIdError, GtsIdPattern}; -use crate::schema_cast::{ - CompatibilityDiagnostic, CompatibilityVerdict, GtsEntityCastResult, ObjectLevel, +use crate::schema_cast::GtsEntityCastResult; +use crate::schema_evolution::{ + CompatibilityDiagnostic, CompatibilityVerdict, ObjectLevel, check_backward_diagnostics, + check_forward_diagnostics, classify_object_levels, }; #[derive(Debug, Error)] @@ -67,7 +69,7 @@ pub struct SchemaComparison { /// /// A caller admitting the new definition uses this to report, per level, /// whether a later definition will be able to add an optional property - /// there - see [`crate::schema_cast::ContentModel::is_evolvable_in_place`]. + /// there - see [`crate::schema_evolution::ContentModel::is_evolvable_in_place`]. /// One flag for the /// whole document would not do: in the closed-envelope shape recommended by /// §4.4.1 the level that decides evolvability is inside an extension @@ -85,15 +87,15 @@ impl SchemaComparison { /// Compares two documents whose references are already resolved. fn of_resolved(old_schema: &Value, new_schema: &Value) -> Self { let (backward_compatibility, backward_diagnostics) = - GtsEntityCastResult::check_backward_diagnostics(old_schema, new_schema); + check_backward_diagnostics(old_schema, new_schema); let (forward_compatibility, forward_diagnostics) = - GtsEntityCastResult::check_forward_diagnostics(old_schema, new_schema); + check_forward_diagnostics(old_schema, new_schema); Self { backward_compatibility, forward_compatibility, backward_diagnostics, forward_diagnostics, - candidate_object_levels: GtsEntityCastResult::classify_object_levels(new_schema), + candidate_object_levels: classify_object_levels(new_schema), } } @@ -465,7 +467,7 @@ impl GtsStore { /// - B (derived from A) is compatible with A /// - C (derived from A~B) is compatible with A~B /// - /// The heavy lifting is delegated to [`crate::schema_compat`]. + /// The heavy lifting is delegated to [`crate::schema_derivation`]. /// /// # Errors /// Returns `StoreError::ValidationError` if any derived schema loosens base constraints. @@ -521,7 +523,7 @@ impl GtsStore { StoreError::ValidationError(format!("Schema '{derived_id}' has {e}")) })?; - let errors = crate::schema_compat::validate_schema_compatibility( + let errors = crate::schema_derivation::validate_derivation_compatibility( &base_resolved, &derived_resolved, base_id, diff --git a/gts/src/store_test.rs b/gts/src/store_test.rs index cd3f797..64af2c9 100644 --- a/gts/src/store_test.rs +++ b/gts/src/store_test.rs @@ -5973,9 +5973,9 @@ fn test_compatibility_inherits_closed_model_through_external_ref() { .expect("resolve new derived schema"); let (backward, _) = - GtsEntityCastResult::check_backward_compatibility(&old_resolved, &new_resolved); + crate::schema_evolution::check_backward_compatibility(&old_resolved, &new_resolved); let (forward, _) = - GtsEntityCastResult::check_forward_compatibility(&old_resolved, &new_resolved); + crate::schema_evolution::check_forward_compatibility(&old_resolved, &new_resolved); assert!(backward.is_compatible()); assert!(forward.is_incompatible()); }