@contractkit/plugin-typescript 0.30.0 → 0.31.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
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.24.0"
29
+ "@contractkit/core": "0.26.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,11 +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
- typeNeedsScalar,
9
18
  modeToWrapper,
10
19
  } from './codegen-contract.js';
11
20
  import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted } from './ts-render.js';
@@ -140,27 +149,6 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
140
149
  const services = collectServices(root);
141
150
  const routerName = deriveRouterName(root.file);
142
151
 
143
- const helpers: string[] = [];
144
- if (opNeedsScalar(root, 'binary')) {
145
- helpers.push(`const _ZodBinary = z.custom<Buffer>((val) => Buffer.isBuffer(val), { error: 'Must be binary data' });`);
146
- }
147
- if (opNeedsScalar(root, 'datetime')) {
148
- helpers.push(
149
- `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' }));`,
150
- );
151
- }
152
- if (opNeedsScalar(root, 'interval')) {
153
- helpers.push(
154
- `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()!);`,
155
- );
156
- }
157
- if (opNeedsScalar(root, 'json')) {
158
- helpers.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
159
- helpers.push(
160
- `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)]));`,
161
- );
162
- }
163
-
164
152
  const lines: string[] = [];
165
153
 
166
154
  lines.push('');
@@ -180,9 +168,40 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
180
168
  }
181
169
  }
182
170
 
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.
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);
178
+
179
+ const helpers: string[] = [];
180
+ if (references('_ZodBinary')) {
181
+ helpers.push(`const _ZodBinary = z.custom<Buffer>((val) => Buffer.isBuffer(val), { error: 'Must be binary data' });`);
182
+ }
183
+ if (references('_ZodDatetime')) {
184
+ helpers.push(
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' }));`,
186
+ );
187
+ }
188
+ if (references('_ZodInterval')) {
189
+ helpers.push(
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()!);`,
191
+ );
192
+ }
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')) {
197
+ helpers.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
198
+ }
199
+ if (needsZodJson) {
200
+ helpers.push(
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)]));`,
202
+ );
203
+ }
204
+
186
205
  const generated = [...(helpers.length ? ['', ...helpers] : []), ...lines].join('\n');
187
206
  const uses = (symbol: string) => new RegExp(`\\b${symbol}\\b`).test(generated);
188
207
 
@@ -327,66 +346,177 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
327
346
  }
328
347
  }
329
348
 
330
- // Service call use the first response with a body as the primary response
331
- 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);
332
352
  const serviceParts = inferService(op, route, file);
333
- 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 ?? [];
334
383
  const hasRespHeaders = respHeaders.length > 0;
335
- const headersAnnotation = hasRespHeaders
336
- ? `{ ${respHeaders
337
- .map(
338
- h =>
339
- `${quoteKey(headerNameToProperty(h.name))}${h.optional ? '?' : ''}: ${renderOutputTsType(h.type, options.modelsWithOutput, 'server')}`,
340
- )
341
- .join('; ')} }`
342
- : '';
343
-
344
- if (primaryResponse?.bodyType) {
345
- const { annotation, prelude } = formatTypeAnnotation(primaryResponse.bodyType!, options.modelsWithOutput);
346
- if (prelude) {
347
- lines.push(` ${prelude}`);
348
- }
349
- 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});`);
350
390
  if (hasRespHeaders) {
351
- lines.push(
352
- ` const result: { body: ${annotation}; headers: ${headersAnnotation} } = await service.${serviceParts.methodName}(${buildArgs(route, op)});`,
353
- );
391
+ lines.push(` const result: { body: ${annotation}; headers: ${headersAnnotation} } = ${call};`);
354
392
  } else {
355
- lines.push(` const result: ${annotation} = await service.${serviceParts.methodName}(${buildArgs(route, op)});`);
393
+ lines.push(` const result: ${annotation} = ${call};`);
356
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};`);
357
400
  } else {
358
- lines.push(` const service = ctx.container.get(${serviceParts.className});`);
401
+ lines.push(` const service = ctx.container.get(${className});`);
359
402
  if (hasRespHeaders) {
360
- lines.push(` const result: { headers: ${headersAnnotation} } = await service.${serviceParts.methodName}(${buildArgs(route, op)});`);
403
+ lines.push(` const result: { headers: ${headersAnnotation} } = ${call};`);
361
404
  } else {
362
- lines.push(` await service.${serviceParts.methodName}(${buildArgs(route, op)});`);
405
+ lines.push(` ${call};`);
363
406
  }
364
407
  }
365
408
 
366
409
  lines.push('');
367
- 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, ' '));
368
414
 
369
- if (hasRespHeaders) {
370
- for (const h of respHeaders) {
371
- const accessor = `result.headers[${JSON.stringify(headerNameToProperty(h.name))}]`;
372
- if (h.optional) {
373
- lines.push(` if (${accessor} !== undefined) ctx.set('${h.name}', String(${accessor}));`);
374
- } else {
375
- lines.push(` ctx.set('${h.name}', String(${accessor}));`);
376
- }
377
- }
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;`);
378
421
  }
379
422
 
380
- if (primaryResponse?.bodyType && primaryResponse.contentType) {
381
- lines.push(` ctx.type = '${primaryResponse.contentType}';`);
382
- 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);
383
440
  }
384
441
 
385
- 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(` }`);
386
460
 
387
461
  return lines;
388
462
  }
389
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
+
390
520
  // ─── Inference helpers ─────────────────────────────────────────────────────
391
521
 
392
522
  /**
@@ -508,9 +638,13 @@ function serverTsScalar(name: ScalarTypeNode['name']): string {
508
638
  }
509
639
  }
510
640
 
511
- 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 } {
512
646
  if (bodyType.kind === 'array') {
513
- const inner = formatTypeAnnotation(bodyType.item, modelsWithOutput);
647
+ const inner = formatTypeAnnotation(bodyType.item, modelsWithOutput, varName);
514
648
  return { annotation: `${inner.annotation}[]`, prelude: inner.prelude };
515
649
  }
516
650
  if (bodyType.kind === 'ref') {
@@ -521,8 +655,8 @@ function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set
521
655
  // For complex types, extract schema into a variable so the result line stays readable
522
656
  const schema = renderType(bodyType);
523
657
  return {
524
- annotation: 'z.infer<typeof resultType>',
525
- prelude: `const resultType = ${schema};`,
658
+ annotation: `z.infer<typeof ${varName}>`,
659
+ prelude: `const ${varName} = ${schema};`,
526
660
  };
527
661
  }
528
662
 
@@ -640,9 +774,9 @@ function collectTypes(root: OpRootNode, modelsWithInput?: Set<string>, modelsWit
640
774
  }
641
775
  }
642
776
  for (const resp of op.responses) {
643
- if (resp.bodyType) {
644
- collectTypeNodeRefs(resp.bodyType, types);
645
- collectOutputTypeNodeRefs(resp.bodyType, types, modelsWithOutput);
777
+ for (const body of resp.bodies) {
778
+ collectTypeNodeRefs(body.bodyType, types);
779
+ collectOutputTypeNodeRefs(body.bodyType, types, modelsWithOutput);
646
780
  }
647
781
  if (resp.headers) {
648
782
  for (const h of resp.headers) {
@@ -786,29 +920,6 @@ function collectTypeNodeRefs(type: ContractTypeNode, out: Set<string>): void {
786
920
  }
787
921
  }
788
922
 
789
-
790
-
791
- function paramSourceNeedsScalar(source: ParamSource | undefined, name: string): boolean {
792
- if (!source) return false;
793
- if (source.kind === 'ref') return false;
794
- if (source.kind === 'params') return source.nodes.some(p => typeNeedsScalar(p.type, name));
795
- return typeNeedsScalar(source.node, name);
796
- }
797
-
798
- function opNeedsScalar(root: OpRootNode, name: string): boolean {
799
- return root.routes.some(
800
- route =>
801
- paramSourceNeedsScalar(route.params, name) ||
802
- route.operations.some(
803
- op =>
804
- !!op.request?.bodies.some(b => typeNeedsScalar(b.bodyType, name)) ||
805
- op.responses.some(r => r.bodyType && typeNeedsScalar(r.bodyType, name)) ||
806
- paramSourceNeedsScalar(op.query, name) ||
807
- paramSourceNeedsScalar(op.headers, name),
808
- ),
809
- );
810
- }
811
-
812
923
  function collectServices(root: OpRootNode): string[] {
813
924
  const services = new Set<string>();
814
925
  const inferredService = `${deriveBaseName(root.file)}Service`;