typespec-http-zod 0.23.0 → 0.25.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
@@ -1,4 +1,4 @@
1
- import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, NoTarget, resolvePath, } from "@typespec/compiler";
1
+ import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary, isErrorModel, isNeverType, isVoidType, navigateProgram, NoTarget, resolvePath, } from "@typespec/compiler";
2
2
  import { getAuthenticationForOperation, isStatusCode, resolveRequestVisibility, Visibility, } from "@typespec/http";
3
3
  import { getOperationId, resolveOperationId } from "@typespec/openapi";
4
4
  import { reportDiagnostic } from "./lib.js";
@@ -48,7 +48,7 @@ export { EmitterOptionsSchema } from "./lib.js";
48
48
  * emitted module stopped being loadable. This is not a style choice a second emitter may make
49
49
  * independently.
50
50
  */
51
- export { objectKey } from "./zod.js";
51
+ export { jsDocComment, objectKey } from "./zod.js";
52
52
  /** Named once. The emitted banner and the internal-invariant messages both quote it. */
53
53
  const PACKAGE_NAME = "typespec-http-zod";
54
54
  /**
@@ -392,15 +392,48 @@ function bodySchemaOf(response, registry) {
392
392
  return undefined;
393
393
  }
394
394
  /**
395
- * Whether the operation declares `@useAuth(NoAuth)` - no caller is established for it.
395
+ * Everything the document says about authenticating an operation, read ONCE.
396
396
  *
397
- * **`@useAuth(NoAuth)` reaches OpenAPI as `security: []`, which is why this is derivable at all.**
398
- * Whether an operation needs a caller is a contract fact the document publishes. What KIND of caller
399
- * - account-scoped, resource-scoped - is not: no OpenAPI keyword expresses it, and a server emitter
400
- * that guesses is enforcing something no document states.
397
+ * **`scopes` and `noAuth` were two lossy projections of this, each with its own read of the same
398
+ * fact.** A flat scope union cannot say which scheme demanded what, so an emitter that wanted the
399
+ * gate the document actually publishes had to call `getAuthenticationForOperation` a third time and
400
+ * rebuild the requirements itself -- which is what both server emitters were doing, in two
401
+ * hand-written copies of one rule.
402
+ *
403
+ * `scopes` is unchanged in meaning and is now derived from `security` rather than separately read:
404
+ * the union of every scope any requirement demands, which is what it always was.
401
405
  */
