@contractkit/plugin-typescript 0.27.0 → 0.28.1

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.
Files changed (41) hide show
  1. package/.turbo/turbo-build$colon$ci.log +3 -3
  2. package/.turbo/turbo-test$colon$ci.log +19 -18
  3. package/CHANGELOG.md +14 -0
  4. package/dist/codegen-contract.d.ts.map +1 -1
  5. package/dist/codegen-sdk.d.ts.map +1 -1
  6. package/dist/index.js +239 -215
  7. package/dist/index.js.map +1 -1
  8. package/dist/path-utils.d.ts.map +1 -1
  9. package/dist/ts-render.d.ts +6 -0
  10. package/dist/ts-render.d.ts.map +1 -1
  11. package/package.json +4 -4
  12. package/src/codegen-contract.ts +7 -4
  13. package/src/codegen-operation.ts +9 -9
  14. package/src/codegen-plain-types.ts +17 -14
  15. package/src/codegen-sdk.ts +5 -4
  16. package/src/path-utils.ts +37 -20
  17. package/src/ts-render.ts +21 -6
  18. package/tests/codegen-contract.test.ts +4 -0
  19. package/tests/codegen-operation.test.ts +5 -5
  20. package/tests/codegen-sdk.test.ts +8 -0
  21. package/tests/escaping-security.test.ts +143 -0
  22. package/coverage/base.css +0 -224
  23. package/coverage/block-navigation.js +0 -87
  24. package/coverage/clover.xml +0 -2213
  25. package/coverage/coverage-final.json +0 -9
  26. package/coverage/favicon.png +0 -0
  27. package/coverage/index.html +0 -131
  28. package/coverage/prettify.css +0 -1
  29. package/coverage/prettify.js +0 -2
  30. package/coverage/sort-arrow-sprite.png +0 -0
  31. package/coverage/sorter.js +0 -210
  32. package/coverage/src/codegen-contract.ts.html +0 -3661
  33. package/coverage/src/codegen-operation.ts.html +0 -2584
  34. package/coverage/src/codegen-plain-types.ts.html +0 -997
  35. package/coverage/src/codegen-sdk.ts.html +0 -3901
  36. package/coverage/src/index.html +0 -206
  37. package/coverage/src/index.ts.html +0 -2761
  38. package/coverage/src/path-utils.ts.html +0 -745
  39. package/coverage/src/ts-render.ts.html +0 -592
  40. package/coverage/tests/helpers.ts.html +0 -826
  41. package/coverage/tests/index.html +0 -116
@@ -1 +1 @@
1
- {"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../src/path-utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGtE,eAAO,MAAM,eAAe,QAAY,CAAC;AAEzC,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAEtF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAclE;AAID,wBAAgB,gBAAgB,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAiBR;AAED,wBAAgB,sBAAsB,CAClC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAER;AAID,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAkBf;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBnH;AAED,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAef;AAED,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,CAiBnG;AAED,wBAAgB,6BAA6B,CACzC,MAAM,EAAE,UAAU,EAAE,EACpB,YAAY,EAAE,gBAAgB,EAAE,EAChC,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,EAC5B,gBAAgB,GAAE,GAAG,CAAC,MAAM,CAAa,GAC1C,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CA0CpB"}
1
+ {"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../src/path-utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGtE,eAAO,MAAM,eAAe,QAAY,CAAC;AAmBzC,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAEtF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAclE;AAID,wBAAgB,gBAAgB,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAiBR;AAED,wBAAgB,sBAAsB,CAClC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAER;AAID,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAkBf;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBnH;AAED,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAef;AAED,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,CAiBnG;AAED,wBAAgB,6BAA6B,CACzC,MAAM,EAAE,UAAU,EAAE,EACpB,YAAY,EAAE,gBAAgB,EAAE,EAChC,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,EAC5B,gBAAgB,GAAE,GAAG,CAAC,MAAM,CAAa,GAC1C,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CA0CpB"}
@@ -1,6 +1,12 @@
1
1
  import type { ContractTypeNode } from '@contractkit/core';
2
2
  export declare const JSON_VALUE_TYPE_DECL = "export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };";
3
3
  export declare function quoteKey(name: string): string;
4
+ /** Escape text for safe inclusion inside a JSDoc block comment: neutralize the
5
+ * block-comment terminator sequence and split embedded newlines into separate
6
+ * ` * ` continuation lines. Returns the content lines (WITHOUT a leading prefix). */
7
+ export declare function escapeJsDocLines(text: string): string[];
8
+ /** Escape a string for inclusion inside a single-quoted TypeScript string literal. */
9
+ export declare function escapeSingleQuoted(s: string): string;
4
10
  /** Convert an HTTP header name (e.g. `preference-applied`, `X-Request-ID`, `ETag`) to camelCase for use as a JS property. */
