@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,192 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createTypescriptPlugin } from '../src/index.js';
3
+ import type { PluginContext } from '@contractkit/core';
4
+ import { opRoot, opRoute, opOperation, opParam, opRequest, opResponse, scalarType, refType, contractRoot, model, field } from './helpers.js';
5
+
6
+ // ─── Helpers ───────────────────────────────────────────────────────────────
7
+
8
+ function makeCtx(rootDir = '/project', options: Record<string, unknown> = {}): PluginContext & { emitted: Map<string, string> } {
9
+ const emitted = new Map<string, string>();
10
+ return {
11
+ rootDir,
12
+ options,
13
+ emitFile: (outPath: string, content: string) => {
14
+ emitted.set(outPath, content);
15
+ },
16
+ emitted,
17
+ };
18
+ }
19
+
20
+ function inputs(opRoots = [opRoot([opRoute('/users', [opOperation('get')])], '/project/contracts/users.ck')], contractRoots = []) {
21
+ return {
22
+ contractRoots,
23
+ opRoots,
24
+ modelOutPaths: new Map<string, string>(),
25
+ modelsWithInput: new Set<string>(),
26
+ modelsWithOutput: new Set<string>(),
27
+ };
28
+ }
29
+
30
+ // ─── Tests ─────────────────────────────────────────────────────────────────
31
+
32
+ describe('createTypescriptPlugin (server)', () => {
33
+ describe('output path computation', () => {
34
+ it('uses template variable {filename} in routes output path', async () => {
35
+ const plugin = createTypescriptPlugin({ server: { output: { routes: 'src/routes/{filename}.router.ts' } } }, '/project');
36
+ const ctx = makeCtx('/project');
37
+ await plugin.generateTargets!(inputs(), ctx);
38
+ expect([...ctx.emitted.keys()].some(p => p.endsWith('users.router.ts'))).toBe(true);
39
+ });
40
+
41
+ it('respects baseDir when computing output path', async () => {
42
+ const plugin = createTypescriptPlugin({ server: { baseDir: 'apps/api', output: { routes: 'src/{filename}.router.ts' } } }, '/project');
43
+ const ctx = makeCtx('/project');
44
+ await plugin.generateTargets!(inputs(), ctx);
45
+ const [outPath] = [...ctx.emitted.keys()];
46
+ expect(outPath).toContain('apps/api');
47
+ });
48
+
49
+ it('defaults to {filename}.router.ts when no output template', async () => {
50
+ const plugin = createTypescriptPlugin({ server: {} }, '/project');
51
+ const ctx = makeCtx('/project');
52
+ await plugin.generateTargets!(inputs(), ctx);
53
+ expect([...ctx.emitted.keys()].some(p => p.endsWith('users.router.ts'))).toBe(true);
54
+ });
55
+
56
+ it('emits one route file per op root', async () => {
57
+ const plugin = createTypescriptPlugin({ server: {} }, '/project');
58
+ const ctx = makeCtx('/project');
59
+ await plugin.generateTargets!(
60
+ inputs([
61
+ opRoot([opRoute('/users', [opOperation('get')])], '/project/contracts/users.ck'),
62
+ opRoot([opRoute('/payments', [opOperation('get')])], '/project/contracts/payments.ck'),
63
+ ]),
64
+ ctx,
65
+ );
66
+ expect(ctx.emitted.size).toBe(2);
67
+ });
68
+
69
+ it('emits type files alongside routes when output.types is configured', async () => {
70
+ const plugin = createTypescriptPlugin(
71
+ {
72
+ server: {
73
+ output: {
74
+ routes: 'src/routes/{filename}.router.ts',
75
+ types: 'src/types/{filename}.ts',
76
+ },
77
+ },
78
+ },
79
+ '/project',
80
+ );
81
+ const ctx = makeCtx('/project');
82
+ const contractRoots = [contractRoot([model('User', [field('id', scalarType('uuid'))])], '/project/contracts/users.ck')];
83
+ await plugin.generateTargets!(inputs(undefined, contractRoots as any), ctx);
84
+ expect([...ctx.emitted.keys()].some(p => p.includes('src/types'))).toBe(true);
85
+ });
86
+
87
+ it('emits Zod schemas for types when zod: true', async () => {
88
+ const plugin = createTypescriptPlugin(
89
+ {
90
+ server: {
91
+ zod: true,
92
+ output: { types: 'src/types/{filename}.ts' },
93
+ },
94
+ },
95
+ '/project',
96
+ );
97
+ const ctx = makeCtx('/project');
98
+ const contractRoots = [contractRoot([model('User', [field('id', scalarType('uuid'))])], '/project/contracts/users.ck')];
99
+ await plugin.generateTargets!(inputs([], contractRoots as any), ctx);
100
+ const typeContent = [...ctx.emitted.values()].find(c => c.includes('z.'));
101
+ expect(typeContent).toBeDefined();
102
+ });
103
+
104
+ it('emits plain TypeScript types when zod is not set', async () => {
105
+ const plugin = createTypescriptPlugin(
106
+ {
107
+ server: {
108
+ output: { types: 'src/types/{filename}.ts' },
109
+ },
110
+ },
111
+ '/project',
112
+ );
113
+ const ctx = makeCtx('/project');
114
+ const contractRoots = [contractRoot([model('User', [field('id', scalarType('uuid'))])], '/project/contracts/users.ck')];
115
+ await plugin.generateTargets!(inputs([], contractRoots as any), ctx);
116
+ const typeContent = [...ctx.emitted.values()][0]!;
117
+ expect(typeContent).not.toContain('z.');
118
+ expect(typeContent).toContain('export interface User');
119
+ });
120
+ });
121
+
122
+ describe('generated content', () => {
123
+ it('emits a Koa router', async () => {
124
+ const plugin = createTypescriptPlugin({ server: {} }, '/project');
125
+ const ctx = makeCtx('/project');
126
+ await plugin.generateTargets!(inputs(), ctx);
127
+ const content = [...ctx.emitted.values()][0]!;
128
+ expect(content).toContain('ServerKitRouter');
129
+ });
130
+
131
+ it('includes route path', async () => {
132
+ const plugin = createTypescriptPlugin({ server: {} }, '/project');
133
+ const ctx = makeCtx('/project');
134
+ await plugin.generateTargets!(inputs([opRoot([opRoute('/payments/{id}', [opOperation('get')])], '/project/contracts/payments.ck')]), ctx);
135
+ const content = [...ctx.emitted.values()][0]!;
136
+ expect(content).toContain('/payments');
137
+ });
138
+
139
+ it('includes service call when servicePathTemplate is set', async () => {
140
+ const plugin = createTypescriptPlugin({ server: { servicePathTemplate: '#services/{module}.service.js' } }, '/project');
141
+ const ctx = makeCtx('/project');
142
+ const root = opRoot([opRoute('/users', [opOperation('get', { service: 'UserService.list' })])], '/project/contracts/users.ck');
143
+ await plugin.generateTargets!(inputs([root]), ctx);
144
+ const content = [...ctx.emitted.values()][0]!;
145
+ expect(content).toContain('UserService');
146
+ });
147
+
148
+ it('includes request body validation when route has a POST body', async () => {
149
+ const plugin = createTypescriptPlugin({ server: {} }, '/project');
150
+ const ctx = makeCtx('/project');
151
+ const root = opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])], '/project/contracts/users.ck');
152
+ await plugin.generateTargets!(inputs([root]), ctx);
153
+ const content = [...ctx.emitted.values()][0]!;
154
+ expect(content).toContain('parseAndValidate');
155
+ });
156
+
157
+ it('includes uuid param validation', async () => {
158
+ const plugin = createTypescriptPlugin({ server: {} }, '/project');
159
+ const ctx = makeCtx('/project');
160
+ const root = opRoot([opRoute('/users/{id}', [opOperation('get')], [opParam('id', scalarType('uuid'))])], '/project/contracts/users.ck');
161
+ await plugin.generateTargets!(inputs([root]), ctx);
162
+ const content = [...ctx.emitted.values()][0]!;
163
+ expect(content).toContain('parseAndValidate');
164
+ });
165
+
166
+ it('includes response type reference when response has model ref', async () => {
167
+ const plugin = createTypescriptPlugin({ server: {} }, '/project');
168
+ const ctx = makeCtx('/project');
169
+ const root = opRoot(
170
+ [opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])],
171
+ '/project/contracts/users.ck',
172
+ );
173
+ await plugin.generateTargets!(inputs([root]), ctx);
174
+ const content = [...ctx.emitted.values()][0]!;
175
+ expect(content).toContain('User');
176
+ });
177
+ });
178
+
179
+ describe('default plugin export', () => {
180
+ it('plugin has name "typescript"', async () => {
181
+ const { default: plugin } = await import('../src/index.js');
182
+ expect(plugin.name).toBe('typescript');
183
+ });
184
+
185
+ it('plugin reads server config from ctx.options.server', async () => {
186
+ const { default: plugin } = await import('../src/index.js');
187
+ const ctx = makeCtx('/project', { server: { output: { routes: 'src/routes/{filename}.router.ts' } } });
188
+ await plugin.generateTargets!(inputs(), ctx);
189
+ expect([...ctx.emitted.keys()].some(p => p.endsWith('users.router.ts'))).toBe(true);
190
+ });
191
+ });
192
+ });
@@ -0,0 +1,247 @@
1
+ import type {
2
+ ContractRootNode,
3
+ ModelNode,
4
+ FieldNode,
5
+ ContractTypeNode,
6
+ ScalarTypeNode,
7
+ ArrayTypeNode,
8
+ TupleTypeNode,
9
+ RecordTypeNode,
10
+ EnumTypeNode,
11
+ LiteralTypeNode,
12
+ UnionTypeNode,
13
+ DiscriminatedUnionTypeNode,
14
+ ModelRefTypeNode,
15
+ InlineObjectTypeNode,
16
+ LazyTypeNode,
17
+ SourceLocation,
18
+ OpRootNode,
19
+ OpRouteNode,
20
+ OpOperationNode,
21
+ OpParamNode,
22
+ OpRequestNode,
23
+ OpResponseNode,
24
+ HttpMethod,
25
+ ParamSource,
26
+ RouteModifier,
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 discriminatedUnionType(discriminator: string, ...members: ContractTypeNode[]): DiscriminatedUnionTypeNode {
64
+ return { kind: 'discriminatedUnion', discriminator, 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 contractRoot(models: ModelNode[], file = 'test.ck'): ContractRootNode {
102
+ return { kind: 'contractRoot', meta: {}, models, file };
103
+ }
104
+
105
+ export function opParam(name: string, type: ContractTypeNode): OpParamNode {
106
+ return { name, type, loc: loc(1, 'test.op') };
107
+ }
108
+
109
+ export function paramNodes(nodes: OpParamNode[]): ParamSource {
110
+ return { kind: 'params', nodes };
111
+ }
112
+
113
+ export function paramRef(name: string): ParamSource {
114
+ return { kind: 'ref', name };
115
+ }
116
+
117
+ export function paramType(node: ContractTypeNode): ParamSource {
118
+ return { kind: 'type', node };
119
+ }
120
+
121
+ export function opRequest(bodyType: string | ContractTypeNode, contentType: string = 'application/json'): OpRequestNode {
122
+ const bt: ContractTypeNode = typeof bodyType === 'string' ? refType(bodyType) : bodyType;
123
+ return { bodies: [{ contentType, bodyType: bt }] };
124
+ }
125
+
126
+ export function opMultiRequest(entries: Array<[string, string | ContractTypeNode]>): OpRequestNode {
127
+ return {
128
+ bodies: entries.map(([contentType, body]) => ({
129
+ contentType,
130
+ bodyType: typeof body === 'string' ? refType(body) : body,
131
+ })),
132
+ };
133
+ }
134
+
135
+ export function opResponse(statusCode: number, bodyType?: string | ContractTypeNode, contentType?: string): OpResponseNode {
136
+ const bt: ContractTypeNode | undefined =
137
+ bodyType === undefined ? undefined : typeof bodyType === 'string' ? parseBodyTypeString(bodyType) : bodyType;
138
+ return { statusCode, contentType, bodyType: bt };
139
+ }
140
+
141
+ function parseBodyTypeString(s: string): ContractTypeNode {
142
+ const arrayMatch = s.match(/^array\((.+)\)$/);
143
+ if (arrayMatch?.[1]) {
144
+ return { kind: 'array', item: refType(arrayMatch[1]) };
145
+ }
146
+ return refType(s);
147
+ }
148
+
149
+ /** Normalize a raw param value (old bare format or new discriminated union) to ParamSource. */
150
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
151
+ function normalizeParamSource(value: any): ParamSource {
152
+ if (!value) return value;
153
+ if (typeof value === 'string') return { kind: 'ref', name: value };
154
+ if (Array.isArray(value)) return { kind: 'params', nodes: value };
155
+ if (value.kind === 'params' || value.kind === 'ref' || value.kind === 'type') return value as ParamSource;
156
+ return { kind: 'type', node: value as ContractTypeNode };
157
+ }
158
+
159
+ export function opOperation(method: HttpMethod, overrides?: Partial<OpOperationNode> & { query?: unknown; headers?: unknown }): OpOperationNode {
160
+ const normalized = { ...overrides } as Partial<OpOperationNode>;
161
+ if (overrides?.query !== undefined) normalized.query = normalizeParamSource(overrides.query);
162
+ if (overrides?.headers !== undefined) normalized.headers = normalizeParamSource(overrides.headers);
163
+ return {
164
+ method,
165
+ responses: [],
166
+ loc: loc(1, 'test.op'),
167
+ ...normalized,
168
+ };
169
+ }
170
+
171
+ export function opRoute(
172
+ path: string,
173
+ operations: OpOperationNode[],
174
+ params?: ParamSource | OpParamNode[] | string,
175
+ modifiers?: RouteModifier[],
176
+ ): OpRouteNode {
177
+ const normalizedParams = params !== undefined ? normalizeParamSource(params) : undefined;
178
+ return { path, params: normalizedParams, operations, modifiers, loc: loc(1, 'test.op') };
179
+ }
180
+
181
+ export function opRoot(routes: OpRouteNode[], file = 'users.op', meta: Record<string, string> = {}): OpRootNode {
182
+ return { kind: 'opRoot', meta, routes, file };
183
+ }
184
+
185
+ // ─── DSL Fixture Strings ────────────────────────────────────────────────────
186
+
187
+ export const SIMPLE_USER_CONTRACT = `\
188
+ contract User: {
189
+ id: readonly uuid
190
+ name: string
191
+ email: email
192
+ age?: number
193
+ active: boolean = true
194
+ }
195
+ `;
196
+
197
+ export const VISIBILITY_CONTRACT = `\
198
+ contract User: {
199
+ id: readonly uuid
200
+ name: string
201
+ password: writeonly string
202
+ }
203
+ `;
204
+
205
+ export const INHERITANCE_CONTRACT = `\
206
+ contract Admin: User & {
207
+ role: enum(admin, superadmin)
208
+ }
209
+ `;
210
+
211
+ export const SIMPLE_USERS_OP = `\
212
+ operation /users: {
213
+ get: {
214
+ response: {
215
+ 200: {
216
+ application/json: array(User)
217
+ }
218
+ }
219
+ }
220
+ post: {
221
+ request: {
222
+ application/json: CreateUserInput
223
+ }
224
+ response: {
225
+ 201: {
226
+ application/json: User
227
+ }
228
+ }
229
+ }
230
+ }
231
+ `;
232
+
233
+ export const PARAMETERIZED_OP = `\
234
+ operation /users/{id}: {
235
+ params: {
236
+ id: uuid
237
+ }
238
+ get: {
239
+ response: {
240
+ 200: {
241
+ application/json: User
242
+ }
243
+ }
244
+ }
245
+ delete: {}
246
+ }
247
+ `;