Skip to content

fix(compilers/openapi): treat a restated union branch as bare - #1278

Open
Wahbeh-Mohammad wants to merge 5 commits into
mainfrom
fix/xor-required-restated-type
Open

Wahbeh-Mohammad wants to merge 5 commits into
mainfrom
fix/xor-required-restated-type

Conversation

@Wahbeh-Mohammad

@Wahbeh-Mohammad Wahbeh-Mohammad commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Closes #620.

Summary

Two spellings of "exactly one of html and url" lowered differently. Branches writing only required kept the oneOf as validation_only; branches also restating a property the body declares kept it as degraded_lowering, with "a branch is written inline, so it names no referent". A branch that repeats the body declares no shape of its own, so it now lowers like the bare form.

branchDeclaresShape in compilers/openapi/internal/schema/union.go now takes the body and asks restatesBody. A branch repeats the body when:

  • it writes nothing but type, nullable, properties, required, not, title and description, where required and not narrow as they do in a bare branch;
  • the type it writes, if any, is the body's, with nullable agreeing;
  • each property it writes is one the schema's own properties declares, either written alike, or written in nothing but $ref, type, format and nullable, each as the body writes it, with the two agreeing on $ref and nullable.

Declarations are compared on raw nodes with yml.EqualNodes, which is why internal/archtest allows that import for this package: the typed model has no field for some keywords (docs/parser-library.md#unmodeled-keywords), so Schema.IsEqual calls a branch writing one equal to a body without it.

A consumer gets one Model, the union verbatim under Unmodeled with reason validation_only, one info openapi/validation-only-keyword and no openapi/degraded-construct. Nothing is lost either way: only the reason and the diagnostic change. The IR's shape does not change, so there is no IRVersion bump.

Deliberate limits, each stated in docs/compilers/openapi.md#union-beside-structure:

  • A branch that restates the body and writes any other keyword stays degraded, even one that only narrows, such as minProperties; a branch writing only required and minProperties is still validation_only. The closed list is what stops items, format or minLength beside a repeated type from reading as validation logic.
  • A property keyword outside $ref, type, format and nullable keeps the branch degraded even when written as the body writes it, unless the whole declaration is written alike. A not inside a property is a repeat only in that case.
  • Annotations other than title and description ($comment, deprecated, x- extensions) beside a repeated type keep the branch degraded, as at main.
  • A property the schema only composes through allOf has no one declaration to repeat, so such a branch stays degraded while its bare form is validation_only.
  • Declarations are compared as written, so one spelling its keywords in another order is not alike. This errs towards the degraded result.
  • A branch type against a body with no type stays degraded, since it narrows the body.

Test plan

  • make gate exits 0 on 686c158, lint included, with all 18589 statements covered.
  • CI passes on 686c158, both jobs.
  • The issue's schemas: Render and RenderBare lower alike, pinned by the conformance case union-restated-branches, which also holds RenderDocumented, RenderExclusive and a const branch that stays degraded.
  • TestOneOf_CoDeclaredBranchSayingMoreDeclaresShape pins the closed property list: adding minLength, items, enum, const or dependentRequired to typeKeywords reddens a row each, and removing $ref, format or nullable reddens a row in TestOneOf_CoDeclaredRestatingBranchDeclaresNoShape.
  • Golden spec (openapi.json at 319dc756), against a build of main at 3188471: jq -S 'del(.diagnostics)' shows 17 reason values moving from degraded_lowering to validation_only and no other IR difference. Lines matching a branch is written inline go from 45 to 28, and no browser-rendering request body is degraded.

@Wahbeh-Mohammad

Wahbeh-Mohammad commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Follow-up: open items, by scope

All in-scope items landed in afb4a4f0 and are no longer listed: not in a restating branch, the false rule text, the type: 'null' escape, the untested parts of the closed list, and the incomplete citation. What remains:

Borderline: deferred, same mechanism

  • allOf-composed body. {allOf: [{$ref: Base}], oneOf: [{required: [x], properties: {x: {type: string}}}]} stays degraded while its bare form is validation_only. Confirmed by compile. This is the same restated-branch defect at another site, so a sweep would normally take it in. It is left out because the comparison needs a fold-aware lookup through $refs, since the merge settles such a field only after every type lowers, which is a design of its own. The rule and restatesBody's doc comment state the limit. No issue found; it needs one, and the rule's Status should cite it.
  • Other narrowing keywords beside a restatement. not now passes, while minProperties, if, dependentSchemas and similar keywords that also only narrow stay degraded beside a restatement, as MinPropertiesBesideRestatement pins. It is the same class of gap. None of the 28 remaining inline-degraded schemas on the golden spec writes one in a branch, so it has no golden impact.

Out of scope: file separately

  • Bare items branch at main. {type: array, oneOf: [{items: {$ref: Str}}, {items: {$ref: Num}}]} lowers to a list with elem t/prim/any, labelled validation_only. A bare $dynamicRef branch draws the same label. It exists at main, and concerns bare branches, which this PR does not change. No issue found, and neither appears beside a body on the golden spec, so it reads as P3.
  • openapi: restating a property's type inside xor-of-required branches degrades the union #620 has no Priority line. Tracker housekeeping, no code. This PR moves 17 golden schemas, in reason and diagnostics only.
  • Skills gaps. Each needs a skills: issue:
    • reuse-library-readings read literally points at Schema.IsEqual, which cannot see keywords the model has no field for. It lacks that caveat.
    • no-forwarding-layer and reuse-library-readings disagree on whether a package needing a yml function extends its allowlist or goes through a nodeview wrapper.
    • every-reader's probe is specific to allOf; a bare-against-restated position sweep is its general form.
    • disk quota exceeded on a shared /tmp reads as a failed test or plant, and toolchain.md does not say so. skills: planting mutants says nothing of what a run adds to the build cache #1265 covers cache growth, skills: planting and review setup assume a writable checkout with an origin remote #1257 planting without a throwaway worktree.
    • morphic-openapi-compiler's compile --verify advice does not warn that 2>&1 >/dev/null | grep under zsh's multios still pipes the IR.

Corrections to the PR description

The description was written at 8d4e71d8 and has not been edited since afb4a4f0 landed. Where the two differ, this comment holds:

  • The opening Refs #620 line says the golden spec's form is not addressed. It now is, but merging this PR will not close openapi: restating a property's type inside xor-of-required branches degrades the union #620, so the issue needs closing by hand or the line changing to a closing keyword.
  • A branch that repeats the body may also write not, so "not included" no longer holds. A not written inside a property still stays degraded, as the rule states.
  • Golden spec: 17 schemas move, not 7. All 17 write type: object; 10 write not and 9 restate properties. Inline-degraded unions go from 45 to 28, not 38.
  • "The tree pushed here is the tree the second round reviewed" no longer holds: see below.

Verification state

  • CI passed on afb4a4f0, both jobs.
  • make gate exits 0 on the tree of afb4a4f0, run before it was committed.
  • Golden spec (openapi.json @319dc756): no browser-rendering request body degrades. Against 8d4e71d8, jq -S 'del(.diagnostics)' shows exactly 10 reason values moving from degraded_lowering to validation_only.
  • not soundness sweep: 49 keywords, 7 branch bases and 6 not forms at 3 OpenAPI versions, 2401 schemas per version. Adding not never changed a verdict on this commit, and no flip touched a keyword that shapes the value.
  • Not reviewed: afb4a4f0 has had no independent review. morphic-reviewing-prs has not been run on the opened PR.

@OmarAlJarrah OmarAlJarrah left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of afb4a4f0. The code holds up: I found no wrong lowering. One issue with the PR text and a few smaller items follow, each in its own comment.

CI: both jobs passed on afb4a4f0, but that head is behind main by #1238, #1191 and #1210, and #1191 edits union.go, docs/compilers/openapi.md and conformance_test.go. The merge has no conflicts, and go test ./compilers/openapi/... ./internal/... ./cmd/... ./ir/... ./pass/... passes on it. Please merge main so CI covers that tree. A local make gate there passed through build and was stopped during coverage, so it says nothing beyond that.

Probed clean:

  • 34 planted mutants of the new code: 33 reddened a test, and the survivor has its own comment.
  • Bare, restated and restated-with-not forms lower alike at 19 schema positions, with main as the control (it differs at 16).
  • 58 near-miss specs (aliases, merge keys, discriminators, $ref sites) gave no wrong verdict, and 7 hostile specs (cycles, depth) no crash.
  • testdata: 1 of 216 specs differs, this PR's own case.
  • Golden spec, openapi.json @319dc756: only 17 reason values change, on the head and on the merged tree, and inline-degraded unions go from 45 to 28.

Comment thread docs/compilers/openapi.md Outdated
or `format`, does not repeat the body, so it is judged as written: one that writes a `type`, a
`properties` or another keyword declaring shape falls to the five shapes above, even beside a
`not`, and one that only narrows, as `{required: [a], minProperties: 2}` does, stays validation
logic. A `not` written inside a property is no repeat either. A property the schema only composes

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this sentence is false when the body writes the same not.

S:
  type: object
  properties: {a: {type: string, not: {enum: [x]}}}
  oneOf:
    - {properties: {a: {type: string, not: {enum: [x]}}}, required: [a]}
    - required: [a]

morphic compile --verify reports two info openapi/validation-only-keyword, for the property's not and for the oneOf, and no openapi/degraded-construct: the branch's property is written alike, so restatesProperty returns true at its first EqualNodes check. That verdict agrees with "written alike" earlier in this rule, so the sentence is what is wrong. The follow-up comment repeats it.

Rule: this document is normative (CLAUDE.md), and "Zero nits" covers imprecise text.

Fix: limit the sentence, for example "A not inside a property repeats the body only when the declaration is written alike."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5d191b1. Your spec gives two info openapi/validation-only-keyword and no openapi/degraded-construct on the head, which agrees with "written alike" earlier in the rule, so the sentence was what was wrong. It now reads: "A not written inside a property repeats the body only where the property is written alike." With the not in the branch's property alone, the union still degrades. The follow-up comment's restatement of the old sentence is superseded by the updated description.

}

