@specverse/engines 6.6.3 → 6.7.8

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.
@@ -1,6 +1,27 @@
1
+ function resolveOrmName(manifest) {
2
+ if (!manifest) return "PrismaORM";
3
+ const inner = manifest.manifests ? Object.values(manifest.manifests)[0] : manifest;
4
+ if (!inner) return "PrismaORM";
5
+ const caps = Array.isArray(inner.capabilityMappings) ? inner.capabilityMappings : [];
6
+ const ormCap = caps.find((m) => m?.capability === "orm.client") || caps.find((m) => m?.capability === "orm.schema");
7
+ if (ormCap) return ormCap.implementation || ormCap.instanceFactory || "PrismaORM";
8
+ return inner.defaultMappings?.orm || "PrismaORM";
9
+ }
1
10
  function generateBackendPackageJson(context) {
2
- const { spec } = context;
11
+ const { spec, manifest } = context;
3
12
  const appName = (spec.metadata?.component || "app").toLowerCase().replace(/\s+/g, "-");
13
+ const orm = resolveOrmName(manifest);
14
+ const isMongoNative = orm === "MongoDBNativeDriver";
15
+ const dbScripts = isMongoNative ? {} : {
16
+ "db:setup": "prisma generate && prisma db push",
17
+ "db:generate": "prisma generate",
18
+ "db:push": "prisma db push",
19
+ "db:migrate": "prisma migrate dev",
20
+ "db:studio": "prisma studio",
21
+ "db:seed": "tsx prisma/seed.ts"
22
+ };
23
+ const ormDeps = isMongoNative ? { "mongodb": "^6.3.0" } : { "@prisma/client": "^5.7.0" };
24
+ const ormDevDeps = isMongoNative ? {} : { "prisma": "^5.7.0" };
4
25
  const pkg = {
5
26
  name: `${appName}-backend`,
6
27
  version: spec.metadata?.version || "1.0.0",
@@ -18,13 +39,8 @@ function generateBackendPackageJson(context) {
18
39
  "build:watch": "tsc --watch",
19
40
  // Production
20
41
  "start": "node dist/main.js",
21
- // Database
22
- "db:setup": "prisma generate && prisma db push",
23
- "db:generate": "prisma generate",
24
- "db:push": "prisma db push",
25
- "db:migrate": "prisma migrate dev",
26
- "db:studio": "prisma studio",
27
- "db:seed": "tsx prisma/seed.ts",
42
+ // Database (ORM-specific; empty for native driver)
43
+ ...dbScripts,
28
44
  // Testing
29
45
  "test": "vitest run --passWithNoTests",
30
46
  "test:watch": "vitest watch",
@@ -36,7 +52,7 @@ function generateBackendPackageJson(context) {
36
52
  "typecheck": "tsc --noEmit"
37
53
  },
38
54
  dependencies: {
39
- "@prisma/client": "^5.7.0",
55
+ ...ormDeps,
40
56
  "fastify": "^5.8.3",
41
57
  "@fastify/cors": "^10.0.0",
42
58
  "@fastify/helmet": "^12.0.0",
@@ -51,7 +67,7 @@ function generateBackendPackageJson(context) {
51
67
  "typescript": "^5.3.0",
52
68
  "@types/node": "^20.10.0",
53
69
  "tsx": "^4.7.0",
54
- "prisma": "^5.7.0",
70
+ ...ormDevDeps,
55
71
  "vitest": "^3.0.0",
56
72
  "@vitest/coverage-v8": "^3.0.0",
57
73
  "eslint": "^9.0.0",
@@ -39,7 +39,7 @@ function generateFastifyServer(context) {
39
39
  // workspace before running the script.
40
40
  import { config as loadEnv } from 'dotenv';
41
41
  import { existsSync } from 'fs';
42
- import { resolve as resolvePath, dirname, join } from 'path';
42
+ import { dirname, join } from 'path';
43
43
  import { fileURLToPath } from 'url';
44
44
  {
45
45
  let dir = dirname(fileURLToPath(import.meta.url));
@@ -8,7 +8,7 @@ function generateMongoNativeController(context) {
8
8
  const modelVar = lowerFirst(modelName);
9
9
  const collection = collectionName(model);
10
10
  const curedOps = controller.cured || {};
11
- const customActions = generateCustomActions(controller, modelName);
11
+ const customActions = generateCustomActions(controller);
12
12
  const validate = generateValidateMethod(model, modelName);
13
13
  const create = curedOps.create ? generateCreateMethod(model, modelName, modelVar, collection) : "";
14
14
  const retrieve = curedOps.retrieve ? generateRetrieveMethod(modelName, modelVar, collection) : "";
@@ -24,7 +24,6 @@ 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/${modelName}Controller.ai.js';` : ""}
28
27
 
29
28
  const COLLECTION_NAME = '${collection}';
30
29
 
@@ -107,7 +106,7 @@ function generateCreateMethod(model, modelName, modelVar, collection) {
107
106
  const validation = this.validate(data, { operation: 'create' });
108
107
  if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
109
108
 
110
- const collection = await getCollection('${collection}');
109
+ const collection = await getCollection(COLLECTION_NAME);
111
110
  const result = await collection.insertOne({ ...data });
112
111
  const ${modelVar} = { _id: result.insertedId, ...data };
113
112
 
@@ -122,7 +121,7 @@ function generateRetrieveMethod(modelName, modelVar, collection) {
122
121
  * Retrieve ${modelName} by id. Returns null when not found.
123
122
  */
124
123
  public async retrieve(id: string): Promise<any | null> {
125
- const collection = await getCollection('${collection}');
124
+ const collection = await getCollection(COLLECTION_NAME);
126
125
  return await collection.findOne(byId(id));
127
126
  }
128
127
 
@@ -130,7 +129,7 @@ function generateRetrieveMethod(modelName, modelVar, collection) {
130
129
  * Retrieve a page of ${modelName}s.
131
130
  */
132
131
  public async retrieveAll(options: { skip?: number; take?: number } = {}): Promise<any[]> {
133
- const collection = await getCollection('${collection}');
132
+ const collection = await getCollection(COLLECTION_NAME);
134
133
  const cursor = collection.find({});
135
134
  if (options.skip) cursor.skip(options.skip);
136
135
  if (options.take) cursor.limit(options.take);
@@ -156,7 +155,7 @@ function generateUpdateMethod(modelName, modelVar, collection) {
156
155
  updateData[key] = value;
157
156
  }
158
157
 
159
- const collection = await getCollection('${collection}');
158
+ const collection = await getCollection(COLLECTION_NAME);
160
159
  await collection.updateOne(byId(id), { $set: updateData });
161
160
  const ${modelVar} = await collection.findOne(byId(id));
162
161
  if (!${modelVar}) throw new Error('${modelName} not found after update');
@@ -181,7 +180,7 @@ function generateEvolveMethod(model, modelName, modelVar, collection) {
181
180
  * States: ${states.join(" \u2192 ") || "(none declared)"}
182
181
  */
183
182
  public async evolve(id: string, data: any): Promise<any> {
184
- const collection = await getCollection('${collection}');
183
+ const collection = await getCollection(COLLECTION_NAME);
185
184
  const current = await collection.findOne(byId(id));
186
185
  if (!current) throw new Error('${modelName} not found');
187
186
 
@@ -213,7 +212,7 @@ function generateDeleteMethod(modelName, modelVar, collection) {
213
212
  * Delete ${modelName}.
214
213
  */
215
214
  public async delete(id: string): Promise<void> {
216
- const collection = await getCollection('${collection}');
215
+ const collection = await getCollection(COLLECTION_NAME);
217
216
  const ${modelVar} = await collection.findOne(byId(id));
218
217
  await collection.deleteOne(byId(id));
219
218
  if (${modelVar}) {
@@ -222,13 +221,12 @@ function generateDeleteMethod(modelName, modelVar, collection) {
222
221
  }
223
222
  `;
224
223
  }
225
- function generateCustomActions(controller, modelName) {
224
+ function generateCustomActions(controller) {
226
225
  if (!controller.actions || Object.keys(controller.actions).length === 0) {
227
- return { code: "", needsAiBehaviors: false };
226
+ return { code: "" };
228
227
  }
229
228
  const out = [];
230
229
  for (const [actionName, action] of Object.entries(controller.actions)) {
231
- const params = generateActionParams(action);
232
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)";
233
231
  out.push(`
234
232
  /**
@@ -238,21 +236,16 @@ function generateCustomActions(controller, modelName) {
238
236
  * Spec steps:
239
237
  ${stepsHeader}
240
238
  */
241
- public async ${actionName}(${params}): Promise<any> {
242
- return await aiBehaviors.${actionName}({ controller: this, ...args });
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');
243
245
  }
244
246
  `);
245
247
  }
246
- return { code: out.join("\n"), needsAiBehaviors: true };
247
- }
248
- function generateActionParams(action) {
249
- if (Array.isArray(action.parameters) && action.parameters.length > 0) {
250
- return "args: any";
251
- }
252
- if (action.parameters && typeof action.parameters === "object" && Object.keys(action.parameters).length > 0) {
253
- return "args: any";
254
- }
255
- return "args: any = {}";
248
+ return { code: out.join("\n") };
256
249
  }
257
250
  export {
258
251
  generateMongoNativeController as default
@@ -16,7 +16,66 @@ async function validateTypeScript(code) {
16
16
  return msg;
17
17
  }
18
18
  }
19
- const PROMPT_VERSION = "9.1.0";
19
+ async function validateTypeScriptTypes(code) {
20
+ let ts;
21
+ try {
22
+ ts = await import("typescript");
23
+ if (ts.default) ts = ts.default;
24
+ } catch {
25
+ return null;
26
+ }
27
+ try {
28
+ const fileName = "aiBehavior.ts";
29
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.ES2022, true);
30
+ const compilerOptions = {
31
+ target: ts.ScriptTarget.ES2022,
32
+ module: ts.ModuleKind.ESNext,
33
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
34
+ strict: true,
35
+ noImplicitAny: false,
36
+ // body uses `any` extensively for inputs
37
+ noUnusedLocals: true,
38
+ noUnusedParameters: true,
39
+ noImplicitReturns: true,
40
+ noUncheckedIndexedAccess: true,
41
+ noFallthroughCasesInSwitch: true,
42
+ noEmit: true,
43
+ skipLibCheck: true,
44
+ types: [],
45
+ // Without `lib`, tsc can't resolve Promise / Array / RegExp etc., and
46
+ // every body fails the basic-types check before it even gets to the
47
+ // strict-null-check rules we actually want to validate.
48
+ lib: ["lib.es2022.d.ts"]
49
+ };
50
+ const defaultHost = ts.createCompilerHost(compilerOptions);
51
+ const host = {
52
+ ...defaultHost,
53
+ getSourceFile: (n, target) => {
54
+ if (n === fileName) return sourceFile;
55
+ return defaultHost.getSourceFile(n, target);
56
+ },
57
+ writeFile: () => {
58
+ },
59
+ fileExists: (n) => n === fileName || defaultHost.fileExists(n),
60
+ readFile: (n) => n === fileName ? code : defaultHost.readFile(n)
61
+ };
62
+ const program = ts.createProgram([fileName], compilerOptions, host);
63
+ const diagnostics = [
64
+ ...program.getSyntacticDiagnostics(sourceFile),
65
+ ...program.getSemanticDiagnostics(sourceFile)
66
+ ];
67
+ if (diagnostics.length === 0) return null;
68
+ const formatted = diagnostics.slice(0, 5).map((d) => {
69
+ const msg = ts.flattenDiagnosticMessageText(d.messageText, "\n");
70
+ const pos = d.file && d.start !== void 0 ? d.file.getLineAndCharacterOfPosition(d.start) : null;
71
+ return pos ? `line ${pos.line + 1}, col ${pos.character + 1}: ${msg}` : msg;
72
+ });
73
+ return formatted.join("; ");
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+ const PROMPT_VERSION = "9.2.0";
20
79
  function cacheKey(step, modelName, operationName, functionName, inputs) {
21
80
  const payload = JSON.stringify({ step, modelName, operationName, functionName, inputs: [...inputs].sort(), v: PROMPT_VERSION });
22
81
  return createHash("sha256").update(payload).digest("hex").slice(0, 16);
@@ -124,8 +183,33 @@ async function generateAiBehaviorsFile(opts) {
124
183
  let cacheHits = 0;
125
184
  let cacheMisses = 0;
126
185
  for (const { functionName, step, operationName, parameterNames, inputs, returns, modelName } of unmatchedFunctions) {
127
- const signature = inputs.length > 0 ? `input: { ${inputs.map((n) => `${n}: any`).join("; ")} }` : "input: Record<string, never>";
128
- const destructure = inputs.length > 0 ? ` const { ${inputs.join(", ")} } = input;` : "";
186
+ 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
+ };
189
+ const bodyHandlesInputItself = (body2) => {
190
+ const codeOnly = stripLiteralsAndComments(body2);
191
+ if (/(?:const|let|var)\s*\{[^}]+\}\s*=\s*input\b/.test(codeOnly)) return true;
192
+ if (/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*input\.[A-Za-z_$][\w$]*/.test(codeOnly)) return true;
193
+ return false;
194
+ };
195
+ const isRealReference = (n, codeOnly) => {
196
+ const escaped = n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
197
+ const re = new RegExp(`(?<![A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$:])`, "g");
198
+ return re.test(codeOnly);
199
+ };
200
+ const buildSignatureAndDestructure = (body2) => {
201
+ if (inputs.length === 0) {
202
+ return { signature: "input: Record<string, never>", destructure: "" };
203
+ }
204
+ const codeOnly = stripLiteralsAndComments(body2);
205
+ const referenced = inputs.filter((n) => isRealReference(n, codeOnly));
206
+ const usesInputObject = /(?<![A-Za-z0-9_$])input(?![A-Za-z0-9_$])/.test(codeOnly);
207
+ const sigName = referenced.length > 0 || usesInputObject ? "input" : "_input";
208
+ const sig = `${sigName}: { ${inputs.map((n) => `${n}: any`).join("; ")} }`;
209
+ const bodyAccessesInputProps = /(?<![A-Za-z0-9_$])input\.[A-Za-z_$]/.test(codeOnly);
210
+ const destructure = referenced.length > 0 && !bodyHandlesInputItself(body2) && !bodyAccessesInputProps ? ` const { ${referenced.join(", ")} } = input;` : "";
211
+ return { signature: sig, destructure };
212
+ };
129
213
  let returnType = "any";
130
214
  if (typeof returns === "string") {
131
215
  returnType = returns;
@@ -140,9 +224,10 @@ async function generateAiBehaviorsFile(opts) {
140
224
  const testCode = `export async function ${functionName}(input: any): Promise<any> {
141
225
  ${body}
142
226
  }`;
143
- const validationError = await validateTypeScript(testCode);
144
- if (validationError) {
145
- console.warn(` [ai-validate] cached ${functionName} failed validation: ${validationError}`);
227
+ const syntaxError = await validateTypeScript(testCode);
228
+ const typeError = syntaxError ? null : await validateTypeScriptTypes(testCode);
229
+ if (syntaxError || typeError) {
230
+ console.warn(` [ai-validate] cached ${functionName} failed validation: ${syntaxError || typeError}`);
146
231
  body = null;
147
232
  source = "STUB";
148
233
  } else {
@@ -168,16 +253,65 @@ ${body}
168
253
  const testCode = `export async function ${functionName}(input: any): Promise<any> {
169
254
  ${body}
170
255
  }`;
171
- const validationError = await validateTypeScript(testCode);
172
- if (validationError) {
173
- console.warn(` [ai-validate] ${functionName} has syntax error: ${validationError}`);
174
- body = `// AI-generated code failed validation: ${validationError}
256
+ const syntaxError = await validateTypeScript(testCode);
257
+ if (syntaxError) {
258
+ console.warn(` [ai-validate] ${functionName} has syntax error: ${syntaxError}`);
259
+ body = `// AI-generated code failed validation: ${syntaxError}
175
260
  // Step: ${step}
176
261
  throw new Error('AI behavior has invalid syntax \u2014 see comment above');`;
177
262
  source = "AI-INVALID";
178
263
  } else {
179
- source = "AI-GENERATED";
180
- cacheWrite(key, body);
264
+ const typeError = await validateTypeScriptTypes(testCode);
265
+ if (typeError) {
266
+ console.warn(` [ai-validate] ${functionName} type errors: ${typeError}`);
267
+ try {
268
+ const retryHint = `Your previous output produced TypeScript type errors:
269
+ ${typeError}
270
+
271
+ Fix these specifically \u2014 common causes:
272
+ - RegExp match indices are 'string | undefined'; use non-null assertion or extract to a typed variable
273
+ - Strict null checks: guard or assert before use
274
+ - Don't declare locals you never reference
275
+
276
+ IMPORTANT: The destructure line \`const { ... } = input;\` is added by the wrapper, NOT by you. Output ONLY the function body that goes AFTER that line \u2014 do not repeat the destructure or you will produce duplicate-declaration errors.`;
277
+ const retried = await aiService.generateBehavior({
278
+ step: `${step}
279
+
280
+ ${retryHint}`,
281
+ modelName,
282
+ operationName,
283
+ functionName,
284
+ parameterNames: inputs,
285
+ availableModels,
286
+ spec,
287
+ returnType
288
+ });
289
+ if (retried) {
290
+ const retryCode = `export async function ${functionName}(input: any): Promise<any> {
291
+ ${retried}
292
+ }`;
293
+ const retrySyntaxError = await validateTypeScript(retryCode);
294
+ const retryTypeError = retrySyntaxError ? null : await validateTypeScriptTypes(retryCode);
295
+ if (!retrySyntaxError && !retryTypeError) {
296
+ body = retried;
297
+ source = "AI-GENERATED";
298
+ cacheWrite(key, body);
299
+ } else {
300
+ source = "AI-INVALID";
301
+ cacheWrite(key, body);
302
+ }
303
+ } else {
304
+ source = "AI-INVALID";
305
+ cacheWrite(key, body);
306
+ }
307
+ } catch {
308
+ source = "AI-INVALID";
309
+ cacheWrite(key, body);
310
+ }
311
+ } else {
312
+ source = "AI-GENERATED";
313
+ cacheWrite(key, body);
314
+ }
181
315
  }
182
316
  }
183
317
  } catch {
@@ -204,8 +338,14 @@ ${inputsDoc}${returnsDoc} * Source: ${source}
204
338
  * All data comes in via \`input\`; all effects happen in the calling controller.
205
339
  * ${source === "AI-GENERATED" ? "AI-generated implementation. Review and test before deploying." : source === "AI-CACHED" ? "Restored from AI cache (.specverse/ai-cache/). Delete cache entry to regenerate." : source === "AI-INVALID" ? "AI returned code with syntax errors \u2014 function throws at runtime. Fix or regenerate." : "STUB \u2014 Claude CLI unavailable. Install Claude Code or implement manually."}
206
340
  */
207
- export async function ${functionName}(${signature}): Promise<${returnType}> {
208
- ${destructure ? destructure + "\n" : ""}${body}
341
+ export async function ${functionName}(${(() => {
342
+ const { signature: sig } = buildSignatureAndDestructure(body);
343
+ return sig;
344
+ })()}): Promise<${returnType}> {
345
+ ${(() => {
346
+ const { destructure } = buildSignatureAndDestructure(body);
347
+ return destructure ? destructure + "\n" : "";
348
+ })()}${body}
209
349
  }`);
210
350
  }
211
351
  if (aiService?.endSession) aiService.endSession();
@@ -220,27 +360,28 @@ ${destructure ? destructure + "\n" : ""}${body}
220
360
  * These functions could not be generated from convention patterns.
221
361
  * They are called by ${ownerName} when executing operations.
222
362
  *
363
+ * PURE-FUNCTION CONTRACT \u2014 these bodies must NOT touch the database, the
364
+ * event bus, or any external service. Persistence and side effects happen
365
+ * in the calling controller; this file does pure transformations only.
366
+ *
223
367
  * Options for each function:
224
368
  * - Implement manually (recommended for business-critical logic)
225
369
  * - Use AI generation: specverse ai generate <function>
226
370
  * - Refactor the spec step to use a convention pattern
227
371
  *
228
- * Convention patterns that ARE auto-generated (no AI needed):
229
- * "Find {Model} by {field}" \u2192 prisma.model.findUniqueOrThrow(...)
230
- * "Create {Model}" \u2192 prisma.model.create(...)
231
- * "Update {Model} {field} to {value}" \u2192 prisma.model.update(...)
232
- * "Delete {Model}" \u2192 prisma.model.delete(...)
233
- * "Transition {Model} to {state}" \u2192 prisma.model.update({ status: ... })
234
- * "Count {Model}s per {Group}" \u2192 prisma.model.groupBy(...)
235
- * See step-conventions.ts for the full list.
372
+ * Convention patterns that ARE auto-generated by the realize engine (no
373
+ * AI needed) \u2014 these are emitted inline in the controller, not here:
374
+ * "Find {Model} by {field}" \u2192 ORM-specific find call
375
+ * "Create {Model}" \u2192 ORM-specific create call
376
+ * "Update {Model} {field} to {value}"
377
+ * "Delete {Model}"
378
+ * "Transition {Model} to {state}"
379
+ * "Count {Model}s per {Group}"
380
+ * See the ORM's step-conventions module for the full list.
236
381
  *
237
382
  * Generated: ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}
238
383
  */
239
384
 
240
- import { PrismaClient } from '@prisma/client';
241
-
242
- const prisma = new PrismaClient();
243
-
244
385
  ${functions.join("\n\n")}
245
386
  `;
246
387
  }
@@ -1,15 +1,56 @@
1
1
  /**
2
2
  * Backend Package.json Generator
3
3
  *
4
- * Generates package.json for backend workspace in monorepo
4
+ * Generates package.json for backend workspace in monorepo. Adapts deps +
5
+ * scripts to the manifest's resolved ORM/storage so a manifest pinning
6
+ * MongoDBNativeDriver doesn't get stuck with Prisma scripts and
7
+ * `@prisma/client` in dependencies.
5
8
  */
6
9
 
7
10
  import type { TemplateContext } from '@specverse/types';
8
11
 
9
- export default function generateBackendPackageJson(context: TemplateContext): string {
10
- const { spec } = context;
12
+ /** Read the manifest's resolved orm name (e.g. "PrismaORM", "MongoDBNativeDriver"). */
13
+ function resolveOrmName(manifest: any): string {
14
+ if (!manifest) return 'PrismaORM';
15
+ const inner = manifest.manifests
16
+ ? Object.values(manifest.manifests)[0] as any
17
+ : manifest;
18
+ if (!inner) return 'PrismaORM';
19
+ // Prefer explicit capabilityMappings for orm.client; fall back to defaultMappings.orm.
20
+ const caps = Array.isArray(inner.capabilityMappings) ? inner.capabilityMappings : [];
21
+ const ormCap = caps.find((m: any) => m?.capability === 'orm.client') ||
22
+ caps.find((m: any) => m?.capability === 'orm.schema');
23
+ if (ormCap) return ormCap.implementation || ormCap.instanceFactory || 'PrismaORM';
24
+ return inner.defaultMappings?.orm || 'PrismaORM';
25
+ }
11
26
 
27
+ export default function generateBackendPackageJson(context: TemplateContext): string {
28
+ const { spec, manifest } = context as any;
12
29
  const appName = (spec.metadata?.component || 'app').toLowerCase().replace(/\s+/g, '-');
30
+ const orm = resolveOrmName(manifest);
31
+ const isMongoNative = orm === 'MongoDBNativeDriver';
32
+
33
+ // Database scripts are ORM-specific. Prisma has db:setup/generate/push;
34
+ // MongoDB native has nothing to generate (collections are dynamic), so
35
+ // we drop those scripts entirely rather than emit no-op placeholders.
36
+ const dbScripts: Record<string, string> = isMongoNative
37
+ ? {}
38
+ : {
39
+ 'db:setup': 'prisma generate && prisma db push',
40
+ 'db:generate': 'prisma generate',
41
+ 'db:push': 'prisma db push',
42
+ 'db:migrate': 'prisma migrate dev',
43
+ 'db:studio': 'prisma studio',
44
+ 'db:seed': 'tsx prisma/seed.ts',
45
+ };
46
+
47
+ const ormDeps: Record<string, string> = isMongoNative
48
+ ? { 'mongodb': '^6.3.0' }
49
+ : { '@prisma/client': '^5.7.0' };
50
+
51
+ const ormDevDeps: Record<string, string> = isMongoNative
52
+ ? {}
53
+ : { 'prisma': '^5.7.0' };
13
54
 
14
55
  const pkg: Record<string, any> = {
15
56
  name: `${appName}-backend`,
@@ -32,13 +73,8 @@ export default function generateBackendPackageJson(context: TemplateContext): st
32
73
  // Production
33
74
  'start': 'node dist/main.js',
34
75
 
35
- // Database
36
- 'db:setup': 'prisma generate && prisma db push',
37
- 'db:generate': 'prisma generate',
38
- 'db:push': 'prisma db push',
39
- 'db:migrate': 'prisma migrate dev',
40
- 'db:studio': 'prisma studio',
41
- 'db:seed': 'tsx prisma/seed.ts',
76
+ // Database (ORM-specific; empty for native driver)
77
+ ...dbScripts,
42
78
 
43
79
  // Testing
44
80
  'test': 'vitest run --passWithNoTests',
@@ -54,7 +90,7 @@ export default function generateBackendPackageJson(context: TemplateContext): st
54
90
  },
55
91
 
56
92
  dependencies: {
57
- '@prisma/client': '^5.7.0',
93
+ ...ormDeps,
58
94
  'fastify': '^5.8.3',
59
95
  '@fastify/cors': '^10.0.0',
60
96
  '@fastify/helmet': '^12.0.0',
@@ -70,7 +106,7 @@ export default function generateBackendPackageJson(context: TemplateContext): st
70
106
  'typescript': '^5.3.0',
71
107
  '@types/node': '^20.10.0',
72
108
  'tsx': '^4.7.0',
73
- 'prisma': '^5.7.0',
109
+ ...ormDevDeps,
74
110
  'vitest': '^3.0.0',
75
111
  '@vitest/coverage-v8': '^3.0.0',
76
112
  'eslint': '^9.0.0',
@@ -59,7 +59,7 @@ export default function generateFastifyServer(context: TemplateContext): string
59
59
  // workspace before running the script.
60
60
  import { config as loadEnv } from 'dotenv';
61
61
  import { existsSync } from 'fs';
62
- import { resolve as resolvePath, dirname, join } from 'path';
62
+ import { dirname, join } from 'path';
63
63
  import { fileURLToPath } from 'url';
64
64
  {
65
65
  let dir = dirname(fileURLToPath(import.meta.url));
@@ -94,7 +94,9 @@ describe('MongoDB native — controller-generator', () => {
94
94
  model: { ...baseModel, storage: { collection: 'custom_todos' } },
95
95
  } as any);
96
96
  expect(out).toContain(`COLLECTION_NAME = 'custom_todos'`);
97
- expect(out).toContain(`getCollection('custom_todos')`);
97
+ // CURVED ops reference the constant, not the literal — keeps the
98
+ // generator output noUnusedLocals-clean under strict tsc.
99
+ expect(out).toContain(`getCollection(COLLECTION_NAME)`);
98
100
  });
99
101
 
100
102
  it('omits CURVED ops not declared on the controller', () => {
@@ -36,7 +36,7 @@ export default function generateMongoNativeController(context: TemplateContext):
36
36
  const collection = collectionName(model);
37
37
  const curedOps = controller.cured || {};
38
38
 
39
- const customActions = generateCustomActions(controller, modelName);
39
+ const customActions = generateCustomActions(controller);
40
40
 
41
41
  const validate = generateValidateMethod(model, modelName);
42
42
  const create = curedOps.create ? generateCreateMethod(model, modelName, modelVar, collection) : '';
@@ -56,7 +56,6 @@ 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/${modelName}Controller.ai.js';` : ''}
60
59
 
61
60
  const COLLECTION_NAME = '${collection}';
62
61
 
@@ -147,7 +146,7 @@ function generateCreateMethod(model: any, modelName: string, modelVar: string, c
147
146
  const validation = this.validate(data, { operation: 'create' });
148
147
  if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);
149
148
 
150
- const collection = await getCollection('${collection}');
149
+ const collection = await getCollection(COLLECTION_NAME);
151
150
  const result = await collection.insertOne({ ...data });
152
151
  const ${modelVar} = { _id: result.insertedId, ...data };
153
152
 
@@ -163,7 +162,7 @@ function generateRetrieveMethod(modelName: string, modelVar: string, collection:
163
162
  * Retrieve ${modelName} by id. Returns null when not found.
164
163
  */
165
164
  public async retrieve(id: string): Promise<any | null> {
166
- const collection = await getCollection('${collection}');
165
+ const collection = await getCollection(COLLECTION_NAME);
167
166
  return await collection.findOne(byId(id));
168
167
  }
169
168
 
@@ -171,7 +170,7 @@ function generateRetrieveMethod(modelName: string, modelVar: string, collection:
171
170
  * Retrieve a page of ${modelName}s.
172
171
  */
173
172
  public async retrieveAll(options: { skip?: number; take?: number } = {}): Promise<any[]> {
174
- const collection = await getCollection('${collection}');
173
+ const collection = await getCollection(COLLECTION_NAME);
175
174
  const cursor = collection.find({});
176
175
  if (options.skip) cursor.skip(options.skip);
177
176
  if (options.take) cursor.limit(options.take);
@@ -198,7 +197,7 @@ function generateUpdateMethod(modelName: string, modelVar: string, collection: s
198
197
  updateData[key] = value;
199
198
  }
200
199
 
201
- const collection = await getCollection('${collection}');
200
+ const collection = await getCollection(COLLECTION_NAME);
202
201
  await collection.updateOne(byId(id), { $set: updateData });
203
202
  const ${modelVar} = await collection.findOne(byId(id));
204
203
  if (!${modelVar}) throw new Error('${modelName} not found after update');
@@ -231,7 +230,7 @@ function generateEvolveMethod(model: any, modelName: string, modelVar: string, c
231
230
  * States: ${states.join(' → ') || '(none declared)'}
232
231
  */
233
232
  public async evolve(id: string, data: any): Promise<any> {
234
- const collection = await getCollection('${collection}');
233
+ const collection = await getCollection(COLLECTION_NAME);
235
234
  const current = await collection.findOne(byId(id));
236
235
  if (!current) throw new Error('${modelName} not found');
237
236
 
@@ -264,7 +263,7 @@ function generateDeleteMethod(modelName: string, modelVar: string, collection: s
264
263
  * Delete ${modelName}.
265
264
  */
266
265
  public async delete(id: string): Promise<void> {
267
- const collection = await getCollection('${collection}');
266
+ const collection = await getCollection(COLLECTION_NAME);
268
267
  const ${modelVar} = await collection.findOne(byId(id));
269
268
  await collection.deleteOne(byId(id));
270
269
  if (${modelVar}) {
@@ -276,16 +275,26 @@ function generateDeleteMethod(modelName: string, modelVar: string, collection: s
276
275
 
277
276
  interface CustomActionsResult {
278
277
  code: string;
279
- needsAiBehaviors: boolean;
280
278
  }
281
279
 
282
- function generateCustomActions(controller: any, modelName: string): CustomActionsResult {
280
+ /**
281
+ * Custom actions emit TODO stubs that throw "not implemented".
282
+ *
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).
291
+ */
292
+ function generateCustomActions(controller: any): CustomActionsResult {
283
293
  if (!controller.actions || Object.keys(controller.actions).length === 0) {
284
- return { code: '', needsAiBehaviors: false };
294
+ return { code: '' };
285
295
  }
286
296
  const out: string[] = [];
287
297
  for (const [actionName, action] of Object.entries<any>(controller.actions)) {
288
- const params = generateActionParams(action);
289
298
  const stepsHeader = (action.steps && action.steps.length > 0)
290
299
  ? action.steps.map((s: any) => ` * - ${typeof s === 'string' ? s : (s.action || JSON.stringify(s))}`).join('\n')
291
300
  : ' * (no spec steps declared)';
@@ -297,20 +306,14 @@ function generateCustomActions(controller: any, modelName: string): CustomAction
297
306
  * Spec steps:
298
307
  ${stepsHeader}
299
308
  */
300
- public async ${actionName}(${params}): Promise<any> {
301
- return await aiBehaviors.${actionName}({ controller: this, ...args });
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');
302
315
  }
303
316
  `);
304
317
  }
305
- return { code: out.join('\n'), needsAiBehaviors: true };
306
- }
307
-
308
- function generateActionParams(action: any): string {
309
- if (Array.isArray(action.parameters) && action.parameters.length > 0) {
310
- return 'args: any';
311
- }
312
- if (action.parameters && typeof action.parameters === 'object' && Object.keys(action.parameters).length > 0) {
313
- return 'args: any';
314
- }
315
- return 'args: any = {}';
318
+ return { code: out.join('\n') };
316
319
  }
@@ -46,6 +46,85 @@ async function validateTypeScript(code: string): Promise<string | null> {
46
46
  }
47
47
  }
48
48
 
49
+ /**
50
+ * Type-check the generated body in isolation using the TypeScript compiler
51
+ * API. Returns null if clean, or a concise error string suitable for
52
+ * passing back to the LLM as a fix-up hint. Skipped silently if the `typescript`
53
+ * package isn't installed (the realize pipeline still runs full tsc against
54
+ * the realized output downstream — this is a faster, generator-side filter
55
+ * to catch the most common LLM mistakes before they hit the user).
56
+ *
57
+ * Why bother: the LLM regularly emits patterns that are valid syntax but
58
+ * fail strict tsc — RegExp index access (`m[1]` is `string | undefined`),
59
+ * unused locals, undefined references. Reprompting with the tsc error
60
+ * lets the LLM self-correct without burning a per-step retry.
61
+ */
62
+ async function validateTypeScriptTypes(code: string): Promise<string | null> {
63
+ let ts: any;
64
+ try {
65
+ ts = await import('typescript');
66
+ if (ts.default) ts = ts.default;
67
+ } catch {
68
+ return null; // typescript not available — skip
69
+ }
70
+ try {
71
+ const fileName = 'aiBehavior.ts';
72
+ const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.ES2022, true);
73
+ // Mirror the strict shape used by the realized backend's tsconfig so
74
+ // a body that passes here also passes downstream. Failing to match the
75
+ // realized strictness means we'd ship code that fails user-side tsc
76
+ // (defeating the whole point of generator-side validation).
77
+ const compilerOptions: any = {
78
+ target: ts.ScriptTarget.ES2022,
79
+ module: ts.ModuleKind.ESNext,
80
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
81
+ strict: true,
82
+ noImplicitAny: false, // body uses `any` extensively for inputs
83
+ noUnusedLocals: true,
84
+ noUnusedParameters: true,
85
+ noImplicitReturns: true,
86
+ noUncheckedIndexedAccess: true,
87
+ noFallthroughCasesInSwitch: true,
88
+ noEmit: true,
89
+ skipLibCheck: true,
90
+ types: [],
91
+ // Without `lib`, tsc can't resolve Promise / Array / RegExp etc., and
92
+ // every body fails the basic-types check before it even gets to the
93
+ // strict-null-check rules we actually want to validate.
94
+ lib: ['lib.es2022.d.ts'],
95
+ };
96
+ const defaultHost = ts.createCompilerHost(compilerOptions);
97
+ const host: any = {
98
+ ...defaultHost,
99
+ getSourceFile: (n: string, target: any) => {
100
+ if (n === fileName) return sourceFile;
101
+ return defaultHost.getSourceFile(n, target);
102
+ },
103
+ writeFile: () => {},
104
+ fileExists: (n: string) => n === fileName || defaultHost.fileExists(n),
105
+ readFile: (n: string) => (n === fileName ? code : defaultHost.readFile(n)),
106
+ };
107
+ const program = ts.createProgram([fileName], compilerOptions, host);
108
+ const diagnostics = [
109
+ ...program.getSyntacticDiagnostics(sourceFile),
110
+ ...program.getSemanticDiagnostics(sourceFile),
111
+ ];
112
+ if (diagnostics.length === 0) return null;
113
+ const formatted = diagnostics.slice(0, 5).map((d: any) => {
114
+ const msg = ts.flattenDiagnosticMessageText(d.messageText, '\n');
115
+ const pos = d.file && d.start !== undefined
116
+ ? d.file.getLineAndCharacterOfPosition(d.start)
117
+ : null;
118
+ return pos
119
+ ? `line ${pos.line + 1}, col ${pos.character + 1}: ${msg}`
120
+ : msg;
121
+ });
122
+ return formatted.join('; ');
123
+ } catch {
124
+ return null; // never let validation crash the generator
125
+ }
126
+ }
127
+
49
128
  /**
50
129
  * AI output cache — avoids re-calling Claude for unchanged steps.
51
130
  *
@@ -56,7 +135,7 @@ async function validateTypeScript(code: string): Promise<string | null> {
56
135
  * produces a new hash. The prompt version is part of the hash so
57
136
  * prompt upgrades also invalidate.
58
137
  */
59
- const PROMPT_VERSION = '9.1.0';
138
+ const PROMPT_VERSION = '9.2.0';
60
139
 
61
140
  function cacheKey(step: string, modelName: string, operationName: string, functionName: string, inputs: string[]): string {
62
141
  const payload = JSON.stringify({ step, modelName, operationName, functionName, inputs: [...inputs].sort(), v: PROMPT_VERSION });
@@ -229,15 +308,76 @@ export async function generateAiBehaviorsFile(opts: {
229
308
  let cacheHits = 0;
230
309
  let cacheMisses = 0;
231
310
  for (const { functionName, step, operationName, parameterNames, inputs, returns, modelName } of unmatchedFunctions) {
232
- // Pure function signature: all inputs as a typed destructured object
233
- const signature = inputs.length > 0
234
- ? `input: { ${inputs.map(n => `${n}: any`).join('; ')} }`
235
- : 'input: Record<string, never>';
236
-
237
- // Inside the body, destructure for readability
238
- const destructure = inputs.length > 0
239
- ? ` const { ${inputs.join(', ')} } = input;`
240
- : '';
311
+ // Pure function signature + destructure are built AFTER the body so we
312
+ // can match what the LLM actually references — strict tsc's
313
+ // noUnusedLocals / noUnusedParameters fire on every input the body
314
+ // didn't touch. We tolerate underuse rather than re-prompting the LLM:
315
+ // the body is correct as-is, we just trim the wrapper to fit it.
316
+ //
317
+ // Three cases:
318
+ // - body uses some inputs: destructure only those
319
+ // - body uses none directly but references `input.X`: keep `input`
320
+ // - body doesn't touch input at all: prefix signature with `_` so
321
+ // noUnusedParameters is happy
322
+ /** Strip string/template/comment content so identifier-reference checks
323
+ * don't match `reward` inside a kebab-case string like `'reward-not-granted'`
324
+ * or `// reward stays as text`. We replace the literal contents with
325
+ * spaces so positions stay roughly aligned in case of debugging. */
326
+ const stripLiteralsAndComments = (src: string): string => {
327
+ return src
328
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length))
329
+ .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) + '`');
332
+ };
333
+
334
+ /** Detect whether the body manages its own input access (so adding our
335
+ * own destructure would either duplicate-declare or leave us with
336
+ * unused-locals). Three shapes the LLM emits:
337
+ * 1. `const { X, Y } = input;` (destructure)
338
+ * 2. `const X = input.X;` (per-property)
339
+ * 3. `const _X = input.X;` (per-property with rename)
340
+ * If ANY of these appear, the body is its own input setup and we
341
+ * should leave it alone. */
342
+ const bodyHandlesInputItself = (body: string): boolean => {
343
+ const codeOnly = stripLiteralsAndComments(body);
344
+ // Destructure: `const { ... } = input;`
345
+ if (/(?:const|let|var)\s*\{[^}]+\}\s*=\s*input\b/.test(codeOnly)) return true;
346
+ // Per-property access: any `(const|let|var) <name> = input.<name>`
347
+ if (/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*input\.[A-Za-z_$][\w$]*/.test(codeOnly)) return true;
348
+ return false;
349
+ };
350
+
351
+ /** Does the body actually USE the local variable `n` (vs only mention
352
+ * it as an object-literal property name like `step1Result:` in a
353
+ * return object)? A real usage is `n` followed by something other than
354
+ * `:` — i.e. accessed in an expression. We approximate by requiring
355
+ * the next non-whitespace char to be a code-meaningful operator/end. */
356
+ const isRealReference = (n: string, codeOnly: string): boolean => {
357
+ const escaped = n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
358
+ const re = new RegExp(`(?<![A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$:])`, 'g');
359
+ return re.test(codeOnly);
360
+ };
361
+
362
+ const buildSignatureAndDestructure = (body: string): { signature: string; destructure: string } => {
363
+ if (inputs.length === 0) {
364
+ return { signature: 'input: Record<string, never>', destructure: '' };
365
+ }
366
+ const codeOnly = stripLiteralsAndComments(body);
367
+ const referenced = inputs.filter((n) => isRealReference(n, codeOnly));
368
+ const usesInputObject = /(?<![A-Za-z0-9_$])input(?![A-Za-z0-9_$])/.test(codeOnly);
369
+ const sigName = (referenced.length > 0 || usesInputObject) ? 'input' : '_input';
370
+ const sig = `${sigName}: { ${inputs.map(n => `${n}: any`).join('; ')} }`;
371
+ // If the body already manages its own input access (destructure or
372
+ // per-property) OR accesses input.X directly, don't add a wrapper
373
+ // destructure — would leave us with unused locals (TS6198) or
374
+ // duplicate declarations (TS2451).
375
+ const bodyAccessesInputProps = /(?<![A-Za-z0-9_$])input\.[A-Za-z_$]/.test(codeOnly);
376
+ const destructure = (referenced.length > 0 && !bodyHandlesInputItself(body) && !bodyAccessesInputProps)
377
+ ? ` const { ${referenced.join(', ')} } = input;`
378
+ : '';
379
+ return { signature: sig, destructure };
380
+ };
241
381
 
242
382
  // Build return type from spec declaration (if provided)
243
383
  // returns can be:
@@ -258,11 +398,14 @@ export async function generateAiBehaviorsFile(opts: {
258
398
  let source: 'AI-CACHED' | 'AI-GENERATED' | 'AI-INVALID' | 'STUB' = body ? 'AI-CACHED' : 'STUB';
259
399
  if (body) {
260
400
  // Validate cache entry — a previously valid entry may have been
261
- // corrupted on disk or the validator rules may have changed
401
+ // corrupted on disk OR the validation rules may have tightened
402
+ // (e.g. tsc type checks added). A cache hit must still be re-validated
403
+ // through both gates so old bodies don't leak past the new bar.
262
404
  const testCode = `export async function ${functionName}(input: any): Promise<any> {\n${body}\n}`;
263
- const validationError = await validateTypeScript(testCode);
264
- if (validationError) {
265
- console.warn(` [ai-validate] cached ${functionName} failed validation: ${validationError}`);
405
+ const syntaxError = await validateTypeScript(testCode);
406
+ const typeError = syntaxError ? null : await validateTypeScriptTypes(testCode);
407
+ if (syntaxError || typeError) {
408
+ console.warn(` [ai-validate] cached ${functionName} failed validation: ${syntaxError || typeError}`);
266
409
  body = null; // Force regeneration
267
410
  source = 'STUB';
268
411
  } else {
@@ -285,17 +428,62 @@ export async function generateAiBehaviorsFile(opts: {
285
428
  });
286
429
 
287
430
  if (body) {
288
- // Validate the generated body as a standalone function
431
+ // Two-tier validation:
432
+ // 1. esbuild syntax check — fast, catches unbalanced braces, etc.
433
+ // 2. tsc type check — slower, catches `string | undefined` index
434
+ // access, unused locals, and undefined references the LLM
435
+ // regularly produces. On tsc failure, we re-prompt the LLM
436
+ // with the error message appended; only ONE retry to bound
437
+ // cost. If that still fails we keep the body but mark it
438
+ // AI-INVALID so the user sees it needs review.
289
439
  const testCode = `export async function ${functionName}(input: any): Promise<any> {\n${body}\n}`;
290
- const validationError = await validateTypeScript(testCode);
291
- if (validationError) {
292
- console.warn(` [ai-validate] ${functionName} has syntax error: ${validationError}`);
293
- // Don't cache invalid output; treat as failed generation
294
- body = `// AI-generated code failed validation: ${validationError}\n // Step: ${step}\n throw new Error('AI behavior has invalid syntax — see comment above');`;
440
+ const syntaxError = await validateTypeScript(testCode);
441
+ if (syntaxError) {
442
+ console.warn(` [ai-validate] ${functionName} has syntax error: ${syntaxError}`);
443
+ body = `// AI-generated code failed validation: ${syntaxError}\n // Step: ${step}\n throw new Error('AI behavior has invalid syntax see comment above');`;
295
444
  source = 'AI-INVALID';
296
445
  } else {
297
- source = 'AI-GENERATED';
298
- cacheWrite(key, body);
446
+ const typeError = await validateTypeScriptTypes(testCode);
447
+ if (typeError) {
448
+ console.warn(` [ai-validate] ${functionName} type errors: ${typeError}`);
449
+ try {
450
+ const retryHint = `Your previous output produced TypeScript type errors:\n${typeError}\n\nFix these specifically — common causes:\n- RegExp match indices are 'string | undefined'; use non-null assertion or extract to a typed variable\n- Strict null checks: guard or assert before use\n- Don't declare locals you never reference\n\nIMPORTANT: The destructure line \`const { ... } = input;\` is added by the wrapper, NOT by you. Output ONLY the function body that goes AFTER that line — do not repeat the destructure or you will produce duplicate-declaration errors.`;
451
+ const retried = await aiService.generateBehavior({
452
+ step: `${step}\n\n${retryHint}`,
453
+ modelName,
454
+ operationName,
455
+ functionName,
456
+ parameterNames: inputs,
457
+ availableModels,
458
+ spec,
459
+ returnType,
460
+ });
461
+ if (retried) {
462
+ const retryCode = `export async function ${functionName}(input: any): Promise<any> {\n${retried}\n}`;
463
+ const retrySyntaxError = await validateTypeScript(retryCode);
464
+ const retryTypeError = retrySyntaxError ? null : await validateTypeScriptTypes(retryCode);
465
+ if (!retrySyntaxError && !retryTypeError) {
466
+ body = retried;
467
+ source = 'AI-GENERATED';
468
+ cacheWrite(key, body);
469
+ } else {
470
+ // Retry didn't help — keep the original body so the user can fix
471
+ // manually, but mark it as INVALID so it stands out.
472
+ source = 'AI-INVALID';
473
+ cacheWrite(key, body);
474
+ }
475
+ } else {
476
+ source = 'AI-INVALID';
477
+ cacheWrite(key, body);
478
+ }
479
+ } catch {
480
+ source = 'AI-INVALID';
481
+ cacheWrite(key, body);
482
+ }
483
+ } else {
484
+ source = 'AI-GENERATED';
485
+ cacheWrite(key, body);
486
+ }
299
487
  }
300
488
  }
301
489
  } catch {
@@ -335,8 +523,14 @@ ${inputsDoc}${returnsDoc} * Source: ${source}
335
523
  ? 'AI returned code with syntax errors — function throws at runtime. Fix or regenerate.'
336
524
  : 'STUB — Claude CLI unavailable. Install Claude Code or implement manually.'}
337
525
  */
338
- export async function ${functionName}(${signature}): Promise<${returnType}> {
339
- ${destructure ? destructure + '\n' : ''}${body}
526
+ export async function ${functionName}(${(() => {
527
+ const { signature: sig } = buildSignatureAndDestructure(body);
528
+ return sig;
529
+ })()}): Promise<${returnType}> {
530
+ ${(() => {
531
+ const { destructure } = buildSignatureAndDestructure(body);
532
+ return destructure ? destructure + '\n' : '';
533
+ })()}${body}
340
534
  }`);
341
535
  }
342
536
 
@@ -356,27 +550,28 @@ ${destructure ? destructure + '\n' : ''}${body}
356
550
  * These functions could not be generated from convention patterns.
357
551
  * They are called by ${ownerName} when executing operations.
358
552
  *
553
+ * PURE-FUNCTION CONTRACT — these bodies must NOT touch the database, the
554
+ * event bus, or any external service. Persistence and side effects happen
555
+ * in the calling controller; this file does pure transformations only.
556
+ *
359
557
  * Options for each function:
360
558
  * - Implement manually (recommended for business-critical logic)
361
559
  * - Use AI generation: specverse ai generate <function>
362
560
  * - Refactor the spec step to use a convention pattern
363
561
  *
364
- * Convention patterns that ARE auto-generated (no AI needed):
365
- * "Find {Model} by {field}" prisma.model.findUniqueOrThrow(...)
366
- * "Create {Model}" → prisma.model.create(...)
367
- * "Update {Model} {field} to {value}" → prisma.model.update(...)
368
- * "Delete {Model}" prisma.model.delete(...)
369
- * "Transition {Model} to {state}" → prisma.model.update({ status: ... })
370
- * "Count {Model}s per {Group}" → prisma.model.groupBy(...)
371
- * See step-conventions.ts for the full list.
562
+ * Convention patterns that ARE auto-generated by the realize engine (no
563
+ * AI needed) these are emitted inline in the controller, not here:
564
+ * "Find {Model} by {field}" → ORM-specific find call
565
+ * "Create {Model}" → ORM-specific create call
566
+ * "Update {Model} {field} to {value}"
567
+ * "Delete {Model}"
568
+ * "Transition {Model} to {state}"
569
+ * "Count {Model}s per {Group}"
570
+ * See the ORM's step-conventions module for the full list.
372
571
  *
373
572
  * Generated: ${new Date().toISOString().split('T')[0]}
374
573
  */
375
574
 
376
- import { PrismaClient } from '@prisma/client';
377
-
378
- const prisma = new PrismaClient();
379
-
380
575
  ${functions.join('\n\n')}
381
576
  `;
382
577
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specverse/engines",
3
- "version": "6.6.3",
3
+ "version": "6.7.8",
4
4
  "description": "SpecVerse toolchain — parser, inference, realize, generators, AI, registry, bundles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -62,26 +62,25 @@
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/engines": "^6.6.1",
66
65
  "@specverse/entities": "^5.1.0",
67
66
  "@specverse/runtime": "^5.0.1",
68
67
  "@specverse/types": "^5.1.0",
69
68
  "ai": "^6.0.168",
70
69
  "ajv": "^8.17.0",
71
70
  "ajv-formats": "^2.1.0",
71
+ "esbuild": "^0.25.0",
72
72
  "glob": "^10.0.0",
73
73
  "graphology": "^0.26.0",
74
74
  "graphology-communities-louvain": "^2.0.2",
75
75
  "handlebars": "^4.7.9",
76
76
  "js-yaml": "^4.1.0",
77
77
  "semver": "^7.0.0",
78
+ "typescript": "^5.4.0",
78
79
  "yaml": "^2.8.1",
79
80
  "zod": "^4.3.6"
80
81
  },
81
82
  "devDependencies": {
82
- "@types/node": "^25.5.0",
83
- "esbuild": "^0.25.0",
84
- "typescript": "^5.4.0"
83
+ "@types/node": "^25.5.0"
85
84
  },
86
85
  "files": [
87
86
  "dist",