Skip to content

openapi: value constraints are dropped beside an unmodeled oneOf/anyOf #343

Description

@OmarAlJarrah

A schema that co-declares oneOf/anyOf with structural keywords lowers its structural body and keeps the union verbatim (lowerBesideUnmodeledUnion, compilers/openapi/internal/schema/schema.go). When that body reduces to a shared primitive, the position hoists an alias node so the preserved union has somewhere to attach — and that alias is interned with nil constraints, so every value constraint written at the position is dropped, silently.

Reproduction

openapi: 3.1.0
info: {title: T, version: 1.0.0}
paths: {}
components:
  schemas:
    A:
      type: string
      minLength: 3
      oneOf: [{minLength: 1}, {minLength: 2}]
$ morphic compile a.yaml -skip-validate | jq -c '.types["t/openapi/components/schemas/A"] | {kind, constraints, u: (.unmodeled | keys)}'
{"kind":"scalar","constraints":null,"u":["openapi:oneOf"]}

minLength: 3 reaches no field of the document and no diagnostic names it. The only diagnostic emitted is the one about the union. A numeric pair behaves the same way: {type: string, minimum: 10, exclusiveMinimum: 0, oneOf: [...]} produces constraints: null too.

Why it happens

Every other alias hoist reads the position's constraints first — lowerComponentSchema, hoistDeclarationHome, hoistSubSchema and preserveUnhomedKeywords all call schemaConstraints and pass the result to internAlias. This one passes nil. Ownership is what makes it terminal: hoistDeclarationHome resolves to whatever node the pointer already owns and returns early, so the fallback that would otherwise carry the constraints never runs.

A position that also writes an unhomed keyword happens to be rescued, because preserveUnhomedKeywords interns its own alias through the constraint-reading path first. Remove the unhomed keyword and the loss appears — which is why it has gone unnoticed.

Expected

The alias hoisted beside an unmodeled union carries the position's value constraints, exactly as every other alias hoist does. This is the same rule hoistByteScalar states for itself: owning a node is what stops the declaration-home fallback carrying them, so a node that hoists for its own reasons must carry them itself.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions