@amritk/generate-parsers 0.17.2 → 0.19.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.
Files changed (32) hide show
  1. package/AI.md +10 -0
  2. package/README.md +147 -19
  3. package/dist/generators/assert-supported-keywords.d.ts +13 -9
  4. package/dist/generators/assert-supported-keywords.js +58 -18
  5. package/dist/generators/build-schema.d.ts +9 -1
  6. package/dist/generators/build-schema.js +5 -2
  7. package/dist/generators/generate-deep-equal-check.d.ts +24 -0
  8. package/dist/generators/generate-deep-equal-check.js +35 -0
  9. package/dist/generators/generate-discriminated-union-validation.js +1 -2
  10. package/dist/generators/generate-enum-check.d.ts +8 -3
  11. package/dist/generators/generate-enum-check.js +8 -6
  12. package/dist/generators/generate-files.d.ts +7 -0
  13. package/dist/generators/generate-files.js +23 -4
  14. package/dist/generators/generate-parser-function.d.ts +1 -1
  15. package/dist/generators/generate-parser-function.js +212 -65
  16. package/dist/generators/generate-schema-checks.js +10 -8
  17. package/dist/generators/generate-strict-assertion.d.ts +89 -5
  18. package/dist/generators/generate-strict-assertion.js +429 -58
  19. package/dist/generators/generate-type-checks.d.ts +46 -10
  20. package/dist/generators/generate-type-checks.js +112 -16
  21. package/dist/generators/generate-unique-items-check.d.ts +16 -0
  22. package/dist/generators/generate-unique-items-check.js +32 -0
  23. package/dist/generators/generate-validation-expression.d.ts +29 -4
  24. package/dist/generators/generate-validation-expression.js +15 -6
  25. package/dist/generators/subschema-match.d.ts +23 -4
  26. package/dist/generators/subschema-match.js +312 -48
  27. package/dist/generators/unevaluated-match.d.ts +19 -0
  28. package/dist/generators/unevaluated-match.js +206 -0
  29. package/dist/helpers/collect-imports.d.ts +8 -0
  30. package/dist/helpers/collect-imports.js +1 -1
  31. package/dist/helpers/get-default-value.js +45 -2
  32. package/package.json +9 -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
@@ -75,19 +75,61 @@ Write them to disk however you like.
75
75
 
76
76
  ## API
77
77
 
