@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.
Files changed (26) hide show
  1. package/dist/inference/core/specly-converter.d.ts.map +1 -1
  2. package/dist/inference/core/specly-converter.js +20 -0
  3. package/dist/inference/core/specly-converter.js.map +1 -1
  4. package/dist/inference/index.d.ts.map +1 -1
  5. package/dist/inference/index.js +72 -22
  6. package/dist/inference/index.js.map +1 -1
  7. package/dist/inference/logical/generators/controller-generator.d.ts.map +1 -1
  8. package/dist/inference/logical/generators/controller-generator.js +26 -4
  9. package/dist/inference/logical/generators/controller-generator.js.map +1 -1
  10. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +50 -15
  11. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +26 -6
  12. package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +52 -9
  13. package/dist/libs/instance-factories/services/templates/mongodb-native/step-conventions.js +319 -0
  14. package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +26 -3
  15. package/dist/parser/processors/ExecutableProcessor.d.ts.map +1 -1
  16. package/dist/parser/processors/ExecutableProcessor.js +14 -1
  17. package/dist/parser/processors/ExecutableProcessor.js.map +1 -1
  18. package/dist/realize/index.d.ts.map +1 -1
  19. package/dist/realize/index.js +22 -3
  20. package/dist/realize/index.js.map +1 -1
  21. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +80 -21
  22. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +48 -7
  23. package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +73 -19
  24. package/libs/instance-factories/services/templates/mongodb-native/step-conventions.ts +423 -0
  25. package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +60 -6
  26. 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 TODO stubs that throw "not implemented".
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
- * Why not delegate to `aiBehaviors.<actionName>`? Because the AI-behaviors
284
- * generator only emits functions for STEPS that didn't match a convention
285
- * pattern not for actions themselves. So `aiBehaviors.rotate` would not
286
- * exist even when an action named `rotate` is declared on the controller.
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
- const stepsHeader = (action.steps && action.steps.length > 0)
299
- ? action.steps.map((s: any) => ` * - ${typeof s === 'string' ? s : (s.action || JSON.stringify(s))}`).join('\n')
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}(_args: any = {}): Promise<any> {
310
- // TODO (#43F): translate spec steps into native MongoDB driver calls
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
- export default async function generateAiBehaviors(context: TemplateContext): Promise<string> {
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 = matchStep(stepText, ctx);
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
- return src
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
- .replace(/`(?:\\.|\$\{[^}]*\}|(?!`).)*`/g, (m) => '`' + ' '.repeat(m.length - 2) + '`');
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.7.8",
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/entities": "^5.1.0",
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",