typespec-http-zod 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/api.js CHANGED
@@ -7,13 +7,13 @@ import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams
7
7
  import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
8
8
  import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
9
9
  /**
10
- * `typespec-http-zod` — Zod validators and framework-free contract types from a TypeSpec HTTP
10
+ * `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
11
11
  * service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
12
12
  *
13
13
  * **The governing rule: if the document cannot say it, the validator must not enforce it.** A
14
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
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
17
  * class is empty rather than trusting it. Where openapi3 decides something, its rule is read from its
18
18
  * source and copied, refusals included: the same spec has to be representable by both emitters or by
19
19
  * neither, or a differential between them means nothing.
@@ -22,9 +22,9 @@ import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies,
22
22
  * self-describes as "highly experimental", and emits a Node.js `http` router. It also answers a
23
23
  * different question: this package emits no server at all. Validators are useful to Express, Fastify,
24
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.
25
+ * them - `typespec-hono` is one.
26
26
  *
27
- * **Built on the stable 1.x surface only** — `$onEmit` plus `@typespec/http` — rather than on
27
+ * **Built on the stable 1.x surface only** - `$onEmit` plus `@typespec/http` - rather than on
28
28
  * `@typespec/emitter-framework` (pre-1.0) or `@typespec/asset-emitter`, which announces its own
29
29
  * replacement in its package description. Fewer moving parts under something that has to keep working.
30
30
  */
@@ -33,15 +33,15 @@ export { $lib } from "./lib.js";
33
33
  * The option contract, published so an emitter built on this library can **derive** its own rather
34
34
  * than restate it.
35
35
  *
36
- * ⚠️ **Two schemas that have to agree are two lists that will not.** A server generator adds its own
36
+ * **Two schemas that have to agree are two lists that will not.** A server generator adds its own
37
37
  * keys and forwards the rest; if it restates this one, an option added here is silently rejected as
38
38
  * unknown by the wrapper, or accepted and never passed on. Neither failure is visible in output.
39
39
  */
40
40
  export { EmitterOptionsSchema } from "./lib.js";
41
41
  /**
42
- * How a property name is spelled as an object key — quoted only when it has to be.
42
+ * How a property name is spelled as an object key - quoted only when it has to be.
43
43
  *
44
- * ⚠️ **Published because a consumer emitting code beside these validators has to spell keys the
44
+ * **Published because a consumer emitting code beside these validators has to spell keys the
45
45
  * SAME way, and getting it wrong produces a file that does not parse.** An unquoted
46
46
  * `x-ms-test-header` is exactly that, and it happened: a corpus scenario declares one and the
47
47
  * emitted module stopped being loadable. This is not a style choice a second emitter may make
@@ -53,7 +53,7 @@ const PACKAGE_NAME = "typespec-http-zod";
53
53
  /**
54
54
  * The header every emitted file carries.
55
55
  *
56
- * ⚠️ **This once named one repository's build script** — `pnpm --filter … generate`, a command that
56
+ * **This once named one repository's build script** - `pnpm --filter ... generate`, a command that
57
57
  * exists in exactly one checkout. A generated file is the most-read artefact an emitter produces, and
58
58
  * a banner sending the reader to a script they do not have is worse than no instruction at all.
59
59
  *
@@ -67,7 +67,7 @@ const GENERATED_BANNER = `// GENERATED by ${PACKAGE_NAME} from the TypeSpec serv
67
67
  * The success status a row registers.
68
68
  *
69
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
70
+ * handler can return, so it is not something this emitter can turn into a `respond()` call - an
71
71
  * operation carrying only those is skipped rather than guessed at.
72
72
  */
