@contractkit/plugin-typescript 0.29.0 → 0.31.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.31.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.25.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@repo/config-eslint": "0.3.1",
@@ -1,5 +1,5 @@
1
1
  import type { OpRootNode, OpRouteNode, OpOperationNode, McpConfigNode, ParamSource, ContractTypeNode } from '@contractkit/core';
2
- import { resolveModifiers } from '@contractkit/core';
2
+ import { resolveModifiers, emittedResponses } from '@contractkit/core';
3
3
  import { renderType, renderInputType, pascalToDotCase } from './codegen-contract.js';
4
4
  import { inferService, deriveModulePath, buildArgs, deriveBaseName } from './codegen-operation.js';
5
5
  import { quoteKey, escapeSingleQuoted } from './ts-render.js';
@@ -155,14 +155,22 @@ function argsSchemaExpr(props: ArgsProp[]): string {
155
155
 
156
156
  // ─── Output schema ──────────────────────────────────────────────────────────
157
157
 
158
- /** Primary response = first with a body, else the first response. */
159
- function primaryResponse(op: OpOperationNode) {
160
- return op.responses.find(r => r.bodyType) ?? op.responses[0];
158
+ /**
159
+ * The body an MCP tool reports as its output: the first body the service can actually return.
160
+ *
161
+ * Documented and thrown statuses are skipped — an MCP tool describes what a successful call
162
+ * produces, not what the operation is allowed to document.
163
+ */
164
+ function primaryResponseBody(op: OpOperationNode): ContractTypeNode | undefined {
165
+ for (const resp of emittedResponses(op)) {
166
+ if (resp.bodies[0]) return resp.bodies[0].bodyType;
167
+ }
168
+ return undefined;
161
169
  }
162
170
 
163
171
  /** MCP output schemas must be objects — only model refs and inline objects qualify. */
164
172
  function outputSchemaExpr(op: OpOperationNode): string | undefined {
165
- const body = primaryResponse(op)?.bodyType;
173
+ const body = primaryResponseBody(op);
166
174
  if (!body) return undefined;
167
175
  if (body.kind === 'ref') return body.name;
168
176
  if (body.kind === 'inlineObject') return renderType(body);
@@ -233,7 +241,7 @@ function collectSchemaIds(ops: { route: OpRouteNode; op: OpOperationNode }[], mo
233
241
  walkSourceRefs(op.query, ids, modelsWithInput);
234
242
  walkSourceRefs(op.headers, ids, modelsWithInput);
235
243
 
236
- const body = primaryResponse(op)?.bodyType;
244
+ const body = primaryResponseBody(op);
237
245
  if (body && (body.kind === 'ref' || body.kind === 'inlineObject')) walkTypeRefs(body, ids, 'read');
238
246
  }
239
247
  return ids;
@@ -356,7 +364,7 @@ function renderToolClass(plan: ToolPlan, file: string, options: McpCodegenOption
356
364
  const props = buildArgsProps(route, op, options.modelsWithInput);
357
365
  const destructure = props.map(p => p.key);
358
366
  const callArgs = buildArgs(route, op);
359
- const isVoid = !primaryResponse(op)?.bodyType;
367
+ const isVoid = !primaryResponseBody(op);
360
368
  const structured = !!outExpr;
361
369
 
362
370
  lines.push(' async handle(args: Record<string, unknown>, _context: McpToolContext): Promise<CallToolResult> {');
@@ -1,12 +1,20 @@
1
- import type { OpRootNode, OpRouteNode, OpOperationNode, ContractTypeNode, ScalarTypeNode, ParamSource, ObjectMode } from '@contractkit/core';
2
- import { resolveModifiers, resolveSecurity, SECURITY_NONE, classifyContentType } from '@contractkit/core';
1
+ import type {
2
+ OpRootNode,
3
+ OpRouteNode,
4
+ OpOperationNode,
5
+ OpResponseNode,
6
+ OpResponseHeaderNode,
7
+ ContractTypeNode,
8
+ ScalarTypeNode,
9
+ ParamSource,
10
+ ObjectMode,
11
+ } from '@contractkit/core';
12
+ import { resolveModifiers, resolveSecurity, SECURITY_NONE, classifyContentType, emittedResponses } from '@contractkit/core';
3
13
  import {
4
14
  renderType,
5
15
  renderInputType,
6
16
  renderQueryType,
7
17
  pascalToDotCase,
8
- typeNeedsDateTime,
9
- typeNeedsScalar,
10
18
  modeToWrapper,
11
19
  } from './codegen-contract.js';
12
20
  import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted } from './ts-render.js';
@@ -126,89 +134,106 @@ export interface OpCodegenOptions {
126
134
  includeInternal?: boolean;
127
135
  }
128
136
 
129
- /** Generate a Koa router module for every operation in `root`, including the imports, type aliases, and handler list. */
137
+ /**
138
+ * Generate a Koa router module for every operation in `root`, including the imports, type
139
+ * aliases, and handler list.
140
+ *
141
+ * Imports are derived from the generated body — each candidate symbol is emitted only if it
142
+ * actually appears in the output. Deciding them from predicates over the AST instead means any
143
+ * drift between predicate and codegen leaves an unused import in every generated file, which
144
+ * trips `noUnusedLocals` and lint downstream.
145
+ */
130
146
  export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): string {
131
147
  // Collect all referenced types across all routes
132
148
  const types = collectTypes(root, options.modelsWithInput, options.modelsWithOutput);
133
149
  const services = collectServices(root);
134
150
  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
151
 
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
- }
152
+ const lines: string[] = [];
155
153
 
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
- }
154
+ lines.push('');
155
+ lines.push('/**');
156
+ const relFile = options.outPath ? relative(dirname(options.outPath), root.file) : root.file;
157
+ lines.push(` * generated from [${basename(root.file)}](file://./${relFile})`);
158
+ lines.push('*/');
159
+ lines.push(`export const ${routerName} = ServerKitRouter();`);
160
+ lines.push('');
165
161
 
166
- if (needsParseAndValidate) {
167
- body.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
162
+ const includeInternal = options.includeInternal ?? true;
163
+ for (const route of root.routes) {
164
+ for (const op of route.operations) {
165
+ if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
166
+ lines.push(...generateHandler(route, op, root, options));
167
+ lines.push('');
168
+ }
168
169
  }
169
170
 
170
- if (fileUsesMultipart(root)) {
171
- body.push(`import { MultipartBody } from '@maroonedsoftware/multipart';`);
172
- }
171
+ // Helpers and imports are both decided from the code we just generated, not from predicates
172
+ // over the AST that have to be kept in step with it by hand. A predicate that drifts leaves an
173
+ // unused declaration in every generated file, which trips `noUnusedLocals` and lint in
174
+ // consuming projects — `opNeedsScalar(root, 'binary')` over-approximates exactly that way,
175
+ // since a binary *response* body is a plain `Buffer` annotation with no schema behind it.
176
+ const handlerBody = lines.join('\n');
177
+ const references = (symbol: string) => new RegExp(`\\b${symbol}\\b`).test(handlerBody);
173
178
 
174
179
  const helpers: string[] = [];
175
- if (opNeedsScalar(root, 'binary')) {
180
+ if (references('_ZodBinary')) {
176
181
  helpers.push(`const _ZodBinary = z.custom<Buffer>((val) => Buffer.isBuffer(val), { error: 'Must be binary data' });`);
177
182
  }
178
- if (opNeedsScalar(root, 'datetime')) {
183
+ if (references('_ZodDatetime')) {
179
184
  helpers.push(
180
185
  `const _ZodDatetime = z.preprocess((val) => typeof val === 'string' ? DateTime.fromISO(val) : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be in ISO 8601 format' }));`,
181
186
  );
182
187
  }
183
- if (opNeedsScalar(root, 'interval')) {
188
+ if (references('_ZodInterval')) {
184
189
  helpers.push(
185
190
  `const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
186
191
  );
187
192
  }
188
- if (opNeedsScalar(root, 'json')) {
193
+ // `_ZodJson`'s own declaration is annotated with `_JsonValue`, so the type alias comes along
194
+ // with it; the alias is also needed on its own for a `json` body's server-side annotation.
195
+ const needsZodJson = references('_ZodJson');
196
+ if (needsZodJson || references('_JsonValue')) {
189
197
  helpers.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
198
+ }
199
+ if (needsZodJson) {
190
200
  helpers.push(
191
201
  `const _ZodJson: z.ZodType<_JsonValue> = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(_ZodJson), z.record(z.string(), _ZodJson)]));`,
192
202
  );
193
203
  }
194
204
 
195
- const lines: string[] = [];
205
+ const generated = [...(helpers.length ? ['', ...helpers] : []), ...lines].join('\n');
206
+ const uses = (symbol: string) => new RegExp(`\\b${symbol}\\b`).test(generated);
196
207
 
197
- lines.push('');
198
- lines.push('/**');
199
- const relFile = options.outPath ? relative(dirname(options.outPath), root.file) : root.file;
200
- lines.push(` * generated from [${basename(root.file)}](file://./${relFile})`);
201
- lines.push('*/');
202
- lines.push(`export const ${routerName} = ServerKitRouter();`);
203
- lines.push('');
208
+ const body: string[] = [];
204
209
 
205
- const includeInternal = options.includeInternal ?? true;
206
- for (const route of root.routes) {
207
- for (const op of route.operations) {
208
- if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
209
- lines.push(...generateHandler(route, op, root, options));
210
- lines.push('');
211
- }
210
+ const koaImports = ['ServerKitRouter', 'bodyParserMiddleware', 'requirePolicy', 'requireSignature'].filter(uses);
211
+ if (koaImports.length > 0) {
212
+ body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
213
+ }
214
+
215
+ for (const svc of services) {
216
+ const modulePath = root.services?.[svc] ?? root.meta[svc] ?? deriveModulePath(svc, options.servicePathTemplate);
217
+ body.push(`import { ${svc} } from '${modulePath}';`);
218
+ }
219
+
220
+ if (types.length > 0) {
221
+ body.push(...generateTypeImports(types, root.file, options));
222
+ }
223
+
224
+ // luxon is needed for date/time/datetime (DateTime), duration (Duration) and interval (Interval);
225
+ // the rendered Zod schemas and the service-result annotations both reference these classes.
226
+ const luxonImports = ['DateTime', 'Duration', 'Interval'].filter(uses);
227
+ if (luxonImports.length > 0) {
228
+ body.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
229
+ }
230
+
231
+ if (uses('parseAndValidate')) {
232
+ body.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
233
+ }
234
+
235
+ if (uses('MultipartBody')) {
236
+ body.push(`import { MultipartBody } from '@maroonedsoftware/multipart';`);
212
237
  }
213
238
 
214
239
  const allContent = [...body, ...(helpers.length ? ['', ...helpers] : []), ...lines].join('\n');
@@ -321,66 +346,177 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
321
346
  }
322
347
  }
323
348
 
324
- // Service call use the first response with a body as the primary response
325
- const primaryResponse = op.responses.find(r => r.bodyType) ?? op.responses[0];
349
+ // Service call. `emittedResponses` decides which of the declared statuses the service is
350
+ // responsible for producing; the rest are documentation, or the thrown-error path.
351
+ const emitted = emittedResponses(op);
326
352
  const serviceParts = inferService(op, route, file);
327
- const respHeaders = primaryResponse?.headers ?? [];
353
+ const call = `await service.${serviceParts.methodName}(${buildArgs(route, op)})`;
354
+
355
+ if (emitted.length > 1) {
356
+ lines.push(...generateMultiStatusResult(emitted, serviceParts.className, call, options));
357
+ } else {
358
+ lines.push(...generateSingleStatusResult(emitted[0], op, serviceParts.className, call, options));
359
+ }
360
+
361
+ lines.push(`});`);
362
+
363
+ return lines;
364
+ }
365
+
366
+ /**
367
+ * The service produces exactly one status (or none): the result is the body itself, or
368
+ * `{ body, headers }` when the status declares headers, and `ctx.status` is a constant.
369
+ *
370
+ * A status declaring several mimes also gains a `contentType` the service picks, which is the
371
+ * only thing here that can turn `ctx.type` from a literal into an expression.
372
+ */
373
+ function generateSingleStatusResult(
374
+ resp: OpResponseNode | undefined,
375
+ op: OpOperationNode,
376
+ className: string,
377
+ call: string,
378
+ options: OpCodegenOptions,
379
+ ): string[] {
380
+ const lines: string[] = [];
381
+ const bodies = resp ? resp.bodies : [];
382
+ const respHeaders = resp?.headers ?? [];
328
383
  const hasRespHeaders = respHeaders.length > 0;
329
- const headersAnnotation = hasRespHeaders
330
- ? `{ ${respHeaders
331
- .map(
332
- h =>
333
- `${quoteKey(headerNameToProperty(h.name))}${h.optional ? '?' : ''}: ${renderOutputTsType(h.type, options.modelsWithOutput, 'server')}`,
334
- )
335
- .join('; ')} }`
336
- : '';
337
-
338
- if (primaryResponse?.bodyType) {
339
- const { annotation, prelude } = formatTypeAnnotation(primaryResponse.bodyType!, options.modelsWithOutput);
340
- if (prelude) {
341
- lines.push(` ${prelude}`);
342
- }
343
- lines.push(` const service = ctx.container.get(${serviceParts.className});`);
384
+ const headersAnnotation = hasRespHeaders ? renderHeadersAnnotation(respHeaders, options.modelsWithOutput) : '';
385
+
386
+ if (bodies.length === 1) {
387
+ const { annotation, prelude } = formatTypeAnnotation(bodies[0]!.bodyType, options.modelsWithOutput);
388
+ if (prelude) lines.push(` ${prelude}`);
389
+ lines.push(` const service = ctx.container.get(${className});`);
344
390
  if (hasRespHeaders) {
345
- lines.push(
346
- ` const result: { body: ${annotation}; headers: ${headersAnnotation} } = await service.${serviceParts.methodName}(${buildArgs(route, op)});`,
347
- );
391
+ lines.push(` const result: { body: ${annotation}; headers: ${headersAnnotation} } = ${call};`);
348
392
  } else {
349
- lines.push(` const result: ${annotation} = await service.${serviceParts.methodName}(${buildArgs(route, op)});`);
393
+ lines.push(` const result: ${annotation} = ${call};`);
350
394
  }
395
+ } else if (bodies.length > 1) {
396
+ const { members, preludes } = renderResponseMembers(resp!, options, { includeStatus: false, varPrefix: 'result' });
397
+ for (const prelude of preludes) lines.push(` ${prelude}`);
398
+ lines.push(` const service = ctx.container.get(${className});`);
399
+ lines.push(` const result: ${members.join(' | ')} = ${call};`);
351
400
  } else {
352
- lines.push(` const service = ctx.container.get(${serviceParts.className});`);
401
+ lines.push(` const service = ctx.container.get(${className});`);
353
402
  if (hasRespHeaders) {
354
- lines.push(` const result: { headers: ${headersAnnotation} } = await service.${serviceParts.methodName}(${buildArgs(route, op)});`);
403
+ lines.push(` const result: { headers: ${headersAnnotation} } = ${call};`);
355
404
  } else {
356
- lines.push(` await service.${serviceParts.methodName}(${buildArgs(route, op)});`);
405
+ lines.push(` ${call};`);
357
406
  }
358
407
  }
359
408
 
360
409
  lines.push('');
361
- lines.push(` ctx.status = ${primaryResponse?.statusCode ?? 200};`);
410
+ // With nothing emitted, the status is still declared somewhere — fall back to the first
411
+ // one written, which is what a documentation-only 3xx/4xx operation means.
412
+ lines.push(` ctx.status = ${resp?.statusCode ?? op.responses[0]?.statusCode ?? 200};`);
413
+ lines.push(...headerSetLines(respHeaders, ' '));
362
414
 
363
- if (hasRespHeaders) {
364
- for (const h of respHeaders) {
365
- const accessor = `result.headers[${JSON.stringify(headerNameToProperty(h.name))}]`;
366
- if (h.optional) {
367
- lines.push(` if (${accessor} !== undefined) ctx.set('${h.name}', String(${accessor}));`);
368
- } else {
369
- lines.push(` ctx.set('${h.name}', String(${accessor}));`);
370
- }
371
- }
415
+ if (bodies.length === 1) {
416
+ lines.push(` ctx.type = '${bodies[0]!.contentType}';`);
417
+ lines.push(` ctx.body = ${hasRespHeaders ? 'result.body' : 'result'};`);
418
+ } else if (bodies.length > 1) {
419
+ lines.push(` ctx.type = result.contentType;`);
420
+ lines.push(` ctx.body = result.body;`);
372
421
  }
373
422
 
374
- if (primaryResponse?.bodyType && primaryResponse.contentType) {
375
- lines.push(` ctx.type = '${primaryResponse.contentType}';`);
376
- lines.push(` ctx.body = ${hasRespHeaders ? 'result.body' : 'result'};`);
423
+ return lines;
424
+ }
425
+
426
+ /**
427
+ * The service chooses between several statuses: the result is a union discriminated on
428
+ * `status`, and the handler switches on it so each status writes only its own headers, mime
429
+ * and body.
430
+ */
431
+ function generateMultiStatusResult(emitted: OpResponseNode[], className: string, call: string, options: OpCodegenOptions): string[] {
432
+ const lines: string[] = [];
433
+ const members: string[] = [];
434
+ const preludes: string[] = [];
435
+
436
+ for (const resp of emitted) {
437
+ const rendered = renderResponseMembers(resp, options, { includeStatus: true, varPrefix: `result${resp.statusCode}` });
438
+ members.push(...rendered.members);
439
+ preludes.push(...rendered.preludes);
377
440
  }
378
441
 
379
- lines.push(`});`);
442
+ for (const prelude of preludes) lines.push(` ${prelude}`);
443
+ lines.push(` const service = ctx.container.get(${className});`);
444
+ lines.push(` const result:`);
445
+ for (const member of members) lines.push(` | ${member}`);
446
+ lines.push(` = ${call};`);
447
+ lines.push('');
448
+ lines.push(` ctx.status = result.status;`);
449
+ lines.push(` switch (result.status) {`);
450
+ for (const resp of emitted) {
451
+ lines.push(` case ${resp.statusCode}:`);
452
+ lines.push(...headerSetLines(resp.headers ?? [], ' '));
453
+ if (resp.bodies.length > 0) {
454
+ lines.push(` ctx.type = result.contentType;`);
455
+ lines.push(` ctx.body = result.body;`);
456
+ }
457
+ lines.push(` break;`);
458
+ }
459
+ lines.push(` }`);
380
460
 
381
461
  return lines;
382
462
  }
383
463
 
464
+ /**
465
+ * Render one status as the members of the service-result union — its `contentType`, `body` and
466
+ * `headers`, plus `status` when the operation emits more than one.
467
+ *
468
+ * A status declaring several mimes collapses to a single member with a union of mime literals
469
+ * when the bodies are structurally identical (`image/png` and `image/jpeg` both `binary`).
470
+ * When they differ, it produces one member per mime so `contentType` and `body` stay correlated.
471
+ */
472
+ function renderResponseMembers(
473
+ resp: OpResponseNode,
474
+ options: OpCodegenOptions,
475
+ opts: { includeStatus: boolean; varPrefix: string },
476
+ ): { members: string[]; preludes: string[] } {
477
+ const bodies = resp.bodies;
478
+ const headers = resp.headers ?? [];
479
+ const leading = opts.includeStatus ? [`status: ${resp.statusCode}`] : [];
480
+ const trailing = headers.length > 0 ? [`headers: ${renderHeadersAnnotation(headers, options.modelsWithOutput)}`] : [];
481
+ const preludes: string[] = [];
482
+
483
+ if (bodies.length === 0) {
484
+ return { members: [`{ ${[...leading, ...trailing].join('; ')} }`], preludes };
485
+ }
486
+
487
+ const uniform = bodies.every(b => bodyTypesStructurallyEqual(b.bodyType, bodies[0]!.bodyType));
488
+ if (uniform) {
489
+ const { annotation, prelude } = formatTypeAnnotation(bodies[0]!.bodyType, options.modelsWithOutput, `${opts.varPrefix}Type`);
490
+ if (prelude) preludes.push(prelude);
491
+ const contentType = bodies.map(b => `'${b.contentType}'`).join(' | ');
492
+ return { members: [`{ ${[...leading, `contentType: ${contentType}`, `body: ${annotation}`, ...trailing].join('; ')} }`], preludes };
493
+ }
494
+
495
+ const members = bodies.map((b, i) => {
496
+ const { annotation, prelude } = formatTypeAnnotation(b.bodyType, options.modelsWithOutput, `${opts.varPrefix}Type${i}`);
497
+ if (prelude) preludes.push(prelude);
498
+ return `{ ${[...leading, `contentType: '${b.contentType}'`, `body: ${annotation}`, ...trailing].join('; ')} }`;
499
+ });
500
+ return { members, preludes };
501
+ }
502
+
503
+ function renderHeadersAnnotation(headers: OpResponseHeaderNode[], modelsWithOutput?: Set<string>): string {
504
+ const fields = headers.map(
505
+ h => `${quoteKey(headerNameToProperty(h.name))}${h.optional ? '?' : ''}: ${renderOutputTsType(h.type, modelsWithOutput, 'server')}`,
506
+ );
507
+ return `{ ${fields.join('; ')} }`;
508
+ }
509
+
510
+ /** `ctx.set(...)` calls for a status's declared response headers, guarding the optional ones. */
511
+ function headerSetLines(headers: OpResponseHeaderNode[], indent: string): string[] {
512
+ return headers.map(h => {
513
+ const accessor = `result.headers[${JSON.stringify(headerNameToProperty(h.name))}]`;
514
+ return h.optional
515
+ ? `${indent}if (${accessor} !== undefined) ctx.set('${h.name}', String(${accessor}));`
516
+ : `${indent}ctx.set('${h.name}', String(${accessor}));`;
517
+ });
518
+ }
519
+
384
520
  // ─── Inference helpers ─────────────────────────────────────────────────────
385
521
 
386
522
  /**
@@ -502,9 +638,13 @@ function serverTsScalar(name: ScalarTypeNode['name']): string {
502
638
  }
503
639
  }
504
640
 
505
- function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set<string>): { annotation: string; prelude?: string } {
641
+ /**
642
+ * @param varName Name for the extracted schema variable. Distinct per status and per mime when
643
+ * an operation emits several, so two complex bodies in one handler cannot collide.
644
+ */
645
+ function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set<string>, varName = 'resultType'): { annotation: string; prelude?: string } {
506
646
  if (bodyType.kind === 'array') {
507
- const inner = formatTypeAnnotation(bodyType.item, modelsWithOutput);
647
+ const inner = formatTypeAnnotation(bodyType.item, modelsWithOutput, varName);
508
648
  return { annotation: `${inner.annotation}[]`, prelude: inner.prelude };
509
649
  }
510
650
  if (bodyType.kind === 'ref') {
@@ -515,8 +655,8 @@ function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set
515
655
  // For complex types, extract schema into a variable so the result line stays readable
516
656
  const schema = renderType(bodyType);
517
657
  return {
518
- annotation: 'z.infer<typeof resultType>',
519
- prelude: `const resultType = ${schema};`,
658
+ annotation: `z.infer<typeof ${varName}>`,
659
+ prelude: `const ${varName} = ${schema};`,
520
660
  };
521
661
  }
522
662
 
@@ -634,9 +774,9 @@ function collectTypes(root: OpRootNode, modelsWithInput?: Set<string>, modelsWit
634
774
  }
635
775
  }
636
776
  for (const resp of op.responses) {
637
- if (resp.bodyType) {
638
- collectTypeNodeRefs(resp.bodyType, types);
639
- collectOutputTypeNodeRefs(resp.bodyType, types, modelsWithOutput);
777
+ for (const body of resp.bodies) {
778
+ collectTypeNodeRefs(body.bodyType, types);
779
+ collectOutputTypeNodeRefs(body.bodyType, types, modelsWithOutput);
640
780
  }
641
781
  if (resp.headers) {
642
782
  for (const h of resp.headers) {
@@ -780,48 +920,6 @@ function collectTypeNodeRefs(type: ContractTypeNode, out: Set<string>): void {
780
920
  }
781
921
  }
782
922
 
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
-
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
-
804
- function paramSourceNeedsScalar(source: ParamSource | undefined, name: string): boolean {
805
- if (!source) return false;
806
- if (source.kind === 'ref') return false;
807
- if (source.kind === 'params') return source.nodes.some(p => typeNeedsScalar(p.type, name));
808
- return typeNeedsScalar(source.node, name);
809
- }
810
-
811
- function opNeedsScalar(root: OpRootNode, name: string): boolean {
812
- return root.routes.some(
813
- route =>
814
- paramSourceNeedsScalar(route.params, name) ||
815
- route.operations.some(
816
- op =>
817
- !!op.request?.bodies.some(b => typeNeedsScalar(b.bodyType, name)) ||
818
- op.responses.some(r => r.bodyType && typeNeedsScalar(r.bodyType, name)) ||
819
- paramSourceNeedsScalar(op.query, name) ||
820
- paramSourceNeedsScalar(op.headers, name),
821
- ),
822
- );
823
- }
824
-
825
923
  function collectServices(root: OpRootNode): string[] {
826
924
  const services = new Set<string>();
827
925
  const inferredService = `${deriveBaseName(root.file)}Service`;
@@ -838,32 +936,10 @@ function collectServices(root: OpRootNode): string[] {
838
936
  return [...services].sort();
839
937
  }
840
938
 
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
939
 
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
940
 
854
- function fileNeedsPolicy(root: OpRootNode): boolean {
855
- return root.routes.some(route => route.operations.some(op => resolveSecurity(route, op, root) !== SECURITY_NONE));
856
- }
857
941
 
858
- function fileNeedsSignature(root: OpRootNode): boolean {
859
- return root.routes.some(route => route.operations.some(op => !!op.signature));
860
- }
861
942
 
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
943
 
868
944
  function isValidIdentifier(name: string): boolean {
869
945
  return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);