typespec-http-zod 0.1.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.
@@ -0,0 +1,1425 @@
1
+ import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, resolvePath, } from "@typespec/compiler";
2
+ import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
3
+ import { resolveOperationId } from "@typespec/openapi";
4
+ import { reportDiagnostic } from "./lib.js";
5
+ import { SchemaRegistry, TypeRegistry } from "./registry.js";
6
+ import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
7
+ import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
8
+ import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
9
+ /**
10
+ * `typespec-http-zod` — Zod validators and framework-free contract types from a TypeSpec HTTP
11
+ * service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
12
+ *
13
+ * **The governing rule: if the document cannot say it, the validator must not enforce it.** A
14
+ * validator that checks something the published contract never states is a rule no caller reading
15
+ * that contract can see. This package ships **zero decorators** for exactly that reason — four were
16
+ * deleted, each of which let a spec state something openapi3 could not publish — and asserts the
17
+ * class is empty rather than trusting it. Where openapi3 decides something, its rule is read from its
18
+ * source and copied, refusals included: the same spec has to be representable by both emitters or by
19
+ * neither, or a differential between them means nothing.
20
+ *
21
+ * **Why not `@typespec/http-server-js`.** The first-party JS server emitter is `0.58.0-alpha.*`,
22
+ * self-describes as "highly experimental", and emits a Node.js `http` router. It also answers a
23
+ * different question: this package emits no server at all. Validators are useful to Express, Fastify,
24
+ * Elysia, a Workers fetch handler or a typed client, and a server generator is a thin consumer of
25
+ * them — `typespec-hono` is one.
26
+ *
27
+ * **Built on the stable 1.x surface only** — `$onEmit` plus `@typespec/http` — rather than on
28
+ * `@typespec/emitter-framework` (pre-1.0) or `@typespec/asset-emitter`, which announces its own
29
+ * replacement in its package description. Fewer moving parts under something that has to keep working.
30
+ */
31
+ export { $lib } from "./lib.js";
32
+ /**
33
+ * The option contract, published so an emitter built on this library can **derive** its own rather
34
+ * than restate it.
35
+ *
36
+ * ⚠️ **Two schemas that have to agree are two lists that will not.** A server generator adds its own
37
+ * keys and forwards the rest; if it restates this one, an option added here is silently rejected as
38
+ * unknown by the wrapper, or accepted and never passed on. Neither failure is visible in output.
39
+ */
40
+ export { EmitterOptionsSchema } from "./lib.js";
41
+ /**
42
+ * How a property name is spelled as an object key — quoted only when it has to be.
43
+ *
44
+ * ⚠️ **Published because a consumer emitting code beside these validators has to spell keys the
45
+ * SAME way, and getting it wrong produces a file that does not parse.** An unquoted
46
+ * `x-ms-test-header` is exactly that, and it happened: a corpus scenario declares one and the
47
+ * emitted module stopped being loadable. This is not a style choice a second emitter may make
48
+ * independently.
49
+ */
50
+ export { objectKey } from "./zod.js";
51
+ /** Named once. The emitted banner and the internal-invariant messages both quote it. */
52
+ const PACKAGE_NAME = "typespec-http-zod";
53
+ /**
54
+ * The header every emitted file carries.
55
+ *
56
+ * ⚠️ **This once named one repository's build script** — `pnpm --filter … generate`, a command that
57
+ * exists in exactly one checkout. A generated file is the most-read artefact an emitter produces, and
58
+ * a banner sending the reader to a script they do not have is worse than no instruction at all.
59
+ *
60
+ * The package name stays, because that IS the answer to "what wrote this". The instruction becomes
61
+ * the one thing true for every consumer: recompile the source, do not edit the output.
62
+ */
63
+ const GENERATED_BANNER = `// GENERATED by ${PACKAGE_NAME} from the TypeSpec service definition. DO NOT EDIT.
64
+ // Recompile the spec that produced it; edits here are overwritten on the next run.
65
+ `;
66
+ /**
67
+ * The success status a row registers.
68
+ *
69
+ * An operation declares exactly one success status. A range (`2XX`) or a wildcard is not a status a
70
+ * handler can return, so it is not something this emitter can turn into a `respond()` call — an
71
+ * operation carrying only those is skipped rather than guessed at.
72
+ */
73
+ export function successStatusOf(operation) {
74
+ return successStatusesOf(operation)[0];
75
+ }
76
+ /**
77
+ * **Every** success status the operation declares, ascending.
78
+ *
79
+ * An operation that answers `202` when the work was merely accepted and `200` when it completed
80
+ * inline declares both, and something has to choose. A route with two declared statuses and no way to
81
+ * choose would always answer the first, publishing a status that can never occur; the arm carrying
82
+ * asserts the two stay in step.
83
+ */
84
+ export function successStatusesOf(operation) {
85
+ const codes = [];
86
+ for (const response of operation.responses) {
87
+ const code = response.statusCodes;
88
+ if (typeof code === "number" && code >= 200 && code < 300)
89
+ codes.push(code);
90
+ }
91
+ return [...new Set(codes)].toSorted((a, b) => a - b);
92
+ }
93
+ /** The five buckets OpenAPI can express a status RANGE as, in the order it names them. */
94
+ const STATUS_RANGE_GROUPS = [1, 2, 3, 4, 5];
95
+ /**
96
+ * What the document will key this response under — **openapi3's rule, copied, not re-derived.**
97
+ *
98
+ * ⚠️ **`HttpStatusCodesEntry` is `HttpStatusCodeRange | number | "*"`, and the third case was
99
+ * silently dropped.** A response declared `@minValue(400) @maxValue(499) @statusCode _: int32` is a
100
+ * `4XX` entry in the document and was emitted NOWHERE here: not refused, not warned about, just
101
+ * absent from the arms — so a service could publish a whole class of failures and hand `respond`
102
+ * nothing to check them against. Two of the two operations in `response/status-code-range` lost an
103
+ * arm each this way, and the emitter's own `mixed-error-bodies` refusal was firing over the same
104
+ * scenario for an unrelated and by then obsolete reason, which is how it stayed hidden.
105
+ *
106
+ * ⚠️ **A partial range is REFUSED, because `@typespec/openapi3` refuses it.** `494-499` does not
107
+ * cover the `4XX` bucket, and bucketing it anyway would have the server claim that a `400` carries
108
+ * `ErrorInRange` — a rule no document states, since openapi3 declines to publish a document at all.
109
+ * Agreeing with the reference implementation is the whole point of copying its line: the same spec
110
+ * is either representable by both or by neither.
111
+ *
112
+ * `@defaultResponse` is not tested separately the way openapi3 tests it; `@typespec/http` resolves
113
+ * that decorator to `"*"` before we see it, so the one check covers both spellings.
114
+ */
115
+ function statusKeysOf(program, response) {
116
+ const codes = response.statusCodes;
117
+ if (codes === "*")
118
+ return ["default"];
119
+ if (typeof codes === "number")
120
+ return [codes];
121
+ const keys = [];
122
+ let { start, end } = codes;
123
+ const refuse = () => {
124
+ reportDiagnostic(program, {
125
+ code: "unsupported-status-code-range",
126
+ target: response.type,
127
+ format: { start: String(codes.start), end: String(codes.end) },
128
+ });
129
+ };
130
+ if (start < 100) {
131
+ refuse();
132
+ start = 100;
133
+ keys.push("default");
134
+ }
135
+ else if (end > 599) {
136
+ refuse();
137
+ keys.push("default");
138
+ end = 599;
139
+ }
140
+ for (const group of STATUS_RANGE_GROUPS) {
141
+ if (start > end)
142
+ break;
143
+ const groupStart = group * 100;
144
+ const groupEnd = groupStart + 99;
145
+ if (start >= groupStart && start <= groupEnd) {
146
+ keys.push(`${group}XX`);
147
+ if (start !== groupStart || end < groupEnd)
148
+ refuse();
149
+ start = groupStart + 100;
150
+ }
151
+ }
152
+ return keys;
153
+ }
154
+ /** Whether a document status key describes a SUCCESS, so the success arms already carry it. */
155
+ function isSuccessKey(status) {
156
+ return status === "2XX" || (typeof status === "number" && status >= 200 && status < 300);
157
+ }
158
+ /**
159
+ * Every FAILURE arm the operation declares, each with the schema for ITS OWN body.
160
+ *
161
+ * ⚠️ **None of these reached the generated output at all, and a census is the proof.** Measured on one
162
+ * service, the document declared `200`x9, `201`x3, `202`x1, `401`x1 and `default`x2, and only the
163
+ * first three were emitted. So the schema for a failure never arrived, and no application could have
164
+ * checked what it sends when something goes wrong even if it wanted to.
165
+ *
166
+ * ⚠️ **Then every arm that did arrive carried the SERVICE-WIDE error schema.** The spike declares
167
+ * `@error model WebhookUnauthorized { ...ProblemDetails; @statusCode 401 }` and openapi3 publishes
168
+ * the 401 as `$ref: WebhookUnauthorized` while we answered with `problemDetailsSchema`. The shapes
169
+ * were identical there, so nothing was mis-validated — but the component the document names for that
170
+ * response had no declaration at all.
171
+ *
172
+ * ⚠️ **`"default"` used to be the one arm with no body of its own**, filled in downstream from the
173
+ * service-wide schema. That was backwards: a catch-all response declares a body like any other
174
+ * response, and `response/status-code-range` gives two operations whose `default` is a DIFFERENT
175
+ * model from every other failure they declare. Reading each response's own body answers all three at
176
+ * once, and is why the emitter no longer needs to believe a service has one error shape.
177
+ *
178
+ * Arms come out in the order OpenAPI resolves them — exact codes, then ranges, then the catch-all —
179
+ * so a consumer scanning for the first match applies the precedence the specification states.
180
+ */
181
+ function errorArmsOf(program, operation, registry) {
182
+ const arms = new Map();
183
+ for (const response of operation.responses) {
184
+ for (const status of statusKeysOf(program, response)) {
185
+ if (isSuccessKey(status) || arms.has(status))
186
+ continue;
187
+ arms.set(status, bodySchemaOf(response, registry));
188
+ }
189
+ }
190
+ return [...arms]
191
+ .map(([status, schema]) => ({ status, schema }))
192
+ .toSorted((a, b) => statusPrecedenceOf(a.status) - statusPrecedenceOf(b.status));
193
+ }
194
+ /**
195
+ * Where an arm sits in OpenAPI's own precedence order.
196
+ *
197
+ * ⚠️ **The specification decides this, not us.** The Responses Object says a range MAY be given as
198
+ * an uppercase `X` wildcard, and that "if a response is defined using an explicit code, the explicit
199
+ * code definition takes precedence over the range definition for that code". `default` is by
200
+ * definition every status not otherwise listed, so it is last. Emitting the arms already sorted is
201
+ * what lets a consumer take the first match and be right — the alternative is every application
202
+ * re-deriving the rule, and getting it wrong quietly when a range and an exact code both apply.
203
+ */
204
+ function statusPrecedenceOf(status) {
205
+ if (status === "default")
206
+ return 2;
207
+ return typeof status === "number" ? 0 : 1;
208
+ }
209
+ /** A response's own body, as an emitted Zod expression — `undefined` where it declares none. */
210
+ function bodySchemaOf(response, registry) {
211
+ for (const content of response.responses) {
212
+ if (content.body?.bodyKind !== "single")
213
+ continue;
214
+ const type = bodyTypeOf(content.body.type);
215
+ if (type !== undefined)
216
+ return registry.expressionFor(type);
217
+ }
218
+ return undefined;
219
+ }
220
+ /**
221
+ * Whether the operation declares `@useAuth(NoAuth)` — no caller is established for it.
222
+ *
223
+ * ⚠️ **`@useAuth(NoAuth)` reaches OpenAPI as `security: []`, which is why this is derivable at all.**
224
+ * Whether an operation needs a caller is a contract fact the document publishes. What KIND of caller
225
+ * — account-scoped, resource-scoped — is not: no OpenAPI keyword expresses it, and a server emitter
226
+ * that guesses is enforcing something no document states.
227
+ */
228
+ function noAuthFor(program, operation) {
229
+ return (getAuthenticationForOperation(program, operation.operation)?.options.some((option) => option.schemes.some((scheme) => scheme.type === "noAuth")) === true);
230
+ }
231
+ /**
232
+ * The body every `@error` response in the service declares, when they all declare the same one.
233
+ *
234
+ * ⚠️ **The emitter does not know what a problem document is, and must not.** This is the general
235
+ * capability — "a service may declare an error response; emit its validator so the runtime can check
236
+ * what it sends" — which is what makes it reusable by any gateway built on this emitter rather than a
237
+ * CM-shaped special case. RFC 9457 happens to be what `cm-api` puts in that slot.
238
+ *
239
+ * ⚠️ **This used to be an INVARIANT the emitter enforced, and it is now merely an observation.** It
240
+ * reported `mixed-error-bodies` — an error, aborting the compile — whenever a service declared two
241
+ * catch-all bodies, or a specific status whose body neither was nor spread the catch-all one. That
242
+ * was a real constraint while every failure arm carried this single schema; now each declared
243
+ * response resolves its own, and a service answering `404` with `NotFoundError` and everything else
244
+ * with `DefaultError` is an ordinary contract that the generated server serves correctly.
245
+ *
246
+ * So the refusal is gone, and what remains is a convenience: the module-level `GENERATED_ERROR_SCHEMA`
247
+ * the route table publishes, which exists precisely when the service has one such body. Where it does
248
+ * not, the constant is not emitted — and a consumer importing it gets a missing export, named at the
249
+ * import, which is the honest report of "there is no such thing here".
250
+ */
251
+ export function errorBodyOf(program, service) {
252
+ const canonical = new Set();
253
+ const specific = new Set();
254
+ for (const operation of service.operations) {
255
+ for (const response of operation.responses) {
256
+ /**
257
+ * ⚠️ `@error` sits on the RESPONSE type, which is not always the body type.
258
+ *
259
+ * Two declaration styles are both idiomatic and both occur. One returns the error model bare
260
+ * (`op x(): Foo | ProblemDetails`), so response type and body type are the same; the other wraps
261
+ * it (`@error model ErrorResponse { @statusCode …; @body body: Problem }`), so the `@error` is
262
+ * on the wrapper and the body is a plain model. Testing the body missed the second style
263
+ * entirely — measured on a surface declaring an error on all
264
+ * eleven operations and this found none of them.
265
+ */
266
+ if (!isErrorModel(program, response.type))
267
+ continue;
268
+ for (const content of response.responses) {
269
+ const body = content.body;
270
+ if (body?.bodyKind !== "single")
271
+ continue;
272
+ /**
273
+ * ⚠️ **An error response can declare no body either.** `tsp-openapi3` renders a bare
274
+ * `default` response as `DefaultResponse<Body = void>`, so the Swagger Petstore reaches
275
+ * here with the `void` intrinsic — which was then registered as the service's error schema
276
+ * and refused by the Zod walk. There is nothing there to validate.
277
+ */
278
+ const type = bodyTypeOf(body.type);
279
+ if (type === undefined)
280
+ continue;
281
+ // `"*"` is what `@error` resolves to when the model declares no status — the operation's
282
+ // general "any failure" contract, and the shape the runtime constructs.
283
+ (response.statusCodes === "*" ? canonical : specific).add(type);
284
+ }
285
+ }
286
+ }
287
+ /**
288
+ * Two catch-all bodies means there is no "the" service-wide error body, so there is nothing to
289
+ * publish — which is a reason to emit no constant, not a defect to refuse. Each operation's own
290
+ * `default` arm carries its own schema, and that is what the server validates against.
291
+ */
292
+ if (canonical.size > 1)
293
+ return undefined;
294
+ const [body] = canonical;
295
+ if (body !== undefined)
296
+ return body;
297
+ /**
298
+ * A service can decline to declare a catch-all and still have one error shape — ten specific
299
+ * statuses carrying one problem model between them. That is a service-wide
300
+ * body by any honest reading. Two different ones is not, and picking the first would be naming a
301
+ * contract fact by iteration order.
302
+ */
303
+ return specific.size === 1 ? [...specific][0] : undefined;
304
+ }
305
+ /**
306
+ * A response's body type, or `undefined` where it declares no body.
307
+ *
308
+ * ⚠️ **`void` and `never` ARE "no body", wherever they appear.** Answering that in one place is the
309
+ * whole point: the fact reached this emitter by three different roads and each one had to learn it
310
+ * separately otherwise. A `204` with no content at all; an `op remove(): void`; and — the road that
311
+ * actually broke the Swagger Petstore — `tsp-openapi3` writing a `default` error response as
312
+ * `DefaultResponse<Body = void>`. Returned as a type, any of them is walked and refused with
313
+ * `Cannot emit Zod for Intrinsic: 'void' has no runtime representation'`.
314
+ */
315
+ function bodyTypeOf(type) {
316
+ return isVoidType(type) || isNeverType(type) ? undefined : type;
317
+ }
318
+ /**
319
+ * The declared body type of the operation's success response, if it has one.
320
+ *
321
+ * ⚠️ **`void` and `never` ARE "no body", and reading them as types was a defect.** A response with
322
+ * no content reaches this in two shapes: as no body at all, which `undefined` already covered, and
323
+ * as a body whose type is `void` — which is what `tsp-openapi3` writes when it converts a `204`,
324
+ * and what `op remove(): void` means. The second used to be returned as a type, walked, and refused
325
+ * with `Cannot emit Zod for Intrinsic: 'void' has no runtime representation'`. That took out the
326
+ * Swagger Petstore, the most widely implemented reference API there is, on the round-trip suite's
327
+ * first run.
328
+ *
329
+ * One fact arriving by two roads. Answering it here rather than at each of the four call sites is
330
+ * what keeps them from drifting — `collectRoutes`, the contract types, the dual-status
331
+ * discriminator and the alternate-arm schema all want the same answer to the same question.
332
+ */
333
+ function successBodyOf(operation, status) {
334
+ for (const response of operation.responses) {
335
+ if (response.statusCodes !== status)
336
+ continue;
337
+ for (const content of response.responses) {
338
+ if (content.body?.bodyKind !== "single")
339
+ continue;
340
+ return bodyTypeOf(content.body.type);
341
+ }
342
+ }
343
+ return undefined;
344
+ }
345
+ /**
346
+ * The media types this operation's success response can be served as.
347
+ *
348
+ * ⚠️ **The fact that decides whether two operations are a collision or a CHOICE.** TypeSpec models
349
+ * content negotiation as several operations on one route, each accepting a different media type;
350
+ * OpenAPI models it as one path entry whose response lists them all. This emitter read neither, so
351
+ * it registered every operation and Hono — which matches in registration order — left all but the
352
+ * first as dead code that looks mounted.
353
+ *
354
+ * Empty when the response carries no body, which is not the same as a body served as anything.
355
+ */
356
+ function responseContentTypesOf(operation, status) {
357
+ const types = new Set();
358
+ for (const response of operation.responses) {
359
+ if (response.statusCodes !== status)
360
+ continue;
361
+ for (const content of response.responses) {
362
+ for (const contentType of content.body?.contentTypes ?? [])
363
+ types.add(contentType);
364
+ }
365
+ }
366
+ return [...types];
367
+ }
368
+ /** Whether a type is an array — TypeSpec models one as a `Model` carrying an integer indexer. */
369
+ function isArrayType(type) {
370
+ return type.kind === "Model" && type.indexer?.key.name === "integer";
371
+ }
372
+ /**
373
+ * How to choose between an operation's two success statuses, read from the declared arms.
374
+ *
375
+ * ⚠️ **A discriminator, not a trial-parse.** "Try the 200 schema, and if it fails use the 202" is the
376
+ * banned fallback wearing a different hat: a genuine validation failure on the first arm would be
377
+ * reported as the second arm's shape. Each arm pins a **required literal** property instead —
378
+ * `awaiting: true` on the accepted one — and a consumer reads that one property.
379
+ *
380
+ * This replaces hand-written per-operation closures, which existed only because a spec declaring the
381
+ * SAME body model for both statuses could not say which was which.
382
+ */
383
+ function statusDiscriminatorOf(operation, statuses) {
384
+ if (statuses.length < 2)
385
+ return undefined;
386
+ for (const status of statuses) {
387
+ const body = successBodyOf(operation, status);
388
+ if (body?.kind !== "Model")
389
+ continue;
390
+ for (const [name, property] of body.properties) {
391
+ // The OTHER arm carries the same property as optional; a required literal is the mark.
392
+ if (property.optional)
393
+ continue;
394
+ if (property.type.kind === "Boolean" || property.type.kind === "String") {
395
+ return { property: name, value: property.type.value, status };
396
+ }
397
+ }
398
+ }
399
+ return undefined;
400
+ }
401
+ /** The emitted schema for a specific success status, used for the second arm of a dual-status op. */
402
+ function alternateSchemaFor(operation, status, registry) {
403
+ const body = successBodyOf(operation, status);
404
+ return body === undefined ? undefined : registry.expressionFor(body);
405
+ }
406
+ /**
407
+ * The Zod for a **multipart** request body, built from its parts.
408
+ *
409
+ * ⚠️ **`requestBodyOf` returns a type only for `bodyKind === "single"`, so every multipart operation
410
+ * was mounted with NO body validator at all** — 19 routes across `payload/multipart` and 12
411
+ * components the document declares that nothing here could check. Not a refusal, not a diagnostic:
412
+ * silently unvalidated, which is the failure mode this emitter exists to remove.
413
+ *
414
+ * `HttpPart<T>` is unwrapped to `T`, which is what the document publishes — `HttpPart<string>` is
415
+ * `{type: "string"}` and `HttpPart<Address>` is a `$ref`.
416
+ *
417
+ * ⚠️ **A binary part is `z.unknown()`, deliberately.** `SCALARS` maps `bytes` to `z.string()`, which
418
+ * is right for a JSON body (base64) and wrong here: in a multipart part `bytes` is raw binary, and
419
+ * openapi3 publishes `{}` for it — no type at all. Emitting `z.string()` would enforce a rule the
420
+ * document does not state AND reject what a server actually receives, since `c.req.parseBody()`
421
+ * hands back a `File`. The type of a value is positional, exactly as its visibility is.
422
+ */
423
+ function multipartSchemaOf(body, registry) {
424
+ const entries = body.parts.map((part) => {
425
+ const type = part.body?.type;
426
+ const inner = type === undefined || isBinaryPart(type) ? "z.unknown()" : registry.expressionFor(type);
427
+ // `multi` is `HttpPart<T>[]` — the same part repeated, which arrives as an array.
428
+ const value = part.multi === true ? `z.array(${inner})` : inner;
429
+ return `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.optional()` : value},`;
430
+ });
431
+ const shape = entries.length === 0 ? "z.object({})" : `z.object({\n${entries.join("\n")}\n})`;
432
+ /**
433
+ * Declared under the model's own name when it has one, because the document publishes a component
434
+ * for it. Emitted inline the schema is correct and the component still has no validator, which the
435
+ * differential reports — rightly — as a contract the runtime cannot check.
436
+ */
437
+ const named = body.type;
438
+ const name = named !== undefined && named.kind === "Model" && named.name !== "" ? named.name : undefined;
439
+ return name === undefined ? shape : registry.declareNamed(name, () => shape);
440
+ }
441
+ /** `bytes`, or a model built on `File` — the parts openapi3 publishes as an empty schema. */
442
+ function isBinaryPart(type) {
443
+ if (type.kind === "Scalar") {
444
+ for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
445
+ if (scalar.name === "bytes")
446
+ return true;
447
+ }
448
+ return false;
449
+ }
450
+ if (type.kind !== "Model")
451
+ return false;
452
+ for (let model = type; model !== undefined; model = model.baseModel) {
453
+ if (model.name === "File")
454
+ return true;
455
+ }
456
+ return false;
457
+ }
458
+ /**
459
+ * Whether a body served under these media types is RAW BINARY rather than a JSON value.
460
+ *
461
+ * ⚠️ **The distinction decides whether a validator may say anything about it at all.** A `bytes`
462
+ * body inside a JSON payload is base64 and the document publishes `type: "string"` with
463
+ * `contentEncoding`; a `bytes` body served as `application/octet-stream` or `image/png` is the bytes
464
+ * themselves, and `@typespec/openapi3` publishes a schema carrying only `contentMediaType` — which is
465
+ * an ANNOTATION under JSON Schema 2020-12, not an assertion. The document therefore states nothing
466
+ * about the value, and a validator that requires a string enforces a rule no caller reading the
467
+ * contract can see.
468
+ *
469
+ * ⚠️ **The same decision was already made for multipart and not for bodies**, which is how the two
470
+ * came to disagree: a binary PART is `z.unknown()` because openapi3 publishes `{}` for it, while a
471
+ * binary BODY stayed `z.string()`. Found by grading response-body kinds, a surface that had been
472
+ * counted and never compared — three positions in `encode/bytes`.
473
+ */
474
+ export function isRawBinaryMediaType(contentTypes) {
475
+ if (contentTypes.length === 0)
476
+ return false;
477
+ return contentTypes.every((type) => !/^application\/(json|.*\+json)$/.test(type));
478
+ }
479
+ /** The declared request body type, if the operation takes one. */
480
+ function requestBodyOf(operation) {
481
+ const body = operation.parameters.body;
482
+ return body?.bodyKind === "single" ? body.type : undefined;
483
+ }
484
+ /**
485
+ * Parameters are emitted as their **own** schemas rather than folded into the body, because the two
486
+ * are separate things in HTTP and in OpenAPI — but they are equally part of the operation's input,
487
+ * and `?state=nonsense` must be a 400 rather than something the application has to defend against.
488
+ * Leaving them undescribed would document a constraint the runtime does not enforce, which is the
489
+ * same failure the vocabulary tuples exist to prevent one level down.
490
+ *
491
+ * ⚠️ **Split by WHERE they arrive, and only so.** A validator library validates one target at a time
492
+ * — `("param", …)`, `("query", …)` — which is how a caller learns *which* part of the request was
493
+ * wrong instead of being handed a merged object's complaint. A merged form existed beside these until
494
+ * 0.1.0 and was deleted: nothing read it, and it had drifted into a second set of rules. See
495
+ * `EmittedRoute.pathSchema`.
496
+ */
497
+ /**
498
+ * How a list is flattened into one query or header value, per OpenAPI's `style`.
499
+ *
500
+ * ⚠️ **Three of the four standard collection formats rejected every conformant request**, and only
501
+ * one of the three was visible to the differential. `?colors=blue,red,green` arrives as a single
502
+ * string; a validator expecting an array refuses it. For `pipes` and `ssv` the emitter also typed
503
+ * the parameter as a string, so document and validator disagreed and the oracle said so — but for
504
+ * `csv` both sides said `array` and **agreed perfectly** while the server 400'd every caller. No
505
+ * document-to-validator comparison can see that: the disagreement is with the WIRE.
506
+ *
507
+ * `explode: true` is the exception — the value repeats (`?colors=blue&colors=red`) and Hono hands
508
+ * back an array already, so there is nothing to undo.
509
+ */
510
+ const COLLECTION_DELIMITERS = {
511
+ "ArrayEncoding.pipeDelimited": "|",
512
+ "ArrayEncoding.spaceDelimited": " ",
513
+ "ArrayEncoding.commaDelimited": ",",
514
+ "ArrayEncoding.newlineDelimited": "\n",
515
+ };
516
+ /** Whether the declared type is a list, which is what makes a delimiter meaningful. */
517
+ function isCollectionParameter(property) {
518
+ return isArrayType(property.type);
519
+ }
520
+ /**
521
+ * The delimiter a parameter's values were joined with, or `undefined` when they were not joined.
522
+ *
523
+ * Defaults matter as much as the encodings: a query parameter with no `@encode` and no explode is
524
+ * `form` style, which OpenAPI joins with a comma, and a header list is `simple` style, also comma.
525
+ * Those are the two that looked correct because the document and the validator agreed.
526
+ */
527
+ function collectionDelimiterOf(program, parameter) {
528
+ if (!isCollectionParameter(parameter.param))
529
+ return undefined;
530
+ if (parameter.type !== "query" && parameter.type !== "header")
531
+ return undefined;
532
+ if (parameter.explode === true)
533
+ return undefined;
534
+ const encoding = getEncode(program, parameter.param)?.encoding;
535
+ if (encoding !== undefined && encoding in COLLECTION_DELIMITERS) {
536
+ return COLLECTION_DELIMITERS[encoding];
537
+ }
538
+ return ",";
539
+ }
540
+ /**
541
+ * Decode a path, query or header value from the only thing HTTP can carry: text.
542
+ *
543
+ * ⚠️ **A path or query parameter ALWAYS arrives as a string, and `type: integer` describes what it
544
+ * means once decoded — not what is on the wire.** `zValidator("param", …)` hands `c.req.param()`
545
+ * straight to the schema, so `z.number().int()` met `"1"` and refused it. Measured against a server
546
+ * generated from the Swagger Petstore and run under `wrangler dev`:
547
+ *
548
+ * ```
549
+ * GET /pet/findByStatus?status=available -> 200 (string)
550
+ * GET /user/zach -> 200 (string)
551
+ * GET /pet/1 -> 400 (integer) <- every conformant caller
552
+ * GET /store/order/1 -> 400 (integer)
553
+ * ```
554
+ *
555
+ * ⚠️ **Nothing saw it because nothing ever asked.** Every path and query parameter in
556
+ * `test/reference/service.tsp` — the only fixture that makes real requests — is a `string`. Across
557
+ * the corpus this emitter writes **29 numeric query and header parameters** and requests none of
558
+ * them. The validators were right about the document the whole time; the disagreement was with what
559
+ * arrives, which is the one thing no document comparison can see.
560
+ *
561
+ * ⚠️ **This DECODES, it does not coerce.** `z.coerce.number()` would have been one character of
562
+ * effort and wrong: `Number("")` is `0`, so `?limit=` would satisfy a required integer and an empty
563
+ * value the document forbids would sail through as zero. A value that is not a well-formed number is
564
+ * passed along UNCHANGED, so it fails against the schema the document published and reports the error
565
+ * the document justifies. Same rule as the collection split above — decode the wire form, then apply
566
+ * the document's schema to the result, never instead of it.
567
+ *
568
+ * Derived from the emitted expression rather than a restated list of scalar names: `SCALARS` in
569
+ * `zod.ts` decides what becomes `z.number()`, and a second copy of that list here would stop agreeing
570
+ * with it silently.
571
+ */
572
+ function wireDecoded(declared) {
573
+ const NUMBER = '(raw) => (typeof raw === "string" && raw.trim() !== "" && Number.isFinite(Number(raw)) ? Number(raw) : raw)';
574
+ const BOOLEAN = '(raw) => (raw === "true" ? true : raw === "false" ? false : raw)';
575
+ if (declared.startsWith("z.number()"))
576
+ return `z.preprocess(${NUMBER}, ${declared})`;
577
+ if (declared.startsWith("z.boolean()"))
578
+ return `z.preprocess(${BOOLEAN}, ${declared})`;
579
+ /**
580
+ * A list whose ELEMENTS are numeric or boolean — `?ids=1,2,3` after the collection split, or a
581
+ * repeated `?id=1&id=2`. The elements are strings for exactly the same reason the scalar is, so
582
+ * the same decoder applies per element. A non-array value is left alone; the split that produces
583
+ * the array runs outside this and may not have happened.
584
+ */
585
+ const element = /^z\.array\((z\.(?:number|boolean)\(\).*)\)$/s.exec(declared);
586
+ if (element !== null) {
587
+ const inner = element[1] ?? "";
588
+ const decoder = inner.startsWith("z.number()") ? NUMBER : BOOLEAN;
589
+ return `z.preprocess((raw) => (Array.isArray(raw) ? raw.map(${decoder}) : raw), ${declared})`;
590
+ }
591
+ return declared;
592
+ }
593
+ /**
594
+ * Decode a `content-type` header down to the media type the document names.
595
+ *
596
+ * ⚠️ **A media type is a type PLUS parameters, and the document states only the type.** `@header
597
+ * contentType: "multipart/form-data"` becomes `z.literal("multipart/form-data")`, which refuses
598
+ * `multipart/form-data; boundary=…` — and RFC 2046 §5.1.1 makes that boundary MANDATORY, so the
599
+ * validator rejected **every syntactically valid multipart request**. Measured under `wrangler dev`:
600
+ *
601
+ * ```
602
+ * expected "multipart/form-data"
603
+ * received "multipart/form-data; boundary=------------------------IQFk2KVVVwrsWuOSm212u0"
604
+ * ```
605
+ *
606
+ * Across the corpus this emitter writes **78 `z.literal` header validators, every one of them on
607
+ * `content-type`**, in 12 scenarios. Seventeen are `multipart/form-data` and fail 100% of the time;
608
+ * the remaining 61 refuse any caller sending the entirely legal `; charset=utf-8`.
609
+ *
610
+ * ⚠️ **Enforcing "no parameters" is enforcing something the document does not say**, which is the
611
+ * governing rule read in the direction that is easy to miss: the rule forbids inventing constraints
612
+ * just as much as it forbids dropping them. Stripping the parameters before comparing is the same
613
+ * move as decoding `"1"` to `1` — undo the transport form, then apply the document's schema to the
614
+ * result.
615
+ *
616
+ * Lowercased because RFC 9110 §8.3.1 makes media type comparison case-insensitive — but ONLY when the
617
+ * declared literal is itself lowercase, so a document that published an oddly-cased type keeps
618
+ * matching exactly rather than silently never matching. Every literal openapi3 publishes across the
619
+ * corpus is lowercase; this costs one condition and removes the way that fact could stop being true.
620
+ */
621
+ function mediaTypeDecoded(declared) {
622
+ /**
623
+ * ⚠️ **One accepted media type is a literal; several are an ENUM, and the second form is the one a
624
+ * real document produces.** The corpus writes 78 `content-type` validators and every one is a
625
+ * single literal, so a decoder that handled only `z.literal` looked complete against it. The
626
+ * Swagger Petstore's `addPet` accepts three media types and openapi3 publishes
627
+ * `z.enum(["application/json", "application/xml", "application/x-www-form-urlencoded"])` — which
628
+ * went on refusing `application/json; charset=utf-8` after the literal case was fixed. Measured:
629
+ * with the charset parameter **400**, without it **200**, same body.
630
+ */
631
+ const values = /^z\.literal\("([^"]+)"\)/.exec(declared)?.slice(1, 2) ??
632
+ /^z\.enum\(\[([^\]]+)\]\)/
633
+ .exec(declared)?.[1]
634
+ ?.split(",")
635
+ .map((entry) => entry.trim().replace(/^"|"$/g, ""));
636
+ if (values === undefined || values.length === 0)
637
+ return declared;
638
+ // Lowercased only when every declared value already is, so an oddly-cased one keeps matching.
639
+ const normalise = values.every((value) => value === value.toLowerCase()) ? ".toLowerCase()" : "";
640
+ return `z.preprocess((raw) => (typeof raw === "string" ? (raw.split(";")[0] ?? "").trim()${normalise} : raw), ${declared})`;
641
+ }
642
+ function parameterSchemasOf(program, operation, registry) {
643
+ const byTarget = {};
644
+ let accept;
645
+ for (const parameter of operation.parameters.parameters) {
646
+ if (parameter.type !== "path" && parameter.type !== "query" && parameter.type !== "header") {
647
+ continue;
648
+ }
649
+ /**
650
+ * ⚠️ **`parameter.name` is the WIRE name; `parameter.param.name` is the TypeSpec one.** Headers
651
+ * used to be keyed on the second, and it made every header validator reject every conformant
652
+ * request: `@header("webhook-id") id: string` produced `z.object({ id })`, while `zValidator`
653
+ * reads what actually arrived — a header called `webhook-id`. Measured: **400 against the
654
+ * emitted schema, 200 against the documented one**, on a webhook's signed headers.
655
+ *
656
+ * The wire name is also the right answer for path and query where the two differ
657
+ * (`@query("$select") select`), and it is what `propertyKey` already does for body properties
658
+ * via `resolveEncodedName` — the document publishes one name per parameter, and it is this one.
659
+ * The TypeSpec property name is an authoring convenience that does not survive to the contract.
660
+ */
661
+ const delimiter = collectionDelimiterOf(program, parameter);
662
+ const declared = registry.expressionForProperty(parameter.param);
663
+ /**
664
+ * The wire form is decoded before it is validated, never instead of it. `z.preprocess` undoes
665
+ * exactly the flattening the document's `style` describes and then applies the schema the
666
+ * document publishes, so every constraint on the list — `minItems`, the element type — still
667
+ * runs. Splitting in the handler instead would have left those unenforced.
668
+ */
669
+ /**
670
+ * Decoded BEFORE the split is wrapped around it, so the two nest in wire order: the outer
671
+ * preprocess turns `"1,2,3"` into `["1","2","3"]`, the inner one turns those into numbers, and
672
+ * the document's schema then validates the result. Wrapping the other way round would hand the
673
+ * element decoder an unsplit string.
674
+ */
675
+ const decoded = parameter.type === "header" && parameter.name.toLowerCase() === "content-type"
676
+ ? mediaTypeDecoded(declared)
677
+ : wireDecoded(declared);
678
+ const expression = delimiter === undefined
679
+ ? decoded
680
+ : `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${JSON.stringify(delimiter)}) : raw), ${decoded})`;
681
+ const entry = `\t${objectKey(parameter.name)}: ${expression},`;
682
+ (byTarget[parameter.type] ??= []).push(entry);
683
+ /**
684
+ * ⚠️ **On a route several operations share, `accept` SELECTS the operation — it is not a
685
+ * constraint on one of them.** Each member declares its own literal, so validating against any
686
+ * single member's would 400 every request asking for one of the others, and 400 is the wrong
687
+ * answer twice over: the request is well-formed, and the contract's answer to "I cannot serve
688
+ * what you asked for" is 406. `selectContentType` decides it instead, so the negotiated route
689
+ * validates every OTHER header and leaves this one to the negotiation.
690
+ */
691
+ if (parameter.type === "header" && parameter.name.toLowerCase() === "accept") {
692
+ const literal = parameter.param.type;
693
+ if (literal.kind === "String")
694
+ accept = { name: parameter.name, value: literal.value };
695
+ }
696
+ else if (parameter.type === "header") {
697
+ (byTarget["header-without-accept"] ??= []).push(entry);
698
+ }
699
+ }
700
+ const shape = (target) => {
701
+ const entries = byTarget[target];
702
+ return entries === undefined ? undefined : `z.object({\n${entries.join("\n")}\n})`;
703
+ };
704
+ return {
705
+ path: shape("path"),
706
+ query: shape("query"),
707
+ header: shape("header"),
708
+ negotiatedHeader: shape("header-without-accept"),
709
+ accept,
710
+ };
711
+ }
712
+ export function collectRoutes(program, registry, service) {
713
+ const routes = [];
714
+ {
715
+ for (const operation of service.operations) {
716
+ const statusCode = successStatusOf(operation);
717
+ if (statusCode === undefined)
718
+ continue;
719
+ // No body is a legitimate success — see `EmittedRoute.responseSchema`. Skipping here is what
720
+ // dropped 282 operations across the conformance corpus without a word.
721
+ const responseType = successBodyOf(operation, statusCode);
722
+ const requestType = requestBodyOf(operation);
723
+ /**
724
+ * ⚠️ **A streamed operation's EVENT payloads are not reachable from its body.**
725
+ *
726
+ * `op receive(): SSEStream<UnnamedEvents>` has a response body of `string` — the event
727
+ * stream — so the walk above sees a scalar and stops, and `Unnamed.Info` gets no validator
728
+ * even though the document publishes it as a component. Declaring the union here reaches
729
+ * every payload through it, which is the same set openapi3 emits. The result is discarded:
730
+ * the route's response schema is still the stream, and this exists so the vocabulary a
731
+ * consumer needs to check each `data:` against is actually generated.
732
+ */
733
+ const streamed = streamedTypeOf(program, operation.operation.returnType);
734
+ if (streamed !== undefined) {
735
+ withVisibility(program, Visibility.Read, () => registry.expressionFor(streamed));
736
+ }
737
+ // `@requestVisibility` when the operation states one, otherwise the verb's default — the
738
+ // same resolution `@typespec/openapi3` performs before emitting a request schema.
739
+ const requestVisibility = resolveRequestVisibility(program, operation.operation, operation.verb);
740
+ const statusCodes = successStatusesOf(operation);
741
+ const discriminator = statusDiscriminatorOf(operation, statusCodes);
742
+ /**
743
+ * A `bytes` body means the BYTES are the contract.
744
+ *
745
+ * A webhook's MAC covers exactly what arrived, so a parsed-and-re-serialised object would
746
+ * verify a different string and every delivery would fail authentication.
747
+ */
748
+ const bodyParameter = operation.parameters.body;
749
+ const rawBodyProperty = requestType?.kind === "Scalar" && requestType.name === "bytes"
750
+ ? (bodyParameter?.property?.name ?? "body")
751
+ : undefined;
752
+ routes.push({
753
+ operationId: resolveOperationId(program, operation.operation),
754
+ verb: operation.verb.toUpperCase(),
755
+ /**
756
+ * The path TEMPLATE, exactly as the document publishes it — `/widgets/{widget-id}`.
757
+ *
758
+ * ⚠️ **This used to be a router's path, converted here.** `toHonoPath` ran during
759
+ * collection and rewrote `{widget-id}` to `:widget-id`, so the shared intermediate
760
+ * representation carried one framework's spelling of a contract fact — and a template
761
+ * that router could not mount refused the whole operation, validators included.
762
+ *
763
+ * A server emitter converts this when it renders, and refuses what it cannot mount with
764
+ * a diagnostic of its own. The validators for such an operation are still correct and
765
+ * still emitted: what a request body must look like does not depend on whether some
766
+ * particular router can express the path.
767
+ */
768
+ path: operation.path,
769
+ statusCode,
770
+ statusCodes,
771
+ responseContentTypes: responseContentTypesOf(operation, statusCode),
772
+ requestContentTypes: [...(operation.parameters.body?.contentTypes ?? [])],
773
+ /**
774
+ * `@summary` first, `@doc` as the fallback.
775
+ *
776
+ * OpenAPI distinguishes them — `summary` is the short line, `description` the verbose
777
+ * explanation — and `@typespec/openapi3` maps them to exactly those. A spec whose operation
778
+ * strings are one-liners means `summary`; written as `@doc` they land in `description`
779
+ * instead and leave `summary` empty on every operation. The fallback keeps a spec that
780
+ * uses `@doc` working.
781
+ */
782
+ summary: getSummary(program, operation.operation) ?? getDoc(program, operation.operation),
783
+ /**
784
+ * ⚠️ **Each position is walked at ITS OWN visibility, which is the whole point.** A model
785
+ * used as a request body and as a response is two shapes: `@statusCode` is applicable to
786
+ * one and not the other, and `@visibility` can add or remove properties outright. Walking
787
+ * both at one visibility gives whichever was seen first to both.
788
+ */
789
+ requestSchema: withVisibility(program, requestVisibility, () => {
790
+ // Multipart first: its body has no single type to walk, and skipping it left every
791
+ // such route mounted with nothing checking the request at all.
792
+ const parameterBody = operation.parameters.body;
793
+ if (parameterBody?.bodyKind === "multipart") {
794
+ return multipartSchemaOf(parameterBody, registry);
795
+ }
796
+ return requestType === undefined ? undefined : registry.expressionFor(requestType);
797
+ }),
798
+ ...(() => {
799
+ const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry));
800
+ return {
801
+ pathSchema: split.path,
802
+ querySchema: split.query,
803
+ headerSchema: split.header,
804
+ negotiatedHeaderSchema: split.negotiatedHeader,
805
+ accept: split.accept,
806
+ };
807
+ })(),
808
+ responseSchema: responseType === undefined
809
+ ? undefined
810
+ : withVisibility(program, Visibility.Read, () =>
811
+ /**
812
+ * A raw binary body is `z.unknown()`, matching what a binary multipart part
813
+ * already emits and what the document actually asserts — see
814
+ * {@link isRawBinaryMediaType}.
815
+ */
816
+ isBinaryPart(responseType) &&
817
+ isRawBinaryMediaType(responseContentTypesOf(operation, statusCode))
818
+ ? "z.unknown()"
819
+ : registry.expressionFor(responseType)),
820
+ rawBodyProperty,
821
+ // Responses are read at Visibility.Read, exactly as the success body above is. Resolving
822
+ // them under the ambient request visibility declared six components under the wrong
823
+ // suffix — measured: type/model/visibility gained six divergences.
824
+ errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
825
+ scopes: [
826
+ ...new Set((getAuthenticationForOperation(program, operation.operation)?.options ?? []).flatMap((option) => option.schemes.flatMap((scheme) => scheme.type === "oauth2"
827
+ ? scheme.flows.flatMap((flow) => flow.scopes.map((scope) => scope.value))
828
+ : []))),
829
+ ],
830
+ noAuth: noAuthFor(program, operation),
831
+ statusBy: discriminator,
832
+ /**
833
+ * ⚠️ **Each arm is validated against its OWN shape.**
834
+ *
835
+ * A consumer checks its value against the arm's schema, and with one schema
836
+ * for two statuses the acknowledged response was checked against the resolved arm's
837
+ * shape — a 502 on a perfectly good 202.
838
+ */
839
+ alternateResponseSchema: discriminator === undefined
840
+ ? undefined
841
+ : alternateSchemaFor(operation, discriminator.status, registry),
842
+ });
843
+ }
844
+ }
845
+ return routes;
846
+ }
847
+ /**
848
+ * Emit only the external names a given file actually references.
849
+ *
850
+ * Both generated files can need them — `schemas.gen.ts` for the vocabulary tuples and `@refine`
851
+ * predicates, and `routes.gen.ts` when an inline params schema carries a refined scalar.
852
+ * Emitting the whole set into both would leave unused imports (which lint rejects); emitting it into
853
+ * neither is what left `routes.gen.ts` referencing `isParsableInstant` with no import at all.
854
+ */
855
+ function renderExternalImports(externals, ...bodies) {
856
+ const haystack = bodies.join("\n");
857
+ const lines = [];
858
+ for (const [module, names] of externals) {
859
+ const used = [...names].filter((name) => new RegExp(`\\b${name}\\b`).test(haystack));
860
+ if (used.length > 0) {
861
+ lines.push(`import { ${used.toSorted().join(", ")} } from ${JSON.stringify(module)};\n`);
862
+ }
863
+ }
864
+ return lines.join("");
865
+ }
866
+ /**
867
+ * Every validator this service needs, in one module: the component schemas, and the per-operation
868
+ * request and response validators.
869
+ *
870
+ * ⚠️ **The per-operation half used to be emitted by the SERVER generator, into the server file.** So
871
+ * a package advertising *TypeSpec HTTP → Zod* produced component schemas and nothing a caller could
872
+ * validate a request or a response against, and every oracle that graded those validators had to read
873
+ * a server to find them. See {@link nameRouteSchemas}.
874
+ */
875
+ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
876
+ const declarations = registry
877
+ .declarations()
878
+ .map((d) => `\nexport const ${d.identifier} = ${d.source};\nexport type ${d.typeName} = z.infer<typeof ${d.identifier}>;\n`);
879
+ const parts = [GENERATED_BANNER, '\nimport { z } from "zod";\n'];
880
+ /**
881
+ * A TYPE import, so nothing of this package's survives into the emitted JavaScript.
882
+ *
883
+ * The annotation is what makes a misspelled or omitted `schema` a compile error rather than an arm
884
+ * that silently validates nothing — see the option's own docblock for the measurement. Emitted only
885
+ * when there are arms to annotate, because an unused import fails the lint a generated file has to
886
+ * pass like any other.
887
+ */
888
+ if (routeDeclarations.length > 0) {
889
+ parts.push(`import type { ResponseArm } from ${JSON.stringify(runtimeModule)};\n`);
890
+ }
891
+ parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
892
+ parts.push(declarations.join(""));
893
+ if (routeDeclarations.length > 0)
894
+ parts.push(`\n${routeDeclarations.join("\n")}\n`);
895
+ return parts.join("");
896
+ }
897
+ /**
898
+ * The IDENTIFIER a schema can be referred to by, which `z.infer<typeof …>` requires.
899
+ *
900
+ * A route's schema is often an expression rather than a declaration — `z.array(widgetSchema)`,
901
+ * `z.object({…})` for inline parameters — and `typeof z.array(widgetSchema)` is not valid
902
+ * TypeScript. Anything that is not already a bare identifier gets a name here; anything that is
903
+ * keeps it, so the generated file does not fill up with aliases that alias nothing.
904
+ */
905
+ const isIdentifier = (expression) => /^[A-Za-z_$][\w$]*$/.test(expression);
906
+ const schemaConst = (operationId, suffix) => `${operationId}${suffix}`;
907
+ /** `[the name to refer to it by, the `const` line to emit]` — the line is absent when none is needed. */
908
+ function namedSchema(operationId, suffix, expression) {
909
+ if (expression === undefined)
910
+ return { name: undefined, declaration: undefined };
911
+ if (isIdentifier(expression))
912
+ return { name: expression, declaration: undefined };
913
+ const name = schemaConst(operationId, suffix);
914
+ /**
915
+ * ⚠️ **Exported, because a schema nothing outside this file can name is a schema nothing can
916
+ * check.** These are the validators the server actually enforces on every request — the path,
917
+ * query and header shapes — and while they were module-local the only thing that could read them
918
+ * was the generated code itself. That is how a header validator keyed on the wrong name shipped:
919
+ * the conformance differential compares `components.schemas`, a parameter schema is not a
920
+ * component, and there was no third artefact to compare against even in principle.
921
+ *
922
+ * Exporting also gives an application the request shape by name, which is what the per-operation
923
+ * type aliases will be built from.
924
+ */
925
+ return { name, declaration: `export const ${name} = ${expression};` };
926
+ }
927
+ /**
928
+ * Which `verb path` slots carry more than one operation.
929
+ *
930
+ * ⚠️ **A DOCUMENT fact, not a router one, which is why it lives here.** TypeSpec models content
931
+ * negotiation as several operations on one route; OpenAPI models it as a single path item whose
932
+ * response lists every media type. A route reached that way must not validate `accept` against any
933
+ * one member's literal — `accept` SELECTS the operation, so validating it would answer `400` to a
934
+ * well-formed request whose real answer is `406`. That is why `negotiatedHeaderSchema` exists, and
935
+ * deciding which of the two applies is what this set is for.
936
+ */
937
+ function sharedSlots(routes) {
938
+ const shared = new Set();
939
+ const seen = new Set();
940
+ for (const route of routes) {
941
+ const slot = `${route.verb} ${route.path}`;
942
+ if (seen.has(slot))
943
+ shared.add(slot);
944
+ seen.add(slot);
945
+ }
946
+ return shared;
947
+ }
948
+ /**
949
+ * Declare every per-operation validator, and hand back the names they were declared under.
950
+ *
951
+ * ⚠️ **These used to be declared by the SERVER emitter, into the server file.** Six identifiers per
952
+ * operation — the path, query and header validators, the request body, the success response and the
953
+ * response arms — were built and emitted inside `renderApp`, which had three consequences and none of
954
+ * them were intended:
955
+ *
956
+ * 1. a package whose entire claim is *TypeSpec HTTP → Zod* emitted **no** request or response
957
+ * validator that a consumer could name. Component schemas, and nothing to check a call against;
958
+ * 2. every oracle that graded them had to read the SERVER file, so splitting this library out would
959
+ * have taken the parameter, response and response-body arms with it and left component agreement
960
+ * as the only thing still measured — three of the four largest arms, lost silently;
961
+ * 3. two emitters would have had to mint identical identifiers by coincidence, which is the coupling
962
+ * that made a library-plus-emitter split look impossible in the first place.
963
+ *
964
+ * Declaring them here dissolves all three: the library mints the names AND emits them, and a server
965
+ * emitter imports what it is told rather than agreeing about it.
966
+ */
967
+ function nameRouteSchemas(routes) {
968
+ const shared = sharedSlots(routes);
969
+ const declarations = [];
970
+ const names = new Map();
971
+ for (const route of routes) {
972
+ const declare = (suffix, expression) => {
973
+ const resolved = namedSchema(route.operationId, suffix, expression);
974
+ if (resolved.declaration !== undefined)
975
+ declarations.push(resolved.declaration);
976
+ return resolved.name;
977
+ };
978
+ // Order follows the request: what identifies the resource, then what filters it, then its body.
979
+ const path = declare("Path", route.pathSchema);
980
+ const query = declare("Query", route.querySchema);
981
+ const negotiating = shared.has(`${route.verb} ${route.path}`);
982
+ const header = declare("Header", negotiating ? route.negotiatedHeaderSchema : route.headerSchema);
983
+ // A raw body is read as text, never parsed, so it has no validator — the bytes are the contract.
984
+ const body = route.rawBodyProperty === undefined ? declare("Body", route.requestSchema) : undefined;
985
+ const response = declare("Response", route.responseSchema);
986
+ const alternateResponse = declare("AlternateResponse", route.alternateResponseSchema);
987
+ const responses = schemaConst(route.operationId, "Responses");
988
+ declarations.push(`export const ${responses} = ${responseArmsOf(route, response, alternateResponse)} satisfies readonly ResponseArm[];`);
989
+ names.set(route.operationId, {
990
+ operationId: route.operationId,
991
+ path,
992
+ query,
993
+ header,
994
+ body,
995
+ response,
996
+ alternateResponse,
997
+ responses,
998
+ });
999
+ }
1000
+ return { names, declarations };
1001
+ }
1002
+ /**
1003
+ * What an operation may answer with, as a literal — each declared status paired with the body the
1004
+ * document gives it, in OpenAPI's own precedence order.
1005
+ *
1006
+ * ⚠️ **This docblock was orphaned in the source it came from**, sitting above an unrelated function
1007
+ * while the one it describes had none. Reattached.
1008
+ */
1009
+ function responseArmsOf(route, response, alternate) {
1010
+ const arms = [];
1011
+ const primary = response ?? "undefined";
1012
+ if (route.statusBy === undefined) {
1013
+ arms.push(`{ status: ${route.statusCode}, schema: ${primary} }`);
1014
+ }
1015
+ else {
1016
+ const alternateSchema = alternate ?? "undefined";
1017
+ const { property, value, status } = route.statusBy;
1018
+ arms.push(`{ status: ${status}, schema: ${alternateSchema}, when: { property: ${JSON.stringify(property)}, value: ${JSON.stringify(value)} } }`, `{ status: ${route.statusCode}, schema: ${primary} }`);
1019
+ }
1020
+ /**
1021
+ * The failure arms, which used to be dropped entirely.
1022
+ *
1023
+ * ⚠️ **The document declares what an error body looks like and the server never passed it on**, so
1024
+ * `respond` could not check what it sends on failure even in principle.
1025
+ *
1026
+ * ⚠️ **This used to fall back to the service-wide error schema, and the fallback was the defect.**
1027
+ * `arm.schema ?? errorSchema` was reached by every arm the emitter could not resolve a body for —
1028
+ * which, while `"default"` was deliberately given none, meant every catch-all in every service
1029
+ * answered with whichever shape `errorBodyOf` had settled on rather than the one its own response
1030
+ * declares. Each arm resolves its own body now, so an arm with no schema is a response the document
1031
+ * says carries no body, and saying `undefined` is the truthful answer rather than a guess.
1032
+ */
1033
+ for (const arm of route.errorArms) {
1034
+ arms.push(`{ status: ${JSON.stringify(arm.status)}, schema: ${arm.schema ?? "undefined"} }`);
1035
+ }
1036
+ return `[${arms.join(", ")}]`;
1037
+ }
1038
+ const upperFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
1039
+ /**
1040
+ * The effective input of every operation, as TypeScript: its body, with the path, query and header
1041
+ * parameters merged over it.
1042
+ *
1043
+ * ⚠️ **This used to drop any parameter literally named `companyId`.** One application carried its
1044
+ * tenant identifier in a context object rather than in the payload, and the emitter was taught to
1045
+ * match — so for every consumer, a path parameter with that name vanished from the generated input
1046
+ * type while the Zod validator went on requiring it. Two artefacts describing different shapes, from
1047
+ * a rule no document states.
1048
+ *
1049
+ * It is the same defect class this emitter exists to delete, and it hid better than most: the
1050
+ * emitted assertions pair the validator against the contract types for MODELS, so a disagreement
1051
+ * about a merged input type is not among the things they compare.
1052
+ */
1053
+ function collectRequestTypes(program, registry, service) {
1054
+ const entries = [];
1055
+ {
1056
+ for (const operation of service.operations) {
1057
+ if (successStatusOf(operation) === undefined)
1058
+ continue;
1059
+ const body = requestBodyOf(operation);
1060
+ /**
1061
+ * Headers are part of the input too.
1062
+ *
1063
+ * The webhook's three Standard-Webhooks headers are inside the MAC — they are not metadata
1064
+ * beside the payload, they are arguments to the verification. Leaving them out of the wire
1065
+ * type would describe an input the application cannot act on.
1066
+ */
1067
+ const extraProperties = operation.parameters.parameters
1068
+ .filter((parameter) => parameter.type === "path" || parameter.type === "query" || parameter.type === "header")
1069
+ .map((parameter) => registry.expressionForProperty(parameter.param));
1070
+ if (body === undefined && extraProperties.length === 0)
1071
+ continue;
1072
+ /**
1073
+ * A `bytes` body reaches the use-case as a NAMED property holding the unparsed text, not as
1074
+ * a bare string — so the wire type is `{ rawBody: string }`, matching what a server
1075
+ * actually builds. Referencing the scalar directly would type the whole input as `string`
1076
+ * and quietly drop the headers beside it.
1077
+ */
1078
+ const rawBody = body?.kind === "Scalar" && body.name === "bytes"
1079
+ ? (operation.parameters.body?.property?.name ?? "body")
1080
+ : undefined;
1081
+ entries.push({
1082
+ operationId: resolveOperationId(program, operation.operation),
1083
+ bodyRef: rawBody !== undefined
1084
+ ? `{\n\t${rawBody}: string;\n}`
1085
+ : body === undefined
1086
+ ? undefined
1087
+ : registry.expressionFor(body),
1088
+ extraProperties,
1089
+ });
1090
+ }
1091
+ }
1092
+ return entries;
1093
+ }
1094
+ /**
1095
+ * The success response every operation declares, so a producer can be held to producing it.
1096
+ *
1097
+ * ⚠️ **Pinned fields only — these are PERMISSIVE shapes.** The gateway names the fields it depends on
1098
+ * and lets a richer view through untouched, so this is a floor and not an equality: the
1099
+ * domain's result must CONTAIN what the document promises, and may contain more. Asserting equality
1100
+ * would turn every field a producer legitimately adds into a build failure, which is the same mistake
1101
+ * as making a response schema strict.
1102
+ */
1103
+ function collectResponseTypes(program, registry, service) {
1104
+ const entries = [];
1105
+ {
1106
+ for (const operation of service.operations) {
1107
+ const status = successStatusOf(operation);
1108
+ if (status === undefined)
1109
+ continue;
1110
+ const body = successBodyOf(operation, status);
1111
+ if (body === undefined)
1112
+ continue;
1113
+ entries.push({
1114
+ operationId: resolveOperationId(program, operation.operation),
1115
+ ref: registry.expressionFor(body),
1116
+ });
1117
+ }
1118
+ }
1119
+ return entries;
1120
+ }
1121
+ /**
1122
+ * `requests.gen.ts` — the shape both sides are checked against.
1123
+ *
1124
+ * Plain TypeScript with no imports at all, so the contract types stay framework-free and a layering
1125
+ * rule banning a validation library from the rest of the codebase is untouched. `wire-contract.gen.ts`
1126
+ * asserts the emitted Zod infers exactly this; a consumer asserts its own code produces it. Neither
1127
+ * imports the other, and a disagreement that would otherwise answer 400 on every request becomes a
1128
+ * typecheck failure.
1129
+ */
1130
+ function renderRequestTypes(entries, responses, registry) {
1131
+ const declarations = registry.declarations().map((declaration) => {
1132
+ // A vocabulary alias stays module-local: the vocabularies artefact already exports these names
1133
+ // as runtime tuples, and exporting both collides on every one.
1134
+ if (declaration.isVocabulary)
1135
+ return `\ntype ${declaration.name} = ${declaration.source};\n`;
1136
+ return declaration.isObject
1137
+ ? `\nexport interface ${declaration.name} ${declaration.source}\n`
1138
+ : `\nexport type ${declaration.name} = ${declaration.source};\n`;
1139
+ });
1140
+ const inputs = entries.map((entry) => {
1141
+ const parts = [];
1142
+ if (entry.bodyRef !== undefined)
1143
+ parts.push(entry.bodyRef);
1144
+ if (entry.extraProperties.length > 0) {
1145
+ parts.push(`{\n${entry.extraProperties.map((line) => `\t${line}`).join("\n")}\n}`);
1146
+ }
1147
+ return (`\n/** The effective input for \`${entry.operationId}\` — its body, with path and query parameters merged over it. */\n` +
1148
+ `export type ${upperFirst(entry.operationId)}Input = Simplify<${parts.join(" & ")}>;\n`);
1149
+ });
1150
+ /**
1151
+ * The same set again, keyed by operation id.
1152
+ *
1153
+ * TypeScript cannot index a module's *type* exports by a computed name — `typeof Contracts` carries
1154
+ * only the value side, so `Contracts["AppointOfficerInput"]` does not resolve. Both sides'
1155
+ * assertions are written as mapped types over every operation, and this is what makes that
1156
+ * possible. Generated with the aliases above, so it cannot fall behind them.
1157
+ */
1158
+ const responseLookup = responses
1159
+ .map((entry) => `\t${entry.operationId}: Produced<${entry.ref}>;`)
1160
+ .join("\n");
1161
+ const lookup = entries
1162
+ .map((entry) => `\t${entry.operationId}: ${upperFirst(entry.operationId)}Input;`)
1163
+ .join("\n");
1164
+ return `${GENERATED_BANNER}
1165
+ /**
1166
+ * Flattens an intersection into a single object type.
1167
+ *
1168
+ * Without it, \`Body & { filingId: string }\` and the equivalent flat object are structurally the
1169
+ * same but are NOT equal under the \`Equals<>\` conditional-type trick — so the assertions that pair
1170
+ * this file with the emitted Zod would fail on correct code.
1171
+ */
1172
+ type Simplify<T> = { [K in keyof T]: T[K] } & {};
1173
+
1174
+ /**
1175
+ * The same shape, tolerant of \`readonly\`.
1176
+ *
1177
+ * A producer's result is asserted assignable to its published shape, and a codebase commonly declares
1178
+ * views with \`readonly\` properties and \`readonly T[]\` arrays — neither of which is assignable to a
1179
+ * mutable one. Mutability is not a fact about a wire shape: it says nothing about what a caller
1180
+ * receives. Making the TARGET readonly accepts both, which is the permissive direction; the reverse
1181
+ * would reject every view a producer actually returns.
1182
+ */
1183
+ type Produced<T> = T extends readonly (infer Element)[]
1184
+ ? readonly Produced<Element>[]
1185
+ : T extends object
1186
+ ? { readonly [K in keyof T]: Produced<T[K]> }
1187
+ : T;
1188
+ ${declarations.join("")}${inputs.join("")}
1189
+ /** Every operation's effective input, keyed by operation id — what both sides assert against. */
1190
+ export interface WireInputs {
1191
+ ${lookup}
1192
+ }
1193
+
1194
+ /**
1195
+ * Every operation's success response, keyed by operation id.
1196
+ *
1197
+ * ⚠️ **A floor, not an equality.** These shapes are permissive: a caller pins the fields it depends
1198
+ * on and lets a richer view through, so a producer's result must CONTAIN what the document promises
1199
+ * and may contain more. This is what makes a published response checkable at all — measured once, a
1200
+ * route advertised a field it had never returned, and nothing anywhere could see it.
1201
+ */
1202
+ export interface WireOutputs {
1203
+ ${responseLookup}
1204
+ }
1205
+ `;
1206
+ }
1207
+ /**
1208
+ * The validator's half of the wire contract: the emitted Zod must infer **exactly** the shared type.
1209
+ *
1210
+ * Two artefacts describe one contract — the runtime validator and the framework-free type a codebase
1211
+ * passes around — and a shared declaration compared against only one of them lets the other drift
1212
+ * into it unnoticed. This asserts the validator *produces* the shared shape; a consumer that also
1213
+ * asserts its own code *accepts* that shape has both directions pinned.
1214
+ *
1215
+ * ⚠️ This is also the only thing that catches the emitter disagreeing with **itself**. `zod.ts` and
1216
+ * `types.ts` walk the same TypeSpec twice, once to Zod and once to TypeScript, and a construct one
1217
+ * handles differently from the other produces two artefacts that describe different shapes while
1218
+ * both look right in isolation. That happened during this emitter's first run: a vocabulary in a
1219
+ * query-parameter position was inlined by one walk and referenced by the other.
1220
+ *
1221
+ * `Equals` here, not assignability — `z.infer` is the output type, and the wire type is emitted with
1222
+ * defaulted properties required for exactly that reason, so the two really are equal.
1223
+ */
1224
+ function renderWireAssertions(schemas, types, entries, contractsPackage) {
1225
+ // The assertions pair the emitted Zod against the request TYPES, which live in the contracts
1226
+ // package. With no package named there is nothing to import them from and nothing to pair — the
1227
+ // types are still written to `contracts-output-dir`, they simply have no shared specifier yet.
1228
+ if (contractsPackage === undefined)
1229
+ return undefined;
1230
+ const requestModels = new Set(entries.map((entry) => entry.bodyRef).filter((ref) => ref !== undefined));
1231
+ const paired = [];
1232
+ for (const declaration of types.declarations()) {
1233
+ if (!requestModels.has(declaration.name))
1234
+ continue;
1235
+ const schema = schemas
1236
+ .declarations()
1237
+ .find((candidate) => candidate.typeName === declaration.name);
1238
+ if (schema === undefined)
1239
+ continue;
1240
+ paired.push({ name: declaration.name, identifier: schema.identifier });
1241
+ }
1242
+ if (paired.length === 0)
1243
+ return undefined;
1244
+ const assertions = paired.map((pair) => `export type Assert${pair.name} = MustHold<Identical<z.infer<typeof ${pair.identifier}>, Contracts.${pair.name}>>;`);
1245
+ return `${GENERATED_BANNER}
1246
+ import type { z } from "zod";
1247
+ import type * as Contracts from "${contractsPackage}";
1248
+ import type {
1249
+ ${paired.map((pair) => `\t${pair.identifier},`).join("\n")}
1250
+ } from "./schemas.gen.js";
1251
+
1252
+ /**
1253
+ * Exact structural equality. The naive \`A extends B ? B extends A\` form reports two object types
1254
+ * equal when one has an optional property the other lacks, which is the whole class of drift this
1255
+ * exists to catch; the identity-function comparison does not.
1256
+ */
1257
+ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
1258
+ ? true
1259
+ : ["the emitted validator and the shared wire type describe different shapes"];
1260
+
1261
+ /**
1262
+ * \u26a0\ufe0f **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
1263
+ * type when the two disagree, and a type alias to an odd type compiles fine \u2014 66 assertions that
1264
+ * assert nothing. Only a constraint violation is an error.
1265
+ */
1266
+ type MustHold<T extends true> = T;
1267
+
1268
+ ${assertions.join("\n")}
1269
+ `;
1270
+ }
1271
+ /**
1272
+ * `vocabularies.gen.ts` — every vocabulary the surface reaches, as runtime tuples.
1273
+ *
1274
+ * Keyed by enum name rather than emitted as individually-named tuples, because the constant names a
1275
+ * consumer wants are English plurals and some of them are irregular. An emitter that pluralises is an
1276
+ * emitter that will one day invent `CAPACITYS` and silently create a second constant beside the real
1277
+ * one. So the emitter states the fact and the consumer owns the naming.
1278
+ */
1279
+ function renderVocabularies(vocabularies) {
1280
+ const entries = [...vocabularies.entries()]
1281
+ .toSorted(([a], [b]) => a.localeCompare(b))
1282
+ .map(([name, values]) => `\t${name}: [${values.map((v) => JSON.stringify(v)).join(", ")}],`);
1283
+ return `${GENERATED_BANNER}
1284
+ /**
1285
+ * The spec's vocabularies — every enum the surface reaches, and what each one contains.
1286
+ *
1287
+ * \`as const\` so each is a readonly tuple of literals — the type every consumer derives with
1288
+ * \`(typeof X)[number]\` keeps working unchanged.
1289
+ */
1290
+ export const SPEC_VOCABULARIES = {
1291
+ ${entries.join("\n")}
1292
+ } as const;
1293
+ `;
1294
+ }
1295
+ /** What the generated files import their runtime contract from when nothing says otherwise. */
1296
+ export const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
1297
+ /**
1298
+ * Emit this library's artefacts for every `@service` in the program, and hand back what was emitted.
1299
+ *
1300
+ * ⚠️ **The whole orchestration lives here, once.** The nesting below is load-bearing — a walk that
1301
+ * runs outside `collectExternalImports` throws, vocabularies must be collected from what the service
1302
+ * DECLARES rather than from what the Zod walk happens to touch, and the error body has to be
1303
+ * registered inside the walk or its validator never reaches `schemas.gen.ts`. An emitter built on this
1304
+ * library calls this rather than restating it: two copies of a sequence that must agree are two
1305
+ * copies that will not, and the symptom would be a subtly different set of declarations in a file
1306
+ * whose name is the contract.
1307
+ */
1308
+ export async function emitHttpZod(context, wrapper = {}) {
1309
+ const options = context.options;
1310
+ const defaultRuntimeModule = wrapper.defaultRuntimeModule ?? DEFAULT_RUNTIME_MODULE;
1311
+ /**
1312
+ * Each service already projected to the version it currently serves — see `versioning.ts`.
1313
+ *
1314
+ * ⚠️ **This used to be a bare `getAllHttpServices`, which is the UNION of every version.** A spec
1315
+ * that removes an operation in v2 had it mounted anyway, and a spec that removed a property had it
1316
+ * still required. Both while the document said otherwise.
1317
+ */
1318
+ const versioning = await resolveVersioningModule();
1319
+ const snapshots = serviceSnapshots(context.program, versioning);
1320
+ /**
1321
+ * Resolved once, here, because the walk below is synchronous and this is not. `undefined` when the
1322
+ * optional stream libraries are not installed, which is the normal case — see `streams.ts`.
1323
+ */
1324
+ const streamModule = await resolveStreamModule();
1325
+ const emitted = [];
1326
+ /**
1327
+ * ⚠️ **One emission per `@service`, with its own registry and its own import collector.**
1328
+ *
1329
+ * Sharing either across services merges one surface's schemas and imports into the other's file —
1330
+ * an unused import if you are lucky and a missing one if you are not, and a public document
1331
+ * carrying internal models if you are not paying attention. It is invisible while there is one
1332
+ * service, which is exactly why it is built this way before the second one lands rather than after.
1333
+ */
1334
+ for (const snapshot of snapshots) {
1335
+ const service = snapshot.service;
1336
+ const serviceName = service.namespace.name;
1337
+ const perService = options.services?.[serviceName];
1338
+ const outputDir = perService?.["output-dir"] ?? context.emitterOutputDir;
1339
+ const resolved = {
1340
+ contractsOutputDir: perService?.["contracts-output-dir"] ?? options["contracts-output-dir"],
1341
+ contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
1342
+ sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
1343
+ keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
1344
+ runtimeModule: perService?.["runtime-module"] ?? options["runtime-module"] ?? defaultRuntimeModule,
1345
+ };
1346
+ const registry = new SchemaRegistry(context.program);
1347
+ const { result: { routes }, imports: externals, vocabularies, } = collectExternalImports(() => withContractsPackage(resolved.contractsPackage, () => withSealedObjects(resolved.sealObjectSchemas, () => {
1348
+ // Declarations first: a vocabulary is a fact about the contract, so it is collected from
1349
+ // what the service DECLARES rather than from what the Zod walk happens to touch. An enum
1350
+ // reachable only through an error body was silently absent otherwise.
1351
+ noteDeclaredVocabularies(service.namespace.enums.values());
1352
+ /**
1353
+ * A closed key SET is a vocabulary too, and it cannot be an enum.
1354
+ *
1355
+ * TypeSpec has no key-constrained `Record`, so a model naming its permitted properties is
1356
+ * how the document states one — and a generated type cannot be iterated, which leaves a
1357
+ * consumer needing the set at run time with a hand-maintained copy. Naming the model in
1358
+ * `key-vocabularies` writes the keys beside the types. A name matching nothing is
1359
+ * reported rather than quietly emitting no tuple.
1360
+ */
1361
+ const emittedKeyVocabularies = noteKeyVocabularies(context.program, service.namespace.models.values(), resolved.keyVocabularies);
1362
+ for (const name of resolved.keyVocabularies) {
1363
+ if (emittedKeyVocabularies.includes(name))
1364
+ continue;
1365
+ reportDiagnostic(context.program, {
1366
+ code: "unknown-key-vocabulary",
1367
+ target: service.namespace,
1368
+ format: { name, service: service.namespace.name },
1369
+ });
1370
+ }
1371
+ /**
1372
+ * The error body is registered INSIDE this walk, so its validator lands in
1373
+ * `schemas.gen.ts` alongside every other declaration. Nothing else reaches it: the Zod
1374
+ * walk follows success bodies and parameters, so without this a runtime would have no
1375
+ * generated schema to check the documents it sends against.
1376
+ *
1377
+ * ⚠️ **Its expression is registered and then deliberately discarded.** It used to be
1378
+ * returned and used as a fallback for any response arm whose own body could not be
1379
+ * resolved, which meant every catch-all answered with whichever shape `errorBodyOf` had
1380
+ * settled on rather than the one its own response declares. Each arm resolves its own
1381
+ * body now; registering this one only ensures it is declared.
1382
+ */
1383
+ const errorBody = errorBodyOf(context.program, service);
1384
+ if (errorBody !== undefined) {
1385
+ withVisibility(context.program, Visibility.Read, () => registry.expressionFor(errorBody));
1386
+ }
1387
+ return {
1388
+ routes: withStreamModule(streamModule, () => collectRoutes(context.program, registry, service)),
1389
+ };
1390
+ })));
1391
+ /**
1392
+ * ⚠️ **Named AFTER the walk and BEFORE the file is rendered.** Naming mints identifiers that
1393
+ * `schemas.gen.ts` must declare, so it has to happen while the registry is complete and before
1394
+ * anything is written — and it has to happen HERE rather than in a server emitter, or the
1395
+ * validators it produces never reach this package's own output.
1396
+ */
1397
+ const { names, declarations } = nameRouteSchemas(routes);
1398
+ await emitFile(context.program, {
1399
+ path: resolvePath(outputDir, "schemas.gen.ts"),
1400
+ content: renderSchemas(registry, externals, declarations, resolved.runtimeModule),
1401
+ });
1402
+ if (resolved.contractsOutputDir !== undefined) {
1403
+ const types = new TypeRegistry(context.program);
1404
+ const entries = collectRequestTypes(context.program, types, service);
1405
+ const responses = collectResponseTypes(context.program, types, service);
1406
+ await emitFile(context.program, {
1407
+ path: resolvePath(resolved.contractsOutputDir, "requests.gen.ts"),
1408
+ content: renderRequestTypes(entries, responses, types),
1409
+ });
1410
+ await emitFile(context.program, {
1411
+ path: resolvePath(resolved.contractsOutputDir, "vocabularies.gen.ts"),
1412
+ content: renderVocabularies(vocabularies),
1413
+ });
1414
+ const assertions = renderWireAssertions(registry, types, entries, resolved.contractsPackage);
1415
+ if (assertions !== undefined) {
1416
+ await emitFile(context.program, {
1417
+ path: resolvePath(outputDir, "wire-contract.gen.ts"),
1418
+ content: assertions,
1419
+ });
1420
+ }
1421
+ }
1422
+ emitted.push({ service, routes, schemaNames: names, outputDir, options: resolved });
1423
+ }
1424
+ return emitted;
1425
+ }