402
- function noAuthFor(program, operation) {
403
- return (getAuthenticationForOperation(program, operation.operation)?.options.some((option) => option.schemes.some((scheme) => scheme.type === "noAuth")) === true);
406
+ function authenticationFor(program, operation) {
407
+ const options = getAuthenticationForOperation(program, operation.operation)?.options ?? [];
408
+ const security = [];
409
+ let noAuth = false;
410
+ for (const option of options) {
411
+ const requirement = {};
412
+ let anonymous = false;
413
+ for (const scheme of option.schemes) {
414
+ /**
415
+ * **`NoAuth` inside an option means that option needs nothing**, which is how a spec says
416
+ * authentication is optional here. It is not a scheme to demand, and publishing it as one
417
+ * would make an emitter refuse the anonymous callers the document permits.
418
+ */
419
+ if (scheme.type === "noAuth") {
420
+ anonymous = true;
421
+ noAuth = true;
422
+ continue;
423
+ }
424
+ /** Scopes belong to the flows of an OAuth2 scheme; every other kind has none. */
425
+ requirement[scheme.id] =
426
+ scheme.type === "oauth2"
427
+ ? [...new Set(scheme.flows.flatMap((flow) => flow.scopes.map((scope) => scope.value)))]
428
+ : [];
429
+ }
430
+ if (anonymous && Object.keys(requirement).length === 0)
431
+ continue;
432
+ if (Object.keys(requirement).length > 0)
433
+ security.push(requirement);
434
+ }
435
+ const scopes = [...new Set(security.flatMap((requirement) => Object.values(requirement).flat()))];
436
+ return { security, scopes, noAuth };
404
437
  }
405
438
  /**
406
439
  * The body every `@error` response in the service declares, when they all declare the same one.
@@ -680,7 +713,9 @@ function multipartSchemaOf(body, registry) {
680
713
  : registry.expressionFor(type);
681
714
  // `multi` is `HttpPart<T>[]` - the same part repeated, which arrives as an array.
682
715
  const value = part.multi === true ? `z.array(${inner})` : inner;
683
- return `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.optional()` : value},`;
716
+ // `.exactOptional()` for the same reason as a model property: `parseBody` yields present keys
717
+ // only, so an absent part is an absent key rather than an explicit `undefined`.
718
+ return `\t${objectKey(part.name ?? "")}: ${part.optional === true ? `${value}.exactOptional()` : value},`;
684
719
  });
685
720
  const shape = entries.length === 0 ? "z.object({})" : `z.object({\n${entries.join("\n")}\n})`;
686
721
  /**
@@ -1098,9 +1133,36 @@ export function collectRoutes(program, registry, service) {
1098
1133
  * express "absent". See {@link EmittedRoute.optionalBody}.
1099
1134
  */
1100
1135
  const optionalBody = bodyParameter?.property?.optional === true;
1101
- const bodyProperty = requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)
1136
+ /**
1137
+ * **An optional MULTIPART body reaches the same rule by a second route, because
1138
+ * `requestType` cannot see it.** `requestBodyOf` resolves a type only for
1139
+ * `bodyKind === "single"`, so `requestType` is `undefined` for every multipart operation
1140
+ * and the `optionalBody` disjunct above was unreachable however the document declared it.
1141
+ *
1142
+ * The emitted server for such an operation did not compile. Measured, and the mechanism is
1143
+ * worth stating because it is not a union: spreading a value typed `Parts | undefined`
1144
+ * makes every part OPTIONAL, so `{ file?: ...; pages?: ... }` was passed where
1145
+ * `{ file: ...; pages: ... }` was required. `TS2345`, inside `app.gen.ts` itself.
1146
+ *
1147
+ * That is the failure {@link EmittedRoute.optionalBody} predicts in prose - a merge has no
1148
+ * way to say "these properties are here only sometimes" without making every one of them
1149
+ * optional, which is a weaker and different claim about the body that IS sent. So this is
1150
+ * the stated rule reaching the one body kind it had never been applied to, not a new rule.
1151
+ *
1152
+ * **The REQUIRED arm is deliberately excluded.** The indexed reason does not apply to a
1153
+ * parts model, so a required multipart body merges as safely as a required single one, and
1154
+ * `typespec-hono/test/filepart/` compiles a handler that reads `input.file` directly.
1155
+ * Widening this to "multipart is always named" would rewrite that directory for no defect.
1156
+ *
1157
+ * This is the third hole the `"single"` gate has produced, after `multipartSchemaOf` and
1158
+ * `multipartTsOf`, both recorded above.
1159
+ */
1160
+ const optionalMultipartBody = bodyParameter?.bodyKind === "multipart" && optionalBody;
1161
+ const bodyProperty = (requestType?.kind === "Model" && (requestType.indexer !== undefined || optionalBody)) ||
1162
+ optionalMultipartBody
1102
1163
  ? (bodyParameter?.property?.name ?? "body")
1103
1164
  : undefined;
