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/api.d.ts CHANGED
@@ -83,6 +83,15 @@ export interface EmittedResponseHeader {
83
83
  readonly name: string;
84
84
  /** The TypeScript type of the value, so a server emitter can put it in a signature. */
85
85
  readonly type: string;
86
+ /**
87
+ * The Zod expression for the value, so a server can check what it is about to send.
88
+ *
89
+ * The TYPE above constrains what a handler may return at compile time; this constrains what
90
+ * actually goes on the wire, which is the same split `schema` and the contract types already make
91
+ * for a body. A value that crossed a boundary the type system cannot see into - a service binding,
92
+ * a cast - is exactly what it catches.
93
+ */
94
+ readonly schema: string;
86
95
  /** The document's `required: false`. */
87
96
  readonly optional: boolean;
88
97
  }
@@ -298,6 +307,8 @@ export interface EmittedRoute {
298
307
  * request. It read as "the parameters" and was the field an adopter reached for first.
299
308
  */
300
309
  readonly pathSchema: string | undefined;
310
+ /** Cookie parameters, as one object schema, or `undefined` where the operation declares none. */
311
+ readonly cookieSchema: string | undefined;
301
312
  readonly querySchema: string | undefined;
302
313
  /**
303
314
  * The query OBJECT itself, which differs from `querySchema` only where a form-exploded record or
@@ -416,6 +427,11 @@ export interface RouteSchemaNames {
416
427
  */
417
428
  readonly queryFields: string | undefined;
418
429
  readonly header: string | undefined;
430
+ /**
431
+ * Cookie parameters. **Optional on the interface, so a server emitter that does not read it yet
432
+ * still compiles** - this arrived after the other four and two emitters consume this type.
433
+ */
434
+ readonly cookie?: string | undefined;
419
435
  readonly body: string | undefined;
420
436
  /**
421
437
  * The identifier each response's body is declared under, parallel to `EmittedRoute.responses` and
@@ -428,6 +444,11 @@ export interface RouteSchemaNames {
428
444
  readonly arms: readonly {
429
445
  readonly status: StatusKey;
430
446
  readonly schema: string | undefined;
447
+ /**
448
+ * The identifier holding this status's declared response headers, where it declares any.
449
+ * **Optional on the interface**, so a server emitter that does not check them yet still builds.
450
+ */
451
+ readonly headers?: string | undefined;
431
452
  }[];
432
453
  /**
433
454
  * The `readonly ResponseArm[]` const - what the operation may answer with, and with which body.
package/dist/src/api.js CHANGED
@@ -316,6 +316,14 @@ function responsesOf(program, operation, registry) {
316
316
  entry.headers.push({
317
317
  name,
318
318
  type: typeToTs(program, property.type),
319
+ /**
320
+ * **The Zod expression, which used to be discarded here.** Every REQUEST parameter
321
+ * goes through `expressionForProperty` a few hundred lines below; a response header
322
+ * went through `typeToTs` and nothing else, so the emitted arm carried a name and an
323
+ * optionality and no way to check a value. A response body that breaks its schema
324
+ * throws `ResponseContractError`; a header that breaks its own was served.
325
+ */
326
+ schema: registry.expressionForProperty(property),
319
327
  optional: property.optional,
320
328
  });
321
329
  }
