@amritk/validation 0.4.1 → 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 CHANGED
@@ -107,4 +107,15 @@ any of them without one of those two flags.
107
107
  barrel — a leading `_` marks a name for sibling files only). Strict and
108
108
  `stripUnknown` parsers are unchanged.
109
109
 
110
+ 11. **`repairX(input, options?)` takes options, and is one pass when it can be.**
111
+ `inPlace`, `coerce: false` and `path` work for every schema; `refs`,
112
+ `skipRefs`, `onRef`, `maxDepth`, `maxWork`, `memo`, `finite` and
113
+ `plainObjects` only for a schema the single-pass walk covers (the
114
+ `if`/`then`/`else` + `allOf` + `properties` family; see the README) — on any
115
+ other schema `repairX` throws if they are set. Do not
116
+ promise a user instance `$ref` following without `"x-repair": "ref"` on their
117
+ Reference schema — generation refuses that opt-in on a schema the walk cannot
118
+ serve rather than ignoring it. `"x-repair": "drop"` and `false` schemas remove
119
+ a failing position rather than defaulting it.
120
+
110
121
  Only the `.` entry. Install: `bun add @amritk/validation`.
package/README.md CHANGED
@@ -103,6 +103,51 @@ not enough: a document nothing could repair comes back with none either, and
103
103
  `valid: false`. To reject a bad config, use `coerceX`. To hand back a fixed one,
104
104
  use `repairX`.
105
105
 
106
+ **Repairing large documents.** `repairX(input, options?)` takes an options
107
+ object (a bare string is still read as the base path):
108
+
109
+ | option | default | what it does |
110
+ | --- | --- | --- |
111
+ | `path` | `''` | JSON Pointer every reported path is rooted at |
112
+ | `inPlace` | `false` | assign and delete on the input instead of copying the containers a repair touches |
113
+ | `coerce` | `true` | move a scalar written in the wrong type (`"5"` under `integer`) before repairing; off, it is repaired to the schema's value and reported |
114
+ | `refs` | `true` | follow instance `$ref`s at positions whose Reference schema opts in (below) |
115
+ | `skipRefs` | `[]` | pointer prefixes (`'#/components/'`) whose targets the root schema already walks; still resolved, not walked again |
116
+ | `onRef` | — | `(value, path, typeId) => boolean \| void`, called for every opted-in `$ref`; `false` leaves it alone |
117
+ | `maxDepth` | `512` | depth past which a position is reported (`maxDepth`) instead of walked |
118
+ | `maxWork` | `10_000_000` | containers visited before the walk stops (`maxWork`) |
119
+ | `memo` | `false` | walk each object once per schema — for in-memory graphs with shared or cyclic subtrees |
120
+ | `finite` | `false` | reject `NaN` and the infinities where a number is declared |
121
+ | `plainObjects` | `false` | accept only plain objects, and remove an own `__proto__` key |
122
+
123
+ When every definition in the schema is built from `type`, `enum`, `const`,
124
+ `properties`, `required`, `additionalProperties`, `patternProperties`, `items`,
125
+ `$ref`, `if`/`then`/`else` and `allOf` — plus annotations, and scalar
126
+ assertions such as `minLength`, `pattern`, `minimum`, `anyOf`, `oneOf` or `not`,
127
+ which are asked of the validator's own test of the position — `repairX` is a
128
+ single pass: each position is checked and repaired where it stands, an `if` is
129
+ evaluated against the value actually there, a missing required object is built
130
+ with its required children (to a bounded depth on a recursive definition), and
131
+ errors go straight into one array with their paths built only when something is
132
+ reported. A valid document comes back as itself with nothing copied. On a 24 MB
133
+ bundled OpenAPI document that is well under a hundred milliseconds (`bun
134
+ --conditions development scripts/bench-repair-walk.ts`). Any other keyword keeps
135
+ the validate-and-repair loop, which honours `path`, `inPlace` and `coerce` and
136
+ throws on the walk-only options (`skipRefs`, `onRef`, `maxDepth`, `maxWork`,
137
+ `memo`, `finite`, `plainObjects`) rather than quietly dropping a guard.
138
+
139
+ Two schema annotations steer it. `"x-repair": "drop"` — and a `false` schema —
140
+ removes a failing position instead of writing a default: an unknown key under
141
+ `additionalProperties: false`, an array element under `items: false`, a
142
+ drop-marked property. `"x-repair": "ref"` on a Reference schema turns on
143
+ instance `$ref` following: at a position written
144
+ `{ if: { type: object, required: [$ref] }, then: Reference, else: T }`, the
145
+ `$ref` is resolved as a JSON Pointer against the root document (own properties
146
+ only), and its target is checked and repaired as that position's type, with
147
+ errors at the target's own path. Each target is walked once per type, chains
148
+ and cycles included; one that does not resolve is reported as `unresolvedRef`
149
+ and never created.
150
+
106
151
  **`parseX` agrees with `coerceX`.** Wherever `coerceX` accepts a document, the
107
152
  coercing `parseX` returns the very same value — through a union, an `allOf`, an
108
153
  `if`, a `$ref`, recursion included — and it only repairs what `coerceX` would
@@ -136,6 +181,13 @@ is on purpose:
136
181
  string, number or boolean field, and reads those back as `null`. A `null`
137
182
  usually means "not set", and turning it into a real value erases that. Here it
138
183
  goes to the validator as written, and the validator rejects it.
184
+
185
+ Before switching, check what your callers do with a rejected document. If one
186
+ treats rejection as absence (returns `undefined`, falls back to a default),
187
+ a single `null` deep inside a block makes the whole block vanish without an
188
+ error. Ajv's coercion used to hide that. Either let the schema say `null` is
189
+ allowed (`type: ['string', 'null']`), drop `null`s before validating, or have
190
+ the caller surface the errors instead of discarding the value.
139
191
  - **Only clean numerals become numbers.** `" "`, `" 1 "`, `"0x10"`, `"Infinity"`
140
192
  and `"1."` are rejected. Ajv turns them into `0`, `1`, `16`, a value JSON
141
193
  cannot hold, and `1`. `"007"` and `"1e3"` are still coerced.
@@ -175,9 +227,11 @@ both leave the caller's document alone (`bun run bench:validators:coerce`, or
175
227
 
176
228
  <sub>Each cell is how many times faster `coerceX` is than Ajv on the same input,
177
229
  each engine timed in its own process (Bun 1.3.11, Node 22.22, Linux x64). A
178
- valid document is the common case and the widest gap: where `isX` is a
179
- standalone guard, `coerceX` answers it with that guard and hands the input
180
- straight back, without walking it. On valid input Ajv rewrites nothing, so it can
230
+ valid document is the common case and the widest gap: `coerceX` answers it with
231
+ `isX` and hands the input straight back, without walking it. `isX` builds
232
+ nothing — no error, no path — and stops at the first failure, answering a
233
+ `$ref` with the target's own `isX`; it calls `validateX` only for a schema that
234
+ folds to a constant. On valid input Ajv rewrites nothing, so it can
181
235
  also be timed without the clone; `coerceX` is still ahead there on these
