@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.
@@ -1,4 +1,5 @@
1
1
  import { matchStep } from "./step-conventions.js";
2
+ import { validateImportWhitelist } from "@specverse/engines/ai";
2
3
  import { createHash } from "crypto";
3
4
  import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
4
5
  import { dirname, join } from "path";
@@ -16,6 +17,11 @@ async function validateTypeScript(code) {
16
17
  return msg;
17
18
  }
18
19
  }
20
+ function validateImports(code) {
21
+ const offenders = validateImportWhitelist(code);
22
+ if (offenders.length === 0) return null;
23
+ return `import not in whitelist: ${offenders.join(", ")} (allowed: jsonwebtoken | bcryptjs | uuid | crypto | expr-eval)`;
24
+ }
19
25
  async function validateTypeScriptTypes(code) {
20
26
  let ts;
21
27
  try {
@@ -75,7 +81,7 @@ async function validateTypeScriptTypes(code) {
75
81
  return null;
76
82
  }
77
83
  }
78
- const PROMPT_VERSION = "9.7.0";
84
+ const PROMPT_VERSION = "9.8.0";
79
85
  function cacheKey(step, modelName, operationName, functionName, inputs) {
80
86
  const payload = JSON.stringify({ step, modelName, operationName, functionName, inputs: [...inputs].sort(), v: PROMPT_VERSION });
81
87
  return createHash("sha256").update(payload).digest("hex").slice(0, 16);
@@ -249,8 +255,9 @@ ${body}
249
255
  }`;
250
256
  const syntaxError = await validateTypeScript(testCode);
251
257
  const typeError = syntaxError ? null : await validateTypeScriptTypes(testCode);
252
- if (syntaxError || typeError) {
253
- console.warn(` [ai-validate] cached ${functionName} failed validation: ${syntaxError || typeError}`);
258
+ const importError = syntaxError || typeError ? null : validateImports(testCode);
259
+ if (syntaxError || typeError || importError) {
260
+ console.warn(` [ai-validate] cached ${functionName} failed validation: ${syntaxError || typeError || importError}`);
254
261
  body = null;
255
262
  source = "STUB";
256
263
  } else {
@@ -285,13 +292,20 @@ ${body}
285
292
  source = "AI-INVALID";
286
293
  } else {
287
294
  const typeError = await validateTypeScriptTypes(testCode);
288
- if (typeError) {
289
- console.warn(` [ai-validate] ${functionName} type errors: ${typeError}`);
295
+ const importError = typeError ? null : validateImports(testCode);
296
+ if (typeError || importError) {
297
+ console.warn(` [ai-validate] ${functionName} ${typeError ? "type errors: " + typeError : "whitelist violation: " + importError}`);
290
298
  try {
291
- const retryHint = `Your previous output produced TypeScript type errors:
292
- ${typeError}
299
+ const errorParts = [];
300
+ if (typeError) errorParts.push(`TypeScript type errors:
301
+ ${typeError}`);
302
+ if (importError) errorParts.push(`Import-whitelist violation: ${importError}.
303
+ Only these libraries may be dynamic-imported: jsonwebtoken, bcryptjs, uuid, crypto, expr-eval. Anything else is forbidden \u2014 throw an Error if the step needs an unsupported library.`);
304
+ const retryHint = `Your previous output had problems:
305
+
306
+ ${errorParts.join("\n\n")}
293
307
 
294
- Fix these specifically \u2014 common causes:
308
+ Fix these specifically \u2014 common type-error causes:
295
309
  - RegExp match indices are 'string | undefined'; use non-null assertion or extract to a typed variable
296
310
  - Strict null checks: guard or assert before use
297
311
  - Don't declare locals you never reference
@@ -315,7 +329,8 @@ ${retried}
315
329
  }`;
316
330
  const retrySyntaxError = await validateTypeScript(retryCode);
317
331
  const retryTypeError = retrySyntaxError ? null : await validateTypeScriptTypes(retryCode);
318
- if (!retrySyntaxError && !retryTypeError) {
332
+ const retryImportError = retrySyntaxError || retryTypeError ? null : validateImports(retryCode);
333
+ if (!retrySyntaxError && !retryTypeError && !retryImportError) {
319
334
  body = retried;
320
335
  source = "AI-GENERATED";
321
336
  cacheWrite(key, body);
@@ -8,6 +8,20 @@
8
8
  */
9
9
 
10
10
  import type { TemplateContext } from '@specverse/types';
11
+ import { predictAiBehaviorLibraries } from '@specverse/engines/ai';
12
+
13
+ // Version pins for the AI-behavior whitelist libs. Single source of
14
+ // truth so the runtime dep + the @types dep stay in sync. Listed
15
+ // here (not in `library-whitelist.ts`) because that module is shared
16
+ // with the validator + spec scanner — those don't care about npm
17
+ // version ranges, only library names.
18
+ const AI_LIBRARY_VERSIONS: Record<string, { runtime: string; types?: string }> = {
19
+ 'jsonwebtoken': { runtime: '^9.0.0', types: '^9.0.0' },
20
+ 'bcryptjs': { runtime: '^2.4.3', types: '^2.4.0' },
21
+ 'uuid': { runtime: '^9.0.0', types: '^9.0.0' },
22
+ 'crypto': { runtime: '*' /* Node built-in */ },
23
+ 'expr-eval': { runtime: '^2.0.2' },
24
+ };
11
25
 
12
26
  /** Read the manifest's resolved orm name (e.g. "PrismaORM", "MongoDBNativeDriver"). */
13
27
  function resolveOrmName(manifest: any): string {
@@ -108,16 +122,11 @@ export default function generateBackendPackageJson(context: TemplateContext): st
108
122
  'zod': '^3.22.0',
109
123
  'dotenv': '^16.3.0',
110
124
  'commander': '^13.0.0',
111
- // AI-behavior whitelist — these libs are allowed to be dynamic-
112
- // imported from generated `*.ai.ts` pure functions. They cover the
113
- // common cases (JWT, hashing, formula eval) without giving the LLM
114
- // unbounded library access. Listed unconditionally for now; revisit
115
- // (TODO #43K-B-review) whether to gate per-spec when the spec
116
- // doesn't actually use them.
117
- 'jsonwebtoken': '^9.0.0',
118
- 'bcryptjs': '^2.4.3',
119
- 'uuid': '^9.0.0',
120
- 'expr-eval': '^2.0.2'
125
+ // AI-behavior whitelist deps only included when the spec's step
126
+ // text actually triggers them (predicted via predictAiBehaviorLibraries).
127
+ // Pre-fix every backend installed all 5 libs unconditionally even
128
+ // though 97% of generated bodies imported nothing. (#43K-B-review)
129
+ ...buildAiLibraryDeps(spec, 'runtime')
121
130
  },
122
131
 
123
132
  devDependencies: {
@@ -130,10 +139,9 @@ export default function generateBackendPackageJson(context: TemplateContext): st
130
139
  'eslint': '^9.0.0',
131
140
  '@typescript-eslint/eslint-plugin': '^8.0.0',
132
141
  '@typescript-eslint/parser': '^8.0.0',
133
- // Type definitions for the AI-behavior whitelist libs (#43K-B).
134
- '@types/jsonwebtoken': '^9.0.0',
135
- '@types/bcryptjs': '^2.4.0',
136
- '@types/uuid': '^9.0.0'
142
+ // Type definitions for whichever AI-behavior whitelist libs are
143
+ // included as runtime deps above. Same per-spec gating.
144
+ ...buildAiLibraryDeps(spec, 'types')
137
145
  },
138
146
 
139
147
  engines: {
@@ -143,3 +151,31 @@ export default function generateBackendPackageJson(context: TemplateContext): st
143
151
 
144
152
  return JSON.stringify(pkg, null, 2);
145
153
  }
154
+
155
+ /**
156
+ * Build the AI-library deps block for the given spec. `kind = 'runtime'`
157
+ * yields the runtime deps (jsonwebtoken etc.); `kind = 'types'` yields
158
+ * the matching @types/* devDeps. Empty object when no whitelist trigger
159
+ * fires in the spec — the realized backend then ships without any of
160
+ * these deps installed (the validator already prevented bodies from
161
+ * importing them, so nothing references them at runtime).
162
+ *
163
+ * `crypto` is a Node built-in, so it never produces a runtime dep
164
+ * entry; if a body uses it, no install is needed.
165
+ */
166
+ function buildAiLibraryDeps(spec: any, kind: 'runtime' | 'types'): Record<string, string> {
167
+ const out: Record<string, string> = {};
168
+ const predicted = predictAiBehaviorLibraries(spec);
169
+ for (const lib of predicted) {
170
+ const versions = AI_LIBRARY_VERSIONS[lib];
171
+ if (!versions) continue;
172
+ if (kind === 'runtime') {
173
+ // Skip Node built-ins (crypto) — they don't go in package.json deps.
174
+ if (versions.runtime === '*') continue;
175
+ out[lib] = versions.runtime;
176
+ } else if (kind === 'types' && versions.types) {
177
+ out[`@types/${lib}`] = versions.types;
178
+ }
179
+ }
180
+ return out;
181
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Regression tests for the postgres-native DDL generator.
3
+ *
4
+ * Pins existing functionality (CREATE TABLE shape, type mapping, FK
5
+ * indexes, lifecycle columns, default values, unique constraints) AND
6
+ * the new Tier 1 / Tier 2 features added in engines 6.18.0:
7
+ * - Composite primary keys (`keys: [a, b]`, `primaryKey: [a, b]`)
8
+ * - Composite unique constraints (`unique: [[a, b]]`)
9
+ * - Partial indexes (`attr.index: { where, unique }`)
10
+ * - JSON type mapping (already present; pinned as a regression)
11
+ *
12
+ * Tests assert SQL substrings rather than full-text equality so a future
13
+ * comment / formatting tweak doesn't cascade into 30 broken tests.
14
+ */
15
+ import { describe, it, expect } from 'vitest';
16
+ import generatePgSchemaSql from '../ddl-generator.js';
17
+
18
+ const todoModel = {
19
+ name: 'Todo',
20
+ attributes: {
21
+ id: { name: 'id', type: 'UUID', required: true, auto: 'uuid4' },
22
+ title: { name: 'title', type: 'String', required: true },
23
+ completed: { name: 'completed', type: 'Boolean', default: false },
24
+ },
25
+ };
26
+
27
+ describe('postgres-native — ddl-generator (existing functionality)', () => {
28
+ it('emits CREATE TABLE IF NOT EXISTS + pgcrypto extension', () => {
29
+ const sql = generatePgSchemaSql({ models: [todoModel] } as any);
30
+ expect(sql).toContain('CREATE EXTENSION IF NOT EXISTS "pgcrypto"');
31
+ expect(sql).toContain('CREATE TABLE IF NOT EXISTS "todos"');
32
+ });
33
+
34
+ it('maps types correctly (TEXT/INTEGER/BOOLEAN/TIMESTAMPTZ/UUID)', () => {
35
+ const model = {
36
+ name: 'Mix',
37
+ attributes: {
38
+ id: { name: 'id', type: 'UUID', required: true },
39
+ title: { name: 'title', type: 'String', required: true },
40
+ count: { name: 'count', type: 'Integer', required: true },
41
+ ratio: { name: 'ratio', type: 'Float' },
42
+ active: { name: 'active', type: 'Boolean', default: true },
43
+ when: { name: 'when', type: 'DateTime' },
44
+ },
45
+ };
46
+ const sql = generatePgSchemaSql({ models: [model] } as any);
47
+ expect(sql).toContain('"title" TEXT NOT NULL');
48
+ expect(sql).toContain('"count" INTEGER NOT NULL');
49
+ expect(sql).toContain('"ratio" DOUBLE PRECISION');
50
+ expect(sql).toContain('"active" BOOLEAN');
51
+ expect(sql).toContain('"when" TIMESTAMPTZ');
52
+ expect(sql).toContain('"id" UUID PRIMARY KEY DEFAULT gen_random_uuid()');
53
+ });
54
+
55
+ it('synthesises an id column when no id is declared', () => {
56
+ const model = {
57
+ name: 'NoId',
58
+ attributes: { name: { name: 'name', type: 'String', required: true } },
59
+ };
60
+ const sql = generatePgSchemaSql({ models: [model] } as any);
61
+ expect(sql).toContain('"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text');
62
+ });
63
+
64
+ it('emits FK indexes for *Id columns', () => {
65
+ const model = {
66
+ name: 'Item',
67
+ attributes: {
68
+ id: { name: 'id', type: 'UUID', required: true },
69
+ userId: { name: 'userId', type: 'UUID', required: true },
70
+ categoryId: { name: 'categoryId', type: 'UUID', required: true },
71
+ },
72
+ };
73
+ const sql = generatePgSchemaSql({ models: [model] } as any);
74
+ expect(sql).toContain('CREATE INDEX IF NOT EXISTS "items_userId_idx" ON "items" ("userId")');
75
+ expect(sql).toContain('CREATE INDEX IF NOT EXISTS "items_categoryId_idx" ON "items" ("categoryId")');
76
+ });
77
+
78
+ it('emits unique indexes for column-level `unique: true`', () => {
79
+ const model = {
80
+ name: 'User',
81
+ attributes: {
82
+ id: { name: 'id', type: 'UUID', required: true },
83
+ email: { name: 'email', type: 'String', required: true, unique: true },
84
+ },
85
+ };
86
+ const sql = generatePgSchemaSql({ models: [model] } as any);
87
+ expect(sql).toContain('CREATE UNIQUE INDEX IF NOT EXISTS "users_email_uq" ON "users" ("email")');
88
+ });
89
+
90
+ it('honours `model.storage.table` override', () => {
91
+ const sql = generatePgSchemaSql({
92
+ models: [{ ...todoModel, storage: { table: 'custom_todos' } }],
93
+ } as any);
94
+ expect(sql).toContain('CREATE TABLE IF NOT EXISTS "custom_todos"');
95
+ expect(sql).not.toContain('CREATE TABLE IF NOT EXISTS "todos"');
96
+ });
97
+
98
+ it('emits lifecycle columns auto-derived from `flow:` shorthand', () => {
99
+ const model = {
100
+ ...todoModel,
101
+ lifecycles: { status: { name: 'status', flow: 'open -> closed' } },
102
+ };
103
+ const sql = generatePgSchemaSql({ models: [model] } as any);
104
+ expect(sql).toContain(`"status" TEXT NOT NULL DEFAULT 'open'`);
105
+ });
106
+
107
+ it('adds createdAt/updatedAt defaults if not declared', () => {
108
+ const sql = generatePgSchemaSql({ models: [todoModel] } as any);
109
+ expect(sql).toContain('"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
110
+ expect(sql).toContain('"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
111
+ });
112
+
113
+ it('returns a stub when no models declared', () => {
114
+ expect(generatePgSchemaSql({} as any)).toContain('No models declared');
115
+ expect(generatePgSchemaSql({ models: [] } as any)).toContain('No models declared');
116
+ });
117
+ });
118
+
119
+ describe('postgres-native — ddl-generator (engines 6.18.0 — Tier 1: composite keys + uniques)', () => {
120
+ it('composite primary key via `keys: [a, b]` emits a table-level PRIMARY KEY constraint', () => {
121
+ const model = {
122
+ name: 'Membership',
123
+ attributes: {
124
+ tenantId: { name: 'tenantId', type: 'UUID', required: true },
125
+ userId: { name: 'userId', type: 'UUID', required: true },
126
+ role: { name: 'role', type: 'String', required: true },
127
+ },
128
+ keys: ['tenantId', 'userId'],
129
+ };
130
+ const sql = generatePgSchemaSql({ models: [model] } as any);
131
+ // Table-level constraint
132
+ expect(sql).toContain('PRIMARY KEY ("tenantId", "userId")');
133
+ // Both columns NOT NULL (forced by composite-PK membership)
134
+ expect(sql).toContain('"tenantId" UUID NOT NULL');
135
+ expect(sql).toContain('"userId" UUID NOT NULL');
136
+ // No synthesised id column (composite PK takes its place)
137
+ expect(sql).not.toContain('"id" TEXT PRIMARY KEY');
138
+ });
139
+
140
+ it('accepts `primaryKey:` as an alias for `keys:`', () => {
141
+ const model = {
142
+ name: 'Pair',
143
+ attributes: {
144
+ a: { name: 'a', type: 'String', required: true },
145
+ b: { name: 'b', type: 'String', required: true },
146
+ },
147
+ primaryKey: ['a', 'b'],
148
+ };
149
+ const sql = generatePgSchemaSql({ models: [model] } as any);
150
+ expect(sql).toContain('PRIMARY KEY ("a", "b")');
151
+ });
152
+
153
+ it('composite unique constraint via `unique: [[a, b]]`', () => {
154
+ const model = {
155
+ name: 'Slug',
156
+ attributes: {
157
+ id: { name: 'id', type: 'UUID', required: true },
158
+ tenantId: { name: 'tenantId', type: 'UUID', required: true },
159
+ slug: { name: 'slug', type: 'String', required: true },
160
+ },
161
+ unique: [['tenantId', 'slug']],
162
+ };
163
+ const sql = generatePgSchemaSql({ models: [model] } as any);
164
+ expect(sql).toContain('UNIQUE ("tenantId", "slug")');
165
+ });
166
+
167
+ it('multiple composite uniques', () => {
168
+ const model = {
169
+ name: 'Multi',
170
+ attributes: {
171
+ id: { name: 'id', type: 'UUID', required: true },
172
+ a: { name: 'a', type: 'String' },
173
+ b: { name: 'b', type: 'String' },
174
+ c: { name: 'c', type: 'String' },
175
+ },
176
+ unique: [['a', 'b'], ['b', 'c']],
177
+ };
178
+ const sql = generatePgSchemaSql({ models: [model] } as any);
179
+ expect(sql).toContain('UNIQUE ("a", "b")');
180
+ expect(sql).toContain('UNIQUE ("b", "c")');
181
+ });
182
+
183
+ it('a single-array `unique: [a, b]` is treated as ONE composite (not two single-column)', () => {
184
+ // This is the documented contract: single uniques go at the attribute
185
+ // level, not in `model.unique`. So `unique: ["a", "b"]` is one
186
+ // composite spanning a and b.
187
+ const model = {
188
+ name: 'Single',
189
+ attributes: {
190
+ id: { name: 'id', type: 'UUID', required: true },
191
+ a: { name: 'a', type: 'String' },
192
+ b: { name: 'b', type: 'String' },
193
+ },
194
+ unique: ['a', 'b'],
195
+ };
196
+ const sql = generatePgSchemaSql({ models: [model] } as any);
197
+ expect(sql).toContain('UNIQUE ("a", "b")');
198
+ // Did NOT generate two separate single-column unique indexes.
199
+ expect(sql).not.toContain('"single_a_uq"');
200
+ expect(sql).not.toContain('"single_b_uq"');
201
+ });
202
+
203
+ it('does not double-emit FK indexes for columns that are part of composite PK', () => {
204
+ // userId is the FK that would normally get an `_idx` index. When it's
205
+ // part of the PK, the PK already covers index lookups starting on
206
+ // userId, so the extra index would be wasted disk.
207
+ const model = {
208
+ name: 'Member',
209
+ attributes: {
210
+ userId: { name: 'userId', type: 'UUID', required: true },
211
+ groupId: { name: 'groupId', type: 'UUID', required: true },
212
+ },
213
+ keys: ['userId', 'groupId'],
214
+ };
215
+ const sql = generatePgSchemaSql({ models: [model] } as any);
216
+ expect(sql).not.toContain('"members_userId_idx"');
217
+ expect(sql).not.toContain('"members_groupId_idx"');
218
+ });
219
+ });
220
+
221
+ describe('postgres-native — ddl-generator (engines 6.18.0 — Tier 2: partial indexes + JSON)', () => {
222
+ it('partial unique index via `attr.index: { where, unique: true }`', () => {
223
+ const model = {
224
+ name: 'User',
225
+ attributes: {
226
+ id: { name: 'id', type: 'UUID', required: true },
227
+ email: {
228
+ name: 'email',
229
+ type: 'String',
230
+ required: true,
231
+ // Soft-delete-aware unique: only enforce uniqueness for live rows.
232
+ index: { where: '"deletedAt" IS NULL', unique: true },
233
+ },
234
+ deletedAt: { name: 'deletedAt', type: 'DateTime' },
235
+ },
236
+ };
237
+ const sql = generatePgSchemaSql({ models: [model] } as any);
238
+ expect(sql).toContain('CREATE UNIQUE INDEX IF NOT EXISTS "users_email_uq_partial" ON "users" ("email") WHERE "deletedAt" IS NULL');
239
+ });
240
+
241
+ it('plain partial index via `attr.index: { where: "..." }`', () => {
242
+ const model = {
243
+ name: 'Job',
244
+ attributes: {
245
+ id: { name: 'id', type: 'UUID', required: true },
246
+ status: {
247
+ name: 'status',
248
+ type: 'String',
249
+ index: { where: "status IN ('pending', 'running')" },
250
+ },
251
+ },
252
+ };
253
+ const sql = generatePgSchemaSql({ models: [model] } as any);
254
+ expect(sql).toContain(`CREATE INDEX IF NOT EXISTS "jobs_status_idx_partial" ON "jobs" ("status") WHERE status IN ('pending', 'running')`);
255
+ });
256
+
257
+ it('`attr.index: true` emits a plain index (no WHERE)', () => {
258
+ const model = {
259
+ name: 'Q',
260
+ attributes: {
261
+ id: { name: 'id', type: 'UUID', required: true },
262
+ priority: { name: 'priority', type: 'Integer', index: true },
263
+ },
264
+ };
265
+ const sql = generatePgSchemaSql({ models: [model] } as any);
266
+ // Index emitted without WHERE clause.
267
+ const idxLine = sql.match(/CREATE.*"qs_priority_idx_partial".*$/m)?.[0] ?? '';
268
+ expect(idxLine).toContain('"priority"');
269
+ expect(idxLine).not.toContain('WHERE');
270
+ });
271
+
272
+ it('JSON / Jsonb maps to JSONB column', () => {
273
+ const model = {
274
+ name: 'Doc',
275
+ attributes: {
276
+ id: { name: 'id', type: 'UUID', required: true },
277
+ payload: { name: 'payload', type: 'Json', required: true },
278
+ metadata: { name: 'metadata', type: 'Jsonb' },
279
+ },
280
+ };
281
+ const sql = generatePgSchemaSql({ models: [model] } as any);
282
+ expect(sql).toContain('"payload" JSONB NOT NULL');
283
+ expect(sql).toContain('"metadata" JSONB');
284
+ });
285
+ });