@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.
- package/.turbo/turbo-build$colon$ci.log +5 -5
- package/.turbo/turbo-test$colon$ci.log +22 -19
- package/CHANGELOG.md +14 -0
- package/dist/codegen-contract.d.ts.map +1 -1
- package/dist/codegen-mcp.d.ts +38 -0
- package/dist/codegen-mcp.d.ts.map +1 -0
- package/dist/codegen-operation.d.ts +42 -1
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +807 -243
- package/dist/index.js.map +1 -1
- package/dist/path-utils.d.ts.map +1 -1
- package/dist/ts-render.d.ts +6 -0
- package/dist/ts-render.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen-contract.ts +7 -4
- package/src/codegen-mcp.ts +501 -0
- package/src/codegen-operation.ts +43 -9
- package/src/codegen-plain-types.ts +17 -14
- package/src/codegen-sdk.ts +5 -4
- package/src/index.ts +154 -0
- package/src/path-utils.ts +37 -20
- package/src/ts-render.ts +21 -6
- package/tests/codegen-contract.test.ts +4 -0
- package/tests/codegen-mcp.test.ts +246 -0
- package/tests/codegen-operation.test.ts +18 -0
- package/tests/codegen-sdk.test.ts +8 -0
- package/tests/escaping-security.test.ts +143 -0
- package/tests/pipeline.test.ts +59 -0
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/clover.xml +0 -2213
- package/coverage/coverage-final.json +0 -9
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -131
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
- package/coverage/src/codegen-contract.ts.html +0 -3661
- package/coverage/src/codegen-operation.ts.html +0 -2584
- package/coverage/src/codegen-plain-types.ts.html +0 -997
- package/coverage/src/codegen-sdk.ts.html +0 -3901
- package/coverage/src/index.html +0 -206
- package/coverage/src/index.ts.html +0 -2761
- package/coverage/src/path-utils.ts.html +0 -745
- package/coverage/src/ts-render.ts.html +0 -592
- package/coverage/tests/helpers.ts.html +0 -826
- package/coverage/tests/index.html +0 -116
package/src/codegen-operation.ts
CHANGED
|
@@ -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(` * ${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(` * ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
return `/** ${jsdocParts.join(' ')} */\n ${line}`;
|
|
302
|
-
}
|
|
303
|
-
return line;
|
|
306
|
+
return withFieldJsDoc(jsdocParts, line);
|
|
304
307
|
}
|
package/src/codegen-sdk.ts
CHANGED
|
@@ -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
|
-
|
|
293
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
});
|