@specverse/engines 6.16.0 → 6.18.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.
- package/dist/ai/index.d.ts +2 -0
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js +4 -0
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/library-whitelist.d.ts +81 -0
- package/dist/ai/library-whitelist.d.ts.map +1 -0
- package/dist/ai/library-whitelist.js +251 -0
- package/dist/ai/library-whitelist.js.map +1 -0
- package/dist/libs/instance-factories/applications/templates/generic/backend-package-json-generator.js +34 -14
- package/dist/libs/instance-factories/services/templates/postgres-native/ddl-generator.js +61 -7
- package/dist/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.js +24 -9
- package/libs/instance-factories/applications/templates/generic/backend-package-json-generator.ts +50 -14
- package/libs/instance-factories/services/templates/postgres-native/__tests__/ddl-generator.test.ts +285 -0
- package/libs/instance-factories/services/templates/postgres-native/ddl-generator.ts +140 -24
- package/libs/instance-factories/services/templates/prisma/ai-behaviors-generator.ts +38 -7
- package/package.json +1 -1
|
@@ -13,24 +13,35 @@
|
|
|
13
13
|
* Float, Number → DOUBLE PRECISION
|
|
14
14
|
* Boolean → BOOLEAN
|
|
15
15
|
* DateTime, Date → TIMESTAMPTZ
|
|
16
|
-
*
|
|
16
|
+
* UUID → UUID
|
|
17
|
+
* Json, Jsonb → JSONB
|
|
17
18
|
*
|
|
18
19
|
* Primary key:
|
|
19
|
-
* - `
|
|
20
|
-
*
|
|
20
|
+
* - Composite via `model.keys: [a, b]` or `model.primaryKey: [a, b]` →
|
|
21
|
+
* PRIMARY KEY (a, b) constraint at the end of the table; the columns
|
|
22
|
+
* are forced NOT NULL and the synthesised id column is suppressed.
|
|
23
|
+
* - `id UUID required` → id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
|
24
|
+
* - any other "id" required → id TEXT PRIMARY KEY
|
|
21
25
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
26
|
+
* Composite unique constraints:
|
|
27
|
+
* - `model.unique: [[a, b], [c, d]]` → UNIQUE (a, b) constraint per
|
|
28
|
+
* entry. Mixed: a single string array `[a, b]` is interpreted as ONE
|
|
29
|
+
* composite (NOT two single-column ones) — use the
|
|
30
|
+
* attribute-level `unique: true` for single-column uniqueness.
|
|
31
|
+
*
|
|
32
|
+
* Partial indexes:
|
|
33
|
+
* - `attribute.index: { where: "deleted_at IS NULL", unique: true }`
|
|
34
|
+
* emits `CREATE [UNIQUE] INDEX ... WHERE deleted_at IS NULL` after
|
|
35
|
+
* the table. Adopt for soft-delete + tenant-scoped uniqueness.
|
|
36
|
+
*
|
|
37
|
+
* Out of scope (deferred — see TODO #43L): RLS policies, check
|
|
38
|
+
* constraints derived from `min/max` ranges, materialised views.
|
|
24
39
|
*/
|
|
25
40
|
import type { TemplateContext } from '@specverse/types';
|
|
26
41
|
|
|
27
42
|
export default function generatePgSchemaSql(context: TemplateContext): string {
|
|
28
43
|
const { spec, models } = context as any;
|
|
29
44
|
|
|
30
|
-
// Pull a flat list of {modelName, model} from the most-likely shapes:
|
|
31
|
-
// 1. Realize passes `models` (array of model specs from the resolved
|
|
32
|
-
// component); use it directly.
|
|
33
|
-
// 2. Otherwise walk spec.components[].models.
|
|
34
45
|
const allModels: Array<[string, any]> = [];
|
|
35
46
|
if (Array.isArray(models) && models.length > 0) {
|
|
36
47
|
for (const m of models) if (m?.name) allModels.push([m.name, m]);
|
|
@@ -77,29 +88,57 @@ function generateTable(modelName: string, model: any): string {
|
|
|
77
88
|
? attrs.map((a: any) => [a.name, a])
|
|
78
89
|
: Object.entries(attrs);
|
|
79
90
|
|
|
91
|
+
// Composite primary key — read either `keys` or `primaryKey`. Both
|
|
92
|
+
// accepted shapes: array of strings (one composite) OR a single string
|
|
93
|
+
// (degenerate single-column PK; used by some specs as a way to point
|
|
94
|
+
// at a non-`id` column).
|
|
95
|
+
const compositePk = parseColumnList(model.keys ?? model.primaryKey);
|
|
96
|
+
// Composite uniques — array of arrays, each defining one constraint.
|
|
97
|
+
const compositeUniques = parseCompositeUnique(model.unique);
|
|
98
|
+
|
|
80
99
|
const columns: string[] = [];
|
|
81
100
|
const indexes: string[] = [];
|
|
82
101
|
let hasIdColumn = false;
|
|
102
|
+
const compositePkSet = new Set(compositePk);
|
|
83
103
|
|
|
84
104
|
for (const [colName, attr] of list) {
|
|
85
105
|
if (!colName) continue;
|
|
86
|
-
|
|
106
|
+
// When this column is part of a composite PK, force NOT NULL and
|
|
107
|
+
// skip the column-level PRIMARY KEY (the constraint goes at the end
|
|
108
|
+
// of the CREATE TABLE).
|
|
109
|
+
const partOfCompositePk = compositePkSet.has(colName);
|
|
110
|
+
const { sql, isPk, isUnique } = columnDef(colName, attr, modelName, partOfCompositePk);
|
|
87
111
|
if (isPk) hasIdColumn = true;
|
|
88
112
|
columns.push(' ' + sql);
|
|
89
|
-
|
|
113
|
+
|
|
114
|
+
if (isUnique && !isPk && !partOfCompositePk) {
|
|
90
115
|
indexes.push(
|
|
91
116
|
`CREATE UNIQUE INDEX IF NOT EXISTS "${table}_${colName}_uq" ON "${table}" ("${colName}");`,
|
|
92
117
|
);
|
|
93
|
-
} else if (colName.endsWith('Id') && colName !== 'id') {
|
|
118
|
+
} else if (colName.endsWith('Id') && colName !== 'id' && !partOfCompositePk) {
|
|
94
119
|
// FK columns — index for join performance.
|
|
95
120
|
indexes.push(
|
|
96
121
|
`CREATE INDEX IF NOT EXISTS "${table}_${colName}_idx" ON "${table}" ("${colName}");`,
|
|
97
122
|
);
|
|
98
123
|
}
|
|
124
|
+
|
|
125
|
+
// Partial indexes — declared at the attribute level so the column +
|
|
126
|
+
// its specialised index live together in the spec. `attr.index` is
|
|
127
|
+
// either `true` (plain index, no WHERE) or `{ where, unique }`.
|
|
128
|
+
const partial = parsePartialIndex(attr.index);
|
|
129
|
+
if (partial) {
|
|
130
|
+
const indexName = `${table}_${colName}${partial.unique ? '_uq' : '_idx'}_partial`;
|
|
131
|
+
const uniqueClause = partial.unique ? 'UNIQUE ' : '';
|
|
132
|
+
const whereClause = partial.where ? ` WHERE ${partial.where}` : '';
|
|
133
|
+
indexes.push(
|
|
134
|
+
`CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "${table}" ("${colName}")${whereClause};`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
99
137
|
}
|
|
100
138
|
|
|
101
|
-
//
|
|
102
|
-
if
|
|
139
|
+
// Composite PK takes precedence over the synthesised id column. Skip
|
|
140
|
+
// the synthesised id only if the composite covers every "id" need.
|
|
141
|
+
if (compositePk.length === 0 && !hasIdColumn) {
|
|
103
142
|
columns.unshift(' "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text');
|
|
104
143
|
}
|
|
105
144
|
|
|
@@ -112,11 +151,7 @@ function generateTable(modelName: string, model: any): string {
|
|
|
112
151
|
columns.push(' "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
|
|
113
152
|
}
|
|
114
153
|
|
|
115
|
-
// Lifecycle columns
|
|
116
|
-
// (named after the lifecycle, defaulting to its first state) so
|
|
117
|
-
// controller.evolve has somewhere to write the transitioned state.
|
|
118
|
-
// Mongo is schemaless so this happens implicitly there; in postgres we
|
|
119
|
-
// need an explicit column.
|
|
154
|
+
// Lifecycle columns.
|
|
120
155
|
const lifecycles = Array.isArray(model.lifecycles)
|
|
121
156
|
? model.lifecycles
|
|
122
157
|
: (model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]: [string, any]) => ({ name, ...lc })) : []);
|
|
@@ -128,6 +163,16 @@ function generateTable(modelName: string, model: any): string {
|
|
|
128
163
|
columns.push(` "${lcName}" TEXT NOT NULL${defaultClause}`);
|
|
129
164
|
}
|
|
130
165
|
|
|
166
|
+
// Table-level constraints (composite PK + composite uniques) go at
|
|
167
|
+
// the end of the column list so the table reads top-to-bottom in the
|
|
168
|
+
// natural order: columns, then constraints.
|
|
169
|
+
if (compositePk.length > 0) {
|
|
170
|
+
columns.push(` PRIMARY KEY (${compositePk.map(quoteIdent).join(', ')})`);
|
|
171
|
+
}
|
|
172
|
+
for (const cols of compositeUniques) {
|
|
173
|
+
columns.push(` UNIQUE (${cols.map(quoteIdent).join(', ')})`);
|
|
174
|
+
}
|
|
175
|
+
|
|
131
176
|
const indexBlock = indexes.length > 0 ? '\n\n' + indexes.join('\n') : '';
|
|
132
177
|
return `-- ${modelName}\nCREATE TABLE IF NOT EXISTS "${table}" (\n${columns.join(',\n')}\n);${indexBlock}`;
|
|
133
178
|
}
|
|
@@ -138,13 +183,15 @@ interface ColumnDefResult {
|
|
|
138
183
|
isUnique: boolean;
|
|
139
184
|
}
|
|
140
185
|
|
|
141
|
-
function columnDef(name: string, attr: any, _modelName: string): ColumnDefResult {
|
|
142
|
-
const required = !!attr.required;
|
|
186
|
+
function columnDef(name: string, attr: any, _modelName: string, forceNotNull: boolean): ColumnDefResult {
|
|
187
|
+
const required = !!attr.required || forceNotNull;
|
|
143
188
|
const unique = !!attr.unique;
|
|
144
189
|
const type = (attr.type || 'String').toLowerCase();
|
|
145
190
|
const isIdColumn = name === 'id';
|
|
146
191
|
|
|
147
|
-
|
|
192
|
+
// When the column is part of a composite PK, suppress the column-level
|
|
193
|
+
// PRIMARY KEY — the table-level PRIMARY KEY (a, b) constraint covers it.
|
|
194
|
+
if (isIdColumn && !forceNotNull) {
|
|
148
195
|
if (type === 'uuid') {
|
|
149
196
|
return { sql: `"id" UUID PRIMARY KEY DEFAULT gen_random_uuid()`, isPk: true, isUnique: true };
|
|
150
197
|
}
|
|
@@ -212,7 +259,6 @@ function deriveInitialState(lc: any): string | null {
|
|
|
212
259
|
return head || null;
|
|
213
260
|
}
|
|
214
261
|
if (lc.transitions && typeof lc.transitions === 'object') {
|
|
215
|
-
// Pick the source side of the first transition.
|
|
216
262
|
const first = Object.values(lc.transitions)[0];
|
|
217
263
|
if (typeof first === 'string') return first.split(/\s*->\s*/)[0]?.trim() || null;
|
|
218
264
|
}
|
|
@@ -229,8 +275,78 @@ function formatColumnDefault(value: any, type: string): string {
|
|
|
229
275
|
if (value === 'now' && (type === 'datetime' || type === 'date' || type === 'timestamp')) {
|
|
230
276
|
return ` DEFAULT NOW()`;
|
|
231
277
|
}
|
|
232
|
-
// Quoted SQL string literal — escape single quotes.
|
|
233
278
|
return ` DEFAULT '${value.replace(/'/g, "''")}'`;
|
|
234
279
|
}
|
|
235
280
|
return '';
|
|
236
281
|
}
|
|
282
|
+
|
|
283
|
+
/** Parse a `keys:` / `primaryKey:` declaration. Accepts:
|
|
284
|
+
* - undefined / empty → []
|
|
285
|
+
* - string → [string] (single-column PK named explicitly; degenerate)
|
|
286
|
+
* - string[] → as-is (composite PK)
|
|
287
|
+
* - string[][] → flatten the first entry (defensive — some specs nest)
|
|
288
|
+
*/
|
|
289
|
+
function parseColumnList(raw: any): string[] {
|
|
290
|
+
if (raw === undefined || raw === null) return [];
|
|
291
|
+
if (typeof raw === 'string') return [raw];
|
|
292
|
+
if (!Array.isArray(raw)) return [];
|
|
293
|
+
if (raw.length === 0) return [];
|
|
294
|
+
if (typeof raw[0] === 'string') return raw.filter((x: any): x is string => typeof x === 'string');
|
|
295
|
+
if (Array.isArray(raw[0])) {
|
|
296
|
+
return (raw[0] as any[]).filter((x: any): x is string => typeof x === 'string');
|
|
297
|
+
}
|
|
298
|
+
return [];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Parse a composite-unique declaration. Accepts:
|
|
302
|
+
* - undefined → []
|
|
303
|
+
* - string[] → [[...]] (single composite spanning the listed columns)
|
|
304
|
+
* - string[][] → as-is (multiple composites)
|
|
305
|
+
*
|
|
306
|
+
* Single-column uniqueness is intentionally left at the attribute level
|
|
307
|
+
* (`attr.unique: true`) so the spec author signals intent at the right
|
|
308
|
+
* scope. Mixing single + composite at this entry would be ambiguous.
|
|
309
|
+
*/
|
|
310
|
+
function parseCompositeUnique(raw: any): string[][] {
|
|
311
|
+
if (raw === undefined || raw === null) return [];
|
|
312
|
+
if (!Array.isArray(raw) || raw.length === 0) return [];
|
|
313
|
+
if (typeof raw[0] === 'string') {
|
|
314
|
+
return [raw.filter((x: any): x is string => typeof x === 'string')];
|
|
315
|
+
}
|
|
316
|
+
if (Array.isArray(raw[0])) {
|
|
317
|
+
return (raw as any[][])
|
|
318
|
+
.map((row) => row.filter((x: any): x is string => typeof x === 'string'))
|
|
319
|
+
.filter((row) => row.length > 0);
|
|
320
|
+
}
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
interface PartialIndex {
|
|
325
|
+
where: string | null;
|
|
326
|
+
unique: boolean;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Parse an attribute-level `index` declaration. Accepts:
|
|
330
|
+
* - undefined / false → null (no extra index)
|
|
331
|
+
* - true → { where: null, unique: false } (plain index, no WHERE — same
|
|
332
|
+
* as a regular index but explicit; useful for non-FK columns)
|
|
333
|
+
* - { where, unique } → as-is
|
|
334
|
+
*/
|
|
335
|
+
function parsePartialIndex(raw: any): PartialIndex | null {
|
|
336
|
+
if (raw === undefined || raw === null || raw === false) return null;
|
|
337
|
+
if (raw === true) return { where: null, unique: false };
|
|
338
|
+
if (typeof raw === 'object') {
|
|
339
|
+
const where = typeof raw.where === 'string' && raw.where.trim() !== ''
|
|
340
|
+
? raw.where.trim()
|
|
341
|
+
: null;
|
|
342
|
+
const unique = !!raw.unique;
|
|
343
|
+
// No `where` AND no `unique` → equivalent to `index: true`. Still
|
|
344
|
+
// emit so the spec author's explicit declaration stays meaningful.
|
|
345
|
+
return { where, unique };
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function quoteIdent(name: string): string {
|
|
351
|
+
return '"' + name.replace(/"/g, '""') + '"';
|
|
352
|
+
}
|
|
@@ -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.
|
|
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
|
-
|
|
462
|
-
|
|
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
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|