typespec-http-zod 0.26.0 → 0.28.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.
@@ -15,6 +15,27 @@ import { type Program, type Type } from "@typespec/compiler";
15
15
  * asserts that no emitted file carries a `.refine(` outside the one multipart-file carve-out.
16
16
  */
17
17
  export declare function applyConstraints(program: Program, expression: string, target: Type): string;
18
+ /**
19
+ * **Whether a `@pattern` can be proven safe against catastrophic backtracking.**
20
+ *
21
+ * A pure verdict, with no program and no report. The judgement belongs to the
22
+ * `redos-prone-pattern` LINTER rule rather than to emission, because the spec is valid, the
23
+ * document is correct and the emitted regex is exactly the published one: what is wrong is a cost
24
+ * no artefact states, which is the case TypeSpec reserves for a linter.
25
+ *
26
+ * **The engine is the one Zod runs on**, so the analysis carries whichever flag
27
+ * {@link patternToRegex} will emit. Analysing `\p{L}+` with `unicode: false` asks about a different
28
+ * expression from the one that runs - the identity escape for `p` - and a verdict about the wrong
29
+ * pattern is worth nothing whichever way it comes out.
30
+ *
31
+ * **`false` means "not proven safe", not "proven dangerous"**, and the rule's wording follows that.
32
+ * `redos-detector` proves safety rather than guessing at danger, so a pattern it cannot decide inside
33
+ * the step budget is judged the same way as one it decides against. Measured over a realistic spread of
34
+ * 14 patterns an API would carry: 2 conservative warnings, both on a quantifier nested over an
35
+ * overlapping class, and nothing dangerous missed - including `^\w+([.-]?\w+)*$`, which reads as
36
+ * ordinary and takes **11 seconds** on a 29-character input.
37
+ */
38
+ export declare function patternIsProvablySafe(pattern: string): boolean;
18
39
  /**
19
40
  * A `@pattern` string becomes a regex literal - **verbatim**.
20
41
  *
@@ -37,3 +58,28 @@ export declare function applyConstraints(program: Program, expression: string, t
37
58
  * instead of guessing at an equivalence.
38
59
  */
39
60
  export declare function patternToRegex(pattern: string): string;
