@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
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { McpConfigNode } from '@contractkit/core';
|
|
3
|
+
import {
|
|
4
|
+
generateMcpFile,
|
|
5
|
+
generateMcpAggregator,
|
|
6
|
+
generateMcpRouter,
|
|
7
|
+
hasMcpOperations,
|
|
8
|
+
deriveMcpRegisterFnName,
|
|
9
|
+
} from '../src/codegen-mcp.js';
|
|
10
|
+
import { opRoot, opRoute, opOperation, opParam, opRequest, opResponse, scalarType, loc } from './helpers.js';
|
|
11
|
+
|
|
12
|
+
function mcpBlock(over: Partial<McpConfigNode>): McpConfigNode {
|
|
13
|
+
return { loc: loc(), ...over };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('hasMcpOperations', () => {
|
|
17
|
+
it('is false when no op is flagged', () => {
|
|
18
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
19
|
+
expect(hasMcpOperations(root)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('is true when an op is flagged', () => {
|
|
23
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { mcp: true, responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
24
|
+
expect(hasMcpOperations(root)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('excludes internal ops unless includeInternal', () => {
|
|
28
|
+
const root = opRoot([
|
|
29
|
+
opRoute('/users', [opOperation('get', { mcp: true, responses: [opResponse(200, 'User', 'application/json')] })], undefined, ['internal']),
|
|
30
|
+
]);
|
|
31
|
+
expect(hasMcpOperations(root)).toBe(false);
|
|
32
|
+
expect(hasMcpOperations(root, true)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('generateMcpFile', () => {
|
|
37
|
+
describe('op selection', () => {
|
|
38
|
+
it('emits a tool class only for flagged ops', () => {
|
|
39
|
+
const root = opRoot([
|
|
40
|
+
opRoute('/users', [
|
|
41
|
+
opOperation('get', { sdk: 'listUsers', mcp: true, responses: [opResponse(200, 'User', 'application/json')] }),
|
|
42
|
+
opOperation('post', { sdk: 'createUser', request: opRequest('User'), responses: [opResponse(201, 'User', 'application/json')] }),
|
|
43
|
+
]),
|
|
44
|
+
]);
|
|
45
|
+
const out = generateMcpFile(root);
|
|
46
|
+
expect(out).toContain('export class ListUsersMcpTool');
|
|
47
|
+
expect(out).not.toContain('CreateUserMcpTool');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('skips mcp: false', () => {
|
|
51
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { sdk: 'listUsers', mcp: false, responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
52
|
+
const out = generateMcpFile(root);
|
|
53
|
+
expect(out).not.toContain('implements McpToolHandler');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('skips internal ops unless includeInternal', () => {
|
|
57
|
+
const root = opRoot([
|
|
58
|
+
opRoute('/users', [opOperation('get', { sdk: 'listUsers', mcp: true, responses: [opResponse(200, 'User', 'application/json')] })], undefined, [
|
|
59
|
+
'internal',
|
|
60
|
+
]),
|
|
61
|
+
]);
|
|
62
|
+
expect(generateMcpFile(root)).not.toContain('ListUsersMcpTool');
|
|
63
|
+
expect(generateMcpFile(root, { includeInternal: true })).toContain('ListUsersMcpTool');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('tool name + class name', () => {
|
|
68
|
+
it('uses explicit mcp.name verbatim', () => {
|
|
69
|
+
const root = opRoot([
|
|
70
|
+
opRoute('/routes', [opOperation('post', { mcp: mcpBlock({ name: 'searchRoutes' }), request: opRequest('Query'), responses: [opResponse(200, 'RouteList', 'application/json')] })]),
|
|
71
|
+
]);
|
|
72
|
+
const out = generateMcpFile(root);
|
|
73
|
+
expect(out).toContain("name: 'searchRoutes'");
|
|
74
|
+
expect(out).toContain('export class SearchRoutesMcpTool');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('snake_cases the sdk field', () => {
|
|
78
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { sdk: 'listAllUsers', mcp: true, responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
79
|
+
const out = generateMcpFile(root);
|
|
80
|
+
expect(out).toContain("name: 'list_all_users'");
|
|
81
|
+
expect(out).toContain('export class ListAllUsersMcpTool');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('snake_cases the name field', () => {
|
|
85
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { name: 'Get User', mcp: true, responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
86
|
+
expect(generateMcpFile(root)).toContain("name: 'get_user'");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('infers snake_case name from method + path', () => {
|
|
90
|
+
const root = opRoot([
|
|
91
|
+
opRoute('/payments/{id}', [opOperation('get', { mcp: true, responses: [opResponse(200, 'Payment', 'application/json')] })], [opParam('id', scalarType('uuid'))]),
|
|
92
|
+
]);
|
|
93
|
+
expect(generateMcpFile(root)).toContain("name: 'get_payments_by_id'");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('definition metadata', () => {
|
|
98
|
+
it('emits title, description, and only the defined annotations', () => {
|
|
99
|
+
const root = opRoot([
|
|
100
|
+
opRoute('/payments/{id}', [
|
|
101
|
+
opOperation('get', {
|
|
102
|
+
mcp: mcpBlock({ title: 'Get Payment', description: 'Fetch a payment by id.', readOnlyHint: true, idempotentHint: true, destructiveHint: false }),
|
|
103
|
+
responses: [opResponse(200, 'Payment', 'application/json')],
|
|
104
|
+
}),
|
|
105
|
+
], [opParam('id', scalarType('uuid'))]),
|
|
106
|
+
]);
|
|
107
|
+
const out = generateMcpFile(root);
|
|
108
|
+
expect(out).toContain("title: 'Get Payment'");
|
|
109
|
+
expect(out).toContain("description: 'Fetch a payment by id.'");
|
|
110
|
+
expect(out).toContain('annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }');
|
|
111
|
+
expect(out).not.toContain('openWorldHint');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('omits annotations when no hints set', () => {
|
|
115
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { mcp: true, responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
116
|
+
expect(generateMcpFile(root)).not.toContain('annotations:');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('falls back to op description', () => {
|
|
120
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { mcp: true, description: 'List users.', responses: [opResponse(200, 'User', 'application/json')] })])]);
|
|
121
|
+
expect(generateMcpFile(root)).toContain("description: 'List users.'");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('input schema + validation', () => {
|
|
126
|
+
it('folds path params into a shared args const used for schema and validation', () => {
|
|
127
|
+
const root = opRoot([
|
|
128
|
+
opRoute('/payments/{id}', [opOperation('get', { mcp: true, responses: [opResponse(200, 'Payment', 'application/json')] })], [opParam('id', scalarType('uuid'))]),
|
|
129
|
+
]);
|
|
130
|
+
const out = generateMcpFile(root);
|
|
131
|
+
expect(out).toContain('const GetPaymentsByIdArgs = z.object({ id: z.uuid() });');
|
|
132
|
+
expect(out).toContain("inputSchema: z.toJSONSchema(GetPaymentsByIdArgs, { unrepresentable: 'any' }) as Tool['inputSchema']");
|
|
133
|
+
expect(out).toContain('const { id } = await parseAndValidate(args, GetPaymentsByIdArgs);');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('nests the request body under a body field', () => {
|
|
137
|
+
const root = opRoot([
|
|
138
|
+
opRoute('/payments', [opOperation('post', { sdk: 'createPayment', mcp: true, request: opRequest('PaymentInput'), responses: [opResponse(201, 'Payment', 'application/json')] })]),
|
|
139
|
+
]);
|
|
140
|
+
const out = generateMcpFile(root);
|
|
141
|
+
expect(out).toContain('const CreatePaymentArgs = z.object({ body: PaymentInput });');
|
|
142
|
+
expect(out).toContain('const { body } = await parseAndValidate(args, CreatePaymentArgs);');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('service call + result', () => {
|
|
147
|
+
it('injects the declared service and calls it', () => {
|
|
148
|
+
const root = opRoot([
|
|
149
|
+
opRoute('/payments/{id}', [opOperation('get', { mcp: true, service: 'PaymentsService.getById', responses: [opResponse(200, 'Payment', 'application/json')] })], [opParam('id', scalarType('uuid'))]),
|
|
150
|
+
]);
|
|
151
|
+
const out = generateMcpFile(root);
|
|
152
|
+
expect(out).toContain('constructor(private readonly service: PaymentsService) {}');
|
|
153
|
+
expect(out).toContain('const result = await this.service.getById(id);');
|
|
154
|
+
expect(out).toContain('structuredContent: result');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('returns bare content (no structuredContent / outputSchema) for a void response', () => {
|
|
158
|
+
const root = opRoot([
|
|
159
|
+
opRoute('/payments/{id}', [opOperation('delete', { mcp: true, service: 'PaymentsService.remove', responses: [opResponse(204)] })], [opParam('id', scalarType('uuid'))]),
|
|
160
|
+
]);
|
|
161
|
+
const out = generateMcpFile(root);
|
|
162
|
+
expect(out).toContain('await this.service.remove(id);');
|
|
163
|
+
expect(out).toContain("return { content: [{ type: 'text', text: 'OK' }] };");
|
|
164
|
+
expect(out).not.toContain('structuredContent');
|
|
165
|
+
expect(out).not.toContain('outputSchema');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('emits outputSchema for an object response', () => {
|
|
169
|
+
const root = opRoot([
|
|
170
|
+
opRoute('/payments/{id}', [opOperation('get', { mcp: true, service: 'PaymentsService.getById', responses: [opResponse(200, 'Payment', 'application/json')] })], [opParam('id', scalarType('uuid'))]),
|
|
171
|
+
]);
|
|
172
|
+
expect(generateMcpFile(root)).toContain("outputSchema: z.toJSONSchema(Payment, { unrepresentable: 'any' }) as Tool['outputSchema']");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('imports + registration', () => {
|
|
177
|
+
it('imports the MCP + injectkit primitives and emits a per-file register fn', () => {
|
|
178
|
+
const root = opRoot(
|
|
179
|
+
[opRoute('/payments/{id}', [opOperation('get', { mcp: true, service: 'PaymentsService.getById', responses: [opResponse(200, 'Payment', 'application/json')] })], [opParam('id', scalarType('uuid'))])],
|
|
180
|
+
'payments.op',
|
|
181
|
+
);
|
|
182
|
+
const out = generateMcpFile(root);
|
|
183
|
+
expect(out).toContain("import { Injectable, type Container } from 'injectkit';");
|
|
184
|
+
expect(out).toContain("import type { McpToolHandler, McpToolHandlerMap, McpToolContext } from '@maroonedsoftware/mcp';");
|
|
185
|
+
expect(out).toContain("import { parseAndValidate } from '@maroonedsoftware/zod';");
|
|
186
|
+
expect(out).toContain('export function registerPaymentsMcpTools(map: McpToolHandlerMap, container: Container): void {');
|
|
187
|
+
expect(out).toContain("map.set('get_payments_by_id', container.get(GetPaymentsByIdMcpTool));");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('resolves service + schema imports via modelOutPaths', () => {
|
|
191
|
+
const root = opRoot(
|
|
192
|
+
[opRoute('/payments', [opOperation('post', { sdk: 'createPayment', mcp: true, service: 'PaymentsService.create', request: opRequest('PaymentInput'), responses: [opResponse(201, 'Payment', 'application/json')] })])],
|
|
193
|
+
'payments.op',
|
|
194
|
+
);
|
|
195
|
+
const modelOutPaths = new Map<string, string>([
|
|
196
|
+
['Payment', '/api/src/types/payment.ts'],
|
|
197
|
+
['PaymentInput', '/api/src/types/payment.ts'],
|
|
198
|
+
]);
|
|
199
|
+
const out = generateMcpFile(root, {
|
|
200
|
+
outPath: '/api/src/mcp/payments.mcp.ts',
|
|
201
|
+
modelOutPaths,
|
|
202
|
+
modelsWithInput: new Set(['Payment']),
|
|
203
|
+
servicePathTemplate: '#modules/{kebab}/{kebab}.service.js',
|
|
204
|
+
});
|
|
205
|
+
expect(out).toContain("import { PaymentsService } from '#modules/payments/payments.service.js';");
|
|
206
|
+
expect(out).toContain("import { Payment, PaymentInput } from '../types/payment.js';");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('generateMcpAggregator', () => {
|
|
212
|
+
it('imports each register fn and assembles one map', () => {
|
|
213
|
+
const out = generateMcpAggregator([
|
|
214
|
+
{ registerFn: 'registerPaymentsMcpTools', importPath: './payments.mcp.js' },
|
|
215
|
+
{ registerFn: 'registerUsersMcpTools', importPath: './users.mcp.js' },
|
|
216
|
+
]);
|
|
217
|
+
expect(out).toContain("import { McpToolHandlerMap } from '@maroonedsoftware/mcp';");
|
|
218
|
+
expect(out).toContain("import { registerPaymentsMcpTools } from './payments.mcp.js';");
|
|
219
|
+
expect(out).toContain('export function registerMcpTools(container: Container): McpToolHandlerMap {');
|
|
220
|
+
expect(out).toContain('const map = new McpToolHandlerMap();');
|
|
221
|
+
expect(out).toContain('registerPaymentsMcpTools(map, container);');
|
|
222
|
+
expect(out).toContain('registerUsersMcpTools(map, container);');
|
|
223
|
+
expect(out).toContain('container.register(McpToolHandlerMap, { useValue: map });');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('generateMcpRouter', () => {
|
|
228
|
+
it('emits a ServerKit route wired to the dispatcher at the configured path', () => {
|
|
229
|
+
const out = generateMcpRouter({ path: '/mcp' });
|
|
230
|
+
expect(out).toContain("import { McpDispatcher, createMcpRequestContext, MCP_AUTH_POLICY } from '@maroonedsoftware/mcp';");
|
|
231
|
+
expect(out).toContain("router.post('/mcp'");
|
|
232
|
+
expect(out).toContain('ctx.container.get(McpDispatcher)');
|
|
233
|
+
expect(out).toContain("dispatcher.sessionMode === 'stateful'");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('defaults the mount path to /mcp', () => {
|
|
237
|
+
expect(generateMcpRouter()).toContain("router.post('/mcp'");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('deriveMcpRegisterFnName', () => {
|
|
242
|
+
it('derives from the op-root filename', () => {
|
|
243
|
+
expect(deriveMcpRegisterFnName('payments.op')).toBe('registerPaymentsMcpTools');
|
|
244
|
+
expect(deriveMcpRegisterFnName('ledger.categories.op')).toBe('registerLedgerCategoriesMcpTools');
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -103,6 +103,24 @@ describe('generateOperation', () => {
|
|
|
103
103
|
});
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
// ─── Handler signature ─────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
describe('handler signature', () => {
|
|
109
|
+
it('emits a single-parameter handler without the unused next argument', () => {
|
|
110
|
+
const root = opRoot([opRoute('/users', [opOperation('get', { security: SECURITY_NONE })])]);
|
|
111
|
+
const output = generateOp(root);
|
|
112
|
+
expect(output).toContain(".get('/users', async ctx => {");
|
|
113
|
+
expect(output).not.toContain('ctx, next');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('omits next on handlers that carry middleware', () => {
|
|
117
|
+
const root = opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])]);
|
|
118
|
+
const output = generateOp(root);
|
|
119
|
+
expect(output).toContain('async ctx => {');
|
|
120
|
+
expect(output).not.toContain('ctx, next');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
106
124
|
// ─── Handler generation — GET ──────────────────────────────────
|
|
107
125
|
|
|
108
126
|
describe('GET handlers', () => {
|
|
@@ -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
|
+
});
|
package/tests/pipeline.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { parseCk, decomposeCk, validateOp, validateRefs, applyOptionsDefaults, D
|
|
|
2
2
|
import { generateContract } from '../src/codegen-contract.js';
|
|
3
3
|
import { generateOp } from '../src/codegen-operation.js';
|
|
4
4
|
import { generateSdk } from '../src/codegen-sdk.js';
|
|
5
|
+
import { generateMcpFile } from '../src/codegen-mcp.js';
|
|
5
6
|
import { SIMPLE_USER_CONTRACT, VISIBILITY_CONTRACT, INHERITANCE_CONTRACT, SIMPLE_USERS_OP, PARAMETERIZED_OP } from './helpers.js';
|
|
6
7
|
|
|
7
8
|
function compileContractSource(source: string) {
|
|
@@ -118,6 +119,64 @@ describe('OP pipeline (source -> parse -> codegen)', () => {
|
|
|
118
119
|
});
|
|
119
120
|
});
|
|
120
121
|
|
|
122
|
+
describe('MCP pipeline (source -> parse -> codegen)', () => {
|
|
123
|
+
function compileMcp(source: string, file = 'payments.ck') {
|
|
124
|
+
const diag = new DiagnosticCollector();
|
|
125
|
+
const ck = parseCk(source, file, diag);
|
|
126
|
+
const { op } = decomposeCk(ck);
|
|
127
|
+
return { output: generateMcpFile(op, { includeInternal: false }), diag };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
it('generates a tool handler from a parsed mcp block', () => {
|
|
131
|
+
const source = `\
|
|
132
|
+
operation /payments/{id}: {
|
|
133
|
+
params: { id: uuid }
|
|
134
|
+
get: {
|
|
135
|
+
mcp: {
|
|
136
|
+
title: "Get Payment"
|
|
137
|
+
description: "Fetch a payment by id."
|
|
138
|
+
hint: readOnly, idempotent, nonDestructive
|
|
139
|
+
}
|
|
140
|
+
service: PaymentsService.getById
|
|
141
|
+
response: { 200: { application/json: Payment } }
|
|
142
|
+
}
|
|
143
|
+
}`;
|
|
144
|
+
const { output, diag } = compileMcp(source);
|
|
145
|
+
expect(diag.hasErrors()).toBe(false);
|
|
146
|
+
expect(output).toContain('export class GetPaymentsByIdMcpTool implements McpToolHandler');
|
|
147
|
+
expect(output).toContain("title: 'Get Payment'");
|
|
148
|
+
expect(output).toContain('annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }');
|
|
149
|
+
expect(output).toContain('const GetPaymentsByIdArgs = z.object({ id: z.uuid() });');
|
|
150
|
+
expect(output).toContain('constructor(private readonly service: PaymentsService) {}');
|
|
151
|
+
expect(output).toContain('const result = await this.service.getById(id);');
|
|
152
|
+
expect(output).toContain('export function registerPaymentsMcpTools(map: McpToolHandlerMap, container: Container): void {');
|
|
153
|
+
expect(output).toContain("map.set('get_payments_by_id', container.get(GetPaymentsByIdMcpTool));");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('generates a tool from mcp: true with an inferred name', () => {
|
|
157
|
+
const source = `\
|
|
158
|
+
operation /payments: {
|
|
159
|
+
post: {
|
|
160
|
+
mcp: true
|
|
161
|
+
service: PaymentsService.create
|
|
162
|
+
request: { application/json: PaymentInput }
|
|
163
|
+
response: { 201: { application/json: Payment } }
|
|
164
|
+
}
|
|
165
|
+
}`;
|
|
166
|
+
const { output, diag } = compileMcp(source);
|
|
167
|
+
expect(diag.hasErrors()).toBe(false);
|
|
168
|
+
expect(output).toContain("name: 'post_payments'");
|
|
169
|
+
expect(output).toContain('const PostPaymentsArgs = z.object({ body: PaymentInput });');
|
|
170
|
+
expect(output).not.toContain('annotations:');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('emits nothing tool-like for a file with no flagged ops', () => {
|
|
174
|
+
const source = `operation /payments: { get: { response: { 200: { application/json: Payment } } } }`;
|
|
175
|
+
const { output } = compileMcp(source);
|
|
176
|
+
expect(output).not.toContain('implements McpToolHandler');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
121
180
|
describe('undeclared path param warnings', () => {
|
|
122
181
|
it('warns when a route has path params but no params block', () => {
|
|
123
182
|
const source = `operation /users/{id}: { get: {} }`;
|