From 1b3e5368a4d2fb0973db80fa663b93ce4c3a0a6f Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Sat, 5 Sep 2026 19:49:35 +0300 Subject: [PATCH 01/13] fix(compilers/openapi): detect a version key past the sniff cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect capped its search for `openapi`/`swagger` at the first 64 KiB, so a valid document that writes a large object before its version key was reported as an unrecognized format. Stripe's published spec3.json is one: `components` runs to megabytes and `openapi` lands at byte 2,593,401. Mapping key order carries no meaning, so the same document with its keys the other way round compiled fine — the format answer depended on where a writer put a key. The 64 KiB prefix keeps its place as the fast path, and every document that declares a key there is still answered without a full parse. When the prefix declares neither key, a byte scan of the whole source decides whether to read it whole: only bytes that name `openapi:` or `swagger:` as a top-level key reach the parse, so a source of another format still gets the fast path's silence and never a complaint from this compiler. That scan is what Detect already used to tell its own broken source from another format's, and it is no longer bounded to the prefix either. A document whose prefix does not parse and whose declaration sits past the cap is now reported as an undecodable OpenAPI source rather than declined, which is the answer the surrounding rule always intended; detection now reads the bytes it would have had to read to say otherwise. Fixes #420 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1 --- compilers/openapi/detect.go | 100 ++++++++++++++++-------- compilers/openapi/detect_test.go | 129 +++++++++++++++++++++++++------ 2 files changed, 173 insertions(+), 56 deletions(-) diff --git a/compilers/openapi/detect.go b/compilers/openapi/detect.go index a5befa0a..c54fc80d 100644 --- a/compilers/openapi/detect.go +++ b/compilers/openapi/detect.go @@ -11,15 +11,20 @@ import ( "github.com/dexpace/morphic/ir" ) -// maxSniffBytes bounds the bytes Detect parses. Detection reads two top-level -// keys, and 64 KiB reaches them in any document a person wrote, so the cost of -// asking stays flat while spec size does not: a full parse of a 10 MB document -// costs hundreds of milliseconds before the compiler's own parse begins. +// maxSniffBytes bounds the prefix Detect parses on its fast path. Detection +// reads two top-level keys, and 64 KiB reaches them in any document a person +// wrote, so the cost of asking stays flat while spec size does not: a full parse +// of a 10 MB document costs hundreds of milliseconds before the compiler's own +// parse begins. It is a bound on the fast path, not on detection — a document +// whose prefix declares neither key while its bytes name one is read whole, per +// sniffWhole. const maxSniffBytes = 64 << 10 -// maxSniffEntries bounds the top-level entries read from a flow-style prefix. -// The keys being looked for are declared among a document's first few, and a -// prefix full of nothing else is not one this compiler will take. +// maxSniffEntries bounds the top-level entries read from a flow-style mapping. +// A document declares few top-level keys however large it grows, so a mapping +// that runs past this without naming either key is not one this compiler will +// take. The bound is on entries, not bytes: one of them may be megabytes long, +// which is the whole reason the byte cap alone does not answer the question. const maxSniffEntries = 512 // sniffProbe holds the two discriminating top-level keys. Which one is present @@ -66,16 +71,16 @@ func (*Compiler) Detect(src compilers.Source) (compilers.SourceFormat, []ir.Diag } // declaresProbeKey reports whether data names one of the discriminating keys as -// a top-level key. It is what separates a source of this compiler's own that -// will not parse from one of another format that was never its business: a -// parse failure alone says only "not YAML", which a Protobuf or Smithy source -// is not either. +// a top-level key. It is what separates a source of this compiler's own from one +// of another format that was never its business, and it is asked twice: before +// sniff parses a large document whole, and after a parse failed, where "not +// YAML" alone says only what a Protobuf or Smithy source would also say. // -// Only the bounded prefix is read, for the reason sniff bounds its own reads. +// The whole of data is read. A byte scan costs a fraction of the parse it stands +// in front of, and the key it looks for is exactly the one that can sit +// megabytes into a document — bounding this to the prefix would blind it in +// precisely the case it exists to catch. func declaresProbeKey(data []byte) bool { - if len(data) > maxSniffBytes { - data = data[:maxSniffBytes] - } return declaresKey(data, "openapi") || declaresKey(data, "swagger") } @@ -110,26 +115,58 @@ func followedByColon(data, name []byte) bool { } } -// sniff reads the discriminating keys out of at most maxSniffBytes bytes, and -// returns the zero probe and the parser's error for anything it cannot read. -// Whether that error is worth reporting is Detect's question, not this one's: -// here it is only the record of what happened. +// sniff reads the discriminating keys out of data, and returns the zero probe +// and the parser's error for anything it cannot read. Whether that error is +// worth reporting is Detect's question, not this one's: here it is only the +// record of what happened. // -// A document within the cap is decoded whole and exactly. A larger one is -// decoded from a prefix, which cannot simply be cut: flow style — JSON is the -// common case — is one token stream with no line structure, so its entries are -// streamed instead, and block style is cut at its last complete line. +// A document within the cap is decoded whole and exactly. A larger one is read +// from its prefix first, and only from all of itself when that prefix answered +// nothing and the bytes past it name a key this compiler serves. func sniff(data []byte) (sniffProbe, error) { if len(data) <= maxSniffBytes { return decodeYAML(data) } - prefix := data[:maxSniffBytes] - if probe, ok := decodeFlowPrefix(prefix); ok { + + probe, err := sniffPrefix(data[:maxSniffBytes]) + if probe.OpenAPI != "" || probe.Swagger != "" { + return probe, nil + } + if declaresProbeKey(data) { + return sniffWhole(data) + } + return probe, err +} + +// sniffPrefix reads the probe keys from the first maxSniffBytes of a document +// too large to decode whole. The prefix cannot simply be cut: flow style — JSON +// is the common case — is one token stream with no line structure, so its +// entries are streamed instead, and block style is cut at its last complete +// line. +func sniffPrefix(prefix []byte) (sniffProbe, error) { + if probe, ok := decodeFlowEntries(prefix); ok { return probe, nil } return decodeYAML(wholeLines(prefix)) } +// sniffWhole reads the probe keys from a whole document past the cap, for the +// one case that earns the parse: the prefix declared neither key, yet the bytes +// name one further in. Mapping key order carries no meaning, so a document that +// writes a multi-megabyte `components` before its `openapi` is as valid as one +// that writes them the other way round, and declining it would reject a valid +// document over nothing. +// +// Nothing another format wrote reaches here — declaresProbeKey guards the call — +// so the cost is paid only for bytes this compiler is about to parse in full +// anyway, and the answer for everyone else is still the fast path's silence. +func sniffWhole(data []byte) (sniffProbe, error) { + if probe, ok := decodeFlowEntries(data); ok { + return probe, nil + } + return decodeYAML(data) +} + // decodeYAML reads the probe keys from a complete YAML (or JSON, its subset) // document. func decodeYAML(data []byte) (sniffProbe, error) { @@ -140,12 +177,13 @@ func decodeYAML(data []byte) (sniffProbe, error) { return probe, nil } -// decodeFlowPrefix reads the top-level entries of a prefix that opens a flow -// mapping, and reports whether it was one. The JSON decoder is used because it -// streams: a prefix cut mid-document still yields every entry it completed, -// where decoding those same bytes whole reports only that they end early. -func decodeFlowPrefix(prefix []byte) (sniffProbe, bool) { - dec := json.NewDecoder(bytes.NewReader(prefix)) +// decodeFlowEntries reads the top-level entries of data, which may be a whole +// document or a prefix of one, and reports whether it opened a flow mapping. The +// JSON decoder is used because it streams: a prefix cut mid-document still +// yields every entry it completed, where decoding those same bytes whole reports +// only that they end early. +func decodeFlowEntries(data []byte) (sniffProbe, bool) { + dec := json.NewDecoder(bytes.NewReader(data)) tok, err := dec.Token() if err != nil || tok != json.Delim('{') { return sniffProbe{}, false diff --git a/compilers/openapi/detect_test.go b/compilers/openapi/detect_test.go index c2bc3063..7523650e 100644 --- a/compilers/openapi/detect_test.go +++ b/compilers/openapi/detect_test.go @@ -1,6 +1,7 @@ package openapi import ( + "fmt" "strings" "testing" @@ -65,19 +66,19 @@ func TestDetect_Formats(t *testing.T) { // key, so the parse error describes a parser that was wrong to be asked. {"unparseable, key only mentioned", "svc.proto", "syntax = \"openapi\";\n{[", compilers.SourceFormat{}, false, nil}, - // Past the sniff cap and still this compiler's: the key search reads the - // same bounded prefix the decode did, so a document too large to parse in - // full is still recognized as broken rather than as somebody else's. + // Past the sniff cap and still this compiler's: the key it declares is in + // the prefix, so the fast path alone is enough to call it broken rather + // than somebody else's. {"unparseable past the cap", "api.yaml", padTo("openapi: [unterminated\n", "filler: x\n"), compilers.SourceFormat{}, false, []string{diag.UndecodableSource}}, // Declares the key only past the cap, on a prefix that does not parse. The - // key search reads the same bounded prefix the decode did and so does not - // see it either; claiming the source would assert something about bytes - // detection never read. + // key search reads every byte, so the declaration is found and the source + // is this compiler's own — broken, and said so, rather than declined as + // somebody else's for want of looking. {"key past the cap on an unparseable prefix", "api.yaml", padTo("bad: [unterminated\n", "filler: x\n") + "openapi: 3.1.0\n", - compilers.SourceFormat{}, false, nil}, + compilers.SourceFormat{}, false, []string{diag.UndecodableSource}}, {"empty", "empty.yaml", "", compilers.SourceFormat{}, false, nil}, } for _, tc := range cases { @@ -92,8 +93,54 @@ func TestDetect_Formats(t *testing.T) { } } -// padTo returns src grown past the sniff cap by appending filler, so sniff takes -// its bounded-prefix path rather than decoding the source whole. +// TestDetect_KeyOrderDoesNotDecideTheFormat is the shape this bound was getting +// wrong: a published spec whose `components` object runs to megabytes and whose +// `openapi` key sits behind it. A JSON object's keys are unordered, so the same +// document written with its version key first and with it last is one document, +// and detection has to answer the same for both. Only the version-last spellings +// go past the prefix — they are the cases a prefix-only sniff declines. +func TestDetect_KeyOrderDoesNotDecideTheFormat(t *testing.T) { + t.Parallel() + flow, block := bigComponents() + cases := []struct{ name, path, src string }{ + {"flow json, version first", "spec3.json", `{"openapi":"3.0.3",` + flow + `}`}, + {"flow json, version last", "spec3.json", `{` + flow + `,"openapi":"3.0.3"}`}, + {"block yaml, version first", "api.yaml", "openapi: 3.0.3\n" + block}, + {"block yaml, version last", "api.yaml", block + "openapi: 3.0.3\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Greater(t, len(tc.src), maxSniffBytes, "the case must exceed the cap to test it") + got, diags, ok := New().Detect(compilers.Source{Path: tc.path, Data: []byte(tc.src)}) + assert.True(t, ok, "a valid document must not be declined over where it declares its version") + assert.Equal(t, compilers.SourceFormat{Name: "openapi", Version: "3.0"}, got) + assert.Nil(t, codesOf(diags), "a document this compiler recognizes carries no complaint") + }) + } +} + +// bigComponents returns a `components` entry whose value alone runs past the +// sniff cap, in flow and in block style. It stands in for the schema catalogue a +// published spec leads with; what matters is only that it is one entry too large +// to read past. +func bigComponents() (flow, block string) { + var f, b strings.Builder + f.WriteString(`"components":{"schemas":{`) + b.WriteString("components:\n schemas:\n") + for i := 0; f.Len() <= maxSniffBytes || b.Len() <= maxSniffBytes; i++ { + if i > 0 { + f.WriteByte(',') + } + fmt.Fprintf(&f, `"S%d":{"type":"object","description":"a schema"}`, i) + fmt.Fprintf(&b, " S%d:\n type: object\n description: a schema\n", i) + } + f.WriteString(`}}`) + return f.String(), b.String() +} + +// padTo returns src grown past the sniff cap by appending filler, so sniff reads +// a prefix first rather than decoding the source whole on sight. func padTo(src, filler string) string { var b strings.Builder b.WriteString(src) @@ -103,13 +150,16 @@ func padTo(src, filler string) string { return b.String() } -// TestSniff_BeyondTheCap pins what the bound buys and what it costs. A document -// larger than the cap is read from its first maxSniffBytes in whichever style it -// is written, and a declaration past that point is not seen — detection stays -// flat in document size rather than paying a full parse to read two keys. +// TestSniff_BeyondTheCap pins both paths a document larger than the cap can +// take. The prefix answers on its own whenever it names a key, in whichever +// style the document is written; when it names neither, a document whose bytes +// name one further in is read whole rather than declined, because where a writer +// put a key in a mapping says nothing about what the document is. Bytes that +// name neither key anywhere never leave the prefix. func TestSniff_BeyondTheCap(t *testing.T) { t.Parallel() const filler = "# a line of padding that says nothing about the format\n" + pad := strings.Repeat("p", maxSniffBytes) cases := []struct { name, src string want sniffProbe @@ -117,20 +167,24 @@ func TestSniff_BeyondTheCap(t *testing.T) { {"block yaml declaring first", padTo("openapi: 3.1.0\n", filler), sniffProbe{OpenAPI: "3.1.0"}}, {"block yaml declaring past the cap", - padTo("", filler) + "openapi: 3.1.0\n", sniffProbe{}}, + padTo("", filler) + "openapi: 3.1.0\n", sniffProbe{OpenAPI: "3.1.0"}}, {"flow json declaring first", - `{"openapi":"3.1.0","x":"` + strings.Repeat("p", maxSniffBytes) + `"}`, - sniffProbe{OpenAPI: "3.1.0"}}, + `{"openapi":"3.1.0","x":"` + pad + `"}`, sniffProbe{OpenAPI: "3.1.0"}}, {"flow json declaring past the cap", - `{"x":"` + strings.Repeat("p", maxSniffBytes) + `","openapi":"3.1.0"}`, - sniffProbe{}}, + `{"x":"` + pad + `","openapi":"3.1.0"}`, sniffProbe{OpenAPI: "3.1.0"}}, {"flow json swagger first", - `{"swagger":"2.0","x":"` + strings.Repeat("p", maxSniffBytes) + `"}`, - sniffProbe{Swagger: "2.0"}}, + `{"swagger":"2.0","x":"` + pad + `"}`, sniffProbe{Swagger: "2.0"}}, + {"flow json swagger past the cap", + `{"x":"` + pad + `","swagger":"2.0"}`, sniffProbe{Swagger: "2.0"}}, // Neither YAML nor JSON, and larger than the cap: the prefix is parsed, // fails, and the answer is silence rather than a parser's complaint. {"protobuf past the cap", padTo("syntax = \"proto3\";\n", "message M { string a = 1; }\n"), sniffProbe{}}, + // The word is there past the cap and is not a key, so the whole read is + // never reached — asserted on the guard itself below, since the probe a + // whole read would return here is the zero one either way. + {"the word past the cap is not a key", + `{"x":"` + pad + `","note":"openapi"}`, sniffProbe{}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -143,7 +197,32 @@ func TestSniff_BeyondTheCap(t *testing.T) { } } -func TestDecodeFlowPrefix_ReadsWhatTheCutLeft(t *testing.T) { +// TestDeclaresProbeKey_GuardsTheWholeRead pins the one decision that keeps a +// document of another format off the slow path: the whole of a source is scanned +// for a key, and only a declaration — the name with the colon that makes it one +// — counts as having found it. +func TestDeclaresProbeKey_GuardsTheWholeRead(t *testing.T) { + t.Parallel() + pad := strings.Repeat("p", maxSniffBytes) + cases := []struct { + name, src string + want bool + }{ + {"declared past the cap in flow style", `{"x":"` + pad + `","openapi":"3.1.0"}`, true}, + {"declared past the cap in block style", "x: " + pad + "\nswagger: \"2.0\"\n", true}, + {"named past the cap as a value", `{"x":"` + pad + `","note":"openapi"}`, false}, + {"named past the cap in prose", "x: " + pad + "\n# openapi is a format\n", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Greater(t, len(tc.src), maxSniffBytes, "the case must exceed the cap to test it") + assert.Equal(t, tc.want, declaresProbeKey([]byte(tc.src))) + }) + } +} + +func TestDecodeFlowEntries_ReadsWhatTheCutLeft(t *testing.T) { t.Parallel() cases := []struct { name, prefix string @@ -166,17 +245,17 @@ func TestDecodeFlowPrefix_ReadsWhatTheCutLeft(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, flow := decodeFlowPrefix([]byte(tc.prefix)) + got, flow := decodeFlowEntries([]byte(tc.prefix)) assert.Equal(t, tc.wantFlow, flow) assert.Equal(t, tc.want, got) }) } } -// TestDecodeFlowPrefix_StopsAtTheEntryCap proves the walk is bounded by its own +// TestDecodeFlowEntries_StopsAtTheEntryCap proves the walk is bounded by its own // count and not only by the byte cap: a declaration after maxSniffEntries other // entries is not read. -func TestDecodeFlowPrefix_StopsAtTheEntryCap(t *testing.T) { +func TestDecodeFlowEntries_StopsAtTheEntryCap(t *testing.T) { t.Parallel() var b strings.Builder b.WriteByte('{') @@ -192,7 +271,7 @@ func TestDecodeFlowPrefix_StopsAtTheEntryCap(t *testing.T) { } b.WriteString(`,"openapi":"3.1.0"}`) - got, flow := decodeFlowPrefix([]byte(b.String())) + got, flow := decodeFlowEntries([]byte(b.String())) require.True(t, flow) assert.Equal(t, sniffProbe{}, got, "the entry past the cap is not read") } From 8a42a7fbf9c127a19d9952e90f016a5a30473a28 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Sat, 5 Sep 2026 20:04:39 +0300 Subject: [PATCH 02/13] fix(compilers/openapi): keep a conflicting redeclaration's losing type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When allOf branches declare one field with incompatible types the merge keeps the first declaration and warns, naming both pointers. The losing declaration was then dropped: it reached the IR in no form at all, so a consumer reading the document rather than the diagnostic stream saw no trace of it — and a diff across two revisions in which only the losing branch's type moved reported no change. GitHub's published spec writes this shape 102 times. Every other degradation in this compiler keeps what it could not model. This one now does too: the discarded ir.TypeRef is written to the merged property's Unmodeled under ReasonDegradedLowering, keyed by the redeclaration's own pointer so sibling branches never overwrite one another, and stamped with the losing declaration's provenance. The constraint half of the same diagnostic is deliberately left alone. It also discards the redeclaration's keyword, but the recorded direction there is to intersect the bounds so the merged field satisfies both branches (#10), and preserving the loser instead would settle a decision that already has one. The code comment on keepLosingType says so. Fixes #424 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1 --- compilers/openapi/conformance_test.go | 1 + .../openapi/conformance_unmodeled_test.go | 42 ++++ compilers/openapi/internal/diag/diag.go | 6 + compilers/openapi/internal/merge/merge.go | 53 ++++- .../internal/merge/reconcile_internal_test.go | 106 +++++++++ .../openapi/internal/schema/compose_test.go | 10 + .../allof-conflicting-type.golden.json | 215 ++++++++++++++++++ .../openapi/allof-conflicting-type.yaml | 26 +++ 8 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 testdata/conformance/openapi/allof-conflicting-type.golden.json create mode 100644 testdata/conformance/openapi/allof-conflicting-type.yaml diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index bc05c2e9..327a768a 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -170,6 +170,7 @@ func conformanceCases() []conformanceCase { {"allof-inline-residue", assertAllOfInlineResidue, []string{"intersection"}}, {"allof-ref-branch-siblings", assertAllOfRefBranchSiblings, []string{"intersection", "untagged-unions"}}, {"allof-boolean-branch", assertAllOfBooleanBranch, []string{"intersection"}}, + {"allof-conflicting-type", assertAllOfConflictingType, nil}, {"oneof-discriminated", assertOneOfDiscriminated, []string{"tagged-unions"}}, {"discriminator-inheritance", assertDiscriminatorInheritance, []string{"tagged-unions", "inheritance"}}, {"discriminator-default-mapping", assertDiscriminatorDefaultMapping, []string{"tagged-unions"}}, diff --git a/compilers/openapi/conformance_unmodeled_test.go b/compilers/openapi/conformance_unmodeled_test.go index 9ec4d106..10ec29fb 100644 --- a/compilers/openapi/conformance_unmodeled_test.go +++ b/compilers/openapi/conformance_unmodeled_test.go @@ -251,6 +251,48 @@ func assertAllOfInlineResidue(t *testing.T, doc *ir.Document, diags []ir.Diagnos "a branch excluding object contradicts the composed model and is a warning") } +// assertAllOfConflictingType pins what an unsatisfiable redeclaration leaves in +// the document. allOf is an intersection, so a field one branch types `uri` and +// another types `string` describes a shape the IR has no combinator for: the +// merge keeps the first declaration and, under ir-design §4.8, keeps the loser +// verbatim beside it rather than dropping it (GitHub #424). +// +// The diagnostic is not what is being checked here. A consumer that diffs two +// revisions of a document reads the document, and before this entry existed a +// release in which the losing branch's type changed showed no change at all. +// +// The nullable case is the second half: a redeclaration says both what a field +// is and whether it admits null, so an entry keeping only the target ID would +// still be losing half of what it claims to preserve. +func assertAllOfConflictingType(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { + repo, ok := doc.Types[namedID("Repository")].(*ir.Model) + require.True(t, ok) + clone, ok := propByWire(repo, "clone_url") + require.True(t, ok, "the two declarations still reconcile to one property") + assert.Equal(t, ir.TypeID("t/prim/url"), clone.Type.Target, "the first declaration wins the shape") + + entry := unmodeledEntry(t, clone.Unmodeled, + "openapi:conflicting-redeclaration/components/schemas/Repository/allOf/1/properties/clone_url") + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.JSONEq(t, `{"target":"t/prim/string","nullable":false}`, string(entry.Value)) + assert.Equal(t, "/components/schemas/Repository/allOf/1/properties/clone_url", entry.Provenance.Pointer, + "the entry locates the losing declaration, not the merged property") + assert.Equal(t, []ir.Severity{ir.SeverityWarning}, + diagsAt(diags, "openapi/conflicting-redeclaration", + "/components/schemas/Repository/allOf/1/properties/clone_url"), + "and the conflict is still reported") + + identified, ok := doc.Types[namedID("Identified")].(*ir.Model) + require.True(t, ok) + id, ok := propByWire(identified, "id") + require.True(t, ok) + assert.Equal(t, ir.TypeID("t/prim/integer"), id.Type.Target) + entry = unmodeledEntry(t, id.Unmodeled, + "openapi:conflicting-redeclaration/components/schemas/Identified/allOf/1/properties/id") + assert.JSONEq(t, `{"target":"t/prim/string","nullable":true}`, string(entry.Value), + "a nullable loser keeps its nullability, which the target ID alone would drop") +} + // assertAllOfRefBranchSiblings covers the other branch kind: keywords written // beside a `$ref` in an allOf branch bind that branch, not the schema it names, // so they cannot go on the shared target's node. The branch position gets a node diff --git a/compilers/openapi/internal/diag/diag.go b/compilers/openapi/internal/diag/diag.go index 80d7a0ec..7b6f2ba5 100644 --- a/compilers/openapi/internal/diag/diag.go +++ b/compilers/openapi/internal/diag/diag.go @@ -144,6 +144,12 @@ const ( // representable by a simple merge, so the merge keeps an arbitrary // source-order winner — possibly the looser bound — and surfaces the // disagreement instead of silently discarding it. + // + // The type half additionally keeps the losing reference under the merged + // property's Unmodeled, so it survives for a consumer reading the document + // rather than the diagnostic stream (merge.keepLosingType). The constraint + // half does not: the recorded direction there is to intersect the bounds + // (GitHub #10), so this code alone still carries it. ConflictingRedecl = "openapi/conflicting-redeclaration" // DisjointVisibility reports one field restricted to lifecycle sets that // share nothing — readOnly against writeOnly — so no lifecycle admits it at diff --git a/compilers/openapi/internal/merge/merge.go b/compilers/openapi/internal/merge/merge.go index 36ad0afc..373a4866 100644 --- a/compilers/openapi/internal/merge/merge.go +++ b/compilers/openapi/internal/merge/merge.go @@ -9,6 +9,7 @@ package merge import ( "cmp" + "encoding/json" "fmt" "slices" @@ -72,7 +73,10 @@ func (g *Merger) MergeProperty(m *ir.Model, byWire map[string]int, p ir.Property // differs between branches, an incompatible type, or a contradictory constraint // keyword are genuine conflicts the merge cannot represent (see // diag.ConflictingRedecl); each is diagnosed before any detail is folded in, -// rather than silently picking an arbitrary winner. +// rather than silently picking an arbitrary winner. The incompatible type is +// additionally kept beside the winner under Unmodeled (keepLosingType), so the +// discarded declaration survives in the document and not only in the diagnostic +// stream. func (g *Merger) reconcileProperty(dst *ir.Property, src ir.Property, pointer string) { g.diagnoseRedeclarationConflict(dst, &src, pointer) @@ -239,6 +243,7 @@ const maxTypeResolveDepth = 64 // subsumes any constraint conflict. func (g *Merger) diagnoseRedeclarationConflict(dst, src *ir.Property, pointer string) { if g.typesConflict(dst.Type, src.Type) { + keepLosingType(dst, src, pointer) g.redeclarationConflictDiag(dst, pointer, fmt.Sprintf("incompatible types %s and %s", dst.Type.Target, src.Type.Target)) return @@ -248,6 +253,52 @@ func (g *Merger) diagnoseRedeclarationConflict(dst, src *ir.Property, pointer st } } +// losingTypeKey prefixes the Unmodeled entry a discarded redeclaration type is +// kept under. The "openapi:" namespace is what keeps two source formats' keys +// from colliding on one node (ir-design §12); the redeclaration's own pointer +// completes it, the way an allOf branch's index completes the composition's +// keys. +const losingTypeKey = "openapi:conflicting-redeclaration" + +// keepLosingType keeps the redeclaration's discarded type beside the merged +// property, so a consumer reading the document rather than the diagnostic stream +// can still see what the losing declaration said (GitHub #424). Every other +// degradation in this compiler preserves what it could not model; this path was +// the exception. +// +// ReasonDegradedLowering is the reason: the IR has no combinator for "string +// here, integer there", so the pair is lowered to the weaker shape of the first +// declaration with the original kept beside it, which is that reason's own +// definition (ir-design §4.8). Not ReasonNoIRHome — the position has a field and +// it is holding the winner, so nothing is waiting on an IR gap to close; not +// ReasonValidationOnly, since a type is data shape rather than validation. +// +// The value is the discarded ir.TypeRef rather than the branch's source schema: +// this package never sees the document by design (see the package comment), and +// the reference is the whole of what was dropped — Nullable included, which the +// target ID alone would lose. Writing back an IR value already read rather than +// re-reading a raw node is what annotation's redundant-bound preservation does +// too. +// +// The redeclaration's pointer is part of the key rather than only of the +// provenance, so a field three branches type three incompatible ways keeps all +// three entries; a fixed key would leave whichever branch ran last. Provenance +// locates the losing declaration itself, which is the entry's own position and +// not the merged property's. +// +// Only the type is kept here. A constraint conflict discards the redeclaration's +// keyword too, but the recorded direction for that is to intersect the bounds so +// the merged field satisfies both branches (GitHub #10), and preserving the +// loser instead would settle a decision that already has one. +func keepLosingType(dst, src *ir.Property, pointer string) { + // A TypeID string and a bool: json.Marshal fails on neither, and rewrites + // ill-formed UTF-8 rather than refusing it, so the error it declares is + // discarded the way annotation.jsonString discards it for a key. + raw, _ := json.Marshal(src.Type) + annotation.PreserveInto(&dst.Unmodeled, losingTypeKey+pointer, raw, + ir.ReasonDegradedLowering, pointer, src.Provenance.Source) +} + // redeclarationConflictDiag emits the shared conflicting-redeclaration warning, // naming the field and both declaration sites (dst's own, and the redeclaration // at pointer). detail is the caller-formatted disagreement — two type IDs, or a diff --git a/compilers/openapi/internal/merge/reconcile_internal_test.go b/compilers/openapi/internal/merge/reconcile_internal_test.go index e9a88eea..cce93752 100644 --- a/compilers/openapi/internal/merge/reconcile_internal_test.go +++ b/compilers/openapi/internal/merge/reconcile_internal_test.go @@ -392,3 +392,109 @@ func TestDifferentTypeKind_ComparesResolvedKinds(t *testing.T) { assert.True(t, g.typesConflict(ir.TypeRef{Target: "t/opaque"}, ir.TypeRef{Target: "t/union"}), "and typesConflict reaches that comparison when neither side is a primitive") } + +// TestKeepLosingType_RecordsTheDiscardedDeclaration pins what the fix for +// GitHub #424 adds: the redeclaration whose type loses is kept verbatim beside +// the winner, so a consumer reading the document — not the diagnostic stream — +// still sees what the second declaration said. +// +// The reference is asserted whole rather than by its target alone: Nullable is +// half of what a redeclaration says about a field, and an entry that kept only +// the ID would lose it while still looking like a preservation. +func TestKeepLosingType_RecordsTheDiscardedDeclaration(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(map[ir.TypeID]ir.TypeDef{ + "t/str": &ir.Primitive{TypeCommon: ir.TypeCommon{ID: "t/str"}, Prim: ir.PrimString}, + "t/int": &ir.Primitive{TypeCommon: ir.TypeCommon{ID: "t/int"}, Prim: ir.PrimInt32}, + }) + dst := ir.Property{ + WireName: "id", + Type: ir.TypeRef{Target: "t/str"}, + Provenance: ir.Provenance{Source: 2, Pointer: "/components/schemas/S/allOf/0/properties/id"}, + } + src := ir.Property{ + WireName: "id", + Type: ir.TypeRef{Target: "t/int", Nullable: true}, + Provenance: ir.Provenance{Source: 2, Pointer: "/components/schemas/S/allOf/1/properties/id"}, + } + + g.reconcileProperty(&dst, src, "/components/schemas/S/allOf/1/properties/id") + + require.Len(t, *recorded, 1, "the conflict is still diagnosed") + assert.Equal(t, ir.TypeID("t/str"), dst.Type.Target, "the first declaration still wins the shape") + + key := "openapi:conflicting-redeclaration/components/schemas/S/allOf/1/properties/id" + entry, ok := dst.Unmodeled[key] + require.True(t, ok, "the losing declaration is kept under a pointer-namespaced key; got %v", dst.Unmodeled) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.JSONEq(t, `{"target":"t/int","nullable":true}`, string(entry.Value), + "the whole reference is kept, nullability included") + assert.Equal(t, ir.Provenance{Source: 2, Pointer: "/components/schemas/S/allOf/1/properties/id"}, + entry.Provenance, "the entry locates the losing declaration, not the merged property") +} + +// TestKeepLosingType_EveryLoserSurvivesItsSiblings pins the key's namespacing. +// A field three branches type three incompatible ways discards two +// declarations, and a fixed key would leave only whichever branch ran last — +// the same silent overwrite the entry exists to prevent. +func TestKeepLosingType_EveryLoserSurvivesItsSiblings(t *testing.T) { + t.Parallel() + g, _ := stubMerger(map[ir.TypeID]ir.TypeDef{ + "t/str": &ir.Primitive{TypeCommon: ir.TypeCommon{ID: "t/str"}, Prim: ir.PrimString}, + "t/int": &ir.Primitive{TypeCommon: ir.TypeCommon{ID: "t/int"}, Prim: ir.PrimInt32}, + "t/bool": &ir.Primitive{TypeCommon: ir.TypeCommon{ID: "t/bool"}, Prim: ir.PrimBool}, + }) + m := &ir.Model{} + byWire := WireNameIndex(m.Properties) + + g.MergeProperty(m, byWire, ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/str"}}, "/a") + g.MergeProperty(m, byWire, ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/int"}}, "/b") + g.MergeProperty(m, byWire, ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/bool"}}, "/c") + + require.Len(t, m.Properties, 1, "three declarations still reconcile to one property") + assert.Equal(t, ir.Unmodeled{ + "openapi:conflicting-redeclaration/b": { + Reason: ir.ReasonDegradedLowering, + Value: ir.RawValue(`{"target":"t/int","nullable":false}`), + Provenance: ir.Provenance{Pointer: "/b"}, + }, + "openapi:conflicting-redeclaration/c": { + Reason: ir.ReasonDegradedLowering, + Value: ir.RawValue(`{"target":"t/bool","nullable":false}`), + Provenance: ir.Provenance{Pointer: "/c"}, + }, + }, m.Properties[0].Unmodeled) +} + +// TestKeepLosingType_LeavesAgreeingAndConstraintOnlyConflictsAlone pins the two +// sides the entry must not appear on. Agreeing declarations discard nothing, so +// an entry would be noise; a constraint conflict does discard the +// redeclaration's keyword, but the recorded direction there is to intersect the +// bounds rather than preserve the loser (GitHub #10), so preserving it here +// would settle a decision that already has one. +func TestKeepLosingType_LeavesAgreeingAndConstraintOnlyConflictsAlone(t *testing.T) { + t.Parallel() + g, recorded := stubMerger(map[ir.TypeID]ir.TypeDef{ + "t/str": &ir.Primitive{TypeCommon: ir.TypeCommon{ID: "t/str"}, Prim: ir.PrimString}, + }) + ten, twenty := int64(10), int64(20) + agreeing := ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/str"}} + + g.reconcileProperty(&agreeing, ir.Property{WireName: "id", Type: ir.TypeRef{Target: "t/str"}}, "/b") + + assert.Empty(t, *recorded, "agreeing declarations are not a conflict") + assert.Empty(t, agreeing.Unmodeled, "and nothing was discarded to keep") + + bounded := ir.Property{ + WireName: "code", Type: ir.TypeRef{Target: "t/str"}, + Constraints: &ir.Constraints{MaxLength: &ten}, + } + g.reconcileProperty(&bounded, ir.Property{ + WireName: "code", Type: ir.TypeRef{Target: "t/str"}, + Constraints: &ir.Constraints{MaxLength: &twenty}, + }, "/b") + + require.Len(t, *recorded, 1, "the constraint conflict is still diagnosed") + assert.Equal(t, diag.ConflictingRedecl, (*recorded)[0].Code) + assert.Empty(t, bounded.Unmodeled, "a constraint conflict keeps no type entry") +} diff --git a/compilers/openapi/internal/schema/compose_test.go b/compilers/openapi/internal/schema/compose_test.go index 28810922..1f626e0b 100644 --- a/compilers/openapi/internal/schema/compose_test.go +++ b/compilers/openapi/internal/schema/compose_test.go @@ -180,6 +180,16 @@ func TestAllOf_ConflictingRedeclaredTypeDiagnosed(t *testing.T) { assert.Contains(t, d.Message, `"id"`, "the diagnostic names the conflicting field") assert.Contains(t, d.Message, "allOf/0", "the diagnostic names the first branch site") assert.Contains(t, d.Message, "allOf/1", "the diagnostic names the second branch site") + + // A diagnostic is not part of the document, so the losing declaration is + // also kept where a consumer reading the IR will reach it (GitHub #424). + lost := m.Properties[0].Unmodeled["openapi:conflicting-redeclaration"+ + "/components/schemas/Conflictish/allOf/1/properties/id"] + assert.Equal(t, ir.ReasonDegradedLowering, lost.Reason, + "the discarded type is kept beside the winner; got %v", m.Properties[0].Unmodeled) + assert.JSONEq(t, `{"target":"t/prim/integer","nullable":false}`, string(lost.Value), + "and it names the type the second branch declared, not the one that won") + assert.Equal(t, "/components/schemas/Conflictish/allOf/1/properties/id", lost.Provenance.Pointer) } func TestAllOf_ConflictingRedeclaredConstraintDiagnosed(t *testing.T) { diff --git a/testdata/conformance/openapi/allof-conflicting-type.golden.json b/testdata/conformance/openapi/allof-conflicting-type.golden.json new file mode 100644 index 00000000..65e7f546 --- /dev/null +++ b/testdata/conformance/openapi/allof-conflicting-type.golden.json @@ -0,0 +1,215 @@ +{ + "irVersion": "0.3.0", + "name": "AllOfConflictingType", + "version": "1.0.0", + "docs": {}, + "services": [ + { + "id": "s/openapi/0", + "name": { + "source": "AllOfConflictingType", + "canonical": "all_of_conflicting_type" + }, + "docs": {}, + "auth": null, + "provenance": { + "source": 0 + } + } + ], + "types": { + "t/openapi/components/schemas/Identified": { + "kind": "model", + "id": "t/openapi/components/schemas/Identified", + "name": { + "source": "Identified", + "canonical": "identified" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Identified" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Identified/allOf/0/properties/id", + "name": { + "source": "id", + "canonical": "id" + }, + "wireName": "id", + "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": {}, + "unmodeled": { + "openapi:conflicting-redeclaration/components/schemas/Identified/allOf/1/properties/id": { + "reason": "degraded_lowering", + "value": { + "target": "t/prim/string", + "nullable": true + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Identified/allOf/1/properties/id" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Identified/allOf/0/properties/id" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/openapi/components/schemas/Repository": { + "kind": "model", + "id": "t/openapi/components/schemas/Repository", + "name": { + "source": "Repository", + "canonical": "repository" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Repository" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Repository/allOf/0/properties/clone_url", + "name": { + "source": "clone_url", + "canonical": "clone_url" + }, + "wireName": "clone_url", + "type": { + "target": "t/prim/url", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": {}, + "unmodeled": { + "openapi:conflicting-redeclaration/components/schemas/Repository/allOf/1/properties/clone_url": { + "reason": "degraded_lowering", + "value": { + "target": "t/prim/string", + "nullable": false + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Repository/allOf/1/properties/clone_url" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Repository/allOf/0/properties/clone_url" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/prim/integer": { + "kind": "primitive", + "id": "t/prim/integer", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0 + }, + "prim": "integer" + }, + "t/prim/string": { + "kind": "primitive", + "id": "t/prim/string", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0 + }, + "prim": "string" + }, + "t/prim/url": { + "kind": "primitive", + "id": "t/prim/url", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0 + }, + "prim": "url" + } + }, + "servers": [ + { + "name": { + "hint": "server" + }, + "urlTemplate": "/", + "description": {}, + "auth": null + } + ], + "diagnostics": [ + { + "severity": "warning", + "code": "openapi/conflicting-redeclaration", + "message": "declarations of field \"clone_url\" disagree: incompatible types t/prim/url and t/prim/string; kept the first declaration (/components/schemas/Repository/allOf/0/properties/clone_url) over the redeclaration (/components/schemas/Repository/allOf/1/properties/clone_url)", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Repository/allOf/1/properties/clone_url" + } + }, + { + "severity": "warning", + "code": "openapi/conflicting-redeclaration", + "message": "declarations of field \"id\" disagree: incompatible types t/prim/integer and t/prim/string; kept the first declaration (/components/schemas/Identified/allOf/0/properties/id) over the redeclaration (/components/schemas/Identified/allOf/1/properties/id)", + "provenance": { + "source": 0, + "pointer": "/components/schemas/Identified/allOf/1/properties/id" + } + } + ], + "sources": [ + { + "format": "openapi@3.1", + "path": "allof-conflicting-type.yaml", + "hash": "6b80f51a048c29fc5a1adb6b092114770c07b37140485b92da702d4cc309a3df" + } + ] +} diff --git a/testdata/conformance/openapi/allof-conflicting-type.yaml b/testdata/conformance/openapi/allof-conflicting-type.yaml new file mode 100644 index 00000000..0ad1c0b5 --- /dev/null +++ b/testdata/conformance/openapi/allof-conflicting-type.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: {title: AllOfConflictingType, version: "1.0.0"} +paths: {} +components: + schemas: + # The shape GitHub's own spec writes 102 times: one branch narrows a string + # with a format, the other leaves it bare, and the two lower to different + # primitives. The first declaration wins; the second is kept beside it. + Repository: + allOf: + - type: object + properties: + clone_url: {type: string, format: uri} + - type: object + properties: + clone_url: {type: string} + # A nullable loser, so the entry is held to keeping the whole reference + # rather than only the type it names. + Identified: + allOf: + - type: object + properties: + id: {type: integer} + - type: object + properties: + id: {type: [string, "null"]} From 2659f53366439184f97ba1dfd719ac9bdc87ab7a Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Sat, 5 Sep 2026 20:19:25 +0300 Subject: [PATCH 03/13] feat(ir)!: give Payload a Required field for body optionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request-body optionality survived only as an inverted sentinel: the OpenAPI compiler wrote Payload.Unmodeled["openapi:required"] = false when a body was not required and wrote nothing when it was, so recovering the fact meant knowing an OpenAPI-specific key and reading its absence as true. A consumer that reads typed fields alone saw every body as required — 563 times across GitHub's and Stripe's published specs. ir/unmodeled.go grades no_ir_home as "a gap expected to close, not a boundary", and this is that gap. ir.Payload now carries Required *bool. The pointer is the point: a format that expresses body optionality treats an unstated body as optional, so folding "the format is silent" onto the same value as "the document says no" would lose the distinction a non-OpenAPI compiler needs. Response and message payloads leave it nil, because only a request body can be omitted. The OpenAPI compiler always sets it, since OpenAPI's own default makes an undeclared `required` mean false rather than unstated, and it no longer writes the openapi:required entry or the info diagnostic that announced the degradation — the fact is modeled now, so neither describes anything. ir-design.md is normative on the field shapes, so §7.2's Payload and §14's OpenAPI lowering summary are updated with it. BREAKING CHANGE: a consumer reading Payload.Unmodeled["openapi:required"] must read Payload.Required instead; the Unmodeled entry and its openapi/degraded-construct info diagnostic are no longer emitted. The per-reason reachability test moves its no_ir_home witness to a parameter's allowEmptyValue, which still has no typed home. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1 --- .../openapi/internal/operation/content.go | 16 ++--- .../internal/operation/content_test.go | 66 +++++++++++++++---- .../internal/operation/operations_test.go | 13 ++-- compilers/openapi/unpreservable_test.go | 14 ++-- docs/ir-design.md | 9 ++- ir/operation.go | 8 +++ ir/operation_test.go | 30 ++++++++- .../openapi/component-reuse.golden.json | 3 +- .../openapi/extensions-x.golden.json | 1 + .../conformance/openapi/file-body.golden.json | 22 +------ .../inline-hoist-positions.golden.json | 9 ++- .../openapi/multipart-encoding.golden.json | 60 +---------------- .../openapi/path-item-operations.golden.json | 22 +------ .../openapi/streaming-media-31.golden.json | 20 +----- .../conformance/openapi/webhooks.golden.json | 20 +----- testdata/golden/openapi/petstore.golden.json | 3 +- 16 files changed, 139 insertions(+), 177 deletions(-) diff --git a/compilers/openapi/internal/operation/content.go b/compilers/openapi/internal/operation/content.go index 11f32dff..70a172ee 100644 --- a/compilers/openapi/internal/operation/content.go +++ b/compilers/openapi/internal/operation/content.go @@ -723,9 +723,10 @@ func appendValuelessExample(c lowering.Ctx, out []ir.Example, proto ir.Example, } // lowerRequestBody lowers an operation's request body onto op.Request and the -// binding's RequestContentTypes. The IR expresses body optionality via presence, -// so a non-required body stays present with its optionality preserved under -// Unmodeled plus one info diagnostic (ir-design §7.2 clarification). opDeclPtr +// binding's RequestContentTypes. Body optionality lands on Payload.Required, +// always set here because OpenAPI always states it — an undeclared `required` +// means false by the specification's own default, not silence, so leaving the +// field nil would report the format as unable to express optionality. opDeclPtr // is the operation's own declaration pointer, so a $ref'd body interns its // content once at its component pointer rather than once per mount site // (issue #107) — and under the component's name, since the operationId hint @@ -739,13 +740,8 @@ func lowerRequestBody(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorI if payload == nil { return diags } - if !rb.GetRequired() { - schema.Preserve(c, &payload.Unmodeled, "openapi:required", ir.RawValue("false"), - ir.ReasonNoIRHome, bodyPtr+ids.Ptr("required")) - - diags = append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, bodyPtr, - "request body is not required; optionality kept under Unmodeled")) - } + required := rb.GetRequired() + payload.Required = &required // soa.RequestBody exposes no GetExtensions at this library version, so the // field is read directly — as XMLHints already reads its own. Both reads sit // after the payload guard because ir.Payload is the body's only carrier: a diff --git a/compilers/openapi/internal/operation/content_test.go b/compilers/openapi/internal/operation/content_test.go index c1206eec..19ffc157 100644 --- a/compilers/openapi/internal/operation/content_test.go +++ b/compilers/openapi/internal/operation/content_test.go @@ -293,17 +293,59 @@ func TestContent_NonRequiredRequestBody(t *testing.T) { openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.NotNil(t, op.Request, "a non-required body still lowers to a present Payload") - raw, ok := op.Request.Unmodeled["openapi:required"] - require.True(t, ok, "body optionality kept under Unmodeled") - assert.Equal(t, "false", string(raw.Value)) - assert.Equal(t, ir.ReasonNoIRHome, raw.Reason) - found := false + require.NotNil(t, op.Request.Required, "OpenAPI always states body optionality") + assert.False(t, *op.Request.Required) + assert.NotContains(t, op.Request.Unmodeled, "openapi:required", + "the typed field carries the fact, so no sentinel is written beside it") for _, d := range diags { - if d.Severity == ir.SeverityInfo && strings.Contains(d.Message, "request body") { - found = true - } + assert.NotContains(t, d.Message, "request body", + "a typed fact is not a degraded construct") } - assert.True(t, found, "non-required body emits one info diagnostic") +} + +// TestContent_RequiredRequestBody is TestContent_NonRequiredRequestBody's other +// arm: `required: true` must reach the same field rather than being encoded as +// the sentinel's absence, which is what made a consumer read every body alike. +func TestContent_RequiredRequestBody(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpec(` /must: + post: + operationId: must + requestBody: + required: true + content: + application/json: {schema: {type: object, properties: {n: {type: string}}}} + responses: {"200": {description: ok}} +`) + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FirstOp(t, svc) + require.NotNil(t, op.Request) + require.NotNil(t, op.Request.Required) + assert.True(t, *op.Request.Required) +} + +// TestContent_ResponsePayloadStatesNoOptionality pins the third state: only a +// request body can be omitted, so a response Payload leaves Required nil and a +// consumer reading it as "false" would be inventing a fact. +func TestContent_ResponsePayloadStatesNoOptionality(t *testing.T) { + t.Parallel() + spec := openapitest.PathsSpec(` /get: + get: + operationId: getThing + responses: + "200": + description: ok + content: + application/json: {schema: {type: object, properties: {n: {type: string}}}} +`) + _, svc, diags := lowerServiceSpec(t, spec) + openapitest.RequireNoErrorDiags(t, diags) + op := openapitest.FirstOp(t, svc) + require.Len(t, op.Responses, 1) + require.NotNil(t, op.Responses[0].Payload) + assert.Nil(t, op.Responses[0].Payload.Required, + "a response body has no optionality to state") } func TestContent_ArrayMultipartPartMulti(t *testing.T) { @@ -405,10 +447,10 @@ func TestContent_FullPipeline(t *testing.T) { doc, diags := parseFull(t, contentSpec) upload := openapitest.FindOp(t, doc, "upload") - // Non-required body preserved as present with optionality under Unmodeled. + // Non-required body preserved as present, optionality on the typed field. require.NotNil(t, upload.Request) - _, hasReq := upload.Request.Unmodeled["openapi:required"] - assert.True(t, hasReq, "non-required optionality preserved") + require.NotNil(t, upload.Request.Required, "optionality preserved") + assert.False(t, *upload.Request.Required) // Multipart encoding: comma-split content types, header, style/explode, file flag. hb := upload.Bindings.HTTP[0] diff --git a/compilers/openapi/internal/operation/operations_test.go b/compilers/openapi/internal/operation/operations_test.go index 6fbe1343..04211af1 100644 --- a/compilers/openapi/internal/operation/operations_test.go +++ b/compilers/openapi/internal/operation/operations_test.go @@ -1222,7 +1222,7 @@ func TestResponses_RefdErrorAndDefaultInternAtDeclaration(t *testing.T) { } } -const sharedOptionalBodySpec = `openapi: 3.1.0 +const sharedDefectiveBodySpec = `openapi: 3.1.0 info: {title: T, version: "1"} paths: /a: @@ -1243,7 +1243,7 @@ components: required: false content: application/json: - schema: {type: object, properties: {n: {type: string}}} + schema: {type: string, required: [n]} responses: Err: description: err @@ -1256,13 +1256,14 @@ components: // TestDiag_SharedDeclarationReportsEachDefectOnce pins the consequence of // lowering a referenced component at its declaration: both operations reach the -// same optional body and the same header-bearing error response, so each defect +// same request body — whose scalar schema carries a `required` the lowered node +// has no field for — and the same header-bearing error response, so each defect // now has one pointer and one message. Reported per use site they would arrive // as byte-identical copies — nothing a reader could act on twice — and a // component shared by twenty operations would repeat each line twenty times. func TestDiag_SharedDeclarationReportsEachDefectOnce(t *testing.T) { t.Parallel() - _, diags := parseFull(t, sharedOptionalBodySpec) + _, diags := parseFull(t, sharedDefectiveBodySpec) seen := map[string]int{} for _, d := range diags { @@ -1274,8 +1275,8 @@ func TestDiag_SharedDeclarationReportsEachDefectOnce(t *testing.T) { // Every defect still surfaces — de-duplication must not silence any of them. assert.Equal(t, 3, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), - "the optional body, the homeless error headers and the homeless error media type "+ - "are three distinct defects") + "the body schema's homeless required, the homeless error headers and the homeless "+ + "error media type are three distinct defects") } // TestDiag_DistinctDefectsAtOnePointerBothSurvive is the control for the rule diff --git a/compilers/openapi/unpreservable_test.go b/compilers/openapi/unpreservable_test.go index 8bd341d7..778f3d10 100644 --- a/compilers/openapi/unpreservable_test.go +++ b/compilers/openapi/unpreservable_test.go @@ -263,6 +263,8 @@ paths: /p: post: operationId: p + parameters: + - {name: q, in: query, allowEmptyValue: true, schema: {type: string}} requestBody: content: {application/json: {schema: {type: string}}} responses: {"204": {description: ok}} @@ -280,11 +282,13 @@ paths: seen[entry.Reason] = true } } - // The one no_ir_home site reachable from a minimal document: a requestBody - // that omits `required`, which the IR has no field for (§14). - body := openapitest.FirstOp(t, svc).Request - require.NotNil(t, body, "the operation must own a request payload") - for _, entry := range body.Unmodeled { + // The no_ir_home witness is the query parameter's allowEmptyValue, which + // ir.HTTPParamBinding holds no field for (§14). It replaced the requestBody + // one when Payload.Required landed, so this reason now rides on a lowering + // that still has no typed home rather than on one that just grew one. + params := openapitest.FirstOp(t, svc).Params + require.Len(t, params, 1, "the operation must own the allowEmptyValue parameter") + for _, entry := range params[0].Unmodeled { seen[entry.Reason] = true } diff --git a/docs/ir-design.md b/docs/ir-design.md index 31a29cfa..f1b58b44 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1180,6 +1180,13 @@ type Parameter struct { type Payload struct { Contents []Content // one per media type / message schema — all kept + Required *bool // true = the body must be sent, false = it may be omitted; + // nil = the source format does not express body optionality. + // Three states, not two: a format that expresses it treats an + // unstated body as optional, so folding that onto nil would make + // "the format is silent" read as "the document says no". + // Response and message payloads leave it nil — only a request + // body can be omitted Unmodeled Unmodeled } @@ -1873,7 +1880,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, 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); 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); `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; requestBody/responses all content types → Payload.Contents, a non-required requestBody → Payload.Unmodeled (`no_ir_home`, presence is the IR's only body optionality); 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, error-response `headers` and its `content` map whatever its arity → ErrorCase.Unmodeled (`no_ir_home`, ErrorCase has neither field: it holds one TypeRef and no media type, so one entry loses the key it was written under just as several lose all but the 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` → `Encoding` on the scalar the position lowers to and `contentSchema` → Unmodeled (`no_ir_home`) 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 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; 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, 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); 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); `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; 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, error-response `headers` and its `content` map whatever its arity → ErrorCase.Unmodeled (`no_ir_home`, ErrorCase has neither field: it holds one TypeRef and no media type, so one entry loses the key it was written under just as several lose all but the 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` → `Encoding` on the scalar the position lowers to and `contentSchema` → Unmodeled (`no_ir_home`) 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 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; 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/ir/operation.go b/ir/operation.go index 571c32d4..5bce24fe 100644 --- a/ir/operation.go +++ b/ir/operation.go @@ -100,6 +100,14 @@ type Parameter struct { type Payload struct { // Contents holds one entry per media type / message schema — all kept. Contents []Content `json:"contents,omitempty"` + // Required states whether the message may be omitted: true = the body must + // be sent, false = it is optional. nil = the source format does not express + // body optionality at all, which is why this is a pointer — for a format + // that does, an unstated body is optional, and collapsing that onto nil + // would make "the format is silent" indistinguishable from "the document + // says no". A response or message payload leaves it nil: only a request + // body can be omitted. + Required *bool `json:"required,omitempty"` // Unmodeled holds source constructs the IR does not model, kept verbatim. Unmodeled Unmodeled `json:"unmodeled,omitempty"` } diff --git a/ir/operation_test.go b/ir/operation_test.go index a42efeb8..7d7ff698 100644 --- a/ir/operation_test.go +++ b/ir/operation_test.go @@ -155,20 +155,48 @@ func TestParameter_JSONContract(t *testing.T) { }) } -// TestPayload_JSONContract pins Payload's omitempty contract (both fields are +// TestPayload_JSONContract pins Payload's omitempty contract (every field is // optional) and that a Payload with multiple media-type contents round-trips, // all kept per the "no primary-response selection" invariant. func TestPayload_JSONContract(t *testing.T) { t.Parallel() + required := true assertJSONContract(t, ir.Payload{}, `{}`, ir.Payload{ Contents: []ir.Content{ {MediaType: "application/json", Type: populatedTypeRef()}, {MediaType: "application/xml", Type: populatedTypeRef()}, }, + Required: &required, Unmodeled: populatedUnmodeled(), }) } +// TestPayload_RequiredIsTriState pins the reason Required is a pointer: the +// three states must survive the wire as three, so a consumer never has to read +// a missing key as a value. omitempty on a *bool drops only nil, so an optional +// body still says so out loud instead of looking like a format that cannot +// express optionality at all. +func TestPayload_RequiredIsTriState(t *testing.T) { + t.Parallel() + yes, no := true, false + for _, tc := range []struct { + name string + in *bool + want string + }{ + {"unstated", nil, `{}`}, + {"optional", &no, `{"required":false}`}, + {"mandatory", &yes, `{"required":true}`}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + payload := ir.Payload{Required: tc.in} + assertZeroValueShape(t, payload, tc.want) + assertRoundTrip(t, payload) + }) + } +} + // TestContent_JSONContract pins Content's omitempty contract — Type carries // no omitempty, every other field is optional — and that a fully populated // Content — item schema for sequential streaming, per-part encodings, and diff --git a/testdata/conformance/openapi/component-reuse.golden.json b/testdata/conformance/openapi/component-reuse.golden.json index 504132ab..a6dbf8e5 100644 --- a/testdata/conformance/openapi/component-reuse.golden.json +++ b/testdata/conformance/openapi/component-reuse.golden.json @@ -248,7 +248,8 @@ "nullable": false } } - ] + ], + "required": true }, "responses": [ { diff --git a/testdata/conformance/openapi/extensions-x.golden.json b/testdata/conformance/openapi/extensions-x.golden.json index 14ed9713..d0cd0a75 100644 --- a/testdata/conformance/openapi/extensions-x.golden.json +++ b/testdata/conformance/openapi/extensions-x.golden.json @@ -429,6 +429,7 @@ } } ], + "required": true, "unmodeled": { "openapi:x-mark": { "reason": "vendor_extension", diff --git a/testdata/conformance/openapi/file-body.golden.json b/testdata/conformance/openapi/file-body.golden.json index 5445d7b8..61c17561 100644 --- a/testdata/conformance/openapi/file-body.golden.json +++ b/testdata/conformance/openapi/file-body.golden.json @@ -41,16 +41,7 @@ } } ], - "unmodeled": { - "openapi:required": { - "reason": "no_ir_home", - "value": false, - "provenance": { - "source": 0, - "pointer": "/paths/~1blob/put/requestBody/required" - } - } - } + "required": false }, "responses": [ { @@ -154,17 +145,6 @@ "auth": null } ], - "diagnostics": [ - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "request body is not required; optionality kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/paths/~1blob/put/requestBody" - } - } - ], "sources": [ { "format": "openapi@3.1", diff --git a/testdata/conformance/openapi/inline-hoist-positions.golden.json b/testdata/conformance/openapi/inline-hoist-positions.golden.json index 685d2e6e..732190be 100644 --- a/testdata/conformance/openapi/inline-hoist-positions.golden.json +++ b/testdata/conformance/openapi/inline-hoist-positions.golden.json @@ -48,7 +48,8 @@ "nullable": false } } - ] + ], + "required": true }, "responses": [ { @@ -164,7 +165,8 @@ "nullable": false } } - ] + ], + "required": true }, "responses": [ { @@ -230,7 +232,8 @@ "nullable": false } } - ] + ], + "required": true }, "responses": [ { diff --git a/testdata/conformance/openapi/multipart-encoding.golden.json b/testdata/conformance/openapi/multipart-encoding.golden.json index 11ee8d9a..7f4641e0 100644 --- a/testdata/conformance/openapi/multipart-encoding.golden.json +++ b/testdata/conformance/openapi/multipart-encoding.golden.json @@ -108,16 +108,7 @@ } } ], - "unmodeled": { - "openapi:required": { - "reason": "no_ir_home", - "value": false, - "provenance": { - "source": 0, - "pointer": "/paths/~1upload/post/requestBody/required" - } - } - } + "required": false }, "responses": [ { @@ -196,16 +187,7 @@ } } ], - "unmodeled": { - "openapi:required": { - "reason": "no_ir_home", - "value": false, - "provenance": { - "source": 0, - "pointer": "/paths/~1upload-composed/post/requestBody/required" - } - } - } + "required": false }, "responses": [ { @@ -282,16 +264,7 @@ } } ], - "unmodeled": { - "openapi:required": { - "reason": "no_ir_home", - "value": false, - "provenance": { - "source": 0, - "pointer": "/paths/~1submit/post/requestBody/required" - } - } - } + "required": false }, "responses": [ { @@ -713,24 +686,6 @@ } ], "diagnostics": [ - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "request body is not required; optionality kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/paths/~1upload/post/requestBody" - } - }, - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "request body is not required; optionality kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/paths/~1upload-composed/post/requestBody" - } - }, { "severity": "info", "code": "openapi/degraded-construct", @@ -739,15 +694,6 @@ "source": 0, "pointer": "/paths/~1submit/post/requestBody/content/application~1x-www-form-urlencoded/encoding/ids/allowReserved" } - }, - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "request body is not required; optionality kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/paths/~1submit/post/requestBody" - } } ], "sources": [ diff --git a/testdata/conformance/openapi/path-item-operations.golden.json b/testdata/conformance/openapi/path-item-operations.golden.json index 6cdfeaa1..225c171e 100644 --- a/testdata/conformance/openapi/path-item-operations.golden.json +++ b/testdata/conformance/openapi/path-item-operations.golden.json @@ -131,16 +131,7 @@ } } ], - "unmodeled": { - "openapi:required": { - "reason": "no_ir_home", - "value": false, - "provenance": { - "source": 0, - "pointer": "/paths/~1index/query/requestBody/required" - } - } - } + "required": false }, "responses": [ { @@ -401,17 +392,6 @@ "auth": null } ], - "diagnostics": [ - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "request body is not required; optionality kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/paths/~1index/query/requestBody" - } - } - ], "sources": [ { "format": "openapi@3.2", diff --git a/testdata/conformance/openapi/streaming-media-31.golden.json b/testdata/conformance/openapi/streaming-media-31.golden.json index ef217fd2..3e71332a 100644 --- a/testdata/conformance/openapi/streaming-media-31.golden.json +++ b/testdata/conformance/openapi/streaming-media-31.golden.json @@ -35,16 +35,7 @@ } } ], - "unmodeled": { - "openapi:required": { - "reason": "no_ir_home", - "value": false, - "provenance": { - "source": 0, - "pointer": "/paths/~1ingest/post/requestBody/required" - } - } - } + "required": false }, "responses": [ { @@ -528,15 +519,6 @@ } ], "diagnostics": [ - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "request body is not required; optionality kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/paths/~1ingest/post/requestBody" - } - }, { "severity": "info", "code": "openapi/degraded-construct", diff --git a/testdata/conformance/openapi/webhooks.golden.json b/testdata/conformance/openapi/webhooks.golden.json index 5377aa9c..30a109d7 100644 --- a/testdata/conformance/openapi/webhooks.golden.json +++ b/testdata/conformance/openapi/webhooks.golden.json @@ -35,16 +35,7 @@ } } ], - "unmodeled": { - "openapi:required": { - "reason": "no_ir_home", - "value": false, - "provenance": { - "source": 0, - "pointer": "/webhooks/newPet/post/requestBody/required" - } - } - } + "required": false }, "responses": [ { @@ -151,15 +142,6 @@ } ], "diagnostics": [ - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "request body is not required; optionality kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/webhooks/newPet/post/requestBody" - } - }, { "severity": "info", "code": "openapi/degraded-construct", diff --git a/testdata/golden/openapi/petstore.golden.json b/testdata/golden/openapi/petstore.golden.json index 3393444f..d88aba09 100644 --- a/testdata/golden/openapi/petstore.golden.json +++ b/testdata/golden/openapi/petstore.golden.json @@ -164,7 +164,8 @@ "nullable": false } } - ] + ], + "required": true }, "responses": [ { From 95c2735e36da2c27a242ba1e6dc1479bf0e91db3 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Sat, 5 Sep 2026 20:36:34 +0300 Subject: [PATCH 04/13] feat(ir)!: give Parameter a Provenance and promote its x-sunset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ir.Parameter was the last lowered node carrying no Provenance, and two things followed from that. A parameter's vendor extensions were stranded. ir-design §12 rule 4 says a node with no provenance is not promoted into, because a promotion that cannot be marked Inferred cannot be audited — so the parameter position was the one ir.Deprecation carrier PromoteDeprecation was not wired at, and a deprecated parameter's x-sunset sat unread beside an empty Deprecation. It is wired now, and extension-promotion.yaml gains the parameter row so the sweep fails at that carrier rather than being covered by a neighbour. Parameter origin was erased. mergeParameters merges a path item's parameters into every operation on the path, and nothing afterwards recorded that a given parameter was inherited rather than declared. The stamp uses the pointer internal/operation already threads per parameter for the interning fix (#36, #107): an operation's own entry points under that operation, a $ref'd one at the component it names, and a path-item one at the path item — one declaration named by every operation that inherits it, which is what tells the two apart. BREAKING CHANGE: ir.Parameter gains a Provenance field, serialized without omitempty like every other node's. Every golden carrying a parameter moves, and a consumer decoding the IR sees a new object on each one. Closes #423 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1 --- .../openapi/internal/operation/params.go | 17 +- .../openapi/internal/operation/params_test.go | 53 +++++ compilers/openapi/promotion_test.go | 9 + docs/ir-design.md | 13 +- ir/operation.go | 6 + ir/operation_test.go | 13 +- .../codeclared-schema-content.golden.json | 8 + .../openapi/component-reuse.golden.json | 12 +- .../openapi/deprecation.golden.json | 6 +- .../conformance/openapi/examples.golden.json | 6 +- .../openapi/extension-promotion.golden.json | 53 ++++- .../openapi/extension-promotion.yaml | 7 + .../openapi/extensions-x.golden.json | 8 + .../openapi/http-binding.golden.json | 6 +- .../openapi/inline-annotations.golden.json | 4 + .../inline-hoist-positions.golden.json | 6 +- .../openapi/neutral-naming.golden.json | 12 +- .../openapi/nullable-enum-31.golden.json | 6 +- .../openapi/param-querystring.golden.json | 18 +- .../openapi/param-ref-inheritance.golden.json | 12 +- .../openapi/param-style-matrix.golden.json | 192 +++++++++++++++--- .../openapi/param-styles.golden.json | 40 +++- .../openapi/param-xml-residue.golden.json | 8 + .../openapi/response-links.golden.json | 6 +- testdata/golden/openapi/petstore.golden.json | 12 +- 25 files changed, 460 insertions(+), 73 deletions(-) diff --git a/compilers/openapi/internal/operation/params.go b/compilers/openapi/internal/operation/params.go index 71098d1f..5c60d92f 100644 --- a/compilers/openapi/internal/operation/params.go +++ b/compilers/openapi/internal/operation/params.go @@ -49,8 +49,9 @@ func lowerParameters(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIn func lowerParameter(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, p *soa.Parameter, pptr string) (ir.Parameter, ir.HTTPParamBinding, []ir.Diagnostic) { name, in := p.GetName(), p.GetIn() param := ir.Parameter{ - Name: compile.NamingFor(name), - Required: p.GetRequired() || in == soa.ParameterInPath, + Name: compile.NamingFor(name), + Required: p.GetRequired() || in == soa.ParameterInPath, + Provenance: c.ProvenanceAt(pptr), } style, explode := resolveStyleExplode(p, in) binding := ir.HTTPParamBinding{ @@ -263,11 +264,10 @@ func paramHoldsResidue(keyword string) bool { // schema-derived annotations fillParamSchema already recorded rather than // erasing them with an unset value. // -// It is the one carrier of an ir.Deprecation that does not promote a vendor -// extension into it: ir.Parameter has no Provenance, so there is nowhere to -// record that the field was read by a heuristic, and ir-design §12's promotion -// rules require that before the reading. Giving Parameter a provenance is a -// change to that document, not to this file (GitHub #252). +// The extension promotion runs last, after the parameter's own extensions have +// been preserved: PromoteDeprecation reads the kept Unmodeled entries rather +// than the source node, so a parameter whose x-* keys are not in the map yet +// has nothing to promote from (GitHub #423). func fillParamDetail(c lowering.Ctx, param *ir.Parameter, p *soa.Parameter, pptr string) []ir.Diagnostic { if d := p.GetDescription(); d != "" { param.Docs.Description = d @@ -283,7 +283,8 @@ func fillParamDetail(c lowering.Ctx, param *ir.Parameter, p *soa.Parameter, pptr diags = append(diags, extDiags...) param.Unmodeled = annotation.MergeUnmodeled(param.Unmodeled, pExt) diags = append(diags, annotation.UnknownKeysIn(¶m.Unmodeled, p, c.SrcIndex, pptr)...) - return append(diags, preserveAllowEmptyValue(c, param, p, pptr)...) + diags = append(diags, preserveAllowEmptyValue(c, param, p, pptr)...) + return append(diags, c.PromoteDeprecation(param.Unmodeled, param.Deprecation, ¶m.Provenance)...) } // preserveAllowEmptyValue keeps a parameter's allowEmptyValue flag. It says a diff --git a/compilers/openapi/internal/operation/params_test.go b/compilers/openapi/internal/operation/params_test.go index 81e41ca1..f4e1c38f 100644 --- a/compilers/openapi/internal/operation/params_test.go +++ b/compilers/openapi/internal/operation/params_test.go @@ -332,6 +332,59 @@ func TestParams_ComponentRefSharedAcrossOperationsInternsOnce(t *testing.T) { assert.False(t, fabricatedB, "no fabricated per-operation ID for /b") } +const paramProvenanceSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /pets/{petId}: + parameters: + - {name: petId, in: path, required: true, schema: {type: string}} + get: + operationId: getPet + parameters: + - {name: fields, in: query, schema: {type: string}} + - {$ref: '#/components/parameters/Page'} + responses: {"200": {description: ok}} + delete: + operationId: deletePet + responses: {"200": {description: ok}} +components: + parameters: + Page: {name: page, in: query, schema: {type: integer}} +` + +// TestParams_ProvenanceIsTheDeclaringPosition pins where a parameter says it +// came from (GitHub #423). The three positions a parameter can be written at +// each answer differently, and the merge is why: an operation's own entry sits +// under that operation, a $ref'd one under the component it names, and a +// path-item one under the path item — the last shared by every operation on the +// path, which is what tells an inherited parameter from a declared one. +func TestParams_ProvenanceIsTheDeclaringPosition(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, paramProvenanceSpec) + openapitest.RequireNoErrorDiags(t, diags) + getPet := openapitest.FindOp(t, doc, "getPet") + deletePet := openapitest.FindOp(t, doc, "deletePet") + byName := openapitest.IndexBy(getPet.Params, func(p ir.Parameter) string { return p.Name.Source }) + require.Len(t, byName, 3, "two declared plus the inherited path-item one") + + assert.Equal(t, "/paths/~1pets~1{petId}/get/parameters/0", byName["fields"].Provenance.Pointer, + "an operation's own entry is declared under that operation") + assert.Equal(t, "/components/parameters/Page", byName["page"].Provenance.Pointer, + "a $ref'd entry is declared at the component it names, not at the use site") + + const pathItem = "/paths/~1pets~1{petId}/parameters/0" + assert.Equal(t, pathItem, byName["petId"].Provenance.Pointer, + "an inherited entry keeps the path item's pointer rather than the operation it merged into") + require.Len(t, deletePet.Params, 1) + assert.Equal(t, pathItem, deletePet.Params[0].Provenance.Pointer, + "and both operations on the path name the one declaration, not one pointer each") + + for name, p := range byName { + assert.Equal(t, 0, p.Provenance.Source, "%s addresses the compiled source", name) + assert.Empty(t, p.Provenance.Inferred, "%s is declared, not inferred", name) + } +} + const componentContentParamRefSpec = `openapi: 3.1.0 info: {title: T, version: "1"} paths: diff --git a/compilers/openapi/promotion_test.go b/compilers/openapi/promotion_test.go index 75052743..32f135b0 100644 --- a/compilers/openapi/promotion_test.go +++ b/compilers/openapi/promotion_test.go @@ -36,6 +36,8 @@ func promotionCarriers(t *testing.T, doc *ir.Document) map[string]promotionCarri require.Len(t, op.Responses, 1) require.Len(t, op.Responses[0].Headers, 1) header := op.Responses[0].Headers[0] + require.Len(t, op.Params, 1) + param := op.Params[0] model, ok := doc.Types[namedID("Old")].(*ir.Model) require.True(t, ok) @@ -47,6 +49,7 @@ func promotionCarriers(t *testing.T, doc *ir.Document) map[string]promotionCarri return map[string]promotionCarrier{ "operation": {op.Deprecation, op.Provenance, op.Unmodeled}, + "parameter": {param.Deprecation, param.Provenance, param.Unmodeled}, "header": {header.Deprecation, header.Provenance, header.Unmodeled}, "type": {model.Deprecation, model.Provenance, model.Unmodeled}, "property": {prop.Deprecation, prop.Provenance, prop.Unmodeled}, @@ -61,6 +64,7 @@ func promotionCarriers(t *testing.T, doc *ir.Document) map[string]promotionCarri func assertExtensionPromotion(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { want := map[string]string{ "operation": "use getY instead", + "parameter": "use filter instead", "header": "header goes away", "type": "replaced by New", "property": "field goes away", @@ -82,6 +86,11 @@ func assertExtensionPromotion(t *testing.T, doc *ir.Document, diags []ir.Diagnos assert.Equal(t, "1.2.0", op.Deprecation.Since) assert.Equal(t, "2.0.0", op.Deprecation.RemovalVersion) + require.Len(t, op.Params, 1) + require.NotNil(t, op.Params[0].Deprecation) + assert.Equal(t, "3.0.0", op.Params[0].Deprecation.RemovalVersion, + "the parameter's own x-sunset reaches its own removal version, not the operation's") + assertPromotionDeclined(t, doc, diags) } diff --git a/docs/ir-design.md b/docs/ir-design.md index f1b58b44..29fd39a3 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1175,6 +1175,10 @@ type Parameter struct { Availability *Availability Examples []Example Unmodeled Unmodeled + Provenance Provenance // the parameter's own declaration; a parameter merged into several + // operations (an OpenAPI path-item parameter) points at that one + // declaration, not at the operation it was merged into, which is + // what tells an inherited parameter from a declared one // NOTE: no location here — path/query/header is HTTP-binding detail (§8.1) } @@ -1803,10 +1807,11 @@ this from `Unmodeled` and no two derive it differently: 3. **The node records that it was inferred**, in its own `Provenance.Inferred`, naming the heuristic. `Inferred` holds one string and a node can be reached by more than one heuristic, so the names are listed rather than overwritten, and a name already listed is not repeated. -4. **A node with no `Provenance` is not promoted into.** `Parameter` is today's instance: it - carries a `Deprecation` and no provenance, so a promotion there could not satisfy rule 3, and a - heuristic that cannot be audited is worse than an empty field. Giving such a node a provenance - is a change to this document, and the promotion follows it rather than preceding it. +4. **A node with no `Provenance` is not promoted into.** A node carrying a `Deprecation` and no + provenance could not satisfy rule 3, and a heuristic that cannot be audited is worse than an + empty field. Giving such a node a provenance is a change to this document, and the promotion + follows it rather than preceding it — which is the order `Parameter` went through: it was the + instance this rule named until it gained the `Provenance` §7.2 now gives it. A value the mapped field cannot hold — anything but text, for the three `Deprecation` members — is reported and not coerced, since the document means something else by the key. diff --git a/ir/operation.go b/ir/operation.go index 5bce24fe..efe119cc 100644 --- a/ir/operation.go +++ b/ir/operation.go @@ -93,6 +93,12 @@ type Parameter struct { Examples []Example `json:"examples,omitempty"` // Unmodeled holds source constructs the IR does not model, kept verbatim. Unmodeled Unmodeled `json:"unmodeled,omitempty"` + // Provenance records where the parameter was declared. A parameter shared by + // several operations — a path-item parameter in OpenAPI, merged into every + // operation on the path — points at its own single declaration rather than + // at the operation it was merged into, so a consumer can tell an inherited + // parameter from one the operation declares. + Provenance Provenance `json:"provenance"` } // Payload is the body/message content of a request, response, or message diff --git a/ir/operation_test.go b/ir/operation_test.go index 7d7ff698..2f13f2bd 100644 --- a/ir/operation_test.go +++ b/ir/operation_test.go @@ -130,13 +130,15 @@ func TestPageStrategy_Constants(t *testing.T) { } // TestParameter_JSONContract pins Parameter's omitempty contract — Name, -// Type, Required, and Docs carry no omitempty since every parameter has a -// naming, a type, a required flag, and a docs object; everything else is -// optional — and that a fully populated Parameter round-trips. +// Type, Required, Docs, and Provenance carry no omitempty since every parameter +// has a naming, a type, a required flag, a docs object, and a declaring +// position; everything else is optional — and that a fully populated Parameter +// round-trips. func TestParameter_JSONContract(t *testing.T) { t.Parallel() assertJSONContract(t, ir.Parameter{}, - `{"name":{},"type":{"target":"","nullable":false},"required":false,"docs":{}}`, + `{"name":{},"type":{"target":"","nullable":false},"required":false,"docs":{},`+ + `"provenance":{"source":0}}`, ir.Parameter{ Name: populatedNaming(), Type: populatedTypeRef(), @@ -151,7 +153,8 @@ func TestParameter_JSONContract(t *testing.T) { {Name: "ex1", Value: &ir.Value{Kind: ir.ValueNumber, Num: ir.BigVal("1")}}, {Name: "ex2", Value: &ir.Value{Kind: ir.ValueNumber, Num: ir.BigVal("2")}}, }, - Unmodeled: populatedUnmodeled(), + Unmodeled: populatedUnmodeled(), + Provenance: populatedProvenance(), }) } diff --git a/testdata/conformance/openapi/codeclared-schema-content.golden.json b/testdata/conformance/openapi/codeclared-schema-content.golden.json index d390209d..abb44b28 100644 --- a/testdata/conformance/openapi/codeclared-schema-content.golden.json +++ b/testdata/conformance/openapi/codeclared-schema-content.golden.json @@ -48,6 +48,10 @@ "pointer": "/paths/~1x/get/parameters/0/schema" } } + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1x/get/parameters/0" } }, { @@ -72,6 +76,10 @@ "pointer": "/paths/~1x/get/parameters/1/schema" } } + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1x/get/parameters/1" } } ], diff --git a/testdata/conformance/openapi/component-reuse.golden.json b/testdata/conformance/openapi/component-reuse.golden.json index a6dbf8e5..fed21dbd 100644 --- a/testdata/conformance/openapi/component-reuse.golden.json +++ b/testdata/conformance/openapi/component-reuse.golden.json @@ -35,7 +35,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/parameters/Sort" + } } ], "responses": [ @@ -142,7 +146,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/components/parameters/Sort" + } } ], "responses": [ diff --git a/testdata/conformance/openapi/deprecation.golden.json b/testdata/conformance/openapi/deprecation.golden.json index 46d4ae81..fe0e51e7 100644 --- a/testdata/conformance/openapi/deprecation.golden.json +++ b/testdata/conformance/openapi/deprecation.golden.json @@ -38,7 +38,11 @@ }, "required": false, "docs": {}, - "deprecation": {} + "deprecation": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1old/get/parameters/0" + } } ], "responses": [ diff --git a/testdata/conformance/openapi/examples.golden.json b/testdata/conformance/openapi/examples.golden.json index 20dfd940..041b4fe6 100644 --- a/testdata/conformance/openapi/examples.golden.json +++ b/testdata/conformance/openapi/examples.golden.json @@ -48,7 +48,11 @@ "object": null } } - ] + ], + "provenance": { + "source": 0, + "pointer": "/paths/~1items/get/parameters/0" + } } ], "responses": [ diff --git a/testdata/conformance/openapi/extension-promotion.golden.json b/testdata/conformance/openapi/extension-promotion.golden.json index 76707a19..b9f40b8b 100644 --- a/testdata/conformance/openapi/extension-promotion.golden.json +++ b/testdata/conformance/openapi/extension-promotion.golden.json @@ -30,6 +30,47 @@ "since": "1.2.0", "removalVersion": "2.0.0" }, + "params": [ + { + "name": { + "source": "legacy", + "canonical": "legacy" + }, + "type": { + "target": "t/prim/string", + "nullable": false + }, + "required": false, + "docs": {}, + "deprecation": { + "message": "use filter instead", + "removalVersion": "3.0.0" + }, + "unmodeled": { + "openapi:x-deprecated-reason": { + "reason": "vendor_extension", + "value": "use filter instead", + "provenance": { + "source": 0, + "pointer": "/paths/~1x/get/parameters/0/x-deprecated-reason" + } + }, + "openapi:x-sunset": { + "reason": "vendor_extension", + "value": "3.0.0", + "provenance": { + "source": 0, + "pointer": "/paths/~1x/get/parameters/0/x-sunset" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1x/get/parameters/0", + "inferred": "extension-promotion" + } + } + ], "responses": [ { "name": { @@ -100,6 +141,16 @@ "method": "GET", "uriTemplate": "/x", "sharedRoute": false, + "paramBindings": [ + { + "param": "legacy", + "location": "query", + "wireName": "legacy", + "style": "form", + "explode": true, + "allowReserved": false + } + ], "checksumRequired": false, "isWebhook": false } @@ -383,7 +434,7 @@ { "format": "openapi@3.1", "path": "extension-promotion.yaml", - "hash": "1bade65b585c75be141198f5312404923b56b7fbcf36105d20f13e26da63c52f" + "hash": "aa16a7bd98de256e0feb2bf240903d08db7f651a6b802255d7d89a98a69bcadd" } ] } diff --git a/testdata/conformance/openapi/extension-promotion.yaml b/testdata/conformance/openapi/extension-promotion.yaml index 1455012b..30e78be2 100644 --- a/testdata/conformance/openapi/extension-promotion.yaml +++ b/testdata/conformance/openapi/extension-promotion.yaml @@ -8,6 +8,13 @@ paths: x-deprecated-reason: use getY instead x-deprecated-since: "1.2.0" x-sunset: "2.0.0" + parameters: + - name: legacy + in: query + deprecated: true + x-deprecated-reason: use filter instead + x-sunset: "3.0.0" + schema: {type: string} responses: "200": description: ok diff --git a/testdata/conformance/openapi/extensions-x.golden.json b/testdata/conformance/openapi/extensions-x.golden.json index d0cd0a75..e4affc33 100644 --- a/testdata/conformance/openapi/extensions-x.golden.json +++ b/testdata/conformance/openapi/extensions-x.golden.json @@ -71,6 +71,10 @@ "pointer": "/paths/~1widgets/parameters/0/x-mark" } } + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1widgets/parameters/0" } } ], @@ -359,6 +363,10 @@ "pointer": "/paths/~1widgets/parameters/0/x-mark" } } + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1widgets/parameters/0" } } ], diff --git a/testdata/conformance/openapi/http-binding.golden.json b/testdata/conformance/openapi/http-binding.golden.json index 7c54ea3b..d8d1d27b 100644 --- a/testdata/conformance/openapi/http-binding.golden.json +++ b/testdata/conformance/openapi/http-binding.golden.json @@ -36,7 +36,11 @@ "nullable": false }, "required": true, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1items~1{id}/get/parameters/0" + } } ], "responses": [ diff --git a/testdata/conformance/openapi/inline-annotations.golden.json b/testdata/conformance/openapi/inline-annotations.golden.json index 6c00f950..4754e625 100644 --- a/testdata/conformance/openapi/inline-annotations.golden.json +++ b/testdata/conformance/openapi/inline-annotations.golden.json @@ -54,6 +54,10 @@ "pointer": "/paths/~1codes/get/parameters/0/schema/x-facet" } } + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1codes/get/parameters/0" } } ], diff --git a/testdata/conformance/openapi/inline-hoist-positions.golden.json b/testdata/conformance/openapi/inline-hoist-positions.golden.json index 732190be..4eb9d3a6 100644 --- a/testdata/conformance/openapi/inline-hoist-positions.golden.json +++ b/testdata/conformance/openapi/inline-hoist-positions.golden.json @@ -36,7 +36,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1orders/post/parameters/0" + } } ], "request": { diff --git a/testdata/conformance/openapi/neutral-naming.golden.json b/testdata/conformance/openapi/neutral-naming.golden.json index 9eceb455..a071dd27 100644 --- a/testdata/conformance/openapi/neutral-naming.golden.json +++ b/testdata/conformance/openapi/neutral-naming.golden.json @@ -37,7 +37,11 @@ "nullable": false }, "required": true, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1widgets~1{widget.id}/get/parameters/0" + } }, { "name": { @@ -49,7 +53,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1widgets~1{widget.id}/get/parameters/1" + } } ], "responses": [ diff --git a/testdata/conformance/openapi/nullable-enum-31.golden.json b/testdata/conformance/openapi/nullable-enum-31.golden.json index c87377d2..ec8ce1a3 100644 --- a/testdata/conformance/openapi/nullable-enum-31.golden.json +++ b/testdata/conformance/openapi/nullable-enum-31.golden.json @@ -36,7 +36,11 @@ "nullable": true }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1pick/get/parameters/0" + } } ], "responses": [ diff --git a/testdata/conformance/openapi/param-querystring.golden.json b/testdata/conformance/openapi/param-querystring.golden.json index 5e4d992a..84e1774f 100644 --- a/testdata/conformance/openapi/param-querystring.golden.json +++ b/testdata/conformance/openapi/param-querystring.golden.json @@ -36,7 +36,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1reports/get/parameters/0" + } } ], "responses": [ @@ -103,7 +107,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1reports~1summary/get/parameters/0" + } } ], "responses": [ @@ -171,7 +179,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1reports~1raw/get/parameters/0" + } } ], "responses": [ diff --git a/testdata/conformance/openapi/param-ref-inheritance.golden.json b/testdata/conformance/openapi/param-ref-inheritance.golden.json index 37bd069d..68924546 100644 --- a/testdata/conformance/openapi/param-ref-inheritance.golden.json +++ b/testdata/conformance/openapi/param-ref-inheritance.golden.json @@ -47,7 +47,11 @@ "summary": "Cursor", "description": "Opaque pagination cursor." }, - "deprecation": {} + "deprecation": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1items/get/parameters/0" + } }, { "name": { @@ -70,7 +74,11 @@ "summary": "Cursor", "description": "Cursor for this endpoint only." }, - "deprecation": {} + "deprecation": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1items/get/parameters/1" + } } ], "responses": [ diff --git a/testdata/conformance/openapi/param-style-matrix.golden.json b/testdata/conformance/openapi/param-style-matrix.golden.json index e1a0ea94..de4ecae0 100644 --- a/testdata/conformance/openapi/param-style-matrix.golden.json +++ b/testdata/conformance/openapi/param-style-matrix.golden.json @@ -36,7 +36,11 @@ "nullable": false }, "required": true, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/0" + } }, { "name": { @@ -48,7 +52,11 @@ "nullable": false }, "required": true, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/1" + } }, { "name": { @@ -60,7 +68,11 @@ "nullable": false }, "required": true, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/2" + } }, { "name": { @@ -72,7 +84,11 @@ "nullable": false }, "required": true, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/3" + } }, { "name": { @@ -84,7 +100,11 @@ "nullable": false }, "required": true, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/4" + } }, { "name": { @@ -96,7 +116,11 @@ "nullable": false }, "required": true, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/5" + } }, { "name": { @@ -108,7 +132,11 @@ "nullable": false }, "required": true, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/6" + } }, { "name": { @@ -120,7 +148,11 @@ "nullable": false }, "required": true, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/7" + } }, { "name": { @@ -132,7 +164,11 @@ "nullable": false }, "required": true, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/8" + } }, { "name": { @@ -144,7 +180,11 @@ "nullable": false }, "required": true, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/9" + } }, { "name": { @@ -156,7 +196,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/10" + } }, { "name": { @@ -168,7 +212,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/11" + } }, { "name": { @@ -180,7 +228,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/12" + } }, { "name": { @@ -192,7 +244,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/13" + } }, { "name": { @@ -204,7 +260,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/14" + } }, { "name": { @@ -216,7 +276,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/15" + } }, { "name": { @@ -228,7 +292,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/16" + } }, { "name": { @@ -240,7 +308,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/17" + } }, { "name": { @@ -252,7 +324,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/18" + } }, { "name": { @@ -264,7 +340,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/19" + } }, { "name": { @@ -276,7 +356,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/20" + } }, { "name": { @@ -288,7 +372,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/21" + } }, { "name": { @@ -300,7 +388,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/22" + } }, { "name": { @@ -312,7 +404,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/23" + } }, { "name": { @@ -324,7 +420,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/24" + } }, { "name": { @@ -336,7 +436,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/25" + } }, { "name": { @@ -348,7 +452,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/26" + } }, { "name": { @@ -360,7 +468,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/27" + } }, { "name": { @@ -372,7 +484,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/28" + } }, { "name": { @@ -384,7 +500,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/29" + } }, { "name": { @@ -396,7 +516,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1matrix~1{pathMatrixExplode}~1{pathMatrixNoExplode}~1{pathMatrixDefaultExplode}~1{pathLabelExplode}~1{pathLabelNoExplode}~1{pathLabelDefaultExplode}~1{pathSimpleExplode}~1{pathSimpleNoExplode}~1{pathSimpleDefaultExplode}~1{pathDefaulted}/get/parameters/30" + } } ], "responses": [ @@ -704,7 +828,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1whole-query/get/parameters/0" + } } ], "responses": [ diff --git a/testdata/conformance/openapi/param-styles.golden.json b/testdata/conformance/openapi/param-styles.golden.json index acb0ad12..b7edbc64 100644 --- a/testdata/conformance/openapi/param-styles.golden.json +++ b/testdata/conformance/openapi/param-styles.golden.json @@ -36,7 +36,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get/parameters/0" + } }, { "name": { @@ -48,7 +52,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get/parameters/1" + } }, { "name": { @@ -60,7 +68,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get/parameters/2" + } }, { "name": { @@ -82,6 +94,10 @@ "pointer": "/paths/~1search/get/parameters/3/allowEmptyValue" } } + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get/parameters/3" } }, { @@ -94,7 +110,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1search/get/parameters/4" + } }, { "name": { @@ -106,7 +126,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1search/parameters/0" + } } ], "responses": [ @@ -215,7 +239,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1search/parameters/0" + } } ], "responses": [ diff --git a/testdata/conformance/openapi/param-xml-residue.golden.json b/testdata/conformance/openapi/param-xml-residue.golden.json index 92a85607..d7c5c761 100644 --- a/testdata/conformance/openapi/param-xml-residue.golden.json +++ b/testdata/conformance/openapi/param-xml-residue.golden.json @@ -49,6 +49,10 @@ "pointer": "/paths/~1docs/get/parameters/0/schema/xml" } } + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1docs/get/parameters/0" } }, { @@ -74,6 +78,10 @@ "pointer": "/paths/~1docs/get/parameters/1/content/application~1xml/schema/xml" } } + }, + "provenance": { + "source": 0, + "pointer": "/paths/~1docs/get/parameters/1" } } ], diff --git a/testdata/conformance/openapi/response-links.golden.json b/testdata/conformance/openapi/response-links.golden.json index 57bcfa4e..d1e2f8ce 100644 --- a/testdata/conformance/openapi/response-links.golden.json +++ b/testdata/conformance/openapi/response-links.golden.json @@ -134,7 +134,11 @@ "nullable": false }, "required": true, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1orders~1{orderId}/get/parameters/0" + } } ], "responses": [ diff --git a/testdata/golden/openapi/petstore.golden.json b/testdata/golden/openapi/petstore.golden.json index d88aba09..f4b17b49 100644 --- a/testdata/golden/openapi/petstore.golden.json +++ b/testdata/golden/openapi/petstore.golden.json @@ -49,7 +49,11 @@ "nullable": false }, "required": false, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1pets/get/parameters/0" + } } ], "responses": [ @@ -277,7 +281,11 @@ "nullable": false }, "required": true, - "docs": {} + "docs": {}, + "provenance": { + "source": 0, + "pointer": "/paths/~1pets~1{petId}/get/parameters/0" + } } ], "responses": [ From 35533b16d002d9ac5247be71c166957e4ddddc81 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Mon, 7 Sep 2026 23:07:42 +0300 Subject: [PATCH 05/13] feat(ir)!: make ErrorCase a response: name, headers, media types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ir.ErrorCase and ir.Response are two lowerings of one Response Object, but only one of them could say what the source declared. Response carries a Name whose Hint is the status spelling, Headers, and a Payload holding every media type; ErrorCase carried none of the three — no name at all, no headers, and one bare TypeRef where the content map belongs. Everything that fell outside those fields went to ErrorCase.Unmodeled with an info diagnostic, so a consumer's behaviour changed with the status class and nothing said so: - Retry-After and the rate-limit family live on 429 and 503, precisely the side with no typed home for a header. - A 4xx declaring application/json and application/problem+json kept the first schema and lost the media-type key entirely; a 4xx declaring one media type lost the key it was written under. - "5XX" and "default" had no faithful round-trip: StatusRange renders {500,599} and {0,0} with no record of how the source spelled them. ErrorCase now has Name Naming, Headers []Property and Payload *Payload in place of Type, each spelled as Response spells it, and the error path lowers through the same responseName, lowerHeaders and lowerPayload the success path uses. preserveErrorHeaders, fillErrorType, preserveErrorContent and errorContentMessage existed only to soften this gap and are gone with it, along with the two info diagnostics they emitted. pass.checkEncodingKeys grows a fourth Payload carrier, reached at both positions an ErrorCase hangs from — an operation's Errors and a service's CommonErrors — since a check walking only the first would resolve a service-level error's encoding keys against nothing in silence. BREAKING CHANGE: ErrorCase.Type is removed; an error case's models are its Payload.Contents entries' types. The JSON gains "name", "payload" and "headers" and loses "type". ir.IRVersion is deliberately not moved here: per ir-design.md §2.1 a line of work bumps it once, where it lands on main, and two earlier shape changes on this branch left it alone for the same reason. The normative rows in docs/ir-design.md that described the old behaviour are updated, as is the error-taxonomy example in docs/emitter-design.md. The per-status-errors conformance fixture gains a 429 declaring two media types and two rate-limit headers, which is what makes the new fields witnessed rather than merely present. Closes #422 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1 --- compilers/openapi/conformance_test.go | 83 ++++--- .../internal/operation/content_test.go | 21 +- .../openapi/internal/operation/operations.go | 108 ++------- .../operation/operations_internal_test.go | 12 - .../internal/operation/operations_test.go | 149 ++++++++---- compilers/openapi/unpreservable_test.go | 8 - docs/emitter-design.md | 5 +- docs/ir-design.md | 8 +- engine/engine_test.go | 6 +- ir/helpers_test.go | 7 + ir/irverify/verify_corpus_test.go | 22 +- ir/operation.go | 16 +- ir/operation_test.go | 24 +- ir/service_test.go | 4 +- pass/validate.go | 25 +- pass/validate_edgecases_test.go | 4 +- pass/validate_encoding_test.go | 27 +++ pass/validate_refs_test.go | 6 +- .../openapi/component-reuse.golden.json | 48 ++-- .../openapi/extensions-x.golden.json | 5 +- .../openapi/per-status-errors.golden.json | 218 +++++++++++++++--- .../openapi/per-status-errors.yaml | 15 ++ .../openapi/response-links.golden.json | 5 +- testdata/golden/openapi/petstore.golden.json | 84 +++---- 24 files changed, 561 insertions(+), 349 deletions(-) diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index 327a768a..ce89b390 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -2448,50 +2448,77 @@ func assertPerStatusErrors(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { op, ok := opByName(doc, "getWidgets") require.True(t, ok) require.Len(t, op.Responses, 1, "the 2xx success response") - faults := map[string]ir.StatusRange{} + faults := map[ir.StatusRange]string{} byRange := map[ir.StatusRange]ir.ErrorCase{} - var sawDefault bool for _, ec := range op.Errors { require.Len(t, ec.Conditions.StatusCodes, 1) rng := ec.Conditions.StatusCodes[0] byRange[rng] = ec - if rng.From == 0 && rng.To == 0 { - sawDefault = true - assert.Empty(t, ec.Fault, "the default catch-all is unclassified") - continue - } - faults[ec.Fault] = rng + faults[rng] = ec.Fault } - assert.Equal(t, ir.StatusRange{From: 404, To: 404}, faults["client"]) - assert.Equal(t, ir.StatusRange{From: 500, To: 599}, faults["server"]) - assert.True(t, sawDefault, "the default response becomes a catch-all error case") + assert.Equal(t, map[ir.StatusRange]string{ + {From: 404, To: 404}: "client", + {From: 429, To: 429}: "client", + {From: 500, To: 599}: "server", + {}: "", + }, faults, "each range classified from its own status; the default catch-all unclassified") - assertErrorMediaTypeKept(t, byRange) + assertErrorCaseIsAResponse(t, byRange) } -// assertErrorMediaTypeKept covers what ir.ErrorCase cannot say. It holds one -// TypeRef and no media type, so an error declared as application/problem+json -// reached the IR indistinguishable from one declared as application/json — the -// single-entry half of a gap whose multi-entry half was already kept, which is -// why it read as a deliberate asymmetry rather than a loss (GitHub #39). Both -// halves are now the same rule. +// assertErrorCaseIsAResponse holds the three fields ir.ErrorCase gained in +// GitHub #422 to the same claim ir.Response already carried: every status +// spelling, every header and every media type survives, whatever the status +// class. Before them an error case held one bare TypeRef, so a 429 lost its +// Retry-After outright, an error declaring two media types kept the first schema +// and no media-type key at all, and "5XX" and "default" were told apart only by +// ranges that render {500,599} and {0,0}. // -// The 5XX case is the control: an error response with no content at all keeps -// nothing, so the entry marks a declaration rather than appearing on every error. -func assertErrorMediaTypeKept(t *testing.T, byRange map[ir.StatusRange]ir.ErrorCase) { +// The 5XX case doubles as the control: an error response declaring no headers +// and no content gets neither, so what the other two carry marks a declaration +// rather than appearing on every error case. +func assertErrorCaseIsAResponse(t *testing.T, byRange map[ir.StatusRange]ir.ErrorCase) { t.Helper() + hints := map[ir.StatusRange]string{} + for rng, ec := range byRange { + hints[rng] = ec.Name.Hint + } + assert.Equal(t, map[ir.StatusRange]string{ + {From: 404, To: 404}: "404", + {From: 429, To: 429}: "429", + {From: 500, To: 599}: "5_xx", + {}: "default", + }, hints, "the responses-map key as written, which the range cannot state") + notFound, ok := byRange[ir.StatusRange{From: 404, To: 404}] require.True(t, ok) - entry, ok := notFound.Unmodeled["openapi:content"] - require.True(t, ok, "a single-media error keeps the map that names its media type") - assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) - assert.JSONEq(t, - `{"application/json":{"schema":{"$ref":"#/components/schemas/Err"}}}`, string(entry.Value)) + require.NotNil(t, notFound.Payload) + require.Len(t, notFound.Payload.Contents, 1) + assert.Equal(t, "application/json", notFound.Payload.Contents[0].MediaType, + "a single-media error keeps the key it was written under") + + throttled, ok := byRange[ir.StatusRange{From: 429, To: 429}] + require.True(t, ok) + require.NotNil(t, throttled.Payload) + require.Len(t, throttled.Payload.Contents, 2, "every media type is kept, none elected") + assert.Equal(t, "t/openapi/components/schemas/Problem", + string(throttled.Payload.Contents[1].Type.Target), + "the second media type keeps its own schema rather than the first's") + wire := make([]string, 0, len(throttled.Headers)) + for _, h := range throttled.Headers { + wire = append(wire, h.WireName) + } + assert.Equal(t, []string{"Retry-After", "X-RateLimit-Remaining"}, wire, + "the headers that only ever appear on an error status are structural") serverErr, ok := byRange[ir.StatusRange{From: 500, To: 599}] require.True(t, ok) - assert.NotContains(t, serverErr.Unmodeled, "openapi:content", - "an error response declaring no content keeps no content map") + assert.Nil(t, serverErr.Payload, "an error response declaring no content gets no payload") + assert.Empty(t, serverErr.Headers, "an error response declaring no headers gets none") + for rng, ec := range byRange { + assert.NotContains(t, ec.Unmodeled, "openapi:content", "%v keeps no content map", rng) + assert.NotContains(t, ec.Unmodeled, "openapi:headers", "%v keeps no headers map", rng) + } } func assertWebhooks(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { diff --git a/compilers/openapi/internal/operation/content_test.go b/compilers/openapi/internal/operation/content_test.go index 19ffc157..9cf76f9a 100644 --- a/compilers/openapi/internal/operation/content_test.go +++ b/compilers/openapi/internal/operation/content_test.go @@ -477,7 +477,8 @@ func TestContent_FullPipeline(t *testing.T) { _, hasLinks := resp.Unmodeled["openapi:links"] assert.True(t, hasLinks) - assert.True(t, openapitest.HasDiag(diags, diag.DegradedConstruct)) + assert.False(t, openapitest.HasDiag(diags, diag.DegradedConstruct), + "every construct this spec declares has a typed home; got %+v", diags) } func TestContent_OctetAndErrorMulti(t *testing.T) { @@ -488,16 +489,14 @@ func TestContent_OctetAndErrorMulti(t *testing.T) { require.NotNil(t, raw.Request) require.NotEmpty(t, raw.Request.Contents) assert.NotNil(t, raw.Request.Contents[0].File) - // Its 400 error has two media types → content preserved raw. - require.NotEmpty(t, raw.Errors) - var multi ir.ErrorCase - for _, ec := range raw.Errors { - if len(ec.Unmodeled) > 0 { - multi = ec - } - } - _, hasContent := multi.Unmodeled["openapi:content"] - assert.True(t, hasContent, "multi-media error content preserved") + // Its 400 error declares two media types, and both are Contents (#422). + require.Len(t, raw.Errors, 1) + payload := raw.Errors[0].Payload + require.NotNil(t, payload) + require.Len(t, payload.Contents, 2, "neither media type is elected over the other") + assert.Equal(t, []string{"application/json", "application/problem+json"}, + []string{payload.Contents[0].MediaType, payload.Contents[1].MediaType}) + assert.Empty(t, raw.Errors[0].Unmodeled, "nothing is kept verbatim beside the typed payload") } func TestContent_SequentialAndEmptyBody(t *testing.T) { diff --git a/compilers/openapi/internal/operation/operations.go b/compilers/openapi/internal/operation/operations.go index 3da6a03f..554fe1e0 100644 --- a/compilers/openapi/internal/operation/operations.go +++ b/compilers/openapi/internal/operation/operations.go @@ -785,10 +785,11 @@ func lowerResponses(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorInd // An unreadable key always takes the else branch, because statusRange pairs // a false with the zero range and that is no error range. It has to: an // ErrorCase would carry a fault classified from a range nothing derived, - // and it holds no naming to record the key under either. + // and its Conditions would assert a status the key never named — where + // statusConditions lets the success side record no status at all. // TestStatusRange_NamesNoStatus is what pins the pairing. if isErrorRange(rng) { - ec, ecDiags := lowerErrorCase(c, ts, anchors, r, rng, rptr) + ec, ecDiags := lowerErrorCase(c, ts, anchors, r, code, rng, rptr) diags = append(diags, ecDiags...) errs = append(errs, ec) } else { @@ -799,7 +800,7 @@ func lowerResponses(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorInd } def, dptr := resolve.ObjectAt[soa.Response](c.RefScope(), resps.GetDefault(), opDeclPtr+ids.Ptr("responses", defaultResponseKey)) if def != nil { - ec, ecDiags := lowerErrorCase(c, ts, anchors, def, ir.StatusRange{}, dptr) + ec, ecDiags := lowerErrorCase(c, ts, anchors, def, defaultResponseKey, ir.StatusRange{}, dptr) diags = append(diags, ecDiags...) errs = append(errs, ec) } @@ -868,98 +869,39 @@ func preserveResponseExtras(c lowering.Ctx, p *ir.Unmodeled, r *soa.Response, rp // Source stays empty even for a $ref'd response: §7.2 fills it only "for formats // with named outputs", and a components/responses key names a reusable // definition rather than this mount of it — the same component reached at two -// status codes is two responses, told apart by condition. ErrorCase carries no -// Naming at all and so has no counterpart here, and would be held by the -// presence rule at once if it gained one, since irverify does not exempt it. +// status codes is two responses, told apart by condition. +// +// The error side names itself through this same function rather than one of its +// own. ErrorCase.Name is Response.Name (GitHub #422), so a hint derived +// differently on the two sides would make the spelling a document round-trips +// depend on its status class — the asymmetry the field was added to end. func responseName(code string) ir.Naming { return compile.NamingHint(code) } -// lowerErrorCase lowers one error response into an ErrorCase, classifying its -// fault from the status range and lowering its error-model content. -func lowerErrorCase(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, r *soa.Response, rng ir.StatusRange, rptr string) (ir.ErrorCase, []ir.Diagnostic) { +// lowerErrorCase lowers one error response into an ErrorCase: its naming, +// status condition, payload (all media types), headers, docs and fault +// classification, plus any raw links preserved for later promotion. +// +// Everything but the fault is lowerResponse's work done the same way, because +// an error response is a response (GitHub #422). code is the responses-map key +// it was declared under, which is the only record of how the source spelled a +// status its range cannot state — "4XX" and "default" both. +func lowerErrorCase(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, r *soa.Response, code string, rng ir.StatusRange, rptr string) (ir.ErrorCase, []ir.Diagnostic) { + headers, diags := lowerHeaders(c, ts, anchors, r.GetHeaders(), rptr) + payload, payloadDiags := lowerPayload(c, ts, anchors, r.GetContent(), rptr, "error") + diags = append(diags, payloadDiags...) ec := ir.ErrorCase{ + Name: responseName(code), Conditions: ir.ResponseConditions{StatusCodes: []ir.StatusRange{rng}}, + Payload: payload, + Headers: headers, Fault: faultFor(rng), } ec.Docs.Description = r.GetDescription() - diags := fillErrorType(c, ts, anchors, &ec, r, rptr) - diags = append(diags, preserveErrorHeaders(c, &ec, r, rptr)...) return ec, append(diags, preserveResponseExtras(c, &ec.Unmodeled, r, rptr)...) } -// preserveErrorHeaders keeps an error response's headers from being dropped: -// ir.ErrorCase has no Headers field (ir-design §7.2), so when the response -// declares headers they are kept verbatim under Unmodeled with one info -// diagnostic, mirroring the success path's structural header lowering. -func preserveErrorHeaders(c lowering.Ctx, ec *ir.ErrorCase, r *soa.Response, rptr string) []ir.Diagnostic { - headers := r.GetHeaders() - if headers == nil || headers.Len() == 0 { - return nil - } - kept, diags := schema.PreserveNode(c, &ec.Unmodeled, "openapi:headers", - annotation.RawChildNode(r.GetRootNode(), "headers"), ir.ReasonNoIRHome, rptr+ids.Ptr("headers")) - if !kept { - return diags - } - return append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, rptr, - "error response headers have no ErrorCase home; kept verbatim under Unmodeled")) -} - -// fillErrorType lowers every content entry's schema into the type registry -// (nothing dropped) and points ErrorCase.Type at the first, then keeps the -// content map beside it, since ErrorCase.Type holds a single model reference -// (ir-design §7.2 clarification). -func fillErrorType(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorIndex, ec *ir.ErrorCase, r *soa.Response, rptr string) []ir.Diagnostic { - content := r.GetContent() - if content == nil || content.Len() == 0 { - return nil - } - var diags []ir.Diagnostic - first := true - for mt, media := range content.All() { - ref, refDiags := schema.Ref(c, ts, anchors, schema.TopLevelDepth, media.GetSchema(), rptr+ids.Ptr("content", mt, "schema"), "error") - diags = append(diags, refDiags...) - if first { - ec.Type = ref - first = false - } - } - return append(diags, preserveErrorContent(c, ec, r, rptr, content.Len())...) -} - -// preserveErrorContent keeps an error response's content map verbatim under -// Unmodeled, whatever its arity. -// -// ir.ErrorCase holds a TypeRef and no media type at all, so one entry loses the -// media type it was keyed by just as surely as several lose the entries past the -// first: an error declared only as application/problem+json reached the IR -// indistinguishable from one declared as application/json. Only the multi-entry -// case used to be kept, which made the single-entry loss the quieter of two -// halves of one gap rather than a different kind of thing (GitHub #39). -// -// n is the entry count, and picks which of the two the diagnostic names, so a -// reader is told what was actually lost rather than a message covering both. -func preserveErrorContent(c lowering.Ctx, ec *ir.ErrorCase, r *soa.Response, rptr string, n int) []ir.Diagnostic { - kept, diags := schema.PreserveNode(c, &ec.Unmodeled, "openapi:content", - annotation.RawChildNode(r.GetRootNode(), "content"), ir.ReasonNoIRHome, rptr+ids.Ptr("content")) - if kept { - diags = append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, rptr, - "%s", errorContentMessage(n))) - } - return diags -} - -// errorContentMessage names which loss the kept content map stands for: entries -// past the first when there are several, and the sole media type's own key when -// there is one. -func errorContentMessage(n int) string { - if n > 1 { - return "error response has multiple media types; full content map kept under Unmodeled" - } - return "error response media type has no ErrorCase home; content map kept under Unmodeled" -} - // lowerCallbacks lowers each callback expression's path-item operations as // Operations registered in the parent's group, and binds them to the parent via // HTTPBinding.Callbacks keyed by the runtime expression (ir-design §8.1). diff --git a/compilers/openapi/internal/operation/operations_internal_test.go b/compilers/openapi/internal/operation/operations_internal_test.go index 1baf86ba..292d7aea 100644 --- a/compilers/openapi/internal/operation/operations_internal_test.go +++ b/compilers/openapi/internal/operation/operations_internal_test.go @@ -204,18 +204,6 @@ func TestFaultFor(t *testing.T) { assert.Equal(t, "", faultFor(ir.StatusRange{})) } -func TestPreserveErrorHeaders_WithoutRootNode(t *testing.T) { - t.Parallel() - l := newRawLowerer(&soa.OpenAPI{}) - headers := sequencedmap.New( - sequencedmap.NewElem("X-H", &soa.ReferencedHeader{}), - ) - ec := &ir.ErrorCase{} - diags := preserveErrorHeaders(l.ctx, ec, &soa.Response{Headers: headers}, "/r") - assert.Nil(t, ec.Unmodeled, "headers with no raw node are not preserved") - require.Empty(t, diags) -} - func TestLowerResponses_NoResponses(t *testing.T) { t.Parallel() l := newRawLowerer(&soa.OpenAPI{}) diff --git a/compilers/openapi/internal/operation/operations_test.go b/compilers/openapi/internal/operation/operations_test.go index 04211af1..aa08c877 100644 --- a/compilers/openapi/internal/operation/operations_test.go +++ b/compilers/openapi/internal/operation/operations_test.go @@ -73,7 +73,9 @@ func TestResponses_ErrorSplitAndRanges(t *testing.T) { require.Len(t, op.Errors, 3) assert.Equal(t, []ir.StatusRange{{From: 404, To: 404}}, op.Errors[0].Conditions.StatusCodes) assert.Equal(t, "client", op.Errors[0].Fault) - assert.NotEmpty(t, op.Errors[0].Type.Target, "404 error model lowered and referenced") + require.NotNil(t, op.Errors[0].Payload) + require.Len(t, op.Errors[0].Payload.Contents, 1) + assert.NotEmpty(t, op.Errors[0].Payload.Contents[0].Type.Target, "404 error model lowered and referenced") assert.Equal(t, []ir.StatusRange{{From: 500, To: 599}}, op.Errors[1].Conditions.StatusCodes) assert.Equal(t, "server", op.Errors[1].Fault) assert.Equal(t, []ir.StatusRange{{From: 0, To: 0}}, op.Errors[2].Conditions.StatusCodes) @@ -87,8 +89,10 @@ func TestResponses_ErrorSplitAndRanges(t *testing.T) { // empty, leaving an emitter naming a per-response result type nothing to build // one from (GitHub #259). // -// The error half of the same map has no counterpart to check: ir.ErrorCase -// carries no Naming at all, so there is no channel on it to leave empty. +// The error half of the same map is named the same way and by the same +// function (GitHub #422), which is what "404" and "default" below assert: the +// spelling a wildcard or catch-all key was written under is recorded nowhere +// else, since StatusRange renders both {500,599} and {0,0} with no trace of it. func TestResponses_NamedByStatusKey(t *testing.T) { t.Parallel() spec := openapitest.PathsSpec(` /w: @@ -113,6 +117,15 @@ func TestResponses_NamedByStatusKey(t *testing.T) { } assert.Equal(t, []string{"200", "2_xx", "empty"}, hints, "the key as declared, neutralized; a key with no word in it takes the mint") + + require.Len(t, op.Errors, 2) + errHints := make([]string, 0, len(op.Errors)) + for _, ec := range op.Errors { + assert.Empty(t, ec.Name.Source, "OpenAPI declares no error name either, so Source stays empty") + errHints = append(errHints, ec.Name.Hint) + } + assert.Equal(t, []string{"404", "default"}, errHints, + "an error case records the responses-map key its range cannot state") } // TestResponses_InvalidStatusKeyIsReported is the whole of GitHub #262 at the @@ -178,10 +191,14 @@ func TestResponses_ValidStatusKeysAreNotReported(t *testing.T) { "every key here names a status; got %+v", diags) } -func TestResponses_ErrorHeadersPreserved(t *testing.T) { +// TestResponses_ErrorHeadersAreStructural pins the header half of GitHub #422. +// A 429's Retry-After and the rate-limit family live on precisely the status +// class that had no typed home for them: they were kept verbatim under +// ErrorCase.Unmodeled with an info diagnostic, so a consumer reading +// Response.Headers saw headers on a 200 and none on a 429. They are now lowered +// by lowerHeaders, exactly as the success side's are, and nothing is kept. +func TestResponses_ErrorHeadersAreStructural(t *testing.T) { t.Parallel() - // ErrorCase has no Headers field; a 429's Retry-After header must not be - // dropped silently — it is kept verbatim under Unmodeled with a diag. spec := openapitest.PathsSpec(` /w: get: operationId: w @@ -191,23 +208,28 @@ func TestResponses_ErrorHeadersPreserved(t *testing.T) { description: slow down headers: Retry-After: {schema: {type: integer}} + X-RateLimit-Remaining: {schema: {type: integer}} `) _, svc, diags := lowerServiceSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) op := openapitest.FirstOp(t, svc) require.Len(t, op.Errors, 1) - raw, ok := op.Errors[0].Unmodeled["openapi:headers"] - require.True(t, ok, "error response headers kept under Unmodeled") - assert.Contains(t, string(raw.Value), "Retry-After") - assert.Equal(t, ir.ReasonNoIRHome, raw.Reason) + ec := op.Errors[0] + + require.Len(t, ec.Headers, 2, "both headers lowered structurally") + wire := make([]string, 0, len(ec.Headers)) + for _, h := range ec.Headers { + wire = append(wire, h.WireName) + assert.NotEmpty(t, h.Type.Target, "each header carries its lowered type") + } + assert.Equal(t, []string{"Retry-After", "X-RateLimit-Remaining"}, wire) - found := false + assert.NotContains(t, ec.Unmodeled, "openapi:headers", + "a typed home means nothing is kept verbatim beside it") for _, d := range diags { - if d.Severity == ir.SeverityInfo && strings.Contains(d.Message, "error response headers") { - found = true - } + assert.NotContains(t, d.Message, "error response headers", + "nothing is degraded, so nothing announces a degradation") } - assert.True(t, found, "dropped error headers emit one info diagnostic") } func TestOperation_ExplicitlyPublicSecurity(t *testing.T) { @@ -1207,14 +1229,19 @@ func TestResponses_RefdErrorAndDefaultInternAtDeclaration(t *testing.T) { } aErrs, bErrs := byFault(getA), byFault(getB) + errType := func(ec ir.ErrorCase) ir.TypeID { + require.NotNil(t, ec.Payload) + require.Len(t, ec.Payload.Contents, 1) + return ec.Payload.Contents[0].Type.Target + } wantNotFound := ir.TypeID("t/anon/components/responses/NotFound/content/application~1json/schema") - assert.Equal(t, wantNotFound, aErrs["client"].Type.Target) - assert.Equal(t, wantNotFound, bErrs["client"].Type.Target, "the shared 4XX error model interns once") + assert.Equal(t, wantNotFound, errType(aErrs["client"])) + assert.Equal(t, wantNotFound, errType(bErrs["client"]), "the shared 4XX error model interns once") // The default response is the unclassified catch-all, so it keys on "". wantFallback := ir.TypeID("t/anon/components/responses/Fallback/content/application~1json/schema") - assert.Equal(t, wantFallback, aErrs[""].Type.Target) - assert.Equal(t, wantFallback, bErrs[""].Type.Target, "the shared default error model interns once") + assert.Equal(t, wantFallback, errType(aErrs[""])) + assert.Equal(t, wantFallback, errType(bErrs[""]), "the shared default error model interns once") for id := range doc.Types { assert.NotContains(t, string(id), "/responses/404", "no fabricated per-operation error ID") @@ -1248,7 +1275,7 @@ components: Err: description: err headers: - X-E: {schema: {type: string}} + X-E: {schema: {type: array, items: {type: string}}, explode: true} content: application/json: schema: {type: object} @@ -1257,10 +1284,15 @@ components: // TestDiag_SharedDeclarationReportsEachDefectOnce pins the consequence of // lowering a referenced component at its declaration: both operations reach the // same request body — whose scalar schema carries a `required` the lowered node -// has no field for — and the same header-bearing error response, so each defect -// now has one pointer and one message. Reported per use site they would arrive -// as byte-identical copies — nothing a reader could act on twice — and a -// component shared by twenty operations would repeat each line twenty times. +// has no field for — and the same error response, whose header declares an +// `explode` ir.Property has no field for, so each defect has one pointer and one +// message. Reported per use site they would arrive as byte-identical copies — +// nothing a reader could act on twice — and a component shared by twenty +// operations would repeat each line twenty times. +// +// The second defect sits on an error response's header on purpose: those reach +// lowerHeaders only since GitHub #422, so the case covers the shared-declaration +// rule on the path that gained them rather than on the success side alone. func TestDiag_SharedDeclarationReportsEachDefectOnce(t *testing.T) { t.Parallel() _, diags := parseFull(t, sharedDefectiveBodySpec) @@ -1274,9 +1306,9 @@ func TestDiag_SharedDeclarationReportsEachDefectOnce(t *testing.T) { } // Every defect still surfaces — de-duplication must not silence any of them. - assert.Equal(t, 3, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), - "the body schema's homeless required, the homeless error headers and the homeless "+ - "error media type are three distinct defects") + assert.Equal(t, 2, openapitest.CountDiagsAt(diags, diag.DegradedConstruct, ir.SeverityInfo), + "the body schema's homeless required and the error header's homeless explode are two "+ + "distinct defects") } // TestDiag_DistinctDefectsAtOnePointerBothSurvive is the control for the rule @@ -1591,19 +1623,31 @@ func TestOperations_PathItemUnknownKeyKeptOnEveryRoute(t *testing.T) { } } -// TestErrorCase_SingleMediaTypeKeepsContentMap pins the arity-independent half of -// error-content preservation. ir.ErrorCase holds a TypeRef and no media type, so -// an error declared only as application/problem+json reached the IR -// indistinguishable from one declared as application/json — while the same -// response with a second media type beside it was kept in full. One entry losing -// its key is the same loss as several losing all but the first. -func TestErrorCase_SingleMediaTypeKeepsContentMap(t *testing.T) { +// TestErrorCase_EveryMediaTypeIsAContent pins the content half of GitHub #422. +// ir.ErrorCase held one bare TypeRef and no media type, so a 404 declaring only +// application/problem+json reached the IR indistinguishable from one declaring +// application/json, and a 400 declaring both kept the first schema and lost the +// second entirely — the whole map going verbatim to Unmodeled in either case. +// Both now lower to ErrorCase.Payload.Contents, one entry per media type, the +// same shape and by the same function as a success response's. +// +// The 409 is the control: an error declaring no content at all still gets no +// payload, so a Contents entry marks a declaration rather than appearing on +// every error case. +func TestErrorCase_EveryMediaTypeIsAContent(t *testing.T) { t.Parallel() doc, diags := parseFull(t, openapitest.PathsSpec(` /x: get: operationId: getX responses: "200": {description: ok} + "400": + description: bad + content: + application/json: + schema: {type: object, properties: {a: {type: string}}} + application/problem+json: + schema: {type: object, properties: {b: {type: string}}} "404": description: gone content: @@ -1615,19 +1659,30 @@ func TestErrorCase_SingleMediaTypeKeepsContentMap(t *testing.T) { errs := openapitest.IndexBy(openapitest.FindOp(t, doc, "getX").Errors, func(ec ir.ErrorCase) int { return ec.Conditions.StatusCodes[0].From }) - entry, ok := errs[404].Unmodeled["openapi:content"] - require.True(t, ok, "the single-entry content map is kept") - assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) - assert.JSONEq(t, `{"application/problem+json":{"schema":{"type":"object"}}}`, string(entry.Value), - "the media type the map is keyed by is what would otherwise be lost") - assert.Equal(t, "/paths/~1x/get/responses/404/content", entry.Provenance.Pointer) - assert.Contains(t, - openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityInfo, "/paths/~1x/get/responses/404"), - "media type has no ErrorCase home", - "the single-entry case names its own loss, not the multi-entry one") - - assert.NotContains(t, errs[409].Unmodeled, "openapi:content", - "an error response declaring no content keeps no content map") + multi := errs[400] + require.NotNil(t, multi.Payload) + require.Len(t, multi.Payload.Contents, 2, "every media type is kept, none elected") + assert.Equal(t, []string{"application/json", "application/problem+json"}, + []string{multi.Payload.Contents[0].MediaType, multi.Payload.Contents[1].MediaType}) + assert.NotEqual(t, multi.Payload.Contents[0].Type.Target, multi.Payload.Contents[1].Type.Target, + "the second media type keeps its own schema rather than the first's") + + single := errs[404] + require.NotNil(t, single.Payload) + require.Len(t, single.Payload.Contents, 1) + assert.Equal(t, "application/problem+json", single.Payload.Contents[0].MediaType, + "the sole media type's own key is what used to be lost") + + for status, ec := range errs { + assert.NotContains(t, ec.Unmodeled, "openapi:content", + "%d keeps no content map beside a typed payload", status) + } + for _, d := range diags { + assert.NotContains(t, d.Message, "ErrorCase home", + "nothing is degraded, so nothing announces a degradation") + } + + assert.Nil(t, errs[409].Payload, "an error response declaring no content gets no payload") } // operationServersSpec declares `servers` at both levels OpenAPI allows, on an diff --git a/compilers/openapi/unpreservable_test.go b/compilers/openapi/unpreservable_test.go index 778f3d10..6a65221b 100644 --- a/compilers/openapi/unpreservable_test.go +++ b/compilers/openapi/unpreservable_test.go @@ -60,14 +60,6 @@ func TestUnpreservable_AnnouncementNeverOutrunsTheEntry(t *testing.T) { " - {type: string, maxLength: 3, x-t: " + unpreservableValue + "}\n"), at: "/components/schemas/M/allOf/0", }, - { - name: "error response with multiple media types", - spec: openapitest.PathsSpec(" /x:\n get:\n responses:\n" + - " \"500\":\n description: bad\n content:\n" + - " application/json: {schema: {type: string}, example: " + unpreservableValue + "}\n" + - " application/xml: {schema: {type: string}}\n"), - at: "/paths/~1x/get/responses/500/content", - }, { name: "path-item servers", spec: openapitest.PathsSpec(" /x:\n servers: [{url: 'https://a', x-t: " + unpreservableValue + "}]\n" + diff --git a/docs/emitter-design.md b/docs/emitter-design.md index 06789921..16ecf516 100644 --- a/docs/emitter-design.md +++ b/docs/emitter-design.md @@ -1036,8 +1036,9 @@ binding. ### 8.5 An error taxonomy (`RateLimited`, 429, retryable-throttling) -IR: `ErrorCase{ Type:RateLimited (Model, Usage.Error), Conditions:{429}, Fault:"client", -Retryable:&true, Throttling:&true }`; policy default maps `429 → "TooManyRequests"`. +IR: `ErrorCase{ Name:{Hint:"429"}, Conditions:{429}, Payload:{Contents:[{application/json → +RateLimited (Model, Usage.Error)}]}, Headers:[Retry-After], Fault:"client", Retryable:&true, +Throttling:&true }`; policy default maps `429 → "TooManyRequests"`. - **plan.** `OpPlan.Errors` = `[PlannedError{ Conditions:{429}, Type:RateLimited, Fault:"client", Retryable:true, Throttling:true }]` — declared IR facts, carried as such. diff --git a/docs/ir-design.md b/docs/ir-design.md index 29fd39a3..28d4d119 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1251,9 +1251,11 @@ type ResponseConditions struct { StatusCodes []StatusRange // {From,To}: 200–200, 400–499 ("4XX"), 0–0 = default/catch-all } -type ErrorCase struct { - Type TypeRef // an error-flagged model +type ErrorCase struct { // a response too: Name/Conditions/Payload/Headers as on Response + Name Naming // for formats with named errors; Hint elsewhere (the status key as written) Conditions ResponseConditions + Payload *Payload // nil = no body; the error-flagged models are its contents' types + Headers []Property // error metadata fields (Retry-After, rate-limit family) Fault string // "" | "client" | "server" — protocol-neutral fault classification // (Smithy @error; OpenAPI 4XX/5XX is its HTTP lowering). Drives // exception hierarchies and default status synthesis @@ -1885,7 +1887,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, 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); 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); `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; 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, error-response `headers` and its `content` map whatever its arity → ErrorCase.Unmodeled (`no_ir_home`, ErrorCase has neither field: it holds one TypeRef and no media type, so one entry loses the key it was written under just as several lose all but the 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` → `Encoding` on the scalar the position lowers to and `contentSchema` → Unmodeled (`no_ir_home`) 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 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; 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, 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); 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); `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; 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 written → Response.Name.Hint and ErrorCase.Name.Hint alike, so `4XX` and `default` round-trip the spelling their range cannot state; 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; 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` → `Encoding` on the scalar the position lowers to and `contentSchema` → Unmodeled (`no_ir_home`) 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 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; 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/engine/engine_test.go b/engine/engine_test.go index 466abb18..ff7f1521 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -392,8 +392,10 @@ func (danglingCompiler) Compile(_ context.Context, _ []compilers.Source, _ compi ID: "s/x", Groups: []ir.OperationGroup{{ Operations: []ir.Operation{{ - ID: "op/x", - Errors: []ir.ErrorCase{{Type: ir.TypeRef{Target: "t/missing"}}}, + ID: "op/x", + Errors: []ir.ErrorCase{{ + Payload: &ir.Payload{Contents: []ir.Content{{Type: ir.TypeRef{Target: "t/missing"}}}}, + }}, }}, }}, }}, diff --git a/ir/helpers_test.go b/ir/helpers_test.go index 7f8a6f43..f562cc08 100644 --- a/ir/helpers_test.go +++ b/ir/helpers_test.go @@ -366,6 +366,13 @@ func populatedTypeRef() ir.TypeRef { return ir.TypeRef{Target: "t/openapi/components/schemas/User", Nullable: true} } +// errorPayload returns the one-media-type body an ir.ErrorCase fixture carries, +// which is an ir.Payload for the same reason ir.Response's is: the two nodes +// spell a body identically. +func errorPayload() *ir.Payload { + return &ir.Payload{Contents: []ir.Content{{MediaType: "application/json", Type: populatedTypeRef()}}} +} + // populatedValue returns a fully populated Value of ValueKind list, itself // containing at least one member of every other ValueKind so a single // fixture exercises every payload variant (ir-design §6). diff --git a/ir/irverify/verify_corpus_test.go b/ir/irverify/verify_corpus_test.go index 2c056ab5..66fa72c4 100644 --- a/ir/irverify/verify_corpus_test.go +++ b/ir/irverify/verify_corpus_test.go @@ -89,12 +89,18 @@ func TestVerify_Corpus(t *testing.T) { } // uncorpusedUnmodeled exercises the Unmodeled writes no committed fixture -// reaches: path-item servers, an error response's headers, an error response's -// second media type, and an `items` tail after `prefixItems`. The parameter's -// xml hints are reached by the corpus too, and are here so the spec covers every -// field unmodeledKeys reads — the operation itself, its parameters, its errors -// and the type registry — leaving a collector that stopped reading one of them -// to fail the precondition below rather than quietly narrow what is verified. +// reaches: path-item servers, an error response's own x-* extension, and an +// `items` tail after `prefixItems`. The parameter's xml hints are reached by the +// corpus too, and are here so the spec covers every field unmodeledKeys reads — +// the operation itself, its parameters, its errors and the type registry — +// leaving a collector that stopped reading one of them to fail the precondition +// below rather than quietly narrow what is verified. +// +// The 404 also declares headers and two media types, which write nothing here +// any more: both lower structurally onto ir.ErrorCase (GitHub #422). They stay +// because that is what makes them verified rather than merely absent — the +// headers are ir.Property values the naming and reference checks now reach on an +// error case, which no committed fixture put them on before. const uncorpusedUnmodeled = `openapi: 3.1.0 info: {title: UnmodeledSites, version: "1"} paths: @@ -118,6 +124,7 @@ paths: items: {type: integer} "404": description: missing + x-mark: kept headers: X-Reason: {schema: {type: string}} content: @@ -168,8 +175,7 @@ func TestVerify_UnmodeledSitesOutsideTheCorpus(t *testing.T) { doc := compile(t, "unmodeled-sites.yaml", []byte(uncorpusedUnmodeled)) require.NotNil(t, doc) require.Equal(t, []string{ - "openapi:content", "openapi:headers", "openapi:items-after-prefix", - "openapi:servers", "openapi:xml", + "openapi:items-after-prefix", "openapi:servers", "openapi:x-mark", "openapi:xml", }, unmodeledKeys(doc), "the spec must reach every preserve call this test exists for") assert.Empty(t, irverify.Verify(doc)) diff --git a/ir/operation.go b/ir/operation.go index efe119cc..11635928 100644 --- a/ir/operation.go +++ b/ir/operation.go @@ -223,11 +223,23 @@ type StatusRange struct { } // ErrorCase is a declared failure shape of an Operation (ir-design §7.2). +// +// An error case is a response, so the four fields it shares with [Response] — +// Name, Conditions, Payload, Headers — are spelled and lowered identically. +// What separates the two nodes is the failure classification below it, not what +// either can say about a status code, a header or a body. type ErrorCase struct { - // Type is an error-flagged model. - Type TypeRef `json:"type"` + // Name is the error naming for formats with named errors; Hint elsewhere — + // for OpenAPI, the responses-map key as written ("404", "5XX", "default"). + Name Naming `json:"name"` // Conditions are the status codes/ranges this error maps to. Conditions ResponseConditions `json:"conditions"` + // Payload is the error body, one Content per media type; nil = no body. The + // error-flagged models an error case references are its contents' types. + Payload *Payload `json:"payload,omitempty"` + // Headers are the error response's metadata fields — Retry-After and the + // rate-limit family live here. + Headers []Property `json:"headers,omitempty"` // Fault is "" | "client" | "server" — protocol-neutral fault classification // (Smithy @error; OpenAPI 4XX/5XX is its HTTP lowering). Drives exception // hierarchies and default status synthesis. diff --git a/ir/operation_test.go b/ir/operation_test.go index 2f13f2bd..25338634 100644 --- a/ir/operation_test.go +++ b/ir/operation_test.go @@ -52,7 +52,13 @@ func TestOperation_PopulatedRoundTrip(t *testing.T) { }, }, Errors: []ir.ErrorCase{ - {Type: populatedTypeRef(), Conditions: ir.ResponseConditions{StatusCodes: []ir.StatusRange{{From: 400, To: 499}}}, Fault: "client"}, + { + Name: ir.Naming{Source: "client_error"}, + Conditions: ir.ResponseConditions{StatusCodes: []ir.StatusRange{{From: 400, To: 499}}}, + Payload: &ir.Payload{Contents: []ir.Content{{MediaType: "application/json", Type: populatedTypeRef()}}}, + Headers: []ir.Property{{ID: "p/retry-after", Name: ir.Naming{Source: "Retry-After"}, Type: populatedTypeRef()}}, + Fault: "client", + }, }, OneWay: false, Streaming: ir.StreamingBidi, @@ -343,19 +349,25 @@ func TestStatusRange_PopulatedRoundTrip(t *testing.T) { } } -// TestErrorCase_JSONContract pins ErrorCase's omitempty contract (Type, +// TestErrorCase_JSONContract pins ErrorCase's omitempty contract (Name, // Conditions, and Docs carry no omitempty; every other field is optional) -// and that a fully populated ErrorCase — fault classification, -// retryable/throttling tri-state pointers — round-trips. +// and that a fully populated ErrorCase — the four response fields it shares +// with ir.Response, fault classification, retryable/throttling tri-state +// pointers — round-trips. func TestErrorCase_JSONContract(t *testing.T) { t.Parallel() retryable := true throttling := false assertJSONContract(t, ir.ErrorCase{}, - `{"type":{"target":"","nullable":false},"conditions":{},"docs":{}}`, + `{"name":{},"conditions":{},"docs":{}}`, ir.ErrorCase{ - Type: populatedTypeRef(), + Name: ir.Naming{Source: "too_many_requests", Hint: "429"}, Conditions: ir.ResponseConditions{StatusCodes: []ir.StatusRange{{From: 429, To: 429}}}, + Payload: &ir.Payload{Contents: []ir.Content{ + {MediaType: "application/json", Type: populatedTypeRef()}, + {MediaType: "application/problem+json", Type: populatedTypeRef()}, + }}, + Headers: []ir.Property{{ID: "p/retry-after", Name: ir.Naming{Source: "Retry-After"}, Type: populatedTypeRef()}}, Fault: "client", Retryable: &retryable, Throttling: &throttling, diff --git a/ir/service_test.go b/ir/service_test.go index a24f6ede..0ff652c6 100644 --- a/ir/service_test.go +++ b/ir/service_test.go @@ -36,8 +36,8 @@ func TestService_JSONContract(t *testing.T) { {Schemes: []ir.SchemeUse{{Scheme: "auth/apiKey"}}}, }, CommonErrors: []ir.ErrorCase{ - {Type: populatedTypeRef(), Fault: "client"}, - {Type: populatedTypeRef(), Fault: "server"}, + {Name: ir.Naming{Source: "throttled"}, Payload: errorPayload(), Fault: "client"}, + {Name: ir.Naming{Source: "internal"}, Payload: errorPayload(), Fault: "server"}, }, Protocols: []ir.ProtocolDecl{ {Name: "aws.restJson1", Options: populatedRawConfig()}, diff --git a/pass/validate.go b/pass/validate.go index 53793351..08ea813a 100644 --- a/pass/validate.go +++ b/pass/validate.go @@ -234,19 +234,29 @@ func checkPropIDRefs(doc *ir.Document) []ir.Diagnostic { // Only this pass reports it today. // // The fields that carry a Payload are named here — Operation.Request, -// Response.Payload and Message.Payload — because nothing in a Payload's Go type -// says who owns one, so a new one has to be added by hand. That coupling is -// guarded: TestEncodingCarriers_NameEveryPayloadFieldInTheIR +// Response.Payload, ErrorCase.Payload and Message.Payload — because nothing in a +// Payload's Go type says who owns one, so a new one has to be added by hand. +// That coupling is guarded: TestEncodingCarriers_NameEveryPayloadFieldInTheIR // (validate_carriers_test.go) walks the IR for Payload-bearing fields and fails // the moment one of them is not walked here. +// +// ErrorCase.Payload is reached at both of the IR's error positions — an +// operation's own Errors and its service's CommonErrors — because the field is +// one field wherever the node hangs, and a check that walked only the operation +// list would resolve a service-level error's keys against nothing in silence. func checkEncodingKeys(doc *ir.Document) []ir.Diagnostic { var diags []ir.Diagnostic + for i, svc := range doc.Services { + at := fmt.Sprintf("doc/services/%d/commonErrors", i) + diags = appendErrorEncodingDiags(diags, doc, svc.CommonErrors, at) + } forEachOperation(doc, func(op ir.Operation) { diags = appendEncodingKeyDiags(diags, doc, op.Request, string(op.ID)+"/request") for i, r := range op.Responses { at := fmt.Sprintf("%s/responses/%d", op.ID, i) diags = appendEncodingKeyDiags(diags, doc, r.Payload, at) } + diags = appendErrorEncodingDiags(diags, doc, op.Errors, string(op.ID)+"/errors") }) for _, id := range sortedKeys(doc.Messages) { msg := doc.Messages[id] @@ -255,6 +265,15 @@ func checkEncodingKeys(doc *ir.Document) []ir.Diagnostic { return diags } +// appendErrorEncodingDiags appends to dst a diagnostic per unresolvable encoding +// key in each error case's payload; where locates the list the cases hang from. +func appendErrorEncodingDiags(dst []ir.Diagnostic, doc *ir.Document, errs []ir.ErrorCase, where string) []ir.Diagnostic { + for i, ec := range errs { + dst = appendEncodingKeyDiags(dst, doc, ec.Payload, fmt.Sprintf("%s/%d", where, i)) + } + return dst +} + // appendEncodingKeyDiags appends to dst a diagnostic per unresolvable encoding // key in each of the payload's contents; where locates the payload's owner. func appendEncodingKeyDiags(dst []ir.Diagnostic, doc *ir.Document, payload *ir.Payload, where string) []ir.Diagnostic { diff --git a/pass/validate_edgecases_test.go b/pass/validate_edgecases_test.go index f67ed538..0f89dcc7 100644 --- a/pass/validate_edgecases_test.go +++ b/pass/validate_edgecases_test.go @@ -101,7 +101,9 @@ func TestValidate_OperationHeadersAndItemWalked(t *testing.T) { Type: ir.TypeRef{Target: "t/ghost-hdr"}, }}, }}, - Errors: []ir.ErrorCase{{Type: ir.TypeRef{Target: "t/ghost-err"}}}, + Errors: []ir.ErrorCase{{ + Payload: &ir.Payload{Contents: []ir.Content{{Type: ir.TypeRef{Target: "t/ghost-err"}}}}, + }}, } diags := pass.Validate(docWithOperation(op)) // item, header, and error targets are all dangling. diff --git a/pass/validate_encoding_test.go b/pass/validate_encoding_test.go index de4d153d..5d75f73d 100644 --- a/pass/validate_encoding_test.go +++ b/pass/validate_encoding_test.go @@ -41,6 +41,12 @@ func encodingCarriers() []encodingCarrier { Payload: &ir.Payload{Contents: []ir.Content{multipartContent(enc)}}, }} }}, + {"ErrorCase.Payload", "op/errors/0/contents/0", func(d *ir.Document, enc map[ir.PropID]ir.PartEncoding) { + firstOp(d).Errors = []ir.ErrorCase{{ + Name: ir.Naming{Source: "bad_request"}, + Payload: &ir.Payload{Contents: []ir.Content{multipartContent(enc)}}, + }} + }}, {"Message.Payload", "msg/a/contents/0", func(d *ir.Document, enc map[ir.PropID]ir.PartEncoding) { putMessage(d, func(m *ir.Message) { m.Payload = ir.Payload{Contents: []ir.Content{multipartContent(enc)}} @@ -87,6 +93,27 @@ func TestValidate_EncodingKeysNamingRealPropertiesAreClean(t *testing.T) { assert.Empty(t, pass.Validate(doc)) } +// TestValidate_EncodingKeyThroughServiceCommonErrors covers the second position +// an ir.ErrorCase hangs from. The carrier table above plants into an operation's +// own Errors, which is one of two lists of the same node: a check reaching only +// that one would resolve a service-level error's encoding keys against nothing +// while every case above stayed green. +func TestValidate_EncodingKeyThroughServiceCommonErrors(t *testing.T) { + t.Parallel() + doc := validDoc() + service(doc).CommonErrors = []ir.ErrorCase{{ + Name: ir.Naming{Source: "throttled"}, + Payload: &ir.Payload{Contents: []ir.Content{ + multipartContent(map[ir.PropID]ir.PartEncoding{"p/m/ghost": {Multi: true}}), + }}, + }} + + found := withCode(pass.Validate(doc), "ir/encoding-key-unknown-property") + require.Len(t, found, 1, "exactly the planted key must address nothing") + assert.Equal(t, "doc/services/0/commonErrors/0/contents/0/encoding/p/m/ghost", + found[0].Provenance.Pointer, "the pointer names the list the error case hangs from") +} + // TestValidate_EncodingKeyThroughComposition covers the three ways a body model // composes a part in. An emitter renders the flat property set (§4.3), so a key // naming an inherited part is legal IR and the check must stay silent on it — diff --git a/pass/validate_refs_test.go b/pass/validate_refs_test.go index 5d6fb402..2894fae2 100644 --- a/pass/validate_refs_test.go +++ b/pass/validate_refs_test.go @@ -100,7 +100,9 @@ func payloadAndVersioningSites() []refSite { service(d).Renames = map[ir.TypeID]ir.Naming{t: {Source: "Ghost"}} }, "t/ghost/rename-key"}, {"service common errors", ".CommonErrors[0]", func(d *ir.Document, t ir.TypeID) { - service(d).CommonErrors = []ir.ErrorCase{{Type: ir.TypeRef{Target: t}}} + service(d).CommonErrors = []ir.ErrorCase{{ + Payload: &ir.Payload{Contents: []ir.Content{{Type: ir.TypeRef{Target: t}}}}, + }} }, "t/ghost/common-error"}, } } @@ -271,7 +273,7 @@ var sortedRefPointers = []string{ "doc.Channels[chan/a].Params[0].Type.Target", "doc.Messages[msg/a].CorrelationID.Root.Target", "doc.Messages[msg/a].Headers.Target", - "doc.Services[0].CommonErrors[0].Type.Target", + "doc.Services[0].CommonErrors[0].Payload.Contents[0].Type.Target", "doc.Services[0].Groups[0].Operations[0].Bindings.RPC.InputType.Target", "doc.Services[0].Groups[0].Operations[0].LongRunning.FinalType.Target", "doc.Services[0].Groups[0].Operations[0].LongRunning.PollingType.Target", diff --git a/testdata/conformance/openapi/component-reuse.golden.json b/testdata/conformance/openapi/component-reuse.golden.json index fed21dbd..6df36c28 100644 --- a/testdata/conformance/openapi/component-reuse.golden.json +++ b/testdata/conformance/openapi/component-reuse.golden.json @@ -319,9 +319,8 @@ ], "errors": [ { - "type": { - "target": "t/anon/components/responses/Failure/content/application~1json/schema", - "nullable": false + "name": { + "hint": "default" }, "conditions": { "statusCodes": [ @@ -331,29 +330,19 @@ } ] }, - "docs": { - "description": "anything else" - }, - "unmodeled": { - "openapi:content": { - "reason": "no_ir_home", - "value": { - "application/json": { - "schema": { - "properties": { - "message": { - "type": "string" - } - }, - "type": "object" - } + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/anon/components/responses/Failure/content/application~1json/schema", + "nullable": false } - }, - "provenance": { - "source": 0, - "pointer": "/components/responses/Failure/content" } - } + ] + }, + "docs": { + "description": "anything else" } } ], @@ -714,17 +703,6 @@ "auth": null } ], - "diagnostics": [ - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "error response media type has no ErrorCase home; content map kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/components/responses/Failure" - } - } - ], "sources": [ { "format": "openapi@3.1", diff --git a/testdata/conformance/openapi/extensions-x.golden.json b/testdata/conformance/openapi/extensions-x.golden.json index e4affc33..14e03bee 100644 --- a/testdata/conformance/openapi/extensions-x.golden.json +++ b/testdata/conformance/openapi/extensions-x.golden.json @@ -159,9 +159,8 @@ ], "errors": [ { - "type": { - "target": "", - "nullable": false + "name": { + "hint": "404" }, "conditions": { "statusCodes": [ diff --git a/testdata/conformance/openapi/per-status-errors.golden.json b/testdata/conformance/openapi/per-status-errors.golden.json index f587672d..efa04676 100644 --- a/testdata/conformance/openapi/per-status-errors.golden.json +++ b/testdata/conformance/openapi/per-status-errors.golden.json @@ -45,9 +45,8 @@ ], "errors": [ { - "type": { - "target": "t/openapi/components/schemas/Err", - "nullable": false + "name": { + "hint": "404" }, "conditions": { "statusCodes": [ @@ -57,31 +56,116 @@ } ] }, + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/openapi/components/schemas/Err", + "nullable": false + } + } + ] + }, "fault": "client", "docs": { "description": "not found" + } + }, + { + "name": { + "hint": "429" }, - "unmodeled": { - "openapi:content": { - "reason": "no_ir_home", - "value": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Err" - } + "conditions": { + "statusCodes": [ + { + "from": 429, + "to": 429 + } + ] + }, + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/openapi/components/schemas/Err", + "nullable": false } }, + { + "mediaType": "application/problem+json", + "type": { + "target": "t/openapi/components/schemas/Problem", + "nullable": false + } + } + ] + }, + "headers": [ + { + "id": "p/openapi/paths/~1widgets/get/responses/429/headers/Retry-After", + "name": { + "source": "Retry-After", + "canonical": "retry_after" + }, + "wireName": "Retry-After", + "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": "/paths/~1widgets/get/responses/429/headers/Retry-After" + } + }, + { + "id": "p/openapi/paths/~1widgets/get/responses/429/headers/X-RateLimit-Remaining", + "name": { + "source": "X-RateLimit-Remaining", + "canonical": "x_rate_limit_remaining" + }, + "wireName": "X-RateLimit-Remaining", + "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": "/paths/~1widgets/get/responses/404/content" + "pointer": "/paths/~1widgets/get/responses/429/headers/X-RateLimit-Remaining" } } + ], + "fault": "client", + "docs": { + "description": "slow down" } }, { - "type": { - "target": "", - "nullable": false + "name": { + "hint": "5_xx" }, "conditions": { "statusCodes": [ @@ -97,9 +181,8 @@ } }, { - "type": { - "target": "", - "nullable": false + "name": { + "hint": "default" }, "conditions": { "statusCodes": [ @@ -190,6 +273,92 @@ "positional": false, "inputOnly": false }, + "t/openapi/components/schemas/Problem": { + "kind": "model", + "id": "t/openapi/components/schemas/Problem", + "name": { + "source": "Problem", + "canonical": "problem" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Problem" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Problem/properties/title", + "name": { + "source": "title", + "canonical": "title" + }, + "wireName": "title", + "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/Problem/properties/title" + } + }, + { + "id": "p/openapi/components/schemas/Problem/properties/status", + "name": { + "source": "status", + "canonical": "status" + }, + "wireName": "status", + "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/Problem/properties/status" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, + "t/prim/integer": { + "kind": "primitive", + "id": "t/prim/integer", + "name": {}, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0 + }, + "prim": "integer" + }, "t/prim/string": { "kind": "primitive", "id": "t/prim/string", @@ -213,22 +382,11 @@ "auth": null } ], - "diagnostics": [ - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "error response media type has no ErrorCase home; content map kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/paths/~1widgets/get/responses/404" - } - } - ], "sources": [ { "format": "openapi@3.1", "path": "per-status-errors.yaml", - "hash": "f688d76cbabd110346bccc9c5d4f88a504b6fc9d034295ba3d5bc5b41340aea9" + "hash": "67887ccb06ad4e724eb61d53fb79619f13709d5a459d56763bbc3184c88dac74" } ] } diff --git a/testdata/conformance/openapi/per-status-errors.yaml b/testdata/conformance/openapi/per-status-errors.yaml index 199bc478..6e95dcbc 100644 --- a/testdata/conformance/openapi/per-status-errors.yaml +++ b/testdata/conformance/openapi/per-status-errors.yaml @@ -12,6 +12,16 @@ paths: content: application/json: schema: {$ref: '#/components/schemas/Err'} + "429": + description: slow down + headers: + Retry-After: {schema: {type: integer}} + X-RateLimit-Remaining: {schema: {type: integer}} + content: + application/json: + schema: {$ref: '#/components/schemas/Err'} + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} "5XX": description: server error default: @@ -22,3 +32,8 @@ components: type: object properties: message: {type: string} + Problem: + type: object + properties: + title: {type: string} + status: {type: integer} diff --git a/testdata/conformance/openapi/response-links.golden.json b/testdata/conformance/openapi/response-links.golden.json index d1e2f8ce..ce5b8816 100644 --- a/testdata/conformance/openapi/response-links.golden.json +++ b/testdata/conformance/openapi/response-links.golden.json @@ -62,9 +62,8 @@ ], "errors": [ { - "type": { - "target": "", - "nullable": false + "name": { + "hint": "409" }, "conditions": { "statusCodes": [ diff --git a/testdata/golden/openapi/petstore.golden.json b/testdata/golden/openapi/petstore.golden.json index f4b17b49..7a1b9f33 100644 --- a/testdata/golden/openapi/petstore.golden.json +++ b/testdata/golden/openapi/petstore.golden.json @@ -87,9 +87,8 @@ ], "errors": [ { - "type": { - "target": "t/openapi/components/schemas/Error", - "nullable": false + "name": { + "hint": "default" }, "conditions": { "statusCodes": [ @@ -99,24 +98,19 @@ } ] }, - "docs": { - "description": "Unexpected error" - }, - "unmodeled": { - "openapi:content": { - "reason": "no_ir_home", - "value": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/openapi/components/schemas/Error", + "nullable": false } - }, - "provenance": { - "source": 0, - "pointer": "/paths/~1pets/get/responses/default/content" } - } + ] + }, + "docs": { + "description": "Unexpected error" } } ], @@ -202,9 +196,8 @@ ], "errors": [ { - "type": { - "target": "t/openapi/components/schemas/Error", - "nullable": false + "name": { + "hint": "404" }, "conditions": { "statusCodes": [ @@ -214,25 +207,20 @@ } ] }, + "payload": { + "contents": [ + { + "mediaType": "application/json", + "type": { + "target": "t/openapi/components/schemas/Error", + "nullable": false + } + } + ] + }, "fault": "client", "docs": { "description": "Not found" - }, - "unmodeled": { - "openapi:content": { - "reason": "no_ir_home", - "value": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "provenance": { - "source": 0, - "pointer": "/paths/~1pets/post/responses/404/content" - } - } } } ], @@ -830,26 +818,6 @@ } } ], - "diagnostics": [ - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "error response media type has no ErrorCase home; content map kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/paths/~1pets/get/responses/default" - } - }, - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "error response media type has no ErrorCase home; content map kept under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/paths/~1pets/post/responses/404" - } - } - ], "sources": [ { "format": "openapi@3.1", From 608c2040cb928c3be2bfa8687443835f3c0d8df1 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Mon, 7 Sep 2026 23:21:49 +0300 Subject: [PATCH 06/13] feat(ir)!: give Deprecation a RemovalDate and promote x-sunset into it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x-sunset echoes RFC 8594's Sunset header, which is a date by definition, but the default promotion mapping read it into Deprecation.RemovalVersion — a field whose name, doc comment and sibling all say version. A consumer deciding whether removing a deprecated operation is breaking compares a sunset against a release date, and could not tell which spelling it had been handed without re-parsing the string. Take issue #417's option 1: a distinct RemovalDate beside RemovalVersion, with x-sunset promoting to the date. A version and a date are two facts, not two spellings of one — a document may state both ("gone in 3.0.0", "gone on 2026-08-01"), and neither is derivable from the other without a release calendar the IR does not have. A single field carrying which spelling it holds (option 2) would have to drop whichever fact it read second, so it costs losslessness to buy nothing a second field does not already give: the field a value arrives in is what says which fact it is. Deliberately out of scope, and stated in ir-design.md and at the reading site: RemovalDate is the source's own text, neither parsed nor normalized. No source format defines the field, so none defines its format; and the key→field mapping is caller policy, so a key pointed at the date field is the caller's statement that it holds a date. Morphic records which fact was stated and leaves the calendar to the consumer. BREAKING CHANGE: Deprecation gains removalDate, and x-sunset now fills it instead of removalVersion. A consumer reading removalVersion for a sunset reads an empty field until it moves. No default key names RemovalVersion any more — a document stating a removal version names its own key, per promotion rule 1 — so the corpus stops witnessing that field and it joins unwitnessed.golden.txt. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1 --- .../openapi/internal/lowering/promotion.go | 20 ++++-- .../internal/lowering/promotion_test.go | 7 ++- compilers/openapi/options.go | 2 + compilers/openapi/promotion_test.go | 63 +++++++++++++++++-- docs/ir-design.md | 31 ++++++--- ir/docs.go | 19 +++++- ir/docs_test.go | 8 ++- ir/helpers_test.go | 1 + .../openapi/extension-promotion.golden.json | 10 +-- .../openapi/extension-promotion.yaml | 4 +- .../openapi/unwitnessed.golden.txt | 1 + 11 files changed, 134 insertions(+), 32 deletions(-) diff --git a/compilers/openapi/internal/lowering/promotion.go b/compilers/openapi/internal/lowering/promotion.go index a672623f..90866340 100644 --- a/compilers/openapi/internal/lowering/promotion.go +++ b/compilers/openapi/internal/lowering/promotion.go @@ -38,8 +38,13 @@ const ( TargetDeprecationMessage ExtensionTarget = "deprecation.message" // TargetDeprecationSince fills ir.Deprecation.Since. TargetDeprecationSince ExtensionTarget = "deprecation.since" - // TargetDeprecationRemovalVersion fills ir.Deprecation.RemovalVersion. + // TargetDeprecationRemovalVersion fills ir.Deprecation.RemovalVersion. No + // default key names it: the one convention in wide use for a scheduled + // removal, x-sunset, states a date, and a document that spells a removal + // *version* names its own key. TargetDeprecationRemovalVersion ExtensionTarget = "deprecation.removalVersion" + // TargetDeprecationRemovalDate fills ir.Deprecation.RemovalDate. + TargetDeprecationRemovalDate ExtensionTarget = "deprecation.removalDate" ) // ExtensionPromotions is the vendor-extension promotion policy: which x-* keys @@ -71,7 +76,9 @@ func DefaultExtensionPromotions() map[string]ExtensionTarget { return map[string]ExtensionTarget{ "x-deprecated-reason": TargetDeprecationMessage, "x-deprecated-since": TargetDeprecationSince, - "x-sunset": TargetDeprecationRemovalVersion, + // x-sunset echoes the RFC 8594 Sunset header, which is a date by + // definition, so it fills the date field and not the version one. + "x-sunset": TargetDeprecationRemovalDate, } } @@ -127,14 +134,19 @@ func deprecationField(dep *ir.Deprecation, target ExtensionTarget) *string { return &dep.Since case TargetDeprecationRemovalVersion: return &dep.RemovalVersion + case TargetDeprecationRemovalDate: + return &dep.RemovalDate default: return nil } } // extensionText reads a preserved extension value as a string. Every -// Deprecation field is prose or a version, so a value of any other JSON shape -// is a document meaning something else by the key. +// Deprecation field is prose, a version or a date, so a value of any other JSON +// shape is a document meaning something else by the key. Text of the right JSON +// shape is taken as written — a date is not parsed here, because the mapping is +// the caller's policy and a key it points at the date field is its statement +// that the key holds one. func extensionText(raw ir.RawValue) (string, bool) { var text string if err := json.Unmarshal(raw, &text); err != nil { diff --git a/compilers/openapi/internal/lowering/promotion_test.go b/compilers/openapi/internal/lowering/promotion_test.go index 9ec944f9..92dd043b 100644 --- a/compilers/openapi/internal/lowering/promotion_test.go +++ b/compilers/openapi/internal/lowering/promotion_test.go @@ -33,8 +33,10 @@ func vendorExtension(rawJSON string) ir.UnmodeledEntry { } // TestPromoteDeprecation_FillsTheFieldsThePolicyNames pins what each mapping -// writes, one field at a time, because the three share a struct and a promotion -// writing the wrong member of it would still look filled. +// writes, one field at a time, because they share a struct and a promotion +// writing the wrong member of it would still look filled. The removal pair is +// why that matters most: a date written into the version field is the defect +// GitHub #417 records, and it reads as a filled Deprecation either way. func TestPromoteDeprecation_FillsTheFieldsThePolicyNames(t *testing.T) { t.Parallel() tests := []struct { @@ -45,6 +47,7 @@ func TestPromoteDeprecation_FillsTheFieldsThePolicyNames(t *testing.T) { {"message", lowering.TargetDeprecationMessage, ir.Deprecation{Message: "why"}}, {"since", lowering.TargetDeprecationSince, ir.Deprecation{Since: "why"}}, {"removal version", lowering.TargetDeprecationRemovalVersion, ir.Deprecation{RemovalVersion: "why"}}, + {"removal date", lowering.TargetDeprecationRemovalDate, ir.Deprecation{RemovalDate: "why"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/compilers/openapi/options.go b/compilers/openapi/options.go index b59513ed..03852b90 100644 --- a/compilers/openapi/options.go +++ b/compilers/openapi/options.go @@ -56,6 +56,8 @@ const ( TargetDeprecationSince = lowering.TargetDeprecationSince // TargetDeprecationRemovalVersion fills ir.Deprecation.RemovalVersion. TargetDeprecationRemovalVersion = lowering.TargetDeprecationRemovalVersion + // TargetDeprecationRemovalDate fills ir.Deprecation.RemovalDate. + TargetDeprecationRemovalDate = lowering.TargetDeprecationRemovalDate ) // DefaultExtensionPromotions returns the extension-to-field mapping applied diff --git a/compilers/openapi/promotion_test.go b/compilers/openapi/promotion_test.go index 32f135b0..b628f510 100644 --- a/compilers/openapi/promotion_test.go +++ b/compilers/openapi/promotion_test.go @@ -84,12 +84,15 @@ func assertExtensionPromotion(t *testing.T, doc *ir.Document, diags []ir.Diagnos op, ok := opByName(doc, "getX") require.True(t, ok) assert.Equal(t, "1.2.0", op.Deprecation.Since) - assert.Equal(t, "2.0.0", op.Deprecation.RemovalVersion) + assert.Equal(t, "2026-08-01", op.Deprecation.RemovalDate, + "x-sunset is a date, so it reaches the date field") + assert.Empty(t, op.Deprecation.RemovalVersion, + "a sunset date does not land in the field a consumer reads as a version") require.Len(t, op.Params, 1) require.NotNil(t, op.Params[0].Deprecation) - assert.Equal(t, "3.0.0", op.Params[0].Deprecation.RemovalVersion, - "the parameter's own x-sunset reaches its own removal version, not the operation's") + assert.Equal(t, "2027-01-15", op.Params[0].Deprecation.RemovalDate, + "the parameter's own x-sunset reaches its own removal date, not the operation's") assertPromotionDeclined(t, doc, diags) } @@ -209,11 +212,59 @@ paths: op, ok := opByName(doc, "getX") require.True(t, ok) require.NotNil(t, op.Deprecation) - for _, got := range map[openapi.ExtensionTarget]string{ + + // Read off the defaults rather than listing the pairs, so a mapping this + // test does not know about fails here instead of going unread. + fields := map[openapi.ExtensionTarget]string{ openapi.TargetDeprecationMessage: op.Deprecation.Message, openapi.TargetDeprecationSince: op.Deprecation.Since, openapi.TargetDeprecationRemovalVersion: op.Deprecation.RemovalVersion, - } { - assert.Equal(t, "filled", got, "every default target is filled by its default key") + openapi.TargetDeprecationRemovalDate: op.Deprecation.RemovalDate, + } + named := map[openapi.ExtensionTarget]bool{} + for key, target := range defaults { + got, known := fields[target] + require.True(t, known, "%s is a default target this test reads no field for", target) + assert.Equal(t, "filled", got, "%s is filled by its default key %s", target, key) + named[target] = true } + for target, got := range fields { + if !named[target] { + assert.Empty(t, got, "%s is filled by no default key, so it stays empty", target) + } + } +} + +// TestPromotion_RemovalDateAndVersionAreSeparateFacts pins why a scheduled +// removal is two fields rather than one field carrying which spelling it holds +// (GitHub #417). A document can state both — a sunset date and the release it +// goes in — and one field would have to drop whichever it read second. +func TestPromotion_RemovalDateAndVersionAreSeparateFacts(t *testing.T) { + t.Parallel() + both := openapi.Options{Promotions: openapi.ExtensionPromotions{ + Targets: map[string]openapi.ExtensionTarget{ + "x-sunset": openapi.TargetDeprecationRemovalDate, + "x-gone-in": openapi.TargetDeprecationRemovalVersion, + }, + }} + spec := `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /x: + get: + operationId: getX + deprecated: true + x-sunset: "2026-08-01" + x-gone-in: "9.0.0" + responses: + "200": + description: ok +` + doc := compilePromotionSpec(t, spec, both) + op, ok := opByName(doc, "getX") + require.True(t, ok) + require.NotNil(t, op.Deprecation) + assert.Equal(t, "2026-08-01", op.Deprecation.RemovalDate) + assert.Equal(t, "9.0.0", op.Deprecation.RemovalVersion, + "both facts survive; neither overwrites the other") } diff --git a/docs/ir-design.md b/docs/ir-design.md index 28d4d119..36109f62 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1671,7 +1671,14 @@ type Docs struct { ExternalDocs []Link // {URL, Description} } -type Deprecation struct { Message, Since, RemovalVersion string } +type Deprecation struct { Message, Since, RemovalVersion, RemovalDate string } +// A scheduled removal is two fields because a version and a date are two facts, not two +// spellings of one: a document may state either or both, and neither is derivable from the +// other without a release calendar the IR does not have. A consumer deciding whether removing +// a deprecated entity is breaking compares a removal date against a release date, so it must +// be able to tell which fact it holds without re-parsing the string. RemovalDate is the +// source's own text, unparsed and unnormalized — no source format defines the field, so none +// defines its format either. type Example struct { Name string @@ -1792,12 +1799,13 @@ where the IR expects them, so there is no reason to record and no unmodelled con #### Promoting a vendor extension into the field it is the only spelling for Several typed fields model information no source format gives a keyword for, so the only way a -document can state it is a vendor extension: `Deprecation.Message`/`Since`/`RemovalVersion`, -`Pagination.*`, `LongRunning`, `Idempotency`, `ErrorCase.Retryable`/`Throttling`, `Enum.Flags`, -`EnumMember.Name`, `Sensitive` and `Secret`. Reading such an extension into its field is -**promotion**, and because the format assigns an `x-*` key no semantics at all, promotion is a -heuristic — invariant 6 applies to it in full. Four rules, so that no emitter has to re-derive -this from `Unmodeled` and no two derive it differently: +document can state it is a vendor extension: +`Deprecation.Message`/`Since`/`RemovalVersion`/`RemovalDate`, `Pagination.*`, `LongRunning`, +`Idempotency`, `ErrorCase.Retryable`/`Throttling`, `Enum.Flags`, `EnumMember.Name`, `Sensitive` +and `Secret`. Reading such an extension into its field is **promotion**, and because the format +assigns an `x-*` key no semantics at all, promotion is a heuristic — invariant 6 applies to it +in full. Four rules, so that no emitter has to re-derive this from `Unmodeled` and no two derive +it differently: 1. **The mapping is injectable policy, default-on and disableable**, per compiler. Its default contents are conventions, not standards: nothing in any specification says `x-deprecated-reason` @@ -1815,8 +1823,13 @@ this from `Unmodeled` and no two derive it differently: follows it rather than preceding it — which is the order `Parameter` went through: it was the instance this rule named until it gained the `Provenance` §7.2 now gives it. -A value the mapped field cannot hold — anything but text, for the three `Deprecation` members — is -reported and not coerced, since the document means something else by the key. +A value the mapped field cannot hold — anything but text, for the four `Deprecation` members — is +reported and not coerced, since the document means something else by the key. Text of the right +JSON shape is taken as written: `x-sunset` fills `RemovalDate` and not `RemovalVersion` because +the header it echoes ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)) is a date by +definition, and the mapping is where that reading is stated — the promotion does not then parse +the date to confirm it. No default key names `RemovalVersion`: a document stating a removal +*version* names its own key, per rule 1. ### 12.1 One structural home per declaration diff --git a/ir/docs.go b/ir/docs.go index 02d4997a..21325437 100644 --- a/ir/docs.go +++ b/ir/docs.go @@ -21,13 +21,30 @@ type Link struct { } // Deprecation marks an entity as deprecated with optional migration guidance. +// +// A scheduled removal has two fields because a version and a date are two +// facts, not two spellings of one: a document may state either or both ("gone +// in 3.0.0", "gone on 2026-08-01"), and neither is derivable from the other +// without a release calendar the IR does not have. Keeping them apart is what +// lets a consumer compare a scheduled removal against a release date without +// re-parsing the string to work out which kind it was handed. type Deprecation struct { // Message explains the deprecation and any migration path. Message string `json:"message,omitempty"` // Since is the version in which the entity was deprecated. Since string `json:"since,omitempty"` - // RemovalVersion is the version in which the entity is scheduled for removal. + // RemovalVersion is the version in which the entity is scheduled for + // removal. A removal the source states as a date belongs in RemovalDate. RemovalVersion string `json:"removalVersion,omitempty"` + // RemovalDate is the date on which the entity is scheduled for removal — + // the fact an RFC 8594 Sunset carries, and what the OpenAPI x-sunset + // convention echoing it holds. + // + // It is the source's own text, neither parsed nor normalized: the IR + // records which fact the document stated and leaves the calendar to the + // consumer, since no source format defines the field and so none defines + // its format either. + RemovalDate string `json:"removalDate,omitempty"` } // Example is a documentation example. Field legality is contextual: diff --git a/ir/docs_test.go b/ir/docs_test.go index b1150209..cabc0a21 100644 --- a/ir/docs_test.go +++ b/ir/docs_test.go @@ -24,9 +24,11 @@ func TestLink_JSONContract(t *testing.T) { } // TestDeprecation_JSONContract pins Deprecation's omitempty contract — all -// three fields are optional, so an entity deprecated with no detail at all -// still marshals to an empty object rather than three empty strings — and -// that a fully populated Deprecation round-trips. +// four fields are optional, so an entity deprecated with no detail at all +// still marshals to an empty object rather than four empty strings — and +// that a fully populated Deprecation round-trips, RemovalVersion and +// RemovalDate included, since a consumer tells one from the other by which key +// it arrived under. func TestDeprecation_JSONContract(t *testing.T) { t.Parallel() assertJSONContract(t, ir.Deprecation{}, `{}`, *populatedDeprecation()) diff --git a/ir/helpers_test.go b/ir/helpers_test.go index f562cc08..113e8909 100644 --- a/ir/helpers_test.go +++ b/ir/helpers_test.go @@ -276,6 +276,7 @@ func populatedDeprecation() *ir.Deprecation { Message: "use v2 instead", Since: "1.2.0", RemovalVersion: "2.0.0", + RemovalDate: "2026-08-01", } } diff --git a/testdata/conformance/openapi/extension-promotion.golden.json b/testdata/conformance/openapi/extension-promotion.golden.json index b9f40b8b..8b41116b 100644 --- a/testdata/conformance/openapi/extension-promotion.golden.json +++ b/testdata/conformance/openapi/extension-promotion.golden.json @@ -28,7 +28,7 @@ "deprecation": { "message": "use getY instead", "since": "1.2.0", - "removalVersion": "2.0.0" + "removalDate": "2026-08-01" }, "params": [ { @@ -44,7 +44,7 @@ "docs": {}, "deprecation": { "message": "use filter instead", - "removalVersion": "3.0.0" + "removalDate": "2027-01-15" }, "unmodeled": { "openapi:x-deprecated-reason": { @@ -57,7 +57,7 @@ }, "openapi:x-sunset": { "reason": "vendor_extension", - "value": "3.0.0", + "value": "2027-01-15", "provenance": { "source": 0, "pointer": "/paths/~1x/get/parameters/0/x-sunset" @@ -175,7 +175,7 @@ }, "openapi:x-sunset": { "reason": "vendor_extension", - "value": "2.0.0", + "value": "2026-08-01", "provenance": { "source": 0, "pointer": "/paths/~1x/get/x-sunset" @@ -434,7 +434,7 @@ { "format": "openapi@3.1", "path": "extension-promotion.yaml", - "hash": "aa16a7bd98de256e0feb2bf240903d08db7f651a6b802255d7d89a98a69bcadd" + "hash": "2433e30d378223aa920d20cb51cce28080a3a325c92f36dbbe7662470fbee376" } ] } diff --git a/testdata/conformance/openapi/extension-promotion.yaml b/testdata/conformance/openapi/extension-promotion.yaml index 30e78be2..cd0f88e2 100644 --- a/testdata/conformance/openapi/extension-promotion.yaml +++ b/testdata/conformance/openapi/extension-promotion.yaml @@ -7,13 +7,13 @@ paths: deprecated: true x-deprecated-reason: use getY instead x-deprecated-since: "1.2.0" - x-sunset: "2.0.0" + x-sunset: "2026-08-01" parameters: - name: legacy in: query deprecated: true x-deprecated-reason: use filter instead - x-sunset: "3.0.0" + x-sunset: "2027-01-15" schema: {type: string} responses: "200": diff --git a/testdata/conformance/openapi/unwitnessed.golden.txt b/testdata/conformance/openapi/unwitnessed.golden.txt index f6a4bc2f..88950790 100644 --- a/testdata/conformance/openapi/unwitnessed.golden.txt +++ b/testdata/conformance/openapi/unwitnessed.golden.txt @@ -23,6 +23,7 @@ Content.SchemaFormat CtorValue.Args CtorValue.Name CtorValue.Scalar +Deprecation.RemovalVersion Discriminator.Envelope Discriminator.EnvelopeValueName Discriminator.Index From 5ec90fda7d006ca45870e60e7e59f59293d74ee0 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Mon, 7 Sep 2026 23:42:32 +0300 Subject: [PATCH 07/13] feat(compilers/openapi): promote x-extensible-enum onto Enum.Closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ir.Enum has carried a Closed bool since the IR was written, and the OpenAPI compiler set it true at both construction sites unconditionally. So the one key the format has for saying an enum is open, x-extensible-enum, survived only as a generic vendor_extension entry, and every consumer reading typed fields saw a closed enum whatever the document said. Open versus closed decides whether a generator emits a fallback member and whether a differ calls an added value breaking, so this was a wiring gap, not a modelling one. Add TargetEnumOpen to the promotion vocabulary, map x-extensible-enum onto it by default, and apply it in attachDeclaredAnnotations beside the deprecation promotion — the point at which a declaration's extensions have reached the node's map, which is what makes "the extension survives its own promotion" structural here as it is there. Every promotion property holds unchanged: the entry stays put with its vendor_extension reason, the node records extension-promotion in Provenance.Inferred, and a disabled policy writes nothing. The target names the fact rather than the field, which the rest of the vocabulary does not. Openness is the only half of that bool a document ever declares — a schema's `enum` is closed by definition — so a target named for Closed could only ever be written false and would read as its own opposite at every mapping naming it. For the same reason the key's presence is the statement rather than its value: the established spelling writes the member list as the value, and a list of members says nothing about openness the key naming it has not already said. A boolean is the one shape that does state it alone, so an explicit `false` is read as written rather than inverted. Deliberately out of scope, and stated in ir-design.md and at the reading site: a document writing x-extensible-enum *instead* of `enum`, with the members in the extension, lowers to no ir.Enum at all and there is no node to open. Minting one would be reading a member list out of a vendor key rather than promoting a field; the entry survives verbatim for a consumer that wants to. The corpus can now witness the matrix's open-enums row, so its matrixRowsUncovered reason is deleted rather than left to go stale, and extension-promotion.yaml gains the three enums that pin the three answers the reading has: the convention opens one, an explicit false declines to, and an enum naming no such key is untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1 --- compilers/openapi/conformance_matrix_test.go | 3 - compilers/openapi/conformance_test.go | 2 +- .../openapi/internal/lowering/promotion.go | 73 ++++++++ .../internal/lowering/promotion_test.go | 144 ++++++++++++++- compilers/openapi/internal/schema/schema.go | 7 + compilers/openapi/options.go | 2 + compilers/openapi/promotion_test.go | 70 +++++++- docs/ir-design.md | 24 ++- docs/ir-spec-matrix.md | 2 +- .../openapi/extension-promotion.golden.json | 170 +++++++++++++++++- .../openapi/extension-promotion.yaml | 11 ++ 11 files changed, 482 insertions(+), 26 deletions(-) diff --git a/compilers/openapi/conformance_matrix_test.go b/compilers/openapi/conformance_matrix_test.go index 912ba948..043d78b7 100644 --- a/compilers/openapi/conformance_matrix_test.go +++ b/compilers/openapi/conformance_matrix_test.go @@ -207,9 +207,6 @@ func TestConformance_MatrixRowNamesResolve(t *testing.T) { // that reads as closable and is not. func matrixRowsUncovered() map[string]string { return map[string]string{ - "open-enums": "OpenAPI has no open-enum keyword; the matrix's ⚠ is the " + - "anyOf: [{enum: [...]}, {type: string}] idiom, which lowers as an ordinary union " + - "and needs a spec pinning that the enum branch survives beside the open one", "pagination": "OpenAPI states it only through links and x-*, and this compiler keeps both " + "verbatim rather than reading either into ir.Pagination — response-links pins that they " + "survive. Invariant 6 puts the inference in a pass rather than in the compiler, so this " + diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index ce89b390..3ea44008 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -226,7 +226,7 @@ func conformanceCases() []conformanceCase { {"path-item-docs", assertPathItemDocs, []string{"docs-summary-description"}}, {"path-item-operations", assertPathItemOperations, []string{"http-binding"}}, {"deprecation", assertDeprecation, []string{"deprecation"}}, - {"extension-promotion", assertExtensionPromotion, []string{"deprecation"}}, + {"extension-promotion", assertExtensionPromotion, []string{"deprecation", "open-enums"}}, {"examples", assertExamples, []string{"examples"}}, {"docs-summary-desc", assertDocsSummaryDesc, []string{"docs-summary-description"}}, {"extensions-x", assertExtensionsX, []string{"vendor-extensions"}}, diff --git a/compilers/openapi/internal/lowering/promotion.go b/compilers/openapi/internal/lowering/promotion.go index 90866340..03d5470d 100644 --- a/compilers/openapi/internal/lowering/promotion.go +++ b/compilers/openapi/internal/lowering/promotion.go @@ -45,6 +45,21 @@ const ( TargetDeprecationRemovalVersion ExtensionTarget = "deprecation.removalVersion" // TargetDeprecationRemovalDate fills ir.Deprecation.RemovalDate. TargetDeprecationRemovalDate ExtensionTarget = "deprecation.removalDate" + + // TargetEnumOpen clears ir.Enum.Closed, saying the member set admits values + // the document does not list. + // + // It names the fact rather than the field, which the rest of this vocabulary + // does not, because openness is the only half of that bool a document ever + // declares: a schema's `enum` is closed by definition, so a target named for + // Closed could only ever be written false and would read as its own opposite + // at every mapping that names it. + // + // The key's presence is the statement. The established spelling, + // x-extensible-enum, writes the member list as its value, so there is no flag + // to read there — but a boolean value *is* a statement about openness, and an + // explicit `false` is honoured rather than inverted (extensionOpenness). + TargetEnumOpen ExtensionTarget = "enum.open" ) // ExtensionPromotions is the vendor-extension promotion policy: which x-* keys @@ -79,6 +94,9 @@ func DefaultExtensionPromotions() map[string]ExtensionTarget { // x-sunset echoes the RFC 8594 Sunset header, which is a date by // definition, so it fills the date field and not the version one. "x-sunset": TargetDeprecationRemovalDate, + // x-extensible-enum is the convention for an enum a service may add + // members to, so it says the set is open and not that it is closed. + "x-extensible-enum": TargetEnumOpen, } } @@ -123,6 +141,44 @@ func (c Ctx) PromoteDeprecation(unmodeled ir.Unmodeled, dep *ir.Deprecation, pro return diags } +// PromoteEnumOpenness clears e.Closed when the vendor extensions kept in +// unmodeled include a key the policy maps to TargetEnumOpen, and marks prov +// with the heuristic when it does. +// +// It is PromoteDeprecation at a second carrier, with the same three properties: +// the entry it reads stays where it was, the node records that a heuristic +// wrote the field, and a disabled policy writes nothing. What differs is that +// the fact is stated by the key being present rather than by a value, so this +// reports nothing: the deprecation reading declines a value it cannot hold and +// says so, while here every value shape but an explicit `false` is a key that +// means what its name says (TargetEnumOpen, extensionOpenness). +// +// The order the policy's keys are visited in is not fixed, because it cannot +// matter: a key that states openness writes the same field the same value as +// any other, a key that does not is skipped rather than deciding anything, and +// none of them reports. Two keys disagreeing therefore read the same either +// way round — open, because one of them said so. +// +// Deliberately out of scope: a document that writes x-extensible-enum *instead* +// of `enum`, listing the members in the extension, lowers to no ir.Enum at all, +// so there is no node here to open. Reading a member list out of an extension +// would be minting an enum from a vendor key rather than promoting a field, and +// the entry survives verbatim for a consumer that wants to (GitHub #427). +func (c Ctx) PromoteEnumOpenness(unmodeled ir.Unmodeled, e *ir.Enum, prov *ir.Provenance) { + if e == nil || prov == nil || len(unmodeled) == 0 || len(c.promotions) == 0 { + return + } + for key, target := range c.promotions { + entry, declared := unmodeled[extensionKeyPrefix+key] + if target != TargetEnumOpen || !declared || !extensionOpenness(entry.Value) { + continue + } + e.Closed = false + markInferred(prov, ExtensionPromotionHeuristic) + return + } +} + // deprecationField returns the field target names on dep, or nil when target // names something that is not a deprecation field. A policy may map a key to // any target in the vocabulary, and most carriers answer for only some of it. @@ -155,6 +211,23 @@ func extensionText(raw ir.RawValue) (string, bool) { return text, true } +// extensionOpenness reads a preserved extension value as a statement that an +// enum's member set is open. +// +// The key's presence is the statement, so a value of any shape but a boolean +// reads as open: x-extensible-enum's established spelling writes the *members* +// as its value, and a list of members says nothing about openness that the key +// naming it has not already said. A boolean is the one shape that does state +// openness on its own, so an explicit false is read as written — a document +// saying the set is not extensible, which is not something to invert. +func extensionOpenness(raw ir.RawValue) bool { + var open bool + if err := json.Unmarshal(raw, &open); err != nil { + return true + } + return open +} + // markInferred adds one heuristic's name to a provenance, keeping any already // there. Provenance.Inferred holds a single string and more than one heuristic // can reach a node — an operation grouped by path prefix whose deprecation diff --git a/compilers/openapi/internal/lowering/promotion_test.go b/compilers/openapi/internal/lowering/promotion_test.go index 92dd043b..f0db9930 100644 --- a/compilers/openapi/internal/lowering/promotion_test.go +++ b/compilers/openapi/internal/lowering/promotion_test.go @@ -267,6 +267,30 @@ func stringLiteral(value ast.Expr, typed bool) (string, bool) { return lit.Value, true } +// appliers is one closure per promote function the package exports. Each runs +// c's policy against a carrier of its own — the key "x-k", which every caller +// below maps — and reports whether that carrier changed. +// +// The census is hand-written, so a promote function this list does not know +// about is the same gap one level down: the check beneath it would then declare +// a target unapplied that a real lowering does apply. +func appliers() []func(lowering.Ctx) (bool, []ir.Diagnostic) { + return []func(lowering.Ctx) (bool, []ir.Diagnostic){ + func(c lowering.Ctx) (bool, []ir.Diagnostic) { + var dep ir.Deprecation + var prov ir.Provenance + diags := c.PromoteDeprecation(ir.Unmodeled{"openapi:x-k": vendorExtension(`"v"`)}, &dep, &prov) + return dep != (ir.Deprecation{}), diags + }, + func(c lowering.Ctx) (bool, []ir.Diagnostic) { + enum := ir.Enum{Closed: true} + var prov ir.Provenance + c.PromoteEnumOpenness(ir.Unmodeled{"openapi:x-k": vendorExtension(`"v"`)}, &enum, &prov) + return !enum.Closed, nil + }, + } +} + // TestExtensionTarget_EveryDeclaredTargetHasAnApplier holds the vocabulary to // the appliers, which is the half of "a target is a constant and an applier" // that nothing else checks: the constant alone compiles, maps cleanly, and @@ -277,21 +301,125 @@ func stringLiteral(value ast.Expr, typed bool) (string, bool) { // vocabulary entry that fills nothing is the likeliest way this seam breaks. // // A target belonging to a family this package cannot yet apply fails here on -// purpose: adding one means adding its applier, and teaching this test which -// applier answers for it, exactly as a new census keyword means adding its arm. +// purpose: adding one means adding its applier, and teaching appliers() which +// promote function answers for it, exactly as a new census keyword means adding +// its arm. func TestExtensionTarget_EveryDeclaredTargetHasAnApplier(t *testing.T) { t.Parallel() for _, target := range declaredTargets(t) { c := promotionCtx(lowering.ExtensionPromotions{ Targets: map[string]lowering.ExtensionTarget{"x-k": target}, }) - var dep ir.Deprecation - var prov ir.Provenance - diags := c.PromoteDeprecation(ir.Unmodeled{"openapi:x-k": vendorExtension(`"v"`)}, &dep, &prov) - - assert.Empty(t, diags, "%s: a declared target reports nothing when it is applied", target) - assert.NotEqual(t, ir.Deprecation{}, dep, + var applied bool + for _, apply := range appliers() { + wrote, diags := apply(c) + assert.Empty(t, diags, "%s: a declared target reports nothing when it is applied", target) + applied = applied || wrote + } + assert.True(t, applied, "%s is declared in the vocabulary but no applier fills it, so a policy naming it "+ "promotes nothing and says nothing", target) } } + +// TestPromoteEnumOpenness_ClearsClosedAndMarksTheNode pins the promotion the +// vocabulary's one non-Deprecation target performs. Every enum the compiler +// builds is closed, so the write here is the whole of what x-extensible-enum +// buys a consumer, and the marker is what says a heuristic made it. +func TestPromoteEnumOpenness_ClearsClosedAndMarksTheNode(t *testing.T) { + t.Parallel() + tests := []struct { + name string + value string + }{ + {"the member list the convention writes", `["a","b"]`}, + {"an explicit true", `true`}, + {"a value that states nothing", `"whatever"`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + enum := ir.Enum{Closed: true} + var prov ir.Provenance + promotionCtx(lowering.ExtensionPromotions{}).PromoteEnumOpenness( + ir.Unmodeled{"openapi:x-extensible-enum": vendorExtension(tc.value)}, &enum, &prov) + + assert.False(t, enum.Closed, "the key says the member set is open") + assert.Equal(t, lowering.ExtensionPromotionHeuristic, prov.Inferred) + }) + } +} + +// TestPromoteEnumOpenness_LeavesTheEntryItRead is the losslessness half, for +// the same reason its deprecation twin is: the promotion is a second reading of +// a preserved entry, so a consumer that disagrees still has what was written — +// which for this key is the member list itself. +func TestPromoteEnumOpenness_LeavesTheEntryItRead(t *testing.T) { + t.Parallel() + unmodeled := ir.Unmodeled{"openapi:x-extensible-enum": vendorExtension(`["a","b"]`)} + enum := ir.Enum{Closed: true} + promotionCtx(lowering.ExtensionPromotions{}).PromoteEnumOpenness(unmodeled, &enum, &ir.Provenance{}) + + entry, kept := unmodeled["openapi:x-extensible-enum"] + require.True(t, kept, "the entry survives its own promotion") + assert.Equal(t, ir.ReasonVendorExtension, entry.Reason) + assert.JSONEq(t, `["a","b"]`, string(entry.Value)) +} + +// TestPromoteEnumOpenness_WritesNothing pins every shape that must leave the +// enum closed. The last row is the one that is not an absence: a document +// writing the key with a boolean false says the set is *not* extensible, and +// reading presence alone there would record the opposite of what it said. +func TestPromoteEnumOpenness_WritesNothing(t *testing.T) { + t.Parallel() + filled := ir.Unmodeled{"openapi:x-extensible-enum": vendorExtension(`["a","b"]`)} + tests := []struct { + name string + policy lowering.ExtensionPromotions + unmodeled ir.Unmodeled + }{ + {"promotion disabled", lowering.ExtensionPromotions{Disabled: true}, filled}, + {"no extensions kept", lowering.ExtensionPromotions{}, nil}, + {"a key the document did not write", lowering.ExtensionPromotions{}, ir.Unmodeled{ + "openapi:x-other": vendorExtension(`["a","b"]`), + }}, + { + "a key mapped to another target", + lowering.ExtensionPromotions{Targets: map[string]lowering.ExtensionTarget{ + "x-extensible-enum": lowering.TargetDeprecationMessage, + }}, + filled, + }, + { + "an explicit false", + lowering.ExtensionPromotions{}, + ir.Unmodeled{"openapi:x-extensible-enum": vendorExtension(`false`)}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + enum := ir.Enum{Closed: true} + var prov ir.Provenance + promotionCtx(tc.policy).PromoteEnumOpenness(tc.unmodeled, &enum, &prov) + + assert.True(t, enum.Closed, "the enum stays as the format declared it") + assert.Empty(t, prov.Inferred, "nothing was inferred, so nothing is marked") + }) + } +} + +// TestPromoteEnumOpenness_NonEnumCarrierIsTheWholeAnswer pins the nil cases, +// which are the ordinary shape rather than a guard: most nodes an x-* key can +// sit on are not enums, and a node with no provenance could not record the +// guess (promotion rule 4). +func TestPromoteEnumOpenness_NonEnumCarrierIsTheWholeAnswer(t *testing.T) { + t.Parallel() + c := promotionCtx(lowering.ExtensionPromotions{}) + unmodeled := ir.Unmodeled{"openapi:x-extensible-enum": vendorExtension(`["a","b"]`)} + c.PromoteEnumOpenness(unmodeled, nil, &ir.Provenance{}) + + enum := ir.Enum{Closed: true} + c.PromoteEnumOpenness(unmodeled, &enum, nil) + assert.True(t, enum.Closed, "with nowhere to record the guess, none is made") +} diff --git a/compilers/openapi/internal/schema/schema.go b/compilers/openapi/internal/schema/schema.go index f9136cbb..5f8f17f1 100644 --- a/compilers/openapi/internal/schema/schema.go +++ b/compilers/openapi/internal/schema/schema.go @@ -1246,6 +1246,13 @@ func attachDeclaredAnnotations(c lowering.Ctx, ts *compile.Types, anchors *Ancho } common.Unmodeled = annotation.MergeUnmodeled(common.Unmodeled, a.Unmodeled) diags = append(diags, c.PromoteDeprecation(common.Unmodeled, common.Deprecation, &common.Provenance)...) + // The enum-openness promotion is applied here rather than where the Enum is + // built, for the same reason the deprecation one is: a promotion reads the + // preserved Unmodeled entries, and this is the point at which the + // declaration's extensions have reached the node's map. + if enum, isEnum := td.(*ir.Enum); isEnum { + c.PromoteEnumOpenness(common.Unmodeled, enum, &common.Provenance) + } if len(a.Examples) > 0 { common.Examples = a.Examples } diff --git a/compilers/openapi/options.go b/compilers/openapi/options.go index 03852b90..b2521d63 100644 --- a/compilers/openapi/options.go +++ b/compilers/openapi/options.go @@ -58,6 +58,8 @@ const ( TargetDeprecationRemovalVersion = lowering.TargetDeprecationRemovalVersion // TargetDeprecationRemovalDate fills ir.Deprecation.RemovalDate. TargetDeprecationRemovalDate = lowering.TargetDeprecationRemovalDate + // TargetEnumOpen clears ir.Enum.Closed. + TargetEnumOpen = lowering.TargetEnumOpen ) // DefaultExtensionPromotions returns the extension-to-field mapping applied diff --git a/compilers/openapi/promotion_test.go b/compilers/openapi/promotion_test.go index b628f510..15a7cdf3 100644 --- a/compilers/openapi/promotion_test.go +++ b/compilers/openapi/promotion_test.go @@ -94,9 +94,46 @@ func assertExtensionPromotion(t *testing.T, doc *ir.Document, diags []ir.Diagnos assert.Equal(t, "2027-01-15", op.Params[0].Deprecation.RemovalDate, "the parameter's own x-sunset reaches its own removal date, not the operation's") + assertEnumOpenness(t, doc) assertPromotionDeclined(t, doc, diags) } +// assertEnumOpenness is the corpus row for GitHub #427 and the matrix's +// open-enums row. ir.Enum.Closed is exactly the fact x-extensible-enum states, +// and every enum the compiler builds is closed, so without the promotion the +// extension changed nothing an emitter or a differ could read. +// +// The three schemas are the three answers the reading has: the convention's own +// spelling opens the enum, an explicit false declines to, and an enum that +// names no such key is untouched — the last so that the first is a promotion +// rather than a compiler that stopped closing enums. +func assertEnumOpenness(t *testing.T, doc *ir.Document) { + tests := []struct { + schema string + closed bool + inferred string + }{ + {"Size", false, "extension-promotion"}, + {"Shade", true, ""}, + {"Fixed", true, ""}, + } + for _, tc := range tests { + enum, ok := doc.Types[namedID(tc.schema)].(*ir.Enum) + require.True(t, ok, "%s lowers to an enum", tc.schema) + assert.Equal(t, tc.closed, enum.Closed, "%s openness", tc.schema) + assert.Equal(t, tc.inferred, enum.Provenance.Inferred, "%s heuristic marker", tc.schema) + } + + for _, schema := range []string{"Size", "Shade"} { + enum, ok := doc.Types[namedID(schema)].(*ir.Enum) + require.True(t, ok) + entry, kept := enum.Unmodeled["openapi:x-extensible-enum"] + require.True(t, kept, "%s keeps the extension whether or not it was read", schema) + assert.Equal(t, ir.ReasonVendorExtension, entry.Reason, + "%s promotion does not reclassify what it read", schema) + } +} + // assertPromotionDeclined pins the two shapes promotion refuses, both of which // leave the extension exactly where it was: a key on a node that never said it // was deprecated annotates nothing, and a value that is not text is a document @@ -193,6 +230,13 @@ func TestPromotion_DefaultTargetsAreTheOnesApplied(t *testing.T) { defaults := openapi.DefaultExtensionPromotions() require.NotEmpty(t, defaults, "an empty mapping would make this vacuous") + // Every default key is written twice, on a deprecated operation and on an + // enum, because the targets live on two carriers and a key reaching only the + // wrong one would read as a mapping that fills nothing. + keys := "" + for key := range defaults { + keys += " " + key + ": filled\n" + } spec := `openapi: 3.1.0 info: {title: T, version: "1"} paths: @@ -200,18 +244,21 @@ paths: get: operationId: getX deprecated: true -` - for key := range defaults { - spec += " " + key + ": filled\n" - } - spec += ` responses: +` + keys + ` responses: "200": description: ok -` +components: + schemas: + E: + type: string + enum: [a, b] +` + keys doc := compilePromotionSpec(t, spec, openapi.Options{}) op, ok := opByName(doc, "getX") require.True(t, ok) require.NotNil(t, op.Deprecation) + enum, ok := doc.Types[namedID("E")].(*ir.Enum) + require.True(t, ok) // Read off the defaults rather than listing the pairs, so a mapping this // test does not know about fails here instead of going unread. @@ -220,6 +267,7 @@ paths: openapi.TargetDeprecationSince: op.Deprecation.Since, openapi.TargetDeprecationRemovalVersion: op.Deprecation.RemovalVersion, openapi.TargetDeprecationRemovalDate: op.Deprecation.RemovalDate, + openapi.TargetEnumOpen: filledWhen(!enum.Closed), } named := map[openapi.ExtensionTarget]bool{} for key, target := range defaults { @@ -235,6 +283,16 @@ paths: } } +// filledWhen renders a target whose field is not text as the "filled" the text +// ones carry, so one table can read every default target rather than growing an +// arm per field type. +func filledWhen(promoted bool) string { + if promoted { + return "filled" + } + return "" +} + // TestPromotion_RemovalDateAndVersionAreSeparateFacts pins why a scheduled // removal is two fields rather than one field carrying which spelling it holds // (GitHub #417). A document can state both — a sunset date and the release it diff --git a/docs/ir-design.md b/docs/ir-design.md index 36109f62..1c071f6e 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1800,12 +1800,12 @@ where the IR expects them, so there is no reason to record and no unmodelled con Several typed fields model information no source format gives a keyword for, so the only way a document can state it is a vendor extension: -`Deprecation.Message`/`Since`/`RemovalVersion`/`RemovalDate`, `Pagination.*`, `LongRunning`, -`Idempotency`, `ErrorCase.Retryable`/`Throttling`, `Enum.Flags`, `EnumMember.Name`, `Sensitive` -and `Secret`. Reading such an extension into its field is **promotion**, and because the format -assigns an `x-*` key no semantics at all, promotion is a heuristic — invariant 6 applies to it -in full. Four rules, so that no emitter has to re-derive this from `Unmodeled` and no two derive -it differently: +`Deprecation.Message`/`Since`/`RemovalVersion`/`RemovalDate`, `Enum.Closed`, `Pagination.*`, +`LongRunning`, `Idempotency`, `ErrorCase.Retryable`/`Throttling`, `Enum.Flags`, +`EnumMember.Name`, `Sensitive` and `Secret`. Reading such an extension into its field is +**promotion**, and because the format assigns an `x-*` key no semantics at all, promotion is a +heuristic — invariant 6 applies to it in full. Four rules, so that no emitter has to re-derive +this from `Unmodeled` and no two derive it differently: 1. **The mapping is injectable policy, default-on and disableable**, per compiler. Its default contents are conventions, not standards: nothing in any specification says `x-deprecated-reason` @@ -1831,6 +1831,18 @@ definition, and the mapping is where that reading is stated — the promotion do the date to confirm it. No default key names `RemovalVersion`: a document stating a removal *version* names its own key, per rule 1. +`Enum.Closed` is the one target whose fact is stated by a key being *present* rather than by a +value, so nothing is read or reported there. Its default key, `x-extensible-enum`, writes the +member list as its own value, and a list of members says nothing about openness that the key +naming it has not already said; the promotion therefore clears `Closed` on presence. A boolean +value is the one shape that does state openness by itself, and an explicit `false` is read as +written rather than inverted. Only openness is ever promoted: a schema's `enum` is closed by +definition, so a document declares the open case or nothing, and the mapping names that fact +rather than the field's own polarity. A document that writes `x-extensible-enum` *instead* of +`enum` lowers to no `Enum` at all and there is no node to open — minting one from a vendor key +would be a compiler reading a member list out of an extension, not a promotion, so the entry is +left for a consumer that wants to. + ### 12.1 One structural home per declaration Documentation, deprecation, XML hints, examples, vendor extensions, validation-only keywords and diff --git a/docs/ir-spec-matrix.md b/docs/ir-spec-matrix.md index 685d0c33..83d206c4 100644 --- a/docs/ir-spec-matrix.md +++ b/docs/ir-spec-matrix.md @@ -30,7 +30,7 @@ the ones the next compiler will be first to bind to. | `negation` | Negation | ✅ not | — | — | — | — | ✅ not | — | — | | `enums-string` | Enums (string) | ✅ | ✅ | ✅ named members | ✅ enum | ✅ | ✅ | ⚠ | ⚠ atom unions | | `enums-numeric` | Enums (numeric, valued) | ✅ | ✅ | ✅ | ✅ intEnum | — | ✅ | ✅ | ⚠ int unions | -| `open-enums` | Open enums (unknown values allowed) | ⚠ anyOf trick | — | ⚠ union w/ string | ✅ (enums are open by default) | — | ⚠ | ✅ open (proto3/editions) / closed (proto2, per-enum feature) | ⚠ atom() fallback | +| `open-enums` | Open enums (unknown values allowed) | ⚠ anyOf trick, x-extensible-enum | — | ⚠ union w/ string | ✅ (enums are open by default) | — | ⚠ | ✅ open (proto3/editions) / closed (proto2, per-enum feature) | ⚠ atom() fallback | | `custom-scalars` | Custom scalars | ⚠ type+format | ⚠ | ✅ scalar extends | ⚠ traits | ✅ scalar | ⚠ | — | ✅ -type/-opaque | | `encoding-hints` | Wire encoding hints (@encode / format) | ✅ format | ✅ format | ✅ @encode | ✅ timestampFormat | — | ✅ | ✅ fixed/zigzag/packed/delimited | — (ETF fixed) | | `field-wire-ids` | Field wire IDs (numeric tags) | — | — | — | — | — | — | ✅ field numbers | ⚠ tuple positions | diff --git a/testdata/conformance/openapi/extension-promotion.golden.json b/testdata/conformance/openapi/extension-promotion.golden.json index 8b41116b..d4d0c4ee 100644 --- a/testdata/conformance/openapi/extension-promotion.golden.json +++ b/testdata/conformance/openapi/extension-promotion.golden.json @@ -252,6 +252,54 @@ } ], "types": { + "t/openapi/components/schemas/Fixed": { + "kind": "enum", + "id": "t/openapi/components/schemas/Fixed", + "name": { + "source": "Fixed", + "canonical": "fixed" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Fixed" + }, + "valueType": "string", + "members": [ + { + "name": { + "source": "one", + "canonical": "one" + }, + "value": { + "kind": "string", + "str": "one", + "bytes": null, + "list": null, + "object": null + }, + "docs": {} + }, + { + "name": { + "source": "two", + "canonical": "two" + }, + "value": { + "kind": "string", + "str": "two", + "bytes": null, + "list": null, + "object": null + }, + "docs": {} + } + ], + "closed": true, + "flags": false + }, "t/openapi/components/schemas/Old": { "kind": "model", "id": "t/openapi/components/schemas/Old", @@ -365,6 +413,126 @@ "positional": false, "inputOnly": false }, + "t/openapi/components/schemas/Shade": { + "kind": "enum", + "id": "t/openapi/components/schemas/Shade", + "name": { + "source": "Shade", + "canonical": "shade" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:x-extensible-enum": { + "reason": "vendor_extension", + "value": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Shade/x-extensible-enum" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Shade" + }, + "valueType": "string", + "members": [ + { + "name": { + "source": "light", + "canonical": "light" + }, + "value": { + "kind": "string", + "str": "light", + "bytes": null, + "list": null, + "object": null + }, + "docs": {} + }, + { + "name": { + "source": "dark", + "canonical": "dark" + }, + "value": { + "kind": "string", + "str": "dark", + "bytes": null, + "list": null, + "object": null + }, + "docs": {} + } + ], + "closed": true, + "flags": false + }, + "t/openapi/components/schemas/Size": { + "kind": "enum", + "id": "t/openapi/components/schemas/Size", + "name": { + "source": "Size", + "canonical": "size" + }, + "anonymous": false, + "docs": {}, + "sensitive": false, + "unmodeled": { + "openapi:x-extensible-enum": { + "reason": "vendor_extension", + "value": [ + "small", + "large" + ], + "provenance": { + "source": 0, + "pointer": "/components/schemas/Size/x-extensible-enum" + } + } + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Size", + "inferred": "extension-promotion" + }, + "valueType": "string", + "members": [ + { + "name": { + "source": "small", + "canonical": "small" + }, + "value": { + "kind": "string", + "str": "small", + "bytes": null, + "list": null, + "object": null + }, + "docs": {} + }, + { + "name": { + "source": "large", + "canonical": "large" + }, + "value": { + "kind": "string", + "str": "large", + "bytes": null, + "list": null, + "object": null + }, + "docs": {} + } + ], + "closed": false, + "flags": false + }, "t/prim/string": { "kind": "primitive", "id": "t/prim/string", @@ -434,7 +602,7 @@ { "format": "openapi@3.1", "path": "extension-promotion.yaml", - "hash": "2433e30d378223aa920d20cb51cce28080a3a325c92f36dbbe7662470fbee376" + "hash": "d6cc084d89a9e2626e827271c565b828da3241cbf77c658fd57868a02954ff19" } ] } diff --git a/testdata/conformance/openapi/extension-promotion.yaml b/testdata/conformance/openapi/extension-promotion.yaml index cd0f88e2..af7bc15e 100644 --- a/testdata/conformance/openapi/extension-promotion.yaml +++ b/testdata/conformance/openapi/extension-promotion.yaml @@ -39,6 +39,17 @@ components: properties: p: {type: string, deprecated: true, x-deprecated-reason: field goes away} n: {type: string, deprecated: true, x-deprecated-reason: 7} + Size: + type: string + enum: [small, large] + x-extensible-enum: [small, large] + Shade: + type: string + enum: [light, dark] + x-extensible-enum: false + Fixed: + type: string + enum: [one, two] securitySchemes: k: type: apiKey From 2e86169f5a9976b12ac775a74abe0a4612fd81a5 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Mon, 7 Sep 2026 23:59:06 +0300 Subject: [PATCH 08/13] feat(ir)!: give contentSchema a home on Encoding and lower it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2020-12 content vocabulary was two-thirds modelled: contentEncoding reached Encoding.Name and contentMediaType Encoding.MediaType, while contentSchema had no field at any IR position and was always kept verbatim under Unmodeled. A consumer saw an opaque string where the source declared a full shape, and had to special-case one of three keywords (GitHub #426). ir.Encoding gains Schema *TypeRef. contentSchema's value is a schema, so it lowers like every other sub-schema position: hoisted at its own source pointer and referenced by ID, never carried beside the encoding as a raw payload a consumer would have to re-parse. The pointer it hoists at is the one the source wrote it at, which only that declaration can name, so the minted node needs no namespace of its own. The three keywords now share one home, so a position keeps them all or lowers them all: contentSchema joins contentKeywords, and the schema package decides its fate by asking the node that was built rather than the keyword that was written. That is why annotation.noIRHomeAt goes — whether a content keyword reached ir.Encoding is a question only the lowering can answer, and it was answering "never" from outside. Adding the scalar hoisters to the schema walk's recursion is what lets a contentSchema nest; the walk's depth counter already bounds it, and internal/archtest pins the widened cycle. BREAKING CHANGE: contentSchema no longer appears as an openapi:contentSchema Unmodeled entry at a position that lowers to a Scalar; it is Encoding.Schema there. A position with no Encoding field still keeps it verbatim, now alongside its two neighbours. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1 --- .../openapi/conformance_unmodeled_test.go | 20 ++-- .../openapi/internal/annotation/annotation.go | 40 ++------ .../annotation/annotation_internal_test.go | 37 ------- compilers/openapi/internal/schema/schema.go | 99 ++++++++++++------- .../openapi/internal/schema/schema_test.go | 44 +++++++-- docs/ir-design.md | 21 ++-- internal/archtest/recursion_test.go | 10 +- ir/constraints.go | 10 +- .../openapi/content-vocabulary.golden.json | 93 ++++++++++++----- .../openapi/content-vocabulary.yaml | 8 +- 10 files changed, 222 insertions(+), 160 deletions(-) diff --git a/compilers/openapi/conformance_unmodeled_test.go b/compilers/openapi/conformance_unmodeled_test.go index 10ec29fb..7cd785ea 100644 --- a/compilers/openapi/conformance_unmodeled_test.go +++ b/compilers/openapi/conformance_unmodeled_test.go @@ -392,10 +392,11 @@ func assertDependentRequired(t *testing.T, doc *ir.Document, diags []ir.Diagnost diagsAt(diags, "openapi/validation-only-keyword", "/components/schemas/Card")) } -// assertContentVocabulary pins the 2020-12 content vocabulary: contentEncoding -// and contentMediaType are an encoding and lower into ir.Encoding, contentSchema -// is a schema and has no IR home anywhere, and a position with no Encoding field -// at all keeps both of the first two verbatim (GitHub #125). +// assertContentVocabulary pins the 2020-12 content vocabulary: all three +// keywords lower into ir.Encoding — contentEncoding and contentMediaType as +// names, contentSchema as a reference to the type it hoists — and a position +// with no Encoding field at all keeps all three verbatim (GitHub #125, +// GitHub #426). func assertContentVocabulary(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { thumb, ok := doc.Types[namedID("Thumbnail")].(*ir.Scalar) require.True(t, ok) @@ -408,13 +409,16 @@ func assertContentVocabulary(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) require.True(t, ok) require.NotNil(t, env.Encoding) assert.Equal(t, "application/json", env.Encoding.MediaType) - entry := unmodeledEntry(t, env.Unmodeled, "openapi:contentSchema") - assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) - assert.JSONEq(t, `{"type":"object","properties":{"id":{"type":"string"}}}`, string(entry.Value)) + require.NotNil(t, env.Encoding.Schema, "contentSchema has a home on ir.Encoding") + assert.Empty(t, env.Unmodeled, "so it is lowered, never also kept raw") + decoded, ok := doc.Types[env.Encoding.Schema.Target].(*ir.Model) + require.True(t, ok, "and it reaches the registry as a type rather than a blob") + require.Len(t, decoded.Properties, 1) + assert.Equal(t, "id", decoded.Properties[0].WireName) bag, ok := doc.Types[namedID("Bag")].(*ir.Model) require.True(t, ok) - for _, key := range []string{"openapi:contentEncoding", "openapi:contentMediaType"} { + for _, key := range []string{"openapi:contentEncoding", "openapi:contentMediaType", "openapi:contentSchema"} { assert.Equal(t, ir.ReasonNoIRHome, unmodeledEntry(t, bag.Unmodeled, key).Reason, "an object has no Encoding field, so %s is kept", key) } diff --git a/compilers/openapi/internal/annotation/annotation.go b/compilers/openapi/internal/annotation/annotation.go index fe28f587..635a1793 100644 --- a/compilers/openapi/internal/annotation/annotation.go +++ b/compilers/openapi/internal/annotation/annotation.go @@ -639,43 +639,23 @@ func subObjectKeys(s *oas3.Schema, pointer string, srcIndex int) (ir.Unmodeled, // unmodeledAt collects every keyword a site declares that the IR keeps verbatim // instead of modelling, each under the reason that says which of those it is -// (§12): validation logic the IR draws a boundary against (§4.7), data with no IR -// field yet, and JSON Schema resource/dialect metadata the IR excludes on -// purpose. +// (§12): validation logic the IR draws a boundary against (§4.7), and JSON +// Schema resource/dialect metadata the IR excludes on purpose. +// +// The content vocabulary is not read here even though it is data with an IR +// home: whether contentEncoding, contentMediaType and contentSchema reached +// ir.Encoding depends on what the position lowered to, which only the schema +// package can answer — schema.recordUnplacedContent asks the node that was +// built rather than the keyword that was written. func unmodeledAt(s *oas3.Schema, pointer string, srcIndex int) (ir.Unmodeled, []ir.Diagnostic) { vOnly, vDiags := validationOnlyAt(s, pointer, srcIndex) - noHome, nhDiags := noIRHomeAt(s, pointer, srcIndex) dialect, dDiags := dialectAt(s, pointer, srcIndex) - diags := make([]ir.Diagnostic, 0, len(vDiags)+len(nhDiags)+len(dDiags)) + diags := make([]ir.Diagnostic, 0, len(vDiags)+len(dDiags)) diags = append(diags, vDiags...) - diags = append(diags, nhDiags...) diags = append(diags, dDiags...) - return MergeUnmodeled(MergeUnmodeled(vOnly, noHome), dialect), diags -} - -// noIRHomeAt collects the keywords a schema declares that describe real data yet -// have no field at any IR position. Unlike the §4.7 family these are gaps -// expected to close rather than a boundary the IR draws, which is what -// ReasonNoIRHome says and ReasonValidationOnly would not (§12). -// -// Site-only: contentSchema describes the value at the position that wrote it. -func noIRHomeAt(s *oas3.Schema, pointer string, srcIndex int) (ir.Unmodeled, []ir.Diagnostic) { - if s.GetContentSchema() == nil { - return nil, nil - } - at := pointer + ids.Ptr("contentSchema") - var p ir.Unmodeled - kept, diags := PreserveNodeInto(&p, "openapi:contentSchema", RawPropertyNode(s, "contentSchema"), - ir.ReasonNoIRHome, at, srcIndex) - if !kept { - return nil, diags - } - return p, []ir.Diagnostic{diag.Newf(ir.SeverityInfo, diag.DegradedConstruct, - ir.Provenance{Source: srcIndex, Pointer: at}, - "contentSchema is the shape of the decoded content and no IR position has a field "+ - "for it; kept verbatim under Unmodeled")} + return MergeUnmodeled(vOnly, dialect), diags } // DialectKeywords are the JSON Schema resource and dialect keywords the IR diff --git a/compilers/openapi/internal/annotation/annotation_internal_test.go b/compilers/openapi/internal/annotation/annotation_internal_test.go index 5e65e015..400f8223 100644 --- a/compilers/openapi/internal/annotation/annotation_internal_test.go +++ b/compilers/openapi/internal/annotation/annotation_internal_test.go @@ -148,23 +148,6 @@ func TestDialectAt_KeepsEachKeywordOutOfScope(t *testing.T) { "and the hoist gate agrees a node is needed to hold them") } -// TestNoIRHomeAt_ContentSchemaIsKeptNotExcluded pins the reason split the other -// way: contentSchema is real data shape with no field yet, a gap expected to -// close, so it must not be filed as a deliberate exclusion. -func TestNoIRHomeAt_ContentSchemaIsKeptNotExcluded(t *testing.T) { - t.Parallel() - s := schemaFromYAML(t, "type: string\ncontentSchema: {type: object}\n") - - got, diags := noIRHomeAt(s, "/components/schemas/S", 0) - - entry, ok := got["openapi:contentSchema"] - require.True(t, ok) - assert.JSONEq(t, `{"type":"object"}`, string(entry.Value)) - assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) - require.Len(t, diags, 1) - assert.Equal(t, "/components/schemas/S/contentSchema", diags[0].Provenance.Pointer) -} - // schemaFromYAML unmarshals body as a bare schema through the same marshaller // the compiler's loader parses documents with, so the raw nodes the verbatim // readers read off are present. A schema built in Go carries none, which @@ -180,26 +163,6 @@ func schemaFromYAML(t *testing.T, body string) *oas3.Schema { return s } -// TestNoIRHomeAt_ModelSetWithoutRawSourceRecordsNothing pins the guard between -// the model and the raw tree. contentSchema is kept verbatim, so it is read off -// the source node rather than the parsed model — and a schema built in memory, -// or one whose value cannot be converted to JSON, has a model field set with no -// bytes behind it. Recording an entry there would announce a preservation with -// nothing preserved, so the collector reports nothing instead. -func TestNoIRHomeAt_ModelSetWithoutRawSourceRecordsNothing(t *testing.T) { - t.Parallel() - inner := oas3.NewJSONSchemaFromSchema[oas3.Referenceable]( - &oas3.Schema{Type: oas3.NewTypeFromString(oas3.SchemaTypeObject)}) - s := &oas3.Schema{ContentSchema: inner} - require.NotNil(t, s.GetContentSchema(), "the model reports the keyword as set") - require.Nil(t, RawPropertyNode(s, "contentSchema"), "and no raw node backs it") - - got, diags := noIRHomeAt(s, "/components/schemas/A", 0) - - assert.Nil(t, got, "no entry is recorded when there are no bytes to record") - assert.Empty(t, diags, "and nothing is announced, so the two channels agree") -} - // TestKind_String covers both named values and the default case, so an // assertion failure or test diff over a Kind prints a name instead of a bare // int. diff --git a/compilers/openapi/internal/schema/schema.go b/compilers/openapi/internal/schema/schema.go index 5f8f17f1..40ed43a1 100644 --- a/compilers/openapi/internal/schema/schema.go +++ b/compilers/openapi/internal/schema/schema.go @@ -981,7 +981,7 @@ func lowerTyped(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth i case oas3.SchemaTypeArray: return lowerArray(c, ts, anchors, depth, s, pointer, hint) default: - return scalarTypeID(c, ts, s, st, pointer, hint) + return scalarTypeID(c, ts, anchors, depth, s, st, pointer, hint) } } @@ -1337,10 +1337,10 @@ func buildTuple(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth i // 2020-12 content vocabulary each hoist a named Scalar wrapping the base // primitive with an Encoding, so what the position wrote never leaks onto the // shared primitive every other declaration of that type also resolves to. -func scalarTypeID(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, st oas3.SchemaType, pointer, hint string) (ir.TypeID, []ir.Diagnostic) { +func scalarTypeID(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s *oas3.Schema, st oas3.SchemaType, pointer, hint string) (ir.TypeID, []ir.Diagnostic) { format := s.GetFormat() if st == oas3.SchemaTypeString && format == "byte" { - return hoistByteScalar(c, ts, s, pointer, hint) + return hoistByteScalar(c, ts, anchors, depth, s, pointer, hint) } key := string(st) if format != "" { @@ -1348,12 +1348,12 @@ func scalarTypeID(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, st oas3.Sch } prim, known := formatTable[key] if !known { - return hoistFormatScalar(c, ts, s, baseForType(st), format, pointer, hint) + return hoistFormatScalar(c, ts, anchors, depth, s, baseForType(st), format, pointer, hint) } - if !declaresContent(s) { + if !declaresContentVocabulary(s) { return ts.PrimID(prim), nil } - return hoistContentScalar(c, ts, s, prim, pointer, hint) + return hoistContentScalar(c, ts, anchors, depth, s, prim, pointer, hint) } // hoistByteScalar hoists a base64-encoded byte scalar (string+byte). @@ -1363,12 +1363,12 @@ func scalarTypeID(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, st oas3.Sch // otherwise carry them: that fallback resolves to whatever node the pointer // already owns and returns early. A scalar that hoisted because it wrote a // format must not lose the bounds it wrote beside it (invariant 2). -func hoistByteScalar(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, pointer, hint string) (ir.TypeID, []ir.Diagnostic) { +func hoistByteScalar(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s *oas3.Schema, pointer, hint string) (ir.TypeID, []ir.Diagnostic) { var diags []ir.Diagnostic id := internNode(c, ts, pointer, hint, func(common ir.TypeCommon) ir.TypeDef { base := ts.PrimRef(ir.PrimBytes) wire := ts.PrimRef(ir.PrimString) - enc, encDiags := scalarEncoding(c, s, "base64", &common, pointer) + enc, encDiags := scalarEncoding(c, ts, anchors, depth, s, "base64", &common, pointer, hint) diags = append(diags, encDiags...) enc.WireType = &wire cons, consDiags := schemaConstraints(c, &common.Unmodeled, s, pointer) @@ -1385,11 +1385,11 @@ func hoistByteScalar(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, pointer, // hoistFormatScalar hoists a scalar over base carrying an unknown format as its // encoding name, preserving the format losslessly. -func hoistFormatScalar(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, base ir.PrimKind, format, pointer, hint string) (ir.TypeID, []ir.Diagnostic) { +func hoistFormatScalar(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s *oas3.Schema, base ir.PrimKind, format, pointer, hint string) (ir.TypeID, []ir.Diagnostic) { var diags []ir.Diagnostic id := internNode(c, ts, pointer, hint, func(common ir.TypeCommon) ir.TypeDef { baseRef := ts.PrimRef(base) - enc, encDiags := scalarEncoding(c, s, format, &common, pointer) + enc, encDiags := scalarEncoding(c, ts, anchors, depth, s, format, &common, pointer, hint) diags = append(diags, encDiags...) cons, consDiags := schemaConstraints(c, &common.Unmodeled, s, pointer) diags = append(diags, consDiags...) @@ -1407,11 +1407,11 @@ func hoistFormatScalar(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, base i // (type, format) pair maps to, giving the content vocabulary written here a node // of its own to sit on. It carries the position's value constraints for the // reason hoistByteScalar records. -func hoistContentScalar(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, prim ir.PrimKind, pointer, hint string) (ir.TypeID, []ir.Diagnostic) { +func hoistContentScalar(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s *oas3.Schema, prim ir.PrimKind, pointer, hint string) (ir.TypeID, []ir.Diagnostic) { var diags []ir.Diagnostic id := internNode(c, ts, pointer, hint, func(common ir.TypeCommon) ir.TypeDef { base := ts.PrimRef(prim) - enc, encDiags := scalarEncoding(c, s, "", &common, pointer) + enc, encDiags := scalarEncoding(c, ts, anchors, depth, s, "", &common, pointer, hint) diags = append(diags, encDiags...) cons, consDiags := schemaConstraints(c, &common.Unmodeled, s, pointer) diags = append(diags, consDiags...) @@ -1425,25 +1425,34 @@ func hoistContentScalar(c lowering.Ctx, ts *compile.Types, s *oas3.Schema, prim return id, diags } -// scalarEncoding builds the Encoding a scalar position declares: the 2020-12 -// content vocabulary over the OpenAPI `format` spelling of the same thing. -// formatName is the encoding name the format contributes — "base64" for +// scalarEncoding builds the Encoding a scalar position declares: the whole +// 2020-12 content vocabulary over the OpenAPI `format` spelling of the encoding +// name. formatName is the encoding name the format contributes — "base64" for // format: byte, an unrecognized format verbatim, "" when the pairing is already // captured by the primitive kind. +func scalarEncoding(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s *oas3.Schema, + formatName string, common *ir.TypeCommon, pointer, hint string, +) (*ir.Encoding, []ir.Diagnostic) { + name, diags := encodingName(c, s, formatName, common, pointer) + content, contentDiags := contentSchemaRef(c, ts, anchors, depth, s, pointer, hint) + enc := &ir.Encoding{Name: name, MediaType: s.GetContentMediaType(), Schema: content} + return enc, append(diags, contentDiags...) +} + +// encodingName elects the one name ir.Encoding holds from the two keywords that +// can name an encoding at a scalar position. // -// contentEncoding wins Encoding.Name: it is the standard keyword, where a format -// the IR could not place is only parked there. Encoding holds one name, so a -// format that named a *different* encoding is kept verbatim on c rather than +// contentEncoding wins: it is the standard keyword, where a format the IR could +// not place is only parked there. Encoding holds one name, so a format that +// named a *different* encoding is kept verbatim on the node rather than // overwritten away. -func scalarEncoding(c lowering.Ctx, s *oas3.Schema, formatName string, common *ir.TypeCommon, pointer string) (*ir.Encoding, []ir.Diagnostic) { - enc := &ir.Encoding{Name: formatName, MediaType: s.GetContentMediaType()} +func encodingName(c lowering.Ctx, s *oas3.Schema, formatName string, common *ir.TypeCommon, pointer string) (string, []ir.Diagnostic) { content := s.GetContentEncoding() if content == "" || content == formatName { - return enc, nil + return formatName, nil } - enc.Name = content if formatName == "" { - return enc, nil + return content, nil } at := pointer + ids.Ptr("format") kept, diags := PreserveSchemaKeyword(c, &common.Unmodeled, s, "format", ir.ReasonNoIRHome, at) @@ -1452,25 +1461,39 @@ func scalarEncoding(c lowering.Ctx, s *oas3.Schema, formatName string, common *i "format and contentEncoding both name an encoding and ir.Encoding holds one; "+ "contentEncoding %q is lowered and format is kept verbatim under Unmodeled", content)) } - return enc, diags + return content, diags } -// contentKeywords are the content-vocabulary keywords that lower into -// ir.Encoding: contentEncoding names Encoding.Name and contentMediaType names -// Encoding.MediaType (ir/constraints.go, ir-design §5.3). contentSchema is not -// one of them — it is a schema rather than an encoding, and noIRHomeAt keeps it -// verbatim at every position. -var contentKeywords = []string{"contentEncoding", "contentMediaType"} - -// declaresContent reports whether s writes a contentKeywords entry. -func declaresContent(s *oas3.Schema) bool { - return s.GetContentEncoding() != "" || s.GetContentMediaType() != "" +// contentSchemaRef lowers contentSchema — the shape the encoded value has once +// decoded — to Encoding.Schema. Its value is a schema, so it lowers like every +// other sub-schema position (fillAdditional, patternProps): hoisted at its own +// pointer and referenced by ID, never carried beside the encoding as a raw blob +// a consumer would have to re-parse. +// +// The pointer it hoists at is the one the source wrote it at, which only this +// declaration can name, so the node needs no namespace of its own (§4.3). +func contentSchemaRef(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s *oas3.Schema, pointer, hint string) (*ir.TypeRef, []ir.Diagnostic) { + cs := s.GetContentSchema() + if cs == nil { + return nil, nil + } + ref, diags := Ref(c, ts, anchors, depth, cs, pointer+ids.Ptr("contentSchema"), compile.SubHint(hint, "content")) + return &ref, diags } +// contentKeywords are the 2020-12 content-vocabulary keywords, all three of +// which lower into ir.Encoding: contentEncoding names Encoding.Name, +// contentMediaType names Encoding.MediaType, and contentSchema names +// Encoding.Schema (ir/constraints.go, ir-design §5.3). One list because they +// share one home — a position that reached no Encoding keeps all three, and one +// that reached an Encoding keeps none. +var contentKeywords = []string{"contentEncoding", "contentMediaType", "contentSchema"} + // declaresContentVocabulary reports whether s writes any content-vocabulary // keyword, so a position that wrote one owns a node to keep it on. func declaresContentVocabulary(s *oas3.Schema) bool { - return declaresContent(s) || s.GetContentSchema() != nil + return s.GetContentEncoding() != "" || s.GetContentMediaType() != "" || + s.GetContentSchema() != nil } // recordUnplacedContent keeps each content keyword verbatim on p, for a position @@ -1483,7 +1506,7 @@ func declaresContentVocabulary(s *oas3.Schema) bool { // node the position actually lowered to instead of re-deriving lower()'s // dispatch, so the two cannot drift apart. func recordUnplacedContent(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, td ir.TypeDef, pointer string) []ir.Diagnostic { - if !declaresContent(s) || scalarHasEncoding(td) { + if !declaresContentVocabulary(s) || scalarHasEncoding(td) { return nil } var diags []ir.Diagnostic @@ -1494,8 +1517,8 @@ func recordUnplacedContent(c lowering.Ctx, p *ir.Unmodeled, s *oas3.Schema, td i continue } diags = append(diags, c.DiagAt(ir.SeverityInfo, diag.DegradedConstruct, pointer+ids.Ptr(keyword), - "%s encodes a string value and this position lowered to a shape with no "+ - "Encoding field; kept verbatim under Unmodeled", keyword)) + "%s is content-vocabulary data the IR holds in ir.Encoding, and this position "+ + "lowered to a shape with no Encoding field; kept verbatim under Unmodeled", keyword)) } return diags } diff --git a/compilers/openapi/internal/schema/schema_test.go b/compilers/openapi/internal/schema/schema_test.go index 0c625104..2f4f6b14 100644 --- a/compilers/openapi/internal/schema/schema_test.go +++ b/compilers/openapi/internal/schema/schema_test.go @@ -2958,8 +2958,9 @@ func compileVocabIR(t *testing.T, schemas string) string { // TestContentVocabulary_LowersToEncoding pins where the 2020-12 content // vocabulary lands: contentEncoding on Encoding.Name, contentMediaType on -// Encoding.MediaType, on the Scalar node the position hoists rather than on the -// shared primitive every other declaration of that type also resolves to. +// Encoding.MediaType and contentSchema on Encoding.Schema, on the Scalar node +// the position hoists rather than on the shared primitive every other +// declaration of that type also resolves to. func TestContentVocabulary_LowersToEncoding(t *testing.T) { t.Parallel() cases := []struct { @@ -2997,6 +2998,30 @@ func TestContentVocabulary_LowersToEncoding(t *testing.T) { at: componentID("A"), want: ir.Encoding{Name: "base64", WireType: &ir.TypeRef{Target: "t/prim/string"}}, }, + { + name: "contentSchema beside contentMediaType", + schemas: " A: {type: string, contentMediaType: application/json, contentSchema: {type: object, properties: {id: {type: string}}}}\n", + at: componentID("A"), + want: ir.Encoding{ + MediaType: "application/json", + Schema: &ir.TypeRef{Target: "t/anon/components/schemas/A/contentSchema"}, + }, + }, + { + name: "contentSchema alone still hoists the scalar that holds it", + schemas: " A: {type: array, items: {type: string, contentSchema: {type: object}}}\n", + at: "t/anon/components/schemas/A/items", + want: ir.Encoding{ + Schema: &ir.TypeRef{Target: "t/anon/components/schemas/A/items/contentSchema"}, + }, + }, + { + name: "contentSchema naming a component resolves to it", + schemas: " A: {type: string, contentSchema: {$ref: '#/components/schemas/B'}}\n" + + " B: {type: object, properties: {id: {type: string}}}\n", + at: componentID("A"), + want: ir.Encoding{Schema: &ir.TypeRef{Target: componentID("B")}}, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -3009,6 +3034,10 @@ func TestContentVocabulary_LowersToEncoding(t *testing.T) { require.NotNil(t, sc.Encoding) assert.Empty(t, cmp.Diff(tc.want, *sc.Encoding)) assert.Empty(t, sc.Unmodeled, "a keyword with a field is lowered, never also kept raw") + if tc.want.Schema != nil { + assert.Contains(t, doc.Types, tc.want.Schema.Target, + "contentSchema reaches the registry as a type, not a raw blob") + } }) } } @@ -3055,9 +3084,10 @@ func TestContentVocabulary_KeepsTheBoundsWrittenBesideIt(t *testing.T) { } } -// TestContentVocabulary_KeptWhereNoEncodingHolds covers the other half: a schema -// with no Encoding field to fill, and contentSchema, which has no IR field at any -// position. +// TestContentVocabulary_KeptWhereNoEncodingHolds covers the other half: a +// position that lowered to a shape with no Encoding field to fill keeps every +// content keyword verbatim, contentSchema included — the three share one home, +// so they are kept or lowered together. func TestContentVocabulary_KeptWhereNoEncodingHolds(t *testing.T) { t.Parallel() cases := []struct { @@ -3070,8 +3100,8 @@ func TestContentVocabulary_KeptWhereNoEncodingHolds(t *testing.T) { key: "openapi:contentMediaType", wantJSON: `"application/zip"`, at: componentID("A"), }, { - name: "contentSchema has no field anywhere", - schemas: " A: {type: string, contentSchema: {type: object}}\n", + name: "object position has no Encoding for contentSchema either", + schemas: " A: {type: object, properties: {p: {type: string}}, contentSchema: {type: object}}\n", key: "openapi:contentSchema", wantJSON: `{"type":"object"}`, at: componentID("A"), }, { diff --git a/docs/ir-design.md b/docs/ir-design.md index 1c071f6e..def0335d 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -665,11 +665,13 @@ is what turns "irreducible" into a confident expansion. Two neighbouring keyword groups are *not* part of this boundary, and are recorded here so their treatment is stated rather than assumed: -- The content vocabulary is modelled, not preserved. `contentEncoding` lowers to `Encoding.Name` - and `contentMediaType` to `Encoding.MediaType` (§5.3) on the scalar the position lowers to; - where the position lowers to a shape with no `Encoding` field, both stay verbatim under - `ReasonNoIRHome`. `contentSchema` is real data shape with no IR field at any position, so it is - always verbatim under `ReasonNoIRHome` — a gap expected to close, not a boundary (§12). +- The content vocabulary is modelled, not preserved. All three keywords share one home, so a + position keeps them all or lowers them all: `contentEncoding` to `Encoding.Name`, + `contentMediaType` to `Encoding.MediaType`, and `contentSchema` to `Encoding.Schema` (§5.3) on + the scalar the position lowers to. `contentSchema`'s value is a schema and lowers like one — + hoisted at its own source pointer and referenced by ID — rather than riding along as a payload + a consumer would have to re-parse. Where the position lowers to a shape with no `Encoding` + field, all three stay verbatim under `ReasonNoIRHome`. - `$id`, `$schema` and `$vocabulary` identify and configure a JSON Schema *resource*. The IR identifies every type by a synthetic ID derived from its source pointer rather than by `$id` (§3), and describes one API surface rather than a resource graph, so it has no dialect axis and @@ -995,11 +997,16 @@ type Encoding struct { // (utcDateTime encoded as int32; bytes as base64 string) MediaType string // content media type of the value itself (Smithy @mediaType on string/blob, // JSON Schema contentMediaType); "" = none + Schema *TypeRef // the shape the encoded value has once decoded (JSON Schema contentSchema): + // what a base64 blob or an application/json-typed string holds; nil = unstated } ``` The logical-type / encoding-name / wire-type triple is TCGC's reification of TypeSpec `@encode` -and also absorbs OpenAPI `format` and Protobuf's `sint*/fixed*` wire variants. Encoding attaches +and also absorbs OpenAPI `format` and Protobuf's `sint*/fixed*` wire variants. `MediaType` and +`Schema` sit beside it because they answer the same question one level in: what the encoded value +*is*. `Schema` is a `TypeRef` like any other schema position — the decoded shape is hoisted into +the registry at its own source pointer, never carried here as a raw payload. Encoding attaches at the scalar definition or overrides at the property — property wins. Protobuf editions features lower here per element after the compiler resolves the feature cascade (descriptors expose resolved values): `field_presence` → `Presence`, `enum_type` → `Closed`, @@ -1912,7 +1919,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, 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); 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); `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; 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 written → Response.Name.Hint and ErrorCase.Name.Hint alike, so `4XX` and `default` round-trip the spelling their range cannot state; 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; 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` → `Encoding` on the scalar the position lowers to and `contentSchema` → Unmodeled (`no_ir_home`) 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 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; 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, 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); 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); `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; 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 written → Response.Name.Hint and ErrorCase.Name.Hint alike, so `4XX` and `default` round-trip the spelling their range cannot state; 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; 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 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; 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/internal/archtest/recursion_test.go b/internal/archtest/recursion_test.go index 28c78744..fbf7c1c2 100644 --- a/internal/archtest/recursion_test.go +++ b/internal/archtest/recursion_test.go @@ -71,14 +71,18 @@ var loweringRecursions = [][]string{ // The two exported names are the walk's entry points, which is what the // operation lowering reaches it by; the rest of the set is unexported because // nothing outside the schema package has any business entering mid-walk. +// The scalar hoisters and the encoding reader joined it when contentSchema +// gained an IR home: its value is a schema, so lowering it re-enters the walk +// from a scalar position, which until then was the walk's one leaf. var schemaRecursion = []string{ "CarriedRef", "Ref", "buildComposedVariant", "buildTuple", - "composedVariant", "fillAdditional", "fillAllOf", "fillModelProperties", - "hoistSubSchema", "lower", "lowerAllOf", "lowerArray", + "composedVariant", "contentSchemaRef", "fillAdditional", "fillAllOf", + "fillModelProperties", "hoistByteScalar", "hoistContentScalar", + "hoistFormatScalar", "hoistSubSchema", "lower", "lowerAllOf", "lowerArray", "lowerBesideUnmodeledUnion", "lowerCoDeclaredUnion", "lowerDistributedUnion", "lowerModel", "lowerOneOfAnyOf", "lowerSchemaBody", "lowerTyped", "lowerUnion", "lowerUntyped", "patternProps", "refSiteRef", "refTypeRef", "resolveSchemaRef", - "schemaBody", "schemaRefHomed", + "scalarEncoding", "scalarTypeID", "schemaBody", "schemaRefHomed", } // loweringPackages are the directories whose sources the call graph reads, diff --git a/ir/constraints.go b/ir/constraints.go index a84f4880..338dabbd 100644 --- a/ir/constraints.go +++ b/ir/constraints.go @@ -42,8 +42,9 @@ type Constraints struct { } // Encoding is the logical-type / encoding-name / wire-type triple that reifies -// TypeSpec @encode and absorbs OpenAPI format and Protobuf wire variants -// (ir-design §5.3). Property encoding overrides scalar encoding. +// TypeSpec @encode and absorbs OpenAPI format and Protobuf wire variants, plus +// the media type and decoded shape of an encoded payload (ir-design §5.3). +// Property encoding overrides scalar encoding. type Encoding struct { // Name is the encoding scheme ("rfc3339", "base64", "zigzag", "packed", // "delimited", format strings, ...). @@ -54,6 +55,11 @@ type Encoding struct { // MediaType is the content media type of the value itself (Smithy @mediaType, // JSON Schema contentMediaType); "" = none. MediaType string `json:"mediaType,omitempty"` + // Schema is the shape the encoded value has once decoded — what a base64 blob + // or an application/json-typed string holds (JSON Schema contentSchema); nil = + // unstated. It is a reference into the type registry like any other schema, + // never the encoded value's own type. + Schema *TypeRef `json:"schema,omitempty"` } // XMLHints describes an XML wire shape that diverges from the JSON-implied one diff --git a/testdata/conformance/openapi/content-vocabulary.golden.json b/testdata/conformance/openapi/content-vocabulary.golden.json index 4da2e98a..e546670a 100644 --- a/testdata/conformance/openapi/content-vocabulary.golden.json +++ b/testdata/conformance/openapi/content-vocabulary.golden.json @@ -18,6 +18,52 @@ } ], "types": { + "t/anon/components/schemas/Envelope/contentSchema": { + "kind": "model", + "id": "t/anon/components/schemas/Envelope/contentSchema", + "name": { + "hint": "envelope_content" + }, + "anonymous": true, + "docs": {}, + "sensitive": false, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Envelope/contentSchema" + }, + "properties": [ + { + "id": "p/openapi/components/schemas/Envelope/contentSchema/properties/id", + "name": { + "source": "id", + "canonical": "id" + }, + "wireName": "id", + "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/Envelope/contentSchema/properties/id" + } + } + ], + "abstract": false, + "positional": false, + "inputOnly": false + }, "t/openapi/components/schemas/Bag": { "kind": "model", "id": "t/openapi/components/schemas/Bag", @@ -44,6 +90,16 @@ "source": 0, "pointer": "/components/schemas/Bag/contentMediaType" } + }, + "openapi:contentSchema": { + "reason": "no_ir_home", + "value": { + "type": "object" + }, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Bag/contentSchema" + } } }, "provenance": { @@ -93,23 +149,6 @@ "anonymous": false, "docs": {}, "sensitive": false, - "unmodeled": { - "openapi:contentSchema": { - "reason": "no_ir_home", - "value": { - "properties": { - "id": { - "type": "string" - } - }, - "type": "object" - }, - "provenance": { - "source": 0, - "pointer": "/components/schemas/Envelope/contentSchema" - } - } - }, "provenance": { "source": 0, "pointer": "/components/schemas/Envelope" @@ -119,7 +158,11 @@ "nullable": false }, "encoding": { - "mediaType": "application/json" + "mediaType": "application/json", + "schema": { + "target": "t/anon/components/schemas/Envelope/contentSchema", + "nullable": false + } } }, "t/openapi/components/schemas/Thumbnail": { @@ -172,28 +215,28 @@ { "severity": "info", "code": "openapi/degraded-construct", - "message": "contentSchema is the shape of the decoded content and no IR position has a field for it; kept verbatim under Unmodeled", + "message": "contentEncoding is content-vocabulary data the IR holds in ir.Encoding, and this position lowered to a shape with no Encoding field; kept verbatim under Unmodeled", "provenance": { "source": 0, - "pointer": "/components/schemas/Envelope/contentSchema" + "pointer": "/components/schemas/Bag/contentEncoding" } }, { "severity": "info", "code": "openapi/degraded-construct", - "message": "contentEncoding encodes a string value and this position lowered to a shape with no Encoding field; kept verbatim under Unmodeled", + "message": "contentMediaType is content-vocabulary data the IR holds in ir.Encoding, and this position lowered to a shape with no Encoding field; kept verbatim under Unmodeled", "provenance": { "source": 0, - "pointer": "/components/schemas/Bag/contentEncoding" + "pointer": "/components/schemas/Bag/contentMediaType" } }, { "severity": "info", "code": "openapi/degraded-construct", - "message": "contentMediaType encodes a string value and this position lowered to a shape with no Encoding field; kept verbatim under Unmodeled", + "message": "contentSchema is content-vocabulary data the IR holds in ir.Encoding, and this position lowered to a shape with no Encoding field; kept verbatim under Unmodeled", "provenance": { "source": 0, - "pointer": "/components/schemas/Bag/contentMediaType" + "pointer": "/components/schemas/Bag/contentSchema" } } ], @@ -201,7 +244,7 @@ { "format": "openapi@3.1", "path": "content-vocabulary.yaml", - "hash": "b037c671833d74933ede0b2e0dfa2c22ed381955c1cfdec83963882ebf1db6a7" + "hash": "3ef4175a9903ee435b730ca49ade19bec243e6194599dcd221212ea6850fd81c" } ] } diff --git a/testdata/conformance/openapi/content-vocabulary.yaml b/testdata/conformance/openapi/content-vocabulary.yaml index 676a0eb6..df32f2f9 100644 --- a/testdata/conformance/openapi/content-vocabulary.yaml +++ b/testdata/conformance/openapi/content-vocabulary.yaml @@ -8,8 +8,8 @@ components: type: string contentEncoding: base64 contentMediaType: image/png - # contentSchema is a schema, not an encoding, so it has no IR home at any - # position and is kept verbatim beside the encoding that did lower. + # contentSchema is a schema, so it lowers like one: hoisted at its own + # pointer and reached from Encoding.Schema by ID, never a raw blob. Envelope: type: string contentMediaType: application/json @@ -17,10 +17,12 @@ components: type: object properties: id: {type: string} - # An object position has no Encoding field at all, so both keywords are kept. + # An object position has no Encoding field at all, so all three are kept. Bag: type: object contentEncoding: base64 contentMediaType: application/json + contentSchema: + type: object properties: a: {type: string} From 9861f2b3cb19f87fdf6a7046fc32a7b1cd1db65a Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Tue, 8 Sep 2026 00:13:56 +0300 Subject: [PATCH 09/13] docs(ir): say constraints never cross a $ref to a use site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At a $ref use site the compiler merges the referent's documentation, deprecation and default onto the referencing Property/Parameter, and leaves its constraints where they were declared. The split was deliberate and tested but written down nowhere a consumer reads, so an empty Constraints at a use site could be read as "this value is unbounded" when it means "this position declared no bound". The split is kept, because the two halves are not the same kind of fact. An annotation is a single value one position may restate for another, so use-site precedence is the only sensible rule and applying it once in the compiler keeps every carrier alike. A bound is not: maxLength 64 on the referent and maxLength 100 beside the $ref are both in force and the narrower wins, so merging under use-site precedence would publish 100 as the whole truth and lose the bound the document enforces. What changes is that the rule is now stated where it is read: a new ir-design §12.2, the Constraints, Property.Constraints, Parameter.Constraints and TypeRef field docs, and the two lowering sites that implement it. An absent Constraints at a use site means that position declared no bound; the effective bound is its conjunction with every node reached from its TypeRef. param-ref-inheritance now declares a bound beside the $ref at both carriers, so the split is witnessed rather than merely absent: the use site's 100 lands on the carrier, the referent's 64 stays on the referent, and the case reddens if either is copied onto the other. Fixes #428 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1 --- compilers/openapi/conformance_test.go | 18 ++++++- .../openapi/internal/operation/params.go | 4 ++ compilers/openapi/internal/schema/schema.go | 5 ++ docs/ir-design.md | 30 ++++++++++- ir/constraints.go | 10 ++++ ir/operation.go | 7 ++- ir/property.go | 7 ++- ir/typeref.go | 7 +++ .../openapi/param-ref-inheritance.golden.json | 52 ++++++++++++++++++- .../openapi/param-ref-inheritance.yaml | 11 +++- 10 files changed, 144 insertions(+), 7 deletions(-) diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index 3ea44008..59bcef23 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -2120,6 +2120,12 @@ func assertParamQuerystring(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { // silent, and from the use site when it is not. Constraints inherit at neither // carrier, so the identical property is asserted beside it — a parameter must not // take more from a referent than a property does (GitHub #131). +// +// The bound the use site declares beside the $ref is asserted at both carriers +// too, against the referent's own: it is what makes the split observable rather +// than merely absent, and it is the case use-site precedence would get wrong, +// publishing 100 as the whole truth while the document enforces 64 (§12.2, +// GitHub #428). func assertParamRefInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { op, ok := opByName(doc, "listItems") require.True(t, ok) @@ -2139,6 +2145,10 @@ func assertParamRefInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic "...and a keyword the use site is silent about still inherits") require.NotNil(t, override.Default) assert.Equal(t, "9", override.Default.Str) + require.NotNil(t, override.Constraints, "a bound beside the $ref lands on the carrier") + require.NotNil(t, override.Constraints.MaxLength) + assert.Equal(t, int64(100), *override.Constraints.MaxLength, + "and it is the use site's own, not narrowed against the referent's here") holder, ok := doc.Types[namedID("Holder")].(*ir.Model) require.True(t, ok) @@ -2150,12 +2160,18 @@ func assertParamRefInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic assert.Equal(t, *cursor.Default, *prop.Default) assert.Nil(t, prop.Constraints, "neither carrier inherits the referent's constraints") + overrideProp, ok := propByWire(holder, "override") + require.True(t, ok) + assert.Equal(t, override.Constraints, overrideProp.Constraints, + "and a property keeps its own bound exactly as the parameter does") + decl, ok := doc.Types[namedID("Cursor")].(*ir.Scalar) require.True(t, ok) require.NotNil(t, decl.Constraints) require.NotNil(t, decl.Constraints.MaxLength) assert.Equal(t, int64(64), *decl.Constraints.MaxLength, - "a consumer that wants the bound reads it off the referent") + "a consumer that wants the bound reads it off the referent, and conjoins "+ + "it with the use site's: both are in force, and 64 is the narrower") } // assertHeaderContentSchema pins that both spellings of a header's type lower diff --git a/compilers/openapi/internal/operation/params.go b/compilers/openapi/internal/operation/params.go index 5c60d92f..c897ffa3 100644 --- a/compilers/openapi/internal/operation/params.go +++ b/compilers/openapi/internal/operation/params.go @@ -114,6 +114,10 @@ func fillParamType(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorInde // inherit from it still reach the parameter (ir-design §14, GitHub #131). // Constraints stay use-site-only, exactly as fillPropertyConstraints keeps // them: a parameter must not inherit more from a referent than a property does. +// The referent's bounds are not dropped, they are simply left where they were +// declared — bounds conjoin rather than override, so copying one down under +// use-site precedence would publish the wider bound as the whole truth +// (ir-design §12.2). func fillParamSchema(c lowering.Ctx, ts *compile.Types, param *ir.Parameter, js *oas3.JSONSchema[oas3.Referenceable], pointer string) []ir.Diagnostic { if js == nil || !js.IsSchema() { return nil diff --git a/compilers/openapi/internal/schema/schema.go b/compilers/openapi/internal/schema/schema.go index 40ed43a1..966eb4a7 100644 --- a/compilers/openapi/internal/schema/schema.go +++ b/compilers/openapi/internal/schema/schema.go @@ -1203,6 +1203,11 @@ func fillPropertyDefault(c lowering.Ctx, p *ir.Property, ref, tgt *oas3.Schema, // co-declared bound keyword that reached none of them, to the property itself. // ir.Property is the carrier at this position: a property's schema is read // through CarriedRef, so it hoists no node of its own to hold either. +// +// It reads ref alone and never the $ref target, which is why no tgt reaches it: +// bounds conjoin rather than override, so a referent's bound merged here under +// use-site precedence would publish the wider of the two as the whole truth. It +// stays on the node the reference points at instead (ir-design §12.2). func fillPropertyConstraints(c lowering.Ctx, p *ir.Property, ref *oas3.Schema, pointer string) []ir.Diagnostic { cons, diags := schemaConstraints(c, &p.Unmodeled, ref, pointer) if cons != nil { diff --git a/docs/ir-design.md b/docs/ir-design.md index def0335d..892543ec 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1883,6 +1883,34 @@ and `Parameter.Constraints` are filled whether or not the schema hoisted a node, such a schema can hoist holds them: an object lowers to a `Model` and a formatted scalar to a `Scalar` carrying an `Encoding`, and neither has a field for the scalar bounds. +### 12.2 What a reference carries down, and what it does not + +A `$ref` divides the declarations at its target in two, and the line falls between annotations and +constraints. + +**Annotations merge onto the use site.** Documentation, deprecation, visibility and `default` are +read off the referent and written onto the referencing `Property`/`Parameter` with **use-site +precedence** (§14), field by field. Each is a single value that one position may restate for +another — a `description` beside the `$ref` says what *this* input is, replacing the target's — so +the two can never both be true at once, and any consumer reading both would need this precedence +rule anyway. Applying it once, in the compiler, is what keeps every carrier alike. + +**Constraints do not merge, and are never copied to a use site.** Bounds *conjoin*: `maxLength: 64` on the +referent and `maxLength: 100` beside the `$ref` are both in force, and the admitted value is the +narrower of the two. There is no precedence to apply — merging with use-site precedence would +publish `100` as the whole truth and lose the bound the document actually enforces. So +`Property.Constraints` and `Parameter.Constraints` hold what their own position declared and +nothing else, and every node reached through `TypeRef` keeps its own. + +The rule a consumer needs follows from that, and it is the reason this is written down rather than +left to be inferred from an empty struct: **an absent `Constraints` at a use site means that +position declared no bound, never that the value is unbounded.** The effective bound is the +conjunction of the use site's own `Constraints` with those of every node reached from its +`TypeRef`; a consumer that wants it — a validator emitter, a differ comparing two revisions — +resolves the reference and intersects. A differ that reads use sites alone sees no change when a +shared component's `maxLength` moves, because the change is at the component, which is the one +place it was declared and the one place it is recorded. + ## 13. Provenance & diagnostics ```go @@ -1919,7 +1947,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, 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); 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); `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; 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 written → Response.Name.Hint and ErrorCase.Name.Hint alike, so `4XX` and `default` round-trip the spelling their range cannot state; 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; 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 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; 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, 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); 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); `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; 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 written → Response.Name.Hint and ErrorCase.Name.Hint alike, so `4XX` and `default` round-trip the spelling their range cannot state; 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; 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 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/ir/constraints.go b/ir/constraints.go index 338dabbd..4066fdfc 100644 --- a/ir/constraints.go +++ b/ir/constraints.go @@ -3,6 +3,16 @@ package ir // Constraints restricts the admissible values of a scalar, list, string, or // numeric type (ir-design §5.3). Numeric bounds are arbitrary-precision decimal // strings, never float64. +// +// Every Constraints is position-scoped: it holds what the position carrying it +// declared, and nothing is ever copied across a TypeRef. Bounds conjoin rather +// than override, so the effective restriction on a value is this struct +// together with the Constraints of every node reached from the position's +// TypeRef, and an absent Constraints means that position declared no bound — +// never that the value is unbounded (ir-design §12.2). Documentation, +// deprecation and Default are the other way round: a compiler merges them from +// a $ref's target onto the referencing carrier with use-site precedence, so a +// use site already carries those and resolves nothing to read them. type Constraints struct { // Min is the inclusive (or exclusive, per ExclusiveMin) lower numeric bound. Min *BigVal `json:"min,omitempty"` diff --git a/ir/operation.go b/ir/operation.go index 11635928..c15bfe4e 100644 --- a/ir/operation.go +++ b/ir/operation.go @@ -77,7 +77,12 @@ type Parameter struct { Required bool `json:"required"` // Default is the parameter's default value. Default *Value `json:"default,omitempty"` - // Constraints restricts the parameter's admissible values. + // Constraints restricts the parameter's admissible values, and holds only + // what the parameter's own position declared. A bound on a $ref'd schema + // stays on the node Type points at and is never copied here, unlike Docs, + // Deprecation and Default, which merge from that target with use-site + // precedence: bounds conjoin rather than override, so nil means this + // position declared none, not that the value is unbounded (ir-design §12.2). Constraints *Constraints `json:"constraints,omitempty"` // ValueFrom derives the parameter's value from a location in the // outgoing/incoming message (AsyncAPI parameter location runtime diff --git a/ir/property.go b/ir/property.go index b9f5177b..78894a5a 100644 --- a/ir/property.go +++ b/ir/property.go @@ -88,7 +88,12 @@ type Property struct { Visibility Visibility `json:"visibility"` // Default is the property's default value. Default *Value `json:"default,omitempty"` - // Constraints restricts the property's admissible values. + // Constraints restricts the property's admissible values, and holds only + // what the property's own position declared. A bound on a $ref'd schema + // stays on the node Type points at and is never copied here, unlike Docs, + // Deprecation and Default, which merge from that target with use-site + // precedence: bounds conjoin rather than override, so nil means this + // position declared none, not that the value is unbounded (ir-design §12.2). Constraints *Constraints `json:"constraints,omitempty"` // Encoding overrides the property's wire encoding. Encoding *Encoding `json:"encoding,omitempty"` diff --git a/ir/typeref.go b/ir/typeref.go index 6b0bc56a..1366b667 100644 --- a/ir/typeref.go +++ b/ir/typeref.go @@ -5,6 +5,13 @@ package ir // not the target type, because the same type is nullable in one position and not // another; combined with Property.Required it yields the four distinct // required/optional × nullable/non-null states. +// +// A TypeRef carries no fact of the target's down with it. What a use site can +// read without resolving Target is only what a compiler already merged onto the +// carrier — a Property's or Parameter's Docs, Deprecation and Default, taken +// from a $ref's target with use-site precedence. Everything else the target +// declares, Constraints above all, is read from the target node itself +// (ir-design §12.2). type TypeRef struct { // Target identifies the referenced TypeDef in Document.Types. Target TypeID `json:"target"` diff --git a/testdata/conformance/openapi/param-ref-inheritance.golden.json b/testdata/conformance/openapi/param-ref-inheritance.golden.json index 68924546..e1329165 100644 --- a/testdata/conformance/openapi/param-ref-inheritance.golden.json +++ b/testdata/conformance/openapi/param-ref-inheritance.golden.json @@ -70,6 +70,12 @@ "list": null, "object": null }, + "constraints": { + "exclusiveMin": false, + "exclusiveMax": false, + "maxLength": 100, + "uniqueItems": false + }, "docs": { "summary": "Cursor", "description": "Cursor for this endpoint only." @@ -237,6 +243,50 @@ "source": 0, "pointer": "/components/schemas/Holder/properties/cursor" } + }, + { + "id": "p/openapi/components/schemas/Holder/properties/override", + "name": { + "source": "override", + "canonical": "override" + }, + "wireName": "override", + "type": { + "target": "t/openapi/components/schemas/Cursor", + "nullable": false + }, + "required": false, + "clientOptional": false, + "defaultAdded": false, + "visibility": { + "none": false + }, + "default": { + "kind": "string", + "str": "0", + "bytes": null, + "list": null, + "object": null + }, + "constraints": { + "exclusiveMin": false, + "exclusiveMax": false, + "maxLength": 100, + "uniqueItems": false + }, + "flatten": false, + "eventHeader": false, + "eventPayload": false, + "secret": false, + "docs": { + "summary": "Cursor", + "description": "Opaque pagination cursor." + }, + "deprecation": {}, + "provenance": { + "source": 0, + "pointer": "/components/schemas/Holder/properties/override" + } } ], "abstract": false, @@ -281,7 +331,7 @@ { "format": "openapi@3.1", "path": "param-ref-inheritance.yaml", - "hash": "ceaabaa7016e71d9190b69c6ae61fb9a8388ccfc6f7cb9069bd067d65fab5d2a" + "hash": "05b17aae85704e581a282689a5cdb4c3b157bb884d6a23035b1f44e2d13b7d42" } ] } diff --git a/testdata/conformance/openapi/param-ref-inheritance.yaml b/testdata/conformance/openapi/param-ref-inheritance.yaml index 98040de6..7caa3611 100644 --- a/testdata/conformance/openapi/param-ref-inheritance.yaml +++ b/testdata/conformance/openapi/param-ref-inheritance.yaml @@ -14,6 +14,10 @@ paths: $ref: '#/components/schemas/Cursor' description: Cursor for this endpoint only. default: "9" + # A bound is the one keyword the use site does not take over: this + # one lands here and the referent's 64 stays on the referent, both + # in force, since bounds conjoin rather than override. + maxLength: 100 responses: "200": description: ok @@ -26,9 +30,12 @@ components: deprecated: true default: "0" maxLength: 64 - # The property form of the same reference: what a parameter inherits, a - # property inherits identically, constraints included in neither. + # The property form of the same references: what a parameter inherits, a + # property inherits identically, and constraints cross to neither. Holder: type: object properties: cursor: {$ref: '#/components/schemas/Cursor'} + override: + $ref: '#/components/schemas/Cursor' + maxLength: 100 From 8f4c75cba43f955f2a3513604ba6d14393c97f32 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Tue, 8 Sep 2026 00:39:44 +0300 Subject: [PATCH 10/13] fix(ir)!: carry minimum and exclusiveMinimum as separate bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In JSON Schema 2020-12 minimum and exclusiveMinimum are independent keywords that both apply; a schema may legally declare both, and the same holds on the upper side. ir.Constraints held one bound plus one exclusivity flag per side, so a co-declared pair had to be reconciled: the tighter keyword took the slot and the other was kept verbatim under Unmodeled as degraded_lowering. The loser was preserved, so nothing was lost outright. But a consumer comparing constraints across two revisions of a spec reads the fields, not the Unmodeled map: a revision that moved only the dropped keyword read as no change, and one that swapped which keyword was tighter read as a change of a different kind than the one that happened (#425). ir.Constraints now holds four bounds — Min, ExclusiveMin, Max, ExclusiveMax, each a *BigVal, each the keyword of the same name. A co-declared pair reaches two fields, keeps nothing beside them, and reports nothing: there is no degradation left to announce. The reconciliation, its exact-decimal tighter-of-two comparison, and its diagnostics are gone; merge adopts and compares each of the four the way it already did multipleOf. BREAKING CHANGE: ExclusiveMin and ExclusiveMax change from bool to *BigVal and their JSON keys gain omitempty, so `"exclusiveMin": false` no longer appears and an exclusive bound serializes as its literal rather than as a flag on `min`. The OpenAPI 3.0 spelling — a boolean modifying the minimum beside it — now lowers to the bound it means: `{minimum: 5, exclusiveMinimum: true}` becomes ExclusiveMin "5" with Min unset, which is what the 3.1 spelling of the same restriction produces, so a 3.0 document and its 3.1 translation no longer differ in the IR. A 3.0 modifier written with no bound to modify (invalid under draft-4, and unchecked by the loader) is kept verbatim under Unmodeled and reported as a warning rather than setting a flag over an absent bound. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1 --- compilers/openapi/conformance_test.go | 69 ++-- .../openapi/constraints_internal_test.go | 27 +- .../internal/annotation/constraints.go | 229 ++++------- .../annotation/constraints_internal_test.go | 2 +- .../constraints_readers_internal_test.go | 387 +++++++----------- .../annotation/decimal_internal_test.go | 12 +- .../internal/merge/conflict_internal_test.go | 17 +- compilers/openapi/internal/merge/merge.go | 76 ++-- .../internal/merge/reconcile_internal_test.go | 65 ++- .../openapi/internal/operation/params_test.go | 72 ++-- .../openapi/internal/schema/compose_test.go | 9 +- .../openapi/internal/schema/schema_test.go | 165 +++++--- docs/ir-design.md | 6 +- ir/constraints.go | 25 +- ir/constraints_test.go | 13 +- ir/helpers_test.go | 12 +- .../allof-oneof-cooccurrence.golden.json | 2 - .../allof-ref-branch-siblings.golden.json | 2 - .../openapi/constraints.golden.json | 80 +--- testdata/conformance/openapi/constraints.yaml | 17 +- .../openapi/encoding-byte.golden.json | 4 - .../openapi/header-content-schema.golden.json | 6 - .../openapi/inline-annotations.golden.json | 10 - .../openapi/multipart-encoding.golden.json | 2 - .../openapi/numeric-precision.golden.json | 10 +- .../openapi/param-ref-inheritance.golden.json | 6 - .../openapi/scalar-format.golden.json | 4 - .../openapi/unhomed-keywords.golden.json | 4 - 28 files changed, 586 insertions(+), 747 deletions(-) diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go index 59bcef23..e09c24a3 100644 --- a/compilers/openapi/conformance_test.go +++ b/compilers/openapi/conformance_test.go @@ -1480,62 +1480,63 @@ func assertConstraints(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { } // assertCoDeclaredBounds pins the 2020-12 rule that a side declaring both of -// its keywords keeps the tighter of the two: the property bounded below keeps -// its minimum, the one bounded above keeps its exclusiveMaximum, and each side -// names the keyword that did not reach ir.Constraints (GitHub #33). +// its keywords carries both: minimum and exclusiveMinimum are independent and +// conjunctive, ir.Constraints has a field for each, and neither is chosen over +// the other (GitHub #33, #425). // -// Both directions are here on purpose. A case where only the exclusive keyword -// survives passes just as well on the reader that always took it, so on its own -// it would say nothing about the fix. +// Both directions are here on purpose. The property bounded below has the +// inclusive keyword as its tighter bound and the one bounded above the +// exclusive one, so a reader that kept the tighter alone answers the two +// differently — and a reader that always kept the exclusive keyword passes the +// second on its own. func assertCoDeclaredBounds(t *testing.T, m *ir.Model, diags []ir.Diagnostic) { t.Helper() low, ok := propByWire(m, "atLeastTen") require.True(t, ok) require.NotNil(t, low.Constraints) require.NotNil(t, low.Constraints.Min) - assert.Equal(t, ir.BigVal("10"), *low.Constraints.Min, "minimum is the tighter bound") - assert.False(t, low.Constraints.ExclusiveMin, "and it is inclusive as written") + require.NotNil(t, low.Constraints.ExclusiveMin) + assert.Equal(t, ir.BigVal("10"), *low.Constraints.Min, "minimum as written") + assert.Equal(t, ir.BigVal("0"), *low.Constraints.ExclusiveMin, + "and the looser exclusiveMinimum beside it, not dropped for being implied") high, ok := propByWire(m, "underTen") require.True(t, ok) require.NotNil(t, high.Constraints) require.NotNil(t, high.Constraints.Max) - assert.Equal(t, ir.BigVal("10"), *high.Constraints.Max, "exclusiveMaximum is the tighter bound") - assert.True(t, high.Constraints.ExclusiveMax) + require.NotNil(t, high.Constraints.ExclusiveMax) + assert.Equal(t, ir.BigVal("100"), *high.Constraints.Max, "maximum as written") + assert.Equal(t, ir.BigVal("10"), *high.Constraints.ExclusiveMax, "and exclusiveMaximum beside it") - for _, want := range []string{"exclusiveMinimum, which it implies", "maximum, which it implies"} { - assert.True(t, slices.ContainsFunc(diags, func(d ir.Diagnostic) bool { - return strings.Contains(d.Message, want) - }), "the keyword ir.Constraints has no room for is named, not dropped in silence: %q", want) + for _, d := range diags { + assert.NotContains(t, d.Message, "exclusiveMinimum", + "a pair that reaches two fields is not a degradation to report") } } -// assertCoDeclaredBoundKept is the losslessness half of the same rule -// (GitHub #286): a keyword named only in a diagnostic reaches no field of the -// document a downstream stage reads, so {minimum: 10, exclusiveMinimum: 0} and -// {minimum: 10} lowered identically. It is kept verbatim on whichever carrier -// read it — the property here, the alias node a component's body reduces to -// below — beside the constraints it did not reach. +// assertCoDeclaredBoundKept is the losslessness half of the same rule: with a +// field per keyword there is nothing left over, so neither carrier keeps a bound +// verbatim. Nothing is restated beside constraints that hold it all — an entry +// there would give one bound two homes, and {minimum: 10, exclusiveMinimum: 0} +// is told from {minimum: 10} by the fields themselves (GitHub #286). func assertCoDeclaredBoundKept(t *testing.T, doc *ir.Document, m *ir.Model) { t.Helper() low, ok := propByWire(m, "atLeastTen") require.True(t, ok) - entry := unmodeledEntry(t, low.Unmodeled, "openapi:exclusiveMinimum") - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.JSONEq(t, "0", string(entry.Value)) - assert.Equal(t, "/components/schemas/S/properties/atLeastTen/exclusiveMinimum", - entry.Provenance.Pointer) + assert.Empty(t, low.Unmodeled, "the property keeps nothing beside its constraints") high, ok := propByWire(m, "underTen") require.True(t, ok) - assert.JSONEq(t, "100", string(unmodeledEntry(t, high.Unmodeled, "openapi:maximum").Value), - "the inclusive keyword is the one kept where the exclusive bound is tighter") + assert.Empty(t, high.Unmodeled, "and neither does the side settled the other way") alias, ok := doc.Types[namedID("Bounded")].(*ir.Scalar) require.True(t, ok, "a component reducing to a shared primitive owns an alias node") require.NotNil(t, alias.Constraints) - assert.JSONEq(t, "0", string(unmodeledEntry(t, alias.Unmodeled, "openapi:exclusiveMinimum").Value), - "a node carries what its constraints had no room for, exactly as a property does") + require.NotNil(t, alias.Constraints.Min) + require.NotNil(t, alias.Constraints.ExclusiveMin) + assert.Equal(t, ir.BigVal("0"), *alias.Constraints.ExclusiveMin, + "a node carries both bounds, exactly as a property does") + assert.Empty(t, alias.Unmodeled) } // assertLengthAndCollectionBounds pins the non-numeric bounds: a string length @@ -1583,12 +1584,10 @@ func assertNumericPrecision(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { exclusive, ok := propByWire(m, "exclusive") require.True(t, ok) require.NotNil(t, exclusive.Constraints) - require.NotNil(t, exclusive.Constraints.Min) - require.NotNil(t, exclusive.Constraints.Max) - assert.True(t, exclusive.Constraints.ExclusiveMin) - assert.True(t, exclusive.Constraints.ExclusiveMax) - assert.Equal(t, ir.BigVal("0.5"), *exclusive.Constraints.Min) - assert.Equal(t, ir.BigVal("0.12345678901234567890123456789"), *exclusive.Constraints.Max) + require.NotNil(t, exclusive.Constraints.ExclusiveMin) + require.NotNil(t, exclusive.Constraints.ExclusiveMax) + assert.Equal(t, ir.BigVal("0.5"), *exclusive.Constraints.ExclusiveMin) + assert.Equal(t, ir.BigVal("0.12345678901234567890123456789"), *exclusive.Constraints.ExclusiveMax) // A default beyond float64 range is captured as a number, not a string. withDefault, ok := propByWire(m, "withDefault") diff --git a/compilers/openapi/constraints_internal_test.go b/compilers/openapi/constraints_internal_test.go index d2ffa1fa..4270f554 100644 --- a/compilers/openapi/constraints_internal_test.go +++ b/compilers/openapi/constraints_internal_test.go @@ -30,10 +30,15 @@ func TestConstraints_ExclusiveBoolean30(t *testing.T) { doc, diags := lowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) c := propConstraints(t, doc, "S", "n") - assert.True(t, c.ExclusiveMin) - assert.True(t, c.ExclusiveMax) - require.NotNil(t, c.Min) - assert.Equal(t, ir.BigVal("5"), *c.Min) + // The modifier is a spelling of the exclusive bound, so the literal it + // modified lands in the exclusive field and the inclusive one is left empty + // — the same constraints the 2020-12 spelling of "x > 5, x < 10" produces. + assert.Nil(t, c.Min, "the modified minimum does not also stay inclusive") + assert.Nil(t, c.Max) + require.NotNil(t, c.ExclusiveMin) + require.NotNil(t, c.ExclusiveMax) + assert.Equal(t, ir.BigVal("5"), *c.ExclusiveMin) + assert.Equal(t, ir.BigVal("10"), *c.ExclusiveMax) } func TestConstraints_ExclusiveNumeric31(t *testing.T) { @@ -49,12 +54,12 @@ func TestConstraints_ExclusiveNumeric31(t *testing.T) { doc, diags := lowerSpec(t, spec) openapitest.RequireNoErrorDiags(t, diags) c := propConstraints(t, doc, "S", "n") - assert.True(t, c.ExclusiveMin) - assert.True(t, c.ExclusiveMax) - require.NotNil(t, c.Min) - require.NotNil(t, c.Max) - assert.Equal(t, ir.BigVal("1.5"), *c.Min) - assert.Equal(t, ir.BigVal("9.5"), *c.Max) + assert.Nil(t, c.Min) + assert.Nil(t, c.Max) + require.NotNil(t, c.ExclusiveMin) + require.NotNil(t, c.ExclusiveMax) + assert.Equal(t, ir.BigVal("1.5"), *c.ExclusiveMin) + assert.Equal(t, ir.BigVal("9.5"), *c.ExclusiveMax) } func TestConstraints_MalformedNumericLiterals(t *testing.T) { @@ -174,7 +179,7 @@ func TestConstraints_ExclusiveWrongDialectForm(t *testing.T) { require.True(t, ok) for _, p := range m.Properties { if p.WireName == "n" && p.Constraints != nil { - assert.False(t, p.Constraints.ExclusiveMin, "wrong-form exclusive bound is not set") + assert.Nil(t, p.Constraints.ExclusiveMin, "wrong-form exclusive bound is not set") } } }) diff --git a/compilers/openapi/internal/annotation/constraints.go b/compilers/openapi/internal/annotation/constraints.go index 8acc5e39..587a1646 100644 --- a/compilers/openapi/internal/annotation/constraints.go +++ b/compilers/openapi/internal/annotation/constraints.go @@ -27,15 +27,17 @@ const ( // are List-owned and read elsewhere. A non-finite bound literal yields an // error-severity diag.NumericPrecision diagnostic and is skipped; nil is // returned when no constraint is present. exclusiveBoolean selects the -// exclusiveMinimum/exclusiveMaximum dialect (see applyExclusive), and under the -// 2020-12 one a side that declares both of its keywords is settled by -// reconcileBound rather than by whichever ran last. +// exclusiveMinimum/exclusiveMaximum dialect (see applyExclusive); under the +// 2020-12 one a side may declare both of its keywords, and both reach a field +// of their own, so neither is chosen over the other. // -// The keyword that reconciliation leaves out of ir.Constraints comes back as the -// second return, an ir.Unmodeled the caller merges into whichever carrier its -// reading position owns. pointer and srcIndex locate it, exactly as they locate -// what Read keeps. Everything else a schema says about its values reaches a -// field, so on all but a co-declared numeric bound that map is nil. +// A keyword that reaches no field comes back as the second return, an +// ir.Unmodeled the caller merges into whichever carrier its reading position +// owns. pointer and srcIndex locate it, exactly as they locate what Read keeps. +// One keyword can land there: a 3.0 exclusiveMinimum/exclusiveMaximum true with +// no bound beside it to make exclusive (see applyExclusiveFlag). Everything +// else a schema says about its values reaches a field, so that map is usually +// nil. // // It reads beside the other readers here for the reason they are here at all: // what a schema says about the values admitted at a position is read the same @@ -62,12 +64,12 @@ func Constraints(s *oas3.Schema, exclusiveBoolean bool, pointer string, srcIndex return c, residue.kept, diags } -// boundResidue is where a schema's bounds were written, and what became of the -// co-declared keywords that reached no field of ir.Constraints. +// boundResidue is where a schema's bounds were written, and what became of a +// bound keyword that reached no field of ir.Constraints. // -// One value serves both sides, so a schema co-declaring each of them leaves two -// entries here and each keyword survives — writing the map rather than adding to -// it would keep whichever side ran second. +// One value serves both sides, so a schema leaving residue on each of them +// leaves two entries here and each keyword survives — writing the map rather +// than adding to it would keep whichever side ran second. // // The keyword is recorded here rather than handed back for a caller to record, // so that the diagnostic naming it is written at the same statement that keeps @@ -82,19 +84,16 @@ type boundResidue struct { kept ir.Unmodeled } -// keepRedundant keeps the co-declared keyword that ir.Constraints has no room -// for, and returns the diagnostic reporting the pair. +// keepUnmodifiable keeps a 3.0 exclusive-bound modifier that had no bound to +// modify, and returns the diagnostic reporting it. // -// It writes back the literal already read rather than re-reading the keyword's -// raw node. The two produce the same bytes — RawFromNode renders a numeric -// scalar through the same value.NumericLiteral this bound came from — but only -// this one cannot fail, since BigVal's contract is that its text renders as a -// JSON number. That is what lets the message state the keyword is kept without -// a branch for the case where it was not. -func (b *boundResidue) keepRedundant(keptProp string, kept ir.BigVal, dropProp string, dropped ir.BigVal, compared bool) ir.Diagnostic { - PreserveInto(&b.kept, "openapi:"+dropProp, ir.RawValue(dropped), - ir.ReasonDegradedLowering, b.pointer+ids.Ptr(dropProp), b.srcIndex) - return redundantBoundDiag(keptProp, kept, dropProp, dropped, compared) +// The literal is the boolean the keyword was written as, which is the whole of +// what it said; there is no numeric bound here to write back, because the +// absence of one is the reason it is being kept at all. +func (b *boundResidue) keepUnmodifiable(inclProp, exclProp string) ir.Diagnostic { + PreserveInto(&b.kept, "openapi:"+exclProp, ir.RawValue("true"), + ir.ReasonDegradedLowering, b.pointer+ids.Ptr(exclProp), b.srcIndex) + return unmodifiableExclusiveDiag(inclProp, exclProp) } // numericBounds fills Min, Max, and MultipleOf from the raw minimum/maximum/ @@ -135,15 +134,16 @@ func boundLiteralDiag(prop, literal string, err error) ir.Diagnostic { } // applyExclusive handles exclusiveMinimum/exclusiveMaximum in both dialects: the -// 3.0 boolean arm flags the corresponding Min/Max as exclusive; the 2020-12 -// numeric arm (3.1/3.2) carries the bound value itself, read from the raw node to -// avoid the float64 trap, and hands it to reconcileBound, which decides how it -// meets any minimum/maximum declared beside it. side picks which of the two -// keywords is read, residue is where the reconciliation records the one that -// reaches no field, and exclusiveBoolean selects the dialect (true for 3.0). -// Because load suppresses the library's type-mismatch on these keywords, a -// value in the wrong form for the dialect is reported and dropped here rather -// than silently accepted. +// 3.0 boolean arm modifies the minimum/maximum written beside it (see +// applyExclusiveFlag); the 2020-12 numeric arm (3.1/3.2) carries the bound value +// itself, read from the raw node to avoid the float64 trap, and writes it to the +// side's own exclusive field, where it stands beside any minimum/maximum +// declared with it rather than in place of it. side picks which of the two +// keywords is read, residue is where the one keyword that can reach no field is +// recorded, and exclusiveBoolean selects the dialect (true for 3.0). Because +// load suppresses the library's type-mismatch on these keywords, a value in the +// wrong form for the dialect is reported and dropped here rather than silently +// accepted. func applyExclusive(c *ir.Constraints, s *oas3.Schema, side boundSide, residue *boundResidue, exclusiveBoolean bool) []ir.Diagnostic { ev, prop := s.GetExclusiveMaximum(), "exclusiveMaximum" if side == minBound { @@ -156,10 +156,7 @@ func applyExclusive(c *ir.Constraints, s *oas3.Schema, side boundSide, residue * return []ir.Diagnostic{exclusiveFormDiag(prop, exclusiveBoolean)} } if ev.IsLeft() { - if b := ev.GetLeft(); b != nil && *b { - setExclusiveFlag(c, side) - } - return nil + return applyExclusiveFlag(c, side, residue, ev.GetLeft()) } node := RawPropertyNode(s, prop) if node == nil { @@ -169,104 +166,55 @@ func applyExclusive(c *ir.Constraints, s *oas3.Schema, side boundSide, residue * if err != nil { return []ir.Diagnostic{boundLiteralDiag(prop, node.Value, err)} } - return reconcileBound(c, side, residue, v) + setExclusiveBound(c, side, &v) + return nil } -// reconcileBound settles one side's bound when the 2020-12 dialect declares -// both keywords for it: the inclusive minimum/maximum numericBounds has already -// put in c, and the exclusive bound excl read alongside it. +// applyExclusiveFlag reads the 3.0 boolean arm, where exclusiveMinimum is not a +// bound but a modifier of the minimum written beside it: "minimum: 5, +// exclusiveMinimum: true" is "x > 5", which is what ir.Constraints spells as +// ExclusiveMin. So the literal moves from the inclusive slot to the exclusive +// one and the inclusive slot is emptied — the 2020-12 spelling of the same +// restriction, not a lowering of it, and the only reading under which a 3.0 +// document and its 3.1 translation lower alike. // -// The two are independent and conjunctive there — "x >= m and x > e" — so the -// tighter of them is the effective bound and the other adds nothing. ir.Constraints -// holds one bound plus one exclusivity flag per side, so the tighter one is kept; -// taking the exclusive bound unconditionally, as this did before, published a -// constraint weaker than the source wherever minimum was the tighter (GitHub #33). +// A false modifier says the bound beside it is inclusive, which is where +// numericBounds already put it, so it moves nothing. // -// The discarded keyword is implied by the kept one, so no value the source -// admits or excludes changes. What would change is the record that the source -// spelled the bound twice, so it is kept verbatim on residue rather than left to a -// diagnostic message: a consumer reconstructing or diffing the source reads the -// document, not the diagnostics, and cannot otherwise tell -// {minimum: 10, exclusiveMinimum: 0} from {minimum: 10} (GitHub #286). -func reconcileBound(c *ir.Constraints, side boundSide, residue *boundResidue, excl ir.BigVal) []ir.Diagnostic { - incl, inclProp, exclProp := c.Max, "maximum", "exclusiveMaximum" - if side == minBound { - incl, inclProp, exclProp = c.Min, "minimum", "exclusiveMinimum" - } - if incl == nil { - setExclusiveBound(c, side, &excl) +// A true modifier with no bound beside it modifies nothing: draft-4 requires +// minimum wherever exclusiveMinimum appears, so such a schema is invalid, and +// there is no bound for the IR to make exclusive. Dropping it would be a +// declared keyword lost without a word, so it is kept verbatim under Unmodeled +// and reported. +func applyExclusiveFlag(c *ir.Constraints, side boundSide, residue *boundResidue, flag *bool) []ir.Diagnostic { + if flag == nil || !*flag { return nil } - - tighter, compared := inclusiveIsTighter(*incl, excl, side) - if tighter { - return []ir.Diagnostic{residue.keepRedundant(inclProp, *incl, exclProp, excl, compared)} + incl := inclusiveBound(c, side) + if *incl == nil { + inclProp, exclProp := boundProps(side) + return []ir.Diagnostic{residue.keepUnmodifiable(inclProp, exclProp)} } - - dropped := *incl - setExclusiveBound(c, side, &excl) - return []ir.Diagnostic{residue.keepRedundant(exclProp, excl, inclProp, dropped, compared)} + setExclusiveBound(c, side, *incl) + *incl = nil + return nil } -// inclusiveIsTighter reports whether the inclusive bound incl admits fewer -// values than the exclusive bound excl written on the same side, and whether -// the two could be compared at all. -// -// A minimum is tighter when it is the greater of the two, a maximum when it is -// the lesser; equal magnitudes are never tighter, which is what gives the -// exclusive bound the tie on both sides ("x >= 5 and x > 5" is "x > 5", -// "x <= 5 and x < 5" is "x < 5"). -// -// The comparison is exact and never rounds to float64: these are the literals -// BigVal exists to keep intact, so comparing them as floats would let a pair -// that differs past float64's precision — or one beyond its range — pick the -// wrong bound, reintroducing the defect this reconciliation exists to fix. It -// is also total over every magnitude a spec may legally write, which a rational -// is not: math/big will not build 1e1000001 as one, and a bound it cannot order -// is a bound it may silently widen. -// -// What it cannot order is a literal outside the decimal grammar, and there the -// caller keeps the exclusive bound and says the other may have been the tighter. -// No schema reaches that today — every bound comes through ir.NewBigVal, whose -// grammar is the narrower of the two — so it stands for the day that changes: -// a bound this cannot order is one that could be silently replaced by the looser -// of its pair, which is the defect this reconciliation exists to prevent. -func inclusiveIsTighter(incl, excl ir.BigVal, side boundSide) (tighter, compared bool) { - inclDec, inclOK := parseDecimalBound(incl) - exclDec, exclOK := parseDecimalBound(excl) - if !inclOK || !exclOK { - return false, false - } - order := compareDecimalBounds(inclDec, exclDec) - if order == 0 { - return false, true +// boundProps names the inclusive and exclusive keyword that bound one side. +func boundProps(side boundSide) (inclProp, exclProp string) { + if side == minBound { + return "minimum", "exclusiveMinimum" } - return (order > 0) == (side == minBound), true + return "maximum", "exclusiveMaximum" } -// redundantBoundDiag reports the co-declared 2020-12 bound that reached no -// field of ir.Constraints, naming both keywords and both exact literals so a -// reader can see which bound the IR carries without going back to the source. -// -// It states that the other keyword is kept verbatim because keepRedundant has -// already kept it, by a route with no failure to report. -// -// compared tells the two cases apart. When the magnitudes did compare, the kept -// bound is provably the tighter and the other is redundant, which costs the -// consumer nothing — hence info severity. When they did not, the kept bound is -// the exclusive one by fallback and may be the looser of the two, so the message -// says so and the severity rises to warning. -func redundantBoundDiag(keptProp string, kept ir.BigVal, dropProp string, dropped ir.BigVal, compared bool) ir.Diagnostic { - if !compared { - return diag.Newf(ir.SeverityWarning, diag.DegradedConstruct, ir.Provenance{}, - "%s %s and %s %s both bound this value but their magnitudes could not be compared; "+ - "kept %s as the bound, and %s, which may be the tighter of the two, verbatim under Unmodeled", - keptProp, kept, dropProp, dropped, keptProp, dropProp) +// inclusiveBound addresses the Min or Max slot of c, so that a caller reading +// one side can both read and clear it without repeating the side branch. +func inclusiveBound(c *ir.Constraints, side boundSide) **ir.BigVal { + if side == minBound { + return &c.Min } - return diag.Newf(ir.SeverityInfo, diag.DegradedConstruct, ir.Provenance{}, - "%s %s and %s %s both bound this value and the IR holds one bound per side; "+ - "kept %s as the tighter of the two, and %s, which it implies, verbatim under Unmodeled", - keptProp, kept, dropProp, dropped, keptProp, dropProp) + return &c.Max } // exclusiveFormDiag reports an exclusiveMinimum/exclusiveMaximum whose value form @@ -283,27 +231,30 @@ func exclusiveFormDiag(prop string, exclusiveBoolean bool) ir.Diagnostic { "%s must be %s in this OpenAPI dialect", prop, want) } -// setExclusiveFlag marks the low or high bound exclusive. -func setExclusiveFlag(c *ir.Constraints, side boundSide) { - if side == minBound { - c.ExclusiveMin = true - return - } - c.ExclusiveMax = true +// unmodifiableExclusiveDiag reports a 3.0 exclusive-bound modifier written +// without the bound it modifies. Draft-4 requires minimum wherever +// exclusiveMinimum appears (and maximum wherever exclusiveMaximum does), so the +// schema is invalid; but the keyword is one the loader hands to Morphic +// unchecked, and an invalid schema is still a schema whose text a consumer may +// need, so this is a warning over a kept construct rather than an error over a +// dropped one. +func unmodifiableExclusiveDiag(inclProp, exclProp string) ir.Diagnostic { + return diag.Newf(ir.SeverityWarning, diag.DegradedConstruct, ir.Provenance{}, + "%s is true with no %s beside it to make exclusive, so it bounds nothing; "+ + "kept verbatim under Unmodeled", exclProp, inclProp) } -// setExclusiveBound sets an exclusive numeric bound (2020-12 arm) on Min or Max, -// replacing whatever minimum/maximum put there. Only reconcileBound may call it, -// which is where the replacement is decided; calling it directly is the shape of -// GitHub #33. +// setExclusiveBound writes the exclusive bound of one side. It never touches +// the inclusive slot: the two keywords are independent, both apply where both +// are declared, and overwriting one with the other published a bound the source +// never wrote (GitHub #33) and hid a change to the overwritten one (GitHub +// #425). func setExclusiveBound(c *ir.Constraints, side boundSide, v *ir.BigVal) { if side == minBound { - c.Min = v - c.ExclusiveMin = true + c.ExclusiveMin = v return } - c.Max = v - c.ExclusiveMax = true + c.ExclusiveMax = v } // emptyConstraints reports whether c carries no scalar constraint set by @@ -311,7 +262,7 @@ func setExclusiveBound(c *ir.Constraints, side boundSide, v *ir.BigVal) { // Constraints populates must appear in this check; a // missing field silently leaks a non-nil *Constraints when it should be nil. func emptyConstraints(c *ir.Constraints) bool { - return c.Min == nil && c.Max == nil && !c.ExclusiveMin && !c.ExclusiveMax && + return c.Min == nil && c.Max == nil && c.ExclusiveMin == nil && c.ExclusiveMax == nil && c.MultipleOf == nil && c.Precision == nil && c.Scale == nil && c.MinLength == nil && c.MaxLength == nil && c.Pattern == "" && c.PatternMessage == "" && diff --git a/compilers/openapi/internal/annotation/constraints_internal_test.go b/compilers/openapi/internal/annotation/constraints_internal_test.go index 0d31c4c0..9e92fcce 100644 --- a/compilers/openapi/internal/annotation/constraints_internal_test.go +++ b/compilers/openapi/internal/annotation/constraints_internal_test.go @@ -27,6 +27,6 @@ func TestApplyExclusive_NumericWithoutRootNode(t *testing.T) { // The numeric arm is taken (2020-12 dialect, numeric value) but there is no raw // node to read the exact literal from, so nothing is set and no diagnostic. assert.Nil(t, diags) - assert.False(t, c.ExclusiveMin) + assert.Nil(t, c.ExclusiveMin) assert.Empty(t, residue.kept) } diff --git a/compilers/openapi/internal/annotation/constraints_readers_internal_test.go b/compilers/openapi/internal/annotation/constraints_readers_internal_test.go index 6c6c68eb..9c3f723c 100644 --- a/compilers/openapi/internal/annotation/constraints_readers_internal_test.go +++ b/compilers/openapi/internal/annotation/constraints_readers_internal_test.go @@ -124,13 +124,13 @@ func TestApplyExclusive_BothDialects(t *testing.T) { body string exclusiveBoolean bool wantMin, wantMax *ir.BigVal - wantExclMin bool - wantExclMax bool + wantExclMin *ir.BigVal + wantExclMax *ir.BigVal }{ { - name: "3.0 boolean flags the bound beside it", exclusiveBoolean: true, - body: "minimum: 1\nexclusiveMinimum: true\nmaximum: 9\nexclusiveMaximum: true\n", - wantMin: bigOf("1"), wantMax: bigOf("9"), wantExclMin: true, wantExclMax: true, + name: "3.0 boolean turns the bound beside it exclusive", exclusiveBoolean: true, + body: "minimum: 1\nexclusiveMinimum: true\nmaximum: 9\nexclusiveMaximum: true\n", + wantExclMin: bigOf("1"), wantExclMax: bigOf("9"), }, { name: "3.0 false leaves the bound inclusive", exclusiveBoolean: true, @@ -139,8 +139,8 @@ func TestApplyExclusive_BothDialects(t *testing.T) { }, { name: "2020-12 numeric carries the bound itself", exclusiveBoolean: false, - body: "exclusiveMinimum: 1\nexclusiveMaximum: 9\n", - wantMin: bigOf("1"), wantMax: bigOf("9"), wantExclMin: true, wantExclMax: true, + body: "exclusiveMinimum: 1\nexclusiveMaximum: 9\n", + wantExclMin: bigOf("1"), wantExclMax: bigOf("9"), }, } for _, tc := range tests { @@ -190,7 +190,8 @@ func TestApplyExclusive_TheWrongFormForTheDialectIsReported(t *testing.T) { assert.Contains(t, diags[0].Message, tc.wantSays) assert.Contains(t, diags[0].Message, "exclusiveMinimum") require.NotNil(t, got, "the sibling minimum is still read") - assert.False(t, got.ExclusiveMin, "the mismatched value sets no flag") + assert.Nil(t, got.ExclusiveMin, "the mismatched value sets no bound") + assert.Equal(t, bigOf("1"), got.Min, "and it stays inclusive, unmoved") }) } } @@ -208,128 +209,70 @@ func TestApplyExclusive_AMalformedNumericBoundIsReported(t *testing.T) { assert.Nil(t, got) } -// TestReconcileBound_KeepsTheTighterOfTwoCoDeclaredBounds pins the 2020-12 rule -// that a side's two keywords are independent and conjunctive, so one bound slot -// must hold the tighter of them. Keeping the looser is a constraint weaker than -// the source wrote, which is a wrong answer rather than an incomplete one -// (GitHub #33) — {minimum: 10, exclusiveMinimum: 0} once compiled to "> 0". +// TestConstraints_CoDeclaredBoundsBothReachAField pins the 2020-12 rule that a +// side's two keywords are independent and conjunctive: each is a restriction the +// source wrote, ir.Constraints has a field for each, and neither is chosen over +// the other. // -// The tie rows are the reason each side is spelled out rather than derived from -// the other: "x >= 5 and x > 5" is "x > 5" and "x <= 5 and x < 5" is "x < 5", so -// the exclusive bound wins a tie on both sides even though "tighter" runs the -// opposite way on each. +// The rows come in pairs that swap which keyword is the tighter while leaving +// the same two magnitudes on the side. One slot per side answers both rows of a +// pair with the tighter bound alone, so a consumer diffing two revisions of a +// spec across such a swap saw a change of a different kind than the one that +// happened — and a revision that moved only the looser keyword read as no change +// at all (GitHub #425). Two fields answer them differently, which is what these +// pairs are here to hold. // -// wantKept is the other half of the rule and the half a diagnostic cannot do -// (GitHub #286): the keyword the bound slot has no room for is a keyword the -// source wrote, so it comes back as an entry a carrier holds. Without it -// {minimum: 10, exclusiveMinimum: 0} and {minimum: 10} produce the same -// document, which is what lossless-by-default forbids. -// TestConstraints_BothSidesCoDeclaredKeepEachKeyword covers the two sides -// together, which the rows below cover only one at a time. -// -// One boundResidue serves both calls to applyExclusive, so the second side adds -// to what the first kept. Were it to write the map instead, the surviving entry -// would be whichever side ran second and the other keyword would go — silently, -// since a schema declaring all four is as valid as one declaring two. Every -// other case here declares one side, so none of them can tell the two apart. -// -// The two sides are deliberately settled opposite ways — the minimum loses to -// its exclusive keyword, the maximum wins over its own — so the entries come -// from both of reconcileBound's arms rather than twice from one. Both dropping -// the same keyword would leave the other arm's write untested in combination. -func TestConstraints_BothSidesCoDeclaredKeepEachKeyword(t *testing.T) { - t.Parallel() - _, kept, diags := Constraints(schemaFromYAML(t, `type: integer -minimum: 10 -exclusiveMinimum: 20 -maximum: 100 -exclusiveMaximum: 999 -`), false, "/p", 0) - - require.Len(t, kept, 2, "each side leaves the keyword it had no room for; got %v", kept) - for _, want := range []struct{ key, value, pointer string }{ - {"openapi:minimum", "10", "/p/minimum"}, - {"openapi:exclusiveMaximum", "999", "/p/exclusiveMaximum"}, - } { - entry, ok := kept[want.key] - require.True(t, ok, "%s survives the other side", want.key) - assert.Equal(t, want.value, string(entry.Value)) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.Equal(t, ir.Provenance{Pointer: want.pointer}, entry.Provenance) - } - assert.Len(t, diags, 2, "and each side reports its own pair") -} - -func TestReconcileBound_KeepsTheTighterOfTwoCoDeclaredBounds(t *testing.T) { +// Nothing is kept verbatim and nothing is reported: with both keywords in the +// document there is no residue to keep and no degradation to announce. +func TestConstraints_CoDeclaredBoundsBothReachAField(t *testing.T) { t.Parallel() tests := []struct { - name string - body string - want ir.Constraints - wantKept string - wantRaw string - wantSays []string + name string + body string + want ir.Constraints }{ { - name: "minimum is the tighter of the pair", - body: "minimum: 10\nexclusiveMinimum: 0\n", - want: ir.Constraints{Min: bigOf("10")}, - wantKept: "openapi:exclusiveMinimum", wantRaw: "0", - wantSays: []string{"minimum 10", "exclusiveMinimum 0", - "kept minimum as the tighter of the two, and exclusiveMinimum, " + - "which it implies, verbatim under Unmodeled"}, + name: "minimum is the tighter of the pair", + body: "minimum: 10\nexclusiveMinimum: 0\n", + want: ir.Constraints{Min: bigOf("10"), ExclusiveMin: bigOf("0")}, }, { - name: "exclusiveMinimum is the tighter of the pair", - body: "minimum: 0\nexclusiveMinimum: 10\n", - want: ir.Constraints{Min: bigOf("10"), ExclusiveMin: true}, - wantKept: "openapi:minimum", wantRaw: "0", - wantSays: []string{"exclusiveMinimum 10", "minimum 0", - "kept exclusiveMinimum as the tighter of the two, and minimum, " + - "which it implies, verbatim under Unmodeled"}, + name: "exclusiveMinimum is the tighter of the pair", + body: "minimum: 0\nexclusiveMinimum: 10\n", + want: ir.Constraints{Min: bigOf("0"), ExclusiveMin: bigOf("10")}, }, { - name: "equal minimums leave the exclusive one standing", - body: "minimum: 5\nexclusiveMinimum: 5\n", - want: ir.Constraints{Min: bigOf("5"), ExclusiveMin: true}, - wantKept: "openapi:minimum", wantRaw: "5", - wantSays: []string{"exclusiveMinimum 5", "minimum 5", "kept exclusiveMinimum as the tighter"}, + name: "equal minimums are two keywords, not one", + body: "minimum: 5\nexclusiveMinimum: 5\n", + want: ir.Constraints{Min: bigOf("5"), ExclusiveMin: bigOf("5")}, }, { - name: "maximum is the tighter of the pair", - body: "maximum: 10\nexclusiveMaximum: 100\n", - want: ir.Constraints{Max: bigOf("10")}, - wantKept: "openapi:exclusiveMaximum", wantRaw: "100", - wantSays: []string{"maximum 10", "exclusiveMaximum 100", "kept maximum as the tighter"}, + name: "maximum is the tighter of the pair", + body: "maximum: 10\nexclusiveMaximum: 100\n", + want: ir.Constraints{Max: bigOf("10"), ExclusiveMax: bigOf("100")}, }, { - name: "exclusiveMaximum is the tighter of the pair", - body: "maximum: 100\nexclusiveMaximum: 10\n", - want: ir.Constraints{Max: bigOf("10"), ExclusiveMax: true}, - wantKept: "openapi:maximum", wantRaw: "100", - wantSays: []string{"exclusiveMaximum 10", "maximum 100", "kept exclusiveMaximum as the tighter"}, + name: "exclusiveMaximum is the tighter of the pair", + body: "maximum: 100\nexclusiveMaximum: 10\n", + want: ir.Constraints{Max: bigOf("100"), ExclusiveMax: bigOf("10")}, }, { - name: "equal maximums leave the exclusive one standing", - body: "maximum: 5\nexclusiveMaximum: 5\n", - want: ir.Constraints{Max: bigOf("5"), ExclusiveMax: true}, - wantKept: "openapi:maximum", wantRaw: "5", - wantSays: []string{"exclusiveMaximum 5", "maximum 5", "kept exclusiveMaximum as the tighter"}, + name: "both sides co-declared keep all four keywords", + body: "minimum: 10\nexclusiveMinimum: 20\nmaximum: 100\nexclusiveMaximum: 999\n", + want: ir.Constraints{ + Min: bigOf("10"), ExclusiveMin: bigOf("20"), + Max: bigOf("100"), ExclusiveMax: bigOf("999"), + }, }, { - name: "a bound decided by a digit float64 cannot hold", - body: "minimum: 9007199254740993\nexclusiveMinimum: 9007199254740992\n", - want: ir.Constraints{Min: bigOf("9007199254740993")}, - wantKept: "openapi:exclusiveMinimum", wantRaw: "9007199254740992", - wantSays: []string{"minimum 9007199254740993", "exclusiveMinimum 9007199254740992", - "kept minimum as the tighter"}, + name: "a pair no float64 tells apart keeps both literals", + body: "minimum: 9007199254740993\nexclusiveMinimum: 9007199254740992\n", + want: ir.Constraints{Min: bigOf("9007199254740993"), ExclusiveMin: bigOf("9007199254740992")}, }, { - name: "one value spelled two ways is still a tie", - body: "minimum: 1e2\nexclusiveMinimum: 100\n", - want: ir.Constraints{Min: bigOf("100"), ExclusiveMin: true}, - wantKept: "openapi:minimum", wantRaw: "1e2", - wantSays: []string{"exclusiveMinimum 100", "minimum 1e2", "kept exclusiveMinimum as the tighter"}, + name: "one value spelled two ways stays two keywords", + body: "minimum: 1e2\nexclusiveMinimum: 100\n", + want: ir.Constraints{Min: bigOf("1e2"), ExclusiveMin: bigOf("100")}, }, } for _, tc := range tests { @@ -341,32 +284,40 @@ func TestReconcileBound_KeepsTheTighterOfTwoCoDeclaredBounds(t *testing.T) { if diff := cmp.Diff(tc.want, *got); diff != "" { t.Errorf("constraints (-want +got):\n%s", diff) } - entry, ok := kept[tc.wantKept] - require.True(t, ok, "the keyword no bound slot holds is kept verbatim; got %v", kept) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.Equal(t, tc.wantRaw, string(entry.Value), "its exact literal, not the bound that won") - assert.Equal(t, ir.Provenance{Source: 3, Pointer: "/p/" + strings.TrimPrefix(tc.wantKept, "openapi:")}, - entry.Provenance, "located at the keyword it came from") - assert.Len(t, kept, 1, "only the keyword the reconciliation left over") - - require.Len(t, diags, 1, "the keyword that did not reach the IR is reported") - assert.Equal(t, ir.SeverityInfo, diags[0].Severity) - assert.Equal(t, diag.DegradedConstruct, diags[0].Code) - for _, says := range tc.wantSays { - assert.Contains(t, diags[0].Message, says) - } + assert.Empty(t, kept, "every keyword written reaches a field of its own") + assert.Empty(t, diags, "so there is no degradation to report") }) } } -// TestReconcileBound_OneKeywordPerSideIsNotReconciled pins the silent path. A -// side that writes one keyword has nothing to reconcile, so announcing a -// dropped bound there would report a loss that did not happen — and it is the -// common case, which a diagnostic on every numeric schema would drown. -// -// It keeps nothing verbatim either: every keyword written here reaches a field -// of ir.Constraints, and an entry restating one would give a bound two homes. -func TestReconcileBound_OneKeywordPerSideIsNotReconciled(t *testing.T) { +// TestConstraints_ABoundNoFloatHoldsIsCarriedVerbatim pins that the fields hold +// the literal the source wrote at magnitudes nothing else here could carry. +// math/big will not build 1e2000000 as a rational and float64 has no room for it +// at all, so a lowering that reduced either bound to a number would have to +// round or fail; ir.NewBigVal keeps the text, and both keywords keep their own. +func TestConstraints_ABoundNoFloatHoldsIsCarriedVerbatim(t *testing.T) { + t.Parallel() + got, kept, diags := Constraints(schemaFromYAMLUnvalidated(t, + "type: number\nminimum: 1.0e2000000\nexclusiveMinimum: 5\nmaximum: 1e-1000001\nexclusiveMaximum: 5\n"), + false, "/p", 0) + + require.NotNil(t, got) + want := ir.Constraints{ + Min: bigOf("1.0e2000000"), ExclusiveMin: bigOf("5"), + Max: bigOf("1e-1000001"), ExclusiveMax: bigOf("5"), + } + if diff := cmp.Diff(want, *got); diff != "" { + t.Errorf("constraints (-want +got):\n%s", diff) + } + assert.Empty(t, kept) + assert.Empty(t, diags) +} + +// TestConstraints_OneKeywordPerSideKeepsNothing pins the ordinary case. Every +// keyword written reaches a field, so there is nothing to keep verbatim — an +// entry restating one would give a bound two homes — and nothing to report, +// which a diagnostic on every numeric schema would drown anyway. +func TestConstraints_OneKeywordPerSideKeepsNothing(t *testing.T) { t.Parallel() for _, body := range []string{ "minimum: 1\nmaximum: 9\n", @@ -384,141 +335,91 @@ func TestReconcileBound_OneKeywordPerSideIsNotReconciled(t *testing.T) { } } -// TestReconcileBound_ThreeZeroDialectPairIsUntouched pins the 3.0 arm against -// the 2020-12 fix. There exclusiveMinimum is a boolean modifier of the minimum -// beside it, so the two cannot be rival bounds and there is nothing to drop: -// reconciling them would invent a diagnostic and could discard the bound the -// flag modifies. Nothing is kept verbatim there either: both keywords reach a -// field, so there is no keyword left over to keep. -func TestReconcileBound_ThreeZeroDialectPairIsUntouched(t *testing.T) { - t.Parallel() - got, kept, diags := Constraints(schemaFromYAML(t, - "type: number\nminimum: 10\nexclusiveMinimum: true\nmaximum: 20\nexclusiveMaximum: true\n"), true, "/p", 0) - - require.NotNil(t, got) - assert.Empty(t, diags) - assert.Empty(t, kept) - want := ir.Constraints{Min: bigOf("10"), Max: bigOf("20"), ExclusiveMin: true, ExclusiveMax: true} - if diff := cmp.Diff(want, *got); diff != "" { - t.Errorf("constraints (-want +got):\n%s", diff) - } -} - -// TestApplyExclusive_ThreeZeroFlagsTheSideItWasReadFrom pins which side the 3.0 -// boolean arm marks exclusive. -// -// The case above declares the keyword on both sides, and every other 3.0 case -// here does too — where flagging the wrong side is symmetric, so a reader that -// crossed them over produces exactly the expected constraints. Only a schema -// exclusive on one side can tell the two apart. -func TestApplyExclusive_ThreeZeroFlagsTheSideItWasReadFrom(t *testing.T) { - t.Parallel() - got, kept, diags := Constraints(schemaFromYAML(t, - "type: number\nminimum: 10\nexclusiveMinimum: true\nmaximum: 20\n"), true, "/p", 0) - - require.NotNil(t, got) - assert.Empty(t, diags) - assert.Empty(t, kept) - want := ir.Constraints{Min: bigOf("10"), Max: bigOf("20"), ExclusiveMin: true} - if diff := cmp.Diff(want, *got); diff != "" { - t.Errorf("constraints (-want +got):\n%s", diff) - } -} - -// TestReconcileBound_AMagnitudeNoRationalHoldsStillCompares pins the exactness -// of the comparison at the size where the obvious way to make it gives out. -// math/big will not build 1e2000000 as a rational — the exponent is past its -// own limit for one — so reconciling through a rational had to fall back, and -// the fallback keeps the exclusive bound. Here that is the looser one: "> 5" -// where the source says ">= 1e2000000" is the wrong constraint GitHub #33 is -// about, in a rarer case and with a warning attached. +// TestApplyExclusiveFlag_ThreeZeroModifierMovesTheBound pins the 3.0 arm. There +// exclusiveMinimum is not a bound but a boolean modifying the minimum beside it, +// so "minimum: 10, exclusiveMinimum: true" is "x > 10" — which ir.Constraints +// spells as ExclusiveMin, not as Min plus something. The literal therefore moves +// into the exclusive field and the inclusive one is left empty: the 2020-12 +// spelling of the same restriction, so a 3.0 document and its 3.1 translation +// lower to the same constraints rather than to two documents that diff. // -// These magnitudes are legal in a spec and ir.NewBigVal keeps them, so the -// comparison has to reach them; the exponent alone separates the two bounds, -// and nothing here needs the million digits it stands for. -func TestReconcileBound_AMagnitudeNoRationalHoldsStillCompares(t *testing.T) { +// The maximum stays inclusive in the second case for the reason the first case +// cannot cover: flagging the wrong side is symmetric when both sides declare the +// modifier, so only a schema exclusive on one side can tell a crossed-over read +// from a correct one. +func TestApplyExclusiveFlag_ThreeZeroModifierMovesTheBound(t *testing.T) { t.Parallel() tests := []struct { - name string - body string - want ir.Constraints - wantKept string - wantRaw string - wantSays []string + name string + body string + want ir.Constraints }{ { - name: "a minimum too large for a rational is still the tighter", - body: "minimum: 1.0e2000000\nexclusiveMinimum: 5\n", - want: ir.Constraints{Min: bigOf("1.0e2000000")}, - wantKept: "openapi:exclusiveMinimum", wantRaw: "5", - wantSays: []string{"minimum 1.0e2000000", "exclusiveMinimum 5", "kept minimum as the tighter"}, + name: "both sides modified", + body: "minimum: 10\nexclusiveMinimum: true\nmaximum: 20\nexclusiveMaximum: true\n", + want: ir.Constraints{ExclusiveMin: bigOf("10"), ExclusiveMax: bigOf("20")}, + }, + { + name: "only the side that wrote the modifier moves", + body: "minimum: 10\nexclusiveMinimum: true\nmaximum: 20\n", + want: ir.Constraints{ExclusiveMin: bigOf("10"), Max: bigOf("20")}, }, { - name: "a maximum too small for one is the tighter on its side", - body: "maximum: 1e-1000001\nexclusiveMaximum: 5\n", - want: ir.Constraints{Max: bigOf("1e-1000001")}, - wantKept: "openapi:exclusiveMaximum", wantRaw: "5", - wantSays: []string{"maximum 1e-1000001", "exclusiveMaximum 5", "kept maximum as the tighter"}, + name: "a false modifier leaves the bound where it is", + body: "minimum: 10\nexclusiveMinimum: false\nmaximum: 20\nexclusiveMaximum: false\n", + want: ir.Constraints{Min: bigOf("10"), Max: bigOf("20")}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, kept, diags := Constraints(schemaFromYAMLUnvalidated(t, "type: number\n"+tc.body), false, "/p", 0) + got, kept, diags := Constraints(schemaFromYAML(t, "type: number\n"+tc.body), true, "/p", 0) require.NotNil(t, got) if diff := cmp.Diff(tc.want, *got); diff != "" { t.Errorf("constraints (-want +got):\n%s", diff) } - entry, ok := kept[tc.wantKept] - require.True(t, ok, "the keyword the bound slot has no room for; got %v", kept) - assert.Equal(t, tc.wantRaw, string(entry.Value)) - require.Len(t, diags, 1) - assert.Equal(t, ir.SeverityInfo, diags[0].Severity, "the pair did compare") - for _, says := range tc.wantSays { - assert.Contains(t, diags[0].Message, says) - } + assert.Empty(t, kept) + assert.Empty(t, diags) }) } } -// TestReconcileBound_ABoundNoDecimalReadingOrdersKeepsTheExclusiveOne pins the -// guard standing at this reader's boundary with ir.NewBigVal. +// TestApplyExclusiveFlag_AModifierWithNoBoundIsKeptAndReported pins the 3.0 +// modifier that modifies nothing. Draft-4 requires minimum wherever +// exclusiveMinimum appears, so the schema is invalid and there is no bound for +// the IR to make exclusive — but the loader hands these two keywords to Morphic +// unchecked, so dropping it here would lose a declared keyword with nothing +// said. It is kept verbatim at its own pointer and reported instead. // -// It is driven through reconcileBound rather than through a schema because no -// schema reaches it: every bound arrives via ir.NewBigVal, whose grammar -// TestBigValGrammarStaysWithinTheDecimalReading holds inside the one -// parseDecimalBound orders. The guard is what keeps a later widening of that -// grammar from widening a bound instead — a bound that cannot be ordered is one -// that could be silently replaced by the looser of the pair — so it keeps the -// exclusive bound and says the discarded one may have been the tighter, rather -// than claiming a comparison it never made. -func TestReconcileBound_ABoundNoDecimalReadingOrdersKeepsTheExclusiveOne(t *testing.T) { +// Both sides are declared at once because one boundResidue serves both calls to +// applyExclusive: were it to write the map rather than add to it, the surviving +// entry would be whichever side ran second, silently, since a schema writing +// both modifiers is exactly as valid (which is to say not) as one writing either. +func TestApplyExclusiveFlag_AModifierWithNoBoundIsKeptAndReported(t *testing.T) { t.Parallel() - c := &ir.Constraints{Min: bigOf("1p4")} - residue := boundResidue{pointer: "/p", srcIndex: 1} + got, kept, diags := Constraints(schemaFromYAML(t, + "type: number\nexclusiveMinimum: true\nexclusiveMaximum: true\n"), true, "/p", 3) - diags := reconcileBound(c, minBound, &residue, ir.BigVal("5")) + assert.Nil(t, got, "a modifier that bounds nothing leaves no constraint behind") + require.Len(t, kept, 2, "each side keeps its own modifier; got %v", kept) + for _, want := range []struct{ key, pointer string }{ + {"openapi:exclusiveMinimum", "/p/exclusiveMinimum"}, + {"openapi:exclusiveMaximum", "/p/exclusiveMaximum"}, + } { + entry, ok := kept[want.key] + require.True(t, ok, "%s survives the other side", want.key) + assert.Equal(t, "true", string(entry.Value), "the boolean is the whole of what it said") + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.Equal(t, ir.Provenance{Source: 3, Pointer: want.pointer}, entry.Provenance) + } - want := ir.Constraints{Min: bigOf("5"), ExclusiveMin: true} - if diff := cmp.Diff(want, *c); diff != "" { - t.Errorf("constraints (-want +got):\n%s", diff) + require.Len(t, diags, 2, "and each side reports its own") + for _, d := range diags { + assert.Equal(t, ir.SeverityWarning, d.Severity) + assert.Equal(t, diag.DegradedConstruct, d.Code) + assert.Contains(t, d.Message, "bounds nothing") } - require.Len(t, diags, 1) - assert.Equal(t, ir.SeverityWarning, diags[0].Severity, "the kept bound may be the looser one") - assert.Equal(t, diag.DegradedConstruct, diags[0].Code) - assert.Contains(t, diags[0].Message, "could not be compared") - assert.Contains(t, diags[0].Message, "minimum 1p4") - assert.Contains(t, diags[0].Message, "exclusiveMinimum 5") - - // The bound this reading cannot order is still the one the source wrote, so - // the fallback keeps it too — a bound replaced by one that may be looser is - // exactly the case a consumer needs to see the original of. The payload is - // the literal itself: not JSON here only because the fixture is a BigVal that - // breaks BigVal's own promise, which is the state irverify's raw-payload - // check exists to name. - entry, ok := residue.kept["openapi:minimum"] - require.True(t, ok, "the unordered bound is kept verbatim; got %v", residue.kept) - assert.Equal(t, "1p4", string(entry.Value)) - assert.Equal(t, ir.Provenance{Source: 1, Pointer: "/p/minimum"}, entry.Provenance) + assert.Contains(t, diags[0].Message, "exclusiveMinimum is true with no minimum beside it") + assert.Contains(t, diags[1].Message, "exclusiveMaximum is true with no maximum beside it") } diff --git a/compilers/openapi/internal/annotation/decimal_internal_test.go b/compilers/openapi/internal/annotation/decimal_internal_test.go index f2410c35..d4fbcf3b 100644 --- a/compilers/openapi/internal/annotation/decimal_internal_test.go +++ b/compilers/openapi/internal/annotation/decimal_internal_test.go @@ -132,14 +132,14 @@ func TestParseDecimalBound_DeclinesWhatIsNotADecimalLiteral(t *testing.T) { } // TestBigValGrammarStaysWithinTheDecimalReading pins the coupling that decides -// whether reconcileBound's incomparable guard is reachable: every literal +// whether BigValEqual's incomparable guard is reachable: every literal // ir.NewBigVal accepts must be one parseDecimalBound can order. // -// While it holds, no schema reaches that guard — which is why the test for it -// calls reconcileBound directly. The two grammars live in different packages -// and have already moved apart once, so nothing but this holds them together: -// when ir widens NewBigVal, a bound it now admits and this reader cannot order -// is a bound that would be silently replaced by the looser of its pair, and +// While it holds, no bound compiled from a schema reaches that guard. The two +// grammars live in different packages and have already moved apart once, so +// nothing but this holds them together: when ir widens NewBigVal, a bound it +// now admits and this reader cannot order is one whose disagreement with +// another spelling of the same magnitude would be reported as a conflict, and // that has to fail here rather than in a compiled document. // // The spellings NewBigVal refuses today are the load-bearing half of the diff --git a/compilers/openapi/internal/merge/conflict_internal_test.go b/compilers/openapi/internal/merge/conflict_internal_test.go index 087ee365..9c8cfa1a 100644 --- a/compilers/openapi/internal/merge/conflict_internal_test.go +++ b/compilers/openapi/internal/merge/conflict_internal_test.go @@ -59,8 +59,8 @@ func TestDifferentTypeKind_UnresolvableTargetIsNotAConflict(t *testing.T) { "an unresolvable target is not treated as a differing kind") } -// Both BigVal keywords rest on the same magnitude comparison, so both are -// driven here over the literals that comparison has to get right: one value +// Every BigVal keyword rests on the same magnitude comparison, so it is driven +// here over the literals that comparison has to get right: one value // under two spellings, and two values that genuinely differ — mostly at a // magnitude math/big will not build as a rational at all, with one in-range // row so a comparison that only handled the extremes would still be caught. @@ -91,10 +91,10 @@ func TestBigValConflictDetails_CompareMagnitudesAtAnyScale(t *testing.T) { b, err := ir.NewBigVal(tc.b) require.NoError(t, err, "%q is a literal a schema may write", tc.b) - _, boundOK := boundConflictDetail("minimum", &a, &b, false, false) + _, boundOK := bigValConflictDetail("minimum", &a, &b) assert.Equal(t, tc.wantConflict, boundOK, "minimum %s against %s", a, b) - _, multipleOK := multipleOfConflictDetail(&a, &b) + _, multipleOK := bigValConflictDetail("multipleOf", &a, &b) assert.Equal(t, tc.wantConflict, multipleOK, "multipleOf %s against %s", a, b) }) } @@ -208,9 +208,10 @@ func TestMergeConstraints_AdoptsEveryUnsetKeyword(t *testing.T) { // the spec-driven table tests in the compiler package. five, ten := int64(5), int64(10) minVal, maxVal, multipleOf := ir.BigVal("1"), ir.BigVal("9"), ir.BigVal("2") + exclMin, exclMax := ir.BigVal("0"), ir.BigVal("10") src := &ir.Constraints{ - Min: &minVal, ExclusiveMin: true, - Max: &maxVal, ExclusiveMax: true, + Min: &minVal, ExclusiveMin: &exclMin, + Max: &maxVal, ExclusiveMax: &exclMax, MultipleOf: &multipleOf, Precision: &ten, Scale: &five, @@ -227,9 +228,9 @@ func TestMergeConstraints_AdoptsEveryUnsetKeyword(t *testing.T) { merged := mergeConstraints(&ir.Constraints{}, src) require.NotNil(t, merged) assert.Same(t, src.Min, merged.Min) - assert.Equal(t, src.ExclusiveMin, merged.ExclusiveMin) + assert.Same(t, src.ExclusiveMin, merged.ExclusiveMin) assert.Same(t, src.Max, merged.Max) - assert.Equal(t, src.ExclusiveMax, merged.ExclusiveMax) + assert.Same(t, src.ExclusiveMax, merged.ExclusiveMax) assert.Same(t, src.MultipleOf, merged.MultipleOf) assert.Same(t, src.Precision, merged.Precision) assert.Same(t, src.Scale, merged.Scale) diff --git a/compilers/openapi/internal/merge/merge.go b/compilers/openapi/internal/merge/merge.go index 373a4866..985173d3 100644 --- a/compilers/openapi/internal/merge/merge.go +++ b/compilers/openapi/internal/merge/merge.go @@ -118,11 +118,13 @@ func (g *Merger) reconcileProperty(dst *ir.Property, src ir.Property, pointer st // from src any keyword dst leaves unset (nil/""/false) — a keyword only one // branch constrains still applies to the merged field, so it is never dropped. // -// Min and Max are adopted together with their exclusivity flag: taking src.Min -// without src.ExclusiveMin would silently flip an exclusive "> 5" into an -// inclusive ">= 5". UniqueItems has no absent state to detect via cmp.Or, but -// under intersection a true from either branch is always correct, so adopting -// it via cmp.Or never wrongly downgrades dst from true to false. +// The four numeric bounds are four keywords, not two bounds with an +// exclusivity flag apiece, so each is adopted on its own: a branch declaring +// only exclusiveMinimum contributes it to a merged field whose minimum came +// from elsewhere, and neither displaces the other. UniqueItems has no absent +// state to detect via cmp.Or, but under intersection a true from either branch +// is always correct, so adopting it via cmp.Or never wrongly downgrades dst +// from true to false. func mergeConstraints(dst, src *ir.Constraints) *ir.Constraints { if dst == nil { return src @@ -130,12 +132,10 @@ func mergeConstraints(dst, src *ir.Constraints) *ir.Constraints { if src == nil { return dst } - if dst.Min == nil { - dst.Min, dst.ExclusiveMin = src.Min, src.ExclusiveMin - } - if dst.Max == nil { - dst.Max, dst.ExclusiveMax = src.Max, src.ExclusiveMax - } + dst.Min = cmp.Or(dst.Min, src.Min) + dst.Max = cmp.Or(dst.Max, src.Max) + dst.ExclusiveMin = cmp.Or(dst.ExclusiveMin, src.ExclusiveMin) + dst.ExclusiveMax = cmp.Or(dst.ExclusiveMax, src.ExclusiveMax) dst.MultipleOf = cmp.Or(dst.MultipleOf, src.MultipleOf) dst.Precision = cmp.Or(dst.Precision, src.Precision) dst.Scale = cmp.Or(dst.Scale, src.Scale) @@ -443,13 +443,15 @@ func constraintsConflict(a, b *ir.Constraints) (string, bool) { return "", false } checks := []func() (string, bool){ + func() (string, bool) { return bigValConflictDetail("minimum", a.Min, b.Min) }, func() (string, bool) { - return boundConflictDetail("minimum", a.Min, b.Min, a.ExclusiveMin, b.ExclusiveMin) + return bigValConflictDetail("exclusiveMinimum", a.ExclusiveMin, b.ExclusiveMin) }, + func() (string, bool) { return bigValConflictDetail("maximum", a.Max, b.Max) }, func() (string, bool) { - return boundConflictDetail("maximum", a.Max, b.Max, a.ExclusiveMax, b.ExclusiveMax) + return bigValConflictDetail("exclusiveMaximum", a.ExclusiveMax, b.ExclusiveMax) }, - func() (string, bool) { return multipleOfConflictDetail(a.MultipleOf, b.MultipleOf) }, + func() (string, bool) { return bigValConflictDetail("multipleOf", a.MultipleOf, b.MultipleOf) }, func() (string, bool) { return intConflictDetail("precision", a.Precision, b.Precision) }, func() (string, bool) { return intConflictDetail("scale", a.Scale, b.Scale) }, func() (string, bool) { return intConflictDetail("minLength", a.MinLength, b.MinLength) }, @@ -469,41 +471,23 @@ func constraintsConflict(a, b *ir.Constraints) (string, bool) { return "", false } -// boundConflictDetail reports whether two numeric bounds, each with its -// exclusivity flag, are both present and disagree in magnitude or in -// inclusive/exclusive sense, formatting the disagreement when they do. Such a -// disagreement is usually still individually satisfiable (minimum: 10 and -// exclusiveMinimum: 10 together just mean "> 10"), but it's diagnosed anyway: -// the merge keeps dst's bound (first declaration wins) over the true -// intersection, and the discarded bound is always the stricter one — staying -// silent would silently loosen the validation the spec intended. -func boundConflictDetail(keyword string, a, b *ir.BigVal, exclA, exclB bool) (string, bool) { - if a == nil || b == nil || (exclA == exclB && annotation.BigValEqual(*a, *b)) { - return "", false - } - return fmt.Sprintf("conflicting %s (%s and %s)", keyword, boundText(*a, exclA), boundText(*b, exclB)), true -} - -// boundText renders a numeric bound for a conflict detail, marking an -// exclusive bound so "conflicting minimum (10 and exclusive 10)" reads as the -// differing sense it is, not a duplicate magnitude. -func boundText(v ir.BigVal, exclusive bool) string { - if exclusive { - return "exclusive " + v.String() - } - return v.String() -} - -// multipleOfConflictDetail reports whether both branches pin multipleOf and pin -// it to different magnitudes, formatting the disagreement when they do. It is -// the one BigVal constraint with no exclusivity sense, so unlike a bound it -// compares by magnitude alone — the keyword is named here rather than passed -// because there is nothing else with that shape to compare. -func multipleOfConflictDetail(a, b *ir.BigVal) (string, bool) { +// bigValConflictDetail reports whether both branches pin the same +// arbitrary-precision keyword and pin it to different magnitudes, formatting +// the disagreement when they do. Every numeric keyword of ir.Constraints has +// this one shape — each of the four bounds states its own restriction, with no +// exclusivity sense to carry beside it — so one comparison serves them all, by +// magnitude, which is what keeps 10 and 10.0 from reading as a disagreement. +// +// A disagreement between two branches is usually still individually satisfiable +// (minimum: 10 in one and minimum: 20 in the other together just mean ">= 20"), +// but it's diagnosed anyway: the merge keeps dst's value (first declaration +// wins) over the true intersection, and the discarded one may be the stricter — +// staying silent would silently loosen the validation the spec intended. +func bigValConflictDetail(keyword string, a, b *ir.BigVal) (string, bool) { if a == nil || b == nil || annotation.BigValEqual(*a, *b) { return "", false } - return fmt.Sprintf("conflicting multipleOf (%s and %s)", a.String(), b.String()), true + return fmt.Sprintf("conflicting %s (%s and %s)", keyword, a.String(), b.String()), true } // intConflictDetail reports whether two optional integer bounds are both diff --git a/compilers/openapi/internal/merge/reconcile_internal_test.go b/compilers/openapi/internal/merge/reconcile_internal_test.go index cce93752..6eeb7733 100644 --- a/compilers/openapi/internal/merge/reconcile_internal_test.go +++ b/compilers/openapi/internal/merge/reconcile_internal_test.go @@ -295,60 +295,59 @@ func TestDiagnoseRedeclarationConflict_ConstraintDisagreementIsReported(t *testi "and so is the discarded one") } -// TestBoundConflictDetail_ComparesMagnitudeAndSense pins both halves of a bound -// comparison. Equal magnitudes spelled differently must not read as a conflict, -// while the same magnitude under a differing exclusivity flag must — "> 10" and -// ">= 10" are different bounds, and the merge can only keep one. -func TestBoundConflictDetail_ComparesMagnitudeAndSense(t *testing.T) { +// TestBigValConflictDetail_ComparesByMagnitude pins the comparison every +// arbitrary-precision keyword goes through. Equal magnitudes spelled +// differently must not read as a conflict — 10 and 10.0 are one value, and +// reporting them would invent a disagreement the source never wrote — while +// differing magnitudes must, since the merge keeps one and drops the other. +// +// The keyword is a parameter, so the name in the message is the one the caller +// passed: the four bounds and multipleOf share this helper, and a hard-coded +// name would report every one of them as the same keyword. +func TestBigValConflictDetail_ComparesByMagnitude(t *testing.T) { t.Parallel() tests := []struct { - name string - a, b *ir.BigVal - exclA, exclB bool - want string + name string + keyword string + a, b *ir.BigVal + want string }{ - {name: "only one side bounds", a: bigVal("10"), exclA: true}, - {name: "neither side bounds"}, - {name: "the same magnitude, spelled differently", a: bigVal("10"), b: bigVal("10.0")}, + {name: "only one side bounds", keyword: "minimum", a: bigVal("10")}, + {name: "neither side bounds", keyword: "minimum"}, + { + name: "the same magnitude, spelled differently", + keyword: "minimum", a: bigVal("10"), b: bigVal("10.0"), + }, { - name: "differing magnitudes", a: bigVal("10"), b: bigVal("20"), + name: "differing magnitudes", keyword: "minimum", a: bigVal("10"), b: bigVal("20"), want: "conflicting minimum (10 and 20)", }, { - name: "the same magnitude, differing sense", a: bigVal("10"), b: bigVal("10"), exclB: true, - want: "conflicting minimum (10 and exclusive 10)", + name: "the exclusive bound reports under its own keyword", + keyword: "exclusiveMinimum", a: bigVal("10"), b: bigVal("20"), + want: "conflicting exclusiveMinimum (10 and 20)", + }, + { + name: "multipleOf shares the comparison", + keyword: "multipleOf", a: bigVal("3"), b: bigVal("5"), + want: "conflicting multipleOf (3 and 5)", }, { - name: "unparseable operands compare exactly", a: bigVal("nan"), b: bigVal("other"), + name: "unparseable operands compare exactly", + keyword: "minimum", a: bigVal("nan"), b: bigVal("other"), want: "conflicting minimum (nan and other)", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - detail, ok := boundConflictDetail("minimum", tc.a, tc.b, tc.exclA, tc.exclB) + detail, ok := bigValConflictDetail(tc.keyword, tc.a, tc.b) assert.Equal(t, tc.want != "", ok) assert.Equal(t, tc.want, detail) }) } } -// TestMultipleOfConflictDetail_ComparesByMagnitude pins the plain numeric -// comparison multipleOf goes through: 10 and 10.0 are one value, so reporting -// them as a conflict would invent a disagreement the source never wrote. -func TestMultipleOfConflictDetail_ComparesByMagnitude(t *testing.T) { - t.Parallel() - _, ok := multipleOfConflictDetail(bigVal("1e1"), bigVal("10")) - assert.False(t, ok, "equal magnitudes spelled differently do not conflict") - - _, ok = multipleOfConflictDetail(nil, bigVal("10")) - assert.False(t, ok, "a keyword only one branch sets is adopted, not a conflict") - - detail, ok := multipleOfConflictDetail(bigVal("3"), bigVal("5")) - assert.True(t, ok) - assert.Equal(t, "conflicting multipleOf (3 and 5)", detail) -} - // TestResolvePrimKind_EnumResolvesThroughItsValueType pins the enum case of the // resolution walk. An enum member can itself be a scalar of the kind being // redeclared, so it must answer with its value type rather than staying diff --git a/compilers/openapi/internal/operation/params_test.go b/compilers/openapi/internal/operation/params_test.go index f4e1c38f..2bf55534 100644 --- a/compilers/openapi/internal/operation/params_test.go +++ b/compilers/openapi/internal/operation/params_test.go @@ -1,7 +1,6 @@ package operation_test import ( - "strings" "testing" "github.com/speakeasy-api/openapi/validation" @@ -871,48 +870,71 @@ func TestParams_RefSiteKeywordsAreKeptOnTheParameter(t *testing.T) { assert.Equal(t, int64(3), *r.Constraints.MinLength) } -// TestParams_CoDeclaredBoundKeptOnTheParameter covers the parameter carrier for -// a 2020-12 side that declares both of its bound keywords (GitHub #286). -// ir.Constraints holds one bound per side, so one keyword reaches no field of -// the constraints the parameter carries and is kept verbatim beside them — -// otherwise {minimum: 10, exclusiveMinimum: 0} lowers to what {minimum: 10} -// does, at the one carrier ir.Parameter owns rather than a node. +// TestParams_CoDeclaredBoundsReachTheParameter covers the parameter carrier for +// a 2020-12 side that declares both of its bound keywords. Each is a keyword the +// source wrote and ir.Constraints has a field for each, so both reach the +// constraints the parameter holds and nothing is kept beside them — at the one +// carrier ir.Parameter owns rather than a node. // -// Both directions are here for the reason the property cases are: a row where -// the exclusive keyword is the one kept passes on a reader that always kept that -// one. -func TestParams_CoDeclaredBoundKeptOnTheParameter(t *testing.T) { +// The two rows swap which keyword is the tighter across the same magnitudes. A +// reader holding one bound per side answers both rows alike, which is what hid a +// change to the looser keyword from a consumer diffing two revisions (GitHub +// #425). +func TestParams_CoDeclaredBoundsReachTheParameter(t *testing.T) { t.Parallel() _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpec( " /x:\n get:\n operationId: g\n parameters:\n"+ " - {name: low, in: query, schema: {type: integer, minimum: 10, exclusiveMinimum: 0}}\n"+ - " - {name: high, in: query, schema: {type: integer, maximum: 100, exclusiveMaximum: 5}}\n"+ + " - {name: high, in: query, schema: {type: integer, minimum: 0, exclusiveMinimum: 10}}\n"+ " - {name: plain, in: query, schema: {type: integer, minimum: 10}}\n"+ " responses: {\"204\": {description: ok}}\n")) openapitest.RequireNoErrorDiags(t, diags) params := paramsOf(t, svc) cases := []struct { - param, index, wantKept, wantRaw string + param, wantMin, wantExclMin string }{ - {param: "low", index: "0", wantKept: "openapi:exclusiveMinimum", wantRaw: "0"}, - {param: "high", index: "1", wantKept: "openapi:maximum", wantRaw: "100"}, + {param: "low", wantMin: "10", wantExclMin: "0"}, + {param: "high", wantMin: "0", wantExclMin: "10"}, } for _, tc := range cases { t.Run(tc.param, func(t *testing.T) { t.Parallel() - at := "/paths/~1x/get/parameters/" + tc.index + "/schema/" + - strings.TrimPrefix(tc.wantKept, "openapi:") - require.NotNil(t, params[tc.param].Constraints, "the tighter bound still reaches a field") - entry, ok := params[tc.param].Unmodeled[tc.wantKept] - require.True(t, ok, "%s is kept beside the constraints it did not reach; got %v", - tc.wantKept, params[tc.param].Unmodeled) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.JSONEq(t, tc.wantRaw, string(entry.Value)) - assert.Equal(t, at, entry.Provenance.Pointer, "located at the keyword itself") + c := params[tc.param].Constraints + require.NotNil(t, c, "both bounds reach the parameter's constraints") + require.NotNil(t, c.Min) + require.NotNil(t, c.ExclusiveMin) + assert.Equal(t, tc.wantMin, c.Min.String(), "minimum as written") + assert.Equal(t, tc.wantExclMin, c.ExclusiveMin.String(), "exclusiveMinimum as written, beside it") + assert.Empty(t, params[tc.param].Unmodeled, "with a field apiece there is nothing left to keep") }) } assert.Empty(t, params["plain"].Unmodeled, - "a side writing one keyword has it in a field, so nothing is restated beside it") + "and a side writing one keyword has it in a field, so nothing is restated beside it") +} + +// TestParams_ExclusiveModifierWithNoBoundIsKeptOnTheParameter covers the one +// bound keyword that still reaches no field, at the parameter carrier. A 3.0 +// exclusiveMinimum modifies the minimum beside it, so one written without a +// minimum modifies nothing and has no bound to become; dropping it would lose a +// declared keyword silently, so the parameter keeps it verbatim. +func TestParams_ExclusiveModifierWithNoBoundIsKeptOnTheParameter(t *testing.T) { + t.Parallel() + _, svc, diags := lowerServiceSpec(t, openapitest.PathsSpecVer("3.0.3", + " /x:\n get:\n operationId: g\n parameters:\n"+ + " - {name: bare, in: query, schema: {type: integer, exclusiveMinimum: true}}\n"+ + " responses: {\"204\": {description: ok}}\n")) + params := paramsOf(t, svc) + + entry, ok := params["bare"].Unmodeled["openapi:exclusiveMinimum"] + require.True(t, ok, "kept beside the constraints it did not reach; got %v", params["bare"].Unmodeled) + assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) + assert.JSONEq(t, "true", string(entry.Value)) + assert.Equal(t, "/paths/~1x/get/parameters/0/schema/exclusiveMinimum", entry.Provenance.Pointer, + "located at the keyword itself") + assert.Contains(t, + openapitest.DiagMessageAt(t, diags, diag.DegradedConstruct, ir.SeverityWarning, + "/paths/~1x/get/parameters/0/schema"), + "bounds nothing", "and reading it is what reports on it") } diff --git a/compilers/openapi/internal/schema/compose_test.go b/compilers/openapi/internal/schema/compose_test.go index 1f626e0b..9126ad19 100644 --- a/compilers/openapi/internal/schema/compose_test.go +++ b/compilers/openapi/internal/schema/compose_test.go @@ -2572,7 +2572,7 @@ func TestOneOf_CoDeclaredNotDistributedReasons(t *testing.T) { func TestUnionCombinators_CoDeclaredKeepsTheBoundsWrittenBesideIt(t *testing.T) { t.Parallel() three := int64(3) - ten, five := ir.BigVal("10"), ir.BigVal("5") + ten, five, zero := ir.BigVal("10"), ir.BigVal("5"), ir.BigVal("0") cases := []struct { name, schemas, unionKey string reason ir.UnmodeledReason @@ -2605,13 +2605,14 @@ func TestUnionCombinators_CoDeclaredKeepsTheBoundsWrittenBesideIt(t *testing.T) wantKept: []string{"openapi:anyOf"}, }, { + // Both bound keywords reach a field, so the union is the only + // entry on the node: a co-declared pair adds nothing beside it. name: "co-declared bounds beside a union", schemas: " A: {type: number, minimum: 10, exclusiveMinimum: 0, oneOf: [{minLength: 1}, {minLength: 2}]}\n", unionKey: "openapi:oneOf", reason: ir.ReasonValidationOnly, - want: ir.Constraints{Min: &ten}, - wantKept: []string{"openapi:exclusiveMinimum", "openapi:oneOf"}, - wantDiag: "kept minimum as the tighter of the two", + want: ir.Constraints{Min: &ten, ExclusiveMin: &zero}, + wantKept: []string{"openapi:oneOf"}, }, } for _, tc := range cases { diff --git a/compilers/openapi/internal/schema/schema_test.go b/compilers/openapi/internal/schema/schema_test.go index 2f4f6b14..a2d28ea0 100644 --- a/compilers/openapi/internal/schema/schema_test.go +++ b/compilers/openapi/internal/schema/schema_test.go @@ -1502,10 +1502,18 @@ func TestAllOf_ConstraintAndFormatConflictsDiagnosed(t *testing.T) { name, a, b, wantDetail string }{ { - name: "exclusive sense", + name: "minimum", a: "{type: number, minimum: 10}", - b: "{type: number, exclusiveMinimum: 10}", - wantDetail: "conflicting minimum (10 and exclusive 10)", + b: "{type: number, minimum: 20}", + wantDetail: "conflicting minimum (10 and 20)", + }, + { + // The exclusive bound is a keyword of its own, so it conflicts + // under its own name rather than as a differing sense of minimum. + name: "exclusiveMinimum", + a: "{type: number, exclusiveMinimum: 10}", + b: "{type: number, exclusiveMinimum: 20}", + wantDetail: "conflicting exclusiveMinimum (10 and 20)", }, { name: "pattern", @@ -1602,16 +1610,19 @@ func TestAllOf_CompatibleConstraintRedeclarationsStaySilent(t *testing.T) { }, }, { - name: "min and exclusiveMin adopted together", - a: "{type: number, multipleOf: 2}", + // minimum and exclusiveMinimum are two keywords, so a branch + // declaring one and a branch declaring the other intersect to a + // field carrying both — not to whichever the merge picked. + name: "minimum and exclusiveMinimum adopted side by side", + a: "{type: number, minimum: 1}", b: "{type: number, exclusiveMinimum: 5}", assertMerged: func(t *testing.T, c *ir.Constraints) { t.Helper() require.NotNil(t, c) - require.NotNil(t, c.Min, "the second branch's exclusiveMinimum is adopted as Min") - assert.Equal(t, "5", c.Min.String()) - assert.True(t, c.ExclusiveMin, - "ExclusiveMin travels with the adopted Min, not left at its false zero value") + require.NotNil(t, c.Min, "the first branch's minimum stays") + assert.Equal(t, "1", c.Min.String()) + require.NotNil(t, c.ExclusiveMin, "the second branch's exclusiveMinimum is adopted beside it") + assert.Equal(t, "5", c.ExclusiveMin.String()) }, }, {name: "equivalent multipleOf", a: "{type: number, multipleOf: 2}", b: "{type: number, multipleOf: 2.0}"}, @@ -4090,72 +4101,128 @@ func TestUnhomedKeywords_ElectedLoweringKeepsWhatItCannotRead(t *testing.T) { } } -// TestCoDeclaredBound_KeptOnTheCarrierThatReadIt pins the two carriers this -// package owns for a 2020-12 side that declares both of its bound keywords -// (GitHub #286). ir.Constraints holds one bound per side, so one keyword reaches -// no field of it, and without an entry beside those constraints -// {minimum: 10, exclusiveMinimum: 0} lowers to exactly what {minimum: 10} does. +// TestCoDeclaredBound_BothKeywordsReachTheCarriersConstraints pins the two +// carriers this package owns for a 2020-12 side that declares both of its bound +// keywords. The two keywords are independent and both apply, and ir.Constraints +// has a field for each, so both reach the constraints the carrier holds and +// neither is kept beside them. // -// Both directions run at both carriers. A case where the exclusive keyword is -// the one kept verbatim passes just as well on a reader that always kept that -// one, so on its own it would say nothing about which keyword the carrier holds. -func TestCoDeclaredBound_KeptOnTheCarrierThatReadIt(t *testing.T) { +// The rows are pairs that swap which of the two is the tighter while leaving the +// same magnitudes on the side. One bound slot answered both rows of a pair +// identically, which is what made a revision that moved only the looser keyword +// read as no change at all (GitHub #425). +func TestCoDeclaredBound_BothKeywordsReachTheCarriersConstraints(t *testing.T) { t.Parallel() doc, diags := parseFull(t, openapitest.ComponentSpec( " Alias: {type: integer, minimum: 10, exclusiveMinimum: 0}\n"+ - " Tight: {type: integer, maximum: 100, exclusiveMaximum: 5}\n"+ + " Tight: {type: integer, minimum: 0, exclusiveMinimum: 10}\n"+ " Holder:\n type: object\n properties:\n"+ - " low: {type: integer, minimum: 10, exclusiveMinimum: 0}\n"+ - " high: {type: integer, maximum: 100, exclusiveMaximum: 5}\n")) + " low: {type: integer, maximum: 100, exclusiveMaximum: 5}\n"+ + " high: {type: integer, maximum: 5, exclusiveMaximum: 100}\n")) openapitest.RequireNoErrorDiags(t, diags) tests := []struct { name string unmod ir.Unmodeled bound *ir.Constraints - wantKept string - wantRaw string - at string + read func(*ir.Constraints) (incl, excl *ir.BigVal) + wantIncl *ir.BigVal + wantExcl *ir.BigVal }{ { - name: "alias node keeps the exclusive bound the minimum implies", - unmod: typeByName(doc, "Alias").Common().Unmodeled, - bound: typeByName(doc, "Alias").(*ir.Scalar).Constraints, - wantKept: "openapi:exclusiveMinimum", wantRaw: "0", - at: "/components/schemas/Alias/exclusiveMinimum", + name: "alias node where the minimum is the tighter", + unmod: typeByName(doc, "Alias").Common().Unmodeled, + bound: typeByName(doc, "Alias").(*ir.Scalar).Constraints, + read: minSide, wantIncl: bigValOf("10"), wantExcl: bigValOf("0"), }, { - name: "alias node keeps the inclusive bound the exclusive one implies", - unmod: typeByName(doc, "Tight").Common().Unmodeled, - bound: typeByName(doc, "Tight").(*ir.Scalar).Constraints, - wantKept: "openapi:maximum", wantRaw: "100", - at: "/components/schemas/Tight/maximum", + name: "alias node where the exclusive minimum is the tighter", + unmod: typeByName(doc, "Tight").Common().Unmodeled, + bound: typeByName(doc, "Tight").(*ir.Scalar).Constraints, + read: minSide, wantIncl: bigValOf("0"), wantExcl: bigValOf("10"), }, { - name: "property keeps the exclusive bound the minimum implies", - unmod: propertyOf(t, doc, "Holder", "low").Unmodeled, - bound: propertyOf(t, doc, "Holder", "low").Constraints, - wantKept: "openapi:exclusiveMinimum", wantRaw: "0", - at: "/components/schemas/Holder/properties/low/exclusiveMinimum", + name: "property where the exclusive maximum is the tighter", + unmod: propertyOf(t, doc, "Holder", "low").Unmodeled, + bound: propertyOf(t, doc, "Holder", "low").Constraints, + read: maxSide, wantIncl: bigValOf("100"), wantExcl: bigValOf("5"), + }, + { + name: "property where the maximum is the tighter", + unmod: propertyOf(t, doc, "Holder", "high").Unmodeled, + bound: propertyOf(t, doc, "Holder", "high").Constraints, + read: maxSide, wantIncl: bigValOf("5"), wantExcl: bigValOf("100"), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.NotNil(t, tc.bound, "both bounds reach ir.Constraints") + incl, excl := tc.read(tc.bound) + assert.Equal(t, tc.wantIncl, incl, "the inclusive keyword as written") + assert.Equal(t, tc.wantExcl, excl, "the exclusive keyword as written, beside it") + assert.Empty(t, tc.unmod, "with a field apiece there is nothing left to keep") + }) + } +} + +// minSide and maxSide read one side's pair of bounds off a Constraints, so one +// table can drive both sides through the same assertion. +func minSide(c *ir.Constraints) (incl, excl *ir.BigVal) { return c.Min, c.ExclusiveMin } +func maxSide(c *ir.Constraints) (incl, excl *ir.BigVal) { return c.Max, c.ExclusiveMax } + +// bigValOf is the *ir.BigVal a bound assertion compares against. +func bigValOf(v string) *ir.BigVal { + b := ir.BigVal(v) + return &b +} + +// TestExclusiveModifier_WithNoBoundIsKeptOnTheCarrierThatReadIt pins the one +// bound keyword that still reaches no field, at the two carriers this package +// owns. A 3.0 exclusiveMinimum is a modifier of the minimum beside it, so one +// written without a minimum modifies nothing — draft-4 forbids that schema, and +// the loader hands the keyword here unchecked. Dropping it would lose a declared +// keyword silently, so it is kept verbatim beside the constraints it did not +// reach. +func TestExclusiveModifier_WithNoBoundIsKeptOnTheCarrierThatReadIt(t *testing.T) { + t.Parallel() + doc, diags := parseFull(t, openapitest.ComponentSpecVer("3.0.3", + " Alias: {type: integer, exclusiveMinimum: true}\n"+ + " Holder:\n type: object\n properties:\n"+ + " low: {type: integer, exclusiveMaximum: true}\n")) + + tests := []struct { + name string + unmod ir.Unmodeled + wantKept string + at string + carrier string + }{ + { + name: "alias node", + unmod: typeByName(doc, "Alias").Common().Unmodeled, + wantKept: "openapi:exclusiveMinimum", + at: "/components/schemas/Alias/exclusiveMinimum", + carrier: "/components/schemas/Alias", }, { - name: "property keeps the inclusive bound the exclusive one implies", - unmod: propertyOf(t, doc, "Holder", "high").Unmodeled, - bound: propertyOf(t, doc, "Holder", "high").Constraints, - wantKept: "openapi:maximum", wantRaw: "100", - at: "/components/schemas/Holder/properties/high/maximum", + name: "property", + unmod: propertyOf(t, doc, "Holder", "low").Unmodeled, + wantKept: "openapi:exclusiveMaximum", + at: "/components/schemas/Holder/properties/low/exclusiveMaximum", + carrier: "/components/schemas/Holder/properties/low", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - require.NotNil(t, tc.bound, "the tighter bound still reaches ir.Constraints") entry, ok := tc.unmod[tc.wantKept] - require.True(t, ok, "%s is kept beside the constraints it did not reach; got %v", - tc.wantKept, tc.unmod) + require.True(t, ok, "%s is kept on the carrier that read it; got %v", tc.wantKept, tc.unmod) assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.JSONEq(t, tc.wantRaw, string(entry.Value)) - assert.Equal(t, tc.at, entry.Provenance.Pointer) + assert.JSONEq(t, "true", string(entry.Value)) + assert.Equal(t, tc.at, entry.Provenance.Pointer, "located at the keyword itself") + assert.Len(t, diagsAtPointer(diags, diag.DegradedConstruct, tc.carrier), 1, + "and reported once, at the schema that read it: %+v", diags) }) } } diff --git a/docs/ir-design.md b/docs/ir-design.md index 892543ec..f189f973 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -972,8 +972,10 @@ with storage and computation split. ```go type Constraints struct { // numeric — arbitrary-precision decimal strings, never float64 (TypeSpec Numeric lesson) - Min, Max *BigVal - ExclusiveMin, ExclusiveMax bool + Min, Max *BigVal // inclusive bounds (minimum / maximum) + ExclusiveMin, ExclusiveMax *BigVal // exclusive bounds (exclusiveMinimum / exclusiveMaximum); + // independent of Min/Max, not flags on them — a schema may + // declare both per side and both apply MultipleOf *BigVal Precision, Scale *int64 // decimal digit bounds (Avro decimal, XSD totalDigits/fractionDigits, // OData Edm.Decimal) diff --git a/ir/constraints.go b/ir/constraints.go index 4066fdfc..fd92b779 100644 --- a/ir/constraints.go +++ b/ir/constraints.go @@ -14,14 +14,27 @@ package ir // a $ref's target onto the referencing carrier with use-site precedence, so a // use site already carries those and resolves nothing to read them. type Constraints struct { - // Min is the inclusive (or exclusive, per ExclusiveMin) lower numeric bound. + // Min is the inclusive lower numeric bound (JSON Schema minimum): an + // admissible value is >= it. nil = this position declared none. Min *BigVal `json:"min,omitempty"` - // Max is the inclusive (or exclusive, per ExclusiveMax) upper numeric bound. + // Max is the inclusive upper numeric bound (JSON Schema maximum): an + // admissible value is <= it. nil = this position declared none. Max *BigVal `json:"max,omitempty"` - // ExclusiveMin makes Min an exclusive bound. - ExclusiveMin bool `json:"exclusiveMin"` - // ExclusiveMax makes Max an exclusive bound. - ExclusiveMax bool `json:"exclusiveMax"` + // ExclusiveMin is the exclusive lower numeric bound (JSON Schema + // exclusiveMinimum): an admissible value is > it. nil = this position + // declared none. + // + // It is a bound of its own rather than a flag on Min, because the two + // keywords are independent and conjunctive: a schema may declare both, both + // then apply, and the effective floor is whichever admits fewer values. One + // slot per side would have to keep that one and lower the other some other + // way, which is a change to the weaker keyword that a consumer diffing two + // revisions of a spec could not see at all (GitHub #425). + ExclusiveMin *BigVal `json:"exclusiveMin,omitempty"` + // ExclusiveMax is the exclusive upper numeric bound (JSON Schema + // exclusiveMaximum): an admissible value is < it. nil = this position + // declared none. It is independent of Max exactly as ExclusiveMin is of Min. + ExclusiveMax *BigVal `json:"exclusiveMax,omitempty"` // MultipleOf constrains the value to a multiple of this number. MultipleOf *BigVal `json:"multipleOf,omitempty"` // Precision bounds the total decimal digits (Avro decimal, XSD totalDigits, diff --git a/ir/constraints_test.go b/ir/constraints_test.go index b4109287..39d0112c 100644 --- a/ir/constraints_test.go +++ b/ir/constraints_test.go @@ -7,15 +7,16 @@ import ( ) // TestConstraints_JSONContract pins that every bound is a pointer (nil = -// unconstrained) except ExclusiveMin, ExclusiveMax, and UniqueItems, which -// are plain bools that always serialize — an unconstrained Constraints still -// asserts "not exclusive" and "not unique" as facts, not absences. It also -// pins that a fully populated Constraints round-trips with its BigVal decimal -// strings intact (no float64 anywhere in the IR). +// unconstrained) — the four numeric bounds alike, since exclusiveMinimum and +// exclusiveMaximum are bounds of their own rather than flags on minimum and +// maximum — leaving UniqueItems the one field that always serializes, because +// an unconstrained Constraints still asserts "not unique" as a fact rather than +// an absence. It also pins that a fully populated Constraints round-trips with +// its BigVal decimal strings intact (no float64 anywhere in the IR). func TestConstraints_JSONContract(t *testing.T) { t.Parallel() assertJSONContract(t, ir.Constraints{}, - `{"exclusiveMin":false,"exclusiveMax":false,"uniqueItems":false}`, + `{"uniqueItems":false}`, *populatedConstraints()) } diff --git a/ir/helpers_test.go b/ir/helpers_test.go index 113e8909..c5feca1b 100644 --- a/ir/helpers_test.go +++ b/ir/helpers_test.go @@ -314,6 +314,14 @@ func populatedConstraints() *ir.Constraints { if err != nil { panic(err) } + exclMinV, err := ir.NewBigVal("0") + if err != nil { + panic(err) + } + exclMaxV, err := ir.NewBigVal("101") + if err != nil { + panic(err) + } precision := int64(10) scale := int64(2) minLen := int64(1) @@ -325,8 +333,8 @@ func populatedConstraints() *ir.Constraints { return &ir.Constraints{ Min: &minV, Max: &maxV, - ExclusiveMin: true, - ExclusiveMax: true, + ExclusiveMin: &exclMinV, + ExclusiveMax: &exclMaxV, MultipleOf: &multV, Precision: &precision, Scale: &scale, diff --git a/testdata/conformance/openapi/allof-oneof-cooccurrence.golden.json b/testdata/conformance/openapi/allof-oneof-cooccurrence.golden.json index cbc67588..75e7b04b 100644 --- a/testdata/conformance/openapi/allof-oneof-cooccurrence.golden.json +++ b/testdata/conformance/openapi/allof-oneof-cooccurrence.golden.json @@ -287,8 +287,6 @@ "nullable": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "minLength": 3, "uniqueItems": false } diff --git a/testdata/conformance/openapi/allof-ref-branch-siblings.golden.json b/testdata/conformance/openapi/allof-ref-branch-siblings.golden.json index 7b3440c9..6b66caa3 100644 --- a/testdata/conformance/openapi/allof-ref-branch-siblings.golden.json +++ b/testdata/conformance/openapi/allof-ref-branch-siblings.golden.json @@ -48,8 +48,6 @@ "nullable": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "uniqueItems": false, "minProps": 3 } diff --git a/testdata/conformance/openapi/constraints.golden.json b/testdata/conformance/openapi/constraints.golden.json index 0c6fef2f..a4dfba39 100644 --- a/testdata/conformance/openapi/constraints.golden.json +++ b/testdata/conformance/openapi/constraints.golden.json @@ -36,8 +36,6 @@ "nullable": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "minItems": 1, "maxItems": 5, "uniqueItems": true @@ -53,16 +51,6 @@ "anonymous": false, "docs": {}, "sensitive": false, - "unmodeled": { - "openapi:exclusiveMinimum": { - "reason": "degraded_lowering", - "value": 0, - "provenance": { - "source": 0, - "pointer": "/components/schemas/Bounded/exclusiveMinimum" - } - } - }, "provenance": { "source": 0, "pointer": "/components/schemas/Bounded" @@ -73,8 +61,7 @@ }, "constraints": { "min": "10", - "exclusiveMin": false, - "exclusiveMax": false, + "exclusiveMin": "0", "uniqueItems": false } }, @@ -113,8 +100,6 @@ "constraints": { "min": "0.30000000000000004", "max": "9007199254740993", - "exclusiveMin": false, - "exclusiveMax": false, "multipleOf": "0.1", "uniqueItems": false }, @@ -147,8 +132,7 @@ }, "constraints": { "min": "10", - "exclusiveMin": false, - "exclusiveMax": false, + "exclusiveMin": "0", "uniqueItems": false }, "flatten": false, @@ -156,16 +140,6 @@ "eventPayload": false, "secret": false, "docs": {}, - "unmodeled": { - "openapi:exclusiveMinimum": { - "reason": "degraded_lowering", - "value": 0, - "provenance": { - "source": 0, - "pointer": "/components/schemas/S/properties/atLeastTen/exclusiveMinimum" - } - } - }, "provenance": { "source": 0, "pointer": "/components/schemas/S/properties/atLeastTen" @@ -189,9 +163,8 @@ "none": false }, "constraints": { - "max": "10", - "exclusiveMin": false, - "exclusiveMax": true, + "max": "100", + "exclusiveMax": "10", "uniqueItems": false }, "flatten": false, @@ -199,16 +172,6 @@ "eventPayload": false, "secret": false, "docs": {}, - "unmodeled": { - "openapi:maximum": { - "reason": "degraded_lowering", - "value": 100, - "provenance": { - "source": 0, - "pointer": "/components/schemas/S/properties/underTen/maximum" - } - } - }, "provenance": { "source": 0, "pointer": "/components/schemas/S/properties/underTen" @@ -232,8 +195,6 @@ "none": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "minLength": 2, "maxLength": 8, "uniqueItems": false @@ -277,8 +238,6 @@ } ], "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "uniqueItems": false, "minProps": 1, "maxProps": 4 @@ -334,40 +293,11 @@ "auth": null } ], - "diagnostics": [ - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "minimum 10 and exclusiveMinimum 0 both bound this value and the IR holds one bound per side; kept minimum as the tighter of the two, and exclusiveMinimum, which it implies, verbatim under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/components/schemas/S/properties/atLeastTen" - } - }, - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "exclusiveMaximum 10 and maximum 100 both bound this value and the IR holds one bound per side; kept exclusiveMaximum as the tighter of the two, and maximum, which it implies, verbatim under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/components/schemas/S/properties/underTen" - } - }, - { - "severity": "info", - "code": "openapi/degraded-construct", - "message": "minimum 10 and exclusiveMinimum 0 both bound this value and the IR holds one bound per side; kept minimum as the tighter of the two, and exclusiveMinimum, which it implies, verbatim under Unmodeled", - "provenance": { - "source": 0, - "pointer": "/components/schemas/Bounded" - } - } - ], "sources": [ { "format": "openapi@3.1", "path": "constraints.yaml", - "hash": "421ee970477facfbd1d3d21838c66e21f37423d509929cd1d7504307a49db90a" + "hash": "26eb5b8535115a810b33386594ce36baafe0fc21f0bf7171d6cc923754a2ddaa" } ] } diff --git a/testdata/conformance/openapi/constraints.yaml b/testdata/conformance/openapi/constraints.yaml index 29970f41..a6524789 100644 --- a/testdata/conformance/openapi/constraints.yaml +++ b/testdata/conformance/openapi/constraints.yaml @@ -14,12 +14,13 @@ components: maximum: 9007199254740993 multipleOf: 0.1 # In 2020-12 the two keywords on a side are independent and both apply, - # so the effective bound is the tighter of them. Reading whichever came - # last published ">= 0" here and "< 100" below (GitHub #33). One bound - # slot per side means the other keyword reaches no field, so it is kept - # verbatim beside the constraints instead (GitHub #286) — without that, - # these two lower to exactly what `minimum: 10` and `exclusiveMaximum: - # 10` alone would. + # so ir.Constraints holds a field for each and each keeps the literal + # written. Reading whichever came last published ">= 0" here and "< 100" + # below (GitHub #33); keeping only the tighter left a change to the other + # keyword invisible to a consumer diffing two revisions (GitHub #425). + # The two sides are settled opposite ways — the inclusive bound is the + # tighter here, the exclusive one below — so neither field can be read + # off the other. atLeastTen: type: integer minimum: 10 @@ -37,8 +38,8 @@ components: uniqueItems: true items: {type: string} # A component whose body reduces to a shared primitive owns an alias node, - # the other carrier a co-declared bound can land on: the constraints go on - # the node, so the keyword they had no room for goes there too. + # the other carrier a co-declared pair can land on: both bounds go on the + # node's own constraints, exactly as they do on a property. Bounded: type: integer minimum: 10 diff --git a/testdata/conformance/openapi/encoding-byte.golden.json b/testdata/conformance/openapi/encoding-byte.golden.json index a86e4a85..c06231ca 100644 --- a/testdata/conformance/openapi/encoding-byte.golden.json +++ b/testdata/conformance/openapi/encoding-byte.golden.json @@ -36,8 +36,6 @@ "nullable": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "minLength": 5, "maxLength": 9, "uniqueItems": false @@ -83,8 +81,6 @@ "none": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "minLength": 5, "maxLength": 9, "uniqueItems": false diff --git a/testdata/conformance/openapi/header-content-schema.golden.json b/testdata/conformance/openapi/header-content-schema.golden.json index 40b0674c..98b99569 100644 --- a/testdata/conformance/openapi/header-content-schema.golden.json +++ b/testdata/conformance/openapi/header-content-schema.golden.json @@ -57,8 +57,6 @@ "none": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "maxLength": 4, "uniqueItems": false }, @@ -96,8 +94,6 @@ "none": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "maxLength": 4, "uniqueItems": false }, @@ -267,8 +263,6 @@ "nullable": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "pattern": "^r-[0-9]+$", "uniqueItems": false } diff --git a/testdata/conformance/openapi/inline-annotations.golden.json b/testdata/conformance/openapi/inline-annotations.golden.json index 4754e625..b2baf68b 100644 --- a/testdata/conformance/openapi/inline-annotations.golden.json +++ b/testdata/conformance/openapi/inline-annotations.golden.json @@ -37,8 +37,6 @@ }, "required": false, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "maxLength": 4, "uniqueItems": false }, @@ -104,8 +102,6 @@ "none": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "maxLength": 64, "uniqueItems": false }, @@ -196,8 +192,6 @@ "nullable": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "maxLength": 64, "uniqueItems": false } @@ -236,8 +230,6 @@ "nullable": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "maxLength": 3, "uniqueItems": false } @@ -262,8 +254,6 @@ "nullable": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "maxLength": 8192, "uniqueItems": false } diff --git a/testdata/conformance/openapi/multipart-encoding.golden.json b/testdata/conformance/openapi/multipart-encoding.golden.json index 7f4641e0..3dae8aa3 100644 --- a/testdata/conformance/openapi/multipart-encoding.golden.json +++ b/testdata/conformance/openapi/multipart-encoding.golden.json @@ -558,8 +558,6 @@ "nullable": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "pattern": "^[0-9a-f]{64}$", "uniqueItems": false } diff --git a/testdata/conformance/openapi/numeric-precision.golden.json b/testdata/conformance/openapi/numeric-precision.golden.json index c2281a27..44769852 100644 --- a/testdata/conformance/openapi/numeric-precision.golden.json +++ b/testdata/conformance/openapi/numeric-precision.golden.json @@ -168,8 +168,6 @@ "constraints": { "min": "1.8e308", "max": "123456789012345678901234567890", - "exclusiveMin": false, - "exclusiveMax": false, "multipleOf": "1e-30", "uniqueItems": false }, @@ -201,10 +199,8 @@ "none": false }, "constraints": { - "min": "0.5", - "max": "0.12345678901234567890123456789", - "exclusiveMin": true, - "exclusiveMax": true, + "exclusiveMin": "0.5", + "exclusiveMax": "0.12345678901234567890123456789", "uniqueItems": false }, "flatten": false, @@ -397,8 +393,6 @@ "constraints": { "min": "15", "max": "31", - "exclusiveMin": false, - "exclusiveMax": false, "multipleOf": "10", "uniqueItems": false }, diff --git a/testdata/conformance/openapi/param-ref-inheritance.golden.json b/testdata/conformance/openapi/param-ref-inheritance.golden.json index e1329165..86e43bf1 100644 --- a/testdata/conformance/openapi/param-ref-inheritance.golden.json +++ b/testdata/conformance/openapi/param-ref-inheritance.golden.json @@ -71,8 +71,6 @@ "object": null }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "maxLength": 100, "uniqueItems": false }, @@ -185,8 +183,6 @@ "nullable": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "maxLength": 64, "uniqueItems": false } @@ -269,8 +265,6 @@ "object": null }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "maxLength": 100, "uniqueItems": false }, diff --git a/testdata/conformance/openapi/scalar-format.golden.json b/testdata/conformance/openapi/scalar-format.golden.json index dc4cb80a..1141c1ae 100644 --- a/testdata/conformance/openapi/scalar-format.golden.json +++ b/testdata/conformance/openapi/scalar-format.golden.json @@ -36,8 +36,6 @@ "nullable": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "minLength": 4, "uniqueItems": false }, @@ -78,8 +76,6 @@ "none": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "minLength": 4, "uniqueItems": false }, diff --git a/testdata/conformance/openapi/unhomed-keywords.golden.json b/testdata/conformance/openapi/unhomed-keywords.golden.json index 4538a049..47aa1661 100644 --- a/testdata/conformance/openapi/unhomed-keywords.golden.json +++ b/testdata/conformance/openapi/unhomed-keywords.golden.json @@ -233,8 +233,6 @@ "nullable": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "minItems": 3, "maxItems": 9, "uniqueItems": true @@ -523,8 +521,6 @@ "nullable": false }, "constraints": { - "exclusiveMin": false, - "exclusiveMax": false, "minLength": 3, "uniqueItems": false } From e9ad16ca62f957dbefb1615ed664ffa3462de134 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Tue, 8 Sep 2026 01:13:34 +0300 Subject: [PATCH 11/13] fix(compilers/openapi): scope the key scan, keep the read's error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole-source read #420 added got two answers wrong, both by reusing machinery written for a cut prefix. The flow decoder reports "this is a flow mapping" for anything opening with `{`, and drops the error that ended its walk. For a cut prefix that is right: the cut always breaks the token stream, so the error describes the cut and not the document. For a whole document it hides the document's own break. A JSON source past the cap whose `openapi` key sits behind a syntax error came back with a nil error, so Detect saw no failure to report and declined it as an unrecognized format — the very answer #420 set out to replace, still standing for every JSON source, which is the style the motivating spec is written in. The decoder now returns the error that stopped it and treats stopping on the mapping's own closing delimiter or on the entry cap as no error at all; sniffPrefix drops it along with the cut that caused it, and sniffWhole keeps it. The key scan was widened to the whole source without being scoped to the top level. Its block arm reads column 0 and always was top-level, but its quoted arm matched `"openapi":` at any depth, anywhere in the buffer. Bounded to the first 64 KiB that cost a needless parse; over a whole source it makes a claim, and a wrong one — another format's document that nests such a key and does not parse was reported as an undecodable OpenAPI source. Saying nothing about bytes that are not this compiler's own is the rule detection is built on. The quoted spelling is how flow style writes every key, so flow structure is what scopes it: a depth-tracking scan reads the root mapping's own entries and nothing under them, and a source that opens no mapping at all declares nothing here. It is a lexer rather than a parser because the case it exists for is a document broken before the key that names it, where there is no tree to ask. A block document that quotes its top-level key is no longer seen and is declined in silence, which is the direction to be wrong in. A valid document past the cap that declares its version last still compiles, which is what #420 was about. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1 --- compilers/openapi/detect.go | 190 ++++++++++++++++++++++++------- compilers/openapi/detect_test.go | 108 ++++++++++++++++-- 2 files changed, 243 insertions(+), 55 deletions(-) diff --git a/compilers/openapi/detect.go b/compilers/openapi/detect.go index c54fc80d..654f88ea 100644 --- a/compilers/openapi/detect.go +++ b/compilers/openapi/detect.go @@ -80,39 +80,112 @@ func (*Compiler) Detect(src compilers.Source) (compilers.SourceFormat, []ir.Diag // in front of, and the key it looks for is exactly the one that can sit // megabytes into a document — bounding this to the prefix would blind it in // precisely the case it exists to catch. +// +// Top-level is the whole of the claim, and the two styles answer it by different +// structure: column 0 in block style, the root mapping's own entries in flow +// style. Neither reading may be widened to "the name occurs somewhere followed +// by a colon", because other formats nest a key of that name, and reporting +// their bytes under this compiler's parse error is the one thing detection must +// never do. func declaresProbeKey(data []byte) bool { - return declaresKey(data, "openapi") || declaresKey(data, "swagger") + return declaresBlockKey(data, "openapi") || declaresBlockKey(data, "swagger") || + declaresFlowKey(data) +} + +// declaresBlockKey reports whether data writes key bare at the start of a line, +// which in block style is where a top-level key goes and nowhere else: a key +// nested under another is indented past column 0, and a block scalar's content +// is indented past its own key. +// +// Only the bare spelling is read here, because the quoted one is how flow style +// writes every key and flow structure is what scopes it — declaresFlowKey has +// it. A block document that quotes its top-level key is therefore not seen, and +// is declined in silence rather than claimed; that is the direction to be wrong +// in, and the spelling is rare enough that widening column 0 to admit the shape +// JSON writes at every depth would cost far more than it buys. +// +// The colon that makes it a key is required. Without it, a document of another +// format that merely mentions the word — in a comment, or as a value — would be +// claimed as this compiler's and reported under its parse error. +func declaresBlockKey(data []byte, key string) bool { + name := []byte(key + ":") + return bytes.HasPrefix(data, name) || bytes.Contains(data, append([]byte("\n"), name...)) } -// declaresKey reports whether data names key at the top level, in either style: -// unquoted at the start of a line for block style, or quoted for flow style, -// which is how JSON writes every key. +// declaresFlowKey reports whether data opens a flow mapping — the shape JSON +// writes — that names one of the discriminating keys among its own entries. +// +// Nesting depth is what makes the answer top-level, and it is the half a plain +// search for `"openapi":` gets wrong: a quoted name followed by a colon reads as +// a key wherever it sits, and other formats nest one. A source that opens no +// mapping at all — a JSON array, say — declares nothing here for the same +// reason: whatever it names, it does not name it as its own root key. // -// Both spellings require the colon that makes it a key. Without it, a document -// of another format that merely mentions the word — in a comment, or as a value -// — would be claimed as this compiler's and reported under its parse error. -func declaresKey(data []byte, key string) bool { - block := []byte(key + ":") - if bytes.HasPrefix(data, block) || bytes.Contains(data, []byte("\n"+key+":")) { - return true - } - return followedByColon(data, []byte(`"`+key+`"`)) -} - -// followedByColon reports whether name occurs in data followed by a colon, -// ignoring the whitespace a flow mapping may put between them. -func followedByColon(data, name []byte) bool { - for i := 0; ; { - j := bytes.Index(data[i:], name) - if j < 0 { - return false +// The scan is a lexer, not a parser: it tracks quoted strings and nesting and +// reads nothing else. It has to answer on bytes that will not parse, which is +// the case it exists for — a document broken before the key that names it — so +// there is no tree to ask instead. +func declaresFlowKey(data []byte) bool { + i := skipSpace(data, 0) + if i == len(data) || data[i] != '{' { + return false + } + + for depth := 0; i < len(data); { + switch data[i] { + case '"': + name, next := flowString(data, i) + if depth == 1 && isProbeName(name) && startsWithColon(data, next) { + return true + } + i = next + case '{', '[': + depth++ + i++ + case '}', ']': + depth-- + i++ + default: + i++ } - rest := bytes.TrimLeft(data[i+j+len(name):], " \t\r\n") - if len(rest) > 0 && rest[0] == ':' { - return true + } + return false +} + +// flowString returns the bytes between the quotes of the string data[i] opens, +// and the index just past its closing quote. An unterminated string runs to the +// end of data: there is nothing past it left to read. +func flowString(data []byte, i int) ([]byte, int) { + for j := i + 1; j < len(data); j++ { + switch data[j] { + case '\\': + j++ + case '"': + return data[i+1 : j], j + 1 } - i += j + len(name) } + return nil, len(data) +} + +// isProbeName reports whether name is one of the discriminating keys. +func isProbeName(name []byte) bool { + return string(name) == "openapi" || string(name) == "swagger" +} + +// skipSpace returns the index of the first byte at or after i that is not +// whitespace, or len(data) if there is none. +func skipSpace(data []byte, i int) int { + for i < len(data) && (data[i] == ' ' || data[i] == '\t' || data[i] == '\r' || data[i] == '\n') { + i++ + } + return i +} + +// startsWithColon reports whether the first non-whitespace byte at or after i is +// the colon that makes the name before it a key. +func startsWithColon(data []byte, i int) bool { + i = skipSpace(data, i) + return i < len(data) && data[i] == ':' } // sniff reads the discriminating keys out of data, and returns the zero probe @@ -144,7 +217,11 @@ func sniff(data []byte) (sniffProbe, error) { // entries are streamed instead, and block style is cut at its last complete // line. func sniffPrefix(prefix []byte) (sniffProbe, error) { - if probe, ok := decodeFlowEntries(prefix); ok { + // The cut breaks the token stream by construction, so the flow decoder's + // error here describes the cut and not the document. What it managed to read + // before the cut is the whole of what a prefix has to say. + probe, flow, _ := decodeFlowEntries(prefix) + if flow { return probe, nil } return decodeYAML(wholeLines(prefix)) @@ -160,11 +237,22 @@ func sniffPrefix(prefix []byte) (sniffProbe, error) { // Nothing another format wrote reaches here — declaresProbeKey guards the call — // so the cost is paid only for bytes this compiler is about to parse in full // anyway, and the answer for everyone else is still the fast path's silence. +// +// Unlike sniffPrefix this keeps the flow decoder's error, and returns the zero +// probe with it exactly as decodeYAML does. There is no cut here to explain a +// broken token stream away: a whole document that stops mid-stream is a document +// this compiler cannot read, and saying so is the answer the key it declares has +// earned. Dropping the error instead reports the source as another format's, +// which is what the document past the cap was found not to be. func sniffWhole(data []byte) (sniffProbe, error) { - if probe, ok := decodeFlowEntries(data); ok { - return probe, nil + probe, flow, err := decodeFlowEntries(data) + if !flow { + return decodeYAML(data) + } + if err != nil { + return sniffProbe{}, err } - return decodeYAML(data) + return probe, nil } // decodeYAML reads the probe keys from a complete YAML (or JSON, its subset) @@ -177,36 +265,52 @@ func decodeYAML(data []byte) (sniffProbe, error) { return probe, nil } +// opensFlowMapping reports whether the stream's first token opens a mapping. +// A stream that does not is not flow style, which is an answer rather than a +// failure: the decoder's complaint there says only that these bytes are not +// JSON, and block YAML is not JSON either. +func opensFlowMapping(dec *json.Decoder) bool { + tok, err := dec.Token() + return err == nil && tok == json.Delim('{') +} + // decodeFlowEntries reads the top-level entries of data, which may be a whole -// document or a prefix of one, and reports whether it opened a flow mapping. The -// JSON decoder is used because it streams: a prefix cut mid-document still -// yields every entry it completed, where decoding those same bytes whole reports -// only that they end early. -func decodeFlowEntries(data []byte) (sniffProbe, bool) { +// document or a prefix of one. It reports whether data opened a flow mapping, +// and the error that ended the walk early. The JSON decoder is used because it +// streams: a prefix cut mid-document still yields every entry it completed, +// where decoding those same bytes whole reports only that they end early. +// +// A nil error means the walk ended on the mapping's own closing delimiter or on +// the entry cap — the two ways of stopping that say nothing about the bytes. +// Whether a non-nil one describes the document or only the cut that produced +// data is the caller's question, since only the caller knows which it passed. +func decodeFlowEntries(data []byte) (sniffProbe, bool, error) { dec := json.NewDecoder(bytes.NewReader(data)) - tok, err := dec.Token() - if err != nil || tok != json.Delim('{') { - return sniffProbe{}, false + if !opensFlowMapping(dec) { + return sniffProbe{}, false, nil } var probe sniffProbe for range maxSniffEntries { key, err := dec.Token() if err != nil { - break + return probe, true, err + } + if key == json.Delim('}') { + return probe, true, nil } var value json.RawMessage if err := dec.Decode(&value); err != nil { - break + return probe, true, err } recordEntry(&probe, key, value) } - return probe, true + return probe, true, nil } // recordEntry stores value under probe's field for key. key is compared as read -// rather than asserted to a string: the closing delimiter of the mapping -// arrives here too, and it matches neither name. +// rather than asserted to a string: a json.Token holds whichever kind the stream +// produced, and only the two names the switch spells are of any interest here. func recordEntry(probe *sniffProbe, key json.Token, value json.RawMessage) { switch key { case "openapi": diff --git a/compilers/openapi/detect_test.go b/compilers/openapi/detect_test.go index 7523650e..23d9c86b 100644 --- a/compilers/openapi/detect_test.go +++ b/compilers/openapi/detect_test.go @@ -79,6 +79,26 @@ func TestDetect_Formats(t *testing.T) { {"key past the cap on an unparseable prefix", "api.yaml", padTo("bad: [unterminated\n", "filler: x\n") + "openapi: 3.1.0\n", compilers.SourceFormat{}, false, []string{diag.UndecodableSource}}, + // The same case in flow style, which is what the motivating spec is + // written in. A JSON document has no line structure to cut at, so its + // prefix is streamed and the stream stops at the cut; the whole read is + // what finds the declaration, and keeping that read's own error is what + // lets the answer be "this compiler's, and broken" rather than silence. + {"key past the cap on an unparseable flow prefix", "spec3.json", + `{"pad":"` + flowPad() + `","bad" 1,"openapi":"3.1.0"}`, + compilers.SourceFormat{}, false, []string{diag.UndecodableSource}}, + // Another format's document, past the cap, naming the word as a key and + // broken besides. It opens no mapping of its own, so the key is not its + // declaration of itself and this compiler has nothing to say: reporting a + // parse error here would claim bytes that were never its own. + {"a broken document of another format names the key", "asyncapi.json", + `[{"openapi":"3.1.0"},"` + flowPad() + `"`, + compilers.SourceFormat{}, false, nil}, + // The same, one level down inside a mapping that does open the document. + // A nested key names a field, not the format of the file holding it. + {"a broken document of another format nests the key", "other.json", + `{"pad":"` + flowPad() + `","deep":{"openapi":"3.1.0"},"bad" 1}`, + compilers.SourceFormat{}, false, nil}, {"empty", "empty.yaml", "", compilers.SourceFormat{}, false, nil}, } for _, tc := range cases { @@ -139,6 +159,10 @@ func bigComponents() (flow, block string) { return f.String(), b.String() } +// flowPad returns a run of bytes long enough that a flow entry holding it puts +// everything after it past the sniff cap. +func flowPad() string { return strings.Repeat("p", maxSniffBytes) } + // padTo returns src grown past the sniff cap by appending filler, so sniff reads // a prefix first rather than decoding the source whole on sight. func padTo(src, filler string) string { @@ -212,6 +236,13 @@ func TestDeclaresProbeKey_GuardsTheWholeRead(t *testing.T) { {"declared past the cap in block style", "x: " + pad + "\nswagger: \"2.0\"\n", true}, {"named past the cap as a value", `{"x":"` + pad + `","note":"openapi"}`, false}, {"named past the cap in prose", "x: " + pad + "\n# openapi is a format\n", false}, + // A key, and still not this document's: it names a field of something + // nested, which says nothing about the format of the file around it. + {"named past the cap as a nested key", `{"x":"` + pad + `","in":{"openapi":"3.1.0"}}`, false}, + // A document that opens no mapping declares no top-level key at all, so + // whatever its members name, none of it is a declaration of this format. + {"named past the cap in a document that opens no mapping", + `[{"x":"` + pad + `"},{"openapi":"3.1.0"}]`, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -222,32 +253,84 @@ func TestDeclaresProbeKey_GuardsTheWholeRead(t *testing.T) { } } +// TestDecodeFlowEntries_ReadsWhatTheCutLeft pins both halves of what the walk +// returns: the entries it completed, and whether the token stream broke before +// the mapping closed. A cut prefix breaks it by construction and a whole +// document does not, which is why the error is reported rather than folded into +// the probe — the same bytes mean "cut here" to one caller and "unreadable" to +// the other, and only the caller knows which it passed. +// TestDeclaresProbeKey_ScopesTheNameToItsOwnDocument pins the half of the guard +// that decides whose bytes these are. A name followed by a colon is a key +// wherever it sits, so the scan has to say *whose* key: block style answers with +// column 0, flow style with the root mapping's own depth. Everything below is a +// document naming the word somewhere it does not declare this format, and the +// answer for each is no — a compiler that says otherwise reports its own parse +// error over a file that was never its own. +func TestDeclaresProbeKey_ScopesTheNameToItsOwnDocument(t *testing.T) { + t.Parallel() + cases := []struct { + name, src string + want bool + }{ + {"flow mapping declares it", `{"openapi":"3.1.0"}`, true}, + {"flow mapping declares swagger", `{"swagger":"2.0"}`, true}, + {"space around the mapping and the colon", " \n\t{\"openapi\" : \"3.1.0\"}", true}, + {"an escape hides no key from the scan", `{"a\"b":1,"openapi":"3.1.0"}`, true}, + {"block style at column 0", "openapi: 3.1.0\n", true}, + + {"nested one level down", `{"a":{"openapi":"3.1.0"}}`, false}, + {"nested inside a sequence", `{"a":[{"openapi":"3.1.0"}]}`, false}, + {"a document that opens a sequence", `[{"openapi":"3.1.0"}]`, false}, + {"block style indented under another key", "a:\n openapi: 3.1.0\n", false}, + {"the name is a value", `{"note":"openapi"}`, false}, + {"the name has no colon after it", `{"openapi",1}`, false}, + {"the name ends the bytes", `{"openapi"`, false}, + {"a string runs off the end", `{"a":"unterminated`, false}, + {"nothing but whitespace", " \n\t ", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, declaresProbeKey([]byte(tc.src))) + }) + } +} + func TestDecodeFlowEntries_ReadsWhatTheCutLeft(t *testing.T) { t.Parallel() cases := []struct { - name, prefix string - want sniffProbe - wantFlow bool + name, prefix string + want sniffProbe + wantFlow, wantBrok bool }{ {"complete document", `{"openapi":"3.1.0","info":{"title":"T"}}`, - sniffProbe{OpenAPI: "3.1.0"}, true}, + sniffProbe{OpenAPI: "3.1.0"}, true, false}, {"cut inside a later value", `{"openapi":"3.1.0","info":{"title":"T`, - sniffProbe{OpenAPI: "3.1.0"}, true}, + sniffProbe{OpenAPI: "3.1.0"}, true, true}, {"cut inside a key", `{"openapi":"3.1.0","inf`, - sniffProbe{OpenAPI: "3.1.0"}, true}, - {"swagger", `{"swagger":"2.0","info":{}}`, sniffProbe{Swagger: "2.0"}, true}, + sniffProbe{OpenAPI: "3.1.0"}, true, true}, + {"swagger", `{"swagger":"2.0","info":{}}`, sniffProbe{Swagger: "2.0"}, true, false}, // A version that is not a string declares no dialect, and must not be // read as one by accident. - {"non-string version", `{"openapi":3}`, sniffProbe{}, true}, - {"no flow mapping", "openapi: 3.1.0\n", sniffProbe{}, false}, - {"not even a token", "\x00", sniffProbe{}, false}, + {"non-string version", `{"openapi":3}`, sniffProbe{}, true, false}, + // Whole, opens a mapping, and breaks in the middle of it: nothing was cut + // away, so the break is the document's own. + {"malformed mid-mapping", `{"a":1,"b" 2,"openapi":"3.1.0"}`, + sniffProbe{}, true, true}, + {"no flow mapping", "openapi: 3.1.0\n", sniffProbe{}, false, false}, + {"not even a token", "\x00", sniffProbe{}, false, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got, flow := decodeFlowEntries([]byte(tc.prefix)) + got, flow, err := decodeFlowEntries([]byte(tc.prefix)) assert.Equal(t, tc.wantFlow, flow) assert.Equal(t, tc.want, got) + if tc.wantBrok { + assert.Error(t, err, "a broken token stream is reported, not swallowed") + return + } + assert.NoError(t, err) }) } } @@ -271,8 +354,9 @@ func TestDecodeFlowEntries_StopsAtTheEntryCap(t *testing.T) { } b.WriteString(`,"openapi":"3.1.0"}`) - got, flow := decodeFlowEntries([]byte(b.String())) + got, flow, err := decodeFlowEntries([]byte(b.String())) require.True(t, flow) + require.NoError(t, err, "stopping on the cap is not the document breaking") assert.Equal(t, sniffProbe{}, got, "the entry past the cap is not read") } From 68977f466e47a4b9769047888371929aa5d03923 Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Tue, 8 Sep 2026 01:13:34 +0300 Subject: [PATCH 12/13] docs(ir-design): name rule 4's live instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §12's fourth promotion rule read as if it had none left: `Parameter` was the instance it named, and the sentence recording that `Parameter` has since gained a `Provenance` left the rule with nothing to point at. `Variant` (§4.4) and `EnumMember` (§4.5) each still carry a `Deprecation` with no provenance of their own, so the rule governs them today. Name them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1 --- docs/ir-design.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ir-design.md b/docs/ir-design.md index f189f973..6024e2b1 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -1829,8 +1829,10 @@ this from `Unmodeled` and no two derive it differently: 4. **A node with no `Provenance` is not promoted into.** A node carrying a `Deprecation` and no provenance could not satisfy rule 3, and a heuristic that cannot be audited is worse than an empty field. Giving such a node a provenance is a change to this document, and the promotion - follows it rather than preceding it — which is the order `Parameter` went through: it was the - instance this rule named until it gained the `Provenance` §7.2 now gives it. + follows it rather than preceding it — which is the order `Parameter` went through, and it held + this rule's only named instance until it gained the `Provenance` §7.2 now gives it. `Variant` + (§4.4) and `EnumMember` (§4.5) are the instances today: each carries a `Deprecation` and no + provenance of its own, so no key maps into either until one of them gains one. A value the mapped field cannot hold — anything but text, for the four `Deprecation` members — is reported and not coerced, since the document means something else by the key. Text of the right From 15d5dbdff38fadb28a8b26330997d12716470eae Mon Sep 17 00:00:00 2001 From: Fuad Daoud Date: Tue, 8 Sep 2026 01:19:45 +0300 Subject: [PATCH 13/13] feat(ir)!: bump IRVersion to 0.4.0 for this branch's shape changes Six commits on this branch change the JSON shape of a Document and none bumped the constant, each correctly deferring per ir-design 2.1: a line of work bumps it ONCE, where it lands on main. This is that bump. The GoDoc on IRVersion names exactly the failure a missing bump causes -- "a shape change that reaches main without a bump leaves a consumer pinned to the old version accepting a document it cannot read, which is the one thing this constant exists to prevent" -- and nothing in the gate can see it, because TestVerify_CurrentIRVersionIsClean, TestVerify_IncompatibleIRVersionIsAViolation and openapi_test.go all compare against the same constant and stay green whatever it says. Found by review, not by CI. The log paragraph records all six, each framed as what a 0.3.0 consumer gets wrong rather than as a feature: ErrorCase loses Type and gains Name/Payload/ Headers; Payload gains Required; Parameter gains Provenance; Deprecation gains RemovalDate and x-sunset routes there; Encoding gains Schema; and Constraints.ExclusiveMin/Max change from bool to a decimal string, which is the one that fails a consumer's decode rather than degrading it. TestCompatibleVersion's neighbour rows were spelled against 0.3.0, so the bump made "a later generation" 0.4.0 assert that the build rejects its own documents. Re-anchored, with a comment saying they move with the constant. 79 goldens regenerated; the only key that moved is irVersion. BREAKING CHANGE: IR documents now stamp 0.4.0 and CompatibleVersion refuses 0.3.0. Consumers must recompile rather than migrate stored documents. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SYeBgDsskwnyitLgPGCPn1 --- ir/document.go | 24 ++++++++++++++++++- ir/document_test.go | 11 ++++++--- .../openapi/allof-boolean-branch.golden.json | 2 +- .../allof-conflicting-type.golden.json | 2 +- .../openapi/allof-inheritance.golden.json | 2 +- .../openapi/allof-inline-merge.golden.json | 2 +- .../openapi/allof-inline-residue.golden.json | 2 +- .../openapi/allof-mixins.golden.json | 2 +- .../allof-oneof-cooccurrence.golden.json | 2 +- .../allof-ref-branch-siblings.golden.json | 2 +- .../openapi/allof-required-only.golden.json | 2 +- .../openapi/anyof-untagged.golden.json | 2 +- .../conformance/openapi/callbacks.golden.json | 2 +- .../openapi/codeclared-keywords.golden.json | 2 +- .../codeclared-schema-content.golden.json | 2 +- .../openapi/component-reuse.golden.json | 2 +- .../openapi/constraints.golden.json | 2 +- .../openapi/content-vocabulary.golden.json | 2 +- .../conformance/openapi/defaults.golden.json | 2 +- .../openapi/dependent-required.golden.json | 2 +- .../openapi/deprecation.golden.json | 2 +- .../openapi/dialect-keywords.golden.json | 2 +- .../discriminator-default-mapping.golden.json | 2 +- .../discriminator-inheritance.golden.json | 2 +- .../discriminator-transitive.golden.json | 2 +- .../openapi/docs-summary-desc.golden.json | 2 +- .../openapi/dynamic-ref.golden.json | 2 +- .../openapi/empty-enum.golden.json | 2 +- .../openapi/empty-names.golden.json | 2 +- .../openapi/encoding-byte.golden.json | 2 +- .../openapi/enum-numeric.golden.json | 2 +- .../openapi/enum-string.golden.json | 2 +- .../conformance/openapi/examples.golden.json | 2 +- .../openapi/extension-promotion.golden.json | 2 +- .../openapi/extensions-x.golden.json | 2 +- .../conformance/openapi/file-body.golden.json | 2 +- .../openapi/header-content-schema.golden.json | 2 +- .../openapi/http-binding.golden.json | 2 +- .../openapi/inline-annotations.golden.json | 2 +- .../inline-hoist-positions.golden.json | 2 +- .../openapi/inline-residue.golden.json | 2 +- .../openapi/inline-types.golden.json | 2 +- .../openapi/literal-const.golden.json | 2 +- testdata/conformance/openapi/maps.golden.json | 2 +- .../openapi/multi-content.golden.json | 2 +- .../openapi/multipart-encoding.golden.json | 2 +- .../openapi/named-types.golden.json | 2 +- .../openapi/negation-not.golden.json | 2 +- .../openapi/neutral-naming.golden.json | 2 +- .../nullability-conjunction.golden.json | 2 +- .../nullability-four-states.golden.json | 2 +- .../openapi/nullable-30.golden.json | 2 +- .../openapi/nullable-31-ref.golden.json | 2 +- .../openapi/nullable-enum-31.golden.json | 2 +- .../openapi/numeric-precision.golden.json | 2 +- .../openapi/oneof-discriminated.golden.json | 2 +- .../openapi/param-querystring.golden.json | 2 +- .../openapi/param-ref-inheritance.golden.json | 2 +- .../openapi/param-style-matrix.golden.json | 2 +- .../openapi/param-styles.golden.json | 2 +- .../openapi/param-xml-residue.golden.json | 2 +- .../openapi/path-item-docs.golden.json | 2 +- .../openapi/path-item-operations.golden.json | 2 +- .../openapi/per-status-errors.golden.json | 2 +- .../openapi/readonly-writeonly.golden.json | 2 +- .../conformance/openapi/recursive.golden.json | 2 +- .../openapi/response-links.golden.json | 2 +- .../openapi/scalar-format.golden.json | 2 +- .../openapi/security-or-and.golden.json | 2 +- .../openapi/security-schemes.golden.json | 2 +- .../openapi/sequential-media.golden.json | 2 +- .../openapi/servers-variables.golden.json | 2 +- .../openapi/streaming-media-30.golden.json | 2 +- .../openapi/streaming-media-31.golden.json | 2 +- .../openapi/tags-grouping.golden.json | 2 +- .../openapi/tuples-prefixitems.golden.json | 2 +- .../openapi/unhomed-keywords.golden.json | 2 +- .../conformance/openapi/webhooks.golden.json | 2 +- .../conformance/openapi/xml-hints.golden.json | 2 +- .../yaml-timestamp-scalars.golden.json | 2 +- testdata/golden/openapi/petstore.golden.json | 2 +- 81 files changed, 110 insertions(+), 83 deletions(-) diff --git a/ir/document.go b/ir/document.go index d1de046c..4cb15313 100644 --- a/ir/document.go +++ b/ir/document.go @@ -19,7 +19,29 @@ package ir // 0.3.0 renames that field to Unmodeled on every carrier, so the JSON key // "preserved" is now "unmodeled". A consumer pinned to 0.2.0 finds no key it // recognizes and drops every unmodeled construct in silence. -const IRVersion = "0.3.0" +// +// 0.4.0 covers six shape changes made together, all of them closing a gap a +// consumer had to read around rather than adding a capability: +// +// - ErrorCase becomes Response's sibling: Type is REMOVED, and Name, Payload +// and Headers take its place. A consumer pinned to 0.3.0 finds no "type" on +// an error case and cannot reach its models at all; one that reads the new +// fields gets the status spelling, the headers and every media type, which +// 0.3.0 dumped into Unmodeled whatever their arity. +// - Payload gains Required. Body optionality stopped being an inverted +// Unmodeled sentinel read by absence, so a consumer that still reads +// "openapi:required" now finds nothing and reads every body as required. +// - Parameter gains Provenance, non-omitempty, and with it x-sunset promotion +// at the parameter position. +// - Deprecation gains RemovalDate. x-sunset promotes into it rather than into +// RemovalVersion, so a consumer reading a removal date off the version field +// now finds it empty. +// - Encoding gains Schema, giving contentSchema a home at scalar positions. +// - Constraints.ExclusiveMin and ExclusiveMax change from bool to a decimal +// string carrying the bound itself, so the two dialects' exclusive bounds no +// longer lose one keyword to the other. The JSON type of both keys changed; +// a consumer decoding them as booleans fails rather than degrades. +const IRVersion = "0.4.0" // CompatibleVersion reports whether a document stamped version can be read by // this build. It is the predicate behind the compatibility policy in diff --git a/ir/document_test.go b/ir/document_test.go index a4ba07c8..69f33b05 100644 --- a/ir/document_test.go +++ b/ir/document_test.go @@ -81,9 +81,14 @@ func TestCompatibleVersion(t *testing.T) { }{ {"this build's version", ir.IRVersion, true}, {"absent", "", false}, - {"an earlier generation", "0.1.0", false}, - {"a later generation", "0.4.0", false}, - {"a differing patch", "0.3.1", false}, + // These three are spelled relative to IRVersion and MOVE WITH IT. The + // neighbour rows are the point of the test, so a bump that leaves them + // behind stops testing what they name — at 0.4.0 the old "later + // generation" literal WAS the current version, and the row asserted the + // build rejects its own documents. + {"the generation before this one", "0.3.0", false}, + {"a later generation", "0.5.0", false}, + {"a differing patch", "0.4.1", false}, {"a prerelease of this version", ir.IRVersion + "-rc.1", false}, {"padded with whitespace", " " + ir.IRVersion + " ", false}, {"not a version at all", "99.99.99-bogus", false}, diff --git a/testdata/conformance/openapi/allof-boolean-branch.golden.json b/testdata/conformance/openapi/allof-boolean-branch.golden.json index a89a7550..d28ed7d4 100644 --- a/testdata/conformance/openapi/allof-boolean-branch.golden.json +++ b/testdata/conformance/openapi/allof-boolean-branch.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "AllOfBooleanBranch", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/allof-conflicting-type.golden.json b/testdata/conformance/openapi/allof-conflicting-type.golden.json index 65e7f546..8189e65b 100644 --- a/testdata/conformance/openapi/allof-conflicting-type.golden.json +++ b/testdata/conformance/openapi/allof-conflicting-type.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "AllOfConflictingType", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/allof-inheritance.golden.json b/testdata/conformance/openapi/allof-inheritance.golden.json index 3c203a34..93836c42 100644 --- a/testdata/conformance/openapi/allof-inheritance.golden.json +++ b/testdata/conformance/openapi/allof-inheritance.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "AllOfInheritance", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/allof-inline-merge.golden.json b/testdata/conformance/openapi/allof-inline-merge.golden.json index 690ae16f..b3c2dd52 100644 --- a/testdata/conformance/openapi/allof-inline-merge.golden.json +++ b/testdata/conformance/openapi/allof-inline-merge.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "AllOfInlineMerge", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/allof-inline-residue.golden.json b/testdata/conformance/openapi/allof-inline-residue.golden.json index ecd05813..6a652ec2 100644 --- a/testdata/conformance/openapi/allof-inline-residue.golden.json +++ b/testdata/conformance/openapi/allof-inline-residue.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "AllOfInlineResidue", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/allof-mixins.golden.json b/testdata/conformance/openapi/allof-mixins.golden.json index 6fcf58b1..d50c698f 100644 --- a/testdata/conformance/openapi/allof-mixins.golden.json +++ b/testdata/conformance/openapi/allof-mixins.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "AllOfMixins", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/allof-oneof-cooccurrence.golden.json b/testdata/conformance/openapi/allof-oneof-cooccurrence.golden.json index 75e7b04b..d6998506 100644 --- a/testdata/conformance/openapi/allof-oneof-cooccurrence.golden.json +++ b/testdata/conformance/openapi/allof-oneof-cooccurrence.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "AllOfOneOfCooccurrence", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/allof-ref-branch-siblings.golden.json b/testdata/conformance/openapi/allof-ref-branch-siblings.golden.json index 6b66caa3..956d6f20 100644 --- a/testdata/conformance/openapi/allof-ref-branch-siblings.golden.json +++ b/testdata/conformance/openapi/allof-ref-branch-siblings.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "AllOfRefBranchSiblings", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/allof-required-only.golden.json b/testdata/conformance/openapi/allof-required-only.golden.json index e4c56262..cf0c62c1 100644 --- a/testdata/conformance/openapi/allof-required-only.golden.json +++ b/testdata/conformance/openapi/allof-required-only.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "AllOfRequiredOnly", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/anyof-untagged.golden.json b/testdata/conformance/openapi/anyof-untagged.golden.json index 54f450c8..8292b3fb 100644 --- a/testdata/conformance/openapi/anyof-untagged.golden.json +++ b/testdata/conformance/openapi/anyof-untagged.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "AnyOfUntagged", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/callbacks.golden.json b/testdata/conformance/openapi/callbacks.golden.json index c6805e16..bbfb2a52 100644 --- a/testdata/conformance/openapi/callbacks.golden.json +++ b/testdata/conformance/openapi/callbacks.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "Callbacks", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/codeclared-keywords.golden.json b/testdata/conformance/openapi/codeclared-keywords.golden.json index 3987666d..5684ff4f 100644 --- a/testdata/conformance/openapi/codeclared-keywords.golden.json +++ b/testdata/conformance/openapi/codeclared-keywords.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "CoDeclaredKeywords", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/codeclared-schema-content.golden.json b/testdata/conformance/openapi/codeclared-schema-content.golden.json index abb44b28..576ebe9f 100644 --- a/testdata/conformance/openapi/codeclared-schema-content.golden.json +++ b/testdata/conformance/openapi/codeclared-schema-content.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "CoDeclaredSchemaContent", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/component-reuse.golden.json b/testdata/conformance/openapi/component-reuse.golden.json index 6df36c28..275a3e6c 100644 --- a/testdata/conformance/openapi/component-reuse.golden.json +++ b/testdata/conformance/openapi/component-reuse.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "Component Reuse", "version": "1", "docs": {}, diff --git a/testdata/conformance/openapi/constraints.golden.json b/testdata/conformance/openapi/constraints.golden.json index a4dfba39..72bdb7da 100644 --- a/testdata/conformance/openapi/constraints.golden.json +++ b/testdata/conformance/openapi/constraints.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "Constraints", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/content-vocabulary.golden.json b/testdata/conformance/openapi/content-vocabulary.golden.json index e546670a..99e5e849 100644 --- a/testdata/conformance/openapi/content-vocabulary.golden.json +++ b/testdata/conformance/openapi/content-vocabulary.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "ContentVocabulary", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/defaults.golden.json b/testdata/conformance/openapi/defaults.golden.json index 21afdf2d..d43a88dd 100644 --- a/testdata/conformance/openapi/defaults.golden.json +++ b/testdata/conformance/openapi/defaults.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "Defaults", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/dependent-required.golden.json b/testdata/conformance/openapi/dependent-required.golden.json index b3bc85af..67461d5f 100644 --- a/testdata/conformance/openapi/dependent-required.golden.json +++ b/testdata/conformance/openapi/dependent-required.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "DependentRequired", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/deprecation.golden.json b/testdata/conformance/openapi/deprecation.golden.json index fe0e51e7..a391e92f 100644 --- a/testdata/conformance/openapi/deprecation.golden.json +++ b/testdata/conformance/openapi/deprecation.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "Deprecation", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/dialect-keywords.golden.json b/testdata/conformance/openapi/dialect-keywords.golden.json index 188cdf10..ec80c1ec 100644 --- a/testdata/conformance/openapi/dialect-keywords.golden.json +++ b/testdata/conformance/openapi/dialect-keywords.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "DialectKeywords", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/discriminator-default-mapping.golden.json b/testdata/conformance/openapi/discriminator-default-mapping.golden.json index c23ba2a8..1f92bd32 100644 --- a/testdata/conformance/openapi/discriminator-default-mapping.golden.json +++ b/testdata/conformance/openapi/discriminator-default-mapping.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "DiscriminatorDefaultMapping", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/discriminator-inheritance.golden.json b/testdata/conformance/openapi/discriminator-inheritance.golden.json index 25b7a29e..51be5c98 100644 --- a/testdata/conformance/openapi/discriminator-inheritance.golden.json +++ b/testdata/conformance/openapi/discriminator-inheritance.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "DiscriminatorInheritance", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/discriminator-transitive.golden.json b/testdata/conformance/openapi/discriminator-transitive.golden.json index 9dc8b491..41ec0101 100644 --- a/testdata/conformance/openapi/discriminator-transitive.golden.json +++ b/testdata/conformance/openapi/discriminator-transitive.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "DiscriminatorTransitive", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/docs-summary-desc.golden.json b/testdata/conformance/openapi/docs-summary-desc.golden.json index 13f00eb9..3c74ae82 100644 --- a/testdata/conformance/openapi/docs-summary-desc.golden.json +++ b/testdata/conformance/openapi/docs-summary-desc.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "DocsSummaryDesc", "version": "1.0.0", "docs": { diff --git a/testdata/conformance/openapi/dynamic-ref.golden.json b/testdata/conformance/openapi/dynamic-ref.golden.json index b3a48790..76629038 100644 --- a/testdata/conformance/openapi/dynamic-ref.golden.json +++ b/testdata/conformance/openapi/dynamic-ref.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "DynamicRef", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/empty-enum.golden.json b/testdata/conformance/openapi/empty-enum.golden.json index db586997..a086c539 100644 --- a/testdata/conformance/openapi/empty-enum.golden.json +++ b/testdata/conformance/openapi/empty-enum.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "EmptyEnum", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/empty-names.golden.json b/testdata/conformance/openapi/empty-names.golden.json index 7687808f..81bf0d1e 100644 --- a/testdata/conformance/openapi/empty-names.golden.json +++ b/testdata/conformance/openapi/empty-names.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "Empty Names", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/encoding-byte.golden.json b/testdata/conformance/openapi/encoding-byte.golden.json index c06231ca..32c40e81 100644 --- a/testdata/conformance/openapi/encoding-byte.golden.json +++ b/testdata/conformance/openapi/encoding-byte.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "EncodingByte", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/enum-numeric.golden.json b/testdata/conformance/openapi/enum-numeric.golden.json index 63f63719..c7a4f36b 100644 --- a/testdata/conformance/openapi/enum-numeric.golden.json +++ b/testdata/conformance/openapi/enum-numeric.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "EnumNumeric", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/enum-string.golden.json b/testdata/conformance/openapi/enum-string.golden.json index a32a5d76..be79a795 100644 --- a/testdata/conformance/openapi/enum-string.golden.json +++ b/testdata/conformance/openapi/enum-string.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "EnumString", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/examples.golden.json b/testdata/conformance/openapi/examples.golden.json index 041b4fe6..e27de8b0 100644 --- a/testdata/conformance/openapi/examples.golden.json +++ b/testdata/conformance/openapi/examples.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "Examples", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/extension-promotion.golden.json b/testdata/conformance/openapi/extension-promotion.golden.json index d4d0c4ee..bce6ca4d 100644 --- a/testdata/conformance/openapi/extension-promotion.golden.json +++ b/testdata/conformance/openapi/extension-promotion.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "ExtensionPromotion", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/extensions-x.golden.json b/testdata/conformance/openapi/extensions-x.golden.json index 14e03bee..9fa8c9db 100644 --- a/testdata/conformance/openapi/extensions-x.golden.json +++ b/testdata/conformance/openapi/extensions-x.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "ExtensionsX", "version": "1.0.0", "docs": { diff --git a/testdata/conformance/openapi/file-body.golden.json b/testdata/conformance/openapi/file-body.golden.json index 61c17561..a6c05e96 100644 --- a/testdata/conformance/openapi/file-body.golden.json +++ b/testdata/conformance/openapi/file-body.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "FileBody", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/header-content-schema.golden.json b/testdata/conformance/openapi/header-content-schema.golden.json index 98b99569..17f188ec 100644 --- a/testdata/conformance/openapi/header-content-schema.golden.json +++ b/testdata/conformance/openapi/header-content-schema.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "HeaderContentSchema", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/http-binding.golden.json b/testdata/conformance/openapi/http-binding.golden.json index d8d1d27b..bf05b03e 100644 --- a/testdata/conformance/openapi/http-binding.golden.json +++ b/testdata/conformance/openapi/http-binding.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "HTTPBinding", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/inline-annotations.golden.json b/testdata/conformance/openapi/inline-annotations.golden.json index b2baf68b..8cced158 100644 --- a/testdata/conformance/openapi/inline-annotations.golden.json +++ b/testdata/conformance/openapi/inline-annotations.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "InlineAnnotations", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/inline-hoist-positions.golden.json b/testdata/conformance/openapi/inline-hoist-positions.golden.json index 4eb9d3a6..daf8c126 100644 --- a/testdata/conformance/openapi/inline-hoist-positions.golden.json +++ b/testdata/conformance/openapi/inline-hoist-positions.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "InlineHoistPositions", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/inline-residue.golden.json b/testdata/conformance/openapi/inline-residue.golden.json index 1234994e..18d33627 100644 --- a/testdata/conformance/openapi/inline-residue.golden.json +++ b/testdata/conformance/openapi/inline-residue.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "InlineResidue", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/inline-types.golden.json b/testdata/conformance/openapi/inline-types.golden.json index 83a952ba..85284ddf 100644 --- a/testdata/conformance/openapi/inline-types.golden.json +++ b/testdata/conformance/openapi/inline-types.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "InlineTypes", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/literal-const.golden.json b/testdata/conformance/openapi/literal-const.golden.json index 7280b9ff..326b7fab 100644 --- a/testdata/conformance/openapi/literal-const.golden.json +++ b/testdata/conformance/openapi/literal-const.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "LiteralConst", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/maps.golden.json b/testdata/conformance/openapi/maps.golden.json index cd629ff0..b48216f5 100644 --- a/testdata/conformance/openapi/maps.golden.json +++ b/testdata/conformance/openapi/maps.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "Maps", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/multi-content.golden.json b/testdata/conformance/openapi/multi-content.golden.json index 9d381bd0..e8c34703 100644 --- a/testdata/conformance/openapi/multi-content.golden.json +++ b/testdata/conformance/openapi/multi-content.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "MultiContent", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/multipart-encoding.golden.json b/testdata/conformance/openapi/multipart-encoding.golden.json index 3dae8aa3..8a8b3a7b 100644 --- a/testdata/conformance/openapi/multipart-encoding.golden.json +++ b/testdata/conformance/openapi/multipart-encoding.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "MultipartEncoding", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/named-types.golden.json b/testdata/conformance/openapi/named-types.golden.json index 7123f7ce..d5a74962 100644 --- a/testdata/conformance/openapi/named-types.golden.json +++ b/testdata/conformance/openapi/named-types.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "NamedTypes", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/negation-not.golden.json b/testdata/conformance/openapi/negation-not.golden.json index 8ed7e749..f9a83cf2 100644 --- a/testdata/conformance/openapi/negation-not.golden.json +++ b/testdata/conformance/openapi/negation-not.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "NegationNot", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/neutral-naming.golden.json b/testdata/conformance/openapi/neutral-naming.golden.json index a071dd27..9048f238 100644 --- a/testdata/conformance/openapi/neutral-naming.golden.json +++ b/testdata/conformance/openapi/neutral-naming.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "Neutral.Naming API", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/nullability-conjunction.golden.json b/testdata/conformance/openapi/nullability-conjunction.golden.json index 1e5b80cb..b037630a 100644 --- a/testdata/conformance/openapi/nullability-conjunction.golden.json +++ b/testdata/conformance/openapi/nullability-conjunction.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "NullabilityConjunction", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/nullability-four-states.golden.json b/testdata/conformance/openapi/nullability-four-states.golden.json index 2e5c8e0e..e7afd83b 100644 --- a/testdata/conformance/openapi/nullability-four-states.golden.json +++ b/testdata/conformance/openapi/nullability-four-states.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "NullabilityFourStates", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/nullable-30.golden.json b/testdata/conformance/openapi/nullable-30.golden.json index 9a9ff955..fa2d5aa6 100644 --- a/testdata/conformance/openapi/nullable-30.golden.json +++ b/testdata/conformance/openapi/nullable-30.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "Nullable30", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/nullable-31-ref.golden.json b/testdata/conformance/openapi/nullable-31-ref.golden.json index 52d16ecc..f5d09659 100644 --- a/testdata/conformance/openapi/nullable-31-ref.golden.json +++ b/testdata/conformance/openapi/nullable-31-ref.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "Nullable31Ref", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/nullable-enum-31.golden.json b/testdata/conformance/openapi/nullable-enum-31.golden.json index ec8ce1a3..ae844257 100644 --- a/testdata/conformance/openapi/nullable-enum-31.golden.json +++ b/testdata/conformance/openapi/nullable-enum-31.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "NullableEnum31", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/numeric-precision.golden.json b/testdata/conformance/openapi/numeric-precision.golden.json index 44769852..7bcfa82c 100644 --- a/testdata/conformance/openapi/numeric-precision.golden.json +++ b/testdata/conformance/openapi/numeric-precision.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "NumericPrecision", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/oneof-discriminated.golden.json b/testdata/conformance/openapi/oneof-discriminated.golden.json index 71452e1f..d920c667 100644 --- a/testdata/conformance/openapi/oneof-discriminated.golden.json +++ b/testdata/conformance/openapi/oneof-discriminated.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "OneOfDiscriminated", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/param-querystring.golden.json b/testdata/conformance/openapi/param-querystring.golden.json index 84e1774f..75c7acd0 100644 --- a/testdata/conformance/openapi/param-querystring.golden.json +++ b/testdata/conformance/openapi/param-querystring.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "ParamQuerystring", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/param-ref-inheritance.golden.json b/testdata/conformance/openapi/param-ref-inheritance.golden.json index 86e43bf1..18a92c97 100644 --- a/testdata/conformance/openapi/param-ref-inheritance.golden.json +++ b/testdata/conformance/openapi/param-ref-inheritance.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "ParamRefInheritance", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/param-style-matrix.golden.json b/testdata/conformance/openapi/param-style-matrix.golden.json index de4ecae0..d0447371 100644 --- a/testdata/conformance/openapi/param-style-matrix.golden.json +++ b/testdata/conformance/openapi/param-style-matrix.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "ParamStyleMatrix", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/param-styles.golden.json b/testdata/conformance/openapi/param-styles.golden.json index b7edbc64..988eb8c4 100644 --- a/testdata/conformance/openapi/param-styles.golden.json +++ b/testdata/conformance/openapi/param-styles.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "ParamStyles", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/param-xml-residue.golden.json b/testdata/conformance/openapi/param-xml-residue.golden.json index d7c5c761..7165cc68 100644 --- a/testdata/conformance/openapi/param-xml-residue.golden.json +++ b/testdata/conformance/openapi/param-xml-residue.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "ParamXMLResidue", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/path-item-docs.golden.json b/testdata/conformance/openapi/path-item-docs.golden.json index a991f588..6e716dce 100644 --- a/testdata/conformance/openapi/path-item-docs.golden.json +++ b/testdata/conformance/openapi/path-item-docs.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "PathItemDocs", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/path-item-operations.golden.json b/testdata/conformance/openapi/path-item-operations.golden.json index 225c171e..afab297a 100644 --- a/testdata/conformance/openapi/path-item-operations.golden.json +++ b/testdata/conformance/openapi/path-item-operations.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "PathItemOperations", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/per-status-errors.golden.json b/testdata/conformance/openapi/per-status-errors.golden.json index efa04676..837a6bf5 100644 --- a/testdata/conformance/openapi/per-status-errors.golden.json +++ b/testdata/conformance/openapi/per-status-errors.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "PerStatusErrors", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/readonly-writeonly.golden.json b/testdata/conformance/openapi/readonly-writeonly.golden.json index 7e634806..a7b8b4b1 100644 --- a/testdata/conformance/openapi/readonly-writeonly.golden.json +++ b/testdata/conformance/openapi/readonly-writeonly.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "ReadOnlyWriteOnly", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/recursive.golden.json b/testdata/conformance/openapi/recursive.golden.json index 3606bef5..68e30e3b 100644 --- a/testdata/conformance/openapi/recursive.golden.json +++ b/testdata/conformance/openapi/recursive.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "Recursive", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/response-links.golden.json b/testdata/conformance/openapi/response-links.golden.json index ce5b8816..cfd16069 100644 --- a/testdata/conformance/openapi/response-links.golden.json +++ b/testdata/conformance/openapi/response-links.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "ResponseLinks", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/scalar-format.golden.json b/testdata/conformance/openapi/scalar-format.golden.json index 1141c1ae..2296f455 100644 --- a/testdata/conformance/openapi/scalar-format.golden.json +++ b/testdata/conformance/openapi/scalar-format.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "ScalarFormat", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/security-or-and.golden.json b/testdata/conformance/openapi/security-or-and.golden.json index b706a2ba..32f0cdf8 100644 --- a/testdata/conformance/openapi/security-or-and.golden.json +++ b/testdata/conformance/openapi/security-or-and.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "SecurityOrAnd", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/security-schemes.golden.json b/testdata/conformance/openapi/security-schemes.golden.json index 54f7fec2..478b68e5 100644 --- a/testdata/conformance/openapi/security-schemes.golden.json +++ b/testdata/conformance/openapi/security-schemes.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "SecuritySchemes", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/sequential-media.golden.json b/testdata/conformance/openapi/sequential-media.golden.json index 6870e6c8..56f146b5 100644 --- a/testdata/conformance/openapi/sequential-media.golden.json +++ b/testdata/conformance/openapi/sequential-media.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "SequentialMedia", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/servers-variables.golden.json b/testdata/conformance/openapi/servers-variables.golden.json index bef48a37..b697383b 100644 --- a/testdata/conformance/openapi/servers-variables.golden.json +++ b/testdata/conformance/openapi/servers-variables.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "ServersVariables", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/streaming-media-30.golden.json b/testdata/conformance/openapi/streaming-media-30.golden.json index 7217798f..8de5b786 100644 --- a/testdata/conformance/openapi/streaming-media-30.golden.json +++ b/testdata/conformance/openapi/streaming-media-30.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "StreamingMedia30", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/streaming-media-31.golden.json b/testdata/conformance/openapi/streaming-media-31.golden.json index 3e71332a..8899db35 100644 --- a/testdata/conformance/openapi/streaming-media-31.golden.json +++ b/testdata/conformance/openapi/streaming-media-31.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "StreamingMedia31", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/tags-grouping.golden.json b/testdata/conformance/openapi/tags-grouping.golden.json index b227b55b..d558fa85 100644 --- a/testdata/conformance/openapi/tags-grouping.golden.json +++ b/testdata/conformance/openapi/tags-grouping.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "TagsGrouping", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/tuples-prefixitems.golden.json b/testdata/conformance/openapi/tuples-prefixitems.golden.json index 2978752d..ee8d6657 100644 --- a/testdata/conformance/openapi/tuples-prefixitems.golden.json +++ b/testdata/conformance/openapi/tuples-prefixitems.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "TuplesPrefixItems", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/unhomed-keywords.golden.json b/testdata/conformance/openapi/unhomed-keywords.golden.json index 47aa1661..802cc835 100644 --- a/testdata/conformance/openapi/unhomed-keywords.golden.json +++ b/testdata/conformance/openapi/unhomed-keywords.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "UnhomedKeywords", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/webhooks.golden.json b/testdata/conformance/openapi/webhooks.golden.json index 30a109d7..2e2c9fbe 100644 --- a/testdata/conformance/openapi/webhooks.golden.json +++ b/testdata/conformance/openapi/webhooks.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "Webhooks", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/xml-hints.golden.json b/testdata/conformance/openapi/xml-hints.golden.json index f4695e53..e674c8e0 100644 --- a/testdata/conformance/openapi/xml-hints.golden.json +++ b/testdata/conformance/openapi/xml-hints.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "XMLHints", "version": "1.0.0", "docs": {}, diff --git a/testdata/conformance/openapi/yaml-timestamp-scalars.golden.json b/testdata/conformance/openapi/yaml-timestamp-scalars.golden.json index 6d02d49e..9875b55b 100644 --- a/testdata/conformance/openapi/yaml-timestamp-scalars.golden.json +++ b/testdata/conformance/openapi/yaml-timestamp-scalars.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "YamlTimestampScalars", "version": "1.0.0", "docs": {}, diff --git a/testdata/golden/openapi/petstore.golden.json b/testdata/golden/openapi/petstore.golden.json index 7a1b9f33..65affeb4 100644 --- a/testdata/golden/openapi/petstore.golden.json +++ b/testdata/golden/openapi/petstore.golden.json @@ -1,5 +1,5 @@ { - "irVersion": "0.3.0", + "irVersion": "0.4.0", "name": "Petstore", "version": "1.0.0", "docs": {},