@@ -157,24 +157,56 @@ function foldRootAnyOf(root: Record<string, unknown>): void {
157157 delete root [ 'anyOf' ] ;
158158
159159 const rootProperties = root [ 'properties' ] ;
160- const merged : Record < string , unknown > = isRecord ( rootProperties )
161- ? rootProperties
162- : { } ;
160+ const alternativesByName = new Map < string , unknown [ ] > ( ) ;
161+ if ( isRecord ( rootProperties ) ) {
162+ for ( const [ name , property ] of Object . entries ( rootProperties ) ) {
163+ alternativesByName . set ( name , [ cloneJsonValue ( property ) ] ) ;
164+ }
165+ }
163166 for ( const branch of branches ) {
164167 const branchProperties = branch [ 'properties' ] ;
165168 if ( ! isRecord ( branchProperties ) ) continue ;
166169 for ( const [ name , property ] of Object . entries ( branchProperties ) ) {
167- if ( ! hasOwn ( merged , name ) ) {
168- merged [ name ] = cloneJsonValue ( property ) ;
169- }
170+ addRootPropertyAlternative ( alternativesByName , name , property ) ;
170171 }
171172 }
172- if ( Object . keys ( merged ) . length > 0 ) {
173+
174+ if ( alternativesByName . size > 0 ) {
175+ const merged : Record < string , unknown > = { } ;
176+ for ( const [ name , alternatives ] of alternativesByName ) {
177+ merged [ name ] = alternatives . length === 1 ? alternatives [ 0 ] : { anyOf : alternatives } ;
178+ }
173179 root [ 'properties' ] = merged ;
174180 }
175181 root [ 'type' ] = 'object' ;
176182}
177183
184+ /**
185+ * Record one branch's schema for a merged root property.
186+ *
187+ * Root `anyOf` branches are alternatives, so two branches declaring the same
188+ * property with different schemas (e.g. `value` as a string in one branch, an
189+ * integer in another) must both stay representable — keeping only the first
190+ * one seen would silently narrow what the tool actually accepts. Identical
191+ * schemas collapse to one; differing schemas fold into an `anyOf` on the
192+ * merged property.
193+ */
194+ function addRootPropertyAlternative (
195+ alternativesByName : Map < string , unknown [ ] > ,
196+ name : string ,
197+ property : unknown ,
198+ ) : void {
199+ const cloned = cloneJsonValue ( property ) ;
200+ const alternatives = alternativesByName . get ( name ) ;
201+ if ( ! alternatives ) {
202+ alternativesByName . set ( name , [ cloned ] ) ;
203+ return ;
204+ }
205+ if ( ! alternatives . some ( ( existing ) => deepEqualJson ( existing , cloned ) ) ) {
206+ alternatives . push ( cloned ) ;
207+ }
208+ }
209+
178210function hasUnresolvedDefinitionRef ( node : unknown , bucketKey : string ) : boolean {
179211 if ( Array . isArray ( node ) ) {
180212 return node . some ( ( child ) => hasUnresolvedDefinitionRef ( child , bucketKey ) ) ;
@@ -337,8 +369,13 @@ const ANYOF_PARENT_KEEP_KEYS = new Set([
337369 * so schemas that are perfectly valid elsewhere are rejected on this wire.
338370 *
339371 * Distributing is lossless: `P ∧ (B₁ ∨ B₂)` and `(P ∧ B₁) ∨ (P ∧ B₂)` accept
340- * exactly the same instances. A branch that already declares a keyword keeps
341- * its own, which is the narrower of the two.
372+ * exactly the same instances — *if* a branch that already declares the same
373+ * keyword is merged conjunctively with the parent's value rather than simply
374+ * overriding it. `required` is the one keyword this function merges that way
375+ * (parent and branch field lists are unioned, since both are actually
376+ * required). Every other overlapping keyword still keeps the branch's own
377+ * value: a full conjunctive merge for arbitrary keywords (`properties`,
378+ * `items`, …) is out of scope for this compatibility normalizer.
342379 */
343380function distributeAnyOfParentKeywords ( node : Record < string , unknown > ) : void {
344381 const branches = node [ 'anyOf' ] ;
@@ -355,6 +392,8 @@ function distributeAnyOfParentKeywords(node: Record<string, unknown>): void {
355392 for ( const key of inherited ) {
356393 if ( ! hasOwn ( branch , key ) ) {
357394 branch [ key ] = cloneJsonValue ( node [ key ] ) ;
395+ } else if ( key === 'required' ) {
396+ branch [ key ] = mergeRequired ( node [ key ] , branch [ key ] ) ;
358397 }
359398 }
360399 }
@@ -363,6 +402,26 @@ function distributeAnyOfParentKeywords(node: Record<string, unknown>): void {
363402 }
364403}
365404
405+ /**
406+ * Union two `required` field lists.
407+ *
408+ * A parent's `required` and a branch's own `required` are both mandatory —
409+ * dropping the parent's list when the branch already has one would silently
410+ * accept objects missing a field the parent demanded.
411+ */
412+ function mergeRequired ( parentValue : unknown , branchValue : unknown ) : unknown {
413+ if ( ! Array . isArray ( parentValue ) || ! Array . isArray ( branchValue ) ) {
414+ return branchValue ;
415+ }
416+ const merged = [ ...branchValue ] ;
417+ for ( const name of parentValue ) {
418+ if ( ! merged . includes ( name ) ) {
419+ merged . push ( name ) ;
420+ }
421+ }
422+ return merged ;
423+ }
424+
366425function visitChildSchemas ( node : Record < string , unknown > , visit : ( schema : unknown ) => void ) : void {
367426 for ( const { key, kind } of CHILD_SCHEMA_SLOTS ) {
368427 const value = node [ key ] ;
@@ -574,6 +633,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
574633 return typeof value === 'object' && value !== null && ! Array . isArray ( value ) ;
575634}
576635
636+ function deepEqualJson ( a : unknown , b : unknown ) : boolean {
637+ if ( a === b ) {
638+ return true ;
639+ }
640+ if ( Array . isArray ( a ) && Array . isArray ( b ) ) {
641+ return a . length === b . length && a . every ( ( item , index ) => deepEqualJson ( item , b [ index ] ) ) ;
642+ }
643+ if ( isRecord ( a ) && isRecord ( b ) ) {
644+ const aKeys = Object . keys ( a ) ;
645+ const bKeys = Object . keys ( b ) ;
646+ return aKeys . length === bKeys . length && aKeys . every ( ( key ) => hasOwn ( b , key ) && deepEqualJson ( a [ key ] , b [ key ] ) ) ;
647+ }
648+ return false ;
649+ }
650+
577651function hasOwn ( obj : Record < string , unknown > , key : string ) : boolean {
578652 return Object . prototype . hasOwnProperty . call ( obj , key ) ;
579653}
0 commit comments