typespec-http-zod 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/api.d.ts CHANGED
@@ -211,6 +211,8 @@ export interface EmittedRoute {
211
211
  readonly alternateResponseSchema: string | undefined;
212
212
  }
213
213
  export declare function collectRoutes(program: Program, registry: SchemaRegistry, service: HttpService): EmittedRoute[];
214
+ /** Whether emitted source references `name` as an identifier rather than as part of a longer one. */
215
+ export declare function referencedIn(source: string, name: string): boolean;
214
216
  /**
215
217
  * The identifiers a route's validators are declared under, by what the DOCUMENT calls each one.
216
218
  *
package/dist/src/api.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { emitFile, getDoc, getEncode, getSummary, isErrorModel, isNeverType, isVoidType, resolvePath, } from "@typespec/compiler";
2
2
  import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
3
- import { resolveOperationId } from "@typespec/openapi";
3
+ import { getOperationId, resolveOperationId } from "@typespec/openapi";
4
4
  import { reportDiagnostic } from "./lib.js";
5
5
  import { SchemaRegistry, TypeRegistry } from "./registry.js";
6
6
  import { resolveStreamModule, streamedTypeOf, withStreamModule } from "./streams.js";
7
7
  import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
8
- import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
8
+ import { collectExternalImports, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
9
9
  /**
10
10
  * `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
11
11
  * service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
@@ -90,6 +90,89 @@ export function successStatusesOf(operation) {
90
90
  }
91
91
  return [...new Set(codes)].toSorted((a, b) => a - b);
92
92
  }
93
+ /**
94
+ * **Operation ids, deduplicated the way `@typespec/openapi3` deduplicates them.**
95
+ *
96
+ * `resolveOperationId` derives an id from the immediate parent container, so two interfaces of the
97
+ * same name in different namespaces both resolve to `Standard_primitive`. Every per-operation
98
+ * declaration here is keyed on that id, so colliding ids emitted colliding `const`s:
99
+ * `TS2451: Cannot redeclare block-scoped variable 'Standard_primitiveResponses'`, 72 of them on one
100
+ * conformance scenario, from a compile that reported success.
101
+ *
102
+ * **The document does NOT have this problem, so refusing the spec would be wrong.** Measured on
103
+ * 1.15.0, openapi3 publishes `Standard_primitive` and `Standard_primitive_2` - it deduplicates, and
104
+ * the document is valid. The same source is representable there, so it has to be representable here,
105
+ * and matching its ids is also what keeps the promise that identifiers are named from the operation
106
+ * id the document publishes.
107
+ *
108
+ * The rule is copied from `OperationIdResolver` rather than invented, including the parts that look
109
+ * incidental: an explicit `@operationId` wins and is NOT deduplicated or reserved, and the suffix
110
+ * counts from `_2`. It is not exported, so it is reproduced here and the differential compares the
111
+ * result against the document's own ids.
112
+ */
113
+ let operationIds;
114
+ /**
115
+ * Establish a fresh id resolver for one service's emission.
116
+ *
117
+ * **Scoped per SERVICE rather than per walk, because the walks see different subsets.**
118
+ * `collectRoutes` visits every operation with a success status; `collectRequestTypes` skips any with
119
+ * no body and no parameters. Deduplicating independently would let the two assign a suffix to
120
+ * different operations, and the artefacts would name one operation two ways.
121
+ *
122
+ * A reset rather than a wrapper, because the emission loop is `for (const snapshot of snapshots)`
123
+ * and awaits each service to completion before the next begins. Nothing interleaves, so there is no
124
+ * scope to restore, and wrapping an async body would mean threading a callback through every
125
+ * `emitFile` between the three walks.
126
+ *
127
+ * Left `undefined` outside an emission on purpose: `collectRoutes` is exported and a caller reaching
128
+ * it directly gets `resolveOperationId`'s answer unchanged rather than a suffix that depends on
129
+ * whatever ran before.
130
+ */
131
+ function beginOperationIds() {
132
+ operationIds = { used: new Set(), assigned: new Set(), cache: new Map() };
133
+ }
134
+ /** The id this operation is emitted under, unique within the emission. */
135
+ function operationIdOf(program, operation) {
136
+ const scope = operationIds;
137
+ const resolved = resolveOperationId(program, operation);
138
+ if (scope === undefined)
139
+ return resolved;
140
+ const cached = scope.cache.get(operation);
141
+ if (cached !== undefined)
142
+ return cached;
143
+ // An explicit `@operationId` is used as given: openapi3 neither deduplicates nor reserves it.
144
+ const explicit = getOperationId(program, operation) !== undefined;
145
+ let name = resolved;
146
+ if (!explicit && scope.used.has(name)) {
147
+ let count = 1;
148
+ for (;;) {
149
+ count += 1;
150
+ const candidate = `${resolved}_${count}`;
151
+ if (!scope.used.has(candidate)) {
152
+ name = candidate;
153
+ break;
154
+ }
155
+ }
156
+ }
157
+ if (!explicit)
158
+ scope.used.add(name);
159
+ scope.cache.set(operation, name);
160
+ /**
161
+ * **Checked after the id is settled, and reachable only through an explicit `@operationId`.** A
162
+ * derived id is deduplicated against `used` and cannot arrive here twice; an explicit one is never
163
+ * reserved, so it can equal an id already handed out, in either direction. Reported once per
164
+ * operation, because the cache above returns before this on every later walk.
165
+ */
166
+ if (scope.assigned.has(name)) {
167
+ reportDiagnostic(program, {
168
+ code: "duplicate-operation-id",
169
+ format: { operationId: name },
170
+ target: operation,
171
+ });
172
+ }
173
+ scope.assigned.add(name);
174
+ return name;
175
+ }
93
176
  /** The five buckets OpenAPI can express a status RANGE as, in the order it names them. */
