@squiz/dx-json-schema-lib 1.87.0 → 1.88.0

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/formatted-text/v1/resolveFormattedTextNodes.d.ts +5 -2
  3. package/lib/formatted-text/v1/resolveFormattedTextNodes.js +9 -3
  4. package/lib/formatted-text/v1/resolveFormattedTextNodes.js.map +1 -1
  5. package/lib/formatted-text/v1/resolveFormattedTextNodes.spec.js +58 -0
  6. package/lib/formatted-text/v1/resolveFormattedTextNodes.spec.js.map +1 -1
  7. package/lib/index.d.ts +1 -0
  8. package/lib/index.js +1 -0
  9. package/lib/index.js.map +1 -1
  10. package/lib/utils/jsonBreakDetector/checks/findArrayConstraintNarrowing.d.ts +6 -0
  11. package/lib/utils/jsonBreakDetector/checks/findArrayConstraintNarrowing.js +93 -0
  12. package/lib/utils/jsonBreakDetector/checks/findArrayConstraintNarrowing.js.map +1 -0
  13. package/lib/utils/jsonBreakDetector/checks/findConstChanges.d.ts +6 -0
  14. package/lib/utils/jsonBreakDetector/checks/findConstChanges.js +75 -0
  15. package/lib/utils/jsonBreakDetector/checks/findConstChanges.js.map +1 -0
  16. package/lib/utils/jsonBreakDetector/checks/findEnumNarrowing.d.ts +6 -0
  17. package/lib/utils/jsonBreakDetector/checks/findEnumNarrowing.js +90 -0
  18. package/lib/utils/jsonBreakDetector/checks/findEnumNarrowing.js.map +1 -0
  19. package/lib/utils/jsonBreakDetector/checks/findFieldTypeChanges.d.ts +8 -0
  20. package/lib/utils/jsonBreakDetector/checks/findFieldTypeChanges.js +86 -0
  21. package/lib/utils/jsonBreakDetector/checks/findFieldTypeChanges.js.map +1 -0
  22. package/lib/utils/jsonBreakDetector/checks/findNewRequiredFields.d.ts +6 -0
  23. package/lib/utils/jsonBreakDetector/checks/findNewRequiredFields.js +68 -0
  24. package/lib/utils/jsonBreakDetector/checks/findNewRequiredFields.js.map +1 -0
  25. package/lib/utils/jsonBreakDetector/checks/findNumericConstraintNarrowing.d.ts +6 -0
  26. package/lib/utils/jsonBreakDetector/checks/findNumericConstraintNarrowing.js +179 -0
  27. package/lib/utils/jsonBreakDetector/checks/findNumericConstraintNarrowing.js.map +1 -0
  28. package/lib/utils/jsonBreakDetector/checks/findStringConstraintNarrowing.d.ts +6 -0
  29. package/lib/utils/jsonBreakDetector/checks/findStringConstraintNarrowing.js +106 -0
  30. package/lib/utils/jsonBreakDetector/checks/findStringConstraintNarrowing.js.map +1 -0
  31. package/lib/utils/jsonBreakDetector/compareSubschemas.d.ts +9 -0
  32. package/lib/utils/jsonBreakDetector/compareSubschemas.js +49 -0
  33. package/lib/utils/jsonBreakDetector/compareSubschemas.js.map +1 -0
  34. package/lib/utils/jsonBreakDetector/dereference/dereference.d.ts +6 -0
  35. package/lib/utils/jsonBreakDetector/dereference/dereference.js +52 -0
  36. package/lib/utils/jsonBreakDetector/dereference/dereference.js.map +1 -0
  37. package/lib/utils/jsonBreakDetector/dereference/dereference.spec.d.ts +1 -0
  38. package/lib/utils/jsonBreakDetector/dereference/dereference.spec.js +225 -0
  39. package/lib/utils/jsonBreakDetector/dereference/dereference.spec.js.map +1 -0
  40. package/lib/utils/jsonBreakDetector/jsonBreakDetector.d.ts +8 -0
  41. package/lib/utils/jsonBreakDetector/jsonBreakDetector.js +35 -0
  42. package/lib/utils/jsonBreakDetector/jsonBreakDetector.js.map +1 -0
  43. package/lib/utils/jsonBreakDetector/jsonBreakDetector.spec.d.ts +1 -0
  44. package/lib/utils/jsonBreakDetector/jsonBreakDetector.spec.js +2761 -0
  45. package/lib/utils/jsonBreakDetector/jsonBreakDetector.spec.js.map +1 -0
  46. package/lib/utils/jsonBreakDetector/types.d.ts +25 -0
  47. package/lib/utils/jsonBreakDetector/types.js +28 -0
  48. package/lib/utils/jsonBreakDetector/types.js.map +1 -0
  49. package/package.json +1 -1
  50. package/src/formatted-text/v1/resolveFormattedTextNodes.spec.ts +70 -0
  51. package/src/formatted-text/v1/resolveFormattedTextNodes.ts +18 -3
  52. package/src/index.ts +2 -0
  53. package/src/utils/jsonBreakDetector/checks/findArrayConstraintNarrowing.ts +121 -0
  54. package/src/utils/jsonBreakDetector/checks/findConstChanges.ts +97 -0
  55. package/src/utils/jsonBreakDetector/checks/findEnumNarrowing.ts +124 -0
  56. package/src/utils/jsonBreakDetector/checks/findFieldTypeChanges.ts +105 -0
  57. package/src/utils/jsonBreakDetector/checks/findNewRequiredFields.ts +97 -0
  58. package/src/utils/jsonBreakDetector/checks/findNumericConstraintNarrowing.ts +243 -0
  59. package/src/utils/jsonBreakDetector/checks/findStringConstraintNarrowing.ts +139 -0
  60. package/src/utils/jsonBreakDetector/compareSubschemas.ts +67 -0
  61. package/src/utils/jsonBreakDetector/dereference/dereference.spec.ts +245 -0
  62. package/src/utils/jsonBreakDetector/dereference/dereference.ts +65 -0
  63. package/src/utils/jsonBreakDetector/jsonBreakDetector.spec.ts +3041 -0
  64. package/src/utils/jsonBreakDetector/jsonBreakDetector.ts +36 -0
  65. package/src/utils/jsonBreakDetector/types.ts +29 -0
  66. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,124 @@
