@amritk/generate-validators 0.13.1 → 0.14.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 +3 -2
- package/README.md +42 -13
- package/dist/generators/assert-unevaluated-generatable.js +9 -8
- package/dist/generators/build-schema.d.ts +1 -1
- package/dist/generators/build-schema.js +135 -9
- package/dist/generators/collect-emitted-refs.js +12 -10
- package/dist/generators/collect-validator-imports.d.ts +22 -0
- package/dist/generators/collect-validator-imports.js +13 -3
- package/dist/generators/generate-files.js +12 -5
- package/dist/generators/generate-validator-function.js +194 -136
- package/dist/generators/tuple-shape.js +7 -5
- package/dist/generators/unevaluated-match.js +33 -28
- package/package.json +3 -3
package/AI.md
CHANGED
|
@@ -32,8 +32,9 @@ const files = await buildValidatorSchema(schema, 'Document')
|
|
|
32
32
|
(you write them).
|
|
33
33
|
3. **Output includes a shared `validation-result.ts`** (`ValidationError`,
|
|
34
34
|
`ValidationResult`, helpers) plus the `index.ts` barrel.
|
|
35
|
-
4. **`NaN`
|
|
36
|
-
|
|
35
|
+
4. **`NaN` fails a *constrained* number** (`minimum`/`maximum`/`multipleOf` all
|
|
36
|
+
reject it) and satisfies a bare `{ "type": "number" }`, which is Ajv's answer
|
|
37
|
+
too. Draft-07 schemas are auto-upgraded to 2020-12.
|
|
37
38
|
5. **`format` emits no check.** It stays an annotation, like the interpreter's
|
|
38
39
|
default — but *not* like the interpreter run with `{ formats: 'all' }`
|
|
39
40
|
(`@amritk/lint`, `createApi({ formats })`), which rejects strings a generated
|
package/README.md
CHANGED
|
@@ -95,6 +95,18 @@ if (!result.valid) {
|
|
|
95
95
|
|
|
96
96
|
Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; content: string }`.
|
|
97
97
|
|
|
98
|
+
#### Names it will not emit
|
|
99
|
+
|
|
100
|
+
Both name arguments are used as written — the root type name verbatim, the suffix
|
|
101
|
+
appended to every `$ref`-derived name — so both can name something the output
|
|
102
|
+
cannot say. Generation stops with the name and the reason rather than writing a
|
|
103
|
+
file that fails in your build: a name that is not a plain TypeScript identifier
|
|
104
|
+
(`'my-doc'`, `''`, `'123'`, `'class'`); a definition that would claim the
|
|
105
|
+
`validation-result.ts` or `index.ts` filename; and one whose type name comes out
|
|
106
|
+
as `ValidationResult` or `ValidationError`, which every generated file already
|
|
107
|
+
imports. A type suffix that moves such a name clear — `ValidationErrorObject` — is
|
|
108
|
+
no collision, and non-ASCII identifiers are fine, because TypeScript takes them.
|
|
109
|
+
|
|
98
110
|
#### Referencing another document
|
|
99
111
|
|
|
100
112
|
A `$ref` to a URI is resolvable once you hand over the document behind it:
|
|
@@ -143,6 +155,19 @@ The verdict is the contract and it matches the interpreter exactly; for that one
|
|
|
143
155
|
shape `isX` is a weaker type guard than the type it names. Declare a `type` — as
|
|
144
156
|
almost every real schema does — and the guard is exact again.
|
|
145
157
|
|
|
158
|
+
**Values JSON cannot hold get an answer, not a surprise.** A generated validator
|
|
159
|
+
is a plain function applied to whatever you hand it, so it can meet things
|
|
160
|
+
`JSON.parse` never produces — and it answers each the way the interpreter and Ajv
|
|
161
|
+
do. A key present with an `undefined` value is a value to judge, not an absent
|
|
162
|
+
one, wherever a sweep found the key (`patternProperties`, a schema-form
|
|
163
|
+
`additionalProperties`, `unevaluatedProperties`). A hole in a sparse array is an
|
|
164
|
+
element that has to answer for itself, in the item loops, the tuple positions,
|
|
165
|
+
`contains` and `unevaluatedItems` alike. `NaN` equals itself under `const`,
|
|
166
|
+
`enum` and `uniqueItems`, so `[NaN, NaN]` is a duplicate pair while `[NaN, null]`
|
|
167
|
+
is not. And a self-referential object reaches a verdict — the structural
|
|
168
|
+
comparison stops at 512 levels — where it used to throw a `RangeError` out of a
|
|
169
|
+
function whose signature promises a `ValidationResult`.
|
|
170
|
+
|
|
146
171
|
**`format` emits no check.** JSON Schema treats `format` as an annotation, and so
|
|
147
172
|
does this generator: `{ type: 'string', format: 'uuid' }` produces the `typeof`
|
|
148
173
|
check and nothing more. That matches the interpreter's default, but *not* the
|
|
@@ -163,7 +188,10 @@ unresolvable or cyclic `$ref` at the same instance location, a walk deeper than
|
|
|
163
188
|
eight applicators, and a node under an *inert* `additionalItems` — one with no
|
|
164
189
|
array `items` to be the tail of, or with a `prefixItems` that took the positions
|
|
165
190
|
out from under it. The draft-07 tail itself is validated, so the draft-07
|
|
166
|
-
spelling of a schema whose 2020-12 spelling generates is accepted too.
|
|
191
|
+
spelling of a schema whose 2020-12 spelling generates is accepted too. One
|
|
192
|
+
difference worth knowing: the check is a single sweep, so its error is reported at
|
|
193
|
+
the object or array — the shape Ajv reports too — where the interpreter names the
|
|
194
|
+
individual key or index. The verdict is identical either way.
|
|
167
195
|
|
|
168
196
|
One edge worth calling out: **`NaN` fails a constrained number but satisfies an
|
|
169
197
|
unconstrained one.** Every bound is emitted as the negated *pass* condition
|
|
@@ -223,25 +251,26 @@ Keeping the hot function tiny lets V8 optimise it aggressively, so a valid-input
|
|
|
223
251
|
check beats every other library measured — including the build-time transformer
|
|
224
252
|
typia — while still emitting full JSON-Pointer errors for invalid input, and
|
|
225
253
|
emitting the validator stays far cheaper than compiling a schema at startup.
|
|
226
|
-
Measured on Bun 1.
|
|
254
|
+
Measured on Bun 1.4 (Linux x64), validating valid input at steady state:
|
|
227
255
|
|
|
228
256
|
| schema | mjst (generated) | typia (transformed) | ajv (compiled) | typebox (compiled) | zod |
|
|
229
257
|
|:--|--:|--:|--:|--:|--:|
|
|
230
|
-
| small (4 fields) | **~
|
|
231
|
-
| order (nested + array) | **~
|
|
232
|
-
| assert-loose | **~
|
|
233
|
-
| assert-strict | **~
|
|
258
|
+
| small (4 fields) | **~59M** ops/s | ~5.8M ops/s | ~10M ops/s | ~7.6M ops/s | ~2.2M ops/s |
|
|
259
|
+
| order (nested + array) | **~9.5M** ops/s | ~2M ops/s | ~3.9M ops/s | ~3.3M ops/s | ~0.54M ops/s |
|
|
260
|
+
| assert-loose | **~189M** ops/s | ~170M ops/s | ~44M ops/s | ~78M ops/s | ~5M ops/s |
|
|
261
|
+
| assert-strict | **~104M** ops/s | ~68M ops/s | ~21M ops/s | ~42M ops/s | ~1.8M ops/s |
|
|
234
262
|
|
|
235
263
|
The `assert-loose` / `assert-strict` rows are the exact shape used by
|
|
236
264
|
[`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks)
|
|
237
265
|
(seven scalar roots plus a nested object): the boolean guard keeps mjst ahead of
|
|
238
|
-
typia on both, by ~
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
the
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
266
|
+
typia on both, by ~11% on `assert-loose` — close enough that the two can trade
|
|
267
|
+
the lead run-to-run — and by ~52% on `assert-strict` (with
|
|
268
|
+
`additionalProperties: false`), where mjst counts keys once and typia does not.
|
|
269
|
+
(typia and TypeBox still win the *invalid* path, where they bail on the first
|
|
270
|
+
error rather than collecting a full error list.)
|
|
271
|
+
|
|
272
|
+
Preparing a validator costs ~0.3–0.7 ms for mjst codegen and ~0.05–0.2 ms for a
|
|
273
|
+
TypeBox `TypeCompiler` compile, versus ~13–17 ms for an Ajv compile. Every library
|
|
245
274
|
agrees on every verdict; parity is asserted before timing.
|
|
246
275
|
|
|
247
276
|
One caveat on the first two rows: their schemas declare `format` (`uuid`,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { declaresKey, readKey } from "@amritk/helpers/read-key";
|
|
1
2
|
import { unevaluatedItemsExpr, unevaluatedPropertiesExpr } from "./unevaluated-match.js";
|
|
2
3
|
const UNPROVABLE_COVERAGE_MESSAGE = (keyword) => `unsupported "${keyword}": working out which keys the schema already evaluated means reading through an applicator this generator cannot follow inline \u2014 an unresolvable or cyclic "$ref", or a "$dynamicRef", whose target is only known at validation time. Validate this schema with \`@amritk/runtime-validators\` instead.`;
|
|
3
4
|
const SINGLE_SUBSCHEMA_KEYS = [
|
|
@@ -13,12 +14,12 @@ const SINGLE_SUBSCHEMA_KEYS = [
|
|
|
13
14
|
];
|
|
14
15
|
const SUBSCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems"];
|
|
15
16
|
const SUBSCHEMA_MAP_KEYS = ["properties", "patternProperties", "dependentSchemas", "dependencies"];
|
|
16
|
-
const additionalItemsIsEnforced = (node) => Array.isArray(node
|
|
17
|
+
const additionalItemsIsEnforced = (node) => Array.isArray(readKey(node, "items")) && !Array.isArray(readKey(node, "prefixItems"));
|
|
17
18
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
19
|
const check = (node, typeName, rootSchema, match, enforced) => {
|
|
19
20
|
const record = node;
|
|
20
21
|
for (const keyword of ["unevaluatedProperties", "unevaluatedItems"]) {
|
|
21
|
-
if (!(keyword
|
|
22
|
+
if (!declaresKey(record, keyword) || record[keyword] === true)
|
|
22
23
|
continue;
|
|
23
24
|
if (!enforced) {
|
|
24
25
|
throw new Error(`[${typeName}] unsupported "${keyword}": it sits under "additionalItems", a subschema position this generator does not enforce, so the check would never run. Validate this schema with \`@amritk/runtime-validators\` instead.`);
|
|
@@ -32,34 +33,34 @@ const assertUnevaluatedGeneratable = (schema, typeName, rootSchema, match) => {
|
|
|
32
33
|
const visit = (node, enforced) => {
|
|
33
34
|
if (!isRecord(node))
|
|
34
35
|
return;
|
|
35
|
-
if ("unevaluatedProperties"
|
|
36
|
+
if (declaresKey(node, "unevaluatedProperties") || declaresKey(node, "unevaluatedItems")) {
|
|
36
37
|
check(node, typeName, rootSchema, match, enforced);
|
|
37
38
|
}
|
|
38
39
|
for (const key of SINGLE_SUBSCHEMA_KEYS) {
|
|
39
|
-
if (key
|
|
40
|
+
if (declaresKey(node, key))
|
|
40
41
|
visit(node[key], enforced);
|
|
41
42
|
}
|
|
42
|
-
const items = node
|
|
43
|
+
const items = readKey(node, "items");
|
|
43
44
|
if (Array.isArray(items))
|
|
44
45
|
for (const entry of items)
|
|
45
46
|
visit(entry, enforced);
|
|
46
47
|
else if (items !== void 0)
|
|
47
48
|
visit(items, enforced);
|
|
48
49
|
for (const key of SUBSCHEMA_LIST_KEYS) {
|
|
49
|
-
const list = node
|
|
50
|
+
const list = readKey(node, key);
|
|
50
51
|
if (Array.isArray(list))
|
|
51
52
|
for (const entry of list)
|
|
52
53
|
visit(entry, enforced);
|
|
53
54
|
}
|
|
54
55
|
for (const key of SUBSCHEMA_MAP_KEYS) {
|
|
55
|
-
const map = node
|
|
56
|
+
const map = readKey(node, key);
|
|
56
57
|
if (!isRecord(map))
|
|
57
58
|
continue;
|
|
58
59
|
for (const entry of Object.values(map))
|
|
59
60
|
if (!Array.isArray(entry))
|
|
60
61
|
visit(entry, enforced);
|
|
61
62
|
}
|
|
62
|
-
if ("additionalItems"
|
|
63
|
+
if (declaresKey(node, "additionalItems"))
|
|
63
64
|
visit(node["additionalItems"], enforced && additionalItemsIsEnforced(node));
|
|
64
65
|
};
|
|
65
66
|
visit(schema, true);
|
|
@@ -11,7 +11,7 @@ export type GeneratedFile = {
|
|
|
11
11
|
* types plus the helpers emitted code calls as free identifiers. Exported so tests
|
|
12
12
|
* can evaluate the very source that ships instead of reimplementing it.
|
|
13
13
|
*/
|
|
14
|
-
export declare const VALIDATION_RESULT_CONTENT = "/**\n * A single validation error with a human-readable message and a JSON Pointer\n * path indicating where in the document the error occurred.\n */\nexport type ValidationError = {\n message: string\n path: string\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 * 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): boolean => {\n if (a === b) return true\n if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) 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])) 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])) return false\n }\n return true\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
|
|
14
|
+
export declare const VALIDATION_RESULT_CONTENT = "/**\n * A single validation error with a human-readable message and a JSON Pointer\n * path indicating where in the document the error occurred.\n */\nexport type ValidationError = {\n message: string\n path: string\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 * 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 * 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";
|
|
15
15
|
/**
|
|
16
16
|
* Builds all TypeScript validator files from a JSON Schema by traversing all
|
|
17
17
|
* `$ref` / `$dynamicRef` references recursively (via the shared
|
|
@@ -17,15 +17,35 @@ export type ValidationError = {
|
|
|
17
17
|
*/
|
|
18
18
|
export type ValidationResult = true | { valid: false; errors: ValidationError[] }
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* How deep a structural comparison walks before it gives up and answers "not
|
|
22
|
+
* equal".
|
|
23
|
+
*
|
|
24
|
+
* JSON data is acyclic, but a generated validator is a plain function applied to
|
|
25
|
+
* whatever in-memory value a caller hands it \u2014 and a self-referential object
|
|
26
|
+
* reaching a \`const\` / \`enum\` / \`uniqueItems\` check used to recurse until the
|
|
27
|
+
* stack overflowed, so \`validateFoo\` threw a \`RangeError\` instead of returning
|
|
28
|
+
* the \`ValidationResult\` its signature promises. The cap turns that into an
|
|
29
|
+
* ordinary "these are different" without ever coming near real data;
|
|
30
|
+
* \`@amritk/runtime-validators\` guards its own \`deepEqual\` at the same depth.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_EQUAL_DEPTH = 512
|
|
33
|
+
|
|
20
34
|
/**
|
|
21
35
|
* Structural deep equality used by generated \`const\` checks. Objects compare by
|
|
22
36
|
* their key sets rather than serialization, so \`{ a: 1, b: 2 }\` and
|
|
23
37
|
* \`{ b: 2, a: 1 }\` are equal \u2014 unlike \`JSON.stringify\`, which is key-order
|
|
24
38
|
* sensitive and would reject a reordered-but-equal value.
|
|
25
39
|
*/
|
|
26
|
-
export const valuesEqual = (a: unknown, b: unknown): boolean => {
|
|
27
|
-
|
|
40
|
+
export const valuesEqual = (a: unknown, b: unknown, depth = 0): boolean => {
|
|
41
|
+
// SameValueZero: \`===\` settles every primitive except \`NaN\`, which counts as
|
|
42
|
+
// equal to itself here. That is what the native \`Set\` in {@link allUnique} does,
|
|
43
|
+
// what Ajv does, and what the interpreter's \`deepEqual\` does \u2014 leaving it out
|
|
44
|
+
// made a \`NaN\` nested inside an object compare unequal to itself, so the same
|
|
45
|
+
// array was "unique" here and "duplicated" everywhere else.
|
|
46
|
+
if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true
|
|
28
47
|
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
|
|
48
|
+
if (depth >= MAX_EQUAL_DEPTH) return false
|
|
29
49
|
const aArray = Array.isArray(a)
|
|
30
50
|
const bArray = Array.isArray(b)
|
|
31
51
|
if (aArray !== bArray) return false
|
|
@@ -33,7 +53,7 @@ export const valuesEqual = (a: unknown, b: unknown): boolean => {
|
|
|
33
53
|
const aa = a as unknown[]
|
|
34
54
|
const bb = b as unknown[]
|
|
35
55
|
if (aa.length !== bb.length) return false
|
|
36
|
-
for (let i = 0; i < aa.length; i++) if (!valuesEqual(aa[i], bb[i])) return false
|
|
56
|
+
for (let i = 0; i < aa.length; i++) if (!valuesEqual(aa[i], bb[i], depth + 1)) return false
|
|
37
57
|
return true
|
|
38
58
|
}
|
|
39
59
|
const ao = a as Record<string, unknown>
|
|
@@ -41,19 +61,67 @@ export const valuesEqual = (a: unknown, b: unknown): boolean => {
|
|
|
41
61
|
const keys = Object.keys(ao)
|
|
42
62
|
if (keys.length !== Object.keys(bo).length) return false
|
|
43
63
|
for (const key of keys) {
|
|
44
|
-
if (!Object.hasOwn(bo, key) || !valuesEqual(ao[key], bo[key])) return false
|
|
64
|
+
if (!Object.hasOwn(bo, key) || !valuesEqual(ao[key], bo[key], depth + 1)) return false
|
|
45
65
|
}
|
|
46
66
|
return true
|
|
47
67
|
}
|
|
48
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A cheap, order-independent structural hash consistent with {@link valuesEqual}:
|
|
71
|
+
* equal values always hash the same. It buckets candidate-equal elements in
|
|
72
|
+
* {@link allUnique} so the exact comparison only ever runs inside a bucket.
|
|
73
|
+
*
|
|
74
|
+
* Object keys are folded commutatively (XOR) so key order does not change the
|
|
75
|
+
* hash, and \`NaN\` / \`-0\` collapse the way SameValueZero does. Depth-capped like
|
|
76
|
+
* {@link valuesEqual}: an over-deep value simply shares a bucket and is settled by
|
|
77
|
+
* the (also capped) comparison, so the cap can cost a little time and never a
|
|
78
|
+
* wrong answer. This is the same hash \`@amritk/runtime-validators\` uses.
|
|
79
|
+
*/
|
|
80
|
+
const structuralHash = (value: unknown, depth = 0): number => {
|
|
81
|
+
if (value === null) return 0x1a2b3c
|
|
82
|
+
const t = typeof value
|
|
83
|
+
if (t === 'number') {
|
|
84
|
+
const n = value as number
|
|
85
|
+
return Number.isNaN(n) ? 0x7ff8 : n === 0 ? 0 : Math.trunc(n * 2654435761) | 0
|
|
86
|
+
}
|
|
87
|
+
if (t === 'string') {
|
|
88
|
+
const s = value as string
|
|
89
|
+
let h = 0x811c9dc5
|
|
90
|
+
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
|
|
91
|
+
return h | 0
|
|
92
|
+
}
|
|
93
|
+
if (t === 'boolean') return value ? 1 : 2
|
|
94
|
+
if (t !== 'object') return 0x5eed
|
|
95
|
+
if (depth >= MAX_EQUAL_DEPTH) return 0xdee9
|
|
96
|
+
if (Array.isArray(value)) {
|
|
97
|
+
let h = 0x12345 ^ value.length
|
|
98
|
+
for (let i = 0; i < value.length; i++) h = (Math.imul(h, 31) + structuralHash(value[i], depth + 1)) | 0
|
|
99
|
+
return h | 0
|
|
100
|
+
}
|
|
101
|
+
const obj = value as Record<string, unknown>
|
|
102
|
+
const keys = Object.keys(obj)
|
|
103
|
+
let h = 0xabcde ^ keys.length
|
|
104
|
+
for (const k of keys) {
|
|
105
|
+
let kh = 0x811c9dc5
|
|
106
|
+
for (let i = 0; i < k.length; i++) kh = Math.imul(kh ^ k.charCodeAt(i), 0x01000193)
|
|
107
|
+
h = (h ^ (Math.imul(kh, 0x9e3779b1) + structuralHash(obj[k], depth + 1))) | 0
|
|
108
|
+
}
|
|
109
|
+
return h | 0
|
|
110
|
+
}
|
|
111
|
+
|
|
49
112
|
/**
|
|
50
113
|
* True when every element of \`arr\` is distinct under structural equality
|
|
51
114
|
* ({@link valuesEqual}). Backs generated \`uniqueItems\` checks whose items may be
|
|
52
115
|
* objects or arrays, where a \`JSON.stringify\` dedupe key would be key-order
|
|
53
116
|
* sensitive and let a reordered-but-equal duplicate (\`{ a: 1, b: 2 }\` vs
|
|
54
|
-
* \`{ b: 2, a: 1 }\`) slip through.
|
|
55
|
-
*
|
|
56
|
-
*
|
|
117
|
+
* \`{ b: 2, a: 1 }\`) slip through.
|
|
118
|
+
*
|
|
119
|
+
* A native \`Set\` dedupes the all-primitive case in one linear pass. Object and
|
|
120
|
+
* array elements are bucketed by {@link structuralHash} first, so the exact
|
|
121
|
+
* comparison runs only against elements that could actually be equal: an array of
|
|
122
|
+
* distinct objects costs ~O(n) instead of the O(n\xB2) an exhaustive pairwise sweep
|
|
123
|
+
* charged \u2014 4 000 rows took over half a second of pure comparison before, which
|
|
124
|
+
* is a lot to hand an unauthenticated caller.
|
|
57
125
|
*/
|
|
58
126
|
export const allUnique = (arr: readonly unknown[]): boolean => {
|
|
59
127
|
const len = arr.length
|
|
@@ -67,10 +135,17 @@ export const allUnique = (arr: readonly unknown[]): boolean => {
|
|
|
67
135
|
}
|
|
68
136
|
}
|
|
69
137
|
if (allPrimitive) return new Set(arr).size === len
|
|
138
|
+
const buckets = new Map<number, unknown[]>()
|
|
70
139
|
for (let i = 0; i < len; i++) {
|
|
71
|
-
|
|
72
|
-
|
|
140
|
+
const item = arr[i]
|
|
141
|
+
const hash = structuralHash(item)
|
|
142
|
+
const bucket = buckets.get(hash)
|
|
143
|
+
if (bucket === undefined) {
|
|
144
|
+
buckets.set(hash, [item])
|
|
145
|
+
continue
|
|
73
146
|
}
|
|
147
|
+
for (const seen of bucket) if (valuesEqual(seen, item)) return false
|
|
148
|
+
bucket.push(item)
|
|
74
149
|
}
|
|
75
150
|
return true
|
|
76
151
|
}
|
|
@@ -91,6 +166,48 @@ export const allUnique = (arr: readonly unknown[]): boolean => {
|
|
|
91
166
|
export const escapePointer = (key: string): string =>
|
|
92
167
|
key.indexOf('/') !== -1 || key.indexOf('~') !== -1 ? key.replace(/~/g, '~0').replace(/\\//g, '~1') : key
|
|
93
168
|
`;
|
|
169
|
+
const RESERVED_TYPE_NAMES = /* @__PURE__ */ new Set(["ValidationResult", "ValidationError"]);
|
|
170
|
+
const TYPE_NAME = /^[\p{ID_Start}_$][\p{ID_Continue}$]*$/u;
|
|
171
|
+
const RESERVED_WORDS = /* @__PURE__ */ new Set([
|
|
172
|
+
"await",
|
|
173
|
+
"break",
|
|
174
|
+
"case",
|
|
175
|
+
"catch",
|
|
176
|
+
"class",
|
|
177
|
+
"const",
|
|
178
|
+
"continue",
|
|
179
|
+
"debugger",
|
|
180
|
+
"default",
|
|
181
|
+
"delete",
|
|
182
|
+
"do",
|
|
183
|
+
"else",
|
|
184
|
+
"enum",
|
|
185
|
+
"export",
|
|
186
|
+
"extends",
|
|
187
|
+
"false",
|
|
188
|
+
"finally",
|
|
189
|
+
"for",
|
|
190
|
+
"function",
|
|
191
|
+
"if",
|
|
192
|
+
"import",
|
|
193
|
+
"in",
|
|
194
|
+
"instanceof",
|
|
195
|
+
"new",
|
|
196
|
+
"null",
|
|
197
|
+
"return",
|
|
198
|
+
"super",
|
|
199
|
+
"switch",
|
|
200
|
+
"this",
|
|
201
|
+
"throw",
|
|
202
|
+
"true",
|
|
203
|
+
"try",
|
|
204
|
+
"typeof",
|
|
205
|
+
"var",
|
|
206
|
+
"void",
|
|
207
|
+
"while",
|
|
208
|
+
"with",
|
|
209
|
+
"yield"
|
|
210
|
+
]);
|
|
94
211
|
const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "", schemas) => {
|
|
95
212
|
const files = [];
|
|
96
213
|
walkRefGraph(rootSchema, rootTypeName, { typeSuffix, ...schemas !== void 0 ? { schemas } : {} }, (node) => {
|
|
@@ -99,6 +216,15 @@ const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "", s
|
|
|
99
216
|
const purpose = node.filename === "index" ? "the generated barrel" : "the generated validators' runtime contract";
|
|
100
217
|
throw new Error(`${owner} generates the file "${node.filename}.ts", which is reserved for ${purpose}. Rename the definition (or pass a different root type name) so it gets a file of its own.`);
|
|
101
218
|
}
|
|
219
|
+
if (!TYPE_NAME.test(node.typeName) || RESERVED_WORDS.has(node.typeName)) {
|
|
220
|
+
const owner = node.isRoot ? "the root type name" : `the name "${node.ref}" derives`;
|
|
221
|
+
const suffixNote = typeSuffix === "" ? "" : ` (with the type suffix "${typeSuffix}")`;
|
|
222
|
+
throw new Error(`${owner}${suffixNote}, "${node.typeName}", is not a TypeScript type name, so the generated file would not parse. Pass a name that is a plain identifier \u2014 letters, digits, "_" and "$", not starting with a digit, and not a reserved word.`);
|
|
223
|
+
}
|
|
224
|
+
if (RESERVED_TYPE_NAMES.has(node.typeName)) {
|
|
225
|
+
const owner = node.isRoot ? `the root type name "${node.typeName}"` : `"${node.ref}"`;
|
|
226
|
+
throw new Error(`${owner} generates the type "${node.typeName}", which every generated file already imports from "validation-result.ts". Rename the definition (or pass a different root type name or type suffix) so the two names do not collide.`);
|
|
227
|
+
}
|
|
102
228
|
const content = generateValidatorFile(node.schema, node.typeName, {
|
|
103
229
|
rootSchema: node.rootSchema,
|
|
104
230
|
typeSuffix,
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import { declaresKey, readKey } from "@amritk/helpers/read-key";
|
|
1
2
|
import { isSchemaObject } from "@amritk/helpers/schema-guards";
|
|
2
3
|
import { foldsToConstant } from "./folds-to-constant.js";
|
|
3
4
|
import { tupleShapeOf } from "./tuple-shape.js";
|
|
4
5
|
import { unevaluatedItemsExpr, unevaluatedPropertiesExpr } from "./unevaluated-match.js";
|
|
5
6
|
const armsOf = (schema) => {
|
|
6
|
-
if (!("if"
|
|
7
|
+
if (!declaresKey(schema, "if"))
|
|
7
8
|
return [];
|
|
8
|
-
const condition = schema
|
|
9
|
+
const condition = readKey(schema, "if");
|
|
9
10
|
if (!isSchemaObject(condition) && typeof condition !== "boolean")
|
|
10
11
|
return [];
|
|
11
12
|
const decided = foldsToConstant(condition);
|
|
@@ -24,14 +25,15 @@ const collectEmittedRefs = (value, refs = [], rootSchema, includeTypeOnly = fals
|
|
|
24
25
|
return refs;
|
|
25
26
|
}
|
|
26
27
|
const schema = value;
|
|
27
|
-
if (rootSchema !== void 0 && ("unevaluatedProperties"
|
|
28
|
+
if (rootSchema !== void 0 && (declaresKey(schema, "unevaluatedProperties") || declaresKey(schema, "unevaluatedItems"))) {
|
|
28
29
|
collectCoverageRefs(schema, refs, rootSchema);
|
|
29
30
|
}
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
const ref = readKey(schema, "$ref");
|
|
32
|
+
if (typeof ref === "string") {
|
|
33
|
+
refs.push(ref);
|
|
32
34
|
}
|
|
33
35
|
for (const mapKey of ["properties", "patternProperties", "dependentSchemas", "dependencies"]) {
|
|
34
|
-
const map = schema
|
|
36
|
+
const map = readKey(schema, mapKey);
|
|
35
37
|
if (typeof map === "object" && map !== null && !Array.isArray(map)) {
|
|
36
38
|
for (const sub of Object.values(map))
|
|
37
39
|
collectEmittedRefs(sub, refs, rootSchema, includeTypeOnly);
|
|
@@ -40,8 +42,8 @@ const collectEmittedRefs = (value, refs = [], rootSchema, includeTypeOnly = fals
|
|
|
40
42
|
const { tuple, tail } = tupleShapeOf(schema);
|
|
41
43
|
if (tail !== void 0)
|
|
42
44
|
collectEmittedRefs(tail, refs, rootSchema, includeTypeOnly);
|
|
43
|
-
if (includeTypeOnly && Array.isArray(schema
|
|
44
|
-
const additional = schema
|
|
45
|
+
if (includeTypeOnly && Array.isArray(readKey(schema, "items"))) {
|
|
46
|
+
const additional = readKey(schema, "additionalItems");
|
|
45
47
|
if (additional !== void 0 && additional !== tail) {
|
|
46
48
|
collectEmittedRefs(additional, refs, rootSchema, includeTypeOnly);
|
|
47
49
|
}
|
|
@@ -66,11 +68,11 @@ const collectEmittedRefs = (value, refs = [], rootSchema, includeTypeOnly = fals
|
|
|
66
68
|
"unevaluatedProperties",
|
|
67
69
|
"unevaluatedItems"
|
|
68
70
|
]) {
|
|
69
|
-
if (key
|
|
71
|
+
if (declaresKey(schema, key))
|
|
70
72
|
collectEmittedRefs(schema[key], refs, rootSchema, includeTypeOnly);
|
|
71
73
|
}
|
|
72
74
|
for (const key of ["oneOf", "anyOf", "allOf"]) {
|
|
73
|
-
const list = schema
|
|
75
|
+
const list = readKey(schema, key);
|
|
74
76
|
if (!Array.isArray(list))
|
|
75
77
|
continue;
|
|
76
78
|
for (const sub of list)
|
|
@@ -18,6 +18,28 @@ type CollectValidatorImportsOptions = {
|
|
|
18
18
|
* match the suffix used when generating the referenced files. Defaults to `''`.
|
|
19
19
|
*/
|
|
20
20
|
readonly typeSuffix?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the file being generated reads each half of a `$ref`'s import — the
|
|
23
|
+
* type, the validator, or both.
|
|
24
|
+
*
|
|
25
|
+
* The two halves come apart in both directions. A `$ref` in a position the type
|
|
26
|
+
* generator does not read (an `if` arm, whose whole node it types `unknown`) is
|
|
27
|
+
* called and never named; one in a position only the *type* reads (a tuple's
|
|
28
|
+
* rest, taken from `additionalItems`) is named and never called; and a ref
|
|
29
|
+
* inside a branch that folded away is neither. Importing a half nothing reads
|
|
30
|
+
* leaves it unused, which is `TS6133` in the generated file for any consumer
|
|
31
|
+
* with `noUnusedLocals` — this repo, and anything inheriting its flags.
|
|
32
|
+
*
|
|
33
|
+
* Defaults to "both", which is what every caller wanted before anyone asked the
|
|
34
|
+
* question.
|
|
35
|
+
*/
|
|
36
|
+
readonly reads?: (names: {
|
|
37
|
+
readonly typeName: string;
|
|
38
|
+
readonly validatorName: string;
|
|
39
|
+
}) => {
|
|
40
|
+
readonly type: boolean;
|
|
41
|
+
readonly validator: boolean;
|
|
42
|
+
};
|
|
21
43
|
};
|
|
22
44
|
/**
|
|
23
45
|
* Collects import statements for all $ref dependencies of a schema.
|
|
@@ -2,16 +2,24 @@ import { refToFilename } from "@amritk/helpers/ref-to-filename";
|
|
|
2
2
|
import { refToName } from "@amritk/helpers/ref-to-name";
|
|
3
3
|
import { resolveRef } from "@amritk/helpers/resolve-ref";
|
|
4
4
|
import { collectEmittedRefs } from "./collect-emitted-refs.js";
|
|
5
|
-
const buildImport = (ref, suffix) => {
|
|
5
|
+
const buildImport = (ref, suffix, reads) => {
|
|
6
6
|
const filename = refToFilename(ref);
|
|
7
7
|
const typeName = refToName(ref, suffix);
|
|
8
8
|
const validatorName = `validate${typeName}`;
|
|
9
|
-
|
|
9
|
+
const { type, validator } = reads({ typeName, validatorName });
|
|
10
|
+
if (type && validator)
|
|
11
|
+
return `import { type ${typeName}, ${validatorName} } from './${filename}.js'`;
|
|
12
|
+
if (validator)
|
|
13
|
+
return `import { ${validatorName} } from './${filename}.js'`;
|
|
14
|
+
if (type)
|
|
15
|
+
return `import type { ${typeName} } from './${filename}.js'`;
|
|
16
|
+
return null;
|
|
10
17
|
};
|
|
11
18
|
const collectValidatorImports = (schema, options) => {
|
|
12
19
|
const selfFilename = options?.selfRef ? refToFilename(options.selfRef) : null;
|
|
13
20
|
const rootSchema = options?.rootSchema;
|
|
14
21
|
const typeSuffix = options?.typeSuffix ?? "";
|
|
22
|
+
const reads = options?.reads ?? (() => ({ type: true, validator: true }));
|
|
15
23
|
const refs = collectEmittedRefs(schema, [], rootSchema, true);
|
|
16
24
|
const seen = /* @__PURE__ */ new Set();
|
|
17
25
|
const imports = [];
|
|
@@ -26,8 +34,10 @@ const collectValidatorImports = (schema, options) => {
|
|
|
26
34
|
if (!resolved)
|
|
27
35
|
continue;
|
|
28
36
|
}
|
|
37
|
+
const statement = buildImport(ref, typeSuffix, reads);
|
|
29
38
|
seen.add(filename);
|
|
30
|
-
|
|
39
|
+
if (statement !== null)
|
|
40
|
+
imports.push(statement);
|
|
31
41
|
}
|
|
32
42
|
return imports;
|
|
33
43
|
};
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { generateTypeDefinition } from "@amritk/helpers/generate-type-definition";
|
|
2
2
|
import { collectValidatorImports } from "./collect-validator-imports.js";
|
|
3
3
|
import { generateBooleanGuard, generateValidatorFunction } from "./generate-validator-function.js";
|
|
4
|
+
const escapeForWordMatch = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4
5
|
const generateValidatorFile = (schema, typeName, options) => {
|
|
5
6
|
const typeSuffix = options?.typeSuffix ?? "";
|
|
6
|
-
const refImports = collectValidatorImports(schema, {
|
|
7
|
-
selfRef: options?.selfRef,
|
|
8
|
-
rootSchema: options?.rootSchema,
|
|
9
|
-
typeSuffix
|
|
10
|
-
});
|
|
11
7
|
const typeDefinition = generateTypeDefinition(schema, typeName, {
|
|
12
8
|
typeSuffix,
|
|
13
9
|
...options?.rootSchema !== void 0 ? { rootSchema: options.rootSchema } : {}
|
|
@@ -15,6 +11,17 @@ const generateValidatorFile = (schema, typeName, options) => {
|
|
|
15
11
|
const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix, options?.rootSchema);
|
|
16
12
|
const booleanGuard = generateBooleanGuard(schema, typeName, typeSuffix);
|
|
17
13
|
const body = validatorFunction + booleanGuard;
|
|
14
|
+
const emitted = typeDefinition + body;
|
|
15
|
+
const mentions = (name) => new RegExp(`\\b${escapeForWordMatch(name)}\\b`).test(emitted);
|
|
16
|
+
const refImports = collectValidatorImports(schema, {
|
|
17
|
+
selfRef: options?.selfRef,
|
|
18
|
+
rootSchema: options?.rootSchema,
|
|
19
|
+
typeSuffix,
|
|
20
|
+
reads: ({ typeName: name, validatorName }) => ({
|
|
21
|
+
type: mentions(name),
|
|
22
|
+
validator: mentions(validatorName)
|
|
23
|
+
})
|
|
24
|
+
});
|
|
18
25
|
const resultTypes = ["ValidationResult", .../\bValidationError\b/.test(body) ? ["ValidationError"] : []];
|
|
19
26
|
let result = `import type { ${resultTypes.join(", ")} } from './validation-result.js'
|
|
20
27
|
`;
|