@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 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` satisfies numeric bounds** (`minimum`/`maximum`/`multipleOf`) — differs
36
- from Ajv. Draft-07 schemas are auto-upgraded to 2020-12.
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.3 (Linux x64), validating valid input at steady state:
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) | **~49M** ops/s | ~6.4M ops/s | ~11M ops/s | ~5.7M ops/s | ~2.4M ops/s |
231
- | order (nested + array) | **~11M** ops/s | ~2.5M ops/s | ~4M ops/s | ~2.4M ops/s | ~0.52M ops/s |
232
- | assert-loose | **~177M** ops/s | ~162M ops/s | ~46M ops/s | ~70M ops/s | ~3.9M ops/s |
233
- | assert-strict | **~164M** ops/s | ~146M ops/s | ~20M ops/s | ~44M ops/s | ~1.5M ops/s |
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 ~10% on `assert-loose` and ~12% on `assert-strict` (with
239
- `additionalProperties: false`)close enough that the two can trade the lead
240
- run-to-run. (typia and TypeBox still win the *invalid* path, where they bail on
241
- the first error rather than collecting a full error list.)
242
-
243
- Preparing a validator costs ~0.3–0.7 ms for mjst codegen and ~0.04–0.3 ms for a
244
- TypeBox `TypeCompiler` compile, versus ~7–11 ms for an Ajv compile. Every library
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["items"]) && !Array.isArray(node["prefixItems"]);
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 in record) || record[keyword] === true)
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" in node || "unevaluatedItems" in node) {
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 in node)
40
+ if (declaresKey(node, key))
40
41
  visit(node[key], enforced);
41
42
  }
42
- const items = node["items"];
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[key];
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[key];
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" in node)
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. A native `Set` dedupes the all-primitive case\n * in one linear pass; object/array elements fall back to an exact pairwise\n * structural comparison.\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 for (let i = 0; i < len; i++) {\n for (let j = i + 1; j < len; j++) {\n if (valuesEqual(arr[i], arr[j])) return false\n }\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";
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
- if (a === b) return true
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. A native \`Set\` dedupes the all-primitive case
55
- * in one linear pass; object/array elements fall back to an exact pairwise
56
- * structural comparison.
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
- for (let j = i + 1; j < len; j++) {
72
- if (valuesEqual(arr[i], arr[j])) return false
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" in schema))
7
+ if (!declaresKey(schema, "if"))
7
8
  return [];
8
- const condition = schema["if"];
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" in schema || "unevaluatedItems" in schema)) {
28
+ if (rootSchema !== void 0 && (declaresKey(schema, "unevaluatedProperties") || declaresKey(schema, "unevaluatedItems"))) {
28
29
  collectCoverageRefs(schema, refs, rootSchema);
29
30
  }
30
- if (typeof schema["$ref"] === "string") {
31
- refs.push(schema["$ref"]);
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[mapKey];
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["items"])) {
44
- const additional = schema["additionalItems"];
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 in schema)
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[key];
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
- return `import { type ${typeName}, ${validatorName} } from './${filename}.js'`;
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
- imports.push(buildImport(ref, typeSuffix));
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
  `;