typespec-http-zod 0.27.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.
@@ -30,7 +30,7 @@ export declare function applyConstraints(program: Program, expression: string, t
30
30
  *
31
31
  * **`false` means "not proven safe", not "proven dangerous"**, and the rule's wording follows that.
32
32
  * `redos-detector` proves safety rather than guessing at danger, so a pattern it cannot decide inside
33
- * the timeout is judged the same way as one it decides against. Measured over a realistic spread of
33
+ * the step budget is judged the same way as one it decides against. Measured over a realistic spread of
34
34
  * 14 patterns an API would carry: 2 conservative warnings, both on a quantifier nested over an
35
35
  * overlapping class, and nothing dangerous missed - including `^\w+([.-]?\w+)*$`, which reads as
36
36
  * ordinary and takes **11 seconds** on a 29-character input.
@@ -1,6 +1,7 @@
1
1
  import { getEncode, getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
2
- import { isSafePattern } from "redos-detector";
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
@@ -87,18 +113,21 @@ function encodesAsString(program, target) {
87
113
  return (own ?? scalar)?.type.name === "string";
88
114
  }
89
115
  /**
90
- * How long one pattern may be analysed before the answer is "could not prove it".
116
+ * How much analysis one pattern gets before the answer is "could not prove it": a number of steps.
91
117
  *
92
- * **Bounded, because the analysis is itself exponential in the bad case.** Measured: `^(a|a)+$`
93
- * takes 1.6 seconds to decide, and a build should not stall on a pattern whose whole problem is that
94
- * it does too much work. A pattern that cannot be decided inside this is one this emitter cannot
95
- * certify, which is what the warning says.
118
+ * **Bounded, because the analysis is itself exponential in the bad case, and bounded in STEPS,
119
+ * because a verdict must not depend on the machine.** It was 500 ms of wall-clock time, and on a
120
+ * loaded CI host `^([0-9a-f]{40}|[0-9a-f]{64})$` took 93 to 403 ms and once ran out: the same spec
121
+ * warned on one build and not the next. A step count is the same everywhere. `redos-detector`'s own
122
+ * default; measured, `^(a|a)+$` spends it in 1.7 seconds idle and 6 under load, and every ordinary
123
+ * pattern decides in milliseconds. A pattern that cannot be decided inside it is one this emitter
124
+ * cannot certify, which is what the warning says.
96
125
  */
97
- const ANALYSIS_TIMEOUT_MS = 500;
126
+ const ANALYSIS_STEPS = defaultMaxSteps;
98
127
  /**
99
128
  * Patterns already judged, keyed on the text, which is the only input the verdict depends on.
100
129
  *
101
- * The analysis costs up to {@link ANALYSIS_TIMEOUT_MS} a pattern, and one pattern text commonly
130
+ * The analysis costs up to {@link ANALYSIS_STEPS} steps a pattern, and one pattern text commonly
102
131
  * recurs across declarations - `^\\d{4}-\\d{2}-\\d{2}$` appears on several scalars in more than one
103
132
  * consumer spec - so each distinct text is analysed once per process.
104
133
  */
@@ -162,7 +191,7 @@ function firstReportOf(program, code, target) {
162
191
  *
163
192
  * **`false` means "not proven safe", not "proven dangerous"**, and the rule's wording follows that.
164
193
  * `redos-detector` proves safety rather than guessing at danger, so a pattern it cannot decide inside
165
- * the timeout is judged the same way as one it decides against. Measured over a realistic spread of
194
+ * the step budget is judged the same way as one it decides against. Measured over a realistic spread of
166
195
  * 14 patterns an API would carry: 2 conservative warnings, both on a quantifier nested over an
167
196
  * overlapping class, and nothing dangerous missed - including `^\w+([.-]?\w+)*$`, which reads as
168
197
  * ordinary and takes **11 seconds** on a 29-character input.
@@ -173,7 +202,8 @@ export function patternIsProvablySafe(pattern) {
173
202
  try {
174
203
  safe = isSafePattern(pattern, {
175
204
  unicode: unicodeSafe(pattern),
176
- timeout: ANALYSIS_TIMEOUT_MS,
205
+ maxSteps: ANALYSIS_STEPS,
206
+ timeout: Number.POSITIVE_INFINITY,
177
207
  }).safe;
178
208
  }
179
209
  catch {
@@ -206,7 +236,7 @@ export function patternIsProvablySafe(pattern) {
206
236
  * instead of guessing at an equivalence.
207
237
  */
208
238
  export function patternToRegex(pattern) {
209
- return `/${escapeSlashes(pattern)}/${unicodeSafe(pattern) ? "u" : ""}`;
239
+ return `/${escapeNonAscii(escapeSlashes(pattern))}/${unicodeSafe(pattern) ? "u" : ""}`;
210
240
  }
211
241
  /**
212
242
  * **Escapes the slashes a regex literal needs escaped, and only those.**
@@ -228,7 +258,15 @@ function escapeSlashes(pattern) {
228
258
  for (let index = 0; index < pattern.length; index += 1) {
229
259
  const character = pattern[index];
230
260
  if (character === "\\") {
231
- 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;
232
270
  index += 1;
233
271
  continue;
234
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
  *
@@ -134,16 +134,37 @@ const RESERVED_DECLARATION_NAMES = new Set([
134
134
  "with",
135
135
  "yield",
136
136
  ]);
137
+ /**
138
+ * **The names the emitted files declare for THEMSELVES, beside the spec's own declarations.**
139
+ *
140
+ * `requests.gen.ts` declares `Produced`, `Simplify`, `ByteStream` and its three lookups; `schemas.gen.ts`
141
+ * declares `ResponseArm`. A model named after one of them was declared twice: `TS2300` for a type
142
+ * alias, and for `WireInputs` or `WireOutputs` something worse, because two interfaces of one name
143
+ * MERGE - the model's properties silently became members of the lookup. So a spec's model of that
144
+ * name is renamed exactly as a reserved word is.
145
+ *
146
+ * Graded by `test/collisions/`, which reads the names an emitted file declares off the OUTPUT rather
147
+ * than off this set, so a helper added later without a line here fails a test.
148
+ */
149
+ const EMITTED_HELPER_NAMES = new Set([
150
+ "ByteStream",
151
+ "Produced",
152
+ "ResponseArm",
153
+ "Simplify",
154
+ "WireInputs",
155
+ "WireOutputs",
156
+ "WireResults",
157
+ ]);
137
158
  /**
138
159
  * The name a declaration is emitted under.
139
160
  *
140
- * A trailing `_` is appended where the spec's own name cannot be a TypeScript declaration. It is
141
- * applied in ONE place so both walks agree: `wire-contract.gen.ts` pairs a validator against a
142
- * contract type by name, and a mangling applied to one side only would break that assertion rather
143
- * than the file.
161
+ * A trailing `_` is appended where the spec's own name cannot be a TypeScript declaration, or is one
162
+ * the emitted file already declares for itself. It is applied in ONE place so both walks agree:
163
+ * `wire-contract.gen.ts` pairs a validator against a contract type by name, and a mangling applied to
164
+ * one side only would break that assertion rather than the file.
144
165
  */
145
166
  function safeDeclarationName(name) {
146
- return RESERVED_DECLARATION_NAMES.has(name) ? `${name}_` : name;
167
+ return RESERVED_DECLARATION_NAMES.has(name) || EMITTED_HELPER_NAMES.has(name) ? `${name}_` : name;
147
168
  }
148
169
  /** A type earns a name when the spec gave it one - anonymous shapes stay inline where they are used. */
149
170
  function declaredNameOf(program, type) {
@@ -373,7 +394,17 @@ export class SchemaRegistry {
373
394
  * and the emitted `const` would read itself while initialising. `z.lazy()` defers the whole body
374
395
  * instead, which is the only place left to put the deferral once no property can hold it.
375
396
  */
376
- 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)));
377
408
  const declaration = {
378
409
  identifier,
379
410
  typeName: name,
@@ -502,7 +533,20 @@ export class SchemaRegistry {
502
533
  * drift.
503
534
  */
504
535
  #structuralBodyOf(type) {
505
- 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()));
506
550
  }
507
551
  /**
508
552
  * Render `type` without consulting its own declaration, but letting nested types use theirs.
@@ -546,7 +590,7 @@ export class SchemaRegistry {
546
590
  #deferredAnnotationOf(property) {
547
591
  if (property.defaultValue !== undefined)
548
592
  return undefined;
549
- const declared = withTsRefResolver((candidate) => declaredNameOf(this.#program, candidate), () => propertyTypeToTs(this.#program, property));
593
+ const declared = withTsRefResolver((candidate) => this.#typeNameOf(candidate), () => propertyTypeToTs(this.#program, property));
550
594
  const core = `z.ZodType<${declared}, ${declared}>`;
551
595
  const optional = property.optional || isOptionalAt(this.#program, property);
552
596
  return `: ${optional ? `z.ZodExactOptional<${core}>` : core}`;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * **What a result is made of: one member per declared response and media type, and the codes each
3
+ * one answers with.** One derivation, read by every artefact that states a result.
4
+ *
5
+ * `typespec-hono` types a handler's return as a union of these members, `typespec-http-mcp` types a
6
+ * tool's the same way, and `requests.gen.ts` states the same union for a backend that has no
7
+ * framework to import. All three used to hold their own copy of these rules, and three copies of
8
+ * rules that must agree are three that will not: a status one of them could see and another could
9
+ * not is how `WireOutputs` came to publish one success body for an operation answering two.
10
+ */
11
+ /**
12
+ * A status as the DOCUMENT keys it: an exact code, a `4XX` bucket, or the catch-all.
13
+ *
14
+ * `"default"` is kept as itself rather than mapped to a number. The document means "any status not
15
+ * listed above", and inventing one would be answering a question the contract left open.
16
+ */
17
+ export type StatusKey = number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
18
+ /**
19
+ * The facts about one declared response that decide how its result is shaped.
20
+ *
21
+ * Structural, so both an emitted response (`EmittedResponse`, whose body is a Zod expression) and a
22
+ * resolved one (whose body is a TypeSpec type) satisfy it; the caller says which ones carry a body.
23
+ */
24
+ export interface ResponseFacts {
25
+ readonly status: StatusKey;
26
+ readonly contentTypes: readonly string[];
27
+ readonly binary: boolean;
28
+ readonly streamed: boolean;
29
+ readonly textual: boolean;
30
+ }
31
+ /** How a member's body is served, which decides both its type and the call that serves it. */
32
+ export type BodyServing = "none" | "stream" | "binary" | "json" | "text" | "unvalidated";
33
+ /**
34
+ * One member of an operation's result: one response, served as one media type.
35
+ *
36
+ * **A status offering several media types is several members**, because the body differs by media
37
+ * type: the Swagger Petstore answers `getPetById` with a `Pet` as `application/json` and with text as
38
+ * `application/xml`. One member with a union body could not say which body goes with which type.
39
+ */
40
+ export interface ResultMember<R extends ResponseFacts> {
41
+ readonly response: R;
42
+ /** The response's position in the list it came from, so a caller can find what it names it by. */
43
+ readonly index: number;
44
+ /** The media type served, or `undefined` where the response has no body. */
45
+ readonly mediaType: string | undefined;
46
+ /**
47
+ * Whether the result names the media type: the status offers more than one, or the document names
48
+ * a range such as `image/*`, which is not a type a response can be sent as.
49
+ */
50
+ readonly choosesMediaType: boolean;
51
+ readonly serving: BodyServing;
52
+ /**
53
+ * **The document declares a schema for this body that nothing checks it against**: a model under a
54
+ * media type that is not JSON, which the handler supplies as text (`"unvalidated"`) or, under a
55
+ * type that is not text, as bytes (`"binary"`). A `bytes` or `Http.File` body declares nothing to
56
+ * check, so it is not this.
57
+ */
58
+ readonly unvalidated: boolean;
59
+ }
60
+ /**
61
+ * **A media type's essence: `type/subtype`, lowercased, with no parameters** (RFC 9110 section
62
+ * 8.3.1). Two media types are the same type exactly when their essences are equal, so every
63
+ * comparison of a declared media type is of this. `application/json; charset=utf-8` and
64
+ * `Application/JSON` were compared as whole strings and read as not JSON: the body became raw text,
65
+ * and the response an unvalidated string.
66
+ */
67
+ export declare function mediaTypeEssenceOf(type: string): string;
68
+ /** `application/json` and every `+json` suffix: the media types a body is serialised as JSON for. */
69
+ export declare function isJsonMediaType(type: string): boolean;
70
+ /**
71
+ * **A media type whose body is text by its registration**: the `text` top-level type (RFC 2046
72
+ * section 4.1) and the XML media types, `application/xml` and the `+xml` suffix (RFC 7303).
73
+ *
74
+ * **What decides whether a body nothing parses is text or bytes.** A model declared under such a type
75
+ * has no reader or serialisation this emitter can derive, so it is handed over as the text that
76
+ * arrived and answered as the text the handler writes. Under any other type, such as
77
+ * `application/octet-stream` or `application/x-protobuf`, text cannot carry it: decoded as UTF-8,
78
+ * every byte that is not a valid sequence becomes U+FFFD, so it is bytes instead. JSON is text as
79
+ * well, and is parsed rather than handed over, so it never reaches this question.
80
+ */
81
+ export declare function isTextualMediaType(type: string): boolean;
82
+ /** A media range such as `image/*`, rather than a type a response can be sent as. */
83
+ export declare function isMediaRange(mediaType: string): boolean;
84
+ /**
85
+ * The type of the `contentType` a result names for a member.
86
+ *
87
+ * **A range is typed as the types inside it**: `image/*` is `` `image/${string}` ``, so `text/html`
88
+ * under it does not compile, and TypeScript still narrows a literal member away from it. Only the
89
+ * full range is `string`.
90
+ */
91
+ export declare function mediaTypeTypeOf(mediaType: string): string;
92
+ /**
93
+ * Every member an operation's responses declare, in the order the responses are listed.
94
+ *
95
+ * `hasBody` says which responses carry a body, because the two kinds of record spell that
96
+ * differently - an emitted one has a schema, a resolved one has a type.
97
+ */
98
+ export declare function resultMembersOf<R extends ResponseFacts>(responses: readonly R[], hasBody: (response: R) => boolean): ResultMember<R>[];
99
+ /**
100
+ * The codes a declared response answers with, written out.
101
+ *
102
+ * - An exact code is itself.
103
+ * - A range is its class's codes, less every code declared exactly - OpenAPI resolves an exact code
104
+ * before its range, as a server does - and, where it carries a body, less the codes that cannot.
105
+ * - `default` is every code from `2xx` to `5xx` that nothing else declares, exactly or by range,
106
+ * again less the contentless codes where it carries a body. `1xx` is not a result a handler
107
+ * returns.
108
+ *
109
+ * **Written out rather than as `number`**, because `number` admits `999`, and a client cannot
110
+ * narrow a result on a status type that admits everything. Empty where nothing is left, which a
111
+ * caller renders as `never`.
112
+ */
113
+ export declare function statusCodesOf<R extends ResponseFacts>(response: R, responses: readonly R[], hasBody: (response: R) => boolean): readonly number[];