@specverse/engines 4.1.12 → 4.1.14
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 +120 -0
- package/dist/ai/behavior-ai-service.d.ts +63 -0
- package/dist/ai/behavior-ai-service.d.ts.map +1 -0
- package/dist/ai/behavior-ai-service.js +203 -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/inference/index.d.ts.map +1 -1
- package/dist/inference/index.js +1 -0
- package/dist/inference/index.js.map +1 -1
- package/dist/inference/logical/logical-engine.d.ts.map +1 -1
- package/dist/inference/logical/logical-engine.js +3 -0
- package/dist/inference/logical/logical-engine.js.map +1 -1
- package/dist/libs/instance-factories/cli/templates/commander/cli-entry-generator.js +12 -11
- package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +21 -5
- package/dist/libs/instance-factories/communication/templates/eventemitter/bus-generator.js +31 -30
- 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 +25 -9
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +20 -2
- package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +141 -0
- package/dist/libs/instance-factories/services/templates/prisma/behavior-generator.js +62 -42
- package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +39 -7
- package/dist/libs/instance-factories/services/templates/prisma/step-conventions.js +101 -84
- package/dist/parser/processors/AttributeProcessor.d.ts.map +1 -1
- package/dist/parser/processors/AttributeProcessor.js +13 -6
- package/dist/parser/processors/AttributeProcessor.js.map +1 -1
- package/dist/parser/processors/ModelProcessor.d.ts.map +1 -1
- package/dist/parser/processors/ModelProcessor.js +7 -0
- package/dist/parser/processors/ModelProcessor.js.map +1 -1
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +54 -0
- package/dist/realize/index.js.map +1 -1
- package/libs/instance-factories/cli/templates/commander/cli-entry-generator.ts +12 -11
- package/libs/instance-factories/cli/templates/commander/command-generator.ts +21 -5
- package/libs/instance-factories/communication/event-emitter.yaml +16 -12
- package/libs/instance-factories/communication/templates/eventemitter/bus-generator.ts +33 -35
- 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 +32 -11
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +23 -2
- package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +211 -0
- package/libs/instance-factories/services/templates/prisma/behavior-generator.ts +86 -40
- package/libs/instance-factories/services/templates/prisma/controller-generator.ts +54 -8
- package/libs/instance-factories/services/templates/prisma/step-conventions.ts +166 -85
- package/package.json +1 -1
|
@@ -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,23 @@ function generateHandlerBody(
|
|
|
276
293
|
});
|
|
277
294
|
}`;
|
|
278
295
|
|
|
279
|
-
default:
|
|
280
|
-
//
|
|
296
|
+
default: {
|
|
297
|
+
// Custom action — pass named parameters from body (and :id from URL if present)
|
|
298
|
+
const paramNames = endpoint?.parameters ? Object.keys(endpoint.parameters) : [];
|
|
299
|
+
const callArgs = paramNames.length > 0
|
|
300
|
+
? paramNames.map(p => `body.${p}`).join(', ')
|
|
301
|
+
: 'body';
|
|
281
302
|
return `try {
|
|
282
|
-
const
|
|
283
|
-
|
|
303
|
+
const body = (request.body || {}) as Record<string, any>;
|
|
304
|
+
const result = await handler.${operation}(${callArgs});
|
|
305
|
+
return reply.send(result || { success: true });
|
|
284
306
|
} catch (error) {
|
|
285
307
|
return reply.status(400).send({
|
|
286
308
|
error: 'Failed to execute ${operation}',
|
|
287
309
|
message: error instanceof Error ? error.message : String(error)
|
|
288
310
|
});
|
|
289
311
|
}`;
|
|
312
|
+
}
|
|
290
313
|
}
|
|
291
314
|
}
|
|
292
315
|
|
|
@@ -322,11 +345,9 @@ function inferHttpMethod(operation: string): string {
|
|
|
322
345
|
* handles custom service operation paths locally.
|
|
323
346
|
*/
|
|
324
347
|
function inferPath(operation: string, endpoint: any): string {
|
|
325
|
-
//
|
|
326
|
-
if (endpoint?.path
|
|
327
|
-
|
|
328
|
-
const lastPart = pathParts[pathParts.length - 1];
|
|
329
|
-
return `/${lastPart}`;
|
|
348
|
+
// If endpoint has an explicit path, use it directly
|
|
349
|
+
if (endpoint?.path) {
|
|
350
|
+
return endpoint.path;
|
|
330
351
|
}
|
|
331
352
|
|
|
332
353
|
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);
|
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generate AI behaviors file for a controller's unmatched steps.
|
|
25
|
+
* Returns empty string if all steps matched conventions (no AI file needed).
|
|
26
|
+
*
|
|
27
|
+
* This is async — it calls the AI engine to generate function bodies.
|
|
28
|
+
*/
|
|
29
|
+
export default async function generateAiBehaviors(context: TemplateContext): Promise<string> {
|
|
30
|
+
const { controller, model } = context;
|
|
31
|
+
if (!controller?.actions) return '';
|
|
32
|
+
|
|
33
|
+
const modelName = model?.name || controller.model || 'Model';
|
|
34
|
+
const modelVar = modelName.charAt(0).toLowerCase() + modelName.slice(1);
|
|
35
|
+
|
|
36
|
+
// Find unmatched steps across all actions.
|
|
37
|
+
// We simulate step execution through matchStep() so declaredVars accumulates
|
|
38
|
+
// exactly as it does in the controller generator — that way the inputs array
|
|
39
|
+
// for each unmatched step matches what will actually be passed at runtime.
|
|
40
|
+
const unmatchedFunctions: Array<{
|
|
41
|
+
functionName: string;
|
|
42
|
+
step: string;
|
|
43
|
+
operationName: string;
|
|
44
|
+
parameterNames: string[];
|
|
45
|
+
inputs: string[];
|
|
46
|
+
}> = [];
|
|
47
|
+
|
|
48
|
+
for (const [actionName, action] of Object.entries(controller.actions) as [string, any][]) {
|
|
49
|
+
const steps = action.steps || [];
|
|
50
|
+
const parameterNames = Object.keys(action.parameters || {});
|
|
51
|
+
const preconditions = action.requires || action.preconditions || [];
|
|
52
|
+
|
|
53
|
+
// Simulate precondition variable declarations
|
|
54
|
+
const declaredVars = new Set<string>();
|
|
55
|
+
for (const pc of preconditions) {
|
|
56
|
+
const match = pc.match(/^(\w+)\s+(?:exists|is\s+\w+)$/i);
|
|
57
|
+
if (match) {
|
|
58
|
+
const entity = match[1];
|
|
59
|
+
declaredVars.add(entity.charAt(0).toLowerCase() + entity.slice(1));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < steps.length; i++) {
|
|
64
|
+
const step = steps[i];
|
|
65
|
+
if (typeof step !== 'string') continue;
|
|
66
|
+
|
|
67
|
+
const ctx: StepContext = {
|
|
68
|
+
modelName,
|
|
69
|
+
prismaModel: modelVar,
|
|
70
|
+
serviceName: `${modelName}Controller`,
|
|
71
|
+
operationName: actionName,
|
|
72
|
+
stepNum: i + 1,
|
|
73
|
+
parameterNames,
|
|
74
|
+
declaredVars,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const result = matchStep(step, ctx);
|
|
78
|
+
if (!result.matched && result.functionName) {
|
|
79
|
+
// Avoid duplicate function definitions, but capture the widest set of inputs
|
|
80
|
+
const existing = unmatchedFunctions.find(f => f.functionName === result.functionName);
|
|
81
|
+
if (!existing) {
|
|
82
|
+
unmatchedFunctions.push({
|
|
83
|
+
functionName: result.functionName,
|
|
84
|
+
step,
|
|
85
|
+
operationName: actionName,
|
|
86
|
+
parameterNames,
|
|
87
|
+
inputs: result.inputs || [],
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (unmatchedFunctions.length === 0) return '';
|
|
95
|
+
|
|
96
|
+
// Start a Claude session for this controller — all unmatched functions share
|
|
97
|
+
// the conversation context, so Claude builds understanding of the spec
|
|
98
|
+
// across calls (mirrors app-demo's session-based AI service)
|
|
99
|
+
let aiService: any = null;
|
|
100
|
+
try {
|
|
101
|
+
const { BehaviorAIService } = await import('@specverse/engines/ai');
|
|
102
|
+
aiService = new BehaviorAIService();
|
|
103
|
+
if (!aiService.isAvailable) {
|
|
104
|
+
aiService = null;
|
|
105
|
+
} else {
|
|
106
|
+
aiService.startSession(`${modelName}Controller`);
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
aiService = null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const availableModels = (context.spec?.models ? Object.keys(context.spec.models) : []) as string[];
|
|
113
|
+
|
|
114
|
+
// Generate function bodies sequentially — same Claude session for all
|
|
115
|
+
const functions: string[] = [];
|
|
116
|
+
for (const { functionName, step, operationName, parameterNames, inputs } of unmatchedFunctions) {
|
|
117
|
+
// Pure function signature: all inputs as a typed destructured object
|
|
118
|
+
const signature = inputs.length > 0
|
|
119
|
+
? `input: { ${inputs.map(n => `${n}: any`).join('; ')} }`
|
|
120
|
+
: 'input: Record<string, never>';
|
|
121
|
+
|
|
122
|
+
// Inside the body, destructure for readability
|
|
123
|
+
const destructure = inputs.length > 0
|
|
124
|
+
? ` const { ${inputs.join(', ')} } = input;`
|
|
125
|
+
: '';
|
|
126
|
+
|
|
127
|
+
let body: string | null = null;
|
|
128
|
+
let source = 'STUB';
|
|
129
|
+
|
|
130
|
+
if (aiService) {
|
|
131
|
+
try {
|
|
132
|
+
body = await aiService.generateBehavior({
|
|
133
|
+
step,
|
|
134
|
+
modelName,
|
|
135
|
+
operationName,
|
|
136
|
+
functionName,
|
|
137
|
+
parameterNames: inputs, // the actual inputs to the pure function
|
|
138
|
+
availableModels,
|
|
139
|
+
spec: context.spec,
|
|
140
|
+
});
|
|
141
|
+
if (body) source = 'AI-GENERATED';
|
|
142
|
+
} catch {
|
|
143
|
+
// Fall through to stub
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!body) {
|
|
148
|
+
body = ` throw new Error('Not implemented: ${functionName} — see behaviors/${modelName}Controller.ai.ts');`;
|
|
149
|
+
} else {
|
|
150
|
+
// Indent body to match function scope
|
|
151
|
+
body = body.split('\n').map(line => line ? ' ' + line : line).join('\n');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const inputsDoc = inputs.length > 0
|
|
155
|
+
? ` * Inputs: ${inputs.join(', ')}\n`
|
|
156
|
+
: '';
|
|
157
|
+
|
|
158
|
+
functions.push(`/**
|
|
159
|
+
* ${functionName}
|
|
160
|
+
*
|
|
161
|
+
* Spec step: "${step}"
|
|
162
|
+
* Called by: ${modelName}Controller.${operationName}()
|
|
163
|
+
${inputsDoc} * Source: ${source}
|
|
164
|
+
* Generated: ${new Date().toISOString().split('T')[0]}
|
|
165
|
+
*
|
|
166
|
+
* PURE FUNCTION — no database access, no event publishing, no external services.
|
|
167
|
+
* All data comes in via \`input\`; all effects happen in the calling controller.
|
|
168
|
+
* ${source === 'AI-GENERATED'
|
|
169
|
+
? 'AI-generated implementation. Review and test before deploying.'
|
|
170
|
+
: 'STUB — Claude CLI unavailable. Install Claude Code or implement manually.'}
|
|
171
|
+
*/
|
|
172
|
+
export async function ${functionName}(${signature}): Promise<any> {
|
|
173
|
+
${destructure ? destructure + '\n' : ''}${body}
|
|
174
|
+
}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// End the session
|
|
178
|
+
if (aiService?.endSession) aiService.endSession();
|
|
179
|
+
|
|
180
|
+
return `/**
|
|
181
|
+
* ${modelName}Controller — AI-Generated Behaviors
|
|
182
|
+
*
|
|
183
|
+
* ⚠️ THIS FILE CONTAINS STUBS FOR STEPS THAT NEED IMPLEMENTATION
|
|
184
|
+
*
|
|
185
|
+
* These functions could not be generated from convention patterns.
|
|
186
|
+
* They are called by ${modelName}Controller when executing custom actions.
|
|
187
|
+
*
|
|
188
|
+
* Options for each function:
|
|
189
|
+
* - Implement manually (recommended for business-critical logic)
|
|
190
|
+
* - Use AI generation: specverse ai generate <function>
|
|
191
|
+
* - Refactor the spec step to use a convention pattern
|
|
192
|
+
*
|
|
193
|
+
* Convention patterns that ARE auto-generated (no AI needed):
|
|
194
|
+
* "Find {Model} by {field}" → prisma.model.findUniqueOrThrow(...)
|
|
195
|
+
* "Create {Model}" → prisma.model.create(...)
|
|
196
|
+
* "Update {Model} {field} to {value}" → prisma.model.update(...)
|
|
197
|
+
* "Delete {Model}" → prisma.model.delete(...)
|
|
198
|
+
* "Transition {Model} to {state}" → prisma.model.update({ status: ... })
|
|
199
|
+
* "Count {Model}s per {Group}" → prisma.model.groupBy(...)
|
|
200
|
+
* See step-conventions.ts for the full list.
|
|
201
|
+
*
|
|
202
|
+
* Generated: ${new Date().toISOString().split('T')[0]}
|
|
203
|
+
*/
|
|
204
|
+
|
|
205
|
+
import { PrismaClient } from '@prisma/client';
|
|
206
|
+
|
|
207
|
+
const prisma = new PrismaClient();
|
|
208
|
+
|
|
209
|
+
${functions.join('\n\n')}
|
|
210
|
+
`;
|
|
211
|
+
}
|
|
@@ -24,6 +24,8 @@ export interface BehaviorContext {
|
|
|
24
24
|
serviceName: string;
|
|
25
25
|
operationName: string;
|
|
26
26
|
prismaModel?: string;
|
|
27
|
+
/** Parameter names from the action definition (e.g., ['pollId', 'optionId', 'voterName']) */
|
|
28
|
+
parameterNames?: string[];
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export interface BehaviorMetadata {
|
|
@@ -43,6 +45,14 @@ export interface OperationMetadata {
|
|
|
43
45
|
export interface BehaviorResult {
|
|
44
46
|
body: string;
|
|
45
47
|
helperMethods: string[];
|
|
48
|
+
/** Steps that couldn't be matched by conventions — need AI generation */
|
|
49
|
+
unmatchedSteps: Array<{
|
|
50
|
+
step: string;
|
|
51
|
+
functionName: string;
|
|
52
|
+
operationName: string;
|
|
53
|
+
/** Names of variables passed as the typed input object */
|
|
54
|
+
inputs: string[];
|
|
55
|
+
}>;
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
/**
|
|
@@ -71,15 +81,18 @@ export function generateBehaviorWithHelpers(
|
|
|
71
81
|
const parts: string[] = [];
|
|
72
82
|
const helperMethods: string[] = [];
|
|
73
83
|
|
|
84
|
+
// Track variables declared by preconditions so steps can reuse them
|
|
85
|
+
const preconditionDeclared = new Set<string>();
|
|
86
|
+
|
|
74
87
|
// Precondition guards
|
|
75
88
|
const preconditions = generatePreconditionChecks(
|
|
76
|
-
behavior.preconditions || [], context
|
|
89
|
+
behavior.preconditions || [], context, preconditionDeclared
|
|
77
90
|
);
|
|
78
91
|
if (preconditions) parts.push(preconditions);
|
|
79
92
|
|
|
80
93
|
// Main logic (from steps or inferred from operation semantics)
|
|
81
|
-
const { code, helpers } = generateStepLogic(
|
|
82
|
-
behavior.steps || [], context
|
|
94
|
+
const { code, helpers, unmatchedSteps } = generateStepLogic(
|
|
95
|
+
behavior.steps || [], context, preconditionDeclared
|
|
83
96
|
);
|
|
84
97
|
parts.push(code);
|
|
85
98
|
helperMethods.push(...helpers);
|
|
@@ -92,7 +105,7 @@ export function generateBehaviorWithHelpers(
|
|
|
92
105
|
|
|
93
106
|
// Event publishing
|
|
94
107
|
const events = generateEventPublishing(
|
|
95
|
-
behavior.sideEffects || [], context.operationName
|
|
108
|
+
behavior.sideEffects || [], context.operationName, context.parameterNames
|
|
96
109
|
);
|
|
97
110
|
if (events) parts.push(events);
|
|
98
111
|
|
|
@@ -103,39 +116,56 @@ export function generateBehaviorWithHelpers(
|
|
|
103
116
|
body = generateTransactionWrapper(body, context);
|
|
104
117
|
}
|
|
105
118
|
|
|
106
|
-
return { body, helperMethods };
|
|
119
|
+
return { body, helperMethods, unmatchedSteps };
|
|
107
120
|
}
|
|
108
121
|
|
|
109
122
|
/**
|
|
110
123
|
* Generate precondition checks from natural-language strings.
|
|
124
|
+
* Tracks declared variables to avoid duplicates and reuses fetched entities.
|
|
111
125
|
*/
|
|
112
126
|
export function generatePreconditionChecks(
|
|
113
127
|
preconditions: string[],
|
|
114
|
-
context: BehaviorContext
|
|
128
|
+
context: BehaviorContext,
|
|
129
|
+
declared?: Set<string>
|
|
115
130
|
): string {
|
|
116
131
|
if (preconditions.length === 0) return '';
|
|
117
132
|
|
|
118
|
-
|
|
133
|
+
if (!declared) declared = new Set<string>();
|
|
134
|
+
const checks = preconditions.map(pc => matchPreconditionPattern(pc, context, declared));
|
|
119
135
|
return ` // === PRECONDITIONS ===\n${checks.join('\n')}`;
|
|
120
136
|
}
|
|
121
137
|
|
|
138
|
+
/** Find the parameter name that refers to a model (e.g., 'pollId' for 'Poll') */
|
|
139
|
+
function findIdParam(modelName: string, paramNames: string[]): string {
|
|
140
|
+
const modelVar = modelName.charAt(0).toLowerCase() + modelName.slice(1);
|
|
141
|
+
// Check for modelId (e.g., pollId)
|
|
142
|
+
const idParam = paramNames.find(p => p === `${modelVar}Id`);
|
|
143
|
+
if (idParam) return idParam;
|
|
144
|
+
// Check for just 'id'
|
|
145
|
+
if (paramNames.includes('id')) return 'id';
|
|
146
|
+
return `${modelVar}Id`;
|
|
147
|
+
}
|
|
148
|
+
|
|
122
149
|
function matchPreconditionPattern(
|
|
123
150
|
precondition: string,
|
|
124
|
-
context: BehaviorContext
|
|
151
|
+
context: BehaviorContext,
|
|
152
|
+
declared: Set<string>
|
|
125
153
|
): string {
|
|
126
154
|
const pc = precondition.toLowerCase();
|
|
127
|
-
const
|
|
155
|
+
const params = context.parameterNames || [];
|
|
128
156
|
|
|
129
157
|
// Pattern: "{Model} exists"
|
|
130
158
|
const existsMatch = precondition.match(/^(\w+)\s+exists/i);
|
|
131
159
|
if (existsMatch) {
|
|
132
160
|
const entity = existsMatch[1];
|
|
133
161
|
const entityVar = entity.charAt(0).toLowerCase() + entity.slice(1);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
162
|
+
const idParam = findIdParam(entity, params);
|
|
163
|
+
if (declared.has(entityVar)) {
|
|
164
|
+
// Already fetched — just check it
|
|
165
|
+
return ` // Guard: ${precondition} (already fetched)`;
|
|
166
|
+
}
|
|
167
|
+
declared.add(entityVar);
|
|
168
|
+
return ` const ${entityVar} = await prisma.${entityVar}.findUniqueOrThrow({ where: { id: ${idParam} } });`;
|
|
139
169
|
}
|
|
140
170
|
|
|
141
171
|
// Pattern: "{field} is not empty" / "{field} is required"
|
|
@@ -143,31 +173,22 @@ function matchPreconditionPattern(
|
|
|
143
173
|
const fieldMatch = precondition.match(/^(\w+)\s+is/i);
|
|
144
174
|
if (fieldMatch) {
|
|
145
175
|
const field = fieldMatch[1];
|
|
146
|
-
|
|
147
|
-
if (
|
|
148
|
-
throw new Error('Precondition failed: ${precondition}');
|
|
149
|
-
}`;
|
|
176
|
+
const paramRef = params.includes(field) ? field : `params.${field}`;
|
|
177
|
+
return ` if (!${paramRef}) throw new Error('${field} is required');`;
|
|
150
178
|
}
|
|
151
179
|
}
|
|
152
180
|
|
|
153
181
|
// Pattern: "{field} is valid"
|
|
154
182
|
if (pc.includes('is valid')) {
|
|
155
|
-
return ` //
|
|
156
|
-
const validation = this.validate(params, { operation: '${context.operationName}' });
|
|
157
|
-
if (!validation.valid) {
|
|
158
|
-
throw new Error('Precondition failed: ${precondition} — ' + validation.errors.join(', '));
|
|
159
|
-
}`;
|
|
183
|
+
return ` // TODO: Implement validation: ${precondition}`;
|
|
160
184
|
}
|
|
161
185
|
|
|
162
186
|
// Pattern: "{X} matches {Y}" / "{X} equals {Y}"
|
|
163
187
|
const matchesMatch = precondition.match(/^(\w+)\s+(?:matches|equals)\s+(.+)/i);
|
|
164
188
|
if (matchesMatch) {
|
|
165
|
-
const left = matchesMatch[1];
|
|
166
|
-
const right = matchesMatch[2];
|
|
167
|
-
return `
|
|
168
|
-
if (params.${left.charAt(0).toLowerCase() + left.slice(1)} !== params.${right.charAt(0).toLowerCase() + right.slice(1)}) {
|
|
169
|
-
throw new Error('Precondition failed: ${precondition}');
|
|
170
|
-
}`;
|
|
189
|
+
const left = matchesMatch[1].charAt(0).toLowerCase() + matchesMatch[1].slice(1);
|
|
190
|
+
const right = matchesMatch[2].charAt(0).toLowerCase() + matchesMatch[2].slice(1);
|
|
191
|
+
return ` if (${left} !== ${right}) throw new Error('${matchesMatch[1]} must match ${matchesMatch[2]}');`;
|
|
171
192
|
}
|
|
172
193
|
|
|
173
194
|
// Pattern: "{Model} is {state}"
|
|
@@ -176,16 +197,19 @@ function matchPreconditionPattern(
|
|
|
176
197
|
const model = stateMatch[1];
|
|
177
198
|
const state = stateMatch[2];
|
|
178
199
|
const modelVar = model.charAt(0).toLowerCase() + model.slice(1);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
200
|
+
const idParam = findIdParam(model, params);
|
|
201
|
+
|
|
202
|
+
// If already declared, reuse; otherwise fetch
|
|
203
|
+
if (!declared.has(modelVar)) {
|
|
204
|
+
declared.add(modelVar);
|
|
205
|
+
return ` const ${modelVar} = await prisma.${modelVar}.findUniqueOrThrow({ where: { id: ${idParam} } });
|
|
206
|
+
if (${modelVar}.status !== '${state}') throw new Error('${model} must be ${state}, got ' + ${modelVar}.status);`;
|
|
207
|
+
}
|
|
208
|
+
return ` if (${modelVar}.status !== '${state}') throw new Error('${model} must be ${state}, got ' + ${modelVar}.status);`;
|
|
184
209
|
}
|
|
185
210
|
|
|
186
211
|
// Unrecognized
|
|
187
|
-
return ` //
|
|
188
|
-
// TODO: Implement precondition check`;
|
|
212
|
+
return ` // TODO: Implement precondition: ${precondition}`;
|
|
189
213
|
}
|
|
190
214
|
|
|
191
215
|
/**
|
|
@@ -193,9 +217,14 @@ function matchPreconditionPattern(
|
|
|
193
217
|
*/
|
|
194
218
|
function generateStepLogic(
|
|
195
219
|
steps: string[],
|
|
196
|
-
context: BehaviorContext
|
|
197
|
-
|
|
220
|
+
context: BehaviorContext,
|
|
221
|
+
preconditionDeclared?: Set<string>
|
|
222
|
+
): { code: string; helpers: string[]; unmatchedSteps: Array<{ step: string; functionName: string; operationName: string; inputs: string[] }> } {
|
|
198
223
|
const helpers: string[] = [];
|
|
224
|
+
const unmatchedSteps: Array<{ step: string; functionName: string; operationName: string; inputs: string[] }> = [];
|
|
225
|
+
|
|
226
|
+
// Shared declared variables across steps — carried through from preconditions
|
|
227
|
+
const declaredVars = preconditionDeclared || new Set<string>();
|
|
199
228
|
|
|
200
229
|
if (steps && steps.length > 0) {
|
|
201
230
|
const stepCode = steps.map((step, i) => {
|
|
@@ -209,18 +238,29 @@ function generateStepLogic(
|
|
|
209
238
|
serviceName: context.serviceName,
|
|
210
239
|
operationName: context.operationName,
|
|
211
240
|
stepNum: i + 1,
|
|
241
|
+
parameterNames: context.parameterNames,
|
|
242
|
+
declaredVars,
|
|
212
243
|
};
|
|
213
244
|
|
|
214
245
|
const result = matchStep(step, ctx);
|
|
215
246
|
if (result.helperMethod) {
|
|
216
247
|
helpers.push(result.helperMethod);
|
|
217
248
|
}
|
|
249
|
+
if (!result.matched && result.functionName) {
|
|
250
|
+
unmatchedSteps.push({
|
|
251
|
+
step,
|
|
252
|
+
functionName: result.functionName,
|
|
253
|
+
operationName: context.operationName,
|
|
254
|
+
inputs: result.inputs || [],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
218
257
|
return result.call;
|
|
219
258
|
});
|
|
220
259
|
|
|
221
260
|
return {
|
|
222
261
|
code: ` // === EXECUTE ===\n${stepCode.join('\n\n')}`,
|
|
223
262
|
helpers,
|
|
263
|
+
unmatchedSteps,
|
|
224
264
|
};
|
|
225
265
|
}
|
|
226
266
|
|
|
@@ -228,6 +268,7 @@ function generateStepLogic(
|
|
|
228
268
|
return {
|
|
229
269
|
code: ` // === EXECUTE ===\n${inferLogicFromOperationName(context)}`,
|
|
230
270
|
helpers,
|
|
271
|
+
unmatchedSteps,
|
|
231
272
|
};
|
|
232
273
|
}
|
|
233
274
|
|
|
@@ -280,12 +321,17 @@ ${checks.join('\n')}
|
|
|
280
321
|
*/
|
|
281
322
|
export function generateEventPublishing(
|
|
282
323
|
sideEffects: string[],
|
|
283
|
-
operationName: string
|
|
324
|
+
operationName: string,
|
|
325
|
+
parameterNames?: string[]
|
|
284
326
|
): string {
|
|
285
327
|
if (!sideEffects || sideEffects.length === 0) return '';
|
|
286
328
|
|
|
329
|
+
const paramFields = parameterNames?.length
|
|
330
|
+
? parameterNames.join(', ') + ', '
|
|
331
|
+
: '';
|
|
332
|
+
|
|
287
333
|
const publishes = sideEffects.map(event =>
|
|
288
|
-
`
|
|
334
|
+
` await eventBus.publish('${event}', { ${paramFields}timestamp: new Date().toISOString() });`
|
|
289
335
|
);
|
|
290
336
|
|
|
291
337
|
return ` // === EVENTS ===\n${publishes.join('\n')}`;
|