@amritk/validation 0.2.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 (84) hide show
  1. package/AI.md +92 -0
  2. package/LICENSE +21 -0
  3. package/README.md +184 -0
  4. package/dist/generate.d.ts +135 -0
  5. package/dist/generate.js +74 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +5 -0
  8. package/dist/parsers/generators/assert-supported-keywords.d.ts +30 -0
  9. package/dist/parsers/generators/assert-supported-keywords.js +87 -0
  10. package/dist/parsers/generators/build-schema.d.ts +111 -0
  11. package/dist/parsers/generators/build-schema.js +74 -0
  12. package/dist/parsers/generators/generate-deep-equal-check.d.ts +24 -0
  13. package/dist/parsers/generators/generate-deep-equal-check.js +35 -0
  14. package/dist/parsers/generators/generate-discriminated-union-validation.d.ts +15 -0
  15. package/dist/parsers/generators/generate-discriminated-union-validation.js +28 -0
  16. package/dist/parsers/generators/generate-enum-check.d.ts +35 -0
  17. package/dist/parsers/generators/generate-enum-check.js +31 -0
  18. package/dist/parsers/generators/generate-files.d.ts +138 -0
  19. package/dist/parsers/generators/generate-files.js +89 -0
  20. package/dist/parsers/generators/generate-non-discriminated-union-validation.d.ts +6 -0
  21. package/dist/parsers/generators/generate-non-discriminated-union-validation.js +26 -0
  22. package/dist/parsers/generators/generate-parser-function.d.ts +120 -0
  23. package/dist/parsers/generators/generate-parser-function.js +1973 -0
  24. package/dist/parsers/generators/generate-schema-checks.d.ts +5 -0
  25. package/dist/parsers/generators/generate-schema-checks.js +260 -0
  26. package/dist/parsers/generators/generate-strict-assertion.d.ts +155 -0
  27. package/dist/parsers/generators/generate-strict-assertion.js +759 -0
  28. package/dist/parsers/generators/generate-type-checks.d.ts +148 -0
  29. package/dist/parsers/generators/generate-type-checks.js +454 -0
  30. package/dist/parsers/generators/generate-unique-items-check.d.ts +16 -0
  31. package/dist/parsers/generators/generate-unique-items-check.js +32 -0
  32. package/dist/parsers/generators/generate-validation-expression.d.ts +79 -0
  33. package/dist/parsers/generators/generate-validation-expression.js +416 -0
  34. package/dist/parsers/generators/subschema-match.d.ts +48 -0
  35. package/dist/parsers/generators/subschema-match.js +485 -0
  36. package/dist/parsers/generators/unevaluated-match.d.ts +19 -0
  37. package/dist/parsers/generators/unevaluated-match.js +206 -0
  38. package/dist/parsers/helpers/apply-schema-extensions.d.ts +35 -0
  39. package/dist/parsers/helpers/apply-schema-extensions.js +20 -0
  40. package/dist/parsers/helpers/collect-helpers.d.ts +15 -0
  41. package/dist/parsers/helpers/collect-helpers.js +64 -0
  42. package/dist/parsers/helpers/collect-imports.d.ts +104 -0
  43. package/dist/parsers/helpers/collect-imports.js +227 -0
  44. package/dist/parsers/helpers/find-discriminator.d.ts +6 -0
  45. package/dist/parsers/helpers/find-discriminator.js +27 -0
  46. package/dist/parsers/helpers/get-discriminator-value.d.ts +6 -0
  47. package/dist/parsers/helpers/get-discriminator-value.js +18 -0
  48. package/dist/parsers/index.d.ts +2 -0
  49. package/dist/parsers/index.js +4 -0
  50. package/dist/parsers/types/schema-extensions.d.ts +34 -0
  51. package/dist/parsers/types/schema-extensions.js +0 -0
  52. package/dist/rehome-parser-file.d.ts +25 -0
  53. package/dist/rehome-parser-file.js +74 -0
  54. package/dist/validators/generators/assert-generatable-refs.d.ts +19 -0
  55. package/dist/validators/generators/assert-generatable-refs.js +12 -0
  56. package/dist/validators/generators/assert-unevaluated-generatable.d.ts +30 -0
  57. package/dist/validators/generators/assert-unevaluated-generatable.js +71 -0
  58. package/dist/validators/generators/build-schema.d.ts +61 -0
  59. package/dist/validators/generators/build-schema.js +666 -0
  60. package/dist/validators/generators/collect-emitted-refs.d.ts +26 -0
  61. package/dist/validators/generators/collect-emitted-refs.js +112 -0
  62. package/dist/validators/generators/collect-validator-imports.d.ts +86 -0
  63. package/dist/validators/generators/collect-validator-imports.js +62 -0
  64. package/dist/validators/generators/emit-format-checks.d.ts +50 -0
  65. package/dist/validators/generators/emit-format-checks.js +248 -0
  66. package/dist/validators/generators/enforced-keywords.d.ts +33 -0
  67. package/dist/validators/generators/enforced-keywords.js +70 -0
  68. package/dist/validators/generators/folds-to-constant.d.ts +15 -0
  69. package/dist/validators/generators/folds-to-constant.js +43 -0
  70. package/dist/validators/generators/generate-coerce-function.d.ts +22 -0
  71. package/dist/validators/generators/generate-coerce-function.js +208 -0
  72. package/dist/validators/generators/generate-files.d.ts +92 -0
  73. package/dist/validators/generators/generate-files.js +86 -0
  74. package/dist/validators/generators/generate-repair-function.d.ts +27 -0
  75. package/dist/validators/generators/generate-repair-function.js +102 -0
  76. package/dist/validators/generators/generate-validator-function.d.ts +58 -0
  77. package/dist/validators/generators/generate-validator-function.js +1691 -0
  78. package/dist/validators/generators/tuple-shape.d.ts +31 -0
  79. package/dist/validators/generators/tuple-shape.js +20 -0
  80. package/dist/validators/generators/unevaluated-match.d.ts +29 -0
  81. package/dist/validators/generators/unevaluated-match.js +223 -0
  82. package/dist/validators/index.d.ts +2 -0
  83. package/dist/validators/index.js +4 -0
  84. package/package.json +92 -0
