@specverse/engines 4.1.13 → 4.1.15

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 (68) hide show
  1. package/assets/prompts/core/standard/v9/behavior.prompt.yaml +126 -0
  2. package/dist/ai/behavior-ai-service.d.ts +65 -0
  3. package/dist/ai/behavior-ai-service.d.ts.map +1 -0
  4. package/dist/ai/behavior-ai-service.js +205 -0
  5. package/dist/ai/behavior-ai-service.js.map +1 -0
  6. package/dist/ai/index.d.ts +27 -0
  7. package/dist/ai/index.d.ts.map +1 -1
  8. package/dist/ai/index.js +30 -0
  9. package/dist/ai/index.js.map +1 -1
  10. package/dist/ai/prompt-loader.js +2 -2
  11. package/dist/inference/quint-transpiler.d.ts.map +1 -1
  12. package/dist/inference/quint-transpiler.js +204 -4
  13. package/dist/inference/quint-transpiler.js.map +1 -1
  14. package/dist/libs/instance-factories/applications/templates/generic/backend-package-json-generator.js +4 -1
  15. package/dist/libs/instance-factories/applications/templates/generic/backend-tsconfig-generator.js +2 -2
  16. package/dist/libs/instance-factories/applications/templates/react/runtime-package-json-generator.js +1 -0
  17. package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +97 -22
  18. package/dist/libs/instance-factories/communication/templates/eventemitter/bus-generator.js +31 -31
  19. package/dist/libs/instance-factories/communication/templates/eventemitter/types-generator.js +79 -0
  20. package/dist/libs/instance-factories/communication/templates/eventemitter/websocket-bridge-generator.js +96 -0
  21. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +45 -9
  22. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +20 -2
  23. package/dist/libs/instance-factories/scaffolding/templates/generic/tsconfig-generator.js +10 -2
  24. package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +249 -0
  25. package/dist/libs/instance-factories/services/templates/prisma/behavior-generator.js +72 -45
  26. package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +61 -54
  27. package/dist/libs/instance-factories/services/templates/prisma/service-generator.js +31 -10
  28. package/dist/libs/instance-factories/services/templates/prisma/step-conventions.js +101 -84
  29. package/dist/libs/instance-factories/views/templates/react/components-generator.js +40 -10
  30. package/dist/realize/index.d.ts.map +1 -1
  31. package/dist/realize/index.js +192 -23
  32. package/dist/realize/index.js.map +1 -1
  33. package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts +4 -1
  34. package/libs/instance-factories/applications/templates/generic/backend-tsconfig-generator.ts +2 -2
  35. package/libs/instance-factories/applications/templates/react/runtime-package-json-generator.ts +6 -1
  36. package/libs/instance-factories/cli/templates/commander/command-generator.ts +115 -22
  37. package/libs/instance-factories/communication/event-emitter.yaml +16 -12
  38. package/libs/instance-factories/communication/templates/eventemitter/bus-generator.ts +33 -36
  39. package/libs/instance-factories/communication/templates/eventemitter/types-generator.ts +95 -0
  40. package/libs/instance-factories/communication/templates/eventemitter/websocket-bridge-generator.ts +105 -0
  41. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +57 -11
  42. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +23 -2
  43. package/libs/instance-factories/scaffolding/templates/generic/tsconfig-generator.ts +23 -2
  44. package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +376 -0
  45. package/libs/instance-factories/services/templates/prisma/behavior-generator.ts +116 -45
  46. package/libs/instance-factories/services/templates/prisma/controller-generator.ts +83 -59
  47. package/libs/instance-factories/services/templates/prisma/service-generator.ts +40 -10
  48. package/libs/instance-factories/services/templates/prisma/step-conventions.ts +169 -85
  49. package/libs/instance-factories/views/templates/react/components-generator.ts +50 -10
  50. package/package.json +1 -1
  51. package/dist/libs/instance-factories/tools/templates/mcp/static/src/controllers/MCPServerController.js +0 -232
  52. package/dist/libs/instance-factories/tools/templates/mcp/static/src/events/EventEmitter.js +0 -49
  53. package/dist/libs/instance-factories/tools/templates/mcp/static/src/index.js +0 -18
  54. package/dist/libs/instance-factories/tools/templates/mcp/static/src/interfaces/ResourceProvider.js +0 -0
  55. package/dist/libs/instance-factories/tools/templates/mcp/static/src/models/LibrarySuggestion.js +0 -97
  56. package/dist/libs/instance-factories/tools/templates/mcp/static/src/models/SpecVerseResource.js +0 -64
  57. package/dist/libs/instance-factories/tools/templates/mcp/static/src/server/mcp-server.js +0 -182
  58. package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/CLIProxyService.js +0 -1210
  59. package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/EmbeddedResourcesAdapter.js +0 -172
  60. package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/EntityModuleService.js +0 -240
  61. package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/HybridResourcesProvider.js +0 -147
  62. package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/LibraryToolsService.js +0 -281
  63. package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/OrchestratorBridge.js +0 -409
  64. package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/OrchestratorToolsService.js +0 -414
  65. package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/PromptToolsService.js +0 -467
  66. package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/ResourcesProviderService.js +0 -135
  67. package/dist/libs/instance-factories/tools/templates/mcp/static/src/types/index.js +0 -0
  68. package/dist/libs/instance-factories/tools/templates/vscode/static/extension.js +0 -965
