From 2b124d93bd0f3cbe1f9f6287d9d4daf94864eb6a Mon Sep 17 00:00:00 2001 From: mattgarmon Date: Fri, 20 Feb 2026 12:10:39 -0700 Subject: [PATCH 1/7] chore: bump gts-spec Signed-off-by: mattgarmon --- .gts-spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gts-spec b/.gts-spec index 56ca20d..48f15e0 160000 --- a/.gts-spec +++ b/.gts-spec @@ -1 +1 @@ -Subproject commit 56ca20d89df2c2d8e70773da33482e4c748c446d +Subproject commit 48f15e0e2fdb14f8cda10b02f1f13b4253173959 From d41ab52f89e26fe8453490a7fea848ee0eb4db11 Mon Sep 17 00:00:00 2001 From: mattgarmon Date: Fri, 20 Feb 2026 12:11:30 -0700 Subject: [PATCH 2/7] feat: support combined anonymous instance identifiers Signed-off-by: mattgarmon --- gts/gts.go | 51 ++++++++++++++++++++++++++++++++++-------- gts/gts_test.go | 53 ++++++++++++++++++++++++++++++++++++++++++++ gts/gts_uuid_test.go | 5 +++++ gts/parse.go | 3 +++ 4 files changed, 103 insertions(+), 9 deletions(-) diff --git a/gts/gts.go b/gts/gts.go index 8702528..457fab8 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,13 @@ 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 { + _, err := uuid.Parse(s) + if err != nil { + return false + } + // uuid.Parse accepts uppercase; enforce lowercase per GTS spec + return s == strings.ToLower(s) +} 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, } } From 94509345ca733e27663722429ddbefe9ea036d5a Mon Sep 17 00:00:00 2001 From: mattgarmon Date: Fri, 20 Feb 2026 13:58:25 -0700 Subject: [PATCH 3/7] feat: support x-gts-ref combinators (oneOf/anyOf/allOf) Signed-off-by: mattgarmon --- gts/validate.go | 67 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 3 deletions(-) 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 } From 4c3ff92ce9d89a531e61cb38157e3b58ce2e6143 Mon Sep 17 00:00:00 2001 From: mattgarmon Date: Fri, 20 Feb 2026 15:29:26 -0700 Subject: [PATCH 4/7] feat: op12 and op13 - schema and entity validation Signed-off-by: mattgarmon --- .gts-spec | 2 +- README.md | 10 +- cmd/gts/main.go | 4 + cmd/gts/validate_entity.go | 42 +++ cmd/gts/validate_schema.go | 39 ++ gts/gts.go | 9 +- gts/schema_compat.go | 735 +++++++++++++++++++++++++++++++++++++ gts/schema_traits.go | 609 ++++++++++++++++++++++++++++++ server/handlers.go | 68 ++++ server/server.go | 18 + 10 files changed, 1531 insertions(+), 5 deletions(-) create mode 100644 cmd/gts/validate_entity.go create mode 100644 cmd/gts/validate_schema.go create mode 100644 gts/schema_compat.go create mode 100644 gts/schema_traits.go diff --git a/.gts-spec b/.gts-spec index 48f15e0..4ed5421 160000 --- a/.gts-spec +++ b/.gts-spec @@ -1 +1 @@ -Subproject commit 48f15e0e2fdb14f8cda10b02f1f13b4253173959 +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..249835e --- /dev/null +++ b/cmd/gts/validate_entity.go @@ -0,0 +1,42 @@ +/* +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() + } + + 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..71e614c --- /dev/null +++ b/cmd/gts/validate_schema.go @@ -0,0 +1,39 @@ +/* +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() + } + + store := newStore() + result := store.ValidateSchemaChain(validateSchemaID) + writeJSON(result) +} diff --git a/gts/gts.go b/gts/gts.go index 457fab8..132ce6a 100644 --- a/gts/gts.go +++ b/gts/gts.go @@ -417,10 +417,13 @@ func parseSegment(num, offset int, segment string) (*GtsIDSegment, error) { // isUUIDSegment returns true if s is a valid RFC 4122 UUID (lowercase hex with hyphens) func isUUIDSegment(s string) bool { - _, err := uuid.Parse(s) + u, err := uuid.Parse(s) if err != nil { return false } - // uuid.Parse accepts uppercase; enforce lowercase per GTS spec - return s == strings.ToLower(s) + 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/schema_compat.go b/gts/schema_compat.go new file mode 100644 index 0000000..101ac00 --- /dev/null +++ b/gts/schema_compat.go @@ -0,0 +1,735 @@ +/* +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" + "reflect" + "strings" +) + +// effectiveSchema holds the flattened view of a schema used for compatibility comparison. +type effectiveSchema struct { + properties map[string]any + required map[string]bool + requiredSet bool // true if the schema explicitly declared a "required" array + additionalProperties any // nil means not set +} + +// extractEffectiveSchema extracts properties, required, and additionalProperties +// from a fully-resolved JSON Schema value, merging allOf items. +func extractEffectiveSchema(schema map[string]any) *effectiveSchema { + eff := &effectiveSchema{ + properties: make(map[string]any), + required: make(map[string]bool), + } + extractEffectiveSchemaInto(schema, eff) + return eff +} + +func extractEffectiveSchemaInto(schema map[string]any, eff *effectiveSchema) { + if schema == nil { + return + } + + // Direct properties + if props, ok := schema["properties"].(map[string]any); ok { + for k, v := range props { + eff.properties[k] = v + } + } + + // Required + if req, ok := schema["required"].([]any); ok { + eff.requiredSet = true + for _, v := range req { + if s, ok := v.(string); ok { + eff.required[s] = true + } + } + } + + // additionalProperties + if ap, ok := schema["additionalProperties"]; ok { + eff.additionalProperties = ap + } + + // allOf – merge from all items + if allOf, ok := schema["allOf"].([]any); ok { + for _, item := range allOf { + if sub, ok := item.(map[string]any); ok { + extractEffectiveSchemaInto(sub, eff) + } + } + } +} + +// validateSchemaCompatibility validates that a derived schema is compatible with its base. +// Returns a list of human-readable error descriptions (empty = compatible). +func validateSchemaCompatibility(base, derived *effectiveSchema, baseID, derivedID string) []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 { + // Property exists in both – check for disabling (false) + 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 + } + // Compare constraints + 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 { + // New property in derived – base forbids additional properties + errors = append(errors, fmt.Sprintf( + "property '%s': derived schema '%s' adds new property but base '%s' has additionalProperties: false", + propName, derivedID, baseID, + )) + } + } + + // Check if derived loosens additionalProperties constraint. + // When base has additionalProperties: false, derived must also explicitly + // set additionalProperties: false. Omitting it is also loosening. + 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, + )) + } + } + + // Check that derived doesn't remove fields from base's required set + errors = append(errors, checkRequiredRemoval(base, derived, baseID, derivedID)...) + + return errors +} + +// comparePropertyConstraints compares constraints between base and derived property schemas. +func comparePropertyConstraints(baseProp, derivedProp map[string]any, propName string) []string { + var errors []string + + // Type compatibility + errors = append(errors, checkTypeCompatibility(baseProp, derivedProp, propName)...) + + // Collect derived enumerated values (const or enum) + derivedValues, derivedEnumerates := collectDerivedEnumeratedValues(derivedProp) + + // const compatibility + errors = append(errors, checkConstCompatibility(baseProp, derivedProp, propName)...) + + if derivedEnumerates { + // Derived enumerates values: verify every value satisfies base bounds + errors = append(errors, checkEnumeratedValuesAgainstBase(baseProp, derivedValues, propName)...) + } else { + // No enumeration: require keyword-level constraints to be preserved/tightened + 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, "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, "minItems", propName, false)...) + } + + // enum compatibility + errors = append(errors, checkEnumCompatibility(baseProp, derivedProp, propName)...) + + // Array items sub-schema comparison + errors = append(errors, checkItemsCompatibility(baseProp, derivedProp, propName)...) + + // Recurse for nested object properties + 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") + 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 + } + derivedType, hasDerived := derivedProp["type"] + if !hasDerived { + return []string{fmt.Sprintf( + "property '%s': derived omits type constraint (%v) defined in base", + propName, baseType, + )} + } + if !reflect.DeepEqual(baseType, derivedType) { + return []string{fmt.Sprintf( + "property '%s': derived changes type from %v to %v", + propName, baseType, derivedType, + )} + } + return nil +} + +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, + )} + } + // Compare using JSON equality + 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, + )} + } + 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 + } + // Check if derived has enum (subset check) + 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 + } + // Check if derived has const (must be in base enum) + 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 + } + // Neither enum nor const — loosening + 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) + } + return nil +} + +func checkRequiredRemoval(base, derived *effectiveSchema, baseID, derivedID string) []string { + // Only skip if derived omits required entirely; an explicit empty list must be validated + if !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 +} + +// checkBound checks that a numeric constraint is preserved or tightened in the derived schema. +// upper=true means derived value must be <= base (e.g. maxLength); upper=false means >= (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 +} + +// collectDerivedEnumeratedValues returns the concrete values from 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 enumerated 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"} { + baseVal, hasBase := getFloat(baseProp, keyword) + if !hasBase { + continue + } + for _, val := range values { + n, ok := numericValueFor(val, keyword) + if ok && n < baseVal { + 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"} { + baseVal, hasBase := getFloat(baseProp, keyword) + if !hasBase { + continue + } + for _, val := range values { + n, ok := numericValueFor(val, keyword) + if ok && n > baseVal { + errors = append(errors, fmt.Sprintf( + "property '%s': derived const/enum value %v violates base %s (%v)", + propName, val, keyword, baseVal, + )) + } + } + } + + return errors +} + +// numericValueFor extracts a 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(len(s)), true + } + case "minItems", "maxItems": + if arr, ok := val.([]any); ok { + return float64(len(arr)), true + } + default: + return toFloat64(val) + } + return 0, false +} + +// getFloat safely extracts a float64 from a map. +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 +} + +// stringSliceContains checks if a string is in a slice. +func stringSliceContains(slice []string, s string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} + +// anySliceContains checks if a value is in a slice using JSON equality. +func anySliceContains(slice []any, val any) bool { + for _, item := range slice { + if jsonEqual(item, val) { + return true + } + } + return false +} + +// jsonEqual compares two values using JSON serialization for deep equality. +func jsonEqual(a, b any) bool { + aj, _ := json.Marshal(a) + bj, _ := json.Marshal(b) + return string(aj) == string(bj) +} + +// 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 a chained schema ID by checking each derived schema +// against its base (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), + } + } + + // Single-segment schemas have no parent to validate against + if len(gid.Segments) < 2 { + return &ValidateSchemaChainResult{SchemaID: schemaID, OK: true} + } + + // Build pairs of (base_id, derived_id) for each adjacent level + segments := gid.Segments + for i := 0; i < len(segments)-1; i++ { + baseID := buildIDFromSegments(segments[:i+1]) + derivedID := buildIDFromSegments(segments[:i+2]) + + // Check for circular refs in both schemas + 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) + 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() +} + +// 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 $ref references in a raw schema map, detecting cycles. +func (s *GtsStore) resolveRefs(schema map[string]any) (map[string]any, error) { + visited := make(map[string]bool) + cycleFound := false + resolved := s.resolveRefsInner(schema, visited, &cycleFound, true) + if cycleFound { + return nil, fmt.Errorf("circular $ref detected") + } + if m, ok := resolved.(map[string]any); ok { + return m, nil + } + return schema, nil +} + +// resolveRefsInner recursively resolves $ref references in a schema value. +func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFound *bool, strict bool) any { + switch v := schema.(type) { + case map[string]any: + // Handle $ref + if refVal, ok := v["$ref"].(string); ok { + // Local refs are kept as-is + if strings.HasPrefix(refVal, "#") { + result := make(map[string]any) + for k, val := range v { + result[k] = s.resolveRefsInner(val, visited, cycleFound, strict) + } + return result + } + + // Normalize: strip gts:// prefix + canonical := strings.TrimPrefix(refVal, GtsURIPrefix) + + // Cycle detection + 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, strict) + } + } + if len(result) == 0 { + return schema + } + return result + } + + // Try to resolve + entity := s.Get(canonical) + if entity != nil && entity.IsSchema { + visited[canonical] = true + resolved := s.resolveRefsInner(entity.Content, visited, cycleFound, strict) + if !strict { + delete(visited, canonical) + } + + // Remove $id and $schema from resolved content + if resolvedMap, ok := resolved.(map[string]any); ok { + delete(resolvedMap, "$id") + delete(resolvedMap, "$schema") + + // If original object has only $ref, return resolved schema + if len(v) == 1 { + return resolvedMap + } + + // Merge resolved schema with other properties + merged := make(map[string]any) + for k, val := range resolvedMap { + merged[k] = val + } + for k, val := range v { + if k != "$ref" { + merged[k] = s.resolveRefsInner(val, visited, cycleFound, strict) + } + } + return merged + } + } + + // Can't resolve — remove $ref, keep other properties + result := make(map[string]any) + for k, val := range v { + if k != "$ref" { + result[k] = s.resolveRefsInner(val, visited, cycleFound, strict) + } + } + if len(result) > 0 { + return result + } + return schema + } + + // Special handling for allOf: merge properties+required from resolved items + // (but NOT additionalProperties — matches Rust resolve_schema_refs_inner behavior) + if allOf, ok := v["allOf"].([]any); ok { + var resolvedAllOf []any + mergedProps := make(map[string]any) + var mergedRequired []string + + for _, item := range allOf { + resolved := s.resolveRefsInner(item, visited, cycleFound, strict) + if resolvedMap, ok := resolved.(map[string]any); ok { + if _, stillHasRef := resolvedMap["$ref"]; stillHasRef { + resolvedAllOf = append(resolvedAllOf, resolved) + } else { + // Merge only properties and required + 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 s, ok := rv.(string); ok { + if !stringSliceContains(mergedRequired, s) { + mergedRequired = append(mergedRequired, s) + } + } + } + } + } + } else { + resolvedAllOf = append(resolvedAllOf, resolved) + } + } + + if len(mergedProps) > 0 { + // Build merged schema without allOf + merged := make(map[string]any) + for k, val := range v { + if k != "allOf" { + merged[k] = val + } + } + merged["properties"] = mergedProps + 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 + } + } + + // Recursively process all properties + result := make(map[string]any) + for k, val := range v { + result[k] = s.resolveRefsInner(val, visited, cycleFound, strict) + } + return result + + case []any: + result := make([]any, len(v)) + for i, item := range v { + result[i] = s.resolveRefsInner(item, visited, cycleFound, strict) + } + return result + + default: + return schema + } +} diff --git a/gts/schema_traits.go b/gts/schema_traits.go new file mode 100644 index 0000000..9934500 --- /dev/null +++ b/gts/schema_traits.go @@ -0,0 +1,609 @@ +/* +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 + +// 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) { + if depth >= maxTraitsRecursionDepth { + return + } + + if ts, ok := value["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) + } + } + + if allOf, ok := value["allOf"].([]any); ok { + for _, item := range allOf { + if sub, ok := item.(map[string]any); ok { + collectTraitSchemaFromValue(sub, out, depth+1) + } + } + } +} + +// 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) { + if depth >= maxTraitsRecursionDepth { + return + } + + if traits, ok := value["x-gts-traits"].(map[string]any); ok { + for k, v := range traits { + merged[k] = v + } + } + + if allOf, ok := value["allOf"].([]any); ok { + for _, item := range allOf { + if sub, ok := item.(map[string]any); ok { + collectTraitsFromValue(sub, merged, depth+1) + } + } + } +} + +// 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 { + 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 + for _, p := range collectAllProperties(traitSchema, 0) { + _, hasValue := effectiveTraits[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", + p.name, propType, + )) + } + } + + return errors +} + +// 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 + + var traitSchemas []map[string]any + mergedTraits := make(map[string]any) + lockedTraits := make(map[string]bool) + knownDefaults := make(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 + + // Collect x-gts-traits-schema from raw content + prevCount := len(traitSchemas) + collectTraitSchemaFromValue(content, &traitSchemas, 0) + + // Track which properties this level's trait schema introduces + levelSchemaProps := make(map[string]bool) + for _, ts := range traitSchemas[prevCount:] { + 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, segSchemaID, + ), + } + } + } else { + knownDefaults[p.name] = newDefault + } + } + } + } + + // Collect x-gts-traits from raw content + levelTraits := make(map[string]any) + collectTraitsFromValue(content, levelTraits, 0) + + // Check for locked trait overrides + for k, v := range levelTraits { + if existing, exists := mergedTraits[k]; exists { + if !jsonEqual(existing, v) && lockedTraits[k] { + 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, segSchemaID, + ), + } + } + } + } + + // Mark trait values as locked or unlocked + for k := range levelTraits { + if levelSchemaProps[k] { + delete(lockedTraits, k) + } else { + lockedTraits[k] = true + } + } + + // Merge level traits (rightmost wins) + for k, v := range levelTraits { + mergedTraits[k] = v + } + } + + // Normalize $$ref → $ref in collected trait schemas, then resolve $ref references + 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 + } + + // Check for x-gts-traits-schema integrity: must not contain x-gts-traits + for i, ts := range traitSchemas { + if ts == nil { + // Non-object trait schema — will fail validation below + continue + } + if _, hasTraits := ts["x-gts-traits"]; hasTraits { + 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 + 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), + } + } + } + + // 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 traitSchemas []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, &traitSchemas, 0) + levelTraits := make(map[string]any) + collectTraitsFromValue(content, levelTraits, 0) + if len(levelTraits) > 0 { + hasTraitValues = true + } + } + + if len(traitSchemas) == 0 { + return nil + } + + if !hasTraitValues { + return fmt.Errorf("entity defines x-gts-traits-schema but no x-gts-traits values are provided") + } + + for _, ts := range traitSchemas { + if ts == nil { + continue + } + 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 traits validation on the schema chain + if entity.SchemaID != "" { + 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/server/handlers.go b/server/handlers.go index f91899e..364ce1a 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") + 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", + }, + }, }, } } From 172c2cd4fb8998e547081ce3fa1f2b449768becf Mon Sep 17 00:00:00 2001 From: mattgarmon Date: Tue, 24 Feb 2026 16:19:42 -0700 Subject: [PATCH 5/7] fix: address review comments Signed-off-by: mattgarmon --- cmd/gts/validate_entity.go | 1 + cmd/gts/validate_schema.go | 1 + gts/schema_compat.go | 115 +++++++++++++++++++++++++++++++------ server/handlers.go | 2 +- 4 files changed, 100 insertions(+), 19 deletions(-) diff --git a/cmd/gts/validate_entity.go b/cmd/gts/validate_entity.go index 249835e..c5ebce8 100644 --- a/cmd/gts/validate_entity.go +++ b/cmd/gts/validate_entity.go @@ -34,6 +34,7 @@ func init() { func runValidateEntity(cmd *Command, args []string) { if validateEntityID == "" { cmd.Usage() + return } store := newStore() diff --git a/cmd/gts/validate_schema.go b/cmd/gts/validate_schema.go index 71e614c..6b55549 100644 --- a/cmd/gts/validate_schema.go +++ b/cmd/gts/validate_schema.go @@ -31,6 +31,7 @@ func init() { func runValidateSchema(cmd *Command, args []string) { if validateSchemaID == "" { cmd.Usage() + return } store := newStore() diff --git a/gts/schema_compat.go b/gts/schema_compat.go index 101ac00..91a8317 100644 --- a/gts/schema_compat.go +++ b/gts/schema_compat.go @@ -20,8 +20,8 @@ package gts import ( "encoding/json" "fmt" - "reflect" "strings" + "unicode/utf8" ) // effectiveSchema holds the flattened view of a schema used for compatibility comparison. @@ -82,7 +82,9 @@ func extractEffectiveSchemaInto(schema map[string]any, eff *effectiveSchema) { // validateSchemaCompatibility validates that a derived schema is compatible with its base. // Returns a list of human-readable error descriptions (empty = compatible). -func validateSchemaCompatibility(base, derived *effectiveSchema, baseID, derivedID string) []string { +// nested must be true when called recursively for a nested object property; it suppresses +// checks that are only valid at the top level (e.g. omitted base properties). +func validateSchemaCompatibility(base, derived *effectiveSchema, baseID, derivedID string, nested bool) []string { var errors []string baseDisallowsAdditional := false @@ -121,6 +123,39 @@ func validateSchemaCompatibility(base, derived *effectiveSchema, baseID, derived } } + // Check base properties for removals and re-enablings + for propName, baseProp := range base.properties { + derivedProp, exists := derived.properties[propName] + if b, ok := baseProp.(bool); ok && !b { + // base disabled the property; derived must not re-enable it + if !exists { + // derived omits the property entirely — treated as re-enabling + 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 _, baseIsObj := baseProp.(map[string]any); baseIsObj && !nested { + // base defines an object schema; at top level derived must not omit or replace it + if !exists { + errors = append(errors, fmt.Sprintf( + "property '%s': derived schema '%s' omits property defined in base '%s'", + propName, derivedID, baseID, + )) + } else 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, + )) + } + } + } + // Check if derived loosens additionalProperties constraint. // When base has additionalProperties: false, derived must also explicitly // set additionalProperties: false. Omitting it is also loosening. @@ -137,8 +172,9 @@ func validateSchemaCompatibility(base, derived *effectiveSchema, baseID, derived } } - // Check that derived doesn't remove fields from base's required set - errors = append(errors, checkRequiredRemoval(base, derived, baseID, derivedID)...) + // Check that derived doesn't remove fields from base's required set. + // In nested context, omitting required is allowed (partial overlay). + errors = append(errors, checkRequiredRemoval(base, derived, baseID, derivedID, nested)...) return errors } @@ -183,7 +219,7 @@ func comparePropertyConstraints(baseProp, derivedProp map[string]any, propName s if _, hasProps := baseProp["properties"]; hasProps { baseNested := extractEffectiveSchema(baseProp) derivedNested := extractEffectiveSchema(derivedProp) - nestedErrors := validateSchemaCompatibility(baseNested, derivedNested, "base", "derived") + nestedErrors := validateSchemaCompatibility(baseNested, derivedNested, "base", "derived", true) for _, e := range nestedErrors { errors = append(errors, fmt.Sprintf("in nested object '%s': %s", propName, e)) } @@ -205,15 +241,37 @@ func checkTypeCompatibility(baseProp, derivedProp map[string]any, propName strin propName, baseType, )} } - if !reflect.DeepEqual(baseType, derivedType) { - return []string{fmt.Sprintf( - "property '%s': derived changes type from %v to %v", - propName, baseType, derivedType, - )} + baseSet := typeToSet(baseType) + derivedSet := typeToSet(derivedType) + for dt := range derivedSet { + if !baseSet[dt] { + return []string{fmt.Sprintf( + "property '%s': derived changes type from %v to %v", + propName, baseType, derivedType, + )} + } } return nil } +// typeToSet normalises a JSON Schema "type" value (string or []any) 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 { @@ -310,12 +368,19 @@ func checkItemsCompatibility(baseProp, derivedProp map[string]any, propName stri 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) []string { - // Only skip if derived omits required entirely; an explicit empty list must be validated - if !derived.requiredSet { +func checkRequiredRemoval(base, derived *effectiveSchema, baseID, derivedID string, nested bool) []string { + // In nested object context a partial overlay legitimately omits required; + // only enforce when derived explicitly declares required or we are at top level. + if nested && !derived.requiredSet { return nil } var errors []string @@ -409,7 +474,7 @@ func numericValueFor(val any, keyword string) (float64, bool) { switch keyword { case "minLength", "maxLength": if s, ok := val.(string); ok { - return float64(len(s)), true + return float64(utf8.RuneCountInString(s)), true } case "minItems", "maxItems": if arr, ok := val.([]any); ok { @@ -527,7 +592,7 @@ func (s *GtsStore) ValidateSchemaChain(schemaID string) *ValidateSchemaChainResu baseEff := extractEffectiveSchema(baseContent) derivedEff := extractEffectiveSchema(derivedContent) - errs := validateSchemaCompatibility(baseEff, derivedEff, baseID, derivedID) + errs := validateSchemaCompatibility(baseEff, derivedEff, baseID, derivedID, false) if len(errs) > 0 { return &ValidateSchemaChainResult{ SchemaID: schemaID, @@ -617,9 +682,7 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo if entity != nil && entity.IsSchema { visited[canonical] = true resolved := s.resolveRefsInner(entity.Content, visited, cycleFound, strict) - if !strict { - delete(visited, canonical) - } + delete(visited, canonical) // Remove $id and $schema from resolved content if resolvedMap, ok := resolved.(map[string]any); ok { @@ -665,6 +728,22 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo mergedProps := make(map[string]any) var mergedRequired []string + // Pre-scan: flag duplicate $ref targets within this allOf as a cycle. + // Sibling duplicates are degenerate schemas and must not be resolved + // twice (which would misfire the ancestor visited-map check). + allOfRefs := 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 allOfRefs[canonical] { + *cycleFound = true + } + allOfRefs[canonical] = true + } + } + } + for _, item := range allOf { resolved := s.resolveRefsInner(item, visited, cycleFound, strict) if resolvedMap, ok := resolved.(map[string]any); ok { diff --git a/server/handlers.go b/server/handlers.go index 364ce1a..9f41c7d 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -517,7 +517,7 @@ func (s *Server) handleValidateEntity(w http.ResponseWriter, r *http.Request) { id = req.GtsID } if id == "" { - s.writeError(w, http.StatusBadRequest, "Missing entity_id") + s.writeError(w, http.StatusBadRequest, "Missing entity_id or gts_id") return } From b287a767e550d21d440cb5d886bb2feedf4adb13 Mon Sep 17 00:00:00 2001 From: mattgarmon Date: Tue, 24 Feb 2026 17:32:02 -0700 Subject: [PATCH 6/7] feat: unit tests for op12 and op13 Signed-off-by: mattgarmon --- gts/schema_compat.go | 81 ++-- gts/schema_compat_test.go | 916 ++++++++++++++++++++++++++++++++++++++ gts/schema_traits_test.go | 871 ++++++++++++++++++++++++++++++++++++ 3 files changed, 1840 insertions(+), 28 deletions(-) create mode 100644 gts/schema_compat_test.go create mode 100644 gts/schema_traits_test.go diff --git a/gts/schema_compat.go b/gts/schema_compat.go index 91a8317..4e99c94 100644 --- a/gts/schema_compat.go +++ b/gts/schema_compat.go @@ -129,11 +129,18 @@ func validateSchemaCompatibility(base, derived *effectiveSchema, baseID, derived if b, ok := baseProp.(bool); ok && !b { // base disabled the property; derived must not re-enable it if !exists { - // derived omits the property entirely — treated as re-enabling - errors = append(errors, fmt.Sprintf( - "property '%s': derived schema '%s' re-enables property disabled in base '%s'", - propName, derivedID, baseID, - )) + // derived omits the property entirely — only counts as re-enabling + // when the derived schema 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'", @@ -634,10 +641,14 @@ func (s *GtsStore) resolveSchemaRefsChecked(schemaID string) (map[string]any, er func (s *GtsStore) resolveRefs(schema map[string]any) (map[string]any, error) { visited := make(map[string]bool) cycleFound := false - resolved := s.resolveRefsInner(schema, visited, &cycleFound, true) + dupFound := false + resolved := s.resolveRefsInner(schema, visited, &cycleFound, &dupFound, true) 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 { return m, nil } @@ -645,7 +656,7 @@ func (s *GtsStore) resolveRefs(schema map[string]any) (map[string]any, error) { } // resolveRefsInner recursively resolves $ref references in a schema value. -func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFound *bool, strict bool) any { +func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFound *bool, dupFound *bool, strict bool) any { switch v := schema.(type) { case map[string]any: // Handle $ref @@ -654,7 +665,7 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo if strings.HasPrefix(refVal, "#") { result := make(map[string]any) for k, val := range v { - result[k] = s.resolveRefsInner(val, visited, cycleFound, strict) + result[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound, strict) } return result } @@ -668,7 +679,7 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo result := make(map[string]any) for k, val := range v { if k != "$ref" { - result[k] = s.resolveRefsInner(val, visited, cycleFound, strict) + result[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound, strict) } } if len(result) == 0 { @@ -681,7 +692,7 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo entity := s.Get(canonical) if entity != nil && entity.IsSchema { visited[canonical] = true - resolved := s.resolveRefsInner(entity.Content, visited, cycleFound, strict) + resolved := s.resolveRefsInner(entity.Content, visited, cycleFound, dupFound, strict) delete(visited, canonical) // Remove $id and $schema from resolved content @@ -701,7 +712,7 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo } for k, val := range v { if k != "$ref" { - merged[k] = s.resolveRefsInner(val, visited, cycleFound, strict) + merged[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound, strict) } } return merged @@ -712,7 +723,7 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo result := make(map[string]any) for k, val := range v { if k != "$ref" { - result[k] = s.resolveRefsInner(val, visited, cycleFound, strict) + result[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound, strict) } } if len(result) > 0 { @@ -728,29 +739,26 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo mergedProps := make(map[string]any) var mergedRequired []string - // Pre-scan: flag duplicate $ref targets within this allOf as a cycle. - // Sibling duplicates are degenerate schemas and must not be resolved - // twice (which would misfire the ancestor visited-map check). - allOfRefs := make(map[string]bool) + seenRefs := make(map[string]bool) + mergedOther := make(map[string]any) for _, item := range allOf { + // Skip duplicate sibling $ref targets (flag them so resolveRefs can report the error) if itemMap, ok := item.(map[string]any); ok { if refVal, ok := itemMap["$ref"].(string); ok { canonical := strings.TrimPrefix(refVal, GtsURIPrefix) - if allOfRefs[canonical] { - *cycleFound = true + if seenRefs[canonical] { + *dupFound = true + continue } - allOfRefs[canonical] = true + seenRefs[canonical] = true } } - } - - for _, item := range allOf { - resolved := s.resolveRefsInner(item, visited, cycleFound, strict) + resolved := s.resolveRefsInner(item, visited, cycleFound, dupFound, strict) if resolvedMap, ok := resolved.(map[string]any); ok { if _, stillHasRef := resolvedMap["$ref"]; stillHasRef { resolvedAllOf = append(resolvedAllOf, resolved) } else { - // Merge only properties and required + // Merge properties and required if props, ok := resolvedMap["properties"].(map[string]any); ok { for k, pv := range props { mergedProps[k] = pv @@ -765,13 +773,22 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo } } } + // Merge all other constraint keys (but NOT additionalProperties — + // matches Rust resolve_schema_refs_inner behavior; lifting it + // would silently inject constraints into the parent schema) + for k, val := range resolvedMap { + if k == "properties" || k == "required" || k == "$id" || k == "$schema" || k == "additionalProperties" { + continue + } + mergedOther[k] = val + } } } else { resolvedAllOf = append(resolvedAllOf, resolved) } } - if len(mergedProps) > 0 { + if len(mergedProps) > 0 || len(mergedOther) > 0 { // Build merged schema without allOf merged := make(map[string]any) for k, val := range v { @@ -779,7 +796,15 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo merged[k] = val } } - merged["properties"] = mergedProps + // Apply other constraints (do not override keys already on parent) + for k, val := range mergedOther { + if _, exists := merged[k]; !exists { + merged[k] = val + } + } + if len(mergedProps) > 0 { + merged["properties"] = mergedProps + } if len(mergedRequired) > 0 { reqAny := make([]any, len(mergedRequired)) for i, r := range mergedRequired { @@ -797,14 +822,14 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo // Recursively process all properties result := make(map[string]any) for k, val := range v { - result[k] = s.resolveRefsInner(val, visited, cycleFound, strict) + result[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound, strict) } return result case []any: result := make([]any, len(v)) for i, item := range v { - result[i] = s.resolveRefsInner(item, visited, cycleFound, strict) + result[i] = s.resolveRefsInner(item, visited, cycleFound, dupFound, strict) } return result diff --git a/gts/schema_compat_test.go b/gts/schema_compat_test.go new file mode 100644 index 0000000..f365563 --- /dev/null +++ b/gts/schema_compat_test.go @@ -0,0 +1,916 @@ +/* +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", + 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: false, + wantError: true, + }, + { + 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_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") + } +} From e4465b16768e41421df057ee373dd5d131e31fdb Mon Sep 17 00:00:00 2001 From: mattgarmon Date: Thu, 26 Feb 2026 18:09:30 -0700 Subject: [PATCH 7/7] fix: op12 and op13 implementation updates Signed-off-by: mattgarmon --- gts/schema_compat.go | 592 +++++++++++++++++++++++++------------- gts/schema_compat_test.go | 33 ++- gts/schema_traits.go | 264 +++++++++++------ 3 files changed, 604 insertions(+), 285 deletions(-) diff --git a/gts/schema_compat.go b/gts/schema_compat.go index 4e99c94..813ce81 100644 --- a/gts/schema_compat.go +++ b/gts/schema_compat.go @@ -20,24 +20,103 @@ package gts import ( "encoding/json" "fmt" + "math" + "regexp" "strings" "unicode/utf8" ) -// effectiveSchema holds the flattened view of a schema used for compatibility comparison. +// 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 the schema explicitly declared a "required" array - additionalProperties any // nil means not set + 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 extracts properties, required, and additionalProperties -// from a fully-resolved JSON Schema value, merging allOf items. +// 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 @@ -47,15 +126,12 @@ func extractEffectiveSchemaInto(schema map[string]any, eff *effectiveSchema) { if schema == nil { return } - - // Direct properties if props, ok := schema["properties"].(map[string]any); ok { + eff.propertiesSet = true for k, v := range props { eff.properties[k] = v } } - - // Required if req, ok := schema["required"].([]any); ok { eff.requiredSet = true for _, v := range req { @@ -64,13 +140,14 @@ func extractEffectiveSchemaInto(schema map[string]any, eff *effectiveSchema) { } } } - - // additionalProperties if ap, ok := schema["additionalProperties"]; ok { eff.additionalProperties = ap } - - // allOf – merge from all items + 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 { @@ -80,10 +157,11 @@ func extractEffectiveSchemaInto(schema map[string]any, eff *effectiveSchema) { } } -// validateSchemaCompatibility validates that a derived schema is compatible with its base. -// Returns a list of human-readable error descriptions (empty = compatible). -// nested must be true when called recursively for a nested object property; it suppresses -// checks that are only valid at the top level (e.g. omitted base properties). +// ── 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 @@ -94,7 +172,6 @@ func validateSchemaCompatibility(base, derived *effectiveSchema, baseID, derived for propName, derivedProp := range derived.properties { if baseProp, exists := base.properties[propName]; exists { - // Property exists in both – check for disabling (false) if b, ok := derivedProp.(bool); ok && !b { errors = append(errors, fmt.Sprintf( "property '%s': derived schema '%s' disables property defined in base '%s'", @@ -102,7 +179,6 @@ func validateSchemaCompatibility(base, derived *effectiveSchema, baseID, derived )) continue } - // Compare constraints if basePropMap, ok := baseProp.(map[string]any); ok { if derivedPropMap, ok := derivedProp.(map[string]any); ok { errors = append(errors, comparePropertyConstraints(basePropMap, derivedPropMap, propName)...) @@ -115,7 +191,6 @@ func validateSchemaCompatibility(base, derived *effectiveSchema, baseID, derived } } } else if baseDisallowsAdditional { - // New property in derived – base forbids additional properties errors = append(errors, fmt.Sprintf( "property '%s': derived schema '%s' adds new property but base '%s' has additionalProperties: false", propName, derivedID, baseID, @@ -123,14 +198,11 @@ func validateSchemaCompatibility(base, derived *effectiveSchema, baseID, derived } } - // Check base properties for removals and re-enablings for propName, baseProp := range base.properties { derivedProp, exists := derived.properties[propName] if b, ok := baseProp.(bool); ok && !b { - // base disabled the property; derived must not re-enable it if !exists { - // derived omits the property entirely — only counts as re-enabling - // when the derived schema allows additional properties. + // 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 @@ -147,25 +219,29 @@ func validateSchemaCompatibility(base, derived *effectiveSchema, baseID, derived propName, derivedID, baseID, )) } - } else if _, baseIsObj := baseProp.(map[string]any); baseIsObj && !nested { - // base defines an object schema; at top level derived must not omit or replace it - if !exists { + } 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 _, 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, - )) + } 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, + )) + } } } } - // Check if derived loosens additionalProperties constraint. - // When base has additionalProperties: false, derived must also explicitly - // set additionalProperties: false. Omitting it is also loosening. if baseDisallowsAdditional { derivedAlsoClosed := false if b, ok := derived.additionalProperties.(bool); ok && !b { @@ -179,47 +255,42 @@ func validateSchemaCompatibility(base, derived *effectiveSchema, baseID, derived } } - // Check that derived doesn't remove fields from base's required set. - // In nested context, omitting required is allowed (partial overlay). errors = append(errors, checkRequiredRemoval(base, derived, baseID, derivedID, nested)...) + errors = append(errors, checkTopLevelLooseningKeywords(base, derived, baseID, derivedID)...) return errors } -// comparePropertyConstraints compares constraints between base and derived property schemas. +// comparePropertyConstraints compares all keyword-level constraints between base and derived. func comparePropertyConstraints(baseProp, derivedProp map[string]any, propName string) []string { var errors []string - // Type compatibility errors = append(errors, checkTypeCompatibility(baseProp, derivedProp, propName)...) - // Collect derived enumerated values (const or enum) derivedValues, derivedEnumerates := collectDerivedEnumeratedValues(derivedProp) - // const compatibility errors = append(errors, checkConstCompatibility(baseProp, derivedProp, propName)...) if derivedEnumerates { - // Derived enumerates values: verify every value satisfies base bounds errors = append(errors, checkEnumeratedValuesAgainstBase(baseProp, derivedValues, propName)...) } else { - // No enumeration: require keyword-level constraints to be preserved/tightened 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 compatibility + // enum subset check runs regardless of derivedEnumerates (no new values outside base enum) errors = append(errors, checkEnumCompatibility(baseProp, derivedProp, propName)...) - - // Array items sub-schema comparison errors = append(errors, checkItemsCompatibility(baseProp, derivedProp, propName)...) + errors = append(errors, checkLooseningKeywords(baseProp, derivedProp, propName)...) - // Recurse for nested object properties baseType, _ := baseProp["type"].(string) derivedType, _ := derivedProp["type"].(string) if baseType == "object" && derivedType == "object" { @@ -241,17 +312,44 @@ func checkTypeCompatibility(baseProp, derivedProp map[string]any, propName strin 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, )} } - baseSet := typeToSet(baseType) derivedSet := typeToSet(derivedType) for dt := range derivedSet { - if !baseSet[dt] { + if !schemaTypeCompatible(dt, baseSet) { return []string{fmt.Sprintf( "property '%s': derived changes type from %v to %v", propName, baseType, derivedType, @@ -261,7 +359,27 @@ func checkTypeCompatibility(baseProp, derivedProp map[string]any, propName strin return nil } -// typeToSet normalises a JSON Schema "type" value (string or []any) into a set of type strings. +// 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: @@ -291,7 +409,14 @@ func checkConstCompatibility(baseProp, derivedProp map[string]any, propName stri propName, baseConst, )} } - // Compare using JSON equality + // 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", @@ -313,6 +438,7 @@ func checkPatternCompatibility(baseProp, derivedProp map[string]any, propName st 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", @@ -327,7 +453,6 @@ func checkEnumCompatibility(baseProp, derivedProp map[string]any, propName strin if !ok { return nil } - // Check if derived has enum (subset check) if derivedEnum, ok := derivedProp["enum"].([]any); ok { var errors []string for _, val := range derivedEnum { @@ -340,7 +465,6 @@ func checkEnumCompatibility(baseProp, derivedProp map[string]any, propName strin } return errors } - // Check if derived has const (must be in base enum) if derivedConst, ok := derivedProp["const"]; ok { if !anySliceContains(baseEnum, derivedConst) { return []string{fmt.Sprintf( @@ -350,7 +474,6 @@ func checkEnumCompatibility(baseProp, derivedProp map[string]any, propName strin } return nil } - // Neither enum nor const — loosening return []string{fmt.Sprintf( "property '%s': derived omits enum constraint defined in base", propName, @@ -385,8 +508,7 @@ func checkItemsCompatibility(baseProp, derivedProp map[string]any, propName stri } func checkRequiredRemoval(base, derived *effectiveSchema, baseID, derivedID string, nested bool) []string { - // In nested object context a partial overlay legitimately omits required; - // only enforce when derived explicitly declares required or we are at top level. + // In nested context, only enforce when derived explicitly declares required. if nested && !derived.requiredSet { return nil } @@ -402,8 +524,35 @@ func checkRequiredRemoval(base, derived *effectiveSchema, baseID, derivedID stri return errors } -// checkBound checks that a numeric constraint is preserved or tightened in the derived schema. -// upper=true means derived value must be <= base (e.g. maxLength); upper=false means >= (e.g. minimum). +// 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 { @@ -426,7 +575,42 @@ func checkBound(baseProp, derivedProp map[string]any, keyword, propName string, return nil } -// collectDerivedEnumeratedValues returns the concrete values from const or enum. +// 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 @@ -437,18 +621,23 @@ func collectDerivedEnumeratedValues(derivedProp map[string]any) ([]any, bool) { return nil, false } -// checkEnumeratedValuesAgainstBase verifies every enumerated value satisfies base bounds. +// 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"} { + 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 && n < baseVal { + 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, @@ -457,14 +646,19 @@ func checkEnumeratedValuesAgainstBase(baseProp map[string]any, values []any, pro } } - for _, keyword := range []string{"maximum", "maxLength", "maxItems"} { + 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 && n > baseVal { + 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, @@ -473,10 +667,40 @@ func checkEnumeratedValuesAgainstBase(baseProp map[string]any, values []any, pro } } + 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 } -// numericValueFor extracts a numeric value from a JSON value for a given keyword. +// ── 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": @@ -493,7 +717,6 @@ func numericValueFor(val any, keyword string) (float64, bool) { return 0, false } -// getFloat safely extracts a float64 from a map. func getFloat(m map[string]any, key string) (float64, bool) { v, ok := m[key] if !ok { @@ -521,7 +744,23 @@ func toFloat64(v any) (float64, bool) { return 0, false } -// stringSliceContains checks if a string is in a slice. +// 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 { @@ -531,7 +770,6 @@ func stringSliceContains(slice []string, s string) bool { return false } -// anySliceContains checks if a value is in a slice using JSON equality. func anySliceContains(slice []any, val any) bool { for _, item := range slice { if jsonEqual(item, val) { @@ -541,89 +779,13 @@ func anySliceContains(slice []any, val any) bool { return false } -// jsonEqual compares two values using JSON serialization for deep equality. func jsonEqual(a, b any) bool { aj, _ := json.Marshal(a) bj, _ := json.Marshal(b) return string(aj) == string(bj) } -// 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 a chained schema ID by checking each derived schema -// against its base (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), - } - } - - // Single-segment schemas have no parent to validate against - if len(gid.Segments) < 2 { - return &ValidateSchemaChainResult{SchemaID: schemaID, OK: true} - } - - // Build pairs of (base_id, derived_id) for each adjacent level - segments := gid.Segments - for i := 0; i < len(segments)-1; i++ { - baseID := buildIDFromSegments(segments[:i+1]) - derivedID := buildIDFromSegments(segments[:i+2]) - - // Check for circular refs in both schemas - 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() -} +// ── Ref resolution ──────────────────────────────────────────────────────────── // resolveSchemaRefsChecked resolves $ref references in a named schema, detecting cycles. func (s *GtsStore) resolveSchemaRefsChecked(schemaID string) (map[string]any, error) { @@ -637,12 +799,12 @@ func (s *GtsStore) resolveSchemaRefsChecked(schemaID string) (map[string]any, er return s.resolveRefs(entity.Content) } -// resolveRefs resolves $ref references in a raw schema map, detecting cycles. +// 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, true) + resolved := s.resolveRefsInner(schema, visited, &cycleFound, &dupFound) if cycleFound { return nil, fmt.Errorf("circular $ref detected") } @@ -650,36 +812,56 @@ func (s *GtsStore) resolveRefs(schema map[string]any) (map[string]any, error) { 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 } -// resolveRefsInner recursively resolves $ref references in a schema value. -func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFound *bool, dupFound *bool, strict bool) any { +// 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 { - // Local refs are kept as-is - if strings.HasPrefix(refVal, "#") { + 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, strict) + result[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound) } return result } - // Normalize: strip gts:// prefix canonical := strings.TrimPrefix(refVal, GtsURIPrefix) - - // Cycle detection 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, strict) + result[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound) } } if len(result) == 0 { @@ -688,61 +870,62 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo return result } - // Try to resolve entity := s.Get(canonical) if entity != nil && entity.IsSchema { visited[canonical] = true - resolved := s.resolveRefsInner(entity.Content, visited, cycleFound, dupFound, strict) + resolved := s.resolveRefsInner(entity.Content, visited, cycleFound, dupFound) delete(visited, canonical) - // Remove $id and $schema from resolved content if resolvedMap, ok := resolved.(map[string]any); ok { - delete(resolvedMap, "$id") - delete(resolvedMap, "$schema") + copy := make(map[string]any, len(resolvedMap)) + for k, val := range resolvedMap { + copy[k] = val + } + delete(copy, "$id") + delete(copy, "$schema") - // If original object has only $ref, return resolved schema - if len(v) == 1 { - return resolvedMap + if len(v) == 1 { // pure $ref — return resolved copy directly + return copy } - // Merge resolved schema with other properties + // caller's own keys override the resolved base merged := make(map[string]any) - for k, val := range resolvedMap { + for k, val := range copy { merged[k] = val } for k, val := range v { if k != "$ref" { - merged[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound, strict) + merged[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound) } } return merged } } - // Can't resolve — remove $ref, keep other properties + // unresolvable — preserve $ref so callers can detect it result := make(map[string]any) for k, val := range v { - if k != "$ref" { - result[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound, strict) + if k == "$ref" { + result[k] = val + } else { + result[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound) } } - if len(result) > 0 { - return result - } - return schema + return result } - // Special handling for allOf: merge properties+required from resolved items - // (but NOT additionalProperties — matches Rust resolve_schema_refs_inner behavior) + // 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) - mergedOther := make(map[string]any) for _, item := range allOf { - // Skip duplicate sibling $ref targets (flag them so resolveRefs can report the error) if itemMap, ok := item.(map[string]any); ok { if refVal, ok := itemMap["$ref"].(string); ok { canonical := strings.TrimPrefix(refVal, GtsURIPrefix) @@ -753,12 +936,20 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo seenRefs[canonical] = true } } - resolved := s.resolveRefsInner(item, visited, cycleFound, dupFound, strict) + + 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 { - // Merge properties and required + anyMerged = true if props, ok := resolvedMap["properties"].(map[string]any); ok { for k, pv := range props { mergedProps[k] = pv @@ -766,19 +957,21 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo } if req, ok := resolvedMap["required"].([]any); ok { for _, rv := range req { - if s, ok := rv.(string); ok { - if !stringSliceContains(mergedRequired, s) { - mergedRequired = append(mergedRequired, s) + if str, ok := rv.(string); ok { + if !stringSliceContains(mergedRequired, str) { + mergedRequired = append(mergedRequired, str) } } } } - // Merge all other constraint keys (but NOT additionalProperties — - // matches Rust resolve_schema_refs_inner behavior; lifting it - // would silently inject constraints into the parent schema) for k, val := range resolvedMap { - if k == "properties" || k == "required" || k == "$id" || k == "$schema" || k == "additionalProperties" { + switch k { + case "properties", "required", "$id", "$schema": continue + case "additionalProperties": + if itemWasRef { + continue + } } mergedOther[k] = val } @@ -788,23 +981,35 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo } } - if len(mergedProps) > 0 || len(mergedOther) > 0 { - // Build merged schema without allOf + if anyMerged { merged := make(map[string]any) for k, val := range v { if k != "allOf" { merged[k] = val } } - // Apply other constraints (do not override keys already on parent) - for k, val := range mergedOther { + 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 { @@ -819,17 +1024,16 @@ func (s *GtsStore) resolveRefsInner(schema any, visited map[string]bool, cycleFo } } - // Recursively process all properties result := make(map[string]any) for k, val := range v { - result[k] = s.resolveRefsInner(val, visited, cycleFound, dupFound, strict) + 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, strict) + result[i] = s.resolveRefsInner(item, visited, cycleFound, dupFound) } return result diff --git a/gts/schema_compat_test.go b/gts/schema_compat_test.go index f365563..bce5f4f 100644 --- a/gts/schema_compat_test.go +++ b/gts/schema_compat_test.go @@ -531,20 +531,39 @@ func TestValidateSchemaCompatibility(t *testing.T) { wantError: true, }, { - name: "derived omits base property at top level", + name: "derived omits base property at top level (closed model)", base: &effectiveSchema{ - properties: map[string]any{"name": map[string]any{"type": "string"}}, - required: map[string]bool{}, - requiredSet: false, + properties: map[string]any{"name": map[string]any{"type": "string"}}, + propertiesSet: true, + required: map[string]bool{}, + requiredSet: false, }, derived: &effectiveSchema{ - properties: map[string]any{}, - required: map[string]bool{}, - requiredSet: false, + 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{ diff --git a/gts/schema_traits.go b/gts/schema_traits.go index 9934500..b55e9c1 100644 --- a/gts/schema_traits.go +++ b/gts/schema_traits.go @@ -28,50 +28,46 @@ import ( const maxTraitsRecursionDepth = 64 -// 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 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 } - - if ts, ok := value["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) - } - } - + fn(value) if allOf, ok := value["allOf"].([]any); ok { for _, item := range allOf { if sub, ok := item.(map[string]any); ok { - collectTraitSchemaFromValue(sub, out, depth+1) + walkAllOf(sub, depth+1, fn) } } } } -// 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) { - if depth >= maxTraitsRecursionDepth { - return - } - - if traits, ok := value["x-gts-traits"].(map[string]any); ok { - for k, v := range traits { - merged[k] = v +// 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) + } } - } + }) +} - if allOf, ok := value["allOf"].([]any); ok { - for _, item := range allOf { - if sub, ok := item.(map[string]any); ok { - collectTraitsFromValue(sub, merged, depth+1) +// 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. @@ -163,6 +159,14 @@ func applyDefaults(traitSchema map[string]any, traits map[string]any, depth int) 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 { @@ -220,9 +224,26 @@ func validateTraitsAgainstSchema(traitSchema map[string]any, effectiveTraits map return errors } - // Check for unresolved (missing) trait properties that have no default - for _, p := range collectAllProperties(traitSchema, 0) { - _, hasValue := effectiveTraits[p.name] + // 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) @@ -231,14 +252,35 @@ func validateTraitsAgainstSchema(traitSchema map[string]any, effectiveTraits map } errors = append(errors, fmt.Sprintf( "trait property '%s' (type: %s) is not resolved: no value provided and no default defined in the trait schema", - p.name, propType, + 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 { @@ -268,10 +310,16 @@ func (s *GtsStore) ValidateSchemaTraits(schemaID string) *ValidateSchemaTraitsRe 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 - mergedTraits := make(map[string]any) - lockedTraits := make(map[string]bool) - knownDefaults := make(map[string]any) for i := range segments { segSchemaID := buildIDFromSegments(segments[:i+1]) @@ -287,13 +335,54 @@ func (s *GtsStore) ValidateSchemaTraits(schemaID string) *ValidateSchemaTraitsRe content := entity.Content - // Collect x-gts-traits-schema from raw content - prevCount := len(traitSchemas) - collectTraitSchemaFromValue(content, &traitSchemas, 0) + var rawSchemas []map[string]any + collectTraitSchemaFromValue(content, &rawSchemas, 0) + traitSchemas = append(traitSchemas, rawSchemas...) + + levelTraits := make(map[string]any) + collectTraitsFromValue(content, levelTraits, 0) - // Track which properties this level's trait schema introduces + 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 traitSchemas[prevCount:] { + for _, ts := range resolvedSchemasByLevel[li] { if ts == nil { continue } @@ -307,7 +396,7 @@ func (s *GtsStore) ValidateSchemaTraits(schemaID string) *ValidateSchemaTraitsRe 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, segSchemaID, + schemaID, p.name, lv.segSchemaID, ), } } @@ -318,65 +407,44 @@ func (s *GtsStore) ValidateSchemaTraits(schemaID string) *ValidateSchemaTraitsRe } } - // Collect x-gts-traits from raw content - levelTraits := make(map[string]any) - collectTraitsFromValue(content, levelTraits, 0) - - // Check for locked trait overrides - for k, v := range levelTraits { + // Check for locked trait overrides BEFORE merging this level's values. + for k, v := range lv.traits { if existing, exists := mergedTraits[k]; exists { - if !jsonEqual(existing, v) && lockedTraits[k] { + 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, segSchemaID, + schemaID, k, lv.segSchemaID, ), } } } } - // Mark trait values as locked or unlocked - for k := range levelTraits { - if levelSchemaProps[k] { - delete(lockedTraits, k) - } else { - lockedTraits[k] = true - } - } - - // Merge level traits (rightmost wins) - for k, v := range levelTraits { + // Merge level traits (rightmost wins). + for k, v := range lv.traits { mergedTraits[k] = v } - } - // Normalize $$ref → $ref in collected trait schemas, then resolve $ref references - 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), + // 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 } } - traitSchemas[i] = resolved } - // Check for x-gts-traits-schema integrity: must not contain x-gts-traits + // 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 _, hasTraits := ts["x-gts-traits"]; hasTraits { + if containsXGtsTraits(ts) { return &ValidateSchemaTraitsResult{ SchemaID: schemaID, OK: false, @@ -401,7 +469,7 @@ func (s *GtsStore) ValidateSchemaTraits(schemaID string) *ValidateSchemaTraitsRe return &ValidateSchemaTraitsResult{SchemaID: schemaID, OK: true} } - // Check for nil (non-object) trait schemas + // Check for nil (non-object) trait schemas and enforce type:object for i, ts := range traitSchemas { if ts == nil { return &ValidateSchemaTraitsResult{ @@ -410,6 +478,13 @@ func (s *GtsStore) ValidateSchemaTraits(schemaID string) *ValidateSchemaTraitsRe 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 @@ -483,7 +558,7 @@ func (s *GtsStore) validateEntityLevelTraits(schemaID string) error { } segments := gid.Segments - var traitSchemas []map[string]any + var rawTraitSchemas []map[string]any hasTraitValues := false for i := range segments { @@ -493,7 +568,7 @@ func (s *GtsStore) validateEntityLevelTraits(schemaID string) error { return fmt.Errorf("schema '%s' not found", segSchemaID) } content := entity.Content - collectTraitSchemaFromValue(content, &traitSchemas, 0) + collectTraitSchemaFromValue(content, &rawTraitSchemas, 0) levelTraits := make(map[string]any) collectTraitsFromValue(content, levelTraits, 0) if len(levelTraits) > 0 { @@ -501,7 +576,7 @@ func (s *GtsStore) validateEntityLevelTraits(schemaID string) error { } } - if len(traitSchemas) == 0 { + if len(rawTraitSchemas) == 0 { return nil } @@ -509,10 +584,21 @@ func (s *GtsStore) validateEntityLevelTraits(schemaID string) error { return fmt.Errorf("entity defines x-gts-traits-schema but no x-gts-traits values are provided") } - for _, ts := range traitSchemas { + // 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") @@ -592,8 +678,18 @@ func (s *GtsStore) ValidateEntity(entityID string) *ValidateEntityResult { } } - // Also run traits validation on the schema chain + // 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{