@contractkit/plugin-typescript 0.29.0 → 0.30.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
5
5
  "author": {
6
6
  "name": "Marooned Software",
@@ -26,7 +26,7 @@
26
26
  ".": "./dist/index.js"
27
27
  },
28
28
  "dependencies": {
29
- "@contractkit/core": "0.23.0"
29
+ "@contractkit/core": "0.24.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@repo/config-eslint": "0.3.1",
@@ -5,7 +5,6 @@ import {
5
5
  renderInputType,
6
6
  renderQueryType,
7
7
  pascalToDotCase,
8
- typeNeedsDateTime,
9
8
  typeNeedsScalar,
10
9
  modeToWrapper,
11
10
  } from './codegen-contract.js';
@@ -126,50 +125,20 @@ export interface OpCodegenOptions {
126
125
  includeInternal?: boolean;
127
126
  }
128
127
 
129
- /** Generate a Koa router module for every operation in `root`, including the imports, type aliases, and handler list. */
128
+ /**
129
+ * Generate a Koa router module for every operation in `root`, including the imports, type
130
+ * aliases, and handler list.
131
+ *
132
+ * Imports are derived from the generated body — each candidate symbol is emitted only if it
133
+ * actually appears in the output. Deciding them from predicates over the AST instead means any
134
+ * drift between predicate and codegen leaves an unused import in every generated file, which
135
+ * trips `noUnusedLocals` and lint downstream.
136
+ */
130
137
  export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): string {
131
138
  // Collect all referenced types across all routes
132
139
  const types = collectTypes(root, options.modelsWithInput, options.modelsWithOutput);
133
140
  const services = collectServices(root);
134
141
  const routerName = deriveRouterName(root.file);
135
- const needsParseAndValidate = routeNeedsValidation(root);
136
-
137
- // Generate the body first so we can detect whether `z.` is actually referenced
138
- // before deciding whether to emit the zod import.
139
- const body: string[] = [];
140
- const needsSignature = fileNeedsSignature(root);
141
- const needsPolicy = fileNeedsPolicy(root);
142
- const koaImports = ['ServerKitRouter', 'bodyParserMiddleware'];
143
- if (needsPolicy) koaImports.push('requirePolicy');
144
- if (needsSignature) koaImports.push('requireSignature');
145
- body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
146
-
147
- for (const svc of services) {
148
- const modulePath = root.services?.[svc] ?? root.meta[svc] ?? deriveModulePath(svc, options.servicePathTemplate);
149
- body.push(`import { ${svc} } from '${modulePath}';`);
150
- }
151
-
152
- if (types.length > 0) {
153
- body.push(...generateTypeImports(types, root.file, options));
154
- }
155
-
156
- // luxon is needed for date/time/datetime (DateTime), duration (Duration) and interval (Interval);
157
- // the rendered Zod schemas and the service-result annotations both reference these classes.
158
- const luxonImports: string[] = [];
159
- if (opNeedsDateTime(root)) luxonImports.push('DateTime');
160
- if (opNeedsScalar(root, 'duration')) luxonImports.push('Duration');
161
- if (opNeedsScalar(root, 'interval')) luxonImports.push('Interval');
162
- if (luxonImports.length > 0) {
163
- body.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
164
- }
165
-
166
- if (needsParseAndValidate) {
167
- body.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
168
- }
169
-
170
- if (fileUsesMultipart(root)) {
171
- body.push(`import { MultipartBody } from '@maroonedsoftware/multipart';`);
172
- }
173
142
 
174
143
  const helpers: string[] = [];
175
144
  if (opNeedsScalar(root, 'binary')) {
@@ -211,6 +180,43 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
211
180
  }
212
181
  }
213
182
 