@@ -968,9 +976,39 @@ function mediaTypeDecoded(declared) {
968
976
  .map((entry) => entry.trim().replace(/^"|"$/g, ""));
969
977
  if (values === undefined || values.length === 0)
970
978
  return declared;
979
+ /**
980
+ * **A declared media RANGE admits the types inside it, and a literal admits only itself.**
981
+ *
982
+ * \`@header contentType: "text/*"\` published \`z.literal("text/*")\`, so the only request that
983
+ * satisfied it was one sending the range itself as its \`Content-Type\`, which no client does.
984
+ * Measured before this existed: \`text/plain\` and \`text/html\` both answered 400 on a route whose
985
+ * document declares the whole \`text\` group.
986
+ *
987
+ * A template literal is the Zod counterpart of the TypeScript type the contract already uses for
988
+ * a range, so the validator and the handler's own type say the same thing.
989
+ */
990
+ const admitted = values.map((value) => {
991
+ /**
992
+ * **The full wildcard admits every media type, so there is nothing left to match on.**
993
+ *
994
+ * Spelled as a template literal it becomes the prefix `*` + `/`, which matches only a
995
+ * Content-Type whose text begins with the range's own first two characters. Measured on the
996
+ * emitted schema: `application/json`, `text/plain` and `image/png` were all REFUSED and the
997
+ * literal `*` + `/*` was the one value accepted, which is the defect the range branch exists
998
+ * to remove rather than to respell. A consumer declares this on a file upload that takes
999
+ * whatever the caller sends.
1000
+ */
1001
+ if (value === "*/*")
1002
+ return "z.string()";
1003
+ return value.endsWith("/*")
1004
+ ? `z.templateLiteral([${JSON.stringify(value.slice(0, -1))}, z.string()])`
1005
+ : `z.literal(${JSON.stringify(value)})`;
1006
+ });
1007
+ const schema = admitted.length === 1 ? (admitted[0] ?? declared) : `z.union([${admitted.join(", ")}])`;
1008
+ const ranged = values.some((value) => value.endsWith("/*")) ? schema : declared;
971
1009
  // Lowercased only when every declared value already is, so an oddly-cased one keeps matching.
972
1010
  const normalise = values.every((value) => value === value.toLowerCase()) ? ".toLowerCase()" : "";
973
- return `z.preprocess((raw) => (typeof raw === "string" ? (raw.split(";")[0] ?? "").trim()${normalise} : raw), ${declared})`;
1011
+ return `z.preprocess((raw) => (typeof raw === "string" ? (raw.split(";")[0] ?? "").trim()${normalise} : raw), ${ranged})`;
974
1012
  }
