typespec-http-zod 0.4.0 → 0.5.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.js CHANGED
@@ -1389,11 +1389,32 @@ export async function emitHttpZod(context, wrapper = {}) {
1389
1389
  * carrying internal models if you are not paying attention. It is invisible while there is one
1390
1390
  * service, which is exactly why it is built this way before the second one lands rather than after.
1391
1391
  */
1392
+ /**
1393
+ * **Two `@service` namespaces wrote one `schemas.gen.ts`, and the second silently won.**
1394
+ *
1395
+ * Every service emitted into `emitterOutputDir` under the same file name, so a spec publishing an
1396
+ * internal surface and a public one produced a single file holding whichever was walked last.
1397
+ * Measured on two services with no per-service `output-dir`: zero diagnostics, one file, and the
1398
+ * first service's validators absent entirely. Nothing refused, nothing warned - a consumer would
1399
+ * find half their API had no validators at all.
1400
+ *
1401
+ * The rule is `@typespec/openapi3`'s, copied: its default output path is
1402
+ * `openapi.{service-name-if-multiple}...`, so a name is inserted only when there is more than one
1403
+ * service to tell apart. A single-service spec emits exactly where it always did.
1404
+ *
1405
+ * The directory is what carries it rather than the file name, because `EmittedService.outputDir`
1406
+ * is already the published seam a wrapping emitter writes its own files beside. Disambiguating
1407
+ * there means a server emitter follows without knowing this rule exists.
1408
+ */
1409
+ const distinctServices = new Set(snapshots.map((snapshot) => snapshot.service.namespace.name));
1392
1410
  for (const snapshot of snapshots) {
1393
1411
  const service = snapshot.service;
1394
1412
  const serviceName = service.namespace.name;
1395
1413
  const perService = options.services?.[serviceName];
1396
- const outputDir = perService?.["output-dir"] ?? context.emitterOutputDir;
1414
+ const outputDir = perService?.["output-dir"] ??
1415
+ (distinctServices.size > 1
1416
+ ? resolvePath(context.emitterOutputDir, serviceName)
1417
+ : context.emitterOutputDir);
1397
1418
  const resolved = {
1398
1419
  contractsOutputDir: perService?.["contracts-output-dir"] ?? options["contracts-output-dir"],
1399
1420
  contractsPackage: perService?.["contracts-package"] ?? options["contracts-package"],
package/dist/src/lib.d.ts CHANGED
@@ -104,7 +104,7 @@ export interface EmitterOptions {
104
104
  export declare const EmitterOptionsSchema: JSONSchemaType<EmitterOptions>;
105
105
  export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
106
106
  "unsupported-type": {
107
- readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
107
+ readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
108
108
  };
109
109
  "unsupported-default": {
110
110
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
@@ -124,7 +124,7 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
124
124
  }, EmitterOptions, never>;
125
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 {
126
126
  "unsupported-type": {
127
- readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
127
+ readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
128
128
  };
129
129
  "unsupported-default": {
130
130
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
@@ -143,7 +143,7 @@ export declare const reportDiagnostic: <C extends "empty-union" | "undeclared-di
143
143
  };
144
144
  }[C]>(program: import("@typespec/compiler").Program, diag: import("@typespec/compiler").DiagnosticReport<{
145
145
  "unsupported-type": {
146
- readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why"]>;
146
+ readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
147
147
  };
148
148
  "unsupported-default": {
149
149
  readonly default: import("@typespec/compiler").CallableMessage<["why"]>;
package/dist/src/lib.js CHANGED
@@ -65,13 +65,13 @@ const diagnostics = {
65
65
  "unsupported-type": {
66
66
  severity: "error",
67
67
  messages: {
68
- default: paramMessage `Cannot emit ${"artefact"} for ${"kind"}: ${"why"}.`,
68
+ default: paramMessage `Cannot emit ${"artefact"} for ${"kind"}: ${"why"}. Replace it with a type a value can inhabit. ${"remedy"}`,
69
69
  },
70
70
  },
71
71
  "unsupported-default": {
72
72
  severity: "error",
73
73
  messages: {
74
- default: paramMessage `Cannot emit a default value of ${"why"}. Scalars, arrays and objects are emitted as literals; a default built by a scalar constructor has no literal form. The property keeps its declared shape and loses only the fallback.`,
74
+ default: paramMessage `Cannot emit a default value of ${"why"}. Scalars, arrays and objects are emitted as literals; a default built by a scalar constructor has no literal form. Replace it with a literal value, or drop the default and let the property be optional. The property keeps its declared shape and loses only the fallback.`,
75
75
  },
76
76
  },
77
77
  /**
@@ -107,7 +107,7 @@ const diagnostics = {
107
107
  "empty-union": {
108
108
  severity: "error",
109
109
  messages: {
110
- default: paramMessage `A union with no representable variants (${"why"}) has nothing to validate against.`,
110
+ default: paramMessage `A union with no representable variants (${"why"}) has nothing to validate against. Declare at least one variant this emitter can walk, or replace the union with the single type it reduces to.`,
111
111
  },
112
112
  },
113
113
  /*
package/dist/src/types.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { getDiscriminatedUnion, getEncode, isNeverType, resolveEncodedName, } from "@typespec/compiler";
2
2
  import { reportDiagnostic } from "./lib.js";
3
- import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, objectKey, } from "./zod.js";
3
+ import { discriminatedSubtypes, effectiveIndexer, inheritedAndOwnProperties, objectKey, refusalTarget, withDeclarationSite, } from "./zod.js";
4
4
  /**
5
5
  * TypeSpec type -> plain TypeScript type text.
6
6
  *
@@ -53,12 +53,17 @@ const SCALARS = {
53
53
  * built on one cannot be used by accident, and the error diagnostic has already failed the compile.
54
54
  */
55
55
  const UNREPRESENTABLE = "never";
56
- /** Refuse `type`, pointing at its declaration, and carry on so the rest is reported too. */
57
- function refuse(program, type, why) {
56
+ /**
57
+ * Refuse `type`, pointing at somewhere the reader can go, and carry on so the rest is reported too.
58
+ *
59
+ * An intrinsic has no source node, so it is located by the declaration being walked instead - see
60
+ * `withDeclarationSite` in `zod.ts`, whose scope this walk shares.
61
+ */
62
+ function refuse(program, type, why, remedy = "") {
58
63
  reportDiagnostic(program, {
59
64
  code: "unsupported-type",
60
- target: type,
61
- format: { artefact: "a TypeScript type", kind: type.kind, why },
65
+ target: refusalTarget(type),
66
+ format: { artefact: "a TypeScript type", kind: type.kind, why, remedy },
62
67
  });
63
68
  return UNREPRESENTABLE;
64
69
  }
@@ -166,7 +171,12 @@ function modelToTs(program, model) {
166
171
  * as a bare `Record<string, unknown>` - a type that satisfies any assertion about what the response
167
172
  * contains, which is precisely the check this file exists to feed.
168
173
  */
169
- if (indexerKey === "string" && indexer !== undefined && declared.length === 0) {
174
+ // `Record<never>` states closedness rather than a value type - see `modelToZod`. A model that is
175
+ // only that is an object with no declared properties, not a dictionary of an unwalkable type.
176
+ if (indexerKey === "string" &&
177
+ indexer !== undefined &&
178
+ !isNeverType(indexer.value) &&
179
+ declared.length === 0) {
170
180
  return `Record<string, ${typeToTs(program, indexer.value)}>`;
171
181
  }
172
182
  /**
@@ -185,7 +195,7 @@ function modelToTs(program, model) {
185
195
  */
186
196
  const entries = declared
187
197
  .filter((property) => !isNeverType(property.type))
188
- .map((property) => `\t${propertyToTs(program, property)}`);
198
+ .map((property) => `\t${withDeclarationSite(property, () => propertyToTs(program, property))}`);
189
199
  /**
190
200
  * **The indexer is deliberately NOT intersected in.**
191
201
  *
@@ -279,10 +289,10 @@ export function typeToTsBody(program, type) {
279
289
  if (type.name === "unknown")
280
290
  return "unknown";
281
291
  if (type.name === "void" || type.name === "never") {
282
- return refuse(program, type, `\`${type.name}\` has no runtime representation`);
292
+ return refuse(program, type, `\`${type.name}\` has no runtime representation`, "Give the position a type a value can inhabit. A `never` PROPERTY is dropped to match the document, and `Record<never>` seals the model, so neither of those reaches here.");
283
293
  }
284
- return refuse(program, type, `intrinsic "${type.name}"`);
294
+ return refuse(program, type, `intrinsic "${type.name}"`, "This emitter has no rule for that intrinsic. Replace it with a declared scalar or model.");
285
295
  default:
286
- return refuse(program, type, "no rule for this kind");
296
+ return refuse(program, type, "no rule for this kind", "Replace it with a model, scalar, enum or union, which are the kinds this emitter walks.");
287
297
  }
288
298
  }
package/dist/src/zod.d.ts CHANGED
@@ -52,6 +52,14 @@ export declare function collectExternalImports<T>(walk: () => T): {
52
52
  readonly imports: Map<string, Set<string>>;
53
53
  readonly vocabularies: Map<string, readonly string[]>;
54
54
  };
55
+ export declare function withDeclarationSite<T>(site: Type | undefined, run: () => T): T;
56
+ /**
57
+ * Where a refusal should point.
58
+ *
59
+ * The offending type, when it has a source location of its own. Otherwise the declaration being
60
+ * walked, which does.
61
+ */
62
+ export declare function refusalTarget(type: Type): Type;
55
63
  export declare function withSyntheticDeclarations<T>(hook: (name: string, build: () => string) => string, run: () => T): T;
56
64
  /**
57
65
  * Every property the model declares, **including the ones it inherits**.
package/dist/src/zod.js CHANGED
@@ -275,12 +275,43 @@ export function collectExternalImports(walk) {
275
275
  vocabularies = previousVocabularies;
276
276
  }
277
277
  }
278
+ /**
279
+ * The declaration currently being walked, used to locate a refusal that has nowhere of its own.
280
+ *
281
+ * An intrinsic like `never` has no source node, so pointing a diagnostic at it prints
282
+ * `<unknown location>:1:1`. A consumer compiling a large spec got 32 byte-identical messages with
283
+ * nothing to grep for and no way to tell which declaration caused which. The property or model being
284
+ * walked when the intrinsic was reached is the thing they can actually go and edit.
285
+ *
286
+ * Scoped rather than threaded, matching the other ambient facts in this walk.
287
+ */
288
+ let declarationSite;
289
+ export function withDeclarationSite(site, run) {
290
+ const previous = declarationSite;
291
+ if (site !== undefined)
292
+ declarationSite = site;
293
+ try {
294
+ return run();
295
+ }
296
+ finally {
297
+ declarationSite = previous;
298
+ }
299
+ }
300
+ /**
301
+ * Where a refusal should point.
302
+ *
303
+ * The offending type, when it has a source location of its own. Otherwise the declaration being
304
+ * walked, which does.
305
+ */
306
+ export function refusalTarget(type) {
307
+ return type.node === undefined ? (declarationSite ?? type) : type;
308
+ }
278
309
  /** Refuse `type`, pointing at its declaration, and carry on so the rest is reported too. */
279
- function refuse(program, type, why) {
310
+ function refuse(program, type, why, remedy = "") {
280
311
  reportDiagnostic(program, {
281
312
  code: "unsupported-type",
282
- target: type,
283
- format: { artefact: "Zod", kind: type.kind, why },
313
+ target: refusalTarget(type),
314
+ format: { artefact: "Zod", kind: type.kind, why, remedy },
284
315
  });
285
316
  return UNREPRESENTABLE;
286
317
  }
@@ -474,7 +505,7 @@ function unionToZod(program, union) {
474
505
  if (discriminated.defaultVariant !== undefined) {
475
506
  // "Anything else" has no discriminator value to switch on. Refused by name rather than
476
507
  // silently dropped - no corpus scenario exercises it, so guessing would be untested code.
477
- return refuse(program, union, "a discriminated union with a default variant");
508
+ return refuse(program, union, "a discriminated union with a default variant", "Remove the default variant, or declare it as an ordinary variant with its own discriminator value.");
478
509
  }
479
510
  /**
480
511
  * **Refused, not compensated for.** With no envelope the discriminator lives inside the
@@ -633,7 +664,7 @@ function modelToZod(program, model) {
633
664
  * it once keyed on the model's *name*, and lost the indexer of anything derived from `Record`.
634
665
  */
635
666
  if (indexerKey === "string" && indexer !== undefined && declared.length === 0) {
636
- return applyConstraints(program, `z.record(z.string(), ${typeToZod(program, indexer.value)})`, model);
667
+ return applyConstraints(program, `z.record(z.string(), ${withDeclarationSite(model, () => typeToZod(program, indexer.value))})`, model);
637
668
  }
638
669
  /**
639
670
  * Permissiveness, read from the INDEXER and from nothing else.
@@ -646,12 +677,23 @@ function modelToZod(program, model) {
646
677
  * An indexer over something other than `unknown` is a typed catchall, where `.loose()` would drop
647
678
  * that value type on the floor.
648
679
  */
649
- const indexerValue = indexerKey === "string" ? indexer?.value : undefined;
680
+ /**
681
+ * **`Record<never>` is how a spec says "and nothing else", not a value type to walk.**
682
+ *
683
+ * `model A { ...Record<never>; name: string }` gives an indexer whose value is the `never`
684
+ * intrinsic. Read as an ordinary typed catchall it became `.catchall(z.never())`, which reached
685
+ * `typeToZod` and refused the whole compile - while `@typespec/openapi3` publishes the same model
686
+ * cleanly as `additionalProperties: {not: {}}`, with no diagnostic at all. `isSealed` already reads
687
+ * a `never` indexer as sealed; this stops the catchall branch consuming it first.
688
+ */
689
+ const indexerValue = indexerKey === "string" && indexer !== undefined && !isNeverType(indexer.value)
690
+ ? indexer.value
691
+ : undefined;
650
692
  const suffix = indexerValue !== undefined && isUnknownType(indexerValue)
651
693
  ? ".loose()"
652
694
  : indexerValue === undefined
653
695
  ? ""
654
- : `.catchall(${typeToZod(program, indexerValue)})`;
696
+ : `.catchall(${withDeclarationSite(model, () => typeToZod(program, indexerValue))})`;
655
697
  /**
656
698
  * A request model REJECTS an unrecognised field rather than stripping it.
657
699
  *
@@ -703,7 +745,7 @@ function modelToZod(program, model) {
703
745
  .filter((property) => !isNeverType(property.type))
704
746
  .map((property) => {
705
747
  const key = propertyKey(program, property);
706
- const { value, deferred } = captureBackEdges(() => propertyToZod(program, property));
748
+ const { value, deferred } = captureBackEdges(() => withDeclarationSite(property, () => propertyToZod(program, property)));
707
749
  return {
708
750
  deferred,
709
751
  text: deferred ? `\tget ${key}() {\n\t\treturn ${value};\n\t},` : `\t${key}: ${value},`,
@@ -1060,10 +1102,10 @@ export function typeToZodBody(program, type) {
1060
1102
  if (type.name === "unknown")
1061
1103
  return "z.unknown()";
1062
1104
  if (type.name === "void" || type.name === "never") {
1063
- return refuse(program, type, `\`${type.name}\` has no runtime representation`);
1105
+ return refuse(program, type, `\`${type.name}\` has no runtime representation`, "Give the position a type a value can inhabit. A `never` PROPERTY is dropped to match the document, and `Record<never>` seals the model, so neither of those reaches here.");
1064
1106
  }
1065
- return refuse(program, type, `intrinsic "${type.name}"`);
1107
+ return refuse(program, type, `intrinsic "${type.name}"`, "This emitter has no rule for that intrinsic. Replace it with a declared scalar or model.");
1066
1108
  default:
1067
- return refuse(program, type, "no rule for this kind");
1109
+ return refuse(program, type, "no rule for this kind", "Replace it with a model, scalar, enum or union, which are the kinds this emitter walks.");
1068
1110
  }
1069
1111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-zod",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",