@specverse/engines 6.21.4 → 6.27.12

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 (61) hide show
  1. package/dist/ai/analyse-runner.d.ts +16 -0
  2. package/dist/ai/analyse-runner.d.ts.map +1 -1
  3. package/dist/ai/analyse-runner.js +417 -53
  4. package/dist/ai/analyse-runner.js.map +1 -1
  5. package/dist/ai/microcall-orchestrator.d.ts +207 -0
  6. package/dist/ai/microcall-orchestrator.d.ts.map +1 -0
  7. package/dist/ai/microcall-orchestrator.js +753 -0
  8. package/dist/ai/microcall-orchestrator.js.map +1 -0
  9. package/dist/ai/skeleton-emitter.d.ts +94 -0
  10. package/dist/ai/skeleton-emitter.d.ts.map +1 -0
  11. package/dist/ai/skeleton-emitter.js +752 -0
  12. package/dist/ai/skeleton-emitter.js.map +1 -0
  13. package/dist/analyse-prepass/adapters/express-routes.d.ts +71 -0
  14. package/dist/analyse-prepass/adapters/express-routes.d.ts.map +1 -0
  15. package/dist/analyse-prepass/adapters/express-routes.js +329 -0
  16. package/dist/analyse-prepass/adapters/express-routes.js.map +1 -0
  17. package/dist/analyse-prepass/adapters/typescript-interfaces.d.ts +91 -0
  18. package/dist/analyse-prepass/adapters/typescript-interfaces.d.ts.map +1 -0
  19. package/dist/analyse-prepass/adapters/typescript-interfaces.js +411 -0
  20. package/dist/analyse-prepass/adapters/typescript-interfaces.js.map +1 -0
  21. package/dist/analyse-prepass/backends/gitnexus.d.ts.map +1 -1
  22. package/dist/analyse-prepass/backends/gitnexus.js +36 -8
  23. package/dist/analyse-prepass/backends/gitnexus.js.map +1 -1
  24. package/dist/analyse-prepass/backends/index.d.ts.map +1 -1
  25. package/dist/analyse-prepass/backends/index.js +3 -5
  26. package/dist/analyse-prepass/backends/index.js.map +1 -1
  27. package/dist/analyse-prepass/behavior-step-classifier.d.ts +3 -0
  28. package/dist/analyse-prepass/behavior-step-classifier.d.ts.map +1 -1
  29. package/dist/analyse-prepass/behavior-step-classifier.js +1 -0
  30. package/dist/analyse-prepass/behavior-step-classifier.js.map +1 -1
  31. package/dist/analyse-prepass/index.d.ts +69 -0
  32. package/dist/analyse-prepass/index.d.ts.map +1 -1
  33. package/dist/analyse-prepass/index.js +319 -37
  34. package/dist/analyse-prepass/index.js.map +1 -1
  35. package/dist/analyse-prepass/method-body-walker.d.ts +4 -0
  36. package/dist/analyse-prepass/method-body-walker.d.ts.map +1 -1
  37. package/dist/analyse-prepass/method-body-walker.js +14 -0
  38. package/dist/analyse-prepass/method-body-walker.js.map +1 -1
  39. package/dist/audit/realize-recorder.d.ts +164 -0
  40. package/dist/audit/realize-recorder.d.ts.map +1 -0
  41. package/dist/audit/realize-recorder.js +153 -0
  42. package/dist/audit/realize-recorder.js.map +1 -0
  43. package/dist/audit/verify-checks.d.ts +32 -0
  44. package/dist/audit/verify-checks.d.ts.map +1 -0
  45. package/dist/audit/verify-checks.js +202 -0
  46. package/dist/audit/verify-checks.js.map +1 -0
  47. package/dist/audit/verify-recorder.d.ts +84 -0
  48. package/dist/audit/verify-recorder.d.ts.map +1 -0
  49. package/dist/audit/verify-recorder.js +90 -0
  50. package/dist/audit/verify-recorder.js.map +1 -0
  51. package/dist/inference/core/specly-converter.d.ts.map +1 -1
  52. package/dist/inference/core/specly-converter.js +67 -36
  53. package/dist/inference/core/specly-converter.js.map +1 -1
  54. package/dist/libs/instance-factories/cli/templates/commander/command-generator.js +1 -0
  55. package/dist/libs/instance-factories/services/templates/_shared/step-matching.js +39 -15
  56. package/dist/realize/index.d.ts.map +1 -1
  57. package/dist/realize/index.js +63 -0
  58. package/dist/realize/index.js.map +1 -1
  59. package/libs/instance-factories/cli/templates/commander/command-generator.ts +1 -0
  60. package/libs/instance-factories/services/templates/_shared/step-matching.ts +61 -16
  61. package/package.json +1 -1