183
+ // Imports are decided from the code we just generated, not from predicates over the AST that
184
+ // have to be kept in step with it by hand. A predicate that drifts leaves an unused import in
185
+ // every generated file, which trips `noUnusedLocals` and lint in consuming projects.
186
+ const generated = [...(helpers.length ? ['', ...helpers] : []), ...lines].join('\n');
187
+ const uses = (symbol: string) => new RegExp(`\\b${symbol}\\b`).test(generated);
188
+
189
+ const body: string[] = [];
190
+
191
+ const koaImports = ['ServerKitRouter', 'bodyParserMiddleware', 'requirePolicy', 'requireSignature'].filter(uses);
192
+ if (koaImports.length > 0) {
193
+ body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
194
+ }
195
+
196
+ for (const svc of services) {
197
+ const modulePath = root.services?.[svc] ?? root.meta[svc] ?? deriveModulePath(svc, options.servicePathTemplate);
198
+ body.push(`import { ${svc} } from '${modulePath}';`);
199
+ }
200
+
201
+ if (types.length > 0) {
202
+ body.push(...generateTypeImports(types, root.file, options));
203
+ }
204
+
205
+ // luxon is needed for date/time/datetime (DateTime), duration (Duration) and interval (Interval);
206
+ // the rendered Zod schemas and the service-result annotations both reference these classes.
207
+ const luxonImports = ['DateTime', 'Duration', 'Interval'].filter(uses);
208
+ if (luxonImports.length > 0) {
209
+ body.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
210
+ }
211
+
212
+ if (uses('parseAndValidate')) {
213
+ body.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
214
+ }
215
+
216
+ if (uses('MultipartBody')) {
217
+ body.push(`import { MultipartBody } from '@maroonedsoftware/multipart';`);
218
+ }
219
+
214
220
  const allContent = [...body, ...(helpers.length ? ['', ...helpers] : []), ...lines].join('\n');
215
221
  const needsZod = /\bz\./.test(allContent);
216
222
  return (needsZod ? `import { z } from 'zod';\n` : '') + allContent;
@@ -780,26 +786,7 @@ function collectTypeNodeRefs(type: ContractTypeNode, out: Set<string>): void {
780
786
  }
781
787
  }
782
788
 
783
- function paramSourceNeedsDateTime(source: ParamSource | undefined): boolean {
784
- if (!source) return false;
785
- if (source.kind === 'ref') return false;
786
- if (source.kind === 'params') return source.nodes.some(p => typeNeedsDateTime(p.type));
787
- return typeNeedsDateTime(source.node);
788
- }
789
789
 
790
- function opNeedsDateTime(root: OpRootNode): boolean {
791
- return root.routes.some(
792
- route =>
793
- paramSourceNeedsDateTime(route.params) ||
794
- route.operations.some(
795
- op =>
796
- !!op.request?.bodies.some(b => typeNeedsDateTime(b.bodyType)) ||
797
- op.responses.some(r => r.bodyType && typeNeedsDateTime(r.bodyType)) ||
798
- paramSourceNeedsDateTime(op.query) ||
799
- paramSourceNeedsDateTime(op.headers),
800
- ),
801
- );
802
- }
803
790
 
