fix(cli): resolve allOf composition bugs in V3 OpenAPI importer#14873
fix(cli): resolve allOf composition bugs in V3 OpenAPI importer#14873devin-ai-integration[bot] wants to merge 24 commits intomainfrom
Conversation
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 EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
There was a problem hiding this comment.
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>
packages/cli/api-importers/v3-importer-commons/src/converters/schema/SchemaConverter.ts
Outdated
Show resolved
Hide resolved
Co-Authored-By: bot_apk <apk@cognition.ai>
Co-Authored-By: bot_apk <apk@cognition.ai>
Co-Authored-By: bot_apk <apk@cognition.ai>
SDK Generation Benchmark ResultsComparing PR branch against latest nightly baseline on Full benchmark table (click to expand)
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 |
SDK Generation Benchmark ResultsComparing PR branch against latest nightly baseline on Full benchmark table (click to expand)
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 |
SDK Generation Benchmark ResultsComparing PR branch against latest nightly baseline on Full benchmark table (click to expand)
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 |
Co-Authored-By: bot_apk <apk@cognition.ai>
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>
SDK Generation Benchmark ResultsComparing PR branch against latest nightly baseline on Full benchmark table (click to expand)
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 |
|
@claude review |
packages/cli/api-importers/v3-importer-commons/src/converters/schema/SchemaConverter.ts
Show resolved
Hide resolved
...es/cli/api-importers/v3-importer-commons/src/converters/schema/SchemaOrReferenceConverter.ts
Show resolved
Hide resolved
…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.
SDK Generation Benchmark ResultsComparing PR branch against latest nightly baseline on Full benchmark table (click to expand)
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 |
packages/cli/api-importers/v3-importer-commons/src/converters/schema/SchemaConverter.ts
Show resolved
Hide resolved
packages/cli/api-importers/v3-importer-commons/src/converters/schema/SchemaConverter.ts
Show resolved
Hide resolved
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.
SDK Generation Benchmark ResultsComparing PR branch against latest nightly baseline on Full benchmark table (click to expand)
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 |
…onflict Co-Authored-By: bot_apk <apk@cognition.ai>
packages/cli/api-importers/v3-importer-commons/src/converters/schema/SchemaConverter.ts
Show resolved
Hide resolved
SDK Generation Benchmark ResultsComparing PR branch against latest nightly baseline on Full benchmark table (click to expand)
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 |
packages/cli/api-importers/v3-importer-commons/src/converters/schema/SchemaConverter.ts
Show resolved
Hide resolved
packages/cli/api-importers/v3-importer-commons/src/converters/schema/SchemaConverter.ts
Show resolved
Hide resolved
...es/cli/api-importers/v3-importer-commons/src/converters/schema/SchemaOrReferenceConverter.ts
Show resolved
Hide resolved
SDK Generation Benchmark ResultsComparing PR branch against latest nightly baseline on Full benchmark table (click to expand)
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 |
- 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
SDK Generation Benchmark ResultsComparing PR branch against latest nightly baseline on Full benchmark table (click to expand)
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 |
| /** | ||
| * 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. |
There was a problem hiding this comment.
🟡 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
- PR title: "fix(cli): resolve allOf composition bugs in V3 OpenAPI importer" — this PR is the fix.
- PR description (Testing section): "All 8 assertion-based tests in allof-spscommerce.test.ts pass (7 were failing before)" — tests pass after this fix.
- allof.test.ts line 4 (added in this PR): "They should FAIL until the allOf bugs are fixed." — directly contradicts point 2.
- 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.
| }); | ||
|
|
||
| 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(); |
There was a problem hiding this comment.
🔴 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: visitedRefsForChild—inlined: 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
- OpenAPI has:
MyAlias: allOf: [: '#/components/schemas/SomeEnum'](where SomeEnum is{type: string, enum: [...]}) - The importer creates a top-level
SchemaConverterforMyAliaswithinlined: false. allOf.length === 1→ enters the single-element branch intryConvertSingularAllOfSchema.allOfConverter = new SchemaConverter({ ..., inlined: true })← hardcoded, ignoresthis.inlined = false.allOfResultfromallOfConverter.convert()has itstypeDeclaration.inline = true(derived fromthis.inlinedinsidecreateTypeDeclaration).- The early-return check (
allOfResult?.convertedSchema.typeDeclaration?.shape.type \!== 'object') is satisfied since SomeEnum resolves to a non-object type. - Line ~213:
return allOfResult— the result withinline: trueis returned as the final output forMyAlias. MyAliasis 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.
| 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; |
There was a problem hiding this comment.
🔴 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
allOfarrays with exactly one element - Multi-element allOf shortcut (new in this PR, lines 134-143): handles
allOfarrays 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
- OpenAPI spec contains:
{ allOf: [{ $ref: '#/components/schemas/Outer/properties/Inner' }] } maybeConvertSingularAllOfReferenceObjectis called;allOf.length === 1matches the single-element shortcut (lines 95-103)convertReferenceToTypeReferenceis called with this reference- Inside that method,
typeId = 'components/schemas/Outer/properties/Inner'; since it contains/, aSchemaConverteris created, the nested schema is converted, and the result is returned as{ ok: true, reference: ..., inlinedTypes: { 'components/schemas/Outer/properties/Inner': <converted schema> } } - Back in the single-element shortcut,
response.ok === true, so it returns{ type: ..., inlinedTypes: {} }— the populatedinlinedTypesfrom step 4 is thrown away - The caller never registers the inlined type in the IR
- Any code generator referencing
components/schemas/Outer/properties/Innerfinds 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 ?? {}:
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:
Required status lost for properties from
$refparents: WhenallOfcombined a$ref(e.g.,PaginatedResultwithrequired: [results]) with an inline schema narrowing a property, theshouldMergeAllOfpath bailed out on encountering a$ref, falling through toObjectSchemaConverterwhich didn't propagate the parent'srequiredarray.Property-level allOf with
$ref+ inline primitive dropped: When a property usedallOfto combine a$refto an enum with inline string constraints, a synthetic empty object type was created instead of referencing the original enum.Changes Made
SchemaConverter.tsshouldMergeAllOfcode path to resolve$refelements viaresolveMaybeReferenceinstead of returningundefinedvisitedRefs: Set<string>threaded through all recursiveSchemaConverterinstantiations — falls back to ObjectSchemaConverter on cyclemergeWith(same as existing non-ref logic), preserving required arrays and property schemasoneOf/anyOfelements within allOf (mutual exclusion patterns): flattens variant properties into the merged schema as optional properties, filtering outnot: {}schemasSchemaOrReferenceConverter.tsmaybeConvertSingularAllOfReferenceObjectto handle allOf with one$refto a non-object type (e.g., enum) + inline primitive constraints$reftype$ref, resolved type is non-object, inline elements have no properties/enum/composition keywordsallof-spscommerce.test.tsbeforeAllto convert runtimetypediscriminants to_type(the serialized JSON form the assertions expect)Test fixture & snapshot updates
allofandallof-inlinetest-definition fixtures added undertest-definitions/fern/apis/allof-array-items-narrowing,examples-endpoint-level,examples-endpoint-level-named,url-reference) — these reflect allOf subschemas now being merged instead of modeled as extendsconvertIRtoJsonSchemasnapshots forallofandallof-inlinefixturesir-generator-teststest-definitions forallofandallof-inline(dynamic-snippets + IR)allof-spscommerceTesting
allof-spscommerce.test.tspass (7 were failing before)circular-referencesandcircular-references-advancedtests still passpnpm compilepasses forv3-importer-commonspnpm formatandbiome checkclean on changed filesNote: The test file
allof-spscommerce.test.tshas 15 pre-existingnoNonNullAssertionbiome errors from before this PR (the assertion code was written that way on the branch).biome-ignorecomments were added to suppress these without modifying the test assertions.Review Checklist
SchemaOrReferenceConvertercondition for non-object$reftypes isn't too broad — could it incorrectly short-circuit allOf patterns where the inline elements meaningfully change the type (not just add constraints)?undefined→ ObjectSchemaConverter) is the correct behavior for circular allOf referencesvisitedRefsis accumulated across recursiveSchemaConvertercalls. Verify this doesn't false-positive on diamond-shaped$refpatterns (same$reflegitimately used in different branches of the schema tree, which is not a cycle)JSON.stringifyreplacer (renamingtype→_typeon objects with_visit) is the right approach vs. using the IR SDK serializerLink to Devin session: https://app.devin.ai/sessions/25aab7ec7b404629a2076196be8be747