@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,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The repairing half of the runtime every generated validator file imports from
|
|
3
|
+
* `validation-result.ts`, appended to {@link VALIDATION_RESULT_CONTENT} in
|
|
4
|
+
* `build-schema.ts`.
|
|
5
|
+
*
|
|
6
|
+
* It lives in a file of its own because it is most of that module by now, and
|
|
7
|
+
* because it holds two engines that share one vocabulary:
|
|
8
|
+
*
|
|
9
|
+
* - the **loop** (`runRepairLoop`): validate, repair every reported position
|
|
10
|
+
* through the `repairXAt` lookup, validate again. It works for any schema the
|
|
11
|
+
* validator can emit, because it never needs to understand one — it only
|
|
12
|
+
* reads `validateX`'s errors.
|
|
13
|
+
* - the **walk** (`runRepairWalk`): one pass that checks each position and
|
|
14
|
+
* repairs it on the spot. It is emitted only for schemas whose keywords it
|
|
15
|
+
* understands (see `repair-walkable.ts`), and it is what makes a 24 MB
|
|
16
|
+
* document a few hundred milliseconds of work instead of several full
|
|
17
|
+
* validations.
|
|
18
|
+
*
|
|
19
|
+
* Both ask the very same lookup what a position repairs to, so they cannot land
|
|
20
|
+
* on different values.
|
|
21
|
+
*
|
|
22
|
+
* The text is a plain template, so it carries no backticks and no interpolation:
|
|
23
|
+
* the comments quote code with single quotes instead.
|
|
24
|
+
*/
|
|
25
|
+
export declare const REPAIR_RUNTIME_CONTENT = "/**\n * The result of a generated repairing validator.\n *\n * 'repairs' is not a second, parallel account of what went wrong \u2014 it *is* the\n * errors the validator produced, the ones a repair was found for. So a caller\n * that logs a repair logs the same path, keyword and params it would have been\n * rejected with, and the two can never drift apart, because there is only one of\n * them.\n *\n * A document that needed no repair comes back 'valid: true' with an empty\n * 'repairs'. One that was fully repaired is 'valid: true' with a non-empty\n * 'repairs' \u2014 the caller decides whether that is acceptable by looking, rather\n * than by being told. One that could not be fully repaired is 'valid: false' and\n * carries both: the repairs that were applied, and the 'errors' still\n * outstanding against the value handed back.\n */\nexport type RepairResult<T> =\n | { valid: true; value: T; repairs: ValidationError[] }\n | { valid: false; value: unknown; errors: ValidationError[]; repairs: ValidationError[] }\n\n/**\n * What a lookup answers for a position that should be removed rather than\n * replaced: an '\"x-repair\": \"drop\"' schema, or a 'false' one. A symbol, so no\n * value a document can hold is ever mistaken for it.\n */\nexport const REPAIR_DROP: unique symbol = Symbol('mjst.repair.drop')\n\n/**\n * Builds the value a position repairs to. Called once per repair, so no two\n * repaired positions ever share an object.\n *\n * 'depth' is how many required levels have been filled above this one: a\n * missing required object is built with its own required children in one step,\n * and the depth is what keeps a recursive definition from building forever.\n * 'into' is an object being filled already: a conditional branch adds its\n * required keys to it instead of building a separate value.\n */\nexport type RepairThunk = (depth: number, into?: Record<string, unknown>) => unknown\n\n/** A lookup's answer: build a value, remove the position, or nothing to offer. */\nexport type RepairAnswer = RepairThunk | typeof REPAIR_DROP | null\n\n/**\n * Looks up what the position at 'segments' repairs to, starting at\n * 'segments[index]', with 'value' the instance at the node the lookup belongs\n * to. The instance is what an 'if' is evaluated against, so a lookup under an\n * if/then/else answers from the branch the document actually takes.\n */\nexport type RepairLookup = (segments: readonly string[], index: number, value: unknown) => RepairAnswer\n\n/** The segments of the position a lookup is asked about when it is the node itself. */\nexport const NO_SEGMENTS: readonly string[] = []\n\n/**\n * How many required levels one repair fills. Deep enough for any document a\n * person writes by hand, and a hard stop for a definition that requires itself.\n */\nexport const MAX_FILL_DEPTH = 16\n\n/**\n * How many validate-and-repair rounds the loop will run. Each round is bounded\n * further by the rule that a position is repaired at most once, so this is a\n * backstop rather than the thing doing the terminating.\n */\nexport const MAX_REPAIR_PASSES = 8\n\n/** How deep a walk descends before it reports the position instead. */\nconst DEFAULT_MAX_DEPTH = 512\n\n/**\n * How many containers a walk visits before it stops. Forty times what a 24 MB\n * OpenAPI document holds, so no real document comes near it \u2014 it is there for\n * an in-memory graph whose shared or cyclic subtrees fan out, which a depth cap\n * alone lets grow exponentially.\n */\nconst DEFAULT_MAX_WORK = 10_000_000\n\n/**\n * What a call to 'repairX' may do to the document, and how hard it looks.\n *\n * The first three apply to every generated repairer. The rest are read by the\n * single-pass walk, which is emitted for schemas built from the keywords it\n * understands; the loop that repairs everything else ignores them.\n */\nexport type RepairOptions = {\n /** JSON Pointer every reported path is rooted at. Defaults to the document root, ''. */\n readonly path?: string\n /**\n * Assign and delete on the input itself instead of copying the containers a\n * repair touches. Off by default: a caller who compares the repaired document\n * against what was sent needs the original to survive.\n */\n readonly inPlace?: boolean\n /**\n * Move a scalar written in the wrong type toward the declared one before\n * repairing it ('\"5\"' to '5' under 'type: integer'). On by default. Off, a\n * wrong-typed value is a reported position like any other and is repaired to\n * the value the schema supplies.\n */\n readonly coerce?: boolean\n /**\n * Follow instance '$ref's at positions whose Reference schema opts in with\n * '\"x-repair\": \"ref\"', validating and repairing each target under the type of\n * the position that referenced it. On by default; it only does anything for a\n * schema that opted in.\n */\n readonly refs?: boolean\n /**\n * Pointer prefixes ('#/components/') whose targets are not walked again. Use\n * it for the parts of the document the root schema already covers. A skipped\n * target is still resolved, so a dangling '$ref' into it is still reported.\n */\n readonly skipRefs?: readonly string[]\n /**\n * Called for every opted-in '$ref', with the Reference object, its path and\n * the id of the position's type. Return 'false' to leave that one alone.\n */\n readonly onRef?: (value: Record<string, unknown>, path: string, typeId: string) => boolean | void\n /** How deep the walk descends before it reports 'maxDepth' instead. Defaults to 512. */\n readonly maxDepth?: number\n /** How many containers the walk visits before it stops with 'maxWork'. Defaults to ten million. */\n readonly maxWork?: number\n /**\n * Walk each object at most once per schema, and hand back the same result\n * every other time it is reached. Off by default, because it costs a map entry\n * per object; turn it on for in-memory graphs that share subtrees or hold\n * cycles, which a parsed JSON document never does.\n */\n readonly memo?: boolean\n /** Reject 'NaN' and the infinities where a number is declared. JSON cannot hold them. */\n readonly finite?: boolean\n /**\n * Accept only plain objects (the prototype is 'Object.prototype' or 'null')\n * where an object is declared, and remove an own '__proto__' key \u2014 the one\n * 'JSON.parse' creates and a later 'Object.assign' would turn into a prototype.\n */\n readonly plainObjects?: boolean\n}\n\n/** The own property or array element at one pointer segment, or 'undefined'. */\nexport const childAt = (value: unknown, segment: string): unknown => {\n if (Array.isArray(value)) {\n const index = Number(segment)\n return Number.isInteger(index) && index >= 0 ? value[index] : undefined\n }\n if (typeof value === 'object' && value !== null && Object.hasOwn(value, segment)) {\n return (value as Record<string, unknown>)[segment]\n }\n return undefined\n}\n\n/**\n * Writes an own property. A plain assignment to '__proto__' replaces the\n * object's prototype instead, so that one key is defined rather than assigned.\n */\nexport const setOwn = (target: Record<string, unknown>, key: string, value: unknown): void => {\n if (key === '__proto__') {\n Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true })\n } else {\n target[key] = value\n }\n}\n\n/**\n * How many keys one repair fills, across every level of it. The depth cap alone\n * bounds a definition that requires itself once, but one that requires itself\n * at three keys builds three to the sixteenth objects inside it.\n */\nconst MAX_FILL_KEYS = 10_000\n\n/** What is left of {@link MAX_FILL_KEYS} for the repair being built. */\nlet fillBudget = MAX_FILL_KEYS\n\n/**\n * Starts the budget for a new repair. A generated fill calls it when it is the\n * outermost one \u2014 depth zero, building rather than filling into an object.\n */\nexport const fillBegin = (): void => {\n fillBudget = MAX_FILL_KEYS\n}\n\n/**\n * Fills one required key of an object being built, through the lookup of the\n * node that declares it. Asking the node rather than the property is what lets a\n * key only an 'additionalProperties' schema describes, or one only a\n * conditional branch declares, be filled the same way.\n */\nexport const fillKey = (target: Record<string, unknown>, key: string, lookup: RepairLookup, depth: number): void => {\n if (Object.hasOwn(target, key) || fillBudget <= 0) return\n fillBudget--\n const answer = lookup([key], 0, target)\n if (typeof answer !== 'function') return\n const value = answer(depth + 1)\n if (value !== undefined) setOwn(target, key, value)\n}\n\n/** Lets a branch's answer add what it requires to an object being filled. */\nexport const fillInto = (target: Record<string, unknown>, answer: RepairAnswer, depth: number): void => {\n if (typeof answer === 'function') answer(depth, target)\n}\n\n/**\n * Splits a JSON Pointer into its segments, undoing the escaping\n * 'escapePointer' applied. The root pointer ('') is zero segments.\n */\nexport const pointerSegments = (path: string): string[] => {\n if (path === '') return []\n const segments = path.slice(1).split('/')\n for (let i = 0; i < segments.length; i++) segments[i] = unescapeSegment(segments[i] as string)\n return segments\n}\n\n/** RFC 6901 decoding: '~1' to '/' first, then '~0' to '~'. */\nconst unescapeSegment = (segment: string): string =>\n segment.indexOf('~') === -1 ? segment : segment.split('~1').join('/').split('~0').join('~')\n\n/** What 'writeAt' and 'dropAt' answer when the position was not there to change. */\nconst NOT_APPLIED: unique symbol = Symbol('mjst.repair.not-applied')\n\n/** True for the canonical decimal spelling of an array index. */\nconst isIndexSegment = (segment: string): boolean => {\n if (segment === '0') return true\n const first = segment.charCodeAt(0)\n if (!(first >= 49 && first <= 57)) return false\n for (let i = 1; i < segment.length; i++) {\n const c = segment.charCodeAt(i)\n if (c < 48 || c > 57) return false\n }\n return true\n}\n\n/**\n * Returns 'root' with the position at 'segments' replaced by 'value'.\n *\n * Copy-on-write by default: only the containers along the way are copied, so a\n * repaired document shares everything the repair did not touch and the input\n * survives. In place, the containers are assigned to directly.\n *\n * Answers NOT_APPLIED when the path does not lead anywhere \u2014 a segment into a\n * missing or non-container parent \u2014 or when the position already holds exactly\n * that value. Neither is a failure to handle here: the parent has an error of\n * its own, and repairing *it* is what makes this position reachable.\n */\nconst writeAt = (\n root: unknown,\n segments: readonly string[],\n index: number,\n value: unknown,\n inPlace: boolean,\n): unknown => {\n if (index === segments.length) return Object.is(root, value) ? NOT_APPLIED : value\n const head = segments[index] as string\n\n if (Array.isArray(root)) {\n const position = Number(head)\n // 'position === root.length' is an append, which is what padding a short\n // array is made of. Anything past that would leave a hole.\n if (!Number.isInteger(position) || position < 0 || position > root.length) return NOT_APPLIED\n if (position === root.length && index + 1 < segments.length) return NOT_APPLIED\n const next = writeAt(root[position], segments, index + 1, value, inPlace)\n if (next === NOT_APPLIED) return NOT_APPLIED\n const target = inPlace ? root : root.slice()\n target[position] = next\n return target\n }\n\n if (typeof root !== 'object' || root === null) return NOT_APPLIED\n const obj = root as Record<string, unknown>\n const own = Object.hasOwn(obj, head)\n // A missing key is reachable only when this is the last segment: that is the\n // 'required' repair, which is putting the key there.\n if (!own && index + 1 < segments.length) return NOT_APPLIED\n const next = writeAt(own ? obj[head] : undefined, segments, index + 1, value, inPlace)\n if (next === NOT_APPLIED) return NOT_APPLIED\n const target = inPlace ? obj : { ...obj }\n setOwn(target, head, next)\n return target\n}\n\n/** Returns 'root' without the position at 'segments', or NOT_APPLIED. */\nconst dropAt = (root: unknown, segments: readonly string[], index: number, inPlace: boolean): unknown => {\n const head = segments[index] as string\n const last = index === segments.length - 1\n\n if (Array.isArray(root)) {\n const position = Number(head)\n if (!Number.isInteger(position) || position < 0 || position >= root.length) return NOT_APPLIED\n const target = inPlace ? root : root.slice()\n if (last) {\n target.splice(position, 1)\n return target\n }\n const next = dropAt(root[position], segments, index + 1, inPlace)\n if (next === NOT_APPLIED) return NOT_APPLIED\n target[position] = next\n return target\n }\n\n if (typeof root !== 'object' || root === null || !Object.hasOwn(root, head)) return NOT_APPLIED\n const obj = root as Record<string, unknown>\n if (last) {\n const target = inPlace ? obj : { ...obj }\n delete target[head]\n return target\n }\n const next = dropAt(obj[head], segments, index + 1, inPlace)\n if (next === NOT_APPLIED) return NOT_APPLIED\n const target = inPlace ? obj : { ...obj }\n setOwn(target, head, next)\n return target\n}\n\n/**\n * Orders removals so that each one leaves the positions of the rest intact:\n * the later index in an array first, and a deeper position before the\n * container holding it.\n */\nconst compareDrops = (a: readonly string[], b: readonly string[]): number => {\n const shared = Math.min(a.length, b.length)\n for (let i = 0; i < shared; i++) {\n const x = a[i] as string\n const y = b[i] as string\n if (x === y) continue\n if (isIndexSegment(x) && isIndexSegment(y)) return Number(y) - Number(x)\n return x < y ? 1 : -1\n }\n return b.length - a.length\n}\n\n/**\n * The position one error is asking to have repaired, or 'null' when the error\n * is not about a position a value can be put at.\n *\n * A 'required' error is reported against the *object* that is missing the key,\n * and names the key in 'params.missingProperty', so the position to fill is one\n * segment deeper. 'minItems' is handled separately, because it is satisfied by\n * adding several values rather than by replacing one.\n */\nconst repairTarget = (error: ValidationError, base: string): string[] | null => {\n if (error.path.slice(0, base.length) !== base) return null\n const segments = pointerSegments(error.path.slice(base.length))\n if (error.keyword === 'required') {\n const missing = error.params['missingProperty']\n if (typeof missing !== 'string') return null\n segments.push(missing)\n }\n return segments\n}\n\n/** The value at a pointer, or 'undefined' when the path does not lead anywhere. */\nconst valueAt = (root: unknown, segments: readonly string[]): unknown => {\n let node = root\n for (const segment of segments) node = childAt(node, segment)\n return node\n}\n\n/**\n * Grows a short array to the length 'minItems' asks for, or NOT_APPLIED.\n *\n * One 'minItems' error is satisfied by *several* values, so the shortfall is\n * filled at once rather than an element per round, which would run out of\n * rounds on a 'minItems: 50'. Every element is a separate call to the thunk.\n */\nconst padToMinItems = (\n root: unknown,\n error: ValidationError,\n base: string,\n lookup: RepairLookup,\n done: Set<string>,\n inPlace: boolean,\n): unknown => {\n const segments = repairTarget(error, base)\n if (segments === null) return NOT_APPLIED\n const array = valueAt(root, segments)\n const limit = error.params['limit']\n if (!Array.isArray(array) || typeof limit !== 'number' || array.length >= limit) return NOT_APPLIED\n\n // Keyed apart from a position repair at the same path: replacing the whole\n // array and padding it are different repairs.\n const key = segments.join('/') + '#minItems'\n if (done.has(key)) return NOT_APPLIED\n\n const element = lookup(segments.concat(String(array.length)), 0, root)\n if (typeof element !== 'function') return NOT_APPLIED\n\n const padded = inPlace ? array : array.slice()\n while (padded.length < limit) padded.push(element(0))\n done.add(key)\n return inPlace ? root : writeAt(root, segments, 0, padded, false)\n}\n\n/**\n * Applies one round of repairs: every error the schema offers a value for is\n * repaired, and the rest are handed back untouched.\n *\n * 'done' carries the positions already repaired on an earlier round. A position\n * that failed again after being repaired is not repaired a second time \u2014 the\n * value the schema offered did not satisfy the schema, which is a fact about the\n * schema, and trying again would only produce the same value.\n *\n * Removals wait for the end of the round. Removing an array element moves every\n * element after it, so taking one out while later errors still name the old\n * indices would repair the wrong positions.\n */\nexport const applyRepairs = (\n value: unknown,\n errors: readonly ValidationError[],\n lookup: RepairLookup,\n done: Set<string>,\n inPlace = false,\n base = '',\n): { value: unknown; repaired: ValidationError[] } => {\n const repaired: ValidationError[] = []\n const drops: { segments: string[]; error: ValidationError }[] = []\n let next = value\n\n for (const error of errors) {\n if (error.keyword === 'minItems') {\n const padded = padToMinItems(next, error, base, lookup, done, inPlace)\n if (padded === NOT_APPLIED) continue\n next = padded\n repaired.push(error)\n continue\n }\n\n const target = repairTarget(error, base)\n if (target === null) continue\n const key = target.join('/')\n if (done.has(key)) continue\n const answer = lookup(target, 0, next)\n if (answer === null) continue\n\n if (answer === REPAIR_DROP) {\n // The root has no container to be removed from.\n if (target.length === 0) continue\n done.add(key)\n drops.push({ segments: target, error })\n continue\n }\n\n const updated = writeAt(next, target, 0, answer(0), inPlace)\n // An unreachable position leaves the document exactly as it was. Not\n // counting it as repaired is what lets the parent's own error report.\n if (updated === NOT_APPLIED) continue\n done.add(key)\n next = updated\n repaired.push(error)\n }\n\n drops.sort((a, b) => compareDrops(a.segments, b.segments))\n for (const drop of drops) {\n const updated = dropAt(next, drop.segments, 0, inPlace)\n if (updated === NOT_APPLIED) continue\n next = updated\n repaired.push(drop.error)\n // Removing an element moves every element after it, so a position repaired\n // under that array on this or an earlier round may now hold another value.\n if (Array.isArray(valueAt(next, drop.segments.slice(0, -1)))) {\n const prefix = drop.segments.slice(0, -1).join('/') + '/'\n for (const key of done) if (key.startsWith(prefix)) done.delete(key)\n }\n }\n\n return { value: next, repaired }\n}\n\n/**\n * The options only the walk reads. The loop refuses them rather than quietly\n * ignoring a guard the caller is relying on \u2014 'plainObjects' to strip an own\n * '__proto__', 'maxWork' to bound a hostile document.\n */\nconst WALK_ONLY_OPTIONS = ['skipRefs', 'onRef', 'maxDepth', 'maxWork', 'memo', 'finite', 'plainObjects'] as const\n\n/**\n * Writes what a copy-on-write pass changed back into the value it copied from,\n * and answers the value to carry on with: 'target' itself, unless the root was\n * replaced outright. Unchanged subtrees are shared between the two, so only the\n * containers the copy rebuilt are visited.\n */\nconst adopt = (target: unknown, source: unknown, depth = 0): unknown => {\n if (source === target) return target\n if (depth > DEFAULT_MAX_DEPTH) return source\n if (Array.isArray(target) && Array.isArray(source)) {\n target.length = source.length\n for (let i = 0; i < source.length; i++) target[i] = adopt(target[i], source[i], depth + 1)\n return target\n }\n const isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value)\n if (isRecord(target) && isRecord(source)) {\n for (const key of Object.keys(source)) setOwn(target, key, adopt(target[key], source[key], depth + 1))\n return target\n }\n return source\n}\n\n/**\n * The validate-repair-revalidate loop behind a generated 'repairX' for any\n * schema the single-pass walk does not cover.\n *\n * Coercion runs first, so anything that was only written in the wrong type is\n * already right and never counts as a repair. What is left is judged by\n * 'validateX', and *its errors* are what drive the repair: each one names a\n * position, the lookup answers with a value for it, and the error the repair\n * came from is what gets reported. Re-validating after a round catches what one\n * pass cannot see; a position is repaired at most once, so the loop terminates\n * on progress rather than on the pass cap.\n */\nexport const runRepairLoop = <T>(\n input: unknown,\n options: RepairOptions | string | undefined,\n validate: (value: unknown, path: string) => ValidationResult,\n lookup: RepairLookup,\n coerce: (value: unknown) => unknown,\n): RepairResult<T> => {\n const settings: RepairOptions = typeof options === 'string' ? { path: options } : (options ?? {})\n for (const name of WALK_ONLY_OPTIONS) {\n const option = settings[name]\n if (option !== undefined && option !== false) {\n throw new TypeError(\n \"repairX: '\" + name + \"' needs the single-pass repair walk, and this schema uses a keyword the walk does \" +\n 'not repair, so it is repaired by the validate-and-repair loop instead. Leave the option out, or remove ' +\n 'the keyword.',\n )\n }\n }\n const base = settings.path ?? ''\n const inPlace = settings.inPlace === true\n let value = input\n if (settings.coerce !== false) {\n const coerced = coerce(input)\n // Coercion copies what it changes. In place, the changes are written back\n // into the input, so the caller's document is the one that gets repaired.\n value = inPlace ? adopt(input, coerced) : coerced\n }\n const repairs: ValidationError[] = []\n const done = new Set<string>()\n\n for (let pass = 0; pass < MAX_REPAIR_PASSES; pass++) {\n const result = validate(value, base)\n if (result === true) return { valid: true, value: value as T, repairs }\n\n const applied = applyRepairs(value, result.errors, lookup, done, inPlace, base)\n // Nothing moved, so nothing on a further pass would either.\n if (applied.repaired.length === 0) return { valid: false, value, errors: result.errors, repairs }\n value = applied.value\n for (const repair of applied.repaired) repairs.push(repair)\n }\n\n const final = validate(value, base)\n return final === true\n ? { valid: true, value: value as T, repairs }\n : { valid: false, value, errors: final.errors, repairs }\n}\n\n/**\n * One position's walk: checks the value against its schema, repairs what it\n * can, and answers the value the position should hold \u2014 the input itself when\n * nothing changed, a replacement, or REPAIR_DROP when the parent should remove\n * the position.\n */\nexport type RepairWalk = (value: unknown, run: RepairRun) => unknown\n\n/** A '$ref' target waiting to be walked under the type of the position that named it. */\ntype RefTask = { readonly ref: string; readonly walk: RepairWalk; readonly typeId: string }\n\n/**\n * Everything one walk shares: the options, the errors and repairs it collects,\n * and where it is.\n *\n * 'path' is the stack of segments down to the position being walked. Nothing\n * turns it into a string until a position is reported, so a valid document\n * builds no path at all. 'errors' and 'repairs' are the arrays handed back in\n * the result; every position pushes straight into them.\n */\nexport type RepairRun = {\n readonly base: string\n path: (string | number)[]\n /** Path length of the position the current walk started at, which has no container to be removed from. */\n anchor: number\n readonly errors: ValidationError[]\n readonly repairs: ValidationError[]\n readonly inPlace: boolean\n readonly coerce: boolean\n readonly finite: boolean\n readonly plain: boolean\n readonly maxDepth: number\n readonly maxWork: number\n work: number\n /** Above zero while a value the walk built itself is checked: report, never repair. */\n verify: number\n /** How many positions this run has changed, coercions included. */\n changed: number\n halted: boolean\n /** The document '$ref's resolve against: the input, then the repaired value. */\n root: unknown\n readonly refs: boolean\n readonly skipRefs: readonly string[]\n readonly onRef: RepairOptions['onRef']\n readonly refQueue: RefTask[]\n /**\n * The lookups of the positions whose branches are being walked, with the\n * path length of each. A branch that requires a key only its parent declares\n * fills it through the parent's lookup.\n */\n readonly scopeDepth: number[]\n readonly scopeLookup: RepairLookup[]\n /** The targets queued so far, by the type they are queued under. */\n readonly refQueued: Map<string, Set<string>>\n /**\n * In the check after a repair, the targets the repair itself queued: the check\n * walks those and no others, so a '$ref' 'onRef' declined stays declined.\n */\n readonly refAllowed: Map<string, Set<string>> | null\n readonly refWalked: Map<object, Set<string>>\n readonly refResolvable: Map<string, boolean>\n readonly memo: Map<object, Map<RepairWalk, unknown>> | null\n}\n\n/** The JSON Pointer of the position being walked, with one more segment when given. */\nexport const walkPath = (run: RepairRun, extra?: string): string => {\n let out = run.base\n const path = run.path\n for (let i = 0; i < path.length; i++) {\n const segment = path[i]\n out += '/' + (typeof segment === 'number' ? String(segment) : escapePointer(segment as string))\n }\n return extra === undefined ? out : out + '/' + escapePointer(extra)\n}\n\n/** One error at the position being walked. Only ever called once something is wrong. */\nexport const walkError = (\n run: RepairRun,\n keyword: string,\n message: string,\n params: Record<string, unknown>,\n segment?: string,\n): ValidationError => ({ message, path: walkPath(run, segment), keyword, params })\n\n/**\n * Repairs the position being walked, which failed the checks that produced\n * 'found'.\n *\n * Coercion comes first, and a value it fixes is no repair at all. Then the\n * lookup answers: a value is taken when it passes the position's own checks,\n * and a removal when the position has a container to be removed from. Anything\n * else leaves the value as it was and reports 'found' as errors.\n */\nexport const walkRepair = (\n run: RepairRun,\n value: unknown,\n found: readonly ValidationError[],\n lookup: RepairLookup | null,\n ok: (value: unknown, run: RepairRun) => boolean,\n types: readonly string[] | null,\n): unknown => {\n if (run.verify === 0) {\n if (run.coerce && types !== null) {\n const coerced = types.length === 1 ? coerceScalar(value, types[0] as string) : coerceUnion(value, types)\n if (coerced !== value && ok(coerced, run)) {\n run.changed++\n return coerced\n }\n }\n const answer = lookup === null ? null : lookup(NO_SEGMENTS, 0, value)\n if (answer === REPAIR_DROP && run.path.length > run.anchor) {\n for (const error of found) run.repairs.push(error)\n run.changed++\n return REPAIR_DROP\n }\n if (typeof answer === 'function') {\n const next = answer(0)\n if (ok(next, run)) {\n for (const error of found) run.repairs.push(error)\n run.changed++\n return next\n }\n }\n }\n for (const error of found) run.errors.push(error)\n return value\n}\n\n/**\n * Walks a value the walk just built or coerced, reporting rather than repairing\n * what is still wrong with it. A replacement is built from the schema, so this\n * normally finds nothing; when it does, the schema could not describe a valid\n * value, and saying so beats repairing the repair.\n */\nexport const walkVerified = (run: RepairRun, value: unknown, walk: RepairWalk): unknown => {\n run.verify++\n const out = walk(value, run)\n run.verify--\n return out\n}\n\n/** Writes a walked child back: in place, or into the parent's copy, made on first write. */\nexport const walkSet = (\n run: RepairRun,\n target: Record<string, unknown>,\n owned: boolean,\n key: string,\n next: unknown,\n): Record<string, unknown> => {\n const out = owned || run.inPlace ? target : { ...target }\n if (next === REPAIR_DROP) delete out[key]\n else setOwn(out, key, next)\n return out\n}\n\n/**\n * Writes a walked element back. A removal is marked in place and taken out by\n * 'walkCompact' once the loop is done, so the indices the rest of the loop\n * reports stay the document's own.\n */\nexport const walkSetItem = (run: RepairRun, target: unknown[], owned: boolean, index: number, next: unknown): unknown[] => {\n const out = owned || run.inPlace ? target : target.slice()\n out[index] = next\n return out\n}\n\n/** Takes the elements marked for removal out of an array the walk owns. */\nexport const walkCompact = (target: unknown[]): unknown[] => {\n let kept = 0\n for (let i = 0; i < target.length; i++) {\n const item = target[i]\n if (item !== REPAIR_DROP) target[kept++] = item\n }\n target.length = kept\n return target\n}\n\n/**\n * A required key that is not there. It is filled through the declaring node's\n * lookup, so a missing object arrives with its own required children already\n * in it, and the new value is checked under its schema before it is kept.\n */\nexport const walkMissing = (\n run: RepairRun,\n target: Record<string, unknown>,\n owned: boolean,\n key: string,\n lookup: RepairLookup | null,\n walk: RepairWalk | null,\n): Record<string, unknown> => {\n const error: ValidationError = {\n message: \"must have required property '\" + key + \"'\",\n path: walkPath(run),\n keyword: 'required',\n params: { missingProperty: key },\n }\n if (run.verify === 0) {\n let answer = lookup === null ? null : lookup([key], 0, target)\n // A key a branch requires and only its parent declares: ask the parent.\n for (let i = run.scopeDepth.length - 1; typeof answer !== 'function' && i >= 0; i--) {\n if (run.scopeDepth[i] !== run.path.length) break\n answer = (run.scopeLookup[i] as RepairLookup)([key], 0, target)\n }\n if (typeof answer === 'function') {\n const value = answer(0)\n if (value !== undefined) {\n if (walk !== null) {\n run.path.push(key)\n walkVerified(run, value, walk)\n run.path.pop()\n }\n run.repairs.push(error)\n run.changed++\n const out = owned || run.inPlace ? target : { ...target }\n setOwn(out, key, value)\n return out\n }\n }\n }\n run.errors.push(error)\n return target\n}\n\n/** Puts a position's lookup in scope while its branches walk it. */\nexport const walkScope = (run: RepairRun, lookup: RepairLookup): void => {\n run.scopeDepth.push(run.path.length)\n run.scopeLookup.push(lookup)\n}\n\n/** Takes it out of scope again, answering the value the position holds. */\nexport const walkUnscope = (run: RepairRun, value: unknown): unknown => {\n run.scopeDepth.pop()\n run.scopeLookup.pop()\n return value\n}\n\n/** A key 'additionalProperties: false' does not allow: reported, and removed. */\nexport const walkExtra = (\n run: RepairRun,\n target: Record<string, unknown>,\n owned: boolean,\n key: string,\n): Record<string, unknown> => {\n const error = walkError(\n run,\n 'additionalProperties',\n 'must NOT have additional properties',\n { additionalProperty: key },\n key,\n )\n if (run.verify > 0) {\n run.errors.push(error)\n return target\n }\n run.repairs.push(error)\n run.changed++\n return walkSet(run, target, owned, key, REPAIR_DROP)\n}\n\n/** True for an object a JSON parser could have produced. */\nexport const isPlainObject = (value: object): boolean => {\n const proto = Object.getPrototypeOf(value)\n return proto === Object.prototype || proto === null\n}\n\n/** An own '__proto__' key, under 'plainObjects': reported, and removed. */\nexport const walkProto = (run: RepairRun, target: Record<string, unknown>, owned: boolean): Record<string, unknown> => {\n if (!Object.hasOwn(target, '__proto__')) return target\n const error = walkError(run, 'plainObject', \"must NOT have an own '__proto__' property\", {}, '__proto__')\n if (run.verify > 0) {\n run.errors.push(error)\n return target\n }\n run.repairs.push(error)\n run.changed++\n return walkSet(run, target, owned, '__proto__', REPAIR_DROP)\n}\n\n/**\n * The position is past 'maxDepth', or the walk is past 'maxWork'. Either way it\n * is reported and left as it is. The work budget is reported once: everything\n * after it is unwalked rather than wrong.\n */\nexport const walkHalt = (run: RepairRun, value: unknown): unknown => {\n if (run.work > run.maxWork) {\n if (!run.halted) {\n run.halted = true\n run.errors.push(\n walkError(run, 'maxWork', 'exceeds the work budget of ' + run.maxWork + ' containers', { limit: run.maxWork }),\n )\n }\n } else {\n run.errors.push(walkError(run, 'maxDepth', 'exceeds the maximum depth of ' + run.maxDepth, { limit: run.maxDepth }))\n }\n return value\n}\n\n/**\n * Walks an object at most once per schema under 'memo', handing back the first\n * walk's result every other time. The entry is written before the walk, so a\n * cycle meets itself and stops.\n */\nexport const walkMemo = (run: RepairRun, value: unknown, body: RepairWalk): unknown => {\n const memo = run.memo\n if (memo === null || typeof value !== 'object' || value === null) return body(value, run)\n let walked = memo.get(value)\n if (walked === undefined) {\n walked = new Map()\n memo.set(value, walked)\n }\n if (walked.has(body)) return walked.get(body)\n walked.set(body, value)\n const out = body(value, run)\n walked.set(body, out)\n return out\n}\n\n/** What 'resolveSegments' answers for a pointer that leads nowhere. */\nconst UNRESOLVED: unique symbol = Symbol('mjst.repair.unresolved')\n\n/** Decodes one reference token: percent-escapes (it is a URI fragment), then RFC 6901. */\nconst decodeToken = (token: string): string => {\n let decoded = token\n if (decoded.indexOf('%') !== -1) {\n try {\n decoded = decodeURIComponent(decoded)\n } catch {\n // A malformed escape is a literal '%' in a key, not an error.\n }\n }\n return unescapeSegment(decoded)\n}\n\n/** The segments of a local '$ref' ('#/a/b'), or 'null' for one that is not a JSON Pointer. */\nconst refSegments = (ref: string): string[] | null => {\n if (ref.length === 1) return []\n if (ref.charCodeAt(1) !== 47) return null\n const tokens = ref.slice(2).split('/')\n for (let i = 0; i < tokens.length; i++) tokens[i] = decodeToken(tokens[i] as string)\n return tokens\n}\n\n/** Follows segments through own properties and in-range indices only. */\nconst resolveSegments = (root: unknown, segments: readonly string[]): unknown => {\n let node = root\n for (const segment of segments) {\n if (Array.isArray(node)) {\n if (!isIndexSegment(segment) || Number(segment) >= node.length) return UNRESOLVED\n node = node[Number(segment)]\n } else if (typeof node === 'object' && node !== null && Object.hasOwn(node, segment)) {\n node = (node as Record<string, unknown>)[segment]\n } else {\n return UNRESOLVED\n }\n }\n return node\n}\n\n/**\n * A Reference object at a position that opted in: its target is resolved\n * against the document and queued to be walked under the position's type.\n *\n * Targets wait in a queue rather than being walked here, so a chain of\n * references or a cycle between them is a loop over the queue rather than a\n * recursion, and each target is walked once per type. A target that does not\n * resolve is reported at the '$ref' that named it and never created: inventing\n * the definition would hide the mistake.\n */\nexport const walkRef = (run: RepairRun, value: unknown, walk: RepairWalk, typeId: string): void => {\n if (!run.refs || typeof value !== 'object' || value === null || Array.isArray(value)) return\n const holder = value as Record<string, unknown>\n if (!Object.hasOwn(holder, '$ref')) return\n const ref = holder['$ref']\n // Only a local pointer resolves inside the document; anything else is the\n // bundler's business.\n if (typeof ref !== 'string' || ref.charCodeAt(0) !== 35) return\n if (run.onRef !== undefined && run.onRef(holder, walkPath(run), typeId) === false) return\n if (run.refAllowed !== null && run.refAllowed.get(typeId)?.has(ref) !== true) return\n\n let resolvable = run.refResolvable.get(ref)\n if (resolvable === undefined) {\n const segments = refSegments(ref)\n resolvable = segments !== null && resolveSegments(run.root, segments) !== UNRESOLVED\n run.refResolvable.set(ref, resolvable)\n }\n if (!resolvable) {\n run.errors.push(walkError(run, 'unresolvedRef', \"can't resolve reference \" + ref, { ref }, '$ref'))\n return\n }\n\n for (const prefix of run.skipRefs) if (ref.startsWith(prefix)) return\n // Keyed by type first, so a site whose target is already queued costs two\n // lookups and builds nothing.\n let queued = run.refQueued.get(typeId)\n if (queued === undefined) {\n queued = new Set()\n run.refQueued.set(typeId, queued)\n }\n if (queued.has(ref)) return\n queued.add(ref)\n run.refQueue.push({ ref, walk, typeId })\n}\n\n/** Walks every queued '$ref' target, including the ones walking them queues. */\nconst walkRefTargets = (run: RepairRun): void => {\n for (let next = 0; next < run.refQueue.length && !run.halted; next++) {\n const task = run.refQueue[next] as RefTask\n const segments = refSegments(task.ref)\n if (segments === null) continue\n const target = resolveSegments(run.root, segments)\n // It resolved where it was named; a repair since then removed it.\n if (target === UNRESOLVED) continue\n if (typeof target === 'object' && target !== null) {\n let types = run.refWalked.get(target)\n if (types === undefined) {\n types = new Set()\n run.refWalked.set(target, types)\n }\n if (types.has(task.typeId)) continue\n types.add(task.typeId)\n }\n run.path = segments\n run.anchor = segments.length\n const out = task.walk(target, run)\n if (out !== target && out !== REPAIR_DROP) {\n const updated = writeAt(run.root, segments, 0, out, run.inPlace)\n if (updated !== NOT_APPLIED) run.root = updated\n }\n }\n}\n\n/** A run over 'root' with the given settings; 'verify' 1 is a check that repairs nothing. */\nconst createRun = (\n settings: RepairOptions,\n root: unknown,\n verify: number,\n refAllowed: Map<string, Set<string>> | null = null,\n): RepairRun => ({\n base: settings.path ?? '',\n path: [],\n anchor: 0,\n errors: [],\n repairs: [],\n inPlace: settings.inPlace === true,\n coerce: settings.coerce !== false,\n finite: settings.finite === true,\n plain: settings.plainObjects === true,\n maxDepth: settings.maxDepth ?? DEFAULT_MAX_DEPTH,\n maxWork: settings.maxWork ?? DEFAULT_MAX_WORK,\n work: 0,\n verify,\n changed: 0,\n halted: false,\n root,\n refs: settings.refs !== false,\n skipRefs: settings.skipRefs ?? [],\n onRef: verify === 0 ? settings.onRef : undefined,\n refQueue: [],\n scopeDepth: [],\n scopeLookup: [],\n refQueued: new Map(),\n refAllowed,\n refWalked: new Map(),\n refResolvable: new Map(),\n memo: settings.memo === true ? new Map() : null,\n})\n\n/**\n * The single-pass repair behind a generated 'repairX' whose schema the walk\n * covers: one walk over the document that checks every position and repairs\n * the ones that fail, then one walk over each opted-in '$ref' target.\n *\n * A valid document is walked and handed back untouched: nothing is copied, no\n * path is built and no error object exists, so the only allocation is the run\n * itself.\n *\n * When anything was changed, the result is walked once more, checking only.\n * Every replacement was already checked where it was made, but a position can\n * answer to several subschemas at once \u2014 'allOf' entries, overlapping patterns,\n * a '$ref' beside its siblings \u2014 and a repair made for one could break another.\n * 'valid: true' is a promise about the value handed back, so it is checked\n * rather than assumed; whatever that finds is reported, not repaired again.\n */\nexport const runRepairWalk = <T>(\n input: unknown,\n options: RepairOptions | string | undefined,\n walk: RepairWalk,\n): RepairResult<T> => {\n const settings: RepairOptions = typeof options === 'string' ? { path: options } : (options ?? {})\n const run = createRun(settings, input, 0)\n\n const out = walk(input, run)\n run.root = out === REPAIR_DROP ? input : out\n walkRefTargets(run)\n const value = run.root\n const errors = run.errors\n\n if (run.changed > 0 && !run.halted) {\n const check = createRun(settings, value, 1, run.refQueued)\n walk(value, check)\n walkRefTargets(check)\n if (check.errors.length > 0) {\n const known = new Set<string>()\n for (const error of errors) known.add(error.keyword + ' ' + error.path)\n for (const error of check.errors) if (!known.has(error.keyword + ' ' + error.path)) errors.push(error)\n }\n }\n\n return errors.length === 0\n ? { valid: true, value: value as T, repairs: run.repairs }\n : { valid: false, value, errors, repairs: run.repairs }\n}\n";
|