804
791
  function paramSourceNeedsScalar(source: ParamSource | undefined, name: string): boolean {
805
792
  if (!source) return false;
@@ -838,32 +825,10 @@ function collectServices(root: OpRootNode): string[] {
838
825
  return [...services].sort();
839
826
  }
840
827
 
841
- function hasParamSource(source?: ParamSource): boolean {
842
- if (!source) return false;
843
- if (source.kind === 'ref') return true;
844
- if (source.kind === 'params') return source.nodes.length > 0;
845
- return true; // type
846
- }
847
828
 
848
- function routeNeedsValidation(root: OpRootNode): boolean {
849
- return root.routes.some(
850
- r => hasParamSource(r.params) || r.operations.some(op => !!op.request || hasParamSource(op.query) || hasParamSource(op.headers)),
851
- );
852
- }
853
829
 
854
- function fileNeedsPolicy(root: OpRootNode): boolean {
855
- return root.routes.some(route => route.operations.some(op => resolveSecurity(route, op, root) !== SECURITY_NONE));
856
- }
857
830
 
858
- function fileNeedsSignature(root: OpRootNode): boolean {
859
- return root.routes.some(route => route.operations.some(op => !!op.signature));
860
- }
861
831
 
862
- function fileUsesMultipart(root: OpRootNode): boolean {
863
- return root.routes.some(route =>
864
- route.operations.some(op => (op.request?.bodies ?? []).some(b => b.contentType === 'multipart/form-data')),
865
- );
866
- }
867
832
 
868
833
  function isValidIdentifier(name: string): boolean {
869
834
  return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
@@ -101,6 +101,66 @@ describe('generateOperation', () => {
101
101
  const output = generateOp(root);
102
102
  expect(output).not.toContain('luxon');
103
103
  });
104
+
105
+ // Every conditional import must be justified by a reference in the generated body —
106
+ // an unused import trips `noUnusedLocals` and lint in the consuming project.
107
+ it('imports bodyParserMiddleware only when an operation has a request body', () => {
108
+ const withBody = generateOp(opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])]));
109
+ expect(withBody).toContain('bodyParserMiddleware');
110
+
111
+ const withoutBody = generateOp(opRoot([opRoute('/users', [opOperation('get')])]));
112
+ expect(withoutBody).not.toContain('bodyParserMiddleware');
113
+ });
114
+
115
+ it('omits MultipartBody when a multipart body shares its shape with the other MIME types', () => {
116
+ // Structurally equal bodies collapse to a single parseAndValidate call, so nothing
117
+ // references MultipartBody even though the operation does declare multipart.
118
+ const root = opRoot([
119
+ opRoute('/upload', [
120
+ opOperation('post', {
121
+ request: opMultiRequest([
122
+ ['multipart/form-data', 'UploadForm'],
123
+ ['application/json', 'UploadForm'],
124
+ ]),
125
+ }),
126
+ ]),
127
+ ]);
128
+ const output = generateOp(root);
129
+ expect(output).not.toContain('MultipartBody');
130
+ });
131
+
132
+ it('imports MultipartBody when the multipart body is handled on its own', () => {
133
+ const root = opRoot([opRoute('/upload', [opOperation('post', { request: opMultiRequest([['multipart/form-data', 'UploadForm']]) })])]);
134
+ expect(generateOp(root)).toContain("import { MultipartBody } from '@maroonedsoftware/multipart';");
135
+ });
136
+
137
+ it('leaves no import unreferenced in the generated body', () => {
138
+ const root = opRoot([
139
+ opRoute(
140
+ '/users/{id}',
141
+ [
142
+ opOperation('get', { security: SECURITY_NONE }),
143
+ opOperation('post', { request: opRequest('CreateUser'), signature: 'webhookKey' }),
144
+ ],
145
+ [opParam('id', scalarType('uuid'))],
146
+ ),
147
+ ]);
148
+ const output = generateOp(root);
149
+ const importLines = output.split('\n').filter(l => l.startsWith('import '));
150
+ expect(importLines.length).toBeGreaterThan(0);
151
+
152
+ const bodyText = output
153
+ .split('\n')
154
+ .filter(l => !l.startsWith('import '))
155
+ .join('\n');
156
+ for (const line of importLines) {
157
+ const named = line.match(/^import \{([^}]*)\}/);
158
+ if (!named) continue;
159
+ for (const symbol of named[1]!.split(',').map(s => s.trim().replace(/^type /, ''))) {
160
+ expect(bodyText, `${symbol} is imported but never used`).toMatch(new RegExp(`\\b${symbol}\\b`));
161
+ }
162
+ }
163
+ });
104
164
  });
105
165
 
106
166
  // ─── Handler signature ─────────────────────────────────────────
@@ -974,7 +1034,7 @@ describe('generateOp — route modifiers JSDoc', () => {
974
1034
  const root = opRoot([opRoute('/users', [op])]);
975
1035
  const out = generateOp(root);
976
1036
  expect(out).not.toContain('requireSignature');
977
- expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requirePolicy }`);
1037
+ expect(out).toContain(`import { ServerKitRouter, requirePolicy }`);
978
1038
  });
979
1039
  });
980
1040
 
@@ -985,7 +1045,7 @@ describe('generateOp — route modifiers JSDoc', () => {
985
1045
  const op = opOperation('get');
986
1046
  const root = opRoot([opRoute('/users', [op])]);
987
1047
  const out = generateOp(root);
988
- expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requirePolicy }`);
1048
+ expect(out).toContain(`import { ServerKitRouter, requirePolicy }`);
989
1049
  expect(out).toContain(`requirePolicy()`);
990
1050
  });
991
1051
 
@@ -1019,7 +1079,7 @@ describe('generateOp — route modifiers JSDoc', () => {
1019
1079
  const op = opOperation('get', { security: SECURITY_NONE });
1020
1080
  const root = opRoot([opRoute('/health', [op])]);
1021
1081
  const out = generateOp(root);
1022
- expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware }`);
1082
+ expect(out).toContain(`import { ServerKitRouter } from`);
1023
1083
  expect(out).not.toContain('requirePolicy');
1024
1084
  });
1025
1085