From ea052d6bec47d6f2293d989596ee4584bd3f49ab Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Wed, 18 Feb 2026 12:14:33 +0200 Subject: [PATCH 01/11] feat: add file path to origin location tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass file path through the origin chain (yaml3 → yaml → kin-openapi) so that each Location includes which file it came from. This enables source location tracking for external $ref resolution. Co-Authored-By: Claude Opus 4.6 --- go.mod | 4 +- go.sum | 8 +-- openapi3/internalize_refs.go | 2 +- openapi3/loader.go | 8 +-- openapi3/marsh.go | 9 ++- openapi3/origin.go | 5 +- openapi3/origin_test.go | 66 +++++++++++++++++++ openapi3/testdata/origin/external-schema.yaml | 4 ++ openapi3/testdata/origin/external.yaml | 23 +++++++ 9 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 openapi3/testdata/origin/external-schema.yaml create mode 100644 openapi3/testdata/origin/external.yaml diff --git a/go.mod b/go.mod index 6f53d9f7e..f42385aea 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 github.com/gorilla/mux v1.8.0 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 + github.com/oasdiff/yaml v0.0.0-20260217201108-4f1c3d02ddd4 + github.com/oasdiff/yaml3 v0.0.0-20260217201013-8a620da6102a github.com/perimeterx/marshmallow v1.1.5 github.com/stretchr/testify v1.9.0 github.com/woodsbury/decimal128 v1.3.0 diff --git a/go.sum b/go.sum index 34001ef0a..93beafcd2 100644 --- a/go.sum +++ b/go.sum @@ -18,10 +18,10 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oasdiff/yaml v0.0.0-20260217201108-4f1c3d02ddd4 h1:WM2g1YIFwlbz3xcGO/RtaT0LBykfe0Cd3seWa4jZELM= +github.com/oasdiff/yaml v0.0.0-20260217201108-4f1c3d02ddd4/go.mod h1:ZTJR/EwUBsFK7J01Ybq7z/XCz8ioB4TMdr72QTeuqoY= +github.com/oasdiff/yaml3 v0.0.0-20260217201013-8a620da6102a h1:so9gdCU1AyG+EFCCp6ORLut4MBi/wIh4J3jGrEjNZnI= +github.com/oasdiff/yaml3 v0.0.0-20260217201013-8a620da6102a/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/openapi3/internalize_refs.go b/openapi3/internalize_refs.go index b725bafc0..dbfa57b84 100644 --- a/openapi3/internalize_refs.go +++ b/openapi3/internalize_refs.go @@ -6,7 +6,7 @@ import ( "strings" ) -// RefNameResolver maps a component to an name that is used as it's internalized name. +// RefNameResolver maps a component to a name that is used as it's internalized name. // // The function should avoid name collisions (i.e. be a injective mapping). // It must only contain characters valid for fixed field names: [IdentifierRegExp]. diff --git a/openapi3/loader.go b/openapi3/loader.go index 67674fc65..ddffa73a8 100644 --- a/openapi3/loader.go +++ b/openapi3/loader.go @@ -108,7 +108,7 @@ func (loader *Loader) loadSingleElementFromURI(ref string, rootPath *url.URL, el if err != nil { return nil, err } - if err := unmarshal(data, element, IncludeOrigin); err != nil { + if err := unmarshal(data, element, IncludeOrigin, resolvedPath); err != nil { return nil, err } @@ -144,7 +144,7 @@ func (loader *Loader) LoadFromIoReader(reader io.Reader) (*T, error) { func (loader *Loader) LoadFromData(data []byte) (*T, error) { loader.resetVisitedPathItemRefs() doc := &T{} - if err := unmarshal(data, doc, IncludeOrigin); err != nil { + if err := unmarshal(data, doc, IncludeOrigin, nil); err != nil { return nil, err } if err := loader.ResolveRefsIn(doc, nil); err != nil { @@ -173,7 +173,7 @@ func (loader *Loader) loadFromDataWithPathInternal(data []byte, location *url.UR doc := &T{} loader.visitedDocuments[uri] = doc - if err := unmarshal(data, doc, IncludeOrigin); err != nil { + if err := unmarshal(data, doc, IncludeOrigin, location); err != nil { return nil, err } @@ -427,7 +427,7 @@ func (loader *Loader) resolveComponent(doc *T, ref string, path *url.URL, resolv if err2 != nil { return nil, nil, err } - if err2 = unmarshal(data, &cursor, IncludeOrigin); err2 != nil { + if err2 = unmarshal(data, &cursor, IncludeOrigin, path); err2 != nil { return nil, nil, err } if cursor, err2 = drill(cursor); err2 != nil || cursor == nil { diff --git a/openapi3/marsh.go b/openapi3/marsh.go index 2f00828a6..5d081f9ea 100644 --- a/openapi3/marsh.go +++ b/openapi3/marsh.go @@ -3,6 +3,7 @@ package openapi3 import ( "encoding/json" "fmt" + "net/url" "strings" "github.com/oasdiff/yaml" @@ -16,7 +17,7 @@ func unmarshalError(jsonUnmarshalErr error) error { return jsonUnmarshalErr } -func unmarshal(data []byte, v any, includeOrigin bool) error { +func unmarshal(data []byte, v any, includeOrigin bool, location *url.URL) error { var jsonErr, yamlErr error // See https://github.com/getkin/kin-openapi/issues/680 @@ -25,7 +26,11 @@ func unmarshal(data []byte, v any, includeOrigin bool) error { } // UnmarshalStrict(data, v) TODO: investigate how ymlv3 handles duplicate map keys - if yamlErr = yaml.UnmarshalWithOrigin(data, v, includeOrigin); yamlErr == nil { + var file string + if location != nil { + file = location.Path + } + if yamlErr = yaml.UnmarshalWithOrigin(data, v, includeOrigin, file); yamlErr == nil { return nil } diff --git a/openapi3/origin.go b/openapi3/origin.go index d2a51d946..71427ddfc 100644 --- a/openapi3/origin.go +++ b/openapi3/origin.go @@ -12,6 +12,7 @@ type Origin struct { // Location is a struct that contains the location of a field. type Location struct { - Line int `json:"line,omitempty" yaml:"line,omitempty"` - Column int `json:"column,omitempty" yaml:"column,omitempty"` + File string `json:"file,omitempty" yaml:"file,omitempty"` + Line int `json:"line,omitempty" yaml:"line,omitempty"` + Column int `json:"column,omitempty" yaml:"column,omitempty"` } diff --git a/openapi3/origin_test.go b/openapi3/origin_test.go index 2c1d46de0..abae7f194 100644 --- a/openapi3/origin_test.go +++ b/openapi3/origin_test.go @@ -26,6 +26,7 @@ func TestOrigin_Info(t *testing.T) { require.NotNil(t, doc.Info.Origin) require.Equal(t, &Location{ + File: "testdata/origin/simple.yaml", Line: 2, Column: 1, }, @@ -33,6 +34,7 @@ func TestOrigin_Info(t *testing.T) { require.Equal(t, Location{ + File: "testdata/origin/simple.yaml", Line: 3, Column: 3, }, @@ -40,6 +42,7 @@ func TestOrigin_Info(t *testing.T) { require.Equal(t, Location{ + File: "testdata/origin/simple.yaml", Line: 4, Column: 3, }, @@ -61,6 +64,7 @@ func TestOrigin_Paths(t *testing.T) { require.NotNil(t, doc.Paths.Origin) require.Equal(t, &Location{ + File: "testdata/origin/simple.yaml", Line: 5, Column: 1, }, @@ -71,6 +75,7 @@ func TestOrigin_Paths(t *testing.T) { require.NotNil(t, base.Origin) require.Equal(t, &Location{ + File: "testdata/origin/simple.yaml", Line: 13, Column: 3, }, @@ -79,6 +84,7 @@ func TestOrigin_Paths(t *testing.T) { require.NotNil(t, base.Get.Origin) require.Equal(t, &Location{ + File: "testdata/origin/simple.yaml", Line: 14, Column: 5, }, @@ -101,6 +107,7 @@ func TestOrigin_RequestBody(t *testing.T) { require.NotNil(t, base.Origin) require.Equal(t, &Location{ + File: "testdata/origin/request_body.yaml", Line: 8, Column: 7, }, @@ -109,6 +116,7 @@ func TestOrigin_RequestBody(t *testing.T) { require.NotNil(t, base.Content["application/json"].Origin) require.Equal(t, &Location{ + File: "testdata/origin/request_body.yaml", Line: 10, Column: 11, }, @@ -131,6 +139,7 @@ func TestOrigin_Responses(t *testing.T) { require.NotNil(t, base.Origin) require.Equal(t, &Location{ + File: "testdata/origin/simple.yaml", Line: 17, Column: 7, }, @@ -140,6 +149,7 @@ func TestOrigin_Responses(t *testing.T) { require.Nil(t, base.Value("200").Origin) require.Equal(t, &Location{ + File: "testdata/origin/simple.yaml", Line: 18, Column: 9, }, @@ -147,6 +157,7 @@ func TestOrigin_Responses(t *testing.T) { require.Equal(t, Location{ + File: "testdata/origin/simple.yaml", Line: 19, Column: 11, }, @@ -169,6 +180,7 @@ func TestOrigin_Parameters(t *testing.T) { require.NotNil(t, base) require.Equal(t, &Location{ + File: "testdata/origin/parameters.yaml", Line: 9, Column: 11, }, @@ -176,6 +188,7 @@ func TestOrigin_Parameters(t *testing.T) { require.Equal(t, Location{ + File: "testdata/origin/parameters.yaml", Line: 10, Column: 11, }, @@ -183,6 +196,7 @@ func TestOrigin_Parameters(t *testing.T) { require.Equal(t, Location{ + File: "testdata/origin/parameters.yaml", Line: 9, Column: 11, }, @@ -207,6 +221,7 @@ func TestOrigin_SchemaInAdditionalProperties(t *testing.T) { require.NotNil(t, base.Schema.Value.Origin) require.Equal(t, &Location{ + File: "testdata/origin/additional_properties.yaml", Line: 14, Column: 17, }, @@ -214,6 +229,7 @@ func TestOrigin_SchemaInAdditionalProperties(t *testing.T) { require.Equal(t, Location{ + File: "testdata/origin/additional_properties.yaml", Line: 15, Column: 19, }, @@ -237,6 +253,7 @@ func TestOrigin_ExternalDocs(t *testing.T) { require.Equal(t, &Location{ + File: "testdata/origin/external_docs.yaml", Line: 13, Column: 1, }, @@ -244,6 +261,7 @@ func TestOrigin_ExternalDocs(t *testing.T) { require.Equal(t, Location{ + File: "testdata/origin/external_docs.yaml", Line: 14, Column: 3, }, @@ -251,6 +269,7 @@ func TestOrigin_ExternalDocs(t *testing.T) { require.Equal(t, Location{ + File: "testdata/origin/external_docs.yaml", Line: 15, Column: 3, }, @@ -274,6 +293,7 @@ func TestOrigin_Security(t *testing.T) { require.Equal(t, &Location{ + File: "testdata/origin/security.yaml", Line: 29, Column: 5, }, @@ -281,6 +301,7 @@ func TestOrigin_Security(t *testing.T) { require.Equal(t, Location{ + File: "testdata/origin/security.yaml", Line: 30, Column: 7, }, @@ -288,6 +309,7 @@ func TestOrigin_Security(t *testing.T) { require.Equal(t, &Location{ + File: "testdata/origin/security.yaml", Line: 31, Column: 7, }, @@ -295,6 +317,7 @@ func TestOrigin_Security(t *testing.T) { require.Equal(t, &Location{ + File: "testdata/origin/security.yaml", Line: 32, Column: 9, }, @@ -302,6 +325,7 @@ func TestOrigin_Security(t *testing.T) { require.Equal(t, Location{ + File: "testdata/origin/security.yaml", Line: 33, Column: 11, }, @@ -324,6 +348,7 @@ func TestOrigin_Example(t *testing.T) { require.NotNil(t, base.Origin) require.Equal(t, &Location{ + File: "testdata/origin/example.yaml", Line: 14, Column: 15, }, @@ -331,6 +356,7 @@ func TestOrigin_Example(t *testing.T) { require.Equal(t, Location{ + File: "testdata/origin/example.yaml", Line: 15, Column: 17, }, @@ -363,6 +389,7 @@ func TestOrigin_XML(t *testing.T) { require.NotNil(t, base.Origin) require.Equal(t, &Location{ + File: "testdata/origin/xml.yaml", Line: 21, Column: 19, }, @@ -370,6 +397,7 @@ func TestOrigin_XML(t *testing.T) { require.Equal(t, Location{ + File: "testdata/origin/xml.yaml", Line: 22, Column: 21, }, @@ -377,6 +405,7 @@ func TestOrigin_XML(t *testing.T) { require.Equal(t, Location{ + File: "testdata/origin/xml.yaml", Line: 23, Column: 21, }, @@ -416,3 +445,40 @@ components: require.Equal(t, `failed to unmarshal data: json error: invalid character 'p' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: unmarshal errors: line 0: mapping key "__origin__" already defined at line 17`, err.Error()) } + +func TestOrigin_WithExternalRef(t *testing.T) { + loader := NewLoader() + loader.IsExternalRefsAllowed = true + + IncludeOrigin = true + defer unsetIncludeOrigin() + + loader.Context = context.Background() + + doc, err := loader.LoadFromFile("testdata/origin/external.yaml") + require.NoError(t, err) + + base := doc.Paths.Find("/subscribe").Post.RequestBody.Value.Content["application/json"].Schema.Value.Properties["name"].Value + require.NotNil(t, base.XML.Origin) + require.Equal(t, base.XML.Origin.Key.File, "testdata/origin/external-schema.yaml") + // require.Equal(t, + // &Location{ + // Line: 1, + // Column: 1, + // }, + // base.Origin.Key) + + // require.Equal(t, + // Location{ + // Line: 2, + // Column: 3, + // }, + // base.Origin.Fields["namespace"]) + + // require.Equal(t, + // Location{ + // Line: 3, + // Column: 3, + // }, + // base.Origin.Fields["prefix"]) +} diff --git a/openapi3/testdata/origin/external-schema.yaml b/openapi3/testdata/origin/external-schema.yaml new file mode 100644 index 000000000..45376d0ad --- /dev/null +++ b/openapi3/testdata/origin/external-schema.yaml @@ -0,0 +1,4 @@ +type: string +xml: + namespace: http://example.com/schema/sample + prefix: sample \ No newline at end of file diff --git a/openapi3/testdata/origin/external.yaml b/openapi3/testdata/origin/external.yaml new file mode 100644 index 000000000..8bb4a5303 --- /dev/null +++ b/openapi3/testdata/origin/external.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.0 +info: + title: External Reference Example + version: 1.0.0 +paths: + /subscribe: + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: integer + format: int32 + xml: + attribute: true + name: + $ref: "./external-schema.yaml" + responses: + "200": + description: OK \ No newline at end of file From 0de143ebd34d8c7504232cdef98a553ed2ebfab5 Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Wed, 18 Feb 2026 23:06:59 +0200 Subject: [PATCH 02/11] feat: add Sequences field to Origin for scalar list item locations Co-Authored-By: Claude Opus 4.6 --- openapi3/origin.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openapi3/origin.go b/openapi3/origin.go index 71427ddfc..a47f25c58 100644 --- a/openapi3/origin.go +++ b/openapi3/origin.go @@ -4,10 +4,12 @@ const originKey = "__origin__" // Origin contains the origin of a collection. // Key is the location of the collection itself. -// Fields is a map of the location of each field in the collection. +// Fields is a map of the location of each scalar field in the collection. +// Sequences is a map of the location of each item in sequence-valued fields. type Origin struct { - Key *Location `json:"key,omitempty" yaml:"key,omitempty"` - Fields map[string]Location `json:"fields,omitempty" yaml:"fields,omitempty"` + Key *Location `json:"key,omitempty" yaml:"key,omitempty"` + Fields map[string]Location `json:"fields,omitempty" yaml:"fields,omitempty"` + Sequences map[string][]Location `json:"sequences,omitempty" yaml:"sequences,omitempty"` } // Location is a struct that contains the location of a field. From db9b0e8afff253435a4650f25e8da604bf401e0e Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Wed, 18 Feb 2026 23:10:51 +0200 Subject: [PATCH 03/11] feat: add Name field to Location for sequence item lookup Co-Authored-By: Claude Opus 4.6 --- openapi3/origin.go | 1 + 1 file changed, 1 insertion(+) diff --git a/openapi3/origin.go b/openapi3/origin.go index a47f25c58..663e23bb1 100644 --- a/openapi3/origin.go +++ b/openapi3/origin.go @@ -17,4 +17,5 @@ type Location struct { File string `json:"file,omitempty" yaml:"file,omitempty"` Line int `json:"line,omitempty" yaml:"line,omitempty"` Column int `json:"column,omitempty" yaml:"column,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` } From 2f563077c490c8252dccacbeaf2db4d4c3c2a84b Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Wed, 18 Feb 2026 23:18:38 +0200 Subject: [PATCH 04/11] fix: regenerate docs and document Origin/Location API changes - Regenerate .github/docs/*.txt to match go doc output - Document Location and Origin struct changes in README changelog - Fix docs.sh heading grep to match actual README heading Co-Authored-By: Claude Opus 4.6 --- .github/docs/openapi3.txt | 20 ++++++++++++-------- README.md | 4 ++++ docs.sh | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/docs/openapi3.txt b/.github/docs/openapi3.txt index 7458a1b1d..a1aae91a2 100644 --- a/.github/docs/openapi3.txt +++ b/.github/docs/openapi3.txt @@ -842,8 +842,10 @@ func (loader *Loader) ResolveRefsIn(doc *T, location *url.URL) (err error) ResolveRefsIn expands references if for instance spec was just unmarshaled type Location struct { - Line int `json:"line,omitempty" yaml:"line,omitempty"` - Column int `json:"column,omitempty" yaml:"column,omitempty"` + File string `json:"file,omitempty" yaml:"file,omitempty"` + Line int `json:"line,omitempty" yaml:"line,omitempty"` + Column int `json:"column,omitempty" yaml:"column,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` } Location is a struct that contains the location of a field. @@ -1041,12 +1043,14 @@ func (operation *Operation) Validate(ctx context.Context, opts ...ValidationOpti spec. type Origin struct { - Key *Location `json:"key,omitempty" yaml:"key,omitempty"` - Fields map[string]Location `json:"fields,omitempty" yaml:"fields,omitempty"` + Key *Location `json:"key,omitempty" yaml:"key,omitempty"` + Fields map[string]Location `json:"fields,omitempty" yaml:"fields,omitempty"` + Sequences map[string][]Location `json:"sequences,omitempty" yaml:"sequences,omitempty"` } Origin contains the origin of a collection. Key is the location of the - collection itself. Fields is a map of the location of each field in the - collection. + collection itself. Fields is a map of the location of each scalar field + in the collection. Sequences is a map of the location of each item in + sequence-valued fields. type Parameter struct { Extensions map[string]any `json:"-" yaml:"-"` @@ -1308,8 +1312,8 @@ type Ref struct { https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#reference-object type RefNameResolver func(*T, ComponentRef) string - RefNameResolver maps a component to an name that is used as it's - internalized name. + RefNameResolver maps a component to a name that is used as it's internalized + name. The function should avoid name collisions (i.e. be a injective mapping). It must only contain characters valid for fixed field names: IdentifierRegExp. diff --git a/README.md b/README.md index 58a612844..951ae0204 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,10 @@ for _, path := range doc.Paths.InMatchingOrder() { ## CHANGELOG: Sub-v1 breaking API changes +### v0.134.0 +* `openapi3.Location` gained `File` and `Name` fields (`string` type, replacing previous `int`-only struct layout) +* `openapi3.Origin` gained `Sequences` field (`map[string][]Location`, extending previous `map[string]Location`-only struct) + ### v0.131.0 * No longer `openapi3filter.RegisterBodyDecoder` the `openapi3filter.ZipFileBodyDecoder` by default. diff --git a/docs.sh b/docs.sh index ca3029a75..5d37676b4 100755 --- a/docs.sh +++ b/docs.sh @@ -17,7 +17,7 @@ count_missing_mentions() { | grep -Eo '^-[^ ]+ ([^ (]+)[ (]' \ | sed 's%(% %' \ | cut -d' ' -f2); do - if ! grep -A999999 '## Sub-v0 breaking API changes' README.md | grep -F "$thing"; then + if ! grep -A999999 'breaking API changes' README.md | grep -F "$thing"; then ((errors++)) || true fi done From 4189ebd17a6af4b5810a6223cfa6642db62dd02f Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Wed, 18 Feb 2026 23:18:54 +0200 Subject: [PATCH 05/11] Add SKILL.md with build instructions Co-Authored-By: Claude Opus 4.6 --- SKILL.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 SKILL.md diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 000000000..01a9d80f6 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,23 @@ +# kin-openapi Build Instructions + +## Before committing + +Always run `./docs.sh` before committing changes. CI checks that `.github/docs/*.txt` matches `go doc` output and will fail if they are out of date. + +```bash +./docs.sh +``` + +### What docs.sh does + +1. Regenerates `.github/docs/*.txt` files from `go doc -all` output for each package +2. Checks that any removed public API symbols are mentioned in README.md's "Sub-v1 breaking API changes" section + +### If docs.sh fails + +- **Stale docs:** The generated `.txt` files don't match current code. Stage the updated `.github/docs/*.txt` files in your commit. +- **Missing breaking change mention:** If you changed or removed a public API (struct field, function signature, type), add an entry to the `## CHANGELOG: Sub-v1 breaking API changes` section in README.md describing the change. The script uses `grep -F` to match the removed symbol text, so the mention must contain the exact symbol name. + +### CI lint rule + +Never use `require.Contains(t, err.Error(), ...)` in tests. Use `require.ErrorContains(t, err, ...)` instead. CI greps for `require[.].+err.Error` and fails. From b956c41f07a18892f00776f7619f74ff85e15ce6 Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Wed, 18 Feb 2026 23:21:03 +0200 Subject: [PATCH 06/11] Move SKILL.md to .claude/skills/pre-commit/ per Claude Code conventions Co-Authored-By: Claude Opus 4.6 --- .claude/skills/pre-commit/SKILL.md | 39 ++++++++++++++++++++++++++++++ SKILL.md | 23 ------------------ 2 files changed, 39 insertions(+), 23 deletions(-) create mode 100644 .claude/skills/pre-commit/SKILL.md delete mode 100644 SKILL.md diff --git a/.claude/skills/pre-commit/SKILL.md b/.claude/skills/pre-commit/SKILL.md new file mode 100644 index 000000000..051f236e4 --- /dev/null +++ b/.claude/skills/pre-commit/SKILL.md @@ -0,0 +1,39 @@ +--- +name: pre-commit +description: Run pre-commit checks for kin-openapi before committing. Use when about to commit, after making code changes, or when the user asks to validate changes. +disable-model-invocation: true +allowed-tools: Bash +--- + +# Pre-commit checks for kin-openapi + +Before committing changes to kin-openapi, run these steps in order: + +## 1. Regenerate docs + +```bash +./docs.sh +``` + +This regenerates `.github/docs/*.txt` from `go doc` output. CI checks that these files match and will fail if they are stale. + +If `docs.sh` fails with missing mentions, it means a public API symbol was changed or removed. Add an entry to the `## CHANGELOG: Sub-v1 breaking API changes` section in `README.md` describing the change. The script uses `grep -F` to find the symbol name, so the mention must contain the exact symbol text. + +Stage any updated `.github/docs/*.txt` and `README.md` files. + +## 2. Run tests + +```bash +go test ./... +``` + +## 3. Vet and format + +```bash +go vet ./... +go fmt ./... +``` + +## CI lint rule + +Never use `require.Contains(t, err.Error(), ...)` in tests. Use `require.ErrorContains(t, err, ...)` instead. CI greps for `require[.].+err.Error` and fails. diff --git a/SKILL.md b/SKILL.md deleted file mode 100644 index 01a9d80f6..000000000 --- a/SKILL.md +++ /dev/null @@ -1,23 +0,0 @@ -# kin-openapi Build Instructions - -## Before committing - -Always run `./docs.sh` before committing changes. CI checks that `.github/docs/*.txt` matches `go doc` output and will fail if they are out of date. - -```bash -./docs.sh -``` - -### What docs.sh does - -1. Regenerates `.github/docs/*.txt` files from `go doc -all` output for each package -2. Checks that any removed public API symbols are mentioned in README.md's "Sub-v1 breaking API changes" section - -### If docs.sh fails - -- **Stale docs:** The generated `.txt` files don't match current code. Stage the updated `.github/docs/*.txt` files in your commit. -- **Missing breaking change mention:** If you changed or removed a public API (struct field, function signature, type), add an entry to the `## CHANGELOG: Sub-v1 breaking API changes` section in README.md describing the change. The script uses `grep -F` to match the removed symbol text, so the mention must contain the exact symbol name. - -### CI lint rule - -Never use `require.Contains(t, err.Error(), ...)` in tests. Use `require.ErrorContains(t, err, ...)` instead. CI greps for `require[.].+err.Error` and fails. From ed03da3eac32dfd2d1bb9d867e3a68c45e10a8d0 Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Thu, 19 Feb 2026 16:07:24 +0200 Subject: [PATCH 07/11] chore: update yaml/yaml3 to feat/origin-enhancements branches Updates dependencies to use the sequence origin tracking versions. Fixes origin tests to expect Location.Name populated by yaml3. Co-Authored-By: Claude Opus 4.6 --- go.mod | 4 ++-- go.sum | 8 ++++---- openapi3/origin_test.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index f42385aea..b9123d352 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 github.com/gorilla/mux v1.8.0 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 - github.com/oasdiff/yaml v0.0.0-20260217201108-4f1c3d02ddd4 - github.com/oasdiff/yaml3 v0.0.0-20260217201013-8a620da6102a + github.com/oasdiff/yaml v0.0.0-20260219140331-2c8a4d8a2666 + github.com/oasdiff/yaml3 v0.0.0-20260218210655-d948faea3f9a github.com/perimeterx/marshmallow v1.1.5 github.com/stretchr/testify v1.9.0 github.com/woodsbury/decimal128 v1.3.0 diff --git a/go.sum b/go.sum index 93beafcd2..8f3b77ea9 100644 --- a/go.sum +++ b/go.sum @@ -18,10 +18,10 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/oasdiff/yaml v0.0.0-20260217201108-4f1c3d02ddd4 h1:WM2g1YIFwlbz3xcGO/RtaT0LBykfe0Cd3seWa4jZELM= -github.com/oasdiff/yaml v0.0.0-20260217201108-4f1c3d02ddd4/go.mod h1:ZTJR/EwUBsFK7J01Ybq7z/XCz8ioB4TMdr72QTeuqoY= -github.com/oasdiff/yaml3 v0.0.0-20260217201013-8a620da6102a h1:so9gdCU1AyG+EFCCp6ORLut4MBi/wIh4J3jGrEjNZnI= -github.com/oasdiff/yaml3 v0.0.0-20260217201013-8a620da6102a/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oasdiff/yaml v0.0.0-20260219140331-2c8a4d8a2666 h1:hSY3jBzVizJgAAIFz1KnNzRBsvfYugpTDImHQKXyulQ= +github.com/oasdiff/yaml v0.0.0-20260219140331-2c8a4d8a2666/go.mod h1:fJaKnR28y1gcnKBH3jmYS583SFfvje/pXHrq09/rp/I= +github.com/oasdiff/yaml3 v0.0.0-20260218210655-d948faea3f9a h1:GJqDDMVzCsld3oF8oRNfQwtQ5BqmXyUx0c59bSfrgzI= +github.com/oasdiff/yaml3 v0.0.0-20260218210655-d948faea3f9a/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/openapi3/origin_test.go b/openapi3/origin_test.go index abae7f194..f2183f5eb 100644 --- a/openapi3/origin_test.go +++ b/openapi3/origin_test.go @@ -29,6 +29,7 @@ func TestOrigin_Info(t *testing.T) { File: "testdata/origin/simple.yaml", Line: 2, Column: 1, + Name: "info", }, doc.Info.Origin.Key) @@ -37,6 +38,7 @@ func TestOrigin_Info(t *testing.T) { File: "testdata/origin/simple.yaml", Line: 3, Column: 3, + Name: "title", }, doc.Info.Origin.Fields["title"]) @@ -45,6 +47,7 @@ func TestOrigin_Info(t *testing.T) { File: "testdata/origin/simple.yaml", Line: 4, Column: 3, + Name: "version", }, doc.Info.Origin.Fields["version"]) } @@ -67,6 +70,7 @@ func TestOrigin_Paths(t *testing.T) { File: "testdata/origin/simple.yaml", Line: 5, Column: 1, + Name: "paths", }, doc.Paths.Origin.Key) @@ -78,6 +82,7 @@ func TestOrigin_Paths(t *testing.T) { File: "testdata/origin/simple.yaml", Line: 13, Column: 3, + Name: "/partner-api/test/another-method", }, base.Origin.Key) @@ -87,6 +92,7 @@ func TestOrigin_Paths(t *testing.T) { File: "testdata/origin/simple.yaml", Line: 14, Column: 5, + Name: "get", }, base.Get.Origin.Key) } @@ -110,6 +116,7 @@ func TestOrigin_RequestBody(t *testing.T) { File: "testdata/origin/request_body.yaml", Line: 8, Column: 7, + Name: "requestBody", }, base.Origin.Key) @@ -119,6 +126,7 @@ func TestOrigin_RequestBody(t *testing.T) { File: "testdata/origin/request_body.yaml", Line: 10, Column: 11, + Name: "application/json", }, base.Content["application/json"].Origin.Key) } @@ -142,6 +150,7 @@ func TestOrigin_Responses(t *testing.T) { File: "testdata/origin/simple.yaml", Line: 17, Column: 7, + Name: "responses", }, base.Origin.Key) @@ -152,6 +161,7 @@ func TestOrigin_Responses(t *testing.T) { File: "testdata/origin/simple.yaml", Line: 18, Column: 9, + Name: "200", }, base.Value("200").Value.Origin.Key) @@ -160,6 +170,7 @@ func TestOrigin_Responses(t *testing.T) { File: "testdata/origin/simple.yaml", Line: 19, Column: 11, + Name: "description", }, base.Value("200").Value.Origin.Fields["description"]) } @@ -183,6 +194,7 @@ func TestOrigin_Parameters(t *testing.T) { File: "testdata/origin/parameters.yaml", Line: 9, Column: 11, + Name: "name", }, base.Origin.Key) @@ -191,6 +203,7 @@ func TestOrigin_Parameters(t *testing.T) { File: "testdata/origin/parameters.yaml", Line: 10, Column: 11, + Name: "in", }, base.Origin.Fields["in"]) @@ -199,6 +212,7 @@ func TestOrigin_Parameters(t *testing.T) { File: "testdata/origin/parameters.yaml", Line: 9, Column: 11, + Name: "name", }, base.Origin.Fields["name"]) } @@ -224,6 +238,7 @@ func TestOrigin_SchemaInAdditionalProperties(t *testing.T) { File: "testdata/origin/additional_properties.yaml", Line: 14, Column: 17, + Name: "additionalProperties", }, base.Schema.Value.Origin.Key) @@ -232,6 +247,7 @@ func TestOrigin_SchemaInAdditionalProperties(t *testing.T) { File: "testdata/origin/additional_properties.yaml", Line: 15, Column: 19, + Name: "type", }, base.Schema.Value.Origin.Fields["type"]) } @@ -256,6 +272,7 @@ func TestOrigin_ExternalDocs(t *testing.T) { File: "testdata/origin/external_docs.yaml", Line: 13, Column: 1, + Name: "externalDocs", }, base.Origin.Key) @@ -264,6 +281,7 @@ func TestOrigin_ExternalDocs(t *testing.T) { File: "testdata/origin/external_docs.yaml", Line: 14, Column: 3, + Name: "description", }, base.Origin.Fields["description"]) @@ -272,6 +290,7 @@ func TestOrigin_ExternalDocs(t *testing.T) { File: "testdata/origin/external_docs.yaml", Line: 15, Column: 3, + Name: "url", }, base.Origin.Fields["url"]) } @@ -296,6 +315,7 @@ func TestOrigin_Security(t *testing.T) { File: "testdata/origin/security.yaml", Line: 29, Column: 5, + Name: "petstore_auth", }, base.Origin.Key) @@ -304,6 +324,7 @@ func TestOrigin_Security(t *testing.T) { File: "testdata/origin/security.yaml", Line: 30, Column: 7, + Name: "type", }, base.Origin.Fields["type"]) @@ -312,6 +333,7 @@ func TestOrigin_Security(t *testing.T) { File: "testdata/origin/security.yaml", Line: 31, Column: 7, + Name: "flows", }, base.Flows.Origin.Key) @@ -320,6 +342,7 @@ func TestOrigin_Security(t *testing.T) { File: "testdata/origin/security.yaml", Line: 32, Column: 9, + Name: "implicit", }, base.Flows.Implicit.Origin.Key) @@ -328,6 +351,7 @@ func TestOrigin_Security(t *testing.T) { File: "testdata/origin/security.yaml", Line: 33, Column: 11, + Name: "authorizationUrl", }, base.Flows.Implicit.Origin.Fields["authorizationUrl"]) } @@ -351,6 +375,7 @@ func TestOrigin_Example(t *testing.T) { File: "testdata/origin/example.yaml", Line: 14, Column: 15, + Name: "bar", }, base.Origin.Key) @@ -359,6 +384,7 @@ func TestOrigin_Example(t *testing.T) { File: "testdata/origin/example.yaml", Line: 15, Column: 17, + Name: "summary", }, base.Origin.Fields["summary"]) @@ -392,6 +418,7 @@ func TestOrigin_XML(t *testing.T) { File: "testdata/origin/xml.yaml", Line: 21, Column: 19, + Name: "xml", }, base.Origin.Key) @@ -400,6 +427,7 @@ func TestOrigin_XML(t *testing.T) { File: "testdata/origin/xml.yaml", Line: 22, Column: 21, + Name: "namespace", }, base.Origin.Fields["namespace"]) @@ -408,6 +436,7 @@ func TestOrigin_XML(t *testing.T) { File: "testdata/origin/xml.yaml", Line: 23, Column: 21, + Name: "prefix", }, base.Origin.Fields["prefix"]) } From 9d893e759f9326bdf873bb689952fa58e6ae3026 Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Thu, 19 Feb 2026 16:15:16 +0200 Subject: [PATCH 08/11] fix: exclude .claude directory from docs.sh package scan The docs.sh script iterates git-tracked directories and runs go doc on each. The .claude/skills/pre-commit directory has no Go source files, causing the CI to fail. Co-Authored-By: Claude Opus 4.6 --- docs.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs.sh b/docs.sh index 5d37676b4..bdcfce4ec 100755 --- a/docs.sh +++ b/docs.sh @@ -3,7 +3,7 @@ set -o pipefail outdir=.github/docs mkdir -p "$outdir" -for pkgpath in $(git ls-files | grep / | while read -r path; do dirname "$path"; done | sort -u | grep -vE '[.]git|testdata|internal|cmd/'); do +for pkgpath in $(git ls-files | grep / | while read -r path; do dirname "$path"; done | sort -u | grep -vE '[.]git|[.]claude|testdata|internal|cmd/'); do echo $pkgpath go doc -all ./"$pkgpath" | tee "$outdir/${pkgpath////_}.txt" done From 50de2204363c58761c2f931fab0fb44b289ef63e Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Thu, 19 Feb 2026 16:17:38 +0200 Subject: [PATCH 09/11] fix: gofmt alignment in Origin struct Co-Authored-By: Claude Opus 4.6 --- openapi3/origin.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openapi3/origin.go b/openapi3/origin.go index 663e23bb1..bf12d617a 100644 --- a/openapi3/origin.go +++ b/openapi3/origin.go @@ -7,9 +7,9 @@ const originKey = "__origin__" // Fields is a map of the location of each scalar field in the collection. // Sequences is a map of the location of each item in sequence-valued fields. type Origin struct { - Key *Location `json:"key,omitempty" yaml:"key,omitempty"` - Fields map[string]Location `json:"fields,omitempty" yaml:"fields,omitempty"` - Sequences map[string][]Location `json:"sequences,omitempty" yaml:"sequences,omitempty"` + Key *Location `json:"key,omitempty" yaml:"key,omitempty"` + Fields map[string]Location `json:"fields,omitempty" yaml:"fields,omitempty"` + Sequences map[string][]Location `json:"sequences,omitempty" yaml:"sequences,omitempty"` } // Location is a struct that contains the location of a field. From 1dfadd996a57b0ec8ef53b2bac53c8df73be2c50 Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Thu, 19 Feb 2026 16:21:53 +0200 Subject: [PATCH 10/11] remove .claude skills directory from repository CI docs.sh fails on non-Go directories. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/pre-commit/SKILL.md | 39 ------------------------------ 1 file changed, 39 deletions(-) delete mode 100644 .claude/skills/pre-commit/SKILL.md diff --git a/.claude/skills/pre-commit/SKILL.md b/.claude/skills/pre-commit/SKILL.md deleted file mode 100644 index 051f236e4..000000000 --- a/.claude/skills/pre-commit/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: pre-commit -description: Run pre-commit checks for kin-openapi before committing. Use when about to commit, after making code changes, or when the user asks to validate changes. -disable-model-invocation: true -allowed-tools: Bash ---- - -# Pre-commit checks for kin-openapi - -Before committing changes to kin-openapi, run these steps in order: - -## 1. Regenerate docs - -```bash -./docs.sh -``` - -This regenerates `.github/docs/*.txt` from `go doc` output. CI checks that these files match and will fail if they are stale. - -If `docs.sh` fails with missing mentions, it means a public API symbol was changed or removed. Add an entry to the `## CHANGELOG: Sub-v1 breaking API changes` section in `README.md` describing the change. The script uses `grep -F` to find the symbol name, so the mention must contain the exact symbol text. - -Stage any updated `.github/docs/*.txt` and `README.md` files. - -## 2. Run tests - -```bash -go test ./... -``` - -## 3. Vet and format - -```bash -go vet ./... -go fmt ./... -``` - -## CI lint rule - -Never use `require.Contains(t, err.Error(), ...)` in tests. Use `require.ErrorContains(t, err, ...)` instead. CI greps for `require[.].+err.Error` and fails. From 78f66ebc5599dca97166a3f499b71a2d0f49494c Mon Sep 17 00:00:00 2001 From: Reuven Harrison Date: Tue, 24 Feb 2026 21:44:57 +0200 Subject: [PATCH 11/11] chore: update yaml3 to include sequence keys in origin Fields Co-Authored-By: Claude Opus 4.6 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b9123d352..ad387803f 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/gorilla/mux v1.8.0 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 github.com/oasdiff/yaml v0.0.0-20260219140331-2c8a4d8a2666 - github.com/oasdiff/yaml3 v0.0.0-20260218210655-d948faea3f9a + github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b github.com/perimeterx/marshmallow v1.1.5 github.com/stretchr/testify v1.9.0 github.com/woodsbury/decimal128 v1.3.0 diff --git a/go.sum b/go.sum index 8f3b77ea9..ca05f258b 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/oasdiff/yaml v0.0.0-20260219140331-2c8a4d8a2666 h1:hSY3jBzVizJgAAIFz1KnNzRBsvfYugpTDImHQKXyulQ= github.com/oasdiff/yaml v0.0.0-20260219140331-2c8a4d8a2666/go.mod h1:fJaKnR28y1gcnKBH3jmYS583SFfvje/pXHrq09/rp/I= -github.com/oasdiff/yaml3 v0.0.0-20260218210655-d948faea3f9a h1:GJqDDMVzCsld3oF8oRNfQwtQ5BqmXyUx0c59bSfrgzI= -github.com/oasdiff/yaml3 v0.0.0-20260218210655-d948faea3f9a/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g= +github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=