@@ -0,0 +1,666 @@
1
+ import { generateIndexBarrel } from "@amritk/helpers/generate-index-barrel";
2
+ import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
3
+ import { walkRefGraph } from "@amritk/helpers/walk-ref-graph";
4
+ import { emitFormatModule, FORMAT_FRAGMENTS, formatCheckName } from "./emit-format-checks.js";
5
+ import { generateValidatorFile } from "./generate-files.js";
6
+ const VALIDATION_RESULT_CONTENT = `/**
7
+ * A single validation error: what went wrong, where, and which keyword said so.
8
+ *
9
+ * The shape matches \`@amritk/runtime-validators\`, so an error from a generated
10
+ * validator and one from the runtime interpreter can be handled by the same
11
+ * code \u2014 grouped, translated, or branched on \u2014 without knowing which produced it.
12
+ */
13
+ export type ValidationError = {
14
+ /** Human-readable description of what went wrong. */
15
+ message: string
16
+ /** JSON Pointer to the offending value inside the instance. */
17
+ path: string
18
+ /** The JSON Schema keyword that rejected the value \u2014 \`type\`, \`required\`, \`minimum\`, \u2026 */
19
+ keyword: string
20
+ /**
21
+ * The keyword's own values, as far as they explain the failure: the bound that
22
+ * was exceeded, the property that was missing, the allowed values that were not
23
+ * matched. Empty for a keyword with nothing to add beyond its name.
24
+ */
25
+ params: Record<string, unknown>
26
+ }
27
+
28
+ /**
29
+ * The result of a generated validator function.
30
+ * Returns \`true\` when the input is valid, or an object with \`valid: false\`
31
+ * and a list of errors when it is not.
32
+ */
33
+ export type ValidationResult = true | { valid: false; errors: ValidationError[] }
34
+
35
+ /**
36
+ * The result of a generated coercing validator.
37
+ *
38
+ * Unlike \`ValidationResult\` there is no bare \`true\`: a caller that coerces wants
39
+ * the value back, and the whole point is that it may differ from what went in.
40
+ * The input is never modified \u2014 \`value\` is the input itself when nothing needed
41
+ * coercing, and otherwise a copy that shares everything the coercion did not
42
+ * touch. So a caller does not have to clone defensively the way an in-place
43
+ * coercer forces them to.
44
+ */
45
+ export type CoercionResult<T> = { valid: true; value: T } | { valid: false; errors: ValidationError[] }
46
+
47
+ /**
48
+ * A string this will read as a number: an optional sign, digits with an optional
49
+ * fractional part, an optional exponent. Nothing else.
50
+ *
51
+ * Deliberately narrower than what \`Number()\` accepts, which is where Ajv gets
52
+ * its surprises \u2014 \`Number(" ")\` is \`0\`, \`Number("0x10")\` is \`16\` and
53
+ * \`Number("Infinity")\` is a value JSON cannot even represent. A config that says
54
+ * \`retries: " "\` has a mistake in it, and answering \`0\` is the one thing worse
55
+ * than rejecting it. Leading zeros (\`"007"\`) and exponents (\`"1e3"\`) stay: both
56
+ * are ordinary ways to write a number in a YAML file, and neither is ambiguous.
57
+ */
58
+ const NUMERIC_STRING = /^[+-]?(?:\\d+|\\d*\\.\\d+)(?:[eE][+-]?\\d+)?$/
59
+
60
+ /**
61
+ * One scalar, coerced toward \`type\`, or returned untouched when that is not
62
+ * possible.
63
+ *
64
+ * Returning the original on failure is what keeps the error honest: nothing is
65
+ * substituted, so the validator that runs next rejects the value the caller
66
+ * actually wrote, with the keyword and params that rejected it. That is the
67
+ * difference between this and a parser's repair, which repairs toward a default
68
+ * and leaves nothing to report.
69
+ *
70
+ * The table is Ajv's \`coerceTypes\` minus the cells where Ajv guesses. Every
71
+ * value this coerces, Ajv coerces to the same value \u2014 \`coerced-vs-ajv\` pins that
72
+ * as a property, so moving off Ajv never changes a value, it only turns some of
73
+ * Ajv's silent repairs into errors. What is deliberately *not* coerced:
74
+ *
75
+ * - **Anything to or from \`null\`.** Ajv reads \`null\` as \`""\`, \`0\` and
76
+ * \`false\`, and reads \`""\`, \`0\` and \`false\` back as \`null\`. \`null\` is a
77
+ * JSON value in its own right and usually means "not set"; turning it into an
78
+ * empty string, or an empty string into it, loses the distinction the document
79
+ * drew.
80
+ * - **Strings that are not cleanly numeric** ({@link NUMERIC_STRING}) \u2014 no
81
+ * whitespace padding, no \`0x\`/\`0o\`/\`0b\`, no \`Infinity\`, no trailing \`.\`.
82
+ */
83
+ export const coerceScalar = (value: unknown, type: string): unknown => {
84
+ switch (type) {
85
+ case 'string':
86
+ return typeof value === 'number' || typeof value === 'boolean' ? String(value) : value
87
+ case 'number':
88
+ case 'integer': {
89
+ if (typeof value === 'boolean') return value ? 1 : 0
90
+ if (typeof value !== 'string' || !NUMERIC_STRING.test(value)) return value
91
+ const asNumber = Number(value)
92
+ if (!Number.isFinite(asNumber)) return value
93
+ return type === 'integer' && asNumber % 1 !== 0 ? value : asNumber
94
+ }
95
+ case 'boolean':
96
+ if (value === 'true' || value === 1) return true
97
+ if (value === 'false' || value === 0) return false
98
+ return value
99
+ default:
100
+ return value
101
+ }
102
+ }
103
+
104
+ /**
105
+ * One scalar at a position that offers several types \u2014 a \`type\` array, or a
106
+ * union of scalar branches \u2014 coerced only when exactly one of them can take it.
107
+ *
108
+ * A value whose type is already one of the offered types is left alone: it is
109
+ * what the schema asked for, and the question of coercion does not arise. That
110
+ * one rule is where this parts company with Ajv, which walks its own coercion
111
+ * list in order and so turns \`"1"\` into \`1\` under \`["number", "string"]\` while
112
+ * leaving it a string under \`["string", "number"]\`. The answer should not depend
113
+ * on the order someone wrote the union in.
114
+ *
115
+ * Otherwise every offered type is tried, and the coercion is taken only if it is
116
+ * the only one that succeeds. \`true\` against \`number | string\` could be \`1\` or
117
+ * \`"true"\` with equal justification, so it stays \`true\` and the validator says
118
+ * what is wrong with it.
119
+ */
120
+ export const coerceUnion = (value: unknown, types: readonly string[]): unknown => {
121
+ const actual = value === null ? 'null' : typeof value
122
+ for (const type of types) {
123
+ // \`integer\` is satisfied by a number, so a non-integral number is not a
124
+ // *type* mismatch here \u2014 the validator is what holds it to being whole.
125
+ if (actual === type || (actual === 'number' && type === 'integer')) return value
126
+ }
127
+
128
+ let coerced: unknown = value
129
+ let found = 0
130
+ for (const type of types) {
131
+ const candidate = coerceScalar(value, type)
132
+ if (candidate === value) continue
133
+ coerced = candidate
134
+ found++
135
+ }
136
+ return found === 1 ? coerced : value
137
+ }
138
+
139
+ /**
140
+ * How deep a structural comparison walks before it gives up and answers "not
141
+ * equal".
142
+ *
143
+ * JSON data is acyclic, but a generated validator is a plain function applied to
144
+ * whatever in-memory value a caller hands it \u2014 and a self-referential object
145
+ * reaching a \`const\` / \`enum\` / \`uniqueItems\` check used to recurse until the
146
+ * stack overflowed, so \`validateFoo\` threw a \`RangeError\` instead of returning
147
+ * the \`ValidationResult\` its signature promises. The cap turns that into an
148
+ * ordinary "these are different" without ever coming near real data;
149
+ * \`@amritk/runtime-validators\` guards its own \`deepEqual\` at the same depth.
150
+ */
151
+ const MAX_EQUAL_DEPTH = 512
152
+
153
+ /**
154
+ * Structural deep equality used by generated \`const\` checks. Objects compare by
155
+ * their key sets rather than serialization, so \`{ a: 1, b: 2 }\` and
156
+ * \`{ b: 2, a: 1 }\` are equal \u2014 unlike \`JSON.stringify\`, which is key-order
157
+ * sensitive and would reject a reordered-but-equal value.
158
+ */
159
+ export const valuesEqual = (a: unknown, b: unknown, depth = 0): boolean => {
160
+ // SameValueZero: \`===\` settles every primitive except \`NaN\`, which counts as
161
+ // equal to itself here. That is what the native \`Set\` in {@link allUnique} does,
162
+ // what Ajv does, and what the interpreter's \`deepEqual\` does \u2014 leaving it out
163
+ // made a \`NaN\` nested inside an object compare unequal to itself, so the same
164
+ // array was "unique" here and "duplicated" everywhere else.
165
+ if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true
166
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
167
+ if (depth >= MAX_EQUAL_DEPTH) return false
168
+ const aArray = Array.isArray(a)
169
+ const bArray = Array.isArray(b)
170
+ if (aArray !== bArray) return false
171
+ if (aArray) {
172
+ const aa = a as unknown[]
173
+ const bb = b as unknown[]
174
+ if (aa.length !== bb.length) return false
175
+ for (let i = 0; i < aa.length; i++) if (!valuesEqual(aa[i], bb[i], depth + 1)) return false
176
+ return true
177
+ }
178
+ const ao = a as Record<string, unknown>
179
+ const bo = b as Record<string, unknown>
180
+ const keys = Object.keys(ao)
181
+ if (keys.length !== Object.keys(bo).length) return false
182
+ for (const key of keys) {
183
+ if (!Object.hasOwn(bo, key) || !valuesEqual(ao[key], bo[key], depth + 1)) return false
184
+ }
185
+ return true
186
+ }
187
+
188
+ /**
189
+ * A cheap, order-independent structural hash consistent with {@link valuesEqual}:
190
+ * equal values always hash the same. It buckets candidate-equal elements in
191
+ * {@link allUnique} so the exact comparison only ever runs inside a bucket.
192
+ *
193
+ * Object keys are folded commutatively (XOR) so key order does not change the
194
+ * hash, and \`NaN\` / \`-0\` collapse the way SameValueZero does. Depth-capped like
195
+ * {@link valuesEqual}: an over-deep value simply shares a bucket and is settled by
196
+ * the (also capped) comparison, so the cap can cost a little time and never a
197
+ * wrong answer. This is the same hash \`@amritk/runtime-validators\` uses.
198
+ */
199
+ const structuralHash = (value: unknown, depth = 0): number => {
200
+ if (value === null) return 0x1a2b3c
201
+ const t = typeof value
202
+ if (t === 'number') {
203
+ const n = value as number
204
+ return Number.isNaN(n) ? 0x7ff8 : n === 0 ? 0 : Math.trunc(n * 2654435761) | 0
205
+ }
206
+ if (t === 'string') {
207
+ const s = value as string
208
+ let h = 0x811c9dc5
209
+ for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
210
+ return h | 0
211
+ }
212
+ if (t === 'boolean') return value ? 1 : 2
213
+ if (t !== 'object') return 0x5eed
214
+ if (depth >= MAX_EQUAL_DEPTH) return 0xdee9
215
+ if (Array.isArray(value)) {
216
+ let h = 0x12345 ^ value.length
217
+ for (let i = 0; i < value.length; i++) h = (Math.imul(h, 31) + structuralHash(value[i], depth + 1)) | 0
218
+ return h | 0
219
+ }
220
+ const obj = value as Record<string, unknown>
221
+ const keys = Object.keys(obj)
222
+ let h = 0xabcde ^ keys.length
223
+ for (const k of keys) {
224
+ let kh = 0x811c9dc5
225
+ for (let i = 0; i < k.length; i++) kh = Math.imul(kh ^ k.charCodeAt(i), 0x01000193)
226
+ h = (h ^ (Math.imul(kh, 0x9e3779b1) + structuralHash(obj[k], depth + 1))) | 0
227
+ }
228
+ return h | 0
229
+ }
230
+
231
+ /**
232
+ * True when every element of \`arr\` is distinct under structural equality
233
+ * ({@link valuesEqual}). Backs generated \`uniqueItems\` checks whose items may be
234
+ * objects or arrays, where a \`JSON.stringify\` dedupe key would be key-order
235
+ * sensitive and let a reordered-but-equal duplicate (\`{ a: 1, b: 2 }\` vs
236
+ * \`{ b: 2, a: 1 }\`) slip through.
237
+ *
238
+ * A native \`Set\` dedupes the all-primitive case in one linear pass. Object and
239
+ * array elements are bucketed by {@link structuralHash} first, so the exact
240
+ * comparison runs only against elements that could actually be equal: an array of
241
+ * distinct objects costs ~O(n) instead of the O(n\xB2) an exhaustive pairwise sweep
242
+ * charged \u2014 4 000 rows took over half a second of pure comparison before, which
243
+ * is a lot to hand an unauthenticated caller.
244
+ */
245
+ export const allUnique = (arr: readonly unknown[]): boolean => {
246
+ const len = arr.length
247
+ if (len < 2) return true
248
+ let allPrimitive = true
249
+ for (let i = 0; i < len; i++) {
250
+ const v = arr[i]
251
+ if (v !== null && typeof v === 'object') {
252
+ allPrimitive = false
253
+ break
254
+ }
255
+ }
256
+ if (allPrimitive) return new Set(arr).size === len
257
+ const buckets = new Map<number, unknown[]>()
258
+ for (let i = 0; i < len; i++) {
259
+ const item = arr[i]
260
+ const hash = structuralHash(item)
261
+ const bucket = buckets.get(hash)
262
+ if (bucket === undefined) {
263
+ buckets.set(hash, [item])
264
+ continue
265
+ }
266
+ for (const seen of bucket) if (valuesEqual(seen, item)) return false
267
+ bucket.push(item)
268
+ }
269
+ return true
270
+ }
271
+
272
+ /**
273
+ * True when \`test\` holds for every element of \`arr\`, holes included. Backs the
274
+ * item check inside a generated boolean guard.
275
+ *
276
+ * Not \`Array.prototype.every\`, because that *skips holes* in a sparse array
277
+ * (\`[, 'x']\`), whereas the validator's index-based loop reads a hole as
278
+ * \`undefined\` and rejects it \u2014 and the guard must never accept what the validator
279
+ * rejects. The guard used to get that by materialising \`Array.from(arr)\` first,
280
+ * which copied every array it looked at; an index loop reads a hole the same way
281
+ * and copies nothing.
282
+ */
283
+ export const everyItem = (arr: readonly unknown[], test: (item: unknown) => boolean): boolean => {
284
+ for (let i = 0; i < arr.length; i++) if (!test(arr[i])) return false
285
+ return true
286
+ }
287
+
288
+ /**
289
+ * Escapes one JSON Pointer segment (RFC 6901): \`~\` \u2192 \`~0\`, \`/\` \u2192 \`~1\`, in that
290
+ * order. Generated error paths are built from *runtime* keys wherever the schema
291
+ * did not name them \u2014 a \`patternProperties\` match, an \`additionalProperties\`
292
+ * sweep, a \`propertyNames\` loop \u2014 and a key containing a \`/\` would otherwise read
293
+ * back as two segments, so an error on \`{"a/b": \u2026}\` pointed at \`/a/b\`, which is
294
+ * the child \`b\` of a property \`a\`. Keys the schema *does* name are escaped at
295
+ * generation time instead, and \`@amritk/runtime-validators\` escapes the same way,
296
+ * so all three agree.
297
+ *
298
+ * The \`indexOf\` pre-test keeps the common key \u2014 no \`/\`, no \`~\` \u2014 off the replace
299
+ * path entirely, which is what the interpreter does for the same reason.
300
+ */
301
+ export const escapePointer = (key: string): string =>
302
+ key.indexOf('/') !== -1 || key.indexOf('~') !== -1 ? key.replace(/~/g, '~0').replace(/\\//g, '~1') : key
303
+
304
+ /**
305
+ * The errors of the branch that was plainly the one meant, out of every branch a
306
+ * failing \`anyOf\` / \`oneOf\` rejected. Empty when no branch stands out.
307
+ *
308
+ * A failing combinator on its own says almost nothing: "must match a schema in
309
+ * anyOf" names no field and no reason, and on the shape this is most often used
310
+ * for \u2014 a union where the value plainly *is* one of the variants and one field of
311
+ * it is wrong \u2014 that is the least useful thing a validator can say. The branch
312
+ * errors are computed anyway to answer the yes/no question, so the only question
313
+ * is which of them are worth reporting.
314
+ *
315
+ * Branches that rejected the value's *kind* go first: a branch wanting a string
316
+ * has nothing to say about an object, so a \`string | { \u2026 }\` union is left with
317
+ * the one branch that was even talking about this value. When more than one
318
+ * survives they all describe the same kind of value, and the tie is broken the
319
+ * way a discriminated union reads from the outside \u2014 if every survivor but one
320
+ * was rejected on the value's *identity* (a \`const\` or \`enum\` on the value or
321
+ * one of its own properties), the remaining one is the variant the author meant.
322
+ *
323
+ * Nothing is reported when no branch stands out, which is as much as can be said
324
+ * honestly: "the branch with the fewest errors" would answer here too, and
325
+ * answers wrongly on \`oneOf: [aReference, theActualThing]\`, where "you did not
326
+ * write a $ref" is one complaint and the real mistake is two.
327
+ *
328
+ * \`path\` is where the combinator was applied, so a segment below it is a direct
329
+ * property of the value being judged. \`@amritk/runtime-validators\` selects the
330
+ * same branch by the same rule, so a generated validator and the interpreter
331
+ * explain a failing union the same way.
332
+ */
333
+ export const selectBranchErrors = (
334
+ branches: readonly (readonly ValidationError[])[],
335
+ path: string,
336
+ ): readonly ValidationError[] => {
337
+ const candidates: (readonly ValidationError[])[] = []
338
+ for (const errors of branches) {
339
+ // The value itself is \`path\`, so a \`type\` error there is a rejected kind.
340
+ if (!errors.some((error) => error.keyword === 'type' && error.path === path)) candidates.push(errors)
341
+ }
342
+ if (candidates.length === 1) return candidates[0] as readonly ValidationError[]
343
+
344
+ let selected: readonly ValidationError[] | null = null
345
+ let rejectedOnIdentity = 0
346
+ for (const errors of candidates) {
347
+ // One segment below \`path\` and no deeper: a discriminator is conventionally a
348
+ // direct field, and a \`const\` buried further down is far more likely to be an
349
+ // ordinary payload constraint.
350
+ const identity = errors.some(
351
+ (error) =>
352
+ (error.keyword === 'const' || error.keyword === 'enum') && error.path.indexOf('/', path.length + 1) === -1,
353
+ )
354
+ if (identity) {
355
+ rejectedOnIdentity++
356
+ continue
357
+ }
358
+ // Two branches survive the discriminator, so it did not discriminate.
359
+ if (selected !== null) return []
360
+ selected = errors
361
+ }
362
+
363
+ return selected !== null && rejectedOnIdentity === candidates.length - 1 ? selected : []
364
+ }
365
+
366
+ /**
367
+ * The result of a generated repairing validator.
368
+ *
369
+ * \`repairs\` is not a second, parallel account of what went wrong \u2014 it *is* the
370
+ * errors \`validateX\` produced, the ones a repair was found for. So a caller that
371
+ * logs a repair logs the same path, keyword and params it would have been
372
+ * rejected with, and the two can never drift apart, because there is only one of
373
+ * them.
374
+ *
375
+ * A document that needed no repair comes back \`valid: true\` with an empty
376
+ * \`repairs\`, which is exactly what \`coerceX\` would have returned. One that was
377
+ * fully repaired is \`valid: true\` with a non-empty \`repairs\` \u2014 the caller
378
+ * decides whether that is acceptable by looking, rather than by being told. One
379
+ * that could not be fully repaired is \`valid: false\` and carries both: the
380
+ * repairs that were applied, and the \`errors\` still outstanding against the
381
+ * value handed back.
382
+ */
383
+ export type RepairResult<T> =
384
+ | { valid: true; value: T; repairs: ValidationError[] }
385
+ | { valid: false; value: unknown; errors: ValidationError[]; repairs: ValidationError[] }
386
+
387
+ /**
388
+ * Looks up the repaired value for one position, given that position's JSON
389
+ * Pointer split into segments. Returns a thunk rather than a value so each
390
+ * repair gets its own object \u2014 a shared literal would alias every site that
391
+ * repaired to it \u2014 and \`null\` where the schema offers nothing to repair toward.
392
+ */
393
+ export type RepairLookup = (segments: readonly string[]) => (() => unknown) | null
394
+
395
+ /**
396
+ * How many validate-and-repair rounds \`repairX\` will run. Repairing a child can
397
+ * expose a parent that only became checkable once the child was there, so one
398
+ * round is not always enough; but each round is bounded further by the rule that
399
+ * a position is repaired at most once, so this is a backstop rather than the
400
+ * thing doing the terminating.
401
+ */
402
+ export const MAX_REPAIR_PASSES = 8
403
+
404
+ /**
405
+ * Splits a JSON Pointer into its segments, undoing the escaping
406
+ * {@link escapePointer} applied. The root pointer (\`''\`) is zero segments.
407
+ */
408
+ export const pointerSegments = (path: string): string[] =>
409
+ path === ''
410
+ ? []
411
+ : path
412
+ .slice(1)
413
+ .split('/')
414
+ .map((segment) =>
415
+ segment.indexOf('~') === -1 ? segment : segment.replace(/~1/g, '/').replace(/~0/g, '~'),
416
+ )
417
+
418
+ /**
419
+ * Returns \`root\` with the position at \`segments\` replaced by \`value\`, copying
420
+ * only the containers along the way.
421
+ *
422
+ * Copying rather than writing is not politeness, it is the same promise
423
+ * \`coerceX\` makes: the caller's input is never modified, so a repaired document
424
+ * can be compared against what was actually sent. Everything the path did not
425
+ * touch is shared, so repairing one field of a large document does not clone it.
426
+ *
427
+ * Returns \`root\` unchanged when the path does not lead anywhere \u2014 a segment into
428
+ * a missing or non-container parent. That is not a failure to handle here: the
429
+ * parent has an error of its own, and repairing *it* is what makes this position
430
+ * reachable on a later pass.
431
+ */
432
+ export const repairAt = (root: unknown, segments: readonly string[], value: unknown): unknown => {
433
+ if (segments.length === 0) return value
434
+ const [head, ...rest] = segments as [string, ...string[]]
435
+
436
+ if (Array.isArray(root)) {
437
+ const index = Number(head)
438
+ // \`index === root.length\` is an append, which is what padding a short array
439
+ // to its \`minItems\` is made of. Anything past that would leave a hole, and a
440
+ // hole is not a repair.
441
+ if (!Number.isInteger(index) || index < 0 || index > root.length) return root
442
+ const next = repairAt(root[index], rest, value)
443
+ if (next === root[index]) return root
444
+ const copy = [...root]
445
+ copy[index] = next
446
+ return copy
447
+ }
448
+
449
+ if (typeof root !== 'object' || root === null) return root
450
+ const obj = root as Record<string, unknown>
451
+ // A missing key is reachable only when this is the last segment: that is the
452
+ // \`required\` repair, which is putting the key there. Deeper than that and the
453
+ // parent is the thing that needs repairing first.
454
+ if (!Object.hasOwn(obj, head) && rest.length > 0) return root
455
+ const next = repairAt(obj[head], rest, value)
456
+ if (next === obj[head] && Object.hasOwn(obj, head)) return root
457
+ return { ...obj, [head]: next }
458
+ }
459
+
460
+ /**
461
+ * The position one error is asking to have repaired, or \`null\` when the error is
462
+ * not about a position a value can be put at.
463
+ *
464
+ * One keyword does not point at the value that is wrong: a \`required\` error is
465
+ * reported against the *object* that is missing the key, and names the key in
466
+ * \`params.missingProperty\`, so the position to fill is one segment deeper.
467
+ * \`minItems\` is the other exception and is handled separately, because it is
468
+ * satisfied by adding several values rather than by replacing one.
469
+ */
470
+ const repairTarget = (error: ValidationError): readonly string[] | null => {
471
+ const segments = pointerSegments(error.path)
472
+ if (error.keyword === 'required') {
473
+ const missing = error.params['missingProperty']
474
+ return typeof missing === 'string' ? [...segments, missing] : null
475
+ }
476
+ return segments
477
+ }
478
+
479
+ /** The value at a pointer, or \`undefined\` when the path does not lead anywhere. */
480
+ const valueAt = (root: unknown, segments: readonly string[]): unknown =>
481
+ segments.reduce<unknown>(
482
+ (node, segment) =>
483
+ Array.isArray(node)
484
+ ? node[Number(segment)]
485
+ : typeof node === 'object' && node !== null
486
+ ? (node as Record<string, unknown>)[segment]
487
+ : undefined,
488
+ root,
489
+ )
490
+
491
+ /**
492
+ * Grows a short array to the length \`minItems\` asks for, or \`null\` when it
493
+ * cannot.
494
+ *
495
+ * Padding is its own operation rather than a position repair because one
496
+ * \`minItems\` error is satisfied by *several* values, not one. Doing it a single
497
+ * element per round would work, but it would spend a round per element and a
498
+ * \`minItems: 50\` would run out of them; filling the shortfall at once keeps the
499
+ * number of rounds a question about the shape of the document rather than about
500
+ * the size of its arrays.
501
+ *
502
+ * Every added element is a separate call to the thunk, so no two share an object.
503
+ */
504
+ const padToMinItems = (
505
+ root: unknown,
506
+ error: ValidationError,
507
+ lookup: RepairLookup,
508
+ done: Set<string>,
509
+ ): unknown => {
510
+ const segments = pointerSegments(error.path)
511
+ const array = valueAt(root, segments)
512
+ const limit = error.params['limit']
513
+ if (!Array.isArray(array) || typeof limit !== 'number' || array.length >= limit) return null
514
+
515
+ // Keyed apart from a position repair at the same path: replacing the whole
516
+ // array and padding it are different repairs, and having done one is no reason
517
+ // to refuse the other.
518
+ const key = \`\${segments.join('/')}#minItems\`
519
+ if (done.has(key)) return null
520
+
521
+ const element = lookup([...segments, String(array.length)])
522
+ if (element === null) return null
523
+
524
+ const padded = [...array]
525
+ while (padded.length < limit) padded.push(element())
526
+ const updated = repairAt(root, segments, padded)
527
+ if (updated === root) return null
528
+ done.add(key)
529
+ return updated
530
+ }
531
+
532
+ /**
533
+ * Applies one round of repairs: every error the schema offers a value for is
534
+ * repaired, and the rest are handed back untouched.
535
+ *
536
+ * \`done\` carries the positions already repaired on an earlier round. A position
537
+ * that failed again after being repaired is not repaired a second time \u2014 the
538
+ * value the schema offered did not satisfy the schema, which is a fact about the
539
+ * schema, and trying again would only produce the same value. Refusing keeps the
540
+ * loop finite without relying on the pass cap, and turns the position into an
541
+ * honest error instead of a silent spin.
542
+ */
543
+ export const applyRepairs = (
544
+ value: unknown,
545
+ errors: readonly ValidationError[],
546
+ lookup: RepairLookup,
547
+ done: Set<string>,
548
+ ): { value: unknown; repaired: ValidationError[] } => {
549
+ const repaired: ValidationError[] = []
550
+ let next = value
551
+
552
+ for (const error of errors) {
553
+ if (error.keyword === 'minItems') {
554
+ const padded = padToMinItems(next, error, lookup, done)
555
+ if (padded === null) continue
556
+ next = padded
557
+ repaired.push(error)
558
+ continue
559
+ }
560
+
561
+ const target = repairTarget(error)
562
+ if (target === null) continue
563
+ const key = target.join('/')
564
+ if (done.has(key)) continue
565
+ const to = lookup(target)
566
+ if (to === null) continue
567
+
568
+ const updated = repairAt(next, target, to())
569
+ // An unreachable position leaves the document exactly as it was. Not counting
570
+ // it as repaired is what lets the parent's own error be the thing that
571
+ // reports, rather than this silently claiming a fix that did not land.
572
+ if (updated === next) continue
573
+ done.add(key)
574
+ next = updated
575
+ repaired.push(error)
576
+ }
577
+
578
+ return { value: next, repaired }
579
+ }
580
+ `;
581
+ const RESERVED_TYPE_NAMES = /* @__PURE__ */ new Set(["ValidationResult", "ValidationError"]);
582
+ const TYPE_NAME = /^[\p{ID_Start}_$][\p{ID_Continue}$]*$/u;
583
+ const RESERVED_WORDS = /* @__PURE__ */ new Set([
584
+ "await",
585
+ "break",
586
+ "case",
587
+ "catch",
588
+ "class",
589
+ "const",
590
+ "continue",
591
+ "debugger",
592
+ "default",
593
+ "delete",
594
+ "do",
595
+ "else",
596
+ "enum",
597
+ "export",
598
+ "extends",
599
+ "false",
600
+ "finally",
601
+ "for",
602
+ "function",
603
+ "if",
604
+ "import",
605
+ "in",
606
+ "instanceof",
607
+ "new",
608
+ "null",
609
+ "return",
610
+ "super",
611
+ "switch",
612
+ "this",
613
+ "throw",
614
+ "true",
615
+ "try",
616
+ "typeof",
617
+ "var",
618
+ "void",
619
+ "while",
620
+ "with",
621
+ "yield"
622
+ ]);
623
+ const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "", schemas, unknownKeys = DEFAULT_UNKNOWN_KEYS, formats, coerce = false, branchErrors = false, repair = false, importExt = "js", check = false) => {
624
+ const enforced = formats === "all" ? new Set(Object.keys(FORMAT_FRAGMENTS)) : new Set((formats ?? []).filter((name) => Object.hasOwn(FORMAT_FRAGMENTS, name)));
625
+ const files = [];
626
+ walkRefGraph(rootSchema, rootTypeName, { typeSuffix, ...schemas !== void 0 ? { schemas } : {} }, (node) => {
627
+ if (node.filename === "validation-result" || node.filename === "index") {
628
+ const owner = node.isRoot ? `the root type "${node.typeName}"` : `"${node.ref}"`;
629
+ const purpose = node.filename === "index" ? "the generated barrel" : "the generated validators' runtime contract";
630
+ throw new Error(`${owner} generates the file "${node.filename}.ts", which is reserved for ${purpose}. Rename the definition (or pass a different root type name) so it gets a file of its own.`);
631
+ }
632
+ if (!TYPE_NAME.test(node.typeName) || RESERVED_WORDS.has(node.typeName)) {
633
+ const owner = node.isRoot ? "the root type name" : `the name "${node.ref}" derives`;
634
+ const suffixNote = typeSuffix === "" ? "" : ` (with the type suffix "${typeSuffix}")`;
635
+ throw new Error(`${owner}${suffixNote}, "${node.typeName}", is not a TypeScript type name, so the generated file would not parse. Pass a name that is a plain identifier \u2014 letters, digits, "_" and "$", not starting with a digit, and not a reserved word.`);
636
+ }
637
+ if (RESERVED_TYPE_NAMES.has(node.typeName)) {
638
+ const owner = node.isRoot ? `the root type name "${node.typeName}"` : `"${node.ref}"`;
639
+ throw new Error(`${owner} generates the type "${node.typeName}", which every generated file already imports from "validation-result.ts". Rename the definition (or pass a different root type name or type suffix) so the two names do not collide.`);
640
+ }
641
+ const content = generateValidatorFile(node.schema, node.typeName, {
642
+ rootSchema: node.rootSchema,
643
+ typeSuffix,
644
+ unknownKeys,
645
+ formats: enforced,
646
+ check,
647
+ coerce,
648
+ branchErrors,
649
+ repair,
650
+ importExt,
651
+ ...node.ref !== void 0 ? { selfRef: node.ref } : {}
652
+ });
653
+ files.push({ filename: `${node.filename}.ts`, content });
654
+ });
655
+ files.push({ filename: "validation-result.ts", content: VALIDATION_RESULT_CONTENT });
656
+ const called = [...enforced].filter((format) => files.some((file) => file.content.includes(`${formatCheckName(format)}(`)));
657
+ const formatModule = emitFormatModule(called);
658
+ if (formatModule !== "")
659
+ files.push({ filename: "formats.ts", content: formatModule });
660
+ files.push({ filename: "index.ts", content: generateIndexBarrel(files, { importExt }) });
661
+ return files;
662
+ };
663
+ export {
664
+ VALIDATION_RESULT_CONTENT,
665
+ buildValidatorSchema
666
+ };