diff --git a/compilers/openapi/internal/diag/diag.go b/compilers/openapi/internal/diag/diag.go index 1dd9ed4f..e0a611cc 100644 --- a/compilers/openapi/internal/diag/diag.go +++ b/compilers/openapi/internal/diag/diag.go @@ -165,6 +165,17 @@ const ( // // Error, as the parser's report was. DuplicateParameter = "openapi/duplicate-parameter" + // DuplicateServerName reports a servers list entry whose name an earlier + // entry in the same list already has, compared as written, at the repeat's + // name. Load checks every list the source writes, the document's, a path + // item's or an operation's, lowered or not, but none only a $ref reaches + // (GitHub #880). Both entries are kept, though a consumer keying servers by + // name cannot tell them apart. Names equal once canonicalized, and URL + // hints, are not reported: an emitter uniquifies them. + // + // Warning, where DuplicateParameter is an error: OpenAPI 3.2 calls the name + // "an optional unique string" with no RFC 2119 keyword. + DuplicateServerName = "openapi/duplicate-server-name" // InvalidMethodKey reports an additionalOperations key that names no method: // the empty string. The operation still lowers, binding the key as written, so // nothing the entry declares is lost — what is reported is that the binding's diff --git a/compilers/openapi/internal/diag/diag_test.go b/compilers/openapi/internal/diag/diag_test.go index 6414f1a6..19b2361f 100644 --- a/compilers/openapi/internal/diag/diag_test.go +++ b/compilers/openapi/internal/diag/diag_test.go @@ -138,7 +138,7 @@ func codes() []string { diag.OverlayAction, diag.OverlayOriginIncomplete, diag.ValidationOnlyKeyword, diag.FalseSchema, diag.EmptyEnum, diag.DuplicateEnumValue, diag.NumericPrecision, diag.ExclusiveBoundForm, diag.InvalidStatusKey, - diag.DuplicateStatusKey, diag.DuplicateParameter, + diag.DuplicateStatusKey, diag.DuplicateParameter, diag.DuplicateServerName, diag.InvalidMethodKey, diag.DegradedConstruct, diag.CompositionLowering, diag.DynamicRefExpanded, diag.ConflictingRedecl, diag.DisjointVisibility, diff --git a/compilers/openapi/internal/load/later_internal_test.go b/compilers/openapi/internal/load/later_internal_test.go index 8b08b5a7..b02127e2 100644 --- a/compilers/openapi/internal/load/later_internal_test.go +++ b/compilers/openapi/internal/load/later_internal_test.go @@ -360,7 +360,7 @@ components: } // TestLaterConstructs_AWalkThatPanicsIsReported pins the barrier around the -// walk, as parameterRepeats has one: a panic is an error at the model reached. +// walk, as listRepeats has one: a panic is an error at the model reached. func TestLaterConstructs_AWalkThatPanicsIsReported(t *testing.T) { t.Parallel() panics := func(func(soa.WalkItem) bool) { panic("walk fault") } diff --git a/compilers/openapi/internal/load/load.go b/compilers/openapi/internal/load/load.go index 7f66bca3..f2d675d6 100644 --- a/compilers/openapi/internal/load/load.go +++ b/compilers/openapi/internal/load/load.go @@ -308,13 +308,13 @@ func validated(ctx context.Context, at func(jsontext.Pointer) ir.Provenance, loc } // sourceChecks runs the rules the compiler checks over the resolved source: -// the repeats of a parameter list, and the references refused for reading past -// a path item $ref into the fields beside it, less any resolved already -// reports (see besideRefs). +// the repeats of a parameter or servers list, and the references refused for +// reading past a path item $ref into the fields beside it, less any resolved +// already reports (see besideRefs). func sourceChecks(ctx context.Context, at func(jsontext.Pointer) ir.Provenance, doc *soa.OpenAPI, sibs Siblings, resolved []ir.Diagnostic, ) []ir.Diagnostic { - diags := parameterRepeats(at, sibs, walkSource(ctx, doc, sibs)) + diags := listRepeats(at, sibs, walkSource(ctx, doc, sibs)) return append(diags, besideRefs(at, sibs, walkSource(ctx, doc, sibs), resolved)...) } @@ -357,8 +357,8 @@ func dropped(verr validation.Error) bool { // // Parameter uniqueness: the library reads only inline entries, so a repeat // through a $ref went unreported, and beside the compiler's own report an -// inline repeat drew two errors. parameterRepeats checks every list the source -// writes, its $refs resolved (#634, #840). +// inline repeat drew two errors. listRepeats checks every parameter list the +// source writes, its $refs resolved (#634, #840). func compilerOwned(verr validation.Error) bool { return verr.Rule == validation.RuleValidationOperationIdUnique || verr.Rule == validation.RuleValidationOperationParameters diff --git a/compilers/openapi/internal/load/load_internal_test.go b/compilers/openapi/internal/load/load_internal_test.go index 2d4b510f..cd2ff731 100644 --- a/compilers/openapi/internal/load/load_internal_test.go +++ b/compilers/openapi/internal/load/load_internal_test.go @@ -111,7 +111,7 @@ paths: // TestLoad_ParameterUniquenessIsTheCompilers pins compilerOwned's second // member: the library's report of an inline repeat is dropped, and the -// compiler's own (parameterRepeats) is the one report, at the repeat. As for +// compiler's own (paramRepeats) is the one report, at the repeat. As for // operationIds, the test first confirms the library raises its finding for the // fixture, or a library that stopped would leave this passing with nothing // dropped. diff --git a/compilers/openapi/internal/load/params.go b/compilers/openapi/internal/load/params.go deleted file mode 100644 index 27183410..00000000 --- a/compilers/openapi/internal/load/params.go +++ /dev/null @@ -1,79 +0,0 @@ -package load - -import ( - "encoding/json/jsontext" - "iter" - "strconv" - - soa "github.com/speakeasy-api/openapi/openapi" - - "github.com/dexpace/morphic/compilers/openapi/internal/diag" - refscope "github.com/dexpace/morphic/compilers/openapi/internal/resolve" - "github.com/dexpace/morphic/ir" -) - -// parameterRepeats reports each entry of a parameter list written in the source -// that repeats the name and location of an earlier entry in the same list, -// which OpenAPI forbids. The compiler owns the rule (compilerOwned): the -// library read only entries written inline, so it missed a repeat through a -// $ref. -// -// items is walkSource's walk, which visits each list once where it is written, -// lowered or not, and never through a $ref. A list beside a path item's $ref is -// sibs's model's, checked where that model is walked from. A panic in the walk -// is reported at the model it reached. -func parameterRepeats(at func(jsontext.Pointer) ir.Provenance, sibs Siblings, items iter.Seq[soa.WalkItem], -) []ir.Diagnostic { - var diags []ir.Diagnostic - site, err := eachModel(items, "parameter check", func(site jsontext.Pointer, model any) error { - switch m := model.(type) { - case *soa.ReferencedPathItem: - if !m.IsReference() { - diags = append(diags, listRepeats(at, m.GetObject().GetParameters(), site)...) - } else if sib, ok := sibs.Of(m); ok && sib.Pointer == site { - diags = append(diags, listRepeats(at, sib.Item.GetParameters(), site)...) - } - case *soa.Operation: - diags = append(diags, listRepeats(at, m.GetParameters(), site)...) - default: - // No other model holds a parameter list. - } - return nil - }) - if err != nil { - diags = append(diags, diag.Newf(ir.SeverityError, diag.Validation, at(site), "%s", err.Error())) - } - return diags -} - -// paramSlot is what OpenAPI identifies a parameter by within one list: its name -// and location, compared as written. -type paramSlot struct { - name, in string -} - -// listRepeats reports each entry of the parameter list held by the object at -// owner that repeats an earlier entry's slot, at the repeat, naming the first -// holder. An entry is read as the lowering reads it (refscope.Object), so a -// repeat reported here is one the lowering numbers; one whose $ref resolved to -// nothing has no slot. -func listRepeats(at func(jsontext.Pointer) ir.Provenance, params []*soa.ReferencedParameter, owner jsontext.Pointer) []ir.Diagnostic { - firstAt := make(map[paramSlot]jsontext.Pointer, len(params)) - var diags []ir.Diagnostic - for i, rp := range params { - p := refscope.Object[soa.Parameter](rp) - if p == nil { - continue - } - entry := owner.AppendToken("parameters").AppendToken(strconv.Itoa(i)) - slot := paramSlot{name: p.GetName(), in: string(p.GetIn())} - first, repeated := firstAt[slot] - if !repeated { - firstAt[slot] = entry - continue - } - diags = append(diags, diag.Newf(ir.SeverityError, diag.DuplicateParameter, at(entry), - "parameter %q in %q repeats the entry at %s", slot.name, slot.in, first)) - } - return diags -} diff --git a/compilers/openapi/internal/load/params_internal_test.go b/compilers/openapi/internal/load/params_internal_test.go deleted file mode 100644 index 34657913..00000000 --- a/compilers/openapi/internal/load/params_internal_test.go +++ /dev/null @@ -1,179 +0,0 @@ -package load - -import ( - "encoding/json/jsontext" - "testing" - - soa "github.com/speakeasy-api/openapi/openapi" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/dexpace/morphic/compilers" - "github.com/dexpace/morphic/compilers/openapi/internal/diag" - "github.com/dexpace/morphic/ir" -) - -// paramRepeatComponents are the components every case below draws on: two -// parameters holding one slot, and a third holding another. -const paramRepeatComponents = `components: - parameters: - Limit: {name: limit, in: query, schema: {type: integer}} - Limit2: {name: limit, in: query, schema: {type: string}} - Other: {name: other, in: query, schema: {type: string}} -` - -// repeatsAt loads src and returns each openapi/duplicate-parameter it draws, -// keyed by the pointer it is reported at, with the message as the value. -func repeatsAt(t *testing.T, src string) map[jsontext.Pointer]string { - t.Helper() - _, diags, err := Load(t.Context(), 0, compilers.Source{Path: "spec.yaml", Data: []byte(src)}, Options{}) - require.NoError(t, err) - got := map[jsontext.Pointer]string{} - for _, d := range diags { - assert.NotEqual(t, diag.Validation+"/validation-operation-parameters", d.Code, "the library's report is dropped") - if d.Code == diag.DuplicateParameter { - assert.Equal(t, ir.SeverityError, d.Severity) - got[d.Provenance.Pointer] = d.Message - } - } - return got -} - -// TestParameterRepeats_ReportsEveryListTheSourceWrites covers each place a -// parameter list is written, lowered or not: a path item's entries an operation -// shadows, a path item with no operation, a component path item nothing -// mounts, a component callback, and a webhook. Each repeat is reported once, at -// itself, naming the first entry holding its slot, whether its entries are -// inline or $refs. -func TestParameterRepeats_ReportsEveryListTheSourceWrites(t *testing.T) { - t.Parallel() - const head = "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n" - tests := []struct { - name string - src string - want map[jsontext.Pointer]string - }{ - { - name: "path-item entries an operation shadows", - src: head + `paths: - /x: - parameters: - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Limit2" - get: - parameters: - - {name: limit, in: query, schema: {type: boolean}} - responses: {"200": {description: ok}} -` + paramRepeatComponents, - want: map[jsontext.Pointer]string{ - "/paths/~1x/parameters/1": `parameter "limit" in "query" repeats the entry at /paths/~1x/parameters/0`, - }, - }, - { - name: "a path item with no operation", - src: head + `paths: - /x: - parameters: - - $ref: "#/components/parameters/Limit" - - {name: limit, in: query, schema: {type: string}} -` + paramRepeatComponents, - want: map[jsontext.Pointer]string{ - "/paths/~1x/parameters/1": `parameter "limit" in "query" repeats the entry at /paths/~1x/parameters/0`, - }, - }, - { - name: "a component path item nothing mounts, and its operation", - src: head + "paths: {}\n" + paramRepeatComponents + ` pathItems: - Unused: - parameters: - - {name: a, in: header, schema: {type: string}} - - {name: a, in: header, schema: {type: string}} - get: - parameters: - - $ref: "#/components/parameters/Other" - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Limit2" - - $ref: "#/components/parameters/Limit" - responses: {"200": {description: ok}} -`, - want: map[jsontext.Pointer]string{ - "/components/pathItems/Unused/parameters/1": `parameter "a" in "header" repeats the entry at /components/pathItems/Unused/parameters/0`, - "/components/pathItems/Unused/get/parameters/2": `parameter "limit" in "query" repeats the entry at /components/pathItems/Unused/get/parameters/1`, - "/components/pathItems/Unused/get/parameters/3": `parameter "limit" in "query" repeats the entry at /components/pathItems/Unused/get/parameters/1`, - }, - }, - { - name: "a component callback and a webhook", - src: head + `paths: {} -webhooks: - hook: - post: - parameters: - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Limit" - responses: {"200": {description: ok}} -` + paramRepeatComponents + ` callbacks: - CB: - "{$request.query.q}": - parameters: - - $ref: "#/components/parameters/Limit2" - - $ref: "#/components/parameters/Limit" -`, - want: map[jsontext.Pointer]string{ - "/webhooks/hook/post/parameters/1": `parameter "limit" in "query" repeats the entry at /webhooks/hook/post/parameters/0`, - "/components/callbacks/CB/{$request.query.q}/parameters/1": `parameter "limit" in "query" repeats the entry at /components/callbacks/CB/{$request.query.q}/parameters/0`, - }, - }, - { - name: "a path item two paths mount, reported where it is written", - src: head + `paths: - /a: {$ref: "#/components/pathItems/Shared"} - /b: {$ref: "#/components/pathItems/Shared"} -` + paramRepeatComponents + ` pathItems: - Shared: - parameters: - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Limit" - get: - responses: {"200": {description: ok}} -`, - want: map[jsontext.Pointer]string{ - "/components/pathItems/Shared/parameters/1": `parameter "limit" in "query" repeats the entry at /components/pathItems/Shared/parameters/0`, - }, - }, - { - name: "one name in two locations, and a $ref that resolves to nothing", - src: head + `paths: - /x/{limit}: - get: - parameters: - - {name: limit, in: path, required: true, schema: {type: string}} - - $ref: "#/components/parameters/Limit" - - $ref: "#/components/parameters/Missing" - - $ref: "#/components/parameters/Missing" - responses: {"200": {description: ok}} -` + paramRepeatComponents, - want: map[jsontext.Pointer]string{}, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tc.want, repeatsAt(t, tc.src)) - }) - } -} - -// TestParameterRepeats_AWalkThatPanicsIsReported pins the barrier around the -// walk: the library's walk faults on shapes its parser accepts, and the panic -// is reported as an error rather than crashing the compile. -func TestParameterRepeats_AWalkThatPanicsIsReported(t *testing.T) { - t.Parallel() - panics := func(func(soa.WalkItem) bool) { panic("walk fault") } - at := func(p jsontext.Pointer) ir.Provenance { return ir.Provenance{Pointer: p} } - diags := parameterRepeats(at, Siblings{}, panics) - require.Len(t, diags, 1) - assert.Equal(t, ir.SeverityError, diags[0].Severity) - assert.Equal(t, diag.Validation, diags[0].Code) - assert.Contains(t, diags[0].Message, "parameter check panicked: walk fault") -} diff --git a/compilers/openapi/internal/load/repeats.go b/compilers/openapi/internal/load/repeats.go new file mode 100644 index 00000000..a7c92746 --- /dev/null +++ b/compilers/openapi/internal/load/repeats.go @@ -0,0 +1,129 @@ +package load + +import ( + "encoding/json/jsontext" + "iter" + "strconv" + + soa "github.com/speakeasy-api/openapi/openapi" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + refscope "github.com/dexpace/morphic/compilers/openapi/internal/resolve" + "github.com/dexpace/morphic/ir" +) + +// listRepeats reports each entry of a list written in the source that repeats +// what an earlier entry of the same list is identified by: a parameter's name +// and location (paramRepeats), or a server's name (serverRepeats). +// +// items is walkSource's walk, which visits each list once where it is written, +// lowered or not, and never through a $ref, so a list only a $ref reaches is +// not checked (GitHub #880). The lists beside a path item's $ref are sibs's +// model's, checked where that model is walked from. A panic in the walk is +// reported at the model it reached. +func listRepeats(at func(jsontext.Pointer) ir.Provenance, sibs Siblings, items iter.Seq[soa.WalkItem], +) []ir.Diagnostic { + var diags []ir.Diagnostic + site, err := eachModel(items, "repeat check", func(site jsontext.Pointer, model any) error { + switch m := model.(type) { + case *soa.OpenAPI: + // Placed from "", not site: the library renders the root's location + // as "/", the pointer to a key named "". Read from the field, since + // GetServers stands in a server the source never wrote. + diags = append(diags, serverRepeats(at, m.Servers, "")...) + case *soa.ReferencedPathItem: + if item, ok := writtenItem(m, sibs, site); ok { + diags = append(diags, paramRepeats(at, item.GetParameters(), site)...) + diags = append(diags, serverRepeats(at, item.GetServers(), site)...) + } + case *soa.Operation: + diags = append(diags, paramRepeats(at, m.GetParameters(), site)...) + diags = append(diags, serverRepeats(at, m.GetServers(), site)...) + default: + // No other model holds a parameter or a servers list. + } + return nil + }) + if err != nil { + diags = append(diags, diag.Newf(ir.SeverityError, diag.Validation, at(site), "%s", err.Error())) + } + return diags +} + +// writtenItem returns the path item whose lists rp writes at site: its own +// object, or the model of the fields beside its $ref when site is the position +// that model was found at, so an alias reaching those fields again is not +// checked twice. A $ref with nothing beside it writes no list. +func writtenItem(rp *soa.ReferencedPathItem, sibs Siblings, site jsontext.Pointer) (*soa.PathItem, bool) { + if !rp.IsReference() { + return rp.GetObject(), true + } + sib, ok := sibs.Of(rp) + return sib.Item, ok && sib.Pointer == site +} + +// paramSlot is what OpenAPI identifies a parameter by within one list: its name +// and location, compared as written. +type paramSlot struct { + name, in string +} + +// paramRepeats reports each entry of the parameter list held by the object at +// owner that repeats an earlier entry's slot, which OpenAPI forbids, at the +// repeat, naming the first holder. The compiler owns the rule (compilerOwned): +// the library read only entries written inline, so it missed a repeat through +// a $ref. +// +// An entry is read as the lowering reads it (refscope.Object), so a repeat +// reported here is one the lowering numbers; one whose $ref resolved to nothing +// has no slot. +func paramRepeats(at func(jsontext.Pointer) ir.Provenance, params []*soa.ReferencedParameter, owner jsontext.Pointer) []ir.Diagnostic { + firstAt := make(map[paramSlot]jsontext.Pointer, len(params)) + var diags []ir.Diagnostic + for i, rp := range params { + p := refscope.Object[soa.Parameter](rp) + if p == nil { + continue + } + entry := owner.AppendToken("parameters").AppendToken(strconv.Itoa(i)) + slot := paramSlot{name: p.GetName(), in: string(p.GetIn())} + first, repeated := firstAt[slot] + if !repeated { + firstAt[slot] = entry + continue + } + diags = append(diags, diag.Newf(ir.SeverityError, diag.DuplicateParameter, at(entry), + "parameter %q in %q repeats the entry at %s", slot.name, slot.in, first)) + } + return diags +} + +// serverRepeats reports each entry of the servers list held by the object at +// owner whose name an earlier entry already has, at the repeat's name, naming +// the first holder's. Names compare as written, and an entry without one +// repeats nothing: a URL hint is the lowering's, not a name the source wrote. +// A list shorter than two entries has nothing to compare, so most lists cost +// no map. +func serverRepeats(at func(jsontext.Pointer) ir.Provenance, servers []*soa.Server, owner jsontext.Pointer) []ir.Diagnostic { + if len(servers) < 2 { + return nil + } + firstAt := make(map[string]jsontext.Pointer, len(servers)) + var diags []ir.Diagnostic + for i, s := range servers { + name := s.GetName() + if name == "" { + continue + } + key := owner.AppendToken("servers").AppendToken(strconv.Itoa(i)).AppendToken("name") + first, repeated := firstAt[name] + if !repeated { + firstAt[name] = key + continue + } + diags = append(diags, diag.Newf(ir.SeverityWarning, diag.DuplicateServerName, at(key), + "server name %q repeats the name at %s; both servers are kept, and a consumer "+ + "keying servers by name cannot tell them apart", name, first)) + } + return diags +} diff --git a/compilers/openapi/internal/load/repeats_internal_test.go b/compilers/openapi/internal/load/repeats_internal_test.go new file mode 100644 index 00000000..843f11f5 --- /dev/null +++ b/compilers/openapi/internal/load/repeats_internal_test.go @@ -0,0 +1,425 @@ +package load + +import ( + "encoding/json/jsontext" + "fmt" + "testing" + + soa "github.com/speakeasy-api/openapi/openapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/ir" +) + +// paramRepeatComponents are the components every case below draws on: two +// parameters holding one slot, and a third holding another. +const paramRepeatComponents = `components: + parameters: + Limit: {name: limit, in: query, schema: {type: integer}} + Limit2: {name: limit, in: query, schema: {type: string}} + Other: {name: other, in: query, schema: {type: string}} +` + +// repeatsAt loads src and returns each openapi/duplicate-parameter it draws, +// keyed by the pointer it is reported at, with the message as the value. +func repeatsAt(t *testing.T, src string) map[jsontext.Pointer]string { + t.Helper() + _, diags, err := Load(t.Context(), 0, compilers.Source{Path: "spec.yaml", Data: []byte(src)}, Options{}) + require.NoError(t, err) + got := map[jsontext.Pointer]string{} + for _, d := range diags { + assert.NotEqual(t, diag.Validation+"/validation-operation-parameters", d.Code, "the library's report is dropped") + if d.Code == diag.DuplicateParameter { + assert.Equal(t, ir.SeverityError, d.Severity) + got[d.Provenance.Pointer] = d.Message + } + } + return got +} + +// TestParameterRepeats_ReportsEveryListTheSourceWrites covers each place a +// parameter list is written, lowered or not: a path item's entries an operation +// shadows, a path item with no operation, a component path item nothing +// mounts, a component callback, and a webhook. Each repeat is reported once, at +// itself, naming the first entry holding its slot, whether its entries are +// inline or $refs. +func TestParameterRepeats_ReportsEveryListTheSourceWrites(t *testing.T) { + t.Parallel() + const head = "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\n" + tests := []struct { + name string + src string + want map[jsontext.Pointer]string + }{ + { + name: "path-item entries an operation shadows", + src: head + `paths: + /x: + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Limit2" + get: + parameters: + - {name: limit, in: query, schema: {type: boolean}} + responses: {"200": {description: ok}} +` + paramRepeatComponents, + want: map[jsontext.Pointer]string{ + "/paths/~1x/parameters/1": `parameter "limit" in "query" repeats the entry at /paths/~1x/parameters/0`, + }, + }, + { + name: "a path item with no operation", + src: head + `paths: + /x: + parameters: + - $ref: "#/components/parameters/Limit" + - {name: limit, in: query, schema: {type: string}} +` + paramRepeatComponents, + want: map[jsontext.Pointer]string{ + "/paths/~1x/parameters/1": `parameter "limit" in "query" repeats the entry at /paths/~1x/parameters/0`, + }, + }, + { + name: "a component path item nothing mounts, and its operation", + src: head + "paths: {}\n" + paramRepeatComponents + ` pathItems: + Unused: + parameters: + - {name: a, in: header, schema: {type: string}} + - {name: a, in: header, schema: {type: string}} + get: + parameters: + - $ref: "#/components/parameters/Other" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Limit2" + - $ref: "#/components/parameters/Limit" + responses: {"200": {description: ok}} +`, + want: map[jsontext.Pointer]string{ + "/components/pathItems/Unused/parameters/1": `parameter "a" in "header" repeats the entry at /components/pathItems/Unused/parameters/0`, + "/components/pathItems/Unused/get/parameters/2": `parameter "limit" in "query" repeats the entry at /components/pathItems/Unused/get/parameters/1`, + "/components/pathItems/Unused/get/parameters/3": `parameter "limit" in "query" repeats the entry at /components/pathItems/Unused/get/parameters/1`, + }, + }, + { + name: "a component callback and a webhook", + src: head + `paths: {} +webhooks: + hook: + post: + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Limit" + responses: {"200": {description: ok}} +` + paramRepeatComponents + ` callbacks: + CB: + "{$request.query.q}": + parameters: + - $ref: "#/components/parameters/Limit2" + - $ref: "#/components/parameters/Limit" +`, + want: map[jsontext.Pointer]string{ + "/webhooks/hook/post/parameters/1": `parameter "limit" in "query" repeats the entry at /webhooks/hook/post/parameters/0`, + "/components/callbacks/CB/{$request.query.q}/parameters/1": `parameter "limit" in "query" repeats the entry at /components/callbacks/CB/{$request.query.q}/parameters/0`, + }, + }, + { + name: "a path item two paths mount, reported where it is written", + src: head + `paths: + /a: {$ref: "#/components/pathItems/Shared"} + /b: {$ref: "#/components/pathItems/Shared"} +` + paramRepeatComponents + ` pathItems: + Shared: + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Limit" + get: + responses: {"200": {description: ok}} +`, + want: map[jsontext.Pointer]string{ + "/components/pathItems/Shared/parameters/1": `parameter "limit" in "query" repeats the entry at /components/pathItems/Shared/parameters/0`, + }, + }, + { + name: "one name in two locations, and a $ref that resolves to nothing", + src: head + `paths: + /x/{limit}: + get: + parameters: + - {name: limit, in: path, required: true, schema: {type: string}} + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Missing" + - $ref: "#/components/parameters/Missing" + responses: {"200": {description: ok}} +` + paramRepeatComponents, + want: map[jsontext.Pointer]string{}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, repeatsAt(t, tc.src)) + }) + } +} + +// TestListRepeats_AWalkThatPanicsIsReported pins the barrier around the walk: +// the library's walk faults on shapes its parser accepts, and the panic is +// reported as an error rather than crashing the compile. +func TestListRepeats_AWalkThatPanicsIsReported(t *testing.T) { + t.Parallel() + panics := func(func(soa.WalkItem) bool) { panic("walk fault") } + at := func(p jsontext.Pointer) ir.Provenance { return ir.Provenance{Pointer: p} } + diags := listRepeats(at, Siblings{}, panics) + require.Len(t, diags, 1) + assert.Equal(t, ir.SeverityError, diags[0].Severity) + assert.Equal(t, diag.Validation, diags[0].Code) + assert.Contains(t, diags[0].Message, "repeat check panicked: walk fault") +} + +// serverRepeatsAt loads src and returns each openapi/duplicate-server-name it +// draws, keyed by the pointer it is reported at, with the message as the value. +func serverRepeatsAt(t *testing.T, src string) map[jsontext.Pointer]string { + t.Helper() + _, diags, err := Load(t.Context(), 0, compilers.Source{Path: "spec.yaml", Data: []byte(src)}, Options{}) + require.NoError(t, err) + got := map[jsontext.Pointer]string{} + for _, d := range diags { + if d.Code == diag.DuplicateServerName { + assert.Equal(t, ir.SeverityWarning, d.Severity) + got[d.Provenance.Pointer] = d.Message + } + } + return got +} + +// nameRepeat is the message a repeat of name reports, naming the first holder +// at first. +func nameRepeat(name, first string) string { + return fmt.Sprintf("server name %q repeats the name at %s; both servers are kept, "+ + "and a consumer keying servers by name cannot tell them apart", name, first) +} + +// TestServerRepeats_ReportsEveryListTheSourceWrites covers each way a servers +// list repeats a name and each place one is written, lowered or not: the +// document's, a path item's and an operation's, a component path item nothing +// mounts, a webhook, a component callback, and the fields beside a path item +// $ref. Each repeat is reported once, at its own name, naming the first entry +// holding it, at source indices. A 3.1 document is checked as well: its parser +// reads the name, and the lowering keeps it. +func TestServerRepeats_ReportsEveryListTheSourceWrites(t *testing.T) { + t.Parallel() + const head = "openapi: 3.2.0\ninfo: {title: T, version: \"1\"}\n" + const operation = `{responses: {"200": {description: ok}}}` + tests := []struct { + name string + src string + want map[jsontext.Pointer]string + }{ + { + name: "two entries of the document's list", + src: head + `servers: + - {url: https://a.example.com, name: prod} + - {url: https://b.example.com, name: prod} +paths: {} +`, + want: map[jsontext.Pointer]string{"/servers/1/name": nameRepeat("prod", "/servers/0/name")}, + }, + { + name: "three entries: one report per repeat, each naming the first", + src: head + `servers: + - {url: https://a.example.com, name: prod} + - {url: https://b.example.com, name: prod} + - {url: https://c.example.com, name: prod} +paths: {} +`, + want: map[jsontext.Pointer]string{ + "/servers/1/name": nameRepeat("prod", "/servers/0/name"), + "/servers/2/name": nameRepeat("prod", "/servers/0/name"), + }, + }, + { + name: "an alias repeating an entry, and a merge key carrying a name into two", + src: head + `x-base: &b {name: edge} +servers: + - &prod {url: https://a.example.com, name: prod} + - *prod + - {<<: *b, url: https://b.example.com} + - {<<: *b, url: https://c.example.com} +paths: {} +`, + want: map[jsontext.Pointer]string{ + "/servers/1/name": nameRepeat("prod", "/servers/0/name"), + "/servers/3/name": nameRepeat("edge", "/servers/2/name"), + }, + }, + { + name: "an unnamed entry ahead, which keeps the source indices", + src: head + `servers: + - {url: https://a.example.com, name: ""} + - {url: https://b.example.com, name: prod} + - {url: https://c.example.com} + - {url: https://d.example.com, name: prod} +paths: {} +`, + want: map[jsontext.Pointer]string{"/servers/3/name": nameRepeat("prod", "/servers/1/name")}, + }, + { + name: "a path item's list and its operation's", + src: head + `paths: + /x: + servers: + - {url: https://a.example.com, name: eu} + - {url: https://b.example.com, name: eu} + get: + servers: + - {url: https://c.example.com, name: us} + - {url: https://d.example.com, name: us} + responses: {"200": {description: ok}} +`, + want: map[jsontext.Pointer]string{ + "/paths/~1x/servers/1/name": nameRepeat("eu", "/paths/~1x/servers/0/name"), + "/paths/~1x/get/servers/1/name": nameRepeat("us", "/paths/~1x/get/servers/0/name"), + }, + }, + { + name: "a component path item nothing mounts, a webhook and a component callback", + src: head + `paths: {} +webhooks: + hook: + post: + servers: + - {url: https://a.example.com, name: w} + - {url: https://b.example.com, name: w} + responses: {"200": {description: ok}} +components: + pathItems: + Unused: + servers: + - {url: https://c.example.com, name: p} + - {url: https://d.example.com, name: p} + callbacks: + CB: + "{$request.query.q}": + servers: + - {url: https://e.example.com, name: c} + - {url: https://f.example.com, name: c} +`, + want: map[jsontext.Pointer]string{ + "/webhooks/hook/post/servers/1/name": nameRepeat("w", "/webhooks/hook/post/servers/0/name"), + "/components/pathItems/Unused/servers/1/name": nameRepeat("p", "/components/pathItems/Unused/servers/0/name"), + "/components/callbacks/CB/{$request.query.q}/servers/1/name": nameRepeat("c", "/components/callbacks/CB/{$request.query.q}/servers/0/name"), + }, + }, + { + name: "a path item two paths mount, reported where it is written", + src: head + `paths: + /a: {$ref: "#/components/pathItems/Shared"} + /b: {$ref: "#/components/pathItems/Shared"} +components: + pathItems: + Shared: + servers: + - {url: https://a.example.com, name: s} + - {url: https://b.example.com, name: s} + get: ` + operation + ` +`, + want: map[jsontext.Pointer]string{ + "/components/pathItems/Shared/servers/1/name": nameRepeat("s", "/components/pathItems/Shared/servers/0/name"), + }, + }, + { + name: "the fields beside a path item $ref, which an alias mounts again", + src: head + `paths: + /a: &use + $ref: "#/components/pathItems/P" + servers: + - {url: https://a.example.com, name: b} + - {url: https://b.example.com, name: b} + /b: *use +components: + pathItems: + P: + get: ` + operation + ` +`, + want: map[jsontext.Pointer]string{"/paths/~1a/servers/1/name": nameRepeat("b", "/paths/~1a/servers/0/name")}, + }, + { + name: "a 3.1 document", + src: `openapi: 3.1.0 +info: {title: T, version: "1"} +servers: + - {url: https://a.example.com, name: prod} + - {url: https://b.example.com, name: prod} +paths: {} +`, + want: map[jsontext.Pointer]string{"/servers/1/name": nameRepeat("prod", "/servers/0/name")}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, serverRepeatsAt(t, tc.src)) + }) + } +} + +// TestServerRepeats_ReportsOnlyANameOneListRepeatsExactly is the boundary: an +// entry with no name or an empty one holds none, and a name may recur across +// lists, since a path item's or an operation's servers replace the ones above +// them rather than join them. Names equal only once canonicalized are the +// openapi package's TestDuplicateServerName_CanonicalCollisionsAreNotReported, +// which proves each collision is real. +func TestServerRepeats_ReportsOnlyANameOneListRepeatsExactly(t *testing.T) { + t.Parallel() + const head = "openapi: 3.2.0\ninfo: {title: T, version: \"1\"}\n" + tests := []struct { + name string + src string + }{ + { + name: "distinct names", + src: head + `servers: + - {url: https://a.example.com, name: prod} + - {url: https://b.example.com, name: staging} +paths: {} +`, + }, + { + name: "entries with no name or an empty one", + src: head + `servers: + - {url: https://api.example.com/v1} + - {url: https://api.example.com/v-1} + - {url: https://a.example.com, name: ""} + - {url: https://b.example.com, name: ""} +paths: {} +`, + }, + { + name: "one name in the lists of the document, a path item and two operations", + src: head + `servers: + - {url: https://a.example.com, name: prod} +paths: + /x: + servers: + - {url: https://b.example.com, name: prod} + get: + servers: + - {url: https://c.example.com, name: prod} + responses: {"200": {description: ok}} + put: + servers: + - {url: https://d.example.com, name: prod} + responses: {"200": {description: ok}} +`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Empty(t, serverRepeatsAt(t, tc.src)) + }) + } +} diff --git a/compilers/openapi/internal/operation/paramledger.go b/compilers/openapi/internal/operation/paramledger.go index e36552e7..9ae1588e 100644 --- a/compilers/openapi/internal/operation/paramledger.go +++ b/compilers/openapi/internal/operation/paramledger.go @@ -22,7 +22,7 @@ type paramSlot struct { // never the iteration order of a map. // // A repeat in a list the source writes is load's to report, once, where it is -// written (parameterRepeats). One in another document's content, which that +// written (paramRepeats). One in another document's content, which that // walk does not reach, is reported here, at the entry it was lowered from. type paramLedger struct { mount jsontext.Pointer diff --git a/compilers/openapi/meta_test.go b/compilers/openapi/meta_test.go index 8355a8e5..90ca4632 100644 --- a/compilers/openapi/meta_test.go +++ b/compilers/openapi/meta_test.go @@ -230,3 +230,111 @@ func TestMeta_JSONSchemaDialectIsKept(t *testing.T) { doc, _ := parseFull(t, "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n") assert.NotContains(t, doc.Unmodeled, "openapi:jsonSchemaDialect") } + +// TestDuplicateServerName_BothServersLower is GitHub #631's repro compiled end +// to end: the repeat is reported, a warning at its own name naming the first, +// and nothing is dropped or merged, each server lowering with the name it +// declares. Load's tests cover every list the rule reads. +func TestDuplicateServerName_BothServersLower(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, `openapi: 3.2.0 +info: {title: T, version: "1"} +servers: + - url: https://prod.example.com + name: prod + - url: https://prod-mirror.example.com + name: prod +paths: {} +`) + require.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.DuplicateServerName, ir.SeverityWarning), + "one report: %+v", diags) + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DuplicateServerName, ir.SeverityWarning, "/servers/1/name"), + "/servers/0/name") + require.Len(t, doc.Servers, 2) + for i, url := range []string{"https://prod.example.com", "https://prod-mirror.example.com"} { + assert.Equal(t, "prod", doc.Servers[i].Name.Source) + assert.Equal(t, url, doc.Servers[i].URLTemplate) + } +} + +// TestDuplicateServerName_CanonicalCollisionsAreNotReported is the tolerated +// half: servers whose lowered names collide although the document never wrote +// one name twice. A hint colliding with a hint, a declared name equal to +// another entry's hint, and two names equal only once canonicalized are all +// silent, since an emitter uniquifies them. Each row first proves its collision +// is real. +func TestDuplicateServerName_CanonicalCollisionsAreNotReported(t *testing.T) { + t.Parallel() + tests := []struct { + name string + spec string + // collide picks the two lowered words a row claims are equal, and the + // row requires them to be, non-empty: it pins a boundary only if the + // collision really happens rather than by passing vacuously. + collide func(a, b ir.Naming) (string, string) + }{ + { + name: "two unnamed servers whose URL hints collide", + spec: `openapi: 3.1.0 +info: {title: T, version: "1"} +servers: + - url: https://api.example.com/v1 + - url: https://api.example.com/v-1 +paths: {} +`, + collide: func(a, b ir.Naming) (string, string) { return a.Hint, b.Hint }, + }, + { + name: "a declared name equal to another entry's hint", + spec: `openapi: 3.2.0 +info: {title: T, version: "1"} +servers: + - url: https://api.example.com/v1 + - url: https://other.example.com + name: https_api_example_com_v_1 +paths: {} +`, + collide: func(a, b ir.Naming) (string, string) { return a.Hint, b.Canonical }, + }, + { + name: "two names that differ only in case", + spec: `openapi: 3.2.0 +info: {title: T, version: "1"} +servers: + - url: https://a.example.com + name: prod + - url: https://b.example.com + name: Prod +paths: {} +`, + collide: func(a, b ir.Naming) (string, string) { return a.Canonical, b.Canonical }, + }, + { + name: "two names that differ only in their separator", + spec: `openapi: 3.2.0 +info: {title: T, version: "1"} +servers: + - url: https://a.example.com + name: prod-eu + - url: https://b.example.com + name: prod_eu +paths: {} +`, + collide: func(a, b ir.Naming) (string, string) { return a.Canonical, b.Canonical }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, tc.spec) + + assert.False(t, openapitest.HasDiag(diags, diag.DuplicateServerName), + "nothing is reported: %+v", diags) + require.Len(t, doc.Servers, 2) + a, b := tc.collide(doc.Servers[0].Name, doc.Servers[1].Name) + assert.NotEmpty(t, a, "the row compares lowered words, so they must be present") + assert.Equal(t, a, b, "and they must really collide, or the row asserts nothing") + }) + } +} diff --git a/compilers/openapi/overlay_test.go b/compilers/openapi/overlay_test.go index 0ef798d2..4919d36e 100644 --- a/compilers/openapi/overlay_test.go +++ b/compilers/openapi/overlay_test.go @@ -63,6 +63,121 @@ func compileWith(t *testing.T, opts openapi.Options) (*ir.Document, []ir.Diagnos return doc, diags } +// TestCompile_OverlayServerNameRepeatsAreCreditedToTheirWriter pins the +// duplicate-server-name rule against the tree the overlay produced rather than +// the bytes on disk. Each repeat is reported at itself, naming the first +// holder, and credited to the document that wrote that entry, as any finding at +// a pointer is: the overlay for an entry it appended, the base for the ones it +// wrote, though an overlay renaming an earlier entry joins them. +func TestCompile_OverlayServerNameRepeatsAreCreditedToTheirWriter(t *testing.T) { + t.Parallel() + type report struct { + at, first string + source int + } + tests := []struct { + name, base, patch string + want []report + names []string // each lowered server's declared name + }{ + { + name: "the overlay appends an entry repeating the base's name", + base: `openapi: 3.2.0 +info: {title: Pets, version: "1"} +servers: + - url: https://prod.example.com + name: prod +paths: {} +`, + patch: `overlay: 1.0.0 +info: {title: Patch, version: "1"} +actions: + - target: $.servers + update: + - url: https://prod-mirror.example.com + name: prod +`, + want: []report{{at: "/servers/1/name", first: "/servers/0/name", source: 1}}, + names: []string{"prod", "prod"}, + }, + { + name: "the overlay renames an entry ahead of a repeat the base writes", + base: `openapi: 3.2.0 +info: {title: Pets, version: "1"} +servers: + - url: https://dev.example.com + name: dev + - url: https://a.example.com + name: prod + - url: https://b.example.com + name: prod +paths: {} +`, + patch: `overlay: 1.0.0 +info: {title: Patch, version: "1"} +actions: + - target: $.servers[0] + update: {name: prod} +`, + want: []report{ + {at: "/servers/1/name", first: "/servers/0/name", source: 0}, + {at: "/servers/2/name", first: "/servers/0/name", source: 0}, + }, + names: []string{"prod", "prod", "prod"}, + }, + { + name: "the overlay appends two entries sharing a name", + base: `openapi: 3.2.0 +info: {title: Pets, version: "1"} +servers: + - url: https://dev.example.com + name: dev +paths: {} +`, + patch: `overlay: 1.0.0 +info: {title: Patch, version: "1"} +actions: + - target: $.servers + update: + - {url: https://a.example.com, name: prod} + - {url: https://b.example.com, name: prod} +`, + want: []report{{at: "/servers/2/name", first: "/servers/1/name", source: 1}}, + names: []string{"dev", "prod", "prod"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + doc, diags, err := openapi.New().Compile(t.Context(), + []compilers.Source{{Path: "spec.yaml", Data: []byte(tc.base)}}, + compilers.Options{FormatOptions: openapi.Options{ + Overlay: &openapi.Overlay{Path: "patch.yaml", Data: []byte(tc.patch)}, + }}) + require.NoError(t, err) + require.NotNil(t, doc, "compile refused: %+v", diags) + + var got []report + for _, d := range diags { + if d.Code != diag.DuplicateServerName { + continue + } + assert.Equal(t, ir.SeverityWarning, d.Severity) + _, first, _ := strings.Cut(d.Message, " repeats the name at ") + first, _, _ = strings.Cut(first, ";") + got = append(got, report{at: string(d.Provenance.Pointer), first: first, source: d.Provenance.Source}) + } + assert.Equal(t, tc.want, got, "each repeat, in order, credited to its writer: %+v", diags) + + names := make([]string, 0, len(doc.Servers)) + for _, s := range doc.Servers { + names = append(names, s.Name.Source) + } + assert.Equal(t, tc.names, names, "every server lowered, each with the name the document wrote") + }) + } +} + // propertyProvenance finds the named property of the named component schema and // returns the provenance the compiler stamped on it. func propertyProvenance(t *testing.T, doc *ir.Document, schema, property string) ir.Provenance { diff --git a/docs/ir-design.md b/docs/ir-design.md index 90d0f054..c509471a 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -2501,7 +2501,7 @@ How each format's distinctive concepts land in the IR (full details live with ea | Format | Lowering highlights | |---|---| -| **OpenAPI 3.x** | components/schemas → registry (IDs from pointers); inline schemas hoisted with hints; `allOf` → Base/Mixins per §4.3; `oneOf`/`anyOf` → Union (Exclusive bit), null-variant → Nullable ref (a keyword beside the collapsed union that no reference holds — a `discriminator`, a `format`, a collection bound — kept verbatim Unmodeled, `degraded_lowering`, on the node or carrier owning the position, as at a `$ref` site), a null-only branch set (every branch a bare `type: null`) → the same nullable `any` a bare `{type: null}` schema at that position lowers to, with the oneOf/anyOf itself kept verbatim Unmodeled (`degraded_lowering`) since no node built for it carries a branch set, co-declared with structural keywords → the composition distributed across the variants per §4.3, or — for the five shapes that cannot be distributed — structural body + verbatim union per §4.8 (branches that declare no shape at all are `validation_only` per §4.7), with a declared `discriminator` beside it staying on the model only when every type it routes to is a subtype of it, decided once every node is interned, and otherwise kept verbatim in `Unmodeled["openapi:discriminator"]` (§4.8); the same co-declared `oneOf`/`anyOf` beside a `$ref` at the same level, rather than a structural body, has nothing at that position to distribute the union across either, so it lowers to the alias over the `$ref` target with the union kept verbatim beside it (`degraded_lowering`), through the same keeper as the structural-body case; an `allOf` beside a `$ref` at the same level joins the alias's unhomed-keyword census the same way (`degraded_lowering`), since a `$ref` site elects no composition family for it to lose or win; competing keywords at one position — `const`/`enum`/`allOf`, elected in that order; `oneOf` beside `anyOf`, where oneOf wins; and a parameter's or header's `schema` beside its `content`, where content wins, since a media-type entry names both a schema and the media type serializing it and the IR models both — lower as the elected keyword with every passed-over one verbatim Unmodeled (`degraded_lowering`) at its own pointer per §4.8, and a `{X, null}` oneOf beside an anyOf stays a Union rather than collapsing to a nullable ref; `discriminator` → Discriminator (3.2 `defaultMapping` → Discriminator.Default), beside a node that does not route by it (neither a Model nor a Union) → verbatim Unmodeled (`degraded_lowering`) per §4.8 (a multi-typed schema excepted, which runs no census yet, #852), and a subtype the mapping names under several keys — an alias tag — takes the smallest in byte order as its DiscriminatorValue, since the field holds one and a mapping's key order is not a property of the document, with every key kept in the base's Mapping and the election reported (`degraded-construct`, info); `nullable`/type-arrays → Nullable; readOnly/writeOnly → Visibility and schema-level `default` → the referencing Property/Parameter's Default: both bind a *use* of the type rather than the type, so a declaration-site one is pushed down to referencing properties with use-site precedence and the declaration keeps its own copy verbatim (`no_ir_home` Unmodeled + diagnostic) — which is what a component nothing references would otherwise lose silently; `additionalProperties: false` → Additional=closed, `unevaluatedProperties: false` → closed_after_composition, `minProperties`/`maxProperties` → Model.Constraints (the property set's cardinality, as against Additional's openness); parameters → Params + HTTPBinding locations w/ style/explode, `allowEmptyValue` → Parameter.Unmodeled (`no_ir_home`: HTTPParamBinding holds its neighbours but not this one), a header parameter named Accept/Content-Type/Authorization lowered as declared + `reserved-header-name` warning (OpenAPI says such a definition SHALL be ignored; dropping declared content is an emitter's call, not a compiler's), 3.2 `in: querystring` → querystring location; a declared `style` there is refused by the parser, while `explode` or `allowReserved` (which it does not check; GitHub #408) lowers as declared + `invalid-location-keyword` warning at the keyword's own pointer; requestBody/responses all content types → Payload.Contents, `requestBody.required` → Payload.Required, always set since OpenAPI's own default makes an undeclared `required` mean false rather than unstated, and `requestBody.description` → Payload.Docs; 3.2 Response `summary` → Docs.Summary; 3.2 example `dataValue` → Example.Value and `serializedValue` → Example.Unmodeled (`no_ir_home`); 3.2 `itemSchema` → Content.Item and `itemEncoding` → Content.ItemEncoding — except beside a positional `prefixEncoding`, where both go to Content.Unmodeled (`no_ir_home`) because a single every-item encoding cannot state ordinals; an Encoding Object's `allowReserved` → Content.Unmodeled (`no_ir_home`) under `openapi:encoding//allowReserved` or `openapi:itemEncoding/allowReserved`, ir.PartEncoding holding `style` and `explode` beside it but no field for this one, and read before the entry's emptiness is judged so an entry declaring nothing else is not dropped with the empty PartEncoding it lowers to; per-status responses/default → Conditions + ranges, with the responses-map key as declared and then neutralized → Response.Name.Hint and ErrorCase.Name.Hint alike (`404`, `5_xx`, `default`), which records the spelling a range cannot state though only `default` survives neutralization unchanged; two keys resolving to one range — `4XX` beside `4xx` — are both kept and reported `openapi/duplicate-status-key`, since they reach the IR with one name and one condition; an error response lowers exactly as a success one — its `headers` → ErrorCase.Headers and every media type of its `content` → ErrorCase.Payload.Contents, neither degraded and neither kept under Unmodeled, and the payload's naming hint derived from the declaration pointer on both sides so that one `components/responses` entry mounted at a success and an error status interns one type whichever side reaches it first; response/encoding header `style` and `explode` → Property.Unmodeled (`no_ir_home`, ir.Property has neither field), and a `Content-Type` entry in either headers map lowered as declared + the same `reserved-header-name` warning the parameter position gets; webhooks → HTTPBinding.IsWebhook, the map key → HTTPBinding.WebhookName with URITemplate left empty, since the key names an event rather than a path; callbacks → Callbacks; links → Response.Unmodeled and ErrorCase.Unmodeled alike (`no_ir_home`, promotable later): the two are lowerings of one Response Object, so a construct kept on only the success one makes a declaration survive or vanish on nothing but its status code; path-item `servers`, under `paths`, `webhooks` and a callback expression alike → Operation.Unmodeled (`no_ir_home`: §10 scopes servers by index list at service and channel, and an operation has no such list yet), with an operation's own `servers` — which OpenAPI says override the path item's — kept beside them under `openapi:operationServers`, the one key here not named for the keyword it holds, since two declarations at two pointers cannot share one map key without the survivor depending on lowering order; a path item's own `summary`/`description`, at the same three mounts → Operation.Unmodeled under `openapi:pathItemSummary`/`openapi:pathItemDescription` (`no_ir_home`) rather than merged into Docs: ir.Docs holds the operation's own pair and a path item's documents the path, so merging would need a precedence rule and would attach documentation the operation's author never wrote — an inference, which §6 places in policy rather than in a lowering; every operation a path item declares — the fixed method fields, 3.2 `query`, and 3.2 `additionalOperations` keyed by method — → an Operation apiece, mounted at its own pointer, with the `additionalOperations` key used verbatim as HTTPBinding.Method since OpenAPI reads a method name case-sensitively, and a key naming no method at all lowered as declared + `invalid-method-key` warning (the binding is unusable, but dropping the entry would lose every operation it declares); the fields a Path Item Object writes beside its `$ref`, which OpenAPI allows and leaves undefined only where both sides declare one field, → a layer of their own at their own pointers, a mount reading the fields beside each `$ref` of its chain nearest the use site first and then the item the chain ends at, the nearer layer keeping every method or `additionalOperations` key, `summary`, `description`, `servers`, `x-*` name, undeclared key and item `(name, in)` parameter both declare and the other reported (`path-item-ref-collision`, warning) rather than merged, so no construct is attributed to a declaration that did not write it, and the mount listing its operations in the order one item holding them all would; a reference whose pointer passes through those fields — which the resolver reads through the `$ref` into the item it names, while the mount declares the field written there at that very pointer — is left unresolved and reported (`unresolved-ref`) rather than lowered as either, a `#/$defs/...` pointer written in them included, since its definition is read the same way, unless what the resolver found there is the field written there, as it can be for a reference other than a schema's `$ref` or a mapping value when external references are allowed (GitHub #824); an item another document holds, mounted where the use site writes fields too, is declared under the use site's `$ref` (`…/$ref/…`), a position the source cannot name, and the fields beside a `$ref` the load phase's walk does not reach, in another document or under an `x-*` key, are reported (`degraded-construct`) rather than lowered; securitySchemes/security → Auth OR-of-ANDs, 3.2 device flow + `oauth2MetadataUrl` → Flows/OAuth2MetadataURL, and an oauth2 scheme whose `flows` declares no flow and that has no `oauth2MetadataUrl` lowered as declared + `oauth2-no-flow` warning, a `flows` the parser's REQUIRED-field rule finds missing being that rule's to report instead; a flow field the flow's kind does not define (`authorizationUrl` beside a token-only flow, `tokenUrl` on `implicit`, `deviceAuthorizationUrl` off the device flow) kept verbatim in that flow's Unmodeled (`degraded_lowering`) + info, never lowered into a field the flow lacks; servers+variables (3.2 named) → Servers; tags (3.2 parent/kind) → groups + TagDefs; info contact/license → Document; schema `example(s)` → Examples; `xml` object (incl. 3.2 nodeType) → XMLHints at type and property level; `not`/`if-then-else`/`dependentSchemas`/`dependentRequired`/`contains`/`propertyNames`/`unevaluated*` → verbatim Unmodeled per §4.7; a numeric `type`+`format` → the primitive the format names — an integer width under `integer` and `number` alike, since OpenAPI applies a format to both (3.1.1 §4.4.1), and `float`/`double`/`decimal`/`decimal128` under `number` alone, since on an integer they admit only integral values, which no primitive narrower than `integer` holds — and a pairing no primitive names → a `Scalar` over the type's primitive that keeps the format, as `Encoding.Name` or, where a `contentEncoding` beside it takes that field, verbatim Unmodeled; `contentEncoding`/`contentMediaType`/`contentSchema` → `Encoding` on the scalar the position lowers to — the last as `Encoding.Schema`, a TypeRef to the decoded shape hoisted at its own pointer — and all three → Unmodeled (`no_ir_home`) at a position with no `Encoding` field, per §4.7; `$id`/`$schema`/`$vocabulary` → Unmodeled (`out_of_scope`, `$id` not honoured for resolution), and the document's `jsonSchemaDialect`, the default `$schema` of its schemas, → Document.Unmodeled the same way, every schema read in the OpenAPI dialect whatever it names; `$dynamicRef` → the anchored type by compiler expansion, else verbatim Unmodeled with the reason it was irreducible; an inline `allOf` branch declaring more than the merge consumes → verbatim Unmodeled (`degraded_lowering`) per §4.8; a property redeclared across branches with a type, constraint keyword, default, description, examples, deprecation or `xml` the merge drops → the losing declaration's node verbatim Unmodeled (`degraded_lowering`) under `openapi:conflicting-redeclaration` per §4.8, keyed by the losing declaration's pointer, with the `openapi/conflicting-redeclaration` diagnostic only where the two are unsatisfiable, an info `degraded-construct` naming a detail held differently, and nullability intersecting rather than dropping where the targets agree; a boolean `false` `allOf` branch → the composed Model closed, branch verbatim Unmodeled (`degraded_lowering`) per §4.8, a `true` branch a silent no-op; a shape applicator (`properties`/`patternProperties`/`additionalProperties`/`required`/`items`/`prefixItems`, and `format` where no type is declared) the lowered node has no field for → verbatim Unmodeled (`degraded_lowering`) per §4.8; a parameter schema's `xml` and its `readOnly`/`writeOnly` → Parameter.Unmodeled (`no_ir_home`: Parameter has no field for either); `patternProperties` → AdditionalProps.Patterns; `prefixItems` → Tuple.Elems and the `items` beside it → Tuple.Rest (`items: false` → none, an absent `items` → the top type, as 2020-12 reads it `true`), with `minItems`/`maxItems`/`uniqueItems` → Tuple.Constraints; `x-*` → namespaced Unmodeled (legal on every object — hence Unmodeled on every node), read at every object that admits one: an object lowering to a node with a map of its own keeps them unscoped there, and one lowering to no node of its own is keyed by the path from its carrier down to it — on the document, `openapi:info/x-*`, `openapi:info/contact/x-*`, `openapi:info/license/x-*`, `openapi:externalDocs/x-*`, `openapi:components/x-*`, `openapi:tags//x-*`, `openapi:tags//externalDocs/x-*`; on the service, `openapi:paths/x-*`; on each operation the path item's `openapi:pathItem/x-*` plus `openapi:responses/x-*` and `openapi:externalDocs/x-*`; on the HTTP binding, `openapi:callbacks//x-*`; on the content, `openapi:encoding//x-*` and `openapi:itemEncoding/x-*`, `` and `` alike escaped RFC 6901 style so a document-chosen name stays one segment (§12); on the schema's type, `openapi:xml/x-*`, `openapi:discriminator/x-*`, `openapi:externalDocs/x-*`; on the scheme, `openapi:flows/x-*` — since several such objects reach one map and an unscoped key would leave the survivor to lowering order (§12); a Link Object's own ride inside the verbatim `links` entry rather than taking a key beside it; `$ref`-adjacent sibling keywords (3.1) and ref-target annotations merge onto the referencing Property/Parameter with **use-site precedence**, applied uniformly (oagen's ad-hoc per-site patching is the counterexample), and at a position carrying no Property/Parameter — an `allOf`/`oneOf`/`anyOf` branch, `items`, a component — bind an alias hoisted at that position instead, per §4.3 — constraints excepted, since bounds conjoin rather than override: each position keeps the ones it declared and none is copied to a use site (§12.2); a oneOf/anyOf whose variants are all string consts normalizes to a closed `Enum` in a `pass/` normalization — not in the compiler — so per-variant `Docs` survive until the collapse is chosen; mutually-exclusive parameter groups (`x-mutually-exclusive-parameter-groups`) stay as namespaced Unmodeled entries, and their documented *promotion* (no dedicated node needed) is a pass that synthesizes one logical `Parameter` typed by a `Union` of variant models, bound via `HTTPParamBinding.ParamPath` per field; pagination only via injectable policy, marked Inferred; a component entry of a section with no registry — responses, parameters, examples, requestBodies, headers, links, callbacks, pathItems — that no lowering followed a reference to is kept verbatim on the document under `openapi:components/
/` (`no_ir_home`, no diagnostic) rather than dropped (§12), and 3.2's components/mediaTypes is kept whole, since no reference into it is followed yet (GitHub #856); `$self` → that source's `SourceInfo.SelfURI` as written, a relative one unresolved, and at every minor, with a 3.0 or 3.1 document writing one warned `later-version-construct` as for every construct a later minor added (§12) | +| **OpenAPI 3.x** | components/schemas → registry (IDs from pointers); inline schemas hoisted with hints; `allOf` → Base/Mixins per §4.3; `oneOf`/`anyOf` → Union (Exclusive bit), null-variant → Nullable ref (a keyword beside the collapsed union that no reference holds — a `discriminator`, a `format`, a collection bound — kept verbatim Unmodeled, `degraded_lowering`, on the node or carrier owning the position, as at a `$ref` site), a null-only branch set (every branch a bare `type: null`) → the same nullable `any` a bare `{type: null}` schema at that position lowers to, with the oneOf/anyOf itself kept verbatim Unmodeled (`degraded_lowering`) since no node built for it carries a branch set, co-declared with structural keywords → the composition distributed across the variants per §4.3, or — for the five shapes that cannot be distributed — structural body + verbatim union per §4.8 (branches that declare no shape at all are `validation_only` per §4.7), with a declared `discriminator` beside it staying on the model only when every type it routes to is a subtype of it, decided once every node is interned, and otherwise kept verbatim in `Unmodeled["openapi:discriminator"]` (§4.8); the same co-declared `oneOf`/`anyOf` beside a `$ref` at the same level, rather than a structural body, has nothing at that position to distribute the union across either, so it lowers to the alias over the `$ref` target with the union kept verbatim beside it (`degraded_lowering`), through the same keeper as the structural-body case; an `allOf` beside a `$ref` at the same level joins the alias's unhomed-keyword census the same way (`degraded_lowering`), since a `$ref` site elects no composition family for it to lose or win; competing keywords at one position — `const`/`enum`/`allOf`, elected in that order; `oneOf` beside `anyOf`, where oneOf wins; and a parameter's or header's `schema` beside its `content`, where content wins, since a media-type entry names both a schema and the media type serializing it and the IR models both — lower as the elected keyword with every passed-over one verbatim Unmodeled (`degraded_lowering`) at its own pointer per §4.8, and a `{X, null}` oneOf beside an anyOf stays a Union rather than collapsing to a nullable ref; `discriminator` → Discriminator (3.2 `defaultMapping` → Discriminator.Default), beside a node that does not route by it (neither a Model nor a Union) → verbatim Unmodeled (`degraded_lowering`) per §4.8 (a multi-typed schema excepted, which runs no census yet, #852), and a subtype the mapping names under several keys — an alias tag — takes the smallest in byte order as its DiscriminatorValue, since the field holds one and a mapping's key order is not a property of the document, with every key kept in the base's Mapping and the election reported (`degraded-construct`, info); `nullable`/type-arrays → Nullable; readOnly/writeOnly → Visibility and schema-level `default` → the referencing Property/Parameter's Default: both bind a *use* of the type rather than the type, so a declaration-site one is pushed down to referencing properties with use-site precedence and the declaration keeps its own copy verbatim (`no_ir_home` Unmodeled + diagnostic) — which is what a component nothing references would otherwise lose silently; `additionalProperties: false` → Additional=closed, `unevaluatedProperties: false` → closed_after_composition, `minProperties`/`maxProperties` → Model.Constraints (the property set's cardinality, as against Additional's openness); parameters → Params + HTTPBinding locations w/ style/explode, `allowEmptyValue` → Parameter.Unmodeled (`no_ir_home`: HTTPParamBinding holds its neighbours but not this one), a header parameter named Accept/Content-Type/Authorization lowered as declared + `reserved-header-name` warning (OpenAPI says such a definition SHALL be ignored; dropping declared content is an emitter's call, not a compiler's), 3.2 `in: querystring` → querystring location; a declared `style` there is refused by the parser, while `explode` or `allowReserved` (which it does not check; GitHub #408) lowers as declared + `invalid-location-keyword` warning at the keyword's own pointer; requestBody/responses all content types → Payload.Contents, `requestBody.required` → Payload.Required, always set since OpenAPI's own default makes an undeclared `required` mean false rather than unstated, and `requestBody.description` → Payload.Docs; 3.2 Response `summary` → Docs.Summary; 3.2 example `dataValue` → Example.Value and `serializedValue` → Example.Unmodeled (`no_ir_home`); 3.2 `itemSchema` → Content.Item and `itemEncoding` → Content.ItemEncoding — except beside a positional `prefixEncoding`, where both go to Content.Unmodeled (`no_ir_home`) because a single every-item encoding cannot state ordinals; an Encoding Object's `allowReserved` → Content.Unmodeled (`no_ir_home`) under `openapi:encoding//allowReserved` or `openapi:itemEncoding/allowReserved`, ir.PartEncoding holding `style` and `explode` beside it but no field for this one, and read before the entry's emptiness is judged so an entry declaring nothing else is not dropped with the empty PartEncoding it lowers to; per-status responses/default → Conditions + ranges, with the responses-map key as declared and then neutralized → Response.Name.Hint and ErrorCase.Name.Hint alike (`404`, `5_xx`, `default`), which records the spelling a range cannot state though only `default` survives neutralization unchanged; two keys resolving to one range — `4XX` beside `4xx` — are both kept and reported `openapi/duplicate-status-key`, since they reach the IR with one name and one condition; an error response lowers exactly as a success one — its `headers` → ErrorCase.Headers and every media type of its `content` → ErrorCase.Payload.Contents, neither degraded and neither kept under Unmodeled, and the payload's naming hint derived from the declaration pointer on both sides so that one `components/responses` entry mounted at a success and an error status interns one type whichever side reaches it first; response/encoding header `style` and `explode` → Property.Unmodeled (`no_ir_home`, ir.Property has neither field), and a `Content-Type` entry in either headers map lowered as declared + the same `reserved-header-name` warning the parameter position gets; webhooks → HTTPBinding.IsWebhook, the map key → HTTPBinding.WebhookName with URITemplate left empty, since the key names an event rather than a path; callbacks → Callbacks; links → Response.Unmodeled and ErrorCase.Unmodeled alike (`no_ir_home`, promotable later): the two are lowerings of one Response Object, so a construct kept on only the success one makes a declaration survive or vanish on nothing but its status code; path-item `servers`, under `paths`, `webhooks` and a callback expression alike → Operation.Unmodeled (`no_ir_home`: §10 scopes servers by index list at service and channel, and an operation has no such list yet), with an operation's own `servers` — which OpenAPI says override the path item's — kept beside them under `openapi:operationServers`, the one key here not named for the keyword it holds, since two declarations at two pointers cannot share one map key without the survivor depending on lowering order; a path item's own `summary`/`description`, at the same three mounts → Operation.Unmodeled under `openapi:pathItemSummary`/`openapi:pathItemDescription` (`no_ir_home`) rather than merged into Docs: ir.Docs holds the operation's own pair and a path item's documents the path, so merging would need a precedence rule and would attach documentation the operation's author never wrote — an inference, which §6 places in policy rather than in a lowering; every operation a path item declares — the fixed method fields, 3.2 `query`, and 3.2 `additionalOperations` keyed by method — → an Operation apiece, mounted at its own pointer, with the `additionalOperations` key used verbatim as HTTPBinding.Method since OpenAPI reads a method name case-sensitively, and a key naming no method at all lowered as declared + `invalid-method-key` warning (the binding is unusable, but dropping the entry would lose every operation it declares); the fields a Path Item Object writes beside its `$ref`, which OpenAPI allows and leaves undefined only where both sides declare one field, → a layer of their own at their own pointers, a mount reading the fields beside each `$ref` of its chain nearest the use site first and then the item the chain ends at, the nearer layer keeping every method or `additionalOperations` key, `summary`, `description`, `servers`, `x-*` name, undeclared key and item `(name, in)` parameter both declare and the other reported (`path-item-ref-collision`, warning) rather than merged, so no construct is attributed to a declaration that did not write it, and the mount listing its operations in the order one item holding them all would; a reference whose pointer passes through those fields — which the resolver reads through the `$ref` into the item it names, while the mount declares the field written there at that very pointer — is left unresolved and reported (`unresolved-ref`) rather than lowered as either, a `#/$defs/...` pointer written in them included, since its definition is read the same way, unless what the resolver found there is the field written there, as it can be for a reference other than a schema's `$ref` or a mapping value when external references are allowed (GitHub #824); an item another document holds, mounted where the use site writes fields too, is declared under the use site's `$ref` (`…/$ref/…`), a position the source cannot name, and the fields beside a `$ref` the load phase's walk does not reach, in another document or under an `x-*` key, are reported (`degraded-construct`) rather than lowered; securitySchemes/security → Auth OR-of-ANDs, 3.2 device flow + `oauth2MetadataUrl` → Flows/OAuth2MetadataURL, and an oauth2 scheme whose `flows` declares no flow and that has no `oauth2MetadataUrl` lowered as declared + `oauth2-no-flow` warning, a `flows` the parser's REQUIRED-field rule finds missing being that rule's to report instead; a flow field the flow's kind does not define (`authorizationUrl` beside a token-only flow, `tokenUrl` on `implicit`, `deviceAuthorizationUrl` off the device flow) kept verbatim in that flow's Unmodeled (`degraded_lowering`) + info, never lowered into a field the flow lacks; servers+variables (3.2 named) → Servers, a name one servers list repeats — the document's, a path item's or an operation's, compared as written on every version, since the parser reads `name` on each — kept on both entries + `duplicate-server-name` warning; tags (3.2 parent/kind) → groups + TagDefs; info contact/license → Document; schema `example(s)` → Examples; `xml` object (incl. 3.2 nodeType) → XMLHints at type and property level; `not`/`if-then-else`/`dependentSchemas`/`dependentRequired`/`contains`/`propertyNames`/`unevaluated*` → verbatim Unmodeled per §4.7; a numeric `type`+`format` → the primitive the format names — an integer width under `integer` and `number` alike, since OpenAPI applies a format to both (3.1.1 §4.4.1), and `float`/`double`/`decimal`/`decimal128` under `number` alone, since on an integer they admit only integral values, which no primitive narrower than `integer` holds — and a pairing no primitive names → a `Scalar` over the type's primitive that keeps the format, as `Encoding.Name` or, where a `contentEncoding` beside it takes that field, verbatim Unmodeled; `contentEncoding`/`contentMediaType`/`contentSchema` → `Encoding` on the scalar the position lowers to — the last as `Encoding.Schema`, a TypeRef to the decoded shape hoisted at its own pointer — and all three → Unmodeled (`no_ir_home`) at a position with no `Encoding` field, per §4.7; `$id`/`$schema`/`$vocabulary` → Unmodeled (`out_of_scope`, `$id` not honoured for resolution), and the document's `jsonSchemaDialect`, the default `$schema` of its schemas, → Document.Unmodeled the same way, every schema read in the OpenAPI dialect whatever it names; `$dynamicRef` → the anchored type by compiler expansion, else verbatim Unmodeled with the reason it was irreducible; an inline `allOf` branch declaring more than the merge consumes → verbatim Unmodeled (`degraded_lowering`) per §4.8; a property redeclared across branches with a type, constraint keyword, default, description, examples, deprecation or `xml` the merge drops → the losing declaration's node verbatim Unmodeled (`degraded_lowering`) under `openapi:conflicting-redeclaration` per §4.8, keyed by the losing declaration's pointer, with the `openapi/conflicting-redeclaration` diagnostic only where the two are unsatisfiable, an info `degraded-construct` naming a detail held differently, and nullability intersecting rather than dropping where the targets agree; a boolean `false` `allOf` branch → the composed Model closed, branch verbatim Unmodeled (`degraded_lowering`) per §4.8, a `true` branch a silent no-op; a shape applicator (`properties`/`patternProperties`/`additionalProperties`/`required`/`items`/`prefixItems`, and `format` where no type is declared) the lowered node has no field for → verbatim Unmodeled (`degraded_lowering`) per §4.8; a parameter schema's `xml` and its `readOnly`/`writeOnly` → Parameter.Unmodeled (`no_ir_home`: Parameter has no field for either); `patternProperties` → AdditionalProps.Patterns; `prefixItems` → Tuple.Elems and the `items` beside it → Tuple.Rest (`items: false` → none, an absent `items` → the top type, as 2020-12 reads it `true`), with `minItems`/`maxItems`/`uniqueItems` → Tuple.Constraints; `x-*` → namespaced Unmodeled (legal on every object — hence Unmodeled on every node), read at every object that admits one: an object lowering to a node with a map of its own keeps them unscoped there, and one lowering to no node of its own is keyed by the path from its carrier down to it — on the document, `openapi:info/x-*`, `openapi:info/contact/x-*`, `openapi:info/license/x-*`, `openapi:externalDocs/x-*`, `openapi:components/x-*`, `openapi:tags//x-*`, `openapi:tags//externalDocs/x-*`; on the service, `openapi:paths/x-*`; on each operation the path item's `openapi:pathItem/x-*` plus `openapi:responses/x-*` and `openapi:externalDocs/x-*`; on the HTTP binding, `openapi:callbacks//x-*`; on the content, `openapi:encoding//x-*` and `openapi:itemEncoding/x-*`, `` and `` alike escaped RFC 6901 style so a document-chosen name stays one segment (§12); on the schema's type, `openapi:xml/x-*`, `openapi:discriminator/x-*`, `openapi:externalDocs/x-*`; on the scheme, `openapi:flows/x-*` — since several such objects reach one map and an unscoped key would leave the survivor to lowering order (§12); a Link Object's own ride inside the verbatim `links` entry rather than taking a key beside it; `$ref`-adjacent sibling keywords (3.1) and ref-target annotations merge onto the referencing Property/Parameter with **use-site precedence**, applied uniformly (oagen's ad-hoc per-site patching is the counterexample), and at a position carrying no Property/Parameter — an `allOf`/`oneOf`/`anyOf` branch, `items`, a component — bind an alias hoisted at that position instead, per §4.3 — constraints excepted, since bounds conjoin rather than override: each position keeps the ones it declared and none is copied to a use site (§12.2); a oneOf/anyOf whose variants are all string consts normalizes to a closed `Enum` in a `pass/` normalization — not in the compiler — so per-variant `Docs` survive until the collapse is chosen; mutually-exclusive parameter groups (`x-mutually-exclusive-parameter-groups`) stay as namespaced Unmodeled entries, and their documented *promotion* (no dedicated node needed) is a pass that synthesizes one logical `Parameter` typed by a `Union` of variant models, bound via `HTTPParamBinding.ParamPath` per field; pagination only via injectable policy, marked Inferred; a component entry of a section with no registry — responses, parameters, examples, requestBodies, headers, links, callbacks, pathItems — that no lowering followed a reference to is kept verbatim on the document under `openapi:components/
/` (`no_ir_home`, no diagnostic) rather than dropped (§12), and 3.2's components/mediaTypes is kept whole, since no reference into it is followed yet (GitHub #856); `$self` → that source's `SourceInfo.SelfURI` as written, a relative one unresolved, and at every minor, with a 3.0 or 3.1 document writing one warned `later-version-construct` as for every construct a later minor added (§12) | | **Swagger 2.0** | lifted to OpenAPI 3.x shape first (body/formData → Payload; host/basePath/schemes → Servers; consumes/produces → content types), then the OpenAPI lowering runs | | **TypeSpec** | consumed post-check (monomorphized, `isFinished`); template instances → TypeCommon.Instantiation incl. value args → TemplateArg; models → Model w/ Base + spread provenance → Mixins; scalars → Scalar chains, constructors in values → Value.Ctor; `@encode`/`@format` → Encoding triple; `@encodedName` → WireNameByFormat at property AND type level; unions w/ named variants → Union, `@discriminated` → Discriminator.PropertyName/Envelope/EnvelopeValueName; `| null` → Nullable; visibility classes (incl. custom, `@invisible` → Visibility.None) → Visibility, op overrides → ParameterVisibility/ReturnTypeVisibility; `@patch` implicitOptionality → HTTPBinding.PatchImplicitOptionality; interfaces → OperationGroups (versionable); `@overload` → OverloadOf; `@sharedRoute` → SharedRoute; `@service` → Service; versioning decorators incl. `@typeChangedFrom`/`@madeOptional`/`@madeRequired` and add/remove cycles → Availability timeline (on members/variants/params too); pagination decorators incl. prev/first/last links and header continuation tokens → Pagination PropPaths (In:"header"); Azure.Core `@pollingOperation`/`@finalOperation` → LongRunning; multipart w/ parts → Content.Encoding/PartEncoding, `Http.File` → FileInfo (content-type set, contents chain, filename location); streams/SSE → StreamDetail + Variant.Event (contentType, terminal); `@error` → UsageFlags.Error; `@example`/`@opExample` → Examples (Input/Output pairs); `@pattern` message → Constraints.PatternMessage; `@mediaTypeHint` → TypeCommon.MediaTypeHint; `never` members deleted + diagnostic per §4.8; tuples → Tuple with `Constraints.MinItems = len(Elems)` and no Rest, since a TypeSpec tuple requires every position and admits nothing past them; TCGC client-shaping decorators (`@clientName`, `@access`, `@usage`, `@scope`, `@override`, …) → namespaced Unmodeled (`out_of_scope`) consumed by emitter policy, never IR semantics; values/consts incl. enum-member refs → Values channel | | **Smithy 2.0** | structures → Model, mixins → Mixins (non-structure mixins flattened — spec-sanctioned); `document` → Any; unions → WireTagged Union, member `@jsonName` → Variant.WireName; enum/intEnum → Enum (open by default); `@sparse` → element Nullable; traits: constraints → Constraints, `@paginated` → Pagination (declared), `@retryable` → ErrorCase.Retryable + Throttling, `@error` fault → ErrorCase.Fault, `@readonly` → Idempotency safe, `@idempotent`/`@idempotencyToken` → Idempotency, `@sensitive` → Sensitive/Secret, `@tags` → Tags, `@clientOptional`/`@input` → Property.ClientOptional (+InputOnly), `@addedDefault` → DefaultAdded, root-shape `@default` pushed down to properties w/ provenance; `@streaming` blob → StreamDetail (+`@requiresLength` → RequiresLength); event streams → StreamDetail.Events union + Property.EventHeader/EventPayload + Initial messages; service-level errors → Service.CommonErrors; protocol traits → Service.Protocols; service `rename`/`version` → Service.Renames/Version; resources → OperationGroup + ResourceInfo (identifiers, properties, lifecycle incl. put/@noReplace, instance vs collection ops); http traits → HTTPBinding incl. `@endpoint`/`@hostLabel` → HostPrefix/host location (additive binding), `@httpPrefixHeaders`/`@httpQueryParams` → Prefix bindings, `@httpResponseCode` → Response.StatusCodeProp, `@requestCompression` → Compression, `@httpChecksumRequired` → ChecksumRequired; `@auth` order → priority-ordered Auth, `@optionalAuth` → empty option; `@jsonName` → WireName; `@mediaType` → Encoding.MediaType; xml traits → XMLHints at type and property level; `@examples` → Examples (Input/Output/Error); waiters + rules-engine traits → verbatim Unmodeled (`out_of_scope`, §15); `smithy.api#Unit` → nil payload / shared empty Model for tag-only variants; other traits → namespaced Unmodeled | diff --git a/testdata/openapi/duplicate_server_names.yaml b/testdata/openapi/duplicate_server_names.yaml new file mode 100644 index 00000000..9facdc64 --- /dev/null +++ b/testdata/openapi/duplicate_server_names.yaml @@ -0,0 +1,27 @@ +# Each way, and each place, a servers list repeats a name: the document's list +# by a second entry writing it, an alias mounting the first entry again and a +# merge key carrying one name into two; a path item's list; and an operation's. +# Each raises the openapi/duplicate-server-name warning, and this fixture puts +# it under the corpus order-invariance sweep. The document's last entry differs +# from its first only in case, and the path's lists reuse the document's names: +# neither is a repeat. +openapi: 3.2.0 +info: {title: t, version: "1"} +x-base: &b {name: edge} +servers: + - &prod {url: https://prod.example.com, name: prod} + - {url: https://prod-mirror.example.com, name: prod} + - *prod + - {<<: *b, url: https://edge-a.example.com} + - {<<: *b, url: https://edge-b.example.com} + - {url: https://staging.example.com, name: Prod} +paths: + /pets: + servers: + - {url: https://eu.example.com, name: prod} + - {url: https://eu-mirror.example.com, name: prod} + get: + servers: + - {url: https://us.example.com, name: edge} + - {url: https://us-mirror.example.com, name: edge} + responses: {"200": {description: ok}}