@contractkit/plugin-typescript 0.16.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 (60) hide show
  1. package/.turbo/turbo-build$colon$ci.log +35 -0
  2. package/.turbo/turbo-build.log +15 -0
  3. package/.turbo/turbo-test$colon$ci.log +81 -0
  4. package/.turbo/turbo-test.log +19 -0
  5. package/CHANGELOG.md +151 -0
  6. package/README.md +153 -0
  7. package/coverage/base.css +224 -0
  8. package/coverage/block-navigation.js +87 -0
  9. package/coverage/clover.xml +1882 -0
  10. package/coverage/coverage-final.json +9 -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/codegen-contract.ts.html +3331 -0
  18. package/coverage/src/codegen-operation.ts.html +2530 -0
  19. package/coverage/src/codegen-plain-types.ts.html +901 -0
  20. package/coverage/src/codegen-sdk.ts.html +2797 -0
  21. package/coverage/src/index.html +206 -0
  22. package/coverage/src/index.ts.html +1360 -0
  23. package/coverage/src/path-utils.ts.html +649 -0
  24. package/coverage/src/ts-render.ts.html +592 -0
  25. package/coverage/tests/helpers.ts.html +826 -0
  26. package/coverage/tests/index.html +116 -0
  27. package/dist/codegen-contract.d.ts +56 -0
  28. package/dist/codegen-contract.d.ts.map +1 -0
  29. package/dist/codegen-operation.d.ts +25 -0
  30. package/dist/codegen-operation.d.ts.map +1 -0
  31. package/dist/codegen-plain-types.d.ts +10 -0
  32. package/dist/codegen-plain-types.d.ts.map +1 -0
  33. package/dist/codegen-sdk.d.ts +38 -0
  34. package/dist/codegen-sdk.d.ts.map +1 -0
  35. package/dist/index.d.ts +77 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +3162 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/path-utils.d.ts +15 -0
  40. package/dist/path-utils.d.ts.map +1 -0
  41. package/dist/ts-render.d.ts +20 -0
  42. package/dist/ts-render.d.ts.map +1 -0
  43. package/eslint.config.js +6 -0
  44. package/package.json +43 -0
  45. package/src/codegen-contract.ts +1082 -0
  46. package/src/codegen-operation.ts +815 -0
  47. package/src/codegen-plain-types.ts +272 -0
  48. package/src/codegen-sdk.ts +904 -0
  49. package/src/index.ts +425 -0
  50. package/src/path-utils.ts +188 -0
  51. package/src/ts-render.ts +169 -0
  52. package/tests/codegen-contract.test.ts +1004 -0
  53. package/tests/codegen-operation.test.ts +939 -0
  54. package/tests/codegen-plain-types.test.ts +636 -0
  55. package/tests/codegen-sdk.test.ts +1500 -0
  56. package/tests/codegen-server.test.ts +192 -0
  57. package/tests/helpers.ts +247 -0
  58. package/tests/pipeline.test.ts +372 -0
  59. package/tsconfig.json +9 -0
  60. package/vitest.config.ts +14 -0
