@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 +13 -6
- package/README.md +36 -16
- package/dist/generators/build-schema.d.ts +2 -2
- package/dist/generators/build-schema.js +23 -3
- package/dist/generators/emit-format-checks.d.ts +50 -0
- package/dist/generators/emit-format-checks.js +248 -0
- package/dist/generators/enforced-keywords.d.ts +16 -4
- package/dist/generators/enforced-keywords.js +13 -3
- package/dist/generators/folds-to-constant.d.ts +1 -1
- package/dist/generators/folds-to-constant.js +6 -2
- package/dist/generators/generate-files.d.ts +7 -0
- package/dist/generators/generate-files.js +10 -2
- package/dist/generators/generate-validator-function.d.ts +2 -2
- package/dist/generators/generate-validator-function.js +103 -75
- package/package.json +4 -4
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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`
|
|
173
|
-
does this generator: `{ type: 'string', format:
|
|
174
|
-
|
|
175
|
-
interpreter
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
**
|
|
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
|
|
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),
|
|
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
|
|
326
|
-
|
|
327
|
-
and `order`, mjst is
|
|
328
|
-
|
|
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
|
|
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
|
|
7
|
-
*
|
|
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`,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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;
|