@contractkit/plugin-typescript 0.28.0 → 0.28.2

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 (51) hide show
  1. package/.turbo/turbo-build$colon$ci.log +5 -5
  2. package/.turbo/turbo-test$colon$ci.log +22 -19
  3. package/CHANGELOG.md +14 -0
  4. package/dist/codegen-contract.d.ts.map +1 -1
  5. package/dist/codegen-mcp.d.ts +38 -0
  6. package/dist/codegen-mcp.d.ts.map +1 -0
  7. package/dist/codegen-operation.d.ts +42 -1
  8. package/dist/codegen-operation.d.ts.map +1 -1
  9. package/dist/codegen-sdk.d.ts.map +1 -1
  10. package/dist/index.d.ts +27 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +807 -243
  13. package/dist/index.js.map +1 -1
  14. package/dist/path-utils.d.ts.map +1 -1
  15. package/dist/ts-render.d.ts +6 -0
  16. package/dist/ts-render.d.ts.map +1 -1
  17. package/package.json +2 -2
  18. package/src/codegen-contract.ts +7 -4
  19. package/src/codegen-mcp.ts +501 -0
  20. package/src/codegen-operation.ts +43 -9
  21. package/src/codegen-plain-types.ts +17 -14
  22. package/src/codegen-sdk.ts +5 -4
  23. package/src/index.ts +154 -0
  24. package/src/path-utils.ts +37 -20
  25. package/src/ts-render.ts +21 -6
  26. package/tests/codegen-contract.test.ts +4 -0
  27. package/tests/codegen-mcp.test.ts +246 -0
  28. package/tests/codegen-operation.test.ts +18 -0
  29. package/tests/codegen-sdk.test.ts +8 -0
  30. package/tests/escaping-security.test.ts +143 -0
  31. package/tests/pipeline.test.ts +59 -0
  32. package/coverage/base.css +0 -224
  33. package/coverage/block-navigation.js +0 -87
  34. package/coverage/clover.xml +0 -2213
  35. package/coverage/coverage-final.json +0 -9
  36. package/coverage/favicon.png +0 -0
  37. package/coverage/index.html +0 -131
  38. package/coverage/prettify.css +0 -1
  39. package/coverage/prettify.js +0 -2
  40. package/coverage/sort-arrow-sprite.png +0 -0
  41. package/coverage/sorter.js +0 -210
  42. package/coverage/src/codegen-contract.ts.html +0 -3661
  43. package/coverage/src/codegen-operation.ts.html +0 -2584
  44. package/coverage/src/codegen-plain-types.ts.html +0 -997
  45. package/coverage/src/codegen-sdk.ts.html +0 -3901
  46. package/coverage/src/index.html +0 -206
  47. package/coverage/src/index.ts.html +0 -2761
  48. package/coverage/src/path-utils.ts.html +0 -745
  49. package/coverage/src/ts-render.ts.html +0 -592
  50. package/coverage/tests/helpers.ts.html +0 -826
  51. package/coverage/tests/index.html +0 -116
