@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.
- package/AI.md +11 -0
- package/README.md +50 -3
- package/dist/validators/generators/build-schema.d.ts +1 -1
- package/dist/validators/generators/build-schema.js +32 -217
- package/dist/validators/generators/collect-validator-imports.d.ts +7 -0
- package/dist/validators/generators/collect-validator-imports.js +6 -3
- package/dist/validators/generators/generate-files.d.ts +6 -0
- package/dist/validators/generators/generate-files.js +34 -9
- package/dist/validators/generators/generate-repair-function.d.ts +13 -17
- package/dist/validators/generators/generate-repair-function.js +224 -118
- package/dist/validators/generators/generate-repair-walk.d.ts +24 -0
- package/dist/validators/generators/generate-repair-walk.js +335 -0
- package/dist/validators/generators/generate-validator-function.d.ts +19 -1
- package/dist/validators/generators/generate-validator-function.js +53 -18
- package/dist/validators/generators/repair-context.d.ts +39 -0
- package/dist/validators/generators/repair-context.js +35 -0
- package/dist/validators/generators/repair-runtime.d.ts +25 -0
- package/dist/validators/generators/repair-runtime.js +1071 -0
- package/dist/validators/generators/repair-walkable.d.ts +33 -0
- package/dist/validators/generators/repair-walkable.js +148 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|