From 1e9c53c74cdf2d358fd40505e21cf9c0ecee68da Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Wed, 30 Sep 2026 15:43:23 +0300 Subject: [PATCH 1/8] feat(compilers/openapi): add the duplicate-server-name code A document may declare the same server name at two entries. Both entries lower, but an emitter rendering one client per server, or a consumer keying by the name, cannot tell the two hosts apart. The code is an error, matching conflicting-operation-id at the neighbouring uniqueness rule: the document writes one name twice, which is not the shape duplicate-status-key warns about, where two spellings collapse under this compiler's own neutralization. It reports only declared names compared to one another; a hint derived from a URL template is not a claim the document made. Only the document's servers list is read: a path-item or operation servers list is preserved raw under Unmodeled and builds no ir.Server. --- compilers/openapi/internal/diag/diag.go | 25 ++++++++++++++++++++ compilers/openapi/internal/diag/diag_test.go | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/compilers/openapi/internal/diag/diag.go b/compilers/openapi/internal/diag/diag.go index a93ddf33..504705d0 100644 --- a/compilers/openapi/internal/diag/diag.go +++ b/compilers/openapi/internal/diag/diag.go @@ -144,6 +144,31 @@ const ( // because neither key is wrong on its own and dropping one would pick a winner // on nothing but declaration order. DuplicateStatusKey = "openapi/duplicate-status-key" + // DuplicateServerName reports a name the document's own servers list + // declares more than once: a second entry whose Server.name repeats an + // earlier entry's. Both servers are kept — nothing is dropped or merged — + // but an emitter rendering one client per server, or a consumer keying by + // the name, cannot tell the two hosts apart, so the document is told. + // + // Error rather than a warning, which is the same call ConflictingOperationID + // makes one field over: the document writes one name twice, and `name` is the + // server's identity ("an optional unique string to refer to the host + // designated by the URL", OAS 3.2.1 §4.5.1), so honouring the declared names + // and staying unambiguous are not both possible. That is the shape this repo + // refuses, unlike the tolerated collisions it warns about: DuplicateStatusKey + // warns because two spellings of one key collapse under this compiler's own + // neutralization, and a URL-hint collision is the compiler's canonicalization + // rather than a claim the document made. A caller who wants only the report + // without the refusal has --fail-on warning. + // + // Only the document's servers list is checked, and only declared names are + // compared to one another. A name is a claim the document wrote; a hint + // derived from a URL template is not, so a hint colliding with another hint + // or with a declared name is unreported by design. A path-item or operation + // servers list is preserved raw under Unmodeled + // (operations.go applyPathServers / applyOperationServers) and builds no + // ir.Server, so nothing there can be reached by this code. + 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 f26d0bda..c862c24e 100644 --- a/compilers/openapi/internal/diag/diag_test.go +++ b/compilers/openapi/internal/diag/diag_test.go @@ -137,7 +137,7 @@ func codes() []string { diag.OverlayAction, diag.OverlayOriginIncomplete, diag.ValidationOnlyKeyword, diag.FalseSchema, diag.EmptyEnum, diag.NumericPrecision, diag.ExclusiveBoundForm, diag.InvalidStatusKey, - diag.DuplicateStatusKey, + diag.DuplicateStatusKey, diag.DuplicateServerName, diag.InvalidMethodKey, diag.DegradedConstruct, diag.CompositionLowering, diag.DynamicRefExpanded, diag.ConflictingRedecl, diag.DisjointVisibility, From ae62ee682189d194652a6af2b865cba49c62896b Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Wed, 30 Sep 2026 15:43:23 +0300 Subject: [PATCH 2/8] fix(compilers/openapi): report duplicate server names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lowerServers now walks the servers list once more, in source order, and reports every entry whose declared name a lower-indexed entry already claimed: one diagnostic per repeat at /servers//name naming the first claimant, and nothing else changes — both servers still lower with the name the document wrote. A nil entry or an empty name claims nothing, and a hint derived from a URL template is never compared, so the collisions the compiler's own canonicalization makes stay tolerated; serverName's comment now scopes that tolerance to hints and says why a declared name is not one. --- compilers/openapi/meta.go | 54 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/compilers/openapi/meta.go b/compilers/openapi/meta.go index aa3823de..94803da2 100644 --- a/compilers/openapi/meta.go +++ b/compilers/openapi/meta.go @@ -8,6 +8,7 @@ import ( "github.com/dexpace/morphic/compilers/compile" "github.com/dexpace/morphic/compilers/openapi/internal/annotation" + "github.com/dexpace/morphic/compilers/openapi/internal/diag" "github.com/dexpace/morphic/compilers/openapi/internal/ids" "github.com/dexpace/morphic/compilers/openapi/internal/lowering" "github.com/dexpace/morphic/ir" @@ -217,6 +218,10 @@ func infoDocs(c lowering.Ctx, info *soa.Info) ir.Docs { // template, description, and templated variables (ir-design §10). It returns nil // rather than an empty slice when every entry was skipped, so a document // declaring no usable server leaves the field unset. +// +// A declared name one entry repeats from another is reported and nothing else +// changes: both servers still lower, each with the name the document wrote +// (duplicateServerNameDiags). func lowerServers(c lowering.Ctx) ([]ir.Server, []ir.Diagnostic) { // GetServers never returns an empty slice — it injects a default "/" server // when none are declared — so the loop always runs at least once. @@ -231,12 +236,51 @@ func lowerServers(c lowering.Ctx) ([]ir.Server, []ir.Diagnostic) { diags = append(diags, serverDiags...) out = append(out, one) } + diags = append(diags, duplicateServerNameDiags(c, servers)...) if len(out) == 0 { return nil, diags } return out, diags } +// duplicateServerNameDiags reports every server in the document's own servers +// list whose declared name a lower-indexed server already claimed, one +// diagnostic per repeat rather than one per name: the first entry claiming the +// name is the one each message names, and each repeat is sited at its own name +// key. +// +// Only declared names are compared, and a nil entry or an empty name claims +// nothing. A name derived from a URL template is not a claim the document made +// (serverName), so hints are never compared — not to each other, and not to a +// declared name. +// +// The map is a lookup consulted as the list is walked in source order and never +// iterated, so neither map order nor anything but the entry a repeat names can +// reach a diagnostic. +func duplicateServerNameDiags(c lowering.Ctx, servers []*soa.Server) []ir.Diagnostic { + claimed := make(map[string]jsontext.Pointer, len(servers)) + var diags []ir.Diagnostic + for i, s := range servers { + if s == nil { + continue + } + name := s.GetName() + if name == "" { + continue + } + ptr := ids.Ptr("servers", strconv.Itoa(i), "name") + first, seen := claimed[name] + if !seen { + claimed[name] = ptr + continue + } + diags = append(diags, c.DiagAt(ir.SeverityError, diag.DuplicateServerName, ptr, + "server name %q is also declared by the server at %s; both are kept, and the name identifies two hosts", + name, first)) + } + return diags +} + // lowerServer lowers one server, named by serverName, keeping its x-* on the // ir.Server itself; sptr is the server's own pointer in the servers list. func lowerServer(c lowering.Ctx, s *soa.Server, sptr jsontext.Pointer) (ir.Server, []ir.Diagnostic) { @@ -265,10 +309,12 @@ func lowerServer(c lowering.Ctx, s *soa.Server, sptr jsontext.Pointer) (ir.Serve // where transcribing is not, and it keeps what the omitted part distinguished: // one host serving /v1 and /v2 is two servers. // -// Distinct servers can still collide, since canonicalizing drops every non-word -// character and ".../v1" and ".../v-1" reduce to one word sequence. That is the -// collision "red" and "Red" already produce between two enum members, and -// uniquifying it is an emitter's job. +// Distinct servers can still collide on a hint, since canonicalizing drops every +// non-word character and ".../v1" and ".../v-1" reduce to one word sequence. That +// is the collision "red" and "Red" already produce between two enum members, and +// uniquifying a hint is an emitter's job. A declared name is not a hint and is not +// tolerated this way: two entries writing one name are an error the document +// drew, not a collision this compiler made (duplicateServerNameDiags). func serverName(s *soa.Server) ir.Naming { if name := s.GetName(); name != "" { return compile.NamingFor(name) From 61038b428174bd725b0e4d8f08dfdf2722ba42cf Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Wed, 30 Sep 2026 15:43:23 +0300 Subject: [PATCH 3/8] test(compilers/openapi): pin the duplicate-server-name rule at both ends The reported table covers the issue's repro, three entries sharing one name (one report per repeat, in source order), and one declaration mounted twice by a YAML alias; a hand-built nil entry keeps the pointers on the source indices. The not-reported table covers distinct names, two unnamed servers whose URL hints collide (asserting the hints really do collide, so the row cannot pass vacuously), a declared name equal to another entry's hint, and two empty names. --- compilers/openapi/meta_test.go | 202 +++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/compilers/openapi/meta_test.go b/compilers/openapi/meta_test.go index 9602c29f..86d62c58 100644 --- a/compilers/openapi/meta_test.go +++ b/compilers/openapi/meta_test.go @@ -199,3 +199,205 @@ func TestLowerServers_EveryEntrySkippedIsNil(t *testing.T) { assert.Nil(t, got) assert.Empty(t, diags) } + +// dupServerNameMessage is the message every reported row expects. It pins all +// three parts the diagnostic must carry: the repeated name, the pointer of the +// first entry that claimed it, and that nothing was dropped. +const dupServerNameMessage = `server name "prod" is also declared by the server at /servers/0/name; ` + + `both are kept, and the name identifies two hosts` + +// TestDuplicateServerName_EveryRepeatIsReported is the reported half of the +// rule: a name the document's own servers list declares more than once is an +// error, once per repeat rather than once per name, sited at the repeat's own +// name key and naming the first entry that claimed it. Both servers still lower +// with the name the document wrote: nothing is dropped and nothing is merged. +func TestDuplicateServerName_EveryRepeatIsReported(t *testing.T) { + t.Parallel() + tests := []struct { + name string + spec string + want []string // the pointer of each repeat, in source order + servers []string // each lowered server's declared name + }{ + { + name: "the issue's repro: two entries declaring one name", + spec: `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: {} +`, + want: []string{"/servers/1/name"}, + servers: []string{"prod", "prod"}, + }, + { + name: "three entries sharing one name: one report per repeat", + 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 + - url: https://c.example.com + name: prod +paths: {} +`, + want: []string{"/servers/1/name", "/servers/2/name"}, + servers: []string{"prod", "prod", "prod"}, + }, + { + name: "one declaration mounted twice by a YAML alias", + spec: `openapi: 3.2.0 +info: {title: T, version: "1"} +servers: + - &s + url: https://prod.example.com + name: prod + - *s +paths: {} +`, + want: []string{"/servers/1/name"}, + servers: []string{"prod", "prod"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, tc.spec) + + assert.Equal(t, len(tc.want), + openapitest.CountDiagsAt(diags, diag.DuplicateServerName, ir.SeverityError), + "one diagnostic per repeat, not one per name: %+v", diags) + var got []string + for _, d := range diags { + if d.Code == diag.DuplicateServerName { + got = append(got, string(d.Provenance.Pointer)) + } + } + assert.Equal(t, tc.want, got, "each repeat sits at its own name key, in source order") + for _, ptr := range tc.want { + assert.Equal(t, dupServerNameMessage, + openapitest.DiagMessageAt(t, diags, diag.DuplicateServerName, ir.SeverityError, ptr), + "%s names the repeated name and the first claimant's key", ptr) + } + + names := make([]string, 0, len(doc.Servers)) + for _, s := range doc.Servers { + names = append(names, s.Name.Source) + } + assert.Equal(t, tc.servers, names, "every server lowered, each with the name the document wrote") + }) + } +} + +// TestDuplicateServerName_NilEntryKeepsSourceIndices pins the rule against a +// list the parser cannot produce: a nil entry between the claimant and the +// repeat. The pointers must carry the *source* indices, so the repeat at index 2 +// names the claimant at index 1 rather than the first surviving server. The +// document is hand-built for the same reason TestLowerServers_NilEntrySkipped's +// is: a nil entry never reaches lowering through the parser. +func TestDuplicateServerName_NilEntryKeepsSourceIndices(t *testing.T) { + t.Parallel() + prod := "prod" + doc := &soa.OpenAPI{Servers: []*soa.Server{ + nil, + {URL: "https://prod.example.com", Name: &prod}, + {URL: "https://prod-mirror.example.com", Name: &prod}, + }} + + got, diags := lowerServers(lowering.Ctx{Doc: doc}) + + require.Len(t, got, 2, "the nil entry is skipped and both real servers still lower") + assert.Equal(t, []string{"prod", "prod"}, []string{got[0].Name.Source, got[1].Name.Source}) + require.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.DuplicateServerName, ir.SeverityError), + "one report at the repeat: %+v", diags) + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DuplicateServerName, "/servers/2/name"), + "the repeat sits at its source index: %+v", diags) + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DuplicateServerName, ir.SeverityError, "/servers/2/name"), + "/servers/1/name", "and names the first claimant at its source index") +} + +// TestDuplicateServerName_NotReported is the tolerated half: the collisions the +// rule deliberately ignores. Declared names are compared only to other declared +// names, so a hint colliding with a hint, a declared name equal to another +// entry's hint, and two empty names are all silent — none of them is a name the +// document declared twice. +func TestDuplicateServerName_NotReported(t *testing.T) { + t.Parallel() + tests := []struct { + name string + spec string + // hintsCollide requires both lowered names to carry the same non-empty + // hint, so the row pins the hint boundary only if the hints really do + // collide rather than by passing vacuously. + hintsCollide bool + }{ + { + name: "distinct declared names", + spec: `openapi: 3.2.0 +info: {title: T, version: "1"} +servers: + - url: https://prod.example.com + name: prod + - url: https://staging.example.com + name: staging +paths: {} +`, + }, + { + 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: {} +`, + hintsCollide: true, + }, + { + 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: {} +`, + }, + { + name: "an empty name is not a name", + spec: `openapi: 3.2.0 +info: {title: T, version: "1"} +servers: + - url: https://a.example.com + name: "" + - url: https://b.example.com + name: "" +paths: {} +`, + }, + } + 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) + if !tc.hintsCollide { + return + } + require.Len(t, doc.Servers, 2) + assert.NotEmpty(t, doc.Servers[0].Name.Hint, "the row is about hints, so the names must be hints") + assert.Equal(t, doc.Servers[0].Name.Hint, doc.Servers[1].Name.Hint, + "and they must really collide, or the row asserts nothing") + }) + } +} From a5b23cc6c8c43b3a353df50f3b21eddf403dfe9e Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Wed, 30 Sep 2026 15:43:23 +0300 Subject: [PATCH 4/8] test(compilers/openapi): credit the overlay for the name it duplicates The base document declares one server, so only the patched tree has a repeat to report; the diagnostic about the appended entry names the overlay, which is the source the rule has to be reading. --- compilers/openapi/overlay_test.go | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/compilers/openapi/overlay_test.go b/compilers/openapi/overlay_test.go index 79bc9186..52eaa333 100644 --- a/compilers/openapi/overlay_test.go +++ b/compilers/openapi/overlay_test.go @@ -63,6 +63,56 @@ func compileWith(t *testing.T, opts openapi.Options) (*ir.Document, []ir.Diagnos return doc, diags } +// overlayServerBase declares one named server, and overlayServerPatch appends a +// second that declares the same name: an array update on an array target appends +// its items, so the colliding entry is a position the overlay introduced. +const overlayServerBase = `openapi: 3.2.0 +info: {title: Pets, version: "1"} +servers: + - url: https://prod.example.com + name: prod +paths: {} +` + +const overlayServerPatch = `overlay: 1.0.0 +info: {title: Patch, version: "1"} +actions: + - target: $.servers + update: + - url: https://prod-mirror.example.com + name: prod +` + +// TestCompile_OverlayIntroducingADuplicateServerNameCreditsTheOverlay pins the +// duplicate-server-name rule against the tree the overlay produced rather than +// the bytes on disk: the base document declares one server, so only the patched +// document has a repeat to report, and the diagnostic about the appended entry +// names the overlay as the source it came from. The base's own server keeps the +// document as its source. +func TestCompile_OverlayIntroducingADuplicateServerNameCreditsTheOverlay(t *testing.T) { + t.Parallel() + doc, diags, err := openapi.New().Compile(t.Context(), + []compilers.Source{{Path: "spec.yaml", Data: []byte(overlayServerBase)}}, + compilers.Options{FormatOptions: openapi.Options{ + Overlay: &openapi.Overlay{Path: "patch.yaml", Data: []byte(overlayServerPatch)}, + }}) + require.NoError(t, err) + require.NotNil(t, doc, "compile refused: %+v", diags) + + found := diagnosticsByCodeAndPointer(diags)[diagKey{diag.DuplicateServerName, "/servers/1/name"}] + require.Len(t, found, 1, "one report at the entry the overlay appended: %+v", diags) + assert.Equal(t, ir.SeverityError, found[0].Severity) + assert.Equal(t, 1, found[0].Provenance.Source, + "the overlay appended this entry, so the overlay is the source the repeat is reported against") + assert.Contains(t, found[0].Message, "/servers/0/name", + "and the first claimant is the base document's server") + + require.Len(t, doc.Servers, 2, "both servers still lower, the base's and the overlay's") + for i, want := range []string{"prod", "prod"} { + assert.Equal(t, want, doc.Servers[i].Name.Source, "server %d keeps the name the document wrote", i) + } +} + // 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 { From b23b370a1128b88b729944d24f32b6e5503be2a7 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Sun, 11 Oct 2026 13:18:56 +0300 Subject: [PATCH 5/8] fix(compilers/openapi): warn on a duplicate server name OAS 3.2 calls a server's name "an optional unique string" with no RFC 2119 keyword, unlike operationId's MUST, and a YAML alias or merge key can repeat a name the document wrote only once. So a repeated name now warns rather than refusing the compile: a document that exits 0 today still does under the default --fail-on. The check runs in the lowerServers loop, extending the server pointer lowerServer already builds rather than deriving a second one, and drops the dead nil guard. Names are compared as exact strings; the tests now pin that names equal only once canonicalized (prod, Prod) stay silent, and that a declared name really equals the other entry's hint in the row that claims so. A 3.1 document is checked too, since its servers lower with the name it wrote. When only the earlier claim came from an overlay, the report sits at that entry and names the overlay as its source, since the overlay made the two collide. A fixture under testdata/openapi puts the warning under the harness order-invariance sweep. --- compilers/openapi/internal/diag/diag.go | 15 +-- compilers/openapi/meta.go | 79 ++++++------ compilers/openapi/meta_test.go | 100 ++++++++++++---- compilers/openapi/overlay_test.go | 120 +++++++++++++------ docs/ir-design.md | 2 +- testdata/openapi/duplicate_server_names.yaml | 16 +++ 6 files changed, 229 insertions(+), 103 deletions(-) create mode 100644 testdata/openapi/duplicate_server_names.yaml diff --git a/compilers/openapi/internal/diag/diag.go b/compilers/openapi/internal/diag/diag.go index 9016822f..335077ff 100644 --- a/compilers/openapi/internal/diag/diag.go +++ b/compilers/openapi/internal/diag/diag.go @@ -143,14 +143,15 @@ const ( // on nothing but declaration order. DuplicateStatusKey = "openapi/duplicate-status-key" // DuplicateServerName reports a Server.name the document's servers list - // declares more than once. Both servers are kept, but a consumer keying by - // name cannot tell the two hosts apart. + // declares more than once, compared as exact strings. Both servers are kept, + // but a consumer keying servers by name cannot tell them apart. // - // Error, as ConflictingOperationID is: `name` is the server's identity ("an - // optional unique string", OAS 3.2.1 §4.5.1), so the document's own claim is - // contradictory. Collisions this compiler makes, like DuplicateStatusKey's or - // a URL hint's, only warn. Only declared names in the document's servers - // list are compared; path-item and operation servers build no ir.Server. + // A warning, not ConflictingOperationID's error: OAS 3.2 calls the name "an + // optional unique string" with no RFC 2119 keyword, where operationId's + // uniqueness is a MUST, and a YAML alias mounting one server twice repeats a + // name the document wrote once. Names equal only once canonicalized, and URL + // hints, are tolerated: an emitter uniquifies them. Path-item and operation + // servers build no ir.Server and are not compared. 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 diff --git a/compilers/openapi/meta.go b/compilers/openapi/meta.go index 64c92a7e..e0509632 100644 --- a/compilers/openapi/meta.go +++ b/compilers/openapi/meta.go @@ -219,58 +219,58 @@ func infoDocs(c lowering.Ctx, info *soa.Info) ir.Docs { // // A declared name one entry repeats from another is reported and nothing else // changes: both servers still lower, each with the name the document wrote -// (duplicateServerNameDiags). +// (serverNameClaims). func lowerServers(c lowering.Ctx) ([]ir.Server, []ir.Diagnostic) { // GetServers never returns an empty slice — it injects a default "/" server // when none are declared — so the loop always runs at least once. servers := c.Doc.GetServers() out := make([]ir.Server, 0, len(servers)) + claims := make(serverNameClaims, len(servers)) var diags []ir.Diagnostic for i, s := range servers { if s == nil { continue } - one, serverDiags := lowerServer(c, s, ids.Ptr("servers", strconv.Itoa(i))) + sptr := ids.Ptr("servers", strconv.Itoa(i)) + one, serverDiags := lowerServer(c, s, sptr) diags = append(diags, serverDiags...) + diags = append(diags, claims.claim(c, s.GetName(), sptr)...) out = append(out, one) } - diags = append(diags, duplicateServerNameDiags(c, servers)...) if len(out) == 0 { return nil, diags } return out, diags } -// duplicateServerNameDiags reports every server in the document's own servers -// list whose declared name a lower-indexed server already claimed, one -// diagnostic per repeat, sited at its own name key and naming the first claim. +// serverNameClaims maps each declared server name to the name key of the first +// server that declared it. It is consulted in source order and never iterated, +// so map order cannot reach a diagnostic. +type serverNameClaims map[string]jsontext.Pointer + +// claim records name for the server at sptr, or reports it when an earlier +// server already declared it, compared as exact strings. An empty name claims +// nothing, and a URL hint is never a claim: neither is a name the document wrote. // -// Only declared names are compared, and a nil entry or an empty name claims -// nothing: a hint derived from a URL template is not a claim the document made -// (serverName). The map is consulted in source order and never iterated, so map -// order cannot reach a diagnostic. -func duplicateServerNameDiags(c lowering.Ctx, servers []*soa.Server) []ir.Diagnostic { - claimed := make(map[string]jsontext.Pointer, len(servers)) - var diags []ir.Diagnostic - for i, s := range servers { - if s == nil { - continue - } - name := s.GetName() - if name == "" { - continue - } - ptr := ids.Ptr("servers", strconv.Itoa(i), "name") - first, seen := claimed[name] - if !seen { - claimed[name] = ptr - continue - } - diags = append(diags, c.DiagAt(ir.SeverityError, diag.DuplicateServerName, ptr, - "server name %q is also declared by the server at %s; both are kept, and the name identifies two hosts", - name, first)) +// The report sits at the repeat's name key, unless only the earlier claim came +// from the overlay: the overlay then made the two collide, so the report names +// it as the source a reader has to open. +func (claims serverNameClaims) claim(c lowering.Ctx, name string, sptr jsontext.Pointer) []ir.Diagnostic { + if name == "" { + return nil } - return diags + at := sptr + ids.Ptr("name") + first, seen := claims[name] + if !seen { + claims[name] = at + return nil + } + if c.ProvenanceAt(at).Source == c.SrcIndex && c.ProvenanceAt(first).Source != c.SrcIndex { + at, first = first, at + } + return []ir.Diagnostic{c.DiagAt(ir.SeverityWarning, diag.DuplicateServerName, at, + "server name %q is also declared by the server at %s; both are kept, and a consumer "+ + "keying servers by name cannot tell them apart", name, first)} } // lowerServer lowers one server, named by serverName, keeping its x-* on the @@ -289,16 +289,17 @@ func lowerServer(c lowering.Ctx, s *soa.Server, sptr jsontext.Pointer) (ir.Serve return out, append(diags, annotation.UnknownKeysIn(&out.Unmodeled, s, c.ProvenanceAt, sptr)...) } -// serverName builds a server's neutral naming: the declared name (only OpenAPI -// 3.2 has one), else a hint derived from the URL template, as operationName does -// for an operation with no operationId (GitHub #258). It names by the whole URL, -// not the position in servers[], which shifts on reordering, nor a part of it, -// which drops what the rest distinguishes: one host serving /v1 and /v2 is two -// servers. +// serverName builds a server's neutral naming: the declared name (a field +// OpenAPI 3.2 defines, but read on every version, as the parser does), else a +// hint derived from the URL template, as operationName does for an operation +// with no operationId (GitHub #258). It names by the whole URL, not the position +// in servers[], which shifts on reordering, nor a part of it, which drops what +// the rest distinguishes: one host serving /v1 and /v2 is two servers. // // Distinct servers can collide (".../v1" and ".../v-1" reduce to one word -// sequence), as two enum members can; an emitter uniquifies. Two declared names -// colliding are the document's error (duplicateServerNameDiags). +// sequence), as two enum members can; an emitter uniquifies. So can declared +// names that differ only once canonicalized (prod, Prod). Only declared names +// equal as exact strings are reported (serverNameClaims). func serverName(s *soa.Server) ir.Naming { if name := s.GetName(); name != "" { return compile.NamingFor(name) diff --git a/compilers/openapi/meta_test.go b/compilers/openapi/meta_test.go index 86d62c58..a065925e 100644 --- a/compilers/openapi/meta_test.go +++ b/compilers/openapi/meta_test.go @@ -204,13 +204,15 @@ func TestLowerServers_EveryEntrySkippedIsNil(t *testing.T) { // three parts the diagnostic must carry: the repeated name, the pointer of the // first entry that claimed it, and that nothing was dropped. const dupServerNameMessage = `server name "prod" is also declared by the server at /servers/0/name; ` + - `both are kept, and the name identifies two hosts` + `both are kept, and a consumer keying servers by name cannot tell them apart` // TestDuplicateServerName_EveryRepeatIsReported is the reported half of the -// rule: a name the document's own servers list declares more than once is an -// error, once per repeat rather than once per name, sited at the repeat's own +// rule: a name the document's own servers list declares more than once is a +// warning, once per repeat rather than once per name, sited at the repeat's own // name key and naming the first entry that claimed it. Both servers still lower // with the name the document wrote: nothing is dropped and nothing is merged. +// A 3.1 document is checked too, since its servers lower with the name it wrote +// although only 3.2 defines the field. func TestDuplicateServerName_EveryRepeatIsReported(t *testing.T) { t.Parallel() tests := []struct { @@ -259,6 +261,33 @@ servers: name: prod - *s paths: {} +`, + want: []string{"/servers/1/name"}, + servers: []string{"prod", "prod"}, + }, + { + name: "one name carried into two entries by a merge key", + spec: `openapi: 3.2.0 +info: {title: T, version: "1"} +x-base: &b {name: prod} +servers: + - {<<: *b, url: https://a.example.com} + - {<<: *b, url: https://b.example.com} +paths: {} +`, + want: []string{"/servers/1/name"}, + servers: []string{"prod", "prod"}, + }, + { + name: "a 3.1 document, whose servers lower with the name it wrote", + spec: `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: []string{"/servers/1/name"}, servers: []string{"prod", "prod"}, @@ -270,7 +299,7 @@ paths: {} doc, diags := parseFull(t, tc.spec) assert.Equal(t, len(tc.want), - openapitest.CountDiagsAt(diags, diag.DuplicateServerName, ir.SeverityError), + openapitest.CountDiagsAt(diags, diag.DuplicateServerName, ir.SeverityWarning), "one diagnostic per repeat, not one per name: %+v", diags) var got []string for _, d := range diags { @@ -281,7 +310,7 @@ paths: {} assert.Equal(t, tc.want, got, "each repeat sits at its own name key, in source order") for _, ptr := range tc.want { assert.Equal(t, dupServerNameMessage, - openapitest.DiagMessageAt(t, diags, diag.DuplicateServerName, ir.SeverityError, ptr), + openapitest.DiagMessageAt(t, diags, diag.DuplicateServerName, ir.SeverityWarning, ptr), "%s names the repeated name and the first claimant's key", ptr) } @@ -294,9 +323,8 @@ paths: {} } } -// TestDuplicateServerName_NilEntryKeepsSourceIndices pins the rule against a -// list the parser cannot produce: a nil entry between the claimant and the -// repeat. The pointers must carry the *source* indices, so the repeat at index 2 +// TestDuplicateServerName_NilEntryKeepsSourceIndices pins that the pointers carry +// *source* indices, so with a skipped entry ahead of them the repeat at index 2 // names the claimant at index 1 rather than the first surviving server. The // document is hand-built for the same reason TestLowerServers_NilEntrySkipped's // is: a nil entry never reaches lowering through the parser. @@ -313,29 +341,30 @@ func TestDuplicateServerName_NilEntryKeepsSourceIndices(t *testing.T) { require.Len(t, got, 2, "the nil entry is skipped and both real servers still lower") assert.Equal(t, []string{"prod", "prod"}, []string{got[0].Name.Source, got[1].Name.Source}) - require.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.DuplicateServerName, ir.SeverityError), + require.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.DuplicateServerName, ir.SeverityWarning), "one report at the repeat: %+v", diags) assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DuplicateServerName, "/servers/2/name"), "the repeat sits at its source index: %+v", diags) assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DuplicateServerName, ir.SeverityError, "/servers/2/name"), + openapitest.DiagMessageAt(t, diags, diag.DuplicateServerName, ir.SeverityWarning, "/servers/2/name"), "/servers/1/name", "and names the first claimant at its source index") } // TestDuplicateServerName_NotReported is the tolerated half: the collisions the // rule deliberately ignores. Declared names are compared only to other declared -// names, so a hint colliding with a hint, a declared name equal to another -// entry's hint, and two empty names are all silent — none of them is a name the -// document declared twice. +// names and only as exact strings, so a hint colliding with a hint, a declared +// name equal to another entry's hint, two names equal only once canonicalized, +// and two empty names are all silent — none of them is a string the document +// declared twice. func TestDuplicateServerName_NotReported(t *testing.T) { t.Parallel() tests := []struct { name string spec string - // hintsCollide requires both lowered names to carry the same non-empty - // hint, so the row pins the hint boundary only if the hints really do - // collide rather than by passing vacuously. - hintsCollide bool + // 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: "distinct declared names", @@ -358,7 +387,7 @@ servers: - url: https://api.example.com/v-1 paths: {} `, - hintsCollide: true, + collide: func(a, b ir.Naming) (string, string) { return a.Hint, b.Hint }, }, { name: "a declared name equal to another entry's hint", @@ -370,6 +399,33 @@ servers: 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 }, }, { name: "an empty name is not a name", @@ -391,13 +447,13 @@ paths: {} assert.False(t, openapitest.HasDiag(diags, diag.DuplicateServerName), "nothing is reported: %+v", diags) - if !tc.hintsCollide { + if tc.collide == nil { return } require.Len(t, doc.Servers, 2) - assert.NotEmpty(t, doc.Servers[0].Name.Hint, "the row is about hints, so the names must be hints") - assert.Equal(t, doc.Servers[0].Name.Hint, doc.Servers[1].Name.Hint, - "and they must really collide, or the row asserts nothing") + 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 06338953..296f7733 100644 --- a/compilers/openapi/overlay_test.go +++ b/compilers/openapi/overlay_test.go @@ -63,53 +63,105 @@ func compileWith(t *testing.T, opts openapi.Options) (*ir.Document, []ir.Diagnos return doc, diags } -// overlayServerBase declares one named server, and overlayServerPatch appends a -// second that declares the same name: an array update on an array target appends -// its items, so the colliding entry is a position the overlay introduced. -const overlayServerBase = `openapi: 3.2.0 +// TestCompile_OverlayMakingServerNamesCollideCreditsTheOverlay pins the +// duplicate-server-name rule against the tree the overlay produced rather than +// the bytes on disk: each base document declares no repeat, so only the patched +// document has one to report. The report names the overlay as its source, at +// the entry the overlay wrote, whether that entry comes after the base's +// claimant (an appended item) or before it (an earlier entry renamed); when the +// overlay wrote both, at the later one, as in a single document. +func TestCompile_OverlayMakingServerNamesCollideCreditsTheOverlay(t *testing.T) { + t.Parallel() + tests := []struct { + name, base, patch string + at, other string // the reported name key and the one its message names + 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: {} -` - -const overlayServerPatch = `overlay: 1.0.0 +`, + patch: `overlay: 1.0.0 info: {title: Patch, version: "1"} actions: - target: $.servers update: - url: https://prod-mirror.example.com name: prod -` +`, + at: "/servers/1/name", other: "/servers/0/name", + names: []string{"prod", "prod"}, + }, + { + name: "the overlay renames an entry ahead of the base's claimant", + base: `openapi: 3.2.0 +info: {title: Pets, version: "1"} +servers: + - url: https://dev.example.com + name: dev + - url: https://prod.example.com + name: prod +paths: {} +`, + patch: `overlay: 1.0.0 +info: {title: Patch, version: "1"} +actions: + - target: $.servers[0] + update: {name: prod} +`, + at: "/servers/0/name", other: "/servers/1/name", + names: []string{"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} +`, + at: "/servers/2/name", other: "/servers/1/name", + 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) -// TestCompile_OverlayIntroducingADuplicateServerNameCreditsTheOverlay pins the -// duplicate-server-name rule against the tree the overlay produced rather than -// the bytes on disk: the base document declares one server, so only the patched -// document has a repeat to report, and the diagnostic about the appended entry -// names the overlay as the source it came from. The base's own server keeps the -// document as its source. -func TestCompile_OverlayIntroducingADuplicateServerNameCreditsTheOverlay(t *testing.T) { - t.Parallel() - doc, diags, err := openapi.New().Compile(t.Context(), - []compilers.Source{{Path: "spec.yaml", Data: []byte(overlayServerBase)}}, - compilers.Options{FormatOptions: openapi.Options{ - Overlay: &openapi.Overlay{Path: "patch.yaml", Data: []byte(overlayServerPatch)}, - }}) - require.NoError(t, err) - require.NotNil(t, doc, "compile refused: %+v", diags) + found := diagnosticsByCodeAndPointer(diags)[diagKey{diag.DuplicateServerName, jsontext.Pointer(tc.at)}] + require.Len(t, found, 1, "one report, at the entry the overlay wrote: %+v", diags) + assert.Equal(t, ir.SeverityWarning, found[0].Severity) + assert.Equal(t, 1, found[0].Provenance.Source, + "the overlay made the names collide, so it is the source the report is against") + assert.Contains(t, found[0].Message, tc.other, "and the message names the other claimant") - found := diagnosticsByCodeAndPointer(diags)[diagKey{diag.DuplicateServerName, "/servers/1/name"}] - require.Len(t, found, 1, "one report at the entry the overlay appended: %+v", diags) - assert.Equal(t, ir.SeverityError, found[0].Severity) - assert.Equal(t, 1, found[0].Provenance.Source, - "the overlay appended this entry, so the overlay is the source the repeat is reported against") - assert.Contains(t, found[0].Message, "/servers/0/name", - "and the first claimant is the base document's server") - - require.Len(t, doc.Servers, 2, "both servers still lower, the base's and the overlay's") - for i, want := range []string{"prod", "prod"} { - assert.Equal(t, want, doc.Servers[i].Name.Source, "server %d keeps the name the document wrote", i) + 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") + }) } } diff --git a/docs/ir-design.md b/docs/ir-design.md index 79eb78ba..0cc25d04 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -2131,7 +2131,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 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); 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), 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; 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; 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); securitySchemes/security → Auth OR-of-ANDs, 3.2 device flow + `oauth2MetadataUrl` → Flows/OAuth2MetadataURL; 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; `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); `$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, with any trailing `items` → Tuple.Unmodeled (`degraded_lowering` per §4.8: an open tuple has no IR combinator, so the fixed head is lowered and the tail kept beside it); `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 | +| **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 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); 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), 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; 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; 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); securitySchemes/security → Auth OR-of-ANDs, 3.2 device flow + `oauth2MetadataUrl` → Flows/OAuth2MetadataURL; servers+variables (3.2 named) → Servers, with a declared name the list repeats — compared as exact strings, on every version since the parser reads `name` on each — lowered as declared on both servers + `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; `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); `$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, with any trailing `items` → Tuple.Unmodeled (`degraded_lowering` per §4.8: an open tuple has no IR combinator, so the fixed head is lowered and the tail kept beside it); `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 | | **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; 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..5f2dc891 --- /dev/null +++ b/testdata/openapi/duplicate_server_names.yaml @@ -0,0 +1,16 @@ +# Every way a servers list repeats a declared name: a second entry writing it +# again, an alias mounting the first entry twice, and a merge key carrying one +# name into two entries. Each raises only the openapi/duplicate-server-name +# warning, and this fixture puts that warning under the corpus order-invariance +# sweep. The last entry differs from the first only in case, which is tolerated. +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: {} From 63384ae2d56387cae613e6d7ab5f97fffde81fc1 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Sun, 11 Oct 2026 13:37:22 +0300 Subject: [PATCH 6/8] docs(compilers/openapi): keep the serverName comment under the cap --- compilers/openapi/meta.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/compilers/openapi/meta.go b/compilers/openapi/meta.go index e0509632..d1e9d166 100644 --- a/compilers/openapi/meta.go +++ b/compilers/openapi/meta.go @@ -289,17 +289,16 @@ func lowerServer(c lowering.Ctx, s *soa.Server, sptr jsontext.Pointer) (ir.Serve return out, append(diags, annotation.UnknownKeysIn(&out.Unmodeled, s, c.ProvenanceAt, sptr)...) } -// serverName builds a server's neutral naming: the declared name (a field -// OpenAPI 3.2 defines, but read on every version, as the parser does), else a -// hint derived from the URL template, as operationName does for an operation -// with no operationId (GitHub #258). It names by the whole URL, not the position -// in servers[], which shifts on reordering, nor a part of it, which drops what -// the rest distinguishes: one host serving /v1 and /v2 is two servers. +// serverName builds a server's neutral naming: the declared name (defined by +// OpenAPI 3.2, but read on every version, as the parser does), else a hint +// derived from the whole URL template, as operationName does for an operation +// with no operationId (GitHub #258). Not the position, which shifts on +// reordering, nor part of the URL: one host serving /v1 and /v2 is two servers. // // Distinct servers can collide (".../v1" and ".../v-1" reduce to one word -// sequence), as two enum members can; an emitter uniquifies. So can declared -// names that differ only once canonicalized (prod, Prod). Only declared names -// equal as exact strings are reported (serverNameClaims). +// sequence), as can declared names differing only once canonicalized (prod, +// Prod); an emitter uniquifies. Only exact repeats are reported +// (serverNameClaims). func serverName(s *soa.Server) ir.Naming { if name := s.GetName(); name != "" { return compile.NamingFor(name) From e55d9f73113151e7c7cf05232e4be18338ef2e64 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Thu, 15 Oct 2026 00:39:21 +0300 Subject: [PATCH 7/8] refactor(compilers/openapi): check every source list in one walk The parameter-list uniqueness check walked the source on its own. A second list rule, a server name repeated within one servers list, reads the same owners: the document, each path item, the fields beside a path item $ref, and each operation. Walking the source again for it would cost about a million allocations on an 11 MB description, so the walk becomes listRepeats, which hands each owner's lists to a rule per list: paramRepeats is the parameter rule, unchanged, and writtenItem names the path item whose lists a walk item writes, which the check had inline. Behaviour is unchanged. --- .../internal/load/later_internal_test.go | 2 +- compilers/openapi/internal/load/load.go | 6 +- .../internal/load/load_internal_test.go | 2 +- compilers/openapi/internal/load/params.go | 79 ---------------- compilers/openapi/internal/load/repeats.go | 92 +++++++++++++++++++ ...ernal_test.go => repeats_internal_test.go} | 12 +-- .../openapi/internal/operation/paramledger.go | 2 +- 7 files changed, 104 insertions(+), 91 deletions(-) delete mode 100644 compilers/openapi/internal/load/params.go create mode 100644 compilers/openapi/internal/load/repeats.go rename compilers/openapi/internal/load/{params_internal_test.go => repeats_internal_test.go} (93%) 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..af4e31d1 100644 --- a/compilers/openapi/internal/load/load.go +++ b/compilers/openapi/internal/load/load.go @@ -314,7 +314,7 @@ func validated(ctx context.Context, at func(jsontext.Pointer) ir.Provenance, loc 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/repeats.go b/compilers/openapi/internal/load/repeats.go new file mode 100644 index 00000000..def51d04 --- /dev/null +++ b/compilers/openapi/internal/load/repeats.go @@ -0,0 +1,92 @@ +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). +// +// 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.ReferencedPathItem: + if item, ok := writtenItem(m, sibs, site); ok { + diags = append(diags, paramRepeats(at, item.GetParameters(), site)...) + } + case *soa.Operation: + diags = append(diags, paramRepeats(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 +} + +// 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 +} diff --git a/compilers/openapi/internal/load/params_internal_test.go b/compilers/openapi/internal/load/repeats_internal_test.go similarity index 93% rename from compilers/openapi/internal/load/params_internal_test.go rename to compilers/openapi/internal/load/repeats_internal_test.go index 34657913..08f763e6 100644 --- a/compilers/openapi/internal/load/params_internal_test.go +++ b/compilers/openapi/internal/load/repeats_internal_test.go @@ -164,16 +164,16 @@ webhooks: } } -// 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) { +// 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 := parameterRepeats(at, Siblings{}, panics) + 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, "parameter check panicked: walk fault") + assert.Contains(t, diags[0].Message, "repeat check panicked: walk fault") } 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 From 933b5072790cd29f0f367ae7f2297829c0718d70 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Thu, 15 Oct 2026 00:39:37 +0300 Subject: [PATCH 8/8] fix(compilers/openapi): check every servers list for a repeated name The check ran where the document's servers lower, so a path item's or an operation's list, which lowers to no ir.Server, was never read. Those lists are in the source's model, and load already walks every list the source writes for the parameter rule. The check moves there as serverRepeats: the document's list, each path item's (the fields beside a path item $ref included) and each operation's, lowered or not, each checked once where it is written. Each repeat is now reported at itself and credited to the document that wrote it, as every other repeat rule reports. The lowering moved the report to the earlier entry when only that one came from an overlay, so beside an overlay's rename of the first entry, a name the base repeated was reported twice at the overlay's entry and never at the base's own. A list only a $ref reaches, under an x-* key or in another document, is left unchecked by both rules (#880). --- compilers/openapi/internal/diag/diag.go | 18 +- compilers/openapi/internal/load/load.go | 6 +- compilers/openapi/internal/load/repeats.go | 41 ++- .../internal/load/repeats_internal_test.go | 246 ++++++++++++++++++ compilers/openapi/meta.go | 54 +--- compilers/openapi/meta_test.go | 194 ++------------ compilers/openapi/overlay_test.go | 53 ++-- docs/ir-design.md | 2 +- testdata/openapi/duplicate_server_names.yaml | 23 +- 9 files changed, 378 insertions(+), 259 deletions(-) diff --git a/compilers/openapi/internal/diag/diag.go b/compilers/openapi/internal/diag/diag.go index 81899007..e0a611cc 100644 --- a/compilers/openapi/internal/diag/diag.go +++ b/compilers/openapi/internal/diag/diag.go @@ -165,16 +165,16 @@ const ( // // Error, as the parser's report was. DuplicateParameter = "openapi/duplicate-parameter" - // DuplicateServerName reports a Server.name the document's servers list - // declares more than once, compared as exact strings. Both servers are kept, - // but a consumer keying servers by name cannot tell them apart. + // 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. // - // A warning, not ConflictingOperationID's error: OAS 3.2 calls the name "an - // optional unique string" with no RFC 2119 keyword, where operationId's - // uniqueness is a MUST, and a YAML alias mounting one server twice repeats a - // name the document wrote once. Names equal only once canonicalized, and URL - // hints, are tolerated: an emitter uniquifies them. Path-item and operation - // servers build no ir.Server and are not compared. + // 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 diff --git a/compilers/openapi/internal/load/load.go b/compilers/openapi/internal/load/load.go index af4e31d1..f2d675d6 100644 --- a/compilers/openapi/internal/load/load.go +++ b/compilers/openapi/internal/load/load.go @@ -308,9 +308,9 @@ 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 { diff --git a/compilers/openapi/internal/load/repeats.go b/compilers/openapi/internal/load/repeats.go index def51d04..a7c92746 100644 --- a/compilers/openapi/internal/load/repeats.go +++ b/compilers/openapi/internal/load/repeats.go @@ -14,7 +14,7 @@ import ( // 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). +// 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 @@ -26,14 +26,21 @@ func listRepeats(at func(jsontext.Pointer) ir.Provenance, sibs Siblings, items i 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 list. + // No other model holds a parameter or a servers list. } return nil }) @@ -90,3 +97,33 @@ func paramRepeats(at func(jsontext.Pointer) ir.Provenance, params []*soa.Referen } 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 index 08f763e6..843f11f5 100644 --- a/compilers/openapi/internal/load/repeats_internal_test.go +++ b/compilers/openapi/internal/load/repeats_internal_test.go @@ -2,6 +2,7 @@ package load import ( "encoding/json/jsontext" + "fmt" "testing" soa "github.com/speakeasy-api/openapi/openapi" @@ -177,3 +178,248 @@ func TestListRepeats_AWalkThatPanicsIsReported(t *testing.T) { 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/meta.go b/compilers/openapi/meta.go index f2eecb2e..56c57cb7 100644 --- a/compilers/openapi/meta.go +++ b/compilers/openapi/meta.go @@ -300,25 +300,18 @@ func infoDocs(c lowering.Ctx, info *soa.Info) ir.Docs { // template, description, and templated variables (ir-design §10). It returns nil // rather than an empty slice when every entry was skipped, so a document // declaring no usable server leaves the field unset. -// -// A declared name one entry repeats from another is reported and nothing else -// changes: both servers still lower, each with the name the document wrote -// (serverNameClaims). func lowerServers(c lowering.Ctx) ([]ir.Server, []ir.Diagnostic) { // GetServers never returns an empty slice — it injects a default "/" server // when none are declared — so the loop always runs at least once. servers := c.Doc.GetServers() out := make([]ir.Server, 0, len(servers)) - claims := make(serverNameClaims, len(servers)) var diags []ir.Diagnostic for i, s := range servers { if s == nil { continue } - sptr := ids.Ptr("servers", strconv.Itoa(i)) - one, serverDiags := lowerServer(c, s, sptr) + one, serverDiags := lowerServer(c, s, ids.Ptr("servers", strconv.Itoa(i))) diags = append(diags, serverDiags...) - diags = append(diags, claims.claim(c, s.GetName(), sptr)...) out = append(out, one) } if len(out) == 0 { @@ -327,36 +320,6 @@ func lowerServers(c lowering.Ctx) ([]ir.Server, []ir.Diagnostic) { return out, diags } -// serverNameClaims maps each declared server name to the name key of the first -// server that declared it. It is consulted in source order and never iterated, -// so map order cannot reach a diagnostic. -type serverNameClaims map[string]jsontext.Pointer - -// claim records name for the server at sptr, or reports it when an earlier -// server already declared it, compared as exact strings. An empty name claims -// nothing, and a URL hint is never a claim: neither is a name the document wrote. -// -// The report sits at the repeat's name key, unless only the earlier claim came -// from the overlay: the overlay then made the two collide, so the report names -// it as the source a reader has to open. -func (claims serverNameClaims) claim(c lowering.Ctx, name string, sptr jsontext.Pointer) []ir.Diagnostic { - if name == "" { - return nil - } - at := sptr + ids.Ptr("name") - first, seen := claims[name] - if !seen { - claims[name] = at - return nil - } - if c.ProvenanceAt(at).Source == c.SrcIndex && c.ProvenanceAt(first).Source != c.SrcIndex { - at, first = first, at - } - return []ir.Diagnostic{c.DiagAt(ir.SeverityWarning, diag.DuplicateServerName, at, - "server name %q is also declared by the server at %s; both are kept, and a consumer "+ - "keying servers by name cannot tell them apart", name, first)} -} - // lowerServer lowers one server, named by serverName, keeping its x-* on the // ir.Server itself; sptr is the server's own pointer in the servers list. func lowerServer(c lowering.Ctx, s *soa.Server, sptr jsontext.Pointer) (ir.Server, []ir.Diagnostic) { @@ -373,16 +336,15 @@ func lowerServer(c lowering.Ctx, s *soa.Server, sptr jsontext.Pointer) (ir.Serve return out, append(diags, annotation.UnknownKeysIn(&out.Unmodeled, s, c.ProvenanceAt, sptr)...) } -// serverName builds a server's neutral naming: the declared name (defined by -// OpenAPI 3.2, but read on every version, as the parser does), else a hint -// derived from the whole URL template, as operationName does for an operation -// with no operationId (GitHub #258). Not the position, which shifts on -// reordering, nor part of the URL: one host serving /v1 and /v2 is two servers. +// serverName builds a server's neutral naming: the declared name (only OpenAPI +// 3.2 has one), else a hint derived from the URL template, as operationName does +// for an operation with no operationId (GitHub #258). It names by the URL, not +// the position in servers[], which shifts when the list is reordered, and by the +// whole template, not a part: choosing a part is a policy and drops what the +// rest distinguishes, since one host serving /v1 and /v2 is two servers. // // Distinct servers can collide (".../v1" and ".../v-1" reduce to one word -// sequence), as can declared names differing only once canonicalized (prod, -// Prod); an emitter uniquifies. Only exact repeats are reported -// (serverNameClaims). +// sequence), as two enum members can; an emitter uniquifies. func serverName(s *soa.Server) ir.Naming { if name := s.GetName(); name != "" { return compile.NamingFor(name) diff --git a/compilers/openapi/meta_test.go b/compilers/openapi/meta_test.go index 53602088..90ca4632 100644 --- a/compilers/openapi/meta_test.go +++ b/compilers/openapi/meta_test.go @@ -231,30 +231,13 @@ func TestMeta_JSONSchemaDialectIsKept(t *testing.T) { assert.NotContains(t, doc.Unmodeled, "openapi:jsonSchemaDialect") } -// dupServerNameMessage is the message every reported row expects. It pins all -// three parts the diagnostic must carry: the repeated name, the pointer of the -// first entry that claimed it, and that nothing was dropped. -const dupServerNameMessage = `server name "prod" is also declared by the server at /servers/0/name; ` + - `both are kept, and a consumer keying servers by name cannot tell them apart` - -// TestDuplicateServerName_EveryRepeatIsReported is the reported half of the -// rule: a name the document's own servers list declares more than once is a -// warning, once per repeat rather than once per name, sited at the repeat's own -// name key and naming the first entry that claimed it. Both servers still lower -// with the name the document wrote: nothing is dropped and nothing is merged. -// A 3.1 document is checked too, since its servers lower with the name it wrote -// although only 3.2 defines the field. -func TestDuplicateServerName_EveryRepeatIsReported(t *testing.T) { +// 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() - tests := []struct { - name string - spec string - want []string // the pointer of each repeat, in source order - servers []string // each lowered server's declared name - }{ - { - name: "the issue's repro: two entries declaring one name", - spec: `openapi: 3.2.0 + doc, diags := parseFull(t, `openapi: 3.2.0 info: {title: T, version: "1"} servers: - url: https://prod.example.com @@ -262,132 +245,26 @@ servers: - url: https://prod-mirror.example.com name: prod paths: {} -`, - want: []string{"/servers/1/name"}, - servers: []string{"prod", "prod"}, - }, - { - name: "three entries sharing one name: one report per repeat", - 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 - - url: https://c.example.com - name: prod -paths: {} -`, - want: []string{"/servers/1/name", "/servers/2/name"}, - servers: []string{"prod", "prod", "prod"}, - }, - { - name: "one declaration mounted twice by a YAML alias", - spec: `openapi: 3.2.0 -info: {title: T, version: "1"} -servers: - - &s - url: https://prod.example.com - name: prod - - *s -paths: {} -`, - want: []string{"/servers/1/name"}, - servers: []string{"prod", "prod"}, - }, - { - name: "one name carried into two entries by a merge key", - spec: `openapi: 3.2.0 -info: {title: T, version: "1"} -x-base: &b {name: prod} -servers: - - {<<: *b, url: https://a.example.com} - - {<<: *b, url: https://b.example.com} -paths: {} -`, - want: []string{"/servers/1/name"}, - servers: []string{"prod", "prod"}, - }, - { - name: "a 3.1 document, whose servers lower with the name it wrote", - spec: `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: []string{"/servers/1/name"}, - servers: []string{"prod", "prod"}, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, tc.spec) - - assert.Equal(t, len(tc.want), - openapitest.CountDiagsAt(diags, diag.DuplicateServerName, ir.SeverityWarning), - "one diagnostic per repeat, not one per name: %+v", diags) - var got []string - for _, d := range diags { - if d.Code == diag.DuplicateServerName { - got = append(got, string(d.Provenance.Pointer)) - } - } - assert.Equal(t, tc.want, got, "each repeat sits at its own name key, in source order") - for _, ptr := range tc.want { - assert.Equal(t, dupServerNameMessage, - openapitest.DiagMessageAt(t, diags, diag.DuplicateServerName, ir.SeverityWarning, ptr), - "%s names the repeated name and the first claimant's key", ptr) - } - - names := make([]string, 0, len(doc.Servers)) - for _, s := range doc.Servers { - names = append(names, s.Name.Source) - } - assert.Equal(t, tc.servers, names, "every server lowered, each with the name the document wrote") - }) - } -} - -// TestDuplicateServerName_NilEntryKeepsSourceIndices pins that the pointers carry -// *source* indices, so with a skipped entry ahead of them the repeat at index 2 -// names the claimant at index 1 rather than the first surviving server. The -// document is hand-built for the same reason TestLowerServers_NilEntrySkipped's -// is: a nil entry never reaches lowering through the parser. -func TestDuplicateServerName_NilEntryKeepsSourceIndices(t *testing.T) { - t.Parallel() - prod := "prod" - doc := &soa.OpenAPI{Servers: []*soa.Server{ - nil, - {URL: "https://prod.example.com", Name: &prod}, - {URL: "https://prod-mirror.example.com", Name: &prod}, - }} - - got, diags := lowerServers(lowering.Ctx{Doc: doc}) - - require.Len(t, got, 2, "the nil entry is skipped and both real servers still lower") - assert.Equal(t, []string{"prod", "prod"}, []string{got[0].Name.Source, got[1].Name.Source}) +`) require.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.DuplicateServerName, ir.SeverityWarning), - "one report at the repeat: %+v", diags) - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DuplicateServerName, "/servers/2/name"), - "the repeat sits at its source index: %+v", diags) + "one report: %+v", diags) assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DuplicateServerName, ir.SeverityWarning, "/servers/2/name"), - "/servers/1/name", "and names the first claimant at its source index") + 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_NotReported is the tolerated half: the collisions the -// rule deliberately ignores. Declared names are compared only to other declared -// names and only as exact strings, so a hint colliding with a hint, a declared -// name equal to another entry's hint, two names equal only once canonicalized, -// and two empty names are all silent — none of them is a string the document -// declared twice. -func TestDuplicateServerName_NotReported(t *testing.T) { +// 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 @@ -397,18 +274,6 @@ func TestDuplicateServerName_NotReported(t *testing.T) { // collision really happens rather than by passing vacuously. collide func(a, b ir.Naming) (string, string) }{ - { - name: "distinct declared names", - spec: `openapi: 3.2.0 -info: {title: T, version: "1"} -servers: - - url: https://prod.example.com - name: prod - - url: https://staging.example.com - name: staging -paths: {} -`, - }, { name: "two unnamed servers whose URL hints collide", spec: `openapi: 3.1.0 @@ -458,18 +323,6 @@ paths: {} `, collide: func(a, b ir.Naming) (string, string) { return a.Canonical, b.Canonical }, }, - { - name: "an empty name is not a name", - spec: `openapi: 3.2.0 -info: {title: T, version: "1"} -servers: - - url: https://a.example.com - name: "" - - url: https://b.example.com - name: "" -paths: {} -`, - }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -478,9 +331,6 @@ paths: {} assert.False(t, openapitest.HasDiag(diags, diag.DuplicateServerName), "nothing is reported: %+v", diags) - if tc.collide == nil { - return - } 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") diff --git a/compilers/openapi/overlay_test.go b/compilers/openapi/overlay_test.go index 7b7f1543..4919d36e 100644 --- a/compilers/openapi/overlay_test.go +++ b/compilers/openapi/overlay_test.go @@ -63,18 +63,21 @@ func compileWith(t *testing.T, opts openapi.Options) (*ir.Document, []ir.Diagnos return doc, diags } -// TestCompile_OverlayMakingServerNamesCollideCreditsTheOverlay pins the +// TestCompile_OverlayServerNameRepeatsAreCreditedToTheirWriter pins the // duplicate-server-name rule against the tree the overlay produced rather than -// the bytes on disk: each base document declares no repeat, so only the patched -// document has one to report. The report names the overlay as its source, at -// the entry the overlay wrote, whether that entry comes after the base's -// claimant (an appended item) or before it (an earlier entry renamed); when the -// overlay wrote both, at the later one, as in a single document. -func TestCompile_OverlayMakingServerNamesCollideCreditsTheOverlay(t *testing.T) { +// 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 - at, other string // the reported name key and the one its message names + want []report names []string // each lowered server's declared name }{ { @@ -94,17 +97,19 @@ actions: - url: https://prod-mirror.example.com name: prod `, - at: "/servers/1/name", other: "/servers/0/name", + want: []report{{at: "/servers/1/name", first: "/servers/0/name", source: 1}}, names: []string{"prod", "prod"}, }, { - name: "the overlay renames an entry ahead of the base's claimant", + 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://prod.example.com + - url: https://a.example.com + name: prod + - url: https://b.example.com name: prod paths: {} `, @@ -114,8 +119,11 @@ actions: - target: $.servers[0] update: {name: prod} `, - at: "/servers/0/name", other: "/servers/1/name", - names: []string{"prod", "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", @@ -134,7 +142,7 @@ actions: - {url: https://a.example.com, name: prod} - {url: https://b.example.com, name: prod} `, - at: "/servers/2/name", other: "/servers/1/name", + want: []report{{at: "/servers/2/name", first: "/servers/1/name", source: 1}}, names: []string{"dev", "prod", "prod"}, }, } @@ -149,12 +157,17 @@ actions: require.NoError(t, err) require.NotNil(t, doc, "compile refused: %+v", diags) - found := diagnosticsByCodeAndPointer(diags)[diagKey{diag.DuplicateServerName, jsontext.Pointer(tc.at)}] - require.Len(t, found, 1, "one report, at the entry the overlay wrote: %+v", diags) - assert.Equal(t, ir.SeverityWarning, found[0].Severity) - assert.Equal(t, 1, found[0].Provenance.Source, - "the overlay made the names collide, so it is the source the report is against") - assert.Contains(t, found[0].Message, tc.other, "and the message names the other claimant") + 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 { diff --git a/docs/ir-design.md b/docs/ir-design.md index b44c5e24..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, with a declared name the list repeats — compared as exact strings, on every version since the parser reads `name` on each — lowered as declared on both servers + `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) | +| **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 index 5f2dc891..9facdc64 100644 --- a/testdata/openapi/duplicate_server_names.yaml +++ b/testdata/openapi/duplicate_server_names.yaml @@ -1,8 +1,10 @@ -# Every way a servers list repeats a declared name: a second entry writing it -# again, an alias mounting the first entry twice, and a merge key carrying one -# name into two entries. Each raises only the openapi/duplicate-server-name -# warning, and this fixture puts that warning under the corpus order-invariance -# sweep. The last entry differs from the first only in case, which is tolerated. +# 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} @@ -13,4 +15,13 @@ servers: - {<<: *b, url: https://edge-a.example.com} - {<<: *b, url: https://edge-b.example.com} - {url: https://staging.example.com, name: Prod} -paths: {} +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}}