typespec-http-zod 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/api.js CHANGED
@@ -2,6 +2,7 @@ import { emitFile, getDoc, getDocData, getEncode, getLocationContext, getSummary
2
2
  import { getAuthenticationForOperation, resolveRequestVisibility, Visibility, } from "@typespec/http";
3
3
  import { getOperationId, resolveOperationId } from "@typespec/openapi";
4
4
  import { reportDiagnostic } from "./lib.js";
5
+ import { literal } from "./literal.js";
5
6
  import { SchemaRegistry, TypeRegistry } from "./registry.js";
6
7
  import { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
7
8
  import { propertyTypeToTs } from "./types.js";
@@ -10,7 +11,7 @@ import { resolveVersioningModule, serviceSnapshots } from "./versioning.js";
10
11
  import { EXPLODED_QUERY_SOURCE, explodedQueryCall, needsExpansionDecoding, routeTemplateOf, URI_EXPANSION_SOURCE, uriExpansionCall, } from "./uri-template.js";
11
12
  export { CONTENTLESS_STATUS_CODES, STATUS_CODES } from "./statuses.js";
12
13
  export { isJsonMediaType, isMediaRange, isTextualMediaType, mediaTypeEssenceOf, mediaTypeTypeOf, resultMembersOf, statusCodesOf, } from "./results.js";
13
- import { collectExternalImports, isPayloadProperty, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
14
+ import { collectExternalImports, encodedTypeOf, isPayloadProperty, noteDeclaredVocabularies, noteKeyVocabularies, objectKey, wireKindOf, withContractsPackage, withSealedObjects, withVisibility, } from "./zod.js";
14
15
  /**
15
16
  * `typespec-http-zod` - Zod validators and framework-free contract types from a TypeSpec HTTP
16
17
  * service, agreeing with the OpenAPI document `@typespec/openapi3` publishes from the same source.
@@ -53,6 +54,12 @@ export { EmitterOptionsSchema } from "./lib.js";
53
54
  * independently.
54
55
  */
55
56
  export { jsDocComment, objectKey } from "./zod.js";
57
+ /**
58
+ * And the same for a VALUE written into emitted code: a string, enum value or media type as a literal
59
+ * whose source is ASCII. Spec text outside ASCII is the case, and a sibling emitter spelling it with a
60
+ * bare `JSON.stringify` writes the character itself into a file its consumer commits.
61
+ */
62
+ export { literal } from "./literal.js";
56
63
  /** Named once. The emitted banner and the internal-invariant messages both quote it. */
57
64
  const PACKAGE_NAME = "typespec-http-zod";
58
65
  /**
@@ -321,19 +328,20 @@ function resolveResponses(program, operation) {
321
328
  file: false,
322
329
  };
323
330
  for (const content of response.responses) {
324
- if (entry.body === undefined && content.body?.bodyKind === "single") {
325
- entry.body = bodyTypeOf(content.body.type);
331
+ const body = presentBody(content.body);
332
+ if (entry.body === undefined && body?.bodyKind === "single") {
333
+ entry.body = body.type;
326
334
  }
327
335
  /**
328
336
  * **An `Http.File` response's body is its contents, served as bytes.** The same reading
329
337
  * as a file request body: a media type and nothing to serialise. Read only for
330
338
  * `"single"`, every download in `type/file` declared `image/png` and was served empty.
331
339
  */
332
- if (entry.body === undefined && content.body?.bodyKind === "file") {
333
- entry.body = content.body.contents.type;
340
+ if (entry.body === undefined && body?.bodyKind === "file") {
341
+ entry.body = body.contents.type;
334
342
  entry.file = true;
335
343
  }
336
- for (const contentType of content.body?.contentTypes ?? []) {
344
+ for (const contentType of body?.contentTypes ?? []) {
337
345
  entry.contentTypes.add(contentType);
338
346
  }
339
347
  for (const [name, property] of Object.entries(content.headers ?? {})) {
@@ -599,6 +607,26 @@ export function errorBodyOf(program, service) {
599
607
  function bodyTypeOf(type) {
600
608
  return isVoidType(type) || isNeverType(type) ? undefined : type;
601
609
  }
610
+ /**
611
+ * A declared body as the wire carries it: `undefined` where it is `void` or `never`, so no media
612
+ * type is listed for it either.
613
+ *
614
+ * **`@typespec/http` keeps a body it was told is empty**, media types and all, whenever the response
615
+ * or request declares it explicitly - `@statusCode _: 409; @body body: void`. Every artefact read
616
+ * the media types off it, so a 409 with no body was published as `contentTypes:
617
+ * ["application/json"]` and `schema: undefined`, which a consumer reads as a body it cannot validate,
618
+ * and a `void` request body reached the Zod walk and refused the compile. openapi3 publishes no
619
+ * `content` for `void`. For `never` it publishes `schema: {}` - any value - because its body paths
620
+ * test only for `void`, while it omits a `never` PROPERTY from every schema; a type with no values is
621
+ * no body here, and `reference.md` records the divergence.
622
+ */
623
+ function presentBody(body) {
624
+ return body?.bodyKind === "single" && bodyTypeOf(body.type) === undefined ? undefined : body;
625
+ }
626
+ /** The operation's request body, absent where it is declared `void` or `never`. */
627
+ function requestBodyParameterOf(operation) {
628
+ return presentBody(operation.parameters.body);
629
+ }
602
630
  /**
603
631
  * The media types this operation's success response can be served as.
604
632
  *
@@ -616,15 +644,24 @@ function responseContentTypesOf(operation, status) {
616
644
  if (response.statusCodes !== status)
617
645
  continue;
618
646
  for (const content of response.responses) {
619
- for (const contentType of content.body?.contentTypes ?? [])
647
+ for (const contentType of presentBody(content.body)?.contentTypes ?? []) {
620
648
  types.add(contentType);
649
+ }
621
650
  }
622
651
  }
623
652
  return [...types];
624
653
  }
625
- /** The type a parameter actually crosses the wire as, after any `@encode` on the property. */
654
+ /**
655
+ * The type a parameter actually crosses the wire as, after any `@encode` on the property.
656
+ *
657
+ * **Read through `encodedTypeOf`, the rule the parameter's schema is built by.** This read
658
+ * `getEncode(...).type` directly, which for an array encoding on a query parameter is `string`: the
659
+ * schema kept the declared `int32[]`, as the document does, while the element decoder found no list
660
+ * and left `"1"` to meet `z.int32()`. Every path here that decides how a value is split, decoded or
661
+ * expanded reads this type, so none of them can disagree with the schema it feeds.
662
+ */
626
663
  function encodedParameterType(program, property) {
627
- return getEncode(program, property)?.type ?? property.type;
664
+ return encodedTypeOf(program, property) ?? property.type;
628
665
  }
629
666
  /**
630
667
  * The wire kind of an ARRAY parameter's elements, or `undefined` where it is not such an array.
@@ -849,7 +886,7 @@ function isParseableMediaType(type) {
849
886
  }
850
887
  /** The declared request body type, if the operation takes one. */
851
888
  function requestBodyOf(operation) {
852
- const body = operation.parameters.body;
889
+ const body = requestBodyParameterOf(operation);
853
890
  if (body?.bodyKind === "single")
854
891
  return body.type;
855
892
  return undefined;
@@ -886,9 +923,17 @@ const COLLECTION_DELIMITERS = {
886
923
  "ArrayEncoding.commaDelimited": ",",
887
924
  "ArrayEncoding.newlineDelimited": "\n",
888
925
  };
889
- /** Whether the declared type is a list, which is what makes a delimiter meaningful. */
890
- function isCollectionParameter(property) {
891
- return isArrayType(property.type);
926
+ /**
927
+ * Whether the parameter crosses the wire as a list, which is what makes a delimiter meaningful.
928
+ *
929
+ * **The encoded type, not the declared one.** `@header @encode(ArrayEncoding.commaDelimited) tags:
930
+ * string[]` is a string on the wire - the document publishes `type: string` - and its schema is
931
+ * `z.string()`; reading the declared list split `"a,b"` into an array and handed that to the string
932
+ * schema, so every request was refused. Only a query parameter keeps its list under an array
933
+ * encoding, and only there is a split undone.
934
+ */
935
+ function isCollectionParameter(program, property) {
936
+ return isArrayType(encodedParameterType(program, property));
892
937
  }
893
938
  /**
894
939
  * Whether the values arrive one per OCCURRENCE (RFC 6570 explode) rather than joined into one.
@@ -899,8 +944,8 @@ function isCollectionParameter(property) {
899
944
  * refused a conformant request while accepting the same list sent twice, which no document
900
945
  * describes. Found by the first consumer's `?topics=ip` (#1).
901
946
  */
902
- function isExplodedCollection(parameter) {
903
- return (isCollectionParameter(parameter.param) &&
947
+ function isExplodedCollection(program, parameter) {
948
+ return (isCollectionParameter(program, parameter.param) &&
904
949
  (parameter.type === "query" || parameter.type === "header") &&
905
950
  parameter.explode === true);
906
951
  }
@@ -912,7 +957,7 @@ function isExplodedCollection(parameter) {
912
957
  * Those are the two that looked correct because the document and the validator agreed.
913
958
  */
914
959
  function collectionDelimiterOf(program, parameter) {
915
- if (!isCollectionParameter(parameter.param))
960
+ if (!isCollectionParameter(program, parameter.param))
916
961
  return undefined;
917
962
  if (parameter.type !== "query" && parameter.type !== "header")
918
963
  return undefined;
@@ -1045,8 +1090,8 @@ function mediaTypeDecoded(declared) {
1045
1090
  if (value === "*/*")
1046
1091
  return "z.string()";
1047
1092
  return isMediaRange(value)
1048
- ? `z.templateLiteral([${JSON.stringify(mediaTypeEssenceOf(value).slice(0, -1))}, z.string()])`
1049
- : `z.literal(${JSON.stringify(value)})`;
1093
+ ? `z.templateLiteral([${literal(mediaTypeEssenceOf(value).slice(0, -1))}, z.string()])`
1094
+ : `z.literal(${literal(value)})`;
1050
1095
  });
1051
1096
  const schema = admitted.length === 1 ? (admitted[0] ?? declared) : `z.union([${admitted.join(", ")}])`;
1052
1097
  const ranged = values.some(isMediaRange) ? schema : declared;
@@ -1063,7 +1108,7 @@ function mediaTypeDecoded(declared) {
1063
1108
  const decoded = restored.length === 0
1064
1109
  ? essence
1065
1110
  : `((essence) => ${restored
1066
- .map((value) => `essence === ${JSON.stringify(mediaTypeEssenceOf(value))} ? ${JSON.stringify(value)} : `)
1111
+ .map((value) => `essence === ${literal(mediaTypeEssenceOf(value))} ? ${literal(value)} : `)
1067
1112
  .join("")}essence)(${essence})`;
1068
1113
  return `z.preprocess((raw) => (typeof raw === "string" ? ${decoded} : raw), ${ranged})`;
1069
1114
  }
@@ -1071,6 +1116,9 @@ function parameterSchemasOf(program, operation, registry, segments,
1071
1116
  /** The names of a query string written into the route itself, which no parameter may gather. */
1072
1117
  literalQueryNames) {
1073
1118
  const byTarget = {};
1119
+ const add = (target, key, expression) => {
1120
+ (byTarget[target] ??= []).push(`\t${objectKey(key)}: ${expression},`);
1121
+ };
1074
1122
  let accept;
1075
1123
  /** Form-exploded records and models, gathered back under their names around the query object. */
1076
1124
  const gathered = [];
@@ -1108,8 +1156,21 @@ literalQueryNames) {
1108
1156
  * via `resolveEncodedName` - the document publishes one name per parameter, and it is this one.
1109
1157
  * The TypeSpec property name is an authoring convenience that does not survive to the contract.
1110
1158
  */
1159
+ /**
1160
+ * **Two parameters of one location and one wire name are one value on the wire**, and both
1161
+ * declarations apply to it: their schemas are intersected and the value is decoded once, by
1162
+ * the first declaration's wire form, so each judges the same decoded value. Decoded by each
1163
+ * and then intersected, `@query("x") a: string` beside `@query("x") b: int32` would hand Zod
1164
+ * `"1"` and `1` to merge, which it does not refuse but throws on. An object literal cannot
1165
+ * hold the key twice either. `shared-input-key` reports it.
1166
+ */
1167
+ const sharing = operation.parameters.parameters.filter((other) => other.type === parameter.type && other.name === parameter.name);
1168
+ if (sharing[0] !== parameter)
1169
+ continue;
1111
1170
  const delimiter = collectionDelimiterOf(program, parameter);
1112
- const declared = registry.expressionForProperty(parameter.param);
1171
+ const declared = sharing
1172
+ .map((other) => registry.expressionForProperty(other.param))
1173
+ .reduce((first, other) => `${first}.and(${other})`);
1113
1174
  /**
1114
1175
  * The wire form is decoded before it is validated, never instead of it. `z.preprocess` undoes
1115
1176
  * exactly the flattening the document's `style` describes and then applies the schema the
@@ -1139,22 +1200,23 @@ literalQueryNames) {
1139
1200
  * occurrences arrive already an array and pass through untouched, exactly as the delimiter
1140
1201
  * arm leaves a pre-split value alone.
1141
1202
  */
1142
- const boxedOrSplit = isExplodedCollection(parameter)
1203
+ const boxedOrSplit = isExplodedCollection(program, parameter)
1143
1204
  ? `z.preprocess((raw) => (typeof raw === "string" ? [raw] : raw), ${decoded})`
1144
1205
  : delimiter === undefined
1145
1206
  ? decoded
1146
- : `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${JSON.stringify(delimiter)}) : raw), ${decoded})`;
1207
+ : `z.preprocess((raw) => (typeof raw === "string" ? raw.split(${literal(delimiter)}) : raw), ${decoded})`;
1147
1208
  /**
1148
1209
  * **An RFC 6570 expansion is undone OUTSIDE everything above**, because it is the first thing the
1149
1210
  * wire did: `array.a.b` becomes `["a", "b"]`, then the element decoder and the document's schema
1150
1211
  * run on that. See {@link uriExpansionCall}.
1151
1212
  */
1152
- const shape = expansionShapeOf(parameter.param.type);
1153
- const values = recordValuesOf(program, parameter.param.type);
1213
+ const wire = encodedParameterType(program, parameter.param);
1214
+ const shape = expansionShapeOf(wire);
1215
+ const values = recordValuesOf(program, wire);
1154
1216
  let expression = boxedOrSplit;
1155
1217
  if (parameter.type === "path") {
1156
- const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
1157
- if (segment !== undefined && needsExpansionDecoding(segment, shape)) {
1218
+ const segment = decodedSegmentOf(program, parameter, segments);
1219
+ if (segment !== undefined) {
1158
1220
  expression = `z.preprocess(${uriExpansionCall({ ...segment, name: parameter.name, shape, values })}, ${decoded})`;
1159
1221
  }
1160
1222
  }
@@ -1164,9 +1226,7 @@ literalQueryNames) {
1164
1226
  * `{?param*}` over an object expands to the object's own keys, so there is no `param` key for
1165
1227
  * this entry to read; the gatherer around the query object puts one back.
1166
1228
  */
1167
- const model = parameter.param.type.kind === "Model" && parameter.param.type.indexer === undefined
1168
- ? parameter.param.type
1169
- : undefined;
1229
+ const model = wire.kind === "Model" && wire.indexer === undefined ? wire : undefined;
1170
1230
  const properties = model === undefined ? [] : modelPropertiesOf(model);
1171
1231
  gathered.push(explodedQueryCall({
1172
1232
  name: parameter.name,
@@ -1175,10 +1235,10 @@ literalQueryNames) {
1175
1235
  ? undefined
1176
1236
  : properties.map((property) => resolveEncodedName(program, property, "application/json")),
1177
1237
  numbers: properties
1178
- .filter((property) => wireKindOf(program, property.type) === "number")
1238
+ .filter((property) => wireKindOf(program, encodedParameterType(program, property)) === "number")
1179
1239
  .map((property) => resolveEncodedName(program, property, "application/json")),
1180
1240
  booleans: properties
1181
- .filter((property) => wireKindOf(program, property.type) === "boolean")
1241
+ .filter((property) => wireKindOf(program, encodedParameterType(program, property)) === "boolean")
1182
1242
  .map((property) => resolveEncodedName(program, property, "application/json")),
1183
1243
  values,
1184
1244
  }));
@@ -1187,8 +1247,7 @@ literalQueryNames) {
1187
1247
  expression = `z.preprocess(${uriExpansionCall({ prefix: "", suffix: "", operator: "", name: parameter.name, explode: false, shape, values })}, ${decoded})`;
1188
1248
  }
1189
1249
  }
1190
- const entry = `\t${objectKey(parameter.name)}: ${expression},`;
1191
- (byTarget[parameter.type] ??= []).push(entry);
1250
+ add(parameter.type, parameter.name, expression);
1192
1251
  /**
1193
1252
  * **On a route several operations share, `accept` SELECTS the operation - it is not a
1194
1253
  * constraint on one of them.** Each member declares its own literal, so validating against any
@@ -1198,20 +1257,20 @@ literalQueryNames) {
1198
1257
  * validates every OTHER header and leaves this one to the negotiation.
1199
1258
  */
1200
1259
  if (parameter.type === "header" && parameter.name.toLowerCase() === "accept") {
1201
- const literal = parameter.param.type;
1202
- if (literal.kind === "String")
1203
- accept = { name: parameter.name, value: literal.value };
1260
+ const declared = parameter.param.type;
1261
+ if (declared.kind === "String")
1262
+ accept = { name: parameter.name, value: declared.value };
1204
1263
  }
1205
1264
  else if (parameter.type === "header") {
1206
- (byTarget["header-without-accept"] ??= []).push(entry);
1265
+ add("header-without-accept", parameter.name, expression);
1207
1266
  }
1208
1267
  }
1209
1268
  const fileContentType = fileContentTypeOf(operation);
1210
1269
  if (fileContentType !== undefined) {
1211
1270
  // The same decoding a declared `Content-Type` header gets: parameters dropped, lowercased.
1212
- const entry = `\t${objectKey("Content-Type")}: ${mediaTypeDecoded(registry.expressionFor(fileContentType.type))},`;
1213
- (byTarget["header"] ??= []).push(entry);
1214
- (byTarget["header-without-accept"] ??= []).push(entry);
1271
+ const expression = mediaTypeDecoded(registry.expressionFor(fileContentType.type));
1272
+ add("header", "Content-Type", expression);
1273
+ add("header-without-accept", "Content-Type", expression);
1215
1274
  }
1216
1275
  const shape = (target) => {
1217
1276
  const entries = byTarget[target];
@@ -1230,6 +1289,24 @@ literalQueryNames) {
1230
1289
  accept,
1231
1290
  };
1232
1291
  }
1292
+ /**
1293
+ * The route segment a path parameter's validator decodes, or `undefined` where it validates the
1294
+ * segment's text as the value. The one rule for both the validator and
1295
+ * {@link EmittedRoute.decodedPathParameters}, so a consumer reading the route cannot disagree with
1296
+ * the validator it describes.
1297
+ *
1298
+ * **Shaped from the encoded type, as the validator's schema is.** `@path
1299
+ * @encode(ArrayEncoding.commaDelimited) ids: string[]` is a string on the wire, published as `type:
1300
+ * string`, and validated by `z.string()`; shaped from the declared list it was expanded into
1301
+ * `["a", "b"]` first, and every request was refused.
1302
+ */
1303
+ function decodedSegmentOf(program, parameter, segments) {
1304
+ const segment = segments.find((candidate) => candidate.kind === "expression" && candidate.parameter === parameter.name);
1305
+ return segment !== undefined &&
1306
+ needsExpansionDecoding(segment, expansionShapeOf(encodedParameterType(program, parameter.param)))
1307
+ ? segment
1308
+ : undefined;
1309
+ }
1233
1310
  /** What a parameter's value decodes into once its expansion is undone. */
1234
1311
  function expansionShapeOf(type) {
1235
1312
  if (isArrayType(type))
@@ -1260,7 +1337,7 @@ function modelPropertiesOf(model) {
1260
1337
  * `undefined` where the body is not a file, or where the operation already declares the header.
1261
1338
  */
1262
1339
  function fileContentTypeOf(operation) {
1263
- const body = operation.parameters.body;
1340
+ const body = requestBodyParameterOf(operation);
1264
1341
  if (body?.bodyKind !== "file")
1265
1342
  return undefined;
1266
1343
  const declared = operation.parameters.parameters.some((parameter) => parameter.type === "header" && parameter.name.toLowerCase() === "content-type");
@@ -1282,7 +1359,7 @@ function fileContentTypeOf(operation) {
1282
1359
  * body. A read-only property is not sent, so it contributes nothing.
1283
1360
  */
1284
1361
  function mergedBodyKeysOf(program, operation) {
1285
- const body = operation.parameters.body;
1362
+ const body = requestBodyParameterOf(operation);
1286
1363
  if (body?.bodyKind === "multipart") {
1287
1364
  return body.parts.flatMap((part) => (part.name === undefined ? [] : [part.name]));
1288
1365
  }
@@ -1293,9 +1370,86 @@ function mergedBodyKeysOf(program, operation) {
1293
1370
  .filter((property) => isPayloadProperty(program, property))
1294
1371
  .map((property) => resolveEncodedName(program, property, "application/json")));
1295
1372
  }
1373
+ const INPUT_LOCATION_NOUNS = {
1374
+ path: "path parameter",
1375
+ query: "query parameter",
1376
+ header: "header",
1377
+ cookie: "cookie",
1378
+ body: "body",
1379
+ };
1380
+ /**
1381
+ * The pairs of inputs an operation hands its handler under one key, reported once per pair and
1382
+ * program.
1383
+ *
1384
+ * **Read from the same facts the input is rendered from**: each parameter under its wire name, the
1385
+ * `Content-Type` of an `Http.File` body, and the body where {@link requestBodyPlacementOf} names it.
1386
+ * A merged body is not listed, because that decision already names the body whenever one of its keys
1387
+ * is a parameter's - which is how a `@query("body")` beside a body named `body` stays possible to
1388
+ * write, and why it is caught here rather than there.
1389
+ *
1390
+ * Resolved once per operation, since the route walk and the contract walk both read it and the
1391
+ * warning must be raised once.
1392
+ */
1393
+ function sharedInputKeysOf(program, operation) {
1394
+ const resolved = sharedInputKeys.get(operation);
1395
+ if (resolved !== undefined)
1396
+ return resolved;
1397
+ const inputs = [];
1398
+ for (const parameter of operation.parameters.parameters) {
1399
+ if (parameter.type === "path" ||
1400
+ parameter.type === "query" ||
1401
+ parameter.type === "header" ||
1402
+ parameter.type === "cookie") {
1403
+ inputs.push({ key: parameter.name, location: parameter.type, target: parameter.param });
1404
+ }
1405
+ }
1406
+ const fileContentType = fileContentTypeOf(operation);
1407
+ if (fileContentType !== undefined) {
1408
+ inputs.push({ key: "Content-Type", location: "header", target: fileContentType });
1409
+ }
1410
+ const placement = requestBodyPlacementOf(program, operation);
1411
+ const bodyKey = placement.rawBodyProperty ?? placement.bodyProperty;
1412
+ if (bodyKey !== undefined) {
1413
+ inputs.push({
1414
+ key: bodyKey,
1415
+ location: "body",
1416
+ target: requestBodyParameterOf(operation)?.property ?? operation.operation,
1417
+ });
1418
+ }
1419
+ const shared = [];
1420
+ inputs.forEach((input, index) => {
1421
+ const first = inputs.slice(0, index).find((earlier) => earlier.key === input.key);
1422
+ if (first === undefined)
1423
+ return;
1424
+ shared.push({ key: input.key, first: first.location, second: input.location });
1425
+ const format = {
1426
+ operation: operationIdOf(program, operation.operation),
1427
+ key: input.key,
1428
+ first: INPUT_LOCATION_NOUNS[first.location],
1429
+ second: INPUT_LOCATION_NOUNS[input.location],
1430
+ };
1431
+ /**
1432
+ * **Once per program, keyed on what the warning says.** Each emitter that runs the library
1433
+ * collects its own operations, over types a visibility or version projection may have cloned,
1434
+ * so neither the operation nor the declaration is the same object twice: `typespec-hono` beside
1435
+ * `typespec-http-mcp` raised the warning twice per pair.
1436
+ */
1437
+ const reported = sharedInputKeysReported.get(program) ?? new Set();
1438
+ sharedInputKeysReported.set(program, reported);
1439
+ const said = JSON.stringify(format);
1440
+ if (reported.has(said))
1441
+ return;
1442
+ reported.add(said);
1443
+ reportDiagnostic(program, { code: "shared-input-key", target: input.target, format });
1444
+ });
1445
+ sharedInputKeys.set(operation, shared);
1446
+ return shared;
1447
+ }
1448
+ const sharedInputKeys = new WeakMap();
1449
+ const sharedInputKeysReported = new WeakMap();
1296
1450
  function requestBodyPlacementOf(program, operation) {
1297
1451
  const requestType = requestBodyOf(operation);
1298
- const bodyParameter = operation.parameters.body;
1452
+ const bodyParameter = requestBodyParameterOf(operation);
1299
1453
  const contentTypes = [...(bodyParameter?.contentTypes ?? [])];
1300
1454
  /**
1301
1455
  * A `bytes` body means the BYTES are the contract.
@@ -1486,15 +1640,19 @@ export function collectRoutes(program, registry, service) {
1486
1640
  .filter((parameter) => parameter.type === "path" && parameter.allowReserved)
1487
1641
  .map((parameter) => parameter.name),
1488
1642
  pathSegments: template.segments,
1643
+ sharedInputKeys: sharedInputKeysOf(program, operation),
1644
+ decodedPathParameters: operation.parameters.parameters
1645
+ .filter((parameter) => decodedSegmentOf(program, parameter, template.segments) !== undefined)
1646
+ .map((parameter) => parameter.name),
1489
1647
  literalQuery: template.literalQuery,
1490
1648
  responseContentTypes: statusCode === undefined ? [] : responseContentTypesOf(operation, statusCode),
1491
- requestContentTypes: [...(operation.parameters.body?.contentTypes ?? [])],
1649
+ requestContentTypes: [...(requestBodyParameterOf(operation)?.contentTypes ?? [])],
1492
1650
  // `isRawBinaryMediaType` is "every declared type is non-JSON", which is the same question
1493
1651
  // here: a string body under `application/json` IS json, and its reader is the JSON one.
1494
1652
  requestTextual: requestType !== undefined &&
1495
1653
  rawBodyProperty === undefined &&
1496
1654
  isStringType(requestType) &&
1497
- isRawBinaryMediaType([...(operation.parameters.body?.contentTypes ?? [])]),
1655
+ isRawBinaryMediaType([...(requestBodyParameterOf(operation)?.contentTypes ?? [])]),
1498
1656
  /**
1499
1657
  * `@summary` first, `@doc` as the fallback.
1500
1658
  *
@@ -1514,7 +1672,7 @@ export function collectRoutes(program, registry, service) {
1514
1672
  requestSchema: withVisibility(program, requestVisibility, () => {
1515
1673
  // Multipart first: its body has no single type to walk, and skipping it left every
1516
1674
  // such route mounted with nothing checking the request at all.
1517
- const parameterBody = operation.parameters.body;
1675
+ const parameterBody = requestBodyParameterOf(operation);
1518
1676
  if (parameterBody?.bodyKind === "multipart") {
1519
1677
  return multipartSchemaOf(program, parameterBody, registry);
1520
1678
  }
@@ -1581,9 +1739,121 @@ export function collectRoutes(program, registry, service) {
1581
1739
  * Tokenising has neither, needs no escaping to stay correct, and is one pass rather than one pattern
1582
1740
  * per name. `typespec-hono` arrived at it after the same defect went live there: a spec whose every
1583
1741
  * identifier carried a `$` emitted a file that imported nothing and did not compile.
1742
+ *
1743
+ * **Only CODE is tokenised: string, template and regular-expression literals and comments are
1744
+ * skipped.** Spec-authored text reaches emitted source inside them - a `.describe("...")`, a
1745
+ * `.regex(/.../)` - and a word in that text is not a reference. Measured: a parameter documented as
1746
+ * "names uriExpansion and explodedQuery" declared both RFC 6570 decoders in a service that decodes
1747
+ * nothing, and the file failed `noUnusedLocals` - the lint a generated file has to pass like any
1748
+ * other. A literal is told from code by its opening character; a `/` opens a regular expression only
1749
+ * where an expression may start, which is the rule a JavaScript scanner uses, and this emitter
1750
+ * writes no division.
1584
1751
  */
1585
1752
  function identifiersIn(source) {
1586
- return new Set(source.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []);
1753
+ const found = new Set();
1754
+ scanCode(source, 0, found, false);
1755
+ return found;
1756
+ }
1757
+ /**
1758
+ * Collect the identifiers of the code from `start`, returning where it stopped: at the end, or -
1759
+ * `nested`, for a template's `${...}` - just past the brace that closes it.
1760
+ */
1761
+ function scanCode(source, start, found, nested) {
1762
+ const identifier = /[A-Za-z_$][A-Za-z0-9_$]*/y;
1763
+ let previous = "";
1764
+ let depth = 0;
1765
+ let index = start;
1766
+ while (index < source.length) {
1767
+ const char = source.charAt(index);
1768
+ const next = source.charAt(index + 1);
1769
+ if (char === "`") {
1770
+ index = scanTemplate(source, index, found);
1771
+ previous = char;
1772
+ }
1773
+ else if (char === '"' || char === "'") {
1774
+ index = quotedEnd(source, index, char);
1775
+ previous = char;
1776
+ }
1777
+ else if (char === "/" && next === "/") {
1778
+ const end = source.indexOf("\n", index);
1779
+ index = end === -1 ? source.length : end;
1780
+ }
1781
+ else if (char === "/" && next === "*") {
1782
+ const end = source.indexOf("*/", index + 2);
1783
+ index = end === -1 ? source.length : end + 2;
1784
+ }
1785
+ else if (char === "/" && (previous === "" || "(,=:[!&|?{};".includes(previous))) {
1786
+ index = regexEnd(source, index);
1787
+ previous = char;
1788
+ }
1789
+ else if (/[A-Za-z_$]/.test(char)) {
1790
+ identifier.lastIndex = index;
1791
+ const [name = char] = identifier.exec(source) ?? [];
1792
+ found.add(name);
1793
+ index += name.length;
1794
+ previous = "a";
1795
+ }
1796
+ else {
1797
+ if (char === "}" && nested && depth === 0)
1798
+ return index + 1;
1799
+ if (char === "{")
1800
+ depth += 1;
1801
+ if (char === "}")
1802
+ depth -= 1;
1803
+ if (!/\s/.test(char))
1804
+ previous = char;
1805
+ index += 1;
1806
+ }
1807
+ }
1808
+ return index;
1809
+ }
1810
+ /** Past a template literal opened at `start`, collecting the code in each `${...}` on the way. */
1811
+ function scanTemplate(source, start, found) {
1812
+ let index = start + 1;
1813
+ while (index < source.length) {
1814
+ const char = source.charAt(index);
1815
+ if (char === "\\")
1816
+ index += 2;
1817
+ else if (char === "`")
1818
+ return index + 1;
1819
+ else if (char === "$" && source.charAt(index + 1) === "{") {
1820
+ index = scanCode(source, index + 2, found, true);
1821
+ }
1822
+ else
1823
+ index += 1;
1824
+ }
1825
+ return index;
1826
+ }
1827
+ /** Past a quoted string opened at `start`, escapes honoured. */
1828
+ function quotedEnd(source, start, quote) {
1829
+ for (let index = start + 1; index < source.length; index += 1) {
1830
+ const char = source.charAt(index);
1831
+ if (char === "\\")
1832
+ index += 1;
1833
+ else if (char === quote)
1834
+ return index + 1;
1835
+ }
1836
+ return source.length;
1837
+ }
1838
+ /** Past a regular-expression literal opened at `start`, flags included. */
1839
+ function regexEnd(source, start) {
1840
+ let inClass = false;
1841
+ for (let index = start + 1; index < source.length; index += 1) {
1842
+ const char = source.charAt(index);
1843
+ if (char === "\\")
1844
+ index += 1;
1845
+ else if (char === "[")
1846
+ inClass = true;
1847
+ else if (char === "]")
1848
+ inClass = false;
1849
+ else if (char === "/" && !inClass) {
1850
+ let end = index + 1;
1851
+ while (/[a-z]/.test(source.charAt(end)))
1852
+ end += 1;
1853
+ return end;
1854
+ }
1855
+ }
1856
+ return source.length;
1587
1857
  }
1588
1858
  /** Whether emitted source references `name` as an identifier rather than as part of a longer one. */
1589
1859
  export function referencedIn(source, name) {
@@ -1595,7 +1865,7 @@ function renderExternalImports(externals, ...bodies) {
1595
1865
  for (const [module, names] of externals) {
1596
1866
  const used = [...names].filter((name) => referencedIn(haystack, name));
1597
1867
  if (used.length > 0) {
1598
- lines.push(`import { ${used.toSorted().join(", ")} } from ${JSON.stringify(module)};\n`);
1868
+ lines.push(`import { ${used.toSorted().join(", ")} } from ${literal(module)};\n`);
1599
1869
  }
1600
1870
  }
1601
1871
  return lines.join("");
@@ -1944,11 +2214,11 @@ function responseArmsOf(route, arms) {
1944
2214
  const schema = arms[index]?.schema ?? "undefined";
1945
2215
  const contentTypes = response.contentTypes.length === 0
1946
2216
  ? ""
1947
- : `, contentTypes: [${response.contentTypes.map((type) => JSON.stringify(type)).join(", ")}]`;
2217
+ : `, contentTypes: [${response.contentTypes.map((type) => literal(type)).join(", ")}]`;
1948
2218
  const headers = response.headers.length === 0
1949
2219
  ? ""
1950
- : `, headers: [${response.headers.map((header) => `{ name: ${JSON.stringify(header.name)}, optional: ${header.optional} }`).join(", ")}]`;
1951
- return `{ status: ${JSON.stringify(response.status)}, schema: ${schema}${contentTypes}${headers} }`;
2220
+ : `, headers: [${response.headers.map((header) => `{ name: ${literal(header.name)}, optional: ${header.optional} }`).join(", ")}]`;
2221
+ return `{ status: ${literal(response.status)}, schema: ${schema}${contentTypes}${headers} }`;
1952
2222
  });
1953
2223
  return `[${rendered.join(", ")}]`;
1954
2224
  }
@@ -2000,7 +2270,7 @@ function collectRequestTypes(program, registry, service) {
2000
2270
  const body = requestBodyOf(operation);
2001
2271
  const placement = requestBodyPlacementOf(program, operation);
2002
2272
  /** The parts of a multipart body, which `requestBodyOf` deliberately does not return. */
2003
- const parameterBody = operation.parameters.body;
2273
+ const parameterBody = requestBodyParameterOf(operation);
2004
2274
  /**
2005
2275
  * Headers are part of the input too.
2006
2276
  *
@@ -2020,11 +2290,24 @@ function collectRequestTypes(program, registry, service) {
2020
2290
  * `"x-thing"` here too: a consumer checking its handlers against these types otherwise
2021
2291
  * fails to typecheck against a server that is correct.
2022
2292
  */
2023
- .map((parameter) => registry.expressionForProperty(parameter.param, parameter.name));
2293
+ .map((parameter) => ({
2294
+ key: parameter.name,
2295
+ line: registry.expressionForProperty(parameter.param, parameter.name),
2296
+ }));
2024
2297
  const fileContentType = fileContentTypeOf(operation);
2025
2298
  if (fileContentType !== undefined) {
2026
- extraProperties.push(`${objectKey("Content-Type")}: ${registry.expressionFor(fileContentType.type)};`);
2299
+ extraProperties.push({
2300
+ key: "Content-Type",
2301
+ line: `${objectKey("Content-Type")}: ${registry.expressionFor(fileContentType.type)};`,
2302
+ });
2027
2303
  }
2304
+ /**
2305
+ * **A key two parameters share is written once per declaration, each in its own part**,
2306
+ * so the intersection types it as both - what the validators beside it accept - where two
2307
+ * members of one object literal do not compile (TS2300). `shared-input-key` reports it.
2308
+ */
2309
+ const repeated = extraProperties.filter((property, index) => extraProperties.slice(0, index).some((earlier) => earlier.key === property.key));
2310
+ const distinct = extraProperties.filter((property) => !repeated.includes(property));
2028
2311
  const parts = [];
2029
2312
  let streamsBody = false;
2030
2313
  let bodyRef;
@@ -2060,9 +2343,11 @@ function collectRequestTypes(program, registry, service) {
2060
2343
  : `{\n\t${objectKey(placement.bodyProperty)}${placement.optionalBody ? "?" : ""}: ${expression.replaceAll("\n", "\n\t")};\n}`);
2061
2344
  }
2062
2345
  }
2063
- if (extraProperties.length > 0) {
2064
- parts.push(`{\n${extraProperties.map((line) => `\t${line}`).join("\n")}\n}`);
2346
+ if (distinct.length > 0) {
2347
+ parts.push(`{\n${distinct.map(({ line }) => `\t${line}`).join("\n")}\n}`);
2065
2348
  }
2349
+ for (const { line } of repeated)
2350
+ parts.push(`{\n\t${line}\n}`);
2066
2351
  entries.push({
2067
2352
  operationId: operationIdOf(program, operation.operation),
2068
2353
  bodyRef,
@@ -2528,7 +2813,7 @@ ${assertions.join("\n")}
2528
2813
  function renderVocabularies(vocabularies) {
2529
2814
  const entries = [...vocabularies.entries()]
2530
2815
  .toSorted(([a], [b]) => a.localeCompare(b))
2531
- .map(([name, values]) => `\t${name}: [${values.map((v) => JSON.stringify(v)).join(", ")}],`);
2816
+ .map(([name, values]) => `\t${name}: [${values.map((v) => literal(v)).join(", ")}],`);
2532
2817
  return `${generatedBanner()}
2533
2818
  /**
2534
2819
  * The spec's vocabularies - every enum the surface reaches, and what each one contains.