182
236
  schemas, by 1.2–3.3×, but not on every one: on a large, union-heavy document
183
237
  that the caller lets Ajv coerce in place, Ajv can come out modestly ahead. The
@@ -207,7 +261,7 @@ holding it (`/readme`). `params.additionalProperty` names the key in both.
207
261
  | `options.schemas` | `Record<string, unknown>` | — | Documents you have already loaded, keyed by the URI a `$ref` names them by. |
208
262
  | `options.unknownKeys` | `'count-keys' \| 'count-enumerable'` | `'count-keys'` | How a closed object's fast path proves it carries no undeclared key. |
209
263
  | `options.formats` | `'all' \| string[]` | — | String `format`s the validators enforce. Unset leaves `format` an annotation. |
210
- | `options.branchErrors` | `boolean` | `false` | Explain a failing `anyOf` / `oneOf` with the branch it plainly meant. |
264
+ | `options.branchErrors` | `boolean` | `false` | Explain a failing `anyOf` / `oneOf` with the branch it plainly meant, or with the kinds it accepts when no branch takes the value's kind. |
211
265
  | `options.stripUnknown` | `boolean` | `false` | Build each parsed result from the declared properties only. |
212
266
  | `options.readonly` | `boolean` | `false` | Emit `readonly` type members. |
213
267
  | `options.caseInsensitive` | `boolean` | `false` | Normalize a mis-cased string onto an `enum` member it matches. |
@@ -801,7 +801,7 @@ const inlineNestedFastPath = (childSchema, childTypeName, objectExpr, useRefImpo
801
801
  };
802
802
  const RESULT_VARIABLE = "out";
803
803
  const PROTOTYPE_MEMBERS = new Set(Object.getOwnPropertyNames(Object.prototype));
804
- const resultMember = (key) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? `${RESULT_VARIABLE}.${key}` : `${RESULT_VARIABLE}[${JSON.stringify(key)}]`;
804
+ const resultMember = (key) => `${RESULT_VARIABLE}[${JSON.stringify(key)}]`;
805
805
  const renderLiteralEntry = (entry, indent) => {
806
806
  if (entry.kind === "field")
807
807
  return `${indent}${safeLiteralKey(entry.key)}: ${entry.value},`;
@@ -133,7 +133,6 @@ const wrongTypeCondition = (accessor, type) => {
133
133
  return null;
134
134
  }
135
135
  };
