diff --git a/.gts-spec b/.gts-spec index 56ca20d..4ed5421 160000 --- a/.gts-spec +++ b/.gts-spec @@ -1 +1 @@ -Subproject commit 56ca20d89df2c2d8e70773da33482e4c748c446d +Subproject commit 4ed5421d2723a54bc4c2981e2f6a35e42d34c38f diff --git a/README.md b/README.md index 082e026..b8f2887 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ Featureset: - [x] **OP#9 - Version Casting**: Transform instances between compatible MINOR versions - [x] **OP#10 - Query Execution**: Filter identifier collections using the GTS query language - [x] **OP#11 - Attribute Access**: Retrieve property values and metadata using the attribute selector (`@`) -- [ ] **OP#12 - Schema Validation**: Validate schema against its precedent schema +- [x] **OP#12 - Schema Validation**: Validate schema against its precedent schema +- [x] **OP#13 - Schema Traits Validation**: Validate `x-gts-traits-schema` / `x-gts-traits` across the inheritance chain TODO - need a file with Go code snippets for all Ops above @@ -222,6 +223,13 @@ gts -path ./examples query -expr "gts.vendor.pkg.*" -limit 10 # OP#10 - Get attribute value gts -path ./examples attr -path gts.vendor.pkg.ns.type.v1.0@name +# OP#12 - Validate a derived schema against its chain +gts -path ./examples validate-schema -id gts.vendor.pkg.ns.base.v1~derived.v1~ + +# OP#13 - Validate any entity (schema or instance) +gts -path ./examples validate-entity -id gts.vendor.pkg.ns.base.v1~derived.v1~ +gts -path ./examples validate-entity -id gts.vendor.pkg.ns.type.v1.0 + # List all entities gts -path ./examples list -limit 100 diff --git a/cmd/gts/main.go b/cmd/gts/main.go index 164d128..1697b5a 100644 --- a/cmd/gts/main.go +++ b/cmd/gts/main.go @@ -27,6 +27,8 @@ The commands are: match-id-pattern match a GTS ID against a pattern uuid generate UUID from a GTS ID validate validate an instance against its schema + validate-schema validate a derived schema against its chain + validate-entity validate any entity (schema or instance) including traits relationships resolve relationships for an entity compatibility check compatibility between two schemas cast cast an instance to a target schema @@ -83,6 +85,8 @@ var commands = []*Command{ cmdMatchIDPattern, cmdUUID, cmdValidate, + cmdValidateSchema, + cmdValidateEntity, cmdRelationships, cmdCompatibility, cmdCast, diff --git a/cmd/gts/validate_entity.go b/cmd/gts/validate_entity.go new file mode 100644 index 0000000..c5ebce8 --- /dev/null +++ b/cmd/gts/validate_entity.go @@ -0,0 +1,43 @@ +/* +Copyright © 2025 Global Type System +Released under Apache License 2.0 +*/ + +package main + +var cmdValidateEntity = &Command{ + UsageLine: "validate-entity -id ", + Short: "validate any entity (schema or instance) including traits", + Long: ` +ValidateEntity validates any GTS entity by its ID. + +For schemas: runs OP#12 chain validation and OP#13 traits validation. +For instances: validates the instance against its schema. + +The -id flag specifies the GTS entity ID to validate. +Requires -path to be set to load entities. + +Example: + + gts -path ./examples validate-entity -id gts.vendor.pkg.ns.base.v1~derived.v1~ + gts -path ./examples validate-entity -id gts.vendor.pkg.ns.type.v1.0 + `, +} + +var validateEntityID string + +func init() { + cmdValidateEntity.Run = runValidateEntity + cmdValidateEntity.Flag.StringVar(&validateEntityID, "id", "", "GTS entity ID to validate") +} + +func runValidateEntity(cmd *Command, args []string) { + if validateEntityID == "" { + cmd.Usage() + return + } + + store := newStore() + result := store.ValidateEntity(validateEntityID) + writeJSON(result) +} diff --git a/cmd/gts/validate_schema.go b/cmd/gts/validate_schema.go new file mode 100644 index 0000000..6b55549 --- /dev/null +++ b/cmd/gts/validate_schema.go @@ -0,0 +1,40 @@ +/* +Copyright © 2025 Global Type System +Released under Apache License 2.0 +*/ + +package main + +var cmdValidateSchema = &Command{ + UsageLine: "validate-schema -id ", + Short: "validate a derived schema against its chain", + Long: ` +ValidateSchema checks that a derived schema only tightens, never loosens, +the constraints defined by its base schema(s). + +The -id flag specifies the chained GTS schema ID to validate. +Requires -path to be set to load entities. + +Example: + + gts -path ./examples validate-schema -id gts.vendor.pkg.ns.base.v1~derived.v1~ + `, +} + +var validateSchemaID string + +func init() { + cmdValidateSchema.Run = runValidateSchema + cmdValidateSchema.Flag.StringVar(&validateSchemaID, "id", "", "chained GTS schema ID to validate") +} + +func runValidateSchema(cmd *Command, args []string) { + if validateSchemaID == "" { + cmd.Usage() + return + } + + store := newStore() + result := store.ValidateSchemaChain(validateSchemaID) + writeJSON(result) +} diff --git a/gts/gts.go b/gts/gts.go index 8702528..132ce6a 100644 --- a/gts/gts.go +++ b/gts/gts.go @@ -78,6 +78,7 @@ type GtsIDSegment struct { VerMinor *int IsType bool IsWildcard bool + IsUUID bool } // GtsID represents a validated GTS identifier @@ -95,11 +96,6 @@ func NewGtsID(id string) (*GtsID, error) { return nil, &InvalidGtsIDError{GtsID: id, Cause: "Must be lower case"} } - // Validate no hyphens - if strings.Contains(raw, "-") { - return nil, &InvalidGtsIDError{GtsID: id, Cause: "Must not contain '-'"} - } - // Validate prefix if !strings.HasPrefix(raw, GtsPrefix) { return nil, &InvalidGtsIDError{GtsID: id, Cause: fmt.Sprintf("Does not start with '%s'", GtsPrefix)} @@ -110,21 +106,48 @@ func NewGtsID(id string) (*GtsID, error) { return nil, &InvalidGtsIDError{GtsID: id, Cause: "Too long"} } + // Split by ~ to get segments, preserving empties to detect trailing ~ + remainder := raw[len(GtsPrefix):] + parts := splitPreservingTilde(remainder) + + // Detect combined anonymous instance: last part is a UUID (no trailing ~) + hasUUIDTail := len(parts) >= 2 && isUUIDSegment(parts[len(parts)-1]) + + // Validate no hyphens in non-UUID portions (reuse already-split parts) + gtsPartsToCheck := parts + if hasUUIDTail { + gtsPartsToCheck = parts[:len(parts)-1] + } + for _, p := range gtsPartsToCheck { + if strings.Contains(p, "-") { + return nil, &InvalidGtsIDError{GtsID: id, Cause: "Must not contain '-'"} + } + } + gtsID := &GtsID{ ID: raw, Segments: make([]*GtsIDSegment, 0), } - // Split by ~ to get segments, preserving empties to detect trailing ~ - remainder := raw[len(GtsPrefix):] - parts := splitPreservingTilde(remainder) - offset := len(GtsPrefix) for i, part := range parts { if part == "" { return nil, &InvalidGtsIDError{GtsID: id, Cause: fmt.Sprintf("GTS segment #%d @ offset %d is empty", i+1, offset)} } + // Last part is a UUID tail — store as a special segment + if hasUUIDTail && i == len(parts)-1 { + gtsID.Segments = append(gtsID.Segments, &GtsIDSegment{ + Num: i + 1, + Offset: offset, + Segment: part, + IsType: false, + IsUUID: true, + }) + offset += len(part) + continue + } + segment, err := parseSegment(i+1, offset, part) if err != nil { return nil, err @@ -391,3 +414,16 @@ func parseSegment(num, offset int, segment string) (*GtsIDSegment, error) { return seg, nil } + +// isUUIDSegment returns true if s is a valid RFC 4122 UUID (lowercase hex with hyphens) +func isUUIDSegment(s string) bool { + u, err := uuid.Parse(s) + if err != nil { + return false + } + if u.Variant() != uuid.RFC4122 { + return false + } + // uuid.Parse accepts URN, braced, and uppercase forms; enforce canonical lowercase hyphenated form + return s == strings.ToLower(u.String()) +} diff --git a/gts/gts_test.go b/gts/gts_test.go index 8d9f2ae..9501220 100644 --- a/gts/gts_test.go +++ b/gts/gts_test.go @@ -251,3 +251,56 @@ func TestGtsID_TildeNotAtEnd(t *testing.T) { t.Errorf("Expected error for tilde not at end: %q", invalidID) } } + +// TestGtsID_CombinedAnonymousInstance tests combined anonymous instance IDs (UUID tail) +func TestGtsID_CombinedAnonymousInstance(t *testing.T) { + validIDs := []string{ + "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~7a1d2f34-5678-49ab-9012-abcdef123456", + "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~7a1d2f34-5678-49ab-9012-000000000000", + } + + for _, id := range validIDs { + t.Run(id, func(t *testing.T) { + gtsID, err := NewGtsID(id) + if err != nil { + t.Fatalf("Expected valid combined anonymous instance ID %q, got error: %v", id, err) + } + lastSeg := gtsID.Segments[len(gtsID.Segments)-1] + if !lastSeg.IsUUID { + t.Errorf("Expected last segment IsUUID=true for %q", id) + } + if lastSeg.IsType { + t.Errorf("Expected last segment IsType=false for %q", id) + } + if gtsID.IsType() { + t.Errorf("Expected IsType()=false for combined anonymous instance %q", id) + } + }) + } +} + +// TestGtsID_CombinedAnonymousInstance_Invalid tests that invalid UUID tails are rejected +func TestGtsID_CombinedAnonymousInstance_Invalid(t *testing.T) { + invalidIDs := []struct { + id string + desc string + }{ + { + "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~not-a-uuid", + "non-UUID tail with hyphens", + }, + { + "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~7A1D2F34-5678-49AB-9012-ABCDEF123456", + "uppercase UUID tail", + }, + } + + for _, tt := range invalidIDs { + t.Run(tt.desc, func(t *testing.T) { + _, err := NewGtsID(tt.id) + if err == nil { + t.Errorf("Expected error for %s: %q", tt.desc, tt.id) + } + }) + } +} diff --git a/gts/gts_uuid_test.go b/gts/gts_uuid_test.go index 685d6a2..eb44d36 100644 --- a/gts/gts_uuid_test.go +++ b/gts/gts_uuid_test.go @@ -57,6 +57,11 @@ func TestToUUID_Instances(t *testing.T) { gtsID: "gts.x.test5.events.type.v1~abc.app._.custom_event.v1.2", expected: "c7f8cca7-3af6-58af-b72b-3febfd93f1a8", }, + { + name: "Combined anonymous instance (UUID tail)", + gtsID: "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~7a1d2f34-5678-49ab-9012-abcdef123456", + expected: "4a31b759-722b-5bb1-a1dc-2cf40963e81b", + }, } for _, tt := range tests { diff --git a/gts/parse.go b/gts/parse.go index 6216916..b858fef 100644 --- a/gts/parse.go +++ b/gts/parse.go @@ -16,6 +16,7 @@ type ParseIDSegment struct { VerMajor int `json:"ver_major"` VerMinor *int `json:"ver_minor"` IsType bool `json:"is_type"` + IsUUID bool `json:"is_uuid"` } // ParseIDResult represents the result of parsing a GTS identifier @@ -58,6 +59,7 @@ func ParseID(gtsID string) ParseIDResult { VerMajor: seg.VerMajor, VerMinor: seg.VerMinor, IsType: seg.IsType, + IsUUID: seg.IsUUID, } } @@ -97,6 +99,7 @@ func ParseID(gtsID string) ParseIDResult { VerMajor: seg.VerMajor, VerMinor: seg.VerMinor, IsType: seg.IsType, + IsUUID: seg.IsUUID, } } diff --git a/gts/schema_compat.go b/gts/schema_compat.go new file mode 100644 index 0000000..813ce81 --- /dev/null +++ b/gts/schema_compat.go @@ -0,0 +1,1043 @@ +/* +Copyright © 2025 Global Type System +Released under Apache License 2.0 +*/ + +package gts + +// OP#12 – Schema-vs-schema compatibility validation. +// +// Given a chained GTS schema ID like `gts.A~B~C~`, this validates that +// each derived schema is compatible with its base: +// +// - B (derived from A) must be compatible with A +// - C (derived from A~B) must be compatible with A~B +// +// "Compatible" means every valid instance of the derived schema is also a valid +// instance of the base schema. The derived schema may only tighten (never loosen) +// constraints on properties inherited from the base. + +import ( + "encoding/json" + "fmt" + "math" + "regexp" + "strings" + "unicode/utf8" +) + +// ValidateSchemaChainResult is the result of OP#12 schema chain validation. +type ValidateSchemaChainResult struct { + SchemaID string `json:"schema_id"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +// ValidateSchemaChain validates each derived schema against its base across the chain (OP#12). +func (s *GtsStore) ValidateSchemaChain(schemaID string) *ValidateSchemaChainResult { + gid, err := NewGtsID(schemaID) + if err != nil { + return &ValidateSchemaChainResult{ + SchemaID: schemaID, + OK: false, + Error: fmt.Sprintf("Invalid GTS ID: %v", err), + } + } + + if len(gid.Segments) < 2 { + return &ValidateSchemaChainResult{SchemaID: schemaID, OK: true} + } + + segments := gid.Segments + for i := 0; i < len(segments)-1; i++ { + baseID := buildIDFromSegments(segments[:i+1]) + derivedID := buildIDFromSegments(segments[:i+2]) + + baseContent, err := s.resolveSchemaRefsChecked(baseID) + if err != nil { + return &ValidateSchemaChainResult{ + SchemaID: schemaID, + OK: false, + Error: fmt.Sprintf("Schema '%s' has %v", baseID, err), + } + } + derivedContent, err := s.resolveSchemaRefsChecked(derivedID) + if err != nil { + return &ValidateSchemaChainResult{ + SchemaID: schemaID, + OK: false, + Error: fmt.Sprintf("Schema '%s' has %v", derivedID, err), + } + } + + baseEff := extractEffectiveSchema(baseContent) + derivedEff := extractEffectiveSchema(derivedContent) + + errs := validateSchemaCompatibility(baseEff, derivedEff, baseID, derivedID, false) + if len(errs) > 0 { + return &ValidateSchemaChainResult{ + SchemaID: schemaID, + OK: false, + Error: fmt.Sprintf( + "Schema '%s' is not compatible with base '%s': %s", + derivedID, baseID, strings.Join(errs, "; "), + ), + } + } + } + + return &ValidateSchemaChainResult{SchemaID: schemaID, OK: true} +} + +// buildIDFromSegments reconstructs a GTS ID string from a slice of segments. +func buildIDFromSegments(segments []*GtsIDSegment) string { + sb := strings.Builder{} + sb.WriteString(GtsPrefix) + for _, seg := range segments { + sb.WriteString(seg.Segment) + } + return sb.String() +} + +// ── Effective schema extraction ─────────────────────────────────────────────── + +// effectiveSchema is a flattened view of a schema used for compatibility comparison. +type effectiveSchema struct { + properties map[string]any + propertiesSet bool // true if schema explicitly declared a "properties" key + required map[string]bool + requiredSet bool // true if schema explicitly declared a "required" key + additionalProperties any // nil means not set + extra map[string]any // top-level combinators: not, anyOf, oneOf, if, then, else +} + +// extractEffectiveSchema builds an effectiveSchema from a fully-resolved JSON Schema map. +func extractEffectiveSchema(schema map[string]any) *effectiveSchema { + eff := &effectiveSchema{ + properties: make(map[string]any), + required: make(map[string]bool), + extra: make(map[string]any), + } + extractEffectiveSchemaInto(schema, eff) + return eff +} + +func extractEffectiveSchemaInto(schema map[string]any, eff *effectiveSchema) { + if schema == nil { + return + } + if props, ok := schema["properties"].(map[string]any); ok { + eff.propertiesSet = true + for k, v := range props { + eff.properties[k] = v + } + } + if req, ok := schema["required"].([]any); ok { + eff.requiredSet = true + for _, v := range req { + if s, ok := v.(string); ok { + eff.required[s] = true + } + } + } + if ap, ok := schema["additionalProperties"]; ok { + eff.additionalProperties = ap + } + for _, kw := range []string{"not", "anyOf", "oneOf", "if", "then", "else"} { + if v, ok := schema[kw]; ok { + eff.extra[kw] = v + } + } + if allOf, ok := schema["allOf"].([]any); ok { + for _, item := range allOf { + if sub, ok := item.(map[string]any); ok { + extractEffectiveSchemaInto(sub, eff) + } + } + } +} + +// ── Core compatibility validation ───────────────────────────────────────────── + +// validateSchemaCompatibility returns compatibility errors between base and derived. +// nested suppresses top-level-only checks (e.g. omitted base properties) when called +// recursively for nested object properties. +func validateSchemaCompatibility(base, derived *effectiveSchema, baseID, derivedID string, nested bool) []string { + var errors []string + + baseDisallowsAdditional := false + if b, ok := base.additionalProperties.(bool); ok && !b { + baseDisallowsAdditional = true + } + + for propName, derivedProp := range derived.properties { + if baseProp, exists := base.properties[propName]; exists { + if b, ok := derivedProp.(bool); ok && !b { + errors = append(errors, fmt.Sprintf( + "property '%s': derived schema '%s' disables property defined in base '%s'", + propName, derivedID, baseID, + )) + continue + } + if basePropMap, ok := baseProp.(map[string]any); ok { + if derivedPropMap, ok := derivedProp.(map[string]any); ok { + errors = append(errors, comparePropertyConstraints(basePropMap, derivedPropMap, propName)...) + } else { + // derived replaced schema object with non-object + errors = append(errors, fmt.Sprintf( + "property '%s': derived replaces schema object with a non-object value, loosening base constraints", + propName, + )) + } + } + } else if baseDisallowsAdditional { + errors = append(errors, fmt.Sprintf( + "property '%s': derived schema '%s' adds new property but base '%s' has additionalProperties: false", + propName, derivedID, baseID, + )) + } + } + + for propName, baseProp := range base.properties { + derivedProp, exists := derived.properties[propName] + if b, ok := baseProp.(bool); ok && !b { + if !exists { + // omitting a disabled property re-enables it only if derived allows additional properties + derivedAllowsAdditional := true + if ap, ok := derived.additionalProperties.(bool); ok && !ap { + derivedAllowsAdditional = false + } + if derivedAllowsAdditional { + errors = append(errors, fmt.Sprintf( + "property '%s': derived schema '%s' re-enables property disabled in base '%s'", + propName, derivedID, baseID, + )) + } + } else if db, dOk := derivedProp.(bool); !dOk || db { + errors = append(errors, fmt.Sprintf( + "property '%s': derived schema '%s' re-enables property disabled in base '%s'", + propName, derivedID, baseID, + )) + } + } else if !nested { + // Only flag omission when derived is explicitly closed (has a properties key or AP:false). + // An open-model derived schema implicitly accepts all properties. + derivedIsClosed := derived.propertiesSet + if ap, ok := derived.additionalProperties.(bool); ok && !ap { + derivedIsClosed = true + } + if !exists && derivedIsClosed { + errors = append(errors, fmt.Sprintf( + "property '%s': derived schema '%s' omits property defined in base '%s'", + propName, derivedID, baseID, + )) + } else if _, baseIsObj := baseProp.(map[string]any); baseIsObj && exists { + if _, derivedIsObj := derivedProp.(map[string]any); !derivedIsObj { + errors = append(errors, fmt.Sprintf( + "property '%s': derived schema '%s' replaces object schema with a non-object value, loosening base '%s' constraints", + propName, derivedID, baseID, + )) + } + } + } + } + + if baseDisallowsAdditional { + derivedAlsoClosed := false + if b, ok := derived.additionalProperties.(bool); ok && !b { + derivedAlsoClosed = true + } + if !derivedAlsoClosed { + errors = append(errors, fmt.Sprintf( + "derived schema '%s' loosens additionalProperties from false in base '%s'", + derivedID, baseID, + )) + } + } + + errors = append(errors, checkRequiredRemoval(base, derived, baseID, derivedID, nested)...) + errors = append(errors, checkTopLevelLooseningKeywords(base, derived, baseID, derivedID)...) + + return errors +} + +// comparePropertyConstraints compares all keyword-level constraints between base and derived. +func comparePropertyConstraints(baseProp, derivedProp map[string]any, propName string) []string { + var errors []string + + errors = append(errors, checkTypeCompatibility(baseProp, derivedProp, propName)...) + + derivedValues, derivedEnumerates := collectDerivedEnumeratedValues(derivedProp) + + errors = append(errors, checkConstCompatibility(baseProp, derivedProp, propName)...) + + if derivedEnumerates { + errors = append(errors, checkEnumeratedValuesAgainstBase(baseProp, derivedValues, propName)...) + } else { + errors = append(errors, checkPatternCompatibility(baseProp, derivedProp, propName)...) + errors = append(errors, checkBound(baseProp, derivedProp, "maxLength", propName, true)...) + errors = append(errors, checkBound(baseProp, derivedProp, "maximum", propName, true)...) + errors = append(errors, checkBound(baseProp, derivedProp, "exclusiveMaximum", propName, true)...) + errors = append(errors, checkBound(baseProp, derivedProp, "maxItems", propName, true)...) + errors = append(errors, checkBound(baseProp, derivedProp, "minLength", propName, false)...) + errors = append(errors, checkBound(baseProp, derivedProp, "minimum", propName, false)...) + errors = append(errors, checkBound(baseProp, derivedProp, "exclusiveMinimum", propName, false)...) + errors = append(errors, checkBound(baseProp, derivedProp, "minItems", propName, false)...) + errors = append(errors, checkMultipleOf(baseProp, derivedProp, propName)...) + } + + // enum subset check runs regardless of derivedEnumerates (no new values outside base enum) + errors = append(errors, checkEnumCompatibility(baseProp, derivedProp, propName)...) + errors = append(errors, checkItemsCompatibility(baseProp, derivedProp, propName)...) + errors = append(errors, checkLooseningKeywords(baseProp, derivedProp, propName)...) + + baseType, _ := baseProp["type"].(string) + derivedType, _ := derivedProp["type"].(string) + if baseType == "object" && derivedType == "object" { + if _, hasProps := baseProp["properties"]; hasProps { + baseNested := extractEffectiveSchema(baseProp) + derivedNested := extractEffectiveSchema(derivedProp) + nestedErrors := validateSchemaCompatibility(baseNested, derivedNested, "base", "derived", true) + for _, e := range nestedErrors { + errors = append(errors, fmt.Sprintf("in nested object '%s': %s", propName, e)) + } + } + } + + return errors +} + +func checkTypeCompatibility(baseProp, derivedProp map[string]any, propName string) []string { + baseType, hasBase := baseProp["type"] + if !hasBase { + return nil + } + baseSet := typeToSet(baseType) + derivedType, hasDerived := derivedProp["type"] + if !hasDerived { + // A const or enum implicitly constrains the type; verify it is compatible. + if constVal, hasConst := derivedProp["const"]; hasConst { + if constType := jsonValueType(constVal); constType != "" { + if !valueTypeCompatible(constType, baseSet) { + return []string{fmt.Sprintf( + "property '%s': derived const value has type %s, incompatible with base type %v", + propName, constType, baseType, + )} + } + } + return nil + } + if enumVal, hasEnum := derivedProp["enum"]; hasEnum { + if arr, ok := enumVal.([]any); ok { + for _, item := range arr { + if itemType := jsonValueType(item); itemType != "" { + if !valueTypeCompatible(itemType, baseSet) { + return []string{fmt.Sprintf( + "property '%s': derived enum value %v has type %s, incompatible with base type %v", + propName, item, itemType, baseType, + )} + } + } + } + } + return nil + } + return []string{fmt.Sprintf( + "property '%s': derived omits type constraint (%v) defined in base", + propName, baseType, + )} + } + derivedSet := typeToSet(derivedType) + for dt := range derivedSet { + if !schemaTypeCompatible(dt, baseSet) { + return []string{fmt.Sprintf( + "property '%s': derived changes type from %v to %v", + propName, baseType, derivedType, + )} + } + } + return nil +} + +// schemaTypeCompatible reports if dt is in baseSet. No widening: integer↔number are distinct. +func schemaTypeCompatible(dt string, baseSet map[string]bool) bool { + return baseSet[dt] +} + +// valueTypeCompatible is like schemaTypeCompatible but allows integer↔number for +// const/enum value checks, since JSON decoder ambiguously types whole numbers. +func valueTypeCompatible(vt string, baseSet map[string]bool) bool { + if baseSet[vt] { + return true + } + if vt == "number" && baseSet["integer"] { + return true + } + if vt == "integer" && baseSet["number"] { + return true + } + return false +} + +// typeToSet normalises a JSON Schema "type" value into a set of type strings. +func typeToSet(t any) map[string]bool { + switch v := t.(type) { + case string: + return map[string]bool{v: true} + case []any: + s := make(map[string]bool, len(v)) + for _, item := range v { + if str, ok := item.(string); ok { + s[str] = true + } + } + return s + default: + return map[string]bool{} + } +} + +func checkConstCompatibility(baseProp, derivedProp map[string]any, propName string) []string { + baseConst, hasBaseConst := baseProp["const"] + if !hasBaseConst { + return nil + } + derivedConst, hasDerivedConst := derivedProp["const"] + if !hasDerivedConst { + return []string{fmt.Sprintf( + "property '%s': derived omits const constraint (%v) defined in base", + propName, baseConst, + )} + } + // Per §4.4.3: GTS-ID discriminator consts (strings containing '~') may differ across versions. + if baseStr, ok := baseConst.(string); ok { + if derivedStr, ok := derivedConst.(string); ok { + if strings.Contains(baseStr, "~") && strings.Contains(derivedStr, "~") { + return nil + } + } + } + if !jsonEqual(baseConst, derivedConst) { + return []string{fmt.Sprintf( + "property '%s': derived redefines const from %v to %v", + propName, baseConst, derivedConst, + )} + } + return nil +} + +func checkPatternCompatibility(baseProp, derivedProp map[string]any, propName string) []string { + basePat, hasBase := baseProp["pattern"] + if !hasBase { + return nil + } + derivedPat, hasDerived := derivedProp["pattern"] + if !hasDerived { + return []string{fmt.Sprintf( + "property '%s': derived omits pattern constraint (%v) defined in base", + propName, basePat, + )} + } + // No regex subset analysis available — any change is conservatively rejected. + if basePat != derivedPat { + return []string{fmt.Sprintf( + "property '%s': derived changes pattern from %v to %v", + propName, basePat, derivedPat, + )} + } + return nil +} + +func checkEnumCompatibility(baseProp, derivedProp map[string]any, propName string) []string { + baseEnum, ok := baseProp["enum"].([]any) + if !ok { + return nil + } + if derivedEnum, ok := derivedProp["enum"].([]any); ok { + var errors []string + for _, val := range derivedEnum { + if !anySliceContains(baseEnum, val) { + errors = append(errors, fmt.Sprintf( + "property '%s': derived enum contains value %v not in base enum", + propName, val, + )) + } + } + return errors + } + if derivedConst, ok := derivedProp["const"]; ok { + if !anySliceContains(baseEnum, derivedConst) { + return []string{fmt.Sprintf( + "property '%s': derived const %v is not in base enum", + propName, derivedConst, + )} + } + return nil + } + return []string{fmt.Sprintf( + "property '%s': derived omits enum constraint defined in base", + propName, + )} +} + +func checkItemsCompatibility(baseProp, derivedProp map[string]any, propName string) []string { + baseItems, hasBase := baseProp["items"] + if !hasBase { + return nil + } + derivedItems, hasDerived := derivedProp["items"] + if !hasDerived { + return []string{fmt.Sprintf( + "property '%s': derived omits items constraint defined in base", + propName, + )} + } + itemsName := propName + ".items" + baseItemsMap, baseOk := baseItems.(map[string]any) + derivedItemsMap, derivedOk := derivedItems.(map[string]any) + if baseOk && derivedOk { + return comparePropertyConstraints(baseItemsMap, derivedItemsMap, itemsName) + } + if baseOk && !derivedOk { + return []string{fmt.Sprintf( + "property '%s': derived replaced object schema with a non-object items value, loosening base constraints", + itemsName, + )} + } + return nil +} + +func checkRequiredRemoval(base, derived *effectiveSchema, baseID, derivedID string, nested bool) []string { + // In nested context, only enforce when derived explicitly declares required. + if nested && !derived.requiredSet { + return nil + } + var errors []string + for baseReq := range base.required { + if !derived.required[baseReq] { + errors = append(errors, fmt.Sprintf( + "derived schema '%s' removes required field '%s' defined in base '%s'", + derivedID, baseReq, baseID, + )) + } + } + return errors +} + +// checkMultipleOf verifies derived multipleOf is a multiple of base (stricter divisibility). +// E.g. base=2, derived=6 → valid; derived=3 → invalid. +func checkMultipleOf(baseProp, derivedProp map[string]any, propName string) []string { + baseVal, hasBase := getFloat(baseProp, "multipleOf") + if !hasBase { + return nil + } + derivedVal, hasDerived := getFloat(derivedProp, "multipleOf") + if !hasDerived { + return []string{fmt.Sprintf( + "property '%s': derived omits multipleOf constraint (%v) defined in base", + propName, baseVal, + )} + } + if baseVal == 0 { + return nil + } + remainder := math.Mod(derivedVal, baseVal) + if remainder != 0 { + return []string{fmt.Sprintf( + "property '%s': derived multipleOf (%v) is not a multiple of base multipleOf (%v)", + propName, derivedVal, baseVal, + )} + } + return nil +} + +// checkBound verifies a numeric constraint is preserved or tightened. +// upper=true: derived must be ≤ base (e.g. maxLength). upper=false: derived must be ≥ base (e.g. minimum). +func checkBound(baseProp, derivedProp map[string]any, keyword, propName string, upper bool) []string { + baseVal, hasBase := getFloat(baseProp, keyword) + if !hasBase { + return nil + } + derivedVal, hasDerived := getFloat(derivedProp, keyword) + if !hasDerived { + return []string{fmt.Sprintf( + "property '%s': derived omits %s constraint (%v) defined in base", + propName, keyword, baseVal, + )} + } + loosened := (upper && derivedVal > baseVal) || (!upper && derivedVal < baseVal) + if loosened { + return []string{fmt.Sprintf( + "property '%s': derived %s (%v) loosens base %s (%v)", + propName, keyword, derivedVal, keyword, baseVal, + )} + } + return nil +} + +// checkTopLevelLooseningKeywords flags 'not'/'if' introduced at the derived top level when +// absent in base. anyOf/oneOf/then/else are excluded — they narrow rather than loosen. +func checkTopLevelLooseningKeywords(base, derived *effectiveSchema, baseID, derivedID string) []string { + var errors []string + for _, kw := range []string{"not", "if"} { + _, derivedHas := derived.extra[kw] + _, baseHas := base.extra[kw] + if derivedHas && !baseHas { + errors = append(errors, fmt.Sprintf( + "derived schema '%s' introduces top-level '%s' keyword not present in base '%s', which may loosen constraints", + derivedID, kw, baseID, + )) + } + } + return errors +} + +// checkLooseningKeywords is the per-property equivalent of checkTopLevelLooseningKeywords. +func checkLooseningKeywords(baseProp, derivedProp map[string]any, propName string) []string { + var errors []string + for _, kw := range []string{"not", "if"} { + _, derivedHas := derivedProp[kw] + _, baseHas := baseProp[kw] + if derivedHas && !baseHas { + errors = append(errors, fmt.Sprintf( + "property '%s': derived introduces '%s' keyword not present in base, which may loosen constraints", + propName, kw, + )) + } + } + return errors +} + +// ── Enumerated value helpers ───────────────────────────────────────────────── + +// collectDerivedEnumeratedValues returns (values, true) when derived uses const or enum. +func collectDerivedEnumeratedValues(derivedProp map[string]any) ([]any, bool) { + if c, ok := derivedProp["const"]; ok { + return []any{c}, true + } + if arr, ok := derivedProp["enum"].([]any); ok { + return arr, true + } + return nil, false +} + +// checkEnumeratedValuesAgainstBase verifies every const/enum value satisfies base bounds. +func checkEnumeratedValuesAgainstBase(baseProp map[string]any, values []any, propName string) []string { + var errors []string + + for _, keyword := range []string{"minimum", "minLength", "minItems", "exclusiveMinimum"} { + baseVal, hasBase := getFloat(baseProp, keyword) + if !hasBase { + continue + } + for _, val := range values { + n, ok := numericValueFor(val, keyword) + if !ok { + continue + } + violates := keyword == "exclusiveMinimum" && n <= baseVal + violates = violates || (keyword != "exclusiveMinimum" && n < baseVal) + if violates { + errors = append(errors, fmt.Sprintf( + "property '%s': derived const/enum value %v violates base %s (%v)", + propName, val, keyword, baseVal, + )) + } + } + } + + for _, keyword := range []string{"maximum", "maxLength", "maxItems", "exclusiveMaximum"} { + baseVal, hasBase := getFloat(baseProp, keyword) + if !hasBase { + continue + } + for _, val := range values { + n, ok := numericValueFor(val, keyword) + if !ok { + continue + } + violates := keyword == "exclusiveMaximum" && n >= baseVal + violates = violates || (keyword != "exclusiveMaximum" && n > baseVal) + if violates { + errors = append(errors, fmt.Sprintf( + "property '%s': derived const/enum value %v violates base %s (%v)", + propName, val, keyword, baseVal, + )) + } + } + } + + if baseMultiple, hasBase := getFloat(baseProp, "multipleOf"); hasBase && baseMultiple != 0 { + for _, val := range values { + n, ok := toFloat64(val) + if ok && math.Mod(n, baseMultiple) != 0 { + errors = append(errors, fmt.Sprintf( + "property '%s': derived const/enum value %v violates base multipleOf (%v)", + propName, val, baseMultiple, + )) + } + } + } + + if basePat, ok := baseProp["pattern"].(string); ok && basePat != "" { + re, err := regexp.Compile(basePat) + if err == nil { + for _, val := range values { + if s, ok := val.(string); ok { + if !re.MatchString(s) { + errors = append(errors, fmt.Sprintf( + "property '%s': derived const/enum value %q does not match base pattern %q", + propName, s, basePat, + )) + } + } + } + } + } + + return errors +} + +// ── Primitive helpers ──────────────────────────────────────────────────────── + +// numericValueFor extracts a comparable numeric value from a JSON value for a given keyword. +func numericValueFor(val any, keyword string) (float64, bool) { + switch keyword { + case "minLength", "maxLength": + if s, ok := val.(string); ok { + return float64(utf8.RuneCountInString(s)), true + } + case "minItems", "maxItems": + if arr, ok := val.([]any); ok { + return float64(len(arr)), true + } + default: + return toFloat64(val) + } + return 0, false +} + +func getFloat(m map[string]any, key string) (float64, bool) { + v, ok := m[key] + if !ok { + return 0, false + } + return toFloat64(v) +} + +func toFloat64(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case float32: + return float64(n), true + case int: + return float64(n), true + case int64: + return float64(n), true + case int32: + return float64(n), true + case json.Number: + f, err := n.Float64() + return f, err == nil + } + return 0, false +} + +// jsonValueType returns the JSON Schema type name for a decoded Go value, or "" for null. +func jsonValueType(v any) string { + switch v.(type) { + case string: + return "string" + case bool: + return "boolean" + case float64, float32, int, int32, int64, json.Number: + return "number" + case []any: + return "array" + case map[string]any: + return "object" + } + return "" +} + +func stringSliceContains(slice []string, s string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} + +func anySliceContains(slice []any, val any) bool { + for _, item := range slice { + if jsonEqual(item, val) { + return true + } + } + return false +} + +func jsonEqual(a, b any) bool { + aj, _ := json.Marshal(a) + bj, _ := json.Marshal(b) + return string(aj) == string(bj) +} + +// ── Ref resolution ──────────────────────────────────────────────────────────── + +// resolveSchemaRefsChecked resolves $ref references in a named schema, detecting cycles. +func (s *GtsStore) resolveSchemaRefsChecked(schemaID string) (map[string]any, error) { + entity := s.Get(schemaID) + if entity == nil { + return nil, fmt.Errorf("schema '%s' not found", schemaID) + } + if !entity.IsSchema { + return nil, fmt.Errorf("entity '%s' is not a schema", schemaID) + } + return s.resolveRefs(entity.Content) +} + +// resolveRefs resolves all $ref references in a schema map, detecting cycles and duplicates. +func (s *GtsStore) resolveRefs(schema map[string]any) (map[string]any, error) { + visited := make(map[string]bool) + cycleFound := false + dupFound := false + resolved := s.resolveRefsInner(schema, visited, &cycleFound, &dupFound) + if cycleFound { + return nil, fmt.Errorf("circular $ref detected") + } + if dupFound { + return nil, fmt.Errorf("duplicate sibling $ref in allOf") + } + if m, ok := resolved.(map[string]any); ok { + if ref := findUnresolvedRef(m); ref != "" { + return nil, fmt.Errorf("unresolved $ref: %v", ref) + } + return m, nil + } + return schema, nil +} + +// findUnresolvedRef returns the first non-local $ref still present after resolution, or "". +func findUnresolvedRef(schema any) string { + switch v := schema.(type) { + case map[string]any: + if ref, ok := v["$ref"].(string); ok && !strings.HasPrefix(ref, "#") { + return ref + } + for _, val := range v { + if found := findUnresolvedRef(val); found != "" { + return found + } + } + case []any: + for _, item := range v { + if found := findUnresolvedRef(item); found != "" { + return found + } + } + } + return "" +} + +func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFound *bool, dupFound *bool) any { + switch v := schema.(type) { + case map[string]any: + // Handle $ref + if refVal, ok := v["$ref"].(string); ok { + if strings.HasPrefix(refVal, "#") { // local refs kept as-is + result := make(map[string]any) + for k, val := range v { + result[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound) + } + return result + } + + canonical := strings.TrimPrefix(refVal, GtsURIPrefix) + if visited[canonical] { + *cycleFound = true + result := make(map[string]any) + for k, val := range v { + if k != "$ref" { + result[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound) + } + } + if len(result) == 0 { + return schema + } + return result + } + + entity := s.Get(canonical) + if entity != nil && entity.IsSchema { + visited[canonical] = true + resolved := s.resolveRefsInner(entity.Content, visited, cycleFound, dupFound) + delete(visited, canonical) + + if resolvedMap, ok := resolved.(map[string]any); ok { + copy := make(map[string]any, len(resolvedMap)) + for k, val := range resolvedMap { + copy[k] = val + } + delete(copy, "$id") + delete(copy, "$schema") + + if len(v) == 1 { // pure $ref — return resolved copy directly + return copy + } + + // caller's own keys override the resolved base + merged := make(map[string]any) + for k, val := range copy { + merged[k] = val + } + for k, val := range v { + if k != "$ref" { + merged[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound) + } + } + return merged + } + } + + // unresolvable — preserve $ref so callers can detect it + result := make(map[string]any) + for k, val := range v { + if k == "$ref" { + result[k] = val + } else { + result[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound) + } + } + return result + } + + // Flatten allOf: merge properties/required with union semantics; hoist other keywords + // rightmost-wins. AP is excluded from $ref-originated items to prevent base AP:false + // bleeding onto the derived schema. + if allOf, ok := v["allOf"].([]any); ok { + var resolvedAllOf []any + mergedProps := make(map[string]any) + var mergedRequired []string + mergedOther := make(map[string]any) + anyMerged := false + + seenRefs := make(map[string]bool) + for _, item := range allOf { + if itemMap, ok := item.(map[string]any); ok { + if refVal, ok := itemMap["$ref"].(string); ok { + canonical := strings.TrimPrefix(refVal, GtsURIPrefix) + if seenRefs[canonical] { + *dupFound = true + continue + } + seenRefs[canonical] = true + } + } + + itemWasRef := false + if itemMap, ok := item.(map[string]any); ok { + if _, hasRef := itemMap["$ref"].(string); hasRef { + itemWasRef = true + } + } + + resolved := s.resolveRefsInner(item, visited, cycleFound, dupFound) + if resolvedMap, ok := resolved.(map[string]any); ok { + if _, stillHasRef := resolvedMap["$ref"]; stillHasRef { + resolvedAllOf = append(resolvedAllOf, resolved) + } else { + anyMerged = true + if props, ok := resolvedMap["properties"].(map[string]any); ok { + for k, pv := range props { + mergedProps[k] = pv + } + } + if req, ok := resolvedMap["required"].([]any); ok { + for _, rv := range req { + if str, ok := rv.(string); ok { + if !stringSliceContains(mergedRequired, str) { + mergedRequired = append(mergedRequired, str) + } + } + } + } + for k, val := range resolvedMap { + switch k { + case "properties", "required", "$id", "$schema": + continue + case "additionalProperties": + if itemWasRef { + continue + } + } + mergedOther[k] = val + } + } + } else { + resolvedAllOf = append(resolvedAllOf, resolved) + } + } + + if anyMerged { + merged := make(map[string]any) + for k, val := range v { + if k != "allOf" { + merged[k] = val + } + } + for k, val := range mergedOther { // parent keys take precedence + if _, exists := merged[k]; !exists { + merged[k] = val + } + } + if parentProps, ok := v["properties"].(map[string]any); ok { + for k, pv := range parentProps { // parent props overlay allOf props + mergedProps[k] = pv + } + } + if len(mergedProps) > 0 { + merged["properties"] = mergedProps + } + if parentReq, ok := v["required"].([]any); ok { + for _, rv := range parentReq { + if str, ok := rv.(string); ok { + if !stringSliceContains(mergedRequired, str) { + mergedRequired = append(mergedRequired, str) + } + } + } + } + if len(mergedRequired) > 0 { + reqAny := make([]any, len(mergedRequired)) + for i, r := range mergedRequired { + reqAny[i] = r + } + merged["required"] = reqAny + } + if len(resolvedAllOf) > 0 { + merged["allOf"] = resolvedAllOf + } + return merged + } + } + + result := make(map[string]any) + for k, val := range v { + result[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound) + } + return result + + case []any: + result := make([]any, len(v)) + for i, item := range v { + result[i] = s.resolveRefsInner(item, visited, cycleFound, dupFound) + } + return result + + default: + return schema + } +} diff --git a/gts/schema_compat_test.go b/gts/schema_compat_test.go new file mode 100644 index 0000000..bce5f4f --- /dev/null +++ b/gts/schema_compat_test.go @@ -0,0 +1,935 @@ +/* +Copyright © 2025 Global Type System +Released under Apache License 2.0 +*/ + +package gts + +import ( + "encoding/json" + "strings" + "testing" +) + +// ============================================================================= +// typeToSet +// ============================================================================= + +func TestTypeToSet(t *testing.T) { + tests := []struct { + name string + input any + want map[string]bool + }{ + {"string", "string", map[string]bool{"string": true}}, + {"integer", "integer", map[string]bool{"integer": true}}, + {"slice two types", []any{"string", "null"}, map[string]bool{"string": true, "null": true}}, + {"slice one type", []any{"number"}, map[string]bool{"number": true}}, + {"unknown type int", 42, map[string]bool{}}, + {"nil", nil, map[string]bool{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := typeToSet(tt.input) + if len(got) != len(tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + return + } + for k := range tt.want { + if !got[k] { + t.Errorf("missing key %q in result %v", k, got) + } + } + }) + } +} + +// ============================================================================= +// toFloat64 +// ============================================================================= + +func TestToFloat64(t *testing.T) { + tests := []struct { + name string + input any + want float64 + wantOK bool + }{ + {"float64", float64(3.14), 3.14, true}, + {"float32", float32(1.5), float64(float32(1.5)), true}, + {"int", int(5), 5, true}, + {"int32", int32(7), 7, true}, + {"int64", int64(100), 100, true}, + {"json.Number", json.Number("42.5"), 42.5, true}, + {"string", "nope", 0, false}, + {"bool", true, 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := toFloat64(tt.input) + if ok != tt.wantOK { + t.Errorf("ok=%v, want %v", ok, tt.wantOK) + } + if ok && got != tt.want { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +// ============================================================================= +// jsonEqual / anySliceContains +// ============================================================================= + +func TestJsonEqual(t *testing.T) { + tests := []struct { + name string + a, b any + want bool + }{ + {"equal strings", "foo", "foo", true}, + {"different strings", "foo", "bar", false}, + {"equal numbers", 42.0, 42.0, true}, + {"different numbers", 1.0, 2.0, false}, + {"equal maps", map[string]any{"x": 1.0}, map[string]any{"x": 1.0}, true}, + {"different maps", map[string]any{"x": 1.0}, map[string]any{"x": 2.0}, false}, + {"nil equal", nil, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := jsonEqual(tt.a, tt.b); got != tt.want { + t.Errorf("jsonEqual(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestAnySliceContains(t *testing.T) { + slice := []any{"a", "b", 1.0} + tests := []struct { + val any + want bool + }{ + {"a", true}, + {"b", true}, + {1.0, true}, + {"z", false}, + {2.0, false}, + } + for _, tt := range tests { + if got := anySliceContains(slice, tt.val); got != tt.want { + t.Errorf("anySliceContains(%v) = %v, want %v", tt.val, got, tt.want) + } + } +} + +// ============================================================================= +// extractEffectiveSchema +// ============================================================================= + +func TestExtractEffectiveSchema(t *testing.T) { + t.Run("direct properties and required", func(t *testing.T) { + schema := map[string]any{ + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "age": map[string]any{"type": "integer"}, + }, + "required": []any{"name"}, + } + eff := extractEffectiveSchema(schema) + if _, ok := eff.properties["name"]; !ok { + t.Error("expected 'name' in properties") + } + if _, ok := eff.properties["age"]; !ok { + t.Error("expected 'age' in properties") + } + if !eff.required["name"] { + t.Error("expected 'name' in required") + } + if !eff.requiredSet { + t.Error("expected requiredSet=true") + } + }) + + t.Run("allOf merges properties and required", func(t *testing.T) { + schema := map[string]any{ + "allOf": []any{ + map[string]any{ + "properties": map[string]any{"a": map[string]any{"type": "string"}}, + "required": []any{"a"}, + }, + map[string]any{ + "properties": map[string]any{"b": map[string]any{"type": "integer"}}, + }, + }, + } + eff := extractEffectiveSchema(schema) + if _, ok := eff.properties["a"]; !ok { + t.Error("expected 'a' from allOf") + } + if _, ok := eff.properties["b"]; !ok { + t.Error("expected 'b' from allOf") + } + if !eff.required["a"] { + t.Error("expected 'a' required from allOf") + } + }) + + t.Run("additionalProperties captured", func(t *testing.T) { + eff := extractEffectiveSchema(map[string]any{"additionalProperties": false}) + if eff.additionalProperties != false { + t.Errorf("expected false, got %v", eff.additionalProperties) + } + }) + + t.Run("no required array leaves requiredSet false", func(t *testing.T) { + eff := extractEffectiveSchema(map[string]any{ + "properties": map[string]any{"x": map[string]any{"type": "string"}}, + }) + if eff.requiredSet { + t.Error("requiredSet should be false") + } + }) +} + +// ============================================================================= +// checkTypeCompatibility +// ============================================================================= + +func TestCheckTypeCompatibility(t *testing.T) { + tests := []struct { + name string + base map[string]any + derived map[string]any + wantError bool + }{ + {"same type", map[string]any{"type": "string"}, map[string]any{"type": "string"}, false}, + {"different type", map[string]any{"type": "string"}, map[string]any{"type": "integer"}, true}, + {"base has no type", map[string]any{}, map[string]any{"type": "string"}, false}, + {"derived omits type", map[string]any{"type": "string"}, map[string]any{}, true}, + {"derived narrows union", map[string]any{"type": []any{"string", "null"}}, map[string]any{"type": "string"}, false}, + {"derived expands type", map[string]any{"type": "string"}, map[string]any{"type": []any{"string", "integer"}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := checkTypeCompatibility(tt.base, tt.derived, "field") + if tt.wantError && len(errs) == 0 { + t.Error("expected error but got none") + } + if !tt.wantError && len(errs) != 0 { + t.Errorf("expected no error but got: %v", errs) + } + }) + } +} + +// ============================================================================= +// checkConstCompatibility +// ============================================================================= + +func TestCheckConstCompatibility(t *testing.T) { + tests := []struct { + name string + base map[string]any + derived map[string]any + wantError bool + }{ + {"no base const", map[string]any{"type": "string"}, map[string]any{"type": "string"}, false}, + {"same const", map[string]any{"const": "v"}, map[string]any{"const": "v"}, false}, + {"different const", map[string]any{"const": "A"}, map[string]any{"const": "B"}, true}, + {"derived omits const", map[string]any{"const": "v"}, map[string]any{}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := checkConstCompatibility(tt.base, tt.derived, "field") + if tt.wantError && len(errs) == 0 { + t.Error("expected error but got none") + } + if !tt.wantError && len(errs) != 0 { + t.Errorf("expected no error but got: %v", errs) + } + }) + } +} + +// ============================================================================= +// checkPatternCompatibility +// ============================================================================= + +func TestCheckPatternCompatibility(t *testing.T) { + tests := []struct { + name string + base map[string]any + derived map[string]any + wantError bool + }{ + {"same pattern", map[string]any{"pattern": "^[a-z]+$"}, map[string]any{"pattern": "^[a-z]+$"}, false}, + {"different pattern", map[string]any{"pattern": "^[a-z]+$"}, map[string]any{"pattern": "^[A-Z]+$"}, true}, + {"derived omits pattern", map[string]any{"pattern": "^[a-z]+$"}, map[string]any{}, true}, + {"no base pattern", map[string]any{}, map[string]any{"pattern": "^x$"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := checkPatternCompatibility(tt.base, tt.derived, "field") + if tt.wantError && len(errs) == 0 { + t.Error("expected error but got none") + } + if !tt.wantError && len(errs) != 0 { + t.Errorf("expected no error but got: %v", errs) + } + }) + } +} + +// ============================================================================= +// checkEnumCompatibility +// ============================================================================= + +func TestCheckEnumCompatibility(t *testing.T) { + tests := []struct { + name string + base map[string]any + derived map[string]any + wantError bool + }{ + {"no base enum", map[string]any{"type": "string"}, map[string]any{"type": "string"}, false}, + {"derived subset", map[string]any{"enum": []any{"a", "b", "c"}}, map[string]any{"enum": []any{"a", "b"}}, false}, + {"derived same", map[string]any{"enum": []any{"a", "b"}}, map[string]any{"enum": []any{"a", "b"}}, false}, + {"derived adds value", map[string]any{"enum": []any{"a", "b"}}, map[string]any{"enum": []any{"a", "b", "c"}}, true}, + {"derived const in enum", map[string]any{"enum": []any{"a", "b", "c"}}, map[string]any{"const": "b"}, false}, + {"derived const not in enum", map[string]any{"enum": []any{"a", "b"}}, map[string]any{"const": "z"}, true}, + {"derived omits enum", map[string]any{"enum": []any{"a", "b"}}, map[string]any{"type": "string"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := checkEnumCompatibility(tt.base, tt.derived, "field") + if tt.wantError && len(errs) == 0 { + t.Error("expected error but got none") + } + if !tt.wantError && len(errs) != 0 { + t.Errorf("expected no error but got: %v", errs) + } + }) + } +} + +// ============================================================================= +// checkBound +// ============================================================================= + +func TestCheckBound(t *testing.T) { + tests := []struct { + name string + base map[string]any + derived map[string]any + keyword string + upper bool + wantError bool + }{ + // upper bounds + {"maxLength tightened", map[string]any{"maxLength": 100}, map[string]any{"maxLength": 50}, "maxLength", true, false}, + {"maxLength same", map[string]any{"maxLength": 50}, map[string]any{"maxLength": 50}, "maxLength", true, false}, + {"maxLength loosened", map[string]any{"maxLength": 50}, map[string]any{"maxLength": 100}, "maxLength", true, true}, + {"maximum derived omits", map[string]any{"maximum": 100}, map[string]any{}, "maximum", true, true}, + {"maxItems tightened", map[string]any{"maxItems": 10}, map[string]any{"maxItems": 5}, "maxItems", true, false}, + // lower bounds + {"minLength tightened", map[string]any{"minLength": 5}, map[string]any{"minLength": 10}, "minLength", false, false}, + {"minLength loosened", map[string]any{"minLength": 10}, map[string]any{"minLength": 3}, "minLength", false, true}, + {"minimum derived omits", map[string]any{"minimum": 0}, map[string]any{}, "minimum", false, true}, + {"minItems tightened", map[string]any{"minItems": 1}, map[string]any{"minItems": 3}, "minItems", false, false}, + // base has no constraint + {"no base bound", map[string]any{}, map[string]any{"maxLength": 50}, "maxLength", true, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := checkBound(tt.base, tt.derived, tt.keyword, "field", tt.upper) + if tt.wantError && len(errs) == 0 { + t.Error("expected error but got none") + } + if !tt.wantError && len(errs) != 0 { + t.Errorf("expected no error but got: %v", errs) + } + }) + } +} + +// ============================================================================= +// collectDerivedEnumeratedValues +// ============================================================================= + +func TestCollectDerivedEnumeratedValues(t *testing.T) { + tests := []struct { + name string + prop map[string]any + wantLen int + wantOK bool + }{ + {"const", map[string]any{"const": "only"}, 1, true}, + {"enum three", map[string]any{"enum": []any{"x", "y", "z"}}, 3, true}, + {"neither", map[string]any{"type": "string"}, 0, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vals, ok := collectDerivedEnumeratedValues(tt.prop) + if ok != tt.wantOK { + t.Errorf("ok=%v, want %v", ok, tt.wantOK) + } + if len(vals) != tt.wantLen { + t.Errorf("len=%v, want %v", len(vals), tt.wantLen) + } + }) + } +} + +// ============================================================================= +// checkEnumeratedValuesAgainstBase +// ============================================================================= + +func TestCheckEnumeratedValuesAgainstBase(t *testing.T) { + tests := []struct { + name string + base map[string]any + values []any + wantError bool + }{ + {"valid minimum", map[string]any{"minimum": 5.0}, []any{5.0, 10.0}, false}, + {"violates minimum", map[string]any{"minimum": 10.0}, []any{5.0}, true}, + {"valid maximum", map[string]any{"maximum": 100.0}, []any{50.0, 99.0}, false}, + {"violates maximum", map[string]any{"maximum": 100.0}, []any{101.0}, true}, + {"string ok minLength", map[string]any{"minLength": 2.0}, []any{"hi"}, false}, + {"string violates minLength", map[string]any{"minLength": 3.0}, []any{"hi"}, true}, + {"string violates maxLength", map[string]any{"maxLength": 3.0}, []any{"toolong"}, true}, + {"array ok minItems", map[string]any{"minItems": 1.0}, []any{[]any{"a"}}, false}, + {"array violates minItems", map[string]any{"minItems": 2.0}, []any{[]any{"a"}}, true}, + {"array violates maxItems", map[string]any{"maxItems": 1.0}, []any{[]any{"a", "b"}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := checkEnumeratedValuesAgainstBase(tt.base, tt.values, "field") + if tt.wantError && len(errs) == 0 { + t.Error("expected error but got none") + } + if !tt.wantError && len(errs) != 0 { + t.Errorf("expected no error but got: %v", errs) + } + }) + } +} + +// ============================================================================= +// checkItemsCompatibility +// ============================================================================= + +func TestCheckItemsCompatibility(t *testing.T) { + tests := []struct { + name string + base map[string]any + derived map[string]any + wantError bool + }{ + {"no base items", map[string]any{"type": "array"}, map[string]any{"items": map[string]any{"type": "string"}}, false}, + {"derived omits items", map[string]any{"items": map[string]any{"type": "string"}}, map[string]any{}, true}, + {"compatible items (tightened)", map[string]any{"items": map[string]any{"type": "string", "minLength": 1.0}}, map[string]any{"items": map[string]any{"type": "string", "minLength": 3.0}}, false}, + {"incompatible items type change", map[string]any{"items": map[string]any{"type": "string"}}, map[string]any{"items": map[string]any{"type": "integer"}}, true}, + {"derived replaces object items with bool", map[string]any{"items": map[string]any{"type": "string"}}, map[string]any{"items": true}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := checkItemsCompatibility(tt.base, tt.derived, "field") + if tt.wantError && len(errs) == 0 { + t.Error("expected error but got none") + } + if !tt.wantError && len(errs) != 0 { + t.Errorf("expected no error but got: %v", errs) + } + }) + } +} + +// ============================================================================= +// checkRequiredRemoval +// ============================================================================= + +func TestCheckRequiredRemoval(t *testing.T) { + tests := []struct { + name string + baseReq map[string]bool + derivedReq map[string]bool + derivedSet bool + wantError bool + }{ + // At top level (nested=false), omitting required is treated as empty — error if base has required fields + {"top-level derived not set — treated as empty", map[string]bool{"a": true}, map[string]bool{}, false, true}, + {"derived preserves all", map[string]bool{"a": true, "b": true}, map[string]bool{"a": true, "b": true, "c": true}, true, false}, + {"derived removes one", map[string]bool{"a": true, "b": true}, map[string]bool{"a": true}, true, true}, + {"derived removes all", map[string]bool{"a": true}, map[string]bool{}, true, true}, + {"base empty", map[string]bool{}, map[string]bool{"a": true}, true, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + base := &effectiveSchema{required: tt.baseReq, requiredSet: true} + derived := &effectiveSchema{required: tt.derivedReq, requiredSet: tt.derivedSet} + errs := checkRequiredRemoval(base, derived, "base", "derived", false) + if tt.wantError && len(errs) == 0 { + t.Error("expected error but got none") + } + if !tt.wantError && len(errs) != 0 { + t.Errorf("expected no error but got: %v", errs) + } + }) + } + + // In nested context, omitting required is a valid partial overlay + t.Run("nested derived not set — allowed partial overlay", func(t *testing.T) { + base := &effectiveSchema{required: map[string]bool{"a": true}, requiredSet: true} + derived := &effectiveSchema{required: map[string]bool{}, requiredSet: false} + errs := checkRequiredRemoval(base, derived, "base", "derived", true) + if len(errs) != 0 { + t.Errorf("nested partial overlay should be allowed, got: %v", errs) + } + }) +} + +// ============================================================================= +// validateSchemaCompatibility +// ============================================================================= + +func TestValidateSchemaCompatibility(t *testing.T) { + tests := []struct { + name string + base *effectiveSchema + derived *effectiveSchema + nested bool + wantError bool + }{ + { + name: "identical schemas", + base: &effectiveSchema{ + properties: map[string]any{"name": map[string]any{"type": "string"}}, + required: map[string]bool{"name": true}, + requiredSet: true, + }, + derived: &effectiveSchema{ + properties: map[string]any{"name": map[string]any{"type": "string"}}, + required: map[string]bool{"name": true}, + requiredSet: true, + }, + wantError: false, + }, + { + name: "derived disables base property", + base: &effectiveSchema{ + properties: map[string]any{"name": map[string]any{"type": "string"}}, + required: map[string]bool{}, + requiredSet: false, + }, + derived: &effectiveSchema{ + properties: map[string]any{"name": false}, + required: map[string]bool{}, + requiredSet: false, + }, + wantError: true, + }, + { + name: "derived omits base property at top level (closed model)", + base: &effectiveSchema{ + properties: map[string]any{"name": map[string]any{"type": "string"}}, + propertiesSet: true, + required: map[string]bool{}, + requiredSet: false, + }, + derived: &effectiveSchema{ + properties: map[string]any{}, + propertiesSet: true, + required: map[string]bool{}, + requiredSet: false, + }, + nested: false, + wantError: true, + }, + { + name: "derived omits base property at top level (open model — no error)", + base: &effectiveSchema{ + properties: map[string]any{"name": map[string]any{"type": "string"}}, + propertiesSet: true, + required: map[string]bool{}, + requiredSet: false, + }, + derived: &effectiveSchema{ + properties: map[string]any{}, + propertiesSet: false, + required: map[string]bool{}, + requiredSet: false, + }, + nested: false, + wantError: false, + }, + { + name: "derived omits base property in nested context (ok)", + base: &effectiveSchema{ + properties: map[string]any{"name": map[string]any{"type": "string"}}, + required: map[string]bool{}, + requiredSet: false, + }, + derived: &effectiveSchema{ + properties: map[string]any{}, + required: map[string]bool{}, + requiredSet: false, + }, + nested: true, + wantError: false, + }, + { + name: "derived loosens additionalProperties from false", + base: &effectiveSchema{ + properties: map[string]any{}, + required: map[string]bool{}, + requiredSet: false, + additionalProperties: false, + }, + derived: &effectiveSchema{ + properties: map[string]any{}, + required: map[string]bool{}, + requiredSet: false, + additionalProperties: nil, + }, + wantError: true, + }, + { + name: "derived adds new property when base has additionalProperties:false", + base: &effectiveSchema{ + properties: map[string]any{}, + required: map[string]bool{}, + requiredSet: false, + additionalProperties: false, + }, + derived: &effectiveSchema{ + properties: map[string]any{"extra": map[string]any{"type": "string"}}, + required: map[string]bool{}, + requiredSet: false, + additionalProperties: false, + }, + wantError: true, + }, + { + name: "derived re-enables disabled base property", + base: &effectiveSchema{ + properties: map[string]any{"disabled": false}, + required: map[string]bool{}, + requiredSet: false, + }, + derived: &effectiveSchema{ + properties: map[string]any{"disabled": map[string]any{"type": "string"}}, + required: map[string]bool{}, + requiredSet: false, + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validateSchemaCompatibility(tt.base, tt.derived, "base", "derived", tt.nested) + if tt.wantError && len(errs) == 0 { + t.Error("expected error but got none") + } + if !tt.wantError && len(errs) != 0 { + t.Errorf("expected no error but got: %v", errs) + } + }) + } +} + +// ============================================================================= +// ValidateSchemaChain integration tests +// ============================================================================= + +func newSchemaEntity(content map[string]any) *JsonEntity { + return NewJsonEntity(content, DefaultGtsConfig()) +} + +func mustRegister(t *testing.T, store *GtsStore, content map[string]any) { + t.Helper() + if _, ok := content["$schema"]; !ok { + content["$schema"] = "http://json-schema.org/draft-07/schema#" + } + if err := store.Register(newSchemaEntity(content)); err != nil { + t.Fatalf("register failed: %v", err) + } +} + +func TestValidateSchemaChain_SingleSegment(t *testing.T) { + store := NewGtsStore(nil) + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain.ns.base.v1~", + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + }) + result := store.ValidateSchemaChain("gts.x.chain.ns.base.v1~") + if !result.OK { + t.Errorf("single-segment schema should always be valid, got: %s", result.Error) + } +} + +func TestValidateSchemaChain_InvalidGtsID(t *testing.T) { + store := NewGtsStore(nil) + result := store.ValidateSchemaChain("not-a-valid-id") + if result.OK { + t.Error("expected failure for invalid GTS ID") + } +} + +func TestValidateSchemaChain_MissingBase(t *testing.T) { + store := NewGtsStore(nil) + // Only register derived, not base + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain.ns.base.v1~x.chain.ns.derived.v1~", + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + }) + result := store.ValidateSchemaChain("gts.x.chain.ns.base.v1~x.chain.ns.derived.v1~") + if result.OK { + t.Error("expected failure when base schema is missing") + } +} + +func TestValidateSchemaChain_TwoLevel_Compatible(t *testing.T) { + store := NewGtsStore(nil) + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain.ns.animal.v1~", + "type": "object", + "required": []any{"name"}, + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }) + // Derived tightens name with a minLength — compatible + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain.ns.animal.v1~x.chain.ns.dog.v1~", + "type": "object", + "required": []any{"name"}, + "properties": map[string]any{ + "name": map[string]any{"type": "string", "minLength": 1.0}, + "breed": map[string]any{"type": "string"}, + }, + }) + result := store.ValidateSchemaChain("gts.x.chain.ns.animal.v1~x.chain.ns.dog.v1~") + if !result.OK { + t.Errorf("expected compatible two-level chain, got: %s", result.Error) + } +} + +func TestValidateSchemaChain_TwoLevel_TypeChange(t *testing.T) { + store := NewGtsStore(nil) + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain.ns.item.v1~", + "type": "object", + "properties": map[string]any{ + "count": map[string]any{"type": "integer"}, + }, + }) + // Derived changes count type — incompatible + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain.ns.item.v1~x.chain.ns.item2.v1~", + "type": "object", + "properties": map[string]any{ + "count": map[string]any{"type": "string"}, + }, + }) + result := store.ValidateSchemaChain("gts.x.chain.ns.item.v1~x.chain.ns.item2.v1~") + if result.OK { + t.Error("expected failure for type change in derived schema") + } +} + +func TestValidateSchemaChain_TwoLevel_LoosensAdditionalProperties(t *testing.T) { + store := NewGtsStore(nil) + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain.ns.closed.v1~", + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + }) + // Derived omits additionalProperties:false — loosening + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain.ns.closed.v1~x.chain.ns.open.v1~", + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + }) + result := store.ValidateSchemaChain("gts.x.chain.ns.closed.v1~x.chain.ns.open.v1~") + if result.OK { + t.Error("expected failure when derived loosens additionalProperties") + } +} + +func TestValidateSchemaChain_TwoLevel_RemovesRequired(t *testing.T) { + store := NewGtsStore(nil) + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain.ns.req.v1~", + "type": "object", + "required": []any{"id", "name"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + }, + }) + // Derived drops 'name' from required + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain.ns.req.v1~x.chain.ns.req2.v1~", + "type": "object", + "required": []any{"id"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "name": map[string]any{"type": "string"}, + }, + }) + result := store.ValidateSchemaChain("gts.x.chain.ns.req.v1~x.chain.ns.req2.v1~") + if result.OK { + t.Error("expected failure when derived removes a required field") + } +} + +func TestValidateSchemaChain_ThreeLevel_AllCompatible(t *testing.T) { + store := NewGtsStore(nil) + // A + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain3.ns.a.v1~", + "type": "object", + "required": []any{"id"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + }) + // A~B — adds optional field + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain3.ns.a.v1~x.chain3.ns.b.v1~", + "type": "object", + "required": []any{"id"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "label": map[string]any{"type": "string"}, + }, + }) + // A~B~C — tightens label minLength + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain3.ns.a.v1~x.chain3.ns.b.v1~x.chain3.ns.c.v1~", + "type": "object", + "required": []any{"id"}, + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "label": map[string]any{"type": "string", "minLength": 1.0}, + }, + }) + result := store.ValidateSchemaChain("gts.x.chain3.ns.a.v1~x.chain3.ns.b.v1~x.chain3.ns.c.v1~") + if !result.OK { + t.Errorf("expected three-level chain to be valid, got: %s", result.Error) + } +} + +func TestValidateSchemaChain_ThreeLevel_MiddleIncompatible(t *testing.T) { + store := NewGtsStore(nil) + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain3b.ns.a.v1~", + "type": "object", + "properties": map[string]any{ + "val": map[string]any{"type": "string"}, + }, + }) + // B changes val type — incompatible with A + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain3b.ns.a.v1~x.chain3b.ns.b.v1~", + "type": "object", + "properties": map[string]any{ + "val": map[string]any{"type": "integer"}, + }, + }) + mustRegister(t, store, map[string]any{ + "$id": "gts.x.chain3b.ns.a.v1~x.chain3b.ns.b.v1~x.chain3b.ns.c.v1~", + "type": "object", + "properties": map[string]any{ + "val": map[string]any{"type": "integer"}, + }, + }) + result := store.ValidateSchemaChain("gts.x.chain3b.ns.a.v1~x.chain3b.ns.b.v1~x.chain3b.ns.c.v1~") + if result.OK { + t.Error("expected failure when middle of chain is incompatible") + } +} + +func TestValidateSchemaChain_CircularRef(t *testing.T) { + store := NewGtsStore(nil) + // Schema that directly references itself via $ref + mustRegister(t, store, map[string]any{ + "$id": "gts.x.cyclic.ns.self.v1~", + "type": "object", + "properties": map[string]any{ + "child": map[string]any{"$ref": "gts.x.cyclic.ns.self.v1~"}, + }, + }) + mustRegister(t, store, map[string]any{ + "$id": "gts.x.cyclic.ns.self.v1~x.cyclic.ns.derived.v1~", + "type": "object", + "properties": map[string]any{ + "child": map[string]any{"$ref": "gts.x.cyclic.ns.self.v1~"}, + }, + }) + result := store.ValidateSchemaChain("gts.x.cyclic.ns.self.v1~x.cyclic.ns.derived.v1~") + if result.OK { + t.Fatalf("expected ValidateSchemaChain to fail for circular $ref schema, got ok=true") + } + if !strings.Contains(strings.ToLower(result.Error), "circular") { + t.Errorf("expected error to contain 'circular', got: %s", result.Error) + } +} + +// ============================================================================= +// comparePropertyConstraints — nested object recursion +// ============================================================================= + +func TestComparePropertyConstraints_NestedObject(t *testing.T) { + t.Run("compatible nested object", func(t *testing.T) { + base := map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + } + derived := map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + } + errs := comparePropertyConstraints(base, derived, "obj") + if len(errs) != 0 { + t.Errorf("expected no errors, got %v", errs) + } + }) + + t.Run("nested type change is an error", func(t *testing.T) { + base := map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{"type": "integer"}, + }, + } + derived := map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{"type": "string"}, + }, + } + errs := comparePropertyConstraints(base, derived, "obj") + if len(errs) == 0 { + t.Error("expected error for nested type change") + } + }) +} diff --git a/gts/schema_traits.go b/gts/schema_traits.go new file mode 100644 index 0000000..b55e9c1 --- /dev/null +++ b/gts/schema_traits.go @@ -0,0 +1,705 @@ +/* +Copyright © 2025 Global Type System +Released under Apache License 2.0 +*/ + +package gts + +// OP#13 – Schema Traits Validation (x-gts-traits-schema / x-gts-traits) +// +// Validates that trait values provided in derived schemas conform to the +// effective trait schema built from the entire inheritance chain. +// +// Algorithm: +// 1. Walk the chain from leftmost (base) to rightmost (leaf) segment. +// 2. For each schema in the chain, collect: +// - x-gts-traits-schema objects → compose via allOf into the effective trait schema. +// - x-gts-traits objects → shallow-merge (rightmost wins) into the effective traits object. +// +// 3. Apply defaults from the effective trait schema to fill unresolved trait properties. +// 4. Validate the effective traits object against the effective trait schema. + +import ( + "fmt" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +const maxTraitsRecursionDepth = 64 + +// walkAllOf calls fn on the given schema and recursively on every item inside its allOf array. +// Recursion is capped at maxTraitsRecursionDepth to prevent infinite loops on cyclic schemas. +func walkAllOf(value map[string]any, depth int, fn func(map[string]any)) { + if depth >= maxTraitsRecursionDepth { + return + } + fn(value) + if allOf, ok := value["allOf"].([]any); ok { + for _, item := range allOf { + if sub, ok := item.(map[string]any); ok { + walkAllOf(sub, depth+1, fn) + } + } + } +} + +// collectTraitSchemaFromValue recursively searches a schema value for x-gts-traits-schema entries. +// Handles both top-level and allOf-nested occurrences. +func collectTraitSchemaFromValue(value map[string]any, out *[]map[string]any, depth int) { + walkAllOf(value, depth, func(node map[string]any) { + if ts, ok := node["x-gts-traits-schema"]; ok { + if tsMap, ok := ts.(map[string]any); ok { + *out = append(*out, tsMap) + } else { + // Non-object trait schema — still collect it as a sentinel (nil) to signal presence + *out = append(*out, nil) + } + } + }) +} + +// collectTraitsFromValue recursively searches a schema value for x-gts-traits entries and merges them. +func collectTraitsFromValue(value map[string]any, merged map[string]any, depth int) { + walkAllOf(value, depth, func(node map[string]any) { + if traits, ok := node["x-gts-traits"].(map[string]any); ok { + for k, v := range traits { + merged[k] = v + } + } + }) +} + +// buildEffectiveTraitSchema composes all collected trait schemas using allOf. +func buildEffectiveTraitSchema(schemas []map[string]any) map[string]any { + switch len(schemas) { + case 0: + return map[string]any{} + case 1: + if schemas[0] == nil { + return map[string]any{} + } + return schemas[0] + default: + allOf := make([]any, 0, len(schemas)) + for _, s := range schemas { + if s != nil { + allOf = append(allOf, s) + } + } + return map[string]any{ + "type": "object", + "allOf": allOf, + } + } +} + +type namedProp struct { + name string + schema map[string]any +} + +// collectAllProperties collects all property definitions from a schema, handling allOf composition. +// Later definitions override earlier ones (rightmost-wins semantics). +func collectAllProperties(schema map[string]any, depth int) []namedProp { + if depth >= maxTraitsRecursionDepth { + return nil + } + + // Use ordered insertion into a map to deduplicate (last write wins). + order := make([]string, 0) + byName := make(map[string]map[string]any) + + var collect func(s map[string]any, d int) + collect = func(s map[string]any, d int) { + if d >= maxTraitsRecursionDepth { + return + } + if propsMap, ok := s["properties"].(map[string]any); ok { + for k, v := range propsMap { + if propSchema, ok := v.(map[string]any); ok { + if _, seen := byName[k]; !seen { + order = append(order, k) + } + byName[k] = propSchema + } + } + } + if allOf, ok := s["allOf"].([]any); ok { + for _, item := range allOf { + if sub, ok := item.(map[string]any); ok { + collect(sub, d+1) + } + } + } + } + collect(schema, depth) + + result := make([]namedProp, 0, len(order)) + for _, name := range order { + result = append(result, namedProp{name, byName[name]}) + } + return result +} + +// applyDefaults applies JSON Schema default values from the effective trait schema +// to the merged traits object for any properties that are not yet present. +func applyDefaults(traitSchema map[string]any, traits map[string]any, depth int) map[string]any { + if depth >= maxTraitsRecursionDepth { + return traits + } + + result := make(map[string]any) + for k, v := range traits { + result[k] = v + } + + props := collectAllProperties(traitSchema, 0) + for _, p := range props { + if _, exists := result[p.name]; !exists { + if def, ok := p.schema["default"]; ok { + result[p.name] = def + } else if p.schema["type"] == "object" { + if _, hasProps := p.schema["properties"]; hasProps { + // Parent absent but sub-properties may have defaults — recurse with empty map. + sub := applyDefaults(p.schema, map[string]any{}, depth+1) + if len(sub) > 0 { + result[p.name] = sub + } + } + } + } else if p.schema["type"] == "object" { + if _, hasProps := p.schema["properties"]; hasProps { + if existing, ok := result[p.name].(map[string]any); ok { + result[p.name] = applyDefaults(p.schema, existing, depth+1) + } + } + } + } + + return result +} + +// validateTraitsAgainstSchema validates the effective traits object against the effective trait schema. +func validateTraitsAgainstSchema(traitSchema map[string]any, effectiveTraits map[string]any, checkUnresolved bool) []string { + var errors []string + + // Use jsonschema library for standard JSON Schema validation + compiler := jsonschema.NewCompiler() + + // Register lenient format validators + lenientValidator := func(v any) error { return nil } + formats := []string{ + "uuid", "date-time", "date", "time", "email", "hostname", + "ipv4", "ipv6", "uri", "uri-reference", "iri", "iri-reference", + "uri-template", "json-pointer", "relative-json-pointer", "regex", + } + for _, fmt := range formats { + compiler.RegisterFormat(&jsonschema.Format{ + Name: fmt, + Validate: lenientValidator, + }) + } + + // Remove x-gts-ref and x-gts-traits from schema before validation + cleanSchema := removeXGtsFields(traitSchema) + + schemaID := "gts://internal/trait-schema" + if err := compiler.AddResource(schemaID, cleanSchema); err != nil { + errors = append(errors, fmt.Sprintf("failed to compile trait schema: %v", err)) + return errors + } + + compiled, err := compiler.Compile(schemaID) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to compile trait schema: %v", err)) + return errors + } + + if verr := compiled.Validate(effectiveTraits); verr != nil { + errors = append(errors, fmt.Sprintf("trait validation: %v", verr)) + } + + if !checkUnresolved { + return errors + } + + // Check for unresolved (missing) trait properties that have no default, + // recursing into nested object sub-properties. + errors = append(errors, checkUnresolvedProps(traitSchema, effectiveTraits, "")...) + + return errors +} + +// checkUnresolvedProps recursively checks that all trait properties have either a value +// in traits or a default in the schema. Per spec §9.7.5: "if a trait is required by the +// effective trait schema (i.e., not covered by a default) but is not provided by any +// x-gts-traits in the chain, schema validation MUST fail". A property without a default +// is implicitly required regardless of the JSON Schema 'required' array. +func checkUnresolvedProps(schema map[string]any, traits map[string]any, prefix string) []string { + var errors []string + for _, p := range collectAllProperties(schema, 0) { + fullName := p.name + if prefix != "" { + fullName = prefix + "." + p.name + } + val, hasValue := traits[p.name] + _, hasDefault := p.schema["default"] + if !hasValue && !hasDefault { + propType, _ := p.schema["type"].(string) + if propType == "" { + propType = "any" + } + errors = append(errors, fmt.Sprintf( + "trait property '%s' (type: %s) is not resolved: no value provided and no default defined in the trait schema", + fullName, propType, + )) + } else if p.schema["type"] == "object" { + if _, hasProps := p.schema["properties"]; hasProps { + var subTraits map[string]any + if valMap, ok := val.(map[string]any); ok { + subTraits = valMap + } else { + subTraits = map[string]any{} + } + errors = append(errors, checkUnresolvedProps(p.schema, subTraits, fullName)...) + } + } + } + return errors +} + +// containsXGtsTraits reports whether a schema map contains an 'x-gts-traits' key +// at its top level or nested inside any allOf items (recursively). +func containsXGtsTraits(schema map[string]any) bool { + found := false + walkAllOf(schema, 0, func(node map[string]any) { + if _, ok := node["x-gts-traits"]; ok { + found = true + } + }) + return found +} + +// removeXGtsFields removes x-gts-* extension fields from a schema recursively. +func removeXGtsFields(schema map[string]any) map[string]any { + return walkSchema(schema, nil, func(k string) bool { + return strings.HasPrefix(k, "x-gts-") + }) +} + +// ValidateSchemaTraitsResult is the result of OP#13 schema traits validation. +type ValidateSchemaTraitsResult struct { + SchemaID string `json:"schema_id"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +// ValidateSchemaTraits validates schema traits across the inheritance chain (OP#13). +// Walks the chain from base to leaf, collects x-gts-traits-schema and x-gts-traits +// from each level's raw content, then validates. +func (s *GtsStore) ValidateSchemaTraits(schemaID string) *ValidateSchemaTraitsResult { + gid, err := NewGtsID(schemaID) + if err != nil { + return &ValidateSchemaTraitsResult{ + SchemaID: schemaID, + OK: false, + Error: fmt.Sprintf("Invalid GTS ID: %v", err), + } + } + + segments := gid.Segments + + // levelInfo holds per-level data collected during the chain walk. + type levelInfo struct { + segSchemaID string + rawSchemas []map[string]any // raw (unresolved) trait schemas from this level + traits map[string]any // x-gts-traits collected from this level + } + + // Pass 1: walk the chain and collect raw trait schemas and trait values per level. + var allLevels []levelInfo + var traitSchemas []map[string]any + + for i := range segments { + segSchemaID := buildIDFromSegments(segments[:i+1]) + + entity := s.Get(segSchemaID) + if entity == nil { + return &ValidateSchemaTraitsResult{ + SchemaID: schemaID, + OK: false, + Error: fmt.Sprintf("Schema '%s' not found for trait validation", segSchemaID), + } + } + + content := entity.Content + + var rawSchemas []map[string]any + collectTraitSchemaFromValue(content, &rawSchemas, 0) + traitSchemas = append(traitSchemas, rawSchemas...) + + levelTraits := make(map[string]any) + collectTraitsFromValue(content, levelTraits, 0) + + allLevels = append(allLevels, levelInfo{ + segSchemaID: segSchemaID, + rawSchemas: rawSchemas, + traits: levelTraits, + }) + } + + // Pass 2: normalize $$ref and resolve $ref in all collected trait schemas. + for i, ts := range traitSchemas { + if ts == nil { + continue + } + normalized := normalizeDollarRefs(ts) + resolved, err := s.resolveRefs(normalized) + if err != nil { + return &ValidateSchemaTraitsResult{ + SchemaID: schemaID, + OK: false, + Error: fmt.Sprintf("Schema '%s' trait schema has %v", schemaID, err), + } + } + traitSchemas[i] = resolved + } + + // Build a per-level slice of resolved schemas (parallel to allLevels). + resolvedSchemasByLevel := make([][]map[string]any, len(allLevels)) + idx := 0 + for li, lv := range allLevels { + resolvedSchemasByLevel[li] = traitSchemas[idx : idx+len(lv.rawSchemas)] + idx += len(lv.rawSchemas) + } + + // Pass 3: run cross-level checks against resolved schemas, then merge traits. + mergedTraits := make(map[string]any) + lockedTraits := make(map[string]bool) + knownDefaults := make(map[string]any) + + for li, lv := range allLevels { + // Track which properties this level's resolved trait schemas introduce. + levelSchemaProps := make(map[string]bool) + for _, ts := range resolvedSchemasByLevel[li] { + if ts == nil { + continue + } + for _, p := range collectAllProperties(ts, 0) { + levelSchemaProps[p.name] = true + if newDefault, ok := p.schema["default"]; ok { + if oldDefault, exists := knownDefaults[p.name]; exists { + if !jsonEqual(oldDefault, newDefault) { + return &ValidateSchemaTraitsResult{ + SchemaID: schemaID, + OK: false, + Error: fmt.Sprintf( + "Schema '%s' trait validation failed: trait schema default for '%s' in '%s' overrides default set by ancestor", + schemaID, p.name, lv.segSchemaID, + ), + } + } + } else { + knownDefaults[p.name] = newDefault + } + } + } + } + + // Check for locked trait overrides BEFORE merging this level's values. + for k, v := range lv.traits { + if existing, exists := mergedTraits[k]; exists { + if lockedTraits[k] && !jsonEqual(existing, v) { + return &ValidateSchemaTraitsResult{ + SchemaID: schemaID, + OK: false, + Error: fmt.Sprintf( + "Schema '%s' trait validation failed: trait '%s' in '%s' overrides value set by ancestor", + schemaID, k, lv.segSchemaID, + ), + } + } + } + } + + // Merge level traits (rightmost wins). + for k, v := range lv.traits { + mergedTraits[k] = v + } + + // Lock trait values set at this level only when this level does NOT introduce + // a schema property for the key (via the resolved schema). + for k := range lv.traits { + if !levelSchemaProps[k] { + lockedTraits[k] = true + } + } + } + + // Check for x-gts-traits-schema integrity: must not contain x-gts-traits anywhere + // (including nested inside allOf items). + for i, ts := range traitSchemas { + if ts == nil { + // Non-object trait schema — will fail validation below + continue + } + if containsXGtsTraits(ts) { + return &ValidateSchemaTraitsResult{ + SchemaID: schemaID, + OK: false, + Error: fmt.Sprintf( + "x-gts-traits-schema[%d] contains 'x-gts-traits' — trait values must not appear inside a trait schema definition", + i, + ), + } + } + } + + // Check: if no trait schemas, but trait values exist → error + hasTraitValues := len(mergedTraits) > 0 + if len(traitSchemas) == 0 { + if hasTraitValues { + return &ValidateSchemaTraitsResult{ + SchemaID: schemaID, + OK: false, + Error: "x-gts-traits values provided but no x-gts-traits-schema is defined in the inheritance chain", + } + } + return &ValidateSchemaTraitsResult{SchemaID: schemaID, OK: true} + } + + // Check for nil (non-object) trait schemas and enforce type:object + for i, ts := range traitSchemas { + if ts == nil { + return &ValidateSchemaTraitsResult{ + SchemaID: schemaID, + OK: false, + Error: fmt.Sprintf("x-gts-traits-schema[%d] is not a valid JSON Schema object", i), + } + } + if t, _ := ts["type"].(string); t != "object" { + return &ValidateSchemaTraitsResult{ + SchemaID: schemaID, + OK: false, + Error: fmt.Sprintf("x-gts-traits-schema[%d] must have \"type\": \"object\"", i), + } + } + } + + // Build effective trait schema + effectiveTraitSchema := buildEffectiveTraitSchema(traitSchemas) + + // Apply defaults + effectiveTraits := applyDefaults(effectiveTraitSchema, mergedTraits, 0) + + // Validate + errs := validateTraitsAgainstSchema(effectiveTraitSchema, effectiveTraits, true) + if len(errs) > 0 { + return &ValidateSchemaTraitsResult{ + SchemaID: schemaID, + OK: false, + Error: fmt.Sprintf("Schema '%s' trait validation failed: %s", schemaID, strings.Join(errs, "; ")), + } + } + + return &ValidateSchemaTraitsResult{SchemaID: schemaID, OK: true} +} + +// walkSchema applies a key transform and a recursive map transform to every node in a schema. +// keyFn renames keys; valFn transforms map values (called after key rename). +func walkSchema(m map[string]any, keyFn func(string) string, skipKey func(string) bool) map[string]any { + result := make(map[string]any, len(m)) + for k, v := range m { + if skipKey != nil && skipKey(k) { + continue + } + newKey := k + if keyFn != nil { + newKey = keyFn(k) + } + switch val := v.(type) { + case map[string]any: + result[newKey] = walkSchema(val, keyFn, skipKey) + case []any: + newArr := make([]any, len(val)) + for i, item := range val { + if sub, ok := item.(map[string]any); ok { + newArr[i] = walkSchema(sub, keyFn, skipKey) + } else { + newArr[i] = item + } + } + result[newKey] = newArr + default: + result[newKey] = v + } + } + return result +} + +// normalizeDollarRefs converts $$ref → $ref throughout a schema map. +func normalizeDollarRefs(m map[string]any) map[string]any { + return walkSchema(m, func(k string) string { + if k == "$$ref" { + return "$ref" + } + return k + }, nil) +} + +// validateEntityLevelTraits checks entity-level trait constraints: +// - If a trait schema is defined, trait values must be provided. +// - Each trait schema must be closed (additionalProperties: false). +func (s *GtsStore) validateEntityLevelTraits(schemaID string) error { + gid, err := NewGtsID(schemaID) + if err != nil { + return fmt.Errorf("invalid GTS ID: %v", err) + } + + segments := gid.Segments + var rawTraitSchemas []map[string]any + hasTraitValues := false + + for i := range segments { + segSchemaID := buildIDFromSegments(segments[:i+1]) + entity := s.Get(segSchemaID) + if entity == nil { + return fmt.Errorf("schema '%s' not found", segSchemaID) + } + content := entity.Content + collectTraitSchemaFromValue(content, &rawTraitSchemas, 0) + levelTraits := make(map[string]any) + collectTraitsFromValue(content, levelTraits, 0) + if len(levelTraits) > 0 { + hasTraitValues = true + } + } + + if len(rawTraitSchemas) == 0 { + return nil + } + + if !hasTraitValues { + return fmt.Errorf("entity defines x-gts-traits-schema but no x-gts-traits values are provided") + } + + // Resolve $refs before checking additionalProperties + traitSchemas := make([]map[string]any, 0, len(rawTraitSchemas)) + for _, ts := range rawTraitSchemas { + if ts == nil { + continue + } + normalized := normalizeDollarRefs(ts) + resolved, err := s.resolveRefs(normalized) + if err != nil { + return fmt.Errorf("entity trait schema has %v", err) + } + traitSchemas = append(traitSchemas, resolved) + } + + for _, ts := range traitSchemas { + ap, hasAP := ts["additionalProperties"] + if !hasAP { + return fmt.Errorf("entity trait schema must set additionalProperties: false to be a valid standalone entity") + } + if b, ok := ap.(bool); !ok || b { + return fmt.Errorf("entity trait schema must set additionalProperties: false to be a valid standalone entity") + } + } + + return nil +} + +// ValidateEntityResult is the result of OP#13 entity-level validation. +type ValidateEntityResult struct { + EntityID string `json:"entity_id"` + EntityType string `json:"entity_type"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +// ValidateEntity validates an entity by running both OP#12 (schema chain) and OP#13 (traits). +// The entity_id can be either a schema ID or an instance ID. +func (s *GtsStore) ValidateEntity(entityID string) *ValidateEntityResult { + entity := s.Get(entityID) + if entity == nil { + return &ValidateEntityResult{ + EntityID: entityID, + OK: false, + Error: fmt.Sprintf("Entity '%s' not found", entityID), + } + } + + if entity.IsSchema { + // For schemas: run OP#12 chain validation + OP#13 traits validation + chainResult := s.ValidateSchemaChain(entityID) + if !chainResult.OK { + return &ValidateEntityResult{ + EntityID: entityID, + EntityType: "schema", + OK: false, + Error: chainResult.Error, + } + } + + traitsResult := s.ValidateSchemaTraits(entityID) + if !traitsResult.OK { + return &ValidateEntityResult{ + EntityID: entityID, + EntityType: "schema", + OK: false, + Error: traitsResult.Error, + } + } + + // Entity-level trait check: schema must have trait values if it defines a trait schema, + // and all trait schemas must be closed (additionalProperties: false). + if err := s.validateEntityLevelTraits(entityID); err != nil { + return &ValidateEntityResult{ + EntityID: entityID, + EntityType: "schema", + OK: false, + Error: err.Error(), + } + } + + return &ValidateEntityResult{EntityID: entityID, EntityType: "schema", OK: true} + } + + // For instances: validate against schema + instanceResult := s.ValidateInstance(entityID) + if !instanceResult.OK { + return &ValidateEntityResult{ + EntityID: entityID, + EntityType: "instance", + OK: false, + Error: instanceResult.Error, + } + } + + // Also run OP#12 chain validation and OP#13 traits validation on the schema + if entity.SchemaID != "" { + chainResult := s.ValidateSchemaChain(entity.SchemaID) + if !chainResult.OK { + return &ValidateEntityResult{ + EntityID: entityID, + EntityType: "instance", + OK: false, + Error: chainResult.Error, + } + } + + traitsResult := s.ValidateSchemaTraits(entity.SchemaID) + if !traitsResult.OK { + return &ValidateEntityResult{ + EntityID: entityID, + EntityType: "instance", + OK: false, + Error: traitsResult.Error, + } + } + } + + return &ValidateEntityResult{EntityID: entityID, EntityType: "instance", OK: true} +} diff --git a/gts/schema_traits_test.go b/gts/schema_traits_test.go new file mode 100644 index 0000000..81e1936 --- /dev/null +++ b/gts/schema_traits_test.go @@ -0,0 +1,871 @@ +/* +Copyright © 2025 Global Type System +Released under Apache License 2.0 +*/ + +package gts + +import ( + "testing" +) + +// ============================================================================= +// walkSchema / normalizeDollarRefs / removeXGtsFields +// ============================================================================= + +func TestWalkSchema_SkipKeys(t *testing.T) { + input := map[string]any{ + "type": "object", + "x-gts-traits": map[string]any{"color": "red"}, + "x-gts-ref": "gts.x.foo.ns.bar.v1~", + "properties": map[string]any{"id": map[string]any{"type": "string", "x-gts-ref": "gts.*"}}, + } + result := walkSchema(input, nil, func(k string) bool { + return k == "x-gts-traits" || k == "x-gts-ref" + }) + if _, ok := result["x-gts-traits"]; ok { + t.Error("x-gts-traits should have been skipped") + } + if _, ok := result["x-gts-ref"]; ok { + t.Error("x-gts-ref should have been skipped") + } + if _, ok := result["type"]; !ok { + t.Error("type should be preserved") + } + // Nested removal + props, _ := result["properties"].(map[string]any) + idProp, _ := props["id"].(map[string]any) + if _, ok := idProp["x-gts-ref"]; ok { + t.Error("nested x-gts-ref should have been removed") + } +} + +func TestWalkSchema_RenameKeys(t *testing.T) { + input := map[string]any{ + "$$ref": "gts.x.foo.ns.bar.v1~", + "type": "object", + } + result := normalizeDollarRefs(input) + if _, ok := result["$ref"]; !ok { + t.Error("$$ref should be renamed to $ref") + } + if _, ok := result["$$ref"]; ok { + t.Error("$$ref should be gone after rename") + } +} + +func TestNormalizeDollarRefs_Nested(t *testing.T) { + input := map[string]any{ + "allOf": []any{ + map[string]any{"$$ref": "gts.x.foo.ns.bar.v1~"}, + }, + } + result := normalizeDollarRefs(input) + allOf, _ := result["allOf"].([]any) + if len(allOf) != 1 { + t.Fatalf("expected 1 allOf item, got %d", len(allOf)) + } + item, _ := allOf[0].(map[string]any) + if _, ok := item["$ref"]; !ok { + t.Error("nested $$ref should be renamed to $ref") + } +} + +func TestRemoveXGtsFields(t *testing.T) { + input := map[string]any{ + "type": "object", + "x-gts-traits": map[string]any{"k": "v"}, + "x-gts-traits-schema": map[string]any{"type": "object"}, + "properties": map[string]any{ + "id": map[string]any{ + "type": "string", + "x-gts-ref": "gts.*", + }, + }, + } + result := removeXGtsFields(input) + for _, k := range []string{"x-gts-traits", "x-gts-traits-schema"} { + if _, ok := result[k]; ok { + t.Errorf("key %q should have been removed", k) + } + } + props, _ := result["properties"].(map[string]any) + idProp, _ := props["id"].(map[string]any) + if _, ok := idProp["x-gts-ref"]; ok { + t.Error("nested x-gts-ref should have been removed") + } +} + +// ============================================================================= +// collectTraitSchemaFromValue +// ============================================================================= + +func TestCollectTraitSchemaFromValue(t *testing.T) { + t.Run("top-level x-gts-traits-schema", func(t *testing.T) { + schema := map[string]any{ + "x-gts-traits-schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string"}, + }, + }, + } + var out []map[string]any + collectTraitSchemaFromValue(schema, &out, 0) + if len(out) != 1 { + t.Fatalf("expected 1 trait schema, got %d", len(out)) + } + if out[0] == nil { + t.Error("expected non-nil trait schema") + } + }) + + t.Run("non-object x-gts-traits-schema appends nil sentinel", func(t *testing.T) { + schema := map[string]any{ + "x-gts-traits-schema": "invalid", + } + var out []map[string]any + collectTraitSchemaFromValue(schema, &out, 0) + if len(out) != 1 || out[0] != nil { + t.Error("expected one nil sentinel for non-object trait schema") + } + }) + + t.Run("allOf nested x-gts-traits-schema", func(t *testing.T) { + schema := map[string]any{ + "allOf": []any{ + map[string]any{ + "x-gts-traits-schema": map[string]any{"type": "object"}, + }, + map[string]any{ + "x-gts-traits-schema": map[string]any{"type": "object"}, + }, + }, + } + var out []map[string]any + collectTraitSchemaFromValue(schema, &out, 0) + if len(out) != 2 { + t.Errorf("expected 2 trait schemas from allOf, got %d", len(out)) + } + }) + + t.Run("no x-gts-traits-schema", func(t *testing.T) { + var out []map[string]any + collectTraitSchemaFromValue(map[string]any{"type": "object"}, &out, 0) + if len(out) != 0 { + t.Errorf("expected 0 trait schemas, got %d", len(out)) + } + }) + + t.Run("depth limit stops recursion", func(t *testing.T) { + var out []map[string]any + collectTraitSchemaFromValue(map[string]any{"x-gts-traits-schema": map[string]any{}}, &out, maxTraitsRecursionDepth) + if len(out) != 0 { + t.Error("should not collect at max depth") + } + }) +} + +// ============================================================================= +// collectTraitsFromValue +// ============================================================================= + +func TestCollectTraitsFromValue(t *testing.T) { + t.Run("top-level x-gts-traits", func(t *testing.T) { + schema := map[string]any{ + "x-gts-traits": map[string]any{"color": "red", "size": "large"}, + } + merged := make(map[string]any) + collectTraitsFromValue(schema, merged, 0) + if merged["color"] != "red" || merged["size"] != "large" { + t.Errorf("expected traits to be merged, got %v", merged) + } + }) + + t.Run("allOf nested x-gts-traits merged (rightmost wins)", func(t *testing.T) { + schema := map[string]any{ + "allOf": []any{ + map[string]any{"x-gts-traits": map[string]any{"color": "red"}}, + map[string]any{"x-gts-traits": map[string]any{"color": "blue", "size": "small"}}, + }, + } + merged := make(map[string]any) + collectTraitsFromValue(schema, merged, 0) + if merged["color"] != "blue" { + t.Errorf("rightmost should win: expected blue, got %v", merged["color"]) + } + if merged["size"] != "small" { + t.Errorf("expected size=small, got %v", merged["size"]) + } + }) + + t.Run("no x-gts-traits", func(t *testing.T) { + merged := make(map[string]any) + collectTraitsFromValue(map[string]any{"type": "object"}, merged, 0) + if len(merged) != 0 { + t.Errorf("expected empty merged map, got %v", merged) + } + }) + + t.Run("depth limit stops recursion", func(t *testing.T) { + merged := make(map[string]any) + collectTraitsFromValue(map[string]any{"x-gts-traits": map[string]any{"k": "v"}}, merged, maxTraitsRecursionDepth) + if len(merged) != 0 { + t.Error("should not collect at max depth") + } + }) +} + +// ============================================================================= +// buildEffectiveTraitSchema +// ============================================================================= + +func TestBuildEffectiveTraitSchema(t *testing.T) { + tests := []struct { + name string + schemas []map[string]any + wantEmpty bool + wantAllOf bool + wantDirect bool + }{ + { + name: "empty input returns empty map", + schemas: []map[string]any{}, + wantEmpty: true, + }, + { + name: "single nil returns empty map", + schemas: []map[string]any{nil}, + wantEmpty: true, + }, + { + name: "single non-nil returns it directly", + schemas: []map[string]any{{"type": "object"}}, + wantDirect: true, + }, + { + name: "two schemas wrapped in allOf", + schemas: []map[string]any{{"type": "object"}, {"type": "object"}}, + wantAllOf: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildEffectiveTraitSchema(tt.schemas) + if tt.wantEmpty && len(result) != 0 { + t.Errorf("expected empty map, got %v", result) + } + if tt.wantAllOf { + if _, ok := result["allOf"]; !ok { + t.Errorf("expected allOf key in result, got %v", result) + } + } + if tt.wantDirect { + if _, ok := result["type"]; !ok { + t.Errorf("expected direct schema pass-through, got %v", result) + } + } + }) + } +} + +func TestBuildEffectiveTraitSchema_NilsInMultiple(t *testing.T) { + // Nils mixed with valid schemas: nils should be filtered out of allOf + schemas := []map[string]any{nil, {"type": "object"}, nil, {"properties": map[string]any{}}} + result := buildEffectiveTraitSchema(schemas) + allOf, ok := result["allOf"].([]any) + if !ok { + t.Fatalf("expected allOf, got %v", result) + } + if len(allOf) != 2 { + t.Errorf("expected 2 items in allOf (nils filtered), got %d", len(allOf)) + } +} + +// ============================================================================= +// collectAllProperties +// ============================================================================= + +func TestCollectAllProperties(t *testing.T) { + t.Run("direct properties", func(t *testing.T) { + schema := map[string]any{ + "properties": map[string]any{ + "color": map[string]any{"type": "string"}, + "size": map[string]any{"type": "string"}, + }, + } + props := collectAllProperties(schema, 0) + names := make(map[string]bool) + for _, p := range props { + names[p.name] = true + } + if !names["color"] || !names["size"] { + t.Errorf("expected color and size, got %v", props) + } + }) + + t.Run("allOf nested properties merged", func(t *testing.T) { + schema := map[string]any{ + "allOf": []any{ + map[string]any{"properties": map[string]any{ + "a": map[string]any{"type": "string"}, + }}, + map[string]any{"properties": map[string]any{ + "b": map[string]any{"type": "integer"}, + }}, + }, + } + props := collectAllProperties(schema, 0) + names := make(map[string]bool) + for _, p := range props { + names[p.name] = true + } + if !names["a"] || !names["b"] { + t.Errorf("expected a and b from allOf, got %v", names) + } + }) + + t.Run("later definition overwrites earlier (last-write wins)", func(t *testing.T) { + schema := map[string]any{ + "allOf": []any{ + map[string]any{"properties": map[string]any{ + "x": map[string]any{"type": "string", "default": "first"}, + }}, + map[string]any{"properties": map[string]any{ + "x": map[string]any{"type": "string", "default": "second"}, + }}, + }, + } + props := collectAllProperties(schema, 0) + if len(props) != 1 { + t.Fatalf("expected 1 deduplicated property, got %d", len(props)) + } + if props[0].schema["default"] != "second" { + t.Errorf("expected last-write-wins, got %v", props[0].schema["default"]) + } + }) + + t.Run("depth limit returns nil", func(t *testing.T) { + schema := map[string]any{ + "properties": map[string]any{"x": map[string]any{"type": "string"}}, + } + props := collectAllProperties(schema, maxTraitsRecursionDepth) + if props != nil { + t.Error("expected nil at max depth") + } + }) +} + +// ============================================================================= +// applyDefaults +// ============================================================================= + +func TestApplyDefaults(t *testing.T) { + t.Run("fills missing property with default", func(t *testing.T) { + traitSchema := map[string]any{ + "properties": map[string]any{ + "color": map[string]any{"type": "string", "default": "blue"}, + "size": map[string]any{"type": "string"}, + }, + } + traits := map[string]any{"size": "large"} + result := applyDefaults(traitSchema, traits, 0) + if result["color"] != "blue" { + t.Errorf("expected default 'blue' for color, got %v", result["color"]) + } + if result["size"] != "large" { + t.Errorf("expected existing 'large' for size, got %v", result["size"]) + } + }) + + t.Run("does not overwrite existing value", func(t *testing.T) { + traitSchema := map[string]any{ + "properties": map[string]any{ + "color": map[string]any{"type": "string", "default": "blue"}, + }, + } + traits := map[string]any{"color": "red"} + result := applyDefaults(traitSchema, traits, 0) + if result["color"] != "red" { + t.Errorf("existing value should not be overwritten, got %v", result["color"]) + } + }) + + t.Run("no default — leaves property absent", func(t *testing.T) { + traitSchema := map[string]any{ + "properties": map[string]any{ + "required_trait": map[string]any{"type": "string"}, + }, + } + result := applyDefaults(traitSchema, map[string]any{}, 0) + if _, ok := result["required_trait"]; ok { + t.Error("property without default should not appear in result") + } + }) + + t.Run("nested object defaults applied recursively", func(t *testing.T) { + traitSchema := map[string]any{ + "properties": map[string]any{ + "meta": map[string]any{ + "type": "object", + "properties": map[string]any{ + "version": map[string]any{"type": "string", "default": "1.0"}, + }, + }, + }, + } + traits := map[string]any{"meta": map[string]any{}} + result := applyDefaults(traitSchema, traits, 0) + meta, ok := result["meta"].(map[string]any) + if !ok { + t.Fatal("expected meta to be a map") + } + if meta["version"] != "1.0" { + t.Errorf("expected nested default '1.0' for version, got %v", meta["version"]) + } + }) + + t.Run("depth limit returns original traits", func(t *testing.T) { + traitSchema := map[string]any{ + "properties": map[string]any{ + "x": map[string]any{"default": "val"}, + }, + } + traits := map[string]any{} + result := applyDefaults(traitSchema, traits, maxTraitsRecursionDepth) + if _, ok := result["x"]; ok { + t.Error("should not apply defaults at max depth") + } + }) +} + +// ============================================================================= +// validateTraitsAgainstSchema +// ============================================================================= + +func TestValidateTraitsAgainstSchema(t *testing.T) { + t.Run("valid traits pass", func(t *testing.T) { + traitSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string"}, + "count": map[string]any{"type": "integer"}, + }, + "required": []any{"color"}, + } + traits := map[string]any{"color": "red", "count": 3.0} + errs := validateTraitsAgainstSchema(traitSchema, traits, false) + if len(errs) != 0 { + t.Errorf("expected no errors, got %v", errs) + } + }) + + t.Run("missing required trait fails", func(t *testing.T) { + traitSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string"}, + }, + "required": []any{"color"}, + } + errs := validateTraitsAgainstSchema(traitSchema, map[string]any{}, false) + if len(errs) == 0 { + t.Error("expected error for missing required trait") + } + }) + + t.Run("wrong type fails", func(t *testing.T) { + traitSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{"type": "integer"}, + }, + } + traits := map[string]any{"count": "not-a-number"} + errs := validateTraitsAgainstSchema(traitSchema, traits, false) + if len(errs) == 0 { + t.Error("expected error for wrong type") + } + }) + + t.Run("checkUnresolved flags unresolved properties without defaults", func(t *testing.T) { + traitSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "unresolved": map[string]any{"type": "string"}, + }, + } + errs := validateTraitsAgainstSchema(traitSchema, map[string]any{}, true) + if len(errs) == 0 { + t.Error("expected error for unresolved trait without default") + } + }) + + t.Run("unresolved property with default is ok", func(t *testing.T) { + traitSchema := map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string", "default": "blue"}, + }, + } + errs := validateTraitsAgainstSchema(traitSchema, map[string]any{}, true) + if len(errs) != 0 { + t.Errorf("property with default should not be flagged as unresolved, got %v", errs) + } + }) +} + +// ============================================================================= +// ValidateSchemaTraits integration tests +// ============================================================================= + +func mustRegisterTraits(t *testing.T, store *GtsStore, content map[string]any) { + t.Helper() + if _, ok := content["$schema"]; !ok { + content["$schema"] = "http://json-schema.org/draft-07/schema#" + } + if err := store.Register(NewJsonEntity(content, DefaultGtsConfig())); err != nil { + t.Fatalf("register failed: %v", err) + } +} + +func TestValidateSchemaTraits_InvalidGtsID(t *testing.T) { + store := NewGtsStore(nil) + result := store.ValidateSchemaTraits("not-valid") + if result.OK { + t.Error("expected failure for invalid GTS ID") + } +} + +func TestValidateSchemaTraits_MissingSchema(t *testing.T) { + store := NewGtsStore(nil) + result := store.ValidateSchemaTraits("gts.x.traits.ns.missing.v1~") + if result.OK { + t.Error("expected failure for missing schema") + } +} + +func TestValidateSchemaTraits_NoTraitsAnywhere_OK(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.traits.ns.plain.v1~", + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + }) + result := store.ValidateSchemaTraits("gts.x.traits.ns.plain.v1~") + if !result.OK { + t.Errorf("schema with no traits at all should be valid, got: %s", result.Error) + } +} + +func TestValidateSchemaTraits_TraitValuesWithoutSchema_Fails(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.traits.ns.noschema.v1~", + "type": "object", + "x-gts-traits": map[string]any{"color": "red"}, + }) + result := store.ValidateSchemaTraits("gts.x.traits.ns.noschema.v1~") + if result.OK { + t.Error("expected failure: trait values provided but no trait schema defined") + } +} + +func TestValidateSchemaTraits_NonObjectTraitSchema_Fails(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.traits.ns.badschema.v1~", + "type": "object", + "x-gts-traits-schema": "not-an-object", + "x-gts-traits": map[string]any{"k": "v"}, + }) + result := store.ValidateSchemaTraits("gts.x.traits.ns.badschema.v1~") + if result.OK { + t.Error("expected failure: x-gts-traits-schema is not an object") + } +} + +func TestValidateSchemaTraits_ValidTraits_SingleLevel(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.traits.ns.typed.v1~", + "type": "object", + "x-gts-traits-schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string"}, + "count": map[string]any{"type": "integer"}, + }, + "required": []any{"color"}, + "additionalProperties": false, + }, + "x-gts-traits": map[string]any{"color": "red", "count": 3.0}, + }) + result := store.ValidateSchemaTraits("gts.x.traits.ns.typed.v1~") + if !result.OK { + t.Errorf("expected valid traits, got: %s", result.Error) + } +} + +func TestValidateSchemaTraits_MissingRequiredTrait_Fails(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.traits.ns.missingreq.v1~", + "type": "object", + "x-gts-traits-schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string"}, + }, + "required": []any{"color"}, + "additionalProperties": false, + }, + // x-gts-traits omitted → unresolved required property + }) + result := store.ValidateSchemaTraits("gts.x.traits.ns.missingreq.v1~") + if result.OK { + t.Error("expected failure: required trait 'color' is not provided") + } +} + +func TestValidateSchemaTraits_DefaultFillsMissingTrait(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.traits.ns.defaults.v1~", + "type": "object", + "x-gts-traits-schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string", "default": "blue"}, + }, + "required": []any{"color"}, + "additionalProperties": false, + }, + // No x-gts-traits — default should fill 'color' + }) + result := store.ValidateSchemaTraits("gts.x.traits.ns.defaults.v1~") + if !result.OK { + t.Errorf("expected default to fill required trait, got: %s", result.Error) + } +} + +func TestValidateSchemaTraits_WrongTraitType_Fails(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.traits.ns.wrongtype.v1~", + "type": "object", + "x-gts-traits-schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "count": map[string]any{"type": "integer"}, + }, + "additionalProperties": false, + }, + "x-gts-traits": map[string]any{"count": "not-a-number"}, + }) + result := store.ValidateSchemaTraits("gts.x.traits.ns.wrongtype.v1~") + if result.OK { + t.Error("expected failure: trait 'count' has wrong type") + } +} + +func TestValidateSchemaTraits_InheritedTraitSchema_TwoLevel(t *testing.T) { + store := NewGtsStore(nil) + // Base defines trait schema + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.traits2.ns.base.v1~", + "type": "object", + "x-gts-traits-schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string"}, + }, + "additionalProperties": false, + }, + }) + // Derived provides the trait value + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.traits2.ns.base.v1~x.traits2.ns.child.v1~", + "type": "object", + "x-gts-traits": map[string]any{"color": "green"}, + }) + result := store.ValidateSchemaTraits("gts.x.traits2.ns.base.v1~x.traits2.ns.child.v1~") + if !result.OK { + t.Errorf("expected valid inherited trait, got: %s", result.Error) + } +} + +func TestValidateSchemaTraits_TraitSchemaContainsTraitValues_Fails(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.traits.ns.selfcontained.v1~", + "type": "object", + "x-gts-traits-schema": map[string]any{ + "type": "object", + "x-gts-traits": map[string]any{"color": "red"}, + "properties": map[string]any{ + "color": map[string]any{"type": "string"}, + }, + "additionalProperties": false, + }, + "x-gts-traits": map[string]any{"color": "red"}, + }) + result := store.ValidateSchemaTraits("gts.x.traits.ns.selfcontained.v1~") + if result.OK { + t.Error("expected failure: x-gts-traits inside x-gts-traits-schema is not allowed") + } +} + +func TestValidateSchemaTraits_LockedTraitOverride_Fails(t *testing.T) { + store := NewGtsStore(nil) + // Base defines trait value without a schema property — trait becomes locked + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.traits3.ns.base.v1~", + "type": "object", + "x-gts-traits": map[string]any{"color": "red"}, + }) + // Child tries to override with a different value — must fail + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.traits3.ns.base.v1~x.traits3.ns.child.v1~", + "type": "object", + "x-gts-traits": map[string]any{"color": "blue"}, + }) + result := store.ValidateSchemaTraits("gts.x.traits3.ns.base.v1~x.traits3.ns.child.v1~") + if result.OK { + t.Error("expected failure: child overrides locked trait value") + } +} + +func TestValidateSchemaTraits_DuplicateDefaultAcrossChain_Fails(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.traits4.ns.base.v1~", + "type": "object", + "x-gts-traits-schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string", "default": "red"}, + }, + "additionalProperties": false, + }, + }) + // Child redefines default for the same property with a different value — must fail + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.traits4.ns.base.v1~x.traits4.ns.child.v1~", + "type": "object", + "x-gts-traits-schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string", "default": "blue"}, + }, + "additionalProperties": false, + }, + }) + result := store.ValidateSchemaTraits("gts.x.traits4.ns.base.v1~x.traits4.ns.child.v1~") + if result.OK { + t.Error("expected failure: child overrides ancestor's trait schema default") + } +} + +// ============================================================================= +// ValidateEntity integration tests +// ============================================================================= + +func TestValidateEntity_NotFound(t *testing.T) { + store := NewGtsStore(nil) + result := store.ValidateEntity("gts.x.entity.ns.ghost.v1~") + if result.OK { + t.Error("expected failure for non-existent entity") + } +} + +func TestValidateEntity_Schema_Valid(t *testing.T) { + store := NewGtsStore(nil) + // Single-segment schema with a closed trait schema and a matching trait value + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.entity.ns.valid.v1~", + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + "x-gts-traits-schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "color": map[string]any{"type": "string", "default": "blue"}, + }, + "additionalProperties": false, + }, + "x-gts-traits": map[string]any{"color": "red"}, + }) + result := store.ValidateEntity("gts.x.entity.ns.valid.v1~") + if !result.OK { + t.Errorf("expected valid entity, got: %s", result.Error) + } + if result.EntityType != "schema" { + t.Errorf("expected EntityType=schema, got %s", result.EntityType) + } +} + +func TestValidateEntity_Schema_ChainIncompatible(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.entity.ns.typea.v1~", + "type": "object", + "properties": map[string]any{ + "val": map[string]any{"type": "string"}, + }, + }) + // Derived changes val to integer — incompatible + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.entity.ns.typea.v1~x.entity.ns.typeb.v1~", + "type": "object", + "properties": map[string]any{ + "val": map[string]any{"type": "integer"}, + }, + }) + result := store.ValidateEntity("gts.x.entity.ns.typea.v1~x.entity.ns.typeb.v1~") + if result.OK { + t.Error("expected failure due to incompatible chain") + } + if result.EntityType != "schema" { + t.Errorf("expected EntityType=schema, got %s", result.EntityType) + } +} + +func TestValidateEntity_Schema_TraitSchemaWithoutValues_Fails(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.entity.ns.novals.v1~", + "type": "object", + "x-gts-traits-schema": map[string]any{ + "type": "object", + "properties": map[string]any{"color": map[string]any{"type": "string"}}, + "additionalProperties": false, + }, + // No x-gts-traits — entity-level check must fail + }) + result := store.ValidateEntity("gts.x.entity.ns.novals.v1~") + if result.OK { + t.Error("expected failure: entity has trait schema but no trait values") + } +} + +func TestValidateEntity_Schema_TraitSchemaNotClosed_Fails(t *testing.T) { + store := NewGtsStore(nil) + mustRegisterTraits(t, store, map[string]any{ + "$id": "gts.x.entity.ns.notclosed.v1~", + "type": "object", + "x-gts-traits-schema": map[string]any{ + "type": "object", + "properties": map[string]any{"color": map[string]any{"type": "string"}}, + // additionalProperties intentionally absent — must fail entity-level check + }, + "x-gts-traits": map[string]any{"color": "red"}, + }) + result := store.ValidateEntity("gts.x.entity.ns.notclosed.v1~") + if result.OK { + t.Error("expected failure: entity trait schema must have additionalProperties:false") + } +} diff --git a/gts/validate.go b/gts/validate.go index 3eef190..cd427ca 100644 --- a/gts/validate.go +++ b/gts/validate.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/santhosh-tekuri/jsonschema/v6" + "golang.org/x/text/message" ) // gtsURLLoader implements jsonschema.URLLoader for GTS ID reference resolution @@ -105,13 +106,14 @@ func (s *GtsStore) ValidateInstance(gtsID string) *ValidationResult { } } - // Validate x-gts-ref constraints + // Validate x-gts-ref constraints via XGtsRefValidator (separate pass with full + // instance path context for JSON pointer resolution and prefix/self-ref semantics) xGtsRefValidator := NewXGtsRefValidator(s) xGtsRefErrors := xGtsRefValidator.ValidateInstance(obj.Content, schemaEntity.Content, "") if len(xGtsRefErrors) > 0 { var errorMsgs []string - for _, err := range xGtsRefErrors { - errorMsgs = append(errorMsgs, err.Error()) + for _, e := range xGtsRefErrors { + errorMsgs = append(errorMsgs, e.Error()) } return &ValidationResult{ ID: gtsID, @@ -127,6 +129,60 @@ func (s *GtsStore) ValidateInstance(gtsID string) *ValidationResult { } } +// xGtsRefExt is the compiled form of an x-gts-ref keyword for a single schema node. +// Validate enforces the GTS pattern constraint so that oneOf/anyOf/allOf branches +// correctly pass or fail based on whether the value matches the pattern. +// The separate XGtsRefValidator pass handles JSON pointer resolution and other +// schema-level semantics that require full instance path context. +type xGtsRefExt struct { + pattern string + rootSchema map[string]any + store *GtsStore +} + +func (e *xGtsRefExt) Validate(ctx *jsonschema.ValidatorContext, v any) { + str, ok := v.(string) + if !ok { + return + } + // Relative pointer patterns (starting with "/") require the full root schema + // context for resolution — defer those entirely to XGtsRefValidator's separate pass. + if strings.HasPrefix(e.pattern, "/") { + return + } + validator := NewXGtsRefValidator(e.store) + if err := validator.validateRefValue(str, e.pattern, "", e.rootSchema); err != nil { + ctx.AddError(&xGtsRefErrorKind{err.Reason}) + } +} + +// xGtsRefErrorKind implements jsonschema.ErrorKind for x-gts-ref validation errors. +type xGtsRefErrorKind struct{ reason string } + +func (k *xGtsRefErrorKind) KeywordPath() []string { return []string{"x-gts-ref"} } +func (k *xGtsRefErrorKind) LocalizedString(_ *message.Printer) string { return k.reason } + +// newXGtsRefVocabulary registers x-gts-ref as a proper vocabulary with the JSON schema +// compiler. This is the correct fix for the oneOf/anyOf/allOf problem: branches like +// {"x-gts-ref": "gts.x.foo~"} are no longer empty match-all schemas — they carry a +// real constraint that the library evaluates during combinator resolution. +func newXGtsRefVocabulary(store *GtsStore) *jsonschema.Vocabulary { + return &jsonschema.Vocabulary{ + URL: "https://globaltypesystem.io/vocab/x-gts-ref", + Compile: func(_ *jsonschema.CompilerContext, obj map[string]any) (jsonschema.SchemaExt, error) { + raw, ok := obj["x-gts-ref"] + if !ok { + return nil, nil + } + pattern, ok := raw.(string) + if !ok { + return nil, fmt.Errorf("x-gts-ref must be a string") + } + return &xGtsRefExt{pattern: pattern, rootSchema: obj, store: store}, nil + }, + } +} + // validateWithSchema performs the actual JSON Schema validation func (s *GtsStore) validateWithSchema(instance map[string]any, schema map[string]any) error { // Normalize schema to convert $$id to $id and $$schema to $schema for JSON Schema validation @@ -145,6 +201,11 @@ func (s *GtsStore) validateWithSchema(instance map[string]any, schema map[string // Create a custom compiler with GTS reference resolution compiler := jsonschema.NewCompiler() + // Register x-gts-ref as a proper vocabulary so the library treats it as a real + // keyword with validation semantics. This prevents oneOf/anyOf/allOf branches + // containing only x-gts-ref from being treated as empty match-all schemas. + compiler.RegisterVocabulary(newXGtsRefVocabulary(s)) + // Register lenient format validators to match Python's jsonschema behavior // Python's jsonschema library does NOT validate formats by default lenientValidator := func(v any) error { return nil } diff --git a/server/handlers.go b/server/handlers.go index f91899e..9f41c7d 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -464,6 +464,74 @@ func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) { s.writeJSON(w, http.StatusOK, result) } +// OP#12 - Validate Schema (schema-vs-schema chain validation) +func (s *Server) handleValidateSchema(w http.ResponseWriter, r *http.Request) { + var req struct { + SchemaID string `json:"schema_id"` + } + if err := s.readJSON(r, &req); err != nil { + s.writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + if req.SchemaID == "" { + s.writeError(w, http.StatusBadRequest, "Missing schema_id") + return + } + + result := s.store.ValidateSchemaChain(req.SchemaID) + if result.OK { + // Also run OP#13 traits validation + traitsResult := s.store.ValidateSchemaTraits(req.SchemaID) + if !traitsResult.OK { + s.writeJSON(w, http.StatusOK, map[string]any{ + "ok": false, + "error": traitsResult.Error, + }) + return + } + } + + if result.OK { + s.writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + } else { + s.writeJSON(w, http.StatusOK, map[string]any{ + "ok": false, + "error": result.Error, + }) + } +} + +// OP#13 - Validate Entity (schema chain + traits validation) +func (s *Server) handleValidateEntity(w http.ResponseWriter, r *http.Request) { + var req struct { + EntityID string `json:"entity_id"` + GtsID string `json:"gts_id"` + } + if err := s.readJSON(r, &req); err != nil { + s.writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + // Accept either entity_id or gts_id + id := req.EntityID + if id == "" { + id = req.GtsID + } + if id == "" { + s.writeError(w, http.StatusBadRequest, "Missing entity_id or gts_id") + return + } + + result := s.store.ValidateEntity(id) + resp := map[string]any{ + "ok": result.OK, + "entity_type": result.EntityType, + } + if !result.OK { + resp["error"] = result.Error + } + s.writeJSON(w, http.StatusOK, resp) +} + // OP#11 - Attribute Access func (s *Server) handleAttribute(w http.ResponseWriter, r *http.Request) { gtsWithPath := s.getQueryParam(r, "gts_with_path") diff --git a/server/server.go b/server/server.go index c1bc904..23496d2 100644 --- a/server/server.go +++ b/server/server.go @@ -78,6 +78,12 @@ func (s *Server) registerRoutes() { // OP#11 - Attribute Access s.mux.HandleFunc("GET /attr", s.handleAttribute) + + // OP#12 - Validate Schema (schema-vs-schema chain validation) + s.mux.HandleFunc("POST /validate-schema", s.handleValidateSchema) + + // OP#13 - Validate Entity (schema chain + traits validation) + s.mux.HandleFunc("POST /validate-entity", s.handleValidateEntity) } // Start starts the HTTP server @@ -235,6 +241,18 @@ func (s *Server) GetOpenAPISpec() map[string]any { "operationId": "attr", }, }, + "/validate-schema": map[string]any{ + "post": map[string]any{ + "summary": "Validate a derived schema against its chain", + "operationId": "validateSchema", + }, + }, + "/validate-entity": map[string]any{ + "post": map[string]any{ + "summary": "Validate any entity (schema or instance) including traits", + "operationId": "validateEntity", + }, + }, }, } }