@@ -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 ──────────────────────────────────────────────────
@@ -107,6 +107,7 @@ export function bodyTypesStructurallyEqual(a: ContractTypeNode, b: ContractTypeN
107
107
 
108
108
  // ─── Public entry point ────────────────────────────────────────────────────
109
109
 
110
+ /** Options controlling how {@link generateOp} renders a Koa router module. */
110
111
  export interface OpCodegenOptions {
111
112
  servicePathTemplate?: string;
112
113
  typeImportPathTemplate?: string;
@@ -217,7 +218,7 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
217
218
  // JSDoc from description
218
219
  const desc = op.description ?? route.description;
219
220
  if (desc) {
220
- lines.push(` * ${desc}`);
221
+ for (const l of escapeJsDocLines(desc)) lines.push(` * ${l}`);
221
222
  }
222
223
  // Source location comment
223
224
  const relFile = outPath ? relative(dirname(outPath), file) : file;
@@ -261,13 +262,13 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
261
262
  }
262
263
  if (op.signature) {
263
264
  const sigArgs = op.signaturePolicy
264
- ? `'${op.signature}', { policy: '${op.signaturePolicy}' }`
265
- : `'${op.signature}'`;
265
+ ? `'${escapeSingleQuoted(op.signature)}', { policy: '${escapeSingleQuoted(op.signaturePolicy)}' }`
266
+ : `'${escapeSingleQuoted(op.signature)}'`;
266
267
  middlewares.push(`requireSignature(${sigArgs})`);
267
268
  }
268
269
  const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
269
270
 
270
- lines.push(`${deriveRouterName(file)}.${method}('${path}'${middlewareStr} async (ctx, next) => {`);
271
+ lines.push(`${deriveRouterName(file)}.${method}('${path}'${middlewareStr} async ctx => {`);
271
272
 
272
273
  // Params / query / headers validation (request-side — use Input variants)
273
274
  lines.push(...generateParamValidation(route.params, 'ctx.params', 'params', route.paramsMode ?? 'strict', '', modelsWithInput));
@@ -368,7 +369,16 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
368
369
 
369
370
  // ─── Inference helpers ─────────────────────────────────────────────────────
370
371
 
371
- function inferService(op: OpOperationNode, route: OpRouteNode, file: string): { className: string; methodName: string } {
372
+ /**
373
+ * Resolve the service class and method a handler should delegate to.
374
+ *
375
+ * Uses the operation's explicit `service: Class.method` declaration when present; otherwise derives
376
+ * the class from the contract file name (`ledger.categories.ck` → `LedgerCategoriesService`) and the
377
+ * method from the HTTP verb and whether the path carries a parameter (`get` → `list` / `getById`).
378
+ *
379
+ * @param file Path of the `.ck` file the operation came from.
380
+ */
381
+ export function inferService(op: OpOperationNode, route: OpRouteNode, file: string): { className: string; methodName: string } {
372
382
  // If explicitly declared: service: ServiceClass.methodName
373
383
  if (op.service) {
374
384
  const [cls = '', method] = op.service.split('.');
@@ -400,7 +410,16 @@ function inferMethodName(method: string, path: string): string {
400
410
  }
401
411
  }
402
412
 
403
- function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
413
+ /**
414
+ * Build the comma-separated argument list passed to the service method in a generated handler.
415
+ *
416
+ * Order is params, body, query, headers. Inline path params are spread as individual identifiers;
417
+ * a referenced/compound params type is passed as a single `params` object. A lone
418
+ * `multipart/form-data` request body is passed as `multipartBody` rather than `body`.
419
+ *
420
+ * @returns The rendered argument list, or an empty string when the method takes no arguments.
421
+ */
422
+ export function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
404
423
  const args: string[] = [];
405
424
  // Path params: spread individually (inline) or pass 'params' object (type-ref/ContractTypeNode)
406
425
  if (route.params) {
@@ -792,7 +811,13 @@ function isValidIdentifier(name: string): boolean {
792
811
 
793
812
  // ─── Naming conventions ────────────────────────────────────────────────────
794
813
 
795
- function deriveBaseName(file: string): string {
814
+ /**
815
+ * Derive the PascalCase base name used for router, service, and type names from a contract file path.
816
+ *
817
+ * Strips directories and the `.op`/`.ck` extension, then PascalCases each dot-separated segment
818
+ * (`contracts/ledger.categories.ck` → `LedgerCategories`). Falls back to `Resource` for an empty path.
819
+ */
820
+ export function deriveBaseName(file: string): string {
796
821
  const base =
797
822
  file
798
823
  .split('/')
@@ -809,7 +834,16 @@ function deriveRouterName(file: string): string {
809
834
  return `${deriveBaseName(file)}Router`;
810
835
  }
811
836
 
812
- function deriveModulePath(serviceName: string, template?: string): string {
837
+ /**
838
+ * Resolve the import specifier for a service class.
839
+ *
840
+ * Drops the trailing `Service` suffix and kebab-cases the remainder, then applies `template` if given
841
+ * (`{name}` → `Ledger`, `{kebab}` → `ledger`). Without a template, defaults to
842
+ * `#modules/<kebab>/<kebab>.service.js`.
843
+ *
844
+ * @param template Optional `servicePathTemplate` from the plugin config.
845
+ */
846
+ export function deriveModulePath(serviceName: string, template?: string): string {
813
847
  // LedgerService -> #modules/ledger/ledger.service.js
814
848
  const base = serviceName.replace(/Service$/, '');
815
849
  const kebab = base.replace(/([A-Z])/g, m => `-${m.toLowerCase()}`).replace(/^-/, '');
@@ -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/index.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  type SdkScaffoldDeps,
42
42
  } from './codegen-sdk.js';
43
43
  import { generatePlainTypes } from './codegen-plain-types.js';
44
+ import { generateMcpFile, generateMcpAggregator, generateMcpRouter, hasMcpOperations, deriveMcpRegisterFnName } from './codegen-mcp.js';
44
45
  import {
45
46
  TEMPLATE_VAR_RE,
46
47
  resolveTemplate,
@@ -104,11 +105,39 @@ export interface TypesConfig {
104
105
  output?: string;
105
106
  }
106
107
 
108
+ export interface McpConfig {
109
+ /** Directory (relative to rootDir) where MCP files are written. Default: rootDir. */
110
+ baseDir?: string;
111
+ output?: {
112
+ /** Path template for per-op-file tool handlers. Supports {filename}, {dir}, {area}. Default `{filename}.mcp.ts`. */
113
+ tools?: string;
114
+ /** Path (or template) for the aggregator that assembles the McpToolHandlerMap. Default `mcp.tools.ts`. */
115
+ index?: string;
116
+ /** Path (or template) for the optional POST /mcp route file. Default `mcp.router.ts`. */
117
+ router?: string;
118
+ /**
119
+ * Path template for the model **Zod schema** files the tools import (for arg validation and
120
+ * `z.toJSONSchema`). When omitted, falls back to the `server` sub-config's `output.types`
121
+ * (if `server.zod`) or the `zod` sub-config's output. Tools require Zod schemas, not plain types.
122
+ */
123
+ types?: string;
124
+ };
125
+ /** Emit the `mcp.router.ts` route boilerplate. Default true. */
126
+ emitRouter?: boolean;
127
+ /** Mount path used in the emitted router. Default `/mcp`. */
128
+ path?: string;
129
+ /** Import path template for service implementations (same semantics as ServerConfig). */
130
+ servicePathTemplate?: string;
131
+ /** Whether to expose operations marked `internal` as MCP tools. Default false. */
132
+ includeInternal?: boolean;
133
+ }
134
+
107
135
  export interface TypescriptPluginConfig {
108
136
  server?: ServerConfig;
109
137
  sdk?: SdkConfig;
110
138
  zod?: ZodConfig;
111
139
  types?: TypesConfig;
140
+ mcp?: McpConfig;
112
141
  }
113
142
 
114
143
  // ─── Caching constants ─────────────────────────────────────────────────────
@@ -165,6 +194,7 @@ async function runTypescriptCodegen(
165
194
  if (config.sdk) collectSdkOutput(config.sdk, rootDir, inputs, units, globalFiles);
166
195
  if (config.zod) collectZodOutput(config.zod, rootDir, inputs, units);
167
196
  if (config.types) collectTypesOutput(config.types, rootDir, inputs, units);
197
+ if (config.mcp) collectMcpOutput(config.mcp, config, rootDir, inputs, units, globalFiles);
168
198
 
169
199
  const result = runIncrementalCodegen({
170
200
  codegenVersion: TYPESCRIPT_CODEGEN_VERSION,
@@ -838,6 +868,130 @@ function collectTypesOutput(
838
868
  }
839
869
  }
840
870
 
871
+ // ─── MCP sub-generator ─────────────────────────────────────────────────────
872
+
873
+ /**
874
+ * Resolve where the model **Zod schema** files live so the MCP tools can import them (for arg
875
+ * validation + `z.toJSONSchema`). Precedence: explicit `mcp.output.types` → the `server` sub-config's
876
+ * `output.types` (only when `server.zod`) → the `zod` sub-config's output. Returns an empty map when
877
+ * none resolve (imports then fall back to a colocated `./<name>.js` guess).
878
+ */
879
+ function resolveMcpModelOutPaths(
880
+ config: TypescriptPluginConfig,
881
+ rootDir: string,
882
+ contractRoots: readonly ContractRootNode[],
883
+ commonRoot: string,
884
+ modelsWithInput: Set<string>,
885
+ modelsWithOutput: Set<string>,
886
+ ): Map<string, string> {
887
+ const map = new Map<string, string>();
888
+ let base: string;
889
+ let template: string | undefined;
890
+ let suffix: string;
891
+ if (config.mcp?.output?.types) {
892
+ base = resolve(rootDir, config.mcp.baseDir ?? '.');
893
+ template = config.mcp.output.types;
894
+ suffix = '.ts';
895
+ } else if (config.server?.zod && config.server.output?.types) {
896
+ base = resolve(rootDir, config.server.baseDir ?? '.');
897
+ template = config.server.output.types;
898
+ suffix = '.ts';
899
+ } else if (config.zod) {
900
+ base = resolve(rootDir, config.zod.baseDir ?? '.');
901
+ template = config.zod.output;
902
+ suffix = '.schema.ts';
903
+ } else {
904
+ return map;
905
+ }
906
+
907
+ for (const ast of contractRoots) {
908
+ const outPath = computeContractOutPath(ast.file, base, template, suffix, commonRoot, ast.meta);
909
+ for (const model of ast.models) {
910
+ map.set(model.name, outPath);
911
+ if (modelsWithInput.has(model.name)) map.set(`${model.name}Input`, outPath);
912
+ if (modelsWithOutput.has(model.name)) map.set(`${model.name}Output`, outPath);
913
+ }
914
+ }
915
+ return map;
916
+ }
917
+
918
+ function collectMcpOutput(
919
+ config: McpConfig,
920
+ fullConfig: TypescriptPluginConfig,
921
+ rootDir: string,
922
+ inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
923
+ units: IncrementalUnit[],
924
+ globalFiles: IncrementalOutputFile[],
925
+ ): void {
926
+ const mcpBase = resolve(rootDir, config.baseDir ?? '.');
927
+ const modelsWithInput = inputs.modelsWithInput as Set<string>;
928
+ const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
929
+ const modelMap = buildModelMap(inputs.contractRoots);
930
+ const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
931
+ const commonRoot = commonDir(allFiles, rootDir);
932
+ const subConfigKey = stableSubConfig(config);
933
+ const includeInternal = config.includeInternal ?? false;
934
+
935
+ const modelOutPaths = resolveMcpModelOutPaths(fullConfig, rootDir, inputs.contractRoots, commonRoot, modelsWithInput, modelsWithOutput);
936
+
937
+ // ── Per-op-root tool-handler units (only files with MCP-exposed ops) ──
938
+ const entries: { outPath: string; registerFn: string }[] = [];
939
+ for (const ast of inputs.opRoots) {
940
+ if (!hasMcpOperations(ast, includeInternal)) continue;
941
+ const outPath = computeOpOutPath(ast.file, mcpBase, config.output?.tools, '.mcp.ts', commonRoot, ast.meta);
942
+ const refs = collectOpRootRefs(ast, modelMap);
943
+ const fingerprint = hashFingerprint({
944
+ kind: 'mcp-tools',
945
+ v: TYPESCRIPT_CODEGEN_VERSION,
946
+ outPath,
947
+ root: ast,
948
+ outPathSlice: sliceOutPathMap(refs, modelOutPaths, modelsWithInput, modelsWithOutput),
949
+ modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
950
+ modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
951
+ servicePathTemplate: config.servicePathTemplate ?? null,
952
+ includeInternal,
953
+ sub: subConfigKey,
954
+ });
955
+ units.push({
956
+ key: `mcp-tools::${outPath}`,
957
+ fingerprint,
958
+ render: () => [
959
+ {
960
+ relativePath: outPath,
961
+ content: generateMcpFile(ast, {
962
+ outPath,
963
+ modelOutPaths,
964
+ modelsWithInput,
965
+ modelsWithOutput,
966
+ servicePathTemplate: config.servicePathTemplate,
967
+ includeInternal,
968
+ }),
969
+ },
970
+ ],
971
+ });
972
+ entries.push({ outPath, registerFn: deriveMcpRegisterFnName(ast.file) });
973
+ }
974
+
975
+ if (entries.length === 0) return;
976
+
977
+ // ── Aggregator (global) ──
978
+ const indexPath = join(mcpBase, config.output?.index ?? 'mcp.tools.ts');
979
+ const aggregatorEntries = entries
980
+ .map(e => {
981
+ let rel = relative(dirname(indexPath), e.outPath).replace(/\.ts$/, '.js');
982
+ if (!rel.startsWith('.')) rel = './' + rel;
983
+ return { registerFn: e.registerFn, importPath: rel };
984
+ })
985
+ .sort((a, b) => a.registerFn.localeCompare(b.registerFn));
986
+ globalFiles.push({ relativePath: indexPath, content: generateMcpAggregator(aggregatorEntries) });
987
+
988
+ // ── Router (global, optional) ──
989
+ if (config.emitRouter !== false) {
990
+ const routerPath = join(mcpBase, config.output?.router ?? 'mcp.router.ts');
991
+ globalFiles.push({ relativePath: routerPath, content: generateMcpRouter({ path: config.path }) });
992
+ }
993
+ }
994
+
841
995
  // ─── Manifest IO + cleanup ─────────────────────────────────────────────────
842
996
 
843
997
  function readManifest(manifestPath: string): IncrementalManifest {
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
  });