@specverse/engines 6.21.2 → 6.27.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/analyse-runner.d.ts +16 -0
- package/dist/ai/analyse-runner.d.ts.map +1 -1
- package/dist/ai/analyse-runner.js +417 -53
- package/dist/ai/analyse-runner.js.map +1 -1
- package/dist/ai/microcall-orchestrator.d.ts +187 -0
- package/dist/ai/microcall-orchestrator.d.ts.map +1 -0
- package/dist/ai/microcall-orchestrator.js +673 -0
- package/dist/ai/microcall-orchestrator.js.map +1 -0
- package/dist/ai/skeleton-emitter.d.ts +94 -0
- package/dist/ai/skeleton-emitter.d.ts.map +1 -0
- package/dist/ai/skeleton-emitter.js +752 -0
- package/dist/ai/skeleton-emitter.js.map +1 -0
- package/dist/analyse-prepass/adapters/express-routes.d.ts +71 -0
- package/dist/analyse-prepass/adapters/express-routes.d.ts.map +1 -0
- package/dist/analyse-prepass/adapters/express-routes.js +329 -0
- package/dist/analyse-prepass/adapters/express-routes.js.map +1 -0
- package/dist/analyse-prepass/adapters/typescript-interfaces.d.ts +91 -0
- package/dist/analyse-prepass/adapters/typescript-interfaces.d.ts.map +1 -0
- package/dist/analyse-prepass/adapters/typescript-interfaces.js +411 -0
- package/dist/analyse-prepass/adapters/typescript-interfaces.js.map +1 -0
- package/dist/analyse-prepass/backends/gitnexus.d.ts.map +1 -1
- package/dist/analyse-prepass/backends/gitnexus.js +36 -8
- package/dist/analyse-prepass/backends/gitnexus.js.map +1 -1
- package/dist/analyse-prepass/backends/index.d.ts.map +1 -1
- package/dist/analyse-prepass/backends/index.js +3 -5
- package/dist/analyse-prepass/backends/index.js.map +1 -1
- package/dist/analyse-prepass/behavior-step-classifier.d.ts +3 -0
- package/dist/analyse-prepass/behavior-step-classifier.d.ts.map +1 -1
- package/dist/analyse-prepass/behavior-step-classifier.js +1 -0
- package/dist/analyse-prepass/behavior-step-classifier.js.map +1 -1
- package/dist/analyse-prepass/index.d.ts +69 -0
- package/dist/analyse-prepass/index.d.ts.map +1 -1
- package/dist/analyse-prepass/index.js +385 -17
- package/dist/analyse-prepass/index.js.map +1 -1
- package/dist/analyse-prepass/method-body-walker.d.ts +4 -0
- package/dist/analyse-prepass/method-body-walker.d.ts.map +1 -1
- package/dist/analyse-prepass/method-body-walker.js +14 -0
- package/dist/analyse-prepass/method-body-walker.js.map +1 -1
- package/dist/audit/realize-recorder.d.ts +164 -0
- package/dist/audit/realize-recorder.d.ts.map +1 -0
- package/dist/audit/realize-recorder.js +153 -0
- package/dist/audit/realize-recorder.js.map +1 -0
- package/dist/audit/verify-checks.d.ts +32 -0
- package/dist/audit/verify-checks.d.ts.map +1 -0
- package/dist/audit/verify-checks.js +202 -0
- package/dist/audit/verify-checks.js.map +1 -0
- package/dist/audit/verify-recorder.d.ts +84 -0
- package/dist/audit/verify-recorder.d.ts.map +1 -0
- package/dist/audit/verify-recorder.js +90 -0
- package/dist/audit/verify-recorder.js.map +1 -0
- package/dist/inference/core/specly-converter.d.ts.map +1 -1
- package/dist/inference/core/specly-converter.js +67 -36
- package/dist/inference/core/specly-converter.js.map +1 -1
- package/dist/libs/instance-factories/services/templates/_shared/step-matching.js +39 -15
- package/dist/realize/index.d.ts.map +1 -1
- package/dist/realize/index.js +63 -0
- package/dist/realize/index.js.map +1 -1
- package/libs/instance-factories/services/templates/_shared/step-matching.ts +61 -16
- 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
|