Skip to content

fix(cli): resolve allOf composition bugs in V3 OpenAPI importer#14873

Open
devin-ai-integration[bot] wants to merge 24 commits intomainfrom
jsklan/allof-openapi-import
Open

fix(cli): resolve allOf composition bugs in V3 OpenAPI importer#14873
devin-ai-integration[bot] wants to merge 24 commits intomainfrom
jsklan/allof-openapi-import

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot commented Apr 10, 2026

Description

Linear ticket: Refs FER-9158

Fixes two allOf composition bugs in the V3 OpenAPI importer that caused incorrect IR output for SPS Commerce's OpenAPI spec:

  1. Required status lost for properties from $ref parents: When allOf combined a $ref (e.g., PaginatedResult with required: [results]) with an inline schema narrowing a property, the shouldMergeAllOf path bailed out on encountering a $ref, falling through to ObjectSchemaConverter which didn't propagate the parent's required array.

  2. Property-level allOf with $ref + inline primitive dropped: When a property used allOf to combine a $ref to an enum with inline string constraints, a synthetic empty object type was created instead of referencing the original enum.

Changes Made

SchemaConverter.ts

  • Enhanced the shouldMergeAllOf code path to resolve $ref elements via resolveMaybeReference instead of returning undefined
  • Added cross-schema cycle detection using a visitedRefs: Set<string> threaded through all recursive SchemaConverter instantiations — falls back to ObjectSchemaConverter on cycle
  • Resolved schemas are deep-merged with mergeWith (same as existing non-ref logic), preserving required arrays and property schemas
  • Added handling for bare oneOf/anyOf elements within allOf (mutual exclusion patterns): flattens variant properties into the merged schema as optional properties, filtering out not: {} schemas

SchemaOrReferenceConverter.ts

  • Extended maybeConvertSingularAllOfReferenceObject to handle allOf with one $ref to a non-object type (e.g., enum) + inline primitive constraints
  • Instead of creating a synthetic merged copy, returns a direct type reference to the original $ref type
  • Guarded by conditions: exactly one $ref, resolved type is non-object, inline elements have no properties/enum/composition keywords

allof-spscommerce.test.ts

  • Added IR serialization in beforeAll to convert runtime type discriminants to _type (the serialized JSON form the assertions expect)

Test fixture & snapshot updates

  • New allof and allof-inline test-definition fixtures added under test-definitions/fern/apis/
  • Updated 4 v3-sdks snapshots (allof-array-items-narrowing, examples-endpoint-level, examples-endpoint-level-named, url-reference) — these reflect allOf subschemas now being merged instead of modeled as extends
  • Created 28 new convertIRtoJsonSchema snapshots for allof and allof-inline fixtures
  • Updated ir-generator-tests test-definitions for allof and allof-inline (dynamic-snippets + IR)
  • New baseline-sdks snapshot for allof-spscommerce
  • Seed SDK outputs regenerated for all affected generators (ts-sdk, csharp-sdk, go-sdk, java-sdk, python-sdk, ruby-sdk, php-sdk, swift-sdk, rust-sdk)

Testing

  • All 8 assertion-based tests in allof-spscommerce.test.ts pass (7 were failing before)
  • circular-references and circular-references-advanced tests still pass
  • Updated snapshots: v3-sdks (4 updated), ir-to-jsonschema (28 created), ir-generator-tests (allof + allof-inline)
  • pnpm compile passes for v3-importer-commons
  • pnpm format and biome check clean on changed files

Note: The test file allof-spscommerce.test.ts has 15 pre-existing noNonNullAssertion biome errors from before this PR (the assertion code was written that way on the branch). biome-ignore comments were added to suppress these without modifying the test assertions.

Review Checklist

  • Verify the SchemaOrReferenceConverter condition for non-object $ref types isn't too broad — could it incorrectly short-circuit allOf patterns where the inline elements meaningfully change the type (not just add constraints)?
  • Confirm cycle detection fallback (returning undefined → ObjectSchemaConverter) is the correct behavior for circular allOf references
  • Cross-schema cycle detection: visitedRefs is accumulated across recursive SchemaConverter calls. Verify this doesn't false-positive on diamond-shaped $ref patterns (same $ref legitimately used in different branches of the schema tree, which is not a cycle)
  • Review the oneOf/anyOf flattening logic in the merge path — it makes variant properties optional on the parent, which may not be correct for all mutual exclusion patterns
  • Check whether the test's JSON.stringify replacer (renaming type_type on objects with _visit) is the right approach vs. using the IR SDK serializer
  • Verify the 4 updated v3-sdks snapshots reflect correct IR output (allOf merge vs. previous extends behavior)