5
11
  export declare function headerNameToProperty(name: string): string;
6
12
  export declare function renderTsType(type: ContractTypeNode): string;
@@ -1 +1 @@
1
- {"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAa,MAAM,mBAAmB,CAAC;AAErE,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,CAoC3D;AA4CD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2B/F;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2BjG"}
1
+ {"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,CAoC3D;AA+CD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2B/F;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2BjG"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.27.0",
3
+ "version": "0.28.1",
4
4
  "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
5
5
  "author": {
6
6
  "name": "Marooned Software",
@@ -26,11 +26,11 @@
26
26
  ".": "./dist/index.js"
27
27
  },
28
28
  "dependencies": {
29
- "@contractkit/core": "0.22.0"
29
+ "@contractkit/core": "0.23.0"
30
30
  },
31
31
  "devDependencies": {
32
- "@repo/config-typescript": "0.1.0",
33
- "@repo/config-eslint": "0.3.1"
32
+ "@repo/config-eslint": "0.3.1",
33
+ "@repo/config-typescript": "0.1.0"
34
34
  },
35
35
  "scripts": {
36
36
  "build": "tsup src/index.ts --format esm --sourcemap --dts && tsc --emitDeclarationOnly --declaration",
@@ -21,6 +21,7 @@ import {
21
21
  computeModelsWithOutput as ckComputeModelsWithOutput,
22
22
  collectExternalOutputRefs as ckCollectExternalOutputRefs,
23
23
  } from '@contractkit/core';
24
+ import { escapeJsDocLines } from './ts-render.js';
24
25
 
25
26
  /**
26
27
  * Maps a ContractKit object mode to its Zod constructor name.
@@ -108,7 +109,7 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
108
109
  lines.push(` * @deprecated`);
109
110
  }
110
111
  if (model.description) {
111
- lines.push(` * ${model.description}`);
112
+ for (const l of escapeJsDocLines(model.description)) lines.push(` * ${l}`);
112
113
  }
113
114
 
114
115
  const relPath = outPath ? relative(dirname(outPath), model.loc.file) : model.loc.file;
@@ -664,8 +665,10 @@ function renderScalar(s: ScalarTypeNode): string {
664
665
  return '_ZodBinary';
665
666
  case 'json':
666
667
  return '_ZodJson';
667
- default:
668
- return 'z.unknown()';
668
+ default: {
669
+ const _exhaustive: never = s.name;
670
+ throw new Error(`plugin-typescript: unmapped scalar '${String(_exhaustive)}' — add a case`);
671
+ }
669
672
  }
670
673
  }
671
674
 
@@ -685,7 +688,7 @@ function renderRecord(r: RecordTypeNode): string {
685
688
  }
686
689
 
687
690
  function renderEnum(e: EnumTypeNode): string {
688
- const vals = e.values.map(v => `"${v}"`).join(', ');
691
+ const vals = e.values.map(v => `"${escapeString(v)}"`).join(', ');
689
692
  return `z.enum([${vals}])`;
690
693
  }
691
694
 
@@ -9,7 +9,7 @@ import {
9
9
  typeNeedsScalar,
10
10
  modeToWrapper,
11
11
  } from './codegen-contract.js';
12
- import { renderOutputTsType, quoteKey, headerNameToProperty } from './ts-render.js';
12
+ import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted } from './ts-render.js';
13
13
  import { basename, dirname, relative } from 'path';
14
14
 
15
15
  // ─── Content-type helpers ──────────────────────────────────────────────────
@@ -217,7 +217,7 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
217
217
  // JSDoc from description
218
218
  const desc = op.description ?? route.description;
219
219
  if (desc) {
220
- lines.push(` * ${desc}`);
220
+ for (const l of escapeJsDocLines(desc)) lines.push(` * ${l}`);
221
221
  }
222
222
  // Source location comment
223
223
  const relFile = outPath ? relative(dirname(outPath), file) : file;
@@ -261,8 +261,8 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
261
261
  }
262
262
  if (op.signature) {
263
263
  const sigArgs = op.signaturePolicy
264
- ? `'${op.signature}', { policy: '${op.signaturePolicy}' }`
265
- : `'${op.signature}'`;
264
+ ? `'${escapeSingleQuoted(op.signature)}', { policy: '${escapeSingleQuoted(op.signaturePolicy)}' }`
265
+ : `'${escapeSingleQuoted(op.signature)}'`;
266
266
  middlewares.push(`requireSignature(${sigArgs})`);
267
267
  }
268
268
  const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
@@ -277,14 +277,14 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
277
277
  // Body validation (request-side — use Input variants)
278
278
  if (hasBody && op.request) {
279
279
  if (isSingleMultipart) {
280
- lines.push(` const multipartBody = ctx.body as MultipartBody;`);
280
+ lines.push(` const multipartBody = ctx.parsedBody as MultipartBody;`);
281
281
  lines.push('');
282
282
  } else if (bodies.length === 1) {
283
- lines.push(` const body = await parseAndValidate(ctx.body, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
283
+ lines.push(` const body = await parseAndValidate(ctx.parsedBody, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
284
284
  lines.push('');
285
285
  } else if (bodies.every(b => bodyTypesStructurallyEqual(b.bodyType, bodies[0]!.bodyType))) {
286
286
  // All declared MIMEs share the same body shape — single validation suffices
287
- lines.push(` const body = await parseAndValidate(ctx.body, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
287
+ lines.push(` const body = await parseAndValidate(ctx.parsedBody, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
288
288
  lines.push('');
289
289
  } else {
290
290
  // Different body types per MIME — dispatch on Content-Type
@@ -298,9 +298,9 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
298
298
  for (const b of bodies) {
299
299
  lines.push(` case '${b.contentType}':`);
300
300
  if (b.contentType === 'multipart/form-data') {
301
- lines.push(` body = ctx.body as MultipartBody;`);
301
+ lines.push(` body = ctx.parsedBody as MultipartBody;`);
302
302
  } else {
303
- lines.push(` body = await parseAndValidate(ctx.body, ${renderInputType(b.bodyType, modelsWithInput)});`);
303
+ lines.push(` body = await parseAndValidate(ctx.parsedBody, ${renderInputType(b.bodyType, modelsWithInput)});`);
304
304
  }
305
305
  lines.push(` break;`);
306
306
  }
@@ -10,7 +10,7 @@ import {
10
10
  resolveImportPath,
11
11
  rootNeedsScalar,
12
12
  } from './codegen-contract.js';
13
- import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, JSON_VALUE_TYPE_DECL } from './ts-render.js';
13
+ import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
14
14
 
15
15
  // ─── Public entry point ────────────────────────────────────────────────────
16
16
 
@@ -123,7 +123,7 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
123
123
  lines.push(` * @deprecated`);
124
124
  }
125
125
  if (model.description) {
126
- lines.push(` * ${model.description}`);
126
+ for (const l of escapeJsDocLines(model.description)) lines.push(` * ${l}`);
127
127
  }
128
128
 
129
129
  const relPath = outPath ? relative(dirname(outPath), model.loc.file) : model.loc.file;
@@ -205,6 +205,18 @@ function generateVisibilityModel(model: ModelNode, outPath?: string, modelsWithI
205
205
 
206
206
  // ─── Field rendering ──────────────────────────────────────────────────────
207
207
 
208
+ /** Prefix a field declaration with a JSDoc comment built from `@deprecated` / description parts,
209
+ * neutralizing any block-comment terminator and expanding embedded newlines into continuation lines. */
210
+ function withFieldJsDoc(jsdocParts: string[], line: string): string {
211
+ if (jsdocParts.length === 0) return line;
212
+ const contentLines = escapeJsDocLines(jsdocParts.join(' '));
213
+ if (contentLines.length === 1) {
214
+ return `/** ${contentLines[0]} */\n ${line}`;
215
+ }
216
+ const body = contentLines.map(l => ` * ${l}`).join('\n');
217
+ return `/**\n${body}\n */\n ${line}`;
218
+ }
219
+
208
220
  function renderField(field: FieldNode): string {
209
221
  const opt = field.optional || field.default !== undefined ? '?' : '';
210
222
  let typeStr = renderTsType(field.type);
@@ -213,10 +225,7 @@ function renderField(field: FieldNode): string {
213
225
  const jsdocParts: string[] = [];
214
226
  if (field.deprecated) jsdocParts.push('@deprecated');
215
227
  if (field.description) jsdocParts.push(field.description);
216
- if (jsdocParts.length > 0) {
217
- return `/** ${jsdocParts.join(' ')} */\n ${line}`;
218
- }
219
- return line;
228
+ return withFieldJsDoc(jsdocParts, line);
220
229
  }
221
230
 
222
231
  function renderInputField(field: FieldNode, modelsWithInput: Set<string>): string {
@@ -227,10 +236,7 @@ function renderInputField(field: FieldNode, modelsWithInput: Set<string>): strin
227
236
  const jsdocParts: string[] = [];
228
237
  if (field.deprecated) jsdocParts.push('@deprecated');
229
238
  if (field.description) jsdocParts.push(field.description);
230
- if (jsdocParts.length > 0) {
231
- return `/** ${jsdocParts.join(' ')} */\n ${line}`;
232
- }
233
- return line;
239
+ return withFieldJsDoc(jsdocParts, line);
234
240
  }
235
241
 
236
242
  // ─── Output (post-transform wire shape) ──────────────────────────────────
@@ -297,8 +303,5 @@ function renderOutputField(field: FieldNode, outputCase: 'camel' | 'snake' | 'pa
297
303
  const jsdocParts: string[] = [];
298
304
  if (field.deprecated) jsdocParts.push('@deprecated');
299
305
  if (field.description) jsdocParts.push(field.description);
300
- if (jsdocParts.length > 0) {
301
- return `/** ${jsdocParts.join(' ')} */\n ${line}`;
302
- }
303
- return line;
306
+ return withFieldJsDoc(jsdocParts, line);
304
307
  }
@@ -1,6 +1,6 @@
1
1
  import type { OpRootNode, OpRouteNode, OpOperationNode, OpRequestBodyNode, ContractTypeNode, ParamSource } from '@contractkit/core';
2
2
  import { resolveModifiers, isJsonMime, classifyContentType } from '@contractkit/core';
3
- import { renderInputTsType, renderOutputTsType, quoteKey, headerNameToProperty, JSON_VALUE_TYPE_DECL } from './ts-render.js';
3
+ import { renderInputTsType, renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
4
4
  import { pascalToDotCase, typeNeedsScalar } from './codegen-contract.js';
5
5
  import { bodyTypesStructurallyEqual } from './codegen-operation.js';
6
6
  import { basename, dirname, relative } from 'path';
@@ -289,11 +289,12 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, file: string, o
289
289
  const tags: string[] = [];
290
290
  if (op.name) tags.push(`@name ${op.name}`);
291
291
  if (desc) tags.push(`@description ${desc}`);
292
- if (tags.length === 1) {
293
- lines.push(` /** ${tags[0]} */`);
292
+ const contentLines = tags.flatMap(t => escapeJsDocLines(t));
293
+ if (contentLines.length === 1) {
294
+ lines.push(` /** ${contentLines[0]} */`);
294
295
  } else {
295
296
  lines.push(` /**`);
296
- for (const tag of tags) lines.push(` * ${tag}`);
297
+ for (const l of contentLines) lines.push(` * ${l}`);
297
298
  lines.push(` */`);
298
299
  }
299
300
  }
package/src/path-utils.ts CHANGED
@@ -1,9 +1,26 @@
1
- import { resolve, join, relative, dirname } from 'node:path';
1
+ import { resolve, join, relative, dirname, isAbsolute } from 'node:path';
2
2
  import type { ContractRootNode, OpRootNode } from '@contractkit/core';
3
3
  import { collectTypeRefs, collectPublicTypeNames } from '@contractkit/core';
4
4
 
5
5
  export const TEMPLATE_VAR_RE = /\{\w+\}/;
6
6
 
7
+ /**
8
+ * Guard against path traversal in output-path templates. Output-path template variables
9
+ * (`{area}`, `{dir}`, `{filename}`, `{name}`) can be sourced from a `.ck` file's
10
+ * `options { keys }` block, so a malicious value like `../../../tmp/x` could escape the
11
+ * plugin's output directory. After the final absolute path is computed, verify it stays
12
+ * within `baseOutDir`; otherwise throw.
13
+ */
14
+ function assertWithinBase(baseOutDir: string, outPath: string): string {
15
+ const rel = relative(resolve(baseOutDir), resolve(outPath));
16
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
17
+ throw new Error(
18
+ `Refusing to emit outside output directory: resolved path "${outPath}" escapes "${baseOutDir}" (check options { keys } values used in output path templates)`,
19
+ );
20
+ }
21
+ return outPath;
22
+ }
23
+
7
24
  export function resolveTemplate(template: string, vars: Record<string, string>): string {
8
25
  return template.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? `{${key}}`);
9
26
  }
@@ -47,14 +64,14 @@ export function computeOpOutPath(
47
64
 
48
65
  if (output && TEMPLATE_VAR_RE.test(output)) {
49
66
  const resolved = resolveTemplate(output, { filename, dir: relDir, ext: 'ck', ...meta });
50
- if (includesFilename(resolved)) return join(baseOutDir, resolved);
51
- return join(baseOutDir, resolved, defaultName);
67
+ if (includesFilename(resolved)) return assertWithinBase(baseOutDir, join(baseOutDir, resolved));
68
+ return assertWithinBase(baseOutDir, join(baseOutDir, resolved, defaultName));
52
69
  }
53
70
  if (output) {
54
- if (includesFilename(output)) return join(baseOutDir, output);
55
- return join(baseOutDir, output, relDir, defaultName);
71
+ if (includesFilename(output)) return assertWithinBase(baseOutDir, join(baseOutDir, output));
72
+ return assertWithinBase(baseOutDir, join(baseOutDir, output, relDir, defaultName));
56
73
  }
57
- return join(baseOutDir, relDir, defaultName);
74
+ return assertWithinBase(baseOutDir, join(baseOutDir, relDir, defaultName));
58
75
  }
59
76
 
60
77
  export function computeContractOutPath(
@@ -86,14 +103,14 @@ export function computeSdkOutPath(
86
103
 
87
104
  if (clientOutput && TEMPLATE_VAR_RE.test(clientOutput)) {
88
105
  const resolved = resolveTemplate(clientOutput, { filename, dir: relDir, ext: 'ck', ...meta });
89
- if (includesFilename(resolved)) return join(baseOutDir, resolved);
90
- return join(baseOutDir, resolved, defaultOutName);
106
+ if (includesFilename(resolved)) return assertWithinBase(baseOutDir, join(baseOutDir, resolved));
107
+ return assertWithinBase(baseOutDir, join(baseOutDir, resolved, defaultOutName));
91
108
  }
92
109
  if (clientOutput) {
93
- if (includesFilename(clientOutput)) return join(baseOutDir, clientOutput);
94
- return join(baseOutDir, clientOutput, relDir, defaultOutName);
110
+ if (includesFilename(clientOutput)) return assertWithinBase(baseOutDir, join(baseOutDir, clientOutput));
111
+ return assertWithinBase(baseOutDir, join(baseOutDir, clientOutput, relDir, defaultOutName));
95
112
  }
96
- return join(baseOutDir, relDir, defaultOutName);
113
+ return assertWithinBase(baseOutDir, join(baseOutDir, relDir, defaultOutName));
97
114
  }
98
115
 
99
116
  /**
@@ -118,14 +135,14 @@ export function computeSdkAreaClientOutPath(area: string, rootDir: string, clien
118
135
  if (clientOutput && TEMPLATE_VAR_RE.test(clientOutput)) {
119
136
  const resolved = resolveTemplate(clientOutput, { filename, dir: '', ext: 'ck', area, subarea: '' });
120
137
  const cleaned = fixHiddenSegment(resolved.replace(/\/+/g, '/').replace(/^\//, ''));
121
- if (includesFilename(cleaned)) return join(baseOutDir, cleaned);
122
- return join(baseOutDir, cleaned, `${filename}.client.ts`);
138
+ if (includesFilename(cleaned)) return assertWithinBase(baseOutDir, join(baseOutDir, cleaned));
139
+ return assertWithinBase(baseOutDir, join(baseOutDir, cleaned, `${filename}.client.ts`));
123
140
  }
124
141
  if (clientOutput) {
125
- if (includesFilename(clientOutput)) return join(baseOutDir, clientOutput);
126
- return join(baseOutDir, clientOutput, `${filename}.client.ts`);
142
+ if (includesFilename(clientOutput)) return assertWithinBase(baseOutDir, join(baseOutDir, clientOutput));
143
+ return assertWithinBase(baseOutDir, join(baseOutDir, clientOutput, `${filename}.client.ts`));
127
144
  }
128
- return join(baseOutDir, `${filename}.client.ts`);
145
+ return assertWithinBase(baseOutDir, join(baseOutDir, `${filename}.client.ts`));
129
146
  }
130
147
 
131
148
  export function computeSdkTypeOutPath(
@@ -144,11 +161,11 @@ export function computeSdkTypeOutPath(
144
161
 
145
162
  if (TEMPLATE_VAR_RE.test(typeOutput)) {
146
163
  const resolved = resolveTemplate(typeOutput, { filename, dir: relDir, ext: 'ck', ...meta });
147
- if (includesFilename(resolved)) return join(baseOutDir, resolved);
148
- return join(baseOutDir, resolved, defaultOutName);
164
+ if (includesFilename(resolved)) return assertWithinBase(baseOutDir, join(baseOutDir, resolved));
165
+ return assertWithinBase(baseOutDir, join(baseOutDir, resolved, defaultOutName));
149
166
  }
150
- if (includesFilename(typeOutput)) return join(baseOutDir, typeOutput);
151
- return join(baseOutDir, typeOutput, relDir, defaultOutName);
167
+ if (includesFilename(typeOutput)) return assertWithinBase(baseOutDir, join(baseOutDir, typeOutput));
168
+ return assertWithinBase(baseOutDir, join(baseOutDir, typeOutput, relDir, defaultOutName));
152
169
  }
153
170
 
154
171
  export function generateBarrelFiles(contractPaths: string[]): { outPath: string; content: string }[] {
package/src/ts-render.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ContractTypeNode, FieldNode } from '@contractkit/core';
1
+ import type { ContractTypeNode, FieldNode, ScalarTypeNode } from '@contractkit/core';
2
2
 
3
3
  export const JSON_VALUE_TYPE_DECL = 'export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };';
4
4
 
@@ -6,6 +6,18 @@ export function quoteKey(name: string): string {
6
6
  return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `'${name}'`;
7
7
  }
8
8
 
9
+ /** Escape text for safe inclusion inside a JSDoc block comment: neutralize the
10
+ * block-comment terminator sequence and split embedded newlines into separate
11
+ * ` * ` continuation lines. Returns the content lines (WITHOUT a leading prefix). */
12
+ export function escapeJsDocLines(text: string): string[] {
13
+ return text.replace(/\*\//g, '*\\/').split('\n');
14
+ }
15
+
16
+ /** Escape a string for inclusion inside a single-quoted TypeScript string literal. */
17
+ export function escapeSingleQuoted(s: string): string {
18
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
19
+ }
20
+
9
21
  /** Convert an HTTP header name (e.g. `preference-applied`, `X-Request-ID`, `ETag`) to camelCase for use as a JS property. */
10
22
  export function headerNameToProperty(name: string): string {
11
23
  const parts = name.split(/[-_]/).filter(Boolean);
@@ -37,9 +49,9 @@ export function renderTsType(type: ContractTypeNode): string {
37
49
  case 'record':
38
50
  return `Record<${renderTsType(type.key)}, ${renderTsType(type.value)}>`;
39
51
  case 'enum':
40
- return type.values.map(v => `'${v}'`).join(' | ');
52
+ return type.values.map(v => `'${escapeSingleQuoted(v)}'`).join(' | ');
41
53
  case 'literal':
42
- return typeof type.value === 'string' ? `'${type.value}'` : String(type.value);
54
+ return typeof type.value === 'string' ? `'${escapeSingleQuoted(type.value)}'` : String(type.value);
43
55
  case 'union':
44
56
  return type.members.map(renderTsType).join(' | ');
45
57
  case 'discriminatedUnion':
@@ -57,7 +69,7 @@ export function renderTsType(type: ContractTypeNode): string {
57
69
  }
58
70
  }
59
71
 
60
- function renderTsScalar(name: string): string {
72
+ function renderTsScalar(name: ScalarTypeNode['name']): string {
61
73
  switch (name) {
62
74
  case 'string':
63
75
  case 'email':
@@ -72,6 +84,7 @@ function renderTsScalar(name: string): string {
72
84
  case 'boolean':
73
85
  return 'boolean';
74
86
  case 'date':
87
+ case 'time':
75
88
  case 'datetime':
76
89
  case 'duration':
77
90
  case 'interval':
@@ -86,8 +99,10 @@ function renderTsScalar(name: string): string {
86
99
  return 'Blob';
87
100
  case 'json':
88
101
  return 'JsonValue';
89
- default:
90
- return 'unknown';
102
+ default: {
103
+ const _exhaustive: never = name;
104
+ throw new Error(`plugin-typescript: unmapped scalar '${String(_exhaustive)}' — add a case`);
105
+ }
91
106
  }
92
107
  }
93
108
 
@@ -26,6 +26,10 @@ describe('renderType', () => {
26
26
  expect(renderType(scalarType('string'))).toBe('z.string()');
27
27
  });
28
28
 
29
+ it('throws on an unmapped scalar name', () => {
30
+ expect(() => renderType({ kind: 'scalar', name: 'decimal' } as any)).toThrow(/unmapped scalar 'decimal'/);
31
+ });
32
+
29
33
  it('renders z.string() with min/max', () => {
30
34
  expect(renderType(scalarType('string', { min: 1, max: 100 }))).toBe('z.string().min(1).max(100)');
31
35
  });
@@ -132,7 +132,7 @@ describe('generateOperation', () => {
132
132
  it('generates body validation with parseAndValidate', () => {
133
133
  const root = opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])]);
134
134
  const output = generateOp(root);
135
- expect(output).toContain('parseAndValidate(ctx.body, CreateUser)');
135
+ expect(output).toContain('parseAndValidate(ctx.parsedBody, CreateUser)');
136
136
  });
137
137
 
138
138
  it('generates create service method for POST', () => {
@@ -172,7 +172,7 @@ describe('generateOperation', () => {
172
172
  ]),
173
173
  ]);
174
174
  const output = generateOp(root);
175
- expect(output).toContain('const body = await parseAndValidate(ctx.body, AuthRequest)');
175
+ expect(output).toContain('const body = await parseAndValidate(ctx.parsedBody, AuthRequest)');
176
176
  expect(output).not.toContain('switch (ctx.request.type)');
177
177
  });
178
178
 
@@ -191,8 +191,8 @@ describe('generateOperation', () => {
191
191
  expect(output).toContain('switch (ctx.request.type)');
192
192
  expect(output).toContain("case 'application/json':");
193
193
  expect(output).toContain("case 'multipart/form-data':");
194
- expect(output).toContain('body = ctx.body as MultipartBody;');
195
- expect(output).toContain('body = await parseAndValidate(ctx.body, UploadMeta)');
194
+ expect(output).toContain('body = ctx.parsedBody as MultipartBody;');
195
+ expect(output).toContain('body = await parseAndValidate(ctx.parsedBody, UploadMeta)');
196
196
  expect(output).toContain("import { MultipartBody } from '@maroonedsoftware/multipart';");
197
197
  });
198
198
  });
@@ -471,7 +471,7 @@ describe('generateOperation', () => {
471
471
  const root = opRoot([opRoute('/uploads', [opOperation('post', { request: opRequest('Upload', 'multipart/form-data') })])]);
472
472
  const output = generateOp(root);
473
473
  expect(output).toContain("bodyParserMiddleware(['multipart'])");
474
- expect(output).toContain('ctx.body as MultipartBody');
474
+ expect(output).toContain('ctx.parsedBody as MultipartBody');
475
475
  expect(output).toContain("import { MultipartBody } from '@maroonedsoftware/multipart';");
476
476
  });
477
477
  });
