From ec5bf7853fefb6b346441a385a89545667727086 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 10 Nov 2026 23:13:28 +0300 Subject: [PATCH 01/11] fix(compilers/openapi)!: fold allOf branches beside a union An inline allOf branch writes what it says of the position, its docs, deprecation, examples, bounds and what a use binds, as if written beside the allOf, and folds there. Beside a oneOf, an anyOf or a $dynamicRef it folded nothing: the branch stayed whole under Unmodeled["openapi:allOf/"], on each composed variant where the union distributes and on the Model where the union or the $dynamicRef is kept verbatim. The same keywords written beside the allOf reached the Union, its carriers and each variant's constraints, so two spellings JSON Schema reads alike lowered apart, and a consumer found the description of a distributed schema on no typed field. allOfDecides inherited allOfConjoins' exclusion of a union and a $dynamicRef beside the allOf. Those exclusions belong to refinement, which keeps composing beside either, but the composition is still what the position lowers. allOfDecides now excludes only a $ref beside the allOf, which keeps the allOf verbatim, and the distributed variants read the branches' bounds as lowerAllOf does. readAllOf marks which branches fold for both composers. A variant is the conjunction, so what a branch says of the position is said of each variant; it now lands once, on the Union where Docs and a carrier already read it, and a bound on each variant, where one beside the allOf lands. Keeping the branch whole on each variant let any one variant rebuild the source, but copied one fact onto nodes no reader of the position looks at. docs/ir/models.md records the choice. BREAKING CHANGE: IRVersion moves to 0.38.0 with no change of shape. A consumer pinned to 0.37.0 that read such a branch under its Unmodeled key no longer finds it there. --- compilers/openapi/internal/schema/allof.go | 21 +- .../openapi/internal/schema/allof_disjoint.go | 9 - .../internal/schema/allof_residue_test.go | 8 +- .../openapi/internal/schema/nulls_test.go | 15 +- compilers/openapi/internal/schema/union.go | 9 +- compilers/openapi/internal/schema/voices.go | 26 +- .../openapi/internal/schema/voices_test.go | 169 ++++++----- docs/compilers/openapi.md | 15 +- docs/ir/models.md | 8 + docs/ir/version-history.md | 11 + ir/document.go | 2 +- ir/irversion_test.go | 3 + .../openapi/allof-inline-residue.golden.json | 280 +++++++++++++++++- .../openapi/allof-inline-residue.yaml | 15 + 14 files changed, 481 insertions(+), 110 deletions(-) diff --git a/compilers/openapi/internal/schema/allof.go b/compilers/openapi/internal/schema/allof.go index 6b952f7db..185c43ecd 100644 --- a/compilers/openapi/internal/schema/allof.go +++ b/compilers/openapi/internal/schema/allof.go @@ -41,10 +41,6 @@ func lowerAllOf(c lowering.Ctx, ts *compile.Types, depth int, s *oas3.Schema, po m := &ir.Model{TypeCommon: common, Constraints: cons} comp := readAllOf(c, s, pointer) // the one flattened list; the readers below take it, not s diags = append(diags, diagDisjointConjuncts(c, comp, composedShape)...) - if allOfDecides(c, s) { - v := voicingOf(c, s) - comp.speaks, comp.rebased = v.speaks, v.rebased - } fields := newModelFields(c, ts, m) diags = append(diags, fillAllOf(c, ts, depth, m, fields, comp)...) diags = append(diags, fillModelProperties(c, ts, depth, fields, s, pointer)...) @@ -167,8 +163,8 @@ type allOfComposition struct { disjoint []string // speaks marks each branch that folds at the position (voicesAmong), and // rebased each whose folding keywords write a relative $ref it reads against - // another base, so it does not. They are nil for a composition distributed - // across a union, whose variants each keep every branch. + // another base, so it does not. They are nil where the allOf is not what the + // position lowers (allOfDecides). speaks []bool rebased []bool } @@ -191,6 +187,19 @@ func allOfBranches(s *oas3.Schema, pointer jsontext.Pointer) allOfComposition { return allOfComposition{pointer: pointer, branches: out} } +// readAllOf is allOfBranches of the schema written at pointer, with what its +// conjuncts jointly admit read and, where the allOf is what the position lowers, +// which branches fold there (voicingOf), for a lowering that composes it. +func readAllOf(c lowering.Ctx, s *oas3.Schema, pointer jsontext.Pointer) allOfComposition { + comp := allOfBranches(s, pointer) + comp.disjoint = disjointConjuncts(c, s, comp) + if allOfDecides(c, s) { + v := voicingOf(c, s) + comp.speaks, comp.rebased = v.speaks, v.rebased + } + return comp +} + // allOfRefBranches yields the $ref branches of s's allOf after flattening, // schemas only: the view an ancestor walk needs, which reads no pointer. func allOfRefBranches(s *oas3.Schema) iter.Seq[*oas3.JSONSchema[oas3.Referenceable]] { diff --git a/compilers/openapi/internal/schema/allof_disjoint.go b/compilers/openapi/internal/schema/allof_disjoint.go index 7981b7b6a..4c1fdd018 100644 --- a/compilers/openapi/internal/schema/allof_disjoint.go +++ b/compilers/openapi/internal/schema/allof_disjoint.go @@ -4,7 +4,6 @@ package schema import ( - "encoding/json/jsontext" "strings" "github.com/speakeasy-api/openapi/jsonschema/oas3" @@ -231,11 +230,3 @@ func diagDisjointConjuncts(c lowering.Ctx, comp allOfComposition, shape string) // composedShape is the shape a composition lowers to, for diagDisjointConjuncts. const composedShape = "a composition" - -// readAllOf is allOfBranches of the schema written at pointer, with what its -// conjuncts jointly admit read, for a lowering that composes it. -func readAllOf(c lowering.Ctx, s *oas3.Schema, pointer jsontext.Pointer) allOfComposition { - comp := allOfBranches(s, pointer) - comp.disjoint = disjointConjuncts(c, s, comp) - return comp -} diff --git a/compilers/openapi/internal/schema/allof_residue_test.go b/compilers/openapi/internal/schema/allof_residue_test.go index 758f0f960..26f0df6d3 100644 --- a/compilers/openapi/internal/schema/allof_residue_test.go +++ b/compilers/openapi/internal/schema/allof_residue_test.go @@ -249,9 +249,9 @@ func TestAllOf_EachInlineBranchKeyedSeparately(t *testing.T) { // TestAllOf_BranchResidueRidesEveryDistributedVariant covers fillAllOf's second // caller: buildComposedVariant re-runs the same merge once per union variant, so -// the residue has to land on every variant the composition rides on. The -// diagnostic is announced once even so — the branch is one source construct -// however many variants carry it. +// the residue has to land on every variant the composition rides on, the whole +// branch with what folds at the Union. The diagnostic is announced once even so — +// the branch is one source construct however many variants carry it. func TestAllOf_BranchResidueRidesEveryDistributedVariant(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(` Base: {type: object, properties: {id: {type: string}}} @@ -263,6 +263,7 @@ func TestAllOf_BranchResidueRidesEveryDistributedVariant(t *testing.T) { - type: object properties: {c: {type: string}} description: BranchDoc + patternProperties: {'^x': {type: string}} oneOf: - {$ref: '#/components/schemas/A'} - {$ref: '#/components/schemas/B'} @@ -280,6 +281,7 @@ func TestAllOf_BranchResidueRidesEveryDistributedVariant(t *testing.T) { require.True(t, ok, "variant %d carries the branch residue; got %+v", i, variant.Unmodeled) assert.Contains(t, string(entry.Value), `"description":"BranchDoc"`, "variant %d", i) } + assert.Equal(t, "BranchDoc", u.Docs.Description) assert.Equal(t, 1, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), "one branch, one diagnostic, however many variants carry it; got %+v", diags) } diff --git a/compilers/openapi/internal/schema/nulls_test.go b/compilers/openapi/internal/schema/nulls_test.go index 5c98869eb..1a4befe8b 100644 --- a/compilers/openapi/internal/schema/nulls_test.go +++ b/compilers/openapi/internal/schema/nulls_test.go @@ -394,10 +394,17 @@ func TestNullableWidening_ABranchWidensTheAllOfHoldingIt(t *testing.T) { assert.Equal(t, "d", propertyOf(t, doc, "Holder", "p").Docs.Description, name) } - besideUnion := openapitest.HolderSpec("3.0.3", - "{oneOf: [{type: string}, {type: integer}], allOf: [{$ref: '#/components/schemas/Plain'}, {nullable: true}]}", "") - doc, _ = compileNullable(t, besideUnion, false) - assert.False(t, holderP(t, doc).Nullable, "nor does one a union conjoins with") + for name, tc := range map[string]struct{ union, code string }{ + "degraded": {"oneOf: [{type: string}, {type: integer}]", diag.DegradedConstruct}, + "distributed": {"oneOf: [{$ref: '#/components/schemas/Pet'}, {$ref: '#/components/schemas/Toy'}]", diag.CompositionLowering}, + } { + besideUnion := openapitest.HolderSpec("3.0.3", "{"+tc.union+", allOf: [{$ref: '#/components/schemas/Pet'}, {nullable: true}]}", + " Toy: {type: object, properties: {kind: {type: string}}}\n") + doc, diags = compileNullable(t, besideUnion, false) + require.True(t, openapitest.HasDiagCodeAt(diags, tc.code, "/components/schemas/Holder/properties/p"), name) + assert.True(t, holderP(t, doc).Nullable, "%s: so does one beside a union, as beside the allOf", name) + assert.NotEmpty(t, holderP(t, doc).Inferred, name) + } properties := openapitest.HolderSpec("3.0.3", "{allOf: [{$ref: '#/components/schemas/Pet'}, {nullable: true, properties: {a: {type: string}}}]}", "") diff --git a/compilers/openapi/internal/schema/union.go b/compilers/openapi/internal/schema/union.go index b4db1a237..688d4c0f8 100644 --- a/compilers/openapi/internal/schema/union.go +++ b/compilers/openapi/internal/schema/union.go @@ -693,11 +693,12 @@ func composedVariantRef(c lowering.Ctx, id ir.TypeID, body *oas3.Schema, branch // declared, named after the enclosing schema, not after whichever branch built // them first. // -// The enclosing schema's value constraints are read as lowerAllOf reads them -// (GitHub #407). Each variant carries a copy, since a bound on the schema -// constrains both sides of the disjunction. +// The enclosing schema's value constraints, its folding branches' among them, +// are read as lowerAllOf reads them (GitHub #407). Each variant carries a copy, +// since a bound on the schema constrains both sides of the disjunction. func buildComposedVariant(c lowering.Ctx, ts *compile.Types, depth int, body composedBody, branch ir.TypeID, common ir.TypeCommon) (ir.TypeDef, []ir.Diagnostic) { - cons, diags := ConstraintsOf(c, &common.Unmodeled, body.schema, nil, body.comp.pointer) + voices := positionVoices(c, ts, body.schema, body.comp.pointer, ir.TypeRef{}) + cons, diags := ConstraintsOf(c, &common.Unmodeled, body.schema, voices, body.comp.pointer) m := &ir.Model{TypeCommon: common, Constraints: cons} fields := newModelFields(c, ts, m) diags = append(diags, fillAllOf(c, ts, depth, m, fields, body.comp)...) diff --git a/compilers/openapi/internal/schema/voices.go b/compilers/openapi/internal/schema/voices.go index 6752c917e..0e2bbb6c9 100644 --- a/compilers/openapi/internal/schema/voices.go +++ b/compilers/openapi/internal/schema/voices.go @@ -88,21 +88,29 @@ func foldValue(s *oas3.Schema, entry string) foldRendering { return r } -// allOfConjoins reports whether s's allOf conjoins with what its position -// lowers: it is no keyword beside a $ref, a $dynamicRef or a union, whose -// branches keep what they write on each variant -// (docs/compilers/openapi.md#union-beside-structure). +// allOfBesideNoRef reports whether s writes an allOf with no $ref beside it, +// which would make the position a $ref site, whose alias keeps the allOf +// verbatim (docs/compilers/openapi.md#allof-beside-ref). +func allOfBesideNoRef(s *oas3.Schema) bool { + return len(s.GetAllOf()) > 0 && s.Ref == nil +} + +// allOfConjoins reports whether s's allOf may refine: no $ref, union or +// $dynamicRef stands beside it, the last two keeping it the composition it would +// otherwise be (docs/compilers/openapi.md#allof-refinement). func allOfConjoins(s *oas3.Schema) bool { - return len(s.GetAllOf()) > 0 && s.Ref == nil && !declaresUnion(s) && !declaresDynamicRef(s) + return allOfBesideNoRef(s) && !declaresUnion(s) && !declaresDynamicRef(s) } // allOfDecides reports whether s's allOf is what its position lowers: one the // election of competing keywords made the value, or one refining, whose rival -// there, an enum or a const beside it, is one of its conjuncts. An allOf that -// value set beats otherwise stays verbatim -// (docs/compilers/openapi.md#competing-keywords). +// there, an enum or a const beside it, is one of its conjuncts. A union beside +// the composing allOf leaves it deciding, composed into each variant the union +// distributes across or into the Model keeping the union verbatim, and so does +// a $dynamicRef it keeps from expanding. An allOf that value set beats +// otherwise stays verbatim (docs/compilers/openapi.md#competing-keywords). func allOfDecides(c lowering.Ctx, s *oas3.Schema) bool { - if !allOfConjoins(s) { + if !allOfBesideNoRef(s) { return false } if dispatchOf(s).won == "allOf" { diff --git a/compilers/openapi/internal/schema/voices_test.go b/compilers/openapi/internal/schema/voices_test.go index de3ff6372..6702cd377 100644 --- a/compilers/openapi/internal/schema/voices_test.go +++ b/compilers/openapi/internal/schema/voices_test.go @@ -4,6 +4,7 @@ package schema_test import ( + "slices" "testing" "github.com/stretchr/testify/assert" @@ -15,15 +16,33 @@ import ( "github.com/dexpace/morphic/ir" ) -// voicesDoc is a document of Base, an object, and a component S written as written. +// voicesDoc is a document of Base, A and B, objects, Meta, an object anchored +// dynamically, and a component S written as written. func voicesDoc(written string) string { return openapitest.ComponentSpecVer("3.1.0", " Base: {type: object, properties: {id: {type: string}}}\n"+ + " A: {type: object, properties: {a: {type: string}}}\n"+ + " B: {type: object, properties: {b: {type: string}}}\n"+ + " Meta: {$dynamicAnchor: meta, type: object}\n"+ " S: "+written+"\n") } +// besideTheAllOf is what S writes beside its allOf for each outcome the allOf +// composes at: nothing, a union it distributes across, one kept beside its Model +// for an inline branch, one narrowing it alone +// (docs/compilers/openapi.md#union-beside-structure), and a $dynamicRef it keeps +// from expanding (docs/compilers/openapi.md#dynamic-ref). +var besideTheAllOf = map[string]string{ + "alone": "", + "distributed": ", oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}]", + "beside a degraded union": ", oneOf: [{$ref: '#/components/schemas/A'}, {type: object, properties: {z: {type: string}}}]", + "beside a narrowing union": ", oneOf: [{required: [a]}, {required: [b]}]", + "beside a $dynamicRef": ", $dynamicRef: '#meta'", +} + // TestVoices_AKeywordFoldsWhereTheAllOfWouldKeepIt holds a keyword on an inline -// branch to the same keyword beside the allOf: for each one the two lower to the -// same node, the branch's kept nowhere else and reported nowhere. +// branch to the same keyword beside the allOf, beside each union outcome: for each +// one the two lower to the same nodes, the branch's kept nowhere else and +// reported nowhere but where it is written. func TestVoices_AKeywordFoldsWhereTheAllOfWouldKeepIt(t *testing.T) { t.Parallel() for name, keyword := range map[string]string{ @@ -48,32 +67,72 @@ func TestVoices_AKeywordFoldsWhereTheAllOfWouldKeepIt(t *testing.T) { "propertyNames": "propertyNames: {pattern: '^a'}", "a redaction": "format: password", } { - t.Run(name, func(t *testing.T) { - t.Parallel() - ref := "{$ref: '#/components/schemas/Base'}" - beside, besideDiags := parseFull(t, voicesDoc("{allOf: ["+ref+"], "+keyword+"}")) - branch, branchDiags := parseFull(t, voicesDoc("{allOf: ["+ref+", {"+keyword+"}]}")) - assert.Empty(t, withCode(branchDiags, diag.DegradedConstruct), "nothing is left to keep or report") - assert.Len(t, branchDiags, len(besideDiags)) + for shape, beside := range besideTheAllOf { + t.Run(shape+"/"+name, func(t *testing.T) { + t.Parallel() + ref := "{$ref: '#/components/schemas/Base'}" + want, wantDiags := parseFull(t, voicesDoc("{allOf: ["+ref+"], "+keyword+beside+"}")) + got, gotDiags := parseFull(t, voicesDoc("{allOf: ["+ref+", {"+keyword+"}]"+beside+"}")) + assert.False(t, openapitest.HasDiagCodeAt(gotDiags, diag.DegradedConstruct, "/components/schemas/S/allOf/1"), + "nothing is left to keep or report; got %v", gotDiags) + assert.Equal(t, severityCodes(wantDiags), severityCodes(gotDiags)) - got, ok := typeByName(branch, "S").(*ir.Model) - require.True(t, ok) - want, ok := typeByName(beside, "S").(*ir.Model) - require.True(t, ok) - assert.Equal(t, want.Docs, got.Docs) - assert.Equal(t, want.Deprecation, got.Deprecation) - assert.Equal(t, want.Sensitive, got.Sensitive) - assert.Equal(t, want.XML, got.XML) - assert.Equal(t, want.Constraints, got.Constraints) - require.Len(t, got.Examples, len(want.Examples)) - assert.Len(t, got.Unmodeled, len(want.Unmodeled)) - for key, entry := range want.Unmodeled { - if assert.Contains(t, got.Unmodeled, key) { - assert.JSONEq(t, string(entry.Value), string(got.Unmodeled[key].Value), key) + id := componentID("S") + assertNodeAlike(t, want.Types[id], got.Types[id]) + wantUnion, ok := want.Types[id].(*ir.Union) + if !ok { + return } - } - }) + gotUnion, ok := got.Types[id].(*ir.Union) + require.True(t, ok) + require.Len(t, gotUnion.Variants, len(wantUnion.Variants)) + for i, v := range wantUnion.Variants { + target := gotUnion.Variants[i].Type.Target + require.Equal(t, v.Type.Target, target) + assertNodeAlike(t, want.Types[target], got.Types[target]) + } + }) + } + } +} + +// assertNodeAlike holds got to want in what a reader of the position finds on +// it: its docs, deprecation, secrecy, XML hints, examples, a model's constraints, +// and what it keeps, by key, reason and value. +func assertNodeAlike(t *testing.T, want, got ir.TypeDef) { + t.Helper() + require.NotNil(t, want) + require.NotNil(t, got) + require.Equal(t, want.Kind(), got.Kind()) + w, g := want.Common(), got.Common() + assert.Equal(t, w.Docs, g.Docs) + assert.Equal(t, w.Deprecation, g.Deprecation) + assert.Equal(t, w.Sensitive, g.Sensitive) + assert.Equal(t, w.XML, g.XML) + assert.Len(t, g.Examples, len(w.Examples)) + assert.Len(t, g.Unmodeled, len(w.Unmodeled)) + for key, entry := range w.Unmodeled { + if assert.Contains(t, g.Unmodeled, key) { + assert.Equal(t, entry.Reason, g.Unmodeled[key].Reason, key) + assert.JSONEq(t, string(entry.Value), string(g.Unmodeled[key].Value), key) + } + } + if wm, ok := want.(*ir.Model); ok { + gm, ok := got.(*ir.Model) + require.True(t, ok) + assert.Equal(t, wm.Constraints, gm.Constraints) + } +} + +// severityCodes returns the severity and code of each of diags, sorted, so two +// runs reporting alike at other pointers compare equal. +func severityCodes(diags []ir.Diagnostic) []string { + out := make([]string, 0, len(diags)) + for _, d := range diags { + out = append(out, string(d.Severity)+" "+d.Code) } + slices.Sort(out) + return out } // TestVoices_ABranchDisagreeingFoldsNothingInEveryOrder pins that a keyword two @@ -247,13 +306,6 @@ func TestVoices_ADisagreementIsReportedAsOne(t *testing.T) { got = openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/T/allOf/1") assert.NotContains(t, got, disagreed, "a branch that folds keeps what it declares of a shape, disagreed on by nothing") - _, diags = parseFull(t, openapitest.ComponentSpec(" Obj: {type: object}\n Other: {type: object}\n"+ - " T:\n oneOf: [{$ref: '#/components/schemas/Other'}, {type: object}]\n"+ - " allOf: [{$ref: '#/components/schemas/Obj'}, {title: t}]\n")) - for _, d := range withCode(diags, diag.DegradedConstruct) { - assert.NotContains(t, d.Message, disagreed, "a distributed branch folds nowhere, so it disagrees with nothing") - } - deep := "{description: deep, allOf: [{type: object}]}" for range schema.MaxAllOfFlattenDepth { deep = "{allOf: [" + deep + "]}" @@ -335,40 +387,32 @@ func TestVoices_UnevaluatedFalseStaysTheModels(t *testing.T) { assert.Contains(t, m.Unmodeled, "openapi:allOf/1") } -// TestVoices_ADistributedCompositionKeepsEveryBranch pins that a branch distributed -// across a union folds nothing: each variant keeps it whole, and the Union the -// position lowers to takes nothing of it. -func TestVoices_ADistributedCompositionKeepsEveryBranch(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, voicesDoc(`{allOf: [{$ref: '#/components/schemas/Base'}, {description: mine, properties: {c: {type: string}}}], - oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}]} - A: {type: object, properties: {a: {type: string}}} - B: {type: object, properties: {b: {type: string}}}`)) - openapitest.RequireNoErrorDiags(t, diags) - u, ok := typeByName(doc, "S").(*ir.Union) - require.True(t, ok) - assert.Empty(t, u.Docs.Description) - for _, v := range u.Variants { - assert.Contains(t, doc.Types[v.Type.Target].Common().Unmodeled, "openapi:allOf/1") - } -} - // TestVoices_AUseBindingKeywordReachesItsCarrierOrItsNode pins readOnly and default -// on a branch: a carrier holds them as beside the allOf, and with no carrier a node -// the position owns keeps them verbatim, reported at the keyword. +// on a branch: a carrier holds them as beside the allOf, a union distributed across +// it or not, and with no carrier a node the position owns keeps them verbatim, +// reported at the keyword. func TestVoices_AUseBindingKeywordReachesItsCarrierOrItsNode(t *testing.T) { t.Parallel() doc, diags := parseFull(t, openapitest.ComponentSpecVer("3.1.0", ` Base: {type: object, properties: {id: {type: string}}} + A: {type: object, properties: {a: {type: string}}} + B: {type: object, properties: {b: {type: string}}} Holder: type: object properties: f: {allOf: [{$ref: '#/components/schemas/Base'}, {readOnly: true, default: {id: a}}]} + g: + allOf: [{$ref: '#/components/schemas/Base'}, {readOnly: true, default: {id: a}}] + oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}] Bare: {allOf: [{$ref: '#/components/schemas/Base'}, {readOnly: true}, {readOnly: true}], writeOnly: false} `)) openapitest.RequireNoErrorDiags(t, diags) - f := propertyOf(t, doc, "Holder", "f") - assert.NotEmpty(t, f.Visibility.Only) - assert.NotNil(t, f.Default) + for _, name := range []string{"f", "g"} { + p := propertyOf(t, doc, "Holder", name) + assert.NotEmpty(t, p.Visibility.Only, name) + assert.NotNil(t, p.Default, name) + assert.Empty(t, doc.Types[p.Type.Target].Common().Unmodeled, name) + } + assert.True(t, openapitest.HasDiagCodeAt(diags, diag.CompositionLowering, "/components/schemas/Holder/properties/g")) bare, ok := typeByName(doc, "Bare").(*ir.Model) require.True(t, ok) entry, ok := bare.Unmodeled["openapi:readOnly"] @@ -417,16 +461,3 @@ func TestVoices_ARefBranchIsNoVoice(t *testing.T) { require.NotNil(t, m.Base) assert.Equal(t, "beside", doc.Types[m.Base.Target].Common().Docs.Description) } - -// TestVoices_AUnionBesideKeepsEveryBranch pins an allOf beside a union kept verbatim: -// its Model is no use a branch can speak for, so each branch stays whole. -func TestVoices_AUnionBesideKeepsEveryBranch(t *testing.T) { - t.Parallel() - doc, diags := parseFull(t, voicesDoc(`{allOf: [{$ref: '#/components/schemas/Base'}, {description: mine}], - oneOf: [{required: [a]}, {required: [b]}]}`)) - openapitest.RequireNoErrorDiags(t, diags) - m, ok := typeByName(doc, "S").(*ir.Model) - require.True(t, ok) - assert.Empty(t, m.Docs.Description) - assert.Contains(t, m.Unmodeled, "openapi:allOf/1") -} diff --git a/docs/compilers/openapi.md b/docs/compilers/openapi.md index 37fc983b5..7df98eaf4 100644 --- a/docs/compilers/openapi.md +++ b/docs/compilers/openapi.md @@ -1454,9 +1454,15 @@ fold, being a composing model's openness; a bound does not where the position ha as one beside the `allOf` reaches none ([`constraints`](#constraints)): a value bound on a node with no `Constraints` for one, and a collection bound on any node but a `List` or a `Tuple`, the `Union` of a type list holding one where such a variant does; a collection bound `Constraints` cannot state, -a `uniqueItems` of `false` or a count past 64 bits, never folds; and a composition distributed -across a union keeps every branch on each variant -([`union-beside-structure`](#union-beside-structure)). +a `uniqueItems` of `false` or a count past 64 bits, never folds. A `oneOf`, an `anyOf` or a +`$dynamicRef` beside the `allOf` keeps it from refining ([`allof-refinement`](#allof-refinement)) +and changes nothing else here: where the `allOf` composes, its branches fold into the composition +as they do with neither there, beside a `$dynamicRef` it keeps from expanding +([`dynamic-ref`](#dynamic-ref)) or a union kept verbatim +([`union-beside-structure`](#union-beside-structure)). Where the union distributes, a branch's +annotations and what a use binds reach the `Union` and its carriers, and a value bound each variant, +as the same keyword beside the `allOf` does, and a branch with no residue is kept on no variant +([models](../ir/models.md#composition-beside-a-union)). Folding needs no precedence, because a branch that disagrees with any conjunct on one of these, the keywords beside the `allOf` among them, folds nothing, in every order: the keywords beside the @@ -1515,7 +1521,8 @@ every entry reads that one twice. `schema.TestVoices_BoundsMeetInEveryOrder`, `schema.TestVoices_KeywordsOneEntryHoldsDisagreeAsOne`, `schema.TestVoices_UnevaluatedFalseStaysTheModels`, - `schema.TestVoices_ADistributedCompositionKeepsEveryBranch`, + `schema.TestVoices_AUseBindingKeywordReachesItsCarrierOrItsNode`, + `schema.TestAllOf_BranchResidueRidesEveryDistributedVariant`, `schema.TestAllOf_ABranchReadAgainstAnotherBaseFoldsNothing`, `schema.TestAllOf_ABranchWritingNoRelativeReferenceFoldsUnderAnyBase`, `schema.TestRefinement_ABranchReadAgainstAnotherBaseFoldsNothing`, diff --git a/docs/ir/models.md b/docs/ir/models.md index 95e6adc34..47c7bc70c 100644 --- a/docs/ir/models.md +++ b/docs/ir/models.md @@ -286,6 +286,14 @@ one or dropped, so nothing degrades and no `allOf` branch is flattened. A proper appears on each variant under its single source-derived `PropID`, since the source declares it once. +What `S` says of the position rather than of its shape, its docs, deprecation, examples and what a +use binds, is said once, on the `Union` that stands there and the carriers using it, and a value +bound, which constrains both sides of the disjunction, on each variant. That holds whether `S` +writes it beside the `allOf` or on an inline branch of it, which conjoins the same way. Keeping such +a branch whole on each variant instead would let the source be rebuilt from any one variant, but it +would copy one fact onto as many nodes as there are variants, none of them where a reader of the +position looks, and tell apart two spellings that mean the same. + The variant models are anonymous and **synthesized**: no schema in the source occupies them, so each takes an ID in a namespace of its own, derived from — but not equal to — the ID the branch pointer would yield. Provenance still points at the branch, the nearest thing the source wrote. diff --git a/docs/ir/version-history.md b/docs/ir/version-history.md index 4be041763..1f687bb2d 100644 --- a/docs/ir/version-history.md +++ b/docs/ir/version-history.md @@ -377,3 +377,14 @@ it sees. Changes made together in one bump are listed together under it. source's index with the overlay as its writer, where `Source` had named the overlay at a position of another document. A consumer pinned to 0.36.0 reading such a pointer against the overlay's file reads it at the wrong place. +- **0.38.0** — no shape change: an inline branch of an `allOf` beside a `oneOf`, an `anyOf` or a + `$dynamicRef` folds what it writes for the position into the composition, as a composing `allOf`'s + branch does with neither there, where the OpenAPI compiler had kept it whole under + `Unmodeled["openapi:allOf/"]` + ([`allof-inline-residue`](../compilers/openapi.md#allof-inline-residue)). Where a union + distributes ([models](./models.md#composition-beside-a-union)), the branch's docs, deprecation, + examples, secrecy and XML hints reach the `Union`, what a use binds its carriers, and a value + bound each composed variant, and a branch with no residue left is kept on no variant; beside a + union or a `$dynamicRef` kept verbatim they reach the `Model`, as in a composition with neither + beside it. A consumer pinned to 0.37.0 that read the branch under that key no longer finds it, and + refuses the document as another generation's. diff --git a/ir/document.go b/ir/document.go index 2246d7494..6db9566ec 100644 --- a/ir/document.go +++ b/ir/document.go @@ -12,7 +12,7 @@ package ir // docs/ir/ir-version.md holds that policy, docs/ir/version-history.md what each // bump changed, and TestIRVersion_PinsTheEncodedShape and // TestIRVersion_PinsTheNames hold it to the shape and the names. -const IRVersion = "0.37.0" +const IRVersion = "0.38.0" // CompatibleVersion reports whether a document stamped version can be read by // this build: exact equality with IRVersion, per docs/ir/ir-version.md. A diff --git a/ir/irversion_test.go b/ir/irversion_test.go index 23cf6db51..fe74e7be9 100644 --- a/ir/irversion_test.go +++ b/ir/irversion_test.go @@ -93,6 +93,9 @@ var generations = []generation{ names: "5506159ecd463736fdbbad0c4cabfad3fd50b48509b46fbea55862e82eca9675"}, {version: "0.37.0", format: 1, shape: "371d8ded3a52ab677a8318b202ab570ce495fb8c335b703477b00643a473275a", names: "5506159ecd463736fdbbad0c4cabfad3fd50b48509b46fbea55862e82eca9675"}, + {version: "0.38.0", format: 1, shape: "371d8ded3a52ab677a8318b202ab570ce495fb8c335b703477b00643a473275a", + names: "5506159ecd463736fdbbad0c4cabfad3fd50b48509b46fbea55862e82eca9675", + meaning: "an inline branch of an allOf beside a oneOf, an anyOf or a $dynamicRef folds into the composition: what it says of the position reaches the Union a distributed union lowers to and its carriers, and a value bound each variant"}, } // shapeGolden holds the description the newest row's shape hashes, so a change diff --git a/testdata/conformance/openapi/allof-inline-residue.golden.json b/testdata/conformance/openapi/allof-inline-residue.golden.json index c5b6a4eac..13d9be627 100644 --- a/testdata/conformance/openapi/allof-inline-residue.golden.json +++ b/testdata/conformance/openapi/allof-inline-residue.golden.json @@ -58,6 +58,126 @@ } ], "types": { + "t/composed/components/schemas/Distributed/oneOf/0": { + "kind": "model", + "id": "t/composed/components/schemas/Distributed/oneOf/0", + "name": { + "hint": "distributed_feline" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Distributed/oneOf/0" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Distributed/allOf/1/properties/name", + "name": { + "source": "name", + "canonical": "name" + }, + "wireName": "name", + "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/Distributed/allOf/1/properties/name" + } + } + ], + "base": { + "target": "t/openapi/components/schemas/Base", + "nullable": false + }, + "mixins": [ + { + "target": "t/openapi/components/schemas/Feline", + "nullable": false + } + ], + "constraints": { + "uniqueItems": false, + "maxProps": 4 + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/composed/components/schemas/Distributed/oneOf/1": { + "kind": "model", + "id": "t/composed/components/schemas/Distributed/oneOf/1", + "name": { + "hint": "distributed_canine" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Distributed/oneOf/1" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Distributed/allOf/1/properties/name", + "name": { + "source": "name", + "canonical": "name" + }, + "wireName": "name", + "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/Distributed/allOf/1/properties/name" + } + } + ], + "base": { + "target": "t/openapi/components/schemas/Base", + "nullable": false + }, + "mixins": [ + { + "target": "t/openapi/components/schemas/Canine", + "nullable": false + } + ], + "constraints": { + "uniqueItems": false, + "maxProps": 4 + }, + "abstract": false, + "positional": false, + "inputOnly": false + }, "t/openapi/components/schemas/Base": { "kind": "model", "id": "t/openapi/components/schemas/Base", @@ -105,6 +225,53 @@ "positional": false, "inputOnly": false }, + "t/openapi/components/schemas/Canine": { + "kind": "model", + "id": "t/openapi/components/schemas/Canine", + "name": { + "source": "Canine", + "canonical": "canine" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Canine" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Canine/properties/barks", + "name": { + "source": "barks", + "canonical": "barks" + }, + "wireName": "barks", + "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/Canine/properties/barks" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, "t/openapi/components/schemas/Contradicted": { "kind": "model", "id": "t/openapi/components/schemas/Contradicted", @@ -144,6 +311,96 @@ "positional": false, "inputOnly": false }, + "t/openapi/components/schemas/Distributed": { + "kind": "union", + "id": "t/openapi/components/schemas/Distributed", + "name": { + "source": "Distributed", + "canonical": "distributed" + }, + "anonymous": false, + "docs": { + "description": "A pet of either kind." + }, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Distributed" + }, + "variants": [ + { + "name": { + "hint": "feline" + }, + "type": { + "target": "t/composed/components/schemas/Distributed/oneOf/0", + "nullable": false + }, + "docs": {} + }, + { + "name": { + "hint": "canine" + }, + "type": { + "target": "t/composed/components/schemas/Distributed/oneOf/1", + "nullable": false + }, + "docs": {} + } + ], + "exclusive": true, + "wireTagged": false, + "closed": true, + "inputOnly": false + }, + "t/openapi/components/schemas/Feline": { + "kind": "model", + "id": "t/openapi/components/schemas/Feline", + "name": { + "source": "Feline", + "canonical": "feline" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Feline" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Feline/properties/purrs", + "name": { + "source": "purrs", + "canonical": "purrs" + }, + "wireName": "purrs", + "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/Feline/properties/purrs" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, "t/openapi/components/schemas/ItemA": { "kind": "scalar", "id": "t/openapi/components/schemas/ItemA", @@ -395,6 +652,18 @@ "positional": false, "inputOnly": false }, + "t/prim/bool": { + "kind": "primitive", + "id": "t/prim/bool", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": -1 + }, + "prim": "bool" + }, "t/prim/integer": { "kind": "primitive", "id": "t/prim/integer", @@ -452,6 +721,15 @@ "pointer": "/components/schemas/Contradicted/allOf/1" } }, + { + "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/Distributed" + } + }, { "severity": "info", "code": "openapi/degraded-construct", @@ -511,7 +789,7 @@ { "format": "openapi@3.1", "path": "allof-inline-residue.yaml", - "hash": "b6cea8de9f6660e43b6d6cfee543a954e832f9c0c800dcb527922e908bea49bd" + "hash": "1ab5e69bcc867baf4cf76bf738b90d538c76ca8e53eb752ffe8028462fae9987" } ] } diff --git a/testdata/conformance/openapi/allof-inline-residue.yaml b/testdata/conformance/openapi/allof-inline-residue.yaml index 030f16ac6..c208dbb73 100644 --- a/testdata/conformance/openapi/allof-inline-residue.yaml +++ b/testdata/conformance/openapi/allof-inline-residue.yaml @@ -32,6 +32,21 @@ components: allOf: - {$ref: '#/components/schemas/Untyped'} - {type: string, maxLength: 3} + # Beside a union the branch folds as it does alone: the Union the + # distribution lowers to takes its description, each composed variant its + # bound, and no variant keeps the branch. + Feline: {type: object, properties: {purrs: {type: boolean}}} + Canine: {type: object, properties: {barks: {type: boolean}}} + Distributed: + allOf: + - {$ref: '#/components/schemas/Base'} + - properties: + name: {type: string} + description: A pet of either kind. + maxProperties: 4 + oneOf: + - {$ref: '#/components/schemas/Feline'} + - {$ref: '#/components/schemas/Canine'} # Under the $id beside its allOf, `item.json` names ItemB; under the # nested wrapper's, ItemA. The nested branch's `not` would name ItemB once # folded here, so it folds nothing and is kept whole, while the outer From 1ae30ac9c08e2d87f4d828d64841af8f8a7af5da Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 10 Nov 2026 23:38:46 +0300 Subject: [PATCH 02/11] test(compilers/openapi): compare a folded branch's nodes whole The differential holding a branch's keywords to the same keywords beside the allOf compared the nodes field by field: docs, deprecation, secrecy, XML hints, example count, constraints and kept entries. A spelling that moved a property, a base, a mixin or a catch-all apart passed it. It now compares each node whole with cmp, provenance aside, which names where each spelling was written. readAllOf's comment now says why it marks folds only where the allOf decides the position: that is the gate positionVoices delivers voices by, so a branch gives up a keyword only where a reader takes it up. --- compilers/openapi/internal/schema/allof.go | 5 +++- .../openapi/internal/schema/voices_test.go | 27 +++++-------------- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/compilers/openapi/internal/schema/allof.go b/compilers/openapi/internal/schema/allof.go index 185c43ecd..3e2f7922e 100644 --- a/compilers/openapi/internal/schema/allof.go +++ b/compilers/openapi/internal/schema/allof.go @@ -189,7 +189,10 @@ func allOfBranches(s *oas3.Schema, pointer jsontext.Pointer) allOfComposition { // readAllOf is allOfBranches of the schema written at pointer, with what its // conjuncts jointly admit read and, where the allOf is what the position lowers, -// which branches fold there (voicingOf), for a lowering that composes it. +// which branches fold there (voicingOf), for a lowering that composes it. That +// gate is positionVoices', so a branch gives up a keyword only where a reader +// of the position takes it up; both composers pass it, and the report of an +// allOf the election passed over does not. func readAllOf(c lowering.Ctx, s *oas3.Schema, pointer jsontext.Pointer) allOfComposition { comp := allOfBranches(s, pointer) comp.disjoint = disjointConjuncts(c, s, comp) diff --git a/compilers/openapi/internal/schema/voices_test.go b/compilers/openapi/internal/schema/voices_test.go index 6702cd377..0325848e1 100644 --- a/compilers/openapi/internal/schema/voices_test.go +++ b/compilers/openapi/internal/schema/voices_test.go @@ -7,6 +7,8 @@ import ( "slices" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -96,31 +98,14 @@ func TestVoices_AKeywordFoldsWhereTheAllOfWouldKeepIt(t *testing.T) { } } -// assertNodeAlike holds got to want in what a reader of the position finds on -// it: its docs, deprecation, secrecy, XML hints, examples, a model's constraints, -// and what it keeps, by key, reason and value. +// assertNodeAlike holds got to want whole but for provenance, which names where +// each spelling was written and so differs between them. func assertNodeAlike(t *testing.T, want, got ir.TypeDef) { t.Helper() require.NotNil(t, want) require.NotNil(t, got) - require.Equal(t, want.Kind(), got.Kind()) - w, g := want.Common(), got.Common() - assert.Equal(t, w.Docs, g.Docs) - assert.Equal(t, w.Deprecation, g.Deprecation) - assert.Equal(t, w.Sensitive, g.Sensitive) - assert.Equal(t, w.XML, g.XML) - assert.Len(t, g.Examples, len(w.Examples)) - assert.Len(t, g.Unmodeled, len(w.Unmodeled)) - for key, entry := range w.Unmodeled { - if assert.Contains(t, g.Unmodeled, key) { - assert.Equal(t, entry.Reason, g.Unmodeled[key].Reason, key) - assert.JSONEq(t, string(entry.Value), string(g.Unmodeled[key].Value), key) - } - } - if wm, ok := want.(*ir.Model); ok { - gm, ok := got.(*ir.Model) - require.True(t, ok) - assert.Equal(t, wm.Constraints, gm.Constraints) + if diff := cmp.Diff(want, got, cmpopts.IgnoreTypes(ir.Provenance{})); diff != "" { + t.Errorf("node mismatch (-beside the allOf +on a branch):\n%s", diff) } } From d5832611bd2ac07bb44be42380a086dbf0f504c6 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 10 Nov 2026 23:41:50 +0300 Subject: [PATCH 03/11] test(compilers/openapi): compare provenance but where it places a node The differential ignored provenance whole, so a branch whose fold marked a fact inferred, or named another writer, where the same keyword beside the allOf does not, passed it. It now ignores only the pointer, position and node, which differ between the two spellings by where each was written. --- compilers/openapi/internal/schema/voices_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/compilers/openapi/internal/schema/voices_test.go b/compilers/openapi/internal/schema/voices_test.go index 0325848e1..a78c82f8c 100644 --- a/compilers/openapi/internal/schema/voices_test.go +++ b/compilers/openapi/internal/schema/voices_test.go @@ -98,13 +98,15 @@ func TestVoices_AKeywordFoldsWhereTheAllOfWouldKeepIt(t *testing.T) { } } -// assertNodeAlike holds got to want whole but for provenance, which names where -// each spelling was written and so differs between them. +// assertNodeAlike holds got to want whole but for where provenance places each +// part, which differs between the two spellings: what it marks inferred and +// which document wrote it do not. func assertNodeAlike(t *testing.T, want, got ir.TypeDef) { t.Helper() require.NotNil(t, want) require.NotNil(t, got) - if diff := cmp.Diff(want, got, cmpopts.IgnoreTypes(ir.Provenance{})); diff != "" { + at := cmpopts.IgnoreFields(ir.Provenance{}, "Pointer", "Position", "Node") + if diff := cmp.Diff(want, got, at); diff != "" { t.Errorf("node mismatch (-beside the allOf +on a branch):\n%s", diff) } } From 3766457341dd522bc56953074fc97fb4e91dc9c5 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 11 Nov 2026 00:22:13 +0300 Subject: [PATCH 04/11] test(compilers/openapi): degrade a union by its combinators, not a branch Two fixtures took a union with an inline branch as one that degrades beside the allOf. Distributing a union's inline branches is planned (#661), and a fixture that then distributes would test a second distributed case under the degraded name, or fail where the nullable test requires the degraded report. Both now write a oneOf beside an anyOf, which degrades for a reason that change leaves alone. Each outcome the fold differential runs at now asserts the report that shows it reached that outcome, so a row that stops degrading fails rather than passing as another. --- .../openapi/internal/schema/nulls_test.go | 2 +- .../openapi/internal/schema/voices_test.go | 30 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/compilers/openapi/internal/schema/nulls_test.go b/compilers/openapi/internal/schema/nulls_test.go index 1a4befe8b..d2231f530 100644 --- a/compilers/openapi/internal/schema/nulls_test.go +++ b/compilers/openapi/internal/schema/nulls_test.go @@ -395,7 +395,7 @@ func TestNullableWidening_ABranchWidensTheAllOfHoldingIt(t *testing.T) { } for name, tc := range map[string]struct{ union, code string }{ - "degraded": {"oneOf: [{type: string}, {type: integer}]", diag.DegradedConstruct}, + "degraded": {"oneOf: [{$ref: '#/components/schemas/Pet'}], anyOf: [{$ref: '#/components/schemas/Toy'}]", diag.DegradedConstruct}, "distributed": {"oneOf: [{$ref: '#/components/schemas/Pet'}, {$ref: '#/components/schemas/Toy'}]", diag.CompositionLowering}, } { besideUnion := openapitest.HolderSpec("3.0.3", "{"+tc.union+", allOf: [{$ref: '#/components/schemas/Pet'}, {nullable: true}]}", diff --git a/compilers/openapi/internal/schema/voices_test.go b/compilers/openapi/internal/schema/voices_test.go index a78c82f8c..728d58065 100644 --- a/compilers/openapi/internal/schema/voices_test.go +++ b/compilers/openapi/internal/schema/voices_test.go @@ -29,16 +29,18 @@ func voicesDoc(written string) string { } // besideTheAllOf is what S writes beside its allOf for each outcome the allOf -// composes at: nothing, a union it distributes across, one kept beside its Model -// for an inline branch, one narrowing it alone +// composes at: nothing, a union it distributes across, a oneOf and an anyOf kept +// beside its Model, a union narrowing it alone // (docs/compilers/openapi.md#union-beside-structure), and a $dynamicRef it keeps -// from expanding (docs/compilers/openapi.md#dynamic-ref). -var besideTheAllOf = map[string]string{ - "alone": "", - "distributed": ", oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}]", - "beside a degraded union": ", oneOf: [{$ref: '#/components/schemas/A'}, {type: object, properties: {z: {type: string}}}]", - "beside a narrowing union": ", oneOf: [{required: [a]}, {required: [b]}]", - "beside a $dynamicRef": ", $dynamicRef: '#meta'", +// from expanding (docs/compilers/openapi.md#dynamic-ref). code is the report +// showing the outcome reached, drawn at S or at the keyword at names below it. +var besideTheAllOf = map[string]struct{ beside, code, at string }{ + "alone": {}, + "distributed": {", oneOf: [{$ref: '#/components/schemas/A'}, {$ref: '#/components/schemas/B'}]", diag.CompositionLowering, ""}, + "beside a degraded union": {", oneOf: [{$ref: '#/components/schemas/A'}], anyOf: [{$ref: '#/components/schemas/B'}]", + diag.DegradedConstruct, ""}, + "beside a narrowing union": {", oneOf: [{required: [a]}, {required: [b]}]", diag.ValidationOnlyKeyword, ""}, + "beside a $dynamicRef": {", $dynamicRef: '#meta'", diag.DegradedConstruct, "/$dynamicRef"}, } // TestVoices_AKeywordFoldsWhereTheAllOfWouldKeepIt holds a keyword on an inline @@ -69,12 +71,16 @@ func TestVoices_AKeywordFoldsWhereTheAllOfWouldKeepIt(t *testing.T) { "propertyNames": "propertyNames: {pattern: '^a'}", "a redaction": "format: password", } { - for shape, beside := range besideTheAllOf { + for shape, tc := range besideTheAllOf { t.Run(shape+"/"+name, func(t *testing.T) { t.Parallel() ref := "{$ref: '#/components/schemas/Base'}" - want, wantDiags := parseFull(t, voicesDoc("{allOf: ["+ref+"], "+keyword+beside+"}")) - got, gotDiags := parseFull(t, voicesDoc("{allOf: ["+ref+", {"+keyword+"}]"+beside+"}")) + want, wantDiags := parseFull(t, voicesDoc("{allOf: ["+ref+"], "+keyword+tc.beside+"}")) + got, gotDiags := parseFull(t, voicesDoc("{allOf: ["+ref+", {"+keyword+"}]"+tc.beside+"}")) + if tc.code != "" { + require.True(t, openapitest.HasDiagCodeAt(gotDiags, tc.code, "/components/schemas/S"+tc.at), + "the outcome this row names; got %v", gotDiags) + } assert.False(t, openapitest.HasDiagCodeAt(gotDiags, diag.DegradedConstruct, "/components/schemas/S/allOf/1"), "nothing is left to keep or report; got %v", gotDiags) assert.Equal(t, severityCodes(wantDiags), severityCodes(gotDiags)) From a1b37f0f610f603564102b42589299bc76efbda8 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 11 Nov 2026 00:48:06 +0300 Subject: [PATCH 05/11] test(compilers/openapi): compare a $ref use in the fold differential A use of a schema through a $ref reads what its folding branches lend (lendingBranches), and the change to fold a branch beside a union or a $dynamicRef widened that too: a property naming such a schema now takes the branch's readOnly, default, docs, deprecation and secrecy, as it takes them written beside the allOf. No test reached it. With lendingBranches given back the union and $dynamicRef exclusions, every test passed while the property lost each of them. The differential now compares a component holding a $ref use of S whole, beside S and its variants, and holds readOnly and default as it holds the other keywords. That plant now fails the readOnly, default, title, description, externalDocs, deprecated and redaction rows at each union and $dynamicRef outcome, and passes alone. --- compilers/openapi/internal/schema/voices_test.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/compilers/openapi/internal/schema/voices_test.go b/compilers/openapi/internal/schema/voices_test.go index 728d58065..ea54b1e4b 100644 --- a/compilers/openapi/internal/schema/voices_test.go +++ b/compilers/openapi/internal/schema/voices_test.go @@ -19,12 +19,14 @@ import ( ) // voicesDoc is a document of Base, A and B, objects, Meta, an object anchored -// dynamically, and a component S written as written. +// dynamically, Holder, an object using S through a $ref, and a component S +// written as written. func voicesDoc(written string) string { return openapitest.ComponentSpecVer("3.1.0", " Base: {type: object, properties: {id: {type: string}}}\n"+ " A: {type: object, properties: {a: {type: string}}}\n"+ " B: {type: object, properties: {b: {type: string}}}\n"+ " Meta: {$dynamicAnchor: meta, type: object}\n"+ + " Holder: {type: object, properties: {s: {$ref: '#/components/schemas/S'}}}\n"+ " S: "+written+"\n") } @@ -45,8 +47,8 @@ var besideTheAllOf = map[string]struct{ beside, code, at string }{ // TestVoices_AKeywordFoldsWhereTheAllOfWouldKeepIt holds a keyword on an inline // branch to the same keyword beside the allOf, beside each union outcome: for each -// one the two lower to the same nodes, the branch's kept nowhere else and -// reported nowhere but where it is written. +// one the two lower to the same nodes, a $ref use of S among them, the branch's +// kept nowhere else and reported nowhere but where it is written. func TestVoices_AKeywordFoldsWhereTheAllOfWouldKeepIt(t *testing.T) { t.Parallel() for name, keyword := range map[string]string{ @@ -54,6 +56,8 @@ func TestVoices_AKeywordFoldsWhereTheAllOfWouldKeepIt(t *testing.T) { "description": "description: mine", "externalDocs": "externalDocs: {url: 'https://example.com'}", "deprecated": "deprecated: true", + "readOnly": "readOnly: true", + "default": "default: {id: a}", "example": "example: {id: a}", "examples": "examples: [{id: a}]", "xml": "xml: {name: mine}", @@ -85,6 +89,8 @@ func TestVoices_AKeywordFoldsWhereTheAllOfWouldKeepIt(t *testing.T) { "nothing is left to keep or report; got %v", gotDiags) assert.Equal(t, severityCodes(wantDiags), severityCodes(gotDiags)) + holder := componentID("Holder") + assertNodeAlike(t, want.Types[holder], got.Types[holder]) id := componentID("S") assertNodeAlike(t, want.Types[id], got.Types[id]) wantUnion, ok := want.Types[id].(*ir.Union) From b751d4955135f0649162f1b80431a70b3b5ab6cd Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 11 Nov 2026 00:48:21 +0300 Subject: [PATCH 06/11] refactor(compilers/openapi): rename allOfConjoins to allOfMayRefine allOfConjoins reported whether an allOf's branches conjoin with what its position lowers, and excluded a union and a $dynamicRef beside it on that ground. Branches beside either now fold into the composition, so the predicate answers only whether the allOf may refine, as its comment already says, and both its callers ask exactly that. A name saying it conjoins invites the next reader to gate folding on it again, which is the exclusion allOfDecides had inherited. --- compilers/openapi/internal/schema/refinement.go | 2 +- compilers/openapi/internal/schema/use.go | 2 +- compilers/openapi/internal/schema/voices.go | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/compilers/openapi/internal/schema/refinement.go b/compilers/openapi/internal/schema/refinement.go index 736bc9f8a..9f83f73c8 100644 --- a/compilers/openapi/internal/schema/refinement.go +++ b/compilers/openapi/internal/schema/refinement.go @@ -76,7 +76,7 @@ type decision struct { // the bindings, never the registry, so a decision turns on no lowering order, and // each position's many readers share one reading. func refinementOf(c lowering.Ctx, s *oas3.Schema, pointer jsontext.Pointer) (refinement, bool) { - if !allOfConjoins(s) { + if !allOfMayRefine(s) { return refinement{}, false } d := lowering.Remembered(c, refinementAt{s, pointer}, func() decision { return decide(c, s, pointer) }) diff --git a/compilers/openapi/internal/schema/use.go b/compilers/openapi/internal/schema/use.go index 3517448ff..f3d16724e 100644 --- a/compilers/openapi/internal/schema/use.go +++ b/compilers/openapi/internal/schema/use.go @@ -176,7 +176,7 @@ func throughRef(s *oas3.Schema) *oas3.JSONSchema[oas3.Referenceable] { if slices.Contains(dispatchOf(s).skipped, "allOf") { return refs[0] } - if !allOfConjoins(s) || annotation.DeclaresAny(s, objectKeywords) || !refinableBranches(allOfBranches(s, "")) { + if !allOfMayRefine(s) || annotation.DeclaresAny(s, objectKeywords) || !refinableBranches(allOfBranches(s, "")) { return nil } return refs[0] diff --git a/compilers/openapi/internal/schema/voices.go b/compilers/openapi/internal/schema/voices.go index 0e2bbb6c9..c824665f5 100644 --- a/compilers/openapi/internal/schema/voices.go +++ b/compilers/openapi/internal/schema/voices.go @@ -95,10 +95,10 @@ func allOfBesideNoRef(s *oas3.Schema) bool { return len(s.GetAllOf()) > 0 && s.Ref == nil } -// allOfConjoins reports whether s's allOf may refine: no $ref, union or +// allOfMayRefine reports whether s's allOf may refine: no $ref, union or // $dynamicRef stands beside it, the last two keeping it the composition it would // otherwise be (docs/compilers/openapi.md#allof-refinement). -func allOfConjoins(s *oas3.Schema) bool { +func allOfMayRefine(s *oas3.Schema) bool { return allOfBesideNoRef(s) && !declaresUnion(s) && !declaresDynamicRef(s) } From 44bb49da246193f2fa54b612f106e8a96ae2e174 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 11 Nov 2026 00:48:21 +0300 Subject: [PATCH 07/11] docs: say where a distributed branch's residue and folds land The rule and the 0.38.0 entry read "as they do with neither there" after naming three keywords, a oneOf, an anyOf and a $dynamicRef; they now say none of them. The comment on the distributed residue test read as if the whole branch landed on the Union; it now says the branch is kept on each variant while what it folds reaches the Union. --- compilers/openapi/internal/schema/allof_residue_test.go | 7 ++++--- docs/compilers/openapi.md | 2 +- docs/ir/version-history.md | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/compilers/openapi/internal/schema/allof_residue_test.go b/compilers/openapi/internal/schema/allof_residue_test.go index 26f0df6d3..2bfc2b77a 100644 --- a/compilers/openapi/internal/schema/allof_residue_test.go +++ b/compilers/openapi/internal/schema/allof_residue_test.go @@ -249,9 +249,10 @@ func TestAllOf_EachInlineBranchKeyedSeparately(t *testing.T) { // TestAllOf_BranchResidueRidesEveryDistributedVariant covers fillAllOf's second // caller: buildComposedVariant re-runs the same merge once per union variant, so -// the residue has to land on every variant the composition rides on, the whole -// branch with what folds at the Union. The diagnostic is announced once even so — -// the branch is one source construct however many variants carry it. +// the residue, the whole branch, has to land on every variant the composition +// rides on, while what the branch folds reaches the Union. The diagnostic is +// announced once even so — the branch is one source construct however many +// variants carry it. func TestAllOf_BranchResidueRidesEveryDistributedVariant(t *testing.T) { t.Parallel() spec := openapitest.ComponentSpec(` Base: {type: object, properties: {id: {type: string}}} diff --git a/docs/compilers/openapi.md b/docs/compilers/openapi.md index 7df98eaf4..015bcb5a5 100644 --- a/docs/compilers/openapi.md +++ b/docs/compilers/openapi.md @@ -1457,7 +1457,7 @@ of a type list holding one where such a variant does; a collection bound `Constr a `uniqueItems` of `false` or a count past 64 bits, never folds. A `oneOf`, an `anyOf` or a `$dynamicRef` beside the `allOf` keeps it from refining ([`allof-refinement`](#allof-refinement)) and changes nothing else here: where the `allOf` composes, its branches fold into the composition -as they do with neither there, beside a `$dynamicRef` it keeps from expanding +as they do with none of them there, beside a `$dynamicRef` it keeps from expanding ([`dynamic-ref`](#dynamic-ref)) or a union kept verbatim ([`union-beside-structure`](#union-beside-structure)). Where the union distributes, a branch's annotations and what a use binds reach the `Union` and its carriers, and a value bound each variant, diff --git a/docs/ir/version-history.md b/docs/ir/version-history.md index 1f687bb2d..979b39af4 100644 --- a/docs/ir/version-history.md +++ b/docs/ir/version-history.md @@ -379,7 +379,7 @@ it sees. Changes made together in one bump are listed together under it. overlay's file reads it at the wrong place. - **0.38.0** — no shape change: an inline branch of an `allOf` beside a `oneOf`, an `anyOf` or a `$dynamicRef` folds what it writes for the position into the composition, as a composing `allOf`'s - branch does with neither there, where the OpenAPI compiler had kept it whole under + branch does with none of them there, where the OpenAPI compiler had kept it whole under `Unmodeled["openapi:allOf/"]` ([`allof-inline-residue`](../compilers/openapi.md#allof-inline-residue)). Where a union distributes ([models](./models.md#composition-beside-a-union)), the branch's docs, deprecation, From 22f42228f426a77639b164876ec3fda78edd8713 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 11 Nov 2026 01:37:05 +0300 Subject: [PATCH 08/11] perf(compilers/openapi): read a distributed union's voices once buildComposedVariant read positionVoices once for every variant, though what it reads is the same for each: the schema, its position and an empty target. composedBody already carries the flattened allOf built once for every variant; it now carries the voices beside it, read once in lowerDistributedUnion. On a union of 500 variants beside an allOf of 20 branches that do not fold, the read per variant took about 1% more instructions and 1.3% more peak memory to compile; read once, both are where they were before such branches folded. No spec under testdata, the real-world corpus or a grid of the fold's outcomes at every schema position changes. --- compilers/openapi/internal/schema/union.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/compilers/openapi/internal/schema/union.go b/compilers/openapi/internal/schema/union.go index 688d4c0f8..765a3b311 100644 --- a/compilers/openapi/internal/schema/union.go +++ b/compilers/openapi/internal/schema/union.go @@ -616,7 +616,8 @@ const refSiteUnionWhy = "oneOf/anyOf beside a $ref conjoin with it rather than c func lowerDistributedUnion(c lowering.Ctx, ts *compile.Types, depth int, s *oas3.Schema, pointer jsontext.Pointer) (ir.TypeID, []ir.Diagnostic) { var diags []ir.Diagnostic id := internNode(c, ts, pointer, func(common ir.TypeCommon) ir.TypeDef { - body := composedBody{schema: s, comp: readAllOf(c, s, pointer), id: common.ID} + body := composedBody{schema: s, comp: readAllOf(c, s, pointer), + voices: positionVoices(c, ts, s, pointer, ir.TypeRef{}), id: common.ID} diags = append(diags, diagDisjointConjuncts(c, body.comp, composedShape)...) def, unionDiags := buildUnion(c, ts, s, common, pointer, func(b *oas3.JSONSchema[oas3.Referenceable], vptr jsontext.Pointer) ir.TypeRef { @@ -638,7 +639,8 @@ func lowerDistributedUnion(c lowering.Ctx, ts *compile.Types, depth int, s *oas3 // declares one schema, not one per branch. type composedBody struct { schema *oas3.Schema - comp allOfComposition // schema's flattened allOf, built once for every variant + comp allOfComposition // schema's flattened allOf, built once for every variant + voices []annotation.Voice // what comp's folding branches say of the position, read once too id ir.TypeID } @@ -697,8 +699,7 @@ func composedVariantRef(c lowering.Ctx, id ir.TypeID, body *oas3.Schema, branch // are read as lowerAllOf reads them (GitHub #407). Each variant carries a copy, // since a bound on the schema constrains both sides of the disjunction. func buildComposedVariant(c lowering.Ctx, ts *compile.Types, depth int, body composedBody, branch ir.TypeID, common ir.TypeCommon) (ir.TypeDef, []ir.Diagnostic) { - voices := positionVoices(c, ts, body.schema, body.comp.pointer, ir.TypeRef{}) - cons, diags := ConstraintsOf(c, &common.Unmodeled, body.schema, voices, body.comp.pointer) + cons, diags := ConstraintsOf(c, &common.Unmodeled, body.schema, body.voices, body.comp.pointer) m := &ir.Model{TypeCommon: common, Constraints: cons} fields := newModelFields(c, ts, m) diags = append(diags, fillAllOf(c, ts, depth, m, fields, body.comp)...) From a60a2f47af98d518eefb7772bd9d3baf8ecdc1c5 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 11 Nov 2026 01:37:05 +0300 Subject: [PATCH 09/11] test(compilers/openapi): hold kept and met branches at every outcome The rule says a oneOf, an anyOf or a $dynamicRef beside an allOf changes nothing but whether it refines: a branch disagreeing with a conjunct, or reading a $ref against another base, stays whole with its report, and two bounds meet, as they do with nothing beside the allOf. Every test of those held an allOf with nothing beside it, so a voicing that let a disagreeing branch fold, ignored another base, or kept two meeting bounds apart only where a union or a $dynamicRef stands beside the allOf passed every test under compilers/openapi. TestVoices_KeptAndMetBranchesHoldAtEveryOutcome runs a disagreement and two meeting bounds in both orders, and a branch under another base, at each outcome besideTheAllOf names, holding each Model the composition lowers to. Each of those three defects now fails it at every union and $dynamicRef outcome and passes alone. The fold differential's comment now names those outcomes too, not the unions alone. --- .../openapi/internal/schema/voices_test.go | 86 ++++++++++++++++++- docs/compilers/openapi.md | 1 + 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/compilers/openapi/internal/schema/voices_test.go b/compilers/openapi/internal/schema/voices_test.go index ea54b1e4b..6558cddb5 100644 --- a/compilers/openapi/internal/schema/voices_test.go +++ b/compilers/openapi/internal/schema/voices_test.go @@ -5,6 +5,7 @@ package schema_test import ( "slices" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -46,9 +47,9 @@ var besideTheAllOf = map[string]struct{ beside, code, at string }{ } // TestVoices_AKeywordFoldsWhereTheAllOfWouldKeepIt holds a keyword on an inline -// branch to the same keyword beside the allOf, beside each union outcome: for each -// one the two lower to the same nodes, a $ref use of S among them, the branch's -// kept nowhere else and reported nowhere but where it is written. +// branch to the same keyword beside the allOf, at each outcome besideTheAllOf +// names: for each one the two lower to the same nodes, a $ref use of S among them, +// the branch's kept nowhere else and reported nowhere but where it is written. func TestVoices_AKeywordFoldsWhereTheAllOfWouldKeepIt(t *testing.T) { t.Parallel() for name, keyword := range map[string]string{ @@ -134,6 +135,85 @@ func severityCodes(diags []ir.Diagnostic) []string { return out } +// TestVoices_KeptAndMetBranchesHoldAtEveryOutcome pins that what keeps a branch +// whole, or meets two, holds at each outcome besideTheAllOf names as it does +// alone (docs/compilers/openapi.md#allof-inline-residue): a branch disagreeing +// with a conjunct, or reading a $ref against another base, stays whole on each +// Model the composition lowers to, with one report saying why and nothing it +// writes folded, and two bounds meet there in either order. +func TestVoices_KeptAndMetBranchesHoldAtEveryOutcome(t *testing.T) { + t.Parallel() + const ref = "{$ref: '#/components/schemas/Base'}" + const n = "\n N: {$id: 'https://ex.test/a/n.json', type: string}" // what `n.json` names under https://ex.test/a/ + met := &ir.Constraints{MaxProps: new(int64(3))} + for name, tc := range map[string]struct { + branches string + kept []string + why string + cons *ir.Constraints + }{ + "a disagreement": {ref + ", {description: a}, {description: b}", []string{"allOf/1", "allOf/2"}, + "the conjuncts of this allOf disagree", nil}, + "a disagreement swapped": {"{description: b}, {description: a}, " + ref, []string{"allOf/0", "allOf/1"}, + "the conjuncts of this allOf disagree", nil}, + "another base": {ref + ", {$id: 'https://ex.test/a/x.json', not: {$ref: 'n.json'}, description: d}", + []string{"allOf/1"}, "reads its $refs against another base", nil}, + "bounds meeting": {ref + ", {maxProperties: 3}, {maxProperties: 5}", nil, "", met}, + "bounds meeting swapped": {ref + ", {maxProperties: 5}, {maxProperties: 3}", nil, "", met}, + } { + for shape, outcome := range besideTheAllOf { + t.Run(shape+"/"+name, func(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, voicesDoc("{allOf: ["+tc.branches+"]"+outcome.beside+"}"+n)) + openapitest.RequireNoErrorDiags(t, diags) + if outcome.code != "" { + require.True(t, openapitest.HasDiagCodeAt(diags, outcome.code, "/components/schemas/S"+outcome.at), + "the outcome this row names; got %v", diags) + } + for _, path := range tc.kept { + got := openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/components/schemas/S/"+path) + assert.Contains(t, got, tc.why, path) + } + assert.Empty(t, typeByName(doc, "S").Common().Docs.Description, "nothing a kept branch writes folds") + for _, m := range composedModels(t, doc) { + assert.ElementsMatch(t, tc.kept, keptBranches(m), m.ID) + assert.Equal(t, tc.cons, m.Constraints, m.ID) + } + }) + } + } +} + +// composedModels returns the Models S's composition lowers to: S itself, or each +// variant of the Union a distributed union lowers it to. +func composedModels(t *testing.T, doc *ir.Document) []*ir.Model { + t.Helper() + u, distributed := typeByName(doc, "S").(*ir.Union) + if !distributed { + m, ok := typeByName(doc, "S").(*ir.Model) + require.True(t, ok) + return []*ir.Model{m} + } + out := make([]*ir.Model, 0, len(u.Variants)) + for _, v := range u.Variants { + m, ok := doc.Types[v.Type.Target].(*ir.Model) + require.True(t, ok) + out = append(out, m) + } + return out +} + +// keptBranches returns the path of each allOf branch m keeps whole. +func keptBranches(m *ir.Model) []string { + var out []string + for key := range m.Unmodeled { + if path, ok := strings.CutPrefix(key, "openapi:"); ok && strings.HasPrefix(path, "allOf/") { + out = append(out, path) + } + } + return out +} + // TestVoices_ABranchDisagreeingFoldsNothingInEveryOrder pins that a keyword two // conjuncts write differently has no winner: the keywords beside the allOf stand, // and each branch writing another value stays whole, whichever comes first. diff --git a/docs/compilers/openapi.md b/docs/compilers/openapi.md index 015bcb5a5..c517e0a25 100644 --- a/docs/compilers/openapi.md +++ b/docs/compilers/openapi.md @@ -1522,6 +1522,7 @@ every entry reads that one twice. `schema.TestVoices_KeywordsOneEntryHoldsDisagreeAsOne`, `schema.TestVoices_UnevaluatedFalseStaysTheModels`, `schema.TestVoices_AUseBindingKeywordReachesItsCarrierOrItsNode`, + `schema.TestVoices_KeptAndMetBranchesHoldAtEveryOutcome`, `schema.TestAllOf_BranchResidueRidesEveryDistributedVariant`, `schema.TestAllOf_ABranchReadAgainstAnotherBaseFoldsNothing`, `schema.TestAllOf_ABranchWritingNoRelativeReferenceFoldsUnderAnyBase`, From f7714b60b428a5d34eb040fb1b238ee7f1ddbdbb Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 11 Nov 2026 01:37:05 +0300 Subject: [PATCH 10/11] docs(compilers/openapi): put allOfDecides' union sentence last The sentence on a union beside the allOf had been inserted between "an enum or a const beside it" and "An allOf that value set beats", which refers back to it. It now follows both. --- compilers/openapi/internal/schema/voices.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/compilers/openapi/internal/schema/voices.go b/compilers/openapi/internal/schema/voices.go index c824665f5..a7e5dfe3c 100644 --- a/compilers/openapi/internal/schema/voices.go +++ b/compilers/openapi/internal/schema/voices.go @@ -104,11 +104,12 @@ func allOfMayRefine(s *oas3.Schema) bool { // allOfDecides reports whether s's allOf is what its position lowers: one the // election of competing keywords made the value, or one refining, whose rival -// there, an enum or a const beside it, is one of its conjuncts. A union beside -// the composing allOf leaves it deciding, composed into each variant the union -// distributes across or into the Model keeping the union verbatim, and so does -// a $dynamicRef it keeps from expanding. An allOf that value set beats -// otherwise stays verbatim (docs/compilers/openapi.md#competing-keywords). +// there, an enum or a const beside it, is one of its conjuncts. An allOf that +// value set beats otherwise stays verbatim +// (docs/compilers/openapi.md#competing-keywords). A union beside the composing +// allOf leaves it deciding, composed into each variant the union distributes +// across or into the Model keeping the union verbatim, and so does a $dynamicRef +// it keeps from expanding. func allOfDecides(c lowering.Ctx, s *oas3.Schema) bool { if !allOfBesideNoRef(s) { return false From d480c865464c14aed5560d3d761a8a91b513cc97 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 11 Nov 2026 01:50:44 +0300 Subject: [PATCH 11/11] test(compilers/openapi): name what S lowered to when a helper fails composedModels, through which TestVoices_KeptAndMetBranchesHoldAtEveryOutcome reads the Models a composition lowers to, failed with a bare "Should be true" where S or one of its variants lowered to another kind, naming neither the node nor what it was. Each check now says which node lowered to which kind, so the failure diagnoses itself. --- compilers/openapi/internal/schema/voices_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/compilers/openapi/internal/schema/voices_test.go b/compilers/openapi/internal/schema/voices_test.go index 6558cddb5..56ee567f3 100644 --- a/compilers/openapi/internal/schema/voices_test.go +++ b/compilers/openapi/internal/schema/voices_test.go @@ -188,16 +188,17 @@ func TestVoices_KeptAndMetBranchesHoldAtEveryOutcome(t *testing.T) { // variant of the Union a distributed union lowers it to. func composedModels(t *testing.T, doc *ir.Document) []*ir.Model { t.Helper() - u, distributed := typeByName(doc, "S").(*ir.Union) + s := typeByName(doc, "S") + u, distributed := s.(*ir.Union) if !distributed { - m, ok := typeByName(doc, "S").(*ir.Model) - require.True(t, ok) + m, ok := s.(*ir.Model) + require.True(t, ok, "S lowered to a %T, want a Model or a Union", s) return []*ir.Model{m} } out := make([]*ir.Model, 0, len(u.Variants)) for _, v := range u.Variants { m, ok := doc.Types[v.Type.Target].(*ir.Model) - require.True(t, ok) + require.True(t, ok, "variant %s lowered to a %T, want a Model", v.Type.Target, doc.Types[v.Type.Target]) out = append(out, m) } return out