@specverse/engines 6.39.3 → 6.41.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 (26) hide show
  1. package/dist/libs/instance-factories/services/templates/prisma/controller-generator.js +39 -20
  2. package/dist/libs/instance-factories/services/templates/prisma/service-generator.js +14 -5
  3. package/dist/libs/instance-factories/services/templates/prisma/step-conventions.js +10 -1
  4. package/dist/realize/index.d.ts.map +1 -1
  5. package/dist/realize/index.js +101 -13
  6. package/dist/realize/index.js.map +1 -1
  7. package/dist/realize/per-owner-emit.d.ts +101 -0
  8. package/dist/realize/per-owner-emit.d.ts.map +1 -0
  9. package/dist/realize/per-owner-emit.js +351 -0
  10. package/dist/realize/per-owner-emit.js.map +1 -0
  11. package/dist/realize/per-owner-runner.d.ts +105 -0
  12. package/dist/realize/per-owner-runner.d.ts.map +1 -0
  13. package/dist/realize/per-owner-runner.js +336 -0
  14. package/dist/realize/per-owner-runner.js.map +1 -0
  15. package/dist/realize/runtime-emitters/library.d.ts.map +1 -1
  16. package/dist/realize/runtime-emitters/library.js +17 -1
  17. package/dist/realize/runtime-emitters/library.js.map +1 -1
  18. package/libs/instance-factories/services/templates/_shared/step-matching.d.ts +39 -0
  19. package/libs/instance-factories/services/templates/_shared/step-matching.d.ts.map +1 -0
  20. package/libs/instance-factories/services/templates/_shared/step-matching.js +90 -0
  21. package/libs/instance-factories/services/templates/_shared/step-matching.js.map +1 -0
  22. package/libs/instance-factories/services/templates/prisma/__tests__/step-conventions-return.test.ts +124 -0
  23. package/libs/instance-factories/services/templates/prisma/controller-generator.ts +70 -22
  24. package/libs/instance-factories/services/templates/prisma/service-generator.ts +52 -6
  25. package/libs/instance-factories/services/templates/prisma/step-conventions.ts +33 -1
  26. package/package.json +1 -1
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Step-conventions — `return` pattern safety tests.
3
+ *
4
+ * The 2026-05-11 per-owner trial surfaced a defect: when a spec step
5
+ * like `Return logout confirmation` matched the `^return\s+(.+)/i`
6
+ * pattern, the convention emitted `return logout confirmation;` —
7
+ * two bare identifiers after `return`, a parse error. Per-action
8
+ * passed this through because each body parsed in isolation only
9
+ * checked the wrapping body, not the surrounding file. Per-owner
10
+ * caught it because the whole-file parse check failed. Either way,
11
+ * runtime would have broken.
12
+ *
13
+ * Fix: the convention now ONLY emits when the value is a single
14
+ * bareword identifier (or simple property accessor) AND was
15
+ * previously declared in the action's emitted body. Otherwise
16
+ * returns the empty string so the matcher falls through to the AI
17
+ * fallback (LLM authors the return).
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { STEP_CONVENTIONS } from '../step-conventions.js';
22
+ import type { StepContext } from '../step-conventions.js';
23
+
24
+ function findReturnConvention() {
25
+ const c = STEP_CONVENTIONS.find((x) => x.name === 'return');
26
+ expect(c).toBeDefined();
27
+ return c!;
28
+ }
29
+
30
+ function makeCtx(overrides: Partial<StepContext> = {}): StepContext {
31
+ return {
32
+ modelName: 'Order',
33
+ serviceName: 'OrderService',
34
+ operationName: 'createOrder',
35
+ stepNum: 7,
36
+ parameterNames: [],
37
+ declaredVars: new Set<string>(),
38
+ ...overrides,
39
+ } as any;
40
+ }
41
+
42
+ describe("prisma step-convention `return` — only emits parseable TS", () => {
43
+ it("emits the model var for `Return updated order` (model-pattern matches)", () => {
44
+ const convention = findReturnConvention();
45
+ const m = 'Return updated order'.match(convention.pattern)!;
46
+ const call = convention.generateCall(m, makeCtx());
47
+ expect(call).toContain('return order;');
48
+ });
49
+
50
+ it("emits `return <name>;` when the value is a single identifier already declared", () => {
51
+ const convention = findReturnConvention();
52
+ const m = 'Return user'.match(convention.pattern)!;
53
+ const ctx = makeCtx({ declaredVars: new Set(['user']) });
54
+ const call = convention.generateCall(m, ctx);
55
+ expect(call).toContain('return user;');
56
+ });
57
+
58
+ it("emits `return <obj>.<prop>;` for a simple property accessor when head is declared", () => {
59
+ const convention = findReturnConvention();
60
+ const m = 'Return result.payload'.match(convention.pattern)!;
61
+ const ctx = makeCtx({ declaredVars: new Set(['result']) });
62
+ const call = convention.generateCall(m, ctx);
63
+ expect(call).toContain('return result.payload;');
64
+ });
65
+
66
+ it("REGRESSION: prose phrase 'logout confirmation' returns empty (bounces to LLM)", () => {
67
+ // Pre-fix this emitted `return logout confirmation;` — two bare
68
+ // identifiers, parse error. New behaviour: empty string → matcher
69
+ // treats as no match → AI fallback path.
70
+ const convention = findReturnConvention();
71
+ const m = 'Return logout confirmation'.match(convention.pattern)!;
72
+ const call = convention.generateCall(m, makeCtx());
73
+ expect(call).toBe('');
74
+ });
75
+
76
+ it("REGRESSION: '204 No Content' returns empty (number + identifiers is a parse error)", () => {
77
+ const convention = findReturnConvention();
78
+ const m = 'Return 204 No Content'.match(convention.pattern)!;
79
+ const call = convention.generateCall(m, makeCtx());
80
+ expect(call).toBe('');
81
+ });
82
+
83
+ it("REGRESSION: a PascalCase type name that was NEVER declared returns empty", () => {
84
+ // `return AuthRegisterResponse;` looks like valid TS but references
85
+ // a type as a value — won't resolve at runtime. Only emit when the
86
+ // identifier was actually declared in the body.
87
+ const convention = findReturnConvention();
88
+ const m = 'Return AuthRegisterResponse'.match(convention.pattern)!;
89
+ const call = convention.generateCall(m, makeCtx()); // declaredVars empty
90
+ expect(call).toBe('');
91
+ });
92
+
93
+ it("when the identifier IS declared, it WILL emit (the LLM put the value in scope)", () => {
94
+ const convention = findReturnConvention();
95
+ const m = 'Return AuthRegisterResponse'.match(convention.pattern)!;
96
+ const ctx = makeCtx({ declaredVars: new Set(['AuthRegisterResponse']) });
97
+ const call = convention.generateCall(m, ctx);
98
+ expect(call).toContain('return AuthRegisterResponse;');
99
+ });
100
+
101
+ it("falls back for whitespace-y multi-word values that aren't valid identifiers", () => {
102
+ const convention = findReturnConvention();
103
+ const m = 'Return the cached invoice if still fresh'.match(convention.pattern)!;
104
+ const call = convention.generateCall(m, makeCtx());
105
+ // "the cached invoice..." DOES start with "the" so model-pattern
106
+ // applies — emits the model var. That's the existing behaviour
107
+ // and intentional.
108
+ expect(call).toContain('return order;');
109
+ });
110
+
111
+ it("regression: empty result string causes the matcher to fall through", async () => {
112
+ // Smoke-test the integration: import the shared matcher and feed
113
+ // it a prose 'Return X' step. Expect matched: false (AI fallback).
114
+ const { matchAgainstConventions } = await import('../../_shared/step-matching.js');
115
+ const ctx = makeCtx();
116
+ const result = matchAgainstConventions(
117
+ 'Return logout confirmation',
118
+ ctx,
119
+ STEP_CONVENTIONS as any,
120
+ (inputs: string[]) => (inputs.length > 0 ? `{ ${inputs.join(', ')} }` : '{}'),
121
+ );
122
+ expect(result.matched).toBe(false);
123
+ });
124
+ });
@@ -377,24 +377,69 @@ function generateCustomActions(controller: any, modelName: string, modelVar: str
377
377
  const allUnmatchedSteps: Array<{ step: string; functionName: string; operationName: string }> = [];
378
378
 
379
379
  Object.entries(controller.actions).forEach(([actionName, action]: [string, any]) => {
380
- const behavior: BehaviorMetadata = {
381
- preconditions: action.requires || action.preconditions || [],
382
- steps: action.steps || [],
383
- postconditions: action.ensures || action.postconditions || [],
384
- sideEffects: action.publishes || action.events || [],
385
- transactional: action.transactional,
386
- };
387
-
388
- const ctx: BehaviorContext = {
389
- modelName,
390
- serviceName: `${modelName}Controller`,
391
- operationName: actionName,
392
- prismaModel: modelVar,
393
- parameterNames: Object.keys(action.parameters || {}),
394
- };
395
-
396
- const result = generateBehaviorWithHelpers(behavior, {}, ctx);
397
- allUnmatchedSteps.push(...result.unmatchedSteps);
380
+ const params = action.parameters || {};
381
+ const paramNames = Object.keys(params);
382
+ const hasSteps = Array.isArray(action.steps) && action.steps.length > 0;
383
+
384
+ // Realize L3 contract: when an action declares `steps:`, the
385
+ // implementation lives in `behaviors/<ModelName>Controller.ai.ts`
386
+ // as an exported async function named EXACTLY `actionName`. Both
387
+ // per-action and per-owner emitters honour this contract. The
388
+ // controller method here just delegates: collect the action's
389
+ // parameters into a single `input` object and forward to the
390
+ // per-action implementation.
391
+ //
392
+ // Pre-this-fix, the controller emitted a per-STEP chain
393
+ // (`step1Result = await aiBehaviors.<verboseStepName>(...)`,
394
+ // `step2Result = await aiBehaviors.<verboseStepName>(step1Result)`,
395
+ // ...) referencing function names that NO emitter produces anymore
396
+ // per-action and per-owner both export per-action names only.
397
+ // Every controller method was broken at compile time. Surfaced by
398
+ // the 2026-05-11 per-owner trial v4 (TS2339 "Property
399
+ // 'throwImmediatelyIfNoRefreshTokenIsHeldInLocalState' does not
400
+ // exist on type 'typeof import("...")'").
401
+ //
402
+ // Actions WITHOUT steps still go through the legacy
403
+ // behavior-generator path (deterministic shell from
404
+ // requires/ensures/publishes only); they don't need an .ai.ts
405
+ // delegate.
406
+ let body: string;
407
+ let needsAi = false;
408
+ if (hasSteps) {
409
+ // Delegate path. `input` is the bag of declared params, even if
410
+ // the action has zero declared params (then `input = {}`).
411
+ const inputObj = paramNames.length > 0
412
+ ? `{ ${paramNames.join(', ')} }`
413
+ : '{}';
414
+ body = ` const input = ${inputObj};
415
+ return await aiBehaviors.${actionName}(input);`;
416
+ needsAi = true;
417
+ } else {
418
+ // No-steps path — use the legacy behavior-generator that
419
+ // emits requires/ensures/publishes scaffolding.
420
+ const behavior: BehaviorMetadata = {
421
+ preconditions: action.requires || action.preconditions || [],
422
+ steps: [],
423
+ postconditions: action.ensures || action.postconditions || [],
424
+ sideEffects: action.publishes || action.events || [],
425
+ transactional: action.transactional,
426
+ };
427
+ const ctx: BehaviorContext = {
428
+ modelName,
429
+ serviceName: `${modelName}Controller`,
430
+ operationName: actionName,
431
+ prismaModel: modelVar,
432
+ parameterNames: paramNames,
433
+ };
434
+ const result = generateBehaviorWithHelpers(behavior, {}, ctx);
435
+ allUnmatchedSteps.push(...result.unmatchedSteps);
436
+ body = result.body;
437
+ needsAi = result.unmatchedSteps.length > 0;
438
+
439
+ if (result.helperMethods.length > 0) {
440
+ actions.push(...result.helperMethods);
441
+ }
442
+ }
398
443
 
399
444
  actions.push(`
400
445
  /**
@@ -402,12 +447,15 @@ function generateCustomActions(controller: any, modelName: string, modelVar: str
402
447
  * ${action.description || ''}
403
448
  */
404
449
  public async ${actionName}(${generateActionParams(action)}): Promise<any> {
405
- ${result.body}
450
+ ${body}
406
451
  }`);
407
452
 
408
- if (result.helperMethods.length > 0) {
409
- actions.push(...result.helperMethods);
410
- }
453
+ // Aggregate the needsAi signal for the import-line decision below.
454
+ if (needsAi) allUnmatchedSteps.push({
455
+ step: '<delegate-to-ai-ts>',
456
+ functionName: actionName,
457
+ operationName: actionName,
458
+ });
411
459
  });
412
460
 
413
461
  return {
@@ -151,7 +151,7 @@ function generateOperations(service: any): string {
151
151
  function generateOperation(operationName: string, operation: any, service: any): string {
152
152
  const rawParams = generateOperationParams(operation);
153
153
  const hasPublish = service.publishes && service.publishes.length > 0;
154
- const body = generateOperationLogic(operation, service);
154
+ const body = generateOperationLogic(operation, service, operationName);
155
155
  // Rename any operation parameter that the body doesn't reference so tsc's
156
156
  // noUnusedParameters doesn't trip on placeholder service stubs.
157
157
  const params = renameUnusedParams(rawParams, body);
@@ -221,7 +221,7 @@ function generateOperationParams(operation: any): string {
221
221
  * Generate operation logic from behavioral metadata (L3 generation).
222
222
  * Falls back to placeholder if no behavioral data available.
223
223
  */
224
- function generateOperationLogic(operation: any, service: any): string {
224
+ function generateOperationLogic(operation: any, service: any, operationName?: string): string {
225
225
  // Check for behavioral metadata — supports both AI-optimized format (implementation.preconditions)
226
226
  // and SpecVerse convention format (requires/ensures/publishes)
227
227
  const impl = operation.implementation || {};
@@ -239,14 +239,60 @@ function generateOperationLogic(operation: any, service: any): string {
239
239
  .map((e: string) => `Publish event: ${e}`);
240
240
  }
241
241
 
242
- if (impl.preconditions?.length || impl.postconditions?.length || impl.steps?.length || impl.transactional) {
243
- // L3: Generate from behavioral specification
242
+ // Realize L3 contract (mirrors controller-generator.ts): when an
243
+ // operation declares `steps:`, the implementation lives in
244
+ // `behaviors/<ServiceName>.ai.ts` as an exported async function
245
+ // named EXACTLY `operation.name`. Both per-action and per-owner
246
+ // emitters honour this contract. The service method here delegates:
247
+ // collect the operation's parameters into a single `input` object
248
+ // and forward to the per-action implementation in the .ai.ts.
249
+ //
250
+ // Pre-this-fix, the service emitted a per-STEP chain
251
+ // (`step1Result = await aiBehaviors.<verboseStepName>(...)`,
252
+ // `step2Result = await aiBehaviors.<verboseStepName>(step1Result)`,
253
+ // ...) referencing function names that NO emitter produces anymore
254
+ // — per-action and per-owner both export per-action names only.
255
+ // Every service method was broken at compile time. Surfaced by the
256
+ // 2026-05-11 per-owner trial v5: 411 TS errors in src/services/
257
+ // tracing back to "Property '<verboseStepName>' does not exist on
258
+ // type 'typeof import('.../behaviors/<X>.ai.js')'". Same defect as
259
+ // the controller-side per-step mismatch.
260
+ //
261
+ // Operations WITHOUT steps still go through the legacy
262
+ // behavior-generator path (preconditions/postconditions only,
263
+ // no per-step chain to break).
264
+ const hasSteps = Array.isArray(impl.steps) && impl.steps.length > 0
265
+ || Array.isArray(operation.steps) && operation.steps.length > 0;
266
+ if (hasSteps) {
267
+ // operationName resolution: prefer the caller-supplied entry KEY
268
+ // (always correct when operations is a name-keyed object — the
269
+ // canonical spec shape), fall back to operation.name (array-shape
270
+ // operations), final fallback 'execute' for malformed input.
271
+ // Pre-this-fix line read `operation.name || 'execute'`, which
272
+ // landed every operation on the `execute` delegate when the spec
273
+ // keyed operations by name without a redundant inner `.name`
274
+ // (the analyse-pipeline's canonical output). Result: the service
275
+ // method delegated to `aiBehaviors.execute(input)` but the .ai.ts
276
+ // only exported the real operation names → 69 TS2339 errors per
277
+ // 2026-05-11 idle-meta stub trial.
278
+ const resolvedOpName = operationName || operation.name || 'execute';
279
+ const paramNames = Object.keys(operation.parameters || {});
280
+ const inputObj = paramNames.length > 0
281
+ ? `{ ${paramNames.join(', ')} }`
282
+ : '{}';
283
+ return ` const input = ${inputObj};
284
+ return await aiBehaviors.${resolvedOpName}(input);`;
285
+ }
286
+
287
+ if (impl.preconditions?.length || impl.postconditions?.length || impl.transactional) {
288
+ // L3: Generate from behavioral specification (no-steps path —
289
+ // only requires/ensures/publishes scaffolding).
244
290
  const modelName = inferModelFromServiceName(service.name);
245
291
  const parameterNames = Object.keys(operation.parameters || {});
246
292
  const context: BehaviorContext = {
247
293
  modelName,
248
294
  serviceName: service.name,
249
- operationName: operation.name || 'execute',
295
+ operationName: operationName || operation.name || 'execute',
250
296
  prismaModel: modelName,
251
297
  parameterNames,
252
298
  };
@@ -255,7 +301,7 @@ function generateOperationLogic(operation: any, service: any): string {
255
301
  preconditions: impl.preconditions || [],
256
302
  postconditions: impl.postconditions || [],
257
303
  sideEffects: impl.sideEffects || [],
258
- steps: impl.steps || operation.steps || [],
304
+ steps: [], // steps path handled by the delegate branch above
259
305
  transactional: impl.transactional || false
260
306
  };
261
307
 
@@ -330,8 +330,40 @@ export const STEP_CONVENTIONS: StepConvention[] = [
330
330
  return ` // Step ${ctx.stepNum}: Return result
331
331
  return ${toVar(ctx.modelName)};`;
332
332
  }
333
- return ` // Step ${ctx.stepNum}: Return ${value}
333
+ // Only emit a typed `return <expr>;` when `value` is a single
334
+ // bareword identifier (or a single dotted/bracketed accessor) —
335
+ // anything that's syntactically a valid TypeScript expression
336
+ // already declared in the action's scope. Spec text like
337
+ // "logout confirmation" or "204 No Content" produces a
338
+ // grammatical English phrase but unparseable TS:
339
+ // return logout confirmation; // two bare identifiers
340
+ // return 204 No Content; // number + identifiers
341
+ // Returning the empty string here makes the matcher treat this
342
+ // as "convention regex matched but no safe emission" — the
343
+ // matcher falls through to the AI-fallback ([WRITE]) shape so
344
+ // the per-action / per-owner LLM hook handles it. Verified
345
+ // empirically by the 2026-05-11 idle-meta per-owner trial:
346
+ // the LLM correctly stubbed actions where prose returns had
347
+ // been bound to broken pre-baked snippets.
348
+ const isSafeIdentifier = /^[A-Za-z_$][\w$]*$/.test(value);
349
+ const isSafePropertyAccess = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(value);
350
+ if (isSafeIdentifier || isSafePropertyAccess) {
351
+ // Reference a single in-scope variable — but only if it was
352
+ // declared earlier in this action's emitted body (via the
353
+ // matcher's declaredVars set). Otherwise we'd emit
354
+ // `return AuthRegisterResponse;` referencing a type name
355
+ // that never resolves at runtime.
356
+ const head = value.split('.')[0]!;
357
+ const declared = ctx.declaredVars instanceof Set
358
+ ? ctx.declaredVars.has(head)
359
+ : false;
360
+ if (declared) {
361
+ return ` // Step ${ctx.stepNum}: Return ${value}
334
362
  return ${value};`;
363
+ }
364
+ }
365
+ // Bounce to [WRITE] — LLM will author this return.
366
+ return '';
335
367
  },
336
368
  },
337
369
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specverse/engines",
3
- "version": "6.39.3",
3
+ "version": "6.41.0",
4
4
  "description": "SpecVerse toolchain — parser, inference, realize, generators, AI, registry, bundles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",