@contractkit/prettier-plugin 0.13.0 → 0.14.1
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 +15 -13
- package/CHANGELOG.md +62 -0
- package/README.md +9 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +63 -22
- package/dist/index.js.map +1 -1
- package/dist/print-ck.d.ts +5 -4
- package/dist/print-ck.d.ts.map +1 -1
- package/dist/print-operation.d.ts +5 -2
- package/dist/print-operation.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +17 -1
- package/src/print-ck.ts +35 -16
- package/src/print-operation.ts +57 -17
- package/tests/format-plugin.test.ts +42 -0
- package/tests/print-ck.test.ts +5 -3
- package/tests/round-trip.test.ts +426 -0
package/src/print-operation.ts
CHANGED
|
@@ -177,7 +177,7 @@ function printOperationKey(op: OpOperationNode, key: OpBodyKey): string[] {
|
|
|
177
177
|
return lines;
|
|
178
178
|
}
|
|
179
179
|
case 'responses':
|
|
180
|
-
return op.responses.length > 0 ? printResponseBlock(op.responses) : [];
|
|
180
|
+
return op.responses.length > 0 ? printResponseBlock(op.responses, op.responsesTrailingComments) : [];
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
|
|
@@ -189,6 +189,9 @@ function printOperation(op: OpOperationNode): string[] {
|
|
|
189
189
|
// re-emitted as a trailing `#` on the header. Nodes built programmatically carry no placement,
|
|
190
190
|
// and default to inline — the form most `.ck` sources use and one that round-trips as written.
|
|
191
191
|
const inlineDescription = op.descriptionInline ?? true;
|
|
192
|
+
// Standalone prose above the verb, kept apart from the doc comment when the verb carries an
|
|
193
|
+
// inline one. Emitted first so it stays above the line it was written above.
|
|
194
|
+
for (const c of op.leadingComments ?? []) lines.push(`${I1}# ${c}`);
|
|
192
195
|
if (op.description && !inlineDescription) {
|
|
193
196
|
for (const line of op.description.split('\n')) lines.push(`${I1}# ${line}`);
|
|
194
197
|
}
|
|
@@ -199,10 +202,22 @@ function printOperation(op: OpOperationNode): string[] {
|
|
|
199
202
|
// Any key the source order doesn't mention (e.g. added by a later AST pass) follows in canonical order.
|
|
200
203
|
const order = op.keyOrder ?? [];
|
|
201
204
|
const rest = CANONICAL_KEY_ORDER.filter(k => !order.includes(k));
|
|
205
|
+
// A comment run whose key turned out to print nothing has nowhere to sit; rather than drop it,
|
|
206
|
+
// it falls through to the trailing run at the end of the body.
|
|
207
|
+
const orphaned: string[] = [];
|
|
202
208
|
for (const key of [...order, ...rest]) {
|
|
203
|
-
|
|
209
|
+
const keyLines = printOperationKey(op, key);
|
|
210
|
+
const comments = op.bodyLeadingComments?.[key] ?? [];
|
|
211
|
+
if (keyLines.length === 0) {
|
|
212
|
+
orphaned.push(...comments);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
for (const c of comments) lines.push(`${I2}# ${c}`);
|
|
216
|
+
lines.push(...keyLines);
|
|
204
217
|
}
|
|
205
218
|
|
|
219
|
+
for (const c of [...orphaned, ...(op.bodyTrailingComments ?? [])]) lines.push(`${I2}# ${c}`);
|
|
220
|
+
|
|
206
221
|
lines.push(`${I1}}`);
|
|
207
222
|
return lines;
|
|
208
223
|
}
|
|
@@ -311,8 +326,11 @@ function formatSignatureValue(value: string): string {
|
|
|
311
326
|
|
|
312
327
|
/**
|
|
313
328
|
* Print a `security:` declaration. Returns `["${indent}security: none"]` for the public-endpoint
|
|
314
|
-
* sentinel, a multi-line block when `policy` is set
|
|
315
|
-
* meaningful to emit.
|
|
329
|
+
* sentinel, a multi-line block when `policy` is set or the block carries comments, or an empty
|
|
330
|
+
* array when there is nothing meaningful to emit.
|
|
331
|
+
*
|
|
332
|
+
* A block holding only comments still prints: the rationale for a policy floor is the reason
|
|
333
|
+
* authors write in here, and collapsing the braces away would delete it.
|
|
316
334
|
*
|
|
317
335
|
* @param indent indentation for the `security` keyword line
|
|
318
336
|
* @param innerIndent indentation for field lines inside the block
|
|
@@ -320,11 +338,19 @@ function formatSignatureValue(value: string): string {
|
|
|
320
338
|
export function printSecurity(security: SecurityNode, indent = I2, innerIndent = I3): string[] {
|
|
321
339
|
if (security === SECURITY_NONE) return [`${indent}security: none`];
|
|
322
340
|
const fields = security as SecurityFields;
|
|
323
|
-
|
|
341
|
+
const leading = fields.leadingComments ?? [];
|
|
342
|
+
const trailing = fields.trailingComments ?? [];
|
|
343
|
+
// A block with no policy still has to be emitted when it carries comments, or the author's
|
|
344
|
+
// rationale disappears along with the empty braces.
|
|
345
|
+
if (fields.policy === undefined && leading.length === 0 && trailing.length === 0) return [];
|
|
324
346
|
const lines = [`${indent}security: {`];
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
347
|
+
for (const c of leading) lines.push(`${innerIndent}# ${c}`);
|
|
348
|
+
if (fields.policy !== undefined) {
|
|
349
|
+
const comment = fields.policyDescription ? ` # ${fields.policyDescription}` : '';
|
|
350
|
+
const value = fields.policy === false ? 'none' : fields.policy;
|
|
351
|
+
lines.push(`${innerIndent}policy: ${value}${comment}`);
|
|
352
|
+
}
|
|
353
|
+
for (const c of trailing) lines.push(`${innerIndent}# ${c}`);
|
|
328
354
|
lines.push(`${indent}}`);
|
|
329
355
|
return lines;
|
|
330
356
|
}
|
|
@@ -377,21 +403,33 @@ function printContentTypeLine(contentType: string, bodyType: ContractTypeNode, l
|
|
|
377
403
|
|
|
378
404
|
// ─── Response block ──────────────────────────────────────────────────────────
|
|
379
405
|
|
|
380
|
-
function printResponseBlock(responses: OpResponseNode[]): string[] {
|
|
406
|
+
function printResponseBlock(responses: OpResponseNode[], trailingComments?: string[]): string[] {
|
|
381
407
|
const lines: string[] = [`${I2}response: {`];
|
|
382
408
|
|
|
383
409
|
for (const resp of responses) {
|
|
384
|
-
const
|
|
410
|
+
for (const comment of resp.leadingComments ?? []) lines.push(`${I3}# ${comment}`);
|
|
411
|
+
const bodies = resp.bodies;
|
|
385
412
|
const hasHeaders = resp.headers && resp.headers.length > 0;
|
|
386
413
|
const optOut = resp.headersOptOut;
|
|
387
|
-
|
|
414
|
+
// `404(documented):` — the modifier changes what codegen does, so it has to survive.
|
|
415
|
+
const code = resp.emit ? `${resp.statusCode}(${resp.emit})` : `${resp.statusCode}`;
|
|
416
|
+
const inlinable = resp.inline && bodies.length > 0 && !hasHeaders && !optOut && bodies.every(b => b.bodyType.kind !== 'inlineObject');
|
|
417
|
+
if (inlinable) {
|
|
388
418
|
// Written on one line in the source, so keep it there: `200: { application/json: Pet }`.
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
lines.push(`${I3}${
|
|
392
|
-
|
|
393
|
-
|
|
419
|
+
// Several mimes on that line stay on it too, space-separated as the grammar has them.
|
|
420
|
+
const inner = bodies.map(b => `${b.contentType}: ${printType(b.bodyType)}`).join(' ');
|
|
421
|
+
lines.push(`${I3}${code}: { ${inner} }`);
|
|
422
|
+
} else if (bodies.length === 0 && !hasHeaders && !optOut && resp.hasBlock) {
|
|
423
|
+
// An empty block means "emitted, no body" — collapsing it to `304:` would change
|
|
424
|
+
// the generated router, so it is not a formatting detail.
|
|
425
|
+
lines.push(`${I3}${code}: {}`);
|
|
426
|
+
} else if (bodies.length > 0 || hasHeaders || optOut || (resp.trailingComments?.length ?? 0) > 0) {
|
|
427
|
+
lines.push(`${I3}${code}: {`);
|
|
428
|
+
for (const body of bodies) {
|
|
429
|
+
for (const comment of body.leadingComments ?? []) lines.push(`${I4}# ${comment}`);
|
|
430
|
+
lines.push(...printContentTypeLine(body.contentType, body.bodyType, I4));
|
|
394
431
|
}
|
|
432
|
+
for (const comment of resp.headersLeadingComments ?? []) lines.push(`${I4}# ${comment}`);
|
|
395
433
|
if (optOut) {
|
|
396
434
|
lines.push(`${I4}headers: none`);
|
|
397
435
|
} else if (hasHeaders) {
|
|
@@ -403,12 +441,14 @@ function printResponseBlock(responses: OpResponseNode[]): string[] {
|
|
|
403
441
|
}
|
|
404
442
|
lines.push(`${I4}}`);
|
|
405
443
|
}
|
|
444
|
+
for (const comment of resp.trailingComments ?? []) lines.push(`${I4}# ${comment}`);
|
|
406
445
|
lines.push(`${I3}}`);
|
|
407
446
|
} else {
|
|
408
|
-
lines.push(`${I3}${
|
|
447
|
+
lines.push(`${I3}${code}:`);
|
|
409
448
|
}
|
|
410
449
|
}
|
|
411
450
|
|
|
451
|
+
for (const comment of trailingComments ?? []) lines.push(`${I3}# ${comment}`);
|
|
412
452
|
lines.push(`${I2}}`);
|
|
413
453
|
return lines;
|
|
414
454
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as prettier from 'prettier';
|
|
3
|
+
import plugin from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* End-to-end checks that go through prettier itself rather than calling `printCk` directly.
|
|
7
|
+
*
|
|
8
|
+
* `printCk` terminates the file with a newline, so a printer-level test cannot see whether the
|
|
9
|
+
* plugin's doc wrapper preserves it — the plugin used to `trimEnd()` the printed source and
|
|
10
|
+
* return it without re-adding the terminator, so every formatted `.ck` file lost its trailing
|
|
11
|
+
* newline. These tests exercise the layer where that happened.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function format(source: string): Promise<string> {
|
|
15
|
+
return prettier.format(source, { parser: 'contract-ck', plugins: [plugin] });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('prettier plugin — end to end', () => {
|
|
19
|
+
it('ends the formatted file with exactly one newline', async () => {
|
|
20
|
+
const source = `contract Pet: {
|
|
21
|
+
name: string
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
const out = await format(source);
|
|
25
|
+
expect(out).toBe(source);
|
|
26
|
+
expect(out.endsWith('\n')).toBe(true);
|
|
27
|
+
expect(out.endsWith('\n\n')).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('adds the trailing newline when the source lacks one', async () => {
|
|
31
|
+
const out = await format('contract Pet: {\n name: string\n}');
|
|
32
|
+
expect(out).toBe('contract Pet: {\n name: string\n}\n');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('does not accumulate blank lines when run twice', async () => {
|
|
36
|
+
const source = `contract Pet: {
|
|
37
|
+
name: string
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
expect(await format(await format(source))).toBe(source);
|
|
41
|
+
});
|
|
42
|
+
});
|
package/tests/print-ck.test.ts
CHANGED
|
@@ -453,7 +453,7 @@ describe('printCk — request blocks', () => {
|
|
|
453
453
|
makeRoute('/users', [
|
|
454
454
|
makeOp('post', {
|
|
455
455
|
request: { bodies: [{ contentType: 'application/vnd.api+json', bodyType: { kind: 'ref', name: 'CreateUser' } }] },
|
|
456
|
-
responses: [{ statusCode: 201, contentType: 'application/vnd.api+json', bodyType: { kind: 'ref', name: 'User' } }],
|
|
456
|
+
responses: [{ statusCode: 201, hasBlock: true, bodies: [{ contentType: 'application/vnd.api+json', bodyType: { kind: 'ref', name: 'User' } }] }],
|
|
457
457
|
}),
|
|
458
458
|
]),
|
|
459
459
|
]);
|
|
@@ -493,8 +493,8 @@ describe('printCk — response headers', () => {
|
|
|
493
493
|
responses: [
|
|
494
494
|
{
|
|
495
495
|
statusCode: 200,
|
|
496
|
-
|
|
497
|
-
bodyType: { kind: 'ref', name: 'Transfer' },
|
|
496
|
+
hasBlock: true,
|
|
497
|
+
bodies: [{ contentType: 'application/json', bodyType: { kind: 'ref', name: 'Transfer' } }],
|
|
498
498
|
headers: [
|
|
499
499
|
{ name: 'preference-applied', optional: true, type: { kind: 'scalar', name: 'string' } },
|
|
500
500
|
{ name: 'etag', optional: false, type: { kind: 'scalar', name: 'string' }, description: 'cache validator' },
|
|
@@ -519,6 +519,8 @@ describe('printCk — response headers', () => {
|
|
|
519
519
|
responses: [
|
|
520
520
|
{
|
|
521
521
|
statusCode: 204,
|
|
522
|
+
hasBlock: true,
|
|
523
|
+
bodies: [],
|
|
522
524
|
headers: [{ name: 'x-deleted-at', optional: false, type: { kind: 'scalar', name: 'string' } }],
|
|
523
525
|
},
|
|
524
526
|
],
|
package/tests/round-trip.test.ts
CHANGED
|
@@ -111,6 +111,86 @@ contract Pet: {
|
|
|
111
111
|
});
|
|
112
112
|
|
|
113
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
|
+
|
|
114
194
|
it('keeps a comment above a sub-block', () => {
|
|
115
195
|
const source = `options {
|
|
116
196
|
# where these come from
|
|
@@ -245,6 +325,352 @@ describe('round-trip — layout', () => {
|
|
|
245
325
|
}
|
|
246
326
|
}
|
|
247
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
|
+
// ─── Security blocks ─────────────────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* A `security { }` block is where authors record *why* a policy floor sits where it does, so the
|
|
478
|
+
* prose in and around it is load-bearing. Every position here used to be deleted outright on
|
|
479
|
+
* format: the parser skipped standalone comments inside the block, dropped comment runs above a
|
|
480
|
+
* body key, and let a comment run above a verb lose to the verb's own inline doc comment.
|
|
481
|
+
*/
|
|
482
|
+
describe('round-trip — security block comments', () => {
|
|
483
|
+
it('keeps a comment above the policy line in the options block', () => {
|
|
484
|
+
const source = `options {
|
|
485
|
+
security: {
|
|
486
|
+
# The floor for every operation in this file.
|
|
487
|
+
# Reads override it downward at their own verb.
|
|
488
|
+
policy: platform.manage
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
`;
|
|
492
|
+
expect(format(source)).toBe(source);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('keeps a comment after the policy line, before the closing brace', () => {
|
|
496
|
+
const source = `options {
|
|
497
|
+
security: {
|
|
498
|
+
policy: platform.manage
|
|
499
|
+
# Object-scoped permissions cannot be named here yet.
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
`;
|
|
503
|
+
expect(format(source)).toBe(source);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('distinguishes an inline policy comment from one on the next line', () => {
|
|
507
|
+
const source = `options {
|
|
508
|
+
security: {
|
|
509
|
+
# Why this floor
|
|
510
|
+
policy: platform.manage # the operator gate
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
`;
|
|
514
|
+
expect(format(source)).toBe(source);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('keeps a comment above a security block inside an operation body', () => {
|
|
518
|
+
const source = `operation /plugins: {
|
|
519
|
+
get: {
|
|
520
|
+
name: List plugins
|
|
521
|
+
# A read, so it drops to the view floor.
|
|
522
|
+
security: {
|
|
523
|
+
policy: platform.view
|
|
524
|
+
}
|
|
525
|
+
response: {
|
|
526
|
+
200:
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
`;
|
|
531
|
+
expect(format(source)).toBe(source);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('keeps a comment above a security: none line', () => {
|
|
535
|
+
const source = `operation /plugins/callback: {
|
|
536
|
+
get: {
|
|
537
|
+
name: OAuth callback
|
|
538
|
+
# The provider redirects a browser here, so it cannot carry a session.
|
|
539
|
+
security: none
|
|
540
|
+
response: {
|
|
541
|
+
200:
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
`;
|
|
546
|
+
expect(format(source)).toBe(source);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('keeps a comment run above a verb that also has an inline doc comment', () => {
|
|
550
|
+
const source = `operation /plugins/{id}/logs: {
|
|
551
|
+
params: {
|
|
552
|
+
id: uuid
|
|
553
|
+
}
|
|
554
|
+
# Log output is whatever the plugin chose to write, including upstream error
|
|
555
|
+
# bodies. Treat it as operator-only until the log store can promise otherwise.
|
|
556
|
+
get: { # Returns the plugin's buffered log lines
|
|
557
|
+
name: Get plugin logs
|
|
558
|
+
response: {
|
|
559
|
+
200:
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
`;
|
|
564
|
+
expect(format(source)).toBe(source);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('keeps a comment left after the last key in an operation body', () => {
|
|
568
|
+
const source = `operation /plugins: {
|
|
569
|
+
get: {
|
|
570
|
+
name: List plugins
|
|
571
|
+
response: {
|
|
572
|
+
200:
|
|
573
|
+
}
|
|
574
|
+
# TODO: gate this behind the object-scoped permission once it exists
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
`;
|
|
578
|
+
expect(format(source)).toBe(source);
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// ─── Declaration-boundary comments ───────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* A comment between two top-level declarations is ambiguous: it can be an inline comment trailing
|
|
586
|
+
* the one above, a doc comment for the one below, or a standalone block. A declaration could not
|
|
587
|
+
* see what preceded it, so `ModelBody`'s optional trailing comment claimed the *next* line's
|
|
588
|
+
* comment — deleting a doc comment, or silently re-filing it as the previous contract's
|
|
589
|
+
* description. `Root` places these now, by comparing source lines.
|
|
590
|
+
*/
|
|
591
|
+
describe('round-trip — comments between declarations', () => {
|
|
592
|
+
it('keeps a doc comment on a contract that follows a one-line alias contract', () => {
|
|
593
|
+
const source = `contract ConfigFieldType: enum(string, url, secret)
|
|
594
|
+
|
|
595
|
+
# One choice of a \`select\` config field
|
|
596
|
+
contract ConfigFieldOption: {
|
|
597
|
+
value: string
|
|
598
|
+
}
|
|
599
|
+
`;
|
|
600
|
+
expect(format(source)).toBe(source);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('keeps an inline comment on an alias contract where it was written', () => {
|
|
604
|
+
const source = `contract PluginStatus: enum(discovered, active) # Lifecycle state
|
|
605
|
+
|
|
606
|
+
contract ConfigFieldOption: {
|
|
607
|
+
value: string
|
|
608
|
+
}
|
|
609
|
+
`;
|
|
610
|
+
expect(format(source)).toBe(source);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('tells an inline comment apart from the doc comment below it', () => {
|
|
614
|
+
const source = `contract PluginStatus: enum(discovered, active) # Lifecycle state
|
|
615
|
+
|
|
616
|
+
# One choice of a \`select\` config field
|
|
617
|
+
contract ConfigFieldOption: {
|
|
618
|
+
value: string
|
|
619
|
+
}
|
|
620
|
+
`;
|
|
621
|
+
expect(format(source)).toBe(source);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('keeps an inline comment on the last declaration in the file', () => {
|
|
625
|
+
const source = `contract PluginStatus: enum(discovered, active) # Lifecycle state
|
|
626
|
+
`;
|
|
627
|
+
expect(format(source)).toBe(source);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('keeps a comment after the last declaration', () => {
|
|
631
|
+
const source = `contract PluginStatus: enum(discovered, active)
|
|
632
|
+
|
|
633
|
+
# TODO: add the archived state once the store supports it
|
|
634
|
+
`;
|
|
635
|
+
expect(format(source)).toBe(source);
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// ─── Field comments ──────────────────────────────────────────────────────────
|
|
640
|
+
|
|
641
|
+
describe('round-trip — field comments', () => {
|
|
642
|
+
it('keeps a comment on a nested inline object opening brace', () => {
|
|
643
|
+
const source = `contract FidoFactorAttestation: {
|
|
644
|
+
rp: { # The relying party
|
|
645
|
+
name: string # The relying party name
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
`;
|
|
649
|
+
expect(format(source)).toBe(source);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('keeps a nested brace comment that repeats the contract header wording', () => {
|
|
653
|
+
// The header comment is reclaimed from the field list by matching text; when a nested
|
|
654
|
+
// field carried the same wording, the reclaim deleted the wrong one.
|
|
655
|
+
const source = `contract FidoFactorAttestation: { # The FIDO factor attestation information
|
|
656
|
+
rp: { # The FIDO factor attestation information
|
|
657
|
+
name: string # The relying party name
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
`;
|
|
661
|
+
expect(format(source)).toBe(source);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it('keeps a trailing comment on a field whose enum type wraps', () => {
|
|
665
|
+
const source = `contract LineupEntry: {
|
|
666
|
+
segmentState?: enum(
|
|
667
|
+
planned,
|
|
668
|
+
rendering,
|
|
669
|
+
ready,
|
|
670
|
+
failed,
|
|
671
|
+
gone
|
|
672
|
+
) # How far along the segment is
|
|
673
|
+
}
|
|
248
674
|
`;
|
|
249
675
|
expect(format(source)).toBe(source);
|
|
250
676
|
});
|