@json-schema-engine/core 0.0.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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/dist/coverage.d.ts +30 -0
  4. package/dist/coverage.d.ts.map +1 -0
  5. package/dist/coverage.js +81 -0
  6. package/dist/coverage.js.map +1 -0
  7. package/dist/cursor.d.ts +16 -0
  8. package/dist/cursor.d.ts.map +1 -0
  9. package/dist/cursor.js +28 -0
  10. package/dist/cursor.js.map +1 -0
  11. package/dist/dialect.d.ts +316 -0
  12. package/dist/dialect.d.ts.map +1 -0
  13. package/dist/dialect.js +135 -0
  14. package/dist/dialect.js.map +1 -0
  15. package/dist/engine.d.ts +203 -0
  16. package/dist/engine.d.ts.map +1 -0
  17. package/dist/engine.js +493 -0
  18. package/dist/engine.js.map +1 -0
  19. package/dist/index.d.ts +182 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +370 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/json.d.ts +49 -0
  24. package/dist/json.d.ts.map +1 -0
  25. package/dist/json.js +128 -0
  26. package/dist/json.js.map +1 -0
  27. package/dist/keywords/applicator.d.ts +57 -0
  28. package/dist/keywords/applicator.d.ts.map +1 -0
  29. package/dist/keywords/applicator.js +789 -0
  30. package/dist/keywords/applicator.js.map +1 -0
  31. package/dist/keywords/core.d.ts +42 -0
  32. package/dist/keywords/core.d.ts.map +1 -0
  33. package/dist/keywords/core.js +139 -0
  34. package/dist/keywords/core.js.map +1 -0
  35. package/dist/keywords/format.d.ts +43 -0
  36. package/dist/keywords/format.d.ts.map +1 -0
  37. package/dist/keywords/format.js +101 -0
  38. package/dist/keywords/format.js.map +1 -0
  39. package/dist/keywords/metaschemas2019.d.ts +3 -0
  40. package/dist/keywords/metaschemas2019.d.ts.map +1 -0
  41. package/dist/keywords/metaschemas2019.js +406 -0
  42. package/dist/keywords/metaschemas2019.js.map +1 -0
  43. package/dist/keywords/metaschemas2020.d.ts +3 -0
  44. package/dist/keywords/metaschemas2020.d.ts.map +1 -0
  45. package/dist/keywords/metaschemas2020.js +441 -0
  46. package/dist/keywords/metaschemas2020.js.map +1 -0
  47. package/dist/keywords/metaschemas6.d.ts +3 -0
  48. package/dist/keywords/metaschemas6.d.ts.map +1 -0
  49. package/dist/keywords/metaschemas6.js +221 -0
  50. package/dist/keywords/metaschemas6.js.map +1 -0
  51. package/dist/keywords/metaschemas7.d.ts +3 -0
  52. package/dist/keywords/metaschemas7.d.ts.map +1 -0
  53. package/dist/keywords/metaschemas7.js +248 -0
  54. package/dist/keywords/metaschemas7.js.map +1 -0
  55. package/dist/keywords/unevaluated.d.ts +14 -0
  56. package/dist/keywords/unevaluated.d.ts.map +1 -0
  57. package/dist/keywords/unevaluated.js +296 -0
  58. package/dist/keywords/unevaluated.js.map +1 -0
  59. package/dist/keywords/validation.d.ts +21 -0
  60. package/dist/keywords/validation.d.ts.map +1 -0
  61. package/dist/keywords/validation.js +332 -0
  62. package/dist/keywords/validation.js.map +1 -0
  63. package/dist/keywords/vocab2019.d.ts +36 -0
  64. package/dist/keywords/vocab2019.d.ts.map +1 -0
  65. package/dist/keywords/vocab2019.js +585 -0
  66. package/dist/keywords/vocab2019.js.map +1 -0
  67. package/dist/keywords/vocab2020.d.ts +20 -0
  68. package/dist/keywords/vocab2020.d.ts.map +1 -0
  69. package/dist/keywords/vocab2020.js +55 -0
  70. package/dist/keywords/vocab2020.js.map +1 -0
  71. package/dist/keywords/vocab7.d.ts +46 -0
  72. package/dist/keywords/vocab7.d.ts.map +1 -0
  73. package/dist/keywords/vocab7.js +305 -0
  74. package/dist/keywords/vocab7.js.map +1 -0
  75. package/dist/loader.d.ts +41 -0
  76. package/dist/loader.d.ts.map +1 -0
  77. package/dist/loader.js +13 -0
  78. package/dist/loader.js.map +1 -0
  79. package/dist/lowering.d.ts +371 -0
  80. package/dist/lowering.d.ts.map +1 -0
  81. package/dist/lowering.js +59 -0
  82. package/dist/lowering.js.map +1 -0
  83. package/dist/output.d.ts +251 -0
  84. package/dist/output.d.ts.map +1 -0
  85. package/dist/output.js +357 -0
  86. package/dist/output.js.map +1 -0
  87. package/dist/records.d.ts +71 -0
  88. package/dist/records.d.ts.map +1 -0
  89. package/dist/records.js +150 -0
  90. package/dist/records.js.map +1 -0
  91. package/dist/ref.d.ts +10 -0
  92. package/dist/ref.d.ts.map +1 -0
  93. package/dist/ref.js +5 -0
  94. package/dist/ref.js.map +1 -0
  95. package/dist/regex.d.ts +45 -0
  96. package/dist/regex.d.ts.map +1 -0
  97. package/dist/regex.js +163 -0
  98. package/dist/regex.js.map +1 -0
  99. package/dist/registry.d.ts +141 -0
  100. package/dist/registry.d.ts.map +1 -0
  101. package/dist/registry.js +407 -0
  102. package/dist/registry.js.map +1 -0
  103. package/dist/result.d.ts +109 -0
  104. package/dist/result.d.ts.map +1 -0
  105. package/dist/result.js +112 -0
  106. package/dist/result.js.map +1 -0
  107. package/dist/uri.d.ts +18 -0
  108. package/dist/uri.d.ts.map +1 -0
  109. package/dist/uri.js +27 -0
  110. package/dist/uri.js.map +1 -0
  111. package/dist/walk.d.ts +25 -0
  112. package/dist/walk.d.ts.map +1 -0
  113. package/dist/walk.js +65 -0
  114. package/dist/walk.js.map +1 -0
  115. package/package.json +32 -0
  116. package/src/coverage.ts +88 -0
  117. package/src/cursor.ts +42 -0
  118. package/src/dialect.ts +428 -0
  119. package/src/engine.ts +741 -0
  120. package/src/index.ts +699 -0
  121. package/src/json.ts +137 -0
  122. package/src/keywords/applicator.ts +895 -0
  123. package/src/keywords/core.ts +168 -0
  124. package/src/keywords/format.ts +143 -0
  125. package/src/keywords/metaschemas2019.ts +410 -0
  126. package/src/keywords/metaschemas2020.ts +443 -0
  127. package/src/keywords/metaschemas6.ts +226 -0
  128. package/src/keywords/metaschemas7.ts +253 -0
  129. package/src/keywords/unevaluated.ts +329 -0
  130. package/src/keywords/validation.ts +627 -0
  131. package/src/keywords/vocab2019.ts +676 -0
  132. package/src/keywords/vocab2020.ts +82 -0
  133. package/src/keywords/vocab7.ts +393 -0
  134. package/src/loader.ts +60 -0
  135. package/src/lowering.ts +414 -0
  136. package/src/output.ts +651 -0
  137. package/src/records.ts +226 -0
  138. package/src/ref.ts +14 -0
  139. package/src/regex.ts +171 -0
  140. package/src/registry.ts +481 -0
  141. package/src/result.ts +252 -0
  142. package/src/uri.ts +35 -0
  143. package/src/walk.ts +88 -0
