fix!: keep the source constructs an OpenAPI lowering cannot carry - #156
Merged
Conversation
OmarAlJarrah
force-pushed
the
fix/openapi-silent-drops-round-two
branch
from
July 30, 2026 09:51
3451ca6 to
d343a79
Compare
OmarAlJarrah
force-pushed
the
fix/openapi-silent-drops-round-two
branch
from
July 30, 2026 09:56
d343a79 to
b81f3fc
Compare
Source constructs that reached a lowering with no field for them were dropped
without a diagnostic, and several checks meant to catch that class never reached
it.
A shape applicator — properties, patternProperties, additionalProperties,
required, items, prefixItems — has exactly one IR home, and a position whose
lowering produced neither a Model nor a List/Tuple had nowhere to put it. That
covers a contradictory schema, where one half is taken and the other vanished
({type: string, enum: [a, b], properties: {f: ...}} lowered to the enum alone),
and an applicator written with no type beside it, where the position lowered to
the top type and the applicator went with it. Each is now kept verbatim under
Unmodeled with one info diagnostic naming the kind the position lowered to. Which
keyword has a home is asked of the node the lowering produced rather than
re-derived from the dispatch that produced it. format joins the list only where
no type is declared, since a type is what homes it.
A scalar that hoisted its own node because of format: byte or an unknown format
lost the bounds it wrote beside it: owning the pointer is what stops the shared
alias path attaching them, which the content-vocabulary hoister already accounted
for and these two did not.
A boolean false allOf branch composed an empty open model — the most permissive
shape the IR has, for a source that admits nothing. §4.8 already fixes the
lowering of a bare false schema; it now holds through composition too, closing the
composed model and keeping the branch beside it. A true branch stays a silent
no-op.
Three switches over ir.ValueKind each ended in a guess, so a !!binary enum member
was described as a string and given no source name at all — two distinct members
arrived sharing one empty name. They are replaced by a single classification that
names every kind ir declares, with a test that derives the set from the ir sources
so the next addition reddens rather than being absorbed.
A multipart encoding key minted from an external $ref took the fragment as a
pointer into this document, so it named whichever local schema happened to share
the path. The document half of the ref now decides.
A conformant 3.2 document using a 3.2-only schema keyword failed to compile:
speakeasy/openapi v1.24.0 drops the options that carry the document version into
schema validation, so every schema object is checked against the 3.1 meta-schema.
Findings are now reconciled by validating each schema at the document's own
version and dropping only what the wrong meta-schema invented. Discriminator.Default
has a corpus witness as a result.
pass.Validate resolves PropID references, which nothing did: PropPath.Segments,
ParamPath.Segments, HTTPParamBinding.ParamPath and Discriminator.Property all
address a position inside a model rather than a document-level registry, so the
registry-driven walk classified them as unresolvable. Sites and declarations both
come from the existing reflection walk, and the discriminator's tag property gets
the tighter model-scoped check.
The architecture test requires every production package to be ruled or exempted,
so internal/testspec is audited and the next package cannot be added silently. The
conformance table check enumerates the corpus directory instead of globbing *.yaml,
which a spec named .yml or .json slipped past while the unwitnessed sweep still
read it.
BREAKING CHANGE: new error-severity diagnostic codes — ir/dangling-prop-ref for a
PropID naming no property the document declares, and
pass/discriminator-unknown-property for a model discriminator tagging a property the
model neither declares nor composes in. Nothing produces either today, but a document
that did validated clean before and now fails, which changes the exit status under the
CLI's default --fail-on error.
A 3.2 document using a 3.2-only schema keyword no longer emits
openapi/validation/validation-invalid-schema for it, so a consumer selecting on that
code stops seeing those findings.
Compiled output moves: a shape applicator the lowered node cannot carry, an untyped
format, a false allOf branch, and the value constraints beside a hoisted format/byte
scalar all appear in the IR where they produced nothing before. A byte-valued enum
declares valueType bytes rather than string, and its members carry their base64 text
as Name.Source rather than the empty string. {oneOf: [...], items: {...}} lowers its
structural body and keeps both halves where it previously lowered the union alone.
Downstream goldens must be regenerated.
Closes #145
Closes #146
Closes #147
Closes #148
Closes #150
Closes #151
Closes #152
Closes #153
Closes #154
Closes #155
OmarAlJarrah
force-pushed
the
fix/openapi-silent-drops-round-two
branch
from
July 30, 2026 10:02
b81f3fc to
01c92dc
Compare
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
The open issues about the OpenAPI compiler losing source constructs without saying so, and about
checks that were supposed to catch that class but never reached it. Each is named in the closing
line at the end. Each was reproduced by
compiling a probe and reading the emitted IR and the full diagnostic list before anything was
changed, and each fix was then checked against a binary built from
mainso a difference isattributable rather than assumed.
Keywords a lowering had no field for were dropped. A shape applicator —
properties,patternProperties,additionalProperties,required,items,prefixItems— has exactly oneIR home: a
Model's property set, openness and pattern bindings, or aList's element and aTuple's positions. A position whose lowering produced neither had nowhere to put it and droppedit in silence. That is one defect with two faces. A contradictory schema takes one half and lost
the other, so
{type: string, enum: [a, b], properties: {f: …}}lowered to a two-member enum andthe property set was gone — which §4.8 forbids, and which surfaced only downstream as a multipart
encoding key addressing a model the IR no longer held. And an applicator written with no
typebeside it still constrains an instance:
{items: {type: string}}constrains every array, and theposition lowered to the top type with the applicator dropped. Each unhomed keyword is now kept
verbatim under
Unmodeledwith oneinfonaming the kind the position lowered to. Which keywordhas a home is asked of the node the lowering produced, never re-derived from the dispatch that
produced it, so the two cannot drift apart.
formatjoins the list only where notypeisdeclared, because a type is what homes it — asking a node kind instead would misjudge every
{type: string, format: date}, which the formatTable resolves to a shared primitive.The same line moved in
hasUnionSiblings:itemsandprefixItemsnarrow an instance withoutdeclaring a shape, so they counted as not-a-sibling and
{oneOf: […], items: {…}}lowered theunion over a dropped applicator. That distinction is about what to build; where the question is
whether the lowering can carry what the position wrote, it does not apply, and
dynamicRefSiblingsalready drew the wider line for the same reason.A hoisted scalar lost its bounds.
format: byteand an unknownformathoist a node of theirown, which is what stops the shared alias path attaching the position's constraints afterwards —
the content-vocabulary hoister accounted for that and its two siblings did not, so
{type: string, format: byte, minLength: 5}emittedconstraints: nullwhile the same bound on anon-hoisting spelling was kept.
A
falseallOfbranch composed the most permissive shape the IR has.allOf: [false]admitsnothing; it lowered to an empty open model, which admits everything. §4.8 already fixes the
lowering of a bare
falseschema as a closed emptyModelwith aninfo, and the composition pathdid not apply it, so one source construct lowered two ways depending on where it appeared. It now
holds through composition: the composed model closes and the branch is kept verbatim beside it,
which is what distinguishes a composition containing
falsefrom a model that merely wroteadditionalProperties: false. What the other branches contributed stays — closing is the nearestshape the IR has, and emptying the model would trade one silent loss for another. A
truebranch isa genuine no-op and stays silent.
Three
ir.ValueKindswitches guessed. Each fell through to a default, so a kind none of themnamed was described as a string by one, given no text form by another, and admitted by the third. It
was reachable: YAML
!!binaryproduces aValueBytes, so an enum of two distinct byte membersdeclared
valueType: stringand arrived with two empty, identical source names. The three arereplaced by one classification that names every kind
irdeclares, and a test derives that set fromthe
irsources — so a kind added there without an arm reddens rather than being absorbed.A multipart encoding key ignored the document half of a
$ref. The fragment was cut off a./other.yaml#/components/schemas/Formreference and used as a pointer into this document, mintingan identity for a property declared in another one. It is latent only because external references do
not resolve yet (#31); the pointer now follows
internalPointer, which distinguishes the two, andfalls back to the position the reference itself occupies.
A conformant 3.2 document failed to compile.
speakeasy-api/openapi@v1.24.0'sJSONSchema[T].Validatediscards its options and callsSchema.Validatewith none, so theParentDocumentVersionthe document walk supplies never reaches schema validation and every schemaobject is checked against the 3.1 meta-schema. A 3.2-only schema keyword —
discriminator.defaultMappingis the one that surfaced — then fails against a meta-schema that does not declare it, and under the
CLI's default
--fail-on errora valid spec exits 1. Which findings to drop is derived rather thanlisted:
oas3.Validatedoes honour the option, so each schema is validated twice, once at thedocument's own version and once as the library does, and only what the second run raises and the
first does not is dropped. Nothing names a keyword, a finding on a schema the walk did not reach
appears in neither run and so is never in the difference, and once the library stops dropping its
options the two runs agree and this drops nothing.
Discriminator.Defaultgains a corpus witness asa result — its only source is
defaultMapping, and a spec declaring one could not compile cleanbefore.
PropIDreferences were resolved by nothing.PropPath.Segments,ParamPath.Segments,HTTPParamBinding.ParamPathandDiscriminator.Propertyall address a position inside a modelrather than an entry in a document-level registry, so the registry-driven walk classified the whole
class as unresolvable and no check replaced it.
pass.Validatenow resolves them. Both halves comefrom the reflection walk that already finds dangling
TypeIDs — the sites that carry aPropID, andthe
ir.Propertyvalues that declare one — so a new carrier or a newProperty-bearing list iscovered the moment it exists, where a hand-written enumeration would drift. Where the root is written
down, the tighter claim is made instead: a model discriminator's tag property must be one the model
declares or composes in.
irverifydeliberately does not grow a copy; its walk cannot reach theclass at all, and a second implementation would be a second answer to keep in step.
Two gates did not reach what they were meant to cover. The import-graph test enumerates the
packages it audits, and
internal/testspecappeared in neither that list nor the exemption commentbeside
internal/harness— so its absence read as an oversight rather than a decision, and a packagethe test does not reach can import anything with the gate still green. Every production package must
now be ruled or exempted with a reason, so the next one cannot be added silently. And the conformance
table check globbed
*.yamlwhile the sweep that computes the never-witnessed field set reads.yaml,.ymland.json: a spec added asfoo.ymlcounted as witnessed there while carryingneither a capability assertion nor a golden here. The check now enumerates the corpus directory and
fails on anything that is neither a
.yamlspec nor a golden beside one, which makes the restrictionreal rather than assumed — and pairs specs with goldens in both directions, so a stray golden no
longer sits unnoticed.
Test plan
gofmt -l,go vet ./...,golangci-lint run(0 issues),go test ./..., and./scripts/check-coverage.shall pass; coverage is 100% of statements.structurally, not by grep — provenance pointers contain keyword names and mislead.
test that reddened named.
enumMemberForm'sValueBytesarm →TestEnum_ByteMembersAreNamedAndTyped;hoistByteScalar's constraints →TestConformance/encoding-byte;bodySchemaPointer'sinternalPointercall →TestBodySchemaPointer; the 3.2 gate inmetaSchemaVersionArtifacts→TestConformance/discriminator-default-mapping; thepreserveUnhomedKeywordswrap on the untypedand enum arms, each separately →
TestConformance/unhomed-keywords;applyFalseBranches' closure →TestConformance/allof-boolean-branch;checkPropIDRefsandcheckDiscriminatorProperty, eachseparately → the new
passtests. TheValueKindcompleteness test was attacked from both sides:dropping an arm and adding a kind to
ireach redden it. The archtest was attacked three ways —an unruled package, a brand-new
internal/package, and an exempt entry naming nothing — and thecorpus check two ways: a
.ymlspec and a golden with no spec.-updatewas confirmed not to be a no-op: it produced real diffs onencoding-byte,scalar-formatanddiscriminator-inheritance, and the mutation runs above show the goldensredden.
--fail-on errorwith exit 0, andTestValidate_Corpusruns the new integrity checks over all of them, so the checks are known toreach real compiler output rather than only hand-built fixtures.
$refinto an inline sub-schema carrying an unhomed keyword, and a$refcycle were each compiled to confirm the new registry lookup on everylower()never reportsa broken invariant.
FuzzCompileandFuzzLowerSchemaeach ran a minute without a finding.mainon an alias-over-any, anir.Anynode and a realstring-vs-integer clash: only the real one is reported, before and after.
Breaking
Diagnostic codes
ir/dangling-prop-refaterrorfrompass.Validate, for aPropIDnaming no property thedocument declares. New:
pass/discriminator-unknown-propertyaterror, for a model discriminatortagging a property the model neither declares nor composes in. Nothing produces either today, but
a document that did validated clean before and now fails, which changes the exit status under the
CLI's default
--fail-on error.openapi/validation/validation-invalid-schemafor it, so a consumer selecting on that code stopsseeing those findings — which is the defect being fixed, not a side effect.
Compiled output
format, afalseallOfbranch, andthe value constraints beside a hoisted
format/bytescalar all now appear in the IR where theyproduced nothing before.
openapi/degraded-constructandopenapi/false-schemafire atinfowhere they did not, and a pipeline running
--fail-on warningis unaffected. Downstream goldensmust be regenerated.
valueType: bytesrather thanstring, and its members carry theirbase64 text as
Name.Sourcerather than the empty string.{oneOf: […], items: {…}}lowers its structural body and keeps both the union and the applicator,where it previously lowered the union alone.
Deliberately out of scope
Each is also stated in the code where a reader reaches it.
document is mis-validated in the other direction. Reconciling it is not the removal of a false
positive: the 3.0 meta-schema words the same defect differently and raises findings 3.1 does not, so
it changes what a 3.0 document reports. The gate is
3.2only, andmetaSchemaReconciledMinorrecords why.
declared type from the same schema and the homes are the variants', not the
Union's — so{type: [string, object], properties: {…}}puts the property set on the object variant and nothingis lost.
itemsas a list — is a §4lowering decision this repository has not taken. Keeping the source is the honest alternative and
leaves that decision open.
$refbranch's$ref-adjacent siblings in anallOfare still dropped (compilers/openapi: $ref-adjacent siblings on an allOf branch are silently dropped #143): giving thatbranch a node of its own moves what
Base/Mixinspoint at.Docs.Description's{t:TypeID}cross-reference tokens are still resolved by nothing. Theyneed a token parser rather than a lookup, and a false positive inside prose is noisier than a
missing check.
does not hold, so
pass.Validatereportsir/encoding-key-unknown-propertybeside the newdiagnostic. That check is correct about what it checks, and the reader now gets the cause beside
the symptom rather than only the symptom; whether such a body should mint those keys at all is a
question about
Content.Encodingrather than about what is dropped.Closes #145
Closes #146
Closes #147
Closes #148
Closes #150
Closes #151
Closes #152
Closes #153
Closes #154
Closes #155