Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions compilers/openapi/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ func conformanceCases() []conformanceCase {
{"dynamic-ref", assertDynamicRef, nil},
{"enum-string", assertEnumString, []string{"enums-string"}},
{"enum-numeric", assertEnumNumeric, []string{"enums-numeric"}},
{"empty-enum", assertEmptyEnum, nil},
{"scalar-format", assertScalarFormat, []string{"custom-scalars"}},
{"encoding-byte", assertEncodingByte, []string{"encoding-hints"}},
{"content-vocabulary", assertContentVocabulary, []string{"encoding-hints"}},
Expand Down Expand Up @@ -956,6 +957,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)
Expand Down
15 changes: 15 additions & 0 deletions compilers/openapi/internal/diag/diag.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,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).
Expand Down
2 changes: 1 addition & 1 deletion compilers/openapi/internal/diag/diag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ func codes() []string {
diag.CycleScanFailed, diag.SourceTooLarge, diag.UndecodableSource,
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.InvalidMethodKey, diag.DegradedConstruct,
diag.CompositionLowering, diag.DynamicRefExpanded, diag.ConflictingRedecl,
Expand Down
51 changes: 49 additions & 2 deletions compilers/openapi/internal/schema/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,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 {
Expand Down Expand Up @@ -1045,7 +1045,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 member set past the caller's budget is the one case where something is: the
// enum lowers as the top type with an error diagnostic naming the budget. The
Expand Down Expand Up @@ -1077,6 +1078,15 @@ 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 {
// The degenerate list first, then the budget. They cannot both hold — a
// member count of zero exceeds no positive budget — so the order decides
// nothing, but reading the empty case beside the members it lacks is
// clearer than reading it after a bound on how many there may be.
if len(s.GetEnum()) == 0 {
def, emptyDiags := emptyEnum(c, s, common, pointer)
diags = append(diags, emptyDiags...)
return def
}
if n := len(s.GetEnum()); c.Limits.EnumMembersExceeded(n) {
diags = append(diags, c.DiagAt(ir.SeverityError, diag.BudgetExceeded, pointer,
"enum declares %d members, past the %d-member budget; lowered as any",
Expand All @@ -1099,6 +1109,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.
Expand Down
128 changes: 128 additions & 0 deletions compilers/openapi/internal/schema/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,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, openapitest.ComponentSpec(tc.schema+"\n"))
openapitest.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, openapitest.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 := openapitest.ComponentSpec(` S:
enum: []
oneOf: [{type: string}, {type: integer}]
`)
doc, diags := lowerSpec(t, spec)
openapitest.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, openapitest.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 := openapitest.ComponentSpec(` K:
Expand Down
21 changes: 19 additions & 2 deletions compilers/openapi/internal/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,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 {
Expand Down Expand Up @@ -507,14 +507,31 @@ 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:
return false
}
}

// 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.
Expand Down
5 changes: 5 additions & 0 deletions docs/ir-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,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
Expand Down
Loading
Loading