73
73
  export function successStatusOf(operation) {
@@ -93,19 +93,19 @@ export function successStatusesOf(operation) {
93
93
  /** The five buckets OpenAPI can express a status RANGE as, in the order it names them. */
94
94
  const STATUS_RANGE_GROUPS = [1, 2, 3, 4, 5];
95
95
  /**
96
- * What the document will key this response under — **openapi3's rule, copied, not re-derived.**
96
+ * What the document will key this response under - **openapi3's rule, copied, not re-derived.**
97
97
  *
98
- * ⚠️ **`HttpStatusCodesEntry` is `HttpStatusCodeRange | number | "*"`, and the third case was
98
+ * **`HttpStatusCodesEntry` is `HttpStatusCodeRange | number | "*"`, and the third case was
99
99
  * silently dropped.** A response declared `@minValue(400) @maxValue(499) @statusCode _: int32` is a
100
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`
101
+ * absent from the arms - so a service could publish a whole class of failures and hand `respond`
102
102
  * nothing to check them against. Two of the two operations in `response/status-code-range` lost an
103
103
  * arm each this way, and the emitter's own `mixed-error-bodies` refusal was firing over the same
104
104
  * scenario for an unrelated and by then obsolete reason, which is how it stayed hidden.
105
105
  *
106
- * ⚠️ **A partial range is REFUSED, because `@typespec/openapi3` refuses it.** `494-499` does not
106
+ * **A partial range is REFUSED, because `@typespec/openapi3` refuses it.** `494-499` does not
107
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.
108
+ * `ErrorInRange` - a rule no document states, since openapi3 declines to publish a document at all.
109
109
  * Agreeing with the reference implementation is the whole point of copying its line: the same spec
110
110
  * is either representable by both or by neither.
111
111
  *
@@ -158,24 +158,24 @@ function isSuccessKey(status) {
158
158
  /**
159
159
  * Every FAILURE arm the operation declares, each with the schema for ITS OWN body.
160
160
  *
161
- * ⚠️ **None of these reached the generated output at all, and a census is the proof.** Measured on one
161
+ * **None of these reached the generated output at all, and a census is the proof.** Measured on one
162
162
  * service, the document declared `200`x9, `201`x3, `202`x1, `401`x1 and `default`x2, and only the
163
163
  * first three were emitted. So the schema for a failure never arrived, and no application could have
164
164
  * checked what it sends when something goes wrong even if it wanted to.
165
165
  *
166
- * ⚠️ **Then every arm that did arrive carried the SERVICE-WIDE error schema.** The spike declares
166
+ * **Then every arm that did arrive carried the SERVICE-WIDE error schema.** The spike declares
167
167
  * `@error model WebhookUnauthorized { ...ProblemDetails; @statusCode 401 }` and openapi3 publishes
168
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
169
+ * were identical there, so nothing was mis-validated - but the component the document names for that
170
170
  * response had no declaration at all.
171
171
  *
172
- * ⚠️ **`"default"` used to be the one arm with no body of its own**, filled in downstream from the
172
+ * **`"default"` used to be the one arm with no body of its own**, filled in downstream from the
173
173
  * service-wide schema. That was backwards: a catch-all response declares a body like any other
174
174
  * response, and `response/status-code-range` gives two operations whose `default` is a DIFFERENT
175
175
  * model from every other failure they declare. Reading each response's own body answers all three at
176
176
  * once, and is why the emitter no longer needs to believe a service has one error shape.
177
177
  *
178
- * Arms come out in the order OpenAPI resolves them — exact codes, then ranges, then the catch-all —
178
+ * Arms come out in the order OpenAPI resolves them - exact codes, then ranges, then the catch-all -
179
179
  * so a consumer scanning for the first match applies the precedence the specification states.
180
180
  */
181
181
  function errorArmsOf(program, operation, registry) {
@@ -194,11 +194,11 @@ function errorArmsOf(program, operation, registry) {
194
194
  /**
195
195
  * Where an arm sits in OpenAPI's own precedence order.
196
196
  *
197
- * ⚠️ **The specification decides this, not us.** The Responses Object says a range MAY be given as
197
+ * **The specification decides this, not us.** The Responses Object says a range MAY be given as
198
198
  * an uppercase `X` wildcard, and that "if a response is defined using an explicit code, the explicit
199
199
  * code definition takes precedence over the range definition for that code". `default` is by
200
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
201
+ * what lets a consumer take the first match and be right - the alternative is every application
202
202
  * re-deriving the rule, and getting it wrong quietly when a range and an exact code both apply.
203
203
  */
204
204
  function statusPrecedenceOf(status) {
@@ -206,7 +206,7 @@ function statusPrecedenceOf(status) {
206
206
  return 2;
207
207
  return typeof status === "number" ? 0 : 1;
208
208
  }
209
- /** A response's own body, as an emitted Zod expression — `undefined` where it declares none. */
209
+ /** A response's own body, as an emitted Zod expression - `undefined` where it declares none. */
210
210
  function bodySchemaOf(response, registry) {
211
211
  for (const content of response.responses) {
212
212
  if (content.body?.bodyKind !== "single")
@@ -218,11 +218,11 @@ function bodySchemaOf(response, registry) {
218
218
  return undefined;
219
219
  }
220
220
  /**
221
- * Whether the operation declares `@useAuth(NoAuth)` — no caller is established for it.
221
+ * Whether the operation declares `@useAuth(NoAuth)` - no caller is established for it.
222
222
  *
223
- * ⚠️ **`@useAuth(NoAuth)` reaches OpenAPI as `security: []`, which is why this is derivable at all.**
223
+ * **`@useAuth(NoAuth)` reaches OpenAPI as `security: []`, which is why this is derivable at all.**
224
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
225
+ * - account-scoped, resource-scoped - is not: no OpenAPI keyword expresses it, and a server emitter
226
226
  * that guesses is enforcing something no document states.
227
227
  */
228
228
  function noAuthFor(program, operation) {
@@ -231,13 +231,13 @@ function noAuthFor(program, operation) {
231
231
  /**
232
232
  * The body every `@error` response in the service declares, when they all declare the same one.
233
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
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
237
  * CM-shaped special case. RFC 9457 happens to be what `cm-api` puts in that slot.
238
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
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
241
  * catch-all bodies, or a specific status whose body neither was nor spread the catch-all one. That
242
242
  * was a real constraint while every failure arm carried this single schema; now each declared
243
243
  * response resolves its own, and a service answering `404` with `NotFoundError` and everything else
@@ -245,7 +245,7 @@ function noAuthFor(program, operation) {
245
245
  *
246
246
  * So the refusal is gone, and what remains is a convenience: the module-level `GENERATED_ERROR_SCHEMA`
247
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
248
+ * not, the constant is not emitted - and a consumer importing it gets a missing export, named at the
249
249
  * import, which is the honest report of "there is no such thing here".
250
250
  */
251
251
  export function errorBodyOf(program, service) {
@@ -254,13 +254,13 @@ export function errorBodyOf(program, service) {
254
254
  for (const operation of service.operations) {
255
255
  for (const response of operation.responses) {
256
256
  /**
257
- * ⚠️ `@error` sits on the RESPONSE type, which is not always the body type.
257
+ * `@error` sits on the RESPONSE type, which is not always the body type.
258
258
  *
259
259
  * Two declaration styles are both idiomatic and both occur. One returns the error model bare
260
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
261
+ * it (`@error model ErrorResponse { @statusCode ...; @body body: Problem }`), so the `@error` is
262
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
263
+ * entirely - measured on a surface declaring an error on all
264
264
  * eleven operations and this found none of them.
265
265
  */
266
266
  if (!isErrorModel(program, response.type))
@@ -270,15 +270,15 @@ export function errorBodyOf(program, service) {
270
270
  if (body?.bodyKind !== "single")
271
271
  continue;
272
272
  /**
273
- * ⚠️ **An error response can declare no body either.** `tsp-openapi3` renders a bare
273
+ * **An error response can declare no body either.** `tsp-openapi3` renders a bare
274
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
275
+ * here with the `void` intrinsic - which was then registered as the service's error schema
276
276
  * and refused by the Zod walk. There is nothing there to validate.
277
277
  */
278
278
  const type = bodyTypeOf(body.type);
279
279
  if (type === undefined)
280
280
  continue;
281
- // `"*"` is what `@error` resolves to when the model declares no status — the operation's
281
+ // `"*"` is what `@error` resolves to when the model declares no status - the operation's
282
282
  // general "any failure" contract, and the shape the runtime constructs.
283
283
  (response.statusCodes === "*" ? canonical : specific).add(type);
284
284
  }
@@ -286,7 +286,7 @@ export function errorBodyOf(program, service) {
286
286
  }
287
287
  /**
288
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
289
+ * publish - which is a reason to emit no constant, not a defect to refuse. Each operation's own
290
290
  * `default` arm carries its own schema, and that is what the server validates against.
291
291
  */
292
292
  if (canonical.size > 1)
@@ -295,7 +295,7 @@ export function errorBodyOf(program, service) {
295
295
  if (body !== undefined)
296
296
  return body;
297
297
  /**
298
- * A service can decline to declare a catch-all and still have one error shape — ten specific
298
+ * A service can decline to declare a catch-all and still have one error shape - ten specific
299
299
  * statuses carrying one problem model between them. That is a service-wide
300
300
  * body by any honest reading. Two different ones is not, and picking the first would be naming a
301
301
  * contract fact by iteration order.
@@ -305,10 +305,10 @@ export function errorBodyOf(program, service) {
305
305
  /**
306
306
  * A response's body type, or `undefined` where it declares no body.
307
307
  *
308
- * ⚠️ **`void` and `never` ARE "no body", wherever they appear.** Answering that in one place is the
308
+ * **`void` and `never` ARE "no body", wherever they appear.** Answering that in one place is the
309
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
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
312
  * `DefaultResponse<Body = void>`. Returned as a type, any of them is walked and refused with
313
313
  * `Cannot emit Zod for Intrinsic: 'void' has no runtime representation'`.
314
314
  */
@@ -318,16 +318,16 @@ function bodyTypeOf(type) {
318
318
  /**
319
319
  * The declared body type of the operation's success response, if it has one.
320
320
  *
321
- * ⚠️ **`void` and `never` ARE "no body", and reading them as types was a defect.** A response with
321
+ * **`void` and `never` ARE "no body", and reading them as types was a defect.** A response with
322
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`,
323
+ * as a body whose type is `void` - which is what `tsp-openapi3` writes when it converts a `204`,
324
324
  * and what `op remove(): void` means. The second used to be returned as a type, walked, and refused
325
325
  * with `Cannot emit Zod for Intrinsic: 'void' has no runtime representation'`. That took out the
326
326
  * Swagger Petstore, the most widely implemented reference API there is, on the round-trip suite's
327
327
  * first run.
328
328
  *
329
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
330
+ * what keeps them from drifting - `collectRoutes`, the contract types, the dual-status
331
331
  * discriminator and the alternate-arm schema all want the same answer to the same question.
332
332
  */
333
333
  function successBodyOf(operation, status) {
@@ -345,10 +345,10 @@ function successBodyOf(operation, status) {
345
345
  /**
346
346
  * The media types this operation's success response can be served as.
347
347
  *
348
- * ⚠️ **The fact that decides whether two operations are a collision or a CHOICE.** TypeSpec models
348
+ * **The fact that decides whether two operations are a collision or a CHOICE.** TypeSpec models
349
349
  * content negotiation as several operations on one route, each accepting a different media type;
350
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
351
+ * it registered every operation and Hono - which matches in registration order - left all but the
352
352
  * first as dead code that looks mounted.
353
353
  *
354
354
  * Empty when the response carries no body, which is not the same as a body served as anything.
@@ -365,17 +365,17 @@ function responseContentTypesOf(operation, status) {
365
365
  }
366
366
  return [...types];
367
367
  }
368
- /** Whether a type is an array — TypeSpec models one as a `Model` carrying an integer indexer. */
368
+ /** Whether a type is an array - TypeSpec models one as a `Model` carrying an integer indexer. */
369
369
  function isArrayType(type) {
370
370
  return type.kind === "Model" && type.indexer?.key.name === "integer";
371
371
  }
372
372
  /**
373
373
  * How to choose between an operation's two success statuses, read from the declared arms.
374
374
  *
375
- * ⚠️ **A discriminator, not a trial-parse.** "Try the 200 schema, and if it fails use the 202" is the
375
+ * **A discriminator, not a trial-parse.** "Try the 200 schema, and if it fails use the 202" is the
376
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.
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
379
  *
380
380
  * This replaces hand-written per-operation closures, which existed only because a spec declaring the
381
381
  * SAME body model for both statuses could not say which was which.
@@ -406,17 +406,17 @@ function alternateSchemaFor(operation, status, registry) {
406
406
  /**
407
407
  * The Zod for a **multipart** request body, built from its parts.
408
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
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
411
  * components the document declares that nothing here could check. Not a refusal, not a diagnostic:
412
412
  * silently unvalidated, which is the failure mode this emitter exists to remove.
413
413
  *
414
- * `HttpPart<T>` is unwrapped to `T`, which is what the document publishes — `HttpPart<string>` is
414
+ * `HttpPart<T>` is unwrapped to `T`, which is what the document publishes - `HttpPart<string>` is
415
415
  * `{type: "string"}` and `HttpPart<Address>` is a `$ref`.
416
416
  *
417
- * ⚠️ **A binary part is `z.unknown()`, deliberately.** `SCALARS` maps `bytes` to `z.string()`, which
417
+ * **A binary part is `z.unknown()`, deliberately.** `SCALARS` maps `bytes` to `z.string()`, which
418
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
419
+ * openapi3 publishes `{}` for it - no type at all. Emitting `z.string()` would enforce a rule the
420
420
  * document does not state AND reject what a server actually receives, since `c.req.parseBody()`
421
421
  * hands back a `File`. The type of a value is positional, exactly as its visibility is.
422
422
  */
@@ -424,7 +424,7 @@ function multipartSchemaOf(body, registry) {
424
424
  const entries = body.parts.map((part) => {
425
425
  const type = part.body?.type;
426
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.
427
+ // `multi` is `HttpPart<T>[]` - the same part repeated, which arrives as an array.
428
428
  const value = part.multi === true ? `z.array(${inner})` : inner;
429
429
  return `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.optional()` : value},`;
430
430
  });
@@ -432,13 +432,13 @@ function multipartSchemaOf(body, registry) {
432
432
  /**
433
433
  * Declared under the model's own name when it has one, because the document publishes a component
434
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.
435
+ * differential reports - rightly - as a contract the runtime cannot check.
436
436
  */
437
437
  const named = body.type;
438
438
  const name = named !== undefined && named.kind === "Model" && named.name !== "" ? named.name : undefined;
439
439
  return name === undefined ? shape : registry.declareNamed(name, () => shape);
440
440
  }
441
- /** `bytes`, or a model built on `File` — the parts openapi3 publishes as an empty schema. */
441
+ /** `bytes`, or a model built on `File` - the parts openapi3 publishes as an empty schema. */
442
442
  function isBinaryPart(type) {
443
443
  if (type.kind === "Scalar") {
444
444
  for (let scalar = type; scalar !== undefined; scalar = scalar.baseScalar) {
@@ -458,18 +458,18 @@ function isBinaryPart(type) {
458
458
  /**
459
459
  * Whether a body served under these media types is RAW BINARY rather than a JSON value.
460
460
  *
461
- * ⚠️ **The distinction decides whether a validator may say anything about it at all.** A `bytes`
461
+ * **The distinction decides whether a validator may say anything about it at all.** A `bytes`
462
462
  * body inside a JSON payload is base64 and the document publishes `type: "string"` with
463
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
464
+ * themselves, and `@typespec/openapi3` publishes a schema carrying only `contentMediaType` - which is
465
465
  * an ANNOTATION under JSON Schema 2020-12, not an assertion. The document therefore states nothing
466
466
  * about the value, and a validator that requires a string enforces a rule no caller reading the
467
467
  * contract can see.
468
468
  *
469
- * ⚠️ **The same decision was already made for multipart and not for bodies**, which is how the two
469
+ * **The same decision was already made for multipart and not for bodies**, which is how the two
470
470
  * came to disagree: a binary PART is `z.unknown()` because openapi3 publishes `{}` for it, while a
471
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`.
472
+ * counted and never compared - three positions in `encode/bytes`.
473
473
  */
474
474
  export function isRawBinaryMediaType(contentTypes) {
475
475
  if (contentTypes.length === 0)
@@ -483,13 +483,13 @@ function requestBodyOf(operation) {
483
483
  }
484
484
  /**
485
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,
486
+ * are separate things in HTTP and in OpenAPI - but they are equally part of the operation's input,
487
487
  * and `?state=nonsense` must be a 400 rather than something the application has to defend against.
488
488
  * Leaving them undescribed would document a constraint the runtime does not enforce, which is the
489
489
  * same failure the vocabulary tuples exist to prevent one level down.
490
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
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
493
  * wrong instead of being handed a merged object's complaint. A merged form existed beside these until
494
494
  * 0.1.0 and was deleted: nothing read it, and it had drifted into a second set of rules. See
495
495
  * `EmittedRoute.pathSchema`.
@@ -497,14 +497,14 @@ function requestBodyOf(operation) {
497
497
  /**
498
498
  * How a list is flattened into one query or header value, per OpenAPI's `style`.
499
499
  *
500
- * ⚠️ **Three of the four standard collection formats rejected every conformant request**, and only
500
+ * **Three of the four standard collection formats rejected every conformant request**, and only
501
501
  * one of the three was visible to the differential. `?colors=blue,red,green` arrives as a single
502
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
503
+ * the parameter as a string, so document and validator disagreed and the oracle said so - but for
504
504
  * `csv` both sides said `array` and **agreed perfectly** while the server 400'd every caller. No
505
505
  * document-to-validator comparison can see that: the disagreement is with the WIRE.
506
506
  *
507
- * `explode: true` is the exception — the value repeats (`?colors=blue&colors=red`) and Hono hands
507
+ * `explode: true` is the exception - the value repeats (`?colors=blue&colors=red`) and Hono hands
508
508
  * back an array already, so there is nothing to undo.
509
509
  */
510
510
  const COLLECTION_DELIMITERS = {
@@ -540,8 +540,8 @@ function collectionDelimiterOf(program, parameter) {
540
540
  /**
541
541
  * Decode a path, query or header value from the only thing HTTP can carry: text.
542
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()`
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
545
  * straight to the schema, so `z.number().int()` met `"1"` and refused it. Measured against a server
546
546
  * generated from the Swagger Petstore and run under `wrangler dev`:
547
547
  *
@@ -552,17 +552,17 @@ function collectionDelimiterOf(program, parameter) {
552
552
  * GET /store/order/1 -> 400 (integer)
553
553
  * ```
554
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
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
557
  * the corpus this emitter writes **29 numeric query and header parameters** and requests none of
558
558
  * them. The validators were right about the document the whole time; the disagreement was with what
559
559
  * arrives, which is the one thing no document comparison can see.
560
560
  *
561
- * ⚠️ **This DECODES, it does not coerce.** `z.coerce.number()` would have been one character of
561
+ * **This DECODES, it does not coerce.** `z.coerce.number()` would have been one character of
562
562
  * effort and wrong: `Number("")` is `0`, so `?limit=` would satisfy a required integer and an empty
563
563
  * value the document forbids would sail through as zero. A value that is not a well-formed number is
564
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
565
+ * the document justifies. Same rule as the collection split above - decode the wire form, then apply
566
566
  * the document's schema to the result, never instead of it.
567
567
  *
568
568
  * Derived from the emitted expression rather than a restated list of scalar names: `SCALARS` in
@@ -577,7 +577,7 @@ function wireDecoded(declared) {
577
577
  if (declared.startsWith("z.boolean()"))
578
578
  return `z.preprocess(${BOOLEAN}, ${declared})`;
579
579
  /**
580
- * A list whose ELEMENTS are numeric or boolean — `?ids=1,2,3` after the collection split, or a
580
+ * A list whose ELEMENTS are numeric or boolean - `?ids=1,2,3` after the collection split, or a
581
581
  * repeated `?id=1&id=2`. The elements are strings for exactly the same reason the scalar is, so
582
582
  * the same decoder applies per element. A non-array value is left alone; the split that produces
583
583
  * the array runs outside this and may not have happened.
@@ -593,9 +593,9 @@ function wireDecoded(declared) {
593
593
  /**
594
594
  * Decode a `content-type` header down to the media type the document names.
595
595
  *
596
- * ⚠️ **A media type is a type PLUS parameters, and the document states only the type.** `@header
596
+ * **A media type is a type PLUS parameters, and the document states only the type.** `@header
597
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
598
+ * `multipart/form-data; boundary=...` - and RFC 2046 section 5.1.1 makes that boundary MANDATORY, so the
599
599
  * validator rejected **every syntactically valid multipart request**. Measured under `wrangler dev`:
600
600
  *
601
601
  * ```
@@ -607,24 +607,24 @@ function wireDecoded(declared) {
607
607
  * `content-type`**, in 12 scenarios. Seventeen are `multipart/form-data` and fail 100% of the time;
608
608
  * the remaining 61 refuse any caller sending the entirely legal `; charset=utf-8`.
609
609
  *
610
- * ⚠️ **Enforcing "no parameters" is enforcing something the document does not say**, which is the
610
+ * **Enforcing "no parameters" is enforcing something the document does not say**, which is the
611
611
  * governing rule read in the direction that is easy to miss: the rule forbids inventing constraints
612
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
613
+ * move as decoding `"1"` to `1` - undo the transport form, then apply the document's schema to the
614
614
  * result.
615
615
  *
616
- * Lowercased because RFC 9110 §8.3.1 makes media type comparison case-insensitive — but ONLY when the
616
+ * Lowercased because RFC 9110 section 8.3.1 makes media type comparison case-insensitive - but ONLY when the
617
617
  * declared literal is itself lowercase, so a document that published an oddly-cased type keeps
618
618
  * matching exactly rather than silently never matching. Every literal openapi3 publishes across the
619
619
  * corpus is lowercase; this costs one condition and removes the way that fact could stop being true.
620
620
  */
621
621
  function mediaTypeDecoded(declared) {
622
622
  /**
623
- * ⚠️ **One accepted media type is a literal; several are an ENUM, and the second form is the one a
623
+ * **One accepted media type is a literal; several are an ENUM, and the second form is the one a
624
624
  * real document produces.** The corpus writes 78 `content-type` validators and every one is a
625
625
  * single literal, so a decoder that handled only `z.literal` looked complete against it. The
626
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
627
+ * `z.enum(["application/json", "application/xml", "application/x-www-form-urlencoded"])` - which
628
628
  * went on refusing `application/json; charset=utf-8` after the literal case was fixed. Measured:
629
629
  * with the charset parameter **400**, without it **200**, same body.
630
630
  */
@@ -647,15 +647,15 @@ function parameterSchemasOf(program, operation, registry) {
647
647
  continue;
648
648
  }
649
649
  /**
650
- * ⚠️ **`parameter.name` is the WIRE name; `parameter.param.name` is the TypeSpec one.** Headers
650
+ * **`parameter.name` is the WIRE name; `parameter.param.name` is the TypeSpec one.** Headers
651
651
  * used to be keyed on the second, and it made every header validator reject every conformant
652
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
653
+ * reads what actually arrived - a header called `webhook-id`. Measured: **400 against the
654
654
  * emitted schema, 200 against the documented one**, on a webhook's signed headers.
655
655
  *
656
656
  * The wire name is also the right answer for path and query where the two differ
657
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.
658
+ * via `resolveEncodedName` - the document publishes one name per parameter, and it is this one.
659
659
  * The TypeSpec property name is an authoring convenience that does not survive to the contract.
660
660
  */
661
661
  const delimiter = collectionDelimiterOf(program, parameter);
@@ -663,7 +663,7 @@ function parameterSchemasOf(program, operation, registry) {
663
663
  /**
664
664
  * The wire form is decoded before it is validated, never instead of it. `z.preprocess` undoes
665
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
666
+ * document publishes, so every constraint on the list - `minItems`, the element type - still
667
667
  * runs. Splitting in the handler instead would have left those unenforced.
668
668
  */
669
669
  /**
@@ -681,7 +681,7 @@ function parameterSchemasOf(program, operation, registry) {
681
681
  const entry = `\t${objectKey(parameter.name)}: ${expression},`;
682
682
  (byTarget[parameter.type] ??= []).push(entry);
683
683
  /**
684
- * ⚠️ **On a route several operations share, `accept` SELECTS the operation — it is not a
684
+ * **On a route several operations share, `accept` SELECTS the operation - it is not a
685
685
  * constraint on one of them.** Each member declares its own literal, so validating against any
686
686
  * single member's would 400 every request asking for one of the others, and 400 is the wrong
687
687
  * answer twice over: the request is well-formed, and the contract's answer to "I cannot serve
@@ -716,15 +716,15 @@ export function collectRoutes(program, registry, service) {
716
716
  const statusCode = successStatusOf(operation);
717
717
  if (statusCode === undefined)
718
718
  continue;
719
- // No body is a legitimate success — see `EmittedRoute.responseSchema`. Skipping here is what
719
+ // No body is a legitimate success - see `EmittedRoute.responseSchema`. Skipping here is what
720
720
  // dropped 282 operations across the conformance corpus without a word.
721
721
  const responseType = successBodyOf(operation, statusCode);
722
722
  const requestType = requestBodyOf(operation);
723
723
  /**
724
- * ⚠️ **A streamed operation's EVENT payloads are not reachable from its body.**
724
+ * **A streamed operation's EVENT payloads are not reachable from its body.**
725
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
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
728
  * even though the document publishes it as a component. Declaring the union here reaches
729
729
  * every payload through it, which is the same set openapi3 emits. The result is discarded:
730
730
  * the route's response schema is still the stream, and this exists so the vocabulary a
@@ -734,7 +734,7 @@ export function collectRoutes(program, registry, service) {
734
734
  if (streamed !== undefined) {
735
735
  withVisibility(program, Visibility.Read, () => registry.expressionFor(streamed));
736
736
  }
737
- // `@requestVisibility` when the operation states one, otherwise the verb's default — the
737
+ // `@requestVisibility` when the operation states one, otherwise the verb's default - the
738
738
  // same resolution `@typespec/openapi3` performs before emitting a request schema.
739
739
  const requestVisibility = resolveRequestVisibility(program, operation.operation, operation.verb);
740
740
  const statusCodes = successStatusesOf(operation);
@@ -753,11 +753,11 @@ export function collectRoutes(program, registry, service) {
753
753
  operationId: resolveOperationId(program, operation.operation),
754
754
  verb: operation.verb.toUpperCase(),
755
755
  /**
756
- * The path TEMPLATE, exactly as the document publishes it — `/widgets/{widget-id}`.
756
+ * The path TEMPLATE, exactly as the document publishes it - `/widgets/{widget-id}`.
757
757
  *
758
- * ⚠️ **This used to be a router's path, converted here.** `toHonoPath` ran during
758
+ * **This used to be a router's path, converted here.** `toHonoPath` ran during
759
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
760
+ * representation carried one framework's spelling of a contract fact - and a template
761
761
  * that router could not mount refused the whole operation, validators included.
762
762
  *
763
763
  * A server emitter converts this when it renders, and refuses what it cannot mount with
@@ -773,15 +773,15 @@ export function collectRoutes(program, registry, service) {
773
773
  /**
774
774
  * `@summary` first, `@doc` as the fallback.
775
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
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
778
  * strings are one-liners means `summary`; written as `@doc` they land in `description`
779
779
  * instead and leave `summary` empty on every operation. The fallback keeps a spec that
780
780
  * uses `@doc` working.
781
781
  */
782
782
  summary: getSummary(program, operation.operation) ?? getDoc(program, operation.operation),
783
783
  /**
784
- * ⚠️ **Each position is walked at ITS OWN visibility, which is the whole point.** A model
784
+ * **Each position is walked at ITS OWN visibility, which is the whole point.** A model
785
785
  * used as a request body and as a response is two shapes: `@statusCode` is applicable to
786
786
  * one and not the other, and `@visibility` can add or remove properties outright. Walking
787
787
  * both at one visibility gives whichever was seen first to both.
@@ -810,7 +810,7 @@ export function collectRoutes(program, registry, service) {
810
810
  : withVisibility(program, Visibility.Read, () =>
811
811
  /**
812
812
  * A raw binary body is `z.unknown()`, matching what a binary multipart part
813
- * already emits and what the document actually asserts — see
813
+ * already emits and what the document actually asserts - see
814
814
  * {@link isRawBinaryMediaType}.
815
815
  */
816
816
  isBinaryPart(responseType) &&
@@ -820,7 +820,7 @@ export function collectRoutes(program, registry, service) {
820
820
  rawBodyProperty,
821
821
  // Responses are read at Visibility.Read, exactly as the success body above is. Resolving
822
822
  // them under the ambient request visibility declared six components under the wrong
823
- // suffix — measured: type/model/visibility gained six divergences.
823
+ // suffix - measured: type/model/visibility gained six divergences.
824
824
  errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
825
825
  scopes: [
826
826
  ...new Set((getAuthenticationForOperation(program, operation.operation)?.options ?? []).flatMap((option) => option.schemes.flatMap((scheme) => scheme.type === "oauth2"
@@ -830,11 +830,11 @@ export function collectRoutes(program, registry, service) {
830
830
  noAuth: noAuthFor(program, operation),
831
831
  statusBy: discriminator,
832
832
  /**
833
- * ⚠️ **Each arm is validated against its OWN shape.**
833
+ * **Each arm is validated against its OWN shape.**
834
834
  *
835
835
  * A consumer checks its value against the arm's schema, and with one schema
836
836
  * for two statuses the acknowledged response was checked against the resolved arm's
837
- * shape — a 502 on a perfectly good 202.
837
+ * shape - a 502 on a perfectly good 202.
838
838
  */
839
839
  alternateResponseSchema: discriminator === undefined
840
840
  ? undefined
@@ -847,7 +847,7 @@ export function collectRoutes(program, registry, service) {
847
847
  /**
848
848
  * Emit only the external names a given file actually references.
849
849
  *
850
- * Both generated files can need them — `schemas.gen.ts` for the vocabulary tuples and `@refine`
850
+ * Both generated files can need them - `schemas.gen.ts` for the vocabulary tuples and `@refine`
851
851
  * predicates, and `routes.gen.ts` when an inline params schema carries a refined scalar.
852
852
  * Emitting the whole set into both would leave unused imports (which lint rejects); emitting it into
853
853
  * neither is what left `routes.gen.ts` referencing `isParsableInstant` with no import at all.
@@ -867,21 +867,36 @@ function renderExternalImports(externals, ...bodies) {
867
867
  * Every validator this service needs, in one module: the component schemas, and the per-operation
868
868
  * request and response validators.
869
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
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
872
  * validate a request or a response against, and every oracle that graded those validators had to read
873
873
  * a server to find them. See {@link nameRouteSchemas}.
874
874
  */
875
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`);
876
+ const declarations = registry.declarations().map((d) => {
877
+ /**
878
+ * **A declaration on a CYCLE cannot take its type from `z.infer`, because that is the loop.**
879
+ * The ordinary form - `const x = ...; type X = z.infer<typeof x>` - is circular the moment `x`'s
880
+ * own initialiser depends on `X`, and TypeScript says so: `TS2456`, `TS2502`, `TS7022`. So a
881
+ * cyclic declaration carries the type written out, and the deferred one is annotated with it,
882
+ * which is the recipe Zod documents for exactly this. Everything else is unchanged, so a spec
883
+ * with no cycle emits byte-identical output to before.
884
+ */
885
+ if (d.structural === undefined) {
886
+ return `\nexport const ${d.identifier} = ${d.source};\nexport type ${d.typeName} = z.infer<typeof ${d.identifier}>;\n`;
887
+ }
888
+ const declared = d.structural.startsWith("{")
889
+ ? `export interface ${d.typeName} ${d.structural}`
890
+ : `export type ${d.typeName} = ${d.structural};`;
891
+ const annotation = d.annotated === true ? `: z.ZodType<${d.typeName}>` : "";
892
+ return `\n${declared}\nexport const ${d.identifier}${annotation} = ${d.source};\n`;
893
+ });
879
894
  const parts = [GENERATED_BANNER, '\nimport { z } from "zod";\n'];
880
895
  /**
881
896
  * A TYPE import, so nothing of this package's survives into the emitted JavaScript.
882
897
  *
883
898
  * 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
899
+ * that silently validates nothing - see the option's own docblock for the measurement. Emitted only
885
900
  * when there are arms to annotate, because an unused import fails the lint a generated file has to
886
901
  * pass like any other.
887
902
  */
@@ -895,16 +910,16 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
895
910
  return parts.join("");
896
911
  }
897
912
  /**
898
- * The IDENTIFIER a schema can be referred to by, which `z.infer<typeof …>` requires.
913
+ * The IDENTIFIER a schema can be referred to by, which `z.infer<typeof ...>` requires.
899
914
  *
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
915
+ * A route's schema is often an expression rather than a declaration - `z.array(widgetSchema)`,
916
+ * `z.object({...})` for inline parameters - and `typeof z.array(widgetSchema)` is not valid
902
917
  * TypeScript. Anything that is not already a bare identifier gets a name here; anything that is
903
918
  * keeps it, so the generated file does not fill up with aliases that alias nothing.
904
919
  */
905
920
  const isIdentifier = (expression) => /^[A-Za-z_$][\w$]*$/.test(expression);
906
921
  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. */
922
+ /** `[the name to refer to it by, the `const` line to emit]` - the line is absent when none is needed. */
908
923
  function namedSchema(operationId, suffix, expression) {
909
924
  if (expression === undefined)
910
925
  return { name: undefined, declaration: undefined };
@@ -912,9 +927,9 @@ function namedSchema(operationId, suffix, expression) {
912
927
  return { name: expression, declaration: undefined };
913
928
  const name = schemaConst(operationId, suffix);
914
929
  /**
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
930
+ * **Exported, because a schema nothing outside this file can name is a schema nothing can
931
+ * check.** These are the validators the server actually enforces on every request - the path,
932
+ * query and header shapes - and while they were module-local the only thing that could read them
918
933
  * was the generated code itself. That is how a header validator keyed on the wrong name shipped:
919
934
  * the conformance differential compares `components.schemas`, a parameter schema is not a
920
935
  * component, and there was no third artefact to compare against even in principle.
@@ -927,10 +942,10 @@ function namedSchema(operationId, suffix, expression) {
927
942
  /**
928
943
  * Which `verb path` slots carry more than one operation.
929
944
  *
930
- * ⚠️ **A DOCUMENT fact, not a router one, which is why it lives here.** TypeSpec models content
945
+ * **A DOCUMENT fact, not a router one, which is why it lives here.** TypeSpec models content
931
946
  * negotiation as several operations on one route; OpenAPI models it as a single path item whose
932
947
  * 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
948
+ * one member's literal - `accept` SELECTS the operation, so validating it would answer `400` to a
934
949
  * well-formed request whose real answer is `406`. That is why `negotiatedHeaderSchema` exists, and
935
950
  * deciding which of the two applies is what this set is for.
936
951
  */
@@ -948,16 +963,16 @@ function sharedSlots(routes) {
948
963
  /**
949
964
  * Declare every per-operation validator, and hand back the names they were declared under.
950
965
  *
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
966
+ * **These used to be declared by the SERVER emitter, into the server file.** Six identifiers per
967
+ * operation - the path, query and header validators, the request body, the success response and the
968
+ * response arms - were built and emitted inside `renderApp`, which had three consequences and none of
954
969
  * them were intended:
955
970
  *
956
- * 1. a package whose entire claim is *TypeSpec HTTP → Zod* emitted **no** request or response
971
+ * 1. a package whose entire claim is *TypeSpec HTTP -> Zod* emitted **no** request or response
957
972
  * validator that a consumer could name. Component schemas, and nothing to check a call against;
958
973
  * 2. every oracle that graded them had to read the SERVER file, so splitting this library out would
959
974
  * 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;
975
+ * as the only thing still measured - three of the four largest arms, lost silently;
961
976
  * 3. two emitters would have had to mint identical identifiers by coincidence, which is the coupling
962
977
  * that made a library-plus-emitter split look impossible in the first place.
963
978
  *
@@ -980,7 +995,7 @@ function nameRouteSchemas(routes) {
980
995
  const query = declare("Query", route.querySchema);
981
996
  const negotiating = shared.has(`${route.verb} ${route.path}`);
982
997
  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.
998
+ // A raw body is read as text, never parsed, so it has no validator - the bytes are the contract.
984
999
  const body = route.rawBodyProperty === undefined ? declare("Body", route.requestSchema) : undefined;
985
1000
  const response = declare("Response", route.responseSchema);
986
1001
  const alternateResponse = declare("AlternateResponse", route.alternateResponseSchema);
@@ -1000,10 +1015,10 @@ function nameRouteSchemas(routes) {
1000
1015
  return { names, declarations };
1001
1016
  }
1002
1017
  /**
1003
- * What an operation may answer with, as a literal — each declared status paired with the body the
1018
+ * What an operation may answer with, as a literal - each declared status paired with the body the
1004
1019
  * document gives it, in OpenAPI's own precedence order.
1005
1020
  *
1006
- * ⚠️ **This docblock was orphaned in the source it came from**, sitting above an unrelated function
1021
+ * **This docblock was orphaned in the source it came from**, sitting above an unrelated function
1007
1022
  * while the one it describes had none. Reattached.
1008
1023
  */
1009
1024
  function responseArmsOf(route, response, alternate) {
@@ -1020,11 +1035,11 @@ function responseArmsOf(route, response, alternate) {
1020
1035
  /**
1021
1036
  * The failure arms, which used to be dropped entirely.
1022
1037
  *
1023
- * ⚠️ **The document declares what an error body looks like and the server never passed it on**, so
1038
+ * **The document declares what an error body looks like and the server never passed it on**, so
1024
1039
  * `respond` could not check what it sends on failure even in principle.
1025
1040
  *
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 —
1041
+ * **This used to fall back to the service-wide error schema, and the fallback was the defect.**
1042
+ * `arm.schema ?? errorSchema` was reached by every arm the emitter could not resolve a body for -
1028
1043
  * which, while `"default"` was deliberately given none, meant every catch-all in every service
1029
1044
  * answered with whichever shape `errorBodyOf` had settled on rather than the one its own response
1030
1045
  * declares. Each arm resolves its own body now, so an arm with no schema is a response the document
@@ -1040,9 +1055,9 @@ const upperFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
1040
1055
  * The effective input of every operation, as TypeScript: its body, with the path, query and header
1041
1056
  * parameters merged over it.
1042
1057
  *
1043
- * ⚠️ **This used to drop any parameter literally named `companyId`.** One application carried its
1058
+ * **This used to drop any parameter literally named `companyId`.** One application carried its
1044
1059
  * 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
1060
+ * match - so for every consumer, a path parameter with that name vanished from the generated input
1046
1061
  * type while the Zod validator went on requiring it. Two artefacts describing different shapes, from
1047
1062
  * a rule no document states.
1048
1063
  *
@@ -1060,28 +1075,48 @@ function collectRequestTypes(program, registry, service) {
1060
1075
  /**
1061
1076
  * Headers are part of the input too.
1062
1077
  *
1063
- * The webhook's three Standard-Webhooks headers are inside the MAC — they are not metadata
1078
+ * The webhook's three Standard-Webhooks headers are inside the MAC - they are not metadata
1064
1079
  * beside the payload, they are arguments to the verification. Leaving them out of the wire
1065
1080
  * type would describe an input the application cannot act on.
1066
1081
  */
1067
1082
  const extraProperties = operation.parameters.parameters
1068
1083
  .filter((parameter) => parameter.type === "path" || parameter.type === "query" || parameter.type === "header")
1069
- .map((parameter) => registry.expressionForProperty(parameter.param));
1084
+ /**
1085
+ * Named as the WIRE names it, which is what the emitted validator keys on.
1086
+ * `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
1087
+ * `"x-thing"` here too: a consumer checking its handlers against these types otherwise
1088
+ * fails to typecheck against a server that is correct.
1089
+ */
1090
+ .map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
1070
1091
  if (body === undefined && extraProperties.length === 0)
1071
1092
  continue;
1072
1093
  /**
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.
1094
+ * A `bytes` body reaches the use-case as a NAMED property holding the unparsed body, not as
1095
+ * a bare value, so the wire type is `{ rawBody: ... }`, matching what a server actually
1096
+ * builds. Referencing the scalar directly would type the whole input as that value and
1097
+ * quietly drop the headers beside it.
1077
1098
  */
1078
1099
  const rawBody = body?.kind === "Scalar" && body.name === "bytes"
1079
1100
  ? (operation.parameters.body?.property?.name ?? "body")
1080
1101
  : undefined;
1102
+ /**
1103
+ * **Raw binary is `ArrayBuffer`; base64 inside a JSON payload is `string`.** The same
1104
+ * `isRawBinaryMediaType` rule the response side and the multipart parts already follow,
1105
+ * applied here for the first time.
1106
+ *
1107
+ * This type said `string` for every `bytes` body whatever it was served as. A server reading
1108
+ * an `application/octet-stream` body hands the handler bytes, so a consumer checking its
1109
+ * handlers against these types could not typecheck against a correct server. It is the
1110
+ * request half of the corruption already fixed on the reader: decoding raw bytes as text
1111
+ * replaces every byte outside ASCII, and typing them as text is what invites it.
1112
+ */
1113
+ const rawBodyType = isRawBinaryMediaType([...(operation.parameters.body?.contentTypes ?? [])])
1114
+ ? "ArrayBuffer"
1115
+ : "string";
1081
1116
  entries.push({
1082
1117
  operationId: resolveOperationId(program, operation.operation),
1083
1118
  bodyRef: rawBody !== undefined
1084
- ? `{\n\t${rawBody}: string;\n}`
1119
+ ? `{\n\t${rawBody}: ${rawBodyType};\n}`
1085
1120
  : body === undefined
1086
1121
  ? undefined
1087
1122
  : registry.expressionFor(body),
@@ -1094,7 +1129,7 @@ function collectRequestTypes(program, registry, service) {
1094
1129
  /**
1095
1130
  * The success response every operation declares, so a producer can be held to producing it.
1096
1131
  *
1097
- * ⚠️ **Pinned fields only — these are PERMISSIVE shapes.** The gateway names the fields it depends on
1132
+ * **Pinned fields only - these are PERMISSIVE shapes.** The gateway names the fields it depends on
1098
1133
  * and lets a richer view through untouched, so this is a floor and not an equality: the
1099
1134
  * domain's result must CONTAIN what the document promises, and may contain more. Asserting equality
1100
1135
  * would turn every field a producer legitimately adds into a build failure, which is the same mistake
@@ -1119,7 +1154,7 @@ function collectResponseTypes(program, registry, service) {
1119
1154
  return entries;
1120
1155
  }
1121
1156
  /**
1122
- * `requests.gen.ts` — the shape both sides are checked against.
1157
+ * `requests.gen.ts` - the shape both sides are checked against.
1123
1158
  *
1124
1159
  * Plain TypeScript with no imports at all, so the contract types stay framework-free and a layering
1125
1160
  * rule banning a validation library from the rest of the codebase is untouched. `wire-contract.gen.ts`
@@ -1144,13 +1179,13 @@ function renderRequestTypes(entries, responses, registry) {
1144
1179
  if (entry.extraProperties.length > 0) {
1145
1180
  parts.push(`{\n${entry.extraProperties.map((line) => `\t${line}`).join("\n")}\n}`);
1146
1181
  }
1147
- return (`\n/** The effective input for \`${entry.operationId}\` — its body, with path and query parameters merged over it. */\n` +
1182
+ return (`\n/** The effective input for \`${entry.operationId}\` - its body, with path and query parameters merged over it. */\n` +
1148
1183
  `export type ${upperFirst(entry.operationId)}Input = Simplify<${parts.join(" & ")}>;\n`);
1149
1184
  });
1150
1185
  /**
1151
1186
  * The same set again, keyed by operation id.
1152
1187
  *
1153
- * TypeScript cannot index a module's *type* exports by a computed name — `typeof Contracts` carries
1188
+ * TypeScript cannot index a module's *type* exports by a computed name - `typeof Contracts` carries
1154
1189
  * only the value side, so `Contracts["AppointOfficerInput"]` does not resolve. Both sides'
1155
1190
  * assertions are written as mapped types over every operation, and this is what makes that
1156
1191
  * possible. Generated with the aliases above, so it cannot fall behind them.
@@ -1166,7 +1201,7 @@ function renderRequestTypes(entries, responses, registry) {
1166
1201
  * Flattens an intersection into a single object type.
1167
1202
  *
1168
1203
  * 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
1204
+ * same but are NOT equal under the \`Equals<>\` conditional-type trick - so the assertions that pair
1170
1205
  * this file with the emitted Zod would fail on correct code.
1171
1206
  */
1172
1207
  type Simplify<T> = { [K in keyof T]: T[K] } & {};
@@ -1175,7 +1210,7 @@ type Simplify<T> = { [K in keyof T]: T[K] } & {};
1175
1210
  * The same shape, tolerant of \`readonly\`.
1176
1211
  *
1177
1212
  * 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
1213
+ * views with \`readonly\` properties and \`readonly T[]\` arrays - neither of which is assignable to a
1179
1214
  * mutable one. Mutability is not a fact about a wire shape: it says nothing about what a caller
1180
1215
  * receives. Making the TARGET readonly accepts both, which is the permissive direction; the reverse
1181
1216
  * would reject every view a producer actually returns.
@@ -1186,7 +1221,7 @@ type Produced<T> = T extends readonly (infer Element)[]
1186
1221
  ? { readonly [K in keyof T]: Produced<T[K]> }
1187
1222
  : T;
1188
1223
  ${declarations.join("")}${inputs.join("")}
1189
- /** Every operation's effective input, keyed by operation id — what both sides assert against. */
1224
+ /** Every operation's effective input, keyed by operation id - what both sides assert against. */
1190
1225
  export interface WireInputs {
1191
1226
  ${lookup}
1192
1227
  }
@@ -1194,9 +1229,9 @@ ${lookup}
1194
1229
  /**
1195
1230
  * Every operation's success response, keyed by operation id.
1196
1231
  *
1197
- * ⚠️ **A floor, not an equality.** These shapes are permissive: a caller pins the fields it depends
1232
+ * **A floor, not an equality.** These shapes are permissive: a caller pins the fields it depends
1198
1233
  * 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
1234
+ * and may contain more. This is what makes a published response checkable at all - measured once, a
1200
1235
  * route advertised a field it had never returned, and nothing anywhere could see it.
1201
1236
  */
1202
1237
  export interface WireOutputs {
@@ -1207,23 +1242,23 @@ ${responseLookup}
1207
1242
  /**
1208
1243
  * The validator's half of the wire contract: the emitted Zod must infer **exactly** the shared type.
1209
1244
  *
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
1245
+ * Two artefacts describe one contract - the runtime validator and the framework-free type a codebase
1246
+ * passes around - and a shared declaration compared against only one of them lets the other drift
1212
1247
  * into it unnoticed. This asserts the validator *produces* the shared shape; a consumer that also
1213
1248
  * asserts its own code *accepts* that shape has both directions pinned.
1214
1249
  *
1215
- * ⚠️ This is also the only thing that catches the emitter disagreeing with **itself**. `zod.ts` and
1250
+ * This is also the only thing that catches the emitter disagreeing with **itself**. `zod.ts` and
1216
1251
  * `types.ts` walk the same TypeSpec twice, once to Zod and once to TypeScript, and a construct one
1217
1252
  * handles differently from the other produces two artefacts that describe different shapes while
1218
1253
  * both look right in isolation. That happened during this emitter's first run: a vocabulary in a
1219
1254
  * query-parameter position was inlined by one walk and referenced by the other.
1220
1255
  *
1221
- * `Equals` here, not assignability — `z.infer` is the output type, and the wire type is emitted with
1256
+ * `Equals` here, not assignability - `z.infer` is the output type, and the wire type is emitted with
1222
1257
  * defaulted properties required for exactly that reason, so the two really are equal.
1223
1258
  */
1224
1259
  function renderWireAssertions(schemas, types, entries, contractsPackage) {
1225
1260
  // 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
1261
+ // package. With no package named there is nothing to import them from and nothing to pair - the
1227
1262
  // types are still written to `contracts-output-dir`, they simply have no shared specifier yet.
1228
1263
  if (contractsPackage === undefined)
1229
1264
  return undefined;
@@ -1269,7 +1304,7 @@ ${assertions.join("\n")}
1269
1304
  `;
1270
1305
  }
1271
1306
  /**
1272
- * `vocabularies.gen.ts` — every vocabulary the surface reaches, as runtime tuples.
1307
+ * `vocabularies.gen.ts` - every vocabulary the surface reaches, as runtime tuples.
1273
1308
  *
1274
1309
  * Keyed by enum name rather than emitted as individually-named tuples, because the constant names a
1275
1310
  * consumer wants are English plurals and some of them are irregular. An emitter that pluralises is an
@@ -1282,9 +1317,9 @@ function renderVocabularies(vocabularies) {
1282
1317
  .map(([name, values]) => `\t${name}: [${values.map((v) => JSON.stringify(v)).join(", ")}],`);
1283
1318
  return `${GENERATED_BANNER}
1284
1319
  /**
1285
- * The spec's vocabularies — every enum the surface reaches, and what each one contains.
1320
+ * The spec's vocabularies - every enum the surface reaches, and what each one contains.
1286
1321
  *
1287
- * \`as const\` so each is a readonly tuple of literals — the type every consumer derives with
1322
+ * \`as const\` so each is a readonly tuple of literals - the type every consumer derives with
1288
1323
  * \`(typeof X)[number]\` keeps working unchanged.
1289
1324
  */
1290
1325
  export const SPEC_VOCABULARIES = {
@@ -1297,7 +1332,7 @@ export const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
1297
1332
  /**
1298
1333
  * Emit this library's artefacts for every `@service` in the program, and hand back what was emitted.
1299
1334
  *
1300
- * ⚠️ **The whole orchestration lives here, once.** The nesting below is load-bearing — a walk that
1335
+ * **The whole orchestration lives here, once.** The nesting below is load-bearing - a walk that
1301
1336
  * runs outside `collectExternalImports` throws, vocabularies must be collected from what the service
1302
1337
  * DECLARES rather than from what the Zod walk happens to touch, and the error body has to be
1303
1338
  * registered inside the walk or its validator never reaches `schemas.gen.ts`. An emitter built on this
@@ -1309,9 +1344,9 @@ export async function emitHttpZod(context, wrapper = {}) {
1309
1344
  const options = context.options;
1310
1345
  const defaultRuntimeModule = wrapper.defaultRuntimeModule ?? DEFAULT_RUNTIME_MODULE;
1311
1346
  /**
1312
- * Each service already projected to the version it currently serves — see `versioning.ts`.
1347
+ * Each service already projected to the version it currently serves - see `versioning.ts`.
1313
1348
  *
1314
- * ⚠️ **This used to be a bare `getAllHttpServices`, which is the UNION of every version.** A spec
1349
+ * **This used to be a bare `getAllHttpServices`, which is the UNION of every version.** A spec
1315
1350
  * that removes an operation in v2 had it mounted anyway, and a spec that removed a property had it
1316
1351
  * still required. Both while the document said otherwise.
1317
1352
  */
@@ -1319,14 +1354,14 @@ export async function emitHttpZod(context, wrapper = {}) {
1319
1354
  const snapshots = serviceSnapshots(context.program, versioning);
1320
1355
  /**
1321
1356
  * 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`.
1357
+ * optional stream libraries are not installed, which is the normal case - see `streams.ts`.
1323
1358
  */
1324
1359
  const streamModule = await resolveStreamModule();
1325
1360
  const emitted = [];
1326
1361
  /**
1327
- * ⚠️ **One emission per `@service`, with its own registry and its own import collector.**
1362
+ * **One emission per `@service`, with its own registry and its own import collector.**
1328
1363
  *
1329
- * Sharing either across services merges one surface's schemas and imports into the other's file —
1364
+ * Sharing either across services merges one surface's schemas and imports into the other's file -
1330
1365
  * an unused import if you are lucky and a missing one if you are not, and a public document
1331
1366
  * carrying internal models if you are not paying attention. It is invisible while there is one
1332
1367
  * service, which is exactly why it is built this way before the second one lands rather than after.
@@ -1353,7 +1388,7 @@ export async function emitHttpZod(context, wrapper = {}) {
1353
1388
  * A closed key SET is a vocabulary too, and it cannot be an enum.
1354
1389
  *
1355
1390
  * 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
1391
+ * how the document states one - and a generated type cannot be iterated, which leaves a
1357
1392
  * consumer needing the set at run time with a hand-maintained copy. Naming the model in
1358
1393
  * `key-vocabularies` writes the keys beside the types. A name matching nothing is
1359
1394
  * reported rather than quietly emitting no tuple.
@@ -1374,7 +1409,7 @@ export async function emitHttpZod(context, wrapper = {}) {
1374
1409
  * walk follows success bodies and parameters, so without this a runtime would have no
1375
1410
  * generated schema to check the documents it sends against.
1376
1411
  *
1377
- * ⚠️ **Its expression is registered and then deliberately discarded.** It used to be
1412
+ * **Its expression is registered and then deliberately discarded.** It used to be
1378
1413
  * returned and used as a fallback for any response arm whose own body could not be
1379
1414
  * resolved, which meant every catch-all answered with whichever shape `errorBodyOf` had
1380
1415
  * settled on rather than the one its own response declares. Each arm resolves its own
@@ -1389,9 +1424,9 @@ export async function emitHttpZod(context, wrapper = {}) {
1389
1424
  };
1390
1425
  })));
1391
1426
  /**
1392
- * ⚠️ **Named AFTER the walk and BEFORE the file is rendered.** Naming mints identifiers that
1427
+ * **Named AFTER the walk and BEFORE the file is rendered.** Naming mints identifiers that
1393
1428
  * `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
1429
+ * anything is written - and it has to happen HERE rather than in a server emitter, or the
1395
1430
  * validators it produces never reach this package's own output.
1396
1431
  */
1397
1432
  const { names, declarations } = nameRouteSchemas(routes);