61
+ /**
62
+ * Whether the expression is one the `u` flag accepts, which is how a JSON Schema reader compiles it.
63
+ *
64
+ * **The flag is part of the contract, and leaving it off was the divergence.** A bare `/.../` makes
65
+ * `\p{L}` an identity escape for the letter `p`, so the expression the server ran was not the
66
+ * expression the spec author wrote. Measured against Ajv on the published document, with every
67
+ * verdict inverted:
68
+ *
69
+ * ```
70
+ * value document (Ajv) emitted validator
71
+ * "abc" VALID INVALID
72
+ * "Unicode" VALID INVALID
73
+ * "p{L}" INVALID VALID
74
+ * ```
75
+ *
76
+ * Ajv compiles a JSON Schema `pattern` with `u` by default, so carrying it here is not a rule laid
77
+ * on top of the contract; it is the contract.
78
+ *
79
+ * **Only when the pattern compiles under it.** `u` makes several ordinary spellings an error - a
80
+ * redundant `\-` outside a character class, a lone `]` or `{`, an identity escape such as `\a` - and
81
+ * 5 of 15 realistic patterns tested fail to compile with it. Those keep the bare form they already
82
+ * had, and {@link reportNonUnicodePattern} says so at build time, because a reader of the document
83
+ * cannot compile them either.
84
+ */
85
+ export declare function unicodeSafe(pattern: string): boolean;
@@ -1,4 +1,6 @@
1
- import { getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
1
+ import { getEncode, getMaxItems, getMaxLength, getMaxValue, getMinItems, getMinLength, getMinValue, getMinValueExclusive, getMaxValueExclusive, getPattern, } from "@typespec/compiler";
2
+ import { defaultMaxSteps, isSafePattern } from "redos-detector";
3
+ import { reportDiagnostic } from "./lib.js";
2
4
  /**
3
5
  * Constraint decorators -> the Zod modifiers that enforce them.
4
6
  *
@@ -23,26 +25,169 @@ export function applyConstraints(program, expression, target) {
23
25
  const minItems = getMinItems(program, target);
24
26
  const maxItems = getMaxItems(program, target);
25
27
  const pattern = getPattern(program, target);
28
+ const minExclusive = getMinValueExclusive(program, target);
29
+ const maxExclusive = getMaxValueExclusive(program, target);
30
+ /**
31
+ * **A numeric bound says nothing about a value carried as text.** `@encode(string)` is legal on a
32
+ * numeric property and makes the wire value a string, so the expression here is `z.string()` while
33
+ * `@minValue` is still a statement about the number it spells. Folding the two together emitted a
34
+ * LENGTH check: `@minValue(1)` accepted `"0"`, and `@minValue(100)` refused `"150"`. The exclusive
35
+ * form was worse - `z.string().gt(0)` is not a function, so the module threw on import and took
36
+ * the whole service down with it.
37
+ *
38
+ * It cannot be enforced either, and not for want of a mechanism: the document publishes `minimum`
39
+ * on a `type: string` schema, which JSON Schema ignores, so a validator that checked it would
40
+ * refuse payloads the published contract accepts. That is the same defect in the other direction,
41
+ * and this package's rule is that the two artefacts agree. So the bound is dropped and reported.
42
+ */
43
+ const encodedAsText = encodesAsString(program, target);
44
+ const numeric = [minValue, maxValue, minExclusive, maxExclusive].some((bound) => bound !== undefined);
45
+ if (encodedAsText && numeric) {
46
+ reportDiagnostic(program, {
47
+ code: "unenforceable-encoded-bound",
48
+ format: {
49
+ name: target.kind === "ModelProperty" || target.kind === "Scalar" ? target.name : "",
50
+ },
51
+ target,
52
+ });
53
+ }
26
54
  // `@minLength`/`@minValue`/`@minItems` all land on Zod's `.min()`; they are distinguished by the
27
- // type they are legal on, which TypeSpec has already checked by the time we get here.
28
- const min = minLength ?? minValue ?? minItems;
29
- const max = maxLength ?? maxValue ?? maxItems;
55
+ // type they are legal on, which TypeSpec has already checked by the time we get here - except
56
+ // under `@encode(string)`, where the type they are legal on is not the type emitted.
57
+ const min = minLength ?? (encodedAsText ? undefined : minValue) ?? minItems;
58
+ const max = maxLength ?? (encodedAsText ? undefined : maxValue) ?? maxItems;
30
59
  if (min !== undefined)
31
60
  result += `.min(${min})`;
32
61
  if (max !== undefined)
33
62
  result += `.max(${max})`;
34
63
  // Exclusive bounds are a distinct question from inclusive ones - `z.number().positive()` is
35
64
  // `.gt(0)`, and emitting `.min(0)` for it would accept a zero share count or a zero spot rate.
36
- const minExclusive = getMinValueExclusive(program, target);
37
- const maxExclusive = getMaxValueExclusive(program, target);
38
- if (minExclusive !== undefined)
65
+ if (!encodedAsText && minExclusive !== undefined)
39
66
  result += `.gt(${minExclusive})`;
40
- if (maxExclusive !== undefined)
67
+ if (!encodedAsText && maxExclusive !== undefined)
41
68
  result += `.lt(${maxExclusive})`;
42
- if (pattern !== undefined)
69
+ if (pattern !== undefined) {
70
+ reportNonUnicodePattern(program, pattern, target);
43
71
  result += `.regex(${patternToRegex(pattern)})`;
72
+ }
44
73
  return result;
45
74
  }