@@ -0,0 +1,752 @@
1
+ /**
2
+ * Faithful skeleton emitter — deterministic SpecVerseFacts to spec yaml
3
+ * (engines 6.23.0+, plan: 2026-05-04-ANALYSE-VIA-FAITHFUL-SKELETON-AND-MICROCALLS).
4
+ *
5
+ * Architectural principle (user-articulated): the analyse phase must
6
+ * produce a spec that is a FAITHFUL MIRROR of the codebase. If a query
7
+ * method is not in source, the spec has no retrieve op. No defaults, no
8
+ * fabrication. The existing inference engine (spv infer) is for
9
+ * from-scratch authoring expansion; it is the wrong building block for
10
+ * analyse output. A future spv enhance command (TODO #48) is where
11
+ * default-filling lives.
12
+ *
13
+ * Forward-logging discipline: every emission records a provenance entry
14
+ * mapping the emitted yaml line range to its source fact + emission
15
+ * rule. The report assembler reads from the provenance ledger; no
16
+ * backward parsing of the yaml is needed to know where each line came
17
+ * from.
18
+ */
19
+ /**
20
+ * Build the indexed lookup the emitter walks during emission. Component
21
+ * assignment uses the suggestedComponent's structural sourceDir (when
22
+ * present) as the prefix; classes whose filePath starts with that prefix
23
+ * belong to that component. Classes that don't match any component go to
24
+ * a default bucket.
25
+ */
26
+ function buildContext(facts) {
27
+ const classByName = new Map();
28
+ for (const cm of facts.candidateMethods ?? []) {
29
+ classByName.set(cm.entityName, cm);
30
+ }
31
+ const classesByComponent = new Map();
32
+ // Pre-create a bucket for each suggested component (preserves order).
33
+ const components = facts.suggestedComponents ?? [];
34
+ for (const comp of components) {
35
+ classesByComponent.set(comp.suggestedName, []);
36
+ }
37
+ // Plus a fallback bucket when nothing matches.
38
+ classesByComponent.set('_unassigned', []);
39
+ for (const cm of facts.candidateMethods ?? []) {
40
+ let assigned = false;
41
+ for (const comp of components) {
42
+ const sourceDir = comp.structural?.sourceDir;
43
+ if (sourceDir && cm.filePath.startsWith(sourceDir)) {
44
+ classesByComponent.get(comp.suggestedName).push(cm);
45
+ assigned = true;
46
+ break;
47
+ }
48
+ }
49
+ if (!assigned) {
50
+ classesByComponent.get('_unassigned').push(cm);
51
+ }
52
+ }
53
+ // Build the single-home entity mapping. First-named-component-wins;
54
+ // orphans fall to 'Unassigned' (or 'Application' when no components
55
+ // were suggested at all, mirroring the default-component fallback in
56
+ // emission). specName matches the post-strip name used during emission.
57
+ const entityHome = new Map();
58
+ const entityHomeAssignments = [];
59
+ const fallbackHome = components.length === 0 ? 'Application' : 'Unassigned';
60
+ for (const ent of facts.entities ?? []) {
61
+ let matchedComp = null;
62
+ for (const comp of components) {
63
+ if ((comp.entities ?? []).includes(ent.name)) {
64
+ matchedComp = comp;
65
+ break;
66
+ }
67
+ }
68
+ const home = matchedComp
69
+ ? (matchedComp.suggestedName.replace(/Component$/, '') || matchedComp.suggestedName)
70
+ : fallbackHome;
71
+ entityHome.set(ent.name, home);
72
+ entityHomeAssignments.push({
73
+ entityName: ent.name,
74
+ componentSpecName: home,
75
+ reason: matchedComp ? 'first-named-match' : 'fallback',
76
+ matchedComponentSuggestedName: matchedComp?.suggestedName,
77
+ });
78
+ }
79
+ return { facts, classByName, classesByComponent, entityHome, entityHomeAssignments };
80
+ }
81
+ /**
82
+ * Stateful builder collecting yaml lines + provenance entries in step.
83
+ * Caller invokes emit() to push lines; the line range is computed
84
+ * automatically based on the current cursor.
85
+ */
86
+ class SkeletonBuilder {
87
+ lines = [];
88
+ provenance = [];
89
+ actionStubs = [];
90
+ cursor = 1; // 1-based line number of the next line to be emitted
91
+ emit(text, decision) {
92
+ const lines = text.split('\n');
93
+ const startLine = this.cursor;
94
+ this.lines.push(text);
95
+ // text may contain trailing newline; count actual lines added
96
+ const lineCount = text.endsWith('\n') ? lines.length - 1 : lines.length;
97
+ this.cursor += lineCount;
98
+ const endLine = this.cursor - 1;
99
+ this.provenance.push({ ...decision, lineRange: [startLine, endLine] });
100
+ }
101
+ recordActionStub(stub) {
102
+ this.actionStubs.push(stub);
103
+ }
104
+ toResult(entityHomeAssignments) {
105
+ const skeleton = this.lines.join('');
106
+ const emissionsByRule = {};
107
+ for (const e of this.provenance)
108
+ emissionsByRule[e.rule] = (emissionsByRule[e.rule] ?? 0) + 1;
109
+ return {
110
+ skeleton,
111
+ provenance: {
112
+ schemaVersion: '1.0',
113
+ emittedAt: new Date().toISOString(),
114
+ entityHomeAssignments,
115
+ emissionCount: this.provenance.length,
116
+ emissionsByRule,
117
+ emissions: this.provenance,
118
+ actionStubs: this.actionStubs,
119
+ },
120
+ };
121
+ }
122
+ }
123
+ /**
124
+ * Faithfully emit a skeleton spec from prepass facts.
125
+ *
126
+ * The skeleton mirrors source: only what was detected gets emitted.
127
+ * Action bodies are STUBS — name + parameters + return placeholder + a
128
+ * pointer to the candidate-step timeline the per-action LLM micro-call
129
+ * should consume. The orchestrator merges per-action LLM output back
130
+ * into the skeleton afterward.
131
+ */
132
+ export function emitFaithfulSkeleton(facts) {
133
+ const ctx = buildContext(facts);
134
+ const b = new SkeletonBuilder();
135
+ b.emit('# SpecVerse skeleton — faithfully emitted from prepass facts.\n', {
136
+ yamlPath: '',
137
+ rule: 'comment-skeleton-marker',
138
+ note: 'No defaults injected. Action bodies filled by per-action LLM micro-calls.',
139
+ });
140
+ b.emit('# Per-line provenance lives in skeleton-provenance.json alongside this file.\n', {
141
+ yamlPath: '',
142
+ rule: 'comment-skeleton-marker',
143
+ });
144
+ b.emit('\n', {
145
+ yamlPath: '',
146
+ rule: 'comment-skeleton-marker',
147
+ });
148
+ b.emit('components:\n', {
149
+ yamlPath: 'components',
150
+ rule: 'comment-skeleton-marker',
151
+ });
152
+ const components = ctx.facts.suggestedComponents ?? [];
153
+ if (components.length === 0) {
154
+ // Adapter-less + structural-detector also empty — emit a single
155
+ // default component so the spec has somewhere to attach walker
156
+ // classes. Logged with the explicit "default-when-none-suggested"
157
+ // rule for transparency.
158
+ b.emit(' Application:\n', {
159
+ yamlPath: 'components.Application',
160
+ rule: 'header-component-default-when-none-suggested',
161
+ note: 'No components suggested by prepass; emitting a single default component.',
162
+ });
163
+ b.emit(` version: "1.0.0"\n`, {
164
+ yamlPath: 'components.Application.version',
165
+ rule: 'comment-skeleton-marker',
166
+ });
167
+ emitComponentBody(b, ctx, 'Application', '_unassigned', undefined);
168
+ }
169
+ else {
170
+ for (let i = 0; i < components.length; i++) {
171
+ const comp = components[i];
172
+ // Strip "Component" suffix on the spec-name if present (suggestedName
173
+ // is FooComponent; spec convention is FooApp / Foo).
174
+ const specName = comp.suggestedName.replace(/Component$/, '') || comp.suggestedName;
175
+ b.emit(` ${specName}:\n`, {
176
+ yamlPath: `components.${specName}`,
177
+ rule: 'header-component-from-suggested',
178
+ sourceFactRef: `facts.suggestedComponents[${i}]`,
179
+ sourceLocation: comp.structural?.sourceDir
180
+ ? { filePath: comp.structural.sourceDir, lineRange: [1, 1] }
181
+ : undefined,
182
+ });
183
+ b.emit(` version: "1.0.0"\n`, {
184
+ yamlPath: `components.${specName}.version`,
185
+ rule: 'comment-skeleton-marker',
186
+ note: 'Schema requires version: on every component (default 1.0.0).',
187
+ });
188
+ emitComponentBody(b, ctx, specName, comp.suggestedName, comp);
189
+ }
190
+ const orphanClasses = ctx.classesByComponent.get('_unassigned') ?? [];
191
+ const orphanEntities = [...ctx.entityHome.entries()].filter(([, h]) => h === 'Unassigned');
192
+ if ((orphanClasses?.length ?? 0) > 0 || orphanEntities.length > 0) {
193
+ const noteParts = [];
194
+ if ((orphanClasses?.length ?? 0) > 0) {
195
+ noteParts.push(`${orphanClasses.length} walked class(es) did not match any suggested component's source path`);
196
+ }
197
+ if (orphanEntities.length > 0) {
198
+ noteParts.push(`${orphanEntities.length} entit(ies) not listed in any named component`);
199
+ }
200
+ b.emit(' Unassigned:\n', {
201
+ yamlPath: 'components.Unassigned',
202
+ rule: 'header-component-default-when-none-suggested',
203
+ note: noteParts.join('; '),
204
+ });
205
+ b.emit(` version: "1.0.0"\n`, {
206
+ yamlPath: 'components.Unassigned.version',
207
+ rule: 'comment-skeleton-marker',
208
+ });
209
+ emitComponentBody(b, ctx, 'Unassigned', '_unassigned', undefined);
210
+ }
211
+ }
212
+ return b.toResult(ctx.entityHomeAssignments);
213
+ }
214
+ function emitComponentBody(b, ctx, specName, bucketKey, _comp) {
215
+ // ── Models (only when adapters detected entities) ─────────────────
216
+ // Single-home entity mapping (Option A, 2026-05-04): every detected
217
+ // entity maps to EXACTLY ONE component via ctx.entityHome. Eliminates
218
+ // both Unassigned-vs-named and named-vs-named duplication that
219
+ // otherwise triggers `realize` collisions ("models.X declared in
220
+ // multiple components"). The intra-component seenModelNames guard
221
+ // remains as a defense against the TS-walker emitting the same name
222
+ // twice from different source files (still possible in adapter merge).
223
+ const seenModelNames = new Set();
224
+ const componentEntities = (ctx.facts.entities ?? []).filter((e) => {
225
+ if (ctx.entityHome.get(e.name) !== specName)
226
+ return false;
227
+ if (seenModelNames.has(e.name))
228
+ return false;
229
+ seenModelNames.add(e.name);
230
+ return true;
231
+ });
232
+ if (componentEntities.length > 0) {
233
+ b.emit(` models:\n`, {
234
+ yamlPath: `components.${specName}.models`,
235
+ rule: 'comment-skeleton-marker',
236
+ });
237
+ for (const ent of componentEntities) {
238
+ const entityIdx = (ctx.facts.entities ?? []).indexOf(ent);
239
+ b.emit(` ${ent.name}:\n`, {
240
+ yamlPath: `components.${specName}.models.${ent.name}`,
241
+ rule: 'model-from-detected-entity',
242
+ sourceFactRef: `facts.entities[${entityIdx}]`,
243
+ sourceLocation: { filePath: ent.filePath, lineRange: [1, 1] },
244
+ });
245
+ // Attributes — emit one line per detected attribute, exactly as detected.
246
+ const attrs = ent.attributes ?? [];
247
+ if (attrs.length > 0) {
248
+ b.emit(` attributes:\n`, {
249
+ yamlPath: `components.${specName}.models.${ent.name}.attributes`,
250
+ rule: 'comment-skeleton-marker',
251
+ });
252
+ // Dedupe attribute names within the model — TS walker may
253
+ // emit the same field twice if declared in two source variants.
254
+ const seenAttrNames = new Set();
255
+ for (let ai = 0; ai < attrs.length; ai++) {
256
+ const a = attrs[ai];
257
+ if (seenAttrNames.has(a.name))
258
+ continue;
259
+ seenAttrNames.add(a.name);
260
+ const declaredType = normalizeTypeForSpec(a.declaredType ?? 'String');
261
+ // Schema allows: required | optional | unique | auto=X | min=X
262
+ // | max=X | default=X | verified | searchable | values=[...].
263
+ // `isPrimary` is a TS-walker concept, NOT a SpecVerse modifier —
264
+ // by convention, the `id` field IS the primary key. Skip the flag.
265
+ const flags = [
266
+ a.isUnique ? 'unique' : null,
267
+ a.required === false ? 'optional' : 'required',
268
+ ].filter(Boolean).join(' ');
269
+ b.emit(` ${a.name}: ${declaredType}${flags ? ' ' + flags : ''}\n`, {
270
+ yamlPath: `components.${specName}.models.${ent.name}.attributes.${a.name}`,
271
+ rule: 'model-attribute-from-detected-attribute',
272
+ sourceFactRef: `facts.entities[${entityIdx}].attributes[${ai}]`,
273
+ });
274
+ }
275
+ }
276
+ // Relationships matching this model.
277
+ const rels = (ctx.facts.relationships ?? []).filter((r) => r.from === ent.name);
278
+ if (rels.length > 0) {
279
+ b.emit(` relationships:\n`, {
280
+ yamlPath: `components.${specName}.models.${ent.name}.relationships`,
281
+ rule: 'comment-skeleton-marker',
282
+ });
283
+ for (let ri = 0; ri < rels.length; ri++) {
284
+ const r = rels[ri];
285
+ const relIdx = (ctx.facts.relationships ?? []).indexOf(r);
286
+ const cascade = r.cascade ? ' cascade' : '';
287
+ // Lowercase first letter of target as the field name (convention).
288
+ const fieldName = r.to.charAt(0).toLowerCase() + r.to.slice(1) + (r.type === 'hasMany' || r.type === 'manyToMany' ? 's' : '');
289
+ b.emit(` ${fieldName}: ${r.type} ${r.to}${cascade}\n`, {
290
+ yamlPath: `components.${specName}.models.${ent.name}.relationships.${fieldName}`,
291
+ rule: 'model-relationship-from-detected-relationship',
292
+ sourceFactRef: `facts.relationships[${relIdx}]`,
293
+ });
294
+ }
295
+ }
296
+ // Lifecycle (if any). Schema shape (SPECVERSE-SCHEMA.json $defs.Model):
297
+ // lifecycles: <- plural wrapper
298
+ // <statusField>: <- arbitrary field name
299
+ // states: ["..."]
300
+ // transitions:
301
+ // <action>: "from -> to"
302
+ // Earlier emitter versions used `lifecycle:` (singular wrapper) or
303
+ // `<statusField>:` directly on the Model; both rejected as Unknown
304
+ // property. The plural `lifecycles:` is the canonical container.
305
+ const lc = (ctx.facts.lifecycles ?? []).find((l) => l.model === ent.name);
306
+ if (lc) {
307
+ const lcIdx = (ctx.facts.lifecycles ?? []).indexOf(lc);
308
+ b.emit(` lifecycles:\n`, {
309
+ yamlPath: `components.${specName}.models.${ent.name}.lifecycles`,
310
+ rule: 'model-lifecycle-from-detected-lifecycle',
311
+ sourceFactRef: `facts.lifecycles[${lcIdx}]`,
312
+ });
313
+ b.emit(` ${lc.field}:\n`, {
314
+ yamlPath: `components.${specName}.models.${ent.name}.lifecycles.${lc.field}`,
315
+ rule: 'model-lifecycle-from-detected-lifecycle',
316
+ sourceFactRef: `facts.lifecycles[${lcIdx}].field`,
317
+ });
318
+ b.emit(` states:\n`, {
319
+ yamlPath: `components.${specName}.models.${ent.name}.lifecycles.${lc.field}.states`,
320
+ rule: 'model-lifecycle-from-detected-lifecycle',
321
+ sourceFactRef: `facts.lifecycles[${lcIdx}].states`,
322
+ });
323
+ for (let si = 0; si < lc.states.length; si++) {
324
+ b.emit(` - "${lc.states[si]}"\n`, {
325
+ yamlPath: `components.${specName}.models.${ent.name}.lifecycles.${lc.field}.states[${si}]`,
326
+ rule: 'model-lifecycle-from-detected-lifecycle',
327
+ sourceFactRef: `facts.lifecycles[${lcIdx}].states[${si}]`,
328
+ });
329
+ }
330
+ // `transitions:` is REQUIRED by the schema's Shape-2 lifecycle
331
+ // (states + transitions). Always emit the key, even when no
332
+ // transitions were inferred — the realize stack treats the
333
+ // absence of mappings as "states-only enum" and doesn't
334
+ // generate transition guards. Empty transitions is preferable
335
+ // to "Unknown property 'states'" schema validation failure.
336
+ if (lc.transitions && lc.transitions.length > 0) {
337
+ b.emit(` transitions:\n`, {
338
+ yamlPath: `components.${specName}.models.${ent.name}.lifecycles.${lc.field}.transitions`,
339
+ rule: 'comment-skeleton-marker',
340
+ });
341
+ for (let ti = 0; ti < lc.transitions.length; ti++) {
342
+ const t = lc.transitions[ti];
343
+ // Spec language carries one canonical "from -> to" pair per
344
+ // transition. Multi-from cases are documented in the
345
+ // behavior's `requires:` (see nestjs-billing expected.specly).
346
+ const fromState = t.from[0] ?? 'unknown';
347
+ b.emit(` ${t.action}: "${fromState} -> ${t.to}"\n`, {
348
+ yamlPath: `components.${specName}.models.${ent.name}.lifecycles.${lc.field}.transitions.${t.action}`,
349
+ rule: 'model-lifecycle-transition-from-detected-transition',
350
+ sourceFactRef: `facts.lifecycles[${lcIdx}].transitions[${ti}]`,
351
+ });
352
+ }
353
+ }
354
+ else {
355
+ b.emit(` transitions: {}\n`, {
356
+ yamlPath: `components.${specName}.models.${ent.name}.lifecycles.${lc.field}.transitions`,
357
+ rule: 'comment-skeleton-marker',
358
+ note: 'No transitions detected (states-only enum); emitted as empty mapping for schema compliance.',
359
+ });
360
+ }
361
+ }
362
+ // Model-level behaviors (TODO #154 — engines 6.25.11+).
363
+ // When the walker captured a class with the same name as this
364
+ // entity, treat its kept methods as model-level behaviors. The
365
+ // schema has `behaviors:` as a first-class slot on models for
366
+ // domain actions (e.g. Invoice.send / Invoice.markPaid). Each
367
+ // method becomes a microcall stub the orchestrator fills.
368
+ const entityClass = ctx.classByName.get(ent.name);
369
+ if (entityClass && entityClass.methods.length > 0) {
370
+ // Dedupe by method name (overloads, multiple source variants).
371
+ const seenMethodNames = new Set();
372
+ const dedupedMethods = entityClass.methods.filter((m) => {
373
+ if (seenMethodNames.has(m.methodName))
374
+ return false;
375
+ seenMethodNames.add(m.methodName);
376
+ return true;
377
+ });
378
+ if (dedupedMethods.length > 0) {
379
+ b.emit(` behaviors:\n`, {
380
+ yamlPath: `components.${specName}.models.${ent.name}.behaviors`,
381
+ rule: 'comment-skeleton-marker',
382
+ });
383
+ for (let mi = 0; mi < dedupedMethods.length; mi++) {
384
+ const method = dedupedMethods[mi];
385
+ // Sanitise to schema-allowed pattern (^[a-z][a-zA-Z0-9_]*$).
386
+ const safeName = /^[a-z][a-zA-Z0-9_]*$/.test(method.methodName)
387
+ ? method.methodName
388
+ : method.methodName.charAt(0).toLowerCase() + method.methodName.slice(1);
389
+ const stubPath = `components.${specName}.models.${ent.name}.behaviors.${safeName}`;
390
+ b.emit(` ${safeName}:\n`, {
391
+ yamlPath: stubPath,
392
+ rule: 'action-stub-from-business-method',
393
+ sourceLocation: { filePath: entityClass.filePath, lineRange: method.sourceLineRange },
394
+ note: `${method.candidates.length} candidate-step(s); model behavior body filled by LLM micro-call.`,
395
+ });
396
+ b.emit(` # body filled by per-action LLM micro-call (skeleton-emitter stub)\n`, {
397
+ yamlPath: stubPath,
398
+ rule: 'action-stub-from-business-method',
399
+ });
400
+ // Use 'service' ownerKind for the microcall — model behaviors
401
+ // and service operations have the same ExecutableProperties
402
+ // schema (steps/requires/ensures/publishes/etc.); the spec
403
+ // schema doesn't enforce a different shape per location.
404
+ b.recordActionStub({
405
+ componentName: specName,
406
+ ownerKind: 'service',
407
+ ownerName: ent.name,
408
+ actionName: safeName,
409
+ sourceLocation: { filePath: entityClass.filePath, lineRange: method.sourceLineRange },
410
+ candidateMethodRef: { className: entityClass.entityName, methodIndex: mi },
411
+ yamlPath: stubPath,
412
+ });
413
+ }
414
+ }
415
+ }
416
+ }
417
+ }
418
+ // ── Controllers + services from walker ────────────────────────────
419
+ // Dedupe by class name within the bucket — the walker may capture two
420
+ // classes with the same name in different source files (e.g. an
421
+ // `apps/.../FormulaEvaluator.ts` AND `shared/.../FormulaEvaluator.ts`),
422
+ // both falling into this component. Emitting twice produces a yaml
423
+ // duplicate-mapping-key error. First-wins keeps the spec valid.
424
+ //
425
+ // Plus: a class name that's ALSO an entity (already emitted in models:)
426
+ // should NOT be re-emitted as a service. The TS-interfaces walker
427
+ // surfaces classes with fields as entities; the method-walker surfaces
428
+ // those same classes for behaviour analysis. They're the SAME thing,
429
+ // and only the model emission should win in the spec.
430
+ const allEntityNames = new Set((ctx.facts.entities ?? []).map((e) => e.name));
431
+ const rawClasses = ctx.classesByComponent.get(bucketKey) ?? [];
432
+ const seenOwnerNames = new Set();
433
+ const classes = rawClasses.filter((c) => {
434
+ if (allEntityNames.has(c.entityName))
435
+ return false; // emitted as a model already
436
+ if (seenOwnerNames.has(c.entityName))
437
+ return false;
438
+ seenOwnerNames.add(c.entityName);
439
+ return true;
440
+ });
441
+ const controllers = classes.filter((c) => /Controller$/.test(c.entityName));
442
+ const services = classes.filter((c) => !/Controller$/.test(c.entityName));
443
+ if (controllers.length > 0) {
444
+ b.emit(` controllers:\n`, {
445
+ yamlPath: `components.${specName}.controllers`,
446
+ rule: 'comment-skeleton-marker',
447
+ });
448
+ for (const ctrl of controllers) {
449
+ emitOwner(b, ctx, specName, 'controller', ctrl);
450
+ }
451
+ }
452
+ if (services.length > 0) {
453
+ b.emit(` services:\n`, {
454
+ yamlPath: `components.${specName}.services`,
455
+ rule: 'comment-skeleton-marker',
456
+ });
457
+ for (const svc of services) {
458
+ emitOwner(b, ctx, specName, 'service', svc);
459
+ }
460
+ }
461
+ }
462
+ function emitOwner(b, ctx, specName, ownerKind, cls) {
463
+ // Schema: controllers use `actions:`, services use `operations:`.
464
+ const ownerSection = ownerKind === 'controller' ? 'controllers' : 'services';
465
+ const bodyKey = ownerKind === 'controller' ? 'actions' : 'operations';
466
+ b.emit(` ${cls.entityName}:\n`, {
467
+ yamlPath: `components.${specName}.${ownerSection}.${cls.entityName}`,
468
+ rule: ownerKind === 'controller' ? 'controller-from-walked-class' : 'service-from-walked-class',
469
+ sourceLocation: { filePath: cls.filePath, lineRange: [1, 1] },
470
+ });
471
+ // Controllers must declare `model: <ModelName>` — the validator
472
+ // requires a model reference per controller AND that reference must
473
+ // resolve to a declared entity (otherwise: "Controller X references
474
+ // non-existent model Y"). Resolution priority:
475
+ // 1. Strip "Controller" suffix and match detected entity by name.
476
+ // 2. Scan all method bodies for entity-name occurrences; pick the
477
+ // most-referenced (handles route-style controllers like
478
+ // AuthController whose stripped name `Auth` isn't an entity but
479
+ // whose handlers reference `User` / `Device` / etc.).
480
+ // 3. Fall back to the first entity in the same component
481
+ // (entityHome lookup) so we always emit a valid reference.
482
+ // 4. As a last resort, omit the line — the validator complains
483
+ // `missing required` but that's strictly better than referencing
484
+ // a non-existent model.
485
+ if (ownerKind === 'controller') {
486
+ const stripped = cls.entityName.replace(/Controller$/, '');
487
+ const allEntities = ctx.facts.entities ?? [];
488
+ let modelRef = null;
489
+ let resolveNote = '';
490
+ // (1) Direct stripped-name match.
491
+ const directMatch = allEntities.find((e) => e.name === stripped);
492
+ if (directMatch) {
493
+ modelRef = directMatch.name;
494
+ resolveNote = `Resolved by stripping "Controller" suffix and matching detected entity.`;
495
+ }
496
+ // (2) Scan method bodies for entity-name references.
497
+ if (!modelRef && cls.methods.length > 0) {
498
+ const bodyText = cls.methods.map((m) => m.body ?? '').join('\n');
499
+ const counts = new Map();
500
+ for (const ent of allEntities) {
501
+ const re = new RegExp(`\\b${ent.name}\\b`, 'g');
502
+ const c = (bodyText.match(re) ?? []).length;
503
+ if (c > 0)
504
+ counts.set(ent.name, c);
505
+ }
506
+ if (counts.size > 0) {
507
+ const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
508
+ modelRef = top[0];
509
+ resolveNote = `Resolved by handler-body entity-reference scan: "${top[0]}" referenced ${top[1]}× across handler bodies.`;
510
+ }
511
+ }
512
+ // (3) Fall back to the first entity assigned to this component.
513
+ if (!modelRef) {
514
+ const sameComponentEntity = [...ctx.entityHome.entries()].find(([, home]) => home === specName);
515
+ if (sameComponentEntity) {
516
+ modelRef = sameComponentEntity[0];
517
+ resolveNote = `Fallback to first entity in component ${specName}.`;
518
+ }
519
+ }
520
+ if (modelRef) {
521
+ b.emit(` model: ${modelRef}\n`, {
522
+ yamlPath: `components.${specName}.${ownerSection}.${cls.entityName}.model`,
523
+ rule: 'controller-from-walked-class',
524
+ note: resolveNote,
525
+ });
526
+ }
527
+ }
528
+ // Track which method names get emitted as cured: shorthand so we can
529
+ // skip them when emitting the actions: block below (avoiding duplication).
530
+ const curedSourceMethods = new Set();
531
+ // Controllers — emit `cured:` shorthand for CRUD-shape methods.
532
+ // Two source streams:
533
+ // (a) Audit log classified them as level-1-curved-* and dropped from
534
+ // the kept list (filter judged "realize stack handles via
535
+ // convention"). These ARE in source as explicit endpoints.
536
+ // (b) Kept methods whose names canonicalise to a CRUD op (e.g. a
537
+ // `retrieve(id)` that throws NotFound was classified as a
538
+ // business-action, but its NAME is canonical CRUD — so it IS
539
+ // the cured retrieve op, just with a custom body).
540
+ // Maps walker method names to canonical CURVED op names (list →
541
+ // retrieve_many, etc.).
542
+ if (ownerKind === 'controller') {
543
+ const curedOps = new Map();
544
+ // Stream (a) — level-1 dropped methods from audit log.
545
+ if (ctx.facts.auditDecisions) {
546
+ const auditClass = ctx.facts.auditDecisions.classes.find((c) => c.className === cls.entityName);
547
+ if (auditClass) {
548
+ for (const m of auditClass.methods) {
549
+ // `reason` is only set on dropped methods (kept=false). Express-
550
+ // routes adapter keeps every handler with no reason — skip those
551
+ // here since they're not level-1-curved drops by definition.
552
+ const reason = m.filter.reason;
553
+ if (!reason || !reason.startsWith('level-1-curved-'))
554
+ continue;
555
+ const canonical = canonicalCuredName(m.methodName);
556
+ if (!canonical)
557
+ continue;
558
+ if (!curedOps.has(canonical)) {
559
+ curedOps.set(canonical, {
560
+ sourceMethodName: m.methodName,
561
+ lineRange: m.sourceLineRange,
562
+ });
563
+ curedSourceMethods.add(m.methodName);
564
+ }
565
+ }
566
+ }
567
+ }
568
+ // Stream (b) — kept methods whose names canonicalise to CRUD.
569
+ for (const m of cls.methods) {
570
+ const canonical = canonicalCuredName(m.methodName);
571
+ if (!canonical)
572
+ continue;
573
+ // Skip if same canonical op already emitted from a (level-1) source —
574
+ // first-wins, which prefers the level-1 method (cleanest CRUD shape).
575
+ if (curedOps.has(canonical))
576
+ continue;
577
+ curedOps.set(canonical, {
578
+ sourceMethodName: m.methodName,
579
+ lineRange: m.sourceLineRange,
580
+ });
581
+ curedSourceMethods.add(m.methodName);
582
+ }
583
+ if (curedOps.size > 0) {
584
+ b.emit(` cured:\n`, {
585
+ yamlPath: `components.${specName}.${ownerSection}.${cls.entityName}.cured`,
586
+ rule: 'comment-skeleton-marker',
587
+ });
588
+ for (const [opName, src] of curedOps) {
589
+ b.emit(` ${opName}: {}\n`, {
590
+ yamlPath: `components.${specName}.${ownerSection}.${cls.entityName}.cured.${opName}`,
591
+ rule: 'cured-op-from-level1-curved-method',
592
+ sourceLocation: { filePath: cls.filePath, lineRange: src.lineRange },
593
+ note: `Source method: ${cls.entityName}.${src.sourceMethodName} (${reasonForCanonical(opName)}).`,
594
+ });
595
+ }
596
+ }
597
+ }
598
+ if (cls.methods.length === 0)
599
+ return;
600
+ // Dedupe by method name within a class — schema disallows duplicate
601
+ // keys in actions/operations. Walker may capture overloaded methods
602
+ // or two same-named files; first-wins keeps the spec valid.
603
+ // Plus: skip methods that were emitted as `cured:` shorthand above
604
+ // (only applies to controllers; services don't have cured:).
605
+ const seenMethodNames = new Set();
606
+ const dedupedMethods = [];
607
+ for (const m of cls.methods) {
608
+ if (seenMethodNames.has(m.methodName))
609
+ continue;
610
+ if (curedSourceMethods.has(m.methodName))
611
+ continue;
612
+ seenMethodNames.add(m.methodName);
613
+ dedupedMethods.push(m);
614
+ }
615
+ // Early return if all methods went to cured: — no actions: block needed.
616
+ if (dedupedMethods.length === 0)
617
+ return;
618
+ // Method names must match schema patterns (^[a-z][a-zA-Z0-9_]*$).
619
+ // Capitalised method names (e.g. "constructor", "MyMethod") get
620
+ // mangled to lowercase-first to satisfy the pattern.
621
+ const sanitiseName = (name) => {
622
+ if (/^[a-z][a-zA-Z0-9_]*$/.test(name))
623
+ return name;
624
+ return name.charAt(0).toLowerCase() + name.slice(1);
625
+ };
626
+ b.emit(` ${bodyKey}:\n`, {
627
+ yamlPath: `components.${specName}.${ownerSection}.${cls.entityName}.${bodyKey}`,
628
+ rule: 'comment-skeleton-marker',
629
+ });
630
+ // Track yaml-path duplicates across the whole emitter — even after
631
+ // per-class dedup, two classes with the same name in different files
632
+ // could produce conflicting stubs. We skip the duplicate to keep the
633
+ // skeleton valid; provenance still records the first-wins decision.
634
+ for (let mi = 0; mi < dedupedMethods.length; mi++) {
635
+ const method = dedupedMethods[mi];
636
+ const safeName = sanitiseName(method.methodName);
637
+ const stubPath = `components.${specName}.${ownerSection}.${cls.entityName}.${bodyKey}.${safeName}`;
638
+ b.emit(` ${safeName}:\n`, {
639
+ yamlPath: stubPath,
640
+ rule: 'action-stub-from-business-method',
641
+ sourceLocation: { filePath: cls.filePath, lineRange: method.sourceLineRange },
642
+ note: `${method.candidates.length} candidate-step(s); ${ownerKind === 'controller' ? 'action' : 'operation'} body filled by LLM micro-call.`,
643
+ });
644
+ b.emit(` # body filled by per-action LLM micro-call (skeleton-emitter stub)\n`, {
645
+ yamlPath: stubPath,
646
+ rule: 'action-stub-from-business-method',
647
+ });
648
+ b.recordActionStub({
649
+ componentName: specName,
650
+ ownerKind,
651
+ ownerName: cls.entityName,
652
+ actionName: safeName,
653
+ sourceLocation: { filePath: cls.filePath, lineRange: method.sourceLineRange },
654
+ candidateMethodRef: { className: cls.entityName, methodIndex: mi },
655
+ yamlPath: stubPath,
656
+ });
657
+ }
658
+ }
659
+ /**
660
+ * Map TS-flavored declared types to SpecVerse-vocabulary types where
661
+ * possible, and quote anything containing yaml-unsafe characters so the
662
+ * emitted skeleton parses cleanly.
663
+ *
664
+ * Mapping (best-effort, deliberately lossy):
665
+ * string -> String
666
+ * number -> Number
667
+ * boolean -> Boolean
668
+ * Date -> DateTime
669
+ * any | unknown -> Json
670
+ * bigint -> BigInt
671
+ * T[] / Array<T> -> Array (loses element type — acceptable v1)
672
+ * Record<X, Y> / Map<X, Y> -> Json
673
+ * string literal union ('a' | 'b') -> Enum (or quoted)
674
+ * T | null / T | undefined -> T (the optional flag captures nullability)
675
+ * Unknown identifier (like `Platform` or `User`) -> kept as-is (presumed reference)
676
+ */
677
+ function normalizeTypeForSpec(raw) {
678
+ let t = raw.trim();
679
+ // Strip trailing nullable unions — captured as optional already.
680
+ t = t.replace(/\s*\|\s*null\s*$/i, '').replace(/\s*\|\s*undefined\s*$/i, '');
681
+ // Strip nullable in any position (handles `null | string` etc).
682
+ t = t.replace(/\s*\|\s*null\b/i, '').replace(/\s*\|\s*undefined\b/i, '');
683
+ t = t.replace(/^null\s*\|\s*/i, '').replace(/^undefined\s*\|\s*/i, '');
684
+ t = t.trim();
685
+ const lower = t.toLowerCase();
686
+ if (lower === 'string')
687
+ return 'String';
688
+ if (lower === 'number')
689
+ return 'Number';
690
+ if (lower === 'boolean')
691
+ return 'Boolean';
692
+ if (lower === 'bigint')
693
+ return 'BigInt';
694
+ if (lower === 'date')
695
+ return 'DateTime';
696
+ if (lower === 'any' || lower === 'unknown')
697
+ return 'Json';
698
+ if (lower === 'object')
699
+ return 'Json';
700
+ // Array forms: T[] or Array<T>
701
+ if (/\[\]$/.test(t) || /^Array</.test(t))
702
+ return 'Array';
703
+ // Generic containers — collapse to Json.
704
+ if (/^(Record|Map|Set|Partial|Readonly|Required|Promise)\s*</.test(t))
705
+ return 'Json';
706
+ // String literal unions like 'a' | 'b' | 'c' → String
707
+ // Mixed unions with literals + types → Json (lose precision, gain validity)
708
+ if (/['"]/.test(t) || /\|/.test(t)) {
709
+ const allLiterals = /^\s*(?:['"][^'"]*['"]\s*\|\s*)*['"][^'"]*['"]\s*$/.test(t);
710
+ if (allLiterals)
711
+ return 'String';
712
+ return 'Json';
713
+ }
714
+ // Function type, intersection, complex generic — fall back to Json.
715
+ if (/[<>{}()=>&]/.test(t))
716
+ return 'Json';
717
+ // Bareword identifier (could be a referenced entity or TS enum).
718
+ // Validate: must be a valid yaml-safe single token.
719
+ if (/^[A-Za-z_$][\w$.]*$/.test(t))
720
+ return t;
721
+ // Anything else — coerce to String to keep spec valid.
722
+ return 'String';
723
+ }
724
+ /** Map a walker method name to a canonical CURVED op name. Returns null
725
+ * when the name doesn't fit a known CRUD-shape pattern (those methods
726
+ * stay under `actions:` / `operations:`, not `cured:`). */
727
+ function canonicalCuredName(methodName) {
728
+ const m = methodName.toLowerCase();
729
+ if (/^(list|findall|index|getall|search|all)$/.test(m))
730
+ return 'retrieve_many';
731
+ if (/^(retrieve|findone|findbyid|getbyid|get|getone|find|show)$/.test(m))
732
+ return 'retrieve';
733
+ if (/^(create|add|insert|new|post)$/.test(m))
734
+ return 'create';
735
+ if (/^(update|patch|edit|modify)$/.test(m))
736
+ return 'update';
737
+ if (/^(delete|remove|destroy|softdelete|archive)$/.test(m))
738
+ return 'delete';
739
+ return null;
740
+ }
741
+ /** Human-readable explanation for the `note:` field on a cured emission. */
742
+ function reasonForCanonical(canonical) {
743
+ switch (canonical) {
744
+ case 'retrieve_many': return 'list/findAll-shape method -> CURVED retrieve_many';
745
+ case 'retrieve': return 'findOne/get-shape method -> CURVED retrieve';
746
+ case 'create': return 'create/add-shape method -> CURVED create';
747
+ case 'update': return 'update/patch-shape method -> CURVED update';
748
+ case 'delete': return 'delete/remove-shape method -> CURVED delete';
749
+ default: return canonical;
750
+ }
751
+ }
752
+ //# sourceMappingURL=skeleton-emitter.js.map