From 2fff61864946b8f402f43b5511a991076d4d6cc1 Mon Sep 17 00:00:00 2001 From: Jason Westover Date: Thu, 19 Feb 2026 20:05:05 -0600 Subject: [PATCH] fix: resolve sibling schemas from external files during bundling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `$RefParser.bundle()` processes external files that use wrapper/redirect patterns (common in DMTF Redfish and similar large-scale OpenAPI specs), sibling schemas within versioned files are not hoisted into the root spec. This produces dangling `$ref` pointers and "Skipping unresolvable $ref" warnings. **Affects:** 35 schemas lost when bundling the [DMTF Redfish OpenAPI spec](https://github.com/DMTF/Redfish-Publications/blob/main/openapi/openapi.yaml). The bundler's crawl path retains the **wrapper file** URL as context when traversing the resolved schema's properties. When a local `$ref` like `#/components/schemas/SiblingSchema` is encountered inside the resolved schema, `url.resolve()` resolves it against the wrapper file — which doesn't contain the sibling. The sibling only exists in the versioned file. **Example chain:** 1. `openapi.yaml` → `Message.v1_2_1.yaml` (HTTP) 2. `Message.v1_2_1.ya→ `ResolutionStep.yaml` (wrapper) 3. `ResolutionStep.yaml` → `ResolutionStep.v1_0_1.yaml` (versioned) 4. `ResolutionStep.v1_0_1.yaml` has `ResolutionStep_v1_0_1_ResolutionStep` with local `$ref: '#/components/schemas/ResolutionStep_v1_0_1_ResolutionType'` `bundle()` hoists `ResolutionStep_v1_0_1_ResolutionStep` but fails to resolve its sibling `ResolutionStep_v1_0_1_ResolutionType` because it looks in `ResolutionStep.yaml` (the wrapper) instead of `ResolutionStep.v1_0_1.yaml` (the versioned file). When `_resolve()` fails with `MissingPointerError`, try resolving the same hash fragment against all other files in the `$refs` registry. This handles the case where a local `$ref` targets a sibling schema that exists in a different file than the one retained in the crawl path. ```typescript // Before: fail immediately catch (error) { if (error instanceof MissingPointerError) { console.warn(`Skipping unresolvable $ref: ${$refPath}`); return; } } // After: try other files before giving atch (error) { if (error instanceof MissingPointerError) { const hash = url.getHash($refPath); if (hash) { const baseFile = url.stripHash($refPath); for (const filePath of Object.keys($refs._$refs)) { if (filePath === baseFile) continue; try { pointer = $refs._resolve(filePath + hash, pathFromRoot, options); if (pointer) break; } catch { /* try next file */ } } } if (!pointer) { console.warn(`Skipping unresolvable $ref: ${$refPath}`); return; } } } ``` Additionally, when `inventory$Ref` resolves a `$ref` that chains to a different file, the recursive crawl's path is rebased to the resolved file URL so that subsequent local `$ref`s resolve against the correct file. Tested against the full DMTF Redfish OpenAPI specification (~2600 schemas, ~2670 paths, hundreds of external HTTP files): - **Before:** 37 "Skipping unresolvable $ref" warnings, 35 schemas lost - **After:** 0 warnings, all schemas correctly hoisted - `packages/json-schema-ref-parser/src/bundle.ts` — two changes in `inventory$Ref`: 1. Fallback resolution against all `$refs` files when `MissingPointerError` occurs 2. Crawl path rebase when resolution chains to a different file Fixes #3412 Signed-off-by: Jason Westover --- packages/json-schema-ref-parser/src/bundle.ts | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/json-schema-ref-parser/src/bundle.ts b/packages/json-schema-ref-parser/src/bundle.ts index 7293f917d..e337b0a29 100644 --- a/packages/json-schema-ref-parser/src/bundle.ts +++ b/packages/json-schema-ref-parser/src/bundle.ts @@ -157,11 +157,31 @@ const inventory$Ref = ({ pointer = $refs._resolve($refPath, pathFromRoot, options); } catch (error) { if (error instanceof MissingPointerError) { - // Log warning but continue - common in complex schema ecosystems - console.warn(`Skipping unresolvable $ref: ${$refPath}`); - return; + // The ref couldn't be resolved in the target file. This commonly + // happens when a wrapper file redirects via $ref to a versioned + // file, and the bundler's crawl path retains the wrapper URL. + // Try resolving the hash fragment against other files in $refs + // that might contain the target schema. + const hash = url.getHash($refPath); + if (hash) { + const baseFile = url.stripHash($refPath); + for (const filePath of Object.keys($refs._$refs)) { + if (filePath === baseFile) continue; + try { + pointer = $refs._resolve(filePath + hash, pathFromRoot, options); + if (pointer) break; + } catch { + // try next file + } + } + } + if (!pointer) { + console.warn(`Skipping unresolvable $ref: ${$refPath}`); + return; + } + } else { + throw error; } - throw error; // Re-throw unexpected errors } if (pointer) { @@ -217,8 +237,19 @@ const inventory$Ref = ({ inventory.push(newEntry); inventoryLookup.add(newEntry); - // Recursively crawl the resolved value + // Recursively crawl the resolved value. + // When the resolution followed a $ref chain to a different file, + // use the resolved file as the base path so that local $ref values + // (e.g. #/components/schemas/SiblingSchema) inside the resolved + // value resolve against the correct file. if (!existingEntry || external) { + let crawlPath = pointer.path; + + const originalFile = url.stripHash($refPath); + if (file !== originalFile) { + crawlPath = file + url.getHash(pointer.path); + } + crawl({ $refs, indirections: indirections + 1, @@ -227,7 +258,7 @@ const inventory$Ref = ({ key: null, options, parent: pointer.value, - path: pointer.path, + path: crawlPath, pathFromRoot, resolvedRefs, visitedObjects, -- 2.51.2