@@ -0,0 +1,627 @@
1
+ // Validation vocabulary: pure assertions. Assertions never produce and
2
+ // never descend.
3
+
4
+ import {
5
+ JsonValue,
6
+ JsonType,
7
+ isObject,
8
+ jsonTypeOf,
9
+ jsonEqual,
10
+ codePointLength,
11
+ firstDuplicatePair,
12
+ } from "../json.js";
13
+ import { ErrorParams, KeywordBehavior, KeywordContext } from "../dialect.js";
14
+ import {
15
+ LowerExpr,
16
+ LoweringContext,
17
+ LowerParams,
18
+ lowerIR,
19
+ } from "../lowering.js";
20
+ import { Cursor } from "../cursor.js";
21
+
22
+ /** 2020-12 validation vocabulary URI. */
23
+ export const VOCAB_VALIDATION =
24
+ "https://json-schema.org/draft/2020-12/vocab/validation";
25
+
26
+ const id = (name: string): string => `${VOCAB_VALIDATION}#${name}`;
27
+
28
+ const assertion = (
29
+ name: string,
30
+ test: (value: JsonValue, instance: JsonValue) => boolean,
31
+ message: (value: JsonValue) => string,
32
+ params?: (value: JsonValue) => ErrorParams,
33
+ ): KeywordBehavior => ({
34
+ id: id(name),
35
+ evaluate: (value: JsonValue, cursor: Cursor, ctx: KeywordContext) => {
36
+ if (test(value, cursor.value)) return true;
37
+ ctx.error(message(value), params?.(value));
38
+ return false;
39
+ },
40
+ });
41
+
42
+ /**
43
+ * Shared shape for the numeric/string/array/object "guard, then compare"
44
+ * assertions (minLength/maxLength/minimum/maximum/exclusiveMinimum/
45
+ * exclusiveMaximum/minItems/maxItems/minProperties/maxProperties): the
46
+ * keyword is vacuously true unless the instance has the guarded type, in
47
+ * which case a comparison against the (hoisted) keyword value must hold.
48
+ * Mirrors each assertion's `test` above exactly — same guard, same
49
+ * direction of comparison — via `when(and(guard, not(cmp)), [fail])`.
50
+ */
51
+ const guardedCompare =
52
+ (
53
+ guardType: JsonType,
54
+ measure: (lctx: LoweringContext) => LowerExpr,
55
+ op: "<" | "<=" | ">" | ">=",
56
+ message: (value: JsonValue) => string,
57
+ ) =>
58
+ (value: JsonValue, lctx: LoweringContext): void => {
59
+ lctx.emit(
60
+ lowerIR.when(
61
+ lowerIR.and(
62
+ lowerIR.typeIs(lctx.instance, guardType),
63
+ lowerIR.not(lowerIR.cmp(op, measure(lctx), lowerIR.constant(value))),
64
+ ),
65
+ [lowerIR.failWith({ limit: lowerIR.constant(value) }, message(value))],
66
+ ),
67
+ );
68
+ };
69
+ /**
70
+ * The `limit` params shape shared by every guard-then-compare bounds
71
+ * keyword: minLength/maxLength, minItems/maxItems, minProperties/
72
+ * maxProperties, and minimum/maximum/exclusiveMinimum/exclusiveMaximum.
73
+ */
74
+ const limitParams = (value: JsonValue): ErrorParams => ({ limit: value });
75
+ const arrayLengthMeasure = (lctx: LoweringContext): LowerExpr =>
76
+ lowerIR.helper("lengthOf", lctx.instance);
77
+ const propertyCountMeasure = (lctx: LoweringContext): LowerExpr =>
78
+ lowerIR.helper("lengthOf", lowerIR.helper("keysOf", lctx.instance));
79
+ const numberMeasure = (lctx: LoweringContext): LowerExpr => lctx.instance;
80
+
81
+ const typeMatches = (t: JsonValue, v: JsonValue): boolean =>
82
+ t === "integer"
83
+ ? typeof v === "number" && Number.isInteger(v)
84
+ : jsonTypeOf(v) === t;
85
+
86
+ /**
87
+ * EXEMPLAR (assertion class): inspect the instance, report one error on
88
+ * failure, return the verdict. `pattern` compiles its regex through the
89
+ * context so a caller-supplied engine and the per-engine cache apply
90
+ * (see regex.ts), and declares the pattern via analyze() so
91
+ * `rejectUnsafeRegex` can screen it at registration.
92
+ */
93
+ export const pattern: KeywordBehavior = {
94
+ id: id("pattern"),
95
+ analyze: (value) => ({
96
+ regexes: typeof value === "string" ? [value] : [],
97
+ }),
98
+ evaluate: (value, cursor, ctx) => {
99
+ const instance = cursor.value;
100
+ if (typeof instance !== "string") return true;
101
+ if (ctx.compileRegex(value as string).test(instance)) return true;
102
+ ctx.error("does not match required pattern", { pattern: value });
103
+ return false;
104
+ },
105
+ lower: (value, lctx) => {
106
+ lctx.emit(
107
+ lowerIR.when(
108
+ lowerIR.and(
109
+ lowerIR.typeIs(lctx.instance, "string"),
110
+ lowerIR.not(lowerIR.regexTest(value as string, lctx.instance)),
111
+ ),
112
+ [
113
+ lowerIR.failWith(
114
+ { pattern: lowerIR.constant(value) },
115
+ "does not match required pattern",
116
+ ),
117
+ ],
118
+ ),
119
+ );
120
+ },
121
+ };
122
+
123
+ // Number of digits after the decimal point in `n`'s shortest representation,
124
+ // including exponential notation (1e-8 has 8). `%` on the raw floats fails
125
+ // suite cases like 0.0075 % 0.0001 (binary rounding); scaling both operands
126
+ // to integers by the same power of ten sidesteps that at the cost of this
127
+ // string inspection.
128
+ function decimalDigits(n: number): number {
129
+ if (!Number.isFinite(n)) return 0;
130
+ const s = Math.abs(n).toString();
131
+ const eIndex = s.indexOf("e");
132
+ if (eIndex !== -1) {
133
+ const mantissa = s.slice(0, eIndex);
134
+ const exponent = Number(s.slice(eIndex + 1));
135
+ const dot = mantissa.indexOf(".");
136
+ const mantissaDigits = dot === -1 ? 0 : mantissa.length - dot - 1;
137
+ return Math.max(0, mantissaDigits - exponent);
138
+ }
139
+ const dot = s.indexOf(".");
140
+ return dot === -1 ? 0 : s.length - dot - 1;
141
+ }
142
+
143
+ /**
144
+ * `instance` is an exact multiple of `divisor` (`multipleOf`'s predicate,
145
+ * shared by both tiers). Scales both operands to integers by the same power
146
+ * of ten before the modulus so binary-float rounding doesn't misfire (e.g.
147
+ * 0.0075 % 0.0001 in raw floats).
148
+ */
149
+ export function isMultipleOf(instance: number, divisor: number): boolean {
150
+ const scale = 10 ** Math.max(decimalDigits(instance), decimalDigits(divisor));
151
+ const scaledInstance = instance * scale;
152
+ const scaledDivisor = divisor * scale;
153
+ // Suite case: the scaling itself can overflow to Infinity for huge
154
+ // instances against a small divisor; that must read as non-multiple, not
155
+ // throw or silently misvalidate.
156
+ if (Number.isFinite(scaledInstance) && Number.isFinite(scaledDivisor)) {
157
+ return Math.round(scaledInstance) % Math.round(scaledDivisor) === 0;
158
+ }
159
+ const quotient = instance / divisor;
160
+ return Number.isFinite(quotient) && Number.isInteger(quotient);
161
+ }
162
+
163
+ /** The 2020-12 validation vocabulary's keyword behaviors, by name. */
164
+ export const validationVocabulary: Record<string, KeywordBehavior> = {
165
+ type: {
166
+ id: id("type"),
167
+ evaluate: (value, cursor, ctx) => {
168
+ const ok = Array.isArray(value)
169
+ ? value.some((t) => typeMatches(t, cursor.value))
170
+ : typeMatches(value, cursor.value);
171
+ if (!ok)
172
+ ctx.error(`expected type ${JSON.stringify(value)}`, {
173
+ expected: value,
174
+ });
175
+ return ok;
176
+ },
177
+ lower: (value, lctx) => {
178
+ const types = (Array.isArray(value) ? value : [value]) as (
179
+ JsonType | "integer"
180
+ )[];
181
+ lctx.emit(
182
+ lowerIR.when(lowerIR.not(lowerIR.typeIs(lctx.instance, ...types)), [
183
+ lowerIR.failWith(
184
+ { expected: lowerIR.constant(value) },
185
+ `expected type ${JSON.stringify(value)}`,
186
+ ),
187
+ ]),
188
+ );
189
+ },
190
+ },
191
+ enum: {
192
+ ...assertion(
193
+ "enum",
194
+ (value, instance) =>
195
+ (value as JsonValue[]).some((x) => jsonEqual(x, instance)),
196
+ () => "not one of the allowed values",
197
+ (value) => ({ allowedValues: value }),
198
+ ),
199
+ lower: (value, lctx) => {
200
+ const alternatives = value as JsonValue[];
201
+ const params: LowerParams = { allowedValues: lowerIR.constant(value) };
202
+ // An empty enum can never match (some() over zero alternatives is
203
+ // false); guard explicitly since lowerIR.or() with zero parts has no
204
+ // meaningful "no alternatives matched" expression to negate.
205
+ lctx.emit(
206
+ alternatives.length === 0
207
+ ? lowerIR.failWith(params, "not one of the allowed values")
208
+ : lowerIR.when(
209
+ lowerIR.not(
210
+ lowerIR.or(
211
+ ...alternatives.map((x) =>
212
+ lowerIR.helper(
213
+ "jsonEqual",
214
+ lowerIR.constant(x),
215
+ lctx.instance,
216
+ ),
217
+ ),
218
+ ),
219
+ ),
220
+ [lowerIR.failWith(params, "not one of the allowed values")],
221
+ ),
222
+ );
223
+ },
224
+ },
225
+ const: {
226
+ ...assertion(
227
+ "const",
228
+ (value, instance) => jsonEqual(value, instance),
229
+ () => "does not equal the required constant",
230
+ (value) => ({ allowedValue: value }),
231
+ ),
232
+ lower: (value, lctx) => {
233
+ lctx.emit(
234
+ lowerIR.when(
235
+ lowerIR.not(
236
+ lowerIR.helper("jsonEqual", lowerIR.constant(value), lctx.instance),
237
+ ),
238
+ [
239
+ lowerIR.failWith(
240
+ { allowedValue: lowerIR.constant(value) },
241
+ "does not equal the required constant",
242
+ ),
243
+ ],
244
+ ),
245
+ );
246
+ },
247
+ },
248
+ pattern,
249
+ minLength: {
250
+ ...assertion(
251
+ "minLength",
252
+ (value, instance) =>
253
+ typeof instance !== "string" ||
254
+ codePointLength(instance) >= (value as number),
255
+ (value) => `must be at least ${value as number} characters`,
256
+ limitParams,
257
+ ),
258
+ // Violation = code points < n. UTF-16 units bound points from above
259
+ // (points <= units) and below (points >= units/2), so units alone decide
260
+ // outside [n, 2n) — codePointLength runs only in that window (D9).
261
+ lower: (value, lctx) => {
262
+ const n = value as number;
263
+ const len = lowerIR.helper("lengthOf", lctx.instance);
264
+ const cpl = lowerIR.helper("codePointLength", lctx.instance);
265
+ lctx.emit(
266
+ lowerIR.when(
267
+ lowerIR.and(
268
+ lowerIR.typeIs(lctx.instance, "string"),
269
+ lowerIR.or(
270
+ lowerIR.cmp("<", len, lowerIR.constant(n)),
271
+ lowerIR.and(
272
+ lowerIR.cmp("<", len, lowerIR.constant(2 * n)),
273
+ lowerIR.cmp("<", cpl, lowerIR.constant(n)),
274
+ ),
275
+ ),
276
+ ),
277
+ [
278
+ lowerIR.failWith(
279
+ { limit: lowerIR.constant(n) },
280
+ `must be at least ${n} characters`,
281
+ ),
282
+ ],
283
+ ),
284
+ );
285
+ },
286
+ },
287
+ maxLength: {
288
+ ...assertion(
289
+ "maxLength",
290
+ (value, instance) =>
291
+ typeof instance !== "string" ||
292
+ codePointLength(instance) <= (value as number),
293
+ (value) => `must be at most ${value as number} characters`,
294
+ limitParams,
295
+ ),
296
+ // Violation = code points > n; units <= n implies points <= n, so the
297
+ // expensive count runs only when units exceed the bound (D9).
298
+ lower: (value, lctx) => {
299
+ const n = value as number;
300
+ lctx.emit(
301
+ lowerIR.when(
302
+ lowerIR.and(
303
+ lowerIR.typeIs(lctx.instance, "string"),
304
+ lowerIR.cmp(
305
+ ">",
306
+ lowerIR.helper("lengthOf", lctx.instance),
307
+ lowerIR.constant(n),
308
+ ),
309
+ lowerIR.cmp(
310
+ ">",
311
+ lowerIR.helper("codePointLength", lctx.instance),
312
+ lowerIR.constant(n),
313
+ ),
314
+ ),
315
+ [
316
+ lowerIR.failWith(
317
+ { limit: lowerIR.constant(n) },
318
+ `must be at most ${n} characters`,
319
+ ),
320
+ ],
321
+ ),
322
+ );
323
+ },
324
+ },
325
+ minimum: {
326
+ ...assertion(
327
+ "minimum",
328
+ (value, instance) =>
329
+ typeof instance !== "number" || instance >= (value as number),
330
+ (value) => `must be >= ${value as number}`,
331
+ limitParams,
332
+ ),
333
+ lower: guardedCompare(
334
+ "number",
335
+ numberMeasure,
336
+ ">=",
337
+ (value) => `must be >= ${value as number}`,
338
+ ),
339
+ },
340
+ maximum: {
341
+ ...assertion(
342
+ "maximum",
343
+ (value, instance) =>
344
+ typeof instance !== "number" || instance <= (value as number),
345
+ (value) => `must be <= ${value as number}`,
346
+ limitParams,
347
+ ),
348
+ lower: guardedCompare(
349
+ "number",
350
+ numberMeasure,
351
+ "<=",
352
+ (value) => `must be <= ${value as number}`,
353
+ ),
354
+ },
355
+ exclusiveMinimum: {
356
+ ...assertion(
357
+ "exclusiveMinimum",
358
+ (value, instance) =>
359
+ typeof instance !== "number" || instance > (value as number),
360
+ (value) => `must be > ${value as number}`,
361
+ limitParams,
362
+ ),
363
+ lower: guardedCompare(
364
+ "number",
365
+ numberMeasure,
366
+ ">",
367
+ (value) => `must be > ${value as number}`,
368
+ ),
369
+ },
370
+ exclusiveMaximum: {
371
+ ...assertion(
372
+ "exclusiveMaximum",
373
+ (value, instance) =>
374
+ typeof instance !== "number" || instance < (value as number),
375
+ (value) => `must be < ${value as number}`,
376
+ limitParams,
377
+ ),
378
+ lower: guardedCompare(
379
+ "number",
380
+ numberMeasure,
381
+ "<",
382
+ (value) => `must be < ${value as number}`,
383
+ ),
384
+ },
385
+ minItems: {
386
+ ...assertion(
387
+ "minItems",
388
+ (value, instance) =>
389
+ !Array.isArray(instance) || instance.length >= (value as number),
390
+ (value) => `must have at least ${value as number} items`,
391
+ limitParams,
392
+ ),
393
+ lower: guardedCompare(
394
+ "array",
395
+ arrayLengthMeasure,
396
+ ">=",
397
+ (value) => `must have at least ${value as number} items`,
398
+ ),
399
+ },
400
+ maxItems: {
401
+ ...assertion(
402
+ "maxItems",
403
+ (value, instance) =>
404
+ !Array.isArray(instance) || instance.length <= (value as number),
405
+ (value) => `must have at most ${value as number} items`,
406
+ limitParams,
407
+ ),
408
+ lower: guardedCompare(
409
+ "array",
410
+ arrayLengthMeasure,
411
+ "<=",
412
+ (value) => `must have at most ${value as number} items`,
413
+ ),
414
+ },
415
+ minProperties: {
416
+ ...assertion(
417
+ "minProperties",
418
+ (value, instance) =>
419
+ !isObject(instance) ||
420
+ Object.keys(instance).length >= (value as number),
421
+ (value) => `must have at least ${value as number} properties`,
422
+ limitParams,
423
+ ),
424
+ lower: guardedCompare(
425
+ "object",
426
+ propertyCountMeasure,
427
+ ">=",
428
+ (value) => `must have at least ${value as number} properties`,
429
+ ),
430
+ },
431
+ maxProperties: {
432
+ ...assertion(
433
+ "maxProperties",
434
+ (value, instance) =>
435
+ !isObject(instance) ||
436
+ Object.keys(instance).length <= (value as number),
437
+ (value) => `must have at most ${value as number} properties`,
438
+ limitParams,
439
+ ),
440
+ lower: guardedCompare(
441
+ "object",
442
+ propertyCountMeasure,
443
+ "<=",
444
+ (value) => `must have at most ${value as number} properties`,
445
+ ),
446
+ },
447
+ required: {
448
+ id: id("required"),
449
+ evaluate: (value, cursor, ctx) => {
450
+ if (!isObject(cursor.value)) return true;
451
+ let ok = true;
452
+ for (const name of value as string[]) {
453
+ if (!Object.hasOwn(cursor.value, name)) {
454
+ ctx.error(`missing required property '${name}'`, {
455
+ missingProperty: name,
456
+ });
457
+ ok = false;
458
+ }
459
+ }
460
+ return ok;
461
+ },
462
+ lower: (value, lctx) => {
463
+ lctx.emit(
464
+ lowerIR.when(lowerIR.typeIs(lctx.instance, "object"), [
465
+ ...(value as string[]).map((name) =>
466
+ lowerIR.when(
467
+ lowerIR.not({
468
+ kind: "hasOwn",
469
+ target: lctx.instance,
470
+ key: name,
471
+ }),
472
+ [
473
+ lowerIR.failWith(
474
+ { missingProperty: lowerIR.constant(name) },
475
+ `missing required property '${name}'`,
476
+ ),
477
+ ],
478
+ ),
479
+ ),
480
+ ]),
481
+ );
482
+ },
483
+ },
484
+ multipleOf: {
485
+ ...assertion(
486
+ "multipleOf",
487
+ (value, instance) =>
488
+ typeof instance !== "number" || isMultipleOf(instance, value as number),
489
+ (value) => `must be a multiple of ${value as number}`,
490
+ (value) => ({ multipleOf: value }),
491
+ ),
492
+ lower: (value, lctx) => {
493
+ lctx.emit(
494
+ lowerIR.when(
495
+ lowerIR.and(
496
+ lowerIR.typeIs(lctx.instance, "number"),
497
+ lowerIR.not(
498
+ lowerIR.helper(
499
+ "isMultipleOf",
500
+ lctx.instance,
501
+ lowerIR.constant(value),
502
+ ),
503
+ ),
504
+ ),
505
+ [
506
+ lowerIR.failWith(
507
+ { multipleOf: lowerIR.constant(value) },
508
+ `must be a multiple of ${value as number}`,
509
+ ),
510
+ ],
511
+ ),
512
+ );
513
+ },
514
+ },
515
+ uniqueItems: {
516
+ id: id("uniqueItems"),
517
+ evaluate: (value, cursor, ctx) => {
518
+ if (value !== true || !Array.isArray(cursor.value)) return true;
519
+ const pair = firstDuplicatePair(cursor.value);
520
+ if (pair === null) return true;
521
+ const [j, i] = pair;
522
+ ctx.error(`items at ${j} and ${i} are not unique`, {
523
+ duplicates: [j, i],
524
+ });
525
+ return false;
526
+ },
527
+ // evaluate() returns true (vacuously) whenever `value !== true` — mirror
528
+ // that by emitting nothing at all when the keyword value isn't literally
529
+ // `true` (the message text can't cite specific indexes at compile time,
530
+ // but the assertion itself does not depend on them: any duplicate fails).
531
+ lower: (value, lctx) => {
532
+ if (value !== true) return;
533
+ // The pair lookup in the message runs only on the failure path
534
+ // (D9e), so the duplicate scan's cost is not doubled for valid data.
535
+ const pair = lowerIR.helper("firstDuplicatePair", lctx.instance);
536
+ lctx.emit(
537
+ lowerIR.when(
538
+ lowerIR.and(
539
+ lowerIR.typeIs(lctx.instance, "array"),
540
+ lowerIR.helper("hasDuplicateItems", lctx.instance),
541
+ ),
542
+ [
543
+ lowerIR.failWith(
544
+ { duplicates: pair },
545
+ "items at ",
546
+ { kind: "item", target: pair, index: lowerIR.constant(0) },
547
+ " and ",
548
+ { kind: "item", target: pair, index: lowerIR.constant(1) },
549
+ " are not unique",
550
+ ),
551
+ ],
552
+ ),
553
+ );
554
+ },
555
+ },
556
+ dependentRequired: {
557
+ id: id("dependentRequired"),
558
+ evaluate: (value, cursor, ctx) => {
559
+ if (!isObject(cursor.value)) return true;
560
+ const instance = cursor.value;
561
+ let ok = true;
562
+ for (const [name, deps] of Object.entries(
563
+ value as Record<string, JsonValue>,
564
+ )) {
565
+ if (!Object.hasOwn(instance, name)) continue;
566
+ for (const dep of deps as string[]) {
567
+ if (!Object.hasOwn(instance, dep)) {
568
+ ctx.error(`'${name}' requires '${dep}' to be present`, {
569
+ property: name,
570
+ missingProperty: dep,
571
+ });
572
+ ok = false;
573
+ }
574
+ }
575
+ }
576
+ return ok;
577
+ },
578
+ lower: (value, lctx) => {
579
+ lctx.emit(
580
+ lowerIR.when(lowerIR.typeIs(lctx.instance, "object"), [
581
+ ...Object.entries(value as Record<string, JsonValue>).map(
582
+ ([name, deps]) =>
583
+ lowerIR.when(
584
+ { kind: "hasOwn", target: lctx.instance, key: name },
585
+ (deps as string[]).map((dep) =>
586
+ lowerIR.when(
587
+ lowerIR.not({
588
+ kind: "hasOwn",
589
+ target: lctx.instance,
590
+ key: dep,
591
+ }),
592
+ [
593
+ lowerIR.failWith(
594
+ {
595
+ property: lowerIR.constant(name),
596
+ missingProperty: lowerIR.constant(dep),
597
+ },
598
+ `'${name}' requires '${dep}' to be present`,
599
+ ),
600
+ ],
601
+ ),
602
+ ),
603
+ ),
604
+ ),
605
+ ]),
606
+ );
607
+ },
608
+ },
609
+ // minContains/maxContains have no assertion of their own; `contains` reads
610
+ // them as inert siblings (applicator.ts), the same way `if` drives `then`/
611
+ // `else`. They must still be registered so unknown-keyword handling and the
612
+ // registration walk don't treat them as annotation-only or absent.
613
+ minContains: {
614
+ id: id("minContains"),
615
+ evaluate: () => true,
616
+ lower: () => {
617
+ /* contains reads this sibling directly (lctx.schema.minContains) */
618
+ },
619
+ },
620
+ maxContains: {
621
+ id: id("maxContains"),
622
+ evaluate: () => true,
623
+ lower: () => {
624
+ /* contains reads this sibling directly (lctx.schema.maxContains) */
625
+ },
626
+ },
627
+ };