@contractkit/openapi-to-ck 0.7.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.
Files changed (97) hide show
  1. package/.turbo/turbo-build$colon$ci.log +40 -0
  2. package/.turbo/turbo-build.log +20 -0
  3. package/.turbo/turbo-format.log +36 -0
  4. package/.turbo/turbo-test$colon$ci.log +77 -0
  5. package/.turbo/turbo-test.log +19 -0
  6. package/CHANGELOG.md +108 -0
  7. package/coverage/base.css +224 -0
  8. package/coverage/block-navigation.js +87 -0
  9. package/coverage/clover.xml +878 -0
  10. package/coverage/coverage-final.json +10 -0
  11. package/coverage/favicon.png +0 -0
  12. package/coverage/index.html +131 -0
  13. package/coverage/prettify.css +1 -0
  14. package/coverage/prettify.js +2 -0
  15. package/coverage/sort-arrow-sprite.png +0 -0
  16. package/coverage/sorter.js +210 -0
  17. package/coverage/src/ast-to-ck.ts.html +1336 -0
  18. package/coverage/src/circular-refs.ts.html +298 -0
  19. package/coverage/src/convert.ts.html +535 -0
  20. package/coverage/src/index.html +221 -0
  21. package/coverage/src/normalize.ts.html +1162 -0
  22. package/coverage/src/paths-to-ast.ts.html +1165 -0
  23. package/coverage/src/schema-to-ast.ts.html +1378 -0
  24. package/coverage/src/tag-splitter.ts.html +685 -0
  25. package/coverage/src/warnings.ts.html +154 -0
  26. package/coverage/tests/helpers.ts.html +559 -0
  27. package/coverage/tests/index.html +116 -0
  28. package/dist/ast-to-ck.d.ts +8 -0
  29. package/dist/ast-to-ck.d.ts.map +1 -0
  30. package/dist/chunk-CQGFQKBJ.js +1681 -0
  31. package/dist/chunk-CQGFQKBJ.js.map +1 -0
  32. package/dist/chunk-FNUU2DWY.js +1779 -0
  33. package/dist/chunk-FNUU2DWY.js.map +1 -0
  34. package/dist/chunk-LD2HAFZG.js +1681 -0
  35. package/dist/chunk-LD2HAFZG.js.map +1 -0
  36. package/dist/chunk-LQ2B3EJG.js +1777 -0
  37. package/dist/chunk-LQ2B3EJG.js.map +1 -0
  38. package/dist/chunk-M6JA6WY2.js +1714 -0
  39. package/dist/chunk-M6JA6WY2.js.map +1 -0
  40. package/dist/chunk-MQTKLXTN.js +1681 -0
  41. package/dist/chunk-MQTKLXTN.js.map +1 -0
  42. package/dist/chunk-NV7RGUUS.js +1699 -0
  43. package/dist/chunk-NV7RGUUS.js.map +1 -0
  44. package/dist/chunk-REM25FDE.js +1779 -0
  45. package/dist/chunk-REM25FDE.js.map +1 -0
  46. package/dist/chunk-SNW7GJOM.js +1681 -0
  47. package/dist/chunk-SNW7GJOM.js.map +1 -0
  48. package/dist/chunk-UWOSCBRG.js +1681 -0
  49. package/dist/chunk-UWOSCBRG.js.map +1 -0
  50. package/dist/chunk-YP7FWA4H.js +1779 -0
  51. package/dist/chunk-YP7FWA4H.js.map +1 -0
  52. package/dist/circular-refs.d.ts +12 -0
  53. package/dist/circular-refs.d.ts.map +1 -0
  54. package/dist/convert.d.ts +6 -0
  55. package/dist/convert.d.ts.map +1 -0
  56. package/dist/index.d.ts +9 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +29 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/normalize.d.ts +12 -0
  61. package/dist/normalize.d.ts.map +1 -0
  62. package/dist/paths-to-ast.d.ts +22 -0
  63. package/dist/paths-to-ast.d.ts.map +1 -0
  64. package/dist/plugin.d.ts +4 -0
  65. package/dist/plugin.d.ts.map +1 -0
  66. package/dist/plugin.js +86 -0
  67. package/dist/plugin.js.map +1 -0
  68. package/dist/schema-to-ast.d.ts +39 -0
  69. package/dist/schema-to-ast.d.ts.map +1 -0
  70. package/dist/tag-splitter.d.ts +16 -0
  71. package/dist/tag-splitter.d.ts.map +1 -0
  72. package/dist/types.d.ts +137 -0
  73. package/dist/types.d.ts.map +1 -0
  74. package/dist/warnings.d.ts +10 -0
  75. package/dist/warnings.d.ts.map +1 -0
  76. package/eslint.config.js +6 -0
  77. package/package.json +46 -0
  78. package/src/ast-to-ck.ts +417 -0
  79. package/src/circular-refs.ts +71 -0
  80. package/src/convert.ts +150 -0
  81. package/src/index.ts +8 -0
  82. package/src/normalize.ts +359 -0
  83. package/src/paths-to-ast.ts +360 -0
  84. package/src/plugin.ts +89 -0
  85. package/src/schema-to-ast.ts +431 -0
  86. package/src/tag-splitter.ts +200 -0
  87. package/src/types.ts +133 -0
  88. package/src/warnings.ts +23 -0
  89. package/tests/ast-to-ck.test.ts +425 -0
  90. package/tests/circular-refs.test.ts +71 -0
  91. package/tests/convert.test.ts +330 -0
  92. package/tests/fixtures/petstore-3.1.json +187 -0
  93. package/tests/helpers.ts +158 -0
  94. package/tests/normalize.test.ts +114 -0
  95. package/tests/schema-to-ast.test.ts +341 -0
  96. package/tests/tag-splitter.test.ts +75 -0
  97. package/tsconfig.json +9 -0