75
+ /**
76
+ * Whether the value this type puts on the wire is text because `@encode` says so. Read from the
77
+ * property and then from its scalar, which is where an encoding declared once for a named scalar
78
+ * lives.
79
+ */
80
+ function encodesAsString(program, target) {
81
+ if (target.kind !== "ModelProperty" && target.kind !== "Scalar")
82
+ return false;
83
+ const own = getEncode(program, target);
84
+ const scalar = own === undefined && target.kind === "ModelProperty" && target.type.kind === "Scalar"
85
+ ? getEncode(program, target.type)
86
+ : undefined;
87
+ return (own ?? scalar)?.type.name === "string";
88
+ }
89
+ /**
90
+ * How much analysis one pattern gets before the answer is "could not prove it": a number of steps.
91
+ *
92
+ * **Bounded, because the analysis is itself exponential in the bad case, and bounded in STEPS,
93
+ * because a verdict must not depend on the machine.** It was 500 ms of wall-clock time, and on a
94
+ * loaded CI host `^([0-9a-f]{40}|[0-9a-f]{64})$` took 93 to 403 ms and once ran out: the same spec
95
+ * warned on one build and not the next. A step count is the same everywhere. `redos-detector`'s own
96
+ * default; measured, `^(a|a)+$` spends it in 1.7 seconds idle and 6 under load, and every ordinary
97
+ * pattern decides in milliseconds. A pattern that cannot be decided inside it is one this emitter
98
+ * cannot certify, which is what the warning says.
99
+ */
100
+ const ANALYSIS_STEPS = defaultMaxSteps;
101
+ /**
102
+ * Patterns already judged, keyed on the text, which is the only input the verdict depends on.
103
+ *
104
+ * The analysis costs up to {@link ANALYSIS_STEPS} steps a pattern, and one pattern text commonly
105
+ * recurs across declarations - `^\\d{4}-\\d{2}-\\d{2}$` appears on several scalars in more than one
106
+ * consumer spec - so each distinct text is analysed once per process.
107
+ */
108
+ const verdicts = new Map();
109
+ /**
110
+ * Which declarations have already been reported, per program.
111
+ *
112
+ * **Per PROGRAM, and that distinction is the whole reason this is a `WeakMap`.** One scalar carrying
113
+ * a pattern is reached once per USE - the reference service reaches a single scalar 153 times - and
114
+ * three identical warnings pointing at one declaration are noise a reader learns to skip. But a
115
+ * module-level "already said that" would also silence the second compile in a watch session, or the
116
+ * second service in one program, which is a warning lost rather than a warning tidied. Keyed on the
117
+ * program, it is emptied whenever the program is.
118
+ *
119
+ * The verdict cache above is different and is deliberately global: it is a pure function of the
120
+ * pattern text, so it cannot be wrong for a later program.
121
+ */
122
+ const reported = new WeakMap();
123
+ /**
124
+ * **Whether this diagnostic has already been raised against this declaration.**
125
+ *
126
+ * Keyed on the code as well as the target. Keying on the target alone once made the memo shared
127
+ * between every pattern diagnostic, so whichever ran first claimed the declaration and silenced
128
+ * the rest: measured on `^(\w+\s?)*\-$`, one warning where two were due. The ReDoS judgement has
129
+ * since moved to a linter rule, which reports per declaration by construction, so that pairing can
130
+ * no longer meet here - but a memo keyed on the target alone is the wrong shape for the next
131
+ * diagnostic added beside this one, and costs nothing to keep right.
132
+ *
133
+ * The de-duplication itself is per declaration, because emission reaches a scalar once per USE -
134
+ * 153 times for a single scalar in the reference service - and repeating one warning that often
135
+ * is noise a reader learns to skip, which is how a real one gets missed.
136
+ */
137
+ function firstReportOf(program, code, target) {
138
+ let byCode = reported.get(program);
139
+ if (byCode === undefined) {
140
+ byCode = new Map();
141
+ reported.set(program, byCode);
142
+ }
143
+ let already = byCode.get(code);
144
+ if (already === undefined) {
145
+ already = new WeakSet();
146
+ byCode.set(code, already);
147
+ }
148
+ if (already.has(target))
149
+ return false;
150
+ already.add(target);
151
+ return true;
152
+ }
153
+ /**
154
+ * **Whether a `@pattern` can be proven safe against catastrophic backtracking.**
155
+ *
156
+ * A pure verdict, with no program and no report. The judgement belongs to the
157
+ * `redos-prone-pattern` LINTER rule rather than to emission, because the spec is valid, the
158
+ * document is correct and the emitted regex is exactly the published one: what is wrong is a cost
159
+ * no artefact states, which is the case TypeSpec reserves for a linter.
160
+ *
161
+ * **The engine is the one Zod runs on**, so the analysis carries whichever flag
162
+ * {@link patternToRegex} will emit. Analysing `\p{L}+` with `unicode: false` asks about a different
163
+ * expression from the one that runs - the identity escape for `p` - and a verdict about the wrong
164
+ * pattern is worth nothing whichever way it comes out.
165
+ *
166
+ * **`false` means "not proven safe", not "proven dangerous"**, and the rule's wording follows that.
167
+ * `redos-detector` proves safety rather than guessing at danger, so a pattern it cannot decide inside
168
+ * the step budget is judged the same way as one it decides against. Measured over a realistic spread of
169
+ * 14 patterns an API would carry: 2 conservative warnings, both on a quantifier nested over an
170
+ * overlapping class, and nothing dangerous missed - including `^\w+([.-]?\w+)*$`, which reads as
171
+ * ordinary and takes **11 seconds** on a 29-character input.
172
+ */
173
+ export function patternIsProvablySafe(pattern) {
174
+ let safe = verdicts.get(pattern);
175
+ if (safe === undefined) {
176
+ try {
177
+ safe = isSafePattern(pattern, {
178
+ unicode: unicodeSafe(pattern),
179
+ maxSteps: ANALYSIS_STEPS,
180
+ timeout: Number.POSITIVE_INFINITY,
181
+ }).safe;
182
+ }
183
+ catch {
184
+ // A pattern the analyser cannot even parse is not one to make a claim about.
185
+ safe = true;
186
+ }
187
+ verdicts.set(pattern, safe);
188
+ }
189
+ return safe;
190
+ }
46
191
  /**
47
192
  * A `@pattern` string becomes a regex literal - **verbatim**.
48
193
  *
@@ -65,5 +210,87 @@ export function applyConstraints(program, expression, target) {
65
210
  * instead of guessing at an equivalence.
66
211
  */
