@specverse/engines 6.7.8 → 6.11.2
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/dist/inference/core/specly-converter.d.ts.map +1 -1
- package/dist/inference/core/specly-converter.js +20 -0
- package/dist/inference/core/specly-converter.js.map +1 -1
- package/dist/inference/index.d.ts.map +1 -1
- package/dist/inference/index.js +72 -22
- package/dist/inference/index.js.map +1 -1
- package/dist/inference/logical/generators/controller-generator.d.ts.map +1 -1
- package/dist/inference/logical/generators/controller-generator.js +26 -4
- package/dist/inference/logical/generators/controller-generator.js.map +1 -1
- package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +50 -15
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +26 -6
- package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +52 -9
- package/dist/libs/instance-factories/services/templates/mongodb-native/step-conventions.js +319 -0
- package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +26 -3
- package/dist/parser/processors/ExecutableProcessor.d.ts.map +1 -1
- package/dist/parser/processors/ExecutableProcessor.js +14 -1
- package/dist/parser/processors/ExecutableProcessor.js.map +1 -1
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +22 -3
- package/dist/realize/index.js.map +1 -1
- package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +80 -21
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +48 -7
- package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +73 -19
- package/libs/instance-factories/services/templates/mongodb-native/step-conventions.ts +423 -0
- package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +60 -6
- package/package.json +3 -2
|
@@ -56,6 +56,7 @@ export default function generateMongoNativeController(context: TemplateContext):
|
|
|
56
56
|
import { ObjectId, type Filter, type Document } from 'mongodb';
|
|
57
57
|
import { getCollection } from '../db/mongoClient.js';
|
|
58
58
|
${hasEventPublishing ? `import { eventBus } from '../events/eventBus.js';` : ''}
|
|
59
|
+
${customActions.needsAiBehaviors ? `import * as aiBehaviors from '../behaviors/${controllerName}.ai.js';` : ''}
|
|
59
60
|
|
|
60
61
|
const COLLECTION_NAME = '${collection}';
|
|
61
62
|
|
|
@@ -275,29 +276,86 @@ function generateDeleteMethod(modelName: string, modelVar: string, collection: s
|
|
|
275
276
|
|
|
276
277
|
interface CustomActionsResult {
|
|
277
278
|
code: string;
|
|
279
|
+
needsAiBehaviors: boolean;
|
|
278
280
|
}
|
|
279
281
|
|
|
280
282
|
/**
|
|
281
|
-
* Custom actions emit
|
|
283
|
+
* Custom actions emit a per-step body using `matchMongoStep`. The same
|
|
284
|
+
* matcher is passed to the AI-behaviors-generator (via realize/index.ts)
|
|
285
|
+
* so both sides accumulate the same `declaredVars` set and produce
|
|
286
|
+
* matching function names + inputs for unmatched steps.
|
|
282
287
|
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
* Real implementation of action bodies is deferred to the MongoDB-native
|
|
289
|
-
* step-conventions library (#43F follow-up — mirror Prisma's
|
|
290
|
-
* step-conventions.ts but emit native-driver collection calls).
|
|
288
|
+
* Per step:
|
|
289
|
+
* - matched → emit conventional native-driver code inline
|
|
290
|
+
* - unmatched → emit `const stepNResult = await aiBehaviors.<funcName>({...});`
|
|
291
|
+
* (the aiBehaviors function comes from the controller's `*.ai.ts`
|
|
292
|
+
* file, generated by AI-behaviors-generator using the SAME matcher).
|
|
291
293
|
*/
|
|
294
|
+
import { matchMongoStep, type MongoStepContext } from './step-conventions.js';
|
|
295
|
+
|
|
292
296
|
function generateCustomActions(controller: any): CustomActionsResult {
|
|
293
297
|
if (!controller.actions || Object.keys(controller.actions).length === 0) {
|
|
294
|
-
return { code: '' };
|
|
298
|
+
return { code: '', needsAiBehaviors: false };
|
|
295
299
|
}
|
|
300
|
+
const CRUD_NAMES = new Set(['create', 'retrieve', 'retrieveAll', 'update', 'evolve', 'delete', 'validate']);
|
|
301
|
+
const modelName = controller.model || (controller.name || '').replace(/Controller$/, '') || 'Model';
|
|
302
|
+
const collectionName = modelName.toLowerCase() + 's';
|
|
296
303
|
const out: string[] = [];
|
|
304
|
+
let needsAiBehaviors = false;
|
|
297
305
|
for (const [actionName, action] of Object.entries<any>(controller.actions)) {
|
|
298
|
-
|
|
299
|
-
|
|
306
|
+
if (CRUD_NAMES.has(actionName)) continue;
|
|
307
|
+
const steps: any[] = Array.isArray(action.steps) ? action.steps : [];
|
|
308
|
+
const stepsHeader = steps.length > 0
|
|
309
|
+
? steps.map((s: any) => ` * - ${typeof s === 'string' ? s : (s.action || JSON.stringify(s))}`).join('\n')
|
|
300
310
|
: ' * (no spec steps declared)';
|
|
311
|
+
|
|
312
|
+
const declaredVars = new Set<string>();
|
|
313
|
+
const stepBodies: string[] = [];
|
|
314
|
+
let usesArgs = false;
|
|
315
|
+
let actionRefersToAi = false;
|
|
316
|
+
steps.forEach((rawStep: any, i: number) => {
|
|
317
|
+
const stepText = typeof rawStep === 'string' ? rawStep : (rawStep?.step || rawStep?.action);
|
|
318
|
+
if (typeof stepText !== 'string') {
|
|
319
|
+
stepBodies.push(` // Step ${i + 1}: (non-string step ignored)`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const ctx: MongoStepContext = {
|
|
323
|
+
modelName,
|
|
324
|
+
collectionName,
|
|
325
|
+
serviceName: controller.name || 'Controller',
|
|
326
|
+
operationName: actionName,
|
|
327
|
+
stepNum: i + 1,
|
|
328
|
+
parameterNames: Object.keys(action.parameters || {}),
|
|
329
|
+
declaredVars,
|
|
330
|
+
};
|
|
331
|
+
const result = matchMongoStep(stepText, ctx);
|
|
332
|
+
stepBodies.push(result.call);
|
|
333
|
+
if (/\bargs\./.test(result.call)) usesArgs = true;
|
|
334
|
+
if (!result.matched) actionRefersToAi = true;
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
if (actionRefersToAi) needsAiBehaviors = true;
|
|
338
|
+
const argsParam = usesArgs ? 'args: any = {}' : '_args: any = {}';
|
|
339
|
+
let combined = stepBodies.join('\n\n');
|
|
340
|
+
// Drop the `const stepNResult =` declaration when no later step
|
|
341
|
+
// references the result. Strict tsc's noUnusedLocals applies to
|
|
342
|
+
// locals regardless of underscore prefix, so the only safe fix is
|
|
343
|
+
// to omit the assignment entirely. The await still fires.
|
|
344
|
+
const stepResultRe = /const\s+(step\d+Result)\s*=/g;
|
|
345
|
+
let mres: RegExpExecArray | null;
|
|
346
|
+
const declared: string[] = [];
|
|
347
|
+
while ((mres = stepResultRe.exec(combined)) !== null) declared.push(mres[1]);
|
|
348
|
+
for (const name of declared) {
|
|
349
|
+
const refCount = (combined.match(new RegExp(`\\b${name}\\b`, 'g')) || []).length;
|
|
350
|
+
if (refCount <= 1) {
|
|
351
|
+
// Only the declaration itself — drop the binding, keep the await.
|
|
352
|
+
combined = combined.replace(new RegExp(`const\\s+${name}\\s*=\\s*`), '');
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const body = steps.length > 0
|
|
356
|
+
? combined + `\n return { success: true };`
|
|
357
|
+
: ` throw new Error('${controller.name || 'Controller'}.${actionName} is not implemented');`;
|
|
358
|
+
|
|
301
359
|
out.push(`
|
|
302
360
|
/**
|
|
303
361
|
* ${actionName}
|
|
@@ -306,14 +364,10 @@ function generateCustomActions(controller: any): CustomActionsResult {
|
|
|
306
364
|
* Spec steps:
|
|
307
365
|
${stepsHeader}
|
|
308
366
|
*/
|
|
309
|
-
public async ${actionName}(
|
|
310
|
-
|
|
311
|
-
// via a mongodb-native step-conventions library (mirror of the prisma
|
|
312
|
-
// one). For now this is a stub so realize completes and the action
|
|
313
|
-
// surface is callable for parity tests.
|
|
314
|
-
throw new Error('${controller.name}.${actionName} is not implemented');
|
|
367
|
+
public async ${actionName}(${argsParam}): Promise<any> {
|
|
368
|
+
${body}
|
|
315
369
|
}
|
|
316
370
|
`);
|
|
317
371
|
}
|
|
318
|
-
return { code: out.join('\n') };
|
|
372
|
+
return { code: out.join('\n'), needsAiBehaviors };
|
|
319
373
|
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step Conventions for MongoDB native driver — mirrors the prisma library's
|
|
3
|
+
* pattern set but emits native-driver collection code instead of `prisma.X`.
|
|
4
|
+
*
|
|
5
|
+
* Each convention maps a natural-language step pattern to a generated code
|
|
6
|
+
* block. Patterns are tried in order; first match wins. Unmatched steps
|
|
7
|
+
* fall through to the AI-behaviors layer (a separate concern).
|
|
8
|
+
*
|
|
9
|
+
* The emitted code assumes the controller's surrounding scope provides:
|
|
10
|
+
* - `getCollection(name)` (from `../db/mongoClient.js`)
|
|
11
|
+
* - `byId(id)` filter helper for `_id`-shaped queries
|
|
12
|
+
* - `eventBus` for `publish`
|
|
13
|
+
* These are imported by the mongo-native controller-generator regardless,
|
|
14
|
+
* so step-emitted code compiles inline.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface MongoStepConvention {
|
|
18
|
+
name: string;
|
|
19
|
+
pattern: RegExp;
|
|
20
|
+
generateCall: (match: RegExpMatchArray, ctx: MongoStepContext) => string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MongoStepContext {
|
|
24
|
+
/** The model the action lives on (e.g. 'Player' for PlayerController). */
|
|
25
|
+
modelName: string;
|
|
26
|
+
/** Collection name for the model (lowercased + 's' or storage.collection). */
|
|
27
|
+
collectionName: string;
|
|
28
|
+
serviceName: string;
|
|
29
|
+
operationName: string;
|
|
30
|
+
stepNum: number;
|
|
31
|
+
/** Parameter names from the action (so we know what's in `args`). */
|
|
32
|
+
parameterNames?: string[];
|
|
33
|
+
/** Variables already declared (to avoid redeclaration). */
|
|
34
|
+
declaredVars?: Set<string>;
|
|
35
|
+
/** Named result variable from spec's `as:` clause. */
|
|
36
|
+
resultName?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toVar(name: string): string {
|
|
40
|
+
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toCollection(modelName: string): string {
|
|
44
|
+
return modelName.toLowerCase() + 's';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Camel-case a step's text into a TS identifier. Identical algorithm to
|
|
48
|
+
* prisma's step-conventions `toMethod` so AI-behavior function names align
|
|
49
|
+
* across the orm-agnostic generator. */
|
|
50
|
+
function toMethod(words: string): string {
|
|
51
|
+
const cleaned = words.trim().replace(/[^A-Za-z0-9\s]+/g, ' ');
|
|
52
|
+
const camel = cleaned.replace(/\s+(.)/g, (_, c) => c.toUpperCase()).replace(/^\w/, (c) => c.toLowerCase());
|
|
53
|
+
const safe = camel.replace(/[^A-Za-z0-9_$]/g, '');
|
|
54
|
+
return safe || 'unnamedStep';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Resolve a value reference in step text to a TS expression. */
|
|
58
|
+
function resolveValue(rawValue: string, ctx: MongoStepContext): string {
|
|
59
|
+
const value = rawValue.trim().replace(/^['"]|['"]$/g, '');
|
|
60
|
+
if (/^(current\s*time|now|timestamp)$/i.test(value)) return 'new Date().toISOString()';
|
|
61
|
+
if (value === 'true' || value === 'false') return value;
|
|
62
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return value;
|
|
63
|
+
|
|
64
|
+
const declared = ctx.declaredVars || new Set();
|
|
65
|
+
const params = ctx.parameterNames || [];
|
|
66
|
+
|
|
67
|
+
const rootMatch = value.match(/^([a-zA-Z_][a-zA-Z0-9_]*)(\.[a-zA-Z0-9_.]+)?$/);
|
|
68
|
+
if (rootMatch) {
|
|
69
|
+
const root = rootMatch[1];
|
|
70
|
+
if (declared.has(root) || params.includes(root)) return value;
|
|
71
|
+
if (params.length > 0) return `args.${value}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Fallback: quoted string literal so generated code stays valid TS
|
|
75
|
+
if (/\s/.test(value)) return `/* TODO: resolve "${value}" */ null`;
|
|
76
|
+
return `'${value.replace(/'/g, "\\'")}'`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const MONGO_STEP_CONVENTIONS: MongoStepConvention[] = [
|
|
80
|
+
// --- Find / Lookup by single field ---
|
|
81
|
+
// Matches: "Look up X by Y", "Find X by Y", "Find X by Y or fail with 404"
|
|
82
|
+
{
|
|
83
|
+
name: 'find-by-field',
|
|
84
|
+
pattern: /^(?:look\s+up|find|fetch|get)\s+(\w+)\s+by\s+(\w+)(?:\s+or\s+fail.*)?$/i,
|
|
85
|
+
generateCall: (m, ctx) => {
|
|
86
|
+
const model = m[1];
|
|
87
|
+
const field = m[2];
|
|
88
|
+
const modelVar = toVar(model);
|
|
89
|
+
const collection = toCollection(model);
|
|
90
|
+
const params = ctx.parameterNames || [];
|
|
91
|
+
const declared = ctx.declaredVars || new Set();
|
|
92
|
+
const idVal = field === 'id'
|
|
93
|
+
? (params.includes(`${modelVar}Id`) ? `args.${modelVar}Id` : 'args.id')
|
|
94
|
+
: (params.includes(field) ? `args.${field}` : `args.${field}`);
|
|
95
|
+
|
|
96
|
+
if (declared.has(modelVar)) {
|
|
97
|
+
return ` // Step ${ctx.stepNum}: Find ${model} by ${field} (already loaded)`;
|
|
98
|
+
}
|
|
99
|
+
declared.add(modelVar);
|
|
100
|
+
const failOnMissing = /or\s+fail/i.test(m[0]);
|
|
101
|
+
return ` // Step ${ctx.stepNum}: Find ${model} by ${field}
|
|
102
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
103
|
+
const ${modelVar} = await ${modelVar}_collection.findOne({ ${field}: ${idVal} });${failOnMissing ? `
|
|
104
|
+
if (!${modelVar}) throw new Error('${model} not found');` : ''}`;
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// --- Find by composite (two fields) ---
|
|
109
|
+
// Matches: "Look up X by Y and Z", "Find X by Y and Z"
|
|
110
|
+
{
|
|
111
|
+
name: 'find-by-composite',
|
|
112
|
+
pattern: /^(?:look\s+up|find|fetch|get)(?:\s+existing)?\s+(\w+)\s+by\s+(\w+)\s+and\s+(\w+)$/i,
|
|
113
|
+
generateCall: (m, ctx) => {
|
|
114
|
+
const model = m[1];
|
|
115
|
+
const f1 = m[2];
|
|
116
|
+
const f2 = m[3];
|
|
117
|
+
const modelVar = toVar(model);
|
|
118
|
+
const collection = toCollection(model);
|
|
119
|
+
const declared = ctx.declaredVars || new Set();
|
|
120
|
+
if (declared.has(modelVar)) {
|
|
121
|
+
return ` // Step ${ctx.stepNum}: Find ${model} by ${f1} and ${f2} (already loaded)`;
|
|
122
|
+
}
|
|
123
|
+
declared.add(modelVar);
|
|
124
|
+
return ` // Step ${ctx.stepNum}: Find ${model} by ${f1} and ${f2}
|
|
125
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
126
|
+
const ${modelVar} = await ${modelVar}_collection.findOne({ ${f1}: args.${f1}, ${f2}: args.${f2} });`;
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// --- Create model record ---
|
|
131
|
+
// Matches: "Create X", "Create new X with ..."
|
|
132
|
+
{
|
|
133
|
+
name: 'create',
|
|
134
|
+
pattern: /^create\s+(?:new\s+)?(\w+)(?:\s+(?:with\s+)?(.+))?/i,
|
|
135
|
+
generateCall: (m, ctx) => {
|
|
136
|
+
const model = m[1];
|
|
137
|
+
const modelVar = toVar(model);
|
|
138
|
+
const collection = toCollection(model);
|
|
139
|
+
const params = ctx.parameterNames || [];
|
|
140
|
+
const declared = ctx.declaredVars || new Set();
|
|
141
|
+
declared.add(modelVar);
|
|
142
|
+
const dataExpr = params.length > 0 ? `{ ${params.join(', ')} }` : 'args';
|
|
143
|
+
return ` // Step ${ctx.stepNum}: Create ${model}
|
|
144
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
145
|
+
const ${modelVar}_inserted = await ${modelVar}_collection.insertOne(${dataExpr});
|
|
146
|
+
const ${modelVar} = { _id: ${modelVar}_inserted.insertedId, ...${dataExpr} };`;
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
// --- Update specific field on previously-loaded model ---
|
|
151
|
+
// Matches: "Update X field to value". Only fires when X has been
|
|
152
|
+
// declared by a prior matched find — otherwise the model var doesn't
|
|
153
|
+
// exist in scope and tsc fails. Falling through to the unmatched path
|
|
154
|
+
// lets the AI layer handle it.
|
|
155
|
+
{
|
|
156
|
+
name: 'update-field',
|
|
157
|
+
pattern: /^update\s+(\w+)\s+(\w+)\s+to\s+(.+)/i,
|
|
158
|
+
generateCall: (m, ctx) => {
|
|
159
|
+
const model = m[1];
|
|
160
|
+
const field = m[2];
|
|
161
|
+
const rawValue = m[3];
|
|
162
|
+
const modelVar = toVar(model);
|
|
163
|
+
if (!ctx.declaredVars?.has(modelVar)) return ''; // signal "not really matched"
|
|
164
|
+
const collection = toCollection(model);
|
|
165
|
+
const val = resolveValue(rawValue, ctx);
|
|
166
|
+
return ` // Step ${ctx.stepNum}: Update ${model}.${field} to ${rawValue.trim()}
|
|
167
|
+
{
|
|
168
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
169
|
+
await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { ${field}: ${val} } });
|
|
170
|
+
}`;
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
// --- Update field timestamp (e.g. "Update device lastLoginAt timestamp") ---
|
|
175
|
+
{
|
|
176
|
+
name: 'update-field-timestamp',
|
|
177
|
+
pattern: /^update\s+(\w+)\s+(\w+)\s+timestamp$/i,
|
|
178
|
+
generateCall: (m, ctx) => {
|
|
179
|
+
const model = m[1];
|
|
180
|
+
const field = m[2];
|
|
181
|
+
const modelVar = toVar(model);
|
|
182
|
+
if (!ctx.declaredVars?.has(modelVar)) return '';
|
|
183
|
+
const collection = toCollection(model);
|
|
184
|
+
return ` // Step ${ctx.stepNum}: Update ${model}.${field} timestamp
|
|
185
|
+
{
|
|
186
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
187
|
+
await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { ${field}: new Date().toISOString() } });
|
|
188
|
+
}`;
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
// --- Generic "Update X" (writes args back to record) ---
|
|
193
|
+
{
|
|
194
|
+
name: 'update',
|
|
195
|
+
pattern: /^update\s+(\w+)(?:\s+(.+))?$/i,
|
|
196
|
+
generateCall: (m, ctx) => {
|
|
197
|
+
const model = m[1];
|
|
198
|
+
const modelVar = toVar(model);
|
|
199
|
+
if (!ctx.declaredVars?.has(modelVar)) return '';
|
|
200
|
+
const collection = toCollection(model);
|
|
201
|
+
return ` // Step ${ctx.stepNum}: Update ${model}
|
|
202
|
+
{
|
|
203
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
204
|
+
await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: args });
|
|
205
|
+
}`;
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
// --- Delete model record ---
|
|
210
|
+
{
|
|
211
|
+
name: 'delete',
|
|
212
|
+
pattern: /^delete\s+(\w+)/i,
|
|
213
|
+
generateCall: (m, ctx) => {
|
|
214
|
+
const model = m[1];
|
|
215
|
+
const modelVar = toVar(model);
|
|
216
|
+
if (!ctx.declaredVars?.has(modelVar)) return '';
|
|
217
|
+
const collection = toCollection(model);
|
|
218
|
+
return ` // Step ${ctx.stepNum}: Delete ${model}
|
|
219
|
+
{
|
|
220
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
221
|
+
await ${modelVar}_collection.deleteOne({ _id: ${modelVar}._id });
|
|
222
|
+
}`;
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
// --- Transition to lifecycle state ---
|
|
227
|
+
// Matches: "Transition X to state"
|
|
228
|
+
{
|
|
229
|
+
name: 'transition',
|
|
230
|
+
pattern: /^transition\s+(\w+)\s+to\s+(\w+)/i,
|
|
231
|
+
generateCall: (m, ctx) => {
|
|
232
|
+
const model = m[1];
|
|
233
|
+
const state = m[2];
|
|
234
|
+
const modelVar = toVar(model);
|
|
235
|
+
if (!ctx.declaredVars?.has(modelVar)) return '';
|
|
236
|
+
const collection = toCollection(model);
|
|
237
|
+
return ` // Step ${ctx.stepNum}: Transition ${model} to ${state}
|
|
238
|
+
if ((${modelVar} as any).status === '${state}') throw new Error('${model} is already ${state}');
|
|
239
|
+
{
|
|
240
|
+
const ${modelVar}_collection = await getCollection('${collection}');
|
|
241
|
+
await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { status: '${state}' } });
|
|
242
|
+
}`;
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
// --- Set field to value (on the controller's primary model) ---
|
|
247
|
+
{
|
|
248
|
+
name: 'set',
|
|
249
|
+
pattern: /^set\s+(\w+)\s+to\s+(.+)/i,
|
|
250
|
+
generateCall: (m, ctx) => {
|
|
251
|
+
const field = m[1];
|
|
252
|
+
const rawValue = m[2];
|
|
253
|
+
const modelVar = toVar(ctx.modelName);
|
|
254
|
+
const val = resolveValue(rawValue, ctx);
|
|
255
|
+
return ` // Step ${ctx.stepNum}: Set ${field} to ${rawValue.trim()}
|
|
256
|
+
{
|
|
257
|
+
const ${modelVar}_collection = await getCollection(COLLECTION_NAME);
|
|
258
|
+
await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { ${field}: ${val} } });
|
|
259
|
+
}`;
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
// --- Increment / Decrement ---
|
|
264
|
+
{
|
|
265
|
+
name: 'increment',
|
|
266
|
+
pattern: /^increment\s+(\w+)\s+by\s+(\w+)/i,
|
|
267
|
+
generateCall: (m, ctx) => {
|
|
268
|
+
const field = m[1];
|
|
269
|
+
const amount = m[2];
|
|
270
|
+
const modelVar = toVar(ctx.modelName);
|
|
271
|
+
return ` // Step ${ctx.stepNum}: Increment ${field} by ${amount}
|
|
272
|
+
{
|
|
273
|
+
const ${modelVar}_collection = await getCollection(COLLECTION_NAME);
|
|
274
|
+
await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $inc: { ${field}: ${amount} } });
|
|
275
|
+
}`;
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: 'decrement',
|
|
280
|
+
pattern: /^decrement\s+(\w+)\s+by\s+(\w+)/i,
|
|
281
|
+
generateCall: (m, ctx) => {
|
|
282
|
+
const field = m[1];
|
|
283
|
+
const amount = m[2];
|
|
284
|
+
const modelVar = toVar(ctx.modelName);
|
|
285
|
+
return ` // Step ${ctx.stepNum}: Decrement ${field} by ${amount}
|
|
286
|
+
{
|
|
287
|
+
const ${modelVar}_collection = await getCollection(COLLECTION_NAME);
|
|
288
|
+
await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $inc: { ${field}: -${amount} } });
|
|
289
|
+
}`;
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
// --- Send/Emit/Publish event ---
|
|
294
|
+
// Emits an eventBus.publish call. The payload references the controller's
|
|
295
|
+
// primary model variable IF it was declared by a prior matched step;
|
|
296
|
+
// otherwise the payload is just operation + timestamp (the event still
|
|
297
|
+
// fires, just without a record-level id).
|
|
298
|
+
{
|
|
299
|
+
name: 'send-event',
|
|
300
|
+
pattern: /^(?:send|emit|publish)\s+(\w+)\s+event/i,
|
|
301
|
+
generateCall: (m, ctx) => {
|
|
302
|
+
const event = m[1];
|
|
303
|
+
const modelVar = toVar(ctx.modelName);
|
|
304
|
+
const hasModelVar = ctx.declaredVars?.has(modelVar);
|
|
305
|
+
const payload = hasModelVar
|
|
306
|
+
? `{ ${modelVar}Id: (${modelVar} as any)?._id, operation: '${ctx.operationName}', timestamp: new Date().toISOString() }`
|
|
307
|
+
: `{ operation: '${ctx.operationName}', timestamp: new Date().toISOString() }`;
|
|
308
|
+
return ` // Step ${ctx.stepNum}: Emit ${event} event
|
|
309
|
+
await eventBus.publish('${event}', ${payload} as any);`;
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
// --- Call service ---
|
|
314
|
+
{
|
|
315
|
+
name: 'call-service',
|
|
316
|
+
pattern: /^call\s+(\w+)\.(\w+)/i,
|
|
317
|
+
generateCall: (m, ctx) => {
|
|
318
|
+
const service = m[1];
|
|
319
|
+
const method = m[2];
|
|
320
|
+
const args = (ctx.parameterNames || []).join(', ');
|
|
321
|
+
return ` // Step ${ctx.stepNum}: Call ${service}.${method}
|
|
322
|
+
await (${toVar(service)} as any).${method}({ ${args} });`;
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
// --- Return ---
|
|
327
|
+
// Only matches when the returned value is a single declared identifier
|
|
328
|
+
// OR when the natural-language reference resolves to "the model" (e.g.
|
|
329
|
+
// "Return the user", "Return updated game"). Multi-word phrases like
|
|
330
|
+
// "Return state to caller" don't translate to valid JS, so they fall
|
|
331
|
+
// through to the unmatched-step path and emit a `// TODO:` line.
|
|
332
|
+
{
|
|
333
|
+
name: 'return-model',
|
|
334
|
+
pattern: /^return\s+(?:the\s+|updated\s+|created\s+)?(\w+)\s*$/i,
|
|
335
|
+
generateCall: (m, ctx) => {
|
|
336
|
+
const valueRaw = m[1].trim();
|
|
337
|
+
const declared = ctx.declaredVars || new Set();
|
|
338
|
+
const params = ctx.parameterNames || [];
|
|
339
|
+
const modelVar = toVar(ctx.modelName);
|
|
340
|
+
// Resolve to a declared variable, parameter, or the controller's
|
|
341
|
+
// primary model variable. Anything else doesn't have a JS expression
|
|
342
|
+
// — fall through (return unmatched so the TODO path fires).
|
|
343
|
+
if (valueRaw.toLowerCase() === ctx.modelName.toLowerCase() || valueRaw === modelVar) {
|
|
344
|
+
return ` // Step ${ctx.stepNum}: Return ${ctx.modelName}
|
|
345
|
+
return ${modelVar};`;
|
|
346
|
+
}
|
|
347
|
+
if (declared.has(valueRaw)) {
|
|
348
|
+
return ` // Step ${ctx.stepNum}: Return ${valueRaw}
|
|
349
|
+
return ${valueRaw};`;
|
|
350
|
+
}
|
|
351
|
+
if (params.includes(valueRaw)) {
|
|
352
|
+
return ` // Step ${ctx.stepNum}: Return ${valueRaw}
|
|
353
|
+
return args.${valueRaw};`;
|
|
354
|
+
}
|
|
355
|
+
// Unresolvable — emit a TODO comment + neutral return so generated
|
|
356
|
+
// code still compiles. Caller can replace.
|
|
357
|
+
return ` // Step ${ctx.stepNum}: Return ${valueRaw} — TODO: resolve binding
|
|
358
|
+
return null;`;
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Match a step against the mongo-native conventions.
|
|
365
|
+
*
|
|
366
|
+
* Matched: returns the inline native-driver code to emit AND advances the
|
|
367
|
+
* convention's bookkeeping (e.g. find/create add the model variable to
|
|
368
|
+
* declaredVars).
|
|
369
|
+
*
|
|
370
|
+
* Unmatched: returns the function name + inputs + resultVar that an
|
|
371
|
+
* AI-behaviors layer should produce for this step. The shape mirrors
|
|
372
|
+
* prisma's step-conventions matchStep so the orm-agnostic
|
|
373
|
+
* AI-behaviors-generator can drive either side without branching.
|
|
374
|
+
*/
|
|
375
|
+
export function matchMongoStep(
|
|
376
|
+
step: string,
|
|
377
|
+
ctx: MongoStepContext,
|
|
378
|
+
): {
|
|
379
|
+
matched: boolean;
|
|
380
|
+
call: string;
|
|
381
|
+
helperMethod?: string;
|
|
382
|
+
functionName?: string;
|
|
383
|
+
inputs?: string[];
|
|
384
|
+
resultVar?: string;
|
|
385
|
+
} {
|
|
386
|
+
for (const convention of MONGO_STEP_CONVENTIONS) {
|
|
387
|
+
const m = step.match(convention.pattern);
|
|
388
|
+
if (m) {
|
|
389
|
+
const call = convention.generateCall(m, ctx);
|
|
390
|
+
// A convention may signal "regex matched but I can't safely emit"
|
|
391
|
+
// by returning the empty string (e.g. update-field needs the model
|
|
392
|
+
// to be declared by a prior find; if it isn't, fall through to AI).
|
|
393
|
+
if (call) {
|
|
394
|
+
return { matched: true, call };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Unmatched — caller is expected to delegate to an AI-generated pure
|
|
400
|
+
// function in `behaviors/<Owner>.ai.ts`. Inputs = action parameters +
|
|
401
|
+
// any variables already declared by prior matched conventions; this
|
|
402
|
+
// matches what the AI-behaviors-generator will produce for the same
|
|
403
|
+
// step (it walks the same conventions in the same order, so its
|
|
404
|
+
// declaredVars set agrees with ours by construction).
|
|
405
|
+
const functionName = toMethod(step);
|
|
406
|
+
const declared = Array.from(ctx.declaredVars || []);
|
|
407
|
+
const paramNames = ctx.parameterNames || [];
|
|
408
|
+
const inputs = [...paramNames, ...declared];
|
|
409
|
+
const resultVar = ctx.resultName || `step${ctx.stepNum}Result`;
|
|
410
|
+
if (ctx.declaredVars) ctx.declaredVars.add(resultVar);
|
|
411
|
+
const inputObj = inputs.length > 0
|
|
412
|
+
? `{ ${inputs.map((n) => paramNames.includes(n) ? `${n}: args.${n}` : n).join(', ')} }`
|
|
413
|
+
: '{}';
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
matched: false,
|
|
417
|
+
call: ` // Step ${ctx.stepNum}: ${step} [AI-generated — pure function]
|
|
418
|
+
const ${resultVar} = await aiBehaviors.${functionName}(${inputObj});`,
|
|
419
|
+
functionName,
|
|
420
|
+
inputs,
|
|
421
|
+
resultVar,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
@@ -173,10 +173,33 @@ function cacheWrite(key: string, body: string): void {
|
|
|
173
173
|
*
|
|
174
174
|
* This is async — it calls the AI engine to generate function bodies.
|
|
175
175
|
*/
|
|
176
|
-
|
|
176
|
+
/** Optional matcher for orm-agnostic step matching. When present, this is
|
|
177
|
+
* used in place of the prisma library's `matchStep` so that the unmatched
|
|
178
|
+
* functions' names + inputs are computed by the same conventions library
|
|
179
|
+
* the consuming controller is using. Without this, mongo-native (or any
|
|
180
|
+
* non-prisma) controllers would diverge: they'd emit `aiBehaviors.X` calls
|
|
181
|
+
* with one inputs set while this generator would emit a function with a
|
|
182
|
+
* different signature. */
|
|
183
|
+
type StepMatcher = (step: string, ctx: any) => {
|
|
184
|
+
matched: boolean;
|
|
185
|
+
call?: string;
|
|
186
|
+
helperMethod?: string;
|
|
187
|
+
functionName?: string;
|
|
188
|
+
inputs?: string[];
|
|
189
|
+
resultVar?: string;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export default async function generateAiBehaviors(
|
|
193
|
+
context: TemplateContext,
|
|
194
|
+
matcher?: StepMatcher,
|
|
195
|
+
): Promise<string> {
|
|
177
196
|
const { controller, model } = context;
|
|
178
197
|
if (!controller?.actions) return '';
|
|
179
198
|
|
|
199
|
+
// Default matcher = prisma's matchStep for back-compat. Mongo-native
|
|
200
|
+
// (and any future ORM) passes its own matcher.
|
|
201
|
+
const stepMatcher: StepMatcher = matcher || (matchStep as any);
|
|
202
|
+
|
|
180
203
|
const modelName = model?.name || controller.model || 'Model';
|
|
181
204
|
const modelVar = modelName.charAt(0).toLowerCase() + modelName.slice(1);
|
|
182
205
|
// List of all entity-model names available in the realized output. Forwarded
|
|
@@ -234,7 +257,7 @@ export default async function generateAiBehaviors(context: TemplateContext): Pro
|
|
|
234
257
|
resultName: stepAs,
|
|
235
258
|
};
|
|
236
259
|
|
|
237
|
-
const result =
|
|
260
|
+
const result = stepMatcher(stepText, ctx);
|
|
238
261
|
if (!result.matched && result.functionName) {
|
|
239
262
|
// Avoid duplicate function definitions
|
|
240
263
|
const existing = unmatchedFunctions.find(f => f.functionName === result.functionName);
|
|
@@ -322,13 +345,44 @@ export async function generateAiBehaviorsFile(opts: {
|
|
|
322
345
|
/** Strip string/template/comment content so identifier-reference checks
|
|
323
346
|
* don't match `reward` inside a kebab-case string like `'reward-not-granted'`
|
|
324
347
|
* or `// reward stays as text`. We replace the literal contents with
|
|
325
|
-
* spaces so positions stay roughly aligned in case of debugging.
|
|
348
|
+
* spaces so positions stay roughly aligned in case of debugging.
|
|
349
|
+
*
|
|
350
|
+
* Critical: template-string `${...}` interpolations are EXPRESSIONS, not
|
|
351
|
+
* literal text — they really do reference `input.X` etc. Strip only the
|
|
352
|
+
* non-interpolation segments of the template so the expression content
|
|
353
|
+
* survives the regex check. */
|
|
326
354
|
const stripLiteralsAndComments = (src: string): string => {
|
|
327
|
-
|
|
355
|
+
let out = src
|
|
328
356
|
.replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length))
|
|
329
357
|
.replace(/\/\/[^\n]*/g, (m) => ' '.repeat(m.length))
|
|
330
|
-
.replace(/(['"])(?:\\.|(?!\1).)*\1/g, (m) => m[0] + ' '.repeat(m.length - 2) + m[0])
|
|
331
|
-
|
|
358
|
+
.replace(/(['"])(?:\\.|(?!\1).)*\1/g, (m) => m[0] + ' '.repeat(m.length - 2) + m[0]);
|
|
359
|
+
// Strip text INSIDE backticks but preserve `${...}` expressions.
|
|
360
|
+
// We rebuild template contents by replacing literal-text spans
|
|
361
|
+
// (everything between `\`` and `${`, between `}` and `${`, or
|
|
362
|
+
// between `}` and the closing `\``) with spaces.
|
|
363
|
+
out = out.replace(/`((?:\\.|\$\{[^}]*\}|(?!`).)*)`/g, (full, content) => {
|
|
364
|
+
// Walk the content, copy `${...}` segments verbatim, replace other
|
|
365
|
+
// characters with spaces.
|
|
366
|
+
let result = '`';
|
|
367
|
+
let i = 0;
|
|
368
|
+
while (i < content.length) {
|
|
369
|
+
if (content[i] === '\\' && i + 1 < content.length) {
|
|
370
|
+
result += ' '; i += 2; continue;
|
|
371
|
+
}
|
|
372
|
+
if (content[i] === '$' && content[i + 1] === '{') {
|
|
373
|
+
// copy interpolation verbatim including the `${...}` markers
|
|
374
|
+
const end = content.indexOf('}', i);
|
|
375
|
+
const slice = end >= 0 ? content.slice(i, end + 1) : content.slice(i);
|
|
376
|
+
result += slice;
|
|
377
|
+
i += slice.length;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
result += ' ';
|
|
381
|
+
i++;
|
|
382
|
+
}
|
|
383
|
+
return result + '`';
|
|
384
|
+
});
|
|
385
|
+
return out;
|
|
332
386
|
};
|
|
333
387
|
|
|
334
388
|
/** Detect whether the body manages its own input access (so adding our
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specverse/engines",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.11.2",
|
|
4
4
|
"description": "SpecVerse toolchain — parser, inference, realize, generators, AI, registry, bundles",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
"@ai-sdk/openai-compatible": "^2.0.41",
|
|
63
63
|
"@ai-sdk/provider": "^3.0.8",
|
|
64
64
|
"@specverse/assets": "^1.6.0",
|
|
65
|
-
"@specverse/
|
|
65
|
+
"@specverse/engines": "^6.8.7",
|
|
66
|
+
"@specverse/entities": "^5.2.2",
|
|
66
67
|
"@specverse/runtime": "^5.0.1",
|
|
67
68
|
"@specverse/types": "^5.1.0",
|
|
68
69
|
"ai": "^6.0.168",
|