@amritk/validation 0.5.0 → 0.6.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.
@@ -0,0 +1,1071 @@
1
+ const REPAIR_RUNTIME_CONTENT = `/**
2
+ * The result of a generated repairing validator.
3
+ *
4
+ * 'repairs' is not a second, parallel account of what went wrong \u2014 it *is* the
5
+ * errors the validator produced, the ones a repair was found for. So a caller
6
+ * that logs a repair logs the same path, keyword and params it would have been
7
+ * rejected with, and the two can never drift apart, because there is only one of
8
+ * them.
9
+ *
10
+ * A document that needed no repair comes back 'valid: true' with an empty
11
+ * 'repairs'. One that was fully repaired is 'valid: true' with a non-empty
12
+ * 'repairs' \u2014 the caller decides whether that is acceptable by looking, rather
13
+ * than by being told. One that could not be fully repaired is 'valid: false' and
14
+ * carries both: the repairs that were applied, and the 'errors' still
15
+ * outstanding against the value handed back.
16
+ */
17
+ export type RepairResult<T> =
18
+ | { valid: true; value: T; repairs: ValidationError[] }
19
+ | { valid: false; value: unknown; errors: ValidationError[]; repairs: ValidationError[] }
20
+
21
+ /**
22
+ * What a lookup answers for a position that should be removed rather than
23
+ * replaced: an '"x-repair": "drop"' schema, or a 'false' one. A symbol, so no
24
+ * value a document can hold is ever mistaken for it.
25
+ */
26
+ export const REPAIR_DROP: unique symbol = Symbol('mjst.repair.drop')
27
+
28
+ /**
29
+ * Builds the value a position repairs to. Called once per repair, so no two
30
+ * repaired positions ever share an object.
31
+ *
32
+ * 'depth' is how many required levels have been filled above this one: a
33
+ * missing required object is built with its own required children in one step,
34
+ * and the depth is what keeps a recursive definition from building forever.
35
+ * 'into' is an object being filled already: a conditional branch adds its
36
+ * required keys to it instead of building a separate value.
37
+ */
38
+ export type RepairThunk = (depth: number, into?: Record<string, unknown>) => unknown
39
+
40
+ /** A lookup's answer: build a value, remove the position, or nothing to offer. */
41
+ export type RepairAnswer = RepairThunk | typeof REPAIR_DROP | null
42
+
43
+ /**
44
+ * Looks up what the position at 'segments' repairs to, starting at
45
+ * 'segments[index]', with 'value' the instance at the node the lookup belongs
46
+ * to. The instance is what an 'if' is evaluated against, so a lookup under an
47
+ * if/then/else answers from the branch the document actually takes.
48
+ */
49
+ export type RepairLookup = (segments: readonly string[], index: number, value: unknown) => RepairAnswer
50
+
51
+ /** The segments of the position a lookup is asked about when it is the node itself. */
52
+ export const NO_SEGMENTS: readonly string[] = []
53
+
54
+ /**
55
+ * How many required levels one repair fills. Deep enough for any document a
56
+ * person writes by hand, and a hard stop for a definition that requires itself.
57
+ */
58
+ export const MAX_FILL_DEPTH = 16
59
+
60
+ /**
61
+ * How many validate-and-repair rounds the loop will run. Each round is bounded
62
+ * further by the rule that a position is repaired at most once, so this is a
63
+ * backstop rather than the thing doing the terminating.
64
+ */
65
+ export const MAX_REPAIR_PASSES = 8
66
+
67
+ /** How deep a walk descends before it reports the position instead. */
68
+ const DEFAULT_MAX_DEPTH = 512
69
+
70
+ /**
71
+ * How many containers a walk visits before it stops. Forty times what a 24 MB
72
+ * OpenAPI document holds, so no real document comes near it \u2014 it is there for
73
+ * an in-memory graph whose shared or cyclic subtrees fan out, which a depth cap
74
+ * alone lets grow exponentially.
75
+ */
76
+ const DEFAULT_MAX_WORK = 10_000_000
77
+
78
+ /**
79
+ * What a call to 'repairX' may do to the document, and how hard it looks.
80
+ *
81
+ * The first three apply to every generated repairer. The rest are read by the
82
+ * single-pass walk, which is emitted for schemas built from the keywords it
83
+ * understands; the loop that repairs everything else ignores them.
84
+ */
85
+ export type RepairOptions = {
86
+ /** JSON Pointer every reported path is rooted at. Defaults to the document root, ''. */
87
+ readonly path?: string
88
+ /**
89
+ * Assign and delete on the input itself instead of copying the containers a
90
+ * repair touches. Off by default: a caller who compares the repaired document
91
+ * against what was sent needs the original to survive.
92
+ */
93
+ readonly inPlace?: boolean
94
+ /**
95
+ * Move a scalar written in the wrong type toward the declared one before
96
+ * repairing it ('"5"' to '5' under 'type: integer'). On by default. Off, a
97
+ * wrong-typed value is a reported position like any other and is repaired to
98
+ * the value the schema supplies.
99
+ */
100
+ readonly coerce?: boolean
101
+ /**
102
+ * Follow instance '$ref's at positions whose Reference schema opts in with
103
+ * '"x-repair": "ref"', validating and repairing each target under the type of
104
+ * the position that referenced it. On by default; it only does anything for a
105
+ * schema that opted in.
106
+ */
107
+ readonly refs?: boolean
108
+ /**
109
+ * Pointer prefixes ('#/components/') whose targets are not walked again. Use
110
+ * it for the parts of the document the root schema already covers. A skipped
111
+ * target is still resolved, so a dangling '$ref' into it is still reported.
112
+ */
113
+ readonly skipRefs?: readonly string[]
114
+ /**
115
+ * Called for every opted-in '$ref', with the Reference object, its path and
116
+ * the id of the position's type. Return 'false' to leave that one alone.
117
+ */
118
+ readonly onRef?: (value: Record<string, unknown>, path: string, typeId: string) => boolean | void
119
+ /** How deep the walk descends before it reports 'maxDepth' instead. Defaults to 512. */
120
+ readonly maxDepth?: number
121
+ /** How many containers the walk visits before it stops with 'maxWork'. Defaults to ten million. */
122
+ readonly maxWork?: number
123
+ /**
124
+ * Walk each object at most once per schema, and hand back the same result
125
+ * every other time it is reached. Off by default, because it costs a map entry
126
+ * per object; turn it on for in-memory graphs that share subtrees or hold
127
+ * cycles, which a parsed JSON document never does.
128
+ */
129
+ readonly memo?: boolean
130
+ /** Reject 'NaN' and the infinities where a number is declared. JSON cannot hold them. */
131
+ readonly finite?: boolean
132
+ /**
133
+ * Accept only plain objects (the prototype is 'Object.prototype' or 'null')
134
+ * where an object is declared, and remove an own '__proto__' key \u2014 the one
135
+ * 'JSON.parse' creates and a later 'Object.assign' would turn into a prototype.
136
+ */
137
+ readonly plainObjects?: boolean
138
+ }
139
+
140
+ /** The own property or array element at one pointer segment, or 'undefined'. */
141
+ export const childAt = (value: unknown, segment: string): unknown => {
142
+ if (Array.isArray(value)) {
143
+ const index = Number(segment)
144
+ return Number.isInteger(index) && index >= 0 ? value[index] : undefined
145
+ }
146
+ if (typeof value === 'object' && value !== null && Object.hasOwn(value, segment)) {
147
+ return (value as Record<string, unknown>)[segment]
148
+ }
149
+ return undefined
150
+ }
151
+
152
+ /**
153
+ * Writes an own property. A plain assignment to '__proto__' replaces the
154
+ * object's prototype instead, so that one key is defined rather than assigned.
155
+ */
156
+ export const setOwn = (target: Record<string, unknown>, key: string, value: unknown): void => {
157
+ if (key === '__proto__') {
158
+ Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true })
159
+ } else {
160
+ target[key] = value
161
+ }
162
+ }
163
+
164
+ /**
165
+ * How many keys one repair fills, across every level of it. The depth cap alone
166
+ * bounds a definition that requires itself once, but one that requires itself
167
+ * at three keys builds three to the sixteenth objects inside it.
168
+ */
169
+ const MAX_FILL_KEYS = 10_000
170
+
171
+ /** What is left of {@link MAX_FILL_KEYS} for the repair being built. */
172
+ let fillBudget = MAX_FILL_KEYS
173
+
174
+ /**
175
+ * Starts the budget for a new repair. A generated fill calls it when it is the
176
+ * outermost one \u2014 depth zero, building rather than filling into an object.
177
+ */
178
+ export const fillBegin = (): void => {
179
+ fillBudget = MAX_FILL_KEYS
180
+ }
181
+
182
+ /**
183
+ * Fills one required key of an object being built, through the lookup of the
184
+ * node that declares it. Asking the node rather than the property is what lets a
185
+ * key only an 'additionalProperties' schema describes, or one only a
186
+ * conditional branch declares, be filled the same way.
187
+ */
188
+ export const fillKey = (target: Record<string, unknown>, key: string, lookup: RepairLookup, depth: number): void => {
189
+ if (Object.hasOwn(target, key) || fillBudget <= 0) return
190
+ fillBudget--
191
+ const answer = lookup([key], 0, target)
192
+ if (typeof answer !== 'function') return
193
+ const value = answer(depth + 1)
194
+ if (value !== undefined) setOwn(target, key, value)
195
+ }
196
+
197
+ /** Lets a branch's answer add what it requires to an object being filled. */
198
+ export const fillInto = (target: Record<string, unknown>, answer: RepairAnswer, depth: number): void => {
199
+ if (typeof answer === 'function') answer(depth, target)
200
+ }
201
+
202
+ /**
203
+ * Splits a JSON Pointer into its segments, undoing the escaping
204
+ * 'escapePointer' applied. The root pointer ('') is zero segments.
205
+ */
206
+ export const pointerSegments = (path: string): string[] => {
207
+ if (path === '') return []
208
+ const segments = path.slice(1).split('/')
209
+ for (let i = 0; i < segments.length; i++) segments[i] = unescapeSegment(segments[i] as string)
210
+ return segments
211
+ }
212
+
213
+ /** RFC 6901 decoding: '~1' to '/' first, then '~0' to '~'. */
214
+ const unescapeSegment = (segment: string): string =>
215
+ segment.indexOf('~') === -1 ? segment : segment.split('~1').join('/').split('~0').join('~')
216
+
217
+ /** What 'writeAt' and 'dropAt' answer when the position was not there to change. */
218
+ const NOT_APPLIED: unique symbol = Symbol('mjst.repair.not-applied')
219
+
220
+ /** True for the canonical decimal spelling of an array index. */
221
+ const isIndexSegment = (segment: string): boolean => {
222
+ if (segment === '0') return true
223
+ const first = segment.charCodeAt(0)
224
+ if (!(first >= 49 && first <= 57)) return false
225
+ for (let i = 1; i < segment.length; i++) {
226
+ const c = segment.charCodeAt(i)
227
+ if (c < 48 || c > 57) return false
228
+ }
229
+ return true
230
+ }
231
+
232
+ /**
233
+ * Returns 'root' with the position at 'segments' replaced by 'value'.
234
+ *
235
+ * Copy-on-write by default: only the containers along the way are copied, so a
236
+ * repaired document shares everything the repair did not touch and the input
237
+ * survives. In place, the containers are assigned to directly.
238
+ *
239
+ * Answers NOT_APPLIED when the path does not lead anywhere \u2014 a segment into a
240
+ * missing or non-container parent \u2014 or when the position already holds exactly
241
+ * that value. Neither is a failure to handle here: the parent has an error of
242
+ * its own, and repairing *it* is what makes this position reachable.
243
+ */
244
+ const writeAt = (
245
+ root: unknown,
246
+ segments: readonly string[],
247
+ index: number,
248
+ value: unknown,
249
+ inPlace: boolean,
250
+ ): unknown => {
251
+ if (index === segments.length) return Object.is(root, value) ? NOT_APPLIED : value
252
+ const head = segments[index] as string
253
+
254
+ if (Array.isArray(root)) {
255
+ const position = Number(head)
256
+ // 'position === root.length' is an append, which is what padding a short
257
+ // array is made of. Anything past that would leave a hole.
258
+ if (!Number.isInteger(position) || position < 0 || position > root.length) return NOT_APPLIED
259
+ if (position === root.length && index + 1 < segments.length) return NOT_APPLIED
260
+ const next = writeAt(root[position], segments, index + 1, value, inPlace)
261
+ if (next === NOT_APPLIED) return NOT_APPLIED
262
+ const target = inPlace ? root : root.slice()
263
+ target[position] = next
264
+ return target
265
+ }
266
+
267
+ if (typeof root !== 'object' || root === null) return NOT_APPLIED
268
+ const obj = root as Record<string, unknown>
269
+ const own = Object.hasOwn(obj, head)
270
+ // A missing key is reachable only when this is the last segment: that is the
271
+ // 'required' repair, which is putting the key there.
272
+ if (!own && index + 1 < segments.length) return NOT_APPLIED
273
+ const next = writeAt(own ? obj[head] : undefined, segments, index + 1, value, inPlace)
274
+ if (next === NOT_APPLIED) return NOT_APPLIED
275
+ const target = inPlace ? obj : { ...obj }
276
+ setOwn(target, head, next)
277
+ return target
278
+ }
279
+
280
+ /** Returns 'root' without the position at 'segments', or NOT_APPLIED. */
281
+ const dropAt = (root: unknown, segments: readonly string[], index: number, inPlace: boolean): unknown => {
282
+ const head = segments[index] as string
283
+ const last = index === segments.length - 1
284
+
285
+ if (Array.isArray(root)) {
286
+ const position = Number(head)
287
+ if (!Number.isInteger(position) || position < 0 || position >= root.length) return NOT_APPLIED
288
+ const target = inPlace ? root : root.slice()
289
+ if (last) {
290
+ target.splice(position, 1)
291
+ return target
292
+ }
293
+ const next = dropAt(root[position], segments, index + 1, inPlace)
294
+ if (next === NOT_APPLIED) return NOT_APPLIED
295
+ target[position] = next
296
+ return target
297
+ }
298
+
299
+ if (typeof root !== 'object' || root === null || !Object.hasOwn(root, head)) return NOT_APPLIED
300
+ const obj = root as Record<string, unknown>
301
+ if (last) {
302
+ const target = inPlace ? obj : { ...obj }
303
+ delete target[head]
304
+ return target
305
+ }
306
+ const next = dropAt(obj[head], segments, index + 1, inPlace)
307
+ if (next === NOT_APPLIED) return NOT_APPLIED
308
+ const target = inPlace ? obj : { ...obj }
309
+ setOwn(target, head, next)
310
+ return target
311
+ }
312
+
313
+ /**
314
+ * Orders removals so that each one leaves the positions of the rest intact:
315
+ * the later index in an array first, and a deeper position before the
316
+ * container holding it.
317
+ */
318
+ const compareDrops = (a: readonly string[], b: readonly string[]): number => {
319
+ const shared = Math.min(a.length, b.length)
320
+ for (let i = 0; i < shared; i++) {
321
+ const x = a[i] as string
322
+ const y = b[i] as string
323
+ if (x === y) continue
324
+ if (isIndexSegment(x) && isIndexSegment(y)) return Number(y) - Number(x)
325
+ return x < y ? 1 : -1
326
+ }
327
+ return b.length - a.length
328
+ }
329
+
330
+ /**
331
+ * The position one error is asking to have repaired, or 'null' when the error
332
+ * is not about a position a value can be put at.
333
+ *
334
+ * A 'required' error is reported against the *object* that is missing the key,
335
+ * and names the key in 'params.missingProperty', so the position to fill is one
336
+ * segment deeper. 'minItems' is handled separately, because it is satisfied by
337
+ * adding several values rather than by replacing one.
338
+ */
339
+ const repairTarget = (error: ValidationError, base: string): string[] | null => {
340
+ if (error.path.slice(0, base.length) !== base) return null
341
+ const segments = pointerSegments(error.path.slice(base.length))
342
+ if (error.keyword === 'required') {
343
+ const missing = error.params['missingProperty']
344
+ if (typeof missing !== 'string') return null
345
+ segments.push(missing)
346
+ }
347
+ return segments
348
+ }
349
+
350
+ /** The value at a pointer, or 'undefined' when the path does not lead anywhere. */
351
+ const valueAt = (root: unknown, segments: readonly string[]): unknown => {
352
+ let node = root
353
+ for (const segment of segments) node = childAt(node, segment)
354
+ return node
355
+ }
356
+
357
+ /**
358
+ * Grows a short array to the length 'minItems' asks for, or NOT_APPLIED.
359
+ *
360
+ * One 'minItems' error is satisfied by *several* values, so the shortfall is
361
+ * filled at once rather than an element per round, which would run out of
362
+ * rounds on a 'minItems: 50'. Every element is a separate call to the thunk.
363
+ */
364
+ const padToMinItems = (
365
+ root: unknown,
366
+ error: ValidationError,
367
+ base: string,
368
+ lookup: RepairLookup,
369
+ done: Set<string>,
370
+ inPlace: boolean,
371
+ ): unknown => {
372
+ const segments = repairTarget(error, base)
373
+ if (segments === null) return NOT_APPLIED
374
+ const array = valueAt(root, segments)
375
+ const limit = error.params['limit']
376
+ if (!Array.isArray(array) || typeof limit !== 'number' || array.length >= limit) return NOT_APPLIED
377
+
378
+ // Keyed apart from a position repair at the same path: replacing the whole
379
+ // array and padding it are different repairs.
380
+ const key = segments.join('/') + '#minItems'
381
+ if (done.has(key)) return NOT_APPLIED
382
+
383
+ const element = lookup(segments.concat(String(array.length)), 0, root)
384
+ if (typeof element !== 'function') return NOT_APPLIED
385
+
386
+ const padded = inPlace ? array : array.slice()
387
+ while (padded.length < limit) padded.push(element(0))
388
+ done.add(key)
389
+ return inPlace ? root : writeAt(root, segments, 0, padded, false)
390
+ }
391
+
392
+ /**
393
+ * Applies one round of repairs: every error the schema offers a value for is
394
+ * repaired, and the rest are handed back untouched.
395
+ *
396
+ * 'done' carries the positions already repaired on an earlier round. A position
397
+ * that failed again after being repaired is not repaired a second time \u2014 the
398
+ * value the schema offered did not satisfy the schema, which is a fact about the
399
+ * schema, and trying again would only produce the same value.
400
+ *
401
+ * Removals wait for the end of the round. Removing an array element moves every
402
+ * element after it, so taking one out while later errors still name the old
403
+ * indices would repair the wrong positions.
404
+ */
405
+ export const applyRepairs = (
406
+ value: unknown,
407
+ errors: readonly ValidationError[],
408
+ lookup: RepairLookup,
409
+ done: Set<string>,
410
+ inPlace = false,
411
+ base = '',
412
+ ): { value: unknown; repaired: ValidationError[] } => {
413
+ const repaired: ValidationError[] = []
414
+ const drops: { segments: string[]; error: ValidationError }[] = []
415
+ let next = value
416
+
417
+ for (const error of errors) {
418
+ if (error.keyword === 'minItems') {
419
+ const padded = padToMinItems(next, error, base, lookup, done, inPlace)
420
+ if (padded === NOT_APPLIED) continue
421
+ next = padded
422
+ repaired.push(error)
423
+ continue
424
+ }
425
+
426
+ const target = repairTarget(error, base)
427
+ if (target === null) continue
428
+ const key = target.join('/')
429
+ if (done.has(key)) continue
430
+ const answer = lookup(target, 0, next)
431
+ if (answer === null) continue
432
+
433
+ if (answer === REPAIR_DROP) {
434
+ // The root has no container to be removed from.
435
+ if (target.length === 0) continue
436
+ done.add(key)
437
+ drops.push({ segments: target, error })
438
+ continue
439
+ }
440
+
441
+ const updated = writeAt(next, target, 0, answer(0), inPlace)
442
+ // An unreachable position leaves the document exactly as it was. Not
443
+ // counting it as repaired is what lets the parent's own error report.
444
+ if (updated === NOT_APPLIED) continue
445
+ done.add(key)
446
+ next = updated
447
+ repaired.push(error)
448
+ }
449
+
450
+ drops.sort((a, b) => compareDrops(a.segments, b.segments))
451
+ for (const drop of drops) {
452
+ const updated = dropAt(next, drop.segments, 0, inPlace)
453
+ if (updated === NOT_APPLIED) continue
454
+ next = updated
455
+ repaired.push(drop.error)
456
+ // Removing an element moves every element after it, so a position repaired
457
+ // under that array on this or an earlier round may now hold another value.
458
+ if (Array.isArray(valueAt(next, drop.segments.slice(0, -1)))) {
459
+ const prefix = drop.segments.slice(0, -1).join('/') + '/'
460
+ for (const key of done) if (key.startsWith(prefix)) done.delete(key)
461
+ }
462
+ }
463
+
464
+ return { value: next, repaired }
465
+ }
466
+
467
+ /**
468
+ * The options only the walk reads. The loop refuses them rather than quietly
469
+ * ignoring a guard the caller is relying on \u2014 'plainObjects' to strip an own
470
+ * '__proto__', 'maxWork' to bound a hostile document.
471
+ */
472
+ const WALK_ONLY_OPTIONS = ['skipRefs', 'onRef', 'maxDepth', 'maxWork', 'memo', 'finite', 'plainObjects'] as const
473
+
474
+ /**
475
+ * Writes what a copy-on-write pass changed back into the value it copied from,
476
+ * and answers the value to carry on with: 'target' itself, unless the root was
477
+ * replaced outright. Unchanged subtrees are shared between the two, so only the
478
+ * containers the copy rebuilt are visited.
479
+ */
480
+ const adopt = (target: unknown, source: unknown, depth = 0): unknown => {
481
+ if (source === target) return target
482
+ if (depth > DEFAULT_MAX_DEPTH) return source
483
+ if (Array.isArray(target) && Array.isArray(source)) {
484
+ target.length = source.length
485
+ for (let i = 0; i < source.length; i++) target[i] = adopt(target[i], source[i], depth + 1)
486
+ return target
487
+ }
488
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
489
+ typeof value === 'object' && value !== null && !Array.isArray(value)
490
+ if (isRecord(target) && isRecord(source)) {
491
+ for (const key of Object.keys(source)) setOwn(target, key, adopt(target[key], source[key], depth + 1))
492
+ return target
493
+ }
494
+ return source
495
+ }
496
+
497
+ /**
498
+ * The validate-repair-revalidate loop behind a generated 'repairX' for any
499
+ * schema the single-pass walk does not cover.
500
+ *
501
+ * Coercion runs first, so anything that was only written in the wrong type is
502
+ * already right and never counts as a repair. What is left is judged by
503
+ * 'validateX', and *its errors* are what drive the repair: each one names a
504
+ * position, the lookup answers with a value for it, and the error the repair
505
+ * came from is what gets reported. Re-validating after a round catches what one
506
+ * pass cannot see; a position is repaired at most once, so the loop terminates
507
+ * on progress rather than on the pass cap.
508
+ */
509
+ export const runRepairLoop = <T>(
510
+ input: unknown,
511
+ options: RepairOptions | string | undefined,
512
+ validate: (value: unknown, path: string) => ValidationResult,
513
+ lookup: RepairLookup,
514
+ coerce: (value: unknown) => unknown,
515
+ ): RepairResult<T> => {
516
+ const settings: RepairOptions = typeof options === 'string' ? { path: options } : (options ?? {})
517
+ for (const name of WALK_ONLY_OPTIONS) {
518
+ const option = settings[name]
519
+ if (option !== undefined && option !== false) {
520
+ throw new TypeError(
521
+ "repairX: '" + name + "' needs the single-pass repair walk, and this schema uses a keyword the walk does " +
522
+ 'not repair, so it is repaired by the validate-and-repair loop instead. Leave the option out, or remove ' +
523
+ 'the keyword.',
524
+ )
525
+ }
526
+ }
527
+ const base = settings.path ?? ''
528
+ const inPlace = settings.inPlace === true
529
+ let value = input
530
+ if (settings.coerce !== false) {
531
+ const coerced = coerce(input)
532
+ // Coercion copies what it changes. In place, the changes are written back
533
+ // into the input, so the caller's document is the one that gets repaired.
534
+ value = inPlace ? adopt(input, coerced) : coerced
535
+ }
536
+ const repairs: ValidationError[] = []
537
+ const done = new Set<string>()
538
+
539
+ for (let pass = 0; pass < MAX_REPAIR_PASSES; pass++) {
540
+ const result = validate(value, base)
541
+ if (result === true) return { valid: true, value: value as T, repairs }
542
+
543
+ const applied = applyRepairs(value, result.errors, lookup, done, inPlace, base)
544
+ // Nothing moved, so nothing on a further pass would either.
545
+ if (applied.repaired.length === 0) return { valid: false, value, errors: result.errors, repairs }
546
+ value = applied.value
547
+ for (const repair of applied.repaired) repairs.push(repair)
548
+ }
549
+
550
+ const final = validate(value, base)
551
+ return final === true
552
+ ? { valid: true, value: value as T, repairs }
553
+ : { valid: false, value, errors: final.errors, repairs }
554
+ }
555
+
556
+ /**
557
+ * One position's walk: checks the value against its schema, repairs what it
558
+ * can, and answers the value the position should hold \u2014 the input itself when
559
+ * nothing changed, a replacement, or REPAIR_DROP when the parent should remove
560
+ * the position.
561
+ */
562
+ export type RepairWalk = (value: unknown, run: RepairRun) => unknown
563
+
564
+ /** A '$ref' target waiting to be walked under the type of the position that named it. */
565
+ type RefTask = { readonly ref: string; readonly walk: RepairWalk; readonly typeId: string }
566
+
567
+ /**
568
+ * Everything one walk shares: the options, the errors and repairs it collects,
569
+ * and where it is.
570
+ *
571
+ * 'path' is the stack of segments down to the position being walked. Nothing
572
+ * turns it into a string until a position is reported, so a valid document
573
+ * builds no path at all. 'errors' and 'repairs' are the arrays handed back in
574
+ * the result; every position pushes straight into them.
575
+ */
576
+ export type RepairRun = {
577
+ readonly base: string
578
+ path: (string | number)[]
579
+ /** Path length of the position the current walk started at, which has no container to be removed from. */
580
+ anchor: number
581
+ readonly errors: ValidationError[]
582
+ readonly repairs: ValidationError[]
583
+ readonly inPlace: boolean
584
+ readonly coerce: boolean
585
+ readonly finite: boolean
586
+ readonly plain: boolean
587
+ readonly maxDepth: number
588
+ readonly maxWork: number
589
+ work: number
590
+ /** Above zero while a value the walk built itself is checked: report, never repair. */
591
+ verify: number
592
+ /** How many positions this run has changed, coercions included. */
593
+ changed: number
594
+ halted: boolean
595
+ /** The document '$ref's resolve against: the input, then the repaired value. */
596
+ root: unknown
597
+ readonly refs: boolean
598
+ readonly skipRefs: readonly string[]
599
+ readonly onRef: RepairOptions['onRef']
600
+ readonly refQueue: RefTask[]
601
+ /**
602
+ * The lookups of the positions whose branches are being walked, with the
603
+ * path length of each. A branch that requires a key only its parent declares
604
+ * fills it through the parent's lookup.
605
+ */
606
+ readonly scopeDepth: number[]
607
+ readonly scopeLookup: RepairLookup[]
608
+ /** The targets queued so far, by the type they are queued under. */
609
+ readonly refQueued: Map<string, Set<string>>
610
+ /**
611
+ * In the check after a repair, the targets the repair itself queued: the check
612
+ * walks those and no others, so a '$ref' 'onRef' declined stays declined.
613
+ */
614
+ readonly refAllowed: Map<string, Set<string>> | null
615
+ readonly refWalked: Map<object, Set<string>>
616
+ readonly refResolvable: Map<string, boolean>
617
+ readonly memo: Map<object, Map<RepairWalk, unknown>> | null
618
+ }
619
+
620
+ /** The JSON Pointer of the position being walked, with one more segment when given. */
621
+ export const walkPath = (run: RepairRun, extra?: string): string => {
622
+ let out = run.base
623
+ const path = run.path
624
+ for (let i = 0; i < path.length; i++) {
625
+ const segment = path[i]
626
+ out += '/' + (typeof segment === 'number' ? String(segment) : escapePointer(segment as string))
627
+ }
628
+ return extra === undefined ? out : out + '/' + escapePointer(extra)
629
+ }
630
+
631
+ /** One error at the position being walked. Only ever called once something is wrong. */
632
+ export const walkError = (
633
+ run: RepairRun,
634
+ keyword: string,
635
+ message: string,
636
+ params: Record<string, unknown>,
637
+ segment?: string,
638
+ ): ValidationError => ({ message, path: walkPath(run, segment), keyword, params })
639
+
640
+ /**
641
+ * Repairs the position being walked, which failed the checks that produced
642
+ * 'found'.
643
+ *
644
+ * Coercion comes first, and a value it fixes is no repair at all. Then the
645
+ * lookup answers: a value is taken when it passes the position's own checks,
646
+ * and a removal when the position has a container to be removed from. Anything
647
+ * else leaves the value as it was and reports 'found' as errors.
648
+ */
649
+ export const walkRepair = (
650
+ run: RepairRun,
651
+ value: unknown,
652
+ found: readonly ValidationError[],
653
+ lookup: RepairLookup | null,
654
+ ok: (value: unknown, run: RepairRun) => boolean,
655
+ types: readonly string[] | null,
656
+ ): unknown => {
657
+ if (run.verify === 0) {
658
+ if (run.coerce && types !== null) {
659
+ const coerced = types.length === 1 ? coerceScalar(value, types[0] as string) : coerceUnion(value, types)
660
+ if (coerced !== value && ok(coerced, run)) {
661
+ run.changed++
662
+ return coerced
663
+ }
664
+ }
665
+ const answer = lookup === null ? null : lookup(NO_SEGMENTS, 0, value)
666
+ if (answer === REPAIR_DROP && run.path.length > run.anchor) {
667
+ for (const error of found) run.repairs.push(error)
668
+ run.changed++
669
+ return REPAIR_DROP
670
+ }
671
+ if (typeof answer === 'function') {
672
+ const next = answer(0)
673
+ if (ok(next, run)) {
674
+ for (const error of found) run.repairs.push(error)
675
+ run.changed++
676
+ return next
677
+ }
678
+ }
679
+ }
680
+ for (const error of found) run.errors.push(error)
681
+ return value
682
+ }
683
+
684
+ /**
685
+ * Walks a value the walk just built or coerced, reporting rather than repairing
686
+ * what is still wrong with it. A replacement is built from the schema, so this
687
+ * normally finds nothing; when it does, the schema could not describe a valid
688
+ * value, and saying so beats repairing the repair.
689
+ */
690
+ export const walkVerified = (run: RepairRun, value: unknown, walk: RepairWalk): unknown => {
691
+ run.verify++
692
+ const out = walk(value, run)
693
+ run.verify--
694
+ return out
695
+ }
696
+
697
+ /** Writes a walked child back: in place, or into the parent's copy, made on first write. */
698
+ export const walkSet = (
699
+ run: RepairRun,
700
+ target: Record<string, unknown>,
701
+ owned: boolean,
702
+ key: string,
703
+ next: unknown,
704
+ ): Record<string, unknown> => {
705
+ const out = owned || run.inPlace ? target : { ...target }
706
+ if (next === REPAIR_DROP) delete out[key]
707
+ else setOwn(out, key, next)
708
+ return out
709
+ }
710
+
711
+ /**
712
+ * Writes a walked element back. A removal is marked in place and taken out by
713
+ * 'walkCompact' once the loop is done, so the indices the rest of the loop
714
+ * reports stay the document's own.
715
+ */
716
+ export const walkSetItem = (run: RepairRun, target: unknown[], owned: boolean, index: number, next: unknown): unknown[] => {
717
+ const out = owned || run.inPlace ? target : target.slice()
718
+ out[index] = next
719
+ return out
720
+ }
721
+
722
+ /** Takes the elements marked for removal out of an array the walk owns. */
723
+ export const walkCompact = (target: unknown[]): unknown[] => {
724
+ let kept = 0
725
+ for (let i = 0; i < target.length; i++) {
726
+ const item = target[i]
727
+ if (item !== REPAIR_DROP) target[kept++] = item
728
+ }
729
+ target.length = kept
730
+ return target
731
+ }
732
+
733
+ /**
734
+ * A required key that is not there. It is filled through the declaring node's
735
+ * lookup, so a missing object arrives with its own required children already
736
+ * in it, and the new value is checked under its schema before it is kept.
737
+ */
738
+ export const walkMissing = (
739
+ run: RepairRun,
740
+ target: Record<string, unknown>,
741
+ owned: boolean,
742
+ key: string,
743
+ lookup: RepairLookup | null,
744
+ walk: RepairWalk | null,
745
+ ): Record<string, unknown> => {
746
+ const error: ValidationError = {
747
+ message: "must have required property '" + key + "'",
748
+ path: walkPath(run),
749
+ keyword: 'required',
750
+ params: { missingProperty: key },
751
+ }
752
+ if (run.verify === 0) {
753
+ let answer = lookup === null ? null : lookup([key], 0, target)
754
+ // A key a branch requires and only its parent declares: ask the parent.
755
+ for (let i = run.scopeDepth.length - 1; typeof answer !== 'function' && i >= 0; i--) {
756
+ if (run.scopeDepth[i] !== run.path.length) break
757
+ answer = (run.scopeLookup[i] as RepairLookup)([key], 0, target)
758
+ }
759
+ if (typeof answer === 'function') {
760
+ const value = answer(0)
761
+ if (value !== undefined) {
762
+ if (walk !== null) {
763
+ run.path.push(key)
764
+ walkVerified(run, value, walk)
765
+ run.path.pop()
766
+ }
767
+ run.repairs.push(error)
768
+ run.changed++
769
+ const out = owned || run.inPlace ? target : { ...target }
770
+ setOwn(out, key, value)
771
+ return out
772
+ }
773
+ }
774
+ }
775
+ run.errors.push(error)
776
+ return target
777
+ }
778
+
779
+ /** Puts a position's lookup in scope while its branches walk it. */
780
+ export const walkScope = (run: RepairRun, lookup: RepairLookup): void => {
781
+ run.scopeDepth.push(run.path.length)
782
+ run.scopeLookup.push(lookup)
783
+ }
784
+
785
+ /** Takes it out of scope again, answering the value the position holds. */
786
+ export const walkUnscope = (run: RepairRun, value: unknown): unknown => {
787
+ run.scopeDepth.pop()
788
+ run.scopeLookup.pop()
789
+ return value
790
+ }
791
+
792
+ /** A key 'additionalProperties: false' does not allow: reported, and removed. */
793
+ export const walkExtra = (
794
+ run: RepairRun,
795
+ target: Record<string, unknown>,
796
+ owned: boolean,
797
+ key: string,
798
+ ): Record<string, unknown> => {
799
+ const error = walkError(
800
+ run,
801
+ 'additionalProperties',
802
+ 'must NOT have additional properties',
803
+ { additionalProperty: key },
804
+ key,
805
+ )
806
+ if (run.verify > 0) {
807
+ run.errors.push(error)
808
+ return target
809
+ }
810
+ run.repairs.push(error)
811
+ run.changed++
812
+ return walkSet(run, target, owned, key, REPAIR_DROP)
813
+ }
814
+
815
+ /** True for an object a JSON parser could have produced. */
816
+ export const isPlainObject = (value: object): boolean => {
817
+ const proto = Object.getPrototypeOf(value)
818
+ return proto === Object.prototype || proto === null
819
+ }
820
+
821
+ /** An own '__proto__' key, under 'plainObjects': reported, and removed. */
822
+ export const walkProto = (run: RepairRun, target: Record<string, unknown>, owned: boolean): Record<string, unknown> => {
823
+ if (!Object.hasOwn(target, '__proto__')) return target
824
+ const error = walkError(run, 'plainObject', "must NOT have an own '__proto__' property", {}, '__proto__')
825
+ if (run.verify > 0) {
826
+ run.errors.push(error)
827
+ return target
828
+ }
829
+ run.repairs.push(error)
830
+ run.changed++
831
+ return walkSet(run, target, owned, '__proto__', REPAIR_DROP)
832
+ }
833
+
834
+ /**
835
+ * The position is past 'maxDepth', or the walk is past 'maxWork'. Either way it
836
+ * is reported and left as it is. The work budget is reported once: everything
837
+ * after it is unwalked rather than wrong.
838
+ */
839
+ export const walkHalt = (run: RepairRun, value: unknown): unknown => {
840
+ if (run.work > run.maxWork) {
841
+ if (!run.halted) {
842
+ run.halted = true
843
+ run.errors.push(
844
+ walkError(run, 'maxWork', 'exceeds the work budget of ' + run.maxWork + ' containers', { limit: run.maxWork }),
845
+ )
846
+ }
847
+ } else {
848
+ run.errors.push(walkError(run, 'maxDepth', 'exceeds the maximum depth of ' + run.maxDepth, { limit: run.maxDepth }))
849
+ }
850
+ return value
851
+ }
852
+
853
+ /**
854
+ * Walks an object at most once per schema under 'memo', handing back the first
855
+ * walk's result every other time. The entry is written before the walk, so a
856
+ * cycle meets itself and stops.
857
+ */
858
+ export const walkMemo = (run: RepairRun, value: unknown, body: RepairWalk): unknown => {
859
+ const memo = run.memo
860
+ if (memo === null || typeof value !== 'object' || value === null) return body(value, run)
861
+ let walked = memo.get(value)
862
+ if (walked === undefined) {
863
+ walked = new Map()
864
+ memo.set(value, walked)
865
+ }
866
+ if (walked.has(body)) return walked.get(body)
867
+ walked.set(body, value)
868
+ const out = body(value, run)
869
+ walked.set(body, out)
870
+ return out
871
+ }
872
+
873
+ /** What 'resolveSegments' answers for a pointer that leads nowhere. */
874
+ const UNRESOLVED: unique symbol = Symbol('mjst.repair.unresolved')
875
+
876
+ /** Decodes one reference token: percent-escapes (it is a URI fragment), then RFC 6901. */
877
+ const decodeToken = (token: string): string => {
878
+ let decoded = token
879
+ if (decoded.indexOf('%') !== -1) {
880
+ try {
881
+ decoded = decodeURIComponent(decoded)
882
+ } catch {
883
+ // A malformed escape is a literal '%' in a key, not an error.
884
+ }
885
+ }
886
+ return unescapeSegment(decoded)
887
+ }
888
+
889
+ /** The segments of a local '$ref' ('#/a/b'), or 'null' for one that is not a JSON Pointer. */
890
+ const refSegments = (ref: string): string[] | null => {
891
+ if (ref.length === 1) return []
892
+ if (ref.charCodeAt(1) !== 47) return null
893
+ const tokens = ref.slice(2).split('/')
894
+ for (let i = 0; i < tokens.length; i++) tokens[i] = decodeToken(tokens[i] as string)
895
+ return tokens
896
+ }
897
+
898
+ /** Follows segments through own properties and in-range indices only. */
899
+ const resolveSegments = (root: unknown, segments: readonly string[]): unknown => {
900
+ let node = root
901
+ for (const segment of segments) {
902
+ if (Array.isArray(node)) {
903
+ if (!isIndexSegment(segment) || Number(segment) >= node.length) return UNRESOLVED
904
+ node = node[Number(segment)]
905
+ } else if (typeof node === 'object' && node !== null && Object.hasOwn(node, segment)) {
906
+ node = (node as Record<string, unknown>)[segment]
907
+ } else {
908
+ return UNRESOLVED
909
+ }
910
+ }
911
+ return node
912
+ }
913
+
914
+ /**
915
+ * A Reference object at a position that opted in: its target is resolved
916
+ * against the document and queued to be walked under the position's type.
917
+ *
918
+ * Targets wait in a queue rather than being walked here, so a chain of
919
+ * references or a cycle between them is a loop over the queue rather than a
920
+ * recursion, and each target is walked once per type. A target that does not
921
+ * resolve is reported at the '$ref' that named it and never created: inventing
922
+ * the definition would hide the mistake.
923
+ */
924
+ export const walkRef = (run: RepairRun, value: unknown, walk: RepairWalk, typeId: string): void => {
925
+ if (!run.refs || typeof value !== 'object' || value === null || Array.isArray(value)) return
926
+ const holder = value as Record<string, unknown>
927
+ if (!Object.hasOwn(holder, '$ref')) return
928
+ const ref = holder['$ref']
929
+ // Only a local pointer resolves inside the document; anything else is the
930
+ // bundler's business.
931
+ if (typeof ref !== 'string' || ref.charCodeAt(0) !== 35) return
932
+ if (run.onRef !== undefined && run.onRef(holder, walkPath(run), typeId) === false) return
933
+ if (run.refAllowed !== null && run.refAllowed.get(typeId)?.has(ref) !== true) return
934
+
935
+ let resolvable = run.refResolvable.get(ref)
936
+ if (resolvable === undefined) {
937
+ const segments = refSegments(ref)
938
+ resolvable = segments !== null && resolveSegments(run.root, segments) !== UNRESOLVED
939
+ run.refResolvable.set(ref, resolvable)
940
+ }
941
+ if (!resolvable) {
942
+ run.errors.push(walkError(run, 'unresolvedRef', "can't resolve reference " + ref, { ref }, '$ref'))
943
+ return
944
+ }
945
+
946
+ for (const prefix of run.skipRefs) if (ref.startsWith(prefix)) return
947
+ // Keyed by type first, so a site whose target is already queued costs two
948
+ // lookups and builds nothing.
949
+ let queued = run.refQueued.get(typeId)
950
+ if (queued === undefined) {
951
+ queued = new Set()
952
+ run.refQueued.set(typeId, queued)
953
+ }
954
+ if (queued.has(ref)) return
955
+ queued.add(ref)
956
+ run.refQueue.push({ ref, walk, typeId })
957
+ }
958
+
959
+ /** Walks every queued '$ref' target, including the ones walking them queues. */
960
+ const walkRefTargets = (run: RepairRun): void => {
961
+ for (let next = 0; next < run.refQueue.length && !run.halted; next++) {
962
+ const task = run.refQueue[next] as RefTask
963
+ const segments = refSegments(task.ref)
964
+ if (segments === null) continue
965
+ const target = resolveSegments(run.root, segments)
966
+ // It resolved where it was named; a repair since then removed it.
967
+ if (target === UNRESOLVED) continue
968
+ if (typeof target === 'object' && target !== null) {
969
+ let types = run.refWalked.get(target)
970
+ if (types === undefined) {
971
+ types = new Set()
972
+ run.refWalked.set(target, types)
973
+ }
974
+ if (types.has(task.typeId)) continue
975
+ types.add(task.typeId)
976
+ }
977
+ run.path = segments
978
+ run.anchor = segments.length
979
+ const out = task.walk(target, run)
980
+ if (out !== target && out !== REPAIR_DROP) {
981
+ const updated = writeAt(run.root, segments, 0, out, run.inPlace)
982
+ if (updated !== NOT_APPLIED) run.root = updated
983
+ }
984
+ }
985
+ }
986
+
987
+ /** A run over 'root' with the given settings; 'verify' 1 is a check that repairs nothing. */
988
+ const createRun = (
989
+ settings: RepairOptions,
990
+ root: unknown,
991
+ verify: number,
992
+ refAllowed: Map<string, Set<string>> | null = null,
993
+ ): RepairRun => ({
994
+ base: settings.path ?? '',
995
+ path: [],
996
+ anchor: 0,
997
+ errors: [],
998
+ repairs: [],
999
+ inPlace: settings.inPlace === true,
1000
+ coerce: settings.coerce !== false,
1001
+ finite: settings.finite === true,
1002
+ plain: settings.plainObjects === true,
1003
+ maxDepth: settings.maxDepth ?? DEFAULT_MAX_DEPTH,
1004
+ maxWork: settings.maxWork ?? DEFAULT_MAX_WORK,
1005
+ work: 0,
1006
+ verify,
1007
+ changed: 0,
1008
+ halted: false,
1009
+ root,
1010
+ refs: settings.refs !== false,
1011
+ skipRefs: settings.skipRefs ?? [],
1012
+ onRef: verify === 0 ? settings.onRef : undefined,
1013
+ refQueue: [],
1014
+ scopeDepth: [],
1015
+ scopeLookup: [],
1016
+ refQueued: new Map(),
1017
+ refAllowed,
1018
+ refWalked: new Map(),
1019
+ refResolvable: new Map(),
1020
+ memo: settings.memo === true ? new Map() : null,
1021
+ })
1022
+
1023
+ /**
1024
+ * The single-pass repair behind a generated 'repairX' whose schema the walk
1025
+ * covers: one walk over the document that checks every position and repairs
1026
+ * the ones that fail, then one walk over each opted-in '$ref' target.
1027
+ *
1028
+ * A valid document is walked and handed back untouched: nothing is copied, no
1029
+ * path is built and no error object exists, so the only allocation is the run
1030
+ * itself.
1031
+ *
1032
+ * When anything was changed, the result is walked once more, checking only.
1033
+ * Every replacement was already checked where it was made, but a position can
1034
+ * answer to several subschemas at once \u2014 'allOf' entries, overlapping patterns,
1035
+ * a '$ref' beside its siblings \u2014 and a repair made for one could break another.
1036
+ * 'valid: true' is a promise about the value handed back, so it is checked
1037
+ * rather than assumed; whatever that finds is reported, not repaired again.
1038
+ */
1039
+ export const runRepairWalk = <T>(
1040
+ input: unknown,
1041
+ options: RepairOptions | string | undefined,
1042
+ walk: RepairWalk,
1043
+ ): RepairResult<T> => {
1044
+ const settings: RepairOptions = typeof options === 'string' ? { path: options } : (options ?? {})
1045
+ const run = createRun(settings, input, 0)
1046
+
1047
+ const out = walk(input, run)
1048
+ run.root = out === REPAIR_DROP ? input : out
1049
+ walkRefTargets(run)
1050
+ const value = run.root
1051
+ const errors = run.errors
1052
+
1053
+ if (run.changed > 0 && !run.halted) {
1054
+ const check = createRun(settings, value, 1, run.refQueued)
1055
+ walk(value, check)
1056
+ walkRefTargets(check)
1057
+ if (check.errors.length > 0) {
1058
+ const known = new Set<string>()
1059
+ for (const error of errors) known.add(error.keyword + ' ' + error.path)
1060
+ for (const error of check.errors) if (!known.has(error.keyword + ' ' + error.path)) errors.push(error)
1061
+ }
1062
+ }
1063
+
1064
+ return errors.length === 0
1065
+ ? { valid: true, value: value as T, repairs: run.repairs }
1066
+ : { valid: false, value, errors, repairs: run.repairs }
1067
+ }
1068
+ `;
1069
+ export {
1070
+ REPAIR_RUNTIME_CONTENT
1071
+ };