@specverse/engines 6.66.0 → 6.75.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/inference/index.d.ts +1 -1
- package/dist/inference/index.d.ts.map +1 -1
- package/dist/inference/index.js +1 -1
- package/dist/inference/index.js.map +1 -1
- package/dist/inference/quint-transpiler.d.ts +18 -0
- package/dist/inference/quint-transpiler.d.ts.map +1 -1
- package/dist/inference/quint-transpiler.js +32 -0
- package/dist/inference/quint-transpiler.js.map +1 -1
- package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +14 -5
- package/dist/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
- package/dist/libs/instance-factories/services/postgres-native-services.yaml +10 -0
- package/dist/libs/instance-factories/services/prisma-services.yaml +10 -0
- package/dist/libs/instance-factories/services/templates/_shared/guards-generator.js +209 -0
- package/dist/libs/instance-factories/services/templates/mongodb-native/controller-generator.js +110 -23
- package/dist/libs/instance-factories/services/templates/postgres-native/controller-generator.js +104 -22
- package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +133 -23
- package/dist/libs/instance-factories/services/templates/prisma/guards-generator.js +151 -0
- package/dist/parser/convention-processor.d.ts +44 -1
- package/dist/parser/convention-processor.d.ts.map +1 -1
- package/dist/parser/convention-processor.js +175 -1
- package/dist/parser/convention-processor.js.map +1 -1
- package/dist/parser/types/ast.d.ts +1 -1
- package/dist/parser/types/ast.d.ts.map +1 -1
- package/dist/parser/unified-parser.d.ts.map +1 -1
- package/dist/parser/unified-parser.js +25 -2
- package/dist/parser/unified-parser.js.map +1 -1
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +17 -0
- package/dist/realize/index.js.map +1 -1
- package/libs/instance-factories/controllers/templates/fastify/__tests__/actor-wiring.test.ts +80 -0
- package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +14 -5
- package/libs/instance-factories/services/mongodb-native-services.yaml +10 -0
- package/libs/instance-factories/services/postgres-native-services.yaml +10 -0
- package/libs/instance-factories/services/prisma-services.yaml +10 -0
- package/libs/instance-factories/services/templates/_shared/guards-generator.ts +296 -0
- package/libs/instance-factories/services/templates/mongodb-native/__tests__/controller-with-constraints.test.ts +192 -0
- package/libs/instance-factories/services/templates/mongodb-native/controller-generator.ts +144 -23
- package/libs/instance-factories/services/templates/postgres-native/__tests__/controller-with-constraints.test.ts +192 -0
- package/libs/instance-factories/services/templates/postgres-native/controller-generator.ts +130 -22
- package/libs/instance-factories/services/templates/prisma/__tests__/controller-with-constraints.test.ts +261 -0
- package/libs/instance-factories/services/templates/prisma/controller-generator.ts +186 -22
- package/package.json +1 -1
|
@@ -19,13 +19,14 @@ function generatePrismaController(context) {
|
|
|
19
19
|
const idType = idAttr?.type || "UUID";
|
|
20
20
|
const needsIntParse = idType === "Integer" || idType === "Int" || idType === "Number";
|
|
21
21
|
const customActions = generateCustomActions(controller, modelName, modelVar);
|
|
22
|
+
const hasConstraints = Array.isArray(model.constraints) && model.constraints.length > 0;
|
|
22
23
|
const classBody = [
|
|
23
|
-
generateValidateMethod(model, modelName),
|
|
24
|
-
curedOps.create ? generateCreateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels) : "",
|
|
24
|
+
generateValidateMethod(model, modelName, hasConstraints),
|
|
25
|
+
curedOps.create ? generateCreateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels, hasConstraints) : "",
|
|
25
26
|
curedOps.retrieve ? generateRetrieveMethod(model, modelName, modelVar, prismaDelegate) : "",
|
|
26
|
-
curedOps.update ? generateUpdateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels) : "",
|
|
27
|
-
curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar, prismaDelegate, controller) : "",
|
|
28
|
-
curedOps.delete ? generateDeleteMethod(model, modelName, modelVar, prismaDelegate, controller) : "",
|
|
27
|
+
curedOps.update ? generateUpdateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels, hasConstraints) : "",
|
|
28
|
+
curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar, prismaDelegate, controller, hasConstraints) : "",
|
|
29
|
+
curedOps.delete ? generateDeleteMethod(model, modelName, modelVar, prismaDelegate, controller, hasConstraints) : "",
|
|
29
30
|
customActions.code
|
|
30
31
|
].filter(Boolean).join("\n ");
|
|
31
32
|
const usesPrisma = /\bprisma\b/.test(classBody);
|
|
@@ -35,7 +36,8 @@ function generatePrismaController(context) {
|
|
|
35
36
|
const imports = [
|
|
36
37
|
usesPrisma ? `import { PrismaClient } from '@prisma/client';` : "",
|
|
37
38
|
usesEventBus ? `import { eventBus } from '../events/eventBus.js';` : "",
|
|
38
|
-
usesAiBehaviors ? `import * as aiBehaviors from '../behaviors/${modelName}Controller.ai.js';` : ""
|
|
39
|
+
usesAiBehaviors ? `import * as aiBehaviors from '../behaviors/${modelName}Controller.ai.js';` : "",
|
|
40
|
+
hasConstraints ? `import { runGuards as runConstraintGuards } from './${modelName}.guards.js';` : ""
|
|
39
41
|
].filter(Boolean).join("\n");
|
|
40
42
|
const declarations = [
|
|
41
43
|
usesPrisma ? `const prisma = new PrismaClient();` : "",
|
|
@@ -62,19 +64,39 @@ export const ${modelVar}Controller = new ${controllerName}();
|
|
|
62
64
|
export default ${modelVar}Controller;
|
|
63
65
|
`;
|
|
64
66
|
}
|
|
65
|
-
function generateValidateMethod(model, modelName) {
|
|
67
|
+
function generateValidateMethod(model, modelName, hasConstraints) {
|
|
68
|
+
const opTypeUnion = hasConstraints ? `'create' | 'update' | 'evolve' | 'delete' | \`evolve.\${string}\`` : `'create' | 'update' | 'evolve'`;
|
|
69
|
+
const constraintsCheck = hasConstraints ? `
|
|
70
|
+
// Phase 2 \u2014 run model.constraints[] guards matching this operation.
|
|
71
|
+
// ctx carries a per-model query helper so subquery sugars (rewritten
|
|
72
|
+
// to async guards by guards-generator) can run their findFirst calls.
|
|
73
|
+
const __guardCtx = {
|
|
74
|
+
query: (modelName: string) => {
|
|
75
|
+
const delegate = (prisma as any)[modelName.charAt(0).toLowerCase() + modelName.slice(1)];
|
|
76
|
+
if (!delegate) return undefined;
|
|
77
|
+
return {
|
|
78
|
+
exists: async (predicate: (e: any) => boolean) => {
|
|
79
|
+
const all = await delegate.findMany();
|
|
80
|
+
return all.some(predicate);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const constraintViolations = await runConstraintGuards(_data, _context.operation, _actor, __guardCtx);
|
|
86
|
+
for (const v of constraintViolations) errors.push(v.message);` : "";
|
|
66
87
|
return `
|
|
67
88
|
/**
|
|
68
89
|
* Validate ${modelName} data
|
|
69
90
|
* Unified validation method for all operations
|
|
70
91
|
*/
|
|
71
|
-
public validate(
|
|
92
|
+
public async validate(
|
|
72
93
|
_data: any,
|
|
73
|
-
_context: { operation:
|
|
74
|
-
|
|
94
|
+
_context: { operation: ${opTypeUnion} },
|
|
95
|
+
_actor: any = null
|
|
96
|
+
): Promise<{ valid: boolean; errors: string[] }> {
|
|
75
97
|
const errors: string[] = [];
|
|
76
98
|
|
|
77
|
-
${generateValidationLogic(model, "_data", "_context")}
|
|
99
|
+
${generateValidationLogic(model, "_data", "_context")}${constraintsCheck}
|
|
78
100
|
|
|
79
101
|
return {
|
|
80
102
|
valid: errors.length === 0,
|
|
@@ -125,14 +147,27 @@ function generateValidationLogic(model, dataParam = "_data", contextParam = "_co
|
|
|
125
147
|
});
|
|
126
148
|
return validations.join("\n") || "// No validation rules defined";
|
|
127
149
|
}
|
|
128
|
-
function generateCreateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels) {
|
|
150
|
+
function generateCreateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels, hasConstraints = false) {
|
|
151
|
+
const belongsToLoad = hasConstraints ? generateBelongsToLoad(model, allModels) : "";
|
|
152
|
+
const validateSelf = belongsToLoad ? "__mergedSelf" : "data";
|
|
129
153
|
return `
|
|
130
154
|
/**
|
|
131
155
|
* Create a new ${modelName}
|
|
132
156
|
*/
|
|
133
|
-
public async create(data: any): Promise<any> {
|
|
157
|
+
public async create(data: any, _actor: any = null): Promise<any> {
|
|
158
|
+
${belongsToLoad ? `
|
|
159
|
+
// Phase 2 Slice 15 \u2014 Create-time relation loading. For each belongsTo
|
|
160
|
+
// FK present in input, load the related entity and merge into self
|
|
161
|
+
// so constraints traversing self.<rel>.<field> see the full related
|
|
162
|
+
// record (not just the FK id). Mirrors Slice 8's Update pattern but
|
|
163
|
+
// for the create path. Only fires when the model has declared
|
|
164
|
+
// constraints; one extra round-trip per FK in input.
|
|
165
|
+
const __loadedRels: Record<string, any> = {};
|
|
166
|
+
${belongsToLoad}
|
|
167
|
+
const __mergedSelf = { ...data, ...__loadedRels };
|
|
168
|
+
` : ""}
|
|
134
169
|
// Validate input
|
|
135
|
-
const validationResult = this.validate(
|
|
170
|
+
const validationResult = await this.validate(${validateSelf}, { operation: 'create' }, _actor);
|
|
136
171
|
if (!validationResult.valid) {
|
|
137
172
|
throw new Error(\`Validation failed: \${validationResult.errors.join(', ')}\`);
|
|
138
173
|
}
|
|
@@ -153,6 +188,33 @@ function generateCreateMethod(model, modelName, modelVar, prismaDelegate, contro
|
|
|
153
188
|
}
|
|
154
189
|
`;
|
|
155
190
|
}
|
|
191
|
+
function generateBelongsToLoad(model, allModels) {
|
|
192
|
+
const rels = Array.isArray(model.relationships) ? model.relationships : Object.values(model.relationships || {});
|
|
193
|
+
const belongsToRels = rels.filter((r) => r.type === "belongsTo");
|
|
194
|
+
if (belongsToRels.length === 0) return "";
|
|
195
|
+
const RESERVED_WORDS = /* @__PURE__ */ new Set(["import", "export", "default", "class", "function", "return", "delete", "new", "this", "switch", "case", "break", "continue", "for", "while", "do", "if", "else", "try", "catch", "finally", "throw", "typeof", "instanceof", "in", "of", "let", "const", "var", "void", "with", "yield", "async", "await", "enum", "implements", "interface", "package", "private", "protected", "public", "static", "super", "extends"]);
|
|
196
|
+
return belongsToRels.map((rel) => {
|
|
197
|
+
const relName = rel.name;
|
|
198
|
+
const targetName = rel.target;
|
|
199
|
+
const fkField = `${relName}Id`;
|
|
200
|
+
const targetVar = targetName.charAt(0).toLowerCase() + targetName.slice(1);
|
|
201
|
+
const targetDelegate = RESERVED_WORDS.has(targetVar) ? `prisma['${targetVar}']` : `prisma.${targetVar}`;
|
|
202
|
+
let idExpr = `data.${fkField}`;
|
|
203
|
+
if (allModels) {
|
|
204
|
+
const targetModel = allModels.find((m) => m.name === targetName);
|
|
205
|
+
if (targetModel) {
|
|
206
|
+
const idAttr = (Array.isArray(targetModel.attributes) ? targetModel.attributes : Object.values(targetModel.attributes || {})).find((a) => a.name === "id");
|
|
207
|
+
const idType = idAttr?.type || "String";
|
|
208
|
+
if (idType === "Integer" || idType === "Int" || idType === "Number") {
|
|
209
|
+
idExpr = `parseInt(data.${fkField}, 10)`;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return `if (data.${fkField}) {
|
|
214
|
+
__loadedRels.${relName} = await ${targetDelegate}.findUnique({ where: { id: ${idExpr} } });
|
|
215
|
+
}`;
|
|
216
|
+
}).join("\n ");
|
|
217
|
+
}
|
|
156
218
|
function generateRetrieveMethod(model, modelName, modelVar, prismaDelegate) {
|
|
157
219
|
return `
|
|
158
220
|
/**
|
|
@@ -176,14 +238,26 @@ function generateRetrieveMethod(model, modelName, modelVar, prismaDelegate) {
|
|
|
176
238
|
}
|
|
177
239
|
`;
|
|
178
240
|
}
|
|
179
|
-
function generateUpdateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels) {
|
|
241
|
+
function generateUpdateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels, hasConstraints = false) {
|
|
180
242
|
return `
|
|
181
243
|
/**
|
|
182
244
|
* Update ${modelName}
|
|
183
245
|
*/
|
|
184
|
-
public async update(id: string, data: any): Promise<any> {
|
|
246
|
+
public async update(id: string, data: any, _actor: any = null): Promise<any> {
|
|
247
|
+
${hasConstraints ? `
|
|
248
|
+
// Phase 2 \u2014 Update self-from-DB: load the entity first and validate
|
|
249
|
+
// against the MERGED \`loaded + input\` shape so update-time constraints
|
|
250
|
+
// like \`self.poll.votingStatus == "open"\` see the full record even
|
|
251
|
+
// when the caller only sent a partial payload. Costs one extra DB
|
|
252
|
+
// round-trip; only fires for models with declared constraints.
|
|
253
|
+
const __existing = await ${prismaDelegate}.findUnique({ where: { id: parseId(id) }${generateIncludeRelationships(model)} });
|
|
254
|
+
if (!__existing) throw new Error('${modelName} not found');
|
|
255
|
+
const __merged = { ...__existing, ...data };
|
|
256
|
+
const validationResult = await this.validate(__merged, { operation: 'update' }, _actor);
|
|
257
|
+
` : `
|
|
185
258
|
// Validate input
|
|
186
|
-
const validationResult = this.validate(data, { operation: 'update' });
|
|
259
|
+
const validationResult = await this.validate(data, { operation: 'update' }, _actor);
|
|
260
|
+
`}
|
|
187
261
|
if (!validationResult.valid) {
|
|
188
262
|
throw new Error(\`Validation failed: \${validationResult.errors.join(', ')}\`);
|
|
189
263
|
}
|
|
@@ -213,12 +287,23 @@ function generateUpdateMethod(model, modelName, modelVar, prismaDelegate, contro
|
|
|
213
287
|
}
|
|
214
288
|
`;
|
|
215
289
|
}
|
|
216
|
-
function generateEvolveMethod(model, modelName, modelVar, prismaDelegate, controller) {
|
|
290
|
+
function generateEvolveMethod(model, modelName, modelVar, prismaDelegate, controller, hasConstraints = false) {
|
|
217
291
|
const lifecycles = Array.isArray(model.lifecycles) ? model.lifecycles : model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]) => ({ name, ...lc })) : [];
|
|
218
292
|
const lifecycle = lifecycles[0];
|
|
219
293
|
const lifecycleName = lifecycle?.name || "status";
|
|
220
294
|
const states = lifecycle?.states || [];
|
|
221
295
|
const validTransitions = lifecycle ? buildTransitionMap(lifecycle) : {};
|
|
296
|
+
const evolveOpsMap = {};
|
|
297
|
+
if (lifecycle?.transitions) {
|
|
298
|
+
for (const [actionName, t] of Object.entries(lifecycle.transitions)) {
|
|
299
|
+
const from = t.from;
|
|
300
|
+
const to = t.to;
|
|
301
|
+
if (typeof from === "string" && typeof to === "string") {
|
|
302
|
+
evolveOpsMap[from] = evolveOpsMap[from] ?? {};
|
|
303
|
+
evolveOpsMap[from][to] = actionName;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
222
307
|
return `
|
|
223
308
|
/**
|
|
224
309
|
* Evolve ${modelName} through lifecycle "${lifecycleName}"
|
|
@@ -229,7 +314,7 @@ function generateEvolveMethod(model, modelName, modelVar, prismaDelegate, contro
|
|
|
229
314
|
* runtime's useTransitionStateMutation always sends the former; the
|
|
230
315
|
* realized smoke-parity script can send either.
|
|
231
316
|
*/
|
|
232
|
-
public async evolve(id: string, data: any): Promise<any> {
|
|
317
|
+
public async evolve(id: string, data: any, _actor: any = null): Promise<any> {
|
|
233
318
|
// Get current record to check lifecycle state
|
|
234
319
|
const current = await ${prismaDelegate}.findUnique({ where: { id: parseId(id) } });
|
|
235
320
|
if (!current) {
|
|
@@ -254,7 +339,22 @@ function generateEvolveMethod(model, modelName, modelVar, prismaDelegate, contro
|
|
|
254
339
|
throw new Error(\`Invalid transition: \${currentState} \u2192 \${targetState}. Allowed: \${allowed.join(', ') || 'none'}\`);
|
|
255
340
|
}
|
|
256
341
|
` : ""}
|
|
257
|
-
|
|
342
|
+
${hasConstraints ? `
|
|
343
|
+
// Phase 2 \u2014 resolve the transition's action name so constraints scoped
|
|
344
|
+
// to \`on: 'evolve.<action>'\` match correctly. EVOLVE_OPS is baked at
|
|
345
|
+
// codegen from the lifecycle definition.
|
|
346
|
+
const EVOLVE_OPS: Record<string, Record<string, string>> = ${JSON.stringify(evolveOpsMap)};
|
|
347
|
+
const currentStateForOp = (current as any)[targetLifecycle];
|
|
348
|
+
const actionName = EVOLVE_OPS[currentStateForOp]?.[targetState] ?? targetState;
|
|
349
|
+
const evolveValidation = await this.validate(
|
|
350
|
+
{ ...data, ...current, [targetLifecycle]: targetState },
|
|
351
|
+
{ operation: \`evolve.\${actionName}\` as any },
|
|
352
|
+
_actor
|
|
353
|
+
);
|
|
354
|
+
if (!evolveValidation.valid) {
|
|
355
|
+
throw new Error(\`Validation failed: \${evolveValidation.errors.join(', ')}\`);
|
|
356
|
+
}
|
|
357
|
+
` : ""}
|
|
258
358
|
// Build the Prisma update payload \u2014 only the lifecycle column
|
|
259
359
|
// changes. Strips toState/lifecycleName/state so Prisma doesn't
|
|
260
360
|
// reject unknown fields.
|
|
@@ -273,15 +373,25 @@ function generateEvolveMethod(model, modelName, modelVar, prismaDelegate, contro
|
|
|
273
373
|
}
|
|
274
374
|
`;
|
|
275
375
|
}
|
|
276
|
-
function generateDeleteMethod(model, modelName, modelVar, prismaDelegate, controller) {
|
|
376
|
+
function generateDeleteMethod(model, modelName, modelVar, prismaDelegate, controller, hasConstraints = false) {
|
|
277
377
|
return `
|
|
278
378
|
/**
|
|
279
379
|
* Delete ${modelName}
|
|
280
380
|
*/
|
|
281
|
-
public async delete(id: string): Promise<void> {
|
|
381
|
+
public async delete(id: string, _actor: any = null): Promise<void> {
|
|
282
382
|
// Get record before deletion for event
|
|
283
383
|
const ${modelVar} = await ${prismaDelegate}.findUnique({ where: { id: parseId(id) } });
|
|
284
|
-
|
|
384
|
+
${hasConstraints ? `
|
|
385
|
+
// Phase 2 \u2014 run delete-scoped constraint guards against the loaded
|
|
386
|
+
// record. \`self\` is the entity being deleted (loaded above), giving
|
|
387
|
+
// delete-time constraints access to the entity's current field values.
|
|
388
|
+
if (${modelVar}) {
|
|
389
|
+
const deleteValidation = await this.validate(${modelVar}, { operation: 'delete' }, _actor);
|
|
390
|
+
if (!deleteValidation.valid) {
|
|
391
|
+
throw new Error(\`Validation failed: \${deleteValidation.errors.join(', ')}\`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
` : ""}
|
|
285
395
|
await ${prismaDelegate}.delete({
|
|
286
396
|
where: { id: parseId(id) }
|
|
287
397
|
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { transpilePhase2Guard } from "@specverse/engines/inference";
|
|
2
|
+
function generatePrismaGuards(context) {
|
|
3
|
+
const { model } = context;
|
|
4
|
+
if (!model) {
|
|
5
|
+
throw new Error("Model is required for guards generation");
|
|
6
|
+
}
|
|
7
|
+
const constraints = model.constraints ?? [];
|
|
8
|
+
if (constraints.length === 0) {
|
|
9
|
+
return generateEmptyGuardsModule(model.name);
|
|
10
|
+
}
|
|
11
|
+
const guardFns = [];
|
|
12
|
+
const constraintsTable = [];
|
|
13
|
+
for (const c of constraints) {
|
|
14
|
+
const transpiled = transpilePhase2Guard(
|
|
15
|
+
c.requires.name,
|
|
16
|
+
c.requires.params ?? `self: any, actor: any`,
|
|
17
|
+
c.requires.body
|
|
18
|
+
);
|
|
19
|
+
guardFns.push(transpiled.typescript);
|
|
20
|
+
const onArrayLiteral = `[${c.on.map((o) => JSON.stringify(o)).join(", ")}]`;
|
|
21
|
+
const sourceLiteral = JSON.stringify(
|
|
22
|
+
c.requires.source.input ?? ""
|
|
23
|
+
);
|
|
24
|
+
constraintsTable.push(
|
|
25
|
+
` {
|
|
26
|
+
on: ${onArrayLiteral},
|
|
27
|
+
guard: ${c.requires.name},
|
|
28
|
+
name: ${JSON.stringify(c.requires.name)},
|
|
29
|
+
source: ${sourceLiteral}
|
|
30
|
+
}`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return `/**
|
|
34
|
+
* Auto-generated by SpecVerse Phase 2 (Validate-Centric Constraints).
|
|
35
|
+
*
|
|
36
|
+
* Guard functions for ${model.name}'s declared constraints.
|
|
37
|
+
* DO NOT EDIT \u2014 regenerated on every \`spv realize all\`.
|
|
38
|
+
*
|
|
39
|
+
* Slice 1 limitations:
|
|
40
|
+
* - \`self\` is the input payload (not the loaded entity). For Update/Delete/
|
|
41
|
+
* Evolve, paths through \`self\` see only the fields the caller sent.
|
|
42
|
+
* - \`actor\` defaults to null. Constraints using \`actor.*\` paths fail at
|
|
43
|
+
* runtime when called without a user context.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export interface Violation {
|
|
47
|
+
constraint: string;
|
|
48
|
+
scope: string;
|
|
49
|
+
source: string;
|
|
50
|
+
message: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ConstraintRecord {
|
|
54
|
+
on: string[];
|
|
55
|
+
guard: (self: any, actor: any) => boolean;
|
|
56
|
+
name: string;
|
|
57
|
+
source: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
${guardFns.join("\n\n")}
|
|
61
|
+
|
|
62
|
+
export const MODEL_CONSTRAINTS: ConstraintRecord[] = [
|
|
63
|
+
${constraintsTable.join(",\n")}
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Match a runtime operation against a constraint's \`on:\` array.
|
|
68
|
+
*
|
|
69
|
+
* Op shapes:
|
|
70
|
+
* - exact string: 'create', 'update', 'delete', 'retrieve'
|
|
71
|
+
* - 'evolve.<transition>': specific lifecycle transition
|
|
72
|
+
*
|
|
73
|
+
* \`on:\` entry shapes:
|
|
74
|
+
* - '*': always matches
|
|
75
|
+
* - 'evolve.*': matches any 'evolve.X' op
|
|
76
|
+
* - else: exact-string equality
|
|
77
|
+
*/
|
|
78
|
+
export function matchesOp(constraintOn: string[], op: string): boolean {
|
|
79
|
+
for (const o of constraintOn) {
|
|
80
|
+
if (o === '*') return true;
|
|
81
|
+
if (o === op) return true;
|
|
82
|
+
if (o === 'evolve.*' && op.startsWith('evolve.')) return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run all constraints whose \`on:\` matches the requested op. Each guard
|
|
89
|
+
* is invoked with (input, actor). Returns the collected violations.
|
|
90
|
+
*
|
|
91
|
+
* A guard that returns false produces one Violation; a guard that throws
|
|
92
|
+
* (e.g. actor=null with an actor.* path) is caught and surfaced as a
|
|
93
|
+
* violation with the thrown message \u2014 fail closed.
|
|
94
|
+
*/
|
|
95
|
+
export function runGuards(
|
|
96
|
+
input: any,
|
|
97
|
+
op: string,
|
|
98
|
+
actor: any = null,
|
|
99
|
+
): Violation[] {
|
|
100
|
+
const violations: Violation[] = [];
|
|
101
|
+
for (const c of MODEL_CONSTRAINTS) {
|
|
102
|
+
if (!matchesOp(c.on, op)) continue;
|
|
103
|
+
let passed = false;
|
|
104
|
+
let thrown: string | null = null;
|
|
105
|
+
try {
|
|
106
|
+
passed = c.guard(input, actor);
|
|
107
|
+
} catch (e: any) {
|
|
108
|
+
thrown = e?.message ?? String(e);
|
|
109
|
+
}
|
|
110
|
+
if (!passed) {
|
|
111
|
+
violations.push({
|
|
112
|
+
constraint: c.name,
|
|
113
|
+
scope: op,
|
|
114
|
+
source: c.source,
|
|
115
|
+
message: thrown
|
|
116
|
+
? \`Constraint "\${c.source}" could not be evaluated: \${thrown}\`
|
|
117
|
+
: \`Constraint "\${c.source}" failed\`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return violations;
|
|
122
|
+
}
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
125
|
+
function generateEmptyGuardsModule(modelName) {
|
|
126
|
+
return `/**
|
|
127
|
+
* Auto-generated by SpecVerse Phase 2 \u2014 no constraints declared on ${modelName}.
|
|
128
|
+
* Empty stub kept for symmetry; controllers gate their imports on this file.
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
export interface Violation {
|
|
132
|
+
constraint: string;
|
|
133
|
+
scope: string;
|
|
134
|
+
source: string;
|
|
135
|
+
message: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const MODEL_CONSTRAINTS: any[] = [];
|
|
139
|
+
|
|
140
|
+
export function matchesOp(_constraintOn: string[], _op: string): boolean {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function runGuards(_input: any, _op: string, _actor: any = null): Violation[] {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
149
|
+
export {
|
|
150
|
+
generatePrismaGuards as default
|
|
151
|
+
};
|
|
@@ -11,6 +11,10 @@ import { SpecVerseAST } from './types/ast.js';
|
|
|
11
11
|
import { ProcessorContext } from '@specverse/types';
|
|
12
12
|
export declare class ConventionProcessor implements ProcessorContext {
|
|
13
13
|
warnings: string[];
|
|
14
|
+
/** Hard errors collected during processing. The parser rolls these up
|
|
15
|
+
* into the parseResult.errors so callers (e.g. app-demo's spec-loader)
|
|
16
|
+
* can refuse to load specs with broken constraints. */
|
|
17
|
+
errors: string[];
|
|
14
18
|
private entityProcessors;
|
|
15
19
|
private behaviouralProcessor;
|
|
16
20
|
private componentEntityTypes;
|
|
@@ -20,7 +24,12 @@ export declare class ConventionProcessor implements ProcessorContext {
|
|
|
20
24
|
*/
|
|
21
25
|
getWarnings(): string[];
|
|
22
26
|
/**
|
|
23
|
-
*
|
|
27
|
+
* Get accumulated hard errors. Spec consumers should treat a non-empty
|
|
28
|
+
* list as a load-blocker (refuse to start the spec server).
|
|
29
|
+
*/
|
|
30
|
+
getErrors(): string[];
|
|
31
|
+
/**
|
|
32
|
+
* Clear accumulated warnings + errors.
|
|
24
33
|
*/
|
|
25
34
|
clearWarnings(): void;
|
|
26
35
|
/**
|
|
@@ -35,6 +44,40 @@ export declare class ConventionProcessor implements ProcessorContext {
|
|
|
35
44
|
* Process a single component
|
|
36
45
|
*/
|
|
37
46
|
private processComponent;
|
|
47
|
+
/**
|
|
48
|
+
* Phase 2 — expand a model's `constraints:` array (author form) plus any
|
|
49
|
+
* `transitions.condition` aliases into the typed `ModelConstraintSpec[]`
|
|
50
|
+
* the rest of the pipeline consumes.
|
|
51
|
+
*
|
|
52
|
+
* - `requires:` strings, arrays, and `{and|or|not}` trees are walked
|
|
53
|
+
* recursively; each leaf is expanded via BehaviouralConventionProcessor
|
|
54
|
+
* with the owning model as `currentModel`. Sub-expressions are joined
|
|
55
|
+
* with Quint `and`/`or`/`not` to produce ONE compound guard per record.
|
|
56
|
+
* - `transitions.condition` from structured lifecycles is auto-mapped to
|
|
57
|
+
* `{on: 'evolve.<transition>', requires: condition}` — author can write
|
|
58
|
+
* either form, never both for the same transition (current pipeline
|
|
59
|
+
* warns rather than errors; tightening to a parse-time conflict error
|
|
60
|
+
* is a follow-up).
|
|
61
|
+
* - Leaves that don't match any convention emit a parser warning and skip
|
|
62
|
+
* the affected record; remaining records still produce guards.
|
|
63
|
+
*/
|
|
64
|
+
private expandModelConstraints;
|
|
65
|
+
/**
|
|
66
|
+
* Expand one `{on, requires}` record into a ModelConstraintSpec.
|
|
67
|
+
*
|
|
68
|
+
* `origin` distinguishes author-written records ('authored') from records
|
|
69
|
+
* synthesised from `transitions.condition` ('transition-alias'). The
|
|
70
|
+
* distinction is preserved in `requires.source.convention` so the
|
|
71
|
+
* AST → YAML round-trip only emits author records back as `model.constraints`
|
|
72
|
+
* (aliased ones still live in `lifecycles.*.transitions.<action>` as
|
|
73
|
+
* `when=` clauses — emitting them in both places would double-validate).
|
|
74
|
+
*/
|
|
75
|
+
private expandConstraintRecord;
|
|
76
|
+
/**
|
|
77
|
+
* Recursively expand a `requires:` tree into a single Quint expression.
|
|
78
|
+
* Returns null if any leaf fails to match a convention.
|
|
79
|
+
*/
|
|
80
|
+
private expandRequiresTree;
|
|
38
81
|
/**
|
|
39
82
|
* Process a single manifest
|
|
40
83
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"convention-processor.d.ts","sourceRoot":"","sources":["../../src/parser/convention-processor.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAIL,YAAY,
|
|
1
|
+
{"version":3,"file":"convention-processor.d.ts","sourceRoot":"","sources":["../../src/parser/convention-processor.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAIL,YAAY,EAKb,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAQpD,qBAAa,mBAAoB,YAAW,gBAAgB;IACnD,QAAQ,EAAE,MAAM,EAAE,CAAM;IAC/B;;2DAEuD;IAChD,MAAM,EAAE,MAAM,EAAE,CAAM;IAE7B,OAAO,CAAC,gBAAgB,CAAyC;IACjE,OAAO,CAAC,oBAAoB,CAAiC;IAE7D,OAAO,CAAC,oBAAoB,CAAW;;IAwCvC;;OAEG;IACH,WAAW,IAAI,MAAM,EAAE;IAIvB;;;OAGG;IACH,SAAS,IAAI,MAAM,EAAE;IAIrB;;OAEG;IACH,aAAa,IAAI,IAAI;IAKrB;;OAEG;IACH,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAIjC;;OAEG;IACH,OAAO,CAAC,QAAQ,EAAE,GAAG,GAAG,YAAY;IAiCpC;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAwGxB;;;;;;;;;;;;;;;;OAgBG;IACH,OAAO,CAAC,sBAAsB;IA8C9B;;;;;;;;;OASG;IACH,OAAO,CAAC,sBAAsB;IAkD9B;;;OAGG;IACH,OAAO,CAAC,kBAAkB;IAuC1B;;OAEG;IACH,OAAO,CAAC,eAAe;IASvB;;;OAGG;IACH,OAAO,CAAC,iBAAiB;IAyBzB;;;OAGG;IACH,OAAO,CAAC,wBAAwB;IAwDhC;;;OAGG;IACH,OAAO,CAAC,UAAU;IA+ClB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAiBxB;;OAEG;IACH,OAAO,CAAC,eAAe;CAYxB"}
|