@zmdb/compiler 1.0.0-beta.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.
Files changed (172) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +40 -0
  3. package/dist/codegen/index.d.ts +50 -0
  4. package/dist/codegen/index.d.ts.map +1 -0
  5. package/dist/codegen/index.js +364 -0
  6. package/dist/codegen/index.js.map +1 -0
  7. package/dist/codegen/scan.d.ts +105 -0
  8. package/dist/codegen/scan.d.ts.map +1 -0
  9. package/dist/codegen/scan.js +450 -0
  10. package/dist/codegen/scan.js.map +1 -0
  11. package/dist/codegen/witness.d.ts +76 -0
  12. package/dist/codegen/witness.d.ts.map +1 -0
  13. package/dist/codegen/witness.js +690 -0
  14. package/dist/codegen/witness.js.map +1 -0
  15. package/dist/config/contract.d.ts +41 -0
  16. package/dist/config/contract.d.ts.map +1 -0
  17. package/dist/config/contract.js +5 -0
  18. package/dist/config/contract.js.map +1 -0
  19. package/dist/config/index.d.ts +53 -0
  20. package/dist/config/index.d.ts.map +1 -0
  21. package/dist/config/index.js +277 -0
  22. package/dist/config/index.js.map +1 -0
  23. package/dist/config/index.zmdb.generated.d.ts +12 -0
  24. package/dist/config/index.zmdb.generated.js +28 -0
  25. package/dist/config/index.zmdb.witness.d.ts +3 -0
  26. package/dist/config/index.zmdb.witness.d.ts.map +1 -0
  27. package/dist/config/index.zmdb.witness.js +20 -0
  28. package/dist/config/index.zmdb.witness.js.map +1 -0
  29. package/dist/configured-plugin.d.ts +11 -0
  30. package/dist/configured-plugin.d.ts.map +1 -0
  31. package/dist/configured-plugin.js +27 -0
  32. package/dist/configured-plugin.js.map +1 -0
  33. package/dist/emit/index.d.ts +103 -0
  34. package/dist/emit/index.d.ts.map +1 -0
  35. package/dist/emit/index.js +1356 -0
  36. package/dist/emit/index.js.map +1 -0
  37. package/dist/errors.d.ts +7 -0
  38. package/dist/errors.d.ts.map +1 -0
  39. package/dist/errors.js +2 -0
  40. package/dist/errors.js.map +1 -0
  41. package/dist/index.d.ts +46 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +254 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/lint/ast.d.ts +17 -0
  46. package/dist/lint/ast.d.ts.map +1 -0
  47. package/dist/lint/ast.js +77 -0
  48. package/dist/lint/ast.js.map +1 -0
  49. package/dist/lint/host-types.d.ts +9 -0
  50. package/dist/lint/host-types.d.ts.map +1 -0
  51. package/dist/lint/host-types.js +2 -0
  52. package/dist/lint/host-types.js.map +1 -0
  53. package/dist/lint/index.d.ts +23 -0
  54. package/dist/lint/index.d.ts.map +1 -0
  55. package/dist/lint/index.js +53 -0
  56. package/dist/lint/index.js.map +1 -0
  57. package/dist/lint/rules/no-distributed-nullable-tags.d.ts +3 -0
  58. package/dist/lint/rules/no-distributed-nullable-tags.d.ts.map +1 -0
  59. package/dist/lint/rules/no-distributed-nullable-tags.js +71 -0
  60. package/dist/lint/rules/no-distributed-nullable-tags.js.map +1 -0
  61. package/dist/lint/rules/no-empty-patch.d.ts +3 -0
  62. package/dist/lint/rules/no-empty-patch.d.ts.map +1 -0
  63. package/dist/lint/rules/no-empty-patch.js +26 -0
  64. package/dist/lint/rules/no-empty-patch.js.map +1 -0
  65. package/dist/lint/rules/no-interpolated-sql.d.ts +3 -0
  66. package/dist/lint/rules/no-interpolated-sql.d.ts.map +1 -0
  67. package/dist/lint/rules/no-interpolated-sql.js +35 -0
  68. package/dist/lint/rules/no-interpolated-sql.js.map +1 -0
  69. package/dist/lint/rules/no-unbounded-find.d.ts +3 -0
  70. package/dist/lint/rules/no-unbounded-find.d.ts.map +1 -0
  71. package/dist/lint/rules/no-unbounded-find.js +26 -0
  72. package/dist/lint/rules/no-unbounded-find.js.map +1 -0
  73. package/dist/lint/rules/no-unknown-json-column.d.ts +3 -0
  74. package/dist/lint/rules/no-unknown-json-column.d.ts.map +1 -0
  75. package/dist/lint/rules/no-unknown-json-column.js +35 -0
  76. package/dist/lint/rules/no-unknown-json-column.js.map +1 -0
  77. package/dist/lint/rules/require-sql-on-number.d.ts +3 -0
  78. package/dist/lint/rules/require-sql-on-number.d.ts.map +1 -0
  79. package/dist/lint/rules/require-sql-on-number.js +34 -0
  80. package/dist/lint/rules/require-sql-on-number.js.map +1 -0
  81. package/dist/lint/types.d.ts +12 -0
  82. package/dist/lint/types.d.ts.map +1 -0
  83. package/dist/lint/types.js +2 -0
  84. package/dist/lint/types.js.map +1 -0
  85. package/dist/metro/metro.d.ts +22 -0
  86. package/dist/metro/metro.d.ts.map +1 -0
  87. package/dist/metro/metro.js +242 -0
  88. package/dist/metro/metro.js.map +1 -0
  89. package/dist/protobuf/decode.d.ts +20 -0
  90. package/dist/protobuf/decode.d.ts.map +1 -0
  91. package/dist/protobuf/decode.js +289 -0
  92. package/dist/protobuf/decode.js.map +1 -0
  93. package/dist/protobuf/descriptor.d.ts +16 -0
  94. package/dist/protobuf/descriptor.d.ts.map +1 -0
  95. package/dist/protobuf/descriptor.js +303 -0
  96. package/dist/protobuf/descriptor.js.map +1 -0
  97. package/dist/protobuf/encode.d.ts +20 -0
  98. package/dist/protobuf/encode.d.ts.map +1 -0
  99. package/dist/protobuf/encode.js +203 -0
  100. package/dist/protobuf/encode.js.map +1 -0
  101. package/dist/protobuf/grpc-ir.d.ts +16 -0
  102. package/dist/protobuf/grpc-ir.d.ts.map +1 -0
  103. package/dist/protobuf/grpc-ir.js +2 -0
  104. package/dist/protobuf/grpc-ir.js.map +1 -0
  105. package/dist/protobuf/plan.d.ts +42 -0
  106. package/dist/protobuf/plan.d.ts.map +1 -0
  107. package/dist/protobuf/plan.js +158 -0
  108. package/dist/protobuf/plan.js.map +1 -0
  109. package/dist/reflect/callsites.d.ts +40 -0
  110. package/dist/reflect/callsites.d.ts.map +1 -0
  111. package/dist/reflect/callsites.js +153 -0
  112. package/dist/reflect/callsites.js.map +1 -0
  113. package/dist/reflect/index.d.ts +106 -0
  114. package/dist/reflect/index.d.ts.map +1 -0
  115. package/dist/reflect/index.js +1421 -0
  116. package/dist/reflect/index.js.map +1 -0
  117. package/dist/reflect/session.d.ts +118 -0
  118. package/dist/reflect/session.d.ts.map +1 -0
  119. package/dist/reflect/session.js +228 -0
  120. package/dist/reflect/session.js.map +1 -0
  121. package/dist/testing/index.d.ts +69 -0
  122. package/dist/testing/index.d.ts.map +1 -0
  123. package/dist/testing/index.js +210 -0
  124. package/dist/testing/index.js.map +1 -0
  125. package/dist/transform/index.d.ts +74 -0
  126. package/dist/transform/index.d.ts.map +1 -0
  127. package/dist/transform/index.js +572 -0
  128. package/dist/transform/index.js.map +1 -0
  129. package/dist/unplugin/index.d.ts +53 -0
  130. package/dist/unplugin/index.d.ts.map +1 -0
  131. package/dist/unplugin/index.js +130 -0
  132. package/dist/unplugin/index.js.map +1 -0
  133. package/dist/unplugin/inline-bench.d.ts +29 -0
  134. package/dist/unplugin/inline-bench.d.ts.map +1 -0
  135. package/dist/unplugin/inline-bench.js +82 -0
  136. package/dist/unplugin/inline-bench.js.map +1 -0
  137. package/package.json +110 -0
  138. package/src/codegen/index.ts +451 -0
  139. package/src/codegen/scan.ts +574 -0
  140. package/src/codegen/witness.ts +819 -0
  141. package/src/config/contract.ts +48 -0
  142. package/src/config/index.ts +384 -0
  143. package/src/config/index.zmdb.generated.d.ts +12 -0
  144. package/src/config/index.zmdb.generated.js +28 -0
  145. package/src/config/index.zmdb.witness.ts +22 -0
  146. package/src/configured-plugin.ts +35 -0
  147. package/src/emit/index.ts +1496 -0
  148. package/src/errors.ts +6 -0
  149. package/src/index.ts +336 -0
  150. package/src/lint/ast.ts +96 -0
  151. package/src/lint/host-types.ts +9 -0
  152. package/src/lint/index.ts +80 -0
  153. package/src/lint/rules/no-distributed-nullable-tags.ts +76 -0
  154. package/src/lint/rules/no-empty-patch.ts +26 -0
  155. package/src/lint/rules/no-interpolated-sql.ts +45 -0
  156. package/src/lint/rules/no-unbounded-find.ts +26 -0
  157. package/src/lint/rules/no-unknown-json-column.ts +36 -0
  158. package/src/lint/rules/require-sql-on-number.ts +34 -0
  159. package/src/lint/types.ts +11 -0
  160. package/src/metro/metro.ts +313 -0
  161. package/src/protobuf/decode.ts +375 -0
  162. package/src/protobuf/descriptor.ts +353 -0
  163. package/src/protobuf/encode.ts +277 -0
  164. package/src/protobuf/grpc-ir.ts +17 -0
  165. package/src/protobuf/plan.ts +244 -0
  166. package/src/reflect/callsites.ts +198 -0
  167. package/src/reflect/index.ts +1723 -0
  168. package/src/reflect/session.ts +276 -0
  169. package/src/testing/index.ts +298 -0
  170. package/src/transform/index.ts +690 -0
  171. package/src/unplugin/index.ts +182 -0
  172. package/src/unplugin/inline-bench.ts +92 -0
