@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.
- package/dist/libs/instance-factories/applications/templates/generic/backend-package-json-generator.js +26 -10
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +1 -1
- package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +16 -23
- package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +167 -26
- package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts +48 -12
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +1 -1
- package/libs/instance-factories/services/templates/mongodb-native/__tests__/controller-generator.test.ts +3 -1
- package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +28 -25
- package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +231 -36
- package/package.json +4 -5
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
42
|
+
import { dirname, join } from 'path';
|
|
43
43
|
import { fileURLToPath } from 'url';
|
|
44
44
|
{
|
|
45
45
|
let dir = dirname(fileURLToPath(import.meta.url));
|
package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
224
|
+
function generateCustomActions(controller) {
|
|
226
225
|
if (!controller.actions || Object.keys(controller.actions).length === 0) {
|
|
227
|
-
return { code: ""
|
|
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}(
|
|
242
|
-
|
|
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")
|
|
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
|
-
|
|
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
|
|
128
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
172
|
-
if (
|
|
173
|
-
console.warn(` [ai-validate] ${functionName} has syntax error: ${
|
|
174
|
-
body = `// AI-generated code failed validation: ${
|
|
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
|
-
|
|
180
|
-
|
|
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}(${
|
|
208
|
-
|
|
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
|
|
229
|
-
*
|
|
230
|
-
* "
|
|
231
|
-
* "
|
|
232
|
-
* "
|
|
233
|
-
* "
|
|
234
|
-
* "
|
|
235
|
-
*
|
|
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
|
}
|
package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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: ''
|
|
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}(
|
|
301
|
-
|
|
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')
|
|
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.
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
//
|
|
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
|
|
291
|
-
if (
|
|
292
|
-
console.warn(` [ai-validate] ${functionName} has syntax error: ${
|
|
293
|
-
//
|
|
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
|
-
|
|
298
|
-
|
|
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}(${
|
|
339
|
-
|
|
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
|
|
365
|
-
*
|
|
366
|
-
* "
|
|
367
|
-
* "
|
|
368
|
-
* "
|
|
369
|
-
* "
|
|
370
|
-
* "
|
|
371
|
-
*
|
|
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.
|
|
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",
|