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/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/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-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-id/src/gts_id_pattern.rs b/gts-id/src/gts_id_pattern.rs index 3bc0280..9d66e94 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) @@ -110,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() @@ -147,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; } @@ -309,6 +320,35 @@ 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_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-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..b8add5a 100644 --- a/gts-macros/src/lib.rs +++ b/gts-macros/src/lib.rs @@ -1463,6 +1463,351 @@ 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); + if let Some(definitions) = definitions.as_mut() { + inline_gts_id_refs(definitions); + } + }; + + 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. + // * 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. + 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 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; + }; + + 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); + } + } + } + + 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_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)); + } + } + } + }; + let gts_schema_impl = if has_generic { let generic_param = input.generics.type_params().next().unwrap(); let generic_ident = &generic_param.ident; @@ -1491,7 +1836,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 +1892,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 +1918,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 +1950,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 +2003,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 +2024,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 +2045,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 +2087,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 +2117,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..26ba76e 100644 --- a/gts-macros/tests/inheritance_tests.rs +++ b/gts-macros/tests/inheritance_tests.rs @@ -78,6 +78,144 @@ 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, +} + +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 { + 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 +534,169 @@ 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"); + } + + #[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. + #[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-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/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/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..fc8dbf1 100644 --- a/gts/src/ops.rs +++ b/gts/src/ops.rs @@ -8,6 +8,8 @@ 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; use crate::store::{GtsStore, GtsStoreQueryResult}; @@ -63,13 +65,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(), } @@ -673,30 +669,13 @@ 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 { 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(), - is_fully_compatible: false, - is_backward_compatible: false, - is_forward_compatible: false, - incompatibility_reasons: Vec::new(), - backward_errors: Vec::new(), - forward_errors: Vec::new(), - casted_entity: None, - error: Some(e.to_string()), - }, + Err(e) => GtsEntityCastResult::undecided(from_id, to_type_id, e.to_string()), } } @@ -1828,12 +1807,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 +2086,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 +2119,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 +2152,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 +2184,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 +2215,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 +2241,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 +2267,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 +2294,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 +2322,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 +2676,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 +3275,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" ); } @@ -3355,10 +3339,20 @@ 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 - // 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 +3374,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..7311ecf 100644 --- a/gts/src/schema_cast.rs +++ b/gts/src/schema_cast.rs @@ -1,9 +1,79 @@ +use num_cmp::NumCmp; use serde::{Deserialize, Serialize}; 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 +89,6 @@ pub enum SchemaCastError { CastError(String), } -#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GtsEntityCastResult { #[serde(rename = "from")] @@ -32,18 +101,353 @@ 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 { + /// 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 @@ -66,10 +470,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 +495,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 +530,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 +755,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 +829,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 +849,1214 @@ 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 } + /// 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 = + 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( - 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)] = &[ + ("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, + ) + }), + ); + 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" } + ), )); } - // 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. 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, } - // 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, - )); + 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", + } } - errors - } + 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) + }) + } + } + } - #[must_use] - pub fn check_backward_compatibility( - old_schema: &Value, - new_schema: &Value, - ) -> (bool, Vec) { - Self::check_schema_compatibility(old_schema, new_schema, true) + 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), + ), + )] + } } - #[must_use] - pub fn check_forward_compatibility( - old_schema: &Value, - new_schema: &Value, - ) -> (bool, Vec) { - Self::check_schema_compatibility(old_schema, new_schema, false) + /// 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(), + ), + } } - #[allow(clippy::too_many_lines)] - fn check_schema_compatibility( - old_schema: &Value, - new_schema: &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, - ) -> (bool, Vec) { - let mut errors = Vec::new(); + ) -> 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 + }; - // Flatten schemas to handle allOf - let old_flat = Self::flatten_schema(old_schema); - let new_flat = Self::flatten_schema(new_schema); + 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:?}" + ), + )] + } + } + } + } - let old_props = old_flat - .get("properties") - .and_then(|p| p.as_object()) - .cloned() - .unwrap_or_default(); - let new_props = new_flat + 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, + 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 (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 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_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, + old_supports_unevaluated, + new_supports_unevaluated, + 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, + 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(), + )), + _ => {} + } + } + + 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(); + .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 = 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(); + .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" } + ), + )); + } - // 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(", "))); - } + 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 { - // 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(", "))); + (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(), + )); + } + + 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)); + } + } + } + + 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 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(); + 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; + } - for prop in common_props { - if let (Some(old_prop_schema), Some(new_prop_schema)) = - (old_props.get(*prop), new_props.get(*prop)) + // `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 { - // 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()); + 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 + } + } - if let (Some(ot), Some(nt)) = (old_type, new_type) - && ot != nt - { - errors.push(format!("Property '{prop}' type changed from {ot} to {nt}")); - } + const fn content_model_is_subset(source: ContentModel, target: ContentModel) -> bool { + matches!( + (source, target), + (ContentModel::Closed, _) + | (_, ContentModel::Open) + | (ContentModel::Partial, ContentModel::Partial) + ) + } - // 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:?}")); - } - } - } + 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) + } + }; - // 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); - } + 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) + } - // 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}")); - } - } - } + 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, + 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 + /// 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)); + } - (errors.is_empty(), errors) + 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 { @@ -685,12 +2064,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 +2077,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 +2100,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( @@ -729,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( @@ -759,12 +2210,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 +2236,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 +2256,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 +2279,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 +2306,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 +2327,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 +2344,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 +2362,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 +2377,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 +2395,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 +2413,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 +2444,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 +2463,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 +2482,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 +2517,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 +2555,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 +2585,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 +2610,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 +2702,1042 @@ 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_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); + 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}" + ); + } + } + + #[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}), + ); + 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 = 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", + "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..fda54b3 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,43 +850,77 @@ 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 { - 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 { - 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(), - 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()], - casted_entity: None, - error: None, - }; + /// 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 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 old_schema = &old_ent.content; - let new_schema = &new_ent.content; + 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, + 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 +934,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 +1197,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..a630df0 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] @@ -713,8 +713,66 @@ 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~"); - assert!(!result.is_backward_compatible); + 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] @@ -1337,7 +1395,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 +1462,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 +1824,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 +5876,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();