@@ -0,0 +1,330 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseCk, DiagnosticCollector } from '@contractkit/core';
3
+ import { readFileSync } from 'node:fs';
4
+ import { resolve, dirname } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { convertOpenApiToCk } from '../src/convert.js';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const fixturesDir = resolve(__dirname, 'fixtures');
10
+
11
+ describe('convertOpenApiToCk', () => {
12
+ describe('OpenAPI 3.1 Petstore', () => {
13
+ it('converts to split .ck files by tag', async () => {
14
+ const spec = JSON.parse(readFileSync(resolve(fixturesDir, 'petstore-3.1.json'), 'utf-8'));
15
+ const result = await convertOpenApiToCk({ input: spec, split: 'by-tag' });
16
+
17
+ expect(result.files.size).toBeGreaterThan(0);
18
+
19
+ // Should have pets and store files
20
+ const filenames = [...result.files.keys()];
21
+ expect(filenames.some(f => f.includes('pets'))).toBe(true);
22
+ expect(filenames.some(f => f.includes('store'))).toBe(true);
23
+ });
24
+
25
+ it('includes Pet model in pets file', async () => {
26
+ const spec = JSON.parse(readFileSync(resolve(fixturesDir, 'petstore-3.1.json'), 'utf-8'));
27
+ const result = await convertOpenApiToCk({ input: spec, split: 'by-tag' });
28
+
29
+ const petsFile = [...result.files.entries()].find(([k]) => k.includes('pets'));
30
+ expect(petsFile).toBeDefined();
31
+ const [, content] = petsFile!;
32
+
33
+ expect(content).toContain('contract Pet:');
34
+ expect(content).toContain('id: readonly uuid');
35
+ expect(content).toContain('name: string(min=1, max=100)');
36
+ expect(content).toContain('status: enum(available, pending, sold)');
37
+ expect(content).toContain('createdAt: readonly datetime');
38
+ });
39
+
40
+ it('includes operations in pets file', async () => {
41
+ const spec = JSON.parse(readFileSync(resolve(fixturesDir, 'petstore-3.1.json'), 'utf-8'));
42
+ const result = await convertOpenApiToCk({ input: spec, split: 'by-tag' });
43
+
44
+ const petsFile = [...result.files.entries()].find(([k]) => k.includes('pets'));
45
+ const [, content] = petsFile!;
46
+
47
+ expect(content).toContain('operation /pets:');
48
+ expect(content).toContain('get:');
49
+ expect(content).toContain('post:');
50
+ expect(content).toContain('operation /pets/{petId}:');
51
+ expect(content).toContain('sdk: listPets');
52
+ expect(content).toContain('sdk: createPet');
53
+ });
54
+
55
+ it('marks delete with security: none', async () => {
56
+ const spec = JSON.parse(readFileSync(resolve(fixturesDir, 'petstore-3.1.json'), 'utf-8'));
57
+ const result = await convertOpenApiToCk({ input: spec, split: 'by-tag' });
58
+
59
+ const petsFile = [...result.files.entries()].find(([k]) => k.includes('pets'));
60
+ const [, content] = petsFile!;
61
+
62
+ expect(content).toContain('security: none');
63
+ });
64
+
65
+ it('converts to single file', async () => {
66
+ const spec = JSON.parse(readFileSync(resolve(fixturesDir, 'petstore-3.1.json'), 'utf-8'));
67
+ const result = await convertOpenApiToCk({ input: spec, split: 'single' });
68
+
69
+ expect(result.files.size).toBe(1);
70
+ expect(result.files.has('api.ck')).toBe(true);
71
+
72
+ const content = result.files.get('api.ck')!;
73
+ expect(content).toContain('contract Pet:');
74
+ expect(content).toContain('operation /pets:');
75
+ expect(content).toContain('operation /store/inventory:');
76
+ });
77
+
78
+ it('includes descriptions as comments', async () => {
79
+ const spec = JSON.parse(readFileSync(resolve(fixturesDir, 'petstore-3.1.json'), 'utf-8'));
80
+ const result = await convertOpenApiToCk({ input: spec, split: 'single', includeComments: true });
81
+
82
+ const content = result.files.get('api.ck')!;
83
+ expect(content).toContain('# A pet in the store');
84
+ expect(content).toContain('# The pet identifier');
85
+ });
86
+
87
+ it('omits comments when includeComments is false', async () => {
88
+ const spec = JSON.parse(readFileSync(resolve(fixturesDir, 'petstore-3.1.json'), 'utf-8'));
89
+ const result = await convertOpenApiToCk({ input: spec, split: 'single', includeComments: false });
90
+
91
+ const content = result.files.get('api.ck')!;
92
+ expect(content).not.toContain('#');
93
+ });
94
+
95
+ it('handles path params', async () => {
96
+ const spec = JSON.parse(readFileSync(resolve(fixturesDir, 'petstore-3.1.json'), 'utf-8'));
97
+ const result = await convertOpenApiToCk({ input: spec, split: 'single' });
98
+
99
+ const content = result.files.get('api.ck')!;
100
+ expect(content).toContain('params: {');
101
+ expect(content).toContain('petId: uuid');
102
+ });
103
+
104
+ it('handles record types (additionalProperties)', async () => {
105
+ const spec = JSON.parse(readFileSync(resolve(fixturesDir, 'petstore-3.1.json'), 'utf-8'));
106
+ const result = await convertOpenApiToCk({ input: spec, split: 'single' });
107
+
108
+ const content = result.files.get('api.ck')!;
109
+ // Store inventory returns a record
110
+ expect(content).toContain('record(string, int)');
111
+ });
112
+
113
+ it('handles empty responses (204)', async () => {
114
+ const spec = JSON.parse(readFileSync(resolve(fixturesDir, 'petstore-3.1.json'), 'utf-8'));
115
+ const result = await convertOpenApiToCk({ input: spec, split: 'single' });
116
+
117
+ const content = result.files.get('api.ck')!;
118
+ expect(content).toContain('204:');
119
+ });
120
+ });
121
+
122
+ describe('inline spec object', () => {
123
+ it('converts a minimal spec', async () => {
124
+ const result = await convertOpenApiToCk({
125
+ input: {
126
+ openapi: '3.1.0',
127
+ info: { title: 'Minimal', version: '1.0' },
128
+ components: {
129
+ schemas: {
130
+ Ping: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] },
131
+ },
132
+ },
133
+ paths: {
134
+ '/ping': {
135
+ get: {
136
+ operationId: 'ping',
137
+ responses: {
138
+ '200': {
139
+ description: 'OK',
140
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/Ping' } } },
141
+ },
142
+ },
143
+ },
144
+ },
145
+ },
146
+ },
147
+ split: 'single',
148
+ });
149
+
150
+ expect(result.files.size).toBe(1);
151
+ const content = result.files.get('api.ck')!;
152
+ expect(content).toContain('contract Ping:');
153
+ expect(content).toContain('ok: boolean');
154
+ expect(content).toContain('operation /ping:');
155
+ });
156
+ });
157
+
158
+ describe('response headers', () => {
159
+ it('lifts OpenAPI 3.x response headers into a typed headers block', async () => {
160
+ const result = await convertOpenApiToCk({
161
+ input: {
162
+ openapi: '3.1.0',
163
+ info: { title: 'T', version: '1.0' },
164
+ components: {
165
+ schemas: {
166
+ Transfer: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } }, required: ['id'] },
167
+ },
168
+ },
169
+ paths: {
170
+ '/transfers/{id}': {
171
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
172
+ get: {
173
+ operationId: 'getTransfer',
174
+ responses: {
175
+ '200': {
176
+ description: 'OK',
177
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/Transfer' } } },
178
+ headers: {
179
+ 'preference-applied': { schema: { type: 'string' } },
180
+ ETag: { description: 'cache validator', required: true, schema: { type: 'string' } },
181
+ },
182
+ },
183
+ },
184
+ },
185
+ },
186
+ },
187
+ },
188
+ split: 'single',
189
+ });
190
+ const ck = result.files.get('api.ck')!;
191
+ expect(ck).toContain('200: {');
192
+ expect(ck).toContain('application/json: Transfer');
193
+ expect(ck).toContain('headers: {');
194
+ expect(ck).toContain('preference-applied?: string');
195
+ expect(ck).toContain('ETag: string # cache validator');
196
+
197
+ // Round-trip: the generated .ck must parse cleanly back into an AST with the same headers.
198
+ const diag = new DiagnosticCollector();
199
+ const root = parseCk(ck, 'api.ck', diag);
200
+ expect(diag.hasErrors()).toBe(false);
201
+ const op = root.routes[0]!.operations[0]!;
202
+ const respHeaders = op.responses[0]!.headers!;
203
+ expect(respHeaders.map(h => h.name)).toEqual(['preference-applied', 'ETag']);
204
+ expect(respHeaders[0]!.optional).toBe(true);
205
+ expect(respHeaders[1]!.optional).toBe(false);
206
+ });
207
+
208
+ it('lifts response headers when the status has no body', async () => {
209
+ const result = await convertOpenApiToCk({
210
+ input: {
211
+ openapi: '3.1.0',
212
+ info: { title: 'T', version: '1.0' },
213
+ paths: {
214
+ '/resources/{id}': {
215
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
216
+ delete: {
217
+ operationId: 'deleteResource',
218
+ responses: {
219
+ '204': {
220
+ description: 'No Content',
221
+ headers: {
222
+ 'x-deleted-at': { required: true, schema: { type: 'string' } },
223
+ },
224
+ },
225
+ },
226
+ },
227
+ },
228
+ },
229
+ },
230
+ split: 'single',
231
+ });
232
+ const ck = result.files.get('api.ck')!;
233
+ expect(ck).toContain('204: {');
234
+ expect(ck).toContain('headers: {');
235
+ expect(ck).toContain('x-deleted-at: string');
236
+ });
237
+
238
+ it('lifts Swagger 2.0 response headers via normalize', async () => {
239
+ const result = await convertOpenApiToCk({
240
+ input: {
241
+ swagger: '2.0',
242
+ info: { title: 'T', version: '1.0' },
243
+ paths: {
244
+ '/things': {
245
+ get: {
246
+ operationId: 'listThings',
247
+ produces: ['application/json'],
248
+ responses: {
249
+ '200': {
250
+ description: 'OK',
251
+ schema: { type: 'array', items: { type: 'string' } },
252
+ headers: {
253
+ 'X-Rate-Limit': { type: 'integer', description: 'requests left' },
254
+ },
255
+ },
256
+ },
257
+ },
258
+ },
259
+ },
260
+ },
261
+ split: 'single',
262
+ });
263
+ const ck = result.files.get('api.ck')!;
264
+ expect(ck).toContain('headers: {');
265
+ expect(ck).toContain('X-Rate-Limit?: int');
266
+ expect(ck).toContain('requests left');
267
+ });
268
+ });
269
+
270
+ describe('warnings', () => {
271
+ it('collects warnings for unsupported features', async () => {
272
+ const warnings: string[] = [];
273
+ await convertOpenApiToCk({
274
+ input: {
275
+ openapi: '3.1.0',
276
+ info: { title: 'Test', version: '1.0' },
277
+ components: {
278
+ schemas: {
279
+ WithXml: {
280
+ type: 'object',
281
+ xml: { name: 'thing' },
282
+ properties: { id: { type: 'string' } },
283
+ },
284
+ },
285
+ },
286
+ paths: {},
287
+ },
288
+ split: 'single',
289
+ onWarning: w => warnings.push(w.message),
290
+ });
291
+
292
+ expect(warnings.some(w => w.includes('xml'))).toBe(true);
293
+ });
294
+
295
+ it('lowers oneOf with discriminator to a discriminatedUnion', async () => {
296
+ const warnings: string[] = [];
297
+ const result = await convertOpenApiToCk({
298
+ input: {
299
+ openapi: '3.1.0',
300
+ info: { title: 'Test', version: '1.0' },
301
+ components: {
302
+ schemas: {
303
+ Card: {
304
+ type: 'object',
305
+ properties: { kind: { type: 'string', enum: ['card'] }, last4: { type: 'string' } },
306
+ required: ['kind', 'last4'],
307
+ },
308
+ Bank: {
309
+ type: 'object',
310
+ properties: { kind: { type: 'string', enum: ['bank'] }, accountId: { type: 'string' } },
311
+ required: ['kind', 'accountId'],
312
+ },
313
+ PaymentMethod: {
314
+ oneOf: [{ $ref: '#/components/schemas/Card' }, { $ref: '#/components/schemas/Bank' }],
315
+ discriminator: { propertyName: 'kind' },
316
+ },
317
+ },
318
+ },
319
+ paths: {},
320
+ },
321
+ split: 'single',
322
+ onWarning: w => warnings.push(w.message),
323
+ });
324
+
325
+ const content = result.files.get('api.ck')!;
326
+ expect(content).toContain('discriminated(by=kind');
327
+ expect(warnings.some(w => w.includes('discriminator'))).toBe(false);
328
+ });
329
+ });
330
+ });
@@ -0,0 +1,187 @@
1
+ {
2
+ "openapi": "3.1.0",
3
+ "info": {
4
+ "title": "Petstore",
5
+ "version": "1.0.0",
6
+ "description": "A sample pet store API"
7
+ },
8
+ "servers": [{ "url": "https://petstore.example.com/v1" }],
9
+ "paths": {
10
+ "/pets": {
11
+ "get": {
12
+ "operationId": "listPets",
13
+ "tags": ["pets"],
14
+ "summary": "List all pets",
15
+ "parameters": [
16
+ {
17
+ "name": "limit",
18
+ "in": "query",
19
+ "required": false,
20
+ "schema": { "type": "integer", "minimum": 1, "maximum": 100 }
21
+ }
22
+ ],
23
+ "responses": {
24
+ "200": {
25
+ "description": "A list of pets",
26
+ "content": {
27
+ "application/json": {
28
+ "schema": {
29
+ "type": "array",
30
+ "items": { "$ref": "#/components/schemas/Pet" }
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
36
+ },
37
+ "post": {
38
+ "operationId": "createPet",
39
+ "tags": ["pets"],
40
+ "summary": "Create a pet",
41
+ "requestBody": {
42
+ "required": true,
43
+ "content": {
44
+ "application/json": {
45
+ "schema": { "$ref": "#/components/schemas/CreatePetInput" }
46
+ }
47
+ }
48
+ },
49
+ "responses": {
50
+ "201": {
51
+ "description": "Created",
52
+ "content": {
53
+ "application/json": {
54
+ "schema": { "$ref": "#/components/schemas/Pet" }
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
60
+ },
61
+ "/pets/{petId}": {
62
+ "parameters": [
63
+ {
64
+ "name": "petId",
65
+ "in": "path",
66
+ "required": true,
67
+ "schema": { "type": "string", "format": "uuid" }
68
+ }
69
+ ],
70
+ "get": {
71
+ "operationId": "getPet",
72
+ "tags": ["pets"],
73
+ "summary": "Get a pet by ID",
74
+ "responses": {
75
+ "200": {
76
+ "description": "A pet",
77
+ "content": {
78
+ "application/json": {
79
+ "schema": { "$ref": "#/components/schemas/Pet" }
80
+ }
81
+ }
82
+ },
83
+ "404": {
84
+ "description": "Pet not found"
85
+ }
86
+ }
87
+ },
88
+ "delete": {
89
+ "operationId": "deletePet",
90
+ "tags": ["pets"],
91
+ "summary": "Delete a pet",
92
+ "security": [],
93
+ "responses": {
94
+ "204": {
95
+ "description": "Deleted"
96
+ }
97
+ }
98
+ }
99
+ },
100
+ "/store/inventory": {
101
+ "get": {
102
+ "operationId": "getInventory",
103
+ "tags": ["store"],
104
+ "summary": "Returns pet inventories",
105
+ "responses": {
106
+ "200": {
107
+ "description": "Inventory map",
108
+ "content": {
109
+ "application/json": {
110
+ "schema": {
111
+ "type": "object",
112
+ "additionalProperties": { "type": "integer" }
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }
120
+ },
121
+ "components": {
122
+ "schemas": {
123
+ "Pet": {
124
+ "type": "object",
125
+ "description": "A pet in the store",
126
+ "properties": {
127
+ "id": {
128
+ "type": "string",
129
+ "format": "uuid",
130
+ "readOnly": true,
131
+ "description": "The pet identifier"
132
+ },
133
+ "name": {
134
+ "type": "string",
135
+ "minLength": 1,
136
+ "maxLength": 100,
137
+ "description": "The pet name"
138
+ },
139
+ "tag": {
140
+ "type": "string",
141
+ "description": "Optional tag"
142
+ },
143
+ "status": {
144
+ "type": "string",
145
+ "enum": ["available", "pending", "sold"],
146
+ "description": "Pet status"
147
+ },
148
+ "createdAt": {
149
+ "type": "string",
150
+ "format": "date-time",
151
+ "readOnly": true,
152
+ "description": "Creation timestamp"
153
+ }
154
+ },
155
+ "required": ["id", "name", "status", "createdAt"]
156
+ },
157
+ "CreatePetInput": {
158
+ "type": "object",
159
+ "description": "Input for creating a pet",
160
+ "properties": {
161
+ "name": {
162
+ "type": "string",
163
+ "minLength": 1,
164
+ "maxLength": 100
165
+ },
166
+ "tag": {
167
+ "type": "string"
168
+ },
169
+ "status": {
170
+ "type": "string",
171
+ "enum": ["available", "pending", "sold"],
172
+ "default": "available"
173
+ }
174
+ },
175
+ "required": ["name", "status"]
176
+ },
177
+ "Error": {
178
+ "type": "object",
179
+ "properties": {
180
+ "code": { "type": "integer" },
181
+ "message": { "type": "string" }
182
+ },
183
+ "required": ["code", "message"]
184
+ }
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,158 @@
1
+ import type {
2
+ CkRootNode,
3
+ ModelNode,
4
+ FieldNode,
5
+ ContractTypeNode,
6
+ ScalarTypeNode,
7
+ ArrayTypeNode,
8
+ TupleTypeNode,
9
+ RecordTypeNode,
10
+ EnumTypeNode,
11
+ LiteralTypeNode,
12
+ UnionTypeNode,
13
+ IntersectionTypeNode,
14
+ ModelRefTypeNode,
15
+ InlineObjectTypeNode,
16
+ LazyTypeNode,
17
+ SourceLocation,
18
+ OpRouteNode,
19
+ OpOperationNode,
20
+ OpParamNode,
21
+ OpRequestNode,
22
+ OpResponseNode,
23
+ HttpMethod,
24
+ ParamSource,
25
+ RouteModifier,
26
+ SecurityNode,
27
+ } from '@contractkit/core';
28
+
29
+ // ─── AST Builder Helpers ────────────────────────────────────────────────────
30
+
31
+ export function loc(line = 1, file = 'test.ck'): SourceLocation {
32
+ return { file, line };
33
+ }
34
+
35
+ export function scalarType(name: ScalarTypeNode['name'], mods?: Partial<ScalarTypeNode>): ScalarTypeNode {
36
+ return { kind: 'scalar', name, ...mods };
37
+ }
38
+
39
+ export function arrayType(item: ContractTypeNode, mods?: { min?: number; max?: number }): ArrayTypeNode {
40
+ return { kind: 'array', item, ...mods };
41
+ }
42
+
43
+ export function tupleType(...items: ContractTypeNode[]): TupleTypeNode {
44
+ return { kind: 'tuple', items };
45
+ }
46
+
47
+ export function recordType(key: ContractTypeNode, value: ContractTypeNode): RecordTypeNode {
48
+ return { kind: 'record', key, value };
49
+ }
50
+
51
+ export function enumType(...values: string[]): EnumTypeNode {
52
+ return { kind: 'enum', values };
53
+ }
54
+
55
+ export function literalType(value: string | number | boolean): LiteralTypeNode {
56
+ return { kind: 'literal', value };
57
+ }
58
+
59
+ export function unionType(...members: ContractTypeNode[]): UnionTypeNode {
60
+ return { kind: 'union', members };
61
+ }
62
+
63
+ export function intersectionType(...members: ContractTypeNode[]): IntersectionTypeNode {
64
+ return { kind: 'intersection', members };
65
+ }
66
+
67
+ export function refType(name: string): ModelRefTypeNode {
68
+ return { kind: 'ref', name };
69
+ }
70
+
71
+ export function inlineObjectType(fields: FieldNode[]): InlineObjectTypeNode {
72
+ return { kind: 'inlineObject', fields };
73
+ }
74
+
75
+ export function lazyType(inner: ContractTypeNode): LazyTypeNode {
76
+ return { kind: 'lazy', inner };
77
+ }
78
+
79
+ export function field(name: string, type: ContractTypeNode, overrides?: Partial<FieldNode>): FieldNode {
80
+ return {
81
+ name,
82
+ optional: false,
83
+ nullable: false,
84
+ visibility: 'normal',
85
+ type,
86
+ loc: loc(),
87
+ ...overrides,
88
+ };
89
+ }
90
+
91
+ export function model(name: string, fields: FieldNode[], overrides?: Partial<ModelNode>): ModelNode {
92
+ return {
93
+ kind: 'model',
94
+ name,
95
+ fields,
96
+ loc: loc(),
97
+ ...overrides,
98
+ };
99
+ }
100
+
101
+ export function ckRoot(overrides?: Partial<CkRootNode>): CkRootNode {
102
+ return {
103
+ kind: 'ckRoot',
104
+ meta: {},
105
+ services: {},
106
+ models: [],
107
+ routes: [],
108
+ file: 'test.ck',
109
+ ...overrides,
110
+ };
111
+ }
112
+
113
+ export function opParam(name: string, type: ContractTypeNode, overrides?: Partial<OpParamNode>): OpParamNode {
114
+ return { name, optional: false, nullable: false, type, loc: loc(), ...overrides };
115
+ }
116
+
117
+ export function opRequest(bodyType: ContractTypeNode, contentType: string = 'application/json'): OpRequestNode {
118
+ return {
119
+ bodies: [
120
+ {
121
+ contentType: contentType as OpRequestNode['bodies'][number]['contentType'],
122
+ bodyType,
123
+ },
124
+ ],
125
+ };
126
+ }
127
+
128
+ export function opResponse(statusCode: number, bodyType?: ContractTypeNode, contentType?: 'application/json'): OpResponseNode {
129
+ return { statusCode, contentType, bodyType };
130
+ }
131
+
132
+ /** Normalize a raw param value (old bare format or new discriminated union) to ParamSource. */
133
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
134
+ function normalizeParamSource(value: any): ParamSource {
135
+ if (!value) return value;
136
+ if (typeof value === 'string') return { kind: 'ref', name: value };
137
+ if (Array.isArray(value)) return { kind: 'params', nodes: value };
138
+ if (value.kind === 'params' || value.kind === 'ref' || value.kind === 'type') return value as ParamSource;
139
+ return { kind: 'type', node: value as ContractTypeNode };
140
+ }
141
+
142
+ export function opOperation(method: HttpMethod, overrides?: Partial<OpOperationNode> & { query?: unknown; headers?: unknown }): OpOperationNode {
143
+ const normalized = { ...overrides } as Partial<OpOperationNode>;
144
+ if (overrides?.query !== undefined) normalized.query = normalizeParamSource(overrides.query);
145
+ if (overrides?.headers !== undefined) normalized.headers = normalizeParamSource(overrides.headers);
146
+ return {
147
+ method,
148
+ responses: [],
149
+ loc: loc(),
150
+ ...normalized,
151
+ };
152
+ }
153
+
154
+ export function opRoute(path: string, operations: OpOperationNode[], overrides?: Partial<OpRouteNode> & { params?: unknown }): OpRouteNode {
155
+ const normalized = { ...overrides } as Partial<OpRouteNode>;
156
+ if (overrides?.params !== undefined) normalized.params = normalizeParamSource(overrides.params);
157
+ return { path, operations, loc: loc(), ...normalized };
158
+ }