@x0k/json-schema-merge 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,922 @@
1
+ import type {
2
+ JSONSchema7,
3
+ JSONSchema7Definition,
4
+ JSONSchema7Type,
5
+ JSONSchema7TypeName,
6
+ } from "json-schema";
7
+
8
+ import {
9
+ intersection,
10
+ union,
11
+ type Deduplicator,
12
+ type Intersector,
13
+ } from "../../array.ts";
14
+ import { identity } from "../../function.ts";
15
+ import { lcm } from "../../math.ts";
16
+ import { isAllowAnySchema } from "../json-schema.ts";
17
+
18
+ import { simplePatternsMerger } from "./patterns.ts";
19
+
20
+ type SchemaKey = keyof JSONSchema7;
21
+
22
+ function createPairCombinations<T, R>(
23
+ l: T[],
24
+ r: T[],
25
+ action: (a: T, b: T) => R
26
+ ) {
27
+ const ll = l.length;
28
+ const rl = r.length;
29
+ if (ll > 0 && rl > 0) {
30
+ for (let i = 0; i < ll; i++) {
31
+ const lv = l[i]!;
32
+ for (let j = 0; j < rl; j++) {
33
+ action(lv, r[j]!);
34
+ }
35
+ }
36
+ }
37
+ }
38
+
39
+ function mergeBooleans(l: boolean, r: boolean) {
40
+ return l || r;
41
+ }
42
+
43
+ function createRecordsMerge<T>(merge: (l: T, r: T) => T) {
44
+ return (left: Record<string, T>, right: Record<string, T>) => {
45
+ const target = { ...left };
46
+ const keys = Object.keys(right);
47
+ const l = keys.length;
48
+ for (let i = 0; i < l; i++) {
49
+ const key = keys[i]!;
50
+ target[key] =
51
+ left[key] === undefined ? right[key]! : merge(left[key], right[key]!);
52
+ }
53
+ return target;
54
+ };
55
+ }
56
+
57
+ /**
58
+ * An assigner function operates at the schema-object level.
59
+ * It receives the partially merged `target` and the original
60
+ * `left` and `right` schemas.
61
+ *
62
+ * In most cases, it modifies and returns the `target` object,
63
+ * but it may also return a completely new schema object if needed.
64
+ *
65
+ * Assigners are used for keywords that cannot be merged by simple
66
+ * value-level functions, often because they interact with other
67
+ * keywords or require holistic decisions.
68
+ */
69
+ export type Assigner<R extends {}> = (target: R, l: R, r: R) => R;
70
+
71
+ function createMap<R>(items: Iterable<[SchemaKey[], R]>) {
72
+ const map = new Map<SchemaKey, R>();
73
+ for (const pair of items) {
74
+ for (const key of pair[0]) {
75
+ map.set(key, pair[1]);
76
+ }
77
+ }
78
+ return map;
79
+ }
80
+
81
+ function assignSchemaDefinitionOrRecordOfSchemaDefinitions<
82
+ K extends {
83
+ [T in SchemaKey]: JSONSchema7[T] extends
84
+ JSONSchema7Definition | Record<string, JSONSchema7Definition> | undefined
85
+ ? T
86
+ : never;
87
+ }[SchemaKey],
88
+ >(target: JSONSchema7, key: K, value: JSONSchema7[K]) {
89
+ if (value === undefined || isAllowAnySchema(value)) {
90
+ delete target[key];
91
+ } else {
92
+ target[key] = value;
93
+ }
94
+ }
95
+
96
+ const PROPERTIES_ASSIGNER_KEYS = [
97
+ "properties",
98
+ "patternProperties",
99
+ "additionalProperties",
100
+ ] as const satisfies SchemaKey[];
101
+
102
+ interface CompiledPattern {
103
+ regExp: RegExp;
104
+ schema: JSONSchema7Definition;
105
+ }
106
+
107
+ function compilePatterns(patterns: Record<string, JSONSchema7Definition>) {
108
+ const keys = Object.keys(patterns);
109
+ const l = keys.length;
110
+ const result: CompiledPattern[] = [];
111
+ for (let i = 0; i < l; i++) {
112
+ const source = keys[i]!;
113
+ result.push({
114
+ regExp: new RegExp(source),
115
+ schema: patterns[source]!,
116
+ });
117
+ }
118
+ return [result, keys] as const;
119
+ }
120
+
121
+ const EMPTY_PATTERNS_AND_KEYS: [CompiledPattern[], string[]] = [[], []];
122
+
123
+ /**
124
+ * @returns `true` when `false` schema occurred
125
+ */
126
+ function appendKeyConstraints(
127
+ target: (JSONSchema7 | true)[],
128
+ key: string,
129
+ patterns: CompiledPattern[]
130
+ ): boolean {
131
+ const l = patterns.length;
132
+ for (let i = 0; i < l; i++) {
133
+ const p = patterns[i]!;
134
+ if (!p.regExp.test(key)) {
135
+ continue;
136
+ }
137
+ const s = p.schema;
138
+ if (s === false) {
139
+ return true;
140
+ }
141
+ target.push(s);
142
+ }
143
+ return false;
144
+ }
145
+
146
+ const ITEMS_ASSIGNER_KEYS = [
147
+ "items",
148
+ "additionalItems",
149
+ ] as const satisfies SchemaKey[];
150
+
151
+ const CONDITION_ASSIGNER_KEYS = [
152
+ "if",
153
+ "then",
154
+ "else",
155
+ ] as const satisfies SchemaKey[];
156
+
157
+ const CONTAINS_ASSIGNER_KEYS = ["contains"] as const satisfies SchemaKey[];
158
+
159
+ function assignCondition(target: JSONSchema7, source: JSONSchema7) {
160
+ if (source.if !== undefined) {
161
+ target.if = source.if;
162
+ }
163
+ if (source.then !== undefined) {
164
+ target.then = source.then;
165
+ }
166
+ if (source.else !== undefined) {
167
+ target.else = source.else;
168
+ }
169
+ return target;
170
+ }
171
+
172
+ type AssignerKey =
173
+ | (typeof PROPERTIES_ASSIGNER_KEYS)[number]
174
+ | (typeof ITEMS_ASSIGNER_KEYS)[number]
175
+ | (typeof CONDITION_ASSIGNER_KEYS)[number]
176
+ | (typeof CONTAINS_ASSIGNER_KEYS)[number];
177
+
178
+ function intersectSchemaTypes(
179
+ a: JSONSchema7TypeName,
180
+ b: JSONSchema7TypeName
181
+ ): JSONSchema7TypeName | undefined {
182
+ if (a === b) {
183
+ return a;
184
+ }
185
+ switch (a) {
186
+ case "number": {
187
+ if (b === "integer") {
188
+ return "integer";
189
+ }
190
+ }
191
+ // eslint-disable-next-line no-fallthrough
192
+ case "integer": {
193
+ if (b === "number") {
194
+ return "integer";
195
+ }
196
+ }
197
+ // eslint-disable-next-line no-fallthrough
198
+ default:
199
+ return undefined;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * A merger function combines two values for a specific JSON Schema keyword.
205
+ */
206
+ export type Merger<T> = (a: T, b: T) => T;
207
+
208
+ /**
209
+ * A validation function that ensures consistency between two schema keywords.
210
+ */
211
+ export type Check<K extends SchemaKey> = (
212
+ target: Required<Pick<JSONSchema7, K>>
213
+ ) => boolean;
214
+
215
+ export type CheckEntry<A extends SchemaKey, B extends SchemaKey> = readonly [
216
+ A,
217
+ B,
218
+ Check<A | B>,
219
+ ];
220
+
221
+ export function check<A extends SchemaKey, B extends SchemaKey>(
222
+ a: A,
223
+ b: B,
224
+ check: Check<A | B>
225
+ ): CheckEntry<A, B> {
226
+ return [a, b, check];
227
+ }
228
+
229
+ function createChecksMap(checks: Iterable<CheckEntry<SchemaKey, SchemaKey>>) {
230
+ const map = new Map<
231
+ SchemaKey,
232
+ { oppositeKey: SchemaKey; check: (target: JSONSchema7) => void }[]
233
+ >();
234
+ for (const [a, b, check] of checks) {
235
+ const fn = (target: JSONSchema7) => {
236
+ if (!check(target as Required<JSONSchema7>)) {
237
+ throw new Error(
238
+ `Schema keys '${a}' and '${b}' are conflicting (${a}: ${JSON.stringify(target[a])}, ${b}: ${JSON.stringify(target[b])})`
239
+ );
240
+ }
241
+ };
242
+ for (const k of [
243
+ [a, b],
244
+ [b, a],
245
+ ]) {
246
+ let arr = map.get(k[0]);
247
+ if (arr === undefined) {
248
+ arr = [];
249
+ map.set(k[0], arr);
250
+ }
251
+ arr.push({ oppositeKey: k[1], check: fn });
252
+ }
253
+ }
254
+ return map;
255
+ }
256
+
257
+ export interface MergeOptions {
258
+ /**
259
+ * Custom function to test whether a regular expression `subExpr`
260
+ * is considered a subset of another `superExpr`.
261
+ * @default Object.is
262
+ */
263
+ isSubRegExp?: (subExpr: string, superExpr: string) => boolean;
264
+
265
+ /**
266
+ * Merger function for combining regular expression patterns
267
+ * @default simplePatternsMerger
268
+ */
269
+ mergePatterns?: Merger<string>;
270
+
271
+ /**
272
+ * Intersector function for merging JSON values (enum keyword)
273
+ * @default intersection
274
+ */
275
+ intersectJson?: Intersector<JSONSchema7Type>;
276
+
277
+ /**
278
+ * Deduplication strategy for JSON Schema definitions.
279
+ * @default identity
280
+ */
281
+ deduplicateJsonSchemaDef?: Deduplicator<JSONSchema7Definition>;
282
+
283
+ /**
284
+ * Fallback merger applied when no keyword-specific merger is defined.
285
+ * @default identity
286
+ */
287
+ defaultMerger?: Merger<any>;
288
+
289
+ /**
290
+ * A mapping of schema keywords to merger functions.
291
+ *
292
+ * - A merger operates on **values of a single keyword** (`a`, `b` → merged value).
293
+ * - When provided, a custom merger **overrides the default merger** for that keyword.
294
+ */
295
+ mergers?: Partial<{
296
+ [K in SchemaKey]: Merger<Exclude<JSONSchema7[K], undefined>>;
297
+ }>;
298
+
299
+ /**
300
+ * A collection of keyword groups with associated assigner functions.
301
+ *
302
+ * - An assigner operates at the **schema-object level** (`target`, `left`, `right`).
303
+ * - Custom assigners are **appended** to the default assigners,
304
+ * but can also **replace behavior** for specific keywords if they overlap.
305
+ */
306
+ assigners?: Iterable<[SchemaKey[], Assigner<JSONSchema7>]>;
307
+
308
+ /**
309
+ * Consistency checks to validate relationships between
310
+ * pairs of schema keywords (e.g. `minimum` ≤ `maximum`).
311
+ *
312
+ * - A check ensures that two related keywords do not conflict.
313
+ * - Providing this option **replaces the default checks** completely.
314
+ *
315
+ * @default DEFAULT_CHECKS
316
+ */
317
+ checks?: Iterable<CheckEntry<SchemaKey, SchemaKey>>;
318
+ }
319
+
320
+ export const DEFAULT_CHECKS = [
321
+ check("minimum", "maximum", (t) => t.maximum >= t.minimum),
322
+ check("exclusiveMinimum", "maximum", (t) => t.maximum > t.exclusiveMinimum),
323
+ check("minimum", "exclusiveMaximum", (t) => t.exclusiveMaximum > t.minimum),
324
+ check(
325
+ "exclusiveMinimum",
326
+ "exclusiveMaximum",
327
+ (t) => t.exclusiveMaximum > t.exclusiveMinimum
328
+ ),
329
+ check("minLength", "maxLength", (t) => t.maxLength >= t.minLength),
330
+ check("minItems", "maxItems", (t) => t.maxItems >= t.minItems),
331
+ check(
332
+ "minProperties",
333
+ "maxProperties",
334
+ (t) => t.maxProperties >= t.minProperties
335
+ ),
336
+ ];
337
+
338
+ export function createMerger({
339
+ mergePatterns = simplePatternsMerger,
340
+ isSubRegExp = Object.is,
341
+ intersectJson = intersection,
342
+ deduplicateJsonSchemaDef = identity,
343
+ defaultMerger = identity,
344
+ assigners = [],
345
+ checks = DEFAULT_CHECKS,
346
+ mergers,
347
+ }: MergeOptions = {}) {
348
+ function mergeArrayOfSchemaDefinitions(
349
+ schemas: JSONSchema7Definition[]
350
+ ): JSONSchema7Definition {
351
+ const l = schemas.length;
352
+ let result = schemas[0]!;
353
+ for (let i = 1; i < l; i++) {
354
+ const r = mergeSchemaDefinitions(result, schemas[i]!);
355
+ if (r === false) {
356
+ return false;
357
+ }
358
+ if (isAllowAnySchema(r)) {
359
+ continue;
360
+ }
361
+ result = r;
362
+ }
363
+ return result;
364
+ }
365
+
366
+ function createProperty(
367
+ constraints: (JSONSchema7 | true)[],
368
+ key: string,
369
+ value: JSONSchema7Definition,
370
+ patterns: CompiledPattern[],
371
+ oppositeValue: JSONSchema7Definition | undefined,
372
+ oppositePatterns: CompiledPattern[],
373
+ oppositeAdditional: JSONSchema7 | false | undefined
374
+ ): JSONSchema7Definition | undefined {
375
+ constraints.length = 0;
376
+ if (value === false) {
377
+ return false;
378
+ }
379
+ constraints.push(value);
380
+ const isOppositeValueDefined = oppositeValue !== undefined;
381
+ if (isOppositeValueDefined) {
382
+ if (oppositeValue === false) {
383
+ return false;
384
+ }
385
+ constraints.push(oppositeValue);
386
+ }
387
+ if (appendKeyConstraints(constraints, key, oppositePatterns)) {
388
+ return false;
389
+ }
390
+ const isNotYetAllowed = constraints.length < 2;
391
+ if (oppositeAdditional === false) {
392
+ // There are no allowing constraints from opposite side -> drop property
393
+ if (isNotYetAllowed) {
394
+ return undefined;
395
+ }
396
+ // Applying patterns of current schema cause they may disappear
397
+ if (appendKeyConstraints(constraints, key, patterns)) {
398
+ return false;
399
+ }
400
+ } else if (isNotYetAllowed && oppositeAdditional !== undefined) {
401
+ constraints.push(oppositeAdditional);
402
+ }
403
+ const l = constraints.length;
404
+ if (l === 1) {
405
+ return constraints[0];
406
+ }
407
+ return mergeArrayOfSchemaDefinitions(constraints);
408
+ }
409
+
410
+ function assignPatternPropertiesAndAdditionalPropertiesMerge(
411
+ target: Record<string, JSONSchema7Definition>,
412
+ patterns: Record<string, JSONSchema7Definition> | undefined,
413
+ patternKeys: string[],
414
+ matchedPatterns: Set<string>,
415
+ oppositeAdditional: JSONSchema7Definition,
416
+ isOppositeTruthy: boolean
417
+ ) {
418
+ const l = patternKeys.length;
419
+ if (l > 0 && oppositeAdditional !== false) {
420
+ if (isOppositeTruthy) {
421
+ // TODO: in some cases we can just assign new value instead of copying
422
+ Object.assign(target, patterns);
423
+ } else {
424
+ for (let i = 0; i < l; i++) {
425
+ const pattern = patternKeys[i]!;
426
+ if (matchedPatterns.has(pattern)) {
427
+ continue;
428
+ }
429
+ target[pattern] = mergeSchemaDefinitions(
430
+ patterns![pattern]!,
431
+ oppositeAdditional
432
+ );
433
+ }
434
+ }
435
+ }
436
+ return target;
437
+ }
438
+
439
+ const propertiesAssigner: Assigner<JSONSchema7> = (
440
+ target,
441
+ {
442
+ properties: lProps = {},
443
+ patternProperties: lPatterns,
444
+ additionalProperties: lAdditional = true,
445
+ },
446
+ {
447
+ properties: rProps = {},
448
+ patternProperties: rPatterns,
449
+ additionalProperties: rAdditional = true,
450
+ }
451
+ ) => {
452
+ // Special case
453
+ const isLAddTruthy = isAllowAnySchema(lAdditional);
454
+ const isRAddTruthy = isAllowAnySchema(rAdditional);
455
+ if (isLAddTruthy && isRAddTruthy) {
456
+ assignSchemaDefinitionOrRecordOfSchemaDefinitions(
457
+ target,
458
+ "properties",
459
+ mergeRecordsOfSchemaDefinitions(lProps, rProps)
460
+ );
461
+ assignSchemaDefinitionOrRecordOfSchemaDefinitions(
462
+ target,
463
+ "patternProperties",
464
+ lPatterns && rPatterns
465
+ ? mergeRecordsOfSchemaDefinitions(lPatterns, rPatterns)
466
+ : (lPatterns ?? rPatterns)
467
+ );
468
+ delete target.additionalProperties;
469
+ return target;
470
+ }
471
+ // Additional Properties
472
+ const additionalProperties = mergeSchemaDefinitions(
473
+ lAdditional,
474
+ rAdditional
475
+ );
476
+ assignSchemaDefinitionOrRecordOfSchemaDefinitions(
477
+ target,
478
+ "additionalProperties",
479
+ additionalProperties
480
+ );
481
+ // Properties
482
+ const properties: Record<string, JSONSchema7Definition> = {};
483
+ const lKeys = Object.keys(lProps);
484
+ const lKeysLen = lKeys.length;
485
+ const [lCompiledPatterns, lPatternKeys] = lPatterns
486
+ ? compilePatterns(lPatterns)
487
+ : EMPTY_PATTERNS_AND_KEYS;
488
+ const [rCompiledPatterns, rPatternKeys] = rPatterns
489
+ ? compilePatterns(rPatterns)
490
+ : EMPTY_PATTERNS_AND_KEYS;
491
+ const constraints: (JSONSchema7 | true)[] = [];
492
+ const lKeysSet = new Set<string>();
493
+ const mappedRAdditional = isRAddTruthy ? undefined : rAdditional;
494
+ for (let i = 0; i < lKeysLen; i++) {
495
+ const key = lKeys[i]!;
496
+ lKeysSet.add(key);
497
+ const prop = createProperty(
498
+ constraints,
499
+ key,
500
+ lProps[key]!,
501
+ lCompiledPatterns,
502
+ rProps[key],
503
+ rCompiledPatterns,
504
+ mappedRAdditional
505
+ );
506
+ if (prop !== undefined) {
507
+ properties[key] = prop;
508
+ }
509
+ }
510
+ const rKeys = Object.keys(rProps);
511
+ const rKeysLen = rKeys.length;
512
+ const mappedLAdditional = isLAddTruthy ? undefined : lAdditional;
513
+ for (let i = 0; i < rKeysLen; i++) {
514
+ const key = rKeys[i]!;
515
+ if (lKeysSet.has(key)) {
516
+ continue;
517
+ }
518
+ const prop = createProperty(
519
+ constraints,
520
+ key,
521
+ rProps[key]!,
522
+ rCompiledPatterns,
523
+ undefined,
524
+ lCompiledPatterns,
525
+ mappedLAdditional
526
+ );
527
+ if (prop !== undefined) {
528
+ properties[key] = prop;
529
+ }
530
+ }
531
+ assignSchemaDefinitionOrRecordOfSchemaDefinitions(
532
+ target,
533
+ "properties",
534
+ properties
535
+ );
536
+ // Pattern Properties
537
+ // (lPatterns and rPatterns) or (lPatterns and rAdditional) or (rPatterns and lAdditional)
538
+ let patterns: Record<string, JSONSchema7Definition> = {};
539
+ const matchedPatterns = new Set<string>();
540
+ if (lPatternKeys.length > 0 && rPatternKeys.length > 0) {
541
+ createPairCombinations(lPatternKeys, rPatternKeys, (lKey, rKey) => {
542
+ if (isSubRegExp(lKey, rKey)) {
543
+ matchedPatterns.add(lKey);
544
+ }
545
+ if (isSubRegExp(rKey, lKey)) {
546
+ matchedPatterns.add(rKey);
547
+ }
548
+ patterns[mergePatterns(lKey, rKey)] = mergeSchemaDefinitions(
549
+ lPatterns![lKey]!,
550
+ rPatterns![rKey]!
551
+ );
552
+ });
553
+ }
554
+ patterns = assignPatternPropertiesAndAdditionalPropertiesMerge(
555
+ patterns,
556
+ lPatterns,
557
+ lPatternKeys,
558
+ matchedPatterns,
559
+ rAdditional,
560
+ isRAddTruthy
561
+ );
562
+ patterns = assignPatternPropertiesAndAdditionalPropertiesMerge(
563
+ patterns,
564
+ rPatterns,
565
+ rPatternKeys,
566
+ matchedPatterns,
567
+ lAdditional,
568
+ isLAddTruthy
569
+ );
570
+ assignSchemaDefinitionOrRecordOfSchemaDefinitions(
571
+ target,
572
+ "patternProperties",
573
+ patterns
574
+ );
575
+ return target;
576
+ };
577
+
578
+ const itemsAssigner: Assigner<JSONSchema7> = (
579
+ target,
580
+ // NOTE: Schema that has `additionalItems` without an `items` keyword is invalid
581
+ // so the assigner should be triggered only be colliding `items` properties
582
+ // so default values are used only for type narrowing
583
+ { items: lItems = [], additionalItems: lAdditional },
584
+ { items: rItems = [], additionalItems: rAdditional }
585
+ ) => {
586
+ const isLArr = Array.isArray(lItems);
587
+ const isRArr = Array.isArray(rItems);
588
+ const itemsArray: JSONSchema7Definition[] = [];
589
+ target.items = itemsArray;
590
+ if (isLArr && isRArr) {
591
+ const [l, additional, tail] =
592
+ lItems.length < rItems.length
593
+ ? [lItems.length, lAdditional, rItems]
594
+ : [rItems.length, rAdditional, lItems];
595
+ let i = 0;
596
+ for (; i < l; i++) {
597
+ itemsArray.push(mergeSchemaDefinitions(lItems[i]!, rItems[i]!));
598
+ }
599
+ if (additional === false) {
600
+ target.additionalItems = false;
601
+ } else {
602
+ const isAdditionalTruthy =
603
+ additional === undefined || isAllowAnySchema(additional);
604
+ for (; i < tail.length; i++) {
605
+ itemsArray.push(
606
+ isAdditionalTruthy
607
+ ? tail[i]!
608
+ : mergeSchemaDefinitions(tail[i]!, additional)
609
+ );
610
+ }
611
+ assignSchemaDefinitionOrRecordOfSchemaDefinitions(
612
+ target,
613
+ "additionalItems",
614
+ lAdditional !== undefined && rAdditional !== undefined
615
+ ? mergeSchemaDefinitions(lAdditional, rAdditional)
616
+ : (lAdditional ?? rAdditional)
617
+ );
618
+ }
619
+ } else if (isLArr || isRArr) {
620
+ const [arr, item, additional] = (
621
+ isLArr ? [lItems, rItems, lAdditional] : [rItems, lItems, rAdditional]
622
+ ) as [
623
+ JSONSchema7Definition[],
624
+ JSONSchema7Definition,
625
+ JSONSchema7Definition | undefined,
626
+ ];
627
+ assignSchemaDefinitionOrRecordOfSchemaDefinitions(
628
+ target,
629
+ "additionalItems",
630
+ additional && mergeSchemaDefinitions(additional, item)
631
+ );
632
+ for (let i = 0; i < arr.length; i++) {
633
+ itemsArray.push(mergeSchemaDefinitions(arr[i]!, item));
634
+ }
635
+ } else {
636
+ delete target.additionalItems;
637
+ target.items = mergeSchemaDefinitions(lItems, rItems);
638
+ }
639
+ return target;
640
+ };
641
+
642
+ const conditionAssigner: Assigner<JSONSchema7> = (target, l, r) => {
643
+ assignCondition(target, l);
644
+ const cond = assignCondition({}, r);
645
+ if (target.allOf === undefined) {
646
+ target.allOf = [cond];
647
+ } else {
648
+ target.allOf = target.allOf.concat(cond);
649
+ }
650
+ return target;
651
+ };
652
+
653
+ const containsAssigner: Assigner<JSONSchema7> = (target, l, r) => {
654
+ const lContains = l.contains!;
655
+ const rContains = r.contains!;
656
+ // Cheapest hot path: identical reference (target already holds it via spread).
657
+ if (lContains === rContains) return target;
658
+ // `contains: false` rejects every array, so it dominates the conjunction
659
+ // (non-array instances ignore `contains` on both sides).
660
+ if (lContains === false || rContains === false) {
661
+ target.contains = false;
662
+ return target;
663
+ }
664
+ // `contains: true` (or `{}`) only requires a non-empty array, which is
665
+ // already implied by any other `contains`, so the other side wins.
666
+ // (If both sides allow any, either one is equivalent.)
667
+ if (isAllowAnySchema(lContains)) {
668
+ target.contains = rContains;
669
+ return target;
670
+ }
671
+ if (isAllowAnySchema(rContains)) {
672
+ target.contains = lContains;
673
+ return target;
674
+ }
675
+ // Idempotence: `contains: C` ∧ `contains: C` ≡ `contains: C`
676
+ // (target already holds `lContains` via spread).
677
+ if (deduplicateJsonSchemaDef([lContains, rContains]).length === 1) {
678
+ return target;
679
+ }
680
+ // Existential conjunction cannot be expressed as a single `contains`
681
+ // (`∃i C1(i) ∧ ∃j C2(j)` allows `i ≠ j`, while `∃k C1(k) ∧ C2(k)`
682
+ // requires a single witness), so preserve both branches exactly.
683
+ // Like `conditionAssigner`, keep left at root and move only right to `allOf`.
684
+ const branch: JSONSchema7Definition = { contains: rContains };
685
+ target.allOf =
686
+ target.allOf === undefined
687
+ ? [branch]
688
+ : deduplicateJsonSchemaDef(target.allOf.concat(branch));
689
+ return target;
690
+ };
691
+
692
+ function mergeArraysOfSchemaDefinition(
693
+ l: JSONSchema7Definition[],
694
+ r: JSONSchema7Definition[]
695
+ ) {
696
+ const definitions: JSONSchema7Definition[] = [];
697
+ createPairCombinations(l, r, (a, b) => {
698
+ try {
699
+ definitions.push(mergeSchemaDefinitions(a, b));
700
+ } catch {}
701
+ });
702
+ if (definitions.length === 0) {
703
+ throw new Error(
704
+ `No valid schema combinations could be produced for "${JSON.stringify(l)}" and "${JSON.stringify(r)}"; the merged result is empty`
705
+ );
706
+ }
707
+ return deduplicateJsonSchemaDef(definitions);
708
+ }
709
+
710
+ const ASSIGNERS_MAP = createMap([
711
+ [PROPERTIES_ASSIGNER_KEYS, propertiesAssigner],
712
+ [ITEMS_ASSIGNER_KEYS, itemsAssigner],
713
+ [CONDITION_ASSIGNER_KEYS, conditionAssigner],
714
+ [CONTAINS_ASSIGNER_KEYS, containsAssigner],
715
+ ...assigners,
716
+ ]);
717
+
718
+ const CHECKS_MAP = createChecksMap(checks);
719
+
720
+ function mergeSchemaDefinitions(
721
+ left: JSONSchema7Definition,
722
+ right: JSONSchema7Definition
723
+ ) {
724
+ if (left === false || right === false) {
725
+ return false;
726
+ }
727
+ if (isAllowAnySchema(left)) {
728
+ if (isAllowAnySchema(right)) {
729
+ return true;
730
+ }
731
+ return right;
732
+ }
733
+ if (isAllowAnySchema(right)) {
734
+ return left;
735
+ }
736
+ let target = { ...left };
737
+ const assigners = new Set<Assigner<JSONSchema7>>();
738
+ const checks = new Set<(target: JSONSchema7) => void>();
739
+ const rKeys = Reflect.ownKeys(right) as SchemaKey[];
740
+ const l = rKeys.length;
741
+ for (let i = 0; i < l; i++) {
742
+ const rKey = rKeys[i]!;
743
+ const rv = right[rKey];
744
+ if (rv === undefined) {
745
+ continue;
746
+ }
747
+ const checkData = CHECKS_MAP.get(rKey);
748
+ if (checkData !== undefined) {
749
+ const l = checkData.length;
750
+ for (let j = 0; j < l; j++) {
751
+ const item = checkData[j];
752
+ if (left[item.oppositeKey] !== undefined) {
753
+ checks.add(item.check);
754
+ }
755
+ }
756
+ }
757
+ const lv = left[rKey];
758
+ if (lv === undefined) {
759
+ // @ts-expect-error too complex
760
+ target[rKey] = rv;
761
+ continue;
762
+ }
763
+ const assign = ASSIGNERS_MAP.get(rKey);
764
+ if (assign) {
765
+ assigners.add(assign);
766
+ continue;
767
+ }
768
+ const merge = MERGERS[rKey] ?? defaultMerger;
769
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
770
+ target[rKey] = merge(lv as never, rv as never);
771
+ }
772
+ for (const assign of assigners) {
773
+ target = assign(target, left, right);
774
+ }
775
+ for (const check of checks) {
776
+ check(target);
777
+ }
778
+ return target;
779
+ }
780
+
781
+ const mergeRecordsOfSchemaDefinitions = createRecordsMerge(
782
+ mergeSchemaDefinitions
783
+ );
784
+
785
+ const MERGERS: {
786
+ [K in SchemaKey]?: Merger<Exclude<JSONSchema7[K], undefined>>;
787
+ } = {
788
+ $id: defaultMerger,
789
+ $ref: defaultMerger,
790
+ $schema: defaultMerger,
791
+ $comment: defaultMerger,
792
+ $defs: mergeRecordsOfSchemaDefinitions,
793
+ definitions: mergeRecordsOfSchemaDefinitions,
794
+ type: (a, b) => {
795
+ if (a === b) {
796
+ return a;
797
+ }
798
+ const isAArr = Array.isArray(a);
799
+ const isBArr = Array.isArray(b);
800
+ if (!isAArr && !isBArr) {
801
+ const intersection = intersectSchemaTypes(a, b);
802
+ if (intersection !== undefined) {
803
+ return intersection;
804
+ }
805
+ } else if (isAArr || isBArr) {
806
+ const r = new Set<JSONSchema7TypeName>();
807
+ if (isAArr && isBArr) {
808
+ createPairCombinations(a, b, (x, y) => {
809
+ const type = intersectSchemaTypes(x, y);
810
+ if (type !== undefined) {
811
+ r.add(type);
812
+ }
813
+ });
814
+ } else {
815
+ const arr = (isAArr ? a : b) as JSONSchema7TypeName[];
816
+ const el = (isAArr ? b : a) as JSONSchema7TypeName;
817
+ const l = arr.length;
818
+ for (let i = 0; i < l; i++) {
819
+ const intersection = intersectSchemaTypes(el, arr[i]!);
820
+ if (intersection !== undefined) {
821
+ r.add(intersection);
822
+ }
823
+ }
824
+ }
825
+ const s = r.size;
826
+ if (s === 1) {
827
+ return r.values().next().value!;
828
+ }
829
+ if (s > 1) {
830
+ return Array.from(r);
831
+ }
832
+ }
833
+ throw new Error(
834
+ `It is not possible to create an intersection of the following incompatible types: ${a.toString()}, ${b.toString()}`
835
+ );
836
+ },
837
+ default: defaultMerger,
838
+ description: defaultMerger,
839
+ title: defaultMerger,
840
+ const: defaultMerger,
841
+ format: defaultMerger,
842
+ contentEncoding: defaultMerger,
843
+ contentMediaType: defaultMerger,
844
+ not: (a, b) => {
845
+ const items = deduplicateJsonSchemaDef([a, b]);
846
+ return items.length === 1 ? items[0]! : { anyOf: items };
847
+ },
848
+ pattern: mergePatterns,
849
+ readOnly: mergeBooleans,
850
+ writeOnly: mergeBooleans,
851
+ enum: (a, b) => {
852
+ const data = intersectJson(a, b);
853
+ if (data.length === 0) {
854
+ throw new Error(
855
+ `Intersection of the following enums is empty: "${JSON.stringify(
856
+ a
857
+ )}", "${JSON.stringify(b)}"`
858
+ );
859
+ }
860
+ return data;
861
+ },
862
+ anyOf: mergeArraysOfSchemaDefinition,
863
+ oneOf: mergeArraysOfSchemaDefinition,
864
+ allOf: (l, r) => deduplicateJsonSchemaDef(l.concat(r)),
865
+ propertyNames: mergeSchemaDefinitions,
866
+ dependencies: createRecordsMerge((a, b) => {
867
+ if (Array.isArray(a)) {
868
+ if (Array.isArray(b)) {
869
+ return union(a, b);
870
+ }
871
+ return mergeSchemaDefinitions(b, { required: a });
872
+ }
873
+ if (Array.isArray(b)) {
874
+ return mergeSchemaDefinitions(a, { required: b });
875
+ }
876
+ return mergeSchemaDefinitions(a, b);
877
+ }),
878
+ examples: (l, r) => {
879
+ // https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-validation-01#section-10.4
880
+ if (!Array.isArray(l) || !Array.isArray(r)) {
881
+ throw new Error(
882
+ `Value of the 'examples' field should be an array, but got "${JSON.stringify(
883
+ l
884
+ )}" and "${JSON.stringify(r)}"`
885
+ );
886
+ }
887
+ // TODO: Proper deduplication
888
+ return union(l, r);
889
+ },
890
+ multipleOf: (a, b) => {
891
+ let factor = 1;
892
+ while (!Number.isInteger(a) || !Number.isInteger(b)) {
893
+ factor *= 10;
894
+ a *= 10;
895
+ b *= 10;
896
+ }
897
+ return lcm(a, b) / factor;
898
+ },
899
+ exclusiveMaximum: Math.min,
900
+ maximum: Math.min,
901
+ maxItems: Math.min,
902
+ maxLength: Math.min,
903
+ maxProperties: Math.min,
904
+ exclusiveMinimum: Math.max,
905
+ minimum: Math.max,
906
+ minItems: Math.max,
907
+ minLength: Math.max,
908
+ minProperties: Math.max,
909
+ uniqueItems: mergeBooleans,
910
+ required: union,
911
+ ...mergers,
912
+ } satisfies {
913
+ [K in Exclude<SchemaKey, AssignerKey>]-?: Merger<
914
+ Exclude<JSONSchema7[K], undefined>
915
+ >;
916
+ };
917
+
918
+ return {
919
+ mergeSchemaDefinitions,
920
+ mergeArrayOfSchemaDefinitions,
921
+ };
922
+ }