@contractkit/prettier-plugin 0.14.2 → 0.14.4

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.
@@ -1,1121 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { printCk } from '../src/print-ck.js';
3
- import { parseCk, DiagnosticCollector } from '@contractkit/core';
4
- import type { OpRouteNode, OpOperationNode, CkRootNode, SecurityFields, ParamSource, ContractTypeNode, OpParamNode } from '@contractkit/core';
5
-
6
- // ─── Minimal AST builders ────────────────────────────────────────────────────
7
-
8
- function makeLoc(line = 1) {
9
- return { file: 'test.ck', line };
10
- }
11
-
12
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
- function normalizeParamSource(value: any): ParamSource {
14
- if (!value) return value;
15
- if (typeof value === 'string') return { kind: 'ref', name: value };
16
- if (Array.isArray(value)) return { kind: 'params', nodes: value as OpParamNode[] };
17
- if (value.kind === 'params' || value.kind === 'ref' || value.kind === 'type') return value as ParamSource;
18
- return { kind: 'type', node: value as ContractTypeNode };
19
- }
20
-
21
- function makeOp(method: OpOperationNode['method'], overrides?: Partial<OpOperationNode> & { query?: unknown; headers?: unknown }): OpOperationNode {
22
- const normalized = { ...overrides } as Partial<OpOperationNode>;
23
- if (overrides?.query !== undefined) normalized.query = normalizeParamSource(overrides.query);
24
- if (overrides?.headers !== undefined) normalized.headers = normalizeParamSource(overrides.headers);
25
- return { method, responses: [], loc: makeLoc(), ...normalized };
26
- }
27
-
28
- function makeRoute(path: string, operations: OpOperationNode[], overrides?: Partial<OpRouteNode> & { params?: unknown }): OpRouteNode {
29
- const normalized = { ...overrides } as Partial<OpRouteNode>;
30
- if (overrides?.params !== undefined) normalized.params = normalizeParamSource(overrides.params);
31
- return { path, operations, loc: makeLoc(), ...normalized };
32
- }
33
-
34
- function makeRoot(routes: OpRouteNode[]): CkRootNode {
35
- return { kind: 'ckRoot', meta: {}, services: {}, models: [], routes, file: 'test.ck' };
36
- }
37
-
38
- // ─── Route modifier printing ─────────────────────────────────────────────────
39
-
40
- describe('printCk — route modifiers', () => {
41
- it('prints route with no modifiers', () => {
42
- const ast = makeRoot([makeRoute('/users', [makeOp('get')])]);
43
- expect(printCk(ast)).toContain('operation /users: {');
44
- });
45
-
46
- it('prints route with internal modifier', () => {
47
- const ast = makeRoot([makeRoute('/admin/users', [makeOp('get')], { modifiers: ['internal'] })]);
48
- expect(printCk(ast)).toContain('operation(internal) /admin/users: {');
49
- });
50
-
51
- it('prints route with deprecated modifier', () => {
52
- const ast = makeRoot([makeRoute('/v1/users', [makeOp('get')], { modifiers: ['deprecated'] })]);
53
- expect(printCk(ast)).toContain('operation(deprecated) /v1/users: {');
54
- });
55
-
56
- it('treats empty modifiers array same as no modifier', () => {
57
- const ast = makeRoot([makeRoute('/users', [makeOp('get')], { modifiers: [] })]);
58
- expect(printCk(ast)).toContain('operation /users: {');
59
- });
60
-
61
- it('preserves description alongside modifier', () => {
62
- const ast = makeRoot([makeRoute('/users', [makeOp('get')], { modifiers: ['deprecated'], description: 'Old user list' })]);
63
- // A route description is emitted above the declaration, not inline: a `#` after the `{`
64
- // would re-parse as the first operation's description instead.
65
- expect(printCk(ast)).toContain('# Old user list\noperation(deprecated) /users: {');
66
- });
67
- });
68
-
69
- // ─── Operation modifier printing ─────────────────────────────────────────────
70
-
71
- describe('printCk — operation modifiers', () => {
72
- it('prints operation with no modifiers', () => {
73
- const ast = makeRoot([makeRoute('/users', [makeOp('get')])]);
74
- expect(printCk(ast)).toContain(' get: {');
75
- });
76
-
77
- it('prints operation with internal modifier', () => {
78
- const ast = makeRoot([makeRoute('/users', [makeOp('get', { modifiers: ['internal'] })])]);
79
- expect(printCk(ast)).toContain(' get(internal): {');
80
- });
81
-
82
- it('prints operation with deprecated modifier', () => {
83
- const ast = makeRoot([makeRoute('/users', [makeOp('get', { modifiers: ['deprecated'] })])]);
84
- expect(printCk(ast)).toContain(' get(deprecated): {');
85
- });
86
-
87
- it('treats empty operation modifiers array as no modifiers', () => {
88
- const ast = makeRoot([makeRoute('/users', [makeOp('delete', { modifiers: [] })])]);
89
- expect(printCk(ast)).toContain(' delete: {');
90
- });
91
-
92
- it('prints multiple operations with mixed modifiers', () => {
93
- const ast = makeRoot([
94
- makeRoute('/users', [makeOp('get', { modifiers: ['deprecated'] }), makeOp('post', { modifiers: ['internal'] }), makeOp('delete')]),
95
- ]);
96
- const output = printCk(ast);
97
- expect(output).toContain(' get(deprecated): {');
98
- expect(output).toContain(' post(internal): {');
99
- expect(output).toContain(' delete: {');
100
- });
101
-
102
- it('preserves description alongside modifier on operation', () => {
103
- const ast = makeRoot([makeRoute('/users', [makeOp('get', { modifiers: ['internal'], description: 'Admin only' })])]);
104
- expect(printCk(ast)).toContain(' get(internal): { # Admin only');
105
- });
106
- });
107
-
108
- // ─── Route + operation modifier combinations ──────────────────────────────────
109
-
110
- describe('printCk — route and operation modifiers combined', () => {
111
- it('prints both route and operation modifiers independently', () => {
112
- const ast = makeRoot([
113
- makeRoute('/admin/users', [makeOp('get', { modifiers: ['deprecated'] }), makeOp('post', { modifiers: ['internal'] })], {
114
- modifiers: ['internal'],
115
- }),
116
- ]);
117
- const output = printCk(ast);
118
- expect(output).toContain('operation(internal) /admin/users: {');
119
- expect(output).toContain(' get(deprecated): {');
120
- expect(output).toContain(' post(internal): {');
121
- });
122
-
123
- it('modifier placement: operation(modifier) before path, verb(modifier) before colon', () => {
124
- const ast = makeRoot([makeRoute('/admin', [makeOp('get', { modifiers: ['deprecated'] })], { modifiers: ['internal'] })]);
125
- const output = printCk(ast);
126
- expect(output).toMatch(/^operation\(internal\) \/admin: \{/m);
127
- expect(output).toMatch(/^\s+get\(deprecated\): \{/m);
128
- });
129
- });
130
-
131
- // ─── Security printing ────────────────────────────────────────────────────────
132
-
133
- function makeSecFields(fields: Partial<Pick<SecurityFields, 'policy'>>): SecurityFields {
134
- return { ...fields, loc: makeLoc() };
135
- }
136
-
137
- describe('printCk — security', () => {
138
- it('prints operation-level security: none', () => {
139
- const ast = makeRoot([makeRoute('/health', [makeOp('get', { security: 'none' })])]);
140
- expect(printCk(ast)).toContain(' security: none');
141
- });
142
-
143
- it('prints security block with policy: <name>', () => {
144
- const ast = makeRoot([makeRoute('/users', [makeOp('get', { security: makeSecFields({ policy: 'paymentsWrite' }) })])]);
145
- const out = printCk(ast);
146
- expect(out).toContain(' security: {');
147
- expect(out).toContain(' policy: paymentsWrite');
148
- expect(out).toContain(' }');
149
- });
150
-
151
- it('prints security block with policy: none for explicit bypass', () => {
152
- const ast = makeRoot([makeRoute('/users', [makeOp('get', { security: makeSecFields({ policy: false }) })])]);
153
- expect(printCk(ast)).toContain(' policy: none');
154
- });
155
-
156
- it('prints operation-level signature as its own keyword', () => {
157
- const ast = makeRoot([makeRoute('/users', [makeOp('post', { signature: 'hmac-sha256' })])]);
158
- const out = printCk(ast);
159
- expect(out).toContain(' signature: "hmac-sha256"');
160
- expect(out).not.toContain('security: {');
161
- });
162
-
163
- it('prints unquoted identifier signature without quotes', () => {
164
- const ast = makeRoot([makeRoute('/webhooks', [makeOp('post', { signature: 'MODERN_TREASURY_WEBHOOK' })])]);
165
- expect(printCk(ast)).toContain(' signature: MODERN_TREASURY_WEBHOOK');
166
- });
167
-
168
- it('prints block-form signature when a signature policy is set', () => {
169
- const ast = makeRoot([
170
- makeRoute('/webhooks', [makeOp('post', { signature: 'SLACK_WEBHOOK', signaturePolicy: 'slackSignatureValid' })]),
171
- ]);
172
- const out = printCk(ast);
173
- expect(out).toContain(' signature: {');
174
- expect(out).toContain(' options: SLACK_WEBHOOK');
175
- expect(out).toContain(' policy: slackSignatureValid');
176
- });
177
-
178
- it('prints route-level security with shallower indentation', () => {
179
- const ast = makeRoot([makeRoute('/users', [makeOp('get')], { security: makeSecFields({ policy: 'paymentsWrite' }) })]);
180
- const out = printCk(ast);
181
- expect(out).toContain(' security: {');
182
- expect(out).toContain(' policy: paymentsWrite');
183
- });
184
-
185
- it('prints route-level security: none', () => {
186
- const ast = makeRoot([makeRoute('/public', [makeOp('get')], { security: 'none' })]);
187
- expect(printCk(ast)).toContain(' security: none');
188
- });
189
-
190
- it('emits no security line when security is undefined', () => {
191
- const ast = makeRoot([makeRoute('/users', [makeOp('get')])]);
192
- expect(printCk(ast)).not.toContain('security');
193
- });
194
- });
195
-
196
- // ─── plugins block printing ───────────────────────────────────────────────────
197
-
198
- describe('printCk — plugins block', () => {
199
- it('prints plugins block with single entry', () => {
200
- const ast = makeRoot([makeRoute('/auth/token', [makeOp('post', { plugins: { bruno: 'request-token.yml' } })])]);
201
- const out = printCk(ast);
202
- expect(out).toContain(' plugins: {');
203
- expect(out).toContain(' bruno: "request-token.yml"');
204
- expect(out).toContain(' }');
205
- });
206
-
207
- it('prints plugins block with multiple entries', () => {
208
- const ast = makeRoot([makeRoute('/users', [makeOp('post', { plugins: { bruno: 'create-user.yml', typescript: 'stub.ts' } })])]);
209
- const out = printCk(ast);
210
- expect(out).toContain(' bruno: "create-user.yml"');
211
- expect(out).toContain(' typescript: "stub.ts"');
212
- });
213
-
214
- it('omits plugins block when plugins is undefined', () => {
215
- const ast = makeRoot([makeRoute('/users', [makeOp('get')])]);
216
- expect(printCk(ast)).not.toContain('plugins');
217
- });
218
-
219
- it('omits plugins block when plugins is empty object', () => {
220
- const ast = makeRoot([makeRoute('/users', [makeOp('get', { plugins: {} })])]);
221
- expect(printCk(ast)).not.toContain('plugins');
222
- });
223
-
224
- it('plugins block round-trips via parse', () => {
225
- const src = `operation /auth/token: {
226
- post: {
227
- plugins: {
228
- bruno: "request-token.yml"
229
- }
230
- }
231
- }
232
- `;
233
- const diag = new DiagnosticCollector();
234
- const ast = parseCk(src, 'test.ck', diag);
235
- expect(diag.hasErrors()).toBe(false);
236
- const out = printCk(ast);
237
- expect(out).toContain(' bruno: "request-token.yml"');
238
- });
239
-
240
- it('prints object plugin value with file:// URL', () => {
241
- const ast = makeRoot([
242
- makeRoute('/auth/token', [
243
- makeOp('post', { plugins: { bruno: { template: 'file://request-token.yml' } } }),
244
- ]),
245
- ]);
246
- const out = printCk(ast);
247
- expect(out).toContain(' bruno: {');
248
- expect(out).toContain(' template: "file://request-token.yml"');
249
- });
250
-
251
- it('round-trips object plugin value through parse', () => {
252
- const src = `operation /auth/token: {
253
- post: {
254
- plugins: {
255
- bruno: {
256
- template: "file://request-token.yml"
257
- }
258
- }
259
- }
260
- }
261
- `;
262
- const diag = new DiagnosticCollector();
263
- const ast = parseCk(src, 'test.ck', diag);
264
- expect(diag.hasErrors()).toBe(false);
265
- const out = printCk(ast);
266
- expect(out).toContain(' bruno: {');
267
- expect(out).toContain(' template: "file://request-token.yml"');
268
- });
269
-
270
- it('prints scalar, array, and nested-object plugin values', () => {
271
- const ast = makeRoot([
272
- makeRoute('/x', [
273
- makeOp('get', {
274
- plugins: {
275
- flag: true,
276
- count: 3,
277
- tags: ['a', 'b'],
278
- meta: { nested: { x: 1 } },
279
- },
280
- }),
281
- ]),
282
- ]);
283
- const out = printCk(ast);
284
- expect(out).toContain('flag: true');
285
- expect(out).toContain('count: 3');
286
- expect(out).toContain('tags: [');
287
- expect(out).toContain('meta: {');
288
- });
289
- });
290
-
291
- // ─── Query / headers with descriptions ───────────────────────────────────────
292
-
293
- describe('printCk — query and headers descriptions', () => {
294
- it('emits inline comment on query params that have descriptions', () => {
295
- const loc = makeLoc();
296
- const ast = makeRoot([
297
- makeRoute('/users', [
298
- makeOp('get', {
299
- query: [
300
- { name: 'page', optional: false, nullable: false, type: { kind: 'scalar', name: 'int' }, description: 'Page number', loc },
301
- { name: 'limit', optional: false, nullable: false, type: { kind: 'scalar', name: 'int' }, loc },
302
- ],
303
- }),
304
- ]),
305
- ]);
306
- const out = printCk(ast);
307
- expect(out).toContain(' page: int # Page number');
308
- expect(out).toContain(' limit: int');
309
- expect(out).not.toMatch(/limit: int #/);
310
- });
311
-
312
- it('emits inline comment on headers params that have descriptions', () => {
313
- const loc = makeLoc();
314
- const ast = makeRoot([
315
- makeRoute('/users', [
316
- makeOp('get', {
317
- headers: [
318
- {
319
- name: 'X-Request-Id',
320
- optional: false,
321
- nullable: false,
322
- type: { kind: 'scalar', name: 'uuid' },
323
- description: 'Idempotency key',
324
- loc,
325
- },
326
- ],
327
- }),
328
- ]),
329
- ]);
330
- expect(printCk(ast)).toContain(' X-Request-Id: uuid # Idempotency key');
331
- });
332
- });
333
-
334
- // ─── Query / headers — optional, nullable, defaults ──────────────────────────
335
-
336
- describe('printCk — query and headers optional, nullable, default', () => {
337
- const loc = makeLoc();
338
-
339
- it('emits ? for optional query params', () => {
340
- const ast = makeRoot([
341
- makeRoute('/search', [
342
- makeOp('get', {
343
- query: [
344
- { name: 'q', optional: true, nullable: false, type: { kind: 'scalar', name: 'string' }, loc },
345
- { name: 'page', optional: false, nullable: false, type: { kind: 'scalar', name: 'int' }, loc },
346
- ],
347
- }),
348
- ]),
349
- ]);
350
- const out = printCk(ast);
351
- expect(out).toContain(' q?: string');
352
- expect(out).toContain(' page: int');
353
- expect(out).not.toContain('page?:');
354
- });
355
-
356
- it('emits | null for nullable query params', () => {
357
- const ast = makeRoot([
358
- makeRoute('/items', [
359
- makeOp('get', {
360
- query: [{ name: 'filter', optional: false, nullable: true, type: { kind: 'scalar', name: 'string' }, loc }],
361
- }),
362
- ]),
363
- ]);
364
- expect(printCk(ast)).toContain(' filter: string | null');
365
- });
366
-
367
- it('emits = value for numeric defaults', () => {
368
- const ast = makeRoot([
369
- makeRoute('/items', [
370
- makeOp('get', {
371
- query: [{ name: 'limit', optional: false, nullable: false, type: { kind: 'scalar', name: 'int' }, default: 20, loc }],
372
- }),
373
- ]),
374
- ]);
375
- expect(printCk(ast)).toContain(' limit: int = 20');
376
- });
377
-
378
- it('emits = value for boolean defaults', () => {
379
- const ast = makeRoot([
380
- makeRoute('/items', [
381
- makeOp('get', {
382
- query: [{ name: 'active', optional: false, nullable: false, type: { kind: 'scalar', name: 'boolean' }, default: true, loc }],
383
- }),
384
- ]),
385
- ]);
386
- expect(printCk(ast)).toContain(' active: boolean = true');
387
- });
388
-
389
- it('emits = "value" for string defaults that need quoting', () => {
390
- const ast = makeRoot([
391
- makeRoute('/items', [
392
- makeOp('get', {
393
- query: [
394
- { name: 'label', optional: false, nullable: false, type: { kind: 'scalar', name: 'string' }, default: 'hello world', loc },
395
- ],
396
- }),
397
- ]),
398
- ]);
399
- expect(printCk(ast)).toContain(' label: string = "hello world"');
400
- });
401
-
402
- it('combines optional, default, and description on the same param', () => {
403
- const ast = makeRoot([
404
- makeRoute('/items', [
405
- makeOp('get', {
406
- query: [
407
- {
408
- name: 'page',
409
- optional: true,
410
- nullable: false,
411
- type: { kind: 'scalar', name: 'int' },
412
- default: 1,
413
- description: 'Page number',
414
- loc,
415
- },
416
- ],
417
- }),
418
- ]),
419
- ]);
420
- expect(printCk(ast)).toContain(' page?: int = 1 # Page number');
421
- });
422
-
423
- it('applies the same rules to headers params', () => {
424
- const ast = makeRoot([
425
- makeRoute('/items', [
426
- makeOp('get', {
427
- headers: [{ name: 'X-Version', optional: true, nullable: false, type: { kind: 'scalar', name: 'string' }, default: 'v1', loc }],
428
- }),
429
- ]),
430
- ]);
431
- expect(printCk(ast)).toContain(' X-Version?: string = v1');
432
- });
433
- });
434
-
435
- // ─── Request blocks ──────────────────────────────────────────────────────────
436
-
437
- describe('printCk — request blocks', () => {
438
- it('prints a single content-type request', () => {
439
- const ast = makeRoot([
440
- makeRoute('/users', [
441
- makeOp('post', {
442
- request: { bodies: [{ contentType: 'application/json', bodyType: { kind: 'ref', name: 'CreateUser' } }] },
443
- }),
444
- ]),
445
- ]);
446
- const out = printCk(ast);
447
- expect(out).toContain(' request: {');
448
- expect(out).toContain(' application/json: CreateUser');
449
- });
450
-
451
- it('round-trips vendor JSON mime types like application/vnd.api+json', () => {
452
- const ast = makeRoot([
453
- makeRoute('/users', [
454
- makeOp('post', {
455
- request: { bodies: [{ contentType: 'application/vnd.api+json', bodyType: { kind: 'ref', name: 'CreateUser' } }] },
456
- responses: [{ statusCode: 201, hasBlock: true, bodies: [{ contentType: 'application/vnd.api+json', bodyType: { kind: 'ref', name: 'User' } }] }],
457
- }),
458
- ]),
459
- ]);
460
- const out = printCk(ast);
461
- expect(out).toContain(' application/vnd.api+json: CreateUser');
462
- expect(out).toContain(' application/vnd.api+json: User');
463
- });
464
-
465
- it('prints multiple content-types preserving source order', () => {
466
- const ast = makeRoot([
467
- makeRoute('/auth/token', [
468
- makeOp('post', {
469
- request: {
470
- bodies: [
471
- { contentType: 'application/json', bodyType: { kind: 'ref', name: 'AuthRequest' } },
472
- { contentType: 'application/x-www-form-urlencoded', bodyType: { kind: 'ref', name: 'AuthRequest' } },
473
- ],
474
- },
475
- }),
476
- ]),
477
- ]);
478
- const out = printCk(ast);
479
- expect(out).toContain(' application/json: AuthRequest');
480
- expect(out).toContain(' application/x-www-form-urlencoded: AuthRequest');
481
- // Order check
482
- const i1 = out.indexOf('application/json:');
483
- const i2 = out.indexOf('application/x-www-form-urlencoded:');
484
- expect(i1).toBeLessThan(i2);
485
- });
486
- });
487
-
488
- describe('printCk — response headers', () => {
489
- it('prints response headers alongside body', () => {
490
- const ast = makeRoot([
491
- makeRoute('/transfers/{id}', [
492
- makeOp('get', {
493
- responses: [
494
- {
495
- statusCode: 200,
496
- hasBlock: true,
497
- bodies: [{ contentType: 'application/json', bodyType: { kind: 'ref', name: 'Transfer' } }],
498
- headers: [
499
- { name: 'preference-applied', optional: true, type: { kind: 'scalar', name: 'string' } },
500
- { name: 'etag', optional: false, type: { kind: 'scalar', name: 'string' }, description: 'cache validator' },
501
- ],
502
- },
503
- ],
504
- }),
505
- ]),
506
- ]);
507
- const out = printCk(ast);
508
- expect(out).toContain(' 200: {');
509
- expect(out).toContain(' application/json: Transfer');
510
- expect(out).toContain(' headers: {');
511
- expect(out).toContain(' preference-applied?: string');
512
- expect(out).toContain(' etag: string # cache validator');
513
- });
514
-
515
- it('prints response headers without a body', () => {
516
- const ast = makeRoot([
517
- makeRoute('/resources/{id}', [
518
- makeOp('delete', {
519
- responses: [
520
- {
521
- statusCode: 204,
522
- hasBlock: true,
523
- bodies: [],
524
- headers: [{ name: 'x-deleted-at', optional: false, type: { kind: 'scalar', name: 'string' } }],
525
- },
526
- ],
527
- }),
528
- ]),
529
- ]);
530
- const out = printCk(ast);
531
- expect(out).toContain(' 204: {');
532
- expect(out).toContain(' headers: {');
533
- expect(out).toContain(' x-deleted-at: string');
534
- expect(out).not.toContain('application/json');
535
- });
536
- });
537
-
538
- describe('printCk — mcp field', () => {
539
- function roundTrip(source: string): string {
540
- const diag = new DiagnosticCollector();
541
- const ast = parseCk(source, 'test.ck', diag);
542
- expect(diag.hasErrors()).toBe(false);
543
- return printCk(ast);
544
- }
545
-
546
- it('prints mcp: true', () => {
547
- const ast = makeRoot([makeRoute('/users', [makeOp('get', { mcp: true })])]);
548
- expect(printCk(ast)).toContain(' mcp: true');
549
- });
550
-
551
- it('prints explicit mcp: false', () => {
552
- const ast = makeRoot([makeRoute('/users', [makeOp('get', { mcp: false })])]);
553
- expect(printCk(ast)).toContain(' mcp: false');
554
- });
555
-
556
- it('omits mcp when undefined', () => {
557
- const ast = makeRoot([makeRoute('/users', [makeOp('get')])]);
558
- expect(printCk(ast)).not.toContain('mcp:');
559
- });
560
-
561
- it('prints mcp block with text fields and reconstructed hint list', () => {
562
- const ast = makeRoot([
563
- makeRoute('/routes', [
564
- makeOp('post', {
565
- mcp: {
566
- name: 'searchRoutes',
567
- title: 'Search routes',
568
- description: 'Full-text search.',
569
- readOnlyHint: true,
570
- idempotentHint: true,
571
- destructiveHint: false,
572
- loc: makeLoc(),
573
- },
574
- }),
575
- ]),
576
- ]);
577
- const out = printCk(ast);
578
- expect(out).toContain(' mcp: {');
579
- expect(out).toContain(' name: "searchRoutes"');
580
- expect(out).toContain(' title: "Search routes"');
581
- expect(out).toContain(' description: "Full-text search."');
582
- expect(out).toContain(' hint: readOnly, idempotent, nonDestructive');
583
- });
584
-
585
- it('round-trips mcp: true / false through parse', () => {
586
- expect(roundTrip('operation /a: {\n get: {\n mcp: true\n }\n}\n')).toContain(' mcp: true');
587
- expect(roundTrip('operation /b: {\n get: {\n mcp: false\n }\n}\n')).toContain(' mcp: false');
588
- });
589
-
590
- it('round-trips an mcp settings block through parse', () => {
591
- const source = `\
592
- operation /routes: {
593
- post: {
594
- mcp: {
595
- name: "searchRoutes"
596
- title: "Search routes"
597
- description: "Full-text search across routes."
598
- hint: readOnly, idempotent, nonDestructive, closedWorld
599
- }
600
- }
601
- }
602
- `;
603
- const out = roundTrip(source);
604
- expect(out).toContain(' mcp: {');
605
- expect(out).toContain(' name: "searchRoutes"');
606
- expect(out).toContain(' hint: readOnly, idempotent, nonDestructive, closedWorld');
607
- // Idempotent: printing the round-tripped output again is stable.
608
- const diag = new DiagnosticCollector();
609
- expect(printCk(parseCk(out, 'test.ck', diag))).toBe(out);
610
- expect(diag.hasErrors()).toBe(false);
611
- });
612
- });
613
-
614
- describe('printCk — options-level header globals (round-trip)', () => {
615
- function roundTrip(source: string): string {
616
- const diag = new DiagnosticCollector();
617
- const ast = parseCk(source, 'test.ck', diag);
618
- expect(diag.hasErrors()).toBe(false);
619
- return printCk(ast);
620
- }
621
-
622
- it('round-trips options.request.headers', () => {
623
- const source = `\
624
- options {
625
- request: {
626
- headers: {
627
- x-request-id: uuid
628
- authorization?: string
629
- }
630
- }
631
- }
632
-
633
- operation /widgets: {
634
- get: {
635
- response: {
636
- 200: {
637
- application/json: Widget
638
- }
639
- }
640
- }
641
- }
642
- `;
643
- const out = roundTrip(source);
644
- expect(out).toBe(source);
645
- });
646
-
647
- it('round-trips options.response.headers', () => {
648
- const source = `\
649
- options {
650
- response: {
651
- headers: {
652
- x-request-id: uuid
653
- }
654
- }
655
- }
656
-
657
- operation /widgets: {
658
- get: {
659
- response: {
660
- 200: {
661
- application/json: Widget
662
- }
663
- }
664
- }
665
- }
666
- `;
667
- expect(roundTrip(source)).toBe(source);
668
- });
669
-
670
- it('round-trips operation-level headers: none', () => {
671
- const source = `\
672
- operation /widgets: {
673
- get: {
674
- headers: none
675
- response: {
676
- 200: {
677
- application/json: Widget
678
- }
679
- }
680
- }
681
- }
682
- `;
683
- expect(roundTrip(source)).toBe(source);
684
- });
685
-
686
- it('round-trips per-status headers: none', () => {
687
- const source = `\
688
- operation /widgets: {
689
- get: {
690
- response: {
691
- 200: {
692
- application/json: Widget
693
- }
694
- 404: {
695
- headers: none
696
- }
697
- }
698
- }
699
- }
700
- `;
701
- expect(roundTrip(source)).toBe(source);
702
- });
703
- });
704
-
705
- describe('printCk — multi-base inheritance and override (round-trip)', () => {
706
- function roundTrip(source: string): string {
707
- const diag = new DiagnosticCollector();
708
- const ast = parseCk(source, 'test.ck', diag);
709
- expect(diag.hasErrors()).toBe(false);
710
- return printCk(ast);
711
- }
712
-
713
- it('round-trips multi-base inheritance', () => {
714
- const source = `\
715
- contract Test5: A & B & C & D & {
716
- e: string
717
- }
718
- `;
719
- expect(roundTrip(source)).toBe(source);
720
- });
721
-
722
- it('round-trips override modifier on a field', () => {
723
- const source = `\
724
- contract Test5: A & {
725
- a: override int
726
- e: string
727
- }
728
- `;
729
- expect(roundTrip(source)).toBe(source);
730
- });
731
-
732
- it('emits canonical modifier order: override → deprecated → readonly', () => {
733
- const source = `\
734
- contract Test: A & {
735
- a: deprecated override readonly string
736
- }
737
- `;
738
- // Canonical order is override → deprecated → readonly|writeonly.
739
- // Re-printing reorders the modifiers.
740
- const printed = roundTrip(source);
741
- expect(printed).toContain('a: override deprecated readonly string');
742
- });
743
- });
744
-
745
- describe('printCk — typed path params (round-trip)', () => {
746
- function roundTrip(source: string): string {
747
- const diag = new DiagnosticCollector();
748
- const ast = parseCk(source, 'test.ck', diag);
749
- expect(diag.hasErrors()).toBe(false);
750
- return printCk(ast);
751
- }
752
-
753
- it('round-trips path params with constraint arguments', () => {
754
- const source = `\
755
- operation /orders/{orderId}: {
756
- params: {
757
- orderId: int(min=1, max=5)
758
- }
759
- get: {
760
- }
761
- }
762
- `;
763
- expect(roundTrip(source)).toBe(source);
764
- });
765
-
766
- it('round-trips path params with enum types', () => {
767
- const source = `\
768
- operation /pets/{status}: {
769
- params: {
770
- status: enum(available, pending, sold)
771
- }
772
- get: {
773
- }
774
- }
775
- `;
776
- expect(roundTrip(source)).toBe(source);
777
- });
778
-
779
- it('round-trips path params with regex constraints and a comment', () => {
780
- const source = `\
781
- operation /users/{slug}: {
782
- params: {
783
- slug: string(regex=/^[a-z0-9-]+$/) # url slug
784
- }
785
- get: {
786
- }
787
- }
788
- `;
789
- expect(roundTrip(source)).toBe(source);
790
- });
791
- });
792
-
793
- describe('printCk — options keys and services quoting', () => {
794
- function roundTrip(source: string): string {
795
- const diag = new DiagnosticCollector();
796
- const ast = parseCk(source, 'test.ck', diag);
797
- expect(diag.hasErrors()).toBe(false);
798
- return printCk(ast);
799
- }
800
-
801
- it('leaves simple identifier keys values unquoted', () => {
802
- const source = `\
803
- options {
804
- keys: {
805
- area: payments
806
- }
807
- }
808
- `;
809
- expect(roundTrip(source)).toBe(source);
810
- });
811
-
812
- it('preserves quotes on path-like values containing slashes', () => {
813
- const source = `\
814
- options {
815
- keys: {
816
- bruno: "../../bruno/"
817
- }
818
- }
819
- `;
820
- expect(roundTrip(source)).toBe(source);
821
- });
822
-
823
- it('quotes values that start with a dot', () => {
824
- const source = `\
825
- options {
826
- keys: {
827
- path: "../relative"
828
- }
829
- }
830
- `;
831
- expect(roundTrip(source)).toBe(source);
832
- });
833
-
834
- it('preserves quotes on service paths starting with #', () => {
835
- const source = `\
836
- options {
837
- services: {
838
- AuthService: "#src/modules/auth/auth.service.js"
839
- }
840
- }
841
- `;
842
- expect(roundTrip(source)).toBe(source);
843
- });
844
-
845
- it('leaves plain identifier service names unquoted', () => {
846
- const source = `\
847
- options {
848
- services: {
849
- AuthService: authService
850
- }
851
- }
852
- `;
853
- expect(roundTrip(source)).toBe(source);
854
- });
855
- });
856
-
857
- describe('printCk — enum values with spaces (round-trip)', () => {
858
- function roundTrip(source: string): string {
859
- const diag = new DiagnosticCollector();
860
- const ast = parseCk(source, 'test.ck', diag);
861
- expect(diag.hasErrors()).toBe(false);
862
- return printCk(ast);
863
- }
864
-
865
- it('round-trips enum with bare identifiers unchanged', () => {
866
- const source = `\
867
- contract M: {
868
- status: enum(active, inactive, pending)
869
- }
870
- `;
871
- expect(roundTrip(source)).toBe(source);
872
- });
873
-
874
- it('round-trips enum with quoted multi-word values', () => {
875
- const source = `\
876
- contract M: {
877
- entityType: enum("Sole Proprietorship", LLC, "Limited Partnership")
878
- }
879
- `;
880
- expect(roundTrip(source)).toBe(source);
881
- });
882
- });
883
-
884
- describe('printCk — trailing/orphan comments (round-trip)', () => {
885
- function roundTrip(source: string): string {
886
- const diag = new DiagnosticCollector();
887
- const ast = parseCk(source, 'test.ck', diag);
888
- expect(diag.hasErrors()).toBe(false);
889
- return printCk(ast);
890
- }
891
-
892
- it('preserves a comment on the last line of a contract body', () => {
893
- const source = `\
894
- contract Payment: {
895
- id: uuid
896
- # more fields to come
897
- }
898
- `;
899
- const out = roundTrip(source);
900
- expect(out).toContain('# more fields to come');
901
- // Comment sits on its own line after the last field, before the closing brace.
902
- expect(out).toBe(source);
903
- // Idempotent: formatting the output again yields the same result.
904
- expect(roundTrip(out)).toBe(out);
905
- });
906
-
907
- it('preserves multiple trailing comments in a contract body', () => {
908
- const source = `\
909
- contract Payment: {
910
- id: uuid
911
- # note one
912
- # note two
913
- }
914
- `;
915
- const out = roundTrip(source);
916
- expect(out).toContain('# note one');
917
- expect(out).toContain('# note two');
918
- expect(out).toBe(source);
919
- expect(roundTrip(out)).toBe(out);
920
- });
921
-
922
- it('preserves a comment as the only content of a contract body', () => {
923
- const source = `\
924
- contract Empty: {
925
- # nothing here yet
926
- }
927
- `;
928
- const out = roundTrip(source);
929
- expect(out).toContain('# nothing here yet');
930
- expect(out).toBe(source);
931
- expect(roundTrip(out)).toBe(out);
932
- });
933
-
934
- it('preserves a comment on the last line of an operation/route body', () => {
935
- const source = `\
936
- operation /users: {
937
- get: {
938
- }
939
- # TODO: add post
940
- }
941
- `;
942
- const out = roundTrip(source);
943
- expect(out).toContain('# TODO: add post');
944
- expect(out).toBe(source);
945
- expect(roundTrip(out)).toBe(out);
946
- });
947
-
948
- it('does not double-emit a header inline comment on a contract with no fields', () => {
949
- const source = `\
950
- contract Empty: { # nothing here yet
951
- }
952
- `;
953
- const out = roundTrip(source);
954
- // The inline header comment must survive exactly once, not be duplicated on a trailing line.
955
- expect(out.match(/# nothing here yet/g)?.length).toBe(1);
956
- expect(roundTrip(out)).toBe(out);
957
- });
958
- });
959
-
960
- describe('printCk — inline object trailing comments (round-trip)', () => {
961
- function roundTrip(source: string): string {
962
- const diag = new DiagnosticCollector();
963
- const ast = parseCk(source, 'test.ck', diag);
964
- expect(diag.hasErrors()).toBe(false);
965
- return printCk(ast);
966
- }
967
-
968
- it('preserves a trailing comment as the last inner item of an inline object type', () => {
969
- const source = `\
970
- contract Foo: {
971
- bar: {
972
- a: string
973
- # trailing note
974
- }
975
- }
976
- `;
977
- const out = roundTrip(source);
978
- expect(out).toContain('# trailing note');
979
- expect(out).toBe(source);
980
- expect(roundTrip(out)).toBe(out);
981
- });
982
-
983
- it('preserves multiple trailing comments in an inline object type', () => {
984
- const source = `\
985
- contract Foo: {
986
- bar: {
987
- a: string
988
- # note one
989
- # note two
990
- }
991
- }
992
- `;
993
- const out = roundTrip(source);
994
- expect(out).toContain('# note one');
995
- expect(out).toContain('# note two');
996
- expect(out).toBe(source);
997
- expect(roundTrip(out)).toBe(out);
998
- });
999
-
1000
- it('preserves a trailing comment as the only inner item of an inline object type', () => {
1001
- const source = `\
1002
- contract Foo: {
1003
- bar: {
1004
- # placeholder
1005
- }
1006
- }
1007
- `;
1008
- const out = roundTrip(source);
1009
- expect(out).toContain('# placeholder');
1010
- expect(out).toBe(source);
1011
- expect(roundTrip(out)).toBe(out);
1012
- });
1013
-
1014
- it('still attaches an inline comment on an inline field (no regression)', () => {
1015
- const source = `\
1016
- contract Foo: {
1017
- bar: {
1018
- a: string # about a
1019
- b: int
1020
- }
1021
- }
1022
- `;
1023
- const out = roundTrip(source);
1024
- expect(out).toContain('a: string # about a');
1025
- expect(out).toBe(source);
1026
- expect(roundTrip(out)).toBe(out);
1027
- });
1028
- });
1029
-
1030
- describe('printCk — options block sub-block comments (round-trip)', () => {
1031
- function roundTrip(source: string): string {
1032
- const diag = new DiagnosticCollector();
1033
- const ast = parseCk(source, 'test.ck', diag);
1034
- expect(diag.hasErrors()).toBe(false);
1035
- return printCk(ast);
1036
- }
1037
-
1038
- it('preserves a leading comment on a keys entry', () => {
1039
- const source = `\
1040
- options {
1041
- keys: {
1042
- # the api area
1043
- area: payments
1044
- version: v2
1045
- }
1046
- }
1047
- `;
1048
- const out = roundTrip(source);
1049
- expect(out).toContain('# the api area');
1050
- expect(out).toBe(source);
1051
- expect(roundTrip(out)).toBe(out);
1052
- });
1053
-
1054
- it('preserves a trailing comment at the end of the keys sub-block', () => {
1055
- const source = `\
1056
- options {
1057
- keys: {
1058
- area: payments
1059
- # more keys to come
1060
- }
1061
- }
1062
- `;
1063
- const out = roundTrip(source);
1064
- expect(out).toContain('# more keys to come');
1065
- expect(out).toBe(source);
1066
- expect(roundTrip(out)).toBe(out);
1067
- });
1068
-
1069
- it('preserves a leading comment on a services entry', () => {
1070
- const source = `\
1071
- options {
1072
- services: {
1073
- # payments backend
1074
- PaymentsService: "#src/services/payments.service.js"
1075
- }
1076
- }
1077
- `;
1078
- const out = roundTrip(source);
1079
- expect(out).toContain('# payments backend');
1080
- expect(out).toBe(source);
1081
- expect(roundTrip(out)).toBe(out);
1082
- });
1083
-
1084
- it('preserves a comment at the end of the last options sub-block (end of options block)', () => {
1085
- const source = `\
1086
- options {
1087
- keys: {
1088
- area: payments
1089
- }
1090
- services: {
1091
- PaymentsService: "#src/services/payments.service.js"
1092
- # end of options
1093
- }
1094
- }
1095
- `;
1096
- const out = roundTrip(source);
1097
- expect(out).toContain('# end of options');
1098
- expect(out).toBe(source);
1099
- expect(roundTrip(out)).toBe(out);
1100
- });
1101
-
1102
- it('preserves both leading and trailing comments across keys and services', () => {
1103
- const source = `\
1104
- options {
1105
- keys: {
1106
- # area comment
1107
- area: payments
1108
- # trailing keys
1109
- }
1110
- services: {
1111
- # service comment
1112
- PaymentsService: "#src/services/payments.service.js"
1113
- # trailing services
1114
- }
1115
- }
1116
- `;
1117
- const out = roundTrip(source);
1118
- expect(out).toBe(source);
1119
- expect(roundTrip(out)).toBe(out);
1120
- });
1121
- });