@@ -36,6 +36,22 @@ export default function generateFastifyRoutes(context: TemplateContext): string
36
36
  }
37
37
  }
38
38
 
39
+ // Add custom action endpoints
40
+ if (controller.actions) {
41
+ if (!endpoints) endpoints = [];
42
+ for (const [actionName, action] of Object.entries(controller.actions) as [string, any][]) {
43
+ const params = action.parameters || {};
44
+ const hasIdParam = Object.keys(params).some(p => p === 'id' || p === `${modelName?.charAt(0).toLowerCase()}${modelName?.slice(1)}Id`);
45
+ endpoints.push({
46
+ operation: actionName,
47
+ method: 'POST',
48
+ path: hasIdParam ? `/:id/${actionName}` : `/${actionName}`,
49
+ parameters: params,
50
+ description: action.description || `Custom action: ${actionName}`,
51
+ });
52
+ }
53
+ }
54
+
39
55
  // Generate route handlers for each endpoint
40
56
  if (!endpoints || endpoints.length === 0) {
41
57
  console.warn(`Warning: Controller ${controllerName} has no endpoints. Generating empty routes file.`);
@@ -137,7 +153,7 @@ function generateRouteHandler(
137
153
 
138
154
  const method = endpoint.method?.toLowerCase() || inferHttpMethod(operation);
139
155
  const path = inferPath(operation, endpoint); // Always call inferPath to handle path extraction
140
- const handler = generateHandlerBody(operation, modelName, handlerName, isModelController, implType);
156
+ const handler = generateHandlerBody(operation, modelName, handlerName, isModelController, implType, endpoint);
141
157
 
142
158
  let route = `// ${operation} ${modelName}\n`;
143
159
  route += `fastify.${method}('${path}', {\n`;
@@ -181,7 +197,8 @@ function generateHandlerBody(
181
197
  modelName: string,
182
198
  handlerName: string,
183
199
  isModelController: boolean,
184
- implType: any
200
+ implType: any,
201
+ endpoint?: any
185
202
  ): string {
186
203
  const rawLowerModel = modelName?.toLowerCase() || 'item';
187
204
  // Avoid JavaScript reserved words as variable names
@@ -276,17 +293,48 @@ function generateHandlerBody(
276
293
  });
277
294
  }`;
278
295
 
279
- default:
280
- // For custom actions, generate a handler that calls the action method
296
+ default: {
297
+ // Custom action validate body against declared parameters, then call handler
298
+ const params = endpoint?.parameters || {};
299
+ const paramNames = Object.keys(params);
300
+ const callArgs = paramNames.length > 0
301
+ ? paramNames.map(p => `body.${p}`).join(', ')
302
+ : 'body';
303
+
304
+ // Generate per-parameter validation checks
305
+ const validations: string[] = [];
306
+ for (const [pName, pDef] of Object.entries(params) as [string, any][]) {
307
+ const required = typeof pDef === 'string' ? pDef.includes('required') : pDef?.required;
308
+ const typeStr = typeof pDef === 'string' ? pDef.split(' ')[0] : pDef?.type || 'String';
309
+ if (required) {
310
+ validations.push(` if (body.${pName} === undefined || body.${pName} === null) errors.push({ field: '${pName}', message: '${pName} is required' });`);
311
+ }
312
+ // Type checks
313
+ if (typeStr === 'UUID' || typeStr === 'String' || typeStr === 'Email') {
314
+ validations.push(` if (body.${pName} !== undefined && typeof body.${pName} !== 'string') errors.push({ field: '${pName}', message: '${pName} must be a string' });`);
315
+ } else if (typeStr === 'Integer' || typeStr === 'Number' || typeStr === 'Float') {
316
+ validations.push(` if (body.${pName} !== undefined && typeof body.${pName} !== 'number') errors.push({ field: '${pName}', message: '${pName} must be a number' });`);
317
+ } else if (typeStr === 'Boolean') {
318
+ validations.push(` if (body.${pName} !== undefined && typeof body.${pName} !== 'boolean') errors.push({ field: '${pName}', message: '${pName} must be a boolean' });`);
319
+ }
320
+ }
321
+
322
+ const validationBlock = validations.length > 0
323
+ ? ` const errors: Array<{ field: string; message: string }> = [];\n${validations.join('\n')}\n if (errors.length > 0) return reply.status(400).send({ error: 'Validation failed', details: errors });`
324
+ : '';
325
+
281
326
  return `try {
282
- const result = await handler.${operation}(request.body as any);
283
- return reply.send(result);
327
+ const body = (request.body || {}) as Record<string, any>;
328
+ ${validationBlock}
329
+ const result = await handler.${operation}(${callArgs});
330
+ return reply.send(result || { success: true });
284
331
  } catch (error) {
285
332
  return reply.status(400).send({
286
333
  error: 'Failed to execute ${operation}',
287
334
  message: error instanceof Error ? error.message : String(error)
288
335
  });
289
336
  }`;
337
+ }
290
338
  }
291
339
  }
292
340
 
@@ -322,11 +370,9 @@ function inferHttpMethod(operation: string): string {
322
370
  * handles custom service operation paths locally.
323
371
  */
324
372
  function inferPath(operation: string, endpoint: any): string {
325
- // For custom actions, extract relative path from endpoint.path
326
- if (endpoint?.path && endpoint.serviceOperation?.type === 'custom') {
327
- const pathParts = endpoint.path.split('/').filter((p: string) => p);
328
- const lastPart = pathParts[pathParts.length - 1];
329
- return `/${lastPart}`;
373
+ // If endpoint has an explicit path, use it directly
374
+ if (endpoint?.path) {
375
+ return endpoint.path;
330
376
  }
331
377
 
332
378
  return sharedInferPath(operation);
@@ -24,6 +24,16 @@ export default function generateFastifyServer(context: TemplateContext): string
24
24
  return ` await fastify.register(${name}Routes, { prefix: '${path}', controllers: { ${name}Controller: new (await import('./controllers/${name}Controller.js')).${name}Controller() } });`;
25
25
  }).join('\n');
26
26
 
27
+ // Check for events in spec
28
+ const specEvents = spec.events ? Object.keys(spec.events) : [];
29
+ const hasEvents = specEvents.length > 0 || modelNames.length > 0;
30
+
31
+ // Service imports
32
+ const servicesList = spec.services ? Object.keys(spec.services) : [];
33
+ const serviceImports = servicesList.map((name: string) =>
34
+ `import './${name.charAt(0).toLowerCase() + name.slice(1)}.js'; // Initialize ${name} event subscriptions`
35
+ );
36
+
27
37
  return `/**
28
38
  * Fastify Server
29
39
  * Generated from SpecVerse specification
@@ -32,6 +42,8 @@ export default function generateFastifyServer(context: TemplateContext): string
32
42
  import Fastify from 'fastify';
33
43
  import cors from '@fastify/cors';
34
44
  import { PrismaClient } from '@prisma/client';
45
+ ${hasEvents ? `import { eventBus } from './events/eventBus.js';
46
+ import { registerWebSocketBridge } from './events/websocket-bridge.js';` : ''}
35
47
 
36
48
  // Initialize Prisma
37
49
  export const prisma = new PrismaClient();
@@ -56,13 +68,18 @@ fastify.get('/api/spec', async () => embeddedSpec);
56
68
  fastify.get('/api/runtime/info', async () => ({
57
69
  controllers: ${JSON.stringify(modelNames.map((n: string) => `${n}Controller`))},
58
70
  models: ${JSON.stringify(modelNames)},
59
- events: [],
60
- services: []
71
+ events: ${JSON.stringify(specEvents)},
72
+ services: ${JSON.stringify(servicesList)}
61
73
  }));
62
74
 
75
+ ${hasEvents ? `// Event history endpoint
76
+ fastify.get('/api/events', async () => eventBus.getHistory());` : ''}
77
+
63
78
  // Register routes
64
79
  ${routeImports}
65
80
 
81
+ ${serviceImports.length > 0 ? `// Initialize services (registers event subscriptions)\n${serviceImports.join('\n')}` : ''}
82
+
66
83
  async function registerRoutes() {
67
84
  ${routeRegistrations}
68
85
  }