@@ -0,0 +1,1496 @@
1
+ // IR → JavaScript. The back-end that makes `is<T>(x)` cost a few `typeof`s.
2
+ //
3
+ // Four targets, one walk each, sharing one naming/hoisting/budget context:
4
+ //
5
+ // check `(v) => boolean` `is<T>`, and the first pass of the rest
6
+ // excess statements that `return false` the strict half of `equals`/`assertEquals`
7
+ // issues `(v, path, out) => void` `assert<T>` / `validate<T>`
8
+ // sample an expression producing a value `random<T>`
9
+ //
10
+ // And two that are not walks, and so are not `EmitTarget`s: `emitJsonSchema` emits a
11
+ // *finished* JSON Schema document for `toJsonSchema<T>()`, and `emitSchemaValue` a
12
+ // finished `CoreSchema` for `schemaOf<T>()`. The other four emit code that runs later and
13
+ // therefore need a walk per target; these two are data, computed here by the very
14
+ // functions — `jsonSchemaFromShape`, `schemaFromIR` — that the value path calls.
15
+ //
16
+ // Three decisions shape all of it.
17
+ //
18
+ // **REQ-AV-7 — no allocation on the success path.** `assert<T>(x)` does not build an
19
+ // issues array and then check whether it is empty. It runs the allocation-free `check`
20
+ // and only walks `issues` once it already knows the value is bad. Valid input therefore
21
+ // allocates nothing at all, and the second walk is paid for exactly where a throw was
22
+ // about to happen anyway.
23
+ //
24
+ // **REQ-AV-4 — the emitted and the runtime paths must agree.** They are two walks, so
25
+ // everything both of them decide — issue text, whether a union has a discriminant —
26
+ // lives in `@zmdb/schema/ir` and is imported by both. The validator runtime's
27
+ // `utilities/index.ts` is the other walk.
28
+ //
29
+ // **Anonymous inlines, named hoists.** A name is the signal that a type may recur or
30
+ // appear twice, and `RefIR` exists because it does. So `is<{ n: number }>(x)` stays a
31
+ // straight-line expression with no call in it, while `is<User>(x)` gets one hoisted
32
+ // `_zmdbCheckUser0` that a `ref` can call. Arrays always hoist: a loop is not an
33
+ // expression, and `.every(cb)` allocates a closure per call.
34
+ //
35
+ // An `unsupported` node is a build error, never a guess (plan D4). The walk records a
36
+ // diagnostic and returns `undefined`, and the transformer leaves that call site alone.
37
+
38
+ import type { ToolProvider } from '@zmdb/ai';
39
+ import { toolSchemaForProvider, ToolSpecRefusalError } from '@zmdb/ai/compiler';
40
+ import {
41
+ discriminantOf,
42
+ expectedForConstraint,
43
+ expectedForDiscriminant,
44
+ expectedOf,
45
+ hasExcessCheck,
46
+ jsonSchemaFromShape,
47
+ schemaFromIR,
48
+ shapeOfVariant,
49
+ type ArrayIR,
50
+ type ConstraintKeyword,
51
+ type Constraints,
52
+ type ObjectIR,
53
+ type ScalarIR,
54
+ type SchemaIR,
55
+ type ShapeIR,
56
+ type TupleIR,
57
+ type TypeIR,
58
+ type UnionIR,
59
+ } from '@zmdb/schema/ir';
60
+ import { validatePatternComplexity } from '@zmdb/validator';
61
+
62
+ import { emitProtoDecoder } from '../protobuf/decode.js';
63
+ import { emitGrpcDescriptor, emitProtoDescriptor } from '../protobuf/descriptor.js';
64
+ import { emitProtoEncoder } from '../protobuf/encode.js';
65
+ import type { GrpcServiceIR } from '../protobuf/grpc-ir.js';
66
+
67
+ /** Sanitise a pattern before it goes between `/` delimiters in emitted source. */
68
+ export function escapePattern(pattern: string): string {
69
+ return pattern
70
+ .replace(/(?<!\\)(?:\\\\)*\//g, match => match.slice(0, -1) + '\\/')
71
+ .replaceAll('\n', '\\n')
72
+ .replaceAll('\r', '\\r')
73
+ .replaceAll('
', '\\u2028')
74
+ .replaceAll('
', '\\u2029');
75
+ }
76
+
77
+ export type EmitTarget = 'check' | 'excess' | 'issues' | 'sample';
78
+
79
+ export interface EmitOptions {
80
+ /** Prefix for every emitted identifier. Default `_zmdb`. */
81
+ readonly prefix?: string;
82
+ /** Cap on hoisted helpers per file. Exceeding it is a refusal, not a hang. */
83
+ readonly maxHelpers?: number;
84
+ /** Module specifier the emitted prelude imports `AssertError` from. */
85
+ readonly errorModule?: string;
86
+ /** Module specifier the emitted prelude imports `ProtoReader`/`ProtoWriter` from. */
87
+ readonly protobufModule?: string;
88
+ }
89
+
90
+ /** A refusal. `path` is the property chain that reached it, as in the reflection. */
91
+ export interface EmitDiagnostic {
92
+ readonly path: string;
93
+ readonly reason: string;
94
+ readonly source?: string;
95
+ }
96
+
97
+ const DEFAULT_MAX_HELPERS = 512;
98
+
99
+ /** Above this many literal members a hoisted `Set` beats a chain of `===`. */
100
+ const MANY_LITERALS = 8;
101
+
102
+ /** An identifier or dotted path is cheap to re-read; anything else is bound once. */
103
+ const SIMPLE_REFERENCE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/;
104
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
105
+ const STRING_LITERAL = /^"(?:[^"\\]|\\.)*"$/;
106
+ const NUMERIC = /^\d+$/;
107
+
108
+ /** An excess walk that is nothing but a delegation, so the delegation can be dropped. */
109
+ const SOLE_GUARD = /^if \(!(\w+)\(_v\)\) return false;$/;
110
+
111
+ /** Absent is the existing full-depth walk; a number is one call site's remaining depth. */
112
+ type EmitDepth = number | undefined;
113
+
114
+ function childDepth(depth: EmitDepth): EmitDepth {
115
+ return depth === undefined ? undefined : Math.max(0, depth - 1);
116
+ }
117
+
118
+ function depthKey(depth: EmitDepth): string {
119
+ return depth === undefined ? '' : `:${depth}`;
120
+ }
121
+
122
+ /**
123
+ * "This is a keyed object." An array is excluded, because the runtime walker has always
124
+ * excluded it, and `is<{}>([])` answering `true` in a built bundle and `false` in dev is
125
+ * exactly the divergence REQ-AV-4 forbids.
126
+ */
127
+ function recordTest(v: string): string {
128
+ return `typeof ${v} === "object" && ${v} !== null && !Array.isArray(${v})`;
129
+ }
130
+
131
+ /** Looser: only enough to make a `for…in` or an indexed loop safe. */
132
+ function keyableTest(v: string): string {
133
+ return `typeof ${v} === "object" && ${v} !== null`;
134
+ }
135
+
136
+ /** How a call site's argument expression is referred to inside emitted code. */
137
+ interface Bound {
138
+ /** The expression to read the value from, safe to repeat. */
139
+ readonly ref: string;
140
+ /** Wrap an expression body so `ref` is in scope. */
141
+ expression(body: string): string;
142
+ /** Wrap a statement body (which `return`s) so `ref` is in scope. */
143
+ block(statements: readonly string[]): string;
144
+ }
145
+
146
+ /** Options for emitting an object's property checks without its own record test. */
147
+ interface ObjectBodyOptions {
148
+ /** A property already established by the caller — a union's discriminant. */
149
+ readonly skip?: string;
150
+ /** Omit the record test, because the caller has already done it. */
151
+ readonly bare?: boolean;
152
+ }
153
+
154
+ export class Emitter {
155
+ readonly #prefix: string;
156
+ readonly #maxHelpers: number;
157
+ readonly #errorModule: string;
158
+ readonly #protobufModule: string;
159
+ readonly #helpers: (string | undefined)[] = [];
160
+ readonly #diagnostics: EmitDiagnostic[] = [];
161
+ /** `target:name` → helper, for the emission in progress. Lets a `ref` resolve. */
162
+ readonly #open = new Map<string, string>();
163
+ /** Named objects reached in this walk, so a shallow `ref` can emit a smaller helper. */
164
+ readonly #definitions = new Map<string, ObjectIR>();
165
+ /** `target:fingerprint` → helper, for the whole file. Two call sites share one. */
166
+ readonly #shared = new Map<string, string>();
167
+ #counter = 0;
168
+ #needsAssertError = false;
169
+ #needsProtoReader = false;
170
+ #needsProtoWriter = false;
171
+ #hasIssueHelper = false;
172
+ #hasFreeze = false;
173
+ #hasIntSample = false;
174
+ #hasStringSample = false;
175
+
176
+ constructor(options: EmitOptions = {}) {
177
+ this.#prefix = options.prefix ?? '_zmdb';
178
+ this.#maxHelpers = options.maxHelpers ?? DEFAULT_MAX_HELPERS;
179
+ this.#errorModule = options.errorModule ?? '@zmdb/validator/errors';
180
+ this.#protobufModule = options.protobufModule ?? '@zmdb/protobuf/wire';
181
+ }
182
+
183
+ get diagnostics(): readonly EmitDiagnostic[] {
184
+ return this.#diagnostics;
185
+ }
186
+
187
+ /** Record a build-time refusal discovered at the call-site boundary. */
188
+ refuse(path: string, reason: string): undefined {
189
+ this.#diagnostics.push({ path, reason });
190
+ return undefined;
191
+ }
192
+
193
+ /** True once anything has been hoisted, so a caller knows a prelude is needed. */
194
+ get hasPrelude(): boolean {
195
+ return this.#needsAssertError || this.#needsProtoReader || this.#needsProtoWriter || this.#helpers.length > 0;
196
+ }
197
+
198
+ /**
199
+ * Everything that has to sit at the top of the module: the `AssertError` import when a
200
+ * throwing form was emitted, then the helpers in definition order.
201
+ */
202
+ prelude(): string {
203
+ const lines: string[] = [];
204
+ if (this.#needsAssertError) {
205
+ lines.push(`import { AssertError as ${this.#prefix}AssertError } from ${JSON.stringify(this.#errorModule)};`);
206
+ }
207
+ if (this.#needsProtoReader) {
208
+ lines.push(`import { ProtoReader as ${this.#prefix}ProtoReader } from ${JSON.stringify(this.#protobufModule)};`);
209
+ }
210
+ if (this.#needsProtoWriter) {
211
+ lines.push(`import { ProtoWriter as ${this.#prefix}ProtoWriter } from ${JSON.stringify(this.#protobufModule)};`);
212
+ }
213
+ for (const helper of this.#helpers) if (helper !== undefined) lines.push(helper);
214
+ return lines.join('\n');
215
+ }
216
+
217
+ // -------------------------------------------------------------------------
218
+ // The call sites
219
+ // -------------------------------------------------------------------------
220
+
221
+ /** `is<T>(expr)` → a boolean expression. */
222
+ emitIs(node: TypeIR, expr: string, maxDepth?: number): string | undefined {
223
+ this.#open.clear();
224
+ const depth = this.#depthFor(node, maxDepth);
225
+ const bound = this.#bind(expr);
226
+ const check = this.#check(node, bound.ref, '', depth);
227
+ if (check === undefined) return undefined;
228
+ return bound.expression(`(${check})`);
229
+ }
230
+
231
+ /** `equals<T>(expr)` → `is<T>` plus a recursive no-excess-keys check. */
232
+ emitEquals(node: TypeIR, expr: string): string | undefined {
233
+ this.#open.clear();
234
+ this.#definitions.clear();
235
+ const bound = this.#bind(expr);
236
+ const check = this.#check(node, bound.ref, '');
237
+ if (check === undefined) return undefined;
238
+ // The excess walk goes through a hoisted helper rather than inline statements so the
239
+ // whole thing stays one expression: `equals<T>(x)` in a condition should not have to
240
+ // pay for an IIFE just to run a second pass.
241
+ const excess = this.#excessHelper(node);
242
+ if (excess === undefined) return undefined;
243
+ if (excess === null) return bound.expression(`(${check})`);
244
+ return bound.expression(`((${check}) && ${excess}(${bound.ref}))`);
245
+ }
246
+
247
+ /** `assert<T>(expr)` / `assertEquals<T>(expr)` → the value, or a throw. */
248
+ emitAssert(node: TypeIR, expr: string, strict: boolean, maxDepth?: number): string | undefined {
249
+ const plan = this.#twoPass(node, expr, strict, maxDepth);
250
+ if (!plan) return undefined;
251
+ this.#needsAssertError = true;
252
+ // The success path is `if (gate) return v` and nothing else: no array, no issue
253
+ // objects, no closure (REQ-AV-7). Everything below the `if` runs once, on the way to
254
+ // a throw that was going to be expensive regardless.
255
+ return plan.bound.block([
256
+ `if (${plan.gate}) return ${plan.bound.ref};`,
257
+ `const _e = []; ${plan.collect}(${plan.bound.ref}, "input", _e);`,
258
+ `throw new ${this.#prefix}AssertError(_e[0] ? _e[0].message : "validation failed", _e);`,
259
+ ]);
260
+ }
261
+
262
+ /** `validate<T>(expr)` → a `ValidateResult<T>`, never a throw. */
263
+ emitValidate(node: TypeIR, expr: string, strict = false, maxDepth?: number): string | undefined {
264
+ const plan = this.#twoPass(node, expr, strict, maxDepth);
265
+ if (!plan) return undefined;
266
+ return plan.bound.block([
267
+ `if (${plan.gate}) return { success: true, data: ${plan.bound.ref} };`,
268
+ `const _e = []; ${plan.collect}(${plan.bound.ref}, "input", _e);`,
269
+ 'return { success: false, errors: _e };',
270
+ ]);
271
+ }
272
+
273
+ /** `random<T>()` → an expression producing a value that satisfies `T`. */
274
+ emitRandom(node: TypeIR): string | undefined {
275
+ this.#open.clear();
276
+ this.#definitions.clear();
277
+ const sample = this.#sample(node, '');
278
+ return sample === undefined ? undefined : `(${sample})`;
279
+ }
280
+
281
+ /**
282
+ * `toJsonSchema<T>()` → a reference to the document, hoisted and frozen (REQ-TF-7).
283
+ *
284
+ * One of the two targets that is not a walk. The other four emit code that runs later;
285
+ * a JSON Schema document is *finished* at build time, so what gets emitted is the
286
+ * answer itself. `jsonSchemaFromShape` is the same function the value path calls, which
287
+ * is what makes the two documents identical rather than merely tested for equality.
288
+ */
289
+ emitJsonSchema(shape: ShapeIR): string | undefined {
290
+ return this.#literal('jsonSchema', 'JsonSchema', jsonSchemaFromShape(shape));
291
+ }
292
+
293
+ /**
294
+ * `schemaOf<T>()` → a reference to the generated schema value, hoisted and frozen
295
+ * (REQ-TF-10).
296
+ *
297
+ * The other half of "one IR, several back-ends": the query compiler and the DDL emitter
298
+ * want the table and the column types as data, so what gets emitted is the projection
299
+ * `schemaFromIR` builds — and that includes the IR itself, on the value's `ir` field.
300
+ * Both are in the literal on purpose. The projection is what the query compiler reads on
301
+ * every call and wants flat; the IR is what the decoders, the wire codecs and the OpenAPI
302
+ * document read, and it says things no column map can hold. Emitting only the projection
303
+ * would mean recovering the rest by inference at runtime, which is the walk this design
304
+ * removed.
305
+ *
306
+ * Relations do not travel with the projection. A `CoreSchema` has no relation map — the
307
+ * repository takes one separately — so a `ManyToOne<…>` on the declaration is read, is
308
+ * not a column, and reaches a consumer only through `ir.relations`.
309
+ */
310
+ emitSchemaValue(ir: SchemaIR): string | undefined {
311
+ return this.#literal('schema', 'Schema', schemaFromIR(ir));
312
+ }
313
+
314
+ /**
315
+ * `toolFor<T>(provider, name, opts)` → the provider's frozen parameter document.
316
+ *
317
+ * The transformer supplies the framing expressions because `name` and `description`
318
+ * belong to the call site. The schema itself is complete here: a pure projection of the
319
+ * reflected table IR, shared with the runtime schema-value overload.
320
+ */
321
+ emitToolSchema(ir: SchemaIR, provider: ToolProvider): string | undefined {
322
+ try {
323
+ const document = toolSchemaForProvider(provider, shapeOfVariant(ir, 'create'));
324
+ return this.#literal('toolSchema', 'ToolSchema', document);
325
+ } catch (error) {
326
+ if (!(error instanceof ToolSpecRefusalError)) throw error;
327
+ const { refusal } = error;
328
+ this.#diagnostics.push({
329
+ path: refusal.path,
330
+ reason: `${refusal.provider} refuses ${refusal.construct}: ${refusal.reason}. ` + refusal.suggestion,
331
+ });
332
+ return undefined;
333
+ }
334
+ }
335
+
336
+ /** `protoDescriptor<T>()` -> one compile-time-produced proto3 string literal. */
337
+ emitProtoDescriptor(node: TypeIR, name: string): string | undefined {
338
+ const result = emitProtoDescriptor(node, name);
339
+ for (const diagnostic of result.diagnostics) this.#diagnostics.push(diagnostic);
340
+ return result.source === undefined ? undefined : JSON.stringify(result.source);
341
+ }
342
+
343
+ /** `grpcDescriptor<S>(service, package)` -> one complete proto3 file. */
344
+ emitGrpcDescriptor(node: GrpcServiceIR, service: string, pkg: string): string | undefined {
345
+ const result = emitGrpcDescriptor(node, service, pkg);
346
+ for (const diagnostic of result.diagnostics) this.#diagnostics.push(diagnostic);
347
+ return result.source === undefined ? undefined : JSON.stringify(result.source);
348
+ }
349
+
350
+ /**
351
+ * `loadGrpcService<S>(service, package)` -> a frozen descriptor plus generated
352
+ * request/response validators and codecs for every method.
353
+ */
354
+ emitGrpcService(node: GrpcServiceIR, service: string, pkg: string): string | undefined {
355
+ const descriptor = this.emitGrpcDescriptor(node, service, pkg);
356
+ if (descriptor === undefined) return undefined;
357
+
358
+ const methods: string[] = [];
359
+ for (const method of node.methods) {
360
+ const validateRequest = this.emitAssert(method.request, 'value', false);
361
+ const serializeRequest = this.emitProtoEncode(method.request, method.requestName, 'value');
362
+ const deserializeRequest = this.emitProtoDecode(method.request, method.requestName, 'bytes');
363
+ const validateResponse = this.emitAssert(method.response, 'value', false);
364
+ const serializeResponse = this.emitProtoEncode(method.response, method.responseName, 'value');
365
+ const deserializeResponse = this.emitProtoDecode(method.response, method.responseName, 'bytes');
366
+ if (
367
+ validateRequest === undefined ||
368
+ serializeRequest === undefined ||
369
+ deserializeRequest === undefined ||
370
+ validateResponse === undefined ||
371
+ serializeResponse === undefined ||
372
+ deserializeResponse === undefined
373
+ ) {
374
+ return undefined;
375
+ }
376
+
377
+ methods.push(
378
+ `${JSON.stringify(method.name)}: { ` +
379
+ `path: ${JSON.stringify(`/${pkg}.${service}/${method.name}`)}, ` +
380
+ `requestStream: ${String(method.requestStream)}, ` +
381
+ `responseStream: ${String(method.responseStream)}, ` +
382
+ `validateRequest: (value) => ${validateRequest}, ` +
383
+ `serializeRequest: (value) => ${serializeRequest}, ` +
384
+ `deserializeRequest: (bytes) => ${deserializeRequest}, ` +
385
+ `validateResponse: (value) => ${validateResponse}, ` +
386
+ `serializeResponse: (value) => ${serializeResponse}, ` +
387
+ `deserializeResponse: (bytes) => ${deserializeResponse} }`,
388
+ );
389
+ }
390
+
391
+ return `${this.#freeze()}({ name: ${JSON.stringify(`${pkg}.${service}`)}, descriptor: ${descriptor}, methods: { ${methods.join(', ')} } })`;
392
+ }
393
+
394
+ /** `protoEncode<T>(expr)` -> a call to a compile-time-produced straight-line encoder. */
395
+ emitProtoEncode(node: TypeIR, name: string, expr: string): string | undefined {
396
+ const fingerprint = `protoEncode:${name}:${JSON.stringify(node)}`;
397
+ const cached = this.#shared.get(fingerprint);
398
+ if (cached !== undefined) return `${cached}(${expr.trim()})`;
399
+
400
+ const result = emitProtoEncoder(node, name, {
401
+ namespace: this.#name('Proto'),
402
+ writer: `${this.#prefix}ProtoWriter`,
403
+ });
404
+ for (const diagnostic of result.diagnostics) this.#diagnostics.push(diagnostic);
405
+ if (result.entry === undefined) return undefined;
406
+ if (!this.#append(result.helpers)) return undefined;
407
+
408
+ this.#needsProtoWriter = true;
409
+ this.#shared.set(fingerprint, result.entry);
410
+ return `${result.entry}(${expr.trim()})`;
411
+ }
412
+
413
+ /** `protoDecode<T>(expr)` -> a field-number-dispatched decoder over a bounded reader. */
414
+ emitProtoDecode(node: TypeIR, name: string, expr: string): string | undefined {
415
+ const fingerprint = `protoDecode:${name}:${JSON.stringify(node)}`;
416
+ const cached = this.#shared.get(fingerprint);
417
+ if (cached !== undefined) return `${cached}(new ${this.#prefix}ProtoReader(${expr.trim()}))`;
418
+
419
+ const result = emitProtoDecoder(node, name, {
420
+ namespace: this.#name('Proto'),
421
+ reader: `${this.#prefix}ProtoReader`,
422
+ });
423
+ for (const diagnostic of result.diagnostics) this.#diagnostics.push(diagnostic);
424
+ if (result.entry === undefined) return undefined;
425
+ if (!this.#append(result.helpers)) return undefined;
426
+
427
+ this.#needsProtoReader = true;
428
+ this.#shared.set(fingerprint, result.entry);
429
+ return `${result.entry}(new ${this.#prefix}ProtoReader(${expr.trim()}))`;
430
+ }
431
+
432
+ /**
433
+ * A finished value, hoisted once and deeply frozen.
434
+ *
435
+ * Both callers compute their answer at build time rather than emitting code that
436
+ * computes it later, so what is left is printing — and the IR is JSON by construction
437
+ * (`ir/index.ts`'s first constraint), which makes `JSON.stringify` the printer.
438
+ *
439
+ * Shared by fingerprint, so ten routes asking for the same document, or four modules
440
+ * asking for the same schema, carry one copy. Which is why it is frozen: the value
441
+ * path hands back a fresh object per call, and a shared literal one consumer could
442
+ * mutate would be visible to the other nine. Frozen, that mistake is a `TypeError` at
443
+ * the assignment instead.
444
+ */
445
+ #literal(kind: string, label: string, value: unknown): string | undefined {
446
+ const printed = JSON.stringify(value);
447
+ const fingerprint = `${kind}:${printed}`;
448
+ const cached = this.#shared.get(fingerprint);
449
+ if (cached !== undefined) return cached;
450
+ const slot = this.#reserve();
451
+ if (slot === undefined) return undefined;
452
+ const name = this.#name(label);
453
+ this.#helpers[slot] = `const ${name} = ${this.#freeze()}(${printed});`;
454
+ this.#shared.set(fingerprint, name);
455
+ return name;
456
+ }
457
+
458
+ /**
459
+ * The name of a hoisted function implementing `target` for `node`, for callers that
460
+ * want the function rather than an inlined call site — the differential suite and the
461
+ * emitted-output snapshots both do.
462
+ */
463
+ helper(node: TypeIR, target: EmitTarget): string | undefined {
464
+ this.#open.clear();
465
+ this.#definitions.clear();
466
+ switch (target) {
467
+ case 'check': {
468
+ const check = this.#check(node, '_v', '');
469
+ return check === undefined ? undefined : this.#function('Check', ['_v'], [`return ${check};`]);
470
+ }
471
+ case 'excess': {
472
+ const guards = this.#excess(node, '_v', '');
473
+ return guards === undefined ? undefined : this.#function('Excess', ['_v'], [...guards, 'return true;']);
474
+ }
475
+ case 'issues':
476
+ return this.#issuesHelper(node);
477
+ case 'sample': {
478
+ const sample = this.#sample(node, '');
479
+ return sample === undefined ? undefined : this.#function('Sample', [], [`return ${sample};`]);
480
+ }
481
+ }
482
+ }
483
+
484
+ // -------------------------------------------------------------------------
485
+ // Plumbing
486
+ // -------------------------------------------------------------------------
487
+
488
+ /**
489
+ * Populate the named-object table and collapse an ineffective finite cap onto the
490
+ * existing full-depth path. That keeps `is<T>` and `isShallow<T, 99>` sharing the
491
+ * same helper when `T` has no constructor below depth 99.
492
+ */
493
+ #depthFor(node: TypeIR, maxDepth: number | undefined): EmitDepth {
494
+ this.#definitions.clear();
495
+ this.#collectDefinitions(node, new Set());
496
+ if (maxDepth === undefined) return undefined;
497
+ const required = Math.max(1, this.#requiredDepth(node, new Set()));
498
+ return maxDepth >= required ? undefined : maxDepth;
499
+ }
500
+
501
+ #collectDefinitions(node: TypeIR, seen: Set<TypeIR>): void {
502
+ if (seen.has(node)) return;
503
+ seen.add(node);
504
+ switch (node.kind) {
505
+ case 'object':
506
+ if (node.name !== undefined) this.#definitions.set(node.name, node);
507
+ for (const property of node.properties) this.#collectDefinitions(property.type, seen);
508
+ return;
509
+ case 'array':
510
+ this.#collectDefinitions(node.element, seen);
511
+ return;
512
+ case 'tuple':
513
+ for (const element of node.elements) this.#collectDefinitions(element, seen);
514
+ return;
515
+ case 'union':
516
+ for (const member of node.members) this.#collectDefinitions(member, seen);
517
+ return;
518
+ default:
519
+ return;
520
+ }
521
+ }
522
+
523
+ /** Smallest finite `D` that emits the same walk as full depth; recursion is infinite. */
524
+ #requiredDepth(node: TypeIR, visiting: ReadonlySet<string>): number {
525
+ switch (node.kind) {
526
+ case 'object': {
527
+ if (node.name !== undefined && visiting.has(node.name)) return Number.POSITIVE_INFINITY;
528
+ const nested = new Set(visiting);
529
+ if (node.name !== undefined) nested.add(node.name);
530
+ let required = 1;
531
+ for (const property of node.properties) {
532
+ required = Math.max(required, 1 + this.#requiredDepth(property.type, nested));
533
+ }
534
+ return required;
535
+ }
536
+ case 'array':
537
+ return 1 + Math.max(1, this.#requiredDepth(node.element, visiting));
538
+ case 'tuple': {
539
+ if (node.elements.length === 0) return 1;
540
+ let required = 0;
541
+ for (const element of node.elements) required = Math.max(required, this.#requiredDepth(element, visiting));
542
+ return 1 + Math.max(1, required);
543
+ }
544
+ case 'union': {
545
+ let required = 0;
546
+ for (const member of node.members) required = Math.max(required, this.#requiredDepth(member, visiting));
547
+ return required;
548
+ }
549
+ case 'ref': {
550
+ const target = this.#definitions.get(node.name);
551
+ return target === undefined ? Number.POSITIVE_INFINITY : this.#requiredDepth(target, visiting);
552
+ }
553
+ default:
554
+ return 0;
555
+ }
556
+ }
557
+
558
+ #refuse(path: string, reason: string, source?: string): undefined {
559
+ this.#diagnostics.push(source === undefined ? { path, reason } : { path, reason, source });
560
+ return undefined;
561
+ }
562
+
563
+ #name(hint: string): string {
564
+ return `${this.#prefix}${hint}${this.#counter++}`;
565
+ }
566
+
567
+ /** Reserve a slot so a recursive walk can name a helper before it exists. */
568
+ #reserve(): number | undefined {
569
+ if (this.#helpers.length >= this.#maxHelpers) {
570
+ return this.#refuse('', `more than ${this.#maxHelpers} emitted helpers in one file`);
571
+ }
572
+ this.#helpers.push(undefined);
573
+ return this.#helpers.length - 1;
574
+ }
575
+
576
+ #function(hint: string, parameters: readonly string[], body: readonly string[]): string | undefined {
577
+ const slot = this.#reserve();
578
+ if (slot === undefined) return undefined;
579
+ const name = this.#name(hint);
580
+ this.#helpers[slot] = `function ${name}(${parameters.join(', ')}) { ${body.join(' ')} }`;
581
+ return name;
582
+ }
583
+
584
+ /** Append a complete external back-end's helpers without weakening the per-file cap. */
585
+ #append(helpers: readonly string[]): boolean {
586
+ if (this.#helpers.length + helpers.length > this.#maxHelpers) {
587
+ this.#refuse('', `more than ${this.#maxHelpers} emitted helpers in one file`);
588
+ return false;
589
+ }
590
+ this.#helpers.push(...helpers);
591
+ return true;
592
+ }
593
+
594
+ #bind(expr: string, captureProperty = false): Bound {
595
+ const trimmed = expr.trim();
596
+ if (SIMPLE_REFERENCE.test(trimmed) && (!captureProperty || IDENTIFIER.test(trimmed))) {
597
+ // Keep simple references inline unless their checks require one captured
598
+ // property value, as bounded numeric predicates do.
599
+ return {
600
+ ref: trimmed,
601
+ expression: body => body,
602
+ block: statements => `((() => { ${statements.join(' ')} })())`,
603
+ };
604
+ }
605
+ // Anything with a call, an index or an operator in it is evaluated exactly once:
606
+ // `assert<T>(next())` must not advance the iterator twice.
607
+ const parameter = this.#name('Arg');
608
+ return {
609
+ ref: parameter,
610
+ expression: body => `((${parameter}) => ${body})(${trimmed})`,
611
+ block: statements => `((${parameter}) => { ${statements.join(' ')} })(${trimmed})`,
612
+ };
613
+ }
614
+
615
+ /**
616
+ * The shared shape of `assert` and `validate`: an allocation-free gate, and the issues
617
+ * walker to run only once the gate has said no.
618
+ */
619
+ #twoPass(
620
+ node: TypeIR,
621
+ expr: string,
622
+ strict: boolean,
623
+ maxDepth?: number,
624
+ ): { readonly bound: Bound; readonly gate: string; readonly collect: string } | undefined {
625
+ this.#open.clear();
626
+ const depth = this.#depthFor(node, maxDepth);
627
+ const bound = this.#bind(expr);
628
+ const check = this.#check(node, bound.ref, '', depth);
629
+ if (check === undefined) return undefined;
630
+
631
+ const excess = strict ? this.#excessHelper(node) : null;
632
+ if (excess === undefined) return undefined;
633
+
634
+ const issues = this.#issuesHelper(node, depth);
635
+ if (issues === undefined) return undefined;
636
+
637
+ const gate = excess === null ? `(${check})` : `((${check}) && ${excess}(${bound.ref}))`;
638
+ if (excess === null) return { bound, gate, collect: issues };
639
+
640
+ // Excess properties are one issue about the value as a whole, and only worth
641
+ // reporting when nothing else was wrong: "you also passed `extra`" is noise next to
642
+ // "`email` is not a string".
643
+ const collect = this.#function(
644
+ 'Strict',
645
+ ['_v', '_p', '_o'],
646
+ [
647
+ `${issues}(_v, _p, _o);`,
648
+ `if (_o.length === 0 && !${excess}(_v)) ${this.#issue('_o', '_p', JSON.stringify('no excess properties'), '_v')}`,
649
+ ],
650
+ );
651
+ return collect === undefined ? undefined : { bound, gate, collect };
652
+ }
653
+
654
+ /** A hoisted `(v) => boolean` excess check, or `null` when the type has none. */
655
+ #excessHelper(node: TypeIR): string | null | undefined {
656
+ const fingerprint = `excessFn:${JSON.stringify(node)}`;
657
+ const cached = this.#shared.get(fingerprint);
658
+ if (cached !== undefined) return cached;
659
+ const guards = this.#excess(node, '_v', '');
660
+ if (guards === undefined) return undefined;
661
+ if (guards.length === 0) return null;
662
+ // A named type's excess walk is already a hoisted function, and wrapping
663
+ // `if (!_zmdbExcessUser1(_v)) return false; return true;` in a second one buys a call
664
+ // per validation and nothing else. Hand back the inner function instead.
665
+ const [only] = guards;
666
+ const inner = guards.length === 1 && only !== undefined ? SOLE_GUARD.exec(only)?.[1] : undefined;
667
+ if (inner !== undefined) {
668
+ this.#shared.set(fingerprint, inner);
669
+ return inner;
670
+ }
671
+ const name = this.#function('Excess', ['_v'], [...guards, 'return true;']);
672
+ if (name === undefined) return undefined;
673
+ this.#shared.set(fingerprint, name);
674
+ return name;
675
+ }
676
+
677
+ #issuesHelper(node: TypeIR, depth?: number): string | undefined {
678
+ const fingerprint = `issuesFn${depthKey(depth)}:${JSON.stringify(node)}`;
679
+ const cached = this.#shared.get(fingerprint);
680
+ if (cached !== undefined) return cached;
681
+ const slot = this.#reserve();
682
+ if (slot === undefined) return undefined;
683
+ const name = this.#name('Issues');
684
+ this.#shared.set(fingerprint, name);
685
+ const statements = this.#issues(node, '_v', '_p', '_o', '', depth);
686
+ if (statements === undefined) {
687
+ this.#shared.delete(fingerprint);
688
+ return undefined;
689
+ }
690
+ this.#helpers[slot] = `function ${name}(_v, _p, _o) { ${statements.join(' ')} }`;
691
+ return name;
692
+ }
693
+
694
+ /**
695
+ * The deep-freeze used by `emitJsonSchema`, hoisted once per file.
696
+ *
697
+ * A helper rather than nested `Object.freeze(…)` calls inside the literal: the point of
698
+ * emitting a document is that the emitted source reads as the document, and wrapping
699
+ * every nested object in a call buries it. A recursive walk over a schema document at
700
+ * module load is not a cost worth optimising.
701
+ */
702
+ #freeze(): string {
703
+ const name = `${this.#prefix}Freeze`;
704
+ if (!this.#hasFreeze) {
705
+ this.#hasFreeze = true;
706
+ this.#helpers.push(
707
+ `function ${name}(_v) { if (_v !== null && typeof _v === "object") { for (const _k of Object.keys(_v)) ${name}(_v[_k]); Object.freeze(_v); } return _v; }`,
708
+ );
709
+ }
710
+ return name;
711
+ }
712
+
713
+ /** The one-liner that records a failure. Hoisted once per file. */
714
+ #issue(out: string, path: string, expected: string, value: string): string {
715
+ if (!this.#hasIssueHelper) {
716
+ this.#hasIssueHelper = true;
717
+ this.#helpers.push(
718
+ `function ${this.#prefix}Issue(out, path, expected, value) { out.push({ path, expected, value, message: "expected " + expected }); }`,
719
+ );
720
+ }
721
+ return `${this.#prefix}Issue(${out}, ${path}, ${expected}, ${value});`;
722
+ }
723
+
724
+ // -------------------------------------------------------------------------
725
+ // Target: check
726
+ // -------------------------------------------------------------------------
727
+
728
+ #check(node: TypeIR, v: string, path: string, depth?: number): string | undefined {
729
+ switch (node.kind) {
730
+ case 'unsupported':
731
+ return this.#refuse(path, node.reason, node.source);
732
+ case 'unknown':
733
+ return 'true';
734
+ case 'null':
735
+ return `${v} === null`;
736
+ case 'undefined':
737
+ return `${v} === undefined`;
738
+ case 'literal':
739
+ return `${v} === ${JSON.stringify(node.value)}`;
740
+ case 'scalar':
741
+ return this.#scalarCheck(node, v, path);
742
+ case 'union':
743
+ return this.#unionCheck(node, v, path, depth);
744
+ case 'tuple':
745
+ return this.#tupleCheck(node, v, path, depth);
746
+ case 'array':
747
+ return this.#arrayCheck(node, v, path, depth);
748
+ case 'object':
749
+ return this.#objectCheck(node, v, path, depth);
750
+ case 'ref': {
751
+ if (depth !== undefined) {
752
+ const target = this.#definitions.get(node.name);
753
+ if (target === undefined) {
754
+ return this.#refuse(path, `a back-reference to \`${node.name}\`, which was never declared`);
755
+ }
756
+ return this.#objectCheck(target, v, path, depth);
757
+ }
758
+ const helper = this.#open.get(`check:${node.name}`);
759
+ if (helper === undefined) {
760
+ return this.#refuse(path, `a back-reference to \`${node.name}\`, which was never declared`);
761
+ }
762
+ return `${helper}(${v})`;
763
+ }
764
+ }
765
+ }
766
+
767
+ #scalarCheck(node: ScalarIR, v: string, path: string): string | undefined {
768
+ const boundedNumber =
769
+ node.scalar === 'number' && (node.constraints?.minimum !== undefined || node.constraints?.maximum !== undefined);
770
+ const bound = boundedNumber ? this.#bind(v, true) : undefined;
771
+ const value = bound?.ref ?? v;
772
+ const constraints = this.#constraintChecks(node.constraints, value, node.scalar === 'string', path);
773
+ if (constraints === undefined) return undefined;
774
+ // A numeric comparison rejects NaN itself, provided every check reads the
775
+ // same captured value. Unbounded numbers still need their explicit guard.
776
+ const base = boundedNumber ? `typeof ${value} === "number"` : scalarBase(node.scalar, value);
777
+ const check = [base, ...constraints].join(' && ');
778
+ return bound?.expression(check) ?? check;
779
+ }
780
+
781
+ /** `length`-based bounds read a `.length`; numeric ones compare the value itself. */
782
+ #constraintChecks(
783
+ constraints: Constraints | undefined,
784
+ v: string,
785
+ lengthy: boolean,
786
+ path: string,
787
+ ): string[] | undefined {
788
+ const parts: string[] = [];
789
+ if (!constraints) return parts;
790
+ if (constraints.minimum !== undefined) parts.push(`${v} >= ${constraints.minimum}`);
791
+ if (constraints.maximum !== undefined) parts.push(`${v} <= ${constraints.maximum}`);
792
+ if (constraints.minLength !== undefined) parts.push(`${v}.length >= ${constraints.minLength}`);
793
+ if (constraints.maxLength !== undefined) parts.push(`${v}.length <= ${constraints.maxLength}`);
794
+ if (constraints.pattern !== undefined) {
795
+ if (!lengthy) return this.#refuse(path, 'a `pattern` constraint on something that is not a string');
796
+ // Validated here rather than trusted: an unparseable pattern would otherwise
797
+ // become a syntax error in the emitted module, which is a far worse message.
798
+ validatePatternComplexity(constraints.pattern);
799
+ parts.push(`/${escapePattern(constraints.pattern)}/.test(${v})`);
800
+ }
801
+ return parts;
802
+ }
803
+
804
+ #unionCheck(node: UnionIR, v: string, path: string, depth?: number): string | undefined {
805
+ if (node.members.length === 0) return this.#refuse(path, 'an empty union matches nothing');
806
+
807
+ if (node.members.length > MANY_LITERALS && node.members.every(member => member.kind === 'literal')) {
808
+ // A long enum is a set lookup, not a chain of `===`. Below the cutoff the chain is
809
+ // faster and reads better; above it, the `Set` wins.
810
+ //
811
+ // boundary: `every` above proved each member is a `literal`, but it returns a boolean
812
+ // and the narrowing does not reach this `map`. The alternative is testing `kind` again
813
+ // inside the map for a branch the condition has ruled out.
814
+ const values = node.members.map(member => JSON.stringify((member as { value: unknown }).value));
815
+ const name = this.#name('Set');
816
+ this.#helpers.push(`const ${name} = new Set([${values.join(', ')}]);`);
817
+ return `${name}.has(${v})`;
818
+ }
819
+
820
+ const discriminant = discriminantOf(node.members);
821
+ if (discriminant) {
822
+ const arms: string[] = [];
823
+ for (const arm of discriminant.arms) {
824
+ const body = this.#objectBody(arm.node, v, path, { skip: discriminant.key, bare: true }, depth);
825
+ if (body === undefined) return undefined;
826
+ arms.push(`${v}${accessor(discriminant.key)} === ${JSON.stringify(arm.value)} ? (${body})`);
827
+ }
828
+ return `(${recordTest(v)} && (${arms.join(' : ')} : false))`;
829
+ }
830
+
831
+ const parts: string[] = [];
832
+ for (const [index, member] of node.members.entries()) {
833
+ const check = this.#check(member, v, `${path}|${index}`, depth);
834
+ if (check === undefined) return undefined;
835
+ parts.push(`(${check})`);
836
+ }
837
+ return `(${parts.join(' || ')})`;
838
+ }
839
+
840
+ #tupleCheck(node: TupleIR, v: string, path: string, depth?: number): string | undefined {
841
+ const parts = [`Array.isArray(${v})`, `${v}.length === ${node.elements.length}`];
842
+ if (depth !== undefined && depth <= 1) return parts.join(' && ');
843
+ const nestedDepth = childDepth(depth);
844
+ for (const [index, element] of node.elements.entries()) {
845
+ const check = this.#check(element, `${v}[${index}]`, `${path}[${index}]`, nestedDepth);
846
+ if (check === undefined) return undefined;
847
+ if (check !== 'true') parts.push(`(${check})`);
848
+ }
849
+ return parts.join(' && ');
850
+ }
851
+
852
+ #arrayCheck(node: ArrayIR, v: string, path: string, depth?: number): string | undefined {
853
+ if (depth !== undefined && depth <= 1) {
854
+ const bounds = this.#constraintChecks(node.constraints, v, true, path);
855
+ if (bounds === undefined) return undefined;
856
+ return [`Array.isArray(${v})`, ...bounds].join(' && ');
857
+ }
858
+
859
+ const fingerprint = `checkArray${depthKey(depth)}:${JSON.stringify(node)}`;
860
+ const cached = this.#shared.get(fingerprint);
861
+ if (cached !== undefined) return `${cached}(${v})`;
862
+
863
+ const slot = this.#reserve();
864
+ if (slot === undefined) return undefined;
865
+ const name = this.#name('CheckArray');
866
+ this.#shared.set(fingerprint, name);
867
+
868
+ const element = this.#check(node.element, '_v[_i]', `${path}[]`, childDepth(depth));
869
+ if (element === undefined) {
870
+ this.#shared.delete(fingerprint);
871
+ return undefined;
872
+ }
873
+ const helperBounds = this.#constraintChecks(node.constraints, '_v', true, path);
874
+ if (helperBounds === undefined) return undefined;
875
+
876
+ const body = [`if (!Array.isArray(_v)) return false;`];
877
+ for (const bound of helperBounds) body.push(`if (!(${bound})) return false;`);
878
+ if (element !== 'true') {
879
+ body.push(`for (let _i = 0; _i < _v.length; _i++) { if (!(${element})) return false; }`);
880
+ }
881
+ body.push('return true;');
882
+ this.#helpers[slot] = `function ${name}(_v) { ${body.join(' ')} }`;
883
+ return `${name}(${v})`;
884
+ }
885
+
886
+ #objectCheck(node: ObjectIR, v: string, path: string, depth?: number): string | undefined {
887
+ if (node.name !== undefined) this.#definitions.set(node.name, node);
888
+ if (depth === 0) return recordTest(v);
889
+ if (node.name === undefined) return this.#objectBody(node, v, path, {}, depth);
890
+
891
+ const openKey = `check${depthKey(depth)}:${node.name}`;
892
+ const open = this.#open.get(openKey);
893
+ if (open !== undefined) return `${open}(${v})`;
894
+
895
+ const fingerprint = `check${depthKey(depth)}:${JSON.stringify(node)}`;
896
+ const cached = this.#shared.get(fingerprint);
897
+ if (cached !== undefined) return `${cached}(${v})`;
898
+
899
+ const slot = this.#reserve();
900
+ if (slot === undefined) return undefined;
901
+ const name = this.#name(`Check${capitalise(node.name)}`);
902
+ this.#open.set(openKey, name);
903
+ this.#shared.set(fingerprint, name);
904
+ const body = this.#objectBody(node, '_v', path, {}, depth);
905
+ if (body === undefined) return undefined;
906
+ this.#helpers[slot] = `function ${name}(_v) { return ${body}; }`;
907
+ return `${name}(${v})`;
908
+ }
909
+
910
+ #objectBody(node: ObjectIR, v: string, path: string, options: ObjectBodyOptions, depth?: number): string | undefined {
911
+ if (node.name !== undefined) this.#definitions.set(node.name, node);
912
+ const parts = options.bare === true ? [] : [recordTest(v)];
913
+ if (depth === 0) return parts.length === 0 ? 'true' : parts.join(' && ');
914
+ const nestedDepth = childDepth(depth);
915
+ for (const property of node.properties) {
916
+ if (property.name === options.skip) continue;
917
+ const member = `${v}${accessor(property.name)}`;
918
+ const check = this.#check(property.type, member, join(path, property.name), nestedDepth);
919
+ if (check === undefined) return undefined;
920
+ if (check === 'true') continue;
921
+ parts.push(property.optional ? `(${member} === undefined || (${check}))` : `(${check})`);
922
+ }
923
+ return parts.length === 0 ? 'true' : parts.join(' && ');
924
+ }
925
+
926
+ // -------------------------------------------------------------------------
927
+ // Target: excess
928
+ // -------------------------------------------------------------------------
929
+
930
+ /**
931
+ * Statements that `return false` when the value carries a property the type does not
932
+ * declare. Reached only after `check` has passed, so every declared property is known
933
+ * to be there — which is what lets an all-required object reduce "no excess keys" to a
934
+ * key count.
935
+ */
936
+ #excess(node: TypeIR, v: string, path: string): string[] | undefined {
937
+ switch (node.kind) {
938
+ case 'object':
939
+ return this.#objectExcess(node, v, path);
940
+ case 'array':
941
+ return this.#arrayExcess(node, v, path);
942
+ case 'tuple': {
943
+ const statements: string[] = [];
944
+ for (const [index, element] of node.elements.entries()) {
945
+ if (!hasExcessCheck(element)) continue;
946
+ const inner = this.#excess(element, `${v}[${index}]`, `${path}[${index}]`);
947
+ if (inner === undefined) return undefined;
948
+ statements.push(...inner);
949
+ }
950
+ return statements;
951
+ }
952
+ case 'union': {
953
+ // A value can satisfy several arms of an undiscriminated union, so "which arm's
954
+ // property list is the declared one" has no answer and neither path checks it.
955
+ // With a discriminant there is exactly one answer.
956
+ const discriminant = discriminantOf(node.members);
957
+ if (!discriminant) return [];
958
+ const branches: string[] = [];
959
+ for (const arm of discriminant.arms) {
960
+ const inner = this.#excess(arm.node, v, path);
961
+ if (inner === undefined) return undefined;
962
+ if (inner.length === 0) continue;
963
+ branches.push(
964
+ `if (${v}${accessor(discriminant.key)} === ${JSON.stringify(arm.value)}) { ${inner.join(' ')} }`,
965
+ );
966
+ }
967
+ return branches;
968
+ }
969
+ case 'ref': {
970
+ // Every named object hoists, and registers itself before its own body is
971
+ // walked, so an ancestor a `ref` points at always has a helper by the time the
972
+ // `ref` is reached. The guard is here because "always" is a claim about the
973
+ // reflector, and emitting nothing is safer than emitting a call to nothing.
974
+ const helper = this.#open.get(`excess:${node.name}`);
975
+ if (helper === undefined) {
976
+ return this.#refuse(path, `a back-reference to \`${node.name}\`, which was never declared`);
977
+ }
978
+ return [`if (!${helper}(${v})) return false;`];
979
+ }
980
+ default:
981
+ return [];
982
+ }
983
+ }
984
+
985
+ #objectExcess(node: ObjectIR, v: string, path: string): string[] | undefined {
986
+ // A named object always hoists, even at the top. Inlining it there would leave
987
+ // `excess:<name>` unregistered, so a `ref` back to it inside its own body would
988
+ // find no helper and silently skip the check the runtime walker still performs.
989
+ if (node.name === undefined) return this.#objectExcessBody(node, v, path);
990
+
991
+ const openKey = `excess:${node.name}`;
992
+ const open = this.#open.get(openKey);
993
+ if (open !== undefined) return [`if (!${open}(${v})) return false;`];
994
+
995
+ const fingerprint = `excess:${JSON.stringify(node)}`;
996
+ const cached = this.#shared.get(fingerprint);
997
+ if (cached !== undefined) return [`if (!${cached}(${v})) return false;`];
998
+
999
+ const slot = this.#reserve();
1000
+ if (slot === undefined) return undefined;
1001
+ const name = this.#name(`Excess${capitalise(node.name)}`);
1002
+ this.#open.set(openKey, name);
1003
+ this.#shared.set(fingerprint, name);
1004
+ const inner = this.#objectExcessBody(node, '_v', path);
1005
+ if (inner === undefined) return undefined;
1006
+ this.#helpers[slot] = `function ${name}(_v) { ${inner.join(' ')} return true; }`;
1007
+ return [`if (!${name}(${v})) return false;`];
1008
+ }
1009
+
1010
+ #objectExcessBody(node: ObjectIR, v: string, path: string): string[] | undefined {
1011
+ const statements: string[] = [];
1012
+ const names = node.properties.map(property => property.name);
1013
+
1014
+ if (names.length > 0 && node.properties.every(property => !property.optional)) {
1015
+ // Fast path: nothing is optional, so every declared key is present and "no excess"
1016
+ // is exactly "the counts agree". No Set, no allocation, and the loop bails as soon
1017
+ // as it has seen one key too many.
1018
+ const counter = this.#name('C');
1019
+ statements.push(
1020
+ `let ${counter} = 0; for (const _ in ${v}) { if (++${counter} > ${names.length}) return false; } if (${counter} !== ${names.length}) return false;`,
1021
+ );
1022
+ } else {
1023
+ const set = this.#name('Keys');
1024
+ this.#helpers.push(`const ${set} = new Set([${names.map(name => JSON.stringify(name)).join(', ')}]);`);
1025
+ statements.push(`for (const _k in ${v}) { if (!${set}.has(_k)) return false; }`);
1026
+ }
1027
+
1028
+ for (const property of node.properties) {
1029
+ if (!hasExcessCheck(property.type)) continue;
1030
+ const member = `${v}${accessor(property.name)}`;
1031
+ const inner = this.#excess(property.type, member, join(path, property.name));
1032
+ if (inner === undefined) return undefined;
1033
+ if (inner.length === 0) continue;
1034
+ // The nested value may legitimately be absent (an optional property) or not an
1035
+ // object at all (a `T | null`), and `for (const _ in undefined)` throws.
1036
+ statements.push(`if (${keyableTest(member)}) { ${inner.join(' ')} }`);
1037
+ }
1038
+ return statements;
1039
+ }
1040
+
1041
+ #arrayExcess(node: ArrayIR, v: string, path: string): string[] | undefined {
1042
+ if (!hasExcessCheck(node.element)) return [];
1043
+ const index = this.#name('I');
1044
+ const inner = this.#excess(node.element, `${v}[${index}]`, `${path}[]`);
1045
+ if (inner === undefined) return undefined;
1046
+ if (inner.length === 0) return [];
1047
+ return [`for (let ${index} = 0; ${index} < ${v}.length; ${index}++) { ${inner.join(' ')} }`];
1048
+ }
1049
+
1050
+ // -------------------------------------------------------------------------
1051
+ // Target: issues
1052
+ // -------------------------------------------------------------------------
1053
+
1054
+ #issues(node: TypeIR, v: string, p: string, out: string, path: string, depth?: number): string[] | undefined {
1055
+ switch (node.kind) {
1056
+ case 'unsupported':
1057
+ return this.#refuse(path, node.reason, node.source);
1058
+ case 'unknown':
1059
+ return [];
1060
+ case 'null':
1061
+ case 'undefined':
1062
+ case 'literal': {
1063
+ const check = this.#check(node, v, path, depth);
1064
+ if (check === undefined) return undefined;
1065
+ return [`if (!(${check})) ${this.#issue(out, p, JSON.stringify(expectedOf(node)), v)}`];
1066
+ }
1067
+ case 'scalar':
1068
+ return this.#scalarIssues(node, v, p, out, path);
1069
+ case 'tuple':
1070
+ return this.#tupleIssues(node, v, p, out, path, depth);
1071
+ case 'array':
1072
+ return this.#arrayIssues(node, v, p, out, path, depth);
1073
+ case 'object':
1074
+ return this.#objectIssues(node, v, p, out, path, depth);
1075
+ case 'union':
1076
+ return this.#unionIssues(node, v, p, out, path, depth);
1077
+ case 'ref': {
1078
+ if (depth !== undefined) {
1079
+ const target = this.#definitions.get(node.name);
1080
+ if (target === undefined) {
1081
+ return this.#refuse(path, `a back-reference to \`${node.name}\`, which was never declared`);
1082
+ }
1083
+ return this.#objectIssues(target, v, p, out, path, depth);
1084
+ }
1085
+ const helper = this.#open.get(`issues:${node.name}`);
1086
+ if (helper === undefined) {
1087
+ return this.#refuse(path, `a back-reference to \`${node.name}\`, which was never declared`);
1088
+ }
1089
+ return [`${helper}(${v}, ${p}, ${out});`];
1090
+ }
1091
+ }
1092
+ }
1093
+
1094
+ #scalarIssues(node: ScalarIR, v: string, p: string, out: string, path: string): string[] | undefined {
1095
+ const base = scalarBase(node.scalar, v);
1096
+ const bounds = this.#constraintIssues(node.constraints, v, p, out, node.scalar === 'string', path);
1097
+ if (bounds === undefined) return undefined;
1098
+ const shape = this.#issue(out, p, JSON.stringify(expectedOf(node)), v);
1099
+ // The shape is checked first and stops the walk: reporting `minLength 3` about a
1100
+ // number would be two issues where one is the truth.
1101
+ if (bounds.length === 0) return [`if (!(${base})) ${shape}`];
1102
+ return [`if (!(${base})) { ${shape} } else { ${bounds.join(' ')} }`];
1103
+ }
1104
+
1105
+ #constraintIssues(
1106
+ constraints: Constraints | undefined,
1107
+ v: string,
1108
+ p: string,
1109
+ out: string,
1110
+ lengthy: boolean,
1111
+ path: string,
1112
+ ): string[] | undefined {
1113
+ if (!constraints) return [];
1114
+ const statements: string[] = [];
1115
+ const push = (keyword: ConstraintKeyword, test: string, value: number | string): void => {
1116
+ statements.push(
1117
+ `if (!(${test})) ${this.#issue(out, p, JSON.stringify(expectedForConstraint(keyword, value)), v)}`,
1118
+ );
1119
+ };
1120
+ if (constraints.minimum !== undefined) push('minimum', `${v} >= ${constraints.minimum}`, constraints.minimum);
1121
+ if (constraints.maximum !== undefined) push('maximum', `${v} <= ${constraints.maximum}`, constraints.maximum);
1122
+ if (constraints.minLength !== undefined) {
1123
+ push('minLength', `${v}.length >= ${constraints.minLength}`, constraints.minLength);
1124
+ }
1125
+ if (constraints.maxLength !== undefined) {
1126
+ push('maxLength', `${v}.length <= ${constraints.maxLength}`, constraints.maxLength);
1127
+ }
1128
+ if (constraints.pattern !== undefined) {
1129
+ if (!lengthy) return this.#refuse(path, 'a `pattern` constraint on something that is not a string');
1130
+ validatePatternComplexity(constraints.pattern);
1131
+ push('pattern', `/${escapePattern(constraints.pattern)}/.test(${v})`, constraints.pattern);
1132
+ }
1133
+ return statements;
1134
+ }
1135
+
1136
+ #tupleIssues(node: TupleIR, v: string, p: string, out: string, path: string, depth?: number): string[] | undefined {
1137
+ const inner: string[] = [];
1138
+ if (depth === undefined || depth > 1) {
1139
+ const nestedDepth = childDepth(depth);
1140
+ for (const [index, element] of node.elements.entries()) {
1141
+ const statements = this.#issues(
1142
+ element,
1143
+ `${v}[${index}]`,
1144
+ indexed(p, String(index)),
1145
+ out,
1146
+ `${path}[${index}]`,
1147
+ nestedDepth,
1148
+ );
1149
+ if (statements === undefined) return undefined;
1150
+ inner.push(...statements);
1151
+ }
1152
+ }
1153
+ const shape = this.#issue(out, p, JSON.stringify(expectedOf(node)), v);
1154
+ return [
1155
+ `if (!Array.isArray(${v}) || ${v}.length !== ${node.elements.length}) { ${shape} } else { ${inner.join(' ')} }`,
1156
+ ];
1157
+ }
1158
+
1159
+ #arrayIssues(node: ArrayIR, v: string, p: string, out: string, path: string, depth?: number): string[] | undefined {
1160
+ const fingerprint = `issuesArray${depthKey(depth)}:${JSON.stringify(node)}`;
1161
+ const cached = this.#shared.get(fingerprint);
1162
+ if (cached !== undefined) return [`${cached}(${v}, ${p}, ${out});`];
1163
+
1164
+ const slot = this.#reserve();
1165
+ if (slot === undefined) return undefined;
1166
+ const name = this.#name('IssuesArray');
1167
+ this.#shared.set(fingerprint, name);
1168
+
1169
+ let element: string[] = [];
1170
+ if (depth === undefined || depth > 1) {
1171
+ const emitted = this.#issues(node.element, '_v[_i]', indexed('_p', '_i'), '_o', `${path}[]`, childDepth(depth));
1172
+ if (emitted === undefined) {
1173
+ this.#shared.delete(fingerprint);
1174
+ return undefined;
1175
+ }
1176
+ element = emitted;
1177
+ }
1178
+ const bounds = this.#constraintIssues(node.constraints, '_v', '_p', '_o', true, path);
1179
+ if (bounds === undefined) return undefined;
1180
+ const body = [
1181
+ `if (!Array.isArray(_v)) { ${this.#issue('_o', '_p', JSON.stringify('array'), '_v')} return; }`,
1182
+ ...bounds,
1183
+ ];
1184
+ if (depth === undefined || depth > 1) {
1185
+ body.push(`for (let _i = 0; _i < _v.length; _i++) { ${element.join(' ')} }`);
1186
+ }
1187
+ this.#helpers[slot] = `function ${name}(_v, _p, _o) { ${body.join(' ')} }`;
1188
+ return [`${name}(${v}, ${p}, ${out});`];
1189
+ }
1190
+
1191
+ #objectIssues(node: ObjectIR, v: string, p: string, out: string, path: string, depth?: number): string[] | undefined {
1192
+ if (node.name !== undefined) this.#definitions.set(node.name, node);
1193
+ if (node.name === undefined) return this.#objectIssuesBody(node, v, p, out, path, depth);
1194
+
1195
+ const openKey = `issues${depthKey(depth)}:${node.name}`;
1196
+ const open = this.#open.get(openKey);
1197
+ if (open !== undefined) return [`${open}(${v}, ${p}, ${out});`];
1198
+
1199
+ const fingerprint = `issues${depthKey(depth)}:${JSON.stringify(node)}`;
1200
+ const cached = this.#shared.get(fingerprint);
1201
+ if (cached !== undefined) return [`${cached}(${v}, ${p}, ${out});`];
1202
+
1203
+ const slot = this.#reserve();
1204
+ if (slot === undefined) return undefined;
1205
+ const name = this.#name(`Issues${capitalise(node.name)}`);
1206
+ this.#open.set(openKey, name);
1207
+ this.#shared.set(fingerprint, name);
1208
+ const body = this.#objectIssuesBody(node, '_v', '_p', '_o', path, depth);
1209
+ if (body === undefined) return undefined;
1210
+ this.#helpers[slot] = `function ${name}(_v, _p, _o) { ${body.join(' ')} }`;
1211
+ return [`${name}(${v}, ${p}, ${out});`];
1212
+ }
1213
+
1214
+ #objectIssuesBody(
1215
+ node: ObjectIR,
1216
+ v: string,
1217
+ p: string,
1218
+ out: string,
1219
+ path: string,
1220
+ depth?: number,
1221
+ ): string[] | undefined {
1222
+ if (node.name !== undefined) this.#definitions.set(node.name, node);
1223
+ const inner: string[] = [];
1224
+ if (depth !== 0) {
1225
+ const nestedDepth = childDepth(depth);
1226
+ for (const property of node.properties) {
1227
+ const member = `${v}${accessor(property.name)}`;
1228
+ const statements = this.#issues(
1229
+ property.type,
1230
+ member,
1231
+ join(p, property.name),
1232
+ out,
1233
+ join(path, property.name),
1234
+ nestedDepth,
1235
+ );
1236
+ if (statements === undefined) return undefined;
1237
+ if (statements.length === 0) continue;
1238
+ inner.push(
1239
+ property.optional ? `if (${member} !== undefined) { ${statements.join(' ')} }` : statements.join(' '),
1240
+ );
1241
+ }
1242
+ }
1243
+ const shape = this.#issue(out, p, JSON.stringify(expectedOf(node)), v);
1244
+ return [`if (!(${recordTest(v)})) { ${shape} } else { ${inner.join(' ')} }`];
1245
+ }
1246
+
1247
+ #unionIssues(node: UnionIR, v: string, p: string, out: string, path: string, depth?: number): string[] | undefined {
1248
+ const discriminant = discriminantOf(node.members);
1249
+ if (!discriminant) {
1250
+ // No discriminant, so there is no arm to blame: one issue naming the whole union.
1251
+ const check = this.#check(node, v, path, depth);
1252
+ if (check === undefined) return undefined;
1253
+ return [`if (!(${check})) ${this.#issue(out, p, JSON.stringify(expectedOf(node)), v)}`];
1254
+ }
1255
+
1256
+ const branches: string[] = [];
1257
+ for (const arm of discriminant.arms) {
1258
+ const body = this.#objectIssuesBody(arm.node, v, p, out, path, depth);
1259
+ if (body === undefined) return undefined;
1260
+ branches.push(`if (${v}${accessor(discriminant.key)} === ${JSON.stringify(arm.value)}) { ${body.join(' ')} }`);
1261
+ }
1262
+ const shape = this.#issue(out, p, JSON.stringify(expectedOf(node)), v);
1263
+ const wrongKey = this.#issue(
1264
+ out,
1265
+ join(p, discriminant.key),
1266
+ JSON.stringify(expectedForDiscriminant(discriminant)),
1267
+ `${v}${accessor(discriminant.key)}`,
1268
+ );
1269
+ return [`if (!(${recordTest(v)})) { ${shape} } else ${branches.join(' else ')} else { ${wrongKey} }`];
1270
+ }
1271
+
1272
+ // -------------------------------------------------------------------------
1273
+ // Target: sample
1274
+ // -------------------------------------------------------------------------
1275
+
1276
+ /**
1277
+ * A value that satisfies `node` **by construction**. Where that cannot be promised the
1278
+ * emitter refuses, which is the difference between this and what it replaced: the old
1279
+ * generator answered `'x'` for an arbitrary `pattern`, so `is(random(d), d)` — the one
1280
+ * property it claimed — was false.
1281
+ */
1282
+ #sample(node: TypeIR, path: string): string | undefined {
1283
+ switch (node.kind) {
1284
+ case 'unsupported':
1285
+ return this.#refuse(path, node.reason, node.source);
1286
+ case 'unknown':
1287
+ case 'null':
1288
+ return 'null';
1289
+ case 'undefined':
1290
+ return 'undefined';
1291
+ case 'literal':
1292
+ return JSON.stringify(node.value);
1293
+ case 'scalar':
1294
+ return this.#scalarSample(node, path);
1295
+ case 'tuple': {
1296
+ const elements: string[] = [];
1297
+ for (const [index, element] of node.elements.entries()) {
1298
+ const sample = this.#sample(element, `${path}[${index}]`);
1299
+ if (sample === undefined) return undefined;
1300
+ elements.push(sample);
1301
+ }
1302
+ return `[${elements.join(', ')}]`;
1303
+ }
1304
+ case 'array':
1305
+ return this.#arraySample(node, path);
1306
+ case 'object': {
1307
+ const entries: string[] = [];
1308
+ for (const property of node.properties) {
1309
+ const sample = this.#sample(property.type, join(path, property.name));
1310
+ if (sample === undefined) return undefined;
1311
+ entries.push(`${JSON.stringify(property.name)}: ${sample}`);
1312
+ }
1313
+ return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`;
1314
+ }
1315
+ case 'union':
1316
+ return this.#unionSample(node, path);
1317
+ case 'ref':
1318
+ return this.#refuse(path, `\`${node.name}\` recurs with no terminating arm, so no finite value satisfies it`);
1319
+ }
1320
+ }
1321
+
1322
+ #arraySample(node: ArrayIR, path: string): string | undefined {
1323
+ const element = this.#sample(node.element, `${path}[]`);
1324
+ if (element === undefined) return undefined;
1325
+ const min = node.constraints?.minLength ?? 1;
1326
+ const max = node.constraints?.maxLength ?? Math.max(min, 3);
1327
+ if (min > max) return this.#refuse(path, `an array with minLength ${min} above maxLength ${max}`);
1328
+ const name = this.#function(
1329
+ 'SampleArray',
1330
+ [],
1331
+ [
1332
+ `const _n = ${this.#randInt(min, max)};`,
1333
+ `const _a = []; for (let _i = 0; _i < _n; _i++) _a.push(${element});`,
1334
+ 'return _a;',
1335
+ ],
1336
+ );
1337
+ return name === undefined ? undefined : `${name}()`;
1338
+ }
1339
+
1340
+ #unionSample(node: UnionIR, path: string): string | undefined {
1341
+ // Back-references are dropped rather than sampled: `Node_ { next: Node_ | null }`
1342
+ // has a terminating arm and the generator takes it. A `ref` with no way out is the
1343
+ // refusal in `#sample`.
1344
+ const usable = node.members.filter(member => member.kind !== 'ref');
1345
+ if (usable.length === 0) {
1346
+ return this.#refuse(path, 'a union of nothing but back-references cannot be sampled');
1347
+ }
1348
+ const options: string[] = [];
1349
+ for (const [index, member] of usable.entries()) {
1350
+ const sample = this.#sample(member, `${path}|${index}`);
1351
+ if (sample === undefined) return undefined;
1352
+ options.push(sample);
1353
+ }
1354
+ // boundary: `usable` is non-empty — the line above returns otherwise — and the loop
1355
+ // pushes exactly one option per member or returns, so `options` has at least one
1356
+ // element. `noUncheckedIndexedAccess` cannot follow that, and the alternatives are a
1357
+ // refusal for a case that cannot happen or a default sample that would be emitted.
1358
+ const first = options[0] as string;
1359
+ if (options.length === 1) return first;
1360
+ const cases = options.map((option, index) => `case ${index}: return ${option};`);
1361
+ const name = this.#function(
1362
+ 'SampleUnion',
1363
+ [],
1364
+ [`switch (${this.#randInt(0, options.length - 1)}) { ${cases.join(' ')} }`, `return ${first};`],
1365
+ );
1366
+ return name === undefined ? undefined : `${name}()`;
1367
+ }
1368
+
1369
+ #scalarSample(node: ScalarIR, path: string): string | undefined {
1370
+ const constraints = node.constraints;
1371
+ switch (node.scalar) {
1372
+ case 'boolean':
1373
+ return 'Math.random() < 0.5';
1374
+ case 'date':
1375
+ // The same instant the runtime sampler draws, and for the same reason: one documented
1376
+ // function should not mean "now" when the transformer inlined it and "an arbitrary
1377
+ // instant" when it did not. Epoch to roughly 2024.
1378
+ return 'new Date(Math.floor(Math.random() * 1700000000000))';
1379
+ case 'number':
1380
+ case 'integer':
1381
+ case 'bigint': {
1382
+ const min = constraints?.minimum ?? 0;
1383
+ const max = constraints?.maximum ?? min + 1000;
1384
+ if (min > max) return this.#refuse(path, `a bound with minimum ${min} above maximum ${max}`);
1385
+ const int = this.#randInt(min, max);
1386
+ return node.scalar === 'bigint' ? `BigInt(${int})` : int;
1387
+ }
1388
+ case 'string': {
1389
+ if (constraints?.pattern !== undefined) {
1390
+ return this.#refuse(
1391
+ path,
1392
+ 'a sample cannot be built from a `pattern`; nothing here inverts a regular expression',
1393
+ constraints.pattern,
1394
+ );
1395
+ }
1396
+ const min = constraints?.minLength ?? 1;
1397
+ const max = constraints?.maxLength ?? Math.max(min, 12);
1398
+ if (min > max) return this.#refuse(path, `a string with minLength ${min} above maxLength ${max}`);
1399
+ if (!this.#hasStringSample) {
1400
+ this.#hasStringSample = true;
1401
+ this.#helpers.push(
1402
+ `function ${this.#prefix}Str(min, max) { let s = ""; while (s.length < Math.max(min, 1)) s += Math.random().toString(36).slice(2); return s.slice(0, max); }`,
1403
+ );
1404
+ }
1405
+ return `${this.#prefix}Str(${min}, ${max})`;
1406
+ }
1407
+ }
1408
+ }
1409
+
1410
+ #randInt(min: number, max: number): string {
1411
+ if (!this.#hasIntSample) {
1412
+ this.#hasIntSample = true;
1413
+ this.#helpers.push(
1414
+ `function ${this.#prefix}Int(min, max) { return min + Math.floor(Math.random() * (max - min + 1)); }`,
1415
+ );
1416
+ }
1417
+ return `${this.#prefix}Int(${min}, ${max})`;
1418
+ }
1419
+ }
1420
+
1421
+ // ---------------------------------------------------------------------------
1422
+ // Free helpers
1423
+ // ---------------------------------------------------------------------------
1424
+
1425
+ /**
1426
+ * The shape half of a scalar check, without its bounds. Shared by `check` and `issues`
1427
+ * so the two cannot drift apart about what a `number` is.
1428
+ *
1429
+ * `NaN` is rejected: it cannot cross a JSON boundary, and the runtime walker has always
1430
+ * rejected it. The emitted form used not to, which is one of the divergences REQ-AV-4
1431
+ * exists to close.
1432
+ */
1433
+ function scalarBase(scalar: ScalarIR['scalar'], v: string): string {
1434
+ switch (scalar) {
1435
+ case 'string':
1436
+ return `typeof ${v} === "string"`;
1437
+ case 'number':
1438
+ return `typeof ${v} === "number" && !Number.isNaN(${v})`;
1439
+ case 'integer':
1440
+ return `Number.isInteger(${v})`;
1441
+ case 'bigint':
1442
+ return `typeof ${v} === "bigint"`;
1443
+ case 'boolean':
1444
+ return `typeof ${v} === "boolean"`;
1445
+ case 'date':
1446
+ return `${v} instanceof Date && !Number.isNaN(${v}.getTime())`;
1447
+ }
1448
+ }
1449
+
1450
+ /** `.email`, or `["odd name"]` when the property is not a plain identifier. */
1451
+ function accessor(name: string): string {
1452
+ return IDENTIFIER.test(name) ? `.${name}` : `[${JSON.stringify(name)}]`;
1453
+ }
1454
+
1455
+ /**
1456
+ * Extend a path *expression* with a static key, folding when it is already a literal so
1457
+ * the emitted code reads `"input.email"` rather than `"input" + ".email"`.
1458
+ *
1459
+ * boundary: `JSON.parse` returns `any`, and the assertion says the parse of a JSON string
1460
+ * literal is a string. `STRING_LITERAL` is what establishes that — it matches a complete
1461
+ * double-quoted JSON string and nothing else, so the parse cannot return a number or an
1462
+ * object. `indexed` below carries the same argument.
1463
+ */
1464
+ function join(pathExpr: string, key: string): string {
1465
+ const suffix = IDENTIFIER.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`;
1466
+ if (STRING_LITERAL.test(pathExpr)) return JSON.stringify(`${JSON.parse(pathExpr) as string}${suffix}`);
1467
+ return `${pathExpr} + ${JSON.stringify(suffix)}`;
1468
+ }
1469
+
1470
+ /**
1471
+ * Extend a path expression with an index, which for an array is only known at runtime.
1472
+ *
1473
+ * boundary: as in `join` — `STRING_LITERAL` proves the parse yields a string.
1474
+ */
1475
+ function indexed(pathExpr: string, index: string): string {
1476
+ if (STRING_LITERAL.test(pathExpr) && NUMERIC.test(index)) {
1477
+ return JSON.stringify(`${JSON.parse(pathExpr) as string}[${index}]`);
1478
+ }
1479
+ return `${pathExpr} + "[" + ${index} + "]"`;
1480
+ }
1481
+
1482
+ function capitalise(name: string): string {
1483
+ const cleaned = name.replaceAll(/[^A-Za-z0-9]/g, '');
1484
+ if (cleaned.length === 0) return 'T';
1485
+ return `${cleaned.slice(0, 1).toUpperCase()}${cleaned.slice(1)}`;
1486
+ }
1487
+
1488
+ export {
1489
+ discriminantOf,
1490
+ expectedForConstraint,
1491
+ expectedForDiscriminant,
1492
+ expectedOf,
1493
+ hasExcessCheck,
1494
+ messageFor,
1495
+ } from '@zmdb/schema/ir';
1496
+ export { type ConstraintKeyword, type Discriminant, type DiscriminantArm } from '@zmdb/schema/ir';