78
- ### `buildSchema(rootSchema, rootTypeName, extensions?, typesOnly?, logWarnings?, strict?)`
79
-
80
- | Parameter | Type | Description |
81
- |:---|:---|:---|
82
- | `rootSchema` | `JSONSchema` | The root schema to traverse. `$ref` and `$dynamicRef` are resolved recursively. |
83
- | `rootTypeName` | `string` | Name used for the root type (e.g. `"Document"`). |
84
- | `extensions` | `SchemaExtensions` _(optional)_ | Map of definition name → extra optional properties to merge in before generation. |
85
- | `typesOnly` | `boolean` _(optional)_ | When `true`, only emit `.ts` type definitions — skip parser functions and runtime helpers. |
86
- | `logWarnings` | `boolean` _(optional)_ | When `true`, generated parsers emit a `console.warn` for every input key not declared in the schema's properties. |
87
- | `strict` | `boolean` _(optional)_ | When `true`, generated parsers throw on type/shape mismatches (wrong type, missing required property, enum/pattern/min/max violations) instead of coercing invalid input to default values. |
78
+ ### `buildSchema(rootSchema, rootTypeName, …options)`
79
+
80
+ Every option after the first two is **positional** — pass them in this order:
81
+
82
+ | # | Parameter | Type | Default | Description |
83
+ |--:|:---|:---|:---|:---|
84
+ | 1 | `rootSchema` | `JSONSchema` | — | The root schema to traverse. `$ref` and `$dynamicRef` are resolved recursively. |
85
+ | 2 | `rootTypeName` | `string` | — | Name used for the root type (e.g. `"Document"`). |
86
+ | 3 | `extensions` | `SchemaExtensions` | — | Map of definition name → extra optional properties to merge in before generation. |
87
+ | 4 | `typesOnly` | `boolean` | — | Only emit `.ts` type definitions — skip parser functions and runtime helpers. |
88
+ | 5 | `logWarnings` | `boolean` | — | Generated parsers emit a `console.warn` for every input key not declared in the schema's properties. |
89
+ | 6 | `strict` | `boolean` | — | Generated parsers throw on type/shape mismatches (wrong type, missing required property, enum/pattern/min/max violations) instead of coercing invalid input to default values. |
90
+ | 7 | `helpersMode` | `'package' \| 'embedded'` | `'package'` | `'package'` imports runtime helpers from `@amritk/helpers`; `'embedded'` emits them alongside the output so it is self-contained. |
91
+ | 8 | `helpersImportPrefix` | `string` | `'./'` | Prefix the generated files use when importing embedded helpers. |
92
+ | 9 | `readonly` | `boolean` | `false` | Emit every property, array, and record in the type definitions as `readonly`. |
93
+ | 10 | `stripUnknown` | `boolean` | `false` | Build each result from the declared properties only, dropping undeclared input keys at every level (zod's `.strip()`). |
94
+ | 11 | `typeSuffix` | `string` | `''` | Suffix appended to every `$ref`-derived type name (`'Object'` turns `Contact` into `ContactObject`). The root type name is unaffected. |
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
+ | 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. |
88
98
 
89
99
  Returns: `Promise<GeneratedFile[]>`.
90
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
+
128
+ > [!NOTE]
129
+ > `importExt` defaults to `'js'` here, whereas the [`mjst` CLI](../cli) defaults it to
130
+ > `'ts'`. The example above therefore emits `./info.js` specifiers; pass `'ts'` for
131
+ > output meant to run without a build step.
132
+
91
133
  ---
92
134
 
93
135
  ## Options
@@ -148,12 +190,98 @@ Returns: `Promise<GeneratedFile[]>`.
148
190
 
149
191
  The generator handles:
150
192
 
151
- - `$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: "#"`)
152
194
  - Discriminated and non-discriminated unions (`oneOf` / `anyOf`)
153
195
  - Enums and `const` values
154
- - Nested objects, arrays, records, and tuples
196
+ - Nested objects, arrays, records, and tuples (`prefixItems`, and the draft-07 array form of `items`)
197
+ - Multi-type schemas (`type: ["string", "null"]`) and the OpenAPI 3.0 `nullable: true` spelling
198
+ - Composition alongside a declared shape — `allOf` members and sibling unions are intersected into the type, not dropped
155
199
  - Pattern-based default values
156
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
+
157
285
  ---
158
286
 
159
287
  ## Benchmarks
@@ -173,18 +301,18 @@ x64), parsing valid input at steady state:
173
301
 
174
302
  | case | mode | mjst (generated) | zod | typebox |
175
303
  |:--|:--|--:|--:|--:|
176
- | user (4 fields) | parseSafe | **~16M** ops/s | ~3.3M ops/s | ~1.3M ops/s |
177
- | order (nested + array) | parseSafe | **~5.8M** ops/s | ~0.6M ops/s | ~0.18M ops/s |
178
- | user (4 fields) | parseStrict | **~13M** ops/s | ~1.8M ops/s | ~1.85M ops/s |
179
- | order (nested + array) | parseStrict | **~7.5M** ops/s | ~0.36M ops/s | ~0.28M ops/s |
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 |
180
308
 
181
309
  The upstream `assert` case (seven scalar roots plus a nested object) runs faster
182
- still — tens of millions of ops/s — but at that size the numbers swing enough
310
+ still — ~44M ops/s strict, ~97M safe — but at that size the numbers swing enough
183
311
  run-to-run that the ratio, not the absolute, is the honest signal: mjst lands
184
- ~5–30× ahead of zod across every case above.
312
+ ~6–30× ahead of zod across every case above.
185
313
 