975
1013
  function parameterSchemasOf(program, operation, registry, segments,
976
1014
  /** The names of a query string written into the route itself, which no parameter may gather. */
@@ -983,7 +1021,22 @@ literalQueryNames) {
983
1021
  .filter((parameter) => parameter.type === "query")
984
1022
  .map((parameter) => parameter.name);
985
1023
  for (const parameter of operation.parameters.parameters) {
986
- if (parameter.type !== "path" && parameter.type !== "query" && parameter.type !== "header") {
1024
+ /**
1025
+ * **`cookie` is here because `@typespec/http` puts it here**, and excluding it was silent.
1026
+ * `HttpOperationParameter` discriminates on `"header" | "cookie" | "query" | "path"`, and this
1027
+ * filter named three of the four, so a `@cookie` reached no validator, no handler input and no
1028
+ * diagnostic while `@typespec/openapi3` published it as `in: cookie, required: true`. A
1029
+ * requirement the document states and the server cannot see is the defect class this package
1030
+ * exists to remove, and a cookie is where a session and a CSRF token normally live.
1031
+ *
1032
+ * The exclusion was by omission rather than by a `never` check, which is why TypeScript never
1033
+ * objected; every other place that reads `parameter.type` handles an encoding question a cookie
1034
+ * does not have, since `CookieOptions` carries only a name.
1035
+ */
1036
+ if (parameter.type !== "path" &&
1037
+ parameter.type !== "query" &&
1038
+ parameter.type !== "header" &&
1039
+ parameter.type !== "cookie") {
987
1040
  continue;
988
1041
  }
989
1042
  /**
@@ -1102,6 +1155,7 @@ literalQueryNames) {
1102
1155
  };
1103
1156
  const query = shape("query");
1104
1157
  return {
1158
+ cookie: shape("cookie"),
1105
1159
  path: shape("path"),
1106
1160
  query: query === undefined
1107
1161
  ? undefined
@@ -1299,6 +1353,7 @@ export function collectRoutes(program, registry, service) {
1299
1353
  ...(() => {
1300
1354
  const split = withVisibility(program, requestVisibility, () => parameterSchemasOf(program, operation, registry, template.segments, template.literalQuery.map(([name]) => name)));
1301
1355
  return {
1356
+ cookieSchema: split.cookie,
1302
1357
  pathSchema: split.path,
1303
1358
  querySchema: split.query,
1304
1359
  queryFieldsSchema: split.queryFields,
@@ -1452,13 +1507,30 @@ function renderSchemas(registry, externals, routeDeclarations, compileSchemas) {
1452
1507
  * parse - the walk captures the reference while it is still in the temporal dead zone, so the
1453
1508
  * failure surfaces at request time rather than at import.
1454
1509
  *
1455
- * **Keyed on `annotated`, the fact the registry already holds** - it is set exactly when the
1456
- * declaration is deferred, which is exactly when `z.lazy()` is written - rather than on the
1457
- * shape of the emitted text. A model on a cycle whose back edge sits on a GETTER is a different
1458
- * case and is compiled like any other: measured, it parses, because a getter is not resolved
1459
- * until something reads it.
1510
+ * **CYCLE MEMBERSHIP decides this, not deferral, and that took a zod upgrade to learn.**
1511
+ *
1512
+ * It was keyed on `annotated` - set exactly when the declaration is deferred, which is exactly
1513
+ * when `z.lazy()` is written - on the measurement that a model whose back edge sits on a GETTER
1514
+ * compiled and parsed fine. That was true on zod 4.5.2 and is false on 4.6.5, which made the
1515
+ * recursive parse state weak. The failure moved from the deferred declaration to the one that
1516
+ * REFERENCES it, which was still being compiled:
1517
+ *
1518
+ * ```
1519
+ * const branchSchema = z.lazy(() => z.union([nodeSchema, z.string()]));
1520
+ * const nodeSchema = z.compile(z.strictObject({ ..., branch: branchSchema.exactOptional() }));
1521
+ * nodeSchema.safeParse({ label: "a", attributes: {} });
1522
+ * zod 4.5.2: true
1523
+ * zod 4.6.5: TypeError: Cannot read properties of undefined (reading '_zod')
1524
+ * ```
1525
+ *
1526
+ * Bisected: naming a compiled schema from inside a `z.lazy()` body is the whole trigger, and
1527
+ * the self-referencing getter is not needed for it. So the same rule the registry already
1528
+ * applies to structural types applies here - every member of the cycle, not only the deferred
1529
+ * one - and `structural` is the fact it already holds for exactly that set.
1530
+ *
1531
+ * The peer range admits 4.6, so this reached a running server on the first parse.
1460
1532
  */
1461
- return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.annotated !== true)};\n`;
1533
+ return `\n${declared}\nexport const ${d.identifier}${annotation} = ${compiled(d.source, compileSchemas && d.structural === undefined)};\n`;
1462
1534
  });
1463
1535
  /**
1464
1536
  * **Written only when something in the file names it**, like the `ResponseArm` import below and for
@@ -1633,6 +1705,7 @@ function nameRouteSchemas(routes, compileSchemas) {
1633
1705
  const query = declare("Query", gathers && queryFields !== undefined && route.queryFieldsSchema !== undefined
1634
1706
  ? route.querySchema?.replace(route.queryFieldsSchema, queryFields)
1635
1707
  : route.querySchema);
1708
+ const cookie = declare("Cookie", route.cookieSchema);
1636
1709
  const negotiating = shared.has(`${route.verb} ${route.path}`);
1637
1710
  const header = declare("Header", negotiating ? route.negotiatedHeaderSchema : route.headerSchema);
1638
1711
  // A raw body is read as text, never parsed, so it has no validator - the bytes are the contract.
@@ -1643,13 +1716,28 @@ function nameRouteSchemas(routes, compileSchemas) {
1643
1716
  */
1644
1717
  const declaredBodies = new Map();
1645
1718
  const arms = route.responses.map((response) => {
1646
- if (response.schema === undefined)
1647
- return { status: response.status, schema: undefined };
1719
+ const suffix = response.status === "default" ? "Default" : String(response.status);
1720
+ /**
1721
+ * The headers this status declares, as one object schema, so a server can check what it is
1722
+ * about to send the way it already checks the body.
1723
+ *
1724
+ * **A plain `z.object`, which strips.** A response carries headers the document does not
1725
+ * declare - `Content-Type` among them, which the server sets itself - and those are not a
1726
+ * contract violation. What is checked is that every header the document DOES declare carries
1727
+ * a value the document permits.
1728
+ */
1729
+ const headers = response.headers.length === 0
1730
+ ? undefined
1731
+ : declare(`Response${suffix}Headers`, `z.object({\n${response.headers
1732
+ .map((header) => `\t${objectKey(header.name)}: ${header.schema},`)
1733
+ .join("\n")}\n})`);
1734
+ if (response.schema === undefined) {
1735
+ return { status: response.status, schema: undefined, headers };
1736
+ }
1648
1737
  if (!declaredBodies.has(response.schema)) {
1649
- const suffix = response.status === "default" ? "Default" : String(response.status);
1650
1738
  declaredBodies.set(response.schema, declare(`Response${suffix}`, response.schema));
1651
1739
  }
1652
- return { status: response.status, schema: declaredBodies.get(response.schema) };
1740
+ return { status: response.status, schema: declaredBodies.get(response.schema), headers };
1653
1741
  });
1654
1742
  const responses = schemaConst(route.operationId, "Responses");
1655
1743
  declarations.push(`export const ${responses} = ${responseArmsOf(route, arms)} satisfies readonly ResponseArm[];`);
@@ -1659,6 +1747,7 @@ function nameRouteSchemas(routes, compileSchemas) {
1659
1747
  query,
1660
1748
  queryFields: gathers ? queryFields : query,
1661
1749
  header,
1750
+ cookie,
1662
1751
  body,
1663
1752
  arms,
1664
1753
  responses,
@@ -1741,7 +1830,9 @@ function collectRequestTypes(program, registry, service) {
1741
1830
  const extraProperties = operation.parameters.parameters
1742
1831
  .filter((parameter) => parameter.type === "path" ||
1743
1832
  parameter.type === "query" ||
1744
- parameter.type === "header")
1833
+ parameter.type === "header" ||
1834
+ // The second half of the same omission: a validator with no matching type.
1835
+ parameter.type === "cookie")
1745
1836
  /**
1746
1837
  * Named as the WIRE names it, which is what the emitted validator keys on.
1747
1838
  * `@header("x-thing") thing: string` is `"x-thing"` in `schemas.gen.ts`, so it has to be
@@ -2058,7 +2149,7 @@ type Identical<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends
2058
2149
  * that does not.
2059
2150
  */
2060
2151
  /**
2061
- * \u26a0\ufe0f **An optional property is \`?: T\` here and \`?: T | undefined\` in the contract type, and that is
2152
+ * **An optional property is \`?: T\` here and \`?: T | undefined\` in the contract type, and that is
2062
2153
  * deliberate on both sides.**
2063
2154
  *
2064
2155
  * The validator emits \`.exactOptional()\`, so what ARRIVES is absent or a value - JSON cannot carry an
@@ -2121,8 +2212,8 @@ type Declared<T> = T extends (...args: never[]) => unknown
2121
2212
  : T;
2122
2213
 
2123
2214
  /**
2124
- * \u26a0\ufe0f **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
2125
- * type when the two disagree, and a type alias to an odd type compiles fine \u2014 66 assertions that
2215
+ * **The constraint is what fails the build.** \`Identical<A, B> & true\` merely produces an odd
2216
+ * type when the two disagree, and a type alias to an odd type compiles fine - 66 assertions that
2126
2217
  * assert nothing. Only a constraint violation is an error.
2127
2218
  */
2128
2219
  type MustHold<T extends true> = T;
@@ -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 timeout 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 { 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,165 @@ 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 long one pattern may be analysed before the answer is "could not prove it".
91
+ *
92
+ * **Bounded, because the analysis is itself exponential in the bad case.** Measured: `^(a|a)+$`
93
+ * takes 1.6 seconds to decide, and a build should not stall on a pattern whose whole problem is that
94
+ * it does too much work. A pattern that cannot be decided inside this is one this emitter cannot
95
+ * certify, which is what the warning says.
96
+ */
97
+ const ANALYSIS_TIMEOUT_MS = 500;
98
+ /**
99
+ * Patterns already judged, keyed on the text, which is the only input the verdict depends on.
100
+ *
101
+ * The analysis costs up to {@link ANALYSIS_TIMEOUT_MS} a pattern, and one pattern text commonly
102
+ * recurs across declarations - `^\\d{4}-\\d{2}-\\d{2}$` appears on several scalars in more than one
103
+ * consumer spec - so each distinct text is analysed once per process.
104
+ */
105
+ const verdicts = new Map();
106
+ /**
107
+ * Which declarations have already been reported, per program.
108
+ *
109
+ * **Per PROGRAM, and that distinction is the whole reason this is a `WeakMap`.** One scalar carrying
110
+ * a pattern is reached once per USE - the reference service reaches a single scalar 153 times - and
111
+ * three identical warnings pointing at one declaration are noise a reader learns to skip. But a
112
+ * module-level "already said that" would also silence the second compile in a watch session, or the
113
+ * second service in one program, which is a warning lost rather than a warning tidied. Keyed on the
114
+ * program, it is emptied whenever the program is.
115
+ *
116
+ * The verdict cache above is different and is deliberately global: it is a pure function of the
117
+ * pattern text, so it cannot be wrong for a later program.
118
+ */
119
+ const reported = new WeakMap();
120
+ /**
121
+ * **Whether this diagnostic has already been raised against this declaration.**
122
+ *
123
+ * Keyed on the code as well as the target. Keying on the target alone once made the memo shared
124
+ * between every pattern diagnostic, so whichever ran first claimed the declaration and silenced
125
+ * the rest: measured on `^(\w+\s?)*\-$`, one warning where two were due. The ReDoS judgement has
126
+ * since moved to a linter rule, which reports per declaration by construction, so that pairing can
127
+ * no longer meet here - but a memo keyed on the target alone is the wrong shape for the next
128
+ * diagnostic added beside this one, and costs nothing to keep right.
129
+ *
130
+ * The de-duplication itself is per declaration, because emission reaches a scalar once per USE -
131
+ * 153 times for a single scalar in the reference service - and repeating one warning that often
132
+ * is noise a reader learns to skip, which is how a real one gets missed.
133
+ */
134
+ function firstReportOf(program, code, target) {
135
+ let byCode = reported.get(program);
136
+ if (byCode === undefined) {
137
+ byCode = new Map();
138
+ reported.set(program, byCode);
139
+ }
140
+ let already = byCode.get(code);
141
+ if (already === undefined) {
142
+ already = new WeakSet();
143
+ byCode.set(code, already);
144
+ }
145
+ if (already.has(target))
146
+ return false;
147
+ already.add(target);
148
+ return true;
149
+ }
150
+ /**
151
+ * **Whether a `@pattern` can be proven safe against catastrophic backtracking.**
152
+ *
153
+ * A pure verdict, with no program and no report. The judgement belongs to the
154
+ * `redos-prone-pattern` LINTER rule rather than to emission, because the spec is valid, the
155
+ * document is correct and the emitted regex is exactly the published one: what is wrong is a cost
156
+ * no artefact states, which is the case TypeSpec reserves for a linter.
157
+ *
158
+ * **The engine is the one Zod runs on**, so the analysis carries whichever flag
159
+ * {@link patternToRegex} will emit. Analysing `\p{L}+` with `unicode: false` asks about a different
160
+ * expression from the one that runs - the identity escape for `p` - and a verdict about the wrong
161
+ * pattern is worth nothing whichever way it comes out.
162
+ *
163
+ * **`false` means "not proven safe", not "proven dangerous"**, and the rule's wording follows that.
164
+ * `redos-detector` proves safety rather than guessing at danger, so a pattern it cannot decide inside
165
+ * the timeout is judged the same way as one it decides against. Measured over a realistic spread of
166
+ * 14 patterns an API would carry: 2 conservative warnings, both on a quantifier nested over an
167
+ * overlapping class, and nothing dangerous missed - including `^\w+([.-]?\w+)*$`, which reads as
168
+ * ordinary and takes **11 seconds** on a 29-character input.
169
+ */
170
+ export function patternIsProvablySafe(pattern) {
171
+ let safe = verdicts.get(pattern);
172
+ if (safe === undefined) {
173
+ try {
174
+ safe = isSafePattern(pattern, {
175
+ unicode: unicodeSafe(pattern),
176
+ timeout: ANALYSIS_TIMEOUT_MS,
177
+ }).safe;
178
+ }
179
+ catch {
180
+ // A pattern the analyser cannot even parse is not one to make a claim about.
181
+ safe = true;
182
+ }
183
+ verdicts.set(pattern, safe);
184
+ }
185
+ return safe;
186
+ }
46
187
  /**
47
188
  * A `@pattern` string becomes a regex literal - **verbatim**.
48
189
  *
@@ -65,5 +206,87 @@ export function applyConstraints(program, expression, target) {
65
206
  * instead of guessing at an equivalence.
66
207
  */
67
208
  export function patternToRegex(pattern) {
68
- return `/${pattern.replace(/\//g, "\\/")}/`;
209
+ return `/${escapeSlashes(pattern)}/${unicodeSafe(pattern) ? "u" : ""}`;
210
+ }
211
+ /**
212
+ * **Escapes the slashes a regex literal needs escaped, and only those.**
213
+ *
214
+ * A `.replace(/\//g, "\\/")` escapes unconditionally, so a pattern that already spelled a slash
215
+ * as an escape had its backslash doubled: `^\/api\/v[0-9]+$` became the literal
216
+ * `/^\\/api\\/v[0-9]+$/u`, which ends at the third slash and reads `api` as flags. Measured on
217
+ * that pattern: `tsp compile` EXIT=0 and silent, `node` refused the emitted module with
218
+ * `SyntaxError: Invalid regular expression flags`, and `tsc` reported `TS1127: Invalid character`.
219
+ * A path regex is the most ordinary place a slash appears, and no fixture here had ever carried
220
+ * one - nor does any of the 67 specs in `@typespec/http-specs`.
221
+ *
222
+ * A backslash takes the character after it with it, so `\/` is already escaped and is left alone,
223
+ * while `\\` is a literal backslash and a slash following it still needs escaping. `\/` remains a
224
+ * legal identity escape under the `u` flag, so this does not change which patterns can carry it.
225
+ */
226
+ function escapeSlashes(pattern) {
227
+ let escaped = "";
228
+ for (let index = 0; index < pattern.length; index += 1) {
229
+ const character = pattern[index];
230
+ if (character === "\\") {
231
+ escaped += character + (pattern[index + 1] ?? "");
232
+ index += 1;
233
+ continue;
234
+ }
235
+ escaped += character === "/" ? "\\/" : character;
236
+ }
237
+ return escaped;
238
+ }
239
+ /**
240
+ * Whether the expression is one the `u` flag accepts, which is how a JSON Schema reader compiles it.
241
+ *
242
+ * **The flag is part of the contract, and leaving it off was the divergence.** A bare `/.../` makes
243
+ * `\p{L}` an identity escape for the letter `p`, so the expression the server ran was not the
244
+ * expression the spec author wrote. Measured against Ajv on the published document, with every
245
+ * verdict inverted:
246
+ *
247
+ * ```
248
+ * value document (Ajv) emitted validator
249
+ * "abc" VALID INVALID
250
+ * "Unicode" VALID INVALID
251
+ * "p{L}" INVALID VALID
252
+ * ```
253
+ *
254
+ * Ajv compiles a JSON Schema `pattern` with `u` by default, so carrying it here is not a rule laid
255
+ * on top of the contract; it is the contract.
256
+ *
257
+ * **Only when the pattern compiles under it.** `u` makes several ordinary spellings an error - a
258
+ * redundant `\-` outside a character class, a lone `]` or `{`, an identity escape such as `\a` - and
259
+ * 5 of 15 realistic patterns tested fail to compile with it. Those keep the bare form they already
260
+ * had, and {@link reportNonUnicodePattern} says so at build time, because a reader of the document
261
+ * cannot compile them either.
262
+ */
263
+ export function unicodeSafe(pattern) {
264
+ try {
265
+ new RegExp(pattern, "u");
266
+ return true;
267
+ }
268
+ catch {
269
+ return false;
270
+ }
271
+ }
272
+ /**
273
+ * Warn when a `@pattern` is one a JSON Schema reader cannot compile at all.
274
+ *
275
+ * Separate from the `redos-prone-pattern` linter rule because the failure runs the other way: that
276
+ * one is about a pattern the validator runs too slowly, this one about a pattern nothing READING
277
+ * the document can run. And this one stays a diagnostic, because here the emitter cannot do what the
278
+ * spec asks: it falls back to a different expression from the one it publishes. Measured, Ajv on a published `^\-?\d+$`:
279
+ * `Invalid regular expression: /^\-?\d+$/u: Invalid escape`. The emitted validator falls back to the
280
+ * bare form and enforces it anyway, so the two artefacts stop agreeing without either one failing.
281
+ *
282
+ * De-duplicated per declaration through {@link firstReportOf}, which keys on the diagnostic as
283
+ * well as the declaration. Sharing one memo between the two warnings meant a declaration with
284
+ * both faults reported only whichever ran first.
285
+ */
286
+ function reportNonUnicodePattern(program, pattern, target) {
287
+ if (unicodeSafe(pattern))
288
+ return;
289
+ if (!firstReportOf(program, "non-unicode-pattern", target))
290
+ return;
291
+ reportDiagnostic(program, { code: "non-unicode-pattern", format: { pattern }, target });
69
292
  }
@@ -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";