@amritk/validation 0.4.0 → 0.5.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 +9 -7
- package/README.md +42 -10
- package/dist/parsers/generators/generate-parser-function.js +1 -1
- package/dist/parsers/generators/generate-strict-assertion.js +6 -7
- package/dist/validators/generators/build-schema.d.ts +1 -1
- package/dist/validators/generators/build-schema.js +43 -4
- package/dist/validators/generators/generate-files.js +4 -1
- package/dist/validators/generators/generate-repair-function.d.ts +6 -1
- package/dist/validators/generators/generate-repair-function.js +70 -11
- package/dist/validators/generators/generate-validator-function.js +63 -52
- package/package.json +3 -3
package/AI.md
CHANGED
|
@@ -44,14 +44,16 @@ Every mode is reachable from `mjst` without dropping to the programmatic API:
|
|
|
44
44
|
|:---|:---|
|
|
45
45
|
| `types` | `--types-only` (alone), otherwise always emitted |
|
|
46
46
|
| `guard`, `validate` | `--validators`, or `--validators-only` for no parser |
|
|
47
|
-
| `check` | `--check` |
|
|
48
|
-
| `coerce` | `--coerce` |
|
|
49
|
-
| `repair` | `--repair` (emits `coerceX` too) |
|
|
47
|
+
| `check` | `--validators --check` |
|
|
48
|
+
| `coerce` | `--validators --coerce` |
|
|
49
|
+
| `repair` | `--validators --repair` (emits `coerceX` too) |
|
|
50
50
|
| `parse` | the default on any run that is not `--types-only`/`--validators-only` |
|
|
51
51
|
| `parseStrict` | `--strict` |
|
|
52
52
|
|
|
53
53
|
`--validators-only` is this package's own default (`['types', 'guard', 'validate']`)
|
|
54
54
|
spelled as a flag: everything that judges a document, nothing that builds one.
|
|
55
|
+
`--check`, `--coerce` and `--repair` shape the validator half, so the CLI rejects
|
|
56
|
+
any of them without one of those two flags.
|
|
55
57
|
|
|
56
58
|
## Gotchas — where agents fail
|
|
57
59
|
|
|
@@ -83,10 +85,10 @@ spelled as a flag: everything that judges a document, nothing that builds one.
|
|
|
83
85
|
validateX(v)` pays for both passes and is no faster than `validateX`.
|
|
84
86
|
|
|
85
87
|
8. **It emits each engine's exact bytes.** A single-mode build is byte-identical
|
|
86
|
-
to what
|
|
87
|
-
difference to reason about and no speedup to claim — do not tell a
|
|
88
|
-
faster. What it changes is that the whole matrix comes out as one
|
|
89
|
-
of two, and that a build asking for no validator mode ships no
|
|
88
|
+
to what the engine that owns that mode emits when called directly, so there is
|
|
89
|
+
no runtime difference to reason about and no speedup to claim — do not tell a
|
|
90
|
+
user this is faster. What it changes is that the whole matrix comes out as one
|
|
91
|
+
type instead of two, and that a build asking for no validator mode ships no
|
|
90
92
|
`validation-result.ts`.
|
|
91
93
|
|
|
92
94
|
9. **`coerceX` is not Ajv's `coerceTypes`, on purpose.** It never modifies its
|
package/README.md
CHANGED
|
@@ -74,6 +74,7 @@ so no two can disagree about whether a document is valid.
|
|
|
74
74
|
| `types` | — | — | — | — |
|
|
75
75
|
| `guard` | `isX(input): input is X` | — | a boolean | the first problem |
|
|
76
76
|
| `validate` | `validateX(input)` | — | every error | the end |
|
|
77
|
+
| `check` | `checkX(input)` | — | the first error | the first problem |
|
|
77
78
|
| `coerce` | `coerceX(input)` | coerced | every error | the end |
|
|
78
79
|
| `repair` | `repairX(input)` | repaired | repairs **and** errors | the end |
|
|
79
80
|
| `parse` | `parseX(input)` | repaired | nothing | never fails |
|
|
@@ -84,7 +85,9 @@ it stops at the first thing wrong and builds no error object, which on invalid
|
|
|
84
85
|
input is dramatically cheaper than reporting. `validate` is the other half: every
|
|
85
86
|
error, each with a JSON Pointer, the keyword that rejected the value, and that
|
|
86
87
|
keyword's own values. Reach for `guard` when the answer is a branch and
|
|
87
|
-
`validate` when a human or an API client has to be told what to fix.
|
|
88
|
+
`validate` when a human or an API client has to be told what to fix. `check` sits
|
|
89
|
+
between them: `checkX` returns the same result type as `validateX`, holding only
|
|
90
|
+
the error `validateX` would have reported first, and walks no further.
|
|
88
91
|
|
|
89
92
|
**Coerce and repair, with or without the errors.** `coerceX` and `repairX` hand
|
|
90
93
|
back the value *and* the diagnostics; `parseX` hands back only the value and
|
|
@@ -92,6 +95,14 @@ never fails. Coercion moves a value that is already right but written in the
|
|
|
92
95
|
wrong type; repair also substitutes a value the schema supplies for one that
|
|
93
96
|
cannot be coerced, and reports the validator's own errors as the repairs.
|
|
94
97
|
|
|
98
|
+
That makes `repairX` an autofix tool rather than a stricter validator. It accepts
|
|
99
|
+
documents `validateX` rejects (a missing required name gets one; an unknown
|
|
100
|
+
`enum` value becomes the first member), and accepting only `valid` with an empty
|
|
101
|
+
`repairs` gives exactly the verdicts `coerceX` gives. An empty `repairs` alone is
|
|
102
|
+
not enough: a document nothing could repair comes back with none either, and
|
|
103
|
+
`valid: false`. To reject a bad config, use `coerceX`. To hand back a fixed one,
|
|
104
|
+
use `repairX`.
|
|
105
|
+
|
|
95
106
|
**`parseX` agrees with `coerceX`.** Wherever `coerceX` accepts a document, the
|
|
96
107
|
coercing `parseX` returns the very same value — through a union, an `allOf`, an
|
|
97
108
|
`if`, a `$ref`, recursion included — and it only repairs what `coerceX` would
|
|
@@ -119,11 +130,19 @@ schemas with `anyOf`, `oneOf`, `allOf` and `if`/`then`/`else` checks every
|
|
|
119
130
|
accepted document against an Ajv that does not coerce. Where the two differ, it
|
|
120
131
|
is on purpose:
|
|
121
132
|
|
|
122
|
-
- **`null` is never coerced**, in either direction
|
|
123
|
-
|
|
124
|
-
`
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
- **`null` is never coerced**, in either direction, and it is the difference
|
|
134
|
+
you will meet most: on a real config corpus it was nearly every case where Ajv
|
|
135
|
+
accepted and `coerceX` did not. Ajv reads `null` as `""`, `0` or `false` for a
|
|
136
|
+
string, number or boolean field, and reads those back as `null`. A `null`
|
|
137
|
+
usually means "not set", and turning it into a real value erases that. Here it
|
|
138
|
+
goes to the validator as written, and the validator rejects it.
|
|
139
|
+
|
|
140
|
+
Before switching, check what your callers do with a rejected document. If one
|
|
141
|
+
treats rejection as absence (returns `undefined`, falls back to a default),
|
|
142
|
+
a single `null` deep inside a block makes the whole block vanish without an
|
|
143
|
+
error. Ajv's coercion used to hide that. Either let the schema say `null` is
|
|
144
|
+
allowed (`type: ['string', 'null']`), drop `null`s before validating, or have
|
|
145
|
+
the caller surface the errors instead of discarding the value.
|
|
127
146
|
- **Only clean numerals become numbers.** `" "`, `" 1 "`, `"0x10"`, `"Infinity"`
|
|
128
147
|
and `"1."` are rejected. Ajv turns them into `0`, `1`, `16`, a value JSON
|
|
129
148
|
cannot hold, and `1`. `"007"` and `"1e3"` are still coerced.
|
|
@@ -146,8 +165,8 @@ is on purpose:
|
|
|
146
165
|
document has to clone it first. `coerceX` returns a new value that shares
|
|
147
166
|
everything it did not touch, or the input itself when nothing needed coercing.
|
|
148
167
|
|
|
149
|
-
**It is faster.** Against Ajv cloning first, so
|
|
150
|
-
alone (`bun run bench:validators:coerce`, or
|
|
168
|
+
**It is faster where the input has to survive.** Against Ajv cloning first, so
|
|
169
|
+
both leave the caller's document alone (`bun run bench:validators:coerce`, or
|
|
151
170
|
`bench:validators:coerce:node`):
|
|
152
171
|
|
|
153
172
|
| schema | runtime | valid input | needs coercing | cannot be coerced |
|
|
@@ -166,7 +185,11 @@ each engine timed in its own process (Bun 1.3.11, Node 22.22, Linux x64). A
|
|
|
166
185
|
valid document is the common case and the widest gap: where `isX` is a
|
|
167
186
|
standalone guard, `coerceX` answers it with that guard and hands the input
|
|
168
187
|
straight back, without walking it. On valid input Ajv rewrites nothing, so it can
|
|
169
|
-
also be timed without the clone; `coerceX` is still ahead there
|
|
188
|
+
also be timed without the clone; `coerceX` is still ahead there on these
|
|
189
|
+
schemas, by 1.2–3.3×, but not on every one: on a large, union-heavy document
|
|
190
|
+
that the caller lets Ajv coerce in place, Ajv can come out modestly ahead. The
|
|
191
|
+
gap above is the clone, so it is real exactly where the caller cannot let Ajv
|
|
192
|
+
mutate its input.
|
|
170
193
|
The config case is the shape `--coerce` exists for: every coercible scalar sits
|
|
171
194
|
inside a union reached through `$ref`, and every union branch is tested by a
|
|
172
195
|
named function rather than a closure built per call.</sub>
|
|
@@ -191,15 +214,22 @@ holding it (`/readme`). `params.additionalProperty` names the key in both.
|
|
|
191
214
|
| `options.schemas` | `Record<string, unknown>` | — | Documents you have already loaded, keyed by the URI a `$ref` names them by. |
|
|
192
215
|
| `options.unknownKeys` | `'count-keys' \| 'count-enumerable'` | `'count-keys'` | How a closed object's fast path proves it carries no undeclared key. |
|
|
193
216
|
| `options.formats` | `'all' \| string[]` | — | String `format`s the validators enforce. Unset leaves `format` an annotation. |
|
|
194
|
-
| `options.branchErrors` | `boolean` | `false` | Explain a failing `anyOf` / `oneOf` with the branch it plainly meant. |
|
|
217
|
+
| `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. |
|
|
195
218
|
| `options.stripUnknown` | `boolean` | `false` | Build each parsed result from the declared properties only. |
|
|
196
219
|
| `options.readonly` | `boolean` | `false` | Emit `readonly` type members. |
|
|
197
220
|
| `options.caseInsensitive` | `boolean` | `false` | Normalize a mis-cased string onto an `enum` member it matches. |
|
|
198
221
|
| `options.helpersMode` | `'package' \| 'embedded'` | `'package'` | Where the parser half gets its runtime helpers. |
|
|
222
|
+
| `options.helpersImportPrefix` | `string` | `'./'` | Prefix on emitted-helper import specifiers, when `helpersMode` is `'embedded'`. |
|
|
199
223
|
| `options.extensions` | `SchemaExtensions` | — | Schema extensions, passed through to the parser half. |
|
|
224
|
+
| `options.importExt` | `'js' \| 'ts'` | `'js'` | Extension on every emitted relative import. `'ts'` emits the on-disk paths, so the output runs under Node's type stripping with no build step. |
|
|
225
|
+
| `options.logWarnings` | `boolean` | `false` | Print the parser half's warnings about the schema, such as a keyword it cannot enforce. |
|
|
200
226
|
|
|
201
227
|
Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; content: string }`.
|
|
202
228
|
|
|
229
|
+
The package also exports `ALL_MODES` (every mode but `parseStrict`, for a caller
|
|
230
|
+
who wants the lot) and the types `Mode`, `GenerateOptions`, `GeneratedFile` and
|
|
231
|
+
`ImportExtension`.
|
|
232
|
+
|
|
203
233
|
---
|
|
204
234
|
|
|
205
235
|
## Output layout
|
|
@@ -208,6 +238,8 @@ Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; c
|
|
|
208
238
|
config.ts the type, plus isX / validateX / coerceX / repairX
|
|
209
239
|
config.parse.ts parseX, importing the type from ./config.js
|
|
210
240
|
validation-result.ts the shared error and result types, and the runtime helpers
|
|
241
|
+
formats.ts the format checks, only when `formats` enforces one the schema names
|
|
242
|
+
_helpers/ the parser's runtime helpers, only when `helpersMode` is 'embedded'
|
|
211
243
|
index.ts a barrel over all of it
|
|
212
244
|
```
|
|
213
245
|
|
|
@@ -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) =>
|
|
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 ${
|
|
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 ${
|
|
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.
|
|
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 ${
|
|
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.
|
|
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 ${
|
|
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 = "/**\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 * When every branch rejected the value's kind, no branch was meant, but the\n * kinds they wanted together are what the union accepts, so that is reported as\n * one `type` error: \"must be object or boolean\" for a `true` written as the\n * string `'true'`. `total` is how many branches the combinator has, and the kinds\n * are only named when all of them were heard from. A branch that could not\n * explain itself might have accepted this kind, and then the list would be wrong.\n *\n * Otherwise nothing is reported when no branch stands out, which is as much as\n * can be said honestly: \"the branch with the fewest errors\" would answer here\n * too, and answers wrongly on `oneOf: [aReference, theActualThing]`, where \"you\n * did not 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 total?: number,\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 if (candidates.length === 0) return branches.length === total ? acceptedKinds(branches, path) : []\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 * One `type` error naming every kind the branches asked for, in branch order and\n * once each. A branch's own `type` may be a list, and a nested union reports its\n * kinds as a list too, so both are read flat.\n */\nconst acceptedKinds = (\n branches: readonly (readonly ValidationError[])[],\n path: string,\n): readonly ValidationError[] => {\n const kinds: string[] = []\n for (const errors of branches) {\n for (const error of errors) {\n if (error.keyword !== 'type' || error.path !== path) continue\n const type = error.params['type']\n for (const kind of Array.isArray(type) ? type : [type]) {\n if (typeof kind === 'string' && !kinds.includes(kind)) kinds.push(kind)\n }\n }\n }\n if (kinds.length === 0) return []\n return [\n {\n message: 'must be ' + kinds.join(' or '),\n path,\n keyword: 'type',\n params: { type: kinds.length === 1 ? kinds[0] : kinds },\n },\n ]\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";
|
|
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
|
|
@@ -354,10 +354,17 @@ export const escapePointer = (key: string): string =>
|
|
|
354
354
|
* was rejected on the value's *identity* (a \`const\` or \`enum\` on the value or
|
|
355
355
|
* one of its own properties), the remaining one is the variant the author meant.
|
|
356
356
|
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
357
|
+
* When every branch rejected the value's kind, no branch was meant, but the
|
|
358
|
+
* kinds they wanted together are what the union accepts, so that is reported as
|
|
359
|
+
* one \`type\` error: "must be object or boolean" for a \`true\` written as the
|
|
360
|
+
* string \`'true'\`. \`total\` is how many branches the combinator has, and the kinds
|
|
361
|
+
* are only named when all of them were heard from. A branch that could not
|
|
362
|
+
* explain itself might have accepted this kind, and then the list would be wrong.
|
|
363
|
+
*
|
|
364
|
+
* Otherwise nothing is reported when no branch stands out, which is as much as
|
|
365
|
+
* can be said honestly: "the branch with the fewest errors" would answer here
|
|
366
|
+
* too, and answers wrongly on \`oneOf: [aReference, theActualThing]\`, where "you
|
|
367
|
+
* did not write a $ref" is one complaint and the real mistake is two.
|
|
361
368
|
*
|
|
362
369
|
* \`path\` is where the combinator was applied, so a segment below it is a direct
|
|
363
370
|
* property of the value being judged. \`@amritk/runtime-validators\` selects the
|
|
@@ -367,6 +374,7 @@ export const escapePointer = (key: string): string =>
|
|
|
367
374
|
export const selectBranchErrors = (
|
|
368
375
|
branches: readonly (readonly ValidationError[])[],
|
|
369
376
|
path: string,
|
|
377
|
+
total?: number,
|
|
370
378
|
): readonly ValidationError[] => {
|
|
371
379
|
const candidates: (readonly ValidationError[])[] = []
|
|
372
380
|
for (const errors of branches) {
|
|
@@ -374,6 +382,7 @@ export const selectBranchErrors = (
|
|
|
374
382
|
if (!errors.some((error) => error.keyword === 'type' && error.path === path)) candidates.push(errors)
|
|
375
383
|
}
|
|
376
384
|
if (candidates.length === 1) return candidates[0] as readonly ValidationError[]
|
|
385
|
+
if (candidates.length === 0) return branches.length === total ? acceptedKinds(branches, path) : []
|
|
377
386
|
|
|
378
387
|
let selected: readonly ValidationError[] | null = null
|
|
379
388
|
let rejectedOnIdentity = 0
|
|
@@ -397,6 +406,36 @@ export const selectBranchErrors = (
|
|
|
397
406
|
return selected !== null && rejectedOnIdentity === candidates.length - 1 ? selected : []
|
|
398
407
|
}
|
|
399
408
|
|
|
409
|
+
/**
|
|
410
|
+
* One \`type\` error naming every kind the branches asked for, in branch order and
|
|
411
|
+
* once each. A branch's own \`type\` may be a list, and a nested union reports its
|
|
412
|
+
* kinds as a list too, so both are read flat.
|
|
413
|
+
*/
|
|
414
|
+
const acceptedKinds = (
|
|
415
|
+
branches: readonly (readonly ValidationError[])[],
|
|
416
|
+
path: string,
|
|
417
|
+
): readonly ValidationError[] => {
|
|
418
|
+
const kinds: string[] = []
|
|
419
|
+
for (const errors of branches) {
|
|
420
|
+
for (const error of errors) {
|
|
421
|
+
if (error.keyword !== 'type' || error.path !== path) continue
|
|
422
|
+
const type = error.params['type']
|
|
423
|
+
for (const kind of Array.isArray(type) ? type : [type]) {
|
|
424
|
+
if (typeof kind === 'string' && !kinds.includes(kind)) kinds.push(kind)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (kinds.length === 0) return []
|
|
429
|
+
return [
|
|
430
|
+
{
|
|
431
|
+
message: 'must be ' + kinds.join(' or '),
|
|
432
|
+
path,
|
|
433
|
+
keyword: 'type',
|
|
434
|
+
params: { type: kinds.length === 1 ? kinds[0] : kinds },
|
|
435
|
+
},
|
|
436
|
+
]
|
|
437
|
+
}
|
|
438
|
+
|
|
400
439
|
/**
|
|
401
440
|
* The result of a generated repairing validator.
|
|
402
441
|
*
|
|
@@ -25,7 +25,10 @@ const generateValidatorFile = (schema, typeName, options) => {
|
|
|
25
25
|
formats,
|
|
26
26
|
standaloneGuard
|
|
27
27
|
}).code : "";
|
|
28
|
-
const repairer = options?.repair === true ? generateRepairFunction(schema, typeName, typeSuffix
|
|
28
|
+
const repairer = options?.repair === true ? generateRepairFunction(schema, typeName, typeSuffix, {
|
|
29
|
+
...options?.rootSchema !== void 0 ? { rootSchema: options.rootSchema } : {},
|
|
30
|
+
formats
|
|
31
|
+
}).code : "";
|
|
29
32
|
const appended = [checker, coercer, repairer].filter((part) => part !== "");
|
|
30
33
|
const body = validatorFunction + booleanGuard + (appended.length === 0 ? "" : "\n\n" + appended.join("\n\n"));
|
|
31
34
|
const mentions = identifierMentions(typeDefinition + body);
|
|
@@ -22,6 +22,11 @@ export declare const repairLookupNameFor: (ref: string, typeSuffix: string) => s
|
|
|
22
22
|
* then can its own children be judged. A position is repaired at most once, so
|
|
23
23
|
* the loop terminates on progress rather than on the pass cap.
|
|
24
24
|
*/
|
|
25
|
-
export declare const generateRepairFunction: (schema: JSONSchema, typeName: string, typeSuffix?: string
|
|
25
|
+
export declare const generateRepairFunction: (schema: JSONSchema, typeName: string, typeSuffix?: string, options?: {
|
|
26
|
+
/** The whole document, for a `propertyNames` test that reads a `$ref`'s target. */
|
|
27
|
+
readonly rootSchema?: Record<string, unknown>;
|
|
28
|
+
/** The `format` names the validator enforces, so a key test agrees with it. */
|
|
29
|
+
readonly formats?: ReadonlySet<string>;
|
|
30
|
+
}) => {
|
|
26
31
|
code: string;
|
|
27
32
|
};
|
|
@@ -1,8 +1,24 @@
|
|
|
1
|
+
import { regexFlagsFor } from "@amritk/helpers/escape-regex-pattern";
|
|
1
2
|
import { getDefaultValue } from "@amritk/helpers/get-default-value";
|
|
2
3
|
import { readKey } from "@amritk/helpers/read-key";
|
|
3
4
|
import { refToName } from "@amritk/helpers/ref-to-name";
|
|
4
5
|
import { isSchemaObject } from "@amritk/helpers/schema-guards";
|
|
6
|
+
import { NO_FORMATS } from "./enforced-keywords.js";
|
|
7
|
+
import { createSubschemaMatcher } from "./generate-validator-function.js";
|
|
5
8
|
const repairLookupNameFor = (ref, typeSuffix) => `repair${refToName(ref, typeSuffix)}At`;
|
|
9
|
+
const keyTest = (nameSchema, ctx) => {
|
|
10
|
+
const named = ctx.matcher.named(nameSchema);
|
|
11
|
+
if (named !== null)
|
|
12
|
+
return named;
|
|
13
|
+
const body = ctx.matcher.test(nameSchema);
|
|
14
|
+
if (typeof body !== "string")
|
|
15
|
+
return body;
|
|
16
|
+
const name = `_repairKey${ctx.next()}`;
|
|
17
|
+
const parameter = /\binput\b/.test(body) ? "input" : "_input";
|
|
18
|
+
const path = /\b_path\b/.test(body) ? [` const _path = ''`] : [];
|
|
19
|
+
ctx.helpers.push([`const ${name} = (${parameter}: unknown): boolean => {`, ...path, body, `}`].join("\n"));
|
|
20
|
+
return name;
|
|
21
|
+
};
|
|
6
22
|
const fallbackLiteral = (schema) => {
|
|
7
23
|
const literal = getDefaultValue(schema);
|
|
8
24
|
return literal === "undefined" ? null : literal;
|
|
@@ -21,12 +37,20 @@ const emitWalker = (schema, ctx) => {
|
|
|
21
37
|
const own = fallbackLiteral(schema);
|
|
22
38
|
const properties = schemaMap(node, "properties") ?? {};
|
|
23
39
|
const children = Object.entries(properties).map(([key, child]) => [key, emitWalker(child, ctx)]).filter((entry) => entry[1] !== null);
|
|
40
|
+
const patternProperties = schemaMap(node, "patternProperties") ?? {};
|
|
41
|
+
const patterns = Object.entries(patternProperties).map(([source, child]) => ({
|
|
42
|
+
source,
|
|
43
|
+
walker: emitWalker(child, ctx)
|
|
44
|
+
}));
|
|
45
|
+
const additional = readKey(node, "additionalProperties");
|
|
46
|
+
const additionalWalker = typeof additional === "object" && additional !== null ? emitWalker(additional, ctx) : null;
|
|
47
|
+
const mapped = additionalWalker !== null || patterns.some((pattern) => pattern.walker !== null);
|
|
24
48
|
const prefixItems = readKey(node, "prefixItems");
|
|
25
49
|
const tuple = Array.isArray(prefixItems) ? prefixItems : [];
|
|
26
50
|
const tupleWalkers = tuple.map((item) => emitWalker(item, ctx));
|
|
27
51
|
const items = readKey(node, "items");
|
|
28
52
|
const itemsWalker = typeof items === "object" && items !== null && !Array.isArray(items) ? emitWalker(items, ctx) : null;
|
|
29
|
-
const hasChildren = children.length > 0 || itemsWalker !== null || tupleWalkers.some((walker) => walker !== null);
|
|
53
|
+
const hasChildren = children.length > 0 || mapped || itemsWalker !== null || tupleWalkers.some((walker) => walker !== null);
|
|
30
54
|
if (own === null && !hasChildren)
|
|
31
55
|
return null;
|
|
32
56
|
const id = ctx.next();
|
|
@@ -36,15 +60,28 @@ const emitWalker = (schema, ctx) => {
|
|
|
36
60
|
` if (segments.length === 0) return ${own === null ? "null" : `() => (${own})`}`
|
|
37
61
|
];
|
|
38
62
|
if (hasChildren) {
|
|
39
|
-
|
|
40
|
-
|
|
63
|
+
const declared = mapped ? Object.keys(properties) : children.map(([key]) => key);
|
|
64
|
+
const indexed = tupleWalkers.some((walker) => walker !== null) || itemsWalker !== null;
|
|
65
|
+
const arrayKeywords = tuple.length > 0 || typeof items === "object" && items !== null && !Array.isArray(items);
|
|
66
|
+
const indexBlock = indexed || mapped && arrayKeywords;
|
|
67
|
+
const nameSchema = readKey(node, "propertyNames");
|
|
68
|
+
const keyCheck = nameSchema === void 0 ? true : keyTest(nameSchema, ctx);
|
|
69
|
+
const readsHead = declared.length > 0 || indexBlock || mapped && patterns.length > 0 || typeof keyCheck === "string";
|
|
70
|
+
lines.push(` const [${readsHead ? "head" : ""}, ...rest] = segments as [string, ...string[]]`);
|
|
71
|
+
if (keyCheck === false)
|
|
72
|
+
lines.push(` if (rest.length === 0) return null`);
|
|
73
|
+
if (typeof keyCheck === "string")
|
|
74
|
+
lines.push(` if (rest.length === 0 && !${keyCheck}(head)) return null`);
|
|
75
|
+
if (declared.length > 0) {
|
|
76
|
+
const walkers = new Map(children);
|
|
41
77
|
lines.push(` switch (head) {`);
|
|
42
|
-
for (const
|
|
43
|
-
|
|
78
|
+
for (const key of declared) {
|
|
79
|
+
const walker = walkers.get(key);
|
|
80
|
+
lines.push(` case ${JSON.stringify(key)}: return ${walker === void 0 ? "null" : `${walker}(rest)`}`);
|
|
81
|
+
}
|
|
44
82
|
lines.push(` }`);
|
|
45
83
|
}
|
|
46
|
-
|
|
47
|
-
if (indexed) {
|
|
84
|
+
if (indexBlock) {
|
|
48
85
|
lines.push(` const index = Number(head)`);
|
|
49
86
|
lines.push(` if (Number.isInteger(index) && index >= 0) {`);
|
|
50
87
|
tupleWalkers.forEach((walker, position) => {
|
|
@@ -55,21 +92,43 @@ const emitWalker = (schema, ctx) => {
|
|
|
55
92
|
if (itemsWalker !== null) {
|
|
56
93
|
lines.push(` if (index >= ${tuple.length}) return ${itemsWalker}(rest)`);
|
|
57
94
|
}
|
|
95
|
+
if (mapped && arrayKeywords)
|
|
96
|
+
lines.push(` return null`);
|
|
58
97
|
lines.push(` }`);
|
|
59
98
|
}
|
|
99
|
+
if (mapped && patterns.length > 0) {
|
|
100
|
+
const regexNames = patterns.map((pattern, index) => {
|
|
101
|
+
const regexName = `_repairPattern${id}_${index}`;
|
|
102
|
+
ctx.helpers.push(`const ${regexName} = new RegExp(${JSON.stringify(pattern.source)}, ${JSON.stringify(regexFlagsFor(pattern.source))})`);
|
|
103
|
+
return regexName;
|
|
104
|
+
});
|
|
105
|
+
const answer = (walker) => walker === null ? "null" : `${walker}(rest)`;
|
|
106
|
+
const only = patterns[0];
|
|
107
|
+
if (patterns.length === 1 && only !== void 0) {
|
|
108
|
+
lines.push(` if (${regexNames[0]}.test(head)) return ${answer(only.walker)}`);
|
|
109
|
+
} else {
|
|
110
|
+
lines.push(` const claims = [${regexNames.join(", ")}].filter((pattern) => pattern.test(head))`);
|
|
111
|
+
lines.push(` if (claims.length > 1) return null`);
|
|
112
|
+
patterns.forEach((pattern, index) => {
|
|
113
|
+
lines.push(` if (claims[0] === ${regexNames[index]}) return ${answer(pattern.walker)}`);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
60
117
|
}
|
|
61
|
-
lines.push(` return null`, `}`);
|
|
118
|
+
lines.push(additionalWalker === null ? ` return null` : ` return ${additionalWalker}(rest)`, `}`);
|
|
62
119
|
ctx.helpers.push(lines.join("\n"));
|
|
63
120
|
return name;
|
|
64
121
|
};
|
|
65
|
-
const generateRepairFunction = (schema, typeName, typeSuffix = "") => {
|
|
122
|
+
const generateRepairFunction = (schema, typeName, typeSuffix = "", options = {}) => {
|
|
66
123
|
const helpers = [];
|
|
67
124
|
let counter = 0;
|
|
68
|
-
const
|
|
125
|
+
const matcher = createSubschemaMatcher(typeSuffix, options.rootSchema ?? (isSchemaObject(schema) ? schema : void 0), options.formats ?? NO_FORMATS, "_repair");
|
|
126
|
+
const ctx = { typeSuffix, helpers, next: () => counter++, matcher };
|
|
69
127
|
const walker = emitWalker(schema, ctx);
|
|
70
128
|
const lookupName = `repair${typeName}At`;
|
|
129
|
+
const declarations = [...matcher.declarations(helpers.join("\n")), ...helpers];
|
|
71
130
|
const parts = [
|
|
72
|
-
...
|
|
131
|
+
...declarations.length > 0 ? [declarations.join("\n\n"), ""] : [],
|
|
73
132
|
`export const ${lookupName}: RepairLookup = ${walker === null ? "() => null" : walker}`,
|
|
74
133
|
"",
|
|
75
134
|
`export const repair${typeName} = (input: unknown, _path = ''): RepairResult<${typeName}> => {`,
|
|
@@ -26,11 +26,6 @@ const hasOwnCheck = (objVar, key) => {
|
|
|
26
26
|
return `${JSON.stringify(key)} in ${objVar}`;
|
|
27
27
|
};
|
|
28
28
|
const missingCheck = (objVar, key) => `!(${hasOwnCheck(objVar, key)})`;
|
|
29
|
-
const typeofString = (type) => {
|
|
30
|
-
if (type === "integer")
|
|
31
|
-
return "number";
|
|
32
|
-
return type;
|
|
33
|
-
};
|
|
34
29
|
const constMismatchCondition = (accessor, value) => {
|
|
35
30
|
if (value === null || typeof value !== "object") {
|
|
36
31
|
return `${accessor} !== ${JSON.stringify(value)}`;
|
|
@@ -292,7 +287,7 @@ const generateKeywordChecks = (key, raw, path, schema, suffix, ctx, presence) =>
|
|
|
292
287
|
lines.push(` }`);
|
|
293
288
|
}
|
|
294
289
|
if (hasConst(schema)) {
|
|
295
|
-
const mismatch = constMismatchCondition(raw, schema.const);
|
|
290
|
+
const mismatch = constMismatchCondition(typeTestAccessor(raw, ctx), schema.const);
|
|
296
291
|
const msg = JSON.stringify(`must be ${JSON.stringify(schema.const)}`);
|
|
297
292
|
lines.push(` if (${presence}${mismatch}) {`);
|
|
298
293
|
lines.push(` ${pushError(ctx.sink, msg, path, "const", JSON.stringify({ allowedValue: schema.const }))}`);
|
|
@@ -300,7 +295,7 @@ const generateKeywordChecks = (key, raw, path, schema, suffix, ctx, presence) =>
|
|
|
300
295
|
}
|
|
301
296
|
if (hasEnum(schema)) {
|
|
302
297
|
const label = schema.enum.map((v) => JSON.stringify(v)).join(", ");
|
|
303
|
-
lines.push(` if (${presence}!${enumMembershipExpr(schema.enum, raw)}) {`);
|
|
298
|
+
lines.push(` if (${presence}!${enumMembershipExpr(schema.enum, typeTestAccessor(raw, ctx))}) {`);
|
|
304
299
|
lines.push(` ${pushError(ctx.sink, JSON.stringify(`must be one of: ${label}`), path, "enum", JSON.stringify({ allowedValues: schema.enum }))}`);
|
|
305
300
|
lines.push(` }`);
|
|
306
301
|
}
|
|
@@ -310,7 +305,7 @@ const generateKeywordChecks = (key, raw, path, schema, suffix, ctx, presence) =>
|
|
|
310
305
|
const wrongType = wrongTypeCondition(typeTestAccessor(raw, ctx), t);
|
|
311
306
|
if (wrongType) {
|
|
312
307
|
lines.push(` if (${presence === "" ? wrongType : `${presence}(${wrongType})`}) {`);
|
|
313
|
-
lines.push(` ${pushError(ctx.sink, `'must be ${
|
|
308
|
+
lines.push(` ${pushError(ctx.sink, `'must be ${t}'`, path, "type", JSON.stringify({ type: t }))}`);
|
|
314
309
|
lines.push(` }`);
|
|
315
310
|
}
|
|
316
311
|
}
|
|
@@ -318,7 +313,7 @@ const generateKeywordChecks = (key, raw, path, schema, suffix, ctx, presence) =>
|
|
|
318
313
|
if (typeArray) {
|
|
319
314
|
const allWrong = typeArray.map((t) => wrongTypeCondition(typeTestAccessor(raw, ctx), t)).filter((c) => c !== "").map((c) => `(${c})`).join(" && ");
|
|
320
315
|
if (allWrong) {
|
|
321
|
-
const label = typeArray.
|
|
316
|
+
const label = typeArray.join(" or ");
|
|
322
317
|
lines.push(` if (${presence === "" ? allWrong : `${presence}(${allWrong})`}) {`);
|
|
323
318
|
lines.push(` ${pushError(ctx.sink, JSON.stringify(`must be ${label}`), path, "type", JSON.stringify({ type: schema.type }))}`);
|
|
324
319
|
lines.push(` }`);
|
|
@@ -573,7 +568,7 @@ const generateValueCheckLines = (_key, raw, path, propSchema, suffix, ctx, requi
|
|
|
573
568
|
lines.push(...generateKeywordChecks("", raw, path, propSchema, suffix, valueCtx, presence));
|
|
574
569
|
return lines;
|
|
575
570
|
};
|
|
576
|
-
const NARROWABLE_REFERENCE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\[[A-Za-z_$][\w$]*\]|\[\d+\])*$/;
|
|
571
|
+
const NARROWABLE_REFERENCE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\["[A-Za-z_$][\w$]*"\]|\[[A-Za-z_$][\w$]*\]|\[\d+\])*$/;
|
|
577
572
|
const namedMatcher = (sub, suffix, ctx) => {
|
|
578
573
|
const key = JSON.stringify(sub);
|
|
579
574
|
const existing = ctx.namedMatchers.get(key);
|
|
@@ -609,7 +604,31 @@ const namedMatcher = (sub, suffix, ctx) => {
|
|
|
609
604
|
ctx.hoisted.push({ declaration, reference: `${name}(`, calls: true });
|
|
610
605
|
return name;
|
|
611
606
|
};
|
|
612
|
-
const
|
|
607
|
+
const namedBranchErrors = (sub, suffix, ctx) => {
|
|
608
|
+
if (!isSchemaObject(sub))
|
|
609
|
+
return null;
|
|
610
|
+
const key = `explain:${JSON.stringify(sub)}`;
|
|
611
|
+
const existing = ctx.namedMatchers.get(key);
|
|
612
|
+
if (existing !== void 0)
|
|
613
|
+
return existing;
|
|
614
|
+
const fnCtx = { ...ctx, objVar: "obj", pathPrefix: "${_path}", depth: 0, sink: MATCH_ERROR_SINK };
|
|
615
|
+
const checks = generateValueChecks("", "input", "`${_path}`", sub, suffix, fnCtx, true);
|
|
616
|
+
if (checks.length === 0)
|
|
617
|
+
return null;
|
|
618
|
+
const body = checks.join("\n");
|
|
619
|
+
const name = `${hoistPrefix(ctx)}_explain${ctx.namedMatchers.size}`;
|
|
620
|
+
const declaration = [
|
|
621
|
+
`const ${name} = (${readsBinding("input", body) ? "input" : "_input"}: unknown, _path: string): ValidationError[] => {`,
|
|
622
|
+
` const ${MATCH_ERROR_SINK}: ValidationError[] = []`,
|
|
623
|
+
body,
|
|
624
|
+
` return ${MATCH_ERROR_SINK}`,
|
|
625
|
+
`}`
|
|
626
|
+
].join("\n");
|
|
627
|
+
ctx.namedMatchers.set(key, name);
|
|
628
|
+
ctx.hoisted.push({ declaration, reference: `${name}(`, calls: true });
|
|
629
|
+
return name;
|
|
630
|
+
};
|
|
631
|
+
const generateMatchesExpr = (raw, sub, suffix, ctx, required = false) => {
|
|
613
632
|
if (sub === true)
|
|
614
633
|
return "true";
|
|
615
634
|
if (sub === false)
|
|
@@ -617,31 +636,26 @@ const generateMatchesExpr = (raw, sub, suffix, ctx, required = false, branch) =>
|
|
|
617
636
|
if (!isSchemaObject(sub))
|
|
618
637
|
return "true";
|
|
619
638
|
const value = NARROWABLE_REFERENCE.test(raw) ? raw : `_mv${ctx.depth}`;
|
|
620
|
-
const valuePath =
|
|
639
|
+
const valuePath = "`${_path}`";
|
|
621
640
|
const binding = value === raw ? "" : `const ${value}: unknown = ${raw}
|
|
622
641
|
`;
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
return `((): boolean => {
|
|
642
|
+
const named = namedMatcher(sub, suffix, ctx);
|
|
643
|
+
if (named !== null)
|
|
644
|
+
return named === "true" ? "true" : `${named}(${raw})`;
|
|
645
|
+
const matchState = { on: ctx.failFast.on, blocked: false };
|
|
646
|
+
const matchCtx = { ...ctx, sink: MATCH_BOOL_SINK, failFast: matchState, branchErrors: false };
|
|
647
|
+
const boolChecks = generateValueChecks("", value, valuePath, sub, suffix, matchCtx, required);
|
|
648
|
+
if (boolChecks.length === 0)
|
|
649
|
+
return "true";
|
|
650
|
+
if (!matchState.blocked)
|
|
651
|
+
return `((): boolean => {
|
|
634
652
|
${binding}${boolChecks.join("\n")}
|
|
635
653
|
return true })()`;
|
|
636
|
-
}
|
|
637
654
|
const checks = generateValueChecks("", value, valuePath, sub, suffix, { ...ctx, sink: MATCH_ERROR_SINK }, required);
|
|
638
655
|
if (checks.length === 0)
|
|
639
656
|
return "true";
|
|
640
|
-
const body = checks.join("\n");
|
|
641
|
-
const collect = branch === void 0 ? "" : `
|
|
642
|
-
if (_m.length !== 0) (${branch.buffer} ??= []).push(_m)`;
|
|
643
657
|
return `((): boolean => { const _m: ValidationError[] = []
|
|
644
|
-
${binding}${
|
|
658
|
+
${binding}${checks.join("\n")}
|
|
645
659
|
return _m.length === 0 })()`;
|
|
646
660
|
};
|
|
647
661
|
const unevaluatedMatcher = (suffix, ctx) => (accessor, schema, depth) => (
|
|
@@ -691,17 +705,18 @@ const generateUnevaluatedChecks = (raw, path, schema, suffix, ctx) => {
|
|
|
691
705
|
}
|
|
692
706
|
return lines;
|
|
693
707
|
};
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
708
|
+
const branchErrorsReport = (guard, raw, explain, total, message, keyword, path, ctx) => [
|
|
709
|
+
` if (${guard}) {`,
|
|
710
|
+
` ${pushError(ctx.sink, message, path, keyword)}`,
|
|
711
|
+
...explain.length === 0 ? [] : [
|
|
712
|
+
` const _bp = ${path}`,
|
|
713
|
+
` const _br: ValidationError[][] = []`,
|
|
714
|
+
` for (const _b of [${explain.map((name) => `${name}(${raw}, _bp)`).join(", ")}]) if (_b.length !== 0) _br.push(_b)`,
|
|
715
|
+
` for (const _e of selectBranchErrors(_br, _bp${explain.length === total ? `, ${total}` : ""})) ${ctx.sink}.push(_e)`
|
|
716
|
+
],
|
|
703
717
|
` }`
|
|
704
718
|
];
|
|
719
|
+
const branchExplainers = (branches, suffix, ctx) => branches.map((branch) => namedBranchErrors(branch, suffix, ctx)).filter((name) => name !== null);
|
|
705
720
|
const generateCombinatorChecks = (key, raw, path, schema, suffix, ctx) => {
|
|
706
721
|
if (!isSchemaObject(schema))
|
|
707
722
|
return [];
|
|
@@ -711,11 +726,10 @@ const generateCombinatorChecks = (key, raw, path, schema, suffix, ctx) => {
|
|
|
711
726
|
lines.push(...generateValueChecks(key, raw, path, branch, suffix, ctx, true));
|
|
712
727
|
}
|
|
713
728
|
if (hasAnyOf(schema) && schema.anyOf.length > 0) {
|
|
714
|
-
const
|
|
715
|
-
const conds = schema.anyOf.map((b) => generateMatchesExpr(raw, b, suffix, ctx, true, collector));
|
|
729
|
+
const conds = schema.anyOf.map((b) => generateMatchesExpr(raw, b, suffix, ctx, true));
|
|
716
730
|
if (!conds.includes("true")) {
|
|
717
731
|
if (ctx.branchErrors) {
|
|
718
|
-
lines.push(...
|
|
732
|
+
lines.push(...branchErrorsReport(`!(${conds.join(" || ")})`, raw, branchExplainers(schema.anyOf, suffix, ctx), schema.anyOf.length, `'must match a schema in anyOf'`, "anyOf", path, ctx));
|
|
719
733
|
} else {
|
|
720
734
|
const condition = `!(${conds.join(" || ")})`;
|
|
721
735
|
if (LITERAL_CONDITION.test(condition))
|
|
@@ -727,20 +741,18 @@ const generateCombinatorChecks = (key, raw, path, schema, suffix, ctx) => {
|
|
|
727
741
|
}
|
|
728
742
|
}
|
|
729
743
|
if (hasOneOf(schema) && schema.oneOf.length > 0) {
|
|
730
|
-
const
|
|
731
|
-
const conds = schema.oneOf.map((b) => `(${generateMatchesExpr(raw, b, suffix, ctx, true, collector)} ? 1 : 0)`);
|
|
744
|
+
const conds = schema.oneOf.map((b) => `(${generateMatchesExpr(raw, b, suffix, ctx, true)} ? 1 : 0)`);
|
|
732
745
|
const message = `'must match exactly one schema in oneOf'`;
|
|
733
746
|
if (!ctx.branchErrors) {
|
|
734
747
|
lines.push(` if ((${conds.join(" + ")}) !== 1) {`);
|
|
735
748
|
lines.push(` ${pushError(ctx.sink, message, path, "oneOf")}`);
|
|
736
749
|
lines.push(` }`);
|
|
737
750
|
} else {
|
|
751
|
+
const explain = branchExplainers(schema.oneOf, suffix, ctx);
|
|
738
752
|
lines.push(` {`);
|
|
739
|
-
lines.push(` let ${BRANCH_BUFFER}: ValidationError[][] | null = null`);
|
|
740
753
|
lines.push(` const _n = ${conds.join(" + ")}`);
|
|
741
|
-
|
|
742
|
-
lines.push(
|
|
743
|
-
lines.push(` ${selectBranchErrorsLine(path, ctx)}`);
|
|
754
|
+
const report = branchErrorsReport("_n === 0", raw, explain, schema.oneOf.length, message, "oneOf", path, ctx).map((line) => ` ${line}`);
|
|
755
|
+
lines.push(...report.slice(0, -1));
|
|
744
756
|
lines.push(` } else if (_n !== 1) {`);
|
|
745
757
|
lines.push(` ${pushError(ctx.sink, message, path, "oneOf")}`);
|
|
746
758
|
lines.push(` }`);
|
|
@@ -1567,7 +1579,7 @@ const generateScalarValidator = (schema, typeName, suffix, rootSchema, formats,
|
|
|
1567
1579
|
if (rootTypeArray2) {
|
|
1568
1580
|
const allWrong = rootTypeArray2.map((t) => wrongTypeCondition(typeTestAccessor("input", ctx), t)).filter((c) => c !== "").map((c) => `(${c})`).join(" && ");
|
|
1569
1581
|
if (allWrong) {
|
|
1570
|
-
const label = rootTypeArray2.
|
|
1582
|
+
const label = rootTypeArray2.join(" or ");
|
|
1571
1583
|
checks.push(` if (${allWrong}) {`);
|
|
1572
1584
|
checks.push(` ${pushError(ctx.sink, JSON.stringify(`must be ${label}`), rootPath, "type", JSON.stringify({ type: schema.type }))}`);
|
|
1573
1585
|
checks.push(` }`);
|
|
@@ -1577,7 +1589,7 @@ const generateScalarValidator = (schema, typeName, suffix, rootSchema, formats,
|
|
|
1577
1589
|
const wrongType = wrongTypeCondition(typeTestAccessor("input", ctx), t);
|
|
1578
1590
|
if (wrongType) {
|
|
1579
1591
|
checks.push(` if (${wrongType}) {`);
|
|
1580
|
-
checks.push(` ${pushError(ctx.sink, `'must be ${
|
|
1592
|
+
checks.push(` ${pushError(ctx.sink, `'must be ${t}'`, rootPath, "type", JSON.stringify({ type: t }))}`);
|
|
1581
1593
|
checks.push(` }`);
|
|
1582
1594
|
}
|
|
1583
1595
|
}
|
|
@@ -1599,7 +1611,7 @@ const generateScalarValidator = (schema, typeName, suffix, rootSchema, formats,
|
|
|
1599
1611
|
const checks = [];
|
|
1600
1612
|
const allWrong = rootTypeArray.map((t) => wrongTypeCondition(typeTestAccessor("input", ctx), t)).filter((c) => c !== "").map((c) => `(${c})`).join(" && ");
|
|
1601
1613
|
if (allWrong) {
|
|
1602
|
-
const label = rootTypeArray.
|
|
1614
|
+
const label = rootTypeArray.join(" or ");
|
|
1603
1615
|
checks.push(` if (${allWrong}) {`);
|
|
1604
1616
|
checks.push(` ${pushError(ctx.sink, JSON.stringify(`must be ${label}`), rootPath, "type", JSON.stringify({ type: schema.type }))}`);
|
|
1605
1617
|
checks.push(` }`);
|
|
@@ -1624,7 +1636,6 @@ const generateScalarValidator = (schema, typeName, suffix, rootSchema, formats,
|
|
|
1624
1636
|
if (hasType(schema)) {
|
|
1625
1637
|
const t = schema.type;
|
|
1626
1638
|
const wrongType = wrongTypeCondition("input", t);
|
|
1627
|
-
const typLabel = typeofString(t);
|
|
1628
1639
|
const rootCtx = createRootContext(rootSchema, formats, branchErrors, failFast);
|
|
1629
1640
|
const constraintLines = generateConstraintChecks("", "_root", "`${_path}`", schema, suffix, rootCtx);
|
|
1630
1641
|
if (!wrongType) {
|
|
@@ -1638,7 +1649,7 @@ const generateScalarValidator = (schema, typeName, suffix, rootSchema, formats,
|
|
|
1638
1649
|
return [
|
|
1639
1650
|
`export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
1640
1651
|
` if (${wrongType}) {`,
|
|
1641
|
-
` ${returnError(`'must be ${
|
|
1652
|
+
` ${returnError(`'must be ${t}'`, "_path", "type", JSON.stringify({ type: schema.type }))}`,
|
|
1642
1653
|
` }`,
|
|
1643
1654
|
` return true`,
|
|
1644
1655
|
`}`
|
|
@@ -1647,7 +1658,7 @@ const generateScalarValidator = (schema, typeName, suffix, rootSchema, formats,
|
|
|
1647
1658
|
return withHoisted(rootCtx.hoisted, [
|
|
1648
1659
|
`export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
1649
1660
|
` if (${wrongType}) {`,
|
|
1650
|
-
` ${returnError(`'must be ${
|
|
1661
|
+
` ${returnError(`'must be ${t}'`, "_path", "type", JSON.stringify({ type: schema.type }))}`,
|
|
1651
1662
|
` }`,
|
|
1652
1663
|
...readsBinding("_root", constraintLines.join("\n")) ? [` const _root: unknown = input`] : [],
|
|
1653
1664
|
...errorsBinding(failFast),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/validation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "One generator surface over mjst's types, validators, coercers, repairers and parsers.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -73,11 +73,11 @@
|
|
|
73
73
|
}
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
|
-
"@amritk/helpers": "^0.
|
|
76
|
+
"@amritk/helpers": "^0.25.0",
|
|
77
77
|
"json-schema-typed": "^8.0.1"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
|
-
"@amritk/runtime-validators": "^0.15.
|
|
80
|
+
"@amritk/runtime-validators": "^0.15.2",
|
|
81
81
|
"@ryoppippi/unplugin-typia": "^2.6.5",
|
|
82
82
|
"@scalar/openapi-parser": "^0.26.1",
|
|
83
83
|
"@sinclair/typebox": "^0.34.49",
|