// typeKeywords are the keywords a branch can repeat of a property with more
// written beside the body's copy. Each means the same whatever stands beside

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "Each means the same whatever stands beside it" does not hold for $ref or nullable, and the comment on restatesProperty just below says so: a $ref "in 3.0 replaces what stands beside it" and a nullable "widens type". OpenAPI 3.0.3 also applies nullable only if type is defined in the same Schema Object. No verdict changes, because restatesProperty holds both to agree.

Rule: CLAUDE.md, "Zero nits", names "a doc comment that is imprecise".

Fix: say what the list is for rather than what each keyword means, leaving the two exceptions to restatesProperty's comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5d191b1. The comment no longer says what each keyword means. It says what the list is for: the keywords naming the property's type, and that the list is closed, so any other keyword keeps the branch from being a repeat even when written as the body writes it, rather than asking of each keyword whether it reads its siblings. It points to restatesProperty for why $ref and nullable must also agree.

// TestOneOf_CoDeclaredBranchSayingMoreDeclaresShape holds a branch that writes
// anything the body does not to the degraded lowering, and pins each way of
// doing so a looser comparison would read as a restatement.
func TestOneOf_CoDeclaredBranchSayingMoreDeclaresShape(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: nothing pins typeKeywords as a closed list. Adding minLength to it leaves go test ./compilers/openapi/internal/schema ./compilers/openapi/e2e green, though the output changes:

S:
  type: object
  properties: {a: {type: string, minLength: 1, description: d}}
  oneOf:
    - {properties: {a: {type: string, minLength: 1}}, required: [a]}
    - required: [a]

This head reports openapi/degraded-construct; with the mutant it reports openapi/validation-only-keyword. ConstraintBesideTheType misses it because its body writes no minLength, so the comparison fails either way.

Rule: CLAUDE.md, "Break what a test claims to catch". This test's comment says it pins each way a looser comparison would read as a restatement.

Fix: add a row for this spec. That branch says nothing the body does not, so if it should count as a repeat, the other fix is to widen the list. Either way a row should pin the choice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the closed list and pinned it, in 686c158. Widening it would mean deciding, keyword by keyword and version by version, which keywords read their siblings, as additionalProperties reads properties, which is a design change of its own rather than a fix here.

Your mutant was one of several: adding items, enum, const, dependentRequired, pattern, maxLength, description or properties to typeKeywords also left the suite green. ConstraintTheBodyAlsoWrites (your spec) and four sibling rows, ConstTheBodyAlsoWrites, EnumTheBodyAlsoWrites, ItemsTheBodyAlsoWrites and KeywordTheModelHasNoFieldForTheBodyAlsoWrites, each redden when minLength, const, enum, items or dependentRequired is added; the clean tree passes. Removing $ref, format or nullable already reddened a row. Adding pattern or maxLength (the same kind as minLength), description or properties is still not caught by a row of its own.

// shape the body does not: those that can repeat it, and `required` and `not`,
// which only narrow it whatever they hold. Any other says something the body may
// not, so the branch keeps the verdict declaresShape gives it.
var restatingBranchKeywords = []string{"type", "nullable", "properties", "required", "not", "title", "description"}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: title and description pass here but other annotations do not, so the two spellings this PR aligns still differ beside them. {type: object, required: [a], $comment: hi} stays degraded while {required: [a], $comment: hi} is validation_only, and the same holds for deprecated and an x- extension.

This is not a defect: it matches main, the rule's closed list covers it, and no branch in the 28 remaining inline-degraded schemas on the golden spec writes one. These keywords say nothing about shape either, so they are candidates if the list grows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it is not a defect. On the head, {type: object, required: [a], $comment: hi} degrades while {required: [a], $comment: hi} is validation_only, and the same holds for deprecated: true and an x- extension; the first form degrades at main too. The list is unchanged here, and the updated description now states this as a deliberate limit.

@OmarAlJarrah

Copy link
Copy Markdown
Member

issue: the description no longer matches afb4a4f0, and merging will not close #620.

Rule: morphic-shipping-prs, "Every sentence that states behaviour is executed on the final tree first". The follow-up comment corrects most of these, but the description is what a reader meets first, and it still says them:

  • "it writes nothing but type, nullable, properties, required, title and description" leaves out not, which restatingBranchKeywords holds.
  • "A branch writing any other keyword stays degraded, not included" is the reverse of what the code does.
  • "7 schemas move" and "Inline-degraded unions go from 45 to 38": I get 17 and 45 to 28 on openapi.json @319dc756, as the follow-up comment says.
  • "The tree pushed here is the tree the second round reviewed" was true of 8d4e71d8 only.

On #620, the opening line says the issue stays open, but what it asks for is done: Render lowers as RenderBare, and all 7 components its comment names move to validation_only. The PR closes nothing, while the rule's Status has already dropped #620, so merging leaves open an issue the document treats as settled.

Fix: edit the description to match the head, and use Closes #620.

@OmarAlJarrah

Copy link
Copy Markdown
Member

nit: the description does not use the form CLAUDE.md sets: "PR description: Summary / Test plan (/ Breaking, when applicable)". Recent merges use those headings, #1181 and #1232 among them. "Reviews done" also tells the history of review rounds, where CLAUDE.md asks for text a reader understands on its own terms.

Fix: use Summary and Test plan, keep the deliberate limits, and drop the review history, in the same edit as the corrections above.

@OmarAlJarrah

Copy link
Copy Markdown
Member

nit: the follow-up comment says none of its items are filed, and CLAUDE.md says "File an adjacent bug; don't fix it inline". I confirmed the two that touch the code by compiling them:

  • allOf-composed body: {allOf: [{$ref: Only}], oneOf: [{required: [x], properties: {x: {type: string}}}, {required: [y]}]} stays degraded, while bare {required: [x]} branches give validation_only. No golden impact: none of the 28 remaining inline-degraded schemas composes the property it restates.
  • Bare items branch: {type: array, oneOf: [{items: {$ref: Str}}, {items: {$ref: Num}}]} lowers to a list of t/prim/any under validation_only, on main and on this head.

Fix: file each, along with the skills items the comment lists, and cite the allOf issue in the rule's Status, as the comment proposes.

docs/compilers/openapi.md conflicted in the Cases list of
union-beside-structure: main added keywords-at-every-outcome and this
branch added union-restated-branches, so the list keeps both.

The union-restated-branches golden takes the unresolved auth ID space
main added beside openapi, as every other conformance golden does.
@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor Author

Answering the review summary and the three comments on the description and the follow-ups:

  • Merge main: merged at 3188471 in d606ca3. The only conflict was the Cases list of union-beside-structure in docs/compilers/openapi.md, which now keeps both keywords-at-every-outcome and union-restated-branches. After the merge the union-restated-branches golden took the unresolved auth ID space that main added to every other conformance golden, and nothing else in it changed. make gate exits 0 on 686c158 with all 18589 statements covered, and both CI jobs pass there. On the golden spec (openapi.json at 319dc756), against a build of main at 3188471, jq -S 'del(.diagnostics)' shows exactly 17 reason values moving from degraded_lowering to validation_only and no other IR difference; inline-degraded unions go from 45 to 28.
  • Description no longer matching the head, and openapi: restating a property's type inside xor-of-required branches degrades the union #620: the description is rewritten against 686c158. The keyword list includes not, the reversed not limit is gone, the golden numbers are 17 and 45 to 28, the review-history sentence is dropped, and it opens with Closes #620, which the PR's closing references now show.
  • Description form: it now uses Summary and Test plan, keeps the deliberate limits, adding the annotation limit from the $comment thread and the property-keyword limit from the typeKeywords thread, and drops the review history.
  • Filing the follow-ups: not done in this round. The allOf-composed body, the bare items branch and the skills items are still unfiled, so the rule's Status cannot cite the allOf issue until that issue has a number.

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: restating a property's type inside xor-of-required branches degrades the union

2 participants