fix(compilers/openapi): keep the keywords a schema co-declares - #270
Conversation
Schema lowering picks one keyword per position and never revisits the rest
of the body, so a schema writing more than one lost everything but the
winner — with no Unmodeled entry and no diagnostic. Both halves violate
"lossless by default": a compiler that cannot model something must keep it
verbatim and say why.
Two independent sites, one mechanism.
lower() elects one keyword family in the order const, enum, allOf, and
first match wins. `allOf: [{$ref: Base}]` beside an `enum` is the ordinary
narrowing idiom, not a malformed document, and it compiled to a bare Enum
with the whole relationship to Base gone. The same happened for allOf
beside const, and for enum beside const. The election is now derived once
(dispatchOf) and drives both the arm lower() takes and the set
recordSkippedFamilies keeps, so the winner and the losers cannot be read
off two tests that disagree.
unionBranches takes oneOf whenever it is written and falls back to anyOf
only when it is not, so a schema declaring both silently lost the anyOf.
The elected set still becomes the Union — that is a shape the IR can
express, and discarding it would model nothing at all — and the set passed
over is kept on the Union node. nullUnionCollapse shared the same
preference and the same loss; a {X, null} oneOf beside an anyOf now
declines to collapse, since the collapse asserts the position *is*
nullable X while a co-declared anyOf conjoins with it, and collapsing
would resolve the position to a shared primitive that must never carry one
declaration's keywords.
Neither half flattens or merges anything. Every kept entry records
ReasonDegradedLowering and the pointer it was written at, and each
position reports only what it actually stored, so a payload that fails to
convert is reported as unpreservable rather than announced as kept.
A keyword the *elected* lowering never reads — `type: string` beside an
allOf, `format` beside a const — is a different question, since `allOf`
beside `type: object` is the common case and loses nothing. It needs a
per-winner rule rather than a keyword list and is left open at #268.
The order-independence test asserted that a node exists at the union
branch's pointer, with the message that it is one "both lowerings reach".
Those are not the same claim, and only the weaker one was checked: the
outside $ref hoists that node on its own, so the assertion holds even for
a bare `{type: string}` branch — the exact fixture the test was written
away from, because such a branch resolves through the union to the shared
primitive and never competes for the pointer.
Verified by planting a branchHint that disagrees with subSchemaHint and
reducing the fixture to a bare branch: the test passed, guard included.
It now pins the union's own variant to the branch's node, in both
documents. That is the state where two lowerings reach one pointer and
only the first to arrive interns it, so a hint disagreement changes the
IR — and reducing the branch reddens the guard instead of slipping past
it.
Electing one of several conjoined keywords and keeping the rest verbatim is a new source-construct degradation, and the code that performs it cites section 4.8 as its authority — but 4.8 enumerated only the structural-sibling case, and the OpenAPI row of the spec matrix listed neither. Both now describe what the compiler does: which families compete, which wins, where the losers land, and why the elected combinator still becomes a Union rather than degrading to the top type. The keyword an elected lowering never reads is named there as unsettled, so the boundary is stated where the rule is, not only in a tracker entry. unionBranches carried the premise the whole bug grew from — that only the verbatim lowering ever sees a schema writing both combinators. That was already false when it was written, and it is now the sentence a reader would have to disbelieve to understand the callers. It names what each caller owes the set it passed over instead.
declaresFamily answered allOf's guard from its default arm, so a name added to familyOrder without a case here would report "declared" on every schema that wrote an allOf, and be elected on schemas that never wrote it at all. allOf is now its own case and an unrecognised name declares nothing, which keeps it out of the election entirely. That direction matters because of what the losing direction costs. dispatchOf's comment claimed a family listed in familyOrder but absent from lower() "would be kept verbatim rather than dropped". Only when it loses. Dropping lower()'s allOf arm while leaving allOf in familyOrder and compiling a schema that declares only an allOf yields a bare scalar, an empty Unmodeled and zero diagnostics -- a keyword dropped in silence, which is the failure GitHub #35 exists to fix. A winner lower() cannot lower falls through the switch to the type-set arms and is neither lowered nor skipped. The comment now says which of the three pairings fail safely and which does not. TestDeclaresFamily_AnUnknownNameDeclaresNothing reaches the new arm directly, as TestRecordSkippedFamilies_MissingOwner already does for its own unreachable guard, and reddens if the default goes back to answering for allOf.
Code reviewFound 2 issues, both fixed on the branch (70dcd0d, da48be4). 1. A keyword family could still be dropped in silence — the failure this change exists to fix. Compounding it, morphic/compilers/openapi/internal/schema/schema.go Lines 438 to 470 in 8a96be4 2. #281 was recorded only in the PR body. #268 is stated in morphic/compilers/openapi/internal/schema/schema.go Lines 1705 to 1719 in da48be4 The change itself is correct. All five shapes were checked by compiling and reading the full
Every entry carries The mutation matrix reproduces in full, each row landing where the table says:
The two announcement guards are each killed by exactly one test and nothing else, so "report only what you actually stored" is pinned rather than incidentally covered. Deleting the Two structural points worth recording for later readers. One reproduction note for anyone re-running the matrix: removing Gate green at da48be4: 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
The {X, null} collapse hands the surviving branch the enclosing schema's
name hint, while an outside $ref to that same branch pointer derives
variant_<index>, so the two declaration orders produce different
documents. That is deliberately left alone here and tracked in #281, but
it was recorded only in the pull request body -- a reader arriving at
nullUnionCollapse had nothing telling them the hint it returns is a known
open question, and this change edits that very function.
Stated on nullUnionCollapse itself now, including what this change does
and does not do to it: declining the collapse when both combinators are
declared removes one order in which the discrepancy can be reached, and
settles nothing else.
Summary
Schema lowering picks one keyword per position and never revisits the rest of the schema body. A schema that legally writes more than one lost everything but the winner — no
Unmodeledentry, no diagnostic, nothing in the IR to recover it from. That breaks "lossless by default": a compiler that cannot model a construct must keep it verbatim and record why.Two independent sites, one mechanism. Both were confirmed by compiling the inputs and reading the full
[]ir.Diagnostic, not by reading the code.Keyword families.
lower()elects one ofconst,enum,allOfin that order, first match wins.allOf: [{$ref: Base}]beside anenumis the ordinary narrowing idiom rather than a malformed document, and it compiled to a bareEnumwith the entire relationship toBasegone:The same silent drop applied to
allOfbesideconstand toenumbesideconst— neither is named in the issue; both are the same mechanism and are fixed here. The election is now derived once (dispatchOf) and drives both the armlower()takes and the setrecordSkippedFamilieskeeps, so the winner and the losers cannot be read off two tests that drift apart.Three declarations have to name the same families —
familyOrder,declaresFamilyandlower()'s switch — and only two of the three pairings fail safely. A namefamilyOrderlists thatdeclaresFamilydoes not know is never declared, so it is never elected; a name that loses the election is kept whether or notlower()can lower it. But a name that wins an electionlower()has no arm for is neither lowered nor skipped: the switch falls through to the type-set arms and the keyword is dropped in silence, which is the defect this change exists to remove.declaresFamilytherefore answers an unrecognised name with "not declared" rather than falling through toallOf's guard — otherwise a family added tofamilyOrderalone would report itself declared on every schema writing anallOf, and be elected on schemas that never wrote it.dispatchOf's comment records which pairing is unsafe.Union combinators.
unionBranchestakesoneOfwhenever it is written and falls back toanyOfonly when it is not, so a schema declaring both silently lost theanyOf:The elected set still becomes the
Union: that is a shape the IR can express, and discarding it as well would model nothing at all.nullUnionCollapseshared the same preference and the same loss, so{oneOf: [X, null], anyOf: [...]}now declines to collapse — the collapse asserts the position is nullable X, while a co-declaredanyOfconjoins with it, and collapsing would resolve the position to a shared primitive that must never carry one declaration's keywords.Where a structural sibling is written as well (
{type: object, properties: ..., oneOf: ..., anyOf: ...}),classifyUnionSiblingsalready reachedunionBothCombinatorsand kept both sets verbatim. That path is unchanged; the rule across all of them is the same one — lower the most the IR can express at the position, keep the rest beside it.Nothing is flattened or merged. Every kept entry carries
ReasonDegradedLoweringand the pointer it was written at, and each site announces only what it actually stored, so a payload that fails to convert is reported as unpreservable rather than claimed as kept.Deliberately out of scope
A keyword the elected lowering never reads is a different question:
type: stringbeside anallOf, orformatbeside aconst, are still dropped silently. Deciding those needs a per-winner rule rather than a keyword list, becauseallOfbesidetype: objectis the common case and loses nothing — a rule keyed on node kind alone would fire on a large share of real specs. Filed as #268 and stated inlower()'s doc comment.One neighbouring defect is older than this change and is left alone: the ordinary
{X, null}collapse — the one a schema writing a single combinator still takes — hands the surviving branch the enclosing schema's name hint, while an outside$refto that branch pointer derivesvariant_<index>, so the two orders produce different documents. It is #181's mechanism at a site #181 did not sweep, it reproduces onmain, and this change only narrows its reach by declining the collapse when ananyOfis co-declared. Filed as #281, and stated onnullUnionCollapseitself — the function whose collapse it is — rather than here alone.Test plan
gofmt/go vet/golangci-lintclean;./scripts/check-coverage.shpasses at exactly 100% (4653 statements).New corpus entry
testdata/conformance/openapi/codeclared-keywords.yamlcovers all five shapes end to end, with a capability assertion and a golden IR snapshot. Being in the conformance corpus also puts it through the dangling-reference sweep, the fuzz seed corpus,pass.Validate, and the harness oracles (irverify, JSON round-trip, determinism, order invariance).go run ./cmd/morphic-harnesson the fixture reports ok.Unit tests in the schema package cover each shape plus the two announcement guards and the missing-owner invariant, and a two-order test compiles the union with an outside
$refto one of its branches declared first and last andcmp.Diffs the documents.Every assertion was proven able to fail by planting the defect and watching it go red:
buildUniondrops the passed-over combinator (the original bug)TestUnionCombinators_{PassedOverBranchSetIsKept,NullBranchDoesNotCollapsePastTheAnyOf,UnpreservableIsNotAnnounced},TestConformance/codeclared-keywordsnullUnionCollapsecollapses past a co-declaredanyOf(the original bug)TestUnionCombinators_{NullBranchDoesNotCollapsePastTheAnyOf,KeepingIsOrderIndependent},TestConformance/codeclared-keywordspreserveUnhomedKeywordsnever records the skipped families (the original bug)TestCoDeclaredFamily_*,TestConformance/codeclared-keywordsdispatchOfforgets the families after the winnerTestCoDeclaredFamily_*,TestConformance/codeclared-keywordsdeclaresFamilyanswers an unknown name fromallOf's guardTestDeclaresFamily_AnUnknownNameDeclaresNothinglower()elects the last declared family instead of the firstTestCoDeclaredFamily_*,TestConformance/codeclared-keywordsotherCombinatorkeeps the elected set instead of the passed-over oneTestUnionCombinators_*,TestConformance/codeclared-keywordsrecordSkippedFamiliesannounces a keyword it did not keepTestCoDeclaredFamily_UnpreservableIsNotAnnouncedpreserveUnusedCombinatorannounces a set it did not keepTestUnionCombinators_UnpreservableIsNotAnnouncedbranchHintdisagrees withsubSchemaHintat the branch pointerTestUnionCombinators_KeepingIsOrderIndependentThe two-order test earned that last row only after a fix: as first written its branch was a bare
{type: string}, which interns no node through the union, so the two lowerings never competed for one pointer and the test could not have caught a hint disagreement. The branch now declares a description, which is what makes both lowerings hoist its home.Its guard against that regression was rewritten for the same reason. Asserting a node exists at the branch pointer proves nothing — the outside
$refhoists one there by itself, so the assertion holds even for the bare branch it was meant to rule out. It now pins the union's own variant to that node, in both documents, which is the state where two lowerings reach one pointer; reducing the branch reddens the guard rather than slipping past it.The corpus entry was checked the same way rather than trusted: deleting the
anyOffromBothCombinators, theallOffromNarrowedEnum, or theenumfromConstWithinEnumeach reddensTestConformance/codeclared-keywords.Closes #35