1165
+ const authentication = authenticationFor(program, operation);
1104
1166
  routes.push({
1105
1167
  operationId: operationIdOf(program, operation.operation),
1106
1168
  verb: operation.verb.toUpperCase(),
@@ -1186,12 +1248,9 @@ export function collectRoutes(program, registry, service) {
1186
1248
  errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
1187
1249
  responseHeaders: responseHeadersOf(program, operation),
1188
1250
  responseMediaTypes: responseMediaTypesOf(operation),
1189
- scopes: [
1190
- ...new Set((getAuthenticationForOperation(program, operation.operation)?.options ?? []).flatMap((option) => option.schemes.flatMap((scheme) => scheme.type === "oauth2"
1191
- ? scheme.flows.flatMap((flow) => flow.scopes.map((scope) => scope.value))
1192
- : []))),
1193
- ],
1194
- noAuth: noAuthFor(program, operation),
1251
+ scopes: authentication.scopes,
1252
+ security: authentication.security,
1253
+ noAuth: authentication.noAuth,
1195
1254
  statusBy: discriminator,
1196
1255
  statusSelector,
1197
1256
  /**
@@ -1266,7 +1325,26 @@ function renderExternalImports(externals, ...bodies) {
1266
1325
  * validate a request or a response against, and every oracle that graded those validators had to read
1267
1326
  * a server to find them. See {@link nameRouteSchemas}.
1268
1327
  */
1269
- function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1328
+ /**
1329
+ * **`z.compile(...)` around a validator, when `compile-schemas` asks for it.**
1330
+ *
1331
+ * Zod walks the schema once and emits a flat, loop-free function through `new Function`, falling back
1332
+ * to the ordinary parser for anything it cannot handle. Measured on zod 4.5.2 before this was written:
1333
+ * `z.toJSONSchema` output is byte-identical, `_zod.def` is unchanged and still readable, and verdicts
1334
+ * AND parsed output match for every construct this emitter writes - including `z.preprocess`,
1335
+ * `.exactOptional()` and a `z.lazy()` cycle. So the two oracles that read a schema rather than run it
1336
+ * are unaffected, and the wrapper is transparent.
1337
+ *
1338
+ * **A cyclic declaration is wrapped too, and that was worth checking rather than assuming.** The
1339
+ * emitted form names the const being defined from inside its own `z.lazy()` body, and this repository
1340
+ * has already shipped a break of exactly that shape - `.strict()` reads `shape` eagerly and throws
1341
+ * `Cannot access 'X' before initialization` during module initialisation. Measured on the emitted
1342
+ * shape: `z.compile()` does not force the lazy body, and the declaration parses.
1343
+ */
1344
+ function compiled(expression, on) {
1345
+ return on ? `z.compile(${expression})` : expression;
1346
+ }
1347
+ function renderSchemas(registry, externals, routeDeclarations, runtimeModule, compileSchemas) {
1270
1348
  const declarations = registry.declarations().map((d) => {
1271
1349
  /**
1272
1350
  * **A declaration on a CYCLE cannot take its type from `z.infer`, because that is the loop.**
@@ -1277,7 +1355,7 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1277
1355
  * with no cycle emits byte-identical output to before.
1278
1356
  */
1279
1357
  if (d.structural === undefined) {
1280
- return `\nexport const ${d.identifier} = ${d.source};\nexport type ${d.typeName} = Exact<z.infer<typeof ${d.identifier}>>;\n`;
1358
+ return `\nexport const ${d.identifier} = ${compiled(d.source, compileSchemas)};\nexport type ${d.typeName} = z.infer<typeof ${d.identifier}>;\n`;
1281
1359
  }
1282
1360
  const declared = d.structural.startsWith("{")
1283
1361
  ? `export interface ${d.typeName} ${d.structural}`
@@ -1308,7 +1386,22 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1308
1386
  * than from this type, so no handler signature is affected.
1309
1387
  */
1310
1388
  const annotation = d.annotated === true ? `: z.ZodType<${d.typeName}, ${d.typeName}>` : "";
1311
- return `\n${declared}\nexport const ${d.identifier}${annotation} = ${d.source};\n`;
1389
+ /**
1390
+ * **A deferred declaration is NEVER compiled, and this was measured rather than reasoned.**
1391
+ *
1392
+ * `z.lazy()` is on Zod's own list of constructs the compiler cannot handle, and here the body
1393
+ * forward-references a `const` declared further down the module. Wrapping it emitted output that
1394
+ * loaded fine and then threw `Cannot read properties of undefined (reading '_zod')` on the first
1395
+ * parse - the walk captures the reference while it is still in the temporal dead zone, so the
1396
+ * failure surfaces at request time rather than at import.
1397
+ *
1398
+ * **Keyed on `annotated`, the fact the registry already holds** - it is set exactly when the
1399
+ * declaration is deferred, which is exactly when `z.lazy()` is written - rather than on the
1400
+ * shape of the emitted text. A model on a cycle whose back edge sits on a GETTER is a different
1401
+ * case and is compiled like any other: measured, it parses, because a getter is not resolved
1402
+ * until something reads it.
1403
+ */
1404
+ return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.annotated !== true)};\n`;
1312
1405
  });
1313
1406
  /**
1314
1407
  * **Written only when something in the file names it**, like the `ResponseArm` import below and for
@@ -1327,39 +1420,24 @@ function renderSchemas(registry, externals, routeDeclarations, runtimeModule) {
1327
1420
  * rendered content for the same reason.
1328
1421
  */
1329
1422
  /**
1330
- * **`Exact` narrows an inferred optional to what the wire can carry.**
1331
- *
1332
- * `z.infer` of `.optional()` gives `p?: T | undefined`, which under `exactOptionalPropertyTypes`
1333
- * admits an explicitly-undefined property. **JSON has no `undefined`**, and the document says the
1334
- * same thing by leaving the property out of `required`, so the inferred type is wider than the
1335
- * wire and a consumer reading a payload had to strip it at the boundary.
1423
+ * **`Exact<>` lived here until `0.24.0`, and `z.exactOptional()` replaced it.**
1336
1424
  *
1337
- * **It does NOT match the contract type, and this docblock used to claim it did.** The
1338
- * contract types in `requests.gen.ts` keep `?: T | undefined` on purpose: they are the floor a
1339
- * PRODUCER supplies, and `{ p: undefined }` serialises identically to omitting `p`, so refusing
1340
- * it would cost every producer a conditional spread and buy the wire nothing. Measured when this
1341
- * was "fixed" to agree: `(ctx, input) => ok(input)` - return what you were given - stopped
1342
- * compiling.
1425
+ * It was a mapped type over `z.infer` that stripped `| undefined` off every optional property,
1426
+ * because `.optional()` infers `p?: T | undefined` and **JSON has no `undefined`** - the document
1427
+ * says "absent or a value" by leaving the property out of `required`, so the inferred type was
1428
+ * wider than both the document and the wire.
1343
1429
  *
1344
- * So the two surfaces differ on this axis deliberately, exactly as they differ on openness: this
1345
- * one is the narrow view of what ARRIVES, the contract type is the permissive floor for what you
1346
- * may SEND. A reader comparing them will find them unequal and should - the emitted assertion
1347
- * pairs the contract against raw `z.input`, not against this. They differ on a second axis for
1348
- * the same reason: a defaulted property is present here, because a default has fired by the time
1349
- * a value arrives, and optional in the contract type, because a caller may omit it.
1430
+ * The reasoning was right and it was applied to only one of the two artefacts. The VALIDATOR went
1431
+ * on accepting `{ p: undefined }`, so one program published a type refusing a value beside a
1432
+ * validator admitting it, compared by nothing. Emitting `.exactOptional()` makes `z.infer` produce
1433
+ * `p?: T` directly - at every depth, which a shallow mapped type never reached - so the narrowing
1434
+ * happens once, in the schema, and this helper has nothing left to do.
1350
1435
  *
1351
- * Derived from the same schema rather than hand-written beside it, so there is still one source of
1352
- * truth: a mapped type cannot drift from the thing it maps.
1353
- *
1354
- * Emitted only where a declaration names it, like the imports beside it.
1436
+ * The contract types in `requests.gen.ts` still keep `?: T | undefined` on purpose: they are the
1437
+ * floor a PRODUCER supplies, and `{ p: undefined }` serialises identically to omitting `p`, so
1438
+ * refusing it there would cost every producer a conditional spread and buy the wire nothing.
1439
+ * Narrowing THAT surface was tried in `0.19.0` and reverted - it broke `(ctx, input) => ok(input)`.
1355
1440
  */
1356
- const EXACT_HELPER = `
1357
- /** An inferred type narrowed to what JSON can carry: an optional property is absent or a value. */
1358
- type Exact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } & {
1359
- [K in keyof T as undefined extends T[K] ? K : never]?: Exclude<T[K], undefined>;
1360
- };
1361
- `;
1362
- const usesExact = declarations.some((d) => d.includes("Exact<"));
1363
1441
  const usesZod = new Set([...declarations, ...routeDeclarations].join("").match(/[A-Za-z_$][\w$]*/g) ?? []).has("z");
1364
1442
  const parts = [generatedBanner()];
1365
1443
  if (usesZod)
@@ -1376,8 +1454,6 @@ type Exact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } &
1376
1454
  parts.push(`import type { ResponseArm } from ${JSON.stringify(runtimeModule)};\n`);
1377
1455
  }
1378
1456
  parts.push(renderExternalImports(externals, [...declarations, ...routeDeclarations].join("")));
1379
- if (usesExact)
1380
- parts.push(EXACT_HELPER);
1381
1457
  parts.push(declarations.join(""));
1382
1458
  if (routeDeclarations.length > 0)
1383
1459
  parts.push(`\n${routeDeclarations.join("\n")}\n`);
@@ -1394,7 +1470,7 @@ type Exact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } &
1394
1470
  const isIdentifier = (expression) => /^[A-Za-z_$][\w$]*$/.test(expression);
1395
1471
  const schemaConst = (operationId, suffix) => `${operationId}${suffix}`;
1396
1472
  /** `[the name to refer to it by, the `const` line to emit]` - the line is absent when none is needed. */
1397
- function namedSchema(operationId, suffix, expression) {
1473
+ function namedSchema(operationId, suffix, expression, compileSchemas) {
1398
1474
  if (expression === undefined)
1399
1475
  return { name: undefined, declaration: undefined };
1400
1476
  if (isIdentifier(expression))
@@ -1411,7 +1487,7 @@ function namedSchema(operationId, suffix, expression) {
1411
1487
  * Exporting also gives an application the request shape by name, which is what the per-operation
1412
1488
  * type aliases will be built from.
1413
1489
  */
1414
- return { name, declaration: `export const ${name} = ${expression};` };
1490
+ return { name, declaration: `export const ${name} = ${compiled(expression, compileSchemas)};` };
1415
1491
  }
1416
1492
  /**
1417
1493
  * Which `verb path` slots carry more than one operation.
@@ -1453,13 +1529,13 @@ function sharedSlots(routes) {
1453
1529
  * Declaring them here dissolves all three: the library mints the names AND emits them, and a server
1454
1530
  * emitter imports what it is told rather than agreeing about it.
1455
1531
  */
1456
- function nameRouteSchemas(routes) {
1532
+ function nameRouteSchemas(routes, compileSchemas) {
1457
1533
  const shared = sharedSlots(routes);
1458
1534
  const declarations = [];
1459
1535
  const names = new Map();
1460
1536
  for (const route of routes) {
1461
1537
  const declare = (suffix, expression) => {
1462
- const resolved = namedSchema(route.operationId, suffix, expression);
1538
+ const resolved = namedSchema(route.operationId, suffix, expression, compileSchemas);
1463
1539
  if (resolved.declaration !== undefined)
1464
1540
  declarations.push(resolved.declaration);
1465
1541
  return resolved.name;
@@ -1611,61 +1687,83 @@ function collectRequestTypes(program, registry, service) {
1611
1687
  for (const operation of service.operations) {
1612
1688
  if (successStatusOf(operation) === undefined)
1613
1689
  continue;
1614
- const body = requestBodyOf(operation);
1615
- /** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
1616
- const multipartBody = operation.parameters.body;
1617
- const multipart = multipartBody?.bodyKind === "multipart" ? { parts: multipartBody.parts } : undefined;
1618
1690
  /**
1619
- * Headers are part of the input too.
1691
+ * **Walked at the operation's REQUEST visibility, which is the whole point of this file.**
1692
+ *
1693
+ * `resolveRequestVisibility` is `@typespec/http`'s own answer - POST is Create, PATCH is
1694
+ * Update, PUT is CreateOrUpdate - and it is what the Zod walk already uses at the route
1695
+ * level. Without it this walk ran at the default `Read`, so a POST body's contract type
1696
+ * carried the canonical property set while the validator beside it accepted the create
1697
+ * projection. A caller satisfying the published type sent read-only fields and a strict
1698
+ * validator answered 400.
1620
1699
  *
1621
- * The webhook's three Standard-Webhooks headers are inside the MAC - they are not metadata
1622
- * beside the payload, they are arguments to the verification. Leaving them out of the wire
1623
- * type would describe an input the application cannot act on.
1700
+ * It is also what keeps `metadataInfo` scoped: the predicates `propertyToTs` consults live
1701
+ * behind `withVisibility`, and calling them outside one leaves a module-level singleton
1702
+ * built for the wrong program.
1624
1703
  */
1625
- const extraProperties = operation.parameters.parameters
1626
- .filter((parameter) => parameter.type === "path" || parameter.type === "query" || parameter.type === "header")
1704
+ withVisibility(program, resolveRequestVisibility(program, operation.operation, operation.verb), () => {
1705
+ const body = requestBodyOf(operation);
1706
+ /** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
1707
+ const multipartBody = operation.parameters.body;
1708
+ const multipart = multipartBody?.bodyKind === "multipart" ? { parts: multipartBody.parts } : undefined;
1627
1709
  /**
1628
- * Named as the WIRE names it, which is what the emitted validator keys on.
1629
- * `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
1630
- * `"x-thing"` here too: a consumer checking its handlers against these types otherwise
1631
- * fails to typecheck against a server that is correct.
1710
+ * Headers are part of the input too.
1711
+ *
1712
+ * The webhook's three Standard-Webhooks headers are inside the MAC - they are not metadata
1713
+ * beside the payload, they are arguments to the verification. Leaving them out of the wire
1714
+ * type would describe an input the application cannot act on.
1632
1715
  */
1633
- .map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
1634
- if (body === undefined && extraProperties.length === 0)
1635
- continue;
1636
- /**
1637
- * A `bytes` body reaches the use-case as a NAMED property holding the unparsed body, not as
1638
- * a bare value, so the wire type is `{ rawBody: ... }`, matching what a server actually
1639
- * builds. Referencing the scalar directly would type the whole input as that value and
1640
- * quietly drop the headers beside it.
1641
- */
1642
- const rawBody = body?.kind === "Scalar" && body.name === "bytes"
1643
- ? (operation.parameters.body?.property?.name ?? "body")
1644
- : undefined;
1645
- /**
1646
- * **Raw binary is `ArrayBuffer`; base64 inside a JSON payload is `string`.** The same
1647
- * `isRawBinaryMediaType` rule the response side and the multipart parts already follow,
1648
- * applied here for the first time.
1649
- *
1650
- * This type said `string` for every `bytes` body whatever it was served as. A server reading
1651
- * an `application/octet-stream` body hands the handler bytes, so a consumer checking its
1652
- * handlers against these types could not typecheck against a correct server. It is the
1653
- * request half of the corruption already fixed on the reader: decoding raw bytes as text
1654
- * replaces every byte outside ASCII, and typing them as text is what invites it.
1655
- */
1656
- const rawBodyType = isRawBinaryMediaType([...(operation.parameters.body?.contentTypes ?? [])])
1657
- ? "ArrayBuffer"
1658
- : "string";
1659
- entries.push({
1660
- operationId: operationIdOf(program, operation.operation),
1661
- bodyRef: rawBody !== undefined
1662
- ? `{\n\t${rawBody}: ${rawBodyType};\n}`
1663
- : multipart !== undefined
1664
- ? multipartTsOf(multipart, registry)
1665
- : body === undefined
1666
- ? undefined
1667
- : registry.expressionFor(body),
1668
- extraProperties,
1716
+ const extraProperties = operation.parameters.parameters
1717
+ .filter((parameter) => parameter.type === "path" ||
1718
+ parameter.type === "query" ||
1719
+ parameter.type === "header")
1720
+ /**
1721
+ * Named as the WIRE names it, which is what the emitted validator keys on.
1722
+ * `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
1723
+ * `"x-thing"` here too: a consumer checking its handlers against these types otherwise
1724
+ * fails to typecheck against a server that is correct.
1725
+ */
1726
+ .map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
1727
+ // `return` rather than `continue`: this body is a callback now, and returning from it
1728
+ // skips the operation exactly as the `continue` did.
1729
+ if (body === undefined && extraProperties.length === 0)
1730
+ return;
1731
+ /**
1732
+ * A `bytes` body reaches the use-case as a NAMED property holding the unparsed body, not as
1733
+ * a bare value, so the wire type is `{ rawBody: ... }`, matching what a server actually
1734
+ * builds. Referencing the scalar directly would type the whole input as that value and
1735
+ * quietly drop the headers beside it.
1736
+ */
1737
+ const rawBody = body?.kind === "Scalar" && body.name === "bytes"
1738
+ ? (operation.parameters.body?.property?.name ?? "body")
1739
+ : undefined;
1740
+ /**
1741
+ * **Raw binary is `ArrayBuffer`; base64 inside a JSON payload is `string`.** The same
1742
+ * `isRawBinaryMediaType` rule the response side and the multipart parts already follow,
1743
+ * applied here for the first time.
1744
+ *
1745
+ * This type said `string` for every `bytes` body whatever it was served as. A server reading
1746
+ * an `application/octet-stream` body hands the handler bytes, so a consumer checking its
1747
+ * handlers against these types could not typecheck against a correct server. It is the
1748
+ * request half of the corruption already fixed on the reader: decoding raw bytes as text
1749
+ * replaces every byte outside ASCII, and typing them as text is what invites it.
1750
+ */
1751
+ const rawBodyType = isRawBinaryMediaType([
1752
+ ...(operation.parameters.body?.contentTypes ?? []),
1753
+ ])
1754
+ ? "ArrayBuffer"
1755
+ : "string";
1756
+ entries.push({
1757
+ operationId: operationIdOf(program, operation.operation),
1758
+ bodyRef: rawBody !== undefined
1759
+ ? `{\n\t${rawBody}: ${rawBodyType};\n}`
1760
+ : multipart !== undefined
1761
+ ? multipartTsOf(multipart, registry)
1762
+ : body === undefined
1763
+ ? undefined
1764
+ : registry.expressionFor(body),
1765
+ extraProperties,
1766
+ });
1669
1767
  });
1670
1768
  }
1671
1769
  }
@@ -1682,7 +1780,15 @@ function collectRequestTypes(program, registry, service) {
1682
1780
  */
1683
1781
  function collectResponseTypes(program, registry, service) {
1684
1782
  const entries = [];
1685
- {
1783
+ /**
1784
+ * **Pinned to `Read` explicitly, rather than inheriting whatever the caller last set.**
1785
+ *
1786
+ * A response is the canonical projection whatever verb produced it, and the same rule is stated
1787
+ * for the route walk: resolving responses under the ambient REQUEST visibility declared six
1788
+ * components under the wrong suffix. Saying it here rather than relying on the default is what
1789
+ * makes that impossible from either direction.
1790
+ */
1791
+ withVisibility(program, Visibility.Read, () => {
1686
1792
  for (const operation of service.operations) {
1687
1793
  const status = successStatusOf(operation);
1688
1794
  if (status === undefined)
@@ -1695,7 +1801,7 @@ function collectResponseTypes(program, registry, service) {
1695
1801
  ref: registry.expressionFor(body),
1696
1802
  });
1697
1803
  }
1698
- }
1804
+ });
1699
1805
  return entries;
1700
1806
  }
1701
1807
  /**
@@ -1709,10 +1815,23 @@ function collectResponseTypes(program, registry, service) {
1709
1815
  */
1710
1816
  function renderRequestTypes(entries, responses, registry) {
1711
1817
  const declarations = registry.declarations().map((declaration) => {
1712
- // A vocabulary alias stays module-local: the vocabularies artefact already exports these names
1713
- // as runtime tuples, and exporting both collides on every one.
1818
+ /**
1819
+ * **A vocabulary alias is exported like any other declaration.**
1820
+ *
1821
+ * It used to stay module-local, on the stated ground that "the vocabularies artefact already
1822
+ * exports these names as runtime tuples, and exporting both collides on every one". That was
1823
+ * true of a shape this emitter no longer produces: `vocabularies.gen.ts` has exactly one
1824
+ * top-level export, `SPEC_VOCABULARIES`, and the enum names are KEYS inside it - see the
1825
+ * docblock on `renderVocabularies`, which records why the keyed form replaced individually
1826
+ * named tuples. There is nothing left to collide with.
1827
+ *
1828
+ * The stale rationale was load-bearing: `wire-contract.gen.ts` names `Contracts.<EnumName>`
1829
+ * for an enum used as a request body, and a barrel cannot re-export a declaration that is not
1830
+ * exported. So that file did not compile for any such spec. `TS2694`, found the first time
1831
+ * anything compiled the conformance corpus rather than reading it.
1832
+ */
1714
1833
  if (declaration.isVocabulary)
1715
- return `\ntype ${declaration.name} = ${declaration.source};\n`;
1834
+ return `\nexport type ${declaration.name} = ${declaration.source};\n`;
1716
1835
  return declaration.isObject
1717
1836
  ? `\nexport interface ${declaration.name} ${declaration.source}\n`
1718
1837
  : `\nexport type ${declaration.name} = ${declaration.source};\n`;
@@ -1913,6 +2032,26 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
1913
2032
  * dropped from a shape that has other keys, and kept - with its value type still mapped - from one
1914
2033
  * that does not.
1915
2034
  */
2035
+ /**
2036
+ * \u26a0\ufe0f **An optional property is \`?: T\` here and \`?: T | undefined\` in the contract type, and that is
2037
+ * deliberate on both sides.**
2038
+ *
2039
+ * The validator emits \`.exactOptional()\`, so what ARRIVES is absent or a value - JSON cannot carry an
2040
+ * \`undefined\` and the document says exactly that by leaving the property out of \`required\`. The
2041
+ * contract type is the floor a PRODUCER supplies, and \`{ p: undefined }\` serialises identically to
2042
+ * omitting \`p\`, so refusing it would cost every producer a conditional spread and buy the wire
2043
+ * nothing. Narrowing THAT surface was tried in \`0.19.0\` and reverted: it broke
2044
+ * \`(ctx, input) => ok(input)\`.
2045
+ *
2046
+ * So the two disagree on exactly one axis, on purpose, and this widens the inferred side to the
2047
+ * producer floor before comparing - the same job \`Declared\` does for openness, in the same pass
2048
+ * rather than a second deep one. Every other kind of drift still fails: a property that changes type,
2049
+ * appears, disappears or changes optionality is untouched by this.
2050
+ */
2051
+ type Supplied<T, K extends keyof T> = {} extends Pick<T, K>
2052
+ ? Declared<T[K]> | undefined
2053
+ : Declared<T[K]>;
2054
+
1916
2055
  type DeclaredKeyOf<T> = keyof {
1917
2056
  [K in keyof T as string extends K
1918
2057
  ? never
@@ -1931,7 +2070,20 @@ type Declared<T> = T extends (...args: never[]) => unknown
1931
2070
  : readonly Declared<Element>[]
1932
2071
  : T extends object
1933
2072
  ? [DeclaredKeyOf<T>] extends [never]
1934
- ? { [K in keyof T]: Declared<T[K]> }
2073
+ ? [T[keyof T]] extends [never]
2074
+ ? // **An indexer whose VALUE type is \`never\` admits no property, so it is an EMPTY
2075
+ // OBJECT rather than a dictionary.** Zod spells an empty object that way:
2076
+ // \`z.strictObject({})\` infers \`Record<string, never>\` under Zod 4, while this
2077
+ // walk emits \`{}\` for the same model. The two describe one shape and compared
2078
+ // unequal, so \`wire-contract.gen.ts\` did not compile for any spec declaring an
2079
+ // empty model. Normalising both to \`{}\` is what makes them agree, and it cannot
2080
+ // equate a real dictionary with an empty object: a dictionary whose values are
2081
+ // \`never\` accepts nothing either.
2082
+ {}
2083
+ : // A shape with no declared keys is a dictionary: it has no optional property to
2084
+ // widen, and an index signature satisfies \`{} extends Pick<T, K>\` for every
2085
+ // value type.
2086
+ { [K in keyof T]: Declared<T[K]> }
1935
2087
  : {
1936
2088
  [K in keyof T as string extends K
1937
2089
  ? never
@@ -1939,7 +2091,7 @@ type Declared<T> = T extends (...args: never[]) => unknown
1939
2091
  ? never
1940
2092
  : symbol extends K
1941
2093
  ? never
1942
- : K]: Declared<T[K]>;
2094
+ : K]: Supplied<T, K>;
1943
2095
  }
1944
2096
  : T;
1945
2097
 
@@ -1990,6 +2142,49 @@ export const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
1990
2142
  * copies that will not, and the symptom would be a subtly different set of declarations in a file
1991
2143
  * whose name is the contract.
1992
2144
  */
2145
+ /**
2146
+ * The doc tags TypeSpec understands. Anything else parses as an unknown tag, and the description
2147
+ * ends where it began. Written out rather than imported: `SyntaxKind` lives behind
2148
+ * `@typespec/compiler/ast`, whose exports map declares no `types` condition, and comparing the tag
2149
+ * NAME needs no AST import at all.
2150
+ */
2151
+ const KNOWN_DOC_TAGS = new Set(["param", "template", "prop", "returns", "return", "errors"]);
2152
+ /** See the `truncated-doc-comment` docblock in `lib.ts` for why this reads the node, not the string. */
2153
+ function reportTruncatedDocComments(program) {
2154
+ const check = (type) => {
2155
+ if (getLocationContext(program, type).type !== "project")
2156
+ return;
2157
+ // An `@doc(...)` decorator wins, and `@` inside a string literal truncates nothing.
2158
+ if (getDocData(program, type)?.source !== "comment")
2159
+ return;
2160
+ const docs = type.node?.docs;
2161
+ for (const doc of docs ?? []) {
2162
+ for (const tag of doc.tags) {
2163
+ if (KNOWN_DOC_TAGS.has(tag.tagName.sv))
2164
+ continue;
2165
+ reportDiagnostic(program, {
2166
+ code: "truncated-doc-comment",
2167
+ target: type,
2168
+ format: {
2169
+ name: typeof type.name === "string" && type.name !== "" ? type.name : "(anonymous)",
2170
+ tag: tag.tagName.sv,
2171
+ },
2172
+ });
2173
+ }
2174
+ }
2175
+ };
2176
+ navigateProgram(program, {
2177
+ model: check,
2178
+ modelProperty: check,
2179
+ operation: check,
2180
+ enum: check,
2181
+ enumMember: check,
2182
+ union: check,
2183
+ unionVariant: check,
2184
+ interface: check,
2185
+ scalar: check,
2186
+ });
2187
+ }
1993
2188
  export async function emitHttpZod(context, wrapper = {}) {
1994
2189
  const options = context.options;
1995
2190
  const defaultRuntimeModule = wrapper.defaultRuntimeModule ?? DEFAULT_RUNTIME_MODULE;
@@ -2064,6 +2259,11 @@ export async function emitHttpZod(context, wrapper = {}) {
2064
2259
  format: { sealed: sealed.join(", "), open: open.join(", ") },
2065
2260
  });
2066
2261
  }
2262
+ /**
2263
+ * Once per program, before the per-service loop: a doc comment belongs to a type, not to a
2264
+ * service, and a type reachable from two services would otherwise be named twice.
2265
+ */
2266
+ reportTruncatedDocComments(context.program);
2067
2267
  for (const snapshot of snapshots) {
2068
2268
  beginOperationIds();
2069
2269
  const service = snapshot.service;
@@ -2077,6 +2277,7 @@ export async function emitHttpZod(context, wrapper = {}) {
2077
2277
  contractsOutputDir: perService?.["contracts-output-dir"] ?? options["contracts-output-dir"],
2078
2278
  contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
2079
2279
  sealObjectSchemas: perService?.["seal-object-schemas"] ?? options["seal-object-schemas"] ?? false,
2280
+ compileSchemas: perService?.["compile-schemas"] ?? options["compile-schemas"] ?? false,
2080
2281
  keyVocabularies: perService?.["key-vocabularies"] ?? options["key-vocabularies"] ?? [],
2081
2282
  runtimeModule: perService?.["runtime-module"] ?? options["runtime-module"] ?? defaultRuntimeModule,
2082
2283
  regenerateHint: perService?.["regenerate-hint"] ?? options["regenerate-hint"],
@@ -2133,10 +2334,10 @@ export async function emitHttpZod(context, wrapper = {}) {
2133
2334
  * anything is written - and it has to happen HERE rather than in a server emitter, or the
2134
2335
  * validators it produces never reach this package's own output.
2135
2336
  */
2136
- const { names, declarations } = nameRouteSchemas(routes);
2337
+ const { names, declarations } = nameRouteSchemas(routes, resolved.compileSchemas);
2137
2338
  await emitFile(context.program, {
2138
2339
  path: resolvePath(outputDir, "schemas.gen.ts"),
2139
- content: renderSchemas(registry, externals, declarations, resolved.runtimeModule),
2340
+ content: renderSchemas(registry, externals, declarations, resolved.runtimeModule, resolved.compileSchemas),
2140
2341
  });
2141
2342
  if (resolved.contractsOutputDir !== undefined) {
2142
2343
  const types = new TypeRegistry(context.program);