@amritk/generate-validators 0.16.1 → 0.17.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
@@ -27,20 +27,27 @@ const files = await buildValidatorSchema(schema, 'Document')
27
27
  `validateFoo` returns `true | { valid: false; errors: ValidationError[] }`.
28
28
  Check `if (result !== true)` for the failure path — `if (result.valid)` is
29
29
  wrong.
30
- 2. **Small signature:** `buildValidatorSchema(rootSchema, rootTypeName, typeSuffix?, schemas?, unknownKeys?)`
30
+ 2. **Small signature:** `buildValidatorSchema(rootSchema, rootTypeName, typeSuffix?, schemas?, unknownKeys?, formats?)`
31
31
  — async, no `strict`/`typesOnly`/options object. Returns `GeneratedFile[]` in
32
32
  memory (you write them). `unknownKeys` (`'count-keys'` by default,
33
33
  `'count-enumerable'` for Node-only output) picks how a closed object's guard
34
34
  counts keys; nothing in the generated code detects its runtime.
35
35
  3. **Output includes a shared `validation-result.ts`** (`ValidationError`,
36
- `ValidationResult`, helpers) plus the `index.ts` barrel.
36
+ `ValidationResult`, helpers) plus the `index.ts` barrel — and a `formats.ts`
37
+ when `formats` is passed. Each error is
38
+ `{ message, path, keyword, params }`, the shape
39
+ `@amritk/runtime-validators` reports, so branch on `keyword` rather than
40
+ matching message text.
37
41
  4. **`NaN` fails a *constrained* number** (`minimum`/`maximum`/`multipleOf` all
38
42
  reject it) and satisfies a bare `{ "type": "number" }`, which is Ajv's answer
39
43
  too. Draft-07 schemas are auto-upgraded to 2020-12.
