@specverse/engines 6.11.2 → 6.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/ai/behavior-ai-service.js +2 -2
  2. package/dist/ai/behavior-ai-service.js.map +1 -1
  3. package/dist/ai/index.d.ts +2 -0
  4. package/dist/ai/index.d.ts.map +1 -1
  5. package/dist/ai/index.js +4 -0
  6. package/dist/ai/index.js.map +1 -1
  7. package/dist/ai/library-whitelist.d.ts +81 -0
  8. package/dist/ai/library-whitelist.d.ts.map +1 -0
  9. package/dist/ai/library-whitelist.js +251 -0
  10. package/dist/ai/library-whitelist.js.map +1 -0
  11. package/dist/libs/instance-factories/applications/templates/generic/backend-package-json-generator.js +42 -5
  12. package/dist/libs/instance-factories/services/postgres-native-services.yaml +90 -0
  13. package/dist/libs/instance-factories/services/templates/_shared/step-matching.js +44 -0
  14. package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +18 -6
  15. package/dist/libs/instance-factories/services/templates/mongodb-native/step-conventions.js +230 -34
  16. package/dist/libs/instance-factories/services/templates/postgres-native/client-generator.js +165 -0
  17. package/dist/libs/instance-factories/services/templates/postgres-native/controller-generator.js +300 -0
  18. package/dist/libs/instance-factories/services/templates/postgres-native/ddl-generator.js +169 -0
  19. package/dist/libs/instance-factories/services/templates/postgres-native/service-generator.js +65 -0
  20. package/dist/libs/instance-factories/services/templates/postgres-native/step-conventions.js +433 -0
  21. package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +24 -9
  22. package/dist/libs/instance-factories/services/templates/prisma/step-conventions.js +7 -34
  23. package/dist/realize/index.d.ts.map +1 -1
  24. package/dist/realize/index.js +8 -0
  25. package/dist/realize/index.js.map +1 -1
  26. package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts +82 -24
  27. package/libs/instance-factories/services/postgres-native-services.yaml +90 -0
  28. package/libs/instance-factories/services/templates/_shared/step-matching.ts +103 -0
  29. package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +25 -5
  30. package/libs/instance-factories/services/templates/mongodb-native/step-conventions.ts +336 -68
  31. package/libs/instance-factories/services/templates/postgres-native/__tests__/controller-generator.test.ts +193 -0
  32. package/libs/instance-factories/services/templates/postgres-native/client-generator.ts +178 -0
  33. package/libs/instance-factories/services/templates/postgres-native/controller-generator.ts +372 -0
  34. package/libs/instance-factories/services/templates/postgres-native/ddl-generator.ts +236 -0
  35. package/libs/instance-factories/services/templates/postgres-native/service-generator.ts +84 -0
  36. package/libs/instance-factories/services/templates/postgres-native/step-conventions.ts +539 -0
  37. package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +38 -7
  38. package/libs/instance-factories/services/templates/prisma/step-conventions.ts +21 -68
  39. package/package.json +3 -3