1
+ import { BREAK_MESSAGES, JsonBreak, pushBreak } from '../types';
2
+ import { JSONSchema } from '@squiz/json-schema-library';
3
+
4
+ /** Recursively detects enum narrowing at each schema level, accumulating dotted paths. */
5
+ export function findEnumNarrowing(
6
+ beforeSchema: JSONSchema,
7
+ afterSchema: JSONSchema,
8
+ path: string,
9
+ breaks: JsonBreak[],
10
+ ): void {
11
+ const beforeProperties = beforeSchema.properties ?? {};
12
+ const afterProperties = afterSchema.properties ?? {};
13
+
14
+ // Recurse into nested object properties.
15
+ for (const [key, afterPropertySchema] of Object.entries(afterProperties)) {
16
+ if (!afterPropertySchema || typeof afterPropertySchema !== 'object') {
17
+ continue;
18
+ }
19
+
20
+ const beforePropertySchema = beforeProperties[key];
21
+ const childPath = path ? `${path}.${key}` : key;
22
+
23
+ if (beforePropertySchema && typeof beforePropertySchema === 'object') {
24
+ // Only compare enum on fields that existed in the before schema.
25
+ reportEnumNarrowingAtNode(beforePropertySchema, afterPropertySchema, childPath, breaks);
26
+
27
+ // Compare `enum` on this property, then recurse into nested schemas for child fields.
28
+ findEnumNarrowing(beforePropertySchema, afterPropertySchema, childPath, breaks);
29
+ }
30
+ // New properties in afterSchema are skipped; adding a field is not an enum change.
31
+ }
32
+
33
+ const beforeItems = beforeSchema.items;
34
+ const afterItems = afterSchema.items;
35
+
36
+ // Recurse into array item schemas.
37
+ if (afterItems && typeof afterItems === 'object' && !Array.isArray(afterItems)) {
38
+ const itemsPath = path ? `${path}[]` : '[]';
39
+ const beforeItemSchema =
40
+ beforeItems && typeof beforeItems === 'object' && !Array.isArray(beforeItems) ? beforeItems : undefined;
41
+
42
+ // Only compare enum on items that existed in the before schema. New items are skipped.
43
+ if (beforeItemSchema) {
44
+ reportEnumNarrowingAtNode(beforeItemSchema, afterItems, itemsPath, breaks);
45
+ findEnumNarrowing(beforeItemSchema, afterItems, itemsPath, breaks);
46
+ }
47
+ }
48
+ }
49
+
50
+ /** Reports enum narrowing on a single schema node. */
51
+ function reportEnumNarrowingAtNode(
52
+ beforeSchema: JSONSchema,
53
+ afterSchema: JSONSchema,
54
+ path: string,
55
+ breaks: JsonBreak[],
56
+ ): void {
57
+ // Removing an options could leave old default value that matched that invalid, so report as breaking.
58
+ if (hasRemovedEnumOptions(beforeSchema, afterSchema)) {
59
+ pushBreak(breaks, path, BREAK_MESSAGES.enum);
60
+ }
61
+
62
+ // Adding an enum constraint could leave old values that dont match invalid, so report as breaking.
63
+ if (hasAddedEnum(beforeSchema, afterSchema)) {
64
+ pushBreak(breaks, path, BREAK_MESSAGES.enumAdded);
65
+ }
66
+ }
67
+
68
+ /** Reports enum narrowing on a single schema node. */
69
+ export function checkEnumNarrowingAtNode(
70
+ beforeSchema: JSONSchema,
71
+ afterSchema: JSONSchema,
72
+ path: string,
73
+ breaks: JsonBreak[],
74
+ ): void {
75
+ reportEnumNarrowingAtNode(beforeSchema, afterSchema, path, breaks);
76
+ }
77
+
78
+ /** Returns true when afterSchema adds an enum where the before schema had none. */
79
+ function hasAddedEnum(beforeSchema: JSONSchema, afterSchema: JSONSchema): boolean {
80
+ const afterEnum = afterSchema.enum;
81
+ // No enum constraint is not breaking.
82
+ if (!afterEnum || afterEnum.length === 0) {
83
+ return false;
84
+ }
85
+
86
+ const beforeEnum = beforeSchema.enum;
87
+ if (beforeEnum && beforeEnum.length > 0) {
88
+ return false;
89
+ }
90
+
91
+ // Check if a before const has been converted to an enum. e.g. const: 'published' -> enum: ['draft', 'published'].
92
+ if (beforeSchema.const !== undefined) {
93
+ return !afterEnum.some((value: unknown) => JSON.stringify(value) === JSON.stringify(beforeSchema.const));
94
+ }
95
+
96
+ return true;
97
+ }
98
+
99
+ /** Returns true when afterSchema.enum no longer allows every value from beforeSchema.enum. */
100
+ function hasRemovedEnumOptions(beforeSchema: JSONSchema, afterSchema: JSONSchema): boolean {
101
+ const beforeEnum = beforeSchema.enum;
102
+ if (!beforeEnum || beforeEnum.length === 0) {
103
+ return false;
104
+ }
105
+
106
+ const afterEnum = afterSchema.enum;
107
+ const afterConst = afterSchema.const;
108
+
109
+ // Enum removed entirely is a relaxation, not a breaking change.
110
+ if (!afterEnum) {
111
+ if (afterConst === undefined) {
112
+ return false;
113
+ }
114
+
115
+ // True when any former enum value is not matched by the new `const`.
116
+ return beforeEnum.some((beforeValue: unknown) => JSON.stringify(beforeValue) !== JSON.stringify(afterConst));
117
+ }
118
+
119
+ // True when any former enum value is missing from the after `enum` list.
120
+ return beforeEnum.some(
121
+ (beforeValue: unknown) =>
122
+ !afterEnum.some((afterValue: unknown) => JSON.stringify(beforeValue) === JSON.stringify(afterValue)),
123
+ );
124
+ }
@@ -0,0 +1,105 @@
1
+ import { BREAK_MESSAGES, JsonBreak, pushBreak } from '../types';
2
+ import { JSONSchema } from '@squiz/json-schema-library';
3
+
4
+ /**
5
+ * Recursively function to compare field type values at each schema level, accumulating dotted paths.
6
+ * */
7
+ export function findFieldTypeChanges(
8
+ beforeSchema: JSONSchema,
9
+ afterSchema: JSONSchema,
10
+ path: string,
11
+ typeChanges: JsonBreak[],
12
+ ): void {
13
+ // Get the current schema level child properties
14
+ const beforeProperties = beforeSchema.properties ?? {};
15
+ const afterProperties = afterSchema.properties ?? {};
16
+
17
+ // Recurse into latest nested object properties.
18
+ for (const [key, afterPropertySchema] of Object.entries(afterProperties)) {
19
+ // Only objects have types
20
+ if (!afterPropertySchema || typeof afterPropertySchema !== 'object') {
21
+ continue;
22
+ }
23
+
24
+ const beforePropertySchema = beforeProperties[key];
25
+ const childPath = path ? `${path}.${key}` : key;
26
+
27
+ // Only compare type on fields that existed in the before schema. New fields are skipped.
28
+ if (beforePropertySchema && typeof beforePropertySchema === 'object') {
29
+ // Only compare type on fields that existed in the before schema.
30
+ if (hasTypeChange(beforePropertySchema, afterPropertySchema)) {
31
+ pushBreak(typeChanges, childPath, BREAK_MESSAGES.type);
32
+ }
33
+
34
+ // Compare `type` on this property, then recurse into its nested schema so child fields are checked too.
35
+ findFieldTypeChanges(beforePropertySchema, afterPropertySchema, childPath, typeChanges);
36
+ }
37
+ }
38
+
39
+ // If instead the current schema level is an array, get the items schema
40
+ const beforeItems = beforeSchema.items;
41
+ const afterItems = afterSchema.items;
42
+
43
+ // Recurse into latest array item schemas.
44
+ if (afterItems && typeof afterItems === 'object' && !Array.isArray(afterItems)) {
45
+ const itemsPath = path ? `${path}[]` : '[]';
46
+ const beforeItemSchema =
47
+ beforeItems && typeof beforeItems === 'object' && !Array.isArray(beforeItems) ? beforeItems : undefined;
48
+
49
+ // Compare the `items` schema `type` for array fields, e.g. `tags[]` when
50
+ // `items: { type: 'string' }` becomes `items: { type: 'number' }`.
51
+ if (beforeItemSchema) {
52
+ if (hasTypeChange(beforeItemSchema, afterItems)) {
53
+ pushBreak(typeChanges, itemsPath, BREAK_MESSAGES.type);
54
+ }
55
+ findFieldTypeChanges(beforeItemSchema, afterItems, itemsPath, typeChanges);
56
+ }
57
+ }
58
+ }
59
+
60
+ function toTypeArray(type: string | string[]): string[] {
61
+ return Array.isArray(type) ? type : [type];
62
+ }
63
+
64
+ /**
65
+ * Returns true when every type allowed by `beforeType` is still allowed by `afterType`.
66
+ * In JSON Schema, integer is a subset of number, so relaxing integer to number is not breaking.
67
+ */
68
+ function isBeforeTypeCoveredByAfterType(beforeType: string | string[], afterType: string | string[]): boolean {
69
+ const afterTypes = new Set(toTypeArray(afterType));
70
+
71
+ for (const type of toTypeArray(beforeType)) {
72
+ // Integer is a subset of number, so relaxing integer to number is not breaking.
73
+ if (type === 'integer') {
74
+ if (afterTypes.has('integer') || afterTypes.has('number')) {
75
+ continue;
76
+ }
77
+
78
+ return false;
79
+ }
80
+
81
+ if (!afterTypes.has(type)) {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ return true;
87
+ }
88
+
89
+ /** Returns true when the JSON Schema `type` keyword narrows between two schemas. */
90
+ export function hasTypeChange(beforeSchema: JSONSchema, afterSchema: JSONSchema): boolean {
91
+ const beforeType = beforeSchema.type;
92
+ const afterType = afterSchema.type;
93
+
94
+ // If both types are undefined, there is no change.
95
+ if (beforeType === undefined && afterType === undefined) {
96
+ return false;
97
+ }
98
+
99
+ // If one type is undefined, there is a change.
100
+ if (beforeType === undefined || afterType === undefined) {
101
+ return true;
102
+ }
103
+
104
+ return !isBeforeTypeCoveredByAfterType(beforeType, afterType);
105
+ }
@@ -0,0 +1,97 @@
1
+ import { JSONSchema } from '@squiz/json-schema-library';
2
+ import { JsonBreak, BREAK_MESSAGES, pushBreak } from '../types';
3
+
4
+ type DependentRequiredMap = Record<string, string[]>;
5
+
6
+ function getDependentRequired(schema: JSONSchema): DependentRequiredMap {
7
+ const dependentRequired = (schema as JSONSchema & { dependentRequired?: DependentRequiredMap }).dependentRequired;
8
+ return dependentRequired ?? {};
9
+ }
10
+
11
+ function findNewDependentRequiredFields(
12
+ beforeSchema: JSONSchema,
13
+ afterSchema: JSONSchema,
14
+ path: string,
15
+ newRequiredFields: JsonBreak[],
16
+ ): void {
17
+ const beforeDependentRequired = getDependentRequired(beforeSchema);
18
+ const afterDependentRequired = getDependentRequired(afterSchema);
19
+ const beforeProperties = beforeSchema.properties ?? {};
20
+
21
+ for (const [triggerField, afterDependentFields] of Object.entries(afterDependentRequired)) {
22
+ if (!Array.isArray(afterDependentFields)) {
23
+ continue;
24
+ }
25
+
26
+ // Adding dependentRequired for a newly added trigger field is not a breaking change.
27
+ if (!(triggerField in beforeProperties)) {
28
+ continue;
29
+ }
30
+
31
+ const beforeDependentFields = beforeDependentRequired[triggerField] ?? [];
32
+
33
+ for (const field of afterDependentFields) {
34
+ if (!beforeDependentFields.includes(field)) {
35
+ pushBreak(newRequiredFields, path ? `${path}.${field}` : field, BREAK_MESSAGES.dependentRequired);
36
+ }
37
+ }
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Recursively compares required and dependentRequired fields at each schema level, accumulating dotted paths.
43
+ */
44
+ export function findNewRequiredFields(
45
+ beforeSchema: JSONSchema,
46
+ afterSchema: JSONSchema,
47
+ path: string,
48
+ newRequiredFields: JsonBreak[],
49
+ ): void {
50
+ // Get whats required at the current schema level
51
+ const beforeRequired = beforeSchema.required ?? [];
52
+ const afterRequired = afterSchema.required ?? [];
53
+
54
+ // Collect fields present in afterSchema.required but not in beforeSchema.required.
55
+ for (const field of afterRequired) {
56
+ if (!beforeRequired.includes(field)) {
57
+ pushBreak(newRequiredFields, path ? `${path}.${field}` : field, BREAK_MESSAGES.required);
58
+ }
59
+ }
60
+
61
+ findNewDependentRequiredFields(beforeSchema, afterSchema, path, newRequiredFields);
62
+
63
+ // Get the current schema level child properties
64
+ const beforeProperties = beforeSchema.properties ?? {};
65
+ const afterProperties = afterSchema.properties ?? {};
66
+ // Recurse into latest nested object properties.
67
+ for (const [key, afterPropertySchema] of Object.entries(afterProperties)) {
68
+ // Skip if the property is not an object.
69
+ if (!afterPropertySchema || typeof afterPropertySchema !== 'object') {
70
+ continue;
71
+ }
72
+
73
+ const beforePropertySchema = beforeProperties[key];
74
+ const childPath = path ? `${path}.${key}` : key;
75
+ // Missing before property is treated as an empty schema with no required fields.
76
+ findNewRequiredFields(
77
+ beforePropertySchema && typeof beforePropertySchema === 'object' ? beforePropertySchema : {},
78
+ afterPropertySchema,
79
+ childPath,
80
+ newRequiredFields,
81
+ );
82
+ }
83
+
84
+ // If instead the current schema level is an array, get the items schema
85
+ const beforeItems = beforeSchema.items;
86
+ const afterItems = afterSchema.items;
87
+ // Recurse into array item schemas (tuple items are not supported).
88
+ if (afterItems && typeof afterItems === 'object' && !Array.isArray(afterItems)) {
89
+ const itemsPath = path ? `${path}[]` : '[]';
90
+ findNewRequiredFields(
91
+ beforeItems && typeof beforeItems === 'object' && !Array.isArray(beforeItems) ? beforeItems : {},
92
+ afterItems,
93
+ itemsPath,
94
+ newRequiredFields,
95
+ );
96
+ }
97
+ }
@@ -0,0 +1,243 @@
1
+ import { BREAK_MESSAGES, JsonBreak, pushBreak } from '../types';
2
+ import { JSONSchema } from '@squiz/json-schema-library';
3
+
4
+ /** Recursively detects narrowed numeric constraints at each schema level, accumulating dotted paths. */
5
+ export function findNumericConstraintNarrowing(
6
+ beforeSchema: JSONSchema,
7
+ afterSchema: JSONSchema,
8
+ path: string,
9
+ breaks: JsonBreak[],
10
+ ): void {
11
+ const beforeProperties = beforeSchema.properties ?? {};
12
+ const afterProperties = afterSchema.properties ?? {};
13
+
14
+ // Recurse into latest nested object properties.
15
+ for (const [key, afterPropertySchema] of Object.entries(afterProperties)) {
16
+ if (!afterPropertySchema || typeof afterPropertySchema !== 'object') {
17
+ continue;
18
+ }
19
+
20
+ const beforePropertySchema = beforeProperties[key];
21
+ const childPath = path ? `${path}.${key}` : key;
22
+
23
+ // Only compare numeric constraints on fields that existed in the before schema. New fields are skipped.
24
+ if (beforePropertySchema && typeof beforePropertySchema === 'object') {
25
+ checkNumericConstraintNarrowing(beforePropertySchema, afterPropertySchema, childPath, breaks);
26
+ findNumericConstraintNarrowing(beforePropertySchema, afterPropertySchema, childPath, breaks);
27
+ }
28
+ }
29
+
30
+ const beforeItems = beforeSchema.items;
31
+ const afterItems = afterSchema.items;
32
+
33
+ // Recurse into latest nested array items.
34
+ if (afterItems && typeof afterItems === 'object' && !Array.isArray(afterItems)) {
35
+ const itemsPath = path ? `${path}[]` : '[]';
36
+ const beforeItemSchema =
37
+ beforeItems && typeof beforeItems === 'object' && !Array.isArray(beforeItems) ? beforeItems : undefined;
38
+
39
+ // Only compare numeric constraints on items that existed in the before schema. New items are skipped.
40
+ if (beforeItemSchema) {
41
+ checkNumericConstraintNarrowing(beforeItemSchema, afterItems, itemsPath, breaks);
42
+ findNumericConstraintNarrowing(beforeItemSchema, afterItems, itemsPath, breaks);
43
+ }
44
+ }
45
+ }
46
+
47
+ /** Returns true when a schema represents a numeric field. */
48
+ function isNumericFieldSchema(schema: JSONSchema): boolean {
49
+ const type = schema.type;
50
+ if (type === 'number' || type === 'integer') {
51
+ return true;
52
+ }
53
+
54
+ // Type can be defined as a union of strings e.g. ['number', 'integer'], so check if 'number' or 'integer' is included.
55
+ if (Array.isArray(type) && (type.includes('number') || type.includes('integer'))) {
56
+ return true;
57
+ }
58
+
59
+ return (
60
+ schema.minimum !== undefined ||
61
+ schema.exclusiveMinimum !== undefined ||
62
+ schema.maximum !== undefined ||
63
+ schema.exclusiveMaximum !== undefined ||
64
+ schema.multipleOf !== undefined
65
+ );
66
+ }
67
+
68
+ type LowerBoundKeyword = 'minimum' | 'exclusiveMinimum';
69
+ type UpperBoundKeyword = 'maximum' | 'exclusiveMaximum';
70
+
71
+ /** Represents the effective bound contributed by a single numeric constraint keyword. */
72
+ type NumericBound<K extends string = string> = {
73
+ value: number;
74
+ keyword: K;
75
+ };
76
+
77
+ /** Returns the strictest lower bound from `minimum` and `exclusiveMinimum`, which may both be set. */
78
+ function getEffectiveLowerBound(schema: JSONSchema): NumericBound<LowerBoundKeyword> | undefined {
79
+ const minimum = schema.minimum;
80
+ const exclusiveMinimum = schema.exclusiveMinimum;
81
+
82
+ // Both are defined, need to figure out which is lower
83
+ if (minimum !== undefined && exclusiveMinimum !== undefined) {
84
+ // min 5 is same as exclusiveMinimum 6 (x >= minimum and x > exclusiveMinimum)
85
+ // so if they are the same exclusiveMinimum is the effective lower bound
86
+ if (minimum > exclusiveMinimum) {
87
+ return { value: minimum, keyword: 'minimum' };
88
+ }
89
+
90
+ return { value: exclusiveMinimum, keyword: 'exclusiveMinimum' };
91
+ }
92
+
93
+ if (minimum !== undefined) {
94
+ return { value: minimum, keyword: 'minimum' };
95
+ }
96
+
97
+ if (exclusiveMinimum !== undefined) {
98
+ return { value: exclusiveMinimum, keyword: 'exclusiveMinimum' };
99
+ }
100
+
101
+ return undefined;
102
+ }
103
+
104
+ /** Returns the strictest upper bound from `maximum` and `exclusiveMaximum`, which may both be set. */
105
+ function getEffectiveUpperBound(schema: JSONSchema): NumericBound<UpperBoundKeyword> | undefined {
106
+ const maximum = schema.maximum;
107
+ const exclusiveMaximum = schema.exclusiveMaximum;
108
+
109
+ if (maximum !== undefined && exclusiveMaximum !== undefined) {
110
+ // max 4 is same as exclusiveMaximum 5 (x <= maximum and x < exclusiveMaximum)
111
+ // so if they are the same exclusiveMaximum is the effective upper bound
112
+ if (maximum < exclusiveMaximum) {
113
+ return { value: maximum, keyword: 'maximum' };
114
+ }
115
+
116
+ return { value: exclusiveMaximum, keyword: 'exclusiveMaximum' };
117
+ }
118
+
119
+ if (maximum !== undefined) {
120
+ return { value: maximum, keyword: 'maximum' };
121
+ }
122
+
123
+ if (exclusiveMaximum !== undefined) {
124
+ return { value: exclusiveMaximum, keyword: 'exclusiveMaximum' };
125
+ }
126
+
127
+ return undefined;
128
+ }
129
+
130
+ /** Returns true when afterSchema requires a stricter lower bound than beforeSchema. */
131
+ function hasLowerBoundIncreased(beforeSchema: JSONSchema, afterSchema: JSONSchema): boolean {
132
+ const beforeBound = getEffectiveLowerBound(beforeSchema);
133
+ const afterBound = getEffectiveLowerBound(afterSchema);
134
+
135
+ if (afterBound === undefined) {
136
+ return false;
137
+ }
138
+
139
+ if (beforeBound === undefined) {
140
+ return true;
141
+ }
142
+
143
+ if (afterBound.value > beforeBound.value) {
144
+ return true;
145
+ }
146
+
147
+ if (afterBound.value < beforeBound.value) {
148
+ return false;
149
+ }
150
+
151
+ // Same effective value, but inclusive vs exclusive still differs (e.g. minimum: 5 allows 5, exclusiveMinimum: 5 does not).
152
+ return afterBound.keyword === 'exclusiveMinimum' && beforeBound.keyword === 'minimum';
153
+ }
154
+
155
+ /** Returns true when afterSchema allows a stricter upper bound than beforeSchema. */
156
+ function hasUpperBoundLowered(beforeSchema: JSONSchema, afterSchema: JSONSchema): boolean {
157
+ const beforeBound = getEffectiveUpperBound(beforeSchema);
158
+ const afterBound = getEffectiveUpperBound(afterSchema);
159
+
160
+ if (afterBound === undefined) {
161
+ return false;
162
+ }
163
+
164
+ if (beforeBound === undefined) {
165
+ return true;
166
+ }
167
+
168
+ if (afterBound.value < beforeBound.value) {
169
+ return true;
170
+ }
171
+
172
+ if (afterBound.value > beforeBound.value) {
173
+ return false;
174
+ }
175
+
176
+ // Same effective value, but inclusive vs exclusive still differs (e.g. maximum: 10 allows 10, exclusiveMaximum: 10 does not).
177
+ return afterBound.keyword === 'exclusiveMaximum' && beforeBound.keyword === 'maximum';
178
+ }
179
+
180
+ /** Picks the break message for the keyword that supplies the effective lower bound in the after schema. */
181
+ function getLowerBoundBreakMessage(_beforeSchema: JSONSchema, afterSchema: JSONSchema): string {
182
+ const afterBound = getEffectiveLowerBound(afterSchema);
183
+
184
+ return afterBound?.keyword === 'exclusiveMinimum' ? BREAK_MESSAGES.exclusiveMinimum : BREAK_MESSAGES.minimum;
185
+ }
186
+
187
+ /** Picks the break message for the keyword that supplies the effective upper bound in the after schema. */
188
+ function getUpperBoundBreakMessage(_beforeSchema: JSONSchema, afterSchema: JSONSchema): string {
189
+ const afterBound = getEffectiveUpperBound(afterSchema);
190
+
191
+ return afterBound?.keyword === 'exclusiveMaximum' ? BREAK_MESSAGES.exclusiveMaximum : BREAK_MESSAGES.maximum;
192
+ }
193
+
194
+ /**
195
+ * Returns true when afterSchema narrows the set of allowed multiples.
196
+ * A value valid under the before `multipleOf` may be invalid under the after `multipleOf`
197
+ * unless the before factor is itself a multiple of the after factor.
198
+ */
199
+ function hasMultipleOfNarrowed(beforeSchema: JSONSchema, afterSchema: JSONSchema): boolean {
200
+ const afterMultipleOf = afterSchema.multipleOf;
201
+ const beforeMultipleOf = beforeSchema.multipleOf;
202
+
203
+ if (afterMultipleOf === undefined) {
204
+ return false;
205
+ }
206
+
207
+ if (beforeMultipleOf === undefined) {
208
+ return true;
209
+ }
210
+
211
+ if (beforeMultipleOf === afterMultipleOf) {
212
+ return false;
213
+ }
214
+
215
+ // Check if the constraint is strictly narrowed
216
+ // e.g. from 4 to 2 is ok as previous valid entries will still be valid
217
+ // but from 4 to 3 is not
218
+ return beforeMultipleOf % afterMultipleOf !== 0;
219
+ }
220
+
221
+ /** Checks numeric constraint keywords on a single schema for narrowing changes. */
222
+ export function checkNumericConstraintNarrowing(
223
+ beforeSchema: JSONSchema,
224
+ afterSchema: JSONSchema,
225
+ path: string,
226
+ breaks: JsonBreak[],
227
+ ): void {
228
+ if (!isNumericFieldSchema(beforeSchema) && !isNumericFieldSchema(afterSchema)) {
229
+ return;
230
+ }
231
+
232
+ if (hasLowerBoundIncreased(beforeSchema, afterSchema)) {
233
+ pushBreak(breaks, path, getLowerBoundBreakMessage(beforeSchema, afterSchema));
234
+ }
235
+
236
+ if (hasUpperBoundLowered(beforeSchema, afterSchema)) {
237
+ pushBreak(breaks, path, getUpperBoundBreakMessage(beforeSchema, afterSchema));
238
+ }
239
+
240
+ if (hasMultipleOfNarrowed(beforeSchema, afterSchema)) {
241
+ pushBreak(breaks, path, BREAK_MESSAGES.multipleOf);
242
+ }
243
+ }