diff --git a/bundler/bundler_test.go b/bundler/bundler_test.go index d0a53ec00..876989d44 100644 --- a/bundler/bundler_test.go +++ b/bundler/bundler_test.go @@ -135,11 +135,9 @@ func TestBundleDocument_Circular(t *testing.T) { bytes, e := BundleDocument(&v3Doc.Model) assert.NoError(t, e) if runtime.GOOS != "windows" { - assert.Len(t, *doc.GetSpecInfo().SpecBytes, 1563) - } else { - assert.Len(t, *doc.GetSpecInfo().SpecBytes, 1637) + assert.Len(t, *doc.GetSpecInfo().SpecBytes, 1692) } - assert.Len(t, bytes, 2016) + assert.Len(t, bytes, 2068) logEntries := strings.Split(byteBuf.String(), "\n") if len(logEntries) == 1 && logEntries[0] == "" { @@ -230,7 +228,7 @@ func TestBundleBytes(t *testing.T) { bytes, e := BundleBytes(digi, config) assert.Error(t, e) - assert.Len(t, bytes, 2016) + assert.Len(t, bytes, 2068) logEntries := strings.Split(byteBuf.String(), "\n") if len(logEntries) == 1 && logEntries[0] == "" { @@ -358,7 +356,7 @@ components: assert.Len(t, bytes, 458) logEntries := strings.Split(byteBuf.String(), "\n") - assert.Len(t, logEntries, 13) + assert.Len(t, logEntries, 17) } func TestBundleBytes_Bad(t *testing.T) { diff --git a/bundler/composer_functions.go b/bundler/composer_functions.go index cdb0a48dc..9c072db24 100644 --- a/bundler/composer_functions.go +++ b/bundler/composer_functions.go @@ -27,7 +27,6 @@ func calculateCollisionName(name, pointer, delimiter string, iteration int) stri jsonPointer := strings.Split(pointer, "#/") if len(jsonPointer) == 2 { - // TODO: make delimiter configurable. // count the number of collisions by splitting the name by the __ delimiter. nameSegments := strings.Split(name, delimiter) if len(nameSegments) > 1 { @@ -52,7 +51,6 @@ func calculateCollisionName(name, pointer, delimiter string, iteration int) stri return fileName } - } // split a path into segments and then create a new name by appending the iteration count. @@ -62,7 +60,7 @@ func calculateCollisionName(name, pointer, delimiter string, iteration int) stri lastSegment := segments[len(segments)-(iteration)] - // split the name by __ and append the last segment of the path + // split the name by the delimiter and append the last segment of the path nameSegments := strings.Split(name, delimiter) if len(nameSegments) > 1 { if len(nameSegments) <= iteration { diff --git a/datamodel/document_config.go b/datamodel/document_config.go index b6de8205a..926001971 100644 --- a/datamodel/document_config.go +++ b/datamodel/document_config.go @@ -126,6 +126,24 @@ type DocumentConfiguration struct { // The bundler will attempt to create a single document, with all references moved to the `components` section. Any names used // will be kept, any collisions will be resolved by appending a number to the name RecomposeRefs bool + + // UseSchemaQuickHash will use a quick hash to determine if a schema is the same as another schema if its a reference. + // This is important when a root / entry document does not have a components/schemas node, and schemas are defined in + // external documents. Enabling this will allow the what-changed module to perform deeper schema reference checks. + /// + // -- IMPORTANT -- + /// + // Enabling this (default is false) will stop changes from being detected if a schema is circular. + // As identified in https://github.com/pb33f/libopenapi/pull/441 + // + // In the edge case where you have circular references in your root / entry components/schemas and you also + // want changes in them to be picked up, then you should not enable this. + // + // If your schemas are in external documents, and you want changes in them to be picked up, then you should enable this. + // + // By default schemas as references are ignored and only the root / entry document's components/schemas are + // used to determine changes. + UseSchemaQuickHash bool } func NewDocumentConfiguration() *DocumentConfiguration { diff --git a/datamodel/high/base/tag.go b/datamodel/high/base/tag.go index fb405a1b2..c9395c978 100644 --- a/datamodel/high/base/tag.go +++ b/datamodel/high/base/tag.go @@ -16,10 +16,14 @@ import ( // tag defined in the Operation Object instances. // - v2: https://swagger.io/specification/v2/#tagObject // - v3: https://swagger.io/specification/#tag-object +// - v3.2: https://spec.openapis.org/oas/v3.2.0#tag-object type Tag struct { Name string `json:"name,omitempty" yaml:"name,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` Description string `json:"description,omitempty" yaml:"description,omitempty"` ExternalDocs *ExternalDoc `json:"externalDocs,omitempty" yaml:"externalDocs,omitempty"` + Parent string `json:"parent,omitempty" yaml:"parent,omitempty"` + Kind string `json:"kind,omitempty" yaml:"kind,omitempty"` Extensions *orderedmap.Map[string, *yaml.Node] low *low.Tag } @@ -31,12 +35,21 @@ func NewTag(tag *low.Tag) *Tag { if !tag.Name.IsEmpty() { t.Name = tag.Name.Value } + if !tag.Summary.IsEmpty() { + t.Summary = tag.Summary.Value + } if !tag.Description.IsEmpty() { t.Description = tag.Description.Value } if !tag.ExternalDocs.IsEmpty() { t.ExternalDocs = NewExternalDoc(tag.ExternalDocs.Value) } + if !tag.Parent.IsEmpty() { + t.Parent = tag.Parent.Value + } + if !tag.Kind.IsEmpty() { + t.Kind = tag.Kind.Value + } t.Extensions = high.ExtractExtensions(tag.Extensions) return t } diff --git a/datamodel/high/base/tag_test.go b/datamodel/high/base/tag_test.go index 2159dc698..64a778772 100644 --- a/datamodel/high/base/tag_test.go +++ b/datamodel/high/base/tag_test.go @@ -49,6 +49,47 @@ x-hack: code` assert.Equal(t, strings.TrimSpace(string(highTagBytes)), yml) } +func TestNewTag_OpenAPI32(t *testing.T) { + var cNode yaml.Node + + yml := `name: account-updates +summary: Account Updates +description: Account update operations +parent: external +kind: nav +externalDocs: + url: https://pb33f.io + description: Find more info here +x-custom: value` + + _ = yaml.Unmarshal([]byte(yml), &cNode) + + var lowTag lowbase.Tag + _ = lowmodel.BuildModel(cNode.Content[0], &lowTag) + _ = lowTag.Build(context.Background(), nil, cNode.Content[0], nil) + + highTag := NewTag(&lowTag) + + var xCustom string + _ = highTag.Extensions.GetOrZero("x-custom").Decode(&xCustom) + + assert.Equal(t, "account-updates", highTag.Name) + assert.Equal(t, "Account Updates", highTag.Summary) + assert.Equal(t, "Account update operations", highTag.Description) + assert.Equal(t, "external", highTag.Parent) + assert.Equal(t, "nav", highTag.Kind) + assert.Equal(t, "https://pb33f.io", highTag.ExternalDocs.URL) + assert.Equal(t, "Find more info here", highTag.ExternalDocs.Description) + assert.Equal(t, "value", xCustom) + + wentLow := highTag.GoLow() + assert.Equal(t, "account-updates", wentLow.Name.Value) + assert.Equal(t, "Account Updates", wentLow.Summary.Value) + assert.Equal(t, "external", wentLow.Parent.Value) + assert.Equal(t, "nav", wentLow.Kind.Value) + assert.NotNil(t, highTag.GoLowUntyped()) +} + func TestTag_RenderInline(t *testing.T) { tag := &Tag{ Name: "cake", diff --git a/datamodel/low/base/constants.go b/datamodel/low/base/constants.go index 25ffd0e6e..60e41b15c 100644 --- a/datamodel/low/base/constants.go +++ b/datamodel/low/base/constants.go @@ -11,10 +11,13 @@ const ( TitleLabel = "title" EmailLabel = "email" NameLabel = "name" + SummaryLabel = "summary" URLLabel = "url" ServersLabel = "servers" ServerLabel = "server" TagsLabel = "tags" + ParentLabel = "parent" + KindLabel = "kind" ExternalDocsLabel = "externalDocs" ExamplesLabel = "examples" ExampleLabel = "example" diff --git a/datamodel/low/base/schema_proxy.go b/datamodel/low/base/schema_proxy.go index 78d799cfc..e0576caeb 100644 --- a/datamodel/low/base/schema_proxy.go +++ b/datamodel/low/base/schema_proxy.go @@ -158,9 +158,12 @@ func (sp *SchemaProxy) Hash() [32]byte { sp.rendered = sch hashError := fmt.Errorf("circular reference detected: %s", sp.GetReference()) if sch != nil { - if !CheckSchemaProxyForCircularRefs(sp) { - return sch.Hash() + if sp.idx != nil && sp.idx.GetConfig() != nil && sp.idx.GetConfig().UseSchemaQuickHash { + if !CheckSchemaProxyForCircularRefs(sp) { + return sch.Hash() + } } + return sch.Hash() } var logger *slog.Logger if sp.idx != nil && sp.idx.GetLogger() != nil { @@ -177,12 +180,14 @@ func (sp *SchemaProxy) Hash() [32]byte { } // let's check the rolodex for a potential circular reference, and if there isn't a match, go ahead and hash the reference value. - if sp.GetIndex() != nil && !CheckSchemaProxyForCircularRefs(sp) { - if sp.rendered == nil { - sp.rendered = sp.Schema() + if sp.idx != nil && sp.idx.GetConfig() != nil && sp.idx.GetConfig().UseSchemaQuickHash { + if sp.idx != nil && !CheckSchemaProxyForCircularRefs(sp) { + if sp.rendered == nil { + sp.rendered = sp.Schema() + } + qh := sp.rendered.QuickHash() // quick hash uses a cache to keep things fast. + return qh } - qh := sp.rendered.QuickHash() // quick hash uses a cache to keep things fast. - return qh } // hash reference value only, do not resolve! diff --git a/datamodel/low/base/schema_proxy_test.go b/datamodel/low/base/schema_proxy_test.go index 9856b771c..be5d1f812 100644 --- a/datamodel/low/base/schema_proxy_test.go +++ b/datamodel/low/base/schema_proxy_test.go @@ -206,7 +206,7 @@ func TestSchemaProxy_QuickHash_Empty(t *testing.T) { sp.Reference = r logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - cfg := &index.SpecIndexConfig{Logger: logger} + cfg := &index.SpecIndexConfig{Logger: logger, UseSchemaQuickHash: true} idx := index.NewSpecIndexWithConfig(nil, cfg) sp.idx = idx @@ -238,3 +238,43 @@ func TestSchemaProxy_TestRolodexHasId(t *testing.T) { assert.Equal(t, "6da88c34ba124c41f977db66a4fc5c1a951708d285c81bb0d47c3206f4c27ca8", low.GenerateHashString(&sch)) } + +func TestSchemaProxy_Hash_UseSchemaQuickHash_NonCircular(t *testing.T) { + yml := `type: object +properties: + name: + type: string + age: + type: integer` + + var sch SchemaProxy + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + // Create index with UseSchemaQuickHash enabled + cfg := &index.SpecIndexConfig{UseSchemaQuickHash: true} + idx := index.NewSpecIndexWithConfig(idxNode.Content[0], cfg) + rolo := index.NewRolodex(cfg) + rolo.SetRootIndex(idx) + idx.SetRolodex(rolo) + + err := sch.Build(context.Background(), nil, idxNode.Content[0], idx) + assert.NoError(t, err) + + // Ensure this is not a reference schema (to trigger the !sp.IsReference() path) + assert.False(t, sch.IsReference()) + + // Pre-render the schema to ensure it's available + schema := sch.Schema() + assert.NotNil(t, schema) + + // This should trigger lines 162-164: UseSchemaQuickHash is true, + // CheckSchemaProxyForCircularRefs returns false (no circular refs in simple object) + hash := sch.Hash() + + // Verify we get a valid hash (not empty) + assert.NotEqual(t, [32]byte{}, hash) + + // Verify the schema was rendered and available + assert.NotNil(t, sch.rendered) +} diff --git a/datamodel/low/base/tag.go b/datamodel/low/base/tag.go index 5d6bc566b..b00c600ac 100644 --- a/datamodel/low/base/tag.go +++ b/datamodel/low/base/tag.go @@ -21,10 +21,14 @@ import ( // tag defined in the Operation Object instances. // - v2: https://swagger.io/specification/v2/#tagObject // - v3: https://swagger.io/specification/#tag-object +// - v3.2: https://spec.openapis.org/oas/v3.2.0#tag-object type Tag struct { Name low.NodeReference[string] + Summary low.NodeReference[string] Description low.NodeReference[string] ExternalDocs low.NodeReference[*ExternalDoc] + Parent low.NodeReference[string] + Kind low.NodeReference[string] Extensions *orderedmap.Map[low.KeyReference[string], low.ValueReference[*yaml.Node]] KeyNode *yaml.Node RootNode *yaml.Node @@ -84,18 +88,27 @@ func (t *Tag) GetExtensions() *orderedmap.Map[low.KeyReference[string], low.Valu return t.Extensions } -// Hash will return a consistent SHA256 Hash of the Info object +// Hash will return a consistent SHA256 Hash of the Tag object func (t *Tag) Hash() [32]byte { var f []string if !t.Name.IsEmpty() { f = append(f, t.Name.Value) } + if !t.Summary.IsEmpty() { + f = append(f, t.Summary.Value) + } if !t.Description.IsEmpty() { f = append(f, t.Description.Value) } if !t.ExternalDocs.IsEmpty() { f = append(f, low.GenerateHashString(t.ExternalDocs.Value)) } + if !t.Parent.IsEmpty() { + f = append(f, t.Parent.Value) + } + if !t.Kind.IsEmpty() { + f = append(f, t.Kind.Value) + } f = append(f, low.HashExtensions(t.Extensions)...) return sha256.Sum256([]byte(strings.Join(f, "|"))) } diff --git a/datamodel/low/base/tag_test.go b/datamodel/low/base/tag_test.go index 7c160085d..b2ee77ad2 100644 --- a/datamodel/low/base/tag_test.go +++ b/datamodel/low/base/tag_test.go @@ -91,3 +91,99 @@ x-b33f: princess` assert.Equal(t, lDoc.Hash(), rDoc.Hash()) } + +func TestTag_Build_OpenAPI32(t *testing.T) { + yml := `name: partner +summary: Partner +description: Operations available to the partners network +parent: external +kind: audience +externalDocs: + url: https://pb33f.io + description: Find more info here +x-custom: value` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + idx := index.NewSpecIndex(&idxNode) + + var n Tag + err := low.BuildModel(idxNode.Content[0], &n) + assert.NoError(t, err) + + err = n.Build(context.Background(), nil, idxNode.Content[0], idx) + assert.NoError(t, err) + + assert.Equal(t, "partner", n.Name.Value) + assert.Equal(t, "Partner", n.Summary.Value) + assert.Equal(t, "Operations available to the partners network", n.Description.Value) + assert.Equal(t, "external", n.Parent.Value) + assert.Equal(t, "audience", n.Kind.Value) + assert.Equal(t, "https://pb33f.io", n.ExternalDocs.Value.URL.Value) + assert.Equal(t, "Find more info here", n.ExternalDocs.Value.Description.Value) + + var xCustom string + _ = n.FindExtension("x-custom").GetValue().Decode(&xCustom) + assert.Equal(t, "value", xCustom) + + assert.Equal(t, 1, orderedmap.Len(n.GetExtensions())) + assert.NotNil(t, n.GetRootNode()) + assert.Nil(t, n.GetKeyNode()) + assert.NotNil(t, n.GetContext()) + assert.NotNil(t, n.GetIndex()) +} + +func TestTag_Hash_OpenAPI32(t *testing.T) { + left := `name: partner +summary: Partner +description: Operations available to the partners network +parent: external +kind: audience +externalDocs: + url: https://pb33f.io + description: Find more info here +x-custom: value` + + right := `name: partner +summary: Partner +description: Operations available to the partners network +parent: external +kind: audience +externalDocs: + url: https://pb33f.io + description: Find more info here +x-custom: value` + + var lNode, rNode yaml.Node + _ = yaml.Unmarshal([]byte(left), &lNode) + _ = yaml.Unmarshal([]byte(right), &rNode) + + // create low level objects + var lDoc Tag + var rDoc Tag + _ = low.BuildModel(lNode.Content[0], &lDoc) + _ = low.BuildModel(rNode.Content[0], &rDoc) + _ = lDoc.Build(context.Background(), nil, lNode.Content[0], nil) + _ = rDoc.Build(context.Background(), nil, rNode.Content[0], nil) + + assert.Equal(t, lDoc.Hash(), rDoc.Hash()) + + // test hash difference when fields change + right2 := `name: partner +summary: Partner API +description: Operations available to the partners network +parent: external +kind: nav +externalDocs: + url: https://pb33f.io + description: Find more info here +x-custom: value` + + var rNode2 yaml.Node + _ = yaml.Unmarshal([]byte(right2), &rNode2) + var rDoc2 Tag + _ = low.BuildModel(rNode2.Content[0], &rDoc2) + _ = rDoc2.Build(context.Background(), nil, rNode2.Content[0], nil) + + assert.NotEqual(t, lDoc.Hash(), rDoc2.Hash()) +} diff --git a/datamodel/low/v3/constants.go b/datamodel/low/v3/constants.go index 7ce7a321e..019b1227c 100644 --- a/datamodel/low/v3/constants.go +++ b/datamodel/low/v3/constants.go @@ -66,6 +66,8 @@ const ( WrappedLabel = "wrapped" PropertyNameLabel = "propertyName" SummaryLabel = "summary" + ParentLabel = "parent" + KindLabel = "kind" ValueLabel = "value" ExternalValue = "externalValue" SchemaDialectLabel = "$schema" diff --git a/datamodel/low/v3/create_document.go b/datamodel/low/v3/create_document.go index 7e3418e20..5e0bcc700 100644 --- a/datamodel/low/v3/create_document.go +++ b/datamodel/low/v3/create_document.go @@ -42,6 +42,7 @@ func createDocument(info *datamodel.SpecInfo, config *datamodel.DocumentConfigur // create an index config and shadow the document configuration. idxConfig := index.CreateClosedAPIIndexConfig() idxConfig.SpecInfo = info + idxConfig.UseSchemaQuickHash = config.UseSchemaQuickHash idxConfig.ExcludeExtensionRefs = config.ExcludeExtensionRefs idxConfig.IgnoreArrayCircularReferences = config.IgnoreArrayCircularReferences idxConfig.IgnorePolymorphicCircularReferences = config.IgnorePolymorphicCircularReferences diff --git a/index/index_model.go b/index/index_model.go index 020aae75d..e7a0bc1ed 100644 --- a/index/index_model.go +++ b/index/index_model.go @@ -179,6 +179,16 @@ type SpecIndexConfig struct { // defaults to false (which means extensions will be included) ExcludeExtensionRefs bool + // UseSchemaQuickHash will use a quick hash to determine if a schema is the same as another schema if its a reference. + // This is important when a root / entry document does not have a components/schemas node, and schemas are defined in + // external documents. Enabling this will allow the what-changed module to perform deeper schema reference checks. + // -- IMPORTANT -- + // Enabling this (default is false) will stop changes from being detected if a schema is circular. + // As identified in https://github.com/pb33f/libopenapi/pull/441 + // So, in the edge case where you have circular references in your root / entry components/schemas and you also + // want changes in them to be picked up, then you should not enable this. + UseSchemaQuickHash bool + // private fields uri []string id string @@ -327,6 +337,7 @@ type SpecIndex struct { circularReferences []*CircularReferenceResult // only available when the resolver has been used. polyCircularReferences []*CircularReferenceResult // only available when the resolver has been used. arrayCircularReferences []*CircularReferenceResult // only available when the resolver has been used. + tagCircularReferences []*CircularReferenceResult // tag parent-child circular references for OpenAPI 3.2+ allowCircularReferences bool // decide if you want to error out, or allow circular references, default is false. config *SpecIndexConfig // configuration for the index componentIndexChan chan struct{} diff --git a/index/resolver.go b/index/resolver.go index c12134d4b..12b3be6d7 100644 --- a/index/resolver.go +++ b/index/resolver.go @@ -663,11 +663,9 @@ func (resolver *Resolver) extractRelatives(ref *Reference, node, parent *yaml.No } } } - + // Only treat as polymorphic keywords if not inside properties - if !isInsideProperties && (n.Value == "allOf" || - n.Value == "oneOf" || - n.Value == "anyOf") { + if !isInsideProperties && (n.Value == "allOf" || n.Value == "oneOf" || n.Value == "anyOf") { // if this is a polymorphic link, we want to follow it and see if it becomes circular if i+1 < len(node.Content) && utils.IsNodeMap(node.Content[i+1]) { // check for nested items diff --git a/index/resolver_test.go b/index/resolver_test.go index 02dd86b5d..a4ebf7e31 100644 --- a/index/resolver_test.go +++ b/index/resolver_test.go @@ -913,6 +913,46 @@ components: assert.Len(t, resolver.GetIgnoredCircularPolyReferences(), 1) } +func TestDocument_NoIgnorePolyCircularReferences_NoArrayForRef(t *testing.T) { + d := `openapi: 3.1.0 +components: + schemas: + bingo: + type: object + properties: + bango: + $ref: "#/components/schemas/ProductCategory" + ProductCategory: + type: "object" + properties: + name: + type: "string" + children: + type: "object" + items: + anyOf: + items: + $ref: "#/components/schemas/ProductCategory" + description: "Array of sub-categories in the same format." + required: + - "name" + - "children"` + + var rootNode yaml.Node + _ = yaml.Unmarshal([]byte(d), &rootNode) + + idx := NewSpecIndexWithConfig(&rootNode, CreateClosedAPIIndexConfig()) + + resolver := NewResolver(idx) + //resolver.IgnorePolymorphicCircularReferences() + assert.NotNil(t, resolver) + + circ := resolver.Resolve() + assert.Len(t, circ, 0) + assert.Len(t, resolver.GetIgnoredCircularPolyReferences(), 0) + assert.Len(t, resolver.GetSafeCircularReferences(), 1) +} + func TestResolver_isInfiniteCircularDep_NoRef(t *testing.T) { resolver := NewResolver(nil) a, b := resolver.isInfiniteCircularDependency(nil, nil, nil) diff --git a/index/spec_index.go b/index/spec_index.go index 96e4563df..55cd43dfa 100644 --- a/index/spec_index.go +++ b/index/spec_index.go @@ -213,6 +213,12 @@ func (index *SpecIndex) GetCircularReferences() []*CircularReferenceResult { return index.circularReferences } +// GetTagCircularReferences will return any circular reference results found in tag parent-child relationships. +// This is used for OpenAPI 3.2+ tag hierarchies where a tag can reference another tag as its parent. +func (index *SpecIndex) GetTagCircularReferences() []*CircularReferenceResult { + return index.tagCircularReferences +} + // SetIgnoredPolymorphicCircularReferences passes on any ignored poly circular refs captured using // `IgnorePolymorphicCircularReferences` func (index *SpecIndex) SetIgnoredPolymorphicCircularReferences(refs []*CircularReferenceResult) { @@ -658,6 +664,9 @@ func (index *SpecIndex) GetGlobalTagsCount() int { index.globalTagRefs[name.Value] = ref } } + + // Check for tag circular references (OpenAPI 3.2+) + index.checkTagCircularReferences() } } } @@ -665,6 +674,120 @@ func (index *SpecIndex) GetGlobalTagsCount() int { return index.globalTagsCount } +// checkTagCircularReferences performs circular reference detection for OpenAPI 3.2+ tag parent-child relationships. +// It builds a parent-child map and then uses depth-first search to detect cycles. +func (index *SpecIndex) checkTagCircularReferences() { + if index.tagsNode == nil { + return + } + + // Build parent-child mapping from tag nodes + tagParentMap := make(map[string]string) // tagName -> parentName + tagRefs := make(map[string]*Reference) // tagName -> Reference + tagNodes := make(map[string]*yaml.Node) // tagName -> yaml.Node + + for x, tagNode := range index.tagsNode.Content { + _, nameNode := utils.FindKeyNode("name", tagNode.Content) + _, parentNode := utils.FindKeyNode("parent", tagNode.Content) + + if nameNode != nil { + tagName := nameNode.Value + tagNodes[tagName] = tagNode + tagRefs[tagName] = &Reference{ + Name: tagName, + Node: tagNode, + Path: fmt.Sprintf("$.tags[%d]", x), + } + + if parentNode != nil { + parentName := parentNode.Value + tagParentMap[tagName] = parentName + } + } + } + + // Perform circular reference detection using depth-first search + visited := make(map[string]bool) + recStack := make(map[string]bool) // recursion stack to detect cycles + + for tagName := range tagRefs { + if !visited[tagName] { + // Only check tags that have parents - no point checking orphans + if _, hasParent := tagParentMap[tagName]; hasParent { + if path := index.detectTagCircularHelper(tagName, tagParentMap, tagRefs, visited, recStack, []string{}); len(path) > 0 { + // Circular reference detected, create CircularReferenceResult + journey := make([]*Reference, len(path)) + for i, name := range path { + journey[i] = tagRefs[name] + } + + loopIndex := -1 + loopStart := path[len(path)-1] // The repeated tag name + for i, name := range path { + if name == loopStart { + loopIndex = i + break + } + } + + circRef := &CircularReferenceResult{ + Journey: journey, + Start: tagRefs[path[0]], + LoopIndex: loopIndex, + LoopPoint: tagRefs[loopStart], + ParentNode: tagNodes[loopStart], + IsArrayResult: false, + IsPolymorphicResult: false, + IsInfiniteLoop: true, // Tag parent cycles are always problematic + } + + index.tagCircularReferences = append(index.tagCircularReferences, circRef) + } + } + } + } +} + +// detectTagCircularHelper is a recursive helper function for detecting circular references in tag hierarchies. +// Returns the path to the circular reference if found, empty slice otherwise. +func (index *SpecIndex) detectTagCircularHelper(tagName string, parentMap map[string]string, tagRefs map[string]*Reference, visited map[string]bool, recStack map[string]bool, path []string) []string { + // Check if this tag even exists - if not, we can't have a circular reference + if _, exists := tagRefs[tagName]; !exists { + return []string{} + } + + visited[tagName] = true + recStack[tagName] = true + path = append(path, tagName) + + // Check if this tag has a parent + if parentName, hasParent := parentMap[tagName]; hasParent { + // Validate that parent exists as a defined tag + if _, parentExists := tagRefs[parentName]; !parentExists { + // Parent doesn't exist - this is a validation error but not a circular reference + // Remove from recursion stack before returning + recStack[tagName] = false + return []string{} + } + + // If parent is already in recursion stack, we found a cycle + if recStack[parentName] { + return append(path, parentName) // Return path including the cycle + } + + // If parent not visited, recursively check it + if !visited[parentName] { + if cyclePath := index.detectTagCircularHelper(parentName, parentMap, tagRefs, visited, recStack, path); len(cyclePath) > 0 { + return cyclePath + } + } + } + + // Remove from recursion stack when backtracking + recStack[tagName] = false + return []string{} +} + // GetOperationTagsCount will return the number of operation tags found (tags referenced in operations) func (index *SpecIndex) GetOperationTagsCount() int { if index.root == nil { diff --git a/index/spec_index_test.go b/index/spec_index_test.go index 1df981d3f..925bc7194 100644 --- a/index/spec_index_test.go +++ b/index/spec_index_test.go @@ -193,7 +193,11 @@ func TestSpecIndex_DigitalOcean(t *testing.T) { // get all the files! files := remoteFS.GetFiles() fileLen := len(files) - assert.Equal(t, 1660, fileLen) + + // if windows + if runtime.GOOS != "windows" { + assert.Equal(t, 1660, fileLen) + } assert.Len(t, remoteFS.GetErrors(), 0) // check circular references @@ -1924,6 +1928,12 @@ func TestSpecIndex_GetAllComponentSchemas_NilIndex(t *testing.T) { assert.Nil(t, schemas, "Expected GetAllComponentSchemas to return nil when index is nil") } +func TestSpecIndex_ChecTagCircularRefNil(t *testing.T) { + index := &SpecIndex{} + index.checkTagCircularReferences() + +} + func TestSpecIndex_Cache(t *testing.T) { idx := NewTestSpecIndex().Load().(*SpecIndex) assert.NotNil(t, idx.GetHighCache()) diff --git a/index/tag_circular_references_test.go b/index/tag_circular_references_test.go new file mode 100644 index 000000000..a8fffb7b5 --- /dev/null +++ b/index/tag_circular_references_test.go @@ -0,0 +1,359 @@ +// Copyright 2024 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package index + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +func TestSpecIndex_TagCircularReferences_SimpleCircle(t *testing.T) { + yml := `openapi: 3.2.0 +info: + title: Test API + version: 1.0.0 +tags: + - name: tagA + summary: Tag A + parent: tagB + - name: tagB + summary: Tag B + parent: tagA +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + idx := NewSpecIndex(&idxNode) + + // Trigger the tag counting which will check for circular references + count := idx.GetGlobalTagsCount() + assert.Equal(t, 2, count) + + // Check that circular references were detected + circRefs := idx.GetTagCircularReferences() + assert.Len(t, circRefs, 1) + + circRef := circRefs[0] + assert.True(t, circRef.IsInfiniteLoop) + assert.False(t, circRef.IsArrayResult) + assert.False(t, circRef.IsPolymorphicResult) + + // Check the journey path - should be 3 items forming a circle + assert.Len(t, circRef.Journey, 3) + + // Extract journey names for easier checking + journeyNames := make([]string, len(circRef.Journey)) + for i, ref := range circRef.Journey { + journeyNames[i] = ref.Name + } + + // Should contain both tags and form a circle + assert.Contains(t, journeyNames, "tagA") + assert.Contains(t, journeyNames, "tagB") + + // First and last elements should be the same (forming a circle) + assert.Equal(t, journeyNames[0], journeyNames[2]) + + // Loop point and start should be the same + assert.Equal(t, circRef.LoopPoint.Name, circRef.Start.Name) +} + +func TestSpecIndex_TagCircularReferences_ThreeTagCircle(t *testing.T) { + yml := `openapi: 3.2.0 +info: + title: Test API + version: 1.0.0 +tags: + - name: tagA + summary: Tag A + parent: tagB + - name: tagB + summary: Tag B + parent: tagC + - name: tagC + summary: Tag C + parent: tagA +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + idx := NewSpecIndex(&idxNode) + + count := idx.GetGlobalTagsCount() + assert.Equal(t, 3, count) + + circRefs := idx.GetTagCircularReferences() + assert.Len(t, circRefs, 1) + + circRef := circRefs[0] + assert.True(t, circRef.IsInfiniteLoop) + + // Check the journey path - should be 4 items: tagA -> tagB -> tagC -> tagA + assert.Len(t, circRef.Journey, 4) + journeyNames := make([]string, len(circRef.Journey)) + for i, ref := range circRef.Journey { + journeyNames[i] = ref.Name + } + + // Should contain the cycle + assert.Contains(t, journeyNames, "tagA") + assert.Contains(t, journeyNames, "tagB") + assert.Contains(t, journeyNames, "tagC") +} + +func TestSpecIndex_TagCircularReferences_NoCircle(t *testing.T) { + yml := `openapi: 3.2.0 +info: + title: Test API + version: 1.0.0 +tags: + - name: external + summary: External + description: Operations available to external consumers + kind: audience + - name: partner + summary: Partner + description: Operations available to the partners network + parent: external + kind: audience + - name: account-updates + summary: Account Updates + description: Account update operations + kind: nav +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + idx := NewSpecIndex(&idxNode) + + count := idx.GetGlobalTagsCount() + assert.Equal(t, 3, count) + + // Should have no circular references + circRefs := idx.GetTagCircularReferences() + assert.Len(t, circRefs, 0) +} + +func TestSpecIndex_TagCircularReferences_NonExistentParent(t *testing.T) { + yml := `openapi: 3.2.0 +info: + title: Test API + version: 1.0.0 +tags: + - name: tagA + summary: Tag A + parent: nonExistentTag + - name: tagB + summary: Tag B + parent: tagA +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + idx := NewSpecIndex(&idxNode) + + count := idx.GetGlobalTagsCount() + assert.Equal(t, 2, count) + + // Should have no circular references (nonExistentTag is not defined) + circRefs := idx.GetTagCircularReferences() + assert.Len(t, circRefs, 0) +} + +func TestSpecIndex_TagCircularReferences_SelfReference(t *testing.T) { + yml := `openapi: 3.2.0 +info: + title: Test API + version: 1.0.0 +tags: + - name: selfRef + summary: Self Reference + parent: selfRef +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + idx := NewSpecIndex(&idxNode) + + count := idx.GetGlobalTagsCount() + assert.Equal(t, 1, count) + + // Should detect self-reference as circular + circRefs := idx.GetTagCircularReferences() + assert.Len(t, circRefs, 1) + + circRef := circRefs[0] + assert.True(t, circRef.IsInfiniteLoop) + assert.Equal(t, "selfRef", circRef.Start.Name) + assert.Equal(t, "selfRef", circRef.LoopPoint.Name) + + // Journey should be [selfRef, selfRef] + assert.Len(t, circRef.Journey, 2) + assert.Equal(t, "selfRef", circRef.Journey[0].Name) + assert.Equal(t, "selfRef", circRef.Journey[1].Name) +} + +func TestSpecIndex_TagCircularReferences_ComplexHierarchy(t *testing.T) { + yml := `openapi: 3.2.0 +info: + title: Test API + version: 1.0.0 +tags: + - name: root + summary: Root tag + - name: childA + summary: Child A + parent: root + - name: childB + summary: Child B + parent: root + - name: grandchildA1 + summary: Grandchild A1 + parent: childA + - name: grandchildA2 + summary: Grandchild A2 + parent: childA + - name: circularChild + summary: Circular Child + parent: circularParent + - name: circularParent + summary: Circular Parent + parent: circularChild +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + idx := NewSpecIndex(&idxNode) + + count := idx.GetGlobalTagsCount() + assert.Equal(t, 7, count) + + // Should detect the one circular reference between circularChild and circularParent + circRefs := idx.GetTagCircularReferences() + assert.Len(t, circRefs, 1) + + circRef := circRefs[0] + assert.True(t, circRef.IsInfiniteLoop) + + // Check that the circular reference involves the expected tags + journeyNames := make([]string, len(circRef.Journey)) + for i, ref := range circRef.Journey { + journeyNames[i] = ref.Name + } + + assert.Contains(t, journeyNames, "circularChild") + assert.Contains(t, journeyNames, "circularParent") +} + +func TestSpecIndex_TagCircularReferences_NoTags(t *testing.T) { + yml := `openapi: 3.2.0 +info: + title: Test API + version: 1.0.0 +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + idx := NewSpecIndex(&idxNode) + + count := idx.GetGlobalTagsCount() + assert.Equal(t, 0, count) + + // Should have no circular references + circRefs := idx.GetTagCircularReferences() + assert.Len(t, circRefs, 0) +} + +func TestSpecIndex_TagCircularReferences_EmptyTags(t *testing.T) { + yml := `openapi: 3.2.0 +info: + title: Test API + version: 1.0.0 +tags: [] +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + idx := NewSpecIndex(&idxNode) + + count := idx.GetGlobalTagsCount() + assert.Equal(t, 0, count) + + // Should have no circular references + circRefs := idx.GetTagCircularReferences() + assert.Len(t, circRefs, 0) +} + +func TestSpecIndex_TagCircularReferences_NilTagsNode(t *testing.T) { + yml := `openapi: 3.2.0 +info: + title: Test API + version: 1.0.0 +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + idx := NewSpecIndex(&idxNode) + + // Explicitly set tagsNode to nil to test the early return + idx.tagsNode = nil + + // This should trigger checkTagCircularReferences() which should return early due to nil tagsNode + count := idx.GetGlobalTagsCount() + assert.Equal(t, 0, count) + + // Should have no circular references due to early return + circRefs := idx.GetTagCircularReferences() + assert.Len(t, circRefs, 0) +} + +func TestSpecIndex_detectTagCircularHelper_NonExistentTag(t *testing.T) { + yml := `openapi: 3.2.0 +info: + title: Test API + version: 1.0.0 +tags: + - name: existingTag + summary: Existing Tag +paths: {}` + + var idxNode yaml.Node + _ = yaml.Unmarshal([]byte(yml), &idxNode) + + idx := NewSpecIndex(&idxNode) + + // Create the maps that would be passed to detectTagCircularHelper + parentMap := map[string]string{} + tagRefs := map[string]*Reference{ + "existingTag": { + Name: "existingTag", + Node: &yaml.Node{Value: "existingTag"}, + Path: "$.tags[0]", + }, + } + visited := map[string]bool{} + recStack := map[string]bool{} + + // Test calling detectTagCircularHelper with a non-existent tag name + // This should trigger the early return on lines 756-757 + path := idx.detectTagCircularHelper("nonExistentTag", parentMap, tagRefs, visited, recStack, []string{}) + + // Should return empty slice since the tag doesn't exist + assert.Len(t, path, 0) + + // Verify that visited and recStack remain untouched + assert.Len(t, visited, 0) + assert.Len(t, recStack, 0) +} \ No newline at end of file diff --git a/test_specs/circular-tests.yaml b/test_specs/circular-tests.yaml index beeb6778a..22d0d8a1d 100644 --- a/test_specs/circular-tests.yaml +++ b/test_specs/circular-tests.yaml @@ -21,8 +21,12 @@ components: properties: testThing: "$ref": "#/components/schemas/One" + oneOf: + - "$ref": "#/components/schemas/Three" + allOf: + - "$ref": "#/components/schemas/Three" anyOf: - - "$ref": "#/components/schemas/Four" + - "$ref": "#/components/schemas/Three" required: - testThing - anyOf diff --git a/what-changed/model/tags.go b/what-changed/model/tags.go index 322c7bcc1..6f1eef1df 100644 --- a/what-changed/model/tags.go +++ b/what-changed/model/tags.go @@ -89,6 +89,17 @@ func CompareTags(l, r []low.ValueReference[*base.Tag]) []*TagChanges { New: seenRight[i].Value, }) + // Summary + props = append(props, &PropertyCheck{ + LeftNode: seenLeft[i].Value.Summary.ValueNode, + RightNode: seenRight[i].Value.Summary.ValueNode, + Label: v3.SummaryLabel, + Changes: &changes, + Breaking: false, + Original: seenLeft[i].Value, + New: seenRight[i].Value, + }) + // Description props = append(props, &PropertyCheck{ LeftNode: seenLeft[i].Value.Description.ValueNode, @@ -100,6 +111,28 @@ func CompareTags(l, r []low.ValueReference[*base.Tag]) []*TagChanges { New: seenRight[i].Value, }) + // Parent + props = append(props, &PropertyCheck{ + LeftNode: seenLeft[i].Value.Parent.ValueNode, + RightNode: seenRight[i].Value.Parent.ValueNode, + Label: v3.ParentLabel, + Changes: &changes, + Breaking: true, + Original: seenLeft[i].Value, + New: seenRight[i].Value, + }) + + // Kind + props = append(props, &PropertyCheck{ + LeftNode: seenLeft[i].Value.Kind.ValueNode, + RightNode: seenRight[i].Value.Kind.ValueNode, + Label: v3.KindLabel, + Changes: &changes, + Breaking: false, + Original: seenLeft[i].Value, + New: seenRight[i].Value, + }) + // check properties CheckProperties(props) diff --git a/what-changed/model/tags_test.go b/what-changed/model/tags_test.go index 0cc7a558f..759225eb3 100644 --- a/what-changed/model/tags_test.go +++ b/what-changed/model/tags_test.go @@ -312,3 +312,185 @@ tags: assert.Len(t, changes[0].GetAllChanges(), 1) assert.Equal(t, ObjectRemoved, changes[0].Changes[0].ChangeType) } + +func TestCompareTags_OpenAPI32_NewFields(t *testing.T) { + left := `openapi: 3.0.1 +tags: + - name: partner + description: Operations available to the partners network + externalDocs: + url: https://pb33f.io + description: Find more info here` + + right := `openapi: 3.0.1 +tags: + - name: partner + summary: Partner API + description: Operations available to the partners network + parent: external + kind: audience + externalDocs: + url: https://pb33f.io + description: Find more info here` + + // create document (which will create our correct tags low level structures) + lInfo, _ := datamodel.ExtractSpecInfo([]byte(left)) + rInfo, _ := datamodel.ExtractSpecInfo([]byte(right)) + lDoc, _ := lowv3.CreateDocumentFromConfig(lInfo, datamodel.NewDocumentConfiguration()) + rDoc, _ := lowv3.CreateDocumentFromConfig(rInfo, datamodel.NewDocumentConfiguration()) + + // compare. + changes := CompareTags(lDoc.Tags.Value, rDoc.Tags.Value) + + // evaluate. + assert.Len(t, changes[0].Changes, 3) // summary, parent, kind added + assert.Equal(t, 3, changes[0].TotalChanges()) + assert.Len(t, changes[0].GetAllChanges(), 3) + + // Check the changes + changeMap := make(map[string]*Change) + for _, change := range changes[0].Changes { + changeMap[change.Property] = change + } + + // Summary was added + summaryChange := changeMap["summary"] + assert.NotNil(t, summaryChange) + assert.Equal(t, PropertyAdded, summaryChange.ChangeType) + assert.Equal(t, "Partner API", summaryChange.New) + assert.False(t, summaryChange.Breaking) + + // Parent was added + parentChange := changeMap["parent"] + assert.NotNil(t, parentChange) + assert.Equal(t, PropertyAdded, parentChange.ChangeType) + assert.Equal(t, "external", parentChange.New) + assert.True(t, parentChange.Breaking) + + // Kind was added + kindChange := changeMap["kind"] + assert.NotNil(t, kindChange) + assert.Equal(t, PropertyAdded, kindChange.ChangeType) + assert.Equal(t, "audience", kindChange.New) + assert.False(t, kindChange.Breaking) +} + +func TestCompareTags_OpenAPI32_ModifiedFields(t *testing.T) { + left := `openapi: 3.0.1 +tags: + - name: partner + summary: Partner + description: Operations available to the partners network + parent: external + kind: audience` + + right := `openapi: 3.0.1 +tags: + - name: partner + summary: Partner API + description: Operations available to the partners network + parent: internal + kind: nav` + + // create document (which will create our correct tags low level structures) + lInfo, _ := datamodel.ExtractSpecInfo([]byte(left)) + rInfo, _ := datamodel.ExtractSpecInfo([]byte(right)) + lDoc, _ := lowv3.CreateDocumentFromConfig(lInfo, datamodel.NewDocumentConfiguration()) + rDoc, _ := lowv3.CreateDocumentFromConfig(rInfo, datamodel.NewDocumentConfiguration()) + + // compare. + changes := CompareTags(lDoc.Tags.Value, rDoc.Tags.Value) + + // evaluate. + assert.Len(t, changes[0].Changes, 3) // summary, parent, kind modified + assert.Equal(t, 3, changes[0].TotalChanges()) + assert.Equal(t, 1, changes[0].TotalBreakingChanges()) // only parent change is breaking + assert.Len(t, changes[0].GetAllChanges(), 3) + + // Check the changes + changeMap := make(map[string]*Change) + for _, change := range changes[0].Changes { + changeMap[change.Property] = change + } + + // Summary was modified (non-breaking) + summaryChange := changeMap["summary"] + assert.NotNil(t, summaryChange) + assert.Equal(t, Modified, summaryChange.ChangeType) + assert.Equal(t, "Partner", summaryChange.Original) + assert.Equal(t, "Partner API", summaryChange.New) + assert.False(t, summaryChange.Breaking) + + // Parent was modified (breaking) + parentChange := changeMap["parent"] + assert.NotNil(t, parentChange) + assert.Equal(t, Modified, parentChange.ChangeType) + assert.Equal(t, "external", parentChange.Original) + assert.Equal(t, "internal", parentChange.New) + assert.True(t, parentChange.Breaking) + + // Kind was modified (non-breaking) + kindChange := changeMap["kind"] + assert.NotNil(t, kindChange) + assert.Equal(t, Modified, kindChange.ChangeType) + assert.Equal(t, "audience", kindChange.Original) + assert.Equal(t, "nav", kindChange.New) + assert.False(t, kindChange.Breaking) +} + +func TestCompareTags_OpenAPI32_RemovedFields(t *testing.T) { + left := `openapi: 3.0.1 +tags: + - name: partner + summary: Partner API + description: Operations available to the partners network + parent: external + kind: audience` + + right := `openapi: 3.0.1 +tags: + - name: partner + description: Operations available to the partners network` + + // create document (which will create our correct tags low level structures) + lInfo, _ := datamodel.ExtractSpecInfo([]byte(left)) + rInfo, _ := datamodel.ExtractSpecInfo([]byte(right)) + lDoc, _ := lowv3.CreateDocumentFromConfig(lInfo, datamodel.NewDocumentConfiguration()) + rDoc, _ := lowv3.CreateDocumentFromConfig(rInfo, datamodel.NewDocumentConfiguration()) + + // compare. + changes := CompareTags(lDoc.Tags.Value, rDoc.Tags.Value) + + // evaluate. + assert.Len(t, changes[0].Changes, 3) // summary, parent, kind removed + assert.Equal(t, 3, changes[0].TotalChanges()) + assert.Equal(t, 1, changes[0].TotalBreakingChanges()) // only parent removal is breaking + assert.Len(t, changes[0].GetAllChanges(), 3) + + // Check the changes + changeMap := make(map[string]*Change) + for _, change := range changes[0].Changes { + changeMap[change.Property] = change + } + + // Summary was removed (non-breaking) + summaryChange := changeMap["summary"] + assert.NotNil(t, summaryChange) + assert.Equal(t, PropertyRemoved, summaryChange.ChangeType) + assert.Equal(t, "Partner API", summaryChange.Original) + assert.False(t, summaryChange.Breaking) + + // Parent was removed (breaking) + parentChange := changeMap["parent"] + assert.NotNil(t, parentChange) + assert.Equal(t, PropertyRemoved, parentChange.ChangeType) + assert.Equal(t, "external", parentChange.Original) + assert.True(t, parentChange.Breaking) + + // Kind was removed (non-breaking) + kindChange := changeMap["kind"] + assert.NotNil(t, kindChange) + assert.Equal(t, PropertyRemoved, kindChange.ChangeType) + assert.Equal(t, "audience", kindChange.Original) + assert.False(t, kindChange.Breaking) +} diff --git a/what-changed/what_changed_test.go b/what-changed/what_changed_test.go index 82ee9604a..37b0fb441 100644 --- a/what-changed/what_changed_test.go +++ b/what-changed/what_changed_test.go @@ -44,6 +44,7 @@ func TestCompareSwaggerDocuments(t *testing.T) { // TestCacheCollisionSelfReference reproduces the cache collision bug with self-referencing schemas // This is the key pattern that triggers the QuickHash cache collision issue +// see: https://github.com/pb33f/libopenapi/pull/441 func TestCacheCollisionSelfReference(t *testing.T) { // Original spec - TreeNode schema with basic properties original := `{ @@ -268,6 +269,7 @@ func TestCheckExplodedFileCheck(t *testing.T) { config := datamodel.NewDocumentConfiguration() config.BasePath = "../test_specs" config.AllowFileReferences = true + config.UseSchemaQuickHash = true origDoc, _ := v3.CreateDocumentFromConfig(infoOrig, config) modDoc, _ := v3.CreateDocumentFromConfig(infoMod, config) @@ -292,10 +294,12 @@ func TestCheckExplodedFileCheck_IdenticalRefNames(t *testing.T) { origDoc, _ := v3.CreateDocumentFromConfig(infoOrig, &datamodel.DocumentConfiguration{ BasePath: "../test_specs/ref_test/orig", AllowFileReferences: true, + UseSchemaQuickHash: true, }) modDoc, _ := v3.CreateDocumentFromConfig(infoMod, &datamodel.DocumentConfiguration{ BasePath: "../test_specs/ref_test/mod", AllowFileReferences: true, + UseSchemaQuickHash: true, }) changes := CompareOpenAPIDocuments(origDoc, modDoc)