Repository navigation
fix(compilers/openapi): keep a keyword at every schema outcome - #1217
Merged
Merged
Conversation
A keyword reached the IR only on some of the outcomes a schema lowers through. Three outcomes returned before the census ran: a Union, a multi-typed schema and an expanded $dynamicRef. The gate that hoists a node for a shared position never asked about a keyword the census keeps, so an unknown keyword there went nowhere. A null keyword was read three ways depending on the keyword and the node it sat on. Every outcome now runs the census. A collapsed union and an expanded $dynamicRef take a $ref site's, and a type array and a distributed union ask their variants, each built from the schema itself, so what they consume is neither kept nor reported. The gate reads what the census would keep rather than a second list. RawPropertyNode, the one reader every keeper goes through, reads a null as absent, unless the keyword's value is an instance. declaredTypes does the same for `type`, which the parser decodes as a type spelled "". The table grades each such null as a field left out, a bound included. An unknown keyword beside a union branch's $ref now hoists an alias, and an alias is what a mapping naming that component missed, so a document that compiled with the keyword dropped would have failed validation. A mapping target now routes to the one variant aliasing it.
This was referenced Nov 1, 2026
This was referenced Nov 1, 2026
A null keyword reads as the keyword left out, decided in annotation.RawPropertyNode, but two readers of the raw tree decide whether a $dynamicRef expands and go through neither it nor the model. declaresResourceIDAbove took a null $id for a resource boundary, so a null at or above either end stopped the expansion. anchors.record indexed a null $dynamicAnchor under its text, so `$dynamicRef: '#null'` expanded to a schema naming no anchor. Each now reads the key as absent, as bind's own reader of those keywords already does.
Two guards this branch adds had no test that fails without them: routing a defaultMapping to the variant aliasing its target, and reading a null $ref as no reference only in a Schema Object. The second is out of a document's reach while the parser refuses a null $ref elsewhere (#1284), so it is pinned where the check runs.
The rules now say a mapping to a type two variants alias is refused (#1298), and that a null beneath the same keyword turns the outer schema false (#1299), neither of which the branch fixes. The lines it added past the column limit are rewrapped, and a comment that named DecidedKeywords after the census stopped passing them is corrected.
This was referenced Nov 1, 2026
The type-arrays rule said each variant holds what its own type admits, but a value bound lands on any variant with a Constraints field, one whose type it cannot constrain included, which #1310 tracks; it now says so where the rule and the census state it. The unknown-keys rule said a keyword the dialect does not define is left out, when the census keeps it as it keeps an unknown one, and hoistDeclarationHome named only a collapsed union among the outcomes lowering to a reference.
The specs these tests compile spelled newlines and quotes by hand, which rule 12.11 of the styleguide asks to write as raw string literals.
OmarAlJarrah
added a commit
that referenced
this pull request
Nov 2, 2026
#1219 landed 0.24.0 first, so this change keeps the row and the history entry main holds and appends its own under 0.25.0, with the names hash the pin main added requires. Its history entry now names 0.24.0 as the generation a pinned consumer holds. The conformance and golden cases #1217 and #1219 added each declare the unresolved auth namespace.
OmarAlJarrah
added a commit
that referenced
this pull request
Nov 2, 2026
main took IRVersion 0.24.0 for #1219, so this change moves to 0.25.0, with its own generations row and history entry. #1217 made an expanded $dynamicRef run the keyword census. That census asks keywordHome at no node, which now homes a collection bound the reader fills, so the keywords-at-every-outcome carrier holds its maxItems in Constraints. The case now writes a false uniqueItems beside it too, which stays verbatim, so the census at that carrier is still pinned. The rule's Status drops the items #1217 closed and the clause saying a bound beside an expanded $dynamicRef is dropped.
OmarAlJarrah
added a commit
that referenced
this pull request
Nov 2, 2026
Resolves the conflicts with #1217 and #1219, and what their rules ask of the readers this branch adds: - keepsAtOutcome takes referenceOutcomeKeywords for every outcome that is not a refinement, so an expanded $dynamicRef keeps its census, and unmergedBranchKeys keeps #1217's null check beside the predicate this branch passes it. - A null keyword is the keyword left out (#1217). writtenKinds and saysKind read `type: null` as no type written (writesType), where a null type had admitted nothing and kept `{allOf: [{type: null}]}` a Model; voicesAmong and restates pass over a null keyword, where `description: null` on one branch had kept another's description verbatim as a disagreement. - declaresScoped passes the context declaresPositionScoped now takes. - TestAllOf_ANullTypeExcludesNoObject composes, so it still reaches the branch residue it pins: its old spec now refines. - A node at an inline allOf branch takes the name of the schema holding it, which moves a hint the name pin (#1219) holds, so IRVersion moves to 0.25.0 with a row keeping 0.24.0's shape.
OmarAlJarrah
added a commit
that referenced
this pull request
Nov 2, 2026
#1219 took 0.24.0 and #1226 0.25.0, so this change moves to 0.26.0, keeps both rows and history entries, and appends its own. #1219 turned the field list of docs/ir/unmodeled.md#promotion into a table held to the code both ways. It gains the fields this change fills: a Deprecation itself at each carrier, Operation.Stability and Parameter.Secret, with TypeCommon named among the nodes keeping a deprecation's or a Secret's extension, as a $ref's target lends both to a use. TestExtensionTarget_EveryTargetIsInThePromotionTable now names every field a target fills rather than one, since TargetRedaction fills three. OperationGroup joins Variant and EnumMember as a node rule 4 names, and TestPromotionTable_EveryNodeCanRecord holds it there. #1219's rule 3 records a promotion on the node keeping the extension. A field a $ref's target lends a use is the one case this change records twice: the target keeps the entry and its marker, and the use lists the marker too, since a use is read without resolving its target and its type reaches the entry. Rule 3, extension-promotion and the 0.26.0 history entry say so. #1217 ended fillPropertyAnnotations in PreserveCarriedKeywords; the promotion this change moved to PromoteUse stays out of it. #1226 rewrote lowerSecurityScheme's return, which now calls the Promoter. The two new conformance goldens gain the unresolved auth namespace every OpenAPI document declares since #1226.
OmarAlJarrah
added a commit
that referenced
this pull request
Nov 2, 2026
The merge of #1217 taught voicesAmong and restates to pass over a key written as null, which reads as the keyword left out: a branch writing `description: null` had disagreed with one writing `description: d`, keeping d verbatim, and items restating a $ref's but for a null keyword had kept their branch. Neither reader had a test of its own, and TestCompile_ANullKeywordLowersAsTheKeywordLeftOut writes one branch alone, where no disagreement can arise. These pin both, in both orders for the first.
OmarAlJarrah
added a commit
that referenced
this pull request
Nov 2, 2026
A refinement's inline principal that lowers as a type array dropped what none of its variants holds, as a type array written alone did, and the allof-refinement rule and keepsAtOutcome named that as a limit (#852). #1217 closed it: the census asks the variants and keeps such a keyword on the union, and a principal reaches the same census. So `{allOf: [{type: [string, integer], minLength: 1}]}` keeps minLength on its union, as `{type: [string, integer], minLength: 1}` does. The limit leaves the rule's status and the comment, and TestRefinement_ATypeArrayPrincipalKeepsWhatNoVariantHolds holds both spellings to it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A schema keyword reached the IR only on some of the outcomes a schema lowers through. Three outcomes returned before the keyword census ran, the gate that gives a shared position a node of its own never asked about what the census keeps, and a
nullwas read a different way depending on the keyword and the node it sat on. On Cloudflare's schema this losesminimumandmaximumbeside theoneOfofspectrum-config_origin_port.Every outcome now answers the census:
A
Unionkeeps what it has no field for, a value bound included.A type array and a distributed union ask their variants, since each variant is built from the schema itself. What a variant consumes is neither kept nor reported, and anything else is kept on the union. A
formatbeside a distributedpropertiesis kept; thepropertiesare not.An expanded
$dynamicReflowers to a reference, so it takes a$refsite's census with a collapsed union (referenceOutcomeKeywords), and a carrier reaches the same function.A parameter keeps what a property and a header keep. Its filler carried its own copy of that list, which had lost the content vocabulary and an unexpanded
$dynamicRef, so both vanished without a report. All three carriers now go throughschema.PreserveCarriedKeywords, so they cannot drift again.The hoist gate reads what the census would keep (
annotation.WritesUnknownKeyword,undefinedKeywords) rather than a second list, so an unknown keyword, and anullablethe dialect does not define, lands on an alias at a position with no node or carrier.A
nullfor a Schema Object keyword reads as the keyword left out. That is decided once, inannotation.RawPropertyNode, which every keeper goes through, rather than per keeper, withconst,defaultandexampleexcepted because their value is an instance.schema.writesTypeandeffectiveTypesdo the same fortype, which the parser decodes as a type spelled""(docs/parser-library.md#null-type-is-empty, with its canary).unmergedBranchKeyslists keys rather than asking for one, so it asksannotation.WritesKeywordLeftOut. The two readers of the raw tree that decide whether a$dynamicRefexpands read it the same way: a null$idstarts no resource, and a null$dynamicAnchoris no anchor namednull. The table grades every suchnullas a field left out, numeric bounds included, whose shape it otherwise leaves to the lowering.Why #847 and #764 are in here
An unknown keyword beside a
$refnow hoists an alias, and an alias is exactly what a mapping misses, so a document that compiled with the keyword silently dropped would have started failingpass/discriminator-missing-variant. Two shapes reach it, and fixing only the first would make this PR worse thanmainon the second.A mapping naming a component a branch aliases (#847). A mapping target now routes to the variant whose branch names it (
referencedVariants). That reads the branches, not the registry, so it turns on no lowering order. Two options were open in the issue: route in the lowering, or teach validation to read through aliases. The lowering is the right side because an emitter routes byMapping, which has to name a variant.A type two branches name maps to neither, in either branch order. If one of them is the type itself the mapping stays on that variant, and if both alias it the pass reports it, which is the stated rule the issue asked for, though it refuses a valid document (below).
A mapping naming a position whose
$refhas something beside it (#764).readsThroughused to stop there, keeping an alias that is no variant or subtype. It no longer asks what is beside the$ref, only whether the position is itself a variant, so the mapping means what the$refnames. That also retiresrefSiteHomesNothing.Behaviour to check on review
type: nullis the keyword left out, as openapi: a null schema keyword is read three ways and draws an error #1069 proposed. Real documents write this unquoted in ananyOfbranch, most likely meaning"null".openapi-generator's3_1/null_schema_test.yamland3_1/simplifyOneOfAnyOf_test.yamlgo from exit 1 to exit 0 under a warning that says how it was read, and such a branch lowers to the top type. An author who meant"null"gets a wider type and a warning, not a refusal.Expectedin openapi: an unknown schema keyword at a position with no node or carrier is dropped #1053 asks for.oneOf: [{$ref: X, k: 1}, {$ref: X, k: 2}]with a mapping toXgoes from exit 0 to exit 1:maindrops an unknown keyword, which now gets an alias as adescriptionalready does, andmainrefuses thedescriptionspelling the same way. No spec intestdataormorphic-referencesgoes to exit 1, and openapi: a mapping to a type two union branches alias is refused #1298 tracks the refusal.Constraintsfield.type: [integer, object]withminimum: 1putsminon the object variant and none on the integer, onmainas here; the census counts that as homed rather than keeping it on the union (openapi: a type array's bound lands on a variant it cannot constrain #1310).nullbeneath itself still misreads.items: {type: string, items: null}turns the outeritemsinto thefalseschema, onmainas here, since the parser decodes that body as a boolean (openapi: a keyword failing to decode beneath itself turns the outer schema false #1299).Test plan
irverifyon each. Identical output means it vanished silently: 6,015 pairs onmainand 3,078 here, with 2,937 fixed and no pair worse, and no violation on either. The 3,078 are 2,888 for the four exemptionsdocs/compilers/openapi.md#vocabulary-coveragenames, and 190 where the keyword says nothing new: atypea string enum already implies, anallOf: [{type: object}]on an object, and a 3.0nullableon a component nothing references. This check is a throwaway script; the conformance cases andoperation.TestParams_KeepWhatAnyCarrierKeepshold the shapes.e2e.TestCompile_ANullKeywordLowersAsTheKeywordLeftOutreads every keyword off the table at 3.0.3, 3.1.0 and 3.2.0, in four positions and several contexts, and requires the IR to equal the same document with the key removed. It found what a list of the issue's examples would not: a 3.0exclusiveMinimum: null, a 3.0$ref: nullstill reading its siblings as beside a$ref,dependentRequiredand$vocabulary, which have no model field, and anallOfbranch counting anullas residue.e2e.TestCompile_ANullIdentifierLowersAsTheKeywordLeftOutholds the two readers that sweep cannot reach, as it writes no$dynamicRef. A wider throwaway run of 54,740 pairs, adding a union, a type array and a$refas what each keyword sits beside, differs only undernotand in anallOfbranch, which keep the schema verbatim, and at openapi: a keyword failing to decode beneath itself turns the outer schema false #1299's shape. A third run of 136,568 pairs adds outcomes (type arrays mixing scalars with objects, a discriminated union, a distributed union withallOforrequired, a tuple, a 3.0nullable), positions (component parameters, headers, bodies and responses, path-level parameters, callbacks, encoding headers,propertyNames,if,contentSchema) and a second value per keyword, each empty or falsy: none is worse thanmain, 6,414 are fixed, and what both builds leave alike is a value that states nothing, such astitle: ''ordeprecated: false, or openapi: unevaluatedProperties false on a position lowering to no model is dropped #1283's shape.keywords-at-every-outcome,unknown-keywords-at-shared-nodes,null-keywords,discriminator-aliased-branch.defaultMappingand of a null$refoutside a Schema Object, since filled.sweep.pywith--verifyagainstmainovertestdataandmorphic-references(2,175 specs): 1,652 stay 0, 519 stay 1, 4 go 1→0 (the two specs above and two of the new cases, whichmainrefuses), and none get worse. 16 specs differ. I read the 12 real-world ones and each is one of: a bound beside a union kept (api_constrained,anyOf.json),$recursiveRefat a branch kept, anullerror turned warning, or an invalidtype, such as an unquotedtype: [string, null], no longer lowering a variant for the empty type.$ref, beside everytype, and a bound and aformatbeside everyoneOf,anyOfand type array, into 1,396 documents drawn from those two corpora, throughharness.Checkon both builds: nothing panics, nothing breaksirverify, determinism or order-invariance, and the one spec goingokto error is the openapi: a mapping to a type two union branches alias is refused #1298 shape above. Compared with the same spec before injection, with each alias the keyword minted read through to its target and its own entry set aside, every type, property, parameter and mapping is unchanged.openapi.json@319dc756af: one new info, atspectrum-config_origin_port, and that type'sunmodeledis the only change to the type registry.BenchmarkCompile_Petstore: 52 more allocations of about 12,051 (0.4%), and on Cloudflare's schema 0.3% more instructions retired, medians of three alternating runs whose ranges overlap. A first version returned a slice from a helper, which pushedGetType's stack-allocated slice to the heap at every read; a profile diff found it, and the predicate that replaced it does not.go test ./... -racewithmorphic-covergateat 100% of statements, vet, build, lint and the planted-lint check.internal/anchorsanddeclaresResourceIDAbove, passesgo test ./compilers/openapi/internal/schema ./compilers/openapi/internal/bindand the e2e conformance, lowering-spec and null tests, asbindalready reads a null identifier as left out. It conflicts in five files, thedynamic-refrule among them, and needs two fixes no conflict marks, which that PR lists.TestBuild_ANullAnchorNamesNothingand its citation innull-keywordsgo with the package. fix(compilers/openapi)!: lower allOf by what its conjuncts say #1269 conflicts in five files and adds a caller ofcollapsedUnhomedKeywords, which this removes, feat(ir)!: type the secrecy and stability a document states #1229 conflicts in three, and fix(compilers/openapi): treat a restated union branch as bare #1278 indocs/compilers/openapi.md.Closes #848
Closes #852
Closes #1053
Closes #1069
Closes #847
Closes #764