@contractkit/prettier-plugin 0.12.2 → 0.14.0

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.
@@ -0,0 +1,489 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { printCk } from '../src/print-ck.js';
5
+ import { parseCk, DiagnosticCollector } from '@contractkit/core';
6
+
7
+ /**
8
+ * Formatting a `.ck` file must not change it.
9
+ *
10
+ * The prettier plugin used to fold standalone `#` comment blocks into trailing comments on the
11
+ * following declaration, reorder operation body keys, drop blank lines between operations, and
12
+ * expand single-line response bodies — all of which silently rewrote a user's file on
13
+ * `pnpm format`. These tests pin the guarantee: parse → print is the identity on well-formed
14
+ * source, and printing is idempotent.
15
+ */
16
+
17
+ function format(source: string, file = 'test.ck'): string {
18
+ const diag = new DiagnosticCollector();
19
+ const ast = parseCk(source, file, diag);
20
+ expect(diag.hasErrors()).toBe(false);
21
+ return printCk(ast);
22
+ }
23
+
24
+ // ─── Repository contracts ────────────────────────────────────────────────────
25
+
26
+ const CONTRACTS_DIR = new URL('../../../contracts', import.meta.url).pathname;
27
+ const ckFiles = readdirSync(CONTRACTS_DIR).filter(f => f.endsWith('.ck'));
28
+
29
+ describe('round-trip — repository .ck files', () => {
30
+ it('finds .ck files to check', () => {
31
+ expect(ckFiles.length).toBeGreaterThan(0);
32
+ });
33
+
34
+ for (const name of ckFiles) {
35
+ it(`formats ${name} to itself`, () => {
36
+ const source = readFileSync(join(CONTRACTS_DIR, name), 'utf8');
37
+ expect(format(source, name)).toBe(source);
38
+ });
39
+ }
40
+ });
41
+
42
+ // ─── Constructs that previously round-tripped lossily ────────────────────────
43
+
44
+ describe('round-trip — comment placement', () => {
45
+ it('keeps a standalone comment block above the declaration it precedes', () => {
46
+ const source = `# ─── Pet endpoints ───
47
+
48
+ operation /pet: {
49
+ get: {
50
+ response: {
51
+ 200:
52
+ }
53
+ }
54
+ }
55
+ `;
56
+ expect(format(source)).toBe(source);
57
+ });
58
+
59
+ it('keeps a contract doc comment on its own line', () => {
60
+ const source = `# A pet for sale
61
+ contract Pet: {
62
+ id: int
63
+ }
64
+ `;
65
+ expect(format(source)).toBe(source);
66
+ });
67
+
68
+ it('keeps a contract doc comment inline when written inline', () => {
69
+ const source = `contract Pet: { # A pet for sale
70
+ id: int
71
+ }
72
+ `;
73
+ expect(format(source)).toBe(source);
74
+ });
75
+
76
+ it('distinguishes a divider from the doc comment below it', () => {
77
+ const source = `# ─── Models ───
78
+
79
+ # A pet for sale
80
+ contract Pet: {
81
+ id: int
82
+ }
83
+ `;
84
+ expect(format(source)).toBe(source);
85
+ });
86
+
87
+ it('keeps an operation doc comment on its own line', () => {
88
+ const source = `operation /pet: {
89
+ # update an existing pet
90
+ put: {
91
+ response: {
92
+ 200:
93
+ }
94
+ }
95
+ }
96
+ `;
97
+ expect(format(source)).toBe(source);
98
+ });
99
+
100
+ it('keeps an operation doc comment inline when written inline', () => {
101
+ const source = `operation /pet: {
102
+ put: { # update an existing pet
103
+ response: {
104
+ 200:
105
+ }
106
+ }
107
+ }
108
+ `;
109
+ expect(format(source)).toBe(source);
110
+ });
111
+ });
112
+
113
+ describe('round-trip — comments in the options block', () => {
114
+ it('keeps a header comment above the options keyword', () => {
115
+ const source = `# ContractKit contracts for billing.
116
+ # Owned by the payments team.
117
+ options {
118
+ keys: {
119
+ area: billing
120
+ }
121
+ }
122
+
123
+ contract Pet: {
124
+ id: uuid
125
+ }
126
+ `;
127
+ expect(format(source)).toBe(source);
128
+ });
129
+
130
+ it('keeps a trailing comment on an options entry, braces and all', () => {
131
+ // The value used to swallow the comment and stop at the first `}`, which closed the
132
+ // block early and silently mis-parsed everything after it.
133
+ const source = `options {
134
+ keys: {
135
+ area: billing # interpolated elsewhere as {{area}}
136
+ }
137
+ services: {
138
+ PetService: "#modules/pet/pet.service.js"
139
+ }
140
+ }
141
+
142
+ contract Pet: {
143
+ id: uuid
144
+ }
145
+ `;
146
+ expect(format(source)).toBe(source);
147
+ });
148
+
149
+ it('keeps an unquoted subpath value unquoted', () => {
150
+ // Both forms parse to the same string, so the parser records which one the author wrote;
151
+ // without that the formatter normalizes every value to the quoted form.
152
+ const source = `options {
153
+ services: {
154
+ PetService: #modules/pet/pet.service.js
155
+ }
156
+ }
157
+
158
+ contract Pet: {
159
+ id: uuid
160
+ }
161
+ `;
162
+ expect(format(source)).toBe(source);
163
+ });
164
+
165
+ it('keeps a quoted value quoted', () => {
166
+ const source = `options {
167
+ services: {
168
+ PetService: "#modules/pet/pet.service.js"
169
+ }
170
+ }
171
+
172
+ contract Pet: {
173
+ id: uuid
174
+ }
175
+ `;
176
+ expect(format(source)).toBe(source);
177
+ });
178
+
179
+ it('keeps both forms side by side, with their comments', () => {
180
+ const source = `options {
181
+ services: {
182
+ Bare: #modules/a/a.service.js # unquoted
183
+ Quoted: "#modules/b/b.service.js" # quoted
184
+ }
185
+ }
186
+
187
+ contract Pet: {
188
+ id: uuid
189
+ }
190
+ `;
191
+ expect(format(source)).toBe(source);
192
+ });
193
+
194
+ it('keeps a comment above a sub-block', () => {
195
+ const source = `options {
196
+ # where these come from
197
+ keys: {
198
+ area: ledger
199
+ }
200
+ }
201
+ `;
202
+ expect(format(source)).toBe(source);
203
+ });
204
+
205
+ it('keeps a comment run above the sub-block it precedes', () => {
206
+ const source = `options {
207
+ keys: {
208
+ area: ledger
209
+ }
210
+ # service wiring
211
+ # one per module
212
+ services: {
213
+ UserService: "#src/user.js"
214
+ }
215
+ }
216
+ `;
217
+ expect(format(source)).toBe(source);
218
+ });
219
+
220
+ it('keeps a trailing comment before the closing brace', () => {
221
+ const source = `options {
222
+ keys: {
223
+ area: ledger
224
+ }
225
+ # nothing below
226
+ }
227
+ `;
228
+ expect(format(source)).toBe(source);
229
+ });
230
+
231
+ it('keeps an options block that holds nothing but a comment', () => {
232
+ const source = `options {
233
+ # a note
234
+ }
235
+ `;
236
+ expect(format(source)).toBe(source);
237
+ });
238
+ });
239
+
240
+ describe('round-trip — operation body key order', () => {
241
+ it('does not reorder keys into a canonical order', () => {
242
+ const source = `operation /pet: {
243
+ put: {
244
+ sdk: updatePet
245
+ service: PetService.update
246
+ response: {
247
+ 200:
248
+ }
249
+ }
250
+ }
251
+ `;
252
+ expect(format(source)).toBe(source);
253
+ });
254
+
255
+ it('preserves the opposite order just as faithfully', () => {
256
+ const source = `operation /pet: {
257
+ put: {
258
+ service: PetService.update
259
+ sdk: updatePet
260
+ response: {
261
+ 200:
262
+ }
263
+ }
264
+ }
265
+ `;
266
+ expect(format(source)).toBe(source);
267
+ });
268
+ });
269
+
270
+ describe('round-trip — layout', () => {
271
+ it('keeps blank lines between operations', () => {
272
+ const source = `operation /pet: {
273
+ get: {
274
+ response: {
275
+ 200:
276
+ }
277
+ }
278
+
279
+ post: {
280
+ response: {
281
+ 201:
282
+ }
283
+ }
284
+ }
285
+ `;
286
+ expect(format(source)).toBe(source);
287
+ });
288
+
289
+ it('keeps operations packed when the source has no blank line', () => {
290
+ const source = `operation /pet: {
291
+ get: {
292
+ response: {
293
+ 200:
294
+ }
295
+ }
296
+ post: {
297
+ response: {
298
+ 201:
299
+ }
300
+ }
301
+ }
302
+ `;
303
+ expect(format(source)).toBe(source);
304
+ });
305
+
306
+ it('keeps a single-line response body on one line', () => {
307
+ const source = `operation /pet: {
308
+ get: {
309
+ response: {
310
+ 200: { application/json: Pet }
311
+ }
312
+ }
313
+ }
314
+ `;
315
+ expect(format(source)).toBe(source);
316
+ });
317
+
318
+ it('keeps an expanded response body expanded', () => {
319
+ const source = `operation /pet: {
320
+ get: {
321
+ response: {
322
+ 200: {
323
+ application/json: Pet
324
+ }
325
+ }
326
+ }
327
+ }
328
+ `;
329
+ expect(format(source)).toBe(source);
330
+ });
331
+
332
+ it('keeps an empty status block, which means emitted with no body', () => {
333
+ const source = `operation /art/{id}: {
334
+ get: {
335
+ response: {
336
+ 200: { application/json: Art }
337
+ 304: {}
338
+ }
339
+ }
340
+ }
341
+ `;
342
+ expect(format(source)).toBe(source);
343
+ });
344
+
345
+ it('keeps a bare status bare', () => {
346
+ const source = `operation /art/{id}: {
347
+ get: {
348
+ response: {
349
+ 200: { application/json: Art }
350
+ 304:
351
+ }
352
+ }
353
+ }
354
+ `;
355
+ expect(format(source)).toBe(source);
356
+ });
357
+
358
+ it('keeps the documented modifier on a status', () => {
359
+ const source = `operation /pet: {
360
+ get: {
361
+ response: {
362
+ 200: { application/json: Pet }
363
+ 404(documented): { application/json: Problem }
364
+ 410(documented):
365
+ }
366
+ }
367
+ }
368
+ `;
369
+ expect(format(source)).toBe(source);
370
+ });
371
+
372
+ it('keeps every declared mime for a status, in source order', () => {
373
+ const source = `operation /art/{id}: {
374
+ get: {
375
+ response: {
376
+ 200: {
377
+ image/png: binary
378
+ image/jpeg: binary
379
+ headers: {
380
+ etag?: string
381
+ }
382
+ }
383
+ }
384
+ }
385
+ }
386
+ `;
387
+ expect(format(source)).toBe(source);
388
+ });
389
+
390
+ it('keeps several mimes on one line when the source has them there', () => {
391
+ const source = `operation /art/{id}: {
392
+ get: {
393
+ response: {
394
+ 200: { image/png: binary image/jpeg: binary }
395
+ }
396
+ }
397
+ }
398
+ `;
399
+ expect(format(source)).toBe(source);
400
+ });
401
+ });
402
+
403
+ // ─── Comments in the response block ──────────────────────────────────────────
404
+
405
+ describe('round-trip — comments in the response block', () => {
406
+ it('keeps a comment run above a status code', () => {
407
+ const source = `operation /art/{id}: {
408
+ get: {
409
+ response: {
410
+ # One declared mime per format, because the router pins ctx.type from this line
411
+ # and a service cannot vary it per request.
412
+ 200: {
413
+ image/png: binary
414
+ }
415
+ # Produced by the conditional-GET middleware, not by the handler.
416
+ 304:
417
+ }
418
+ }
419
+ }
420
+ `;
421
+ expect(format(source)).toBe(source);
422
+ });
423
+
424
+ it('keeps a comment above a mime line inside a status block', () => {
425
+ const source = `operation /art/{id}: {
426
+ get: {
427
+ response: {
428
+ 200: {
429
+ # served straight from object storage
430
+ image/png: binary
431
+ }
432
+ }
433
+ }
434
+ }
435
+ `;
436
+ expect(format(source)).toBe(source);
437
+ });
438
+
439
+ it('keeps a comment above a headers block', () => {
440
+ const source = `operation /art/{id}: {
441
+ get: {
442
+ response: {
443
+ 200: {
444
+ image/png: binary
445
+ # set by the CDN, echoed here so the SDK types it
446
+ headers: {
447
+ etag?: string
448
+ }
449
+ }
450
+ }
451
+ }
452
+ }
453
+ `;
454
+ expect(format(source)).toBe(source);
455
+ });
456
+
457
+ it('keeps trailing comments before either closing brace', () => {
458
+ const source = `operation /art/{id}: {
459
+ get: {
460
+ response: {
461
+ 200: {
462
+ image/png: binary
463
+ # TODO: add image/avif once the encoder lands
464
+ }
465
+ # TODO: document the 429 the rate limiter returns
466
+ }
467
+ }
468
+ }
469
+ `;
470
+ expect(format(source)).toBe(source);
471
+ });
472
+ });
473
+
474
+ // ─── Idempotence ─────────────────────────────────────────────────────────────
475
+
476
+ describe('round-trip — idempotence', () => {
477
+ const sources = [
478
+ ...ckFiles.map(name => readFileSync(join(CONTRACTS_DIR, name), 'utf8')),
479
+ // Non-canonical spacing: formatting once must reach a fixed point.
480
+ `operation /pet: {\n get: {\n response: {\n 200:\n }\n }\n}\n`,
481
+ ];
482
+
483
+ for (const [i, source] of sources.entries()) {
484
+ it(`formatting is a fixed point for source #${i}`, () => {
485
+ const once = format(source);
486
+ expect(format(once)).toBe(once);
487
+ });
488
+ }
489
+ });