@amritk/generate-validators 0.11.11 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AI.md CHANGED
@@ -34,5 +34,13 @@ const files = await buildValidatorSchema(schema, 'Document')
34
34
  `ValidationResult`, helpers) plus the `index.ts` barrel.
35
35
  4. **`NaN` satisfies numeric bounds** (`minimum`/`maximum`/`multipleOf`) — differs
36
36
  from Ajv. Draft-07 schemas are auto-upgraded to 2020-12.
37
+ 5. **`format` emits no check.** It stays an annotation, like the interpreter's
38
+ default — but *not* like the interpreter run with `{ formats: 'all' }`
39
+ (`@amritk/lint`, `createApi({ formats })`), which rejects strings a generated
40
+ validator accepts. `unevaluatedProperties`/`unevaluatedItems` *are* generated;
41
+ four shapes still refuse (coverage through a `$dynamicRef`, an unresolvable or
42
+ cyclic `$ref` at the same instance location, a walk deeper than eight
43
+ applicators, a node under `additionalItems`), and generation **throws** for
44
+ those rather than widening the verdict.
37
45
 
38
46
  Only the `.` entry. Install: `bun add @amritk/generate-validators`.
package/README.md CHANGED
@@ -23,6 +23,9 @@ Each generated file exports:
23
23
 
24
24
  - A TypeScript `type` definition for the schema
25
25
  - A `validateFoo(input: unknown, _path?: string): ValidationResult` function
26
+ - An `isFoo(input: unknown): input is Foo` boolean type guard — a single flat
27
+ predicate (no error array, no cold-path call) reaching the same verdict as
28
+ `validateFoo`, for the common "is this valid?" question
26
29
 
27
30
  A shared `validation-result.ts` template and an `index.ts` barrel are emitted alongside the generated files.
28
31
 