@@ -1110,6 +1110,14 @@ describe('renderTsType', () => {
1110
1110
  it('maps json to JsonValue', () => {
1111
1111
  expect(renderTsType(scalarType('json'))).toBe('JsonValue');
1112
1112
  });
1113
+
1114
+ it('maps time to string', () => {
1115
+ expect(renderTsType(scalarType('time'))).toBe('string');
1116
+ });
1117
+
1118
+ it('throws on an unmapped scalar name', () => {
1119
+ expect(() => renderTsType({ kind: 'scalar', name: 'decimal' } as any)).toThrow(/unmapped scalar 'decimal'/);
1120
+ });
1113
1121
  });
1114
1122
 
1115
1123
  it('renders tuple type', () => {
@@ -0,0 +1,143 @@
1
+ import { generateContract, renderType } from '../src/codegen-contract.js';
2
+ import { generatePlainTypes } from '../src/codegen-plain-types.js';
3
+ import { generateOp } from '../src/codegen-operation.js';
4
+ import { renderTsType, escapeJsDocLines, escapeSingleQuoted } from '../src/ts-render.js';
5
+ import { computeOpOutPath, computeSdkOutPath, computeSdkTypeOutPath, computeSdkAreaClientOutPath } from '../src/path-utils.js';
6
+ import { field, model, contractRoot, enumType, scalarType, opRoot, opRoute, opOperation, opResponse } from './helpers.js';
7
+
8
+ // ─── Fix 1: JSDoc comment injection via descriptions ────────────────────────
9
+
10
+ describe('JSDoc comment injection (descriptions)', () => {
11
+ it('neutralizes `*/` in a model description (Zod codegen)', () => {
12
+ const root = contractRoot([model('Widget', [field('id', scalarType('uuid'))], { description: 'closes */ early' })]);
13
+ const out = generateContract(root);
14
+ // The raw terminator must not appear inside the generated comment.
15
+ expect(out).not.toContain('closes */ early');
16
+ expect(out).toContain('closes *\\/ early');
17
+ });
18
+
19
+ it('splits a two-line model description into ` * ` continuation lines (Zod codegen)', () => {
20
+ const root = contractRoot([model('Widget', [field('id', scalarType('uuid'))], { description: 'line one\nline two' })]);
21
+ const out = generateContract(root);
22
+ expect(out).toContain(' * line one');
23
+ expect(out).toContain(' * line two');
24
+ });
25
+
26
+ it('neutralizes `*/` in a model description (plain-types codegen)', () => {
27
+ const root = contractRoot([model('Widget', [field('id', scalarType('uuid'))], { description: 'closes */ early' })]);
28
+ const out = generatePlainTypes(root);
29
+ expect(out).not.toContain('closes */ early');
30
+ expect(out).toContain('closes *\\/ early');
31
+ });
32
+
33
+ it('neutralizes `*/` in a field description (plain-types codegen)', () => {
34
+ const root = contractRoot([model('Widget', [field('id', scalarType('uuid'), { description: 'bad */ desc' })])]);
35
+ const out = generatePlainTypes(root);
36
+ expect(out).not.toContain('bad */ desc');
37
+ expect(out).toContain('bad *\\/ desc');
38
+ });
39
+
40
+ it('splits a two-line field description into a multi-line JSDoc block (plain-types codegen)', () => {
41
+ const root = contractRoot([model('Widget', [field('id', scalarType('uuid'), { description: 'first\nsecond' })])]);
42
+ const out = generatePlainTypes(root);
43
+ expect(out).toContain('first');
44
+ expect(out).toContain('* second');
45
+ // No premature close on the same physical line as content.
46
+ expect(out).not.toMatch(/first\nsecond/);
47
+ });
48
+
49
+ it('neutralizes `*/` in an operation description (server codegen)', () => {
50
+ const root = opRoot([opRoute('/x', [opOperation('get', { description: 'op */ desc', responses: [opResponse(200)] })])]);
51
+ const out = generateOp(root);
52
+ expect(out).not.toContain('op */ desc');
53
+ expect(out).toContain('op *\\/ desc');
54
+ });
55
+
56
+ it('escapeJsDocLines neutralizes terminators and splits newlines', () => {
57
+ expect(escapeJsDocLines('a */ b')).toEqual(['a *\\/ b']);
58
+ expect(escapeJsDocLines('a\nb')).toEqual(['a', 'b']);
59
+ });
60
+ });
61
+
62
+ // ─── Fix 2: enum / literal value escaping ───────────────────────────────────
63
+
64
+ describe('enum value escaping', () => {
65
+ it('escapes double and single quotes in a Zod enum', () => {
66
+ const out = renderType(enumType('a"b', "c'd"));
67
+ expect(out).toBe('z.enum(["a\\"b", "c\'d"])');
68
+ });
69
+
70
+ it('escapes single quotes in a TS union of enum values', () => {
71
+ const out = renderTsType(enumType('a"b', "c'd"));
72
+ expect(out).toBe("'a\"b' | 'c\\'d'");
73
+ });
74
+
75
+ it('escapes single quotes in a TS string literal', () => {
76
+ expect(renderTsType({ kind: 'literal', value: "it's" })).toBe("'it\\'s'");
77
+ });
78
+
79
+ it('escapeSingleQuoted escapes backslashes, quotes and newlines', () => {
80
+ expect(escapeSingleQuoted("a'b\\c\nd")).toBe("a\\'b\\\\c\\nd");
81
+ });
82
+ });
83
+
84
+ // ─── Fix 3: signature interpolation escaping ────────────────────────────────
85
+
86
+ describe('signature escaping (server codegen)', () => {
87
+ it('escapes a single quote in the signature value', () => {
88
+ const root = opRoot([opRoute('/x', [opOperation('get', { signature: "sig'v", responses: [opResponse(200)] })])]);
89
+ const out = generateOp(root);
90
+ expect(out).toContain("requireSignature('sig\\'v')");
91
+ });
92
+
93
+ it('escapes single quotes in signature and policy', () => {
94
+ const root = opRoot([
95
+ opRoute('/x', [opOperation('get', { signature: "sig'v", signaturePolicy: "pol'y", responses: [opResponse(200)] })]),
96
+ ]);
97
+ const out = generateOp(root);
98
+ expect(out).toContain("requireSignature('sig\\'v', { policy: 'pol\\'y' })");
99
+ });
100
+ });
101
+
102
+ // ─── Fix 4: path traversal via .ck-derived template vars ────────────────────
103
+
104
+ describe('path traversal containment', () => {
105
+ const base = '/project/out';
106
+
107
+ it('throws when a `.ck`-derived {area} escapes the base output dir', () => {
108
+ expect(() =>
109
+ computeOpOutPath('/project/contracts/a.ck', base, '{area}/{filename}.ts', '.ts', '/project/contracts', {
110
+ area: '../../../tmp/x',
111
+ }),
112
+ ).toThrow(/Refusing to emit outside output directory/);
113
+ });
114
+
115
+ it('throws when a `.ck`-derived {filename} escapes the base output dir', () => {
116
+ expect(() =>
117
+ computeSdkTypeOutPath('/project/contracts/a.ck', base, '{filename}.ts', '/project/contracts', {
118
+ filename: '../../etc/passwd',
119
+ }),
120
+ ).toThrow(/Refusing to emit outside output directory/);
121
+ });
122
+
123
+ it('throws for computeSdkOutPath traversal', () => {
124
+ expect(() =>
125
+ computeSdkOutPath('/project/contracts/a.ck', base, '{area}/{filename}.ts', '/project/contracts', {
126
+ area: '../../../evil',
127
+ }),
128
+ ).toThrow(/Refusing to emit outside output directory/);
129
+ });
130
+
131
+ it('throws for computeSdkAreaClientOutPath traversal', () => {
132
+ expect(() => computeSdkAreaClientOutPath('../../../evil', base, '{area}/{filename}.client.ts')).toThrow(
133
+ /Refusing to emit outside output directory/,
134
+ );
135
+ });
136
+
137
+ it('allows a normal in-base path', () => {
138
+ const p = computeOpOutPath('/project/contracts/a.ck', base, '{area}/{filename}.ts', '.ts', '/project/contracts', {
139
+ area: 'billing',
140
+ });
141
+ expect(p).toBe('/project/out/billing/a.ts');
142
+ });
143
+ });