typespec-http-zod 0.26.0 → 0.27.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/lib.d.ts CHANGED
@@ -70,8 +70,10 @@ export interface EmitterOptions {
70
70
  * `import "zod/compile"` does not: it compiles on first PARSE, which happens inside a request,
71
71
  * where the runtime refuses. Only the emitter owns the module scope these schemas are built in.
72
72
  *
73
- * A recursive model emits `z.lazy()`, which cannot be compiled. Zod returns it uncompiled and it
74
- * goes on parsing normally, so the option is safe to set on any spec.
73
+ * No member of a reference CYCLE is compiled. Compiling one that a `z.lazy()` body names throws
74
+ * `Cannot read properties of undefined (reading '_zod')` on the first parse from zod 4.6 onwards, so
75
+ * the whole cycle is left uncompiled and goes on parsing normally. The option stays safe to set on
76
+ * any spec.
75
77
  */
76
78
  "compile-schemas"?: boolean;
77
79
  /**
@@ -154,14 +156,23 @@ export declare const $lib: import("@typespec/compiler").TypeSpecLibrary<{
154
156
  "unsupported-status-code-range": {
155
157
  readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
156
158
  };
159
+ "non-unicode-pattern": {
160
+ readonly default: import("@typespec/compiler").CallableMessage<["pattern"]>;
161
+ };
162
+ "unenforceable-encoded-bound": {
163
+ readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
164
+ };
157
165
  "duplicate-operation-id": {
158
166
  readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
159
167
  };
168
+ "inline-cycle": {
169
+ readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
170
+ };
160
171
  "duplicate-declaration": {
161
172
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
162
173
  };
163
174
  }, EmitterOptions, never>;
164
- export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "truncated-doc-comment" | "undeclared-discriminator" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
175
+ export declare const reportDiagnostic: <C extends "default-on-required-property" | "duplicate-declaration" | "duplicate-operation-id" | "empty-union" | "inline-cycle" | "non-unicode-pattern" | "truncated-doc-comment" | "undeclared-discriminator" | "unenforceable-encoded-bound" | "unknown-key-vocabulary" | "unmirrorable-seal" | "unsupported-default" | "unsupported-status-code-range" | "unsupported-type", M extends keyof {
165
176
  "unsupported-type": {
166
177
  readonly default: import("@typespec/compiler").CallableMessage<["artefact", "kind", "why", "remedy"]>;
167
178
  };
@@ -189,9 +200,18 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
189
200
  "unsupported-status-code-range": {
190
201
  readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
191
202
  };
203
+ "non-unicode-pattern": {
204
+ readonly default: import("@typespec/compiler").CallableMessage<["pattern"]>;
205
+ };
206
+ "unenforceable-encoded-bound": {
207
+ readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
208
+ };
192
209
  "duplicate-operation-id": {
193
210
  readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
194
211
  };
212
+ "inline-cycle": {
213
+ readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
214
+ };
195
215
  "duplicate-declaration": {
196
216
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
197
217
  };
@@ -223,9 +243,18 @@ export declare const reportDiagnostic: <C extends "default-on-required-property"
223
243
  "unsupported-status-code-range": {
224
244
  readonly default: import("@typespec/compiler").CallableMessage<["start", "end"]>;
225
245
  };
246
+ "non-unicode-pattern": {
247
+ readonly default: import("@typespec/compiler").CallableMessage<["pattern"]>;
248
+ };
249
+ "unenforceable-encoded-bound": {
250
+ readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
251
+ };
226
252
  "duplicate-operation-id": {
227
253
  readonly default: import("@typespec/compiler").CallableMessage<["operationId"]>;
228
254
  };
255
+ "inline-cycle": {
256
+ readonly default: import("@typespec/compiler").CallableMessage<["type"]>;
257
+ };
229
258
  "duplicate-declaration": {
230
259
  readonly default: import("@typespec/compiler").CallableMessage<["name"]>;
231
260
  };
package/dist/src/lib.js CHANGED
@@ -267,6 +267,36 @@ const diagnostics = {
267
267
  * **This is the one collision worth refusing.** The derived case is representable and is emitted.
268
268
  * This one cannot be resolved without contradicting what the author explicitly wrote.
269
269
  */
270
+ /**
271
+ * **A pattern the document's own readers cannot compile.**
272
+ *
273
+ * A JSON Schema `pattern` is an ECMA-262 expression, and a reader compiles it with the Unicode
274
+ * flag - Ajv does by default. Several ordinary spellings are an error under that flag: a redundant
275
+ * `\-` outside a character class, a lone `]` or `{`, an identity escape such as `\a`.
276
+ * Measured: Ajv throws `Invalid regular expression: /^\-?\d+$/u` on a pattern the emitted
277
+ * validator runs perfectly well, so the server enforces a rule nothing reading the document can.
278
+ *
279
+ * Reported rather than rewritten, for the same reason as every other pattern diagnostic: openapi3
280
+ * publishes the expression verbatim and this package does not edit a published contract. The
281
+ * remedy is a character the flag accepts, and it is almost always a `\` that was never needed.
282
+ */
283
+ "non-unicode-pattern": {
284
+ severity: "warning",
285
+ messages: {
286
+ default: paramMessage `'${"pattern"}' is not a valid regular expression under the Unicode flag, which is how a JSON Schema reader compiles a published pattern: Ajv throws on it rather than evaluating it. The emitted validator falls back to running it without the flag, so this server enforces a rule no reader of its own document can. Remove the character the flag rejects: it is almost always a backslash that was never needed, such as a '\\-' outside a character class or an escape like '\\a', and otherwise a lone ']' or '{' that should be escaped.`,
287
+ },
288
+ },
289
+ /**
290
+ * **A diagnostic, not a linter rule**, because here the emitter cannot do what the spec asks: it
291
+ * drops a bound the author wrote. That is the case TypeSpec reserves for `$onValidate`-style
292
+ * diagnostics, where a linter is for a program that is correct but could be better.
293
+ */
294
+ "unenforceable-encoded-bound": {
295
+ severity: "warning",
296
+ messages: {
297
+ default: paramMessage `The bound on '${"name"}' cannot be enforced, and is not emitted. '@encode(string)' makes the wire value text, so '@minValue'/'@maxValue' would become a check on how many CHARACTERS it has - '@minValue(10)' would refuse '"42"' - and the exclusive form has no string equivalent at all. The document is no help either: it publishes 'minimum' on a 'type: string' schema, which JSON Schema ignores, so a validator enforcing the bound would refuse what the published contract accepts. Declare the bound as a '@pattern', which the document publishes and every caller can read, or remove '@encode(string)' and let the value be a number.`,
298
+ },
299
+ },
270
300
  "duplicate-operation-id": {
271
301
  severity: "error",
272
302
  messages: {
@@ -285,6 +315,29 @@ const diagnostics = {
285
315
  * namespace and a TypeScript module cannot, so keeping the first would publish a contract for a
286
316
  * type nothing checks, and picking either is a guess about which the author meant.
287
317
  */
318
+ /**
319
+ * **A cycle that never passes through a declaration has nothing to reference itself by.**
320
+ *
321
+ * A recursive MODEL is fine: it earns a declaration, and the back edge becomes a getter or a
322
+ * `z.lazy()` naming that declaration. A cycle closing through a type this emitter INLINES has no
323
+ * such name. A template instantiation is the reachable case - `declaredNameOf` returns undefined
324
+ * for one, matching how `@typespec/openapi3` inlines it - so `model Box<T> { next?: Box<T> }` used
325
+ * as `Box<string>` walked into itself forever. Measured before this existed:
326
+ * `RangeError: Maximum call stack size exceeded`, reported by the compiler as "Emitter crashed!
327
+ * This is a bug."
328
+ *
329
+ * **An error, and the same error openapi3 raises.** It answers the identical spec with
330
+ * `@typespec/openapi3/inline-cycle` at severity error and writes no document, so there is no
331
+ * contract for a validator to agree with even if one could be emitted. The remedy it names works
332
+ * here for the same reason: `@friendlyName` gives the instantiation a name, which makes it a
333
+ * declaration, which gives the back edge something to point at.
334
+ */
335
+ "inline-cycle": {
336
+ severity: "error",
337
+ messages: {
338
+ default: paramMessage `'${"type"}' closes a reference cycle without passing through anything this emitter can declare, so the cycle has no name to reference itself by. A template instantiation is inlined rather than declared, exactly as '@typespec/openapi3' inlines it, and that emitter refuses the same spec with its own 'inline-cycle' error. Give the type a name with '@friendlyName' so it becomes a declaration, or break the cycle.`,
339
+ },
340
+ },
288
341
  "duplicate-declaration": {
289
342
  severity: "error",
290
343
  messages: {
@@ -0,0 +1,15 @@
1
+ import { type LinterDefinition } from "@typespec/compiler";
2
+ /**
3
+ * **The advisory checks, opt-in, as TypeSpec expects them.**
4
+ *
5
+ * Enabled from a consumer's `tspconfig.yaml` with `linter: { extends: ["typespec-http-zod/recommended"] }`.
6
+ * A consumer of `typespec-hono` enables the same rule through that package's own ruleset instead,
7
+ * because it never loads this package as a TypeSpec library and so cannot name it there.
8
+ */
9
+ /**
10
+ * Typed explicitly through this package's own `@typespec/compiler` specifier. A checkout beside
11
+ * `typespec-hono` resolves two copies of the compiler, and an INFERRED declaration type can then
12
+ * name the other package's copy by a `.pnpm` path (`TS2883`, "likely not portable"), which is
13
+ * broken for everyone who installed differently. `typespec-hono` hit exactly that on its own linter.
14
+ */
15
+ export declare const $linter: LinterDefinition;
@@ -0,0 +1,23 @@
1
+ import { defineLinter } from "@typespec/compiler";
2
+ import { redosPronePatternRule } from "./rules/redos-prone-pattern.rule.js";
3
+ /**
4
+ * **The advisory checks, opt-in, as TypeSpec expects them.**
5
+ *
6
+ * Enabled from a consumer's `tspconfig.yaml` with `linter: { extends: ["typespec-http-zod/recommended"] }`.
7
+ * A consumer of `typespec-hono` enables the same rule through that package's own ruleset instead,
8
+ * because it never loads this package as a TypeSpec library and so cannot name it there.
9
+ */
10
+ /**
11
+ * Typed explicitly through this package's own `@typespec/compiler` specifier. A checkout beside
12
+ * `typespec-hono` resolves two copies of the compiler, and an INFERRED declaration type can then
13
+ * name the other package's copy by a `.pnpm` path (`TS2883`, "likely not portable"), which is
14
+ * broken for everyone who installed differently. `typespec-hono` hit exactly that on its own linter.
15
+ */
16
+ export const $linter = defineLinter({
17
+ rules: [redosPronePatternRule],
18
+ ruleSets: {
19
+ recommended: {
20
+ enable: { [`typespec-http-zod/${redosPronePatternRule.name}`]: true },
21
+ },
22
+ },
23
+ });
@@ -1,4 +1,4 @@
1
- import type { ModelProperty, Program, Type } from "@typespec/compiler";
1
+ import { type ModelProperty, type Program, type Type } from "@typespec/compiler";
2
2
  import { Visibility } from "@typespec/http";
3
3
  /**
4
4
  * Assigns each named TypeSpec model/enum a single Zod declaration, emitted in dependency order.
@@ -1,7 +1,8 @@
1
+ import { getFriendlyName, } from "@typespec/compiler";
1
2
  import { getVisibilitySuffix, Visibility } from "@typespec/http";
2
3
  import { reportDiagnostic } from "./lib.js";
3
4
  import { propertyToTs, propertyTypeToTs, typeToTsBody, withTsRefResolver } from "./types.js";
4
- import { captureBackEdges, currentVisibility, isOptionalAt, isTransformedBy, noteBackEdge, propertyToZod, typeToZodBody, withDeferredAnnotator, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
5
+ import { captureBackEdges, currentVisibility, isOptionalAt, isTransformedBy, noteBackEdge, propertyToZod, UNREPRESENTABLE, typeToZodBody, withDeferredAnnotator, withRefResolver, withSyntheticDeclarations, } from "./zod.js";
5
6
  /**
6
7
  * The name an ANONYMOUS payload model inherits from the single model it spreads.
7
8
  *
@@ -145,7 +146,7 @@ function safeDeclarationName(name) {
145
146
  return RESERVED_DECLARATION_NAMES.has(name) ? `${name}_` : name;
146
147
  }
147
148
  /** A type earns a name when the spec gave it one - anonymous shapes stay inline where they are used. */
148
- function declaredNameOf(type) {
149
+ function declaredNameOf(program, type) {
149
150
  if (type.kind === "Enum")
150
151
  return safeDeclarationName(type.name);
151
152
  /**
@@ -168,18 +169,29 @@ function declaredNameOf(type) {
168
169
  return spread === undefined ? undefined : safeDeclarationName(spread);
169
170
  }
170
171
  /**
171
- * **A template INSTANTIATION carries the template's name, and it is not a declaration.**
172
+ * **A template INSTANTIATION carries the template's name, and it is not a declaration - unless
173
+ * `@friendlyName` gives it one of its own.**
172
174
  *
173
175
  * `PublicSuccess<PublicCompanyDetail>` and `PublicSuccess<PublicOfficers>` are both called
174
176
  * `PublicSuccess`, so naming them would export eleven consts called `publicSuccessSchema` - the
175
- * file does not compile, and if it did, one shape would validate every response. `@typespec/openapi3`
176
- * inlines instantiations for the same reason (the public document has no `PublicSuccess*`
177
- * component), so both artefacts agree about what has a name and what does not.
177
+ * file does not compile, and if it did, one shape would validate every response.
178
+ * `@typespec/openapi3` inlines instantiations for the same reason.
179
+ *
180
+ * **But it stops inlining one that has been named, and this used to keep inlining it.** Its rule
181
+ * is `shouldInline` in `@typespec/openapi`, and the first thing that function does is
182
+ * `if (getFriendlyName(program, type)) return false`. Measured on
183
+ * `@friendlyName("{name}Box", T) model Box<T>` used as `Box<string>`: openapi3 publishes a
184
+ * component `stringBox` and refers to it with `$ref`, while this emitter wrote the object out
185
+ * inline. So the two artefacts disagreed about what has a name, which is the opposite of what
186
+ * this docblock used to claim, and `@friendlyName` - the remedy openapi3 names for an inline
187
+ * cycle, and the one {@link "inline-cycle"} names here - did nothing.
178
188
  *
179
189
  * Invisible until a spec uses a template; a paged-response envelope is the usual first one.
180
190
  */
181
- if (type.templateMapper !== undefined)
182
- return undefined;
191
+ if (type.templateMapper !== undefined) {
192
+ const friendly = getFriendlyName(program, type);
193
+ return friendly === undefined || friendly === "" ? undefined : safeDeclarationName(friendly);
194
+ }
183
195
  return safeDeclarationName(type.name);
184
196
  }
185
197
  const lowerFirst = (value) => value.charAt(0).toLowerCase() + value.slice(1);
@@ -269,6 +281,19 @@ export class SchemaRegistry {
269
281
  #inProgress = new Map();
270
282
  /** Keys on a cycle - every one needs a structural type rather than a `z.infer` alias. */
271
283
  #cyclic = new Set();
284
+ /**
285
+ * Inlined types currently being walked, by identity, against the number of declarations that were
286
+ * in flight when each was entered.
287
+ *
288
+ * **The count is what tells a real inline cycle from an ordinary recursive model.** `#inProgress`
289
+ * cannot serve on its own: it is keyed by declaration name and these types have none, which is
290
+ * the whole reason the cycle has nothing to point at. But `InnerModel[]` re-enters `Array<InnerModel>`
291
+ * on a perfectly ordinary recursion - array, then the model, then the model's own `children` - and
292
+ * that cycle closes through `InnerModel`, which IS a declaration and terminates it. Comparing the
293
+ * count is how this asks openapi3's question, `cycle.containsDeclaration`: if a declaration was
294
+ * entered since, the cycle has a name to close on and the walk may continue.
295
+ */
296
+ #inlineInProgress = new Map();
272
297
  constructor(program) {
273
298
  this.#program = program;
274
299
  }
@@ -278,9 +303,30 @@ export class SchemaRegistry {
278
303
  * what lets the generated file be plain `const` declarations with no forward references.
279
304
  */
280
305
  expressionFor(type) {
281
- const bare = declaredNameOf(type);
282
- if (bare === undefined)
283
- return this.#inline(type);
306
+ const bare = declaredNameOf(this.#program, type);
307
+ if (bare === undefined) {
308
+ /**
309
+ * **A back edge to an INLINED type is a cycle with no name to close it**, and following it
310
+ * is a stack overflow rather than a wrong answer: the compiler reports "Emitter crashed!
311
+ * This is a bug." Tracked by identity because there is no key to track it by.
312
+ */
313
+ const entered = this.#inlineInProgress.get(type);
314
+ if (entered !== undefined && this.#inProgress.size <= entered) {
315
+ reportDiagnostic(this.#program, {
316
+ code: "inline-cycle",
317
+ format: { type: "name" in type && type.name !== undefined ? String(type.name) : "" },
318
+ target: type,
319
+ });
320
+ return UNREPRESENTABLE;
321
+ }
322
+ this.#inlineInProgress.set(type, this.#inProgress.size);
323
+ try {
324
+ return this.#inline(type);
325
+ }
326
+ finally {
327
+ this.#inlineInProgress.delete(type);
328
+ }
329
+ }
284
330
  /**
285
331
  * **A type not reshaped at this visibility IS the canonical declaration - not a copy of it.**
286
332
  *
@@ -335,6 +381,31 @@ export class SchemaRegistry {
335
381
  ...(this.#cyclic.has(key) ? { structural: this.#structuralBodyOf(type) } : {}),
336
382
  ...(rendered.deferred ? { annotated: true } : {}),
337
383
  };
384
+ /**
385
+ * **Two different types can resolve to one identifier, and the module then does not parse.**
386
+ *
387
+ * The key is `(visibility, name, identity)`, so two models genuinely named the same in
388
+ * different namespaces are two entries - and both then emit `export const thingSchema`.
389
+ * Measured on one service declaring `Alpha.Thing` and `Beta.Thing`: the document published
390
+ * both components under their full names, the emitted module declared `thingSchema` twice,
391
+ * and `tsc` answered `TS2451: Cannot redeclare block-scoped variable` while `tsp compile`
392
+ * reported success.
393
+ *
394
+ * `duplicate-declaration` already existed for exactly this and already said exactly this, but
395
+ * only {@link TypeRegistry} raised it - so the contracts files were covered and
396
+ * `schemas.gen.ts`, which every consumer imports, was not. `@typespec/openapi3` reports the
397
+ * same class through `checkDuplicateTypeName`, and `@typespec/json-schema` through
398
+ * `duplicate-id`; neither resolves it for you, because only the author knows which name to
399
+ * change.
400
+ */
401
+ const collision = this.#order.find((candidate) => candidate.identifier === identifier && candidate.source !== declaration.source);
402
+ if (collision !== undefined) {
403
+ reportDiagnostic(this.#program, {
404
+ code: "duplicate-declaration",
405
+ format: { name },
406
+ target: type,
407
+ });
408
+ }
338
409
  this.#declarations.set(key, declaration);
339
410
  this.#order.push(declaration);
340
411
  this.#declareRelatives(type);
@@ -372,11 +443,18 @@ export class SchemaRegistry {
372
443
  * would be documented and never enforced.
373
444
  */
374
445
  expressionForProperty(property) {
375
- return withRefResolver((candidate) => {
376
- if (declaredNameOf(candidate) === undefined)
377
- return undefined;
378
- return this.expressionFor(candidate);
379
- }, () => propertyToZod(this.#program, property));
446
+ return withRefResolver(
447
+ /**
448
+ * **Every candidate goes through {@link expressionFor}, including the ones with no name.**
449
+ *
450
+ * This used to return `undefined` for an undeclared type, which sent it straight to
451
+ * `typeToZodBody` and past the only place a cycle is detected. A template instantiation is
452
+ * undeclared - `declaredNameOf` returns undefined for one, matching openapi3's inlining -
453
+ * so `model Box<T> { next?: Box<T> }` walked into itself until the stack ran out.
454
+ * `expressionFor` renders an undeclared type through `#inline` exactly as before, and now
455
+ * also notices when it is already doing so.
456
+ */
457
+ (candidate) => this.expressionFor(candidate), () => propertyToZod(this.#program, property));
380
458
  }
381
459
  /**
382
460
  * A declaration for a shape the spec never wrote, under a name the document publishes.
@@ -424,7 +502,7 @@ export class SchemaRegistry {
424
502
  * drift.
425
503
  */
426
504
  #structuralBodyOf(type) {
427
- return withTsRefResolver((candidate) => declaredNameOf(candidate), () => typeToTsBody(this.#program, type));
505
+ return withTsRefResolver((candidate) => declaredNameOf(this.#program, candidate), () => typeToTsBody(this.#program, type));
428
506
  }
429
507
  /**
430
508
  * Render `type` without consulting its own declaration, but letting nested types use theirs.
@@ -437,11 +515,18 @@ export class SchemaRegistry {
437
515
  * a nested occurrence resolve to the identifier while the body still renders as a body.
438
516
  */
439
517
  #inline(type) {
440
- return withRefResolver((candidate) => {
441
- if (declaredNameOf(candidate) === undefined)
442
- return undefined;
443
- return this.expressionFor(candidate);
444
- }, () => withDeferredAnnotator((property) => this.#deferredAnnotationOf(property), () => withSyntheticDeclarations((name, build) => this.#declareSynthetic(name, build), () => typeToZodBody(this.#program, type))));
518
+ return withRefResolver(
519
+ /**
520
+ * **Every candidate goes through {@link expressionFor}, including the ones with no name.**
521
+ *
522
+ * This used to return `undefined` for an undeclared type, which sent it straight to
523
+ * `typeToZodBody` and past the only place a cycle is detected. A template instantiation is
524
+ * undeclared - `declaredNameOf` returns undefined for one, matching openapi3's inlining -
525
+ * so `model Box<T> { next?: Box<T> }` walked into itself until the stack ran out.
526
+ * `expressionFor` renders an undeclared type through `#inline` exactly as before, and now
527
+ * also notices when it is already doing so.
528
+ */
529
+ (candidate) => this.expressionFor(candidate), () => withDeferredAnnotator((property) => this.#deferredAnnotationOf(property), () => withSyntheticDeclarations((name, build) => this.#declareSynthetic(name, build), () => typeToZodBody(this.#program, type))));
445
530
  }
446
531
  /**
447
532
  * The Zod type a property emitted as a getter is annotated with, or `undefined` for none.
@@ -461,7 +546,7 @@ export class SchemaRegistry {
461
546
  #deferredAnnotationOf(property) {
462
547
  if (property.defaultValue !== undefined)
463
548
  return undefined;
464
- const declared = withTsRefResolver((candidate) => declaredNameOf(candidate), () => propertyTypeToTs(this.#program, property));
549
+ const declared = withTsRefResolver((candidate) => declaredNameOf(this.#program, candidate), () => propertyTypeToTs(this.#program, property));
465
550
  const core = `z.ZodType<${declared}, ${declared}>`;
466
551
  const optional = property.optional || isOptionalAt(this.#program, property);
467
552
  return `: ${optional ? `z.ZodExactOptional<${core}>` : core}`;
@@ -538,13 +623,31 @@ export class TypeRegistry {
538
623
  #declarations = new Map();
539
624
  #order = [];
540
625
  #inProgress = new Map();
626
+ /**
627
+ * The same in-flight tracking {@link SchemaRegistry} keeps, for the same cycle and by the same
628
+ * rule. Guarding one walk and not the other left the Zod side reporting `inline-cycle` correctly
629
+ * and the TypeScript side still running off the stack immediately afterwards, which reads to a
630
+ * consumer as "Emitter crashed! This is a bug." rather than as the error that was raised.
631
+ * Reported only by the schema walk, so one spec does not produce the same complaint twice.
632
+ */
633
+ #inlineInProgress = new Map();
541
634
  constructor(program) {
542
635
  this.#program = program;
543
636
  }
544
637
  expressionFor(type) {
545
- const bare = declaredNameOf(type);
546
- if (bare === undefined)
547
- return this.#inline(type);
638
+ const bare = declaredNameOf(this.#program, type);
639
+ if (bare === undefined) {
640
+ const entered = this.#inlineInProgress.get(type);
641
+ if (entered !== undefined && this.#inProgress.size <= entered)
642
+ return "never";
643
+ this.#inlineInProgress.set(type, this.#inProgress.size);
644
+ try {
645
+ return this.#inline(type);
646
+ }
647
+ finally {
648
+ this.#inlineInProgress.delete(type);
649
+ }
650
+ }
548
651
  const at = visibilityFor(this.#program, type, currentVisibility());
549
652
  const key = keyFor(type, at);
550
653
  const name = nameAt(bare, at);
@@ -627,18 +730,14 @@ export class TypeRegistry {
627
730
  */
628
731
  /** `wireName` names an HTTP parameter as the wire does - see {@link propertyToTs}. */
629
732
  expressionForProperty(property, wireName) {
630
- return withTsRefResolver((candidate) => {
631
- if (declaredNameOf(candidate) === undefined)
632
- return undefined;
633
- return this.expressionFor(candidate);
634
- }, () => propertyToTs(this.#program, property, wireName));
733
+ return withTsRefResolver(
734
+ // Through expressionFor even when undeclared, so the cycle guard above is reachable.
735
+ (candidate) => this.expressionFor(candidate), () => propertyToTs(this.#program, property, wireName));
635
736
  }
636
737
  #inline(type) {
637
- return withTsRefResolver((candidate) => {
638
- if (declaredNameOf(candidate) === undefined)
639
- return undefined;
640
- return this.expressionFor(candidate);
641
- }, () => typeToTsBody(this.#program, type));
738
+ return withTsRefResolver(
739
+ // Through expressionFor even when undeclared, so the cycle guard above is reachable.
740
+ (candidate) => this.expressionFor(candidate), () => typeToTsBody(this.#program, type));
642
741
  }
643
742
  /** Every declaration, dependencies first. */
644
743
  declarations() {
@@ -0,0 +1,27 @@
1
+ import { type CallableMessage, type LinterRuleDefinition } from "@typespec/compiler";
2
+ /**
3
+ * **A `@pattern` a backtracking engine can be made to spend unbounded time on.**
4
+ *
5
+ * **A linter rule, because TypeSpec's own definition puts it there.** A diagnostic says the program
6
+ * is not valid for this library; a linter says it "could be correct, but there might be room for
7
+ * improvements", and has to be enabled explicitly. Here the spec is valid, the document is correct,
8
+ * and the emitted regex is exactly the one `@typespec/openapi3` publishes. What is wrong is a cost no
9
+ * artefact states. `@typespec/http` puts its own advisory rule in a linter for the same reason.
10
+ *
11
+ * **This is a server-side denial of service, not a style note.** Measured on a generated server
12
+ * under `workerd`: `@pattern("^(\w+\s?)*$")`, which reads as "words", answers a 31-byte query
13
+ * parameter in 7.8 seconds against 2.9 milliseconds for a conformant one, and the cost doubles per
14
+ * added byte. The caller needs no credential and the parameter is validated before any handler runs.
15
+ * That is why the package's `recommended` ruleset enables it, and why the documentation says to.
16
+ *
17
+ * **The emitted regex is not changed, and cannot be.** Anchoring or bounding it here would make the
18
+ * validator enforce something the document does not state. What is left is to say so while the author
19
+ * can still change the spec.
20
+ *
21
+ * **Reported once per DECLARATION.** A linter walks declarations rather than uses, so a scalar used
22
+ * in 153 places is one report - and an instantiated template's properties share their declaration's
23
+ * node, which is what the de-duplication keys on.
24
+ */
25
+ export declare const redosPronePatternRule: LinterRuleDefinition<"redos-prone-pattern", {
26
+ readonly default: CallableMessage<["pattern"]>;
27
+ }>;
@@ -0,0 +1,47 @@
1
+ import { createRule, getPattern, paramMessage, } from "@typespec/compiler";
2
+ import { patternIsProvablySafe } from "../constraints.js";
3
+ /**
4
+ * **A `@pattern` a backtracking engine can be made to spend unbounded time on.**
5
+ *
6
+ * **A linter rule, because TypeSpec's own definition puts it there.** A diagnostic says the program
7
+ * is not valid for this library; a linter says it "could be correct, but there might be room for
8
+ * improvements", and has to be enabled explicitly. Here the spec is valid, the document is correct,
9
+ * and the emitted regex is exactly the one `@typespec/openapi3` publishes. What is wrong is a cost no
10
+ * artefact states. `@typespec/http` puts its own advisory rule in a linter for the same reason.
11
+ *
12
+ * **This is a server-side denial of service, not a style note.** Measured on a generated server
13
+ * under `workerd`: `@pattern("^(\w+\s?)*$")`, which reads as "words", answers a 31-byte query
14
+ * parameter in 7.8 seconds against 2.9 milliseconds for a conformant one, and the cost doubles per
15
+ * added byte. The caller needs no credential and the parameter is validated before any handler runs.
16
+ * That is why the package's `recommended` ruleset enables it, and why the documentation says to.
17
+ *
18
+ * **The emitted regex is not changed, and cannot be.** Anchoring or bounding it here would make the
19
+ * validator enforce something the document does not state. What is left is to say so while the author
20
+ * can still change the spec.
21
+ *
22
+ * **Reported once per DECLARATION.** A linter walks declarations rather than uses, so a scalar used
23
+ * in 153 places is one report - and an instantiated template's properties share their declaration's
24
+ * node, which is what the de-duplication keys on.
25
+ */
26
+ export const redosPronePatternRule = createRule({
27
+ name: "redos-prone-pattern",
28
+ severity: "warning",
29
+ description: "Flag a @pattern that cannot be proven safe against catastrophic backtracking.",
30
+ messages: {
31
+ default: paramMessage `'${"pattern"}' cannot be proven safe against catastrophic backtracking: one input can match it more than one way, so a caller can choose a value that takes exponentially long to reject. The emitted validator runs it on every request, before any handler, and the pattern is published verbatim so it cannot be rewritten for you. Remove the ambiguity, usually by taking out a quantifier nested inside another or an optional separator between repeated groups, or add a SMALL '@maxLength' to bound the work.`,
32
+ },
33
+ create(context) {
34
+ const seen = new WeakSet();
35
+ const check = (target) => {
36
+ const pattern = getPattern(context.program, target);
37
+ if (pattern === undefined || patternIsProvablySafe(pattern))
38
+ return;
39
+ const declaration = target.node ?? target;
40
+ if (seen.has(declaration))
41
+ return;
42
+ seen.add(declaration);
43
+ context.reportDiagnostic({ format: { pattern }, target });
44
+ };
45
+ return { scalar: check, modelProperty: check };
46
+ },
47
+ });
package/dist/src/zod.d.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  import { type Enum, type Model, type ModelProperty, type Program, type Type } from "@typespec/compiler";
2
2
  import { Visibility } from "@typespec/http";
3
+ /**
4
+ * What a walk returns after reporting a refusal.
5
+ *
6
+ * **Reporting does not unwind, which is the point.** A throw abandoned the compile at the first
7
+ * unrepresentable construct; reporting lets the walk carry on and name every one of them in a single
8
+ * run. The walk still has to return a `string`, and `z.never()` is the honest placeholder: valid
9
+ * Zod, rejects everything, and unreachable in practice because the error diagnostic has already
10
+ * failed the compile that would have written it.
11
+ */
12
+ export declare const UNREPRESENTABLE = "z.never()";
3
13
  export declare function withDeferredAnnotator<T>(annotator: (property: ModelProperty) => string | undefined, run: () => T): T;
4
14
  export declare function withSealedObjects<T>(sealed: boolean, run: () => T): T;
5
15
  export declare function withVisibility<T>(program: Program, next: Visibility, run: () => T): T;
@@ -98,23 +108,6 @@ export declare function effectiveIndexer(model: Model): Model["indexer"];
98
108
  * for a real subtype - the same rule that decides whether a base may be sealed.
99
109
  */
100
110
  export declare function discriminatedSubtypes(program: Program, model: Model): readonly Model[];
101
- /**
102
- * **What JSON kind a value of this type arrives as, for a parameter carried in text.**
103
- *
104
- * A path, query or header value is always a string on the wire, so a numeric or boolean parameter has
105
- * to be decoded before the document's schema runs. Deciding WHICH decoder applies used to be done by
106
- * inspecting the emitted Zod - `declared.startsWith("z.number()")` - which is a string about a string
107
- * and fails silently the moment the expression is spelled any other way.
108
- *
109
- * It did. `@query size: 10 | 25 | 50` emits `z.union([z.literal(10), ...])`, which starts with
110
- * neither, so no decoder was wrapped around it and the validator required the NUMBER 25 from a wire
111
- * that can only deliver `"25"`. Measured: `?size=25` rejected, `size: 25` accepted. Every conformant
112
- * caller got a 400, and no document comparison could see it, because both sides agree the parameter
113
- * is a number - the disagreement is with the wire.
114
- *
115
- * Reading the TYPE answers the question the emitted text was standing in for. `SCALARS` remains the
116
- * single list of what becomes a number, so this cannot drift from what `scalarToZod` emits.
117
- */
118
111
  export declare function wireKindOf(program: Program, type: Type): "number" | "boolean" | undefined;
119
112
  /**
120
113
  * A name as an object key - quoted when it is not a legal JavaScript identifier.