typespec-http-zod 0.24.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/README.md CHANGED
@@ -1,14 +1,60 @@
1
1
  # typespec-http-zod
2
2
 
3
- Generate Zod validators and framework-free TypeScript types from a TypeSpec HTTP service.
3
+ Zod validators generated from the same API definition as your OpenAPI document.
4
4
 
5
- The emitted validators agree, keyword for keyword, with the OpenAPI document
6
- [`@typespec/openapi3`](https://typespec.io) publishes from the same source. Use them with Express,
7
- Fastify, Elysia, a Workers fetch handler, a typed client, or a test suite. No server framework is
8
- required.
5
+ ## What is it?
9
6
 
10
- It is also a library. [`typespec-hono`](https://github.com/bison-digital/typespec-hono) is an emitter
11
- built on its API.
7
+ An emitter for [TypeSpec](https://typespec.io), Microsoft's language for describing APIs. Describe
8
+ your API once, and this package generates the Zod schemas that validate every request and response,
9
+ plus the TypeScript types to go with them.
10
+
11
+ TypeSpec compiles to OpenAPI as well, so the same definition also gives you documentation that cannot
12
+ drift from the validation.
13
+
14
+ One model:
15
+
16
+ ```tsp
17
+ model Widget {
18
+ id: string;
19
+
20
+ @minLength(1)
21
+ name: string;
22
+ }
23
+ ```
24
+
25
+ The validators and types this package generates:
26
+
27
+ ```ts
28
+ export const widgetSchema = z.object({
29
+ id: z.string(),
30
+ name: z.string().min(1),
31
+ });
32
+
33
+ export type Widget = z.infer<typeof widgetSchema>;
34
+ ```
35
+
36
+ And the OpenAPI that [`@typespec/openapi3`](https://typespec.io) generates from that same model:
37
+
38
+ ```jsonc
39
+ "Widget": {
40
+ "type": "object",
41
+ "required": ["id", "name"],
42
+ "properties": {
43
+ "id": { "type": "string" },
44
+ "name": { "type": "string", "minLength": 1 }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ## Features
50
+
51
+ - **Documentation and validation cannot drift** - both come from one definition, so there is no
52
+ second source to keep in step
53
+ - **One runtime dependency** - `zod`, and nothing else
54
+ - **No framework** - use the validators in a server, a client, or a test
55
+ - **Types with zero imports** - a contracts module anything can depend on
56
+ - **Built on a public API** - [`typespec-hono`](https://github.com/bison-digital/typespec-hono)
57
+ generates a whole Hono server from it
12
58
 
13
59
  ## Install
14
60
 
@@ -138,6 +184,12 @@ then a range such as `4XX`, then `default`.
138
184
  | `vocabularies.gen.ts` | each enum's members as a runtime tuple | `contracts-output-dir` is set |
139
185
  | `wire-contract.gen.ts` | assertions pairing the emitted Zod against those types | that and `contracts-package` |
140
186
 
187
+ `wire-contract.gen.ts` is not imported by any other file. The file contains type-level assertions
188
+ pairing each generated validator with its contract type, so a mismatch between the two is a compile
189
+ error. TypeScript reaches the file through `tsconfig`'s `include`, which has two consequences worth
190
+ knowing before removing it: dependency scanners report the file as unused, and deleting it does not
191
+ break the build until the validator and the contract type diverge.
192
+
141
193
  Identifiers are named from the operation id the document publishes. An operation inside a namespace
142
194
  carries the whole id, so `op readWidget` in `namespace Widgets` becomes `Widgets_readWidgetPath`.
143
195
 
package/dist/src/api.d.ts CHANGED
@@ -42,7 +42,7 @@ export { EmitterOptionsSchema, type EmitterOptions } from "./lib.js";
42
42
  * emitted module stopped being loadable. This is not a style choice a second emitter may make
43
43
  * independently.
44
44
  */
45
- export { objectKey } from "./zod.js";
45
+ export { jsDocComment, objectKey } from "./zod.js";
46
46
  export declare function beginBanner(hint: string | undefined): void;
47
47
  /**
48
48
  * The status a row registers: the first non-error status the operation declares.
@@ -69,6 +69,22 @@ export declare function successStatusesOf(operation: HttpOperation): number[];
69
69
  * listed above", and inventing one would be answering a question the contract left open.
70
70
  */
71
71
  export type StatusKey = number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
72
+ /**
73
+ * Whether the operation declares `@useAuth(NoAuth)` - no caller is established for it.
74
+ *
75
+ * **`@useAuth(NoAuth)` reaches OpenAPI as `security: []`, which is why this is derivable at all.**
76
+ * Whether an operation needs a caller is a contract fact the document publishes. What KIND of caller
77
+ * - account-scoped, resource-scoped - is not: no OpenAPI keyword expresses it, and a server emitter
78
+ * that guesses is enforcing something no document states.
79
+ */
80
+ /**
81
+ * One requirement the document publishes: scheme name to the scopes it demands.
82
+ *
83
+ * Satisfying **any one** requirement authorises a caller, and every scheme WITHIN a requirement must
84
+ * be satisfied together -- which is what an array of OpenAPI `security` objects means, and why this
85
+ * is published as a list of lists rather than a flat set of scopes.
86
+ */
87
+ export type SecurityRequirement = Readonly<Record<string, readonly string[]>>;
72
88
  /**
73
89
  * The body every `@error` response in the service declares, when they all declare the same one.
74
90
  *
@@ -298,6 +314,21 @@ export interface EmittedRoute {
298
314
  * `requireScopes(...)` repeated per route that nothing checks against it.
299
315
  */
300
316
  readonly scopes: readonly string[];
317
+ /**
318
+ * What the DOCUMENT says a caller must satisfy, in the shape the document says it.
319
+ *
320
+ * **`scopes` and `noAuth` are lossy projections of this, and publishing only those was a defect
321
+ * both server emitters had to work around.** A flat scope union cannot say WHICH scheme demanded
322
+ * what, so `@useAuth(BearerAuth)` -- which reaches OpenAPI as `security: [{ "BearerAuth": [] }]`
323
+ * -- arrived here as an empty scope list indistinguishable from no authentication at all. An
324
+ * emitter wanting the gate the document publishes had to call `getAuthenticationForOperation`
325
+ * again and rebuild this itself, and two of them did.
326
+ *
327
+ * Satisfying **any one** requirement authorises; every scheme within a requirement must be
328
+ * satisfied together. Empty where the operation declares `@useAuth(NoAuth)` or nothing, which is
329
+ * a fact rather than an absence.
330
+ */
331
+ readonly security: readonly SecurityRequirement[];
301
332
  /** Which of two success statuses to answer, and the literal property that decides it. */
302
333
  readonly statusBy: {
303
334
  property: string;
@@ -405,15 +436,4 @@ export interface EmitHttpZodOptions {
405
436
  /** The specifier generated files import their runtime contract from when the consumer sets none. */
406
437
  readonly defaultRuntimeModule?: string;
407
438
  }
408
- /**
409
- * Emit this library's artefacts for every `@service` in the program, and hand back what was emitted.
410
- *
411
- * **The whole orchestration lives here, once.** The nesting below is load-bearing - a walk that
412
- * runs outside `collectExternalImports` throws, vocabularies must be collected from what the service
413
- * DECLARES rather than from what the Zod walk happens to touch, and the error body has to be
414
- * registered inside the walk or its validator never reaches `schemas.gen.ts`. An emitter built on this
415
- * library calls this rather than restating it: two copies of a sequence that must agree are two
416
- * copies that will not, and the symptom would be a subtly different set of declarations in a file
417
- * whose name is the contract.
418
- */
419
439
  export declare function emitHttpZod(context: EmitContext, wrapper?: EmitHttpZodOptions): Promise<readonly EmittedService[]>;
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.
@@ -1100,9 +1133,36 @@ export function collectRoutes(program, registry, service) {
1100
1133
  * express "absent". See {@link EmittedRoute.optionalBody}.
1101
1134
  */
1102
1135
  const optionalBody = bodyParameter?.property?.optional === true;
1103
- 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
1104
1163
  ? (bodyParameter?.property?.name ?? "body")
1105
1164
  : undefined;
1165
+ const authentication = authenticationFor(program, operation);
1106
1166
  routes.push({
1107
1167
  operationId: operationIdOf(program, operation.operation),
1108
1168
  verb: operation.verb.toUpperCase(),
@@ -1188,12 +1248,9 @@ export function collectRoutes(program, registry, service) {
1188
1248
  errorArms: withVisibility(program, Visibility.Read, () => errorArmsOf(program, operation, registry)),
1189
1249
  responseHeaders: responseHeadersOf(program, operation),
1190
1250
  responseMediaTypes: responseMediaTypesOf(operation),
1191
- scopes: [
1192
- ...new Set((getAuthenticationForOperation(program, operation.operation)?.options ?? []).flatMap((option) => option.schemes.flatMap((scheme) => scheme.type === "oauth2"
1193
- ? scheme.flows.flatMap((flow) => flow.scopes.map((scope) => scope.value))
1194
- : []))),
1195
- ],
1196
- noAuth: noAuthFor(program, operation),
1251
+ scopes: authentication.scopes,
1252
+ security: authentication.security,
1253
+ noAuth: authentication.noAuth,
1197
1254
  statusBy: discriminator,
1198
1255
  statusSelector,
1199
1256
  /**
@@ -1630,61 +1687,83 @@ function collectRequestTypes(program, registry, service) {
1630
1687
  for (const operation of service.operations) {
1631
1688
  if (successStatusOf(operation) === undefined)
1632
1689
  continue;
1633
- const body = requestBodyOf(operation);
1634
- /** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
1635
- const multipartBody = operation.parameters.body;
1636
- const multipart = multipartBody?.bodyKind === "multipart" ? { parts: multipartBody.parts } : undefined;
1637
1690
  /**
1638
- * Headers are part of the input too.
1691
+ * **Walked at the operation's REQUEST visibility, which is the whole point of this file.**
1639
1692
  *
1640
- * The webhook's three Standard-Webhooks headers are inside the MAC - they are not metadata
1641
- * beside the payload, they are arguments to the verification. Leaving them out of the wire
1642
- * type would describe an input the application cannot act on.
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.
1699
+ *
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.
1643
1703
  */
1644
- const extraProperties = operation.parameters.parameters
1645
- .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;
1646
1709
  /**
1647
- * Named as the WIRE names it, which is what the emitted validator keys on.
1648
- * `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
1649
- * `"x-thing"` here too: a consumer checking its handlers against these types otherwise
1650
- * 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.
1651
1715
  */
1652
- .map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
1653
- if (body === undefined && extraProperties.length === 0)
1654
- continue;
1655
- /**
1656
- * A `bytes` body reaches the use-case as a NAMED property holding the unparsed body, not as
1657
- * a bare value, so the wire type is `{ rawBody: ... }`, matching what a server actually
1658
- * builds. Referencing the scalar directly would type the whole input as that value and
1659
- * quietly drop the headers beside it.
1660
- */
1661
- const rawBody = body?.kind === "Scalar" && body.name === "bytes"
1662
- ? (operation.parameters.body?.property?.name ?? "body")
1663
- : undefined;
1664
- /**
1665
- * **Raw binary is `ArrayBuffer`; base64 inside a JSON payload is `string`.** The same
1666
- * `isRawBinaryMediaType` rule the response side and the multipart parts already follow,
1667
- * applied here for the first time.
1668
- *
1669
- * This type said `string` for every `bytes` body whatever it was served as. A server reading
1670
- * an `application/octet-stream` body hands the handler bytes, so a consumer checking its
1671
- * handlers against these types could not typecheck against a correct server. It is the
1672
- * request half of the corruption already fixed on the reader: decoding raw bytes as text
1673
- * replaces every byte outside ASCII, and typing them as text is what invites it.
1674
- */
1675
- const rawBodyType = isRawBinaryMediaType([...(operation.parameters.body?.contentTypes ?? [])])
1676
- ? "ArrayBuffer"
1677
- : "string";
1678
- entries.push({
1679
- operationId: operationIdOf(program, operation.operation),
1680
- bodyRef: rawBody !== undefined
1681
- ? `{\n\t${rawBody}: ${rawBodyType};\n}`
1682
- : multipart !== undefined
1683
- ? multipartTsOf(multipart, registry)
1684
- : body === undefined
1685
- ? undefined
1686
- : registry.expressionFor(body),
1687
- 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
+ });
1688
1767
  });
1689
1768
  }
1690
1769
  }
@@ -1701,7 +1780,15 @@ function collectRequestTypes(program, registry, service) {
1701
1780
  */
1702
1781
  function collectResponseTypes(program, registry, service) {
1703
1782
  const entries = [];
1704
- {
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, () => {
1705
1792
  for (const operation of service.operations) {
1706
1793
  const status = successStatusOf(operation);
1707
1794
  if (status === undefined)
@@ -1714,7 +1801,7 @@ function collectResponseTypes(program, registry, service) {
1714
1801
  ref: registry.expressionFor(body),
1715
1802
  });
1716
1803
  }
1717
- }
1804
+ });
1718
1805
  return entries;
1719
1806
  }
1720
1807
  /**
@@ -1728,10 +1815,23 @@ function collectResponseTypes(program, registry, service) {
1728
1815
  */
1729
1816
  function renderRequestTypes(entries, responses, registry) {
1730
1817
  const declarations = registry.declarations().map((declaration) => {
1731
- // A vocabulary alias stays module-local: the vocabularies artefact already exports these names
1732
- // 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
+ */
1733
1833
  if (declaration.isVocabulary)
1734
- return `\ntype ${declaration.name} = ${declaration.source};\n`;
1834
+ return `\nexport type ${declaration.name} = ${declaration.source};\n`;
1735
1835
  return declaration.isObject
1736
1836
  ? `\nexport interface ${declaration.name} ${declaration.source}\n`
1737
1837
  : `\nexport type ${declaration.name} = ${declaration.source};\n`;
@@ -1970,9 +2070,20 @@ type Declared<T> = T extends (...args: never[]) => unknown
1970
2070
  : readonly Declared<Element>[]
1971
2071
  : T extends object
1972
2072
  ? [DeclaredKeyOf<T>] extends [never]
1973
- ? // A shape with no declared keys is a dictionary: it has no optional property to widen,
1974
- // and an index signature satisfies \`{} extends Pick<T, K>\` for every value type.
1975
- { [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]> }
1976
2087
  : {
1977
2088
  [K in keyof T as string extends K
1978
2089
  ? never
@@ -2031,6 +2142,49 @@ export const DEFAULT_RUNTIME_MODULE = "typespec-http-zod/runtime";
2031
2142
  * copies that will not, and the symptom would be a subtly different set of declarations in a file
2032
2143
  * whose name is the contract.
2033
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
+ }
2034
2188
  export async function emitHttpZod(context, wrapper = {}) {
2035
2189
  const options = context.options;
2036
2190
  const defaultRuntimeModule = wrapper.defaultRuntimeModule ?? DEFAULT_RUNTIME_MODULE;
@@ -2105,6 +2259,11 @@ export async function emitHttpZod(context, wrapper = {}) {
2105
2259
  format: { sealed: sealed.join(", "), open: open.join(", ") },
2106
2260
  });
2107
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);
2108
2267
  for (const snapshot of snapshots) {
2109
2268
  beginOperationIds();
2110
2269
  const service = snapshot.service;
package/dist/src/lib.d.ts CHANGED
@@ -155,6 +155,9 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
155
155
  "default-on-required-property": {
156
156
  readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
157
157
  };
158
+ "truncated-doc-comment": {
159
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "tag", "tag"]>;
160
+ };
158
161
  "undeclared-discriminator": {
159
162
  readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
160
163
  };
@@ -177,7 +180,7 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
177
180
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
178
181
  };
179
182
  }, EmitterOptions, never>;
180
- export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
183
+ export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "truncated-doc-comment" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
181
184
  "unsupported-type": {
182
185
  readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
183
186
  };
@@ -187,6 +190,9 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
187
190
  "default-on-required-property": {
188
191
  readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
189
192
  };
193
+ "truncated-doc-comment": {
194
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "tag", "tag"]>;
195
+ };
190
196
  "undeclared-discriminator": {
191
197
  readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
192
198
  };
@@ -218,6 +224,9 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
218
224
  "default-on-required-property": {
219
225
  readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
220
226
  };
227
+ "truncated-doc-comment": {
228
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "tag", "tag"]>;
229
+ };
221
230
  "undeclared-discriminator": {
222
231
  readonly default: import("@typespec/compiler").CallableMessage<["name", "property", "variant", "property", "variant"]>;
223
232
  };
package/dist/src/lib.js CHANGED
@@ -114,6 +114,50 @@ const diagnostics = {
114
114
  default: paramMessage `'${"name"}' is required, so its default can never apply: a required property is never absent. The OpenAPI document publishes it in 'required' and carries the default only as an annotation, and this validator does the same - a request omitting '${"name"}' is refused. Declare it optional, '${"name"}?: ... = ...', if callers may omit it.`,
115
115
  },
116
116
  },
117
+ /**
118
+ * **A doc comment the compiler truncated, so the description this emitter passes on is cut short.**
119
+ *
120
+ * **An unescaped `@` in a doc comment begins a doc tag, and everything from there to the end of
121
+ * the comment leaves the description.** Measured against `@typespec/compiler@1.15.0` through its
122
+ * own parser: `parseDocContent` breaks its loop on `Token.At` outside a code fence, and the
123
+ * doc-mode scanner turns every `@` into that token. `parseDocTag` then parses the remainder as a
124
+ * tag and, for a name it does not know, keeps it as `DocUnknownTag` - which `getDoc` never reads.
125
+ * No diagnostic is raised anywhere in that path.
126
+ *
127
+ * So it is not only `{@link}`. `support@example.com` in prose truncates at `support`, and
128
+ * "use @maxLength to bound it" truncates at "use", with no brace anywhere to notice. A consumer
129
+ * measured 14 of 1,006 published descriptions cut this way and found it by reading the emitted
130
+ * document by hand.
131
+ *
132
+ * **The check reads the AST, because the truncated string cannot answer.** By the time `getDoc`
133
+ * returns, the `@` is gone, so a check over the emitted text would find nothing to match - and
134
+ * pattern-matching emitted text is the thing this package refuses to decide anything by. The fact
135
+ * is on the node: a tag that is not `param`, `template`, `prop`, `returns`, `return` or `errors`
136
+ * parses as `DocUnknownTag`. That is exact, with no heuristic and no false positive on the four
137
+ * spellings that survive: a brace in prose, a backticked brace, `\{`, and `\@`.
138
+ *
139
+ * **Gated on the description actually coming from the comment**, via `getDocData().source`. A type
140
+ * carrying an `@doc(...)` decorator takes its description from there, where `@` inside a string
141
+ * literal means nothing, so warning would be a false positive - and a warning that fires on
142
+ * correct specs is one a consumer turns off wholesale.
143
+ *
144
+ * **A WARNING, for the reason `default-on-required-property` is one.** The spec is representable:
145
+ * `@typespec/openapi3` publishes the same truncated description happily, so refusing it would make
146
+ * one spec emit under one emitter and not the other. An error would also set `program.hasError()`
147
+ * and cost the whole document over one comment. What is wrong is not that it cannot be served, but
148
+ * that the author has written something that does not say what they meant.
149
+ *
150
+ * **Upstream this is intended, and the escape is sanctioned.** `microsoft/typespec#3374` reported
151
+ * exactly this, naming both `{@link}` and email addresses, and was closed as a duplicate of
152
+ * `#1358`, which is open on the Backlog. `#3375` then added the `\@` escape deliberately. So the
153
+ * remedy this message names is upstream's own, and only the silence is unaccounted for.
154
+ */
155
+ "truncated-doc-comment": {
156
+ severity: "warning",
157
+ messages: {
158
+ default: paramMessage `'${"name"}' has a doc comment that stops at '@${"tag"}': an unescaped '@' begins a doc tag, so everything after it is dropped from the description this emitter and the OpenAPI document beside it both publish. Use '\\@${"tag"}' to escape it, wrap it in backticks, or set the description with '@doc(...)' instead.`,
159
+ },
160
+ },
117
161
  /**
118
162
  * **A discriminated union whose discriminator the document never publishes.**
119
163
  *
@@ -1,4 +1,5 @@
1
1
  import type { ModelProperty, Program, Type } from "@typespec/compiler";
2
+ import { Visibility } from "@typespec/http";
2
3
  /**
3
4
  * Assigns each named TypeSpec model/enum a single Zod declaration, emitted in dependency order.
4
5
  *
@@ -54,6 +55,46 @@ interface Declaration {
54
55
  /** Whether the declaration is annotated `: z.ZodType<typeName>`, which `z.lazy()` requires. */
55
56
  readonly annotated?: boolean;
56
57
  }
58
+ /**
59
+ * One model can need MORE THAN ONE declaration.
60
+ *
61
+ * **Keyed on `(type, visibility)`, because a model is not one shape.** `@visibility` and HTTP
62
+ * metadata both mean the same declaration projects differently by position: `@typespec/openapi3`
63
+ * emits `VisibilityModel` and `VisibilityModelCreate` as separate components for exactly this
64
+ * reason. Keyed on the type alone, whichever position was walked first won and every other position
65
+ * silently got its shape - measured on the corpus, one strict model carrying all six lifecycle
66
+ * properties, so a create request was required to send read-only fields.
67
+ *
68
+ * The suffix rule is openapi3's: canonical (`Read`) keeps the bare name, and another visibility only
69
+ * earns a suffix when the model is actually TRANSFORMED at it. Same rule, so the identifiers line up
70
+ * with the component names the document publishes.
71
+ */
72
+ /**
73
+ * The visibility a type is actually DECLARED at, which is not always the one requested.
74
+ *
75
+ * **A type not reshaped at this visibility IS the canonical declaration, not a copy of it.** Keying
76
+ * on the requested visibility while only suffixing transformed types produced two cache entries
77
+ * sharing one identifier and an emitted file that stopped parsing:
78
+ * `Identifier 'approvalTierSchema' has already been declared`. An enum is the same enum in a request
79
+ * and a response, which is what the `Model` guard is for.
80
+ *
81
+ * It is the same collapse `@typespec/openapi3` performs before naming a component, and
82
+ * `isTransformedBy` is its own predicate, `metadataInfo.isTransformed`.
83
+ *
84
+ * **Extracted so both registries answer it identically.** They must: the emitted assertion pairs a
85
+ * validator with a contract type BY NAME, so a rule applied in one walk and paraphrased in the other
86
+ * is a pairing that silently compares the wrong two things.
87
+ */
88
+ export declare function visibilityFor(program: Program, type: Type, requested: Visibility): Visibility;
89
+ /**
90
+ * A declaration's name at a visibility: the bare name at canonical, suffixed anywhere else.
91
+ *
92
+ * The suffix is `@typespec/http`'s `getVisibilitySuffix`, called with `Visibility.Read` as the
93
+ * canonical exactly as `@typespec/openapi3` calls it in `schema-emitter.ts`. Same function, same
94
+ * argument, so these identifiers line up with the component names the document publishes rather than
95
+ * merely resembling them.
96
+ */
97
+ export declare function nameAt(bare: string, at: Visibility): string;
57
98
  export declare class SchemaRegistry {
58
99
  #private;
59
100
  constructor(program: Program);
@@ -78,11 +78,22 @@ const spreadSourceName = (type) => spreadSourceOf(type)?.name;
78
78
  * so a word missing from the list fails a test rather than reaching a consumer - the list is the
79
79
  * implementation and the compiler is the oracle.
80
80
  *
81
- * Contextual keywords are deliberately absent, because they are legal: `interface`, `string`,
82
- * `number`, `any`, `never`, `public` and `yield` all name a type without complaint. Measured, rather
83
- * than assumed from a language reference.
81
+ * Most contextual keywords are absent, because they are legal: `interface`, `string`, `number`,
82
+ * `any`, `never` and `public` all name a type without complaint.
83
+ *
84
+ * **Two contextual words are NOT legal, and both were missing from this set.** `as` and `yield` are
85
+ * refused in the declaration positions this emitter writes -- `export type as = ...` is
86
+ * `TS1005: '{' expected`, and `yield` is `TS1214: 'yield' is a reserved word in strict mode` as both
87
+ * a type alias and an interface. Either one made the whole emitted file unparseable, because the
88
+ * parser gives up after the first.
89
+ *
90
+ * **A word belongs here when `tsc` refuses it in EITHER position**, since both are emitted for the
91
+ * same model. Measured that way against tsc 7.0.2, one invocation per word: of the 56 candidates,
92
+ * 39 are refused and 39 are listed. `yield` had been recorded here as measured-legal and is not,
93
+ * which is why the arm that grades this set now takes its words from somewhere else entirely.
84
94
  */
85
95
  const RESERVED_DECLARATION_NAMES = new Set([
96
+ "as",
86
97
  "await",
87
98
  "break",
88
99
  "case",
@@ -120,6 +131,7 @@ const RESERVED_DECLARATION_NAMES = new Set([
120
131
  "void",
121
132
  "while",
122
133
  "with",
134
+ "yield",
123
135
  ]);
124
136
  /**
125
137
  * The name a declaration is emitted under.
@@ -185,6 +197,40 @@ const lowerFirst = (value) => value.charAt(0).toLowerCase() + value.slice(1);
185
197
  * earns a suffix when the model is actually TRANSFORMED at it. Same rule, so the identifiers line up
186
198
  * with the component names the document publishes.
187
199
  */
200
+ /**
201
+ * The visibility a type is actually DECLARED at, which is not always the one requested.
202
+ *
203
+ * **A type not reshaped at this visibility IS the canonical declaration, not a copy of it.** Keying
204
+ * on the requested visibility while only suffixing transformed types produced two cache entries
205
+ * sharing one identifier and an emitted file that stopped parsing:
206
+ * `Identifier 'approvalTierSchema' has already been declared`. An enum is the same enum in a request
207
+ * and a response, which is what the `Model` guard is for.
208
+ *
209
+ * It is the same collapse `@typespec/openapi3` performs before naming a component, and
210
+ * `isTransformedBy` is its own predicate, `metadataInfo.isTransformed`.
211
+ *
212
+ * **Extracted so both registries answer it identically.** They must: the emitted assertion pairs a
213
+ * validator with a contract type BY NAME, so a rule applied in one walk and paraphrased in the other
214
+ * is a pairing that silently compares the wrong two things.
215
+ */
216
+ export function visibilityFor(program, type, requested) {
217
+ return type.kind === "Model" &&
218
+ requested !== Visibility.Read &&
219
+ isTransformedBy(program, type, requested)
220
+ ? requested
221
+ : Visibility.Read;
222
+ }
223
+ /**
224
+ * A declaration's name at a visibility: the bare name at canonical, suffixed anywhere else.
225
+ *
226
+ * The suffix is `@typespec/http`'s `getVisibilitySuffix`, called with `Visibility.Read` as the
227
+ * canonical exactly as `@typespec/openapi3` calls it in `schema-emitter.ts`. Same function, same
228
+ * argument, so these identifiers line up with the component names the document publishes rather than
229
+ * merely resembling them.
230
+ */
231
+ export function nameAt(bare, at) {
232
+ return `${bare}${at === Visibility.Read ? "" : getVisibilitySuffix(at, Visibility.Read)}`;
233
+ }
188
234
  function keyFor(type, visibility) {
189
235
  /**
190
236
  * **Keyed on the SPREAD SOURCE where there is one, or one component gets two declarations.**
@@ -244,12 +290,7 @@ export class SchemaRegistry {
244
290
  * request and a response. Collapsing onto `Read` first is what makes the key and the name agree,
245
291
  * and it is the same collapse openapi3 performs before naming a component.
246
292
  */
247
- const requested = currentVisibility();
248
- const at = type.kind === "Model" &&
249
- requested !== Visibility.Read &&
250
- isTransformedBy(this.#program, type, requested)
251
- ? requested
252
- : Visibility.Read;
293
+ const at = visibilityFor(this.#program, type, currentVisibility());
253
294
  const key = keyFor(type, at);
254
295
  const existing = this.#declarations.get(key);
255
296
  if (existing !== undefined)
@@ -275,7 +316,7 @@ export class SchemaRegistry {
275
316
  this.#cyclic.add(inFlight);
276
317
  return pending;
277
318
  }
278
- const name = `${bare}${at === Visibility.Read ? "" : getVisibilitySuffix(at, Visibility.Read)}`;
319
+ const name = nameAt(bare, at);
279
320
  const identifier = `${lowerFirst(name)}Schema`;
280
321
  this.#inProgress.set(key, identifier);
281
322
  const rendered = captureBackEdges(() => this.#inline(type));
@@ -459,17 +500,32 @@ function isSingleObjectLiteral(source) {
459
500
  }
460
501
  export class TypeRegistry {
461
502
  #program;
503
+ /**
504
+ * **Keyed on `(type, visibility)`, exactly as {@link SchemaRegistry} is, and for the same reason.**
505
+ *
506
+ * One model is not one shape. Keyed on the `Type` alone, whichever position was walked first won
507
+ * and every other position silently got its shape - measured on `type/model/visibility`, where a
508
+ * `GET` reached the model first and the create body's contract type came out carrying the read
509
+ * projection. The two registries pair BY NAME in the emitted assertion, so they have to agree
510
+ * about how many declarations there are before the pairing means anything.
511
+ *
512
+ * Both now go through the shared {@link visibilityFor} and {@link nameAt}: paraphrasing either
513
+ * rule in one walk is how a validator ends up compared against a type describing something else.
514
+ */
462
515
  #declarations = new Map();
463
516
  #order = [];
464
- #inProgress = new Set();
517
+ #inProgress = new Map();
465
518
  constructor(program) {
466
519
  this.#program = program;
467
520
  }
468
521
  expressionFor(type) {
469
- const name = declaredNameOf(type);
470
- if (name === undefined)
522
+ const bare = declaredNameOf(type);
523
+ if (bare === undefined)
471
524
  return this.#inline(type);
472
- const existing = this.#declarations.get(type);
525
+ const at = visibilityFor(this.#program, type, currentVisibility());
526
+ const key = keyFor(type, at);
527
+ const name = nameAt(bare, at);
528
+ const existing = this.#declarations.get(key);
473
529
  if (existing !== undefined)
474
530
  return existing.name;
475
531
  /**
@@ -477,12 +533,17 @@ export class TypeRegistry {
477
533
  * may refer to itself - `interface InnerModel { children?: InnerModel[] }` is the ordinary way
478
534
  * to write a tree - so the name alone is the whole answer. The Zod registry has to defer the
479
535
  * same edge behind a getter only because a `const` cannot read itself while initialising.
536
+ *
537
+ * The in-flight name is the SUFFIXED one, not the bare one: a recursive model reached at
538
+ * `Create` must refer to `ThingCreate`, or it self-references a declaration that is not the
539
+ * one being written.
480
540
  */
481
- if (this.#inProgress.has(type))
482
- return name;
483
- this.#inProgress.add(type);
541
+ const pending = this.#inProgress.get(key);
542
+ if (pending !== undefined)
543
+ return pending;
544
+ this.#inProgress.set(key, name);
484
545
  const source = this.#inline(type);
485
- this.#inProgress.delete(type);
546
+ this.#inProgress.delete(key);
486
547
  const declaration = {
487
548
  name,
488
549
  /**
@@ -508,7 +569,7 @@ export class TypeRegistry {
508
569
  isVocabulary: type.kind === "Enum",
509
570
  source,
510
571
  };
511
- this.#declarations.set(type, declaration);
572
+ this.#declarations.set(key, declaration);
512
573
  /**
513
574
  * **Declared once per NAME, not once per `Type`, and the two are not the same thing.**
514
575
  *
package/dist/src/types.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { getDiscriminatedUnion, getEncode, isNeverType, resolveEncodedName, } from "@typespec/compiler";
2
2
  import { reportDiagnostic } from "./lib.js";
3
- import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
3
+ import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
4
4
  /**
5
5
  * TypeSpec type -> plain TypeScript type text.
6
6
  *
@@ -196,7 +196,20 @@ function modelToTs(program, model) {
196
196
  * that catches this emitter disagreeing with itself, and it cannot catch a disagreement that
197
197
  * aborts the compile first.
198
198
  */
199
+ /**
200
+ * **Metadata is filtered out here, as it is on the Zod side, and the two must agree.**
201
+ *
202
+ * `metadataInfo.isPayloadProperty` is what decides whether a property is in the BODY at this
203
+ * position: a `@query` or `@header` property is carried separately, and an `@invisible` one is
204
+ * carried nowhere. `modelToZod` has asked since it was written; this walk never did, so a
205
+ * contract type listed properties the validator beside it refuses as unknown keys.
206
+ *
207
+ * **Applied AFTER the dictionary check above, deliberately.** That check reads the UNFILTERED
208
+ * list, because a model whose only properties are metadata is still a declared object rather than
209
+ * a dictionary, and filtering first would flip it to `Record<string, T>` on one side alone.
210
+ */
199
211
  const entries = declared
212
+ .filter((property) => isPayloadProperty(program, property))
200
213
  .filter((property) => !isNeverType(property.type))
201
214
  .map((property) => `\t${withDeclarationSite(property, () => propertyToTs(program, property))}`);
202
215
  /**
@@ -226,8 +239,8 @@ function modelToTs(program, model) {
226
239
  * of the failure - see `test/contractshape/`.
227
240
  *
228
241
  * What arrives is still described honestly, on the surface derived from the validator:
229
- * `schemas.gen.ts` exports `Exact<z.infer<typeof x>>`, which carries the index signature because
230
- * a `.loose()` parse really does. Two surfaces, two directions, neither lying.
242
+ * `schemas.gen.ts` exports `z.infer<typeof x>`, which carries the index signature because a
243
+ * `.loose()` parse really does. Two surfaces, two directions, neither lying.
231
244
  */
232
245
  return entries.length === 0 ? "{}" : `{\n${entries.join("\n")}\n}`;
233
246
  }
@@ -266,9 +279,9 @@ export function propertyToTs(program, property, wireName) {
266
279
  * optionality per property fixes every depth at once, because there is no depth to reach.
267
280
  *
268
281
  * **The two directions of the emitted set differ here on purpose**, exactly as they do on
269
- * openness: `schemas.gen.ts` exports `Exact<z.infer<...>>`, the narrow view of what ARRIVES, in
270
- * which a default has fired and the property is present. `wire-contract.gen.ts` pairs this walk
271
- * against `z.input` for the same reason.
282
+ * openness: `schemas.gen.ts` exports `z.infer<...>`, the narrow view of what ARRIVES, in which a
283
+ * default has fired and the property is present. `wire-contract.gen.ts` pairs this walk against
284
+ * `z.input` for the same reason.
272
285
  *
273
286
  * A defaulted property that reaches here is one the document publishes as optional, because
274
287
  * `propertyToZod` reports `default-on-required-property` for the other spelling and emits no
@@ -278,7 +291,7 @@ export function propertyToTs(program, property, wireName) {
278
291
  * **`?: T | undefined`, and dropping the `| undefined` was tried in `0.19.0` and reverted.**
279
292
  *
280
293
  * It looks wrong - JSON has no `undefined`, so a wire type saying `?: T` reads more honest, and
281
- * it would make this walk agree with `Exact<z.infer<...>>` on the schemas side. **Measured, it
294
+ * it would make this walk agree with `z.infer<...>` on the schemas side. **Measured, it
282
295
  * breaks the most ordinary handler there is.** `createWidget: (ctx, input) => ok(input)` - return
283
296
  * what you were given - stopped compiling, because what a handler RECEIVES carries
284
297
  * `?: T | undefined` and what it would then have to SUPPLY did not.
@@ -291,7 +304,15 @@ export function propertyToTs(program, property, wireName) {
291
304
  * The two emitted surfaces differ here on purpose, exactly as they do on openness:
292
305
  * `schemas.gen.ts` exports the narrow received view, this walk emits the permissive floor.
293
306
  */
294
- return property.optional ? `${key}?: ${value} | undefined;` : `${key}: ${value};`;
307
+ /**
308
+ * **Optional AT THIS POSITION, not merely optional in the declaration.** A PATCH body's properties
309
+ * are optional whether or not the spec marked them so, which is `metadataInfo.isOptional` and is
310
+ * what `propertyToZod` has always consulted. This walk read `property.optional` alone, so an
311
+ * update contract demanded every field while the validator beside it accepted any subset.
312
+ */
313
+ return property.optional || isOptionalAt(program, property)
314
+ ? `${key}?: ${value} | undefined;`
315
+ : `${key}: ${value};`;
295
316
  }
296
317
  /** Installed by the registry so a named model is declared once and referenced everywhere else. */
297
318
  let resolveRef = () => undefined;
package/dist/src/zod.d.ts CHANGED
@@ -2,6 +2,10 @@ import { type Enum, type Model, type ModelProperty, type Program, type Type } fr
2
2
  import { Visibility } from "@typespec/http";
3
3
  export declare function withSealedObjects<T>(sealed: boolean, run: () => T): T;
4
4
  export declare function withVisibility<T>(program: Program, next: Visibility, run: () => T): T;
5
+ /** Whether a property is optional AT THE CURRENT POSITION - PATCH makes update fields optional. */
6
+ export declare function isOptionalAt(program: Program, property: ModelProperty): boolean;
7
+ /** Whether a property belongs in the BODY at the current position, rather than being metadata. */
8
+ export declare function isPayloadProperty(program: Program, property: ModelProperty): boolean;
5
9
  /** Whether this model is shaped differently at `visibility` than at the canonical `Read`. */
6
10
  export declare function isTransformedBy(program: Program, model: Model, at: Visibility): boolean;
7
11
  /** The visibility the current walk is emitting for - the registry keys declarations on it. */
@@ -122,6 +126,29 @@ export declare function wireKindOf(program: Program, type: Type): "number" | "bo
122
126
  * Exported so there is one answer to this question. There were two sites and only one of them knew.
123
127
  */
124
128
  export declare function objectKey(name: string): string;
129
+ /**
130
+ * A doc string as a JSDoc comment, indented, or `""` when there is none.
131
+ *
132
+ * **Spec-authored text going into emitted source is the same question `objectKey` answers for a
133
+ * property name, and it had the same two-sites-one-answer shape.** A summary was interpolated raw
134
+ * into a `/** ... *\/` block by this estate's two server emitters. Measured: `@summary("a *\/ b")` is
135
+ * an ordinary string literal, so the text reaches here intact, closes the comment early and turns
136
+ * the remainder into code. `TS1131`, `TS1434`, `TS1161` - one operation costing the whole file,
137
+ * exactly like a model named `as`.
138
+ *
139
+ * **The bar is idiomatic output, not merely parseable output.** Two consequences:
140
+ *
141
+ * - A terminator is escaped as `*\/`, which is how TypeScript itself writes one inside a comment, so
142
+ * the sentence survives and reads correctly. Dropping or replacing the characters would lose an
143
+ * author's words to a mechanical problem they cannot see from their spec.
144
+ * - A multi-line description is rendered as a multi-line BLOCK. Interpolating the newlines into a
145
+ * one-line form produces a comment whose continuation lines carry no ` * `, which no formatter
146
+ * would emit and no reviewer would write.
147
+ *
148
+ * `indent` is passed rather than assumed because the two call sites sit at different depths, and a
149
+ * comment indented differently from the member it documents is the tell that a generator wrote it.
150
+ */
151
+ export declare function jsDocComment(text: string | undefined, indent: string): string;
125
152
  export declare function propertyToZod(program: Program, property: ModelProperty): string;
126
153
  export declare function withRefResolver<T>(resolver: (type: Type) => string | undefined, run: () => T): T;
127
154
  /** Called by the registry when it hands out the identifier of a declaration that is still open. */
package/dist/src/zod.js CHANGED
@@ -139,12 +139,12 @@ export function withVisibility(program, next, run) {
139
139
  }
140
140
  }
141
141
  /** Whether a property is optional AT THE CURRENT POSITION - PATCH makes update fields optional. */
142
- function isOptionalAt(program, property) {
142
+ export function isOptionalAt(program, property) {
143
143
  metadataInfo ??= createMetadataInfo(program);
144
144
  return metadataInfo.isOptional(property, visibility);
145
145
  }
146
146
  /** Whether a property belongs in the BODY at the current position, rather than being metadata. */
147
- function isPayloadProperty(program, property) {
147
+ export function isPayloadProperty(program, property) {
148
148
  metadataInfo ??= createMetadataInfo(program);
149
149
  return metadataInfo.isPayloadProperty(property, visibility);
150
150
  }
@@ -874,6 +874,49 @@ export function wireKindOf(program, type) {
874
874
  export function objectKey(name) {
875
875
  return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
876
876
  }
877
+ /**
878
+ * A doc string as a JSDoc comment, indented, or `""` when there is none.
879
+ *
880
+ * **Spec-authored text going into emitted source is the same question `objectKey` answers for a
881
+ * property name, and it had the same two-sites-one-answer shape.** A summary was interpolated raw
882
+ * into a `/** ... *\/` block by this estate's two server emitters. Measured: `@summary("a *\/ b")` is
883
+ * an ordinary string literal, so the text reaches here intact, closes the comment early and turns
884
+ * the remainder into code. `TS1131`, `TS1434`, `TS1161` - one operation costing the whole file,
885
+ * exactly like a model named `as`.
886
+ *
887
+ * **The bar is idiomatic output, not merely parseable output.** Two consequences:
888
+ *
889
+ * - A terminator is escaped as `*\/`, which is how TypeScript itself writes one inside a comment, so
890
+ * the sentence survives and reads correctly. Dropping or replacing the characters would lose an
891
+ * author's words to a mechanical problem they cannot see from their spec.
892
+ * - A multi-line description is rendered as a multi-line BLOCK. Interpolating the newlines into a
893
+ * one-line form produces a comment whose continuation lines carry no ` * `, which no formatter
894
+ * would emit and no reviewer would write.
895
+ *
896
+ * `indent` is passed rather than assumed because the two call sites sit at different depths, and a
897
+ * comment indented differently from the member it documents is the tell that a generator wrote it.
898
+ */
899
+ export function jsDocComment(text, indent) {
900
+ if (text === undefined)
901
+ return "";
902
+ const lines = text
903
+ .replaceAll("*/", "*\\/")
904
+ .split("\n")
905
+ .map((line) => line.trimEnd());
906
+ while (lines.length > 0 && lines[lines.length - 1] === "")
907
+ lines.pop();
908
+ while (lines.length > 0 && lines[0] === "")
909
+ lines.shift();
910
+ if (lines.length === 0)
911
+ return "";
912
+ const [only] = lines;
913
+ if (lines.length === 1 && only !== undefined)
914
+ return `${indent}/** ${only} */\n`;
915
+ const body = lines
916
+ .map((line) => (line === "" ? `${indent} *` : `${indent} * ${line}`))
917
+ .join("\n");
918
+ return `${indent}/**\n${body}\n${indent} */\n`;
919
+ }
877
920
  /**
878
921
  * The name a property carries **on the wire**, which is not always the name the spec gave it.
879
922
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "TypeSpec emitter and library: turn an HTTP service definition into Zod validators and framework-free contract types that agree, keyword for keyword, with the OpenAPI document @typespec/openapi3 publishes from the same source.",
5
5
  "keywords": [
6
6
  "codegen",