@contractual/differs.core 0.1.0-dev.1

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.
package/dist/walker.js ADDED
@@ -0,0 +1,1195 @@
1
+ /**
2
+ * JSON Schema structural walker
3
+ *
4
+ * Recursively walks two resolved JSON Schemas side-by-side (DFS)
5
+ * and emits RawChange for every structural difference.
6
+ */
7
+ import { ANNOTATION_KEYS, arraysEqual, CONSTRAINT_DIRECTION, CONSTRAINT_KEYS, deepEqual, DEFAULT_MAX_DEPTH, isSchemaObject, joinPath, METADATA_KEYS, normalizeType, } from './types.js';
8
+ /**
9
+ * Walk two resolved JSON Schemas and emit changes
10
+ *
11
+ * @param oldSchema - The original schema (resolved, no $refs)
12
+ * @param newSchema - The new schema (resolved, no $refs)
13
+ * @param basePath - JSON Pointer base path (default: '')
14
+ * @returns Array of raw changes detected
15
+ */
16
+ export function walk(oldSchema, newSchema, basePath = '') {
17
+ return walkInternal(oldSchema, newSchema, basePath, 0);
18
+ }
19
+ /**
20
+ * Internal walk function with depth tracking
21
+ */
22
+ function walkInternal(oldSchema, newSchema, path, depth) {
23
+ const changes = [];
24
+ // Prevent infinite recursion
25
+ if (depth > DEFAULT_MAX_DEPTH) {
26
+ return changes;
27
+ }
28
+ // Handle null/undefined schemas
29
+ if (oldSchema === undefined && newSchema === undefined) {
30
+ return changes;
31
+ }
32
+ // Normalize to objects for comparison
33
+ const oldObj = isSchemaObject(oldSchema) ? oldSchema : null;
34
+ const newObj = isSchemaObject(newSchema) ? newSchema : null;
35
+ // Schema added or removed entirely
36
+ if (oldObj === null && newObj !== null) {
37
+ changes.push({
38
+ path,
39
+ type: 'property-added',
40
+ oldValue: undefined,
41
+ newValue: newSchema,
42
+ });
43
+ return changes;
44
+ }
45
+ if (oldObj !== null && newObj === null) {
46
+ changes.push({
47
+ path,
48
+ type: 'property-removed',
49
+ oldValue: oldSchema,
50
+ newValue: undefined,
51
+ });
52
+ return changes;
53
+ }
54
+ // Both are non-object (boolean schemas in JSON Schema draft-06+)
55
+ if (oldObj === null && newObj === null) {
56
+ if (oldSchema !== newSchema) {
57
+ changes.push({
58
+ path,
59
+ type: 'unknown-change',
60
+ oldValue: oldSchema,
61
+ newValue: newSchema,
62
+ });
63
+ }
64
+ return changes;
65
+ }
66
+ // Both are schema objects - compare all aspects
67
+ const old = oldObj;
68
+ const newS = newObj;
69
+ // 1. Metadata changes (title, description, default, examples)
70
+ changes.push(...compareMetadata(old, newS, path));
71
+ // 2. Annotation changes (deprecated, readOnly, writeOnly)
72
+ changes.push(...compareAnnotations(old, newS, path));
73
+ // 3. Content keyword changes (contentEncoding, contentMediaType, contentSchema)
74
+ changes.push(...compareContentKeywords(old, newS, path, depth));
75
+ // 4. Type changes
76
+ changes.push(...compareType(old, newS, path));
77
+ // 5. Enum changes
78
+ changes.push(...compareEnum(old, newS, path));
79
+ // 6. Format changes
80
+ changes.push(...compareFormat(old, newS, path));
81
+ // 7. Constraint changes
82
+ changes.push(...compareConstraints(old, newS, path));
83
+ // 8. Properties changes (recurse)
84
+ changes.push(...compareProperties(old, newS, path, depth));
85
+ // 9. Required changes
86
+ changes.push(...compareRequired(old, newS, path));
87
+ // 10. additionalProperties changes
88
+ changes.push(...compareAdditionalProperties(old, newS, path, depth));
89
+ // 11. propertyNames changes
90
+ changes.push(...comparePropertyNames(old, newS, path, depth));
91
+ // 12. dependentRequired changes
92
+ changes.push(...compareDependentRequired(old, newS, path));
93
+ // 13. dependentSchemas changes
94
+ changes.push(...compareDependentSchemas(old, newS, path, depth));
95
+ // 14. unevaluatedProperties changes
96
+ changes.push(...compareUnevaluatedProperties(old, newS, path, depth));
97
+ // 15. Array items changes (recurse)
98
+ changes.push(...compareArrayItems(old, newS, path, depth));
99
+ // 16. unevaluatedItems changes
100
+ changes.push(...compareUnevaluatedItems(old, newS, path, depth));
101
+ // 17. minContains/maxContains changes
102
+ changes.push(...compareMinMaxContains(old, newS, path));
103
+ // 18. Composition changes (anyOf, oneOf, allOf, if/then/else, not)
104
+ changes.push(...compareComposition(old, newS, path, depth));
105
+ return changes;
106
+ }
107
+ /**
108
+ * Mapping of metadata keys to their change types
109
+ */
110
+ const METADATA_CHANGE_TYPES = {
111
+ description: 'description-changed',
112
+ title: 'title-changed',
113
+ default: 'default-changed',
114
+ examples: 'examples-changed',
115
+ };
116
+ /**
117
+ * Compare metadata fields (description, title, default, examples)
118
+ */
119
+ function compareMetadata(oldSchema, newSchema, path) {
120
+ const changes = [];
121
+ for (const key of METADATA_KEYS) {
122
+ const oldValue = oldSchema[key];
123
+ const newValue = newSchema[key];
124
+ if (!deepEqual(oldValue, newValue)) {
125
+ changes.push({
126
+ path: joinPath(path, key),
127
+ type: METADATA_CHANGE_TYPES[key],
128
+ oldValue,
129
+ newValue,
130
+ });
131
+ }
132
+ }
133
+ return changes;
134
+ }
135
+ /**
136
+ * Mapping of annotation keys to their change types
137
+ */
138
+ const ANNOTATION_CHANGE_TYPES = {
139
+ deprecated: 'deprecated-changed',
140
+ readOnly: 'read-only-changed',
141
+ writeOnly: 'write-only-changed',
142
+ };
143
+ /**
144
+ * Compare annotation fields (deprecated, readOnly, writeOnly)
145
+ * These are patch-level changes per Strands API
146
+ */
147
+ function compareAnnotations(oldSchema, newSchema, path) {
148
+ const changes = [];
149
+ for (const key of ANNOTATION_KEYS) {
150
+ const oldValue = oldSchema[key];
151
+ const newValue = newSchema[key];
152
+ if (oldValue !== newValue) {
153
+ changes.push({
154
+ path: joinPath(path, key),
155
+ type: ANNOTATION_CHANGE_TYPES[key],
156
+ oldValue,
157
+ newValue,
158
+ });
159
+ }
160
+ }
161
+ return changes;
162
+ }
163
+ /**
164
+ * Mapping of content keys to their change types
165
+ */
166
+ const CONTENT_CHANGE_TYPES = {
167
+ contentEncoding: 'content-encoding-changed',
168
+ contentMediaType: 'content-media-type-changed',
169
+ contentSchema: 'content-schema-changed',
170
+ };
171
+ /**
172
+ * Compare content keywords (contentEncoding, contentMediaType, contentSchema)
173
+ * These are patch-level changes per Strands API
174
+ */
175
+ function compareContentKeywords(oldSchema, newSchema, path, depth) {
176
+ const changes = [];
177
+ // Compare contentEncoding and contentMediaType (simple string values)
178
+ for (const key of ['contentEncoding', 'contentMediaType']) {
179
+ const oldValue = oldSchema[key];
180
+ const newValue = newSchema[key];
181
+ if (oldValue !== newValue) {
182
+ changes.push({
183
+ path: joinPath(path, key),
184
+ type: CONTENT_CHANGE_TYPES[key],
185
+ oldValue,
186
+ newValue,
187
+ });
188
+ }
189
+ }
190
+ // Compare contentSchema (recurse into schema)
191
+ const oldContentSchema = oldSchema.contentSchema;
192
+ const newContentSchema = newSchema.contentSchema;
193
+ if (!deepEqual(oldContentSchema, newContentSchema)) {
194
+ if (isSchemaObject(oldContentSchema) && isSchemaObject(newContentSchema)) {
195
+ // Both are schemas - recurse but wrap all changes as content-schema-changed
196
+ const nestedChanges = walkInternal(oldContentSchema, newContentSchema, joinPath(path, 'contentSchema'), depth + 1);
197
+ // If there are nested changes, report as content-schema-changed
198
+ if (nestedChanges.length > 0) {
199
+ changes.push({
200
+ path: joinPath(path, 'contentSchema'),
201
+ type: 'content-schema-changed',
202
+ oldValue: oldContentSchema,
203
+ newValue: newContentSchema,
204
+ });
205
+ }
206
+ }
207
+ else {
208
+ // Schema added, removed, or type changed
209
+ changes.push({
210
+ path: joinPath(path, 'contentSchema'),
211
+ type: 'content-schema-changed',
212
+ oldValue: oldContentSchema,
213
+ newValue: newContentSchema,
214
+ });
215
+ }
216
+ }
217
+ return changes;
218
+ }
219
+ /**
220
+ * Compare type field, detecting narrowed/widened/changed
221
+ */
222
+ function compareType(oldSchema, newSchema, path) {
223
+ const changes = [];
224
+ const oldType = normalizeType(oldSchema.type);
225
+ const newType = normalizeType(newSchema.type);
226
+ // No type defined in either
227
+ if (oldType.length === 0 && newType.length === 0) {
228
+ return changes;
229
+ }
230
+ // Types are identical
231
+ if (arraysEqual(oldType, newType)) {
232
+ return changes;
233
+ }
234
+ // Determine change type
235
+ const changeType = determineTypeChange(oldType, newType);
236
+ changes.push({
237
+ path: joinPath(path, 'type'),
238
+ type: changeType,
239
+ oldValue: oldSchema.type,
240
+ newValue: newSchema.type,
241
+ });
242
+ return changes;
243
+ }
244
+ /**
245
+ * Determine if type change is narrowed, widened, or changed
246
+ */
247
+ function determineTypeChange(oldType, newType) {
248
+ // Type added where none existed
249
+ if (oldType.length === 0 && newType.length > 0) {
250
+ return 'type-narrowed'; // Adding type constraint narrows
251
+ }
252
+ // Type removed where one existed
253
+ if (oldType.length > 0 && newType.length === 0) {
254
+ return 'type-widened'; // Removing type constraint widens
255
+ }
256
+ // Check if new is subset of old (narrowed)
257
+ const oldSet = new Set(oldType);
258
+ const newSet = new Set(newType);
259
+ const isSubset = newType.every((t) => oldSet.has(t));
260
+ const isSuperset = oldType.every((t) => newSet.has(t));
261
+ if (isSubset && !isSuperset) {
262
+ return 'type-narrowed'; // New allows fewer types
263
+ }
264
+ if (isSuperset && !isSubset) {
265
+ return 'type-widened'; // New allows more types
266
+ }
267
+ // Types changed incompatibly (neither subset nor superset)
268
+ return 'type-changed';
269
+ }
270
+ /**
271
+ * Compare enum values
272
+ */
273
+ function compareEnum(oldSchema, newSchema, path) {
274
+ const changes = [];
275
+ const oldEnum = oldSchema.enum;
276
+ const newEnum = newSchema.enum;
277
+ // Enum added
278
+ if (oldEnum === undefined && newEnum !== undefined) {
279
+ changes.push({
280
+ path: joinPath(path, 'enum'),
281
+ type: 'enum-added',
282
+ oldValue: undefined,
283
+ newValue: newEnum,
284
+ });
285
+ return changes;
286
+ }
287
+ // Enum removed
288
+ if (oldEnum !== undefined && newEnum === undefined) {
289
+ changes.push({
290
+ path: joinPath(path, 'enum'),
291
+ type: 'enum-removed',
292
+ oldValue: oldEnum,
293
+ newValue: undefined,
294
+ });
295
+ return changes;
296
+ }
297
+ // Both have enums - compare values
298
+ if (oldEnum !== undefined && newEnum !== undefined) {
299
+ // Find removed values
300
+ for (const oldValue of oldEnum) {
301
+ const exists = newEnum.some((v) => deepEqual(v, oldValue));
302
+ if (!exists) {
303
+ changes.push({
304
+ path: joinPath(path, 'enum'),
305
+ type: 'enum-value-removed',
306
+ oldValue,
307
+ newValue: undefined,
308
+ });
309
+ }
310
+ }
311
+ // Find added values
312
+ for (const newValue of newEnum) {
313
+ const exists = oldEnum.some((v) => deepEqual(v, newValue));
314
+ if (!exists) {
315
+ changes.push({
316
+ path: joinPath(path, 'enum'),
317
+ type: 'enum-value-added',
318
+ oldValue: undefined,
319
+ newValue,
320
+ });
321
+ }
322
+ }
323
+ }
324
+ // Also check const (single-value enum)
325
+ if (!deepEqual(oldSchema.const, newSchema.const)) {
326
+ if (oldSchema.const === undefined && newSchema.const !== undefined) {
327
+ changes.push({
328
+ path: joinPath(path, 'const'),
329
+ type: 'enum-added',
330
+ oldValue: undefined,
331
+ newValue: newSchema.const,
332
+ });
333
+ }
334
+ else if (oldSchema.const !== undefined && newSchema.const === undefined) {
335
+ changes.push({
336
+ path: joinPath(path, 'const'),
337
+ type: 'enum-removed',
338
+ oldValue: oldSchema.const,
339
+ newValue: undefined,
340
+ });
341
+ }
342
+ else {
343
+ changes.push({
344
+ path: joinPath(path, 'const'),
345
+ type: 'enum-value-removed',
346
+ oldValue: oldSchema.const,
347
+ newValue: newSchema.const,
348
+ });
349
+ }
350
+ }
351
+ return changes;
352
+ }
353
+ /**
354
+ * Compare format field
355
+ */
356
+ function compareFormat(oldSchema, newSchema, path) {
357
+ const changes = [];
358
+ const oldFormat = oldSchema.format;
359
+ const newFormat = newSchema.format;
360
+ if (oldFormat === newFormat) {
361
+ return changes;
362
+ }
363
+ if (oldFormat === undefined && newFormat !== undefined) {
364
+ changes.push({
365
+ path: joinPath(path, 'format'),
366
+ type: 'format-added',
367
+ oldValue: undefined,
368
+ newValue: newFormat,
369
+ });
370
+ }
371
+ else if (oldFormat !== undefined && newFormat === undefined) {
372
+ changes.push({
373
+ path: joinPath(path, 'format'),
374
+ type: 'format-removed',
375
+ oldValue: oldFormat,
376
+ newValue: undefined,
377
+ });
378
+ }
379
+ else {
380
+ changes.push({
381
+ path: joinPath(path, 'format'),
382
+ type: 'format-changed',
383
+ oldValue: oldFormat,
384
+ newValue: newFormat,
385
+ });
386
+ }
387
+ return changes;
388
+ }
389
+ /**
390
+ * Compare numeric and string constraints
391
+ */
392
+ function compareConstraints(oldSchema, newSchema, path) {
393
+ const changes = [];
394
+ for (const key of CONSTRAINT_KEYS) {
395
+ const oldValue = oldSchema[key];
396
+ const newValue = newSchema[key];
397
+ if (deepEqual(oldValue, newValue)) {
398
+ continue;
399
+ }
400
+ const direction = CONSTRAINT_DIRECTION[key];
401
+ const changeType = determineConstraintChange(key, direction, oldValue, newValue);
402
+ changes.push({
403
+ path: joinPath(path, key),
404
+ type: changeType,
405
+ oldValue,
406
+ newValue,
407
+ });
408
+ }
409
+ return changes;
410
+ }
411
+ /**
412
+ * Determine if constraint change is tightened or loosened
413
+ */
414
+ function determineConstraintChange(key, direction, oldValue, newValue) {
415
+ // Handle special cases for minItems/maxItems
416
+ if (key === 'minItems') {
417
+ if (oldValue === undefined && typeof newValue === 'number') {
418
+ return 'min-items-increased';
419
+ }
420
+ if (typeof oldValue === 'number' && newValue === undefined) {
421
+ return 'constraint-loosened';
422
+ }
423
+ if (typeof oldValue === 'number' && typeof newValue === 'number') {
424
+ return newValue > oldValue ? 'min-items-increased' : 'constraint-loosened';
425
+ }
426
+ }
427
+ if (key === 'maxItems') {
428
+ if (oldValue === undefined && typeof newValue === 'number') {
429
+ return 'max-items-decreased';
430
+ }
431
+ if (typeof oldValue === 'number' && newValue === undefined) {
432
+ return 'constraint-loosened';
433
+ }
434
+ if (typeof oldValue === 'number' && typeof newValue === 'number') {
435
+ return newValue < oldValue ? 'max-items-decreased' : 'constraint-loosened';
436
+ }
437
+ }
438
+ // Pattern and multipleOf are exact - any change is significant
439
+ if (direction === 'exact') {
440
+ return 'constraint-tightened'; // Conservative: treat as tightening
441
+ }
442
+ // For min constraints: increasing tightens, decreasing loosens
443
+ if (direction === 'min') {
444
+ if (oldValue === undefined && newValue !== undefined) {
445
+ return 'constraint-tightened'; // Adding min constraint tightens
446
+ }
447
+ if (oldValue !== undefined && newValue === undefined) {
448
+ return 'constraint-loosened'; // Removing min constraint loosens
449
+ }
450
+ if (typeof oldValue === 'number' && typeof newValue === 'number') {
451
+ return newValue > oldValue ? 'constraint-tightened' : 'constraint-loosened';
452
+ }
453
+ }
454
+ // For max constraints: decreasing tightens, increasing loosens
455
+ if (direction === 'max') {
456
+ if (oldValue === undefined && newValue !== undefined) {
457
+ return 'constraint-tightened'; // Adding max constraint tightens
458
+ }
459
+ if (oldValue !== undefined && newValue === undefined) {
460
+ return 'constraint-loosened'; // Removing max constraint loosens
461
+ }
462
+ if (typeof oldValue === 'number' && typeof newValue === 'number') {
463
+ return newValue < oldValue ? 'constraint-tightened' : 'constraint-loosened';
464
+ }
465
+ }
466
+ // Handle uniqueItems specially
467
+ if (key === 'uniqueItems') {
468
+ if (newValue === true && oldValue !== true) {
469
+ return 'constraint-tightened';
470
+ }
471
+ if (newValue !== true && oldValue === true) {
472
+ return 'constraint-loosened';
473
+ }
474
+ }
475
+ return 'constraint-tightened'; // Conservative default
476
+ }
477
+ /**
478
+ * Compare object properties (recursive)
479
+ */
480
+ function compareProperties(oldSchema, newSchema, path, depth) {
481
+ const changes = [];
482
+ const oldProps = oldSchema.properties ?? {};
483
+ const newProps = newSchema.properties ?? {};
484
+ const oldKeys = new Set(Object.keys(oldProps));
485
+ const newKeys = new Set(Object.keys(newProps));
486
+ // Find removed properties
487
+ for (const key of oldKeys) {
488
+ if (!newKeys.has(key)) {
489
+ changes.push({
490
+ path: joinPath(path, 'properties', key),
491
+ type: 'property-removed',
492
+ oldValue: oldProps[key],
493
+ newValue: undefined,
494
+ });
495
+ }
496
+ }
497
+ // Find added properties
498
+ for (const key of newKeys) {
499
+ if (!oldKeys.has(key)) {
500
+ changes.push({
501
+ path: joinPath(path, 'properties', key),
502
+ type: 'property-added',
503
+ oldValue: undefined,
504
+ newValue: newProps[key],
505
+ });
506
+ }
507
+ }
508
+ // Recurse into common properties
509
+ for (const key of oldKeys) {
510
+ if (newKeys.has(key)) {
511
+ const nestedChanges = walkInternal(oldProps[key], newProps[key], joinPath(path, 'properties', key), depth + 1);
512
+ changes.push(...nestedChanges);
513
+ }
514
+ }
515
+ // Also compare patternProperties if present
516
+ const oldPatternProps = oldSchema.patternProperties ?? {};
517
+ const newPatternProps = newSchema.patternProperties ?? {};
518
+ const oldPatternKeys = new Set(Object.keys(oldPatternProps));
519
+ const newPatternKeys = new Set(Object.keys(newPatternProps));
520
+ for (const pattern of oldPatternKeys) {
521
+ if (!newPatternKeys.has(pattern)) {
522
+ changes.push({
523
+ path: joinPath(path, 'patternProperties', pattern),
524
+ type: 'property-removed',
525
+ oldValue: oldPatternProps[pattern],
526
+ newValue: undefined,
527
+ });
528
+ }
529
+ }
530
+ for (const pattern of newPatternKeys) {
531
+ if (!oldPatternKeys.has(pattern)) {
532
+ changes.push({
533
+ path: joinPath(path, 'patternProperties', pattern),
534
+ type: 'property-added',
535
+ oldValue: undefined,
536
+ newValue: newPatternProps[pattern],
537
+ });
538
+ }
539
+ }
540
+ for (const pattern of oldPatternKeys) {
541
+ if (newPatternKeys.has(pattern)) {
542
+ const nestedChanges = walkInternal(oldPatternProps[pattern], newPatternProps[pattern], joinPath(path, 'patternProperties', pattern), depth + 1);
543
+ changes.push(...nestedChanges);
544
+ }
545
+ }
546
+ return changes;
547
+ }
548
+ /**
549
+ * Compare required array
550
+ */
551
+ function compareRequired(oldSchema, newSchema, path) {
552
+ const changes = [];
553
+ const oldRequired = new Set(oldSchema.required ?? []);
554
+ const newRequired = new Set(newSchema.required ?? []);
555
+ // Find removed required fields
556
+ for (const field of oldRequired) {
557
+ if (!newRequired.has(field)) {
558
+ changes.push({
559
+ path: joinPath(path, 'required'),
560
+ type: 'required-removed',
561
+ oldValue: field,
562
+ newValue: undefined,
563
+ });
564
+ }
565
+ }
566
+ // Find added required fields
567
+ for (const field of newRequired) {
568
+ if (!oldRequired.has(field)) {
569
+ changes.push({
570
+ path: joinPath(path, 'required'),
571
+ type: 'required-added',
572
+ oldValue: undefined,
573
+ newValue: field,
574
+ });
575
+ }
576
+ }
577
+ return changes;
578
+ }
579
+ /**
580
+ * Compare additionalProperties
581
+ */
582
+ function compareAdditionalProperties(oldSchema, newSchema, path, depth) {
583
+ const changes = [];
584
+ const oldAP = oldSchema.additionalProperties;
585
+ const newAP = newSchema.additionalProperties;
586
+ // No change
587
+ if (deepEqual(oldAP, newAP)) {
588
+ return changes;
589
+ }
590
+ // Normalize: undefined means allowed (true)
591
+ const oldAllows = oldAP !== false;
592
+ const newAllows = newAP !== false;
593
+ // Check for denied/allowed transitions
594
+ if (oldAllows && !newAllows) {
595
+ changes.push({
596
+ path: joinPath(path, 'additionalProperties'),
597
+ type: 'additional-properties-denied',
598
+ oldValue: oldAP,
599
+ newValue: newAP,
600
+ });
601
+ return changes;
602
+ }
603
+ if (!oldAllows && newAllows) {
604
+ changes.push({
605
+ path: joinPath(path, 'additionalProperties'),
606
+ type: 'additional-properties-allowed',
607
+ oldValue: oldAP,
608
+ newValue: newAP,
609
+ });
610
+ return changes;
611
+ }
612
+ // Both allow but with different schemas
613
+ if (isSchemaObject(oldAP) && isSchemaObject(newAP)) {
614
+ const nestedChanges = walkInternal(oldAP, newAP, joinPath(path, 'additionalProperties'), depth + 1);
615
+ if (nestedChanges.length > 0) {
616
+ changes.push(...nestedChanges);
617
+ }
618
+ return changes;
619
+ }
620
+ // One is boolean, one is schema, or other differences
621
+ if (oldAP !== newAP) {
622
+ changes.push({
623
+ path: joinPath(path, 'additionalProperties'),
624
+ type: 'additional-properties-changed',
625
+ oldValue: oldAP,
626
+ newValue: newAP,
627
+ });
628
+ }
629
+ return changes;
630
+ }
631
+ /**
632
+ * Compare propertyNames schema
633
+ */
634
+ function comparePropertyNames(oldSchema, newSchema, path, _depth) {
635
+ const changes = [];
636
+ const oldPN = oldSchema.propertyNames;
637
+ const newPN = newSchema.propertyNames;
638
+ if (deepEqual(oldPN, newPN)) {
639
+ return changes;
640
+ }
641
+ // Any change to propertyNames is complex and requires manual review
642
+ if (isSchemaObject(oldPN) && isSchemaObject(newPN)) {
643
+ // Could recurse, but propertyNames changes are fundamentally unknown
644
+ changes.push({
645
+ path: joinPath(path, 'propertyNames'),
646
+ type: 'property-names-changed',
647
+ oldValue: oldPN,
648
+ newValue: newPN,
649
+ });
650
+ }
651
+ else if (oldPN !== undefined || newPN !== undefined) {
652
+ changes.push({
653
+ path: joinPath(path, 'propertyNames'),
654
+ type: 'property-names-changed',
655
+ oldValue: oldPN,
656
+ newValue: newPN,
657
+ });
658
+ }
659
+ return changes;
660
+ }
661
+ /**
662
+ * Compare dependentRequired (Draft 2019-09+)
663
+ */
664
+ function compareDependentRequired(oldSchema, newSchema, path) {
665
+ const changes = [];
666
+ const oldDR = oldSchema.dependentRequired ?? {};
667
+ const newDR = newSchema.dependentRequired ?? {};
668
+ const oldKeys = new Set(Object.keys(oldDR));
669
+ const newKeys = new Set(Object.keys(newDR));
670
+ // Find removed dependencies (non-breaking)
671
+ for (const key of oldKeys) {
672
+ if (!newKeys.has(key)) {
673
+ changes.push({
674
+ path: joinPath(path, 'dependentRequired', key),
675
+ type: 'dependent-required-removed',
676
+ oldValue: oldDR[key],
677
+ newValue: undefined,
678
+ });
679
+ }
680
+ }
681
+ // Find added dependencies (breaking)
682
+ for (const key of newKeys) {
683
+ if (!oldKeys.has(key)) {
684
+ changes.push({
685
+ path: joinPath(path, 'dependentRequired', key),
686
+ type: 'dependent-required-added',
687
+ oldValue: undefined,
688
+ newValue: newDR[key],
689
+ });
690
+ }
691
+ }
692
+ // Compare modified dependencies
693
+ for (const key of oldKeys) {
694
+ if (newKeys.has(key)) {
695
+ const oldReqs = new Set(oldDR[key] ?? []);
696
+ const newReqs = new Set(newDR[key] ?? []);
697
+ // Find removed requirements (non-breaking)
698
+ for (const req of oldReqs) {
699
+ if (!newReqs.has(req)) {
700
+ changes.push({
701
+ path: joinPath(path, 'dependentRequired', key),
702
+ type: 'dependent-required-removed',
703
+ oldValue: req,
704
+ newValue: undefined,
705
+ });
706
+ }
707
+ }
708
+ // Find added requirements (breaking)
709
+ for (const req of newReqs) {
710
+ if (!oldReqs.has(req)) {
711
+ changes.push({
712
+ path: joinPath(path, 'dependentRequired', key),
713
+ type: 'dependent-required-added',
714
+ oldValue: undefined,
715
+ newValue: req,
716
+ });
717
+ }
718
+ }
719
+ }
720
+ }
721
+ return changes;
722
+ }
723
+ /**
724
+ * Compare dependentSchemas (Draft 2019-09+)
725
+ */
726
+ function compareDependentSchemas(oldSchema, newSchema, path, _depth) {
727
+ const changes = [];
728
+ const oldDS = oldSchema.dependentSchemas ?? {};
729
+ const newDS = newSchema.dependentSchemas ?? {};
730
+ const oldKeys = new Set(Object.keys(oldDS));
731
+ const newKeys = new Set(Object.keys(newDS));
732
+ const allKeys = new Set([...oldKeys, ...newKeys]);
733
+ for (const key of allKeys) {
734
+ const oldValue = oldDS[key];
735
+ const newValue = newDS[key];
736
+ if (!deepEqual(oldValue, newValue)) {
737
+ // dependentSchemas changes require manual review
738
+ changes.push({
739
+ path: joinPath(path, 'dependentSchemas', key),
740
+ type: 'dependent-schemas-changed',
741
+ oldValue,
742
+ newValue,
743
+ });
744
+ }
745
+ }
746
+ return changes;
747
+ }
748
+ /**
749
+ * Compare unevaluatedProperties (Draft 2019-09+)
750
+ */
751
+ function compareUnevaluatedProperties(oldSchema, newSchema, path, _depth) {
752
+ const changes = [];
753
+ const oldUP = oldSchema.unevaluatedProperties;
754
+ const newUP = newSchema.unevaluatedProperties;
755
+ if (deepEqual(oldUP, newUP)) {
756
+ return changes;
757
+ }
758
+ // unevaluatedProperties changes require manual review
759
+ changes.push({
760
+ path: joinPath(path, 'unevaluatedProperties'),
761
+ type: 'unevaluated-properties-changed',
762
+ oldValue: oldUP,
763
+ newValue: newUP,
764
+ });
765
+ return changes;
766
+ }
767
+ /**
768
+ * Compare array items schema (recursive)
769
+ */
770
+ function compareArrayItems(oldSchema, newSchema, path, depth) {
771
+ const changes = [];
772
+ const oldItems = oldSchema.items;
773
+ const newItems = newSchema.items;
774
+ // Compare single items schema
775
+ if (isSchemaObject(oldItems) && isSchemaObject(newItems)) {
776
+ const nestedChanges = walkInternal(oldItems, newItems, joinPath(path, 'items'), depth + 1);
777
+ changes.push(...nestedChanges);
778
+ }
779
+ else if (oldItems !== undefined || newItems !== undefined) {
780
+ // Items added, removed, or changed type (array to object or vice versa)
781
+ if (!deepEqual(oldItems, newItems)) {
782
+ // Handle tuple items (array of schemas)
783
+ if (Array.isArray(oldItems) && Array.isArray(newItems)) {
784
+ const maxLen = Math.max(oldItems.length, newItems.length);
785
+ for (let i = 0; i < maxLen; i++) {
786
+ const nestedChanges = walkInternal(oldItems[i], newItems[i], joinPath(path, 'items', String(i)), depth + 1);
787
+ changes.push(...nestedChanges);
788
+ }
789
+ }
790
+ else if (Array.isArray(oldItems) !== Array.isArray(newItems)) {
791
+ // Structural change between tuple and list validation
792
+ changes.push({
793
+ path: joinPath(path, 'items'),
794
+ type: 'items-changed',
795
+ oldValue: oldItems,
796
+ newValue: newItems,
797
+ });
798
+ }
799
+ else if (oldItems === undefined || newItems === undefined) {
800
+ changes.push({
801
+ path: joinPath(path, 'items'),
802
+ type: 'items-changed',
803
+ oldValue: oldItems,
804
+ newValue: newItems,
805
+ });
806
+ }
807
+ }
808
+ }
809
+ // Compare prefixItems (JSON Schema draft 2020-12)
810
+ const oldPrefixItems = oldSchema.prefixItems;
811
+ const newPrefixItems = newSchema.prefixItems;
812
+ if (Array.isArray(oldPrefixItems) || Array.isArray(newPrefixItems)) {
813
+ const oldArr = oldPrefixItems ?? [];
814
+ const newArr = newPrefixItems ?? [];
815
+ const maxLen = Math.max(oldArr.length, newArr.length);
816
+ for (let i = 0; i < maxLen; i++) {
817
+ const nestedChanges = walkInternal(oldArr[i], newArr[i], joinPath(path, 'prefixItems', String(i)), depth + 1);
818
+ changes.push(...nestedChanges);
819
+ }
820
+ }
821
+ // Compare contains schema
822
+ if (oldSchema.contains !== undefined || newSchema.contains !== undefined) {
823
+ if (!deepEqual(oldSchema.contains, newSchema.contains)) {
824
+ if (isSchemaObject(oldSchema.contains) && isSchemaObject(newSchema.contains)) {
825
+ const nestedChanges = walkInternal(oldSchema.contains, newSchema.contains, joinPath(path, 'contains'), depth + 1);
826
+ changes.push(...nestedChanges);
827
+ }
828
+ else {
829
+ changes.push({
830
+ path: joinPath(path, 'contains'),
831
+ type: 'items-changed',
832
+ oldValue: oldSchema.contains,
833
+ newValue: newSchema.contains,
834
+ });
835
+ }
836
+ }
837
+ }
838
+ return changes;
839
+ }
840
+ /**
841
+ * Compare unevaluatedItems (Draft 2020-12)
842
+ */
843
+ function compareUnevaluatedItems(oldSchema, newSchema, path, _depth) {
844
+ const changes = [];
845
+ const oldUI = oldSchema.unevaluatedItems;
846
+ const newUI = newSchema.unevaluatedItems;
847
+ if (deepEqual(oldUI, newUI)) {
848
+ return changes;
849
+ }
850
+ // unevaluatedItems changes require manual review
851
+ changes.push({
852
+ path: joinPath(path, 'unevaluatedItems'),
853
+ type: 'unevaluated-items-changed',
854
+ oldValue: oldUI,
855
+ newValue: newUI,
856
+ });
857
+ return changes;
858
+ }
859
+ /**
860
+ * Compare minContains and maxContains (Draft 2019-09+)
861
+ */
862
+ function compareMinMaxContains(oldSchema, newSchema, path) {
863
+ const changes = [];
864
+ // Compare minContains
865
+ const oldMinContains = oldSchema.minContains;
866
+ const newMinContains = newSchema.minContains;
867
+ if (oldMinContains !== newMinContains) {
868
+ changes.push({
869
+ path: joinPath(path, 'minContains'),
870
+ type: 'min-contains-changed',
871
+ oldValue: oldMinContains,
872
+ newValue: newMinContains,
873
+ });
874
+ }
875
+ // Compare maxContains
876
+ const oldMaxContains = oldSchema.maxContains;
877
+ const newMaxContains = newSchema.maxContains;
878
+ if (oldMaxContains !== newMaxContains) {
879
+ changes.push({
880
+ path: joinPath(path, 'maxContains'),
881
+ type: 'max-contains-changed',
882
+ oldValue: oldMaxContains,
883
+ newValue: newMaxContains,
884
+ });
885
+ }
886
+ return changes;
887
+ }
888
+ /**
889
+ * Compare composition keywords (anyOf, oneOf, allOf, if/then/else, not)
890
+ *
891
+ * Provides detailed analysis of composition changes with granular change types
892
+ * aligned with Strands API classification.
893
+ */
894
+ function compareComposition(oldSchema, newSchema, path, depth) {
895
+ const changes = [];
896
+ // Compare anyOf
897
+ changes.push(...compareAnyOf(oldSchema, newSchema, path, depth));
898
+ // Compare oneOf
899
+ changes.push(...compareOneOf(oldSchema, newSchema, path, depth));
900
+ // Compare allOf
901
+ changes.push(...compareAllOf(oldSchema, newSchema, path, depth));
902
+ // Compare not
903
+ changes.push(...compareNot(oldSchema, newSchema, path, depth));
904
+ // Compare if/then/else
905
+ changes.push(...compareIfThenElse(oldSchema, newSchema, path, depth));
906
+ return changes;
907
+ }
908
+ /**
909
+ * Compare anyOf composition (option additions are breaking, removals are non-breaking)
910
+ */
911
+ function compareAnyOf(oldSchema, newSchema, path, depth) {
912
+ const changes = [];
913
+ const oldAnyOf = oldSchema.anyOf;
914
+ const newAnyOf = newSchema.anyOf;
915
+ // No anyOf in either
916
+ if (oldAnyOf === undefined && newAnyOf === undefined) {
917
+ return changes;
918
+ }
919
+ // anyOf added
920
+ if (oldAnyOf === undefined && newAnyOf !== undefined) {
921
+ for (let i = 0; i < newAnyOf.length; i++) {
922
+ changes.push({
923
+ path: joinPath(path, 'anyOf', String(i)),
924
+ type: 'anyof-option-added',
925
+ oldValue: undefined,
926
+ newValue: newAnyOf[i],
927
+ });
928
+ }
929
+ return changes;
930
+ }
931
+ // anyOf removed
932
+ if (oldAnyOf !== undefined && newAnyOf === undefined) {
933
+ for (let i = 0; i < oldAnyOf.length; i++) {
934
+ changes.push({
935
+ path: joinPath(path, 'anyOf', String(i)),
936
+ type: 'anyof-option-removed',
937
+ oldValue: oldAnyOf[i],
938
+ newValue: undefined,
939
+ });
940
+ }
941
+ return changes;
942
+ }
943
+ // Both have anyOf - compare options
944
+ if (oldAnyOf !== undefined && newAnyOf !== undefined) {
945
+ const matched = matchCompositionOptions(oldAnyOf, newAnyOf);
946
+ // Report removed options
947
+ for (const idx of matched.removed) {
948
+ changes.push({
949
+ path: joinPath(path, 'anyOf', String(idx)),
950
+ type: 'anyof-option-removed',
951
+ oldValue: oldAnyOf[idx],
952
+ newValue: undefined,
953
+ });
954
+ }
955
+ // Report added options
956
+ for (const idx of matched.added) {
957
+ changes.push({
958
+ path: joinPath(path, 'anyOf', String(idx)),
959
+ type: 'anyof-option-added',
960
+ oldValue: undefined,
961
+ newValue: newAnyOf[idx],
962
+ });
963
+ }
964
+ // Recurse into matched options for nested changes
965
+ for (const [oldIdx, newIdx] of matched.matched) {
966
+ const nestedChanges = walkInternal(oldAnyOf[oldIdx], newAnyOf[newIdx], joinPath(path, 'anyOf', String(newIdx)), depth + 1);
967
+ changes.push(...nestedChanges);
968
+ }
969
+ }
970
+ return changes;
971
+ }
972
+ /**
973
+ * Compare oneOf composition (option additions are breaking, removals are non-breaking)
974
+ */
975
+ function compareOneOf(oldSchema, newSchema, path, depth) {
976
+ const changes = [];
977
+ const oldOneOf = oldSchema.oneOf;
978
+ const newOneOf = newSchema.oneOf;
979
+ // No oneOf in either
980
+ if (oldOneOf === undefined && newOneOf === undefined) {
981
+ return changes;
982
+ }
983
+ // oneOf added
984
+ if (oldOneOf === undefined && newOneOf !== undefined) {
985
+ for (let i = 0; i < newOneOf.length; i++) {
986
+ changes.push({
987
+ path: joinPath(path, 'oneOf', String(i)),
988
+ type: 'oneof-option-added',
989
+ oldValue: undefined,
990
+ newValue: newOneOf[i],
991
+ });
992
+ }
993
+ return changes;
994
+ }
995
+ // oneOf removed
996
+ if (oldOneOf !== undefined && newOneOf === undefined) {
997
+ for (let i = 0; i < oldOneOf.length; i++) {
998
+ changes.push({
999
+ path: joinPath(path, 'oneOf', String(i)),
1000
+ type: 'oneof-option-removed',
1001
+ oldValue: oldOneOf[i],
1002
+ newValue: undefined,
1003
+ });
1004
+ }
1005
+ return changes;
1006
+ }
1007
+ // Both have oneOf - compare options
1008
+ if (oldOneOf !== undefined && newOneOf !== undefined) {
1009
+ const matched = matchCompositionOptions(oldOneOf, newOneOf);
1010
+ // Report removed options
1011
+ for (const idx of matched.removed) {
1012
+ changes.push({
1013
+ path: joinPath(path, 'oneOf', String(idx)),
1014
+ type: 'oneof-option-removed',
1015
+ oldValue: oldOneOf[idx],
1016
+ newValue: undefined,
1017
+ });
1018
+ }
1019
+ // Report added options
1020
+ for (const idx of matched.added) {
1021
+ changes.push({
1022
+ path: joinPath(path, 'oneOf', String(idx)),
1023
+ type: 'oneof-option-added',
1024
+ oldValue: undefined,
1025
+ newValue: newOneOf[idx],
1026
+ });
1027
+ }
1028
+ // Recurse into matched options for nested changes
1029
+ for (const [oldIdx, newIdx] of matched.matched) {
1030
+ const nestedChanges = walkInternal(oldOneOf[oldIdx], newOneOf[newIdx], joinPath(path, 'oneOf', String(newIdx)), depth + 1);
1031
+ changes.push(...nestedChanges);
1032
+ }
1033
+ }
1034
+ return changes;
1035
+ }
1036
+ /**
1037
+ * Compare allOf composition (member additions are breaking, removals are non-breaking)
1038
+ */
1039
+ function compareAllOf(oldSchema, newSchema, path, depth) {
1040
+ const changes = [];
1041
+ const oldAllOf = oldSchema.allOf;
1042
+ const newAllOf = newSchema.allOf;
1043
+ // No allOf in either
1044
+ if (oldAllOf === undefined && newAllOf === undefined) {
1045
+ return changes;
1046
+ }
1047
+ // allOf added
1048
+ if (oldAllOf === undefined && newAllOf !== undefined) {
1049
+ for (let i = 0; i < newAllOf.length; i++) {
1050
+ changes.push({
1051
+ path: joinPath(path, 'allOf', String(i)),
1052
+ type: 'allof-member-added',
1053
+ oldValue: undefined,
1054
+ newValue: newAllOf[i],
1055
+ });
1056
+ }
1057
+ return changes;
1058
+ }
1059
+ // allOf removed
1060
+ if (oldAllOf !== undefined && newAllOf === undefined) {
1061
+ for (let i = 0; i < oldAllOf.length; i++) {
1062
+ changes.push({
1063
+ path: joinPath(path, 'allOf', String(i)),
1064
+ type: 'allof-member-removed',
1065
+ oldValue: oldAllOf[i],
1066
+ newValue: undefined,
1067
+ });
1068
+ }
1069
+ return changes;
1070
+ }
1071
+ // Both have allOf - compare members
1072
+ if (oldAllOf !== undefined && newAllOf !== undefined) {
1073
+ const matched = matchCompositionOptions(oldAllOf, newAllOf);
1074
+ // Report removed members
1075
+ for (const idx of matched.removed) {
1076
+ changes.push({
1077
+ path: joinPath(path, 'allOf', String(idx)),
1078
+ type: 'allof-member-removed',
1079
+ oldValue: oldAllOf[idx],
1080
+ newValue: undefined,
1081
+ });
1082
+ }
1083
+ // Report added members
1084
+ for (const idx of matched.added) {
1085
+ changes.push({
1086
+ path: joinPath(path, 'allOf', String(idx)),
1087
+ type: 'allof-member-added',
1088
+ oldValue: undefined,
1089
+ newValue: newAllOf[idx],
1090
+ });
1091
+ }
1092
+ // Recurse into matched members for nested changes
1093
+ for (const [oldIdx, newIdx] of matched.matched) {
1094
+ const nestedChanges = walkInternal(oldAllOf[oldIdx], newAllOf[newIdx], joinPath(path, 'allOf', String(newIdx)), depth + 1);
1095
+ changes.push(...nestedChanges);
1096
+ }
1097
+ }
1098
+ return changes;
1099
+ }
1100
+ /**
1101
+ * Compare not schema (any change is breaking)
1102
+ */
1103
+ function compareNot(oldSchema, newSchema, path, _depth) {
1104
+ const changes = [];
1105
+ const oldNot = oldSchema.not;
1106
+ const newNot = newSchema.not;
1107
+ if (deepEqual(oldNot, newNot)) {
1108
+ return changes;
1109
+ }
1110
+ // Any change to not schema is breaking
1111
+ changes.push({
1112
+ path: joinPath(path, 'not'),
1113
+ type: 'not-schema-changed',
1114
+ oldValue: oldNot,
1115
+ newValue: newNot,
1116
+ });
1117
+ return changes;
1118
+ }
1119
+ /**
1120
+ * Compare if/then/else conditional schema (complex, requires manual review)
1121
+ */
1122
+ function compareIfThenElse(oldSchema, newSchema, path, _depth) {
1123
+ const changes = [];
1124
+ const oldIf = oldSchema.if;
1125
+ const newIf = newSchema.if;
1126
+ const oldThen = oldSchema.then;
1127
+ const newThen = newSchema.then;
1128
+ const oldElse = oldSchema.else;
1129
+ const newElse = newSchema.else;
1130
+ // Check if any of the if/then/else keywords changed
1131
+ const ifChanged = !deepEqual(oldIf, newIf);
1132
+ const thenChanged = !deepEqual(oldThen, newThen);
1133
+ const elseChanged = !deepEqual(oldElse, newElse);
1134
+ if (ifChanged || thenChanged || elseChanged) {
1135
+ // Report as a single if-then-else-changed for simplicity
1136
+ // These are complex and require manual review
1137
+ changes.push({
1138
+ path: joinPath(path, 'if'),
1139
+ type: 'if-then-else-changed',
1140
+ oldValue: { if: oldIf, then: oldThen, else: oldElse },
1141
+ newValue: { if: newIf, then: newThen, else: newElse },
1142
+ });
1143
+ }
1144
+ return changes;
1145
+ }
1146
+ /**
1147
+ * Match composition options between old and new arrays
1148
+ * Uses structural similarity to find corresponding options
1149
+ */
1150
+ function matchCompositionOptions(oldOptions, newOptions) {
1151
+ const matched = [];
1152
+ const usedOld = new Set();
1153
+ const usedNew = new Set();
1154
+ // First pass: find exact matches
1155
+ for (let i = 0; i < oldOptions.length; i++) {
1156
+ for (let j = 0; j < newOptions.length; j++) {
1157
+ if (usedNew.has(j))
1158
+ continue;
1159
+ if (deepEqual(oldOptions[i], newOptions[j])) {
1160
+ matched.push([i, j]);
1161
+ usedOld.add(i);
1162
+ usedNew.add(j);
1163
+ break;
1164
+ }
1165
+ }
1166
+ }
1167
+ // Second pass: match by position for remaining unmatched
1168
+ // This handles cases where schemas are modified but at same position
1169
+ for (let i = 0; i < oldOptions.length; i++) {
1170
+ if (usedOld.has(i))
1171
+ continue;
1172
+ if (i < newOptions.length && !usedNew.has(i)) {
1173
+ // Match by position as a heuristic
1174
+ matched.push([i, i]);
1175
+ usedOld.add(i);
1176
+ usedNew.add(i);
1177
+ }
1178
+ }
1179
+ // Collect removed (in old but not matched)
1180
+ const removed = [];
1181
+ for (let i = 0; i < oldOptions.length; i++) {
1182
+ if (!usedOld.has(i)) {
1183
+ removed.push(i);
1184
+ }
1185
+ }
1186
+ // Collect added (in new but not matched)
1187
+ const added = [];
1188
+ for (let j = 0; j < newOptions.length; j++) {
1189
+ if (!usedNew.has(j)) {
1190
+ added.push(j);
1191
+ }
1192
+ }
1193
+ return { matched, removed, added };
1194
+ }
1195
+ //# sourceMappingURL=walker.js.map