40
- 5. **`format` emits no check.** It stays an annotation, like the interpreter's
41
- default — but *not* like the interpreter run with `{ formats: 'all' }`
42
- (`@amritk/lint`, `createApi({ formats })`), which rejects strings a generated
43
- validator accepts. `unevaluatedProperties`/`unevaluatedItems` *are* generated;
44
+ 5. **`format` emits no check unless you pass `formats`.** By default it stays an
45
+ annotation, like the interpreter given no formats — but *not* like the
46
+ interpreter run with `{ formats: 'all' }` (`@amritk/lint`,
47
+ `createApi({ formats })`), which rejects strings such a validator accepts.
48
+ Pass `formats` (`'all'` or a list, or `--formats` on the CLI) and both
49
+ `validateX` and `isX` check them, against a `formats.ts` emitted alongside.
50
+ Set it to whatever validates the same schemas at runtime. `unevaluatedProperties`/`unevaluatedItems` *are* generated;
44
51
  four shapes still refuse (coverage through a `$dynamicRef`, an unresolvable or
45
52
  cyclic `$ref` at the same instance location, a walk deeper than eight
46
53
  applicators, a node under an *inert* `additionalItems` — one with no array
package/README.md CHANGED
@@ -22,7 +22,11 @@
22
22
  Each generated file exports:
23
23
 
24
24
  - A TypeScript `type` definition for the schema
25
- - A `validateFoo(input: unknown, _path?: string): ValidationResult` function
25
+ - A `validateFoo(input: unknown, _path?: string): ValidationResult` function, whose
26
+ errors carry the keyword that rejected the value and that keyword's own values
27
+ (`{ message, path, keyword, params }`) — the shape
28
+ [`@amritk/runtime-validators`](../runtime-validators) reports, so an error from
29
+ either can be rendered, translated or branched on by the same code
26
30
  - An `isFoo(input: unknown): input is Foo` boolean type guard — a single flat
27
31
  predicate (no error array, no cold-path call) reaching the same verdict as
28
32
  `validateFoo`, for the common "is this valid?" question
@@ -84,7 +88,7 @@ if (!result.valid) {
84
88
 
85
89
  ## API
86
90
 
87
- ### `buildValidatorSchema(rootSchema, rootTypeName, typeSuffix?, schemas?, unknownKeys?)`
91
+ ### `buildValidatorSchema(rootSchema, rootTypeName, typeSuffix?, schemas?, unknownKeys?, formats?)`
88
92
 
89
93
  | Parameter | Type | Default | Description |
90
94
  |:---|:---|:---|:---|
@@ -93,6 +97,7 @@ if (!result.valid) {
93
97
  | `typeSuffix` | `string` | `''` | Suffix appended to every `$ref`-derived type name (`'Object'` turns `Contact` into `ContactObject`). The root type name is unaffected. |
94
98
  | `schemas` | `Record<string, unknown>` | — | Documents you have **already loaded**, keyed by the absolute URI a `$ref` names them by. See below. |
95
99
  | `unknownKeys` | `'count-keys' \| 'count-enumerable'` | `'count-keys'` | How the fast paths prove a closed object (`additionalProperties: false`) has no undeclared key: `Object.keys(obj).length` (fastest on Bun) or a `for…in` count (fastest on Node). See [Choosing how keys are counted](#choosing-how-keys-are-counted). |
100
+ | `formats` | `'all' \| string[]` | — | String `format`s the generated validators enforce. Unset leaves `format` an annotation, as JSON Schema reads it. See [Semantics](#semantics). |
96
101
 
97
102
  Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; content: string }`.
98
103
 
@@ -169,12 +174,26 @@ is not. And a self-referential object reaches a verdict — the structural
169
174
  comparison stops at 512 levels — where it used to throw a `RangeError` out of a
170
175
  function whose signature promises a `ValidationResult`.
171
176
 
172
- **`format` emits no check.** JSON Schema treats `format` as an annotation, and so
173
- does this generator: `{ type: 'string', format: 'uuid' }` produces the `typeof`
174
- check and nothing more. That matches the interpreter's default, but *not* the
175
- interpreter run with `{ formats: 'all' }` — as `@amritk/lint` and
176
- `createApi({ formats })` do — so a generated validator accepts strings those
177
- reject.
177
+ **`format` is enforced when you ask for it.** JSON Schema treats `format` as an
178
+ annotation, and so does this generator by default: `{ type: 'string', format:
179
+ 'uuid' }` produces the `typeof` check and nothing more, which matches the
180
+ interpreter given no formats.
181
+
182
+ Pass `formats` (`'all'`, or the names to check) and both `validateX` and the flat
183
+ `isX` check them — the guard has to see the same set as the validator, or the two
184
+ would disagree, which is the one thing it may never do. The checks are emitted
185
+ into a `formats.ts` beside the validators, holding only the formats the schema
186
+ names, because generated output is dependency-free and cannot import
187
+ `@amritk/runtime-validators`' table.
188
+
189
+ Two implementations of one rule is what drifts, so they are not merely assumed to
190
+ agree: `emit-format-checks.test.ts` runs both over the official suite's whole
191
+ optional/format corpus and requires the same verdict on every case.
192
+
193
+ Set it to whatever validates the same schemas at runtime — `@amritk/lint` and
194
+ `createApi({ formats })` run the interpreter with formats on — and the build-time
195
+ and runtime answers agree. Leave it unset and they agree too, on the annotation
196
+ reading.
178
197
 
179
198
  **`unevaluatedProperties` / `unevaluatedItems` are generated**, not refused. Each
180
199
  emits a flat expression computing what the interpreter computes as annotations: per
@@ -212,7 +231,7 @@ official [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema
212
231
  (the required Draft 2020-12 tests — 1281 cases), compiles and links the emitted
213
232
  files in memory, and runs the suite's instances through the real generated code:
214
233
 
215
- **1274 / 1281 cases pass (99.5%).**
234
+ **1276 / 1281 cases pass (99.6%).**
216
235
 
217
236
  The suite's `remotes/` documents and the 2020-12 dialect metaschema are supplied
218
237
  through the `schemas` option, which is how the suite intends a validator that does
@@ -220,10 +239,9 @@ no I/O to answer the retrieval step. Everything else — applying the base URIs,
220
239
  walking anchors across documents, naming and emitting a file per definition — the
221
240
  generator still has to do.
222
241
 
223
- Of the 7 that do not pass: four `$dynamicRef`s whose binding depends on the
242
+ Of the 5 that do not pass: four `$dynamicRef`s whose binding depends on the
224
243
  evaluation path (a generator emits one function per definition, shared by every
225
- path that reaches it, so it cannot bind per path), two definitions in different
226
- embedded resources that reduce to one filename, and `$vocabulary`. Nothing on the
244
+ path that reaches it, so it cannot bind per path), and `$vocabulary`. Nothing on the
227
245
  list is a keyword that silently returns the wrong answer.
228
246
 
229
247
  Every case is named in
@@ -322,10 +340,12 @@ are within a whisker of each other on both engines. Every library agrees on
322
340
  every verdict; parity is asserted before timing.
323
341
 
324
342
  One caveat on the first two rows: their schemas declare `format` (`uuid`,
325
- `email`), and Ajv, typia, zod, and TypeBox all check it, while mjst's generated
326
- validators treat it as an annotation (see [Semantics](#semantics)). So on `small`
327
- and `order`, mjst is doing slightly less work than the columns beside it — the
328
- parity samples fail other constraints too, which is why the verdicts still agree.
343
+ `email`), and Ajv, typia, zod, and TypeBox all check it, while the benchmark
344
+ generates mjst's validators without `formats` the default, where `format` is an
345
+ annotation (see [Semantics](#semantics)). So on `small` and `order`, mjst is
346
+ doing slightly less work than the columns beside it; pass `formats` and it does
347
+ the same work. The parity samples fail other constraints too, which is why the
348
+ verdicts still agree either way.
329
349
  The `assert-loose` / `assert-strict` rows carry no `format` and are the
330
350
  constraint-for-constraint comparison. Each library is
331
351
  timed in an isolated process over a pool of distinct inputs, reporting the median
@@ -12,7 +12,7 @@ export type GeneratedFile = {
12
12
  * types plus the helpers emitted code calls as free identifiers. Exported so tests
13
13
  * can evaluate the very source that ships instead of reimplementing it.
14
14
  */
15
- export declare const VALIDATION_RESULT_CONTENT = "/**\n * A single validation error 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 * True when `test` holds for every element of `arr`, holes included. Backs the\n * item check inside a generated boolean guard.\n *\n * Not `Array.prototype.every`, because that *skips holes* in a sparse array\n * (`[, 'x']`), whereas the validator's index-based loop reads a hole as\n * `undefined` and rejects it \u2014 and the guard must never accept what the validator\n * rejects. The guard used to get that by materialising `Array.from(arr)` first,\n * which copied every array it looked at; an index loop reads a hole the same way\n * and copies nothing.\n */\nexport const everyItem = (arr: readonly unknown[], test: (item: unknown) => boolean): boolean => {\n for (let i = 0; i < arr.length; i++) if (!test(arr[i])) return false\n return true\n}\n\n/**\n * Escapes one JSON Pointer segment (RFC 6901): `~` \u2192 `~0`, `/` \u2192 `~1`, in that\n * order. Generated error paths are built from *runtime* keys wherever the schema\n * did not name them \u2014 a `patternProperties` match, an `additionalProperties`\n * sweep, a `propertyNames` loop \u2014 and a key containing a `/` would otherwise read\n * back as two segments, so an error on `{\"a/b\": \u2026}` pointed at `/a/b`, which is\n * the child `b` of a property `a`. Keys the schema *does* name are escaped at\n * generation time instead, and `@amritk/runtime-validators` escapes the same way,\n * so all three agree.\n *\n * The `indexOf` pre-test keeps the common key \u2014 no `/`, no `~` \u2014 off the replace\n * path entirely, which is what the interpreter does for the same reason.\n */\nexport const escapePointer = (key: string): string =>\n key.indexOf('/') !== -1 || key.indexOf('~') !== -1 ? key.replace(/~/g, '~0').replace(/\\//g, '~1') : key\n";
15
+ export declare const VALIDATION_RESULT_CONTENT = "/**\n * A single validation error: what went wrong, where, and which keyword said so.\n *\n * The shape matches `@amritk/runtime-validators`, so an error from a generated\n * validator and one from the runtime interpreter can be handled by the same\n * code \u2014 grouped, translated, or branched on \u2014 without knowing which produced it.\n */\nexport type ValidationError = {\n /** Human-readable description of what went wrong. */\n message: string\n /** JSON Pointer to the offending value inside the instance. */\n path: string\n /** The JSON Schema keyword that rejected the value \u2014 `type`, `required`, `minimum`, \u2026 */\n keyword: string\n /**\n * The keyword's own values, as far as they explain the failure: the bound that\n * was exceeded, the property that was missing, the allowed values that were not\n * matched. Empty for a keyword with nothing to add beyond its name.\n */\n params: Record<string, unknown>\n}\n\n/**\n * The result of a generated validator function.\n * Returns `true` when the input is valid, or an object with `valid: false`\n * and a list of errors when it is not.\n */\nexport type ValidationResult = true | { valid: false; errors: ValidationError[] }\n\n/**\n * How deep a structural comparison walks before it gives up and answers \"not\n * equal\".\n *\n * JSON data is acyclic, but a generated validator is a plain function applied to\n * whatever in-memory value a caller hands it \u2014 and a self-referential object\n * reaching a `const` / `enum` / `uniqueItems` check used to recurse until the\n * stack overflowed, so `validateFoo` threw a `RangeError` instead of returning\n * the `ValidationResult` its signature promises. The cap turns that into an\n * ordinary \"these are different\" without ever coming near real data;\n * `@amritk/runtime-validators` guards its own `deepEqual` at the same depth.\n */\nconst MAX_EQUAL_DEPTH = 512\n\n/**\n * Structural deep equality used by generated `const` checks. Objects compare by\n * their key sets rather than serialization, so `{ a: 1, b: 2 }` and\n * `{ b: 2, a: 1 }` are equal \u2014 unlike `JSON.stringify`, which is key-order\n * sensitive and would reject a reordered-but-equal value.\n */\nexport const valuesEqual = (a: unknown, b: unknown, depth = 0): boolean => {\n // SameValueZero: `===` settles every primitive except `NaN`, which counts as\n // equal to itself here. That is what the native `Set` in {@link allUnique} does,\n // what Ajv does, and what the interpreter's `deepEqual` does \u2014 leaving it out\n // made a `NaN` nested inside an object compare unequal to itself, so the same\n // array was \"unique\" here and \"duplicated\" everywhere else.\n if (a === b || (Number.isNaN(a) && Number.isNaN(b))) return true\n if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false\n if (depth >= MAX_EQUAL_DEPTH) return false\n const aArray = Array.isArray(a)\n const bArray = Array.isArray(b)\n if (aArray !== bArray) return false\n if (aArray) {\n const aa = a as unknown[]\n const bb = b as unknown[]\n if (aa.length !== bb.length) return false\n for (let i = 0; i < aa.length; i++) if (!valuesEqual(aa[i], bb[i], depth + 1)) return false\n return true\n }\n const ao = a as Record<string, unknown>\n const bo = b as Record<string, unknown>\n const keys = Object.keys(ao)\n if (keys.length !== Object.keys(bo).length) return false\n for (const key of keys) {\n if (!Object.hasOwn(bo, key) || !valuesEqual(ao[key], bo[key], depth + 1)) return false\n }\n return true\n}\n\n/**\n * A cheap, order-independent structural hash consistent with {@link valuesEqual}:\n * equal values always hash the same. It buckets candidate-equal elements in\n * {@link allUnique} so the exact comparison only ever runs inside a bucket.\n *\n * Object keys are folded commutatively (XOR) so key order does not change the\n * hash, and `NaN` / `-0` collapse the way SameValueZero does. Depth-capped like\n * {@link valuesEqual}: an over-deep value simply shares a bucket and is settled by\n * the (also capped) comparison, so the cap can cost a little time and never a\n * wrong answer. This is the same hash `@amritk/runtime-validators` uses.\n */\nconst structuralHash = (value: unknown, depth = 0): number => {\n if (value === null) return 0x1a2b3c\n const t = typeof value\n if (t === 'number') {\n const n = value as number\n return Number.isNaN(n) ? 0x7ff8 : n === 0 ? 0 : Math.trunc(n * 2654435761) | 0\n }\n if (t === 'string') {\n const s = value as string\n let h = 0x811c9dc5\n for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)\n return h | 0\n }\n if (t === 'boolean') return value ? 1 : 2\n if (t !== 'object') return 0x5eed\n if (depth >= MAX_EQUAL_DEPTH) return 0xdee9\n if (Array.isArray(value)) {\n let h = 0x12345 ^ value.length\n for (let i = 0; i < value.length; i++) h = (Math.imul(h, 31) + structuralHash(value[i], depth + 1)) | 0\n return h | 0\n }\n const obj = value as Record<string, unknown>\n const keys = Object.keys(obj)\n let h = 0xabcde ^ keys.length\n for (const k of keys) {\n let kh = 0x811c9dc5\n for (let i = 0; i < k.length; i++) kh = Math.imul(kh ^ k.charCodeAt(i), 0x01000193)\n h = (h ^ (Math.imul(kh, 0x9e3779b1) + structuralHash(obj[k], depth + 1))) | 0\n }\n return h | 0\n}\n\n/**\n * True when every element of `arr` is distinct under structural equality\n * ({@link valuesEqual}). Backs generated `uniqueItems` checks whose items may be\n * objects or arrays, where a `JSON.stringify` dedupe key would be key-order\n * sensitive and let a reordered-but-equal duplicate (`{ a: 1, b: 2 }` vs\n * `{ b: 2, a: 1 }`) slip through.\n *\n * A native `Set` dedupes the all-primitive case in one linear pass. Object and\n * array elements are bucketed by {@link structuralHash} first, so the exact\n * comparison runs only against elements that could actually be equal: an array of\n * distinct objects costs ~O(n) instead of the O(n\u00B2) an exhaustive pairwise sweep\n * charged \u2014 4 000 rows took over half a second of pure comparison before, which\n * is a lot to hand an unauthenticated caller.\n */\nexport const allUnique = (arr: readonly unknown[]): boolean => {\n const len = arr.length\n if (len < 2) return true\n let allPrimitive = true\n for (let i = 0; i < len; i++) {\n const v = arr[i]\n if (v !== null && typeof v === 'object') {\n allPrimitive = false\n break\n }\n }\n if (allPrimitive) return new Set(arr).size === len\n const buckets = new Map<number, unknown[]>()\n for (let i = 0; i < len; i++) {\n const item = arr[i]\n const hash = structuralHash(item)\n const bucket = buckets.get(hash)\n if (bucket === undefined) {\n buckets.set(hash, [item])\n continue\n }\n for (const seen of bucket) if (valuesEqual(seen, item)) return false\n bucket.push(item)\n }\n return true\n}\n\n/**\n * True when `test` holds for every element of `arr`, holes included. Backs the\n * item check inside a generated boolean guard.\n *\n * Not `Array.prototype.every`, because that *skips holes* in a sparse array\n * (`[, 'x']`), whereas the validator's index-based loop reads a hole as\n * `undefined` and rejects it \u2014 and the guard must never accept what the validator\n * rejects. The guard used to get that by materialising `Array.from(arr)` first,\n * which copied every array it looked at; an index loop reads a hole the same way\n * and copies nothing.\n */\nexport const everyItem = (arr: readonly unknown[], test: (item: unknown) => boolean): boolean => {\n for (let i = 0; i < arr.length; i++) if (!test(arr[i])) return false\n return true\n}\n\n/**\n * Escapes one JSON Pointer segment (RFC 6901): `~` \u2192 `~0`, `/` \u2192 `~1`, in that\n * order. Generated error paths are built from *runtime* keys wherever the schema\n * did not name them \u2014 a `patternProperties` match, an `additionalProperties`\n * sweep, a `propertyNames` loop \u2014 and a key containing a `/` would otherwise read\n * back as two segments, so an error on `{\"a/b\": \u2026}` pointed at `/a/b`, which is\n * the child `b` of a property `a`. Keys the schema *does* name are escaped at\n * generation time instead, and `@amritk/runtime-validators` escapes the same way,\n * so all three agree.\n *\n * The `indexOf` pre-test keeps the common key \u2014 no `/`, no `~` \u2014 off the replace\n * path entirely, which is what the interpreter does for the same reason.\n */\nexport const escapePointer = (key: string): string =>\n key.indexOf('/') !== -1 || key.indexOf('~') !== -1 ? key.replace(/~/g, '~0').replace(/\\//g, '~1') : key\n";
16
16
  /**
17
17
  * Builds all TypeScript validator files from a JSON Schema by traversing all
18
18
  * `$ref` / `$dynamicRef` references recursively (via the shared
@@ -56,4 +56,4 @@ export declare const VALIDATION_RESULT_CONTENT = "/**\n * A single validation er
56
56
  * })
57
57
  * ```
58
58
  */
59
- export declare const buildValidatorSchema: (rootSchema: JSONSchema, rootTypeName: string, typeSuffix?: string, schemas?: Readonly<Record<string, unknown>>, unknownKeys?: UnknownKeysStrategy) => Promise<GeneratedFile[]>;
59
+ export declare const buildValidatorSchema: (rootSchema: JSONSchema, rootTypeName: string, typeSuffix?: string, schemas?: Readonly<Record<string, unknown>>, unknownKeys?: UnknownKeysStrategy, formats?: 'all' | readonly string[]) => Promise<GeneratedFile[]>;
@@ -1,14 +1,28 @@
1
1
  import { generateIndexBarrel } from "@amritk/helpers/generate-index-barrel";
2
2
  import { DEFAULT_UNKNOWN_KEYS } from "@amritk/helpers/unknown-keys-strategy";
3
3
  import { walkRefGraph } from "@amritk/helpers/walk-ref-graph";
4
+ import { emitFormatModule, FORMAT_FRAGMENTS, formatCheckName } from "./emit-format-checks.js";
4
5
  import { generateValidatorFile } from "./generate-files.js";
5
6
  const VALIDATION_RESULT_CONTENT = `/**
6
- * A single validation error with a human-readable message and a JSON Pointer
7
- * path indicating where in the document the error occurred.
7
+ * A single validation error: what went wrong, where, and which keyword said so.
8
+ *
9
+ * The shape matches \`@amritk/runtime-validators\`, so an error from a generated
10
+ * validator and one from the runtime interpreter can be handled by the same
11
+ * code \u2014 grouped, translated, or branched on \u2014 without knowing which produced it.
8
12
  */
9
13
  export type ValidationError = {
14
+ /** Human-readable description of what went wrong. */
10
15
  message: string
16
+ /** JSON Pointer to the offending value inside the instance. */
11
17
  path: string
18
+ /** The JSON Schema keyword that rejected the value \u2014 \`type\`, \`required\`, \`minimum\`, \u2026 */
19
+ keyword: string
20
+ /**
21
+ * The keyword's own values, as far as they explain the failure: the bound that
22
+ * was exceeded, the property that was missing, the allowed values that were not
23
+ * matched. Empty for a keyword with nothing to add beyond its name.
24
+ */
25
+ params: Record<string, unknown>
12
26
  }
13
27
 
14
28
  /**
@@ -225,7 +239,8 @@ const RESERVED_WORDS = /* @__PURE__ */ new Set([
225
239
  "with",
226
240
  "yield"
227
241
  ]);
228
- const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "", schemas, unknownKeys = DEFAULT_UNKNOWN_KEYS) => {
242
+ const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "", schemas, unknownKeys = DEFAULT_UNKNOWN_KEYS, formats) => {
243
+ const enforced = formats === "all" ? new Set(Object.keys(FORMAT_FRAGMENTS)) : new Set((formats ?? []).filter((name) => Object.hasOwn(FORMAT_FRAGMENTS, name)));
229
244
  const files = [];
230
245
  walkRefGraph(rootSchema, rootTypeName, { typeSuffix, ...schemas !== void 0 ? { schemas } : {} }, (node) => {
231
246
  if (node.filename === "validation-result" || node.filename === "index") {
@@ -246,11 +261,16 @@ const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "", s
246
261
  rootSchema: node.rootSchema,
247
262
  typeSuffix,
248
263
  unknownKeys,
264
+ formats: enforced,
249
265
  ...node.ref !== void 0 ? { selfRef: node.ref } : {}
250
266
  });
251
267
  files.push({ filename: `${node.filename}.ts`, content });
252
268
  });
253
269
  files.push({ filename: "validation-result.ts", content: VALIDATION_RESULT_CONTENT });
270
+ const called = [...enforced].filter((format) => files.some((file) => file.content.includes(`${formatCheckName(format)}(`)));
271
+ const formatModule = emitFormatModule(called);
272
+ if (formatModule !== "")
273
+ files.push({ filename: "formats.ts", content: formatModule });
254
274
  files.push({ filename: "index.ts", content: generateIndexBarrel(files) });
255
275
  return files;
256
276
  };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The `format` checks, as emittable source.
3
+ *
4
+ * Generated validators are dependency-free straight-line TypeScript — that is
5
+ * most of what makes them fast and all of what makes them readable — so a
6
+ * generated validator cannot import `@amritk/runtime-validators`' format table.
7
+ * It gets its own copy, and only of the formats the schema actually names.
8
+ *
9
+ * Two implementations of one rule is exactly the situation that drifts, so it is
10
+ * pinned rather than trusted: `emit-format-checks.test.ts` runs this source and
11
+ * the interpreter's table over the official suite's whole optional/format corpus
12
+ * and requires the same verdict on every case. A divergence fails the build.
13
+ *
14
+ * Each entry is a source *fragment*: a `const` declaration whose name is
15
+ * {@link formatCheckName}. Fragments may depend on shared helpers, named in
16
+ * `needs`, which are emitted once ahead of them.
17
+ *
18
+ * Nearly every string here is code for the *generated* file, so a `${…}` in one
19
+ * is a template hole in the emitted source rather than an interpolation that was
20
+ * meant to happen here — which is what `noTemplateCurlyInString` exists to catch
21
+ * and what makes it a false positive throughout this file.
22
+ */
23
+ /** The identifier a format's check is emitted under. */
24
+ export declare const formatCheckName: (format: string) => string;
25
+ type FormatFragment = {
26
+ /** The JSON type the check takes — a format asserts about one and is silent about the rest. */
27
+ readonly family: 'string' | 'number';
28
+ /** Names of {@link SHARED} helpers this fragment reads. */
29
+ readonly needs?: readonly string[];
30
+ /** The `const <name> = …` declaration, with `<name>` already substituted. */
31
+ readonly source: (name: string) => string;
32
+ };
33
+ /**
34
+ * Every format the generator can emit a check for — the same set
35
+ * `@amritk/runtime-validators` enforces, and deliberately not a subset: a
36
+ * generator that silently skipped some would be the exact footgun this closes.
37
+ */
38
+ export declare const FORMAT_FRAGMENTS: Readonly<Record<string, FormatFragment>>;
39
+ /** The JSON type a format asserts about, or `undefined` when nothing defines it. */
40
+ export declare const formatFamily: (format: string) => 'string' | 'number' | undefined;
41
+ /**
42
+ * The source of a `formats.ts` module defining a check for each format in
43
+ * `formats`, or `''` when none of them is one this generator knows.
44
+ *
45
+ * Only the formats the schema actually names are emitted, along with whichever
46
+ * shared helpers those need — so a schema declaring one `uuid` gets one regex
47
+ * rather than the whole table.
48
+ */
49
+ export declare const emitFormatModule: (formats: Iterable<string>) => string;
50
+ export {};
@@ -0,0 +1,248 @@
1
+ const formatCheckName = (format) => `isFormat${format.split(/[^A-Za-z0-9]+/).filter((part) => part !== "").map((part) => part[0]?.toUpperCase() + part.slice(1)).join("")}`;
2
+ const SHARED = {
3
+ _asciiLabel: `const _asciiLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'`,
4
+ _idnLabel: `const _idnLabel = '[\\\\p{L}\\\\p{N}](?:[\\\\p{L}\\\\p{N}\\\\p{M}-]{0,61}[\\\\p{L}\\\\p{N}\\\\p{M}])?'`,
5
+ _ipv4Octets: [
6
+ `const _ipv4Octet = '(?:25[0-5]|2[0-4]\\\\d|1\\\\d\\\\d|[1-9]?\\\\d)'`,
7
+ "const _ipv4Octets = `(?:${_ipv4Octet}\\\\.){3}${_ipv4Octet}`"
8
+ ].join("\n"),
9
+ _ipv6Body: [
10
+ `const _h16 = '[0-9a-fA-F]{1,4}'`,
11
+ "const _ls32 = `(?:${_h16}:${_h16}|${_ipv4Octets})`",
12
+ "const _ipv6Body =",
13
+ " `(?:(?:${_h16}:){6}${_ls32}` +",
14
+ " `|::(?:${_h16}:){5}${_ls32}` +",
15
+ " `|(?:${_h16})?::(?:${_h16}:){4}${_ls32}` +",
16
+ " `|(?:(?:${_h16}:){0,1}${_h16})?::(?:${_h16}:){3}${_ls32}` +",
17
+ " `|(?:(?:${_h16}:){0,2}${_h16})?::(?:${_h16}:){2}${_ls32}` +",
18
+ " `|(?:(?:${_h16}:){0,3}${_h16})?::(?:${_h16}:)${_ls32}` +",
19
+ " `|(?:(?:${_h16}:){0,4}${_h16})?::${_ls32}` +",
20
+ " `|(?:(?:${_h16}:){0,5}${_h16})?::${_h16}` +",
21
+ " `|(?:(?:${_h16}:){0,6}${_h16})?::)`"
22
+ ].join("\n"),
23
+ _uriGrammar: [
24
+ `const _subDelims = "!$&'()*+,;="`,
25
+ `const _pctEncoded = '%[0-9A-Fa-f]{2}'`,
26
+ "const _ucschar =",
27
+ " '\\\\u00A0-\\\\uD7FF\\\\uF900-\\\\uFDCF\\\\uFDF0-\\\\uFFEF' +",
28
+ " '\\\\u{10000}-\\\\u{1FFFD}\\\\u{20000}-\\\\u{2FFFD}\\\\u{30000}-\\\\u{3FFFD}\\\\u{40000}-\\\\u{4FFFD}' +",
29
+ " '\\\\u{50000}-\\\\u{5FFFD}\\\\u{60000}-\\\\u{6FFFD}\\\\u{70000}-\\\\u{7FFFD}\\\\u{80000}-\\\\u{8FFFD}' +",
30
+ " '\\\\u{90000}-\\\\u{9FFFD}\\\\u{A0000}-\\\\u{AFFFD}\\\\u{B0000}-\\\\u{BFFFD}\\\\u{C0000}-\\\\u{CFFFD}' +",
31
+ " '\\\\u{D0000}-\\\\u{DFFFD}\\\\u{E1000}-\\\\u{EFFFD}'",
32
+ `const _iprivate = '\\\\uE000-\\\\uF8FF\\\\u{F0000}-\\\\u{FFFFD}\\\\u{100000}-\\\\u{10FFFD}'`,
33
+ 'const _uriGrammar = (extraUnreserved: string, privateQuery = ""): { uri: string; reference: string } => {',
34
+ " const unreserved = `A-Za-z0-9\\\\-._~${extraUnreserved}`",
35
+ " const scheme = '[A-Za-z][A-Za-z0-9+\\\\-.]*'",
36
+ " const pchar = `(?:[${unreserved}${_subDelims}:@]|${_pctEncoded})`",
37
+ " const segment = `${pchar}*`",
38
+ " const segmentNz = `${pchar}+`",
39
+ " const segmentNzNc = `(?:[${unreserved}${_subDelims}@]|${_pctEncoded})+`",
40
+ " const userinfo = `(?:[${unreserved}${_subDelims}:]|${_pctEncoded})*`",
41
+ " const ipvFuture = `[Vv][0-9A-Fa-f]+\\\\.[${unreserved}${_subDelims}:]+`",
42
+ " const ipLiteral = `\\\\[(?:${_ipv6Body}|${ipvFuture})\\\\]`",
43
+ " const regName = `(?:[${unreserved}${_subDelims}]|${_pctEncoded})*`",
44
+ " const host = `(?:${ipLiteral}|${_ipv4Octets}|${regName})`",
45
+ " const authority = `(?:${userinfo}@)?${host}(?::\\\\d*)?`",
46
+ " const pathAbempty = `(?:/${segment})*`",
47
+ " const pathAbsolute = `/(?:${segmentNz}(?:/${segment})*)?`",
48
+ " const pathRootless = `${segmentNz}(?:/${segment})*`",
49
+ " const pathNoscheme = `${segmentNzNc}(?:/${segment})*`",
50
+ " const hierPart = `(?://${authority}${pathAbempty}|${pathAbsolute}|${pathRootless}|)`",
51
+ " const relativePart = `(?://${authority}${pathAbempty}|${pathAbsolute}|${pathNoscheme}|)`",
52
+ " const query = `(?:\\\\?(?:${pchar}|[/?${privateQuery}])*)?`",
53
+ " const fragment = `(?:#(?:${pchar}|[/?])*)?`",
54
+ " return {",
55
+ " uri: `^${scheme}:${hierPart}${query}${fragment}$`,",
56
+ " reference: `^(?:${scheme}:${hierPart}|${relativePart})${query}${fragment}$`,",
57
+ " }",
58
+ "}",
59
+ 'const _asciiUri = _uriGrammar("")',
60
+ "const _iriUri = _uriGrammar(_ucschar, _iprivate)"
61
+ ].join("\n"),
62
+ _isDate: [
63
+ "const _dateShape = /^(\\d\\d\\d\\d)-(\\d\\d)-(\\d\\d)$/",
64
+ "const _daysInMonth = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]",
65
+ "const _isLeapYear = (year: number): boolean => year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)",
66
+ "const _isDate = (value: string): boolean => {",
67
+ " const matches = _dateShape.exec(value)",
68
+ " if (matches === null) return false",
69
+ " const year = +(matches[1] as string)",
70
+ " const month = +(matches[2] as string)",
71
+ " const day = +(matches[3] as string)",
72
+ " if (month < 1 || month > 12 || day < 1) return false",
73
+ " return day <= (month === 2 && _isLeapYear(year) ? 29 : (_daysInMonth[month] as number))",
74
+ "}"
75
+ ].join("\n"),
76
+ _timeCheck: [
77
+ "const _timeShape = /^(\\d\\d):(\\d\\d):(\\d\\d)(?:\\.\\d+)?(?:([Zz])|([+-])(\\d\\d):(\\d\\d))?$/",
78
+ "const _timeCheck =",
79
+ " (requireOffset: boolean) =>",
80
+ " (value: string): boolean => {",
81
+ " const matches = _timeShape.exec(value)",
82
+ " if (matches === null) return false",
83
+ " const hour = +(matches[1] as string)",
84
+ " const minute = +(matches[2] as string)",
85
+ " const second = +(matches[3] as string)",
86
+ " const zulu = matches[4] !== undefined",
87
+ ' const offsetSign = matches[5] === "-" ? -1 : 1',
88
+ " const hasOffset = zulu || matches[5] !== undefined",
89
+ " if (requireOffset && !hasOffset) return false",
90
+ " const offsetHours = +(matches[6] ?? 0)",
91
+ " const offsetMinutes = +(matches[7] ?? 0)",
92
+ " if (hour > 23 || minute > 59 || second > 60 || offsetHours > 23 || offsetMinutes > 59) return false",
93
+ " if (second < 60) return true",
94
+ " const utcMinute = minute - offsetMinutes * offsetSign",
95
+ " const utcHour = hour - offsetHours * offsetSign - (utcMinute < 0 ? 1 : 0)",
96
+ " return (utcHour === 23 || utcHour === -1) && (utcMinute === 59 || utcMinute === -1)",
97
+ " }",
98
+ "const _dateTimeSeparator = /t|\\s/i",
99
+ "const _dateTimeCheck = (time: (value: string) => boolean) => (value: string) => {",
100
+ " const parts = value.split(_dateTimeSeparator)",
101
+ " return parts.length === 2 && _isDate(parts[0] as string) && time(parts[1] as string)",
102
+ "}"
103
+ ].join("\n"),
104
+ _emailPattern: [
105
+ `const _asciiAtom = "[a-zA-Z0-9!#$%&'*+/=?^_\`{|}~-]"`,
106
+ `const _idnAtom = "[\\\\p{L}\\\\p{N}\\\\p{M}!#$%&'*+/=?^_\`{|}~-]"`,
107
+ "const _emailPattern = (atom: string, label: string, unicode: boolean): RegExp =>",
108
+ ' new RegExp(`^${atom}+(?:\\\\.${atom}+)*@(?:${label}\\\\.)+${label}$`, unicode ? "u" : "")'
109
+ ].join("\n")
110
+ };
111
+ const SHARED_NEEDS = {
112
+ _ipv6Body: ["_ipv4Octets"],
113
+ _uriGrammar: ["_ipv4Octets", "_ipv6Body"],
114
+ _timeCheck: ["_isDate"]
115
+ };
116
+ const regexFragment = (family, pattern, needs) => ({
117
+ family,
118
+ ...needs === void 0 ? {} : { needs },
119
+ source: (name) => `const ${name} = (value: string): boolean => ${pattern}.test(value)`
120
+ });
121
+ const builtFragment = (family, expression, needs) => ({
122
+ family,
123
+ needs,
124
+ source: (name) => `const ${name} = ${expression}`
125
+ });
126
+ const FORMAT_FRAGMENTS = {
127
+ email: builtFragment("string", "_emailPattern(_asciiAtom, _asciiLabel, false).test.bind(_emailPattern(_asciiAtom, _asciiLabel, false))", ["_emailPattern", "_asciiLabel"]),
128
+ "idn-email": builtFragment("string", "_emailPattern(_idnAtom, _idnLabel, true).test.bind(_emailPattern(_idnAtom, _idnLabel, true))", ["_emailPattern", "_idnLabel"]),
129
+ date: builtFragment("string", "_isDate", ["_isDate"]),
130
+ time: builtFragment("string", "_timeCheck(true)", ["_timeCheck"]),
131
+ "iso-time": builtFragment("string", "_timeCheck(false)", ["_timeCheck"]),
132
+ "date-time": builtFragment("string", "_dateTimeCheck(_timeCheck(true))", ["_timeCheck"]),
133
+ "iso-date-time": builtFragment("string", "_dateTimeCheck(_timeCheck(false))", ["_timeCheck"]),
134
+ duration: builtFragment("string", [
135
+ "((): ((value: string) => boolean) => {",
136
+ " const second = '\\\\d+S'",
137
+ " const minute = `\\\\d+M(?:${second})?`",
138
+ " const hour = `\\\\d+H(?:${minute})?`",
139
+ " const time = `T(?:${hour}|${minute}|${second})`",
140
+ " const day = '\\\\d+D'",
141
+ " const month = `\\\\d+M(?:${day})?`",
142
+ " const year = `\\\\d+Y(?:${month})?`",
143
+ " const date = `(?:${year}|${month}|${day})(?:${time})?`",
144
+ " const pattern = new RegExp(`^P(?:${date}|${time}|\\\\d+W)$`)",
145
+ " return (value) => pattern.test(value)",
146
+ "})()"
147
+ ].join("\n"), []),
148
+ uuid: regexFragment("string", "/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/"),
149
+ uri: builtFragment("string", "((re) => (value: string) => re.test(value))(new RegExp(_asciiUri.uri))", [
150
+ "_uriGrammar"
151
+ ]),
152
+ "uri-reference": builtFragment("string", "((re) => (value: string) => re.test(value))(new RegExp(_asciiUri.reference))", ["_uriGrammar"]),
153
+ iri: builtFragment("string", '((re) => (value: string) => re.test(value))(new RegExp(_iriUri.uri, "u"))', [
154
+ "_uriGrammar"
155
+ ]),
156
+ "iri-reference": builtFragment("string", '((re) => (value: string) => re.test(value))(new RegExp(_iriUri.reference, "u"))', ["_uriGrammar"]),
157
+ url: builtFragment("string", "((re) => (value: string) => /^(?:https?|ftp):\\/\\/[^/?#]/.test(value) && re.test(value))(new RegExp(_asciiUri.uri))", ["_uriGrammar"]),
158
+ "uri-template": builtFragment("string", [
159
+ "((): ((value: string) => boolean) => {",
160
+ " const literal = `(?:[!#$&(-;=?-\\\\[\\\\]_a-z~${_ucschar}${_iprivate}]|${_pctEncoded})`",
161
+ " const varchar = `(?:[A-Za-z0-9_]|${_pctEncoded})`",
162
+ " const varname = `${varchar}(?:\\\\.?${varchar})*`",
163
+ " const modifier = '(?::[1-9]\\\\d{0,3}|\\\\*)?'",
164
+ " const varspec = `${varname}${modifier}`",
165
+ " const expression = `\\\\{[+#./;?&=,!@|]?${varspec}(?:,${varspec})*\\\\}`",
166
+ ' const pattern = new RegExp(`^(?:${literal}|${expression})*$`, "iu")',
167
+ " return (value) => pattern.test(value)",
168
+ "})()"
169
+ ].join("\n"), ["_uriGrammar"]),
170
+ "json-pointer": regexFragment("string", "/^(?:\\/(?:[^~/]|~[01])*)*$/"),
171
+ "json-pointer-uri-fragment": regexFragment("string", "/^#(?:\\/(?:[^~/]|~[01])*)*$/"),
172
+ "relative-json-pointer": regexFragment("string", "/^(?:0|[1-9]\\d*)(?:#|(?:\\/(?:[^~/]|~[01])*)*)$/"),
173
+ hostname: builtFragment("string", "((re) => (value: string) => re.test(value))(new RegExp(`^(?=.{1,253}$)${_asciiLabel}(?:\\\\.${_asciiLabel})*$`))", ["_asciiLabel"]),
174
+ "idn-hostname": builtFragment("string", '((re) => (value: string) => re.test(value))(new RegExp(`^(?=.{1,253}$)${_idnLabel}(?:\\\\.${_idnLabel})*$`, "u"))', ["_idnLabel"]),
175
+ ipv4: builtFragment("string", "((re) => (value: string) => re.test(value))(new RegExp(`^${_ipv4Octets}$`))", [
176
+ "_ipv4Octets"
177
+ ]),
178
+ ipv6: builtFragment("string", "((re) => (value: string) => re.test(value))(new RegExp(`^${_ipv6Body}$`))", [
179
+ "_ipv6Body"
180
+ ]),
181
+ regex: {
182
+ family: "string",
183
+ source: (name) => `const ${name} = (value: string): boolean => {
184
+ try {
185
+ new RegExp(value)
186
+ return true
187
+ } catch {
188
+ return false
189
+ }
190
+ }`
191
+ },
192
+ byte: regexFragment("string", "/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/"),
193
+ binary: { family: "string", source: (name) => `const ${name} = (_value: string): boolean => true` },
194
+ password: { family: "string", source: (name) => `const ${name} = (_value: string): boolean => true` },
195
+ int32: {
196
+ family: "number",
197
+ source: (name) => `const ${name} = (value: number): boolean => Number.isInteger(value) && value >= -(2 ** 31) && value <= 2 ** 31 - 1`
198
+ },
199
+ int64: { family: "number", source: (name) => `const ${name} = (value: number): boolean => Number.isInteger(value)` },
200
+ float: { family: "number", source: (name) => `const ${name} = (_value: number): boolean => true` },
201
+ double: { family: "number", source: (name) => `const ${name} = (_value: number): boolean => true` }
202
+ };
203
+ const formatFamily = (format) => Object.hasOwn(FORMAT_FRAGMENTS, format) ? FORMAT_FRAGMENTS[format].family : void 0;
204
+ const emitFormatModule = (formats) => {
205
+ const wanted = [...new Set(formats)].filter((format) => Object.hasOwn(FORMAT_FRAGMENTS, format)).sort();
206
+ if (wanted.length === 0)
207
+ return "";
208
+ const needed = /* @__PURE__ */ new Set();
209
+ const require2 = (helper) => {
210
+ if (needed.has(helper))
211
+ return;
212
+ needed.add(helper);
213
+ for (const nested of SHARED_NEEDS[helper] ?? [])
214
+ require2(nested);
215
+ };
216
+ for (const format of wanted) {
217
+ for (const helper of FORMAT_FRAGMENTS[format].needs ?? [])
218
+ require2(helper);
219
+ }
220
+ const helpers = Object.keys(SHARED).filter((helper) => needed.has(helper));
221
+ const checks = wanted.map((format) => {
222
+ const fragment = FORMAT_FRAGMENTS[format];
223
+ const name = formatCheckName(format);
224
+ return `${fragment.source(name)}
225
+ export { ${name} }`;
226
+ });
227
+ return [
228
+ "/**",
229
+ " * `format` checks for the formats this schema declares.",
230
+ " *",
231
+ " * Generated alongside the validators that call them, so the emitted code",
232
+ " * stays dependency-free. Behaviour matches `@amritk/runtime-validators` run",
233
+ " * with the same formats enabled \u2014 a differential test over the official",
234
+ " * suite's optional/format corpus holds the two together.",
235
+ " */",
236
+ "",
237
+ ...helpers.map((helper) => SHARED[helper]),
238
+ "",
239
+ ...checks,
240
+ ""
241
+ ].join("\n");
242
+ };
243
+ export {
244
+ FORMAT_FRAGMENTS,
245
+ emitFormatModule,
246
+ formatCheckName,
247
+ formatFamily
248
+ };
@@ -1,9 +1,14 @@
1
1
  import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
2
  /**
3
3
  * Every keyword this generator turns into a runtime check. Annotations
4
- * (`title`, `description`, `default`, `$defs`, `format`, …) are deliberately
5
- * absent: they change no verdict, so a node carrying only those is still "just"
6
- * whatever its one validation keyword says.
4
+ * (`title`, `description`, `default`, `$defs`, …) are deliberately absent: they
5
+ * change no verdict, so a node carrying only those is still "just" whatever its
6
+ * one validation keyword says.
7
+ *
8
+ * `format` is the one keyword whose membership is not fixed. It is an annotation
9
+ * by default — the 2020-12 reading, and the interpreter's — and an assertion
10
+ * when the caller asks for formats to be enforced, so every predicate here takes
11
+ * the enforced set rather than reading a constant.
7
12
  */
8
13
  export declare const ENFORCED_KEYWORDS: Set<string>;
9
14
  /**
@@ -18,4 +23,11 @@ export declare const ENFORCED_KEYWORDS: Set<string>;
18
23
  * lets each of them keep its tight output for the node it really does describe,
19
24
  * and hand anything richer to the general path.
20
25
  */
21
- export declare const declaresKeywordOutside: (schema: JSONSchema, owned: readonly string[]) => boolean;
26
+ export declare const declaresKeywordOutside: (schema: JSONSchema, owned: readonly string[], formats?: ReadonlySet<string>) => boolean;
27
+ /** No formats enforced — the default, and what makes `format` an annotation. */
28
+ export declare const NO_FORMATS: ReadonlySet<string>;
29
+ /**
30
+ * Whether this node's `format` is one the caller asked to enforce, making it an
31
+ * assertion rather than an annotation.
32
+ */
33
+ export declare const enforcesFormat: (schema: Record<string, unknown>, formats: ReadonlySet<string>) => boolean;
@@ -43,18 +43,28 @@ const ENFORCED_KEYWORDS = /* @__PURE__ */ new Set([
43
43
  "unevaluatedProperties",
44
44
  "unevaluatedItems"
45
45
  ]);
46
- const declaresKeywordOutside = (schema, owned) => {
46
+ const declaresKeywordOutside = (schema, owned, formats = NO_FORMATS) => {
47
47
  if (!isSchemaObject(schema))
48
48
  return false;
49
- for (const keyword of Object.keys(schema)) {
49
+ const record = schema;
50
+ for (const keyword of Object.keys(record)) {
50
51
  if (owned.includes(keyword))
51
52
  continue;
52
53
  if (ENFORCED_KEYWORDS.has(keyword))
53
54
  return true;
55
+ if (keyword === "format" && enforcesFormat(record, formats))
56
+ return true;
54
57
  }
55
58
  return false;
56
59
  };
60
+ const NO_FORMATS = /* @__PURE__ */ new Set();
61
+ const enforcesFormat = (schema, formats) => {
62
+ const format = schema["format"];
63
+ return typeof format === "string" && formats.has(format);
64
+ };
57
65
  export {
58
66
  ENFORCED_KEYWORDS,
59
- declaresKeywordOutside
67
+ NO_FORMATS,
68
+ declaresKeywordOutside,
69
+ enforcesFormat
60
70
  };
@@ -12,4 +12,4 @@
12
12
  * Hence the conservative `undefined`: this recognises the spellings that are
13
13
  * decidable from the node alone and declines everything else.
14
14
  */
15
- export declare const foldsToConstant: (schema: unknown) => boolean | undefined;
15
+ export declare const foldsToConstant: (schema: unknown, formats?: ReadonlySet<string>) => boolean | undefined;