136
- const typeLabel = (type) => type === "integer" ? "number" : type;
137
136
  const throwError = (message, suffixExpr) => {
138
137
  const literal = quoteJsString(message);
139
138
  return suffixExpr ? `throw new Error(${literal} + (${suffixExpr}))` : `throw new Error(${literal})`;
@@ -216,7 +215,7 @@ const generateItemCheck = (schema, context) => {
216
215
  }
217
216
  const scalarCheck = plainScalarItemCheck(items, "_it");
218
217
  if (scalarCheck !== null && isSchemaObject(items) && hasType(items)) {
219
- return { check: scalarCheck, message: `items expected ${typeLabel(items.type)}` };
218
+ return { check: scalarCheck, message: `items expected ${items.type}` };
220
219
  }
221
220
  if (hasRef(items) && context.useRefImports === true || shapeIsDelegated(items, context))
222
221
  return null;
@@ -489,7 +488,7 @@ const generatePrefixItemsAssertion = (acc, field, schema, context) => {
489
488
  const pt = pos.type;
490
489
  const wrong = wrongTypeCondition(el, pt);
491
490
  if (wrong) {
492
- lines.push(` if (${present} && (${wrong})) ${throwError(`${field}[${i}] expected ${typeLabel(pt)}, got `, `typeof ${el}`)};`);
491
+ lines.push(` if (${present} && (${wrong})) ${throwError(`${field}[${i}] expected ${pt}, got `, `typeof ${el}`)};`);
493
492
  }
494
493
  }
495
494
  const match = subschemaMatchExpr(el, prefix[i], matchContext(context));
@@ -604,7 +603,7 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName, contex
604
603
  if (multiType !== void 0) {
605
604
  const types = propSchema.type;
606
605
  if (multiType !== null) {
607
- const expected = throwError(`${field} expected ${types.map(typeLabel).join(" | ")}, got `, `typeof ${acc}`);
606
+ const expected = throwError(`${field} expected ${types.join(" | ")}, got `, `typeof ${acc}`);
608
607
  lines.push(isRequired ? ` if (!${multiType}) ${expected};` : ` if (${acc} !== undefined && !${multiType}) ${expected};`);
609
608
  }
610
609
  const nonNull = types.filter((type) => type !== "null");
@@ -618,7 +617,7 @@ const generatePropertyAssertion = (key, propSchema, isRequired, typeName, contex
618
617
  const t = propSchema.type;
619
618
  const wrongType = wrongTypeCondition(acc, t);
620
619
  if (wrongType) {
621
- const expected = throwError(`${field} expected ${typeLabel(t)}, got `, `typeof ${acc}`);
620
+ const expected = throwError(`${field} expected ${t}, got `, `typeof ${acc}`);
622
621
  if (isRequired) {
623
622
  lines.push(` if (${wrongType}) ${expected};`);
624
623
  } else {
@@ -720,7 +719,7 @@ const generateScalarStrictAssertion = (schema, typeName, context = {}) => {
720
719
  if (rootMultiType !== void 0) {
721
720
  const types = schema.type;
722
721
  if (rootMultiType !== null) {
723
- lines.push(` if (!${rootMultiType}) ${throwError(`${label} expected ${types.map(typeLabel).join(" | ")}, got `, got)};`);
722
+ lines.push(` if (!${rootMultiType}) ${throwError(`${label} expected ${types.join(" | ")}, got `, got)};`);
724
723
  }
725
724
  const nonNull = types.filter((type) => type !== "null");
726
725
  if (nonNull.length === 1) {
@@ -734,7 +733,7 @@ const generateScalarStrictAssertion = (schema, typeName, context = {}) => {
734
733
  const t = schema.type;
735
734
  const wrongType = wrongTypeCondition("input", t);
736
735
  if (wrongType) {
737
- lines.push(` if (${wrongType}) ${throwError(`${label} expected ${typeLabel(t)}, got `, got)};`);
736
+ lines.push(` if (${wrongType}) ${throwError(`${label} expected ${t}, got `, got)};`);
738
737
  }
739
738
  if (t === "array") {
740
739
  lines.push(...generateContainsCheck("input", schema, label, context));
@@ -12,7 +12,7 @@ export type GeneratedFile = {
12
12
  * types plus the helpers emitted code calls as free identifiers. Exported so tests
13
13
  * can evaluate the very source that ships instead of reimplementing it.
14
14
  */
15
- export declare const VALIDATION_RESULT_CONTENT = "/**\n * A single validation error: what went wrong, where, and which keyword said so.\n *\n * The shape matches `@amritk/runtime-validators`, so an error from a generated\n * validator and one from the runtime interpreter can be handled by the same\n * code \u2014 grouped, translated, or branched on \u2014 without knowing which produced it.\n */\nexport type ValidationError = {\n /** Human-readable description of what went wrong. */\n message: string\n /** JSON Pointer to the offending value inside the instance. */\n path: string\n /** The JSON Schema keyword that rejected the value \u2014 `type`, `required`, `minimum`, \u2026 */\n keyword: string\n /**\n * The keyword's own values, as far as they explain the failure: the bound that\n * was exceeded, the property that was missing, the allowed values that were not\n * matched. Empty for a keyword with nothing to add beyond its name.\n */\n params: Record<string, unknown>\n}\n\n/**\n * The result of a generated validator function.\n * Returns `true` when the input is valid, or an object with `valid: false`\n * and a list of errors when it is not.\n */\nexport type ValidationResult = true | { valid: false; errors: ValidationError[] }\n\n/**\n * The result of a generated coercing validator.\n *\n * Unlike `ValidationResult` there is no bare `true`: a caller that coerces wants\n * the value back, and the whole point is that it may differ from what went in.\n * The input is never modified \u2014 `value` is the input itself when nothing needed\n * coercing, and otherwise a copy that shares everything the coercion did not\n * touch. So a caller does not have to clone defensively the way an in-place\n * coercer forces them to.\n */\nexport type CoercionResult<T> = { valid: true; value: T } | { valid: false; errors: ValidationError[] }\n\n/**\n * A string this will read as a number: an optional sign, digits with an optional\n * fractional part, an optional exponent. Nothing else.\n *\n * Deliberately narrower than what `Number()` accepts, which is where Ajv gets\n * its surprises \u2014 `Number(\" \")` is `0`, `Number(\"0x10\")` is `16` and\n * `Number(\"Infinity\")` is a value JSON cannot even represent. A config that says\n * `retries: \" \"` has a mistake in it, and answering `0` is the one thing worse\n * than rejecting it. Leading zeros (`\"007\"`) and exponents (`\"1e3\"`) stay: both\n * are ordinary ways to write a number in a YAML file, and neither is ambiguous.\n */\nconst isNumericString = (text: string): boolean => {\n // `/^[+-]?(?:\\d+|\\d*\\.\\d+)(?:[eE][+-]?\\d+)?$/`, walked by hand. The regex\n // cost more than the rest of a number coercion put together \u2014 a string past\n // its end reads `NaN` from `charCodeAt`, which fails every test below, so\n // the scan needs no bounds checks of its own.\n let i = 0\n let c = text.charCodeAt(0)\n if (c === 43 || c === 45) c = text.charCodeAt(++i)\n let digits = 0\n while (c >= 48 && c <= 57) {\n digits++\n c = text.charCodeAt(++i)\n }\n if (c === 46) {\n c = text.charCodeAt(++i)\n let fraction = 0\n while (c >= 48 && c <= 57) {\n fraction++\n c = text.charCodeAt(++i)\n }\n if (fraction === 0) return false\n } else if (digits === 0) return false\n if (c === 101 || c === 69) {\n c = text.charCodeAt(++i)\n if (c === 43 || c === 45) c = text.charCodeAt(++i)\n let exponent = 0\n while (c >= 48 && c <= 57) {\n exponent++\n c = text.charCodeAt(++i)\n }\n if (exponent === 0) return false\n }\n return i === text.length\n}\n\n/**\n * One scalar, coerced toward `type`, or returned untouched when that is not\n * possible.\n *\n * Returning the original on failure is what keeps the error honest: nothing is\n * substituted, so the validator that runs next rejects the value the caller\n * actually wrote, with the keyword and params that rejected it. That is the\n * difference between this and a parser's repair, which repairs toward a default\n * and leaves nothing to report.\n *\n * The table is Ajv's `coerceTypes` minus the cells where Ajv guesses. Every\n * value this coerces, Ajv coerces to the same value \u2014 `coerced-vs-ajv` pins that\n * as a property, so moving off Ajv never changes a value, it only turns some of\n * Ajv's silent repairs into errors. What is deliberately *not* coerced:\n *\n * - **Anything to or from `null`.** Ajv reads `null` as `\"\"`, `0` and\n * `false`, and reads `\"\"`, `0` and `false` back as `null`. `null` is a\n * JSON value in its own right and usually means \"not set\"; turning it into an\n * empty string, or an empty string into it, loses the distinction the document\n * drew.\n * - **Strings that are not cleanly numeric** ({@link isNumericString}) \u2014 no\n * whitespace padding, no `0x`/`0o`/`0b`, no `Infinity`, no trailing `.`.\n */\nexport const coerceScalar = (value: unknown, type: string): unknown => {\n switch (type) {\n case 'string':\n return typeof value === 'number' || typeof value === 'boolean' ? String(value) : value\n case 'number':\n case 'integer': {\n if (typeof value === 'boolean') return value ? 1 : 0\n if (typeof value !== 'string' || !isNumericString(value)) return value\n const asNumber = Number(value)\n if (!Number.isFinite(asNumber)) return value\n return type === 'integer' && asNumber % 1 !== 0 ? value : asNumber\n }\n case 'boolean':\n if (value === 'true' || value === 1) return true\n if (value === 'false' || value === 0) return false\n return value\n default:\n return value\n }\n}\n\n/**\n * One scalar at a position that offers several types \u2014 a `type` array, or a\n * union of scalar branches \u2014 coerced only when every type that can take it\n * agrees on the result.\n *\n * A value whose type is already one of the offered types is left alone: it is\n * what the schema asked for, and the question of coercion does not arise. That\n * one rule is where this parts company with Ajv, which walks its own coercion\n * list in order and so turns `\"1\"` into `1` under `[\"number\", \"string\"]` while\n * leaving it a string under `[\"string\", \"number\"]`. The answer should not depend\n * on the order someone wrote the union in.\n *\n * Otherwise every offered type is tried, and the coercion is taken only if the\n * types that succeed all agree on it. `true` against `number | string` could be\n * `1` or `\"true\"` with equal justification, so it stays `true` and the\n * validator says what is wrong with it. `\"1\"` against `number | integer` is `1`\n * either way, so it is `1`.\n */\nexport const coerceUnion = (value: unknown, types: readonly string[]): unknown => {\n const actual = value === null ? 'null' : typeof value\n for (const type of types) {\n // `integer` is satisfied by a number, so a non-integral number is not a\n // *type* mismatch here \u2014 the validator is what holds it to being whole.\n if (actual === type || (actual === 'number' && type === 'integer')) return value\n }\n\n let coerced: unknown = value\n for (const type of types) {\n const candidate = coerceScalar(value, type)\n if (candidate === value) continue\n if (coerced === value) coerced = candidate\n else if (candidate !== coerced) return value\n }\n return coerced\n}\n\n/**\n * How deep a structural comparison walks before it gives up and answers \"not\n * equal\".\n *\n * JSON data is acyclic, but a generated validator is a plain function applied to\n * whatever in-memory value a caller hands it \u2014 and a self-referential object\n * reaching a `const` / `enum` / `uniqueItems` check used to recurse until the\n * stack overflowed, so `validateFoo` threw a `RangeError` instead of returning\n * the `ValidationResult` its signature promises. The cap turns that into an\n * ordinary \"these are different\" without ever coming near real data;\n * `@amritk/runtime-validators` guards its own `deepEqual` at the same depth.\n */\nconst MAX_EQUAL_DEPTH = 512\n\n/**\n * Structural deep equality used by generated `const` checks. Objects compare by\n * their key sets rather than serialization, so `{ a: 1, b: 2 }` and\n * `{ b: 2, a: 1 }` are equal \u2014 unlike `JSON.stringify`, which is key-order\n * sensitive and would reject a reordered-but-equal value.\n */\nexport const valuesEqual = (a: unknown, b: unknown, depth = 0): boolean => {\n // SameValueZero: `===` settles every primitive except `NaN`, which counts as\n // equal to itself here. That is what the native `Set` in {@link allUnique} does,\n // what Ajv does, and what the interpreter's `deepEqual` does \u2014 leaving it out\n // made a `NaN` nested inside an object compare unequal to itself, so the same\n // array was \"unique\" here and \"duplicated\" everywhere else.\n if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true\n if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false\n if (depth >= MAX_EQUAL_DEPTH) return false\n const aArray = Array.isArray(a)\n const bArray = Array.isArray(b)\n if (aArray !== bArray) return false\n if (aArray) {\n const aa = a as unknown[]\n const bb = b as unknown[]\n if (aa.length !== bb.length) return false\n for (let i = 0; i < aa.length; i++) if (!valuesEqual(aa[i], bb[i], depth + 1)) return false\n return true\n }\n const ao = a as Record<string, unknown>\n const bo = b as Record<string, unknown>\n const keys = Object.keys(ao)\n if (keys.length !== Object.keys(bo).length) return false\n for (const key of keys) {\n if (!Object.hasOwn(bo, key) || !valuesEqual(ao[key], bo[key], depth + 1)) return false\n }\n return true\n}\n\n/**\n * A cheap, order-independent structural hash consistent with {@link valuesEqual}:\n * equal values always hash the same. It buckets candidate-equal elements in\n * {@link allUnique} so the exact comparison only ever runs inside a bucket.\n *\n * Object keys are folded commutatively (XOR) so key order does not change the\n * hash, and `NaN` / `-0` collapse the way SameValueZero does. Depth-capped like\n * {@link valuesEqual}: an over-deep value simply shares a bucket and is settled by\n * the (also capped) comparison, so the cap can cost a little time and never a\n * wrong answer. This is the same hash `@amritk/runtime-validators` uses.\n */\nconst structuralHash = (value: unknown, depth = 0): number => {\n if (value === null) return 0x1a2b3c\n const t = typeof value\n if (t === 'number') {\n const n = value as number\n return Number.isNaN(n) ? 0x7ff8 : n === 0 ? 0 : Math.trunc(n * 2654435761) | 0\n }\n if (t === 'string') {\n const s = value as string\n let h = 0x811c9dc5\n for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)\n return h | 0\n }\n if (t === 'boolean') return value ? 1 : 2\n if (t !== 'object') return 0x5eed\n if (depth >= MAX_EQUAL_DEPTH) return 0xdee9\n if (Array.isArray(value)) {\n let h = 0x12345 ^ value.length\n for (let i = 0; i < value.length; i++) h = (Math.imul(h, 31) + structuralHash(value[i], depth + 1)) | 0\n return h | 0\n }\n const obj = value as Record<string, unknown>\n const keys = Object.keys(obj)\n let h = 0xabcde ^ keys.length\n for (const k of keys) {\n let kh = 0x811c9dc5\n for (let i = 0; i < k.length; i++) kh = Math.imul(kh ^ k.charCodeAt(i), 0x01000193)\n h = (h ^ (Math.imul(kh, 0x9e3779b1) + structuralHash(obj[k], depth + 1))) | 0\n }\n return h | 0\n}\n\n/**\n * True when every element of `arr` is distinct under structural equality\n * ({@link valuesEqual}). Backs generated `uniqueItems` checks whose items may be\n * objects or arrays, where a `JSON.stringify` dedupe key would be key-order\n * sensitive and let a reordered-but-equal duplicate (`{ a: 1, b: 2 }` vs\n * `{ b: 2, a: 1 }`) slip through.\n *\n * A native `Set` dedupes the all-primitive case in one linear pass. Object and\n * array elements are bucketed by {@link structuralHash} first, so the exact\n * comparison runs only against elements that could actually be equal: an array of\n * distinct objects costs ~O(n) instead of the O(n\u00B2) an exhaustive pairwise sweep\n * charged \u2014 4 000 rows took over half a second of pure comparison before, which\n * is a lot to hand an unauthenticated caller.\n */\nexport const allUnique = (arr: readonly unknown[]): boolean => {\n const len = arr.length\n if (len < 2) return true\n let allPrimitive = true\n for (let i = 0; i < len; i++) {\n const v = arr[i]\n if (v !== null && typeof v === 'object') {\n allPrimitive = false\n break\n }\n }\n if (allPrimitive) return new Set(arr).size === len\n const buckets = new Map<number, unknown[]>()\n for (let i = 0; i < len; i++) {\n const item = arr[i]\n const hash = structuralHash(item)\n const bucket = buckets.get(hash)\n if (bucket === undefined) {\n buckets.set(hash, [item])\n continue\n }\n for (const seen of bucket) if (valuesEqual(seen, item)) return false\n bucket.push(item)\n }\n return true\n}\n\n/**\n * True when `test` holds for every element of `arr`, holes included. Backs the\n * item check inside a generated boolean guard.\n *\n * Not `Array.prototype.every`, because that *skips holes* in a sparse array\n * (`[, 'x']`), whereas the validator's index-based loop reads a hole as\n * `undefined` and rejects it \u2014 and the guard must never accept what the validator\n * rejects. The guard used to get that by materialising `Array.from(arr)` first,\n * which copied every array it looked at; an index loop reads a hole the same way\n * and copies nothing.\n */\nexport const everyItem = (arr: readonly unknown[], test: (item: unknown) => boolean): boolean => {\n for (let i = 0; i < arr.length; i++) if (!test(arr[i])) return false\n return true\n}\n\n/**\n * Escapes one JSON Pointer segment (RFC 6901): `~` \u2192 `~0`, `/` \u2192 `~1`, in that\n * order. Generated error paths are built from *runtime* keys wherever the schema\n * did not name them \u2014 a `patternProperties` match, an `additionalProperties`\n * sweep, a `propertyNames` loop \u2014 and a key containing a `/` would otherwise read\n * back as two segments, so an error on `{\"a/b\": \u2026}` pointed at `/a/b`, which is\n * the child `b` of a property `a`. Keys the schema *does* name are escaped at\n * generation time instead, and `@amritk/runtime-validators` escapes the same way,\n * so all three agree.\n *\n * The `indexOf` pre-test keeps the common key \u2014 no `/`, no `~` \u2014 off the replace\n * path entirely, which is what the interpreter does for the same reason.\n */\nexport const escapePointer = (key: string): string =>\n key.indexOf('/') !== -1 || key.indexOf('~') !== -1 ? key.replace(/~/g, '~0').replace(/\\//g, '~1') : key\n\n/**\n * The errors of the branch that was plainly the one meant, out of every branch a\n * failing `anyOf` / `oneOf` rejected. Empty when no branch stands out.\n *\n * A failing combinator on its own says almost nothing: \"must match a schema in\n * anyOf\" names no field and no reason, and on the shape this is most often used\n * for \u2014 a union where the value plainly *is* one of the variants and one field of\n * it is wrong \u2014 that is the least useful thing a validator can say. The branch\n * errors are computed anyway to answer the yes/no question, so the only question\n * is which of them are worth reporting.\n *\n * Branches that rejected the value's *kind* go first: a branch wanting a string\n * has nothing to say about an object, so a `string | { \u2026 }` union is left with\n * the one branch that was even talking about this value. When more than one\n * survives they all describe the same kind of value, and the tie is broken the\n * way a discriminated union reads from the outside \u2014 if every survivor but one\n * was rejected on the value's *identity* (a `const` or `enum` on the value or\n * one of its own properties), the remaining one is the variant the author meant.\n *\n * Nothing is reported when no branch stands out, which is as much as can be said\n * honestly: \"the branch with the fewest errors\" would answer here too, and\n * answers wrongly on `oneOf: [aReference, theActualThing]`, where \"you did not\n * write a $ref\" is one complaint and the real mistake is two.\n *\n * `path` is where the combinator was applied, so a segment below it is a direct\n * property of the value being judged. `@amritk/runtime-validators` selects the\n * same branch by the same rule, so a generated validator and the interpreter\n * explain a failing union the same way.\n */\nexport const selectBranchErrors = (\n branches: readonly (readonly ValidationError[])[],\n path: string,\n): readonly ValidationError[] => {\n const candidates: (readonly ValidationError[])[] = []\n for (const errors of branches) {\n // The value itself is `path`, so a `type` error there is a rejected kind.\n if (!errors.some((error) => error.keyword === 'type' && error.path === path)) candidates.push(errors)\n }\n if (candidates.length === 1) return candidates[0] as readonly ValidationError[]\n\n let selected: readonly ValidationError[] | null = null\n let rejectedOnIdentity = 0\n for (const errors of candidates) {\n // One segment below `path` and no deeper: a discriminator is conventionally a\n // direct field, and a `const` buried further down is far more likely to be an\n // ordinary payload constraint.\n const identity = errors.some(\n (error) =>\n (error.keyword === 'const' || error.keyword === 'enum') && error.path.indexOf('/', path.length + 1) === -1,\n )\n if (identity) {\n rejectedOnIdentity++\n continue\n }\n // Two branches survive the discriminator, so it did not discriminate.\n if (selected !== null) return []\n selected = errors\n }\n\n return selected !== null && rejectedOnIdentity === candidates.length - 1 ? selected : []\n}\n\n/**\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 `validateX` produced, the ones a repair was found for. So a caller that\n * 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`, which is exactly what `coerceX` would have returned. One that was\n * fully repaired is `valid: true` with a non-empty `repairs` \u2014 the caller\n * decides whether that is acceptable by looking, rather than by being told. One\n * that could not be fully repaired is `valid: false` and carries both: the\n * repairs that were applied, and the `errors` still outstanding against the\n * 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 * Looks up the repaired value for one position, given that position's JSON\n * Pointer split into segments. Returns a thunk rather than a value so each\n * repair gets its own object \u2014 a shared literal would alias every site that\n * repaired to it \u2014 and `null` where the schema offers nothing to repair toward.\n */\nexport type RepairLookup = (segments: readonly string[]) => (() => unknown) | null\n\n/**\n * How many validate-and-repair rounds `repairX` will run. Repairing a child can\n * expose a parent that only became checkable once the child was there, so one\n * round is not always enough; but each round is bounded further by the rule that\n * a position is repaired at most once, so this is a backstop rather than the\n * thing doing the terminating.\n */\nexport const MAX_REPAIR_PASSES = 8\n\n/**\n * Splits a JSON Pointer into its segments, undoing the escaping\n * {@link escapePointer} applied. The root pointer (`''`) is zero segments.\n */\nexport const pointerSegments = (path: string): string[] =>\n path === ''\n ? []\n : path\n .slice(1)\n .split('/')\n .map((segment) =>\n segment.indexOf('~') === -1 ? segment : segment.replace(/~1/g, '/').replace(/~0/g, '~'),\n )\n\n/**\n * Returns `root` with the position at `segments` replaced by `value`, copying\n * only the containers along the way.\n *\n * Copying rather than writing is not politeness, it is the same promise\n * `coerceX` makes: the caller's input is never modified, so a repaired document\n * can be compared against what was actually sent. Everything the path did not\n * touch is shared, so repairing one field of a large document does not clone it.\n *\n * Returns `root` unchanged when the path does not lead anywhere \u2014 a segment into\n * a missing or non-container parent. That is not a failure to handle here: the\n * parent has an error of its own, and repairing *it* is what makes this position\n * reachable on a later pass.\n */\nexport const repairAt = (root: unknown, segments: readonly string[], value: unknown): unknown => {\n if (segments.length === 0) return value\n const [head, ...rest] = segments as [string, ...string[]]\n\n if (Array.isArray(root)) {\n const index = Number(head)\n // `index === root.length` is an append, which is what padding a short array\n // to its `minItems` is made of. Anything past that would leave a hole, and a\n // hole is not a repair.\n if (!Number.isInteger(index) || index < 0 || index > root.length) return root\n const next = repairAt(root[index], rest, value)\n if (next === root[index]) return root\n const copy = [...root]\n copy[index] = next\n return copy\n }\n\n if (typeof root !== 'object' || root === null) return root\n const obj = root as Record<string, unknown>\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. Deeper than that and the\n // parent is the thing that needs repairing first.\n if (!Object.hasOwn(obj, head) && rest.length > 0) return root\n const next = repairAt(obj[head], rest, value)\n if (next === obj[head] && Object.hasOwn(obj, head)) return root\n return { ...obj, [head]: next }\n}\n\n/**\n * The position one error is asking to have repaired, or `null` when the error is\n * not about a position a value can be put at.\n *\n * One keyword does not point at the value that is wrong: a `required` error is\n * reported against the *object* that is missing the key, and names the key in\n * `params.missingProperty`, so the position to fill is one segment deeper.\n * `minItems` is the other exception and is handled separately, because it is\n * satisfied by adding several values rather than by replacing one.\n */\nconst repairTarget = (error: ValidationError): readonly string[] | null => {\n const segments = pointerSegments(error.path)\n if (error.keyword === 'required') {\n const missing = error.params['missingProperty']\n return typeof missing === 'string' ? [...segments, missing] : null\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 segments.reduce<unknown>(\n (node, segment) =>\n Array.isArray(node)\n ? node[Number(segment)]\n : typeof node === 'object' && node !== null\n ? (node as Record<string, unknown>)[segment]\n : undefined,\n root,\n )\n\n/**\n * Grows a short array to the length `minItems` asks for, or `null` when it\n * cannot.\n *\n * Padding is its own operation rather than a position repair because one\n * `minItems` error is satisfied by *several* values, not one. Doing it a single\n * element per round would work, but it would spend a round per element and a\n * `minItems: 50` would run out of them; filling the shortfall at once keeps the\n * number of rounds a question about the shape of the document rather than about\n * the size of its arrays.\n *\n * Every added element is a separate call to the thunk, so no two share an object.\n */\nconst padToMinItems = (\n root: unknown,\n error: ValidationError,\n lookup: RepairLookup,\n done: Set<string>,\n): unknown => {\n const segments = pointerSegments(error.path)\n const array = valueAt(root, segments)\n const limit = error.params['limit']\n if (!Array.isArray(array) || typeof limit !== 'number' || array.length >= limit) return null\n\n // Keyed apart from a position repair at the same path: replacing the whole\n // array and padding it are different repairs, and having done one is no reason\n // to refuse the other.\n const key = `${segments.join('/')}#minItems`\n if (done.has(key)) return null\n\n const element = lookup([...segments, String(array.length)])\n if (element === null) return null\n\n const padded = [...array]\n while (padded.length < limit) padded.push(element())\n const updated = repairAt(root, segments, padded)\n if (updated === root) return null\n done.add(key)\n return updated\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. Refusing keeps the\n * loop finite without relying on the pass cap, and turns the position into an\n * honest error instead of a silent spin.\n */\nexport const applyRepairs = (\n value: unknown,\n errors: readonly ValidationError[],\n lookup: RepairLookup,\n done: Set<string>,\n): { value: unknown; repaired: ValidationError[] } => {\n const repaired: ValidationError[] = []\n let next = value\n\n for (const error of errors) {\n if (error.keyword === 'minItems') {\n const padded = padToMinItems(next, error, lookup, done)\n if (padded === null) continue\n next = padded\n repaired.push(error)\n continue\n }\n\n const target = repairTarget(error)\n if (target === null) continue\n const key = target.join('/')\n if (done.has(key)) continue\n const to = lookup(target)\n if (to === null) continue\n\n const updated = repairAt(next, target, to())\n // An unreachable position leaves the document exactly as it was. Not counting\n // it as repaired is what lets the parent's own error be the thing that\n // reports, rather than this silently claiming a fix that did not land.\n if (updated === next) continue\n done.add(key)\n next = updated\n repaired.push(error)\n }\n\n return { value: next, repaired }\n}\n";
15
+ export declare const VALIDATION_RESULT_CONTENT: string;
16
16
  /**
17
17
  * Builds all TypeScript validator files from a JSON Schema by traversing all
18
18
  * `$ref` / `$dynamicRef` references recursively (via the shared
@@ -3,6 +3,8 @@ import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
3
3
  import { walkRefGraph } from "@amritk/helpers/walk-ref-graph";
4
4
  import { emitFormatModule, FORMAT_FRAGMENTS, formatCheckName } from "./emit-format-checks.js";
5
5
  import { generateValidatorFile } from "./generate-files.js";
6
+ import { REPAIR_RUNTIME_CONTENT } from "./repair-runtime.js";
7
+ import { asksForRefs, repairWalkBlocker } from "./repair-walkable.js";
6
8
  const VALIDATION_RESULT_CONTENT = `/**
7
9
  * A single validation error: what went wrong, where, and which keyword said so.
8
10
  *
@@ -354,10 +356,17 @@ export const escapePointer = (key: string): string =>
354
356
  * was rejected on the value's *identity* (a \`const\` or \`enum\` on the value or
355
357
  * one of its own properties), the remaining one is the variant the author meant.
356
358
  *
357
- * Nothing is reported when no branch stands out, which is as much as can be said
358
- * honestly: "the branch with the fewest errors" would answer here too, and
359
- * answers wrongly on \`oneOf: [aReference, theActualThing]\`, where "you did not
360
- * write a $ref" is one complaint and the real mistake is two.
359
+ * When every branch rejected the value's kind, no branch was meant, but the
360
+ * kinds they wanted together are what the union accepts, so that is reported as
361
+ * one \`type\` error: "must be object or boolean" for a \`true\` written as the
362
+ * string \`'true'\`. \`total\` is how many branches the combinator has, and the kinds
363
+ * are only named when all of them were heard from. A branch that could not
364
+ * explain itself might have accepted this kind, and then the list would be wrong.
365
+ *
366
+ * Otherwise nothing is reported when no branch stands out, which is as much as
367
+ * can be said honestly: "the branch with the fewest errors" would answer here
368
+ * too, and answers wrongly on \`oneOf: [aReference, theActualThing]\`, where "you
369
+ * did not write a $ref" is one complaint and the real mistake is two.
361
370
  *
362
371
  * \`path\` is where the combinator was applied, so a segment below it is a direct
363
372
  * property of the value being judged. \`@amritk/runtime-validators\` selects the
@@ -367,6 +376,7 @@ export const escapePointer = (key: string): string =>
367
376
  export const selectBranchErrors = (
368
377
  branches: readonly (readonly ValidationError[])[],
369
378
  path: string,
379
+ total?: number,
370
380
  ): readonly ValidationError[] => {
371
381
  const candidates: (readonly ValidationError[])[] = []
372
382
  for (const errors of branches) {
@@ -374,6 +384,7 @@ export const selectBranchErrors = (
374
384
  if (!errors.some((error) => error.keyword === 'type' && error.path === path)) candidates.push(errors)
375
385
  }
376
386
  if (candidates.length === 1) return candidates[0] as readonly ValidationError[]
387
+ if (candidates.length === 0) return branches.length === total ? acceptedKinds(branches, path) : []
377
388
 
378
389
  let selected: readonly ValidationError[] | null = null
379
390
  let rejectedOnIdentity = 0
@@ -398,221 +409,39 @@ export const selectBranchErrors = (
398
409
  }
399
410
 
400
411
  /**
401
- * The result of a generated repairing validator.
402
- *
403
- * \`repairs\` is not a second, parallel account of what went wrong \u2014 it *is* the
404
- * errors \`validateX\` produced, the ones a repair was found for. So a caller that
405
- * logs a repair logs the same path, keyword and params it would have been
406
- * rejected with, and the two can never drift apart, because there is only one of
407
- * them.
408
- *
409
- * A document that needed no repair comes back \`valid: true\` with an empty
410
- * \`repairs\`, which is exactly what \`coerceX\` would have returned. One that was
411
- * fully repaired is \`valid: true\` with a non-empty \`repairs\` \u2014 the caller
412
- * decides whether that is acceptable by looking, rather than by being told. One
413
- * that could not be fully repaired is \`valid: false\` and carries both: the
414
- * repairs that were applied, and the \`errors\` still outstanding against the
415
- * value handed back.
416
- */
417
- export type RepairResult<T> =
418
- | { valid: true; value: T; repairs: ValidationError[] }
419
- | { valid: false; value: unknown; errors: ValidationError[]; repairs: ValidationError[] }
420
-
421
- /**
422
- * Looks up the repaired value for one position, given that position's JSON
423
- * Pointer split into segments. Returns a thunk rather than a value so each
424
- * repair gets its own object \u2014 a shared literal would alias every site that
425
- * repaired to it \u2014 and \`null\` where the schema offers nothing to repair toward.
412
+ * One \`type\` error naming every kind the branches asked for, in branch order and
413
+ * once each. A branch's own \`type\` may be a list, and a nested union reports its
414
+ * kinds as a list too, so both are read flat.
426
415
  */
427
- export type RepairLookup = (segments: readonly string[]) => (() => unknown) | null
428
-
429
- /**
430
- * How many validate-and-repair rounds \`repairX\` will run. Repairing a child can
431
- * expose a parent that only became checkable once the child was there, so one
432
- * round is not always enough; but each round is bounded further by the rule that
433
- * a position is repaired at most once, so this is a backstop rather than the
434
- * thing doing the terminating.
435
- */
436
- export const MAX_REPAIR_PASSES = 8
437
-
438
- /**
439
- * Splits a JSON Pointer into its segments, undoing the escaping
440
- * {@link escapePointer} applied. The root pointer (\`''\`) is zero segments.
441
- */
442
- export const pointerSegments = (path: string): string[] =>
443
- path === ''
444
- ? []
445
- : path
446
- .slice(1)
447
- .split('/')
448
- .map((segment) =>
449
- segment.indexOf('~') === -1 ? segment : segment.replace(/~1/g, '/').replace(/~0/g, '~'),
450
- )
451
-
452
- /**
453
- * Returns \`root\` with the position at \`segments\` replaced by \`value\`, copying
454
- * only the containers along the way.
455
- *
456
- * Copying rather than writing is not politeness, it is the same promise
457
- * \`coerceX\` makes: the caller's input is never modified, so a repaired document
458
- * can be compared against what was actually sent. Everything the path did not
459
- * touch is shared, so repairing one field of a large document does not clone it.
460
- *
461
- * Returns \`root\` unchanged when the path does not lead anywhere \u2014 a segment into
462
- * a missing or non-container parent. That is not a failure to handle here: the
463
- * parent has an error of its own, and repairing *it* is what makes this position
464
- * reachable on a later pass.
465
- */
466
- export const repairAt = (root: unknown, segments: readonly string[], value: unknown): unknown => {
467
- if (segments.length === 0) return value
468
- const [head, ...rest] = segments as [string, ...string[]]
469
-
470
- if (Array.isArray(root)) {
471
- const index = Number(head)
472
- // \`index === root.length\` is an append, which is what padding a short array
473
- // to its \`minItems\` is made of. Anything past that would leave a hole, and a
474
- // hole is not a repair.
475
- if (!Number.isInteger(index) || index < 0 || index > root.length) return root
476
- const next = repairAt(root[index], rest, value)
477
- if (next === root[index]) return root
478
- const copy = [...root]
479
- copy[index] = next
480
- return copy
481
- }
482
-
483
- if (typeof root !== 'object' || root === null) return root
484
- const obj = root as Record<string, unknown>
485
- // A missing key is reachable only when this is the last segment: that is the
486
- // \`required\` repair, which is putting the key there. Deeper than that and the
487
- // parent is the thing that needs repairing first.
488
- if (!Object.hasOwn(obj, head) && rest.length > 0) return root
489
- const next = repairAt(obj[head], rest, value)
490
- if (next === obj[head] && Object.hasOwn(obj, head)) return root
491
- return { ...obj, [head]: next }
492
- }
493
-
494
- /**
495
- * The position one error is asking to have repaired, or \`null\` when the error is
496
- * not about a position a value can be put at.
497
- *
498
- * One keyword does not point at the value that is wrong: a \`required\` error is
499
- * reported against the *object* that is missing the key, and names the key in
500
- * \`params.missingProperty\`, so the position to fill is one segment deeper.
501
- * \`minItems\` is the other exception and is handled separately, because it is
502
- * satisfied by adding several values rather than by replacing one.
503
- */
504
- const repairTarget = (error: ValidationError): readonly string[] | null => {
505
- const segments = pointerSegments(error.path)
506
- if (error.keyword === 'required') {
507
- const missing = error.params['missingProperty']
508
- return typeof missing === 'string' ? [...segments, missing] : null
509
- }
510
- return segments
511
- }
512
-
513
- /** The value at a pointer, or \`undefined\` when the path does not lead anywhere. */
514
- const valueAt = (root: unknown, segments: readonly string[]): unknown =>
515
- segments.reduce<unknown>(
516
- (node, segment) =>
517
- Array.isArray(node)
518
- ? node[Number(segment)]
519
- : typeof node === 'object' && node !== null
520
- ? (node as Record<string, unknown>)[segment]
521
- : undefined,
522
- root,
523
- )
524
-
525
- /**
526
- * Grows a short array to the length \`minItems\` asks for, or \`null\` when it
527
- * cannot.
528
- *
529
- * Padding is its own operation rather than a position repair because one
530
- * \`minItems\` error is satisfied by *several* values, not one. Doing it a single
531
- * element per round would work, but it would spend a round per element and a
532
- * \`minItems: 50\` would run out of them; filling the shortfall at once keeps the
533
- * number of rounds a question about the shape of the document rather than about
534
- * the size of its arrays.
535
- *
536
- * Every added element is a separate call to the thunk, so no two share an object.
537
- */
538
- const padToMinItems = (
539
- root: unknown,
540
- error: ValidationError,
541
- lookup: RepairLookup,
542
- done: Set<string>,
543
- ): unknown => {
544
- const segments = pointerSegments(error.path)
545
- const array = valueAt(root, segments)
546
- const limit = error.params['limit']
547
- if (!Array.isArray(array) || typeof limit !== 'number' || array.length >= limit) return null
548
-
549
- // Keyed apart from a position repair at the same path: replacing the whole
550
- // array and padding it are different repairs, and having done one is no reason
551
- // to refuse the other.
552
- const key = \`\${segments.join('/')}#minItems\`
553
- if (done.has(key)) return null
554
-
555
- const element = lookup([...segments, String(array.length)])
556
- if (element === null) return null
557
-
558
- const padded = [...array]
559
- while (padded.length < limit) padded.push(element())
560
- const updated = repairAt(root, segments, padded)
561
- if (updated === root) return null
562
- done.add(key)
563
- return updated
564
- }
565
-
566
- /**
567
- * Applies one round of repairs: every error the schema offers a value for is
568
- * repaired, and the rest are handed back untouched.
569
- *
570
- * \`done\` carries the positions already repaired on an earlier round. A position
571
- * that failed again after being repaired is not repaired a second time \u2014 the
572
- * value the schema offered did not satisfy the schema, which is a fact about the
573
- * schema, and trying again would only produce the same value. Refusing keeps the
574
- * loop finite without relying on the pass cap, and turns the position into an
575
- * honest error instead of a silent spin.
576
- */
577
- export const applyRepairs = (
578
- value: unknown,
579
- errors: readonly ValidationError[],
580
- lookup: RepairLookup,
581
- done: Set<string>,
582
- ): { value: unknown; repaired: ValidationError[] } => {
583
- const repaired: ValidationError[] = []
584
- let next = value
585
-
586
- for (const error of errors) {
587
- if (error.keyword === 'minItems') {
588
- const padded = padToMinItems(next, error, lookup, done)
589
- if (padded === null) continue
590
- next = padded
591
- repaired.push(error)
592
- continue
416
+ const acceptedKinds = (
417
+ branches: readonly (readonly ValidationError[])[],
418
+ path: string,
419
+ ): readonly ValidationError[] => {
420
+ const kinds: string[] = []
421
+ for (const errors of branches) {
422
+ for (const error of errors) {
423
+ if (error.keyword !== 'type' || error.path !== path) continue
424
+ const type = error.params['type']
425
+ for (const kind of Array.isArray(type) ? type : [type]) {
426
+ if (typeof kind === 'string' && !kinds.includes(kind)) kinds.push(kind)
427
+ }
593
428
  }
594
-
595
- const target = repairTarget(error)
596
- if (target === null) continue
597
- const key = target.join('/')
598
- if (done.has(key)) continue
599
- const to = lookup(target)
600
- if (to === null) continue
601
-
602
- const updated = repairAt(next, target, to())
603
- // An unreachable position leaves the document exactly as it was. Not counting
604
- // it as repaired is what lets the parent's own error be the thing that
605
- // reports, rather than this silently claiming a fix that did not land.
606
- if (updated === next) continue
607
- done.add(key)
608
- next = updated
609
- repaired.push(error)
610
429
  }
611
-
612
- return { value: next, repaired }
430
+ if (kinds.length === 0) return []
431
+ return [
432
+ {
433
+ message: 'must be ' + kinds.join(' or '),
434
+ path,
435
+ keyword: 'type',
436
+ params: { type: kinds.length === 1 ? kinds[0] : kinds },
437
+ },
438
+ ]
613
439
  }
614
- `;
440
+
441
+ ` + REPAIR_RUNTIME_CONTENT;
615
442
  const RESERVED_TYPE_NAMES = /* @__PURE__ */ new Set(["ValidationResult", "ValidationError"]);
443
+ const COERCE_TYPE_NAMES = ["CoercionResult"];
444
+ const REPAIR_TYPE_NAMES = ["RepairResult", "RepairLookup", "RepairOptions", "RepairThunk", "RepairWalk", "RepairRun"];
616
445
  const TYPE_NAME = /^[\p{ID_Start}_$][\p{ID_Continue}$]*$/u;
617
446
  const RESERVED_WORDS = /* @__PURE__ */ new Set([
618
447
  "await",
@@ -654,10 +483,34 @@ const RESERVED_WORDS = /* @__PURE__ */ new Set([
654
483
  "with",
655
484
  "yield"
656
485
  ]);
486
+ const decideRepairWalk = (nodes) => {
487
+ for (const node of nodes) {
488
+ const blocker = repairWalkBlocker(node.schema, node.isRoot);
489
+ if (blocker === null)
490
+ continue;
491
+ const refs = nodes.find((candidate) => asksForRefs(candidate.schema));
492
+ if (refs !== void 0) {
493
+ const owner = node.isRoot ? "the root schema" : `"${node.ref}"`;
494
+ throw new Error(`"x-repair": "ref" needs the single-pass repair walk, which ${owner} cannot use: ${blocker} is a keyword the walk does not repair. Remove it, or drop the "x-repair": "ref" opt-in.`);
495
+ }
496
+ return false;
497
+ }
498
+ return true;
499
+ };
657
500
  const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "", schemas, unknownKeys = DEFAULT_UNKNOWN_KEYS, formats, coerce = false, branchErrors = false, repair = false, importExt = "js", check = false) => {
658
501
  const enforced = formats === "all" ? new Set(Object.keys(FORMAT_FRAGMENTS)) : new Set((formats ?? []).filter((name) => Object.hasOwn(FORMAT_FRAGMENTS, name)));
659
502
  const files = [];
503
+ const reserved = /* @__PURE__ */ new Set([
504
+ ...RESERVED_TYPE_NAMES,
505
+ ...coerce || repair ? COERCE_TYPE_NAMES : [],
506
+ ...repair ? REPAIR_TYPE_NAMES : []
507
+ ]);
508
+ const nodes = [];
660
509
  walkRefGraph(rootSchema, rootTypeName, { typeSuffix, ...schemas !== void 0 ? { schemas } : {} }, (node) => {
510
+ nodes.push(node);
511
+ });
512
+ const repairWalk = repair && decideRepairWalk(nodes);
513
+ for (const node of nodes) {
661
514
  if (node.filename === "validation-result" || node.filename === "index") {
662
515
  const owner = node.isRoot ? `the root type "${node.typeName}"` : `"${node.ref}"`;
663
516
  const purpose = node.filename === "index" ? "the generated barrel" : "the generated validators' runtime contract";
@@ -668,7 +521,7 @@ const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "", s
668
521
  const suffixNote = typeSuffix === "" ? "" : ` (with the type suffix "${typeSuffix}")`;
669
522
  throw new Error(`${owner}${suffixNote}, "${node.typeName}", is not a TypeScript type name, so the generated file would not parse. Pass a name that is a plain identifier \u2014 letters, digits, "_" and "$", not starting with a digit, and not a reserved word.`);
670
523
  }
671
- if (RESERVED_TYPE_NAMES.has(node.typeName)) {
524
+ if (reserved.has(node.typeName)) {
672
525
  const owner = node.isRoot ? `the root type name "${node.typeName}"` : `"${node.ref}"`;
673
526
  throw new Error(`${owner} generates the type "${node.typeName}", which every generated file already imports from "validation-result.ts". Rename the definition (or pass a different root type name or type suffix) so the two names do not collide.`);
674
527
  }
@@ -681,11 +534,12 @@ const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "", s
681
534
  coerce,
682
535
  branchErrors,
683
536
  repair,
537
+ repairWalk,
684
538
  importExt,
685
539
  ...node.ref !== void 0 ? { selfRef: node.ref } : {}
686
540
  });
687
541
  files.push({ filename: `${node.filename}.ts`, content });
688
- });
542
+ }
689
543
  files.push({ filename: "validation-result.ts", content: VALIDATION_RESULT_CONTENT });
690
544
  const called = [...enforced].filter((format) => files.some((file) => file.content.includes(`${formatCheckName(format)}(`)));
691
545
  const formatModule = emitFormatModule(called);
@@ -46,6 +46,7 @@ type CollectValidatorImportsOptions = {
46
46
  readonly checkerName: string;
47
47
  readonly coercerName: string;
48
48
  readonly repairerName: string;
49
+ readonly walkerName: string;
49
50
  }) => {
50
51
  readonly type: boolean;
51
52
  readonly validator: boolean;
@@ -76,6 +77,12 @@ type CollectValidatorImportsOptions = {
76
77
  * without {@link coercer}, though the reverse is ordinary.
77
78
  */
78
79
  readonly repairer?: boolean;
80
+ /**
81
+ * The `$ref` target's single-pass repair walk. Only emitted when the whole
82
+ * schema graph is one the walk covers, and then called wherever a walk
83
+ * reaches the ref.
84
+ */
85
+ readonly walker?: boolean;
79
86
  };
80
87
  };
81
88
  /**