@@ -130,9 +130,329 @@ describe('combineSchemas (allOf required handling)', () => {
130130 nullable : '' ,
131131 } ) ;
132132
133- expect ( result . value ) . toContain (
134- "Required<Pick<MidWrapper, Extract<keyof (MidWrapper), 'baseProp'>>>" ,
135- ) ;
133+ expect ( result . value ) . toContain ( "Required<Pick<MidWrapper, 'baseProp'>>" ) ;
134+ expect ( result . value ) . not . toContain ( 'Extract<' ) ;
135+ } ) ;
136+
137+ // #3748: required keys whose properties live two $ref hops away (the
138+ // referenced schema is itself an allOf composition) must resolve to a plain
139+ // Required<Pick>. The Extract guard is not equivalent here: with
140+ // `additionalProperties: true` the index signature collapses
141+ // `Extract<keyof T, K>` to `never`, silently dropping the required override.
142+ it ( 'resolves required keys defined in a nested allOf $ref composition (#3748)' , ( ) => {
143+ const contextWithNestedComposition = {
144+ ...context ,
145+ spec : {
146+ components : {
147+ schemas : {
148+ ...context . spec . components ! . schemas ,
149+ Contents : {
150+ type : 'object' ,
151+ properties : {
152+ id : { type : 'string' } ,
153+ name : { type : 'string' } ,
154+ } ,
155+ additionalProperties : true ,
156+ } ,
157+ ItemBase : {
158+ allOf : [
159+ { $ref : '#/components/schemas/Contents' } ,
160+ {
161+ type : 'object' ,
162+ properties : {
163+ status : { type : 'string' } ,
164+ } ,
165+ required : [ 'status' ] ,
166+ additionalProperties : true ,
167+ } ,
168+ ] ,
169+ additionalProperties : true ,
170+ } ,
171+ } ,
172+ } ,
173+ } ,
174+ } as unknown as ContextSpec ;
175+
176+ const schema : OpenApiSchemaObject = {
177+ allOf : [
178+ { $ref : '#/components/schemas/ItemBase' } ,
179+ {
180+ type : 'object' ,
181+ properties : {
182+ extra : { type : 'number' } ,
183+ } ,
184+ required : [ 'id' , 'name' ] ,
185+ additionalProperties : true ,
186+ } ,
187+ ] ,
188+ additionalProperties : true ,
189+ } ;
190+
191+ const result = combineSchemas ( {
192+ schema,
193+ name : 'ItemDetail' ,
194+ separator : 'allOf' ,
195+ context : contextWithNestedComposition ,
196+ nullable : '' ,
197+ } ) ;
198+
199+ expect ( result . value ) . toContain ( "'id' | 'name'>>" ) ;
200+ expect ( result . value ) . toContain ( 'Required<Pick<' ) ;
201+ expect ( result . value ) . not . toContain ( 'Extract<' ) ;
202+ } ) ;
203+
204+ // Keys reached through a nullable node must stay Extract-guarded: the
205+ // emitted type unions `| null`, so `keyof` is `never` and a plain
206+ // Required<Pick> would fail with TS2344.
207+ it ( 'keeps Extract guard when the nested composition member is nullable' , ( ) => {
208+ const contextWithNullableBase = {
209+ ...context ,
210+ spec : {
211+ components : {
212+ schemas : {
213+ ...context . spec . components ! . schemas ,
214+ NullableBase : {
215+ type : [ 'object' , 'null' ] ,
216+ properties : {
217+ id : { type : 'string' } ,
218+ } ,
219+ } ,
220+ NullableWrapper : {
221+ allOf : [ { $ref : '#/components/schemas/NullableBase' } ] ,
222+ } ,
223+ } ,
224+ } ,
225+ } ,
226+ } as unknown as ContextSpec ;
227+
228+ const schema : OpenApiSchemaObject = {
229+ type : 'object' ,
230+ required : [ 'id' ] ,
231+ allOf : [ { $ref : '#/components/schemas/NullableWrapper' } ] ,
232+ } ;
233+
234+ const result = combineSchemas ( {
235+ schema,
236+ name : 'NullableItem' ,
237+ separator : 'allOf' ,
238+ context : contextWithNullableBase ,
239+ nullable : '' ,
240+ } ) ;
241+
242+ expect ( result . value ) . toContain ( "Extract<keyof (NullableWrapper), 'id'>" ) ;
243+ expect ( result . value ) . not . toContain ( "Pick<NullableWrapper, 'id'>>" ) ;
244+ } ) ;
245+
246+ // Same reasoning for enum-bearing nodes: the emission is a literal union,
247+ // so property keys collected from the node are not in `keyof`.
248+ it ( 'keeps Extract guard when the nested composition member carries an enum' , ( ) => {
249+ const contextWithEnumBase = {
250+ ...context ,
251+ spec : {
252+ components : {
253+ schemas : {
254+ ...context . spec . components ! . schemas ,
255+ EnumBase : {
256+ type : 'object' ,
257+ enum : [ { id : 'a' } , { id : 'b' } ] ,
258+ properties : {
259+ id : { type : 'string' } ,
260+ } ,
261+ } ,
262+ EnumWrapper : {
263+ allOf : [ { $ref : '#/components/schemas/EnumBase' } ] ,
264+ } ,
265+ } ,
266+ } ,
267+ } ,
268+ } as unknown as ContextSpec ;
269+
270+ const schema : OpenApiSchemaObject = {
271+ type : 'object' ,
272+ required : [ 'id' ] ,
273+ allOf : [ { $ref : '#/components/schemas/EnumWrapper' } ] ,
274+ } ;
275+
276+ const result = combineSchemas ( {
277+ schema,
278+ name : 'EnumItem' ,
279+ separator : 'allOf' ,
280+ context : contextWithEnumBase ,
281+ nullable : '' ,
282+ } ) ;
283+
284+ expect ( result . value ) . toContain ( "Extract<keyof (EnumWrapper), 'id'>" ) ;
285+ expect ( result . value ) . not . toContain ( "Pick<EnumWrapper, 'id'>>" ) ;
286+ } ) ;
287+
288+ // A `$ref` member can carry union-producing siblings (`nullable: true`,
289+ // `type: ['object', 'null']`) that the resolver merges into the emission
290+ // (`Wrapper = Base | null`), so the ref-site object must pass the same
291+ // union guard as inline nodes before dereferencing.
292+ it ( 'keeps Extract guard when a nested $ref member carries a nullable sibling' , ( ) => {
293+ const contextWithNullableRefSite = {
294+ ...context ,
295+ spec : {
296+ components : {
297+ schemas : {
298+ ...context . spec . components ! . schemas ,
299+ RefSiteBase : {
300+ type : 'object' ,
301+ properties : {
302+ id : { type : 'string' } ,
303+ } ,
304+ } ,
305+ RefSiteWrapper : {
306+ allOf : [
307+ { $ref : '#/components/schemas/RefSiteBase' , nullable : true } ,
308+ ] ,
309+ } ,
310+ } ,
311+ } ,
312+ } ,
313+ } as unknown as ContextSpec ;
314+
315+ const schema : OpenApiSchemaObject = {
316+ type : 'object' ,
317+ required : [ 'id' ] ,
318+ allOf : [ { $ref : '#/components/schemas/RefSiteWrapper' } ] ,
319+ } ;
320+
321+ const result = combineSchemas ( {
322+ schema,
323+ name : 'RefSiteItem' ,
324+ separator : 'allOf' ,
325+ context : contextWithNullableRefSite ,
326+ nullable : '' ,
327+ } ) ;
328+
329+ expect ( result . value ) . toContain ( "Extract<keyof (RefSiteWrapper), 'id'>" ) ;
330+ expect ( result . value ) . not . toContain ( "Pick<RefSiteWrapper, 'id'>>" ) ;
331+ } ) ;
332+
333+ // A multi-entry type array unions its branches even without `null`
334+ // (`type: ['object', 'string']` emits `{...} | string`), so keys from such
335+ // nodes are not guaranteed in `keyof` either.
336+ it ( 'keeps Extract guard when the nested composition member has a non-null type array union' , ( ) => {
337+ const contextWithMixedType = {
338+ ...context ,
339+ spec : {
340+ components : {
341+ schemas : {
342+ ...context . spec . components ! . schemas ,
343+ MixedBase : {
344+ type : [ 'object' , 'string' ] ,
345+ properties : {
346+ id : { type : 'string' } ,
347+ } ,
348+ } ,
349+ MixedWrapper : {
350+ allOf : [ { $ref : '#/components/schemas/MixedBase' } ] ,
351+ } ,
352+ } ,
353+ } ,
354+ } ,
355+ } as unknown as ContextSpec ;
356+
357+ const schema : OpenApiSchemaObject = {
358+ type : 'object' ,
359+ required : [ 'id' ] ,
360+ allOf : [ { $ref : '#/components/schemas/MixedWrapper' } ] ,
361+ } ;
362+
363+ const result = combineSchemas ( {
364+ schema,
365+ name : 'MixedItem' ,
366+ separator : 'allOf' ,
367+ context : contextWithMixedType ,
368+ nullable : '' ,
369+ } ) ;
370+
371+ expect ( result . value ) . toContain ( "Extract<keyof (MixedWrapper), 'id'>" ) ;
372+ expect ( result . value ) . not . toContain ( "Pick<MixedWrapper, 'id'>>" ) ;
373+ } ) ;
374+
375+ // Any anyOf/oneOf on a walked node is treated as union emission: deep key
376+ // collection is restricted to plain object/intersection shapes.
377+ it ( 'keeps Extract guard when the nested composition member carries a oneOf' , ( ) => {
378+ const contextWithOneOf = {
379+ ...context ,
380+ spec : {
381+ components : {
382+ schemas : {
383+ ...context . spec . components ! . schemas ,
384+ OneOfBase : {
385+ properties : {
386+ id : { type : 'string' } ,
387+ } ,
388+ oneOf : [ { type : 'object' } , { type : 'string' } ] ,
389+ } ,
390+ OneOfWrapper : {
391+ allOf : [ { $ref : '#/components/schemas/OneOfBase' } ] ,
392+ } ,
393+ } ,
394+ } ,
395+ } ,
396+ } as unknown as ContextSpec ;
397+
398+ const schema : OpenApiSchemaObject = {
399+ type : 'object' ,
400+ required : [ 'id' ] ,
401+ allOf : [ { $ref : '#/components/schemas/OneOfWrapper' } ] ,
402+ } ;
403+
404+ const result = combineSchemas ( {
405+ schema,
406+ name : 'OneOfItem' ,
407+ separator : 'allOf' ,
408+ context : contextWithOneOf ,
409+ nullable : '' ,
410+ } ) ;
411+
412+ expect ( result . value ) . toContain ( "Extract<keyof (OneOfWrapper), 'id'>" ) ;
413+ expect ( result . value ) . not . toContain ( "Pick<OneOfWrapper, 'id'>>" ) ;
414+ } ) ;
415+
416+ it ( 'terminates on cyclic allOf $ref compositions' , ( ) => {
417+ const contextWithCycle = {
418+ ...context ,
419+ spec : {
420+ components : {
421+ schemas : {
422+ ...context . spec . components ! . schemas ,
423+ CycleA : {
424+ properties : {
425+ aProp : { type : 'string' } ,
426+ } ,
427+ allOf : [ { $ref : '#/components/schemas/CycleB' } ] ,
428+ } ,
429+ CycleB : {
430+ properties : {
431+ bProp : { type : 'string' } ,
432+ } ,
433+ allOf : [ { $ref : '#/components/schemas/CycleA' } ] ,
434+ } ,
435+ } ,
436+ } ,
437+ } ,
438+ } as unknown as ContextSpec ;
439+
440+ const schema : OpenApiSchemaObject = {
441+ type : 'object' ,
442+ required : [ 'bProp' ] ,
443+ allOf : [ { $ref : '#/components/schemas/CycleA' } ] ,
444+ } ;
445+
446+ const result = combineSchemas ( {
447+ schema,
448+ name : 'CycleItem' ,
449+ separator : 'allOf' ,
450+ context : contextWithCycle ,
451+ nullable : '' ,
452+ } ) ;
453+
454+ expect ( result . value ) . toContain ( "'bProp'>>" ) ;
455+ expect ( result . value ) . not . toContain ( 'Extract<' ) ;
136456 } ) ;
137457
138458 it ( 'uses Extract guard for required ghost keys missing from all subschema properties' , ( ) => {
0 commit comments