Skip to content

fix(parser)!: contest a title between an inline allOf's $ref and inline members - #498

Open
gjtorikian wants to merge 3 commits into
issue/373-contested-title-later-variantfrom
issue/374-contested-title-allof-ref
Open

gjtorikian wants to merge 3 commits into
issue/373-contested-title-later-variantfrom
issue/374-contested-title-allof-ref

Conversation

@gjtorikian

@gjtorikian gjtorikian commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #374

What

An inline allOf: [{ $ref: Base }, { … }] merges Base'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 that Base.shape and the inline marker.shape give different shapes. As a 2xx response (the issue's allof.json), both unions minted one Circle and one Square, and marker.shape resolved to Base's { radius }/{ 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 }/{ len }. Now the title is contested between the two members wherever such an allOf is 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 $ref member of an allOf the parser merges into one model included, and read the merged allOf'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 property mergeAllOfMembers settles on, as a claim group of its own:

    • what a $ref member's component contributes, its own allOf chain included;
    • a property two members declare, as the declaration the merge keeps or conjoins (meta beside Base.meta; Greptile review);
    • a property only variants declare, as the union of their declarations.

    A declaration the merge drops (an inline shape after Base's, which types the field) and a union whose variants it flattens are not read (review). Neither are a $ref under 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 allOf is built by extractAllOfModel, not by the merge. markContestedTitles gives markContestedTitlesUnderRoot the document's components.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 markContestedTitles and markContestedTitlesUnderRoot and the inline-union paragraph of docs/architecture/ir-types.md state the rule.

Tests

Added to test/parser/inline-models.test.ts:

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

  • Marking is symmetric. This follows the issue ("contested between the two members") and the existing rule for a schema object two roots share: a mark from either root stands. So Base's own variants are qualified wherever they appear, and Base.shape becomes (CircleBaseShape | SquareBaseShape). I did not take the alternative of marking only the root's own variants so the component keeps Circle. It renames less, but it makes the contest asymmetric, which the doc and the issue rule out.
  • The rule applies wherever an allOf is 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 $ref members only in response roots: it leaves headers and parameters broken, and the two paths would name the same document differently.
  • A merged allOf is 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 from mergeAllOfMembers. 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 component allOfs (discriminated variants included) are built by their own readers.
  • Following is transitive: a merged allOf among 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.
  • A 2xx response's inherited shape gets its own qualified copy (CircleGetDrawingResponseShape, identical to CircleBaseShape). The response path already re-mints inherited inline models under the response's name (GetDrawingResponseMeta beside BaseMeta).
  • Breaking (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 merged allOfs outside their component schemas, 4 of them through a $ref.

…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.
@greptile-apps

greptile-apps Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no new blocking issue was found.

Summary

The parser now compares conflicting titles across the properties an inline allOf actually keeps.

  • Merged allOf models give differently shaped title variants separate names.

Reviews (3) · Last reviewed commit: "fix(parser)!: read a merged allOf as the..." · Reviewed by Greptile

Comment thread src/parser/inline-models.ts Resolved

@MichaelDeBoey MichaelDeBoey left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

  1. 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 a 200 allOf: [{ $ref: Holder }, { properties: { meta: { properties: { marker: { properties: { shape: oneOf [Circle { diameter }, Square { len }] } } } } } }], the head still prints the two "minted twice" warnings, and GetDrawingResponseMetaMarker.shape is (Circle | Square) with radius/side. A request body gives the same. The merge combines the two meta declarations into one with origin: null, so inheritedAllOfProperties drops it. I'd expect GetDrawingResponseMetaMarker.shape to point at { diameter }/{ len } models, as it does when both members are $refs. The ir-types.md sentence ("every property the merge inherits through the $ref … is compared") reads as covering this case.

    function inheritedAllOfProperties(
    schema: SchemaObject,
    componentSchemas: Record<string, SchemaObject>,
    ): SchemaObject[] {
    if (!schema.allOf || readInlineAllOf(schema.allOf, schema, componentSchemas) !== 'merged') return [];
    return mergeAllOfMembers(schema, componentSchemas).properties.flatMap((p) => (p.origin ? [p.schema] : []));
    }

  2. A union the merge drops still contests the title, so names change where only one shape is emitted. With Base as in the issue and a 200 allOf: [{ $ref: Base }, { properties: { shape: oneOf [Circle { diameter }, Square { len }] } }], Base's shape wins the merge, so the inline shape is never typed. The head still renames Base.shape to (CircleBaseShape | SquareBaseShape) and gives the response CircleGetDrawingResponseShape/SquareGetDrawingResponseShape, all with radius/side. The base gives Circle/Square for both with no warning, and a request body does the same. The inline member is walked through its allOf keyword, 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 merged allOf's unions to be read from what mergeAllOfMembers keeps (its properties), which would also cover the combined meta in 1.

    // A `$ref` member of an `allOf` merged into one model is no reference:
    // the model carries what the merge inherits through it beside its inline
    // members' properties, so those properties are read with this root, each
    // a group of its own like any property.
    for (const property of inheritedAllOfProperties(folded, componentSchemas)) {
    if (inheritedRead.has(property)) continue;
    inheritedRead.add(property);
    walk(property);
    }

…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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants