typespec-http-zod 0.28.0 → 0.29.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.d.ts CHANGED
@@ -48,6 +48,12 @@ export { EmitterOptionsSchema, type EmitterOptions } from "./lib.js";
48
48
  * independently.
49
49
  */
50
50
  export { jsDocComment, objectKey } from "./zod.js";
51
+ /**
52
+ * And the same for a VALUE written into emitted code: a string, enum value or media type as a literal
53
+ * whose source is ASCII. Spec text outside ASCII is the case, and a sibling emitter spelling it with a
54
+ * bare `JSON.stringify` writes the character itself into a file its consumer commits.
55
+ */
56
+ export { literal } from "./literal.js";
51
57
  export declare function beginBanner(hint: string | undefined): void;
52
58
  /**
53
59
  * The status a row registers: the first non-error status the operation declares.
@@ -238,6 +244,19 @@ export interface EmittedRoute {
238
244
  * `unsupported`: no segment router can split it.
239
245
  */
240
246
  readonly pathSegments: readonly EmittedPathSegment[];
247
+ /**
248
+ * **The path parameters whose validator undoes an RFC 6570 expansion**, by wire name.
249
+ *
250
+ * For these, the path validator's field is `z.preprocess(<decode>, <value schema>)`: it takes the
251
+ * TEXT of the segment a router extracts - `primitive.a` for `{.param}` - and hands on the value,
252
+ * `a`. A caller already holding the value, such as a tool call or a service-binding request, must
253
+ * validate it against the value schema, `pathSchema.shape[name].out`: through the decoder, the value
254
+ * `a` is refused as not the expansion the route declares, and a value that happens to start with
255
+ * the route's literal is rewritten without an error. Decided by the same rule the validator is
256
+ * built by: a prefix or suffix in the segment, the `.` or `;` operator, or a parameter that is not
257
+ * a scalar. Empty when there is none, never `undefined`.
258
+ */
259
+ readonly decodedPathParameters: readonly string[];
241
260
  /**
242
261
  * A query string written into the route itself, as the pairs it spells:
243
262
  * `/items?fixed=true{&param}` carries `[["fixed", "true"]]`. It identifies the route as much as
package/dist/src/api.js CHANGED
@@ -2,6 +2,7 @@ import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary
2
2
  import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
3
3
  import { getOperationId, resolveOperationId } from "@typespec/openapi";
4
4
  import { reportDiagnostic } from "./lib.js";
5
+ import { literal } from "./literal.js";
5
6
  import { SchemaRegistry, TypeRegistry } from "./registry.js";
6
7
  import { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
7
8
  import { propertyTypeToTs } from "./types.js";
@@ -53,6 +54,12 @@ export { EmitterOptionsSchema } from "./lib.js";
53
54
  * independently.
54
55
  */
55
56
  export { jsDocComment, objectKey } from "./zod.js";
57
+ /**
58
+ * And the same for a VALUE written into emitted code: a string, enum value or media type as a literal
59
+ * whose source is ASCII. Spec text outside ASCII is the case, and a sibling emitter spelling it with a
60
+ * bare `JSON.stringify` writes the character itself into a file its consumer commits.
61
+ */
62
+ export { literal } from "./literal.js";
56
63
  /** Named once. The emitted banner and the internal-invariant messages both quote it. */
57
64
  const PACKAGE_NAME = "typespec-http-zod";
58
65
  /**
@@ -1045,8 +1052,8 @@ function mediaTypeDecoded(declared) {
1045
1052
  if (value === "*/*")
1046
1053
  return "z.string()";
1047
1054
  return isMediaRange(value)
1048
- ? `z.templateLiteral([${JSON.stringify(mediaTypeEssenceOf(value).slice(0, -1))}, z.string()])`
1049
- : `z.literal(${JSON.stringify(value)})`;
1055
+ ? `z.templateLiteral([${literal(mediaTypeEssenceOf(value).slice(0, -1))}, z.string()])`
1056
+ : `z.literal(${literal(value)})`;
1050
1057
  });
1051
1058
  const schema = admitted.length === 1 ? (admitted[0] ?? declared) : `z.union([${admitted.join(", ")}])`;
1052
1059
  const ranged = values.some(isMediaRange) ? schema : declared;
@@ -1063,7 +1070,7 @@ function mediaTypeDecoded(declared) {
1063
1070
  const decoded = restored.length === 0
1064
1071
  ? essence
1065
1072
  : `((essence) => ${restored
1066
- .map((value) => `essence === ${JSON.stringify(mediaTypeEssenceOf(value))} ? ${JSON.stringify(value)} : `)
1073
+ .map((value) => `essence === ${literal(mediaTypeEssenceOf(value))} ? ${literal(value)} : `)
1067
1074
  .join("")}essence)(${essence})`;
1068
1075
  return `z.preprocess((raw) => (typeof raw === "string" ? ${decoded} : raw), ${ranged})`;
1069
1076
  }
@@ -1143,7 +1150,7 @@ literalQueryNames) {
1143
1150
  ? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
1144
1151
  : delimiter === undefined
1145
1152
  ? decoded
1146
- : `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${JSON.stringify(delimiter)}) : raw), ${decoded})`;
1153
+ : `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${literal(delimiter)}) : raw), ${decoded})`;
1147
1154
  /**
1148
1155
  * **An RFC 6570 expansion is undone OUTSIDE everything above**, because it is the first thing the
1149
1156
  * wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
@@ -1153,8 +1160,8 @@ literalQueryNames) {
1153
1160
  const values = recordValuesOf(program, parameter.param.type);
1154
1161
  let expression = boxedOrSplit;
1155
1162
  if (parameter.type === "path") {
1156
- const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
1157
- if (segment !== undefined && needsExpansionDecoding(segment, shape)) {
1163
+ const segment = decodedSegmentOf(parameter, segments);
1164
+ if (segment !== undefined) {
1158
1165
  expression = `z.preprocess(${uriExpansionCall({ ...segment, name: parameter.name, shape, values })}, ${decoded})`;
1159
1166
  }
1160
1167
  }
@@ -1198,9 +1205,9 @@ literalQueryNames) {
1198
1205
  * validates every OTHER header and leaves this one to the negotiation.
1199
1206
  */
1200
1207
  if (parameter.type === "header" && parameter.name.toLowerCase() === "accept") {
1201
- const literal = parameter.param.type;
1202
- if (literal.kind === "String")
1203
- accept = { name: parameter.name, value: literal.value };
1208
+ const declared = parameter.param.type;
1209
+ if (declared.kind === "String")
1210
+ accept = { name: parameter.name, value: declared.value };
1204
1211
  }
1205
1212
  else if (parameter.type === "header") {
1206
1213
  (byTarget["header-without-accept"] ??= []).push(entry);
@@ -1231,6 +1238,19 @@ literalQueryNames) {
1231
1238
  };
1232
1239
  }
1233
1240
  /** What a parameter's value decodes into once its expansion is undone. */
1241
+ /**
1242
+ * The route segment a path parameter's validator decodes, or `undefined` where it validates the
1243
+ * segment's text as the value. The one rule for both the validator and
1244
+ * {@link EmittedRoute.decodedPathParameters}, so a consumer reading the route cannot disagree with
1245
+ * the validator it describes.
1246
+ */
1247
+ function decodedSegmentOf(parameter, segments) {
1248
+ const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
1249
+ return segment !== undefined &&
1250
+ needsExpansionDecoding(segment, expansionShapeOf(parameter.param.type))
1251
+ ? segment
1252
+ : undefined;
1253
+ }
1234
1254
  function expansionShapeOf(type) {
1235
1255
  if (isArrayType(type))
1236
1256
  return "list";
@@ -1486,6 +1506,9 @@ export function collectRoutes(program, registry, service) {
1486
1506
  .filter((parameter) => parameter.type === "path" && parameter.allowReserved)
1487
1507
  .map((parameter) => parameter.name),
1488
1508
  pathSegments: template.segments,
1509
+ decodedPathParameters: operation.parameters.parameters
1510
+ .filter((parameter) => decodedSegmentOf(parameter, template.segments) !== undefined)
1511
+ .map((parameter) => parameter.name),
1489
1512
  literalQuery: template.literalQuery,
1490
1513
  responseContentTypes: statusCode === undefined ? [] : responseContentTypesOf(operation, statusCode),
1491
1514
  requestContentTypes: [...(operation.parameters.body?.contentTypes ?? [])],
@@ -1581,9 +1604,121 @@ export function collectRoutes(program, registry, service) {
1581
1604
  * Tokenising has neither, needs no escaping to stay correct, and is one pass rather than one pattern
1582
1605
  * per name. `typespec-hono` arrived at it after the same defect went live there: a spec whose every
1583
1606
  * identifier carried a `$` emitted a file that imported nothing and did not compile.
1607
+ *
1608
+ * **Only CODE is tokenised: string, template and regular-expression literals and comments are
1609
+ * skipped.** Spec-authored text reaches emitted source inside them - a `.describe("...")`, a
1610
+ * `.regex(/.../)` - and a word in that text is not a reference. Measured: a parameter documented as
1611
+ * "names uriExpansion and explodedQuery" declared both RFC 6570 decoders in a service that decodes
1612
+ * nothing, and the file failed `noUnusedLocals` - the lint a generated file has to pass like any
1613
+ * other. A literal is told from code by its opening character; a `/` opens a regular expression only
1614
+ * where an expression may start, which is the rule a JavaScript scanner uses, and this emitter
1615
+ * writes no division.
1584
1616
  */
1585
1617
  function identifiersIn(source) {
1586
- return new Set(source.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
1618
+ const found = new Set();
1619
+ scanCode(source, 0, found, false);
1620
+ return found;
1621
+ }
1622
+ /**
1623
+ * Collect the identifiers of the code from `start`, returning where it stopped: at the end, or -
1624
+ * `nested`, for a template's `${...}` - just past the brace that closes it.
1625
+ */
1626
+ function scanCode(source, start, found, nested) {
1627
+ const identifier = /[A-Za-z_$][A-Za-z0-9_$]*/y;
1628
+ let previous = "";
1629
+ let depth = 0;
1630
+ let index = start;
1631
+ while (index < source.length) {
1632
+ const char = source.charAt(index);
1633
+ const next = source.charAt(index + 1);
1634
+ if (char === "`") {
1635
+ index = scanTemplate(source, index, found);
1636
+ previous = char;
1637
+ }
1638
+ else if (char === '"' || char === "'") {
1639
+ index = quotedEnd(source, index, char);
1640
+ previous = char;
1641
+ }
1642
+ else if (char === "/" && next === "/") {
1643
+ const end = source.indexOf("\n", index);
1644
+ index = end === -1 ? source.length : end;
1645
+ }
1646
+ else if (char === "/" && next === "*") {
1647
+ const end = source.indexOf("*/", index + 2);
1648
+ index = end === -1 ? source.length : end + 2;
1649
+ }
1650
+ else if (char === "/" && (previous === "" || "(,=:[!&|?{};".includes(previous))) {
1651
+ index = regexEnd(source, index);
1652
+ previous = char;
1653
+ }
1654
+ else if (/[A-Za-z_$]/.test(char)) {
1655
+ identifier.lastIndex = index;
1656
+ const [name = char] = identifier.exec(source) ?? [];
1657
+ found.add(name);
1658
+ index += name.length;
1659
+ previous = "a";
1660
+ }
1661
+ else {
1662
+ if (char === "}" && nested && depth === 0)
1663
+ return index + 1;
1664
+ if (char === "{")
1665
+ depth += 1;
1666
+ if (char === "}")
1667
+ depth -= 1;
1668
+ if (!/\s/.test(char))
1669
+ previous = char;
1670
+ index += 1;
1671
+ }
1672
+ }
1673
+ return index;
1674
+ }
1675
+ /** Past a template literal opened at `start`, collecting the code in each `${...}` on the way. */
1676
+ function scanTemplate(source, start, found) {
1677
+ let index = start + 1;
1678
+ while (index < source.length) {
1679
+ const char = source.charAt(index);
1680
+ if (char === "\\")
1681
+ index += 2;
1682
+ else if (char === "`")
1683
+ return index + 1;
1684
+ else if (char === "$" && source.charAt(index + 1) === "{") {
1685
+ index = scanCode(source, index + 2, found, true);
1686
+ }
1687
+ else
1688
+ index += 1;
1689
+ }
1690
+ return index;
1691
+ }
1692
+ /** Past a quoted string opened at `start`, escapes honoured. */
1693
+ function quotedEnd(source, start, quote) {
1694
+ for (let index = start + 1; index < source.length; index += 1) {
1695
+ const char = source.charAt(index);
1696
+ if (char === "\\")
1697
+ index += 1;
1698
+ else if (char === quote)
1699
+ return index + 1;
1700
+ }
1701
+ return source.length;
1702
+ }
1703
+ /** Past a regular-expression literal opened at `start`, flags included. */
1704
+ function regexEnd(source, start) {
1705
+ let inClass = false;
1706
+ for (let index = start + 1; index < source.length; index += 1) {
1707
+ const char = source.charAt(index);
1708
+ if (char === "\\")
1709
+ index += 1;
1710
+ else if (char === "[")
1711
+ inClass = true;
1712
+ else if (char === "]")
1713
+ inClass = false;
1714
+ else if (char === "/" && !inClass) {
1715
+ let end = index + 1;
1716
+ while (/[a-z]/.test(source.charAt(end)))
1717
+ end += 1;
1718
+ return end;
1719
+ }
1720
+ }
1721
+ return source.length;
1587
1722
  }
1588
1723
  /** Whether emitted source references `name` as an identifier rather than as part of a longer one. */
1589
1724
  export function referencedIn(source, name) {
@@ -1595,7 +1730,7 @@ function renderExternalImports(externals, ...bodies) {
1595
1730
  for (const [module, names] of externals) {
1596
1731
  const used = [...names].filter((name) => referencedIn(haystack, name));
1597
1732
  if (used.length > 0) {
1598
- lines.push(`import { ${used.toSorted().join(", ")} } from ${JSON.stringify(module)};\n`);
1733
+ lines.push(`import { ${used.toSorted().join(", ")} } from ${literal(module)};\n`);
1599
1734
  }
1600
1735
  }
1601
1736
  return lines.join("");
@@ -1944,11 +2079,11 @@ function responseArmsOf(route, arms) {
1944
2079
  const schema = arms[index]?.schema ?? "undefined";
1945
2080
  const contentTypes = response.contentTypes.length === 0
1946
2081
  ? ""
1947
- : `, contentTypes: [${response.contentTypes.map((type) => JSON.stringify(type)).join(", ")}]`;
2082
+ : `, contentTypes: [${response.contentTypes.map((type) => literal(type)).join(", ")}]`;
1948
2083
  const headers = response.headers.length === 0
1949
2084
  ? ""
1950
- : `, headers: [${response.headers.map((header) => `{ name: ${JSON.stringify(header.name)}, optional: ${header.optional} }`).join(", ")}]`;
1951
- return `{ status: ${JSON.stringify(response.status)}, schema: ${schema}${contentTypes}${headers} }`;
2085
+ : `, headers: [${response.headers.map((header) => `{ name: ${literal(header.name)}, optional: ${header.optional} }`).join(", ")}]`;
2086
+ return `{ status: ${literal(response.status)}, schema: ${schema}${contentTypes}${headers} }`;
1952
2087
  });
1953
2088
  return `[${rendered.join(", ")}]`;
1954
2089
  }
@@ -2528,7 +2663,7 @@ ${assertions.join("\n")}
2528
2663
  function renderVocabularies(vocabularies) {
2529
2664
  const entries = [...vocabularies.entries()]
2530
2665
  .toSorted(([a], [b]) => a.localeCompare(b))
2531
- .map(([name, values]) => `\t${name}: [${values.map((v) => JSON.stringify(v)).join(", ")}],`);
2666
+ .map(([name, values]) => `\t${name}: [${values.map((v) => literal(v)).join(", ")}],`);
2532
2667
  return `${generatedBanner()}
2533
2668
  /**
2534
2669
  * The spec's vocabularies - every enum the surface reaches, and what each one contains.
@@ -1,6 +1,7 @@
1
1
  import { getEncode, getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
2
2
  import { defaultMaxSteps, isSafePattern } from "redos-detector";
3
3
  import { reportDiagnostic } from "./lib.js";
4
+ import { escapeNonAscii } from "./literal.js";
4
5
  /**
5
6
  * Constraint decorators -> the Zod modifiers that enforce them.
6
7
  *
@@ -18,15 +19,16 @@ import { reportDiagnostic } from "./lib.js";
18
19
  */
19
20
  export function applyConstraints(program, expression, target) {
20
21
  let result = expression;
21
- const minLength = getMinLength(program, target);
22
- const maxLength = getMaxLength(program, target);
23
- const minValue = getMinValue(program, target);
24
- const maxValue = getMaxValue(program, target);
25
- const minItems = getMinItems(program, target);
26
- const maxItems = getMaxItems(program, target);
27
- const pattern = getPattern(program, target);
28
- const minExclusive = getMinValueExclusive(program, target);
29
- const maxExclusive = getMaxValueExclusive(program, target);
22
+ const read = (get) => declaredOn(program, target, get);
23
+ const minLength = read(getMinLength);
24
+ const maxLength = read(getMaxLength);
25
+ const minValue = read(getMinValue);
26
+ const maxValue = read(getMaxValue);
27
+ const minItems = read(getMinItems);
28
+ const maxItems = read(getMaxItems);
29
+ const pattern = read(getPattern);
30
+ const minExclusive = read(getMinValueExclusive);
31
+ const maxExclusive = read(getMaxValueExclusive);
30
32
  /**
31
33
  * **A numeric bound says nothing about a value carried as text.** `@encode(string)` is legal on a
32
34
  * numeric property and makes the wire value a string, so the expression here is `z.string()` while
@@ -72,6 +74,30 @@ export function applyConstraints(program, expression, target) {
72
74
  }
73
75
  return result;
74
76
  }
77
+ /**
78
+ * **A constraint on a scalar is read up its `extends` chain: the nearest scalar declaring it wins.**
79
+ *
80
+ * The compiler's getters read the decorators of the one type they are handed, and this read the
81
+ * scalar a property NAMED. So `scalar ShortWord extends Word` with `@pattern` on `Word` validated
82
+ * as a bare `z.string()`, and every value the pattern forbids was accepted - while the document
83
+ * published the pattern on `ShortWord`. `@typespec/openapi3`'s `#getSchemaForScalar` builds a
84
+ * derived scalar from its base's schema and then applies its own decorators over it, so a keyword
85
+ * the derived scalar declares REPLACES the base's rather than adding to it. Reading the nearest
86
+ * declaration per keyword is that rule exactly: `Overrides extends Word` with its own `@pattern`
87
+ * publishes only its own, and enforcing both would refuse what the document accepts.
88
+ *
89
+ * A property is not a chain: its type's constraints are applied where the type is rendered.
90
+ */
91
+ function declaredOn(program, target, get) {
92
+ if (target.kind !== "Scalar")
93
+ return get(program, target);
94
+ for (let current = target; current !== undefined; current = current.baseScalar) {
95
+ const value = get(program, current);
96
+ if (value !== undefined)
97
+ return value;
98
+ }
99
+ return undefined;
100
+ }
75
101
  /**
76
102
  * Whether the value this type puts on the wire is text because `@encode` says so. Read from the
77
103
  * property and then from its scalar, which is where an encoding declared once for a named scalar
@@ -210,7 +236,7 @@ export function patternIsProvablySafe(pattern) {
210
236
  * instead of guessing at an equivalence.
211
237
  */
212
238
  export function patternToRegex(pattern) {
213
- return `/${escapeSlashes(pattern)}/${unicodeSafe(pattern) ? "u" : ""}`;
239
+ return `/${escapeNonAscii(escapeSlashes(pattern))}/${unicodeSafe(pattern) ? "u" : ""}`;
214
240
  }
215
241
  /**
216
242
  * **Escapes the slashes a regex literal needs escaped, and only those.**
@@ -232,7 +258,15 @@ function escapeSlashes(pattern) {
232
258
  for (let index = 0; index < pattern.length; index += 1) {
233
259
  const character = pattern[index];
234
260
  if (character === "\\") {
235
- escaped += character + (pattern[index + 1] ?? "");
261
+ const next = pattern[index + 1] ?? "";
262
+ /**
263
+ * **An identity escape of a character outside printable ASCII loses its backslash**, because
264
+ * the character is about to be written as `\uXXXX` and `\\uXXXX` would be a literal
265
+ * backslash followed by `u`. An accented letter matches the same thing escaped or not; under
266
+ * the `u` flag the escape is a syntax error, and `unicodeSafe` has already sent such a
267
+ * pattern to the bare form.
268
+ */
269
+ escaped += /[^\x20-\x7e]/.test(next) ? next : character + next;
236
270
  index += 1;
237
271
  continue;
238
272
  }
package/dist/src/lib.d.ts CHANGED
@@ -162,6 +162,12 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
162
162
  "unenforceable-encoded-bound": {
163
163
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
164
164
  };
165
+ "unenforceable-property-name": {
166
+ readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
167
+ };
168
+ "optional-prototype-name": {
169
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
170
+ };
165
171
  "duplicate-operation-id": {
166
172
  readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
167
173
  };
@@ -172,7 +178,7 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
172
178
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
173
179
  };
174
180
  }, EmitterOptions, never>;
175
- export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "inline-cycle" | "non-unicode-pattern" | "truncated-doc-comment" | "undeclared-discriminator" | "unenforceable-encoded-bound" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
181
+ export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "inline-cycle" | "non-unicode-pattern" | "optional-prototype-name" | "truncated-doc-comment" | "undeclared-discriminator" | "unenforceable-encoded-bound" | "unenforceable-property-name" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
176
182
  "unsupported-type": {
177
183
  readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
178
184
  };
@@ -206,6 +212,12 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
206
212
  "unenforceable-encoded-bound": {
207
213
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
208
214
  };
215
+ "unenforceable-property-name": {
216
+ readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
217
+ };
218
+ "optional-prototype-name": {
219
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
220
+ };
209
221
  "duplicate-operation-id": {
210
222
  readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
211
223
  };
@@ -249,6 +261,12 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
249
261
  "unenforceable-encoded-bound": {
250
262
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
251
263
  };
264
+ "unenforceable-property-name": {
265
+ readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
266
+ };
267
+ "optional-prototype-name": {
268
+ readonly default: import("@typespec/compiler").CallableMessage<["name", "name", "name"]>;
269
+ };
252
270
  "duplicate-operation-id": {
253
271
  readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
254
272
  };
package/dist/src/lib.js CHANGED
@@ -297,6 +297,48 @@ const diagnostics = {
297
297
  default: paramMessage `The bound on '${"name"}' cannot be enforced, and is not emitted. '@encode(string)' makes the wire value text, so '@minValue'/'@maxValue' would become a check on how many CHARACTERS it has - '@minValue(10)' would refuse '"42"' - and the exclusive form has no string equivalent at all. The document is no help either: it publishes 'minimum' on a 'type: string' schema, which JSON Schema ignores, so a validator enforcing the bound would refuse what the published contract accepts. Declare the bound as a '@pattern', which the document publishes and every caller can read, or remove '@encode(string)' and let the value be a number.`,
298
298
  },
299
299
  },
300
+ /**
301
+ * **A property whose wire name is `__proto__` is published, and no Zod validator can check it.**
302
+ *
303
+ * Zod skips a declared `__proto__` key on purpose, so prototype pollution cannot reach through a
304
+ * parse (colinhacks/zod#6386): a value of any type, or none, is accepted for it and dropped from
305
+ * the result. The document publishes it like any other property, and nothing emitted can enforce
306
+ * it - which is the case `unenforceable-encoded-bound` reports for a bound, for the same reason:
307
+ * the spec is representable, so refusing it would make it emit under one emitter and not the
308
+ * other, and serving it silently would leave a validator looser than its contract unsaid.
309
+ *
310
+ * The key is still DECLARED, as a computed `["__proto__"]`, because a plain `__proto__:` in an
311
+ * object literal sets the shape's prototype instead. Measured: that dropped the property from the
312
+ * published JSON Schema, and under `seal-object-schemas` refused every body carrying it as an
313
+ * unrecognised key - the opposite of what the document says.
314
+ */
315
+ "unenforceable-property-name": {
316
+ severity: "warning",
317
+ messages: {
318
+ default: paramMessage `'${"name"}' is published as a property and cannot be validated: Zod skips a key named '__proto__' on purpose (colinhacks/zod#6386), so any value, or none, is accepted for it and dropped from the parsed result. The published schema still states it. Give it another wire name if its value has to be checked.`,
319
+ },
320
+ },
321
+ /**
322
+ * **An optional property named after an `Object.prototype` member refuses every value that
323
+ * leaves it out.**
324
+ *
325
+ * Zod reads a property as `input[key]`, so an absent `constructor`, `toString`, `valueOf` or any
326
+ * other inherited member reads as the function every object inherits, and is refused as the wrong
327
+ * type (colinhacks/zod#6666, open). A required one is unaffected, because a value that leaves it
328
+ * out is refused either way. The emitted validators are right and inherit the defect - on
329
+ * requests and responses alike - and the only repair in emitted code, a `z.preprocess` onto a
330
+ * null-prototype copy, would cost the model `.shape` and `.extend`.
331
+ *
332
+ * Reported where the property is optional, which includes the PATCH position that makes a
333
+ * required property optional. When Zod ships the fix, the peer floor moves to it and this retires:
334
+ * `test/inheritednames` pins the defect so that release is noticed.
335
+ */
336
+ "optional-prototype-name": {
337
+ severity: "warning",
338
+ messages: {
339
+ default: paramMessage `'${"name"}' is optional here, and the validator refuses every value that leaves it out: Zod reads an absent '${"name"}' as the member every object inherits from Object.prototype, a function, and rejects it (colinhacks/zod#6666). A value that carries '${"name"}' validates normally. Give it a wire name Object.prototype does not have, or declare it required, until Zod releases a fix.`,
340
+ },
341
+ },
300
342
  "duplicate-operation-id": {
301
343
  severity: "error",
302
344
  messages: {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * **A value as a JavaScript literal whose source is ASCII.**
3
+ *
4
+ * `JSON.stringify` is the right spelling for spec text that becomes code - a key, an enum value, a
5
+ * media type, a description - because it quotes and escapes exactly as a string literal needs. It
6
+ * leaves every other character as it is, and no file this package generates may carry one: a
7
+ * consumer commits the output, and an invisible character in it has held a block comment open past
8
+ * its terminator in the sibling package. `test/portability.test.ts` grades every emitted file.
9
+ *
10
+ * Measured once descriptions reached the output: `@typespec/http-specs`' `payload/xml` documents its
11
+ * models as `"\u00a71.1 \u2014 Contains fields of primitive types."`, and the section sign and the dash went
12
+ * straight into `schemas.gen.ts`. Every other site had the same exposure and was ASCII only because
13
+ * no spec in the corpus happened to put a non-ASCII character in a key or a value.
14
+ *
15
+ * So every character outside printable ASCII is written as a `\uXXXX` escape: the same string at
16
+ * run time, in either quote position, and in a TypeScript literal type. An astral character is two
17
+ * UTF-16 units, escaped one at a time, which is how JavaScript spells it.
18
+ */
19
+ export declare function literal(value: unknown): string;
20
+ /**
21
+ * Every UTF-16 unit outside printable ASCII as a `\uXXXX` escape. A string literal and a regular
22
+ * expression read the escape alike, with or without the `u` flag: under it, an escaped surrogate pair
23
+ * is the one code point the pair spells.
24
+ */
25
+ export declare function escapeNonAscii(source: string): string;
26
+ /** One UTF-16 unit as `\uXXXX`. */
27
+ export declare function unicodeEscape(unit: string): string;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * **A value as a JavaScript literal whose source is ASCII.**
3
+ *
4
+ * `JSON.stringify` is the right spelling for spec text that becomes code - a key, an enum value, a
5
+ * media type, a description - because it quotes and escapes exactly as a string literal needs. It
6
+ * leaves every other character as it is, and no file this package generates may carry one: a
7
+ * consumer commits the output, and an invisible character in it has held a block comment open past
8
+ * its terminator in the sibling package. `test/portability.test.ts` grades every emitted file.
9
+ *
10
+ * Measured once descriptions reached the output: `@typespec/http-specs`' `payload/xml` documents its
11
+ * models as `"\u00a71.1 \u2014 Contains fields of primitive types."`, and the section sign and the dash went
12
+ * straight into `schemas.gen.ts`. Every other site had the same exposure and was ASCII only because
13
+ * no spec in the corpus happened to put a non-ASCII character in a key or a value.
14
+ *
15
+ * So every character outside printable ASCII is written as a `\uXXXX` escape: the same string at
16
+ * run time, in either quote position, and in a TypeScript literal type. An astral character is two
17
+ * UTF-16 units, escaped one at a time, which is how JavaScript spells it.
18
+ */
19
+ export function literal(value) {
20
+ return escapeNonAscii(JSON.stringify(value));
21
+ }
22
+ /**
23
+ * Every UTF-16 unit outside printable ASCII as a `\uXXXX` escape. A string literal and a regular
24
+ * expression read the escape alike, with or without the `u` flag: under it, an escaped surrogate pair
25
+ * is the one code point the pair spells.
26
+ */
27
+ export function escapeNonAscii(source) {
28
+ return source.replace(/[^\x20-\x7e]/g, unicodeEscape);
29
+ }
30
+ /** One UTF-16 unit as `\uXXXX`. */
31
+ export function unicodeEscape(unit) {
32
+ return `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`;
33
+ }
@@ -1,8 +1,8 @@
1
- import { getFriendlyName, } from "@typespec/compiler";
1
+ import { getDoc, getFriendlyName, } from "@typespec/compiler";
2
2
  import { getVisibilitySuffix, Visibility } from "@typespec/http";
3
3
  import { reportDiagnostic } from "./lib.js";
4
4
  import { propertyToTs, propertyTypeToTs, typeToTsBody, withTsRefResolver } from "./types.js";
5
- import { captureBackEdges, currentVisibility, isOptionalAt, isTransformedBy, noteBackEdge, propertyToZod, UNREPRESENTABLE, typeToZodBody, withDeferredAnnotator, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
5
+ import { captureBackEdges, currentVisibility, described, isOptionalAt, isTransformedBy, noteBackEdge, propertyToZod, UNREPRESENTABLE, typeToZodBody, withDeferredAnnotator, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
6
6
  /**
7
7
  * The name an ANONYMOUS payload model inherits from the single model it spreads.
8
8
  *
@@ -394,7 +394,17 @@ export class SchemaRegistry {
394
394
  * and the emitted `const` would read itself while initialising. `z.lazy()` defers the whole body
395
395
  * instead, which is the only place left to put the deferral once no property can hold it.
396
396
  */
397
- const source = rendered.deferred ? `z.lazy(() => ${rendered.value})` : rendered.value;
397
+ /**
398
+ * **The description goes on the declaration's own body, so the exported const IS the
399
+ * described schema.** Every reference then names that one instance, which is what keeps
400
+ * identity, `z.compile`'s clone (it inherits the description through its parent link) and
401
+ * Zod's `$ref` extraction all intact. Described at a reference instead, each use would be a
402
+ * fresh clone. Read from the canonical type, because an anonymous payload has its doc only on
403
+ * the model it was spread from, and a visibility projection carries the doc of the model it
404
+ * projects - as openapi3 publishes one component for both.
405
+ */
406
+ const body = rendered.deferred ? `z.lazy(() => ${rendered.value})` : rendered.value;
407
+ const source = described(body, getDoc(this.#program, canonicalTypeFor(type)));
398
408
  const declaration = {
399
409
  identifier,
400
410
  typeName: name,
@@ -523,7 +533,20 @@ export class SchemaRegistry {
523
533
  * drift.
524
534
  */
525
535
  #structuralBodyOf(type) {
526
- return withTsRefResolver((candidate) => declaredNameOf(this.#program, candidate), () => typeToTsBody(this.#program, type));
536
+ return withTsRefResolver((candidate) => this.#typeNameOf(candidate), () => typeToTsBody(this.#program, type));
537
+ }
538
+ /**
539
+ * **The TypeScript name a reference resolves to is the one {@link expressionFor} declares it
540
+ * under**, visibility suffix included. Resolved by the bare name, a cycle through a model a
541
+ * request projects typed the projection's back edges by the READ shape: `NodeCreate`'s
542
+ * `children` became `Node[]`, which carries a property the request never sends, and the declared
543
+ * validator did not satisfy its own annotation (`TS2375`).
544
+ */
545
+ #typeNameOf(candidate) {
546
+ const bare = declaredNameOf(this.#program, candidate);
547
+ if (bare === undefined)
548
+ return undefined;
549
+ return nameAt(bare, visibilityFor(this.#program, candidate, currentVisibility()));
527
550
  }
528
551
  /**
529
552
  * Render `type` without consulting its own declaration, but letting nested types use theirs.
@@ -567,7 +590,7 @@ export class SchemaRegistry {
567
590
  #deferredAnnotationOf(property) {
568
591
  if (property.defaultValue !== undefined)
569
592
  return undefined;
570
- const declared = withTsRefResolver((candidate) => declaredNameOf(this.#program, candidate), () => propertyTypeToTs(this.#program, property));
593
+ const declared = withTsRefResolver((candidate) => this.#typeNameOf(candidate), () => propertyTypeToTs(this.#program, property));
571
594
  const core = `z.ZodType<${declared}, ${declared}>`;
572
595
  const optional = property.optional || isOptionalAt(this.#program, property);
573
596
  return `: ${optional ? `z.ZodExactOptional<${core}>` : core}`;
@@ -1,4 +1,5 @@
1
1
  import { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
2
+ import { literal } from "./literal.js";
2
3
  /**
3
4
  * **A media type's essence: `type/subtype`, lowercased, with no parameters** (RFC 9110 section
4
5
  * 8.3.1). Two media types are the same type exactly when their essences are equal, so every
@@ -41,7 +42,7 @@ export function isMediaRange(mediaType) {
41
42
  */
42
43
  export function mediaTypeTypeOf(mediaType) {
43
44
  if (!isMediaRange(mediaType))
44
- return JSON.stringify(mediaType);
45
+ return literal(mediaType);
45
46
  if (mediaType === "*/*")
46
47
  return "string";
47
48
  return `\`${mediaType.slice(0, -1)}\${string}\``;
package/dist/src/types.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getDiscriminatedUnion, isNeverType, resolveEncodedName, } from "@typespec/compiler";
2
2
  import { reportDiagnostic } from "./lib.js";
3
+ import { literal } from "./literal.js";
3
4
  import { discriminatedSubtypes, effectiveIndexer, emitsDefault, encodedTypeOf, inheritedAndOwnProperties, isOptionalAt, isPayloadProperty, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
4
5
  /**
5
6
  * TypeSpec type -> plain TypeScript type text.
@@ -102,7 +103,7 @@ function scalarToTs(program, scalar) {
102
103
  }
103
104
  function enumToTs(target) {
104
105
  const values = [...target.members.values()].map((member) => member.value ?? member.name);
105
- return values.map((value) => JSON.stringify(value)).join(" | ");
106
+ return values.map((value) => literal(value)).join(" | ");
106
107
  }
107
108
  function unionToTs(program, union) {
108
109
  /**
@@ -115,7 +116,7 @@ function unionToTs(program, union) {
115
116
  if (discriminated !== undefined && discriminated.defaultVariant === undefined) {
116
117
  const { envelope, discriminatorPropertyName, envelopePropertyName } = discriminated.options;
117
118
  const arms = [...discriminated.variants.entries()].map(([name, variant]) => {
118
- const tag = `${objectKey(discriminatorPropertyName)}: ${JSON.stringify(name)};`;
119
+ const tag = `${objectKey(discriminatorPropertyName)}: ${literal(name)};`;
119
120
  const inner = typeToTs(program, variant);
120
121
  // No injection: with no envelope the variant declares the discriminator itself, or the union
121
122
  // is refused in `zod.ts`. Nothing is added that the document does not publish.
@@ -369,13 +370,13 @@ export function typeToTsBody(program, type) {
369
370
  case "Union":
370
371
  return unionToTs(program, type);
371
372
  case "String":
372
- return JSON.stringify(type.value);
373
+ return literal(type.value);
373
374
  case "Number":
374
375
  return String(type.value);
375
376
  case "Boolean":
376
377
  return String(type.value);
377
378
  case "EnumMember":
378
- return JSON.stringify(type.value ?? type.name);
379
+ return literal(type.value ?? type.name);
379
380
  // A named union variant is a referable type - `DogKind.Golden`. See the note in `zod.ts`.
380
381
  case "UnionVariant":
381
382
  return typeToTs(program, type.type);
@@ -1,15 +1,3 @@
1
- /**
2
- * RFC 6570 URI templates, read for what a server has to mount and what it has to decode.
3
- *
4
- * **`HttpOperation.uriTemplate`, never `HttpOperation.path`.** `path` drops every operator, so
5
- * `array{.param*}`, `array{;param}` and `array{/param}` all become `array{param}`, and a route mounted
6
- * from it answers 404 to the URL its own contract declares. Measured on `@typespec/http-specs`
7
- * `routes`: 30 of its mock server's URIs, plus the query-continuation six.
8
- *
9
- * `parseUriTemplate` is copied from `@typespec/http` 1.16.0 (`dist/src/uri-template.js`, MIT), which
10
- * implements it but does not export it, so the template is split exactly as the library that built
11
- * it splits it.
12
- */
13
1
  /**
14
2
  * One `/`-delimited segment of a route, as a server has to mount it.
15
3
  *
@@ -1,3 +1,4 @@
1
+ import { literal } from "./literal.js";
1
2
  /**
2
3
  * RFC 6570 URI templates, read for what a server has to mount and what it has to decode.
3
4
  *
@@ -128,7 +129,7 @@ export function needsExpansionDecoding(segment, shape) {
128
129
  * {@link URI_EXPANSION_SOURCE}, declared once in `schemas.gen.ts` when something calls it.
129
130
  */
130
131
  export function uriExpansionCall(spec) {
131
- return `(raw, ctx) => uriExpansion(raw, ctx, { prefix: ${JSON.stringify(spec.prefix)}, suffix: ${JSON.stringify(spec.suffix)}, operator: ${JSON.stringify(spec.operator)}, name: ${JSON.stringify(spec.name)}, explode: ${spec.explode}, shape: ${JSON.stringify(spec.shape)}, values: ${JSON.stringify(spec.values)} })`;
132
+ return `(raw, ctx) => uriExpansion(raw, ctx, { prefix: ${literal(spec.prefix)}, suffix: ${literal(spec.suffix)}, operator: ${literal(spec.operator)}, name: ${literal(spec.name)}, explode: ${spec.explode}, shape: ${literal(spec.shape)}, values: ${literal(spec.values)} })`;
132
133
  }
133
134
  /**
134
135
  * **RFC 6570 section 3.2, run backwards.** A router hands over the text of the segment an expression
@@ -209,7 +210,7 @@ function uriExpansion(
209
210
  * gatherer itself is {@link EXPLODED_QUERY_SOURCE}.
210
211
  */
211
212
  export function explodedQueryCall(spec) {
212
- return `(raw) => explodedQuery(raw, { name: ${JSON.stringify(spec.name)}, others: ${JSON.stringify(spec.others)}, keys: ${JSON.stringify(spec.keys ?? null)}, numbers: ${JSON.stringify(spec.numbers)}, booleans: ${JSON.stringify(spec.booleans)}, values: ${JSON.stringify(spec.values)} })`;
213
+ return `(raw) => explodedQuery(raw, { name: ${literal(spec.name)}, others: ${literal(spec.others)}, keys: ${literal(spec.keys ?? null)}, numbers: ${literal(spec.numbers)}, booleans: ${literal(spec.booleans)}, values: ${literal(spec.values)} })`;
213
214
  }
214
215
  /**
215
216
  * **`{?param*}` over a record or a model expands to the object's own keys**, `?a=1&b=2` and
package/dist/src/zod.d.ts CHANGED
@@ -75,6 +75,40 @@ export declare function withDeclarationSite<T>(site: Type | undefined, run: () =
75
75
  * walked, which does.
76
76
  */
77
77
  export declare function refusalTarget(type: Type): Type;
78
+ /**
79
+ * **A doc becomes `.describe()`, which is the one place Zod keeps a description.**
80
+ *
81
+ * `@typespec/openapi3` writes every `getDoc` as a JSON Schema `description`, and nothing carried it
82
+ * into the validators: a surface publishing its input schema FROM them - an MCP server's
83
+ * `tools/list` - gave a model calling a tool bare patterns and enums with no word on what a field is
84
+ * for. Measured on one consumer: a tool refused 369 calls and accepted 98, most refusals coming from
85
+ * the client checking that published schema before it called.
86
+ *
87
+ * `.describe()` records the text in `z.globalRegistry`, which is a `globalThis` singleton shared by
88
+ * every copy of Zod in a process, and both `z.toJSONSchema` and Zod's Standard Schema converter read
89
+ * it by default. It returns a clone and changes nothing a parse decides.
90
+ *
91
+ * **It is always the OUTERMOST call at a position, and never on a bare reference.** Inside
92
+ * `.nullable()` the text lands in `anyOf[0]` rather than on the property; on an identifier inside
93
+ * `z.array()` or a union it would clone a declaration that other code finds by identity. So it is
94
+ * applied where a declaration's body, a property's value or a scalar's schema is finished, and
95
+ * nowhere else.
96
+ *
97
+ * Gated on `undefined` rather than on truthiness, as openapi3's `applyConstraint` is: an empty
98
+ * `@doc("")` is published as `""`, and so is it here.
99
+ */
100
+ export declare function described(expression: string, doc: string | undefined): string;
101
+ /**
102
+ * The description a declared scalar publishes: its own doc, else the nearest one up its `extends`
103
+ * chain, and never a standard type's.
104
+ *
105
+ * Both halves are openapi3's, from `#getSchemaForScalar`: a derived scalar starts from its base's
106
+ * schema, description included, and overwrites what it declares itself - so `scalar B extends A`
107
+ * with no doc publishes A's. A standard scalar's description is deleted, because "standard types are
108
+ * going to be inlined in the spec and we don't want the description of the scalar to show up".
109
+ * Without that, `string`'s own "A sequence of textual characters." would sit on every `z.string()`.
110
+ */
111
+ export declare function scalarDocOf(program: Program, scalar: Scalar): string | undefined;
78
112
  export declare function withSyntheticDeclarations<T>(hook: (name: string, build: () => string) => string, run: () => T): T;
79
113
  /**
80
114
  * Every property the model declares, **including the ones it inherits**.
package/dist/src/zod.js CHANGED
@@ -1,6 +1,7 @@
1
- import { getDiscriminatedUnion, getDiscriminator, getEncode, resolveEncodedName, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
2
- import { createMetadataInfo, isQueryParam, Visibility } from "@typespec/http";
1
+ import { getDiscriminatedUnion, getDiscriminator, getDoc, getEncode, resolveEncodedName, isNeverType, isTemplateDeclaration, } from "@typespec/compiler";
2
+ import { createMetadataInfo, getCookieParamOptions, getHeaderFieldName, getPathParamName, getQueryParamName, isCookieParam, isHeader, isPathParam, isQueryParam, Visibility, } from "@typespec/http";
3
3
  import { applyConstraints } from "./constraints.js";
4
+ import { literal } from "./literal.js";
4
5
  import { reportDiagnostic } from "./lib.js";
5
6
  /**
6
7
  * What a walk returns after reporting a refusal.
@@ -406,10 +407,57 @@ const SCALARS = {
406
407
  url: "z.url()",
407
408
  };
408
409
  function scalarToZod(program, scalar) {
410
+ return described(scalarSchemaOf(program, scalar), scalarDocOf(program, scalar));
411
+ }
412
+ /**
413
+ * **A doc becomes `.describe()`, which is the one place Zod keeps a description.**
414
+ *
415
+ * `@typespec/openapi3` writes every `getDoc` as a JSON Schema `description`, and nothing carried it
416
+ * into the validators: a surface publishing its input schema FROM them - an MCP server's
417
+ * `tools/list` - gave a model calling a tool bare patterns and enums with no word on what a field is
418
+ * for. Measured on one consumer: a tool refused 369 calls and accepted 98, most refusals coming from
419
+ * the client checking that published schema before it called.
420
+ *
421
+ * `.describe()` records the text in `z.globalRegistry`, which is a `globalThis` singleton shared by
422
+ * every copy of Zod in a process, and both `z.toJSONSchema` and Zod's Standard Schema converter read
423
+ * it by default. It returns a clone and changes nothing a parse decides.
424
+ *
425
+ * **It is always the OUTERMOST call at a position, and never on a bare reference.** Inside
426
+ * `.nullable()` the text lands in `anyOf[0]` rather than on the property; on an identifier inside
427
+ * `z.array()` or a union it would clone a declaration that other code finds by identity. So it is
428
+ * applied where a declaration's body, a property's value or a scalar's schema is finished, and
429
+ * nowhere else.
430
+ *
431
+ * Gated on `undefined` rather than on truthiness, as openapi3's `applyConstraint` is: an empty
432
+ * `@doc("")` is published as `""`, and so is it here.
433
+ */
434
+ export function described(expression, doc) {
435
+ return doc === undefined ? expression : `${expression}.describe(${literal(doc)})`;
436
+ }
437
+ /**
438
+ * The description a declared scalar publishes: its own doc, else the nearest one up its `extends`
439
+ * chain, and never a standard type's.
440
+ *
441
+ * Both halves are openapi3's, from `#getSchemaForScalar`: a derived scalar starts from its base's
442
+ * schema, description included, and overwrites what it declares itself - so `scalar B extends A`
443
+ * with no doc publishes A's. A standard scalar's description is deleted, because "standard types are
444
+ * going to be inlined in the spec and we don't want the description of the scalar to show up".
445
+ * Without that, `string`'s own "A sequence of textual characters." would sit on every `z.string()`.
446
+ */
447
+ export function scalarDocOf(program, scalar) {
448
+ for (let current = scalar; current !== undefined && !program.checker.isStdType(current); current = current.baseScalar) {
449
+ const doc = getDoc(program, current);
450
+ if (doc !== undefined)
451
+ return doc;
452
+ }
453
+ return undefined;
454
+ }
455
+ /** A scalar's schema without its description, for a position that publishes its own. */
456
+ function scalarSchemaOf(program, scalar) {
409
457
  // `scalar unixTimestamp32 is utcDateTime;` with `@encode(int32)` on the SCALAR, not on each use.
410
458
  const encoded = encodedTypeOf(program, scalar);
411
459
  if (encoded !== undefined && encoded !== scalar) {
412
- return applyConstraints(program, scalarToZod(program, encoded), scalar);
460
+ return applyConstraints(program, scalarSchemaOf(program, encoded), scalar);
413
461
  }
414
462
  let current = scalar;
415
463
  // Walk the `extends` chain so a named scalar (`scalar chNumber extends string`) resolves to the
@@ -454,7 +502,7 @@ function scalarToZod(program, scalar) {
454
502
  function enumToZod(_program, target) {
455
503
  const values = [...target.members.values()].map((member) => member.value ?? member.name);
456
504
  if (values.some((value) => typeof value !== "string")) {
457
- return `z.union([${values.map((value) => `z.literal(${JSON.stringify(value)})`).join(", ")}])`;
505
+ return `z.union([${values.map((value) => `z.literal(${literal(value)})`).join(", ")}])`;
458
506
  }
459
507
  /**
460
508
  * Every string vocabulary resolves through the generated constant rather than being restated.
@@ -475,7 +523,7 @@ function enumToZod(_program, target) {
475
523
  */
476
524
  const shared = contractsPackage();
477
525
  if (shared === undefined) {
478
- return `z.enum([${values.map((value) => JSON.stringify(value)).join(", ")}])`;
526
+ return `z.enum([${values.map((value) => literal(value)).join(", ")}])`;
479
527
  }
480
528
  noteExternalImport(shared, "SPEC_VOCABULARIES");
481
529
  return `z.enum(SPEC_VOCABULARIES.${target.name})`;
@@ -534,7 +582,7 @@ function declaresDiscriminator(program, variant, property) {
534
582
  */
535
583
  function discriminatedVariant(program, union, variantName, variant, options) {
536
584
  const inner = typeToZod(program, variant);
537
- const tag = `${objectKey(options.discriminatorPropertyName)}: z.literal(${JSON.stringify(variantName)})`;
585
+ const tag = `${objectKey(options.discriminatorPropertyName)}: z.literal(${literal(variantName)})`;
538
586
  // The variant already carries the discriminator, so the document publishes it and there is
539
587
  // nothing to add.
540
588
  if (options.envelope === "none")
@@ -587,7 +635,7 @@ function unionToZod(program, union) {
587
635
  }
588
636
  const members = [...discriminated.variants.entries()].map(([name, variant]) => discriminatedVariant(program, union, name, variant, options));
589
637
  const property = options.discriminatorPropertyName;
590
- return `z.discriminatedUnion(${JSON.stringify(property)}, [${members.join(", ")}])`;
638
+ return `z.discriminatedUnion(${literal(property)}, [${members.join(", ")}])`;
591
639
  }
592
640
  const variants = [...union.variants.values()].map((variant) => variant.type);
593
641
  const nullable = variants.some((type) => type.kind === "Intrinsic" && type.name === "null");
@@ -602,7 +650,7 @@ function unionToZod(program, union) {
602
650
  }
603
651
  const suffix = nullable ? ".nullable()" : "";
604
652
  if (real.every((type) => type.kind === "String")) {
605
- const literals = real.map((type) => JSON.stringify(type.value));
653
+ const literals = real.map((type) => literal(type.value));
606
654
  return `z.enum([${literals.join(", ")}])${suffix}`;
607
655
  }
608
656
  if (real.length === 1)
@@ -698,9 +746,10 @@ function modelToZod(program, model) {
698
746
  */
699
747
  const subtypes = discriminatedSubtypes(program, model);
700
748
  if (subtypes.length > 0) {
701
- const discriminator = getDiscriminator(program, model);
749
+ // `discriminatedSubtypes` is empty unless the model has a discriminator.
750
+ const discriminator = getDiscriminator(program, model)?.propertyName ?? "";
702
751
  const options = subtypes.map((derived) => typeToZod(program, derived));
703
- return `z.discriminatedUnion(${JSON.stringify(discriminator?.propertyName)}, [${options.join(", ")}])`;
752
+ return `z.discriminatedUnion(${literal(discriminator)}, [${options.join(", ")}])`;
704
753
  }
705
754
  const indexer = effectiveIndexer(model);
706
755
  const indexerKey = indexer?.key.name;
@@ -945,7 +994,16 @@ export function wireKindOf(program, type) {
945
994
  * Exported so there is one answer to this question. There were two sites and only one of them knew.
946
995
  */
947
996
  export function objectKey(name) {
948
- return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
997
+ /**
998
+ * **`__proto__` is the one name a key cannot spell plainly.** In an object literal, `__proto__:`
999
+ * - quoted or not - sets the object's prototype rather than declaring a key, so the emitted shape
1000
+ * had no such property: it vanished from the published JSON Schema, and under
1001
+ * `seal-object-schemas` every body carrying it was refused as an unrecognised key. A computed
1002
+ * key declares it, in a value and in a type alike. See `unenforceable-property-name`.
1003
+ */
1004
+ if (name === "__proto__")
1005
+ return `[${literal(name)}]`;
1006
+ return /^[A-Za-z_$][\w$]*$/.test(name) ? name : literal(name);
949
1007
  }
950
1008
  /**
951
1009
  * A doc string as a JSDoc comment, indented, or `""` when there is none.
@@ -1063,9 +1121,56 @@ const PARAMETER_STYLE_ENCODINGS = new Set([
1063
1121
  "ArrayEncoding.commaDelimited",
1064
1122
  "ArrayEncoding.newlineDelimited",
1065
1123
  ]);
1124
+ /**
1125
+ * **A date-time header with no `@encode` is an RFC 7231 HTTP-date, as if it said
1126
+ * `@encode(DateTimeKnownEncoding.rfc7231)`.**
1127
+ *
1128
+ * That is TypeSpec's wire form for one, and `@typespec/openapi3` publishes it as
1129
+ * `format: http-date` from `applyEncoding`'s `isDateTimeHeader` branch, which this emitter did not
1130
+ * follow. The header fell through to the scalar's default, `z.iso.datetime({ offset: true })`, so a
1131
+ * request sending it as the document describes was refused with 400, and a handler setting it as an
1132
+ * HTTP-date failed its own response contract with 500 (issue #4). The explicit encoding already
1133
+ * emitted `z.string()`, and this is that.
1134
+ *
1135
+ * **The predicate is openapi3's: the header's own schema carries `format: date-time`.** That is
1136
+ * the standard `utcDateTime` or `offsetDateTime` itself. A declared scalar extending one is
1137
+ * published as a `$ref`, a list carries the format on its items, and `T | null` is an `anyOf`
1138
+ * under 3.1 and 3.2 - the versions this package grades against - so none of those is an HTTP-date
1139
+ * in the document, and none is here. (3.0 publishes the nullable one as `nullable: true` beside an
1140
+ * `http-date` format; the graded documents do not.)
1141
+ */
1142
+ function isDateTimeHeader(program, property) {
1143
+ const type = property.type;
1144
+ return isHeader(program, property) && type.kind === "Scalar" && isStdDateTime(program, type);
1145
+ }
1146
+ function isStdDateTime(program, scalar) {
1147
+ const { checker } = program;
1148
+ return (scalar === checker.getStdType("utcDateTime") || scalar === checker.getStdType("offsetDateTime"));
1149
+ }
1150
+ /** Whether a property's value, or a scalar, is a date-time: one of the two, or built on one. */
1151
+ function isDateTime(program, target) {
1152
+ const type = target.kind === "ModelProperty" ? (nullableInnerOf(target.type) ?? target.type) : target;
1153
+ for (let current = type.kind === "Scalar" ? type : undefined; current !== undefined; current = current.baseScalar) {
1154
+ if (isStdDateTime(program, current))
1155
+ return true;
1156
+ }
1157
+ return false;
1158
+ }
1066
1159
  export function encodedTypeOf(program, target) {
1067
1160
  const encoded = getEncode(program, target);
1068
- if (encoded === undefined)
1161
+ if (encoded === undefined) {
1162
+ return target.kind === "ModelProperty" && isDateTimeHeader(program, target)
1163
+ ? program.checker.getStdType("string")
1164
+ : undefined;
1165
+ }
1166
+ /**
1167
+ * **`rfc3339` on a date-time is the form it already has, so it changes nothing.** openapi3's
1168
+ * `mergeFormatAndEncoding` keeps `format: date-time` for it, and the type it reports, `string`,
1169
+ * emitted `z.string()`: the explicit spelling of the default dropped the date-time check the
1170
+ * document still states. Only the encodings that change the wire form - `rfc7231`,
1171
+ * `unixTimestamp` - change the type.
1172
+ */
1173
+ if (encoded.encoding === "rfc3339" && isDateTime(program, target))
1069
1174
  return undefined;
1070
1175
  if (target.kind === "ModelProperty" &&
1071
1176
  isQueryParam(program, target) &&
@@ -1091,9 +1196,26 @@ export function propertyToZod(program, property) {
1091
1196
  // wrapper is kept: `@encode` changes what the value IS, not whether it may be absent.
1092
1197
  const encoded = encodedTypeOf(program, property);
1093
1198
  const declared = encoded ?? nullable ?? property.type;
1094
- const base = nullable === undefined || encoded !== undefined
1095
- ? applyConstraints(program, typeToZod(program, declared), property)
1096
- : `${applyConstraints(program, typeToZod(program, declared), property)}.nullable()`;
1199
+ const bare = nullable === undefined || encoded !== undefined;
1200
+ /**
1201
+ * **One description per position, and a property's own doc wins over its scalar's.**
1202
+ *
1203
+ * openapi3 publishes `subject: Subject` as `{ $ref: Subject, description: <the property's doc> }`,
1204
+ * and a reader takes the sibling over what the reference carries. A scalar here is inlined rather
1205
+ * than referenced, so where the property's value IS the scalar, its schema is taken without its
1206
+ * description and the one the position publishes is decided once: the property's doc, else the
1207
+ * scalar's. Otherwise a documented property of a documented scalar was written
1208
+ * `.describe(a).describe(b)`, which says the same thing in a way no one would write.
1209
+ *
1210
+ * Under `.nullable()` the scalar keeps its own, inside the wrapper: openapi3 publishes
1211
+ * `anyOf: [{ $ref: Subject }, { type: "null" }]` there, so the scalar's description belongs to the
1212
+ * branch and only the property's belongs to the property.
1213
+ */
1214
+ const inlinedScalar = bare && declared.kind === "Scalar";
1215
+ const value = inlinedScalar ? scalarSchemaOf(program, declared) : typeToZod(program, declared);
1216
+ const constrained = applyConstraints(program, value, property);
1217
+ const base = bare ? constrained : `${constrained}.nullable()`;
1218
+ const description = getDoc(program, property) ?? (inlinedScalar ? scalarDocOf(program, declared) : undefined);
1097
1219
  /**
1098
1220
  * **Optionality is a function of the position too, not only of the `?` in the spec.**
1099
1221
  *
@@ -1104,6 +1226,7 @@ export function propertyToZod(program, property) {
1104
1226
  * predicate `@typespec/openapi3` uses to decide the `required` list.
1105
1227
  */
1106
1228
  const optional = property.optional || isOptionalAt(program, property);
1229
+ reportInheritedName(program, property, optional);
1107
1230
  /**
1108
1231
  * **A default only applies where the document says the property may be absent.**
1109
1232
  *
@@ -1125,7 +1248,7 @@ export function propertyToZod(program, property) {
1125
1248
  target: property,
1126
1249
  format: { name: property.name },
1127
1250
  });
1128
- return base;
1251
+ return described(base, description);
1129
1252
  }
1130
1253
  /**
1131
1254
  * A TypeSpec default (`basis?: string = "s1159"`) becomes `.default(...)`, which in Zod also makes
@@ -1136,7 +1259,7 @@ export function propertyToZod(program, property) {
1136
1259
  */
1137
1260
  const defaulted = defaultOf(program, property);
1138
1261
  if (defaulted !== undefined)
1139
- return `${base}.default(${defaulted})`;
1262
+ return described(`${base}.default(${defaulted})`, description);
1140
1263
  /**
1141
1264
  * **`.exactOptional()`, because the document says the KEY may be absent and nothing more.**
1142
1265
  *
@@ -1155,7 +1278,66 @@ export function propertyToZod(program, property) {
1155
1278
  * differential's serialisation axis is untouched, and `def.type` is still `"optional"`, so the
1156
1279
  * shape describers read it unchanged.
1157
1280
  */
1158
- return optional ? `${base}.exactOptional()` : base;
1281
+ return described(optional ? `${base}.exactOptional()` : base, description);
1282
+ }
1283
+ /**
1284
+ * The names every JavaScript object inherits, which Zod reads in place of an absent property.
1285
+ * `__proto__` is among them and is the different case: Zod skips it outright.
1286
+ */
1287
+ const INHERITED_NAMES = new Set(Object.getOwnPropertyNames(Object.prototype));
1288
+ /** Properties already reported, by declaration: one property renders once per visibility. */
1289
+ const reportedNames = new WeakMap();
1290
+ /**
1291
+ * **A wire name the validator cannot treat as an ordinary key is reported where it bites.** See
1292
+ * `unenforceable-property-name` and `optional-prototype-name` in `lib.ts` for why each is a
1293
+ * warning and what each costs.
1294
+ *
1295
+ * Reported here rather than from a walk over the program because `optional` is a fact about the
1296
+ * POSITION: a PATCH makes a required property optional, and only there does an inherited name
1297
+ * refuse its omission. Once per declaration, by its syntax node, because a visibility projection
1298
+ * hands this a different `ModelProperty` for the same one.
1299
+ */
1300
+ function reportInheritedName(program, property, optional) {
1301
+ const name = wireNameOf(program, property);
1302
+ if (!INHERITED_NAMES.has(name))
1303
+ return;
1304
+ const unenforceable = name === "__proto__";
1305
+ if (!unenforceable && !optional)
1306
+ return;
1307
+ const declaration = property.node ?? property;
1308
+ let reported = reportedNames.get(program);
1309
+ if (reported === undefined)
1310
+ reportedNames.set(program, (reported = new WeakSet()));
1311
+ if (reported.has(declaration))
1312
+ return;
1313
+ reported.add(declaration);
1314
+ if (unenforceable) {
1315
+ reportDiagnostic(program, {
1316
+ code: "unenforceable-property-name",
1317
+ target: property,
1318
+ format: { name },
1319
+ });
1320
+ }
1321
+ else {
1322
+ reportDiagnostic(program, {
1323
+ code: "optional-prototype-name",
1324
+ target: property,
1325
+ format: { name },
1326
+ });
1327
+ }
1328
+ }
1329
+ /** The key a property is validated under: its parameter name, or its name in a JSON body. */
1330
+ function wireNameOf(program, property) {
1331
+ if (isHeader(program, property))
1332
+ return getHeaderFieldName(program, property);
1333
+ if (isQueryParam(program, property))
1334
+ return getQueryParamName(program, property) ?? property.name;
1335
+ if (isPathParam(program, property))
1336
+ return getPathParamName(program, property) ?? property.name;
1337
+ if (isCookieParam(program, property)) {
1338
+ return getCookieParamOptions(program, property)?.name ?? property.name;
1339
+ }
1340
+ return rawPropertyKey(program, property);
1159
1341
  }
1160
1342
  /**
1161
1343
  * A default value as a JS literal, or `undefined` where this emitter cannot render one.
@@ -1180,7 +1362,7 @@ export function propertyToZod(program, property) {
1180
1362
  */
1181
1363
  function renderValue(value) {
1182
1364
  if (value.valueKind === "StringValue")
1183
- return JSON.stringify(value.value);
1365
+ return literal(value.value);
1184
1366
  if (value.valueKind === "NumericValue")
1185
1367
  return String(value.value.asNumber());
1186
1368
  if (value.valueKind === "BooleanValue")
@@ -1189,7 +1371,7 @@ function renderValue(value) {
1189
1371
  return "null";
1190
1372
  if (value.valueKind === "EnumValue") {
1191
1373
  const member = value.value;
1192
- return JSON.stringify(member.value ?? member.name);
1374
+ return literal(member.value ?? member.name);
1193
1375
  }
1194
1376
  if (value.valueKind === "ArrayValue") {
1195
1377
  const rendered = value.values.map(renderValue);
@@ -1203,7 +1385,7 @@ function renderValue(value) {
1203
1385
  const rendered = renderValue(descriptor.value);
1204
1386
  if (rendered === undefined)
1205
1387
  return undefined;
1206
- entries.push(`${JSON.stringify(name)}: ${rendered}`);
1388
+ entries.push(`${literal(name)}: ${rendered}`);
1207
1389
  }
1208
1390
  return `{${entries.join(", ")}}`;
1209
1391
  }
@@ -1330,13 +1512,13 @@ export function typeToZodBody(program, type) {
1330
1512
  case "Union":
1331
1513
  return unionToZod(program, type);
1332
1514
  case "String":
1333
- return `z.literal(${JSON.stringify(type.value)})`;
1515
+ return `z.literal(${literal(type.value)})`;
1334
1516
  case "Number":
1335
1517
  return `z.literal(${type.value})`;
1336
1518
  case "Boolean":
1337
1519
  return `z.literal(${String(type.value)})`;
1338
1520
  case "EnumMember":
1339
- return `z.literal(${JSON.stringify(type.value ?? type.name)})`;
1521
+ return `z.literal(${literal(type.value ?? type.name)})`;
1340
1522
  /**
1341
1523
  * **A named union VARIANT is a type you can refer to, and it is not the union.**
1342
1524
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.28.0",
3
+ "version": "0.29.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",