diff --git a/CLAUDE.md b/CLAUDE.md index 90c39030..3fdbb701 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,8 @@ compilers/ Layer 1 — the Compiler contract and the format-keyed registry. Im internal/ Layer 1 — that compiler's own packages, each with its own allowlist rather than the compiler's. The ordering among them is real and enforced, from diag (reaches only ir) up to operation, document and detect. None may reach the compiler above it. + e2e/ Test files only, so outside the layering: that compiler's end-to-end tests, the + conformance corpus and goldens among them, compiled through its public face. pass/ Layer 1 — IR → IR passes. Imports ir and ir/irfacts. emitters/* Layer 2 — imports ir, ir/irfacts + emitter contract; never compiler. (Not built yet.) engine/ Layer 3 — orchestration; imports everything below. It owns every read, write and @@ -205,11 +207,12 @@ These all exist already — extend them rather than building a parallel mechanis `internal/jsonshape` by `internal/census`; `go test ./cmd/morphic-census` holds the file to a census of `testdata/`. A case that sets a fact takes its line out, so its change runs `-update`. - **Lowering specification**: `docs/compilers/openapi.md` is machine-read like the matrix. Each rule - names its evidence, and the `TestLoweringSpec` tests in `compilers/openapi` hold the names to the - code both ways, each listed code to the rule's text, and every `docs/compilers/openapi.md#` - citation to a rule. It, `docs/parser-library.md` and each format's conformance `matrix.md` are - read through `internal/keydoc`, the one reader of the repository's keyed documents, and a test a - Markdown document names must exist (`internal/archtest`). + names its evidence, and the `TestLoweringSpec` tests in `compilers/openapi/e2e` hold the names to + the code both ways, each listed code to the rule's text, and every + `docs/compilers/openapi.md#` citation to a rule. It, `docs/parser-library.md` and each + format's conformance `matrix.md` are read through `internal/keydoc`, the one reader of the + repository's keyed documents, and a test a Markdown document names must exist + (`internal/archtest`). - **Oracles**: `internal/harness` drives a spec through no-panic → no error diagnostic → `irverify` invariants → JSON round-trip → determinism → order-invariance, stopping at the first one that fires. `harness.Check` is the list — read it there rather than trusting this sentence; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 26fd470f..50207854 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,31 @@ Read the documents for your layer, and skip the rest until you need them. | The IR | `ir-design.md`, `architecture.md` §2.5, `prior-art.md` | `reference-learnings.md` | | An emitter (none exists yet) | [`emitter-design.md`](docs/emitter-design.md), `architecture.md` §2.3 to §2.5 | `prior-art.md` | +### Where a test goes + +- **A unit test sits beside the code it tests,** in its package, in the `_test.go` named after the + file that declares what the test's name says it tests, as `TestTypes_RegisterTakesNoCoordinate`, + of `Types` in `compilers/compile/types.go`, sits in `types_test.go`. Once a file's tests split by + topic, each part keeps that prefix, as `load_entry_internal_test.go` does beside `load.go`. A + scenario several files of a package decide together goes in a file named for the scenario, as + `pass/validate_propids_test.go` is, a check over a whole package in one named for what it checks, + as `ir/nofloat_test.go` is, and in a package of one source file a test file needs no prefix. + *Fails if missed:* `make coverage`, for a test in another package than its code, since coverage + counts only a package's own tests. Nothing fails for a test in the wrong file of its package. +- **An end-to-end test of the OpenAPI compiler goes in `compilers/openapi/e2e`,** in a file named + for the construct or the property it checks: a test of what the compiler does as a whole, which no + one package decides, such as the conformance corpus, the goldens, fuzzing, benchmarks and a + scenario several packages settle together. A test of one package's code stays in that package even + when it compiles a whole spec through `openapi.New` to reach the code, as `internal/schema`'s + `parseFull` tests do, since only there does it count toward that package's coverage. The e2e + package holds no code, so its tests cover nothing, and the code they reach still needs a unit test + beside it; `compilers/openapi` itself holds only the tests of its own files. A helper several of + the package's files share is in its `helpers_test.go`, except the corpus's own reader, which + `conformance_test.go` declares, and a fixture the files of one construct share, which stays with + that construct. + *Fails if missed:* `make coverage`, for code only an end-to-end test reaches. Nothing fails for an + end-to-end test written in the compiler's own package or in one of its internal packages. + ## Add a lowering rule to the OpenAPI compiler A lowering rule says how one OpenAPI construct reaches the IR: the field it fills, what it keeps in @@ -74,20 +99,21 @@ A lowering rule says how one OpenAPI construct reaches the IR: the field it fill `TestDocComments_StayUnderTheCap`, `TestSuppressions_NoDirectiveStandsInTheSource`. 4. **Add a conformance case and a unit test.** Write a minimal spec at `testdata/conformance/openapi/.yaml` that exercises the rule, add an entry to - `conformanceCases()` in `compilers/openapi/conformance_test.go` naming its assertion, list it + `conformanceCases()` in `compilers/openapi/e2e/conformance_test.go` naming its assertion, list it under each [`ir-spec-matrix.md`](docs/ir-spec-matrix.md) row it witnesses in `testdata/conformance/openapi/matrix.md` (none, for a case that pins only a diagnostic), write - the assertion, and name the case in the rule's Cases field. Then write its golden alone, since + the assertion in the `conformance__test.go` of its construct, and name the case in the + rule's Cases field. Then write its golden alone, since a run that also reaches `TestConformance_TableNamesEveryCorpusSpec` reads the directory before the golden exists: ```bash - go test ./compilers/openapi -run '^TestConformance$/^$' -update + go test ./compilers/openapi/e2e -run '^TestConformance$/^$' -update ``` - Coverage is counted per package, and the case runs in `compilers/openapi`, so it covers nothing - under `internal/`: the code you wrote there also needs a test in its own package, which the - rule's Tests field then names. + Coverage is counted per package, and the case runs in `compilers/openapi/e2e`, which holds no + code of its own, so it covers nothing: the code you wrote also needs a test in its own package, + which the rule's Tests field then names. *Fails if missed:* `TestConformance_TableNamesEveryCorpusSpec` for a spec with no entry, `TestConformance/` for a golden that does not match, `TestLoweringSpec_CasesAreTheCorpus` for a case no rule names, `TestConformance_EveryRegisteredFormatIsHeldToItsColumn` for a name in @@ -97,7 +123,7 @@ A lowering rule says how one OpenAPI construct reaches the IR: the field it fill 5. **Regenerate what the change moves, and read the diff.** Other goldens may change too: ```bash - go test ./compilers/openapi -run 'TestGolden|TestConformance' -update + go test ./compilers/openapi/e2e -run 'TestGolden|TestConformance' -update git status --short testdata/ ``` diff --git a/README.md b/README.md index b031dc23..89d0574e 100644 --- a/README.md +++ b/README.md @@ -316,13 +316,13 @@ Run a single test with `go test ./ir -run TestName`. Beyond that: and review the diff: ```bash - go test ./compilers/openapi -run 'TestGolden|TestConformance' -update + go test ./compilers/openapi/e2e -run 'TestGolden|TestConformance' -update ``` - **Conformance corpus.** Each case in `testdata/conformance/openapi/` is a minimal spec that witnesses one or more rows of the [spec capability matrix](docs/ir-spec-matrix.md). To add one, write `.yaml`, add an entry to `conformanceCases()` in - `compilers/openapi/conformance_test.go` naming its assertion, list it under the matrix rows it + `compilers/openapi/e2e/conformance_test.go` naming its assertion, list it under the matrix rows it witnesses in `testdata/conformance/openapi/matrix.md`, then run the command above to write `.golden.json`, and name the case in the rule of the [OpenAPI lowering specification](docs/compilers/openapi.md) it pins. The suite fails on a spec @@ -342,7 +342,7 @@ Run a single test with `go test ./ir -run TestName`. Beyond that: it reads the IR's declarations through the go command. - **Fuzzing.** `make fuzz` runs every fuzz target for 10s each (`make fuzz FUZZTIME=5m` for a longer search). One target runs on its own with, for example, - `go test ./compilers/openapi -run '^$' -fuzz '^FuzzCompile$' -fuzztime 1m`. + `go test ./compilers/openapi/e2e -run '^$' -fuzz '^FuzzCompile$' -fuzztime 1m`. ## Design docs diff --git a/cmd/morphic/edgecases_test.go b/cmd/morphic/main_edge_cases_test.go similarity index 98% rename from cmd/morphic/edgecases_test.go rename to cmd/morphic/main_edge_cases_test.go index 6f109000..8f5b16ca 100644 --- a/cmd/morphic/edgecases_test.go +++ b/cmd/morphic/main_edge_cases_test.go @@ -163,8 +163,8 @@ func missingParentDest(t *testing.T) string { } // readOnlyDirDest returns a writable file inside a read-only directory — the -// shape TestWriteCompiled_ReadOnlyDirFails pins, where the destination itself -// could be rewritten but its directory will not take the temp file. +// shape atomicfile.TestReplace_ReadOnlyDirFails pins, where the destination +// itself could be rewritten but its directory will not take the temp file. func readOnlyDirDest(t *testing.T) string { t.Helper() if os.Geteuid() == 0 { diff --git a/cmd/morphic/explain_test.go b/cmd/morphic/main_explain_test.go similarity index 100% rename from cmd/morphic/explain_test.go rename to cmd/morphic/main_explain_test.go diff --git a/compilers/compile/diags_test.go b/compilers/compile/diags_test.go new file mode 100644 index 00000000..09739e4d --- /dev/null +++ b/compilers/compile/diags_test.go @@ -0,0 +1,70 @@ +package compile_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers/compile" + "github.com/dexpace/morphic/ir" +) + +// TestDiags_DedupesByFullIdentity pins that the collapse is by identity and +// nothing coarser: two findings differing in any one field both survive. +func TestDiags_DedupesByFullIdentity(t *testing.T) { + t.Parallel() + base := ir.Diagnostic{ + Severity: ir.SeverityError, + Code: "x/y", + Message: "m", + Provenance: ir.Provenance{Source: 0, Pointer: "/p"}, + } + differs := map[string]func(d *ir.Diagnostic){ + "severity": func(d *ir.Diagnostic) { d.Severity = ir.SeverityInfo }, + "code": func(d *ir.Diagnostic) { d.Code = "x/z" }, + "message": func(d *ir.Diagnostic) { d.Message = "other" }, + "source": func(d *ir.Diagnostic) { d.Provenance.Source = 1 }, + "pointer": func(d *ir.Diagnostic) { d.Provenance.Pointer = "/q" }, + "inferredBy": func(d *ir.Diagnostic) { d.Provenance.Inferred = "guess" }, + "position line": func(d *ir.Diagnostic) { d.Provenance.Position.Line = 5 }, + "position column": func(d *ir.Diagnostic) { d.Provenance.Position.Column = 5 }, + "node": func(d *ir.Diagnostic) { d.Provenance.Node = "op/x" }, + } + for name, mutate := range differs { + t.Run(name, func(t *testing.T) { + t.Parallel() + var d compile.Diags + d.Append(base) + d.Append(base) + require.Equal(t, 1, d.Len(), "an identical repeat collapses") + + other := base + mutate(&other) + d.Append(other) + assert.Equal(t, 2, d.Len(), "a diagnostic differing in %s is a distinct finding", name) + }) + } +} + +func TestDiags_AppendAllPreservesFirstSeenOrder(t *testing.T) { + t.Parallel() + mk := func(code string) ir.Diagnostic { + return ir.Diagnostic{Severity: ir.SeverityError, Code: code} + } + var d compile.Diags + d.AppendAll([]ir.Diagnostic{mk("a"), mk("b"), mk("a"), mk("c")}) + + got := make([]string, 0, d.Len()) + for _, x := range d.List() { + got = append(got, x.Code) + } + assert.Equal(t, []string{"a", "b", "c"}, got) +} + +func TestDiags_ZeroValueIsUsable(t *testing.T) { + t.Parallel() + var d compile.Diags + assert.Zero(t, d.Len()) + assert.Empty(t, d.List()) +} diff --git a/compilers/compile/compile_test.go b/compilers/compile/types_test.go similarity index 85% rename from compilers/compile/compile_test.go rename to compilers/compile/types_test.go index df37c016..9f4f1d35 100644 --- a/compilers/compile/compile_test.go +++ b/compilers/compile/types_test.go @@ -146,65 +146,6 @@ func TestTypes_RegistryIsTheLiveMap(t *testing.T) { assert.Contains(t, types.String(), "types: 1") } -// TestDiags_DedupesByFullIdentity pins that the collapse is by identity and -// nothing coarser: two findings differing in any one field both survive. -func TestDiags_DedupesByFullIdentity(t *testing.T) { - t.Parallel() - base := ir.Diagnostic{ - Severity: ir.SeverityError, - Code: "x/y", - Message: "m", - Provenance: ir.Provenance{Source: 0, Pointer: "/p"}, - } - differs := map[string]func(d *ir.Diagnostic){ - "severity": func(d *ir.Diagnostic) { d.Severity = ir.SeverityInfo }, - "code": func(d *ir.Diagnostic) { d.Code = "x/z" }, - "message": func(d *ir.Diagnostic) { d.Message = "other" }, - "source": func(d *ir.Diagnostic) { d.Provenance.Source = 1 }, - "pointer": func(d *ir.Diagnostic) { d.Provenance.Pointer = "/q" }, - "inferredBy": func(d *ir.Diagnostic) { d.Provenance.Inferred = "guess" }, - "position line": func(d *ir.Diagnostic) { d.Provenance.Position.Line = 5 }, - "position column": func(d *ir.Diagnostic) { d.Provenance.Position.Column = 5 }, - "node": func(d *ir.Diagnostic) { d.Provenance.Node = "op/x" }, - } - for name, mutate := range differs { - t.Run(name, func(t *testing.T) { - t.Parallel() - var d compile.Diags - d.Append(base) - d.Append(base) - require.Equal(t, 1, d.Len(), "an identical repeat collapses") - - other := base - mutate(&other) - d.Append(other) - assert.Equal(t, 2, d.Len(), "a diagnostic differing in %s is a distinct finding", name) - }) - } -} - -func TestDiags_AppendAllPreservesFirstSeenOrder(t *testing.T) { - t.Parallel() - mk := func(code string) ir.Diagnostic { - return ir.Diagnostic{Severity: ir.SeverityError, Code: code} - } - var d compile.Diags - d.AppendAll([]ir.Diagnostic{mk("a"), mk("b"), mk("a"), mk("c")}) - - got := make([]string, 0, d.Len()) - for _, x := range d.List() { - got = append(got, x.Code) - } - assert.Equal(t, []string{"a", "b", "c"}, got) -} - -func TestDiags_ZeroValueIsUsable(t *testing.T) { - t.Parallel() - var d compile.Diags - assert.Zero(t, d.Len()) - assert.Empty(t, d.List()) -} - // TestTypes_RefusesEntriesTheRegistryCannotHold covers the boundary this type // exists to hold. It owns the registry's invariants, so it is also the one place // able to manufacture the malformed states irverify and pass.Validate report — diff --git a/compilers/configure_test.go b/compilers/compilers_configure_test.go similarity index 100% rename from compilers/configure_test.go rename to compilers/compilers_configure_test.go diff --git a/compilers/openapi/budgets_test.go b/compilers/openapi/budgets_test.go index f8ba0afe..26444fee 100644 --- a/compilers/openapi/budgets_test.go +++ b/compilers/openapi/budgets_test.go @@ -25,6 +25,10 @@ import ( "github.com/dexpace/morphic/ir" ) +// amplificationBombFixture is a document whose YAML aliases expand it to many +// times its own size, which no alias budget admits. +const amplificationBombFixture = "../../testdata/openapi/amplification_alias_bomb.yaml" + // enumSpec is a minimal document whose one component declares a string enum of // n members. The specs these tests need differ only in how large they are, and // building them here is what keeps a multi-megabyte fixture out of the corpus. diff --git a/compilers/openapi/conformance_test.go b/compilers/openapi/conformance_test.go deleted file mode 100644 index 1f710a89..00000000 --- a/compilers/openapi/conformance_test.go +++ /dev/null @@ -1,4659 +0,0 @@ -// This file is a package-level suite, not a per-source-file test: it runs the -// capability-conformance corpus (one minimal spec per ir-spec-matrix.md row) -// through the whole compiler to keep "lossless by default" honest, so it has no -// single source file to pair with. -package openapi_test // external test package — exercises only the public API - -import ( - "encoding/json/jsontext" - "encoding/json/v2" - "maps" - "os" - "path/filepath" - "reflect" - "slices" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/dexpace/morphic/compilers" - "github.com/dexpace/morphic/compilers/openapi" - "github.com/dexpace/morphic/compilers/openapi/internal/diag" - "github.com/dexpace/morphic/compilers/openapi/internal/openapitest" - "github.com/dexpace/morphic/ir" - "github.com/dexpace/morphic/ir/irtest" - "github.com/dexpace/morphic/pass" -) - -// conformanceDir is the corpus of one minimal spec per capability row of -// ir-spec-matrix.md, addressed relative to this test file. -const conformanceDir = "../../testdata/conformance/openapi" - -// witnessDoc is the file in the corpus directory that names the matrix rows each -// spec witnesses. It is no spec, and internal/conformance reads it. -const witnessDoc = "matrix.md" - -// TestConformance drives one minimal spec per OpenAPI-expressible capability -// through the full compiler and asserts lossless capture: a focused -// capability-specific assertion plus a byte-exact golden IR snapshot. Regenerate -// the goldens with `go test ./compilers/openapi -run TestConformance -update`. -// -// What the corpus does not cover is derived, not described, so nothing about -// its reach is claimed by hand: TestConformance_UnwitnessedIRFields snapshots -// every ir field no committed spec drives to a non-zero value. Some of what it -// lists no spec can reach, such as a field only another source format writes. -func TestConformance(t *testing.T) { - t.Parallel() - for _, tc := range conformanceCases() { - t.Run(tc.file, func(t *testing.T) { - t.Parallel() - doc, diags := parseCorpus(t, tc.file) - assertOnlyErrorDiags(t, diags, invalidCases()[tc.file]) - tc.assert(t, doc, diags) - // A failed capability assertion must not reach the golden. require - // aborts, but assert does not, and under -update a non-fatal failure - // would rewrite the snapshot with the regressed document — leaving a - // loud run behind a file that now agrees with the regression. - require.False(t, t.Failed(), "capability assertion failed; not comparing or rewriting the golden") - irtest.CompareGolden(t, filepath.Join(conformanceDir, tc.file+".golden.json"), doc) - assertJSONRoundTrip(t, doc) - }) - } -} - -// TestConformance_TableNamesEveryCorpusSpec requires the table and the corpus -// directory to name the same specs. Both directions matter: a spec with no row -// gets neither a capability assertion nor a golden while the corpus-wide sweeps -// (dangling references, the fuzz seed, the census of unwitnessed facts) still -// read it, so it looks covered; a row naming a deleted spec fails the other -// way. Comparing sorted lists rather than sets also catches a spec named by two -// rows. -func TestConformance_TableNamesEveryCorpusSpec(t *testing.T) { - t.Parallel() - onDisk := corpusSpecNames(t) - cases := conformanceCases() - inTable := make([]string, 0, len(cases)) - for _, tc := range cases { - inTable = append(inTable, tc.file) - } - slices.Sort(onDisk) - slices.Sort(inTable) - if diff := cmp.Diff(onDisk, inTable); diff != "" { - t.Errorf("corpus and table disagree (-on disk +in table):\n%s", diff) - } -} - -// corpusSpecNames returns the base name of every spec in the corpus directory, -// failing on any file that is neither a .yaml spec, a golden beside one nor the -// witness document. -// -// The .yaml-only restriction is asserted rather than left to a narrower glob. -// parseCorpus reads only ".yaml", so a spec committed as .yml or .json -// could not be named by a table row, yet the census of the corpus that -// cmd/morphic-census holds to its golden reads all three extensions and would -// count its facts as witnessed, leaving it with neither assertion nor golden. -func corpusSpecNames(t *testing.T) []string { - t.Helper() - entries, err := os.ReadDir(conformanceDir) - require.NoError(t, err) - - specs := make([]string, 0, len(entries)) - goldens := make([]string, 0, len(entries)) - for _, e := range entries { - name := e.Name() - require.False(t, e.IsDir(), "corpus directory holds a subdirectory %q; no sweep descends into one", name) - switch { - case name == witnessDoc: // names rows, not a spec - case strings.HasSuffix(name, ".golden.json"): - goldens = append(goldens, strings.TrimSuffix(name, ".golden.json")) - case strings.HasSuffix(name, ".yaml"): - specs = append(specs, strings.TrimSuffix(name, ".yaml")) - default: - t.Errorf("corpus file %q is neither a .yaml spec nor a golden; "+ - "parseCorpus reads only .yaml, so a spec in any other extension gets no table row", name) - } - } - require.NotEmpty(t, specs, "the corpus directory must hold specs") - slices.Sort(specs) - slices.Sort(goldens) - if diff := cmp.Diff(specs, goldens); diff != "" { - t.Errorf("corpus specs and goldens disagree (-specs +goldens):\n%s", diff) - } - return specs -} - -// conformanceCase pairs one corpus spec with the assertion that its capability -// is captured losslessly. -// -// Which ir-spec-matrix.md rows a spec witnesses is data, not a field here: it -// lives in testdata/conformance/openapi/matrix.md, which a test outside this -// package can read. A spec pinning a construct the matrix has no row for, such -// as a dialect keyword, appears under no row there. -type conformanceCase struct { - file string - assert func(*testing.T, *ir.Document, []ir.Diagnostic) -} - -// conformanceCases is the corpus table: one row per spec, each naming the file -// and the assertion that reads it. -func conformanceCases() []conformanceCase { - return []conformanceCase{ - {"named-types", assertNamedTypes}, - {"neutral-naming", assertNeutralNaming}, - {"empty-names", assertEmptyNames}, - {"inline-types", assertInlineTypes}, - {"component-reuse", assertComponentReuse}, - {"shared-response-across-status", assertSharedResponseAcrossStatus}, - {"allof-inheritance", assertAllOfInheritance}, - {"allof-mixins", assertAllOfMixins}, - {"allof-inline-merge", assertAllOfInlineMerge}, - {"allof-required-only", assertAllOfRequiredOnly}, - {"required-members", assertRequiredMembers}, - {"allof-oneof-cooccurrence", assertAllOfOneOfCooccurrence}, - {"allof-inline-residue", assertAllOfInlineResidue}, - {"allof-nested-inline", assertAllOfNestedInline}, - {"allof-ref-branch-siblings", assertAllOfRefBranchSiblings}, - {"allof-boolean-branch", assertAllOfBooleanBranch}, - {"allof-conflicting-type", assertAllOfConflictingType}, - {"allof-narrowing-redeclaration", assertAllOfNarrowingRedeclaration}, - {"allof-position-constraints", assertAllOfPositionConstraints}, - {"oneof-discriminated", assertOneOfDiscriminated}, - {"anyof-discriminated", assertAnyOfDiscriminated}, - {"discriminator-inheritance", assertDiscriminatorInheritance}, - {"discriminator-inline-subtype", assertDiscriminatorInlineSubtype}, - {"discriminator-default-mapping", assertDiscriminatorDefaultMapping}, - {"discriminator-transitive", assertDiscriminatorTransitive}, - {"discriminator-alias-mapping", assertDiscriminatorAliasMapping}, - {"discriminated-union-beside-model", assertDiscriminatedUnionBesideModel}, - {"nullable-union-discriminator", assertNullableUnionDiscriminator}, - {"unhomed-keywords", assertUnhomedKeywords}, - {"union-beside-ref", assertUnionBesideRef}, - {"codeclared-keywords", assertCoDeclaredKeywords}, - {"codeclared-schema-content", assertCoDeclaredSchemaContent}, - {"anyof-untagged", assertAnyOfUntagged}, - {"union-decorated-branch", assertUnionDecoratedBranch}, - {"negation-not", assertNegationNot}, - {"dependent-required", assertDependentRequired}, - {"dialect-keywords", assertDialectKeywords}, - {"dynamic-ref", assertDynamicRef}, - {"schema-identifiers", assertSchemaIdentifiers}, - {"enum-string", assertEnumString}, - {"enum-numeric", assertEnumNumeric}, - {"empty-enum", assertEmptyEnum}, - {"missing-array-items", assertMissingArrayItems}, - {"scalar-format", assertScalarFormat}, - {"secret-format", assertSecretFormat}, - {"encoding-byte", assertEncodingByte}, - {"content-vocabulary", assertContentVocabulary}, - {"xml-hints", assertXMLHints}, - {"nullability-four-states", assertNullabilityFourStates}, - {"nullable-30", assertNullable30}, - {"nullable-31-ref", assertNullable31Ref}, - {"nullable-enum-31", assertNullableEnum31}, - {"nullable-keyword-30", assertNullableKeyword30}, - {"nullable-keyword-31", assertNullableKeyword31}, - {"schema-ref-siblings-30", assertSchemaRefSiblings30}, - {"nullability-conjunction", assertNullabilityConjunction}, - {"null-only-union", assertNullOnlyUnion}, - {"defaults", assertDefaults}, - {"yaml-timestamp-scalars", assertYAMLTimestampScalars}, - {"constraints", assertConstraints}, - {"numeric-precision", assertNumericPrecision}, - {"readonly-writeonly", assertReadOnlyWriteOnly}, - {"recursive", assertRecursive}, - {"maps", assertMaps}, - {"tuples-prefixitems", assertTuples}, - {"literal-const", assertLiteralConst}, - {"tags-grouping", assertTagsGrouping}, - {"group-identity", assertGroupIdentity}, - {"param-identity", assertParamIdentity}, - {"enum-member-identity", assertEnumMemberIdentity}, - {"http-binding", assertHTTPBinding}, - {"param-styles", assertParamStyles}, - {"param-style-matrix", assertParamStyleMatrix}, - {"param-querystring", assertParamQuerystring}, - {"querystring-forbidden-keywords", assertQuerystringForbiddenKeywords}, - {"param-allowreserved-locations", assertParamAllowReservedLocations}, - {laterVersionCase, assertLaterVersionConstructs}, - {"param-xml-residue", assertParamXMLResidue}, - {"param-ref-inheritance", assertParamRefInheritance}, - {"header-content-schema", assertHeaderContentSchema}, - {"multi-content", assertMultiContent}, - {"multipart-encoding", assertMultipartEncoding}, - {"file-body", assertFileBody}, - {"media-type-spelling", assertMediaTypeSpelling}, - {"sequential-media", assertSequentialMedia}, - {"streaming-media-30", assertStreamingMedia30}, - {"streaming-media-31", assertStreamingMedia31}, - {"per-status-errors", assertPerStatusErrors}, - {"default-response", assertDefaultResponse}, - {"response-links", assertResponseLinks}, - {"webhooks", assertWebhooks}, - {"callbacks", assertCallbacks}, - {"inline-hoist-positions", assertInlineHoistPositions}, - {"path-item-docs", assertPathItemDocs}, - {"path-item-operations", assertPathItemOperations}, - {"path-item-anchored-key", assertPathItemAnchoredKey}, - {"yaml-merge-keys", assertYAMLMergeKeys}, - {"path-item-ref-siblings", assertPathItemRefSiblings}, - {"deprecation", assertDeprecation}, - {"extension-promotion", assertExtensionPromotion}, - {"examples", assertExamples}, - {"docs-summary-desc", assertDocsSummaryDesc}, - {"request-body-docs", assertRequestBodyDocs}, - {"ref-site-docs", assertRefSiteDocs}, - {"param-content-fields", assertParamContentFields}, - {"examples-32", assertExamples32}, - {"tags-grouping-32", assertTagsGrouping32}, - {"response-summary-32", assertResponseSummary32}, - {"component-media-types-32", assertComponentMediaTypes32}, - {"xml-nodetype-32", assertXMLNodeType32}, - {"nested-encoding-32", assertNestedEncoding32}, - {"unreferenced-components", assertUnreferencedComponents}, - {"self-uri", assertSelfURI}, - {"extensions-x", assertExtensionsX}, - {"inline-annotations", assertInlineAnnotations}, - {"inline-residue", assertInlineResidue}, - {"servers-variables", assertServersVariables}, - {"security-schemes", assertSecuritySchemes}, - {"oauth2-no-flow", assertOAuth2NoFlow}, - {"empty-required-values", assertEmptyRequiredValues}, - {"relaxed-required-fields", assertRelaxedRequiredFields}, - {"empty-request-content", assertEmptyRequestContent}, - {"oauth-flow-fields", assertOAuthFlowFields}, - {"reached-outside-model", assertReachedOutsideModel}, - {"security-or-and", assertSecurityOrAnd}, - } -} - -// parseCorpus reads and parses one corpus spec through the full compiler. -func parseCorpus(t *testing.T, name string) (*ir.Document, []ir.Diagnostic) { - t.Helper() - data, err := os.ReadFile(filepath.Join(conformanceDir, name+".yaml")) - require.NoError(t, err) - doc, diags, err := openapi.New().Compile(t.Context(), - []compilers.Source{{Path: name + ".yaml", Data: data}}, compilers.Options{}) - require.NoError(t, err) - require.NotNil(t, doc) - return doc, diags -} - -// assertJSONRoundTrip marshals doc, reads it back, and re-marshals, requiring -// the two encodings to be byte-identical — the serialized oracle fuzz_test.go -// and internal/harness already apply, compared as JSON so the unpreservable -// nil-vs-empty-collection distinction is ignored while real loss still shows. -// -// Invariant 7 is asserted per corpus document rather than only on the golden -// one because each spec reaches IR shapes the others never build — a reserved -// map key, a preserved raw payload, a sum-typed node — and only the shapes a -// document contains can round-trip wrongly. -func assertJSONRoundTrip(t *testing.T, doc *ir.Document) { - t.Helper() - first, err := json.Marshal(doc) - require.NoError(t, err) - var back ir.Document - require.NoError(t, json.Unmarshal(first, &back)) - second, err := json.Marshal(&back) - require.NoError(t, err) - assert.Equal(t, string(first), string(second), "round-trip must preserve the document") -} - -// invalidCases lists the corpus specs that write a construct the specification -// forbids, because what they pin is how such a document lowers: each draws -// errors of the codes listed, and of no other. -func invalidCases() map[string][]string { - return map[string][]string{ - // A parameter and a header writing both schema and content: "but not both". - "codeclared-schema-content": {"openapi/validation/validation-mutually-exclusive-fields"}, - } -} - -// assertNoErrorDiags fails when any diagnostic has error severity. -func assertNoErrorDiags(t *testing.T, diags []ir.Diagnostic) { - t.Helper() - assertOnlyErrorDiags(t, diags, nil) -} - -// assertOnlyErrorDiags fails when a diagnostic has error severity and a code -// outside want, or when a code of want draws no error. -func assertOnlyErrorDiags(t *testing.T, diags []ir.Diagnostic, want []string) { - t.Helper() - drawn := map[string]bool{} - for _, d := range diags { - if d.Severity != ir.SeverityError { - continue - } - drawn[d.Code] = true - assert.Contains(t, want, d.Code, "unexpected error diagnostic: %+v", d) - } - for _, code := range want { - assert.True(t, drawn[code], "the spec is listed as invalid but draws no %s", code) - } -} - -// namedID is the stable TypeID of a components-named schema. -func namedID(name string) ir.TypeID { - return ir.TypeID("t/openapi/components/schemas/" + name) -} - -// allOperations flattens every operation across a document's service groups. -func allOperations(doc *ir.Document) []ir.Operation { - var out []ir.Operation - var walk func(gs []ir.OperationGroup) - walk = func(gs []ir.OperationGroup) { - for _, g := range gs { - out = append(out, g.Operations...) - walk(g.Groups) - } - } - for _, svc := range doc.Services { - walk(svc.Groups) - } - return out -} - -// opByName finds an operation by its source operationId. -func opByName(doc *ir.Document, source string) (ir.Operation, bool) { - for _, op := range allOperations(doc) { - if op.Name.Source == source { - return op, true - } - } - return ir.Operation{}, false -} - -// propByWire returns the property of m with the given wire name. -func propByWire(m *ir.Model, wire string) (ir.Property, bool) { - for _, p := range m.Properties { - if p.WireName == wire { - return p, true - } - } - return ir.Property{}, false -} - -// paramByName returns the operation's logical parameter with the given source -// name. -func paramByName(op ir.Operation, name string) (ir.Parameter, bool) { - for _, p := range op.Params { - if p.Name.Source == name { - return p, true - } - } - return ir.Parameter{}, false -} - -func assertNamedTypes(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - user, ok := doc.Types[namedID("User")].(*ir.Model) - require.True(t, ok, "named schema User present under its pointer-derived ID") - assert.False(t, user.Anonymous) - addr, ok := propByWire(user, "address") - require.True(t, ok) - assert.Equal(t, namedID("Address"), addr.Type.Target) - _, ok = doc.Types[namedID("Address")].(*ir.Model) - assert.True(t, ok, "referenced Address resolves in the registry") -} - -// assertNeutralNaming covers invariant 4's second half: Naming.Canonical is a -// neutral word sequence whatever the source spelled the word boundaries as. -// Real specs use dots (namespaced components, versioned fields, enum values), -// brackets (deep-object query parameters) and hyphens (headers), so this spec -// writes each where a segmentation fault would show. Naming.Hint is covered by -// the same spec: its context strings carry the punctuation, and a hint such as -// "rollout.state" is a name no emitter can render (GitHub #54). -func assertNeutralNaming(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - require.Len(t, doc.Services, 1) - svc := doc.Services[0] - assert.Equal(t, "neutral_naming_api", svc.Name.Canonical, "info.title") - require.Len(t, svc.Groups, 1) - assert.Equal(t, "inventory_core", svc.Groups[0].Name.Canonical, "tag") - - op, ok := opByName(doc, "widgets.list") - require.True(t, ok, "operation present under its source operationId") - assert.Equal(t, "widgets_list", op.Name.Canonical, "operationId") - byParam := map[string]string{} - for _, p := range op.Params { - byParam[p.Name.Source] = p.Name.Canonical - } - assert.Equal(t, "widget_id", byParam["widget.id"], "path parameter") - assert.Equal(t, "filter_name", byParam["filter[name]"], "bracketed query parameter") - - widget, ok := doc.Types[namedID("com.example.Widget")].(*ir.Model) - require.True(t, ok, "namespaced component keeps its pointer-derived ID") - assert.Equal(t, "com_example_widget", widget.Name.Canonical, "component name") - assert.Equal(t, "com.example.Widget", widget.Name.Source, "the source spelling is kept beside it") - name, ok := propByWire(widget, "widget.name") - require.True(t, ok) - assert.Equal(t, "widget_name", name.Name.Canonical, "property name") - version, ok := propByWire(widget, "api.version.v2") - require.True(t, ok) - assert.Equal(t, "api_version_v_2", version.Name.Canonical, - "the letter/digit boundary still splits inside a dotted segment") - - assertNeutralNamingLeaves(t, doc, widget, op) -} - -// assertNeutralNamingLeaves checks the three canonicals that hang off another -// node rather than off the service walk: an enum member under a property, a -// header under a response, a scheme in the auth registry. -func assertNeutralNamingLeaves(t *testing.T, doc *ir.Document, widget *ir.Model, op ir.Operation) { - t.Helper() - state, ok := propByWire(widget, "rollout.state") - require.True(t, ok) - rollout, ok := doc.Types[state.Type.Target].(*ir.Enum) - require.True(t, ok, "the enum property hoists an Enum node") - require.NotEmpty(t, rollout.Members) - assert.Equal(t, "in_progress", rollout.Members[0].Name.Canonical, "enum member") - assert.Equal(t, "rollout_state", rollout.Name.Hint, - "the hoisted node's hint is words too, not the property key verbatim") - - require.Len(t, op.Responses, 1) - require.Len(t, op.Responses[0].Headers, 1) - assert.Equal(t, "x_rate_limit", op.Responses[0].Headers[0].Name.Canonical, "response header") - - require.Len(t, doc.Auth, 1) - for _, scheme := range doc.Auth { - assert.Equal(t, "oauth_2_client", scheme.Name.Canonical, "security scheme") - } -} - -// assertEmptyNames is the complement of assertNeutralNaming: every position -// where the source names an entity with the empty string, which is a legal -// OpenAPI document at each of them. The compiler used to pass "" through as a -// Naming with nothing in any channel, leaving an emitter no identifier to render -// and satisfying every neutrality check vacuously (GitHub #251). Each of these -// now carries the minted hint instead, and irverify's presence rule — swept over -// this corpus by TestVerify_Corpus — is what holds the general case. -func assertEmptyNames(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - require.Len(t, doc.Services, 1) - require.Len(t, doc.Services[0].Groups, 1) - assert.Equal(t, "empty", doc.Services[0].Groups[0].Name.Hint, "tag declared as \"\"") - - require.Len(t, doc.Auth, 1) - for _, scheme := range doc.Auth { - assert.Equal(t, "empty", scheme.Name.Hint, "security scheme keyed \"\"") - } - - blank, ok := doc.Types[ir.TypeID("t/anon/components/schemas/")] - require.True(t, ok, "a component schema keyed \"\" is hoisted at its own pointer") - assert.Equal(t, "empty", blank.Common().Name.Hint, "component schema keyed \"\"") - - assertEmptyEnumMember(t, doc) - assertEmptyNamedProperty(t, doc) - assertEmptyDerivedHints(t, doc) -} - -// assertEmptyEnumMember covers the enum member the issue was reported against, -// and beside it the collision the minted word can cause: "" and "empty" are two -// distinct values, and a member named for each lands on the same word. -// -// The IR keeps them apart — two members, two values, one in each name channel — -// because uniquifying identifiers is an emitter's job and always has been: -// "red" and "Red" already canonicalize to the same words. This pins that the -// minted name does not merge or drop a member, which is the only part of the -// collision the IR is answerable for. -func assertEmptyEnumMember(t *testing.T, doc *ir.Document) { - t.Helper() - rollout, ok := doc.Types[namedID("Rollout")].(*ir.Enum) - require.True(t, ok) - require.Len(t, rollout.Members, 3, "no member is merged away by the shared word") - - assert.Equal(t, "empty", rollout.Members[0].Name.Hint, "enum member whose value is \"\"") - assert.Empty(t, rollout.Members[0].Name.Source, "nothing is fabricated into the source channel") - assert.Empty(t, rollout.Members[0].Value.Str, "the value itself is kept as the empty string") - - declared := rollout.Members[1] - assert.Equal(t, "empty", declared.Name.Source, "a member whose value really is \"empty\"") - assert.Equal(t, "empty", declared.Name.Canonical, "named through the declared channels, not the hint") - assert.Empty(t, declared.Name.Hint) - - assert.Equal(t, "done", rollout.Members[2].Name.Source, "the ordinary sibling is unaffected") -} - -// assertEmptyNamedProperty covers the two entities one property keyed "" makes: -// the property, and the inline schema hoisted at its position, whose context -// hint is derived from the same empty name. -func assertEmptyNamedProperty(t *testing.T, doc *ir.Document) { - t.Helper() - widget, ok := doc.Types[namedID("Widget")].(*ir.Model) - require.True(t, ok) - require.Len(t, widget.Properties, 1) - prop := widget.Properties[0] - assert.Equal(t, "empty", prop.Name.Hint, "property keyed \"\"") - assert.Empty(t, prop.WireName, "the wire key itself is still the empty string") - - inline, ok := doc.Types[prop.Type.Target] - require.True(t, ok, "the property's inline object is hoisted") - assert.Equal(t, ir.TypeID("t/anon/components/schemas/Widget/properties/"), prop.Type.Target) - assert.Equal(t, "empty", inline.Common().Name.Hint, - "a hint derived from an empty position is minted, not passed through empty") -} - -// composedHints is every node in the corpus spec whose hint is built out of an -// enclosing node's. Each pairs an unnamed position with the role or index that -// distinguishes the child from its siblings — a list element, a map value, a -// pattern group, a tuple slot or rest, an enum branch, a composed union -// variant. A slice rather than a map so a failure names them in a fixed order. -var composedHints = []struct { - id ir.TypeID - hint string -}{ - {"t/anon/components/schemas/List/properties//items", "empty_item"}, - {"t/anon/components/schemas/Maps/properties//additionalProperties", "empty_value"}, - {"t/anon/components/schemas/Patterns/properties//patternProperties/^x", "empty_pattern"}, - {"t/anon/components/schemas/Tuple/properties//prefixItems/0", "empty_0"}, - {"t/anon/components/schemas/Tuple/properties//items", "empty_item"}, - {"t/anon/components/schemas/Mixed/properties//enum/0", "empty_0"}, - {"t/anon/components/schemas/Mixed/properties//enum/1", "empty_1"}, - {"t/composed/components/schemas/Host/properties//oneOf/0", "empty_alt"}, -} - -// assertEmptyDerivedHints covers hints a child composes from an enclosing -// node's. Concatenating onto an empty hint once yielded a leading "_", which is -// non-empty so irverify's presence check passes it, yet no grammar produces it. -// -// composedHints has no row for contentSchema, which an empty-named property -// reaches (#743). The sequential media type's itemSchema cannot need one: both -// callers of lowerPayload pass a non-empty hint, since ids.ComponentEntry -// refuses a component keyed "" and the fallbacks are "response" and "request". -// A composed variant of an inline branch is absent too: -// docs/compilers/openapi.md#union-beside-structure keeps that union verbatim, -// so only the $ref form the Host case uses reaches composedVariant. -func assertEmptyDerivedHints(t *testing.T, doc *ir.Document) { - t.Helper() - mixed, ok := doc.Types[namedID("Mixed")].(*ir.Model) - require.True(t, ok) - require.Len(t, mixed.Properties, 1) - union, ok := doc.Types[mixed.Properties[0].Type.Target].(*ir.Union) - require.True(t, ok, "a heterogeneous enum lowers to a union of literals") - assert.Equal(t, "empty", union.Name.Hint, "the enclosing node keeps the plain minted hint") - - for _, tc := range composedHints { - node, found := doc.Types[tc.id] - require.True(t, found, "no node at %s; the composition site moved", tc.id) - assert.Equal(t, tc.hint, node.Common().Name.Hint, "composed hint at %s", tc.id) - } -} - -func assertInlineTypes(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - order, ok := doc.Types[namedID("Order")].(*ir.Model) - require.True(t, ok) - require.Len(t, order.Properties, 1) - shipping := order.Properties[0] - // hoisted exactly once, under its pointer-derived anonymous ID. - assert.Equal(t, ir.TypeID("t/anon/components/schemas/Order/properties/shipping"), - shipping.Type.Target) - inline, ok := doc.Types[shipping.Type.Target].(*ir.Model) - require.True(t, ok, "the inline object was hoisted as its own type") - assert.True(t, inline.Anonymous) - assert.Equal(t, "shipping", inline.Name.Hint) -} - -// assertInlineHoistPositions pins the six operation-side positions an inline -// composite can be declared at against the ID each one's source pointer derives. -// Neither inline-types.yaml nor the inlinePositions table in -// compilers/openapi/internal/schema reaches a parameter, response header, -// webhook or callback, which are lowered a layer up. -// -// The referring site must point at the derived ID, which a moved position -// changes. The six targets must also be six distinct nodes, which survives a -// synchronized edit: a hoist collapsing identical bodies and an updated -// expectation would otherwise agree. The fixture writes one body at all six so a -// collapse is reachable. -func assertInlineHoistPositions(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - got := inlineHoistPositionRefs(t, doc) - if diff := cmp.Diff(inlineHoistPositionIDs(), got); diff != "" { - t.Errorf("hoisted node per source position (-want +got):\n%s", diff) - } - - owner := map[ir.TypeID]string{} - for position, id := range got { - assert.NotContains(t, owner, id, - "the %s and %s positions share one node, %s", owner[id], position, id) - owner[id] = position - - node, found := doc.Types[id] - require.True(t, found, "%s: nothing is interned at %s", position, id) - model, ok := node.(*ir.Model) - require.True(t, ok, "%s: the inline object hoisted as a model", position) - assert.True(t, model.Anonymous, "%s: a minted node is anonymous", position) - assert.Len(t, model.Properties, 1, "%s: it kept the body it was declared with", position) - } -} - -// inlineHoistPositionIDs is the ID each position's source pointer derives, -// spelled out so a node that moves to another pointer is a diff rather than a -// silently different document. -func inlineHoistPositionIDs() map[string]ir.TypeID { - const order = "t/anon/paths/~1orders/post" - return map[string]ir.TypeID{ - "parameter schema": order + "/parameters/0/schema", - "response-header schema": order + "/responses/200/headers/X-Order-Trace/schema", - "request-body property": order + "/requestBody/content/application~1json/schema/properties/shipping", - "response-body property": order + "/responses/200/content/application~1json/schema/properties/receipt", - "webhook body": "t/anon/webhooks/onShipped/post/requestBody/content/application~1json/schema", - "callback body": order + "/callbacks/onProgress/{$request.body#~1callbackUrl}" + - "/post/requestBody/content/application~1json/schema", - } -} - -// inlineHoistPositionRefs reads back what each declaring site actually refers -// to. It walks from the operation rather than from doc.Types so a node interned -// at the right ID but wired to nothing still fails. -func inlineHoistPositionRefs(t *testing.T, doc *ir.Document) map[string]ir.TypeID { - t.Helper() - order, ok := opByName(doc, "placeOrder") - require.True(t, ok) - audit, ok := paramByName(order, "audit") - require.True(t, ok, "the operation declares its inline-schema parameter") - require.Len(t, order.Responses, 1) - require.Len(t, order.Responses[0].Headers, 1, "the response declares its inline-schema header") - webhook, ok := opByName(doc, "onShipped") - require.True(t, ok, "the webhook operation is registered") - callback, ok := opByName(doc, "onProgress") - require.True(t, ok, "the callback operation is registered") - - return map[string]ir.TypeID{ - "parameter schema": audit.Type.Target, - "response-header schema": order.Responses[0].Headers[0].Type.Target, - "request-body property": inlinePropTarget(t, doc, openapitest.BodyTarget(t, order.Request), "shipping"), - "response-body property": inlinePropTarget(t, doc, openapitest.BodyTarget(t, order.Responses[0].Payload), "receipt"), - "webhook body": openapitest.BodyTarget(t, webhook.Request), - "callback body": openapitest.BodyTarget(t, callback.Request), - } -} - -// inlinePropTarget returns the type the named property of the model at id refers -// to — the body-property positions, one level inside a body root. -func inlinePropTarget(t *testing.T, doc *ir.Document, id ir.TypeID, wire string) ir.TypeID { - t.Helper() - model, ok := doc.Types[id].(*ir.Model) - require.True(t, ok, "the body at %s hoisted as a model", id) - prop, ok := propByWire(model, wire) - require.True(t, ok, "the body declares the property %q", wire) - return prop.Type.Target -} - -// assertSharedResponseAcrossStatus reads the one shape that puts lowerResponse -// and lowerErrorCase on the same declaration: a components/responses entry -// mounted at both a success and an error status. Both intern the body type at -// the component's pointer, so the two mints race for it and the loser's naming -// hint is discarded, which the order-invariance oracle reports as an -// order-dependent registry. The hint derives from the declaration pointer, the -// same whichever side reaches it first. No other corpus spec reaches one -// response component from both sides, so the oracle needs this one to ask. -func assertSharedResponseAcrossStatus(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - op := operationAt(t, doc, "GET", "/widgets") - require.Len(t, op.Responses, 1, "the success mount") - require.Len(t, op.Errors, 1, "and the error mount, of the one component") - - success := op.Responses[0].Payload.Contents[0].Type.Target - failure := op.Errors[0].Payload.Contents[0].Type.Target - assert.Equal(t, success, failure, "one declaration is one type, reached from either status") - - td, ok := doc.Types[success] - require.True(t, ok) - assert.Equal(t, "envelope", td.Common().Name.Hint, - "the hint comes from the declaration, not from whichever status class minted it first") - - created := operationAt(t, doc, "POST", "/widgets").Responses[0].Payload.Contents[0].Type.Target - rejected := operationAt(t, doc, "POST", "/gadgets").Errors[0].Payload.Contents[0].Type.Target - assert.Equal(t, created, rejected, "a path-pointer $ref reaches the same one type") - td, ok = doc.Types[created] - require.True(t, ok) - assert.Equal(t, "response", td.Common().Name.Hint, - "with no component to name it, both sides fall back to the one word") -} - -// assertComponentReuse covers the non-schema half of `$ref`: OpenAPI lets a -// parameter, requestBody, response, header, callback, or whole path item be -// declared once under components and referenced from many operations. Each -// lowers at its declaration, so the shared node is interned once however many -// operations reach it, while the operations that reach it stay distinct. -func assertComponentReuse(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - widgets := operationAt(t, doc, "GET", "/widgets") - gadgets := operationAt(t, doc, "GET", "/gadgets") - order := operationAt(t, doc, "POST", "/orders") - assert.NotEqual(t, widgets.ID, gadgets.ID, "one path item mounted twice is two operations") - assert.Equal(t, widgets.Provenance.Pointer, gadgets.Provenance.Pointer, - "...both written at the one component that declares them") - - // Every hoisted type in this document belongs to a component declaration: - // no operation contributes a pointer of its own, because none declares a - // schema of its own. - for id, td := range doc.Types { - if td.Common().Provenance.Pointer == "" { - continue // shared primitives are pointerless by construction - } - assert.True(t, strings.HasPrefix(string(td.Common().Provenance.Pointer), "/components/"), - "type %s hoists at a component declaration, not a use site", id) - } - - sortID := ir.TypeID("t/anon/components/parameters/Sort/schema") - require.Len(t, widgets.Params, 1) - require.Len(t, gadgets.Params, 1) - assert.Equal(t, sortID, widgets.Params[0].Type.Target) - assert.Equal(t, sortID, gadgets.Params[0].Type.Target, "the shared parameter interns once") - - listedID := ir.TypeID("t/anon/components/responses/Listed/content/application~1json/schema") - assert.Equal(t, listedID, openapitest.BodyTarget(t, widgets.Responses[0].Payload)) - assert.Equal(t, listedID, openapitest.BodyTarget(t, order.Responses[0].Payload), - "a response reused across unrelated operations interns once") - - bodyID := ir.TypeID("t/anon/components/requestBodies/OrderBody/content/application~1json/schema") - require.NotNil(t, order.Request) - assert.Equal(t, bodyID, openapitest.BodyTarget(t, order.Request)) - assert.Equal(t, "order_body", doc.Types[bodyID].Common().Name.Hint, - "a shared body is named after its component, not the operation that reached it first") - - require.Len(t, widgets.Responses[0].Headers, 1) - header := widgets.Responses[0].Headers[0] - assert.Equal(t, ir.TypeID("t/anon/components/headers/RateUnit/schema"), header.Type.Target) - assert.Equal(t, jsontext.Pointer("/components/responses/Listed/headers/X-Rate-Unit"), header.Provenance.Pointer, - "the header's identity is the map entry that binds its name") - - require.Len(t, order.Bindings.HTTP[0].Callbacks, 1) - assert.Equal(t, "{$request.body#/callbackUrl}", order.Bindings.HTTP[0].Callbacks[0].Expression) - _, ok := opByName(doc, "onShipped") - assert.True(t, ok, "the shared callback's operation is registered alongside its parent") -} - -// operationAt finds the operation bound to one HTTP method and URI template. -// Operations reached through a shared path item have no distinguishing name of -// their own, so their binding is what tells them apart. A webhook binding has -// no URI template, so no webhook is found. -func operationAt(t *testing.T, doc *ir.Document, method, uri string) ir.Operation { - t.Helper() - for _, op := range allOperations(doc) { - for _, hb := range op.Bindings.HTTP { - if hb.Method == method && hb.URITemplate == uri { - return op - } - } - } - t.Fatalf("no operation bound to %s %s", method, uri) - return ir.Operation{} -} - -func assertAllOfInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - d, ok := doc.Types[namedID("Derived")].(*ir.Model) - require.True(t, ok) - require.NotNil(t, d.Base, "sole allOf $ref becomes Base") - assert.Equal(t, namedID("Base"), d.Base.Target) - assert.Empty(t, d.Mixins) - _, ok = propByWire(d, "extra") - assert.True(t, ok, "property declared alongside allOf survives") - // Model-level detail declared alongside allOf must be captured, not dropped. - assert.Equal(t, "A Base specialized with one extra field.", d.Docs.Description, - "composed schema keeps its description") - assert.NotNil(t, d.Deprecation, "composed schema keeps deprecated") - assert.Equal(t, ir.AdditionalClosed, d.Additional, - "composed schema keeps additionalProperties: false") - assert.Contains(t, d.Unmodeled, "openapi:x-team", "composed schema keeps x-* extensions") -} - -func assertAllOfMixins(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - c, ok := doc.Types[namedID("C")].(*ir.Model) - require.True(t, ok) - assert.Nil(t, c.Base, "multiple non-hierarchy refs stay Mixins, none is Base") - require.Len(t, c.Mixins, 2) - assert.Equal(t, namedID("A"), c.Mixins[0].Target) - assert.Equal(t, namedID("B"), c.Mixins[1].Target) -} - -func assertAllOfInlineMerge(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("Merged")].(*ir.Model) - require.True(t, ok) - require.NotNil(t, m.Base) - assert.Equal(t, namedID("Base"), m.Base.Target) - _, ok = propByWire(m, "name") - assert.True(t, ok, "inline allOf branch contributes its properties") -} - -// assertAllOfNestedInline pins the flattening of -// docs/compilers/openapi.md#allof-classification: a nested spelling classifies -// as the flat one does and keeps each property's own pointer. A wrapper writing -// nothing else is kept nowhere; one that also writes a keyword is kept whole, -// with one diagnostic. -func assertAllOfNestedInline(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - m, ok := doc.Types[namedID("M")].(*ir.Model) - require.True(t, ok) - assert.Nil(t, m.Base, "two refs and no hierarchy: none is Base") - require.Len(t, m.Mixins, 2) - assert.Equal(t, namedID("A"), m.Mixins[0].Target) - assert.Equal(t, namedID("B"), m.Mixins[1].Target) - assert.Empty(t, m.Unmodeled, "the wrapper is flattened, not kept verbatim") - - n, ok := doc.Types[namedID("Nested2")].(*ir.Model) - require.True(t, ok) - require.NotNil(t, n.Base, "the sole ref two wrappers down is the Base") - assert.Equal(t, namedID("A"), n.Base.Target) - c, ok := propByWire(n, "c") - require.True(t, ok) - assert.Equal(t, jsontext.Pointer("/components/schemas/Nested2/allOf/0/allOf/1/properties/c"), - c.Provenance.Pointer) - - mixed, ok := doc.Types[namedID("Mixed")].(*ir.Model) - require.True(t, ok) - mc, ok := propByWire(mixed, "c") - require.True(t, ok) - assert.True(t, mc.Required, "the wrapper's required list reaches the property inside it") - wires := make([]string, 0, len(mixed.Properties)) - for _, p := range mixed.Properties { - wires = append(wires, p.WireName) - } - assert.Equal(t, []string{"c", "w"}, wires, "the wrapper's own property merges after its branches'") - - closed, ok := doc.Types[namedID("Closed")].(*ir.Model) - require.True(t, ok) - assert.Equal(t, ir.AdditionalClosed, closed.Additional, "a false inside a wrapper still closes") - assert.Contains(t, closed.Unmodeled, "openapi:allOf/0/allOf/0") - assert.NotContains(t, closed.Unmodeled, "openapi:allOf/0") - - described, ok := doc.Types[namedID("Described")].(*ir.Model) - require.True(t, ok) - assert.Nil(t, described.Base) - require.Len(t, described.Mixins, 2, "the wrapper's refs compose despite its description") - assert.Equal(t, namedID("A"), described.Mixins[0].Target) - assert.Equal(t, namedID("B"), described.Mixins[1].Target) - assert.Contains(t, described.Unmodeled, "openapi:allOf/0", "the wrapper is kept whole") - - for _, id := range []ir.TypeID{"t/composed/components/schemas/U/oneOf/0", "t/composed/components/schemas/U/oneOf/1"} { - variant, ok := doc.Types[id].(*ir.Model) - require.True(t, ok, "%s should be a composed variant", id) - assert.Equal(t, ir.AdditionalClosed, variant.Additional, "%s: a false nested beside a oneOf closes every variant", id) - assert.Contains(t, variant.Unmodeled, "openapi:allOf/0/allOf/2/allOf/0", "%s keeps the false at its path", id) - a, ok := propByWire(variant, "a") - require.True(t, ok, id) - assert.True(t, a.Required, "%s: the wrapper's required list reaches the variant's property", id) - } - - for _, d := range diags { - if d.Code == diag.DegradedConstruct && d.Provenance.Pointer == "/components/schemas/Described/allOf/0" { - continue // the one kept wrapper - } - assert.NotEqual(t, diag.DegradedConstruct, d.Code, "flattening is exact: %+v", d) - } -} - -func assertAllOfRequiredOnly(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - d, ok := doc.Types[namedID("Derived")].(*ir.Model) - require.True(t, ok) - require.NotNil(t, d.Base, "the $ref branch still becomes Base") - name, ok := propByWire(d, "name") - require.True(t, ok, "the inline branch's own property survives") - assert.True(t, name.Required, - "a required-only allOf branch attaches across the whole composition, not just its own properties map (issue #29)") -} - -// assertRequiredMembers pins where a `required` name lands when no property of -// the object it is written for declares it (ir-design §4.3): in Model.Requires, -// once, beside the Required on the properties it does name. The four shapes are -// four ways an object lowers: a plain object, an allOf making a property its -// base declares required, an object whose catch-all governs the name, which -// stays a Model rather than becoming a map since a map requires no key, and the -// object variant of a multi-typed schema. -func assertRequiredMembers(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - plain, ok := doc.Types[namedID("Plain")].(*ir.Model) - require.True(t, ok) - id, ok := propByWire(plain, "id") - require.True(t, ok) - assert.True(t, id.Required, "a declared name is Required on its property") - assert.Equal(t, []string{"ghost"}, plain.Requires, "an undeclared one is listed, and the declared one is not") - - created, ok := doc.Types[namedID("CreatedUser")].(*ir.Model) - require.True(t, ok) - require.NotNil(t, created.Base) - assert.Equal(t, namedID("User"), created.Base.Target) - assert.Empty(t, created.Properties, "the base declares id, so the composed model has no property for it") - assert.Equal(t, []string{"id"}, created.Requires, "the inherited property it makes required") - user, ok := doc.Types[namedID("User")].(*ir.Model) - require.True(t, ok) - assert.Empty(t, user.Requires, "the base is not made to require what only its subtype does") - userID, ok := propByWire(user, "id") - require.True(t, ok) - assert.False(t, userID.Required, "and its own property stays optional") - - tagged, ok := doc.Types[namedID("Tagged")].(*ir.Model) - require.True(t, ok, "a required name beside a catch-all keeps the object a Model, not a map") - assert.NotNil(t, tagged.AdditionalProps) - assert.Empty(t, tagged.Properties) - assert.Equal(t, []string{"a"}, tagged.Requires, "required only because the catch-all governs it") - _, isMap := ir.AsMap(tagged) - assert.False(t, isMap, "AsMap keeps a model that requires a member a Model") - - either, ok := doc.Types[namedID("Either")].(*ir.Union) - require.True(t, ok) - var object *ir.Model - for _, v := range either.Variants { - if m, isModel := doc.Types[v.Type.Target].(*ir.Model); isModel { - object = m - } - } - require.NotNil(t, object, "the object variant is a Model") - variantID, ok := propByWire(object, "id") - require.True(t, ok) - assert.True(t, variantID.Required) - assert.Equal(t, []string{"extra"}, object.Requires, "the variant lists what it declares nowhere") -} - -// assertAllOfOneOfCooccurrence pins both halves of the co-declared composition -// rule: §4.3 distributes a union whose branches all name referents, and the -// rule docs/compilers/openapi.md#union-beside-structure keeps one with an -// inline branch verbatim rather than distributing it halfway. The verbatim half -// is covered over a model body, which owns a node already, and over a scalar -// one, which does not — there the alias hoisted for the union carries what the -// position wrote beside it too. The outside reference to a branch pointer pins -// the third thing: a composed variant is Morphic's own node, so it cannot be -// taken by, or take from, a $ref. -func assertAllOfOneOfCooccurrence(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - combo, ok := doc.Types[namedID("Combo")].(*ir.Union) - require.True(t, ok, "the union is the value, not a preserved sibling") - require.Len(t, combo.Variants, 2) - for i, branch := range []string{"A", "B"} { - v, ok := doc.Types[combo.Variants[i].Type.Target].(*ir.Model) - require.True(t, ok, "variant %d composes as a model", i) - require.NotNil(t, v.Base, "the allOf composition rides on every variant") - assert.Equal(t, namedID("Base"), v.Base.Target) - require.Len(t, v.Mixins, 1) - assert.Equal(t, namedID(branch), v.Mixins[0].Target) - require.NotNil(t, v.Constraints, "the enclosing schema's own minProperties rides on every variant too (GitHub #407)") - require.NotNil(t, v.Constraints.MinProps) - assert.Equal(t, int64(2), *v.Constraints.MinProps) - } - - mixed, ok := doc.Types[namedID("MixedKinds")].(*ir.Model) - require.True(t, ok, "the structural body survives") - entry, ok := mixed.Unmodeled["openapi:oneOf"] - require.True(t, ok, "and the union it could not absorb survives beside it") - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - - bounded, ok := doc.Types[namedID("BoundedKinds")].(*ir.Scalar) - require.True(t, ok, "a body that is not a model reduces to a shared primitive and hoists an alias") - entry, ok = bounded.Unmodeled["openapi:oneOf"] - require.True(t, ok, "which is the node the kept union sits on") - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - require.NotNil(t, bounded.Constraints, "and the bounds written beside the union sit on it too") - require.NotNil(t, bounded.Constraints.MinLength) - assert.Equal(t, int64(3), *bounded.Constraints.MinLength) - - outsider, ok := doc.Types[namedID("Outsider")].(*ir.Model) - require.True(t, ok) - require.Len(t, outsider.Properties, 1) - branchNode, ok := doc.Types[outsider.Properties[0].Type.Target].(*ir.Scalar) - require.True(t, ok, "the branch pointer hoists an alias of what the branch references") - require.NotNil(t, branchNode.Base) - assert.Equal(t, namedID("A"), branchNode.Base.Target, - "the reference gets the branch schema, never the variant composed for that branch") - - assertInlineBranchHint(t, doc) -} - -// assertInlineBranchHint pins the hint an inline composition branch takes when -// an outside reference names its pointer too. The union reaches that node -// through its composition, and the reference builds it (hoistSubSchema) only -// where nothing else did, under the hint the composition recorded. An inline -// branch has only its position to be named by, and the reference path once used -// the bare ordinal "0" (GitHub #181). The value is asserted because the golden -// records whichever hint won without saying the two agree, and the corpus -// carries the shape so the two-order oracle covers it. -func assertInlineBranchHint(t *testing.T, doc *ir.Document) { - t.Helper() - const branchID = ir.TypeID("t/anon/components/schemas/InlineHost/oneOf/0") - - host, ok := doc.Types[namedID("InlineHost")].(*ir.Union) - require.True(t, ok, "the inline union is the value") - require.Len(t, host.Variants, 2) - assert.Equal(t, branchID, host.Variants[0].Type.Target, - "the union's first variant is the node the branch pointer owns") - - branch, ok := doc.Types[branchID] - require.True(t, ok, "the branch pointer owns a node") - assert.Equal(t, "variant_0", branch.Common().Name.Hint, - "named by position, which is all an inline branch has to be named by") - - outsider, ok := doc.Types[namedID("InlineOutsider")].(*ir.Model) - require.True(t, ok) - require.Len(t, outsider.Properties, 1) - assert.Equal(t, branchID, outsider.Properties[0].Type.Target, - "the outside reference resolves to that same node rather than hoisting a second") -} - -// assertAllOfPositionConstraints pins GitHub #407: a value constraint written -// beside an allOf composing position reaches the same ir.Model.Constraints -// field a plain object's does, rather than falling to Unmodeled under a -// diagnostic that claims the node has no home for it — the node demonstrably -// does, since ViaModel's sibling proves the field exists and is read. -func assertAllOfPositionConstraints(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - viaModel, ok := doc.Types[namedID("ViaModel")].(*ir.Model) - require.True(t, ok) - require.NotNil(t, viaModel.Constraints) - require.NotNil(t, viaModel.Constraints.MinProps) - assert.Equal(t, int64(2), *viaModel.Constraints.MinProps) - - viaAllOf, ok := doc.Types[namedID("ViaAllOf")].(*ir.Model) - require.True(t, ok) - require.NotNil(t, viaAllOf.Base, "the sole allOf $ref still becomes Base") - assert.Equal(t, namedID("Base"), viaAllOf.Base.Target) - require.NotNil(t, viaAllOf.Constraints, "the composing position's own minProperties reaches the model it built, exactly as a plain object's does") - require.NotNil(t, viaAllOf.Constraints.MinProps) - assert.Equal(t, int64(2), *viaAllOf.Constraints.MinProps) - assert.NotContains(t, viaAllOf.Unmodeled, "openapi:minProperties", - "minProperties has a home now, so it is not also kept verbatim") - assert.Empty(t, diagsAt(diags, diag.DegradedConstruct, "/components/schemas/ViaAllOf"), - "and no diagnostic claims the node has no home for it") -} - -func assertOneOfDiscriminated(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - pet, ok := doc.Types[namedID("Pet")].(*ir.Union) - require.True(t, ok, "oneOf survives as a Union node, never collapsed") - assert.True(t, pet.Exclusive) - assert.True(t, pet.WireTagged, "the discriminator's tag names the variant") - require.Len(t, pet.Variants, 2) - require.NotNil(t, pet.Discriminator) - assert.Equal(t, "petType", pet.Discriminator.PropertyName) - assert.Equal(t, namedID("Cat"), pet.Discriminator.Mapping["cat"]) - assert.Equal(t, namedID("Dog"), pet.Discriminator.Mapping["dog"]) -} - -// assertAnyOfDiscriminated pins that the tag mode and Exclusive are independent -// (ir-design §4.4): a discriminator on an anyOf tags the union without making -// it exclusive, since a payload may still satisfy both branches. -func assertAnyOfDiscriminated(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - u, ok := doc.Types[namedID("Resource")].(*ir.Union) - require.True(t, ok, "anyOf survives as a Union node, never collapsed") - assert.False(t, u.Exclusive, "anyOf admits a payload both branches satisfy") - assert.True(t, u.WireTagged, "the discriminator's tag names the variant") - require.Len(t, u.Variants, 2) - require.NotNil(t, u.Discriminator) - assert.Equal(t, "kind", u.Discriminator.PropertyName) - assert.Equal(t, namedID("Named"), u.Discriminator.Mapping["named"]) - assert.Equal(t, namedID("Labelled"), u.Discriminator.Mapping["labelled"]) -} - -// assertDiscriminatorInheritance covers the model-hierarchy form of a -// discriminator, which the union form (oneof-discriminated) does not reach: the -// tag is declared on the base, each subtype composes the base by $ref, and the -// wire value each subtype answers to is recorded on the subtype. -// -// Both spellings of that value are pinned, because they come from different -// places: a subtype the base's mapping names takes the mapping key, and one the -// mapping omits takes OpenAPI's implicit schema name. -func assertDiscriminatorInheritance(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - pet, ok := doc.Types[namedID("Pet")].(*ir.Model) - require.True(t, ok, "the base is a Model, not a Union") - require.NotNil(t, pet.Discriminator) - tag, ok := propByWire(pet, "petType") - require.True(t, ok, "the base declares the tag property itself") - assert.Equal(t, tag.ID, pet.Discriminator.Property, - "a declared tag property binds by identity, not by wire name") - assert.Empty(t, pet.Discriminator.PropertyName, - "the name spelling is for a tag no property declares") - assert.Equal(t, namedID("Cat"), pet.Discriminator.Mapping["cat"]) - - for name, value := range map[string]string{"Cat": "cat", "Dog": "Dog"} { - sub, ok := doc.Types[namedID(name)].(*ir.Model) - require.True(t, ok, "%s composes as a Model", name) - require.NotNil(t, sub.Base, "the discriminated base becomes Base, not a Mixin") - assert.Equal(t, namedID("Pet"), sub.Base.Target) - assert.Equal(t, value, sub.DiscriminatorValue) - assert.Nil(t, sub.Discriminator, "a subtype does not restate its base's discriminator") - } -} - -// assertDiscriminatorInlineSubtype covers the subtypes OpenAPI's implicit -// mapping cannot name (GitHub #517). The implicit tag is the name of the -// component schema that declares a subtype, so one declared inline — as a -// property or a union branch — takes no tag, while it still composes the -// discriminated base. Kennel's Dog shares its key with the component Dog on -// purpose: a tag read off the pointer gives both the same value. -func assertDiscriminatorInlineSubtype(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - pet, ok := doc.Types[namedID("Pet")].(*ir.Model) - require.True(t, ok, "the base is a Model") - require.NotNil(t, pet.Discriminator) - assert.Empty(t, pet.Discriminator.Mapping, "every tag in this document is implicit") - - for _, tc := range []struct { - id ir.TypeID - value string - }{ - {namedID("Dog"), "Dog"}, - {"t/anon/components/schemas/Kennel/properties/Dog", ""}, - {"t/anon/components/schemas/Kennel/properties/pet", ""}, - {"t/anon/components/schemas/Either/oneOf/0", ""}, - } { - sub, ok := doc.Types[tc.id].(*ir.Model) - require.True(t, ok, "%s composes as a Model", tc.id) - require.NotNil(t, sub.Base, "%s composes the discriminated base", tc.id) - assert.Equal(t, namedID("Pet"), sub.Base.Target) - assert.Equal(t, tc.value, sub.DiscriminatorValue, - "%s: only a component schema has a name to answer to", tc.id) - } -} - -// assertDiscriminatorTransitive covers a hierarchy deeper than the two levels -// discriminator-inheritance reaches: the subtype's own allOf branch names an -// intermediate schema that declares no discriminator, so the tag value can only -// come from an ancestor further up (GitHub #305). What each subtype composes is -// pinned beside its tag, since depth is the point. The undiscriminated chain -// beside them is the boundary: depth alone must not manufacture a value. Puppy -// is named by the mapping, routing a wire value straight onto a grandchild; -// Whelp is named by nothing and answers to its own schema name three hops down. -func assertDiscriminatorTransitive(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - pet, ok := doc.Types[namedID("Pet")].(*ir.Model) - require.True(t, ok, "the root of the hierarchy is a Model") - require.NotNil(t, pet.Discriminator) - assert.Equal(t, namedID("Dog"), pet.Discriminator.Mapping["dog"]) - assert.Equal(t, namedID("Puppy"), pet.Discriminator.Mapping["puppy"], - "the base maps a wire value straight onto its grandchild") - - // base is the schema each subtype's own allOf branch names, which is the only - // thing a one-hop reading of the chain could see. - for _, tc := range []struct{ name, base, value string }{ - {"Dog", "Pet", "dog"}, - {"Puppy", "Dog", "puppy"}, - {"Whelp", "Puppy", "Whelp"}, - } { - sub, ok := doc.Types[namedID(tc.name)].(*ir.Model) - require.True(t, ok, "%s composes as a Model", tc.name) - require.NotNil(t, sub.Base, "%s composes a base", tc.name) - assert.Equal(t, namedID(tc.base), sub.Base.Target, - "%s composes %s, not the schema anchoring the hierarchy", tc.name, tc.base) - assert.Equal(t, tc.value, sub.DiscriminatorValue, - "%s answers to the tag its ancestor spells for it", tc.name) - assert.Nil(t, sub.Discriminator, "a subtype does not restate the hierarchy's discriminator") - } - - for _, name := range []string{"Shrub", "Sapling"} { - sub, ok := doc.Types[namedID(name)].(*ir.Model) - require.True(t, ok, "%s composes as a Model", name) - require.NotNil(t, sub.Base, "%s composes a base", name) - assert.Empty(t, sub.DiscriminatorValue, - "%s has no discriminated ancestor, so walking the chain finds no tag", name) - } -} - -// assertDiscriminatorAliasMapping covers a mapping that spells two keys for one -// subtype (GitHub #410). Model.DiscriminatorValue holds one value and a mapping -// is unordered, so the subtype takes the smallest key in byte order whichever -// was written first — the fixture writes the larger first — while the base's -// mapping keeps both, and the election is reported at the subtype as -// information since nothing is lost. -func assertDiscriminatorAliasMapping(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - pet, ok := doc.Types[namedID("Pet")].(*ir.Model) - require.True(t, ok, "the base is a Model") - require.NotNil(t, pet.Discriminator) - assert.Equal(t, map[string]ir.TypeID{"alpha": namedID("Dog"), "zulu": namedID("Dog")}, - pet.Discriminator.Mapping, "the base keeps every key") - - dog, ok := doc.Types[namedID("Dog")].(*ir.Model) - require.True(t, ok, "Dog composes as a Model") - assert.Equal(t, "alpha", dog.DiscriminatorValue, - "the smallest key in byte order, not the first written") - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/Dog"), - "the election is reported at the subtype: %+v", diags) -} - -// assertDiscriminatorDefaultMapping pins Discriminator.Default, whose only source -// is the 3.2 discriminator.defaultMapping — and with it that a 3.2-only schema -// keyword compiles without an error diagnostic (GitHub #146). The library checks -// every schema object against the 3.1 meta-schema whatever the document says, so -// before this the spec below failed to compile and the field had no witness. -func assertDiscriminatorDefaultMapping(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - assert.Empty(t, diagsAt(diags, "openapi/validation/validation-invalid-schema", - "/components/schemas/Pet/discriminator/defaultMapping"), - "a 3.2 keyword in a 3.2 document is not an invalid schema") - - pet, ok := doc.Types[namedID("Pet")].(*ir.Model) - require.True(t, ok) - require.NotNil(t, pet.Discriminator) - assert.Equal(t, namedID("Dog"), pet.Discriminator.Default, - "defaultMapping names the variant an unrecognized tag falls back to") - assert.Equal(t, namedID("Cat"), pet.Discriminator.Mapping["cat"], - "and it is read separately from the mapping") -} - -// assertDiscriminatedUnionBesideModel pins where a discriminator goes when its -// union is kept verbatim beside a model. Pet's and Animal's routes are not -// subtypes of them, so pass.Validate would reject the discriminator on the -// model; it moves whole to Unmodeled beside the union. Vehicle's object -// variant holds one the same way. Shape's routes are true allOf subtypes, so -// the model keeps it, and the document validates clean. -func assertDiscriminatedUnionBesideModel(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - assert.Empty(t, pass.Validate(doc)) - - for _, name := range []string{"Pet", "Animal"} { - m, ok := doc.Types[namedID(name)].(*ir.Model) - require.True(t, ok, name) - assert.Nil(t, m.Discriminator, "%s routes to a non-subtype, so the model does not carry it", name) - entry, ok := m.Unmodeled["openapi:discriminator"] - require.True(t, ok, "%s keeps it verbatim instead", name) - assert.Equal(t, ir.ReasonDegradedLowering, entry.Reason) - assert.Contains(t, m.Unmodeled, "openapi:oneOf", "beside the union it routes through") - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/"+name+"/discriminator")) - } - _, isUnion := doc.Types[namedID("Bird")].(*ir.Union) - assert.True(t, isUnion, "Bird writes allOf to Animal but lowers to a union, which is no subtype") - - vehicle, ok := doc.Types[namedID("Vehicle")].(*ir.Union) - require.True(t, ok, "a multi-typed body lowers to a union") - assert.Contains(t, vehicle.Unmodeled, "openapi:discriminator", "kept beside the union it was written for") - variant, ok := doc.Types["t/anon/components/schemas/Vehicle/type/0"].(*ir.Model) - require.True(t, ok) - assert.Nil(t, variant.Discriminator, "the object variant does not carry it") - - shape, ok := doc.Types[namedID("Shape")].(*ir.Model) - require.True(t, ok) - require.NotNil(t, shape.Discriminator, "every target is a subtype, so it stays") - assert.Equal(t, namedID("Circle"), shape.Discriminator.Mapping["circle"]) - assert.NotContains(t, shape.Unmodeled, "openapi:discriminator") -} - -// assertNullableUnionDiscriminator pins that a discriminator beside a oneOf or -// anyOf that collapses to nullable X is kept verbatim, with a diagnostic, -// where the collapse leaves no node with a field for it. -func assertNullableUnionDiscriminator(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - maybe, ok := doc.Types[namedID("MaybeCat")].(*ir.Scalar) - require.True(t, ok, "the component is an alias over the nullable reference") - assert.Equal(t, ir.TypeRef{Target: namedID("Cat"), Nullable: true}, *maybe.Base) - assert.Contains(t, maybe.Unmodeled, "openapi:discriminator") - assert.True(t, openapitest.HasDiagCodeAt(diags, diag.DegradedConstruct, "/components/schemas/MaybeCat")) - - owner, ok := doc.Types[namedID("Owner")].(*ir.Model) - require.True(t, ok) - require.Len(t, owner.Properties, 1) - assert.Equal(t, ir.TypeRef{Target: namedID("Cat"), Nullable: true}, owner.Properties[0].Type) - assert.Contains(t, owner.Properties[0].Unmodeled, "openapi:discriminator", "the property carries it") -} - -func assertAnyOfUntagged(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - u, ok := doc.Types[namedID("StringOrNumber")].(*ir.Union) - require.True(t, ok) - assert.False(t, u.Exclusive, "anyOf is a non-exclusive union") - assert.False(t, u.WireTagged, "with no discriminator, validation infers the variant") - assert.Nil(t, u.Discriminator) - assert.Len(t, u.Variants, 2) -} - -// assertUnionDecoratedBranch pins a union branch annotated the only way 3.0 -// can, by wrapping its $ref (GitHub #1018). The wrapper lowers to a decorator -// over Pet that keeps the description, so no information is lost, and its -// variant is named for Pet while the node keeps the positional hint. -func assertUnionDecoratedBranch(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - u, ok := doc.Types[namedID("U")].(*ir.Union) - require.True(t, ok) - require.Len(t, u.Variants, 2) - assert.Equal(t, "user", u.Variants[0].Name.Hint) - assert.Equal(t, "pet", u.Variants[1].Name.Hint, "named for the $ref it wraps, as 3.1's sibling spelling is") - - branch, ok := doc.Types[u.Variants[1].Type.Target].(*ir.Model) - require.True(t, ok, "the wrapped branch is a node of its own") - base, decorates := ir.Decorated(branch) - require.True(t, decorates, "a description beside the wrapped $ref is an annotation, not a shape") - assert.Equal(t, namedID("Pet"), base.Target) - assert.Equal(t, "a pet", branch.Docs.Description, "the annotation is kept") - assert.Equal(t, "variant_1", branch.Name.Hint, "the node keeps the positional hint") -} - -func assertNegationNot(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - m, ok := doc.Types[namedID("NotFoo")].(*ir.Model) - require.True(t, ok) - raw, ok := m.Unmodeled["openapi:not"] - require.True(t, ok, "not-keyword kept verbatim under Unmodeled") - assert.JSONEq(t, `{"required":["b"]}`, string(raw.Value)) - assert.Equal(t, ir.ReasonValidationOnly, raw.Reason) - var found bool - for _, d := range diags { - if d.Code == "openapi/validation-only-keyword" { - found = true - assert.Equal(t, ir.SeverityInfo, d.Severity) - } - } - assert.True(t, found, "expected a validation-only-keyword info diagnostic") -} - -// assertEnumString pins a string enum and its one-member form, which is an -// Enum as well: an `enum` keyword lowers to an Enum whatever its member count, -// and only `const` lowers to a Literal (ir-design §4.5). -func assertEnumString(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - e, ok := doc.Types[namedID("Color")].(*ir.Enum) - require.True(t, ok) - assert.Equal(t, ir.PrimString, e.ValueType) - assert.True(t, e.Closed) - require.Len(t, e.Members, 3) - assert.Equal(t, "red", e.Members[0].Value.Str) - assert.Equal(t, "blue", e.Members[2].Value.Str) - - pinned, ok := doc.Types[namedID("Pinned")].(*ir.Enum) - require.True(t, ok, "a one-member enum is an Enum; got %T", doc.Types[namedID("Pinned")]) - assert.Equal(t, ir.PrimString, pinned.ValueType) - assert.True(t, pinned.Closed) - require.Len(t, pinned.Members, 1) - assert.Equal(t, "only", pinned.Members[0].Value.Str) -} - -func assertEnumNumeric(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - e, ok := doc.Types[namedID("BigCodes")].(*ir.Enum) - require.True(t, ok) - assert.Equal(t, ir.PrimInteger, e.ValueType) - require.Len(t, e.Members, 2) - assert.Equal(t, ir.ValueNumber, e.Members[1].Value.Kind) - // BigVal member value preserves the full 64-bit integer. - assert.Equal(t, ir.BigVal("9007199254740993"), e.Members[1].Value.Num) -} - -// assertEmptyEnum covers `enum: []`: legal JSON Schema whose value space holds -// no member, so the position accepts no instance. The IR says that exactly, -// since a closed Enum admits its members and nothing else. Reading the keyword -// off `len(enum) > 0` cannot tell an empty list from an absent one, so the -// position would widen to what its siblings admit (GitHub #278). Holder's -// `colour` is in the corpus so that a change reaching every enum, not only the -// empty one, reddens this. -func assertEmptyEnum(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - empty := map[string]ir.PrimKind{ - "Nothing": ir.PrimString, - "Bare": ir.PrimAny, - "BesideProperties": ir.PrimAny, - "BesideAllOf": ir.PrimAny, - } - for name, valueType := range empty { - e, ok := doc.Types[namedID(name)].(*ir.Enum) - require.True(t, ok, "%s lowers to an Enum, got %T", name, doc.Types[namedID(name)]) - assert.True(t, e.Closed, "%s admits its members and nothing else", name) - assert.Empty(t, e.Members, "%s declares no member", name) - assert.Equal(t, valueType, e.ValueType, - "%s takes the declared scalar type where one is written", name) - assert.Equal(t, []ir.Severity{ir.SeverityWarning}, - diagsAt(diags, "openapi/empty-enum", "/components/schemas/"+name), - "%s is reported once, since nobody writes an empty member list on purpose", name) - } - - // An Enum has no home for a property set or a composition, so what the empty - // enum is written beside stays verbatim rather than being traded for it. - props := unmodeledEntry(t, doc.Types[namedID("BesideProperties")].Common().Unmodeled, "openapi:properties") - assert.Equal(t, ir.ReasonDegradedLowering, props.Reason) - assert.JSONEq(t, `{"x":{"type":"string"}}`, string(props.Value)) - composed := unmodeledEntry(t, doc.Types[namedID("BesideAllOf")].Common().Unmodeled, "openapi:allOf") - assert.Equal(t, ir.ReasonDegradedLowering, composed.Reason) - assert.JSONEq(t, `[{"$ref":"#/components/schemas/Base"}]`, string(composed.Value)) - - holder, ok := doc.Types[namedID("Holder")].(*ir.Model) - require.True(t, ok) - colour, ok := propByWire(holder, "colour") - require.True(t, ok) - populated, ok := doc.Types[colour.Type.Target].(*ir.Enum) - require.True(t, ok, "a populated enum beside the degenerate ones is unaffected") - require.Len(t, populated.Members, 2) - assert.Equal(t, ir.PrimString, populated.ValueType) - assert.Empty(t, diagsAt(diags, "openapi/empty-enum", "/components/schemas/Holder/properties/colour"), - "and reports nothing") -} - -// assertMissingArrayItems pins that a 3.0 schema writing type array and no -// items is reported once, at its own pointer, wherever it sits and whatever it -// lowers to: Choice is a union of literals (GitHub #650). Copy's $ref names -// Holder's property coordinate and is declared first, so the property is -// lowered twice. Holder's typed and empty arrays write items, so the list is -// every report the document draws. -func assertMissingArrayItems(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - var got []string - for _, d := range diags { - if d.Code == "openapi/missing-array-items" { - assert.Equal(t, ir.SeverityWarning, d.Severity, "at %s", d.Provenance.Pointer) - got = append(got, string(d.Provenance.Pointer)) - } - } - slices.Sort(got) - assert.Equal(t, []string{ - "/components/schemas/Bare", - "/components/schemas/Choice", - "/components/schemas/Holder/properties/tags", - "/paths/~1things/get/parameters/0/schema", - }, got) - - bare, ok := doc.Types[namedID("Bare")].(*ir.List) - require.True(t, ok, "Bare still lowers to a List, got %T", doc.Types[namedID("Bare")]) - assert.Equal(t, ir.TypeID("t/prim/any"), bare.Elem.Target, "over the top type") -} - -func assertScalarFormat(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - h, ok := doc.Types[namedID("Holder")].(*ir.Model) - require.True(t, ok) - color, ok := propByWire(h, "color") - require.True(t, ok) - sc, ok := doc.Types[color.Type.Target].(*ir.Scalar) - require.True(t, ok, "unknown format hoists a named Scalar") - require.NotNil(t, sc.Encoding) - assert.Equal(t, "hex-color", sc.Encoding.Name) - require.NotNil(t, sc.Base) - assert.Equal(t, ir.TypeID("t/prim/string"), sc.Base.Target) - assert.False(t, color.Secret, "an ordinary format asks for no redaction") - // The bound survives the hoist the unknown format forced (GitHub #147). - require.NotNil(t, sc.Constraints, "the hoisted format scalar carries its own bounds") - require.NotNil(t, sc.Constraints.MinLength) - assert.Equal(t, int64(4), *sc.Constraints.MinLength) - - // format: password asks for the value to be redacted and names no shape, so - // a property whose schema owns no node holds it, and its type is the bare - // string's (secret-format covers every other position). - token, ok := propByWire(h, "token") - require.True(t, ok) - assert.True(t, token.Secret) - assert.Equal(t, ir.PrimTypeID(ir.PrimString), token.Type.Target) - - // An integer width selects its primitive whichever numeric type carries it. - for wire, want := range map[string]ir.PrimKind{"small": ir.PrimInt8, "count": ir.PrimUint32} { - p, ok := propByWire(h, wire) - require.True(t, ok) - assert.Equal(t, ir.PrimTypeID(want), p.Type.Target, "%s selects the shared primitive", wire) - } - // A float format on an integer admits only integral values, which no - // primitive narrower than integer holds, so it hoists over integer. - whole, ok := propByWire(h, "whole") - require.True(t, ok) - ws, ok := doc.Types[whole.Type.Target].(*ir.Scalar) - require.True(t, ok, "a pairing no primitive holds hoists a Scalar") - require.NotNil(t, ws.Base) - assert.Equal(t, ir.PrimTypeID(ir.PrimInteger), ws.Base.Target) - require.NotNil(t, ws.Encoding) - assert.Equal(t, "double", ws.Encoding.Name) -} - -// assertSecretFormat pins where `format: password` lands at every position the -// compiler reaches: Sensitive on the node a position owns, whatever its kind; -// Property.Secret at a property or header owning none; and verbatim on a -// parameter, which has no field. It reads the nodes rather than the golden's -// bytes, so a fact that moved to another carrier still fails (GitHub #579). -func assertSecretFormat(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - pw := requireSensitiveAlias(t, doc, namedID("Pw"), ir.PrimTypeID(ir.PrimString), "component schema") - require.NotNil(t, pw.Constraints, "the bound written beside the format stays on the node") - assert.Equal(t, new(int64(8)), pw.Constraints.MinLength) - - assertSecretCarriers(t, doc) - assertSecretNodes(t, doc) - assertSecretOperation(t, doc, diags) - - // The guard: a bare string keeps the shared primitive, and a shared node - // never carries one declaration's annotations (GitHub #116). - plain, ok := propByWire(secretModel(t, doc, "Login"), "plain") - require.True(t, ok) - assert.Equal(t, ir.PrimTypeID(ir.PrimString), plain.Type.Target) - assert.False(t, plain.Secret) - assert.False(t, doc.Types[ir.PrimTypeID(ir.PrimString)].Common().Sensitive) - assert.False(t, doc.Types[namedID("Plain")].Common().Sensitive, "a referent beside which a use wrote it") -} - -// assertSecretCarriers covers the properties of Login that own no node, where -// Property.Secret is the home, and the wrappers whose type reaches the -// Sensitive node instead. -func assertSecretCarriers(t *testing.T, doc *ir.Document) { - login := secretModel(t, doc, "Login") - for _, tc := range []struct { - wire string - target ir.TypeID - }{ - {"inline", ir.PrimTypeID(ir.PrimString)}, - {"pin", ir.PrimTypeID(ir.PrimInteger)}, - {"viaRef", namedID("Pw")}, // inherited from the referent, as deprecated is - {"viaAlias", namedID("Chain")}, // from the end of the $ref chain - {"beside", namedID("Plain")}, // written beside the $ref, binding the use - } { - p, ok := propByWire(login, tc.wire) - require.True(t, ok, tc.wire) - assert.True(t, p.Secret, "%s: the property is the carrier", tc.wire) - assert.Equal(t, tc.target, p.Type.Target, "%s: the redaction names no shape", tc.wire) - } - inline, ok := propByWire(login, "inline") - require.True(t, ok) - assert.Equal(t, "shown on the property", inline.Docs.Description, "no node takes the docs away") - - // A wrapper's own schema writes no redaction; its type reaches the node - // that does, along a nullable reference or a Base. - nullable, ok := propByWire(login, "nullable") - require.True(t, ok) - assert.False(t, nullable.Secret, "the node holds it, not a copy on the use") - assert.Equal(t, ir.TypeRef{Target: namedID("Pw"), Nullable: true}, nullable.Type) - wrapped, ok := propByWire(login, "wrapped") - require.True(t, ok) - assert.False(t, wrapped.Secret, "the node holds it, not a copy on the use") - m, ok := doc.Types[wrapped.Type.Target].(*ir.Model) - require.True(t, ok, "an allOf over one $ref is a model") - require.NotNil(t, m.Base) - assert.Equal(t, namedID("Pw"), m.Base.Target) - chain, ok := doc.Types[namedID("Chain")].(*ir.Scalar) - require.True(t, ok, "an alias component is a Scalar") - require.NotNil(t, chain.Base) - assert.Equal(t, namedID("Pw"), chain.Base.Target) - - // A property whose schema owns a node leaves the redaction on it. - pick, ok := propByWire(login, "pick") - require.True(t, ok) - assert.False(t, pick.Secret, "one home per declaration") - assert.Equal(t, ir.KindEnum, requireSensitive(t, doc, pick.Type.Target, "enum property").Kind()) -} - -// assertSecretNodes covers the positions with no carrier: each owns a node, an -// alias over the bare type where the shape alone would own none. -func assertSecretNodes(t *testing.T, doc *ir.Document) { - arr, ok := propByWire(secretModel(t, doc, "Login"), "arr") - require.True(t, ok) - list, ok := doc.Types[arr.Type.Target].(*ir.List) - require.True(t, ok) - items := requireSensitiveAlias(t, doc, list.Elem.Target, ir.PrimTypeID(ir.PrimString), "array items") - require.NotNil(t, items.Constraints) - assert.Equal(t, new(int64(64)), items.Constraints.MaxLength) - - bag := secretModel(t, doc, "Bag") - require.NotNil(t, bag.AdditionalProps) - requireSensitiveAlias(t, doc, bag.AdditionalProps.Value.Target, ir.PrimTypeID(ir.PrimString), "additionalProperties") - require.Len(t, bag.AdditionalProps.Patterns, 1) - requireSensitiveAlias(t, doc, bag.AdditionalProps.Patterns[0].Value.Target, ir.PrimTypeID(ir.PrimString), - "patternProperties") - - pair, ok := doc.Types[namedID("Pair")].(*ir.Tuple) - require.True(t, ok) - require.Len(t, pair.Elems, 1) - requireSensitiveAlias(t, doc, pair.Elems[0].Target, ir.PrimTypeID(ir.PrimString), "prefixItems") - - tokens, ok := doc.Types[namedID("Tokens")].(*ir.List) - require.True(t, ok) - requireSensitiveAlias(t, doc, tokens.Elem.Target, namedID("Plain"), "items beside a $ref") - - either, ok := doc.Types[namedID("Either")].(*ir.Union) - require.True(t, ok) - require.Len(t, either.Variants, 2) - requireSensitiveAlias(t, doc, either.Variants[0].Type.Target, ir.PrimTypeID(ir.PrimString), "union branch") - assert.False(t, either.Sensitive, "the branch is redacted, not the union") - - assert.Equal(t, ir.KindLiteral, requireSensitive(t, doc, namedID("Code"), "const").Kind()) - requireSensitiveAlias(t, doc, namedID("Opaque"), ir.PrimTypeID(ir.PrimAny), "untyped") - encoded, ok := requireSensitive(t, doc, namedID("Encoded"), "beside contentEncoding").(*ir.Scalar) - require.True(t, ok) - require.NotNil(t, encoded.Encoding) - assert.Equal(t, "base64", encoded.Encoding.Name, "contentEncoding alone names the encoding") -} - -// assertSecretOperation covers the parameters, bodies and headers. -func assertSecretOperation(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - op, ok := opByName(doc, "login") - require.True(t, ok) - - token, ok := paramByName(op, "X-Token") - require.True(t, ok) - assert.Equal(t, ir.PrimTypeID(ir.PrimString), token.Type.Target, "a parameter owns no node for it") - assert.Equal(t, "the session token", token.Docs.Description) - require.Contains(t, token.Unmodeled, "openapi:format", "ir.Parameter has no field (GitHub #578)") - assert.Equal(t, ir.ReasonNoIRHome, token.Unmodeled["openapi:format"].Reason) - assert.Equal(t, []ir.Severity{ir.SeverityInfo}, - diagsAt(diags, "openapi/degraded-construct", "/paths/~1login/post/parameters/0/schema/format")) - key, ok := paramByName(op, "api_key") - require.True(t, ok) - assert.Equal(t, namedID("Pw"), key.Type.Target, "a referent's redaction is its own node's") - assert.NotContains(t, key.Unmodeled, "openapi:format") - - str := ir.PrimTypeID(ir.PrimString) - requireSensitiveAlias(t, doc, openapitest.BodyTarget(t, op.Request), str, "request content schema") - require.Len(t, op.Responses, 1) - requireSensitiveAlias(t, doc, openapitest.BodyTarget(t, op.Responses[0].Payload), str, "response content schema") - - require.Len(t, op.Responses[0].Headers, 2) - header := op.Responses[0].Headers[0] - assert.True(t, header.Secret, "a header is a property carrier") - assert.Equal(t, ir.PrimTypeID(ir.PrimString), header.Type.Target) - assert.Equal(t, ir.TypeRef{Target: namedID("Pw"), Nullable: true}, op.Responses[0].Headers[1].Type) -} - -// secretModel returns the secret-format component name as a Model. -func secretModel(t *testing.T, doc *ir.Document, name string) *ir.Model { - t.Helper() - m, ok := doc.Types[namedID(name)].(*ir.Model) - require.True(t, ok, "%s is a model", name) - return m -} - -// requireSensitive requires the node at id to exist and be Sensitive. -func requireSensitive(t *testing.T, doc *ir.Document, id ir.TypeID, position string) ir.TypeDef { - t.Helper() - node, ok := doc.Types[id] - require.True(t, ok, "%s: nothing is interned at %s", position, id) - assert.True(t, node.Common().Sensitive, "%s: whole-type redaction", position) - return node -} - -// requireSensitiveAlias requires the node at id to be a Sensitive alias: the -// Scalar over base a position owns where its shape alone is that shared base, -// with no encoding, since a redaction is none. -func requireSensitiveAlias(t *testing.T, doc *ir.Document, id, base ir.TypeID, position string) *ir.Scalar { - t.Helper() - sc, ok := requireSensitive(t, doc, id, position).(*ir.Scalar) - require.True(t, ok, "%s: a Scalar", position) - require.NotNil(t, sc.Base, "%s: a base", position) - assert.Equal(t, base, sc.Base.Target, "%s: over its shape", position) - assert.Nil(t, sc.Encoding, "%s: the redaction is no encoding", position) - return sc -} - -// assertXMLHints covers the XML wire shape, whose hints attach at two carriers -// with different scopes: a type node holds the root element shape, a property -// holds a per-use override. A position that owns a node keeps them there, so -// which carrier a hint lands on follows what the position lowered to. -func assertXMLHints(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - book, ok := doc.Types[namedID("Book")].(*ir.Model) - require.True(t, ok) - require.NotNil(t, book.XML) - assert.Equal(t, ir.XMLHints{Name: "book", Namespace: "urn:example:books", Prefix: "bk"}, *book.XML) - - isbn, ok := propByWire(book, "isbn") - require.True(t, ok) - require.NotNil(t, isbn.XML, "a scalar property has no node of its own, so the property carries them") - assert.Equal(t, ir.XMLHints{Namespace: "urn:example:books", Prefix: "bk", NodeType: "attribute"}, - *isbn.XML, "attribute: true is the node type, not a flag of its own") - - authors, ok := propByWire(book, "authors") - require.True(t, ok) - assert.Nil(t, authors.XML, "an array owns a node, which is where its hints go") - list, ok := doc.Types[authors.Type.Target].(*ir.List) - require.True(t, ok) - require.NotNil(t, list.XML) - assert.True(t, list.XML.Wrapped, "wrapping is a property of the collection") - elem, ok := doc.Types[list.Elem.Target] - require.True(t, ok) - require.NotNil(t, elem.Common().XML) - assert.Equal(t, "author", elem.Common().XML.Name) -} - -func assertEncodingByte(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - h, ok := doc.Types[namedID("Holder")].(*ir.Model) - require.True(t, ok) - require.Len(t, h.Properties, 1) - sc, ok := doc.Types[h.Properties[0].Type.Target].(*ir.Scalar) - require.True(t, ok) - require.NotNil(t, sc.Encoding) - assert.Equal(t, "base64", sc.Encoding.Name) - require.NotNil(t, sc.Encoding.WireType) - assert.Equal(t, ir.TypeID("t/prim/string"), sc.Encoding.WireType.Target) - require.NotNil(t, sc.Base) - assert.Equal(t, ir.TypeID("t/prim/bytes"), sc.Base.Target) - - // A scalar that hoists a node because of its format must keep the bounds it - // wrote beside it: owning the pointer is what stops the shared alias path - // attaching them afterwards (GitHub #147). - require.NotNil(t, sc.Constraints, "the hoisted byte scalar carries its own bounds") - require.NotNil(t, sc.Constraints.MinLength) - assert.Equal(t, int64(5), *sc.Constraints.MinLength) - require.NotNil(t, sc.Constraints.MaxLength) - assert.Equal(t, int64(9), *sc.Constraints.MaxLength) -} - -func assertNullabilityFourStates(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("S")].(*ir.Model) - require.True(t, ok) - require.Len(t, m.Properties, 4) - states := openapitest.PropsByWire(m.Properties) - assert.True(t, states["reqPlain"].Required) - assert.False(t, states["reqPlain"].Type.Nullable) - assert.True(t, states["reqNull"].Required) - assert.True(t, states["reqNull"].Type.Nullable) - assert.False(t, states["optPlain"].Required) - assert.False(t, states["optPlain"].Type.Nullable) - assert.False(t, states["optNull"].Required) - assert.True(t, states["optNull"].Type.Nullable) -} - -func assertNullable30(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("S")].(*ir.Model) - require.True(t, ok) - require.Len(t, m.Properties, 1) - assert.True(t, m.Properties[0].Type.Nullable, "3.0 nullable lowers to the same IR bit") -} - -func assertNullable31Ref(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("Owner")].(*ir.Model) - require.True(t, ok) - require.Len(t, m.Properties, 2) - byName := openapitest.PropsByWire(m.Properties) - - assert.True(t, byName["p"].Type.Nullable, - "3.1's type-array null spelling normalizes to the same IR bit at a $ref site") - assert.Equal(t, namedID("Target"), byName["p"].Type.Target, "the ref resolves to the named component") - - assert.True(t, byName["q"].Type.Nullable, - "a union's null branch is stripped into the ref's Nullable bit, so the ref must carry it") - assert.Equal(t, namedID("UnionTarget"), byName["q"].Type.Target, "the ref resolves to the union component") - - u, ok := doc.Types[namedID("UnionTarget")].(*ir.Union) - require.True(t, ok) - assert.Len(t, u.Variants, 2, "the null branch lifts to the ref rather than becoming a variant") - - assertCollapsedBranchHint(t, doc) -} - -// assertCollapsedBranchHint covers the {X, null} collapse's naming of the -// branch it keeps. The branch pointer is nameable from outside (BranchRef names -// it) and only the first lowering to reach it interns the node, so the collapse -// and an outside $ref must derive the same hint, or the document depends on -// which component is declared first (GitHub #281). The spec declares Collapsed -// before BranchRef on purpose: that is the order in which the collapse reaches -// the pointer first. The permutation half is the two-order oracle's -// (internal/harness). -func assertCollapsedBranchHint(t *testing.T, doc *ir.Document) { - t.Helper() - const branchID = ir.TypeID("t/anon/components/schemas/Collapsed/oneOf/0") - - collapsed, ok := doc.Types[namedID("Collapsed")].(*ir.Scalar) - require.True(t, ok, "a {X, null} set resolves to its one branch rather than to a union node") - require.NotNil(t, collapsed.Base) - assert.True(t, collapsed.Base.Nullable, "the null branch lifts onto the reference") - assert.Equal(t, branchID, collapsed.Base.Target) - - branch, ok := doc.Types[branchID] - require.True(t, ok, "the branch declares a description, so it owns a node of its own") - assert.Equal(t, "variant_0", branch.Common().Name.Hint, - "the branch is named by its position in the composition, which is what a $ref to it derives too") - - ref, ok := doc.Types[namedID("BranchRef")].(*ir.Scalar) - require.True(t, ok) - require.NotNil(t, ref.Base) - assert.Equal(t, branchID, ref.Base.Target, "the outside reference reaches that same node") -} - -// assertNullableEnum31 covers 3.1's spelling of a nullable enum: `null` listed -// among the members of a null-admitting type array. The member is normalized -// onto the Nullable bit of every reference to the enum rather than degrading the -// declaration to a union of literals, so the claim is the pair: a closed Enum of -// the scalar members, and null admitted at each use. The golden cannot pin the -// strip, since the type array already carries the bit and deleting the `null` -// member moves only the source hash; the member count below does. -func assertNullableEnum31(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - e, ok := doc.Types[namedID("Color")].(*ir.Enum) - require.True(t, ok, "the null member does not cost the declaration its enum-ness") - assert.True(t, e.Closed) - assert.Equal(t, ir.PrimString, e.ValueType, "null contributes no value type of its own") - require.Len(t, e.Members, 2, "the null member is normalized away, the scalar members are not") - assert.Equal(t, "red", e.Members[0].Value.Str) - assert.Equal(t, "green", e.Members[1].Value.Str) - for _, d := range diags { - assert.NotEqual(t, "openapi/degraded-construct", d.Code, "normalizing is not degrading: %+v", d) - } - - m, ok := doc.Types[namedID("Holder")].(*ir.Model) - require.True(t, ok) - one, ok := propByWire(m, "one") - require.True(t, ok) - assert.Equal(t, ir.TypeRef{Target: namedID("Color"), Nullable: true}, one.Type, - "a property $ref admits the null the members no longer carry") - - many, ok := propByWire(m, "many") - require.True(t, ok) - list, ok := doc.Types[many.Type.Target].(*ir.List) - require.True(t, ok, "the array property hoists a List") - assert.Equal(t, ir.TypeRef{Target: namedID("Color"), Nullable: true}, list.Elem, - "and so does a list element, which holds its own reference") - assert.False(t, many.Type.Nullable, "the list itself was never declared nullable") - - op, ok := opByName(doc, "pick") - require.True(t, ok) - p, ok := paramByName(op, "color") - require.True(t, ok) - assert.Equal(t, ir.TypeRef{Target: namedID("Color"), Nullable: true}, p.Type, - "a parameter reaches the same declaration through the operation walk") - - // The two spellings beside Color. A non-empty enum fixes the value space, so - // the members decide null admission whether or not a type keyword is written - // beside them — and when one is, the two conjoin rather than the type winning. - bare, ok := doc.Types[namedID("BareColor")].(*ir.Enum) - require.True(t, ok, "a bare enum listing null is still an enum of its scalar members") - require.Len(t, bare.Members, 2, "the null member is normalized away here too") - assert.Equal(t, ir.PrimString, bare.ValueType, "the kept members supply the value type") - - narrowed, ok := doc.Types[namedID("NarrowedColor")].(*ir.Enum) - require.True(t, ok) - require.Len(t, narrowed.Members, 2, "nothing to strip: the members never listed null") - - bareRef, ok := propByWire(m, "bare") - require.True(t, ok) - assert.Equal(t, ir.TypeRef{Target: namedID("BareColor"), Nullable: true}, bareRef.Type, - "the bare spelling admits null at its uses, like the type-array spelling of the same set") - - narrowedRef, ok := propByWire(m, "narrowed") - require.True(t, ok) - assert.Equal(t, ir.TypeRef{Target: namedID("NarrowedColor"), Nullable: false}, narrowedRef.Type, - "an enum excluding null must not admit it, whatever the type keyword names") -} - -// assertNullabilityConjunction covers how null admission is decided for a schema -// whose keywords disagree: JSON Schema conjoins them, so a position admits null -// when something declares it and nothing takes it away. The claim is agreement: -// the same constraint written two ways reaches one Nullable bit, which is all an -// emitter reads. -// -// Base and Mixins are asserted to stay bare on purpose. They name one side of a -// conjunction, so the bit belongs to the usage that names the whole; a -// composition carrying its own would state one fact in two places that can -// disagree. -func assertNullabilityConjunction(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("Holder")].(*ir.Model) - require.True(t, ok) - props := openapitest.PropsByWire(m.Properties) - require.Len(t, props, 5) - - assert.Equal(t, props["direct"].Type.Nullable, props["viaAllOf"].Type.Nullable, - "a sole conjunct's null survives the composition that names it") - assert.True(t, props["viaAllOf"].Type.Nullable) - assert.Equal(t, props["directModel"].Type.Nullable, props["viaAllOfModel"].Type.Nullable, - "a model target has no second hop to recover the null from") - assert.True(t, props["viaAllOfModel"].Type.Nullable) - assert.False(t, props["viaAllOfBoth"].Type.Nullable, - "one conjunct forbidding null decides the conjunction") - - for _, name := range []string{"WrapNullableScalar", "WrapNullableModel", "WrapBoth"} { - wrap, isModel := doc.Types[namedID(name)].(*ir.Model) - require.True(t, isModel, "%s composes as a model", name) - if wrap.Base != nil { - assert.False(t, wrap.Base.Nullable, "%s.Base names a conjunct, not a usage", name) - } - for i, mix := range wrap.Mixins { - assert.False(t, mix.Nullable, "%s.Mixins[%d] names a conjunct, not a usage", name, i) - } - } - - plain, ok := doc.Types[namedID("PlainUnion")].(*ir.Union) - require.True(t, ok) - distributed, ok := doc.Types[namedID("DistributedUnion")].(*ir.Union) - require.True(t, ok) - require.Len(t, plain.Variants, 2) - require.Len(t, distributed.Variants, 2) - for i := range plain.Variants { - assert.Equal(t, plain.Variants[i].Type.Nullable, distributed.Variants[i].Type.Nullable, - "variant %d: distributing a body that declares no type does not change what its branch admits", i) - } - assert.True(t, plain.Variants[0].Type.Nullable, "the nullable branch is the one that admits null") - assert.False(t, plain.Variants[1].Type.Nullable, "and the plain one is not") -} - -func assertDefaults(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("S")].(*ir.Model) - require.True(t, ok) - n, ok := propByWire(m, "n") - require.True(t, ok) - require.NotNil(t, n.Default) - assert.Equal(t, ir.ValueNumber, n.Default.Kind) - assert.Equal(t, ir.BigVal("9007199254740993"), n.Default.Num) - - // A declaration-site default reaches the referencing property, and the - // declaration keeps it verbatim because the type node has no field for it. - viaRef, ok := propByWire(m, "viaRef") - require.True(t, ok) - require.NotNil(t, viaRef.Default, "the referent's default binds the reference site") - assert.Equal(t, ir.BigVal("41"), viaRef.Default.Num) - - decl, ok := doc.Types[namedID("DeclaredDefault")].(*ir.Scalar) - require.True(t, ok) - entry, ok := decl.Unmodeled["openapi:default"] - require.True(t, ok, "the declaration's own default is kept as residue") - assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) - assert.JSONEq(t, `41`, string(entry.Value)) - - assertNonScalarDefaults(t, m) -} - -// assertNonScalarDefaults pins the Value payloads a default reaches beyond string -// and number: a boolean, a base64 !!binary scalar, and a sequence. Each selects a -// different payload field, and Kind is what says which one carries meaning. -func assertNonScalarDefaults(t *testing.T, m *ir.Model) { - t.Helper() - flag, ok := propByWire(m, "flag") - require.True(t, ok) - require.NotNil(t, flag.Default) - assert.Equal(t, ir.Value{Kind: ir.ValueBool, Bool: true}, *flag.Default) - - blob, ok := propByWire(m, "blob") - require.True(t, ok) - require.NotNil(t, blob.Default) - assert.Equal(t, ir.ValueBytes, blob.Default.Kind) - assert.Equal(t, []byte("hi"), blob.Default.Bytes, "a !!binary default is decoded, not kept as text") - - list, ok := propByWire(m, "list") - require.True(t, ok) - require.NotNil(t, list.Default) - assert.Equal(t, ir.ValueList, list.Default.Kind) - require.Len(t, list.Default.List, 2) - assert.Equal(t, ir.BigVal("1"), list.Default.List[0].Num) - assert.Equal(t, ir.BigVal("2"), list.Default.List[1].Num) -} - -// assertYAMLTimestampScalars covers a YAML 1.1 quirk: an unquoted date like -// 2021-01-01 resolves to tag !!timestamp, not !!str. It must survive as the -// literal string everywhere OpenAPI's JSON data model can carry one — enum, -// const, a property default, a schema-level example, and a media-type example — -// with nothing dropped or degraded to null. -// -// "Everywhere" spans both channels a date can land in. It used to mean only the -// ir.Value one, and the raw-JSON one went on rewriting a date to RFC 3339 with -// this fixture green (GitHub #242); assertRawPreservedDates is the other half. -func assertYAMLTimestampScalars(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - // R's `not` announces the §4.7 carve-out, and D's `format: date` announces - // that an ir.Enum has no Encoding field to put it in, so it is kept verbatim - // beside the enum rather than dropped. Those two are the only notices any - // site here is allowed to say, and neither is about a date: one that was - // dropped or degraded reports itself as a warning, which is what this fixture - // exists to catch. - for _, d := range diags { - assert.Equal(t, ir.SeverityInfo, d.Severity, - "every unquoted date converts cleanly; nothing is dropped or degraded: %+v", d) - if d.Code == "openapi/degraded-construct" && d.Provenance.Pointer == "/components/schemas/D" { - continue - } - assert.Equal(t, "openapi/validation-only-keyword", d.Code, - "the §4.7 carve-out is the only other notice this fixture expects: %+v", d) - } - - d, ok := doc.Types[namedID("D")].(*ir.Enum) - require.True(t, ok, "D stays a closed Enum of the real dates, not a union of null literals") - assert.Equal(t, ir.PrimString, d.ValueType) - require.Len(t, d.Members, 2) - assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, d.Members[0].Value) - assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2022-02-02"}, d.Members[1].Value) - - k, ok := doc.Types[namedID("K")].(*ir.Literal) - require.True(t, ok, "K's const hoists a real Literal, not the schemaless top type") - assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, k.Value) - - s, ok := doc.Types[namedID("S")].(*ir.Model) - require.True(t, ok) - require.Len(t, s.Properties, 1) - prop := s.Properties[0] - require.NotNil(t, prop.Default, "the property default is preserved") - assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, *prop.Default) - require.Len(t, prop.Examples, 1, "the schema-level example is preserved") - require.NotNil(t, prop.Examples[0].Value) - assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, *prop.Examples[0].Value) - - op, ok := opByName(doc, "getItem") - require.True(t, ok) - require.NotNil(t, op.Responses[0].Payload) - require.Len(t, op.Responses[0].Payload.Contents, 1) - mediaExamples := op.Responses[0].Payload.Contents[0].Examples - require.Len(t, mediaExamples, 1, "the media-type example is preserved") - require.NotNil(t, mediaExamples[0].Value) - assert.Equal(t, ir.Value{Kind: ir.ValueString, Str: "2021-01-01"}, *mediaExamples[0].Value) - - assertRawPreservedDates(t, doc) -} - -// assertRawPreservedDates pins the source spelling of a date kept as raw JSON, -// at both sites that channel has: a vendor extension and the §4.7 -// validation-only carve-out. Each wants the text the source wrote — resolving -// the tag and re-rendering it hands the construct a padding, a time and a zone -// nobody asked for, and the resolved form cannot be read back to the spelling. -func assertRawPreservedDates(t *testing.T, doc *ir.Document) { - t.Helper() - r, ok := doc.Types[namedID("R")].(*ir.Model) - require.True(t, ok) - kept := r.Unmodeled - - for _, tc := range []struct{ key, want string }{ - {"openapi:x-effective", `"2021-1-1"`}, - {"openapi:x-window", `{"from":"2021-1-1","to":"2022-2-2"}`}, - {"openapi:not", `{"const":"2021-1-1"}`}, - } { - entry, found := kept[tc.key] - require.True(t, found, "%s is preserved; got %v", tc.key, kept) - assert.Equal(t, tc.want, string(entry.Value), - "%s keeps the date as written, not as RFC 3339 spells it", tc.key) - } -} - -func assertConstraints(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - m, ok := doc.Types[namedID("S")].(*ir.Model) - require.True(t, ok) - ratio, ok := propByWire(m, "ratio") - require.True(t, ok) - c := ratio.Constraints - require.NotNil(t, c) - // Exact decimal strings — a float64 path would corrupt all three. - require.NotNil(t, c.Min) - require.NotNil(t, c.Max) - require.NotNil(t, c.MultipleOf) - assert.Equal(t, ir.BigVal("0.30000000000000004"), *c.Min) - assert.Equal(t, ir.BigVal("9007199254740993"), *c.Max) - assert.Equal(t, ir.BigVal("0.1"), *c.MultipleOf) - - // The object's own cardinality bound lands on the Model, not on a property - // and not nowhere (GitHub #129). - require.NotNil(t, m.Constraints) - require.NotNil(t, m.Constraints.MinProps) - require.NotNil(t, m.Constraints.MaxProps) - assert.Equal(t, int64(1), *m.Constraints.MinProps) - assert.Equal(t, int64(4), *m.Constraints.MaxProps) - - assertLengthAndCollectionBounds(t, doc, m) - assertCoDeclaredBounds(t, m, diags) - assertCoDeclaredBoundKept(t, doc, m) -} - -// assertCoDeclaredBounds pins the 2020-12 rule that a side declaring both of -// 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. 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) - 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) - 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 _, 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: 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) - assert.Empty(t, low.Unmodeled, "the property keeps nothing beside its constraints") - - high, ok := propByWire(m, "underTen") - require.True(t, ok) - 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) - 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 -// pair on the declaring property, and a collection bound on the List the array -// position hoisted, which is the node that describes the collection. -func assertLengthAndCollectionBounds(t *testing.T, doc *ir.Document, m *ir.Model) { - t.Helper() - label, ok := propByWire(m, "label") - require.True(t, ok) - require.NotNil(t, label.Constraints) - require.NotNil(t, label.Constraints.MinLength) - require.NotNil(t, label.Constraints.MaxLength) - assert.Equal(t, int64(2), *label.Constraints.MinLength) - assert.Equal(t, int64(8), *label.Constraints.MaxLength) - - tags, ok := propByWire(m, "tags") - require.True(t, ok) - list, ok := doc.Types[tags.Type.Target].(*ir.List) - require.True(t, ok, "an array position hoists a List") - require.NotNil(t, list.Constraints) - require.NotNil(t, list.Constraints.MinItems) - require.NotNil(t, list.Constraints.MaxItems) - assert.Equal(t, int64(1), *list.Constraints.MinItems) - assert.Equal(t, int64(5), *list.Constraints.MaxItems) - assert.True(t, list.Constraints.UniqueItems) -} - -func assertNumericPrecision(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - m, ok := doc.Types[namedID("S")].(*ir.Model) - require.True(t, ok) - - // Bounds beyond float64 range and a huge integer beyond int64, exact. - bounded, ok := propByWire(m, "bounded") - require.True(t, ok) - require.NotNil(t, bounded.Constraints) - require.NotNil(t, bounded.Constraints.Min) - require.NotNil(t, bounded.Constraints.Max) - require.NotNil(t, bounded.Constraints.MultipleOf) - assert.Equal(t, ir.BigVal("1.8e308"), *bounded.Constraints.Min) - assert.Equal(t, ir.BigVal("123456789012345678901234567890"), *bounded.Constraints.Max) - assert.Equal(t, ir.BigVal("1e-30"), *bounded.Constraints.MultipleOf) - - // A leading-dot spelling is canonicalized to JSON form; a high-precision - // decimal is kept to the last digit. - exclusive, ok := propByWire(m, "exclusive") - require.True(t, ok) - require.NotNil(t, exclusive.Constraints) - 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") - require.True(t, ok) - require.NotNil(t, withDefault.Default) - assert.Equal(t, ir.ValueNumber, withDefault.Default.Kind) - assert.Equal(t, ir.BigVal("1.8e308"), withDefault.Default.Num) - - // A const beyond float64 range hoists a Literal over the exact number. - pinned, ok := propByWire(m, "pinned") - require.True(t, ok) - lit, ok := doc.Types[pinned.Type.Target].(*ir.Literal) - require.True(t, ok) - assert.Equal(t, ir.ValueNumber, lit.Value.Kind) - assert.Equal(t, ir.BigVal("1.8e308"), lit.Value.Num) - - // Numeric enum members keep their exact value past int64 range. - choice, ok := propByWire(m, "choice") - require.True(t, ok) - enum, ok := doc.Types[choice.Type.Target].(*ir.Enum) - require.True(t, ok) - require.Len(t, enum.Members, 2) - assert.Equal(t, ir.BigVal("123456789012345678901234567890"), enum.Members[1].Value.Num) - - // A leading-dot example is canonicalized losslessly. - sampled, ok := propByWire(m, "sampled") - require.True(t, ok) - require.Len(t, sampled.Examples, 1) - require.NotNil(t, sampled.Examples[0].Value) - assert.Equal(t, ir.BigVal("0.5"), sampled.Examples[0].Value.Num) - - assertYAMLIntegerBases(t, doc, m) - assertRawPreservedNumbers(t, m) -} - -// assertRawPreservedNumbers pins the second numeric channel: the constructs kept -// verbatim as raw JSON rather than lowered into a Value. It rounded every one of -// these through float64 until GitHub #32 — a 30-digit extension came back as -// 1.2345678901234568e+29 — which no case in this corpus could see, because none -// of them carried a number a float64 cannot hold. -func assertRawPreservedNumbers(t *testing.T, m *ir.Model) { - t.Helper() - preserved, ok := propByWire(m, "preserved") - require.True(t, ok) - kept := preserved.Unmodeled - - for _, tc := range []struct{ key, want string }{ - {"openapi:x-precise-limit", "123456789012345678901234567890"}, - {"openapi:x-precise-step", "1.000000000000000000001"}, - {"openapi:x-precise-scale", "1.10"}, - {"openapi:not", `{"const":123456789012345678901234567890}`}, - } { - entry, found := kept[tc.key] - require.True(t, found, "%s is preserved; got %v", tc.key, kept) - assert.Equal(t, tc.want, string(entry.Value), - "%s is preserved as written, not as a float64 can spell it", tc.key) - } -} - -// assertYAMLIntegerBases pins the value every numeric site stores for an integer -// whose YAML base comes from its prefix. Reading the source spelling as base 10 -// would store 644 where the document said 420, and would do it silently. -func assertYAMLIntegerBases(t *testing.T, doc *ir.Document, m *ir.Model) { - t.Helper() - mode, ok := propByWire(m, "mode") - require.True(t, ok) - require.NotNil(t, mode.Default) - assert.Equal(t, ir.BigVal("420"), mode.Default.Num, "0644 is octal in YAML") - - enum, ok := doc.Types[mode.Type.Target].(*ir.Enum) - require.True(t, ok) - require.Len(t, enum.Members, 2) - assert.Equal(t, ir.BigVal("420"), enum.Members[0].Value.Num) - assert.Equal(t, ir.BigVal("493"), enum.Members[1].Value.Num) - - bounds, ok := propByWire(m, "bounds") - require.True(t, ok) - require.NotNil(t, bounds.Constraints) - require.NotNil(t, bounds.Constraints.Min) - require.NotNil(t, bounds.Constraints.Max) - require.NotNil(t, bounds.Constraints.MultipleOf) - assert.Equal(t, ir.BigVal("15"), *bounds.Constraints.Min, "017 is octal") - assert.Equal(t, ir.BigVal("31"), *bounds.Constraints.Max, "0x1f is hex") - assert.Equal(t, ir.BigVal("10"), *bounds.Constraints.MultipleOf, "0b1010 is binary") - require.Len(t, bounds.Examples, 1) - require.NotNil(t, bounds.Examples[0].Value) - assert.Equal(t, ir.BigVal("30"), bounds.Examples[0].Value.Num, "0b11110 is binary") - - // "_" is a separator, and a leading zero YAML resolves as a decimal float - // still has to come out as a JSON-valid literal. - counted, ok := propByWire(m, "counted") - require.True(t, ok) - require.NotNil(t, counted.Default) - assert.Equal(t, ir.BigVal("1000000"), counted.Default.Num) - loose, ok := propByWire(m, "loose") - require.True(t, ok) - require.NotNil(t, loose.Default) - assert.Equal(t, ir.BigVal("9"), loose.Default.Num) -} - -func assertReadOnlyWriteOnly(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - m, ok := doc.Types[namedID("S")].(*ir.Model) - require.True(t, ok) - r, ok := propByWire(m, "r") - require.True(t, ok) - w, ok := propByWire(m, "w") - require.True(t, ok) - assert.Equal(t, ir.Visibility{Only: []ir.Lifecycle{ir.LifecycleRead, ir.LifecycleDelete, ir.LifecycleQuery}}, r.Visibility) - assert.Equal(t, ir.Visibility{Only: []ir.Lifecycle{ir.LifecycleCreate, ir.LifecycleUpdate}}, w.Visibility) - - // A declaration-site readOnly reaches the referencing property, and the - // declaration keeps it verbatim because the type node has no field for it. - viaRef, ok := propByWire(m, "viaRef") - require.True(t, ok) - assert.Equal(t, r.Visibility, viaRef.Visibility, "the referent's readOnly binds the reference site") - - decl, ok := doc.Types[namedID("DeclaredReadOnly")].(*ir.Scalar) - require.True(t, ok) - entry, ok := decl.Unmodeled["openapi:readOnly"] - require.True(t, ok, "the declaration's own readOnly is kept as residue") - assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) - assert.JSONEq(t, `true`, string(entry.Value)) - - assertBothFlagsAreVisibleNowhere(t, m, diags) -} - -// assertBothFlagsAreVisibleNowhere pins the contradictory pairing in both its -// spellings: written on one schema, and written across a $ref that already -// carries the other flag. Each admits no lifecycle at all and each is reported, -// where declaring both used to lower exactly as readOnly alone did and say -// nothing (GitHub #276). -func assertBothFlagsAreVisibleNowhere(t *testing.T, m *ir.Model, diags []ir.Diagnostic) { - t.Helper() - for _, wire := range []string{"both", "bothViaRef"} { - p, ok := propByWire(m, wire) - require.True(t, ok) - assert.Equal(t, ir.Visibility{None: true}, p.Visibility, - "readOnly and writeOnly both in force admit no lifecycle: %s", wire) - assert.Equal(t, []ir.Severity{ir.SeverityWarning}, - diagsAt(diags, diag.DisjointVisibility, "/components/schemas/S/properties/"+wire), - "the contradiction is reported once, at the position that wrote it: %s", wire) - } -} - -func assertRecursive(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - n, ok := doc.Types[namedID("Node")].(*ir.Model) - require.True(t, ok) - next, ok := propByWire(n, "next") - require.True(t, ok) - assert.Equal(t, namedID("Node"), next.Type.Target, "self-reference terminates on the interned ID") -} - -// assertMaps pins ir-design §4.6's line between the two map shapes: an object -// whose one member fact is its catch-all is a MapT, carrying its value type and -// entry bounds, and a catch-all beside anything else is a Model's -// AdditionalProps. An explicit `true` types the catch-all as the top type in -// either. -func assertMaps(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - i64 := func(v int64) *int64 { return &v } - for name, want := range map[string]*ir.MapT{ - "OpenMap": {Value: ir.TypeRef{Target: "t/prim/integer"}}, - "AnyMap": {Value: ir.TypeRef{Target: "t/prim/any"}}, - "BoundedMap": {Value: ir.TypeRef{Target: "t/prim/string"}, Constraints: &ir.Constraints{MinProps: i64(1), MaxProps: i64(10)}}, - } { - mt, ok := doc.Types[namedID(name)].(*ir.MapT) - require.True(t, ok, "%s is a map and nothing else; got %T", name, doc.Types[namedID(name)]) - assert.Nil(t, mt.Key, "%s has string keys", name) - assert.Equal(t, want.Value, mt.Value, name) - assert.Empty(t, cmp.Diff(want.Constraints, mt.Constraints), name) - } - pm, ok := doc.Types[namedID("PatternMap")].(*ir.Model) - require.True(t, ok) - require.NotNil(t, pm.AdditionalProps) - require.Len(t, pm.AdditionalProps.Patterns, 1) - assert.Equal(t, "^x-", pm.AdditionalProps.Patterns[0].Pattern) - rec, ok := doc.Types[namedID("OpenRecord")].(*ir.Model) - require.True(t, ok) - require.NotNil(t, rec.AdditionalProps, "an explicit true is a catch-all, not an unwritten keyword") - assert.Equal(t, ir.TypeRef{Target: "t/prim/any"}, rec.AdditionalProps.Value) - cr, ok := doc.Types[namedID("ClosedRecord")].(*ir.Model) - require.True(t, ok) - assert.Equal(t, ir.AdditionalClosed, cr.Additional) - ca, ok := doc.Types[namedID("ClosedAfter")].(*ir.Model) - require.True(t, ok) - assert.Equal(t, ir.AdditionalClosedAfterComposition, ca.Additional) -} - -// assertTuples pins what each prefixItems tuple admits past its elements and -// the bounds beside them (GitHub #597). Only `items: false` closes a tuple, and -// a typed rest is lowered into the type graph as a List's items is, at the same -// pointer and under the same hint. All of it is modeled, so no tuple keeps -// anything beside itself or reports a degradation. -func assertTuples(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - t.Helper() - i64 := func(v int64) *int64 { return &v } - pair := []ir.TypeRef{{Target: "t/prim/string"}, {Target: "t/prim/integer"}} - restID := ir.TypeID("t/anon/components/schemas/PairWithRest/items") - for _, tc := range []struct { - name string - elems []ir.TypeRef - rest ir.TypeID // "" = no element may follow - bounds *ir.Constraints - }{ - {"ClosedPair", pair, "", nil}, - {"Pair", pair, "t/prim/any", nil}, - {"PairWithRest", pair[:1], restID, nil}, - {"ExactPair", pair, "", &ir.Constraints{MinItems: i64(2)}}, - {"BoundedClosedPair", pair, "", &ir.Constraints{MinItems: i64(1), MaxItems: i64(2), UniqueItems: true}}, - } { - tup, ok := doc.Types[namedID(tc.name)].(*ir.Tuple) - require.True(t, ok, "%s: prefixItems hoists a Tuple; got %T", tc.name, doc.Types[namedID(tc.name)]) - assert.Equal(t, tc.elems, tup.Elems, tc.name) - if tc.rest == "" { - assert.Nil(t, tup.Rest, "%s: no element may follow the head", tc.name) - } else if assert.NotNil(t, tup.Rest, tc.name) { - assert.Equal(t, tc.rest, tup.Rest.Target, tc.name) - } - assert.Equal(t, tc.bounds, tup.Constraints, tc.name) - assert.Empty(t, tup.Unmodeled, "%s: a modeled rest keeps nothing beside it", tc.name) - assert.Empty(t, diagsAt(diags, "openapi/degraded-construct", "/components/schemas/"+tc.name), tc.name) - } - - rest, ok := doc.Types[restID].(*ir.Model) - require.True(t, ok, "the inline rest is hoisted at its own pointer; got %T", doc.Types[restID]) - assert.Equal(t, "pair_with_rest_item", rest.Name.Hint, "named as a List's items would be") - _, ok = propByWire(rest, "flag") - assert.True(t, ok, "the rest's own properties reach the IR") -} - -func assertLiteralConst(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - l, ok := doc.Types[namedID("Version")].(*ir.Literal) - require.True(t, ok, "const hoists a Literal") - assert.Equal(t, ir.ValueString, l.Value.Kind) - assert.Equal(t, "v1", l.Value.Str) - - // An unrepresentable const still hoists a node, and a node that admits any - // value is the only honest one: a Literal here would have to invent a value. - _, ok = doc.Types[namedID("Opaque")].(*ir.Any) - assert.True(t, ok, "an unconvertible const hoists the top type; got %T", doc.Types[namedID("Opaque")]) - assert.Equal(t, []ir.Severity{ir.SeverityWarning}, - diagsAt(diags, "openapi/degraded-construct", "/components/schemas/Opaque"), - "degrading a declared value is a warning, not silent") -} - -func assertTagsGrouping(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - require.Len(t, doc.Services, 1) - require.Len(t, doc.Services[0].Groups, 1) - assert.Equal(t, "pets", doc.Services[0].Groups[0].Name.Source) - require.Len(t, doc.TagDefs, 1) - assert.Equal(t, "pets", doc.TagDefs[0].Name) -} - -// assertGroupIdentity pins what identifies a group: the rule that formed it and -// the key that rule read, never its name. Two pairs among the seven render from -// the same words — the declared tag "default" and the group untagged operations -// fall into, the tag "webhooks" and the webhook group — and two tags differ only -// by what RFC 6901 escapes. Each keeps an ID of its own. The IDs are written out -// rather than derived, so the test cannot agree with a change to the derivation -// (GitHub #673). -func assertGroupIdentity(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - require.Len(t, doc.Services, 1) - got := map[ir.GroupID][]string{} - for _, g := range doc.Services[0].Groups { - require.NotContains(t, got, g.ID, "two groups share an ID") - for _, op := range g.Operations { - got[g.ID] = append(got[g.ID], op.Name.Source) - } - } - assert.Equal(t, map[ir.GroupID][]string{ - "g/tags/default": {"declared"}, - "g/default": {"untagged"}, - "g/tags/webhooks": {"namedLikeWebhooks"}, - "g/tags/ghost": {"undeclared"}, - "g/tags/a~1b": {"slash"}, - "g/tags/a~01b": {"tilde"}, - "g/webhooks": {"onEvent"}, - }, got) -} - -// assertParamIdentity pins what identifies a parameter: the operation it is -// mounted under, its name as one escaped token, and its location. Two -// parameters named id in one operation get two IDs; a path-item parameter is one -// parameter per operation it is copied into; a path item reached from two paths -// roots each mount's parameters at that mount. The IDs are written out rather -// than derived, so the test cannot agree with a change to the derivation, and -// every binding must name its own parameter's ID. -func assertParamIdentity(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - require.Len(t, doc.Services, 1) - got := map[ir.OpID][]ir.ParamID{} - svc := doc.Services[0] - for _, g := range svc.Groups { - for _, op := range g.Operations { - for _, p := range op.Params { - got[op.ID] = append(got[op.ID], p.ID) - } - require.Len(t, op.Bindings.HTTP, 1) - var bound []ir.ParamID - for _, pb := range op.Bindings.HTTP[0].ParamBindings { - bound = append(bound, pb.Param) - } - assert.ElementsMatch(t, got[op.ID], bound, "%s: each binding names its own parameter", op.ID) - } - } - assert.Equal(t, map[ir.OpID][]ir.ParamID{ - "op/openapi/paths/~1items~1{id}/get": { - "param/openapi/paths/~1items~1{id}/get/parameters/id/query", - "param/openapi/paths/~1items~1{id}/get/parameters/a~1b/query", - "param/openapi/paths/~1items~1{id}/get/parameters/id/path", - }, - "op/openapi/paths/~1items~1{id}/post": { - "param/openapi/paths/~1items~1{id}/post/parameters/id/path", - }, - "op/openapi/paths/~1a/get": {"param/openapi/paths/~1a/get/parameters/limit/query"}, - "op/openapi/paths/~1b/get": {"param/openapi/paths/~1b/get/parameters/limit/query"}, - }, got) -} - -// assertEnumMemberIdentity pins what identifies an enum member: the value it -// holds, tagged by kind, under the enum that declares it. The list repeats a -// value, includes names that spell one canonical word sequence, and carries the -// characters a pointer token escapes, so a derivation from the name, the -// position or the unescaped value changes an ID. The IDs are written out, not -// derived. The repeat is kept, its ID ends in its occurrence number, and it is -// reported once at its own entry; the string spelling that ID's tail is no -// repeat. -func assertEnumMemberIdentity(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - memberIDs := func(id ir.TypeID) []ir.EnumMemberID { - enum, ok := doc.Types[id].(*ir.Enum) - require.True(t, ok, "%s is an enum", id) - got := make([]ir.EnumMemberID, 0, len(enum.Members)) - for _, m := range enum.Members { - got = append(got, m.ID) - } - return got - } - assert.Equal(t, []ir.EnumMemberID{ - "e/openapi/components/schemas/E/s:a-b", - "e/openapi/components/schemas/E/s:a_b", - "e/openapi/components/schemas/E/s:A B", - "e/openapi/components/schemas/E/s:a~0b", - "e/openapi/components/schemas/E/s:a-b/2", - "e/openapi/components/schemas/E/s:a-b~12", - }, memberIDs("t/openapi/components/schemas/E")) - assert.Equal(t, []ir.EnumMemberID{ - "e/openapi/components/schemas/N/n:-1e0", - "e/openapi/components/schemas/N/n:1e0", - "e/openapi/components/schemas/N/n:0", - }, memberIDs("t/openapi/components/schemas/N")) - assert.Equal(t, []ir.EnumMemberID{ - "e/anon/paths/~1status/get/responses/200/content/application~1json/schema/s:open", - "e/anon/paths/~1status/get/responses/200/content/application~1json/schema/s:closed", - }, memberIDs("t/anon/paths/~1status/get/responses/200/content/application~1json/schema")) - - var repeats []string - for _, d := range diags { - if d.Code == "openapi/duplicate-enum-value" { - repeats = append(repeats, string(d.Provenance.Pointer)) - } - } - assert.Equal(t, []string{"/components/schemas/E/enum/4"}, repeats) -} - -func assertHTTPBinding(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) { - op, ok := opByName(doc, "getItem") - require.True(t, ok) - require.Len(t, op.Bindings.HTTP, 1) - hb := op.Bindings.HTTP[0] - assert.Equal(t, "GET", hb.Method) - assert.Equal(t, "/items/{id}", hb.URITemplate) -} - -func assertParamStyles(t *testing.T, doc *ir.Document, diags []ir.Diagnostic) { - op, ok := opByName(doc, "search") - require.True(t, ok) - require.Len(t, op.Bindings.HTTP, 1) - byParam := map[string]ir.HTTPParamBinding{} - for _, pb := range op.Bindings.HTTP[0].ParamBindings { - byParam[pb.WireName] = pb - } - q := byParam["q"] - assert.Equal(t, "form", q.Style, "query default style is form") - require.NotNil(t, q.Explode) - assert.True(t, *q.Explode, "form default explodes") - assert.Equal(t, "deepObject", byParam["filter"].Style) - assert.Equal(t, "application/json", byParam["payload"].ContentType, "content-style param records its media type") - assert.True(t, byParam["path"].AllowReserved, - "reserved characters passing through unescaped is a wire fact, not a style") - assert.False(t, q.AllowReserved, "and the default is to escape them") - - // "path" is a query parameter in this 3.1 document, the one location the - // keyword belongs to before 3.2 — so a declaration of it, which the - // assertion above pins, is not the off-query case the warning reports. - assert.Zero(t, openapitest.CountDiagsAt(diags, diag.InapplicableLocationKeyword, ir.SeverityWarning), - "a 3.1 query declaration of allowReserved stays silent") - - assertAllowEmptyValueKept(t, op) - - // The path item's shared parameter merges into both of its operations and - // interns its schema once, at the path item's own pointer (issue #36). - clearSearch, ok := opByName(doc, "clearSearch") - require.True(t, ok, "the path item's second operation is registered") - searchReqID, ok := paramByName(op, "requestId") - require.True(t, ok, "search inherits the path-item-level parameter") - clearReqID, ok := paramByName(clearSearch, "requestId") - require.True(t, ok, "clearSearch inherits the same path-item-level parameter") - assert.Equal(t, searchReqID.Type.Target, clearReqID.Type.Target, - "both operations resolve the shared path-item parameter to the same interned schema") - assert.Equal(t, ir.TypeID("t/anon/paths/~1search/parameters/0/schema"), searchReqID.Type.Target, - "the shared schema is hoisted at the path item's own pointer, not a per-operation one") -} - -// assertAllowEmptyValueKept covers the one serialization flag ir.HTTPParamBinding -// has no field for. Style, explode, allowReserved and the content-style media -// type all land on the binding above; allowEmptyValue used to be read nowhere at -// all, so a document declaring it produced IR indistinguishable from one that -// did not (GitHub #39). It is kept on the logical Parameter, which is the -// carrier at this position with an Unmodeled map. -func assertAllowEmptyValueKept(t *testing.T, op ir.Operation) { - t.Helper() - bare, ok := paramByName(op, "bare") - require.True(t, ok, "the allowEmptyValue parameter still lowers") - entry, ok := bare.Unmodeled["openapi:allowEmptyValue"] - require.True(t, ok, "and its declared flag is kept beside it") - assert.Equal(t, ir.ReasonNoIRHome, entry.Reason) - assert.JSONEq(t, `true`, string(entry.Value)) -} - -// paramSlot is what OpenAPI identifies a parameter by: its name and location. -// Two parameters may legally share a name at different locations, so a map -// keyed on the name alone silently keeps one of such a pair and compares the -// other against nothing. -type paramSlot struct { - Name string - Location ir.HTTPLocation -} - -// paramWire is what a parameter's location-dependent serialization resolves to: -// the style it serializes with, and whether it explodes. The two travel together -// because OpenAPI settles them together — the location picks the style when none -// is written, and the style picks explode. -type paramWire struct { - Style string - Explode bool -} - -// paramStylePair is one legal (in, style) combination of the Parameter object. -type paramStylePair struct { - location ir.HTTPLocation - style string -} - -// param names the fixture parameter declaring this pair with one explode -// spelling: "