@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
@@ -21,10 +21,14 @@ function generateFastifyRoutes(context) {
21
21
  for (const [actionName, action] of Object.entries(controller.actions)) {
22
22
  const params = action.parameters || {};
23
23
  const hasIdParam = Object.keys(params).some((p) => p === "id" || p === `${modelName?.charAt(0).toLowerCase()}${modelName?.slice(1)}Id`);
24
+ const explicitPath = action.path;
25
+ const explicitMethod = action.method;
26
+ const isExternal = !!(explicitPath && /^\/api\//.test(explicitPath));
24
27
  endpoints.push({
25
28
  operation: actionName,
26
- method: "POST",
27
- path: hasIdParam ? `/:id/${actionName}` : `/${actionName}`,
29
+ method: explicitMethod || "POST",
30
+ path: explicitPath || (hasIdParam ? `/:id/${actionName}` : `/${actionName}`),
31
+ external: isExternal,
28
32
  parameters: params,
29
33
  description: action.description || `Custom action: ${actionName}`
30
34
  });
@@ -33,9 +37,27 @@ function generateFastifyRoutes(context) {
33
37
  if (!endpoints || endpoints.length === 0) {
34
38
  console.warn(`Warning: Controller ${controllerName} has no endpoints. Generating empty routes file.`);
35
39
  }
36
- const routeHandlers = endpoints?.map((endpoint) => {
37
- return generateRouteHandler(endpoint, modelName, handlerName, isModelController, implType, controllerName);
38
- }).join("\n\n") || "";
40
+ const internalEndpoints = endpoints?.filter((e) => !e.external) || [];
41
+ const externalEndpoints = endpoints?.filter((e) => e.external) || [];
42
+ const internalHandlers = internalEndpoints.map((endpoint) => generateRouteHandler(endpoint, modelName, handlerName, isModelController, implType, controllerName)).join("\n\n");
43
+ const externalHandlers = externalEndpoints.map((endpoint) => generateRouteHandler(endpoint, modelName, handlerName, isModelController, implType, controllerName)).join("\n\n");
44
+ const externalRoutesExport = externalEndpoints.length > 0 ? `
45
+ /**
46
+ * Mount the external (root-prefix) routes for this controller.
47
+ *
48
+ * Called by the generated main.ts at root scope (no prefix) so action.path
49
+ * declarations like '/api/v2/auth/register' land at exactly that URL,
50
+ * bypassing the controller's '/api/<plural>' prefix.
51
+ */
52
+ export async function registerExternalRoutes(
53
+ fastify: FastifyInstance,
54
+ options: any
55
+ ) {
56
+ const handler = ${isModelController ? "options.controllers" : "options.services"}.${handlerName};
57
+
58
+ ${externalHandlers.split("\n").map((line) => " " + line).join("\n")}
59
+ }
60
+ ` : "";
39
61
  return `${imports}
40
62
 
41
63
  /**
@@ -51,9 +73,9 @@ export default async function ${routeName.replace("Controller", "")}Routes(
51
73
  ) {
52
74
  const handler = ${isModelController ? "options.controllers" : "options.services"}.${handlerName};
53
75
 
54
- ${routeHandlers.split("\n").map((line) => " " + line).join("\n")}
76
+ ${internalHandlers.split("\n").map((line) => " " + line).join("\n")}
55
77
  }
56
- `;
78
+ ${externalRoutesExport}`;
57
79
  }
58
80
  function generateImports(controller, modelName, handlerName, isModelController, implType) {
59
81
  const imports = [
@@ -226,36 +248,49 @@ function generateHandlerBody(operation, modelName, handlerName, isModelControlle
226
248
  });
227
249
  }`;
228
250
  default: {
251
+ const path = endpoint?.path || "";
252
+ const urlParamNames = /* @__PURE__ */ new Set();
253
+ const urlParamRegex = /:([A-Za-z_][\w]*)/g;
254
+ let urlMatch;
255
+ while ((urlMatch = urlParamRegex.exec(path)) !== null) {
256
+ urlParamNames.add(urlMatch[1]);
257
+ }
229
258
  const params = endpoint?.parameters || {};
230
- const paramNames = Object.keys(params);
231
- const callArgs = paramNames.length > 0 ? paramNames.map((p) => `body.${p}`).join(", ") : "body";
232
259
  const validations = [];
233
260
  for (const [pName, pDef] of Object.entries(params)) {
261
+ if (urlParamNames.has(pName)) continue;
234
262
  const required = typeof pDef === "string" ? pDef.includes("required") : pDef?.required;
235
263
  const typeStr = typeof pDef === "string" ? pDef.split(" ")[0] : pDef?.type || "String";
236
264
  if (required) {
237
- validations.push(` if (body.${pName} === undefined || body.${pName} === null) errors.push({ field: '${pName}', message: '${pName} is required' });`);
265
+ validations.push(` if (args.${pName} === undefined || args.${pName} === null) errors.push({ field: '${pName}', message: '${pName} is required' });`);
238
266
  }
239
267
  if (typeStr === "UUID" || typeStr === "String" || typeStr === "Email") {
240
- validations.push(` if (body.${pName} !== undefined && typeof body.${pName} !== 'string') errors.push({ field: '${pName}', message: '${pName} must be a string' });`);
268
+ validations.push(` if (args.${pName} !== undefined && typeof args.${pName} !== 'string') errors.push({ field: '${pName}', message: '${pName} must be a string' });`);
241
269
  } else if (typeStr === "Integer" || typeStr === "Number" || typeStr === "Float") {
242
- validations.push(` if (body.${pName} !== undefined && typeof body.${pName} !== 'number') errors.push({ field: '${pName}', message: '${pName} must be a number' });`);
270
+ validations.push(` if (args.${pName} !== undefined && typeof args.${pName} !== 'number') errors.push({ field: '${pName}', message: '${pName} must be a number' });`);
243
271
  } else if (typeStr === "Boolean") {
244
- validations.push(` if (body.${pName} !== undefined && typeof body.${pName} !== 'boolean') errors.push({ field: '${pName}', message: '${pName} must be a boolean' });`);
272
+ validations.push(` if (args.${pName} !== undefined && typeof args.${pName} !== 'boolean') errors.push({ field: '${pName}', message: '${pName} must be a boolean' });`);
245
273
  }
246
274
  }
247
275
  const validationBlock = validations.length > 0 ? ` const errors: Array<{ field: string; message: string }> = [];
248
276
  ${validations.join("\n")}
249
277
  if (errors.length > 0) return reply.status(400).send({ error: 'Validation failed', details: errors });` : "";
250
278
  return `try {
279
+ const params = (request.params || {}) as Record<string, any>;
251
280
  const body = (request.body || {}) as Record<string, any>;
281
+ const args = { ...params, ...body };
252
282
  ${validationBlock}
253
- const result = await handler.${operation}(${callArgs});
283
+ const result = await handler.${operation}(args);
254
284
  return reply.send(result || { success: true });
255
285
  } catch (error) {
286
+ const msg = error instanceof Error ? error.message : String(error);
287
+ // 501 for TODO-stubbed action bodies (#43F); 400 for real failures.
288
+ if (msg.includes('is not implemented')) {
289
+ return reply.status(501).send({ error: 'Not Implemented', message: msg });
290
+ }
256
291
  return reply.status(400).send({
257
292
  error: 'Failed to execute ${operation}',
258
- message: error instanceof Error ? error.message : String(error)
293
+ message: msg
259
294
  });
260
295
  }`;
261
296
  }
@@ -1,15 +1,31 @@
1
1
  import { normalizeSpec, deriveBasePath } from "@specverse/types/spec-rules";
2
+ function resolveOrmName(manifest) {
3
+ if (!manifest) return "PrismaORM";
4
+ const inner = manifest.manifests ? Object.values(manifest.manifests)[0] : manifest;
5
+ if (!inner) return "PrismaORM";
6
+ const caps = Array.isArray(inner.capabilityMappings) ? inner.capabilityMappings : [];
7
+ const ormCap = caps.find((m) => m?.capability === "orm.client") || caps.find((m) => m?.capability === "orm.schema");
8
+ if (ormCap) return ormCap.implementation || ormCap.instanceFactory || "PrismaORM";
9
+ return inner.defaultMappings?.orm || "PrismaORM";
10
+ }
2
11
  function generateFastifyServer(context) {
3
- const { spec, models } = context;
12
+ const { spec, models, manifest } = context;
13
+ const orm = resolveOrmName(manifest);
14
+ const isMongoNative = orm === "MongoDBNativeDriver";
4
15
  const allModels = models || (spec?.models ? Object.values(spec.models) : []);
5
16
  const modelNames = allModels.map((m) => m.name).filter(Boolean);
6
17
  const routeImports = modelNames.map(
7
- (name) => `import ${name}Routes from './routes/${name}Controller.js';`
18
+ (name) => `import ${name}Routes, * as ${name}RoutesNS from './routes/${name}Controller.js';`
8
19
  ).join("\n");
9
20
  const routeRegistrations = modelNames.map((name) => {
10
21
  const path = deriveBasePath(name);
11
22
  return ` await fastify.register(${name}Routes, { prefix: '${path}', controllers: { ${name}Controller: new (await import('./controllers/${name}Controller.js')).${name}Controller() } });`;
12
23
  }).join("\n");
24
+ const externalRouteRegistrations = modelNames.map((name) => {
25
+ return ` if (typeof (${name}RoutesNS as any).registerExternalRoutes === 'function') {
26
+ await (${name}RoutesNS as any).registerExternalRoutes(fastify, { controllers: { ${name}Controller: new (await import('./controllers/${name}Controller.js')).${name}Controller() } });
27
+ }`;
28
+ }).join("\n");
13
29
  const specEvents = spec.events ? Object.keys(spec.events) : [];
14
30
  const hasEvents = specEvents.length > 0 || modelNames.length > 0;
15
31
  const servicesList = spec.services ? Object.keys(spec.services) : [];
@@ -52,12 +68,13 @@ import { fileURLToPath } from 'url';
52
68
 
53
69
  import Fastify from 'fastify';
54
70
  import cors from '@fastify/cors';
55
- import { PrismaClient } from '@prisma/client';
71
+ ${isMongoNative ? `import { disconnect as disconnectMongo } from './db/mongoClient.js';` : `import { PrismaClient } from '@prisma/client';`}
56
72
  ${hasEvents ? `import { eventBus } from './events/eventBus.js';
57
73
  import { registerWebSocketBridge } from './events/websocket-bridge.js';` : ""}
58
74
 
59
- // Initialize Prisma
60
- export const prisma = new PrismaClient();
75
+ ${isMongoNative ? `// MongoDB native driver client lives in db/mongoClient.ts; nothing
76
+ // to initialize at module scope (lazy-connect on first getCollection).` : `// Initialize Prisma
77
+ export const prisma = new PrismaClient();`}
61
78
 
62
79
  // Initialize Fastify
63
80
  const fastify = Fastify({
@@ -99,6 +116,9 @@ ${serviceRouteImports}` : ""}
99
116
  async function registerRoutes() {
100
117
  ${routeRegistrations}
101
118
  ${serviceRouteRegistrations ? "\n // Service operation routes (RPC-style under /api/services/{ServiceName})\n" + serviceRouteRegistrations : ""}
119
+
120
+ // External-prefix actions (action.path declarations like '/api/v2/auth/register')
121
+ ${externalRouteRegistrations}
102
122
  }
103
123
 
104
124
  // Start server
@@ -127,7 +147,7 @@ ${hasEvents ? ` console.log(\`WebSocket: ws://localhost:\${port}/ws\`);
127
147
  try {
128
148
  fastify.log.info(\`Received \${signal}, closing server...\`);
129
149
  await fastify.close();
130
- try { await (prisma as any).$disconnect?.(); } catch { /* ignore */ }
150
+ try { ${isMongoNative ? "await disconnectMongo();" : "await (prisma as any).$disconnect?.();"} } catch { /* ignore */ }
131
151
  process.exit(0);
132
152
  } catch (err) {
133
153
  fastify.log.error({ err }, 'Error during shutdown');
@@ -24,6 +24,7 @@ function generateMongoNativeController(context) {
24
24
  import { ObjectId, type Filter, type Document } from 'mongodb';
25
25
  import { getCollection } from '../db/mongoClient.js';
26
26
  ${hasEventPublishing ? `import { eventBus } from '../events/eventBus.js';` : ""}
27
+ ${customActions.needsAiBehaviors ? `import * as aiBehaviors from '../behaviors/${controllerName}.ai.js';` : ""}
27
28
 
28
29
  const COLLECTION_NAME = '${collection}';
29
30
 
@@ -221,13 +222,59 @@ function generateDeleteMethod(modelName, modelVar, collection) {
221
222
  }
222
223
  `;
223
224
  }
225
+ import { matchMongoStep } from "./step-conventions.js";
224
226
  function generateCustomActions(controller) {
225
227
  if (!controller.actions || Object.keys(controller.actions).length === 0) {
226
- return { code: "" };
228
+ return { code: "", needsAiBehaviors: false };
227
229
  }
230
+ const CRUD_NAMES = /* @__PURE__ */ new Set(["create", "retrieve", "retrieveAll", "update", "evolve", "delete", "validate"]);
231
+ const modelName = controller.model || (controller.name || "").replace(/Controller$/, "") || "Model";
232
+ const collectionName2 = modelName.toLowerCase() + "s";
228
233
  const out = [];
234
+ let needsAiBehaviors = false;
229
235
  for (const [actionName, action] of Object.entries(controller.actions)) {
230
- const stepsHeader = action.steps && action.steps.length > 0 ? action.steps.map((s) => ` * - ${typeof s === "string" ? s : s.action || JSON.stringify(s)}`).join("\n") : " * (no spec steps declared)";
236
+ if (CRUD_NAMES.has(actionName)) continue;
237
+ const steps = Array.isArray(action.steps) ? action.steps : [];
238
+ const stepsHeader = steps.length > 0 ? steps.map((s) => ` * - ${typeof s === "string" ? s : s.action || JSON.stringify(s)}`).join("\n") : " * (no spec steps declared)";
239
+ const declaredVars = /* @__PURE__ */ new Set();
240
+ const stepBodies = [];
241
+ let usesArgs = false;
242
+ let actionRefersToAi = false;
243
+ steps.forEach((rawStep, i) => {
244
+ const stepText = typeof rawStep === "string" ? rawStep : rawStep?.step || rawStep?.action;
245
+ if (typeof stepText !== "string") {
246
+ stepBodies.push(` // Step ${i + 1}: (non-string step ignored)`);
247
+ return;
248
+ }
249
+ const ctx = {
250
+ modelName,
251
+ collectionName: collectionName2,
252
+ serviceName: controller.name || "Controller",
253
+ operationName: actionName,
254
+ stepNum: i + 1,
255
+ parameterNames: Object.keys(action.parameters || {}),
256
+ declaredVars
257
+ };
258
+ const result = matchMongoStep(stepText, ctx);
259
+ stepBodies.push(result.call);
260
+ if (/\bargs\./.test(result.call)) usesArgs = true;
261
+ if (!result.matched) actionRefersToAi = true;
262
+ });
263
+ if (actionRefersToAi) needsAiBehaviors = true;
264
+ const argsParam = usesArgs ? "args: any = {}" : "_args: any = {}";
265
+ let combined = stepBodies.join("\n\n");
266
+ const stepResultRe = /const\s+(step\d+Result)\s*=/g;
267
+ let mres;
268
+ const declared = [];
269
+ while ((mres = stepResultRe.exec(combined)) !== null) declared.push(mres[1]);
270
+ for (const name of declared) {
271
+ const refCount = (combined.match(new RegExp(`\\b${name}\\b`, "g")) || []).length;
272
+ if (refCount <= 1) {
273
+ combined = combined.replace(new RegExp(`const\\s+${name}\\s*=\\s*`), "");
274
+ }
275
+ }
276
+ const body = steps.length > 0 ? combined + `
277
+ return { success: true };` : ` throw new Error('${controller.name || "Controller"}.${actionName} is not implemented');`;
231
278
  out.push(`
232
279
  /**
233
280
  * ${actionName}
@@ -236,16 +283,12 @@ function generateCustomActions(controller) {
236
283
  * Spec steps:
237
284
  ${stepsHeader}
238
285
  */
239
- public async ${actionName}(_args: any = {}): Promise<any> {
240
- // TODO (#43F): translate spec steps into native MongoDB driver calls
241
- // via a mongodb-native step-conventions library (mirror of the prisma
242
- // one). For now this is a stub so realize completes and the action
243
- // surface is callable for parity tests.
244
- throw new Error('${controller.name}.${actionName} is not implemented');
286
+ public async ${actionName}(${argsParam}): Promise<any> {
287
+ ${body}
245
288
  }
246
289
  `);
247
290
  }
248
- return { code: out.join("\n") };
291
+ return { code: out.join("\n"), needsAiBehaviors };
249
292
  }
250
293
  export {
251
294
  generateMongoNativeController as default
@@ -0,0 +1,319 @@
1
+ function toVar(name) {
2
+ return name.charAt(0).toLowerCase() + name.slice(1);
3
+ }
4
+ function toCollection(modelName) {
5
+ return modelName.toLowerCase() + "s";
6
+ }
7
+ function toMethod(words) {
8
+ const cleaned = words.trim().replace(/[^A-Za-z0-9\s]+/g, " ");
9
+ const camel = cleaned.replace(/\s+(.)/g, (_, c) => c.toUpperCase()).replace(/^\w/, (c) => c.toLowerCase());
10
+ const safe = camel.replace(/[^A-Za-z0-9_$]/g, "");
11
+ return safe || "unnamedStep";
12
+ }
13
+ function resolveValue(rawValue, ctx) {
14
+ const value = rawValue.trim().replace(/^['"]|['"]$/g, "");
15
+ if (/^(current\s*time|now|timestamp)$/i.test(value)) return "new Date().toISOString()";
16
+ if (value === "true" || value === "false") return value;
17
+ if (/^-?\d+(\.\d+)?$/.test(value)) return value;
18
+ const declared = ctx.declaredVars || /* @__PURE__ */ new Set();
19
+ const params = ctx.parameterNames || [];
20
+ const rootMatch = value.match(/^([a-zA-Z_][a-zA-Z0-9_]*)(\.[a-zA-Z0-9_.]+)?$/);
21
+ if (rootMatch) {
22
+ const root = rootMatch[1];
23
+ if (declared.has(root) || params.includes(root)) return value;
24
+ if (params.length > 0) return `args.${value}`;
25
+ }
26
+ if (/\s/.test(value)) return `/* TODO: resolve "${value}" */ null`;
27
+ return `'${value.replace(/'/g, "\\'")}'`;
28
+ }
29
+ const MONGO_STEP_CONVENTIONS = [
30
+ // --- Find / Lookup by single field ---
31
+ // Matches: "Look up X by Y", "Find X by Y", "Find X by Y or fail with 404"
32
+ {
33
+ name: "find-by-field",
34
+ pattern: /^(?:look\s+up|find|fetch|get)\s+(\w+)\s+by\s+(\w+)(?:\s+or\s+fail.*)?$/i,
35
+ generateCall: (m, ctx) => {
36
+ const model = m[1];
37
+ const field = m[2];
38
+ const modelVar = toVar(model);
39
+ const collection = toCollection(model);
40
+ const params = ctx.parameterNames || [];
41
+ const declared = ctx.declaredVars || /* @__PURE__ */ new Set();
42
+ const idVal = field === "id" ? params.includes(`${modelVar}Id`) ? `args.${modelVar}Id` : "args.id" : params.includes(field) ? `args.${field}` : `args.${field}`;
43
+ if (declared.has(modelVar)) {
44
+ return ` // Step ${ctx.stepNum}: Find ${model} by ${field} (already loaded)`;
45
+ }
46
+ declared.add(modelVar);
47
+ const failOnMissing = /or\s+fail/i.test(m[0]);
48
+ return ` // Step ${ctx.stepNum}: Find ${model} by ${field}
49
+ const ${modelVar}_collection = await getCollection('${collection}');
50
+ const ${modelVar} = await ${modelVar}_collection.findOne({ ${field}: ${idVal} });${failOnMissing ? `
51
+ if (!${modelVar}) throw new Error('${model} not found');` : ""}`;
52
+ }
53
+ },
54
+ // --- Find by composite (two fields) ---
55
+ // Matches: "Look up X by Y and Z", "Find X by Y and Z"
56
+ {
57
+ name: "find-by-composite",
58
+ pattern: /^(?:look\s+up|find|fetch|get)(?:\s+existing)?\s+(\w+)\s+by\s+(\w+)\s+and\s+(\w+)$/i,
59
+ generateCall: (m, ctx) => {
60
+ const model = m[1];
61
+ const f1 = m[2];
62
+ const f2 = m[3];
63
+ const modelVar = toVar(model);
64
+ const collection = toCollection(model);
65
+ const declared = ctx.declaredVars || /* @__PURE__ */ new Set();
66
+ if (declared.has(modelVar)) {
67
+ return ` // Step ${ctx.stepNum}: Find ${model} by ${f1} and ${f2} (already loaded)`;
68
+ }
69
+ declared.add(modelVar);
70
+ return ` // Step ${ctx.stepNum}: Find ${model} by ${f1} and ${f2}
71
+ const ${modelVar}_collection = await getCollection('${collection}');
72
+ const ${modelVar} = await ${modelVar}_collection.findOne({ ${f1}: args.${f1}, ${f2}: args.${f2} });`;
73
+ }
74
+ },
75
+ // --- Create model record ---
76
+ // Matches: "Create X", "Create new X with ..."
77
+ {
78
+ name: "create",
79
+ pattern: /^create\s+(?:new\s+)?(\w+)(?:\s+(?:with\s+)?(.+))?/i,
80
+ generateCall: (m, ctx) => {
81
+ const model = m[1];
82
+ const modelVar = toVar(model);
83
+ const collection = toCollection(model);
84
+ const params = ctx.parameterNames || [];
85
+ const declared = ctx.declaredVars || /* @__PURE__ */ new Set();
86
+ declared.add(modelVar);
87
+ const dataExpr = params.length > 0 ? `{ ${params.join(", ")} }` : "args";
88
+ return ` // Step ${ctx.stepNum}: Create ${model}
89
+ const ${modelVar}_collection = await getCollection('${collection}');
90
+ const ${modelVar}_inserted = await ${modelVar}_collection.insertOne(${dataExpr});
91
+ const ${modelVar} = { _id: ${modelVar}_inserted.insertedId, ...${dataExpr} };`;
92
+ }
93
+ },
94
+ // --- Update specific field on previously-loaded model ---
95
+ // Matches: "Update X field to value". Only fires when X has been
96
+ // declared by a prior matched find — otherwise the model var doesn't
97
+ // exist in scope and tsc fails. Falling through to the unmatched path
98
+ // lets the AI layer handle it.
99
+ {
100
+ name: "update-field",
101
+ pattern: /^update\s+(\w+)\s+(\w+)\s+to\s+(.+)/i,
102
+ generateCall: (m, ctx) => {
103
+ const model = m[1];
104
+ const field = m[2];
105
+ const rawValue = m[3];
106
+ const modelVar = toVar(model);
107
+ if (!ctx.declaredVars?.has(modelVar)) return "";
108
+ const collection = toCollection(model);
109
+ const val = resolveValue(rawValue, ctx);
110
+ return ` // Step ${ctx.stepNum}: Update ${model}.${field} to ${rawValue.trim()}
111
+ {
112
+ const ${modelVar}_collection = await getCollection('${collection}');
113
+ await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { ${field}: ${val} } });
114
+ }`;
115
+ }
116
+ },
117
+ // --- Update field timestamp (e.g. "Update device lastLoginAt timestamp") ---
118
+ {
119
+ name: "update-field-timestamp",
120
+ pattern: /^update\s+(\w+)\s+(\w+)\s+timestamp$/i,
121
+ generateCall: (m, ctx) => {
122
+ const model = m[1];
123
+ const field = m[2];
124
+ const modelVar = toVar(model);
125
+ if (!ctx.declaredVars?.has(modelVar)) return "";
126
+ const collection = toCollection(model);
127
+ return ` // Step ${ctx.stepNum}: Update ${model}.${field} timestamp
128
+ {
129
+ const ${modelVar}_collection = await getCollection('${collection}');
130
+ await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { ${field}: new Date().toISOString() } });
131
+ }`;
132
+ }
133
+ },
134
+ // --- Generic "Update X" (writes args back to record) ---
135
+ {
136
+ name: "update",
137
+ pattern: /^update\s+(\w+)(?:\s+(.+))?$/i,
138
+ generateCall: (m, ctx) => {
139
+ const model = m[1];
140
+ const modelVar = toVar(model);
141
+ if (!ctx.declaredVars?.has(modelVar)) return "";
142
+ const collection = toCollection(model);
143
+ return ` // Step ${ctx.stepNum}: Update ${model}
144
+ {
145
+ const ${modelVar}_collection = await getCollection('${collection}');
146
+ await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: args });
147
+ }`;
148
+ }
149
+ },
150
+ // --- Delete model record ---
151
+ {
152
+ name: "delete",
153
+ pattern: /^delete\s+(\w+)/i,
154
+ generateCall: (m, ctx) => {
155
+ const model = m[1];
156
+ const modelVar = toVar(model);
157
+ if (!ctx.declaredVars?.has(modelVar)) return "";
158
+ const collection = toCollection(model);
159
+ return ` // Step ${ctx.stepNum}: Delete ${model}
160
+ {
161
+ const ${modelVar}_collection = await getCollection('${collection}');
162
+ await ${modelVar}_collection.deleteOne({ _id: ${modelVar}._id });
163
+ }`;
164
+ }
165
+ },
166
+ // --- Transition to lifecycle state ---
167
+ // Matches: "Transition X to state"
168
+ {
169
+ name: "transition",
170
+ pattern: /^transition\s+(\w+)\s+to\s+(\w+)/i,
171
+ generateCall: (m, ctx) => {
172
+ const model = m[1];
173
+ const state = m[2];
174
+ const modelVar = toVar(model);
175
+ if (!ctx.declaredVars?.has(modelVar)) return "";
176
+ const collection = toCollection(model);
177
+ return ` // Step ${ctx.stepNum}: Transition ${model} to ${state}
178
+ if ((${modelVar} as any).status === '${state}') throw new Error('${model} is already ${state}');
179
+ {
180
+ const ${modelVar}_collection = await getCollection('${collection}');
181
+ await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { status: '${state}' } });
182
+ }`;
183
+ }
184
+ },
185
+ // --- Set field to value (on the controller's primary model) ---
186
+ {
187
+ name: "set",
188
+ pattern: /^set\s+(\w+)\s+to\s+(.+)/i,
189
+ generateCall: (m, ctx) => {
190
+ const field = m[1];
191
+ const rawValue = m[2];
192
+ const modelVar = toVar(ctx.modelName);
193
+ const val = resolveValue(rawValue, ctx);
194
+ return ` // Step ${ctx.stepNum}: Set ${field} to ${rawValue.trim()}
195
+ {
196
+ const ${modelVar}_collection = await getCollection(COLLECTION_NAME);
197
+ await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { ${field}: ${val} } });
198
+ }`;
199
+ }
200
+ },
201
+ // --- Increment / Decrement ---
202
+ {
203
+ name: "increment",
204
+ pattern: /^increment\s+(\w+)\s+by\s+(\w+)/i,
205
+ generateCall: (m, ctx) => {
206
+ const field = m[1];
207
+ const amount = m[2];
208
+ const modelVar = toVar(ctx.modelName);
209
+ return ` // Step ${ctx.stepNum}: Increment ${field} by ${amount}
210
+ {
211
+ const ${modelVar}_collection = await getCollection(COLLECTION_NAME);
212
+ await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $inc: { ${field}: ${amount} } });
213
+ }`;
214
+ }
215
+ },
216
+ {
217
+ name: "decrement",
218
+ pattern: /^decrement\s+(\w+)\s+by\s+(\w+)/i,
219
+ generateCall: (m, ctx) => {
220
+ const field = m[1];
221
+ const amount = m[2];
222
+ const modelVar = toVar(ctx.modelName);
223
+ return ` // Step ${ctx.stepNum}: Decrement ${field} by ${amount}
224
+ {
225
+ const ${modelVar}_collection = await getCollection(COLLECTION_NAME);
226
+ await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $inc: { ${field}: -${amount} } });
227
+ }`;
228
+ }
229
+ },
230
+ // --- Send/Emit/Publish event ---
231
+ // Emits an eventBus.publish call. The payload references the controller's
232
+ // primary model variable IF it was declared by a prior matched step;
233
+ // otherwise the payload is just operation + timestamp (the event still
234
+ // fires, just without a record-level id).
235
+ {
236
+ name: "send-event",
237
+ pattern: /^(?:send|emit|publish)\s+(\w+)\s+event/i,
238
+ generateCall: (m, ctx) => {
239
+ const event = m[1];
240
+ const modelVar = toVar(ctx.modelName);
241
+ const hasModelVar = ctx.declaredVars?.has(modelVar);
242
+ const payload = hasModelVar ? `{ ${modelVar}Id: (${modelVar} as any)?._id, operation: '${ctx.operationName}', timestamp: new Date().toISOString() }` : `{ operation: '${ctx.operationName}', timestamp: new Date().toISOString() }`;
243
+ return ` // Step ${ctx.stepNum}: Emit ${event} event
244
+ await eventBus.publish('${event}', ${payload} as any);`;
245
+ }
246
+ },
247
+ // --- Call service ---
248
+ {
249
+ name: "call-service",
250
+ pattern: /^call\s+(\w+)\.(\w+)/i,
251
+ generateCall: (m, ctx) => {
252
+ const service = m[1];
253
+ const method = m[2];
254
+ const args = (ctx.parameterNames || []).join(", ");
255
+ return ` // Step ${ctx.stepNum}: Call ${service}.${method}
256
+ await (${toVar(service)} as any).${method}({ ${args} });`;
257
+ }
258
+ },
259
+ // --- Return ---
260
+ // Only matches when the returned value is a single declared identifier
261
+ // OR when the natural-language reference resolves to "the model" (e.g.
262
+ // "Return the user", "Return updated game"). Multi-word phrases like
263
+ // "Return state to caller" don't translate to valid JS, so they fall
264
+ // through to the unmatched-step path and emit a `// TODO:` line.
265
+ {
266
+ name: "return-model",
267
+ pattern: /^return\s+(?:the\s+|updated\s+|created\s+)?(\w+)\s*$/i,
268
+ generateCall: (m, ctx) => {
269
+ const valueRaw = m[1].trim();
270
+ const declared = ctx.declaredVars || /* @__PURE__ */ new Set();
271
+ const params = ctx.parameterNames || [];
272
+ const modelVar = toVar(ctx.modelName);
273
+ if (valueRaw.toLowerCase() === ctx.modelName.toLowerCase() || valueRaw === modelVar) {
274
+ return ` // Step ${ctx.stepNum}: Return ${ctx.modelName}
275
+ return ${modelVar};`;
276
+ }
277
+ if (declared.has(valueRaw)) {
278
+ return ` // Step ${ctx.stepNum}: Return ${valueRaw}
279
+ return ${valueRaw};`;
280
+ }
281
+ if (params.includes(valueRaw)) {
282
+ return ` // Step ${ctx.stepNum}: Return ${valueRaw}
283
+ return args.${valueRaw};`;
284
+ }
285
+ return ` // Step ${ctx.stepNum}: Return ${valueRaw} \u2014 TODO: resolve binding
286
+ return null;`;
287
+ }
288
+ }
289
+ ];
290
+ function matchMongoStep(step, ctx) {
291
+ for (const convention of MONGO_STEP_CONVENTIONS) {
292
+ const m = step.match(convention.pattern);
293
+ if (m) {
294
+ const call = convention.generateCall(m, ctx);
295
+ if (call) {
296
+ return { matched: true, call };
297
+ }
298
+ }
299
+ }
300
+ const functionName = toMethod(step);
301
+ const declared = Array.from(ctx.declaredVars || []);
302
+ const paramNames = ctx.parameterNames || [];
303
+ const inputs = [...paramNames, ...declared];
304
+ const resultVar = ctx.resultName || `step${ctx.stepNum}Result`;
305
+ if (ctx.declaredVars) ctx.declaredVars.add(resultVar);
306
+ const inputObj = inputs.length > 0 ? `{ ${inputs.map((n) => paramNames.includes(n) ? `${n}: args.${n}` : n).join(", ")} }` : "{}";
307
+ return {
308
+ matched: false,
309
+ call: ` // Step ${ctx.stepNum}: ${step} [AI-generated \u2014 pure function]
310
+ const ${resultVar} = await aiBehaviors.${functionName}(${inputObj});`,
311
+ functionName,
312
+ inputs,
313
+ resultVar
314
+ };
315
+ }
316
+ export {
317
+ MONGO_STEP_CONVENTIONS,
318
+ matchMongoStep
319
+ };
@@ -102,9 +102,10 @@ function cacheWrite(key, body) {
102
102
  console.warn(` [ai-cache] write failed: ${err?.message || err}`);
103
103
  }
104
104
  }
105
- async function generateAiBehaviors(context) {
105
+ async function generateAiBehaviors(context, matcher) {
106
106
  const { controller, model } = context;
107
107
  if (!controller?.actions) return "";
108
+ const stepMatcher = matcher || matchStep;
108
109
  const modelName = model?.name || controller.model || "Model";
109
110
  const modelVar = modelName.charAt(0).toLowerCase() + modelName.slice(1);
110
111
  const availableModels = (context.models || []).map((m) => m?.name).filter(Boolean);
@@ -137,7 +138,7 @@ async function generateAiBehaviors(context) {
137
138
  declaredVars,
138
139
  resultName: stepAs
139
140
  };
140
- const result = matchStep(stepText, ctx);
141
+ const result = stepMatcher(stepText, ctx);
141
142
  if (!result.matched && result.functionName) {
142
143
  const existing = unmatchedFunctions.find((f) => f.functionName === result.functionName);
143
144
  if (!existing) {
@@ -184,7 +185,29 @@ async function generateAiBehaviorsFile(opts) {
184
185
  let cacheMisses = 0;
185
186
  for (const { functionName, step, operationName, parameterNames, inputs, returns, modelName } of unmatchedFunctions) {
186
187
  const stripLiteralsAndComments = (src) => {
187
- return src.replace(/\/\*[\s\S]*?\*\//g, (m) => " ".repeat(m.length)).replace(/\/\/[^\n]*/g, (m) => " ".repeat(m.length)).replace(/(['"])(?:\\.|(?!\1).)*\1/g, (m) => m[0] + " ".repeat(m.length - 2) + m[0]).replace(/`(?:\\.|\$\{[^}]*\}|(?!`).)*`/g, (m) => "`" + " ".repeat(m.length - 2) + "`");
188
+ let out = src.replace(/\/\*[\s\S]*?\*\//g, (m) => " ".repeat(m.length)).replace(/\/\/[^\n]*/g, (m) => " ".repeat(m.length)).replace(/(['"])(?:\\.|(?!\1).)*\1/g, (m) => m[0] + " ".repeat(m.length - 2) + m[0]);
189
+ out = out.replace(/`((?:\\.|\$\{[^}]*\}|(?!`).)*)`/g, (full, content) => {
190
+ let result = "`";
191
+ let i = 0;
192
+ while (i < content.length) {
193
+ if (content[i] === "\\" && i + 1 < content.length) {
194
+ result += " ";
195
+ i += 2;
196
+ continue;
197
+ }
198
+ if (content[i] === "$" && content[i + 1] === "{") {
199
+ const end = content.indexOf("}", i);
200
+ const slice = end >= 0 ? content.slice(i, end + 1) : content.slice(i);
201
+ result += slice;
202
+ i += slice.length;
203
+ continue;
204
+ }
205
+ result += " ";
206
+ i++;
207
+ }
208
+ return result + "`";
209
+ });
210
+ return out;
188
211
  };
189
212
  const bodyHandlesInputItself = (body2) => {
190
213
  const codeOnly = stripLiteralsAndComments(body2);
@@ -1 +1 @@
1
- {"version":3,"file":"ExecutableProcessor.d.ts","sourceRoot":"","sources":["../../../src/parser/processors/ExecutableProcessor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,wBAAwB,EAAiB,MAAM,iBAAiB,CAAC;AAG1E,qBAAa,mBAAoB,SAAQ,iBAAiB,CAAC,GAAG,EAAE,wBAAwB,CAAC;IACvF,OAAO,CAAC,kBAAkB,CAAqB;gBAEnC,OAAO,EAAE,GAAG;IAKxB;;;OAGG;IACH,OAAO,CAAC,KAAK,EAAE,GAAG,EAAE,WAAW,GAAE,MAAqB,GAAG,wBAAwB;IA4EjF;;OAEG;IACH,OAAO,CAAC,qBAAqB;IA2C7B;;;OAGG;IACH,OAAO,CAAC,kBAAkB;CAkB3B"}
1
+ {"version":3,"file":"ExecutableProcessor.d.ts","sourceRoot":"","sources":["../../../src/parser/processors/ExecutableProcessor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,wBAAwB,EAAiB,MAAM,iBAAiB,CAAC;AAG1E,qBAAa,mBAAoB,SAAQ,iBAAiB,CAAC,GAAG,EAAE,wBAAwB,CAAC;IACvF,OAAO,CAAC,kBAAkB,CAAqB;gBAEnC,OAAO,EAAE,GAAG;IAKxB;;;OAGG;IACH,OAAO,CAAC,KAAK,EAAE,GAAG,EAAE,WAAW,GAAE,MAAqB,GAAG,wBAAwB;IA0FjF;;OAEG;IACH,OAAO,CAAC,qBAAqB;IA2C7B;;;OAGG;IACH,OAAO,CAAC,kBAAkB;CAkB3B"}