@@ -0,0 +1,372 @@
1
+ import { parseCk, decomposeCk, validateOp, validateRefs, applyOptionsDefaults, DiagnosticCollector } from '@contractkit/core';
2
+ import { generateContract } from '../src/codegen-contract.js';
3
+ import { generateOp } from '../src/codegen-operation.js';
4
+ import { generateSdk } from '../src/codegen-sdk.js';
5
+ import { SIMPLE_USER_CONTRACT, VISIBILITY_CONTRACT, INHERITANCE_CONTRACT, SIMPLE_USERS_OP, PARAMETERIZED_OP } from './helpers.js';
6
+
7
+ function compileContractSource(source: string) {
8
+ const diag = new DiagnosticCollector();
9
+ const ck = parseCk(source, 'test.ck', diag);
10
+ const { contract } = decomposeCk(ck);
11
+ const output = generateContract(contract);
12
+ return { root: contract, output, diag };
13
+ }
14
+
15
+ function compileOpSource(source: string, file = 'users.ck') {
16
+ const diag = new DiagnosticCollector();
17
+ const ck = parseCk(source, file, diag);
18
+ const { op } = decomposeCk(ck);
19
+ const output = generateOp(op);
20
+ return { root: op, output, diag };
21
+ }
22
+
23
+ describe('Contract pipeline (source -> parse -> codegen)', () => {
24
+ it('compiles a simple contract to valid Zod schema code', () => {
25
+ const { output, diag } = compileContractSource(SIMPLE_USER_CONTRACT);
26
+ expect(diag.hasErrors()).toBe(false);
27
+ expect(output).toContain("import { z } from 'zod';");
28
+ expect(output).toContain('id: z.uuid()');
29
+ expect(output).toContain('name: z.string()');
30
+ expect(output).toContain('email: z.email()');
31
+ expect(output).toContain('age: z.coerce.number().optional()');
32
+ expect(output).toContain(`active: z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean()).default(true)`);
33
+ });
34
+
35
+ it('compiles a contract with visibility to three-schema pattern', () => {
36
+ const { output, diag } = compileContractSource(VISIBILITY_CONTRACT);
37
+ expect(diag.hasErrors()).toBe(false);
38
+ expect(output).toContain('const UserBase = z.strictObject({');
39
+ expect(output).toContain('export const User = z.strictObject({');
40
+ expect(output).toContain('export const UserInput = z.strictObject({');
41
+
42
+ // Read schema (User) should not contain writeonly 'password'
43
+ const readSection = output.split('export const User =')[1]!.split('});')[0]!;
44
+ expect(readSection).not.toContain('password');
45
+
46
+ // Write schema (UserInput) should not contain readonly 'id'
47
+ const writeSection = output.split('export const UserInput =')[1]!.split('});')[0]!;
48
+ expect(writeSection).not.toContain('id:');
49
+ });
50
+
51
+ it('compiles a contract with inheritance', () => {
52
+ const { output, diag } = compileContractSource(INHERITANCE_CONTRACT);
53
+ expect(diag.hasErrors()).toBe(false);
54
+ expect(output).toContain('User.extend({');
55
+ expect(output).toContain('z.enum(["admin", "superadmin"])');
56
+ });
57
+
58
+ it('compiles a contract with all type kinds', () => {
59
+ const source = `\
60
+ contract Kitchen: {
61
+ tags: array(string)
62
+ coords: tuple(number, number)
63
+ meta: record(string, unknown)
64
+ status: enum(open, closed)
65
+ kind: literal("kitchen")
66
+ value: string | number
67
+ ref: Address
68
+ children: lazy(Kitchen)
69
+ }`;
70
+ const { output, diag } = compileContractSource(source);
71
+ expect(diag.hasErrors()).toBe(false);
72
+ expect(output).toContain('z.array(z.string())');
73
+ expect(output).toContain('z.tuple([z.coerce.number(), z.coerce.number()])');
74
+ expect(output).toContain('z.record(z.string(), z.unknown())');
75
+ expect(output).toContain('z.enum(["open", "closed"])');
76
+ expect(output).toContain('z.literal("kitchen")');
77
+ expect(output).toContain('z.union([z.string(), z.coerce.number()])');
78
+ expect(output).toContain('Address');
79
+ expect(output).toContain('z.lazy(() => Kitchen)');
80
+ });
81
+
82
+ it('includes DateTime import when date fields are used', () => {
83
+ const source = `\
84
+ contract Event: {
85
+ startDate: date
86
+ createdAt: datetime
87
+ }`;
88
+ const { output } = compileContractSource(source);
89
+ expect(output).toContain("import { DateTime } from 'luxon';");
90
+ });
91
+ });
92
+
93
+ describe('OP pipeline (source -> parse -> codegen)', () => {
94
+ it('compiles a simple operation to Koa router code', () => {
95
+ const { output, diag } = compileOpSource(SIMPLE_USERS_OP);
96
+ expect(diag.hasErrors()).toBe(false);
97
+ expect(output).not.toContain("import { z } from 'zod';");
98
+ expect(output).toContain('ServerKitRouter');
99
+ expect(output).toContain("UsersRouter.get('/users'");
100
+ expect(output).toContain("UsersRouter.post('/users'");
101
+ expect(output).toContain("bodyParserMiddleware(['json'])");
102
+ expect(output).toContain('ctx.status = 201');
103
+ });
104
+
105
+ it('compiles an operation with params, request, and response', () => {
106
+ const { output, diag } = compileOpSource(PARAMETERIZED_OP);
107
+ expect(diag.hasErrors()).toBe(false);
108
+ expect(output).toContain("UsersRouter.get('/users/:id'");
109
+ expect(output).toContain("UsersRouter.delete('/users/:id'");
110
+ expect(output).toContain('parseAndValidate(');
111
+ expect(output).toContain('id: z.uuid()');
112
+ });
113
+
114
+ it('uses correct router name for dotted file names', () => {
115
+ const source = `operation /items: { get: {} }`;
116
+ const { output } = compileOpSource(source, 'ledger.items.ck');
117
+ expect(output).toContain('LedgerItemsRouter');
118
+ });
119
+ });
120
+
121
+ describe('undeclared path param warnings', () => {
122
+ it('warns when a route has path params but no params block', () => {
123
+ const source = `operation /users/{id}: { get: {} }`;
124
+ const diag = new DiagnosticCollector();
125
+ const ck = parseCk(source, 'test.ck', diag);
126
+ const { op } = decomposeCk(ck);
127
+ validateOp(op, diag);
128
+ const warnings = diag.getAll().filter(d => d.severity === 'warning');
129
+ expect(warnings).toHaveLength(1);
130
+ expect(warnings[0]!.message).toContain('{id}');
131
+ });
132
+
133
+ it('warns for each undeclared param', () => {
134
+ const source = `operation /users/{userId}/posts/{postId}: { get: {} }`;
135
+ const diag = new DiagnosticCollector();
136
+ const ck = parseCk(source, 'test.ck', diag);
137
+ const { op } = decomposeCk(ck);
138
+ validateOp(op, diag);
139
+ const warnings = diag.getAll().filter(d => d.severity === 'warning');
140
+ expect(warnings).toHaveLength(2);
141
+ expect(warnings[0]!.message).toContain('{userId}');
142
+ expect(warnings[1]!.message).toContain('{postId}');
143
+ });
144
+
145
+ it('does not warn when all path params are declared', () => {
146
+ const source = `operation /users/{id}: {\n params: {\n id: uuid\n }\n get: {}\n}`;
147
+ const diag = new DiagnosticCollector();
148
+ const ck = parseCk(source, 'test.ck', diag);
149
+ const { op } = decomposeCk(ck);
150
+ validateOp(op, diag);
151
+ const warnings = diag.getAll().filter(d => d.severity === 'warning');
152
+ expect(warnings).toHaveLength(0);
153
+ });
154
+
155
+ it('warns only for the subset of undeclared params', () => {
156
+ const source = `operation /accounts/{accountId}/entries/{entryId}: {\n params: {\n accountId: uuid\n }\n get: {}\n}`;
157
+ const diag = new DiagnosticCollector();
158
+ const ck = parseCk(source, 'test.ck', diag);
159
+ const { op } = decomposeCk(ck);
160
+ validateOp(op, diag);
161
+ const warnings = diag.getAll().filter(d => d.severity === 'warning');
162
+ expect(warnings).toHaveLength(1);
163
+ expect(warnings[0]!.message).toContain('{entryId}');
164
+ });
165
+
166
+ it('does not warn when params uses a type reference', () => {
167
+ const source = `operation /users/{id}: {\n params: UserParams\n get: {}\n}`;
168
+ const diag = new DiagnosticCollector();
169
+ const ck = parseCk(source, 'test.ck', diag);
170
+ const { op } = decomposeCk(ck);
171
+ validateOp(op, diag);
172
+ const warnings = diag.getAll().filter(d => d.severity === 'warning');
173
+ expect(warnings).toHaveLength(0);
174
+ });
175
+
176
+ it('does not warn for routes without path params', () => {
177
+ const source = `operation /users: { get: {} }`;
178
+ const diag = new DiagnosticCollector();
179
+ const ck = parseCk(source, 'test.ck', diag);
180
+ const { op } = decomposeCk(ck);
181
+ validateOp(op, diag);
182
+ const warnings = diag.getAll().filter(d => d.severity === 'warning');
183
+ expect(warnings).toHaveLength(0);
184
+ });
185
+ });
186
+
187
+ describe('param type warnings', () => {
188
+ it('does not warn when param types are specified', () => {
189
+ const diag = new DiagnosticCollector();
190
+ const ck = parseCk(PARAMETERIZED_OP, 'test.ck', diag);
191
+ const { op } = decomposeCk(ck);
192
+ validateOp(op, diag);
193
+ const warnings = diag.getAll().filter(d => d.message.includes('no explicit type'));
194
+ expect(warnings).toHaveLength(0);
195
+ });
196
+ });
197
+
198
+ describe('error handling pipeline', () => {
199
+ it('reports diagnostics for invalid contract source', () => {
200
+ const { diag } = compileContractSource('Bad name: string');
201
+ expect(diag.hasErrors()).toBe(true);
202
+ });
203
+
204
+ it('reports diagnostics for invalid OP source', () => {
205
+ const { diag } = compileOpSource('no-slash { get: {} }');
206
+ expect(diag.hasErrors()).toBe(true);
207
+ });
208
+ });
209
+
210
+ describe('options-level header globals parity', () => {
211
+ function compileOp(source: string) {
212
+ const diag = new DiagnosticCollector();
213
+ const ck = parseCk(source, 'widgets.ck', diag);
214
+ applyOptionsDefaults(ck, diag);
215
+ const { op } = decomposeCk(ck);
216
+ return { server: generateOp(op), sdk: generateSdk(op), diag };
217
+ }
218
+
219
+ // Strip source-line refs (e.g. `widgets.ck#L7`) so we can compare two equivalent
220
+ // shapes whose operation sits on different lines in the source.
221
+ const stripLineRefs = (s: string) => s.replace(/widgets\.ck#L\d+/g, 'widgets.ck#L?');
222
+
223
+ it('options-level request headers produce the same server and SDK output as inlined headers', () => {
224
+ const globalsForm = `
225
+ options { request: { headers: {
226
+ x-request-id: uuid
227
+ authorization: string
228
+ } } }
229
+
230
+ operation /widgets: {
231
+ get: {
232
+ response: { 200: { application/json: Widget } }
233
+ }
234
+ }`;
235
+ const inlinedForm = `
236
+ operation /widgets: {
237
+ get: {
238
+ headers: {
239
+ x-request-id: uuid
240
+ authorization: string
241
+ }
242
+ response: { 200: { application/json: Widget } }
243
+ }
244
+ }`;
245
+ const a = compileOp(globalsForm);
246
+ const b = compileOp(inlinedForm);
247
+ expect(a.diag.hasErrors()).toBe(false);
248
+ expect(b.diag.hasErrors()).toBe(false);
249
+ expect(stripLineRefs(a.server)).toBe(stripLineRefs(b.server));
250
+ expect(stripLineRefs(a.sdk)).toBe(stripLineRefs(b.sdk));
251
+ });
252
+
253
+ it('options-level response headers on primary status produce the same server and SDK output as inlined headers', () => {
254
+ const globalsForm = `
255
+ options { response: { headers: {
256
+ x-request-id: uuid
257
+ } } }
258
+
259
+ operation /widgets: {
260
+ get: {
261
+ response: { 200: { application/json: Widget } }
262
+ }
263
+ }`;
264
+ const inlinedForm = `
265
+ operation /widgets: {
266
+ get: {
267
+ response: {
268
+ 200: {
269
+ application/json: Widget
270
+ headers: { x-request-id: uuid }
271
+ }
272
+ }
273
+ }
274
+ }`;
275
+ const a = compileOp(globalsForm);
276
+ const b = compileOp(inlinedForm);
277
+ expect(a.diag.hasErrors()).toBe(false);
278
+ expect(b.diag.hasErrors()).toBe(false);
279
+ expect(stripLineRefs(a.server)).toBe(stripLineRefs(b.server));
280
+ expect(stripLineRefs(a.sdk)).toBe(stripLineRefs(b.sdk));
281
+ });
282
+
283
+ it('headers: none on an operation suppresses the global request header merge', () => {
284
+ const source = `
285
+ options { request: { headers: { x-request-id: uuid } } }
286
+ operation /widgets: {
287
+ get: {
288
+ headers: none
289
+ response: { 200: { application/json: Widget } }
290
+ }
291
+ }`;
292
+ const { server, sdk } = compileOp(source);
293
+ // the request header should not appear in either output
294
+ expect(server).not.toContain("'x-request-id'");
295
+ expect(sdk).not.toContain("'x-request-id'");
296
+ });
297
+ });
298
+
299
+ describe('cross-file type reference validation', () => {
300
+ it('warns when a contract references an undefined model', () => {
301
+ const diag = new DiagnosticCollector();
302
+ const ck = parseCk('contract Order: { customer: NonExistentModel }', 'order.ck', diag);
303
+ const { contract } = decomposeCk(ck);
304
+ validateRefs([contract], [], diag);
305
+ const warnings = diag.getAll().filter(d => d.severity === 'warning');
306
+ expect(warnings.some(w => w.message.includes('NonExistentModel'))).toBe(true);
307
+ });
308
+
309
+ it('does not warn when referenced model exists in another file', () => {
310
+ const diag = new DiagnosticCollector();
311
+ const ck1 = parseCk('contract User: { name: string }', 'user.ck', diag);
312
+ const ck2 = parseCk('contract Order: { customer: User }', 'order.ck', diag);
313
+ const { contract: contract1 } = decomposeCk(ck1);
314
+ const { contract: contract2 } = decomposeCk(ck2);
315
+ validateRefs([contract1, contract2], [], diag);
316
+ const warnings = diag.getAll().filter(d => d.severity === 'warning' && d.message.includes('User'));
317
+ expect(warnings).toHaveLength(0);
318
+ });
319
+
320
+ it('warns when base model is undefined', () => {
321
+ const diag = new DiagnosticCollector();
322
+ const ck = parseCk('contract Admin: MissingBase & { role: string }', 'admin.ck', diag);
323
+ const { contract } = decomposeCk(ck);
324
+ validateRefs([contract], [], diag);
325
+ const warnings = diag.getAll().filter(d => d.severity === 'warning');
326
+ expect(warnings.some(w => w.message.includes('MissingBase'))).toBe(true);
327
+ });
328
+
329
+ it('warns when an operation references an undefined body type', () => {
330
+ const diag = new DiagnosticCollector();
331
+ const ck = parseCk(
332
+ `\
333
+ operation /users: {
334
+ get: {
335
+ response: {
336
+ 200: {
337
+ application/json: MissingType
338
+ }
339
+ }
340
+ }
341
+ }`,
342
+ 'users.ck',
343
+ diag,
344
+ );
345
+ const { op } = decomposeCk(ck);
346
+ const diagAll = new DiagnosticCollector();
347
+ validateRefs([], [op], diagAll);
348
+ const warnings = diagAll.getAll().filter(d => d.severity === 'warning');
349
+ expect(warnings.some(w => w.message.includes('MissingType'))).toBe(true);
350
+ });
351
+
352
+ it('does not warn for scalar type names in ops', () => {
353
+ const diag = new DiagnosticCollector();
354
+ const ck = parseCk(
355
+ `\
356
+ operation /users: {
357
+ get: {
358
+ query: {
359
+ page: int
360
+ }
361
+ }
362
+ }`,
363
+ 'users.ck',
364
+ diag,
365
+ );
366
+ const { op } = decomposeCk(ck);
367
+ const diagAll = new DiagnosticCollector();
368
+ validateRefs([], [op], diagAll);
369
+ const warnings = diagAll.getAll().filter(d => d.severity === 'warning');
370
+ expect(warnings).toHaveLength(0);
371
+ });
372
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "@repo/config-typescript/base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist"]
9
+ }
@@ -0,0 +1,14 @@
1
+ import { defineProject } from 'vitest/config';
2
+ import swc from 'unplugin-swc';
3
+
4
+ export default defineProject({
5
+ test: {
6
+ globals: true,
7
+ include: ['./tests/**/*.test.ts'],
8
+ environment: 'node',
9
+ testTimeout: 50000,
10
+ hookTimeout: 30000,
11
+ fileParallelism: true,
12
+ },
13
+ plugins: [swc.vite()],
14
+ });