Link to Devin session: https://app.devin.ai/sessions/25aab7ec7b404629a2076196be8be747


Open with Devin

jsklan and others added 4 commits April 9, 2026 19:12
Add two test-definition fixtures exercising allOf edge cases:
- allof: default settings (extends mode)
- allof-inline: inline-all-of-schemas enabled, shares the same spec

Covers array items narrowing, primitive constraint narrowing via $ref,
required propagation, metadata inheritance, and multi-parent overlap.
Add debug-allof-pipeline.ts for running allof/allof-inline fixtures
through the full CLI pipeline (openapi-ir → write-definition → ir).
Include generated IR snapshots confirming remaining allOf bugs.
Add V3 importer test fixture and assertion tests for the allOf edge
cases reported in Pylon #18189 / FER-9158. Tests validate:
- Case A: array items narrowing preserves parent required status
- Case B: property-level allOf with $ref + inline primitive
- Case C: required field preservation through allOf composition

7 of 8 assertions currently fail, confirming the bugs. The task is
complete when all 8 pass.
- Enhance shouldMergeAllOf path to resolve $ref elements instead of bailing out
- Add cycle detection via Set to prevent infinite loops on circular refs
- Handle property-level allOf with $ref to non-object types (e.g. enums)
  by referencing the original type instead of creating synthetic copies
- Update test infrastructure to serialize IR discriminants for assertions
- Update snapshots for allof-spscommerce fixture

Co-Authored-By: bot_apk <apk@cognition.ai>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

…eConverter