186
314
  The trade is a one-shot **prepare** cost that only mjst pays — generating the
187
- parser source — which measures **~0.1–0.8 ms** per schema here (zod and TypeBox
315
+ parser source — which measures **~0.15–1 ms** per schema here (zod and TypeBox
188
316
  author or interpret their parsers with no separate build step, so there is
189
317
  nothing to time). That is trivially amortized: you generate once at build time
190
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
- * - `unevaluatedProperties` / `unevaluatedItems` with a constraining value
13
- * (`false` or a subschema). The generator has no support for the "which
14
- * properties/items were evaluated" bookkeeping these require; only the
15
- * runtime interpreter does. A `true` value permits everything and is allowed.
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
- * - `contains`, `propertyNames`, or `dependentSchemas` whose subschema uses a
18
- * form {@link canMatchSubschema} cannot prove inline (a `$ref`, a combinator,
19
- * a schema-valued record, …). The parser enforces these keywords only for
20
- * subschemas it can match exactly; anything else would be silently ignored.
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 assertNoUnsupportedKeywords = (schema, typeName) => {
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 ("unevaluatedProperties" in record && record["unevaluatedProperties"] !== true) {
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 (!canMatchSubschema(record["propertyNames"])) {
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 (!canMatchSubschema(sub)) {
55
+ if (!provable(sub)) {
36
56
  fail("dependentSchemas", "cannot prove one of its subschemas inline");
37
57
  }
38
58
  }
39
59
  }
40
- for (const key in record)
41
- visit(record[key]);
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[]>;
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname, resolve as resolvePath } from "node:path";
4
+ import { foldNullable } from "@amritk/helpers/fold-nullable";
4
5
  import { generateIndexBarrel } from "@amritk/helpers/generate-index-barrel";
5
6
  import { walkRefGraph } from "@amritk/helpers/walk-ref-graph";
6
7
  import { applySchemaExtensions } from "../helpers/apply-schema-extensions.js";
@@ -19,14 +20,16 @@ const readHelperSource = async (helper, importExt) => {
19
20
  return { ext: "js", content: compiled };
20
21
  }
21
22
  };
22
- 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) => {
23
24
  const files = [];
24
25
  const usedHelpers = /* @__PURE__ */ new Set();
25
- walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
26
+ const foldedSchema = foldNullable(rootSchema);
27
+ walkRefGraph(foldedSchema, rootTypeName, { typeSuffix, ...schemas !== void 0 ? { schemas } : {} }, (node) => {
26
28
  if (node.filename === "index")
27
29
  return;
28
30
  const extended = extensions ? applySchemaExtensions(node.schema, node.filename, extensions) : node.schema;
29
31
  const result = generateFile(extended, node.typeName, {
32
+ selfFilename: node.filename,
30
33
  typesOnly: typesOnly ?? false,
31
34
  rootSchema: node.rootSchema,
32
35
  helpersMode,
@@ -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
+ };
@@ -1,8 +1,7 @@
1
- import { safeAccessor } from "@amritk/helpers/safe-accessor";
2
1
  import { getDiscriminatorValue } from "../helpers/get-discriminator-value.js";
3
2
  import { generateSchemaChecks } from "./generate-schema-checks.js";
4
3
  const generateDiscriminatedUnionValidation = (accessor, schemas, discriminatorKey, defaultValue, isRequired) => {
5
- const discAccessor = safeAccessor(`${accessor}?`, discriminatorKey);
4
+ const discAccessor = `(${accessor} as Record<string, unknown> | null | undefined)?.[${JSON.stringify(discriminatorKey)}]`;
6
5
  const branches = [];
7
6
  for (const schema of schemas) {
8
7
  const discriminatorValue = getDiscriminatorValue(schema, discriminatorKey);
@@ -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`. It falls back to `.includes` when a member is an
10
- * object/array (reference equality) or `NaN` (where `includes`'s SameValueZero
11
- * differs from `===`), so the verdict is unchanged.
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
- const allPrimitive = enumValues.length > 0 && enumValues.every((v) => (v === null || typeof v !== "object") && typeof v !== "function") && !enumValues.some((v) => typeof v === "number" && Number.isNaN(v));
3
- if (allPrimitive) {
4
- return `(${enumValues.map((v) => `${accessor} === ${JSON.stringify(v)}`).join(" || ")})`;
5
- }
6
- const serializedEnum = JSON.stringify(enumValues);
7
- return `${serializedEnum}.includes(${accessor} as never)`;
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 = [];
@@ -16,6 +16,13 @@ type GenerateFileOptions = {
16
16
  * the import list, preventing a file from importing itself.
17
17
  */
18
18
  readonly selfRef?: string;
19
+ /**
20
+ * The filename this file is written as, without extension. Used for
21
+ * self-import detection when the schema has no `$ref` of its own — the root
22
+ * document — so a `$defs` entry that collides with the root type name cannot
23
+ * make the root file import itself.
24
+ */
25
+ readonly selfFilename?: string;
19
26
  /**
20
27
  * The root schema document. When provided, URI refs that cannot be resolved
21
28
  * within the root schema's $defs are excluded from the import list.
@@ -9,9 +9,20 @@ const generateFile = (schema, typeName, options) => {
9
9
  const helpersMode = options?.helpersMode ?? "package";
10
10
  const typeSuffix = options?.typeSuffix ?? "";
11
11
  const importExt = options?.importExt ?? "js";
12
- const typeDefinition = generateTypeDefinition(schema, typeName, { readonly: options?.readonly ?? false, typeSuffix });
12
+ const typeDefinition = generateTypeDefinition(schema, typeName, {
13
+ readonly: options?.readonly ?? false,
14
+ typeSuffix,
15
+ ...rootSchema !== void 0 ? { rootSchema } : {}
16
+ });
13
17
  if (typesOnly) {
14
- const imports2 = collectImports(schema, { typesOnly: true, selfRef, rootSchema, typeSuffix, importExt });
18
+ const imports2 = collectImports(schema, {
19
+ typesOnly: true,
20
+ selfRef,
21
+ selfFilename: options?.selfFilename,
22
+ rootSchema,
23
+ typeSuffix,
24
+ importExt
25
+ });
15
26
  let result2 = "";
16
27
  if (imports2.length > 0) {
17
28
  result2 += imports2[0];
@@ -23,7 +34,12 @@ const generateFile = (schema, typeName, options) => {
23
34
  return { content: result2 + typeDefinition, usedHelpers: /* @__PURE__ */ new Set() };
24
35
  }
25
36
  const stripUnknown = options?.stripUnknown ?? false;
26
- const reservedNames = collectImportTypeNames(schema, { selfRef, rootSchema, typeSuffix });
37
+ const reservedNames = collectImportTypeNames(schema, {
38
+ selfRef,
39
+ selfFilename: options?.selfFilename,
40
+ rootSchema,
41
+ typeSuffix
42
+ });
27
43
  const shapeValidator = generateShapeValidator(schema, typeName, true, typeSuffix, true, stripUnknown, reservedNames);
28
44
  const parserFunction = generateParserFunction(schema, typeName, {
29
45
  useRefImports: true,
@@ -41,7 +57,10 @@ const generateFile = (schema, typeName, options) => {
41
57
 
42
58
  ${parserFunction}`;
43
59
  const helpers = collectHelpers(combinedFunctions, helpersMode, options?.helpersImportPrefix, importExt);
44
- const imports = [...collectImports(schema, { selfRef, rootSchema, typeSuffix, importExt }), ...helpers.imports];
60
+ const imports = [
61
+ ...collectImports(schema, { selfRef, selfFilename: options?.selfFilename, rootSchema, typeSuffix, importExt }),
62
+ ...helpers.imports
63
+ ];
45
64
  let result = "";
46
65
  if (imports.length > 0) {
47
66
  result += imports[0];
@@ -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, options?: GenerateParserOptions) => 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'