From b2e30f56a64e0eb546bf483556e696052754527b Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 9 Aug 2026 04:10:24 +0300 Subject: [PATCH] fix(compilers/openapi): stop an empty enum widening the type --- compilers/openapi/conformance_test.go | 55 +++ compilers/openapi/internal/diag/diag.go | 15 + compilers/openapi/internal/diag/diag_test.go | 2 +- compilers/openapi/internal/schema/compose.go | 47 +- .../openapi/internal/schema/compose_test.go | 128 ++++++ compilers/openapi/internal/schema/schema.go | 21 +- docs/ir-design.md | 5 + .../openapi/empty-enum.golden.json | 402 ++++++++++++++++++ testdata/conformance/openapi/empty-enum.yaml | 26 ++ 9 files changed, 696 insertions(+), 5 deletions(-) create mode 100644 testdata/conformance/openapi/empty-enum.golden.json create mode 100644 testdata/conformance/openapi/empty-enum.yaml diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index 17ae7d9..935ad60 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -160,6 +160,7 @@ func conformanceCases() []conformanceCase { {"dynamic-ref", assertDynamicRef}, {"enum-string", assertEnumString}, {"enum-numeric", assertEnumNumeric}, + {"empty-enum", assertEmptyEnum}, {"scalar-format", assertScalarFormat}, {"encoding-byte", assertEncodingByte}, {"content-vocabulary", assertContentVocabulary}, @@ -814,6 +815,60 @@ func assertEnumNumeric(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { assert.Equal(t, ir.BigVal("9007199254740993"), e.Members[1].Value.Num) } +// assertEmptyEnum covers `enum: []`: legal JSON Schema whose value space holds +// no member, so the position it is written at accepts no instance. The +// capability claimed is that the IR says that exactly rather than approximating +// it — a closed Enum admits its members and nothing else, so a closed Enum with +// none admits nothing. +// +// It used to say the opposite. The keyword was read off `len(enum) > 0`, which +// cannot tell an empty member list from an absent one, so the position widened +// to whatever its siblings admitted — the top type where nothing else was +// written — reporting nothing and keeping nothing (GitHub #278). +// +// Holder's `colour` is in the corpus for the same reason the empty spellings +// are: a populated enum must go on lowering as it did, so this reddens for a +// change reaching every enum rather than the degenerate one. +func assertEmptyEnum(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + empty := map[string]ir.PrimKind{ + "Nothing": ir.PrimString, + "Bare": ir.PrimAny, + "BesideProperties": ir.PrimAny, + "BesideAllOf": ir.PrimAny, + } + for name, valueType := range empty { + e, ok := doc.Types[namedID(name)].(*ir.Enum) + require.True(t, ok, "%s lowers to an Enum, got %T", name, doc.Types[namedID(name)]) + assert.True(t, e.Closed, "%s admits its members and nothing else", name) + assert.Empty(t, e.Members, "%s declares no member", name) + assert.Equal(t, valueType, e.ValueType, + "%s takes the declared scalar type where one is written", name) + assert.Equal(t, []ir.Severity{ir.SeverityWarning}, + diagsAt(diags, "openapi/empty-enum", "/components/schemas/"+name), + "%s is reported once, since nobody writes an empty member list on purpose", name) + } + + // An Enum has no home for a property set or a composition, so what the empty + // enum is written beside stays verbatim rather than being traded for it. + props := unmodeledEntry(t, doc.Types[namedID("BesideProperties")].Common().Unmodeled, "openapi:properties") + assert.Equal(t, ir.ReasonDegradedLowering, props.Reason) + assert.JSONEq(t, `{"x":{"type":"string"}}`, string(props.Value)) + composed := unmodeledEntry(t, doc.Types[namedID("BesideAllOf")].Common().Unmodeled, "openapi:allOf") + assert.Equal(t, ir.ReasonDegradedLowering, composed.Reason) + assert.JSONEq(t, `[{"$ref":"#/components/schemas/Base"}]`, string(composed.Value)) + + holder, ok := doc.Types[namedID("Holder")].(*ir.Model) + require.True(t, ok) + colour, ok := propByWire(holder, "colour") + require.True(t, ok) + populated, ok := doc.Types[colour.Type.Target].(*ir.Enum) + require.True(t, ok, "a populated enum beside the degenerate ones is unaffected") + require.Len(t, populated.Members, 2) + assert.Equal(t, ir.PrimString, populated.ValueType) + assert.Empty(t, diagsAt(diags, "openapi/empty-enum", "/components/schemas/Holder/properties/colour"), + "and reports nothing") +} + func assertScalarFormat(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { h, ok := doc.Types[namedID("Holder")].(*ir.Model) require.True(t, ok) diff --git a/compilers/openapi/internal/diag/diag.go b/compilers/openapi/internal/diag/diag.go index 5902708..9271b5e 100644 --- a/compilers/openapi/internal/diag/diag.go +++ b/compilers/openapi/internal/diag/diag.go @@ -69,6 +69,21 @@ const ( ValidationOnlyKeyword = "openapi/validation-only-keyword" // FalseSchema reports a boolean `false` schema (matches nothing). FalseSchema = "openapi/false-schema" + // EmptyEnum reports an `enum` whose member list is empty. JSON Schema allows + // it and gives it a meaning — the value space holds no member, so the + // position accepts no instance at all — which the IR states exactly, as a + // closed Enum with no members. + // + // Warning rather than info, and the split from FalseSchema beside it is the + // reason. A boolean `false` schema is the idiom for "forbid this here", so + // announcing the lowering is all a reader needs; an empty member list is the + // same statement written the way nobody writes it on purpose, and it is what + // a generator emitting a list it never filled produces. Every position + // reaching it is uncallable, so the document is told rather than merely + // recorded. Not an error: the document is well-formed, and harness.Check + // stops at the first error diagnostic, which would hide every later finding + // in the same spec. + EmptyEnum = "openapi/empty-enum" // NumericPrecision reports a numeric bound literal that is not a finite // number (error severity: Morphic owns these keywords, so this is the sole // diagnostic for the defect — see boundLiteralDiag). diff --git a/compilers/openapi/internal/diag/diag_test.go b/compilers/openapi/internal/diag/diag_test.go index 26e351a..8aedda8 100644 --- a/compilers/openapi/internal/diag/diag_test.go +++ b/compilers/openapi/internal/diag/diag_test.go @@ -130,7 +130,7 @@ func codes() []string { diag.Validation, diag.UnsupportedVersion, diag.UnresolvedRef, diag.CyclicRef, diag.CycleScanFailed, diag.OverlayInvalid, diag.OverlayFailed, diag.OverlayAction, diag.OverlayOriginIncomplete, - diag.ValidationOnlyKeyword, diag.FalseSchema, + diag.ValidationOnlyKeyword, diag.FalseSchema, diag.EmptyEnum, diag.NumericPrecision, diag.ExclusiveBoundForm, diag.InvalidStatusKey, diag.DegradedConstruct, diag.CompositionLowering, diag.DynamicRefExpanded, diag.ConflictingRedecl, diff --git a/compilers/openapi/internal/schema/compose.go b/compilers/openapi/internal/schema/compose.go index e4d93ed..47f6172 100644 --- a/compilers/openapi/internal/schema/compose.go +++ b/compilers/openapi/internal/schema/compose.go @@ -601,7 +601,7 @@ func diagUnresolvedBranches(c lowering.Ctx, s *oas3.Schema, pointer string) []ir // across. It mirrors lower's dispatch: const and enum win over everything, then // allOf, then a declared object type or a bare property set. func composesAsModel(s *oas3.Schema) bool { - if s.GetConst() != nil || len(s.GetEnum()) > 0 { + if s.GetConst() != nil || enumWritten(s) { return false } if len(s.GetAllOf()) > 0 { @@ -1012,7 +1012,8 @@ func propIDByName(m *ir.Model, name string) (ir.PropID, bool) { // lowerEnum hoists a schema with `enum` as a closed Enum. A heterogeneous or // non-scalar member set has no Enum home, so it falls back to a Union of -// Literals with an info diagnostic — nothing is dropped. +// Literals with an info diagnostic — nothing is dropped. An empty member list +// takes neither path (emptyEnum). // // A schema that admits null in its own right — `type: [T, "null"]`, 3.0 // `nullable: true` — spells its nullable enum by listing `null` among the @@ -1036,6 +1037,11 @@ func propIDByName(m *ir.Model, name string) (ir.PropID, bool) { func lowerEnum(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, pointer, hint string) (ir.TypeID, []ir.Diagnostic) { var diags []ir.Diagnostic id := internNode(c, ts, pointer, hint, func(common ir.TypeCommon) ir.TypeDef { + if len(s.GetEnum()) == 0 { + def, emptyDiags := emptyEnum(c, s, common, pointer) + diags = append(diags, emptyDiags...) + return def + } members, memberPrim, ok := enumMembers(s.GetEnum(), schemaAdmitsNull(s)) if !ok { def, enumDiags := enumAsUnion(c, ts, s, common, pointer, hint) @@ -1052,6 +1058,43 @@ func lowerEnum(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, pointer, hint return id, diags } +// emptyEnum lowers `enum: []` — a value space fixed to the empty set, so the +// position accepts no instance — as the closed Enum over no member, with the one +// warning that says so. +// +// This is the IR's exact spelling of an empty value space, not an approximation +// of one. There is no bottom TypeKind to reach for, but a closed Enum admits its +// members and nothing else, so a closed Enum with none admits nothing. The +// neighbouring construct settles for less: a boolean `false` schema also matches +// nothing and lowers to a closed empty Model (falseSchema), which still admits +// the empty object. +// +// It deliberately does not reach enumAsUnion. That fallback mints one variant +// per member, so an empty member list would produce a Union with no variants — +// a node nothing in the IR rejects and no reader can act on, which is a quieter +// version of the same defect rather than a fix for it (GitHub #318). +// +// ValueType is the declared scalar type where one is written and the top type +// otherwise: with no member to classify, nothing narrower is known, and nothing +// narrower is needed either — the member list is what holds the values, and it +// is empty whatever this says. +// +// What the position lowers to is settled here; whether a *reference* to it +// admits null is not. That bit is computed by schemaAdmitsNull at each use site, +// which reads the type keyword and not the value set, so +// `{type: [T, "null"], enum: []}` still reads as nullable at its uses — a +// nullable type array beside an enum listing no null member, which is GitHub +// #288's shape exactly and is settled there rather than here. +func emptyEnum(c lowering.Ctx, s *oas3.Schema, common ir.TypeCommon, pointer string) (ir.TypeDef, []ir.Diagnostic) { + diags := []ir.Diagnostic{c.DiagAt(ir.SeverityWarning, diag.EmptyEnum, pointer, + "enum declares no member, so this position accepts no value; lowered as a closed enum with no members")} + return &ir.Enum{ + TypeCommon: common, + ValueType: enumValueType(s, ir.PrimAny), + Closed: true, + }, diags +} + // enumMembers converts enum nodes into scalar members, reporting ok=false on any // of three: a member that is non-scalar, members heterogeneous in kind, or a set // that keeps no member at all. diff --git a/compilers/openapi/internal/schema/compose_test.go b/compilers/openapi/internal/schema/compose_test.go index a44e5d0..ad95f82 100644 --- a/compilers/openapi/internal/schema/compose_test.go +++ b/compilers/openapi/internal/schema/compose_test.go @@ -1142,6 +1142,134 @@ func TestEnum_NullMemberKeepsUnionFallback(t *testing.T) { } } +// TestEnum_EmptyMemberListMatchesNoValue pins what `enum: []` lowers to. It is +// legal JSON Schema and it matches no instance, so the position it is written at +// accepts nothing — the narrowest statement a schema can make. +// +// It used to make the widest one instead. The family election read the keyword +// off `len(enum) > 0`, which cannot tell an empty member list from an absent +// one, so the enum was neither lowered nor recorded and the position widened to +// whatever its siblings admitted: the top type where nothing else was written, +// the declared type where something was. Both channels were silent — no +// diagnostic, nothing under Unmodeled (GitHub #278). +// +// The rows are the positions the keyword can sit at, because the widening +// followed the siblings rather than the enum: a type keyword, a property set, a +// composition, a nullable type array. The last row is the control — a populated +// enum must keep lowering exactly as it did, so a fix reaching every enum rather +// than the empty one fails here. +func TestEnum_EmptyMemberListMatchesNoValue(t *testing.T) { + t.Parallel() + cases := []struct { + name, schema string + wantValueType ir.PrimKind + wantMembers int + wantEmpty bool + wantKept string + }{ + { + name: "bare", + schema: ` S: {enum: []}`, + wantValueType: ir.PrimAny, + wantEmpty: true, + }, + { + name: "beside a type keyword", + schema: ` S: {type: string, enum: []}`, + wantValueType: ir.PrimString, + wantEmpty: true, + }, + { + name: "beside a property set", + schema: ` S: + type: object + properties: {x: {type: string}} + enum: []`, + wantValueType: ir.PrimAny, + wantEmpty: true, + wantKept: "openapi:properties", + }, + { + name: "beside an allOf", + schema: ` Base: {type: object, properties: {x: {type: string}}} + S: + allOf: [{$ref: '#/components/schemas/Base'}] + enum: []`, + wantValueType: ir.PrimAny, + wantEmpty: true, + wantKept: "openapi:allOf", + }, + { + name: "beside a nullable type array", + schema: ` S: {type: [string, "null"], enum: []}`, + wantValueType: ir.PrimString, + wantEmpty: true, + }, + { + name: "a populated enum is untouched", + schema: ` S: {type: string, enum: [red, green]}`, + wantValueType: ir.PrimString, + wantMembers: 2, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + doc, diags := lowerSpec(t, componentSpec(tc.schema+"\n")) + requireNoErrorDiags(t, diags) + + e, ok := typeByName(doc, "S").(*ir.Enum) + require.True(t, ok, "the enum keyword lowers to an Enum, got %T", typeByName(doc, "S")) + assert.True(t, e.Closed, "an enum admits its members and nothing else") + assert.Len(t, e.Members, tc.wantMembers, "the member list is the declared one") + assert.Equal(t, tc.wantValueType, e.ValueType) + + want := 0 + if tc.wantEmpty { + want = 1 + } + assert.Equal(t, want, countDiagsAt(diags, diag.EmptyEnum, ir.SeverityWarning), + "a value space with no member is reported once; got %+v", diags) + + if tc.wantKept == "" { + return + } + assert.Contains(t, e.Unmodeled, tc.wantKept, + "the keyword the election passed over stays beside the enum") + }) + } +} + +// TestEnum_EmptyMemberListIsAUnionSibling pins the same keyword at the one +// position that reads it through a different predicate. A oneOf/anyOf can be the +// whole type only when nothing structural is written beside it, and an empty +// enum is a value set like any other — so the union conjoins with a set holding +// no member, and lowering the union alone would state the opposite of what the +// source says. +// +// declaresShape and composesAsModel each spelled the same `len(enum) > 0` test +// as the family election did, which is why this is a second case rather than a +// second row: the first reaches lower()'s dispatch and this one does not reach +// it at all. +func TestEnum_EmptyMemberListIsAUnionSibling(t *testing.T) { + t.Parallel() + spec := componentSpec(` S: + enum: [] + oneOf: [{type: string}, {type: integer}] +`) + doc, diags := lowerSpec(t, spec) + requireNoErrorDiags(t, diags) + + e, ok := typeByName(doc, "S").(*ir.Enum) + require.True(t, ok, "the empty value set is the shape the position lowers to, got %T", + typeByName(doc, "S")) + assert.Empty(t, e.Members) + assert.Equal(t, 1, countDiagsAt(diags, diag.EmptyEnum, ir.SeverityWarning), + "reported once here too; got %+v", diags) + assert.Contains(t, e.Unmodeled, "openapi:oneOf", + "the union the enum cannot carry stays beside it rather than replacing it") +} + func TestConst_BecomesLiteral(t *testing.T) { t.Parallel() spec := componentSpec(` K: diff --git a/compilers/openapi/internal/schema/schema.go b/compilers/openapi/internal/schema/schema.go index d06d1eb..f962b77 100644 --- a/compilers/openapi/internal/schema/schema.go +++ b/compilers/openapi/internal/schema/schema.go @@ -357,7 +357,7 @@ func declaresShape(s *oas3.Schema) bool { if props := s.GetProperties(); props != nil && props.Len() > 0 { return true } - if s.GetConst() != nil || len(s.GetEnum()) > 0 || len(s.GetAllOf()) > 0 { + if s.GetConst() != nil || enumWritten(s) || len(s.GetAllOf()) > 0 { return true } if s.GetAdditionalProperties() != nil { @@ -454,7 +454,7 @@ func declaresFamily(s *oas3.Schema, family string) bool { case "const": return s.GetConst() != nil case "enum": - return len(s.GetEnum()) > 0 + return enumWritten(s) case "allOf": return len(s.GetAllOf()) > 0 default: @@ -462,6 +462,23 @@ func declaresFamily(s *oas3.Schema, family string) bool { } } +// enumWritten reports whether s writes `enum` at all, an empty member list +// included. The three predicates that read the keyword — this family guard, +// declaresShape and composesAsModel — each spelled it `len(...) > 0`, which +// cannot tell `enum: []` from no enum keyword at all, so the degenerate spelling +// was elected by none of them and preserved by none of them either. The position +// then widened to whatever its siblings admitted, in silence (GitHub #278). +// +// `enum: []` is legal JSON Schema and it fixes the value space to the empty set, +// so it declares one exactly as a populated list does. Nilness is the +// distinction the parser keeps: an absent keyword leaves the field nil, an empty +// list leaves it non-nil and empty. A member list the model layer could not +// parse at all reads as absent here, which is a document the loader already +// refuses. +func enumWritten(s *oas3.Schema) bool { + return s.GetEnum() != nil +} + // dispatch records how lower() resolved a schema's competing keyword families: // the one it lowered, and the ones it passed over. won is "" when the schema // declares none of them and the type set decides the lowering instead. diff --git a/docs/ir-design.md b/docs/ir-design.md index a72ceea..b776c92 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -492,6 +492,11 @@ bit must survive to that point (Kiota's string-only closed enums are the counter Duplicate member values are legal (protobuf `allow_alias`); slice order preserves which name is canonical for serialization, and the validate pass must not reject them. +A **closed** Enum with **no members** is the empty value space — it admits its members and has +none — so it is how a compiler states a position that accepts no instance at all (JSON Schema's +`enum: []`) without a bottom `TypeKind`. Emitters render it as their uninhabited type where they +have one (TypeScript `never`); none may widen it to the top type. + ### 4.6 Containers and the rest ```go diff --git a/testdata/conformance/openapi/empty-enum.golden.json b/testdata/conformance/openapi/empty-enum.golden.json new file mode 100644 index 0000000..a3cd423 --- /dev/null +++ b/testdata/conformance/openapi/empty-enum.golden.json @@ -0,0 +1,402 @@ +{ + "irVersion": "0.3.0", + "name": "EmptyEnum", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "EmptyEnum", + "canonical": "empty_enum" + }, + "docs": {}, + "auth": null, + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/anon/components/schemas/Holder/properties/colour": { + "kind": "enum", + "id": "t/anon/components/schemas/Holder/properties/colour", + "name": { + "hint": "colour" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Holder/properties/colour" + }, + "valueType": "string", + "members": [ + { + "name": { + "source": "red", + "canonical": "red" + }, + "value": { + "kind": "string", + "str": "red", + "bytes": null, + "list": null, + "object": null + }, + "docs": {} + }, + { + "name": { + "source": "green", + "canonical": "green" + }, + "value": { + "kind": "string", + "str": "green", + "bytes": null, + "list": null, + "object": null + }, + "docs": {} + } + ], + "closed": true, + "flags": false + }, + "t/openapi/components/schemas/Bare": { + "kind": "enum", + "id": "t/openapi/components/schemas/Bare", + "name": { + "source": "Bare", + "canonical": "bare" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Bare" + }, + "valueType": "any", + "closed": true, + "flags": false + }, + "t/openapi/components/schemas/Base": { + "kind": "model", + "id": "t/openapi/components/schemas/Base", + "name": { + "source": "Base", + "canonical": "base" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Base" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Base/properties/y", + "name": { + "source": "y", + "canonical": "y" + }, + "wireName": "y", + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Base/properties/y" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/BesideAllOf": { + "kind": "enum", + "id": "t/openapi/components/schemas/BesideAllOf", + "name": { + "source": "BesideAllOf", + "canonical": "beside_all_of" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:allOf": { + "reason": "degraded_lowering", + "value": [ + { + "$ref": "#/components/schemas/Base" + } + ], + "provenance": { + "source": 0, + "pointer": "/components/schemas/BesideAllOf/allOf" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/BesideAllOf" + }, + "valueType": "any", + "closed": true, + "flags": false + }, + "t/openapi/components/schemas/BesideProperties": { + "kind": "enum", + "id": "t/openapi/components/schemas/BesideProperties", + "name": { + "source": "BesideProperties", + "canonical": "beside_properties" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:properties": { + "reason": "degraded_lowering", + "value": { + "x": { + "type": "string" + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/BesideProperties/properties" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/BesideProperties" + }, + "valueType": "any", + "closed": true, + "flags": false + }, + "t/openapi/components/schemas/Holder": { + "kind": "model", + "id": "t/openapi/components/schemas/Holder", + "name": { + "source": "Holder", + "canonical": "holder" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Holder" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Holder/properties/nothing", + "name": { + "source": "nothing", + "canonical": "nothing" + }, + "wireName": "nothing", + "type": { + "target": "t/openapi/components/schemas/Nothing", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Holder/properties/nothing" + } + }, + { + "id": "p/openapi/components/schemas/Holder/properties/bare", + "name": { + "source": "bare", + "canonical": "bare" + }, + "wireName": "bare", + "type": { + "target": "t/openapi/components/schemas/Bare", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Holder/properties/bare" + } + }, + { + "id": "p/openapi/components/schemas/Holder/properties/colour", + "name": { + "source": "colour", + "canonical": "colour" + }, + "wireName": "colour", + "type": { + "target": "t/anon/components/schemas/Holder/properties/colour", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Holder/properties/colour" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/Nothing": { + "kind": "enum", + "id": "t/openapi/components/schemas/Nothing", + "name": { + "source": "Nothing", + "canonical": "nothing" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Nothing" + }, + "valueType": "string", + "closed": true, + "flags": false + }, + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0 + }, + "prim": "string" + } + }, + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {}, + "auth": null + } + ], + "diagnostics": [ + { + "severity": "warning", + "code": "openapi/empty-enum", + "message": "enum declares no member, so this position accepts no value; lowered as a closed enum with no members", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Nothing" + } + }, + { + "severity": "warning", + "code": "openapi/empty-enum", + "message": "enum declares no member, so this position accepts no value; lowered as a closed enum with no members", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Bare" + } + }, + { + "severity": "warning", + "code": "openapi/empty-enum", + "message": "enum declares no member, so this position accepts no value; lowered as a closed enum with no members", + "provenance": { + "source": 0, + "pointer": "/components/schemas/BesideProperties" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "this position lowered to a node of kind \"enum\", which has no home for properties declared beside it; kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/BesideProperties" + } + }, + { + "severity": "warning", + "code": "openapi/empty-enum", + "message": "enum declares no member, so this position accepts no value; lowered as a closed enum with no members", + "provenance": { + "source": 0, + "pointer": "/components/schemas/BesideAllOf" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "this position declares allOf beside its enum, and JSON Schema conjoins them where only one can be the value; it lowered as the enum, with allOf kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/BesideAllOf" + } + } + ], + "sources": [ + { + "format": "openapi@3.1", + "path": "empty-enum.yaml", + "hash": "ef67ef043c8831c60c30eb6be0a435cc09eed28394f0497006dfb6a3454f1ec9" + } + ] +} diff --git a/testdata/conformance/openapi/empty-enum.yaml b/testdata/conformance/openapi/empty-enum.yaml new file mode 100644 index 0000000..34e64cd --- /dev/null +++ b/testdata/conformance/openapi/empty-enum.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: {title: EmptyEnum, version: "1.0.0"} +paths: {} +components: + schemas: + Nothing: + type: string + enum: [] + Bare: + enum: [] + BesideProperties: + type: object + properties: {x: {type: string}} + enum: [] + Base: + type: object + properties: {y: {type: string}} + BesideAllOf: + allOf: [{$ref: '#/components/schemas/Base'}] + enum: [] + Holder: + type: object + properties: + nothing: {$ref: '#/components/schemas/Nothing'} + bare: {$ref: '#/components/schemas/Bare'} + colour: {type: string, enum: [red, green]}