@@ -81,15 +84,40 @@ if (!result.valid) {
81
84
 
82
85
  ## API
83
86
 
84
- ### `buildValidatorSchema(rootSchema, rootTypeName)`
87
+ ### `buildValidatorSchema(rootSchema, rootTypeName, typeSuffix?, schemas?)`
85
88
 
86
- | Parameter | Type | Description |
87
- |:---|:---|:---|
88
- | `rootSchema` | `JSONSchema` | The root schema to traverse. `$ref` and `$dynamicRef` are resolved recursively. Draft-07 schemas are upgraded to 2020-12 automatically. |
89
- | `rootTypeName` | `string` | Name used for the root type (e.g. `"Document"`). |
89
+ | Parameter | Type | Default | Description |
90
+ |:---|:---|:---|:---|
91
+ | `rootSchema` | `JSONSchema` | — | The root schema to traverse. `$ref` and `$dynamicRef` are resolved recursively. Draft-07 schemas are upgraded to 2020-12 automatically. |
92
+ | `rootTypeName` | `string` | — | Name used for the root type (e.g. `"Document"`). |
93
+ | `typeSuffix` | `string` | `''` | Suffix appended to every `$ref`-derived type name (`'Object'` turns `Contact` into `ContactObject`). The root type name is unaffected. |
94
+ | `schemas` | `Record<string, unknown>` | — | Documents you have **already loaded**, keyed by the absolute URI a `$ref` names them by. See below. |
90
95
 
91
96
  Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; content: string }`.
92
97
 
98
+ #### Referencing another document
99
+
100
+ A `$ref` to a URI is resolvable once you hand over the document behind it:
101
+
102
+ ```typescript
103
+ const files = await buildValidatorSchema({ $ref: 'https://example.com/user.json' }, 'Document', '', {
104
+ 'https://example.com/user.json': userSchema,
105
+ })
106
+ ```
107
+
108
+ Each registered document becomes a resource of the generated document: its `$id`,
109
+ its `$anchor`s and `$dynamicAnchor`s and its own embedded resources all resolve, a
110
+ `$ref` from one registered document into another resolves, and each definition
111
+ reached gets a file and a type like any other. A document with no `$id` resolves
112
+ its relative `$ref`s against the URI you registered it under; one whose `$id`
113
+ disagrees answers to both.
114
+
115
+ Nothing is fetched — you cannot pass a URL, only a document — so generation stays
116
+ a pure function of its inputs. Loading is yours to do, or
117
+ [`@amritk/resolve-refs`](../resolve-refs)'. Registering more than the schema uses
118
+ costs nothing: only the documents actually reached are emitted. A `$ref` to a URI
119
+ nobody registered still stops the build, with a message naming the ref.
120
+
93
121
  ---
94
122
 
95
123
  ## Semantics
@@ -103,13 +131,81 @@ per-item work (a bare `string[]` is free; a closed object with several fields is
103
131
  meaningfully slower), which is why array-heavy schemas validate more slowly than
104
132
  scalar/object ones.
105
133
 
106
- One divergence is worth calling out: **`NaN` satisfies a constrained number.**
107
- Because the numeric bound checks are the exact negation of the error condition
108
- (e.g. `!(x < minimum)`), and every comparison against `NaN` is `false`, a `NaN`
109
- passes `minimum`/`maximum`/`exclusive*`/`multipleOf`. This matches the interpreter
110
- but differs from validators (e.g. Ajv) that reject `NaN` for `type: "number"`.
111
- `NaN` never appears in parsed JSON; guard against it upstream if your values can
112
- be non-JSON.
134
+ **A schema needs no `type` for its keywords to be enforced.** `{ minLength: 2 }`,
135
+ `{ required: ['a'] }` and `{ uniqueItems: true }` each emit their check behind a
136
+ runtime test for the family they constrain, so they reject a bad string / object /
137
+ array and *ignore* every other kind of value — which is what JSON Schema means by
138
+ a type-less constraint, and what the interpreter does. One consequence is worth
139
+ knowing: object keywords no longer imply `type: 'object'`, so `validateX` accepts
140
+ `42` against `{ properties: { … } }`, while the emitted TypeScript type still
141
+ describes the object case (as `FromSchema` does in `@amritk/runtime-validators`).
142
+ The verdict is the contract and it matches the interpreter exactly; for that one
143
+ shape `isX` is a weaker type guard than the type it names. Declare a `type` — as
144
+ almost every real schema does — and the guard is exact again.
145
+
146
+ **`format` emits no check.** JSON Schema treats `format` as an annotation, and so
147
+ does this generator: `{ type: 'string', format: 'uuid' }` produces the `typeof`
148
+ check and nothing more. That matches the interpreter's default, but *not* the
149
+ interpreter run with `{ formats: 'all' }` — as `@amritk/lint` and
150
+ `createApi({ formats })` do — so a generated validator accepts strings those
151
+ reject.
152
+
153
+ **`unevaluatedProperties` / `unevaluatedItems` are generated**, not refused. Each
154
+ emits a flat expression computing what the interpreter computes as annotations: per
155
+ key or index, a boolean that is true when some keyword evaluated it. Keywords that
156
+ must succeed for the value to be valid at all (`allOf` members, a `$ref` target, a
157
+ satisfied `contains`) count unconditionally — sound, because the test is one
158
+ conjunct of a validator that also asserts them — while conditional applicators
159
+ (`anyOf` / `oneOf` branches, `if` / `then` / `else`, `dependentSchemas`) carry their
160
+ condition, hoisted out of the per-key loop. Four shapes still refuse, each named as
161
+ a shape rather than as a keyword: coverage running through a `$dynamicRef`, an
162
+ unresolvable or cyclic `$ref` at the same instance location, a walk deeper than
163
+ eight applicators, and a node under `additionalItems`.
164
+
165
+ One edge worth calling out: **`NaN` fails a constrained number but satisfies an
166
+ unconstrained one.** Every bound is emitted as the negated *pass* condition
167
+ (`!(x >= minimum)`, not `x < minimum`), and `NaN` compares `false` against every
168
+ operator, so it fails the bound — and `multipleOf` rejects it on both branches of
169
+ the shared `@amritk/helpers/multiple-of-check`. A bare `{ type: 'number' }` with
170
+ no constraint still accepts it, as Ajv does. This matches
171
+ `@amritk/runtime-validators` exactly, and the match is pinned value-by-value in
172
+ `interpreter-parity.test.ts` rather than asserted here. `NaN` never appears in
173
+ parsed JSON, so this only matters for values built in memory.
174
+
175
+ ### Conformance, measured
176
+
177
+ The semantics above are measured, not asserted.
178
+ `src/generators/conformance.test.ts` generates a validator for each schema in the
179
+ official [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite)
180
+ (the required Draft 2020-12 tests — 1281 cases), compiles and links the emitted
181
+ files in memory, and runs the suite's instances through the real generated code:
182
+
183
+ **1271 / 1281 cases pass (99.2%).**
184
+
185
+ The suite's `remotes/` documents and the 2020-12 dialect metaschema are supplied
186
+ through the `schemas` option, which is how the suite intends a validator that does
187
+ no I/O to answer the retrieval step. Everything else — applying the base URIs,
188
+ walking anchors across documents, naming and emitting a file per definition — the
189
+ generator still has to do.
190
+
191
+ Of the 10 that do not pass: five `$dynamicRef`s whose binding depends on the
192
+ evaluation path (a generator emits one function per definition, shared by every
193
+ path that reaches it, so it cannot bind per path), two definitions in different
194
+ embedded resources that reduce to one filename, two `$id`-scoped in-document
195
+ pointers, and `$vocabulary`. Nothing on the list is a keyword that silently
196
+ returns the wrong answer.
197
+
198
+ Every case is named in
199
+ `src/generators/conformance-expected-failures.test-utils.ts` with its reason, and
200
+ the test fails if a case moves in *either* direction — a regression breaks the
201
+ build, and so does a case that starts passing without its entry being removed.
202
+
203
+ If you need one of those refusals to be an answer instead, validate with
204
+ [`@amritk/runtime-validators`](../runtime-validators), which passes the same corpus
205
+ in full (**1281/1281**), at the cost of interpreting the schema at runtime.
206
+ The corpus is vendored under
207
+ [`fixtures/json-schema-test-suite`](../../fixtures/json-schema-test-suite); none
208
+ of it is published.
113
209
 
114
210
  ---
115
211
 
@@ -129,23 +225,30 @@ Measured on Bun 1.3 (Linux x64), validating valid input at steady state:
129
225
 
130
226
  | schema | mjst (generated) | typia (transformed) | ajv (compiled) | typebox (compiled) | zod |
131
227
  |:--|--:|--:|--:|--:|--:|
132
- | small (4 fields) | **~48M** ops/s | ~5M ops/s | ~10.5M ops/s | ~5.3M ops/s | ~2M ops/s |
133
- | order (nested + array) | **~7.8M** ops/s | ~2.2M ops/s | ~3.5M ops/s | ~2.1M ops/s | ~0.5M ops/s |
134
- | assert-loose | **~184M** ops/s | ~183M ops/s | ~45M ops/s | ~63M ops/s | ~3.8M ops/s |
135
- | assert-strict | **~162M** ops/s | ~148M ops/s | ~22M ops/s | ~38M ops/s | ~1.3M ops/s |
228
+ | small (4 fields) | **~49M** ops/s | ~6.4M ops/s | ~11M ops/s | ~5.7M ops/s | ~2.4M ops/s |
229
+ | order (nested + array) | **~11M** ops/s | ~2.5M ops/s | ~4M ops/s | ~2.4M ops/s | ~0.52M ops/s |
230
+ | assert-loose | **~177M** ops/s | ~162M ops/s | ~46M ops/s | ~70M ops/s | ~3.9M ops/s |
231
+ | assert-strict | **~164M** ops/s | ~146M ops/s | ~20M ops/s | ~44M ops/s | ~1.5M ops/s |
136
232
 
137
233
  The `assert-loose` / `assert-strict` rows are the exact shape used by
138
234
  [`moltar/typescript-runtime-type-benchmarks`](https://github.com/moltar/typescript-runtime-type-benchmarks)
139
- (seven scalar roots plus a nested object): the boolean guard puts mjst clearly
140
- ahead of typia on `assert-strict` (with `additionalProperties: false`) and
141
- neck-and-neck with it on `assert-loose` the two trade the lead run-to-run
142
- within noise. (typia and TypeBox still win the *invalid* path, where they bail on
235
+ (seven scalar roots plus a nested object): the boolean guard keeps mjst ahead of
236
+ typia on both, by ~10% on `assert-loose` and ~12% on `assert-strict` (with
237
+ `additionalProperties: false`) close enough that the two can trade the lead
238
+ run-to-run. (typia and TypeBox still win the *invalid* path, where they bail on
143
239
  the first error rather than collecting a full error list.)
144
240
 
145
- Preparing a validator costs ~0.3–0.6 ms for mjst codegen and ~0.05–0.2 ms for a
146
- TypeBox `TypeCompiler` compile, versus ~912 ms for an Ajv compile. Every library
147
- agrees on every verdict; parity is asserted before timing (TypeBox is given
148
- uuid/email format checkers so every library does the same work). Each library is
241
+ Preparing a validator costs ~0.3–0.7 ms for mjst codegen and ~0.04–0.3 ms for a
242
+ TypeBox `TypeCompiler` compile, versus ~711 ms for an Ajv compile. Every library
243
+ agrees on every verdict; parity is asserted before timing.
244
+
245
+ One caveat on the first two rows: their schemas declare `format` (`uuid`,
246
+ `email`), and Ajv, typia, zod, and TypeBox all check it, while mjst's generated
247
+ validators treat it as an annotation (see [Semantics](#semantics)). So on `small`
248
+ and `order`, mjst is doing slightly less work than the columns beside it — the
249
+ parity samples fail other constraints too, which is why the verdicts still agree.
250
+ The `assert-loose` / `assert-strict` rows carry no `format` and are the
251
+ constraint-for-constraint comparison. Each library is
149
252
  timed in an isolated process over a pool of distinct inputs, reporting the median
150
253
  of many trials — so the optimiser can't hoist or eliminate the work and the
151
254
  numbers stay reproducible. Micro-benchmark figures vary by machine and runtime —
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Throws when a schema would make the emitter call a validator that no file
3
+ * defines.
4
+ *
5
+ * The walker refuses an unresolvable `#`/HTTP `$ref` already, so those never get
6
+ * this far. A ref written against an `$id` does: the walker's queue skips it
7
+ * (nothing in the document is keyed by `int.json`), the import collector skips it
8
+ * for the same reason — and the emitter, which only ever looks at the ref
9
+ * *string*, happily writes `validateIntJson(input, _path)` anyway. The result is
10
+ * generated TypeScript that does not compile, which is a loud failure in the
11
+ * wrong place: it lands in the consumer's build, long after the schema that
12
+ * caused it is out of sight.
13
+ *
14
+ * Stopping here instead puts the failure next to its cause and names the ref, the
15
+ * same answer the resolvable-but-unsupported paths already give. Resolving these
16
+ * refs properly means tracking `$id` base URIs through the document, which is a
17
+ * separate feature — until it exists, refusing is the honest report.
18
+ */
19
+ export declare const assertGeneratableRefs: (schema: unknown, typeName: string) => void;
@@ -0,0 +1,12 @@
1
+ import { collectEmittedRefs } from "./collect-emitted-refs.js";
2
+ const isGeneratableRef = (ref) => ref.startsWith("#") || ref.startsWith("http://") || ref.startsWith("https://");
3
+ const assertGeneratableRefs = (schema, typeName) => {
4
+ for (const ref of collectEmittedRefs(schema)) {
5
+ if (isGeneratableRef(ref))
6
+ continue;
7
+ throw new Error(`[${typeName}] unresolvable $ref "${ref}": its target is not in this document, and generation reads no files, so the validator it would call is never emitted. Inline the other document first with @amritk/resolve-refs, rewrite the ref as an in-document pointer (e.g. "#/$defs/name") or an absolute http(s) URI keyed in "$defs", or validate this schema with the runtime interpreter, which takes documents you already have via its \`schemas\` option.`);
8
+ }
9
+ };
10
+ export {
11
+ assertGeneratableRefs
12
+ };
@@ -0,0 +1,30 @@
1
+ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
+ import { type UnevaluatedMatchFn } from './unevaluated-match.js';
3
+ /**
4
+ * The message a node gets when its coverage cannot be worked out inline. Shared
5
+ * with the emitter so the gate and the code path it guards say the same thing.
6
+ */
7
+ export declare const UNPROVABLE_COVERAGE_MESSAGE: (keyword: string) => string;
8
+ /**
9
+ * Throws when a schema uses `unevaluatedProperties` / `unevaluatedItems` in a way
10
+ * the generated code cannot honour.
11
+ *
12
+ * Both keywords police whatever a value's *other* keywords left untouched, which
13
+ * the interpreter answers with annotations gathered during its walk. Generated
14
+ * code has no walk, so `unevaluated-match.ts` reconstructs the same answer as an
15
+ * expression — and where it cannot (a `$ref` that resolves to nothing or back to
16
+ * itself, a `$dynamicRef` whose target is only known at runtime, or a position
17
+ * this generator does not enforce at all), generation stops here.
18
+ *
19
+ * That is deliberately a refusal *per shape* rather than per keyword: the vast
20
+ * majority of `unevaluated*` schemas are expressible and are now generated. What
21
+ * is left is the handful the flat form genuinely cannot see through, and for
22
+ * those a build error is the honest report — far better than a validator that
23
+ * accepts documents the interpreter rejects.
24
+ *
25
+ * `$defs` / `definitions` are skipped: a referenced definition is generated as
26
+ * its own file and gated there, with its own fresh annotation scope (which is
27
+ * also what the spec says a `$ref` target gets), and an unreferenced one is never
28
+ * applied to anything.
29
+ */
30
+ export declare const assertUnevaluatedGeneratable: (schema: JSONSchema, typeName: string, rootSchema: Record<string, unknown> | undefined, match: UnevaluatedMatchFn) => void;
@@ -0,0 +1,72 @@
1
+ import { unevaluatedItemsExpr, unevaluatedPropertiesExpr } from "./unevaluated-match.js";
2
+ const UNPROVABLE_COVERAGE_MESSAGE = (keyword) => `unsupported "${keyword}": working out which keys the schema already evaluated means reading through an applicator this generator cannot follow inline \u2014 an unresolvable or cyclic "$ref", or a "$dynamicRef", whose target is only known at validation time. Validate this schema with \`@amritk/runtime-validators\` instead.`;
3
+ const SINGLE_SUBSCHEMA_KEYS = [
4
+ "additionalProperties",
5
+ "propertyNames",
6
+ "contains",
7
+ "not",
8
+ "if",
9
+ "then",
10
+ "else",
11
+ "unevaluatedProperties",
12
+ "unevaluatedItems"
13
+ ];
14
+ const SUBSCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems"];
15
+ const SUBSCHEMA_MAP_KEYS = ["properties", "patternProperties", "dependentSchemas", "dependencies"];
16
+ const UNENFORCED_SUBSCHEMA_KEYS = ["additionalItems"];
17
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
18
+ const check = (node, typeName, rootSchema, match, enforced) => {
19
+ const record = node;
20
+ for (const keyword of ["unevaluatedProperties", "unevaluatedItems"]) {
21
+ if (!(keyword in record) || record[keyword] === true)
22
+ continue;
23
+ if (!enforced) {
24
+ throw new Error(`[${typeName}] unsupported "${keyword}": it sits under "additionalItems", a subschema position this generator does not enforce, so the check would never run. Validate this schema with \`@amritk/runtime-validators\` instead.`);
25
+ }
26
+ const expression = keyword === "unevaluatedProperties" ? unevaluatedPropertiesExpr("_probe", node, rootSchema, 0, match) : unevaluatedItemsExpr("_probe", node, rootSchema, 0, match);
27
+ if (expression === null)
28
+ throw new Error(`[${typeName}] ${UNPROVABLE_COVERAGE_MESSAGE(keyword)}`);
29
+ }
30
+ };
31
+ const assertUnevaluatedGeneratable = (schema, typeName, rootSchema, match) => {
32
+ const visit = (node, enforced) => {
33
+ if (!isRecord(node))
34
+ return;
35
+ if ("unevaluatedProperties" in node || "unevaluatedItems" in node) {
36
+ check(node, typeName, rootSchema, match, enforced);
37
+ }
38
+ for (const key of SINGLE_SUBSCHEMA_KEYS) {
39
+ if (key in node)
40
+ visit(node[key], enforced);
41
+ }
42
+ const items = node["items"];
43
+ if (Array.isArray(items))
44
+ for (const entry of items)
45
+ visit(entry, enforced);
46
+ else if (items !== void 0)
47
+ visit(items, enforced);
48
+ for (const key of SUBSCHEMA_LIST_KEYS) {
49
+ const list = node[key];
50
+ if (Array.isArray(list))
51
+ for (const entry of list)
52
+ visit(entry, enforced);
53
+ }
54
+ for (const key of SUBSCHEMA_MAP_KEYS) {
55
+ const map = node[key];
56
+ if (!isRecord(map))
57
+ continue;
58
+ for (const entry of Object.values(map))
59
+ if (!Array.isArray(entry))
60
+ visit(entry, enforced);
61
+ }
62
+ for (const key of UNENFORCED_SUBSCHEMA_KEYS) {
63
+ if (key in node)
64
+ visit(node[key], false);
65
+ }
66
+ };
67
+ visit(schema, true);
68
+ };
69
+ export {
70
+ UNPROVABLE_COVERAGE_MESSAGE,
71
+ assertUnevaluatedGeneratable
72
+ };
@@ -20,12 +20,28 @@ export type GeneratedFile = {
20
20
  *
21
21
  * @param rootSchema - The root JSON Schema to build from
22
22
  * @param rootTypeName - The name for the root type (e.g. "Document")
23
+ * @param typeSuffix - Suffix appended to every type name derived from a `$ref`
24
+ * (e.g. `'Object'` → `ContactObject`). Defaults to `''`. The root type name is
25
+ * used verbatim and is not affected.
26
+ * @param schemas - Other schema documents you have **already loaded**, keyed by
27
+ * the absolute URI a `$ref` names them by. Supplying them makes those URIs
28
+ * resolvable, so the schema can reference a document that is not itself — each
29
+ * one becomes a resource of the generated document, with its own `$id`,
30
+ * anchors and nested resources all resolvable. Nothing is fetched here:
31
+ * loading is yours to do (or `@amritk/resolve-refs`'), and a `$ref` to a URI
32
+ * nobody registered still stops generation. Only the documents actually
33
+ * referenced get files.
23
34
  * @returns An array of generated TypeScript files
24
35
  *
25
36
  * @example
26
37
  * ```typescript
27
38
  * const files = await buildValidatorSchema(schema, 'Document')
28
39
  * // files → [{ filename: 'document.ts', content: '...' }, { filename: 'info.ts', ... }, ...]
40
+ *
41
+ * // Referencing a document you loaded yourself:
42
+ * const withRemote = await buildValidatorSchema({ $ref: 'https://example.com/user.json' }, 'Document', '', {
43
+ * 'https://example.com/user.json': userSchema,
44
+ * })
29
45
  * ```
30
46
  */
31
- export declare const buildValidatorSchema: (rootSchema: JSONSchema, rootTypeName: string, typeSuffix?: string) => Promise<GeneratedFile[]>;
47
+ export declare const buildValidatorSchema: (rootSchema: JSONSchema, rootTypeName: string, typeSuffix?: string, schemas?: Readonly<Record<string, unknown>>) => Promise<GeneratedFile[]>;
@@ -75,9 +75,9 @@ export const allUnique = (arr: readonly unknown[]): boolean => {
75
75
  return true
76
76
  }
77
77
  `;
78
- const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "") => {
78
+ const buildValidatorSchema = async (rootSchema, rootTypeName, typeSuffix = "", schemas) => {
79
79
  const files = [];
80
- walkRefGraph(rootSchema, rootTypeName, { typeSuffix }, (node) => {
80
+ walkRefGraph(rootSchema, rootTypeName, { typeSuffix, ...schemas !== void 0 ? { schemas } : {} }, (node) => {
81
81
  if (node.filename === "validation-result" || node.filename === "index")
82
82
  return;
83
83
  const content = generateValidatorFile(node.schema, node.typeName, {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Recursively walks a schema and yields every `$ref` the validator emitter turns
3
+ * into a `validateX(...)` call, in traversal order (duplicates included — the
4
+ * callers dedupe on what they key by).
5
+ *
6
+ * The emitter recurses into far more than properties/items/additionalProperties
7
+ * and the top-level combinators: it also delegates for `patternProperties`,
8
+ * `propertyNames`, `if`/`then`/`else`, `contains`, `prefixItems`,
9
+ * `dependentSchemas`, `not`, the `unevaluated*` subschemas, and objects nested
10
+ * inside any combinator branch. A `$ref` reached by *any* of those paths becomes
11
+ * a call in the output, so both the import collector and the generatability check
12
+ * have to see exactly this set — which is why the traversal lives here rather
13
+ * than in either of them.
14
+ *
15
+ * `$defs` / `definitions` are deliberately skipped: they are split into their own
16
+ * generated files rather than inlined into this one.
17
+ *
18
+ * Pass `rootSchema` to also pick up the refs an `unevaluated*` keyword reaches.
19
+ * Those are the one case where a call can appear in *this* file for a branch
20
+ * written in *another* definition: working out what a node's `$ref` target
21
+ * already evaluated means reading through that target's own `anyOf` / `oneOf` /
22
+ * `if`, and each of those branches is emitted here as a condition. Without the
23
+ * root document there is nothing to resolve the target against, so the extra
24
+ * refs are simply not collected.
25
+ */
26
+ export declare const collectEmittedRefs: (value: unknown, refs?: string[], rootSchema?: Record<string, unknown>) => string[];
@@ -0,0 +1,62 @@
1
+ import { unevaluatedItemsExpr, unevaluatedPropertiesExpr } from "./unevaluated-match.js";
2
+ const collectEmittedRefs = (value, refs = [], rootSchema) => {
3
+ if (typeof value !== "object" || value === null)
4
+ return refs;
5
+ if (Array.isArray(value)) {
6
+ for (const item of value)
7
+ collectEmittedRefs(item, refs, rootSchema);
8
+ return refs;
9
+ }
10
+ const schema = value;
11
+ if (rootSchema !== void 0 && ("unevaluatedProperties" in schema || "unevaluatedItems" in schema)) {
12
+ collectCoverageRefs(schema, refs, rootSchema);
13
+ }
14
+ if (typeof schema["$ref"] === "string") {
15
+ refs.push(schema["$ref"]);
16
+ return refs;
17
+ }
18
+ for (const mapKey of ["properties", "patternProperties", "dependentSchemas", "dependencies"]) {
19
+ const map = schema[mapKey];
20
+ if (typeof map === "object" && map !== null && !Array.isArray(map)) {
21
+ for (const sub of Object.values(map))
22
+ collectEmittedRefs(sub, refs, rootSchema);
23
+ }
24
+ }
25
+ for (const key of [
26
+ "items",
27
+ "additionalProperties",
28
+ "propertyNames",
29
+ "contains",
30
+ "if",
31
+ "then",
32
+ "else",
33
+ "not",
34
+ // The `unevaluated*` subschemas are validated against the leftover keys /
35
+ // indices, so a `$ref` inside one becomes a `validateX(...)` call like any
36
+ // other — and without it the generated file would call an import it never asked for.
37
+ "unevaluatedProperties",
38
+ "unevaluatedItems"
39
+ ]) {
40
+ if (key in schema)
41
+ collectEmittedRefs(schema[key], refs, rootSchema);
42
+ }
43
+ for (const key of ["oneOf", "anyOf", "allOf", "prefixItems"]) {
44
+ const list = schema[key];
45
+ if (Array.isArray(list)) {
46
+ for (const sub of list)
47
+ collectEmittedRefs(sub, refs, rootSchema);
48
+ }
49
+ }
50
+ return refs;
51
+ };
52
+ const collectCoverageRefs = (schema, refs, rootSchema) => {
53
+ const record = (_accessor, sub) => {
54
+ collectEmittedRefs(sub, refs);
55
+ return "true";
56
+ };
57
+ unevaluatedPropertiesExpr("_refs", schema, rootSchema, 0, record);
58
+ unevaluatedItemsExpr("_refs", schema, rootSchema, 0, record);
59
+ };
60
+ export {
61
+ collectEmittedRefs
62
+ };
@@ -1,7 +1,7 @@
1
1
  import { refToFilename } from "@amritk/helpers/ref-to-filename";
2
2
  import { refToName } from "@amritk/helpers/ref-to-name";
3
3
  import { resolveRef } from "@amritk/helpers/resolve-ref";
4
- import { hasRef } from "@amritk/helpers/schema-guards";
4
+ import { collectEmittedRefs } from "./collect-emitted-refs.js";
5
5
  const buildImport = (ref, suffix) => {
6
6
  const filename = refToFilename(ref);
7
7
  const typeName = refToName(ref, suffix);
@@ -12,47 +12,11 @@ const canonicalFilename = (ref) => {
12
12
  const base = ref.endsWith("-or-reference") ? ref.replace("-or-reference", "") : ref;
13
13
  return refToFilename(base);
14
14
  };
15
- const collectDirectRefs = (value, refs = []) => {
16
- if (typeof value !== "object" || value === null)
17
- return refs;
18
- if (Array.isArray(value)) {
19
- for (const item of value)
20
- collectDirectRefs(item, refs);
21
- return refs;
22
- }
23
- const schema = value;
24
- if (hasRef(schema)) {
25
- refs.push(schema.$ref);
26
- return refs;
27
- }
28
- const subSchemaMaps = ["properties", "patternProperties", "dependentSchemas", "dependencies"];
29
- for (const mapKey of subSchemaMaps) {
30
- const map = schema[mapKey];
31
- if (typeof map === "object" && map !== null && !Array.isArray(map)) {
32
- for (const sub of Object.values(map))
33
- collectDirectRefs(sub, refs);
34
- }
35
- }
36
- const singleSubSchemas = ["items", "additionalProperties", "propertyNames", "contains", "if", "then", "else", "not"];
37
- for (const key of singleSubSchemas) {
38
- if (key in schema)
39
- collectDirectRefs(schema[key], refs);
40
- }
41
- const arraySubSchemas = ["oneOf", "anyOf", "allOf", "prefixItems"];
42
- for (const key of arraySubSchemas) {
43
- const arr = schema[key];
44
- if (Array.isArray(arr)) {
45
- for (const sub of arr)
46
- collectDirectRefs(sub, refs);
47
- }
48
- }
49
- return refs;
50
- };
51
15
  const collectValidatorImports = (schema, options) => {
52
16
  const selfFilename = options?.selfRef ? refToFilename(options.selfRef) : null;
53
17
  const rootSchema = options?.rootSchema;
54
18
  const typeSuffix = options?.typeSuffix ?? "";
55
- const refs = collectDirectRefs(schema);
19
+ const refs = collectEmittedRefs(schema, [], rootSchema);
56
20
  const seen = /* @__PURE__ */ new Set();
57
21
  const imports = [];
58
22
  for (const ref of refs) {
@@ -8,8 +8,11 @@ const generateValidatorFile = (schema, typeName, options) => {
8
8
  rootSchema: options?.rootSchema,
9
9
  typeSuffix
10
10
  });
11
- const typeDefinition = generateTypeDefinition(schema, typeName, { typeSuffix });
12
- const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix);
11
+ const typeDefinition = generateTypeDefinition(schema, typeName, {
12
+ typeSuffix,
13
+ ...options?.rootSchema !== void 0 ? { rootSchema: options.rootSchema } : {}
14
+ });
15
+ const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix, options?.rootSchema);
13
16
  const booleanGuard = generateBooleanGuard(schema, typeName, typeSuffix);
14
17
  let result = `import type { ValidationResult, ValidationError } from './validation-result.js'
15
18
  `;
@@ -1,6 +1,6 @@
1
1
  import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
2
2
  /**
3
- * Generates the exported boolean type-guard `isTypeName(input): input is TypeName`.
3
+ * Generates the exported boolean guard `isTypeName`.
4
4
  *
5
5
  * Unlike `validateTypeName` (which returns rich `ValidationResult` errors), this
6
6
  * is a single flat boolean predicate — no error array, no cold-path call — so V8
@@ -8,6 +8,9 @@ import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
8
8
  * compiled checker. It returns the *same verdict* as the validator. When the
9
9
  * schema carries anything the flat form can't mirror exactly, it falls back to
10
10
  * `validateTypeName(input) === true`, which is always correct.
11
+ *
12
+ * The signature is `input is TypeName` whenever that narrowing is sound, and a
13
+ * plain `boolean` when it is not — see {@link typeDescribesEveryAcceptedValue}.
11
14
  */
12
15
  export declare const generateBooleanGuard: (schema: JSONSchema, typeName: string, _suffix?: string) => string;
13
16
  /**
@@ -32,4 +35,4 @@ export declare const generateBooleanGuard: (schema: JSONSchema, typeName: string
32
35
  * // }
33
36
  * ```
34
37
  */
35
- export declare const generateValidatorFunction: (schema: JSONSchema, typeName: string, suffix?: string) => string;
38
+ export declare const generateValidatorFunction: (schema: JSONSchema, typeName: string, suffix?: string, rootSchema?: Record<string, unknown>) => string;