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.
- package/README.md +6 -6
- package/dist/src/api.d.ts +63 -9
- package/dist/src/api.js +710 -295
- package/dist/src/constraints.d.ts +46 -0
- package/dist/src/constraints.js +237 -10
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/lib.d.ts +32 -3
- package/dist/src/lib.js +53 -0
- package/dist/src/linter.d.ts +15 -0
- package/dist/src/linter.js +23 -0
- package/dist/src/registry.d.ts +1 -1
- package/dist/src/registry.js +161 -41
- package/dist/src/results.d.ts +113 -0
- package/dist/src/results.js +134 -0
- package/dist/src/rules/redos-prone-pattern.rule.d.ts +27 -0
- package/dist/src/rules/redos-prone-pattern.rule.js +47 -0
- package/dist/src/statuses.d.ts +24 -0
- package/dist/src/statuses.js +27 -0
- package/dist/src/types.js +37 -24
- package/dist/src/uri-template.d.ts +1 -1
- package/dist/src/uri-template.js +4 -1
- package/dist/src/zod.d.ts +25 -18
- package/dist/src/zod.js +69 -14
- package/package.json +19 -16
|
@@ -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;
|
package/dist/src/constraints.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.js
CHANGED
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
|
-
*
|
|
74
|
-
*
|
|
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
|
+
});
|
package/dist/src/registry.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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.
|