@@ -81,4 +81,231 @@ describe('bundle', () => {
8181
8282 await expectBundledSchemaToMatchSnapshot ( schema , 'redfish-like.json' ) ;
8383 } ) ;
84+
85+ describe ( 'sibling schema resolution' , ( ) => {
86+ const specsDir = path . join ( getSpecsPath ( ) , 'json-schema-ref-parser' ) ;
87+
88+ const findSchemaByValue = (
89+ schemas : Record < string , any > ,
90+ predicate : ( value : any ) => boolean ,
91+ ) : [ string , any ] | undefined => {
92+ for ( const [ name , value ] of Object . entries ( schemas ) ) {
93+ if ( predicate ( value ) ) {
94+ return [ name , value ] ;
95+ }
96+ }
97+ return undefined ;
98+ } ;
99+
100+ it ( 'hoists sibling schemas through a bare $ref wrapper chain' , async ( ) => {
101+ const refParser = new $RefParser ( ) ;
102+ const pathOrUrlOrSchema = path . join ( specsDir , 'sibling-schema-root.json' ) ;
103+ const schema = ( await refParser . bundle ( { pathOrUrlOrSchema } ) ) as any ;
104+
105+ expect ( schema . components ) . toBeDefined ( ) ;
106+ expect ( schema . components . schemas ) . toBeDefined ( ) ;
107+
108+ const schemas = schema . components . schemas ;
109+
110+ const mainSchema = findSchemaByValue (
111+ schemas ,
112+ ( v ) => v . type === 'object' && v . properties ?. name ,
113+ ) ;
114+ expect ( mainSchema ) . toBeDefined ( ) ;
115+ const [ mainName , mainValue ] = mainSchema ! ;
116+ expect ( mainValue . type ) . toBe ( 'object' ) ;
117+ expect ( mainValue . properties . name ) . toEqual ( { type : 'string' } ) ;
118+
119+ const enumSchema = findSchemaByValue (
120+ schemas ,
121+ ( v ) => Array . isArray ( v . enum ) && v . enum . includes ( 'active' ) ,
122+ ) ;
123+ expect ( enumSchema ) . toBeDefined ( ) ;
124+ const [ enumName , enumValue ] = enumSchema ! ;
125+ expect ( enumValue . type ) . toBe ( 'string' ) ;
126+ expect ( enumValue . enum ) . toEqual ( [ 'active' , 'inactive' , 'pending' ] ) ;
127+
128+ // The main schema's status property should reference the hoisted enum
129+ expect ( mainValue . properties . status . $ref ) . toBe ( `#/components/schemas/${ enumName } ` ) ;
130+
131+ // The root path's schema ref should point to the hoisted main schema
132+ const rootRef = schema . paths [ '/test' ] . get . responses [ '200' ] . content [ 'application/json' ] . schema ;
133+ expect ( rootRef . $ref ) . toBe ( `#/components/schemas/${ mainName } ` ) ;
134+ } ) ;
135+
136+ it ( 'hoists sibling schemas through an extended $ref wrapper chain' , async ( ) => {
137+ const refParser = new $RefParser ( ) ;
138+ const pathOrUrlOrSchema = path . join ( specsDir , 'sibling-schema-extended-root.json' ) ;
139+ const warnSpy = vi . spyOn ( console , 'warn' ) . mockImplementation ( ( ) => { } ) ;
140+
141+ try {
142+ const schema = ( await refParser . bundle ( { pathOrUrlOrSchema } ) ) as any ;
143+
144+ expect ( schema . components ) . toBeDefined ( ) ;
145+ expect ( schema . components . schemas ) . toBeDefined ( ) ;
146+
147+ const schemas = schema . components . schemas ;
148+
149+ // The main schema should be hoisted (with the extra description merged in)
150+ const mainSchema = findSchemaByValue (
151+ schemas ,
152+ ( v ) =>
153+ v . description === 'Wrapper that extends the versioned schema' ||
154+ ( v . type === 'object' && v . properties ?. name ) ,
155+ ) ;
156+ expect ( mainSchema ) . toBeDefined ( ) ;
157+
158+ // The sibling enum must also be hoisted (this was the bug — it was lost before the fix)
159+ const enumSchema = findSchemaByValue (
160+ schemas ,
161+ ( v ) => Array . isArray ( v . enum ) && v . enum . includes ( 'active' ) ,
162+ ) ;
163+ expect ( enumSchema ) . toBeDefined ( ) ;
164+ const [ , enumValue ] = enumSchema ! ;
165+ expect ( enumValue . type ) . toBe ( 'string' ) ;
166+ expect ( enumValue . enum ) . toEqual ( [ 'active' , 'inactive' , 'pending' ] ) ;
167+
168+ // No "Skipping unresolvable $ref" warnings should have been emitted
169+ const unresolvableWarnings = warnSpy . mock . calls . filter (
170+ ( args ) => typeof args [ 0 ] === 'string' && args [ 0 ] . includes ( 'Skipping unresolvable $ref' ) ,
171+ ) ;
172+ expect ( unresolvableWarnings ) . toHaveLength ( 0 ) ;
173+ } finally {
174+ warnSpy . mockRestore ( ) ;
175+ }
176+ } ) ;
177+
178+ it ( 'hoists sibling schemas from a direct reference (no wrapper)' , async ( ) => {
179+ const refParser = new $RefParser ( ) ;
180+ const pathOrUrlOrSchema = path . join ( specsDir , 'sibling-schema-direct-root.json' ) ;
181+ const schema = ( await refParser . bundle ( { pathOrUrlOrSchema } ) ) as any ;
182+
183+ expect ( schema . components ) . toBeDefined ( ) ;
184+ expect ( schema . components . schemas ) . toBeDefined ( ) ;
185+
186+ const schemas = schema . components . schemas ;
187+
188+ const mainSchema = findSchemaByValue (
189+ schemas ,
190+ ( v ) => v . type === 'object' && v . properties ?. name ,
191+ ) ;
192+ expect ( mainSchema ) . toBeDefined ( ) ;
193+
194+ const enumSchema = findSchemaByValue (
195+ schemas ,
196+ ( v ) => Array . isArray ( v . enum ) && v . enum . includes ( 'active' ) ,
197+ ) ;
198+ expect ( enumSchema ) . toBeDefined ( ) ;
199+ const [ enumName , enumValue ] = enumSchema ! ;
200+ expect ( enumValue . enum ) . toEqual ( [ 'active' , 'inactive' , 'pending' ] ) ;
201+
202+ const [ , mainValue ] = mainSchema ! ;
203+ expect ( mainValue . properties . status . $ref ) . toBe ( `#/components/schemas/${ enumName } ` ) ;
204+ } ) ;
205+
206+ it ( 'hoists multiple sibling schemas through an extended wrapper' , async ( ) => {
207+ const refParser = new $RefParser ( ) ;
208+ const pathOrUrlOrSchema = path . join ( specsDir , 'sibling-schema-multi-root.json' ) ;
209+ const warnSpy = vi . spyOn ( console , 'warn' ) . mockImplementation ( ( ) => { } ) ;
210+
211+ try {
212+ const schema = ( await refParser . bundle ( { pathOrUrlOrSchema } ) ) as any ;
213+
214+ expect ( schema . components ) . toBeDefined ( ) ;
215+ expect ( schema . components . schemas ) . toBeDefined ( ) ;
216+
217+ const schemas = schema . components . schemas ;
218+
219+ const mainSchema = findSchemaByValue (
220+ schemas ,
221+ ( v ) => v . type === 'object' && v . properties ?. health ,
222+ ) ;
223+ expect ( mainSchema ) . toBeDefined ( ) ;
224+
225+ const statusEnum = findSchemaByValue (
226+ schemas ,
227+ ( v ) => Array . isArray ( v . enum ) && v . enum . includes ( 'enabled' ) ,
228+ ) ;
229+ expect ( statusEnum ) . toBeDefined ( ) ;
230+ expect ( statusEnum ! [ 1 ] . enum ) . toEqual ( [ 'enabled' , 'disabled' , 'standby' ] ) ;
231+
232+ const healthEnum = findSchemaByValue (
233+ schemas ,
234+ ( v ) => Array . isArray ( v . enum ) && v . enum . includes ( 'ok' ) ,
235+ ) ;
236+ expect ( healthEnum ) . toBeDefined ( ) ;
237+ expect ( healthEnum ! [ 1 ] . enum ) . toEqual ( [ 'ok' , 'warning' , 'critical' ] ) ;
238+
239+ const [ , mainValue ] = mainSchema ! ;
240+ expect ( mainValue . properties . status . $ref ) . toBe ( `#/components/schemas/${ statusEnum ! [ 0 ] } ` ) ;
241+ expect ( mainValue . properties . health . $ref ) . toBe ( `#/components/schemas/${ healthEnum ! [ 0 ] } ` ) ;
242+
243+ const unresolvableWarnings = warnSpy . mock . calls . filter (
244+ ( args ) => typeof args [ 0 ] === 'string' && args [ 0 ] . includes ( 'Skipping unresolvable $ref' ) ,
245+ ) ;
246+ expect ( unresolvableWarnings ) . toHaveLength ( 0 ) ;
247+ } finally {
248+ warnSpy . mockRestore ( ) ;
249+ }
250+ } ) ;
251+
252+ it ( 'handles multiple external files with same-named sibling schemas' , async ( ) => {
253+ const refParser = new $RefParser ( ) ;
254+ const pathOrUrlOrSchema = path . join ( specsDir , 'sibling-schema-collision-root.json' ) ;
255+ const warnSpy = vi . spyOn ( console , 'warn' ) . mockImplementation ( ( ) => { } ) ;
256+
257+ try {
258+ const schema = ( await refParser . bundle ( { pathOrUrlOrSchema } ) ) as any ;
259+
260+ expect ( schema . components ) . toBeDefined ( ) ;
261+ expect ( schema . components . schemas ) . toBeDefined ( ) ;
262+
263+ const schemas = schema . components . schemas ;
264+ const schemaNames = Object . keys ( schemas ) ;
265+
266+ const mainSchemaKey = schemaNames . find ( ( name ) => name . includes ( 'MainSchema' ) ) ;
267+ const otherSchemaKey = schemaNames . find ( ( name ) => name . includes ( 'OtherSchema' ) ) ;
268+
269+ expect ( mainSchemaKey ) . toBeDefined ( ) ;
270+ expect ( otherSchemaKey ) . toBeDefined ( ) ;
271+
272+ const statusSchemas = schemaNames . filter ( ( name ) => name . includes ( 'Status' ) ) ;
273+ expect ( statusSchemas . length ) . toBeGreaterThanOrEqual ( 2 ) ;
274+
275+ const statusValues = statusSchemas . map ( ( name ) => schemas [ name ] ) ;
276+ const stringStatus = statusValues . find ( ( v : any ) => v . type === 'string' ) ;
277+ const integerStatus = statusValues . find ( ( v : any ) => v . type === 'integer' ) ;
278+
279+ expect ( stringStatus ) . toBeDefined ( ) ;
280+ expect ( integerStatus ) . toBeDefined ( ) ;
281+ expect ( stringStatus ! . enum ) . toEqual ( [ 'active' , 'inactive' ] ) ;
282+ expect ( integerStatus ! . enum ) . toEqual ( [ 0 , 1 , 2 ] ) ;
283+
284+ const mainSchemaValue = schemas [ mainSchemaKey ! ] ;
285+ const mainStatusRef = mainSchemaValue . properties . status . $ref ;
286+ expect ( mainStatusRef ) . toMatch ( / ^ # \/ c o m p o n e n t s \/ s c h e m a s \/ .* S t a t u s / ) ;
287+
288+ const referencedStatus = schemas [ mainStatusRef . replace ( '#/components/schemas/' , '' ) ] ;
289+ expect ( referencedStatus ) . toBeDefined ( ) ;
290+ expect ( referencedStatus . type ) . toBe ( 'string' ) ;
291+ expect ( referencedStatus . enum ) . toEqual ( [ 'active' , 'inactive' ] ) ;
292+
293+ const otherSchemaValue = schemas [ otherSchemaKey ! ] ;
294+ const otherStatusRef = otherSchemaValue . properties . code . $ref ;
295+ expect ( otherStatusRef ) . toMatch ( / ^ # \/ c o m p o n e n t s \/ s c h e m a s \/ .* S t a t u s / ) ;
296+
297+ const referencedOtherStatus = schemas [ otherStatusRef . replace ( '#/components/schemas/' , '' ) ] ;
298+ expect ( referencedOtherStatus ) . toBeDefined ( ) ;
299+ expect ( referencedOtherStatus . type ) . toBe ( 'integer' ) ;
300+ expect ( referencedOtherStatus . enum ) . toEqual ( [ 0 , 1 , 2 ] ) ;
301+
302+ const unresolvableWarnings = warnSpy . mock . calls . filter (
303+ ( args ) => typeof args [ 0 ] === 'string' && args [ 0 ] . includes ( 'Skipping unresolvable $ref' ) ,
304+ ) ;
305+ expect ( unresolvableWarnings ) . toHaveLength ( 0 ) ;
306+ } finally {
307+ warnSpy . mockRestore ( ) ;
308+ }
309+ } ) ;
310+ } ) ;
84311} ) ;
0 commit comments