Skip to content

fix(compilers/openapi): keep bounds beside a preserved union - #363

Open
OmarAlJarrah wants to merge 1 commit into
mainfrom
fix/openapi-constraints-beside-union
Open

fix(compilers/openapi): keep bounds beside a preserved union#363
OmarAlJarrah wants to merge 1 commit into
mainfrom
fix/openapi-constraints-beside-union

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Aug 9, 2026

Copy link
Copy Markdown
Member

Summary

A schema that co-declares oneOf/anyOf with structural keywords lowers its structural
body and keeps the union verbatim beside it. When the body reduces to a shared primitive,
the position hoists an alias so the preserved union has a node of its own to attach to —
and that alias was interned with nil constraints, so every value constraint written at
the position was dropped in silence.

Ownership is what made it terminal: hoistDeclarationHome resolves to whatever node the
pointer already owns and returns early, so the fallback that carries constraints for every
other position never ran. A position that also wrote an unhomed keyword happened to be
rescued, because preserveUnhomedKeywords interns its alias through the constraint-reading
path first — which is why this went unnoticed.

The fix reads the position's constraints and hands them to internAlias, as every other
alias hoist here does. Their diagnostics are routed too, so the co-declared-bound
reconciliation now reports at these positions, where before nothing read the bounds to
report on.

Every internAlias call site

Five call sites, one of which passed nil:

site constraints verdict
lowerComponentSchema (schema.go) schemaConstraints correct
hoistDeclarationHome (schema.go) schemaConstraints correct
lowerBesideUnmodeledUnion (schema.go) nil the defect — fixed here
preserveUnhomedKeywords (schema.go) schemaConstraints correct
hoistSubSchema (resolve.go) schemaConstraints correct

The wider mechanism — a node that hoists for its own reasons, and so must carry the
position's constraints itself because owning the pointer stops the declaration-home
fallback — was swept over the internNode hoisters too: lowerModel, lowerArray
(collection bounds via listConstraints), hoistByteScalar, hoistFormatScalar and
hoistContentScalar all read them already; falseSchema interns for a schema that writes
no keywords at all.

What the sweep also turned up is a different question, and #268 already frames it: a
keyword the elected lowering never reads. lowerAllOf builds an ir.Model and never
fills its Constraints, so {allOf: [...], minProperties: 2} drops minProperties; and
lowerEnum/hoistLiteral/lowerOneOfAnyOf own their pointer while ir.Enum,
ir.Literal and ir.Union carry no Constraints field at all, so {enum: [...], minLength: 3} has nowhere to put the bound. Deciding those needs the per-winner rule #268
is about — whether the answer is an IR home or an Unmodeled entry differs between the
two — so they are left standing here and recorded on that issue rather than settled as a
side effect of this fix.

Test plan

Compiling the issue's reproducer through the CLI, before and after.

Before:

$ 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"]}
info openapi/validation-only-keyword a.yaml#/components/schemas/A: validation-only keyword "oneOf" kept verbatim under Unmodeled

After:

$ morphic compile a.yaml -skip-validate | jq -c '.types["t/openapi/components/schemas/A"] | {kind, constraints, u: (.unmodeled|keys)}'
{"kind":"scalar","constraints":{"exclusiveMin":false,"exclusiveMax":false,"minLength":3,"uniqueItems":false},"u":["openapi:oneOf"]}
info openapi/validation-only-keyword a.yaml#/components/schemas/A: validation-only keyword "oneOf" kept verbatim under Unmodeled

The numeric variant {type: string, minimum: 10, exclusiveMinimum: 0, oneOf: [...]} behaves
the same way, and now also reports the bound it had to reconcile — a diagnostic that could
not fire while nothing read the bounds:

$ morphic compile b.yaml -skip-validate | jq -c '.types["t/openapi/components/schemas/B"].constraints'
{"min":"10","exclusiveMin":false,"exclusiveMax":false,"uniqueItems":false}
info openapi/degraded-construct b.yaml#/components/schemas/B: minimum 10 and exclusiveMinimum 0 both bound this value and the IR holds one bound per side; kept minimum as the tighter of the two and dropped exclusiveMinimum, which it implies

TestOneOf_CoDeclaredKeepsTheBoundsWrittenBesideIt covers both routes to the verbatim
lowering plus the reconciliation case. Restoring the nil reddens it:

--- FAIL: TestOneOf_CoDeclaredKeepsTheBoundsWrittenBesideIt (0.00s)
    --- FAIL: TestOneOf_CoDeclaredKeepsTheBoundsWrittenBesideIt/a_validation-only_union (0.00s)
        	Error:      	Expected value not to be nil.
        	Messages:   	while keeping the bounds written beside it
    --- FAIL: TestOneOf_CoDeclaredKeepsTheBoundsWrittenBesideIt/a_union_kept_as_a_degraded_lowering (0.00s)
        	Error:      	Expected value not to be nil.
        	Messages:   	while keeping the bounds written beside it

Dropping only the diagnostic routing (cons, _ := schemaConstraints(...)) reddens the third
case on its own, so both statements of the fix are held by an assertion:

--- FAIL: TestOneOf_CoDeclaredKeepsTheBoundsWrittenBesideIt/co-declared_bounds_beside_a_union (0.00s)
        	Error:      	"[]" should have 1 item(s), but has 0
        	Messages:   	want exactly one info "openapi/degraded-construct" at "/components/schemas/A"

No golden moved when the fix landed, which is the corpus sharing the code's blind spot: no
committed spec put a constraint beside a union kept verbatim. allof-oneof-cooccurrence
gains that shape (BoundedKinds) beside the model-body case it already carried; its golden
is regenerated with -update and now records the alias holding both the union and
minLength: 3. Restoring the nil reddens TestConformance/allof-oneof-cooccurrence too.

Gate: gofmt, go vet ./..., golangci-lint run (0 issues), go build ./...,
./scripts/check-coverage.sh (100% of 4944 statements) all pass.

Closes #343

A schema that co-declares oneOf/anyOf with structural keywords lowers its
structural body and keeps the union verbatim. When that body reduces to a
shared primitive, the position hoists an alias so the preserved union has
a node of its own to attach to — and that alias was interned with nil
constraints, so every value constraint written at the position was
dropped in silence:

    A: {type: string, minLength: 3, oneOf: [{minLength: 1}, {minLength: 2}]}

lowered to a scalar with no constraints at all, and no diagnostic named
minLength.

Ownership is what made it terminal. hoistDeclarationHome resolves to
whatever node the pointer already owns and returns early, so the fallback
that carries constraints for every other position never ran. A position
that also wrote an unhomed keyword happened to be rescued, because
preserveUnhomedKeywords interns its alias through the constraint-reading
path first — which is why this went unnoticed.

Read the position's constraints and hand them to internAlias, as
lowerComponentSchema, hoistDeclarationHome, hoistSubSchema and
preserveUnhomedKeywords all already do. Their diagnostics are routed too,
so the co-declared-bound reconciliation now reports at these positions,
where before nothing read the bounds to report on.

The corpus had the same blind spot as the code: no committed spec put a
constraint beside a union kept verbatim, so nothing moved when the drop
was introduced. allof-oneof-cooccurrence gains that shape beside the
model-body case it already carried.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant