Skip to content

fix(parser): resolve nested $ref objects in allOf flattening during v3 OpenAPI parsing#16162

Merged
jsklan merged 3 commits into
mainfrom
devin/1780338258-fix-allof-nested-ref-resolution
Jun 2, 2026
Merged

fix(parser): resolve nested $ref objects in allOf flattening during v3 OpenAPI parsing#16162
jsklan merged 3 commits into
mainfrom
devin/1780338258-fix-allof-nested-ref-resolution

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot commented Jun 1, 2026

Description

When a parent schema in an allOf composition itself uses allOf with $ref elements (e.g., userPost → userBase → userStrict), the v3 parser's flattenNestedAllOf silently drops those nested $ref objects. This causes parent properties and required arrays to be lost in docs rendering.

Live example: https://docs.bigcommerce.com/developer/api-reference/rest/b2b/management/users/companies-users-create

Changes Made

  • Added optional resolveRef callback parameter to mergeAllOfSchemas and flattenNestedAllOf
  • When a resolver is provided, nested $ref entries are resolved instead of filtered out
  • Cycle detection via visitedRefs set prevents infinite recursion on circular $ref chains
  • SchemaConverter passes its context.resolveMaybeReference as the resolver callback, with a guard that skips results that are still references (e.g. URL ref aliases)
  • Backward-compatible: when no resolver is provided, behavior is unchanged (refs still filtered)

Root cause in mergeAllOfSchemas.ts flattenNestedAllOf():

// Before: silently drops nested $ref objects (defensive — no resolver available)
const schemaChildren = allOf.filter((child) => !("$ref" in child));

// After: resolves them when a resolver is available, still skips when not
if ("$ref" in child) {
    if (resolveRef == null) { continue; }
    const resolved = resolveRef(child);
    if (resolved != null) { schemaChildren.push(resolved); }
} else {
    schemaChildren.push(child);
}

The original filtering was intentional as a defensive measure — merging an unresolved {"$ref": "..."} into the result would corrupt it with a spurious $ref key. The issue was that flattenNestedAllOf had no way to resolve refs; the fix adds that capability via an optional callback while preserving the safe fallback.

Note on url-reference snapshot diff

The large snapshot deletion (6234 lines) in url-reference.json / url-reference-baseline.json is a pre-existing stale snapshot — not caused by this change. That fixture fetches an external spec from https://raw.githubusercontent.com/fern-api/external-ref-fixture-specs/, and the upstream spec changed after the snapshot was last committed. I verified this by checking out main, regenerating the snapshot, and diffing — the output is byte-for-byte identical between main and this branch.

Testing

  • 26 unit tests (8 new: single-level resolution, deep chains, circular refs, multiple refs, sibling properties, required merging, undefined resolver, backward compat)
  • v3-importer-tests fixture (allof-nested-ref) with 3 test cases: three-level chain (UserPost → UserBase → UserStrict), sibling properties + nested ref (CompanyPost → CompanyBase → AddressBase), multiple refs in single allOf (PlantRecord → PlantBase → [Identifiable, Describable])
  • Seed tests extended: added Cases 7-8 to existing allof fixture (nested $ref chain, multiple nested refs in parent allOf)
  • 4 SDK generators verified: TypeScript, Python, Java, Go — all produce correct generated code with full property inheritance
  • Before/after comparison against main: bug confirmed (5 props on main, 8 on fix branch)
  • All v3-importer-tests pass (286 tests)
  • Lint passes (biome lint --error-on-warnings, biome format)

Link to Devin session: https://app.devin.ai/sessions/52f4bc574f654a6a8c40f70ea454cca6

@devin-ai-integration devin-ai-integration Bot requested a review from amckinney as a code owner June 1, 2026 18:30
@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, CI, and merge conflict 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.

@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

E2E Test Results

Tested by creating a minimal OpenAPI fixture matching the BigCommerce userPost allOf nesting pattern (UserPost → UserBase → UserStrict), running it through the full v3 parser pipeline on both main and the fix branch.

Before/After Comparison

🔴 On main (BUG) — UserPost IR output:

Property count: 5
  role: REQUIRED
  companyId: REQUIRED
  acceptWelcomeEmail: optional
  originChannelId: optional
  channelIds: REQUIRED

  Grandparent prop "firstName" present: False
  Grandparent prop "lastName" present: False
  Grandparent prop "email" present: False

🟢 On fix branch (FIXED) — UserPost IR output:

Property count: 8
  firstName: REQUIRED
  lastName: REQUIRED
  email: REQUIRED
  role: REQUIRED
  companyId: REQUIRED
  acceptWelcomeEmail: optional
  originChannelId: optional
  channelIds: REQUIRED

The 3 grandparent properties (firstName, lastName, email) from UserStrict are now correctly resolved through the nested allOf chain, and the required array from the inline child is properly applied.

Test Assertions
  • ✅ Parent properties preserved: all 8 properties from 3 levels merged into UserPost
  • ✅ Required array propagated: firstName, lastName, email, role, companyId, channelIds all marked REQUIRED
  • ✅ Optional fields correct: acceptWelcomeEmail, originChannelId correctly optional
  • ✅ Before/after confirmed: bug reproduced on main (only 5 props), fix recovers all 8
  • ✅ Unit tests: all 22 pass (18 existing + 4 new, including cycle detection)
  • ✅ Backward compat: without resolver, behavior unchanged (existing tests pass)

Devin session

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 1, 2026

Docs Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-06-02T05:32:59Z).

Fixture main PR Delta
docs 230.4s (n=5) 224.9s (35 versions) -5.5s (-2.4%)

Docs generation runs fern generate --docs --preview end-to-end against the benchmark fixture with 35 API versions (each version: markdown processing + OpenAPI-to-IR + FDR upload).
Delta is computed against the nightly baseline on main.
Baseline from nightly run(s) on main (latest: 2026-06-02T05:32:59Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-06-02 22:29 UTC

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 1, 2026

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-06-02T05:32:59Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
csharp-sdk square 74s (n=5) 110s (n=5) 60s -14s (-18.9%)
go-sdk square 136s (n=5) 282s (n=5) 131s -5s (-3.7%)
java-sdk square 225s (n=5) 272s (n=5) 182s -43s (-19.1%)
php-sdk square 59s (n=5) 81s (n=5) 52s -7s (-11.9%)
python-sdk square 135s (n=5) 236s (n=5) 132s -3s (-2.2%)
ruby-sdk-v2 square 89s (n=5) 124s (n=5) 80s -9s (-10.1%)
rust-sdk square 167s (n=5) 170s (n=5) 161s -6s (-3.6%)
swift-sdk square 55s (n=5) 746s (n=5) 49s -6s (-10.9%)
ts-sdk square 229s (n=5) 234s (n=5) 219s -10s (-4.4%)

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-06-02T05:32:59Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-06-02 22:33 UTC

devin-ai-integration Bot and others added 3 commits June 2, 2026 12:53
…I parsing

When a parent schema in an allOf composition itself uses allOf with $ref
elements (e.g., userPost → userBase → userStrict), the v3 parser's
flattenNestedAllOf was silently dropping those nested $ref objects. This
caused parent properties and required arrays to be lost in docs rendering.

The fix adds an optional resolveRef callback to mergeAllOfSchemas that
flattenNestedAllOf uses to resolve nested $ref objects instead of
filtering them out. Cycle detection via a visited set prevents infinite
recursion.

Co-Authored-By: will.kendall@buildwithfern.com <wpk235@gmail.com>
…le url-reference snapshots

Guard against ref chains (local alias → external URL ref) that resolve
to another ReferenceObject instead of a schema. The resolver now returns
undefined for these, preserving the existing filter-out behavior.

Also updates the pre-existing stale url-reference test snapshots (the
external spec changed upstream; identical output on main).

Co-Authored-By: will.kendall@buildwithfern.com <wpk235@gmail.com>
- Add permanent v3-importer-tests fixture (allof-nested-ref) with 3 test cases:
  three-level chain, sibling properties + nested ref, multiple refs in single allOf
- Add 4 new unit test edge cases: required merging across ref boundaries,
  multiple refs in single allOf, sibling properties preservation, undefined resolver
- Extend existing allof seed test fixture with Cases 7-8 (nested $ref chain,
  multiple nested refs in parent allOf)
- Run seed tests through TS, Python, Java, Go generators - all pass
- Total: 26 unit tests, 286 v3-importer-tests, 4 seed generators verified

Co-Authored-By: will.kendall@buildwithfern.com <wpk235@gmail.com>
@devin-ai-integration devin-ai-integration Bot force-pushed the devin/1780338258-fix-allof-nested-ref-resolution branch from d9b780f to 91a8771 Compare June 2, 2026 12:55
@fern-support
Copy link
Copy Markdown
Collaborator

@claude review once

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.

This is a well-tested logic fix in the v3 parser's allOf flattening, but it changes core IR generation behavior that flows into every SDK generator — worth a human look before merging.

Extended reasoning...

Overview

This PR fixes a real bug in flattenNestedAllOf where nested $ref objects inside an allOf array were silently filtered out, causing grandparent properties to be lost (the BigCommerce userPost → userBase → userStrict pattern). The fix threads an optional resolveRef callback through mergeAllOfSchemas and flattenNestedAllOf, with cycle detection via a shared visitedRefs set, and SchemaConverter wires up context.resolveMaybeReference with a guard that skips results that remain references (e.g. URL ref aliases). The diff is 79 files but most of that is seed snapshot churn — the actual logic change is small and self-contained in mergeAllOfSchemas.ts and SchemaConverter.ts.

Security risks

No security risks. This is internal parser/IR-generation logic; no auth, crypto, network, or untrusted-input handling is involved. The cycle-detection set bounds recursion correctly, so there's no DoS surface from circular $ref chains either.

Level of scrutiny

This warrants careful human review. mergeAllOfSchemas is core IR-generation code used by every downstream SDK generator (TS/Python/Java/Go/C#/Ruby/PHP/Swift/Rust). The previous "filter $refs" behavior was intentional and defensive — now we're resolving them, which by design changes the IR output for any spec that uses this nesting pattern. The seed snapshot updates (allof fixture, url-reference snapshot regen) are the visible blast radius, but real-world customer specs that happened to rely on the buggy behavior (e.g. duplicating grandparent props inline as a workaround) could see SDK output changes. The author has done good due diligence — 26 unit tests including cycle detection, undefined-resolver fallback, and multi-$ref cases; a dedicated allof-nested-ref fixture exercising three patterns; before/after IR comparison showing 5→8 props recovered on the BigCommerce repro.

Other factors

The optional-callback design preserves backward compat for any other caller of mergeAllOfSchemas (the test filters out $ref objects from nested allOf arrays when no resolver provided locks this in). The isReferenceObject guard in the SchemaConverter callback is a thoughtful touch for URL ref aliases. The maintainer (fern-support) explicitly tagged for review (@claude review once), suggesting they want a second pair of eyes on the behavior change. The bug-hunting pass found nothing, and I don't see correctness issues either — deferring purely on blast-radius and the fact that this is a deliberate behavioral change in shared parser infrastructure.

@jsklan jsklan merged commit b6a6467 into main Jun 2, 2026
233 of 395 checks passed
@jsklan jsklan deleted the devin/1780338258-fix-allof-nested-ref-resolution branch June 2, 2026 19:28
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