@@ -71,10 +88,14 @@ ${routeRegistrations}
71
88
  const start = async () => {
72
89
  try {
73
90
  await registerRoutes();
91
+ ${hasEvents ? ` // Register WebSocket bridge for real-time frontend events
92
+ await registerWebSocketBridge(fastify);` : ''}
74
93
  const port = parseInt(process.env.PORT || '3000');
75
94
  await fastify.listen({ port, host: '0.0.0.0' });
76
95
  console.log(\`Server running at http://localhost:\${port}\`);
77
96
  console.log(\`API endpoints: ${modelNames.map((n: string) => `/api/${n.toLowerCase()}s`).join(', ')}\`);
97
+ ${hasEvents ? ` console.log(\`WebSocket: ws://localhost:\${port}/ws\`);
98
+ console.log(\`Events: ${specEvents.join(', ')}\`);` : ''}
78
99
  } catch (err) {
79
100
  fastify.log.error(err);
80
101
  process.exit(1);
@@ -9,12 +9,33 @@ import type { TemplateContext } from '@specverse/types';
9
9
  export default function generateTsConfig(context: TemplateContext): string {
10
10
  const { manifest, spec } = context;
11
11
 
12
+ // Detect layout: monorepo (default) vs standalone. In standalone layouts
13
+ // the scaffolding factory sets `outputStructure: standalone` + `backendDir: "."`
14
+ // so code lives in ./src directly. In monorepo layouts code lives under
15
+ // backend/src/ and frontend/src/, each with its own tsconfig, and the root
16
+ // tsconfig is just a solution-style stub so `tsc --noEmit` at the project
17
+ // root doesn't hit TS18003 ("no inputs").
18
+ const implementationTypes = (context as any).implementationTypes || [];
19
+ const isMonorepo = !implementationTypes.some((it: any) => {
20
+ const cfg = it?.configuration || it?.instanceFactory?.configuration || {};
21
+ return cfg.outputStructure === 'standalone';
22
+ });
23
+
24
+ if (isMonorepo) {
25
+ // In monorepo mode each workspace (backend/, frontend/) owns its own
26
+ // tsconfig. A root tsconfig is actively harmful: tsc --noEmit at the
27
+ // project root either trips TS18003 ("no inputs") on `include: ["src/**/*"]`
28
+ // or TS18002 ("empty files list") on `{"files": []}`. Return an empty
29
+ // string so the realize pipeline treats it as "skip this template".
30
+ return '';
31
+ }
32
+
12
33
  // Extract TypeScript options from implementation types
13
- const mergedOptions = extractTsConfigOptions(context.implementationTypes || []);
34
+ const mergedOptions = extractTsConfigOptions(implementationTypes);
14
35
 
15
36
  // Detect if we're using React - check both implementation types AND if we have views in the spec
16
37
  const hasViews = spec && (spec.views || (Array.isArray(spec.views) && spec.views.length > 0));
17
- const usesReact = hasViews && (context.implementationTypes || []).some((implType: any) =>
38
+ const usesReact = hasViews && implementationTypes.some((implType: any) =>
18
39
  implType.capabilities?.provides?.includes('ui.components') &&
19
40
  implType.technology?.framework === 'react'
20
41
  );
@@ -0,0 +1,376 @@
1
+ /**
2
+ * AI Behaviors Generator
3
+ *
4
+ * Generates files for behavior steps that couldn't be matched
5
+ * by convention patterns. Tries to use the configured AI engine to
6
+ * generate real implementations; falls back to stubs if AI is unavailable.
7
+ *
8
+ * - Clearly separated from convention-generated code (.ai.ts suffix)
9
+ * - Each function header documents the spec step + generation method
10
+ * - Human reviewable and editable
11
+ * - Regeneratable via `specverse ai regenerate` (TODO)
12
+ * - Falls back to stub if AI unavailable or fails
13
+ *
14
+ * Level separation:
15
+ * L1/L2: Convention code → inline in controller (trusted, deterministic)
16
+ * L3: Quint guards → guards.ts (formally verified)
17
+ * L4: AI-generated → behaviors/*.ai.ts (needs human review)
18
+ */
19
+
20
+ import type { TemplateContext } from '@specverse/types';
21
+ import { matchStep, type StepContext } from './step-conventions.js';
22
+ import { createHash } from 'crypto';
23
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
24
+ import { join } from 'path';
25
+
26
+ /**
27
+ * Validate generated TypeScript using esbuild's transform.
28
+ * Returns null if valid, or an error message describing the problem.
29
+ *
30
+ * This catches syntax errors (unbalanced braces, unclosed strings, etc.)
31
+ * but not type errors — those need full tsc which requires the project
32
+ * context. That's a separate validation step run by the realize pipeline.
33
+ */
34
+ async function validateTypeScript(code: string): Promise<string | null> {
35
+ try {
36
+ const esbuild = await import('esbuild');
37
+ await esbuild.transform(code, {
38
+ loader: 'ts',
39
+ format: 'esm',
40
+ target: 'es2022',
41
+ });
42
+ return null;
43
+ } catch (err: any) {
44
+ const msg = err?.errors?.[0]?.text || err?.message || 'unknown syntax error';
45
+ return msg;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * AI output cache — avoids re-calling Claude for unchanged steps.
51
+ *
52
+ * Cache key: sha256(step + modelName + operationName + inputs + promptVersion)
53
+ * Cache location: $SPECVERSE_USER_CWD/.specverse/ai-cache/<hash>.ts
54
+ *
55
+ * Invalidation: automatic — any change to the step text or inputs
56
+ * produces a new hash. The prompt version is part of the hash so
57
+ * prompt upgrades also invalidate.
58
+ */
59
+ const PROMPT_VERSION = '9.1.0';
60
+
61
+ function cacheKey(step: string, modelName: string, operationName: string, functionName: string, inputs: string[]): string {
62
+ const payload = JSON.stringify({ step, modelName, operationName, functionName, inputs: [...inputs].sort(), v: PROMPT_VERSION });
63
+ return createHash('sha256').update(payload).digest('hex').slice(0, 16);
64
+ }
65
+
66
+ function cacheDir(): string {
67
+ const cwd = process.env.SPECVERSE_USER_CWD || process.cwd();
68
+ return join(cwd, '.specverse', 'ai-cache');
69
+ }
70
+
71
+ function cacheRead(key: string): string | null {
72
+ const path = join(cacheDir(), `${key}.ts`);
73
+ if (!existsSync(path)) return null;
74
+ try {
75
+ return readFileSync(path, 'utf8');
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function cacheWrite(key: string, body: string): void {
82
+ const dir = cacheDir();
83
+ try {
84
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
85
+ writeFileSync(join(dir, `${key}.ts`), body, 'utf8');
86
+ } catch (err: any) {
87
+ console.warn(` [ai-cache] write failed: ${err?.message || err}`);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Generate AI behaviors file for a controller's unmatched steps.
93
+ * Returns empty string if all steps matched conventions (no AI file needed).
94
+ *
95
+ * This is async — it calls the AI engine to generate function bodies.
96
+ */
97
+ export default async function generateAiBehaviors(context: TemplateContext): Promise<string> {
98
+ const { controller, model } = context;
99
+ if (!controller?.actions) return '';
100
+
101
+ const modelName = model?.name || controller.model || 'Model';
102
+ const modelVar = modelName.charAt(0).toLowerCase() + modelName.slice(1);
103
+
104
+ // Find unmatched steps across all actions.
105
+ // We simulate step execution through matchStep() so declaredVars accumulates
106
+ // exactly as it does in the controller generator — that way the inputs array
107
+ // for each unmatched step matches what will actually be passed at runtime.
108
+ const unmatchedFunctions: Array<{
109
+ functionName: string;
110
+ step: string;
111
+ operationName: string;
112
+ parameterNames: string[];
113
+ inputs: string[];
114
+ returns?: Record<string, string> | string;
115
+ }> = [];
116
+
117
+ for (const [actionName, action] of Object.entries(controller.actions) as [string, any][]) {
118
+ const steps = action.steps || [];
119
+ const parameterNames = Object.keys(action.parameters || {});
120
+ const preconditions = action.requires || action.preconditions || [];
121
+
122
+ // Simulate precondition variable declarations
123
+ const declaredVars = new Set<string>();
124
+ for (const pc of preconditions) {
125
+ const match = pc.match(/^(\w+)\s+(?:exists|is\s+\w+)$/i);
126
+ if (match) {
127
+ const entity = match[1];
128
+ declaredVars.add(entity.charAt(0).toLowerCase() + entity.slice(1));
129
+ }
130
+ }
131
+
132
+ for (let i = 0; i < steps.length; i++) {
133
+ const stepInput = steps[i];
134
+ // Support both string steps and object steps { step, as, returns }
135
+ const stepText = typeof stepInput === 'string' ? stepInput : stepInput?.step;
136
+ const stepAs = typeof stepInput === 'object' ? stepInput?.as : undefined;
137
+ const stepReturns = typeof stepInput === 'object' ? stepInput?.returns : undefined;
138
+
139
+ if (typeof stepText !== 'string') continue;
140
+
141
+ const ctx: StepContext = {
142
+ modelName,
143
+ prismaModel: modelVar,
144
+ serviceName: `${modelName}Controller`,
145
+ operationName: actionName,
146
+ stepNum: i + 1,
147
+ parameterNames,
148
+ declaredVars,
149
+ resultName: stepAs,
150
+ };
151
+
152
+ const result = matchStep(stepText, ctx);
153
+ if (!result.matched && result.functionName) {
154
+ // Avoid duplicate function definitions
155
+ const existing = unmatchedFunctions.find(f => f.functionName === result.functionName);
156
+ if (!existing) {
157
+ unmatchedFunctions.push({
158
+ functionName: result.functionName,
159
+ step: stepText,
160
+ operationName: actionName,
161
+ parameterNames,
162
+ inputs: result.inputs || [],
163
+ returns: stepReturns,
164
+ });
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ if (unmatchedFunctions.length === 0) return '';
171
+
172
+ return generateAiBehaviorsFile({
173
+ ownerName: `${modelName}Controller`,
174
+ unmatchedFunctions: unmatchedFunctions.map(f => ({
175
+ ...f,
176
+ modelName,
177
+ })),
178
+ availableModels,
179
+ spec: context.spec,
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Generate an AI behaviors file from a pre-collected list of unmatched
185
+ * functions. Used by both the controller generator (via generateAiBehaviors
186
+ * above) and the service generator (via collectUnmatched → this function).
187
+ */
188
+ export async function generateAiBehaviorsFile(opts: {
189
+ ownerName: string;
190
+ unmatchedFunctions: Array<{
191
+ functionName: string;
192
+ step: string;
193
+ operationName: string;
194
+ parameterNames: string[];
195
+ inputs: string[];
196
+ returns?: Record<string, string> | string;
197
+ modelName: string;
198
+ }>;
199
+ availableModels: string[];
200
+ spec?: any;
201
+ }): Promise<string> {
202
+ const { ownerName, unmatchedFunctions, availableModels, spec } = opts;
203
+ if (unmatchedFunctions.length === 0) return '';
204
+
205
+ // Start a Claude session for this owner — all unmatched functions share
206
+ // the conversation context, so Claude builds understanding of the spec
207
+ // across calls (mirrors app-demo's session-based AI service)
208
+ let aiService: any = null;
209
+ try {
210
+ const { BehaviorAIService } = await import('@specverse/engines/ai');
211
+ aiService = new BehaviorAIService();
212
+ if (!aiService.isAvailable) {
213
+ aiService = null;
214
+ } else {
215
+ aiService.startSession(ownerName);
216
+ }
217
+ } catch {
218
+ aiService = null;
219
+ }
220
+
221
+ // Generate function bodies sequentially — same Claude session for all
222
+ const functions: string[] = [];
223
+ let cacheHits = 0;
224
+ let cacheMisses = 0;
225
+ for (const { functionName, step, operationName, parameterNames, inputs, returns, modelName } of unmatchedFunctions) {
226
+ // Pure function signature: all inputs as a typed destructured object
227
+ const signature = inputs.length > 0
228
+ ? `input: { ${inputs.map(n => `${n}: any`).join('; ')} }`
229
+ : 'input: Record<string, never>';
230
+
231
+ // Inside the body, destructure for readability
232
+ const destructure = inputs.length > 0
233
+ ? ` const { ${inputs.join(', ')} } = input;`
234
+ : '';
235
+
236
+ // Build return type from spec declaration (if provided)
237
+ // returns can be:
238
+ // - a string: "number" → Promise<number>
239
+ // - an object: { amount: "number", rate: "number" } → Promise<{ amount: number; rate: number }>
240
+ // - undefined → Promise<any>
241
+ let returnType = 'any';
242
+ if (typeof returns === 'string') {
243
+ returnType = returns;
244
+ } else if (returns && typeof returns === 'object') {
245
+ const fields = Object.entries(returns).map(([k, v]) => `${k}: ${v}`).join('; ');
246
+ returnType = `{ ${fields} }`;
247
+ }
248
+
249
+ // Check cache first — skip Claude if we've generated this exact step before
250
+ const key = cacheKey(step, modelName, operationName, functionName, inputs);
251
+ let body: string | null = cacheRead(key);
252
+ let source: 'AI-CACHED' | 'AI-GENERATED' | 'AI-INVALID' | 'STUB' = body ? 'AI-CACHED' : 'STUB';
253
+ if (body) {
254
+ // Validate cache entry — a previously valid entry may have been
255
+ // corrupted on disk or the validator rules may have changed
256
+ const testCode = `export async function ${functionName}(input: any): Promise<any> {\n${body}\n}`;
257
+ const validationError = await validateTypeScript(testCode);
258
+ if (validationError) {
259
+ console.warn(` [ai-validate] cached ${functionName} failed validation: ${validationError}`);
260
+ body = null; // Force regeneration
261
+ source = 'STUB';
262
+ } else {
263
+ cacheHits++;
264
+ }
265
+ }
266
+
267
+ if (!body && aiService) {
268
+ cacheMisses++;
269
+ try {
270
+ body = await aiService.generateBehavior({
271
+ step,
272
+ modelName,
273
+ operationName,
274
+ functionName,
275
+ parameterNames: inputs, // the actual inputs to the pure function
276
+ availableModels,
277
+ spec,
278
+ returnType, // Pass declared return type to Claude
279
+ });
280
+
281
+ if (body) {
282
+ // Validate the generated body as a standalone function
283
+ const testCode = `export async function ${functionName}(input: any): Promise<any> {\n${body}\n}`;
284
+ const validationError = await validateTypeScript(testCode);
285
+ if (validationError) {
286
+ console.warn(` [ai-validate] ${functionName} has syntax error: ${validationError}`);
287
+ // Don't cache invalid output; treat as failed generation
288
+ body = `// AI-generated code failed validation: ${validationError}\n // Step: ${step}\n throw new Error('AI behavior has invalid syntax — see comment above');`;
289
+ source = 'AI-INVALID';
290
+ } else {
291
+ source = 'AI-GENERATED';
292
+ cacheWrite(key, body);
293
+ }
294
+ }
295
+ } catch {
296
+ // Fall through to stub
297
+ }
298
+ }
299
+
300
+ if (!body) {
301
+ body = ` throw new Error('Not implemented: ${functionName} — see behaviors/${modelName}Controller.ai.ts');`;
302
+ } else {
303
+ // Indent body to match function scope
304
+ body = body.split('\n').map(line => line ? ' ' + line : line).join('\n');
305
+ }
306
+
307
+ const inputsDoc = inputs.length > 0
308
+ ? ` * Inputs: ${inputs.join(', ')}\n`
309
+ : '';
310
+ const returnsDoc = returnType !== 'any'
311
+ ? ` * Returns: ${returnType}\n`
312
+ : '';
313
+
314
+ functions.push(`/**
315
+ * ${functionName}
316
+ *
317
+ * Spec step: "${step}"
318
+ * Called by: ${ownerName}.${operationName}()
319
+ ${inputsDoc}${returnsDoc} * Source: ${source}
320
+ * Generated: ${new Date().toISOString().split('T')[0]}
321
+ *
322
+ * PURE FUNCTION — no database access, no event publishing, no external services.
323
+ * All data comes in via \`input\`; all effects happen in the calling controller.
324
+ * ${source === 'AI-GENERATED'
325
+ ? 'AI-generated implementation. Review and test before deploying.'
326
+ : source === 'AI-CACHED'
327
+ ? 'Restored from AI cache (.specverse/ai-cache/). Delete cache entry to regenerate.'
328
+ : source === 'AI-INVALID'
329
+ ? 'AI returned code with syntax errors — function throws at runtime. Fix or regenerate.'
330
+ : 'STUB — Claude CLI unavailable. Install Claude Code or implement manually.'}
331
+ */
332
+ export async function ${functionName}(${signature}): Promise<${returnType}> {
333
+ ${destructure ? destructure + '\n' : ''}${body}
334
+ }`);
335
+ }
336
+
337
+ // End the session
338
+ if (aiService?.endSession) aiService.endSession();
339
+
340
+ // Report cache stats to stdout — picked up by realize pipeline
341
+ if (cacheHits > 0 || cacheMisses > 0) {
342
+ console.log(` AI cache: ${cacheHits} hit(s), ${cacheMisses} miss(es) for ${ownerName}`);
343
+ }
344
+
345
+ return `/**
346
+ * ${ownerName} — AI-Generated Behaviors
347
+ *
348
+ * ⚠️ THIS FILE CONTAINS STUBS FOR STEPS THAT NEED IMPLEMENTATION
349
+ *
350
+ * These functions could not be generated from convention patterns.
351
+ * They are called by ${ownerName} when executing operations.
352
+ *
353
+ * Options for each function:
354
+ * - Implement manually (recommended for business-critical logic)
355
+ * - Use AI generation: specverse ai generate <function>
356
+ * - Refactor the spec step to use a convention pattern
357
+ *
358
+ * Convention patterns that ARE auto-generated (no AI needed):
359
+ * "Find {Model} by {field}" → prisma.model.findUniqueOrThrow(...)
360
+ * "Create {Model}" → prisma.model.create(...)
361
+ * "Update {Model} {field} to {value}" → prisma.model.update(...)
362
+ * "Delete {Model}" → prisma.model.delete(...)
363
+ * "Transition {Model} to {state}" → prisma.model.update({ status: ... })
364
+ * "Count {Model}s per {Group}" → prisma.model.groupBy(...)
365
+ * See step-conventions.ts for the full list.
366
+ *
367
+ * Generated: ${new Date().toISOString().split('T')[0]}
368
+ */
369
+
370
+ import { PrismaClient } from '@prisma/client';
371
+
372
+ const prisma = new PrismaClient();
373
+
374
+ ${functions.join('\n\n')}
375
+ `;
376
+ }