67
212
  export function patternToRegex(pattern) {
68
- return `/${pattern.replace(/\//g, "\\/")}/`;
213
+ return `/${escapeSlashes(pattern)}/${unicodeSafe(pattern) ? "u" : ""}`;
214
+ }
215
+ /**
216
+ * **Escapes the slashes a regex literal needs escaped, and only those.**
217
+ *
218
+ * A `.replace(/\//g, "\\/")` escapes unconditionally, so a pattern that already spelled a slash
219
+ * as an escape had its backslash doubled: `^\/api\/v[0-9]+$` became the literal
220
+ * `/^\\/api\\/v[0-9]+$/u`, which ends at the third slash and reads `api` as flags. Measured on
221
+ * that pattern: `tsp compile` EXIT=0 and silent, `node` refused the emitted module with
222
+ * `SyntaxError: Invalid regular expression flags`, and `tsc` reported `TS1127: Invalid character`.
223
+ * A path regex is the most ordinary place a slash appears, and no fixture here had ever carried
224
+ * one - nor does any of the 67 specs in `@typespec/http-specs`.
225
+ *
226
+ * A backslash takes the character after it with it, so `\/` is already escaped and is left alone,
227
+ * while `\\` is a literal backslash and a slash following it still needs escaping. `\/` remains a
228
+ * legal identity escape under the `u` flag, so this does not change which patterns can carry it.
229
+ */
230
+ function escapeSlashes(pattern) {
231
+ let escaped = "";
232
+ for (let index = 0; index < pattern.length; index += 1) {
233
+ const character = pattern[index];
234
+ if (character === "\\") {
235
+ escaped += character + (pattern[index + 1] ?? "");
236
+ index += 1;
237
+ continue;
238
+ }
239
+ escaped += character === "/" ? "\\/" : character;
240
+ }
241
+ return escaped;
242
+ }
243
+ /**
244
+ * Whether the expression is one the `u` flag accepts, which is how a JSON Schema reader compiles it.
245
+ *
246
+ * **The flag is part of the contract, and leaving it off was the divergence.** A bare `/.../` makes
247
+ * `\p{L}` an identity escape for the letter `p`, so the expression the server ran was not the
248
+ * expression the spec author wrote. Measured against Ajv on the published document, with every
249
+ * verdict inverted:
250
+ *
251
+ * ```
252
+ * value document (Ajv) emitted validator
253
+ * "abc" VALID INVALID
254
+ * "Unicode" VALID INVALID
255
+ * "p{L}" INVALID VALID
256
+ * ```
257
+ *
258
+ * Ajv compiles a JSON Schema `pattern` with `u` by default, so carrying it here is not a rule laid
259
+ * on top of the contract; it is the contract.
260
+ *
261
+ * **Only when the pattern compiles under it.** `u` makes several ordinary spellings an error - a
262
+ * redundant `\-` outside a character class, a lone `]` or `{`, an identity escape such as `\a` - and
263
+ * 5 of 15 realistic patterns tested fail to compile with it. Those keep the bare form they already
264
+ * had, and {@link reportNonUnicodePattern} says so at build time, because a reader of the document
265
+ * cannot compile them either.
266
+ */
267
+ export function unicodeSafe(pattern) {
268
+ try {
269
+ new RegExp(pattern, "u");
270
+ return true;
271
+ }
272
+ catch {
273
+ return false;
274
+ }
275
+ }
276
+ /**
277
+ * Warn when a `@pattern` is one a JSON Schema reader cannot compile at all.
278
+ *
279
+ * Separate from the `redos-prone-pattern` linter rule because the failure runs the other way: that
280
+ * one is about a pattern the validator runs too slowly, this one about a pattern nothing READING
281
+ * the document can run. And this one stays a diagnostic, because here the emitter cannot do what the
282
+ * spec asks: it falls back to a different expression from the one it publishes. Measured, Ajv on a published `^\-?\d+$`:
283
+ * `Invalid regular expression: /^\-?\d+$/u: Invalid escape`. The emitted validator falls back to the
284
+ * bare form and enforces it anyway, so the two artefacts stop agreeing without either one failing.
285
+ *
286
+ * De-duplicated per declaration through {@link firstReportOf}, which keys on the diagnostic as
287
+ * well as the declaration. Sharing one memo between the two warnings meant a declaration with
288
+ * both faults reported only whichever ran first.
289
+ */
290
+ function reportNonUnicodePattern(program, pattern, target) {
291
+ if (unicodeSafe(pattern))
292
+ return;
293
+ if (!firstReportOf(program, "non-unicode-pattern", target))
294
+ return;
295
+ reportDiagnostic(program, { code: "non-unicode-pattern", format: { pattern }, target });
69
296
  }
@@ -8,3 +8,6 @@
8
8
  */
9
9
  export * from "./api.js";
10
10
  export { $onEmit } from "./emitter.js";
11
+ export { $linter } from "./linter.js";
12
+ export { redosPronePatternRule } from "./rules/redos-prone-pattern.rule.js";
13
+ export { routeTemplateOf } from "./uri-template.js";
package/dist/src/index.js CHANGED
@@ -8,3 +8,6 @@
8
8
  */
9
9
  export * from "./api.js";
10
10
  export { $onEmit } from "./emitter.js";
11
+ export { $linter } from "./linter.js";
12
+ export { redosPronePatternRule } from "./rules/redos-prone-pattern.rule.js";
13
+ export { routeTemplateOf } from "./uri-template.js";
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.