From 00f7719eb056cd17673bafb110eab5d18bfbe65d Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Fri, 9 Oct 2026 18:32:42 +0300 Subject: [PATCH 01/13] fix(compilers/openapi): keep discriminators unions cannot place A discriminated union kept verbatim beside a model could leave the model carrying a discriminator that names targets which are not its subtypes, so a valid document failed validation. Separately, a discriminator beside a union that collapsed to a nullable reference, a null-only branch set, a union beside a non-model body, or a union beside a $ref was dropped silently. The discriminator now stays on the model only when every target it routes to (mapping values and defaultMapping, or the union branches when no mapping entry resolves) is a subtype along the allOf base chain. The check reads the raw source schemas, not the registry, so a subtype declared later cannot change the answer. Otherwise, and at every site whose node has no field for it, the whole object is kept verbatim in Unmodeled["openapi:discriminator"] with an info diagnostic, through one shared helper. Co-Authored-By: Claude Sonnet 5.5 --- compilers/openapi/conformance_test.go | 44 ++ .../schema/collapse_discriminator_test.go | 228 +++++++++ compilers/openapi/internal/schema/compose.go | 11 +- .../internal/schema/compose_internal_test.go | 12 + .../openapi/internal/schema/discriminated.go | 172 +++++++ .../internal/schema/discriminated_test.go | 207 +++++++++ compilers/openapi/internal/schema/resolve.go | 5 +- compilers/openapi/internal/schema/schema.go | 20 +- docs/ir-design.md | 18 +- ...scriminated-union-beside-model.golden.json | 435 ++++++++++++++++++ .../discriminated-union-beside-model.yaml | 50 ++ .../nullable-union-discriminator.golden.json | 209 +++++++++ .../openapi/nullable-union-discriminator.yaml | 30 ++ 13 files changed, 1434 insertions(+), 7 deletions(-) create mode 100644 compilers/openapi/internal/schema/collapse_discriminator_test.go create mode 100644 compilers/openapi/internal/schema/discriminated.go create mode 100644 compilers/openapi/internal/schema/discriminated_test.go create mode 100644 testdata/conformance/openapi/discriminated-union-beside-model.golden.json create mode 100644 testdata/conformance/openapi/discriminated-union-beside-model.yaml create mode 100644 testdata/conformance/openapi/nullable-union-discriminator.golden.json create mode 100644 testdata/conformance/openapi/nullable-union-discriminator.yaml diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index 609745e1..8a2b4da2 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -24,6 +24,7 @@ import ( "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/ir" "github.com/dexpace/morphic/ir/irtest" + "github.com/dexpace/morphic/pass" ) // conformanceDir is the corpus of one minimal spec per capability row of @@ -163,6 +164,8 @@ func conformanceCases() []conformanceCase { {"discriminator-default-mapping", assertDiscriminatorDefaultMapping, []string{"tagged-unions"}}, {"discriminator-transitive", assertDiscriminatorTransitive, []string{"tagged-unions", "inheritance"}}, {"discriminator-alias-mapping", assertDiscriminatorAliasMapping, []string{"tagged-unions"}}, + {"discriminated-union-beside-model", assertDiscriminatedUnionBesideModel, []string{"tagged-unions", "inheritance"}}, + {"nullable-union-discriminator", assertNullableUnionDiscriminator, []string{"tagged-unions", "optionality-vs-nullability"}}, {"unhomed-keywords", assertUnhomedKeywords, nil}, {"union-beside-ref", assertUnionBesideRef, []string{"untagged-unions"}}, {"codeclared-keywords", assertCoDeclaredKeywords, []string{"intersection", "literal-types", "enums-string"}}, @@ -1016,6 +1019,47 @@ func assertDiscriminatorDefaultMapping(t *testing.T, doc *ir.Document, diags []i "and it is read separately from the mapping") } +// assertDiscriminatedUnionBesideModel pins where a discriminator goes when its +// union is kept verbatim beside a model. Pet's targets are not subtypes of it, +// so pass.Validate would reject the discriminator on the model; it moves whole +// to Unmodeled beside the union. Shape's targets are true allOf subtypes, so +// the model keeps it, and the document carries no validation error. +func assertDiscriminatedUnionBesideModel(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + assert.Empty(t, pass.Validate(doc)) + + pet, ok := doc.Types[namedID("Pet")].(*ir.Model) + require.True(t, ok) + assert.Nil(t, pet.Discriminator, "its targets are not subtypes, so the model does not carry it") + entry, ok := pet.Unmodeled["openapi:discriminator"] + require.True(t, ok, "it is kept verbatim instead") + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.Contains(t, pet.Unmodeled, "openapi:oneOf", "beside the union it routes") + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/Pet")) + + shape, ok := doc.Types[namedID("Shape")].(*ir.Model) + require.True(t, ok) + require.NotNil(t, shape.Discriminator, "every target is a subtype, so it stays") + assert.Equal(t, namedID("Circle"), shape.Discriminator.Mapping["circle"]) + assert.NotContains(t, shape.Unmodeled, "openapi:discriminator") +} + +// assertNullableUnionDiscriminator pins that a discriminator beside a oneOf or +// anyOf that collapses to nullable X is kept verbatim, with a diagnostic, +// where the collapse leaves no node with a field for it. +func assertNullableUnionDiscriminator(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + maybe, ok := doc.Types[namedID("MaybeCat")].(*ir.Scalar) + require.True(t, ok, "the component is an alias over the nullable reference") + assert.Equal(t, ir.TypeRef{Target: namedID("Cat"), Nullable: true}, *maybe.Base) + assert.Contains(t, maybe.Unmodeled, "openapi:discriminator") + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/MaybeCat")) + + owner, ok := doc.Types[namedID("Owner")].(*ir.Model) + require.True(t, ok) + require.Len(t, owner.Properties, 1) + assert.Equal(t, ir.TypeRef{Target: namedID("Cat"), Nullable: true}, owner.Properties[0].Type) + assert.Contains(t, owner.Properties[0].Unmodeled, "openapi:discriminator", "the property carries it") +} + func assertAnyOfUntagged(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { u, ok := doc.Types[namedID("StringOrNumber")].(*ir.Union) require.True(t, ok) diff --git a/compilers/openapi/internal/schema/collapse_discriminator_test.go b/compilers/openapi/internal/schema/collapse_discriminator_test.go new file mode 100644 index 00000000..ab4a09c7 --- /dev/null +++ b/compilers/openapi/internal/schema/collapse_discriminator_test.go @@ -0,0 +1,228 @@ +package schema_test + +import ( + "encoding/json/v2" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/pass" +) + +const nullableCat = "oneOf: [{$ref: '#/components/schemas/Cat'}, {type: 'null'}]" + +// collapseSpec declares a nullable Cat at every position a schema can sit at +// with a discriminator beside it, plus a parameter and a response body. +const collapseSpec = `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: + /p: + get: + parameters: + - name: q + in: query + schema: + ` + nullableCat + ` + discriminator: {propertyName: kind} + responses: + "200": + description: ok + content: + application/json: + schema: + ` + nullableCat + ` + discriminator: {propertyName: kind} +components: + schemas: + Cat: {type: object, properties: {kind: {type: string}}} + W: + ` + nullableCat + ` + discriminator: {propertyName: kind, defaultMapping: '#/components/schemas/Cat', mapping: {cat: '#/components/schemas/Cat'}} + Holder: + type: object + properties: + w: + anyOf: [{$ref: '#/components/schemas/Cat'}, {type: 'null'}] + discriminator: {propertyName: kind} + L: + type: array + items: + ` + nullableCat + ` + discriminator: {propertyName: kind} + M: + type: object + additionalProperties: + ` + nullableCat + ` + discriminator: {propertyName: kind} +` + +// TestCollapsedUnion_KeepsItsDiscriminatorAtEveryPosition pins that a +// discriminator beside a oneOf/anyOf that collapses to a nullable reference is +// kept verbatim, whole, on the node that position owns, with an info +// diagnostic, instead of vanishing. The reference itself is unchanged. The +// component, the model property, an array's items, additionalProperties, a +// parameter and a response body each reach lowerOneOfAnyOf by their own path. +func TestCollapsedUnion_KeepsItsDiscriminatorAtEveryPosition(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, collapseSpec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, pass.Validate(doc)) + cat := componentID("Cat") + + w, ok := typeByName(doc, "W").(*ir.Scalar) + require.True(t, ok, "the component is an alias over the nullable reference") + assert.Equal(t, ir.TypeRef{Target: cat, Nullable: true}, *w.Base, "the reference is unchanged") + entry, ok := w.Unmodeled["openapi:discriminator"] + require.True(t, ok, "the component keeps its discriminator") + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.Contains(t, string(entry.Value), "defaultMapping", "the whole object is kept, not just the property name") + assert.Contains(t, string(entry.Value), `"mapping"`) + + holder, ok := typeByName(doc, "Holder").(*ir.Model) + require.True(t, ok) + require.Len(t, holder.Properties, 1) + assert.Equal(t, ir.TypeRef{Target: cat, Nullable: true}, holder.Properties[0].Type, "the property's reference is unchanged") + assert.Contains(t, holder.Properties[0].Unmodeled, "openapi:discriminator", "the property carrier keeps it") + + for _, id := range []ir.TypeID{"t/anon/components/schemas/L/items", "t/anon/components/schemas/M/additionalProperties"} { + node, ok := doc.Types[id] + require.True(t, ok, id) + assert.Contains(t, node.Common().Unmodeled, "openapi:discriminator", "%s keeps it on the node it hoists", id) + } + + for _, pointer := range []string{ + "/components/schemas/W", "/components/schemas/Holder/properties/w", + "/components/schemas/L/items", "/components/schemas/M/additionalProperties", + "/paths/~1p/get/parameters/0/schema", "/paths/~1p/get/responses/200/content/application~1json/schema", + } { + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, pointer), + "collapses to a nullable reference", pointer) + } + assert.Equal(t, 6, strings.Count(marshalForTest(t, doc), `"openapi:discriminator"`), + "one entry per position, including the parameter and the response, none duplicated") +} + +// TestCollapsedUnion_WithoutADiscriminatorHoistsNothing pins that the same +// positions without a discriminator stay as they were: no entry, no +// diagnostic, and no node hoisted for the position that has none today. +func TestCollapsedUnion_WithoutADiscriminatorHoistsNothing(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Cat: {type: object, properties: {kind: {type: string}}} + L: {type: array, items: {` + nullableCat + `}} +`) + doc, diags := parseFull(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Zero(t, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), "%+v", diags) + assert.NotContains(t, marshalForTest(t, doc), "openapi:discriminator") + assert.NotContains(t, doc.Types, ir.TypeID("t/anon/components/schemas/L/items"), "a bare nullable reference needs no node") +} + +// marshalForTest returns doc's canonical JSON, to count entries wherever they +// sit in it. +func marshalForTest(t *testing.T, doc *ir.Document) string { + t.Helper() + out, err := json.Marshal(doc) + require.NoError(t, err) + return string(out) +} + +// unplacedSpec declares a discriminator beside a oneOf/anyOf at each position +// whose lowering has no model to hold it: a null-only union as a component, a +// property and beside a second combinator, a union beside a const, an enum and +// a scalar, and a union beside a $ref as a component and as a property. Each +// reaches its keeper by its own path. +const unplacedSpec = `openapi: 3.2.0 +info: {title: T, version: "1"} +paths: {} +components: + schemas: + Cat: {type: object, properties: {kind: {type: string}}} + Dog: {type: object, properties: {kind: {type: string}}} + NullOnly: + oneOf: [{type: 'null'}] + discriminator: {propertyName: kind} + NullBoth: + oneOf: [{type: 'null'}, {type: 'null'}] + anyOf: [{type: 'null'}] + discriminator: {propertyName: kind} + EnumU: + enum: [a, b] + oneOf: [{$ref: '#/components/schemas/Cat'}, {type: string}] + discriminator: {propertyName: kind} + ConstU: + const: a + oneOf: [{$ref: '#/components/schemas/Cat'}, {type: string}] + discriminator: {propertyName: kind} + ScalarU: + type: string + oneOf: [{$ref: '#/components/schemas/Cat'}, {type: string}] + discriminator: {propertyName: kind} + RefSite: + $ref: '#/components/schemas/Cat' + oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Dog'}] + discriminator: {propertyName: kind} + Holder: + type: object + properties: + n: + anyOf: [{type: 'null'}, {type: 'null'}] + discriminator: {propertyName: kind} + r: + $ref: '#/components/schemas/Cat' + oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Dog'}] + discriminator: {propertyName: kind} +` + +// TestUnplacedDiscriminator_IsKeptAtEverySite pins that a discriminator beside +// a oneOf/anyOf whose lowering has no model to hold it is kept verbatim on the +// node or carrier that owns the position, with an info diagnostic, instead of +// vanishing. A model-bodied union still carries it in its own field. +func TestUnplacedDiscriminator_IsKeptAtEverySite(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, unplacedSpec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, pass.Validate(doc)) + + for _, name := range []string{"NullOnly", "NullBoth", "EnumU", "ConstU", "ScalarU", "RefSite"} { + node := typeByName(doc, name) + require.NotNil(t, node, name) + entry, ok := node.Common().Unmodeled["openapi:discriminator"] + require.True(t, ok, "%s keeps its discriminator", name) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason, name) + assert.Contains(t, string(entry.Value), "propertyName", name) + assert.True(t, namesDiscriminatorAt(diags, "/components/schemas/"+name), name) + } + for _, pointer := range []string{"/components/schemas/Holder/properties/n", "/components/schemas/Holder/properties/r"} { + assert.True(t, namesDiscriminatorAt(diags, pointer), pointer) + } + + holder, ok := typeByName(doc, "Holder").(*ir.Model) + require.True(t, ok) + require.Len(t, holder.Properties, 2) + owned := map[string]ir.Unmodeled{"n": nil, "r": nil} + for _, prop := range holder.Properties { + owned[prop.Name.Source] = prop.Unmodeled + } + // The null-only property hoists a node of its own; the $ref one is on the carrier. + assert.Contains(t, doc.Types["t/anon/components/schemas/Holder/properties/n"].Common().Unmodeled, "openapi:discriminator") + assert.Contains(t, owned["r"], "openapi:discriminator") + assert.Equal(t, 8, strings.Count(marshalForTest(t, doc), `"openapi:discriminator"`), + "one entry per position, none duplicated") +} + +// namesDiscriminatorAt reports whether an info degraded-construct diagnostic at +// pointer mentions a discriminator. A site also reports its union, so the count +// at a pointer is not one. +func namesDiscriminatorAt(diags []ir.Diagnostic, pointer string) bool { + return slices.ContainsFunc(diags, func(d ir.Diagnostic) bool { + return d.Code == diag.DegradedConstruct && d.Severity == ir.SeverityInfo && + string(d.Provenance.Pointer) == pointer && strings.Contains(d.Message, "discriminator") + }) +} diff --git a/compilers/openapi/internal/schema/compose.go b/compilers/openapi/internal/schema/compose.go index f15d1f60..aa1aaf01 100644 --- a/compilers/openapi/internal/schema/compose.go +++ b/compilers/openapi/internal/schema/compose.go @@ -590,6 +590,9 @@ func preserveNullOnlyUnion(c lowering.Ctx, ts *compile.Types, id ir.TypeID, s *o common := td.Common() kept, keepDiags := preserveBranchSets(c, &common.Unmodeled, s, ir.ReasonDegradedLowering, pointer) diags = append(diags, keepDiags...) + diags = append(diags, keepUnplacedDiscriminator(c, &common.Unmodeled, s, pointer, + "every branch admits only the null value, so a discriminator beside them routes "+ + "through no union; kept verbatim under Unmodeled")...) if len(kept) == 0 { return diags } @@ -721,8 +724,12 @@ func lowerCoDeclaredUnion(c lowering.Ctx, ts *compile.Types, anchors *AnchorInde "a branch's $ref names no referent this compilation resolves") return id, append(diagUnresolvedBranches(c, s, pointer), diags...) default: // unionDiscriminated - return beside(ir.ReasonDegradedLowering, - "a declared discriminator binds the branches by name, which distributing them would break") + const why = "a declared discriminator binds the branches by name, which distributing them would break" + if discriminatorRoutesToSubtypes(c, s) { + return beside(ir.ReasonDegradedLowering, why) + } + id, diags := beside(ir.ReasonDegradedLowering, why+discriminatorMovedWhy) + return id, append(diags, moveDiscriminatorToUnmodeled(c, ts, id, s, pointer)...) } } diff --git a/compilers/openapi/internal/schema/compose_internal_test.go b/compilers/openapi/internal/schema/compose_internal_test.go index 6d4f8195..7ec6a73f 100644 --- a/compilers/openapi/internal/schema/compose_internal_test.go +++ b/compilers/openapi/internal/schema/compose_internal_test.go @@ -801,3 +801,15 @@ func TestResolveMappingTarget_DefsValueNothingInternedIsHoistedAtItsOwnPosition( }) } } + +// TestMoveDiscriminatorToUnmodeled_ReportsAMissingNode pins that a node absent +// from the registry is announced, not skipped: the discriminator would vanish +// from the model with nowhere to be kept. +func TestMoveDiscriminatorToUnmodeled_ReportsAMissingNode(t *testing.T) { + t.Parallel() + + diags := moveDiscriminatorToUnmodeled(lowering.Ctx{}, compile.NewTypes(), "t/absent", nil, "/components/schemas/Ghost") + + require.Len(t, diags, 1) + assert.Equal(t, diag.InternalInvariant, diags[0].Code) +} diff --git a/compilers/openapi/internal/schema/discriminated.go b/compilers/openapi/internal/schema/discriminated.go new file mode 100644 index 00000000..c224d043 --- /dev/null +++ b/compilers/openapi/internal/schema/discriminated.go @@ -0,0 +1,172 @@ +package schema + +import ( + "encoding/json/jsontext" + + oas3 "github.com/speakeasy-api/openapi/jsonschema/oas3" + + "github.com/dexpace/morphic/compilers/compile" + "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" +) + +// discriminatorMovedWhy ends the degraded-lowering diagnostic when the +// discriminator leaves the model for Unmodeled beside the union. +const discriminatorMovedWhy = ", and it routes to a target that is not a subtype of this model, " + + "so the discriminator is kept verbatim too" + +// discriminatorRoutesToSubtypes reports whether every target the discriminator +// on s routes to is a subtype of s, the rule pass/validate holds a model +// discriminator to. Targets are the mapping values and defaultMapping, or the +// union branches when no mapping entry resolves. It reads the raw schemas, not +// the registry, where a subtype declared after s has no Base yet, so the answer +// cannot depend on declaration order. s declares a discriminator. +func discriminatorRoutesToSubtypes(c lowering.Ctx, s *oas3.Schema) bool { + targets, mapped := mappingSchemas(c, s.GetDiscriminator()) + if !mapped { + branches, _, _ := unionBranches(s) + for _, b := range branches { + targets = appendSchema(targets, refBranchTarget(b)) + } + } + for _, t := range targets { + if !reachesByBase(t, s) { + return false + } + } + return true +} + +// appendSchema appends t to dst unless it is nil, which is how a target that +// resolves to nothing drops out. +func appendSchema(dst []*oas3.Schema, t *oas3.Schema) []*oas3.Schema { + if t == nil { + return dst + } + return append(dst, t) +} + +// mappingSchemas returns the schemas d's mapping values and defaultMapping +// resolve to, and whether any mapping entry resolved. An unresolvable value +// contributes nothing. +func mappingSchemas(c lowering.Ctx, d *oas3.Discriminator) ([]*oas3.Schema, bool) { + var out []*oas3.Schema + if m := d.GetMapping(); m != nil { + for _, target := range m.All() { + out = appendSchema(out, mappingSchema(c, d, target)) + } + } + mapped := len(out) > 0 + if dm := d.GetDefaultMapping(); dm != "" { + out = appendSchema(out, mappingSchema(c, d, dm)) + } + return out, mapped +} + +// mappingSchema returns the schema a mapping value names, or nil when it names +// none this compilation resolves. A component names its own node, whatever it +// declares, so it is not read through; any other position that is only a $ref +// names the position the $ref does (typePosition). +func mappingSchema(c lowering.Ctx, d *oas3.Discriminator, target string) *oas3.Schema { + scope := c.RefScope() + pointer := ids.Ptr("components", "schemas", target) + if !c.DeclaresSchema(target) { + var ok bool + if pointer, ok = scope.MappingPointer(d, target); !ok { + return nil + } + } + js := scope.DeclaredAt(pointer) + if _, named := ids.ComponentSchemaName(pointer); named { + return js.GetSchema() + } + if t := refBranchTarget(js); t != nil { + return t + } + return js.GetSchema() +} + +// reachesByBase reports whether base is an ancestor of target along the allOf +// base chain: the branch selectAllOfBase elects at each level, which is what +// lowers to Model.Base. A mixin does not count, as isSubtype ignores Mixins. +// The chain is bounded and cycle-checked. +func reachesByBase(target, base *oas3.Schema) bool { + seen := map[*oas3.Schema]bool{target: true} + for cur, hop := target, 0; hop < maxDiscriminatorAncestorDepth; hop++ { + branches := cur.GetAllOf() + i := selectAllOfBase(branches) + if i < 0 { + return false + } + next := refBranchTarget(branches[i]) + if next == base { + return true + } + if next == nil || seen[next] { + return false + } + seen[next] = true + cur = next + } + return false +} + +// moveDiscriminatorToUnmodeled takes the discriminator off the model that +// lowered beside a kept union and stores it verbatim in that model's Unmodeled, +// whole: propertyName, mapping and defaultMapping. A model discriminator that +// routes to a non-subtype is one pass/validate rejects, and the union that +// would have given it variants is kept verbatim, not lowered. +func moveDiscriminatorToUnmodeled(c lowering.Ctx, ts *compile.Types, id ir.TypeID, s *oas3.Schema, pointer jsontext.Pointer) []ir.Diagnostic { + td, ok, diags := registeredNode(c, ts, id, pointer) + if !ok { + return diags + } + if m, isModel := td.(*ir.Model); isModel { + m.Discriminator = nil + } + _, keepDiags := PreserveSchemaKeyword(c, &td.Common().Unmodeled, s, "discriminator", + ir.ReasonDegradedLowering, pointer+ids.Ptr("discriminator")) + return append(diags, keepDiags...) +} + +// collapsesBesideDiscriminator reports whether s is a nullable-X union that +// lowerOneOfAnyOf collapses to a reference (nullUnionCollapse) and that also +// declares a discriminator. A reference has no field for one, so the position +// needs a node of its own to keep it on. +func collapsesBesideDiscriminator(s *oas3.Schema) bool { + if s.GetDiscriminator() == nil || hasUnionSiblings(s) { + return false + } + _, _, _, collapses := nullUnionCollapse(s, "") + return collapses +} + +// keepUnplacedDiscriminator keeps, verbatim under p, the discriminator s +// declares and the lowering at this position has no field to hold, and reports +// it with msg at pointer. It is the one mechanism every such site shares, so a +// union lowering that cannot place a discriminator cannot drop it quietly. It +// does nothing when s declares none. p is the Unmodeled of whatever owns the +// position: a node the position hoisted, or a carrier's own. +func keepUnplacedDiscriminator(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, pointer jsontext.Pointer, msg string) []ir.Diagnostic { + if s.GetDiscriminator() == nil { + return nil + } + _, diags := PreserveSchemaKeyword(c, p, s, "discriminator", + ir.ReasonDegradedLowering, pointer+ids.Ptr("discriminator")) + return append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, pointer, "%s", msg)) +} + +// preserveCollapsedDiscriminator keeps the discriminator of a union that +// collapsed to a nullable reference. The collapse lowers {X, null} to nullable +// X, which routes nothing by tag, so the discriminator (propertyName, mapping +// and defaultMapping) would otherwise vanish without a word. +func preserveCollapsedDiscriminator(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, pointer jsontext.Pointer) []ir.Diagnostic { + if !collapsesBesideDiscriminator(s) { + return nil + } + return keepUnplacedDiscriminator(c, p, s, pointer, + "a discriminator beside a oneOf/anyOf that collapses to a nullable reference "+ + "routes through no union; kept verbatim under Unmodeled") +} diff --git a/compilers/openapi/internal/schema/discriminated_test.go b/compilers/openapi/internal/schema/discriminated_test.go new file mode 100644 index 00000000..5571ef32 --- /dev/null +++ b/compilers/openapi/internal/schema/discriminated_test.go @@ -0,0 +1,207 @@ +package schema_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" + "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/pass" +) + +const ( + cat = " Cat: {type: object, properties: {kind: {type: string}, meow: {type: boolean}}}\n" + dog = " Dog: {type: object, properties: {kind: {type: string}, bark: {type: boolean}}}\n" + + // subtypes are Cat and Dog declared as the allOf subtypes of Pet. + subtypes = " Cat: {allOf: [{$ref: '#/components/schemas/Pet'}, {type: object, properties: {meow: {type: boolean}}}]}\n" + + " Dog: {allOf: [{$ref: '#/components/schemas/Pet'}, {type: object, properties: {bark: {type: boolean}}}]}\n" + + petBranches = " oneOf:\n - $ref: '#/components/schemas/Cat'\n - $ref: '#/components/schemas/Dog'\n" +) + +// petWith declares Pet, a model whose oneOf names Cat and Dog, beside the +// discriminator text given. +func petWith(discriminator string) string { + return " Pet:\n type: object\n discriminator: " + discriminator + "\n" + petBranches +} + +// discriminatedOrders compiles pet and rest in both declaration orders, so a +// lowering that read the registry mid-walk would answer differently for each. +func discriminatedOrders(t *testing.T, version, pet, rest string) []*ir.Document { + t.Helper() + docs := make([]*ir.Document, 0, 2) + for _, src := range []string{pet + rest, rest + pet} { + doc, diags := parseFull(t, openapitest.ComponentSpecVer(version, src)) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, pass.Validate(doc), "the document must validate") + docs = append(docs, doc) + } + assert.Empty(t, cmp.Diff(docs[0], docs[1], orderInvariantIR()...), "declaration order must not change the document") + return docs +} + +// TestDiscriminatedUnion_NonSubtypeTargetsStayVerbatim pins that the union kept +// verbatim beside a model does not leave its discriminator on that model when a +// target it routes to is not a subtype of it, which pass.Validate rejects as +// discriminator-missing-variant. The whole discriminator moves to Unmodeled +// beside the union, with the reason the union is kept for. Each row is compiled +// in both declaration orders. +func TestDiscriminatedUnion_NonSubtypeTargetsStayVerbatim(t *testing.T) { + t.Parallel() + const mixed = " Cat: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n" + cases := []struct { + name string + version string + discriminator string + rest string + wantValue string + }{ + {"explicit mapping", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", cat + dog, "mapping"}, + {"inferred by name", "3.1.0", "{propertyName: kind}", cat + dog, "propertyName"}, + {"one subtype and one not", "3.1.0", + "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat', dog: '#/components/schemas/Dog'}}", + mixed + dog, "dog"}, + {"default mapping", "3.2.0", "{propertyName: kind, defaultMapping: '#/components/schemas/Dog'}", cat + dog, "defaultMapping"}, + {"mixin only", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", + " Cat: {allOf: [{$ref: '#/components/schemas/Dog'}, {$ref: '#/components/schemas/Other'}]}\n" + + dog + " Other: {type: object}\n", "mapping"}, + {"position that declares an inline object", "3.1.0", + "{propertyName: kind, mapping: {cat: '#/components/schemas/Holder/properties/pet'}}", + cat + dog + " Holder: {type: object, properties: {pet: {type: object, properties: {kind: {type: string}}}}}\n", + "mapping"}, + {"component that is only an alias", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Alias'}}", + subtypes + " Alias: {$ref: '#/components/schemas/Cat'}\n", "mapping"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + for _, doc := range discriminatedOrders(t, c.version, petWith(c.discriminator), c.rest) { + pet, ok := typeByName(doc, "Pet").(*ir.Model) + require.True(t, ok, "Pet lowers to a model beside its union") + assert.Nil(t, pet.Discriminator, "a discriminator routing to a non-subtype stays off the model") + entry, ok := pet.Unmodeled["openapi:discriminator"] + require.True(t, ok, "the discriminator is kept verbatim") + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.Contains(t, string(entry.Value), c.wantValue) + assert.Contains(t, string(entry.Value), `"propertyName":"kind"`, "the whole object is kept") + _, ok = pet.Unmodeled["openapi:oneOf"] + assert.True(t, ok, "the union stays beside it") + } + }) + } +} + +// TestDiscriminatedUnion_SaysItKeptTheDiscriminator pins that the diagnostic +// for the kept union names the discriminator when it moved too, and does not +// when it stayed on the model. +func TestDiscriminatedUnion_SaysItKeptTheDiscriminator(t *testing.T) { + t.Parallel() + const pointer = "/components/schemas/Pet" + moved := petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}") + _, diags := lowerSpec(t, openapitest.ComponentSpec(moved+cat+dog)) + assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, pointer), + "not a subtype of this model, so the discriminator is kept verbatim too") + + stayed := petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}") + _, diags = lowerSpec(t, openapitest.ComponentSpec(stayed+subtypes)) + assert.NotContains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, pointer), + "discriminator is kept") +} + +// TestDiscriminatedUnion_SubtypeTargetsKeepTheDiscriminator pins the one case +// the model keeps its discriminator beside a kept union: every target it routes +// to is a subtype, at any distance. It must hold in both declaration orders, +// which is why the rule reads the schemas and not the lowered registry, where a +// subtype declared after Pet has no Base yet while Pet lowers. +func TestDiscriminatedUnion_SubtypeTargetsKeepTheDiscriminator(t *testing.T) { + t.Parallel() + const grand = " Cat: {allOf: [{$ref: '#/components/schemas/Mid'}], type: object}\n" + + " Mid: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n" + + " Dog: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n" + cases := []struct { + name string + version string + discriminator string + rest string + }{ + {"explicit mapping", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", subtypes}, + {"inferred by name", "3.1.0", "{propertyName: kind}", subtypes}, + {"mapping by bare name", "3.1.0", "{propertyName: kind, mapping: {cat: Cat, dog: Dog}}", subtypes}, + {"position that is only a $ref", "3.1.0", + "{propertyName: kind, mapping: {cat: '#/components/schemas/Holder/properties/pet'}}", + subtypes + " Holder: {type: object, properties: {pet: {$ref: '#/components/schemas/Cat'}}}\n"}, + {"grandchild", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", grand}, + {"default mapping", "3.2.0", "{propertyName: kind, defaultMapping: '#/components/schemas/Dog'}", subtypes}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + for _, doc := range discriminatedOrders(t, c.version, petWith(c.discriminator), c.rest) { + pet, ok := typeByName(doc, "Pet").(*ir.Model) + require.True(t, ok, "Pet lowers to a model beside its union") + require.NotNil(t, pet.Discriminator, "every target is a subtype, so the model keeps it") + assert.Equal(t, "kind", pet.Discriminator.PropertyName) + assert.NotContains(t, pet.Unmodeled, "openapi:discriminator", "nothing is stored twice") + assert.Contains(t, pet.Unmodeled, "openapi:oneOf") + } + }) + } +} + +// TestDiscriminatedUnion_UnresolvedAndCyclicTargets pins that the rule +// terminates and drops what it cannot read: a mapping value that names nothing +// is skipped (discriminatorMapping reports it), and a composition cycle that +// never reaches Pet is no subtype. +func TestDiscriminatedUnion_UnresolvedAndCyclicTargets(t *testing.T) { + t.Parallel() + pet := petWith("{propertyName: kind, mapping: {gone: '#/components/schemas/Nope', far: 'https://example.com/x.yaml#/A', " + + "loop: '#/components/schemas/Loop'}}") + rest := cat + dog + + " Loop: {allOf: [{$ref: '#/components/schemas/LoopB'}]}\n" + + " LoopB: {allOf: [{$ref: '#/components/schemas/Loop'}]}\n" + doc, _ := lowerSpec(t, openapitest.ComponentSpec(pet+rest)) + m, ok := typeByName(doc, "Pet").(*ir.Model) + require.True(t, ok) + assert.Nil(t, m.Discriminator, "the cycle never reaches Pet") + assert.Contains(t, m.Unmodeled, "openapi:discriminator") +} + +// TestDiscriminatedUnion_DeepBaseChainIsBounded pins that the walk up the base +// chain stops at its bound on a chain longer than it, rather than following it. +func TestDiscriminatedUnion_DeepBaseChainIsBounded(t *testing.T) { + t.Parallel() + var b strings.Builder + for i := range 300 { + fmt.Fprintf(&b, " C%d: {allOf: [{$ref: '#/components/schemas/C%d'}]}\n", i, i+1) + } + b.WriteString(" C300: {type: object}\n") + pet := petWith("{propertyName: kind, mapping: {c: '#/components/schemas/C0'}}") + doc, _ := lowerSpec(t, openapitest.ComponentSpec(pet+cat+dog+b.String())) + m, ok := typeByName(doc, "Pet").(*ir.Model) + require.True(t, ok) + assert.Nil(t, m.Discriminator) + assert.Contains(t, m.Unmodeled, "openapi:discriminator") +} + +// TestDiscriminatedUnion_KeepsAnAllOfBodyToo pins the other model lowering +// reachable beside a kept union: a body composed with allOf lowers through +// lowerAllOf, and its discriminator is held to the same rule. +func TestDiscriminatedUnion_KeepsAnAllOfBodyToo(t *testing.T) { + t.Parallel() + pet := " Pet:\n allOf: [{$ref: '#/components/schemas/Base'}]\n" + + " discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}\n" + petBranches + doc, diags := parseFull(t, openapitest.ComponentSpec(pet+cat+dog+" Base: {type: object}\n")) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, pass.Validate(doc)) + m, ok := typeByName(doc, "Pet").(*ir.Model) + require.True(t, ok) + assert.Nil(t, m.Discriminator) + assert.Contains(t, m.Unmodeled, "openapi:discriminator") +} diff --git a/compilers/openapi/internal/schema/resolve.go b/compilers/openapi/internal/schema/resolve.go index e41569d8..5c6da8cd 100644 --- a/compilers/openapi/internal/schema/resolve.go +++ b/compilers/openapi/internal/schema/resolve.go @@ -115,9 +115,12 @@ func PreserveRefSiteKeywords(c lowering.Ctx, ts *compile.Types, p *ir.Unmodeled, // GetSchema is nil-safe and yields nil for a boolean schema too, so the one // check covers an absent position, a boolean one, and a caller passing nil. s := js.GetSchema() - if s == nil || !resolve.IsRefSite(js, s) || LoweredToOwnNode(ts, pointer, t) { + if s == nil || LoweredToOwnNode(ts, pointer, t) { return nil } + if !resolve.IsRefSite(js, s) { + return preserveCollapsedDiscriminator(c, p, s, pointer) + } diags := recordUnhomedAt(c, p, s, refSiteUnhomedKeywords(s, nil), refSiteShape, pointer) if !declaresUnion(s) { return diags diff --git a/compilers/openapi/internal/schema/schema.go b/compilers/openapi/internal/schema/schema.go index c3431c92..7e64e798 100644 --- a/compilers/openapi/internal/schema/schema.go +++ b/compilers/openapi/internal/schema/schema.go @@ -235,6 +235,7 @@ func hoistDeclarationHome(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, ref } var kept ir.Unmodeled cons, diags := schemaConstraints(c, &kept, s, pointer) + diags = append(diags, preserveCollapsedDiscriminator(c, &kept, s, pointer)...) id := internAlias(c, ts, pointer, hint, ref, cons, kept) return ir.TypeRef{Target: id, Nullable: ref.Nullable}, diags } @@ -259,7 +260,8 @@ func declaresPositionScoped(s *oas3.Schema) bool { return declaresAnnotations(s) || declaresValueConstraints(s) || declaresValidationOnly(s) || annotation.DeclaresAny(s, residueKeywords) || declaresContentVocabulary(s) || declaresDynamicRef(s) || - annotation.DeclaresAny(s, annotation.DialectKeywords) + annotation.DeclaresAny(s, annotation.DialectKeywords) || + collapsesBesideDiscriminator(s) } // declaresAnnotations reports whether s carries documentation, deprecation, XML @@ -433,7 +435,12 @@ func preserveUnionSiblings(c lowering.Ctx, ts *compile.Types, id ir.TypeID, s *o if !ok { return diags } - return append(diags, preserveUnionSiblingsAt(c, &td.Common().Unmodeled, s, pointer, reason, why)...) + p := &td.Common().Unmodeled + if _, isModel := td.(*ir.Model); isModel { + // A model holds the discriminator in its own field. + return append(diags, preserveUnionBranches(c, p, s, pointer, reason, why)...) + } + return append(diags, preserveUnionSiblingsAt(c, p, s, pointer, reason, why)...) } // preserveUnionSiblingsAt is preserveUnionSiblings' body, addressed by the @@ -445,6 +452,15 @@ func preserveUnionSiblings(c lowering.Ctx, ts *compile.Types, id ir.TypeID, s *o // lowerCoDeclaredUnion's reasons presuppose a structural body at the position, // and a $ref site has none, so a shared template would contradict itself there. func preserveUnionSiblingsAt(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, pointer jsontext.Pointer, reason ir.UnmodeledReason, why string) []ir.Diagnostic { + diags := preserveUnionBranches(c, p, s, pointer, reason, why) + return append(diags, keepUnplacedDiscriminator(c, p, s, pointer, + "a discriminator beside a oneOf/anyOf kept verbatim under Unmodeled routes through "+ + "no union, as this position has no model to hold it")...) +} + +// preserveUnionBranches keeps s's oneOf/anyOf verbatim under p and says so, +// without touching a discriminator: the owner may hold that itself. +func preserveUnionBranches(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, pointer jsontext.Pointer, reason ir.UnmodeledReason, why string) []ir.Diagnostic { kept, diags := preserveBranchSets(c, p, s, reason, pointer) if reason == ir.ReasonValidationOnly || len(kept) == 0 { return diags diff --git a/docs/ir-design.md b/docs/ir-design.md index 458cf5b9..0f277bb9 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -832,7 +832,14 @@ from it is exact and nothing is reported. branch is unusable for the same reason an inline one is, so it declines the same way rather than composing a variant whose branch quietly went missing; the broken reference is reported at `error` severity on top of the `info` below; - 4. a `discriminator` is declared, binding the branch schemas by name; + 4. a `discriminator` is declared, binding the branch schemas by name. The discriminator stays + on the lowered `Model` only when every target it routes to — each `mapping` value and the + `defaultMapping`, or the union's branches when no mapping entry resolves — is a subtype of + that model at any distance along its `allOf` base chain, the rule `pass/validate` holds a + model discriminator to; the compiler reads it from the source schemas, so declaration order + cannot change the answer. Otherwise the whole object (`propertyName`, `mapping`, + `defaultMapping`) is kept verbatim in `Unmodeled["openapi:discriminator"]` beside the union, + and the `info` diagnostic says so; 5. `oneOf` and `anyOf` are both declared, so distributing one would drop the other. In all five, whatever structural body the schema declares is lowered — `properties` or a @@ -847,6 +854,13 @@ from it is exact and nothing is reported. it admits values the union would reject — which is why the branches must survive for a validating emitter to reach. + A `discriminator` written beside a union is held by the lowered `Model` where there is one. + Wherever the position lowers to a node that is not a `Model` — a null-only branch set, a union + beside a `const`, `enum` or scalar body, a union beside a `$ref`, whether on an alias or on a + property carrier — there is no field to hold it, and the whole object is kept verbatim in + `Unmodeled["openapi:discriminator"]` on that node or carrier, with an `info` diagnostic. When + `oneOf` and `anyOf` both sit beside it, `oneOf` wins and the discriminator rides on that union. + A union whose branches declare no shape at all (`oneOf: [{required: [a]}, {required: [b]}]`) is not a degradation and is not listed here: it narrows the body without reshaping it, which is validation logic, so it is preserved under `ReasonValidationOnly` with §4.7's keyword family. @@ -2080,7 +2094,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 `discriminator` beside the collapsed union has no node field to land in, so it is kept verbatim in `Unmodeled["openapi:discriminator"]` on the node or carrier that owns the position, with an `info` diagnostic; the same holds for a discriminator beside a null-only branch set, a union beside a non-model body, or a union beside a `$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), with a declared `discriminator` beside it staying on the model only when every target is a subtype of it and otherwise kept verbatim in `Unmodeled["openapi:discriminator"]` (§4.8 case 4); 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 | | **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/conformance/openapi/discriminated-union-beside-model.golden.json b/testdata/conformance/openapi/discriminated-union-beside-model.golden.json new file mode 100644 index 00000000..6255ad86 --- /dev/null +++ b/testdata/conformance/openapi/discriminated-union-beside-model.golden.json @@ -0,0 +1,435 @@ +{ + "irVersion": "0.6.0", + "name": "Discriminated union beside a model", + "version": "1", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "Discriminated union beside a model", + "canonical": "discriminated_union_beside_a_model" + }, + "docs": {}, + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/openapi/components/schemas/Cat": { + "kind": "model", + "id": "t/openapi/components/schemas/Cat", + "name": { + "source": "Cat", + "canonical": "cat" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Cat" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Cat/properties/kind", + "name": { + "source": "kind", + "canonical": "kind" + }, + "wireName": "kind", + "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/Cat/properties/kind" + } + }, + { + "id": "p/openapi/components/schemas/Cat/properties/meow", + "name": { + "source": "meow", + "canonical": "meow" + }, + "wireName": "meow", + "type": { + "target": "t/prim/bool", + "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/Cat/properties/meow" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/Circle": { + "kind": "model", + "id": "t/openapi/components/schemas/Circle", + "name": { + "source": "Circle", + "canonical": "circle" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Circle" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Circle/allOf/1/properties/radius", + "name": { + "source": "radius", + "canonical": "radius" + }, + "wireName": "radius", + "type": { + "target": "t/prim/number", + "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/Circle/allOf/1/properties/radius" + } + } + ], + "base": { + "target": "t/openapi/components/schemas/Shape", + "nullable": false + }, + "abstract": false, + "positional": false, + "discriminatorValue": "circle", + "inputOnly": false + }, + "t/openapi/components/schemas/Dog": { + "kind": "model", + "id": "t/openapi/components/schemas/Dog", + "name": { + "source": "Dog", + "canonical": "dog" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Dog" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Dog/properties/kind", + "name": { + "source": "kind", + "canonical": "kind" + }, + "wireName": "kind", + "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/Dog/properties/kind" + } + }, + { + "id": "p/openapi/components/schemas/Dog/properties/bark", + "name": { + "source": "bark", + "canonical": "bark" + }, + "wireName": "bark", + "type": { + "target": "t/prim/bool", + "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/Dog/properties/bark" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/Pet": { + "kind": "model", + "id": "t/openapi/components/schemas/Pet", + "name": { + "source": "Pet", + "canonical": "pet" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:discriminator": { + "reason": "degraded_lowering", + "value": { + "mapping": { + "cat": "#/components/schemas/Cat" + }, + "propertyName": "kind" + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Pet/discriminator" + } + }, + "openapi:oneOf": { + "reason": "degraded_lowering", + "value": [ + { + "$ref": "#/components/schemas/Cat" + }, + { + "$ref": "#/components/schemas/Dog" + } + ], + "provenance": { + "source": 0, + "pointer": "/components/schemas/Pet/oneOf" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Pet" + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/Shape": { + "kind": "model", + "id": "t/openapi/components/schemas/Shape", + "name": { + "source": "Shape", + "canonical": "shape" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:oneOf": { + "reason": "degraded_lowering", + "value": [ + { + "$ref": "#/components/schemas/Circle" + }, + { + "$ref": "#/components/schemas/Square" + } + ], + "provenance": { + "source": 0, + "pointer": "/components/schemas/Shape/oneOf" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Shape" + }, + "abstract": false, + "positional": false, + "discriminator": { + "propertyName": "kind", + "mapping": { + "circle": "t/openapi/components/schemas/Circle" + }, + "inferred": false + }, + "inputOnly": false + }, + "t/openapi/components/schemas/Square": { + "kind": "model", + "id": "t/openapi/components/schemas/Square", + "name": { + "source": "Square", + "canonical": "square" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Square" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Square/allOf/1/properties/side", + "name": { + "source": "side", + "canonical": "side" + }, + "wireName": "side", + "type": { + "target": "t/prim/number", + "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/Square/allOf/1/properties/side" + } + } + ], + "base": { + "target": "t/openapi/components/schemas/Shape", + "nullable": false + }, + "abstract": false, + "positional": false, + "discriminatorValue": "Square", + "inputOnly": false + }, + "t/prim/bool": { + "kind": "primitive", + "id": "t/prim/bool", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "bool" + }, + "t/prim/number": { + "kind": "primitive", + "id": "t/prim/number", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "number" + }, + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "string" + } + }, + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {} + } + ], + "diagnostics": [ + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "oneOf/anyOf co-declared with structural keywords intersects with them, and a declared discriminator binds the branches by name, which distributing them would break, and it routes to a target that is not a subtype of this model, so the discriminator is kept verbatim too; union branches kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Pet" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "oneOf/anyOf co-declared with structural keywords intersects with them, and a declared discriminator binds the branches by name, which distributing them would break; union branches kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Shape" + } + } + ], + "sources": [ + { + "format": "openapi@3.1", + "path": "discriminated-union-beside-model.yaml", + "hash": "551db712e7c0016e4c705a8b08cb2663e2a981cdc790e6cec98b14f65e342867" + } + ] +} diff --git a/testdata/conformance/openapi/discriminated-union-beside-model.yaml b/testdata/conformance/openapi/discriminated-union-beside-model.yaml new file mode 100644 index 00000000..7d54dc59 --- /dev/null +++ b/testdata/conformance/openapi/discriminated-union-beside-model.yaml @@ -0,0 +1,50 @@ +openapi: 3.1.0 +info: + title: Discriminated union beside a model + version: "1" +paths: {} +components: + schemas: + # The branches are not subtypes of Pet, so a model discriminator mapping to + # them is one no emitter could route: it stays beside the kept union. + Pet: + type: object + discriminator: + propertyName: kind + mapping: + cat: '#/components/schemas/Cat' + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + Cat: + type: object + properties: + kind: {type: string} + meow: {type: boolean} + Dog: + type: object + properties: + kind: {type: string} + bark: {type: boolean} + # Here every target is a true subtype of Shape, so the model keeps it. + Shape: + type: object + discriminator: + propertyName: kind + mapping: + circle: '#/components/schemas/Circle' + oneOf: + - $ref: '#/components/schemas/Circle' + - $ref: '#/components/schemas/Square' + Circle: + allOf: + - $ref: '#/components/schemas/Shape' + - type: object + properties: + radius: {type: number} + Square: + allOf: + - $ref: '#/components/schemas/Shape' + - type: object + properties: + side: {type: number} diff --git a/testdata/conformance/openapi/nullable-union-discriminator.golden.json b/testdata/conformance/openapi/nullable-union-discriminator.golden.json new file mode 100644 index 00000000..acf13431 --- /dev/null +++ b/testdata/conformance/openapi/nullable-union-discriminator.golden.json @@ -0,0 +1,209 @@ +{ + "irVersion": "0.6.0", + "name": "Discriminator beside a nullable union", + "version": "1", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "Discriminator beside a nullable union", + "canonical": "discriminator_beside_a_nullable_union" + }, + "docs": {}, + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/openapi/components/schemas/Cat": { + "kind": "model", + "id": "t/openapi/components/schemas/Cat", + "name": { + "source": "Cat", + "canonical": "cat" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Cat" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Cat/properties/kind", + "name": { + "source": "kind", + "canonical": "kind" + }, + "wireName": "kind", + "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/Cat/properties/kind" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/MaybeCat": { + "kind": "scalar", + "id": "t/openapi/components/schemas/MaybeCat", + "name": { + "source": "MaybeCat", + "canonical": "maybe_cat" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:discriminator": { + "reason": "degraded_lowering", + "value": { + "mapping": { + "cat": "#/components/schemas/Cat" + }, + "propertyName": "kind" + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/MaybeCat/discriminator" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/MaybeCat" + }, + "base": { + "target": "t/openapi/components/schemas/Cat", + "nullable": true + } + }, + "t/openapi/components/schemas/Owner": { + "kind": "model", + "id": "t/openapi/components/schemas/Owner", + "name": { + "source": "Owner", + "canonical": "owner" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Owner" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Owner/properties/pet", + "name": { + "source": "pet", + "canonical": "pet" + }, + "wireName": "pet", + "type": { + "target": "t/openapi/components/schemas/Cat", + "nullable": true + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "unmodeled": { + "openapi:discriminator": { + "reason": "degraded_lowering", + "value": { + "propertyName": "kind" + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Owner/properties/pet/discriminator" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Owner/properties/pet" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "string" + } + }, + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {} + } + ], + "diagnostics": [ + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "a discriminator beside a oneOf/anyOf that collapses to a nullable reference routes through no union; kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/MaybeCat" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "a discriminator beside a oneOf/anyOf that collapses to a nullable reference routes through no union; kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Owner/properties/pet" + } + } + ], + "sources": [ + { + "format": "openapi@3.1", + "path": "nullable-union-discriminator.yaml", + "hash": "01703f710f41b4b58731319eb261c9ec54cd106c0d46292cb95e848caa1b2576" + } + ] +} diff --git a/testdata/conformance/openapi/nullable-union-discriminator.yaml b/testdata/conformance/openapi/nullable-union-discriminator.yaml new file mode 100644 index 00000000..1a2ffd92 --- /dev/null +++ b/testdata/conformance/openapi/nullable-union-discriminator.yaml @@ -0,0 +1,30 @@ +openapi: 3.1.0 +info: + title: Discriminator beside a nullable union + version: "1" +paths: {} +components: + schemas: + Cat: + type: object + properties: + kind: {type: string} + # {Cat, null} collapses to a nullable reference, which has no field for a + # discriminator, so it is kept verbatim on the node that owns the position. + MaybeCat: + oneOf: + - $ref: '#/components/schemas/Cat' + - type: 'null' + discriminator: + propertyName: kind + mapping: + cat: '#/components/schemas/Cat' + Owner: + type: object + properties: + pet: + anyOf: + - $ref: '#/components/schemas/Cat' + - type: 'null' + discriminator: + propertyName: kind From 87c257ee9f2fbc5a7405d0952c320b059d3b3c05 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Fri, 9 Oct 2026 18:38:31 +0300 Subject: [PATCH 02/13] fix(compilers/openapi): gate discriminators on every degraded union Model-bodied unions kept verbatim for inline branches, oneOf beside anyOf, validation-only branches and unresolved refs kept the discriminator on the model without checking its targets are subtypes, so valid documents failed pass/discriminator-missing-variant. The gate now sits in the one closure every degraded outcome goes through. An inline or unresolved branch counts as a non-subtype when no mapping entry resolves. Co-Authored-By: Claude Sonnet 5.5 --- compilers/openapi/internal/schema/compose.go | 20 ++-- .../openapi/internal/schema/discriminated.go | 10 +- .../internal/schema/discriminated_test.go | 105 ++++++++++++++++++ docs/ir-design.md | 22 ++-- 4 files changed, 139 insertions(+), 18 deletions(-) diff --git a/compilers/openapi/internal/schema/compose.go b/compilers/openapi/internal/schema/compose.go index aa1aaf01..2393c1bf 100644 --- a/compilers/openapi/internal/schema/compose.go +++ b/compilers/openapi/internal/schema/compose.go @@ -699,11 +699,21 @@ const coDeclaredUnionWhyPrefix = "oneOf/anyOf co-declared with structural keywor // lowerCoDeclaredUnion lowers a schema whose oneOf/anyOf sits beside structural // keywords, per classifyUnionSiblings. func lowerCoDeclaredUnion(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s *oas3.Schema, pointer jsontext.Pointer, hint string) (ir.TypeID, []ir.Diagnostic) { + // beside is the one place a body lowered to a Model beside a kept union gets + // its discriminator judged: every degraded outcome goes through it. beside := func(reason ir.UnmodeledReason, why string) (ir.TypeID, []ir.Diagnostic) { + moves := s.GetDiscriminator() != nil && composesAsModel(s) && !discriminatorRoutesToSubtypes(c, s) if why != "" { why = coDeclaredUnionWhyPrefix + why + if moves { + why += discriminatorMovedWhy + } + } + id, diags := lowerBesideUnmodeledUnion(c, ts, anchors, depth, s, pointer, hint, reason, why) + if !moves { + return id, diags } - return lowerBesideUnmodeledUnion(c, ts, anchors, depth, s, pointer, hint, reason, why) + return id, append(diags, moveDiscriminatorToUnmodeled(c, ts, id, s, pointer)...) } switch classifyUnionSiblings(c, s) { case unionDistributed: @@ -724,12 +734,8 @@ func lowerCoDeclaredUnion(c lowering.Ctx, ts *compile.Types, anchors *AnchorInde "a branch's $ref names no referent this compilation resolves") return id, append(diagUnresolvedBranches(c, s, pointer), diags...) default: // unionDiscriminated - const why = "a declared discriminator binds the branches by name, which distributing them would break" - if discriminatorRoutesToSubtypes(c, s) { - return beside(ir.ReasonDegradedLowering, why) - } - id, diags := beside(ir.ReasonDegradedLowering, why+discriminatorMovedWhy) - return id, append(diags, moveDiscriminatorToUnmodeled(c, ts, id, s, pointer)...) + return beside(ir.ReasonDegradedLowering, + "a declared discriminator binds the branches by name, which distributing them would break") } } diff --git a/compilers/openapi/internal/schema/discriminated.go b/compilers/openapi/internal/schema/discriminated.go index c224d043..a632724e 100644 --- a/compilers/openapi/internal/schema/discriminated.go +++ b/compilers/openapi/internal/schema/discriminated.go @@ -20,7 +20,8 @@ const discriminatorMovedWhy = ", and it routes to a target that is not a subtype // discriminatorRoutesToSubtypes reports whether every target the discriminator // on s routes to is a subtype of s, the rule pass/validate holds a model // discriminator to. Targets are the mapping values and defaultMapping, or the -// union branches when no mapping entry resolves. It reads the raw schemas, not +// union branches when no mapping entry resolves, an inline or +// unresolved branch counting as a non-subtype. It reads the raw schemas, not // the registry, where a subtype declared after s has no Base yet, so the answer // cannot depend on declaration order. s declares a discriminator. func discriminatorRoutesToSubtypes(c lowering.Ctx, s *oas3.Schema) bool { @@ -28,7 +29,12 @@ func discriminatorRoutesToSubtypes(c lowering.Ctx, s *oas3.Schema) bool { if !mapped { branches, _, _ := unionBranches(s) for _, b := range branches { - targets = appendSchema(targets, refBranchTarget(b)) + t := refBranchTarget(b) + if t == nil { + // An inline branch names no component, so it is no subtype. + return false + } + targets = append(targets, t) } } for _, t := range targets { diff --git a/compilers/openapi/internal/schema/discriminated_test.go b/compilers/openapi/internal/schema/discriminated_test.go index 5571ef32..19db70c9 100644 --- a/compilers/openapi/internal/schema/discriminated_test.go +++ b/compilers/openapi/internal/schema/discriminated_test.go @@ -205,3 +205,108 @@ func TestDiscriminatedUnion_KeepsAnAllOfBodyToo(t *testing.T) { assert.Nil(t, m.Discriminator) assert.Contains(t, m.Unmodeled, "openapi:discriminator") } + +// degradedShapes are the model-bodied unions kept verbatim beside a +// discriminator other than the one the rule was first written for, each with +// the branch text that makes it that shape. +var degradedShapes = []struct { + name string + branches string +}{ + {"inline branch", " oneOf: [{$ref: '#/components/schemas/Cat'}, {type: object, properties: {kind: {type: string}}}]\n"}, + {"oneOf beside anyOf", " oneOf: [{$ref: '#/components/schemas/Cat'}]\n anyOf: [{$ref: '#/components/schemas/Dog'}]\n"}, + {"validation-only branches", " oneOf: [{required: [kind]}, {required: [x]}]\n"}, +} + +const degradedMapping = "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}" + +func degradedPet(branches string) string { + return " Pet:\n type: object\n discriminator: " + degradedMapping + "\n" + branches +} + +// TestDiscriminatedUnion_EveryDegradedShapeGatesTheDiscriminator pins that a +// discriminator routing to a non-subtype leaves the model for Unmodeled on +// every degraded union, not only the discriminated one, so pass.Validate +// accepts the document in both declaration orders. +func TestDiscriminatedUnion_EveryDegradedShapeGatesTheDiscriminator(t *testing.T) { + t.Parallel() + for _, shape := range degradedShapes { + t.Run(shape.name, func(t *testing.T) { + t.Parallel() + for _, doc := range discriminatedOrders(t, "3.1.0", degradedPet(shape.branches), cat+dog) { + pet, ok := typeByName(doc, "Pet").(*ir.Model) + require.True(t, ok, "Pet lowers to a model beside its union") + assert.Nil(t, pet.Discriminator, "a non-subtype target stays off the model") + assert.Contains(t, pet.Unmodeled, "openapi:discriminator") + } + }) + } +} + +// TestDiscriminatedUnion_DegradedShapesKeepSubtypeDiscriminators pins that the +// gate does not over-reach: a mapping whose every target is a subtype keeps the +// discriminator on the model on each degraded shape. +func TestDiscriminatedUnion_DegradedShapesKeepSubtypeDiscriminators(t *testing.T) { + t.Parallel() + for _, shape := range degradedShapes { + t.Run(shape.name, func(t *testing.T) { + t.Parallel() + for _, doc := range discriminatedOrders(t, "3.1.0", degradedPet(shape.branches), subtypes) { + pet, ok := typeByName(doc, "Pet").(*ir.Model) + require.True(t, ok) + assert.NotNil(t, pet.Discriminator, "every target is a subtype") + assert.NotContains(t, pet.Unmodeled, "openapi:discriminator") + } + }) + } +} + +// TestDiscriminatedUnion_InlineBranchesNameNoSubtype pins that with no mapping +// entry resolving, a union whose only branch is inline gives the discriminator +// nothing to route to, so it moves to Unmodeled. +func TestDiscriminatedUnion_InlineBranchesNameNoSubtype(t *testing.T) { + t.Parallel() + pet := " Pet:\n type: object\n discriminator: {propertyName: kind}\n" + + " oneOf: [{type: object, properties: {kind: {type: string}}}]\n" + for _, doc := range discriminatedOrders(t, "3.1.0", pet, cat) { + m, ok := typeByName(doc, "Pet").(*ir.Model) + require.True(t, ok) + assert.Nil(t, m.Discriminator) + assert.Contains(t, m.Unmodeled, "openapi:discriminator") + } +} + +// TestDiscriminatedUnion_UnresolvedBranchGatesTheDiscriminator pins the same +// rule on a union with a $ref branch that resolves nothing. The unresolved +// $ref is itself an error diagnostic, so the document is checked with +// pass.Validate directly rather than through the no-error harness. +func TestDiscriminatedUnion_UnresolvedBranchGatesTheDiscriminator(t *testing.T) { + t.Parallel() + const branches = " oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Nope'}]\n" + cases := []struct { + name, rest string + keeps bool + }{ + {"non-subtype target", cat + dog, false}, + {"subtype target", subtypes, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + pet := degradedPet(branches) + for _, src := range []string{pet + c.rest, c.rest + pet} { + doc, _ := lowerSpec(t, openapitest.ComponentSpec(src)) + assert.Empty(t, pass.Validate(doc), "the document must validate") + m, ok := typeByName(doc, "Pet").(*ir.Model) + require.True(t, ok) + assert.Equal(t, c.keeps, m.Discriminator != nil) + assert.Equal(t, !c.keeps, hasKey(m.Unmodeled, "openapi:discriminator")) + } + }) + } +} + +func hasKey(m ir.Unmodeled, key string) bool { + _, ok := m[key] + return ok +} diff --git a/docs/ir-design.md b/docs/ir-design.md index 0f277bb9..5cb58d4d 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -832,16 +832,20 @@ from it is exact and nothing is reported. branch is unusable for the same reason an inline one is, so it declines the same way rather than composing a variant whose branch quietly went missing; the broken reference is reported at `error` severity on top of the `info` below; - 4. a `discriminator` is declared, binding the branch schemas by name. The discriminator stays - on the lowered `Model` only when every target it routes to — each `mapping` value and the - `defaultMapping`, or the union's branches when no mapping entry resolves — is a subtype of - that model at any distance along its `allOf` base chain, the rule `pass/validate` holds a - model discriminator to; the compiler reads it from the source schemas, so declaration order - cannot change the answer. Otherwise the whole object (`propertyName`, `mapping`, - `defaultMapping`) is kept verbatim in `Unmodeled["openapi:discriminator"]` beside the union, - and the `info` diagnostic says so; + 4. a `discriminator` is declared, binding the branch schemas by name; 5. `oneOf` and `anyOf` are both declared, so distributing one would drop the other. + The discriminator rule is not case 4's alone: it holds on every one of the five whose body + lowers to a `Model`, and all of them pass through one gate. The discriminator stays on the + `Model` only when every target it routes to — each `mapping` value and the `defaultMapping`, or + the union's branches when no mapping entry resolves, an inline or unresolved branch being no + subtype — is a subtype of that model at any distance along its `allOf` base chain, the rule + `pass/validate` holds a model discriminator to; the compiler reads it from the source schemas, + so declaration order cannot change the answer. Otherwise the whole object (`propertyName`, + `mapping`, `defaultMapping`) is kept verbatim in `Unmodeled["openapi:discriminator"]` beside + the union, and the `info` diagnostic says so. A union whose branches declare no shape + (`validation_only`) is held to the same rule. + In all five, whatever structural body the schema declares is lowered — `properties` or a declared `type` yields a Model or typed node — and the union branches are kept verbatim in `Unmodeled["openapi:oneOf"]` / `["openapi:anyOf"]` + `info` diagnostic naming which of the five @@ -2094,7 +2098,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 `discriminator` beside the collapsed union has no node field to land in, so it is kept verbatim in `Unmodeled["openapi:discriminator"]` on the node or carrier that owns the position, with an `info` diagnostic; the same holds for a discriminator beside a null-only branch set, a union beside a non-model body, or a union beside a `$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), with a declared `discriminator` beside it staying on the model only when every target is a subtype of it and otherwise kept verbatim in `Unmodeled["openapi:discriminator"]` (§4.8 case 4); 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 `discriminator` beside the collapsed union has no node field to land in, so it is kept verbatim in `Unmodeled["openapi:discriminator"]` on the node or carrier that owns the position, with an `info` diagnostic; the same holds for a discriminator beside a null-only branch set, a union beside a non-model body, or a union beside a `$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), with a declared `discriminator` beside it staying on the model only when every target is a subtype of it and otherwise kept verbatim in `Unmodeled["openapi:discriminator"]` (§4.8, on every degraded union whose body is a model, not only case 4); 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 | | **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 | From 68ae8f959941fff5e105df330fbca04cca4fda77 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Fri, 9 Oct 2026 18:54:01 +0300 Subject: [PATCH 03/13] fix(compilers/openapi): report a discriminator moved off a model A co-declared union reported through the validation-only keyword family moved its discriminator to Unmodeled without any diagnostic saying so. The beside closure now emits one info degraded-construct diagnostic at the discriminator pointer when no other diagnostic already names the move, so every degraded path reports it exactly once. Co-Authored-By: Claude Sonnet 5.5 --- compilers/openapi/internal/schema/compose.go | 17 +++++++++++++++++ .../internal/schema/discriminated_test.go | 16 ++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/compilers/openapi/internal/schema/compose.go b/compilers/openapi/internal/schema/compose.go index 2393c1bf..c89e1417 100644 --- a/compilers/openapi/internal/schema/compose.go +++ b/compilers/openapi/internal/schema/compose.go @@ -713,6 +713,11 @@ func lowerCoDeclaredUnion(c lowering.Ctx, ts *compile.Types, anchors *AnchorInde if !moves { return id, diags } + if !namesMovedDiscriminator(diags) { + diags = append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, + pointer+ids.Ptr("discriminator"), "the discriminator routes to a target that is not a subtype "+ + "of this model, so it is kept verbatim")) + } return id, append(diags, moveDiscriminatorToUnmodeled(c, ts, id, s, pointer)...) } switch classifyUnionSiblings(c, s) { @@ -739,6 +744,18 @@ func lowerCoDeclaredUnion(c lowering.Ctx, ts *compile.Types, anchors *AnchorInde } } +// namesMovedDiscriminator reports whether diags already carry an info +// degraded-construct diagnostic that says the discriminator moved off the model. +func namesMovedDiscriminator(diags []ir.Diagnostic) bool { + for _, d := range diags { + if d.Code == diag.DegradedConstruct && d.Severity == ir.SeverityInfo && + strings.Contains(d.Message, discriminatorMovedWhy) { + return true + } + } + return false +} + // diagUnresolvedBranches returns one diagnostic per union branch whose $ref // names nothing this compilation resolves, or none when they all resolve. The branch text survives verbatim under Unmodeled, so // nothing is dropped — but a reference that resolves nowhere is a defect of the diff --git a/compilers/openapi/internal/schema/discriminated_test.go b/compilers/openapi/internal/schema/discriminated_test.go index 19db70c9..fd0f8ea0 100644 --- a/compilers/openapi/internal/schema/discriminated_test.go +++ b/compilers/openapi/internal/schema/discriminated_test.go @@ -239,10 +239,26 @@ func TestDiscriminatedUnion_EveryDegradedShapeGatesTheDiscriminator(t *testing.T assert.Nil(t, pet.Discriminator, "a non-subtype target stays off the model") assert.Contains(t, pet.Unmodeled, "openapi:discriminator") } + for _, src := range []string{degradedPet(shape.branches) + cat + dog, cat + dog + degradedPet(shape.branches)} { + _, diags := parseFull(t, openapitest.ComponentSpecVer("3.1.0", src)) + assert.Equal(t, 1, countMovedDiscriminator(diags), "exactly one diagnostic names the move: %+v", diags) + } }) } } +// countMovedDiscriminator counts the diagnostics saying the discriminator was +// kept verbatim because it routes to a target that is not a subtype. +func countMovedDiscriminator(diags []ir.Diagnostic) int { + n := 0 + for _, d := range diags { + if strings.Contains(d.Message, "not a subtype of this model") { + n++ + } + } + return n +} + // TestDiscriminatedUnion_DegradedShapesKeepSubtypeDiscriminators pins that the // gate does not over-reach: a mapping whose every target is a subtype keeps the // discriminator on the model on each degraded shape. From ec40e2b16f5034e3dc77005de70e1e1a3c1d9cf1 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 13 Oct 2026 13:34:59 +0300 Subject: [PATCH 04/13] feat(compilers/compile): defer decisions that read the whole registry A lowering walks one declaration at a time, so a decision about a node that reads nodes other declarations intern sees only those lowered so far, and its answer follows declaration order. A Settlement lets a lowering defer such a decision, and Types.Settle takes every deferred one once the walk has ended, against the whole registry. Settle refuses a settlement that adds a node, since nothing lowers after it, and Defer refuses one deferred after Settle has run, which would never be taken. The facility lives on the registry because Types is the one handle a lowering holds. --- compilers/compile/settle.go | 48 ++++++++++++++ compilers/compile/settle_test.go | 103 +++++++++++++++++++++++++++++++ compilers/compile/types.go | 4 ++ docs/architecture.md | 6 +- 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 compilers/compile/settle.go create mode 100644 compilers/compile/settle_test.go diff --git a/compilers/compile/settle.go b/compilers/compile/settle.go new file mode 100644 index 00000000..c24577a7 --- /dev/null +++ b/compilers/compile/settle.go @@ -0,0 +1,48 @@ +package compile + +import "github.com/dexpace/morphic/ir" + +// A Settlement is a decision about a node that reads nodes other lowerings +// intern. A walk lowers one declaration at a time, so mid-walk such a decision +// would read whatever had been lowered so far, and its answer would follow +// declaration order (invariant 3). Deferred to Settle, it reads the registry +// whole. +// +// Settle may rewrite nodes already in reg but must not add one: nothing lowers +// after it, so a node it added would be the only one no lowering produced. +type Settlement interface { + Settle(reg ir.TypeRegistry) []ir.Diagnostic +} + +// Defer queues s for Settle. One deferred once Settle has run would never be +// taken, and a nil one cannot be, so both are refused. +func (t *Types) Defer(s Settlement) { + if t.settled { + t.refuse("defer rejected: the registry has already settled") + return + } + if s == nil { + t.refuse("defer rejected: nil settlement") + return + } + t.pending = append(t.pending, s) +} + +// Settle takes every deferred Settlement once, in the order deferred, and +// returns their diagnostics. A compiler calls it after its last lowering +// returns. A settlement that adds a node is refused; the node stays, as it may +// already be referenced. +func (t *Types) Settle() []ir.Diagnostic { + t.settled = true + pending := t.pending + t.pending = nil + diags := make([]ir.Diagnostic, 0, len(pending)) + for _, s := range pending { + before := len(t.reg) + diags = append(diags, s.Settle(t.reg)...) + if added := len(t.reg) - before; added != 0 { + t.refuse("settlement %T added %d node(s); a settlement may only rewrite nodes the walk interned", s, added) + } + } + return diags +} diff --git a/compilers/compile/settle_test.go b/compilers/compile/settle_test.go new file mode 100644 index 00000000..59b57860 --- /dev/null +++ b/compilers/compile/settle_test.go @@ -0,0 +1,103 @@ +package compile_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/compile" + "github.com/dexpace/morphic/ir" +) + +// settleFunc adapts a function to compile.Settlement. +type settleFunc func(reg ir.TypeRegistry) []ir.Diagnostic + +func (f settleFunc) Settle(reg ir.TypeRegistry) []ir.Diagnostic { return f(reg) } + +// TestTypes_SettleTakesEachDeferredSettlementOnceInOrder pins what a compiler +// relies on: every settlement it deferred runs, once, against the live +// registry, and the diagnostics come back in the order deferred. +func TestTypes_SettleTakesEachDeferredSettlementOnceInOrder(t *testing.T) { + t.Parallel() + types := compile.NewTypes() + types.Intern("/p", "t/x", func() ir.TypeDef { return &ir.Model{ID: "t/x"} }) + var runs []string + for _, name := range []string{"first", "second"} { + types.Defer(settleFunc(func(reg ir.TypeRegistry) []ir.Diagnostic { + runs = append(runs, name) + reg["t/x"].Common().Docs.Description += name + return []ir.Diagnostic{{Code: name}} + })) + } + + diags := types.Settle() + + assert.Equal(t, []string{"first", "second"}, runs, "each runs once, in the order deferred") + require.Len(t, diags, 2) + assert.Equal(t, "first", diags[0].Code) + assert.Equal(t, "second", diags[1].Code) + td, ok := types.Node("t/x") + require.True(t, ok) + assert.Equal(t, "firstsecond", td.Common().Docs.Description, "a settlement rewrites the registry the document holds") + assert.Empty(t, types.Settle(), "a settlement taken is not taken again") + assert.Empty(t, types.Violations()) +} + +// TestTypes_SettleWithNothingDeferredReturnsNothing pins the common case: a +// compile that deferred nothing settles to no diagnostics and no violation. +func TestTypes_SettleWithNothingDeferredReturnsNothing(t *testing.T) { + t.Parallel() + types := compile.NewTypes() + + assert.Empty(t, types.Settle()) + assert.Empty(t, types.Violations()) +} + +// TestTypes_DeferRefusesWhatSettleWouldNeverTake pins the two settlements +// Defer refuses rather than drops: one deferred after Settle ran, which no +// later Settle is promised to take, and a nil one, which cannot be taken. +func TestTypes_DeferRefusesWhatSettleWouldNeverTake(t *testing.T) { + t.Parallel() + cases := []struct { + name string + queue func(types *compile.Types, s compile.Settlement) + }{ + {"after settling", func(types *compile.Types, s compile.Settlement) { + types.Settle() + types.Defer(s) + }}, + {"nil", func(types *compile.Types, _ compile.Settlement) { types.Defer(nil) }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + types := compile.NewTypes() + runs := 0 + c.queue(types, settleFunc(func(ir.TypeRegistry) []ir.Diagnostic { runs++; return nil })) + + assert.Empty(t, types.Settle()) + assert.Zero(t, runs) + assert.Len(t, types.Violations(), 1, "the refusal is recorded, not dropped") + }) + } +} + +// TestTypes_SettlementThatAddsANodeIsRefused pins the one write a settlement +// may not make. Nothing lowers after Settle, so a node one added would be the +// only node no lowering produced; the registry keeps it and reports the breach. +func TestTypes_SettlementThatAddsANodeIsRefused(t *testing.T) { + t.Parallel() + types := compile.NewTypes() + types.Defer(settleFunc(func(reg ir.TypeRegistry) []ir.Diagnostic { + reg["t/added"] = &ir.Model{ID: "t/added"} + return nil + })) + + types.Settle() + + require.Len(t, types.Violations(), 1) + assert.Contains(t, types.Violations()[0], "added 1 node") + _, kept := types.Node("t/added") + assert.True(t, kept, "the node stays, since something may already reference it") +} diff --git a/compilers/compile/types.go b/compilers/compile/types.go index 987e0ba1..89a04179 100644 --- a/compilers/compile/types.go +++ b/compilers/compile/types.go @@ -34,6 +34,10 @@ type Types struct { // byReference holds the coordinates whose node a reference built and the // declaration that owns them has not rebuilt yet. See InternDeclared. byReference map[string]bool + // pending holds the settlements Defer queued for Settle, and settled + // records that Settle has run. See Settlement. + pending []Settlement + settled bool } // refuse records why an entry was rejected. The registry declines to hold it diff --git a/docs/architecture.md b/docs/architecture.md index 481921de..9c7d4fe2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -90,7 +90,11 @@ What a compiler does *not* own is in `compilers/compile`: the state and the gram must agree on. That is the type registry with the source-coordinate map behind stable IDs (and the rule that a minted node takes a namespace no source coordinate addresses), diagnostic accumulation, the canonical naming grammar, and the identifier grammar — the kind prefix and the namespace after -it, with the declaration of the namespaces a compiler mints in. What stays with the compiler is what +it, with the declaration of the namespaces a compiler mints in. The registry also holds the +decisions about a node that read nodes other lowerings intern (`compile.Settlement`), deferred until +the walk ends: a walk lowers one declaration at a time, so taken mid-walk they would follow +declaration order. They live on the registry, though only OpenAPI defers one yet, because the +registry is the one handle a lowering holds (micro-compiler-design §4). What stays with the compiler is what only it can compute: the path an ID derives from, since a JSON Pointer, a GraphQL structural path and a protobuf fully-qualified name are different things. From 8e86bd88da2df798485baac1bebb05de308fa9e2 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 13 Oct 2026 13:34:59 +0300 Subject: [PATCH 05/13] fix(compilers/openapi): judge a discriminator's home by what lowered A discriminator beside a union kept verbatim stayed on the model its body lowered to even where it routed to a type that is no subtype of that model, which pass/validate rejects (#593). The gate that decided this read the source schemas, mirroring what Model.Base would become, and a route whose allOf names the model but which lowers to an enum, a literal, an alias over a $ref or a distributed union fooled it. The decision is now a settlement taken once every node is interned, with ir.IsSubtype, the predicate pass/validate applies. Each mapping target, the default and each component a $ref branch names, which the discriminator routes to by name, must be a subtype for it to stay. Where no Model or Union routes by a discriminator it was dropped without a diagnostic: beside a union that collapses to a nullable reference (#595), a null-only branch set, a non-model body or a $ref, and beside a scalar, enum, list or bare schema. The discriminator is now a census keyword, so each keeps it verbatim with an info diagnostic. The collapse and the null-only set never reach lower(), so they run the census themselves; the collapse takes a $ref site's, which keeps a format or collection bound beside it as well. --- compilers/openapi/conformance_test.go | 28 +- .../openapi/conformance_unmodeled_test.go | 14 +- compilers/openapi/internal/schema/compose.go | 36 +- .../internal/schema/compose_internal_test.go | 12 - .../openapi/internal/schema/discriminated.go | 214 +++------ .../schema/discriminated_internal_test.go | 34 ++ .../internal/schema/discriminated_test.go | 452 ++++++++++-------- compilers/openapi/internal/schema/resolve.go | 8 +- compilers/openapi/internal/schema/schema.go | 101 ++-- .../internal/schema/schema_internal_test.go | 31 +- ..._test.go => unhomed_discriminator_test.go} | 176 ++++++- compilers/openapi/openapi.go | 7 +- docs/ir-design.md | 44 +- ...scriminated-union-beside-model.golden.json | 274 ++++++++++- .../discriminated-union-beside-model.yaml | 29 +- .../nullable-union-discriminator.golden.json | 4 +- .../openapi/unhomed-keywords.golden.json | 82 +++- .../conformance/openapi/unhomed-keywords.yaml | 4 + 18 files changed, 1050 insertions(+), 500 deletions(-) create mode 100644 compilers/openapi/internal/schema/discriminated_internal_test.go rename compilers/openapi/internal/schema/{collapse_discriminator_test.go => unhomed_discriminator_test.go} (50%) diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index 1dd0ac2c..0cdf86cc 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -1024,21 +1024,25 @@ func assertDiscriminatorDefaultMapping(t *testing.T, doc *ir.Document, diags []i } // assertDiscriminatedUnionBesideModel pins where a discriminator goes when its -// union is kept verbatim beside a model. Pet's targets are not subtypes of it, -// so pass.Validate would reject the discriminator on the model; it moves whole -// to Unmodeled beside the union. Shape's targets are true allOf subtypes, so -// the model keeps it, and the document carries no validation error. +// union is kept verbatim beside a model. Pet's and Animal's routes are not +// subtypes of them, so pass.Validate would reject the discriminator on the +// model; it moves whole to Unmodeled beside the union. Shape's routes are true +// allOf subtypes, so the model keeps it, and the document validates clean. func assertDiscriminatedUnionBesideModel(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { assert.Empty(t, pass.Validate(doc)) - pet, ok := doc.Types[namedID("Pet")].(*ir.Model) - require.True(t, ok) - assert.Nil(t, pet.Discriminator, "its targets are not subtypes, so the model does not carry it") - entry, ok := pet.Unmodeled["openapi:discriminator"] - require.True(t, ok, "it is kept verbatim instead") - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.Contains(t, pet.Unmodeled, "openapi:oneOf", "beside the union it routes") - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/Pet")) + for _, name := range []string{"Pet", "Animal"} { + m, ok := doc.Types[namedID(name)].(*ir.Model) + require.True(t, ok, name) + assert.Nil(t, m.Discriminator, "%s routes to a non-subtype, so the model does not carry it", name) + entry, ok := m.Unmodeled["openapi:discriminator"] + require.True(t, ok, "%s keeps it verbatim instead", name) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.Contains(t, m.Unmodeled, "openapi:oneOf", "beside the union it routes through") + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/"+name+"/discriminator")) + } + _, isUnion := doc.Types[namedID("Bird")].(*ir.Union) + assert.True(t, isUnion, "Bird writes allOf to Animal but lowers to a union, which is no subtype") shape, ok := doc.Types[namedID("Shape")].(*ir.Model) require.True(t, ok) diff --git a/compilers/openapi/conformance_unmodeled_test.go b/compilers/openapi/conformance_unmodeled_test.go index f914fdec..3e09334e 100644 --- a/compilers/openapi/conformance_unmodeled_test.go +++ b/compilers/openapi/conformance_unmodeled_test.go @@ -47,10 +47,11 @@ func diagsAt(diags []ir.Diagnostic, code, pointer string) []ir.Severity { // the top type, which understates it entirely. func assertUnhomedKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { untyped := map[string]struct{ keyword, raw string }{ - "UntypedItems": {"items", `{"type":"string"}`}, - "UntypedPrefixItems": {"prefixItems", `[{"type":"string"}]`}, - "UntypedRequired": {"required", `["name"]`}, - "UntypedFormat": {"format", `"uuid"`}, + "UntypedItems": {"items", `{"type":"string"}`}, + "UntypedPrefixItems": {"prefixItems", `[{"type":"string"}]`}, + "UntypedRequired": {"required", `["name"]`}, + "UntypedFormat": {"format", `"uuid"`}, + "UntypedDiscriminator": {"discriminator", `{"propertyName":"kind"}`}, } for name, want := range untyped { sc, ok := doc.Types[namedID(name)].(*ir.Scalar) @@ -101,8 +102,8 @@ func assertUnhomedKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic // assertRefSiteKeywords pins the same census at a $ref site, where it did not // run at all (GitHub #283). $ref is an ordinary 2020-12 keyword, so its siblings // are conjoined with it; the position lowers to an alias over the target, which -// has none of their fields, and the five below reached no field, no Unmodeled -// entry and no diagnostic. +// has none of their fields, and each below reached no field, no Unmodeled entry +// and no diagnostic. func assertRefSiteKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { for name, want := range map[string]struct{ keyword, target, raw string }{ "RefFormat": {"format", "RefTargetStr", `"email"`}, @@ -110,6 +111,7 @@ func assertRefSiteKeywords(t *testing.T, doc *ir.Document, diags []ir.Diagnostic "RefConst": {"const", "RefTargetStr", `"a"`}, "RefRequired": {"required", "RefTargetObj", `["a"]`}, "RefAdditionalProperties": {"additionalProperties", "RefTargetObj", `false`}, + "RefDiscriminator": {"discriminator", "RefTargetObj", `{"propertyName":"a"}`}, } { sc, ok := doc.Types[namedID(name)].(*ir.Scalar) require.True(t, ok, "%s hoists an alias to hold what it wrote beside its $ref", name) diff --git a/compilers/openapi/internal/schema/compose.go b/compilers/openapi/internal/schema/compose.go index 7133eb1d..59a430b7 100644 --- a/compilers/openapi/internal/schema/compose.go +++ b/compilers/openapi/internal/schema/compose.go @@ -579,18 +579,17 @@ func lowerNullOnlyUnion(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, point // preserveNullOnlyUnion keeps s's oneOf/anyOf verbatim under the owning node's // Unmodeled: lowerNullOnlyUnion's node is the shared `any` primitive or an // alias over it, neither of which carries a branch set of its own to hold -// them in. +// them in. It also runs the census lower() runs for a body, which this arm +// never reaches, so a discriminator written beside the branches is kept too. func preserveNullOnlyUnion(c lowering.Ctx, ts *compile.Types, id ir.TypeID, s *oas3.Schema, pointer jsontext.Pointer) []ir.Diagnostic { td, ok, diags := registeredNode(c, ts, id, pointer) if !ok { return diags } common := td.Common() + diags = append(diags, recordUnhomedAt(c, &common.Unmodeled, s, unhomedKeywords(s, td, nil), nodeShape(td.Kind()), pointer)...) kept, keepDiags := preserveBranchSets(c, &common.Unmodeled, s, ir.ReasonDegradedLowering, pointer) diags = append(diags, keepDiags...) - diags = append(diags, keepUnplacedDiscriminator(c, &common.Unmodeled, s, pointer, - "every branch admits only the null value, so a discriminator beside them routes "+ - "through no union; kept verbatim under Unmodeled")...) if len(kept) == 0 { return diags } @@ -697,26 +696,11 @@ const coDeclaredUnionWhyPrefix = "oneOf/anyOf co-declared with structural keywor // lowerCoDeclaredUnion lowers a schema whose oneOf/anyOf sits beside structural // keywords, per classifyUnionSiblings. func lowerCoDeclaredUnion(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s *oas3.Schema, pointer jsontext.Pointer, hint string) (ir.TypeID, []ir.Diagnostic) { - // beside is the one place a body lowered to a Model beside a kept union gets - // its discriminator judged: every degraded outcome goes through it. beside := func(reason ir.UnmodeledReason, why string) (ir.TypeID, []ir.Diagnostic) { - moves := s.GetDiscriminator() != nil && composesAsModel(s) && !discriminatorRoutesToSubtypes(c, s) if why != "" { why = coDeclaredUnionWhyPrefix + why - if moves { - why += discriminatorMovedWhy - } - } - id, diags := lowerBesideUnmodeledUnion(c, ts, anchors, depth, s, pointer, hint, reason, why) - if !moves { - return id, diags } - if !namesMovedDiscriminator(diags) { - diags = append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, - pointer+ids.Ptr("discriminator"), "the discriminator routes to a target that is not a subtype "+ - "of this model, so it is kept verbatim")) - } - return id, append(diags, moveDiscriminatorToUnmodeled(c, ts, id, s, pointer)...) + return lowerBesideUnmodeledUnion(c, ts, anchors, depth, s, pointer, hint, reason, why) } switch classifyUnionSiblings(c, s) { case unionDistributed: @@ -742,18 +726,6 @@ func lowerCoDeclaredUnion(c lowering.Ctx, ts *compile.Types, anchors *AnchorInde } } -// namesMovedDiscriminator reports whether diags already carry an info -// degraded-construct diagnostic that says the discriminator moved off the model. -func namesMovedDiscriminator(diags []ir.Diagnostic) bool { - for _, d := range diags { - if d.Code == diag.DegradedConstruct && d.Severity == ir.SeverityInfo && - strings.Contains(d.Message, discriminatorMovedWhy) { - return true - } - } - return false -} - // diagUnresolvedBranches returns one diagnostic per union branch whose $ref // names nothing this compilation resolves, or none when they all resolve. The branch text survives verbatim under Unmodeled, so // nothing is dropped — but a reference that resolves nowhere is a defect of the diff --git a/compilers/openapi/internal/schema/compose_internal_test.go b/compilers/openapi/internal/schema/compose_internal_test.go index 01c01ffa..4b5d4ccb 100644 --- a/compilers/openapi/internal/schema/compose_internal_test.go +++ b/compilers/openapi/internal/schema/compose_internal_test.go @@ -802,15 +802,3 @@ func TestResolveMappingTarget_DefsValueNothingInternedIsHoistedAtItsOwnPosition( }) } } - -// TestMoveDiscriminatorToUnmodeled_ReportsAMissingNode pins that a node absent -// from the registry is announced, not skipped: the discriminator would vanish -// from the model with nowhere to be kept. -func TestMoveDiscriminatorToUnmodeled_ReportsAMissingNode(t *testing.T) { - t.Parallel() - - diags := moveDiscriminatorToUnmodeled(lowering.Ctx{}, compile.NewTypes(), "t/absent", nil, "/components/schemas/Ghost") - - require.Len(t, diags, 1) - assert.Equal(t, diag.InternalInvariant, diags[0].Code) -} diff --git a/compilers/openapi/internal/schema/discriminated.go b/compilers/openapi/internal/schema/discriminated.go index a632724e..464855cb 100644 --- a/compilers/openapi/internal/schema/discriminated.go +++ b/compilers/openapi/internal/schema/discriminated.go @@ -2,6 +2,8 @@ package schema import ( "encoding/json/jsontext" + "maps" + "slices" oas3 "github.com/speakeasy-api/openapi/jsonschema/oas3" @@ -12,167 +14,87 @@ import ( "github.com/dexpace/morphic/ir" ) -// discriminatorMovedWhy ends the degraded-lowering diagnostic when the -// discriminator leaves the model for Unmodeled beside the union. -const discriminatorMovedWhy = ", and it routes to a target that is not a subtype of this model, " + - "so the discriminator is kept verbatim too" - -// discriminatorRoutesToSubtypes reports whether every target the discriminator -// on s routes to is a subtype of s, the rule pass/validate holds a model -// discriminator to. Targets are the mapping values and defaultMapping, or the -// union branches when no mapping entry resolves, an inline or -// unresolved branch counting as a non-subtype. It reads the raw schemas, not -// the registry, where a subtype declared after s has no Base yet, so the answer -// cannot depend on declaration order. s declares a discriminator. -func discriminatorRoutesToSubtypes(c lowering.Ctx, s *oas3.Schema) bool { - targets, mapped := mappingSchemas(c, s.GetDiscriminator()) - if !mapped { - branches, _, _ := unionBranches(s) - for _, b := range branches { - t := refBranchTarget(b) - if t == nil { - // An inline branch names no component, so it is no subtype. - return false - } - targets = append(targets, t) - } - } - for _, t := range targets { - if !reachesByBase(t, s) { - return false - } - } - return true -} - -// appendSchema appends t to dst unless it is nil, which is how a target that -// resolves to nothing drops out. -func appendSchema(dst []*oas3.Schema, t *oas3.Schema) []*oas3.Schema { - if t == nil { - return dst - } - return append(dst, t) -} - -// mappingSchemas returns the schemas d's mapping values and defaultMapping -// resolve to, and whether any mapping entry resolved. An unresolvable value -// contributes nothing. -func mappingSchemas(c lowering.Ctx, d *oas3.Discriminator) ([]*oas3.Schema, bool) { - var out []*oas3.Schema - if m := d.GetMapping(); m != nil { - for _, target := range m.All() { - out = appendSchema(out, mappingSchema(c, d, target)) - } - } - mapped := len(out) > 0 - if dm := d.GetDefaultMapping(); dm != "" { - out = appendSchema(out, mappingSchema(c, d, dm)) +// deferDiscriminatorBesideUnion queues the settlement of whatever discriminator +// the model at id holds beside the union lowerBesideUnmodeledUnion kept +// verbatim. A body that lowered to anything else holds none: the census kept +// its discriminator where it kept the rest. +func deferDiscriminatorBesideUnion(c lowering.Ctx, ts *compile.Types, id ir.TypeID, s *oas3.Schema, pointer jsontext.Pointer) { + td, _ := ts.Node(id) + if _, isModel := td.(*ir.Model); !isModel { + return } - return out, mapped + ts.Defer(besideUnionDiscriminator{c: c, s: s, pointer: pointer, model: id, named: namedBranches(c, s)}) } -// mappingSchema returns the schema a mapping value names, or nil when it names -// none this compilation resolves. A component names its own node, whatever it -// declares, so it is not read through; any other position that is only a $ref -// names the position the $ref does (typePosition). -func mappingSchema(c lowering.Ctx, d *oas3.Discriminator, target string) *oas3.Schema { +// namedBranches returns the components s's union branches name by $ref, in +// branch order: the types a discriminator routes to by name when no mapping +// key routes to them. An inline branch has no name and neither has any other +// position, so the discriminator reaches those only through its mapping, which +// the model's Discriminator already holds. +func namedBranches(c lowering.Ctx, s *oas3.Schema) []ir.TypeID { scope := c.RefScope() - pointer := ids.Ptr("components", "schemas", target) - if !c.DeclaresSchema(target) { - var ok bool - if pointer, ok = scope.MappingPointer(d, target); !ok { - return nil - } - } - js := scope.DeclaredAt(pointer) - if _, named := ids.ComponentSchemaName(pointer); named { - return js.GetSchema() - } - if t := refBranchTarget(js); t != nil { - return t - } - return js.GetSchema() -} - -// reachesByBase reports whether base is an ancestor of target along the allOf -// base chain: the branch selectAllOfBase elects at each level, which is what -// lowers to Model.Base. A mixin does not count, as isSubtype ignores Mixins. -// The chain is bounded and cycle-checked. -func reachesByBase(target, base *oas3.Schema) bool { - seen := map[*oas3.Schema]bool{target: true} - for cur, hop := target, 0; hop < maxDiscriminatorAncestorDepth; hop++ { - branches := cur.GetAllOf() - i := selectAllOfBase(branches) - if i < 0 { - return false - } - next := refBranchTarget(branches[i]) - if next == base { - return true + branches, _, _ := unionBranches(s) + var named []ir.TypeID + for _, b := range branches { + pointer, internal := scope.TargetPointer(b, b.GetRef().String()) + if !internal { + continue } - if next == nil || seen[next] { - return false + if id, declared, component := scope.ComponentRef(pointer); component && declared { + named = append(named, id) } - seen[next] = true - cur = next } - return false + return named } -// moveDiscriminatorToUnmodeled takes the discriminator off the model that -// lowered beside a kept union and stores it verbatim in that model's Unmodeled, -// whole: propertyName, mapping and defaultMapping. A model discriminator that -// routes to a non-subtype is one pass/validate rejects, and the union that -// would have given it variants is kept verbatim, not lowered. -func moveDiscriminatorToUnmodeled(c lowering.Ctx, ts *compile.Types, id ir.TypeID, s *oas3.Schema, pointer jsontext.Pointer) []ir.Diagnostic { - td, ok, diags := registeredNode(c, ts, id, pointer) - if !ok { - return diags - } - if m, isModel := td.(*ir.Model); isModel { - m.Discriminator = nil - } - _, keepDiags := PreserveSchemaKeyword(c, &td.Common().Unmodeled, s, "discriminator", - ir.ReasonDegradedLowering, pointer+ids.Ptr("discriminator")) - return append(diags, keepDiags...) +// besideUnionDiscriminator settles a discriminator a model holds beside a +// union kept verbatim. A model discriminator says the model is a hierarchy's +// base, so it stays only where every type it routes to is a subtype, the rule +// pass/validate holds it to (ir.IsSubtype). Otherwise it moves, whole, to +// Unmodeled beside the union it was written for. +// +// It is a settlement because a subtype lowered after the model has no Base yet +// when the model lowers. +type besideUnionDiscriminator struct { + c lowering.Ctx + s *oas3.Schema + pointer jsontext.Pointer + model ir.TypeID + named []ir.TypeID // namedBranches } -// collapsesBesideDiscriminator reports whether s is a nullable-X union that -// lowerOneOfAnyOf collapses to a reference (nullUnionCollapse) and that also -// declares a discriminator. A reference has no field for one, so the position -// needs a node of its own to keep it on. -func collapsesBesideDiscriminator(s *oas3.Schema) bool { - if s.GetDiscriminator() == nil || hasUnionSiblings(s) { - return false +// Settle keeps the model's discriminator or moves it. A model holding none is +// left alone: it declared none, or it was settled already, by the settlement a +// reference deferred when it built the model before its declaration did. +func (d besideUnionDiscriminator) Settle(reg ir.TypeRegistry) []ir.Diagnostic { + m, isModel := reg[d.model].(*ir.Model) + if !isModel || m == nil { + return []ir.Diagnostic{d.c.DiagAt(ir.SeverityError, diag.InternalInvariant, d.pointer, + "internal: %q is no model, so the discriminator deferred for it is dropped", d.model)} } - _, _, _, collapses := nullUnionCollapse(s, "") - return collapses -} - -// keepUnplacedDiscriminator keeps, verbatim under p, the discriminator s -// declares and the lowering at this position has no field to hold, and reports -// it with msg at pointer. It is the one mechanism every such site shares, so a -// union lowering that cannot place a discriminator cannot drop it quietly. It -// does nothing when s declares none. p is the Unmodeled of whatever owns the -// position: a node the position hoisted, or a carrier's own. -func keepUnplacedDiscriminator(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, pointer jsontext.Pointer, msg string) []ir.Diagnostic { - if s.GetDiscriminator() == nil { + if m.Discriminator == nil || routesToSubtypes(reg, m, d.named) { return nil } - _, diags := PreserveSchemaKeyword(c, p, s, "discriminator", - ir.ReasonDegradedLowering, pointer+ids.Ptr("discriminator")) - return append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, pointer, "%s", msg)) + m.Discriminator = nil + at := d.pointer + ids.Ptr("discriminator") + _, diags := PreserveSchemaKeyword(d.c, &m.Unmodeled, d.s, "discriminator", ir.ReasonDegradedLowering, at) + return append(diags, d.c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, at, + "the discriminator routes to a type that is not a subtype of this model, so it cannot stay on "+ + "the model; kept verbatim under Unmodeled beside the union it was written for")) } -// preserveCollapsedDiscriminator keeps the discriminator of a union that -// collapsed to a nullable reference. The collapse lowers {X, null} to nullable -// X, which routes nothing by tag, so the discriminator (propertyName, mapping -// and defaultMapping) would otherwise vanish without a word. -func preserveCollapsedDiscriminator(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, pointer jsontext.Pointer) []ir.Diagnostic { - if !collapsesBesideDiscriminator(s) { - return nil +// routesToSubtypes reports whether every type m's discriminator routes to is a +// subtype of m: each mapping target, the default, and each of named. +func routesToSubtypes(reg ir.TypeRegistry, m *ir.Model, named []ir.TypeID) bool { + doc := &ir.Document{Types: reg} + routes := slices.AppendSeq(slices.Clone(named), maps.Values(m.Discriminator.Mapping)) + if m.Discriminator.Default != "" { + routes = append(routes, m.Discriminator.Default) } - return keepUnplacedDiscriminator(c, p, s, pointer, - "a discriminator beside a oneOf/anyOf that collapses to a nullable reference "+ - "routes through no union; kept verbatim under Unmodeled") + for _, target := range routes { + if !ir.IsSubtype(doc, target, m.ID) { + return false + } + } + return true } diff --git a/compilers/openapi/internal/schema/discriminated_internal_test.go b/compilers/openapi/internal/schema/discriminated_internal_test.go new file mode 100644 index 00000000..7cdbbaf9 --- /dev/null +++ b/compilers/openapi/internal/schema/discriminated_internal_test.go @@ -0,0 +1,34 @@ +package schema + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/lowering" + "github.com/dexpace/morphic/ir" +) + +// TestBesideUnionDiscriminator_ReportsWhatIsNoModel pins that a settlement +// whose model is missing, or is no model, is announced rather than skipped: +// the discriminator it was deferred for would otherwise vanish unremarked. +func TestBesideUnionDiscriminator_ReportsWhatIsNoModel(t *testing.T) { + t.Parallel() + for name, reg := range map[string]ir.TypeRegistry{ + "missing": {}, + "no model": {"t/ghost": &ir.Scalar{ID: "t/ghost"}}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + d := besideUnionDiscriminator{c: lowering.Ctx{}, pointer: "/components/schemas/Ghost", model: "t/ghost"} + + diags := d.Settle(reg) + + require.Len(t, diags, 1) + assert.Equal(t, diag.InternalInvariant, diags[0].Code) + assert.Equal(t, ir.SeverityError, diags[0].Severity) + }) + } +} diff --git a/compilers/openapi/internal/schema/discriminated_test.go b/compilers/openapi/internal/schema/discriminated_test.go index fd0f8ea0..374c43bc 100644 --- a/compilers/openapi/internal/schema/discriminated_test.go +++ b/compilers/openapi/internal/schema/discriminated_test.go @@ -2,6 +2,7 @@ package schema_test import ( "fmt" + "slices" "strings" "testing" @@ -19,11 +20,16 @@ const ( cat = " Cat: {type: object, properties: {kind: {type: string}, meow: {type: boolean}}}\n" dog = " Dog: {type: object, properties: {kind: {type: string}, bark: {type: boolean}}}\n" - // subtypes are Cat and Dog declared as the allOf subtypes of Pet. - subtypes = " Cat: {allOf: [{$ref: '#/components/schemas/Pet'}, {type: object, properties: {meow: {type: boolean}}}]}\n" + - " Dog: {allOf: [{$ref: '#/components/schemas/Pet'}, {type: object, properties: {bark: {type: boolean}}}]}\n" + // subCat and subDog declare Cat and Dog as allOf subtypes of Pet. + subCat = " Cat: {allOf: [{$ref: '#/components/schemas/Pet'}, {type: object, properties: {meow: {type: boolean}}}]}\n" + subDog = " Dog: {allOf: [{$ref: '#/components/schemas/Pet'}, {type: object, properties: {bark: {type: boolean}}}]}\n" + subtypes = subCat + subDog petBranches = " oneOf:\n - $ref: '#/components/schemas/Cat'\n - $ref: '#/components/schemas/Dog'\n" + + // movedPointer is where the diagnostic for a discriminator kept verbatim + // rather than on Pet is reported. + movedPointer = "/components/schemas/Pet/discriminator" ) // petWith declares Pet, a model whose oneOf names Cat and Dog, beside the @@ -32,8 +38,10 @@ func petWith(discriminator string) string { return " Pet:\n type: object\n discriminator: " + discriminator + "\n" + petBranches } -// discriminatedOrders compiles pet and rest in both declaration orders, so a -// lowering that read the registry mid-walk would answer differently for each. +// discriminatedOrders compiles pet and rest in both declaration orders, rest +// first second, and requires each to validate and the two to agree on their +// types and on what they report. A decision read off the registry while Pet +// lowers sees no subtype declared after it. func discriminatedOrders(t *testing.T, version, pet, rest string) []*ir.Document { t.Helper() docs := make([]*ir.Document, 0, 2) @@ -43,172 +51,175 @@ func discriminatedOrders(t *testing.T, version, pet, rest string) []*ir.Document assert.Empty(t, pass.Validate(doc), "the document must validate") docs = append(docs, doc) } - assert.Empty(t, cmp.Diff(docs[0], docs[1], orderInvariantIR()...), "declaration order must not change the document") + assert.Empty(t, cmp.Diff(docs[0].Types, docs[1].Types, orderInvariantIR()...), + "declaration order must not change the types") + assert.Equal(t, reported(docs[0].Diagnostics), reported(docs[1].Diagnostics), + "declaration order must not change what is reported, only the order it is reported in") return docs } -// TestDiscriminatedUnion_NonSubtypeTargetsStayVerbatim pins that the union kept -// verbatim beside a model does not leave its discriminator on that model when a -// target it routes to is not a subtype of it, which pass.Validate rejects as -// discriminator-missing-variant. The whole discriminator moves to Unmodeled -// beside the union, with the reason the union is kept for. Each row is compiled -// in both declaration orders. -func TestDiscriminatedUnion_NonSubtypeTargetsStayVerbatim(t *testing.T) { +// reported renders diags as a sorted multiset of what was reported where, since +// diagnostics are appended in the order the walk reaches them. +func reported(diags []ir.Diagnostic) []string { + out := make([]string, 0, len(diags)) + for _, d := range diags { + out = append(out, fmt.Sprintf("%s %s %s: %s", d.Severity, d.Code, d.Provenance.Pointer, d.Message)) + } + slices.Sort(out) + return out +} + +// petModel returns Pet, which lowers to a model beside its kept union. +func petModel(t *testing.T, doc *ir.Document) *ir.Model { + t.Helper() + pet, ok := typeByName(doc, "Pet").(*ir.Model) + require.True(t, ok, "Pet lowers to a model beside its union") + assert.Contains(t, pet.Unmodeled, "openapi:oneOf", "the union is kept beside it") + return pet +} + +// assertMoved requires Pet's discriminator to be kept verbatim, whole, rather +// than on the model, and want to appear in what was kept. +func assertMoved(t *testing.T, doc *ir.Document, want string) { + t.Helper() + pet := petModel(t, doc) + assert.Nil(t, pet.Discriminator, "a discriminator routing to a non-subtype stays off the model") + entry, ok := pet.Unmodeled["openapi:discriminator"] + require.True(t, ok, "the discriminator is kept verbatim") + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.Equal(t, ir.Provenance{Pointer: movedPointer}, entry.Provenance) + assert.Contains(t, string(entry.Value), `"propertyName":"kind"`, "the whole object is kept") + assert.Contains(t, string(entry.Value), want) +} + +// assertStayed requires Pet to hold its discriminator and to store it nowhere +// else. +func assertStayed(t *testing.T, doc *ir.Document) { + t.Helper() + pet := petModel(t, doc) + require.NotNil(t, pet.Discriminator, "every route is a subtype, so the model keeps it") + assert.Equal(t, "kind", pet.Discriminator.PropertyName) + assert.NotContains(t, pet.Unmodeled, "openapi:discriminator", "nothing is stored twice") +} + +// TestDiscriminatorBesideUnion_MovesWhenARouteIsNoSubtype pins that a model +// beside a union kept verbatim does not keep a discriminator that routes to a +// type that is not its subtype, which pass.Validate rejects. Each route counts: +// a mapping target, the default, and a component a $ref branch names, which +// the discriminator routes to by name. Each "lowers to" row names a type whose +// own allOf names Pet but which lowers to no Model with that Base, which only +// the lowered document can tell. +func TestDiscriminatorBesideUnion_MovesWhenARouteIsNoSubtype(t *testing.T) { t.Parallel() - const mixed = " Cat: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n" + const viaPet = "{allOf: [{$ref: '#/components/schemas/Pet'}]" cases := []struct { - name string - version string - discriminator string - rest string - wantValue string + name, version, discriminator, rest, want string }{ - {"explicit mapping", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", cat + dog, "mapping"}, - {"inferred by name", "3.1.0", "{propertyName: kind}", cat + dog, "propertyName"}, - {"one subtype and one not", "3.1.0", - "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat', dog: '#/components/schemas/Dog'}}", - mixed + dog, "dog"}, - {"default mapping", "3.2.0", "{propertyName: kind, defaultMapping: '#/components/schemas/Dog'}", cat + dog, "defaultMapping"}, + {"mapped", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", cat + dog, `"mapping"`}, + {"named by branch", "3.1.0", "{propertyName: kind}", cat + dog, `"propertyName"`}, + {"one mapped subtype and one branch that is not", "3.1.0", + "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", + " Cat: " + viaPet + "}\n" + dog, `"mapping"`}, + {"default", "3.2.0", "{propertyName: kind, defaultMapping: '#/components/schemas/Other'}", + subtypes + " Other: {type: object}\n", `"defaultMapping"`}, {"mixin only", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", " Cat: {allOf: [{$ref: '#/components/schemas/Dog'}, {$ref: '#/components/schemas/Other'}]}\n" + - dog + " Other: {type: object}\n", "mapping"}, - {"position that declares an inline object", "3.1.0", + dog + " Other: {type: object}\n", `"mapping"`}, + {"position declaring an inline object", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Holder/properties/pet'}}", - cat + dog + " Holder: {type: object, properties: {pet: {type: object, properties: {kind: {type: string}}}}}\n", - "mapping"}, + subtypes + " Holder: {type: object, properties: {pet: {type: object, properties: {kind: {type: string}}}}}\n", + `"mapping"`}, {"component that is only an alias", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Alias'}}", - subtypes + " Alias: {$ref: '#/components/schemas/Cat'}\n", "mapping"}, + subtypes + " Alias: {$ref: '#/components/schemas/Cat'}\n", `"mapping"`}, + {"lowers to an enum", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", + " Cat: " + viaPet + ", enum: [{kind: cat}]}\n" + subDog, `"mapping"`}, + {"lowers to a literal", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", + " Cat: " + viaPet + ", const: {kind: cat}}\n" + subDog, `"mapping"`}, + {"lowers to an alias over its $ref", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", + " Cat: {$ref: '#/components/schemas/Other', allOf: [{$ref: '#/components/schemas/Pet'}]}\n" + + " Other: {type: object}\n" + subDog, `"mapping"`}, + {"lowers to a distributed union", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", + " Cat: " + viaPet + ", oneOf: [{$ref: '#/components/schemas/House'}, {$ref: '#/components/schemas/Wild'}]}\n" + + " House: {type: object}\n Wild: {type: object}\n" + subDog, `"mapping"`}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() for _, doc := range discriminatedOrders(t, c.version, petWith(c.discriminator), c.rest) { - pet, ok := typeByName(doc, "Pet").(*ir.Model) - require.True(t, ok, "Pet lowers to a model beside its union") - assert.Nil(t, pet.Discriminator, "a discriminator routing to a non-subtype stays off the model") - entry, ok := pet.Unmodeled["openapi:discriminator"] - require.True(t, ok, "the discriminator is kept verbatim") - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.Contains(t, string(entry.Value), c.wantValue) - assert.Contains(t, string(entry.Value), `"propertyName":"kind"`, "the whole object is kept") - _, ok = pet.Unmodeled["openapi:oneOf"] - assert.True(t, ok, "the union stays beside it") + assertMoved(t, doc, c.want) } }) } } -// TestDiscriminatedUnion_SaysItKeptTheDiscriminator pins that the diagnostic -// for the kept union names the discriminator when it moved too, and does not -// when it stayed on the model. -func TestDiscriminatedUnion_SaysItKeptTheDiscriminator(t *testing.T) { - t.Parallel() - const pointer = "/components/schemas/Pet" - moved := petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}") - _, diags := lowerSpec(t, openapitest.ComponentSpec(moved+cat+dog)) - assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, pointer), - "not a subtype of this model, so the discriminator is kept verbatim too") - - stayed := petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}") - _, diags = lowerSpec(t, openapitest.ComponentSpec(stayed+subtypes)) - assert.NotContains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, pointer), - "discriminator is kept") -} - -// TestDiscriminatedUnion_SubtypeTargetsKeepTheDiscriminator pins the one case -// the model keeps its discriminator beside a kept union: every target it routes -// to is a subtype, at any distance. It must hold in both declaration orders, -// which is why the rule reads the schemas and not the lowered registry, where a -// subtype declared after Pet has no Base yet while Pet lowers. -func TestDiscriminatedUnion_SubtypeTargetsKeepTheDiscriminator(t *testing.T) { +// TestDiscriminatorBesideUnion_StaysWhenEveryRouteIsASubtype pins the case the +// model keeps its discriminator beside a kept union: every route is a subtype, +// at any distance and however it is spelled. A branch no name reaches, inline +// or a position, is no route, so it does not move the discriminator either. +func TestDiscriminatorBesideUnion_StaysWhenEveryRouteIsASubtype(t *testing.T) { t.Parallel() const grand = " Cat: {allOf: [{$ref: '#/components/schemas/Mid'}], type: object}\n" + " Mid: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n" + " Dog: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n" + unnamed := " Pet:\n type: object\n discriminator: {propertyName: kind}\n" + + " oneOf: [{type: object, properties: {kind: {type: string}}}, {$ref: '#/components/schemas/Holder/properties/pet'}]\n" cases := []struct { - name string - version string - discriminator string - rest string + name, version, pet, rest string }{ - {"explicit mapping", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", subtypes}, - {"inferred by name", "3.1.0", "{propertyName: kind}", subtypes}, - {"mapping by bare name", "3.1.0", "{propertyName: kind, mapping: {cat: Cat, dog: Dog}}", subtypes}, - {"position that is only a $ref", "3.1.0", - "{propertyName: kind, mapping: {cat: '#/components/schemas/Holder/properties/pet'}}", + {"mapped", "3.1.0", petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}"), subtypes}, + {"named by branch", "3.1.0", petWith("{propertyName: kind}"), subtypes}, + {"mapped by bare name", "3.1.0", petWith("{propertyName: kind, mapping: {cat: Cat, dog: Dog}}"), subtypes}, + {"mapped through a position that is only a $ref", "3.1.0", + petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Holder/properties/pet'}}"), subtypes + " Holder: {type: object, properties: {pet: {$ref: '#/components/schemas/Cat'}}}\n"}, - {"grandchild", "3.1.0", "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", grand}, - {"default mapping", "3.2.0", "{propertyName: kind, defaultMapping: '#/components/schemas/Dog'}", subtypes}, + {"grandchild", "3.1.0", petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}"), grand}, + {"default", "3.2.0", petWith("{propertyName: kind, defaultMapping: '#/components/schemas/Dog'}"), subtypes}, + {"branches no name reaches", "3.1.0", unnamed, cat + " Holder: {type: object, properties: {pet: {type: object}}}\n"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Parallel() - for _, doc := range discriminatedOrders(t, c.version, petWith(c.discriminator), c.rest) { - pet, ok := typeByName(doc, "Pet").(*ir.Model) - require.True(t, ok, "Pet lowers to a model beside its union") - require.NotNil(t, pet.Discriminator, "every target is a subtype, so the model keeps it") - assert.Equal(t, "kind", pet.Discriminator.PropertyName) - assert.NotContains(t, pet.Unmodeled, "openapi:discriminator", "nothing is stored twice") - assert.Contains(t, pet.Unmodeled, "openapi:oneOf") + for _, doc := range discriminatedOrders(t, c.version, c.pet, c.rest) { + assertStayed(t, doc) } }) } } -// TestDiscriminatedUnion_UnresolvedAndCyclicTargets pins that the rule -// terminates and drops what it cannot read: a mapping value that names nothing -// is skipped (discriminatorMapping reports it), and a composition cycle that -// never reaches Pet is no subtype. -func TestDiscriminatedUnion_UnresolvedAndCyclicTargets(t *testing.T) { +// TestDiscriminatorBesideUnion_AnySubtypeDepthStays pins that no depth bound +// stands between a route and Pet: pass.Validate follows a hierarchy to any +// depth, so a bound here would move a discriminator it accepts. +func TestDiscriminatorBesideUnion_AnySubtypeDepthStays(t *testing.T) { t.Parallel() - pet := petWith("{propertyName: kind, mapping: {gone: '#/components/schemas/Nope', far: 'https://example.com/x.yaml#/A', " + - "loop: '#/components/schemas/Loop'}}") - rest := cat + dog + - " Loop: {allOf: [{$ref: '#/components/schemas/LoopB'}]}\n" + - " LoopB: {allOf: [{$ref: '#/components/schemas/Loop'}]}\n" - doc, _ := lowerSpec(t, openapitest.ComponentSpec(pet+rest)) - m, ok := typeByName(doc, "Pet").(*ir.Model) - require.True(t, ok) - assert.Nil(t, m.Discriminator, "the cycle never reaches Pet") - assert.Contains(t, m.Unmodeled, "openapi:discriminator") -} - -// TestDiscriminatedUnion_DeepBaseChainIsBounded pins that the walk up the base -// chain stops at its bound on a chain longer than it, rather than following it. -func TestDiscriminatedUnion_DeepBaseChainIsBounded(t *testing.T) { - t.Parallel() - var b strings.Builder + var chain strings.Builder for i := range 300 { - fmt.Fprintf(&b, " C%d: {allOf: [{$ref: '#/components/schemas/C%d'}]}\n", i, i+1) + fmt.Fprintf(&chain, " C%d: {allOf: [{$ref: '#/components/schemas/C%d'}], type: object}\n", i, i+1) } - b.WriteString(" C300: {type: object}\n") + chain.WriteString(" C300: {allOf: [{$ref: '#/components/schemas/Pet'}], type: object}\n") pet := petWith("{propertyName: kind, mapping: {c: '#/components/schemas/C0'}}") - doc, _ := lowerSpec(t, openapitest.ComponentSpec(pet+cat+dog+b.String())) - m, ok := typeByName(doc, "Pet").(*ir.Model) - require.True(t, ok) - assert.Nil(t, m.Discriminator) - assert.Contains(t, m.Unmodeled, "openapi:discriminator") + for _, doc := range discriminatedOrders(t, "3.1.0", pet, subtypes+chain.String()) { + assertStayed(t, doc) + } } -// TestDiscriminatedUnion_KeepsAnAllOfBodyToo pins the other model lowering -// reachable beside a kept union: a body composed with allOf lowers through -// lowerAllOf, and its discriminator is held to the same rule. -func TestDiscriminatedUnion_KeepsAnAllOfBodyToo(t *testing.T) { +// TestDiscriminatorBesideUnion_ReportsTheMoveOnce pins the diagnostic: exactly +// one, at the discriminator, when it moves, in either declaration order, and +// none when it stays. +func TestDiscriminatorBesideUnion_ReportsTheMoveOnce(t *testing.T) { t.Parallel() - pet := " Pet:\n allOf: [{$ref: '#/components/schemas/Base'}]\n" + - " discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}\n" + petBranches - doc, diags := parseFull(t, openapitest.ComponentSpec(pet+cat+dog+" Base: {type: object}\n")) - openapitest.RequireNoErrorDiags(t, diags) - assert.Empty(t, pass.Validate(doc)) - m, ok := typeByName(doc, "Pet").(*ir.Model) - require.True(t, ok) - assert.Nil(t, m.Discriminator) - assert.Contains(t, m.Unmodeled, "openapi:discriminator") + pet := petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}") + for _, src := range []string{pet + cat + dog, cat + dog + pet} { + _, diags := parseFull(t, openapitest.ComponentSpec(src)) + assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, movedPointer), + "not a subtype of this model") + } + _, diags := parseFull(t, openapitest.ComponentSpec(pet+subtypes)) + assert.False(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, movedPointer)) } // degradedShapes are the model-bodied unions kept verbatim beside a -// discriminator other than the one the rule was first written for, each with -// the branch text that makes it that shape. +// discriminator other than the discriminated one, each with the branch text +// that makes it that shape. Each goes through lowerBesideUnmodeledUnion. var degradedShapes = []struct { name string branches string @@ -218,111 +229,152 @@ var degradedShapes = []struct { {"validation-only branches", " oneOf: [{required: [kind]}, {required: [x]}]\n"}, } -const degradedMapping = "{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}" - func degradedPet(branches string) string { - return " Pet:\n type: object\n discriminator: " + degradedMapping + "\n" + branches + return " Pet:\n type: object\n" + + " discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}\n" + branches } -// TestDiscriminatedUnion_EveryDegradedShapeGatesTheDiscriminator pins that a -// discriminator routing to a non-subtype leaves the model for Unmodeled on -// every degraded union, not only the discriminated one, so pass.Validate -// accepts the document in both declaration orders. -func TestDiscriminatedUnion_EveryDegradedShapeGatesTheDiscriminator(t *testing.T) { +// TestDiscriminatorBesideUnion_EveryDegradedShapeIsSettled pins that the rule +// holds on every union kept verbatim beside a model, not only the +// discriminated one, both ways. +func TestDiscriminatorBesideUnion_EveryDegradedShapeIsSettled(t *testing.T) { t.Parallel() for _, shape := range degradedShapes { t.Run(shape.name, func(t *testing.T) { t.Parallel() for _, doc := range discriminatedOrders(t, "3.1.0", degradedPet(shape.branches), cat+dog) { - pet, ok := typeByName(doc, "Pet").(*ir.Model) - require.True(t, ok, "Pet lowers to a model beside its union") - assert.Nil(t, pet.Discriminator, "a non-subtype target stays off the model") - assert.Contains(t, pet.Unmodeled, "openapi:discriminator") - } - for _, src := range []string{degradedPet(shape.branches) + cat + dog, cat + dog + degradedPet(shape.branches)} { - _, diags := parseFull(t, openapitest.ComponentSpecVer("3.1.0", src)) - assert.Equal(t, 1, countMovedDiscriminator(diags), "exactly one diagnostic names the move: %+v", diags) + assertMoved(t, doc, `"mapping"`) } - }) - } -} - -// countMovedDiscriminator counts the diagnostics saying the discriminator was -// kept verbatim because it routes to a target that is not a subtype. -func countMovedDiscriminator(diags []ir.Diagnostic) int { - n := 0 - for _, d := range diags { - if strings.Contains(d.Message, "not a subtype of this model") { - n++ - } - } - return n -} - -// TestDiscriminatedUnion_DegradedShapesKeepSubtypeDiscriminators pins that the -// gate does not over-reach: a mapping whose every target is a subtype keeps the -// discriminator on the model on each degraded shape. -func TestDiscriminatedUnion_DegradedShapesKeepSubtypeDiscriminators(t *testing.T) { - t.Parallel() - for _, shape := range degradedShapes { - t.Run(shape.name, func(t *testing.T) { - t.Parallel() for _, doc := range discriminatedOrders(t, "3.1.0", degradedPet(shape.branches), subtypes) { - pet, ok := typeByName(doc, "Pet").(*ir.Model) - require.True(t, ok) - assert.NotNil(t, pet.Discriminator, "every target is a subtype") - assert.NotContains(t, pet.Unmodeled, "openapi:discriminator") + assertStayed(t, doc) } }) } } -// TestDiscriminatedUnion_InlineBranchesNameNoSubtype pins that with no mapping -// entry resolving, a union whose only branch is inline gives the discriminator -// nothing to route to, so it moves to Unmodeled. -func TestDiscriminatedUnion_InlineBranchesNameNoSubtype(t *testing.T) { +// TestDiscriminatorBesideUnion_AllOfBodyIsSettled pins the other model body a +// kept union can sit beside: one composed with allOf lowers through +// lowerAllOf, and its discriminator is settled the same way. +func TestDiscriminatorBesideUnion_AllOfBodyIsSettled(t *testing.T) { t.Parallel() - pet := " Pet:\n type: object\n discriminator: {propertyName: kind}\n" + - " oneOf: [{type: object, properties: {kind: {type: string}}}]\n" - for _, doc := range discriminatedOrders(t, "3.1.0", pet, cat) { - m, ok := typeByName(doc, "Pet").(*ir.Model) - require.True(t, ok) - assert.Nil(t, m.Discriminator) - assert.Contains(t, m.Unmodeled, "openapi:discriminator") + pet := " Pet:\n allOf: [{$ref: '#/components/schemas/Base'}]\n" + + " discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}\n" + petBranches + for _, doc := range discriminatedOrders(t, "3.1.0", pet, cat+dog+" Base: {type: object}\n") { + assertMoved(t, doc, `"mapping"`) } } -// TestDiscriminatedUnion_UnresolvedBranchGatesTheDiscriminator pins the same -// rule on a union with a $ref branch that resolves nothing. The unresolved -// $ref is itself an error diagnostic, so the document is checked with -// pass.Validate directly rather than through the no-error harness. -func TestDiscriminatedUnion_UnresolvedBranchGatesTheDiscriminator(t *testing.T) { +// TestDiscriminatorBesideUnion_UnreachableBranchesAreNoRoute pins that a +// branch the discriminator cannot route to by name decides nothing, whichever +// way it is unreachable: a $ref to an undeclared component, or one into +// another document, though its fragment spells a component this one declares, +// Lone, which is no subtype. The unresolved $refs are error diagnostics, so +// the document is checked with pass.Validate directly. +func TestDiscriminatorBesideUnion_UnreachableBranchesAreNoRoute(t *testing.T) { t.Parallel() - const branches = " oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Nope'}]\n" - cases := []struct { + const branches = " oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Nope'}, " + + "{$ref: 'https://example.com/x.yaml#/components/schemas/Lone'}]\n" + const lone = " Lone: {type: object}\n" + for _, c := range []struct { name, rest string - keeps bool + stays bool }{ - {"non-subtype target", cat + dog, false}, - {"subtype target", subtypes, true}, - } - for _, c := range cases { + {"beside a subtype", subtypes + lone, true}, + {"beside a non-subtype", cat + dog + lone, false}, + } { t.Run(c.name, func(t *testing.T) { t.Parallel() pet := degradedPet(branches) + docs := make([]*ir.Document, 0, 2) for _, src := range []string{pet + c.rest, c.rest + pet} { - doc, _ := lowerSpec(t, openapitest.ComponentSpec(src)) + doc, _ := parseFull(t, openapitest.ComponentSpec(src)) assert.Empty(t, pass.Validate(doc), "the document must validate") - m, ok := typeByName(doc, "Pet").(*ir.Model) - require.True(t, ok) - assert.Equal(t, c.keeps, m.Discriminator != nil) - assert.Equal(t, !c.keeps, hasKey(m.Unmodeled, "openapi:discriminator")) + docs = append(docs, doc) + if c.stays { + assertStayed(t, doc) + continue + } + assertMoved(t, doc, `"mapping"`) } + assert.Empty(t, cmp.Diff(docs[0].Types, docs[1].Types, orderInvariantIR()...)) }) } } -func hasKey(m ir.Unmodeled, key string) bool { - _, ok := m[key] - return ok +// TestDiscriminatorBesideUnion_CyclicRouteMoves pins that a route whose +// hierarchy loops without reaching Pet is no subtype, and the check ends. +func TestDiscriminatorBesideUnion_CyclicRouteMoves(t *testing.T) { + t.Parallel() + pet := petWith("{propertyName: kind, mapping: {loop: '#/components/schemas/Loop'}}") + rest := subtypes + + " Loop: {allOf: [{$ref: '#/components/schemas/LoopB'}]}\n" + + " LoopB: {allOf: [{$ref: '#/components/schemas/Loop'}]}\n" + for _, doc := range discriminatedOrders(t, "3.1.0", pet, rest) { + assertMoved(t, doc, `"loop"`) + } +} + +// TestDiscriminatorBesideUnion_SubtypeKeepsItsTag pins what a move leaves +// alone: a route that is a subtype still carries the tag the mapping gives it, +// since that is the value its instances carry on the wire. +func TestDiscriminatorBesideUnion_SubtypeKeepsItsTag(t *testing.T) { + t.Parallel() + pet := petWith("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat', dog: '#/components/schemas/Dog'}}") + rest := subCat + dog + for _, doc := range discriminatedOrders(t, "3.1.0", pet, rest) { + assertMoved(t, doc, `"dog"`) + sub, ok := typeByName(doc, "Cat").(*ir.Model) + require.True(t, ok) + assert.Equal(t, "cat", sub.DiscriminatorValue) + } +} + +// TestDiscriminatorBesideUnion_SettlesAnOperationsSchemaToo pins that the +// settlement waits for the operations as well as the components: a request +// body lowers in the service walk, after every component. +func TestDiscriminatorBesideUnion_SettlesAnOperationsSchemaToo(t *testing.T) { + t.Parallel() + spec := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /pets: + post: + requestBody: + content: + application/json: + schema: + type: object + discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}} + oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Dog'}] + responses: + "204": {description: none} +components: + schemas: +` + cat + dog + doc, diags := parseFull(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, pass.Validate(doc)) + const at = "/paths/~1pets/post/requestBody/content/application~1json/schema/discriminator" + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, at), "%+v", diags) +} + +// TestDiscriminatorBesideUnion_SettlesAPositionAReferenceBuiltFirst pins a +// model two settlements are deferred for: a $ref reaches the position first +// and builds it, then the declaration rebuilds it. The first moves the +// discriminator off the rebuilt model; the second finds none left to move. +func TestDiscriminatorBesideUnion_SettlesAPositionAReferenceBuiltFirst(t *testing.T) { + t.Parallel() + const at = "/components/schemas/Holder/properties/pet" + outside := " Outside: {$ref: '#/components/schemas/Holder/properties/pet'}\n" + holder := " Holder:\n type: object\n properties:\n pet:\n type: object\n" + + " discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}\n" + + " oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Dog'}]\n" + for _, doc := range discriminatedOrders(t, "3.1.0", outside, holder+cat+dog) { + pet, ok := doc.Types["t/anon"+at].(*ir.Model) + require.True(t, ok) + assert.Nil(t, pet.Discriminator) + assert.Contains(t, pet.Unmodeled, "openapi:discriminator") + assert.Equal(t, 1, strings.Count(fmt.Sprint(reported(doc.Diagnostics)), at+"/discriminator"), + "one move, reported once") + } } diff --git a/compilers/openapi/internal/schema/resolve.go b/compilers/openapi/internal/schema/resolve.go index 6c7d46ab..1b841d45 100644 --- a/compilers/openapi/internal/schema/resolve.go +++ b/compilers/openapi/internal/schema/resolve.go @@ -100,8 +100,9 @@ func refSiteRef(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth i } // PreserveRefSiteKeywords keeps, on a carrier's own Unmodeled, the keywords a -// $ref position declared that the alias it resolves to cannot hold. It is -// refSiteRef's other half: the same census, recorded where an +// $ref position declared that the alias it resolves to cannot hold, and those a +// collapsed union declared, which resolves to a reference the same way. It is +// refSiteRef's and schemaBody's other half: the same census, recorded where an // annotation.HomeCarrier position keeps everything else its schema declared — // oneOf/anyOf/allOf included, through the same keepers refSiteRef calls // (GitHub #406). @@ -119,7 +120,8 @@ func PreserveRefSiteKeywords(c lowering.Ctx, ts *compile.Types, p *ir.Unmodeled, return nil } if !resolve.IsRefSite(js, s) { - return preserveCollapsedDiscriminator(c, p, s, pointer) + // A collapsed union lowers to a reference just as a $ref site does. + return recordUnhomedAt(c, p, s, collapsedUnhomedKeywords(s), collapsedShape, pointer) } diags := recordUnhomedAt(c, p, s, refSiteUnhomedKeywords(s, nil), refSiteShape, pointer) if !declaresUnion(s) { diff --git a/compilers/openapi/internal/schema/schema.go b/compilers/openapi/internal/schema/schema.go index 52b7519d..3b67727e 100644 --- a/compilers/openapi/internal/schema/schema.go +++ b/compilers/openapi/internal/schema/schema.go @@ -200,10 +200,16 @@ func internAlias(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, pointer json // leading $ref. func schemaBody(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, schema *oas3.Schema, pointer jsontext.Pointer, hint string, home annotation.Home) (ir.TypeRef, []ir.Diagnostic) { target, diags := lowerSchemaBody(c, ts, anchors, depth, schema, pointer, hint) - // No census verdict: a body's census ran inside lower(), against the node the - // walk actually built, which is the only thing that can answer it. - ref, homeDiags := homeDeclaration(c, ts, anchors, schema, target, pointer, hint, home, false) - return ref, append(diags, homeDiags...) + // A body's census ran inside lower(), against the node the walk actually + // built, which is the only thing that can answer it. A collapsed union built + // none, so it takes a $ref site's census, and its verdict homes an alias. + unhomed := collapsedUnhomedKeywords(schema) + ref, homeDiags := homeDeclaration(c, ts, anchors, schema, target, pointer, hint, home, len(unhomed) > 0) + diags = append(diags, homeDiags...) + if home != annotation.HomeOwnNode { + return ref, diags // the carrier keeps them (PreserveRefSiteKeywords) + } + return ref, append(diags, recordUnhomedKeywords(c, ts, ref.Target, schema, unhomed, collapsedShape, pointer)...) } // hoistDeclarationHome gives a declaration a node of its own when its lowering @@ -230,9 +236,6 @@ func hoistDeclarationHome(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, ref return ir.TypeRef{Target: id, Nullable: ref.Nullable}, nil } id, diags := internAlias(c, ts, s, pointer, hint, ref) - if td, ok := ts.NodeAt(string(pointer)); ok { - diags = append(diags, preserveCollapsedDiscriminator(c, &td.Common().Unmodeled, s, pointer)...) - } return ir.TypeRef{Target: id, Nullable: ref.Nullable}, diags } @@ -256,8 +259,7 @@ func declaresPositionScoped(s *oas3.Schema) bool { return declaresAnnotations(s) || declaresValueConstraints(s) || declaresValidationOnly(s) || annotation.DeclaresAny(s, residueKeywords) || declaresContentVocabulary(s) || declaresDynamicRef(s) || - annotation.DeclaresAny(s, annotation.DialectKeywords) || - collapsesBesideDiscriminator(s) + annotation.DeclaresAny(s, annotation.DialectKeywords) } // declaresAnnotations reports whether s carries documentation, deprecation, XML @@ -403,7 +405,8 @@ func declaresShape(s *oas3.Schema) bool { // co-declares oneOf/anyOf and keeps the union verbatim beside it, so neither the // structural shape nor the union is dropped. reason says which kind of union it // is and why says what stopped a classified lowering; classifyUnionSiblings -// picks both. +// picks both. A discriminator the body's model holds is settled once every node +// is interned (deferDiscriminatorBesideUnion). func lowerBesideUnmodeledUnion(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s *oas3.Schema, pointer jsontext.Pointer, hint string, reason ir.UnmodeledReason, why string) (ir.TypeID, []ir.Diagnostic) { inner, diags := lower(c, ts, anchors, depth, s, pointer, hint) owner := inner @@ -415,6 +418,7 @@ func lowerBesideUnmodeledUnion(c lowering.Ctx, ts *compile.Types, anchors *Ancho owner, aliasDiags = internAlias(c, ts, s, pointer, hint, ir.TypeRef{Target: inner}) diags = append(diags, aliasDiags...) } + deferDiscriminatorBesideUnion(c, ts, owner, s, pointer) return owner, append(diags, preserveUnionSiblings(c, ts, owner, s, pointer, reason, why)...) } @@ -426,12 +430,7 @@ func preserveUnionSiblings(c lowering.Ctx, ts *compile.Types, id ir.TypeID, s *o if !ok { return diags } - p := &td.Common().Unmodeled - if _, isModel := td.(*ir.Model); isModel { - // A model holds the discriminator in its own field. - return append(diags, preserveUnionBranches(c, p, s, pointer, reason, why)...) - } - return append(diags, preserveUnionSiblingsAt(c, p, s, pointer, reason, why)...) + return append(diags, preserveUnionSiblingsAt(c, &td.Common().Unmodeled, s, pointer, reason, why)...) } // preserveUnionSiblingsAt is preserveUnionSiblings' body, addressed by the @@ -443,15 +442,6 @@ func preserveUnionSiblings(c lowering.Ctx, ts *compile.Types, id ir.TypeID, s *o // lowerCoDeclaredUnion's reasons presuppose a structural body at the position, // and a $ref site has none, so a shared template would contradict itself there. func preserveUnionSiblingsAt(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, pointer jsontext.Pointer, reason ir.UnmodeledReason, why string) []ir.Diagnostic { - diags := preserveUnionBranches(c, p, s, pointer, reason, why) - return append(diags, keepUnplacedDiscriminator(c, p, s, pointer, - "a discriminator beside a oneOf/anyOf kept verbatim under Unmodeled routes through "+ - "no union, as this position has no model to hold it")...) -} - -// preserveUnionBranches keeps s's oneOf/anyOf verbatim under p and says so, -// without touching a discriminator: the owner may hold that itself. -func preserveUnionBranches(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, pointer jsontext.Pointer, reason ir.UnmodeledReason, why string) []ir.Diagnostic { kept, diags := preserveBranchSets(c, p, s, reason, pointer) if reason == ir.ReasonValidationOnly || len(kept) == 0 { return diags @@ -654,21 +644,21 @@ func lower(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s } // censusKeywords are the keywords whose IR home depends on what the position -// lowered to: a Model carries properties, a List an element type and the -// collection bounds, a Literal a value, an Enum a member set. keywordHome asks -// the built node which a position kept. +// lowered to: a Model carries properties, a List an element type and +// collection bounds, a Literal a value, an Enum a member set, a Model or Union +// a discriminator. keywordHome asks the built node which a position kept. // -// Annotations, validation-only keywords, content vocabulary, use-site keywords -// and value constraints have their own keepers. +// Annotations, validation-only and use-site keywords, content vocabulary and +// value constraints have their own keepers. // -// The list and keywordHome's switch must name the same set. A keyword listed -// without an arm is reported homeless and preserved everywhere, noisy but -// lossless; one in neither is dropped in silence (GitHub #268, #283). +// The list and keywordHome's switch must agree. A keyword listed without an +// arm is homeless and preserved everywhere, noisy but lossless; one in neither +// is dropped in silence (GitHub #268, #283). // oneOf/anyOf/allOf stay out; see refSiteUnhomedKeywords (GitHub #406). var censusKeywords = []string{ - "additionalProperties", "const", "enum", "format", "items", "maxItems", - "minItems", "patternProperties", "prefixItems", "properties", "required", - "type", "uniqueItems", + "additionalProperties", "const", "discriminator", "enum", "format", "items", + "maxItems", "minItems", "patternProperties", "prefixItems", "properties", + "required", "type", "uniqueItems", } // keywordHome reports whether td, the node this position's own declaration @@ -714,6 +704,22 @@ func keywordHome(td ir.TypeDef, s *oas3.Schema, keyword string) bool { return formatHome(td, s) case "type": return typeHome(td, s) + case "discriminator": + return discriminatorHome(td) + default: + return false + } +} + +// discriminatorHome reports whether td holds the discriminator its position +// declared. Only a Model and a Union have the field, and having it is not +// filling it: the Union a mixed-type enum lowers to carries none. +func discriminatorHome(td ir.TypeDef) bool { + switch n := td.(type) { + case *ir.Model: + return n.Discriminator != nil + case *ir.Union: + return n.Discriminator != nil default: return false } @@ -851,6 +857,29 @@ func refSiteUnhomedKeywords(s *oas3.Schema, handled []string) []string { return unhomed } +// collapsedShape describes what a collapsed union lowers to, for the diagnostic +// recordUnhomedAt reports. +const collapsedShape = "a nullable reference to its one non-null branch" + +// collapsedUnhomedKeywords returns the census keywords s writes when its +// oneOf/anyOf collapses to a nullable reference (lowerOneOfAnyOf), or nil when +// it does not. Every one is homeless: the position lowers to a reference to its +// branch's node, which belongs to the branch, as a $ref site's target belongs +// to the referent (keywordHome's nil td). The census keywords such a schema can +// write are the few neither declaresShape nor narrowsInstance counts, the +// discriminator among them (GitHub #595). lowerSchemaBody's other outcomes +// that skip lower(), a Union and an expanded $dynamicRef, run no census yet +// (GitHub #848). +func collapsedUnhomedKeywords(s *oas3.Schema) []string { + if hasUnionSiblings(s) { + return nil + } + if _, _, _, collapses := nullUnionCollapse(s, ""); !collapses { + return nil + } + return unhomedKeywords(s, nil, nil) +} + // preserveUnhomedKeywords keeps verbatim the census keywords and value // constraints a position declared that its lowered node cannot carry, and the // families lower()'s election skipped, returning the position's resulting ID diff --git a/compilers/openapi/internal/schema/schema_internal_test.go b/compilers/openapi/internal/schema/schema_internal_test.go index 74e94af8..fdd93c31 100644 --- a/compilers/openapi/internal/schema/schema_internal_test.go +++ b/compilers/openapi/internal/schema/schema_internal_test.go @@ -800,7 +800,8 @@ func TestKeywordHome_EveryCensusKeywordHasANodeThatCarriesIt(t *testing.T) { // `type` on anything at all. s := &oas3.Schema{Type: oas3.NewTypeFromString(oas3.SchemaTypeString)} nodes := []ir.TypeDef{ - &ir.Model{}, &ir.List{}, &ir.Tuple{}, &ir.Literal{}, &ir.Enum{}, &ir.Union{}, + &ir.Model{}, &ir.List{}, &ir.Tuple{}, &ir.Literal{}, &ir.Enum{}, + &ir.Union{Discriminator: &ir.Discriminator{}}, &ir.Scalar{Encoding: &ir.Encoding{}}, &ir.Primitive{}, } require.NotEmpty(t, censusKeywords, "an empty census would make this pass vacuously") @@ -812,3 +813,31 @@ func TestKeywordHome_EveryCensusKeywordHasANodeThatCarriesIt(t *testing.T) { assert.True(t, homed, "%q is in the census with no node kind that carries it", keyword) } } + +// TestDiscriminatorHome_AsksWhetherTheFieldWasFilled pins what homes a +// discriminator: a Model or Union whose lowering filled the field. Having the +// field is not enough, since the Union a mixed-type enum lowers to holds none, +// and no other kind, nor a $ref site's nil node, has the field at all. +func TestDiscriminatorHome_AsksWhetherTheFieldWasFilled(t *testing.T) { + t.Parallel() + filled := &ir.Discriminator{PropertyName: "kind"} + cases := []struct { + name string + td ir.TypeDef + want bool + }{ + {"model holding one", &ir.Model{Discriminator: filled}, true}, + {"union holding one", &ir.Union{Discriminator: filled}, true}, + {"model holding none", &ir.Model{}, false}, + {"union holding none", &ir.Union{}, false}, + {"scalar", &ir.Scalar{}, false}, + {"enum", &ir.Enum{}, false}, + {"ref site", nil, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, c.want, keywordHome(c.td, &oas3.Schema{}, "discriminator")) + }) + } +} diff --git a/compilers/openapi/internal/schema/collapse_discriminator_test.go b/compilers/openapi/internal/schema/unhomed_discriminator_test.go similarity index 50% rename from compilers/openapi/internal/schema/collapse_discriminator_test.go rename to compilers/openapi/internal/schema/unhomed_discriminator_test.go index ab4a09c7..eedc38ac 100644 --- a/compilers/openapi/internal/schema/collapse_discriminator_test.go +++ b/compilers/openapi/internal/schema/unhomed_discriminator_test.go @@ -2,6 +2,7 @@ package schema_test import ( "encoding/json/v2" + "maps" "slices" "strings" "testing" @@ -64,10 +65,10 @@ components: // TestCollapsedUnion_KeepsItsDiscriminatorAtEveryPosition pins that a // discriminator beside a oneOf/anyOf that collapses to a nullable reference is -// kept verbatim, whole, on the node that position owns, with an info -// diagnostic, instead of vanishing. The reference itself is unchanged. The -// component, the model property, an array's items, additionalProperties, a -// parameter and a response body each reach lowerOneOfAnyOf by their own path. +// kept verbatim, whole, by whatever owns the position, with an info diagnostic, +// instead of vanishing (GitHub #595). The reference itself is unchanged. The +// component, the property, an array's items, additionalProperties, a parameter +// and a response body each reach the collapse by their own path. func TestCollapsedUnion_KeepsItsDiscriminatorAtEveryPosition(t *testing.T) { t.Parallel() doc, diags := parseFull(t, collapseSpec) @@ -103,16 +104,40 @@ func TestCollapsedUnion_KeepsItsDiscriminatorAtEveryPosition(t *testing.T) { } { assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, pointer), - "collapses to a nullable reference", pointer) + "lowered to a nullable reference to its one non-null branch, which has no home for discriminator", pointer) } assert.Equal(t, 6, strings.Count(marshalForTest(t, doc), `"openapi:discriminator"`), "one entry per position, including the parameter and the response, none duplicated") } -// TestCollapsedUnion_WithoutADiscriminatorHoistsNothing pins that the same -// positions without a discriminator stay as they were: no entry, no -// diagnostic, and no node hoisted for the position that has none today. -func TestCollapsedUnion_WithoutADiscriminatorHoistsNothing(t *testing.T) { +// TestCollapsedUnion_KeepsEveryCensusKeywordItCannotHold pins that the collapse +// takes the whole census a $ref site takes, not a discriminator rule: a format +// or a collection bound is no more at home on a nullable reference. A null +// type, which the reference's Nullable bit carries, is, so the component's +// alias keeps nothing for it. +func TestCollapsedUnion_KeepsEveryCensusKeywordItCannotHold(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(` Cat: {type: object, properties: {kind: {type: string}}} + Bounded: {` + nullableCat + `, format: uuid, maxItems: 2} + NullTyped: {` + nullableCat + `, type: 'null'} +`) + doc, diags := parseFull(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + bounded, ok := typeByName(doc, "Bounded").(*ir.Scalar) + require.True(t, ok) + assert.Equal(t, []string{"openapi:format", "openapi:maxItems"}, slices.Sorted(maps.Keys(bounded.Unmodeled))) + assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, + "/components/schemas/Bounded"), "no home for format, maxItems") + + assert.Empty(t, typeByName(doc, "NullTyped").Common().Unmodeled) + assert.False(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/NullTyped")) +} + +// TestCollapsedUnion_WithoutACensusKeywordHoistsNothing pins that the same +// positions without one stay as they were: no entry, no diagnostic, and no +// node hoisted for a position that has none. +func TestCollapsedUnion_WithoutACensusKeywordHoistsNothing(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(` Cat: {type: object, properties: {kind: {type: string}}} L: {type: array, items: {` + nullableCat + `}} @@ -136,8 +161,7 @@ func marshalForTest(t *testing.T, doc *ir.Document) string { // unplacedSpec declares a discriminator beside a oneOf/anyOf at each position // whose lowering has no model to hold it: a null-only union as a component, a // property and beside a second combinator, a union beside a const, an enum and -// a scalar, and a union beside a $ref as a component and as a property. Each -// reaches its keeper by its own path. +// a scalar, and a union beside a $ref as a component and as a property. const unplacedSpec = `openapi: 3.2.0 info: {title: T, version: "1"} paths: {} @@ -180,11 +204,11 @@ components: discriminator: {propertyName: kind} ` -// TestUnplacedDiscriminator_IsKeptAtEverySite pins that a discriminator beside -// a oneOf/anyOf whose lowering has no model to hold it is kept verbatim on the -// node or carrier that owns the position, with an info diagnostic, instead of -// vanishing. A model-bodied union still carries it in its own field. -func TestUnplacedDiscriminator_IsKeptAtEverySite(t *testing.T) { +// TestUnplacedDiscriminator_IsKeptAtEveryUnionSite pins that a discriminator +// beside a oneOf/anyOf whose lowering has no model or union to hold it is kept +// verbatim on the node or carrier that owns the position, with an info +// diagnostic, instead of vanishing. +func TestUnplacedDiscriminator_IsKeptAtEveryUnionSite(t *testing.T) { t.Parallel() doc, diags := parseFull(t, unplacedSpec) openapitest.RequireNoErrorDiags(t, diags) @@ -193,10 +217,7 @@ func TestUnplacedDiscriminator_IsKeptAtEverySite(t *testing.T) { for _, name := range []string{"NullOnly", "NullBoth", "EnumU", "ConstU", "ScalarU", "RefSite"} { node := typeByName(doc, name) require.NotNil(t, node, name) - entry, ok := node.Common().Unmodeled["openapi:discriminator"] - require.True(t, ok, "%s keeps its discriminator", name) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason, name) - assert.Contains(t, string(entry.Value), "propertyName", name) + assertKeptDiscriminator(t, node.Common().Unmodeled, name) assert.True(t, namesDiscriminatorAt(diags, "/components/schemas/"+name), name) } for _, pointer := range []string{"/components/schemas/Holder/properties/n", "/components/schemas/Holder/properties/r"} { @@ -206,17 +227,120 @@ func TestUnplacedDiscriminator_IsKeptAtEverySite(t *testing.T) { holder, ok := typeByName(doc, "Holder").(*ir.Model) require.True(t, ok) require.Len(t, holder.Properties, 2) - owned := map[string]ir.Unmodeled{"n": nil, "r": nil} - for _, prop := range holder.Properties { - owned[prop.Name.Source] = prop.Unmodeled - } // The null-only property hoists a node of its own; the $ref one is on the carrier. - assert.Contains(t, doc.Types["t/anon/components/schemas/Holder/properties/n"].Common().Unmodeled, "openapi:discriminator") - assert.Contains(t, owned["r"], "openapi:discriminator") + assertKeptDiscriminator(t, doc.Types["t/anon/components/schemas/Holder/properties/n"].Common().Unmodeled, "n") + r := holder.Properties[slices.IndexFunc(holder.Properties, func(p ir.Property) bool { return p.Name.Source == "r" })] + assertKeptDiscriminator(t, r.Unmodeled, "r") assert.Equal(t, 8, strings.Count(marshalForTest(t, doc), `"openapi:discriminator"`), "one entry per position, none duplicated") } +// homelessSpec declares a discriminator beside no union at all, at each kind +// of position whose node has no field for it: a scalar, an enum, an array, a +// bare schema, a $ref as a component, a property, a parameter and an allOf +// branch, an array's items, and a mixed-type enum, whose Union has the field +// but routes nothing by it. +const homelessSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /p: + get: + parameters: + - name: q + in: query + schema: {$ref: '#/components/schemas/Cat', discriminator: {propertyName: kind}} + responses: + "204": {description: none} +components: + schemas: + Cat: {type: object, properties: {kind: {type: string}}} + Str: {type: string, discriminator: {propertyName: kind}} + En: {enum: [a, b], discriminator: {propertyName: kind}} + Mixed: {enum: [1, a], discriminator: {propertyName: kind}} + Arr: {type: array, items: {type: string}, discriminator: {propertyName: kind}} + Bare: {discriminator: {propertyName: kind}} + RefD: {$ref: '#/components/schemas/Cat', discriminator: {propertyName: kind}} + Items: {type: array, items: {type: string, discriminator: {propertyName: kind}}} + Sub: {allOf: [{$ref: '#/components/schemas/Cat', discriminator: {propertyName: kind}}]} + Holder: + type: object + properties: + p: {$ref: '#/components/schemas/Cat', discriminator: {propertyName: kind}} + q: {type: string, discriminator: {propertyName: kind}} +` + +// TestHomelessDiscriminator_IsKeptBesideEveryOtherNode pins that a +// discriminator is a census keyword: wherever the position lowered to a node +// that does not route by it, the census keeps it verbatim and says so, as it +// keeps any keyword the node has no field for. Each was dropped in silence. +func TestHomelessDiscriminator_IsKeptBesideEveryOtherNode(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, homelessSpec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, pass.Validate(doc)) + + owners := map[string]ir.Unmodeled{} + for _, name := range []string{"Str", "En", "Mixed", "Arr", "Bare", "RefD"} { + owners["/components/schemas/"+name] = typeByName(doc, name).Common().Unmodeled + } + for _, at := range []string{"Items/items", "Sub/allOf/0", "Holder/properties/q"} { + owners["/components/schemas/"+at] = doc.Types[ir.TypeID("t/anon/components/schemas/"+at)].Common().Unmodeled + } + holder, ok := typeByName(doc, "Holder").(*ir.Model) + require.True(t, ok) + owners["/components/schemas/Holder/properties/p"] = holder.Properties[0].Unmodeled + op := doc.Services[0].Groups[0].Operations[0] + require.Len(t, op.Params, 1) + owners["/paths/~1p/get/parameters/0/schema"] = op.Params[0].Unmodeled + + for pointer, unmodeled := range owners { + assertKeptDiscriminator(t, unmodeled, pointer) + assert.True(t, slices.ContainsFunc(diags, func(d ir.Diagnostic) bool { + return d.Code == diag.DegradedConstruct && d.Severity == ir.SeverityInfo && + string(d.Provenance.Pointer) == pointer && strings.Contains(d.Message, "which has no home for discriminator") + }), "%s reports what it kept", pointer) + } + assert.Equal(t, len(owners), strings.Count(marshalForTest(t, doc), `"openapi:discriminator"`), + "one entry per position, none duplicated") +} + +// TestHeldDiscriminator_IsNotKeptTwice pins the census's control: a union or a +// model that routes by its discriminator holds it, so nothing is kept verbatim +// and nothing is reported. A type array lowers each type as a variant of the +// same schema, so its object variant holds one. +func TestHeldDiscriminator_IsNotKeptTwice(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(cat + dog + + " U: {oneOf: [{$ref: '#/components/schemas/Cat'}, {$ref: '#/components/schemas/Dog'}], discriminator: {propertyName: kind}}\n" + + " Base: {type: object, properties: {kind: {type: string}}, discriminator: {propertyName: kind}}\n" + + " Multi: {type: [object, string], properties: {kind: {type: string}}, discriminator: {propertyName: kind}}\n") + doc, diags := parseFull(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + assert.Empty(t, pass.Validate(doc)) + + u, ok := typeByName(doc, "U").(*ir.Union) + require.True(t, ok) + assert.NotNil(t, u.Discriminator) + base, ok := typeByName(doc, "Base").(*ir.Model) + require.True(t, ok) + assert.NotNil(t, base.Discriminator) + variant, ok := doc.Types["t/anon/components/schemas/Multi/type/0"].(*ir.Model) + require.True(t, ok) + assert.NotNil(t, variant.Discriminator) + assert.NotContains(t, marshalForTest(t, doc), "openapi:discriminator") + assert.Zero(t, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), "%+v", diags) +} + +// assertKeptDiscriminator requires unmodeled to keep a discriminator verbatim +// as a degraded lowering, whole. +func assertKeptDiscriminator(t *testing.T, unmodeled ir.Unmodeled, where string) { + t.Helper() + entry, ok := unmodeled["openapi:discriminator"] + require.True(t, ok, "%s keeps its discriminator", where) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason, where) + assert.JSONEq(t, `{"propertyName":"kind"}`, string(entry.Value), where) +} + // namesDiscriminatorAt reports whether an info degraded-construct diagnostic at // pointer mentions a discriminator. A site also reports its union, so the count // at a pointer is not one. diff --git a/compilers/openapi/openapi.go b/compilers/openapi/openapi.go index be0567a8..02159f18 100644 --- a/compilers/openapi/openapi.go +++ b/compilers/openapi/openapi.go @@ -138,8 +138,8 @@ func (*Compiler) SourceTable(sources []compilers.Source, opts compilers.Options) // run drives the four-phase pipeline over one loaded document (architecture // §2.1) and assembles the Document. Order matters: named component schemas // first, so refs from operations find interned IDs; then security schemes, so -// requirements reference registered IDs; then the service walk; then document -// metadata. +// requirements reference registered IDs; then the service walk; then the +// deferred settlements; then document metadata. // // It reports cancellation as a Go error, not a diagnostic: nothing about the // document is wrong, the caller stopped asking. The document is dropped with it, @@ -182,6 +182,9 @@ func run(ctx context.Context, c lowering.Ctx, ts *compile.Types) (*ir.Document, if err := ctx.Err(); err != nil { return nil, acc.List(), err } + // Every lowering has returned, so what was deferred until the registry is + // whole can be decided now (compile.Settlement). + acc.AppendAll(ts.Settle()) m, metaDiags := lowerMeta(c) out.Name, out.Version = m.Name, m.Version diff --git a/docs/ir-design.md b/docs/ir-design.md index 34248f40..2a212c23 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -948,16 +948,20 @@ from it is exact and nothing is reported. 4. a `discriminator` is declared, binding the branch schemas by name; 5. `oneOf` and `anyOf` are both declared, so distributing one would drop the other. - The discriminator rule is not case 4's alone: it holds on every one of the five whose body - lowers to a `Model`, and all of them pass through one gate. The discriminator stays on the - `Model` only when every target it routes to — each `mapping` value and the `defaultMapping`, or - the union's branches when no mapping entry resolves, an inline or unresolved branch being no - subtype — is a subtype of that model at any distance along its `allOf` base chain, the rule - `pass/validate` holds a model discriminator to; the compiler reads it from the source schemas, - so declaration order cannot change the answer. Otherwise the whole object (`propertyName`, - `mapping`, `defaultMapping`) is kept verbatim in `Unmodeled["openapi:discriminator"]` beside - the union, and the `info` diagnostic says so. A union whose branches declare no shape - (`validation_only`) is held to the same rule. + A `discriminator` beside any of the five whose body lowers to a `Model` stays on that `Model` + only when every type it routes to is a subtype of it at any depth (`ir.IsSubtype`, the rule + `pass/validate` holds a model discriminator to): each `mapping` target, the `defaultMapping`, + and each component a `$ref` branch names, which it routes to by name. An inline branch, or one + naming no component, is no route unless the `mapping` names it. Otherwise the whole object + (`propertyName`, `mapping`, `defaultMapping`) is kept verbatim in + `Unmodeled["openapi:discriminator"]` beside the union, with an `info` diagnostic at the + discriminator; a subtype keeps the `DiscriminatorValue` its mapping key gives it, since that is + still the tag its instances carry. A union whose branches declare no shape (`validation_only`) + is held to the same rule. The compiler decides once every node is interned + (`compile.Settlement`), because neither the walk nor the source schemas can answer it: a + subtype declared after the `Model` has no `Base` while the `Model` lowers, and a schema whose + `allOf` names the `Model` may still lower to no subtype — an `enum`, a `const`, an alias over a + `$ref` beside it, or a union its `allOf` is distributed across. In all five, whatever structural body the schema declares is lowered — `properties` or a declared `type` yields a Model or typed node — and the union branches are kept verbatim in @@ -971,12 +975,15 @@ from it is exact and nothing is reported. it admits values the union would reject — which is why the branches must survive for a validating emitter to reach. - A `discriminator` written beside a union is held by the lowered `Model` where there is one. - Wherever the position lowers to a node that is not a `Model` — a null-only branch set, a union - beside a `const`, `enum` or scalar body, a union beside a `$ref`, whether on an alias or on a - property carrier — there is no field to hold it, and the whole object is kept verbatim in - `Unmodeled["openapi:discriminator"]` on that node or carrier, with an `info` diagnostic. When - `oneOf` and `anyOf` both sit beside it, `oneOf` wins and the discriminator rides on that union. + Where a union's lowering leaves no `Model` or `Union` to route by it — a union beside a + `const`, `enum` or scalar body, a null-only branch set, a `$ref` site (alias or carrier), or a + `{X, null}` union collapsed to a nullable reference (§3.3) — a `discriminator` beside it is one + more keyword the census below keeps: the whole object goes to + `Unmodeled["openapi:discriminator"]` on the node or carrier that owns the position, with an + `info` diagnostic. The collapse takes a `$ref` site's census, since it too lowers to a + reference, so a `format` or collection bound beside it is kept the same way. When `oneOf` and + `anyOf` both sit beside a discriminator, `oneOf` wins: its branches are the ones it routes to + by name. A union whose branches declare no shape at all (`oneOf: [{required: [a]}, {required: [b]}]`) is not a degradation and is not listed here: it narrows the body without reshaping it, which @@ -1075,7 +1082,8 @@ from it is exact and nothing is reported. re-derived from the dispatch that produced it. A position that reduced to a shared node gets an alias of its own first: a shared primitive must never carry one declaration's keywords. The `format` keyword joins the list only where no `type` is declared, since it is homed by pairing - with a type rather than by a node kind. Two limits are deliberate: a multi-typed schema + with a type rather than by a node kind. A `discriminator` is held the same way, by a `Model` or + `Union` that routes by it and by nothing else. Two limits are deliberate: a multi-typed schema (`type: [string, object]`) is exempt, because it lowers one variant per declared type from the same schema and the homes are the variants'; and a `$ref` position is not covered, since its siblings bind the position rather than the referent and are handled at §4.3's use-site rule. @@ -2225,7 +2233,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 `discriminator` beside the collapsed union has no node field to land in, so it is kept verbatim in `Unmodeled["openapi:discriminator"]` on the node or carrier that owns the position, with an `info` diagnostic; the same holds for a discriminator beside a null-only branch set, a union beside a non-model body, or a union beside a `$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), with a declared `discriminator` beside it staying on the model only when every target is a subtype of it and otherwise kept verbatim in `Unmodeled["openapi:discriminator"]` (§4.8, on every degraded union whose body is a model, not only case 4); 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 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, 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 | | **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/conformance/openapi/discriminated-union-beside-model.golden.json b/testdata/conformance/openapi/discriminated-union-beside-model.golden.json index 4001b83d..3917a96c 100644 --- a/testdata/conformance/openapi/discriminated-union-beside-model.golden.json +++ b/testdata/conformance/openapi/discriminated-union-beside-model.golden.json @@ -49,6 +49,146 @@ } ], "types": { + "t/composed/components/schemas/Bird/oneOf/0": { + "kind": "model", + "id": "t/composed/components/schemas/Bird/oneOf/0", + "name": { + "hint": "bird_finch" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Bird/oneOf/0" + }, + "base": { + "target": "t/openapi/components/schemas/Animal", + "nullable": false + }, + "mixins": [ + { + "target": "t/openapi/components/schemas/Finch", + "nullable": false + } + ], + "abstract": false, + "positional": false, + "discriminatorValue": "bird", + "inputOnly": false + }, + "t/composed/components/schemas/Bird/oneOf/1": { + "kind": "model", + "id": "t/composed/components/schemas/Bird/oneOf/1", + "name": { + "hint": "bird_crow" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Bird/oneOf/1" + }, + "base": { + "target": "t/openapi/components/schemas/Animal", + "nullable": false + }, + "mixins": [ + { + "target": "t/openapi/components/schemas/Crow", + "nullable": false + } + ], + "abstract": false, + "positional": false, + "discriminatorValue": "bird", + "inputOnly": false + }, + "t/openapi/components/schemas/Animal": { + "kind": "model", + "id": "t/openapi/components/schemas/Animal", + "name": { + "source": "Animal", + "canonical": "animal" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:discriminator": { + "reason": "degraded_lowering", + "value": { + "mapping": { + "bird": "#/components/schemas/Bird" + }, + "propertyName": "kind" + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Animal/discriminator" + } + }, + "openapi:oneOf": { + "reason": "degraded_lowering", + "value": [ + { + "$ref": "#/components/schemas/Bird" + } + ], + "provenance": { + "source": 0, + "pointer": "/components/schemas/Animal/oneOf" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Animal" + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/Bird": { + "kind": "union", + "id": "t/openapi/components/schemas/Bird", + "name": { + "source": "Bird", + "canonical": "bird" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Bird" + }, + "variants": [ + { + "name": { + "hint": "finch" + }, + "type": { + "target": "t/composed/components/schemas/Bird/oneOf/0", + "nullable": false + }, + "docs": {} + }, + { + "name": { + "hint": "crow" + }, + "type": { + "target": "t/composed/components/schemas/Bird/oneOf/1", + "nullable": false + }, + "docs": {} + } + ], + "exclusive": true, + "wireTagged": false + }, "t/openapi/components/schemas/Cat": { "kind": "model", "id": "t/openapi/components/schemas/Cat", @@ -175,6 +315,53 @@ "discriminatorValue": "circle", "inputOnly": false }, + "t/openapi/components/schemas/Crow": { + "kind": "model", + "id": "t/openapi/components/schemas/Crow", + "name": { + "source": "Crow", + "canonical": "crow" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Crow" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Crow/properties/caw", + "name": { + "source": "caw", + "canonical": "caw" + }, + "wireName": "caw", + "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/Crow/properties/caw" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, "t/openapi/components/schemas/Dog": { "kind": "model", "id": "t/openapi/components/schemas/Dog", @@ -249,6 +436,53 @@ "positional": false, "inputOnly": false }, + "t/openapi/components/schemas/Finch": { + "kind": "model", + "id": "t/openapi/components/schemas/Finch", + "name": { + "source": "Finch", + "canonical": "finch" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Finch" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Finch/properties/song", + "name": { + "source": "song", + "canonical": "song" + }, + "wireName": "song", + "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/Finch/properties/song" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, "t/openapi/components/schemas/Pet": { "kind": "model", "id": "t/openapi/components/schemas/Pet", @@ -441,7 +675,7 @@ { "severity": "info", "code": "openapi/degraded-construct", - "message": "oneOf/anyOf co-declared with structural keywords intersects with them, and a declared discriminator binds the branches by name, which distributing them would break, and it routes to a target that is not a subtype of this model, so the discriminator is kept verbatim too; union branches kept verbatim under Unmodeled", + "message": "oneOf/anyOf co-declared with structural keywords intersects with them, and a declared discriminator binds the branches by name, which distributing them would break; union branches kept verbatim under Unmodeled", "provenance": { "source": 0, "pointer": "/components/schemas/Pet" @@ -455,13 +689,49 @@ "source": 0, "pointer": "/components/schemas/Shape" } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "oneOf/anyOf co-declared with structural keywords intersects with them, and a declared discriminator binds the branches by name, which distributing them would break; union branches kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Animal" + } + }, + { + "severity": "info", + "code": "openapi/composition-lowering", + "message": "oneOf/anyOf co-declared with structural keywords; the composition is distributed across the union variants", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Bird" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "the discriminator routes to a type that is not a subtype of this model, so it cannot stay on the model; kept verbatim under Unmodeled beside the union it was written for", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Pet/discriminator" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "the discriminator routes to a type that is not a subtype of this model, so it cannot stay on the model; kept verbatim under Unmodeled beside the union it was written for", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Animal/discriminator" + } } ], "sources": [ { "format": "openapi@3.1", "path": "discriminated-union-beside-model.yaml", - "hash": "551db712e7c0016e4c705a8b08cb2663e2a981cdc790e6cec98b14f65e342867" + "hash": "c9d514bca7e855ca2b36608c9510dc2f404fdd3e4ac643e32d73e50f519d5f96" } ] } diff --git a/testdata/conformance/openapi/discriminated-union-beside-model.yaml b/testdata/conformance/openapi/discriminated-union-beside-model.yaml index 7d54dc59..8061008c 100644 --- a/testdata/conformance/openapi/discriminated-union-beside-model.yaml +++ b/testdata/conformance/openapi/discriminated-union-beside-model.yaml @@ -26,7 +26,9 @@ components: properties: kind: {type: string} bark: {type: boolean} - # Here every target is a true subtype of Shape, so the model keeps it. + # Here every target is a true subtype of Shape, so the model keeps it. Shape + # is declared first, so a decision taken while Shape lowers would find + # neither subtype interned yet. Shape: type: object discriminator: @@ -48,3 +50,28 @@ components: - type: object properties: side: {type: number} + # Bird writes allOf to Animal, but also a oneOf the allOf is distributed + # across, so Bird lowers to a union and is no subtype of Animal. Only the + # lowered document can tell, so the discriminator is kept beside the union. + Animal: + type: object + discriminator: + propertyName: kind + mapping: + bird: '#/components/schemas/Bird' + oneOf: + - $ref: '#/components/schemas/Bird' + Bird: + allOf: + - $ref: '#/components/schemas/Animal' + oneOf: + - $ref: '#/components/schemas/Finch' + - $ref: '#/components/schemas/Crow' + Finch: + type: object + properties: + song: {type: string} + Crow: + type: object + properties: + caw: {type: string} diff --git a/testdata/conformance/openapi/nullable-union-discriminator.golden.json b/testdata/conformance/openapi/nullable-union-discriminator.golden.json index 607619a3..ef72d324 100644 --- a/testdata/conformance/openapi/nullable-union-discriminator.golden.json +++ b/testdata/conformance/openapi/nullable-union-discriminator.golden.json @@ -215,7 +215,7 @@ { "severity": "info", "code": "openapi/degraded-construct", - "message": "a discriminator beside a oneOf/anyOf that collapses to a nullable reference routes through no union; kept verbatim under Unmodeled", + "message": "this position lowered to a nullable reference to its one non-null branch, which has no home for discriminator declared beside it; kept verbatim under Unmodeled", "provenance": { "source": 0, "pointer": "/components/schemas/MaybeCat" @@ -224,7 +224,7 @@ { "severity": "info", "code": "openapi/degraded-construct", - "message": "a discriminator beside a oneOf/anyOf that collapses to a nullable reference routes through no union; kept verbatim under Unmodeled", + "message": "this position lowered to a nullable reference to its one non-null branch, which has no home for discriminator declared beside it; kept verbatim under Unmodeled", "provenance": { "source": 0, "pointer": "/components/schemas/Owner/properties/pet" diff --git a/testdata/conformance/openapi/unhomed-keywords.golden.json b/testdata/conformance/openapi/unhomed-keywords.golden.json index 6fd18086..d7f94c4e 100644 --- a/testdata/conformance/openapi/unhomed-keywords.golden.json +++ b/testdata/conformance/openapi/unhomed-keywords.golden.json @@ -546,6 +546,37 @@ "uniqueItems": false } }, + "t/openapi/components/schemas/RefDiscriminator": { + "kind": "scalar", + "id": "t/openapi/components/schemas/RefDiscriminator", + "name": { + "source": "RefDiscriminator", + "canonical": "ref_discriminator" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:discriminator": { + "reason": "degraded_lowering", + "value": { + "propertyName": "a" + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/RefDiscriminator/discriminator" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/RefDiscriminator" + }, + "base": { + "target": "t/openapi/components/schemas/RefTargetObj", + "nullable": false + } + }, "t/openapi/components/schemas/RefEnum": { "kind": "scalar", "id": "t/openapi/components/schemas/RefEnum", @@ -783,6 +814,37 @@ "nullable": false } }, + "t/openapi/components/schemas/UntypedDiscriminator": { + "kind": "scalar", + "id": "t/openapi/components/schemas/UntypedDiscriminator", + "name": { + "source": "UntypedDiscriminator", + "canonical": "untyped_discriminator" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:discriminator": { + "reason": "degraded_lowering", + "value": { + "propertyName": "kind" + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/UntypedDiscriminator/discriminator" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/UntypedDiscriminator" + }, + "base": { + "target": "t/prim/any", + "nullable": false + } + }, "t/openapi/components/schemas/UntypedFormat": { "kind": "scalar", "id": "t/openapi/components/schemas/UntypedFormat", @@ -978,6 +1040,15 @@ "pointer": "/components/schemas/UntypedFormat" } }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "this position lowered to a node of kind \"primitive\", which has no home for discriminator declared beside it; kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/UntypedDiscriminator" + } + }, { "severity": "info", "code": "openapi/degraded-construct", @@ -1068,6 +1139,15 @@ "pointer": "/components/schemas/RefAdditionalProperties" } }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "this position lowered to an alias over its $ref target, which has no home for discriminator declared beside it; kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/RefDiscriminator" + } + }, { "severity": "info", "code": "openapi/degraded-construct", @@ -1127,7 +1207,7 @@ { "format": "openapi@3.1", "path": "unhomed-keywords.yaml", - "hash": "dcbdf05a564f1d173b5c0105508e731cc458e932a8307fd75912b092e72d949d" + "hash": "917a0e0a8513e512369d8994c12852b2ee27026035f81297fcec9e0dad7793ac" } ] } diff --git a/testdata/conformance/openapi/unhomed-keywords.yaml b/testdata/conformance/openapi/unhomed-keywords.yaml index ef445b46..c0635caf 100644 --- a/testdata/conformance/openapi/unhomed-keywords.yaml +++ b/testdata/conformance/openapi/unhomed-keywords.yaml @@ -17,6 +17,9 @@ components: # format pairs with a declared type to select a primitive or hoist a scalar. # With no type beside it, nothing reads it. UntypedFormat: {format: uuid} + # A discriminator is held by a Model or a Union that routes by it. The top + # type routes nothing, so the discriminator is kept rather than dropped. + UntypedDiscriminator: {discriminator: {propertyName: kind}} # A contradictory schema: an instance cannot be both a two-member string # enum and an object. The enum is taken and the property set kept beside it, # so which half the IR describes stays recoverable (ir-design §4.8). @@ -56,6 +59,7 @@ components: RefConst: {$ref: '#/components/schemas/RefTargetStr', const: a} RefRequired: {$ref: '#/components/schemas/RefTargetObj', required: [a]} RefAdditionalProperties: {$ref: '#/components/schemas/RefTargetObj', additionalProperties: false} + RefDiscriminator: {$ref: '#/components/schemas/RefTargetObj', discriminator: {propertyName: a}} # The control: a bound and a description at the identical position always # reached the alias's own fields, so neither may acquire an entry now. RefConstrained: {$ref: '#/components/schemas/RefTargetStr', minLength: 3, description: kept} From f86afa7a75abf806f943f4e2c2d5d7c3f2d4a876 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 13 Oct 2026 14:03:16 +0300 Subject: [PATCH 06/13] fix(compilers/compile): stop settling on cancellation, refuse removals Settling does work proportional to the document, as the two walks before it do, but ignored the context, so a deadline could not interrupt it. Settle now takes the context and stops between two settlements once it is done. run calls it before its post-service check, so the one boundary refuses a registry either phase left cut short. Settle refused any change in the registry's size but reported each as an addition, so a settlement that removed a node was said to have added -1. The contract now forbids removing a node as well as adding one, since a removed node leaves its references dangling, and the refusal states the size before and after. --- compilers/compile/settle.go | 27 +++++++--- compilers/compile/settle_test.go | 85 ++++++++++++++++++++++++------- compilers/openapi/budgets_test.go | 61 +++++++++++++++++++--- compilers/openapi/openapi.go | 22 ++++---- docs/architecture.md | 6 +-- 5 files changed, 154 insertions(+), 47 deletions(-) diff --git a/compilers/compile/settle.go b/compilers/compile/settle.go index c24577a7..76fdd49a 100644 --- a/compilers/compile/settle.go +++ b/compilers/compile/settle.go @@ -1,6 +1,10 @@ package compile -import "github.com/dexpace/morphic/ir" +import ( + "context" + + "github.com/dexpace/morphic/ir" +) // A Settlement is a decision about a node that reads nodes other lowerings // intern. A walk lowers one declaration at a time, so mid-walk such a decision @@ -8,8 +12,9 @@ import "github.com/dexpace/morphic/ir" // declaration order (invariant 3). Deferred to Settle, it reads the registry // whole. // -// Settle may rewrite nodes already in reg but must not add one: nothing lowers -// after it, so a node it added would be the only one no lowering produced. +// Settle may rewrite nodes already in reg but must not add or remove one: +// nothing lowers after it, so an added node is one no lowering produced, and a +// removed one leaves every reference to it dangling. type Settlement interface { Settle(reg ir.TypeRegistry) []ir.Diagnostic } @@ -30,18 +35,24 @@ func (t *Types) Defer(s Settlement) { // Settle takes every deferred Settlement once, in the order deferred, and // returns their diagnostics. A compiler calls it after its last lowering -// returns. A settlement that adds a node is refused; the node stays, as it may -// already be referenced. -func (t *Types) Settle() []ir.Diagnostic { +// returns. Like the walks, it stops between two settlements once ctx is done, +// leaving a registry the caller must not assemble. A settlement that adds or +// removes a node is refused, and what it did is left standing: the refusal +// names a compiler bug, not a recovery. +func (t *Types) Settle(ctx context.Context) []ir.Diagnostic { t.settled = true pending := t.pending t.pending = nil diags := make([]ir.Diagnostic, 0, len(pending)) for _, s := range pending { + if ctx.Err() != nil { + return diags + } before := len(t.reg) diags = append(diags, s.Settle(t.reg)...) - if added := len(t.reg) - before; added != 0 { - t.refuse("settlement %T added %d node(s); a settlement may only rewrite nodes the walk interned", s, added) + if after := len(t.reg); after != before { + t.refuse("settlement %T changed the registry from %d to %d nodes; a settlement may only rewrite nodes the walk interned", + s, before, after) } } return diags diff --git a/compilers/compile/settle_test.go b/compilers/compile/settle_test.go index 59b57860..c1d436d0 100644 --- a/compilers/compile/settle_test.go +++ b/compilers/compile/settle_test.go @@ -1,6 +1,7 @@ package compile_test import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -31,7 +32,7 @@ func TestTypes_SettleTakesEachDeferredSettlementOnceInOrder(t *testing.T) { })) } - diags := types.Settle() + diags := types.Settle(t.Context()) assert.Equal(t, []string{"first", "second"}, runs, "each runs once, in the order deferred") require.Len(t, diags, 2) @@ -40,17 +41,43 @@ func TestTypes_SettleTakesEachDeferredSettlementOnceInOrder(t *testing.T) { td, ok := types.Node("t/x") require.True(t, ok) assert.Equal(t, "firstsecond", td.Common().Docs.Description, "a settlement rewrites the registry the document holds") - assert.Empty(t, types.Settle(), "a settlement taken is not taken again") + assert.Empty(t, types.Settle(t.Context()), "a settlement taken is not taken again") assert.Empty(t, types.Violations()) } +// TestTypes_SettleStopsBetweenSettlementsOnCancellation pins that Settle +// honours ctx as the walks do: once it is done, no further settlement is +// taken, and what the ones before it reported is returned. +func TestTypes_SettleStopsBetweenSettlementsOnCancellation(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + types := compile.NewTypes() + var runs []string + types.Defer(settleFunc(func(ir.TypeRegistry) []ir.Diagnostic { + runs = append(runs, "first") + cancel() + return []ir.Diagnostic{{Code: "first"}} + })) + types.Defer(settleFunc(func(ir.TypeRegistry) []ir.Diagnostic { + runs = append(runs, "second") + return nil + })) + + diags := types.Settle(ctx) + + assert.Equal(t, []string{"first"}, runs, "the settlement after the cancellation is not taken") + require.Len(t, diags, 1) + assert.Equal(t, "first", diags[0].Code) + assert.Empty(t, types.Violations(), "a stop is the caller's to act on, not a breach") +} + // TestTypes_SettleWithNothingDeferredReturnsNothing pins the common case: a // compile that deferred nothing settles to no diagnostics and no violation. func TestTypes_SettleWithNothingDeferredReturnsNothing(t *testing.T) { t.Parallel() types := compile.NewTypes() - assert.Empty(t, types.Settle()) + assert.Empty(t, types.Settle(t.Context())) assert.Empty(t, types.Violations()) } @@ -64,7 +91,7 @@ func TestTypes_DeferRefusesWhatSettleWouldNeverTake(t *testing.T) { queue func(types *compile.Types, s compile.Settlement) }{ {"after settling", func(types *compile.Types, s compile.Settlement) { - types.Settle() + types.Settle(t.Context()) types.Defer(s) }}, {"nil", func(types *compile.Types, _ compile.Settlement) { types.Defer(nil) }}, @@ -76,28 +103,48 @@ func TestTypes_DeferRefusesWhatSettleWouldNeverTake(t *testing.T) { runs := 0 c.queue(types, settleFunc(func(ir.TypeRegistry) []ir.Diagnostic { runs++; return nil })) - assert.Empty(t, types.Settle()) + assert.Empty(t, types.Settle(t.Context())) assert.Zero(t, runs) assert.Len(t, types.Violations(), 1, "the refusal is recorded, not dropped") }) } } -// TestTypes_SettlementThatAddsANodeIsRefused pins the one write a settlement -// may not make. Nothing lowers after Settle, so a node one added would be the -// only node no lowering produced; the registry keeps it and reports the breach. -func TestTypes_SettlementThatAddsANodeIsRefused(t *testing.T) { +// TestTypes_SettlementThatAddsOrRemovesANodeIsRefused pins the writes a +// settlement may not make. Nothing lowers after Settle, so an added node is +// the only one no lowering produced, and a removed one leaves its references +// dangling. Either is reported as a breach, and left standing. +func TestTypes_SettlementThatAddsOrRemovesANodeIsRefused(t *testing.T) { t.Parallel() - types := compile.NewTypes() - types.Defer(settleFunc(func(reg ir.TypeRegistry) []ir.Diagnostic { - reg["t/added"] = &ir.Model{ID: "t/added"} - return nil - })) + cases := []struct { + name string + seeded bool // t/other is registered before the settlement runs + write func(reg ir.TypeRegistry) + want string + present bool // t/other is registered after it ran + }{ + {"adds", false, func(reg ir.TypeRegistry) { reg["t/other"] = &ir.Model{ID: "t/other"} }, "from 1 to 2 nodes", true}, + {"removes", true, func(reg ir.TypeRegistry) { delete(reg, "t/other") }, "from 2 to 1 nodes", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + types := compile.NewTypes() + types.Intern("/p", "t/x", func() ir.TypeDef { return &ir.Model{ID: "t/x"} }) + if c.seeded { + types.Register("t/other", &ir.Model{ID: "t/other"}) + } + types.Defer(settleFunc(func(reg ir.TypeRegistry) []ir.Diagnostic { + c.write(reg) + return nil + })) - types.Settle() + types.Settle(t.Context()) - require.Len(t, types.Violations(), 1) - assert.Contains(t, types.Violations()[0], "added 1 node") - _, kept := types.Node("t/added") - assert.True(t, kept, "the node stays, since something may already reference it") + require.Len(t, types.Violations(), 1) + assert.Contains(t, types.Violations()[0], c.want) + _, present := types.Node("t/other") + assert.Equal(t, c.present, present, "what the settlement did is left standing") + }) + } } diff --git a/compilers/openapi/budgets_test.go b/compilers/openapi/budgets_test.go index 4fa9ad84..0cf95214 100644 --- a/compilers/openapi/budgets_test.go +++ b/compilers/openapi/budgets_test.go @@ -3,6 +3,7 @@ package openapi import ( "context" "fmt" + "math" "os" "strings" "testing" @@ -14,6 +15,7 @@ import ( "github.com/dexpace/morphic/compilers" "github.com/dexpace/morphic/compilers/compile" "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/compilers/openapi/internal/load" "github.com/dexpace/morphic/compilers/openapi/internal/lowering" "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" "github.com/dexpace/morphic/ir" @@ -301,12 +303,10 @@ func TestCompile_CanceledContextStopsTheCompile(t *testing.T) { // liveForCalls answers Err with nil for its first left calls and // context.Canceled from then on, counting every call it answers. // -// The document under test declares nothing, so the walks never consult -// ctx.Err() and run's phase boundaries are its only callers. Counting puts the -// cancellation at a chosen boundary deterministically, where a timer would -// race the walk. seen says which boundary stopped the compile: every boundary -// returns the same nil document and context.Canceled, so one that never fires -// is covered for by the next and the outcome cannot tell them apart. +// Counting puts the cancellation at a chosen call deterministically, where a +// timer would race the walk. seen says which call stopped the compile: every +// boundary returns the same nil document and context.Canceled, so one that +// never fires is covered for by the next and the outcome cannot tell them apart. type liveForCalls struct { context.Context left *int @@ -322,6 +322,9 @@ func (c liveForCalls) Err() error { return context.Canceled } +// TestRun_RefusesAtEveryPhaseBoundaryOnCancellation pins each boundary run +// checks. The document declares nothing, so the walks never consult ctx.Err() +// and the boundaries are its only callers. func TestRun_RefusesAtEveryPhaseBoundaryOnCancellation(t *testing.T) { t.Parallel() tests := []struct { @@ -348,3 +351,49 @@ func TestRun_RefusesAtEveryPhaseBoundaryOnCancellation(t *testing.T) { }) } } + +// TestRun_CancellationReachesTheSettlements pins that the settling honours ctx +// as the walks do. Cancelled at its own check it takes no settlement, and the +// boundary after it refuses the document. One call later the settlement runs +// and only the boundary refuses, so the check that stopped it was the +// settling's own. +func TestRun_CancellationReachesTheSettlements(t *testing.T) { + t.Parallel() + const src = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: {} +components: + schemas: + Pet: + type: object + discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}} + oneOf: [{$ref: '#/components/schemas/Cat'}] + Cat: {type: object} +` + runUntil := func(live int) (int, []ir.Diagnostic, error) { + loaded, _, err := load.Load(t.Context(), 0, + compilers.Source{Path: "spec.yaml", Data: []byte(src)}, loadOptions(Options{}.withDefaults())) + require.NoError(t, err) + left, seen := live, 0 + _, diags, err := run(liveForCalls{Context: t.Context(), left: &left, seen: &seen}, + loweringCtx(loaded, Options{}.withDefaults()), compile.NewTypes()) + return seen, diags, err + } + moved := func(diags []ir.Diagnostic) bool { + return openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/Pet/discriminator") + } + // Pet's is the one settlement, so the settling asks once, second to last, + // and the boundary after it asks last. + calls, diags, err := runUntil(math.MaxInt) + require.NoError(t, err) + require.True(t, moved(diags), "uncancelled, the settlement moves the discriminator") + + seen, diags, err := runUntil(calls - 2) + require.ErrorIs(t, err, context.Canceled) + assert.False(t, moved(diags), "a cancelled settling takes no settlement") + assert.Equal(t, calls, seen, "and the boundary after it still asks") + + _, diags, err = runUntil(calls - 1) + require.ErrorIs(t, err, context.Canceled) + assert.True(t, moved(diags), "one call later the settlement ran and only the boundary refused") +} diff --git a/compilers/openapi/openapi.go b/compilers/openapi/openapi.go index 02159f18..6ea584c1 100644 --- a/compilers/openapi/openapi.go +++ b/compilers/openapi/openapi.go @@ -143,9 +143,9 @@ func (*Compiler) SourceTable(sources []compilers.Source, opts compilers.Options) // // It reports cancellation as a Go error, not a diagnostic: nothing about the // document is wrong, the caller stopped asking. The document is dropped with it, -// since the two walks that honour ctx stop mid-registry and what is left -// references types never interned, but the diagnostics gathered before the stop -// are returned. +// since the phases that honour ctx stop mid-registry and what is left +// references types never interned or settled, but the diagnostics gathered +// before the stop are returned. func run(ctx context.Context, c lowering.Ctx, ts *compile.Types) (*ir.Document, []ir.Diagnostic, error) { // out and the anchor memo are this function's, not a struct's: a document // being built and a memo (micro-compiler-design §4.1). Nothing below @@ -159,11 +159,10 @@ func run(ctx context.Context, c lowering.Ctx, ts *compile.Types) (*ir.Document, // whole stream to do it. var acc compile.Diags acc.AppendAll(schema.LowerComponentSchemas(ctx, c, ts, &anchors)) - // The two checks in this function sit after the two phases that honour ctx. - // Each stops between items and returns what it had, so a non-nil ctx.Err() - // here is the only way to tell a phase that finished from one that was cut - // short — and the cut-short one leaves a registry nothing may be assembled - // from. + // The checks in this function sit after the phases that honour ctx. Each + // stops between items and returns what it had, so a non-nil ctx.Err() here + // is the only way to tell a phase that finished from one that was cut short — + // and the cut-short one leaves a registry nothing may be assembled from. if err := ctx.Err(); err != nil { return nil, acc.List(), err } @@ -179,12 +178,13 @@ func run(ctx context.Context, c lowering.Ctx, ts *compile.Types) (*ir.Document, out.Services = []ir.Service{svc} out.TagDefs = tagDefs acc.AppendAll(svcDiags) + // Every lowering has returned, so what was deferred until the registry is + // whole is decided now (compile.Settlement), before the check that refuses + // a registry either phase left cut short. + acc.AppendAll(ts.Settle(ctx)) if err := ctx.Err(); err != nil { return nil, acc.List(), err } - // Every lowering has returned, so what was deferred until the registry is - // whole can be decided now (compile.Settlement). - acc.AppendAll(ts.Settle()) m, metaDiags := lowerMeta(c) out.Name, out.Version = m.Name, m.Version diff --git a/docs/architecture.md b/docs/architecture.md index 9c7d4fe2..3328f2d6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,9 +112,9 @@ parsed-node budget and an alias budget for one source document and a member budg each with a documented default and each settable — zero takes the default, negative turns the budget off. Crossing one is a spec problem like any other, so it is an `openapi/budget-exceeded` diagnostic rather than a Go error. Time is bounded by the caller -instead of by a constant: the two walks that do work proportional to the document honour the -`context.Context` a compile is given, so a deadline or a cancellation stops one between items and -the error reaches the caller unwrapped. +instead of by a constant: the walks that do work proportional to the document, and the settling +after them, honour the `context.Context` a compile is given, so a deadline or a cancellation stops +one between items and the error reaches the caller unwrapped. Compilers are registered in a registry keyed by the formats they report, and detection belongs to them too: the registry asks each compiler in registration order whether it recognizes a source, and From 603053ced688e979ded07bfea1b121695d2ca8fc Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 13 Oct 2026 14:03:16 +0300 Subject: [PATCH 07/13] docs(compilers/openapi): correct discriminator prose, name its limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In ir-design, the census bullet said a discriminator is homed "the same way" as format, which is homed by pairing with a type rather than by a node kind. The paragraph on unions credited that census with the $ref site, which the bullet itself leaves to the use-site rule of §4.3. And the sentence on which combinator's branches are routed sat in the paragraph about the census rather than the one about routes. The settlement's doc now gives both of its reasons, and the code names where two known limits are met: a discriminator kept verbatim keeps its extensions twice (#849), and each route is a fresh ir.IsSubtype walk (#850). One test is renamed to read like its siblings. --- .../openapi/internal/schema/discriminated.go | 9 ++++++--- .../internal/schema/discriminated_test.go | 4 ++-- compilers/openapi/internal/schema/schema.go | 4 +++- docs/ir-design.md | 19 +++++++++---------- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/compilers/openapi/internal/schema/discriminated.go b/compilers/openapi/internal/schema/discriminated.go index 464855cb..b03fd199 100644 --- a/compilers/openapi/internal/schema/discriminated.go +++ b/compilers/openapi/internal/schema/discriminated.go @@ -53,8 +53,9 @@ func namedBranches(c lowering.Ctx, s *oas3.Schema) []ir.TypeID { // pass/validate holds it to (ir.IsSubtype). Otherwise it moves, whole, to // Unmodeled beside the union it was written for. // -// It is a settlement because a subtype lowered after the model has no Base yet -// when the model lowers. +// It is a settlement because only a lowered node says whether its type is a +// subtype, and a subtype lowered after the model has no Base yet when the +// model lowers. type besideUnionDiscriminator struct { c lowering.Ctx s *oas3.Schema @@ -84,7 +85,9 @@ func (d besideUnionDiscriminator) Settle(reg ir.TypeRegistry) []ir.Diagnostic { } // routesToSubtypes reports whether every type m's discriminator routes to is a -// subtype of m: each mapping target, the default, and each of named. +// subtype of m: each mapping target, the default, and each of named. Each is +// one ir.IsSubtype walk, as pass/validate spends on a mapping target; no walk +// is shared yet (GitHub #850). func routesToSubtypes(reg ir.TypeRegistry, m *ir.Model, named []ir.TypeID) bool { doc := &ir.Document{Types: reg} routes := slices.AppendSeq(slices.Clone(named), maps.Values(m.Discriminator.Mapping)) diff --git a/compilers/openapi/internal/schema/discriminated_test.go b/compilers/openapi/internal/schema/discriminated_test.go index 374c43bc..665b79f6 100644 --- a/compilers/openapi/internal/schema/discriminated_test.go +++ b/compilers/openapi/internal/schema/discriminated_test.go @@ -186,10 +186,10 @@ func TestDiscriminatorBesideUnion_StaysWhenEveryRouteIsASubtype(t *testing.T) { } } -// TestDiscriminatorBesideUnion_AnySubtypeDepthStays pins that no depth bound +// TestDiscriminatorBesideUnion_StaysForASubtypeAtAnyDepth pins that no depth bound // stands between a route and Pet: pass.Validate follows a hierarchy to any // depth, so a bound here would move a discriminator it accepts. -func TestDiscriminatorBesideUnion_AnySubtypeDepthStays(t *testing.T) { +func TestDiscriminatorBesideUnion_StaysForASubtypeAtAnyDepth(t *testing.T) { t.Parallel() var chain strings.Builder for i := range 300 { diff --git a/compilers/openapi/internal/schema/schema.go b/compilers/openapi/internal/schema/schema.go index 3b67727e..9c18ffde 100644 --- a/compilers/openapi/internal/schema/schema.go +++ b/compilers/openapi/internal/schema/schema.go @@ -713,7 +713,9 @@ func keywordHome(td ir.TypeDef, s *oas3.Schema, keyword string) bool { // discriminatorHome reports whether td holds the discriminator its position // declared. Only a Model and a Union have the field, and having it is not -// filling it: the Union a mixed-type enum lowers to carries none. +// filling it: the Union a mixed-type enum lowers to carries none. One kept for +// want of a home keeps its extensions a second time, under +// openapi:discriminator/x-* (GitHub #849). func discriminatorHome(td ir.TypeDef) bool { switch n := td.(type) { case *ir.Model: diff --git a/docs/ir-design.md b/docs/ir-design.md index 2a212c23..85750553 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -951,8 +951,9 @@ from it is exact and nothing is reported. A `discriminator` beside any of the five whose body lowers to a `Model` stays on that `Model` only when every type it routes to is a subtype of it at any depth (`ir.IsSubtype`, the rule `pass/validate` holds a model discriminator to): each `mapping` target, the `defaultMapping`, - and each component a `$ref` branch names, which it routes to by name. An inline branch, or one - naming no component, is no route unless the `mapping` names it. Otherwise the whole object + and each component a `$ref` branch names, which it routes to by name — `oneOf`'s branches where + `oneOf` and `anyOf` are both declared, since `oneOf` wins. An inline branch, or one naming no + component, is no route unless the `mapping` names it. Otherwise the whole object (`propertyName`, `mapping`, `defaultMapping`) is kept verbatim in `Unmodeled["openapi:discriminator"]` beside the union, with an `info` diagnostic at the discriminator; a subtype keeps the `DiscriminatorValue` its mapping key gives it, since that is @@ -977,13 +978,11 @@ from it is exact and nothing is reported. Where a union's lowering leaves no `Model` or `Union` to route by it — a union beside a `const`, `enum` or scalar body, a null-only branch set, a `$ref` site (alias or carrier), or a - `{X, null}` union collapsed to a nullable reference (§3.3) — a `discriminator` beside it is one - more keyword the census below keeps: the whole object goes to + `{X, null}` union collapsed to a nullable reference (§3.3) — a `discriminator` beside it is kept + as any keyword its node cannot carry is: the whole object goes to `Unmodeled["openapi:discriminator"]` on the node or carrier that owns the position, with an - `info` diagnostic. The collapse takes a `$ref` site's census, since it too lowers to a - reference, so a `format` or collection bound beside it is kept the same way. When `oneOf` and - `anyOf` both sit beside a discriminator, `oneOf` wins: its branches are the ones it routes to - by name. + `info` diagnostic. The collapse is held to a `$ref` site's rule (§4.3), since it too lowers to a + reference, so a `format` or collection bound beside it is kept the same way. A union whose branches declare no shape at all (`oneOf: [{required: [a]}, {required: [b]}]`) is not a degradation and is not listed here: it narrows the body without reshaping it, which @@ -1082,8 +1081,8 @@ from it is exact and nothing is reported. re-derived from the dispatch that produced it. A position that reduced to a shared node gets an alias of its own first: a shared primitive must never carry one declaration's keywords. The `format` keyword joins the list only where no `type` is declared, since it is homed by pairing - with a type rather than by a node kind. A `discriminator` is held the same way, by a `Model` or - `Union` that routes by it and by nothing else. Two limits are deliberate: a multi-typed schema + with a type rather than by a node kind. `discriminator` joins the list too, homed by a `Model` + or `Union` that routes by it and by nothing else. Two limits are deliberate: a multi-typed schema (`type: [string, object]`) is exempt, because it lowers one variant per declared type from the same schema and the homes are the variants'; and a `$ref` position is not covered, since its siblings bind the position rather than the referent and are handled at §4.3's use-site rule. From 8907c77e14a1cf722b0f457115c34cf7e77a825b Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 13 Oct 2026 15:49:07 +0300 Subject: [PATCH 08/13] fix(compilers/openapi): settle a discriminator on a multi-typed body A schema with a type array, such as `type: [object, string]`, lowers to a Union with one variant per declared type, each built from the same schema, so its object variant is the model holding the discriminator. With a oneOf/anyOf beside it, the branches are kept verbatim on that Union, but the settlement only looked for a model body, so the variant kept a discriminator mapping to types that are not its subtypes, and pass/validate rejected a valid document with pass/discriminator-missing-variant. The settlement now takes each model the body holds the discriminator on: the body itself, or the model variants of a multi-typed body. A discriminator it cannot keep moves to the node keeping the union, beside the union it was written for, so the settlement records both nodes. Settle reports a missing union node as an internal invariant rather than writing through it. The move's diagnostic now says "the model holding it", since that model may be a variant rather than the node the position lowered to. The conformance corpus gains a multi-typed body beside a discriminated union. A multi-typed schema with no object variant still drops its discriminator, as it drops any keyword no variant holds (#852), and one with no union beside it keeps the discriminator on a variant no subtype names (#853). --- compilers/openapi/conformance_test.go | 12 +- .../openapi/internal/schema/discriminated.go | 56 +++-- .../schema/discriminated_internal_test.go | 16 +- .../internal/schema/discriminated_test.go | 46 ++++- docs/ir-design.md | 18 +- ...scriminated-union-beside-model.golden.json | 195 +++++++++++++++++- .../discriminated-union-beside-model.yaml | 21 +- 7 files changed, 326 insertions(+), 38 deletions(-) diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index 0cdf86cc..46266c77 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -1026,8 +1026,9 @@ func assertDiscriminatorDefaultMapping(t *testing.T, doc *ir.Document, diags []i // assertDiscriminatedUnionBesideModel pins where a discriminator goes when its // union is kept verbatim beside a model. Pet's and Animal's routes are not // subtypes of them, so pass.Validate would reject the discriminator on the -// model; it moves whole to Unmodeled beside the union. Shape's routes are true -// allOf subtypes, so the model keeps it, and the document validates clean. +// model; it moves whole to Unmodeled beside the union. Vehicle's object +// variant holds one the same way. Shape's routes are true allOf subtypes, so +// the model keeps it, and the document validates clean. func assertDiscriminatedUnionBesideModel(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { assert.Empty(t, pass.Validate(doc)) @@ -1044,6 +1045,13 @@ func assertDiscriminatedUnionBesideModel(t *testing.T, doc *ir.Document, diags [ _, isUnion := doc.Types[namedID("Bird")].(*ir.Union) assert.True(t, isUnion, "Bird writes allOf to Animal but lowers to a union, which is no subtype") + vehicle, ok := doc.Types[namedID("Vehicle")].(*ir.Union) + require.True(t, ok, "a multi-typed body lowers to a union") + assert.Contains(t, vehicle.Unmodeled, "openapi:discriminator", "kept beside the union it was written for") + variant, ok := doc.Types["t/anon/components/schemas/Vehicle/type/0"].(*ir.Model) + require.True(t, ok) + assert.Nil(t, variant.Discriminator, "the object variant does not carry it") + shape, ok := doc.Types[namedID("Shape")].(*ir.Model) require.True(t, ok) require.NotNil(t, shape.Discriminator, "every target is a subtype, so it stays") diff --git a/compilers/openapi/internal/schema/discriminated.go b/compilers/openapi/internal/schema/discriminated.go index b03fd199..1e93fd56 100644 --- a/compilers/openapi/internal/schema/discriminated.go +++ b/compilers/openapi/internal/schema/discriminated.go @@ -14,16 +14,37 @@ import ( "github.com/dexpace/morphic/ir" ) -// deferDiscriminatorBesideUnion queues the settlement of whatever discriminator -// the model at id holds beside the union lowerBesideUnmodeledUnion kept -// verbatim. A body that lowered to anything else holds none: the census kept -// its discriminator where it kept the rest. -func deferDiscriminatorBesideUnion(c lowering.Ctx, ts *compile.Types, id ir.TypeID, s *oas3.Schema, pointer jsontext.Pointer) { - td, _ := ts.Node(id) - if _, isModel := td.(*ir.Model); !isModel { - return +// deferDiscriminatorBesideUnion queues a settlement for each model that holds +// the discriminator written beside the union lowerBesideUnmodeledUnion kept +// verbatim on owner (discriminatorModels). A body that lowered to anything else +// holds none: the census kept its discriminator where it kept the rest. +func deferDiscriminatorBesideUnion(c lowering.Ctx, ts *compile.Types, owner ir.TypeID, s *oas3.Schema, pointer jsontext.Pointer) { + named := namedBranches(c, s) + for _, model := range discriminatorModels(ts, owner) { + ts.Defer(besideUnionDiscriminator{c: c, s: s, pointer: pointer, owner: owner, model: model, named: named}) + } +} + +// discriminatorModels returns the models holding the discriminator of the body +// lowered to owner: owner itself when it is a model, and otherwise each model +// variant of the union a multi-typed body lowers to, which lowerUnion builds +// from the same schema. lowerDiscriminator fills no other node a body yields. +func discriminatorModels(ts *compile.Types, owner ir.TypeID) []ir.TypeID { + td, _ := ts.Node(owner) + switch n := td.(type) { + case *ir.Model: + return []ir.TypeID{owner} + case *ir.Union: + var models []ir.TypeID + for _, v := range n.Variants { + if vt, _ := ts.Node(v.Type.Target); isKind(vt, ir.KindModel) { + models = append(models, v.Type.Target) + } + } + return models + default: + return nil } - ts.Defer(besideUnionDiscriminator{c: c, s: s, pointer: pointer, model: id, named: namedBranches(c, s)}) } // namedBranches returns the components s's union branches name by $ref, in @@ -51,7 +72,7 @@ func namedBranches(c lowering.Ctx, s *oas3.Schema) []ir.TypeID { // union kept verbatim. A model discriminator says the model is a hierarchy's // base, so it stays only where every type it routes to is a subtype, the rule // pass/validate holds it to (ir.IsSubtype). Otherwise it moves, whole, to -// Unmodeled beside the union it was written for. +// Unmodeled on the node keeping the union it was written for. // // It is a settlement because only a lowered node says whether its type is a // subtype, and a subtype lowered after the model has no Base yet when the @@ -60,7 +81,8 @@ type besideUnionDiscriminator struct { c lowering.Ctx s *oas3.Schema pointer jsontext.Pointer - model ir.TypeID + owner ir.TypeID // keeps the union verbatim + model ir.TypeID // holds the discriminator: owner, or a variant of it named []ir.TypeID // namedBranches } @@ -69,19 +91,21 @@ type besideUnionDiscriminator struct { // reference deferred when it built the model before its declaration did. func (d besideUnionDiscriminator) Settle(reg ir.TypeRegistry) []ir.Diagnostic { m, isModel := reg[d.model].(*ir.Model) - if !isModel || m == nil { + owner := reg[d.owner] + if !isModel || m == nil || ir.IsNilTypeDef(owner) { return []ir.Diagnostic{d.c.DiagAt(ir.SeverityError, diag.InternalInvariant, d.pointer, - "internal: %q is no model, so the discriminator deferred for it is dropped", d.model)} + "internal: %q is no model or %q is not registered, so the discriminator settlement deferred for them is not taken", + d.model, d.owner)} } if m.Discriminator == nil || routesToSubtypes(reg, m, d.named) { return nil } m.Discriminator = nil at := d.pointer + ids.Ptr("discriminator") - _, diags := PreserveSchemaKeyword(d.c, &m.Unmodeled, d.s, "discriminator", ir.ReasonDegradedLowering, at) + _, diags := PreserveSchemaKeyword(d.c, &owner.Common().Unmodeled, d.s, "discriminator", ir.ReasonDegradedLowering, at) return append(diags, d.c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, at, - "the discriminator routes to a type that is not a subtype of this model, so it cannot stay on "+ - "the model; kept verbatim under Unmodeled beside the union it was written for")) + "the discriminator routes to a type that is not a subtype of the model holding it, so it cannot "+ + "stay there; kept verbatim under Unmodeled beside the union it was written for")) } // routesToSubtypes reports whether every type m's discriminator routes to is a diff --git a/compilers/openapi/internal/schema/discriminated_internal_test.go b/compilers/openapi/internal/schema/discriminated_internal_test.go index 7cdbbaf9..0ef20582 100644 --- a/compilers/openapi/internal/schema/discriminated_internal_test.go +++ b/compilers/openapi/internal/schema/discriminated_internal_test.go @@ -11,18 +11,20 @@ import ( "github.com/dexpace/morphic/ir" ) -// TestBesideUnionDiscriminator_ReportsWhatIsNoModel pins that a settlement -// whose model is missing, or is no model, is announced rather than skipped: -// the discriminator it was deferred for would otherwise vanish unremarked. -func TestBesideUnionDiscriminator_ReportsWhatIsNoModel(t *testing.T) { +// TestBesideUnionDiscriminator_ReportsWhatItCannotSettle pins that a settlement +// whose model is missing or is no model, or whose union's node is missing, is +// announced rather than skipped: the discriminator it was deferred for would +// otherwise be settled nowhere, unremarked. +func TestBesideUnionDiscriminator_ReportsWhatItCannotSettle(t *testing.T) { t.Parallel() for name, reg := range map[string]ir.TypeRegistry{ - "missing": {}, - "no model": {"t/ghost": &ir.Scalar{ID: "t/ghost"}}, + "model missing": {"t/owner": &ir.Union{ID: "t/owner"}}, + "no model": {"t/owner": &ir.Union{ID: "t/owner"}, "t/ghost": &ir.Scalar{ID: "t/ghost"}}, + "owner missing": {"t/ghost": &ir.Model{ID: "t/ghost", Discriminator: &ir.Discriminator{Mapping: map[string]ir.TypeID{"cat": "t/cat"}}}}, } { t.Run(name, func(t *testing.T) { t.Parallel() - d := besideUnionDiscriminator{c: lowering.Ctx{}, pointer: "/components/schemas/Ghost", model: "t/ghost"} + d := besideUnionDiscriminator{c: lowering.Ctx{}, pointer: "/components/schemas/Ghost", owner: "t/owner", model: "t/ghost"} diags := d.Settle(reg) diff --git a/compilers/openapi/internal/schema/discriminated_test.go b/compilers/openapi/internal/schema/discriminated_test.go index 665b79f6..be799efb 100644 --- a/compilers/openapi/internal/schema/discriminated_test.go +++ b/compilers/openapi/internal/schema/discriminated_test.go @@ -211,7 +211,7 @@ func TestDiscriminatorBesideUnion_ReportsTheMoveOnce(t *testing.T) { for _, src := range []string{pet + cat + dog, cat + dog + pet} { _, diags := parseFull(t, openapitest.ComponentSpec(src)) assert.Contains(t, openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, movedPointer), - "not a subtype of this model") + "not a subtype of the model holding it") } _, diags := parseFull(t, openapitest.ComponentSpec(pet+subtypes)) assert.False(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, movedPointer)) @@ -264,6 +264,50 @@ func TestDiscriminatorBesideUnion_AllOfBodyIsSettled(t *testing.T) { } } +// TestDiscriminatorBesideUnion_MultiTypedBodyIsSettled pins the other model a +// discriminator beside a kept union can sit on: a multi-typed body lowers to a +// union whose object variant lowerUnion builds from the same schema, so the +// variant holds it. Routed to a non-subtype, it moves to the node keeping the +// union; with no route at all, it stays on the variant. +func TestDiscriminatorBesideUnion_MultiTypedBodyIsSettled(t *testing.T) { + t.Parallel() + const variant = ir.TypeID("t/anon/components/schemas/Pet/type/0") + multi := func(discriminator, branches string) string { + return " Pet:\n type: [object, string]\n properties: {kind: {type: string}}\n" + + " discriminator: " + discriminator + "\n" + branches + } + cases := []struct { + name, pet string + moves bool + }{ + {"mapped to a non-subtype", multi("{propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}", petBranches), true}, + {"no route", multi("{propertyName: kind}", " oneOf: [{type: object, properties: {kind: {type: string}}}, {type: string}]\n"), false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + for _, doc := range discriminatedOrders(t, "3.1.0", c.pet, cat+dog) { + owner, ok := doc.Types[componentID("Pet")].(*ir.Union) + require.True(t, ok, "a multi-typed body lowers to a union") + assert.Contains(t, owner.Unmodeled, "openapi:oneOf", "the union is kept on it") + held, ok := doc.Types[variant].(*ir.Model) + require.True(t, ok, "its object variant is a model") + assert.NotContains(t, held.Unmodeled, "openapi:discriminator", "the variant never keeps it verbatim") + if !c.moves { + assert.NotNil(t, held.Discriminator, "nothing routes anywhere, so it stays") + assert.NotContains(t, owner.Unmodeled, "openapi:discriminator") + continue + } + assert.Nil(t, held.Discriminator) + entry, ok := owner.Unmodeled["openapi:discriminator"] + require.True(t, ok, "it is kept beside the union it was written for") + assert.Equal(t, ir.Provenance{Pointer: movedPointer}, entry.Provenance) + assert.Contains(t, string(entry.Value), `"mapping"`) + } + }) + } +} + // TestDiscriminatorBesideUnion_UnreachableBranchesAreNoRoute pins that a // branch the discriminator cannot route to by name decides nothing, whichever // way it is unreachable: a $ref to an undeclared component, or one into diff --git a/docs/ir-design.md b/docs/ir-design.md index 85750553..2dd03ada 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -955,14 +955,16 @@ from it is exact and nothing is reported. `oneOf` and `anyOf` are both declared, since `oneOf` wins. An inline branch, or one naming no component, is no route unless the `mapping` names it. Otherwise the whole object (`propertyName`, `mapping`, `defaultMapping`) is kept verbatim in - `Unmodeled["openapi:discriminator"]` beside the union, with an `info` diagnostic at the - discriminator; a subtype keeps the `DiscriminatorValue` its mapping key gives it, since that is - still the tag its instances carry. A union whose branches declare no shape (`validation_only`) - is held to the same rule. The compiler decides once every node is interned - (`compile.Settlement`), because neither the walk nor the source schemas can answer it: a - subtype declared after the `Model` has no `Base` while the `Model` lowers, and a schema whose - `allOf` names the `Model` may still lower to no subtype — an `enum`, a `const`, an alias over a - `$ref` beside it, or a union its `allOf` is distributed across. + `Unmodeled["openapi:discriminator"]` on the node keeping the union, with an `info` diagnostic at + the discriminator; a subtype keeps the `DiscriminatorValue` its mapping key gives it, since that + is still the tag its instances carry. A union whose branches declare no shape + (`validation_only`) is held to the same rule, and so is a multi-typed body, which lowers to a + `Union` whose object variant, built from the same schema, holds the discriminator. The compiler + decides once every node is interned (`compile.Settlement`), because neither the walk nor the + source schemas can answer it: a subtype declared after the `Model` has no `Base` while the + `Model` lowers, and a schema whose `allOf` names the `Model` may still lower to no subtype — an + `enum`, a `const`, an alias over a `$ref` beside it, or a union its `allOf` is distributed + across. In all five, whatever structural body the schema declares is lowered — `properties` or a declared `type` yields a Model or typed node — and the union branches are kept verbatim in diff --git a/testdata/conformance/openapi/discriminated-union-beside-model.golden.json b/testdata/conformance/openapi/discriminated-union-beside-model.golden.json index 3917a96c..f3dd3b85 100644 --- a/testdata/conformance/openapi/discriminated-union-beside-model.golden.json +++ b/testdata/conformance/openapi/discriminated-union-beside-model.golden.json @@ -49,6 +49,52 @@ } ], "types": { + "t/anon/components/schemas/Vehicle/type/0": { + "kind": "model", + "id": "t/anon/components/schemas/Vehicle/type/0", + "name": { + "hint": "vehicle" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Vehicle/type/0" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Vehicle/type/0/properties/kind", + "name": { + "source": "kind", + "canonical": "kind" + }, + "wireName": "kind", + "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/Vehicle/type/0/properties/kind" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, "t/composed/components/schemas/Bird/oneOf/0": { "kind": "model", "id": "t/composed/components/schemas/Bird/oneOf/0", @@ -189,6 +235,53 @@ "exclusive": true, "wireTagged": false }, + "t/openapi/components/schemas/Car": { + "kind": "model", + "id": "t/openapi/components/schemas/Car", + "name": { + "source": "Car", + "canonical": "car" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Car" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Car/properties/wheels", + "name": { + "source": "wheels", + "canonical": "wheels" + }, + "wireName": "wheels", + "type": { + "target": "t/prim/integer", + "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/Car/properties/wheels" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, "t/openapi/components/schemas/Cat": { "kind": "model", "id": "t/openapi/components/schemas/Cat", @@ -625,6 +718,72 @@ "discriminatorValue": "Square", "inputOnly": false }, + "t/openapi/components/schemas/Vehicle": { + "kind": "union", + "id": "t/openapi/components/schemas/Vehicle", + "name": { + "source": "Vehicle", + "canonical": "vehicle" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:discriminator": { + "reason": "degraded_lowering", + "value": { + "mapping": { + "car": "#/components/schemas/Car" + }, + "propertyName": "kind" + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Vehicle/discriminator" + } + }, + "openapi:oneOf": { + "reason": "degraded_lowering", + "value": [ + { + "$ref": "#/components/schemas/Car" + } + ], + "provenance": { + "source": 0, + "pointer": "/components/schemas/Vehicle/oneOf" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Vehicle" + }, + "variants": [ + { + "name": { + "hint": "object" + }, + "type": { + "target": "t/anon/components/schemas/Vehicle/type/0", + "nullable": false + }, + "docs": {} + }, + { + "name": { + "hint": "string" + }, + "type": { + "target": "t/prim/string", + "nullable": false + }, + "docs": {} + } + ], + "exclusive": true, + "wireTagged": false + }, "t/prim/bool": { "kind": "primitive", "id": "t/prim/bool", @@ -637,6 +796,18 @@ }, "prim": "bool" }, + "t/prim/integer": { + "kind": "primitive", + "id": "t/prim/integer", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "integer" + }, "t/prim/number": { "kind": "primitive", "id": "t/prim/number", @@ -711,7 +882,16 @@ { "severity": "info", "code": "openapi/degraded-construct", - "message": "the discriminator routes to a type that is not a subtype of this model, so it cannot stay on the model; kept verbatim under Unmodeled beside the union it was written for", + "message": "oneOf/anyOf co-declared with structural keywords intersects with them, and the body is not a model, so it carries no composition to distribute into; union branches kept verbatim under Unmodeled", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Vehicle" + } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "the discriminator routes to a type that is not a subtype of the model holding it, so it cannot stay there; kept verbatim under Unmodeled beside the union it was written for", "provenance": { "source": 0, "pointer": "/components/schemas/Pet/discriminator" @@ -720,18 +900,27 @@ { "severity": "info", "code": "openapi/degraded-construct", - "message": "the discriminator routes to a type that is not a subtype of this model, so it cannot stay on the model; kept verbatim under Unmodeled beside the union it was written for", + "message": "the discriminator routes to a type that is not a subtype of the model holding it, so it cannot stay there; kept verbatim under Unmodeled beside the union it was written for", "provenance": { "source": 0, "pointer": "/components/schemas/Animal/discriminator" } + }, + { + "severity": "info", + "code": "openapi/degraded-construct", + "message": "the discriminator routes to a type that is not a subtype of the model holding it, so it cannot stay there; kept verbatim under Unmodeled beside the union it was written for", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Vehicle/discriminator" + } } ], "sources": [ { "format": "openapi@3.1", "path": "discriminated-union-beside-model.yaml", - "hash": "c9d514bca7e855ca2b36608c9510dc2f404fdd3e4ac643e32d73e50f519d5f96" + "hash": "8c05073b0a8e6b6b0228065b686a7597f365d83e0dc21692dbfbd73f55732408" } ] } diff --git a/testdata/conformance/openapi/discriminated-union-beside-model.yaml b/testdata/conformance/openapi/discriminated-union-beside-model.yaml index 8061008c..381685c6 100644 --- a/testdata/conformance/openapi/discriminated-union-beside-model.yaml +++ b/testdata/conformance/openapi/discriminated-union-beside-model.yaml @@ -6,7 +6,8 @@ paths: {} components: schemas: # The branches are not subtypes of Pet, so a model discriminator mapping to - # them is one no emitter could route: it stays beside the kept union. + # them is one no emitter could route: it is kept verbatim beside the union + # rather than on the model. Pet: type: object discriminator: @@ -75,3 +76,21 @@ components: type: object properties: caw: {type: string} + # A multi-typed body lowers to a union with one variant per type, and its + # object variant holds the discriminator. No schema can name that variant, + # so Car is no subtype of it: the discriminator is kept verbatim on Vehicle, + # beside the union. + Vehicle: + type: [object, string] + properties: + kind: {type: string} + discriminator: + propertyName: kind + mapping: + car: '#/components/schemas/Car' + oneOf: + - $ref: '#/components/schemas/Car' + Car: + type: object + properties: + wheels: {type: integer} From 53f5cedeb676a0edc65c372066f07383bcf38408 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 13 Oct 2026 15:49:15 +0300 Subject: [PATCH 09/13] fix(compilers/openapi): match a null-only union's census to {type: null} A null-only branch set lowers the way a bare `{type: null}` schema does: an alias hoisted at the position over the shared nullable `any` primitive. Its census reported a keyword it could not hold as sitting beside "a node of kind scalar", the alias, while the same keyword on a bare `{type: null}` is reported beside "a node of kind primitive", the node the position lowered to. One IR shape was described two ways, and "scalar" suggested a type the source never declared. The null-only arm now names the primitive, as lower()'s census does, and a test holds the two reports to the same words. --- compilers/openapi/internal/schema/compose.go | 5 ++++- .../schema/unhomed_discriminator_test.go | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/compilers/openapi/internal/schema/compose.go b/compilers/openapi/internal/schema/compose.go index 59a430b7..eb8b58bd 100644 --- a/compilers/openapi/internal/schema/compose.go +++ b/compilers/openapi/internal/schema/compose.go @@ -581,13 +581,16 @@ func lowerNullOnlyUnion(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, point // alias over it, neither of which carries a branch set of its own to hold // them in. It also runs the census lower() runs for a body, which this arm // never reaches, so a discriminator written beside the branches is kept too. +// Its report names the shared primitive as what the position lowered to, as +// that census does for a bare `{type: null}`, not the alias keeping the +// keywords. func preserveNullOnlyUnion(c lowering.Ctx, ts *compile.Types, id ir.TypeID, s *oas3.Schema, pointer jsontext.Pointer) []ir.Diagnostic { td, ok, diags := registeredNode(c, ts, id, pointer) if !ok { return diags } common := td.Common() - diags = append(diags, recordUnhomedAt(c, &common.Unmodeled, s, unhomedKeywords(s, td, nil), nodeShape(td.Kind()), pointer)...) + diags = append(diags, recordUnhomedAt(c, &common.Unmodeled, s, unhomedKeywords(s, td, nil), nodeShape(ir.KindPrimitive), pointer)...) kept, keepDiags := preserveBranchSets(c, &common.Unmodeled, s, ir.ReasonDegradedLowering, pointer) diags = append(diags, keepDiags...) if len(kept) == 0 { diff --git a/compilers/openapi/internal/schema/unhomed_discriminator_test.go b/compilers/openapi/internal/schema/unhomed_discriminator_test.go index eedc38ac..c8187e1c 100644 --- a/compilers/openapi/internal/schema/unhomed_discriminator_test.go +++ b/compilers/openapi/internal/schema/unhomed_discriminator_test.go @@ -235,6 +235,25 @@ func TestUnplacedDiscriminator_IsKeptAtEveryUnionSite(t *testing.T) { "one entry per position, none duplicated") } +// TestNullOnlyUnion_ReportsItsCensusAsABareNullSchemaDoes pins that a null-only +// branch set, which lowers as a bare `{type: null}` schema does, names what a +// keyword it cannot hold sits beside in the same words: the node the position +// lowered to, not the alias holding the keyword. +func TestNullOnlyUnion_ReportsItsCensusAsABareNullSchemaDoes(t *testing.T) { + t.Parallel() + _, diags := parseFull(t, openapitest.ComponentSpec( + " NullOnly: {oneOf: [{type: 'null'}], format: uuid}\n NullTyped: {type: 'null', format: uuid}\n")) + openapitest.RequireNoErrorDiags(t, diags) + census := func(name string) string { + i := slices.IndexFunc(diags, func(d ir.Diagnostic) bool { + return string(d.Provenance.Pointer) == "/components/schemas/"+name && strings.Contains(d.Message, "which has no home for") + }) + require.GreaterOrEqual(t, i, 0, "%s reports what it kept", name) + return diags[i].Message + } + assert.Equal(t, census("NullTyped"), census("NullOnly")) +} + // homelessSpec declares a discriminator beside no union at all, at each kind // of position whose node has no field for it: a scalar, an enum, an array, a // bare schema, a $ref as a component, a property, a parameter and an allOf From 91e932991f7ccbaeeb0b81b3785471d672977359 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 13 Oct 2026 15:49:38 +0300 Subject: [PATCH 10/13] docs(compilers): correct census and settlement docs, tighten tests The collapsed-union census reuses two pieces of the $ref-site census, and their docs still described only a $ref site: keywordHome's nil td, which a collapsed union now passes too, and hoistDeclarationHome's census verdict, which the collapse now supplies. typeShapedBy and the discriminatorHome test named the nil td the same way. Settle's doc said a settlement that adds or removes a node is refused. The check compares the registry's size, so it refuses a net addition or removal, and a swap of one node for another passes it; the doc now says so and names the checks that report what a swap leaves dangling. It also said Settle stops between two settlements, though it checks the context before each one, the first included. lower() skips the census for a multi-typed schema on the reasoning that every keyword has a home in some variant. It does not when no declared type's variant has the field, so the comment there and ir-design's census bullet now name that drop (#852). The settlement tests now compare the reported diagnostics across declaration orders in the unreachable-branch case as the other tables do, read the reference-built-first report through the same exact-pointer helper as the report-once test, and describe the degraded shapes without calling one of them "the discriminated one", since every shape there declares a discriminator. --- compilers/compile/settle.go | 10 ++++++---- .../internal/schema/discriminated_test.go | 17 ++++++++++------- compilers/openapi/internal/schema/schema.go | 19 +++++++++++-------- .../internal/schema/schema_internal_test.go | 5 +++-- docs/ir-design.md | 5 +++-- 5 files changed, 33 insertions(+), 23 deletions(-) diff --git a/compilers/compile/settle.go b/compilers/compile/settle.go index 76fdd49a..6230f479 100644 --- a/compilers/compile/settle.go +++ b/compilers/compile/settle.go @@ -35,10 +35,12 @@ func (t *Types) Defer(s Settlement) { // Settle takes every deferred Settlement once, in the order deferred, and // returns their diagnostics. A compiler calls it after its last lowering -// returns. Like the walks, it stops between two settlements once ctx is done, -// leaving a registry the caller must not assemble. A settlement that adds or -// removes a node is refused, and what it did is left standing: the refusal -// names a compiler bug, not a recovery. +// returns. Like the walks, it checks ctx before each settlement and stops once +// it is done, leaving a registry the caller must not assemble. A settlement +// that changes how many nodes the registry holds is refused, and what it did is +// left standing: the refusal names a compiler bug, not a recovery. A swap of +// one node for another keeps the count, so the references it leaves dangling +// are left to pass.Validate and irverify. func (t *Types) Settle(ctx context.Context) []ir.Diagnostic { t.settled = true pending := t.pending diff --git a/compilers/openapi/internal/schema/discriminated_test.go b/compilers/openapi/internal/schema/discriminated_test.go index be799efb..93093017 100644 --- a/compilers/openapi/internal/schema/discriminated_test.go +++ b/compilers/openapi/internal/schema/discriminated_test.go @@ -217,9 +217,9 @@ func TestDiscriminatorBesideUnion_ReportsTheMoveOnce(t *testing.T) { assert.False(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, movedPointer)) } -// degradedShapes are the model-bodied unions kept verbatim beside a -// discriminator other than the discriminated one, each with the branch text -// that makes it that shape. Each goes through lowerBesideUnmodeledUnion. +// degradedShapes are the cases besides the discriminator itself that keep a +// union verbatim beside a model, each with the branch text that makes the +// union that case. Each goes through lowerBesideUnmodeledUnion. var degradedShapes = []struct { name string branches string @@ -229,14 +229,16 @@ var degradedShapes = []struct { {"validation-only branches", " oneOf: [{required: [kind]}, {required: [x]}]\n"}, } +// degradedPet declares Pet, a model whose discriminator maps cat to Cat, +// beside the branches given. func degradedPet(branches string) string { return " Pet:\n type: object\n" + " discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}\n" + branches } // TestDiscriminatorBesideUnion_EveryDegradedShapeIsSettled pins that the rule -// holds on every union kept verbatim beside a model, not only the -// discriminated one, both ways. +// holds on every union kept verbatim beside a model, not only one its +// discriminator alone degrades, both ways. func TestDiscriminatorBesideUnion_EveryDegradedShapeIsSettled(t *testing.T) { t.Parallel() for _, shape := range degradedShapes { @@ -341,6 +343,7 @@ func TestDiscriminatorBesideUnion_UnreachableBranchesAreNoRoute(t *testing.T) { assertMoved(t, doc, `"mapping"`) } assert.Empty(t, cmp.Diff(docs[0].Types, docs[1].Types, orderInvariantIR()...)) + assert.Equal(t, reported(docs[0].Diagnostics), reported(docs[1].Diagnostics)) }) } } @@ -418,7 +421,7 @@ func TestDiscriminatorBesideUnion_SettlesAPositionAReferenceBuiltFirst(t *testin require.True(t, ok) assert.Nil(t, pet.Discriminator) assert.Contains(t, pet.Unmodeled, "openapi:discriminator") - assert.Equal(t, 1, strings.Count(fmt.Sprint(reported(doc.Diagnostics)), at+"/discriminator"), - "one move, reported once") + assert.Contains(t, openapitest.DiagMessageAt(t, doc.Diagnostics, diag.DegradedConstruct, ir.SeverityInfo, at+"/discriminator"), + "not a subtype", "one move, reported once") } } diff --git a/compilers/openapi/internal/schema/schema.go b/compilers/openapi/internal/schema/schema.go index 9c18ffde..e373737c 100644 --- a/compilers/openapi/internal/schema/schema.go +++ b/compilers/openapi/internal/schema/schema.go @@ -223,7 +223,7 @@ func schemaBody(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth i // body's target would make annotations depend on declaration order (ir-design // §4.3). // -// unhomed is the caller's census verdict for a $ref site's keywords, which +// unhomed is the census verdict of a $ref site or a collapsed union, which // declaresPositionScoped cannot judge. func hoistDeclarationHome(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, ref ir.TypeRef, pointer jsontext.Pointer, hint string, home annotation.Home, unhomed bool) (ir.TypeRef, []ir.Diagnostic) { if home != annotation.HomeOwnNode || s == nil { @@ -634,7 +634,9 @@ func lower(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s // this same schema, so an applicator with no home in one variant has one // in another: `{type: [string, object], properties: {...}}` puts the // property set on the object variant. The homes here are the variants', - // not the Union's, so the applicator check does not apply. + // not the Union's, so the applicator check does not apply. A keyword no + // declared type's variant holds, such as a discriminator with no object + // variant, is dropped (GitHub #852). return lowerUnion(c, ts, anchors, depth, s, pointer, hint, types) case len(types) == 1: return unhomed(lowerTyped(c, ts, anchors, depth, s, pointer, hint, types[0])) @@ -666,11 +668,12 @@ var censusKeywords = []string{ // rather than re-deriving lower()'s dispatch, so the two cannot drift apart // (see recordUnplacedContent). // -// A nil td is a $ref site, whose declaration becomes an alias over the target. -// The alias carries only the position's constraints and annotations, and the -// target's fields belong to the referent's declaration, so a `format` beside a -// $ref must not read the referent's Encoding as its home. So every keyword is -// homeless at a $ref site except a null-only `type`. +// A nil td is a position lowered to a reference: a $ref site, or a collapsed +// union (collapsedUnhomedKeywords). Its alias carries only the position's +// constraints and annotations, and the target's fields belong to the +// referent's declaration, so a `format` beside a $ref must not read the +// referent's Encoding as its home. So every keyword is homeless there except +// a null-only `type`. func keywordHome(td ir.TypeDef, s *oas3.Schema, keyword string) bool { switch keyword { case "properties", "patternProperties", "additionalProperties", "required": @@ -771,7 +774,7 @@ func typeHome(td ir.TypeDef, s *oas3.Schema) bool { // exactly that type set. A Literal carries only its value, so a `type` written // beside a const reached no field at all — including the case where the two // agree, since the IR then holds the value and nothing about what was declared -// about it. Any, External and the $ref site's nil carry no shape. +// about it. Any, External and keywordHome's nil td carry no shape. func typeShapedBy(td ir.TypeDef, st oas3.SchemaType) bool { switch td.(type) { case *ir.Model: diff --git a/compilers/openapi/internal/schema/schema_internal_test.go b/compilers/openapi/internal/schema/schema_internal_test.go index fdd93c31..0fc66ef0 100644 --- a/compilers/openapi/internal/schema/schema_internal_test.go +++ b/compilers/openapi/internal/schema/schema_internal_test.go @@ -817,7 +817,8 @@ func TestKeywordHome_EveryCensusKeywordHasANodeThatCarriesIt(t *testing.T) { // TestDiscriminatorHome_AsksWhetherTheFieldWasFilled pins what homes a // discriminator: a Model or Union whose lowering filled the field. Having the // field is not enough, since the Union a mixed-type enum lowers to holds none, -// and no other kind, nor a $ref site's nil node, has the field at all. +// and no other kind, nor the nil td of a position lowered to a reference, has +// the field at all. func TestDiscriminatorHome_AsksWhetherTheFieldWasFilled(t *testing.T) { t.Parallel() filled := &ir.Discriminator{PropertyName: "kind"} @@ -832,7 +833,7 @@ func TestDiscriminatorHome_AsksWhetherTheFieldWasFilled(t *testing.T) { {"union holding none", &ir.Union{}, false}, {"scalar", &ir.Scalar{}, false}, {"enum", &ir.Enum{}, false}, - {"ref site", nil, false}, + {"reference", nil, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/docs/ir-design.md b/docs/ir-design.md index 2dd03ada..2d76bb22 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1086,8 +1086,9 @@ from it is exact and nothing is reported. with a type rather than by a node kind. `discriminator` joins the list too, homed by a `Model` or `Union` that routes by it and by nothing else. Two limits are deliberate: a multi-typed schema (`type: [string, object]`) is exempt, because it lowers one variant per declared type from the - same schema and the homes are the variants'; and a `$ref` position is not covered, since its - siblings bind the position rather than the referent and are handled at §4.3's use-site rule. + same schema and the homes are the variants' — though a keyword no variant holds is then dropped + (#852); and a `$ref` position is not covered, since its siblings bind the position rather than + the referent and are handled at §4.3's use-site rule. - **JSON Schema open tuples** (`prefixItems` with a trailing `items`) — a fixed positional head plus a homogeneous tail of unbounded length. **The IR** has `Tuple` (fixed arity) and `List` (homogeneous, one element type) and no node that is both; target languages are not the From 6133d2ded184caaa978420bc5c3e89d6a8f56acd Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 13 Oct 2026 16:25:39 +0300 Subject: [PATCH 11/13] fix(compilers/openapi): move a discriminator only once it is copied The settlement that takes a discriminator off a model beside a kept union copied it verbatim through PreserveSchemaKeyword but ignored whether the copy was made. A discriminator merged in through a YAML `<<` is read by the schema model, so the model held it, yet the raw-node reader the copy goes through misses merge keys (#395). The field was cleared, nothing was stored, and an info diagnostic announced a verbatim copy that did not exist: the discriminator vanished where it had been kept on the model before. Settle now copies first and moves only what it copied, as every other preservation site announces only what it kept (#144). A discriminator it cannot copy stays on the model, where pass/validate reports the routes it fails. --- .../openapi/internal/schema/discriminated.go | 11 +++++++++-- .../internal/schema/discriminated_test.go | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/compilers/openapi/internal/schema/discriminated.go b/compilers/openapi/internal/schema/discriminated.go index 1e93fd56..6aabed0e 100644 --- a/compilers/openapi/internal/schema/discriminated.go +++ b/compilers/openapi/internal/schema/discriminated.go @@ -89,6 +89,10 @@ type besideUnionDiscriminator struct { // Settle keeps the model's discriminator or moves it. A model holding none is // left alone: it declared none, or it was settled already, by the settlement a // reference deferred when it built the model before its declaration did. +// +// It moves only what it could copy (GitHub #144). One with no raw node to copy, +// merged in through a `<<` the raw readers miss (GitHub #395), stays on the +// model rather than vanish, where pass/validate reports the routes it fails. func (d besideUnionDiscriminator) Settle(reg ir.TypeRegistry) []ir.Diagnostic { m, isModel := reg[d.model].(*ir.Model) owner := reg[d.owner] @@ -100,9 +104,12 @@ func (d besideUnionDiscriminator) Settle(reg ir.TypeRegistry) []ir.Diagnostic { if m.Discriminator == nil || routesToSubtypes(reg, m, d.named) { return nil } - m.Discriminator = nil at := d.pointer + ids.Ptr("discriminator") - _, diags := PreserveSchemaKeyword(d.c, &owner.Common().Unmodeled, d.s, "discriminator", ir.ReasonDegradedLowering, at) + kept, diags := PreserveSchemaKeyword(d.c, &owner.Common().Unmodeled, d.s, "discriminator", ir.ReasonDegradedLowering, at) + if !kept { + return diags + } + m.Discriminator = nil return append(diags, d.c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, at, "the discriminator routes to a type that is not a subtype of the model holding it, so it cannot "+ "stay there; kept verbatim under Unmodeled beside the union it was written for")) diff --git a/compilers/openapi/internal/schema/discriminated_test.go b/compilers/openapi/internal/schema/discriminated_test.go index 93093017..db96d3e3 100644 --- a/compilers/openapi/internal/schema/discriminated_test.go +++ b/compilers/openapi/internal/schema/discriminated_test.go @@ -310,6 +310,25 @@ func TestDiscriminatorBesideUnion_MultiTypedBodyIsSettled(t *testing.T) { } } +// TestDiscriminatorBesideUnion_MovesOnlyWhatItCopied pins that the move never +// loses a discriminator it could not keep verbatim: one merged in through a +// `<<`, which the model reads but the raw readers miss (GitHub #395), stays on +// the model, and nothing claims a copy that was never made. +func TestDiscriminatorBesideUnion_MovesOnlyWhatItCopied(t *testing.T) { + t.Parallel() + spec := openapitest.ComponentSpec(cat + dog + + " Shared: &shared\n discriminator: {propertyName: kind, mapping: {cat: '#/components/schemas/Cat'}}\n" + + " Pet:\n <<: *shared\n type: object\n" + petBranches) + doc, diags := parseFull(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + + pet := petModel(t, doc) + require.NotNil(t, pet.Discriminator, "the model read it, so it is not lost") + assert.Equal(t, "kind", pet.Discriminator.PropertyName) + assert.NotContains(t, pet.Unmodeled, "openapi:discriminator") + assert.False(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, movedPointer), "no move is announced") +} + // TestDiscriminatorBesideUnion_UnreachableBranchesAreNoRoute pins that a // branch the discriminator cannot route to by name decides nothing, whichever // way it is unreachable: a $ref to an undeclared component, or one into From 8d3ce55997ceab40026aaa68b08b7dc76a21cfc1 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 13 Oct 2026 16:25:39 +0300 Subject: [PATCH 12/13] docs(compilers/openapi): name the multi-typed census gap in each claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ir-design §4.8 said a discriminator beside a union with a scalar body is kept verbatim, and its §14 row said the same of one beside any node that does not route by it. deferDiscriminatorBesideUnion and the homeless-discriminator test said the census keeps it wherever the settlement does not. A multi-typed body with no object variant, such as `type: [string, integer]`, runs no census, so it drops the discriminator in silence (#852). Each claim now names that exception. --- compilers/openapi/internal/schema/discriminated.go | 3 ++- .../internal/schema/unhomed_discriminator_test.go | 3 ++- docs/ir-design.md | 11 ++++++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/compilers/openapi/internal/schema/discriminated.go b/compilers/openapi/internal/schema/discriminated.go index 6aabed0e..4ed3ba05 100644 --- a/compilers/openapi/internal/schema/discriminated.go +++ b/compilers/openapi/internal/schema/discriminated.go @@ -17,7 +17,8 @@ import ( // deferDiscriminatorBesideUnion queues a settlement for each model that holds // the discriminator written beside the union lowerBesideUnmodeledUnion kept // verbatim on owner (discriminatorModels). A body that lowered to anything else -// holds none: the census kept its discriminator where it kept the rest. +// holds none: the census kept its discriminator where it kept the rest, except +// on a multi-typed body with no object variant, which runs none (GitHub #852). func deferDiscriminatorBesideUnion(c lowering.Ctx, ts *compile.Types, owner ir.TypeID, s *oas3.Schema, pointer jsontext.Pointer) { named := namedBranches(c, s) for _, model := range discriminatorModels(ts, owner) { diff --git a/compilers/openapi/internal/schema/unhomed_discriminator_test.go b/compilers/openapi/internal/schema/unhomed_discriminator_test.go index c8187e1c..fe99a985 100644 --- a/compilers/openapi/internal/schema/unhomed_discriminator_test.go +++ b/compilers/openapi/internal/schema/unhomed_discriminator_test.go @@ -291,7 +291,8 @@ components: // TestHomelessDiscriminator_IsKeptBesideEveryOtherNode pins that a // discriminator is a census keyword: wherever the position lowered to a node // that does not route by it, the census keeps it verbatim and says so, as it -// keeps any keyword the node has no field for. Each was dropped in silence. +// keeps any keyword the node has no field for. Each was dropped in silence. A +// multi-typed schema runs no census yet (GitHub #852). func TestHomelessDiscriminator_IsKeptBesideEveryOtherNode(t *testing.T) { t.Parallel() doc, diags := parseFull(t, homelessSpec) diff --git a/docs/ir-design.md b/docs/ir-design.md index 2d76bb22..17d5c8b7 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -979,12 +979,13 @@ from it is exact and nothing is reported. validating emitter to reach. Where a union's lowering leaves no `Model` or `Union` to route by it — a union beside a - `const`, `enum` or scalar body, a null-only branch set, a `$ref` site (alias or carrier), or a - `{X, null}` union collapsed to a nullable reference (§3.3) — a `discriminator` beside it is kept - as any keyword its node cannot carry is: the whole object goes to + `const`, `enum` or single-typed scalar body, a null-only branch set, a `$ref` site (alias or + carrier), or a `{X, null}` union collapsed to a nullable reference (§3.3) — a `discriminator` + beside it is kept as any keyword its node cannot carry is: the whole object goes to `Unmodeled["openapi:discriminator"]` on the node or carrier that owns the position, with an `info` diagnostic. The collapse is held to a `$ref` site's rule (§4.3), since it too lowers to a - reference, so a `format` or collection bound beside it is kept the same way. + reference, so a `format` or collection bound beside it is kept the same way. A multi-typed body + with no `object` among its types runs no census, so it still drops one (#852). A union whose branches declare no shape at all (`oneOf: [{required: [a]}, {required: [b]}]`) is not a degradation and is not listed here: it narrows the body without reshaping it, which @@ -2235,7 +2236,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, 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 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; 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 | | **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 | From 253bd788a95ac613a1c4010319df4b1a9d0008bf Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 13 Oct 2026 16:46:53 +0300 Subject: [PATCH 13/13] =?UTF-8?q?docs:=20rewrap=20the=20settlement=20parag?= =?UTF-8?q?raph=20in=20architecture=20=C2=A72.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sentence on why the registry holds settlements left one line of §2.1 at 112 columns, past the 100 the rest of the file keeps. The words are unchanged. --- docs/architecture.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 3328f2d6..727d9b30 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,9 +94,9 @@ it, with the declaration of the namespaces a compiler mints in. The registry als decisions about a node that read nodes other lowerings intern (`compile.Settlement`), deferred until the walk ends: a walk lowers one declaration at a time, so taken mid-walk they would follow declaration order. They live on the registry, though only OpenAPI defers one yet, because the -registry is the one handle a lowering holds (micro-compiler-design §4). What stays with the compiler is what -only it can compute: the path an ID derives from, since a JSON Pointer, a GraphQL structural path -and a protobuf fully-qualified name are different things. +registry is the one handle a lowering holds (micro-compiler-design §4). What stays with the +compiler is what only it can compute: the path an ID derives from, since a JSON Pointer, a GraphQL +structural path and a protobuf fully-qualified name are different things. The split is enforced rather than documented: architecture tests fail a package outside the framework that writes the type registry, derives a canonical name, builds an ID out of a string, or