@amritk/generate-parsers 0.18.0 → 0.19.1
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 +10 -0
- package/README.md +120 -8
- package/dist/generators/assert-supported-keywords.d.ts +13 -9
- package/dist/generators/assert-supported-keywords.js +58 -18
- package/dist/generators/build-schema.d.ts +9 -1
- package/dist/generators/build-schema.js +2 -2
- package/dist/generators/generate-deep-equal-check.d.ts +24 -0
- package/dist/generators/generate-deep-equal-check.js +35 -0
- package/dist/generators/generate-enum-check.d.ts +8 -3
- package/dist/generators/generate-enum-check.js +8 -6
- package/dist/generators/generate-parser-function.d.ts +1 -1
- package/dist/generators/generate-parser-function.js +201 -57
- package/dist/generators/generate-schema-checks.js +10 -8
- package/dist/generators/generate-strict-assertion.d.ts +82 -5
- package/dist/generators/generate-strict-assertion.js +381 -53
- package/dist/generators/generate-type-checks.d.ts +31 -10
- package/dist/generators/generate-type-checks.js +65 -16
- package/dist/generators/generate-unique-items-check.d.ts +16 -0
- package/dist/generators/generate-unique-items-check.js +32 -0
- package/dist/generators/generate-validation-expression.d.ts +16 -0
- package/dist/generators/generate-validation-expression.js +10 -4
- package/dist/generators/subschema-match.d.ts +23 -4
- package/dist/generators/subschema-match.js +312 -48
- package/dist/generators/unevaluated-match.d.ts +19 -0
- package/dist/generators/unevaluated-match.js +206 -0
- package/dist/helpers/get-default-value.js +22 -1
- package/package.json +6 -3
package/AI.md
CHANGED
|
@@ -31,6 +31,16 @@ const files = await buildSchema(schema, 'Document')
|
|
|
31
31
|
(unless `typesOnly`) runtime helper files.
|
|
32
32
|
4. **Default parsers COERCE invalid input to defaults** rather than throwing;
|
|
33
33
|
pass `strict: true` (the 6th positional) to make them throw.
|
|
34
|
+
5. **Strict mode enforces the whole Draft 2020-12 assertion vocabulary** —
|
|
35
|
+
including `unevaluatedProperties` / `unevaluatedItems`, `contains` bounds,
|
|
36
|
+
`propertyNames`, `dependent*`, `patternProperties`, tuple `prefixItems`, and a
|
|
37
|
+
`$ref`'s 2020-12 siblings — and is held against Ajv by differential fuzz. It
|
|
38
|
+
deliberately differs on three points (`format` is an annotation, `multipleOf`
|
|
39
|
+
uses a scaled tolerance, a type-less `properties` schema still requires an
|
|
40
|
+
object); see the README.
|
|
41
|
+
6. **Strict generation FAILS LOUDLY on anything it cannot enforce** (e.g. a
|
|
42
|
+
recursive `$ref` it would have to inline when ref imports are off). Catch the
|
|
43
|
+
error and fall back to a coercing parser if that is not what you want.
|
|
34
44
|
|
|
35
45
|
Only the `.` entry (`buildSchema`, `GeneratedFile`, `ImportExtension`).
|
|
36
46
|
Install: `bun add @amritk/generate-parsers`.
|
package/README.md
CHANGED
|
@@ -94,9 +94,37 @@ Every option after the first two is **positional** — pass them in this order:
|
|
|
94
94
|
| 11 | `typeSuffix` | `string` | `''` | Suffix appended to every `$ref`-derived type name (`'Object'` turns `Contact` into `ContactObject`). The root type name is unaffected. |
|
|
95
95
|
| 12 | `importExt` | `'js' \| 'ts'` | `'js'` | Extension emitted on relative import specifiers. `'js'` is the NodeNext form for output you compile; `'ts'` emits the literal on-disk paths so the sources run unbuilt. |
|
|
96
96
|
| 13 | `caseInsensitive` | `boolean` | `false` | Normalize a mis-cased string to the exact casing of an enum/const member it matches case-insensitively. Coerce mode only. |
|
|
97
|
+
| 14 | `schemas` | `Record<string, unknown>` | — | Documents you have **already loaded**, keyed by the absolute URI a `$ref` names them by. See below. |
|
|
97
98
|
|
|
98
99
|
Returns: `Promise<GeneratedFile[]>`.
|
|
99
100
|
|
|
101
|
+
#### Referencing another document
|
|
102
|
+
|
|
103
|
+
A `$ref` to a URI is resolvable once you hand over the document behind it — pass
|
|
104
|
+
it as the 14th argument:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
const files = await buildSchema(
|
|
108
|
+
{ $ref: 'https://example.com/user.json' },
|
|
109
|
+
'Document',
|
|
110
|
+
undefined, false, false, true, 'embedded', './', false, false, '', 'js', false,
|
|
111
|
+
{ 'https://example.com/user.json': userSchema },
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Each registered document becomes a resource of the generated document: its `$id`,
|
|
116
|
+
its `$anchor`s and `$dynamicAnchor`s and its own embedded resources all resolve, a
|
|
117
|
+
`$ref` from one registered document into another resolves, and each definition
|
|
118
|
+
reached gets a file, a type and a parser like any other. A document with no `$id`
|
|
119
|
+
resolves its relative `$ref`s against the URI you registered it under; one whose
|
|
120
|
+
`$id` disagrees answers to both.
|
|
121
|
+
|
|
122
|
+
Nothing is fetched — you cannot pass a URL, only a document — so generation stays
|
|
123
|
+
a pure function of its inputs. Loading is yours to do, or
|
|
124
|
+
[`@amritk/resolve-refs`](../resolve-refs)'. Registering more than the schema uses
|
|
125
|
+
costs nothing: only the documents actually reached are emitted. A `$ref` to a URI
|
|
126
|
+
nobody registered still stops the build, with a message naming the ref.
|
|
127
|
+
|
|
100
128
|
> [!NOTE]
|
|
101
129
|
> `importExt` defaults to `'js'` here, whereas the [`mjst` CLI](../cli) defaults it to
|
|
102
130
|
> `'ts'`. The example above therefore emits `./info.js` specifiers; pass `'ts'` for
|
|
@@ -162,7 +190,7 @@ Returns: `Promise<GeneratedFile[]>`.
|
|
|
162
190
|
|
|
163
191
|
The generator handles:
|
|
164
192
|
|
|
165
|
-
- `$ref` and `$dynamicRef` resolution, including JSON Schema 2020-12 `$dynamicAnchor`
|
|
193
|
+
- `$ref` and `$dynamicRef` resolution, including JSON Schema 2020-12 `$dynamicAnchor` and a recursive root (`$ref: "#"`)
|
|
166
194
|
- Discriminated and non-discriminated unions (`oneOf` / `anyOf`)
|
|
167
195
|
- Enums and `const` values
|
|
168
196
|
- Nested objects, arrays, records, and tuples (`prefixItems`, and the draft-07 array form of `items`)
|
|
@@ -170,6 +198,90 @@ The generator handles:
|
|
|
170
198
|
- Composition alongside a declared shape — `allOf` members and sibling unions are intersected into the type, not dropped
|
|
171
199
|
- Pattern-based default values
|
|
172
200
|
|
|
201
|
+
### Strict-mode validation coverage
|
|
202
|
+
|
|
203
|
+
A `strict` parser throws on any document the schema rejects, and its accept /
|
|
204
|
+
reject decision is held against [Ajv](https://ajv.js.org) (2020-12) by a set of
|
|
205
|
+
differential fuzz suites. The whole Draft 2020-12 assertion vocabulary is
|
|
206
|
+
enforced:
|
|
207
|
+
|
|
208
|
+
| family | keywords |
|
|
209
|
+
|:--|:--|
|
|
210
|
+
| types | `type` (single and array form), `enum`, `const` (compared structurally) |
|
|
211
|
+
| numbers | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` |
|
|
212
|
+
| strings | `minLength`, `maxLength` (counted in Unicode **code points**), `pattern` |
|
|
213
|
+
| arrays | `items`, `prefixItems`, `additionalItems`, `minItems`, `maxItems`, `uniqueItems`, `contains`, `minContains`, `maxContains` |
|
|
214
|
+
| objects | `properties`, `required`, `patternProperties`, `additionalProperties`, `propertyNames`, `minProperties`, `maxProperties`, `dependentRequired`, `dependentSchemas` |
|
|
215
|
+
| composition | `allOf`, `anyOf`, `oneOf`, `not`, `if` / `then` / `else`, `$ref` (with its 2020-12 siblings), boolean schemas |
|
|
216
|
+
| annotations | `unevaluatedProperties`, `unevaluatedItems` — including the keys and indices an `allOf` member, a `$ref` target, a matching `anyOf` / `oneOf` branch, an `if` / `then` / `else` arm, or a triggered `dependentSchemas` entry evaluated |
|
|
217
|
+
|
|
218
|
+
Constraints bind their own family, with or without a declared `type`:
|
|
219
|
+
`{ minimum: 5 }` rejects `4` and accepts `"anything"`, exactly as JSON Schema
|
|
220
|
+
(and Ajv) specify.
|
|
221
|
+
|
|
222
|
+
Three deliberate departures:
|
|
223
|
+
|
|
224
|
+
- **`format` is an annotation**, not an assertion — matching Ajv's own default
|
|
225
|
+
(`ajv-formats` is opt-in there). Use
|
|
226
|
+
[`@amritk/runtime-validators`](../runtime-validators), whose `formats` option
|
|
227
|
+
asserts them.
|
|
228
|
+
- **`multipleOf` compares within a magnitude-scaled tolerance** for a fractional
|
|
229
|
+
divisor, rather than Ajv's exact `x / m` integer test, so `0.3` satisfies
|
|
230
|
+
`multipleOf: 0.1` here and fails there. IEEE-754 makes the exact test reject
|
|
231
|
+
values every author intends as valid. An integer divisor takes the exact `%`
|
|
232
|
+
path, which needs no tolerance. Both branches are emitted from
|
|
233
|
+
`@amritk/helpers/multiple-of-check`, which mirrors the runtime interpreter's
|
|
234
|
+
own check — so parsers, validators, and the interpreter cannot disagree about
|
|
235
|
+
a document.
|
|
236
|
+
- **A type-less schema that declares `properties` still requires an object.**
|
|
237
|
+
Ajv accepts `"a string"` against `{ properties: { a: {} } }`; a parser has to
|
|
238
|
+
return the type it declares, and the declared type is an object.
|
|
239
|
+
|
|
240
|
+
If a strict parser cannot enforce something — a `$ref` cycle it would have to
|
|
241
|
+
inline because the build emits a single file, a subschema using a keyword it
|
|
242
|
+
cannot prove — generation **fails with an error naming the keyword** rather than
|
|
243
|
+
emitting a parser that quietly accepts what the schema forbids. Coercing
|
|
244
|
+
(non-strict) parsers are documented to repair rather than reject, so they ignore
|
|
245
|
+
the rejecting keywords by design.
|
|
246
|
+
|
|
247
|
+
### Conformance, measured
|
|
248
|
+
|
|
249
|
+
The coverage above is not a claim — it is checked against the corpus every
|
|
250
|
+
validator in every language is judged on.
|
|
251
|
+
`src/generators/json-schema-conformance.test.ts` generates a strict parser for
|
|
252
|
+
each schema in the official
|
|
253
|
+
[JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite)
|
|
254
|
+
(the required Draft 2020-12 tests — 1281 cases), links the emitted files in
|
|
255
|
+
memory, and runs the suite's instances through the real generated code:
|
|
256
|
+
|
|
257
|
+
**1240 / 1281 cases pass (96.8%).**
|
|
258
|
+
|
|
259
|
+
A case passes only if the parser throws exactly when the spec says invalid *and*
|
|
260
|
+
returns an accepted document unchanged — strict mode does not coerce, so a parser
|
|
261
|
+
that quietly rewrites a valid document fails here too.
|
|
262
|
+
|
|
263
|
+
The suite's `remotes/` documents and the 2020-12 dialect metaschema are supplied
|
|
264
|
+
through the `schemas` option, which is how the suite intends a generator that does
|
|
265
|
+
no I/O to answer the retrieval step. Everything else — applying the base URIs,
|
|
266
|
+
walking anchors across documents, emitting a parser per definition and an import
|
|
267
|
+
graph that links — the generator still has to do.
|
|
268
|
+
|
|
269
|
+
Of the 41 that do not, **18** are the `ignores a non-object` cases that follow
|
|
270
|
+
from the third departure above, and **12** are a keyword strict mode will not
|
|
271
|
+
approximate (a cyclic `$ref` with siblings, a cyclic `unevaluatedProperties`) —
|
|
272
|
+
those cost a build error naming the cause, never a wrong verdict. The rest: **8**
|
|
273
|
+
`$dynamicRef`s whose binding depends on the evaluation path (a generator emits one
|
|
274
|
+
function per definition, shared by every path that reaches it), **2** embedded
|
|
275
|
+
resources whose definitions reduce to one filename, and one `$vocabulary` case.
|
|
276
|
+
|
|
277
|
+
Every one is listed in
|
|
278
|
+
`src/generators/json-schema-conformance-expected-failures.test-utils.ts` with the
|
|
279
|
+
reason, and the test fails if a case moves in *either* direction — a regression
|
|
280
|
+
breaks the build, and so does a case that starts passing without its entry being
|
|
281
|
+
removed. The corpus is vendored under
|
|
282
|
+
[`fixtures/json-schema-test-suite`](../../fixtures/json-schema-test-suite); none
|
|
283
|
+
of it is published.
|
|
284
|
+
|
|
173
285
|
---
|
|
174
286
|
|
|
175
287
|
## Benchmarks
|
|
@@ -189,18 +301,18 @@ x64), parsing valid input at steady state:
|
|
|
189
301
|
|
|
190
302
|
| case | mode | mjst (generated) | zod | typebox |
|
|
191
303
|
|:--|:--|--:|--:|--:|
|
|
192
|
-
| user (4 fields) | parseSafe | **~
|
|
193
|
-
| order (nested + array) | parseSafe | **~
|
|
194
|
-
| user (4 fields) | parseStrict | **~
|
|
195
|
-
| order (nested + array) | parseStrict | **~
|
|
304
|
+
| user (4 fields) | parseSafe | **~18M** ops/s | ~3M ops/s | ~1.6M ops/s |
|
|
305
|
+
| order (nested + array) | parseSafe | **~7.2M** ops/s | ~0.59M ops/s | ~0.18M ops/s |
|
|
306
|
+
| user (4 fields) | parseStrict | **~15M** ops/s | ~2M ops/s | ~2.05M ops/s |
|
|
307
|
+
| order (nested + array) | parseStrict | **~8.5M** ops/s | ~0.38M ops/s | ~0.29M ops/s |
|
|
196
308
|
|
|
197
309
|
The upstream `assert` case (seven scalar roots plus a nested object) runs faster
|
|
198
|
-
still —
|
|
310
|
+
still — ~44M ops/s strict, ~97M safe — but at that size the numbers swing enough
|
|
199
311
|
run-to-run that the ratio, not the absolute, is the honest signal: mjst lands
|
|
200
|
-
~
|
|
312
|
+
~6–30× ahead of zod across every case above.
|
|
201
313
|
|
|
202
314
|
The trade is a one-shot **prepare** cost that only mjst pays — generating the
|
|
203
|
-
parser source — which measures **~0.1
|
|
315
|
+
parser source — which measures **~0.15–1 ms** per schema here (zod and TypeBox
|
|
204
316
|
author or interpret their parsers with no separate build step, so there is
|
|
205
317
|
nothing to time). That is trivially amortized: you generate once at build time
|
|
206
318
|
and run the emitted code forever.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
2
|
+
import { type StrictAssertionContext } from './generate-strict-assertion.js';
|
|
2
3
|
/**
|
|
3
4
|
* Throws when a schema (anywhere in its subtree) uses a keyword the *strict*
|
|
4
5
|
* parser generator cannot enforce but which narrows the set of valid documents.
|
|
@@ -9,18 +10,21 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
|
|
|
9
10
|
*
|
|
10
11
|
* Two families are rejected:
|
|
11
12
|
*
|
|
12
|
-
* - `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* - `contains`, `not`, `propertyNames`, or `dependentSchemas` whose subschema
|
|
14
|
+
* uses a form {@link canMatchSubschema} cannot prove inline (a cyclic `$ref`,
|
|
15
|
+
* an unknown keyword). The parser enforces these keywords only for subschemas
|
|
16
|
+
* it can match exactly; anything else would be silently ignored.
|
|
16
17
|
*
|
|
17
|
-
* -
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* - A node whose enforcement rests on the whole-schema backstop (see
|
|
19
|
+
* {@link backstopReason} — `unevaluatedProperties` / `unevaluatedItems`, a
|
|
20
|
+
* `$ref` nothing delegates, a type-less constraint) when the matcher cannot
|
|
21
|
+
* build that backstop.
|
|
22
|
+
*
|
|
23
|
+
* Everything the matcher *can* prove is enforced rather than refused, which is
|
|
24
|
+
* why `unevaluated*` and `$ref`-carrying subschemas no longer fail outright.
|
|
21
25
|
*
|
|
22
26
|
* This runs for strict generation only — coercing parsers are documented to be
|
|
23
27
|
* permissive (they repair rather than reject), so ignoring these keywords there
|
|
24
28
|
* is the contract, not a lie.
|
|
25
29
|
*/
|
|
26
|
-
export declare const assertNoUnsupportedKeywords: (schema: JSONSchema, typeName: string) => void;
|
|
30
|
+
export declare const assertNoUnsupportedKeywords: (schema: JSONSchema, typeName: string, context?: StrictAssertionContext) => void;
|
|
@@ -1,29 +1,49 @@
|
|
|
1
1
|
import { isSchemaObject } from "@amritk/helpers/schema-guards";
|
|
2
|
+
import { backstopReason, backstopSchema } from "./generate-strict-assertion.js";
|
|
2
3
|
import { canMatchSubschema } from "./subschema-match.js";
|
|
3
|
-
const
|
|
4
|
+
const SUBSCHEMA_KEYS = [
|
|
5
|
+
"items",
|
|
6
|
+
"prefixItems",
|
|
7
|
+
"additionalItems",
|
|
8
|
+
"contains",
|
|
9
|
+
"additionalProperties",
|
|
10
|
+
"propertyNames",
|
|
11
|
+
"unevaluatedProperties",
|
|
12
|
+
"unevaluatedItems",
|
|
13
|
+
"not",
|
|
14
|
+
"if",
|
|
15
|
+
"then",
|
|
16
|
+
"else",
|
|
17
|
+
"allOf",
|
|
18
|
+
"anyOf",
|
|
19
|
+
"oneOf",
|
|
20
|
+
"contentSchema"
|
|
21
|
+
];
|
|
22
|
+
const SUBSCHEMA_MAP_KEYS = [
|
|
23
|
+
"properties",
|
|
24
|
+
"patternProperties",
|
|
25
|
+
"dependentSchemas",
|
|
26
|
+
"$defs",
|
|
27
|
+
"definitions"
|
|
28
|
+
];
|
|
29
|
+
const assertNoUnsupportedKeywords = (schema, typeName, context = {}) => {
|
|
30
|
+
const rootSchema = context.rootSchema ?? (isSchemaObject(schema) ? schema : void 0);
|
|
31
|
+
const provable = (sub) => canMatchSubschema(sub, rootSchema);
|
|
4
32
|
const fail = (keyword, detail) => {
|
|
5
33
|
throw new Error(`[${typeName}] unsupported keyword "${keyword}": the strict parser generator ${detail}, and would silently accept documents the schema rejects. Generate a coercing (non-strict) parser, validate this schema with @amritk/generate-validators, or remove the keyword.`);
|
|
6
34
|
};
|
|
7
35
|
const visit = (node) => {
|
|
8
|
-
if (typeof node !== "object" || node === null)
|
|
36
|
+
if (typeof node !== "object" || node === null || Array.isArray(node))
|
|
9
37
|
return;
|
|
10
|
-
if (Array.isArray(node)) {
|
|
11
|
-
for (const item of node)
|
|
12
|
-
visit(item);
|
|
13
|
-
return;
|
|
14
|
-
}
|
|
15
38
|
const record = node;
|
|
16
|
-
if ("
|
|
17
|
-
fail("unevaluatedProperties", "does not implement it");
|
|
18
|
-
}
|
|
19
|
-
if ("unevaluatedItems" in record && record["unevaluatedItems"] !== true) {
|
|
20
|
-
fail("unevaluatedItems", "does not implement it");
|
|
21
|
-
}
|
|
22
|
-
if ("contains" in record && !canMatchSubschema(record["contains"])) {
|
|
39
|
+
if ("contains" in record && !provable(record["contains"])) {
|
|
23
40
|
fail("contains", "cannot prove its subschema inline");
|
|
24
41
|
}
|
|
42
|
+
if ("not" in record && !provable(record["not"])) {
|
|
43
|
+
fail("not", "cannot prove its subschema inline");
|
|
44
|
+
}
|
|
25
45
|
if ("propertyNames" in record && isSchemaObject(record["propertyNames"])) {
|
|
26
|
-
if (!
|
|
46
|
+
if (!provable(record["propertyNames"])) {
|
|
27
47
|
fail("propertyNames", "cannot prove its subschema inline");
|
|
28
48
|
}
|
|
29
49
|
}
|
|
@@ -32,13 +52,33 @@ const assertNoUnsupportedKeywords = (schema, typeName) => {
|
|
|
32
52
|
for (const sub of Object.values(dependentSchemas)) {
|
|
33
53
|
if (typeof sub === "boolean")
|
|
34
54
|
continue;
|
|
35
|
-
if (!
|
|
55
|
+
if (!provable(sub)) {
|
|
36
56
|
fail("dependentSchemas", "cannot prove one of its subschemas inline");
|
|
37
57
|
}
|
|
38
58
|
}
|
|
39
59
|
}
|
|
40
|
-
|
|
41
|
-
|
|
60
|
+
const reason = backstopReason(node, context);
|
|
61
|
+
if (reason !== null && !provable(backstopSchema(node, reason))) {
|
|
62
|
+
fail(reason, "cannot prove the schema it appears in inline");
|
|
63
|
+
}
|
|
64
|
+
for (const keyword of SUBSCHEMA_KEYS) {
|
|
65
|
+
const value = record[keyword];
|
|
66
|
+
if (value === void 0)
|
|
67
|
+
continue;
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
for (const item of value)
|
|
70
|
+
visit(item);
|
|
71
|
+
} else {
|
|
72
|
+
visit(value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const keyword of SUBSCHEMA_MAP_KEYS) {
|
|
76
|
+
const value = record[keyword];
|
|
77
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
78
|
+
continue;
|
|
79
|
+
for (const sub of Object.values(value))
|
|
80
|
+
visit(sub);
|
|
81
|
+
}
|
|
42
82
|
};
|
|
43
83
|
visit(schema);
|
|
44
84
|
};
|
|
@@ -59,6 +59,14 @@ export type GeneratedFile = {
|
|
|
59
59
|
* mode only — strict parsers still reject a casing mismatch. The normalization lives on
|
|
60
60
|
* the coercion failure branch, so correctly-cased input keeps the exact-match fast path
|
|
61
61
|
* and the hot path is unaffected.
|
|
62
|
+
* @param schemas - Other schema documents you have **already loaded**, keyed by the absolute
|
|
63
|
+
* URI a `$ref` names them by. Supplying them makes those URIs resolvable, so the schema can
|
|
64
|
+
* reference a document that is not itself — each one becomes a resource of the generated
|
|
65
|
+
* document, with its own `$id`, anchors and nested resources all resolvable, and a `$ref`
|
|
66
|
+
* from one registered document into another resolving too. Nothing is fetched here: loading
|
|
67
|
+
* is yours to do (or `@amritk/resolve-refs`'), and a `$ref` to a URI nobody registered still
|
|
68
|
+
* stops generation. Only the documents actually referenced get files, so registering more
|
|
69
|
+
* than the schema uses costs nothing in the output.
|
|
62
70
|
* @returns An array of generated TypeScript files
|
|
63
71
|
*
|
|
64
72
|
* @example
|
|
@@ -91,4 +99,4 @@ export type GeneratedFile = {
|
|
|
91
99
|
* });
|
|
92
100
|
* ```
|
|
93
101
|
*/
|
|
94
|
-
export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean, stripUnknown?: boolean, typeSuffix?: string, importExt?: ImportExtension, caseInsensitive?: boolean) => Promise<GeneratedFile[]>;
|
|
102
|
+
export declare const buildSchema: (rootSchema: JSONSchema, rootTypeName: string, extensions?: SchemaExtensions, typesOnly?: boolean, logWarnings?: boolean, strict?: boolean, helpersMode?: HelpersMode, helpersImportPrefix?: string, readonly?: boolean, stripUnknown?: boolean, typeSuffix?: string, importExt?: ImportExtension, caseInsensitive?: boolean, schemas?: Readonly<Record<string, unknown>>) => Promise<GeneratedFile[]>;
|
|
@@ -20,11 +20,11 @@ const readHelperSource = async (helper, importExt) => {
|
|
|
20
20
|
return { ext: "js", content: compiled };
|
|
21
21
|
}
|
|
22
22
|
};
|
|
23
|
-
const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = "package", helpersImportPrefix = "./", readonly = false, stripUnknown = false, typeSuffix = "", importExt = "js", caseInsensitive = false) => {
|
|
23
|
+
const buildSchema = async (rootSchema, rootTypeName, extensions, typesOnly, logWarnings, strict, helpersMode = "package", helpersImportPrefix = "./", readonly = false, stripUnknown = false, typeSuffix = "", importExt = "js", caseInsensitive = false, schemas) => {
|
|
24
24
|
const files = [];
|
|
25
25
|
const usedHelpers = /* @__PURE__ */ new Set();
|
|
26
26
|
const foldedSchema = foldNullable(rootSchema);
|
|
27
|
-
walkRefGraph(foldedSchema, rootTypeName, { typeSuffix }, (node) => {
|
|
27
|
+
walkRefGraph(foldedSchema, rootTypeName, { typeSuffix, ...schemas !== void 0 ? { schemas } : {} }, (node) => {
|
|
28
28
|
if (node.filename === "index")
|
|
29
29
|
return;
|
|
30
30
|
const extended = extensions ? applySchemaExtensions(node.schema, node.filename, extensions) : node.schema;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a boolean expression that is TRUE when `accessor` is structurally equal
|
|
3
|
+
* to `value` — a literal known at generation time (an `enum` member, a `const`).
|
|
4
|
+
*
|
|
5
|
+
* Why not `===` / `.includes` / `JSON.stringify`, the three forms this replaced:
|
|
6
|
+
*
|
|
7
|
+
* - `.includes(x)` is SameValueZero, i.e. *reference* equality for objects and
|
|
8
|
+
* arrays. `{ enum: [{ a: 1 }] }` could therefore never match a freshly parsed
|
|
9
|
+
* `{ a: 1 }`, so the strict parser threw on a document Ajv accepts and the fast
|
|
10
|
+
* path never fired.
|
|
11
|
+
* - `JSON.stringify(x) !== '{"a":1,"b":2}'` is key-order sensitive, so a
|
|
12
|
+
* reordered-but-equal `{ b: 2, a: 1 }` was rejected — and it serialized the
|
|
13
|
+
* whole value on every call just to answer a yes/no question.
|
|
14
|
+
*
|
|
15
|
+
* Because the literal is known up front the comparison is unrolled into a flat
|
|
16
|
+
* chain of `typeof` / `===` tests against its shape: no serialization, no
|
|
17
|
+
* allocation, and key order cannot matter because keys are compared by name. The
|
|
18
|
+
* `Object.keys(...).length === n` term is what makes it an equality rather than a
|
|
19
|
+
* "has at least these fields" check.
|
|
20
|
+
*
|
|
21
|
+
* @param accessor - Expression yielding the runtime value (may be `unknown`).
|
|
22
|
+
* @param value - The literal to compare against.
|
|
23
|
+
*/
|
|
24
|
+
export declare const generateDeepEqualCheck: (accessor: string, value: unknown) => string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { safeAccessor } from "@amritk/helpers/safe-accessor";
|
|
2
|
+
const generateDeepEqualCheck = (accessor, value) => {
|
|
3
|
+
if (value === null || typeof value !== "object") {
|
|
4
|
+
if (typeof value === "number" && Number.isNaN(value))
|
|
5
|
+
return `Number.isNaN(${accessor})`;
|
|
6
|
+
return `${accessor} === ${JSON.stringify(value)}`;
|
|
7
|
+
}
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
const elements = `(${accessor} as unknown[])`;
|
|
10
|
+
const parts2 = [`Array.isArray(${accessor})`, `${elements}.length === ${value.length}`];
|
|
11
|
+
for (let i = 0; i < value.length; i++) {
|
|
12
|
+
parts2.push(generateDeepEqualCheck(`${elements}[${i}]`, value[i]));
|
|
13
|
+
}
|
|
14
|
+
return parts2.join(" && ");
|
|
15
|
+
}
|
|
16
|
+
const entries = Object.entries(value);
|
|
17
|
+
const record = `(${accessor} as Record<string, unknown>)`;
|
|
18
|
+
const parts = [
|
|
19
|
+
`typeof ${accessor} === "object"`,
|
|
20
|
+
`${accessor} !== null`,
|
|
21
|
+
`!Array.isArray(${accessor})`,
|
|
22
|
+
// Pins the key count, so an object carrying extra keys is not "equal" to one
|
|
23
|
+
// that only has the declared ones. Combined with the per-key value checks
|
|
24
|
+
// below (an absent key reads as `undefined`, which no JSON literal equals)
|
|
25
|
+
// this is exact structural equality.
|
|
26
|
+
`Object.keys(${accessor} as object).length === ${entries.length}`
|
|
27
|
+
];
|
|
28
|
+
for (const [key, sub] of entries) {
|
|
29
|
+
parts.push(generateDeepEqualCheck(safeAccessor(record, key), sub));
|
|
30
|
+
}
|
|
31
|
+
return parts.join(" && ");
|
|
32
|
+
};
|
|
33
|
+
export {
|
|
34
|
+
generateDeepEqualCheck
|
|
35
|
+
};
|
|
@@ -6,9 +6,14 @@
|
|
|
6
6
|
* b` chain rather than `[a, b].includes(x)`. The `.includes` form allocates a
|
|
7
7
|
* fresh array on every call (this expression runs on the parser hot path); the
|
|
8
8
|
* `===` chain is allocation-free and matches the validators package's
|
|
9
|
-
* `enumMembershipExpr`.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* `enumMembershipExpr`.
|
|
10
|
+
*
|
|
11
|
+
* An object or array member is compared *structurally* via
|
|
12
|
+
* {@link generateDeepEqualCheck}. `.includes` was SameValueZero — reference
|
|
13
|
+
* equality — so `{ enum: [{ a: 1 }] }` could never match a freshly parsed
|
|
14
|
+
* `{ a: 1 }`: the fast path never fired and the strict parser threw on a document
|
|
15
|
+
* Ajv accepts. `NaN` keeps its own `Number.isNaN` test, the one case `===` cannot
|
|
16
|
+
* express.
|
|
12
17
|
*/
|
|
13
18
|
export declare const generateEnumCheck: (accessor: string, enumValues: readonly unknown[]) => string;
|
|
14
19
|
/**
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { generateDeepEqualCheck } from "./generate-deep-equal-check.js";
|
|
1
2
|
const generateEnumCheck = (accessor, enumValues) => {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
if (enumValues.length === 0)
|
|
4
|
+
return "false";
|
|
5
|
+
const members = enumValues.map((value) => {
|
|
6
|
+
const check = generateDeepEqualCheck(accessor, value);
|
|
7
|
+
return value !== null && typeof value === "object" ? `(${check})` : check;
|
|
8
|
+
});
|
|
9
|
+
return `(${members.join(" || ")})`;
|
|
8
10
|
};
|
|
9
11
|
const generateEnumCaseInsensitiveCoercion = (accessor, enumValues, fallback) => {
|
|
10
12
|
const entries = [];
|
|
@@ -89,7 +89,7 @@ type GenerateParserOptions = {
|
|
|
89
89
|
* the resolved schema's validation logic. Array properties whose items are a
|
|
90
90
|
* $ref will map each element through the imported parser.
|
|
91
91
|
*/
|
|
92
|
-
export declare const generateParserFunction: (schema: JSONSchema, typeName: string,
|
|
92
|
+
export declare const generateParserFunction: (schema: JSONSchema, typeName: string, rawOptions?: GenerateParserOptions) => string;
|
|
93
93
|
/**
|
|
94
94
|
* Returns the predicate function name for a generated shape validator.
|
|
95
95
|
* @example shapeValidatorFunctionName('CustomerObject') // 'validateCustomerObjectShape'
|