@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/LICENSE +21 -0
- package/dist/assemble.d.ts +10 -0
- package/dist/assemble.d.ts.map +1 -0
- package/dist/assemble.js +42 -0
- package/dist/assemble.js.map +1 -0
- package/dist/classifiers.d.ts +87 -0
- package/dist/classifiers.d.ts.map +1 -0
- package/dist/classifiers.js +375 -0
- package/dist/classifiers.js.map +1 -0
- package/dist/format.d.ts +12 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +127 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/ref-resolver.d.ts +73 -0
- package/dist/ref-resolver.d.ts.map +1 -0
- package/dist/ref-resolver.js +345 -0
- package/dist/ref-resolver.js.map +1 -0
- package/dist/types.d.ts +170 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +151 -0
- package/dist/types.js.map +1 -0
- package/dist/walker.d.ts +17 -0
- package/dist/walker.d.ts.map +1 -0
- package/dist/walker.js +1195 -0
- package/dist/walker.js.map +1 -0
- package/package.json +56 -0
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
|