@@ -0,0 +1,539 @@
1
+ /**
2
+ * Step Conventions for PostgreSQL native (pg) — mirrors the mongo-native
3
+ * library's pattern set but emits raw-SQL via the pgClient helpers
4
+ * (`insertOne` / `findOneByField` / `updateOneById` / etc.) instead of
5
+ * collection methods.
6
+ *
7
+ * Same convention names + same pattern texts, so spec authors can swap
8
+ * MongoDBNativeDriver for PostgresNativeDriver in the manifest without
9
+ * touching their .specly. The shared matcher (`matchAgainstConventions`
10
+ * in `../_shared/step-matching.ts`) drives both libraries — this file
11
+ * is purely the conventions array + a pg-flavoured `matchPgStep`
12
+ * delegate. (#43K-D follow-up: third ORM library demonstrating the
13
+ * abstraction.)
14
+ */
15
+
16
+ import {
17
+ toMethod,
18
+ toVar,
19
+ matchAgainstConventions,
20
+ type SharedConvention,
21
+ type SharedStepContext,
22
+ } from '../_shared/step-matching.js';
23
+
24
+ export type PgStepConvention = SharedConvention<PgStepContext>;
25
+
26
+ export interface PgStepContext extends SharedStepContext {
27
+ /** Table name for the model (lowercased + 's' or storage.table). */
28
+ tableName: string;
29
+ /** Full model registry so conventions can read attributes for default
30
+ * values, types, and FK target tables. */
31
+ models?: Record<string, any>;
32
+ }
33
+
34
+ /**
35
+ * Compute sensible default values for a model's attributes — the
36
+ * postgres-native counterpart of mongodb-native's `deriveModelDefaults`.
37
+ * Same heuristics; same conventionManaged exclusions.
38
+ *
39
+ * Defaults emitted as `key: expression` strings ready to inline into an
40
+ * object-literal record body (the body is then handed to `insertOne`,
41
+ * which does the SQL plumbing). Empty if the model isn't in the registry.
42
+ */
43
+ export function deriveModelDefaults(
44
+ modelName: string,
45
+ ctx: PgStepContext,
46
+ ): string[] {
47
+ if (!ctx.models) return [];
48
+ const model = ctx.models[modelName] || ctx.models[modelName.charAt(0).toUpperCase() + modelName.slice(1)];
49
+ if (!model) return [];
50
+ const attrs = model.attributes;
51
+ if (!attrs) return [];
52
+ const list = Array.isArray(attrs)
53
+ ? attrs.map((a: any) => [a.name, a])
54
+ : Object.entries(attrs);
55
+
56
+ const declaredVars = ctx.declaredVars || new Set();
57
+ const out: string[] = [];
58
+
59
+ // The "id" column is database-managed (BIGSERIAL or DEFAULT gen_random_uuid())
60
+ // so we don't emit it from the convention. createdAt/updatedAt come from
61
+ // the convention footer.
62
+ const conventionManaged = new Set(['createdAt', 'updatedAt', 'id']);
63
+ for (const [name, attr] of list as [string, any][]) {
64
+ if (!name) continue;
65
+ if (conventionManaged.has(name)) continue;
66
+ const required = !!attr.required;
67
+ const hasDefault = attr.default !== undefined;
68
+ if (!required && !hasDefault) continue;
69
+ if (hasDefault) {
70
+ out.push(`${name}: ${formatDefault(attr.default, attr.type)}`);
71
+ continue;
72
+ }
73
+ const type = (attr.type || 'String').toLowerCase();
74
+ if (type === 'integer' || type === 'int' || type === 'number' || type === 'float') {
75
+ const min = attr.min ?? 0;
76
+ out.push(`${name}: ${min}`);
77
+ } else if (type === 'boolean') {
78
+ out.push(`${name}: false`);
79
+ } else if (type === 'datetime' || type === 'date') {
80
+ out.push(`${name}: new Date().toISOString()`);
81
+ } else {
82
+ // String / UUID / Text — try FK resolution first.
83
+ const selfVar = modelName.charAt(0).toLowerCase() + modelName.slice(1);
84
+ const fkMatch = name.match(/^(.+)Id$/);
85
+ if (fkMatch && fkMatch[1] !== selfVar && declaredVars.has(fkMatch[1])) {
86
+ out.push(`${name}: (${fkMatch[1]} as any)?.id`);
87
+ } else {
88
+ out.push(`${name}: ''`);
89
+ }
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ function formatDefault(value: any, type?: string): string {
96
+ if (value === null || value === undefined) return 'null';
97
+ if (typeof value === 'boolean') return String(value);
98
+ if (typeof value === 'number') return String(value);
99
+ if (typeof value === 'string') {
100
+ if (/^-?\d+(\.\d+)?$/.test(value)) return value;
101
+ if (value === 'true' || value === 'false') return value;
102
+ if (value === 'now' && (type === 'DateTime' || type === 'Date')) {
103
+ return 'new Date().toISOString()';
104
+ }
105
+ return JSON.stringify(value);
106
+ }
107
+ return JSON.stringify(value);
108
+ }
109
+
110
+ function toTable(modelName: string): string {
111
+ return modelName.toLowerCase() + 's';
112
+ }
113
+
114
+ /** Resolve a value reference in step text to a TS expression. Mirrors
115
+ * mongo's resolveValue so spec phrases map identically. */
116
+ function resolveValue(rawValue: string, ctx: PgStepContext): string {
117
+ const value = rawValue.trim().replace(/^['"]|['"]$/g, '');
118
+ if (/^(current\s*time|now|timestamp)$/i.test(value)) return 'new Date().toISOString()';
119
+ if (/^-?\d+(\.\d+)?$/.test(value)) return value;
120
+ if (value === 'true' || value === 'false') return value;
121
+ if (value === 'null') return 'null';
122
+ const declared = ctx.declaredVars || new Set();
123
+ const params = ctx.parameterNames || [];
124
+ const head = value.split('.')[0]!;
125
+ if (declared.has(head)) return value;
126
+ if (params.includes(head)) return `args.${value}`;
127
+ return `'${value.replace(/'/g, "\\'")}'`;
128
+ }
129
+
130
+ function mostRecentStepResult(ctx: PgStepContext): string | null {
131
+ const declared = Array.from(ctx.declaredVars || []);
132
+ const stepResults = declared.filter((v) => /^step\d+Result$/.test(v))
133
+ .sort((a, b) => parseInt(b.slice(4), 10) - parseInt(a.slice(4), 10));
134
+ return stepResults[0] ?? null;
135
+ }
136
+
137
+ function pascal(model: string): string {
138
+ return model.charAt(0).toUpperCase() + model.slice(1);
139
+ }
140
+
141
+ export const PG_STEP_CONVENTIONS: PgStepConvention[] = [
142
+ // --- Find / Lookup by single field ---
143
+ {
144
+ name: 'find-by-field',
145
+ pattern: /^(?:look\s+up|find|fetch|get)\s+(\w+)\s+by\s+(\w+)(?:\s+or\s+fail.*)?$/i,
146
+ generateCall: (m, ctx) => {
147
+ const model = m[1]!;
148
+ const field = m[2]!;
149
+ const modelVar = toVar(model);
150
+ const table = toTable(model);
151
+ const params = ctx.parameterNames || [];
152
+ const declared = ctx.declaredVars || new Set();
153
+ const idVal = field === 'id'
154
+ ? (params.includes(`${modelVar}Id`) ? `args.${modelVar}Id` : 'args.id')
155
+ : `args.${field}`;
156
+
157
+ if (declared.has(modelVar)) {
158
+ return ` // Step ${ctx.stepNum}: Find ${model} by ${field} (already loaded)`;
159
+ }
160
+ declared.add(modelVar);
161
+ const failOnMissing = /or\s+fail/i.test(m[0]);
162
+ // `let` so subsequent conditional-create can reassign on null lookup.
163
+ return ` // Step ${ctx.stepNum}: Find ${model} by ${field}
164
+ let ${modelVar} = await findOneByField('${table}', '${field}', ${idVal}) as any;${failOnMissing ? `
165
+ if (!${modelVar}) throw new Error('${model} not found');` : ''}`;
166
+ },
167
+ },
168
+
169
+ // --- Find by composite (two fields) ---
170
+ {
171
+ name: 'find-by-composite',
172
+ pattern: /^(?:look\s+up|find|fetch|get)(?:\s+existing)?\s+(\w+)\s+by\s+(\w+)\s+and\s+(\w+)$/i,
173
+ generateCall: (m, ctx) => {
174
+ const model = m[1]!;
175
+ const f1 = m[2]!;
176
+ const f2 = m[3]!;
177
+ const modelVar = toVar(model);
178
+ const table = toTable(model);
179
+ const declared = ctx.declaredVars || new Set();
180
+ if (declared.has(modelVar)) {
181
+ return ` // Step ${ctx.stepNum}: Find ${model} by ${f1} and ${f2} (already loaded)`;
182
+ }
183
+ declared.add(modelVar);
184
+ const resolveFieldSource = (f: string) => {
185
+ const stripIdMatch = f.match(/^(.+)Id$/);
186
+ if (stripIdMatch) {
187
+ const candidate = stripIdMatch[1]!;
188
+ if (candidate !== modelVar && declared.has(candidate)) {
189
+ return `(${candidate} as any)?.id`;
190
+ }
191
+ }
192
+ return `args.${f}`;
193
+ };
194
+ const f1Src = resolveFieldSource(f1);
195
+ const f2Src = resolveFieldSource(f2);
196
+ return ` // Step ${ctx.stepNum}: Find ${model} by ${f1} and ${f2}
197
+ let ${modelVar} = await findOneByFields('${table}', { ${f1}: ${f1Src}, ${f2}: ${f2Src} }) as any;`;
198
+ },
199
+ },
200
+
201
+ // --- Create model record ---
202
+ {
203
+ name: 'create',
204
+ pattern: /^create\s+(?:new\s+)?(\w+)(?:\s+(?:with\s+)?(.+))?/i,
205
+ generateCall: (m, ctx) => {
206
+ const model = m[1]!;
207
+ const modelVar = toVar(model);
208
+ const table = toTable(model);
209
+ const params = ctx.parameterNames || [];
210
+ const declared = ctx.declaredVars || new Set();
211
+ declared.add(modelVar);
212
+ const dataExpr = params.length > 0 ? `{ ${params.join(', ')} }` : 'args';
213
+ return ` // Step ${ctx.stepNum}: Create ${model}
214
+ const ${modelVar} = await insertOne('${table}', ${dataExpr}) as any;`;
215
+ },
216
+ },
217
+
218
+ // --- Update specific field on previously-loaded model ---
219
+ {
220
+ name: 'update-field',
221
+ pattern: /^update\s+(\w+)\s+(\w+)\s+to\s+(.+)/i,
222
+ generateCall: (m, ctx) => {
223
+ const model = m[1]!;
224
+ const field = m[2]!;
225
+ const rawValue = m[3]!;
226
+ const modelVar = toVar(model);
227
+ if (!ctx.declaredVars?.has(modelVar)) return '';
228
+ const table = toTable(model);
229
+ const val = resolveValue(rawValue, ctx);
230
+ return ` // Step ${ctx.stepNum}: Update ${model}.${field} to ${rawValue.trim()}
231
+ await updateOneById('${table}', ${modelVar}.id, { ${field}: ${val} });`;
232
+ },
233
+ },
234
+
235
+ // --- Update field timestamp ---
236
+ {
237
+ name: 'update-field-timestamp',
238
+ pattern: /^update\s+(\w+)\s+(\w+)\s+timestamp$/i,
239
+ generateCall: (m, ctx) => {
240
+ const model = m[1]!;
241
+ const field = m[2]!;
242
+ const modelVar = toVar(model);
243
+ if (!ctx.declaredVars?.has(modelVar)) return '';
244
+ const table = toTable(model);
245
+ return ` // Step ${ctx.stepNum}: Update ${model}.${field} timestamp
246
+ await updateOneById('${table}', ${modelVar}.id, { ${field}: new Date().toISOString() });`;
247
+ },
248
+ },
249
+
250
+ // --- Generic "Update X" (writes args back) ---
251
+ {
252
+ name: 'update',
253
+ pattern: /^update\s+(\w+)(?:\s+(.+))?$/i,
254
+ generateCall: (m, ctx) => {
255
+ const model = m[1]!;
256
+ const modelVar = toVar(model);
257
+ if (!ctx.declaredVars?.has(modelVar)) return '';
258
+ const table = toTable(model);
259
+ return ` // Step ${ctx.stepNum}: Update ${model}
260
+ await updateOneById('${table}', ${modelVar}.id, args);`;
261
+ },
262
+ },
263
+
264
+ // --- Delete model record ---
265
+ {
266
+ name: 'delete',
267
+ pattern: /^delete\s+(\w+)/i,
268
+ generateCall: (m, ctx) => {
269
+ const model = m[1]!;
270
+ const modelVar = toVar(model);
271
+ if (!ctx.declaredVars?.has(modelVar)) return '';
272
+ const table = toTable(model);
273
+ return ` // Step ${ctx.stepNum}: Delete ${model}
274
+ await deleteOneById('${table}', ${modelVar}.id);`;
275
+ },
276
+ },
277
+
278
+ // --- Transition to lifecycle state ---
279
+ {
280
+ name: 'transition',
281
+ pattern: /^transition\s+(\w+)\s+to\s+(\w+)/i,
282
+ generateCall: (m, ctx) => {
283
+ const model = m[1]!;
284
+ const state = m[2]!;
285
+ const modelVar = toVar(model);
286
+ if (!ctx.declaredVars?.has(modelVar)) return '';
287
+ const table = toTable(model);
288
+ return ` // Step ${ctx.stepNum}: Transition ${model} to ${state}
289
+ if ((${modelVar} as any).status === '${state}') throw new Error('${model} is already ${state}');
290
+ await updateOneById('${table}', ${modelVar}.id, { status: '${state}' });`;
291
+ },
292
+ },
293
+
294
+ // --- Set field to value (on the controller's primary model) ---
295
+ {
296
+ name: 'set',
297
+ pattern: /^set\s+(\w+)\s+to\s+(.+)/i,
298
+ generateCall: (m, ctx) => {
299
+ const field = m[1]!;
300
+ const rawValue = m[2]!;
301
+ const modelVar = toVar(ctx.modelName);
302
+ const val = resolveValue(rawValue, ctx);
303
+ return ` // Step ${ctx.stepNum}: Set ${field} to ${rawValue.trim()}
304
+ await updateOneById(TABLE_NAME, (${modelVar} as any).id, { ${field}: ${val} });`;
305
+ },
306
+ },
307
+
308
+ // --- Increment / Decrement ---
309
+ // Postgres has no Mongo $inc; emit a column = column +/- N update. The
310
+ // helper updateOneById doesn't support raw SQL fragments, so emit query
311
+ // directly here.
312
+ {
313
+ name: 'increment',
314
+ pattern: /^increment\s+(\w+)\s+by\s+(\w+)/i,
315
+ generateCall: (m, ctx) => {
316
+ const field = m[1]!;
317
+ const amount = m[2]!;
318
+ const modelVar = toVar(ctx.modelName);
319
+ return ` // Step ${ctx.stepNum}: Increment ${field} by ${amount}
320
+ await query(\`UPDATE "\${TABLE_NAME}" SET "${field}" = "${field}" + $1 WHERE "id" = $2\`, [${amount}, (${modelVar} as any).id]);`;
321
+ },
322
+ },
323
+ {
324
+ name: 'decrement',
325
+ pattern: /^decrement\s+(\w+)\s+by\s+(\w+)/i,
326
+ generateCall: (m, ctx) => {
327
+ const field = m[1]!;
328
+ const amount = m[2]!;
329
+ const modelVar = toVar(ctx.modelName);
330
+ return ` // Step ${ctx.stepNum}: Decrement ${field} by ${amount}
331
+ await query(\`UPDATE "\${TABLE_NAME}" SET "${field}" = "${field}" - $1 WHERE "id" = $2\`, [${amount}, (${modelVar} as any).id]);`;
332
+ },
333
+ },
334
+
335
+ // --- Persist / Save / Store ---
336
+ {
337
+ name: 'persist',
338
+ pattern: /^(?:persist|save|store)\s+(\w+(?:\s+\w+)?)(?:\s+(?:for|to|record).*)?$/i,
339
+ generateCall: (m, ctx) => {
340
+ const target = toVar(m[1]!.replace(/\s+(.)/g, (_, c) => c.toUpperCase()));
341
+ const table = toTable(target);
342
+ const recordSrc = mostRecentStepResult(ctx) ?? 'args';
343
+ // Ensure `record` is an object before insertOne — wrap primitives.
344
+ return ` // Step ${ctx.stepNum}: Persist ${m[1]}
345
+ {
346
+ const _rec: any = ${recordSrc} && typeof ${recordSrc} === 'object' && !Array.isArray(${recordSrc})
347
+ ? ${recordSrc} : { value: ${recordSrc} };
348
+ await insertOne('${table}', _rec);
349
+ }`;
350
+ },
351
+ },
352
+
353
+ // --- Conditional create ---
354
+ {
355
+ name: 'conditional-create',
356
+ pattern: /^if\s+(\w+)\s+does\s+not\s+exist,?\s+create\s+new\s+(\w+)(?:\s+with\s+.+)?$/i,
357
+ generateCall: (m, ctx) => {
358
+ const modelVar = toVar(m[1]!);
359
+ const Model = pascal(m[2]!);
360
+ const table = toTable(Model);
361
+ if (!ctx.declaredVars?.has(modelVar)) return '';
362
+ const defaults = deriveModelDefaults(Model, ctx);
363
+ const defaultsBlock = defaults.length > 0 ? defaults.join(', ') + ',' : '';
364
+ return ` // Step ${ctx.stepNum}: If ${modelVar} does not exist, create new ${Model}
365
+ if (!${modelVar}) {
366
+ const _newRecord = { ${defaultsBlock} ...args, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
367
+ ${modelVar} = await insertOne('${table}', _newRecord) as any;
368
+ }`;
369
+ },
370
+ },
371
+
372
+ // --- Conditional update ---
373
+ {
374
+ name: 'conditional-update',
375
+ pattern: /^if\s+(\w+)\s+exists,?\s+update\s+(\w+)(?:\s+(.+))?$/i,
376
+ generateCall: (m, ctx) => {
377
+ const modelVar = toVar(m[1]!);
378
+ const field = m[2]!;
379
+ const table = toTable(m[1]!);
380
+ if (!ctx.declaredVars?.has(modelVar)) return '';
381
+ return ` // Step ${ctx.stepNum}: If ${modelVar} exists, update ${field}
382
+ if (${modelVar}) {
383
+ await updateOneById('${table}', (${modelVar} as any).id, { ${field}: new Date().toISOString() });
384
+ }`;
385
+ },
386
+ },
387
+
388
+ // --- Auto-create / Bulk fan-out ---
389
+ {
390
+ name: 'auto-create-loop',
391
+ pattern: /^(?:auto-create|bulk\s+create)\s+(\w+)\s+(?:profile|record|entry|entries)?s?\s+for\s+(?:all\s+)?(?:available\s+)?(\w+)$/i,
392
+ generateCall: (m, ctx) => {
393
+ const Model = pascal(m[1]!);
394
+ const targetTable = toTable(Model);
395
+ const sourceTable = m[2]!.toLowerCase();
396
+ const declared = Array.from(ctx.declaredVars || []);
397
+ const ownerVar = declared.find((v) => !/^step\d+Result$/.test(v) && v !== 'args');
398
+ const sourceSingular = sourceTable.replace(/s$/, '');
399
+ const linkField = sourceSingular + 'Id';
400
+ const ownerLinkField = ownerVar ? ownerVar + 'Id' : 'ownerId';
401
+
402
+ const modelDefaults = deriveModelDefaults(Model, ctx);
403
+ const defaultsBlock = modelDefaults.length > 0
404
+ ? modelDefaults.filter((d) => !d.startsWith(`${ownerLinkField}:`) && !d.startsWith(`${linkField}:`)).join(', ')
405
+ : '';
406
+
407
+ return ` // Step ${ctx.stepNum}: Auto-create ${m[1]} ${m[2]} for all ${m[2]}
408
+ {
409
+ const _allItems = await findAll('${sourceTable}');
410
+ const _ownerId = ${ownerVar ? `(${ownerVar} as any)?.id` : 'null'};
411
+ const _records = _allItems.map((_item: any) => ({
412
+ ${defaultsBlock ? defaultsBlock + ',' : ''}
413
+ ${ownerLinkField}: _ownerId,
414
+ ${linkField}: (_item as any).${sourceSingular}Id ?? (_item as any).id,
415
+ createdAt: new Date().toISOString(),
416
+ updatedAt: new Date().toISOString(),
417
+ }));
418
+ if (_records.length > 0) {
419
+ await insertMany('${targetTable}', _records);
420
+ }
421
+ }`;
422
+ },
423
+ },
424
+
425
+ // --- "Otherwise create new X record" ---
426
+ {
427
+ name: 'otherwise-create',
428
+ pattern: /^otherwise\s+create\s+(?:new\s+)?(\w+)\s+record$/i,
429
+ generateCall: (m, ctx) => {
430
+ const Model = pascal(m[1]!);
431
+ const modelVar = toVar(Model);
432
+ const table = toTable(Model);
433
+ const wasDeclared = ctx.declaredVars?.has(modelVar);
434
+ const declared = Array.from(ctx.declaredVars || []);
435
+ const defaults = deriveModelDefaults(Model, ctx);
436
+ const supplied = new Set<string>();
437
+ for (const entry of defaults) {
438
+ const colonIdx = entry.indexOf(':');
439
+ if (colonIdx > 0) supplied.add(entry.slice(0, colonIdx).trim());
440
+ }
441
+ const fkAssignments = declared
442
+ .filter((v) => v !== modelVar && !/^step\d+Result$/.test(v) && v !== 'args')
443
+ .filter((v) => !supplied.has(v + 'Id'))
444
+ .map((v) => {
445
+ supplied.add(v + 'Id');
446
+ return `${v}Id: (${v} as any)?.id`;
447
+ })
448
+ .join(', ');
449
+ const defaultsBlock = defaults.length > 0 ? defaults.join(', ') + ',' : '';
450
+ ctx.declaredVars?.add(modelVar);
451
+ return ` // Step ${ctx.stepNum}: Otherwise create new ${Model} record
452
+ else {
453
+ const _newRecord = { ${defaultsBlock} ${fkAssignments ? fkAssignments + ',' : ''} ...args, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
454
+ ${wasDeclared ? `${modelVar} = await insertOne('${table}', _newRecord) as any;` : `const ${modelVar} = await insertOne('${table}', _newRecord) as any;
455
+ void ${modelVar};`}
456
+ }`;
457
+ },
458
+ },
459
+
460
+ // --- Send/Emit/Publish event ---
461
+ {
462
+ name: 'send-event',
463
+ pattern: /^(?:send|emit|publish)\s+(\w+)\s+event/i,
464
+ generateCall: (m, ctx) => {
465
+ const event = m[1]!;
466
+ const modelVar = toVar(ctx.modelName);
467
+ const hasModelVar = ctx.declaredVars?.has(modelVar);
468
+ const payload = hasModelVar
469
+ ? `{ ${modelVar}Id: (${modelVar} as any)?.id, operation: '${ctx.operationName}', timestamp: new Date().toISOString() }`
470
+ : `{ operation: '${ctx.operationName}', timestamp: new Date().toISOString() }`;
471
+ return ` // Step ${ctx.stepNum}: Emit ${event} event
472
+ await eventBus.publish('${event}', ${payload} as any);`;
473
+ },
474
+ },
475
+
476
+ // --- Call service ---
477
+ {
478
+ name: 'call-service',
479
+ pattern: /^call\s+(\w+)\.(\w+)/i,
480
+ generateCall: (m, ctx) => {
481
+ const service = m[1]!;
482
+ const method = m[2]!;
483
+ const args = (ctx.parameterNames || []).join(', ');
484
+ return ` // Step ${ctx.stepNum}: Call ${service}.${method}
485
+ await (${toVar(service)} as any).${method}({ ${args} });`;
486
+ },
487
+ },
488
+
489
+ // --- Return ---
490
+ {
491
+ name: 'return-model',
492
+ pattern: /^return\s+(?:the\s+|updated\s+|created\s+)?(\w+)\s*$/i,
493
+ generateCall: (m, ctx) => {
494
+ const valueRaw = m[1]!.trim();
495
+ const declared = ctx.declaredVars || new Set();
496
+ const params = ctx.parameterNames || [];
497
+ const modelVar = toVar(ctx.modelName);
498
+ if (valueRaw.toLowerCase() === ctx.modelName.toLowerCase() || valueRaw === modelVar) {
499
+ return ` // Step ${ctx.stepNum}: Return ${ctx.modelName}
500
+ return ${modelVar};`;
501
+ }
502
+ if (declared.has(valueRaw)) {
503
+ return ` // Step ${ctx.stepNum}: Return ${valueRaw}
504
+ return ${valueRaw};`;
505
+ }
506
+ if (params.includes(valueRaw)) {
507
+ return ` // Step ${ctx.stepNum}: Return ${valueRaw}
508
+ return args.${valueRaw};`;
509
+ }
510
+ return ` // Step ${ctx.stepNum}: Return ${valueRaw} — TODO: resolve binding
511
+ return null;`;
512
+ },
513
+ },
514
+ ];
515
+
516
+ /**
517
+ * Match a step against the postgres-native conventions. Same shape as
518
+ * `matchStep` (prisma) and `matchMongoStep` — the orm-agnostic
519
+ * AI-behaviors-generator can drive any of the three.
520
+ */
521
+ export function matchPgStep(
522
+ step: string,
523
+ ctx: PgStepContext,
524
+ ): {
525
+ matched: boolean;
526
+ call: string;
527
+ helperMethod?: string;
528
+ functionName?: string;
529
+ inputs?: string[];
530
+ resultVar?: string;
531
+ } {
532
+ // Same `args.X` plumbing as mongo-native — the controller wraps the
533
+ // action body with a single `args` parameter rather than destructuring.
534
+ const aiArgsExpr = (inputs: string[], paramNames: string[]) =>
535
+ inputs.length > 0
536
+ ? `{ ${inputs.map((n) => paramNames.includes(n) ? `${n}: args.${n}` : n).join(', ')} }`
537
+ : '{}';
538
+ return matchAgainstConventions(step, ctx, PG_STEP_CONVENTIONS, aiArgsExpr);
539
+ }
@@ -19,6 +19,7 @@
19
19
 
20
20
  import type { TemplateContext } from '@specverse/types';
21
21
  import { matchStep, type StepContext } from './step-conventions.js';
22
+ import { validateImportWhitelist } from '@specverse/engines/ai';
22
23
  import { createHash } from 'crypto';
23
24
  import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'fs';
24
25
  import { dirname, join } from 'path';
@@ -59,6 +60,26 @@ async function validateTypeScript(code: string): Promise<string | null> {
59
60
  * unused locals, undefined references. Reprompting with the tsc error
60
61
  * lets the LLM self-correct without burning a per-step retry.
61
62
  */
63
+ /**
64
+ * Validate that every dynamic `await import('LIT')` in `code` references
65
+ * a whitelisted library. Returns null if clean; an error message
66
+ * otherwise. The whitelist (and the rationale behind each entry) lives
67
+ * in `engines/src/ai/library-whitelist.ts` — single source of truth.
68
+ *
69
+ * This closes the gap noted in TODO #43K-B-review: pre-fix, the type
70
+ * validator above failed on every dynamic import (whitelisted or not)
71
+ * because the engines workspace has none of the whitelist libs
72
+ * installed; bodies were silently routed through the AI-INVALID
73
+ * fallback regardless of import legality. Bodies importing
74
+ * non-whitelisted libs would crash at runtime in the realized backend.
75
+ * Now we reject them at generation time.
76
+ */
77
+ function validateImports(code: string): string | null {
78
+ const offenders = validateImportWhitelist(code);
79
+ if (offenders.length === 0) return null;
80
+ return `import not in whitelist: ${offenders.join(', ')} (allowed: jsonwebtoken | bcryptjs | uuid | crypto | expr-eval)`;
81
+ }
82
+
62
83
  async function validateTypeScriptTypes(code: string): Promise<string | null> {
63
84
  let ts: any;
64
85
  try {
@@ -135,7 +156,7 @@ async function validateTypeScriptTypes(code: string): Promise<string | null> {
135
156
  * produces a new hash. The prompt version is part of the hash so
136
157
  * prompt upgrades also invalidate.
137
158
  */
138
- const PROMPT_VERSION = '9.2.0';
159
+ const PROMPT_VERSION = '9.8.0'; // 9.8: import-whitelist enforcement (#43K-B-review)
139
160
 
140
161
  function cacheKey(step: string, modelName: string, operationName: string, functionName: string, inputs: string[]): string {
141
162
  const payload = JSON.stringify({ step, modelName, operationName, functionName, inputs: [...inputs].sort(), v: PROMPT_VERSION });
@@ -458,8 +479,9 @@ export async function generateAiBehaviorsFile(opts: {
458
479
  const testCode = `export async function ${functionName}(input: any): Promise<any> {\n${body}\n}`;
459
480
  const syntaxError = await validateTypeScript(testCode);
460
481
  const typeError = syntaxError ? null : await validateTypeScriptTypes(testCode);
461
- if (syntaxError || typeError) {
462
- console.warn(` [ai-validate] cached ${functionName} failed validation: ${syntaxError || typeError}`);
482
+ const importError = (syntaxError || typeError) ? null : validateImports(testCode);
483
+ if (syntaxError || typeError || importError) {
484
+ console.warn(` [ai-validate] cached ${functionName} failed validation: ${syntaxError || typeError || importError}`);
463
485
  body = null; // Force regeneration
464
486
  source = 'STUB';
465
487
  } else {
@@ -498,10 +520,18 @@ export async function generateAiBehaviorsFile(opts: {
498
520
  source = 'AI-INVALID';
499
521
  } else {
500
522
  const typeError = await validateTypeScriptTypes(testCode);
501
- if (typeError) {
502
- console.warn(` [ai-validate] ${functionName} type errors: ${typeError}`);
523
+ // Whitelist gate runs after type check so error precedence is
524
+ // syntax > types > imports (most-actionable error first).
525
+ const importError = typeError ? null : validateImports(testCode);
526
+ if (typeError || importError) {
527
+ console.warn(` [ai-validate] ${functionName} ${typeError ? 'type errors: ' + typeError : 'whitelist violation: ' + importError}`);
503
528
  try {
504
- 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.`;
529
+ // Build a retry hint that targets whichever gate failed.
530
+ // Mixed failures (type AND import) get both messages.
531
+ const errorParts: string[] = [];
532
+ if (typeError) errorParts.push(`TypeScript type errors:\n${typeError}`);
533
+ if (importError) errorParts.push(`Import-whitelist violation: ${importError}.\nOnly these libraries may be dynamic-imported: jsonwebtoken, bcryptjs, uuid, crypto, expr-eval. Anything else is forbidden — throw an Error if the step needs an unsupported library.`);
534
+ const retryHint = `Your previous output had problems:\n\n${errorParts.join('\n\n')}\n\nFix these specifically — common type-error 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.`;
505
535
  const retried = await aiService.generateBehavior({
506
536
  step: `${step}\n\n${retryHint}`,
507
537
  modelName,
@@ -516,7 +546,8 @@ export async function generateAiBehaviorsFile(opts: {
516
546
  const retryCode = `export async function ${functionName}(input: any): Promise<any> {\n${retried}\n}`;
517
547
  const retrySyntaxError = await validateTypeScript(retryCode);
518
548
  const retryTypeError = retrySyntaxError ? null : await validateTypeScriptTypes(retryCode);
519
- if (!retrySyntaxError && !retryTypeError) {
549
+ const retryImportError = (retrySyntaxError || retryTypeError) ? null : validateImports(retryCode);
550
+ if (!retrySyntaxError && !retryTypeError && !retryImportError) {
520
551
  body = retried;
521
552
  source = 'AI-GENERATED';
522
553
  cacheWrite(key, body);