Co-Authored-By: bot_apk <apk@cognition.ai>
devin-ai-integration bot and others added 3 commits April 10, 2026 02:17
Co-Authored-By: bot_apk <apk@cognition.ai>
@github-actions
Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against latest nightly baseline on main (2026-04-09T04:46:50Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 95s 138s 85s -10s (-10.5%)
go-sdk square 107s 134s 104s -3s (-2.8%)
java-sdk square 290s 346s 158s -132s (-45.5%)
php-sdk square 85s 119s 83s -2s (-2.4%)
python-sdk square 130s 166s 128s -2s (-1.5%)
ruby-sdk-v2 square 115s 153s 113s -2s (-1.7%)
rust-sdk square 93s 95s 92s -1s (-1.1%)
swift-sdk square 104s 448s 97s -7s (-6.7%)
ts-sdk square 98s 134s 93s -5s (-5.1%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-04-09T04:46:50Z). Trigger benchmark-baseline to refresh.

@github-actions
Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against latest nightly baseline on main (2026-04-10T04:56:48Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 100s 135s 93s -7s (-7.0%)
go-sdk square 104s 137s 107s +3s (+2.9%)
java-sdk square 157s 191s 161s +4s (+2.5%)
php-sdk square 86s 122s 86s +0s (+0.0%)
python-sdk square 124s 167s 132s +8s (+6.5%)
ruby-sdk-v2 square 120s 152s 121s +1s (+0.8%)
rust-sdk square 92s 94s 93s +1s (+1.1%)
swift-sdk square 105s 476s 97s -8s (-7.6%)
ts-sdk square 102s 129s 97s -5s (-4.9%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-04-10T04:56:48Z). Trigger benchmark-baseline to refresh.

@github-actions
Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against latest nightly baseline on main (2026-04-10T04:56:48Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 100s 135s 93s -7s (-7.0%)
go-sdk square 104s 137s 107s +3s (+2.9%)
java-sdk square 157s 191s 164s +7s (+4.5%)
php-sdk square 86s 122s 86s +0s (+0.0%)
python-sdk square 124s 167s 126s +2s (+1.6%)
ruby-sdk-v2 square 120s 152s 115s -5s (-4.2%)
rust-sdk square 92s 94s 96s +4s (+4.3%)
swift-sdk square 105s 476s 104s -1s (-1.0%)
ts-sdk square 102s 129s 100s -2s (-2.0%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-04-10T04:56:48Z). Trigger benchmark-baseline to refresh.

@jsklan jsklan self-requested a review April 10, 2026 15:42
devin-ai-integration bot and others added 5 commits April 10, 2026 15:43
Co-Authored-By: bot_apk <apk@cognition.ai>
…oss-schema cycle detection

Co-Authored-By: bot_apk <apk@cognition.ai>
Co-Authored-By: bot_apk <apk@cognition.ai>
Co-Authored-By: bot_apk <apk@cognition.ai>
Co-Authored-By: bot_apk <apk@cognition.ai>
@github-actions
Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against latest nightly baseline on main (2026-04-10T04:56:48Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 100s 135s 93s -7s (-7.0%)
go-sdk square 104s 137s 111s +7s (+6.7%)
java-sdk square 157s 191s 167s +10s (+6.4%)
php-sdk square 86s 122s 85s -1s (-1.2%)
python-sdk square 124s 167s 130s +6s (+4.8%)
ruby-sdk-v2 square 120s 152s 119s -1s (-0.8%)
rust-sdk square 92s 94s 94s +2s (+2.2%)
swift-sdk square 105s 476s 106s +1s (+1.0%)
ts-sdk square 102s 129s 107s +5s (+4.9%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-04-10T04:56:48Z). Trigger benchmark-baseline to refresh.

@jsklan
Copy link
Copy Markdown
Contributor

jsklan commented Apr 10, 2026

@claude review

…tcuts

Deduplicate $ref entries in the merged allOf array after mergeWith
array-concatenation to prevent false-positive cycle detection in
diamond inheritance patterns (A → B, C; B, C → D).

Add allOf/oneOf/anyOf checks to the resolved-schema guard in
maybeConvertSingularAllOfReferenceObject so the shortcut only fires
for true scalar/enum primitives, not schemas defined via composition
keywords whose inline constraints would be silently discarded.
@github-actions
Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against latest nightly baseline on main (2026-04-10T04:56:48Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 100s 135s 85s -15s (-15.0%)
go-sdk square 104s 137s 107s +3s (+2.9%)
java-sdk square 157s 191s 161s +4s (+2.5%)
php-sdk square 86s 122s 87s +1s (+1.2%)
python-sdk square 124s 167s 132s +8s (+6.5%)
ruby-sdk-v2 square 120s 152s 116s -4s (-3.3%)
rust-sdk square 92s 94s 94s +2s (+2.2%)
swift-sdk square 105s 476s 89s -16s (-15.2%)
ts-sdk square 102s 129s 103s +1s (+1.0%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-04-10T04:56:48Z). Trigger benchmark-baseline to refresh.

jsklan added 2 commits April 10, 2026 16:47
Thread visitedRefs through the single-element allOf branch in
SchemaConverter to detect self-referential and indirect cycles
(e.g. A → B → A via single-element allOf chains), matching the
protection already present in the multi-element shouldMergeAllOf path.

Add the required versions.yml changelog entry for the allOf
composition fixes in this PR.
@github-actions
Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against latest nightly baseline on main (2026-04-10T04:56:48Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 100s 135s 97s -3s (-3.0%)
go-sdk square 104s 137s 105s +1s (+1.0%)
java-sdk square 157s 191s 157s +0s (+0.0%)
php-sdk square 86s 122s 86s +0s (+0.0%)
python-sdk square 124s 167s 127s +3s (+2.4%)
ruby-sdk-v2 square 120s 152s 113s -7s (-5.8%)
rust-sdk square 92s 94s 95s +3s (+3.3%)
swift-sdk square 105s 476s 85s -20s (-19.0%)
ts-sdk square 102s 129s 106s +4s (+3.9%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-04-10T04:56:48Z). Trigger benchmark-baseline to refresh.

…onflict

Co-Authored-By: bot_apk <apk@cognition.ai>
@github-actions
Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against latest nightly baseline on main (2026-04-10T04:56:48Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 100s 135s 89s -11s (-11.0%)
go-sdk square 104s 137s 105s +1s (+1.0%)
java-sdk square 157s 191s 160s +3s (+1.9%)
php-sdk square 86s 122s 70s -16s (-18.6%)
python-sdk square 124s 167s 127s +3s (+2.4%)
ruby-sdk-v2 square 120s 152s 115s -5s (-4.2%)
rust-sdk square 92s 94s 94s +2s (+2.2%)
swift-sdk square 105s 476s 80s -25s (-23.8%)
ts-sdk square 102s 129s 105s +3s (+2.9%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-04-10T04:56:48Z). Trigger benchmark-baseline to refresh.

@github-actions
Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against latest nightly baseline on main (2026-04-10T04:56:48Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 100s 135s 89s -11s (-11.0%)
go-sdk square 104s 137s 105s +1s (+1.0%)
java-sdk square 157s 191s 162s +5s (+3.2%)
php-sdk square 86s 122s 83s -3s (-3.5%)
python-sdk square 124s 167s 129s +5s (+4.0%)
ruby-sdk-v2 square 120s 152s 114s -6s (-5.0%)
rust-sdk square 92s 94s 92s +0s (+0.0%)
swift-sdk square 105s 476s 87s -18s (-17.1%)
ts-sdk square 102s 129s 99s -3s (-2.9%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-04-10T04:56:48Z). Trigger benchmark-baseline to refresh.

- Preserve outer `inlined` status through allOf merge path instead of
  hardcoding `inlined: true`
- Guard variants-flattening against $ref-resolved schemas to prevent
  destructive flattening of named union types
- Seed dedup seenRefs from resolvedRefs to prevent duplicate property
  declarations in diamond inheritance
- Bail out of allOf shortcut when inline elements contain `type: "null"`
  to preserve nullable semantics in OpenAPI 3.1 patterns
- Add optional guard for required executionContext field in Case B test
- Remove debug script (scripts/debug-allof-pipeline.ts)
- Remove orphaned allof-spscommerce snapshot files
- Move changelog to changes/unreleased/ per release-versioning convention
@jsklan jsklan enabled auto-merge (squash) April 10, 2026 22:17
@github-actions
Copy link
Copy Markdown
Contributor

SDK Generation Benchmark Results

Comparing PR branch against latest nightly baseline on main (2026-04-10T04:56:48Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 100s 135s 86s -14s (-14.0%)
go-sdk square 104s 137s 104s +0s (+0.0%)
java-sdk square 157s 191s 165s +8s (+5.1%)
php-sdk square 86s 122s 95s +9s (+10.5%)
python-sdk square 124s 167s 129s +5s (+4.0%)
ruby-sdk-v2 square 120s 152s 117s -3s (-2.5%)
rust-sdk square 92s 94s 90s -2s (-2.2%)
swift-sdk square 105s 476s 80s -25s (-23.8%)
ts-sdk square 102s 129s 98s -4s (-3.9%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-04-10T04:56:48Z). Trigger benchmark-baseline to refresh.

/**
* Assertion-based tests for allOf composition edge cases.
* These tests validate the IR output against expected behavior per the OpenAPI spec,
* rather than snapshot-matching. They should FAIL until the allOf bugs are fixed.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The comment on line 4 of allof.test.ts says "They should FAIL until the allOf bugs are fixed," but this PR is the fix — so post-merge the tests will PASS, making the comment immediately stale and contradictory. Update the comment to read "They should PASS once the allOf bugs are fixed" or simply remove the clause.

Extended reasoning...

The newly-added file packages/cli/api-importers/v3-importer-tests/src/test/allof.test.ts opens with a block comment on lines 1–8. Line 4 reads: "They should FAIL until the allOf bugs are fixed." This phrasing was accurate when the test file was authored before the fix existed. However, this PR IS the fix for those allOf composition bugs, so on the day of merge the comment becomes immediately false.

The specific code path

The comment lives in the file-level JSDoc block of the newly-added test file. After merge, the test suite is green (all 8 assertion-based tests pass per the PR description: "7 were failing before"), yet line 4 instructs any reader that the tests "should FAIL." There is no runtime enforcement of this comment — it is purely documentation.

Why existing code does not prevent it

Comments are invisible to the compiler, the test runner (Jest/Vitest), and all linters configured in this repository. The CI pipeline validates that tests pass, but does not check whether a passing test file contains a note saying it should fail.

Impact

Any developer opening allof.test.ts after merge encounters a contradiction: CI is green, but the file header says the tests should fail. They must grep the git log to discover the comment predates the fix. This erodes trust in the test documentation and creates unnecessary confusion during future maintenance or debugging.

How to fix

Change line 4 from:
"rather than snapshot-matching. They should FAIL until the allOf bugs are fixed."
to:
"rather than snapshot-matching. They are expected to PASS now that the allOf bugs are fixed."
or simply delete the "should FAIL" sentence, as the surrounding description already explains what the file does.

Step-by-step proof

  1. PR title: "fix(cli): resolve allOf composition bugs in V3 OpenAPI importer" — this PR is the fix.
  2. PR description (Testing section): "All 8 assertion-based tests in allof-spscommerce.test.ts pass (7 were failing before)" — tests pass after this fix.
  3. allof.test.ts line 4 (added in this PR): "They should FAIL until the allOf bugs are fixed." — directly contradicts point 2.
  4. Post-merge state: tests pass (CI green) AND the comment says they should fail. Both facts coexist in the same repository, creating a misleading contradiction for any future reader.

Comment on lines 195 to 211
});

if (allOfSchema != null) {
const visitedRefsForChild = this.context.isReferenceObject(allOfElement)
? new Set<string>([...this.visitedRefs, allOfElement.$ref])
: this.visitedRefs;

const allOfConverter = new SchemaConverter({
context: this.context,
breadcrumbs: [...this.breadcrumbs, "allOf", "0"],
schema: allOfSchema,
id: this.id,
inlined: true
inlined: true,
visitedRefs: visitedRefsForChild
});

const allOfResult = allOfConverter.convert();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 The PR fixed the hardcoded inlined: true in mergedConverter (the shouldMergeAllOf path at line ~336) but missed the identical issue in the single-element allOf branch: allOfConverter is still constructed with inlined: true (line ~207), so a named top-level schema expressed as a single-element allOf wrapping a non-object type will be incorrectly marked as inline in the IR. Fix: change inlined: true to inlined: this.inlined in the allOfConverter constructor, matching the fix already applied to mergedConverter.

Extended reasoning...

What the bug is and how it manifests

The PR's primary fix for the hardcoded-inlined problem was applied to mergedConverter inside the shouldMergeAllOf path (around line 336): inlined: true was changed to inlined: this.inlined. However, the identical issue persists in the single-element allOf branch (tryConvertSingularAllOfSchema, around line 207), where allOfConverter is still constructed with inlined: true hardcoded. Looking at the diff, the allOfConverter constructor was only updated to add visitedRefs: visitedRefsForChildinlined: true was left untouched.

The specific code path that triggers it

When a top-level named schema (created by the importer with inlined: false) has allOf with exactly one element that is a reference to a non-object type (e.g., a primitive alias or string enum), tryConvertSingularAllOfSchema takes the length-1 branch. It constructs allOfConverter with inlined: true regardless of this.inlined, calls .convert(), and returns the result at line ~214. createTypeDeclaration uses this.inlined to set the inline field on the resulting type declaration, so the returned typeDeclaration carries inline: true even though the outer converter was created with inlined: false.

Why existing code doesn't prevent it

The mergedConverter fix (changing its inlined to this.inlined) was the intended pattern for preserving named-type status through allOf composition. But the fix was only applied to one of the two symmetric constructor calls inside tryConvertSingularAllOfSchema. The single-element branch was updated to thread visitedRefs through (a fix for the separate cycle-detection bug), but the inlined propagation fix was never applied in that same branch.

What the impact would be

Named top-level schemas (e.g. MyAlias: allOf: [: SomePrimitive]) processed via the single-element path will have inline: true in the IR. Generators that respect the inline flag may skip generating a named class/type for them, causing the named type to disappear from the generated SDK entirely and potentially breaking type references elsewhere in the output.

Step-by-step proof

  1. OpenAPI has: MyAlias: allOf: [: '#/components/schemas/SomeEnum'] (where SomeEnum is {type: string, enum: [...]})
  2. The importer creates a top-level SchemaConverter for MyAlias with inlined: false.
  3. allOf.length === 1 → enters the single-element branch in tryConvertSingularAllOfSchema.
  4. allOfConverter = new SchemaConverter({ ..., inlined: true }) ← hardcoded, ignores this.inlined = false.
  5. allOfResult from allOfConverter.convert() has its typeDeclaration.inline = true (derived from this.inlined inside createTypeDeclaration).
  6. The early-return check (allOfResult?.convertedSchema.typeDeclaration?.shape.type \!== 'object') is satisfied since SomeEnum resolves to a non-object type.
  7. Line ~213: return allOfResult — the result with inline: true is returned as the final output for MyAlias.
  8. MyAlias is treated as an inline anonymous type instead of a named declaration, despite being a named top-level component schema.

How to fix it

In SchemaConverter.ts, in the single-element allOf branch (~line 207), change inlined: true to inlined: this.inlined in the allOfConverter constructor. This mirrors exactly the fix already applied to mergedConverter at line ~336.

Comment on lines +90 to +115
if (this.schemaOrReference.allOf.length === 1) {
const allOfReference = this.schemaOrReference.allOf[0];
if (this.context.isReferenceObject(allOfReference)) {
const response = this.context.convertReferenceToTypeReference({
reference: allOfReference,
breadcrumbs: this.breadcrumbs
});
if (response.ok) {
return {
type: this.wrapTypeReference(response.reference),
inlinedTypes: {}
};
}
}
return undefined;
}

// When allOf has exactly one $ref to a non-object type (e.g. enum) and the
// remaining elements are inline primitive constraints (no properties, no enum,
// no composition keywords), reference the $ref type directly instead of
// creating a synthetic merged copy.
const refElements = this.schemaOrReference.allOf.filter((s) => this.context.isReferenceObject(s));
const inlineElements = this.schemaOrReference.allOf.filter(
(s) => !this.context.isReferenceObject(s)
) as OpenAPIV3_1.SchemaObject[];
const singleRef = refElements.length === 1 ? refElements[0] : undefined;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Both the new multi-element allOf shortcut (lines 134-143) and the pre-existing single-element allOf shortcut (lines 95-103) in maybeConvertSingularAllOfReferenceObject return inlinedTypes: {} unconditionally, discarding any inlinedTypes populated by convertReferenceToTypeReference for deep-path $refs like #/components/schemas/Foo/properties/bar. The fix for both locations is to change inlinedTypes: {} to inlinedTypes: response.inlinedTypes ?? {}, matching the correct pattern already used on line 77.

Extended reasoning...

Root Cause

Both shortcuts inside maybeConvertSingularAllOfReferenceObject in SchemaOrReferenceConverter.ts call convertReferenceToTypeReference to resolve a $ref, then discard the returned inlinedTypes by hard-coding inlinedTypes: {} in their return statements:

  • Single-element allOf shortcut (pre-existing, lines 95-103): handles allOf arrays with exactly one element
  • Multi-element allOf shortcut (new in this PR, lines 134-143): handles allOf arrays where all elements resolve to the same reference

Both sites return { type: ..., inlinedTypes: {} } even when response.inlinedTypes is non-empty.

Triggering Code Path

convertReferenceToTypeReference in OpenAPIConverterContext3_1.ts returns non-empty inlinedTypes whenever the resolved typeId contains a / — i.e., for deep-path $refs like #/components/schemas/Outer/properties/inner. In that case it creates a SchemaConverter, converts the nested schema, and returns { inlinedTypes: { [typeId]: convertedSchema } }. The reference path on line 77 correctly propagates this: inlinedTypes: response.inlinedTypes ?? {}. Both shortcuts do not.

Why Existing Code Doesn't Prevent It

The regular (non-allOf) reference path at line 77 correctly uses response.inlinedTypes ?? {}, so deep-path refs resolved outside of allOf work fine. The two allOf shortcuts are independent early-return branches that each duplicate the return structure but forgot to include the inlinedTypes propagation. There is no shared helper enforcing the correct pattern.

Impact

When a schema uses an allOf to reference a deep-path $ref, the inlined type registrations are silently discarded. The types never get added to the IR type registry. Downstream code generators that attempt to emit code for those named types will encounter dangling references, producing either compile errors in generated SDKs or missing type declarations entirely.

Step-by-Step Proof

  1. OpenAPI spec contains: { allOf: [{ $ref: '#/components/schemas/Outer/properties/Inner' }] }
  2. maybeConvertSingularAllOfReferenceObject is called; allOf.length === 1 matches the single-element shortcut (lines 95-103)
  3. convertReferenceToTypeReference is called with this reference
  4. Inside that method, typeId = 'components/schemas/Outer/properties/Inner'; since it contains /, a SchemaConverter is created, the nested schema is converted, and the result is returned as { ok: true, reference: ..., inlinedTypes: { 'components/schemas/Outer/properties/Inner': <converted schema> } }
  5. Back in the single-element shortcut, response.ok === true, so it returns { type: ..., inlinedTypes: {} } — the populated inlinedTypes from step 4 is thrown away
  6. The caller never registers the inlined type in the IR
  7. Any code generator referencing components/schemas/Outer/properties/Inner finds no definition

The same sequence applies to the multi-element shortcut (lines 134-143) for specs like { allOf: [{ $ref: '#/components/schemas/Outer/properties/A' }, { $ref: '#/components/schemas/Outer/properties/A' }] } where all refs are identical.

Fix

Change both return sites from inlinedTypes: {} to inlinedTypes: response.inlinedTypes ?? {}:

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.

1 participant