typespec-http-zod 0.30.0 → 0.31.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.
@@ -1,72 +1,13 @@
1
1
  import { getDoc, getFriendlyName, } from "@typespec/compiler";
2
2
  import { getVisibilitySuffix, Visibility } from "@typespec/http";
3
+ import { firstReportOf } from "./constraints.js";
3
4
  import { reportDiagnostic } from "./lib.js";
4
- import { propertyToTs, propertyTypeToTs, typeToTsBody, withTsRefResolver } from "./types.js";
5
- import { captureBackEdges, currentVisibility, described, isOptionalAt, isTransformedBy, noteBackEdge, propertyToZod, UNREPRESENTABLE, typeToZodBody, withDeferredAnnotator, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
5
+ import { isSplitByVisibility, isSuffixedByUsage, viewRemedyOf, viewTemplateOf, } from "./published.js";
6
+ import { propertyToTs, propertyTypeToTs, typeToTs, typeToTsBody, withTsRefResolver, withTsSyntheticDeclarations, } from "./types.js";
7
+ import { captureBackEdges, currentVisibility, described, externalReferenceOf, isArrayLiteral, isOptionalAt, isSpeakableInstance, refuseExternalReference, metadataAnnotationsIgnored, metadataInfoOf, multipartBinaryContext, noteBackEdge, OWN_POSITION, propertyToZod, renderedVisibilityOf, rootVisibilityOf, UNREPRESENTABLE, typeToZodBody, visibilityName, withDeclarationSite, withDeferredAnnotator, withMetadataAnnotationsIgnored, withMultipartBinary, withRefResolver, withHeldDeclarations, withSyntheticDeclarations, withParameterPosition, withVisibility, } from "./zod.js";
6
8
  /**
7
- * The name an ANONYMOUS payload model inherits from the single model it spreads.
8
- *
9
- * **`@typespec/http` hands back a synthesised model for a body whose declaration carried
10
- * metadata**, and it has no name - so `@error model InvalidAuth { @statusCode _: 403; error: string }`
11
- * reached the registry as `{error}` with `name: ""`, was inlined at its use site, and produced **no
12
- * validator at all** for a component the document publishes as `InvalidAuth`. Four authentication
13
- * scenarios, each with an unvalidated 403.
14
- *
15
- * `@typespec/openapi3` never meets that model: it works from the TypeSpec type and strips metadata
16
- * itself, which is why its component keeps the original name. This recovers the same name from
17
- * `sourceModels`, so both artefacts call the shape the same thing.
18
- *
19
- * **Only a PURE spread of one named model.** `{...A, extra: string}` is a different shape and must
20
- * not claim `A`'s name - so every property has to come from the source. Metadata-stripping removes
21
- * properties and never adds them, which is exactly the asymmetry this tests for. A collision would be
22
- * caught by the differential's `component-name-collision` arm regardless.
23
- */
24
- function spreadSourceOf(type, seen = new Set(),
25
- /**
26
- * The properties the ORIGINAL type has, carried down the chain rather than re-derived.
27
- *
28
- * **Re-deriving them at each link is what blocked `Alias.InnerModel`.** The intermediate model
29
- * an operation builds holds the spread body's properties AND the operation's own `@path` and
30
- * `@header` parameters, so checking THAT set against the named ancestor fails on properties the
31
- * body never had. The question is whether every property of the shape being named comes from the
32
- * ancestor - `{name}` does - not whether some intermediate did.
33
- *
34
- * The guard is unchanged in what it refuses: `{...A, extra}` still fails, because `extra` is in
35
- * the original set and not in `A`.
36
- */
37
- required = [...type.properties.keys()]) {
38
- if (seen.has(type))
39
- return undefined;
40
- const sources = type.sourceModels;
41
- if (sources.length !== 1)
42
- return undefined;
43
- const source = sources[0];
44
- if (source === undefined || source.usage !== "spread")
45
- return undefined;
46
- const model = source.model;
47
- if (model.templateMapper !== undefined)
48
- return undefined;
49
- for (const property of required) {
50
- if (!model.properties.has(property))
51
- return undefined;
52
- }
53
- /**
54
- * **Follow the chain through ANONYMOUS links.** TypeSpec puts an unnamed model between an
55
- * operation's parameter model and the model that was spread into it, so stopping at the first
56
- * source finds `""` and gives up - measured: the bodies for `Alias.InnerModel` and
57
- * `Model.CompositeRequestMix` both reported `sources: [{ usage: "spread", name: "" }]` while the
58
- * document published both as components, and the one case that already worked
59
- * (`Model.BodyParameter`) was the one whose source happened to be named directly.
60
- *
61
- * Terminating on an unnamed source with no named ancestor is what keeps a spread ALIAS inline:
62
- * OpenAPI names a component for a spread model and not for an alias, and following the chain
63
- * reproduces that distinction rather than asserting it.
64
- */
65
- return model.name === "" ? spreadSourceOf(model, new Set([...seen, type]), required) : model;
66
- }
67
- const spreadSourceName = (type) => spreadSourceOf(type)?.name;
68
- /**
69
- * ECMAScript's reserved words, which cannot name a TypeScript declaration.
9
+ * The words TypeScript refuses as the name of a declaration the emitted files write - a type alias,
10
+ * an interface, and the references to each - in a module, which is strict mode.
70
11
  *
71
12
  * **A model may be named after one, and the corpus declares exactly that on purpose.** `special-words`
72
13
  * carries `await`, `break` and the rest, and this emitter wrote `export interface await { ... }` and
@@ -75,25 +16,28 @@ const spreadSourceName = (type) => spreadSourceOf(type)?.name;
75
16
  *
76
17
  * **This is a LIST, which this package otherwise refuses.** It is defensible here because it is
77
18
  * not a list about our own code that drifts as the code changes: it is a closed set the language
78
- * specification fixes. The guard is that `tsc` compiles a fixture declaring models named after these,
79
- * so a word missing from the list fails a test rather than reaching a consumer - the list is the
80
- * implementation and the compiler is the oracle.
81
- *
82
- * Most contextual keywords are absent, because they are legal: `interface`, `string`, `number`,
83
- * `any`, `never` and `public` all name a type without complaint.
19
+ * fixes. The guard is that `test/round9/names/` reads every keyword from the scanner of the
20
+ * TypeScript this package builds with, measures which ones `tsc` refuses in each position, and holds
21
+ * this set to exactly those - so a word missing here, or one renamed that need not be, fails a test.
84
22
  *
85
- * **Two contextual words are NOT legal, and both were missing from this set.** `as` and `yield` are
86
- * refused in the declaration positions this emitter writes -- `export type as = ...` is
87
- * `TS1005: '{' expected`, and `yield` is `TS1214: 'yield' is a reserved word in strict mode` as both
88
- * a type alias and an interface. Either one made the whole emitted file unparseable, because the
89
- * parser gives up after the first.
23
+ * **It is four groups, and only the first is ECMAScript's reserved words.** This docblock used to say
24
+ * `interface`, `string`, `number`, `any`, `never` and `public` name a type without complaint; every
25
+ * one of them is refused, and `export type object = ...` did not even load (`Type alias name cannot
26
+ * be 'object'`). Measured against tsc 7.0.2 over the 84 keywords its scanner knows, as a model, an
27
+ * enum and a union: 61 refused, the 61 below; the other 23 - `type`, `declare`, `module`, `of` and
28
+ * the like - are legal everywhere the files write a name.
90
29
  *
91
- * **A word belongs here when `tsc` refuses it in EITHER position**, since both are emitted for the
92
- * same model. Measured that way against tsc 7.0.2, one invocation per word: of the 56 candidates,
93
- * 39 are refused and 39 are listed. `yield` had been recorded here as measured-legal and is not,
94
- * which is why the arm that grades this set now takes its words from somewhere else entirely.
30
+ * - ECMAScript's reserved words, and `await` and `yield`, which a module reserves. `as` is contextual
31
+ * but `export type as = ...` is `TS1005`.
32
+ * - The words strict mode reserves: `implements`, `interface`, `let`, `package`, `private`,
33
+ * `protected`, `public`, `static` - `TS1214`, since a module is strict.
34
+ * - The predefined types, which no declaration may be named: `any`, `bigint`, `boolean`, `never`,
35
+ * `number`, `object`, `string`, `symbol`, `undefined`, `unknown` - `TS2457` and `TS2427`.
36
+ * - The type operators, which do not parse where a type is referred to: `p?: keyof;` is `TS1110`,
37
+ * and so are `readonly` and `unique`; `infer` is `TS1003`.
95
38
  */
96
39
  const RESERVED_DECLARATION_NAMES = new Set([
40
+ // ECMAScript's reserved words, and the two a module reserves.
97
41
  "as",
98
42
  "await",
99
43
  "break",
@@ -133,6 +77,31 @@ const RESERVED_DECLARATION_NAMES = new Set([
133
77
  "while",
134
78
  "with",
135
79
  "yield",
80
+ // Strict mode's.
81
+ "implements",
82
+ "interface",
83
+ "let",
84
+ "package",
85
+ "private",
86
+ "protected",
87
+ "public",
88
+ "static",
89
+ // The predefined types.
90
+ "any",
91
+ "bigint",
92
+ "boolean",
93
+ "never",
94
+ "number",
95
+ "object",
96
+ "string",
97
+ "symbol",
98
+ "undefined",
99
+ "unknown",
100
+ // The type operators.
101
+ "infer",
102
+ "keyof",
103
+ "readonly",
104
+ "unique",
136
105
  ]);
137
106
  /**
138
107
  * **The names the emitted files declare for THEMSELVES, beside the spec's own declarations.**
@@ -150,26 +119,87 @@ const EMITTED_HELPER_NAMES = new Set([
150
119
  "ByteStream",
151
120
  "Produced",
152
121
  "ResponseArm",
122
+ "SPEC_VOCABULARIES",
153
123
  "Simplify",
154
124
  "WireInputs",
155
125
  "WireOutputs",
126
+ "WireReader",
127
+ "WireReaders",
156
128
  "WireResults",
157
129
  ]);
158
130
  /**
159
- * The name a declaration is emitted under.
131
+ * **Library types the emitted files name bare, so a spec's type of the name takes a trailing `_`** -
132
+ * a multipart file's `arrayBuffer: () => globalThis.Promise<ArrayBuffer>`, a stream's `Uint8Array`
133
+ * chunks.
134
+ *
135
+ * **Every library GENERIC a file names is written `globalThis.Record<...>`, `globalThis.Readonly<...>`
136
+ * or `globalThis.Promise<...>` where it is written**, so a spec's own `model Record`, `Promise`,
137
+ * `Readonly` or `Exclude` is declared under its name, as openapi3 publishes it, and shadows nothing.
138
+ * This was a rewrite of each finished file instead - every `Record<` in a file declaring a `Record`
139
+ * became `globalThis.Record<` - and a file's text is not only references: the spec's own text is in
140
+ * it too. Measured beside `model Record`: `k?: "Record<k>"` became `z.literal("globalThis.Record<k>")`,
141
+ * refusing the value the document accepts and accepting one it refuses; `@pattern("^Record<...")`,
142
+ * a default and a `.describe()` were rewritten alike; and an enum's value `"Record<int32>"`, kept in
143
+ * `vocabularies.gen.ts`, was rewritten in `requests.gen.ts`, so the wire contract did not compile. Only
144
+ * the site writing a reference knows it is one.
145
+ */
146
+ const LIBRARY_NAMES_WRITTEN_BARE = new Set(["ArrayBuffer", "Uint8Array"]);
147
+ /**
148
+ * The name a declaration is emitted under, from the name the document publishes it under.
160
149
  *
161
150
  * A trailing `_` is appended where the spec's own name cannot be a TypeScript declaration, or is one
162
151
  * the emitted file already declares for itself. It is applied in ONE place so both walks agree:
163
152
  * `wire-contract.gen.ts` pairs a validator against a contract type by name, and a mangling applied to
164
153
  * one side only would break that assertion rather than the file.
154
+ *
155
+ * **Decided on the WHOLE name, visibility suffix included, and for every declaration.** It was
156
+ * decided on the bare name before {@link nameAt} added the suffix, so `model WireReader` seen through a
157
+ * PATCH, which openapi3 publishes as `WireReaderUpdate` - a name nothing in the files holds - was
158
+ * `WireReader_Update` and `wireReader_UpdateSchema`. And a declaration no type stands for never
159
+ * reached it: a multipart body `model public` was `export interface public` (`TS1214`), and a
160
+ * `@discriminated union Wire` with a variant `inputs` declared its envelope `WireInputs` beside the
161
+ * lookup of that name, and the two interfaces merged without a word.
165
162
  */
166
- function safeDeclarationName(name) {
167
- return RESERVED_DECLARATION_NAMES.has(name) || EMITTED_HELPER_NAMES.has(name) ? `${name}_` : name;
163
+ function safeDeclarationName(declared) {
164
+ /**
165
+ * **A character a component key cannot hold is `_`, as openapi3 writes the key**: `model Caf\u{e9}` is
166
+ * published as `Caf_`, its `createValidKey`. Written as declared, the name reached identifiers -
167
+ * `caf\u{e9}Schema`, `interface Caf\u{e9}` - in files this package keeps to printable ASCII, under a name the
168
+ * document does not use. `-` and `.`, which a key may hold and an identifier may not, are `_` too.
169
+ */
170
+ const name = declared.replace(/[^A-Za-z0-9_]/g, "_");
171
+ return RESERVED_DECLARATION_NAMES.has(name) ||
172
+ EMITTED_HELPER_NAMES.has(name) ||
173
+ LIBRARY_NAMES_WRITTEN_BARE.has(name)
174
+ ? `${name}_`
175
+ : name;
176
+ }
177
+ /**
178
+ * **The type a body is validated as: openapi3's effective payload type, at the body itself.**
179
+ * openapi3 resolves every request, response and part body through `getEffectivePayloadType`: what is
180
+ * left once metadata is set aside, matched to the model it all came from. `@body body: { ...Foo;
181
+ * @header h: string }` is `$ref Foo`, `Pet & {}` is `$ref Pet`; validated as written, the first
182
+ * required the header as a key and the second refused a subtype's own keys under sealing.
183
+ */
184
+ function payloadTypeOf(program, type, payload, depth) {
185
+ if (!payload || depth !== 0 || type.kind !== "Model")
186
+ return type;
187
+ return metadataInfoOf(program).getEffectivePayloadType(type, currentVisibility());
168
188
  }
169
- /** A type earns a name when the spec gave it one - anonymous shapes stay inline where they are used. */
189
+ /**
190
+ * A type earns a name when the spec gave it one - anonymous shapes stay inline where they are used.
191
+ * The name the document publishes it under, before any visibility suffix: {@link nameAt} makes it
192
+ * the name it is emitted under.
193
+ */
170
194
  function declaredNameOf(program, type) {
195
+ /**
196
+ * **`@friendlyName` names a declaration, as openapi3's `getOpenAPITypeName` reads it first.**
197
+ * Read as the declared name, `@friendlyName("Dog") model Cat` was validated as `Dog` by the
198
+ * validator named `catSchema`, and two models swapping names swapped validators.
199
+ */
200
+ const named = type.kind === "Enum" || type.kind === "Union" ? getFriendlyName(program, type) : undefined;
171
201
  if (type.kind === "Enum")
172
- return safeDeclarationName(type.name);
202
+ return named || type.name;
173
203
  /**
174
204
  * A named union earns a declaration too.
175
205
  *
@@ -178,17 +208,26 @@ function declaredNameOf(program, type) {
178
208
  * cross-field rule modelled as a discriminated union (which is how `@refine` is being replaced)
179
209
  * would silently stop being checked at the boundary.
180
210
  */
181
- if (type.kind === "Union")
182
- return type.name === undefined ? undefined : safeDeclarationName(type.name);
211
+ // An instance openapi3 cannot name is written out where it is used - see `isSpeakableInstance`.
212
+ if (type.kind === "Union") {
213
+ return type.name === undefined || !isSpeakableInstance(type) ? undefined : named || type.name;
214
+ }
183
215
  if (type.kind !== "Model")
184
216
  return undefined;
185
- // `Array`/`Record` are TypeSpec's built-in generic containers, not names worth exporting.
186
- if (type.name === "Array" || type.name === "Record")
217
+ // A list literal is written out where it is used - see `isArrayLiteral`; `Record<T>` is an
218
+ // instance, and the standard library's own. A spec's model of either name is published by it.
219
+ if (isArrayLiteral(program, type))
220
+ return undefined;
221
+ if (type.name === "Record" && program.checker.isStdType(type))
222
+ return undefined;
223
+ /**
224
+ * **An anonymous model is written out where it is used**, as openapi3 writes `{ ...Pet }`: the one
225
+ * it names is the model a body resolves to, its effective payload type - see `payloadTypeOf` -
226
+ * which is that model, not the copy. Named after its source anywhere else, a property typed
227
+ * `{ ...Pet }` was a discriminated base, and refused with `Pet` where `Pet` was split.
228
+ */
229
+ if (type.name === "")
187
230
  return undefined;
188
- if (type.name === "") {
189
- const spread = spreadSourceName(type);
190
- return spread === undefined ? undefined : safeDeclarationName(spread);
191
- }
192
231
  /**
193
232
  * **A template INSTANTIATION carries the template's name, and it is not a declaration - unless
194
233
  * `@friendlyName` gives it one of its own.**
@@ -211,11 +250,133 @@ function declaredNameOf(program, type) {
211
250
  */
212
251
  if (type.templateMapper !== undefined) {
213
252
  const friendly = getFriendlyName(program, type);
214
- return friendly === undefined || friendly === "" ? undefined : safeDeclarationName(friendly);
253
+ return friendly === undefined || friendly === "" ? undefined : friendly;
215
254
  }
216
- return safeDeclarationName(type.name);
255
+ const friendly = getFriendlyName(program, type);
256
+ return friendly === undefined || friendly === "" ? type.name : friendly;
217
257
  }
218
258
  const lowerFirst = (value) => value.charAt(0).toLowerCase() + value.slice(1);
259
+ /**
260
+ * `duplicate-declaration`, once per name whichever walk meets it first, saying which of the three it
261
+ * is: two types of one name; one model in two metadata contexts; or one model at two visibilities
262
+ * openapi3 names alike - PATCH at `Update` and at `Update` with `Patch`, both `XUpdate`.
263
+ */
264
+ function reportCollision(program, name, first, second) {
265
+ const target = second.type ?? first.type ?? second.synthetic?.site ?? first.synthetic?.site;
266
+ if (target === undefined || !firstReportOf(program, "duplicate-declaration", target, name))
267
+ return;
268
+ /**
269
+ * **A shape no type stands for, beside another under its name**: a multipart body, whose parts
270
+ * are text and files, and an envelope openapi3 names after its union and variant.
271
+ */
272
+ const synthetic = first.synthetic ?? second.synthetic;
273
+ if (synthetic !== undefined) {
274
+ reportDiagnostic(program, {
275
+ code: "duplicate-declaration",
276
+ messageId: synthetic.kind,
277
+ format: { name },
278
+ target,
279
+ });
280
+ return;
281
+ }
282
+ // The declaration's own model: a copy the compiler makes for a payload has no source.
283
+ if (first.type === second.type) {
284
+ // A union published in a multipart part's binary context and outside it - see `withMultipartBinary`.
285
+ const binary = (key) => key?.endsWith("\u0000multipart") === true;
286
+ if (binary(first.key) !== binary(second.key)) {
287
+ reportDiagnostic(program, {
288
+ code: "duplicate-declaration",
289
+ messageId: "binary",
290
+ format: { name },
291
+ target,
292
+ });
293
+ return;
294
+ }
295
+ const visibilities = first.at !== undefined && second.at !== undefined ? [first.at, second.at] : undefined;
296
+ if (visibilities === undefined || visibilities[0] === visibilities[1]) {
297
+ reportDiagnostic(program, {
298
+ code: "duplicate-declaration",
299
+ messageId: "context",
300
+ format: { name },
301
+ target,
302
+ });
303
+ return;
304
+ }
305
+ /**
306
+ * **The request's own view is published apart**, which is the remedy where openapi3 writes one
307
+ * model at a request's visibility and at another: `D extends B` sent once in a create body is
308
+ * written at `Create` and, through `B`'s derived models, at `Read`.
309
+ */
310
+ const view = visibilities
311
+ .map((each) => viewTemplateOf(each))
312
+ .find((template) => template !== undefined);
313
+ reportDiagnostic(program, {
314
+ code: "duplicate-declaration",
315
+ messageId: "visibility",
316
+ format: {
317
+ name,
318
+ first: visibilityName(visibilities[0] ?? Visibility.Read),
319
+ second: visibilityName(visibilities[1] ?? Visibility.Read),
320
+ remedy: view === undefined
321
+ ? "Use the model at one of the two - for a PATCH, one 'implicitOptionality' setting - or give one use a model of its own."
322
+ : first.type === undefined
323
+ ? `Send the request its own '${view}<...>' view, which openapi3 publishes apart, or use the model at one of the two - for a PATCH, one 'implicitOptionality' setting.`
324
+ : `Send the request its own view - ${viewRemedyOf(view, first.type)} and send it as the request's '@body' - which openapi3 publishes apart, or use the model at one of the two - for a PATCH, one 'implicitOptionality' setting.`,
325
+ },
326
+ target,
327
+ });
328
+ return;
329
+ }
330
+ /**
331
+ * **Which of four things made two types one name**, each told as it is: a validator is named for
332
+ * its type with the first letter lowered, so `Foo` and `foo` meet only there; a visibility suffix
333
+ * can spell another type's name, `Thing` at `Create` beside a `ThingCreate`; a character an
334
+ * identifier cannot hold is `_`, so `x-y` meets `x_y`; and a namespace is dropped. Every one was
335
+ * told as the last, in one namespace as in two.
336
+ */
337
+ const declaredName = (type) => type !== undefined && "name" in type && typeof type.name === "string" ? type.name : name;
338
+ const names = [declaredName(first.type), declaredName(second.type)];
339
+ if (first.typeName !== undefined &&
340
+ second.typeName !== undefined &&
341
+ first.typeName !== second.typeName) {
342
+ reportDiagnostic(program, {
343
+ code: "duplicate-declaration",
344
+ messageId: "cased",
345
+ format: { name, first: first.typeName, second: second.typeName },
346
+ target,
347
+ });
348
+ return;
349
+ }
350
+ const suffixed = [first, second].findIndex((each) => {
351
+ const bare = each.type === undefined ? undefined : declaredNameOf(program, each.type);
352
+ return bare !== undefined && safeDeclarationName(bare) !== name && each.at !== undefined;
353
+ });
354
+ if (suffixed !== -1) {
355
+ reportDiagnostic(program, {
356
+ code: "duplicate-declaration",
357
+ messageId: "suffixed",
358
+ format: {
359
+ name,
360
+ first: names[suffixed] ?? name,
361
+ second: names[1 - suffixed] ?? name,
362
+ at: visibilityName([first, second][suffixed]?.at ?? Visibility.Read),
363
+ },
364
+ target,
365
+ });
366
+ return;
367
+ }
368
+ // Two instances of one template: openapi3 names a union instance after its template alone.
369
+ const template = (type) => type !== undefined && "templateMapper" in type && type.templateMapper !== undefined
370
+ ? type.node
371
+ : undefined;
372
+ const instances = template(first.type) !== undefined && template(first.type) === template(second.type);
373
+ reportDiagnostic(program, {
374
+ code: "duplicate-declaration",
375
+ messageId: instances ? "instances" : names[0] !== names[1] ? "renamed" : "default",
376
+ format: { name, first: names[0], second: names[1] },
377
+ target,
378
+ });
379
+ }
219
380
  /**
220
381
  * One model can need MORE THAN ONE declaration.
221
382
  *
@@ -231,57 +392,60 @@ const lowerFirst = (value) => value.charAt(0).toLowerCase() + value.slice(1);
231
392
  * with the component names the document publishes.
232
393
  */
233
394
  /**
234
- * The visibility a type is actually DECLARED at, which is not always the one requested.
395
+ * The visibility a type is actually DECLARED at, which is not always the one requested - see
396
+ * `renderedVisibilityOf`, the one rule both registries name and render by.
235
397
  *
236
398
  * **A type not reshaped at this visibility IS the canonical declaration, not a copy of it.** Keying
237
399
  * on the requested visibility while only suffixing transformed types produced two cache entries
238
400
  * sharing one identifier and an emitted file that stopped parsing:
239
401
  * `Identifier 'approvalTierSchema' has already been declared`. An enum is the same enum in a request
240
- * and a response, which is what the `Model` guard is for.
402
+ * and a response; a union holding a model the request reshapes is not, and was declared once for
403
+ * both - its response validating the create view - until unions were named by the same rule as
404
+ * models, as openapi3 names them.
241
405
  *
242
- * It is the same collapse `@typespec/openapi3` performs before naming a component, and
243
- * `isTransformedBy` is its own predicate, `metadataInfo.isTransformed`.
244
- *
245
- * **Extracted so both registries answer it identically.** They must: the emitted assertion pairs a
246
- * validator with a contract type BY NAME, so a rule applied in one walk and paraphrased in the other
247
- * is a pairing that silently compares the wrong two things.
406
+ * **One rule for both registries.** The emitted assertion pairs a validator with a contract type BY
407
+ * NAME, so a rule applied in one walk and paraphrased in the other is a pairing that silently
408
+ * compares the wrong two things.
248
409
  */
249
- export function visibilityFor(program, type, requested) {
250
- return type.kind === "Model" &&
251
- requested !== Visibility.Read &&
252
- isTransformedBy(program, type, requested)
253
- ? requested
254
- : Visibility.Read;
255
- }
256
410
  /**
257
- * A declaration's name at a visibility: the bare name at canonical, suffixed anywhere else.
411
+ * A declaration's name at a visibility: the bare name at canonical, and suffixed elsewhere only where
412
+ * the type is used at more than one visibility - openapi3's own rule, so a model only ever sent in a
413
+ * create body is `Holder` and not `HolderCreate`, as the document names it.
258
414
  *
259
415
  * The suffix is `@typespec/http`'s `getVisibilitySuffix`, called with `Visibility.Read` as the
260
416
  * canonical exactly as `@typespec/openapi3` calls it in `schema-emitter.ts`. Same function, same
261
- * argument, so these identifiers line up with the component names the document publishes rather than
262
- * merely resembling them.
417
+ * argument, same usage rule, so these identifiers line up with the component names the document
418
+ * publishes rather than merely resembling them.
419
+ */
420
+ /**
421
+ * **The visibility a type is rendered at, by where it is asked for.** An operation's own body,
422
+ * parameter or response - asked for with no body being rendered - is collapsed as openapi3 collapses
423
+ * a root, whatever kind of type it is; a type another refers to is reduced where openapi3 reduces a
424
+ * reference. They differ for a list and a union expression, which openapi3 reduces at the root and
425
+ * not below it.
263
426
  */
264
- export function nameAt(bare, at) {
265
- return `${bare}${at === Visibility.Read ? "" : getVisibilitySuffix(at, Visibility.Read)}`;
427
+ function visibilityAt(program, type, requested, depth) {
428
+ return depth === 0
429
+ ? rootVisibilityOf(program, type, requested)
430
+ : renderedVisibilityOf(program, type, requested);
431
+ }
432
+ export function nameAt(bare, at, type) {
433
+ // Suffixed only where openapi3 suffixes it: used at more than one visibility - see `isSuffixedByUsage`.
434
+ const suffixed = at !== Visibility.Read && isSuffixedByUsage(type);
435
+ return safeDeclarationName(`${bare}${suffixed ? getVisibilitySuffix(at, Visibility.Read) : ""}`);
266
436
  }
267
437
  function keyFor(type, visibility) {
268
438
  /**
269
- * **Keyed on the SPREAD SOURCE where there is one, or one component gets two declarations.**
270
- *
271
- * `@typespec/http` synthesises a fresh anonymous model per position, so two operations taking the
272
- * same body produce two distinct types that both resolve to the name `BodyModel` - and
273
- * `parameters/body-optionality` emitted `export const bodyModelSchema` twice, a file that does not
274
- * parse. The document has exactly one `BodyModel` component; collapsing onto the source is what
275
- * makes the two artefacts agree about how many things there are.
439
+ * **Keyed on the named model, never a copy of it**: `@typespec/http` synthesises a fresh anonymous
440
+ * model per position, and two operations taking one body each hand over a copy. Both resolve to
441
+ * the model itself, their effective payload type - see `payloadTypeOf` - so the document's one
442
+ * `BodyModel` is one declaration, where two copies of it once emitted `bodyModelSchema` twice.
276
443
  */
277
- const canonical = canonicalTypeFor(type);
278
- return `${String(visibility)}\u0000${String(canonical.name ?? "")}\u0000${identityOf(canonical)}`;
279
- }
280
- /** The model an anonymous payload stands for, so every projection of it shares one declaration. */
281
- function canonicalTypeFor(type) {
282
- if (type.kind !== "Model" || type.name !== "")
283
- return type;
284
- return spreadSourceOf(type) ?? type;
444
+ // Inside an explicit `@body` a type can render differently - see `withMetadataAnnotationsIgnored`.
445
+ const body = metadataAnnotationsIgnored() ? "\u0000body" : "";
446
+ // And inside a multipart part's union holding raw `bytes` - see `withMultipartBinary`.
447
+ const binary = multipartBinaryContext() ? "\u0000multipart" : "";
448
+ return `${String(visibility)}\u0000${String(type.name ?? "")}\u0000${identityOf(type)}${body}${binary}`;
285
449
  }
286
450
  /** A stable per-type identity, since two models can share a name across namespaces. */
287
451
  const identities = new WeakMap();
@@ -302,6 +466,12 @@ export class SchemaRegistry {
302
466
  #inProgress = new Map();
303
467
  /** Keys on a cycle - every one needs a structural type rather than a `z.infer` alias. */
304
468
  #cyclic = new Set();
469
+ /** Identifiers refused as two shapes under one name, which no dispatch may hold. */
470
+ #refused = new Set();
471
+ /** Declarations refused as `split-visibility`, which a `@oneOf` holding them holds to nothing. */
472
+ #withheld = new Set();
473
+ /** Every type name a refused identifier stood for: `Foo` and `foo` are both `fooSchema`. */
474
+ #refusedTypeNames = new Set();
305
475
  /**
306
476
  * Inlined types currently being walked, by identity, against the number of declarations that were
307
477
  * in flight when each was entered.
@@ -315,6 +485,8 @@ export class SchemaRegistry {
315
485
  * entered since, the cycle has a name to close on and the walk may continue.
316
486
  */
317
487
  #inlineInProgress = new Map();
488
+ /** How many bodies are being rendered - none, and the type asked for is an operation's own. */
489
+ #depth = 0;
318
490
  constructor(program) {
319
491
  this.#program = program;
320
492
  }
@@ -323,8 +495,20 @@ export class SchemaRegistry {
323
495
  * inline source. Declaring is depth-first so a model's dependencies are emitted above it, which is
324
496
  * what lets the generated file be plain `const` declarations with no forward references.
325
497
  */
326
- expressionFor(type) {
498
+ expressionFor(given, payload = false) {
499
+ const type = payloadTypeOf(this.#program, given, payload, this.#depth);
500
+ // Published as a reference to another document, with no schema to validate.
501
+ const external = externalReferenceOf(type);
502
+ if (external !== undefined)
503
+ return refuseExternalReference(this.#program, type, external);
327
504
  const bare = declaredNameOf(this.#program, type);
505
+ const requested = currentVisibility();
506
+ /**
507
+ * Asked of the model a declaration stands for, as openapi3 asks it of the effective payload type:
508
+ * a spread body's copy holds only what the request sends, so it is reshaped by nothing, while the
509
+ * model it copies - its `@path` property payload at `Read` - is.
510
+ */
511
+ const at = visibilityAt(this.#program, type, requested, this.#depth);
328
512
  if (bare === undefined) {
329
513
  /**
330
514
  * **A back edge to an INLINED type is a cycle with no name to close it**, and following it
@@ -342,7 +526,7 @@ export class SchemaRegistry {
342
526
  }
343
527
  this.#inlineInProgress.set(type, this.#inProgress.size);
344
528
  try {
345
- return this.#inline(type);
529
+ return this.#inline(type, at, undefined);
346
530
  }
347
531
  finally {
348
532
  this.#inlineInProgress.delete(type);
@@ -357,7 +541,6 @@ export class SchemaRegistry {
357
541
  * request and a response. Collapsing onto `Read` first is what makes the key and the name agree,
358
542
  * and it is the same collapse openapi3 performs before naming a component.
359
543
  */
360
- const at = visibilityFor(this.#program, type, currentVisibility());
361
544
  const key = keyFor(type, at);
362
545
  const existing = this.#declarations.get(key);
363
546
  if (existing !== undefined)
@@ -383,34 +566,20 @@ export class SchemaRegistry {
383
566
  this.#cyclic.add(inFlight);
384
567
  return pending;
385
568
  }
386
- const name = nameAt(bare, at);
569
+ const name = nameAt(bare, at, type);
387
570
  const identifier = `${lowerFirst(name)}Schema`;
388
- this.#inProgress.set(key, identifier);
389
- const rendered = captureBackEdges(() => this.#inline(type));
390
- this.#inProgress.delete(key);
391
- /**
392
- * A back edge that survived the whole body reached this declaration by a path with no object
393
- * property on it - a named union variant, or a dictionary value - so nothing could make it lazy
394
- * and the emitted `const` would read itself while initialising. `z.lazy()` defers the whole body
395
- * instead, which is the only place left to put the deferral once no property can hold it.
396
- */
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)));
571
+ const { source, deferred, consulted } = this.#render(type, at, name, key, identifier);
408
572
  const declaration = {
409
573
  identifier,
410
574
  typeName: name,
411
575
  source,
412
- ...(this.#cyclic.has(key) ? { structural: this.#structuralBodyOf(type) } : {}),
413
- ...(rendered.deferred ? { annotated: true } : {}),
576
+ type,
577
+ at,
578
+ key,
579
+ ignore: metadataAnnotationsIgnored(),
580
+ ...(this.#cyclic.has(key) ? { structural: this.#structuralBodyOf(type, at) } : {}),
581
+ ...(deferred ? { annotated: true } : {}),
582
+ ...(consulted.size === 0 ? {} : { consulted }),
414
583
  };
415
584
  /**
416
585
  * **Two different types can resolve to one identifier, and the module then does not parse.**
@@ -429,19 +598,191 @@ export class SchemaRegistry {
429
598
  * `duplicate-id`; neither resolves it for you, because only the author knows which name to
430
599
  * change.
431
600
  */
432
- const collision = this.#order.find((candidate) => candidate.identifier === identifier && candidate.source !== declaration.source);
433
- if (collision !== undefined) {
434
- reportDiagnostic(this.#program, {
435
- code: "duplicate-declaration",
436
- format: { name },
437
- target: type,
438
- });
601
+ const twin = this.#order.find((candidate) => candidate.identifier === identifier);
602
+ /**
603
+ * **One declaration reached in two contexts that render it alike is one declaration.** A type
604
+ * under an explicit `@body` is keyed apart from the same type elsewhere, since the body can
605
+ * publish a `@header` as data; where nothing in it does, the two are the same text and the
606
+ * document's one component.
607
+ */
608
+ if (twin !== undefined && twin.source === declaration.source) {
609
+ this.#declarations.set(key, twin);
610
+ return twin.identifier;
611
+ }
612
+ if (twin !== undefined) {
613
+ reportCollision(this.#program, name, twin, declaration);
614
+ /**
615
+ * **One name, one declaration, and it validates nothing.** Two differing shapes under one
616
+ * identifier was a module declaring it twice, which does not parse. The document holds one
617
+ * of them under the name and openapi3 refuses its own document for it, so neither is kept.
618
+ */
619
+ const refused = {
620
+ identifier,
621
+ typeName: name,
622
+ source: UNREPRESENTABLE,
623
+ ...(twin.type === undefined ? {} : { type: twin.type }),
624
+ ...(twin.at === undefined ? {} : { at: twin.at }),
625
+ };
626
+ this.#order[this.#order.indexOf(twin)] = refused;
627
+ for (const [other, value] of this.#declarations) {
628
+ if (value === twin)
629
+ this.#declarations.set(other, refused);
630
+ }
631
+ this.#declarations.set(key, refused);
632
+ this.#refused.add(identifier);
633
+ this.#refusedTypeNames.add(twin.typeName).add(name);
634
+ if (twin.type !== undefined)
635
+ this.#refuseEnvelopesOf(twin.type);
636
+ this.#renderDispatchesHolding(identifier);
637
+ return identifier;
439
638
  }
440
639
  this.#declarations.set(key, declaration);
441
640
  this.#order.push(declaration);
442
641
  this.#declareRelatives(type);
443
642
  return declaration.identifier;
444
643
  }
644
+ /**
645
+ * A declaration's body, rendered with its key in flight so a reference back to it is a back edge.
646
+ *
647
+ * A back edge that survived the whole body reached this declaration by a path with no object
648
+ * property on it - a named union variant, or a dictionary value - so nothing could make it lazy
649
+ * and the emitted `const` would read itself while initialising. `z.lazy()` defers the whole body
650
+ * instead, which is the only place left to put the deferral once no property can hold it.
651
+ */
652
+ #render(type, at, name, key, identifier) {
653
+ this.#inProgress.set(key, identifier);
654
+ const consulted = new Set();
655
+ this.#consulted.push(consulted);
656
+ const rendered = (() => {
657
+ try {
658
+ return captureBackEdges(() => this.#inline(type, at, name));
659
+ }
660
+ finally {
661
+ this.#consulted.pop();
662
+ }
663
+ })();
664
+ this.#inProgress.delete(key);
665
+ if (rendered.value === UNREPRESENTABLE &&
666
+ isSplitByVisibility(this.#program, type, at, metadataAnnotationsIgnored(), name)) {
667
+ this.#withheld.add(identifier);
668
+ }
669
+ /**
670
+ * **The description goes on the declaration's own body, so the exported const IS the
671
+ * described schema.** Every reference then names that one instance, which is what keeps
672
+ * identity, `z.compile`'s clone (it inherits the description through its parent link) and
673
+ * Zod's `$ref` extraction all intact. Described at a reference instead, each use would be a
674
+ * fresh clone. A visibility projection carries the doc of the model it projects, as openapi3
675
+ * publishes one component for both.
676
+ */
677
+ const body = rendered.deferred ? `z.lazy(() => ${rendered.value})` : rendered.value;
678
+ const source = described(body, getDoc(this.#program, type));
679
+ return { source, deferred: rendered.deferred, consulted };
680
+ }
681
+ /**
682
+ * **Every dispatch rendered before a subtype it holds was refused, rendered again without it.** A
683
+ * refusal is found where the second shape under one name is declared, and a base's dispatch can
684
+ * already hold the first: `z.discriminatedUnion` given the `z.never()` the refusal leaves throws
685
+ * on every parse, and the module does not typecheck. Each is rendered in its own context, its key
686
+ * in flight, so a reference back to it is still a back edge.
687
+ */
688
+ /**
689
+ * **A union refused takes its envelopes with it.** openapi3 publishes each envelope wherever it
690
+ * publishes the union, under one name: where the union is two schemas under its one name, each
691
+ * envelope is too, and the document keeps the one written last - `DU7Y` with its `kind` and
692
+ * `value` optional, written from a PATCH - while its validator held the view it was first declared
693
+ * at, requiring both. Refused with the union, whose diagnostic says why; the contract types follow.
694
+ */
695
+ #refuseEnvelopesOf(union) {
696
+ for (const [index, declaration] of this.#order.entries()) {
697
+ if (declaration.synthetic?.kind !== "envelope" || declaration.synthetic.site !== union)
698
+ continue;
699
+ if (declaration.source === UNREPRESENTABLE)
700
+ continue;
701
+ const refused = { ...declaration, source: UNREPRESENTABLE };
702
+ this.#order[index] = refused;
703
+ for (const [other, value] of this.#declarations) {
704
+ if (value === declaration)
705
+ this.#declarations.set(other, refused);
706
+ }
707
+ this.#refused.add(declaration.identifier);
708
+ this.#refusedTypeNames.add(declaration.typeName);
709
+ }
710
+ }
711
+ /** What a value rendered here may ask of the declarations it refers to - see `HeldDeclarations`. */
712
+ #held = {
713
+ refused: (expression) => this.#refused.has(expression),
714
+ annotated: (expression) => this.#order.some((declaration) => declaration.identifier === expression && declaration.annotated === true),
715
+ refusedAt: (type, at) => {
716
+ const key = keyFor(type, at);
717
+ this.#consulted.at(-1)?.add(key);
718
+ const declared = this.#declarations.get(key);
719
+ return (declared !== undefined &&
720
+ (this.#refused.has(declared.identifier) || this.#withheld.has(declared.identifier)));
721
+ },
722
+ holdsRefusalAt: (type, at) => {
723
+ const key = keyFor(type, at);
724
+ this.#consulted.at(-1)?.add(key);
725
+ const declared = this.#declarations.get(key);
726
+ return declared !== undefined && this.#reachesRefusal(declared.identifier, new Set());
727
+ },
728
+ };
729
+ /** Whether a declaration is refused, or its source refers to one that reaches a refusal. */
730
+ #reachesRefusal(identifier, seen) {
731
+ if (seen.has(identifier))
732
+ return false;
733
+ seen.add(identifier);
734
+ if (this.#refused.has(identifier) || this.#withheld.has(identifier))
735
+ return true;
736
+ const declaration = this.#order.find((each) => each.identifier === identifier);
737
+ if (declaration === undefined)
738
+ return false;
739
+ return [...declaration.source.matchAll(/[A-Za-z_$][\w$]*/g)].some(([name]) => name !== identifier && this.#declares(name) && this.#reachesRefusal(name, seen));
740
+ }
741
+ #declares(identifier) {
742
+ return this.#order.some((each) => each.identifier === identifier);
743
+ }
744
+ /** The keys each declaration in flight asked `refusedAt` of, innermost last. */
745
+ #consulted = [];
746
+ /** What the contract types' walk asks of these declarations, so a merge is `never` on both sides. */
747
+ get held() {
748
+ return this.#held;
749
+ }
750
+ #renderDispatchesHolding(identifier) {
751
+ const holds = new RegExp(`\\b${identifier}\\b`);
752
+ const refusedKeys = new Set([...this.#declarations]
753
+ .filter(([, value]) => value.identifier === identifier)
754
+ .map(([each]) => each));
755
+ // By index: rendering one again can declare more, which hold no refused subtype.
756
+ for (let index = 0; index < this.#order.length; index += 1) {
757
+ const held = this.#order[index];
758
+ const { type, at, key } = held;
759
+ if (type === undefined || at === undefined || key === undefined)
760
+ continue;
761
+ // A dispatch, or a `@oneOf` that now holds a refused member - see `holdsRefusedMember` -
762
+ // directly or through the declarations it refers to.
763
+ const dispatches = (held.source.includes("z.discriminatedUnion(") || held.source.includes("z.xor(")) &&
764
+ (holds.test(held.source) || this.#reachesRefusal(held.identifier, new Set()));
765
+ // A merge that asked before the refusal holds the shape it now refuses - see `refusedAt`.
766
+ const merges = [...(held.consulted ?? [])].some((each) => refusedKeys.has(each));
767
+ if (!dispatches && !merges)
768
+ continue;
769
+ const keys = [...this.#declarations]
770
+ .filter(([, value]) => value === held)
771
+ .map(([each]) => each);
772
+ for (const each of keys)
773
+ this.#declarations.delete(each);
774
+ const { source, deferred, consulted } = withMetadataAnnotationsIgnored(held.ignore ?? false, () => this.#render(type, at, held.typeName, key, held.identifier));
775
+ const fresh = {
776
+ ...held,
777
+ source,
778
+ ...(deferred ? { annotated: true } : {}),
779
+ consulted,
780
+ };
781
+ this.#order[index] = fresh;
782
+ for (const each of keys)
783
+ this.#declarations.set(each, fresh);
784
+ }
785
+ }
445
786
  /**
446
787
  * Declare the BASE CHAIN a component's own declaration implies.
447
788
  *
@@ -451,8 +792,8 @@ export class SchemaRegistry {
451
792
  * them.
452
793
  *
453
794
  * **Subtypes are deliberately NOT walked here, because nothing would be walking them.** A
454
- * discriminated base renders as a dispatch over its subtypes - `z.discriminatedUnion`, beside a base
455
- * arm where its discriminator is open - and rendering that already asks the registry for each one. A loop here looked load-bearing and was measured not to
795
+ * discriminated base renders as a dispatch over its subtypes - `z.discriminatedUnion`, beside the base
796
+ * itself - and rendering that already asks the registry for each one. A loop here looked load-bearing and was measured not to
456
797
  * be: removing it left all 23 polymorphism and differential arms green.
457
798
  *
458
799
  * **Wrapped in {@link captureBackEdges} and the result discarded.** This call can land on a
@@ -466,14 +807,35 @@ export class SchemaRegistry {
466
807
  const base = type.baseModel;
467
808
  captureBackEdges(() => this.expressionFor(base));
468
809
  }
810
+ /**
811
+ * Declare a component the document publishes, at the visibility the walk is at, as a type
812
+ * another refers to - see `publishedComponents`. Nothing refers to it here, so a declaration
813
+ * still in flight is no back edge.
814
+ */
815
+ declare(type) {
816
+ this.#depth += 1;
817
+ try {
818
+ captureBackEdges(() => this.expressionFor(type));
819
+ }
820
+ finally {
821
+ this.#depth -= 1;
822
+ }
823
+ }
469
824
  /**
470
825
  * The Zod for a single property, constraints and optionality included.
471
826
  *
472
827
  * Used for path/query parameters, which are `ModelProperty`s that belong to no model - so they
473
828
  * need the property-level walk rather than the type-level one, or `@minLength` on a path param
474
829
  * would be documented and never enforced.
830
+ *
831
+ * **The property is where a refusal inside it points**, as a model's member is - see
832
+ * `withDeclarationSite`. A parameter is walked outside any model, so `@query a?: never[]` was
833
+ * reported at `<unknown location>:1:1`, the intrinsic's own place, which names nothing to edit.
475
834
  */
476
- expressionForProperty(property) {
835
+ expressionForProperty(property, position = OWN_POSITION) {
836
+ return withParameterPosition(true, () => withDeclarationSite(property, () => this.#parameter(property, position)));
837
+ }
838
+ #parameter(property, position) {
477
839
  return withRefResolver(
478
840
  /**
479
841
  * **Every candidate goes through {@link expressionFor}, including the ones with no name.**
@@ -485,7 +847,7 @@ export class SchemaRegistry {
485
847
  * `expressionFor` renders an undeclared type through `#inline` exactly as before, and now
486
848
  * also notices when it is already doing so.
487
849
  */
488
- (candidate) => this.expressionFor(candidate), () => propertyToZod(this.#program, property));
850
+ (candidate) => this.expressionFor(candidate), () => withHeldDeclarations(this.#held, () => propertyToZod(this.#program, property, position)));
489
851
  }
490
852
  /**
491
853
  * A declaration for a shape the spec never wrote, under a name the document publishes.
@@ -499,10 +861,12 @@ export class SchemaRegistry {
499
861
  * body: the document publishes `MultiPartRequest` as a component, and there is no walkable type
500
862
  * for it - the parts have to be assembled from `@typespec/http`'s own view of the payload.
501
863
  */
502
- declareNamed(name, build) {
503
- return this.#declareSynthetic(name, build);
864
+ declareNamed(name, build, site) {
865
+ return this.#declareSynthetic(name, build, { kind: "multipart", site });
504
866
  }
505
- #declareSynthetic(name, build) {
867
+ #declareSynthetic(published, build, synthetic) {
868
+ // Under the rule every declaration's name is decided by - see `safeDeclarationName`.
869
+ const name = safeDeclarationName(published);
506
870
  const key = `synthetic\u0000${name}`;
507
871
  const existing = this.#declarations.get(key);
508
872
  if (existing !== undefined)
@@ -513,7 +877,31 @@ export class SchemaRegistry {
513
877
  this.#inProgress.set(key, identifier);
514
878
  const source = build();
515
879
  this.#inProgress.delete(key);
516
- const declaration = { identifier, typeName: name, source };
880
+ const declaration = { identifier, typeName: name, source, synthetic };
881
+ /**
882
+ * **A name a type already declares is the same collision as any other**: a model sent as JSON
883
+ * and as a multipart form, its JSON component declared first, declared `formSchema` twice, and
884
+ * the module did not parse. One rendered alike is the one declaration.
885
+ */
886
+ const twin = this.#order.find((candidate) => candidate.identifier === identifier);
887
+ if (twin !== undefined && twin.source === source) {
888
+ this.#declarations.set(key, twin);
889
+ return identifier;
890
+ }
891
+ if (twin !== undefined) {
892
+ reportCollision(this.#program, name, twin, declaration);
893
+ const refused = { ...twin, source: UNREPRESENTABLE };
894
+ this.#order[this.#order.indexOf(twin)] = refused;
895
+ for (const [other, value] of this.#declarations) {
896
+ if (value === twin)
897
+ this.#declarations.set(other, refused);
898
+ }
899
+ this.#declarations.set(key, refused);
900
+ this.#refused.add(identifier);
901
+ this.#refusedTypeNames.add(twin.typeName).add(name);
902
+ this.#renderDispatchesHolding(identifier);
903
+ return identifier;
904
+ }
517
905
  this.#declarations.set(key, declaration);
518
906
  this.#order.push(declaration);
519
907
  return identifier;
@@ -532,8 +920,27 @@ export class SchemaRegistry {
532
920
  * `wire-contract.gen.ts`, and a third spelling of "what this shape is" would be a third thing to
533
921
  * drift.
534
922
  */
535
- #structuralBodyOf(type) {
536
- return withTsRefResolver((candidate) => this.#typeNameOf(candidate), () => typeToTsBody(this.#program, type));
923
+ #structuralBodyOf(type, at) {
924
+ /**
925
+ * At the visibility the declaration is keyed on, as its body was rendered: read at the one it
926
+ * was requested at, a discriminated base reached from a create body annotated itself with
927
+ * `DogCreate`, a declaration nothing made.
928
+ */
929
+ return withVisibility(at, () => withParameterPosition(false, () => withTsRefResolver((candidate) => this.#typeNameOf(candidate) ?? this.#reducedBodyOf(candidate), () => typeToTsBody(this.#program, type))));
930
+ }
931
+ /**
932
+ * **A type with no declaration of its own is written out where openapi3 reduces it to**, as
933
+ * {@link TypeRegistry.expressionFor} writes it: `q?: Record<M4>` under a PATCH is a `Record`
934
+ * openapi3 renders at `Read`, its value `M4`. Walked at the PATCH, the annotation named
935
+ * `M4Update`, which nothing declares (`TS2552`). `undefined` where the visibility stands, so the
936
+ * walk goes on as it was.
937
+ */
938
+ #reducedBodyOf(candidate) {
939
+ const requested = currentVisibility();
940
+ const at = renderedVisibilityOf(this.#program, candidate, requested);
941
+ return at === requested
942
+ ? undefined
943
+ : withVisibility(at, () => typeToTsBody(this.#program, candidate));
537
944
  }
538
945
  /**
539
946
  * **The TypeScript name a reference resolves to is the one {@link expressionFor} declares it
@@ -546,7 +953,7 @@ export class SchemaRegistry {
546
953
  const bare = declaredNameOf(this.#program, candidate);
547
954
  if (bare === undefined)
548
955
  return undefined;
549
- return nameAt(bare, visibilityFor(this.#program, candidate, currentVisibility()));
956
+ return nameAt(bare, renderedVisibilityOf(this.#program, candidate, currentVisibility()), candidate);
550
957
  }
551
958
  /**
552
959
  * Render `type` without consulting its own declaration, but letting nested types use theirs.
@@ -558,8 +965,28 @@ export class SchemaRegistry {
558
965
  * `RangeError: Maximum call stack size exceeded`. Skipping the resolver once, here, is what makes
559
966
  * a nested occurrence resolve to the identifier while the body still renders as a body.
560
967
  */
561
- #inline(type) {
562
- return withRefResolver(
968
+ #inline(type, at, declared) {
969
+ // What openapi3 publishes with its properties read at another visibility - see `published.ts`.
970
+ const ignore = metadataAnnotationsIgnored();
971
+ if (isSplitByVisibility(this.#program, type, at, ignore, declared))
972
+ return UNREPRESENTABLE;
973
+ this.#depth += 1;
974
+ try {
975
+ // A declaration is a component every use shares, a parameter's included.
976
+ return declared === undefined
977
+ ? this.#body(type, at)
978
+ : withParameterPosition(false, () => this.#body(type, at));
979
+ }
980
+ finally {
981
+ this.#depth -= 1;
982
+ }
983
+ }
984
+ /** A model's body renders its own properties, outside a part's binary union - see `withMultipartBinary`. */
985
+ #body(type, at) {
986
+ return withMultipartBinary(multipartBinaryContext() && type.kind !== "Model", () => this.#bodyAt(type, at));
987
+ }
988
+ #bodyAt(type, at) {
989
+ return withVisibility(at, () => withRefResolver(
563
990
  /**
564
991
  * **Every candidate goes through {@link expressionFor}, including the ones with no name.**
565
992
  *
@@ -570,7 +997,7 @@ export class SchemaRegistry {
570
997
  * `expressionFor` renders an undeclared type through `#inline` exactly as before, and now
571
998
  * also notices when it is already doing so.
572
999
  */
573
- (candidate) => this.expressionFor(candidate), () => withDeferredAnnotator((property) => this.#deferredAnnotationOf(property), () => withSyntheticDeclarations((name, build) => this.#declareSynthetic(name, build), () => typeToZodBody(this.#program, type))));
1000
+ (candidate) => this.expressionFor(candidate), () => withDeferredAnnotator((property, position) => this.#deferredAnnotationOf(property, position), (variant) => this.#deferredTypeAnnotationOf(variant), () => withSyntheticDeclarations((name, build, site) => this.#declareSynthetic(name, build, { kind: "envelope", site }), () => withHeldDeclarations(this.#held, () => typeToZodBody(this.#program, type))))));
574
1001
  }
575
1002
  /**
576
1003
  * The Zod type a property emitted as a getter is annotated with, or `undefined` for none.
@@ -587,18 +1014,32 @@ export class SchemaRegistry {
587
1014
  * getter that its own value does not satisfy. A recursive property carrying a default is the one
588
1015
  * shape this leaves as it was.
589
1016
  */
590
- #deferredAnnotationOf(property) {
1017
+ #deferredAnnotationOf(property, position) {
591
1018
  if (property.defaultValue !== undefined)
592
1019
  return undefined;
593
- const declared = withTsRefResolver((candidate) => this.#typeNameOf(candidate), () => propertyTypeToTs(this.#program, property));
1020
+ const declared = withTsRefResolver((candidate) => this.#typeNameOf(candidate), () => propertyTypeToTs(this.#program, property, position));
594
1021
  const core = `z.ZodType<${declared}, ${declared}>`;
595
- const optional = property.optional || isOptionalAt(this.#program, property);
1022
+ const optional = !position.required && (property.optional || isOptionalAt(this.#program, property));
596
1023
  return `: ${optional ? `z.ZodExactOptional<${core}>` : core}`;
597
1024
  }
1025
+ /** The annotation for a deferred value no property declares - an envelope's variant. */
1026
+ #deferredTypeAnnotationOf(type) {
1027
+ const declared = withTsRefResolver((candidate) => this.#typeNameOf(candidate), () => typeToTs(this.#program, type));
1028
+ return `: z.ZodType<${declared}, ${declared}>`;
1029
+ }
598
1030
  /** Every declaration, dependencies first. */
599
1031
  declarations() {
600
1032
  return this.#order;
601
1033
  }
1034
+ /** The names of the declarations that validate nothing, refused here or in the walk beneath. */
1035
+ refusedNames() {
1036
+ return new Set([
1037
+ ...this.#refusedTypeNames,
1038
+ ...this.#order
1039
+ .filter((declaration) => declaration.source === UNREPRESENTABLE)
1040
+ .map((declaration) => declaration.typeName),
1041
+ ]);
1042
+ }
602
1043
  }
603
1044
  /**
604
1045
  * The same walk as {@link SchemaRegistry}, emitting plain TypeScript instead of Zod.
@@ -622,7 +1063,7 @@ export class SchemaRegistry {
622
1063
  * whole body is a single `{...}`, whatever it contains. A composition operator cannot be missed,
623
1064
  * because anything following the matching close brace fails the test whatever it happens to be.
624
1065
  */
625
- function isSingleObjectLiteral(source) {
1066
+ export function isSingleObjectLiteral(source) {
626
1067
  if (!source.startsWith("{"))
627
1068
  return false;
628
1069
  let depth = 0;
@@ -661,7 +1102,7 @@ export class TypeRegistry {
661
1102
  * projection. The two registries pair BY NAME in the emitted assertion, so they have to agree
662
1103
  * about how many declarations there are before the pairing means anything.
663
1104
  *
664
- * Both now go through the shared {@link visibilityFor} and {@link nameAt}: paraphrasing either
1105
+ * Both now go through the shared `renderedVisibilityOf` and {@link nameAt}: paraphrasing either
665
1106
  * rule in one walk is how a validator ends up compared against a type describing something else.
666
1107
  */
667
1108
  #declarations = new Map();
@@ -675,26 +1116,46 @@ export class TypeRegistry {
675
1116
  * Reported only by the schema walk, so one spec does not produce the same complaint twice.
676
1117
  */
677
1118
  #inlineInProgress = new Map();
678
- constructor(program) {
1119
+ /** How many bodies are being rendered - see the schema registry's. */
1120
+ #depth = 0;
1121
+ /** The validators' declarations, whose refusals a merge here holds to as theirs do. */
1122
+ #held;
1123
+ constructor(program, held) {
679
1124
  this.#program = program;
1125
+ this.#held = held ?? {
1126
+ refused: () => false,
1127
+ annotated: () => false,
1128
+ refusedAt: () => false,
1129
+ holdsRefusalAt: () => false,
1130
+ };
680
1131
  }
681
- expressionFor(type) {
1132
+ expressionFor(given, payload = false) {
1133
+ const type = payloadTypeOf(this.#program, given, payload, this.#depth);
1134
+ // Refused by the schema walk, which reports it - see `externalReferenceOf`.
1135
+ if (externalReferenceOf(type) !== undefined)
1136
+ return "never";
682
1137
  const bare = declaredNameOf(this.#program, type);
1138
+ const requested = currentVisibility();
1139
+ /**
1140
+ * Asked of the model a declaration stands for, as openapi3 asks it of the effective payload type:
1141
+ * a spread body's copy holds only what the request sends, so it is reshaped by nothing, while the
1142
+ * model it copies - its `@path` property payload at `Read` - is.
1143
+ */
1144
+ const at = visibilityAt(this.#program, type, requested, this.#depth);
683
1145
  if (bare === undefined) {
684
1146
  const entered = this.#inlineInProgress.get(type);
685
1147
  if (entered !== undefined && this.#inProgress.size <= entered)
686
1148
  return "never";
687
1149
  this.#inlineInProgress.set(type, this.#inProgress.size);
688
1150
  try {
689
- return this.#inline(type);
1151
+ return this.#inline(type, at, undefined);
690
1152
  }
691
1153
  finally {
692
1154
  this.#inlineInProgress.delete(type);
693
1155
  }
694
1156
  }
695
- const at = visibilityFor(this.#program, type, currentVisibility());
696
1157
  const key = keyFor(type, at);
697
- const name = nameAt(bare, at);
1158
+ const name = nameAt(bare, at, type);
698
1159
  const existing = this.#declarations.get(key);
699
1160
  if (existing !== undefined)
700
1161
  return existing.name;
@@ -712,10 +1173,11 @@ export class TypeRegistry {
712
1173
  if (pending !== undefined)
713
1174
  return pending;
714
1175
  this.#inProgress.set(key, name);
715
- const source = this.#inline(type);
1176
+ const source = this.#inline(type, at, name);
716
1177
  this.#inProgress.delete(key);
717
1178
  const declaration = {
718
1179
  name,
1180
+ type,
719
1181
  /**
720
1182
  * Only a plain object literal can be an `interface`. A permissive response is
721
1183
  * `{ ...pinned } & Record<string, unknown>`, and `export interface X { ... } & ...` does not parse -
@@ -738,6 +1200,7 @@ export class TypeRegistry {
738
1200
  */
739
1201
  isVocabulary: type.kind === "Enum",
740
1202
  source,
1203
+ at,
741
1204
  };
742
1205
  this.#declarations.set(key, declaration);
743
1206
  /**
@@ -759,14 +1222,27 @@ export class TypeRegistry {
759
1222
  this.#order.push(declaration);
760
1223
  }
761
1224
  else if (sameName.source !== declaration.source) {
762
- reportDiagnostic(this.#program, {
763
- code: "duplicate-declaration",
764
- format: { name: declaration.name },
765
- target: type,
766
- });
1225
+ // Refused as the schema registry refuses it - one declaration, of nothing.
1226
+ reportCollision(this.#program, declaration.name, sameName, declaration);
1227
+ const refused = { ...sameName, source: "never", isObject: false };
1228
+ this.#order[this.#order.indexOf(sameName)] = refused;
1229
+ for (const [other, value] of this.#declarations) {
1230
+ if (value === sameName || value === declaration)
1231
+ this.#declarations.set(other, refused);
1232
+ }
767
1233
  }
768
1234
  return declaration.name;
769
1235
  }
1236
+ /** Declare a component the document publishes, as {@link SchemaRegistry.declare} does. */
1237
+ declare(type) {
1238
+ this.#depth += 1;
1239
+ try {
1240
+ this.expressionFor(type);
1241
+ }
1242
+ finally {
1243
+ this.#depth -= 1;
1244
+ }
1245
+ }
770
1246
  /**
771
1247
  * The Zod-free type for a single property - used for path and query parameters, which are
772
1248
  * `ModelProperty`s belonging to no model. Without routing them through the registry a named model
@@ -774,17 +1250,102 @@ export class TypeRegistry {
774
1250
  */
775
1251
  /** `wireName` names an HTTP parameter as the wire does - see {@link propertyToTs}. */
776
1252
  expressionForProperty(property, wireName) {
777
- return withTsRefResolver(
1253
+ return withParameterPosition(true, () => withTsRefResolver(
778
1254
  // Through expressionFor even when undeclared, so the cycle guard above is reachable.
779
- (candidate) => this.expressionFor(candidate), () => propertyToTs(this.#program, property, wireName));
1255
+ (candidate) => this.expressionFor(candidate), () =>
1256
+ // A refusal points at the property, as the schema walk's does.
1257
+ withDeclarationSite(property, () => withHeldDeclarations(this.#held, () => propertyToTs(this.#program, property, wireName)))));
780
1258
  }
781
- #inline(type) {
782
- return withTsRefResolver(
1259
+ #inline(type, at, declared) {
1260
+ // Refused as the schema registry refuses it, which reports it.
1261
+ const ignore = metadataAnnotationsIgnored();
1262
+ if (isSplitByVisibility(this.#program, type, at, ignore, declared))
1263
+ return "never";
1264
+ this.#depth += 1;
1265
+ try {
1266
+ // A declaration is a component every use shares, a parameter's included.
1267
+ return declared === undefined
1268
+ ? this.#body(type, at)
1269
+ : withParameterPosition(false, () => this.#body(type, at));
1270
+ }
1271
+ finally {
1272
+ this.#depth -= 1;
1273
+ }
1274
+ }
1275
+ /** A model's body renders its own properties, outside a part's binary union - see `withMultipartBinary`. */
1276
+ #body(type, at) {
1277
+ return withMultipartBinary(multipartBinaryContext() && type.kind !== "Model", () => this.#bodyAt(type, at));
1278
+ }
1279
+ #bodyAt(type, at) {
1280
+ return withVisibility(at, () => withTsRefResolver(
783
1281
  // Through expressionFor even when undeclared, so the cycle guard above is reachable.
784
- (candidate) => this.expressionFor(candidate), () => typeToTsBody(this.#program, type));
1282
+ (candidate) => this.expressionFor(candidate), () => withTsSyntheticDeclarations((name, build, site) => this.#declareSynthetic(name, build, { kind: "envelope", site }), () => withHeldDeclarations(this.#held, () => typeToTsBody(this.#program, type)))));
1283
+ }
1284
+ /** A multipart body's type, under the component's name - see {@link SchemaRegistry.declareNamed}. */
1285
+ declareNamed(name, build, site) {
1286
+ return this.#declareSynthetic(name, build, { kind: "multipart", site });
1287
+ }
1288
+ /** A type for a shape the spec never wrote, as {@link SchemaRegistry.declareNamed} declares one. */
1289
+ #declareSynthetic(published, build, synthetic) {
1290
+ // Under the rule every declaration's name is decided by - see `safeDeclarationName`.
1291
+ const name = safeDeclarationName(published);
1292
+ const key = `synthetic\u0000${name}`;
1293
+ const existing = this.#declarations.get(key);
1294
+ if (existing !== undefined)
1295
+ return existing.name;
1296
+ if (this.#inProgress.has(key))
1297
+ return name;
1298
+ this.#inProgress.set(key, name);
1299
+ const source = build();
1300
+ this.#inProgress.delete(key);
1301
+ const declaration = {
1302
+ name,
1303
+ isObject: isSingleObjectLiteral(source),
1304
+ isVocabulary: false,
1305
+ source,
1306
+ synthetic,
1307
+ };
1308
+ // One name, one declaration, as the schema registry keeps it - see its `#declareSynthetic`.
1309
+ const twin = this.#order.find((candidate) => candidate.name === name);
1310
+ if (twin !== undefined && twin.source === source) {
1311
+ this.#declarations.set(key, twin);
1312
+ return name;
1313
+ }
1314
+ if (twin !== undefined) {
1315
+ reportCollision(this.#program, name, twin, declaration);
1316
+ const refused = { ...twin, source: "never", isObject: false };
1317
+ this.#order[this.#order.indexOf(twin)] = refused;
1318
+ for (const [other, value] of this.#declarations) {
1319
+ if (value === twin)
1320
+ this.#declarations.set(other, refused);
1321
+ }
1322
+ this.#declarations.set(key, refused);
1323
+ return name;
1324
+ }
1325
+ this.#declarations.set(key, declaration);
1326
+ this.#order.push(declaration);
1327
+ return name;
785
1328
  }
786
1329
  /** Every declaration, dependencies first. */
787
1330
  declarations() {
788
1331
  return this.#order;
789
1332
  }
1333
+ /**
1334
+ * **A contract type whose validator validates nothing is `never` too**, whatever refused it: two
1335
+ * shapes under one name, a union this emitter refuses, a model openapi3 publishes split. The wire
1336
+ * assertion pairs the two by name, and a contract type stating a shape beside `z.never()` failed
1337
+ * it - `interface HBase { name: string }` beside a refused `hBaseSchema`.
1338
+ */
1339
+ refuse(names) {
1340
+ for (const [index, declaration] of this.#order.entries()) {
1341
+ if (!names.has(declaration.name) || declaration.source === "never")
1342
+ continue;
1343
+ const refused = { ...declaration, source: "never", isObject: false };
1344
+ this.#order[index] = refused;
1345
+ for (const [key, value] of this.#declarations) {
1346
+ if (value === declaration)
1347
+ this.#declarations.set(key, refused);
1348
+ }
1349
+ }
1350
+ }
790
1351
  }