@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.
- package/assets/prompts/core/standard/v9/behavior.prompt.yaml +126 -0
- package/dist/ai/behavior-ai-service.d.ts +65 -0
- package/dist/ai/behavior-ai-service.d.ts.map +1 -0
- package/dist/ai/behavior-ai-service.js +205 -0
- package/dist/ai/behavior-ai-service.js.map +1 -0
- package/dist/ai/index.d.ts +27 -0
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js +30 -0
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/prompt-loader.js +2 -2
- package/dist/inference/quint-transpiler.d.ts.map +1 -1
- package/dist/inference/quint-transpiler.js +204 -4
- package/dist/inference/quint-transpiler.js.map +1 -1
- package/dist/libs/instance-factories/applications/templates/generic/backend-package-json-generator.js +4 -1
- package/dist/libs/instance-factories/applications/templates/generic/backend-tsconfig-generator.js +2 -2
- package/dist/libs/instance-factories/applications/templates/react/runtime-package-json-generator.js +1 -0
- package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +97 -22
- package/dist/libs/instance-factories/communication/templates/eventemitter/bus-generator.js +31 -31
- package/dist/libs/instance-factories/communication/templates/eventemitter/types-generator.js +79 -0
- package/dist/libs/instance-factories/communication/templates/eventemitter/websocket-bridge-generator.js +96 -0
- package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +45 -9
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +20 -2
- package/dist/libs/instance-factories/scaffolding/templates/generic/tsconfig-generator.js +10 -2
- package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +249 -0
- package/dist/libs/instance-factories/services/templates/prisma/behavior-generator.js +72 -45
- package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +61 -54
- package/dist/libs/instance-factories/services/templates/prisma/service-generator.js +31 -10
- package/dist/libs/instance-factories/services/templates/prisma/step-conventions.js +101 -84
- package/dist/libs/instance-factories/views/templates/react/components-generator.js +40 -10
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +192 -23
- package/dist/realize/index.js.map +1 -1
- package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts +4 -1
- package/libs/instance-factories/applications/templates/generic/backend-tsconfig-generator.ts +2 -2
- package/libs/instance-factories/applications/templates/react/runtime-package-json-generator.ts +6 -1
- package/libs/instance-factories/cli/templates/commander/command-generator.ts +115 -22
- package/libs/instance-factories/communication/event-emitter.yaml +16 -12
- package/libs/instance-factories/communication/templates/eventemitter/bus-generator.ts +33 -36
- package/libs/instance-factories/communication/templates/eventemitter/types-generator.ts +95 -0
- package/libs/instance-factories/communication/templates/eventemitter/websocket-bridge-generator.ts +105 -0
- package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +57 -11
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +23 -2
- package/libs/instance-factories/scaffolding/templates/generic/tsconfig-generator.ts +23 -2
- package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +376 -0
- package/libs/instance-factories/services/templates/prisma/behavior-generator.ts +116 -45
- package/libs/instance-factories/services/templates/prisma/controller-generator.ts +83 -59
- package/libs/instance-factories/services/templates/prisma/service-generator.ts +40 -10
- package/libs/instance-factories/services/templates/prisma/step-conventions.ts +169 -85
- package/libs/instance-factories/views/templates/react/components-generator.ts +50 -10
- package/package.json +1 -1
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/controllers/MCPServerController.js +0 -232
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/events/EventEmitter.js +0 -49
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/index.js +0 -18
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/interfaces/ResourceProvider.js +0 -0
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/models/LibrarySuggestion.js +0 -97
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/models/SpecVerseResource.js +0 -64
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/server/mcp-server.js +0 -182
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/CLIProxyService.js +0 -1210
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/EmbeddedResourcesAdapter.js +0 -172
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/EntityModuleService.js +0 -240
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/HybridResourcesProvider.js +0 -147
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/LibraryToolsService.js +0 -281
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/OrchestratorBridge.js +0 -409
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/OrchestratorToolsService.js +0 -414
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/PromptToolsService.js +0 -467
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/services/ResourcesProviderService.js +0 -135
- package/dist/libs/instance-factories/tools/templates/mcp/static/src/types/index.js +0 -0
- 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
|
-
//
|
|
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
|
|
283
|
-
|
|
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
|
-
//
|
|
326
|
-
if (endpoint?.path
|
|
327
|
-
|
|
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(
|
|
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 &&
|
|
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
|
+
}
|