@amritk/generate-validators 0.13.1 → 0.15.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
@@ -219,29 +247,36 @@ happy path it runs a single allocation-free boolean guard — a pure `&&` chain
219
247
  `typeof` checks (plus an `Object.keys().length` count when an object is closed
220
248
  with `additionalProperties: false`) — and `return true`s straight away, only
221
249
  calling a separate error-collecting function when something is actually wrong.
222
- Keeping the hot function tiny lets V8 optimise it aggressively, so a valid-input
250
+ Keeping the hot function tiny lets the JIT 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
- The `assert-loose` / `assert-strict` rows are the exact shape used by
263
+ The `assert-loose` / `assert-strict` rows use the same *shape* as
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
+ They are **not** that project's numbers and they do not belong next to its
273
+ leaderboard: the shape is shared, the harness is not, and the harness is worth
274
+ an order of magnitude. See
275
+ [Against the moltar harness](#against-the-moltar-harness) for the same functions
276
+ measured under benny, the way the leaderboard measures them.
277
+
278
+ Preparing a validator costs ~0.3–0.7 ms for mjst codegen and ~0.05–0.2 ms for a
279
+ TypeBox `TypeCompiler` compile, versus ~13–17 ms for an Ajv compile. Every library
245
280
  agrees on every verdict; parity is asserted before timing.
246
281
 
247
282
  One caveat on the first two rows: their schemas declare `format` (`uuid`,
@@ -260,6 +295,94 @@ reproduce with:
260
295
  bun run bench
261
296
  ```
262
297
 
298
+ ### Against the moltar harness
299
+
300
+ The table above is this package's own harness (`bench/measure.ts`): the
301
+ validator is called directly over a pool of 32 distinct inputs, its verdict is
302
+ folded into an escaping sink so nothing can be optimised away, and the median of
303
+ 21 timed trials is reported.
304
+
305
+ The public leaderboard measures differently. Every operation there goes through
306
+ [benny](https://github.com/caderek/benny) (benchmark.js) into moltar's
307
+ `Benchmark` class, so the timed work is a call inside benchmark.js's compiled
308
+ loop, then a second call through a class property, around a fixture that is one
309
+ shared frozen module-level constant whose verdict `run()` throws away. That
310
+ harness has a floor, and near the top of the range the floor is what gets
311
+ measured.
312
+
313
+ `bun run bench:moltar` runs exactly that harness over the same functions, always
314
+ alongside a **no-op** control — a "validator" that checks nothing, which is the
315
+ fastest number the harness can physically produce. One run on this machine
316
+ (Linux x64, Bun 1.3.11 / Node 22.22, valid input):
317
+
318
+ | harness | runtime | `assert-loose` | `assert-strict` |
319
+ |:--|:--|--:|--:|
320
+ | this package (`measure.ts`) | Bun | ~200M ops/s | ~185M ops/s |
321
+ | benny, moltar's `Benchmark` | Node | ~100M ops/s | ~38M ops/s |
322
+ | benny, moltar's `Benchmark` | Bun | ~70M ops/s | ~2.4M ops/s |
323
+ | *no-op control, benny* | *Node* | *~120M ops/s* | *~120M ops/s* |
324
+ | *no-op control, benny* | *Bun* | *~325M ops/s (±75%)* | *~325M ops/s (±75%)* |
325
+
326
+ Read two things out of that. First, on Node the `assert-loose` figure sits
327
+ within 20% of a validator that does nothing, so under that harness it is not a
328
+ validator measurement at all: above that floor a faster validator cannot show
329
+ up as a faster number, and the published leaderboard runs on CI hardware slower
330
+ than this box, where the floor sits lower still. Second, `assert-strict` on Bun collapses to
331
+ ~2.4M because moltar's fixture is `Object.freeze({ … })` — see
332
+ [Frozen inputs](#frozen-inputs).
333
+
334
+ The harness makes no difference to correctness and every difference to the
335
+ number, so quote the two separately or not at all. Both are reproducible here:
336
+
337
+ ```bash
338
+ bun run bench # this package's harness
339
+ bun run bench:moltar # benny, under the leaderboard's conditions
340
+ ```
341
+
342
+ ### Frozen inputs
343
+
344
+ Closing an object with `additionalProperties: false` means proving no
345
+ undeclared key is present, and every library answers that by enumerating keys:
346
+ mjst's guard counts them (`Object.keys(obj).length === n`, exact because each
347
+ declared property is required and already proven present), Ajv and Zod sweep
348
+ with `for...in`, TypeBox runs its own sweep. On V8 that costs the same whatever
349
+ the input looks like.
350
+
351
+ On JavaScriptCore (Bun) it does not. Making an object non-extensible —
352
+ `Object.freeze`, `Object.seal` or a bare `Object.preventExtensions` — turns off
353
+ the engine's cached own-keys fast path, and *every* form of key enumeration
354
+ falls back to a generic walk: `Object.keys`, `Object.getOwnPropertyNames`,
355
+ `Reflect.ownKeys` and `for...in` alike. Property reads are untouched (a frozen
356
+ object reads at full speed), so the whole cost lands on the extra-key sweep, and
357
+ therefore on strict schemas only. Frozen inputs are ordinary — a config object
358
+ frozen at startup, a shared fixture, a module-level constant — so `bun run bench`
359
+ carries `small (4 fields, frozen)` and `assert-strict (frozen)` cases to keep it
360
+ measured. One run on this machine (Linux x64, Bun 1.3.11), valid input:
361
+
362
+ | `assert-strict` | mutable input | frozen input |
363
+ |:--|--:|--:|
364
+ | mjst (generated) | ~185M ops/s | ~1.7M ops/s |
365
+ | typia (transformed) | ~68M ops/s | ~1.7M ops/s |
366
+ | typebox (compiled) | ~46M ops/s | ~1.7M ops/s |
367
+ | ajv (compiled) | ~24M ops/s | ~1.5M ops/s |
368
+ | zod | ~1.4M ops/s | ~0.7M ops/s |
369
+
370
+ It is an engine-level cliff, not an mjst one: every compiled or generated strict
371
+ validator lands within a hair of the same number, because they are all paying
372
+ the same engine slow path. The generated code keeps the key count anyway. Every
373
+ alternative was measured and every one is worse overall. `Object.values(obj)`
374
+ and `Object.keys({ ...obj })` sidestep the cliff, but on the ordinary mutable
375
+ path they cost 28–37× under JSC and 2–7× under V8. Branching on
376
+ `Object.isExtensible(obj)` first keeps the mutable path recognisable, at ~4×
377
+ under JSC — and makes V8 slower in *both* directions (~2× mutable, ~7× frozen),
378
+ where there was no cliff to fix in the first place. Trading a large, portable
379
+ regression for a smaller win on one engine is not a good deal, so the sweep
380
+ stays as it is.
381
+
382
+ If it matters for your workload: validate before freezing (the verdict is the
383
+ same either way — `src/generators/frozen-input.test.ts` pins that), or run on a
384
+ V8 runtime, where the cliff does not exist.
385
+
263
386
  ---
264
387
 
265
388
  ## Related packages
@@ -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
  };