Repository navigation
fix(parser)!: contest a title between an inline allOf's $ref and inline members - #498
gjtorikian wants to merge 3 commits into
Conversation
…ne members Since #320 a title two shapes take under one root is contested, and every variant carrying it takes its context-qualified name. The walk that finds the clashes, markContestedTitlesUnderRoot, stopped at every `$ref`. An inline `allOf: [{ $ref: Base }, { … }]` is merged into one model that carries Base's properties beside the inline member's, yet Base's unions were read only under the components root, so a title that `Base.shape` and the inline `marker.shape` give different shapes was contested nowhere. As a 2xx response both unions minted one `Circle`, and `marker.shape` resolved to Base's `{ radius }` and `{ side }` with a "minted twice" warning. As a default, error or secondary body, a header or a parameter, the inherited `shape` was silently pointed at the inline member's `{ diameter }` and `{ len }`. Where an `allOf` is read as one merged model (readInlineAllOf), the walk now also reads every property the merge inherits through a `$ref` member (mergeAllOfMembers, a component's own allOf chain included) under the same root. Each such property is a claim group of its own, as any property is, and is read once per root, so a chain of components that each merge the next stays linear. A plain reference, a `$ref` under a variant and a union component's own variants are not read, since the merge does not read them either. The issue's allof.json now types GetDrawingResponseMarker.shape as (CircleGetDrawingResponseMarkerShape | SquareGetDrawingResponseMarkerShape) over `{ diameter }` and `{ len }`, and Base.shape as (CircleBaseShape | SquareBaseShape) over `{ radius }` and `{ side }`, without a warning. The IR, warnings included, of every fixture spec, the github-subset example and four WorkOS specs is unchanged. BREAKING CHANGE: when an inline allOf merges a component whose union gives a title another shape than a union of the same root does, no variant carrying that title keeps it. The component's variants take their context-qualified names wherever they appear (`Circle` becomes `CircleBaseShape`), and so do the root's (a request body's `CreateDrawingCircle` becomes `CircleCreateDrawingRequestMarkerShape`). The discriminator mappings follow.
[Medium impact] The PR appears safe to merge; no new blocking issue was found. SummaryThe parser now compares conflicting titles across the properties an inline
Reviews (3) · Last reviewed commit: "fix(parser)!: read a merged allOf as the..." · Reviewed by Greptile |
MichaelDeBoey
left a comment
There was a problem hiding this comment.
Thanks, this fixes #374!
The issue's allof.json now gives GetDrawingResponseMarker { shape: (CircleGetDrawingResponseMarkerShape | SquareGetDrawingResponseMarkerShape) } with diameter and len, Base.shape as (CircleBaseShape | SquareBaseShape) and the response's own copies CircleGetDrawingResponseShape/SquareGetDrawingResponseShape, with no warning. OAS 3.0 enum: ['circle'] tags give the same result. A 404 body, a default body, array items, a nested drawing property, a request body and a content query parameter now keep both unions' shapes too. Before, the error, default, items and parameter cases pointed the inherited shape at the { diameter } models. Two $ref members, two $ref members that each declare meta, an inline member's nested allOf: [{ $ref: Base }], and Base also returned as a plain $ref by another operation all work. When both unions have the same shapes, the names stay Circle/Square. shape: { $ref: Shape } on the component is left to the existing cross-root rename (GetDrawingCircle), as before.
-
A property both members declare is still skipped, as Greptile points out in #498 (comment). With
Holder: { properties: { meta: { properties: { shape: oneOf [Circle { radius }, Square { side }] } } } }and a200allOf: [{ $ref: Holder }, { properties: { meta: { properties: { marker: { properties: { shape: oneOf [Circle { diameter }, Square { len }] } } } } } }], the head still prints the two "minted twice" warnings, andGetDrawingResponseMetaMarker.shapeis(Circle | Square)withradius/side. A request body gives the same. The merge combines the twometadeclarations into one withorigin: null, soinheritedAllOfPropertiesdrops it. I'd expectGetDrawingResponseMetaMarker.shapeto point at{ diameter }/{ len }models, as it does when both members are$refs. Their-types.mdsentence ("every property the merge inherits through the$ref… is compared") reads as covering this case.oagen/src/parser/inline-models.ts
Lines 1069 to 1075 in cb8efa6
-
A union the merge drops still contests the title, so names change where only one shape is emitted. With
Baseas in the issue and a200allOf: [{ $ref: Base }, { properties: { shape: oneOf [Circle { diameter }, Square { len }] } }],Base'sshapewins the merge, so the inlineshapeis never typed. The head still renamesBase.shapeto(CircleBaseShape | SquareBaseShape)and gives the responseCircleGetDrawingResponseShape/SquareGetDrawingResponseShape, all withradius/side. The base givesCircle/Squarefor both with no warning, and a request body does the same. The inline member is walked through itsallOfkeyword, and the property it inherits is now walked beside it, so both are compared even though the model keeps only one. I'd expect a mergedallOf's unions to be read from whatmergeAllOfMemberskeeps (itsproperties), which would also cover the combinedmetain 1.oagen/src/parser/inline-models.ts
Lines 1043 to 1051 in cb8efa6
…lare Greptile review of #498. The walk read only the properties a merged `allOf` inherits through a `$ref` member (a non-null origin). When an inline member declares a property the component declares too, such as `meta` beside `Base.meta`, mergeAllOfMembers conjoins the two into one property the merged model owns (origin null), and every site mints it under the root. That property was skipped, so `Base.meta.shape` was never compared with the inline `meta.marker.shape`, and both unions minted one `Circle` with a "minted twice" warning, as a 2xx response, a request body and a default response alike. mergedAllOfProperties (was inheritedAllOfProperties) now returns every always-applying property of the merged model: the inherited ones, the conjoined ones and an inline member's own, which the walk also reads where it is written, so a second read finds the same claims. A property only variants declare is still left where it is written. Each property is still read once per root. The IR, warnings included, of every fixture spec, the github-subset example and four WorkOS specs is unchanged. BREAKING CHANGE: a titled variant under a property both members of a merged inline allOf declare is contested with the root's other unions too: `Base.meta.shape`'s `Circle` becomes `CircleBaseMetaShape`, and the inline `meta.marker.shape`'s takes its own qualified name instead of resolving to it.
…ing a title Review of #498. The walk read a merged `allOf`'s members as written and then the merged model's properties beside them. A declaration the merge drops was read too: with `allOf: [{ $ref: Base }, { properties: { shape } }]`, Base's `shape` types the field and the inline one is never minted, yet the two were compared, so `Base.shape` lost its bare `Circle` for `CircleBaseShape` though one shape is emitted. A union whose variants the merge flattens into the model's fields contested its titles the same way. Outside the components root, a schema read as one merged `allOf` (readInlineAllOf) is now walked as that model and nothing else: each property mergeAllOfMembers settles on, once per root and as a group of its own. That covers what a `$ref` member's component contributes, a property two members declare as the declaration the merge keeps or conjoins, and a property only variants declare as the union of their declarations. The components root reads its schemas as written, as before: every component is one of them, and a component's own `allOf` is built by extractAllOfModel, not by the merge. The IR, warnings included, of every fixture spec, the github-subset example and four WorkOS specs is unchanged. Those documents hold 32 merged `allOf`s outside their component schemas, 4 of them through a `$ref`. BREAKING CHANGE: outside the component schemas, a union a merged inline `allOf` drops or flattens into its fields no longer contests its titles, so a union beside it keeps its bare title (`Circle` rather than `CircleBaseShape`, relative to the earlier commits of this change).
Closes #374
What
An inline
allOf: [{ $ref: Base }, { … }]mergesBase's properties and the inline member's into one model. The contested-title walk stopped at the$ref, though, so no root contested a title thatBase.shapeand the inlinemarker.shapegive different shapes. As a 2xx response (the issue'sallof.json), both unions minted oneCircleand oneSquare, andmarker.shaperesolved toBase's{ radius }/{ side }with a "minted twice" warning. As a default, error or secondary body, a header or a parameter, the inheritedshapewas silently pointed at the inline member's{ diameter }/{ len }. Now the title is contested between the two members wherever such anallOfis merged, each union keeps its own shapes, and no warning is printed. A union the merge drops or flattens into the model's fields is no model, so it contests nothing.How
Root cause:
markContestedTitlesUnderRoot(src/parser/inline-models.ts) returned at every$ref, a$refmember of anallOfthe parser merges into one model included, and read the mergedallOf's members as written rather than as the model the merge builds.Outside the components root, a schema the parser reads as one merged
allOf(readInlineAllOf:'merged') is now walked as that model and nothing else (mergedAllOfProperties). That means each propertymergeAllOfMemberssettles on, as a claim group of its own:$refmember's component contributes, its ownallOfchain included;metabesideBase.meta; Greptile review);A declaration the merge drops (an inline
shapeafterBase's, which types the field) and a union whose variants it flattens are not read (review). Neither are a$refunder a variant or a union component's own variants, since the merge does not read them.The components root reads its schemas as written, as before: every component is one of them, and a component's own
allOfis built byextractAllOfModel, not by the merge.markContestedTitlesgivesmarkContestedTitlesUnderRootthe document'scomponents.schemas(an empty map when it has none) for every other root.Each such property is read once per root (
mergedRead). Without that guard, a chain of components that each merge the next three times over is exponential: every link tripled the time (0.66 s per document at 11 links). With it, a 14-link chain takes under 1 ms.The doc comments of
markContestedTitlesandmarkContestedTitlesUnderRootand the inline-union paragraph ofdocs/architecture/ir-types.mdstate the rule.Tests
Added to
test/parser/inline-models.test.ts:#267describe, "contests a title an inline response's$refand inlineallOfmembers give different shapes (A title clash between an inline response'sallOfmembers is not contested when one member is a$ref, and the inline member's variants are lost #374)": the issue'sallof.json. It pins every model name and shape, no warning, and consistent refs.#267describe, it.each "keeps both members' shapes of a title they contest under $label (A title clash between an inline response'sallOfmembers is not contested when one member is a$ref, and the inline member's variants are lost #374)": a 2xx response, a default response, an error response, a field of a default response, a response header, a query parameter, a request body and a webhook payload. In each,Base.shape, the merged model'sshapeandmarker.shapepoint at their own shapes, with no warning and consistent refs.#267describe, it.each "contests a title under a property both members declare, under $label (A title clash between an inline response'sallOfmembers is not contested when one member is a$ref, and the inline member's variants are lost #374, Greptile)": a 2xx response, a default response and a request body, each withBase.meta.shapebeside the inlinemeta.marker.shape.#267describe, it.each "keeps the bare titles for $label, which is no model (A title clash between an inline response'sallOfmembers is not contested when one member is a$ref, and the inline member's variants are lost #374 review)": ashapethe merge drops forBase's, and a union the merge flattens, each in a response and a request body.CircleandSquarestay bare, as on the base.markContestedTitlesdescribe, it.each over how the merge reaches the component: a$refmember, the component's ownallOfchain, an inline member's nestedallOf, a nullable$refmember,propertiesbeside a lone$refmember, and a property both members declare. Both unions are marked.markContestedTitlesdescribe, it.each over what the merged model does not hold: a plain-referenceallOf, a$refunder a variant, a union component, a declaration the merge drops, and a union it flattens. Nothing is marked.markContestedTitlesdescribe: a 30-link chain plus a self-merging and two mutually merging components. The walk ends, and the deepest union is still contested.Before the
src/changes, all 19 positive tests failed for the issue's reason (warnings, wrong shapes, unmarked variants), and the negative guards passed. The 4 review guards fail on the PR's first two commits and pass now. No existing expectation changed.Stack
Based on
issue/373-contested-title-later-variant(#496). Merge that first; GitHub retargets this PR when the base branch is deleted.Decision / Assumptions
Base's own variants are qualified wherever they appear, andBase.shapebecomes(CircleBaseShape | SquareBaseShape). I did not take the alternative of marking only the root's own variants so the component keepsCircle. It renames less, but it makes the contest asymmetric, which the doc and the issue rule out.allOfis merged outside the components root, not only to 2xx response bodies (the one path that re-mints inherited properties under the response's name). Default, error and secondary bodies, headers and parameters had the same clash, with silent data loss. Request bodies and webhook payloads kept both shapes through the rename pass (CreateDrawingCircle). They now take the contested names (CircleCreateDrawingRequestMarkerShape), so the response and body paths agree. I did not take the alternative of following$refmembers only in response roots: it leaves headers and parameters broken, and the two paths would name the same document differently.allOfis read as its model, a pure inline one included (outside the components root). This is the review's suggestion, and every extractor builds such a model frommergeAllOfMembers. A declaration the merge drops, or a union it flattens, no longer contests a title there. The components root keeps its as-written walk, because componentallOfs (discriminated variants included) are built by their own readers.allOfamong the model's properties is read as its own model. That can contest a title deeper than a given extraction path re-mints. This only qualifies names; it never drops a shape.shapegets its own qualified copy (CircleGetDrawingResponseShape, identical toCircleBaseShape). The response path already re-mints inherited inline models under the response's name (GetDrawingResponseMetabesideBaseMeta).fix(parser)!): names change only in documents where a merged component's union and another union of the same root give one title two shapes. The IR, warnings included, of all 46 fixture specs, the github-subset example and four WorkOS specs is byte-identical before and after. Those documents hold 32 mergedallOfs outside their component schemas, 4 of them through a$ref.