94
177
  const STATUS_RANGE_GROUPS = [1, 2, 3, 4, 5];
95
178
  /**
@@ -365,6 +448,21 @@ function responseContentTypesOf(operation, status) {
365
448
  }
366
449
  return [...types];
367
450
  }
451
+ /** The type a parameter actually crosses the wire as, after any `@encode` on the property. */
452
+ function encodedParameterType(program, property) {
453
+ return getEncode(program, property)?.type ?? property.type;
454
+ }
455
+ /**
456
+ * The wire kind of an ARRAY parameter's elements, or `undefined` where it is not such an array.
457
+ *
458
+ * `?ids=1,2,3` splits into strings, so each element needs the same decoding a lone scalar would.
459
+ */
460
+ function elementKindOf(program, type) {
461
+ if (!isArrayType(type) || type.kind !== "Model")
462
+ return undefined;
463
+ const element = type.indexer?.value;
464
+ return element === undefined ? undefined : wireKindOf(program, element);
465
+ }
368
466
  /** Whether a type is an array - TypeSpec models one as a `Model` carrying an integer indexer. */
369
467
  function isArrayType(type) {
370
468
  return type.kind === "Model" && type.indexer?.key.name === "integer";
@@ -583,12 +681,12 @@ function collectionDelimiterOf(program, parameter) {
583
681
  * `zod.ts` decides what becomes `z.number()`, and a second copy of that list here would stop agreeing
584
682
  * with it silently.
585
683
  */
586
- function wireDecoded(declared) {
684
+ function wireDecoded(declared, kind, elementKind) {
587
685
  const NUMBER = '(raw) => (typeof raw === "string" && raw.trim() !== "" && Number.isFinite(Number(raw)) ? Number(raw) : raw)';
588
686
  const BOOLEAN = '(raw) => (raw === "true" ? true : raw === "false" ? false : raw)';
589
- if (declared.startsWith("z.number()"))
687
+ if (kind === "number")
590
688
  return `z.preprocess(${NUMBER}, ${declared})`;
591
- if (declared.startsWith("z.boolean()"))
689
+ if (kind === "boolean")
592
690
  return `z.preprocess(${BOOLEAN}, ${declared})`;
593
691
  /**
594
692
  * A list whose ELEMENTS are numeric or boolean - `?ids=1,2,3` after the collection split, or a
@@ -596,10 +694,8 @@ function wireDecoded(declared) {
596
694
  * the same decoder applies per element. A non-array value is left alone; the split that produces
597
695
  * the array runs outside this and may not have happened.
598
696
  */
599
- const element = /^z\.array\((z\.(?:number|boolean)\(\).*)\)$/s.exec(declared);
600
- if (element !== null) {
601
- const inner = element[1] ?? "";
602
- const decoder = inner.startsWith("z.number()") ? NUMBER : BOOLEAN;
697
+ if (elementKind !== undefined) {
698
+ const decoder = elementKind === "number" ? NUMBER : BOOLEAN;
603
699
  return `z.preprocess((raw) => (Array.isArray(raw) ? raw.map(${decoder}) : raw), ${declared})`;
604
700
  }
605
701
  return declared;
@@ -688,7 +784,14 @@ function parameterSchemasOf(program, operation, registry) {
688
784
  */
689
785
  const decoded = parameter.type === "header" && parameter.name.toLowerCase() === "content-type"
690
786
  ? mediaTypeDecoded(declared)
691
- : wireDecoded(declared);
787
+ : wireDecoded(declared,
788
+ /**
789
+ * `@encode` on the PROPERTY changes what crosses the wire, so the encoded type is
790
+ * the one to read. `@encode("unixTimestamp", int32) value: utcDateTime` is a number
791
+ * on the wire and a date in the spec, and reading the declared type alone stopped
792
+ * eighteen such parameters being decoded.
793
+ */
794
+ wireKindOf(program, encodedParameterType(program, parameter.param)), elementKindOf(program, encodedParameterType(program, parameter.param)));
692
795
  /**
693
796
  * The boxing half of the same principle: an exploded array's single occurrence arrives as a
694
797
  * bare string, so it is boxed into the one-element array the document describes before the
@@ -773,7 +876,7 @@ export function collectRoutes(program, registry, service) {
773
876
  ? (bodyParameter?.property?.name ?? "body")
774
877
  : undefined;
775
878
  routes.push({
776
- operationId: resolveOperationId(program, operation.operation),
879
+ operationId: operationIdOf(program, operation.operation),
777
880
  verb: operation.verb.toUpperCase(),
778
881
  /**
779
882
  * The path TEMPLATE, exactly as the document publishes it - `/widgets/{widget-id}`.
@@ -875,11 +978,40 @@ export function collectRoutes(program, registry, service) {
875
978
  * Emitting the whole set into both would leave unused imports (which lint rejects); emitting it into
876
979
  * neither is what left `routes.gen.ts` referencing `isParsableInstant` with no import at all.
877
980
  */
981
+ /**
982
+ * Every identifier the emitted source mentions.
983
+ *
984
+ * **Tokenised once on the language's own identifier rule, then tested by equality.** The question is
985
+ * "does this file reference this name", and the two obvious ways to ask it are both wrong in ways
986
+ * that only appear for particular names.
987
+ *
988
+ * A pattern built from the name is wrong when the name carries a regular-expression metacharacter.
989
+ * `$` is a valid identifier character in JavaScript and in TypeSpec, and an anchor in a pattern, so
990
+ * `new RegExp(`\b${name}\b`)` reports `foo$bar` absent from source that references it verbatim -
991
+ * and the import is dropped, leaving the emitted module referencing an undeclared name. Escaping
992
+ * alone does not fix it either, because `\b` is a WORD boundary and `$` is not a word character, so
993
+ * there is no boundary before it to match.
994
+ *
995
+ * A substring test is wrong in the other direction: `Foo` is found inside `FooExtra`, emitting an
996
+ * import the file never uses. That one fails loudly at lint, which makes it the better mistake, but
997
+ * it is still a mistake.
998
+ *
999
+ * Tokenising has neither, needs no escaping to stay correct, and is one pass rather than one pattern
1000
+ * per name. `typespec-hono` arrived at it after the same defect went live there: a spec whose every
1001
+ * identifier carried a `$` emitted a file that imported nothing and did not compile.
1002
+ */
1003
+ function identifiersIn(source) {
1004
+ return new Set(source.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
1005
+ }
1006
+ /** Whether emitted source references `name` as an identifier rather than as part of a longer one. */
1007
+ export function referencedIn(source, name) {
1008
+ return identifiersIn(source).has(name);
1009
+ }
878
1010
  function renderExternalImports(externals, ...bodies) {
879
1011
  const haystack = bodies.join("\n");
880
1012
  const lines = [];
881
1013
  for (const [module, names] of externals) {
882
- const used = [...names].filter((name) => new RegExp(`\\b${name}\\b`).test(haystack));
1014
+ const used = [...names].filter((name) => referencedIn(haystack, name));
883
1015
  if (used.length > 0) {
884
1016
  lines.push(`import { ${used.toSorted().join(", ")} } from ${JSON.stringify(module)};\n`);
885
1017
  }
@@ -1137,7 +1269,7 @@ function collectRequestTypes(program, registry, service) {
1137
1269
  ? "ArrayBuffer"
1138
1270
  : "string";
1139
1271
  entries.push({
1140
- operationId: resolveOperationId(program, operation.operation),
1272
+ operationId: operationIdOf(program, operation.operation),
1141
1273
  bodyRef: rawBody !== undefined
1142
1274
  ? `{\n\t${rawBody}: ${rawBodyType};\n}`
1143
1275
  : body === undefined
@@ -1169,7 +1301,7 @@ function collectResponseTypes(program, registry, service) {
1169
1301
  if (body === undefined)
1170
1302
  continue;
1171
1303
  entries.push({
1172
- operationId: resolveOperationId(program, operation.operation),
1304
+ operationId: operationIdOf(program, operation.operation),
1173
1305
  ref: registry.expressionFor(body),
1174
1306
  });
1175
1307
  }
@@ -1408,6 +1540,7 @@ export async function emitHttpZod(context, wrapper = {}) {
1408
1540
  */
1409
1541
  const distinctServices = new Set(snapshots.map((snapshot) => snapshot.service.namespace.name));
1410
1542
  for (const snapshot of snapshots) {
1543
+ beginOperationIds();
1411
1544
  const service = snapshot.service;
1412
1545
  const serviceName = service.namespace.name;
1413
1546
  const perService = options.services?.[serviceName];
package/dist/src/lib.d.ts CHANGED
@@ -121,8 +121,14 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
121
121
  "unsupported-status-code-range": {
122
122
  readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
123
123
  };
124
+ "duplicate-operation-id": {
125
+ readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
126
+ };
127
+ "duplicate-declaration": {
128
+ readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
129
+ };
124
130
  }, EmitterOptions, never>;
125
- export declare const reportDiagnostic: <C extends "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
131
+ export declare const reportDiagnostic: <C extends "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
126
132
  "unsupported-type": {
127
133
  readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
128
134
  };
@@ -141,6 +147,12 @@ export declare const reportDiagnostic: <C extends "empty-union" | "undeclared-di
141
147
  "unsupported-status-code-range": {
142
148
  readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
143
149
  };
150
+ "duplicate-operation-id": {
151
+ readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
152
+ };
153
+ "duplicate-declaration": {
154
+ readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
155
+ };
144
156
  }[C]>(program: import("@typespec/compiler").Program, diag: import("@typespec/compiler").DiagnosticReport<{
145
157
  "unsupported-type": {
146
158
  readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
@@ -160,4 +172,10 @@ export declare const reportDiagnostic: <C extends "empty-union" | "undeclared-di
160
172
  "unsupported-status-code-range": {
161
173
  readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
162
174
  };
175
+ "duplicate-operation-id": {
176
+ readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
177
+ };
178
+ "duplicate-declaration": {
179
+ readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
180
+ };
163
181
  }, C, M>) => void;
package/dist/src/lib.js CHANGED
@@ -155,6 +155,47 @@ const diagnostics = {
155
155
  default: paramMessage `Status code range '${"start"}' to '${"end"}' cannot be published: OpenAPI can only key a range as 1XX, 2XX, 3XX, 4XX or 5XX, so no document states what this response carries and no validator may be emitted for it. Cover a whole group (\`@minValue(400) @maxValue(499)\` for 4XX) or declare the codes individually.`,
156
156
  },
157
157
  },
158
+ /**
159
+ * An explicit `@operationId` another operation already answers to.
160
+ *
161
+ * **Derived ids are deduplicated and this one cannot be.** `resolveOperationId` names an operation
162
+ * from its immediate parent container, so two interfaces of the same name collide, and openapi3
163
+ * resolves that by appending `_2`. It does NOT do so for an explicit `@operationId`: that id is
164
+ * returned exactly as written and is never reserved, because the author asked for that name and
165
+ * renaming it silently would break every client generated against the document.
166
+ *
167
+ * So a colliding explicit id is a document OpenAPI forbids, `operationId` must be unique, and
168
+ * openapi3 raises nothing and publishes the duplicate. Every declaration here is keyed on the id,
169
+ * so the emitted file declares each name twice and fails with `TS2451: Cannot redeclare
170
+ * block-scoped variable` from a compile that reported success.
171
+ *
172
+ * **This is the one collision worth refusing.** The derived case is representable and is emitted.
173
+ * This one cannot be resolved without contradicting what the author explicitly wrote.
174
+ */
175
+ "duplicate-operation-id": {
176
+ severity: "error",
177
+ messages: {
178
+ default: paramMessage `'${"operationId"}' is already the operation id of another operation, and OpenAPI requires it to be unique. An explicit '@operationId' is never renamed to make room, so this cannot be resolved for you. Give this operation an id no other operation uses, or remove the '@operationId' and let it be derived.`,
179
+ },
180
+ },
181
+ /**
182
+ * Two declarations claiming one TypeScript name with different bodies.
183
+ *
184
+ * **A repeat of the SAME declaration is not this and is dropped silently.** Visibility projection
185
+ * hands the registry a different `Type` object for one declared model, so a model reached under two
186
+ * visibilities is registered twice and renders identically; emitting both produced two
187
+ * byte-identical `export interface BodyModel` and a file that does not compile.
188
+ *
189
+ * This is the other case: one name, two different shapes. The document distinguishes them by
190
+ * namespace and a TypeScript module cannot, so keeping the first would publish a contract for a
191
+ * type nothing checks, and picking either is a guess about which the author meant.
192
+ */
193
+ "duplicate-declaration": {
194
+ severity: "error",
195
+ messages: {
196
+ default: paramMessage `Two different types are both named '${"name"}', and a TypeScript module cannot declare one name twice. The document tells them apart by namespace; this file cannot. Give one of them a different name, or set an explicit one with '@friendlyName'.`,
197
+ },
198
+ },
158
199
  };
159
200
  export const $lib = createTypeSpecLibrary({
160
201
  name: "typespec-http-zod",
@@ -95,14 +95,6 @@ export interface TsDeclaration {
95
95
  readonly isVocabulary: boolean;
96
96
  readonly source: string;
97
97
  }
98
- /**
99
- * The same walk as {@link SchemaRegistry}, emitting plain TypeScript instead of Zod.
100
- *
101
- * Kept as a separate class rather than a mode on the schema registry because the two produce
102
- * different *kinds* of declaration - an `interface` versus a `const` - and their reference resolvers
103
- * hand back different text. Sharing {@link declaredNameOf} is the part that matters: a model named in
104
- * one artefact is named the same way in the other, so the emitted assertion can pair them by name.
105
- */
106
98
  export declare class TypeRegistry {
107
99
  #private;
108
100
  constructor(program: Program);
@@ -1,4 +1,5 @@
1
1
  import { getVisibilitySuffix, Visibility } from "@typespec/http";
2
+ import { reportDiagnostic } from "./lib.js";
2
3
  import { propertyToTs, typeToTsBody, withTsRefResolver } from "./types.js";
3
4
  import { captureBackEdges, currentVisibility, isTransformedBy, noteBackEdge, propertyToZod, typeToZodBody, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
4
5
  /**
@@ -63,10 +64,78 @@ required = [...type.properties.keys()]) {
63
64
  return model.name === "" ? spreadSourceOf(model, new Set([...seen, type]), required) : model;
64
65
  }
65
66
  const spreadSourceName = (type) => spreadSourceOf(type)?.name;
67
+ /**
68
+ * ECMAScript's reserved words, which cannot name a TypeScript declaration.
69
+ *
70
+ * **A model may be named after one, and the corpus declares exactly that on purpose.** `special-words`
71
+ * carries `await`, `break` and the rest, and this emitter wrote `export interface await { ... }` and
72
+ * `export type break = z.infer<...>`, neither of which parses - `TS2427`, then the parser gives up
73
+ * and the whole file is lost.
74
+ *
75
+ * **This is a LIST, which this package otherwise refuses.** It is defensible here because it is
76
+ * not a list about our own code that drifts as the code changes: it is a closed set the language
77
+ * specification fixes. The guard is that `tsc` compiles a fixture declaring models named after these,
78
+ * so a word missing from the list fails a test rather than reaching a consumer - the list is the
79
+ * implementation and the compiler is the oracle.
80
+ *
81
+ * Contextual keywords are deliberately absent, because they are legal: `interface`, `string`,
82
+ * `number`, `any`, `never`, `public` and `yield` all name a type without complaint. Measured, rather
83
+ * than assumed from a language reference.
84
+ */
85
+ const RESERVED_DECLARATION_NAMES = new Set([
86
+ "await",
87
+ "break",
88
+ "case",
89
+ "catch",
90
+ "class",
91
+ "const",
92
+ "continue",
93
+ "debugger",
94
+ "default",
95
+ "delete",
96
+ "do",
97
+ "else",
98
+ "enum",
99
+ "export",
100
+ "extends",
101
+ "false",
102
+ "finally",
103
+ "for",
104
+ "function",
105
+ "if",
106
+ "import",
107
+ "in",
108
+ "instanceof",
109
+ "new",
110
+ "null",
111
+ "return",
112
+ "super",
113
+ "switch",
114
+ "this",
115
+ "throw",
116
+ "true",
117
+ "try",
118
+ "typeof",
119
+ "var",
120
+ "void",
121
+ "while",
122
+ "with",
123
+ ]);
124
+ /**
125
+ * The name a declaration is emitted under.
126
+ *
127
+ * A trailing `_` is appended where the spec's own name cannot be a TypeScript declaration. It is
128
+ * applied in ONE place so both walks agree: `wire-contract.gen.ts` pairs a validator against a
129
+ * contract type by name, and a mangling applied to one side only would break that assertion rather
130
+ * than the file.
131
+ */
132
+ function safeDeclarationName(name) {
133
+ return RESERVED_DECLARATION_NAMES.has(name) ? `${name}_` : name;
134
+ }
66
135
  /** A type earns a name when the spec gave it one - anonymous shapes stay inline where they are used. */
67
136
  function declaredNameOf(type) {
68
137
  if (type.kind === "Enum")
69
- return type.name;
138
+ return safeDeclarationName(type.name);
70
139
  /**
71
140
  * A named union earns a declaration too.
72
141
  *
@@ -76,14 +145,16 @@ function declaredNameOf(type) {
76
145
  * would silently stop being checked at the boundary.
77
146
  */
78
147
  if (type.kind === "Union")
79
- return type.name === undefined ? undefined : type.name;
148
+ return type.name === undefined ? undefined : safeDeclarationName(type.name);
80
149
  if (type.kind !== "Model")
81
150
  return undefined;
82
151
  // `Array`/`Record` are TypeSpec's built-in generic containers, not names worth exporting.
83
152
  if (type.name === "Array" || type.name === "Record")
84
153
  return undefined;
85
- if (type.name === "")
86
- return spreadSourceName(type);
154
+ if (type.name === "") {
155
+ const spread = spreadSourceName(type);
156
+ return spread === undefined ? undefined : safeDeclarationName(spread);
157
+ }
87
158
  /**
88
159
  * **A template INSTANTIATION carries the template's name, and it is not a declaration.**
89
160
  *
@@ -97,7 +168,7 @@ function declaredNameOf(type) {
97
168
  */
98
169
  if (type.templateMapper !== undefined)
99
170
  return undefined;
100
- return type.name;
171
+ return safeDeclarationName(type.name);
101
172
  }
102
173
  const lowerFirst = (value) => value.charAt(0).toLowerCase() + value.slice(1);
103
174
  /**
@@ -344,6 +415,48 @@ export class SchemaRegistry {
344
415
  * hand back different text. Sharing {@link declaredNameOf} is the part that matters: a model named in
345
416
  * one artefact is named the same way in the other, so the emitted assertion can pair them by name.
346
417
  */
418
+ /**
419
+ * Whether a rendered body is ONE object literal, and so may be emitted as an `interface`.
420
+ *
421
+ * **This was `source.startsWith("{") && !source.includes("} & ")`, which named the two shapes it had
422
+ * met rather than the property it needed.** A discriminated union with an envelope renders as
423
+ * `{ kind: "cat"; value: Cat; } | { kind: "dog"; value: Dog; }`: it starts with a brace and holds no
424
+ * intersection, so it was emitted as `export interface PetWithEnvelope { ... } | { ... }`. An
425
+ * interface cannot be a union, so the file did not parse at all - and that is the DEFAULT envelope,
426
+ * the shape a spec gets from writing `@discriminated` and nothing else.
427
+ *
428
+ * Balanced braces answer the question actually being asked: an interface is valid exactly when the
429
+ * whole body is a single `{...}`, whatever it contains. A composition operator cannot be missed,
430
+ * because anything following the matching close brace fails the test whatever it happens to be.
431
+ */
432
+ function isSingleObjectLiteral(source) {
433
+ if (!source.startsWith("{"))
434
+ return false;
435
+ let depth = 0;
436
+ let quoted = false;
437
+ for (let index = 0; index < source.length; index += 1) {
438
+ const character = source[index];
439
+ // A property key may be quoted and may itself contain a brace, which is not a nesting one.
440
+ if (quoted) {
441
+ if (character === "\\")
442
+ index += 1;
443
+ else if (character === '"')
444
+ quoted = false;
445
+ continue;
446
+ }
447
+ if (character === '"')
448
+ quoted = true;
449
+ else if (character === "{")
450
+ depth += 1;
451
+ else if (character === "}") {
452
+ depth -= 1;
453
+ // Closing the outermost brace anywhere but at the very end means something follows it.
454
+ if (depth === 0)
455
+ return index === source.length - 1;
456
+ }
457
+ }
458
+ return false;
459
+ }
347
460
  export class TypeRegistry {
348
461
  #program;
349
462
  #declarations = new Map();
@@ -377,7 +490,7 @@ export class TypeRegistry {
377
490
  * `{ ...pinned } & Record<string, unknown>`, and `export interface X { ... } & ...` does not parse -
378
491
  * so an intersection is emitted as a type alias instead.
379
492
  */
380
- isObject: source.startsWith("{") && !source.includes("} & "),
493
+ isObject: isSingleObjectLiteral(source),
381
494
  /**
382
495
  * **A vocabulary alias is NOT exported.**
383
496
  *
@@ -396,7 +509,31 @@ export class TypeRegistry {
396
509
  source,
397
510
  };
398
511
  this.#declarations.set(type, declaration);
399
- this.#order.push(declaration);
512
+ /**
513
+ * **Declared once per NAME, not once per `Type`, and the two are not the same thing.**
514
+ *
515
+ * `#declarations` is keyed on the `Type` object so a repeated reference resolves without
516
+ * re-walking. Visibility projection produces a DIFFERENT `Type` for the same declaration, so one
517
+ * model reached under two visibilities was emitted twice and the file did not compile:
518
+ * `parameters/body-optionality` declares one `model BodyModel` and `requests.gen.ts` carried two
519
+ * byte-identical `export interface BodyModel`, from a compile that reported success.
520
+ *
521
+ * **Identical is dropped; DIFFERING is a collision and must not be.** Two declarations sharing a
522
+ * name with different bodies are two different types that TypeScript can only express as one, so
523
+ * silently keeping the first would publish a contract for a type nothing checks. The document
524
+ * distinguishes them and this file cannot, which is a refusal rather than a choice.
525
+ */
526
+ const sameName = this.#order.find((candidate) => candidate.name === declaration.name);
527
+ if (sameName === undefined) {
528
+ this.#order.push(declaration);
529
+ }
530
+ else if (sameName.source !== declaration.source) {
531
+ reportDiagnostic(this.#program, {
532
+ code: "duplicate-declaration",
533
+ format: { name: declaration.name },
534
+ target: type,
535
+ });
536
+ }
400
537
  return declaration.name;
401
538
  }
402
539
  /**
package/dist/src/zod.d.ts CHANGED
@@ -93,6 +93,24 @@ export declare function effectiveIndexer(model: Model): Model["indexer"];
93
93
  * for a real subtype - the same rule that decides whether a base may be sealed.
94
94
  */
95
95
  export declare function discriminatedSubtypes(program: Program, model: Model): readonly Model[];
96
+ /**
97
+ * **What JSON kind a value of this type arrives as, for a parameter carried in text.**
98
+ *
99
+ * A path, query or header value is always a string on the wire, so a numeric or boolean parameter has
100
+ * to be decoded before the document's schema runs. Deciding WHICH decoder applies used to be done by
101
+ * inspecting the emitted Zod - `declared.startsWith("z.number()")` - which is a string about a string
102
+ * and fails silently the moment the expression is spelled any other way.
103
+ *
104
+ * It did. `@query size: 10 | 25 | 50` emits `z.union([z.literal(10), ...])`, which starts with
105
+ * neither, so no decoder was wrapped around it and the validator required the NUMBER 25 from a wire
106
+ * that can only deliver `"25"`. Measured: `?size=25` rejected, `size: 25` accepted. Every conformant
107
+ * caller got a 400, and no document comparison could see it, because both sides agree the parameter
108
+ * is a number - the disagreement is with the wire.
109
+ *
110
+ * Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
111
+ * single list of what becomes a number, so this cannot drift from what `scalarToZod` emits.
112
+ */
113
+ export declare function wireKindOf(program: Program, type: Type): "number" | "boolean" | undefined;
96
114
  /**
97
115
  * A name as an object key - quoted when it is not a legal JavaScript identifier.
98
116
  *
package/dist/src/zod.js CHANGED
@@ -787,6 +787,59 @@ function modelToZod(program, model) {
787
787
  : `z.object(${shape})${suffix}${strict}`;
788
788
  return applyConstraints(program, body, model);
789
789
  }
790
+ /**
791
+ * **What JSON kind a value of this type arrives as, for a parameter carried in text.**
792
+ *
793
+ * A path, query or header value is always a string on the wire, so a numeric or boolean parameter has
794
+ * to be decoded before the document's schema runs. Deciding WHICH decoder applies used to be done by
795
+ * inspecting the emitted Zod - `declared.startsWith("z.number()")` - which is a string about a string
796
+ * and fails silently the moment the expression is spelled any other way.
797
+ *
798
+ * It did. `@query size: 10 | 25 | 50` emits `z.union([z.literal(10), ...])`, which starts with
799
+ * neither, so no decoder was wrapped around it and the validator required the NUMBER 25 from a wire
800
+ * that can only deliver `"25"`. Measured: `?size=25` rejected, `size: 25` accepted. Every conformant
801
+ * caller got a 400, and no document comparison could see it, because both sides agree the parameter
802
+ * is a number - the disagreement is with the wire.
803
+ *
804
+ * Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
805
+ * single list of what becomes a number, so this cannot drift from what `scalarToZod` emits.
806
+ */
807
+ export function wireKindOf(program, type) {
808
+ if (type.kind === "Scalar") {
809
+ const encoded = encodedTypeOf(program, type);
810
+ if (encoded !== undefined && encoded !== type)
811
+ return wireKindOf(program, encoded);
812
+ for (let current = type; current !== undefined; current = current.baseScalar) {
813
+ const mapped = SCALARS[current.name];
814
+ if (mapped === undefined)
815
+ continue;
816
+ if (mapped.startsWith("z.number()"))
817
+ return "number";
818
+ if (mapped === "z.boolean()")
819
+ return "boolean";
820
+ return undefined;
821
+ }
822
+ return undefined;
823
+ }
824
+ // A literal is its own type, and a union of them is the enum form a parameter commonly takes.
825
+ if (type.kind === "Number")
826
+ return "number";
827
+ if (type.kind === "Boolean")
828
+ return "boolean";
829
+ if (type.kind === "Union") {
830
+ const kinds = new Set();
831
+ for (const variant of type.variants.values()) {
832
+ // `T | null` is still a T on the wire; the null arm needs no decoding.
833
+ if (variant.type.kind === "Intrinsic" && variant.type.name === "null")
834
+ continue;
835
+ kinds.add(wireKindOf(program, variant.type));
836
+ }
837
+ if (kinds.size !== 1)
838
+ return undefined;
839
+ return [...kinds][0];
840
+ }
841
+ return undefined;
842
+ }
790
843
  /**
791
844
  * A name as an object key - quoted when it is not a legal JavaScript identifier.
792
845
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "TypeSpec emitter and library: turn an HTTP service definition into Zod validators and framework-free contract types that agree, keyword for keyword, with the OpenAPI document @typespec/openapi3 publishes from the same source.",
5
5
  "keywords": [
6
6
  "codegen",